Pythonで非負値行列分解

これもずっと前に記事にしたような気がしていたのですが、最近ちょっと仕事で使おうと思って自分のブログで検索したら書いてなかったことがわかったのでこの機会に記事にします。

行列を複数の行列の積に分解する方法は複数ありますが、その中でも非負値行列分解というsy方があります。

これは、元の行列の全ての要素が0以上(0を許すので正値ではなく非負値といいます)の場合に使える分解方法です。一般的には低rankな二つの行列で、それぞれの行列の全ての要素も0以上の行列の積に分解します。

数式で言うと、$V \approx W \times H$ ですね。 $V$が$M\times N$行列だとした場合、分解後のrankを$K$とすると、$W$は$M\times K$行列、$H$は$K\times N$行列になります。

ここで$V$は元の行列(データ行列)で、行数はサンプル数、列数は特徴数です。
$W$は基底行列になり各行は元データの異なる要素の「基底」や「パターン」を示します。
そして、$H$は係数行列で、各列は基底行列の要素がどの程度元のデータに寄与しているかを示します。

この分解によって、元のデータの背後に潜在する低次元のパターンや構造を捉えることができます。

分解後の要素が全て非負なので、分解結果を加法的に扱えるのが利点です。負の値が混ざってるとある値が大きかった時にそれに掛け算される係数が正なのか負なのか考慮して解釈しないといけないですがここが絶対0以上と保証されていると評価されやすいですね。

また、次元削減やデータ量の削減にも有宇高です。この用途で使われるため、$K$は小さな値が採用されやすいです。

この非負値行列分解はが画像処理とか音声解析、推薦システムの中で活用されていますね。

さて、scikit-learnを使って実際にやってみましょう。乱数で生成した行列でやってみますね。

ドキュメントはこちらです。
参考: NMF — scikit-learn 1.5.2 documentation

import numpy as np
from sklearn.decomposition import NMF

# サンプルデータ生成
# 乱数で5x4の非負行列を作成
np.random.seed(0)
V = np.random.randint(0, 6, size=(5, 4))

# NMFを適用、ランク(分解する際の次元)を2に設定
model = NMF(n_components=2, init='random', random_state=0)
W = model.fit_transform(V)  # 基底行列W
H = model.components_       # 係数行列H

# 分解結果を表示
print("元の行列 V:")
print(V)
"""
元の行列 V:
[[4 5 0 3]
 [3 3 1 3]
 [5 2 4 0]
 [0 4 2 1]
 [0 1 5 1]] 
 """

print("\n基底行列 W:")
print(W)
"""
基底行列 W:
[[1.97084006 0.        ]
 [1.35899323 0.33537722]
 [0.90516396 1.61014842]
 [0.76977917 0.6670942 ]
 [0.         1.7698575 ]]
"""

print("\n係数行列 H:")
print(H)
"""
係数行列 H:
[[2.09286365 2.49350723 0.         1.50627845]
 [0.63319723 0.41601049 2.69948881 0.        ]]
"""

# 元の行列の近似値を計算
V_approx = np.dot(W, H)

print("\n近似された行列 V_approx:")
print(V_approx)
"""
近似された行列 V_approx:
[[4.12469952 4.91430393 0.         2.96863391]
 [3.05654746 3.52817989 0.90534704 2.04702222]
 [2.91392627 2.92687151 4.34657765 1.36342897]
 [2.03344504 2.19696811 1.80081332 1.15950177]
 [1.12066887 0.73627928 4.7777105  0.        ]]
"""

見ての通りでちょっとクセがありますね。
基底行列の方はtransformで元のデータを変換して取得し、係数行列の方がcomponents_に入っています。

さて、近似した行列ですが、元の行列が純粋にただの乱数で生成されたもので、通常のデータであれば背景にあるはずの隠れた構造とかを一切持たないものだった割に結構近い値で近似できてるのではないでしょうか。

久しぶりに使うと、どっちの行列がどっちだっけとか、転地必要だったっけ、とか色々迷うのですが慣れれば手軽に扱えるので機会があれば試してみてください。

sentencepieceを使ってみた その2 (model_type: word)

前回の記事に続いて、sentencepieceの話題です。
今回はmodel_type=”word”を使ってみます。

最初に、自分が勘違いしていたことについて説明します。このmodel_type=”word”ですが、自分はてっきりいわゆるsubwordのアルゴリズムが実装されているものだと勘違いしていました。あらかじめ分かち書きしたテキストを読み込み、低頻度語を洗い出して、より小さい単位、最終的には文字単位に分解して未知語を無くしてくれるのかなと。
しかし、実際の挙動は、分かち書きしたテキストデータからそのまま語彙を学習し、idへ変換する機能のようです。下記のサンプルファイルでも、charとwordは同じカテゴリで扱われていますね。
参考: sentencepiece/sentencepiece_python_module_example.ipynb at master · google/sentencepiece · GitHub

これだとあまりありがたみがなく、わざわざ記事にするほどでもなかったのですが、前回の記事で次はこれ紹介するって書いちゃったのでやってみます。

ドキュメントに、「The input sentence must be pretokenized when using word type.」と書かれている通り、model_typeでwordを指定する場合は、入力データをあらかじめ単語に区切っておく必要があります。

早速、入力データを作ってやってみましょう。前回同様ライブドアニュースコーパスのデータを使い、今回はMeCabで分かち書きしてテキストファイルに書き出しておきます。
(ちなみに、試しに分かち書きして無い状態でsentencepieceに食わせてみるのも実験しましたが、確かにすごく長い単語ばかりの変な学習結果になりました。)
pretokenized の詳細な仕様が明記されて無いですが、常識的に考えて半角スペースあたりで区切れば大丈夫です。(実際それで動きました。)

import pandas as pd
import MeCab
import subprocess


# ライブドアニュースコーパス読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")

# 辞書のディレクトリ取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 分かち書きを出力する設定でTaggerを生成
tagger = MeCab.Tagger(f"-O wakati -d {dicdir}/ipadic")

# 分かち書き
df["tokens"] = df["text"].apply(tagger.parse)
# 末尾に改行コードがついているので取り除く
df["tokens"] = df["tokens"].str.strip()

# ファイル書き出し
with open('livedoor_tokenized_corpus.txt', 'w') as w:
    for text_line in df["tokens"]:
        w.write(text_line + "\n")

データができたので、前回の別のモデルと同様に、語彙を学習させます。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_tokenized_corpus.txt",  # コーパスファイル
    model_type="word",  # デフォルト
    model_prefix='livedoor_word',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

さて、保存されたモデルファイルを読み込んで使ってみましょう。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_word.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"
# model_type="word"の場合は、入力データも分かち書きして渡す必要がある
sample_tokens = tagger.parse(sample_text).strip()
print(sample_tokens)
# これから の 年度 末 に 向け て 、 引越し を 考え て いる 人 も 多い の で は ない だろ う か ?


# 単語のid列への分割
print(sp.encode(sample_tokens))
# [823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]
# 文字列への分割
print(sp.encode_as_pieces(sample_tokens))
# ['▁これから', '▁の', '▁年度', '▁末', '▁に', '▁向け', '▁て', '▁、', '▁引越し', '▁を', '▁考え',
# '▁て', '▁いる', '▁人', '▁も', '▁多い', '▁の', '▁で', '▁は', '▁ない', '▁だろ', '▁う', '▁か', '▁?']

特殊文字 “▁”が各単語の前についていますが、encode_as_piecesによる区切り自体は、元のMeCabの区切り位置から何も変わっていないのがわかりますね。これは基本的には、分かち書きされたトークン列をid列に変換してくれているだけだとわかります。また、よくみるとid列の中に0 (対応する単語は”引越し”)が出ているのがわかります。これが語彙数の上限4000から溢れた未知語です。id列を文字列に復元するとよくわかります。

# id列を文章に戻す
print(sp.decode_ids([823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]))
# これから の 年度 末 に 向け て 、 ⁇  を 考え て いる 人 も 多い の で は ない だろ う か ?

「引越し」を引/越/しに分割して未知語をなくしてくれてるいわゆるsubword処理をやってくれると嬉しかったのですが、冒頭に書いた通りそれは単なる僕の勘違いでした。
低頻度語は未知語としてそのまま捨てられちゃいます。

