関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
キーフレーズ抽出ツール KEA を Lucene 上で実装する

キーフレーズ抽出ツール KEA を Lucene ライブラリを使って実装した KEA-lucene を開発したので紹介したい。Solr や Elasticsearch 利用が講じて Lucene API を勉強している人も参考になると思う。

KEA とは?

KEAはニュージーランドのワイカト大学で開発されている、自然言語で書かれた文書からキーフレーズ(キーワード)を自動抽出するプログラムである。KEAはKeyphrase Extraction Algorithmの略であり、KEAプログラムを構成するアルゴリズムそのものを指す場合もある。

文書に付加されたキーフレーズは、当該文書の意味的メタデータであり、文書の極端に短いサマリーとも言える。そのため、文書を読み込む時間がない場合にその文書のメタデータであるキーフレーズのリストをながめるだけでおおまかな内容をつかむことができる。たとえば限られた時間である調べ物をしていたとしよう。その調査に関係しそうな文書は目の前に山積みされているが、すべてに目を通している時間はない。そんなときは、文書に振られているキーフレーズをまずながめて、関係しそうな文書だけを選んで読み始めることができる。

学術論文などではその論文の著者によってキーフレーズが付加されているものが多い。しかしながら一般の文書や書籍はキーフレーズがつけられていないものがほとんどである。KEAはそのような文書から自動的にキーフレーズを抽出しようというプログラムである。KEAは著者によりキーフレーズが付加された文書を読み込んでその特徴を学習し、キーフレーズがつけられていない未知の文書からキーフレーズを自動抽出するという、教師あり機械学習プログラムである。KEAは言語(英語や日本語など)に関係なく動作可能なアルゴリズムとなっている。

キーフレーズ抽出と情報検索の関係

本稿はLuceneインデックスからキーフレーズを抽出しようという話なので、キーフレーズ抽出と情報検索の関係についても触れておこう。

まずKEAの作者らはキーワードという言葉ではなく、キーフレーズという言葉を使っている点に注目したい。キーワードというと重要な(キー)1つの単語を連想するが、ワードではなくフレーズ(成句)という言葉を使うことで、キーとなる1つ以上の単語の連なりも文書から抽出できるということが強調されている。

Luceneインデックスからキーフレーズが抽出できたとすると、情報検索にはどんないいことがあるだろうか。まず思い浮かぶのがクエリのサジェスチョン(またの名をオートコンプリート)である。Luceneインデックスはどうしても単語単位で文字列が管理されているので、サジェスチョンも単語単位となってしまう。しかしキーとなるフレーズが自動抽出できれば連続した複数の単語が一度にサジェストでき、ぐっとありがたみが増す。もしかして検索も同様である。

また検索結果一覧を表示するときに、ハイライト機能の代わりもしくはハイライト機能と一緒に当該文書のキーフレーズを表示することでユーザの文書選択の助けとすることも考えられる。さらにはファセット(絞り込み検索)のキーとすることも考えられるので、キーフレーズが抽出できることのメリットは大きい。

KEAの処理概要

KEAの論文は格別難しいことが書いてあるわけではないので、時間のある方にはぜひ一読をお勧めする。本稿ではのちのちLuceneライブラリを使ったKEAの実装を解説するので、それが理解できるよう最低限の説明を行うことにしよう。

KEAにおける処理は「学習過程」と「キーフレーズ抽出過程」に大別されるが、両者に共通な処理としてキーフレーズの候補をリストアップするプロセスが存在する。キーフレーズ候補は機械的にリストアップされる。そのようにリストアップされた多数のキーフレーズ候補から、学習時はキーフレーズになるなりやすさ(またはなりにくさ)を学習する。そしてキーフレーズ抽出時は学習した確率モデルを参照して、多数のキーフレーズ候補をスコアづけしてスコアの大きいものから順にキーフレーズ候補を表示する。実際のキーフレーズ抽出時はそのように順位付けされたキーフレーズ候補のリストから適当なところで足切りを行う。

本稿で解説するKEAの実装は、Luceneを大いに活用する。学習のプログラムでは、既知の(著者によりキーフレーズがつけられた)文書が入ったLuceneインデックスを作成した後そこからモデルファイルを作成する。未知の(キーフレーズがつけられていない)文書からキーフレーズを抽出するプログラムでは、その文書をLuceneインデックス登録してからキーフレーズを抽出する。

キーフレーズ候補の列挙

KEAではキーフレーズの候補として、1つから最大3つの連続する単語を列挙する。たとえば、次のような文書があったとしよう。

Tokyo governor likes to go to Yugawara.

すると、KEAでは次のように10個の候補となるキーフレーズを列挙する。

  • tokyo
  • governor
  • like
  • go
  • yugawara (*)
  • tokyo governor (*)
  • governor like
  • tokyo governor like
  • like to go
  • go to yugawara

キーフレーズ候補の列挙においては、ストップワードから始まるフレーズやストップワードで終わるフレーズは候補とならない。よって、単独のtoのみならず、like(s) toやto goなどは候補としてリストアップされない。また情報検索やNLPでよく行われる文字や単語の正規化もこの段階で行われる。よって、TokyoやYugawaraはtokyoやyugawaraに、likesはステミングされてlikeになる。

KEAにおけるモデルの学習

KEAでは単純ベイズ分類器を用いて教師ありデータ(著者によりキーフレーズがつけられた文書データ)からキーフレーズ候補におけるキーフレーズになるなりやすさ(P[yes])となりにくさ(P[no])を学習する。具体的には次のような式を用いる。

P[yes] = Y / (Y + N) * Pt[t|yes] * Pd[d|yes]
P[no] = N / (Y + N) * Pt[t|no] * Pd[d|no]

ここで"Y / (Y + N)"や"N / (Y + N)"の部分はキーフレーズ候補から求められる事前確率である。先の例文でアスタリスクがつけられたものがキーフレーズだとすると、つぎのようになる。

Y / (Y + N) = 2 / 10
N / (Y + N) = 8 / 10

KEAでは文書D中のフレーズPを表現するのに2つの特徴量を用いている。1つめはTF*IDFであり、次のように計算される。

TFIDF = freq(P,D) / size(D) (-log(df(P) / N))

freq(P,D)は文書D中にフレーズPが出現する回数、size(D)は文書の単語数、df(P)はフレーズPを含む文書数、Nは全文書数である。なおlogの底は2とする。

2つめの特徴量はfirst occurrence(別名、距離)と呼ばれるものである。これはフレーズPが文書Dに最初に登場する位置をsize(D)で割ったものである。たとえば先の例でtokyoは1/7、governorは2/7となる。

2つの特徴量は正の実数となるが、KEAでは連続値を離散化した上で各離散値ごとの事後確率を計算する。前述の式のPt[t|yes](またはPt[t|no])はTF*IDFの事後確率、Pd[d|yes](またはPd[d|no])は距離の事後確率である。

KEAにおけるキーフレーズ抽出

未知の文書Dからキーフレーズ抽出を行うには、Dから列挙したキーフレーズ候補について前述の2つの特徴量を計算してP[yes]とP[no]を求め、最終的に次の式を使ってスコアを算出して降順にソートし、適当なところで足切りする。

score(P,D) = P[yes] / (P[yes] + P[no])

なお、未知文書はモデルに含まれていないため、TF*IDFを計算する際は、df(P)とNは1を加算する。

Apache Luceneを使ったKEAプログラム

では前述のアルゴリズムを元に、Luceneライブラリをフル活用してKEAプログラム(KEA-luceneと呼ぶことにする)を自作してみよう。ここで紹介するプログラムはGithubに公開している。なお、プログラムはわかりやすさを優先し、ディレクトリ名などが意図的にハードコーディングされている。

なぜLuceneを使うのか?

ところでKEAを実装するのになぜLuceneを使うのだろうか。KEAに限らず自然言語処理のツールを実装する場合、単語の数を数えたり、ある単語を含む文書の数を数えたり、それ以前に文書を単語に区切ったりすることがよく行われる。Luceneはこういった処理を行うのによく整備されたAPIを備えている。さらにはLuceneの転置インデックス(以下単にインデックスと呼ぶ)は単語辞書としても優秀だ。特にKEA-luceneのために使用したLucene APIを以下に紹介しよう。

Analyzer

Luceneでは、文章を単語に区切るのにAnalyzerクラスを用いる。KEA-luceneでは、トークナイズのためにStandardTokenizerを、小文字への正規化のためにLowerCaseFilterを、そして単語N-gramをサポートするためにShingleFilterを使っている。残念ながらKEAのストップワードの考え方はLuceneのStopFilterでは実現できないので、KEAStopFilterという独自TokenFilterを実装している。そして最終的に次のようにKEAAnalyzerを組み上げた。

public class KEAAnalyzer extends Analyzer {

  private final int n;

  public KEAAnalyzer(int n){
    this.n = n;
  }

  @Override
  protected TokenStreamComponents createComponents(String fieldName) {
    Tokenizer source = new StandardTokenizer();
    TokenStream lcf = new LowerCaseFilter(source);
    if(n == 1){
      TokenStream stf = new KEAStopFilter(lcf, n, Commons.stopWords, Commons.beginStopWords, Commons.endStopWords);
      return new TokenStreamComponents(source, stf);
    }
    else{
      assert n >= 2;
      ShingleFilter shf = new ShingleFilter(lcf, n, n);
      shf.setOutputUnigrams(false);
      KEAStopFilter keasf = new KEAStopFilter(shf, n, Commons.stopWords, Commons.beginStopWords, Commons.endStopWords);
      return new TokenStreamComponents(source, keasf);
    }
  }
}

KEAの論文によると、conjunctions, articles, particles, prepositions, pronouns, anomalous verbs, adjectives および adverbs の各品詞からストップワードリストを構成している。これはLuceneがリストアップしているストップワードよりもはるかに多い。KEA-luceneではLuceneのストップワードに独自にストップワードを追加した。

さらにKEA-luceneでは単語N-gramのNを1から3に変えて個別のフィールドにインデックス登録するため、LuceneのPerFieldAnalyzerWrapperを次のように使用している。

  public static Analyzer getKEAAnalyzer(String fieldName){
    Map amap = new HashMap<>();
    amap.put(Commons.getFieldName(fieldName, 1), new KEAAnalyzer(1));
    amap.put(Commons.getFieldName(fieldName, 2), new KEAAnalyzer(2));
    amap.put(Commons.getFieldName(fieldName, 3), new KEAAnalyzer(3));
    return new PerFieldAnalyzerWrapper(new StandardAnalyzer(), amap);
  }

単語辞書としてLuceneインデックスを活用する

Luceneは単語をキーにした転置インデックスを作成する。これを単語辞書として使うのは実に自然であり理にかなっているといえるだろう。フィールドfieldの単語を最初から最後まで走査するには次のようにする。ただしirはLuceneインデックスをオープンしているIndexReaderのオブジェクトである。

Terms terms = MultiFields.getTerms(ir, field);
TermsEnum te = terms.iterator();
for(BytesRef rawPhrase = te.next(); rawPhrase != null; rawPhrase = te.next()){
  String phrase = rawPhrase.utf8ToString();
  :
}

Luceneのインデックスはただ単に単語がリストアップされているだけではない。前述の2つの特徴量を計算するために必要となる統計量も保存されている。たとえばfreq(P,D)は文書D中にフレーズPが出現する回数であるが、これはMultiFieldsのgetTermDocsEnum()メソッドを使用して取得したPostingsEnumオブジェクトに対しnextDoc()で文書Dを特定した後、freq()を呼び出すことで取得できる。

またdf(P)はフレーズPを含む文書数、Nは全文書数であるがこれは次のようにして取得できる。

int dfreq = ir.docFreq(new Term(field, phrase));
:
int n = ir.numDocs();

さらにはフレーズの距離を計算するためにポジション情報も必要になってくるが、これはMultiFieldsのgetTermPositionsEnum()メソッドを使ってPostingsEnumオブジェクトを取得後、advance()メソッドで文書を特定した後nextPosition()で最初のフレーズのポジションを求める。KEA-luceneでは1-gram, 2-gram, 3-gramを独立したフィールドにしているので、nextPosition()で取得したポジションはそのまま距離計算に用いて問題ない。

