自動タグ付け機能でも作ってみる - 5: MorphoAnalyzer を作り切る

前提

先の構成図 参照。

前の記事で実験したとおり、PythonからMeCabを呼び出して形態素の分割と品詞の種別までは取得できるようになった。

周辺のClass定義

次は、構成図のMorphoAnalyzer周辺のClassを定義してしまおう。

  • Word
  • WordStorage
  • TextStorage
from collections import namedtuple
import MeCab
from abc import ABCMeta, abstractmethod

from shlex import quote
from typing import Optional, List

'''
語(形態素)。
'''
class Word(namedtuple('Word', (
    'word',                # 原形
    'feature_id',          # 素性ID
))):
    pass


'''
語(形態素)を受けて保存しておくStorageのインターフェイス
'''
class WordStorage(metaclass=ABCMeta):
    @abstractmethod
    def push(self, path: str, word: Word) -> None:
        pass


'''
語(形態素)を受けて保存しておくStorageのインターフェイス
'''
class TextStorage(metaclass=ABCMeta):
    @abstractmethod
    def push(self, path: str, text: str) -> None:
        pass

abc 使ってインターフェイスを定義するっていうのはちょっとPythonらしくない感じはするけど、 個人的にはこういう形になっている方が安心できるので、作っておく。

形態素解析前の正規化処理

また、neologdのWikiによると、解析前に行うことが望ましい文字列の正規化処理 というのがあるらしいので、 Pythonコードごと拝借して、mypyと喧嘩しないように型アノテーションを追加しておく。

import re
import unicodedata

from typing import Dict

'''
copied from https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja#python-written-by-hideaki-t--overlast
'''


def unicode_normalize(cls: str, s: str) -> str:
    pt = re.compile('([{}]+)'.format(cls))

    def norm(c: str) -> str:
        return unicodedata.normalize('NFKC', c) if pt.match(c) else c

    s = ''.join(norm(x) for x in re.split(pt, s))
    s = re.sub('-', '-', s)
    return s


def remove_extra_spaces(s: str) -> str:
    s = re.sub('[  ]+', ' ', s)
    blocks = ''.join(('\u4E00-\u9FFF',  # CJK UNIFIED IDEOGRAPHS
                      '\u3040-\u309F',  # HIRAGANA
                      '\u30A0-\u30FF',  # KATAKANA
                      '\u3000-\u303F',  # CJK SYMBOLS AND PUNCTUATION
                      '\uFF00-\uFFEF'   # HALFWIDTH AND FULLWIDTH FORMS
                      ))
    basic_latin = '\u0000-\u007F'

    def remove_space_between(cls1: str, cls2: str, s: str) -> str:
        p = re.compile('([{}]) ([{}])'.format(cls1, cls2))
        while p.search(s):
            s = p.sub(r'\1\2', s)
        return s

    s = remove_space_between(blocks, blocks, s)
    s = remove_space_between(blocks, basic_latin, s)
    s = remove_space_between(basic_latin, blocks, s)
    return s


def normalize(s: str) -> str:
    s = s.strip()
    s = unicode_normalize('0-9A-Za-z。-゚', s)

    def maketrans(f: str, t: str) -> Dict[int, str]:
        return str.maketrans({x: y for x, y in zip(f, t)})

    s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s)  # normalize hyphens
    s = re.sub('[﹣-ー—―─━ー]+', 'ー', s)  # normalize choonpus
    s = re.sub('[~∼∾〜〰~]', '', s)  # remove tildes
    s = s.translate(
        maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」',
                  '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」'))

    s = remove_extra_spaces(s)
    s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s)  # keep =,・,「,」
    s = re.sub('[’]', '\'', s)
    s = re.sub('[”]', '"', s)
    return s


if __name__ == "__main__":
    assert "0123456789" == normalize("0123456789")
    assert "ABCDEFGHIJKLMNOPQRSTUVWXYZ" == normalize(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    assert "abcdefghijklmnopqrstuvwxyz" == normalize(
        "abcdefghijklmnopqrstuvwxyz")
    assert "!\"#$%&'()*+,-./:;<>?@[¥]^_`{|}" == normalize(
        "!”#$%&’()*+,-./:;<>?@[¥]^_`{|}")
    assert "=。、・「」" == normalize("=。、・「」")
    assert "ハンカク" == normalize("ハンカク")
    assert "o-o" == normalize("o₋o")
    assert "majikaー" == normalize("majika━")
    assert "わい" == normalize("わ〰い")
    assert "スーパー" == normalize("スーパーーーー")
    assert "!#" == normalize("!#")
    assert "ゼンカクスペース" == normalize("ゼンカク スペース")
    assert "おお" == normalize("お             お")
    assert "おお" == normalize("      おお")
    assert "おお" == normalize("おお      ")
    assert "検索エンジン自作入門を買いました!!!" == \
        normalize("検索 エンジン 自作 入門 を 買い ました!!!")
    assert "アルゴリズムC" == normalize("アルゴリズム C")
    assert "PRML副読本" == normalize("   PRML  副 読 本   ")
    assert "Coding the Matrix" == normalize("Coding the Matrix")
    assert "南アルプスの天然水Sparking Lemonレモン一絞り" == \
        normalize("南アルプスの 天然水 Sparking Lemon レモン一絞り")
    assert "南アルプスの天然水-Sparking*Lemon+レモン一絞り" == \
        normalize("南アルプスの 天然水- Sparking* Lemon+ レモン一絞り")

MorphoAnalyzer!

下準備ができてしまえば、難しいことは何もない。

  1. normalize (形態素解析前の正規化処理)をする
  2. MeCabで parse する
  3. 結果を改行区切り
  4. 一行一行をタブ区切りで単語と品詞IDに分ける
  5. EOSは除去する
  6. WordStorage にpushする

MorphoAnalyzer の出来上がり。

class MorphoAnalyzer(TextStorage):
    def __init__(self, storage: WordStorage, mecab_dicdir: Optional[str] = None, use_base: bool = False) -> None:
        node_format: str = '%m\\t%h\\n'
        args: List[str] = [
            '--node-format ' + node_format,
            '--unk-format ' + node_format,
            '--eos-format EOS\\t\\n',
        ]
        if mecab_dicdir is not None:
            args.append("-d")
            args.append(quote(mecab_dicdir))
        arg: str = ' '.join(args)
        self.tagger = MeCab.Tagger(arg)
        self.storage = storage
        self.tagger.parse('')

    def push(self, path: str, text: str) -> None:
        parsed = self.tagger.parse(normalize(text))
        for line in parsed.split('\n'):
            if line == '':
                continue
            base_format, feature_id = line.split('\t')
            if base_format == 'EOS':
                continue
            word = Word(word=base_format, feature_id=feature_id)
            self.storage.push(path, word)