WordCloudの文字の色を明示的に指定する

相変わらずWordCloudの話です。(今回くらいで一旦止めます。)

今回は文字の色を個別に指定します。
前回の記事のコードの抜粋が以下ですが、
colormap 引数で どのような色を使うのかはざっくりと指定できます。(実際の色は単語ごとにランダムに決まる)
これを、「この単語は何色」って具体的に指定しようというのが今回の記事です。


# TF-IDFで Word Cloud作成
wc_1 = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",
        width=600,
        height=300,
        prefer_horizontal=1,
        background_color='white',
        include_numbers=True,
        colormap='tab20',
    ).generate_from_frequencies(tfidf_dict)

さて、方法ですが、WordCloudのインスタンスを作る時に、color_func って引数で、色を返す関数を渡すことで実装できます。
ドキュメントの該当ページから引用すると、
次のように word とか font_size とかを受け取る関数を用意しておけばいいようです。

color_funccallable, default=None
Callable with parameters word, font_size, position, orientation, font_path, random_state that returns a PIL color for each word. Overwrites “colormap”.

Using custom colors というページに例もあるので、参考にしながらやってみましょう。

さて、どうやって色をつけるかですが、今回は「品詞」で色を塗り分けることにしてみました。ただ、やってみたら名詞のシェアが高すぎてほぼ全体が同じ色になったので、名詞だけは品詞細分類1までみてわけます。

コードですが、前回の記事で準備したデータを使うので、 形態素分析済みで、TF-IDFも計算できているデータはすでにあるものとします。

まず、単語から品詞を返す関数を作ります。
注: 品詞は本当は文脈にも依存するので、形態素解析したときに取得して保存しておくべきものです。ただ、今回そこは本質でないので、一単語渡したらもう一回MeCabにかけて品詞を返す関数を作りました。
もともと1単語を渡す想定ですが、それがさらに複数の単語にわけられちゃったら1個目の品詞を返します。あまりいい関数ではないですね。
あと、処理の効率化のために関数自体はメモ化しておきます。


from functools import lru_cache


@lru_cache(maxsize=None)
def get_pos(word):
    parsed_lines = tagger.parse(word).split("\n")[:-2]
    features = [l.split('\t')[1] for l in parsed_lines]
    pos = [f.split(',')[0] for f in features]
    pos1 = [f.split(',')[1] for f in features]

    # 名詞の場合は、 品詞細分類1まで返す
    if pos[0] == "名詞":
        return f"{pos[0]}-{pos1[0]}"

    # 名詞以外の場合は 品詞のみ返す
    else:
        return pos[0]

(tagger などは前回の記事のコードの中でインスタンス化しているのでこれだけ実行しても動かないので注意してください)

さて、単語を品詞に変換する関数が得られたので、これを使って、単語に対して品詞に応じた色を戻す関数を作ります。


import matplotlib.cm as cm
import matplotlib.colors as mcolors

# 品詞ごとに整数値を返す辞書を作る
pos_color_index_dict = {}
# カラーマップ指定
cmap = cm.get_cmap("tab20")


# これが単語ごとに色を戻す関数
def pos_color_func(word, font_size, position, orientation, random_state=None,
                   **kwargs):

    # 品詞取得
    pos = get_pos(word)

    # 初登場の品詞の場合は辞書に追加
    if pos not in pos_color_index_dict:
        pos_color_index_dict[pos] = len(pos_color_index_dict)

    color_index = pos_color_index_dict[pos]

    # カラーマーップでrgbに変換
    rgb = cmap(color_index)
    return mcolors.rgb2hex(rgb)

**kwargs が吸収してくれるので、実は font_size とか position とか関数中で使わない変数は引数にも準備しなくていいのですが、
いちおうドキュメントの例を参考に近い形で書いてみました。

これを使って、ワードクラウドを作ります。


# TF-IDFで Word Cloud作成
wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",
        width=600,
        height=300,
        color_func=pos_color_func,
        prefer_horizontal=1,
        background_color='white',
        include_numbers=True,
    ).generate_from_frequencies(tfidf_dict)
wc.to_image()

変わったのは color_func にさっき作った関数を渡しているのと、 colormap の指定がなくなりました。(指定しても上書きされるので無視されます)
そして出力がこちら。

同じ品詞のものが同色に塗られていますね。

どの品詞が何色なのか、凡例?というか色見本も作ってみました。
たまたま対象のテキストで登場した品詞だけ現れます。


fig = plt.figure(figsize=(6, 8), facecolor="w")
ax = fig.add_subplot(111)
ax.barh(
    range(len(pos_color_index_dict)),
    [5] * len(pos_color_index_dict),
    color=[mcolors.rgb2hex(cmap(i)) for i in range(len(pos_color_index_dict))]
)

