関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
スポンサーサイト

一定期間更新がないため広告を表示しています

| スポンサードリンク | - | | - | - |
Solr あるある

弊社は Apache Solr に関する教育/コンサルティング/サポートの各サービスを提供しているが、業務を通じてユーザーによる Solr の残念な使い方に遭遇することもたびたびある。ここではそれを「Solr あるある」と総称して紹介してみよう。なお、大抵の話は Apache Lucene にも当てはまるがここでは簡単に「Solr あるある」と記載することにする。



前方一致検索、中間一致検索

フィールド f1 が abc で始まるものを検索するのに q=f1:abc* としたり、abc がフィールド f1 内に現れるものを検索するのに q=f1:*abc* とするのは誤りである。一般的に前者は前方一致検索、後者は中間一致検索などと呼ばれるが、転置インデックス方式の検索エンジンでは検索対象の最小単位が単語となり、単語の完全一致が基本となる。したがって、「前方一致検索」「中間一致検索」というような概念がない。検索対象フィールドが単語の場合は完全一致検索となるし、2単語以上のフィールドであれば中間一致検索となる。したがって、中間一致検索(単語の完全一致)をしたいのであれば、q=f1:*abc* とはせずに q=f1:abc とすればよろしい。q=f1:*abc* で検索できているように見えているのは、いろいろなパターンが考えられるが、文字列解析の過程で * が落とされて結果的に abc という単語検索になっている、などが考えられる。


では前方一致はどうだろう。前方一致検索をしたい場合は、転置インデックスの作り方を工夫する。具体的には EdgeNGramTokenizer(場面によっては EdgeNGramTokenFilter)を使ってインデックスを作成する。これを使うと、abcdefg というフィールド文字列は次のように単語分割され、インデックスが作られる。


a ab abc abcd abcde abcdef abcdefg

このフィールドに対し、abc という文字列で前方一致検索をしたければ、クエリ側はトークナイズせず、つまり KeywordTokenizer を使って(場合によっては LowerCaseFilter を組み合わせてもよい。その場合はインデックス側にも適用する。以下同じ)次のように検索する。

q=f1:abc

単語の完全一致検索が基本の検索エンジンで従来型の「前方一致検索」「中間一致検索」をしたい場合、* を使う検索は Solr あるあるであるが、気持ちはわかるが間違いである。これは次で述べる後方一致検索でも同様である。



後方一致検索

同じく、フィールド値が abc で終わる文書を探すという意味を込めて後方一致検索をしたいというので q=f1:*abc というクエリを発行するのも Solr あるあるであるが、誤りである。Lucene の転置インデックスは単語がソートされた状態で格納されていることを利用して二分探索される。したがって、単語の末尾が一致しているかどうかを確認するには、転置インデックス上の単語を総なめしなければならなくなってしまう。そのため、高速検索を信条とするLuceneにはそのような検索は用意されていない(APIを使って総なめすることは可能)。では Lucene/Solr で後方一致検索はできないのかというとそんなことはなく、「文字列を逆転させて前方一致にする」というテクニックを使う。具体的にはインデックス側、クエリ側双方で ReverseStringFilter を適用する。


インデックス側では、KeywordTokenizer+ReverseStringFilter+EdgeNGramTokenFilter を用いる。すると、abcdefg という文字列は、次のようにインデックス中に展開される。


g gf gfe gfed gfedc gfedcb gfedcba

クエリ側は KeywordTokenizer+ReverseStringFilter を適用する。すると、efg という文字列の後方一致検索は、q=f1:efg と投げればよい。すると当該フィールドで文字列が逆転された上で検索されることで、gfe がヒットする。



ファセットによる絞り込み検索で fq を使わない

ファセットによる絞り込み検索で fq を使わず、q パラメータに AND で絞り込み条件を追加していくのもよく見る Solr あるあるであるが、これも誤りである。fq による検索結果の絞り込みも、AND 条件追加による検索の絞り込みも、返される文書集合は同じである。しかし文書の順番(ランキング)を見てみれば両者は異なることがわかる。メインのクエリ(q パラメータ)に AND で絞り込み条件を追加してしまうと、それはスコア計算の対象になってしまうので、AND を使う方法だと絞り込むたびにランキングが異なってしまう。つまりユーザーから見ると、ファセットのリンクをクリックするたびに文書の表示順が異なってしまう。これではユーザーが混乱するだろう。また、filterCache がうまく使われない、という別の問題もはらんでいる。


ファセットによる絞り込み検索では、AND を使わず fq を使う、と覚えておこう。



スコアによる足切り

「スコアが0.5未満の文書は足切りして返却されないようにしたい」などといった要望も Solr あるあるであるが、これもナンセンスであり、実施しようとして奮闘すること自体、時間の無駄である。


スコア関数のパラメーターはそれがベクトル空間モデルベースの TFIDFSimilarity であれ、確率モデルベースの BM25Similarity であれ、クエリと各文書である。スコアはクエリを固定したときに、各文書に付与される当該クエリとの関連度合いを表現したものであるから、クエリが変わってしまうと相互に比較することに意味がない。クエリ A でスコア 0.4 を獲得した文書がクエリ A に非常に関連しているが、クエリ B でスコア 0.6 を獲得した文書がクエリ B にたいして関連しているとは見えない、ということがおおいにありえる。



