B'zの歌詞をPythonと機械学習で分析してみた 〜LDA編〜
1. 本Part概要
前PartではB'zの歌詞を「TF-IDF」を用いた分析を行いました。
本Partではトピックモデルの一つである「LDA」を用いた分析についてお話しします。
2. LDAとは
2.1 LDAのイメージ
先に簡単な説明をしてしまいます。
LDAは「たくさんの文書データから単語のグルーピングを行う」モデルです。
このグループ1つ1つを「トピック」と呼びます。
例えば、大量のニュース記事にLDAを適用する例を考えます。
LDAでは「各トピック(トピック数は予め指定)における各単語の所属確率」が算出されます。
理論的なことはさておき、文書データから単語をいくつかのグループに自動で分けてくれる手法
との理解で大丈夫です。
よく勘違いされることとして以下の2点を示します。
- トピック数(いくつのグループに分けるか)は予め決めておく
- どのような意味を持つグループを作るかは予め人間が決めるのではなく、得られた確率から後で人間が解釈をする
特に後者についてはよく勘違いされている方がおりますのでご注意を。
例えば上記の例でいうと、トピック1は「サッカー」や「野球」という単語が高い確率で出現していることからトピック1は「スポーツ」と解釈を与えるといった感じです。
あくまでも、「統計学的に最適なグループ分けをした結果こうなりました」ということをお忘れなく。*1
2.2 LDAの理論
イメージだけでなく一応データサイエンティスト(見習い)感を出すために難しい話もします。
上記でイメージが掴めたなら読み飛ばしても大丈夫です。
では本題に戻り、早速論文のリンクを貼らせていただきます。
http://www.jmlr.org/papers/volume3/blei03a/blei03a.pdf
どこからどこまで話すべきなのか難しいですが、文書データは文書と単語の共起と捉えられ様々な生成モデルが研究されておりました。
この研究の一つとしてHoffmanはpLSAという確率的潜在モデル、いわゆる(確率的)トピックモデルが提案されました。
LDAはざっくり言うと、このpLSAを発展させ「潜在クラスの元での文書の出現確率(多項分布)に事前分布(ディリクレ分布)を仮定」したモデルとなっております。
相当端折って説明しているので、詳しくは論文のIntroductionを読んでください。
データの生成過程を表すグラフィカルモデルは以下のようになっております。
にディリクレ分布を仮定していることがミソです。
このモデルの全文書(コーパス)の生成確率は以下の通りです。
この生成過程に対し、尤もらしいパラメータになるようにパラメータを推定します。*2
しかし、潜在クラスを仮定し、しかも事前分布を仮定しているために陽にパラメータ推定することができません。
そこで、Bleiらは「変分ベイズ」による推定を提案しました*3。
かなり駆け足で説明しましたが、LDAについての論文以外の日本語の説明としては以下の本に相当詳しく書かれております*4。
トピックモデルによる統計的潜在意味解析 (自然言語処理シリーズ)
- 作者: 佐藤一誠,奥村学
- 出版社/メーカー: コロナ社
- 発売日: 2015/03/13
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (5件) を見る
3. 歌詞データへのLDAの適用
さて「文書データから単語のグルーピングを行う」LDAを歌詞データに適用します。
上記の数式を見るにめちゃくちゃ難しいモデルと感じますが、なんとgensim
というパッケージで簡単に回せます。
3.1 コード
辞書の作成
使う単語のリスト化したものを辞書と呼び、gensim
の型の辞書を作成します。
from gensim import corpora text_list=list(df_all["英語の削除"]) dictionary =corpora.Dictionary(text_list)
実はこの段階でfilter_extremes
を使うことで2文書(曲)以上に登場している単語のみを対象とするといったように前処理を行うことができます。
今回は
- 元のまま:7,763 語
- 1曲のみに:3,057語
- 2割以上の曲で出現する単語の削除後:3,028語
- 5割以上の曲で出現する単語の削除後:3,028語(2割と同じ)
という集計もあったので、「1曲のみに出現、2割以上の曲に出現する単語を削除」しました。*5
#1曲のみに出現、2割以上の曲に出現する単語を削除 dictionary.filter_extremes(no_below=2,no_above=0.2) dictionary.save_as_text('最終辞書.txt') # dictionary = corpora.Dictionary.load_from_text('最終辞書.txt')
ちなみにprint(dictionary.token2id)
で辞書の中身を確認できます。
Corpusの作成
先ほど作成した辞書を元に、gensim
のLDAの入力の型に文書データを変換しますよーってだけです。
ちょっと見慣れないdoc2bow
という関数を使います。
corpus=[dictionary.doc2bow(tokens) for tokens in text_list]
LDAの学習
作成した辞書とコーパスを使ってLDAを回します。
といっても、gensim
が便利すぎて下記のコードだけで学習ができます。
from gensim import models #トピック数の設定 zk=20 #モデルの学習 model = gensim.models.LdaModel(corpus, num_topics=zk, id2word=dictionary, random_state=2018 ) model.save('lda_bz.model')
これだけです。。。。gensim
すごい。。 。
ちなみに作者のmacbookでの334曲、一曲平均350単語くらいのコーパスの学習は1秒もかかりませんでした。
3.2 結果の確認
お待ちかねの結果です。
ここから先は(比較的)機械学習の知識がなくても読めます。
各トピックの出現確率
#各曲の各トピックへの所属確率の算出。(曲数×トピック数)のnumpy Prob_songs=np.array(model.get_document_topics(corpus,minimum_probability=0))[:,:,1] #DataFrameに収納 L=[ z for z in range(1,zk+1)] col_name=list(map(lambda x: "Prob_"+str(x),L)) df_prob=pd.DataFrame(Prob_songs) df_prob.columns=col_name #所属確率最大のトピック番号も算出 df_prob["Max"]=df_prob.idxmax(axis=1) def del_Prob(x): return int(x.split("_")[1]) df_prob["Max"]=df_prob["Max"].apply(lambda x : del_Prob(x)) #各トピックの出現確率の算出。曲のトピックへの所属確率を全曲で足して、全トピックで1になるように正規化 df_topic=pd.DataFrame(df_prob.drop("Max",axis=1).sum()/df_prob.drop("Max",axis=1).sum().sum()) df_topic.columns=["Prob"] df_topic["Topic"]=[ z for z in range(1,zk+1)] #可視化 plt.figure(figsize = (30,20)) ax= sns.barplot(x="Topic",y="Prob",data=df_topic,color="darkblue") ax.set_xlabel("Topic",fontsize=50) ax.set_ylabel("Prob",fontsize=50) ax.tick_params(axis='x', labelsize=40) ax.tick_params(axis='y', labelsize=40) plt.show()
特段1つのトピックの出現確率がぶっちぎっているなど、特に違和感のない結果になりました。*6
各トピックに高い確率で所属する単語
トピックごとの単語と出現確率のDataFrameを作成し、リストに格納 topic_word_prob=[] for z in range(zk): word=[] prob=[] topic = model.show_topic(z,1000) #適当な単語数分 for t in topic: word.append(t[0]) prob.append(t[1]) df_lda=pd.DataFrame({"word":word,"prob":prob}) topic_word_prob.append(df_lda)
この結果を全トピック分表示するのも煩雑なので、数トピック分の所属確率の高いTop5の単語を以下の表に載せます。
Topic/Rank | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
Topic1 | あなた | love | life | これ | 世界 |
Topic7 | baby | night | きみ | 待っ | 見つめ |
Topic8 | あなた | いつか | だれ | くる | like |
Topic10 | day | time | night | freedom | 涙 |
率直な感想として「異なる意味の単語同士で綺麗に分かれた」結果が得られました。
これを元に独断と偏見で各トピックに解釈を与えます。
Topic 1 | Topic 2 | Topic 3 | Topic 4 |
---|---|---|---|
あなたと世界の果てでloveを欲しいlife | あなたと新しい世界でnight | 旅立ちだれかと逢いひとつのloveの声聞いて | ラララ あなたとDive Round |
Topic 5 | Topic 6 | Topic 7 | Topic 8 |
---|---|---|---|
GUITAR KIDS RHAPSODY | 知らないおまえとlove | nightにきみと見つめあってbaby | あなたといつか来るだれかのための声 |
Topic 9 | Topic 10 | Topic 11 | Topic 12 |
---|---|---|---|
baby yeah | Freedom Time day Now | ひとつaway | Lady Go Round |
Topic 13 | Topic 14 | Topic 15 | Topic 16 |
---|---|---|---|
特に意味を持たない | あなたが欲しくてoh | 特に意味を持たない | あなたと僕らでhey |
Topic 17 | Topic 18 | Topic 19 | Topic 20 |
---|---|---|---|
今日あなたとbaby alright | 特に意味を持たない | 特に意味を持たない | 特に意味を持たない |
怒涛の20トピック分の解釈をしました。いい感じですね。
Word Cloudを用いた可視化
さて今回もWord Cloudを用いて可視化をします。
今回はトピックごとに所属確率の大きい単語を目立つように可視化することを目標とします。
データ入手編でも触れましたが、Word Cloudは単語の頻度の情報しか持ちません。そこで、トピックごとに単語の所属確率で重みづけた回数分の単語を生成させ、その擬似的な文書にWord Cloudを適用させます。
fig = plt.figure(figsize=(21, 12)) for z in range(zk): topic_text=[] for index, row in topic_word_prob[z].iloc[:2000,].iterrows(): word=row["word"] weight=int(row["prob"]*1000) for _ in range(weight): topic_text.append(word) words = Counter(topic_text) bz_mask = np.array(Image.open("bz.png"))#適当な画像 wc_bz = WordCloud(background_color="white", max_words=3000, max_font_size=70, mask=bz_mask, font_path=font_path ) wc_bz.generate_from_frequencies(words) plt.figure(figsize = (21,12)) sns.set_style("whitegrid") plt.title('Topic_{}'.format(z+1), fontsize=30) plt.imshow(wc_bz, interpolation='bilinear') plt.axis("off") plt.savefig('20_Topic_{}.png'.format(z+1)) plt.show()
これにより、このような図がトピック数分(今回は20)生成されます。
20トピック分並べると壮観です*7。
ちなみに下の一文は作者が先ほど与えた解釈です。
3.3 曲のマッピング
TF-IDF編と同様に、曲のマッピングを行います。
ここでは曲の各トピックへの所属確率を特徴量(20次元)とし、t-SNEを適用し可視化します。
コードは前TF-IDF編とほぼ同等なので省略します。
上図はトピックにより色分けしてあります。 TF-IDFの時と異なり、何個かの集団でまとまっている結果が得られました。
へぇと思った点として、例えば右上(オレンジ・トピック8)をみると「「泣いて泣いて泣き止んだら」と「パーフェクトライフ」は曲調は全然違うが歌詞的には似ている単語で構成されている」など、ファンとして興味深い結果が得られました。
またLDAの適用の全体的なまとめになりますが、「TF-IDFよりも解釈しやすい結果がLDAで得られた」とまとめることができると思います。
4. 最後に
本Partでは、LDAの適用を行い「トピックごとの解釈」や「曲のマッピング」を行いました。
次Partでは「Word 2 Vec」を用いた単語の意味表現(分散表現)の分析について話して行きたいと思います
B'zの歌詞をPythonと機械学習で分析してみた 〜TF-IDF編〜
1. 本Part概要
前Partでは、「歌詞データの前処理」についてお話ししました。
本Partではようやく分析の本編に入り「TF-IDFを用いた分析」についてお話ししていきます。
2. 文書データの分析
いわゆる文書データの一般的な分析観点である「単語の重要度」や「単語・文書の定量化」を行いたいと思います。
例えば、ニュースサイトでオススメの記事*1を出す問題を考えます。
ここで、各文書を「何らかの数値で定量化」(一般にベクトルを用いる)できているならば、あるAさんがいつも読む記事に数値的に近い記事をオススメするといった応用が考えれれます。*2
導入が長くなりましたが、単語・文書を定量化する重要性をお分りいただけたでしょうか。このような分析手法として様々な手法が研究・提案されています。本Partでは、その一つである「TF-IDF」の分析例を示していきます。
3. TF-IDFとは
さて、本題の「TF-IDF」の話に戻ります。
凄く簡単にTF-IDFの説明をすると、ある文書の単語の重要度を以下の2つの要素の掛け算で表現します。
- TF:その単語が文書での出現頻度
- IDF:その単語が出現している文書の数(レア度)
前者は、多く出現していれば重要な単語という解釈は容易にできると思います。
しかし、どんな文書でもたくさん出現していたら、そんなに大切でないということで、その単語のレア度を計る指標(IDF)を加味した指標が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
最低限、主成分分析(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が作成されます。
さて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())
それっぽいですが、明らかな「外れ値」が見受けられます。正直、こうなることは予想がついていました。
というのも、例えば"CHAMP"という曲は"champ"という他の曲ではあまり出てこない単語を一曲で何回も連呼しており、この曲の単語"champ"のTF-IDF値が非常に大きくなることは自明です。
このように他の曲では出てこない単語を連呼されると、その曲のベクトルが異常な値となり次元圧縮した際に端っこにマッピングされてしまいます。
一般的に次元圧縮をする際にこのような外れ値への対処として、1. 予め正規化しておく 2. 一回SVDなど他の圧縮手法を挟むことが知られています。
特に「1. 予め正規化しておく」ことはPCAなどでよく使うので知っておいた方が良いと思います。
X_scaled=StandardScaler().fit_transform(X)
今回はこれを使うと原点付近で曲がギュッとなってしまう恐れがあるので、あえて適用せず、手動で外れ値と思われる「CAHMP,VAMPIRE WOMAN, ルーフトップ, ALL OUT ATACK, 旅☆EVERYDAY, ピエロ」をマッピングの対象データフレームから手動で削除します。
外れ値の手動削除後のマッピングは以下のようになります。
なんだかそれっぽい図が生成されました。
右上に"Bad Communication"の各シリーズが固まっていることなどからある程度の妥当性があると考えられます。
「この図から読み取れる考察は以下のようになります」とスマートに考察を書きたいところですが、めちゃくちゃ考察が難しいです。
なんとかひねり出した考察として
初期の曲(濃い青)同士は固まっている
→ 初期は似たような単語を含む曲を書いていた最近の曲(オレンジや赤)は散らばっている
→ 最近は様々な単語の曲を書いている英語が多めの曲は固まる(中央下、左下)
3点が考えられます。(苦しい・・・)
考察ではないのですが、例えば「星降る夜に騒ごうと弱い男とEndless Summerは類似度高いのかぁ(中央上)」など、ファンとして新しい発見はありました。
いくらファンであっても歌詞をそこまで本気で読み込んで比較をしたことがないので、今回の結果は一定の面白さがありました。
とはいえ、インパクトのある考察を行うことは非常に難しいのが本音ですね。。。B'zファンの方助けてください。。。
6. 最後に
本Partでは、TF-IDFを用いて曲のマッピングを行いました。
その結果、何か面白そうな図が生成されましたが考察は難しいことが分かりました。
次PartではLDAを用いた分析についてお話しさせていただきます。