ax.set_xticks([])
ax.set_yticks(range(len(pos_color_index_dict)))
ax.set_yticklabels(list(pos_color_index_dict.keys()))
plt.show()

単語の頻度データからWord Cloudを作成する方法

今回も Word Cloud の話です。
前回は見た目の設定を変える話でしたが、今回は読み込ませるデータの話になります。

さて、Word Cloudを作るとき、
generate (もしくは generate_from_text) 関数に、 テキストを渡し、その中の出現回数でサイズを決めました。

しかし実際には、出現回数ではなくもっと別の割合でサイズを調整したいことがあります。
例えば、TF-IDFで重みをつけたい場合とか、トピックモデルのトピック別出現確率のようなもともと割合で与えられたデータを使いたい場合などです。

このような時は “単語: 頻度” の辞書(dict)を作成し、
generate_from_frequencies (もしくは fit_words) に渡すと実行できます。
ドキュメントの API Reference には詳しい説明がないので、ギャラリーの Using frequency をみる方がおすすめです。

今回はサンプルとして、ライブドアニュースコーパスから適当に1記事選んで、通常の単語の出現回数(今までと同じ方法)と、
TF-IDFで重み付けした方法(generate_from_frequenciesを使う)でそれぞれ WordCloudを作ってみました。

今まで、単語を名詞動詞形容詞に絞ったり平仮名だけの単語は外したりしていましたが、
今回は差が分かりやすくなるようにするためにSTOPWORDなしで全単語を含めています。(コード中で該当処理をコメントアウトしました。)
「てにをは」系の頻出語が小さくなってSTOPWORD無しでも分かりやすくなる効果が出ているのが感じられると思います。


import re
import MeCab
import pandas as pd
import unicodedata
import matplotlib.pyplot as plt
from wordcloud import WordCloud
from sklearn.feature_extraction.text import TfidfVectorizer

# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")


def mecab_tokenizer(text):
    # ユニコード正規化
    text = unicodedata.normalize("NFKC", text)
    # 分かち書き
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    # pos = [f.split(',')[0] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    # 名詞,動詞,形容詞のみに絞り込み
    # target_pos = ["名詞", "動詞", "形容詞"]
    # token_list = [t for t, p in zip(token_list, pos) if (p in target_pos)]
    # ひらがなのみの単語を除く
    # token_list = [t for t in token_list if not kana_re.match(t)]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    # 半角スペースを挟んで結合する。
    result = " ".join(token_list)
    # 念のためもう一度ユニコード正規化
    result = unicodedata.normalize("NFKC", result)

    return result


# データ読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# 形態素解析(全データ)
df["token"] = df.text.apply(mecab_tokenizer)

# サンプルとして用いるテキストを一つ選び、形態素解析する
text = df.iloc[1010].text
tokenized_text = mecab_tokenizer(text)

# tfidfモデルの作成と学習
tfidf_model = TfidfVectorizer(token_pattern='(?u)\\b\\w+\\b', norm=None)
tfidf_model.fit(df.token)

# 対象テキストをtf-idfデータに変換
tfidf_vec = tfidf_model.transform([tokenized_text]).toarray()[0]
# 単語: tf-idfの辞書にする。
tfidf_dict = dict(zip(tfidf_model.get_feature_names(), tfidf_vec))
# 値が正のkeyだけ残す
tfidf_dict = {k: v for k, v in tfidf_dict.items() if v > 0}

# 単語の出現頻度でWord Cloud作成
wc_0 = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",
        width=600,
        height=300,
        prefer_horizontal=1,
        background_color='white',
        include_numbers=True,
        colormap='tab20',
        regexp=r"[\w']+",
    ).generate_from_text(tokenized_text)

# TF-IDFで Word Cloud作成
wc_1 = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",
        width=600,
        height=300,
        prefer_horizontal=1,
        background_color='white',
        include_numbers=True,
        colormap='tab20',
    ).generate_from_frequencies(tfidf_dict)

# それぞれ可視化
plt.rcParams["font.family"] = "IPAexGothic"
fig = plt.figure(figsize=(12, 12), facecolor="w")
ax = fig.add_subplot(2, 1, 1, title="単語の出現回数で作成")
ax.imshow(wc_0)
ax.axis("off")
ax = fig.add_subplot(2, 1, 2, title="TF-IDFで作成")
ax.imshow(wc_1)
ax.axis("off")
plt.show()

出来上がった図がこちら。

明らかに下のやつの方がいいですね。

wordcloudの見た目を整える設定

最近、真面目にワードクラウドを作る機会がありましたので、久々に以前紹介したwordcloudライブラリを使いました。(並行してTableauでも作りました。)

前回紹介した記事では本当にざっくりとしか紹介していなかったのですが、今回そこそこ見た目を整える必要がありましたので、
細かいオプションの挙動を調べました。その内容をメモしておきます。
参考: Pythonでワードクラウドを作成する

