Aidemy Blog

株式会社アイデミーのブログです。機械学習・ディープラーニング関連技術の活用事例や実装方法をまとめる技術記事や、キャリア記事等を発信しています

機械学習で探すJ-POPあるある

はじめまして。aidemy研修生の川島と申します。

 

さて、いきなり個人的な近況報告で申し訳ないのですが、最近いろんな音楽を積極的に聞こうとしています。なかでもJ-POPは知っている曲の幅を広げようと頑張っております。

J-POP、僕は好きです(ここ大事)。で、これは僕がJ-POPを好きだという前提で聞いてもらいたいのですが、J-POPの歌詞って臭いのが多くないですか?

あいつらはすぐ誰かを愛しますし、手をつないで歩きますし、多分君のことが世界で一番大事ですし。これ、少しは感じたことのある人も多いはずです。

この件に関しては、専門家(?)の非常に有用な先行研究がありました。パーマ大佐さん(以下敬称略)という芸人の方の、「J-POPの歌詞あるあるの歌」(以下「あるあるの歌」)というものが存在します。


パーマ大佐 - 「J-POPの歌詞あるあるの歌」 Music Video

 

こちらがその、パーマ大佐の「あるあるの歌」です。暇な人はぜひ見てみてください。暇じゃなければ見なくてもいいです(でも面白いと思うよ)。

 

ここから先、少しだけ「あるあるの歌」のネタバレが入ることもありますので、ご了承ください。

 

この歌、ある程度J-POPを聞いたことのある人なら、ニヤッとさせられたりクスッと来たりする場面が多々あると思います。でも、「よくよく考えると具体的なのがすぐには思いつかないな……。本当にあったっけ?」ってこともないでしょうか。……僕はいくつかありました(多分J-POPに精通していないせい)。というわけで前置きが長くなりましたが、今回はpythonを用いてJ-POPの歌詞によく見られるフレーズを自分なりに分析していきたいと思います。

 

0.目次

 

1.環境

主なものを載せておきます。

・python 3.6.5

・anaconda 1.6.14

・jupyter 4.4.0

・numpy 1.14.3

・Janome 0.3.6

・gensim 3.4.0

・scikit-learn 0.19.1

2.フレーズから見る「あるある」

最初は、J-POPにおける「あるある」なフレーズを探していくのを目標にしたいと思います。

 

2-1.スクレイピングによる歌詞の取得

まずはスクレイピングにより、分析対象となる歌詞の一覧を引っ張ってきましょう。

今回は、Uta-Netさんのこちらのリンクから引っ張ってきました。楽曲ごとの月間アクセス数です。2018年6月9日現在、米津玄師、RADWIMPS、WANIMAなどのアーティストが多く見られます。

www.uta-net.com

 

 

コードは以下です。引数urlにより上記のURLを渡すと、4ページ目で50曲ずつ、計200曲を取り出してきてくれます。改行ごとに区切ったりアーティスト名や曲名を取得したりといった作業は分析には使いませんが、あとあと分析結果の表示の際に使うので一緒にやっちゃっています。

また、たまに同じ曲が2回ランクインしている場合があるので、それもあらかじめ抜いておきます。同じ曲が2回ってどういうこと?と思われるかもしれませんが、今回だと以下の2組がそれに該当します。

・打上花火(米津玄師) と 打上花火(DAOKO×米津玄師)

・スパークル [original ver.](RADWIMPS) と スパークル (movie var.)(RADWIMPS)

実は最初はこれがあるのに気づかず、バグかと思いずっとバグを探していました……。

 

from time import sleep
import urllib.request
from bs4 import BeautifulSoup
import re

#歌詞ページのURLから歌詞本体を取得
#関数scrapingから呼び出される
def song_scraping(url):
    s_html = urllib.request.urlopen(url)
    s_soup = BeautifulSoup(s_html, "lxml")
    lyric = s_soup.find("div", id="kashi_area")
    
    #HTML上で</br>区切りで改行されているのを用い、正規表現で行ごとの文を取得
    #行ごとに分ける必要がなければ lyric.text でも良い
    lines = re.findall(r'>.*?<', str(lyric))
    lines_cut = []
    for line in lines:
        #何もない行を除く
        if len(line) > 2:
            #空白が"\u3000"になっているので置換
            line = line.replace("\u3000", " ")
            lines_cut.append(line[1:len(line)-1])
    return lines_cut
            

