pyLDAvisを用いたトピックモデル(LDA)の可視化 (scikit-learn編)

久々にトピックモデルの記事です。
トピックモデルを実行した時、各トピックが結局何の話題で構成されているのかを確認するのは結構面倒です。
また、各トピック間の関係などもなかなか掴みづらく解釈しにくいところがあります。
この問題が完全に解決するわけではないのですが、
pyLDAvisと言うライブラリを使うと、各トピックを構築する単語や、トピック間の関係性を手軽に可視化することができます。
しかもインタラクティブな可視化になっており、ポチポチ動かせるので眺めてみるだけで楽しいです。

まずは、可視化するモデルがないことには始まらないので、いつものライブドアニュースコーパスでモデルを学習しておきます。
トピック数はいつもより大目に20としました。(雑ですみません。いつも通り、前処理も適当です。)


import pandas as pd
import MeCab
import re
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.decomposition import LatentDirichletAllocation

# データの読みこみ
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)]
    # 数値を含む単語も除く
    token_list = [t for t in token_list if not re.match("\d", t)]
    return " ".join(token_list)


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

print(df["text_tokens"][:5])
"""
0    読売新聞 連載 直木賞 作家 角田光代 初 長編 サスペンス 八日目の蝉 檀れい 北乃きい ...
1    アンテナ 張る 生活 映画 おかえり、はやぶさ 公開 文部科学省 タイアップ 千代田区立神田...
2    全国ロードショー スティーブン・スピルバーグ 待望 監督 最新作 戦火の馬 アカデミー賞 有...
3    女優 香里奈 都内 行う 映画 ガール 公開 女子高生 限定 試写 会 サプライズ 出席 女...
4    東京都千代田区 内幸町 ホール 映画 キャプテン・アメリカ/ザ・ファースト・アベンジャー 公...
Name: text_tokens, dtype: object
"""

# テキストデータをBOW形式に変換する
tf_vectorizer = CountVectorizer(
    token_pattern='(?u)\\b\\w+\\b',
    max_df=0.90,
    min_df=10,
)
tf = tf_vectorizer.fit_transform(df["text_tokens"])

# LDAのモデル作成と学習
lda = LatentDirichletAllocation(n_components=20)
lda.fit(tf)

さて、この後が本番です。
pyLDAvisのドキュメントはこちらですが、
scikit-learnのLDAの可視化の方法はこっちのnotebookを参照すると良いです。
Jupyter Notebook Viewer pyLDAvis.sklearn

pyLDAvis.sklearn.prepareに、LDAのモデル、BOW(かtf-idf)の学習に用いたデータ、変換に用いた辞書を順番に渡すだけです。
jupyter notebook 上で結果を表示するためにpyLDAvis.enable_notebook()というメソッドも呼び出しています。


import pyLDAvis
import pyLDAvis.sklearn

# jupyter notebookで結果を表示するために必要
pyLDAvis.enable_notebook()

pyLDAvis.sklearn.prepare(
    lda, # LDAのモデル (LatentDirichletAllocation のインスタンス)
    tf, # BOWデータ (sparse matrix)
    tf_vectorizer, # CountVectorizer もしくは TfIdfVectorizer のインスタンス
)

実行すると、以下のような図が表示されます。blogに貼ったのは画像ですが、実際は左の散布図で注目するトピックを切り替えたり、
右側の単語一覧から単語を選択することで、各トピックにその単語がどれだけ含まれるかみることができます。

次のように、 prepare メソッドの戻り値を受け取っておいて、 pyLDAvis.save_htmlメソッドに渡すことで、htmlとして保存することもできます。
このブログにも貼っておくので、是非触ってみてください。

HTMLファイルへのリンク: ldavis-sample

7番目と19番目や、5番目と13番目のトピックが大きくかぶっていて、トピック数が少し多すぎたかなとか、そう言った情報も得られますし、
単語の一覧から各トピックが何の話題なのかざっくりとつかめますね。

表示する単語数は、 R(デフォルト30)と言う引数で指定できます。
また、左側のトピックの位置関係表示に使う次元削減方法は、mdsと言う引数で変更もできます。
指定できるのは現在のところ、pcoa, mmds, tsne の3種類です。

