ベイズを全くわからない人がベイジアンフィルタを利用して、投稿データから投稿者を推定してみる

この記事は [Go2 AdventCalendar](https://qiita.com/advent-calendar/2018/go2) の1日目の記事です。

ガチガチのGoネタは Go AdventCalendar の方が書いてくれると思いますので、
今回は弊社が利用している(作っている？) [Unipos](https://unipos.me/ja/) というサービスのデータをGoから利用して遊んでみようと思います。

Uniposとはご存知の方もいると思いますが、感謝の言葉とポイントを送り合うサービスで、感謝のコメントが社員から沢山投稿されています。
弊社の場合には比較的長めの投稿も多いため、今回は 投稿した内容（文章）から投稿者をベイズフィルタを利用して当てることができるだろうか？というのを試してみます。

ちなみに、記事の中にはGoのコードは殆ど出てきません・・・
あと、ベイズ方面は全く詳しくありません。調べながら書いてるので内容的にアレな部分は、 `なめらかなマサカリ(コメント)` でいただけるとありがたいです。

## 事前準備

- goの実行環境
- Uniposの投稿データ（画面をコツコツスクレイピングする or ユーザスクリプトなどで集めます）
  - Fringe81の直近の投稿データ
    - 学習用に8090件、確認用に1857件に分ける

## Goでベイジアンフィルタをどう実装するか

ベイズの部分は実装できそうな雰囲気もあったのですが、実装を誤っていてもこの分野に明るくないので良い/悪いの判断ができそうにないので、 https://github.com/jbrukh/bayesian を利用しました。

こんな感じに実装することで実際に試せます

```go
const (
	Good Class = "Good"
	Bad  Class = "Bad"
)

func Test_SimpleClassification(t *testing.T) {

	classifier := NewClassifier(Good, Bad)

	goodStuff := []string{"tall", "rich", "handsome"}
	badStuff := []string{"poor", "smelly", "ugly"}

	classifier.Learn(goodStuff, Good)
	classifier.Learn(badStuff, Bad)

	scores, likely, _ := classifier.LogScores([]string{"tall", "girl"})

	t.Log(scores) // [-27.12019549216256 -51.350019226428955]
	t.Log(likely) // 0

	probs, likely, _ := classifier.ProbScores([]string{"tall", "girl"})

	t.Log(probs) // [0.99999999997 2.99999999991e-11]
	t.Log(likely) // [0.99999999997 2.99999999991e-11]
}
```

上の実装例を見てわかるように、Learn（学習）させる場合には、トークン（文章を単語単位に分けたもの）で渡す必要があります。
英語の場合には基本的にスペースで区切られれているので単語分割が容易なのですが、日本語は簡単にはできません。

## Goで日本語の分かち書きをする

日本語の文章の場合、先程も書いたように文章の中から単語を切り出すのは簡単ではなく、
形態素解析などを利用して分割するのが一般的なようです。

```wikipediaより
形態素解析（けいたいそかいせき、Morphological Analysis）とは、文法的な情報の注記の無い自然言語のテキストデータ（文）から、対象言語の文法や、辞書と呼ばれる単語の品詞等の情報にもとづき、形態素（Morpheme, おおまかにいえば、言語で意味を持つ最小単位）の列に分割し、それぞれの形態素の品詞等を判別する作業である。
```

MeCabなどが有名ですが、Goではikawahaさんの https://github.com/ikawaha/kagome という実装がありますので、それを利用します。

コード的には以下のような形で利用でき、かなり高速です。（私感

```go
func Test_日本語分かち書き(t *testing.T) {

	to := tokenizer.New()
	tokens := to.Tokenize("最高の寿司体験")

	for _, token := range tokens {
		if token.Class == tokenizer.DUMMY {
			fmt.Printf("%s\n", token.Surface)
			continue
		}
		features := strings.Join(token.Features(), ",")
		fmt.Printf("%s\t%v\n", token.Surface, features)

    // BOS
    // 最高	名詞,一般,*,*,*,*,最高,サイコウ,サイコー
    // の	助詞,連体化,*,*,*,*,の,ノ,ノ
    // 寿司	名詞,一般,*,*,*,*,寿司,スシ,スシ
    // 体験	名詞,サ変接続,*,*,*,*,体験,タイケン,タイケン
    // EOS
	}
}
```

## 実際に動かしてみる

実装コードなどは省いてしまいましたが、完成したコードを動かしてみます。

前提にも書きましたが、学習用に8090件、確認用に1857件のデータを利用します。

### チューニングなし（ベース）

正解 : 446件
不正解 : 1411件
正解率 : 24.0172%

### 学習データの文章が著しく短いものを省く

ただ単にカンですが、学習用のデータが一言ぐらいしかない場合のデータは良くないかな？ということで短めのものを省きます。
これもカンですが、twitter程度の長さ以下のものは省いてみます。

#### 条件 : 140文字以下の学習データを除外する

正解 : 473件
不正解 : 1384件
正解率 : 25.4712%

ちょっと正解率が上がりました。
調子に乗ってもう少し除外条件を長くしてみます

#### 条件 : 200文字以下の学習データを除外する

正解 : 481件
不正解 : 1376件
正解率 : 25.9020%

さらに正解率が上がりました。
もう少し長いほうが良いのでしょうか？長くしてみます。

### 条件 : 400文字以下の学習データを除外する

正解 : 423件
不正解 : 1434件
正解率 : 22.7787%

下がってしまいました・・・
おそらく学習対象データがトータルで 3186 件まで減ってしまったというのも問題かもしれません。

ちなみに、300件も試しましたが、 24.6634% ぐらいになってしまうので200文字付近が適切そうです。

### 学習データの一部の品詞を除外してみる

一般的にベイジアンフィルタをやる場合には `名詞` を抽出するとどこかで見かけました[要出典]ので、
品詞を抽出して試してみます

#### 条件 : 名詞のみ学習データに含む

学習データは200文字以下除外

正解 : 320件
不正解 : 1537件
正解率 : 17.2321%

残念下がってしまいました・・・
とはいえめげずに別のパラメータを試してみます。

#### 条件 : 助詞を省く

なんか `助詞` とかいらないっしょ、みたいなカジュアルなカンです。
やってみます。

学習データは200文字以下除外

正解 : 376件
不正解 : 1481件
正解率 : 20.2477%

だめ、全然だめ。

#### 条件 : 記号を省く

ほぼさっきのノリです。記号いらないっしょ

学習データは200文字以下除外

正解 : 447件
不正解 : 1410件
正解率 : 24.0711%

### 最終的には

雑にパラメータをいろいろいじってみましたが、最終的には

条件 : 学習データは180文字以下を省く

という条件で

正解 : 487件
不正解 : 1370件
正解率 : 26.2251%

という感じが一番良さそうです。
ちなみにユニークなユーザ数（クラス数）は167です。

## つまり

素人がわからないながらに適当に作っても四分の一（26%）ぐらいの確率で、
文章から誰が投稿したかと言うのを当てることができるようです。

この値が良いのか悪いのかはちょっとわかりませんが、
**今日は子供の誕生日なのに準備も手伝わずに記事を書いていたら家庭が変な空気になりつつあるのでまとめますw**

## まとめ

- わからない分野なのでほんと良くわからない。悔しい、、もう少し勉強しよう。
- データ量が結構あってもgoは速い。優秀
- **家庭は大事にしよう**