日本語自然言語処理で必須の前処理まとめ(Dockerによる環境構築込み)

## この記事でやること
日本語文章で自然言語処理的なことをやる際に, アルゴリズム選択やパラメータチューニングと同等かそれ以上に重要となる前処理。丁寧に色々やりたいとは言え毎回ゼロから同じようなスクリプトを書くのも面倒なので, 毎回必ずやるであろう

- 全角・半角の統一と重ね表現(!!とかーーとか)の除去
- HTMLタグの除去
- 絵文字の除去
- URLの除去
- 記号の除去
- 数字の表記統一
- 分かち書き
- 見出し語化

あたりの処理を一発で済ませるために用意した自分なりの秘伝のタレを公開します。
ついでにMeCabの導入やJupyter lab環境の構築もDocker化できたので合わせて載せてます。

## 環境構築編
まずは以下のDockerfileを用意します。gensimやspaCyなど今回の前処理には使わないパッケージも入ってますがお気になさらず。
(2020/1/12追記) mecab-ipadic-neologdのインストールで-aオプションを付けないと一部の辞書しかインストールされないのでオプションを追加
(2020/1/13追記) プロセス並列で実行するためのメソッドを追加

```dockerfile
FROM jupyter/minimal-notebook

USER root

## Mecab関連インストール
## mecabrc is installed in /etc/mecabrc
## default dictionary path is /var/lib/mecab/dic/debian
## mecab-ipadic-utf-8 is installed in /var/lib/mecab/dic/ipadic-utf8
## mecab-ipadic-neologd is installed in /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd
RUN apt-get update && \
    apt-get install -y curl && \
    apt-get install -y file && \
    apt-get install -y mecab && \
    apt-get install -y libmecab-dev && \
    apt-get install -y mecab-ipadic-utf8 && \
    git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git && \
    cd mecab-ipadic-neologd && \
    bin/install-mecab-ipadic-neologd -n -y -a && \
    rm -rf /home/jovyan/mecab-ipadic-neologd

## Pythonパッケージインストール
USER $NB_UID
RUN conda install --quiet --yes \
    'conda-forge::blas=*=openblas' \
    'ipywidgets=7.5*' \
    'numpy=1.17*' \
    'pandas=0.25*' \
    'matplotlib=3.1*' \
    'seaborn=0.9*' \
    'sqlalchemy=1.3*' \
    'beautifulsoup4=4.7.*' \
    'scikit-learn=0.22*' \
    'tensorflow=1.13*' \
    'keras=2.2*' && \
    conda clean --all -f -y && \
    pip install spacy==2.2.3 \
    'https://github.com/megagonlabs/ginza/releases/download/latest/ginza-latest.tar.gz' \
    japanize-matplotlib==1.0.5 \
    mecab-python3==0.996.3 \
    neologdn==0.4 \
    emoji==0.5.4 \
    gensim==3.8.1 \
    pipetools==0.3.5 && \
    jupyter nbextension enable --py widgetsnbextension --sys-prefix && \
    jupyter labextension install jupyterlab_vim && \
    jupyter labextension install @jupyterlab/toc && \
    jupyter labextension install @jupyter-widgets/jupyterlab-manager@^1.0.0 && \
    rm -rf $CONDA_DIR/share/jupyter/lab/staging && \
    rm -rf /home/$NB_USER/.cache/yarn && \
    rm -rf /home/$NB_USER/.node-gyp && \
    MPLBACKEND=Agg python -c "import matplotlib.pyplot" && \
    fix-permissions /home/$NB_USER

WORKDIR /home/jovyan/work
```

Dockerfileが用意できたら次のコマンドでイメージをビルド・起動してJupyter Labを立ち上げます。

```bash

$ docker build -t my-nlp-notebook:1.0 .
$ docker run --rm -p 8888:8888 -e JUPYTER_ENABLE_LAB=yes -e TZ=Asia/Tokyo -v `pwd`:/home/jovyan/work my-nlp-notebook:1.0
```

## 前処理用テンプレコード
Jupyterに以下のコードを貼っつけます。
(2020/1/12追記) mecabの辞書としてmecab-ipadic-neologdを使う設定が抜けていたので修正。

