TinySegmenterを実装して分かち書きを理解する

木曜日, 6月 04, 2020

機械学習 自然言語処理

t f B! P L

分かち書きの勉強のため、TinySegmenterを再実装、再学習した。
 
ソースコード一式はこちら

分かち書き

文を単語に分けるアレ。日本語の自然言語処理では大前提となる処理。だいたいMeCabにお願いする。
 
MeCabが超優秀なので何の苦労をすることもないが、NLPerとしては動作原理をさっと説明できるようにしておきたい。
 
「そういえば分かち書きってどうやってるの??????」 などと聞かれて、さらっと答えられないと気まずい。
 
「よく理解してないで使ってるの??????」 とか 「ブラックボックス化してるんじゃないの??????」 とか言われるでしょう(想像)。
 
MeCabは条件付き確率場(CRF)を使用していて、いきなり入るには難しいので、簡単なTSからはじめます。

TSが簡単な理由は2点

  • X 形態素解析/O 分かち書き
  • X CRF/O 点推定

ようは簡単なタスクを簡単なモデルで解いているのでわかりやすい。
 
今回はTS風の分かち書き学習モデルを、scikit-learnを用いて自分で学習できるノートブックを作った。
 
使っている学習モデルや特徴量の作り方など、オリジナルと比べていくつか簡略化しているところがある。
 
データは私の愛用しているKNBCを使い(別途ダウンロードしてください)、正解率94%程度の精度が出ている。

※オリジナルのTSは別の学習データで正解率95%程度
 
ほぼ自前で実装したためオリジナルのTinySegmenterコードは使用していませんが、文字種判定ロジックなど一部のみTSのPython Portを参考にさせていただいております。詳細はレポジトリLEGAL.mdをご覧ください。

特徴量

分かち書きの点推定では、各文字ごとにその文字の前で切れるかどうかの2値分類する。とてもシンプル。
 
例えば、「今日はめっちゃいいお天気ですね」の場合
 
今|日|は|め|っ|ち|ゃ|い|い|お|天|気|で|す|ね
 
のように、文字の間ごとに切れ目を想定し、そこで切って良いか?を個別に判定する
 
なので、N文字のセンテンスであれば、その隙間の数であるN-1回だけ分類を行う。
 
目的変数は

  • 分ける: 1
  • 分けない: -1

の2つ。
 
例文の正解は
 
今日|は|めっちゃ|いい|お|天気|です|ね
 
なので、モデルの出力としては
 
-1, 1, 1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1
 
がほしいどころ。
 
予測するもの(目的変数)は分かったので、次は何をもとに予測するか(説明変数)。
 
説明変数には、切れ目の前後の文字そのものと文字種を使う。
 
文字数とはひらがな、カタカナ、漢字、数字、アルファベット、記号のこと。日本語は文字種が多いのでこれが結構分かち書きに有利だったりする。
  
漢語とカタカナが融合し始めた古代の日本語(文語)では、カタカナが実質的に分かち書きの役割を果たしていたという説も聞いたことがあり、興味深い。
 
まとめると、今回の実装では切れ目の前後3文字(6文字)の表層形と文字種を使った。これをさらにそのまま使うunigram、2文字ずつ使うbigramに加工する。
 
6文字なのでunigram(6個)、bigram(5個)であり、表層形と文字種で2倍になるので、1データあたりの特徴量の数は22個。
 
オリジナルでは、これに加えtrigramと、切れ目の前文字の予測結果も特徴量に使っていたが、今回は省略した。

モデル

コーパスから、これら特徴量の重みを学習する。
 
今回は分ける=1にしているので、分けたほうがいい特徴量にはプラスの重み、分けないほうがいい特徴量にはマイナスの重みがつく。
 
カテゴリカルデータなのでsklearnのメソッドでOnehotベクトルに変換。Xが説明変数の行列です。

import sklearn.preprocessing as sp
enc = sp.OneHotEncoder(sparse=True, handle_unknown="ignore")
X = enc.fit_transform(X)

