dtreevizで特徴量とラベルの関係を可視化

※この記事では dtreevizの version 0.8.2 を使っています。

前回の記事では、dtreeviz を使って学習済みの決定木を可視化しました。
dtreevizではこの他にも、1個か2個の特徴量とラベルの関係を可視化できます。

それが、 ctreeviz_univar と、ctreeviz_bivar です。
扱える特徴量がuniの方が1個、biの方が2個です。

データは必要なので、irisを読み込んでおきます。今回は木は不要です。
(その代わり、max_depsかmin_samples_leafのどちらかの設定が必須です。)


from sklearn.datasets import load_iris
iris = load_iris()

まず1個のほうをやってみます。
特徴量4個しかないので全部出します。


import matplotlib.pyplot as plt
from dtreeviz.trees import ctreeviz_univar

figure = plt.figure(figsize=(13, 7), facecolor="w")
for i in range(4):
    ax = figure.add_subplot(2, 2, i+1)
    ctreeviz_univar(
        ax,
        iris.data[:, i],
        iris.target,
        max_depth=2,
        feature_name=iris.feature_names[i],
        class_names=iris.target_names.tolist(),
        target_name='types'
    )

plt.tight_layout()
plt.show()

どの特徴量が有効なのか、自分的にはこれまでで一番わかりやすいと感じました。

次は2個の方です。特徴量2種類とラベルを渡すと、それらの関係を可視化してくれます。
2個ずつ選んで2つのグラフで可視化してみました。
引数、ですがfeature_name が feature_names になっており、渡す値も文字列が配列になっているので注意が必要です。


from dtreeviz.trees import ctreeviz_bivar

figure = plt.figure(figsize=(5, 12), facecolor="w")
ax = figure.add_subplot(2, 1, 1)
ctreeviz_bivar(
    ax,
    iris.data[:, :2],
    iris.target,
    max_depth=2,
    feature_names=iris.feature_names[:2],
    class_names=iris.target_names.tolist(),
    target_name='types'
)

ax = figure.add_subplot(2, 1, 2)
ctreeviz_bivar(
    ax,
    iris.data[:, 2:],
    iris.target,
    max_depth=2,
    feature_names=iris.feature_names[2:],
    class_names=iris.target_names.tolist(),
    target_name='types'
)

plt.show()

出力がこちら。

これもわかりやすいですね。

dtreevizで決定木の可視化

早速、前回の記事でインストールした dtreeviz を使ってみます。

※この記事では dtreevizの version 0.8.2 を使っています。
1.0.0 では一部引数の名前などが違う様です。(X_train が x_dataになるなど。)

とりあえず、データと可視化する木がないと話にならないので、いつものirisで作っておきます。


from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier

iris = load_iris()
clf = DecisionTreeClassifier(min_samples_split=5)
clf.fit(
    iris.data,
    iris.target
)

さて、これで学習したモデル(コード中のclf)を可視化します。
リポジトリのコードを見ながらやってみます。

まず、一番シンプルな可視化は、 dtreeviz.trees.dtreevizにモデルと必要なデータを全部渡すものの様です。
(省略不可能な引数だけ設定して実行しましたが、結構多いですね。)


from dtreeviz.trees import dtreeviz

tree_viz = dtreeviz(
    tree_model=clf,
    X_train=iris.data,
    y_train=iris.target,
    feature_names=iris.feature_names,
    target_name="types",
    class_names=iris. target_names.tolist(),
)
tree_viz

出力がこちら。

graphvizで決定木を可視化 でやったのと比べて、とてもスタイリッシュで解釈しやすいですね。

orientation(デフォルトは’TD’)に’LR’を指定すると、向きを縦から横に変更できます。


tree_viz = dtreeviz(
    tree_model=clf,
    X_train=iris.data,
    y_train=iris.target,
    feature_names=iris.feature_names,
    target_name="types",
    class_names=iris. target_names.tolist(),
    orientation='LR',
)
tree_viz

出力がこちら。

木のサイズによってはこれも選択肢に入りそうですね。

決定木の可視化ライブラリ dtreeviz を conda でインストールする

本記事の免責事項:
dtreevizの公式ではpipでのインストールが推奨されているようです。
手順を見ると、condaでgraphviz が入っている場合はそのアンインストールまで明記されています。
そのため、本記事を真似される場合は自己責任でお願いします。
Python環境の破損やその他の動作不良の責任は負いません。
自分自身、将来的にそれらの事象が発生したらpipで入れ直す可能性もあります。

