関口宏司のLuceneブログ

OSS検索ライブラリのLuceneおよびそのサブプロジェクト(Solr/Tika/Mahoutなど)について
テキストアノテーションツール 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) |
StatsComponent の標準偏差について
Solr の StatsComponent では標準偏差(stddev)が表示されるが、それは同じくStatsComponentで計算されるsumOfSquares を使った、次の近似式で計算されている:

  /**
   * Calculates the standard deviation statistic
   * 
   * @return Standard deviation statistic
   */
  private double getStandardDeviation() {
    if (count <= 1.0D) {
      return 0.0D;
    }

    return Math.sqrt(((count * sumOfSquares) - (sum * sum)) / (count * (count - 1.0D)));
  }


なぜ近似式を使っているかというと、forループを一度回すだけで計算したいためだ。分散や標準偏差を定義通りに計算しようとすると、最初に平均を求め(これでforループを一回回す必要がある)、その平均を使って各値との差の2乗を足していく必要がある(これでforループがもう一度必要)。StatsComponentではforループを一度しか回したくないので、その一回だけでsum(合計)、sumOfSquares(各値の2乗の合計)、min(最小)、max(最大)およびcount(件数)を計算または取得する。そして最後にmean(平均)と標準偏差を近似式で計算している。

そしてその近似式であるが、こちらの方のブログによると、あまりよろしくない結果を出してしまうようである。

これよりも良い近似式で同じく1パスで計算する方法がこちらに紹介されているが、特に私は今のところ急いで正確な標準偏差が必要になるわけでもないので、備忘録を残しておくだけにとどめておこう。
| 関口宏司 | Luceneクラス解説 | 16:54 | comments(0) | trackbacks(0) |
Lucene 4.4 の FST を使って FSA を構築する
Lucene 4.4(別に4.4と特定することもないのだが)のFSTを使ってFSA(有限状態オートマトン)を実装する機会があったので、簡単に使い方を紹介しよう。ここでは次のような単語をFSAに登録し、あとからFSA辞書に単語があるかどうかを問い合わせられるプログラムを作成する。

String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"};


プログラムは次の通りで、非常に簡単である。

public class TestFSA {

  public static void main(String[] args) throws IOException {
    String[] KEYS = {"あいだ", "あいぼう", "あたぼう", "いいだ"};     // (1)
    final Object outputValue = new Object();                          // (2)
    
    NoOutputs outputs = NoOutputs.getSingleton();                     // (3)
    Builder<Object> builder = new Builder<Object>(INPUT_TYPE.BYTE4, outputs);    // (4)
    BytesRef scratchBytes = new BytesRef();
    IntsRef scratchInts = new IntsRef();
    for (String key : KEYS) {                                         // (5)
      scratchBytes.copyChars(key);
      builder.add(Util.toIntsRef(scratchBytes, scratchInts), outputValue);
    }
    FST<Object> fst = builder.finish();                               // (6)

    getAndPrint(fst, "あかだ");                                       // (7)
    getAndPrint(fst, "いまだ");
    getAndPrint(fst, "いいだ");
    getAndPrint(fst, "いいだあ");
    getAndPrint(fst, "いい");
    getAndPrint(fst, "あたぼう");
  }
  
  static void getAndPrint(FST<Object> fst, String key) throws IOException {
    Object value = Util.get(fst, new BytesRef(key));
    System.out.printf("%s is %s¥n", key, value == null ? "not accepted" : "accepted");
  }
}


以下簡単にプログラムの説明をする。
  1. FSA辞書に登録する単語一覧である。(5)のfor文の中でこの順番に登録されるが、あらかじめソートされている必要がある。
  2. FSTはもともと登録する単語に関連した値(出力)を持たせることができるが、今回は単なるFSAとして使うので、出力値は不要である。しかし、nullは指定できないので、ここで固定にダミーのObjectを生成している。
  3. 出力の不要なFSAではNoOutputsを取得する。
  4. (3)で取得したoutputsを指定してFSTのbuilderを取得する。
  5. (1)の単語をbuilderにadd()する。
  6. 単語を登録し終わったら、builderからFSTを生成する。
  7. FSA辞書を使って単語を引いてみる。実際にはfstパッケージ内のUtilクラスのget()メソッドを使って、出力を得ている。単語がない場合(FSAが文字列を受理しない場合)、結果はnullとなる。nullでなければ単語は辞書に登録されている。


