nikkie-ftnextの日記

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

「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コマンドの実装など)