前回と今回の記事をまとめると、sentencepieceは原則、model_typeは unigramかbpeで使うもののようですね。この二つのアルゴリズムはなかなか便利ですよ。

sentencepieceを使ってみた(model_type: unigram / bpe / char)

このブログではテキストを単語に分割するときは概ねMeCabを使っていますが、実はMeCab以外にもテキストを分割する方法はいろいろあります。その中の一つであるsentencepieceを試してみたので紹介します。ちなみにこのsentencepiece、開発者はMeCabと同じ工藤拓さんです。
参考: SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing

sentencepieceとMeCabの違いとしては、MeCabは文章を文法的な意味を持つ最小単位の単語(形態素)に分割する形態素解析エンジンなのに対して、sentencepieceは特に文法的な意味を考慮せずに分割するということがあります。
MeCabは辞書をもとに文章を分割しますが、sentencepiece(unigram/ bpe)はそうではなく生の文章から自動的に分割する単位を学習し、語彙を習得します。
unigram とか bpe というのはそのときのアルゴリズムにつけられた名前です。
この他、model_type=”word”ってのがありますが、これは特殊で、あらかじめ単語に分割されたデータから低頻出語をさらに分割することで学習します。(学習データの準備が違うので次の記事で紹介します。)
これだけだと何もメリットなさそうなのですが、sentencepieceでは語彙の数を事前にパラメーターで指定することができ、例えば語彙を8000語に収めたいなら8000語で学習するといったことができます。BoWのような表現をする場合はもちろんですが、RNNやTransformer系の機械学習モデルへの前処理として使う場合、埋め込み層の語彙数を事前に指定しないといけないので非常に便利な特徴ですね。また、コーパス全体で1,2回しか出てこないような低頻度語を防げるのもありがたいです。

GitHubのPythonラッパーのサンプルコードを見ながら動かしてみましょう。
参考: sentencepiece/python at master · google/sentencepiece · GitHub

サンプルに使うデータは例によってライブドアニュースコーパスです。ニュース記事本文を使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

ちょっと使い方が独特で、まずモデルを学習するためのコーパスをテキストファイルで準備する必要があります。上記の記事で作ったライブドアニュースコーパスのCSVを、1記事1行のテキストに加工して保存しておきます。

import pandas as pd

# ライブドアニュースコーパスの本文を、1記事1行のテキストファイルとして書き出し
df = pd.read_csv("./livedoor_news_corpus.csv")
# 改行除去
df["text"] = df.text.str.replace("\n", " ")
# 全角空白除去 (blog記事上だと分かりにくいですが、全角スペースを半角スペースにしてます。)
df["text"] = df.text.str.replace(" ", " ")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")
# 前後の空白除去
df["text"] = df.text.str.strip()

# ファイル書き出し
with open('livedoor_corpus.txt', 'w') as w:
    for text_line in df["text"]:
        w.write(text_line + "\n")

これでコーパスができました。
このあと、sentencepieceのモデルを学習します。これもかなり使い方が特殊で、先ほど作ったテキストファイルと各種オプションを指定して学習を実行すると、モデルのバイナリ(.model)と、語彙(.vocab)の二つのファイルが出来上がります。ファイル名の拡張子以前の部分は model_prefix 引数で指定した文字列です。では早速、デフォルトの unigramモデルでやってみましょう。語彙数は適当に4000としました。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",  # コーパスファイル
    model_type="unigram",  # デフォルト
    model_prefix='livedoor_unigram',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

これで以下のように、livedoor_unigram.model/ livedoor_unigram.vocab ファイルが出来上がります。


$ ls
livedoor_corpus.txt
livedoor_unigram.model
livedoor_news_corpus.csv
livedoor_unigram.vocab

livedoor_unigram.vocabを開くと学習した語彙が見れます。

$ head -n 20 livedoor_unigram.vocab
<unk>	0
<s>	0
</s>	0
の	-3.17217
、	-3.41012
。	-3.81098
▁	-3.85549
を	-4.01854
が	-4.10413
に	-4.21314
は	-4.29892
で	-4.61122
と	-4.6203
」	-4.65722
「	-4.80934
も	-5.02828
な	-5.19927
)	-5.20129
(	-5.30642
い	-5.32545

先頭部分が1文字ばっかりで分かりにくいですが、ファイルの途中見ていくと、「する」とか「という」とかの単語もちゃんと出て来ます。<unk>,<s>,</s>の3単語がデフォルトで予約語とされていますが、この3語を含めて4000語です。control_symbolsやuser_defined_symbols という引数を使って、自分で定義したシンボルを入れることもできます。
参考: sentencepiece/special_symbols.md at master · google/sentencepiece · GitHub

さて、モデルが学習できたのでこれ使ってみましょう。出来上がったモデルファイルを読み込んで、それを使って文章をトークン化します。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_unigram.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp.encode(sample_text))
[6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]
# 文字列への分割
print(sp.encode_as_pieces(sample_text))
# ['▁', 'これから', 'の', '年', '度', '末', 'に', '向け', 'て', '、', '引', '越', 'し', 'を', '考え', 'ている', '人', 'も多い', 'の', 'ではない', 'だろうか', '?']

