ELIZA NG(2)やさしい Rakuten MA

Rakuten MA understand even monkeys


2019/12/19
藤田昭人


年の瀬は何かと仕事が降り注いできて…

本稿ではピュア JavaScript でコンパクトな形態素解析システムである Rakuten MA のレクチャーをします。 対話システムに限らず日本語の自然言語処理では、ほぼ必須の形態素解析。 僕のELIZA実装ではスマートスピーカーへの搭載も念頭においているので出来るだけコンパクトなエンジンを望むところです。 そういった意向もあって以前紹介した 日本語化ELIZA では TinySegmenter を使いました。これ、ピュア JavaScript なシステムで大変便利だったのですが、 ほぼ唯一の難点は学習機能がないこと。実は TinySegmenter用の学習ツール も紹介されていることから「これで頑張るのかな?」と思っていたのですが…


Rakuten MA について

…と思っていたのですが、こちらの意図を見透かしような Rakuten MA がリリースされてました。

github.com

日本語版README もあります。もちろん npm にも登録されてるので…

www.npmjs.com

普通に nodejs/npm の実行環境がインストールされていれば、以下のように普通に利用できます。 Unix環境であればこんな感じ…

$ npm install rakutenma
$ ( cd node_modules/rakutenma; node demo.js )
[ [ '彼', '' ],
  [ 'は', '' ],
  [ '新', '' ],
  [ 'し', '' ],
  [ 'い', '' ],
  [ '仕', '' ],
  [ '事', '' ],
  [ 'で', '' ],
  [ 'き', '' ],
  [ 'っ', '' ],
  [ 'と', '' ],
  [ '成', '' ],
  [ '功', '' ],
  [ 'す', '' ],
  [ 'る', '' ],
  [ 'だ', '' ],
  [ 'ろ', '' ],
  [ 'う', '' ],
  [ '。', '' ] ]
[ [ '彼', 'N-nc' ],
  [ 'は', 'P-k' ],
  [ '新し', 'V-c' ],
  [ 'い', 'P-k' ],
  [ '仕事', 'N-nc' ],
  [ 'で', 'P-k' ],
  [ 'きっ', 'N-nc' ],
  [ 'と', 'P-rj' ],
  [ '成功', 'N-nc' ],
  [ 'す', 'P-k' ],
  [ 'るだ', 'V-c' ],
  [ 'ろう', 'X' ],
  [ '。', 'M-p' ] ]
[ [ 'うらにわ', 'N-nc' ],
  [ 'に', 'P-k' ],
  [ 'は', 'P-rj' ],
  [ 'にわにわとり', 'N-nc' ],
  [ 'が', 'P-k' ],
  [ 'いる', 'V-dp' ] ]
{ ans:
   [ [ 'うらにわ', 'N-nc' ],
     [ 'に', 'P-k' ],
     [ 'は', 'P-rj' ],
     [ 'にわ', 'N-n' ],
     [ 'にわとり', 'N-nc' ],
     [ 'が', 'P-k' ],
     [ 'いる', 'V-c' ] ],
  sys:
   [ [ 'うらにわ', 'N-nc' ],
     [ 'に', 'P-k' ],
     [ 'は', 'P-rj' ],
     [ 'にわにわとり', 'N-nc' ],
     [ 'が', 'P-k' ],
     [ 'いる', 'V-dp' ] ],
  updated: true }
[ [ 'うらにわ', 'N-nc' ],
  [ 'に', 'P-k' ],
  [ 'は', 'P-rj' ],
  [ 'にわ', 'N-n' ],
  [ 'にわとり', 'N-nc' ],
  [ 'が', 'P-k' ],
  [ 'いる', 'V-c' ] ]
$

これは添付されてる demo.js の実行例ですが、何を実行しているのかは README に解説があります。


Rakuten MA の特徴