自前のハイライト

ハイライト機能(検索キーワードを含む文書スニペットを切り出し、キーワードを太字など目立つように表示する機能)について、Solr が持っているハイライト機能を使わずに、Webアプリケーション(JavaScript含む)でがんばってしまう、というのもたまに見る Solr あるあるである。Solr にハイライト機能があることを知らずに自前で実装してしまうパターン、Solrにハイライト機能があることは知っているが、自前の方がよいと信じて自前で実装してしまうパターンがあるようだ。前者は知らなかったということでお気の毒という他ない。後者は、なぜそのような発想になるのかよくわからない。Solr のハイライト機能はそれほどよくできている。自前で頑張る方式のメリットをあえて上げるならば、たとえば、サーバ側のCPU負荷を下げるため、ユーザーのブラウザにてJavaScriptでハイライトする場合、が考えられるかもしれない。しかしそのメリットの裏で、次の機能を備える Solr の非常によくできたハイライト機能を使わないデメリットをアプリケーションに実装してしまっていることに気づいているだろうか。


  • 検索キーワードがなるべく多く含まれる任意長のスニペットを、任意の個数得ることができる。Solr ではスニペットにもスコアがつけられ、スコアの高いスニペットから返される。同じ機能をアプリケーションで頑張ろうとすると、スニペットを作成するデータソースをすべて転送してこなければならない。その転送元は Solr かもしれないし、RDB かもしれないが、Solr のハイライト機能で作成したスニペットよりも大きなサイズになることが多く、ネットワーク負荷の原因となる。
  • Solr では表記揺れを考慮した検索が可能であるが、ハイライトでも同じ表記揺れを考慮した上でハイライト表示が可能である。アプリケーションで頑張る場合は、同じ表記揺れ対応をしなければ検索にヒットしてもハイライト表示ができない場合が出てきてしまう。また対応する表記揺れルールが変わった場合、Solr でやっていれば設定ファイルの変更で検索もハイライトもどちらも対応できてしまうが、アプリケーションで頑張る方式の場合は、Solr の設定変更がアプリケーションの方に影響してしまうため管理が煩雑になる。
  • Solr のスニペットは単語境界区切りなど、自然な単位でスニペット生成ができるようになっている。さらに、Solr では検索キーワードがフレーズの場合、フレーズが一致している場合のみハイライトさせたり、単語単独でハイライトさせたりといった細かな制御が可能である。同様のことをアプリケーションでやらせようとすれば、簡易であれNLP的機能をアプリケーションに持たせなければならない。
  • Solr ではスニペットが作れない場合の代替表示を指定することができる(大抵はハイライト表示しようとする当該フィールドの先頭100文字など)。

ということで、Solr のハイライト機能は検索アプリケーションのことをよく考慮して用意されており、大変よくできている。自前で用意するというのはたいていの場合誤っていると断言できる。


なお、検索結果一覧表示時にハイライトスニペットを作成せず、ユーザーのクリックにより当該文書表示時に検索キーワードを太字など目立つように表示する機能は、ここでいうハイライト機能とは異なる。ここでいうハイライト機能は、検索結果一覧表示時に、ユーザーに適切な文書をすばやく選択してもらうためのアシスト機能に相当する。ユーザーはハイライトスニペットを、文書をクリックするかどうかの参考にするため、Solr のハイライト機能はぜひ積極的に利用しよう(ただし、商品の写真を検索結果一覧に表示するようなECサイトの検索アプリのようなものは除く)。



ここで上げた以外にも、RDB の正規化されたテーブルをそのまま Solr のインデックススキーマにマッピングし Solr の JOIN を使うあるあるや、親子階層のファセットを自前で頑張るあるある、クエリを投げる際に常にダブルクォーテーションでくくってしまうあるあるなどもある。



Solr は OSS になって10年を超えるソフトウェアとなった。機能も十分豊富で、やりたいことの多くはすでに Solr が持っていることも多い。アプリケーションで頑張る前にぜひマニュアルや書籍等で該当する機能がないか、使い方がアーキテクチャに沿ったものになっているか、今一度考えてみることをお勧めしたい。



さて、今年はおそらく本記事が最後となるでしょう。更新頻度が少なくなったのにもかかわらず、本年もご愛読いただきありがとうございました。皆様、よいお年をお迎えください!

| 関口宏司 | Solr | 18:56 | comments(5) | trackbacks(0) |
LUCENE-6819: Good bye index-time boost

Lucene/Solr 7.0 のリリースの投票がまもなく始まろうとしている。

今回私が気になっているのが、LUCENE-6819 による修正である。
Deprecate index-time boosts?
https://issues.apache.org/jira/browse/LUCENE-6819

タイトルだけ見ると、「インデクシング時ブースト(重み付け)を止めよう」、もっと深読みすると「クエリ時に重み付けが指定できるからインデクシング時に重み付けしなくてもいんじゃね?」ととらえてしまう方もいるかもしれない。もちろん、インデクシング時重み付けとクエリ時重み付けでは、大きく意味や目的が異なる。[1]

