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

plot_roc_curveを試す

今回もscikit-learn 0.22.0 の新機能を試します。
今回はROC曲線を書いてくれる、plot_roc_curveです。
ドキュメント: sklearn.metrics.plot_roc_curve

元々、 roc_curve という機能はあったのですが、これを可視化するのは少しだけ面倒だったので結構期待していました。

ただ、試してみたところ、plot_roc_curveは2値分類のモデルにしか対応していないようです。
roc_curveは引数のpos_labelに対象のラベルを指定してあげれば多値分類にも対応しているので、今後に期待します。

ということで、2値分類のダミーデータを作成して、試してみます。
綺麗に分類できる問題だとROC曲線の価値がよくわからないので線形分離不可能な問題にしています。
最後にグラフを二つ出力していますが、左のが、生成したテストデータとモデルの決定境界、右側が、ROC曲線です。


from sklearn.metrics import plot_roc_curve
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import numpy as np
import matplotlib.pyplot as plt

# 綺麗に線形分類することのできないダミーデータの生成。
X0 = np.clip(2 * np.random.randn(500, 2) + np.array([1, 1]), -7, 7)
X1 = np.clip(2 * np.random.randn(500, 2) + np.array([-1, -1]), -7, 7)
X = np.concatenate([X0, X1])
y = np.array([0]*500 + [1]*500)

# 学習データとテストデータに分ける
X_train, X_test, y_train, y_test = train_test_split(
                                    X,
                                    y,
                                    test_size=0.2,
                                    stratify=y,
                                )
# モデルの作成と学習
clf = LogisticRegression()
clf.fit(X_train, y_train)

# ここから可視化
fig = plt.figure(figsize=(12, 6), facecolor="w")
ax = fig.add_subplot(1, 2, 1, aspect='equal', xlim=(-7, 7), ylim=(-7, 7))
ax.set_title("テストデータと決定境界")

# データを散布図で表示する
ax.scatter(X_test[y_test == 0, 0], X_test[y_test == 0, 1], alpha=0.7)
ax.scatter(X_test[y_test == 1, 0], X_test[y_test == 1, 1], alpha=0.7)

# 決定境界を可視化する
X_mesh, Y_mesh = np.meshgrid(np.linspace(-7, 7, 401), np.linspace(-7, 7, 401))
Z_mesh = clf.predict(np.array([X_mesh.ravel(), Y_mesh.ravel()]).T)
Z_mesh = Z_mesh.reshape(X_mesh.shape)
ax.contourf(X_mesh, Y_mesh, Z_mesh, alpha=0.1)

ax = fig.add_subplot(1, 2, 2, aspect='equal', xlim=(0, 1), ylim=(0, 1))
ax.set_title("ROC曲線",)
plot_roc_curve(clf, X_test, y_test, ax=ax)

plt.show()

出力された図がこちら。

コード全体が長いのでわかりにくくて恐縮ですが、 ROC曲線自体は1行で出力できており、非常に手軽です。
また、AUCも同時に出力してくれています。

scikit-learnにスタッキングのクラスが追加されたので使ってみる

前回に引き続き、scikit-learn 0.22.0 の新機能の紹介です。

複数の機械学習モデルを組み合わせて使う方法の一つに、スタッキング(Stacking)があります。
簡単に言えば、いつくかの機械学習のモデルの予測結果を特徴量として、別のモデルが最終的な予測を行うものです。
アイデアは単純で、効果もあるらしいのですが、これをscikit-learnでやろうとすると、少し面倒だったので僕はあまり使ってきませんでした。

それが、 StackingClassifierStackingRegressor という 二つの機能がscikit-learnに実装され、
手軽にできそうな期待が出てきたので、試してみました。
とりあえず、 StackingClassifier (分類)の方をやってみます。

サンプルのコードを参考しながら、 iris を digitsに変えてやってみました。
ランダムフォレストと、線形SVMの出力を特徴量にして、ロジスティック回帰で予測しています。


from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.ensemble import StackingClassifier

# データの準備
X, y = load_digits(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        stratify=y,
        random_state=42,
    )


# 1段目として、二つのモデルを構築。
# 初期値の max_iter では収束しなかったので大きめの値を設定
estimators = [
        ('rf', RandomForestClassifier(n_estimators=10, random_state=42)),
        ('svr', make_pipeline(
                StandardScaler(),
                LinearSVC(max_iter=4000, random_state=42)
                )
            )
        ]
clf = StackingClassifier(
    estimators=estimators,
    final_estimator=LogisticRegression(max_iter=600),
)
clf.fit(X_train, y_train)
print("正解率:", clf.score(X_test, y_test))
# 正解率: 0.96

そこそこの正解率が出ましたね。
ちなみに、同じパラメータのランダムフォレストと線形SVM、の単体の正解率は次のようになります。


make_pipeline(
            StandardScaler(),
            LinearSVC(max_iter=4000, random_state=42)
        ).fit(X_train, y_train).score(X_test, y_test)

# 0.9511111111111111

RandomForestClassifier(
    n_estimators=10,
    random_state=42
).fit(X_train, y_train).score(X_test, y_test)

# 0.9333333333333333

確かにスタッキングしたモデルの方が正解率が高くなっていました。