#曲の一覧のurlが渡される
def scraping(url):
    lyrics = []   
    songs = []
    artists = []
    url_list = []
    for i in range(1,5):
        url_list.append(url + "?p=" + str(i))
    #それぞれのページについて
    for u in url_list:
        html = urllib.request.urlopen(u)
        soup = BeautifulSoup(html, "lxml")
        links = soup.find_all("a")

        for link in links:
            link_content = link.get("href")
            if link_content[0:5] == '/song':
                #曲へのリンクを見つけた場合そこに飛ぶ
                song_url = 'https://www.uta-net.com' + link_content
                lyrics.append(song_scraping(song_url))
                songs.append(link.text)
            elif link_content[0:7] == '/artist':
                #アーティストへのリンクを見つけた場合そのアーティスト名を保存
                artists.append(link.text)
        sleep(0.5)
    return lyrics, songs, artists

#曲のリストから歌詞の1行目を参照して同一と思われる曲を削除
def cut_same_song(lyrics, songs, artists):
    cut = []
    for i in range(len(songs)):
        for j in range(i+1, len(songs)):
            if lyrics[i][0] == lyrics[j][0]:
                cut.append(j)
    cut.sort()
    for k in range(len(cut)):
        del lyrics[cut[k]-k]
        del songs[cut[k]-k]
        del artists[cut[k]-k]
    return lyrics, songs, artists

url ="https://www.uta-net.com/user/ranking/monthly.html"
lyrics, songs, artists = scraping(url) lyrics, songs, artists = cut_same_song(lyrics, songs, artists)

2-2.形態素解析を用いた歌詞の分析 

上記の作業により、二重配列lyricsには配列で各曲の歌詞が、さらに配列で行ごとに分けられて入っています。これを基に歌詞によく出てくるフレーズを分析していきましょう。具体的な手順としては、シンプルですが以下のようなものです。フレーズはおおよそ2単語の組み合わせにより作られるという考えに基づいています。

・歌詞から形容詞、形容動詞、名詞、動詞を順に抜き出す。

・曲ごとに、ある程度近くにあった2単語に組に加点をする。

・一定数以上の曲にあった(一定以上の点を取った)単語の組を抽出する。

 

歌詞から形容詞、形容動詞、名詞、動詞を抜き出すのは、janomeを用いて形態素解析をすることで可能となります。形態素解析とは、文を単語ごとに抜き出して品詞や活用形などを特定することを指します。さらに形態素解析では、動詞などの原形を取得したり品詞の細かい分類を取得できたりします。便利……!

今回のコードではこれを利用し、細分類が「非自立」の単語は抜いておきました。「知ってる」の「てる」や「君のこと」の「こと」が非自立に含まれます。大雑把に言えば特に意味のない単語というわけですね。ただ、それでもたまに不要な単語が抽出されるので、手作業で不要な単語を登録しそれは抽出しないようにしておきます。しかし今回の場合は不要な単語が大きく分けて2種類存在します。具体的には、

・意味のあまりない単語

・異常に出現するけどそこまで興味のない単語

です。なぜこれを分けるのかは少しあとの方で説明するとして、以下が無視する単語リストです。

 

trash = [ "(", ")", "(", ")",  "'", ".", ",", "&", "&", ":", ";", "!", "?", "…", "-", "!", "?", "a", "m", "s", "t", \
         "I", "me", "you", "You", "we", "We","the", "that", "it", "is", "be", "are", "Are", "to", "ll", "re", "ve", \
         "say", "in", "for", "of", "and", "one", \
         "oh", "yeah", "wow", "baby", "Baby", \
         "さ", "ら", "す", "かな", "かん", \
          "人", "あと", "たち", "もと", "方", "先", "前", "度", "次", "気", "最後", "すべて", "場所", "全て", "的", "底", \
         "れる", "られる", "なる", "する", "いる", "ある", "くる", "いく", "せる", "ない", "いい", \
          "一", "二", "2", "2"]
         