本記事では、LUCENE-6819 と関連する他の修正と合わせ、Lucene 7.0 以降で fieldNorm がどのようになるか、簡単に解説しよう。

LUCENE-6819

このチケットでは、タイトル通り、インデクシング時のboostを止めよう、というものである。もう少し細かく言うと、fieldNorm という、各ドキュメントの各フィールド(ただし、omitNorms=trueのフィールドを除く)ごとに1バイトでエンコーディングされた値の、次の式からboostを取り除こう、というものである。

fieldNorm = lengthNorm * boost

ちなみにこの式は、BM25Similarity が登場する以前の、Lucene/Solr の標準であったベクトル空間モデルによるランキング計算(各ドキュメントのクエリに対する類似度計算)に強く依存している。Lucene/Solr はコサイン類似度ベースだが、上のように計算された fieldNorm を加味しており、BM25 で考慮されている文書長はとっくに加味されている。[2]

さて、これからboostを除けば、次のようになる。

fieldNorm = lengthNorm

lengthNorm はデフォルトで次のように計算される。

lengthNorm = 1 / sqrt(numTerms)

numTermsは当該フィールドの単語数である。したがって、lengthNorm は単語数が1の時は 1 / sqrt(1) = 1 / 1 = 1、単語数が2の時は 1 / sqrt(2) = 1 / 1.414213562373095… = 0.70710678118655、という具合になるので、0 < lengthNorm <= 1 となる。しかし、Lucene 6.x 以前では、boost が lengthNorm にかかった fieldNorm を保存しなければならず、boost の値はユーザの設定次第なので最悪 Float.MAX_VALUE になってしまうことも考えられる。そこで 0 から Float.MAX_VALUE の広大な値域をわずか1バイトにマッピングしているのが Lucene 6.x である(下図)。

LUCENE-6819

しかし、ほとんどのユーザーはインデクシング時重み付けなどしないので、上図グレーの部分がもったいない。boostがかけられる可能性を考えて1バイトの半分を残しているのだが、ほとんどのユーザーはboost=1なので、非常にもったいない、かつ、低精度な使い方になっている。

そこで、LUCENE-6819 では:

  • Lucene 6.x では boost 設定と参照に係わる API は @deprecated とし、boostに1でない値が投入されようとすると、「将来バージョンで使えなくなる」旨の警告が表示される。
  • Lucene 7.0 では、boost 設定と参照に係わる API から boost 引数を取り除き、fieldNorm には lengthNorm そのものが記録されるようになった。
という修正が行われた。

LUCENE-7730

LUCENE-6819 ではまだ fieldNorm の1バイトが有効活用されていない。そこでこのチケットでは、フィールド長(単語数)をSmallFloat.intoTobyte4(numTerms)でエンコードして保存するように修正された。たとえば単語数が1000のときは、SmallFloat.intoTobyte4(1000)が87となり、これが記録される。デコードにはSmallFloat.byte4ToInt()が使われる。SmallFloat.byte4ToInt(87)は984となる。ちなみに、1〜40はエンコードしても1〜40を出力する(変わらない)。長さ41から徐々にエンコードによる誤差が大きくなる(下図)。

LUCENE-7730

Lucene 6.x では boost / sqrt(numTerms) の計算結果が1バイトに圧縮されて記録されていたが、Lucene 7.0以降では numTerms がそのままエンコードされて1バイトに記録される。もはや BM25Similarity が標準なので、1 / sqrt(numTerms) を計算する意味自体がない、ということだ。

Lucene/Solr 7.0 以降も omitNorms 指定は相変わらず有効である。

ロンウイットの社内勉強会で説明に使用したスライドも公開しているので、合わせて読んで欲しい。







[1] ロンウイット主催のトレーニングコース「Solr基礎」では、このような大事な基礎を丁寧に教えています。お申し込みはこちらから

[2] ロンウイット主催のトレーニングコース「Apache Mahout & Sparkではじめる機械学習」では、機械学習のさまざまなモデルやアルゴリズムを丁寧に解説しています。ベーシックな機械学習の解説にとどまらず、本記事にあるような、検索エンジンのランキングの元になるスコア計算の考え方なども含み、ソフトウェア開発の実際の現場で役に立つ内容を網羅しています。お申し込みはこちらから

| 関口宏司 | Luceneインデックス | 10:41 | comments(0) | trackbacks(0) |
Solr でランキング学習を体験する

最近俄に(?)脚光を浴びてきた「ランキング学習(Learning-to-Rank、以下LTRと略すこともある)」をSolrで試す方法を紹介しよう。

Solrでランキング学習というと、まもなくリリースされる Solr 6.4.0 に含まれる SOLR-8542 を思い浮かべる方もいるかもしれない。しかしここでは、「第19回 Lucene/Solr 勉強会」でシーマークの山本社長が発表したNLP4Lを使った方法を採り上げたい。以下の理由からSOLR-8542よりはるかに使い方が簡単だからだ。