なお、公式のドキュメントはこちらにあるので、英語に抵抗がなければそちらを読む方がおすすめです。サンプルも綺麗ですよ。
ドキュメント: wordcloud

今回は日本語のテキストを使います。
ライブドアニュースのコーパスから長いのを1つ選んで、形態素解析して準備しておきました。
データは一つの文字列形式で、半角スペースで区切ってあります。


# データ型や長さの確認
print(type(tokenized_text))
# 
print(len(tokenized_text))
# 8704
print(len(tokenized_text.split()))
# 2227
# 先頭 70文字
print(tokenized_text[: 70])
# bluetooth bluetooth デジタル 機器 同士 接続 無線 規格 bluetooth 聞く 難しい 思う 今 ケーブル 繋げる

さて、wordcloudの使い方の復習です。
ライブラリをインポートして、 WordCloudのインスタンスを作り、
generate_from_text メソッドか、 そのエイリアスである generate メソッドでワードクラウドが作れます。
デフォルト設定でやってみましょう。


from wordcloud import WordCloud
wc = WordCloud()
wc.generate_from_text(tokenized_text)
wc.to_image()

はい、日本語が見えないですね。
これ以外にも例によって一文字の単語は含まれないとか、いろいろ難点はあります。
これらは、 WordCloudのインスタンスを作るときに、各種設定を渡すことで改善します。一覧はドキュメントにあるので、僕が使うものだけ紹介します。

– font_path: フォントファイル(ttfファイルなど)のファイルパスを指定する。 fontfamilyではないので注意。
– width: 横幅
– height: 高さ
– prefer_horizontal: 横書きで配置することを試みる確率。 (デフォルト0.9) これを1にすると横書きに統一できる。
– background_color: 背景色(デフォルト ‘black’)。とりあえず’white’にすることが多い。
– include_numbers: 数値だけの単語も含むか。デフォルトがFalse
– colormap: 文字色のカラーマップ指定
– regexp: generate_from_text するときの単語区切りに使う正規表現。Noneの場合、r”\w[\w’]+” が使われるので、一文字の単語が消える。

とりあえず、これらをいい感じに設定して出してみましょう。


wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",  # 日本語フォントファイル 
        width=600,  # 幅
        height=400,  # 高さ
        prefer_horizontal=1,  # 横書きで配置することを試す確率 (デフォルト0.9)
        background_color='white',  # 背景色
        include_numbers=True,  # 数値だけの単語も含む
        colormap='tab20',  # 文字色のカラーマップ指定
        regexp=r"[\w']+",  # 一文字の単語も含む
    ).generate(tokenized_text)

wc.to_image()

圧倒的に良くなりましたね。

このほか、実用的には使う場面が思いつかないのですが、画像データ(要するに2次元の配列)でマスクをかけることができます。
0〜255の整数値(floatだとwarningが出ますが動きます。)が入った配列を渡すと、
「値が255の部分」がマスキングされ文字が入らなくなります。最初0の方がマスクされると思ってたのですが逆でした。

本来は画像データを読み込んでやることを想定されているようですが、配列を作ればなんでもいいので、とりあえず円形のデータを使って試します。


import numpy as np
mask_ary = np.zeros(shape=(400, 400))

for i in range(400):
    for j in range(400):
        if (i-200)**2 + (j-200)**2 > 180**2:
            mask_ary[i, j] = 255

# 整数型に変換
mask_ary = mask_ary.astype(int)

wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",
        mask=mask_ary,
        contour_width=1,  # マスク領域の枠線の太さ
        contour_color='green',  # マスク両機の枠線の色
        prefer_horizontal=1,  # 横書きで配置することを試す確率 (デフォルト0.9)
        background_color='white',  # 背景色
        include_numbers=True,  # 数値だけの単語も含む
        colormap='tab20',  # 文字色のカラーマップ指定
        regexp=r"[\w']+",  # 一文字の単語も含む
    ).generate(tokenized_text)

wc.to_image()

少しおしゃれになりました。

Universal Sentence Encoder を使ってニュース記事分類

前回に引き続き、多言語 Universal Sentence Encoder の話です。
テキストをベクトル化しただけで終わるとつまらないので、これを使って、先日のライブドアニュースコーパスの記事分類をやってみました。
最初、本文でやろうとしたのですが、文ベクトルを得るのに結構時間がかかったので、記事タイトルでカテゴリー分類をやってみます。

すごく適当ですが、512次元のベクトルに変換したデータに対してただのニューラルネットワークで学習してみました。

まずはデータの準備からです。


import pandas as pd
import tensorflow_hub as hub
# import numpy as np
import tensorflow_text
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import OneHotEncoder