block = ["それ", "そう", "どれ", "どこ", "ここ", "これ", "誰", "何", "なん", \
         "僕", "私", "君", "あなた", "自分", "ぼく", "僕ら", "僕たち", "今", \
         "いま", "いつ", "いつか", "今日", "明日", "日々", \
         "夢",  "幸せ", "気持ち", "恋", "愛", "言葉", "心", "好き", "想い", "未来", "勇気", "笑顔", "道", \
         "思う", "言う", "見る", "行く", "笑う", "愛す", "信じる", \
         "知る", "わかる", "できる", "出来る", "会う", "分かる", "なれる", "見える", \
          "愛しい", "優しい"]

 

前者trashが「意味のあまりない単語=ほかの助動詞などと同様にすっ飛ばす単語」、後者blockが「異常に出現するけど興味のない単語=ほかの名詞などと同様にカウントされるが加点のランキングに参加しない単語」です。

 

このストップワードリストについては少し前の記事「Pythonで国会議事録から、話題の政治ワードを抽出してみた」でも触れられていますが、そことは少し違う語録になっていると思います。だってJ-POPのやつら、やたら笑うしやたら愛するし、好きだの勇気だの未来だの笑顔だの、びっくりするくらい出現するんで……。あと、サビにめっちゃ英語になるんで、英語もある程度リストに入れておきました。「あるあるの歌」にある「間が埋められなくなるとすぐLaLaLaとかHeyとかBabyって言う」っていうの、まさにですね。

さて、trashとblockをわざわざ分けた意味について少しだけ触れておきます。もしblockをtrashと同様にすっ飛ばしてしまうと、何連続にも単語がすっ飛ばされ、その結果あまり近くにいない単語の組に加点してしまうことがしばしば起こるのです。そのため、 blockの単語は「加点には参加しないがすっ飛ばしはしない単語」ということでtrashと分けたのです。

また、加点対象となる単語の近さの基準ですが、これは前後2単語までとしました。例えば「暗闇であなたの背をなぞった」(米津玄師 Lemon)という歌詞では、

「暗闇」「あなた」「背」「なぞる」が抽出され、「あなた」はblockの対象なので「あなた」を含む組には加点されず、「暗闇」「背」や「背」「なぞる」といった2単語の組に加点され、「暗闇」「なぞる」の組は遠すぎるとみなし加点されません。

少し説明が長くなりましたが、コードは以下です。

 

import numpy as np
from janome.tokenizer import Tokenizer

