英語の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_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 を使いましょう。
NLTKの資料
ちょっと情報が古いものの、無料で公開されているので困ったらすぐ調べられます。
- 作者:Steven Bird,Ewan Klein,Edward Loper
- 発売日: 2010/11/11
- メディア: 大型本
*2:ハーラン・エリスン - Wikipediaでは 「「悔い改めよ、ハーレクィン!」とチクタクマンはいった」 で普通に同じ括弧を使っているので、日本で「」『』の使い分け方が浸透しているかは不明