nikkie-ftnextの日記

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

『入門 自然言語処理』5章から、英語テキストでも品詞分類できると知った私は、特定の品詞を取り出したWordCloudを試してみました

はじめに

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

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

入門 自然言語処理

入門 自然言語処理

今週は5章「単語の分類とタグ付け」の冒頭部分、品詞タグ付けの紹介から、英語のテキストについて品詞タグ付けを試してみました。

目次

前回までのブログ駆動開発!との関連

先日 janomeチュートリアル1の中で、「風の又三郎」(日本語テキスト)から名詞と動詞を取り出して、WordCloudを作りました。

チュートリアルは、英語テキストの例として、『不思議の国のアリス』でもWordCloudを作っています2

『入門 自然言語処理3で、英語テキストについての品詞タグ付け(以下、タグ付け)を知りました。
「『風の又三郎』の例で品詞を名詞と動詞に絞ったのと同じように、『不思議の国のアリス』でも品詞を絞れるのでは?」と着想し、試してみました。
不思議の国のアリス』の英語テキストから、名詞と動詞を使ってWordCloudを作ります

動作環境

先週と同じ環境に、WordCloud作成に必要なパッケージを追加しました。

$ 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
matplotlib       3.1.3  # 追加
nltk             3.4.5
wordcloud        1.6.0  # 追加

WordCloudを作れるよう、janomeチュートリアルに沿った環境構築をします。

$ unzip ~/Downloads/ipagp00303.zip -d .
$ wget https://raw.githubusercontent.com/mocobeta/janome-tutorial/master/hands-on/data/alice_utf8.txt .

フォルダ配置

.
├── alice_utf8.txt
├── alice_word_cloud.py
├── env
├── ipagp00303
└── result  # 作成したWordCloudの画像を置く

nltk を使ったタグ付け

nltk.word_tokenize したテキスト(トークンのリスト)をnltk.tag.pos_tagでタグ付けできます。

Use NLTK’s currently recommended part of speech tagger to tag the given list of tokens.

今回使われたタガーは averaged_perceptron_tagger のようです(例のごとくnltk.downloadが必要でした)

以下は、同音異義語が登場する例です。

In [8]: text = nltk.word_tokenize("They refuse to permit us to obtain the refuse permit")

In [9]: nltk.pos_tag(text)
Out[9]:
[('They', 'PRP'),
 ('refuse', 'VBP'),
 ('to', 'TO'),
 ('permit', 'VB'),
 ('us', 'PRP'),
 ('to', 'TO'),
 ('obtain', 'VB'),
 ('the', 'DT'),
 ('refuse', 'NN'),
 ('permit', 'NN')]

結果から、

  • 名詞はNNで始まる
  • 動詞はVBで始まる

ようです。
書籍 例5-1 のプログラムを見たところ、これらはNNやVBで始まると仮定してよさそうだったので、続きを考えていきました(時間の制約により掘り下げられていません)。

ソースコード

import matplotlib.pyplot as plt
import nltk
from wordcloud import WordCloud


SEED = 42


def show_wordcloud(text, save_path):
    if isinstance(text, list):
        text = ' '.join(text)
    wordcloud = WordCloud(
        font_path='ipagp00303/ipagp.ttf',
        background_color='white',
        width=1024,
        height=674,
        random_state=SEED,
        collocations=False).generate(text)
    plt.imshow(wordcloud, interpolation='bilinear')
    plt.axis('off')
    plt.savefig(save_path)


if __name__ == '__main__':
    with open('alice_utf8.txt', encoding='utf8') as f:
        concatenated_text = f.read()
        concatenated_text = concatenated_text.lower()  # 正規化
    show_wordcloud(concatenated_text, 'result/orig_alice_cloud.png')

    text = nltk.word_tokenize(concatenated_text)
    print(f'tokenized: {len(text)}')
    show_wordcloud(text, 'result/tokenized.png')

    tagged = nltk.pos_tag(text)
    nouns_and_verbs = [
        word for word, tag in tagged if tag.startswith(('NN', 'VB'))]
    print(f'tagged: {len(nouns_and_verbs)}')
    show_wordcloud(nouns_and_verbs, 'result/nouns_and_verbs.png')

作成されたWordCloud

上記のスクリプトを実行すると、3つのWordCloudができます。

  1. 元のファイルの全文から作ったWordCloud
  2. word_tokenizeした後の単語から作ったWordCloud
  3. 2から名詞と動詞を取り出して作ったWordCloud

1.元のファイルの全文から作ったWordCloud

f:id:nikkie-ftnext:20200216233810p:plain

2. word_tokenizeした後の単語から作ったWordCloud

f:id:nikkie-ftnext:20200216233826p:plain

n't という単語が現れているところにトークン化の効果が感じられます。

WordCloud.generateには、自然言語のテキスト(a natural text)を渡す必要があるため、単語のリストを半角スペースでjoinして自然言語のテキストのように見せました。

3. 2から名詞と動詞を取り出して作ったWordCloud

f:id:nikkie-ftnext:20200216233836p:plain

WordCloudに含まれる単語数は

  • トークン化で12460語
  • 名詞と動詞を取り出して4106語

と変化しました。
queen などの名詞が2より大きく表示されているところに効果を感じます。
風の又三郎の例のように、見ただけで内容が分かるようにしたいので、工夫の余地はありそうですね。

感想

今週平日は別に締切のあったタスクを進め、土日はOOC 2020の当日スタッフをし、いま、OOCの達成感と疲労の中でたびたび意識を飛ばしつつ、この記事を書いています。
これまでの週に比べると短縮版ではありますが、「『風の又三郎』の例でやったことは日本語に限らず英語でもできるんだ」という気づきがありました。

入門 自然言語処理』を読み進めることで、言語の違いによらない、自然言語処理の抽象的な方法を学べそうという印象です。
土曜日にお別れの会が執り行われたPython2系ですが、『入門 自然言語処理』を通して引き続き思い出作りをしていきます。

課題

  • startswith(('NN', 'VB'))よりも深堀りした名詞・動詞の取り出し方
    • そのために5章をもっと読む
  • 3章のステミングやレマタイズを取り入れる
  • WordCloudの余白は、matplotlibのコードのどこが原因?

『入門 自然言語処理』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

TensorFlowのドキュメントを確認し、tf.py_functionが何をやっているのか理解を深めました

はじめに

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

先週、総括としてライブドアニュース(日本語テキスト)の分類に取り組んだところ、tf.py_function何をやっているのか分からないという課題が見えました。
そこで、今回はドキュメントを参考にtf.py_functionの理解を深めました。
先週から参照している記事「tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita 」のコードの書き換えにも挑戦しています。

先週の週1ブログ駆動開発!

目次

動作環境

先週と同じ環境です。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ # 注:コードを動かすのにGPUは使っていません
$ python -V
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
tensorflow               2.1.0
tensorflow-datasets      2.0.0
matplotlib               3.1.2
numpy                    1.18.1
Janome                   0.3.10

tf.py_functionとは何かの前に:tf.data.Dataset.mapとは何か

ドキュメントはこちら

必須引数map_funcに焦点を当てます1

引数map_funcは関数(オブジェクト)を受け取り、

Maps map_func across the elements of this dataset.
(データセットの要素全体に map_func を適用する)

mapメソッドがやることは以下の2つです:

  • returns a new dataset((map_funcが適用された) 新しいtf.data.Dataset(以下では「データセット」と表記)オブジェクトを返す)
  • same order as they appeared in the input(要素は入力に現れたのと同じ順番)

map_funcの入力

The input signature of map_func is determined by the structure of each element in this dataset.
map_funcの入力シグネチャは現データセットオブジェクトの各要素の構造によって決定される)

シグネチャ(signature)は馴染みがない言葉なのですが、ここでは「パラメーターとその型」と理解しました。2

ドキュメントにはmap_funcの入力を決める例が3つ続きます:

  1. tf.Tensor1つから構成されたデータセットオブジェクト3
  2. 2つのtf.Tensorからなるタプル(tf.Tensor, tf.Tensor)から構成されたデータセットオブジェクト
  3. 辞書から構成されたデータセットオブジェクト

1の例(dataset.map(lambda x: x + 1))ではxtf.Tensor1つに対応します。

In [2]: dataset = tf.data.Dataset.range(1, 6)

In [7]: list(dataset.as_numpy_iterator())
Out[7]: [1, 2, 3, 4, 5]

In [8]: dataset2 = dataset.map(lambda x: x + 1)

In [9]: list(dataset2.as_numpy_iterator())
Out[9]: [2, 3, 4, 5, 6]

今回知ったデータセットオブジェクトの便利なメソッド

(1) 上の例でもlist()と合わせて使いましたが、as_numpy_iteratorメソッドが便利です。
for inでデータセットオブジェクトを扱わなくても値を見ることができます。