#単語の組み合わせごとの出現頻度を調査
#lines: 歌詞が行ごとにリストで渡される
#dic_id: 今までに登場した単語一覧とそれらのIDの入った辞書
#dic_len: 引数として渡された時点でのdic_idの長さ
#mat: 二次元配列。それぞれの単語の組について、点数を保存している。
#例えば mat[0][3]==1 なら、dic_idでIDが1の単語と3の単語の組み合わせが存在した曲が1曲あったことになる。
#trash: 無視する単語リスト
#block: 単語として意味はあるが無駄に多く登場するので点数をつけない単語リスト
#dic_id, dic_len, mat を更新して返す
def analyze(lines, dic_id, dic_len, mat, trash, block):
    #1つ前、2つ前の単語
    pre = '\0'
    pre2 = '\0'
    #1つ前、2つ前の単語がblockに含まれていないかをここに保存。含まれていたら1が立つ。
    pre_block = [0, 0]
    #この曲で現れた単語の組み合わせはすべてこの二次元配列に保存する。
    mat_temp = np.reshape(np.zeros(len(mat)**2, dtype='uint8'), (len(mat), len(mat)))
    
    t = Tokenizer()
    for line in lines:
        #各行を単語に分ける(形態素解析)
        tokens = t.tokenize(line)  
        for token in tokens:
            #品詞分類
            part_of_speech = token.part_of_speech.split(",")[0]
            part_of_speech_sub = token.part_of_speech.split(",")[1]
            #原形を取得
            base_form = token.base_form


            if (part_of_speech == "名詞" or part_of_speech == "動詞" or part_of_speech == "形容詞" or part_of_speech == "形容動詞")\
                    and part_of_speech_sub != "非自立" and not base_form in trash:
                if not base_form in block:
                    #ここからカウントの対象とみなされた単語に対して行う操作
                    if not base_form in dic_id:
                        #新出の単語ならそれのために dic_id, mat_temp を拡張
                        dic_id[base_form] = len(dic_id)
                        if len(mat)==0:
                            mat = np.array([[0]], dtype='uint8')
                            mat_temp = np.array([[0]], dtype='uint8')
                        else:
                            mat = np.append(mat, np.reshape([np.zeros(len(mat), dtype='uint8')], (-1, 1)), axis=1)
                            mat = np.append(mat, [np.zeros(len(mat)+1, dtype='uint8')], axis=0)
                            mat_temp = np.append(mat_temp, np.reshape([np.zeros(len(mat_temp), dtype='uint8')], (-1, 1)), axis=1)
                            mat_temp = np.append(mat_temp, [np.zeros(len(mat_temp)+1, dtype='uint8')], axis=0)
                    #単語の組に加点
                    if not pre == '\0' and pre_block[0]==0:
                        i = dic_id[base_form]
                        j = dic_id[pre]
                        if i>j:
                            i, j = j, i
                        mat_temp[i][j] = 1
                    if not pre2 == '\0' and pre_block[1]==0:
                        i = dic_id[base_form]
                        j = dic_id[pre2]
                        if i>j:
                            i, j = j, i
                        mat_temp[i][j] = 1
                    #pre_blockの更新
                    pre_block[1] = pre_block[0]
                    pre_block[0] = 0
                else:
                    pre_block[1] = pre_block[0]
                    pre_block[0] = 1

                pre2 = pre
                pre = base_form
    #この曲で加点された結果を足して上書き
    mat = mat + mat_temp
    return dic_id, len(dic_id), mat


dic_len = 0
dic_id = {}
mat = []

for lyric in lyrics:
    dic_id, dic_len, mat = analyze(lyric, dic_id, dic_len, mat, trash, block)

 

これが終われば、二次元配列matにはそれぞれの2単語の組の点数が保存されています。

 

2-3.結果の表示

さて、分析作業はこれで終わりなので、出力はこの関数にお願いします。1曲分の歌詞を渡すと対象となった単語の登場部分を探索し、もしあればその該当付近の行、アーティスト、曲名を見せてくれるものです。

 

#歌詞と2つの単語を受け取り、その2つの単語の組み合わせを見つけ次第表示する
def show_example(lines, song, artist, word1, word2, trash, block):
    #今何行目にいるか
    now_line = 0
    #1つ目の探すべき単語が直近にあるかどうか。
    #現在見ている単語が探すべき単語なら1、1つ前の単語が探すべき単語なら2、
    #2つ前の単語が探すべき単語なら4を示す。
    flag1 = 0
    #同様に、2つ目の探すべき単語が直近にあるかどうか。
    flag2 = 0
    pre = "\0"
    pre2 = "\0"
    t = Tokenizer()
    for line in lines:
        tokens = t.tokenize(line)
        for token in tokens:
            part_of_speech = token.part_of_speech.split(",")[0]
            part_of_speech_sub = token.part_of_speech.split(",")[1]
            base_form = token.base_form


            if (part_of_speech == "名詞" or part_of_speech == "動詞" or part_of_speech == "形容詞" or part_of_speech == "形容動詞")\
                    and part_of_speech_sub != "非自立" and not base_form in trash and not base_form in block:
                if base_form == word1:
                    flag1 = 1
                if base_form == word2:
                    flag2 = 1
                if flag1+flag2 == 3 or flag1+flag2 == 5:
                    #この場合は見つかったので表示
                    print(song, artist, ':')
                    if now_line == 0:
                        print(lines[now_line])
                    else:
                        print(lines[now_line-1]+lines[now_line])
                    return True
                pre2 = pre
                pre = base_form
                flag1 *= 2
                flag2 *= 2
                flag1 %= 8
                flag2 %= 8
        now_line += 1
    #見つからなかった場合
    return False

 

そしてこの関数を以下のように実行します。

 

