SciPyによるカーネル密度推定

前回の記事に続いてカーネル密度推定の話です。
参考: カーネル密度推定法の紹介

前の記事では数式に沿って実装するため、numpyで書きましたが、普段の利用では、SciPyにクラスが実装されているのでこれを使えます。

参考: scipy.stats.gaussian_kde
クラス名から分かる通り、カーネルはガウスカーネルがあらかじめ指定されており、他の関数は使えません。
実際のところ、ガウスカーネル以外が使えなくて困ることは滅多にないでしょう。

使い方ですが、 インスタンスを作るときに、データセットを渡すだけで完了します。
とてもありがたいことに、バンド幅の指定が要りません。
デフォルトでは Scottのルールと呼ばれる方法で決定されます。
bw_methodという引数で指定することで、もっと直接的な指定や、Silvermanのルールを使うことも可能です。
ScottのルールとSilvermanのルールがそれぞれ具体的にどの様にバンド幅を決めているのかは後日のブログで紹介するとして、
とりあえず、正規分布から生成したデータで使ってみましょう。


from scipy.stats import gaussian_kde
from scipy.stats import norm
import numpy as np
import matplotlib.pyplot as plt

# 50件のデータをサンプリング
data = norm(loc=2, scale=2).rvs(50)

# グラフ描写のため真の確率密度関数の値を取得しておく
xticks = np.linspace(-5, 9, 141)
pdf_values = norm(loc=2, scale=2).pdf(xticks)

# カーネル密度推定
kde = gaussian_kde(data)
# evaluate には pdfというエイリアスがあるのでそちらでも可
estimated_value = kde.evaluate(xticks)

# 可視化
fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
ax.plot(xticks, pdf_values, label="真の分布")
ax.plot(xticks, estimated_value, label="推定値",  linestyle="--")
# 標本データ
for x in data:
    ax.vlines(x, ymin=0, ymax=0.01, alpha=0.5)
ax.set_ylim((0, 0.25))
ax.legend()
plt.show()

出力結果がこちらです。

とても手軽に良い推定が得られていますね。

ちなみに、推定してくれたバンド幅ですが、covariance という属性にその情報を持っています。


# バンド幅の確認 (hの2乗なので注意)
print(kde.covariance)

# [[0.79905207]]

多次元にも対応するため、分散共分散行列の形でデータを持てる様になっています。
今回は1次元データなので、ちょっと面倒ですが、無駄に2次元配列になってる中から要素を持ってくる必要があります。
varianceなので、この値は分散、前回の記事のバンド幅は標準偏差なので、平方根を取る必要があることも注意です。

せっかく推定してくれたバンド幅が取れるので、前回スクラッチで描いたコードの推定値と結果が一致することも見ておきましょう。
前回の記事のコピーだと、関数名が重複してしまって面倒なので適当に、my_kdeにリネームしました。


# ガウスカーネル
def gaussian_kernel(u):
    return(np.exp(-(u**2)/2)/np.sqrt(2*np.pi))


# ガウスカーネルを用いたカーネル密度推定
def my_kde(x, h, dataset):
    return sum([gaussian_kernel((x-d)/h) for d in dataset])/(len(dataset)*h)


# バンド幅の取得
h = np.sqrt(kde.covariance[0][0])
# 可視化
fig = plt.figure(facecolor="w")
ax = fig.add_subplot(111)
ax.plot(xticks, pdf_values, label="真の分布")
ax.plot(xticks, kde.evaluate(xticks), label="scipy", linestyle="--")
ax.plot(xticks, my_kde(xticks, h, data), label="スクラッチ", linestyle=":")
ax.legend()
plt.show()

SciPy で出した結果と、スクラッチで書いてるやつの結果が(一致してることの確認なので)重なってしまっていて、見にくいですが、
一応線の種類を変えてあるので、よく見るとしっかり同じところに線が引かれているのが分かります。

カーネル密度推定法の紹介

今回からしばらくカーネル密度推定法について書いていきます。

参考文献ですが「パターン認識と機械学習 上 (PRML 上)」の 119ページ、 2.5.1 カーネル密度推定法 あたりがわかりやすいのではないでしょうか。
ただし、この本だと最後にパラメーター$h$の決め方が重要だ、ってところまで書いて、$h$の決め方を紹介せずに次の話題に移っているので、
これからの数回の記事の中でそこまで説明できたらいいなと思っています。