また README によれば Rakuten MA には次の特徴があるそうです。

  • 100% JavaScript による実装。ほとんどのブラウザや Node.js 上で動きます。
  • 言語非依存の文字単位タグ付けモデルを採用。日本語・中国語の単語の分かち書きおよび品詞付与ができます。
  • オンライン機械学習 (Soft Confidence Weighted, Wang et al. ICML 2012) を使い、解析モデルの差分アップデートが可能。
  • 素性セットのカスタマイズが可能。
  • モデルサイズ削減のため、素性ハッシング、量子化、フィルタリングをサポート。
  • 一般分野のコーパス (BCCWJ [Maekawa 2008] と CTB [Xue et al. 2005]) およびネットショッピング分野のコーパスから学習したモデルを同梱。

…ってことなんですけど、これNLP自然言語処理)の専門家向けのショートコメントですよね?

僕のような門外漢には「わかったような?わからないような?」といった説明だったりします。  なので、参考書を少し。そもそも、現在の形態素解析では ビタビアルゴリズムViterbi algorithm) が使われているそうです。これ、かな漢字変換以来のアルゴリズムなんだそうで、 ちょっと古いですが、次の本に丁寧な説明があります。

gihyo.jp

ちなみに、この本は自然言語処理分野への機械学習の活用に関わる基礎知識が網羅されてるので「非常にお買い得な一冊」のように僕には見えます。

形態素解析を本格的に勉強したい人向けには、 有名な MeCab の開発者である工藤 拓さんが次の技術解説書を出しておられるとのことです。

bookmeter.com

以上、形態素解析に関する突っ込んだ説明は上記2冊にお任せして、 以降は Rakuten MA の使い方について具体的な紹介をします。


Rakuten MA を使ったサンプルコード

Rakuten MAの README は網羅的ですがコンパクトな説明ですし、 開発者が書いた論文 も比較的短く、どちらかというと専門家が効率よく読むようにできてます。 各機能ついては「箇条書きが3〜4行」の説明だけなので、 「ノービスユーザー向けのコードサンプルがあれば良いのに…」と思い立ち 勝手に書き下ろした JavaScript のサンプルコードを掲載することにしました。 各機能はちゃんと動いているようですが…もしチョンボがあったらコメントください。


準備: サンプルコードで使用する学習データについて

もちろん、機械学習を動かすので学習データが必要になります。対話システムに活用したいので 「日常生活に必要な平易な表現が中心」 「できれば1万件以上のエントリがある学習データが欲しい」 という条件で探した結果、次のデータを使うことにしました *1

www.jnlp.org

これは在日外国人の方々の日本語習得を目的としたコーパスなんだそうで、 日本人の慣用表現とやさしい日本語、英訳の3つが併記された、 全部で5万の短めの文章のセットになっています。

データは MS Excel の xlsx ファイルの形式でダウンロードできます。 が、そのままでは取り扱いが面倒なので、 僕は次のコードを書いて json に変換して使っています。

const name = "T15-2018.2.28";

const XLSX  = require("xlsx");
const Utils = XLSX.utils;
const book  = XLSX.readFile(name+".xlsx");
const sheet_name_list = book.SheetNames;
const sheet = book.Sheets[sheet_name_list[0]];

var whole = Utils.sheet_to_json(sheet);

var out = [];
for (var i = 0; i < whole.length; i++) {
    var elm = {};
    elm.id = i;
    elm.regular  = whole[i]['#日本語(原文)'];
    elm.easy     = whole[i]['#やさしい日本語'];
    elm.english  = whole[i]['#英語(原文)'];
    out.push(elm);
}

var fs = require('fs');

fs.writeFile(name+".json",
    JSON.stringify(out, null, 2), function(err, result) {
    if (err) console.log('error', err);
});

動かす手順は以下のとおり。

$ npm install rakutenma
$ npm install xlsx
$ 
$ cat > xlsx2json.js 
$ node xlsx2json.js
$ ls -l T15*
-rw-r--r--@ 1 fujita  staff  10117994 12 10 15:02 T15-2018.2.28.json
-rw-r--r--@ 1 fujita  staff   3547209 11 30 19:55 T15-2018.2.28.xlsx
$ 

見てのとおり、json に変換するとデータ量は3倍ぐらいに膨れます。