#「あるある」の可能性がある単語の組の表示
for i in range(len(mat)):
    word1 = [key for key, v in dic_id.items() if v==i][0]
    for j in range(i+1, len(mat)):
        word2 = [key for key, v in dic_id.items() if v==j][0]
       #今回は3回以上登場した組を抽出してみる
        if mat[i][j] >= 3:
            print('「' + word1 + '」「' + word2 + '」にかんする' + str(mat[i][j]) + '個の「あるある」が検出されました。')
            print()
            found_num = 0
            #歌詞の探索
            for lyric, song, artist in zip(lyrics, songs, artists):
                found = show_example(lyric, song, artist, word1, word2, trash, block)
                if found==True:
                    found_num += 1
                #表示は最大4曲までとする
                if found_num == 4:
                    break
            print()

 

とりあえず前半はこれで作業終了です。あとは実行して寝るだけ。おやすみなさい。

 

2-4.実行結果

さて、結果を見てみましょう。例えば、こんな感じで表示されます。9個はかなり多い方ですね。

 

「手」「伸ばす」にかんする9個の「あるある」が検出されました。

春雷 米津玄師 :
あなたにはこの世界の彩りが どう見えるのか知りたくて今頬に手を伸ばした 壊れそうでただ怖かった
スパークル [original ver.] RADWIMPS :
運命だとか未来とかって 言葉がどれだけ手を伸ばそうと届かない 場所で僕ら恋をする
ロメオ LIP×LIP :
潤んだ瞳 嘆く唇に触れたいと手を伸ばした
HANABI Mr.Children :
もう一回 もう一回僕はこの手を伸ばしたい

 

ということで、4曲以上に登場した「あるある」をいくつか以下に抽出しました。ついでに、「あるあるの歌」に似たようなもの、関連がありそうなものがあれば一緒に載せています。参考にしてください(何の?)。ちなみに3曲以上、2曲以上という条件にすれば、もっと「あるあるの歌」とかぶるものが出てきます。

4曲以上だと、思っていた以上に「あるある」っぽいものが的確に抽出されているように思います。3曲だとやはり、「何だこれは」というのもたまに入っていたりするのですが。当然、2曲以上という条件で抽出してみるとそれなりの頻度で変なのが入ってきました。偶然かぶるというのはわりとありますからね……。

 

あるある 抽出された単語 登場曲数 「あるあるの歌」対応箇所
やたら胸が痛む 「胸」
「痛い」
5  
大事なものは
すぐ遠く離れる
「離れる」
「遠い」
5 「何故J-POPの歌詞っていつも
大切な人がすぐいなくなるの」
すぐ涙を流す 「涙」
「流す」
8 「すぐに涙を流す」
すぐ風が吹く 「風」
「吹く」
11 「すぐ風になる」
(惜しい……)
すぐ夜が明ける 「夜」
「明ける」
5  
空がやたら青い 「青い」
「空」
7  
めっちゃ空を
見上げる
「空」
「見上げる」
5 「どうせ愛している君は今日も
同じ空の下にいるのだろう」
生きる意味を
やけに考える
「生きる」
「意味」
5  
すぐ目を閉じる 「目」
「閉じる」
11 「どうせ愛している君は僕の
瞼の裏に潜んでいるのだろう」
なぜか夏ばっかり
終わりが来る
「夏」
「終わる」
4  
すぐ花が咲く 「花」
「咲く」
6 「すぐ桜咲く」
(※「桜」「舞う」は3曲)
やたら手をつなぐ 「手」
「繋ぐ」
10  
しかも手を掴む 「掴む」
「手」
7  

 

わりと「言われてみれば……!」というのが多く感じるのではないでしょうか(もし感じなかったとしても、僕は多く感じましたので許してください)。

 

3.単語から見る「あるある」

