一定期間更新がないため広告を表示しています
関口宏司のLuceneブログOSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
2017.12.15 Friday
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 のハイライト機能はぜひ積極的に利用しよう(ただし、商品の写真を検索結果一覧に表示するようなECサイトの検索アプリのようなものは除く)。 ここで上げた以外にも、RDB の正規化されたテーブルをそのまま Solr のインデックススキーマにマッピングし Solr の JOIN を使うあるあるや、親子階層のファセットを自前で頑張るあるある、クエリを投げる際に常にダブルクォーテーションでくくってしまうあるあるなどもある。 Solr は OSS になって10年を超えるソフトウェアとなった。機能も十分豊富で、やりたいことの多くはすでに Solr が持っていることも多い。アプリケーションで頑張る前にぜひマニュアルや書籍等で該当する機能がないか、使い方がアーキテクチャに沿ったものになっているか、今一度考えてみることをお勧めしたい。 さて、今年はおそらく本記事が最後となるでしょう。更新頻度が少なくなったのにもかかわらず、本年もご愛読いただきありがとうございました。皆様、よいお年をお迎えください! 2017.08.25 Friday
LUCENE-6819: Good bye index-time boost
Lucene/Solr 7.0 のリリースの投票がまもなく始まろうとしている。 今回私が気になっているのが、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 である(下図)。 しかし、ほとんどのユーザーはインデクシング時重み付けなどしないので、上図グレーの部分がもったいない。boostがかけられる可能性を考えて1バイトの半分を残しているのだが、ほとんどのユーザーはboost=1なので、非常にもったいない、かつ、低精度な使い方になっている。 そこで、LUCENE-6819 では:
LUCENE-6819 ではまだ fieldNorm の1バイトが有効活用されていない。そこでこのチケットでは、フィールド長(単語数)をSmallFloat.intoTobyte4(numTerms)でエンコードして保存するように修正された。たとえば単語数が1000のときは、SmallFloat.intoTobyte4(1000)が87となり、これが記録される。デコードにはSmallFloat.byte4ToInt()が使われる。SmallFloat.byte4ToInt(87)は984となる。ちなみに、1〜40はエンコードしても1〜40を出力する(変わらない)。長さ41から徐々にエンコードによる誤差が大きくなる(下図)。 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ではじめる機械学習」では、機械学習のさまざまなモデルやアルゴリズムを丁寧に解説しています。ベーシックな機械学習の解説にとどまらず、本記事にあるような、検索エンジンのランキングの元になるスコア計算の考え方なども含み、ソフトウェア開発の実際の現場で役に立つ内容を網羅しています。お申し込みはこちらから。 2017.01.21 Saturday
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でのランキング学習の手順の概要である。この順番で以降説明を進める。
前述の通り、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
また、選択可能な特徴を以下のように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パラメータに以下を追加する。
NLP4L-LTRからの特徴抽出リクエストに応答するためのリクエストハンドラを以下のように設定する。
最後に、PRankクエリを呼び出すPRankQParserPluginを次のように設定する(PRank以外にRankingSVMが選択可能)。
またSolrあるあるであるが、Elevation Component関連の設定もここで削除しておこう。 livedoor ニュースコーパスの登録livedoor ニュースコーパスをSolrに登録するために、まずスキーマを設定する(APIを通じてスキーマ変更するのが推奨だが、面倒なのでエディタで編集してしまう)。 $ vi server/solr/collection1/conf/managed-schema
されに不要な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設定をするように促される。次の表を参考に設定する。
[Save]ボタンをクリックして[Load]をクリックすると、画面上部にtestという名前のConfigがロードされたことが示され、Query、Annotation、Feature、Trainingというメニュー項目が現れる。 教師データの作成教師データを作成する場合はQueryまたはAnnotationメニューを使う。なお前述の通り、本記事ではアノテーションGUIを使った教師データの作成方法を説明する。アクセスログ(インプレッションログ)からクリックモデルを計算することで教師データを安価に用意したい場合は、Queryメニューの[Import]ボタンをクリックしてログファイルをロードする。 Queryメニューにはファイルを選択するボタンがあり、これをクリックするとクエリ一覧が記載されたテキストファイルを選べるようになる。このテキストファイルには1行1クエリを記載しておく。このファイルをロードすると、下図のようにクエリが表示される。
このクエリのリンクをクリックすると連携先のSolrにてクエリが実行され、Annotation画面に結果が表示される。この画面ではSearchテキストボックスに選択したクエリが表示されているが、Queryメニューから始めなくても、Annotationメニューを選んでこのSearchテキストボックスに直接クエリを入れても同じである。
Annotation画面にて各クエリと返された文書の関連度を考えながら、右側に表示されている星の数を適切に選んでいく。1画面分終わったら、[Save]ボタンで保存し、[Next]で次のクエリに進む。一通り終了したら次のステップである特徴抽出に進む。 特徴抽出Featureメニューを選択する。すると下図のようなプログレスバーが現れる。[Extract]で特徴抽出が開始するが、すでにプログレスバーが緑色になっているときは、まず[Clear]ボタンをクリックしてから[Extract]を行う。終了するとDONEと表示される。
ランキング学習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で転送される。
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が算出するスコアをより良く改善することが期待できる。ぜひ試していただきたい。 2016.07.09 Saturday
キーフレーズ抽出ツール 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つの連続する単語を列挙する。たとえば、次のような文書があったとしよう。
すると、KEAでは次のように10個の候補となるキーフレーズを列挙する。
キーフレーズ候補の列挙においては、ストップワードから始まるフレーズやストップワードで終わるフレーズは候補とならない。よって、単独のtoのみならず、like(s) toやto goなどは候補としてリストアップされない。また情報検索やNLPでよく行われる文字や単語の正規化もこの段階で行われる。よって、TokyoやYugawaraはtokyoやyugawaraに、likesはステミングされてlikeになる。 KEAにおけるモデルの学習KEAでは単純ベイズ分類器を用いて教師ありデータ(著者によりキーフレーズがつけられた文書データ)からキーフレーズ候補におけるキーフレーズになるなりやすさ(P[yes])となりにくさ(P[no])を学習する。具体的には次のような式を用いる。
ここで"Y / (Y + N)"や"N / (Y + N)"の部分はキーフレーズ候補から求められる事前確率である。先の例文でアスタリスクがつけられたものがキーフレーズだとすると、つぎのようになる。
KEAでは文書D中のフレーズPを表現するのに2つの特徴量を用いている。1つめはTF*IDFであり、次のように計算される。
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]を求め、最終的に次の式を使ってスコアを算出して降順にソートし、適当なところで足切りする。
なお、未知文書はモデルに含まれていないため、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を以下に紹介しよう。 AnalyzerLuceneでは、文章を単語に区切るのに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 単語辞書として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 また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 両者に共通して現れるキーフレーズは次のようになる。
決して正解率が高いとは言えないが、抽出されたキーフレーズはフレーズとして不自然なものが少ないように見える。自動抽出されたフレーズとしてはいいセン行っているのではないだろうか。 また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ライブラリへ興味を持ったり、ライブラリへの知識を少しでも広げていただけたなら幸いである。 課題本稿を執筆した最初のプログラムのバージョンでは、以下の項目については時間の制約から実装していない。
KEA-luceneはLucene学習者向けにあえてハードコーディングをしているが、汎用的なものを適宜NLP4Lを通じて提供する予定である。 2015.06.24 Wednesday
日本語Wikipediaからの類義語辞書の自動生成
日本語Wikipediaから Lucene / Solr / Elasticsearch で使える類義語辞書を自動生成する方法を紹介する。 過去にはこちらで説明している方法で行ったりもしたが、今回はいわゆる外来語に限って言葉を収集するもので、はるかに精度がよい(測ってはいないが・・・)。また実装は NLP4L を通じて公開しているので、誰でも試せる。また理屈がわかれば、日本語Wikipediaに限らず収集できる。 日本語文書は次のようにカタカナ語とその語源の英語(等)のアルファベット文字列が近い距離に配置されて書かれることが頻繁にある。 エンターテインメント(英: entertainment)とは、人々を楽しませる娯楽をいう。(Wikipedia「エンターテインメント」より抜粋) そこで文書を大量に収集して互いに近い距離にあるカタカナ語とアルファベット文字列を類義語のペアとして Lucene/Solr に使えるテキストファイルに出力することを考える。しかし、近い場所に書かれているすべてのペアが必ずしも同じ意味を持つとは限らない。たとえば、次のような文章では間違った結果を得てしまう。 コンピューターはFORTRANのおかげでより身近になった。 文章を相当大量に収集してカタカナ語とアルファベット文字列の共起を調べることでも可能かもしれないが、文章を大量に収集することは誰でもできることではない。 NLP4L には英単語とカタカナ語の Transliteration のプログラムと学習データがついている。 このプログラムを使うとカタカナ語から英単語の綴りを推定できる。この推定値と文章から拾ったアルファベット文字列を比較し、文字列が似ていれば(適当に決めた編集距離以下なら)拾ったカタカナ語とアルファベット文字列は同じ意味を持つとして類義語辞書に出力する。 手順は次の通り。
以上の手順は日本語Wikipediaから抽出するやり方だが、社内文書や専門書などからも収集できる。 先にあげたエンターテインメントやインタフェースのように、カタカナ語に表記揺れがある場合も次のように収集できるのも興味深い。 entertainment,エンターテイメント,エンターテインメント interface,インタフェース,インターフェース pennsylvania,ペンシルバニア,ペンシルベニア うまく使えば検索の再現率向上に大いに役立つだろう。 2015.01.15 Thursday
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なら代替案を検討できるのですが。
意見や対応方法に関するアドバイスを下さい。 Uwe *)ただ、このファイルシステムのセマンティクスはファイルが確実に見えるようにしているため、実際のところは問題ではありません。話がちょっとそれます(ディレクトリを読み込み用に開くと「Access Denied」がJava IOExceptionにマッピングされますが、これは全く問題ありません)。 2014.12.19 Friday
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.xmlTermsComponentで単語リストを取得 次に、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択の問題を出されたならば、ほとんどの人が正解するくらいには専門用語っぽい出力が得られているといえるのではないだろうか。 2014.12.04 Thursday
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 のダウンロード という感じである。すでに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が動くはずである。
-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インデックス上で試していただき、どんな結果になったか教えていただければありがたい。 2014.01.30 Thursday
テキストアノテーションツール 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.pybrat へのログイン
上の起動時に表示された URL http://127.0.0.1:8001 にブラウザからアクセスする。すると、次のような画面が表示される。
ログインしたらコーパスコレクションを選択する。それにはタブキーを押下するか、上のメニューから [Collection] を選んでクリックする。すると、次のコーパスコレクション選択画面になる。 このときすでに、サーバサイドの対応する .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列でソートする必要があるだろう。
SolrとMahoutのトレーニングコース、ただいま2月の受講者を募集中です! 2014.01.20 Monday
Heliosearch/Solrオフヒープフィルタ
筆者のYonikの許可を得て翻訳した。原文はこちら。 Solrのパフォーマンスを一段高めるHeliosearchという新しいオープンソースプロジェクトに真っ先に追加された機能がオフヒープNativeフィルタだ。 大容量JVMヒープの潜在的問題JVMは大きいヒープの処理が決してうまくなかった。ヒープが大きいとガーベッジコレクション作業が難しくなり、GCポーズによって長時間システムが停止し、ほかの処理が一切できなくなる場合も多い。そのためクエリ/リクエストタイムアウトが発生したり、SolrCloudモードでzookeeperセッションのタイムアウトまでも発生する可能性がある。 オフヒープフィルタ非常に高度なフィルタキャッシング機能を用意しているHeliosearch/Solrだが、アプリケーションによっては膨大なメモリを使用する可能性がある。大きく存続期間の長いオブジェクトの方が、JVMヒープから取り出して明示的に管理するメリットを得られる。オフヒープメモリはガーベッジコレクタから見えないのだ。 Heliosearchフィルタ(Solr DocSetオブジェクト)はオフヒープでアロケートされるようになっており、不要になればすぐに解放できるよう参照がカウントされるようになった。また、JVM GCはこのようなメモリブロックのコピー処理で時間を無駄にする必要がなくなった。このことはGCの長時間ポーズを排除し、リクエストスループットを改善するのに役立っている。 テスト設定各方面から報告のあった長時間のGCポーズを再現するにはかなりいろいろなことを試す必要があると考えていたが、何と1回目でいきなりこれが再現されてしまった。ヒープサイズは報告のように大きなものではなく現状は小さく収まっている。ヒープが大きいとGCポーズも延びるようだ。 テストの詳細
Apache Solrコマンドライン: $ java -jar -Xmx4G start.jarHeliosearch/Solrコマンドライン: $ java -jar start.jar Apache Solr実行時はOOM例外を回避するためにヒープサイズを4Gバイトに設定する必要があった。搭載可能なRAMは最大8Gだったため、残りのメモリはインデックスファイルのキャッシング用としてOSに残しておきたかった(そうしないと速度が大幅に落ちてしまう)。 GCの結果2万クエリリクエストを行ったGCの実行結果を以下のグラフに示す。 グレーの棒はGCに要した時間、赤線はヒープの実サイズ、そして青線はヒープの実使用量を示している。 SolrSolrでの長時間ポーズをテスト中に外から見るのはさらに容易だった。ログが有効になっていたためリクエストがすべてログメッセージを残し、ターミナルでは高速スクロールが発生した。そして、GCの大規模なコンパクションが発生するとターミナルのスクロールは突然停止した。 Heliosearchガーベッジコレクションの処理が短時間ですむためにHeliosearch GCグラフの方が完了は早い。長時間の完全なGCポーズがほとんど発生しておらず、ほかのGCポーズも大幅に減少していることに注目したい。 クエリの待ち時間
このグラフは、2万クエリを実行したときの後半の1万クエリ(ホットスポットとキャッシュが落ち着いたタイミングを見計らうため)の待ち時間をパーセントで示している。
プロセス(トップ経由で外部から監視)の常駐メモリ最大使用量は5回にわたって計測した。
今回の簡単なテストにより、オフヒープフィルタが大きな異常値を減らして全体のクエリスループットを高め、長時間のGCポーズを排除してリクエストの予測を容易にすることが判明した。 ご自身でお試しいただき、HeliosearchForumで感想をお聞かせいただきたい。 SolrとMahoutのトレーニングコース、ただいま2月の受講者を募集中です! |
+ Solrによるブログ内検索
+ PROFILE
+ LINKS
+ Lucene&Solrデモ
+ ThinkIT記事
+ RECOMMEND
+ RECOMMEND
Lucene in Action (JUGEMレビュー »)
Erik Hatcher,Otis Gospodnetic,Mike McCandless FastVectorHighlighterについて解説記事を寄稿しました。
+ RECOMMEND
+ SELECTED ENTRIES
+ RECENT COMMENTS
+ RECENT TRACKBACK
+ CATEGORIES
+ ARCHIVES
+ MOBILE
+ SPONSORED LINKS
|
(C) 2024 ブログ JUGEM Some Rights Reserved.
|
PAGE TOP |