エイエイレトリック

なぐりがき

NLTKを使った英語テキストのtokenize

英語のtokenizeは日本語の分かち書きに比べたら楽なようにみえるが、注意すべき点があるよという紹介をします。

そのために、今回は NLTK (Natural Language Toolkit) を使ってtokenizeします。 (NLTK のVersion 3.5、Python 3.7.4で動作確認しています。)

ちなみにtokenizeは日本語に翻訳すると "トークン化する" ですが、NLTK の関数名と統一するためにtokenizeと表記します。

文のtokenize

複数の文を含む文書を、文単位で分割することを考えます。

文のtokenizeは句点 (period) で区切ればよさそうに見えるが、英語にも「モーニング娘。」のような句点を含む単語があるのでうまくいかない。 簡単なところだと、e.g. さん、 論文テキストだと et al. 先生*1が鬼門です。

# https://www.aclweb.org/anthology/N19-1423/ のアブストラクトより
bert_text = "Unlike recent language representation models (Peters et al., 2018a; Radford et al., 2018), \
BERT is designed to pre-train deep bidirectional representations \
from unlabeled text by jointly conditioning on both left and right context in all layers."

# periodでsplit
bert_text.split('.')
>> 
['Unlike recent language representation models (Peters et al',
 ', 2018a; Radford et al',
 ', 2018), BERT is designed to pre-train deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers',
 '']

et al. 先生の力によって、1文のはずのテキストが4文になってしまいました。 そういう訳で、tokenizer を使った文を分割する必要性があります。

sent_tokenize

NLTK の場合、文への分割は sent_tokenize を使って行います。 この関数の内部では PunktSentenceTokenizer クラスのpickle を読み込んでいるので、実質PunktSentenceTokenizerでtokenizeしてると考えてよさそうです。

from nltk.data import load
tok = load("tokenizers/punkt/english.pickle")

type(tok)
>> nltk.tokenize.punkt.PunktSentenceTokenizer

tokenizerのメインの挙動は、_slices_from_text の部分。 (github code)

まず、 self._lang_vars.period_context_re() (下記正規表現) を文末の候補の割り出しに使っています。

