日本語テキストの前処理:neologdn、大文字小文字、Unicode正規化

前処理とは

前回の記事では、対話エージェントの作成にあたり、以下の流れでText classificationの仕組みを構築しました。

  1. わかち書き

  2. 特徴抽出(Bag of Words化)

  3. 識別器を学習&識別器で予測

とりあえず動くものができたものの、まだ完璧ではなく、いくつも問題が残っています。 例えば、

Pythonは好きですか

という文を含んだ学習データで対話エージェントを学習させても、語の表記ゆれに対応できていないため、ユーザが

Pythonは好きですか

という文を入力したら、正しく応答できないでしょう(「Python」が、一つ目の文は半角、二つ目は全角で書かれています)。 また、例えば

0,あなたが好きです
1,ラーメン好き!

のようなデータを含む学習データで対話エージェントを学習させたあとに、ユーザ入力として

ラーメンが好きです

が来た場合、「が」や「です」の共通性に引きずられて、クラスID=0の入力文と判断してしまうかもしれません。

以上のような問題を回避するには、Text classificationの処理に入る前に、テキストを適切に整形することです。この記事では、それに役立つ手法の例を紹介していきます。

正規化

コンピュータに入力される文章というのは、表記がかなり不揃いです。 例えば、次の3つの文字列は同じ文ですが、使われている文字は異なっています。

「初めてのTensorFlow」は定価2200円+税です
「初めての TensorFlow」は定価2200円+税です
「初めての TensorFlow」は定価2200円+税です
  • “初めての”と“TensorFlow”の間のスペースの有無

  • スペースの全角半角

  • “TensorFlow”の全角半角

  • “2200”の全角半角

  • “+”の全角半角

  • “「”、“」”の全角半角

などなど……。

ほとんどの場合、このような表記のゆれは文意に影響を与えないので、無視するのが人間の自然な感覚でしょう。 しかし、 前回の記事で作ったシステムは、これらの文を別物と捉えてしまいます。

例えば、前回の記事の http://tuttieee.hatenablog.com/entry/dialogue-agent-tutorial-text-classification#mecab-tokenizer で定義した tokenize() を使って上の3文をわかち書きしてみると、以下のようになります。

>>> tokenize('「初めてのTensorFlow」は定価2200円+税です')
['「', '初めて', 'の', 'TensorFlow', '」', 'は', '定価', '2200', '円', '+', '税', 'です']
>>> tokenize('「初めての TensorFlow」は定価2200円+税です')
['「', '初めて', 'の', '\u3000', 'TensorFlow', '」', 'は', '定価', '2', '2', '0', '0', '円', '+', '税', 'です']
>>> tokenize('「初めての tensorflow」は定価2200円+税です')
['「', '初めて', 'の', 'tensorflow', '」', 'は', '定価', '2200', '円', '+', '税', 'です']

異なったわかち書きの結果が得られるため、異なったBag of Wordsが得られ、結果として識別器の予測結果も違ってしまいます。

また、2文目に注目すると、2200 が1文字ずつに分解されて '2', '2', '0', '0' になってしまっています。 ここでわかち書きのために使っているMeCab辞書は“連続する半角数字”を1つの単語として処理するようになっていますが、全角数字はそうではないからです。 このように、わかち書きのために利用するライブラリ(ここではMeCab)やその辞書が想定していない表記の文をわかち書き処理に入力すると、正しい結果は得られません。

以上のような問題を回避するため、表記のゆれを吸収し、ある一定の表記に統一する処理を行うことが一般的です。これを文字列の 正規化(normalization) といいます。 文字列の正規化は他の処理の前に行うことが一般的ですが、このように他のメインの処理の前に行う処理を指して 前処理(preprocessing) と呼ぶこともあります。

Note

「正規化」という名前のついた処理は、機械学習の文脈ではほかにも登場します。 本節では「文の表記ゆれをなくすために、1つの決まった表記に統一するための文字列変換処理」のことを、「文字列の正規化」として紹介します。 今後ほかの文脈で何らかの“正規化”処理が登場しても、それが「何を」「どのように」正規化する処理なのかを理解して、混乱しないようにしてください。

