PythonでMeCabの制約付き解析を使う

MeCabには[制約付き解析](https://mecab.googlecode.com/svn/trunk/mecab/doc/partial.html)という機能がありますが、これについて説明している記事がほとんどなかったので手探りで試してみました。

- MeCab 0.996
- Python 3.4
- mecab-python3 0.7

## 制約付き解析とは
<blockquote>入力文の一部の形態素情報が既知である、あるいは境界がわかっているときに、 それを満たすように解析する機能です。

たとえば、「にわにはにわにわとりがいる。」という文に対して、「はにわ」の部分が名詞であるとか、「にわとり」の部分が一つの形態素であるというように指定した上で解析することができます。このとき、制約に反する4文字目の「は」が単独で形態素となったり、「にわとり」が「にわ」と「とり」に分割されるような解析候補は排除されます。
</blockquote>
<a href="https://mecab.googlecode.com/svn/trunk/mecab/doc/partial.html">制約付き解析 (部分解析)</a>より

## 形態素境界の制約をつける

文の一部の形態素境界がわかってるときに、その部分を一つの形態素として扱って解析するように制約をつける機能です。先ほどの「にわにはにわにわとりがいる。」の「にわとり」が一つの形態素であると制約をつけると、「にわとり」の部分が「にわ」「とり」のように分割されなくなります。

### 使い方
1. `MeCab.Lattice()`でLatticeインスタンスを取得します
2. `lattice::set_sentence`で解析したい文をセットします
3. `Lattice::set_boundary_constraint`で、1Byteずつ単語境界が ある/ない/わからない を指定します
4. `Tagger::parse`で境界を設定したLatticeインスタンスを解析します
5. `Lattice::toString`で結果を取得できます

- set_boundary_constraintに与える定数
 - 境界がある場合 -> MECAB_TOKEN_BOUNDARY
 - 境界がない場合(形態素の途中) -> MECAB_INSIDE_TOKEN
 - わからない(MeCabに判断をおまかせしたい)場合 -> MECAB_ANY_BOUNDARY



注意点は、Python 3のstr型の `__len__` は文字単位の長さを返すので、日本語等のマルチバイト文字では、文字のByte数を `__len__` で取得できないことです。この場合、`encode()` メソッドを使ってbytes型に変換するとByteの数を取得できます。

### サンプル
```py3
# -*- coding: utf-8 -*-
import re
import MeCab
from MeCab import MECAB_ANY_BOUNDARY, MECAB_INSIDE_TOKEN, MECAB_TOKEN_BOUNDARY

DICINFO_KEYS = ('charset', 'filename', 'lsize', 'rsize', 'size', 'type', 'version')


class Tagger(MeCab.Tagger):

    def dictionary_info(self):
        info = MeCab._MeCab.Tagger_dictionary_info(self)
        return {key: getattr(info, key) for key in DICINFO_KEYS}

    def split_sentence(self, sentence, pattern):
        """
        Args:
            <str> sentence
            <str> pattern: regex pattern
        Returns:
            <str> token
            <bool> match
        """
        last_found_position = 0
        for m in re.finditer(pattern, sentence):
            if last_found_position < m.start():
                yield (sentence[last_found_position:m.start()], False)
                last_found_position = m.start()
            yield (sentence[last_found_position:m.end()], True)
            last_found_position = m.end()
        if last_found_position < len(sentence):
            yield (sentence[last_found_position:], False)

    def boundary_constraint_parse(self, sentence, pattern='.', any_boundary=False):
        """
        Arg:
            <str> sentence
            <str> pattern
            <bool> any_boundary
        Return:
            <str> result
        """
        lattice = MeCab.Lattice()
        lattice.set_sentence(sentence)
        if any_boundary:
            default_boundary_constraint = MECAB_ANY_BOUNDARY
        else:
            default_boundary_constraint = MECAB_INSIDE_TOKEN

        byte_position = 0
        lattice.set_boundary_constraint(byte_position, MECAB_TOKEN_BOUNDARY)

        charset = self.dictionary_info()['charset']
        for (token, match) in self.split_sentence(sentence, pattern):
            byte_position += 1
            if match:
                boundary_constraint = MECAB_INSIDE_TOKEN
            else:
                boundary_constraint = default_boundary_constraint
            for _ in range(1, len(token.encode(charset))):
                lattice.set_boundary_constraint(byte_position, boundary_constraint)
                byte_position += 1
            lattice.set_boundary_constraint(byte_position, MECAB_TOKEN_BOUNDARY)
        if self.parse(lattice):
            return lattice.toString()


if __name__ == '__main__':
    tagger = Tagger()
    text = 'ポエム読むならQiita最高'
    print('形態素境界制約付き解析\n')
    print(tagger.boundary_constraint_parse(text, '[a-zA-Z0-9\s\-]+', any_boundary=True))

```
```
形態素境界制約付き解析

ポエム	名詞,一般,*,*,*,*,ポエム,ポエム,ポエム
読む	動詞,自立,*,*,五段・マ行,基本形,読む,ヨム,ヨム
なら	助動詞,*,*,*,特殊・ダ,仮定形,だ,ナラ,ナラ
Qiita	名詞,一般,*,*,*,*,*
最高	名詞,一般,*,*,*,*,最高,サイコウ,サイコー
EOS
```
ここでは英数字が連続している部分は必ず単語であるという制約をつけて解析してます。英数字でない部分の解析はMeCabにおまかせです。

この制約をつけることで「Qiita」という単語が辞書になくても必ず1語であるとして解析してくれます。こういうのは本来、未知語処理の方でがんばるべきかもしれませんが、unk.defをいじるにはコスト計算に明るくないといけないので難しかったりします。

他にはこのような使い方もあります。
<blockquote class="twitter-tweet" lang="en"><p>MeCabの制約付き解析を使えば、原理的には英語のPOSタガーが作れます。スペースを必ず切る、単語はそれ以上切らないという制約を入れれば良いです。すこしabuseな気もしますが。</p>&mdash; Taku Kudo (@taku910) <a href="https://twitter.com/taku910/status/295046659530948609">January 26, 2013</a></blockquote>

あと本当は解析結果をNode型で取得したかったのですが、`lattice.begin_nodes`やっても壊れた結果しか返って来ませんでした。もしNode型でうまくいったよって人いたら教えてください(;´Д`)

## 品詞の制約をつける
文全体の形態素境界がわかっていて、一部の形態素の品詞がわかっているときに、解析のときに指定した形態素が指定した品詞になるように制約をつける機能です。先ほどの「にわにはにわにわとりがいる。」の「にわとり」が名詞であると制約をつけると、解析のときに「にわとり」の部分が名詞になる候補だけを出力します。

### 使い方
1. `MeCab.Lattice()`でLatticeインスタンスを取得します
2. `lattice::set_sentence`で解析したい文をセットします
3. `Lattice::set_feature_constraint`で何Byte目から何Byte目はこの品詞だよってセットします。品詞の推定をMeCabにおまかせしたいときは `"*"` を入れます
4. `Tagger::parse`で境界を設定したLatticeインスタンスを解析します
5. `Lattice::begin_nodes`で結果のNodeが返ってくるのであとは`Tagger::parseToNode`と同じように扱えます

### サンプル
```py3
# -*- coding: utf-8 -*-
import MeCab

DICINFO_KEYS = ('charset', 'filename', 'lsize', 'rsize', 'size', 'type', 'version')


class Tagger(MeCab.Tagger):

    def dictionary_info(self):
        info = MeCab._MeCab.Tagger_dictionary_info(self)
        return {key: getattr(info, key) for key in DICINFO_KEYS}

    def feature_constraint_parse(self, tokens):
        """
        Arg:
            tokens (list of (str, str))
        Return:
            result (str)
        """
        lattice = MeCab.Lattice()
        sentence = ''.join(map(lambda x: x[0], tokens))
        lattice.set_sentence(sentence)
        start_position = 0
        charset = self.dictionary_info()['charset']
        for x in tokens:
            if len(x) == 2:
                (token, pos) = x
            else:
                token = x[0]
                pos = '*'
            end_position = start_position + len(token.encode(charset))
            lattice.set_feature_constraint(start_position, end_position, pos)
            start_position = end_position
        if self.parse(lattice):
            node = lattice.begin_nodes(0)
            while node:
                yield node
                node = node.next


if __name__ == '__main__':
    tagger = Tagger()
    print('品詞制約付き解析\n')
    labeled_tokens = [['くぅ〜', '感動詞'],
                      ['マミさん', '名詞'],
                      ['の'], ['紅茶'], ['めちゃウマ'], ['っす'], ['よ'], ['〜']]
    for node in tagger.feature_constraint_parse(labeled_tokens):
        print(node.surface, node.feature)
```
```
品詞制約付き解析

くぅ〜 名詞,サ変接続,*,*,*,*,*
マミさん 名詞,一般,*,*,*,*,*
の 助詞,連体化,*,*,*,*,の,ノ,ノ
紅茶 名詞,一般,*,*,*,*,紅茶,コウチャ,コーチャ
めちゃウマ 名詞,一般,*,*,*,*,*
っす 助動詞,*,*,*,特殊・デス,基本形,っす,ッス,ッス
よ 助詞,終助詞,*,*,*,*,よ,ヨ,ヨ
〜 記号,一般,*,*,*,*,〜,〜,〜
 BOS/EOS,*,*,*,*,*,*,*,*
```
辞書に入ってない「マミさん」を名詞として解析できました。でもなぜか指定したはずの「くぅ〜」が感動詞になってない……。

使い方はわかったけど、実用的に役立つ場面が思いつきませんでした。こういう風に役に立ってるよっていうのがあったら知りたいです。