さて、ここから後半です。先ほど配列blockにより「やたら登場するけど興味のない単語」を抜いておきました。単体であまりに頻繁に登場するし他の単語との組み合わせも興味がないので抜いたわけですが、よくよく考えてみればこれらの単語自身が単体で「あるある」です。ということで、このような単語が登場する箇所を見たい気持ちがあります。……とは言っても、単純に抽出して表示するとやばいんです。というのも、例えば「笑う」だと198曲中64曲に登場するのです。そんなにたくさん表示されると流石に見ていられないというのが率直な気持ちです。じゃあいくつかピックアップすればいいじゃないかという話ですが、いろんな文脈で「笑う」が使われるわけで、ランダムにピックアップしてもあまり面白い結果は得られません。というわけでここでは、「対象の単語がある程度似たような雰囲気で使われている箇所だけに絞ってピックアップ」ということを、pythonの機械学習にかんするライブラリの力を借りて行いたいと思います。

 

3-1.Doc2Vecを利用した準備

まずは取得した歌詞を用いて、Doc2Vecに学習させモデルを作成します。Doc2Vecは、大雑把に言えば文書をベクトル化することにより、文書の類似度などを定量的に示すことを可能にしてくれるものです。自然言語を処理しやすい数値に変換してくれるという点で、とても便利な機能なわけです。学習は以下のように行います。

from gensim.models.doc2vec import Doc2Vec
from gensim.models.doc2vec import TaggedDocument
from janome.tokenizer import Tokenizer

#スクレイピングで取得した1曲分の歌詞を学習できるように成形する
def make_training_doc(num, lines):
    t = Tokenizer()
    #改行で区切っていたのを結合する
    lyric = ''.join(lines)
    #janomeの形態素解析の分かち書きを利用する
    training_token = t.tokenize(lyric, wakati=True)
    training_doc = TaggedDocument(words=training_token, tags=[num])
    return training_doc

training_docs = [] for num, lyric in enumerate(lyrics): training_docs.append(make_training_doc(num, lyric)) #学習 model = Doc2Vec(documents=training_docs, min_count=1)

 

一方で、対象となる単語が出てくる箇所は予め抽出しておきます。対象の単語が「笑う」の場合、以下のようになります。

 

#対象の単語が含まれる部分を抽出
#見つかったかどうかと上書きしたlines_includingを返す
def pickup(lines, word, lines_including):
    t = Tokenizer()
    for i, line in enumerate(lines):
        tokens = t.tokenize(line)
        for token in tokens:
            base_form = token.base_form
            if (base_form == word):
                #見つかった場合
                if i==0:
                    lines_including.append(line+lines[i+1])
                elif i==len(lines)-1:
                    lines_including.append(lines[i-1]+line)
                else:
                    lines_including.append(lines[i-1]+line+lines[i+1])
                return lines_including, True      
    return lines_including, False


#対象の単語が含まれる付近の分をリストに格納
lines_including = []
#対象の単語が含まれる曲番号を順に格納
index_including = []
#対象の単語
word = "笑う"

for i, lyric in enumerate(lyrics):
    lines_including, found = pickup(lyric, word, lines_including)
    if found:
        index_including.append(i)

 

3-2.Doc2Vecによる対象箇所のベクトル化

これで、モデルと調べる対象が作成されました。作成したモデルに、「笑う」が出てくる近辺の文をベクトル化してもらいます。ここでは、特に引数を指定していないので、それぞれの文が100次元ベクトルで表されます。

 

#ベクトル化した結果を格納
vecs_including = np.array([])
t = Tokenizer()

#文書のベクトル化
for line_including in lines_including:
    token_including = t.tokenize(line_including, wakati=True)
    if len(vecs_including) == 0:
        vecs_including = np.array([model.infer_vector(token_including)])
    else:
        vecs_including = np.append(vecs_including, np.array([model.infer_vector(token_including)]), axis=0)

 

というわけで、言語がベクトルに置換されました。ここからは、ベクトルの値がなるべく近いものを集め、最も近かったものたちを表示します。これにより、「笑う」という単語が歌詞の中では主にどのように使われるのかが見られるわけです。

 

3-3.クラスタリングによる「あるある」の抽出

では、どのようにベクトル値が近いものを集めるかという話ですが、ここではクラスタリングを利用します。クラスタリングは教師なし学習の一つであり、点の集合を指定されたグループの数に、なるべく近いものどうしでグループ分けしてくれるものです。pythonではsklearnというライブラリにこのアルゴリズムを行ってくれるものがあるので、これを使うことにします。今回の例では、64個の100次元空間の点のクラスタリングを行うことになります。以下がそのコードです。

