Word2Vec(2)distance.js
藤田昭人
本稿は 前回 の続編です。
Googleのオリジナル実装を使うと比較的お手軽に Word2Vec が使えることがわかりました。が、 BookBot は JavaScript 専用 PaaS である Glitch で動いているので(コーパスの学習は word2vec コマンドを使うとしても)word2vec の ご利益を得るには、 学習済みデータへアクセスする JavaScript のコードが必要になります。
で、最近では JavaScript にも慣れてきたので 「サクッと作れるだろう…」と 当初は甘くみていたのですが、さにあらず。 UNIXプログラミングでは 使い慣れてたはずのストリームの扱いに ハマるハマる。 本稿ではそのドタバタの結果を 紹介したいと思います。
学習済みデータが取り込めない
まずは nodejs の常套句のような次の2行で、前回紹介した 東北大学の日本語 word2vec 学習済みデータ を取り込もうとしたのですが…
const fs = require('fs'); var buf = fs.readFileSync('entity_vector/entity_vector.model.txt', 'utf8');
これを nodejs で実行すると…
$ node a.js buffer.js:608 slice: (buf, start, end) => buf.utf8Slice(start, end), ^ Error: Cannot create a string longer than 0x1fffffe8 characters at Object.slice (buffer.js:608:37) at Buffer.toString (buffer.js:805:14) at Object.readFileSync (fs.js:421:41) at Object.<anonymous> (/Users/fujita/xtr/BookBot/WikiEntVec/a.js:2:14) at Module._compile (internal/modules/cjs/loader.js:1063:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10) at Module.load (internal/modules/cjs/loader.js:928:32) at Function.Module._load (internal/modules/cjs/loader.js:769:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12) at internal/main/run_main_module.js:17:47 { code: 'ERR_STRING_TOO_LONG' } $
…ズッコケます。
使ったことのある方はご存知でしょうが、 nodejs では fs.readFileSync の第2引数に文字コードを指定しておくと、 ファイルから取り込んだデータを指定した文字コードの文字列に変換してくれるのです。 その変換途中でどうやらズッコケているようです。
そこで読み込んでいるテキストファイルのサイズを確認すると…
$ ls -l entity_vector/ total 5475416 -rw-r--r--@ 1 fujita staff 830354941 2 17 2017 entity_vector.model.bin -rw-r--r--@ 1 fujita staff 1951880197 2 17 2017 entity_vector.model.txt
…2GB弱の巨大なテキストファイルでした。 (Wikipedia日本語版の全文だがら当然か)
ちなみに nodejs の場合 readFileSync など インメモリに取り込むファイルのサイズの上限は2GBです。 このファイルの場合は2GBを超えてはないので、 文字コードを指定しなければ(readFileSyncを第1引数だけで呼ぶ)エラーは出ませんが、 その後文字コードの変換ができないので「万事休す」ことに変わりはありません。
nodejs でのストリーム操作
大容量ファイルへの対処方法は「ストリームによる処理」が定石と言われてます。 もちろん javascript もストリーム処理をサポートしており「nodejs ストリーム」で ググるとそのための情報をたくさん集められます。 例えば、次のページは Stream API を網羅的に紹介してくれているので便利…
…なんですが、javascript の Stream API は非同期で実行されるハンドラを 引き渡す仕様になっていて、そのハンドラの書き方の解説を見つけるの ひと苦労します。
そこでハンドラを書いてみました。 次のソースは2GB以上のファイルを1行ずつリードしてコンソールに表示する、 UNIXの cat コマンドみたいな javascript コードです。
const fs = require('fs'); const readline = require('readline'); var file = 'entity_vector/entity_vector.model.txt'; const reader = async function(rl) { for await (const chunk of rl) { console.log(chunk); } } async function main() { const rs = fs.createReadStream(file, 'utf8'); const rl = readline.createInterface(rs, {}); await reader(rl); } main();
このプログラムでは関数 reader が非同期実行できるハンドラなんですが、 関数 main では reader が全てのストリームを受け取って処理し終えるまで 待ってくれます。javascript の非同期実行といえば 昔からある promise/then を使ったコードをよく見かけますが、 このプログラムでは async/await を使ってます。
それからストリームから1行ずつデータを取り出す readline も使ってます。 関数 reader の for await of ループは UNIX の gets を使ったループと等価で console.log(chunk); で1行ごとコンソールに出力してます。 この部分を任意に書き換えて UNIX の フィルター系コマンドと 等価なコードを書くことができます。
コサイン類似度
さて、オリジナル実装に含まれている distance コマンド。 このコマンドは任意の単語を指定すると、 その単語と意味的に距離が近い単語を距離の近い順にソートして、上位40個表示してくれます。 この「意味的な距離が近い」計算とはコサイン類似度の計算で、 実は「2つの単語ベクトル間の角度」を求めています。 計算結果は1〜−1の範囲の値をとり、 1なら角度ゼロ(完全一致)、 0なら角度は90°(意味的には無関係) −1なら角度は180°(意味的には正反対?) を意味します。
Wikipedia日本語版をチェックしてみたのですが、 「コサイン類似度」というページはない( 「ベクトルのなす角」 というページ転送されます)ので、 代わりに次のページを参考にしてください。
ちなみに、Wikipedia英語版では "Cosine similarity” というページで 「情報検索やテキストマイニングでは2つの文書がその主題に関してどれだけ類似しているかを示す有用な尺度となる」 と紹介されています。
さて、肝心のコサイン類似度の計算方法。 当初は「オリジナル実装のコードを見れば」 と考えていたのですが……… とても読解できない「古き悪しき実装」でした。 やむなく上記のページの公式を見ながら 次のコードを書きました。
function Absolute(m) { var ret = 0; for (var i = 0; i < m.length; i++) { ret += m[i] * m[i]; } return(Math.sqrt(ret)); } function DotProduct(m1, m2) { var ret = 0; for (var i = 0; i < m1.length; i++) { ret += m1[i] * m2[i]; } return(ret); } function CosineSimilarity(dot, nrm1, nrm2) { return(dot/(nrm1*nrm2)); }
コサイン類似度は2つのベクトルの 内積 (ドット積) を各ベクトルの 絶対値 で割ることで求めます。この絶対値は各々のベクトルの長さを表し ノルム と呼ばれることもあります。 ベクトルの絶対値、内積、コサイン類似度に対応する Absolute、 DotProduct、 CosineSimilarity を定義しました。
JavaScript 版 distance
以上のコードを組み合わせてJavaScript版 distance.js を作成しました。
ソースコードは末尾に添付しておきます。
オリジナル実装との違いは 次のようにターゲットとなる単語を引数で指定するところです。
$ ./distance.js entity_vector/entity_vector.model.txt '[アラン・チューリング]' Word: [アラン・チューリング] 1015474 times Word Cosine distance -------------------------------------------------------------------------- [ジョン・フォン・ノイマン] 0.8238200737420581 [クロード・シャノン] 0.7706864375586782 [ジョン・マッカーシー] 0.7634118658850984 チューリング 0.7550014937957042 [クルト・ゲーデル] 0.7505430963870695 [ハーバート・サイモン] 0.7503034618503429 [ダフィット・ヒルベルト] 0.7381630672368353 [アルベルト・アインシュタイン] 0.7370111227611059 [スタニスワフ・ウラム] 0.7355724275046345 [ヴォルフガング・パウリ] 0.7320032112916035 [エンリコ・フェルミ] 0.729639429509676 [マックス・ボルン] 0.7238478243360493 [アロンゾ・チャーチ] 0.7203194651116861 [ロバート・オッペンハイマー] 0.7152767971441714 [ポール・ディラック] 0.7129907170231481 [エミー・ネーター] 0.7093252080731395 [ヘルマン・ワイル] 0.708357522309429 [エルヴィン・シュレーディンガー] 0.707707742898606 [ノーバート・ウィーナー] 0.7072144624267143 [バートランド・ラッセル] 0.7030623602860854 [ロジャー・ペンローズ] 0.7027514788704126 [ゴッドフレイ・ハロルド・ハーディ] 0.7015682037971648 [レフ・ランダウ] 0.6995820039570446 アインシュタイン 0.6988088557431229 [アンドレ・ヴェイユ] 0.6978330851832386 [ヴェルナー・ハイゼンベルク] 0.6968193353952211 [ライナス・ポーリング] 0.696506621225639 数学者 0.6874893585915669 [マービン・ミンスキー] 0.6872785430574049 [フリーマン・ダイソン] 0.6863152690167025 [アンリ・ポアンカレ] 0.6851716370245848 [ユージン・ウィグナー] 0.6812997448505773 [アレン・ニューウェル] 0.6774566693465804 [ハンス・ベーテ] 0.6767724362750837 [アルバート・アインシュタイン] 0.6750565951905274 [フリッツ・ロンドン] 0.6739032952575766 [リチャード・P・ファインマン] 0.6737536291870052 [ニールス・ボーア] 0.6737340516366506 [朝永振一郎] 0.6729520531327631 [ゴットロープ・フレーゲ] 0.6717680358048904 $
コンソール表示を見る限り オリジナル実装と遜色ない結果がでるのですが… とにかく遅い。これは教育済みデータを (ターゲットとなる 単語ベクトルを見つけるために1回、 ターゲットの単語とその他の単語との コサイン類似度を計算するためにもう1回の) 都合2回走査しているためです。
まとめ
JavaScriptはスクリプト言語のなかでも高速な部類なんですが、 そのパワーを持ってしてもビッグデータを扱うことが難しいことを実感しました。 これが JavaScript で機械学習系のコード実装が少ない理由なのかもしれません。
東北大学の日本語 word2vec 学習済みデータは十分にビッグデータで、 何か工夫をして扱うデータのサイズを削減しないと、 JavaScript本来のスピードで処理できないと言わざる得ません。
新しい技術的課題を見つけちゃったなぁ…
以上
PS 前回と今回のソースコードを Github にアップしました。
#!/usr/bin/env node var file = process.argv[2]; var keyword = process.argv[3]; const fs = require('fs'); const readline = require('readline'); function Absolute(m) { var ret = 0; for (var i = 0; i < m.length; i++) { ret += m[i] * m[i]; } return(Math.sqrt(ret)); } function DotProduct(m1, m2) { var ret = 0; for (var i = 0; i < m1.length; i++) { ret += m1[i] * m2[i]; } return(ret); } function CosineSimilarity(dot, nrm1, nrm2) { return(dot/(nrm1*nrm2)); } var n1 = 0; var a = {}; const reader1 = async function(rs1) { for await (const chunk of rs1) { var elm = chunk.split(' '); if (elm[0] == keyword) { a = {}; a.key = elm[0]; a.mtx = []; for (var i = 1; i < elm.length; i++) { a.mtx.push(parseFloat(elm[i])); } a.nrm = Absolute(a.mtx); break; } n1++; } }; var n2 = 0; var distance = []; const reader2 = async function(rs2) { for await (const chunk of rs2) { var elm = chunk.split(' '); if (elm.length > 2 && elm[0] != keyword) { var b = {}; b.key = elm[0]; b.mtx = []; for (var i = 1; i < elm.length; i++) { b.mtx.push(parseFloat(elm[i])); } b.nrm = Absolute(b.mtx); dot = DotProduct(a.mtx, b.mtx); sim = CosineSimilarity(dot, a.nrm, b.nrm); distance.push({ 'key': b.key, 'sim': sim }); } process.stderr.write(n2+" times\r"); n2++; } }; const N = 40; // number of closest words that will be shown function Compare(a, b) { return(b.sim - a.sim); } async function main() { const rs1 = fs.createReadStream(file, { encoding: 'utf8' }); const rl1 = readline.createInterface(rs1, {}); await reader1(rl1); if (!a.key) { console.log("Out of dictionary word!"); return; } console.log("\nWord: %s", a.key); const rs2 = fs.createReadStream(file, { encoding: 'utf8' }); const rl2 = readline.createInterface(rs2, {}); await reader2(rl2); distance.sort(Compare); console.log("\n Word Cosine distance\n--------------------------------------------------------------------------"); for (var i = 0; i < N; i++) { var pad = ' '; var str = (distance[i].key+pad).slice(0, 50); console.log("%s\t%f", str, distance[i].sim); } } main();