英語の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が鬼門です。
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."
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.']
しかしながら、ちょこちょこ対応しきれていない箇所があります。
sent_tokenize('I love Japan:) I love Tokyo.')
>> ['I love Japan:) I love Tokyo.']
sent_tokenize('I love Japan a.k.a. Nippon.')
>> ['I love Japan a.k.a.', 'Nippon.']
sent_tokenize('I love Japan.I love Tokyo...')
>> ['I love Japan.I love Tokyo...']
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_line
を True
にすれば、 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