見出し語化の高速化

nltkのWordNetLemmatizerを力ずくで高速化した。

環境

Python 2.6.5

コード

# -*- coding: utf-8 -*-

from collections import defaultdict
import nltk
from nltk.corpus import wordnet as _wordnet


_STEMMER = nltk.PorterStemmer().stem
_LEMMATIZATION_POS_PRIORITY = (_wordnet.NOUN, _wordnet.VERB,
                               _wordnet.ADJ, _wordnet.ADV)
_POS_LIST = (_wordnet.ADJ, _wordnet.ADV, _wordnet.NOUN, _wordnet.VERB)


def stem_form(form):
    return _STEMMER(form)


def _detect_pos(form):
    form = form.replace(' ', '_')
    synsets = _wordnet.synsets(form)
    if not synsets:
        return None
    pos = None
    stem = stem_form(form)
    for synset in synsets:
        if stem_form(synset.name[:-5]) == stem:
            pos = synset.pos
            break
    if pos is None:
        pos = synsets[0].pos
    if pos == _wordnet.ADJ_SAT:
        pos = _wordnet.ADJ
    return pos


def lemmatize_with_wordnet(form, pos=None):
    if pos is None:
        pos = _detect_pos(form)
        if not pos:
            return form
    assert(pos in _POS_LIST)
    return nltk.WordNetLemmatizer().lemmatize(form, pos=pos)


def _lemmatize_form_with_wordnet(form, pos_set):
    assert(pos_set)
    if len(pos_set) == 1:
        target_pos = pos_set.copy().pop()
    else:
        target_pos = _detect_pos(form)
        if not target_pos or target_pos not in pos_set:
            for pos in _LEMMATIZATION_POS_PRIORITY:
                if pos in pos_set:
                    target_pos = pos
                    break
    assert(target_pos in _POS_LIST)
    return nltk.WordNetLemmatizer().lemmatize(form, pos=target_pos)


def _construct_inflected_form_to_lemma_dictionary():
    all_inflected_forms = defaultdict(set)
    for pos, excepted_forms in _wordnet._exception_map.iteritems():
        if pos == _wordnet.ADJ_SAT:
            continue
        for excepted_form in excepted_forms:
            all_inflected_forms[excepted_form].add(pos)

    for pos in _POS_LIST:
        substitutions = _wordnet.MORPHOLOGICAL_SUBSTITUTIONS[pos]
        for lemma in _wordnet.all_lemma_names(pos=pos):
            lemma = lemma.replace('_', ' ')
            all_inflected_forms[lemma].add(pos)

            form = lemma
            if pos == _wordnet.NOUN and form.endswith('ful'):
                suffix = 'ful'
                form = form[:-3]  # len('ful')
            else:
                suffix = ''
            for new_suffix, old_suffix in substitutions:
                if form.endswith(old_suffix) or old_suffix == '':
                    if old_suffix == '':
                        inflected_form = form + new_suffix
                    else:
                        inflected_form = form[:-len(old_suffix)] + new_suffix
                    inflected_form += suffix
                    all_inflected_forms[inflected_form].add(pos)
    inflected_form_to_lemma = {}
    for inflected_form, pos_set in all_inflected_forms.iteritems():
        lemma = _lemmatize_form_with_wordnet(inflected_form, pos_set)
        inflected_form_to_lemma[inflected_form] = lemma.replace('_', ' ')
    return inflected_form_to_lemma


_INFLECTED_FORM_TO_LEMMA = _construct_inflected_form_to_lemma_dictionary()


def lemmatize_with_dict(form):
    try:
        return _INFLECTED_FORM_TO_LEMMA[form.lower().replace(' ', '_')]
    except KeyError:
        pass
    return form.lower()


def _test():
    test_forms = ('media', 'playing', 'player', 'possesses', 'sung', 'became',
                  'begun', 'fallen', 'men', 'buses', 'initial', 'initialization')
    print 'original\tstemming\twordnet_lemmatizer\tlemmatize_with_dict'
    for form in test_forms:
        print ('{0}\t{1}\t{2}\t{3}').format(form,
                                            stem_form(form),
                                            lemmatize_with_wordnet(form),
                                            lemmatize_with_dict(form))


if __name__ == '__main__':
    _test()

'''
original        stemming   wordnet_lemmatizer   lemmatize_with_dict
media           media      medium               medium
playing         play       playing              playing
player          player     player               player
possesses       possess    posse                possess
sung            sung       sung                 sung
became          becam      become               become
begun           begun      begin                begin
fallen          fallen     fall                 fall
men             men        man                  man
buses           buse       bus                  bus
initial         initi      initial              initial
initialization  initi      initialization       initialization
'''


nltkのWordNetLemmatizerの動作は、入力語句に対して以下の手順で見出し語化を行っているようです。

  1. 入力語句が例外リストに載っていないかどうかチェックする。載っていれば別途処理する。
  2. 末尾置換ルールを用いて入力語句の末尾を置換する。
  3. 辞書の見出し語に語句が存在しているかどうかをチェックする。
  4. 末尾置換ルールが無くなるまで手順2.と3.を繰り返す。

手順1.の例外リストとは、主に不規則変化(sing-sang-sungなど)を解決するための手順のようです。
手順2.と手順3.では、あらかじめ用意された末尾置換ルールを使用します。
例えば入力語句が名詞であり、xesで終わっている場合、末尾からxesを取り除き、代わりにxを追加する。その後、置換した単語が辞書の見出し語にあるかどうかをチェックする。例えば入力語句がboxesであるとき、末尾のxesをxに置換しboxとし、その後boxが辞書の見出し語にあるかどうかチェックする。

ソースコードを読む限り、nltkのWordNetLemmatizerは本家WordNetC言語実装と同様の手順で見出し語化を行っているらしい。

末尾の置換、検索、を繰り返しているので実行速度が遅い。
そこで、これら例外リストと置換ルールを使用して、WordNetLemmatizerが処理できる語句をすべて生成する。
それらWordNetLemmatizerが処理できる語句をキーとし、それらの見出し語を値とする辞書(_INFLECTED_FORM_TO_LEMMA)を作成した。
{'boxes': 'box', 'box': 'box', ..., 'media': 'medium', 'medium': 'medium', ...}のような活用形がキーであり、見出し語が値である辞書。

実行速度テスト

http://americannationalcorpus.org/OANC/index.htmlから抽出した16,814,123個の英単語(296,528種類)に対して、見出し語化にかかる時間を測った。

nltkのWordNetLemmatizerによる見出し語化(lemmatize_with_wordnet)では3583秒(およそ1時間)、
nltkのWordNetLemmatizerによる見出し語化(lemmatize_with_wordnet)の入出力をキャッシュした場合では80秒、
今回作成した作成した辞書を使用した見出し語化(lemmatize_with_dict)では20秒、

となった。結局入出力をキャッシュした場合と大差無い結果になった。
しかし、入出力をキャッシュする場合、WordNetに登録されていない単語や固有名詞などが入力されるたび、キャッシュのサイズが大きくなる。
その点、今回作成した辞書のサイズは固定されているので、メモリ消費量の増加などを気にしなくて良い。
今回作成した辞書にも問題点がある。今回作成した辞書では、入力語句から出力が一意に決まるが、通常は一意には決まらない。例えば、betterの見出し語はwellかgoodかは品詞を用いない限り決めにくい。