MeCabのTaggerとかLatticeをC++から直接呼んで形態素解析してみた。
MeCabのコードリーディングをしようかと思ったので、その前に生っぽい感じのコードを書いて感覚を養ってみようという試み。
尚、本記事のサンプルコードはエラー処理とか全然してないので注意。
まずは極力シンプルなコードで形態素解析を実行してみる。
#include <iostream> #include <mecab.h> int main(int argc, char **argv) { char input[1024] = "ジョニーは戦場へ行った"; MeCab::Tagger *tagger = MeCab::createTagger(""); const char *result = tagger->parse(input); std::cout << result << std::endl; delete tagger; }
コンパイル(勇気と愛気)して叩く。
$ g++ `mecab-config --cflags` sample.cpp `mecab-config --libs` $ ./a.out
実行結果
ジョニー 名詞,固有名詞,人名,名,*,*,ジョニー,ジョニー,ジョニー は 助詞,係助詞,*,*,*,*,は,ハ,ワ 戦場 名詞,サ変接続,*,*,*,*,戦争,センジョウ,センジョー へ 助詞,格助詞,一般,*,*,*,へ,ヘ,エ 行っ 動詞,自立,*,*,五段・カ行促音便,連用タ接続,行く,イッ,イッ た 助動詞,*,*,*,特殊・タ,基本形,た,タ,タ EOS
ちゃんと動きました。
tagger->parse(cons char *str)はこんな感じで解析結果を文字列で返します。
尚、Tagger初期化時に渡している空文字は、MeCabをコマンドラインから動かす時に渡す引数と同じものを入れられます。例えばわかち書きモードを指定する-Owakatiを渡すと、こんな風に結果が返る。
char input[1024] = "ジョニーは戦場へ行った"; MeCab::Tagger *tagger = MeCab::createTagger("-Owakati"); const char *result = tagger->parse(input); std::cout << result << std::endl;
ジョニー は 戦場 へ 行っ た
指定できる引数は下記参照。
Taggerはparse(cons char *str)以外にも、様々なparse用のメソッドを持っている。
詳しくは公式サイトにdoxygenで出力したドキュメントがいるので、そちらを参照。
MeCab: MeCab::Tagger Class Reference
以下の関数などがいるようです。
とりあえず上で挙げたものについて、1つずつ順に使ってみる。parse(const char *str)は上の例で使ったので、parseToNode(const char *str)から。
戻り値にNodeを返すparseToNodeを使ってみる。プログラムからMeCabを叩く場合は、おそらくこの子が一番多く使われる。
Nodeはprevやnextなどの関数を持つ、双方向イテレータ的な構造体。surfaceやcostなどのMeCabを使っているとお馴染みの情報を持っています。
MeCab: mecab_node_t Struct Reference
なのでNodeを受け取ったら、あとはnextで回せば話が分かる。
#include <iostream> #include <mecab.h> int main(int argc, char **argv) { char input[1024] = "昼寝する"; MeCab::Tagger *tagger = MeCab::createTagger(""); const MeCab::Node* node = tagger->parseToNode(input); for (; node; node = node->next) { std::cout << "==========" << std::endl; std::cout << "id : " << node->id << std::endl; std::cout << "surface : " << node->surface << std::endl; std::cout << "feature : " << node->feature << std::endl; std::cout << "length : " << node->length << std::endl; std::cout << "rlength : " << node->rlength << std::endl; std::cout << "rcAttr : " << node->rcAttr << std::endl; std::cout << "lcAttr : " << node->lcAttr << std::endl; std::cout << "posid : " << node->posid << std::endl; std::cout << "char_type : " << (int)node->char_type << std::endl; std::cout << "stat : " << (int)node->stat << std::endl; std::cout << "isbest : " << (int)node->isbest << std::endl; std::cout << "alpha : " << node->alpha << std::endl; std::cout << "beta : " << node->beta << std::endl; std::cout << "prob : " << node->prob << std::endl; std::cout << "wcost : " << node->wcost << std::endl; std::cout << "cost : " << node->cost << std::endl; } delete tagger; }
========== id : 0 surface : 昼寝する feature : BOS/EOS,*,*,*,*,*,*,*,* length : 0 rlength : 0 rcAttr : 0 lcAttr : 0 posid : 0 char_type : 0 stat : 2 isbest : 1 alpha : 0 beta : 0 prob : 0 wcost : 0 cost : 0 ========== id : 2 surface : 昼寝する feature : 名詞,サ変接続,*,*,*,*,昼寝,ヒルネ,ヒルネ length : 6 rlength : 6 rcAttr : 1283 lcAttr : 1283 posid : 36 char_type : 2 stat : 0 isbest : 1 alpha : 0 beta : 0 prob : 0 wcost : 4465 cost : 4596 ========== id : 14 surface : する feature : 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル length : 6 rlength : 6 rcAttr : 599 lcAttr : 599 posid : 31 char_type : 6 stat : 0 isbest : 1 alpha : 0 beta : 0 prob : 0 wcost : 9129 cost : 7328 ========== id : 18 surface : feature : BOS/EOS,*,*,*,*,*,*,*,* length : 0 rlength : 0 rcAttr : 0 lcAttr : 0 posid : 0 char_type : 0 stat : 3 isbest : 1 alpha : 0 beta : 0 prob : 0 wcost : 0 cost : 5990
isbestは最適解の場合は1。
char_typeはおそらくIPA辞書を落としたフォルダに入っているchar.defのHIRAGANAやKATAKANAなどの記載の順で振られるID。
lengthが6になっているのはUnicode(日本語はたいてい1文字3byte)なので。surfaceからlength分のバイトを切り取ればその部分の文字になる。
wcostは単語単体のコスト。例えば昼寝はIPA辞書ではコスト4465に指定されている。costは連接コスト等も含めた値。
rcAttrとlcAttrは品詞ごとに付けられたID。IPA辞書内のleft-id.defやright-id.defに記述されている。
alpha, beta, probはデフォルトのモードでは出力されない。索性レベルを2(低速)に指定した場合に出力される。Taggerのset_lattice_leveに2を入れたり、Tagger生成時に引数に "-m"(昔は-l2だったけどdeplecatedになったらしい)を指定すると取得できます。
NBestの結果を返すparseNBasetを使ってみる。戻り値はconst char*。
下記は「指輪物語」という文字列の解析結果を上位3件出力させた場合。
char input[1024] = "指輪物語"; MeCab::Tagger *tagger = MeCab::createTagger(""); const char *result = tagger->parseNBest(3, input); std::cout << result << std::endl;
指輪 名詞,一般,*,*,*,*,指輪,ユビワ,ユビワ 物語 名詞,一般,*,*,*,*,物語,モノガタリ,モノガタリ EOS 指輪 名詞,一般,*,*,*,*,指輪,ユビワ,ユビワ 物 名詞,接尾,一般,*,*,*,物,ブツ,ブツ 語 名詞,接尾,一般,*,*,*,語,ゴ,ゴ EOS 指輪 名詞,一般,*,*,*,*,指輪,ユビワ,ユビワ 物 名詞,接尾,一般,*,*,*,物,モノ,モノ 語 名詞,接尾,一般,*,*,*,語,ゴ,ゴ EOS
普通に「指輪 | 物語」と分けるのがBestで、その後は「指輪 | ブツ | ゴ」と「指輪 | モノ | ゴ」になっています。
parseNBestだと複数の結果がまとめて文字列で返ってくる。
parseNBestInitを使って、taggerに対してnextとかnextNodeを実行すると、結果を上から順に1つずつ扱うことができる。
とりあえずありったけの結果をぶん回してみる。
char input[1024] = "何をするだー"; MeCab::Tagger *tagger = MeCab::createTagger(""); tagger->parseNBestInit(input); const char *result = tagger->next(); for( ; result; result = tagger->next() ) { std::cout << result << std::endl; }
上記を実行すると、828件ほど結果が出力されました。例えば同じ名詞でも、名詞,一般なのか名詞,サ変なのかといった違いがあるので、それらの組み合わせパターンを全部探らせるとこういう件数になるようです。
次にnextNodeでNodeを取って、上位3件を表示してみます。surfaceからlength分だけの言葉を取るところもやっておこう。
char input[1024] = "何をするだー"; MeCab::Tagger *tagger = MeCab::createTagger(""); tagger->parseNBestInit(input); const MeCab::Node *node; char surface[64]; for (int i = 0; i < 3; i++) { node =tagger->nextNode(); for (; node; node = node->next) { memset(surface, 0, 64); std::memcpy(surface, node->surface, node->length); std::cout << surface << "\t" << node->feature << std::endl; } }
実行結果
BOS/EOS,*,*,*,*,*,*,*,* 何 名詞,代名詞,一般,*,*,*,何,ナニ,ナニ を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ する 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ ー 名詞,固有名詞,組織,*,*,*,* BOS/EOS,*,*,*,*,*,*,*,* BOS/EOS,*,*,*,*,*,*,*,* 何 名詞,代名詞,一般,*,*,*,何,ナニ,ナニ を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ する 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ ー 名詞,固有名詞,組織,*,*,*,* BOS/EOS,*,*,*,*,*,*,*,* BOS/EOS,*,*,*,*,*,*,*,* 何 名詞,代名詞,一般,*,*,*,何,ナニ,ナニ を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ する 動詞,自立,*,*,サ変・スル,基本形,する,スル,スル だ 助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ ー 名詞,固有名詞,組織,*,*,*,* BOS/EOS,*,*,*,*,*,*,*,*
引数に文字列ではなくLatticeを取るタイプのparse。
MeCabの形態素解析部分を理解するには、Model、Tagger、Latticeの3つを自前で生成すれば、マルチスレッド下でも使えるそうな。その辺りは公式サイトの説明を参照。
Latticeの生成についてはあまり詳しくないので、少し念入りに見てみます。
形態素解析というと、ラティス作ってビタビアルゴリズムで最短経路探索するみたいな説明文がよくされているあのラティス。日本語入力を支える技術の第4章とか、下記サイトの図とか分かりやすい。
doxygenのドキュメントは下記。
MeCab: MeCab::Lattice Class Reference
普通に文字列でparseした際は内部でLatticeが作られて、最終的にはparse(Lattice *lattice)が呼ばれているようです。但し、この時にLatticeを生成しているのはmutable_lattice()というpreviate関数。mutable(使い回してるっぽい)なので、マルチスレッドでは動かなそう。
とりあえず呼んでみる。
char input[1024] = "ラティスさーん"; MeCab::Tagger *tagger = MeCab::createTagger(""); // Latticeさんの生成 MeCab::Lattice *lattice = MeCab::Lattice::create(); lattice->set_sentence(input); tagger->parse(lattice); // LatticeさんをtoString std::cout << lattice->toString() << std::endl;
ラティス 名詞,一般,*,*,*,*,ラティス,ラティス,ラティス さ 名詞,接尾,特殊,*,*,*,さ,サ,サ ー 名詞,一般,*,*,*,*,* ん 助動詞,*,*,*,不変化型,基本形,ん,ン,ン
Nodeを扱いたい場合は、lattice->bos_node() でNodeを取得して普通にnext()で回せば良さそう。
NBestを取得する場合は、下記のような感じで、set_request_typeでMECAB_NBESTを指定して、enumNBestAsStringで取り出せば良いようです。
char input[1024] = "ラティスさーん"; MeCab::Tagger *tagger = MeCab::createTagger(""); // NBset版Latticeさんの生成 MeCab::Lattice *lattice = MeCab::Lattice::create(); lattice->set_request_type(MECAB_NBEST); lattice->set_sentence(input); tagger->parse(lattice); // latticeさんをtoString std::cout << lattice->enumNBestAsString(3) << std::endl;
NBest + Nodeで回す場合はこんな感じ。
char input[1024] = "ラティスさーん"; MeCab::Tagger *tagger = MeCab::createTagger(""); // NBset版Latticeさんの生成 MeCab::Lattice *lattice = MeCab::Lattice::create(); lattice->set_request_type(MECAB_NBEST); lattice->set_sentence(input); tagger->parse(lattice); // latticeさんをぶん回す MeCab::Node *node; for( int i = 0; i < 3; i++) { node = lattice->bos_node(); for (; node; node = node->next) { std::cout << node->id << std::endl; } if(!lattice->next()) break; lattice->next(); }
資料によると、Latticは解析に必要な情報をローカル変数で持ってるからマルチスレッドで使っても大丈夫だとか。
公式サイトのサンプルコードは、Model、Tagger、Latticeを順に生成しています。
parse(Lattice *lattice) を呼んだ場合は中で model()->viterbi()->analyze(lattice) している。Modelはマルチスレッド下でも複数のTaggerで共有されるので、ModelをSwapする必要がなければLatticeだけ生成すれば大丈夫そう。
試しにサンプルコードをソースコードをダウンロードすると付いてくるexampleの中のthread_test.cppを改変して確認してみる。
#include <iostream> #include <vector> #include <string> #include <fstream> #include <sstream> #include <mecab.h> #include <stdlib.h> #define HAVE_PTHREAD_H 1 #include "../src/thread.h" class LatticeThread: public MeCab::thread { public: void run() { for (size_t i = 0; i < sentences_->size(); ++i) { std::cout << id_ << " run" << std::endl; lattice_->set_sentence((*sentences_)[i].c_str()); tagger_->parse(lattice_); result << id_ << " : " << lattice_->toString(); } } LatticeThread(std::vector<std::string> *sentences, MeCab::Tagger *tagger, int id) : sentences_(sentences), tagger_(tagger), id_(id) { lattice_ = MeCab::Lattice::create(); } ~LatticeThread() { delete lattice_; } std::stringstream result; private: std::vector<std::string> *sentences_; MeCab::Tagger *tagger_; MeCab::Lattice *lattice_; int id_; }; int main(int argc, char **argv) { std::ifstream ifs("japanese_sentences.txt"); std::string line; std::vector<std::string> sentences; while (std::getline(ifs, line)) { sentences.push_back(line); } MeCab::Tagger *tagger = MeCab::Tagger::create(""); const int kMaxThreadSize = 8; std::vector<LatticeThread *> threads(kMaxThreadSize); for (int i = 0; i < kMaxThreadSize; ++i) threads[i] = new LatticeThread(&sentences, tagger, i); for (int i = 0; i < kMaxThreadSize; ++i) threads[i]->start(); for (int i = 0; i < kMaxThreadSize; ++i) threads[i]->join(); for (int i = 0; i < kMaxThreadSize; ++i) std::cout << threads[i]->result.str(); for (int i = 0; i < kMaxThreadSize; ++i) delete threads[i]; delete tagger; }
上記コードを動かした感じでは、マルチスレッド下でもちゃんと動いてくれるようでした。試しにparseにLatticeを渡しているところを、tagger_->parse((*sentences_)[i].c_str())にしてみたところ、セグメンテーション違反で儚くなりました。
ModelとかTaggerの生成され方については下記サイトを見るとなんとなく分かったような気持ちになれた。