以降、Rakuten MAのREADMEにある箇条書きの説明を引用し、 それどおりに実装したコードを掲載する形式で説明します。 コード自体はプログラミングさえできれば誰でも実装できるレベルの内容です。 課題に追われている学部生諸君はコピペでお使いください(笑) *2


学習済みモデルを使って日本語・中国語の文を解析

まず、リリースに同梱されている解析モデルを使って形態素解析をします。

  1. 以下のように、学習済みモデルをロード model = JSON.parse(fs.readFileSync("model_file")); し、 rma = new RakutenMA(model); もしくは rma.set_model(model); としてモデルをセットします。
  2. featset を言語に応じて設定します (例:日本語の場合、rma.featset = RakutenMA.default_featset_ja; 中国語の場合、rma.featset = RakutenMA.default_featset_zh;)
  3. 同梱モデル (model_zh.jsonmodel_ja.json) を使用する場合、15ビットの素性ハッシング関数をセットすることを忘れずに (rma.hash_func = RakutenMA.create_hash_func(15);)
  4. rma.tokenize(input) を使って、入力文を解析します。

ここでは先に紹介したコーパスのうち「やさしい日本語」5万文を入力にしたサンプルを書きました。 こんな感じ…

const name = "T15-2018.2.28";
var fs = require('fs');
var whole = JSON.parse(fs.readFileSync(name+".json"));

var RakutenMA = require('rakutenma');
var model = JSON.parse(fs.readFileSync("node_modules/rakutenma/model_ja.json"));
var rma = new RakutenMA(model, 1024, 0.007812);
rma.featset = RakutenMA.default_featset_ja;
rma.hash_func = RakutenMA.create_hash_func(15);

var out = [];
for (var i = 0; i < whole.length; i++) {
    out.push(rma.tokenize(whole[i].easy));
    if (i % 100 == 0) console.error("  tokenize i = " + i);
}

fs.writeFile("learndatabase.json",
    JSON.stringify(out, undefined, 2), function(err, result) {
    if (err) console.log('error', err);
});

このコードを実行すると "learndatabase.json" というデータファイルが生成されます。


オリジナルの解析モデルの学習

前項で生成した "learndatabase.json" を使って、オリジナルの解析モデルを生成します。

  1. 学習用コーパス ([トークン, 品詞タグ]の配列からなる学習用の文の配列) を準備します。
  2. new RakutenMA() として Rakuten MA のインスタンスを初期化します。
  3. featsetを (必要に応じて、ctype_func, hash_func, 等も) セットします。
  4. train_one() メソッドに、学習用の文を一つずつ与えます。
  5. SCW は、通常1エポック(学習コーパスの文を最初から最後まで与えてモデルを学習する繰り返し1回分)後には収束します。ステップ4をさらにもう2,3エポック繰り返すことにより、さらに精度が上がる可能性があります。

オリジナルの解析モデルを学習する場合のサンプルが、scripts/train_ja.js (日本語) と scripts/train_zh.js (中国語) にありますので、ご参照ください。

ということなので node_modules/rakutenma/scripts/train_ja.js をチラ見しながら 次のオンライン機械学習をするコードを書きました。

var fs = require('fs');
var RakutenMA = require('rakutenma');
rma = new RakutenMA();
rma.featset = RakutenMA.default_featset_ja;
rma.hash_func = RakutenMA.create_hash_func(15);

var rcorpus = JSON.parse(fs.readFileSync("learndatabase.json"));

console.error( "total sents = " + rcorpus.length );

const iter = 1;

for (var k = 0; k < iter; k ++) {
    console.error("iter = " + k);
    for (var i = 0; i < rcorpus.length; i++) {
        var res = rma.train_one(rcorpus[i]);
        if (i % 100 == 0)
            console.error("  train i = " + i);
    }
}

fs.writeFile("learndata.json", JSON.stringify(rma.model), 
function(err, result) {
    if (err) console.log('error', err);
});

コード中の変数 iterイタレーションの回数です。 箇条書きの5.で説明のあるエポックの回数を指定します。 コードでは1回としていますが、通常は3〜4回くらいは繰り返すようです。 これは学習プロセスを実行するので、実行終了まで数分程度要します。 変数 iter を増やすと、実行時間も倍数で増えるので、ここでは1回としました。