また、NLP4Lはランキング学習の論文で紹介される一般的な特徴であるTF、IDF、TF*IDFなどが扱えるのに対し、SOLR-8542はこれらの特徴は扱えない(扱う予定もなさそう)。さらにSOLR-8542はモデルを利用する部分でSolrに依存するところが大きいが、NLP4Lでは大事な部分はLuceneレベルで行われ、LTRの主要な部分の実装がコンパクトである。したがってElasticsearch対応も比較的簡単である。もちろんSOLR-8542の方が優れている部分もあり、使用する特徴をSolrのクエリ式で書けるところはNLP4Lよりも柔軟で優位性があるといえる。

LTRの学習データについて

LTRは教師あり学習を基本とする。教師データのイメージは、「あるクエリについてそれぞれの文書がどのくらい関連しているか」というラベル(NLP4Lではrelevance degreeと呼んでいる)がつけられている一連のレコードである。この形式のデータを学習するアルゴリズムはPointwiseアプローチと呼ばれる。他にも「あるクエリについて文書ペアがどちらがより関連度が高いか」というラベルがついたデータを扱うPairwise、「あるクエリについて文書集合が関連度順にどのようにリストされるか」というラベルがついたデータを扱うListwiseがある。

前述の3番目の「教師データの準備方法」について、NLP4Lでは付属の「アノテーションGUI」を使った方法と、「アクセスログ(aka クリックログ、NLP4Lでは特別に「インプレッションログ」と呼んで通常のアクセスログと区別している)からクリックモデルを計算して関連度を自動算出する」方法が用意されている。本記事では説明の簡略化のため前者の「アノテーションGUI」を使った方法を採り上げる。しかし本番システムで人手でアノテーションを行うのはなにかと大変だ。本記事を読んでランキング学習の手順を理解したら、ぜひ後者の「アクセスログからクリックモデルを計算して関連度を自動算出する」方法に挑戦していただきたい。

以下、NLP4Lでのランキング学習の手順の概要である。この順番で以降説明を進める。

  1. Solr 6.3.0 のインストール
  2. LTRのためのSolrプラグインのビルドと設定
  3. livedoor ニュースコーパスの登録
  4. NLP4Lのインストールと起動および設定
  5. 教師データの作成
  6. 特徴抽出
  7. ランキング学習
  8. モデルのデプロイ
  9. ランキング学習モデルを使ったリランキングの実際

前述の通り、NLP4Lには詳細なLTRのためのマニュアルが付属するのでここであらためて記事にする必要もないかもしれないが、本記事では対象文書セットとしてlivedoorニュースコーパスを用いることで、一連の操作方法をより具体的に説明している。興味のある方は、下記手順通りにインストールと設定を進めれば、Solrでランキング学習の実際を体験することができる。より詳細を知りたい場合にはNLP4Lのマニュアルをご覧いただきたい。

Solr 6.3.0 のインストール

適当なディレクトリにてSolr 6.3.0(本記事執筆時の最新)をダウンロード、展開する。本記事では/opt/nlp4l/solr-6.3.0ディレクトリにSolrを展開するとして話を進める。

$ pwd
/opt/nlp4l
$ wget http://ftp.tsukuba.wide.ad.jp/software/apache/lucene/solr/6.3.0/solr-6.3.0.tgz
$ tar xvzf solr-6.3.0.tgz

Solrを起動し、collection1という名前のコアを作成する。

$ cd solr-6.3.0
$ ./bin/solr start
$ ./bin/solr create_core -c collection1 -d sample_techproducts_configs

LTRのためのSolrプラグインのビルドと設定

適当なディレクトリにてgithubで公開されているNLP4L/solrプロジェクトをダウンロード、ビルド、Solrにデプロイする。

$ pwd
/somewhere/NLP4L
$ git clone https://github.com/NLP4L/solr.git
$ cd solr
$ mvn package
$ cp target/nlp4l-solr-1.1-SNAPSHOT.jar /opt/nlp4l/solr-6.3.0/server/solr-webapp/webapp/WEB-INF/lib
 

NLP4L/solrはtypesafe社のconfigライブラリが必要なので、以下のように入手、ビルド、Solrにデプロイする。

$ cd /somewhere
$ wget https://github.com/typesafehub/config/archive/v1.3.1.tar.gz
$ tar xvzf v1.3.1.tar.gz
$ cd config-1.3.1
$ sbt package
$ cp config/target/config-1.3.0-20170120T044439.jar /opt/nlp4l/solr-6.3.0/server/solr-webapp/webapp/WEB-INF/lib
 

Solrに付属のJettyのwebapp設定を次のように行う。

$ cd /opt/nlp4l/solr-6.3.0
$ vi server/solr-webapp/webapp/WEB-INF/web.xml
 
  
    fileReceiver
    org.nlp4l.solr.servlet.FileReceiver
    
      root_path
      /opt/nlp4l/solr-6.3.0/server/solr/collection1
    
  

  
    fileReceiver
    /nlp4l/receive/file
  
 

また、選択可能な特徴を以下のようにJSON設定ファイルに用意する。

$ vi server/solr/collection1/conf/ltr_features.conf
 