# id列を文章に戻す
print(sp.decode_ids([6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

これで、テキストをトークン化とその逆変換ができましたね。

モデルを学習したときの、model_type=”unigram” の部分を model_type=”bpe” とすることでもう一つのByte pair encodingアルゴリズムも試すことができます。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="bpe",
    model_prefix='livedoor_bpe',
    vocab_size=4000,
)

unigramとbpe、そんなに大きな違いないんじゃ無いかなという予想に反して、出来上がった語彙ファイルを見ると全然違います。

$ head -n 20 livedoor_bpe.vocab
<unk>	0
<s>	0
</s>	0
てい	-0
した	-1
った	-2
する	-3
▁・	-4
して	-5
ない	-6
ている	-7
から	-8
こと	-9
って	-10
os	-11
とい	-12
ます	-13
され	-14
です	-15
ック	-16

最初っから2文字ペアの単語がたくさん出て来ますね。
学習したモデルを読み込んで使う方法は同じです。語彙が違うので微妙に結果が変わります。
たった一文の比較で優劣つけるわけにはいきませんが、この例文だとなかなかどちらがいいとも言い難いです。

# モデルの読み込み
sp_bpe = spm.SentencePieceProcessor(model_file='./livedoor_bpe.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp_bpe.encode(sample_text))
[1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]
# 文字列への分割
print(sp_bpe.encode_as_pieces(sample_text))
# ['▁これ', 'からの', '年', '度', '末', 'に向', 'けて', '、', '引', '越', 'し', 'を', '考え', 'ている', '人も', '多い', 'のではない', 'だろうか', '?']

# id列を文章に戻す
print(sp_bpe.decode_ids([1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

「実践・自然言語処理シリーズ2 形態素解析の理論と実装 (近代科学社/ 工藤拓(著))」に少しだけsentencepieceについての記述もあり、unigramとbpeの違いが少し記載されています。
そのまま引用します。

BPEは、ニューラル翻訳に標準的に用いられている手法であり、1文字1語彙から開始し、連結した際に最も頻度が高くなる二つの語彙を選び新たな語彙とする手続きを決められた語彙サイズに達するまで繰り返すことで語彙結合ルールを学習します。
(中略)
ユニグラム言語モデルは、テキストを符号化するときの符号長が最小となるように、分割モデルをEM法を用いて学習します。

なるほど、って感じですね。個人的にはBPEの方が具体的なアルゴリズムがわかりやすいです。

ちなみに、ユニグラム言語モデルは符号化したときの符号長を最小にするように分割するって書いてあるので、ユニグラムモデルの方が少ない単語数になるのかと思って、学習したテキストで試したのですが結果は逆でした。不思議です。

print(df.text.apply(lambda x: len(sp.encode(x))).mean())
# 814.231301751052

print(df.text.apply(lambda x: len(sp_bpe.encode(x))).mean())
# 811.67992398534

今回の記事の主題はほぼここまでなのですが、model_typeには次の記事で取り上げるwordの他にもcharってのがあるので、一応これも紹介しておきます。

これは非常に単純なやつでして、単純にテキストを文字単位に分割します。学習するのはコーパスに登場した文字の一覧だけです。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="char",
    model_prefix='livedoor_char',
    vocab_size=4000,
)

学習結果の語彙も予約語以外は文字単体だけです。4000種類もなかったので行数も少なくなりました。

$ head -n 20 livedoor_char.vocab
<unk>	0
<s>	0
</s>	0
の	-3.64481
▁	-3.64584
、	-3.80562
い	-3.93749
ー	-4.02605
に	-4.07836
る	-4.1384
と	-4.15518
で	-4.20032
し	-4.23772
な	-4.24788
。	-4.24846
た	-4.2548
て	-4.2787
を	-4.28831
が	-4.29299
は	-4.38982

モデルを読み込んで使う方法はunigramやbpeと同じなので略します。

gensim の phrases で用意されているスコア関数について

前回の記事から引き続き、gensim の phrases モデルの話です。
参考(前回の記事): gensimでフレーズ抽出
参考(公式ドキュメント): models.phrases – Phrase (collocation) detection — gensim
参考(Githubのソース): gensim/phrases.py at master · RaRe-Technologies/gensim · GitHub

前回の記事でこのモデルの使い方を紹介しました。このモデルは連続して出現した単語のペアに対してスコアを計算して、そのスコアが指定した閾値を超えたらその単語のペアをフレーズとして抽出しているのでした。この時に使われているスコアの計算式を具体的に見ていこうという記事です。このスコアの計算式は用意されているものが2種類と、後自分で作ったオリジナルの関数もつかえますが一旦用意されている2種類を見ていきます。

データは前回の記事と同じものを使います。形態素解析(分かち書き)まで終わったデータが、データフレームに格納されているものとします。

計算に使われる引数について

当然計算式はそれぞれ違うのですが、関数に渡される引数は共通です。(とはいえ、Pythonの実装上引数として渡されるだけで、両方が全部使っているわけではありません。)
それらの値について最初に見ておきましょう。
ドキュメント、もしくはソースコードを見ると以下のように6個の値を受け取っていますね。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
# 実装は略

説明と動作確認のため、適当にモデルを学習させておきます。

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

それでは、順番に引数を見ていきます。

まず、 worda_count / wordb_count はそれぞれ1つ目、2つ目の単語が出てきた回数です。
モデルでいうと、 phrase_model.vocab から取得されます。それぞれの単語が出てきたテキスト数ではなく、出てきた回数であるところが注意が必要です。(1つのテキストに5回出てきたらそれで5と数えられます。)

bigram_count は 1つ目の単語と2つ目の単語が連続して登場した回数です。これも同様に連続して登場したテキスト数ではなく回数です。

len_vocab は vocab 辞書の要素数です。コードでいうとlen(phrase_model.vocab)。ユニグラムとバイグラムを両方数えた語彙数になります。前回の記事でいうと、{“キン”: 81, “ドル”: 111, “キン_ドル”: 81} となっていたらこれで3と数えます。 モデルを学習する時、 min_count で一定回数以下しか出現しなかった単語を足切りしますが、このvocab 作成時は足切りが行われません。1回でも出現したユニグラム、バイグラムが全部数えられるので注意が必要です。

min_count はシンプルに、モデル学習時に指定したバイグラムの最低出現回数です。ちなみに、min_count と全く同じ回数だけ出現した単語は対象に含まれるようですが、スコア関数がデフォルトのoriginal_scoreの場合は定義からスコアが絶対に0になるので結果的に抽出されません。

corpus_word_count は学習したテキストの単語の数を単純に足したものです。次のコードの二つの値が一致することからわかります。

print(phrase_model.corpus_word_count)
# 592490

print(df.tokens.apply(len).sum())
# 592490

さて、スコア計算に使われる6個の値が確認できたところで、順番にスコアの定義を見ていきましょう。

オリジナルスコア

scoring=’default’ と指定された時に採用されるのが、original_scorer です。
以下の論文で提唱されたものをベースとしています。
参考: Distributed Representations of Words and Phrases and their Compositionality

ドキュメントによると次の式で定義されています。
$$
\frac{(\text{bigram_count}-\text{min_count})\times \text{len_vocab}}{\text{worda_count}\times\text{wordb_count}}
$$

シンプルでわかりやすいですね。バイグラムが最小出現回数に比べてたくさん出現するほどスコアが伸びるようになっています。一方でユニグラムでの出現回数が増えるとスコアは下がります。出現した時は高確率で連続して出現するという場合に高くなるスコアです。
len_vobabを掛けているのはイマイチ意図が読めないですね。両単語が全く関係ないテキストをコーパスに追加していくとスコアが伸びていってしまいます。
また、min_countがスコアの計算に使われているので、min_countを変えると足切りラインだけでなくスコアも変わる点も個人的にはちょっとイマイチかなと思いました。

ソースコードも一応見ましたが、定義そのままですね。

def original_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    denom = worda_count * wordb_count
    if denom == 0:
        return NEGATIVE_INFINITY
    return (bigram_count - min_count) / float(denom) * len_vocab

もう何回も使っている”キン_ドル”を例に、モデルが計算した結果と、定義通りの計算結果が一致することを見ておきましょう。

# モデルが実際に計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 1062.89667445223

# 計算に使われた値たち
print(len(phrase_model.vocab))
# 156664
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.min_count)
# 20

# 定義に沿った計算結果
print((81-20)*156664/(81*111))
# 1062.89667445223

 以上で、デフォルトのオリジナルスコアが確認できました。次はNPMIスコアです。

NPMIスコア

scoring=’npmi’ と指定すると使われるのがnpmi_scorerです。
以下の論文で提唱されたものをもとにしています。
参考: Normalized (Pointwise) Mutual Information in Colocation Extraction

ドキュメントによると定義は次の式です。
$$
\frac{\ln{(prob(\text{worda}, \text{wordb}) / (prob(\text{worda})\times prob(\text{wordb}))))}}{-\ln{(prob(\text{worda}, \text{wordb}))}}
$$

ここで、
$$
prob(\text{word}) = \frac{\text{word_count}}{\text{corpus_word_count}}
$$
だそうです。
僕は、だいたい想像つくけど、$prob(\text{worda}, \text{wordb})$の定義も書けや、と思いました。
ソースを読んだ限りでは、以下の定義のようです。
$$
prob(\text{worda}, \text{wordb}) = \frac{\text{bigram_count}}{\text{corpus_word_count}}
$$

分子と分母の両方にバイグラムの出現割合が登場するので直感的には少しわかりにくいですね。分母にマイナスがついているのも理解をややこしくしています。

注意点として、このスコアは-1〜1の範囲(もしくは-inf)で値を返します。モデルの閾値はデフォルト10ですが、これは明らかにoriginal_scorerを使うことを想定しているので、これを使う時は閾値も合わせて調整しなければなりません。

一応ソースコードも見ておきましょう。

def npmi_scorer(worda_count, wordb_count, bigram_count, len_vocab, min_count, corpus_word_count):
    if bigram_count >= min_count:
        corpus_word_count = float(corpus_word_count)
        pa = worda_count / corpus_word_count
        pb = wordb_count / corpus_word_count
        pab = bigram_count / corpus_word_count
        try:
            return log(pab / (pa * pb)) / -log(pab)
        except ValueError:  # some of the counts were zero => never a phrase
            return NEGATIVE_INFINITY
    else:
        # Return -infinity to make sure that no phrases will be created
        # from bigrams less frequent than min_count.
        return NEGATIVE_INFINITY

これも一応、モデルの計算結果と自分で計算した値を突き合わせておきます。

import numpy as np  # logを使うためにimport

# npmi をtukausetteidemoderuwogakusyuu
phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='npmi',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=0.8,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

# モデルが計算したスコア
print(phrase_model.export_phrases()["キン_ドル"])
# 0.9645882456014411

# 計算に使われた値たち
print(phrase_model.vocab["キン"])
# 81
print(phrase_model.vocab["ドル"])
# 111
print(phrase_model.vocab["キン_ドル"])
# 81
print(phrase_model.corpus_word_count)
# 592490

# 定義に沿って計算
pa = 81/592490
pb = 111/592490
pab = 81/592490
print(np.log(pab / (pa * pb)) / -np.log(pab))
# 0.9645882456014411

想定通りの結果になりましたね。

これで、gensimのphrasesモデルでフレーズ抽出に使われているスコアの計算式が理解できました。

どういうスコアなのかが分かればそれをもとに閾値を適切に決めれるのでは、という期待があったのですが、正直、この計算式だからこうだみたいな目安はまだあまり見えてきませんでした。

一回 min_countと閾値を両方ともものすごく低い値にして学習し、スコアの分布を見たり、だいたい何単語くらい抽出したいのかといったことをかがえて何パターンか試して使っていくのが良いのかなと思います。

gensimでフレーズ抽出

以前このブログで、テキストデータ中のよく連続する単語を検出するコードを紹介しました。
参考: Pythonを使ってよく連続する文字列を検索する

これは単純にある単語の前か後に出現しやすい単語を探すだけのコードだったのですが、実は同じような目的のモデルでもう少しスマートなロジックで実装されたものがgensimにあることがわかったのでそれを紹介します。

なお、今回の記事は以下のバージョンのgensimで動かすことを前提とします。

$ pip freeze | grep gensim
gensim==4.1.2

僕は複数開発環境を持っているのですが、gensim==3.8.0 など、3系の環境と、今使っている4系の環境で細かい挙動が色々異なり少し手こずりました。(会社のMacで動いたコードが私物のMacで動きませんでした。)
今回紹介するモデルに限らず、githubのgensimのリポジトリのWikiにマイグレーションガイドが出てるので、gensimを頻繁に使われる方は一読をお勧めします。
参考: Migrating from Gensim 3.x to 4 · RaRe-Technologies/gensim Wiki · GitHub

前置きが長くなりました。今回紹介するのは、gensimのphrasesです。
ドキュメント: models.phrases – Phrase (collocation) detection — gensim

要は、分かち書き済みの文章から学習して、頻繁に連続する2単語をフレーズとして抽出してくれるモデルです。
「頻繁に連続する」の基準として、僕が以前の記事で紹介したような単純な割合ではなく、論文で提唱されている手法(を元にした関数)を使ってスコアリングし、そのスコアが閾値を超えたらフレーズとして判定するという手法が採られています。(デフォルトで使われるのは1個目の方です。2個目はオプションで使うことができます。)
参考:
– Distributed Representations of Words and Phrases and their Compositionality
– Normalized (Pointwise) Mutual Information in Collocation Extraction” by Gerlof Bouma

今回の記事は使い方をメインで扱いたいので、このスコアリング関数については次の記事で紹介しましょうかね。

早速使っていきましょう。まず学習させるデータの準備です。以前用意したライブドアニュースコーパスを使います。今回はお試しで、そんなたくさんのデータ量いらないので、「ITライフハック」のデータだけ使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

上記の記事で作ったCSVデータの読み込みと、分かち書きまでやっておきます。

import subprocess
import pandas as pd
import MeCab


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# 今回は"it-life-hack" だけ使う
df = df[df.category=="it-life-hack"].reset_index(drop=True)
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞情報も原型変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を配列で返す関数
def mecab_tokenizer(text):
    return tagger.parse(text).split()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# ['すもも', 'も', 'もも', 'も', 'もも', 'の', 'うち']

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

これで、各テキストを分かち書きして配列にしたものがdf[“tokens”]に入りました。 (scikit-learnの場合は空白区切りの文字列にしますが、gensimの場合は単語を要素とする配列でデータを用意します。)
早速Phrasesモデルを作ります。

デフォルトのスコア関数、閾値はかなり大きめの1000で学習してみます。(あまりたくさんフレーズを見つけられても、この記事ではどうせ紹介できないのでかなり絞っています。デフォルトは10なので、通常の利用では1000は大きすぎです。)

from gensim.models.phrases import Phrases


phrase_model = Phrases(
    sentences=df["tokens"],  # 学習するデータ
    min_count=20,  # 最低何回出現した単語および単語ペアを対象とするか。デフォルト5
    scoring='default',  # スコアリングに用いる関数。 "default", "npmi", もしくは自作の関数を指定。
    threshold=1000,  # スコアが何点を超えたらフレーズとみなすか。でフォルト10.0
)

さて、これで学習ができました。学習した語彙は vocab プロパティが持っています。

phrase_model.vocab
"""
{'マイクロンジャパン': 2,
 'は': 12486,
 'マイクロンジャパン_は': 1,
 '、': 21839,
 'は_、': 3765,
 '従来': 138,
 '、_従来': 54,
 'の': 25248,
 '従来_の': 68,
# 以下略
"""

単語とその単語の出現回数に加えて、アンダーバーで二つの単語を繋いだbi-gram について、その出現回数の辞書となっています。(4系のgensimではvocabが単純な辞書ですが、実は3系では違ったのですよ。gensimオリジナルの型でしたし、単語はエンコーディングされていました。)

このモデルが結果的に見つけてくれたフレーズは、export_phrases()メソッドで取得することができます。(これも3系4系で挙動が違うメソッドです。)

phrase_model.export_phrases()
"""
{'ガ_ジェット': 1793.2403746097816,
 'インター_フェイス': 1398.7857142857142,
 '池田_利夫': 1409.4339100346021,
 '岡本_奈知': 1444.5520523497917,
 'ジャム_ハウス': 1377.0331304935767,
 'エヌプラス_copyright': 1409.4339100346021,
 'all_rights': 1259.6684809500248,
 'rights_reserved': 1393.045143638851,
 '上倉_賢': 1367.7015873015873,
 'キン_ドル': 1062.89667445223,
# 以下略
"""

見つけたフレーズと、そのフレーズのスコアの辞書として結果が得られます。

あの単語と、この単語の組み合わせのスコアって何点だったのかな?と思ったら、scoringメソッドで調べられます。気になるフレーズが検出されなかったら見てみましょう。

引数は結構たくさん渡す必要あります。まずヘルプ見てみましょう。

phrase_model.scoring?
"""
Signature:
phrase_model.scoring(
    worda_count,
    wordb_count,
    bigram_count,
    len_vocab,
    min_count,
    corpus_word_count,
)
"""

試しに、「キン_ドル」で1062.89… であることを見ておきましょうかね。worda_count とかは先に述べた通り、vocabから拾ってこれます。コーパスの単語数頭の情報はモデルが持ってるのでそこからとりましょう。

phrase_model.scoring(
    phrase_model.vocab["キン"],
    phrase_model.vocab["ドル"],
    phrase_model.vocab["キン_ドル"],
    len(phrase_model.vocab),
    phrase_model.min_count,
    phrase_model.corpus_word_count
)
# 1062.89667445223

学習に使ったデータとは別のテキストから、学習済みのフレーズを検索することもできます。

sample_data = [
    ['アマゾン', 'の', '新しい', 'ガ', 'ジェット'],
    ['新しい', 'キン', 'ドル', 'を', '買い', 'まし', 'た'],
]

print(phrase_model.find_phrases(sample_data))
# {'ガ_ジェット': 1793.2403746097816, 'キン_ドル': 1062.89667445223}

また、次のようにdictのようにモデルを使うと、渡されたデータ内で見つけたフレーズを _ で連結してくれます。結果がジェネレーターで帰ってくるので、listを使って配列にしてからprintしました。これはとても便利な機能なのですが、このような辞書的な呼び出し方ではなく、transformか何か名前のあるメソッドにしてほしかったですね。

print(list(phrase_model[sample_data]))
# [['アマゾン', 'の', '新しい', 'ガ_ジェット'], ['新しい', 'キン_ドル', 'を', '買い', 'まし', 'た']]

ちなみに、 _ だと不都合がある場合は、モデル学習時に delimiter 引数で違う文字を使うこともできます。

スコア関数を変えたり、閾値を変えたらり、また、スコア関数の中でmin_countなども使われていますので、この辺の値を変えることで結果は大きく変わります。なかなか面白いので色々試してみましょう。

また、このモデルを重ねがけするように使うことで、3単語以上からなるフレーズを抽出することもできます。(閾値などの調整に少々コツが必要そうですが。)
そのような応用もあるので、なかなか面白いモデルだと思います。

scikit-learnでテキストをBoWやtfidfに変換する時に空白以外の場所で単語を区切らないようにする

以前、scikit-learnのテキスト系の前処理モデルである、CountVectorizer
TfidfVectorizer において、1文字の単語を学習結果に含める方法の記事を書きました。
参照: scikit-learnでテキストをBoWやtfidfに変換する時に一文字の単語も学習対象に含める

この記事の最後の方で、以下のようなことを書いてました。

これ以外にも “-” (ハイフン) などが単語の境界として設定されていて想定外のところで切られたり、デフォルトでアルファベットを小文字に統一する設定になっていたり(lowercase=True)と、注意する時に気をつけないといけないことが、結構あります。

このうち、ハイフンなどの空白以外のところで単語を切ってしまう問題はワードクラウドで可視化したり単語の出現頻度変化を調べたりする用途の時に非常に厄介に思っていました。機械学習の特徴量を作る時などは最終的な精度にあまり影響しないことが多いので良いのですが。

これを回避するスマートな方法を探していたのですが、それがようやく分かったので紹介します。

前の記事でも行ったように、token_patternの指定で対応を目指したわけですが、以下のコード中の\\\\b の部分をどう調整したら良いのかがわからず苦戦していました。
このbは、\\\\wと\\\\Wの境にマッチするという非常に特殊な正規表現ですが、これをどうすれば空白と\\\\wの間にマッチさせらるのかが分からなかったのです。

# 1文字の単語も学習する設定
bow_model = CountVectorizer(token_pattern='(?u)\\b\\w+\\b')

それがあるとき気づいたのですが、この\\\\b とついでに(?u)は無くても動作変わらないんですよ。

先にそれを紹介しておきます。以前作ったニュースコーパスのデータでやってみます。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

import MeCab
import subprocess
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")


# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 今回は品詞による絞り込みも原型への変換も行わないので -Owakati で実行する。
tagger = MeCab.Tagger(f"-Owakati -d {dicdir}/ipadic")


# 分かち書きした結果を返す。
def mecab_tokenizer(text):
    # 末尾に改行コードがつくのでstrip()で取り除く
    return tagger.parse(text).strip()


# 動作確認
print(mecab_tokenizer("すもももももももものうち"))
# すもも も もも も もも の うち

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

# 普段指定している token_pattern で学習
bow_model_1 = CountVectorizer(token_pattern='(?u)\\b\\w+\\b', min_df=10)
bow_model_1.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_1.vocabulary_))
# 15332

# # token_pattern を \\w+ だけにしたもの
bow_model_2 = CountVectorizer(token_pattern='\\w+', min_df=10)
bow_model_2.fit(df["tokens"])
# 学習した語彙数
print(len(bow_model_2.vocabulary_))
# 15332

語彙数が同じなだけでなく、学習した単語の中身も全く同じです。

# 学習した単語は一致する
set(bow_model_1.get_feature_names()) == set(bow_model_2.get_feature_names())
# True

さて、本題に戻ります。\\\\w+ でこれにマッチする単語が抜き出せるとなれば、単純にスペース以外にマッチする正規表現を書いてあげれればそれで解決です。
“[^ ]+” (ハット”^”と閉じ大括弧”]”の間にスペースを忘れないでください)や、\\\\S+ などを使えばOKです。
それぞれ試しておきます。