(2)from_generatorメソッドでデータセットオブジェクトを作る方法を学びました。

In [22]: elements = [(1, "foo"), (2, "bar"), (3, "baz")]

In [23]: dataset = tf.data.Dataset.from_generator(lambda: elements, (tf.int32, tf.string))

from_generatorメソッドの

  • generator引数にlambda(引数なし)を渡し、
  • output_types引数で型を定義

しています。

今回使った引数なしのlambda(無名関数)の確認

In [32]: f = lambda: elements

In [33]: f()
Out[33]: [(1, 'foo'), (2, 'bar'), (3, 'baz')]  # elememntsが返る

なお、「dataset2 = tf.data.Dataset.from_tensor_slices(elements)でもいいのでは」と試したところ、

ValueError: Can't convert Python sequence with mixed types to Tensor.

(型が混在したシーケンスはtf.Tensorに変換できない)とのことです。
だからfrom_generatorメソッドがあるのですね。納得!

map_funcの出力

入力を見たので続いて出力を見ます。

The value or values returned by map_func determine the structure of each element in the returned dataset.
map_funcの返り値が、mapで返されるデータセットオブジェクトの各要素の型を決める)

ドキュメントには、タプルを返すいくつかの例が紹介されていました。

  1. 関数gの例:要素が全て定数で変換されたdatasetが返される
  2. 関数hの例:リストとnumpy arrayはともにtf.Tensorに変換される
  3. 関数iはネストされたタプルの例

gの例です。

In [36]: def g(x):
    ...:     return tf.constant(37.0), tf.constant(["Foo", "Bar", "Baz"])
    ...:

In [37]: g(1)  # 実引数の値によらず返り値は同じ
Out[37]:
(<tf.Tensor: shape=(), dtype=float32, numpy=37.0>,
 <tf.Tensor: shape=(3,), dtype=string, numpy=array([b'Foo', b'Bar', b'Baz'], dtype=object)>)

In [38]: type(g(1))
Out[38]: tuple

In [39]: result = dataset.map(g)

In [40]: for d in result:  # 値だけの確認であれば list(result.as_numpy_iterator()) が使える
    ...:     print(d)
    ...:
(<tf.Tensor: shape=(), dtype=float32, numpy=37.0>, <tf.Tensor: shape=(3,), dtype=string, numpy=array([b'Foo', b'Bar', b'Baz'], dtype=object)>)
(<tf.Tensor: shape=(), dtype=float32, numpy=37.0>, <tf.Tensor: shape=(3,), dtype=string, numpy=array([b'Foo', b'Bar', b'Baz'], dtype=object)>)
(<tf.Tensor: shape=(), dtype=float32, numpy=37.0>, <tf.Tensor: shape=(3,), dtype=string, numpy=array([b'Foo', b'Bar', b'Baz'], dtype=object)>)

In [41]: result.element_spec
Out[41]:
(TensorSpec(shape=(), dtype=tf.float32, name=None),
 TensorSpec(shape=(3,), dtype=tf.string, name=None))

tf.Tensor2つからなるタプルがデータセットオブジェクトの1つ1つとなっていることが確認できます(40のセル参照)。

セル41で使っているelement_spec属性は便利そうです。

本題:tf.py_functionとは何か

mapのドキュメントを読んでいくと、map_funcPythonのコードを渡す方法について言及されています4

To use Python code inside of the function you have two options

  1. Rely on AutoGraph to convert Python code into an equivalent graph computation(AutoGraphを用いてPythonコードを等価なグラフ計算に変換する)
    • AutoGraphですべてのPythonコードを変換できるとは限らない
  2. Use tf.py_function
    • 任意のPythonコードを書ける
    • 1に比べて一般にパフォーマンスは劣る

mapのドキュメントからリンクされているtf.py_functionを見ていきます。

引数について

  • funcPythonの関数
  • inptf.Tensorのリスト
    • funcinpと型が対応するtf.Tensorのリストを受け付ける
  • Tout:TensorFlowのデータ型のリストorタプル。funcの返り値を示す(Noneが返るときは空のリストとする)
    • funcToutの値に対応する型のtf.Tensorのリストを返す

返り値は

A list of Tensor or a single Tensor which func computes
funcが計算するtf.Tensorのリストまたはtf.Tensor単体)

tf.py_functionの使い方を試す

mapのドキュメントにあるupper_case_fnの例で確認します(書き換えたいQiita記事との読み替えが減るように、関数名の先頭をアンダースコアで始めています)。

In [75]: d = tf.data.Dataset.from_tensor_slices(['hello', 'world'])

In [76]: def _upper_case_fn(t):
    ...:     return t.numpy().decode().upper()
    ...:

In [77]: d = d.map(lambda x: tf.py_function(func=_upper_case_fn, inp=[x], Tout=tf.string))

In [78]: list(d.as_numpy_iterator())
Out[78]: [b'HELLO', b'WORLD']

mapmap_funcに渡す関数オブジェクトをlambda以外で作ってみます。
lambdaを使っていないだけで、関数の処理内容は変わりません(tf.py_functionの適用結果を返す)。

In [79]: def upper_case_func(x):
    ...:     return tf.py_function(func=_upper_case_fn, inp=[x], Tout=tf.string)
    ...:

In [80]: d = tf.data.Dataset.from_tensor_slices(['hello', 'world'])

In [81]: list(d.map(upper_case_func).as_numpy_iterator())
Out[81]: [b'HELLO', b'WORLD']

というわけで、mapメソッドのmap_func引数には

  • tf.py_functionを返すlambda
  • または、tf.py_functionを返す関数

のどちらかを渡せばよさそうです。

自然言語処理tf.py_functionを使ったコードの書き換えに挑む

tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita 中のコードを書き換えてみます。
例えば、janomeを使って原形を取り出す部分は、関数内関数を多用しているために書き手以外にわかりにくいコードという印象です。
もっとわかりやすく書けないか、自分の理解を深めるために書き直します。

書き換え前のコード

def janome_tokenizer():
    token_filters = [
        CompoundNounFilter(),
        POSStopFilter(["記号", "助詞", "助動詞", "名詞,非自立", "名詞,代名詞"]),
        LowerCaseFilter(),
        ExtractAttributeFilter("surface"),
    ]
    tokenizer = Tokenizer()
    analyzer = Analyzer(tokenizer=tokenizer, token_filters=token_filters)
    analyze_function = analyzer.analyze

    def _tokenizer(text_tensor, label):
        text_str = text_tensor.numpy().decode("utf-8")
        tokenized_text = " ".join(list(analyze_function(text_str)))
        return tokenized_text, label

    return _tokenizer


def tokenize_map_fn(tokenizer):
    def _tokenize_map_fn(text_tensor, label):
        return tf.py_function(
            tokenizer, inp=[text_tensor, label], Tout=(tf.string, tf.int64)
        )

    return _tokenize_map_fn


datasets = datasets.map(tokenize_map_fn(janome_tokenizer()))
  • tokenize_map_fn(janome_tokenizer())mapの実引数)で_tokenize_map_fn関数が返る
  • mapで適用する_tokenize_map_fnの引数text_tensor, labelは、データセットの各要素と一致
    • mapで渡す関数はデータセットの構成要素に対応する数の引数を受け取れる
    • 1つの関数でまとめようとすると、引数はtext_tensor, labelとする必要がある(tf.py_functioninp引数への指定で使う)

1つの関数にまとめてみた

def tokenize_map_fn():
    token_filters = [
        CompoundNounFilter(),
        POSStopFilter(["記号", "助詞", "助動詞", "名詞,非自立", "名詞,代名詞"]),
        LowerCaseFilter(),
        ExtractAttributeFilter("surface"),
    ]
    tokenizer = Tokenizer()
    analyzer = Analyzer(tokenizer=tokenizer, token_filters=token_filters)
    analyze_function = analyzer.analyze

    def _tokenizer(text_tensor, label):
        text_str = text_tensor.numpy().decode("utf-8")
        tokenized_text = " ".join(list(analyze_function(text_str)))
        return tokenized_text, label

    def _tokenize_map_fn(text_tensor, label):
        return tf.py_function(
            _tokenizer, inp=[text_tensor, label], Tout=(tf.string, tf.int64),
        )

    return _tokenize_map_fn