{
  "features": [
    {
      "name": "TF in title",
      "class": "org.nlp4l.solr.ltr.FieldFeatureTFExtractorFactory",
      "params": { "field": "title" }
    },
    {
      "name": "TF in body",
      "class": "org.nlp4l.solr.ltr.FieldFeatureTFExtractorFactory",
      "params": { "field": "body" }
    },
    {
      "name": "IDF in title",
      "class": "org.nlp4l.solr.ltr.FieldFeatureIDFExtractorFactory",
      "params": { "field": "title" }
    },
    {
      "name": "IDF in body",
      "class": "org.nlp4l.solr.ltr.FieldFeatureIDFExtractorFactory",
      "params": { "field": "body" }
    },
    {
      "name": "TF*IDF in title",
      "class": "org.nlp4l.solr.ltr.FieldFeatureTFIDFExtractorFactory",
      "params": { "field": "title" }
    },
    {
      "name": "TF*IDF in body",
      "class": "org.nlp4l.solr.ltr.FieldFeatureTFIDFExtractorFactory",
      "params": { "field": "body" }
    }
  ]
}

また、solrconfig.xmlを以下のように編集する。

$ vi server/solr/collection1/conf/solrconfig.xml

標準の /select リクエストハンドラのdefaultsパラメータに以下を追加する。

       edismax
       title body

NLP4L-LTRからの特徴抽出リクエストに応答するためのリクエストハンドラを以下のように設定する。


  
    ltr_features.conf
  

最後に、PRankクエリを呼び出すPRankQParserPluginを次のように設定する(PRank以外にRankingSVMが選択可能)。


  
    collection1/conf/ltr_features.conf
    collection1/conf/ltr_model.conf
  

またSolrあるあるであるが、Elevation Component関連の設定もここで削除しておこう。

livedoor ニュースコーパスの登録

livedoor ニュースコーパスをSolrに登録するために、まずスキーマを設定する(APIを通じてスキーマ変更するのが推奨だが、面倒なのでエディタで編集してしまう)。

$ vi server/solr/collection1/conf/managed-schema
   
   
   

   
   

   

   url

されに不要なcopyField設定は取り除いておく。ここでSolr設定の変更を有効化するためにコアのリロードまたは再起動を行う。

そしてlivedoor ニュースコーパスを入手し、Solrに登録する。

$ cd /somewhere
$ wget http://www.rondhuit.com/download/livedoor-news-data.tar.gz
$ tar xvzf livedoor-news-data.tar.gz
$ /opt/nlp4l/solr-6.3.0/bin/post *.xml

NLP4Lのインストールと起動および設定

NLP4L/nlp4l プロジェクトを github から以下のように入手し起動する。

$ cd /somewhere/NLP4L
$ git clone https://github.com/NLP4L/nlp4l.git
$ cd nlp4l
$ cp conf/application.conf.sample conf/application.conf
$ vi conf/application.conf
$ ./activator run

そしてWebブラウザから http://localhost:9000/ にアクセスする。NLP4L-LTRというメニューを選び、画面上部に表示されるメニューのConfigをクリックする。そして<<New>>リンクをクリックすると、新しいConfig設定をするように促される。次の表を参考に設定する。

パラメータ名説明本記事での設定値
Nameこの設定の名前test
Annotation Typeアノテーションの種別。現在はpointwiseしか選べない。これは現在のNLP4Lが学習アルゴリズムがPointwiseのものしか扱えない、という意味ではない。実際、NLP4LがサポートしているRankingSVMはPairwiseであるが、Pointwise形式のデータを内部でPairwiseに変換している。pointwise
Relevance Degreepointwiseにて人手でつける星の数をいくつからいくつまでとするか。0 to 3
Trainer Factory Class学習アルゴリズムのファクトリクラス名を指定する。PRankTrainerFactoryまたはRankingSVMTrainerFactoryが選べる。 org.nlp4l.ltr.support.procs.PRankTrainerFactory
- settings学習の繰り返し数。{ "numIterations": 2000 }
Search URL教師データ作成時、人手でアノテーションする際、連携先となるSolrのURL。http://localhost:8983/solr/collection1/select?q=${query}&wt=json&hl=true&hl.fl=title,body&f.body.hl.snippets=3&hl.simple.pre=<b style="background-color:yellow">&hl.simple.post=</b>
Feature Extract URL特徴抽出する際の連携先となるSolrのURL。solrconfig.xmlに設定したリクエストハンドラのURLを記載する。http://localhost:8983/solr/collection1/features
Feature Extract Config特徴抽出時に参照する、Solr側に配置されたJSON設定ファイル名。ltr_features.conf
Document Unique FieldSolrのユニークキーのフィールド名。url
Document Title FieldSolr文書のタイトルフィールド名。人手によるアノテーション実施時に、タイトルとして画面表示されるフィールド名を指定する。title
Document Body FieldSolr文書の本文フィールド名。人手によるアノテーション実施時に、本文として画面表示されるフィールド名を指定する。 body
Deployer Factory Class学習して得たモデルファイルをSolrにデプロイするときに使用するクラスのファクトリ。org.nlp4l.ltr.support.procs.HttpFileTransferDeployerFactory
- settings上記クラスのURL。{ "deployToUrl": "http://localhost:8983/solr/nlp4l/receive/file", "deployToFile": "conf/ltr_model.conf" }