今回は最終的に10個弱の例を表示すればいいかなということで、全体を8で割った数をグループ数にしています。

 

from sklearn.cluster import KMeans

#いくつのグループに分けるか
groups = len(vecs_including)//8
#学習
km = KMeans(n_clusters=groups, max_iter=500, tol=1e-10)
#それぞれのベクトルの分類先のグループのIDを配列に格納
group_id = km.fit_predict(vecs_including)

これで、ベクトルがグループ分けされました。それぞれのグループにおける分散を求め、一番分散が小さいグループの歌詞を表示することにします。分散が小さいということはそれだけ文書の意味がグループ内で似ているということなので、最も「あるある」らしき歌詞群が得られるわけです。

 

#それぞれのグループの分散を求める
vars_including = []
for i in range(groups):
    vars_including.append(np.linalg.norm(np.var(vecs_including[group_id==i], axis = 0)))
    
#分散が小さい順になるようにインデックスをソート
vars_argsort = np.argsort(vars_including)

i=0
#表示されるグループのID
group_shown = 0

while(True):
    #曲が1つか2つしかグループに含まれていない場合は飛ばす
    if np.sum(group_id==vars_argsort[i]) > 2:
        group_shown = vars_argsort[i]
        break
    else:
        i+=1
    if i == groups:
        print("error.")
        break
    
for i in range(len(group_id)):
    if group_id[i]==group_shown:
        print(songs[index_including[i]], artists[index_including[i]])
        print(lines_including[i])
        print()

 

3-4.実行結果

これまでのコードを実行すると、以下のような歌詞が表示されます。とりあえず眺めてみてください。

 

君はロックを聴かない あいみょん
君は気づくのかい?なぜ今笑うんだい?嘘みたいに泳ぐ目

ピースサイン 米津玄師
口をついて叫んだあの日から変わっていく僕を笑えばいい独りが怖い僕を

真赤 My Hair is Bad
なぜか甘えてしまう格好つかないよなって笑ってた合鍵を返して

サザンカ SEKAI NO OWARI
思い出して つまずいたならいつだって物語の主人公は笑われる方だ人を笑う方じゃないと僕は思うんだよ

君に届け flumpool
その小さな手も上手く笑えない君が 笑えばあの日見た夢がまた一つ 叶う

僕の名前を back number
いらない思い出だらけの僕の頭を君は笑って抱きしめてくれた今 君の手を握って出来るだけ目を見て

手紙 back number
離れていても守られているんだあなたはずっと手を振って笑ってくれた帰り道迷わないように

アイラブユー 西野カナ
あたりまえに今日も隣で笑ってるけどやっぱり私はキミでよかった

愛をこめて花束を Superfly
理由なんて訊かないでよね今だけすべて忘れて 笑わないで受けとめて照れていないで

幻 My Hair is Bad
昨日見た夢のことずっと思い出してるあの日みたいに笑ってたあの時みたいに話してた

これだけ見せられてもちゃんと抽出されているのか分からないと思うので、比較としてほかの歌詞を載せておきます。これらは、一つや二つだけの歌詞でグループを形成しているものであり、いわば外れ値です。全体的に上の歌詞と雰囲気が違うものになっていることが分かるかと思います。というか2曲目はもはやよく分からないですが……。

 

今勝ち上がるためのお勉強 朗らかな表情踊る阿呆に見る阿呆 我らそれを端から笑う阿呆デカイ自意識抱え込んではもう 磨耗 すり減って残る酸っぱい葡萄

(LOSER 米津玄師)

ねぇね 千賀は (ねぇね!) 化粧水な 11本は多すぎ!ヘマしても笑ってLet's go! Come on! Come on! Hey!キタ ガヤ タマ ヨコ ミヤ ニカ セン

 (We are キスマイ! Kis-my-Ft2)

 

こういった外れ値が抜かれたことを考えれば、ある程度は洗練されて抽出されていることが分かるかと思います。とは言っても、見てもらうとわかる通り、完全にすべて同じような意味とはいかないようです。というのも同じ単語が含まれている時点で外れ値があるとは言え基本的に近いベクトル値であるみたいで、いろいろ試してはみたのですがそれらの分類は難しかったです。今回は、表示する歌詞の数の都合上このグループ数にしたわけですが。