# ライブドアニュースコーパス データを読み込む
df = pd.read_csv("./livedoor_news_corpus.csv")
# 訓練データと評価データに分割する
df_train, df_test = train_test_split(df, test_size=0.2, stratify=df.category)
df_train = df_train.copy()
df_test = df_test.copy()
df_train.reset_index(inplace=True, drop=True)
df_test.reset_index(inplace=True, drop=True)

# USEモデルの読み込みと、テキストのベクトル化
url = "https://tfhub.dev/google/universal-sentence-encoder-multilingual/3"
embed = hub.load(url)

X_train = embed(df_train.title).numpy()
X_test = embed(df_test.title).numpy()

# 正解ラベル(記事カテゴリ)を One-Hot 表現に変換

ohe = OneHotEncoder()
ohe.fit(df_train.category.values.reshape(-1, 1))
y_train = ohe.transform(df_train.category.values.reshape(-1, 1)).toarray()
y_test = ohe.transform(df_test.category.values.reshape(-1, 1)).toarray()

あとはモデルを作って学習していきます。


from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.layers import Dropout
from tensorflow.keras.callbacks import EarlyStopping
from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix

# モデルの作成
model = Sequential()
model.add(Input(shape=(512, )))
model.add(Dropout(0.3))
model.add(Dense(128, activation='tanh'))
model.add(Dropout(0.4))
model.add(Dense(32, activation='tanh'))
model.add(Dropout(0.5))
model.add(Dense(9, activation='softmax'))
print(model.summary())
"""
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
dropout (Dropout)            (None, 512)               0         
_________________________________________________________________
dense (Dense)                (None, 128)               65664     
_________________________________________________________________
dropout_1 (Dropout)          (None, 128)               0         
_________________________________________________________________
dense_1 (Dense)              (None, 32)                4128      
_________________________________________________________________
dropout_2 (Dropout)          (None, 32)                0         
_________________________________________________________________
dense_2 (Dense)              (None, 9)                 297       
=================================================================
Total params: 70,089
Trainable params: 70,089
Non-trainable params: 0
_________________________________________________________________
"""

model.compile(
    loss="categorical_crossentropy",
    optimizer="adam",
    metrics=['acc']
)

# 学習
early_stopping = EarlyStopping(
                        monitor='val_loss',
                        min_delta=0.0,
                        patience=5,
                )

history = model.fit(X_train, y_train,
                    batch_size=128,
                    epochs=100,
                    verbose=2,
                    validation_data=(X_test, y_test),
                    callbacks=[early_stopping],
                    )

"""
Train on 5893 samples, validate on 1474 samples
Epoch 1/100
5893/5893 - 3s - loss: 1.9038 - acc: 0.3655 - val_loss: 1.5009 - val_acc: 0.5611
Epoch 2/100
5893/5893 - 1s - loss: 1.3758 - acc: 0.5564 - val_loss: 1.1058 - val_acc: 0.6771

# -- 中略 --

Epoch 28/100
5893/5893 - 1s - loss: 0.7199 - acc: 0.7611 - val_loss: 0.5913 - val_acc: 0.8012
Epoch 29/100
5893/5893 - 1s - loss: 0.7099 - acc: 0.7663 - val_loss: 0.5932 - val_acc: 0.7985
Epoch 30/100
5893/5893 - 1s - loss: 0.7325 - acc: 0.7597 - val_loss: 0.5935 - val_acc: 0.8005
"""

かなり適当なモデルですが、それでもテストデータで80%くらい正解できたようですね。
classification_reportもみておきましょう。


print(classification_report(model.predict_classes(X_test),y_test.argmax(axis=1),target_names=ohe.categories_[0]))
"""
                precision    recall  f1-score   support

dokujo-tsushin       0.77      0.81      0.79       166
  it-life-hack       0.80      0.82      0.81       169
 kaden-channel       0.80      0.80      0.80       174
livedoor-homme       0.56      0.72      0.63        79
   movie-enter       0.85      0.79      0.82       187
        peachy       0.67      0.69      0.68       166
          smax       0.90      0.89      0.89       175
  sports-watch       0.88      0.89      0.89       177
    topic-news       0.88      0.75      0.81       181

      accuracy                           0.80      1474
     macro avg       0.79      0.80      0.79      1474
  weighted avg       0.81      0.80      0.80      1474
"""

どのカテゴリを、どのカテゴリーに間違えたのかを確認したのが次の表です。


df_test["predict_category"] = model.predict_classes(X_test)
df_test["predict_category"] = df_test["predict_category"].apply(lambda x: ohe.categories_[0][x])