また、この記事でインストールしたのは、version 0.8.2 です。
最新のバージョンでは挙動が異なる可能性があります。

免責事項終わり。

さて、決定木をとても綺麗に可視化してくれるという dtreeviz というライブラリがあるのを聞いて以来、試したいと思っていましたが、
conda(と、conda-forge)のリポジトリには見つからないので後回しにしていたのをやってみることにしました。

個人のMacでは環境構築をcondaに統一しているので、pipはあまり使いたくありません。
しかし免責事項の通り、ドキュメントではpipが推奨されています。

自分の端末ならよかろうということで(職場の端末で試す前の毒見も兼ねて)condaで入れることにしました。
使うのは conda skeleton です。
このブログのこちらの記事が参考になります。
PyPIのパッケージをcondaでインストールする方法

dot や python-graphviz など、必要ライブラリがすでに入っているのもあり、非常にスムーズにインストールできました。


# skeleton で dtreeviz インストール
$ conda skeleton pypi dtreeviz
$ conda build dtreeviz
$ conda install --use-local dtreeviz

# インストール結果確認
$ conda list dtreeviz
# packages in environment at {HOMEPATH}/.pyenv/versions/anaconda3-2019.10:
#
# Name                    Version                   Build  Channel
dtreeviz                  0.8.2            py37h39e3cac_0    local

# 一次ファイル削除
$ conda build purge