[Save]ボタンをクリックして[Load]をクリックすると、画面上部にtestという名前のConfigがロードされたことが示され、Query、Annotation、Feature、Trainingというメニュー項目が現れる。

教師データの作成

教師データを作成する場合はQueryまたはAnnotationメニューを使う。なお前述の通り、本記事ではアノテーションGUIを使った教師データの作成方法を説明する。アクセスログ(インプレッションログ)からクリックモデルを計算することで教師データを安価に用意したい場合は、Queryメニューの[Import]ボタンをクリックしてログファイルをロードする。

Queryメニューにはファイルを選択するボタンがあり、これをクリックするとクエリ一覧が記載されたテキストファイルを選べるようになる。このテキストファイルには1行1クエリを記載しておく。このファイルをロードすると、下図のようにクエリが表示される。

LTR-QueryList

このクエリのリンクをクリックすると連携先のSolrにてクエリが実行され、Annotation画面に結果が表示される。この画面ではSearchテキストボックスに選択したクエリが表示されているが、Queryメニューから始めなくても、Annotationメニューを選んでこのSearchテキストボックスに直接クエリを入れても同じである。

LTR-Annotation

Annotation画面にて各クエリと返された文書の関連度を考えながら、右側に表示されている星の数を適切に選んでいく。1画面分終わったら、[Save]ボタンで保存し、[Next]で次のクエリに進む。一通り終了したら次のステップである特徴抽出に進む。

特徴抽出

Featureメニューを選択する。すると下図のようなプログレスバーが現れる。[Extract]で特徴抽出が開始するが、すでにプログレスバーが緑色になっているときは、まず[Clear]ボタンをクリックしてから[Extract]を行う。終了するとDONEと表示される。

LTR-FeatureExtraction

ランキング学習

Trainigメニューを選択し、左側にある<<New>>をクリックする。するとSolrに配置した設定ファイル(ltr_features.conf)にしたがって特徴を選ぶチェックボックスが現れる。このチェックボックスで適当に特徴を選んで[Start]をクリックする。ここでも学習の進行状況がプログレスバーで示され、終了すると下のようにモデルファイルの内容が表示される。ここではPRankを選択したので、各特徴に対する重みと、3つの閾値(星の数を最大3にしたことに由来)が返される。

{
  "model" : {
    "name" : "prank",
    "type" : "prank",
    "weights" : [ {
      "name" : "TF in title",
      "weight" : -113
    }, {
      "name" : "TF in body",
      "weight" : -14
    }, {
      "name" : "IDF in body",
      "weight" : -322.44384765625
    } ],
    "bs" : [ -2818, -2374, 6450 ]
  }
}

モデルのデプロイ

ランキング学習が終了すると[Deploy]ボタンが表示されるので、これをクリックすることで連携先のSolrにHTTPで転送される。

LTR-Model

Solr側ではこの学習モデルを有効化するために、コアのリロードが必要である。Solrの管理画面からコアのリロードを行うのが簡単だ。

ランキング学習モデルを使ったリランキングの実際

NLP4L-LTRはSOLR-8542とは異なり、Solr標準のリランクを使って呼び出すのでrq={!rerank …}パラメータを利用する。前述のsolrconfig.xmlに設定したprank queryParserを呼び出すには次のように検索を実行する。

http://localhost:8983/solr/collection1/select?indent=on&q=社会人 学生&rq={!rerank reRankQuery=$rqq}&rqq={!prank}社会人 学生&debugQuery=on

Solr標準のリランクは、(前述のsolrconfig.xmlの設定にしたがい)最初に通常のedismaxクエリを実行し、次に上位の200クエリ(reRankDocsのデフォルト)についてランキング学習モデルに基づいた再ランキングを行う。デバッグ情報のスコア詳細欄を見ると最初の2行は次のようになっている。

14.37461 = combined first and second pass score using class org.apache.solr.search.ReRankQParserPlugin$ReRankQueryRescorer
  10.37461 = first pass score

最初の行は2段階クエリのスコアの和が14.37461になっていることを示し、次の行は第1段階のクエリ(ここでは通常のedismax)のスコアが10.37461であることを示している。3行目以降はedismaxのスコア詳細なのでここではスキップしよう。

第2段階のクエリ、つまりPRankのクエリで計算したスコア合計は2.0であり、その詳細は下記のブロックで示されている。

  2.0 = second pass score
    2.0 = is the index of bs(-2818.0,-2374.0,6450.0) > -2338.074707 sum of:
      -113.0 = weight: -113.0 * feature: 1.0 sum of:
        0.0 = no matching terms
        0.0 = no matching terms
        1.0 = freq: 1
      -84.0 = weight: -14.0 * feature: 6.0 sum of:
        2.0 = freq: 2
        3.0 = freq: 3
        1.0 = freq: 1
      -2141.0747 = weight: -322.44385 * feature: 6.640147 sum of:
        2.762864 = log(numDocs: 7368/docFreq: 465)
        0.55211145 = log(numDocs: 7368/docFreq: 4242)
        3.3251717 = log(numDocs: 7368/docFreq: 265)

