Remark(5)MeCab による日本語トークナイザー

parse-japanese-mecab: Japanese tokenizer with MeCab


2020/09/11
修正 2020/09/13
藤田昭人


2020/09/13 追記しました

ソースコード に誤りを見つけたため修正版に更新しました。 結局、nlcst 構文木は自前で構築することになりました。 すいません。

僕にとって JavaScript は5つ目のプログラミング言語なんですが、 相変わらず習得に苦労しています。 今回どハマりしているのは prototype です。 もちろん JavaScriptC++Java のような class による記法をサポートしているのですが、 困った事に remark / unified では prototype を使って クラスを実装しているので読み下すのにてこずってます。

この種のトラブルには「わかってしまえばなんて事ない」原因が付き物なのですけども、 それに気付くまでに費やす時間たるや… またもや「ほんと我ながら言語のセンスがない」ことを再確認させられています。 悪戦苦闘して作ったのは、 前回 紹介した parse-latin のラッパーのサブクラスのバージョンです。 コードの方は後ほど…

パーサーをプラグイン・チェーンに挿入するには

何故サブクラスのバージョンを作ったかというと、 プラグイン・チェーンに挿入できるパーサーにする必要があったからです。

remark には各トークンに品詞情報を付与するための retext-pos が用意されてますが、 これを呼び出すためには retextプラグイン・チェーンを構成する必要があります。 例えば、次のコードで構文木には品詞情報が埋め込まれます。

var unified = require('unified');
var japanese = require('./retext-wrapper');
var pos = require('retext-pos');
var stringify = require('retext-stringify');
var inspect = require('unist-util-inspect');


function plugin() {
  return(transformer);

  function transformer(tree) {
    //console.log(inspect(tree));
    console.log("{ type: '%s' }", tree.type);
    console.log("{ type: '%s' }", tree.children[0].type);
    console.log("{ type: '%s' }", tree.children[0].children[0].type);
    console.log(tree.children[0].children[0].children[0]);
    console.log(tree.children[0].children[0].children[1]);
    console.log(tree.children[0].children[0].children[2]);
    console.log(tree.children[0].children[0].children[3]);
    console.log(tree.children[0].children[0].children[4]);
    console.log(tree.children[0].children[0].children[5]);
  }
}


var str = "Professor Bob Fabry, of the University of California at Berkeley, was in attendance and immediately became interested in obtaining a copy of the system to experiment with at Berkeley.";

var processor = unified()
    .use(japanese)
    .use(pos)
    .use(plugin)
    .use(stringify);

processor.process(str, function (err) {
  if (err) {
    throw err;
  }
});

このコードで登場するプラグイン・バージョンの パーサー retext-wrapper は次のようなコードになります。 retext-english をほんの少しだけ修正しました。

'use strict'

var unherit  = require('unherit');
var Wrapper = require('./parse-wrapper');

module.exports = parse;
parse.Parser = Wrapper;

function parse() {
  this.Parser = unherit(Wrapper);
}

ここで 前回 紹介したラッパーバージョンのパーサー本体を使うと プラグイン・チェインの実行時のチェックにひっかかって異常終了します。 まるで「風が吹けば桶屋が儲かる」みたいにトラブルが出現しました(笑)


ラッパーからサブクラスへの書き直し

やむなく unified のコードを読んでみたところ、 パーサーはクラスとして実装されていることを仮定してるように見えました。 前回紹介したようにパーサーの核をなし nlcst 構文木を生成してくれる parse-latin のコードはそのまま残したいので、 次のようにそのサブクラスとしてラッパーを定義することにしました。

'use strict'

var ParseLatin = require('parse-latin');

// Inherit from `ParseLatin`.
ParseLatinPrototype.prototype = ParseLatin.prototype;

// Constructor to create a `ParseWrapper` prototype.
function ParseLatinPrototype() {
}

ParseWrapper.prototype = new ParseLatinPrototype();