今回は高々200曲ほどですので、何千、何万のオーダーで歌詞を読み取ればもう少し細かな分類も可能になってくるかもしれません。また、分析の対象となった歌詞を見ると、歌詞ならではの文脈が分かりにくいような言葉遣いが多く存在したので、それも分類を難しくする一因となっているのではないかと思います。

 何はともあれ、おおよその傾向として、J-POPでは「笑う」が登場する場合は大概君が笑っているのであったりということはわかるかと思います。あとは、人に笑われるパターンのものがいくつか入っていますね。

 

他の例として、「愛」(含まれるのは全55曲)のものを載せておきます。幸せいっぱいかと思いきや、案外ややグロテスクな表現や寂しい表現が多いですね。他のグループを除くと、幸せそうな歌詞が集まっているところもありました。例としてはこちらの方が上手くいったかもしれないです……。

ブルースカイ ブルー 西城秀樹
おそれなどまるで感じないではげしさが愛と信じた立ちどまることも許さずに

キセキ GReeeeN
君に巡り合えた それって『奇跡』2人寄り添って歩いて 永久の愛を形にしていつまでも君の横で 笑っていたくて

ゴーゴー幽霊船 米津玄師
今日も映画みたいな夢うつつ愛も絶え絶えの景色だそこでどんな夢見てもしょうがない

MAD HEAD LOVE 米津玄師
どんどろりんと言葉が溶けていくもう愛から愛へ愛されて愛まで脳みそ全部そんな感じ

さよならエレジー 菅田将暉
はじめてのキスを繰り返して欲しくて愛が僕に噛みついて 離さないと言うけれどさみしさのカタチは変わらないみたいだ

StaRt Mrs. GREEN APPLE
幸せな時間をどれだけ過ごせるかは…微々たるものでも愛に気づけるか。さあ 試されよう

愛をこめて花束を Superfly
ここに来られて本当に良かったわこの込み上がる気持ちが愛じゃないなら何が愛かわからないほど

二人セゾン 欅坂46
見過ごしちゃもったいない愛を拒否しないで君はセゾン

ガラスを割れ! 欅坂46
(OH OH OH OH OH…)愛の鎖引きちぎれよ歯向かうなら背中向けるな

I NEED U (Japanese Ver.) BTS (防弾少年団)
Girl, いっそ、いっそ教えて欲しいよGirl, 君と、君との愛が終わりと oh僕には出来ないよ

ハナウタ [ALEXANDROS]×最果タヒ
痛みだけが指にふれる愛おしさばかり打ち寄せだれにも触れたくないのに

サヨナラの意味 乃木坂46
守りたかった愛に代わるもの電車が通過する

魔法って言っていいかな? 平井堅
最後は君が背中をさすってくれたね愛という曖昧なものに君はその指で輪郭を描いてくれた

One Love 嵐
それぞれ描く 幸せのかたちは重なり今 大きな愛になるずっと二人で 生きてゆこう

遥か GReeeeN
「誰かに嘘をつくような人に なってくれるな」 父の願いと「傷ついたって 笑い飛ばして 傷つけるより全然いいね」 母の愛あの空 流れる雲 思い出す あの頃の僕は

4.おわりに

 いかがだったでしょうか。個人的には、上手くいくかかなり不安だったので、思ったよりはそれらしい結果が出てわりと安心しているところはあります。今回いろいろ試してみたわけですが、pythonの機械学習ライブラリを用いてあとは簡単なアルゴリズムを適用するだけで、自然言語とかいうよくわからないものを定量的に扱えるようになることに感動しました。しかし一方で、歌詞ならではの難しさのようなものも後半では浮かび上がってきたように思います。

今回は自然言語処理の技術の一部を利用したわけですが、調べれば調べるほど興味深い分野ですので、興味がある方はぜひ勉強してみてほしいです(とはいえ僕も勉強を始めたばかりですが)。

だらだらと書いてしまいましたが、このあたりで筆を置きたいと思います。ここまでお読みいただきありがとうございました。駄文、失礼しました。