2.0というスコアは2倍(reRankWeightのデフォルト)され4.0となり、最終スコア合計が14.37461となる。2.0というスコアはどうやって計算されたかというと、モデルで与えられた重みを使って計算した結果が-2338.074707となるが、これは境界値の[-2374.0,6450.0]の範囲内にあるため2.0となる。

まとめ

livedoorニュースコーパスはフィールド数、文書数とも少ないので十分な教師データを作るのが難しいが、実運用されているフィールド数、文書数とも大きなデータにおいて、ランキング学習は通常のSimilarityが算出するスコアをより良く改善することが期待できる。ぜひ試していただきたい。

| 関口宏司 | 機械学習 | 13:49 | comments(3) | trackbacks(0) |
キーフレーズ抽出ツール 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) |
Lucene コミット時の動作(翻訳)
Lucene インデックスのコミット時の動作について詳述されたメール(原文)を見つけたのでその翻訳を掲載しよう。メールそのものは、Lucene コミッターの Uwe 氏が最近の Java 9 のコミットによってディレクトリへの fsync() 動作がうまくいかなくなったことについて最近の Lucene コミュニティの状況について説明し、さらにはコミュニティに解決方法を問うたものとなっている。

最近の Java 9 のコミット (e5b66323ae45) がディレクトリへの fsync を破壊した件について

こんにちは。

Apache Lucene コミッターを代表して投稿します。Apache Lucene/SolrとElasticsearchの両方を先ごろテストしたところHotspotまわりを中心に問題が見つかったことはご存知かと思います。そこで、JDK 9のプレビュー版ビルド40を利用するよう自分たちのテストインフラを先日アップデートしました。これは主にJigsawまわりの問題を確認することが目的でしたが、いくら頑張ってもこのビルドでは問題が発生しないのです。

ところが残念ながらOpenJDK 9の最近のコミットでは問題が発生しており(http://hg.openjdk.java.net/jdk9/jdk9/jdk/rev/e5b66323ae45)、それに相当するのがhttps://bugs.openjdk.java.net/browse/JDK-8066915です。この問題に関連し、自分たちでもhttps://issues.apache.org/jira/browse/LUCENE-6169というスレッドを立てました。

まず現状を説明します。Apache Luceneは write once アプローチ(すべてのファイルで一度だけ書き込みが行われる)を使っています。Luceneの所定の「コミットポイント」で「コミット」する場合は一時的なファイル名に書き込み、それからこのファイル(およびすべての関連ファイル)でfsyncを実行する形になります。これはファイルチャネルではfc.force()をコールするだけ簡単です。このコミットの最終的な「パブリッシュ」は「Files.move(Path, Path, StandardCopyOption.ATOMIC_MOVE)」を使ったアトムのリネームで行われます。

この方法は停電のような大きな問題でもない限りうまくいきますが、そのような問題が発生すると、POSIX OSではリネーム処理が全く見えなくなる可能性があります。LinuxのMANページ(http://linux.die.net/man/2/fsync)にはすべてに以下の記述があります。

「fsync() のコールはファイルのあるディレクトリ内のエントリがディスクに書き込まれたことを必ずしも保証するものではない。そのため、ディレクトリのファイル記述子にも fsync() を明示する必要がある。」

基本的に、現在はApache Luceneでも以下の手順で同じことをしています。ディレクトリ上でFileChannel (READ用)をオープンし、 fc.force() をコールします。もちろん、(動作することは以前から分かっているものの)実際にはJava APIにこの記述がないため、われわれは「有利な推測」に基づいてこれを行うことになります。Windowsではうまくいかないなど(*)、IOExceptionの例外は必ず発生します。

そこで問題になるのが、前述のコミットではこのアプローチがOpenJDK 9のFileSystemExceptionでうまくいかないことです。FileSystemExceptionはディレクトリです。社外のLuceneリリースは(前述のように)例外をすべて受け入れるため大丈夫なのですが、テストインフラでは、少なくともLinuxとMacOSXでは動いてしまうのです。

最新のコードはhttp://goo.gl/vKhtsWにあります。

どうしてもサポートされているOSのディレクトリでfsyncができるようにしておきたいのです。前述のコミットを8u40や7u80のリリースには戻したくないのです。Java 9なら代替案を検討できるのですが。

  • Java 7やJava 8の既存のセマンティクスを残し、どうしてもFileChannelからREAD/WRITEをしたい場合は単純にエラーを発生させるのでしょうか?基盤OSとlibcはこのようにして処理を行っています。 ファイル、ディレクトリ、デバイスなど、ファイル記述子はどこでもオープンできますが、この記述子ではできない処理もあり、例外やエラーが発生する場合もあります。
  • ディレクトリをfsyncするための新しいAPIを追加する(どのファイルタイプでもおそらく可能)。Files.fsync(Path)のようにでしょうか? Windowsではディレクトリではこれがうまくいかないのでしょうか?上記のリンクにあるIOUtils.fsync()のような形です。

意見や対応方法に関するアドバイスを下さい。

Uwe

*)ただ、このファイルシステムのセマンティクスはファイルが確実に見えるようにしているため、実際のところは問題ではありません。話がちょっとそれます(ディレクトリを読み込み用に開くと「Access Denied」がJava IOExceptionにマッピングされますが、これは全く問題ありません)。

| 関口宏司 | Luceneクラス解説 | 10:27 | 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) |
Heliosearch/Solrオフヒープフィルタ

