下町データサイエンティストの日常

下町データサイエンティストの成果物

B'zの歌詞をPythonと機械学習で分析してみた 〜TF-IDF編〜

1. 本Part概要

前Partでは、「歌詞データの前処理」についてお話ししました。
本Partではようやく分析の本編に入り「TF-IDFを用いた分析」についてお話ししていきます。

pira-nino.hatenablog.com

2. 文書データの分析

いわゆる文書データの一般的な分析観点である「単語の重要度」や「単語・文書の定量」を行いたいと思います。

例えば、ニュースサイトでオススメの記事*1を出す問題を考えます。
ここで、各文書を「何らかの数値で定量」(一般にベクトルを用いる)できているならば、あるAさんがいつも読む記事に数値的に近い記事をオススメするといった応用が考えれれます。*2

導入が長くなりましたが、単語・文書を定量化する重要性をお分りいただけたでしょうか。このような分析手法として様々な手法が研究・提案されています。本Partでは、その一つである「TF-IDF」の分析例を示していきます。

3. TF-IDFとは

さて、本題の「TF-IDF」の話に戻ります。
凄く簡単にTF-IDFの説明をすると、ある文書d_jの単語w_iの重要度を以下の2つの要素の掛け算で表現します。

  1. TF:その単語w_iが文書d_jでの出現頻度
  2. IDF:その単語が出現している文書の数(レア度)

前者は、多く出現していれば重要な単語という解釈は容易にできると思います。
しかし、どんな文書でもたくさん出現していたら、そんなに大切でないということで、その単語のレア度を計る指標(IDF)を加味した指標がTF-IDFです。

f:id:pira_nino:20180728205446p:plain
TF-IDFの算出式

上記の式からもわかるように、「ある文書のある単語の重要度」として、(文書数×単語数)の行列を算出します。

4. 歌詞データのTF-IDFの計算

さて、実際にTF-IDFの算出をしていきます。 個人的にはsklearnを使う際は、それっぽい解説のブログ(必要であれば論文)を読んで、ある程度の理解をした後に公式サイトを見て引数を把握します。

sklearn.feature_extraction.text.TfidfVectorizer — scikit-learn 0.19.2 documentation

以下のコードのように簡単にTF-IDFを算出できます。軽くコメントをいくつか箇条書きで示します。

  • vectorizer = TfidfVectorizer()で初期化
  • 上記のインスタンスの作成の際に、n-gramにしたり対象単語を切ったり色々できる(TfidfVectorizerのよく使いそうなオプションまとめ)
  • vectorizer.fit_transform()で出力されるベクトルはscipyのスパースマトリックスなので扱いやすくするために.toarray()numpyに変換
  • dict(zip(vectorizer.get_feature_names(), vectorizer.idf_))で単語のIDFが出せるなど、色々できるのでやりたいことは適宜ググりましょう。
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.feature_extraction.text import CountVectorizer

vectorizer = TfidfVectorizer()
X=vectorizer.fit_transform(text_list)
X=X.toarray()

ちなみに下記のコードで確認できるIDFの下位の単語を見ると、「ない」「いい」「いる」など、確かにどの曲でも出てくる単語が低く算出されていることが分ります。

idf = dict(zip(vectorizer.get_feature_names(), vectorizer.idf_))
df_idf = pd.DataFrame(columns=['idf']).from_dict(
                    dict(idf), orient='index')
df_idf.columns = ['idf']
display(df_idf.sort_values("idf").head(10).T)

5. TF-IDFを用いた曲の可視化

さて、めでたく上記で(曲数×単語数)のTF-IDFが計算されました。
この行列を用いて曲のマッピングをしたいと思います。

TF-IDFのように、大きなスパースな*3行列を見ると「次元圧縮をして可視化」をしたくなるのが世の常です。

今回は、近年何かと流行りのt-SNEを用いて圧縮を行い可視化を行います。 t-SNEについてここで話すと長くなるので、以下のリンクを見てくださればと思います。

http://www.jmlr.org/papers/volume9/vandermaaten08a/vandermaaten08a.pdf

2.2. Manifold learning — scikit-learn 0.19.2 documentation

qiita.com

最低限、主成分分析(PCA)よりいい最近流行りのイケてる次元圧縮手法と思っていただければ大丈夫です。

さて、実際にコードを書いていきます。

from sklearn.decomposition import TruncatedSVD
from sklearn.manifold import TSNE

