nikkie-ftnextの日記

イベントレポートや読書メモを発信

『入門 自然言語処理』3章をPython 3で写経し、テキスト処理の前処理であるステミングとトークン化についてインプットしました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。

今週からは自然言語処理の基礎固めとして『入門 自然言語処理』に取り組んでいきます。

入門 自然言語処理

入門 自然言語処理

今週は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

  1. まずbytesを扱う
    • urllib.request.urlopenでWeb上のリソースを読み込む
  2. bytesdecodeしてstr
    • HTMLの場合はBeautifulSoupのget_textメソッドでHTMLタグを除く
    • ヘッダーやフッターを除き、必要な部分を抜き出すstrfindrfindメソッド)
  3. strlistに(要素はstr
  4. トークンのリストからnltk.Text5
  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つのステマーが紹介されました。

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の方が文字の減りが少ないです。

レマタイズ

見出し語化のことで、トークンを辞書に載っている語形にします。

WordNetLemmatizer

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.splitre.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.tokenize.regexp.regexp_tokenize

これは『入門 自然言語処理』3章 生テキストの処理のうち、3.1〜3.7を写経して学んだことのアウトプットです。

感想

『入門 自然言語処理』はボリューミーで正規表現、NLTKとお腹いっぱいです。
アウトプットしていませんが、Web上のテキストの取得やUnicodeの話などもありました(機会があれば書きたいです)。
テキストの取得〜前処理部分をまとめてインプットをする中で、これまでの経験がつながる感覚もありました。
NLTKは巨大なパッケージで、今回触ったところの他にも色々と寄り道素振りしがいがありそうです。

3章も少し残っていますが、来週は別の章に取り組む予定です。


  1. Python2系は全然触る機会がなかったのですが、sunsetした後にこんな形で2系と思い出作りすることになるとは思いませんでした

  2. リンク先を呼んでいただければおわかりいただけると思いますが、すばらしすぎる本なので禁書にすべきだそうです。バズり戦略ですね

  3. 詳しくは、エラーメッセージで案内されるリンク Installing NLTK Data — NLTK 3.4.5 documentationdownloader Moduleのドキュメント を見るとよさそうです。

  4. ref: http://www.nltk.org/images/pipeline1.png

  5. help(Text)で確認したところ、イニシャライザの引数tokensにはstrのシーケンスを渡すので、トークンのリストも渡せます

  6. ref: https://docs.python.org/ja/3/library/re.html#regular-expression-syntax

  7. ステミング処理を正規表現で実装したことは、正規表現での実現方法を知り、正規表現の素振りをするという「車輪の再実装」効果がありました。

  8. NLTKのドキュメントから Porter Stemming Algorithm が案内されています

  9. https://docs.python.org/ja/3/library/re.html#regular-expression-syntax

  10. 書籍中のコードはキャプチャ無効化がないことにより、想定通り動かないというバグがありました。オンライン版のコードを参考にしています

  11. 詳しくは(?aiLmsux)の項目を参照:https://docs.python.org/ja/3/library/re.html#regular-expression-syntax