引数sparse=Trueがめっちゃ重要。これがないとメモリバカ食いで学習がとても遅くなる。また、hande_unknown="ignore"にすることで学習データにない文字が入力されてもエラーにならないようにする。

print(X.shape) # (119040, 132798)

1つ目がデータ数、2つ目が列数(特徴量の異なり数)。
 
学習はsklearnのSGDClassifierを使う。

from sklearn.linear_model import SGDClassifier
from sklearn.model_selection import train_test_split

train_x, test_x, train_y, test_y = train_test_split(X, y)
clf = SGDClassifier(penalty="l1", verbose=10)
clf.fit(train_x, train_y)

SGDClassifierは、ロス関数が豊富、勾配法で重みを求めてくれる、正則化もある、というイカした分類器の実装。
 
TS同様にL1正則化を使い、影響の小さい特徴量の重みを積極的に0にする。重みが0の特徴量は、その特徴量の値がいくつであっても分類に影響を与えないので、使う必要がなくなる。

clf.score(test_x, test_y)

だいたい94%くらいの精度になるはず。
※パラメータサーチや交差検証はしていません。
 
最後に、使える重みを抽出する。

cond = np.abs(clf.coef_[0])!=0.0
print("素性数合計:", len(clf.coef_[0]))
print("重みゼロの素性数:", len(clf.coef_[0])-sum(cond))
print("重み有りの素性数:", sum(cond),)
print("重み有りの割合:", 100*sum(cond)/len(clf.coef_[0]))

ゼロでない重みの割合が1%以下になっていると思います。
 
重みが0ということは、その特徴量があってもなくても、分類のスコアに影響しないということなので、約99%以上の特徴量は不要ということなる。
 
これをPythonの辞書形式に変換して、TinySegmenterモドキのコンストラクタに貼っつけたら終了。
 
ここからは通常のプログラミングの手順としてはイレギュラーなやり方なので、ノートブックを見てください。

# 各素性の重みの値を取得する
cond = np.abs(clf.coef_[0])!=0.0
name = enc.get_feature_names(mts._feature_columns)[cond]
score = clf.coef_[0][cond]

weights = {n: {} for n in mts._feature_columns}
weights["BIAS"] = {"0": int(clf.intercept_[0] * 1000)}

for n,s in zip(name, score):
  k1, k2 = n.split("_", maxsplit=1)
  v = int(s*1000)
  weights[k1][k2] = v

何をやっているかいうと、特徴量の名前と値をキーにして、0でない特徴量の重みを辞書(変数weights)に格納している。
 
重みは基本的に小数になっているはずなので、見やすさのため1000倍して整数に。
 
※TSモドキの中に定義してある特徴量名を使っているので、コードのどこかでTSモドキをmtsという変数名でインスタンス化してください。
 
ここからは、このweightsの値をPythonのdict風のフォーマットでprintし、その出力結果をMyTinySegmenterクラスの変数weightsにソースコード上で貼り付ける、という手作業が必要になります。
 
この作業が必要なのは、自分で新規に重みを学習させた後の1回だけ。
 
実際に分かち書きする処理は、MyTinySegmentertokenize()メソッドに書かれている。
 
各切れ目ごとに、特徴量のスコアを合計していき、合計スコアが0より大きければ分ける、0以下なら分けない、という処理です。

まとめ

日本語の文字種が多様であることが、分かち書きの精度に寄与しているように思えます。
 
文字種が切り替わる箇所では、送り仮名(漢字+ひらがな)や固有名詞のケースを除き、そこがほぼ単語境界とみなせるため、文字種情報は非常に有益な情報と言えるでしょう。
 
逆に、直接的に文字から学習する必要があるのは、先ほど述べた送り仮名や固有名詞、複合名詞の分割などに限定されるため、結果としてL1正則化を用いて効率よく特徴量を圧縮できたと考えられます。
 
最後にいつもの白々しい文で確認してみましょう。

print(" | ".join(mts.tokenize("こんな綺麗な羊毛は今まで見たことがないよ!")))
# こんな | 綺麗な | 羊毛 | は | 今 | まで | 見た | こと | が | ない | よ | !

ちゃんと分かち書きできているようです。

@youmounlp

QooQ