all_tokenized_data = all_labeled_data.map(tokenize_map_fn())
  • mapに渡るのはtokenize_map_fn()、すなわち_tokenize_map_fnという関数オブジェクト
    • _tokenize_map_fnはデータセットの要素を引数に受け取れる(def _tokenize_map_fn(text_tensor, label):
    • _tokenize_map_fntf.py_functionを適用したオブジェクトを返す
  • 関数tokenize_map_fnjanomeのAnalyzerを作った後、それを使った関数_tokenizerを用意し、_tokenize_map_fn関数の中に設定している

動作するがWarningが出る例

def tokenize_map_fn(text_tensor, label):
    token_filters = [
        CompoundNounFilter(),
        POSStopFilter(["記号", "助詞", "助動詞", "名詞,非自立", "名詞,代名詞"]),
        LowerCaseFilter(),
        ExtractAttributeFilter("surface"),
    ]
    tokenizer = Tokenizer()
    analyzer = Analyzer(tokenizer=tokenizer, token_filters=token_filters)
    analyze_function = analyzer.analyze

    def _tokenizer(text_tensor, label):
        text_str = text_tensor.numpy().decode("utf-8")
        tokenized_text = " ".join(list(analyze_function(text_str)))
        return tokenized_text, label

    return tf.py_function(
        _tokenizer, inp=[text_tensor, label], Tout=(tf.string, tf.int64),
    )


all_tokenized_data = all_labeled_data.map(tokenize_map_fn)

mapに直接渡せるようにtokenize_map_fntext_tensor, labelを引数で渡せるようにしました。
ですが、以下のようなWarningが大量に出ます。

WARNING:tensorflow:AutoGraph could not transform <bound method Tokenizer.tokenize of <janome.tokenizer.Tokenizer object at 0x13e673860>> and will run it as-is.
WARNING:tensorflow:Entity <bound method CompoundNounFilter.apply of <janome.tokenfilter.CompoundNounFilter object at 0x13e6732b0>> appears to be a generator function. It will not be converted by AutoGraph.

おそらくPythonのコードが全て、tf.py_functionで包まれていないのが原因ではないかと見ています。

感想

maptf.py_functionのドキュメントからだいぶ理解は深まりました。
ですが、実務レベルのコードでの適用方法は、書き換えでwarningに出会ったこともあり、まだ自信がありません(時間切れ感もあります)。

先週感じた課題の中からTensorFlowを今後使っていく上で影響しそうなtf.py_functionを選択しました。
まだまだ課題は残っていますし、新しい調査事項も出てきました。
Qiita記事のencodeの部分、私のコードではset_shapeが見られ、これを削除するとエラーになります。
これが何をするものなのか気になっています。

課題は山積していますが、いったんそれらは脇に置いて(時間が余った時にやるように優先度を下げて)、次週からは自然言語処理の基礎を固めるために、「禁書にすべき」という声もあるあの本に取り組みます。


  1. 今回はtf.py_functionがよく分からない問題に取り組んでおり、tf.py_functionが関わってくるのがmap_funcのためです。

  2. Signature (functions) (シグネチャ (関数)) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN

  3. ドキュメントの上部「Source Datasets」の記載から、データセットオブジェクトをfor inで取り出したときの1つ1つがtf.Tensorと理解しています

  4. ここまでの例で単純なlambdaは渡せることが確認できたので、グラフとして扱う(tf.data traces the function and executes it as a graph)中でPythonのコードを使う方法のようです

tf.data でライブドアニュース(日本語テキスト)の分類に取り組んだところ、自分のコードの課題が見えました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はここまでの総決算として、日本語テキストを分類するタスクにTensorFlowを使って取り組みました。

目次

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ # 注:コードを動かすのにGPUは使っていません
$ python -V
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
tensorflow               2.1.0
tensorflow-datasets      2.0.0
matplotlib               3.1.2
numpy                    1.18.1
Janome                   0.3.10

tensorflow-datasetsのバージョンが2系に上がっていました。

TensorFlowを使ったテキストの扱い

方法はいくつかあると思いますが、前回のブログ(英語テキストの場合)にならって tf.data を使う方法で進めます。

  1. テキストのダウンロード
  2. テキストを tf.data.Dataset に読み込む
  3. テキスト中の単語を数字にエンコードする
  4. データセットを、訓練用、バリデーション用、テスト用のバッチに分割する
  5. モデルの構築・訓練

日本語テキストで必要なステップ:形態素解析

日本語テキストと英語テキストの扱いの違いは形態素解析です。
英語は単語が半角スペースで区切られています(例:This is a pen)が、日本語は一続き(例:これはペンです)です。
単語の区切りを見つける必要があり、その方法の1つが形態素解析です。
形態素解析で単語の区切りを見つけることで、英語のように半角スペース区切りで単語を並べることもできます(これ は ペン です)

形態素解析は2と3の間に必要です。
今回は取り組みの中で触ったことのあるjanomeを使用しました。

今週のネタ探しで参照した記事1janomeを使ったコード例があったのも採用理由です。

1. テキストのダウンロード:ライブドアニュースのデータセット

コードでダウンロードするのではなく、手動でダウンロードします。
https://www.rondhuit.com/download.html からダウンロードした「livedoor ニュースコーパス2を使いました。3

展開方法はこちら。
カレントディレクトリにtextディレクトリができます

$ tar xzf ~/Downloads/ldcc-20140209.tar.gz
$ tree -L 1 text/ 
text/ 
├── CHANGES.txt 
├── README.txt 
├── dokujo-tsushin 
├── it-life-hack 
├── kaden-channel 
├── livedoor-homme 
├── movie-enter 
├── peachy 
├── smax 
├── sports-watch 
└── topic-news

tar --helpを見たところ、fオプションによりアーカイブを指定すればいいことに気づきました(以下に抜粋)。

First option must be a mode specifier:
  -c Create  -r Add/Replace  -t List  -u Update  -x Extract
Common Options:
  -f <filename>  Location of archive
  -z, -j, -J, --lzma  Compress archive with gzip/bzip2/xz/lzma

2. テキストを tf.data.Dataset に読み込む

def labeler(example, index):
    return example, tf.cast(index, tf.int64)


text_datasets = []
label_count = 0

text_dir = os.path.join(os.getcwd(), "text")
text_subdir_names = tf.compat.v1.gfile.ListDirectory(text_dir)
for subdir in text_subdir_names:
    data_dir = os.path.join(text_dir, subdir)
    if os.path.isdir(data_dir):
        print(f"{label_count}: {subdir}")
        text_file_names = tf.compat.v1.gfile.ListDirectory(data_dir)

        text_tensors = []
        for file_name in text_file_names:
            text_file = os.path.join(data_dir, file_name)
            lines_dataset = tf.data.TextLineDataset(text_file)
            # 1行1行がTensorとなるので、ファイルの文章全体をつないでTensorとする
            sentences = [
                line_tensor.numpy().decode("utf-8")
                for line_tensor in lines_dataset
            ]
            concatenated_sentences = " ".join(sentences)
            # subdirのファイルごとにTensorを作り、Datasetとする
            text_tensor = tf.convert_to_tensor(concatenated_sentences)
            text_tensors.append(text_tensor)
        text_dataset = tf.data.Dataset.from_tensor_slices(text_tensors)
        text_dataset = text_dataset.map(lambda ex: labeler(ex, label_count))
        text_datasets.append(text_dataset)
        label_count += 1

textディレクトリ以下のサブディレクトリを取得4し、その中のファイルの一覧も取得、1つ1つのファイルについてTextLineDatasetで読み込みます。
今回の場合、サブディレクトリはクラスを表し、その中に複数のテキストファイルがあります。

ここで苦労したのは、TextLineDatasetは1行ずつ読み込まれるという挙動5

  • やりたいこと:テキストファイル全体に含まれる複数行のテキストを最小単位としてtf.data.Datasetに読み込む
  • TextLineDatasetでは、テキストファイルの1行ごとに分かれて読み込まれる

ひねり出した解決策

  1. 1ファイルをTextLineDatasetとして読み込んだら、すべての行を一度取り出し、つないだtf.Tensor(以下テンソル)とする(1ファイルに対応するテンソル):tf.convert_to_tensor
  2. 1のテンソルをリストに入れる(クラスごとのファイルに対応するテンソルが1つのリストに入る。リストはクラスを表す)
  3. 2のリストからtf.data.Datasetを作る:tf.data.Dataset.from_tensor_slices6

これで前回のブログのホメロスのケースと同じ扱いになったので、concatenateで1つのDatasetにまとめ、shuffleします

all_labeled_data = text_datasets[0]
for labeled_data in text_datasets[1:]:
    all_labeled_data = all_labeled_data.concatenate(labeled_data)

all_labeled_data = all_labeled_data.shuffle(
    BUFFER_SIZE, seed=RANDOM_SEED, reshuffle_each_iteration=False
)

追加:Dataset中のテキストを形態素解析する

Analyzerはこれまでで学んだことが活きました。
tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita (☆)にならって、関数_tokenizerを返します

def janome_tokenizer():
    token_filters = [
        CompoundNounFilter(),
        POSStopFilter(["記号", "助詞", "助動詞", "名詞,非自立", "名詞,代名詞"]),
        LowerCaseFilter(),
        ExtractAttributeFilter("surface"),
    ]
    tokenizer = Tokenizer()
    analyzer = Analyzer(tokenizer=tokenizer, token_filters=token_filters)
    analyze_function = analyzer.analyze

    def _tokenizer(text_tensor, label):
        text_str = text_tensor.numpy().decode("utf-8")
        tokenized_text = " ".join(list(analyze_function(text_str)))
        return tokenized_text, label

    return _tokenizer

mapで適用します。

all_tokenized_data = all_labeled_data.map(tokenize_map_fn(janome_tokenizer()))

3. テキスト中の単語を数字にエンコードする

前回の記事と同じです。

tokenizer = tfds.features.text.Tokenizer()
vocabulary_set = set()
for text_tensor, _ in tqdm(all_tokenized_data):
    tokens = tokenizer.tokenize(text_tensor.numpy().decode("utf-8"))
    vocabulary_set.update(tokens)
vocab_size = len(vocabulary_set)
print(f"vocabulary size: {vocab_size}")

encoder = tfds.features.text.TokenTextEncoder(vocabulary_set)
all_encoded_data = all_tokenized_data.map(encode_map_fn)

4. データセットを、訓練用、バリデーション用、テスト用のバッチに分割する

これも前回の記事のとおりです。
(☆)にならって関数化してもいいかも

output_shapes = tf.compat.v1.data.get_output_shapes(all_encoded_data)
test_data = all_encoded_data.take(TAKE_SIZE)
test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)
train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(
    BUFFER_SIZE, seed=RANDOM_SEED
)