学習済みモデルの再学習 (分野適応、エラー修正等)

オリジナルの解析モデルが出来あがると普通に形態素解析ができるのですが、 なかには予期せぬ解析をするケースもあります。 「やさしい日本語」コーパスの場合は「今夜来いよ。」を 誤まって {今}{夜来}{い}{よ}{。} と解析しました。 そこで解析モデルの再学習を行います。

  1. 学習済みモデルをロードし、Rakuten MA のインスタンスを初期化します。(上記の「学習済みモデルを使って日本語・中国語の文を解析」を参照)
  2. 学習用のデータを用意します。フォーマットは、上記「オリジナルの解析モデルの学習」にて用意した学習用コーパスと同じです。(コーパスのサイズはほんの数文でも構いません。必要なサイズは、再学習する対象や度合いによって変わってきます。)
  3. train_one() メソッドに、学習用の文を一つずつ与えます。

再学習、要は train_one() は使って正解を(必要があれば何度も)教え込むだけなようです。
コードは以下のとおり…

var fs = require('fs');

var RakutenMA = require('rakutenma');
var model = JSON.parse(fs.readFileSync("learndata.json"));
var rma = new RakutenMA(model);
rma.featset = RakutenMA.default_featset_ja;
rma.hash_func = RakutenMA.create_hash_func(15);

var s = "今夜来いよ。教えますから。";
console.log(s);
console.log("ー before ーーーーーーーーー")
console.log(rma.tokenize(s));
var res = rma.train_one([
        [ "今夜", "N-nc" ],
        [ "来い", "V-dp" ],
        [ "よ",   "P-sj" ],
        [ "。",   "M-p"  ],
        [ "教え", "V-c"  ],
        [ "ます", "X"    ],
        [ "から", "P-sj" ],
        [ "。",   "M-p"  ]]);
console.log("ー after  ーーーーーーーーー")
console.log(rma.tokenize(s));

このコード(example3.js)を実行すると次のようになります。

$ node example3.js
今夜来いよ。教えますから。
ー before ーーーーーーーーー
[ [ '今', 'P' ],
  [ '夜来', 'N-nc' ],
  [ 'い', 'P-sj' ],
  [ 'よ', 'P-sj' ],
  [ '。', 'M-p' ],
  [ '教え', 'V-c' ],
  [ 'ます', 'X' ],
  [ 'から', 'P-sj' ],
  [ '。', 'M-p' ] ]
ー after  ーーーーーーーーー
[ [ '今夜', 'N-nc' ],
  [ '来い', 'V-dp' ],
  [ 'よ', 'P-sj' ],
  [ '。', 'M-p' ],
  [ '教え', 'V-c' ],
  [ 'ます', 'X' ],
  [ 'から', 'P-sj' ],
  [ '。', 'M-p' ] ]
$ 

これで正しく {今夜}{来い}{よ}{。} と解釈するようになりました。

モデルサイズの削減

解析モデルはかなり大きなデータになりますが、README によると「付属の minify.js を使うと小さくできる」そうです。

モデルのサイズは (素性ハッシングを使用したとしても) 再配布してクライアント側で使用するにはまだ大きすぎることがあります。 素性量子化を適用するスクリプト scripts/minify.js を使用して、学習したモデルのサイズを削減することができます (詳細については、論文 [Hagiwara and Sekine COLING 2014] を参照してください。)

このスクリプトは、node scripts/minify.js [入力モデルファイル] [出力モデルファイル] として実行すると、minify されたモデルファイルを書き出します。

…とのことなんですが、実は最新の nodejs で実行すると minify.js はズッコケます。 (理由は単に fs.writeFile のコールバックがオプションでなくなったせいなんですがね) パッチを当てるほど大きなコードでもないので、僕が修正した minify.js を次に示します。

var RakutenMA = require('rakutenma');
var fs = require('fs');
var Trie = RakutenMA.Trie;

var model = JSON.parse( fs.readFileSync( process.argv[2] ) );