さて、カーネル密度推定法を大雑把に言うと、
「確率密度が全然わからない確率分布から標本が得られたときに、その標本からできるだけいい感じに元の確率密度を推定する方法」です。
元の確率密度が正規分布だとかポアソン分布だとかだけでもわかっていたら、その期待値や分散などのパラメーターだけ推定すればいい(これをパラメトリックという)のですが、
それさえもわからないときに使われる手法であり、ノンパラメトリックなアプローチと呼ばれます。

できるだけ一般的な形でカーネル密度推定法の方法を書くと次のような形になります。

ある$D$次元のユークリッド空間の未知の確率密度$p(\mathbf{x})$から、観測値の集合$\{\mathbf{x}_n\}\ \ (n=1,2, \cdots, N)$が得られたとします。

そして、次の性質を満たすカーネル関数$k(\mathbf{u})$を選びます。
$$
\begin{align}
k(\mathbf{u}) &\geq 0,\\
\int k(\mathbf{u}) d\mathbf{u} &= 1
\end{align}
$$
このとき、$p(\mathbf{x})$を次で推定するのがカーネル密度推定法です。($h$はパラメーター。)
$$
p(\mathbf{x}) = \frac{1}{N}\sum_{n=1}^{N}\frac{1}{h^D}k(\frac{\mathbf{x}-\mathbf{x}_n}{h}).
$$

カーネル密度関数は要は確率密度間数ですね。理論上は何でもいいそうですが、現実的には原点周りで値が大きく、そこから外れると0に近く(もしくは等しく)なる関数が好都合です。
正規分布の確率密度関数(ガウスカーネル)が頻繁に用いられます。

上記の式が導出されるまでの話はPRMLに書いてあります。
その部分を省略してしまったので代わりに上の定義通り計算して、未知の確率分布が近似できている様子を確認しておきましょう。
できるだけライブラリ使わないでやります。(ただ、正解データの分布作成でscipyは使っていますし、numpyやmatplotlibは例外とします。)
カーネル関数にはガウスカーネルと、単位超立方体内で1、その外で0を取る関数(パルツェン窓というらしい)の2種類でやってみます。

元となる分布は正規分布2個を足した、2つ山のある分布で実験します。($D$は1です)

最初に正解になる分布関数の定義と、そこからのデータサンプリングを済ませておきます。


from scipy.stats import norm
import numpy as np
import matplotlib.pyplot as plt


# 推定したい分布の真の確率密度関数
def p_pdf(x):
    return (norm.pdf(x, loc=-4, scale=2)+norm.pdf(x, loc=2, scale=1))/2


# その分布からのサンプリング
def p_rvs(size=1):
    result = []
    for _ in range(size):
        if norm.rvs() < 0:
            result.append(norm.rvs(loc=-4, scale=2))
        else:
            result.append(norm.rvs(loc=2, scale=1))
    return np.array(result)


# グラフ描写のため真の確率密度関数の値を取得しておく
xticks = np.linspace(-10, 5, 151)
pdf_values = p_pdf(xticks)

# 50件のデータをサンプリング
X = p_rvs(50)

そして、それぞれのカーネル関数と、それを使ったカーネル密度関数を定義しておきます。


# パルツェン窓
def parzen_window(u):
    if np.abs(u) <= 0.5:
        return 1
    else:
        return 0


# ガウスカーネル
def gaussian_kernel(u):
    return(np.exp(-(u**2)/2)/np.sqrt(2*np.pi))


# パルツェン窓を用いたカーネル密度推定
def parzen_kde(x, h, dataset):
    return sum([parzen_window((x-d)/h) for d in dataset])/(len(dataset)*h)


# ガウスカーネルを用いたカーネル密度推定
def gaussian_kde(x, h, dataset):
    return sum([gaussian_kernel((x-d)/h) for d in dataset])/(len(dataset)*h)

あとは可視化です。 今回は パラメーター$h$は$0.5, 1, 2$の3つ試しました。


fig = plt.figure(figsize=(12, 12), facecolor="w")