実行結果は次のようになる。プログラムのコンパイルと実行には、lucene-core-4.4.0.jarがあればよい。

あかだ is not accepted
いまだ is not accepted
いいだ is accepted
いいだあ is not accepted
いい is not accepted
あたぼう is accepted
| 関口宏司 | Luceneクラス解説 | 17:13 | comments(0) | trackbacks(0) |
64ビットプラットフォームで絶対お勧めのLuceneのMMapDirectory (Uwe氏のブログより)
前々回にUweさんのブログを紹介したが、その際にUweさん本人より「MMapDirectoryの記事もお奨めだよ!」と背中を押されたので、ここに日本語訳を掲載する:
心配無用 - よくありがちな誤解を解く

Apache LuceneとSolrは、バージョン3.1では64ビット版WindowsおよびSolarisの両システム、バージョン3.3では64ビットLinuxシステムにおいてMMapDirectoryをデフォルトで採用している。ただし、システムがここ数バージョンとは異なる動作をするようになったため、今回の変更にLuceneとSolrのユーザが混乱している。LuceneとSolrのメーリングリストでは、消費する物理メモリが突然3倍になった理由に疑問を抱くユーザや、リソース使用量に不満を訴えるシステム管理者の投稿が多数見られる。また、MMapDirectoryの代わりに、動作の遅いSimpleFSDirectoryやNIOFSDirectory(JVMバグ#6265734に起因してWindowsではさらに遅い)を有効にするようsolrconfig.xmlの設定を推奨する意見がコンサルタントからも出始めている。慎重に検討した結果これらのプラットフォームではMMapDirectoryの使用がベストだと判断したLuceneコミッターからすれば、これはどちらかと言えば不快な反応になる。Lucene/Solrがこれまでより格段に優れたパフォーマンスを発揮することが分かっているからだ。今回の変更の背景に関して間違った情報が出回っているため、この素晴らしい検索エンジンが最適でない形で各所にインストールされてしまう。

このブログでは、カーネル内の仮想メモリ処理に関する基本的な事実と、それを主にLuceneのパフォーマンス向上に利用するための方法について説明しようと思う(「仮想メモリ超入門」である)。また、さまざまな人々が投稿するブログやメーリングリストが間違っていて、MMapDirectoryの用途に矛盾するという理由についても明らかにしていく。後半では、mmapエラーなどや、Javaのこなれないヒープアロケーションに起因した最適でないパフォーマンスの回避に必要な詳しいコンフィギュレーションと必要な設定を紹介する。

仮想メモリ[1]

まずOSのカーネルを考えてみたい。ソフトウェアでI/O処理を行うのは無知なアプローチだ。これは1970年代から行われており、そのパターンは単純なものだ。ディスク上のデータを処理するときは常にシステムカーネルでsyscallを実行し、ポインタを何らかのバッファ(例:Javaのbyte[]アレイ)に渡し、ディスクとの間でバイトをやりとりし、バッファの中身をパースしてプログラムを実行する。(処理能力を消費しすぎる可能性があることから)あまり多くのsyscallを行いたくない場合はソフトウェアでバッファを大きく取って内部でのディスクとのデータ同期を減らすようにする。(RAMDirectoryを使用するなどして)Luceneインデックス全体をJavaのヒープメモリに読み込むようアドバイスするのにはこのような理由もある。

しかし、Linux、Windows(NT以降)、MacOS X、あるいはSolarisといったモダンOSはすべて、洗練されたファイルシステムキャッシュとメモリ管理機能を使用することで1970年代式のコードより格段に優れたアプローチを用意している。「仮想メモリ」と呼ばれるこの機能は、Luceneインデックスのように巨大で広大な空間を占めるデータ構造処理の優れた代替手段となっている。仮想メモリはコンピュータアーキテクチャに不可欠な部分となっており、インプリメンテーションにはハードウェアサポートが必須で、MMU(メモリ管理ユニット)としてCPUに組み込まれているのが一般的だ。その仕組みはかなりシンプルで、各プロセスが独自に仮想アドレス空間を取得し、そこにライブラリ、ヒープ、そしてスタックの各空間をマッピングする。だいたいの場合、このアドレス空間はオフセット0から始まり、アドレスポインタの再配置が一切不要であるためプログラムコードの読み込みが簡単になっている。どのプロセスからも断片化していない連続した大きなアドレス空間が作業空間として見える。このアドレス空間は物理メモリと一切関連性がないことから「仮想メモリ」と呼ばれ、プロセスからもそのように見える。この広大なアドレス空間は、ソフトウェアを使って実メモリであるかのようにアクセスできる。ほかのプロセスもメモリを消費し、独自に仮想アドレス空間を持っていることは意識する必要がない。基盤OSはCPU内のMMUと連携し、最初のアクセス時にこれらの仮想アドレスを実メモリにマッピングする。これはページテーブルと呼ばれるもので行われ、それがMMUハードウェア内のTLB(頻繁にアクセスされるページをキャッシュする変換索引バッファ)によってバックアップされている。こうすることで、OSは実行中のすべてのプロセスの必要メモリを利用可能な実メモリに分散し、実行中のプログラムから完全に透過的に見えるようにすることができる。

仮想メモリ図(Wikipediaより引用[1]、http://en.wikipedia.org/wiki/File:Virtual_memory.svg。CC BY-SA 3.0許諾)
仮想メモリ図(Wikipediaより引用[1]、http://en.wikipedia.org/wiki/File:Virtual_memory.svg。CC BY-SA 3.0許諾)

この仮想化を利用すればOSはもう1つのことができるようになる。物理メモリが十分にないときは、プロセスが使わなくなったページを「スワップアウト」したり、物理メモリを別のプロセス用に解放したり、もっと重要なファイルシステム操作をキャッシングできるようになる。ページアウトされた仮想アドレスにプロセスがアクセスしようとすると、それがメインメモリに再ロードされ、プロセスが利用可能になる。プロセスは何もする必要がなく、完全に透過的になっている。これは利用可能なメモリ容量の把握が一切必要ないためアプリケーションにとっては素晴らしいことだが、メモリの負荷が非常に高いLuceneのようなアプリケーションでは問題にもつながる。

Luceneと仮想メモリ

ここで、インデックス全体もしくはその大半を「メモリ」(これが仮想メモリにすぎないことは先刻承知だ)に読み込む例を見ていく。RAMDirectoryをアロケートし、そこにインデックスファイルをすべて読み込むとOSの足を引っ張ることになる。OSはディスクアクセスを最適化しようとするため、すべてのディスクI/Oが物理メモリにキャッシング済みだ。これらのキャッシュの内容をすべて自分の仮想アドレス空間にコピーして膨大な量の物理メモリを消費している(しかも、コピー処理の開始を待たなくてはならない)。物理メモリには制限があるので、OSはもちろん大きなRAMDirectoryをスワップアウトする判断を下す必要があるのだが、それはどこに行くのだろうか?再びディスク上(OSのスワップファイル上)に戻るのだ。実際、われわれはディスクから読み込むものすべてをページアウトさせるOSカーネルと格闘している[2]。したがって、インデックスの読み込み時間最適化にRAMDirectoryは得策ではない。加えて、RAMDirectoryの方がガーベッジコレクションや並行処理関連で問題が多いのだ。スワップ領域にデータがあるため、自分のヒープ管理下にあるメモリの解放はJavaのガーベッジコレクタにとって大変な作業となる。そのためにディスクI/Oが増加し、インデックスアクセス速度が低下し、ガーベッジコレクタの動作が原因で検索コードの待ち時間が分単位に達する。

一方、もしインデックスのバッファリングにRAMDirectoryを使わずNIOFSDirectoryやSimpleFSDirectoryを使うと、ディスクやファイルシステムのキャッシュとJavaヒープ内のバッファとの間でデータブロックをコピーするためにコードがOSカーネルに対して多数のsyscallを行う必要が出るなど、さらに代償を払わなくてはならなくなる。検索リクエストがある度に何度もくり返して行う必要があるのだ。

メモリマッピングファイル

前述の問題に対するソリューションが、仮想メモリと「mmap」[3] というカーネル機能を使ってディスクファイルにアクセスするMMapDirectoryだ。

先のアプローチでは、ファイルシステムキャッシュとローカルJavaヒープの間でデータをコピーするのにsyscallに頼っていたが、ファイルシステムキャッシュに直接アクセスすればいいのではないか。これこそまさにmmapの仕事なのだ。

基本的に、mmapはLuceneインデックスをスワップファイルとして処理するようなことをする。mmap () syscallはOSカーネルに指示を出し、インデックスファイル全体を前述の仮想アドレス空間に仮想的にマッピングし、それらをLuceneのプロセスが利用可能なRAMのように見せる。そうすれば、大きなbyte[]アレイ(JavaではJavaコードが安全に利用できるようByteBufferインターフェースによってカプセル化される)になるかのようにディスク上のインデックスファイルをアクセスできるようになる。Luceneのコードからこの仮想アドレス空間にアクセスすればsyscallsは一切不要で、プロセッサのMMUとTLBがマッピングをすべて代わりに処理してくれる。データがディスク上にしかない場合は、MMUが割り込みを発生させ、OSカーネルがデータをファイルシステムキャッシュに読み込む。既にキャッシュされている場合は、MMU/TLBがそれをファイルシステムキャッシュ内の物理メモリに直接マッピングする。これでネイティブメモリアクセスに変わることになる。バッファのページイン/ページアウトを気にする必要はなく、これらはすべてOSカーネルによって処理される。さらに、並行処理の問題も全くなく、標準のbyte[]アレイに対する唯一のオーバーヘッドはJavaのByteBufferインターフェースによって生じるラッピングだけとなる(それでも本物のbyte[]アレイより速度は落ちるが、Javaからmmapを使う方法はこれしかなく、Luceneに付属するどのディレクトリインプリメンテーションよりもこちらの方がはるかに高速だ)。また、OSキャッシュ上で直接処理を行うため物理メモリも全く無駄にせず、前述のJava GC問題がすべて回避される。

Lucene/Solrアプリケーションへの影響は?

OSの足を引っ張るような開発は避けるべきなので、ヒープスペース(-Xmx Javaオプション)のアロケートはできるだけ小さく抑えたい。また、インデックスアクセスがOSキャッシュへの直接渡しに依存することを覚えておきたい。これは、Javaのガーベッジコレクタともかなり相性が良い。

OSカーネルがファイルシステムキャッシュとして利用できるよう可能な限り多くの物理メモリを解放する。また、Luceneのコードがそこで直接動作するためディスクとメモリ間のページング/スワッピング回数が減少することを覚えておきたい。ただし、Luceneアプリケーションにヒープをアロケートしすぎるとパフォーマンスに支障が生じる。MMapDirectoryではLuceneがこれを必要としないのだ。

これが64ビットのOSやJava仮想マシンでしか予想通りの動作をしないのはなぜだろうか?

32ビットプラットフォームの制限の1つに、ポインタサイズがあり、ポインタは4 Gバイトである0から232-1までのアドレスにしかアクセスすることができない。大半のOSは残りのアドレス空間をデバイスなどのために予約しておくため、アドレス空間を3 Gバイトに制限している。つまり、プロセスに与えられるリニアアドレス空間の合計は3 Gバイトに制限され、これより大きいファイルは大きいbyte[]アレイとしてこの「小さい」アドレス空間にマッピングできないことになる。そして、この大型ファイルをマッピングすると、仮想空間(「番地」に相当するようなアドレス)はなくなってしまう。既存のシステムの物理メモリサイズがこの大きさを既に超えていることから、リソースを無駄にせずファイルをマッピングするアドレス空間はない(ここでは物理メモリではなく「アドレス空間」)。

だが、64ビットプラットフォームでは異なってくる。264-1は巨大な数字で、1800京バイトを越えるため、実際のところはアドレス空間に制限はない。残念ながら、大半のハードウェア(MMU、CPUのバスシステム)やOSはこのアドレス空間をユーザモードアプリケーション(Windows:43ビット)用に47ビットに制限している[4]。しかし、それでもTバイト単位のデータをマッピングするためのスペースは残っている。

一般的な誤解

仮想メモリに関するこれまでの説明を注意深く読んでいれば、以下が正しいことは簡単に検証できる。

MMapDirectoryは余計なメモリを消費せず、マッピングされたインデックスファイルのサイズはサーバが搭載する物理メモリの制限を受けない。mmap ()ファイルではメモリではなくアドレス空間だけを予約する。64ビットプラットフォームのアドレス空間は無償で利用できることを覚えておきたい。

MMapDirectoryはインデックス全体を物理メモリに読み込むことはしない。では、なぜそうしなければならないのだろうか?OSには、容易なアクセスを可能にするためファイルをアドレス空間にマッピングするよう要求しているだけで、それ以上の要求は決してしていない。JavaとOSはファイル全体をRAMに読み込むオプション(十分な余裕があれば)を任意で提供するが、Luceneはこのオプションを使わない(今後のバージョンで追加の可能性はある)。

MMapDirectoryは「top」から膨大な量のメモリが伝わるとサーバに過負荷を与えない。「top」(Linux)にはメモリ関連の3つのコラムがある「VIRT」、「RES」、および「SHR」だ。最初(VIRT、仮想)のものはアロケートされた仮想アドレス空間を伝える(さらに、これは64ビットプラットフォームでは無償だ)。IndexWriterでマージが行われている場合、この数字はインデックスサイズや物理メモリの数倍にもなる。IndexReaderが1つしかオープンされていない場合は、アロケートされたヒープスペース(-Xmx)にインデックスサイズを加えたものとほぼ同じになり、プロセスが使用する物理メモリにはならない。2番目のコラム(RES、常駐)のメモリはプロセスが処理用にアロケートした(物理)メモリ量を示し、Javaヒープスペース以下に収まる。最後のコラム(SHR、共有)はアロケートされた仮想アドレス空間がほかのプロセスと共有しているサイズを示す。MMapDirectoryを使用する複数のJavaアプリケーションが同じインデックスをアクセスしている場合はこの数字が大きくなっていく。一般的には、共有システムライブラリ、JARファイル、そしてプロセス実行イメージ本体(これもmmapされる)が必要とするスペースが見えるようになる。

MMapDirectoryを最適利用するためのmyOSand Java VMのコンフィギュレーション

まず第一に、LinuxディストリビューションとSolaris/Windowsのデフォルト設定は全く問題ない。しかし、(理解不足から)何でも自分でコントロールしたがる偏執的なシステム管理者もおり、これによりアプリケーションがアロケート可能な仮想アドレス空間の最大量が制限を受けてしまう。そこで、「ulimit -v」「ulimit -m」の両方が「unlimited」を表示することを確認したい。そうしないと、MMapDirectoryがインデックスのオープン時に「mmap failed」を表示する可能性がある。それぞれのセグメントが多い膨大な数のインデックスを持つシステムでもこのエラーが発生する場合は、/etc/sysctl.confのカーネルパラメータをチューニングする必要があるかもしれない。vm.max_map_countのデフォルト値は65530だが、これを引き上げる必要があるかもしれない。WindowsとSolarisの両システムでも同様のセッティングが可能だと思うが、その使用方法は読者ご自身でお調べいただきたい。

Java VMのコンフィギュレーションに関してはメモリ要件を検討し直したい。ヒープスペースは本当に必要な量だけ用意して、OSにはできるだけ多くを残しておきたい。経験則として、Lucene/Solrが動作するJavaに物理メモリの四分の一以上をヒープスペースとして使ってはならず、残りのメモリはOSのキャッシュ用に空けておきたい。サーバ上で動作するアプリケーションが増えたらそれに合わせて調整を行う。従来通り、物理メモリは多ければ多い方が良いが、インデックスサイズと同量までの物理メモリは不要だ。頻繁に使用されるページはカーネルがインデックスからうまくページインしてくれる。

システムを最適にコンフィギュレーションしたかどうか調べるには、「top」(正しい説明は前記参照)と同様のコマンドである「iotop」(Ubuntu Linuxなどでは「apt-get install iotop」でインストール可能)の両方を見る。Luceneプロセスでシステムのスワップイン/スワップアウトが多い場合は、おそらく使いすぎなのでヒープサイズを減らす。ディスクI/Oが多い場合はmmappedされたファイルが四六時中ページイン/アウトされないようRUM(Simon Willnauer語録)を追加購入したい。そして、最終手段はSSDになる。

mmapをお楽しみあれ。



| 関口宏司 | Luceneクラス解説 | 23:15 | comments(0) | trackbacks(0) |
検索窓の設置@ロンウイットホームページ

本日からロンウイットホームページに2年ぶり(?)くらいに検索窓が復活した。同様にこのブログの右上(幅の設定がおかしく、ページによっては右までスクロールしていただかないといけません)に設置されている検索窓も機能を復活した。弊社サブスクリプション版SolrとManifoldCFを使っている。

検索窓をつけたとたん、いろいろ過去に書いて忘れていた記事が一気に発掘できるようになった。たとえば、2年前まで動いていた検索機能について書いた記事を読むと、当時はクローラーにHeritrixを使っていたとある。今はManifoldCFを使ってホームページ、このブログ記事、soleami、SlideShareなどをクロールしている。

取り急ぎとにかく立ち上げることを目標にしたこのデモの最初のバージョンでは、単純なファセットとハイライト、そしてもしかして検索しかサポートしていないが、サジェストや類義語の自動生成などを今後は追加していきたい。

「もしかして検索」について少し説明しよう。たとえば、「ヤクルト」などと検索してみると、そういう単語はインデックス内にないので、「もしかして:リクルート」などとサジェストされる。

日本語のもしかして検索は、弊社製品サブスクリプションで提供している機能をここでは使っているが、実はその機能を実装する前に、LuceneコミュニティにLUCENE-3888として機能改善の提案をしている。当時は日本語の要請を飲んでもらうのに時間をかける余裕がなかったのでそのままになっているが、その気持ちはRobertに受け継がれAnalyzing Suggesterとして実装された(はずである。弊社ではまだ試していない)。

ただ、日本語でもしかして検索をやる場合はこれだけでは十分ではなく、インデクシングの前処理で専門用語抽出をしておく必要がある。


5月からSolrCloudあらため「Solr 4.2 クラウド分散運用」としてトレーニングコースが新設されました。5月受講者募集中です!

| 関口宏司 | 会社ホームページ | 10:41 | comments(0) | trackbacks(0) |
Lucene 4.0で実施されたIndexReaderの主なリファクタリング(Uwe氏のブログより)
ある案件でLucene 4.0におけるIndexReaderの変更点について調べる必要があった。いろいろ調べているうちにLuceneコミッターのUwe Schindler さんのブログに行き着いた。こちらの記事が変更における背景などに触れていてなかなかよかった。どうせなら日本語訳を掲載したいと思いUweさんに聞いたところ、快諾をいただけたので、以下に拙訳を掲載する:
本記事はSearchWorkingsに掲載された記事の再掲です。

Luceneは誕生当初よりIndexReaderとIndexWriterのAPIを通じてインデックスを直接的に読み書きできるようにしていました。しかし、APIは現実を反映していませんでした。過去にはIndexWriterからの視点ではこれは価値がありましたが、インデックスを読むときにこれはいくつかの問題の原因となっていました。Luceneインデックスは論理的には単一ですが実際にはそうではありません。最新のLucene trunkの開発ではタイプセーフと性能のために現実に触れられるようにしようとしています。しかし、Composite、AtomicそしてDirectoryReaderの詳細に入る前に、少しこれまでの経緯をおさらいしてみましょう。

バージョン2.9/3.0以降、LuceneはトップレベルのIndexReaderを直接検索実行することからセグメント毎に検索する方法に舵を切りました。Simon Willnauerさんが彼のブログで説明しているとおり、これによりoptimizeはもはや検索性能を最適化するのには必要ではなくなったことを意味します。実際、optimizeは検索を遅くし、最適化後はファイルシステムとLuceneインデックス内部のキャッシュを無効化してしまいます。

標準のLuceneインデックスはいくつかのセグメントと呼ばれるものから成ります。セグメントはそれ自体小さなインデックスとみなせます。インデックス作成中はLuceneは新しいドキュメントを分割されたセグメントに書き出します。ある閾値にセグメント数が達すると、セグメントはマージされます(マージをビジュアルに表現したMike McCandlessさんのブログはこちら):

Luceneのマージのしくみ

Lucene 2.9より以前は、インデックスが複数のセグメントに分かれていようと、セグメントは1つの大きなインデックスのように扱われていました。しかし2.9からはセグメント毎に働くようにシフトしてきました。そして現在は、Luceneのほとんどすべての構成とコンポーネントはセグメント毎に動作するようになっています。とりわけこれによりLuceneは再オープン時にインデックスのすべてではなく、実際の変更分のみをロードします。それでも利用者からは1つの大きな論理インデックスに見えます(下記IndexSearcherのコード例参照)が、内部ではセグメント毎に動作しています:

public void search(Weight weight, Collector collector) throws IOException {  
  // iterate through all segment readers & execute the search  
  for (int i = 0; i < subReaders.length; i++) {  
    // pass the reader to the collector  
    collector.setNextReader(subReaders[i], docStarts[i]);  
    final Scorer scorer = ...;  
    if (scorer != null) { // score documents on this segment  
      scorer.score(collector);  
    }
  }  
}


しかし、論理インデックスとセグメント間の差異は一貫的にコード階層には反映されていませんでした。Lucene 3.xでは、まだサブReaderをイテレートせずトップレベル(論理)Readerにて検索を実行していたのでした。そのため複数セグメントからなるインデックスでは検索は劇的に遅くなりました。そういうことで昔のLuceneではインデックスをよくoptimizeするよう指導されていました。

もう少し詳しく問題を説明しましょう。DirectoryのIndexReaderは内部的にはSegmentReaderを内包したMultiReaderです。もしMultiReaderにTermEnumやポスティングを問い合わせるとそれはオンザフライでサブReaderのタームやポスティングをマージします。このマージ手続きはプライオリティキューまたは類似のデータ構造を用い、それによりサブReaderの数に応じて深刻な性能劣化を引き起こします。

このような内部的な制限にもかかわらず、MultiReaderと組み合わせてSegmentReaderを利用することはLuceneにおいて上位レベルの構造に影響を与えました。インデックスを非転置にしたFieldCacheは検索時にインデックスされた値によって検索結果をソートしたりドキュメント/値のルックアップに使われます。トップレベルReaderを非転置にするとFieldCacheの二重化を引き起こし、同じ内容のキャッシュを複数生成してしまいます。

Lucene 4.0のタイプセーブIndexReader

Lucene 4.0は当初からタームとポスティングデータをMultiReaderまたはDirectoryReader(ディスク上のインデックスをIndexReader.open(Directory)でオープンしたときに返されるReaderの実装)などのコンポジットなReaderから取得できないように設計されました。Lucene trunkの最初のバージョンは非SegmentReaderからFieldやTermsEnumやDocsEnumを取得しようとすると単純にUnsupportedOperationExceptionを投げていました。このタイプセーフの欠如のせいでプログラマーはIndexReaderがcompositeかatomicかをチェックしなければポスティングを取得できるかどうか判断できませんでした。

Lucene 4.0における主なAPI変更のひとつであるLUCENE-2858では、インデックスとそのセグメント上のLuceneクライアントコード視点を完全に変更しました。抽象クラスIndexReaderは検索結果を表示するのにストアフィールドをアクセスするのに本質的なメソッドだけを持つようにリファクタリングされました。それはもはやインデックスからタームまたはポスティングを取得することはできません。削除ドキュメントに関してももはや不可視です。Luceneはクエリリライトやドキュメント収集といった手続きを自動的にサブReaderに委譲します。

もしインデックスの詳細に立ち入り自分のクエリを書きたいのなら、新しい抽象サブクラスであるAtomicReaderまたはCompositeReaderを詳しく調べてください:

AtomicReaderインスタンスは現時点でTerm、ポスティング、DocValueおよびFieldCacheの唯一のソースです。クエリはセグメント毎のアトミックなReader上で実行されるようになり、FieldCacheはAtomicReaderによってキーづけされます。対するCompositeReaderはそれが保持するメンバーを取得するユーティリティメソッドを持っています。しかし気をつけてください、そのメンバーはアトミックである必要はありません。タイプセーフの次に私たちが行ったのは、抽象クラスIndexReaderからIndexCommitとバージョン番号の概念を取り除いたことでした。これらのIndexWriterと関連する機能はDirectoryReaderに移行されました。Lucene trunkにおけるクエリの実行は以下のようになります:

DirectoryReader reader = DirectoryReader.open(directory);  
IndexSearcher searcher = new IndexSearcher(reader);  
Query query = new QueryParser("fieldname", analyzer).parse(“text”);  
TopDocs hits = searcher.search(query, 10);  
ScoreDoc[] docs = hits.scoreDocs;  
Document doc1 = searcher.doc(docs[0].doc);  
// alternative:  
Document doc2 = reader.document(docs[1].doc);


見慣れたコードですね?実際、API利用者にはこの主なリファクタリングは大きな変更をもたらしません。もしアップグレード中にコンパイルエラーが起こったら、それは性能ボトルネックを示している可能性があります。

Filterにおけるセグメント毎セマンティクスの強制

もしカスタムFilterを使ったコードを持っているなら、Luceneの別の新しいクラス階層に気づいているかもしれません(LUCENE-2831参照):IndexReaderContextの仲間であるAtomic/CompositeReaderContextです。これはだいぶ前に追加されましたが、AtomicやCompositeのReaderたちと関連深いものです。

セグメント毎検索に向かうLucene 2.9ではそれを扱えない多くのカスタムクエリとフィルタが開発されました。たとえば、いくつかのFilter実装は渡されたIndexReaderをIndexSearcherへ渡されたIndexReaderと同等であると期待しました。明らかにこのパラダイムシフトは多くのアプリケーション、とりわけこれらクロスセグメントのデータ構造を利用するアプリケーション(Apache Solrのような)を壊しました。

Lucene 4.0では、私たちはIndexReaderContextというSearcherプライベートなReader階層を導入しました。クエリやフィルタを実行中、Luceneはもはや生のReaderをQuery、FilterもしくはCollectionに渡さなくなりました。その代わり、コンポーネントはAtomicReaderContext(本質的な階層葉)を通じてトップレベルReaderに関連したドキュメントベースのような関連プロパティとして用意されます。これによりクエリとフィルタはセグメント毎であるにもかかわらずドキュメントベースIDのロジックにビルドアップされます。

トップレベルReaderはまだ使えるか?

それでもまだトップレベルReaderを使う正当なユースケースはあります。たとえば、インデックスへのアトミックなビューが欲しい場合です。オートコンプリートやファセットなどで完全なインデックスのすべてのタームが欲しい場合があげられます。このためLuceneはAtomicReaderをエミュレートするSlowCompositeReaderWrapperのようなラッパーユーティリティを用意しています。注意:アトミックなエミュレーターの利用は、タームやポスティングやDocValueやFieldCacheのマージが必要になり深刻なスローダウンの原因となるので注意してください。

Terms terms = SlowCompositeReaderWrapper.wrap(directoryReader).terms(“field”);


残念なことに、Apache Solrはこういったコードを多く使っています。Solrのファセットやフィルタリングはアトミックなセグメント毎に動作するように書き換えられなければなりません!これらのプラグインやコンポーネントはSolrIndexSearcherが提供しているアトミックなビューをSolrIndexSearcher.getAtomicReader()を通じて取得しています。

もしメモリ効率がよく速い検索アプリケーションを書きたいのなら、Solr 4.0を使うのではなくLuceneが提供している新しいファセットモジュールとSearcherManagerを使って独自のアプリケーションを書くことをお奨めします!

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

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

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


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


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

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

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

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

結果

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

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


今回作成したプログラム

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

knbc2cabocha_ne.rb

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

KNBC_DIR = ARGV[0]

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

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

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


eval_ne.rb

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

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

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

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

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

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

tp = fp = fn = 0

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

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

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

ans_file.close
out_file.close

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

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

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


オペレーションメモ

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

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

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

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

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

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

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


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

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

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


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

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

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


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

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

結果

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

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


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

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

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

今回作成したプログラム

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

KNBC_DIR = ARGV[0]

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

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


オペレーションメモ

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

今回作成したプログラム

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

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

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

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

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

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

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

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

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

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


オペレーションメモ

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

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

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

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

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

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

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

# モデルファイル量ごとの評価(エラー率の表示)
$ ruby diff_chunk.rb G1011-norm.dat O10A01-norm.txt 
error = 0.2116 (1354/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A02-norm.txt 
error = 0.1861 (1191/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A03-norm.txt 
error = 0.1708 (1093/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A04-norm.txt 
error = 0.1598 (1023/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A05-norm.txt 
error = 0.1509 (966/6400)
$ ruby .diff_chunk.rb G1011-norm.dat O10A06-norm.txt 
error = 0.1448 (927/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A07-norm.txt 
error = 0.1383 (885/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A08-norm.txt 
error = 0.1341 (858/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A09-norm.txt 
error = 0.1272 (814/6400)
$ ruby diff_chunk.rb G1011-norm.dat O10A10-norm.txt 
error = 0.1259 (806/6400)
| 関口宏司 | NLP | 12:31 | comments(0) | trackbacks(0) |
+ Solrによるブログ内検索
+ PROFILE
  12345
6789101112
13141516171819
20212223242526
27282930   
<< April 2014 >>
+ 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