#t-SNE
tsne= TSNE(n_components=2, verbose=1, n_iter=500)
tsne_tfidf = tsne.fit_transform(X)

#DataFrameに格納
df_tsne = pd.DataFrame(tsne.embedding_[:, 0],columns = ["x"])
df_tsne["y"] = pd.DataFrame(tsne.embedding_[:, 1])
df_tsne["song"]=df_all.SongName  
df_tsne["year"]=df_all.Year

上記でこのようなDataFrameが作成されます。

f:id:pira_nino:20180728180501p:plain
各曲のTF-IDFを2次元に次元圧縮したDataFrame

さて2次元に可視化を行うコードを書きます。非常に汚いですが、ご容赦下さい。*4

from numpy.random import *

#colorの設定
x = np.arange(30)
ys = [i+x+(i*x)**2 for i in range(30)]
colors = cm.rainbow(np.linspace(0, 1, len(ys)))

#散布図の作成
ax = sns.lmplot(
           'x', 
           'y', 
           data=df_tsne, 
           fit_reg=False,
           size = 120,
           aspect =2,
           hue="year",
           palette=colors,
           scatter_kws={
               "s":40
           }
)
#凡例の作成
lg=plt.legend(title="Year",
           fontsize=150,
           markerscale=20,
           loc="upper left",
          )
lg.get_title().set_fontsize(180)
 
#図の上にプロットする曲名の設定
def label_point(x, y, song, year,ax):
    df_tmp = pd.concat({'x': x, 'y': y, 'song': song,'year':year}, axis=1)
    for i, point in df_tmp.iterrows():
        c= colors[point["year"]-1988] #色の設定
        ax.text(point['x']+normal(0.01,0.005), #文字が完全に重なるのを避けるためにプロットする点を乱数でズラす
                point['y']+normal(0.01,0.005),
                str(point['song']),
                fontproperties= font_prop,
                fontsize=150,
                color=c
               )
label_point(df_tsne.x, df_tsne.y, df_tsne.song, df_tsne.year,plt.gca())

f:id:pira_nino:20180728195504p:plain
TF-IDFを特徴量とした曲のマッピング

それっぽいですが、明らかな「外れ値」が見受けられます。正直、こうなることは予想がついていました。
というのも、例えば"CHAMP"という曲は"champ"という他の曲ではあまり出てこない単語を一曲で何回も連呼しており、この曲の単語"champ"のTF-IDF値が非常に大きくなることは自明です。 このように他の曲では出てこない単語を連呼されると、その曲のベクトルが異常な値となり次元圧縮した際に端っこにマッピングされてしまいます。

一般的に次元圧縮をする際にこのような外れ値への対処として、1. 予め正規化しておく 2. 一回SVDなど他の圧縮手法を挟むことが知られています。
特に「1. 予め正規化しておく」ことはPCAなどでよく使うので知っておいた方が良いと思います。

X_scaled=StandardScaler().fit_transform(X)

今回はこれを使うと原点付近で曲がギュッとなってしまう恐れがあるので、あえて適用せず、手動で外れ値と思われる「CAHMP,VAMPIRE WOMAN, ルーフトップ, ALL OUT ATACK, 旅☆EVERYDAY, ピエロ」をマッピングの対象データフレームから手動で削除します。

外れ値の手動削除後のマッピングは以下のようになります。

f:id:pira_nino:20180728201558p:plain
TF-IDFを特徴量とした曲のマッピング(外れ値削除後)

なんだかそれっぽい図が生成されました。
右上に"Bad Communication"の各シリーズが固まっていることなどからある程度の妥当性があると考えられます。

「この図から読み取れる考察は以下のようになります」とスマートに考察を書きたいところですが、めちゃくちゃ考察が難しいです。
なんとかひねり出した考察として

  1. 初期の曲(濃い青)同士は固まっている
    → 初期は似たような単語を含む曲を書いていた

  2. 最近の曲(オレンジや赤)は散らばっている
    → 最近は様々な単語の曲を書いている

  3. 英語が多めの曲は固まる(中央下、左下)

3点が考えられます。(苦しい・・・)

考察ではないのですが、例えば「星降る夜に騒ごうと弱い男とEndless Summerは類似度高いのかぁ(中央上)」など、ファンとして新しい発見はありました。
いくらファンであっても歌詞をそこまで本気で読み込んで比較をしたことがないので、今回の結果は一定の面白さがありました。
とはいえ、インパクトのある考察を行うことは非常に難しいのが本音ですね。。。B'zファンの方助けてください。。。