# self._lang_vars.period_context_re()
\S*                          # some word material
      [\.\?!]             # a potential sentence ending
      (?=(?P<after_tok>
          (?:[?!)";}\]\*:@\'\({\[])              # either other punctuation
          |
          \s+(?P<next_tok>\S+)     # or whitespace and some other token
      ))

この正規表現はちょっと複雑ですが、簡単にいえば、 .?! 以外の他の句読点もしくはスペース+次のtoken( <next_tok>, <after_tok> ) を先読みして判定しています。

その後、 text_contains_sentbreak (github code)でtokenが sentence break かどうか判定して、実際に分割します。

sentence break の判定には 略語 self._params.abbrev_types や コロケーションself._params.collocations のようなさまざまなパラメーターが関わっており、 読むのが面倒 かなり複雑そうなので説明は省きます。

sent_tokenize を使った分割結果は先ほどの例文での et al. 先生にもちゃんと対応しています。

from nltk import sent_tokenize

sent_tokenize(bert_text)
>> ['Unlike recent language representation models (Peters et al., 2018a; Radford et al., 2018), BERT is designed to pre-train deep bidirectional representations from unlabeled text by jointly conditioning on both left and right context in all layers.']

しかしながら、ちょこちょこ対応しきれていない箇所があります。

# 1. 顔文字には対応できない 
sent_tokenize('I love Japan:) I love Tokyo.')
>> ['I love Japan:) I love Tokyo.']

# 2. 対応していない略語もある
sent_tokenize('I love Japan a.k.a. Nippon.')
>> ['I love Japan a.k.a.', 'Nippon.']

# 3. (三点リーダーには対応できるが、)ピリオドの後に空白がないと分割できない
sent_tokenize('I love Japan.I love Tokyo...')
>> ['I love Japan.I love Tokyo...']

# 4. 文末が連続した ! or ? だと最後の1文字で分割されてしまう
sent_tokenize('I love Japan!!! I love Tokyo!!!')
>> ['I love Japan!!!', 'I love Tokyo!!', '!']

1のような比較的noisyなテキストの場合には文単位に区切ろうとせず、 TweetTokenizer (単語のtokenizer) を直接使うとよいと思います。

ちなみに、手元のspacyだと 3, 4は対応していたので、spacy すごいなと思いました (感想)

単語のtokenize

文を単語単位に分割することを考えます。 これまた split() で対応できるのではないか、と思いますが、コロン・コンマ・括弧といった記号は単語と切り離さないと、後々の処理 (tf-idf とか vocab とか) に響きます。

'model (Peters et al., 2018a; Radford et al., 2018), BERT is designed '.split()
>>['model',
 '(Peters',
 'et',
 'al.,',
 '2018a;',
 'Radford',
 'et',
 'al.,',
 '2018),',
 'BERT',
 'is',
 'designed']

上記例でいえば、 '2018),' は2018とは別の語彙として扱われてしまわないよう、 ['2018', ')' ,','] の3つに分けたいです。

word_tokenize

NLTK の単語分割は word_tokenize を使います。

sent_tokenize と同様、内部では NLTKWordTokenizer を呼び出して分割が行われているので、実質NLTKWordTokenizerと同じです。 word_tokenize の場合、 引数 preserve_lineTrue にすれば、 sent_tokenize も同時に実行します。

NLTKWordTokenizer は TreebankTokenizer (後述) を改良したtokenizerです。 ドキュメントにも書いてあるように、このクラスは 正規表現を使った "destructive" (破壊的) なtokenizerなので、detokenizeによる復元が担保されていないので注意が必要です。

このtokenizerで特徴的なのは 'I'll' や 'you're' のような短縮形を [I, 'll], [you, 're] のように分割する点です。

CONTRACTIONS の部分で、'gotta'[got, ta] と分割するルールも設定されています。

# 引用符や省略を含む例文 (すごく適当)
s = '''They're saying that "he is so-- great!". I wanna do this.'''

'_'.join([w for w in word_tokenize(s)])
>> "They_'re_saying_that_``_he_is_so_--_great_!_''_._I_wan_na_do_this_."

また、引用符 "`` に変換されるルール (github code) なので、"destructive" (破壊的) にtokenizeしているといえます。

TreebankWordTokenizer

復元する必要がない場合は、word_tokenize で十分ですが、復元が担保されていない以上、系列ラベリングなど、tokenize 前のテキストの位置情報の必要ある場合に word_tokenize を利用するのは得策ではないと思います。

その場合、別の tokenize module を使いましょう。 span_tokenize 関数をもつクラスであれば、tokenize前に何文字目だったかを取得可能です。

word_tokenize とほぼ同じ実装のTreebankWordTokenizerであれば、 span_tokenize を実行できるので、個人的にこれ一択かなと思います。

元々のTreebankについての説明リンクがことごとく切れているのですが、元々の情報は ftp://ftp.cis.upenn.edu/pub/treebank/public_html/tokenization.html っぽい気がします。

(参考 nlp - Is there an implementation of the Penn Treebank Tokenizer in Perl? - Stack Overflow)

from nltk.tokenize import TreebankWordTokenizer

tree_tok = TreebankWordTokenizer()
for sp in tree_tok.span_tokenize(s):
    print(f'{sp}\t{s[sp[0]:sp[1]]}' )
>>
(0, 4)    They
(4, 7)    're
(8, 14)   saying
(15, 19)  that
(20, 21)  "
(21, 23)  he
(24, 26)  is
(27, 29)  so
(29, 31)  --
(32, 37)  great
(37, 38)  !
(38, 39)  "
(39, 40)  .
(41, 42)  I
(43, 46)  wan
(46, 48)  na
(49, 51)  do
(52, 56)  this
(56, 57)  .

span_tokenize のspan情報を取っておけば、They're の間にスペースが空いていないという情報が保持されるので復元も簡単です。

word_tokenize の時とtokenize結果は大体同じですが、 元のテキストから文字を取得することで、引用符の記号を " のまま使うことができます。

引用符の中の引用符

最後に、ちょっと意地悪なテキストでも正しくtokenizeできるか検証してみます。

Harlan Ellison - Wikipedia のテキストから引用。

Some of his best-known work includes the Star Trek episode "The City on the Edge of Forever", his A Boy and His Dog cycle, and his short stories "I Have No Mouth, and I Must Scream" and "'Repent, Harlequin!' Said the Ticktockman".

タイトルが羅列されていますが、"'Repent, Harlequin!' Said the Ticktockman" ("Repent, Harlequin!" Said the Ticktockman - Wikipedia) がネックになりそう。

タブルクオート" のなかにシングルクオート ' が含まれています。

今まで意識したことはなかったのですが、二重に引用符を使う時は異なる引用符 を使うルールがあるみたいですね。*2

word_tokenize だと _``_'Repent_,_Harlequin_!_'_Said_the_Ticktockman_''_. となってしまい、うまく引用符を分離できませんでした。

elison = '''Some of his best-known work includes the Star Trek episode \ 
"The City on the Edge of Forever", \
his A Boy and His Dog cycle, and his short stories "I Have No Mouth, \
and I Must Scream" and \
"'Repent, Harlequin!' Said the Ticktockman".'''


'_'.join([w for w in word_tokenize(elison)])
>> 
"Some_of_his_best-known_work_includes_the_Star_Trek_episode
_``_The_City_on_the_Edge_of_Forever_''_,
_his_A_Boy_and_His_Dog_cycle_,_and_his_short_stories_``_I_Have_No_Mouth_,
_and_I_Must_Scream_''_and
_``_'Repent_,_Harlequin_!_'_Said_the_Ticktockman_''_."

(※読みやすさのために出力結果に改行を追加してます)

ちなみに spacy だと _"_\'_Repent_,_Harlequin_!_\'_Said_the_Ticktockman_"_.'。ちゃんと分離できました。

import spacy

nlp = spacy.load('en_core_web_sm')

doc = nlp(elison)
'_'.join([d.text for d in doc]) 
>> 
'Some_of_his_best_-_known_work_includes_the_Star_Trek_episode
_"_The_City_on_the_Edge_of_Forever_"_,
_his_A_Boy_and_His_Dog_cycle_,_and_his_short_stories_"_I_Have_No_Mouth_,
_and_I_Must_Scream_"_and
_"_\'_Repent_,_Harlequin_!_\'_Said_the_Ticktockman_"_.'

結論: tokenizerでどれを使えばいいかわからない時は、spacy を使いましょう。

spacy.io

NLTKの資料

ちょっと情報が古いものの、無料で公開されているので困ったらすぐ調べられます。

github.com

入門 自然言語処理

入門 自然言語処理

*1:togetter.com

*2:ハーラン・エリスン - Wikipediaでは 「「悔い改めよ、ハーレクィン!」とチクタクマンはいった」 で普通に同じ括弧を使っているので、日本で「」『』の使い分け方が浸透しているかは不明