bow_model_3 = CountVectorizer(token_pattern='\\S+', min_df=10)
bow_model_3.fit(df["tokens"])
print(len(bow_model_3.vocabulary_))
# 15479

bow_model_4 = CountVectorizer(token_pattern="[^ ]+", min_df=10)
bow_model_4.fit(df["tokens"])
print(len(bow_model_4.vocabulary_))
# 15479

学習した語彙数が増えましたね。

「セ・リーグ」とか「ウォルト・ディズニー」が「・」で区切られずに学習されているのがわかりますよ。

print("セ・リーグ" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("セ・リーグ" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

print("ウォルト・ディズニー" in bow_model_1.get_feature_names())  # \\w+のモデルでは学習されていない。
# False

print("ウォルト・ディズニー" in bow_model_3.get_feature_names())  # \\S+のモデルでは学習されている
# True

これで、変なところで区切られずにMeCabで切った通りの単語で学習ができました。

ただし、この方法でもデメリットがないわけではありません。
\\\\w+ にはマッチしないが、\\\\S+にはマッチする文字がたくさん存在するのです。
要するに\\\\Wにマッチする文字たちのことです。

「?」や「!」などの感嘆符や「◆」のようなこれまで語彙に含まれなかった文字やそれを含む単語も含まれるようになります。これらが不要だという場合は、分かち書きする前か後に消しておいた方が良いでしょう。

もしくは逆に、不要に切ってほしくない文字は「- (ハイフン)」と「・(中点)」だけなんだ、みたいに特定できているのであれば、token_pattern=”[\\w\-・]+” みたいに指定するのも良いと思います。(ハイフンは正規表現の[]内で使うときは文字の範囲指定を意味する特殊文字なのでエスケープ必須なことに気をつけてください。)

いずれにせよ、結果を慎重に検証しながら使った方が良さそうです。

scikit-learnのSimpleImputerで欠損値の補完

とある講演を聞いていて、SimpleImputerという機能の存在を知ったのでその紹介です。(その講演自体は別のテーマで話されていたのですが、その中でSimpleImputerは常識みたいに扱われていたのに、僕は使ったことがなかったので試そうと思いました。)

これは何かというと、pandasのDataFrameやNumPyのArray中にある欠損値を補完してくれるものです。目的はpandasのDataFrameの機能でいうところのfillna()に近いですね。
fillna()で十分だという意見もあると思いますし、実際僕もfillna()で済ませてきたのでこれの存在を知らなかったのですが、ちゃんとSimpleImputerのメリットもあるのでその辺説明していきたいと思います。

ドキュメントはこちらです。
sklearn.impute.SimpleImputer — scikit-learn 1.0.1 documentation
6.4. Imputation of missing values — scikit-learn 1.0.1 documentation

version 0.20 から登場したモデルで、その前まで存在した、 sklearn.preprocessing.Imputer の置き換えとして実装されたようですね。

とりあえず補完対象となる欠損値を含むデータがないと始まらないので、適当に準備します。

import pandas as pd


# 欠損値を含むDataFrameを生成
df = pd.DataFrame(
    {
        "col1": [8, None, 6, 3, None],
        "col2": [None, 8, 2, 2, 10],
        "col3": [3, 10, None, 0, 3],
    }
)
print(df)
"""
   col1  col2  col3
0   8.0   NaN   3.0
1   NaN   8.0  10.0
2   6.0   2.0   NaN
3   3.0   2.0   0.0
4   NaN  10.0   3.0
"""

これで一部のデータが欠損しているDataFrameができましたね。それでは、SimpleImputer を使ってきましょう。SimpleImputer を使うときには、まず欠損値を埋める方法を決める必要があります。 その列の欠損してない値の 平均値、中央値、最頻値を用いて欠損値を埋めるか、もしくは定数を使って埋めることになります。

埋め方を決めたらそれは strategy 引数で指定します。対応は以下の通りです。
– mean ・・・ 平均値
– median ・・・ 中央値
– most_frequent ・・・ 最頻値
– constant ・・・ 定数 (別途、fill_value 引数で定数を指定する)

今回はお試しなので、 mean (平均値) でやってみます。

# インスタンスを生成
imp_mean = SimpleImputer(strategy='mean')
# 学習
imp_mean.fit(df)
# 学習したパラメーター(補完に使う平均値を表示)
print(imp_mean.statistics_)
# [5.66666667 5.5        4.        ]

# 欠損値を補完
imp_ary = imp_mean.transform(df)
# 結果を表示
print(imp_ary)
"""
[5.66666667 5.5        4.        ]
[[ 8.          5.5         3.        ]
 [ 5.66666667  8.         10.        ]
 [ 6.          2.          4.        ]
 [ 3.          2.          0.        ]
 [ 5.66666667 10.          3.        ]]
"""

# 補完後の型はNumpyのArraryになる
print(type(imp_ary))
# <class 'numpy.ndarray'>

fit で各列の平均値を学習し、その値を使ってNaNだったところを埋めてくれていますね。
注意しないといけないのは transform して戻ってくるデータはNumPyのArrayになっていることです。(上のサンプルコードで型を見ている通り。)

欠損値補完後のデータもDataFrameで欲しいんだという場合は再度DataFrameに変換する必要があるようです。モデルの引数でそういうオプションがあるといいのですが、今の時点のバージョン(1.0.1)ではなさそうなので自分でやりましょう。

imp_df = pd.DataFrame(imp_mean.transform(df), columns=df.columns)
print(imp_df)
"""
       col1  col2  col3
0  8.000000   5.5   3.0
1  5.666667   8.0  10.0
2  6.000000   2.0   4.0
3  3.000000   2.0   0.0
4  5.666667  10.0   3.0
"""

正直、このように単一のDataFrameにたいしてそのDataFrameの統計量を使って補完するのであれば、fillnaの方が使いやすい気がします。次のようにして同じ結果が得られるので。

print(df.fillna(value=df.mean()))
"""
       col1  col2  col3
0  8.000000   5.5   3.0
1  5.666667   8.0  10.0
2  6.000000   2.0   4.0
3  3.000000   2.0   0.0
4  5.666667  10.0   3.0
"""

これは、fillnaが列ごとに個別の値を設定できることを利用し、valueにそれぞれの列の平均値(df.mean())を渡すことによって実現しているものです。

ちなみに、SimpleImputer で strategy=’constant’ を指定する場合、fillnaのように列ごとに違う値を指定することはできません。次のように定数を一つだけ指定してそれで補完します。
この点はfillnaと比較したときに明確なデメリットですね。

imp_cons = SimpleImputer(strategy='constant', fill_value=-1)
imp_cons.fit(df)
print(imp_cons.transform(df))
"""
[[ 8. -1.  3.]
 [-1.  8. 10.]
 [ 6.  2. -1.]
 [ 3.  2.  0.]
 [-1. 10.  3.]]
"""

strategy=’constant’ の場合、補完する数値を計算する必要がないので、fit()したときに何を学習しているのか不明だったのですが、どうやらここで渡した列数を記憶しているようです。(fit に3列のDataFrameを渡すと、transformメソッドも3列のDataFrameしか受け付けなくなる。)

さて、これまでの説明だと、どうもfillna()の方が便利に思えてきますね。(Numpyにはfillnaがないので、元のデータがDataFrameではなく2次元のArrayの場合は使えるかもしれませんが。)

ここから SimpleImputer のメリットの紹介になります。

一つの目のメリットは、一度学習させた補完値を他のデータにも適用することができるということです。例えば機械学習の前処理でデータを補完する場合、訓練データの欠損値をある値で補完したのであれば、検証データの欠損値もその値で補完したいですし、本番環境にリリースして実運用が始まった後も欠損値があるデータに対して補完をかけるのであれば訓練時と同じデータで補完したいです。

SimpleImputer であれば、 fitした時点で補完する値を学習しているので、別のデータに対してtransformしてもその値を使って補完してくれます。
fillna でも、補完に使うデータをどこかに退避しておいてそれを使えばいいじゃないか、という声も聞こえてきそうですし、実際そうなのですが、fitしたモデルを保存しておいてそれを使う方が、補完に使うデータ(辞書型か配列か)をどこかに退避しておくより気楽に感じます。(個人の感想です。なぜそう感じるのかは自分でもよくわかりません。)

# 元と別のデータ
df2 = pd.DataFrame({
    "col1": [7, None, 2],
    "col2": [2, 2, None],
    "col3": [6, 8, None],
})

# 学習済みの値を使って補完される
print(imp_mean.transform(df2))
"""
[[7.         2.         6.        ]
 [5.66666667 2.         8.        ]
 [2.         5.5        4.        ]]
"""

もう一つの利点は、これがscikit-learnのモデルなので、Pipelineに組み込めるということです。完全に適当な例なのですが、
平均値で欠損値の補完 -> データの標準化 -> ロジスティック回帰
と処理するパイプラインを構築してみます。
ロジスティック回帰をやるので正解ラベルも適当に作ります。

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression


# ロジスティック回帰のサンプルを動かすために架空の正解ラベルを作る
y = [0, 0, 0, 1, 1]

# 欠損値を補完した後、StandardScalerで標準化してロジスティック回帰で予測するパイプライン
clf = Pipeline([
    ("si", SimpleImputer(strategy="mean")),
    ("ss", StandardScaler()),
    ("lr", LogisticRegression()),
])
# 学習
clf.fit(df, y)

このようにPipelineに組み込めるというのは fillna の方にはないメリットだと思います。

繰り返しますが、上の例は完全に適当に作ったパイプラインです。
一般的な話として、前処理次に欠損値を補完した後に標準化するという前処理を推奨しているわけではないのでご注意ください。あくまでも、SimpleImputerを組み込んだパイプラインは動きますということだけを示した例です。

Pythonを使ってよく連続する文字列を検索する

前回の記事で紹介したn-gram(といっても今回使うのはユニグラムとバイグラム)の応用です。
テキストデータの中から高確率で連続して登場する単語を探索する方法を紹介します。
参考: scikit-learnで単語nグラム

コーパスとして、昔作成したライブドアニュースコーパスをデータフレームにまとめたやつを使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

今回の記事で使うライブラリの読み込み、データの読み込み、さらに分かち書きに使う関数の準備とそれを使った単語の形態素解析まで済ませておきます。

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

# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規かとアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()

# 分かち書きの中で使うオブジェクト生成
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)]
    return " ".join(token_list)

