多言語 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
"""

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

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行目以降:記事の本文

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

matplotlibの3次元プロットを回転するアニメーションで保存する

matplotlobで3次元のグラフを作る時、jupyter notebookではグリグリと動かしていろんな角度から確認することができます。
それをそのままこのブログに埋め込みたくて方法を探していたのですが良いのが見つからなかったので代用としてgifアニメーションを作ることにしました。
今回の記事では、Z軸を中心にぐるっと一周回転させてみます。

以前、 matplotlib.animation.ArtistAnimation を使ったgifの作り方は紹介したことがあるので、
今回は matplotlib.animation.FuncAnimation
を使う別の方法を紹介します。

参考記事: matplotlibでgif動画生成

(ちなみにFuncAnimation自体は、かなり柔軟に動画を作ることができ、
当然3Dプロットを回す以外の使い方もできます。)

可視化の対象は前回の記事のサッカーボールです。
変数Gには、前回の記事と同じグラフが格納されているものとしてください。
無駄に長いコードなので重複部分は今回のコードに入れていません。

さて、 FuncAnimation の使い方の紹介です。

この関数は、
fig, func, frames の3つの引数を渡して使います。
figはグラフを描写するfigureオブジェクトです。
framesにはリスト等を渡します。整数値を渡すとrange()と同じ動きになり、0からその整数値-1までの値を渡したのと同じになります。
この、framesに渡したリストの値を順番にfuncに渡して関数が実行され、それぞれの実行結果をつなげたものがアニメーションになります。

今回は少し工夫して、init_func という引数も使います。
これは、最初に一回だけ実行する関数を渡します。

1. init_func で 3次元にグラフをplotする
2. func で少しづつ回転する

という流れで、func では回転以外の操作をしないようにして少しだけ効率的にしました。


import numpy as np
import networkx as nx
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from mpl_toolkits.mplot3d import Axes3D

# G に サッカーボルー型のグラフデータを格納する処理は略

# ノードの座標を固定
pos = nx.spring_layout(G, dim=3)
# 辞書型から配列型に変換
pos_ary = np.array([pos[n] for n in G])

# plot する figureと、 Axesを準備する
fig = plt.figure(figsize=(10, 10), facecolor="w")
ax = fig.add_subplot(111, projection="3d")


# Axes にGraph をプロットする関数を準備
def plot_graph():
    ax.scatter(
        pos_ary[:, 0],
        pos_ary[:, 1],
        pos_ary[:, 2],
        s=200,
    )

    # ノードにラベルを表示する
    for n in G.nodes:
        ax.text(*pos[n], n)

    # エッジの表示
    for e in G.edges:
        node0_pos = pos[e[0]]
        node1_pos = pos[e[1]]
        xx = [node0_pos[0], node1_pos[0]]
        yy = [node0_pos[1], node1_pos[1]]
        zz = [node0_pos[2], node1_pos[2]]
        ax.plot(xx, yy, zz, c="#aaaaaa")


# 引数を受け取って図を回転させる関数を準備
def plt_graph3d(angle):
    ax.view_init(azim=angle*5)


# アニメーションを作成
ani = FuncAnimation(
    fig,
    func=plt_graph3d,
    frames=72,
    init_func=plot_graph,
    interval=300
)

# imagemagickで作成したアニメーションをGIFで書き出す
ani.save("rolling.gif", writer="pillow")

出力結果がこちらのgifです。

もともと対称性の高い図形なので、回転させるありがたみが薄かったかもしれないですね。

図形を回転させるところでは、
view_init
という関数を使いました。
elev と azim という二つの引数をとりますが、回転の向きが違います。
使うのは二つ目の azim の方なので注意が必要です。

NetworkXで作成したグラフを3次元にプロットする

NetworkXでグラフを可視化する時、
2次元だとエッジが多すぎていわゆる毛玉状態になり、わけがわからないけど3次元だと少しマシになるということがあったので、3次元でプロットする方法を紹介しておきます。

公式ドキュメントの 3D Drawing のページを見ると、
Mayavi2 というのを使う方法が紹介されています。
ただ、僕がこれを使ったことがないのと、Matplotlibで十分できそうだったので、Matplotlibでやってみました。
Mayavi2 はこれはこれで便利そうですし、可視化の幅を広げられそうなので近いうちに試します。

まず、可視化するグラフデータを生成します。
今回はいつもみたいにランダム生成ではなく、エッジを具体的に指定して構築しました。
出来上がるのはサッカーボール型の多面体です。
(実はこのデータ生成の方が3次元プロットより苦労しました。)


import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import networkx as nx
import numpy as np

# エッジデータを生成
edge_list = [
    (0, 1), (1, 2), (2, 3), (3, 4), (4, 0),
    (0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
    (5, 10), (10, 11), (11, 6), (6, 12), (12, 13),
    (13, 7), (7, 14), (14, 15), (15, 8), (8, 16),
    (16, 17), (17, 9), (9, 18), (18, 19), (19, 5),
    (11, 20), (20, 21), (21, 12), (13, 22), (22, 23),
    (23, 14), (15, 24), (24, 25), (25, 16), (17, 26),
    (26, 27), (27, 18), (19, 28), (28, 29), (29, 10),
    (21, 30), (30, 31), (31, 22), (23, 32), (32, 33),
    (33, 24), (25, 34), (34, 35), (35, 26), (27, 36),
    (36, 37), (37, 28), (29, 38), (38, 39), (39, 20),
    (31, 40), (40, 41), (41, 32), (33, 42), (42, 43),
    (43, 34), (35, 44), (44, 45), (45, 36), (37, 46),
    (46, 47), (47, 38), (39, 48), (48, 49), (49, 30),
    (41, 50), (50, 42), (43, 51), (51, 44), (45, 52),
    (52, 46), (47, 53), (53, 48), (49, 54), (54, 40),
    (50, 55), (51, 56), (52, 57), (53, 58), (54, 59),
    (55, 56), (56, 57), (57, 58), (58, 59), (59, 55),
]

# 生成したエッジデータからグラフ作成
G = nx.Graph()
G.add_edges_from(edge_list)

さて、データができたので早速3次元空間にプロットしてみましょう。
方法は簡単で、以前紹介したmatplotlibの3次元プロットの方法で、
ノードとエッジを順番に出力するだけです。

ノードの方はこちらの記事が参考になります。
参考: matplotlibで3D散布図
エッジの方はまだ直接的に消化はしていませんが、2次元空間に直線を引く時と同様に、
ax.plot で描けます。

実際にやってみたのが以下のコードです。
比較用に2次元にプロットしたものを横に並べました。


# spring_layout アルゴリズムで、3次元の座標を生成する
pos = nx.spring_layout(G, dim=3)
# 辞書型から配列型に変換
pos_ary = np.array([pos[n] for n in G])

# ここから可視化
fig = plt.figure(figsize=(20, 10), facecolor="w")
ax = fig.add_subplot(121, projection="3d")

# 各ノードの位置に点を打つ
ax.scatter(
    pos_ary[:, 0],
    pos_ary[:, 1],
    pos_ary[:, 2],
    s=200,
)

# ノードにラベルを表示する
for n in G.nodes:
    ax.text(*pos[n], n)

# エッジの表示
for e in G.edges:
    node0_pos = pos[e[0]]
    node1_pos = pos[e[1]]
    xx = [node0_pos[0], node1_pos[0]]
    yy = [node0_pos[1], node1_pos[1]]
    zz = [node0_pos[2], node1_pos[2]]
    ax.plot(xx, yy, zz, c="#aaaaaa")
    
# 比較用 : 通常の2次元軸へのプロット
ax = fig.add_subplot(122)
nx.draw_networkx(G, edge_color="#aaaaaa")

# 出来上がった図を表示
plt.show()

このコードで以下の図が出力されました。

3次元の方がサッカーボール として綺麗な形になっているのがみて取れると思います。
座標軸の数値はいらないのでこれを消すなどの工夫を加えたらもっと良いかもしれませんね。

blog記事には静止画で貼り付けましたが、
jupyter notebook で実行する時は、
%matplotlib notebook を実行しておくと、
3次元プロットはグリグリ動かして確認ができます。
結構便利なので機会があれば試してみてください。

追記(2022/11/16) : エッジの描写についての解説

3次元にプロットするところの、エッジ(線)の描写部分について質問いただきましたので、解説します。

該当コードはここですね。

for e in G.edges:
    print(e)
    node0_pos = pos[e[0]]
    node1_pos = pos[e[1]]
    xx = [node0_pos[0], node1_pos[0]]
    yy = [node0_pos[1], node1_pos[1]]
    zz = [node0_pos[2], node1_pos[2]]
    ax.plot(xx, yy, zz, c="#aaaaaa")

まず、最初のfor文ですが、これはグラフのエッジをループさせています。変数eエッジの中の一つが格納され、それがどのノードからどのノードへのエッジなのかの情報がただのタプルとして入ってます。1個目だけprintしてみます。一番最初に作ったエッジデータの1個目ですね。

for e in G.edges:
    print(type(e))
    print(e)
    break #  打ち切り
"""
<class 'tuple'>
(0, 1)
"""

エッジが(0, 1)ですから、まずノード0からノード1へ線をひこう、というのが以降の処理です。そのために、ノード0とノード1はどの座標に配置されているのかの情報が必要になります。

その座標が spring_layout ってアルゴリズムで推定して、ノード:座標の形でposって変数に辞書で入ってます。(上の方のコード参照)
中身を見ておきましょう。全ノード分含まれているのですが、最初の5件blogに載せます。

from pprint import pprint


pprint(pos)
"""
{0: array([-0.6114604 ,  0.62195763,  0.31867187]),
 1: array([-0.5150625 ,  0.74024425, -0.01842863]),
 2: array([-0.17471886,  0.96503904,  0.00644528]),
 3: array([-0.02757867,  0.98970947,  0.35055788]),
 4: array([-0.30497656,  0.77198962,  0.53307487]),
# 以下略
""" 

e = (0, 1) ですから、 e[0] = 0, e[1] = 1です。(最初のedgeはインデックスと中身が一致しててややこしく、すみません)
これを使って、エッジが繋いでる2頂点の座標を取得します。アルゴリズムが乱数使っているので具体的な値は実行するたびに変わりますのでご注意ください。

node0_pos = pos[e[0]]
node1_pos = pos[e[1]]

# 一つ目のノードの座標
print(node0_pos)
# [-0.6114604   0.62195763  0.31867187]

# 二つ目のノードの座標
print(node1_pos)
# [-0.5150625   0.74024425 -0.01842863]

具体的な座標が定まったので、この2点の間に線を引きます。これは、Axes3Dをimportした状態のmatplotlibのplotメソッドで実行します。

この時にax.plot(1点目の座標, 2点目の座標)と渡すと動かないのです。
2次元のplotにおいても ax.plot(x座標の一覧, y座標の一覧) とデータを渡すように、3次元plotでもax.plot(x座標の一覧, y座標の一覧, z座標の一覧)とデータを渡す必要があります。

xxとかyyとか変な変数名で恐縮ですが、それを続くコードでやってます。

# x座標, y座標, z座標をそれぞれ取り出し
xx = [node0_pos[0], node1_pos[0]]
yy = [node0_pos[1], node1_pos[1]]
zz = [node0_pos[2], node1_pos[2]]

# 中身確認
print(xx)
# [-0.6114604015979618, -0.5150625045997115]
print(yy)
# [0.6219576319265612, 0.740244253223188]
print(zz)
# [0.3186718713992598, -0.01842863446943393]

そして、出来上がったxx,yy,zz を ax.plot()に渡してエッジが1本引けたことになります。
c=”#aaaaaa” はただの色設定(灰色)なので問題ないと思います。

これで1つ引けるので、あとはfor文で各エッジを変数eに格納して順次繰り返しています。

globでサブフォルダを含めて再帰的にファイルを探索する

普段は、DBに格納された扱いやすいデータや1ファイルにまとめられたデータばかり扱っていて、
散らばったファイルからデータを拾ってくることは少ない恵まれた環境で仕事しています。
しかし、久々にあるフォルダ配下に散ってるファイルを再帰的に探してまとめて処理する機会があったのでそのメモです。

以前、特定のフォルダの直下のファイルは、 globで手軽に見つけられるという記事を書きました。
参考: globで手軽にファイル名の一覧を取得する

今回は pathlib を紹介しようと思っていたのですが、
よく globのドキュメントを見ると、 バージョン 3.5 から、再帰的なglobが実装されていたんですね。
参考: glob — Unix 形式のパス名のパターン展開

ということでこちらを使ってみます。
recursive に True を指定し、 pathname の中に ** を含めればいいようです。

拡張子付きのファイルパスだけリストアップするには次のように書きます。


import glob
for f in glob.glob("./**/*.*", recursive=True):
    print(f)

"""
./001.txt
./folder01/002.txt
./folder01/003.sql
./folder01/subfolder011/004.txt
./folder01/subfolder011/005.sql
./folder02/006.png
./folder02/subfolder021/007.gif
"""

recursive=False (デフォルト) の場合と一応比較しておきましょう。


for f in glob.glob("./**/*.*", recursive=False):
    print(f)

"""
./folder01/002.txt
./folder01/003.sql
./folder02/006.png
"""

for f in glob.glob("./*/*.*", recursive=False):
    print(f)

"""
./folder01/002.txt
./folder01/003.sql
./folder02/006.png
"""

比較用に ** を * に変えたものも一緒に載せましたが、
recursive=False の場合は、 ** は * と同じ挙動しかしていないことがわかります。

recursive=True にすると、 ** は複数階層のフォルダ(ディレクトリ)も含めて探索してくれています。

特定拡張子のファイルのみ欲しい時は、 pathname の記述で指定しましょう。
ディレクトリだけ指定したい時は / で終えれば可能です。
また、 glob.glob の代わりに、 glob.iglob を使うと、結果をリストではなくイテレーターで返してくれます。


for f in glob.iglob("./**/*.txt", recursive=True):
    print(f)

"""
./001.txt
./folder01/002.txt
./folder01/subfolder011/004.txt
"""


for f in glob.iglob("./**/", recursive=True):
    print(f)

"""
./
./folder01/
./folder01/subfolder012/
./folder01/subfolder011/
./folder02/
./folder02/subfolder021/
"""

望む結果が得られました。