```python

import MeCab
import neologdn
import emoji
import re
from bs4 import BeautifulSoup
from pipetools import pipe
from multiprocessing import Pool

class Preprocessor:
    CATEGORY_INDEX = 0   # node.feature中で品詞が格納されているindex
    ROOT_FORM_INDEX = 6  # 単語の原型が格納されているindex
    TAGGER = MeCab.Tagger("-d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd")

    def __init__(self, targets = ["名詞", "動詞", "形容詞", "副詞", "連体詞"]):
        self.target_categories = set(targets)
        self.url_pattern = re.compile(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-]+')
        self.half_width_symbol_pattern = re.compile(r'[!-/:-@[-`{-~]')
        self.full_width_symbol_pattern = re.compile(u'[■-♯]')
        self.number_pattern = re.compile(r'\d+')

    def pipe_all(self, texts):
        '''
        全前処理工程を行う 
        1. 全角・半角の統一と重ね表現の除去
        2. HTMLタグの除去
        3. 絵文字の除去
        4. URLの除去
        5. 記号の除去
        6. 数字の表記統一
        7. 分かち書きと見出し語化
        '''
        
        result = texts > (pipe
            | (lambda x: self._loop(x, self._normalize_text))
            | (lambda x: self._loop(x, self._remove_html_tag))
            | (lambda x: self._loop(x, self._remove_emoji))
            | (lambda x: self._loop(x, self._remove_url))
            | (lambda x: self._loop(x, self._remove_symbol))
            | (lambda x: self._loop(x, self._convert_number_to_zero))
            | (lambda x: self._loop(x, self._divide_text))
        )
        
        return result

    def parallel_pipe_all(self, texts, num_process = 2):
        '''pipe_allをプロセス並列で実行する'''
        
        with Pool(processes = num_process) as p:
            result = p.map(func = self._normalize_text, iterable = texts)
            result = p.map(func = self._remove_html_tag, iterable = result)
            result = p.map(func = self._remove_emoji, iterable = result)
            result = p.map(func = self._remove_url, iterable = result)
            result = p.map(func = self._remove_symbol, iterable = result)
            result = p.map(func = self._convert_number_to_zero, iterable = result)
            result = p.map(func = self._divide_text, iterable = result)
        
        return result    

    def _loop(self, texts, func):
        '''テキストのリストに対する処理を高速化するため内包表記をかます'''

        return [func(text) for text in texts]
        
    def _normalize_text(self, text):
        '''全角/半角の統一と重ね表現の除去'''

        return neologdn.normalize(text)
    
    def _remove_html_tag(self, text):
        '''HTMLタグを含むテキストから文字列のみを取り出す'''

        return BeautifulSoup(text).get_text() 
    
    def _remove_emoji(self, text):
        '''絵文字を空文字に置換する'''

        return ''.join(['' if char in emoji.UNICODE_EMOJI else char for char in text])
    
    def _remove_url(self, text):
        '''URLを空文字に置換する'''

        return self.url_pattern.sub('', text)
    
    def _remove_symbol(self, text):
        '''記号をスペースに置換する(意味のある記号も存在するため)'''

        # 半角記号の除去
        text_without_half_width_symbol = self.half_width_symbol_pattern.sub(' ', text)

        # 全角記号の置換 (ここでは0x25A0 - 0x266Fのブロックのみを除去)
        text_without_full_width_symbol = self.full_width_symbol_pattern.sub(' ', text_without_half_width_symbol)

        return text_without_full_width_symbol
    
    def _convert_number_to_zero(self, text):
        '''数字を全て0に置換する'''

        return self.number_pattern.sub('0', text)
    
    def _divide_text(self, text):
        '''分かち書きとMeCab辞書による見出し語化'''
        
        words = []
        node = self.TAGGER.parseToNode(text)

        while node:
            features = node.feature.split(',')

            if features[self.CATEGORY_INDEX] in self.target_categories:
                # 原型がMeCabの辞書に存在しない場合には単語の表層を格納する
                if features[self.ROOT_FORM_INDEX] == "*":
                    words.append(node.surface)
                # 辞書に載っている単語については原型に直して格納する
                else:
                    words.append(features[self.ROOT_FORM_INDEX])

            node = node.next
            
        return words
```

## 使い方
pipe_allメソッドに文章のリストを渡せば全処理をシリアル実行してくれます。
マルチコア環境の場合はparallel_pipe_allの方を使うと並列実行されます。
全部の処理が効くように敢えて変な文章を相手に前処理してみたのが以下。

```python
text = """
今週は男２人でカフェ開拓してきたよv(^^)v
僕が頼んだのは濃厚なかぼちゃのﾀﾙﾄ。
うめーーーーー!!
超Ｄｅｌｉｃｉｏｕｓで接客もGoodでした😀
これで1,430.52円は安い!☆
お店のリンク: http://hogehoge.navi/fuga_cafe
#週末グルメ
<h1>タイトル<p>路地裏のカフェ</p></h1>
"""

processor = Preprocessor()
print(processor.pipe_all([text]))

=> [['今週', '男', '0', '人', 'カフェ', '開拓', 'する', 'くる', 'v', 'v', '僕', '頼む', 'の', '濃厚', 'かぼちゃ', 'タルト', 'うめー', 'Delicious', '接客', 'Good', 'これ', '0', '0', '0円', '安い', '店', 'リンク', '週末', 'グルメ', 'タイトル', '路地', '裏', 'カフェ']]
```

v(^^)v ⇐こやつの両手が残っちゃってますね:droplet:
まあこれはいずれ何とかします(え)。

## 実行時間を測ってみる
手元にちょうどいいデータセットがなかったので, ひとまずさっきと同じテキストを1万件前処理した際の実行時間を見てみました。

**lenovo ideapad 330S Intel® Core™ i5 8コア**
**Ubuntu 18.04 LTS**

の環境で以下を実行。

```python
[1] texts = [text for _ in range(10000)]

[2] %%timeit
     precessor.pipe_all(texts)
4.08 s ± 7.84 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
```

約4秒。各処理ごとの実行時間の内訳を見てみると...
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227776/5e716b42-3d7f-f393-4372-eb3a2c70ccdf.png" width=500>

正規化, HTMLタグの除去, 分かち書き辺りで時間を食っているようですね。

次に並列実行してみます。と言っても呼び出すメソッド名を変えるだけですが。
どのくらい高速化されたかをわかりやすくするためデータ件数を10万に増やした上でシングルコア計算の場合と比較したものが次のグラフです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/227776/d30189da-caf4-c0d5-e376-3d87ef62a09d.png" width=600>

8コア並列までやるとシングルコアの場合の4倍速くらいになっていますね!