モデルの学習(モデルファイルの出力)

KEA-luceneではKEAModelBuilderがモデルの学習を行っている。このプログラムはここからダウンロードできる教師データ(MAUI.tar.gzを展開しさらにその中にあるfao30.tar.gzを展開する)をdata/fao30/ディレクトリに配置されることを前提に書かれているので注意していただきたい。

KEAではモデルの学習は単純ベイズを使っているので、学習には各種統計量をカウントした上で統計量の割り算を行う。割り算を計算しているのは後述のKeyphraseExtractor(2)プログラムであり、KEAModelBuilderプログラムでは統計量を求めているだけなので、正確にはモデルの学習というよりはモデルファイルを出力しているに過ぎない。

KEAModelBuilderが出力するモデルファイルは次のようなスペース区切りのテキストファイルである。

$ head features-model.txt
0.000852285 0.231328 false
0.000284095 0.980489 false
0.000426143 0.0124768 false
0.000426143 0.429134 false
2.01699e-05 0.0479968 false
0.000284095 0.0160665 false
0.000426143 0.726610 false
0.000136409 0.000752663 false
0.000226198 0.379661 false
0.000284095 0.478057 false

1つのレコードは1つのキーフレーズ候補を表している。最初の数値はTF*IDFで、2番目の数値は距離を表している。3列目はキーフレーズ(true)か否か(false)を表現するクラスである。

KEAModelBuilderプログラムは上記のようなモデルファイルを出力するために教師データからLuceneインデックスを作成し、Luceneインデックスを単語辞書とみなしてiterateしながら特徴量を計算しつつ、上記のモデルファイルを出力する。

参考までに処理時間を示しておこう。私のMacBook Pro(Processor 2.3 GHz Intel Core i7)では5分程度かかった。Luceneインデックスを作成するのは一瞬だが、Luceneインデックスを走査しながらモデルファイルを出力するのに少々時間がかかっている。

Rによる連続値の離散化

KEAでは特徴量の実数値をそのまま用いるのではなく、MDLP(最小記述長原理)により求めた離散値にマッピングして用いる。ここではMDLPの計算のためにRを使って次のように求めた。

data <- read.table('features-model.txt')
mdlp(data)$cutp
[[1]]
[1] 0.0003538965 0.0013242950 0.0041024750

[[2]]
[1] 0.0003553105 0.0144056500 0.0697899000

ここで得られた結果は、次のプログラムKeyphraseExtractor(2)で使用するので、cutp-model.txtというファイル名で保存しておく。

$ cat cutp-model.txt
0.0003538965 0.0013242950 0.0041024750
0.0003553105 0.0144056500 0.0697899000

RによるMDLP計算は同じ環境で30分程度かかった。

Luceneインデックスからのキーフレーズ抽出

ではいよいよLuceneインデックスからキーフレーズを抽出してみよう。プログラムはKeyphraseExtractorとKeyphraseExtractor2である。前者はKEAModelBuilderで作成したインデックスのうち試しに既知のファイルa0011e00.txt(このファイル名もプログラム中にハードコーディングしてある)からキーフレーズを抽出するものである。KeyphraseExtractor2の方はfao780(前述のMAUI.tar.gzを展開した中にある)内の未知の文書t0073e.txtからキーフレーズを抽出するプログラムとなっている。

KeyphraseExtractorの方は既存のLuceneインデックスから統計量を得ているのに対し、KeyphraseExtractor2の方は新規の文書から各種統計量を得るためにLuceneインデックスを作成するところから行っている。また、KeyphraseExtractor2の方はモデルに含まれない新規文書であるために、df(P) / N の計算部分で分母、分子とも1が加算されているところも異なる。

KeyphraseExtractorとKeyphraseExtractor2はともにLuceneインデックスに登録済みの文書を特定し、その文書におけるキーフレーズ候補をリストアップしながらスコアを計算する。したがって単語辞書をiterateするよりもLuceneのTermVectorを使いたい。そこでLuceneドキュメント登録は次のようにFieldTypeのsetStoreTermVectors(true)を呼んでいる。

  static Document getDocumentWithTermVectors(String fn, String content) throws IOException {
    Document doc = new Document();
    doc.add(new StringField(FILE_NAME, fn, Field.Store.YES));
    doc.add(new StoredField(DOC_SIZE_FIELD_NAME, Commons.getDocumentSize(content)));

    FieldType ft = new FieldType();
    ft.setStored(true);
    ft.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
    ft.setStoreTermVectors(true);
    doc.add(new Field(Commons.getFieldName(FIELD_NAME, 1), content, ft));
    doc.add(new Field(Commons.getFieldName(FIELD_NAME, 2), content, ft));
    doc.add(new Field(Commons.getFieldName(FIELD_NAME, 3), content, ft));
    return doc;
  }

キーフレーズ候補は前述のスコア計算式によりscore(P,D)が計算され降順にソートされる。KEA-luceneではそのためにLuceneのPriorityQueueを用いている。ところで、KEAでは特徴量として連続値ではなく離散値を使っているため、score(P,D)が多くのキーフレーズ候補間で同点になる事象が論文で指摘されている。その場合はTF*IDFを使って同点決勝を行うということなので、PriorityQueueの実装は次のようになっている。

  static class KeyphraseScorePQ extends PriorityQueue {

    public KeyphraseScorePQ(int maxSize) {
      super(maxSize);
    }

    @Override
    public boolean lessThan(KeyphraseScore arg0, KeyphraseScore arg1) {
      if(arg0.score < arg1.score) return true;
      else if(arg0.score == arg1.score){ // tie breaker
        return arg0.tfidf < arg1.tfidf;
      }
      else return false;
    }
  }

またPriorityQueue中下位のキーフレーズ候補が、それより上位のキーフレーズ候補のサブフレーズであった場合ははじかれるように実装する必要もある。

実行結果を見てみよう。KeyphraseExtractorを実行して抽出されたキーフレーズの上位20件は次のようである。なお、括弧内の2つの数値は、スコアとTF*IDF特徴量を表している(スコアが同点の時はTFIDF値が高い方が上位にきているのがわかるだろう)。

animal (0.311211,0.001698)
standards setting (0.102368,0.014469)
sps (0.102368,0.009091)
chains (0.102368,0.008121)
food chains (0.102368,0.008038)
food safety (0.102368,0.008004)
sps standards (0.102368,0.006865)
value chain (0.102368,0.005468)
setting process (0.102368,0.005250)
standards setting process (0.102368,0.004846)
livestock food (0.102368,0.004823)
oie (0.102368,0.004501)
poor to cope (0.102368,0.004442)
animal health (0.102368,0.004255)
animal production (0.089053,0.000425)
consultation (0.076506,0.004025)
assisting the poor (0.076506,0.002827)
sanitary and technical (0.076506,0.001615)
requirements assisting (0.076506,0.001615)
dynamics of sanitary (0.076506,0.001615)

これに対し人手でつけられたキーフレーズは次のようである(fao30データは複数の人間が思い思いにキーフレーズをつけているので、一回でもキーフレーズと認識されたフレーズをここではリストアップしている)。

$ cat $(find fao30 -name a0011e00.key)|sort|uniq
animal health
animal production
animal products
capacity building
consumers
developing countries
development policies
disease surveillance
domestic markets
empowerment
fao
food chains
food safety
food security
hygiene
information dissemination
livestock
markets
meat hygiene
mechanics
phytosanitary measures
poverty
public health
regulations
risk analysis
risk management
rural population
standards
technical aid
technology
trade
veterinary hygiene
world

両者に共通して現れるキーフレーズは次のようになる。

food chains, food safety, animal health, animal production

決して正解率が高いとは言えないが、抽出されたキーフレーズはフレーズとして不自然なものが少ないように見える。自動抽出されたフレーズとしてはいいセン行っているのではないだろうか。

またKeyphraseExtractor2を実行して未知の文書からキーフレーズ抽出した結果は次のようである。同じく上位20件を表示している。

post harvest (0.628181,0.004842)
vegetables (0.311211,0.002956)
root crops (0.311211,0.002523)
fruits (0.311211,0.002316)
vegetables and root (0.311211,0.002161)
food losses (0.311211,0.001900)
harvest food losses (0.311211,0.001582)
post harvest food (0.311211,0.001582)
fresh produce (0.102368,0.011087)
packing (0.102368,0.007199)
decay (0.102368,0.005210)
containers (0.102368,0.004370)
prevention of post (0.089053,0.001231)
fruits vegetables (0.089053,0.000472)
handling (0.076506,0.002345)
storage (0.076506,0.002177)
tubers (0.076506,0.001889)
marketing (0.076506,0.001474)
roots (0.076506,0.001389)
potatoes (0.029596,0.003984)

一方で、人手でつけられたキーフレーズは以下である。

$ cat t0073e.key
Fruits
Marketing
Packaging
Postharvest losses
Postharvest technology
Root crops
Vegetables

こちらも結構妥当なフレーズが抽出されている。情報検索のキーワードサジェスチョンなどに用いるには十分な精度といってもいいだろう。

まとめ

本稿は教師あり機械学習でキーフレーズを抽出するKEAを、あえてLuceneライブラリを活用することで実装した。KEAプログラムはアルゴリズムが簡単明瞭なので、キーフレーズ候補の各種統計量を得るためにLucene APIをどのように使えるのか、プログラムから読み取りやすいのではないかと考えている。

読者が本稿を通じてLuceneライブラリへ興味を持ったり、ライブラリへの知識を少しでも広げていただけたなら幸いである。

課題

本稿を執筆した最初のプログラムのバージョンでは、以下の項目については時間の制約から実装していない。

  • ステミング。LuceneのPorter Stemmerなどを使えるはずである。
  • MDLP。記事にあるとおりRを使っている。RのMDLP実装はGPLなので、この部分はぜひ自作か非GPLライセンスの実装を使えるようにしたい。
  • 日本語データなど英語以外のテキストに対して試す。教師データが必要なのと、ストップワード周りのチューニングが必要になるだろう。
  • 追加の特徴量。最新のKEA実装は前述の2つの特徴量に追加して論文には触れられていない特徴量があるようだ。

KEA-luceneはLucene学習者向けにあえてハードコーディングをしているが、汎用的なものを適宜NLP4Lを通じて提供する予定である。

| 関口宏司 | NLP | 14:45 | comments(0) | trackbacks(0) |
日本語Wikipediaからの類義語辞書の自動生成

日本語Wikipediaから Lucene / Solr / Elasticsearch で使える類義語辞書を自動生成する方法を紹介する。

過去にはこちらで説明している方法で行ったりもしたが、今回はいわゆる外来語に限って言葉を収集するもので、はるかに精度がよい(測ってはいないが・・・)。また実装は NLP4L を通じて公開しているので、誰でも試せる。また理屈がわかれば、日本語Wikipediaに限らず収集できる。

日本語文書は次のようにカタカナ語とその語源の英語(等)のアルファベット文字列が近い距離に配置されて書かれることが頻繁にある。

エンターテインメント(英: entertainment)とは、人々を楽しませる娯楽をいう。(Wikipedia「エンターテインメント」より抜粋)

そこで文書を大量に収集して互いに近い距離にあるカタカナ語とアルファベット文字列を類義語のペアとして Lucene/Solr に使えるテキストファイルに出力することを考える。しかし、近い場所に書かれているすべてのペアが必ずしも同じ意味を持つとは限らない。たとえば、次のような文章では間違った結果を得てしまう。

コンピューターはFORTRANのおかげでより身近になった。

文章を相当大量に収集してカタカナ語とアルファベット文字列の共起を調べることでも可能かもしれないが、文章を大量に収集することは誰でもできることではない。

NLP4L には英単語とカタカナ語の Transliteration のプログラムと学習データがついている。