val_data = train_data.take(TAKE_SIZE)
val_data = val_data.padded_batch(BATCH_SIZE, output_shapes)
train_data = train_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE, seed=RANDOM_SEED)

train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)
vocab_size += 1  # padded_batchした際に0という番号を追加している

vocab_size += 1を忘れたところ、学習中にインデックス範囲外のインデックス参照が発生して、落ちました。

5. モデルの構築・訓練

前回の記事で知ったモデルを使います。

model = tf.keras.Sequential(
    [
        tf.keras.layers.Embedding(vocab_size, 64),
        tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64)),
        tf.keras.layers.Dense(64, activation="relu"),
        tf.keras.layers.Dense(64, activation="relu"),
        tf.keras.layers.Dense(9, activation="softmax"),
    ]
)

model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

コードの書き方が悪くて耐えがたい遅さ

Datasetはステップ3のfor文の箇所で初めて評価されるようです(遅延評価という理解)。
ここの処理が遅く、扱うテキスト量の問題と考え、分類に使うテキストを減らしていきました。
最終的にit-life-hackとdokujo-tsushinの2クラス分類になりました。
テキスト数の概要としては1700件程度です(これでも件のfor文に2分半ほどかかります)。

$ ls -l text/dokujo-tsushin/ | wc -l
     872
$ ls -l text/it-life-hack/ | wc -l
     872

訓練用:バリデーション用:テスト用の分割は8:1:1としています。

遅さはどうすれば解消するか

今回のコードには問題があるので、全クラスを対象にできるように今後コードを書き換えます。
今の段階で考えているのは以下のような案です:

  • (☆)の記事のようにtf.py_functionを活用して実行する回数を減らす
    • tf.py_functionが何をやっているのかがいま一つわからず、まだ使いこなせていない課題
    • データの分割でshuffleの回数が減らせそう
  • 形態素解析器をjanomeからmecabに変えてみる
    • Analyzerを都度作っていて遅いというわけではなさそう(関数のローカル変数からグローバル変数に移して比較した)
    • ラッパーは今だとfugashiを使うといいらしい7
  • 1つのスクリプトで全部やろうとせずに中間ファイルを作ってはどうだろう

モデルの評価:2クラス分類させたら

On test data: loss=0.07063097149754564, acc=0.9733333587646484

ITの記事と女性向けの記事は違いが顕著なようで、また、MLPと比べたら多少はいいモデルを使っているので、実運用できそうなモデルができあがりました。
問題は実行時間がだいぶかかる(毎回コーヒー飲める)ことですね。。

f:id:nikkie-ftnext:20200126234424p:plain

感想

動くようにしたコードはGitHubに上げました:

今週は木曜の登壇日曜のリジェクトコン開催で、この取り組みの時間を作れずめちゃくちゃ厳しい週でした。
今回のスクリプトの実行に10分程度かかり、タイムリミットは迫り、「機械学習の取り組みは時間に追われてやるものではないな」と心底感じました。

見るコードによってbytes.decode()の引数がまちまちだなと思ったのですが、これは第1引数encodingのデフォルトが'utf-8'だから書かなくてもいいということでした。8

この取り組みをそろそろモデルの方にシフトしこうと思っていたのですが、今週残した宿題は結構大きい認識なので、もう少しだけ続きに取り組みます(来週の目標は宿題を解消してBERTに触る!)。9


  1. tf.data.Dataset apiでテキスト (自然言語処理) の前処理をする方法をまとめる - Qiita

  2. データセットの説明は今回参照した Mecabとtf.dataを使ってlivedoorニュースコーパスを分かち書きする - Qiita にあります

  3. これを使って過去にこんなこともしています:イベントレポート | #pyhack にて旦那と彼氏の間に何があるのかをword2vecに聞いてみました - nikkie-ftnextの日記

  4. 使ったのがtf.compat.v1.gfile.ListDirectory。これはpathlib.Pathを受け付けなくてがっかりでした

  5. 前回のホメロスの翻訳の分類のように、クラスごとにテキストが1ファイルで1つ1つの文をクラスに分類する際に重宝すると思います。他にはポジティブな感情を表すテキストファイルとネガティブな感情を表すテキストファイルの場合が考えられます

  6. 似たメソッドの tf.data.Dataset.from_tensors と結果を比較して選択しました

  7. ref: Mecabとtf.dataを使ってlivedoorニュースコーパスを分かち書きする - Qiita

  8. ref: https://docs.python.org/ja/3/library/stdtypes.html#bytes.decode

  9. リジェクトコンで知ったnagisaや最近耳にするGiNZAと前処理の新しい潮流も触っておきたいなという思いも出てきています。悩ましいです

告知 | 今週末 1/26 (日) は 「熱意あるPython使いの発表会」です(申込みは1/21(火)いっぱいまで!) #rejectpy

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
PyCon JP 2019 リジェクトコンとして企画した勉強会の最後の告知です。

前回のリジェクトコンエントリ!

勉強会の企画内容

※すでに申し込まれている方、connpassメッセージで送っている入館申請のフォームにご回答ください。

期日

1/26(日)に開催します。
入館申請の都合上、申し込みは1/21(火)いっぱいとします。
迷っている方はぜひお申し込みください!

コンテンツ

後述しますが、リジェクトされたプロポーザルをお持ちの発表者集めは苦戦しました。
そんな中、企画した勉強会に参加申し込みいただけたことがとてもありがたかったので、

  • 発表者の枠をリジェクトされたプロポーザルを持っている方以外にPython関係で発表したい方」へと広げ
  • 参加者の方に実りある時間にしようとアイディア出しの結果、余った時間はYouTubePyCon JP 2019アーカイブを見る(アンケートで評価が高いトークを中心に)

としています。

(これを読んでいるあなた、Python関係の発表ネタがあればドタ参どうぞ。@ftnextまでご連絡ください)

「名は体を表す」というように、「熱意あるPython使いの発表会」となっていると思います。

会場設備

ふだん勉強会に参加して当たり前のように使っているWi-Fi、プロジェクター、電源などですが、運営する側になるとチェックが必要ですね。
今回早稲田大学さんから借りる会議室には、Wi-Fi、プロジェクター、電源が揃っているとのことです(多謝)。
設備リストはチェックリスト化してあると慌てなくて済むと思いました。

企画〜発表者募集して感じた難しさ

「半年近くが経過してのリジェクトコンは難しい」この一言に尽きます(まだ開催してすらいないですけれどね)。

まずpapercallでプロポーザルを公開している方が思っていたより少なかったです(検索の仕方が悪かったのか、数名しか見つかりませんでした)。
発表者が集まらない状況が続く中、発表者がいないと成立しないので、数名お声がけもしたのですが、日程の都合がつかず参加は難しいとのことでした。残念。。

準備中に読んだ『ワンストップ勉強会』に「ツテを頼る」と書いてあって膝を打ったのですが、

勉強会は登壇者をある程度押さえてから開催告知をする

というのが今回の教訓です。

最後に

運営を手伝っていただいている方、発表していただける方、そして参加表明してくださった方に感謝です。
当日まで走り抜けたいと思います。
いよいよ今週末です、楽しみましょう!

f:id:nikkie-ftnext:20191226025055p:plain

tf.dataを使って英文テキストを読み込み、分類するモデルを作るTensorFlowのチュートリアルに取り組みました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週はTensorFlowにおける新しめのデータの扱い方チュートリアルに取り組みました。

チュートリアル「tf.data を使ったテキストの読み込み」

