わかち書きしてるとこのソースコードを軽く読んだので、次はtrainする方を読んでみる。
下記のようなコマンドを発した場合に関係する処理だけ追う。
$ ./train-kytea -full corpus_file -model sample.mod
とりあえずスタート地点のtrain-kytea.cppのmainから読み始めてみる。
ここでやってることは、引数を読み込んで、Kytea::trainAll()を呼び出しているだけ。
trainを実行しているところ。
最初に設定のチェックをしたり、feat(再学習用のファイル)の読み込みを行なったりしてる。feat関連の処理は-feat指定しないと通らないので、今回は無視する。
なので変数fio_(FeatureIO)に対して行なっている処理は基本無視。
あと、global指定もしないので、globalTagあたりの記述も無視。
そうするとKytea::trainAllで気になるところは、以下の記述たち。
buildVocabulary(); trainWS(); trainLocalTags(i); trainUnk(i); writeModel(config_->getModelFile().c_str());
呼び出されているこの5つの関数を追いかければ、なんとなくtrainの仕組みが見えてきそうな感じ。
今回はタグ(trainLocalTags)と未知語(trainUnk)については飛ばして、buildVocablulary、trainWS、writeModelの3つを見てみる。
buildVocabularyは手っ取り早く言えば辞書を作っている。
コーパスやらfeatやらの入力された値から、変数dict_(Dictionary<ModelTagEntry>)に単語を詰めて回る。
例えば以下のような2語だけ含んだコーパスから辞書を作ったとします。
もう/副詞/もう 寝る/動詞/ねる
こうすると、dic_の中には「もう」と「寝る」の2語が格納されます。
std::vector<ModelTagEntry *> tagEntries = dict_->getEntries(); for (int i = 0; i < tagEntries.size(); i++) { std::cout << util_->showString(tagEntries[i]->word) << std::endl; } //=> もう //=> 寝る
この時点ではtagの情報は入ってなく、dict_->getEntries()->tagModsの中にはnullが入っている。
もう1つ、単語だけでなく、DictionaryStateという情報も入れられている。
この中身を覗いてみる。
std::vector<DictionaryState*> states = dict_->getStates(); for (int i = 0; i < states.size(); i++) { std::cout << "i = " << i << std::endl; std::cout << "branch : " << states[i]->isBranch << std::endl; std::vector<std::pair<KyteaChar, unsigned> > gotos = states[i]->gotos; for (int j = 0; j < gotos.size(); j++) std::cout << "goto : " << util_->showChar(gotos[j].first) << ", " << gotos[j].second << std::endl; std::vector<unsigned> output = states[i]->output; for (int j = 0; j < output.size(); j++) std::cout << "output=" << output[j] << ", "; std::cout << std::endl << "===========" << std::endl; }
これを実行して出てくる結果が、以下。
i = 0 branch : 0 goto : も, 1 goto : 寝, 3 =========== i = 1 branch : 0 goto : う, 2 =========== i = 2 branch : 1 output=0, =========== i = 3 branch : 0 goto : る, 4 =========== i = 4 branch : 1 output=1, ===========
0番目の要素のgotoには「も, 1」という情報が入っている。1番目の要素を見ると、「も」と繋がる「う, 2」が入っている。2番目の要素にはoutput=0が入っている。
0番目の要素のgotoにはもう1つ、「寝, 3」という情報が入っている。3番目の要素には「る, 4」がいる。4番目の要素にはoutput=1がいる。
どうやらこんな感じで文字情報が格納されているらしい。もう少し文字列を増やして、「今日は今日子と会った日」というコーパスを入れた場合の結果を見てみる。
今日/名詞/きょう は/助詞/は 今日子/名詞/きょうこ と/助詞/と 会/動詞/あ っ/語尾/っ た/助動詞/た 日/名詞/ひ
実行結果
i = 0 branch : 0 goto : 今, 1 goto : 日, 4 goto : は, 5 goto : と, 6 goto : 会, 7 goto : っ, 8 goto : た, 9 =========== i = 1 branch : 0 goto : 日, 2 =========== i = 2 branch : 1 goto : 子, 3 0, 2, =========== i = 3 branch : 1 1, =========== i = 4 branch : 1 2, =========== i = 5 branch : 1 3, =========== i = 6 branch : 1 4, =========== i = 7 branch : 1 5, =========== i = 8 branch : 1 6, =========== i = 9 branch : 1 7, ===========
こうやって見ると、どんな情報が入っているのかがなんとなく分かったような気がした。
あとは変数sentences_にも値を入れている。ここは「もう寝る」等の文章がそのまま入っている。
次はtrainWSという関数の実行。WSとは何か。とりあえずwsModel_というローカル変数に値を設定していて、コメントにはword segmentation modelsと書いてあった。
で、ここでやっていることは何かというと、必要な値をいろいろ用意してKyteaModel::trainModelを呼び出している。
trainModelは名前の通りtrainしてModelを生成する処理で、Liblinearが用いられている。
C++からLiblinearを使う際の説明は、この辺のページを見ておくと掴みやすい気がする。
trainWSでは、xsという二次元配列と、ysという一次元配列が作られている。
おそらくxsがtrainするための情報の集合で、ysは正解を意味するのだろう。
「もう寝る」のコーパスを用いた場合。
もう/副詞/もう 寝る/動詞/ねる
ysとxsを連結させた結果はこうなる。一番右がys、それ以降がxs。こうして並べるとLiblinearに食べさせるファイル風味になってわかり易い気がする。
-1 1 2 3 4 5 6 7 8 9 28 29 30 31 32 33 34 35 36 1 10 11 12 13 14 15 16 17 18 37 38 39 28 40 41 42 43 44 -1 19 20 21 22 23 24 25 26 27 45 46 47 37 48 49 50 51 31
実際にLiblinearに渡されるのは、こんな感じのデータ。
-1 1:1 2:1 3:1 4:1 5:1 6:1 7:1 8:1 9:1 28:1 29:1 30:1 31:1 32:1 33:1 34:1 35:1 36:1 52:1 1 10:1 11:1 12:1 13:1 14:1 15:1 16:1 17:1 18:1 37:1 38:1 39:1 28:1 40:1 41:1 42:1 43:1 44:1 52:1 -1 19:1 20:1 21:1 22:1 23:1 24:1 25:1 26:1 27:1 45:1 46:1 47:1 37:1 48:1 49:1 50:1 51:1 31:1 52:1
多分、-1が次の文字と連結するという意味。なのでこの場合、1つ目の「も」は次と連結するから「-1」が、つぎの「う」は連結しないから「1」が、といったように値が振られているはず。
最後の「る」は連結しようがないので値は生成されない。
詳しくはこの辺を見るとイメージしやすいはず。
18個並んでいる数値は、1〜3gramの繋がり方とか、文字タイプ(漢字、平仮名、カタカナ等)の1〜3gramとかが入っているはず。数値の個数は可変。
数値は、KyteaModelのmapFeatで振られているっぽい。試しに上の数字から中身を逆引きしてみる。
for(int i = 1; i < 40; i++) std::cout << i << ":" << util_->showString( wsModel_->showFeat( i ) ) << " ";
上記コードを実行すると、下記のような結果が出力される。
1:X0も 2:X0もう 3:X0もう寝 4:X1う 5:X1う寝 6:X1う寝る 7:X2寝 8:X2寝る 9:X3る 10:X-1も 11:X-1もう 12:X-1もう寝 13:X0う 14:X0う寝 15:X0う寝る 16:X1寝 17:X1寝る 18:X2る 19:X-2も 20:X-2もう 21:X-2もう寝 22:X-1う 23:X-1う寝 24:X-1う寝る 25:X0寝 26:X0寝る 27:X1る 28:T0H 29:T0HH 30:T0HHK 31:T1H 32:T1HK 33:T1HKH 34:T2K 35:T2KH 36:T3H 37:T-1H 38:T-1HH 39:T-1HHK
「もう寝る」の場合、最初の「も」は「1,2,3,4,5,6,7,8,9」が設定されているので「X0も,X0もう,X0もう寝,X1う,X1う寝,X1う寝る,X2寝,X2寝る,X3る」が設定されていることになる。
「も」から見てX0は自身を起点、X1は自身の1つ右を起点、X2は自身の2つ右を起点として、存在する1〜3gramを意味しているようだ。
28〜36は文字タイプを意味すると思われる。T0Hは「自身を起点として平仮名1字」、T0HHは「自身を起点として平仮名が2字続く」、T1HKは「自身の1つ右から平仮名、漢字と続く」という意味っぽい。
でも、このままだと辞書が使われていない。以下のような辞書を追加してtrainしてみる。
もう/副詞/もう
辞書付きでtrain
$ ./train-kytea -full corpus -dict dic -model sample.mod
こうすると、結果の「も」に対する部分は以下のようになる。
6 13 14 15 16 17 18 19 20 21 40 41 42 43 44 45 46 47 48 6:D0I2 13:X0も 14:X0もう 15:X0もう寝 16:X1う 17:X1う寝 18:X1う寝る 19:X2寝 20:X2寝る 21:X3る 40:T0H 41:T0HH 42:T0HHK 43:T1H 44:T1HK 45:T1HKH 46:T2K 47:T2KH 48:T3H
辞書を意味すると思われるD0I2という値が登場している。これだけだと意味がわからないので「もう/副詞/もう ねむい/形容詞/ねむい 。/補助/。」という文章(3語とも辞書登録済み)を対象にしてみる。
も D0I2 う D0L3 D0R2 ね D0I3 む D0I3 い D0L1 D0R3
こうなった。「も」のDOI2は「辞書2文字の最中」、「う」のD0R2は「辞書2文字の右」という意味だろうか。
さて、この辺りの情報を入れて、あとでLiblinearでpredictしてあげれば推定はできるようになるわけだけど、形態素解析部分のソースを見るとスコアを用いており、predictは利用してないように見える。
そのスコアはどこから来たのか。ソースを見てみると、KyteaModel::trainModelで変数weight_にtrain結果のModelから重みをそれぞれ取り出して、係数掛けたものを入れているようだ。
名前の通り、モデルをファイル出力しているのではないかと思われる。
まず、buildFeatureLookupsを呼んで、形態素解析の部分でも出てきたFeatureLookupを用意している。
その後、ModelIOクラスを用意し、config、モデル、タグ、辞書、サブワード辞書、サブワードモデルの情報を順に出力している。
具体的にどういった情報が出力されるかは、一度テキストモードでモデルを出力してみるとわかりやすい。
下記のようなコマンドを実行すると、モデルがテキスト形式で出力される。サイズが大きくなりやすいので、小さめのコーパスを使用する。
$ ./train-kytea -full corpus -model model.txt -modtext
参考までに「もう寝る」の出力結果を貼っておく。2語だけだけどけっこうな情報量。
通常はバイナリモードで出力される。ソースを見た感じでは、出力内容はバイナリモードでも同じはず。