print(pd.crosstab(df_test.category, df_test.predict_category).to_html())
predict_category dokujo-tsushin it-life-hack kaden-channel livedoor-homme movie-enter peachy smax sports-watch topic-news
category
dokujo-tsushin 134 2 2 4 5 21 0 1 5
it-life-hack 1 139 13 4 2 5 9 1 0
kaden-channel 1 12 139 4 1 1 6 0 9
livedoor-homme 6 3 5 57 9 14 1 2 5
movie-enter 0 2 1 1 148 8 1 5 8
peachy 21 1 4 7 14 114 1 2 5
smax 0 8 9 0 0 1 156 0 0
sports-watch 1 1 0 2 2 2 0 158 14
topic-news 2 1 1 0 6 0 1 8 135

独女通信とPeachyとか、ITライフハックと家電チャンネルなど、記事タイトルだけだと間違えても仕方がないような誤判定があるくらいで概ね正しそうです。

多言語 Universal Sentence Encoder を試す

自然言語処理をやっていると文章のベクトルが欲しいことが多々あります。
BoWやtf-idf,トピックモデルや、word2vecの平均、一時期流行ったSCDVなどいろいろ方法はあるのですが、これが決定版というのがなかなか無く、毎回悩ましい問題です。
学習済みモデルの活用なども考えるのですが、日本語に対応しているものは珍しかったりします。
そんな状況の中、Googleさんから多言語に対応した、Universal Sentence Encoderというものが公開されているのでこれを試してみることにしました。

元の論文はこちら: Multilingual Universal Sentence Encoder for Semantic Retrieval
学習済みモデルは Tensorflow Hubの universal-sentence-encoder-multilingual のページで配布されています。
現在は Version 3 が出てるようです。

Tensorflow Hub そのものの使い方にまだ慣れていないのですが、このモデルのページのコードだけで動かすことができたので、それを紹介します。

英語、イタリア語、日本語で、それぞれ3種類の文章をベクトル化し、類似度を図ります。
とりあえず、ライブラリを読み込んで、データを準備します。
tensorflow_text はコード中で使わないのですが、importしておかないといけないようです。


# ライブラリのインポートと、サンプルテキストの準備
import tensorflow_hub as hub
import numpy as np
import tensorflow_text

english_sentences = ["dog", "Puppies are nice.", "I enjoy taking long walks along the beach with my dog."]
italian_sentences = ["cane", "I cuccioli sono carini.", "Mi piace fare lunghe passeggiate lungo la spiaggia con il mio cane."]
japanese_sentences = ["犬", "子犬はいいです", "私は犬と一緒にビーチを散歩するのが好きです"]

さて、実際にモデルを読み込んで、データをベクトル化してみます。すごく手軽ですね。


# モデルの読み込み
url = "https://tfhub.dev/google/universal-sentence-encoder-multilingual/3"
embed = hub.load(url)

# 埋め込みの計算
en_result = embed(english_sentences)
it_result = embed(italian_sentences)
ja_result = embed(japanese_sentences)

埋め込んだ結果は TensorflowのTensorで戻ってきます。
Shapeを確認すると、3この文章がそれぞれ 512次元のベクトルに変換されていることがわかります。


print(type(ja_result))
# 
print(ja_result.shape)
# (3, 512)

サンプルでは次のようにして、英語の3文と、イタリア語日本語のそれぞれの類似度を計算していました。
np.inner()は内積を計算する関数なのですが、実は埋め込まれたベクトルはもともとノルムが1になるように正規化されているので、
これでコサイン類似度が計算できています。


# Compute similarity matrix. Higher score indicates greater similarity.
similarity_matrix_it = np.inner(en_result, it_result)
similarity_matrix_ja = np.inner(en_result, ja_result)

ノルムが1であることも確認しておきます。


print(np.linalg.norm(ja_result, axis=1))
# [1. 1. 1.]

結果を表示しておきましょう。これをみると、近い意味の文章は違う言語であっても近い位置に埋め込まれてるのが確認できます。


print(similarity_matrix_it.round(3))
"""
[[0.958 0.331 0.302]
 [0.388 0.734 0.248]
 [0.236 0.218 0.928]]
"""

print(similarity_matrix_ja.round(3))
"""
[[0.917 0.512 0.316]
 [0.443 0.659 0.309]
 [0.267 0.254 0.767]]
"""

さて、テンソル型で帰ってきてるデータですが、普通の numpyのArrayにしたい場合は、 .numpy()というメソッドが使えます。


print(ja_result)
"""
tf.Tensor(
[[ 0.10949969 -0.02602168  0.04610093 ...  0.05233185  0.00311097
   0.01985742]
 [ 0.03606617 -0.00969927  0.04294628 ...  0.02523113 -0.00969072
   0.05069916]
 [-0.02916382 -0.00816513 -0.02910488 ...  0.00125965 -0.00689579
   0.0103978 ]], shape=(3, 512), dtype=float32)
"""