6. 最後に

本Partでは、TF-IDFを用いて曲のマッピングを行いました。
その結果、何か面白そうな図が生成されましたが考察は難しいことが分かりました。 次PartではLDAを用いた分析についてお話しさせていただきます。

pira-nino.hatenablog.com

*1:これを曲に応用し、似たような歌詞の曲をレコメンドってよく分かりませんが・・・

*2:本例は最もシンプルな例であり、実際にはより複雑なレコメンドシステムが提案されています。

*3:1曲で出てくる単語には限りがあるので、多くのTFが0となりTF-IDF行列はスパースな行列になる

*4:ax.textの使い方など説明すべきポイントは山のようにありますがこのコードをガチで読み解く人もいないと思うので特に説明がなく申し訳ございません。同様の理由でコードを綺麗にしておりません。

B'zの歌詞をPythonと機械学習で分析してみた 〜前処理編〜

1. 本Part概要

前Partでは「歌詞データの入手」と「前処理の必要性」について話しました。
本Partでは「実際にどのような前処理をしたか」について話していきます。

pira-nino.hatenablog.com

2. 前処理の概要

先に今回行った前処理の流れについて書かせて頂きます。

  1. mecabを用いて「名詞・動詞・形容詞」の抽出
  2. nltk, sklearnの英語のstopwordリストを用いて英語の不用語の削除

あくまでも上記は本データへの前処理の一例であって、絶対的なものではないです。

今回対象とする歌詞データは日本語・英語が混在しているちょっと特殊な文書データとなっております。
そこで、mecabは英単語を全て名詞と判定する性質を用いて、先にmecabで品詞を絞って、後に英語の前処理を施しました*1

3. mecabを用いた前処理

3.1 mecabとは

簡単にいうと「日本語文を単語ごとに区切ってくれて、しかも品詞判定してくれる」便利なパッケージです。正確にはこれらの作業は「わかち書き」と呼ばれます。

インストールの仕方や簡単な使い方は以下のリンクを参考にしてください。

qiita.com

aidiary.hatenablog.com

例えば、以下のような感じで単語のわかち書きができます。

#辞書指定はお好みで
# tagger = MeCab.Tagger('')
tagger = MeCab.Tagger('/usr/local/lib/mecab/dic/mecab-ipadic-neologd/')
tagger.parse("")#バグ回避 

result = tagger.parse("今日の天気は晴れである。It is sunny today.")
print(result)

今日  名詞,副詞可能,*,*,*,*,今日,キョウ,キョー
の 助詞,連体化,*,*,*,*,の,ノ,ノ
天気  名詞,一般,*,*,*,*,天気,テンキ,テンキ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
晴れ  名詞,一般,*,*,*,*,晴れ,ハレ,ハレ
で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ
ある  助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル
。 記号,句点,*,*,*,*,。,。,。
It  名詞,一般,*,*,*,*,*
is    名詞,一般,*,*,*,*,*
sunny   名詞,一般,*,*,*,*,*
today   名詞,一般,*,*,*,*,*
.   名詞,サ変接続,*,*,*,*,*
EOS

有能ですね。見てお分かりの通り、英語は名詞判定されてしまうのでご注意を。*2

3.2 名詞・動詞・形容詞のみの抽出

NLPでは「名詞・動詞・形容詞以外は大して意味を持っていないので削除」する方針で前処理を行うことが一般的に知られています。
そこで、曲ごとに必要な品詞の単語のみを取り出すコードを書きます*3
詳細な文法等は以下のリンク等を参考にしてください。

詳細情報の取得 parse()の代わりにparseToNode()を使うと形態素の詳細情報が得られます。parseToNode()は先頭のノード(形態素情報)を返し、surfaceで表層形、featureで形態素情報を取得できます。両方とも文字列です。featureは , で区切られているのでsplit()などで分割して必要な情報を抽出します。

WindowsでMeCab Pythonを使う - 人工知能に関する断創録
def get_dokuritsugo_by_mecab(text):
    tagger = MeCab.Tagger('/usr/local/lib/mecab/dic/mecab-ipadic-neologd/')
    tagger.parse('') 
    node = tagger.parseToNode(text)
    word_list = []
    while node:
        pos = node.feature.split(",")[0]
        if pos in ["名詞", "動詞", "形容詞"]:   # 対象とする品詞
            word = node.surface
            word_list.append(word)
        node = node.next
    return " ".join(word_list)

