書籍「15Stepで踏破 自然言語処理アプリケーション開発入門」をやってみる - 2章Step02メモ「前処理」


内容

15stepで踏破 自然言語処理アプリケーション入門 を読み進めていくにあたっての自分用のメモです。
今回は2章Step02で、自分なりのポイントをメモります。

準備

  • 個人用MacPC:MacOS Mojave バージョン10.14.6
  • docker version:Client, Server共にバージョン19.03.2

章の概要

前章で簡単な対話エージェントを作成したが、類似した文章を同様に扱えなかったり、本来は重要でない単語(助詞など)や差異(アルファベットの大文字小文字)を特徴として扱ってしまっている。下記のテクニックについて学び、対話エージェントに適用させる。

  • 正規化
    • neologdnによる正規化
    • アルファベットの小文字化
    • Unicode正規化
  • 品詞によるストップワード除去
  • 見出し語化

02.1 前処理とは

テキスト分類の処理に入る前に、テキストを適切に整形することである。

# 同一の文章として扱えない
Pythonは好きですか
Pythonは好きですか

# 助詞や助動詞の共通性を特徴にしてしまう
# ラベル, 文章
0, あなたが好きです
1, ラーメン好き!
# ↓
# 「ラーメンが好きです」という文章をラベル=0と判断してしまうかも
# 意味的にはラベル=1と判断したい

02.2 正規化

表記のゆれを吸収し、ある一定の表記に統一する処理を文字列の正規化と呼ぶ。
表記のゆれがあっても、同じわかち書きの結果を得て、同じBoWを得ることが目標である。
およその正規化はneologdnで行い、neologdnで足りない正規化(小文字化やUnicode正規化)を個別に対応する。

neologdn

複数の正規化処理をまとめたneologdnというライブラリが用意されている。
これはMeCab辞書の一種のNEologdのデータを生成する時に使われている正規化処理である。
neologdnは関数1つに正規化処理がまとまっていて使い勝手が良く、C言語で実装されているので高速であることが利点である。

使用例
import neologdn

print(neologdn.normalize(<文章>))

小文字化と大文字化

neologdn.normalizeにはアルファベットの小文字・大文字変換は含まれていない。
よって、表記ゆれを吸収するにはPythonのstr型の組み込みメソッドである.lower()や.upper()を使って小文字または大文字に表記を統一する。

ただし、固有名詞などではアルファベットの小文字・大文字の区別が大事なこともあるため、必要に応じて対応する。

Unicode正規化の概要

Unicodeは現在、文字コードの事実上の標準といえるほど広く使われている。
「㈱」と「(株)」や、同じ「デ」でも一文字の「デ」と「テと"を合成したデ」は、そのままではそれぞれ別々の文字として扱われるため当然Bowの結果も異なってしまう。

Unicode正規化の詳解

Unicodeでは文字をコードポイントで表す。(16進表記)
それぞれord()とchr()というPythonの組込関数を用いて相互変換できる。

Unicodeとコードポイントの例
>>> hex(ord('あ'))
'0x3042'
>>> chr(0x3042)
'あ'

# ちなみに、10進表記でも可能
>>> ord('あ')
12354
>>> chr(12354)
'あ'

次に「デ」という文字について、一文字の場合と結合文字列(基底文字と結合文字)の場合でコードポイントを確認する。

デのコードポイント確認
# 一文字
>>> chr(0x30C7)
'デ'

# 結合文字列
>>> chr(0x30C6)
'テ'
>>> chr(0x3099)
'゙'
>>> chr(0x30C6) + chr(0x3099)
'デ'

上記のように、同じ文字に複数の表現方法が存在することになるこの問題に対してUnicodeは、「同じ文字として扱うべきコードポイントの組を定義する」という方法で対応した。これをUnicodeの等価性といい、下記の2つがある。

  • 正準等価性
    • 見た目も機能も同じ文字を等価とみなす
    • 「デ」と「テ」+「"」
  • 互換等価性
    • 見た目や機能が異なる可能性はあるが、同じ文字が元になっているものを等価とみなす
    • 正準等価性を含む
    • 「テ」と「テ」

