はじめに
頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週からは自然言語処理の基礎固めとして『入門 自然言語処理』に取り組んでいきます。
- 作者:Steven Bird,Ewan Klein,Edward Loper
- 出版社/メーカー: オライリージャパン
- 発売日: 2010/11/11
- メディア: 大型本
今週は3章から、英語のテキストについて、以下のトピックを扱います。
- ステミング
- トークン化
目次
『入門 自然言語処理』とは
2010年発行のオライリー本です。
NLTKというパッケージを使った自然言語処理について書かれています。
この本のコードはPython2系で書かれているので、Python3系に書き直しつつ取り組んでいくことになります1。
Python3系対応の英語版がオンラインで公開されていたので、コードに詰まったらオンライン版を参考にしながら進めました。
この本については「禁書にすべき」という声も上がっています2。
動作環境
$ sw_vers ProductName: Mac OS X ProductVersion: 10.14.6 BuildVersion: 18G103 $ python -V # venvによる仮想環境を使用 Python 3.7.3 $ pip list # grepを使って抜粋して表示 beautifulsoup4 4.8.2 ipython 7.12.0 nltk 3.4.5
NLTKのLookupError
『入門 自然言語処理』のコードを写経していく中でLookupError
にたびたび出会いました。
In [16]: from nltk.tokenize import word_tokenize In [17]: tokens = word_tokenize(raw) --------------------------------------------------------------------------- LookupError Traceback (most recent call last) ...(省略)... LookupError: ********************************************************************** Resource punkt not found. Please use the NLTK Downloader to obtain the resource: >>> import nltk >>> nltk.download('punkt') (以下省略)
エラーメッセージで案内されているように、nltk.download
を使って必要なデータをダウンロードすることでLookupErrorは解消します3。
ダウンロードしたデータは$HOME/nltk_data
に置かれました。
$ tree -L 1 $HOME/nltk_data /Users/.../nltk_data ├── corpora └── tokenizers 2 directories, 0 files
自然言語処理のパイプライン(3.1.8より)
3章は自然言語処理の流れを扱う章です(本記事ではステミングやトークン化に注力します)。
自然言語処理で扱うデータの流れは以下のようになると理解しました4。
- まず
bytes
を扱うurllib.request.urlopen
でWeb上のリソースを読み込む
bytes
をdecode
してstr
にstr
をlist
に(要素はstr
)- 段落/文/単語/文字のように粒度を選択できる
- 例:
nltk.tokenize.word_tokenize
(英文からトークン(≒単語)のリストを返す)
- トークンのリストから
nltk.Text
5 - 正規化し語彙を構築
ステミングやトークン化は3のステップに関わります。
ステミング
ステミングとは、単語から語幹を取り出すことです。
ステミングにより、例えば、名詞の単数形と複数形や、動詞の原形と活用形を同一として扱えると理解しています。
『入門 自然言語処理』では正規表現を使った例から始めてNLTKのステマーが紹介されました。
ステミングの対象の語句は、str.lower
メソッドで小文字に正規化するのがよさそうに思います。
正規表現を利用してステマーを作る
以下の関数から始めて正規表現を導入していきます。
In [193]: def stem(word): ...: for suffix in ['ing', 'ly', 'ed', 'ious', 'ies', 'ive', 'es', 's', 'ment']: ...: if word.endswith(suffix): ...: return word[:-len(suffix)] ...: return word ...: In [194]: stem('processing') Out[194]: 'process' In [198]: stem('processes') Out[198]: 'process'
文字列がing, ly, ed, ious, ies, es, s, mentいずれかで終わる場合、該当する文字列とそれ以前の部分を返します。
例えばprocessingを渡した場合、(process)(ing)
と正規表現にマッチするので、()
の機能でキャプチャされて、re.findall
で返されます。
In [205]: re.findall(r'^(.*)(ing|ly|ed|ious|ies|ive|es|s|ment)$', 'processing') Out[205]: [('process', 'ing')]
|
(パイプ、論理和)は左からマッチさせるので、s|es
のようなパターンではsが削除されてeが残るということが起こりえますね。
この正規表現には問題があり、processesをstem
関数のように処理できません。
In [206]: re.findall(r'^(.*)(ing|ly|ed|ious|ies|ive|es|s|ment)$', 'processes') Out[206]: [('processe', 's')]
理由は*
が貪欲マッチ((process)(es)よりも(processe)(s)の方が*
にマッチする部分が長いので選ばれる)だからです。
*?
とすることでこの挙動は解決します6。
'*' 、 '+' 、および '?' 修飾子は全て 貪欲 (greedy) マッチで、できるだけ多くのテキストにマッチします。この挙動が望ましくない時もあります。(中略)修飾子の後に ? を追加すると、 非貪欲 (non-greedy) あるいは 最小 (minimal) のマッチが行われ、できるだけ 少ない 文字にマッチします。
In [207]: re.findall(r'^(.*?)(ing|ly|ed|ious|ies|ive|es|s|ment)$', 'processes') Out[207]: [('process', 'es')]
あとはing, ly, ed, ious, ies, es, s, mentのいずれでも終わらない文字列向けに?
を追加します。
In [209]: re.findall(r'^(.*?)(ing|ly|ed|ious|ies|ive|es|s|ment)?$', 'language') Out[209]: [('language', '')]
In [210]: def stem(word): ...: regexp = r'^(.*?)(ing|ly|ed|ious|ies|ive|es|s|ment)?$' ...: stem, suffix = re.findall(regexp, word)[0] ...: return stem ...:
挙動に
- ingやedが消えるのでlikeのing系 likingが元に戻らない(likになる)
- isやbasisなどのsを落とす
というイケていない点があるものの、正規表現でステマーができました。
NLTK組み込みのステマーを利用する
『入門 自然言語処理』は、NLTK組み込みのステマーを使うことを勧めています7。
理由は、組み込みのステマーは幅広く例外を扱えるためだそうです。
In [223]: raw = """DENNIS: Listen, strange women lying in ponds distributing swords ...: is no basis for a system of government. Supreme executive power derives from ...: a mandate from the masses, not from some farcical aquatic ceremony.""" ...: In [224]: tokens = nltk.word_tokenize(raw)
2つのステマーが紹介されました。
- PorterStemmer
- LancasterStemmer
- Lancaster (Paice/Husk) stemming algorithmに基づくステマー
In [225]: porter = nltk.PorterStemmer() In [226]: lancaster = nltk.LancasterStemmer() In [227]: [porter.stem(t) for t in tokens] Out[227]: ['denni', ':', 'listen', ',', 'strang', 'women', 'lie', 'in', 'pond', 'distribut', 'sword', 'is', 'no', 'basi', 'for', 'a', 'system', 'of', 'govern', '.', 'suprem', 'execut', 'power', 'deriv', 'from', 'a', 'mandat', 'from', 'the', 'mass', ',', 'not', 'from', 'some', 'farcic', 'aquat', 'ceremoni', '.'] In [228]: [lancaster.stem(t) for t in tokens] Out[228]: ['den', ':', 'list', ',', 'strange', 'wom', 'lying', 'in', 'pond', 'distribut', 'sword', 'is', 'no', 'bas', 'for', 'a', 'system', 'of', 'govern', '.', 'suprem', 'execut', 'pow', 'der', 'from', 'a', 'mand', 'from', 'the', 'mass', ',', 'not', 'from', 'som', 'farc', 'aqu', 'ceremony', '.']
書籍中で指摘されたlying(7番目の単語)のステミングの比較から、PorterStemmerを使いたいと思っています。
また、basis, farcical, aquaticを見ると、PorterStemmerの方が文字の減りが少ないです。
レマタイズ
見出し語化のことで、トークンを辞書に載っている語形にします。
In [229]: wnl = nltk.WordNetLemmatizer() In [232]: [wnl.lemmatize(t) for t in tokens] Out[232]: ['DENNIS', ':', 'Listen', ',', 'strange', 'woman', 'lying', 'in', 'pond', 'distributing', 'sword', 'is', 'no', 'basis', 'for', 'a', 'system', 'of', 'government', '.', 'Supreme', 'executive', 'power', 'derives', 'from', 'a', 'mandate', 'from', 'the', 'mass', ',', 'not', 'from', 'some', 'farcical', 'aquatic', 'ceremony', '.']
ステマーが処理していなかったwomenをwomanに変更できています。 一方、lyingはそのままです。
レマタイザは遅いそうなので、処理にかかる時間の優先度によるかと思いますが、WordNetLemmatizer→PorterStemmerの順で試してみたいと思いました。
トークン化
文字列をトークンに分割することです。
トークンとは、「言語データの一部を構成する識別可能な言語学上の単位」だそうです。
正規表現を使ったトークン化
最も単純なトークン化は、空白文字でテキストを分割することです。
r'\s+'
は「空白文字の1回以上の繰り返し」です。
空白文字の1回以上の繰り返しというパターンで文字列を分割します(re.split)。
In [234]: raw = """'When I'M a Duchess,' she said to herself, (not in a very hopeful tone ...: though), 'I won't have any pepper in my kitchen AT ALL. Soup does very ...: well without--Maybe it's always pepper that makes people hot-tempered,'...""" In [238]: re.split(r'\s+', raw) Out[238]: ["'When", "I'M", 'a', "Duchess,'", 'she', 'said', 'to', 'herself,', '(not', 'in', 'a', 'very', 'hopeful', 'tone', 'though),', "'I", "won't", 'have', 'any', 'pepper', 'in', 'my', 'kitchen', 'AT', 'ALL.', 'Soup', 'does', 'very', 'well', 'without--Maybe', "it's", 'always', 'pepper', 'that', 'makes', 'people', "hot-tempered,'..."]
単語に(や'といった記号が含まれてしまうのに対応するため、re.split
をre.findall
に変え、r'\w+'
(=r'[a-zA-Z0-9_]+'
に該当するものを取り出すようにします。
In [242]: re.findall(r'\w+', raw) Out[242]: ['When', 'I', 'M', 'a', 'Duchess', 'she', 'said', 'to', 'herself', 'not', 'in', 'a', 'very', 'hopeful', 'tone', 'though', 'I', 'won', 't', 'have', 'any', 'pepper', 'in', 'my', 'kitchen', 'AT', 'ALL', 'Soup', 'does', 'very', 'well', 'without', 'Maybe', 'it', 's', 'always', 'pepper', 'that', 'makes', 'people', 'hot', 'tempered']
正規表現を拡張していきます。
まずIt'sをItと'sに分けられるようにします。
r'\w+'
([a-zA-Z0-9_]
の1回以上の繰り返し)に一致しなければ、r'\S\w*'
(空白文字以外の1文字とr'\w'
0文字以上)に一致するものを探します。
In [243]: re.findall(r'\w+', "It's show time.") Out[243]: ['It', 's', 'show', 'time'] In [244]: re.findall(r'\w+|\S\w*', "It's show time.") Out[244]: ['It', "'s", 'show', 'time', '.']
--
や...
に対応できるように正規表現を拡張します。
In [251]: re.findall(r"\w+(?:[-']\w+)*|'|[-.(]+|\S\w*", "It's show time. '--' ... (hot-tempered)") Out[251]: ["It's", 'show', 'time', '.', "'", '--', "'", '...', '(', 'hot-tempered', ')']
追加した正規表現パターンにより
--
や...
を抜き出せる(r'[-.(]+'
)- シングルクォートを抜き出せる(
r"'"
)
正規表現中の()
はキャプチャ機能があるため、re.findall
の返り値に含まれてしまいます。
キャプチャを無効化するために(?:)
としています9。
普通の丸括弧の、キャプチャしない版です。
In [263]: re.findall(r"\w+(?:[-']\w+)*|'|[-.(]+|\S\w*", raw) Out[263]: ["'", 'When', "I'M", 'a', 'Duchess', ',', "'", 'she', 'said', 'to', 'herself', ',', '(', 'not', 'in', 'a', 'very', 'hopeful', 'tone', 'though', ')', ',', "'", 'I', "won't", 'have', 'any', 'pepper', 'in', 'my', 'kitchen', 'AT', 'ALL', '.', 'Soup', 'does', 'very', 'well', 'without', '--', 'Maybe', "it's", 'always', 'pepper', 'that', 'makes', 'people', 'hot-tempered', ',', "'", '...']
NLTK+正規表現を使ったトークン化
nltk.tokenize.regexp.regexp_tokenize
メソッドを使います10。
In [253]: text = 'That U.S.A. poster-print costs $12.40...' In [258]: pattern = r'''(?x) ...: (?:[A-Z]\.)+ # U.S.A ...: | \w+(?:-\w+)* # That, poster-print ...: | \$?\d+(?:\.\d+)?%? # $12.40 ...: | \.\.\. # ... ...: | [][.,;"'?():-_`] # separate tokens ...: ''' In [259]: nltk.regexp_tokenize(text, pattern) Out[259]: ['That', 'U.S.A.', 'poster-print', 'costs', '$12.40', '...']
正規表現中の(?x)
は「インラインフラグ」だそうで、他にaやiなどを取れるそうです11。
インラインフラグ(?x)
はre.X
に相当し、正規表現の中でコメントが書けるようになります。
ただし、副作用としてr' '
として空白文字に一致させることができなくなるそうです。
まとめ
自然言語処理(英文テキスト)の前処理の中から以下の2点を学びました。
- ステミング:語幹を取り出す(=接辞を除く)
- NLTKの組み込みのステマー(
PorterStemmer
) - レマタイザ(
WordNetLemmatizer
)
- NLTKの組み込みのステマー(
- トークン化
- 正規表現を使う(
nltk.tokenize.regexp.regexp_tokenize
)
- 正規表現を使う(
これは『入門 自然言語処理』3章 生テキストの処理のうち、3.1〜3.7を写経して学んだことのアウトプットです。
感想
『入門 自然言語処理』はボリューミーで正規表現、NLTKとお腹いっぱいです。
アウトプットしていませんが、Web上のテキストの取得やUnicodeの話などもありました(機会があれば書きたいです)。
テキストの取得〜前処理部分をまとめてインプットをする中で、これまでの経験がつながる感覚もありました。
NLTKは巨大なパッケージで、今回触ったところの他にも色々と寄り道素振りしがいがありそうです。
3章も少し残っていますが、来週は別の章に取り組む予定です。
-
Python2系は全然触る機会がなかったのですが、sunsetした後にこんな形で2系と思い出作りすることになるとは思いませんでした↩
-
リンク先を呼んでいただければおわかりいただけると思いますが、すばらしすぎる本なので禁書にすべきだそうです。バズり戦略ですね↩
-
詳しくは、エラーメッセージで案内されるリンク Installing NLTK Data — NLTK 3.4.5 documentation や downloader Moduleのドキュメント を見るとよさそうです。↩
-
help(Text)
で確認したところ、イニシャライザの引数tokens
にはstr
のシーケンスを渡すので、トークンのリストも渡せます↩ -
ref: https://docs.python.org/ja/3/library/re.html#regular-expression-syntax↩
-
ステミング処理を正規表現で実装したことは、正規表現での実現方法を知り、正規表現の素振りをするという「車輪の再実装」効果がありました。↩
-
NLTKのドキュメントから Porter Stemming Algorithm が案内されています↩
-
https://docs.python.org/ja/3/library/re.html#regular-expression-syntax↩
-
書籍中のコードはキャプチャ無効化がないことにより、想定通り動かないというバグがありました。オンライン版のコードを参考にしています↩
-
詳しくは
(?aiLmsux)
の項目を参照:https://docs.python.org/ja/3/library/re.html#regular-expression-syntax↩