// Transform Japanese natural language into an NLCST-tree.
function ParseWrapper(doc, file) {
  if (!(this instanceof ParseWrapper)) {
    return(new ParseWrapper(doc, file));
  }

  ParseLatin.apply(this, arguments)
  ParseLatin.prototype.position = false;
}

ParseWrapper.prototype.parse = function(value) {
  return(ParseLatin.prototype.parse.call(this, value));
}

ParseWrapper.prototype.tokenize = function(value) {
  return(ParseLatin.prototype.tokenize.call(this, value));
}

module.exports = ParseWrapper;

実は parse-english のコードから継承する部分のみを切り出したものです。 もっとも少しだけ変更も加えてます。 15行目の ParseLatin.prototype.position を false にして 位置情報を記録しないようにしてあります。

以上のコードを組み合わせると…

$ node pos.js
{ type: 'RootNode' }
{ type: 'ParagraphNode' }
{ type: 'SentenceNode' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Professor' } ],
  data: { partOfSpeech: 'NNP' } }
{ type: 'WhiteSpaceNode', value: ' ' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Bob' } ],
  data: { partOfSpeech: 'NNP' } }
{ type: 'WhiteSpaceNode', value: ' ' }
{ type: 'WordNode',
  children: [ { type: 'TextNode', value: 'Fabry' } ],
  data: { partOfSpeech: 'NN' } }
{ type: 'PunctuationNode', value: ',' }
$ 

ようやく動いてくれました(笑)

品詞情報は 'WordNode' に .data.partOfSpeech として埋め込まれます。 英語の場合はトークン化と品詞情報の設定は個別の処理として扱われますが、 日本語の場合は(ご存知のとおり)形態素解析で両方を一気に解決します。


mecab コマンドの呼び出し

さて、その形態素解析ですが、 まずは安直に MeCab コマンドを呼び出すことにしました。 標準サポートのコマンド同期実行の API を使って、 次のようなインターフェースを作りました。

const execSync = require('child_process').execSync;

//const dic = "-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd";
const fmt = "-F'%m\t%h\n'";
const eos = "-E'\n'";

function single_sync_exec(msg) {
    //var command = 'echo "'+msg+'" | mecab '+dic+' '+fmt+' '+eos;
    var command = 'echo "'+msg+'" | mecab '+fmt+' '+eos;
    const ret = execSync(command);
    var sentences = String(ret).split('\n\n');

    var tokens = sentences[0].split('\n');
    var tkn = [];
    for (var j = 0; j < tokens.length; j++) {
    var each = tokens[j].split('\t');
    var elm = [];
    elm[0] = each[0];
    elm[1] = Number(each[1]);
    tkn.push(elm);
    }
    return(tkn);
}

module.exports = single_sync_exec;

このインターフェースでは mecab コマンドの出力どおり トークンと品詞IDが配列に格納されて返ってきます。 ちなみに、品詞IDについては末尾のIPA品詞体系をご覧ください。 今回は MeCab 標準の IPA 辞書を使いますが、 もし NEologd 辞書が使いたい場合はコメントアウトを外してください。 このインターフェースを実行するとこうなります。

$ cat morphological.js
var MecabCall = require('./mecab_call');

var str = "これこそAT&T ベル研究所のケン・トンプソンとデニス・リッチーの二人がUNIXの論文を初めて発表したシンポジウムである。";

console.log(MecabCall(str));
$ node morphological.js
[ [ 'これ', 59 ],
  [ 'こそ', 16 ],
  [ 'AT&T', 45 ],
  [ 'ベル', 38 ],
  [ '研究所', 38 ],
  [ '', 24 ],
  [ 'ケン', 44 ],
  [ '', 4 ],
  [ 'トンプソン', 43 ],
  [ '', 23 ],
  [ 'デニス', 44 ],
  [ '', 4 ],
  [ 'リッチー', 38 ],
  [ '', 24 ],
  [ '', 48 ],
  [ '', 53 ],
  [ '', 13 ],
  [ 'UNIX', 38 ],
  [ '', 24 ],
  [ '論文', 38 ],
  [ '', 13 ],
  [ '初めて', 34 ],
  [ '発表', 36 ],
  [ '', 31 ],
  [ '', 25 ],
  [ 'シンポジウム', 38 ],
  [ '', 25 ],
  [ 'ある', 25 ],
  [ '', 7 ] ]