# 分かち書き
df["tokens"] = df.text.apply(mecab_tokenizer)

さて、データの準備が整ったので本題の処理の方に移っていきましょう。
やることは簡単で、ユニグラムモデル(単語単位のBoW)とバイグラムモデル(2単語連続で学習したBoW)を作成します。そして、「単語1」の出現回数で、「単語1 単語2」の出現回数を割ることによって、「単語1」の後に「単語2」が出現する確率を求めてそれがある程度より高かったらこの2単語は連続しやすいと判断します。また逆に、「単語2」の出現回数で、「単語1 単語2」の出現回数を割って同様の判定もかけます。

そのためにまず、バイグラムとユニグラムの単語の出現回数の辞書を作成します。
それぞれモデルを作ってBoWにし、語彙と出現回数のペアの辞書へと変換します。

# モデル作成
uni_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(1, 1),
        min_df=30,
    )
bi_model = CountVectorizer(
        token_pattern='(?u)\\b\\w+\\b',
        ngram_range=(2, 2),
        min_df=30,
    )

# BoWへ変換
uni_bow = uni_model.fit_transform(df["tokens"])
bi_bow = bi_model.fit_transform(df["tokens"])

# 学習した語彙数
print(len(uni_model.get_feature_names()), len(bi_model.get_feature_names()))
# 6921 12227

