ナイーブベイズでアダルトワードをはじいてみた

# はじめに
はじめまして。オークファンに新卒で入社しました[@aucfan-kumasaka](https://qiita.com/aucfan-kumasaka)です。

業務で自然言語処理のタスクに取り組んだので、そこで学習したこと、やったことを書きたいと思います。

具体的には、
- 目の前の商品名が不適切（＝アダルトなど）かどうか判定したい

というタスクです。今回はscikit-learnのナイーブベイズ分類器を使用してこの問題を解きました。

# 勉強したこと
## モデルについて
自然言語処理のモデルは様々ありますが、今回は手早く実装でき、学習も容易なことからナイーブベイズを選択しました。

ナイーブベイズ分類器はベイズの定理をもとにしています。

例として、商品名に`DVD`という単語が含まれているときに、この商品名が不適切である確率を知りたいとします。

$P(\mathrm{adult}|DVD)$：`DVD`という単語を含む商品名が不適切である確率。

この確率が高ければその商品名を不適切だと判断できますが、直ちにこの確率を知ることはできません。しかし、次の確率であれば、手元の適切、不適切な商品名のデータセットを集計して求めることができます。

- $P(\mathrm{adult})$：商品名が不適切である確率。
- $P(DVD|\mathrm{adult})$：不適切な商品名が`DVD`という単語を含む確率。
- $P(DVD)$：商品名に`DVD`という単語を含む確率。

そして、ベイズの定理によると、求めたい確率$P(\mathrm{adult}|DVD)$とこれらの間には次のような関係があることがいえます。
```math
P(\mathrm{adult}|DVD)=\cfrac{P(\mathrm{adult})P(DVD|\mathrm{adult})}{P(DVD)}
```
例えば用意した1000件ずつのデータで、次のような結果が得られたとします。

||`DVD`を含む件数|`DVD`を含まない件数|
|-|-|-|
|適切なデータ群|93|907|
|不適切なデータ群|134|866|

すると上記の確率はそれぞれ

- $P(\mathrm{adult})=\cfrac{1000}{2000}=\cfrac{1}{2}$
- $P(DVD|\mathrm{adult})=\cfrac{134}{1000}$
- $P(DVD)=\cfrac{93+134}{2000}=\cfrac{227}{2000}$
- $P(\mathrm{adult}|DVD)=\cfrac{\cfrac{1}{2}\times\cfrac{134}{1000}}{\cfrac{227}{2000}}=\cfrac{134}{227}=0.59...$

こうして、`DVD`という単語が入っている商品名が不適切かどうかを判断できるようになりました。この例では不適切なデータ群に比較的多く含まれていたことでこのような結果になりました。

もちろんこれ以外の単語が入ってくる可能性もあるので、上記の理論を複数の単語に一般化した多項分布モデル(Multinomial Naive Bayes)を使用します。数式の実装はすべてライブラリにお任せですので、式を追いかけるのはこのへんで...

# やったこと
## 環境構築
まず、前処理に使用する形態素解析器のMeCabをダウンロードします。私はWindowsのマシンを使用しているため、
- https://github.com/ikegami-yukino/mecab/releases

から`mecab-64-0.996.2.exe`をダウンロードしてインストールします。パスを通して以下のコマンドが確認出来たらインストール完了です。
```c
> mecab -v
mecab of 0.996
```

## 使用データ
オークファンではアダルト・非アダルトの商品データを大量に保有していますので、学習にはこれを使用しました。データがあるって最高ですね！これが無かったら、アダルトデータを求めてインターネットの海をさまようところでした。

今回は以下のような形で商品名をcsvファイルに書き出してありましたので、こちらを読み込んでいきます。データはそれぞれ5万件用意しました。(データ数をこれより大きくしたり小さくしても、さほど精度は変わりませんでした。)

```csv
,Title
1,商品名1
2,商品名2
...,...
```

```python
import pandas as pd

df_normal = pd.read_csv('normal_title.csv', encoding='utf-8')
df_adult = pd.read_csv('adult_title.csv', encoding='utf-8')
```

## 前処理
商品名そのままでは学習器に入れられないので、次のような前処理を行います。
- 正規化
  - ユニコード正規化
  - 記号除去
  - 数字の除去
- 形態素解析

### 正規化
商品名を単語に分割する前に、正規化によって表記を整えておきます。

- Unicode正規化
`ア`と`ｱ`のような、同じ文字として処理したいが、違う文字種として登録されているものを統一します。
- 記号除去
商品名には記号が多く含まれているので、事前に取り除いておきます。今回はシンプルに、ASCIIの記号を取り除いておきます。
- 数字の除去
個々の具体的な数値は不適切かどうかに影響ないと考えられるので、すべて除去しておきます。

これらの前処理を行う関数を作っておき、`apply`関数で各商品名に適用していきます。

```python
import unicodedata
import re

def preprocess(title):
    title = unicodedata.normalize('NFKC', title)
    code_regex = re.compile('[\u0020-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u007E\u3001-\u303F\u30FB]+')
    title = code_regex.sub(' ', title)
    num_regex = re.compile(r'[\d.,]+')
    return num_regex.sub(' ', title)

df_normal['Title'] = df_normal['Title'].apply(preprocess)
df_adult['Title'] = df_adult['Title'].apply(preprocess)
```

### 形態素解析
形態素解析は文章を単語に分割する作業で、先ほどインストールしたMeCabによって行います。使用する辞書の設定を行うこともできますが、今回はデフォルトのIPA辞書を使用します。

MeCabで`すもももももももものうち`を形態素解析すると次のような結果が得られます。

```
すもも  名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も      助詞,係助詞,*,*,*,*,も,モ,モ
もも    名詞,一般,*,*,*,*,もも,モモ,モモ
も      助詞,係助詞,*,*,*,*,も,モ,モ
もも    名詞,一般,*,*,*,*,もも,モモ,モモ
の      助詞,連体化,*,*,*,*,の,ノ,ノ
うち    名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS
```

末尾に終了を示す文字列がついているのでこれを取り除き、タブ文字とカンマで区切られた分析結果を分解します。先頭から8番目の要素が原形を表すので、これを取得します。このとき、今回のユースケースでは名詞の判断ができればいいということで、名詞だけを抽出しています。

最終的に各商品名は、名詞が半角スペースで結合された文字列になります。

```python
import MeCab

tagger = MeCab.Tagger()

def tokenize(title):
    node = tagger.parse(title)[:-4]
    result = [re.split('[,\t]', x) for x in node.splitlines()]
    result = [word[7] for word in result if word[1] == '名詞']
    return ' '.join(result)

df['Title'] = df['Title'].apply(tokenize)
```

## 学習と評価
形態素解析ができたら、データをモデルに与えられるような形式に変換します。

テキストデータをそのまま学習器に与えることはできないので、何かしらの数値に変換する必要がありますが、ここでは`CountVectorizer`を使用します。これは、テキストを単語の種類の長さを持つベクトルに変換するもので、単語が含まれていたら１，含まれなければ０を要素に持つようにします。例えば、全テキストが以下の３つであれば、
- The dog is cute.
- Dog is animal.
- I like the dog and the cat.

以下のようなベクトルが生成されます。
||the|dog|is|cute|animal|I|like|and|cat|
|-|-|-|-|-|-|-|-|-|-|
|第１文|1|1|1|1|0|0|0|0|0|
|第２文|0|1|1|0|1|0|0|0|0|
|第３文|1|1|0|0|0|1|1|1|1|

`fit_transform(文章の配列)`でこのようなベクトルが生成されるほか、以後同じルールに従ってベクトル化を行いたい場合は、このfitさせた`vectorizer`を使用できます。

また、学習や評価のためラベルを作成しておきます。通常のデータは`0`、アダルトのデータは`1`というラベルをつけておきます。

```python
from sklearn.feature_extraction.text import CountVectorizer

sentences = df_normal['Title'].values.tolist() + df_adult['Title'].values.tolist()
vectorizer = CountVectorizer(binary=True)
vectors = vectorizer.fit_transform(sentences)
labels = [0] * len(df_normal) + [1] * len(df_adult)
```

データが作成できたら、これを学習用と検証用に分割し、学習器に突っ込むだけ！

`fit()`でモデルが学習され、`predict()`で入力データに対する予測が返却されます。

```python
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import MultinomialNB
from sklearn.metrics import confusion_matrix, classification_report

x_train, x_test, y_train, y_test = train_test_split(vectors, labels, test_size=0.3)
mnb = MultinomialNB()
y_pred = mnb.fit(x_train, y_train).predict(x_test)
print(confusion_matrix(y_test, y_pred))
print(classification_report(y_test, y_pred))
```
```
[[13554  1538]
 [  955 13953]]
              precision    recall  f1-score   support

           0       0.93      0.90      0.92     15092
           1       0.90      0.94      0.92     14908

    accuracy                           0.92     30000
   macro avg       0.92      0.92      0.92     30000
weighted avg       0.92      0.92      0.92     30000
```
confusion matrix(混同行列)は、予測値が実際の答えとどれだけ合っていたかを確認できます。今回のケースだと、各成分は以下のような意味になります。
|実際\予測|通常と予測|不適切と予測|
|-|-|-|
|通常|通常と正しく判断|不適切と誤判断|
|不適切|通常と誤判断|不適切と正しく判断|

今回の結果は通常のタイトルを誤って不適切と判断するケースが多かったようです。誤判断は一定数紛れてしまいトレードオフの関係なので、どちらを許容してどちらを厳しく見るかはユースケースと相談ですね。ここでは単に`predict`で予測していますが、`predict_proba`を使用すると確率が出力されますので、しきい値を適当に設定すればそういったことが制御できます。例えば上の`DVD`のような例を許容するかどうかなどはここで調節できるでしょう。

また、classification reportから適合率(precision)、再現率(recall)等を確認できます。例えばラベル0(通常)の行を見てみると、
- precisionが0.93なので、通常と予測したもののうち93%は本当に通常だった
- recallが0.90なので、本当は通常のもののうち90%を通常と予測した

ということが分かります。さらに、精度(accuracy)は92%だったことも確認できます。

なお、モデルの保存とロードは以下のようにして行うことができます。Vectorizerについても同様です。

```python
import pickle

with open(model_path, 'wb') as f:
    pickle.dump(mnb, f)

with open(model_path, 'rb') as f:
    mnb = pickle.load(f)
```

## 今後の課題
今回は「商品名が不適切かどうか」というタスクでしたが、実際に弾きたい単語の多くはアダルト関連だったため、このようなデータセットを用意して「アダルト商品名にばかり含まれる単語は弾く」ような学習器を作成しました。

そのため、評価の節でも触れたように、本当は弾きたいんだけど弾かれなかった単語や、スルーしてほしいのに弾いてしまった単語などが存在すると考えられます。（データセット上は不適切だけどユースケース上はスルーしてよい、といったケースも存在しうるので、そことも切り分ける必要があります。）

例えば、今回の学習器では`ドラクエ`という単語が不適切だとして弾かれてしまいました。これはアダルト側の学習データに多く含まれていた（おそらく同人誌など）ためでしょう。応急処置としてはデータセットを調節したり、辞書を作成したりといった対策が考えられます。また、`AV DVD`（弾かれてほしい）も`AV TV`（スルーしてほしい）も同様に弾かれてしまいました。このような文脈も考慮したいとなると、より高級な学習器を検討した方がいい気がします。

# 感じたこと
今回使用したナイーブベイズは非常にシンプルで学習は一瞬で終わるのに、精度はそこそこ出るというかなりコスパのいい手段だと感じました。業務では精度だけでなく時間とも相談しなければいけないことがほとんどだと思いますので、いろいろな手法を学んでおかないといけないですね。

また今回は、モデルや学習の理論だけでなく、そもそも何故そのようなことをしたいかという要件なども考えながらの実装でした。ついつい頭でっかちになって理屈の方ばかり考えてしまいますが、一歩引いてこれでいいのか考えられるようにしたいです。