$ 

このインターフェースの弱点の1つは 解析対象となるメッセージをコマンドライン経由で MeCab コマンドに引き渡しているところです。 なので、形態素解析の邪魔しないように配慮して、 メッセージを小分けにしてインターフェースをコールする必要があります。 必然的に遅くなりますが「取り敢えず形態素解析ができれば良し」とします。


parse-japanese-mecab の作成

MeCab インターフェースを組み込んだ parse-japanese-mecab を作成しました。
コードを次に示します。

'use strict'

var ParseLatin = require('parse-latin');
var MeCabCall =  require('./mecab_call');
var inspect = require('unist-util-inspect');

// Inherit from `ParseLatin`.
ParseLatinPrototype.prototype = ParseLatin.prototype;

// Constructor to create a `ParseJapanese` prototype.
function ParseLatinPrototype() {
}

ParseJapanese.prototype = new ParseLatinPrototype();

// Transform Japanese natural language into an NLCST-tree.
function ParseJapanese(doc, file) {
  if (!(this instanceof ParseJapanese)) {
    return(new ParseJapanese(doc, file));
  }

  ParseLatin.apply(this, arguments)
  ParseLatin.prototype.position = false;
}

function tokenizeRoot(node) {
  var root = {};
  root.type = 'RootNode';
  root.children = [];
  var para = {};
  para.type = 'ParagraphNode';
  para.children = [];
  var sent = {};
  sent.type = 'SentenceNode';
  sent.children = [];

  for (var i = 0; i < node.length; i++) {
    if (node[i].type == 'PunctuationNode') {
      sent.children.push(node[i]);
      if (node[i].value == '.' || node[i].value == '。') {
    para.children.push(sent);
    sent = {};
    sent.type = 'SentenceNode';
    sent.children = [];
      }
    } else if (node[i].type == 'WhiteSpaceNode') {
      root.children.push(para);
      para = {};
      para.type = 'ParagraphNode';
      para.children = [];
    } else {
      sent.children.push(node[i]);
    }
  }
  root.children.push(para);

  return(root);
}

ParseJapanese.prototype.parse = function(value) {
  //return(ParseLatin.prototype.parse.call(this, value));
  return tokenizeRoot(value);
}

// 'WordNode'
function createWordNode(txt, pos) {
  var text = {};
  text.type  = 'TextNode';
  text.value = txt

  var data = {};
  data.partOfSpeech = pos;

  var word = {};
  word.type = 'WordNode';
  word.children = [];
  word.children.push(text);
  word.data = data;

  return(word);
}

// 'WhiteSpaceNode'
function createWhiteSpaceNode(txt) {
  var word = {};
  word.type  = 'WhiteSpaceNode';
  word.value = txt;
  return(word);
}

// 'PunctuationNode'
function createPunctuationNode(txt) {
  var word = {};
  word.type  = 'PunctuationNode';
  word.value = txt;
  return(word);
}