neologdn

複数の正規化処理をまとめた neologdn という便利なライブラリがあるので使ってみましょう。

pipでインストールできます。

$ pip install neologdn

以下に使用例を示します。

neologdn_sample.py
import neologdn

print(neologdn.normalize('「初めてのTensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
print(neologdn.normalize('「初めての TensorFlow」は定価2200円+税です'))
実行結果
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です
「初めてのTensorFlow」は定価2200円+税です

このように表記ゆれが吸収され、文字が統一されていることがわかります。

neologdnのGitHubリポジトリ[1]ではほかにも例が紹介されているので、確認してみてください。

neologdnが行うのは、MeCab辞書([mecab-dic]で後述)の一種であるNEologdのデータを生成する時に使われている正規化処理です。 具体的にどのような正規化が行われているかがNEologdの公式wikiで紹介されており[2]、これをC実装でライブラリ化したものがneologdnです。 このWikiにはPythonで書かれたサンプルコードがありますが、neologdnは関数1つに正規化処理がまとまっていて使い勝手がよいのに加え、Cで実装されているので高速であるという利点もあります。

小文字化/大文字化

neologdn.normalize の変換ルールにはアルファベットの大文字・小文字変換は含まれていないので、次のような表記ゆれを吸収することはできません。

「初めての TensorFlow」は定価2200円+税です
「初めての tensorflow」は定価2200円+税です

しかし、Pythonstr 型の組み込みメソッドである .lower() または .upper() を使って小文字または大文字に表記を統一することで、これを補うことができます。

text = '「初めての TensorFlow」は定価2200円+税です'
print(text.lower())
実行結果
「初めての tensorflow」は定価2200円+税です

特に固有名詞などではアルファベットの大文字・小文字の区別が大事なこともあるため、この処理を行うことがよいとは一概にはいえませんが、有用だと思われるケースでは使っていきましょう。

この小文字化・大文字化処理を行うかどうかを、形態素解析の結果によって判定するというのも1つの手です。 例えばMeCabのほとんどの辞書で形態素解析の結果として品詞情報が得られるため、それを参照し、固有名詞には小文字化・大文字化を行わず、他の単語に対しては行うようにする、などです。

Unicode正規化

Unicodeは現在、文字コードの事実上の標準といえるほど広く使われています。 また、Python 3では文字列型はUnicodeで表現されており[3]Pythonで(特に日本語のような非ASCII文字圏の)自然言語処理を行うのであれば、Unicodeの扱いは避けては通れません。 本節では、Unicodeで文字を扱う際の正規化について見ていきます。

Unicode正規化概説

前節とは違った、以下のような例を考えてみましょう。

㈱ほげほげ
(株)ほげほげ

Unicodeには「㈱」のような文字も定義されており、1つの文字として表現できます。 一方で「(株)」 は「(」「株」「)」の3文字を連ねた文字列ですが、「㈱」も「(株)」も意味としては同じなので、この表記ゆれを吸収できると便利です。

また、もう1つ別の例として、次のコードを見てみましょう。 前回の記事で取り上げた、わかち書き&BoW化を行うコードです。texts には同じ文が2つ入っており、これらをわかち書きした結果をもとにBoWを計算します。同じ文をBoW化するので、同じBoWが得られるはずです。

from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize

texts = [
    'ディスプレイを買った',
    'ディスプレイを買った',
]

vectorizer = CountVectorizer(tokenizer=tokenize)
vectorizer.fit(texts)

print('bow:')
print(vectorizer.transform(texts).toarray())

print('vocabulary:')
print(vectorizer.vocabulary_)
実行結果
bow:
[[1 1 0 0 0 1 1]
 [1 1 1 1 1 0 1]]
vocabulary:
{'ディスプレイ': 5, 'を': 1, '買っ': 6, 'た': 0, 'テ': 4, '゙': 2, 'ィスプレイ': 3}

ところがどうでしょう。異なるBoWが得られてしまいました([1 1 0 0 0 1 1][1 1 1 1 1 0 1])。

BoWの語彙をチェックしてみると、'テ' ' ゙', 'ィスプレイ' という何やら怪しげな語彙が獲得されています。 実は2文目に含まれる「デ」は 結合文字列(combining character sequence) と呼ばれる特殊な表現で、複数の文字(正確には「コードポイント」、後述)の組み合わせで表現される文字なのでした。この例だと「テ」と「 ゙」(濁点)を組み合わせて1つの文字「デ」になります。これは特殊な例ではなく、例えばMacOSのfinderでファイル名やフォルダ名を編集すると、結合文字列が使われてしまいます[4]

それに対して1文目の「デ」は 合成済み文字(precomposed character) と呼ばれ、1つの文字として「デ」を表現します。

以上のように、Unicodeには様々な文字と仕様が定義されているため、同一と思える文字でも細かい表記(というか、バイト表現)が異なることがあります。必要な場面ではこれらをそのまま区別して扱えばよいのですが、「文意を読み取る」ことを目的とした自然言語処理においては、多くの場合この表記ゆれは無視するのが好ましいのです。

Unicodeにはまさにこの表記ゆれの吸収を可能にする正規化の仕様が定義されており、Pythonでは標準モジュールとして提供されている unicodedata から利用することができます。

unicode_normalization_sample.py
import unicodedata

normalized = unicodedata.normalize('NFKC', '㈱ほげほげ')

assert normalized == '(株)ほげほげ'
print(normalized)
実行結果
(株)ほげほげ

このように unicodedata.normalize メソッドによって (株) に変換され、表記ゆれを吸収できます。この変換ルールはUnicodeに仕様としていくつか定義されていて、そのうちの1つである NFKC を第1引数で指定しています。

また、2つ目の例の問題も解決できます。上記のBoWのサンプルコードを修正し、tokenize の前にUnicode正規化を行うようにしてみます。

unicode_normalization_bow_sample.py
import unicodedata

from sklearn.feature_extraction.text import CountVectorizer

from tokenizer import tokenize


def normalize_and_tokenize(text):
    normalized = unicodedata.normalize('NFKC', text)
    return tokenize(normalized)


texts = [
    'ディスプレイを買った',
    'ディスプレイを買った',
]

vectorizer = CountVectorizer(tokenizer=normalize_and_tokenize)
vectorizer.fit(texts)

print('bow:')
print(vectorizer.transform(texts).toarray())

print('vocabulary:')
print(vectorizer.vocabulary_)
実行結果
bow:
[[1 1 1 1]
 [1 1 1 1]]
vocabulary:
{'ディスプレイ': 2, 'を': 1, '買っ': 3, 'た': 0}

すると今度は、期待したとおりの結果が得られました。結合文字列の「デ」が合成済み文字の「デ」に変換されたのです。

Unicode正規化詳解

Unicode正規化についてもう少し詳しく見ていきましょう。 Unicode正規化を理解するには、Unicodeの仕様をある程度知らなければなりませんが、ここでは必要な部分を少しだけかいつまんで解説します(Unicodeに興味のある方は、ぜひ詳細な仕様も調べてみてください)。

Unicode文字コードの一種です。 そして文字コードとは、コンピュータ内部で、文字をどのようなバイト列で表現するかを定めたルールのことです。

例えばUnicodeでは「あ」を 0x3042 で表現することになっています。 この 0x3042コードポイント(code point) と呼び、それがUnicodeのコードポイントであることを示すために「 U+3042 」と表記します。

Pythonで実際に確かめてみましょう。Pythonには次の2つの組み込み関数が用意されています。

ord()

Unicode文字を受け取り、コードポイントを表す整数を返す。

chr()

コードポイントを表す整数を受け取り、Unicode文字を返す。

「あ」のコードポイントが 0x3042 であることを見てみましょう。

>>> hex(ord('あ'))
'0x3042'
>>> chr(0x3042)
'あ'

そして上の例で登場した「デ」のコードポイントは U+30C7 です。

>>> hex(ord('デ'))
'0x30c7'
>>> chr(0x30C7)
'デ'

また、「テ」のコードポイントは U+30C6 で、「 ゙」(濁点)のコードポイントは U+3099 です。 この2つを連続して並べると、Unicodeでは結合文字列として解釈され、1文字として扱われます。

>>> chr(0x30C6) + chr(0x3099)
'デ'

結合文字列を作る場合、先頭の文字(この例では「テ」)を 基底文字(base character) といい、後に続くコードポイント(この例では「 ゙」)を 結合文字(combining character) といいます。

さて、「デ」を表すには、合成済み文字 0x30C7 を使うか、2つのコードポイント U+30C6 U+3099 を並べて結合文字列として表現するかという2種類の方法があることがわかりました。 しかしこれは上の例で見たように、同じ文字に複数の表現方法が存在するということでもあり、場合によっては不便です。

この問題に対してUnicodeは、「同じ文字として扱うべきコードポイントの組を定義する」という方法で対応しました。これを Unicodeの等価性 といいます。 この等価性には「 0x30C7U+30C6 U+3099 は等価である」というルールも定義されています。このルールを参照することで、0x30C7U+30C6 U+3099 が同じ文字を表していることがわかるのです。

そして、Unicode正規化とは、この等価性に基づき合成済み文字を分解したり合成したりすることです。 分解は合成済み文字を結合文字列に変換する処理( 0x30C7U+30C6 U+3099 )、合成は結合文字列を合成済み文字に変換する処理( U+30C6 U+30990x30C7)です。 分解も合成もその名のとおりですね。

Unicodeの分解と合成

Figure 1. Unicodeの分解と合成

Unicode正規化には4種類あり、分解・合成の適用方法と、用いる等価性が異なっています。

NFD (Normalization Form Canonical Decomposition)

正準等価性による分解。 bib

NFC (Normalization Form Canonical Composition)

正準等価性による分解→正準等価性による合成。

NFCD (Normalization Form Compatibility Decomposition)

互換等価性による分解。

NFKC (Normalization Form Compatibility Composition)

互換等価性による分解→正準等価性による合成。

Unicodeの等価性には 正準等価性(canonical equivalence)互換等価性(compatibility equivalence) の2つがあります。これらは、どの文字を同等とみなすかの基準が異なっています。

正準等価性 (canonical equivalence)

見た目も機能も同じ文字を等価とみなす等価性。例えば「デ」と「テ」+「 ゙」。

互換等価性 (compatibility equivalence)

正準等価性よりも範囲の広い等価性。 見た目や機能が異なる可能性はあるが、同じ文字がもとになっているもの[5]を等価とみなす。例えば「テ」と「テ」、「ℌ」と「ℍ」と「H」、など。 互換等価性は正準等価性を含むが、その逆は成り立たない。

unicode_normalization_sample.pyunicodedata.normalize の第1引数に与えた 'NFKC' は、上記の正規化の種類を指定していたのです。 実際にアプリケーションに利用する時は、そのアプリケーションが扱う問題やデータの性質に合わせ、どの正規化を用いるのかを決定する必要があります。 互換等価性に基づいた変換を含むNFCD・NFKCはNFD・NFCより大胆な変換を行いますが、 本書のケース(文字の細かい差異は無視して文の意味に注目するため、カタカナの全角半角などは吸収してしまいたい)では前者の方が適していると判断し、本節の例には NFKC を採用しました。

なお、等価性や正規化の厳密な仕様は、 http://unicode.org/reports/tr15/ を参照してください。 また、本記事の内容は[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)を参考にしました。Unicode正規化に限らず、文字コードについて深く知りたいときは参照してみてください。

 

[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)

[改訂新版]プログラマのための文字コード技術入門 (WEB+DB PRESS plusシリーズ)

 

 


4. macOS High Sierraで確認。
5. Unicodeの仕様には「同一の抽象文字を表す(represent the same abstract character)」と表現されています。