問題設定は、英文テキストの多クラス分類です。

  • データ:ホメロス(ホーマー)の『イリアス』(『イリアッド』)の英語翻訳版3通り
    • cowper, derby, butler
  • 推論:与えられたテキストがどの翻訳版のものか

以下の手順で進みました。

  1. テキストのダウンロード
  2. テキストをデータセットに読み込む
  3. テキスト行を数字にエンコードする
  4. データセットを、テスト用と訓練用のバッチに分割する(ハマりました😢)
  5. モデルの構築・訓練

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ # 注:コードを動かすのにGPUは使っていません
$ python -V  # venvによる仮想環境を使用
Python 3.7.3
$ pip list  # grepを使って抜粋して表示
tensorflow               2.1.0
tensorflow-datasets      1.3.2
matplotlib               3.1.2
  • ipythonのコードでセルの番号が前後するのは、ブログを書くに当たり実行したコードがあるためです
  • 乱数のシードを固定せずに実施しました。再現性を担保する場合、numpyのシードの固定やtensorflow.random.set_seedshuffleメソッドのseed引数を指定することになると考えています(未実施)

そもそも、tf.dataとは

2019年10月に行ったPyCon SingaporeのTensorFlow 2.0 チュートリアルtf.dataについてインプットしていました1

チュートリアルによると、tf.dataの目的は、深層学習に取り組む上でCPUとGPUが交互に遊んでしまう(idle)状態の解決とのことでした。

  • tf.dataを使わない場合
    • CPUでデータの準備をする間、GPUは遊ぶ
    • GPUで学習をする間、CPUは遊ぶ
    • これが交互に繰り返される
  • tf.dataを使う場合
    • CPUでのデータの準備とGPUでの学習を同時に進める(パイプライン

データをtf.data.Datasetとして扱うことで、CPUとGPUが交互に遊ぶ状態は解決されます。
さらにデータの扱い方(インターフェース)が揃うのもメリットだと考えます。

1. テキストのダウンロード

tf.keras.utils.get_fileを用いて、テキスト(『イリアス』の3種の英語翻訳)のURLを指定し、ダウンロードします。

In [4]: DIRECTORY_URL = 'https://storage.googleapis.com/download.tensorflow.org/data/illiad/'

In [5]: FILE_NAMES = ['cowper.txt', 'derby.txt', 'butler.txt']

In [6]: for name in FILE_NAMES:
   ...:     text_dir = tf.keras.utils.get_file(name, origin=DIRECTORY_URL+name)
   ...:
  • 挙動:データがcacheにないため、ダウンロードする
    • 必須引数fnamexxx.txt
    • 必須引数origin:テキストのURL
    • データのcache先はcache_dir引数とcache_subdir引数で指定
    • 今回はどちらもデフォルト値(cache_dir引数がNonecache_subdir引数が'datasets'
    • $HOME/.keras/datasets/2 にダウンロードされる
  • 返り値:ダウンロードされたファイルを指すパス(手元のマシンの中のパス)
    • 上記のcacheにダウンロードされたことを確認できます

このチュートリアルで使われているテキストファイルは、ヘッダ、フッタ、行番号、章のタイトルの削除など、いくつかの典型的な前処理を行ったものです。

との記載から、テキストの前処理はtf.dataを使う前に行うようです(前処理してbutler.txtなどと同じ形式(単語の半角スペース区切り)にする想定)。

$ head -n3 .keras/datasets/butler.txt  # 注:カレントディレクトリはホームディレクトリ
Sing, O goddess, the anger of Achilles son of Peleus, that brought
countless ills upon the Achaeans. Many a brave soul did it send
hurrying down to Hades, and many a hero did it yield a prey to dogs and
$ wc -l .keras/datasets/*.txt
   12131 .keras/datasets/butler.txt  # 24%
   19142 .keras/datasets/cowper.txt  # 39%
   18333 .keras/datasets/derby.txt  # 37%
   49606 total

1では、まだtf.dataは使っていません。

2. テキストをデータセットに読み込む

テキストは、tf.data.TextLineDataset3として読み込みます。

TextLineDataset は、テキストファイルからデータセットを作成するために設計されています。この中では、元のテキストファイルの一行一行がサンプルです。(チュートリアル冒頭より)

In [9]: def labeler(example, index):
   ...:     return example, tf.cast(index, tf.int64)
   ...:

In [10]: labeled_data_sets = []

In [11]: for i, file_name in enumerate(FILE_NAMES):
    ...:     lines_dataset = tf.data.TextLineDataset(os.path.join(parent_dir, file_name))
    ...:     labeled_dataset = lines_dataset.map(lambda ex: labeler(ex, i))
    ...:     labeled_data_sets.append(labeled_dataset)
    ...:

必須引数filenamesを指定してTextLineDatasetを作成します(for文の節の1行目)。

テキストファイルの1行1行についてラベルを付けます(for文の節の2行目)。
ラベルと翻訳の対応は以下の通りです。

ラベル 翻訳
0 cowper
1 derby
2 butler

(リストFILE_NAMESにおけるインデックスがラベルです)

TextLineDataset.mapを使って、lines_datasetの1件1件について、labeler関数を適用します。
labeler関数はラベルの型tf.int64変換します。

labeled_data_sets

  • [0]:cowperによる翻訳テキスト
  • [1]:derbyによる翻訳テキスト
  • [2]:butlerによる翻訳テキストの順に並んでいます。
In [12]: len(labeled_data_sets)
Out[12]: 3

クラスごとに分かれているので、TextLineDataset.concatenateで一続きのDataset(all_labeled_data)になるように繋げます(concatenateした結果でall_labeled_dataを更新して繋げていく)。

In [16]: all_labeled_data = labeled_data_sets[0]

In [17]: for labeled_dataset in labeled_data_sets[1:]:
    ...:     all_labeled_data = all_labeled_data.concatenate(labeled_dataset)
    ...:

繋げた状態だとラベルが0→1→2と順番に並んでいるので、TextLineDataset.shuffleして並べ替えます。

In [23]: all_labeled_data = all_labeled_data.shuffle(
    ...:     BUFFER_SIZE, reshuffle_each_iteration=False)

並べ替えた後、all_labeled_data先頭3件を見ます。

In [18]: for ex in all_labeled_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(), dtype=string, numpy=b'when his true comrade fell at the hands of the Trojans, and he now lies'>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(), dtype=string, numpy=b"On his broad palm, and darkness veil'd his eyes.">, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

TextLineDataset.takeの返り値(Dataset)から取り出したextupleでした。
tupleのインデックス1の要素が、ラベルを表しています(tf.Tensorオブジェクトなるもの)。
ラベルの値(numpy)を見ると、先頭のテキストのラベルが2なので、シャッフルされていますね。

In [23]: type(ex)
Out[23]: tuple

In [26]: ex[0]  # テキストを表す
Out[26]: <tf.Tensor: shape=(), dtype=string, numpy=b'All-bounteous Mercury clandestine there'>

In [27]: ex[1]  # ラベルを表す
Out[27]: <tf.Tensor: shape=(), dtype=int64, numpy=0>

ここでは、テキスト行にクラスを表すラベルを付け、1つのDatasetとしてまとめることをしました。

3. テキスト行を数字にエンコードする

機械学習モデルが扱うのは単語ではなくて数字であるため、文字列は数字のリストに変換する必要があります。

ロイター通信のデータセットのように4、整数の並びにするということですね。
以下の2ステップでした。

  1. ボキャブラリーの構築
  2. テキストのエンコード

ボキャブラリーの構築

tfds.features.text.Tokenizerを使って、テキストに含まれる単語を取り出します。

In [28]: sample_tokenizer = tfds.features.text.Tokenizer()

In [29]: ex[0].numpy()
Out[29]: b'All-bounteous Mercury clandestine there'

In [30]: sample_tokenizer.tokenize(ex[0].numpy())
Out[30]: ['All', 'bounteous', 'Mercury', 'clandestine', 'there']

TextLineDatasetの1件1件(タプル)では、インデックス0がテキストを表します。
テキスト自体はnumpy()メソッドで取得できます。
これをTokenizer.tokenizeに与えると、単語を格納したリストが返ります。

全てのテキストについて上記の処理を繰り返し、入手した単語をボキャブラリーとします。
tokenizeの返り値をセットに入れることで、重複を除きます。

In [32]: tokenizer = tfds.features.text.Tokenizer()

In [33]: vocabulary_set = set()

In [34]: for text_tensor, _ in all_labeled_data:
    ...:     some_tokens = tokenizer.tokenize(text_tensor.numpy())
    ...:     vocabulary_set.update(some_tokens)
    ...:

In [35]: vocab_size = len(vocabulary_set)

In [36]: vocab_size
Out[36]: 17178

日本語テキストを扱う場合は、Tokenizer初期化時にalphanum_only引数をFalseに指定することになりそうです。

テキストのエンコード

vocabulary_settfds.features.text.TokenTextEncoder に渡してエンコーダーを作成します。

In [37]: encoder = tfds.features.text.TokenTextEncoder(vocabulary_set)

エンコーダTokenTextEncoderは、

  • 必須引数vocab_listを渡して初期化
  • encodeメソッドにテキストを渡すと、変換した整数のリスト(a list of integers)を返す

これを使って、all_labeled_dataの各テキストを、整数が並んだリストに置き換えます。

In [43]: def encode(text_tensor, label):
    ...:     encoded_text = encoder.encode(text_tensor.numpy())
    ...:     return encoded_text, label
    ...:

In [44]: def encode_map_fn(text, label):
    ...:     encoded_text, label = tf.py_function(
    ...:         encode, inp=[text, label], Tout=(tf.int64, tf.int64))
    ...:     encoded_text.set_shape([None])
    ...:     label.set_shape([])
    ...:     return encoded_text, label
    ...:

In [45]: all_encoded_data = all_labeled_data.map(encode_map_fn)

tf.py_functionについては今回掘り下げられていないのですが、ドキュメントの中に

tf.function が呼び出されるたびに Python のコードを実行したいのであれば、tf.py_function がぴったりです。

という記載を見つけました5
all_labeled_dataの1件1件にmapメソッドを介してencode関数を適用するために使っているようです。
set_shapeのあたりは言語化できるレベルで理解できていないのですが、tf.functionとの関係の中でとらえるとよさそうです。

エンコードしたテキストの確認

In [40]: for ex in all_encoded_data.take(3):
    ...:     print(ex)
    ...:
(<tf.Tensor: shape=(15,), dtype=int64, numpy=
array([ 7516,  2349, 11186, 13393,  4276,  1931,  1495,  8889, 16159,
        1495,  6530,   423,  5728,  1429, 12401])>, <tf.Tensor: shape=(), dtype=int64, numpy=2>)
(<tf.Tensor: shape=(10,), dtype=int64, numpy=
array([ 4287,  2349,   384,  3282,   423,  8741, 14597,  2713,  2349,
        5192])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)
(<tf.Tensor: shape=(5,), dtype=int64, numpy=array([14260, 10012,  1468,   907,  9474])>, <tf.Tensor: shape=(), dtype=int64, numpy=0>)

4. データセットを、テスト用と訓練用のバッチに分割する

小さなテスト用データセットと、より大きな訓練用セットを作成します。

  • テスト用データはTextLineDataset.takeで先頭から取り出す
  • 訓練用データはTextLineDataset.skipで、先頭からテスト用データを避けて取り出す

訓練用データとテスト用データを「バッチ化」します。
先ほど見たように、テキストを変換した整数のリストの長さは異なります(元のテキストの単語数が異なるため)。
バッチ化にあたり、短いリストには0を埋めて、同じサイズにします。

ハマった:TextLineDataset.padded_batch

チュートリアルのコードのとおりに実行すると、必須引数2つが指定されていないためにエラーが発生します。

In [41]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [42]: train_data = train_data.padded_batch(BATCH_SIZE)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-42-8afff0f1d82a> in <module>
----> 1 train_data = train_data.padded_batch(BATCH_SIZE)

TypeError: padded_batch() missing 1 required positional argument: 'padded_shapes'

padded_shapes引数について

A nested structure of tf.TensorShape or tf.int64 vector tensor-like objects representing the shape to which the respective component of each input element should be padded prior to batching. Any unknown dimensions (e.g. tf.compat.v1.Dimension(None) in a tf.TensorShape or-1 in a tensor-like object) will be padded to the maximum size of that dimension in each batch.

解決の参考になったのは、以下のIssueのコード:

output_shapes_train = tf.compat.v1.data.get_output_shapes(ds_train)

tf.compat.v1.data.get_output_shapesall_encoded_dataのoutput shapesを取得し、padded_batchpadded_shapes引数に渡します。

In [45]: output_shapes = tf.compat.v1.data.get_output_shapes(all_encoded_data)

In [46]: output_shapes
Out[46]: (TensorShape([None]), TensorShape([]))

In [47]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

train_dataのバッチのうち、先頭1件を確認します。

In [52]: for sample_text, sample_label in train_data.take(1):
    ...:     print(sample_text)
    ...:     print('-' * 40)
    ...:     print(sample_label)
    ...:
tf.Tensor(
[[ 9310   722 10783 ...     0     0     0]
 [ 5766 12211 13137 ...     0     0     0]
 [ 2584 10652  4521 ...     0     0     0]
 ...
 [ 7497  8881  8781 ...     0     0     0]
 [ 6450  7516  1495 ...     0     0     0]
 [ 1785  2713 15027 ...     0     0     0]], shape=(64, 17), dtype=int64)
----------------------------------------
tf.Tensor(
[1 0 0 0 2 2 2 1 0 0 0 0 2 2 0 0 0 0 2 1 1 0 1 0 1 1 1 0 1 1 1 1 1 2 2 0 2
 0 1 0 2 2 2 2 1 2 1 0 2 1 0 0 0 0 0 2 1 1 0 1 0 2 0 0], shape=(64,), dtype=int64)

sample_textの整数の並びは終わりが0となって、長さが揃っていますね。
BATCH_SIZEでまとまっています(これがバッチ化という認識です)。

テスト用データも同様にバッチ化します。

In [55]: test_data = all_encoded_data.take(TAKE_SIZE)

In [56]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

0という新しい番号で埋めたので、ボキャブラリーサイズを1増やします。

In [57]: vocab_size += 1

5. モデルの構築・訓練

チュートリアルではテスト用データを訓練中のバリデーションに使っているのが気になったので、テスト用・バリデーション用・訓練用に分けました。

In [82]: test_data = all_encoded_data.take(TAKE_SIZE)

In [83]: test_data = test_data.padded_batch(BATCH_SIZE, output_shapes)

In [84]: train_data = all_encoded_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [85]: val_data = train_data.take(TAKE_SIZE)

In [86]: val_data = val_data.padded_batch(BATCH_SIZE, output_shapes)

In [87]: train_data = train_data.skip(TAKE_SIZE).shuffle(BUFFER_SIZE)

In [88]: train_data = train_data.padded_batch(BATCH_SIZE, output_shapes)

チュートリアルに沿ったモデルとします。

In [89]: model = tf.keras.Sequential(
    ...:     [
    ...:         tf.keras.layers.Embedding(vocab_size, 64),
    ...:         tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(64)),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(64, activation='relu'),
    ...:         tf.keras.layers.Dense(3, activation='softmax')
    ...:     ]
    ...: )
  • Embedding layer
  • LSTM
  • Dense 1
  • Dense 2
  • Dense 3(softmaxをとって、3クラスのどれかに分類)
In [90]: model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
    ...:  metrics=['accuracy'])

In [91]: history = model.fit(train_data, epochs=3, validation_data=val_data)
Epoch 1/3
    619/Unknown - 21s 33ms/step - loss: 0.5528 - accuracy: 0.72992020-01-19 13:15:39.947590: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
2020-01-19 13:15:48.626987: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 29s 47ms/step - loss: 0.5528 - accuracy: 0.7299 - val_loss: 0.3283 - val_accuracy: 0.8696
Epoch 2/3
617/619 [============================>.] - ETA: 0s - loss: 0.3209 - accuracy: 0.85992020-01-19 13:16:15.284238: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.3207 - accuracy: 0.8599 - val_loss: 0.2153 - val_accuracy: 0.9172
Epoch 3/3
615/619 [============================>.] - ETA: 0s - loss: 0.2440 - accuracy: 0.89502020-01-19 13:16:41.786026: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
619/619 [==============================] - 27s 43ms/step - loss: 0.2438 - accuracy: 0.8951 - val_loss: 0.1813 - val_accuracy: 0.9298

テスト用データに対しての正解率は84.0%でした(チュートリアル通り)

In [92]: eval_loss, eval_acc = model.evaluate(test_data)
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.83962020-01-19 13:21:13.484423: W tensorflow/core/common_runtime/base_collective_executor.cc:217] BaseCollectiveExecutor::StartAbort Out of range: End of sequence
     [[{{node IteratorGetNext}}]]
     79/Unknown - 2s 23ms/step - loss: 0.3762 - accuracy: 0.8396
In [93]: f'Eval loss: {eval_loss}, Eval accuracy: {eval_acc}'
Out[93]: 'Eval loss: 0.37618066295038294, Eval accuracy: 0.8396000266075134'

過去に作った plot_accuracy 関数6を使って、学習状況を可視化します。

f:id:nikkie-ftnext:20200119141000p:plain

訓練用データにおける正解率がバリデーション用データにおける正解率を下回っているため、学習不足という印象です。
epochsはもう少し増やせそうです。

感想

初めてしっかり触ったtf.data
この取り組みの中ではこれまで、tf.keras.preprocessing.text.Tokenizerでテキストをnumpyのarrayに変換して扱いました。
tf.dataにはTensortf.functionなど関係するものが多くまだ掴みきれていないのですが、データの扱いが揃うのは便利そうなので、引き続き触っていきたいです(読めるコードも増えそうですし)。
kerasのTokenizerとtfdsのTokenizer, TokenTextEncoderは役割がかぶっている印象ですが、どちらを使ったほうがいいという指針があるのか、私、気になります!

学んだこと

  • TensorFlowだけの利用で英語のテキストを数値にエンコードする方法
  • テキストを扱うときに有効と聞くEmbeddingやLSTMのlayerの使い方(の初めの一歩)

Future Works(今後のネタ帳)


  1. スライド中の図はドキュメントから更新されたようです。ドキュメント内の説明はこちら:Better performance with the tf.data API  |  TensorFlow Core

  2. get_fileのドキュメントより「By default the file at the url origin is downloaded to the cache_dir ~/.keras, placed in the cache_subdir datasets, and given the filename fname.」

  3. issubclass(tf.data.TextLineDataset, tf.data.Dataset)がTrueなので、tf.data.TextLineDatasettf.data.Datasetのサブクラスです(tf.data.Datasetと同様にパイプラインでデータを扱えるということです)ref:ドキュメント 組み込み関数issubclass

  4. [前処理編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita

  5. ref: tf.function で性能アップ  |  TensorFlow Core

  6. ref: [モデル構築編] ロイター通信のデータセットを用いて、ニュースをトピックに分類するモデル(MLP)をkerasで作る(TensorFlow 2系) - Qiita

「Janome ではじめるテキストマイニング」の中のWordCloudのチュートリアルに取り組み、janomeを全然使いこなせていなかったと思い知りました

はじめに

頑張れば、何かがあるって、信じてる。nikkieです。
2019年12月末から自然言語処理のネタで毎週1本ブログを書いています。
今週は、1本目のブログで作ったWordCloudに使っているjanomeについて、チュートリアルに取り組んでの学びをまとめます。

前回までのnikkieとjanome

過去にjanome形態素解析に使って、CfPや自分のブログのWordCloudを作っています。

その中でjanomeのドキュメントを見たところ、形態素解析以外に前処理・後処理もできるらしいと分かり1janomeチュートリアルをブログ駆動開発のネタリストに入れていました。

今回チュートリアルに取り組んでみて、「わたし、janomeのこと、なんにも分かってなかったんだ。。」という心境です。

janomeチュートリアル

取り組んだのは「Janome ではじめるテキストマイニング」です。

Google Colaboratoryでもできますが、今回は手元のマシンで実行しました。
hands-onフォルダのnotebookのうち、01〜03を写経しました。
宮沢賢治の『風の又三郎』のWordCloudを作ります。

f:id:nikkie-ftnext:20200112155643p:plain

動作環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V  # venvによる仮想環境を利用
Python 3.8.1
$ janome --version
janome 0.3.10
$ pip list | grep wordcloud
wordcloud        1.6.0
$ pip list | grep matplotlib
matplotlib       3.1.2

フォルダ配置

Pythonスクリプトもカレントディレクトリに置いていますが、配置から省略しています。

.
├── data
│   └── kazeno_matasaburo_utf8.txt
├── env  # 仮想環境
├── ipagp.ttf
├── stop_words.txt  # ストップワードを列挙したファイル
├── text_sentences.txt
└── udic.csv  # ユーザ定義辞書
  • WordCloudに日本語を表示するためのIPAフォントは、こちらからダウンロード:IPAフォントのダウンロード
  • テキストデータの入手:wget https://raw.githubusercontent.com/mocobeta/janome-tutorial/master/hands-on/data/kazeno_matasaburo_utf8.txt -O data/kazeno_matasaburo_utf8.txt

(目次) チュートリアルに取り組んでの学び

以下の順で学んだことや気づいたことを記していきます。

  1. コマンドラインからjanomeコマンドが使えた
  2. Tokenizerを使った形態素解析(と分かち書き
  3. Pythonicなファイル操作
  4. Analyzerを使った形態素解析 + 前処理・後処理
  5. WordCloud(小ネタ)
  6. チュートリアルのコードで気になる点

janomeコマンド

初めて知ったのですが、pip install janomeすると、janomeコマンドが使えるようになります。
オプション無しでjanomeコマンドを実行した後は、形態素解析したい一文を入力してEnterを押すと結果が出ます。

$ janome
すもももももももものうち
すもも   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
うち  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

パイプを使ってjanomeコマンドを呼び出すこともできます。

$ echo "テキストマイニングを始めよう" | janome
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

janomeコマンドの主なオプションは次の通り(janome -hで確認できます)。

  • --udic:ユーザ定義辞書ファイルのパスの指定
  • --udic-type:ユーザ定義辞書のタイプの指定(デフォルトはipadic。simpledicも取れる)
  • -g:「ラティスグラフ」のpngファイルを出力(要graphviz2

ユーザ定義辞書を使って形態素解析し、ラティスグラフを出力する例

In [23]: !echo '美ら海図面コンクール' | janome --udic udic.csv --udic-type simpledic -g
美ら海   カスタム名詞,*,*,*,*,*,美ら海,チュラウミ,チュラウミ
図面  名詞,一般,*,*,*,*,図面,ズメン,ズメン
コンクール 名詞,一般,*,*,*,*,コンクール,コンクール,コンクール
Graph was successfully output to lattice.gv.png
$ cat udic.csv
美ら海,カスタム名詞,チュラウミ

f:id:nikkie-ftnext:20200112155710p:plain

Tokenizer

コマンドラインからの使い方を知った後はAPIについて。
使ったことのあるTokenizerですが、ドキュメントをたどりつつ見ていきます。

In [1]: from janome.tokenizer import Tokenizer

In [2]: t = Tokenizer()

In [9]: for token in t.tokenize('テキストマイニングを始めよう'):
   ...:     print(token)
   ...:
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

Tokenizer.tokenizeですが、デフォルトではstream引数とwakati引数がどちらもFalseなので、Tokenのリスト3が返ります。
(上記のコードは、t.tokenize('テキストマイニングを始めよう')の返すリストの各要素について処理をしています)

では、Tokenとは何でしょうか。
手を動かした感触としては、「(解析された)形態素と辞書に登録された情報を表すもの」になるかと思います(ドキュメント

In [10]: token = t.tokenize('テキストマイニングを始めよう')[3]  # 「始めよ」を取り出す
In [11]: token.base_form, token.infl_form, token.infl_type, token.part_of_speech
    ...: , token.phonetic, token.reading, token.surface
Out[11]: ('始める', '未然ウ接続', '一段', '動詞,自立,*,*', 'ハジメヨ', 'ハジメヨ', '始めよ')

print(token)をしたとき、以下の対応で表示されています4

始めよ 動詞,自立,,,一段,未然ウ接続,始める,ハジメヨ,ハジメ

  • token.surface表層形 '始めよ'
  • token.part_of_speech:品詞 '動詞,自立,*,*'
  • token.infl_type:活用型 '一段'
  • token.infl_form:活用形 '未然ウ接続'
  • token.base_form基本形 '始める'
  • token.reading:読み 'ハジメヨ'
  • token.phonetic:発音 'ハジメヨ'

基本形の取り出し方

In [17]: for token in t.tokenize('テキストマイニングを始めました'):
    ...:     print(token.base_form)
    ...:
テキスト
マイニング
を
始める
ます
た

表層系の取り出し方

In [16]: for token in t.tokenize('テキストマイニングを始めよう'):
    ...:     print(token.surface)
    ...:
テキスト
マイニング
を
始めよ
う

またtokenizeメソッドのwakati引数をTrueに指定5することで、表層形(str型)のリストが返りtoken.surfaceというアクセスは不要になります。
すごくシンプルに書けるので、今回知ってよかったです。

In [2]: t.tokenize('テキストマイニングを始めよう', wakati=True)
Out[2]: ['テキスト', 'マイニング', 'を', '始めよ', 'う']

補足ですが、Tokenizerを作るときに、ユーザ定義辞書を指定できます。

In [19]: t = Tokenizer('udic.csv', udic_type='simpledic')

In [20]: for token in t.tokenize('美ら海図面コンクール'):
    ...:     print(token)
    ...:
美ら海   カスタム名詞,*,*,*,*,*,美ら海,チュラウミ,チュラウミ
図面  名詞,一般,*,*,*,*,図面,ズメン,ズメン
コンクール 名詞,一般,*,*,*,*,コンクール,コンクール,コンクール

Pythonicなファイルの扱い

少しjanomeから離れてPythonの話を。

組み込み関数openのencoding引数

チュートリアルではencoding引数を'utf8'と指定しています。
デフォルト値はNoneで、指定しないとプラットフォームに依存するそうです(ドキュメント)。
チュートリアルの進行でエラーを出さないように指定しているのだと思います。
なおこの引数は、ファイルをテキストモードで扱う際に有効とのことです(バイナリでは無効と理解)。

openしたファイルの扱い

1行ずつ取り出す方法、for inでいけるんですね!

$ cat text_sentences.txt
すもももももももものうち
テキストマイニングを始めよう
In [4]: with open('text_sentences.txt', encoding='utf8') as f:
   ...:     for line in f:
   ...:         print(repr(line))
   ...:
'すもももももももものうち\n'
'テキストマイニングを始めよう\n'

右端の改行コードはstrip()などで削除できます。

f.read()したものをsplit()して」と処理していったら、改行以外の空白文字(全角スペース)でも分割してしまって想定外の出力になりました(ドキュメント)。
f.read()したあとは、split('\n')ですね6

改行文字を形態素解析しても問題ない!

janomeの話題に戻ります。
上記のfor inで1行取り出す書き方も衝撃でしたが、それを上回る衝撃が。
それはf.read()した文字列(改行文字含む)をtokenizeに渡すコード!

In [7]: with open('text_sentences.txt', encoding='utf8') as f:
   ...:     text = f.read()
   ...:     for token in t.tokenize(text):
   ...:         print(token)
   ...:
すもも   名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
も 助詞,係助詞,*,*,*,*,も,モ,モ
もも  名詞,一般,*,*,*,*,もも,モモ,モモ
の 助詞,連体化,*,*,*,*,の,ノ,ノ
うち  名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ

    記号,空白,*,*,*,*,
,*,*
テキスト    名詞,一般,*,*,*,*,テキスト,テキスト,テキスト
マイニング 名詞,サ変接続,*,*,*,*,マイニング,マイニング,マイニング
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
始めよ   動詞,自立,*,*,一段,未然ウ接続,始める,ハジメヨ,ハジメヨ
う 助動詞,*,*,*,不変化型,基本形,う,ウ,ウ

改行文字の形態素解析結果はsurfaceとbase_formが'\n'であるために、改行されていますね。
後述のAnalyzerの話題になりますが、形態素解析結果の中から品詞を指定して取り出せばいいので、改行コードを含むf.read()tokenizeに放り込めると気づきました。

Analyzer

いよいよ、nikkieの知らないjanomeの世界、Analyzerです!

Analyzerを作る

Tokenizerと2種類のフィルタのリストを渡して作ります(ドキュメント)。

  • token_filters形態素レベルのフィルタのリスト
  • char_filters:文字レベルのフィルタのリスト

チュートリアルではtoken_filtersを中心に扱いました。
なお、フィルタのリストを空にすれば、渡したTokenizerそのものです。
Analyzerを作るということは、Tokenizerに渡す文字列の加工や、解析結果のフィルタリングを設定しているので、sklearnのパイプラインに近いなと思いました。

Analyzeranalyzeメソッドを持ち、「形態素解析 + 設定した処理の結果」をgeneratorで返します7

チュートリアルのお題:指定した品詞だけを取り出し、ストップワードがあれば除き、基本形で取り出すフィルタ

Analyzerを使って処理を追加し、風の又三郎と分かるようなWordCloudを作っていきます。

  • 指定した品詞を取り出す:POSKeepFilter
    • 使い方:POSKeepFilter(['名詞', '動詞', 'カスタム名詞'])
    • 品詞が、名詞・動詞・カスタム名詞のいずれかで始まる形態素を取り出す
    • ユーザ定義辞書で使っている「カスタム名詞」の指定も必要
  • 基本形で取り出す:ExtractAttributeFilter
    • 使い方:ExtractAttributeFilter('base_form')
    • フィルタのリストの最後に置く
  • 含まれる数を求める:TokenCountFilter
    • Analyzerの紹介で何回も登場(WordCloud作成には寄与しない)
    • 基本形を登場回数の多いものから順に取り出す:TokenCountFilter('base_form', sorted=True)
      • f.read()したテキスト全体を渡せば、数行のコードで登場回数の多い単語が分かります(結果から意味を導くには品詞での抽出も必要かもしれません)
    • フィルタのリストの最後に置く

ストップワードがあれば除く:フィルタの自作

自作のフィルタはTokenFilterを継承することで作れます。
継承したクラスでは

  • イニシャライザ
  • applyメソッド

を実装しました。

class StopWordFilter(TokenFilter):
    def __init__(self, word_list=None, word_list_file=''):
        if word_list is None:
            word_list = []
        self._word_list = []
        self._word_list += word_list
        if word_list_file:
            with open(word_list_file, encoding='utf8') as f:
                words_in_file = [word.strip() for word in f]
                self._word_list += words_in_file

    def apply(self, tokens):
        for token in tokens:
            if token.base_form not in self._word_list:
                # 基本形がストップワードのリストに含まれない場合に返す
                yield token

WordCloudを作り、ストップワードを追加するということを何度も繰り返してできたのが、冒頭で見せたWordCloudです。

上記で使わなかったフィルタたち

他にも便利そうなものがありました。

続く2つはchar_filtersに指定するフィルタです。

WordCloud

英文は半角スペースで区切られているので、ファイルをopenしてreadしたもの(str, 改行文字含む)をWordCloud.generateに渡すことができます。

The input “text” is expected to be a natural text.

日本語の場合は、形態素解析、その結果の単語を英文にならって、半角スペースで区切ってファイルに保存します。
あとはそのファイルをopenしてreadしてWordCloud.generateに渡せばいいわけですね。

IMO: 引数のデフォルト値に空のリストというのが、私、気になります

janomeチュートリアル、ファイルのPythonicな扱い方を知れて素晴らしい内容だったのですが、1つ気になることが。

def wc(file, pos=[]):
    with open(file, encoding='utf8') as f:
        """
        ここに処理を書く。
        戻り値として,(単語, 出現回数) のタプルのリストを返す。
        """

それは、pos=[]と、引数のデフォルト値に空のリストを指定している点。
チュートリアルで作る関数では、pos引数の指す値を加工しないので副作用はなさそうですが、予期せぬバグの原因になるかと以下に書き直して取り組んでいました。

def wc(file, pos=None):
    if pos is None:
        pos = []
    with open(file, encoding='utf8') as f:
        """
        ここに処理を書く。
        戻り値として,(単語, 出現回数) のタプルのリストを返す。
        """

「引数のデフォルト値に空のリスト」を避けたほうがいい理由は、色々なところで言われていますが、最近読んだものを紹介。
The 10 Most Common Mistakes That Python Developers Make | Toptal

the default value for a function argument is only evaluated once, at the time that the function is defined. (Common Mistake #1: Misusing expressions as defaults for function arguments より)

感想

検索して見つけたスニペットの切り貼りレベルでこれまで使っていたjanomeですが、チュートリアルを一通りやったところ、だいぶ全体感が掴めました。
今回得た知識を元に活用できそうです。
今週は"木こりの斧を研ぐ"経験でした。
手を動かしたコードはgistに上げています。
「Janome ではじめるテキストマイニング」02, 03の写経(WordCloud) ref:https://github.com/mocobeta/janome-tutorial · GitHub

janomemecabの違いは、前者がPure Python9であることくらいしか認識していなかったのですが、janomeAnalyzerは非常に魅力的に思います。
mecab-python3mecabのラッパーで、janomeAnalyzerに相当するものはmecabには見つけられていません(ご存じの方いたら教えていただけると嬉しいです)。

今週を振り返ると、推しの生誕祭祝いを優先しすぎました。
janomeチュートリアルを終えた後、自分のコードの書き直しまでやりたかったのですが、時間が足りず積み残しです。
振り返って思うのは、

  • ブログ駆動開発は最優先なので、毎週スタートダッシュをかける
  • ブログ駆動開発外のネタの差し込みは、すごく負荷が上がるので、計画的に(やりたいことリストの先頭には入れない。ブログ駆動開発の次くらいで)

です。
引き続き週一自然言語処理ネタで手を動かしていきます。

Future Works(今後のネタ帳)


  1. https://mocobeta.github.io/slides-html/janome-tutorial/tutorial-slides.html#(16) この前後のスライドです

  2. brew install graphvizしました

  3. stream引数をTrueにすると、返り値がgeneratorになります

  4. __str__メソッドの実装を確認しました ref: https://github.com/mocobeta/janome/blob/master/janome/tokenizer.py#L131

  5. ドキュメント中では「wakati (‘分かち書き’) mode」と呼んでいました

  6. splitlines()という道もありそうです。写経では衝撃的だったfor inを多用しました

  7. 中でTokenizertokenize()が呼ばれていました ref: https://github.com/mocobeta/janome/blob/master/janome/analyzer.py#L101

  8. 品詞にどんな種類があるのかは一度見ておくとよさそうです。ドキュメント中にあるのかな(宿題)

  9. 中の実装を一度見てみたいです(janomeコマンドの実装など)