Pythonで
from dtreeviz.trees import dtreeviz
をやってみると無事にインポートできたので、導入は成功した様です。
これからの記事で使い方とか書いていきたいと思います。

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次元にエンコードしてくれます。
かなり活用の場がありそうです。

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
"""

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

gensimでword2vec

とっくに書いたと勘違いしていたのですが、まだ記事にしていなかったことに気づいたので、今更ですがgensimを使って単語の埋め込みを得る方法を紹介します。

word2vec自体の説明はそのうち書きたいですが一旦こちらをご参照ください。
wikipedia: Word2vec

gensim自体はもともとトピックモデル用のライブラリだったようで、
公式サイトのタイトルがズバリ「gensim: Topic modelling for humans」となっています。
ただ自分はもっぱらword2vec(skip-gram/CBOW)の為に使っています。

せっかくなので、このあいだのlivedoorニュースコーパスでやってみましょう。

テキストデータを単語単位で分かち書きした物を「配列で」準備し、
渡してあげればそれだけで学習してくれます。
他のライブラリはスペース区切りの文字列などを受け取ることが多いので、配列で準備する点だけは注意が必要ですね。

今回はgensimの使い方がメインなので、最低限の前処理だけして学習用データを準備します。


import MeCab
import pandas as pd

tagger = MeCab.Tagger("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")


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]
    # 配列で結果を返す
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    return token_list


# コーパスの見込み (df["text"]にニュース記事本文が入る。)
df = pd.read_csv("./livedoor_news_corpus.csv")


# 不要な文字を消す
stop_chars = "\n,.、。()()「」 『 』[]【】“”!! ??—:・■●★▲▼"
for stop_char in stop_chars:
    df["text"] = df["text"].str.replace(stop_char, " ")

# ユニコード正規化
df["text"] = df["text"].str.normalize("NFKC")
# アルファベットを小文字に統一
df["text"] = df["text"].str.lower()

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

# 作成されたデータのサンプル
print(sentences[:5])
"""
0    [2005年, 11月, から, 翌, 2006年, 7月, まで, 読売新聞, にて, 連...
1    [アンテナ, を, 張る, ながら, 生活, を, する, て, いく, ば, いい, 2月...
2    [3月2日, より, 全国ロードショー, と, なる, スティーブン, スピルバーグ, の,...
3    [女優, の, 香里奈, が, 18日, 都内, で, 行う, れる, た, 映画, ガール...
4    [5日, 東京, 千代田区, の, 内幸町, ホール, にて, 映画, キャプテン, アメリ...
Name: text, dtype: object
"""

さて、このsentencesを学習データとしてモデルを訓練します。
アルゴリズムは skip-gramとCBOWがありますが、今回はski-gramで試します。
使い方は簡単で、モデルをインポートして、インスタンス作成するときにデータを渡すだけです。
skip-gramを使いたい場合はsg=1を指定します。(0はCBOW)


from gensim.models import Word2Vec

word2vec_model = Word2Vec(
        sentences,
        sg=1,
    )

人によっては、次のようにインポート方法が違いますが、結果は同じです。


from gensim.models import word2vec

word2vec_model = word2vec.Word2Vec(
        sentences,
        sg=1,
    )

モデルの種類を指定する sg 以外にも実際には多くの引数をとるので、主なもの(自分がよく設定するもの)紹介しておきます。
=の右に書いているのは初期値です。

– size=100, # 埋め込むベクトルの次元
– window=5, # 前後何単語を予測するかの幅
– min_count=5, # 出現頻度の低い単語の足切り基準
– max_vocab_size=None, # 最大語彙数
– workers=3, # 学習の多重度
– sg=0, # skip-gram: 1 , CBOW: 0
– hs=0,
– negative=5, # negative sampling における負例の個数
– iter=5, # 学習回数

学習済みのモデルは次のように保存できます。ついでに、読み込みにコードも紹介。


# モデルの保存
word2vec_model.save("word2vec.model")

# 読み込み
# word2vec_model = Word2Vec.load("word2vec.model")

さて、モデルができたところで、使っていきましょう。
詳細全然説明してませんが、 king – man + woman = queen などの演算ができるということで、
一時非常に有名になったので、以下の例でも雰囲気伝わるのではないかなと思います。
それぞれの詳細な挙動についてはまた改めて説明記事書きたいです。


# 単語ベクトルを得る。 次の二つの書き方は結果は同じ
word2vec_model.wv["パソコン"]
word2vec_model.wv.get_vector("パソコン")

# 類似度の高い単語を得る。 topn引数で個数を指定(デフォルト10)
word2vec_model.wv.most_similar("パソコン", topn=5)
"""
[('pc', 0.7659528851509094),
 ('ノート', 0.7527473568916321),
 ('windows', 0.7253533601760864),
 ('companion', 0.7214531302452087),
 ('macos x', 0.7181501388549805)]
"""

# 単語の足し算、引き算は positive, negative で引数を指定する
# 下の例は 俳優 - 男 + 女 = 女優
word2vec_model.wv.most_similar(positive=["俳優", "女"], negative=["男"], topn=1)
# [('女優', 0.7674037218093872)]

# 二つの単語の類似度を得る
print(word2vec_model.wv.similarity("巨人", "阪神"))
# 0.8579513

# 仲間はずれ探し。
print(word2vec_model.wv.doesnt_match(["ロッテ", "オリックス", "ヤクルト", "ソニー"]))
# ソニー

# 語彙の一覧を取得する
word2vec_model.wv.vocab.keys()

# 埋め込みベクトルを全て得る。 (サイズは 語彙数*埋め込み次元)
word2vec_model.wv.vectors

livedoorニュースコーパスのファイルをデータフレームにまとめる

前回の記事でダウンロードしてきたlivedoorニュースコーパスのデータを扱いやすいようにデータフレームまとめてしまいます。

ファイルの中には、URL、日時、記事タイトルがあって、そのあとに記事本文が続く構成になっていますが、
それぞれ属性が違うので別列に取り出しています、

このブログでは再帰的なファイルの探索はglobを使うことが多かったのですが、
パスからファイル名やディレクトリ名を取り出して使いたかったので、pathlibの方を使いました。

では早速ですがコードの紹介です。


import pandas as pd
import pathlib

df = pd.DataFrame(columns=["category", "url", "time", "title", "text"])

for file_path in pathlib.Path("./text").glob("**/*.txt"):
    f_path = pathlib.Path(file_path)
    file_name = f_path.name
    category_name = f_path.parent.name

    # 特殊ファイルはスキップ
    if file_name in ["CHANGES.txt", "README.txt", "LICENSE.txt"]:
        continue

    with open(file_path, "r") as f:
        text_all = f.read()
        text_lines = text_all.split("\n")
        url, time, title, *article = text_lines
        article = "\n".join(article)

        df.loc[file_name] = [category_name, url, time, title, article]

# インデックスに使用していたファイル名を列の1つにする。
df.reset_index(inplace=True)
df.rename(columns={"index": "filename"}, inplace=True)

# ファイルに保存
df.to_csv("./livedoor_news_corpus.csv", encoding="utf-8_sig", index=None)

思っていたより短く簡単なコードであっさりできてしまったので前回の記事に含めておけばよかったですね。

livedoorニュースコーパスをダウンロードしてみる

職場ではテキストデータに不自由することはほぼないのですが、自学では自然言語処理のモデルを試す時は
大抵、20newsgroupsを使ってました。
参考: 20ニュースグループのテキストデータを読み込んでみる

ただ、やっぱり自宅での検証でも日本語データを使いことがあるので、
以前から存在だけは知っていたlivedoorニュースコーパスを試してみることにしました。
(wikipediaほど巨大なデータではなく、メロスほど少なくなく、ちょうどいいコーパスが欲しいことがよくあるのです)

これは株式会社ロンウイットさんが、収集して配布してくださっているデータです。
登録も何も必要なく、そのままダウンロードできるので非常に便利です。

ライブドアニュースの以下の9カテゴリのニュース記事が格納されています。
(ただし、時期は結構古いです。)

– トピックニュース
– Sports Watch
– ITライフハック
– 家電チャンネル
– MOVIE ENTER
– 独女通信
– エスマックス
– livedoor HOMME
– Peachy

配布ページはこちらです。
ここから、 ldcc-20140209.tar.gz というファイルをダウンロードします。
gzファイルで配布されているので、 tarコマンドで解凍しましょう。
(僕の環境はMacです)


# 展開
$ tar zfx ldcc-20140209.tar.gz
# 確認
$ ls text
CHANGES.txt    dokujo-tsushin kaden-channel  movie-enter    smax           topic-news
README.txt     it-life-hack   livedoor-homme peachy         sports-watch

展開すると text というディレクトリができ、中にさらに9個のディレクトリが含まれています。
それぞれのディレクトリの中に、
sports-watch-5069031.txt などの名前でテキストファイルが格納されています。
全部で 7378 ファイルあるようですが、 そのうち 9個 はライセンスファイル(LICENSE.txt)で、CHANGES.txt と README.txt を含むので、
データとしては 7378 – 11 = 7367 ファイルがデータとして使えます。


$ find . | grep txt | wc -l
    7378

LICENSEファイルは重要なので使う前に一通り読んでおきましょう。
各記事ファイルにはクリエイティブ・コモンズライセンス「表示 – 改変禁止」
https://creativecommons.org/licenses/by-nd/2.1/jp/)が適用されます。

記事ファイルの中身は次のフォーマットで作成されています。(README.txtの引用)

1行目:記事のURL
2行目:記事の日付
3行目:記事のタイトル
4行目以降:記事の本文

ファイルがバラバラなので、便利に使うには一回集約した方が良さそうですね。
少し考えてみて次の記事あたりで紹介したいと思います。

Kerasのモデルやレイヤーの識別子に付く番号をリセットする

Kerasでモデルを構築する時、モデルのオブジェクトやレイヤーにname引数で名前をつけないと、
区別できるように自動的に識別子(名前)をつけてくれています。

適当な例ですが、summary()で表示すると確認できる、
次のコードのsequential_13/lstm_12/dense_14 のようなやつです。


print(model.summary()) 
"""
Model: "sequential_13"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm_12 (LSTM)               (None, 10)                640       
_________________________________________________________________
dense_14 (Dense)             (None, 3)                 33        
=================================================================
Total params: 673
Trainable params: 673
Non-trainable params: 0
_________________________________________________________________
None
"""

jupyter notebookなどで試行錯誤しているとどんどん数字が大きくなっていくのですが、
ちょっと見た目が良く無いので、数値をリセットしたくなることがあります。
(そもそもnameで名前つけてあげればいいのですが。)

そのような場合、 バックエンド関数である、 clear_session() をつかうと、識別子をリセットできます。
ドキュメント: バックエンド

由来をよく知らないのですが、 backend は K という別名でインポートする慣習があるようです。


from tensorflow.keras import backend as K
K.clear_session()

# もう一度モデル構築
model = Sequential()
model.add(LSTM(10, input_shape=(40,5), activation="tanh"))
model.add(Dense(3, activation="softmax"))
print(model.summary()) 
"""
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm (LSTM)                  (None, 10)                640       
_________________________________________________________________
dense (Dense)                (None, 3)                 33        
=================================================================
Total params: 673
Trainable params: 673
Non-trainable params: 0
_________________________________________________________________
None
"""