筆者のYonikの許可を得て翻訳した。原文はこちら

Solrのパフォーマンスを一段高めるHeliosearchという新しいオープンソースプロジェクトに真っ先に追加された機能がオフヒープNativeフィルタだ。

大容量JVMヒープの潜在的問題

JVMは大きいヒープの処理が決してうまくなかった。ヒープが大きいとガーベッジコレクション作業が難しくなり、GCポーズによって長時間システムが停止し、ほかの処理が一切できなくなる場合も多い。そのためクエリ/リクエストタイムアウトが発生したり、SolrCloudモードでzookeeperセッションのタイムアウトまでも発生する可能性がある。

オフヒープフィルタ

非常に高度なフィルタキャッシング機能を用意しているHeliosearch/Solrだが、アプリケーションによっては膨大なメモリを使用する可能性がある。大きく存続期間の長いオブジェクトの方が、JVMヒープから取り出して明示的に管理するメリットを得られる。オフヒープメモリはガーベッジコレクタから見えないのだ。

Heliosearchフィルタ(Solr DocSetオブジェクト)はオフヒープでアロケートされるようになっており、不要になればすぐに解放できるよう参照がカウントされるようになった。また、JVM GCはこのようなメモリブロックのコピー処理で時間を無駄にする必要がなくなった。このことはGCの長時間ポーズを排除し、リクエストスループットを改善するのに役立っている。

テスト設定

各方面から報告のあった長時間のGCポーズを再現するにはかなりいろいろなことを試す必要があると考えていたが、何と1回目でいきなりこれが再現されてしまった。ヒープサイズは報告のように大きなものではなく現状は小さく収まっている。ヒープが大きいとGCポーズも延びるようだ。

テストの詳細

  • Ubuntu Linuxサーバ(RAM 8Gバイト、CPU 4コア、Java 1.7 64ビット)
  • クライアント:8スレッドそれぞれが1つのIDのクエリを任意のフィルタ(500種類)で処理
  • filterCache:size=1000(すべてのフィルタをエビクションせず保持するのに十分な大きさ)
  • インデックス:3.8Gバイト、5000万件

Apache Solrコマンドライン:

$ java -jar -Xmx4G start.jar
Heliosearch/Solrコマンドライン:
$ java -jar start.jar

Apache Solr実行時はOOM例外を回避するためにヒープサイズを4Gバイトに設定する必要があった。搭載可能なRAMは最大8Gだったため、残りのメモリはインデックスファイルのキャッシング用としてOSに残しておきたかった(そうしないと速度が大幅に落ちてしまう)。

GCの結果

2万クエリリクエストを行ったGCの実行結果を以下のグラフに示す。 グレーの棒はGCに要した時間、赤線はヒープの実サイズ、そして青線はヒープの実使用量を示している。

Solr

Solrでの長時間ポーズをテスト中に外から見るのはさらに容易だった。ログが有効になっていたためリクエストがすべてログメッセージを残し、ターミナルでは高速スクロールが発生した。そして、GCの大規模なコンパクションが発生するとターミナルのスクロールは突然停止した。 hs_140120-solr_gc.png

Heliosearch

ガーベッジコレクションの処理が短時間ですむためにHeliosearch GCグラフの方が完了は早い。長時間の完全なGCポーズがほとんど発生しておらず、ほかのGCポーズも大幅に減少していることに注目したい。 hs_140120-heliosearch_gc.png

クエリの待ち時間

このグラフは、2万クエリを実行したときの後半の1万クエリ(ホットスポットとキャッシュが落ち着いたタイミングを見計らうため)の待ち時間をパーセントで示している。
hs_140120-query_latency.png

Query Throughput

hs_140120-query_throughput.png
Query Throughputグラフはオフヒープデータ構造によって解放できるガーベッジコレクションに必要なCPU処理時間を示している。もちろん、これは極端な結果だ。大量のガーベッジコレクションが頻繁に発生している場合はデータ構造をオフヒープにすることで実現するスループット向上の方が適当だろう。

メモリ使用量

プロセス(トップ経由で外部から監視)の常駐メモリ最大使用量は5回にわたって計測した。

Apache SolrHeliosearch
最小3.8GB3.6GB
最大4.3GB3.7GB
オフヒープフィルタを用意するHeliosearchの方がメモリプロファイルが安定し、メモリ使用量も平均的に少なかった。そのため、OSがインデックスファイルをキャッシュするための空きメモリも増えるが、これは高いパフォーマンスを実現するためにきわめて重要なことだ。

結論

今回の簡単なテストにより、オフヒープフィルタが大きな異常値を減らして全体のクエリスループットを高め、長時間のGCポーズを排除してリクエストの予測を容易にすることが判明した。

ご自身でお試しいただきHeliosearchForumで感想をお聞かせいただきたい。



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

| 関口宏司 | Solr | 12:42 | comments(0) | trackbacks(0) |
+ Solrによるブログ内検索
+ PROFILE
     12
3456789
10111213141516
17181920212223
24252627282930
31      
<< March 2024 >>
+ 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