pytablewriterで、pandasのDataFrameからMarkdownの表を生成する

前回の記事に続いて、pytablewriterの機能の紹介です。
参考: pytablewriterでMarkdownの表を生成する

今回はpandasのDataFrameからMarkdownを生成します。
個人的には使う頻度が高いのはこちらです。

まずサンプルとなるデータを作っておきましょう。


import pandas as pd

df = pd.DataFrame(
    data=[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
    columns=['col1', 'col2', 'col3'],
    index=["data1", "data2", "data3", "data4"]
)
print(df)
"""
       col1  col2  col3
data1     1     2     3
data2     4     5     6
data3     7     8     9
data4    10    11    12
"""

早速、このDataFrameをMarkdownに変更します。

利用するのは、 from_dataframe と言うメソッドです。
ドキュメント: 4.4.3. Using pandas DataFrame as tabular data source

次のコードの例のように、 from_dataframe() にDataFrameを渡し、
write_table()メソッドを呼び出すと標準出力にマークダウンが出力されます。


from pytablewriter import MarkdownTableWriter


writer = MarkdownTableWriter()
writer.from_dataframe(df)
writer.write_table()
"""
|col1|col2|col3|
|---:|---:|---:|
|   1|   2|   3|
|   4|   5|   6|
|   7|   8|   9|
|  10|  11|  12|
"""

配列から生成するときはメソッドではなく属性にデータを代入していたのでちょっと使い方が違いますね。

さて、結果をご覧の通り、デフォルトではindexは出力されません。
もしindexが必要な場合は、 from_dataframe()を呼び出す時に、 add_index_column=True を指定する必要があります。


writer = MarkdownTableWriter()
writer.from_dataframe(df, add_index_column=True)
writer.write_table()
"""
|     |col1|col2|col3|
|-----|---:|---:|---:|
|data1|   1|   2|   3|
|data2|   4|   5|   6|
|data3|   7|   8|   9|
|data4|  10|  11|  12|
"""

これでindexも出力されました。

pytablewriterでMarkdownの表を生成する

集計結果を社内に共有する時など、Markdownでテーブルを書く機会はそこそこ頻繁にあります。
(実はBacklogを使っているので、正式なMarkdownとは違うのですが似たようなものです。)

毎回、Vimで置換等を使って書いて、それを貼り付けたりとかしているのですが、
Pythonに pytablewriter という便利なライブラリがあるのを見つけたのでそれを紹介します。
自分の場合は pandas の DataFrameを変換することが多く、それ専用の関数もあるのですが、
まずはこの記事ではドキュメントの Basic usage に沿った基本的な使い方を紹介します。

ドキュメント: 4.1. Basic usage — pytablewriter

ドキュメントの例を参考に、少し書き換えたコードでやってみます。


from pytablewriter import MarkdownTableWriter

writer = MarkdownTableWriter()
writer.table_name = "zone"
writer.headers = ["zone_id", "country_code", "zone_name"]
writer.value_matrix = [
    ["1", "AD", "Europe/Andorra"],
    ["2", "AE", "Asia/Dubai"],
    ["3", "AF", "Asia/Kabul"],
    ["4", "AG", "America/Antigua"],
    ["5", "AI", "America/Anguilla"],
]

writer.write_table()
"""
# zone
|zone_id|country_code|   zone_name    |
|------:|------------|----------------|
|      1|AD          |Europe/Andorra  |
|      2|AE          |Asia/Dubai      |
|      3|AF          |Asia/Kabul      |
|      4|AG          |America/Antigua |
|      5|AI          |America/Anguilla|
"""

テーブル名、ヘッダー、データを順番に指定してあげて、
その後、write_tableを呼び出すとマークダウンのテキストを生成してくれますね。

カラム名は指定しないと、A,B,C,Dとアルファベットが割り当てられるようです。


writer = MarkdownTableWriter()
writer.value_matrix = [
    ["1", "AD", "Europe/Andorra"],
    ["2", "AE", "Asia/Dubai"],
    ["3", "AF", "Asia/Kabul"],
    ["4", "AG", "America/Antigua"],
    ["5", "AI", "America/Anguilla"],
]
writer.write_table()
"""
| A | B |       C        |
|--:|---|----------------|
|  1|AD |Europe/Andorra  |
|  2|AE |Asia/Dubai      |
|  3|AF |Asia/Kabul      |
|  4|AG |America/Antigua |
|  5|AI |America/Anguilla|
"""

引数で渡すのではなく、.value_matrixなどの変数にデータを渡すのが特徴的で、
ちょっと使い方に癖がありますが手軽にマークダウンを生成できるので便利ですね。

n進法(特に2進法,8進法,16進法)の文字列を10進法の数値に変換する

以前書いた記事の逆向きの操作が必要になったのでそのメモです。
参考: Pythonの数値を2進法、8進法、16進法の表記に変換する

結論から言うと intメソッドでできます。
そのまま数字として解釈できる文字列(要するに0から9と+-)しか受け付けないと思い込んでいたのですが、
ドキュメントを読んでみると、2つ目の引数baseで、基数を指定できます。
ここに8なり16なりを渡してあげれば良いようです。


print(int("FF", 16))
# 255

print(int("100", 8))
# 64

base は 0と2~36までの引数を指定できます。(1はダメ)
36進法は 0~9までの数値と、a~z(A~Zでも可)の文字を使った表記ですね。


print(int("AZ09", 36))
# 511929

base=0 を指定した場合、文字列の先頭のプレフィックスを使って、2進法,8進法,10進法,16進法を判定するようです。


# 2進法
print(int("0b100", 0))
# 4

# 8進法
print(int("0o100", 0))
# 64

# 10進法
print(int("100", 0))
# 100

# 16進法
print(int("0x100", 0))
# 256

PythonでROT13暗号

業務で使う機会があったので紹介します。

非常に単純な暗号方式のに、シーザー暗号と呼ばれるものがあります。
参考: シーザー暗号 – Wikipedia

暗号鍵として整数nを決め、アルファベットをn文字ずらしたものに変換するものです。
$n=3$ であれば、aはdに、GはJに、Zはまた先頭に戻ってCに変換されます。
複合はその逆です。

この暗号鍵を13にしたものをROT13と呼びます。
参考: ROT13 – Wikipedia
アルファベットが大文字小文字それぞれ26文字であり、そのちょうど半分をスライドさせることから、
2回適用すると元に戻ると言う特徴があります。要するに暗号化と複合が全く同じと言う特徴があります。

これはPythonでは、 codecs という標準ライブラリの中に実装されています。
参考: codecs — codec レジストリと基底クラス — Python 3.9.1 ドキュメント

早速やってみます。


import codecs


text = "Hello World"
print(codecs.encode(text, "rot_13"))
# Uryyb Jbeyq

# もう一度適応すると元に戻る
print(codecs.encode("Uryyb Jbeyq", "rot_13"))
# Hello World

# encode と decodeの結果が等しい
print(codecs.decode(text, "rot_13"))
# Uryyb Jbeyq

簡単ですね。

コード中の “rot_13” ですが、 “rot13″というアンダーバー無しのエイリアスも用意されています。
そのため、以下のコードでも動きます。


print(codecs.encode(text, "rot13"))
# Uryyb Jbeyq

このROT13ですが、アルファベット以外の文字にには作用しません。数字や日本語の文字などはそのまま残ります。


print(codecs.decode("ROT13暗号はアルファベット以外そのまま", "rot13"))
# EBG13暗号はアルファベット以外そのまま

2021年のご挨拶と今年の方針

新年明けましておめでとうございます。本年もよろしくお願いします。

年末年始の間、このブログの目標をどうしようかなと考えていたのですが、
一旦は昨年と同じペースを維持することを目指したいと思います。
と言うことで、今年も年間100記事の更新を目指します。
随分前からネタ切れ感もあるのですが、
100記事の目標があればネタ探しを兼ねたインプットにも力を入れられると思います。

気合を入れて書いた記事よりもちょっとした小ネタのような記事の方がニーズがあることもはっきりしてきたことですし、
何か調べたら何か書く、くらいのテンションで今年もやっていきたです。

また、技術記事以外の記事も増やしたい(と昨年も言ってたのに結局書かなかった)ので、
今年こそポエム記事も書いていきたいと思います。