function tokenize(value) {
  if (value === null || value === undefined) {
    value = '';
  } else if (value instanceof String) {
    value = value.toString();
  }

  if (typeof value !== 'string') {
    // Return the given nodes
    // if this is either an empty array,
    // or an array with a node as a first child.
    if ('length' in value && (!value[0] || value[0].type)) {
      return(value);
    }

    throw new Error("Illegal invocation: '" + value +
            "' is not a valid argument for 'ParseLatin'");
  }

  var tokens = [];

  value = value.replace(/\n/g, "");
  if (value.indexOf('。') <= -1) {
    var ret = MeCabCall(value);
    for (var i = 0; i < ret.length; i++) {
      if (ret[i][1] == 8) {       // 記号  空白
    tokens.push(createWhiteSpaceNode(ret[i][0]));
      } else if (ret[i][1] == 7 ||  // 記号  句点
         ret[i][1] == 9) {  // 記号  読点
    tokens.push(createPunctuationNode(ret[i][0]));
      } else {
    tokens.push(createWordNode(ret[i][0], ret[i][1]));
      }
    }
  } else {
    var sentences = String(value).split('。');
    for (var j = 0; j < sentences.length-1; j++) {
      var ret = MeCabCall(sentences[j]+'。');
      for (var i = 0; i < ret.length; i++) {
    if (ret[i][1] == 8) {     // 記号  空白
      tokens.push(createWhiteSpaceNode(ret[i][0]));
    } else if (ret[i][1] == 7 ||    // 記号  句点
           ret[i][1] == 9) {    // 記号  読点
      tokens.push(createPunctuationNode(ret[i][0]));
    } else {
      tokens.push(createWordNode(ret[i][0], ret[i][1]));
    }
      }
    }
  }
  return(tokens);
}

ParseJapanese.prototype.tokenize = function(value) {
  //return(ParseLatin.prototype.tokenize.call(this, value));
  return tokenize(value);
}

module.exports = ParseJapanese;

パーサーは markdown ドキュメントのパラグラフ毎に呼び出されます。 (mdast 構文木がパラグラフ単位だからでしょう) 複数の文が含まれるパラグラフでは文単位で分割して形態素解析を行います。 また WordNode の間は強制的に WhiteSpaceNode を挿入しています(81行、99行)。 このようにしないとパーサーが誤認識して適切な nlcst 構文木を構成してくれません。


まとめ

ということで、ようやく日本語パーサーが(一応)形になりましたが…

いやぁ parse-latin の解析にはてこずりました(笑)

強いて理由を挙げれば pluggable です。 parse-latin には次のコメントがあります。

All these nodes are pluggable: they come with a use method which accepts a plugin (function(NLCSTNode)). Every time one of these methods are called, the plugin is invoked with the node, allowing for easy modification.

In fact, the internal transformation from tokenize (a list of words, white space, punctuation, and symbols) to tokenizeRoot (an NLCST tree), is also implemented through this mechanism.

これらのノードはすべて pluggable です: プラグインを受け入れる use メソッドが付属しています (function(NLCSTNode))。 これらのメソッドが呼ばれるたびにプラグインがノードとともに呼び出され、簡単に変更できるようになります。

実際、tokenize (単語、空白、句読点、記号のリスト) から tokenizeRoot (NLCSTツリー) への内部変換もこのメカニズムで実装されています。

どうやら構文解釈の機能をプラグインに小分けしているようです。 現時点では、この pluggable のメカニズムを完全に理解した訳ではありません。 なので、WhiteSpaceNode の強制挿入でお茶を濁してます。

ですが…

先を急ぎたいので、まずはこのバージョンで。すいません。

2020/09/13
形態素解析の誤認識を修正する作業をしていたら問題を発見。 ソースを入れ替えました。

以上


付録:IPA品詞体系

こちらから引用しました。

こちらも併用しています。