print(ja_result.numpy())
"""
[[ 0.10949969 -0.02602168  0.04610093 ...  0.05233185  0.00311097
   0.01985742]
 [ 0.03606617 -0.00969927  0.04294628 ...  0.02523113 -0.00969072
   0.05069916]
 [-0.02916382 -0.00816513 -0.02910488 ...  0.00125965 -0.00689579
   0.0103978 ]]
"""

とても便利ですね。

言語としては 16言語に対応していて、しかも可変長の文章を全て512次元にエンコードしてくれます。
かなり活用の場がありそうです。

TensorflowやKerasでJupyterカーネルが落ちるようになってしまった場合の対応

注意: この記事で紹介しているのは根本的解決ではなく、暫定対応です。

前回の記事: tensorflow-textのインストールに苦戦した話 で、やむなくライブラリを1つpipで入れたところ、Tensorflow(keras)を操作しているとJupyterカーネルが死んでしまう事象が再発するようになりました。
実は以前LightGBMを入れた後も同様の事象が発生していたんですよね。
その時は対応方法をメモしていなかったので、この機会に残しておきます。

まず、事象の切り分けです。
今回の事象は jupyter では、結果の出力枠には、Warning など表示せず、メッセージウィンドウで以下のメッセージを表示してお亡くなりになります。

Kernel Restarting
The kernel appears to have died. It will restart automatically.

これだけだと原因は分からないのですが、 コンソールからPythonを起動し、同じコードをコピペして実行していくと、今度は次のエラーが出ます。

OMP: Error #15: Initializing libiomp5.dylib, but found libiomp5.dylib already initialized.
OMP: Hint This means that multiple copies of the OpenMP runtime have been linked into the program. That is dangerous, since it can degrade performance or cause incorrect results. The best thing to do is to ensure that only a single OpenMP runtime is linked into the process, e.g. by avoiding static linking of the OpenMP runtime in any library. As an unsafe, unsupported, undocumented workaround you can set the environment variable KMP_DUPLICATE_LIB_OK=TRUE to allow the program to continue to execute, but that may cause crashes or silently produce incorrect results. For more information, please see http://www.intel.com/software/products/support/.
Abort trap: 6

要するに、 libiomp5.dylib というファイルがダブってるそうです。一個だけリンクされているようにしなさいと言われているのですが、実はまだこの実態ファイルがどこに存在しているのか見つけられておらず、根本的な対応が取れていません。
そこで、次の記述に頼ります。

you can set the environment variable KMP_DUPLICATE_LIB_OK=TRUE

要は問題のライブラリの重複を許して警告を止める設定のようです。
予め環境変数に入れておいても良いでしょうし、Pythonのコード中で行うには次のように設定したら大丈夫です。


import os
os.environ['KMP_DUPLICATE_LIB_OK']='TRUE'

.bash_profile に設定する時は次の記述を入れます。


export KMP_DUPLICATE_LIB_OK=TRUE

こういうのを避けるために 環境構築のconda統一を進めてたようなものなので、とても残念なのですがしばらくこの暫定対応で行かないといけないですね。

もし同じような事象で悩まれている方がいらっしゃいましたら試してみてください。

また、 Jupyter Kernelの突然死は、同じコードをコンソールで実行するとWarningやErrorを見れる場合があるということも覚えておくと便利です。
(実はこのERRORメッセージはJupyterのログに吐き出されているのですそちらから探すこともできます。)

tensorflow-textのインストールに苦戦した話

conda / pip の混在にこだわらなければ、もしくは初めから pipのみで環境を作っていれば何も問題がなかったのですが、
予想外に大苦戦してしまったので tensorflow-text のインストールについてメモしておきます。
(この記事は 2020年5月5日 時点の話です。近い将来のうちに、conda install でさっと入るようになるのを期待します。)

さて、そもそも変な苦労をする羽目になった背景から説明します。
condaの基本的な使い方 という記事で書いたとおり、今使っている環境はできるだけcondaのみで構築しています。
そして、 Tensorflow-Hub にある、 universal-sentence-encoder-multilingual というモデルを試したくなりました。

これを使うのに、 tensorflow_hub と tensorflow_text というライブラリをインポートする必要があります。
tensorflow_hub は 簡単です。
$ conda install tensorflow-hub
で入ります。 (インポート時とインストール時でアンダーバーとハイフンが変わる罠はありますがそれだけです。)

一方、 tensorflow_text ですが、 これが conda の公式リポジトリにも、 conda-forgeにも存在しません。
それだけならいいのですが、 conda skeleton でもエラーが出ます。
(conda skeleton についてはこちら。)


$ conda skeleton pypi tensorflow-text
# ~~ (中略) ~~
Error: No source urls found for tensorflow-text

いろいろ調査しましたが、原因は分からずとりあえず今回は condaでのインストールを諦めることにしました。