scikit-learnのversion 0.22.0 がリリースされたので、混同行列の可視化機能を試す

先日、scikit-learnの新しいバージョンがリリースされていたことに気づきました。
Version 0.22.0 December 3 2019

色々、機能が追加されていたり、改善が施されていたりしますが、何かパッと試せるものを試してみようと眺めてみたのですが、
新機能の中に metrics.plot_confusion_matrix というのが目についたのでこれをやってみることにしました。

元々、 confusion_matrix を計算する関数はあるのですが、
出力がそっけないarray で、自分でlabelを設定したりしていたのでこれは便利そうです。

まず、元々存在する confusion_matrix で混同行列を出力してみます。


from sklearn.svm import SVC
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import confusion_matrix

# データの読み込み
iris = load_iris()
X = iris.data
y = iris.target
class_names = iris.target_names

X_train, X_test, y_train, y_test = train_test_split(
                                        X,
                                        y,
                                        test_size=0.2,
                                        random_state=0,
                                        stratify=y,
                                    )

# モデルの作成と学習
classifier = SVC(kernel='linear', C=0.01).fit(X_train, y_train)
y_pred = classifier.predict(X_test)
print(confusion_matrix(y_test, y_pred))

"""
[[10  0  0]
 [ 0 10  0]
 [ 0  3  7]]
"""

最後の行列が出力された混同行列です。各行が正解のラベル、各列が予測したラベルに対応し、
例えば一番下の行の中央の3は、正解ラベルが2なのに、1と予測してしまったデータが3件あることを意味します。
(とても便利なのですが、行と列のどちらがどっちだったのかすぐ忘れるのが嫌でした。)

さて、次に sklearn.metrics.plot_confusion_matrix を使ってみます。
どうやら、confusion_matrixのように、正解ラベルと予測ラベルを渡すのではなく、
モデルと、データと、正解ラベルを引数に渡すようです。
こちらにサンプルコードもあるので、参考にしながらやってみます。
normalizeに4種類の設定を渡せるのでそれぞれ試しました。

データとモデルは上のコードのものをそのまま使います。


import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import plot_confusion_matrix

# 表示桁数の設定
np.set_printoptions(precision=2)

# 可視化時のタイトルと、正規化の指定
titles_options = [
        ("Confusion matrix, without normalization", None),
        ("Normalized confusion matrix: true", 'true'),
        ("Normalized confusion matrix: pred", 'pred'),
        ("Normalized confusion matrix: all", 'all'),
    ]

fig = plt.figure(figsize=(10, 10), facecolor="w")
fig.subplots_adjust(hspace=0.2, wspace=0.4)
i = 0
for title, normalize in titles_options:
    i += 1
    ax = fig.add_subplot(2, 2, i)
    disp = plot_confusion_matrix(
                        classifier,
                        X_test,
                        y_test,
                        display_labels=class_names,
                        cmap=plt.cm.Blues,
                        normalize=normalize,
                        ax=ax,
                    )

    # 画像にタイトルを表示する。
    disp.ax_.set_title(title)

    print(title)
    print(disp.confusion_matrix)
plt.show()

"""
Confusion matrix, without normalization
[[10  0  0]
 [ 0 10  0]
 [ 0  3  7]]
Normalized confusion matrix: true
[[1.  0.  0. ]
 [0.  1.  0. ]
 [0.  0.3 0.7]]
Normalized confusion matrix: pred
[[1.   0.   0.  ]
 [0.   0.77 0.  ]
 [0.   0.23 1.  ]]
Normalized confusion matrix: all
[[0.33 0.   0.  ]
 [0.   0.33 0.  ]
 [0.   0.1  0.23]]
"""

最後に表示された画像がこちら。

今回例なので4つ並べましたが、一つだけ表示する方が カラーバーの割合がいい感じにフィットします。
軸に True label、 Predicated label の表記を自動的につけてくれるのありがたいです。

kerasで使える活性化関数を可視化する

kerasではあらかじめ様々な活性化関数が用意されています。
正直普段使ってないものもあるのですが、どのようなもがあるのか一通り把握しておきたかったので可視化を試みました。
原論文か何か読んで数式を順番に実装しようかと思ったのですが、
Activationレイヤーだけから構成される超単純なモデルを作るのが手軽だと気付いたのでやってみました。

今回はこちらのページにある、 利用可能な活性化関数 を対象にしました。
(この他にもより高度な活性化関数はいくつか用意されています。)

コードは次のようになります。


from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Activation
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(-5, 5, 101).reshape(-1, 1)

# 絶対値の範囲が 有限なもの
activations_0 = [
    "sigmoid",
    "tanh",
    "hard_sigmoid",
    "softsign",
    ]

# 絶対値の範囲に上限がないもの
activations_1 = [
    "elu",
    "selu",
    "softplus",
    "relu",
    "linear",
]

fig = plt.figure(figsize=(10, 8))
for i, activations in enumerate([activations_0, activations_1]):
    ax = fig.add_subplot(2, 1,  i+1)
    for activation_str in activations:
        model = Sequential()
        model.add(
            Activation(
                activation_str,
                input_shape=(1, ),
            )
        )
        y = model.predict(x).ravel()
        ax.plot(x, y, label=activation_str)

    ax.legend()

plt.show()

出力結果はこちら。