var new_mu = {};
Trie.each( model.mu,
       function( key, mu_val ) {
           var new_mu_val = Math.round( mu_val * 1000 );
           if (new_mu_val != 0)
           Trie.insert(new_mu, key, new_mu_val);
           // console.log( [key, mu_val, new_mu_val] );
       }
     );

model = {mu: new_mu};

// write out the final file
fs.writeFile(process.argv[3], JSON.stringify(model), 
function(err) {
    if (err) console.log('error', err);
});

このコードを実行すると次のようになります。

$ node minify.js learndata.json learndata.min.json
$ ls -l learndata.json learndata.min.json
-rw-r--r--  1 fujita  staff  12461795 12 10 21:47 learndata.json
-rw-r--r--  1 fujita  staff   2094996 12 17 16:09 learndata.min.json
$

確かに解析モデルデータは小さくなってます。が…

注意 このスクリプトは、学習された SCW の "sigma部" も削除してしまうため、一度 minify されたモデルを再学習することはできません。必要であれば、モデルを再学習した後、minify してください。

…ってことです。単に好奇心から、 解析モデルデータを差し替えて先ほどの再学習コードを実行してみました。

$ node example3.js
今夜来いよ。教えますから。
ー before ーーーーーーーーー
[ [ '今', 'P' ],
  [ '夜来', 'N-nc' ],
  [ 'い', 'P-sj' ],
  [ 'よ', 'P-sj' ],
  [ '。', 'M-p' ],
  [ '教え', 'V-c' ],
  [ 'ます', 'X' ],
  [ 'から', 'P-sj' ],
  [ '。', 'M-p' ] ]
ー after  ーーーーーーーーー
[ [ '今', 'P' ],
  [ '夜来', 'N-nc' ],
  [ 'い', 'P-sj' ],
  [ 'よ', 'P-sj' ],
  [ '。', 'M-p' ],
  [ '教え', 'V-c' ],
  [ 'ます', 'X' ],
  [ 'から', 'P-sj' ],
  [ '。', 'M-p' ] ]
$ 

学習のAPI train_one() を呼び出してもエラーは発生しませんが、 解析モデルに反映されることも無いようです。


おわりに

ということで、本項では Rakuten MA を使った基本的なサンプルコードを紹介しました。 おそらく「README の説明は素っ気無い」と感じる方には多少の役に立つことでしょう。

Rakuten MA は (ウェブ・アプリケーションなどでブラウザー側で形態素解析が実行できるぐらいに) 非常にコンパクトな形態素解析器です。ピュア JavaScript の実装ですが、 ちゃんとしたオンライン機械学習(逐次学習とは言わない?)を備えているので、 機械学習の入門者向けの実習教材としても需要があるんじゃ無いか?と思います。


傾聴対話の実現を目指す僕が感じる魅力は「非常に簡単に再学習できる」ところで、 この機能を使うと対話システムで「言葉を覚える」プロセスを再現できるように思うからです。 実際、人間同士の会話でも「言葉やその意味を覚える」行為は頻繁にあります。 これはワイゼンバウムが 第2論文 で語っていた「サブコンテキスト」の典型例の1つなのではないかと僕は考えています。 また「傾聴」という観点でも「言葉を教える」という行為が 対話システムへの愛着を演出してくれるのではないかと思います。

…ってことで、僕の並々ならぬ期待感も言い添えて Rakuten MA の紹介を終わります。

以上

*1:ちなみに、このコーパスを作成された 長岡技術科学大学 電気電子情報工学専攻 自然言語処理研究室は2020年3月末に閉鎖するそうです。

www.jnlp.org

研究室の閉鎖後のこのコーパスの取り扱いはよくわからないので、 必要な方は今のうちにダウンロードしておいた方が良さそうです。 コーパスの作成を指導なさった山本先生、 およびコーパスの作成を担当された学生の方々に感謝します。

*2:僕の経験則なのですが、この種の未知の外部ライブラリを使うときは、 予め(出来るだけ単機能の)ショート・サンプルを書き溜めておくと、 自分が書いているプログラムに組み入れる時に何かと便利です。
まぁ、最近ではユニット・テストが常識みたいなので改めて断る必要もないかな?