Unicode正規化は、この等価性に基づき合成済み文字を分解したり合成したりすることであり、下記の4つがある。Canonicalが正準、Compatibilityが互換という意味である。

  • NFD(Normalization Form Canonical Decomposition)
    • 正準等価性による分解
  • NFC(Normalization Form Canonical Composition)
    • 正準等価性による分解 → 正準等価性による合成
  • NFCD(Normalization Form Compatibility Decomposition)
    • 互換等価性による分解
  • NFKC(Normalization Form Compatibility Composition)
    • 互換等価性による分解 → 互換等価性による合成

実際にUnicode正規化を行う際は、アプリケーションが扱う問題やデータの性質に合わせて、どの正規化を用いるのかを決定する必要がある。

02.3 見出し語化

活用などによる語形の変化を補正し、辞書の見出しに載っている形に直すことを見出し語化と呼ぶ。ただし、この時点では「本 を 読む だ」と「本 を 読む ます た」ではまだ同じ特徴を抽出できていない。
次節のストップワードも対応することによって、同じ特徴として扱うことができる。

本を読んだ
本を読みました

↓わかち書き + 見出し語化

本 を 読む だ
本 を 読む ます た

実装する際の話

表記ゆれの吸収という観点では前述の正規化と似ているが、わかち書きを補正するためにわかち書きの処理と合わせて記載することも多い。

MeCabのparseToNodeから得られたnode.featureを用いた場合は、カンマ区切りの6番目の要素から原形を得ることができる。

ただし、原形が登録されていない単語は表層形を使う。

BOS/EOSはMeCabの結果として、文の先頭と末尾を表す擬似的な単語なので、わかち書きの結果には含まないようにする。

02.4 ストップワード

前節では、わかち書きした結果「本 を 読む」までは同じ単語だが、その後が「だ」と「ます た」で異なるのでBoWも異なってしまう。
文意に大きな影響も与えず、語彙に含めるとメモリやストレージ効率の観点からも望ましくない。

辞書ベースのストップワード除去

下記のようにあらかじめ除外用の単語のリストを用意しておいて、if文で判定する。
slothlibなど、ネット上から必要なストップワードリストを用意できることもある。

~~
stop_words = ['て', 'に', 'を', 'は', 'です', 'ます']

~~
if token not in stop_words:
  result.append(token)

品詞ベースのストップワード除去

助詞や助動詞は文章を書く上で重要な品詞だが、文意を表現(対話エージェントでは、クラスID分けに必要な特徴を取得)する上では不要である。

~~

if features[0] not in ['助詞', '助動詞']:
~~

02.5 単語置換

前節と同じく、文章としては重要だが、文意を表現する上では「数値や日時」などは大した意味がないことがあるので、特定の文字列に置換する。

# 変換前
卵を1個買った
卵を2個買った
卵を10個買った

# 変換後
卵を SOMENUMBER 個買った
卵を SOMENUMBER 個買った
卵を SOMENUMBER 個買った
  • 個数の情報は失うが、「卵を買った」という文意はそのままで、個数の差異を統一できている
  • 「SOMENUMBER」の前後に半角スペースを含め、わかち書きで前後の文字と結合されることを防ぐ
  • 「SOME NUMBER」と半角スペースを含めても同一の結果が得られるが、次元数が無駄に1つ増えてしまうので避ける

02.6 対話エージェントへの適用

冒頭でも述べたとおり、この章で学んだ下記のテクニックを対話エージェントに適用させる。

  • 正規化
    • neologdnによる正規化
    • アルファベットの小文字化
    • Unicode正規化
  • 品詞によるストップワード除去
  • 見出し語化
~~

# _tokenize()の改良
    def _tokenize(self, text):
        text = unicodedata.normalize('NFKC', text)  # Unicode正規化
        text = neologdn.normalize(text)  # neologdnによる正規化
        text = text.lower()  # アルファベットの小文字化

        node = self.tagger.parseToNode(text)
        result = []
        while node:
            features = node.feature.split(',')

            if features[0] != 'BOS/EOS':
                if features[0] not in ['助詞', '助動詞']:  # 品詞によるストップワード除去
                    token = features[6] \
                            if features[6] != '*' \
                            else node.surface  # 見出し語化
                    result.append(token)

            node = node.next

        return result
実行結果
# evaluate_dialogue_agent.pyの読み込みモジュール名を修正
from dialogue_agent import DialogueAgent
↓
from dialogue_agent_with_preprocessing import DialogueAgent

$ docker run -it -v $(pwd):/usr/src/app/ 15step:latest python evaluate_dialogue_agent.py
0.43617021
  • 通常実装(Step01):37.2%
  • 前処理追加(Step02):43.6%