このプログラムを使うとカタカナ語から英単語の綴りを推定できる。この推定値と文章から拾ったアルファベット文字列を比較し、文字列が似ていれば(適当に決めた編集距離以下なら)拾ったカタカナ語とアルファベット文字列は同じ意味を持つとして類義語辞書に出力する。

手順は次の通り。

  1. 日本語Wikipediaデータを Lucene インデックスに登録する。やり方はこちら
  2. Lucene インデックスからカタカナ語とアルファベット文字列のペアを抽出する。やり方はこちら

以上の手順は日本語Wikipediaから抽出するやり方だが、社内文書や専門書などからも収集できる。

先にあげたエンターテインメントやインタフェースのように、カタカナ語に表記揺れがある場合も次のように収集できるのも興味深い。

entertainment,エンターテイメント,エンターテインメント
interface,インタフェース,インターフェース
pennsylvania,ペンシルバニア,ペンシルベニア

うまく使えば検索の再現率向上に大いに役立つだろう。

| 関口宏司 | NLP | 17:24 | comments(0) | trackbacks(0) |
Solrを使って専門用語抽出

Solrを使って簡単に専門用語抽出する方法を紹介しよう。

専門用語とは?

専門用語」とは、Wikipediaによれば、「ある特定の職業に従事する者や、ある特定の学問の分野、業界等の間でのみ使用され、通用する言葉・用語群」である。そこで本記事では、ある特定の分野の文書が登録されたインデックスと、別の分野の文書が登録されたインデックスを比較し、互いに相手方のインデックスに含まれない単語集合を表示することで専門用語を抽出することにする。

Solrとインデックスの準備

ここで紹介する方法はSolr 1.4 以降であれば動作する(はず)。Solrを持っていない人はダウンロードして用意しよう。そして下記のschema.xmlを指定してSolrを起動する。

<?xml version="1.0" encoding="UTF-8" ?>
<schema name="example" version="1.5">
  <types>
    <fieldType name="string" class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
    <fieldType name="int" class="solr.TrieIntField" precisionStep="0" omitNorms="true" positionIncrementGap="0"/>
    <fieldType name="long" class="solr.TrieLongField" precisionStep="0" positionIncrementGap="0"/>
    <fieldType name="date" class="solr.TrieDateField" omitNorms="true" precisionStep="0" positionIncrementGap="0"/>
    <fieldType name="text_ja" class="solr.TextField" positionIncrementGap="100" autoGeneratePhraseQueries="false">
      <analyzer>
        <tokenizer class="solr.JapaneseTokenizerFactory" mode="search"/>
        <filter class="solr.JapaneseBaseFormFilterFactory"/>
        <filter class="solr.CJKWidthFilterFactory"/>
        <filter class="solr.JapaneseKatakanaStemFilterFactory" minimumLength="4"/>
        <filter class="solr.LowerCaseFilterFactory"/>
      </analyzer>
    </fieldType>
  </types>

  <fields>
    <field name="url" type="string" indexed="true" stored="true" required="true" />
    <field name="cat" type="string" indexed="true" stored="true"/>
    <field name="date" type="date" indexed="true" stored="true"/>
    <field name="title" type="text_ja" indexed="true" stored="true"/>
    <field name="body" type="text_ja" indexed="true" stored="false" multiValued="true"/>
  </fields>

  <uniqueKey>url</uniqueKey>
</schema>

文書データはここではlivedoorニュースコーパスを用いる。以下のようにwgetでlivedoorニュースコーパスを入手して展開しておく。

$ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz
$ tar xvzf livedoor-news-data.tar.gz