df_all["mecabで前処理"]=df_all["Lyric"].apply(lambda x : get_dokuritsugo_by_mecab(x))

さて、こちらで前処理された某曲の歌詞がこちらになります。 と曲のサンプルを載せたいのですが、ちょっと色々怖いので勘弁してください。*4

4. 英語の前処理

さて、次に英語の前処理を行います。
英語の前処理の目的は"it"や"is"といった意味のない単語を削除することです。
前述でも若干触れましたが、実はこのような単語は"stop words"と呼ばれ公開されています

公開されているstop wordsリストで有名なものは以下の2つです。

  1. sklearn.feature_extraction
  2. nltk.corpus.stopwords
from sklearn.feature_extraction import stop_words
stop_words_sklearn=stop_words.ENGLISH_STOP_WORDS

import nltk
nltk.download('stopwords')
stop_words_nltk = nltk.corpus.stopwords.words('english')

どっちがいいかということで比較を行うと、sklearnは318語、nltkは179単語となっており、nltkの方が基本的な単語のリストになっております。

今回は、力技で両方ともくっつけてstop wordリストを作成します。

stop_words_nltk.extend(stop_words_sklearn)
stop_words_all=stop_words_nltk

リストにリストを足すときに、appendではうまくいかないことは想像がつくと思いますが、extendでうまくいきます。
(ちょっと躓いたのでシェア)

さらに実はなのですが、日本語のstop wordsリストを公開してくれているサイトも存在します。

slothlib_path = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
slothlib_file = urllib.request.urlopen(slothlib_path)
slothlib_stopwords = [line.decode("utf-8").strip() for line in slothlib_file]
slothlib_stopwords = [ss for ss in slothlib_stopwords if not ss==u'']

今回はこれを使うと「時間」や「彼女」などB'zっぽい単語も消えてしまうので、あえて使いません。

さて本題に戻り、stop wordsの削除をするコードを書きます。
上記のstop wordsの削除を行っても微妙な単語がいくつか残っていたので(本当はもっと上手くやりたいが)手動で削除。

import string
import re

add_words=["'",u"それ",u"てる",u"よう",u"こと",u"の",u"し",u"い",u"ん",u"さ",u"て",u"せ",u"れ"]
stop_words_all.extend(add_words)
def del_eng_stop_words(text):
    #!マークなども消したいので正規表現で空文字に置換
    regex = re.compile('[' +re.escape(string.punctuation) + '0-9\\r\\t\\n]')
    sample = regex.sub("", text)
    
    #小文字にして、stop wordsに入っていなければリストに追加
    words = [w.lower()  for w in sample.split(" ") \
                 if not w.lower()  in stop_words_all]
    words=' '.join(words)
    return words

df_all["英語の削除"]=list(df_all["mecabで前処理"].apply(lambda x: del_eng_stop_words(x)))

5. 前処理の効果の確認

5.1 高頻出単語の比較

前処理の効果を見るために、出現頻度が高い単語の比較を行います*5

f:id:pira_nino:20180728005257p:plainf:id:pira_nino:20180728005310p:plain
前処理前後の単語出現頻度Top20の比較

上図を見ると前処理後は「」「」「love」といったそれっぽい単語が高頻度で出現していることが見てとれます。
このことからいい感じに前処理できているということができます。

5.2 Word Cloudを用いた可視化

データ入手編でもお話ししたWord Cloudで再度可視化します。

f:id:pira_nino:20180728010942p:plain
Word Cloudを用いた単語の可視化(前処理後)

前Partで作成した図に比べ凄くそれっぽいカッコイイ図が生成されました。

個人的には満足なのでひとまず前処理はこちらで終了です。

6. 最後に

本Partでは諸々の前処理について書きました。

次Partでは、いよいよ「機械学習を用いた分析〜TF-IDF編〜」を書いていきます。

pira-nino.hatenablog.com

*1:試行錯誤の結果こうなりました

*2:そもそも英語はスペース区切りで単語が書かれているので、基本的にはわかち書きをする必要はない。

*3:コードが汚いですね

*4:分析結果の公開についてで書いてあることと若干異なりますがご容赦ください。

*5:ユニークな単語数などでも比較すべきなのですが、前述の\u3000のせいで単語が上手く区切られていないなどの様々な問題があり、最低限の前処理をしないと純粋な比較ができなので、今回は割愛させていただきました。