これだけ pip で入れることにしたのですが、ドキュメントにあるとおり、
>$ pip install tensorflow-text
すると、1点困ったことになります。
condaで入れていた、tensorflowなどの複数の依存ライブラリをアンインストールしてpipで入れ直してしまうのです。
(僕が確認した範囲だと7つほど影響を受けました。)
これだと、pipとcondaが大きく混在した環境になります。

ということで、pipで入ってしまったライブラリは一旦pipで消して、condaで入れ直します。

そして、次に試みたのが、 依存ライブラリを入れない no-deps オプションです。
$ pip install --no-deps tensorflow-text

これで綺麗にさっと入ったのですが、これでもまだ問題があり、jupyterで動かすとすぐにkernelが死んでしまうようになりました。
エラーを調査すると、ライブラリの依存関係に問題があったようです。(依存関係無視してインストールしたので当然ですね。)
pip check で確認すると、次のように出ました。


$ pip check
tensorflow-text 2.1.1 has requirement tensorflow<2.2,>=2.1.0, but you have tensorflow 2.0.0.

tensorflow のバージョンを上げればいいかと思ったのですが、
$ conda search tensorflow
で調べてみるとまだ condaには2.0.0しかありません。

ということで、次に tensorflow-text の方を古いものに入れ替えることにしました。pip uninstallして消した後、
$ pip install tensorflow-text==1.15.1 --no-deps

今度は tensorflowが新しすぎるからダメだとのことでした。


$ pip check
tensorflow-text 1.15.1 has requirement tensorflow<1.16,>=1.15.0, but you have tensorflow 2.0.0.

最終的に、バージョン2.0.1なら問題ないようでした。


$ pip install tensorflow-text==2.0.1 --no-deps
# ~~ (中略) ~~
Installing collected packages: tensorflow-text
Successfully installed tensorflow-text-2.0.1

$ pip check
No broken requirements found.

本来ならライブラリは新しいバージョンを使っていくべきですし、
condaについてしっかり理解していれば、condaでインストール方法もありそうです。

なので、この記事の内容は全く推奨できず、真似される場合は自己責任でお願いしますとしか言いようがないのですが、
とりあえず自分の環境にはこうやって入れた、というメモとして記録させていただきました。

やってることは最終的に次の1行だけなのに、とても疲れました。
$ pip install tensorflow-text==2.0.1 --no-deps

gensimでトピックモデル(LDA)をやってみる

前回の記事でgensimが登場したので、今度はgensimでトピックモデル(LDA)を実装する方法を紹介します。
ちなみに、僕はLDAをやるときはscikit-learnの方を使うことがほどんどで、gensimのldamodelには慣れていないのでご了承ください。
参考: pythonでトピックモデル(LDA)
gensimの中でもword2vecに比べて若干癖があり、使いにくいように感じています。

早速ですがデータの準備からやっていきます。
使うデータは以前作成したライブドアニュースコーパスのテキストです。
以下の前処理を施しました
– ユニコード正規化
– 分かち書き
– 活用形を原型に戻す
– 名詞,動詞,形容詞のみに絞り込む
– ひらがなのみで構成された単語を取り除く
– アルファベットの小文字統一
(本当はSTOP WORDの辞書を真面目に作るべきなのですが、横着して品詞と文字種だけで絞り込んでいます。)


import pandas as pd
import MeCab
import re

# データの読みこみ
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化
df["text"] = df["text"].str.normalize("NFKC")
# アルファベットを小文字に統一
df["text"] = df["text"].str.lower()


# 分かち書きの中で使うオブジェクト生成
tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")


def mecab_tokenizer(text):
    # テキストを分かち書きする関数を準備する
    parsed_lines = tagger.parse(text).split("\n")[:-2]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    pos = [f.split(',')[0] for f in features]

    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]

    # 名詞,動詞,形容詞のみに絞り込み
    target_pos = ["名詞", "動詞", "形容詞"]
    token_list = [t for t, p in zip(token_list, pos) if p in target_pos]

    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]

    # ひらがなのみの単語を除く
    token_list = [t for t in token_list if not kana_re.match(t)]

    return token_list


# 分かち書きしたデータを作成する
sentences = df.text.apply(mecab_tokenizer)

print(sentences[:5])
"""
0    [2005年, 11月, 2006年, 7月, 読売新聞, 連載, 直木賞, 作家, 角田光...
1    [アンテナ, 張る, 生活, 2月28日, 映画, おかえり、はやぶさ, 3月10日, 公開...
2    [3月2日, 全国ロードショー, スティーブン・スピルバーグ, 待望, 監督, 最新作, 戦...
3    [女優, 香里奈, 18日, 都内, 行う, 映画, ガール, 5月26日, 公開, 女子高...
4    [5日, 東京都千代田区, 内幸町, ホール, 映画, キャプテン・アメリカ/ザ・ファースト...
Name: text, dtype: object
"""