# 3種類のhで実験
for i, h in enumerate([0.5, 1, 2]):
    parzen_kde_values = [parzen_kde(x, h, X) for x in xticks]
    gaussian_kde_values = [gaussian_kde(x, h, X) for x in xticks]

    ax = fig.add_subplot(3, 2, i*2+1, facecolor="w")
    ax.set_title(f"パルツェン窓, h={h}")
    ax.plot(xticks, pdf_values, label="真の分布")
    ax.plot(xticks, parzen_kde_values, label="パルツェン窓")
    # 標本データ
    for x in X:
        ax.vlines(x, ymin=0, ymax=0.01)
    ax.set_ylim((0, 0.37))
    ax.legend()

    ax = fig.add_subplot(3, 2, i*2+2, facecolor="w")
    ax.set_title(f"ガウスカーネル, h={h}")
    ax.plot(xticks, pdf_values, label="真の分布")
    ax.plot(xticks, gaussian_kde_values, label="ガウスカーネル")
    # 標本データ
    for x in X:
        ax.vlines(x, ymin=0, ymax=0.01)
    ax.set_ylim((0, 0.37))
    ax.legend()

plt.show()

出力結果がこれです。

青い線が真の分布、黒くて短い縦線たちが推定に使った標本データ、
オレンジの線が推定した分布です。

「元の確率密度関数の形が全くわからない」という前提から出発している割にはうまく推定できているのもあると言えるのではないでしょうか。
また、カーネル密度関数の選び方、$h$の決め方がそれぞれ重要であることも分かりますね。

やってることといえば、得られた標本の付近で大きめの値を取る関数を全ての標本について計算してその平均を取っているだけなのですが、シンプルな割に強力です。

matplotlibでグラフのx軸とy軸のメモリの間隔(アスペクト)を揃える

matplotlibを使ってる方はご存知だと思いますが、matplotlibはグラフを綺麗に描写するためにx軸とy軸でそれぞれメモリの間隔をいい感じに調整してくれます。
この縦横の比率をアスペクト比と言うそうです。(Wikipedia: アスペクト比)

ほとんどの場合、自動的に調整してくれるのでただありがたいのですが、描写するものによってはこの比率を揃えたいことがあります。
そうしないと円を書きたかったのに楕円になったりします。
実はこのブログの過去の記事のサンプルコードの中で利用したことがあるのですが、
この間必要になったときに自分でこのブログ内から探せなかったので今回独立した記事にしました。

文章で説明していると分かりにくいので、とりあえず縦横の比率がそろってない例を出します。
シンプルにいつものアヤメのデータから2次元分だけ拝借して、散布図を書きました。


import matplotlib.pyplot as plt
from sklearn.datasets import load_iris

# データ取得。 2次元分だけ利用
X = load_iris().data[:, 2:]
fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1, title="アスペクト未指定")
ax.scatter(X[:, 0], X[:, 1])
plt.show()

見慣れた図ですが x軸における幅1 と y軸における幅1が全然違いますね。

この縦横の比率を揃えるには、 add_subplot で Axes オブジェクトを作るときに aspectで指定するか、
Axes オブジェクトの set_aspect 関数で指定します。
指定できる値は “auto”(これがデフォルト), “equal”(比率を揃える), 数値(指定した比率になる) の3パターンです。

個人的には add_subplot した時点でしていておく方が好きです。ただ、行が長くなるので set_aspect 使う方が pep8を守りやすいとも思ってます。

実際にやってみるとこうなります。


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1, aspect="equal", title="アスペクト equal")
ax.scatter(X[:, 0], X[:, 1])
plt.show()

比率が揃いましたね。
ただ今回の例だと、 “auto”の方が見やすいのでその点ちょっと失敗したと思いました。

set_aspect を使う時は、 anchor という引数を同時に渡すこともできます。
これは aspect の指定によって、グラフが実際より小さくなったときに、元の領域のどの位置に表示するかをしているものです。
‘C’が中心なのはいいとして、 ‘N’とか’SW’とかやけに分かりにくい値で指定しないといけないなと感じていたのですが、どうやらこれは東西南北をEWSNのアルファベットで表したもののようです。

自分は必要になったことがないのですが、 set_anchor と言う関数のドキュメントに説明がありますのでこちらもご参照ください。

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