そしてSolrを起動して展開して得られたファイルのうちひとつ(例としてここでは「独女通信(dokujo-tsushin.xml)」をSolrに登録する。

$ ./post.sh dokujo-tsushin.xml
TermsComponentで単語リストを取得

次に、SolrのTermsComponentを使ってインデックスから単語一覧を取得する。次のcurlコマンドを実行すると、bodyフィールドでの使用頻度の多い順に上位1万語の単語一覧が得られる。

$ curl "http://localhost:8983/solr/collection1/terms?terms.fl=body&terms.limit=10001&omitHeader=true&wt=json&indent=true" | tail -n 10001|head -n 10000|cut -d , -f1|cut -d ¥" -f2 > dokujo-tsushin.txt
$ head -n 30 dokujo-tsushin.txt 
が
する
た
て
で
と
に
の
は
を
も
だ
いる
ない
ある
こと
から
なる
れる
か
という
人
う
や
です
ます
よう
ん
たい
思う

headコマンドで上位30位の頻出単語を表示しているが、前掲のschema.xmlファイルを見ていただくとわかるとおり、ストップワードの処理を一切していないので、およそ検索には役に立たない(そして専門用語でもない)単語が並んでいるのがわかるだろう。

次に異なる分野の文書を登録して同様に単語一覧を表示する。そのためにここでいったんSolrを停止してインデックスを削除し、再度起動して別の分野の文書(ここでは例として「スポーツウォッチ(sports-watch.xml)」)を登録する。そして同様にTermsComponentを使って単語一覧を得る。

$ ./post.sh sports-watch.xml
$ curl "http://localhost:8983/solr/collection1/terms?terms.fl=body&terms.limit=10001&omitHeader=true&wt=json&indent=true" | tail -n 10001|head -n 10000|cut -d , -f1|cut -d ¥" -f2 > sports-watch.txt
$ head -n 30 sports-watch.txt 
の
は
する
た
に
が
で
と
を
て
だ
日
も
いる
ない
れる
なる
から
ある
こと
か
という
です
ます
ん
その
日本
1
選手
語る
2つの単語集合を比較する

最後に2つの単語集合を比較して差分を表示するプログラムを書く。ここでは以下のような簡単なScalaプログラムを作成した。

package terms

import scala.io.Source

object TermsExtractor {

  def main(args: Array[String]): Unit = {
    val set1 = termsSet("/Users/koji/work/ldcc/dokujo-tsushin.txt")
    val set2 = termsSet("/Users/koji/work/ldcc/sports-watch.txt")
    
    println(set1 &~ set2)
    println(set2 &~ set1)
  }

  def termsSet(file: String): Set[String] = {
    val src = Source.fromFile(file, "UTF-8")
    var result : Set[String] = Set()
    try{
      src.getLines.foreach{ line: String =>
          result = result + line
      }
      result
    }
    finally {
      src.close
    }
  }
}

実行すると次のように2行の出力が得られる。1行目は「独女通信」にのみ出現する単語であり、2行目は「スポーツウォッチ」にだけ出現する単語である。頻出していたストップワードっぽい単語はきれいに消えていることがわかる。

Set(今風, 味の素, 呼び名, 愛車, ヨネスケ, 週末, 空き, 華子, 姓名, 時折, 越える, サン, 思い当たる, はしご, ロードショ, 新鮮味, 調べる, 札, 執筆, 麻美, 養子, 駅, ミッション, フミ, 初恋, 鉄則, 落とし穴, さゆり, メモリ, 飲み食い, 和子, 避妊, 苦痛, アカシックレコードリーディング, ラッキ, 本体, 真美子, よそ見, マザー, 尚更, だれ, 盛, sweet, 余震, 心得, アドレス, 器用貧乏, 渋谷, 次男, 法的, 知人, 集まり, ミニサイズ, 皆無, へえ, 眺める, 洋子, 膨大, あっぱれ, 数少ない, ハッピ, 手数料, ローラ, 安価, 波風, 甲斐, 統計, 労働省, 主演, ntt, 以下省略)
Set(緊迫, かしぐ, ほとぼり, 在米, フジ, 見れる, フェンシング, 連夜, 被, 205, 兼備, 2014, 藤田, 長島, 曙, 颯爽, プロレスラ, セーフコ, 説く, はしる, 知良, アスリートファン, 鳥肌, 主審, 沸く, 周知, ちょう, 円熟, オールナイト, 卓, あしらい, 不用意, 栗山, 火蓋, やらす, 弘和, 修一, 一角, 平泳ぎ, 98, ゴールデンゴールズ, 演説, 敗, 今井, アリエン, スプリングス, 戦後, beautiful, しょうもない, 113, 敗者, 野, 仰天, 素性, 聖, sunday, 井端, イーグルススカウト, 批評, スルーパス, 前線, 弁解, ユース, クロー, 人情, 用水, やぎ, cb, クーラ, 興行, 選考, mmaplanet, きまる, 哲也, 織る, ラピュタ, グルノーブル, ロマン, クロアチア, brad, 山里, wbc, 権限, pm, ぶっつける, 抜擢, いづみ, アフリカ, 以下省略)

これをもってそれぞれの分野の専門用語であると言い切るには難があるが、どちらかが「独女通信」でどちらかが「スポーツウォッチ」だという2択の問題を出されたならば、ほとんどの人が正解するくらいには専門用語っぽい出力が得られているといえるのではないだろうか。

| 関口宏司 | NLP | 09:00 | comments(1) | trackbacks(0) |
word2vec for Lucene ご紹介

Luceneインデックスをコーパスとみなして動作する word2vec for Lucene を開発したのでその使い方を紹介しよう。word2vec は Tomas Mikolov らによって提案/開発された単語をベクトルに変換するOSSツールである。オリジナルの word2vec は自然言語で書かれたテキストファイルを読み込み、単語ベクトルを出力するようになっている。

word2vec for Lucene はテキストファイルの代わりにLuceneインデックスを入力コーパスとして用いる。オリジナルの word2vec が扱うテキストファイルは単語単位で分かち書きされている必要がある。この点は英語テキストなどでは問題ないが、単語で分かち書きされていない日本語などではあらかじめMeCabなどの形態素解析器や分かち書きツールを用いて単語で分かち書きされたテキストファイルを用意しておかなければならない。また英語であっても大文字→小文字変換や単語テキストの直後に続くピリオド/カンマの削除など、事前の正規化作業が必要になってくる。word2vec for Lucene では Luceneインデックスを入力コーパスとして用いるため、事前の正規化作業が不要である。

以下では簡単に試していただけるよう、word2vec for Lucene のデモを実行する手順を説明する。大まかな手順は:

1.word2vec for Lucene のダウンロード
2.デモデータの準備
3.Lucene/Solrインデックスの準備(デモデータの登録)
4.単語ベクトルの作成
5.単語ベクトルで遊ぶ

という感じである。すでにLuceneインデックスを持っている方は手順2〜3の代わりに手持ちのLuceneインデックスを手順4で指定することにしてもよい。word2vec for LuceneはLucene 4.10.2ベースで作成したが、(試していないが)たいていのLucene 4.x インデックスで動作するものと思われる。したがって、Solr 4.x はもちろんのこと、Elasticsearch(バージョンはよく知らないが1.x系以降)で作成したインデックスも指定できる。

手順5で楽しい結果を得るためにはそれなりに大きなコーパスが必要である。数百万文書など大きなLuceneインデックスを持っている方はぜひ word2vec for Lucene を試していただきたい。

以下手順を示すが、簡単のために /Users/koji/work ディレクトリ以下に Solr 環境と word2vec for Lucene 環境をインストールする想定で話を進める。

手順1.word2vec for Lucene のダウンロード
$ pwd
/Users/koji/work
$ git clone https://github.com/kojisekig/word2vec-lucene.git
手順2.デモデータの準備
$ pwd
/Users/koji/work
$ cd word2vec-lucene
# オリジナルの word2vec でも使用されている英語コーパス text8 をダウンロードして Solr 形式に変換
$ ant t8-solr
$ ls -l text8*
-rw-r--r--  1 koji  staff  100000000  6  9  2006 text8
-rw-r--r--  1 koji  staff  100017005 12  3 13:44 text8.txt
-rw-r--r--  1 koji  staff  100017078 12  3 13:44 text8.xml
-rw-r--r--  1 koji  staff   31344016 12  3 13:40 text8.zip
手順3.Lucene/Solrインデックスの準備(デモデータの登録)

コンソールを2つ用意して最初のコンソールでSolrを起動する。

$ pwd
/Users/koji/work

# Solrのダウンロード
$ wget http://ftp.meisei-u.ac.jp/mirror/apache/dist/lucene/solr/4.10.2/solr-4.10.2.tgz
$ tar xvzf solr-4.10.2.tgz

# Solrの起動
$ cd solr-4.10.2/example
$ java -Dsolr.solr.home=/Users/koji/work/word2vec-lucene/solrhome -Dsolr.dir=/Users/koji/word/solr-4.10.2 -jar start.jar

2つめのコンソールではtext8コーパスをSolrに登録する。

$ pwd
/Users/koji/work
$ cd word2vec-lucene
# Solr形式に変換した英語コーパスをSolrに登録
$ ./post.sh collection1 text8.xml
手順4.単語ベクトルの作成

demo-word2vec.sh スクリプトにSolrコア名を指定してword2vecを実行する。実行結果はvectors.txtというファイルに出力される。

$ pwd
/Users/koji/work
$ cd word2vec-lucene
$ ./demo-word2vec.sh collection1
$ ls -l vectors.txt 
-rw-r--r--  1 koji  staff  136053041 12  3 15:31 vectors.txt
手順5.単語ベクトルで遊ぶ

demo-distance.sh を実行するとvectors.txtファイルを読み込んで入力待ちになる。そこで単語を入力すると、その単語に最も近い(コサインを計算)ベクトルを持つ単語上位40個を表示する。

$ ./demo-distance.sh
cat
Word: cat
Position in vocabulary: 2601

                                              Word      Cosine distance
------------------------------------------------------------------------
                                              cats		0.511078
                                               dog		0.471308
                                              dogs		0.469539
                                        sighthound		0.452233
                                           bobtail		0.436424
                                             tapir		0.424105

demo-analogy.sh を実行するとvectors.txtファイルを読み込んで入力待ちになるので、3つの単語をスペース区切りで入力する。以下ではman king womanと入力しているが、「manに対するkingの関係を、womanに適用するとどうなるか」という意味である。するとqueenが期待値になるが、実行結果もそのようになる。

$ ./demo-analogy.sh
man king woman
日本語で試す場合

以上の手順はオリジナルの word2vec でも使用している text8 という英語の正規化済みのコーパスを使った手順であるが、以下では日本語の(正規化済みでない)コーパスを使った手順も紹介しよう。デモデータは livedoor ニュースコーパスを用いる。ロンウイットのサイトから Solr ネイティブ形式に加工した livedoor ニュースコーパスをダウンロードする。

$ pwd
/Users/koji/work
$ cd word2vec-lucene
$ mkdir work
$ cd work
$ pwd
/Users/koji/work/word2vec-lucene/work
$ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz
$ tar xvzf livedoor-news-data.tar.gz
$ cd ..

ダウンロードした livedoorニュースコーパスをSolrに登録し、word2vecを実行する。コア名にはldccを指定する。また、-aオプションでLuceneのAnalyzerクラスを指定する。また以下では-fオプションで出力先ベクトルファイル名を指定している(指定しないとデフォルトのvectors.txtとなってしまい、先ほど作成した英語の単語ベクトルファイルを上書きしてしまう)。

$ ./post.sh ldcc work/*.xml
$ ./demo-word2vec.sh ldcc -a org.apache.lucene.analysis.ja.JapaneseAnalyzer -f vectors-ldcc.txt
$ ./demo-distance.sh -f vectors-ldcc.txt
結婚
野球
デモデータ以外のLuceneインデックスで試す場合の注意点

word2vec for Lucene で配布しているデモ実行スクリプト demo-word2vec.sh を見てもらうとわかるように、いくつかのパラメータがハードコーディングで指定されている。以下はLuceneに関連するパラメータである。これを手持ちのLuceneインデックスに合うように適宜指定しなおせばLucene 4.x インデックスのデータでword2vecが動くはずである。

オプション説明
-indexLuceneインデックスのディレクトリを指定する。
-fieldword2vec を実行するLuceneインデックスのフィールド名を指定する。このフィールドはindexedでかつstoredでなければならない。
-analyzer-fieldオプションで指定されたフィールドのstoredデータをここで指定されたLuceneのAnalyzerクラスでanalyzeする。

-analyzer オプションについては、storedデータではなくTermVectorでもよかったのだが、TermVectorを保存しているLuceneインデックスは少なく、処理速度的にも疑問だったため、storedデータをここで指定するLuceneのAnalyzerクラスでanalyze する方針をとった。ではなぜindexedでなければいけないかというと、(オリジナル実装でも)word2vecではコーパスを2度読んでいて、1回目の読み込みで単語表を作成しているが、indexedであればそこは一瞬で完了し、なんといってもLuceneっぽいからである。また、word2vecで解析したくなるようなLuceneフィールドはたいていindexedでかつstoredと考えられるので、多くのLuceneインデックスオーナーにはこの条件は受け入れられるはずである。

相当数の文書が入っているLuceneインデックスを持っている方は、デモデータで動作を確認したあと、ぜひword2vec for Luceneを手持ちのLuceneインデックス上で試していただき、どんな結果になったか教えていただければありがたい。

| 関口宏司 | NLP | 09:00 | comments(0) | trackbacks(0) |
テキストアノテーションツール brat がすごい件

brat は OSS のテキストアノテーションツールである。GUI(ビジュアルと操作性)とできあがりのアノテーション情報のわかりやすさ(シンプルなフォーマットが自作NLPプログラムから扱いやすい!)、導入の簡単さがすばらしいのでここで紹介しよう。

brat のインストール

Apache などの Web サーバ上で動かして本格的に使う方法と、個人的にさくっと立ち上げて使う方法があり、ここでは後者を紹介する。この場合インストールは簡単で、brat を適当なディレクトリにダウンロードして展開する。

$ mkdir work
$ cd work
$ wget http://weaver.nlplab.org/~brat/releases/brat-v1.3_Crunchy_Frog.tar.gz
$ tar xvzf brat-v1.3_Crunchy_Frog.tar.gz
そして、次のようにインストールのスクリプトを実行して質問に答えれば終わりである。
$ cd brat-v1.3_Crunchy_Frog
$ ./install.sh
# brat にログインするユーザ名を新規設定する。
Please the user name that you want to use when logging into brat
editor
# パスワードを入れる。
Please enter a brat password (this shows on screen)
annotate
# メールアドレスを入れる。
Please enter the administrator contact email
example@brat.org

コーパスの準備

次にアノテーションを入れる対象のコーパスを data ディレクトリの下に用意する。ここでは livedoorニュースコーパスを使った例を示す。

$ wget http://www.rondhuit.com/download/ldcc-20120915.tar.gz
$ tar xvzf ldcc-20120915.tar.gz
コーパスファイルは UTF-8 で保存され、.txt の拡張子でなければならない。さらに、次のようにアノテーションを実行する .txt ファイルひとつひとつに対応する空の .ann ファイルをあらかじめ用意しておく必要がある。
$ find text -name '*.txt' | sed -e 's|¥.txt|.ann|g' | xargs touch

固有表現タグ設定と brat の起動

brat は係り受けのような構造的アノテーションもつけることができるが、本記事では固有表現タグをつける例を示す。デフォルトの状態でもいくつかの固有表現タグが設定されているが、ここでは独自タグを設定してみる。livedoorニュースコーパスを tar で展開したときにできた text ディレクトリの直下に、次のように2つの設定ファイルを作成する。

$ cd text
$ cat > annotation.conf <<EOF
> [entities]
> ORGANIZATION
> FACILITY
> PERSON
> TITLE
> LOCATION
>
> [relations]
> [events]
> [attributes]
> EOF
$
$ cat > visual.conf <<EOF
> [labels]
>
> [drawing]
> ORGANIZATION bgColor:#8fb2ff                                                                                                                
> FACILITY bgColor:#aaaaee                                                                                                                    
> PERSON bgColor:#ffccaa                                                                                                                      
> TITLE bgColor:#7fe2ff                                                                                                                       
> LOCATION bgColor:#6fffdf                                                                                                                    
> EOF
最初の設定ファイルはタグの設定、2つめはタグの色の設定である。なおこれらは必要最低限の設定である。設定ファイルを完璧に設定したい方は、brat ディレクトリ内に設定ファイルがあるので、そちらを参照していただきたい。

以上の設定が終われば、brat をスタンドアロンモードで起動できる。なお、brat は Python で書かれているので、Python の環境が必要である。
$ cd ../..
$ python standalone.py

brat へのログイン

上の起動時に表示された URL http://127.0.0.1:8001 にブラウザからアクセスする。すると、次のような画面が表示される。

brat 起動後

まだログインしていないのでここは一旦 [Cancel] をクリックする。そして上のブルーの部分にマウスポインタを持って行くと、右上にログインボタンが現れるのでクリックする。すると、次のログイン画面が表示されるので、前のインストール時に指定したユーザ情報(editor / annotate)を使ってログインする。

brat ログイン画面

brat を使ってテキストアノテーション

ログインしたらコーパスコレクションを選択する。それにはタブキーを押下するか、上のメニューから [Collection] を選んでクリックする。すると、次のコーパスコレクション選択画面になる。

brat コレクション選択画面

ここでは text ディレクトリの下の movie-enter ディレクトリの下から適当なファイルを選択する。すると、そのテキストファイルが表示されるので、その中の「宮崎あおい」に PERSON タグをつけるためマウスドラッグする。すると、次のようにタグ一覧がポップアップ表示される。

brat タグ一覧表示

ここでは当然 PERSON を選択して [OK] をクリックする。これを繰り返していくと、次のようにテキストがアノテーションされる。

brat によるアノテーション済みテキスト

シンプルなテキストアノテーション情報

このときすでに、サーバサイドの対応する .ann ファイルには次のようにアノテーション情報がシンプルなフォーマットで記録されている。

$ cat movie-enter-5840081.ann
T1	PERSON 81 86	宮崎あおい
T2	PERSON 87 90	堺雅人
T3	TITLE 1046 1048	さん
T4	PERSON 447 450	堺雅人
T5	PERSON 975 977	宮崎
T6	TITLE 977 979	さん
T7	PERSON 980 981	堺
T8	TITLE 981 983	さん
T9	PERSON 1021 1023	宮崎
T10	PERSON 1196 1197	堺
T11	PERSON 1245 1247	宮崎
T12	PERSON 1254 1255	堺
T13	PERSON 947 949	宮崎
タグをつけた順番で記録されているので、プログラムで読み込むときは、3〜4列でソートする必要があるだろう。

SolrMahoutのトレーニングコース、ただいま2月の受講者を募集中です!

Mahout トレーニングコース受講者のインタビュー記事はこちら

| 関口宏司 | NLP | 08:37 | comments(0) | trackbacks(0) |
CaboChaとKNBコーパスで固有表現抽出を行う
さらにCaboChaの評価は続く。今回はKNBコーパスを使って固有表現抽出の交差検定(5分割)を行った。

今回はprecisionやrecallをカウントする評価ツールも自作した。実際にどのようにカウントするかをフィクションのデータを例にした下表を使って説明する:

説明番号正解CaboCha出力カウント
形態素NE形態素NE
1自民党B-ORGANIZATION自民党B-ORGANIZATIONtp++
OO
2谷垣B-PERSON谷垣B-PERSONtp++
総裁O総裁O
OO
3B-DATEB-MONEYfn++, fp++
4I-DATEOfn++
夕方O夕方O
遅いO遅いO
昼食O昼食O
OO
5民主党食堂O民主党B-ORGANIZATIONfp++
食堂O
OO
とったOとったO
OO


  1. 「自民党」のNEタグが一致
  2. 「谷垣」のNEタグが一致
  3. 「6」のNEタグが不一致
  4. 「日」のNEタグが出力されず
  5. 「民主党」のNEタグが出力されたが不正解


以上から、このフィクションのデータの例では:

precision = tp / (tp + fp) = 2 / (2 + 2) = 0.5

recall = tp / (tp + fn) = 2 / (2 + 2) = 0.5

F = 2PR / (P + R) = 2 * 0.5 * 0.5 / (0.5 + 0.5) = 0.5 (ここで、P:precision, R:recall)

結果

結果は予想していた通りF値で40から60ポイント程度と低い値となった。原因としては、KNBコーパスはデータ量が多くないがその中でも固有表現タグはさらに数が限定されるためと考えられる。また、モデルファイルのとりかたによって固有表現タグの出現に濃淡があり、そのため正解率にばらつきがある。固有表現タグの種類(ORGANIZATIONやLOCATIONなど)にも数の多い少ないがあり(詳細はKNBコーパスの論文を参照)、これらも影響している可能性があるだろう。さらに、学習データを作成する際に、複数のNEタグ候補があるOPTIONALタグをそのままOPTIONALタグとして出力した。複数候補をCaboChaに学習させる方法がわからなかったためだが、これも学習データの量を減少させ、正解率を落とした原因といえる(このため、評価ツールでは正解データがOPTIONALの場合、fnをカウントしないこととした)。いずれにしてもデータ量が少ないのが残念だ。

モデルファイル名precisionrecallF
ne.juman.10.4596770.3630570.405694
ne.juman.20.5468750.5844160.565022
ne.juman.30.6043580.6085450.606444
ne.juman.40.5444920.5444920.544492
ne.juman.50.5540900.4988120.525000


今回作成したプログラム

KNBコーパスをNEタグ付きのCaboCha形式に変更するプログラム knbc2cabocha_ne.rb と 評価プログラム eval_ne.rb を作成したので掲載する:

knbc2cabocha_ne.rb

このプログラムでは、OPTIONALタグはそのままOPTIONALタグとして出力した。しかし、(おそらくは)CaboChaではOPTIONALタグはスキップしており(?)、OPTIONALの実際の候補であるLOCATIONやORGANIZATIONなどを出力すべきであると考えられる。そのため、次の評価プログラムでは正解データがOPTIONALの場合は、fnはカウントしないようにした。

KNBC_DIR = ARGV[0]

CATEGORIES = ["Keitai", "Kyoto", "Gourmet", "Sports"]

def ne(terms)
  ne_m = //, 1)
  b = ne_m.post_match.slice(/(¥w+):(¥w+)>/, 2)
  b = b == "head" || b == "single" ? "B" : "I"
  return "#{b}-#{type}"
end

CATEGORIES.each{|category|
  File.open("#{category}.dat", "w"){|ofile|
    Dir.glob(KNBC_DIR+"/KN???_#{category}_*").sort!.each{|article_dir_path|
      File.open("#{article_dir_path}/fileinfos"){|info_file|
        while info_line = info_file.gets
          File.open("#{article_dir_path}/#{info_line.split[1].split(/:/)[1]}"){|ifile|
            chunk_num = 0
            while line = ifile.gets
              next if /¥A# S-ID:/ =~ line # skip the comment line
              terms = line.split
              if terms[0] == "*"
#                ofile.puts "* #{chunk_num} #{terms[1]}"
                ofile.puts "* #{chunk_num} -1D"
                chunk_num += 1
              elsif terms[0] == "+"
                next
              elsif terms[0] == "EOS"
                ofile.puts "EOS"
              else
                ne_info = ne(terms)
                ofile.puts "#{terms[0]}¥t#{terms[3]},#{terms[5]},*,*,#{terms[2]},#{terms[1]},*¥t#{ne_info}"
              end
            end
          }
        end
      }
    }
  }
}


eval_ne.rb

def next_term_ne(file)
  while line = file.gets
    next if /¥A¥*/ =~ line
    return "EOS", nil if /¥AEOS/ =~ line
    terms = line.split
    return terms[0], terms[-1]
  end
  return nil, nil
end

def no_ne(ne_a)
  return true if ne_a.length == 1 && ne_a[0] == nil
  ne_a.each{ |ne|
    return false unless ne == "O"
  }
  true
end

def print_terms(ans_str_a, ans_ne_a, out_str_a, out_ne_a)
  if ans_str_a != out_str_a || ans_ne_a != out_ne_a
    puts "#{ans_str_a.join("/")}(#{ans_ne_a.join(",")}) #{out_str_a.join("/")}(#{out_ne_a.join(",")})"
  end
end

def num_ne(ne_a)
  return 0 if ne_a.length == 1 && ne_a[0] == nil
  num = 0
  ne_a.each{ |ne|
    num += 1 unless ne == "O"
  }
  num
end

def check(ans_str_a, ans_ne_a, out_str_a, out_ne_a, tp, fp, fn)
  if !no_ne(ans_ne_a)
    print_terms(ans_str_a, ans_ne_a, out_str_a, out_ne_a)
    if !no_ne(out_ne_a)
      i = 0
      while true
        ans_ne_c = i < ans_ne_a.length ? ans_ne_a[i] : ""
        out_ne_c = i < out_ne_a.length ? out_ne_a[i] : ""
        break if ans_ne_c == "" && out_ne_c == ""
        if ans_ne_c != "O" && out_ne_c != "O"
          if ans_ne_c == out_ne_c
            tp += 1
          else
            fn += 1 if ans_ne_c != "O" && ans_ne_c != "" && ans_ne_c.index("OPTIONAL") != nil
            fp += 1 if out_ne_c != "O" && out_ne_c != ""
          end
        end
        i += 1
      end
    else
      fn += num_ne(ans_ne_a)
    end
  elsif !no_ne(out_ne_a)
    fp += num_ne(out_ne_a)
    print_terms(ans_str_a, ans_ne_a, out_str_a, out_ne_a)
  end
  return tp, fp, fn
end

ans_file = File.open(ARGV[0])
out_file = File.open(ARGV[1])

tp = fp = fn = 0

while true
  ans_str_a = []
  ans_ne_a = []
  out_str_a = []
  out_ne_a = []
  ans_term, ans_ne = next_term_ne(ans_file)
  ans_str_a << ans_term
  ans_ne_a << ans_ne
  out_term, out_ne = next_term_ne(out_file)
  out_str_a << out_term
  out_ne_a << out_ne

  break if ans_term == nil || out_term == nil

  while out_str_a.join("").length != ans_str_a.join("").length
    if out_str_a.join("").length > ans_str_a.join("").length
      ans_term, ans_ne = next_term_ne(ans_file)
      ans_str_a << ans_term
      ans_ne_a << ans_ne
    elsif out_str_a.join("").length < ans_str_a.join("").length
      out_term, out_ne = next_term_ne(out_file)
      out_str_a << out_term
      out_ne_a << out_ne
    end
  end
  tp, fp, fn = check(ans_str_a, ans_ne_a, out_str_a, out_ne_a, tp, fp, fn)
end

ans_file.close
out_file.close

printf("tp = %d, fp = %d, fn = %d¥n", tp, fp, fn)

precision = tp.to_f / (tp.to_f + fp.to_f)
recall = tp.to_f / (tp.to_f + fn.to_f)
f = 2.0 * precision * recall / (precision + recall)

printf("precision = %f, recall = %f, F = %f¥n", precision, recall, f)


オペレーションメモ

# KNBコーパスをNEタグ付きCaboCha形式に変更。
$ ruby knbc2cabocha_ne.rb ../KNBC_v1.0_090925/corpus1

# NEタグ付きCaboCha形式のデータをマージしながら同程度の5つに分割。
$ ruby rearrange.rb 4 837 Keitai.dat Kyoto.dat Gourmet.dat Sports.dat

# モデルのソースを生成。
$  cat G0402.dat G0403.dat G0404.dat G0405.dat > model.source.ne.1
$  cat G0401.dat G0403.dat G0404.dat G0405.dat > model.source.ne.2
$  cat G0401.dat G0402.dat G0404.dat G0405.dat > model.source.ne.3
$  cat G0401.dat G0402.dat G0403.dat G0405.dat > model.source.ne.4
$  cat G0401.dat G0402.dat G0403.dat G0404.dat > model.source.ne.5

# NEを学習。
$ /usr/local/libexec/cabocha/cabocha-learn -e ne -P JUMAN model.source.ne.1 ne.juman.1
$ /usr/local/libexec/cabocha/cabocha-learn -e ne -P JUMAN model.source.ne.2 ne.juman.2
$ /usr/local/libexec/cabocha/cabocha-learn -e ne -P JUMAN model.source.ne.3 ne.juman.3
$ /usr/local/libexec/cabocha/cabocha-learn -e ne -P JUMAN model.source.ne.4 ne.juman.4
$ /usr/local/libexec/cabocha/cabocha-learn -e ne -P JUMAN model.source.ne.5 ne.juman.5

# CaboCha形式のデータから原文テキストを逆再生。
$ ruby make_source.rb 0401
$ ruby make_source.rb 0402
$ ruby make_source.rb 0403
$ ruby make_source.rb 0404
$ ruby make_source.rb 0405

# cabocha -e ne で固有表現抽出。
$ cat S0401-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.1 -O1 -f1 -n1 > O0401.txt
$ cat S0401-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.1 -O1 -f1 -n1 >> O0401.txt
$ cat S0401-03.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.1 -O1 -f1 -n1 >> O0401.txt
$ cat S0402-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.2 -O1 -f1 -n1 > O0402.txt
$ cat S0402-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.2 -O1 -f1 -n1 >> O0402.txt
$ cat S0402-03.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.2 -O1 -f1 -n1 >> O0402.txt
$ cat S0403-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.3 -O1 -f1 -n1 > O0403.txt
$ cat S0403-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.3 -O1 -f1 -n1 >> O0403.txt
$ cat S0403-03.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.3 -O1 -f1 -n1 >> O0403.txt
$ cat S0404-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.4 -O1 -f1 -n1 > O0404.txt
$ cat S0404-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.4 -O1 -f1 -n1 >> O0404.txt
$ cat S0404-03.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.4 -O1 -f1 -n1 >> O0404.txt
$ cat S0405-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.5 -O1 -f1 -n1 > O0405.txt
$ cat S0405-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.5 -O1 -f1 -n1 >> O0405.txt
$ cat S0405-03.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./ne.juman.5 -O1 -f1 -n1 >> O0405.txt

# 正解データと比較。
$ ruby eval_ne.rb G0401.dat O0401.txt
$ ruby eval_ne.rb G0402.dat O0402.txt
$ ruby eval_ne.rb G0403.dat O0403.txt
$ ruby eval_ne.rb G0404.dat O0404.txt
$ ruby eval_ne.rb G0405.dat O0405.txt


| 関口宏司 | NLP | 15:50 | comments(0) | trackbacks(0) |
CaboChaでKNBコーパスを使う
またまたCaboChaの続きで、今回は京都コーパスではなくKNBコーパス(KNBC)を使う。KNBコーパスは京都コーパスと異なり、タグ情報だけでなく本文情報も含まれているので、この記事の通りのオペレーションを行えば、(途中までは)毎日新聞データを持たない読者も実際にCaboChaの学習や評価を試すことができる。

KNBコーパスにはさまざまな特徴があるが、ここで注目するのは京都コーパスと比較しての下記の点である:

  • データ量が少ない。
  • (大学生が書いた)ブログ記事である。


KNBコーパスは4つのカテゴリ「携帯電話」「京都観光」「グルメ」「スポーツ」のどれかについて書かれたブログ記事に文境界、形態素、係り受け、格・省略・照応、固有表現、評価表現の各種アノテーションを付加した解析済みコーパスである。毎日新聞の記事データにアノテーションを付加した京都コーパスに比べデータ量が約一桁少ない(京都コーパス38400文に対し、KNBコーパスは4184文)。また、ブログ記事ということで新聞記事と比べて「文境界があいまい」「構文構造の解析を困難にする括弧表現」「誤字、方言、顔文字などの多様な形態素」というCGM的な特徴を有する。

このような特徴を踏まえ、今回は次のような評価を行った:

  1. [KNBC-KNBC] KNBコーパスの一部を学習して他の部分のKNBコーパスで評価を行う。
  2. [KNBC-KC] KNBコーパスの全部を学習して京都コーパス(毎日新聞記事)を解析してみる。
  3. [KC-KNBC] 京都コーパスを学習してKNBコーパス(ブログ記事)を解析してみる。


1.はKNBコーパス内部の学習・評価を行うものである。ここでは簡単に522文からなるスポーツカテゴリを評価用に残し、それ以外のカテゴリである携帯電話、京都観光、グルメの全合計3662文を学習することにした。量的には前の記事で作成した chunk.juman.10.1 モデルファイルでのテスト(3200文;エラー率0.2116)に近い量である。

2.と3.はブログ記事と新聞記事のクロス的・相互的な学習・評価のテストであり、ブログ記事を学習してモデルファイルを作成し新聞記事を解析した場合やその逆を行ってみて、解析性能がどの程度になるか見るものである。

結果

前の記事と比較のため、また、自作したツール類が再利用できることなどから、同様にCaboChaのchunkを対象に評価を行った。結果は下表の通り:

テスト名学習データ評価データエラー率備考
KNBC-KNBCブログ記事(携帯電話、京都観光、グルメ)
chunk.juman.knbc-knbc, model.source.knbc-knbc
ブログ記事(スポーツ)
G44.txt, S44-x.txt, O44.txt
0.2989京都コーパス3200文ではエラー率0.2116
KNBC-KC全ブログ記事
chunk.juman.knbc-kc, model.source.knbc-kc
毎日新聞記事
G1011.dat, S1011-x.dat, O1011.txt
0.3214
KC-KNBC京都コーパスchunk.juman.10.10ブログ記事(スポーツ)
G44.txt
0.2969同じモデルを新聞記事に適用した場合のエラー率は 0.1259


KNBC-KNBCテストでは、ブログ記事の3662文を学習したモデルが、量的にはそれよりも少ない京都コーパス3200文を学習したモデルよりもエラー率が高くなった。

KNBC-KCテストは全ブログ記事のコーパスを学習したモデルで新聞記事を解析するテストだが、やはりエラー率はさらに上がった。

KC-KNBCテストはKNBC-KCテストとは逆に、新聞記事である京都コーパスを学習したモデルでブログ記事を解析するテストであるが、同様にエラー率は高かった。

今回作成したプログラム

KNBコーパスのcorpus1ディレクトリを第一引数に指定するとコーパスデータをCaboCha形式のKeitai.dat, Kyoto.dat, Gourmet.dat, Sports.datのデータに出力する knbc2cabocha_chunk.rb というプログラムを作成した:

KNBC_DIR = ARGV[0]

CATEGORIES = ["Keitai", "Kyoto", "Gourmet", "Sports"]

CATEGORIES.each{|category|
  File.open("#{category}.dat", "w"){|ofile|
    Dir.glob(KNBC_DIR+"/KN???_#{category}_*").sort!.each{|article_dir_path|
      File.open("#{article_dir_path}/fileinfos"){|info_file|
        while info_line = info_file.gets
          File.open("#{article_dir_path}/#{info_line.split[1].split(/:/)[1]}"){|ifile|
            chunk_num = 0
            while line = ifile.gets
              next if /¥A# S-ID:/ =~ line # skip the comment line
              terms = line.split
              if terms[0] == "*"
#                ofile.puts "* #{chunk_num} #{terms[1]}"
                ofile.puts "* #{chunk_num} -1D"
                chunk_num += 1
              elsif terms[0] == "+"
                next
              elsif terms[0] == "EOS"
                ofile.puts "EOS"
              else
                ofile.puts "#{terms[0]}¥t#{terms[3]},#{terms[5]},*,*,#{terms[2]},#{terms[1]},*"
              end
            end
          }
        end
      }
    }
  }
}


オペレーションメモ

# KNBコーパスをCaboCha形式に変形しつつ、4つのカテゴリごとのファイルに分ける。
$ ruby knbc2cabocha_chunk.rb ../KNBC_v1.0_090925/corpus1

# 前の記事の習慣に合わせリネーム。
$  mv Keitai.dat G41.dat
$  mv Kyoto.dat G42.dat
$  mv Gourmet.dat G43.dat
$  mv Sports.dat G44.dat

# [KNBC-KNBC] モデルのソースを作成。
$ cat G4[1-3].dat > model.source.knbc-knbc

# [KNBC-KNBC] 学習。
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.knbc-knbc chunk.juman.knbc-knbc

# [KNBC-KNBC] CaboCha形式の学習データG44.datから原文テキストファイルS44-x.txtを逆再生
$ ruby make_source.rb 44

# [KNBC-KNBC] cabochaの実行。
$ cat S44-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.knbc-knbc -O2 -f1 -n0 > O44.txt
$ cat S44-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.knbc-knbc -O2 -f1 -n0 >> O44.txt

# [KNBC-KNBC] 正解データとcabochaの出力を比較用に正規化。
$ ruby norm_chunk.rb G44.dat > G44-norm.dat
$ ruby norm_chunk.rb O44.txt > O44-norm.txt

# [KNBC-KNBC] エラー率の表示。
$ ruby diff_chunk.rb G44-norm.dat O44-norm.txt 
error = 0.2989 (156/522)

# -----------

# [KNBC-KC] モデルのソースを作成。
$ cat G4[1-4].dat > model.source.knbc-kc

# [KNBC-KC] 学習。
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.knbc-kc chunk.juman.knbc-kc

# [KNBC-KC] cabochaの実行。
$ cat S1011-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.knbc-kc -O2 -f1 -n0 > O1011.txt
$ cat S1011-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.knbc-kc -O2 -f1 -n0 >> O1011.txt
:
$ cat S1011-16.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.knbc-kc -O2 -f1 -n0 >> O1011.txt

# [KNBC-KC] cabochaの出力を比較用に正規化。
$ ruby norm_chunk.rb O1011.txt > O1011-norm.txt

# [KNBC-KC] エラー率の表示。
$ ruby diff_chunk.rb G1011-norm.dat O1011-norm.txt 
error = 0.3214 (2057/6400)

# -----------

# [KC-KNBC] cabochaの実行。
$ cat S44-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.10 -O2 -f1 -n0 > O1044.txt
$ cat S44-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.10 -O2 -f1 -n0 >> O1044.txt

# [KC-KNBC] cabochaの出力を比較用に正規化。
$ ruby norm_chunk.rb O1044.txt > O1044-norm.txt

# [KC-KNBC] エラー率の表示。
$ ruby diff_chunk.rb G44-norm.dat O1044-norm.txt 
error = 0.2969 (155/522)
| 関口宏司 | NLP | 11:05 | comments(0) | trackbacks(0) |
モデルとなる京都コーパスの量を変えたときのCaboChaの性能を見る
CaboCha+京都コーパスの続きである。

前の記事ではdat/syn/ ディレクトリにある拡張子が.KNPのファイル28個を上から6個、6個、6個、5個、5個ずつまとめて交差検定を行った。すると、学習データの量に偏りが出たせいか、エラー率は12%から18%とばらつきがあった。

そこで今度は学習データのファイルを文の個数で正確に分割し、学習データの量とchunkの正解率の変化を見てみることにした。具体的には、データが38400文あったのでこれを12分割(すると38400/12=3200となる)し、最後の2つ分、6400文を正解データと固定して、残りの10個分をモデルファイルのソースとし、これを1つ分、2つ分、... と増やしていったときのエラー率に注目する。

結果は期待通り、データ量が増えるにしたがいエラー率が下がった:

モデルファイル名エラー率
chunk.juman.10.10.2116
chunk.juman.10.20.1861
chunk.juman.10.30.1708
chunk.juman.10.40.1598
chunk.juman.10.50.1509
chunk.juman.10.60.1448
chunk.juman.10.70.1383
chunk.juman.10.80.1341
chunk.juman.10.90.1272
chunk.juman.10.100.1259


ここで、chunk.juman.10.1は3200文、chunk.juman.10.2は6400文と学習データ量が増えていき、chunk.juman.10.10は32000文である。グラフ化すると次の通り:

京都コーパスの学習データ量を変えたときのCaboChaのchunkのエラー率

今回作成したプログラム

G5[1-5].datという5つのファイルから同じ数の文に再整理して出力する rearrange.rb というプログラムを作成した。

rearrange.rb
TARGET_NUM = ARGV[0].to_i
SENTENCE_NUM = ARGV[1].to_i

def target_file_name(num)
  sprintf("G%02d%02d.dat", TARGET_NUM, num)
end

$ai = 1
$ifile = nil
$end_of_sources = false

def next_source
  $ifile.close if $ifile
  $ai = $ai + 1
  return nil if $ai >= ARGV.length
  puts "reading #{ARGV[$ai]} ..."
  $ifile = File.open(ARGV[$ai])
end

def read_sentence
  sentence = Array.new
  while true do
    while line = $ifile.gets do
      sentence << line
      return sentence if /¥AEOS/ =~ line
    end
    unless next_source
      $end_of_sources = true
      return []
    end
  end
end

  return nil if $ai >= ARGV.length
  puts "reading #{ARGV[$ai]} ..."
  $ifile = File.open(ARGV[$ai])
end

def read_sentence
  sentence = Array.new
  while true do
    while line = $ifile.gets do
      sentence << line
      return sentence if /¥AEOS/ =~ line
    end
    unless next_source
      $end_of_sources = true
      return []
    end
  end
end

# create target files - for model files
next_source
TARGET_NUM.times {|tnum|
  snum = 0
  File.open(target_file_name(tnum+1), "w"){|ofile|
    while snum < SENTENCE_NUM
      read_sentence.each{|line|
        ofile.puts line
      }
      snum = snum + 1
    end
  }
}

# also create a target file - for evaluation
File.open(target_file_name(TARGET_NUM+1), "w"){|ofile|
  until $end_of_sources do
    read_sentence.each{|line|
      ofile.puts line
    }
  end
}


オペレーションメモ

# 学習データと評価用データの再作成
# 第1引数: ターゲット学習データの個数
# 第2引数: 1個あたりのターゲット学習データ内の文の個数
# 第3引数以降: CaboCha形式ソースデータファイル
$ ruby rearrange.rb 10 3200 G5?.dat

# モデルのソースを生成
$ cat G100[1].dat > model.source.10.1
$ cat G100[1-2].dat > model.source.10.2
$ cat G100[1-3].dat > model.source.10.3
$ cat G100[1-4].dat > model.source.10.4
$ cat G100[1-5].dat > model.source.10.5
$ cat G100[1-6].dat > model.source.10.6
$ cat G100[1-7].dat > model.source.10.7
$ cat G100[1-8].dat > model.source.10.8
$ cat G100[1-9].dat > model.source.10.9
$ cat G100[1-9].dat G1010.dat > model.source.10.10

# 学習。規模に応じてだんだん時間がかかる(20秒〜150秒程度)
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.1 chunk.juman.10.1
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.2 chunk.juman.10.2
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.3 chunk.juman.10.3
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.4 chunk.juman.10.4
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.5 chunk.juman.10.5
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.6 chunk.juman.10.6
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.7 chunk.juman.10.7
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.8 chunk.juman.10.8
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.9 chunk.juman.10.9
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.10.10 chunk.juman.10.10

# CaboCha形式の学習データG1011.datから原文テキストファイルS1011-x.txtを逆再生
$ ruby make_source.rb 1011

# 各モデルファイルを使い原文テキストS1011-x.datに対してcabochaを実行
$ cat S1011-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.1 -O2 -f1 -n0 > O10A01.txt
$ cat S1011-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.1 -O2 -f1 -n0 >> O10A01.txt
:
$ cat S1011-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.2 -O2 -f1 -n0 > O10A02.txt
$ cat S1011-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.10.2 -O2 -f1 -n0 >> O10A02.txt
:

# 正解データの正規化
$ ruby norm_chunk.rb G1011.dat > G1011-norm.dat

# cabochaの出力を正規化
$ ruby norm_chunk.rb O10A01.txt > O10A01-norm.txt
$ ruby norm_chunk.rb O10A02.txt > O10A02-norm.txt
:
$ ruby norm_chunk.rb O10A10.txt > O10A10-norm.txt

# モデルファイル量ごとの評価(エラー率の表示)
$ ruby diff_chunk.rb G1011-norm.dat O10A01-norm.txt 
error = 0.2116 (1354/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A02-norm.txt 
error = 0.1861 (1191/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A03-norm.txt 
error = 0.1708 (1093/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A04-norm.txt 
error = 0.1598 (1023/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A05-norm.txt 
error = 0.1509 (966/6400)
$ ruby .diff_chunk.rb G1011-norm.dat O10A06-norm.txt 
error = 0.1448 (927/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A07-norm.txt 
error = 0.1383 (885/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A08-norm.txt 
error = 0.1341 (858/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A09-norm.txt 
error = 0.1272 (814/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A10-norm.txt 
error = 0.1259 (806/6400)
| 関口宏司 | NLP | 12:31 | comments(0) | trackbacks(0) |
CaboChaと京都コーパスで交差検定
ロンドンオリンピック真っ最中で仕事がはかどらない中、日本語係り受け解析器CaboCha京都コーパスで交差検定を行ったのでメモを残す。

この作業の目的は、本件交差検定というよりはどちらかというとこの作業を通じてCaboChaの学習や評価のツールを正しく使えるように私自身が練習し、作業記録を残すことである。また、必要に応じてテキスト処理のためのツールを整備することも行う。

以下の作業では京都コーパスの方は毎日新聞のCD-ROMを用意し、付属のauto_convを実行した後のデータがあることを前提とする。なおこの方法で作成したモデルファイルは(そしてCaboChaが現在デフォルトで内包しているモデルファイルも)学術研究用に利用が限定されることをここで念のため書き添えておく。

CaboChaのツールで共通して使えるmode(-eオプション)はchunk,dep,neのうち、chunkを用いることにした。depは学習(cabocha-learnの実行)に4時間かけても終わらなかったため、残念ながら時間的な制約から今回は作業対象からはずした。また、入手できたデータにneタグがついていなかったため、neも同様に対象外とした。

文字コードはコーパスに合わせてEUCを選択。京都コーパスのdat/syn/ ディレクトリにある拡張子が.KNPの全ファイルをファイル単位で5分割して交差検定を行う。ファイル数は28個なのでこれを上から6個、6個、6個、5個、5個ずつまとめて各グループとし(下表)、5グループのCaboCha形式の学習データを得る。なお、5分割の5の根拠は特にない。

CaboCha形式の学習データKNP元データ
G51.dat950101.KNP, 950103.KNP, 950104.KNP, 950105.KNP, 950106.KNP, 950107.KNP
G52.dat950108.KNP, 950109.KNP, 950110.KNP, 950111.KNP, 950112.KNP, 950113.KNP
G53.dat950114.KNP, 950115.KNP, 950116.KNP, 950117.KNP, 9501ED.KNP, 9502ED.KNP
G54.dat9503ED.KNP, 9504ED.KNP, 9505ED.KNP, 9506ED.KNP, 9507ED.KNP
G55.dat9508ED.KNP, 9509ED.KNP, 9510ED.KNP, 9511ED.KNP, 9512ED.KNP


またG5N.datのCaboCha形式の学習データから、1カラム目の単語情報を抜き出して原文テキストを逆再生し、それをS5N-x.txtというテキストファイルにする。ここで"-x"の部分は"-01"から"-20"程度の値をとるように分割する(G5N.datを対応するひとつの原文テキストファイルにするとcabochaコマンドで解析するには大きすぎるため、複数個のS5N-x.txtファイルに分割している)。評価時のことも考慮すると、G5N.datはS5N-x.txtと対応する正解データのファイルG5N-eval-x.datに分けておく(ただし、評価は後述するようにCaboCha付属の評価ツールが使えなかったので、結果としてG5N-eval-x.datは不要になった)。これらのファイルを出力するプログラムmake_source.rbは別途自作した。

上記のCaboCha形式の学習データを1つ残して4つずつまとめて学習させ、下表の5通りのモデルファイルと正解データのペアを得る。

モデルファイル名CaboCha形式の学習データ正解データ
chunk.juman.1G52.dat, G53.dat, G54.dat, G55.datG51.dat
chunk.juman.2G51.dat, G53.dat, G54.dat, G55.datG52.dat
chunk.juman.3G51.dat, G52.dat, G54.dat, G55.datG53.dat
chunk.juman.4G51.dat, G52.dat, G53.dat, G55.datG54.dat
chunk.juman.5G51.dat, G52.dat, G53.dat, G54.datG55.dat


また、Juman辞書のバージョン(?)の違いのせいか、はたまた形態素解析器の違いのせいかどうかはわからないが、京都コーパスの形態素の単位と、今回cabochaで原文テキストを解析したときの形態素の単位が異なってしまい、結果としてCaboChaの評価ツールcabocha-system-evalが動かなかった(tree size is differentやToken size is differentというエラーになる)。形態素の単位が異なる部分としては、具体的には次のような部分である:

$ diff c.txt d.txt 
89,90c89
< 至ら	動詞,*,子音動詞ラ行,未然形,至る,いたら,*
< ない	接尾辞,形容詞性述語接尾辞,イ形容詞アウオ段,基本形,ない,ない,*
---
> 至らない	形容詞,*,イ形容詞アウオ段,基本形,至らない,いたらない,代表表記:至らない


この問題に対応するため、形態素を文節ごとにまとめて文節番号とともに出力し、1文で1行に出力するプログラムnorm_chunk.rbを自作した。このプログラムを使うと、CaboChaの出力を次のように正規化できる:

$ ruby norm_chunk.rb G51-eval-1.dat
(0)村山富市首相は(1)年頭に(2)あたり(3)首相官邸で(4)内閣記者会と(5)二十八日(6)会見し、(7)社会党の(8)新民主連合所属議員の(9)離党問題に(10)ついて(11)「政権に(12)影響を(13)及ぼす(14)ことには(15)ならない。(16)離党者が(17)いても、(18)その(19)範囲に(20)とどまると(21)思う」と(22)述べ、(23)大量離党には(24)至らないとの(25)見通しを(26)示した。
:


そして評価は、CaboChaの出力と正解データの出力をともにnorm_chunk.rbにかけ、それらをdiffで比較することで異なる文の数(異なる行数)をカウントすることで評価することにした。異なる部分の例として、diff出力の一部を以下に記す:

$ diff a-diff-source.txt G51-diff-source.txt
46c46
< (0)世界が(1)アッと(2)驚く(3)若い(4)首相が(5)誕生し、(6)がんじがらめの(7)規制が(8)たった(9)一本の(10)法律で(11)撤廃された(12)――新春の(13)初夢であり、(14)期待です。
---
> (0)世界が(1)アッと(2)驚く(3)若い(4)首相が(5)誕生し、(6)がんじがらめの(7)規制が(8)たった(9)一本の(10)法律で(11)撤廃された――(12)新春の(13)初夢であり、(14)期待です。
:


ただ、正解データとcabochaの出力の異なる文を表示するにはdiffコマンドは便利だが、両方のテキストファイルを比べて正解率(文節がすべて一致する文の割合)を見るには不便なので、別途この目的だけに使える簡易diffプログラムdiff_chunk.rbを作成した。

今回作成したプログラム

前述の通り、今回の作業のため以下のmake_source.rb, norm_chunk.rbおよびdiff_chunk.rbの3つのプログラムを自作したのでそれを記す:

make_source.rb

SPS = 400 # number of Sentences Per Source

ifname = "G#{ARGV[0]}.dat"
$osfprefix = "S#{ARGV[0]}"
$oefprefix = "G#{ARGV[0]}-eval"
$onum = 0

def next_out_files
  $onum = $onum + 1
  ofname1 = sprintf("%s-%02d.txt", $osfprefix, $onum)
  ofname2 = sprintf("%s-%02d.dat", $oefprefix, $onum)
  return File.open(ofname1,"w"), File.open(ofname2,"w")
end

# osfile : output source file
# oefile : output eval file
osfile, oefile = next_out_files
count = 0 # EOS(sentence) counter

File.open(ifname){ |ifile|
  while line = ifile.gets
    oefile.puts line
    next if /¥A¥*/ =~ line
    if /¥AEOS/ =~ line
      osfile.print "¥n" # print CRLF for EOS
      count = count + 1
      if count > SPS
        osfile.close
        oefile.close
        osfile, oefile = next_out_files
        count = 0
      end
      next
    end
    osfile.print line.split(nil)[0]
  end
}

osfile.close
oefile.close


norm_chunk.rb

ifname = ARGV[0]

chunk = ""
chunk_num = 0

File.open(ifname){ |ifile|
  while line = ifile.gets
    if /¥A¥*/ =~ line
      print "(#{chunk_num})#{chunk}" unless chunk.empty?
      chunk = ""
      chunk_num = line.split(nil)[1]
    elsif /¥AEOS/ =~ line
      print "(#{chunk_num})#{chunk}¥n" unless chunk.empty?
      chunk = ""
    else
      chunk = chunk + line.split(nil)[0]
    end
  end
}


diff_chunk.rb

ifname1 = ARGV[0]
ifname2 = ARGV[1]
out_diff = ARGV[2] ? true : false

sentences = 0
diffs = 0

File.open(ifname1){ |ifile1|
  File.open(ifname2){ |ifile2|
    while line1 = ifile1.gets
      line2 = ifile2.gets
      sentences = sentences + 1
      if line1 != line2
        if out_diff
          puts line1
          puts line2
          puts "---"
        end
        diffs = diffs + 1
      end
    end
  }
}

error_rate = diffs.to_f / sentences.to_f
printf("error = %1.4f (%d/%d)¥n", error_rate, diffs, sentences)


交差検定の結果

前述のdiff_chunk.rbで正解データとcabochaの出力を各文ごとに比較し、すべての文節が一致するものを正解、それ以外を不正解(エラー)とし、エラーの文の数が全体の文の数に占める割合をエラー率とすると以下のようになった:

モデルファイルエラー率
chunk.juman.10.1821
chunk.juman.20.1688
chunk.juman.30.1556
chunk.juman.40.1297
chunk.juman.50.1254


エラー率は12%から18%となった。28個のKNPファイルから5グループのモデルファイルを作成したわけだが、学習データを「多く」使えたchunk.juman.4とchunk.juman.5(6x3+5=23個のKNPファイル)はエラー率が12%程度と低く、学習データが「少ない」それ以外のモデル(6x2+5x2=22個のKNPファイル)ではエラー率が15%から18%程度となった(ただし、学習データ量の「多い」「少ない」は、KNPファイルの個数なので正確ではない)。

オペレーションメモ

以下にまっさらなUbuntu Server 10.04.3にCaboChaのインストールから本件交差検定を行ったときのオペレーションを示す。

# 必要ツール類のインストール
$ sudo apt-get openssh-server install build-essential nkf emacs23 unzip ruby

# CRF++ のインストール
$ wget http://crfpp.googlecode.com/files/CRF%2B%2B-0.57.tar.gz
$ tar xvzf CRF++-0.57.tar.gz
$ cd CRF++-0.57
$ ./configure
$ make
$ sudo make install

# MeCab のインストール
$ wget http://mecab.googlecode.com/files/mecab-0.994.tar.gz
$ tar xvzf mecab-0.994.tar.gz
$ cd mecab-0.994
$ ./configure --with-charset=euc
$ make
$ make check
$ sudo make install
$ sudo ldconfig

# MeCab Juman辞書のインストール
$ wget http://mecab.googlecode.com/files/mecab-jumandic-5.1-20070304.tar.gz
$ tar xvzf mecab-jumandic-5.1-20070304.tar.gz
$ cd mecab-jumandic-5.1-20070304
$ ./configure --with-charset=euc-jp
$ make
$ sudo make install

# MeCab Perlバインディングのインストール
$ wget http://mecab.googlecode.com/files/mecab-perl-0.994.tar.gz
$ tar xvzf mecab-perl-0.994.tar.gz
$ cd mecab-perl-0.994
$ perl Makefile.PL
$ make
$ sudo make install

# MeCab+Juman 辞書の実行(ここまでの動作確認)
# 「すもももももももものうち」を解析するも、Jumanだと「すもも」が「す股」と解釈される(笑)
$ mecab -d /usr/local/lib/mecab/dic/jumandic
すもももももももものうち
す	接頭辞,名詞接頭辞,*,*,す,す,*
もも	名詞,普通名詞,*,*,もも,もも,代表表記:股
も	助詞,副助詞,*,*,も,も,*
もも	名詞,普通名詞,*,*,もも,もも,代表表記:股
も	助詞,副助詞,*,*,も,も,*
もも	名詞,普通名詞,*,*,もも,もも,代表表記:股
の	助詞,接続助詞,*,*,の,の,*
うち	名詞,副詞的名詞,*,*,うち,うち,*
EOS
^D

# CaboChaのインストール(Juman品詞セット)
$ wget http://cabocha.googlecode.com/files/cabocha-0.64.tar.gz
$ tar xvzf cabocha-0.64.tar.gz
$ cd cabocha-0.64
$ ./configure --with-charset=EUC-JP --with-posset=JUMAN
# make時のエラーを避けるため、Makefileを編集して
# CABOCHA_MODEL_LIST および CABOCHA_TXTMODEL_LISTから*.ipa.*のメンバーを除く
$ emacs Makefile model/Makefile
$ sudo ldconfig
$ make
$ make check
$ sudo make install
$ sudo ldconfig

# CaboChaの動作確認
$ cabocha -d /usr/local/lib/mecab/dic/jumandic
オリンピックがいよいよロンドンで開幕した。
                 オリンピックが-----D
                         いよいよ---D
    ロンドンで-D
                           開幕した。
EOS
^D

# dat/syn/*.KNP のファイルをCaboCha形式に変換するとともに、前述の5グループにまとめる
$ ../cabocha-0.64/tools/kc2juman.pl $(ls -1 *.KNP|head -n 6) > G51.dat
$ ../cabocha-0.64/tools/kc2juman.pl $(ls -1 *.KNP|head -n 12|tail -n 6) > G52.dat
$ ../cabocha-0.64/tools/kc2juman.pl $(ls -1 *.KNP|head -n 18|tail -n 6) > G53.dat
$ ../cabocha-0.64/tools/kc2juman.pl $(ls -1 *.KNP|head -n 23|tail -n 5) > G54.dat
$ ../cabocha-0.64/tools/kc2juman.pl $(ls -1 *.KNP|head -n 28|tail -n 5) > G55.dat

# 正しくCaboCha形式のファイルになっているか、評価ツールを使って確認
$ /usr/local/libexec/cabocha/cabocha-system-eval -e chunk G51.dat G51.dat 
             precision              recall             F
Chunk: 100.0000 (63596/63596) 100.0000 (63596/63596) 100.0000
$ /usr/local/libexec/cabocha/cabocha-system-eval -e dep G51.dat G51.dat 
dependency level0: 100.0000 (63596/63596)
dependency level1: 100.0000 (57133/57133)
dependency level2: 100.0000 (50706/50706)
sentence         : 100.0000 (6463/6463)

# 5つのCaboCha形式の学習データを、4つと1つの組に分ける、次フェーズのモデルのソースを5通り作成。
$ cat G52.dat G53.dat G54.dat G55.dat > model.source.1
$ cat G51.dat G53.dat G54.dat G55.dat > model.source.2
$ cat G51.dat G52.dat G54.dat G55.dat > model.source.3
$ cat G51.dat G52.dat G53.dat G55.dat > model.source.4
$ cat G51.dat G52.dat G53.dat G54.dat > model.source.5

# 学習(chunk モデルファイルの作成)x5回(それぞれ150秒ほどかかる)
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.1 chunk.juman.1
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.2 chunk.juman.2
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.3 chunk.juman.3
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.4 chunk.juman.4
$ /usr/local/libexec/cabocha/cabocha-learn -e chunk -P JUMAN model.source.5 chunk.juman.5

# CaboCha形式の学習データG5N.datから対応する原文テキストファイルS5N-x.txtを逆再生し、
# 同時に対応する正解データG5N-eval-x.datを作成する。
$ ruby make_source.rb 51
$ ruby make_source.rb 52
$ ruby make_source.rb 53
$ ruby make_source.rb 54
$ ruby make_source.rb 55

# 原文テキストを試しにCaboChaにかけてみる(デフォルトのモデルファイルを使用)
$ cat S51-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -O2 -f1 -n0 > a.txt
# 同じことを、前フェーズで作成したモデルファイルに切り替えてやってみる。
$ cat S51-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.1 -O2 -f1 -n0 > b.txt

# 上記の結果をCaboCha付属の評価ツールにかけようとすると、前述のエラーが出てしまう。
# (a.txt を b.txt にしてももちろん同じ)
$ /usr/local/libexec/cabocha/cabocha-system-eval -e chunk a.txt G51-eval-01.dat 
eval.cpp(133) [tree1.token_size() == tree2.token_size()] Token size is different

# 実際にcabochaを実行
# 原文テキスト S5N-x.txt に対し、モデルファイル chunk.juman.N の切り替えと
# 結果ファイル O5N.txt にAPPENDすることに注意しながら手作業で(!)行う
# (もちろん、プログラムを書ければ書いた方が実行は楽)
$ cat S51-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.1 -O2 -f1 -n0 > O51.txt
$ cat S51-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.1 -O2 -f1 -n0 >> O51.txt
:
$ cat S52-01.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.2 -O2 -f1 -n0 > O52.txt
$ cat S52-02.txt | cabocha -d /usr/local/lib/mecab/dic/jumandic -M ./chunk.juman.2 -O2 -f1 -n0 >> O52.txt
:

# 簡易評価ツール diff_chunk.rb で正解データとcabochaの出力を比較するため、
# 両者のファイルを正規化
$ ruby norm_chunk.rb G51.dat > G51-norm.dat
$ ruby norm_chunk.rb O51.txt > O51-norm.txt
$ ruby norm_chunk.rb G52.dat > G52-norm.dat
$ ruby norm_chunk.rb O52.txt > O52-norm.txt
$ ruby norm_chunk.rb G53.dat > G53-norm.dat
$ ruby norm_chunk.rb O53.txt > O53-norm.txt
$ ruby norm_chunk.rb G54.dat > G54-norm.dat
$ ruby norm_chunk.rb O54.txt > O54-norm.txt
$ ruby norm_chunk.rb G55.dat > G55-norm.dat
$ ruby norm_chunk.rb O55.txt > O55-norm.txt

# 評価
$ ruby diff_chunk.rb G51-norm.dat O51-norm.txt
error = 0.1821 (1177/6463)
$ ruby diff_chunk.rb G52-norm.dat O52-norm.txt 
error = 0.1688 (1446/8567)
$ ruby diff_chunk.rb G53-norm.dat O53-norm.txt 
error = 0.1556 (1219/7833)
$ ruby diff_chunk.rb G54-norm.dat O54-norm.txt 
error = 0.1297 (1034/7975)
$ ruby diff_chunk.rb G55-norm.dat O55-norm.txt 
error = 0.1254 (948/7562)

# なお、diff_chunk.rb の第3引数に"on"(なんでもよい)を指定すると、以下のように異なる文を表示する。
$ ruby diff_chunk.rb G51-norm.dat O51-norm.txt on
:
(0)旧満州・奉天生まれ。
(0)旧満州・奉(1)天(2)生まれ。
---
error = 0.1821 (1177/6463)
| 関口宏司 | NLP | 17:38 | comments(0) | trackbacks(0) |
Lucene/Solr インデックスの中を覗いてジップの法則を確認する
単語の出現頻度の順位nと出現確率P(n)の間には、ジップの法則(Zipf's Law)と呼ばれる次の経験則が成り立つ:

P(n) = c / n


ジップは英語の場合、c=0.1であると推定している。

単語の出現頻度などはLucene/Solrのインデックスを調べれば簡単にわかるので、早速手元のインデックス(歴代の内閣総理大臣の所信表明演説が登録されている)でやってみたところ、下図のように見事に直線上に並んだ。ただし、c=0.162とした:

ZipfsLaw

プログラムは以下の通り。試すときは、INDEXとFのString定数に、それぞれインデックスの絶対パスと、調べる対象のフィールド名を指定する。
public final class TestZipfsLaw {
  
  final static String INDEX = "/path/to/index";
  final static String F = "fieldNameToBeChecked";

  public static void main(String[] args) throws Exception {
    Directory dir = getDir();
    List tfList = getTermFreqList(dir);
    dir.close();
    
    Collections.sort(tfList, new Comparator<TermFreq>() {
      public int compare(TermFreq o1, TermFreq o2) {
        return o1.freq == o2.freq ? 0
            : o1.freq < o2.freq ? 1 : -1;
      }
    }
    );
    
    int currentFreq = Integer.MAX_VALUE;
    int ranking = 0;
    for(TermFreq tf : tfList){
      ranking++;
      if(tf.freq < currentFreq){
        //System.out.println( ranking + ", term = " + tf.term + ", totalFreq = " + tf.freq );
        System.out.println( ranking + ", " + tf.freq );
        currentFreq = tf.freq;
      }
      //System.out.println( "term = " + tf.term + ", totalFreq = " + tf.freq );
    }
  }
  
  static Directory getDir() throws IOException {
    File dirFile = new File(INDEX);
    Directory dir = FSDirectory.open(dirFile);
    return dir;
  }

  static List getTermFreqList(Directory dir) throws IOException {
    IndexReader reader = IndexReader.open(dir);
    List results = new ArrayList();
    TermEnum te = reader.terms(new Term(F,""));
    while(true){
      Term t = te.term();
      int totalFreq = 0;
      if(t == null || !t.field().equals(F))
        break;
      TermPositions tp = reader.termPositions(t);
      while(tp.next()){
        totalFreq += tp.freq();
      }
      //System.out.println( "term = " + t.text() + ", totalFreq = " + totalFreq );
      results.add(new TermFreq(t.text(), totalFreq));
      tp.close();
      if(!te.next()) break;
    }
    te.close();
    reader.close();
    
    return results;
  }

  static class TermFreq {
    final String term;
    final int freq;
    public TermFreq(String term, int freq){
      this.term = term;
      this.freq = freq;
    }
  }
}


実行すると、標準出力に次のように2列の数値が表示されるので、これをExcelに貼り付けグラフを表示すると、上手のようになる(はず)。2列の数値の1列目は単語の出現頻度の順位で、2列目は出現回数である。
1, 4483
2, 2415
3, 1239
4, 1228
5, 1091
:    :




元ヤフー社員も大満足のロンウイットのSolrトレーニング・・・受講者インタビュー記事
Solr 3.6 7月 トレーニング受講者募集中

| 関口宏司 | NLP | 00:27 | comments(0) | trackbacks(0) |
+ Solrによるブログ内検索
+ PROFILE
 123456
78910111213
14151617181920
21222324252627
28293031   
<< October 2018 >>
+ LINKS
検索エンジン製品 - 比較のポイント
商用検索エンジンを購入した企業担当者は読まないでください。ショックを受けますから・・・
>>製品比較 10のポイント
+ Lucene&Solrデモ
+ ThinkIT記事
+ RECOMMEND
Apache Solr入門 ―オープンソース全文検索エンジン
Apache Solr入門 ―オープンソース全文検索エンジン (JUGEMレビュー »)
関口 宏司,三部 靖夫,武田 光平,中野 猛,大谷 純
+ RECOMMEND
Lucene in Action
Lucene in Action (JUGEMレビュー »)
Erik Hatcher,Otis Gospodnetic,Mike McCandless
FastVectorHighlighterについて解説記事を寄稿しました。
+ RECOMMEND
+ SELECTED ENTRIES
+ RECENT COMMENTS
+ RECENT TRACKBACK
+ CATEGORIES
+ ARCHIVES
+ MOBILE
qrcode
+ SPONSORED LINKS