さて、ここからが本番です。
公式ドキュメントのサンプルコードを真似しながら進めます。

models.ldamodel – Latent Dirichlet Allocation

word2vecの時は、分かち書きした単語を配列形式でそのまま取り込んで学習してくれましたが、
LdaModel では各テキストを (単語ID, 出現回数) のタプルの配列に変換しておく必要があります。
Dictionary という専用の関数を用意してくれているのでそれを使います。


from gensim.corpora.dictionary import Dictionary


# 単語と単語IDを対応させる辞書の作成
dictionary = Dictionary(sentences)
# LdaModelが読み込めるBoW形式に変換
corpus = [dictionary.doc2bow(text) for text in sentences]

# 5000番目のテキストを変換した結果。(長いので10単語で打ち切って表示)
print(corpus[5000][:10])
# [(10, 1), (67, 1), (119, 1), (125, 1), (174, 1), (182, 1), (223, 1), (270, 1), (299, 1), (345, 1)]

単語IDと元の単語は以下のようにして変換できます。


# idから単語を取得
print(dictionary[119])
# print(dictionary.id2token[119]) # これも同じ結果
# 復帰

# 単語からidを取得
print(dictionary.token2id["復帰"])
# 119

さて、データができたので学習です。これは非常に簡単でトピックス数を指定して
LdaModelに先ほどのデータと一緒に渡すだけ。
(トピック数は本当はいろいろ試して評価して決める必要があるのですが、今回は元のコーパスが9種類のニュースなので、そのまま9にしました。)


from gensim.models import LdaModel
# トピック数を指定してモデルを学習
lda = LdaModel(corpus, num_topics=9)

学習したモデルを使って、テキストをトピックスに変換するのは次のようにやります。


print(lda[corpus[0]])
# [(0, 0.15036948), (2, 0.81322604), (6, 0.03397929)]

この形式だと個人的には使いにくいと感じているので、
次ようなコードで、DataFrameに変換しています。
(これはもっとクレバーな書き方があると思うので検討中です。)


topic_df = pd.DataFrame(index=range(len(corpus)))
for c in range(9):
    topic_df[c] = 0.0

for i in range(len(corpus)):
    topics = lda[corpus[i]]
    for t, p in  topics:
    
        topic_df.loc[i][t] = p


print(topic_df.head().round(3))
"""
       0    1      2      3      4      5      6    7      8
0  0.150  0.0  0.813  0.000  0.000  0.000  0.034  0.0  0.000
1  0.000  0.0  0.492  0.000  0.226  0.041  0.000  0.0  0.239
2  0.427  0.0  0.297  0.000  0.052  0.223  0.000  0.0  0.000
3  0.174  0.0  0.543  0.027  0.000  0.253  0.000  0.0  0.000
4  0.000  0.0  0.245  0.000  0.224  0.120  0.000  0.0  0.408
"""

元のカテゴリーとTopicの対応も確認しておきましょう。
ざっと見た限りではうっすらと傾向は出ていますが、そんなに綺麗に分類できている訳ではないですね。
カテゴリ数9をそのまま使ったのは適当すぎました。


main_topic = topic_df.values.argmax(axis=1)
print(pd.crosstab(df.category, main_topic))
"""
col_0             0    1    2    3    4    5    6    7    8
category                                                   
dokujo-tsushin   60    3  734    5   13   30   18    5    2
it-life-hack     11  350   35   80   76   29   42  113  134
kaden-channel    11  320  106   32   13   35  208  129   10
livedoor-homme   35   49  168   42  129   25   21   14   28
movie-enter      87    1   93    5   59  377   72    0  176
peachy          130   17  228  162   40  163   86    5   11
smax              2  520    3   87    6    5    2  241    4
sports-watch     29    0  305    1  306  238   19    0    2
topic-news       34   15  200    1   69  340  101    1    9
"""

さて、最後にトピックを構成する単語を見ておきましょう。
独女通信が多く含まれる 2番のトピックでやってみます。

次の関数で、トピックごとの出現頻度上位の単語のIDとその確率が取得できます。
lda.get_topic_terms([topicのid], topn=[取得する個数])
IDだとわかりにくいので、単語に戻して表示しましょう。


for i, prob in lda.get_topic_terms(2, topn=20):
    print(i, dictionary.id2token[int(i)], round(prob, 3))

"""
354 思う 0.013
275 人 0.011
178 自分 0.009
883 女性 0.008
186 言う 0.007
2107 結婚 0.007
1211 私 0.007
2833 男性 0.006
1193 多い 0.006
113 彼 0.005
856 仕事 0.005
527 今 0.005
382 気 0.004
162 相手 0.004
183 見る 0.004
270 中 0.004
95 女 0.004
287 何 0.004
614 方 0.004
371 時 0.004
"""

それっぽいのが出てきましたね、