# 出現回数の辞書へ変換
uni_gram_count_dict = dict(zip(
        uni_model.get_feature_names(),
        uni_bow.toarray().sum(axis=0)
    ))
bi_gram_count_dict = dict(zip(
        bi_model.get_feature_names(),
        bi_bow.toarray().sum(axis=0)
    ))

これで計算に必要な情報が揃いました。min_df は少し大きめの30にしていますが、これは利用するコーパスの大きやさかける時間、求める精度などによって調整してください。(少し大きめの値にしておかないと、特にバイグラムの語彙数が膨れ上がり、次の処理が非常に時間がかかるようになります。)

さて、これで出現回数の情報が得られたのでこれを使って「単語1」の次に来やすい「単語2」を探してみましょう。余りたくさん出てきても困るので95%以上の確率で続くなら出力するようにしたのが次のコードです。

for uni_word, uni_count in uni_gram_count_dict.items():
    # uni_word: ユニグラムモデルでカウントした単語
    # uni_count: 上記単語が出現した回数

    # 対象の単語で始まる単語ペアにマッチする正規表現
    pattern = f"^{uni_word}\\b"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.match(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        # bi_words: バイグラムモデルでカウントした単語ペア
        # bi_count: 上記単語ペアが出現した回数

        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
1677 万 73回/73回
2106 bpm 107回/107回
75m bps 68回/70回
84回 アカデミー賞 94回/96回
888 毎日 41回/42回
angrybirds 風 41回/41回
bci 3 81回/81回
blu ray 339回/339回
deji 通 614回/619回
details id 160回/162回
digi 2 322回/326回
icecream sandwich 939回/941回
kamikura digi 89回/89回
katsuosh digi 56回/56回
let s 76回/79回
-- 以下省略 -- 
"""

途中正規表現を使っていますが、これは、選択中の「単語1」に対して「単語1 単語2」という文字列に一致させるものです。^は先頭、\\b(エスケープされて実際は\b)は単語区切りにマッチします。

出力はたくさん出ますので、先頭の方を上のコード中に例示しました。
84回 アカデミー賞 94回/96回 は 「84回」って単語が96回登場し、そのうち、94回は「84回 アカデミー賞」と続いたという意味です。
1677 万 などは 1677万画素って単語の一部ですね。

続いて、ある単語の後ろではなく前に登場しやすい単語も探してみましょう。
これは、正規表現のpatternが少し違うだけです、と行きたかったのですがもう1箇所違います。re.matchが先頭マッチの探索しかしてくれないので、正規表現でマッチさせるところのメソッドがre.searchになります。

for uni_word, uni_count in uni_gram_count_dict.items():

    # 対象の単語で終わる単語ペアにマッチする正規表現
    pattern = f"\\b{uni_word}$"
    target_bi_gram = {k: v for k, v in bi_gram_count_dict.items() if re.search(pattern, k)}
    for bi_words, bi_count in target_bi_gram.items():
        if bi_count / uni_count >= 0.95:
            print(bi_words, f"{bi_count}回/{uni_count}回")

# 以下出力の先頭の方の行
"""
iphone 3gs 38回/38回
第 84回 96回/96回
成長率 888 41回/42回
msm 8960 118回/118回
with amazlet 38回/38回
の angrybirds 41回/41回
パック bci 81回/81回
2106 bpm 107回/109回
apps details 160回/162回
after effects 63回/64回
パッケージ ffp 77回/77回
wi fi 869回/882回
モバイルwi fiルーター 136回/136回
-- 以下省略 -- 
"""

こちらもうまく出力されましたね。読み解き方はは先ほどと同じです。

そもそも、なぜこのような処理を作ろうと思ったかと言うと、MeCab等で分かち書きした時に、本当は1単語なのに複数単語に分かれてしまっているようなものを効率よく検索したかったためです。

この記事のコードでは新語辞書(mecab-ipadic-neologd)を使ってるので、あからさまなものは少ないですが、デフォルトのIPA辞書を使うと、 クラウド が 「クラ」と「ウド」に分かれている例などがポロポロ見つかります。

この記事の目的は上のコードで果たしたので以下は補足です。

さて、今回の結果を辞書の改善等に使うことを考えると、ここで出力された単語ペアを結合させて放り込んでいけば良さそうに見えます。しかし、実際は話はそう単純ではありません。
例えば、「wi fi」とか、「モバイルwi fiルーター」といった単語が出てきていますが、なるほど、辞書にWiFIやモバイルWiFiルーターが含まれてないんだな、と勘違いしそうになります。

しかし実際は、Neologdを使うと、どちらも正しく形態素解析できていて、「Wi-Fi」や「モバイルWi-Fiルーター」と言う単語で出力されるんですね。いつ分かれているかと言うと、sickit-learnが学習する時に-(ハイフン)を単語境界文字として扱っているのでここで切ってしまっています。

同様の例として、「ウォルト ディズニー」などもあります。これも実はMeCabは「ウォルト・ディズニー」と一単語にしているのに、scikit-learnが「・」で勝手に区切ってます。

このほかにも、出力を見ていくと「くだける 充電」と言うのが出てきますが、世の中に「くだける充電」という単語があるのか、と考えると間違えます。
これは元のテキストを見ると「おくだけ充電」という言葉があり、これが、「お」「くだけの原型のくだける」「充電」と形態素解析されて後半の2単語がくっついて出てきたものです。

このほか、「特有 の」とか「非常 に」のように、確かによく連続するんだろうけど、これは単語として分かれるのが正常だよね、って言う例も多く確認はかなり手間なようでした。

そもそも出力が意外に多かったです。(サンプルコードでは0.95以上としましたが、本気で探すならこの閾値はもっと下げた方が良さそうです。しかしそれをやるとどんどん出力が増えます。)

本当は1単語なのに間違って分かれてしまっている単語を探す、と言う目的に対しては、思ったよりノイズが多かったのですが、それでもかなり有効な方法だと思うので同様の課題をお持ちの方は試してみてください。細かくは書いていませんが、形態素解析する段階で品詞を絞っておく(例えば名詞のみする)とか、てにをは的なワードを除いておくなど改善の余地多いので色々試すのも楽しいです。

scikit-learnで単語nグラム

自然言語処理の前処理に、nグラム(n-gram)という概念があります。
これは隣り合って出現したn単語のことです。nの値が小さいときは特別な名前がついていて、
n=1の場合をユニグラム(unigram)、n=2の場合をバイグラム(bigram)、n=3の場合をトライグラム(trigram)と呼びます。

ネットで検索すると、「n単語」のことではなく、隣り合って出現した「n文字」をnグラムと言うという説明も見かけた(例えば、Wikipediaもそうです)ので、誤解を避けるためこの記事のタイトルは単語nグラムとしましたが、面倒なので以下記事中でnグラムと書いたら単語nグラムを指すものとします。
「言語処理のための機械学習入門」などの書籍でも、単語nグラムのことをnグラムと呼んでいるのでおかしくはないと思っています。(P. 62)

自然言語処理を勉強し始めた駆け出しの頃、テキストをBoWでベクトル化する時に、ユニグラムだけでなく、バイグラムを加えて単語間のつながりを考慮するというアイデアを知って、これはいいアイデアだと思って試したりしました。しかし、バイグラムを使って機械学習の精度が上がった経験というのはほとんどなく、その後もたまに試すけど有効だった覚えがほぼ無くだんだん試さなくなってきていました。
しかし、最近機械学習とは少し違う目的でnグラムを使いたいことがあったので、この機会にsciki-learnでBoWを作る時のnグラム関連の引数の挙動をまとめておこうと思ったのでこの記事に整理していきます。

今回は、サンプルに使うテキストデータはあらかじめ分かち書きしたやつを用意しておきます。題材はいつもメロスなので今回は幸福の王子にしました。(青空文庫から拝借)

corpus = [
    "町 の 上 に 高い 柱 が そびえる 、 その 上 に 幸福 の 王子 の 像 が 立つ て いる ます た 。",
    "王子 の 像 は 全体 を 薄い 純金 で 覆う れる 、 目 は 二つ の 輝く サファイア で 、 王子 の 剣 の つ か に は 大きな 赤い ルビー が 光る て いる ます た 。",
    "王子 は 皆 の 自慢 です た 。",
    "「 風見鶏 と 同じ くらい に 美しい 」 と 、 芸術 的 だ センス が ある という 評判 を 得る たい がる て いる 一 人 の 市会 議員 が 言う ます た 。",
    "「 もっとも 風見鶏 ほど 便利 じゃ ない が ね 」 と 付け加える て 言う ます た 。",
    "これ は 夢想 家 だ と 思う れる ない よう に 、 と 心配 する た から です 。",
    "実際 に は 彼 は 夢想 家 なんか じゃ ない た の です が 。",
]

さて、早速やっていきましょう。
scikit-learnのテキストの前処理には、BoWを作るCountVectorizer と、 tf-idfを作るTfidfVectorizer がありますが、nグラムに関しては両方とも同じくngram_range という引数で設定することができます。(最小)何グラムから(最大)何グラムまでを学習に含めるかをタプルで指定するもので、デフォルトは、 ngram_range=(1, 1) です。(ユニグラムのみ)。

バイグラムを学習させたければ(2, 2)と指定すればよく、ユニグラムからトライグラムまで学習したいなら(1, 3)です。

とりあえず、(1, 2)でやってみます。1文字の単語も学習させるため、token_patternも指定します。

from sklearn.feature_extraction.text import CountVectorizer


# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の先頭10個
print(bow_model.get_feature_names()[:10])
# ['ある', 'ある という', 'いる', 'いる ます', 'いる 一', 'か', 'か に', 'から', 'から です', 'が']

get_feature_names() で学習した単語を取得してみましたが、「ある」「いる」「か」などの1単語の語彙に混ざって、「ある という」「いる ます」などのバイグラムの語彙も混ざっていますね。

ユニグラムとバイグラムで合計、194の語彙を学習しているので、このモデルを使ってBoWを作ると、テキスト数(7) * 語彙数(194)の疎行列になります。

print(len(bow_model.get_feature_names()))
# 194

print(bow_model.transform(corpus).shape)
# (7, 194)

さて、もう少しライブラリの挙動を詳しくみていきましょう。次はstopwordとの関連です。
CountVectorizer は stop_wordsという引数で学習に含めない単語を明示的に指定できます。
試しにてにをは的な文字をいくつか入れていみます。(ここでは学習対象はバイグラムだけにします)

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(2, 2),
    stop_words=["て", "に", "を", "は", "が", "の", "た",]
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[50: 60])
# ['夢想 家', '大きな 赤い', '実際 彼', '家 だ', '家 なんか', '市会 議員', '幸福 王子', '彼 夢想', '得る たい', '心配 する']

学習した語彙の一部を表示していますが、わかりやすいのは「幸福 王子」というペアが含まれていることです。これはもちろん「幸福 の 王子」の部分から学習されたものです。
「の」が stopwordに含まれているので、まず、「の」が取り除かれて、「幸福 王子」になってからバイグラムの学習が行われたのだとわかります。
「幸福 の」と「の 王子」を学習してからstopwordを除くわけでは無いということが確認できました。

ちなみに、token_patternがデフォルトの場合、1文字の単語は学習されませんが、この時も似たような挙動になります。

# モデル作成
bow_model = CountVectorizer(
    ngram_range=(2, 2),
)

# 学習
bow_model.fit(corpus)

# 学習した単語の一部
print(bow_model.get_feature_names()[30: 40])
# ['同じ くらい', '夢想 なんか', '夢想 思う', '大きな 赤い', '実際 夢想', '市会 議員', '幸福 王子', '得る たい', '心配 する', '思う れる']

続いて、学習結果に含まれる単語を出現頻度で間引くmid_df/max_df の挙動を確認しておきます。min_dfの方がわかりやすいのでそちらを例に使います。
min_df=3(3テキスト以上に含まれる単語だけ学習する。3回以上では無いので注意)と指定してみます。

# モデル作成
bow_model = CountVectorizer(
    token_pattern="(?u)\\b\\w+\\b",
    ngram_range=(1, 2),
    min_df=3,
)

# 学習
bow_model.fit(corpus)

# 学習した単語
print(bow_model.get_feature_names())
# ['いる', 'が', 'た', 'て', 'て いる', 'です', 'と', 'ない', 'に', 'の', 'は', 'ます', 'ます た', '王子']

「て いる」は3テキストに含まれているので学習されていますね。その一方で「王子 は」は1テキストにしか含まれていないので学習結果に含まれていません。「王子」と「は」はそれぞれ3テキスト以上に含まれていますが、「王子 は」という並びで登場したのが1回だけだったので対象外になっているのです。

このことから、min_dfによる間引きはまずn-gramを学習してその後に行われていることがわかりますね。stop_wordsと実行タイミングが違うので注意しましょう。

例示はしませんがmax_dfも話は同様です。ちなみに、 max_df=0.7 とすると、 ユニグラムの「た」は学習結果らから除外されて、バイグラムの「ます た」などは含まれることが確認できます。

kerasのTokenizerで文書をtfidfベクトル化したときの計算式について

前回で終わりにしようと思っていたのですが今回もtfidfの記事です。
あまり使う機会はないのですが、kerasのテキストの前処理機能である、
keras.preprocessing.text.Tokenizer にも実はコーパスをtfidf化する機能が実装されています。

ドキュメントを読んでもtfidf機能があること自体が書いてありません。(したがって、実装されているtfidfの定義も書いてありません。)
参考: テキストの前処理 – Keras Documentation
そして、実際に動かしてみると、一般的な定義とは違う定義で動いているようなのでどのような計算式なのか調べました。

とりあえずいつもの例文で動かしてみます。
動かし方は、インスタンスを作り、fit_on_textsでコーパスを学習した後、
texts_to_matrixで変換するときに、引数でmode="tfidf"するだけです。

やってみます。


import pandas as pd
from tensorflow.keras.preprocessing.text import Tokenizer

# 表示桁数を小数点以下3位までにする
pd.options.display.precision = 3

# データを準備
corpus = [
    'This is the first document.',
    'This document is the second document.',
    'And this is the third one.',
    'Is this the first document?',
]

keras_tokenizer = Tokenizer()
keras_tokenizer.fit_on_texts(corpus)

# 学習した単語のリストを作成(インデック0はpaddingした単語に予約されている)
word_list = [
        keras_tokenizer.index_word.get(i, "")
        for i in range(len(keras_tokenizer.index_word)+1)
    ]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
      this     is    the  document  first  second    and  third    one
0    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
1    0.0  0.588  0.588  0.588     1.174  0.000   1.099  0.000  0.000  0.000
2    0.0  0.588  0.588  0.588     0.000  0.000   0.000  1.099  1.099  1.099
3    0.0  0.588  0.588  0.588     0.693  0.847   0.000  0.000  0.000  0.000
"""

出現する文書数とidfの対応は以下のようになっていそうです。
出現回数1回 ・・・ idf 1.099
出現回数2回 ・・・ idf 0.847
出現回数3回 ・・・ idf 0.693
出現回数4回 ・・・ idf 0.588

このようになる数式を探してみたところ、
$$
\text{idf}_{t} = \log{\left(1+\frac{\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}
$$
が当てはまるようです。

scikit-learnのデフォルトの定義である、
$$
\text{idf}_{t} = \log{\left(\frac{1+\text{総文書数}}{1+\text{単語tを含む文書数}}\right)}+1
$$
と似てるけど微妙に違いますね。

検算します。


for n in range(1, 5):
    print(np.log(1+4/(n+1)))

"""
1.0986122886681098
0.8472978603872034
0.6931471805599453
0.5877866649021191
"""

さて、idfがわかったので次はtdです。
tdに着目しやすくするために、単語1種類だけにして、出現回数だけ変えたコーパスで実験します。


corpus_2 = [
    "document",
    "document document",
    "document document document",
    "document document document document",
]

df = pd.DataFrame(
        keras_tokenizer.texts_to_matrix(corpus_2, mode="tfidf"),
        columns=word_list
    )
print(df)
"""
     this   is  the  document  first  second  and  third  one
0    0.0   0.0  0.0  0.0     0.693    0.0     0.0  0.0    0.0  0.0
1    0.0   0.0  0.0  0.0     1.174    0.0     0.0  0.0    0.0  0.0
2    0.0   0.0  0.0  0.0     1.455    0.0     0.0  0.0    0.0  0.0
3    0.0   0.0  0.0  0.0     1.654    0.0     0.0  0.0    0.0  0.0
"""

ifidfの値が、出現回数1回の場合(=idf)の何倍になっているかみます。


print(df["document"]/df["document"].iloc[0])
"""
0    1.000
1    1.693
2    2.099
3    2.386
Name: document, dtype: float64
"""

どうやら、
$$
\text{tf}_{t,d} = 1+\log{\text{文書d中の単語tの出現回数}}
$$
のようです。

検算がこちら。


for n in range(1, 5):
    print(1+np.log(n))

"""
1.0
1.6931471805599454
2.09861228866811
2.386294361119891
"""

これで、実験的にkerasにおけるtfidfの定義がわかりました。

あとはドキュメントと突き合わせて確認したかったのですが、冒頭に書いた通りドキュメントには記載がありません。
と言うことで、ソースコードを直接みてみます。

どうやらこの部分が該当するようです。
参考: keras-preprocessing/text.py at master · keras-team/keras-preprocessing · GitHub


                elif mode == 'tfidf':
                    # Use weighting scheme 2 in
                    # https://en.wikipedia.org/wiki/Tf%E2%80%93idf
                    tf = 1 + np.log(c)
                    idf = np.log(1 + self.document_count /
                                 (1 + self.index_docs.get(j, 0)))
                    x[i][j] = tf * idf

ここまで実験的に導いてきた結論と一致しますね。
ハードコーディングされているのでscikit-learnのような細かなオプションはなさそうです。