ID 品詞 細分類1
0 その他 間投 「あ」「ア」のみ
1 フィラー 「えーと」「なんか」など
2 感動詞 「うむ」「お疲れさま」「トホホ」
3 記号 アルファベット 「A-z」
4 記号 一般 「?」「!」「¥」
5 記号 括弧開 「(」「【」など
6 記号 括弧閉 「 )」「】」など
7 記号 句点 「。」「.」のみ
8 記号 空白 「 」のみ
9 記号 読点 「、」「,」のみ
10 形容詞 自立 「美しい」「楽しい」
11 形容詞 接尾 「ったらしい」「っぽい」
12 形容詞 非自立 「づらい」「がたい」「よい」
13 助詞 格助詞 一般 「の」「から」「を」
14 助詞 格助詞 引用 「と」のみ
15 助詞 格助詞 連語 「について」「とかいう」
16 助詞 係助詞 「は」「こそ」「も」「や」
17 助詞 終助詞 「かしら」「ぞ」「っけ」「わい」
18 助詞 接続助詞 「て」「つつ」「および」「ので」
19 助詞 特殊 「かな」「けむ」「にゃ」
20 助詞 副詞化 「と」「に」のみ
21 助詞 副助詞 「くらい」「なんか」「ばっかり」
22 助詞 副助詞/並立助詞/終助詞 「か」のみ
23 助詞 並立助詞 「とか」「だの」「やら」
24 助詞 連体化 「の」のみ
25 助動詞 「ます」「らしい」「です」
26 接続詞 「だから」「しかし」
27 接頭詞 形容詞接続 「お」「まっ」
28 接頭詞 数接続 「計」「毎分」
29 接頭詞 動詞接続 「ぶっ」「引き」
30 接頭詞 名詞接続 「最」「総」
31 動詞 自立 「投げる」
32 動詞 接尾 「しまう」「ちゃう」「願う」
33 動詞 非自立 「しまう」「ちゃう」「願う」
34 副詞 一般 「あいかわらず」「多分」
35 副詞 助詞類接続 「こんなに」「そんなに」
36 名詞 サ変接続 「インプット」「悪化」
37 名詞 ナイ形容詞語幹 「申し訳」「仕方」
38 名詞 一般 「テーブル」
39 名詞 引用文字列 「いわく」のみ
40 名詞 形容動詞語幹 「健康」「安易」「駄目」
41 名詞 固有名詞 一般 北穂高岳」、「電通銀座ビル」、「G1」
42 名詞 固有名詞 人名 一般 グッチ裕三」、「紫式部
43 名詞 固有名詞 人名 「山田」、「ビスコンティ
44 名詞 固有名詞 人名 「B作」、「アントニオ」、「右京太夫
45 名詞 固有名詞 組織 「株式会社◯◯」
46 名詞 固有名詞 地域 一般 「東京」
47 名詞 固有名詞 地域 「日本」
48 名詞 「0」「一」
49 名詞 接続詞的 「◯対◯」「◯兼◯」
50 名詞 接尾 サ変接続 「(可視)化」
51 名詞 接尾 一般 「感」「観」「性」
52 名詞 接尾 形容動詞語幹 「的」「げ」「がち」
53 名詞 接尾 助数詞 「個」「つ」「本」「冊」
54 名詞 接尾 助動詞語幹 「そ」、「そう」のみ
55 名詞 接尾 人名 「君」、「はん」
56 名詞 接尾 地域 「州」、「市内」、「港」
57 名詞 接尾 特殊 「ぶり」、「み」、「方」
58 名詞 接尾 副詞可能 「いっぱい」、「前後」、「次第」
59 名詞 代名詞 一般 「そこ」、「俺」、「こんちくしょう」
60 名詞 代名詞 縮約 「わたしゃ」、「そりゃあ」
61 名詞 動詞非自立的 「しまう」、「ちゃう」、「願う」
62 名詞 特殊 助動詞語幹 「そ」、「そう」のみ
63 名詞 非自立 一般 「こと」、「きらい」、「くせ」、「もの」
64 名詞 非自立 形容動詞語幹 「ふう」、「みたい」のみ
65 名詞 非自立 助動詞語幹 「よ」、「よう」のみ
66 名詞 非自立 副詞可能 「限り」、「さなか」、「うち」
67 名詞 副詞可能 「10月」、「せんだって」、「当分」
68 連体詞 「この」、「いろんな」、「おっきな」、「堂々たる」