Aidemy Tech 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の機械学習ライブラリを用いてあとは簡単なアルゴリズムを適用するだけで、自然言語とかいうよくわからないものを定量的に扱えるようになることに感動しました。しかし一方で、歌詞ならではの難しさのようなものも後半では浮かび上がってきたように思います。

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

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

OpenCVで乃木坂46秋元真夏と銀シャリ鰻和弘の類似度を調べてみた

初めまして、アイデミー研修生のだっちー(@dacciinfo)です。

仮想通貨のブログを書いているので「仮想通貨が大暴落したときのTwitter負の感情分析」みたいなことやろうかなーとざっくり思っていたのですが、ツイートの取得が1週間前までしか無理みたいなので断念しました。

 

そこで今回は好きな乃木坂46を取り上げたいと思います!

いきなりですが、このふたりをご存知でしょうか。

左が乃木坂46の秋元真夏さん、右が銀シャリの鰻和弘さんです。

f:id:h25e28:20180610190151p:plainf:id:h25e28:20180610151811p:plain


以前から、このふたりが似てるとネットで話題となっていました。

このふたりもブログやTwitterでコメントしています。

f:id:h25e28:20180610143603p:plain

鰻さんと真夏さんヽ(。・ω・。)ノ546 | 乃木坂46 秋元真夏 公式ブログ


確かに似てる感じしますね笑

そこで今回はOpenCVを用いて顔の類似点を調べ、どのくらい似ているのか調べたいと思います!

 

今回、プログラム書くにあたり参考とさせていただいたサイトは以下になります。

ありがとうございました。

OpenCVを使って誰の顔なのかを推定する(Eigenface, Fisherface, LBPH)

OpenCVを使った顔認識(Haar-like特徴分類器)

Fisherfaces


目次

使用するアルゴリズムと環境

今回、顔の類似度を調べるにあたり、使用したアルゴリズムは
Fisherfacesです。これはEigenfaceの改良版で、照明や角度の違いに影響されにくいといった特徴があります。
使用する画像からHaar-like特徴分類器を使用し、顔領域を抽出します。
その抽出した顔画像をFisherfaceで学習させ、未学習の画像と比較し、類似度を調べます。

環境は
Python 3.6.5
opencv 3.3.1

秋元さん15枚の画像を学習データとし、未学習の画像である鰻さんの画像をテストデータとします。鰻さんの比較対象として乃木坂メンバー数人の画像もテストデータとして用意します。
f:id:h25e28:20180614185515p:plain

f:id:h25e28:20180614191006p:plain

プログラム

import cv2
import os
import numpy as np
from PIL import Image

#トレーニング画像
train_path = './train_images'

#テスト画像
test_path = './test_images'

#Haar-like特徴分類器
cascadePath = "/Users/ユーザー名/Downloads/opencv-3.4.1/opencv-3.4.1/data/haarcascades/haarcascade_frontalface_default.xml"
faceCascade = cv2.CascadeClassifier(cascadePath)

#FisherFace
#recognizer = cv2.face_FisherFaceRecognizer.create()
recognizer = cv2.face_LBPHFaceRecognizer.create()

#pathしたフォルダ内の画像を習得
def get_images_and_labels(path):
    #画像を格納する配列
    images = []
    #ラベルを格納する配列
    labels = []
    #ファイル名を格納する配列
    files = []
    for f in os.listdir(path):
        #画像のパス
        image_path = os.path.join(path, f)
        #グレースケールで読み込み
        image_pil = Image.open(image_path).convert('L')
        #Numpyの配列に格納
        image = np.array(image_pil, 'uint8')
        #Haar-like特徴分類器で顔を検知
        faces = faceCascade.detectMultiScale(image)
        #検出した画像の処理
        for(x, y, w, h) in faces:
            #200×200にリサイズ
            roi = cv2.resize(image[y: y + h, x: x + w], (200, 200), interpolation=cv2.INTER_LINEAR)
            #画像を配列に格納
            images.append(roi)
            #ファイル名からラベルを取得
            labels.append(int(f[7:9]))
            #ファイル名を配列に格納
            files.append(f)
    
    return images, labels, files

#トレーニング画像を取得
images, labels, files = get_images_and_labels(train_path)

#トレーニング実施
recognizer.train(images, np.array(labels))

#テスト画像を取得
test_images, test_labels, test_files = get_images_and_labels(test_path)

i=0
while i < len(test_labels):
    #テスト画像に対して予測実施
    label, confidence = recognizer.predict(test_images[i])
    #予測結果をコンソール出力
    print("Test Image: {}, Predicted Label: {}, Confidence: {}".format(test_files[i], label, confidence))
    #テスト画像表示
    cv2.imshow("test image", test_images[i])
    cv2.waitKey(300)
    i += 1

#終了処理
cv2.destroyAllWindows()

実行結果はこちらです。

Test Image: subject01.akimoto.png, Predicted Label: 1, Confidence: 48.77426502706794
Test Image: subject01.asuka.jpg, Predicted Label: 1, Confidence: 54.37620231697899
Test Image: subject01.hori.jpg, Predicted Label: 1, Confidence: 53.24049819391969
Test Image: subject01.ikuta.jpg, Predicted Label: 1, Confidence: 56.262042555618166
Test Image: subject01.nishino.png, Predicted Label: 1, Confidence: 50.30127169485847
Test Image: subject01.shiraishi.jpg, Predicted Label: 1, Confidence: 72.06184327769823
Test Image: subject01.unagi.png, Predicted Label: 1, Confidence: 63.37511081588498

subject01の「01」をラベルに設定し、学習しています。複数の画像を学習させる際は、この数値を変更してください。学習した画像は今回秋元さんのみなのでラベルも1のみです。
分散に基づいて計算されているため、Confidence(確度)は0に近い方が確度が高くなります。
学習に使用した画像をテストで使用すると特徴点が一致するため、Confidenceは0です。

未学習の秋元さんの画像は48.77、もちろんこの中で一番近い値です。
類似度順に並び替えると

f:id:h25e28:20180614192751j:plainf:id:h25e28:20180610151811p:plainf:id:h25e28:20180614192801j:plainf:id:h25e28:20180614192754j:plainf:id:h25e28:20180614193458p:plainf:id:h25e28:20180614192805p:plainf:id:h25e28:20180610190151p:plain
白石<鰻<生田<齋藤<堀<西野<秋元

のようになりました。ちなみにニャンちゅうは顔認識されず対象外となってしまいました。

学習データを変更

こちらは秋元さんが番組で平野ノラさんのモノマネをしたときのものです。

f:id:h25e28:20180614194754j:plain


この番組でのモノマネした秋元さんの画像をできるだけ集めて学習させます。あとは先ほどと同様です。
f:id:h25e28:20180614195153p:plain


実行結果

Test Image: subject01.akimoto.png, Predicted Label: 1, Confidence: 64.72162690757675
Test Image: subject01.asuka.jpg, Predicted Label: 1, Confidence: 70.898033127977
Test Image: subject01.hori.png, Predicted Label: 1, Confidence: 71.68987057398674
Test Image: subject01.ikuta.jpg, Predicted Label: 1, Confidence: 72.3624149846466
Test Image: subject01.nishino.png, Predicted Label: 1, Confidence: 71.81215441739869
Test Image: subject01.shiraishi.jpg, Predicted Label: 1, Confidence: 78.99918092527798
Test Image: subject01.unagi.png, Predicted Label: 1, Confidence: 65.89760962981548

f:id:h25e28:20180614192751j:plainf:id:h25e28:20180614192801j:plainf:id:h25e28:20180614192805p:plainf:id:h25e28:20180614192757j:plainf:id:h25e28:20180614192754j:plainf:id:h25e28:20180610151811p:plainf:id:h25e28:20180610190151p:plain
白石<生田<西野<堀<齋藤<鰻<秋元

という結果になりました。
学習データを鰻さんに似ているモノマネした秋元さんの画像に変更することでより近い結果となりました。

学習データの追加

次は、先ほどの乃木坂メンバー白石、生田、西野、堀、齋藤の画像10枚学習させます。
もちろん、テストデータとは違うものです。

f:id:h25e28:20180615211357p:plain

ラベルは01が秋元、02が齋藤、03が堀、04が生田、05が白石、06が西野、07が鰻です。

実行結果

Test Image: subject01.akimoto.jpg, Predicted Label: 1, Confidence: 21.786044627006834
Test Image: subject02.asuka.jpg, Predicted Label: 2, Confidence: 51.351808634017296
Test Image: subject03.hori.jpg, Predicted Label: 3, Confidence: 44.75424378786359
Test Image: subject04.ikuta.jpg, Predicted Label: 4, Confidence: 45.70965779143789
Test Image: subject05shiraishi.jpg, Predicted Label: 5, Confidence: 49.08969544979982
Test Image: subject06nishino.jpg, Predicted Label: 6, Confidence: 43.72499865726888
Test Image: subject07.unagi.png, Predicted Label: 1, Confidence: 63.37511081588498

各乃木坂メンバーはsubjectとPredicted Labelが同じであるため、自分の顔の学習データとテストデータが一致していることが分かります。
鰻さんは学習させていないですが、Predicted Labelが1となりました。これは学習データの中から秋元さんの顔に近いと判断され、ラベル1に振り分けられました。

考察と感想

今回は、Haar-like特徴分類器を使って顔領域を抽出、Fisherfaceで学習、テスト、結果表示という流れで類似度を調べました。ブログに載せた以外にもAKAZE特徴量を使用した特徴点マッチングで類似度を試みましたが上手くいきませんでした。
学習データを変更、追加することでより近い結果になったと思います。今回は比較対象として乃木坂の数人を挙げましたが、少なくともこのメンバーの中では秋元さんが一番鰻さんに似ていることが分かりました。
Pythonにふれてまだ3週間で、まだまだ未熟ではありますがこれから自分で色々実装してみたいと思います。

西野カナに「恋」とは何か聞いてみた

こんにちは。研修生のがっさんです。
いきなりですが、みなさん恋してますか?
僕は出来てないです。というか恋が何か分かってません(笑)
好きは分かるけど、恋って何か重い感じがする。「恋してる」何て言ったことないし、恋って何だーと思ったので、定義を調べてみると

恋:異性に愛情を寄せること、その心。
ほほう。愛情とは?
愛情:相手にそそぐ愛の気持。
ほほう。愛とは?
愛:そのものの価値を認め、強く引きつけられる気持。

うーん。。。よく分からないので、よく恋をしてそうな西野カナ先生に聞いてみましょう!

f:id:nktng117:20180613141607p:plain

どうやって聞くか?

会って直接!とはいかないので、今回は西野カナ先生の歌に対して自然言語処理をして、先生が恋をどのように表現しているか調べてみます。言い換えると
「西野カナ先生に恋とは何かを聞く」

「西野カナ先生の歌詞から辞書を作り、恋と類似度の高い言葉は何かを解析する」

読者対象
  • 自然言語処理で何が出来るか知りたい人
  • 恋に悩んでいる人
使う技術
  • 自然言語処理(Janome, word2vec)
  • スクレイピング(BeautifulSoup)
  • Python

1.データの抽出

まず始めに、こちら(https://www.uta-net.com/search/?Aselect=1&Keyword=%E8%A5%BF%E9%87%8E%E3%82%AB%E3%83%8A&Bselect=3&x=0&y=0)から西野カナの歌全167曲の歌詞をスクレイピングで抽出します。
ライブラリはBeautifulSoupを使います。

import requests
from bs4 import BeautifulSoup

base_url = "https://www.uta-net.com"
target_url = 'https://www.uta-net.com/search/?Aselect=1&Keyword=%E8%A5%BF%E9%87%8E%E3%82%AB%E3%83%8A&Bselect=3&x=0&y=0'
music_num = 167

r = requests.get(target_url)

soup = BeautifulSoup(r.text, "html.parser")
url_list = []
#曲一覧から各曲のURLを取り出してリストに入れる
for i in range(music_num):
   href = soup.find_all("td", attrs={"class": "side td1"})[i].contents[0].get("href")
   url_list.append(href)         

kashi = ""
#曲ごとにRequestを送り歌詞を抽出する
for i in range(music_num):
   target_url = base_url + url_list[i]
   r = requests.get(target_url)
   soup = BeautifulSoup(r.text, "html.parser")

   for string in soup.find_all("div", attrs={"id": "kashi_area"})[0].strings:
       kashi += string

with open('kashi.txt', mode = 'w', encoding = 'utf-8') as fw:
   fw.write(kashi)


これでkashi.txtに全曲の歌詞が入りました。

2.データの前処理

歌詞にはLoveやWowといった英語も含まれており、英数字、記号を正規表現で削除し、日本語のみの歌詞にします。

import re
# 英数字の削除
kashi = re.sub("[a-xA-Z0-9_]","",kashi)
# 記号の削除
kashi = re.sub("[!-/:-@[-`{-~]","",kashi)
# 空白・改行の削除
kashi = re.sub(u'\n\n', '\n', kashi)
kashi = re.sub(u'\r', '', kashi)

3.形態素解析

次に歌詞データに対して形態素解析を行います。
形態素解析とは日本語を最小単位に分割し、品詞などを同定する行為です。
形態素解析なんて難しそーと思われるかもしれませんが、Janomeという形態素解析を行ってくれるライブラリがあるのでそれを使います。

def tokenize(text):
    t = Tokenizer()
    tokens = t.tokenize(text)
    word = []
    stop_word = create_stop_word()
    for token in tokens:
        part_of_speech = token.part_of_speech.split(",")[0]
        if part_of_speech == "名詞":
            if not token.surface in stop_word:
                word.append(token.surface)        
        if part_of_speech == "動詞":
            if not token.base_form in stop_word:
                word.append(token.base_form)
        if part_of_speech == "形容詞":
            if not token.base_form in stop_word:
                word.append(token.base_form)        
        if part_of_speech == "形容動詞":        
            if not token.base_form in stop_word:
                word.append(token.base_form)

    return word

結果はこのようになります。
「・・・横顔を見つめながら考えてる・・・」(歌詞)

横顔 名詞,一般,*,*,*,*,横顔,ヨコガオ,ヨコガオ
を 助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
見つめ 動詞,自立,*,*,一段,連用形,見つめる,ミツメ,ミツメ
ながら 助詞,接続助詞,*,*,*,*,ながら,ナガラ,ナガラ
考え 動詞,自立,*,*,一段,連用形,考える,カンガエ,カンガエ
てる 動詞,非自立,*,*,一段,基本形,てる,テル,テル

「は」や「の」といった助詞・助動詞は今回は必要ないので、形態素解析を行った後に、名詞・動詞・形容詞・形容動詞だけを取り出しています。
動詞・形容詞・形容動詞に関しては、活用されているものは基本形に直します。基本形は.base_formで取り出せます。
(例)見つめ/て → 見つめる/て

surface 表層形
infl_type 活用型
infl_form 活用形
base_form 原形
print token.reading 読み
print token.phonetic 発音

他にも関係なさそうな単語を省くためストップワードリストを作成します。
ストップワードは以下を参考に、いくつか自分で追加しました。
http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt

def create_stop_word():
    target_url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    r =requests.get(target_url)
    soup=BeautifulSoup(r.text, "html.parser")
    stop_word=str(soup).split()
    #自分で追加
    my_stop_word=['いる','する','させる','の','色','真夏','身体','最初','知る','られる']
    stop_word.extend(my_stop_word)
    return stop_word

これで辞書が完成しました。

4.word2vecで学習

word2vecは、大量のテキストデータを解析し、各単語の意味をベクトル表現化する手法です。Word2Vecを使うことで、単語と単語の関係性を簡単に表現でき、
「王様」 - 「男」+ 「女」 = 「女王」
「パリ」 - 「フランス」 + 「日本」 = 「東京」
のような 単語同士の 演算が出来たり、単語同士の類似度を計算することができます。
では歌詞データに使われている単語の関係性をword2vecに学習させましょう。

model = word2vec.Word2Vec(sentence, size=200, min_count=4, window=4, iter=50)

パラメータはそれぞれ以下を表しています。

size ベクトルの次元数
min_count n回未満登場する単語を破棄
window 学習に使う前後の単語数

5.類似度の計算

さあようやく準備が出揃いました。
後は西野カナ先生が恋をどのように表現しているのかを調べるだけです。
どうやって調べるかというと、恋という単語に対して類似度の高い単語を出力します。学習したモデルに対し、

.most_similar(positive=["単語"])

という風にmost_similarメソッドを使うことでその単語と類似度の高い単語が出力されます。

from gensim import corpora
from janome.tokenizer import Tokenizer
from gensim.models import word2vec
import matplotlib.pyplot as plt
from wordcloud import WordCloud
import re
import requests
from bs4 import BeautifulSoup

with open("kashi.txt", "r", encoding="utf-8") as f:
    kashi = f.read()

# 英数字の削除
kashi = re.sub("[a-xA-Z0-9_]","",kashi)
# 記号の削除
kashi = re.sub("[!-/:-@[-`{-~]","",kashi)
# 空白・改行の削除
kashi = re.sub(u'\n\n', '\n', kashi)
kashi = re.sub(u'\r', '', kashi)
# counter = {}
# 品詞を取り出し「名詞、動詞、形容詞、形容動詞」のリスト作成
def tokenize(text):
    t = Tokenizer()
    tokens = t.tokenize(text)
    word = []
    stop_word = create_stop_word()
    for token in tokens:
        part_of_speech = token.part_of_speech.split(",")[0]
        if part_of_speech == "名詞":
            if not token.surface in stop_word:
                word.append(token.surface)        
        if part_of_speech == "動詞":
            if not token.base_form in stop_word:
                word.append(token.base_form)
        if part_of_speech == "形容詞":
            if not token.base_form in stop_word:
                word.append(token.base_form)        
        if part_of_speech == "形容動詞":        
            if not token.base_form in stop_word:
                word.append(token.base_form)

    # for wo in word:
    #     if not wo in counter: counter[wo] = 0
    #     counter[wo] += 1
    return word

def create_stop_word():
    target_url = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    r =requests.get(target_url)
    soup=BeautifulSoup(r.text, "html.parser")
    stop_word=str(soup).split()
    #自分で追加
    my_stop_word=['いる','する','させる','の','色','真夏','身体','最初','知る','られる']
    stop_word.extend(my_stop_word)
    return stop_word

sentence = [tokenize(kashi)]
model = word2vec.Word2Vec(sentence, size=200, min_count=4, window=4, iter=50)
print(model.wv.most_similar(positive=[u"恋"], topn=10))

topnで上位表示数を指定してます。



出力結果
1 追いかける 0.8969742059707642
2 待てる 0.8759297728538513
3 生まれ変わる 0.8688281178474426
4 押す 0.8627504110336304
5 つらい 0.8446334004402161
6 失う 0.8399721384048462
7 逃す 0.8399657011032104
8 送る 0.833713710308075
9 為 0.8328136801719666
10 噂 0.830684244632721

一番近かったのは「追いかける」でした。西野先生いわく、恋とは追いかけるものらしいです。
てっきり「震える」が出ると思ったら出ませんでしたね。
これらの結果から無理やり定義すると
恋:追いかけて、時には待ち、つらい思いもするが生まれ変われるもの
という結果になりました。
なるほど、さすが西野先生。納得しました!

せっかくなのでaiko先生にも聞いてみたいと思います。

f:id:nktng117:20180613141743p:plain

出力結果
1 堕ちる 0.9168707132339478
2 さよなら 0.915056347846984
3 全て 0.9063875675201416
4 嫌い 0.9030067920684814
5 怒る 0.889123797416687
6 並べる 0.8881044983863831
7 上げる 0.8861263990402222
8 我慢 0.885418713092804
9 勇気 0.8848025798797607
10 痺れる 0.8845231533050537

「さよなら」「嫌い」「怒る」「我慢」等。
aiko先生は恋はわりとネガティブなモノと考えているみたいですね。

おまけ

恋についてもっと知りたくなったので、いろいろな演算を行っていましょう!
演算はpositiveに足す単語を、negativeに引く単語をいれることで出来ます。

model.wv.most_similar(positive=[u"単語1", u"単語2"], negative=[u"単語3"], topn=10)

この場合
単語1 + 単語2 - 単語3
になります。


・心
恋という漢字の下に心がつくように、恋とは心あってのものだと思います。
では心のない恋とは存在するのでしょうか?恋の概念から心の概念を引いてみました。

model.wv.most_similar(positive=[u"恋"], negative=["心"], topn=3)

1 友達 0.7440136075019836
2 れる 0.7216039896011353
3 思う 0.7072246074676514

友達になっちゃいました。心が動かないとただの友達ということでしょうか。


・嘘
恋に関して嘘ついたことありませんか?好きでもないのに、好きって言ってみたり。←特に男性!
自分の気持ちに嘘ついたり。

西野先生いわく、恋で嘘つくことは・・・

model.wv.most_similar(positive=[u"恋", u"嘘"], topn=3)

1 仕方 0.9253218173980713
2 辛い 0.922753095626831
3 追いかける 0.9220635890960693

仕方ない見たいです笑
よかったですね、ヤリモクのみなさん。





コードはこちら
github.com

課題

パラメーターによって結果にばらつきがでたので、
今後はパラメーターを変えて結果がどう変わっていくかを調査できればなと思います。

世界一いらない人工知能??OpenCVを用いたカワウソ分類器作成奮闘記

 こんにちは!アイデミー研修生の川内と申します。
突然ですが、みなさんカワウソってご存知ですか??

f:id:shoichitech:20180519154839p:plain

 可愛いですね〜〜。よく犬と猫どっち派とか聞かれますが僕は断然カワウソ派です。

 OpenCVというのを使うとデフォルトで作成されているモデルを用いて人間の顔が検出することが出来ます。OpenCVについては下記のリンクをご覧ください。

機械学習のためのOpenCV入門
OpenCVで物体検出器を作成① 基礎知識【開発会社プロフェッサ】

 
 人の顔の画像の特徴量を抽出することにより学習するのですが、学習させるモデルにおいてはHaar-like特徴というのを用いています。Haar-like特徴は、簡単に言うと画像の明暗差により特徴を捉えます。例えば人間顔で言えば目は黒く、目元は明るいといった特徴をたくさん取ることで、人間の顔の特徴全体を捉える感じです。

Haar-likeについて(英語で書かれています)
Face Detection using Haar Cascades — OpenCV 3.0.0-dev documentation
和訳されたサイトもありました
Haar Cascadesを使った顔検出 — OpenCV-Python Tutorials 1 documentation


 ものは試しで早速やってみましょう。
f:id:shoichitech:20180519153815j:plain
 社長の石川です。いい顔してますね。

# -*- coding: utf-8 -*-

import cv2

#HAAR分類器の顔検出用の特徴量
cascade_path = "/usr/local/opt/opencv/share/OpenCV/haarcascades/haarcascade_frontalface_alt.xml"


image_path = "sample.jpg"

color = (255, 255, 255) #白

#ファイル読み込み
image = cv2.imread(image_path)

#カスケード分類器の特徴量を取得する
cascade = cv2.CascadeClassifier(cascade_path)

#物体認識(顔認識)の実行
#image – CV_8U 型の行列.ここに格納されている画像中から物体が検出されます
#objects – 矩形を要素とするベクトル.それぞれの矩形は,検出した物体を含みます
#scaleFactor – 各画像スケールにおける縮小量を表します
#minNeighbors – 物体候補となる矩形は,最低でもこの数だけの近傍矩形を含む必要があります
#flags – このパラメータは,新しいカスケードでは利用されません.古いカスケードに対しては,cvHaarDetectObjects 関数の場合と同じ意味を持ちます
#minSize – 物体が取り得る最小サイズ.これよりも小さい物体は無視されます
facerect = cascade.detectMultiScale(image, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))

if len(facerect) > 0:
    #検出した顔を囲む矩形の作成
    for rect in facerect:
        cv2.rectangle(image, tuple(rect[0:2]),tuple(rect[0:2]+rect[2:4]), color, thickness=2)

    #認識結果の保存
    cv2.imwrite("face_detected.jpg", image)

 さて結果がこちらになります。
f:id:shoichitech:20180519155552j:plain
 うまく出来ていますね。こんな感じでカワウソの画像を認識して顔を四角で囲ってみたくなりました。誰も使いません。世界一いらない人工知能と言っても過言ではないでしょう。ただ、カワウソが好きという理由だけで作ってみようと思い立ちました。下記のリンクの方が猫の顔検出モデルを作成されていたのでそれを参考に作ってみるという方針を立てました。画像さえ集められればモデル構築出来そうということが分かりました。

ねこと画像処理 part 2 – 猫検出 (モデル配布) « Rest Term

 

画像収集

 さて、方針は決まって後は画像を集めるのですがここが一番の肝です。今回はFlickrという画像共有サイトのAPIを利用して画像を集めました。利用方法については下記を参考にしました。
Flickr APIを使って画像ファイルをダウンロードする

 本来であれば正解データ7000枚、不正解データ3000枚ほど必要なのですがflickr APIではカワウソの画像400枚ほどしか集められませんでした。こうやればたくさん集められるよ!!というのを知っている方がいらっしゃれば教えてくださいm(_ _)m。不正解データは同様にしてパンダの画像を集めました。


正解データの例
f:id:shoichitech:20180519164511j:plain


不正解データの例
f:id:shoichitech:20180520214210j:plain

以下に画像取得の時に利用したスクリプトを載せておきます

import os

import time
import traceback

import flickrapi
from urllib.request import urlretrieve

import sys
from retry import retry

flickr_api_key = ""
secret_key = ""

keyword = sys.argv[1]


@retry()
def get_photos(url, filepath):
    urlretrieve(url, filepath)
    time.sleep(1)


if __name__ == '__main__':

    flicker = flickrapi.FlickrAPI(flickr_api_key, secret_key, format='parsed-json')
    response = flicker.photos.search(
        text=keyword,
        per_page=1000,
        media='photos',
        sort='relevance',
        safe_search=1,
        extras='url_n,license'
    )
    photos = response['photos']

    try:
        if not os.path.exists('./image-data/' + keyword):
            os.mkdir('./image-data/' + keyword)

        for photo in photos['photo']:
            try:
                url_q = photo['url_n']
                filepath = './image-data/' + keyword + '/' + keyword + "-" + photo['id'] + '.jpg'
                get_photos(url_q, filepath)
            except KeyError:
                print("error!!!")

    except Exception as e:
        traceback.print_exc()

正解データのラベル付け

 さて、なんとか画像を集めることが出来たのですが、正解データの画像のどの座標の位置にカワウソの顔があるかを指定していかなければなりません。githubにブラウザで画像データの座標を記録出来るプログラムを作られてる方がいたのでそちらを利用させていただくことにしました。
github.com

f:id:shoichitech:20180519161056p:plain
 
 こんな感じで地道にカワウソの顔を囲っていきます。画像が赤い四角形で囲まれた状態でNEXTボタンを押すと正解データに座標と共に分類され、何もない状態で押すと不正解データに分類されます。しかし、カワウソの画像でも横顔のデータなどは正解データとしては不適切でそういった画像の時はSKIPボタンでどちらにも分類されないようにしました。集めたカワウソの画像の中には横顔だったり、二次元のキャラクターのカワウソも含まれていたので正解データに分類された画像は5割程となりました。


f:id:shoichitech:20180519164659j:plain
正解データとはならない写真の例
 
 正解データ、不正解データに分類するのにものすごく時間かかりました・・・・。正解、不正解合わせて800枚ほどのデータが集まったのですが3時間くらいひたすら画像とにらめっこして分類してました。macのトラックパッドにクリックしすぎて指がおかしくなりましたが、ありとあらゆるカワウソの写真を見れて幸せな時間でもありました笑。

正解のデータのテキストファイルです。【画像ファイル名 対象の物体の数 x座標 y座標 width height】 の順に記録されます。

info.dat
static/img/otter-6814063158.jpg  1  50 24 66 50
static/img/otter-4494710692.jpg  1  174 30 52 38
static/img/otter-8885141162.jpg  1  78 42 67 60
static/img/otter-25450661078.jpg  1  102 36 69 49
static/img/otter-14377745688.jpg  1  38 46 152 97
static/img/otter-32616826780.jpg  1  44 22 133 101
static/img/otter-26964747057.jpg  1  57 55 37 24
static/img/otter-27695436712.jpg  1  64 26 84 67
static/img/otter-5105713030.jpg  1  113 35 75 54
static/img/otter-7394889242.jpg  1  35 73 51 43
static/img/otter-27807883499.jpg  1  89 31 77 50
static/img/otter-33148797350.jpg  1  99 42 37 36
static/img/otter-7941157504.jpg  1  126 14 58 41
static/img/otter-32646878413.jpg  1  49 89 35 27
static/img/otter-33520353216.jpg  1  110 49 52 46
.
.
.


不正解のデータのテキストファイルです。

bg.txt
static/img/panda-8976226384.jpg
static/img/panda-9325505556.jpg
static/img/panda-8761206443.jpg
static/img/panda-4107493751.jpg
static/img/panda-2155184460.jpg
static/img/panda-8426952443.jpg
static/img/panda-190747022.jpg
static/img/panda-26702691447.jpg
static/img/panda-7999165142.jpg
static/img/panda-19517101803.jpg
.
.
.

モデル構築

 そんなこんなで苦労して分類したデータをOpencvを用いて学習させてみました。まずOpenCVに認識してもらうために、正解データをバイナリファイルに変換する処理を行います。

opencv_createsamples -info info.dat -vec kawauso.vec -num 175

 numはサンプル数です。学習させるために以下のコマンドを実行します。

opencv_traincascade -data kawauso/ -vec kawauso.vec -bg bg.txt -numPos 157 -numNeg 376 -featureType HAAR -mode ALL
  • data モデルの保存先です。kawausoというディレクトリを作成しておきました。
  • numPos 正解データの要素数を指定しているのですが、サンプル数×0.9くらいがいいらしいです。

OpenCVのtraincascadeのnumPos引数はvecファイル内のサンプル数より少ない数を設定すること - takminの書きっぱなし備忘録

  • featureType 機械学習の特徴量を指定しています。先ほど述べたHaar-Like特徴を用いていました。

検証

 構築したモデルを使用してカワウソの顔が検出されるか実験してみました。

f:id:shoichitech:20180519170337j:plain
テスト画像


f:id:shoichitech:20180519200718j:plain
検証結果

 なかなか良さそうです。他の画像でも検証してみました。

f:id:shoichitech:20180520184920j:plain
f:id:shoichitech:20180520190008p:plain
f:id:shoichitech:20180519212107j:plain
いい感じに検出されてますね



f:id:shoichitech:20180519201304j:plain
友人の飼ってる猫。くろまるって言います。カワウソ検出器では検出されませんでした。



f:id:shoichitech:20180519201449j:plain
f:id:shoichitech:20180519212042j:plain
軍団カワウソ。うーん。やはり改善の余地がありそうです。


f:id:shoichitech:20180520184505j:plain
ラッコです。顔が似ているのか検出されました笑


検証の時に使用したスクリプトです。

import sys
import cv2 as cv

def detect(imagefilename, cascadefilename):
    srcimg = cv.imread(imagefilename)
    if srcimg is None:
        print('cannot load image')
        sys.exit(-1)
    dstimg = srcimg.copy()
    cascade = cv.CascadeClassifier(cascadefilename)
    if cascade.empty():
        print('cannnot load cascade file')
        sys.exit(-1)
    objects = cascade.detectMultiScale(srcimg, 1.1, 3)
    for (x, y, w, h) in objects:
        print(x, y, w, h)
        cv.rectangle(dstimg, (x, y), (x + w, y + h), (0, 0, 255), 2)
    return dstimg

if __name__ == '__main__':
    result = detect('otter1.jpg', './kawauso/cascade.xml')
    cv.imwrite('otter1_result.jpg', result)

感想

 教師データをもう少し用意できたらよかったです。
 機械学習は前処理が8割なんてもんじゃない。99%が前処理といっても過言じゃないと思いました。便利なライブラリやツールがある中で泥臭い作業こそ機械学習では必要なのだと体感しました。
 こんなやり方あるよというのがありましたらぜひ教えてください!!

Pythonで国会議事録から、話題の政治ワードを抽出してみた

初めまして、Aidemy研修生のぐっちーです。

 

今回はPythonの得意分野である

 ・自然言語処理(コンピュータに人間の言語を処理させる学問)

・スクレイピング(ネット上からデータを収集する行為)

 

を行い、ネット上の国会議事録データから会議内の頻出単語を抽出する

プログラミングを実装してみたいと思います。

 

 

(結果の一例↓) 

f:id:zerebom:20180509214302p:plain

 

Pythonを一通りかじったけど、何をすればいいかわからない…!

というかたの、足がかりになればなと思います!

 

研究背景

突然ですが、統計データは母集団により結果が大きく変わります。

以下の安倍内閣の支持率に対する世論調査をご覧ください。↓

 

日本テレビの世論調査

f:id:zerebom:20180506223953p:plain

 2018年4月の安倍内閣の支持率を26.7%としております。

 

 

国際ニュース通信社ロイターの調査

f:id:zerebom:20180506223956p:plain

こちらは2018年4月の安倍内閣の支持率を73%としております。

 

データを取った母集団は、以下の通り

日本テレビ→電話調査772人

ロイター社→大企業を中心に、企業223社

 

 

このようにデータの母集団が変わると、大きくデータに差が生まれてしまいます

 

 

人が取った統計だと、何が正しいかわからない。。。!

 ⇩

それならば自分でオリジナルデータを収集し、統計を取ろう!

 

  

ということで、今回のテーマを選びました。

実際にステップを踏んで検証していきましょう!

 

目次

 

実験

実験①国会での頻出単語を割り出し、話題の政治ワードを抽出する

 

実験①ではネット上の国会会議録データを収集し、

発言回数の多い順に単語を出力させ、話題を政治ワードを抽出することを目標とします。

 

 

指針は以下の通りです。

①国会会議録APIから予算委員会の安倍首相の発言を入手する

②形態素解析し、特徴語を抽出する

③出現数の多い語から現在の政治の話題を推定する

 

(API…webページの仕様書のようなもの、国会データを収集できるサイト)

(予算委員会…一年を通して開かれる議会。主に内閣のあり方について横断的に話している)

(形態素解析…日本語を最小単位に分割し、品詞を同定すること)

 

結果はこのようになります

f:id:zerebom:20180508130611p:plain

 

実験①-① スクレイピング

スクレイピングとはネット上からデータを収集することです。

今回はこちらを使います↓

国会会議録検索システム -国会会議録検索システム検索用APIについて-

使い方はリンク先に乗っているので、ざっくり説明します。

 

国会会議録検索システムには過去の議事録が、整理されて保存されおり、

プログラミング内でURLを指定すると、議事録データを返送してくれます。 

 

(検索条件を付与して、HTTPのGET情報を送信すると、

議事録が発言者・発言日などの情報を付与してXML形式で返してくれる。)

 

実際にスクレイピングしていきましょう。

import urllib
import untangle
import urllib.parse

if __name__ == '__main__':
    start='1'#発言の通し番号
    while start!=None:
        keyword = '安倍晋三'
        startdate='2017-01-01'
        enddate= '2018-01-01'
        meeting='予算委員会'
        #urllib.parse.quoteが日本語をコーディングしてくれる
        url = 'http://kokkai.ndl.go.jp/api/1.0/speech?'+urllib.parse.quote('startRecord='+ start
        + '&maximumRecords=100&speaker='+ keyword
        + '&nameOfMeeting='+ meeting
        + '&from=' + startdate
        + '&until='+ enddate)
        #Get信号のリクエストの検索結果(XML)
        obj = untangle.parse(url)

        for record in obj.data.records.record:
            speechrecord = record.recordData.speechRecord
            print(speechrecord.date.cdata,
                speechrecord.speech.cdata)

            file=open('abe_2017.txt','a')
            file.write(speechrecord.speech.cdata)
            file.close()
            #一度に100件しか帰ってこないので、開始位置を変更して繰り返しGET関数を送信
        start=obj.data.nextRecordPosition.cdata

 

 データは以下のような形式で返ってきます↓

http://kokkai.ndl.go.jp/api/1.0/speech?startRecord%3D1%26maximumRecords%3D5%26any%3D%E3%82%A2%E3%83%99%E3%83%8E%E3%83%9F%E3%82%AF%E3%82%B9%26speaker%3D%E5%AE%89%E5%80%8D%E6%99%8B%E4%B8%89

実験①-②形態素解析

次に取得したデータをテキストファイルとして保存し、形態素解析しましょう

形態素解析とは日本語を最小単位に分割し、品詞などを同定する行為です

 

f:id:zerebom:20180507103335p:plain

Wikipediaより

 今回はMeCabというソフトをpython内で読み込み、形態素解析します

# coding: utf-8
import MeCab
from collections import Counter
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
import slothLib
import urllib3

fname = 'abe.txt'
fname_parsed = 'abe.txt.mecab'


def to_mecab():
    '''「fname」を形態素解析して[fname_parsed]に保存する
    '''

    with open(fname) as data_file, \
            open(fname_parsed, mode='w') as out_file:

        mecab = MeCab.Tagger('-d /var/lib/mecab/dic/mecab-ipadic-neologd')
        out_file.write(mecab.parse(data_file.read()))


def make_lines():
    '''
    各形態素を
    ・表層形(surface)
    ・基本形(base)
    ・品詞(pos)
    ・品詞細分類1(pos1)
    の4つをキーとする辞書に格納し、1文ずつ、この辞書のリストとして返す

    戻り値:
    1文の各形態素を辞書化したリスト
    '''
    with open(fname_parsed) as file_parsed:

        morphemes = []
        for line in file_parsed:

            # 表層形はtab区切り、それ以外は','区切りでバラす
            cols = line.split('\t')
            if(len(cols) < 2):
                raise StopIteration     # 区切りがなければ終了
            res_cols = cols[1].split(',')

            # 辞書作成、リストに追加
            morpheme = {
                'surface': cols[0],
                'base': res_cols[6],
                'pos': res_cols[0],
                'pos1': res_cols[1]
            }
            morphemes.append(morpheme)

            # 品詞細分類1が'句点'なら文の終わりと判定
            if res_cols[1] == '句点':
                yield morphemes
                morphemes = []

そして出力はこのようになります↓

{'base': 'ミサイル', 'surface': 'ミサイル', 'pos': '名詞', 'pos1': '一般'}
{'base': '攻撃', 'surface': '攻撃', 'pos': '名詞', 'pos1': 'サ変接続'}
{'base': '等', 'surface': '等', 'pos': '名詞', 'pos1': '接尾'}
{'base': 'の', 'surface': 'の', 'pos': '助詞', 'pos1': '連体化'}
{'base': '際', 'surface': '際', 'pos': '名詞', 'pos1': '非自立'}
{'base': 'の', 'surface': 'の', 'pos': '助詞', 'pos1': '連体化'}

 

今回実験で使うのは、以下の二つです。

 

surface:(文章に表れている形)

pos:品詞

 

実験①-③ストップワード作成(統計しない単語リスト)

そのまま解析したいところですが、このまま出現数順に語を表示してしまうと、

助詞の「の」「は」など、必要ない文字が出力されてしまいます。

 

そこで、形態素解析した後の表層形をみて、必要ない語を取り除きます

 

def sloth():
    import urllib3
    from bs4 import BeautifulSoup

    slothlib_path = 'http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt'
    http = urllib3.PoolManager()
    #↑urlib3系のおまじない
    slothlib_file =http.request('GET',slothlib_path)
    soup=BeautifulSoup(slothlib_file.data,'lxml')
    soup=str(soup).split()#soupは文字列じゃないので注意
    #SlothLibに存在しないストップワードを自分で追加↓
    mydict=['いる','内閣総理大臣','おり','ない','あり','ある','いく','なっ','する','あっ']
    soup.extend(mydict)
    return soup

 

有志の方が作った、ストップワード一覧があるので活用させていただきました。↓

http://svn.sourceforge.jp/svnroot/slothlib/CSharp/Version1/SlothLib/NLP/Filter/StopWord/word/Japanese.txt

ここに含まれていないワードは自分で追加し、リストを結合しました。

 

これに加え今回は3語以上、かつ名詞・動詞・形容詞のみを統計しました

 

実験①-④頻出単語をグラフに出力する

 

最後に頻度順に語数を並べて、出力しましょう。

出力にはmatplotlibライブラリを使用します。

# 形態素解析
to_mecab()
#ストップワードよみこみ
stop_word=slothLib.sloth()
# Counterオブジェクトに単語をセット
word_counter = Counter()
for line in make_lines():
    for morpheme in line:
        if morpheme['pos'] == '動詞' or morpheme['pos'] == '名詞' or morpheme['pos'] == '形容詞':
            if len(morpheme['surface'])>3:
                if not morpheme['surface'] in stop_word:
                #リストに入れないと、1文字づつカウントしてしまう
                    word_counter.update([morpheme['surface']])
# 頻度上位30語の取得
size = 30
#sizeの数だけ、上位の単語を表示する
list_word = word_counter.most_common(size)
print(list_word)

# 単語(x軸用)と出現数(y軸用)のリストに分解
list_zipped = list(zip(*list_word))
words = list_zipped[0]
counts = list_zipped[1]

# グラフで使うフォント情報(デフォルトのままでは日本語が表示できない)
fp = FontProperties(
    fname='/usr/share/fonts/truetype/takao-gothic/TakaoGothic.ttf'
)

# 棒グラフのデータ指定
plt.bar(
    range(0, size),     # x軸の値(0,1,2...9)
    counts,             # それに対応するy軸の値
    align='center'      # x軸における棒グラフの表示位置
)

# x軸のラベルの指定
plt.xticks(
    range(0, size),     # x軸の値(0,1,2...
    words,              # それに対応するラベル
    fontproperties=fp   # 使うフォント情報
)

# x軸の値の範囲の調整
plt.xlim(
    xmin=-1, xmax=size  # -1〜10(左右に1の余裕を持たせて見栄え良く)
)

# グラフのタイトル、ラベル指定
plt.title(
    '37. 頻度上位30語',    # タイトル
    fontproperties=fp   # 使うフォント情報
)
plt.xlabel(
    '出現頻度が高い30語',# x軸ラベル
    fontproperties=fp   # 使うフォント情報
)
plt.ylabel(
    '出現頻度',         # y軸ラベル
    fontproperties=fp   # 使うフォント情報
)

# グリッドを表示
plt.grid(axis='y')

# 表示
plt.show()

出力は以下のようになります!

f:id:zerebom:20180507123513p:plain

[('安倍晋三', 1144), ('申し上げ', 782), ('いただい', 654), ('いただき', 534), ('取り組ん', 225), ('トランプ大統領', 170), ('おっしゃっ', 153), ('安倍政権', 142), ('会計検査院', 112), ('国際社会', 109),…以下略]

 

かなり字が小さいですが…こんな感じです!

 

「加計学園」や「会計検査院」、「国家戦略特区」など、

今年の話題が選定出来ているのがわかりますね!

 

 

実験② TF-IDFの実装

実験①では一年間全体での各単語の重要度はわかりますが、

この月は特にこの話題を話し合った!などという情報はわかりません。

 

 

そこで、今回はどの時期にどの話題が持ち上がったかを加味する

TF-IDF計算を実装してより詳しく分析していきます!

 

実験②-①TF-IDF計算とは?

TF-IDF計算とは、いくつかの文書があった時の文書中の単語の重要度を調べる手法

のひとつです

 

 

ここでは軽く説明しますが、詳しく知りたい方は以下のサイトを参照してください↓

TF-IDFで文書内の単語の重み付け | takuti.me

 

TF…TermFrequency

それぞれの単語の文書内での頻出頻度を表す

 

 

IDF...Inverse Document Frequency

それぞれの単語がいくつの文書で共通して使われているか表す

 

f:id:zerebom:20180509110306p:plain

式で表すとこのようになり、TF-IDFはTF×IDFの値で表されます。

 

つまり、

沢山出てくる単語ほど重要(TF)という値と

出現文書数が少ない単語ほど重要(IDF)という値をかけ合わせています。

 

例えば、1年間の議事録では

トランプ大統領という単語が1年を通じてたくさん登場し、

加計学園という単語は年の終わりに集中して登場したとします。

 

この場合

トランプ大統領→(DF:高、IDF:低)

加計学園→(DF:高、IDF:高)

となるので年の終わりの文書では加計学園という単語がより重く、特徴づけられます。

 

 

 

実験②-②sklearnのTfidfTransformerクラスを使用

 TF-IDFの仕組みは平易なため、自分で実装することも可能ですが、

今回は別の検証にも応用できるように、sklearnのTfidfTransformerというメソッドを使って実装しました

 

 

------------------------------------------------------ 実装-----------------------------------------------

①前処理した安倍総理の発言を3つの文書に分け、リストに入れる。

a.2017-01-01~2017-04-01

b.2017-04-01~2017-08-01

c.2017-08-01~2017-12-01

 

そして、文書を形態素解析し、CounterVectorizer(後述)に入力できる形に成型する。

 

今回は3文字以上の名詞のみを選定しました。

to_mecab('/home/share/idf/abe_1.txt','/home/share/idf/abe_1.txt.mecab')
copus=[]
word_counter = Counter()
for line in make_lines('/home/share/idf/abe_1.txt.mecab'):
    for morpheme in line:
        if morpheme['pos'] == '名詞':
            if len(morpheme['surface'])>2:
                if not morpheme['surface'] in stop_word:
                    word_counter.update([morpheme['surface']])
                    copus.append(morpheme['surface'])
                    copus.append(' ')

#ストップワードを除く3文字以上の名詞が全て格納される
#joinにより、一つなぎの文字列になる a=''.join(copus)

 

②CounterVectorizerクラスを用いて単語の出現回数を計測し、各文書を特徴ベクトルに変換する。

CounterVectorizerは、sklearnに用意されているクラスで、

文書中の単語の出現回数を数え、その値をもって文書をベクトル化します。

実装は以下の通りです↓

import numpy as np
from sklearn.feature_extraction.text import CountVectorizer

#カウンターを定義
count=CountVectorizer()
#各文書をarray型に代入する
docs=np.array([a,b,c])

bag=count.fit_transform(docs)

#各単語の出現回数を表示
print(count.vocabulary_)

#各単語の出現回数を、疎な特徴ベクトルとして出力
print(bag.toarray())

 

出力は以下のようになります

{'thaad': 4225, '教育再生実行会議': 2721, 'テーマ': 637, '機密情報': 3030, '保育士': 1482, '君たち': 1950, 'あずかり': 3, '締めくくり': 3539, 'スター': 546, 'ノドンミサイル': 702, '北東アジア': 1758, '長時間労働': 3965, '二十三年': 1298, '輸出企業': 3841, 'クリーン': 425, '在宅医療': 2123, '獣医師会': 3212, '不規則': 1179, '昨年末': 2872, 'グループ': 434, '知ったか': 3342, '手持ち': 2635, '相対的貧困率': 3323, '東北地方': 2968, '一万三千人': 1036, 'ポスト': 879, '新潟県': 2766, 'アフガン': 271, '五年間': 1374, '佐々木': 1465, '三十万円': 1118, '労働力人口': 1719, 'シークレットサービス': 523, 'パチンコ': 746
[[  1   1   1 ... 302  33  13]
 [  0   0   0 ...   0   0   0]
 [  0   0   0 ...  13   0   0]]

 

③TfidfTransformerを使い、TF-IDF計算を実装する。

②でベクトル化された文書に計算を加えて、TF-IDFの式に従って値を変更します。

from sklearn.feature_extraction.text import TfidfTransformer

#tfidfを実装する
'''
use_idf...Falseにすると、dfのみで出力する
norm...正則化の手法。ベクトルの長さが1になる
smooth_idf…idfの計算式が変わる。Faluseにするとlogの後ろの+1がなくなる

'''
tfidf=TfidfTransformer(use_idf=True,norm='l2',smooth_idf=True)

#arrayの出力を成型する(小数点第2位まで)
np.set_printoptions(precision=2)

#tfdifでdocsを変換したのち、array型に変換し出力する
print(tfidf.fit_transform(count.fit_transform(docs)).toarray())

 

出力は以下のようになります。

 

[[0. 0. 0. ... 0.17 0.02 0.01]
[0. 0. 0. ... 0. 0. 0. ]
[0. 0. 0. ... 0.06 0. 0. ]]

 

ぱっと見て、正則化のおかげでベクトルの長さが1になっていることがわかりますね!

 

実験②-③Excelに出力し、データを読み解く

最後にこのベクトルから、どの単語が各文書で重要か調べてみましょう!

今回はExcelに出力したいと思います。

from sklearn.feature_extraction.text import TfidfTransformer
tfidf=TfidfTransformer(use_idf=True,norm='l2',smooth_idf=True)
np.set_printoptions(precision=2)

features = count.get_feature_names()

ans=tfidf.fit_transform(count.fit_transform(docs))
#出力をarray型にする
ans_array=tfidf.fit_transform(count.fit_transform(docs)).toarray()

data={
#単語名
"index":features,
#各文書の各単語のTF-IDF値
"num1":ans_array[0],
"num2":ans_array[1],
"num3":ans_array[2]
}
df=pd.DataFrame(data)
df.to_excel('/home/share/idf/sample.xlsx', sheet_name='abe_2017')

出力はこちら↓

以下は文書1(2017-01-01~2017-04-01)でソートしてあります。

f:id:zerebom:20180509142246p:plain

文書1を特徴づける単語は「tpp」「北朝鮮」「トランプ大統領」などになります!

しかしこれは、実験①とは異なり、単なる出現回数順ではありません。

 

例えば、行番号24番と、25番を見てみましょう。

 「労働時間」という単語は文書1のみ出現していますが、

「明らか」はすべての文書で出現しています。

 

つまり「労働時間」のIDFは「明らか」より、

大きくなり出現回数のわりにTF-IDF値は大きくなります。

 

 

実際に調べてみると、

「労働時間」の出現回数は124回

「明らか」の出現回数は210回となっていました。

 

しっかりと出現文書数が少ないほうがより重みづけされる機能が実装されていますね!

 

( ※文書1の出現単語数が多いのは、2月に予算委員会は多く開かれるためです。

文書1はほかに比べて分量が多いです)

考察

TF-IDF計算は文書の数が多い時に発揮されます。

今回の実験では3文書しかないため、すべての文書で出現する文字が多くなり、

IDFの値に差があまりつきません。

 

文書の数を12個に分け、月別で調べてみても面白いかもしれないですね!

(予算委員会は月ごとの開催回数はかなり違いますが)

 

今回の実験を発展させる方法

 

今回は安倍総理の発言のみをスクレイピングしましたが、

議員毎にスクレイピングし、何に主眼を置いて話しているか等を調査する事や、

議員毎のクラスタリングをしても面白いですね。

 

また、今回の実験②では文章をベクトル化し、数値で表すことが出来ました。

ベクトル化をすると、文章に対して数学的アプローチをかけることができるようになるので、クラスタリング等より高度な技術が可能になります。

 

 機会があれば今回の実験の続編も行いたいなと思います!

感想 

 

 今回は自然言語処理・スクレイピングがテーマでしたが、

この実験を行うにあたっては、テーマ以外にも

  • ローカル環境の構築構築
  • HTTP通信(xmlや文字コードの扱い方)
  • MeCabの辞書作成
  • sklearnのパラメータの理解、渡すデータ型

 

などで非常に苦戦しました汗

 

しかし、苦戦する中で自分でエラーを解決する力や、横断的な知識を身に着けることができ他と思います。

是非、ProgateやAidemyでの学習が一通り終わった方は自分でテーマを設定し、

アウトプットする機会を設けて挑戦してみてください!

環境紹介

今回私が使用した環境はこちらです⇩

・windows10

・VMwareWorkstation14(仮想OSを起動するソフト)

・ubuntu 16.04 32bit(Linuxディストリビューションの一つ)

 

・python3.6.5

・MeCab (形態素解析ソフト)

・Atom(テキストエディタ)

 

参考文献

・冒頭の世論調査↓

jp.reuters.com

 

日本テレビ世論調査

 

・Linuxの導入方法↓

koeda3.hatenablog.com

・MeCabのダウンロード方法↓

 

qiita.com

 

・Atomのダウンロード方法↓

Python入門 初心者でも出来るAtomエディタでの開発方法

 

・ストップワードの実装↓

testpy.hatenablog.com

 

自然言語処理の基礎知識まとめ↓

qiita.com

機械学習プログラミングのまとめ(sklearnの使い方等)

book.impress.co.jp

大公開!初心者でもわかるAidemy社内インフラの全容とその設計思想

インフラって初学者にとってかなりブラックボックスですよね。

インフラ構成なんて会社の業態やサービス形態によって様々なので、初心者にとってどれが重要なのかわかりにくい(そもそもあんまり公開されてないし)。また、調べてみても各サービスの個別的な情報ばかりで、なかなか社内インフラの設計思想のような体系的な情報はでてきません。なにより、何から理解すればいいのかわからないので地道に勉強するのがめんどくさい!

そこで、先日Microsoft社で行われたハックフェストに参加し、マンツーマンでインフラ構成について教えていただきました!

ハックフェストで教わったことを踏まえ、Aidemyのインフラ構成を例に、重視しているポイントや、用いているサービス・ツールについて、インフラに触れた経験のない人でもわかるように解説していきたいと思います。

f:id:aoi_tsuno:20180428194732j:plain

Microsoftオフィスでのハックフェストの様子

Aidemyのインフラ構成設計思想

Aidemyのインフラ設計では以下の3点を重視しています。

  1. コンテンツの配信速度を爆速にしたい!
  2. アプリのアクセスに応じて柔軟にスケーリングしたい!
  3. 安全にバッチ処理、WebHook受信をしたい!

それぞれの要件を実現する方法について、1つずつざっくり解説していきます。

f:id:aoi_tsuno:20180502101240p:plain

Aidemyインフラの概観図(以下、模式図には便宜上Microsoft Azureによって提供されているサービスのロゴを用いていますが、他クラウドサービスでも同様のプロダクトが利用可能な場合があります)

1.「コンテンツの配信速度を爆速にしたい!」を実現する"CDN"(Content Delivery Network)

f:id:aoi_tsuno:20180502101317p:plain

CDNでは、世界各地に配置された全てのキャッシュサーバーに、IPアドレスに対応した静的コンテンツが保存される仕組みになっています。

f:id:aoi_tsuno:20180430183124p:plain

コンテンツデリバリネットワーク - Wikipedia

エンドユーザは、全てのキャッシュサーバーの中でもっとも近くにある(HOP数の少なくて済む)サーバーに保存されたコンテンツをダウンロードするため、非常にスピードの速いアクセスが可能となります。

また、複数台のキャッシュサーバーにより構成されているため、トラフィック分散や、単一のネットワークの不具合による遅延の軽減などのメリットもあります。

2.「アプリのアクセスに応じて柔軟にスケーリングしたい!」を実現する"Container Registry" + "Kubernetes"

f:id:aoi_tsuno:20180502101353p:plain

一般にWebサイトのアクセス頻度は、時期や時間帯によって大きく変化します。さらに、Aidemyでは機械学習のオンライン学習サービスを提供しているため、講座内でニューラルネットワーク等を用いたCPU負荷の大きいソースコードを実行する場面が多々あります。

かといってスペックの高いVM(仮想マシン)を何台も常時保有しているのはかなりの高額のコストがかかってしまいスタートアップにとって致命的です。したがって、ユーザのアクセス量に応じて随時マシン数を増減させる必要が生じます。

そこで、Container RegistryとKubernetesを組み合わせることにより、高速で柔軟な実行環境の展開と、そのデプロイの簡略化を図ることができます。

Container Registry

f:id:aoi_tsuno:20180502101429p:plain

Container Registryとは、Microsoft Azureなどにより提供されているDocker*1レジストリで、ローカルからアップロードされたDockerイメージ(ここでは、実行環境やソースコードをひとまとめにしたファイルと考えてもらえれば十分です!)を、サービスのクラウドストレージに保存することができます。保存されたイメージは権限で守られており、Virtual Network内の至るところからアクセスすることができます。次はKubernetesでこのContainer Registry上のイメージをスケーリングしていきます!

Kubenetes

f:id:aoi_tsuno:20180502121047p:plain*2

Kubernetesは「クーベネティス」と読み、一般に"k8s"と略されているようです。Kubernetesとは下図のようにある一つのノード(マスターノード)に指令を与えるだけで、複数の実行環境を高速に展開し、運用に必要な処理を自動化することができるオープンソースツールです。(図中の"RUN"とは、Aidemyの講座でソースコードを実行する環境のことをさしています。)

f:id:aoi_tsuno:20180428174827p:plain

Docker(コンテナ型仮想化)と Kubernetes についての簡単な紹介 – ゆびてく

KubernetesとContainer Registryを組み合わせることにより、マスターノードへの指令でクラウド上のDockerイメージを指定することで同一環境のコンテナをいくつも立ち上げることが可能になります!

Kubernetesを利用するメリットとしては、

  • 複数台のマシンを稼動させていても、1台だけを操作するように扱える
  • 実行しているコンテナの負荷や不具合に応じて、自動で新たなホストを立ち上げたり、削除したりすることができる(スケールアウト/ダウン)
  • アプリ稼働中、セッションを維持しながらバージョンアップすることができる(ロールアウト)

が挙げられます。

マスターノードに与える指令はyaml形式で記述されており、スケールアウト/ダウンの基準となるCPU負荷率なども簡単に指定可能です。

 さらに、Kubernetesを用いれば、Container Registryに保存したサービス(webフロント、バックエンドAPI)のコンテナイメージをまとめて一気にデプロイすることができるので、本番環境の運用で起こりうる人的ミスを最小限に抑えることもできます!

ubiteku.oinker.me

 

3.「安全にバッチ処理、WebHook受信をしたい!」を実現する"Azure Function App"

f:id:aoi_tsuno:20180502101545p:plain

特定の相手からのリクエスト受信や、定期的な処理を行いたいけど、そのために常時インスタンスを稼働させておくのはコストがかかってしまう…。そんな場合に手軽に活用できるのがFunction Appです!

Function Appはクラウドサービス上のエディタにベタ書きしたステートレスな関数を実行することのできるアプリケーションプラットフォームです。webhookにも対応可能で、フレキシブルに実行のスケジューリングが設定できるうえ、従量課金制なので、安心してサードパーティからのリクエスト(Aidemyではクレジットカード引き落とし情報など)を受け付けることができます。

例えば、名前入りのリクエストを受けて挨拶を返してくれる簡単な関数を次のようにNode.jsで実装してみます。

module.exports = (context, req) => {
  context.res = {
    status: 200,
    body: {
      message: 'Hello, ' + req.query.name + '!',
    }
  };
  context.done();
}

ソースコードをそのまま貼り付けるだけで、(画像はMicrosoft Azure Function Appにおける運用例)

f:id:aoi_tsuno:20180430232317p:plain

作った関数がどこからでも使えるようになります!(ここではRESTクライアントソフトのPostman*3を利用しています

f:id:aoi_tsuno:20180501203006p:plain

さらに、このように鍵や接続文字列をアプリケーション内で環境変数として指定することもできるので、安心して秘匿情報を利用することができます。

f:id:aoi_tsuno:20180430233342p:plain

サードパーティへのアクセス権をコードにもたせたいけど、秘密鍵情報はセキュリティ上ソースコード内に書きたくない…という場合にもってこいです!

このように、Function Appを利用すれば、安全に、そして安価にバッチ処理やサードパーティとの連携をおこなうことができます。

まとめ

ハックフェストでは、コンテナ化の概念や公開鍵認証など初歩的なことから教えていただけるので、専門的な相談はもちろん、「インフラに興味があるけど何から学べばいいかわからない…」という方にも絶好の機会だと思います!

 

Microsoft Japanの安納さん、窪田さんありがとうございました!(真ん中が安納さん)

f:id:aoi_tsuno:20180502225821j:plain

 

*1:コンテナ技術による軽量な仮想化プラットフォーム。

knowledge.sakura.ad.jp

ドットインストールのDocker入門もおすすめです。

https://dotinstall.com/lessons/basic_docker

*2:図式化のため、上図では複数のKubernetesクラスターが分散しているような形式をとっていますが、正確にはKubernetesは下図のように各アプリケーションを一つのクラスタ内で管理しています。

f:id:aoi_tsuno:20180427200605p:plain

Using kubectl to Create a Deployment | Kubernetes

*3:

www.getpostman.com

Twitterのタイムラインからツイートを取得しトピックモデルを生成してみる

はじめまして、研修生ののっぽです。機械学習の勉強を始めてまだ二ヶ月の未熟者ですが、今回は機械学習を用いた簡単なプログラムの実装をしてみようと思います。


突然ですが、皆さんはTwitterを使用したことがありますか?あるいは他のSNSを用いたことはありますか?
Twitter等のSNSでは大きなアカウントであればあるほどログの流れが早く、後から見返すのが大変です。
そこで、pythonを用いて過去のツイートを大量に取得し過去にどのようなことがツイートされていたのかを大まかに知るプログラムを作ってみます。

 

トピックモデルとは

さて、タイトルにもあるトピックモデルとは一体どういう意味なのでしょうか。
トピックモデルとは簡単に言うと「全ての文章には幾つかの話題があり、文章の中身はその話題の何れかから作られている」というモデルのことです。そしてトピックモデルを生成するとは、各文章中の単語を生成元の話題に対してグループに分け、それらの単語がどれだけの確率でどの話題から生成されたのかを推定することになります。
詳しい解説はAidemyの講座にお任せすることとして、早速ツイートを取得してトピックモデルを生成してみましょう。

ツイートを取得する

ツイートの取得に当たって以下の記事を参考にさせていただきました。
qiita.com

TwitterにはAPI制限というものが存在し、タイムラインからのツイートの取得は200(ツイート/回)×15(回) = 3000ツイートが限界です。そのため、API制限に掛かった場合はプログラムを一時的に停止させる必要が有ります。以下に示したプログラムでは一回ツイートを取得するごとに1分停止することで結果的に制限にかからないようにしています。
また、ツイートを遡るために最後に取得した最も古いツイートのIDを一時的に保持しています。

source = []
for j in range(100):   
    params_a = {"since_id" : str(id1),"count":200, "include_entities":"false", "include_rts":"true", "trim_user":"true", "exclude_replies":"true"} 
    req = twitter.get(url, params = params_a)
    if req.status_code == 200:
        timeline = json.loads(req.text)
        i = 0
        print(str(j)) 
        for tweet in timeline:
            source.append(tweet["text"])
            i = i + 1
            if(i == 200):
                id1 = tweet["id"]-200
    else:
        print("error")        
'''
この部分に追加でアカウントを加える
'''
 time.sleep(60)

(上記のコードはプログラムの一部です。実際にはツイート取得数を増やすために、複数アカウントのタイムラインからツイートを取得しています)

以上のプログラムで20000×アカウント数のツイートが取得できることになります。
しかし、そのままではトピックモデルを形成することはできません。そこで得られたツイート群に対して形態素解析を行い必要な単語のみを抽出します。

ツイートを整形する

ツイートの整形(形態素解析)にはMeCabというライブラリを用います。
以下のコードではparserという関数で一つのツイートの形態素解析を、analyzerという関数でリスト内のツイート全てにparser関数をかけています。

import MeCab
mecab = MeCab.Tagger('mecabrc')
mecab.parse('')

#形態素解析を行う
def parser(text):
    mecab.parse('')
    corplist = []
    node = mecab.parseToNode(text)
    while node:
        if(len(node.surface) == 0):
            node = node.next
            continue       
        if (node.feature.split(",")[0] == u'名詞') and ((node.surface[len(node.surface) - 1].encode("utf-8")[0] >= 0xe3) and (node.surface[len(node.surface) - 1].encode("utf-8")[1] >= 0x81) and (node.surface[len(node.surface) - 1].encode("utf-8")[2] >= 0x80)):
            corplist.append(node.surface)
        node = node.next
    return corplist

#データに直接parserをかける
def analyzer(content):
    token = []
    for i in content:
        token_p = parser(i)
        token.append(token_p)
    return token

words = analyzer(source) #単語リストとして保存

parser関数では形態素解析で得られた単語群の中から条件に合うもの(名詞に分類され、単語の初めの文字が英語でないもの)のみを実際にトピックモデルの生成に使うリストに加えています。

これでトピックモデルを生成する準備は整いました。


トピックモデルの生成

さて、後は得られた単語リストからトピックモデルを生成するだけです。トピックモデルの生成にはgensimと呼ばれるライブラリを使います。
トピックモデルを生成するためにはまず辞書と呼ばれる意味のある頻出単語群の作成と、コーパスという辞書中の各単語にそれらがどれだけの頻度で出現したのかを紐付けるリストの作成が必要になります。
以下のコード中のdictionary.filter_extremesでは上で説明した辞書に単語を加える際の条件を付加しています。今回の例で言えば、文章群全体で二回以上出現した単語でありかつ全体の1%以下の出現率である単語が辞書に加えられます。

作成したコーパスをLDAという種類のトピックモデルを生成する関数に入れてあげることで、指定したトピック数に対して単語群からトピックそのものとトピックに各単語がどれだけの割合で含まれているかを推定することができます(この節の後半でLDAについて説明をします)。

import gensim
from gensim import corpora, models, similarities

#辞書の作成
dictionary = corpora.Dictionary(words)
dictionary.filter_extremes(no_below=2, no_above=0.01)

# コーパスを作成
corpus = [dictionary.doc2bow(text) for text in words]

#トピックモデルを生成
lda = gensim.models.ldamodel.LdaModel(corpus=corpus, num_topics=100)

# 各トピックの出現頻度上位を取得
topic_top = []
for topic in lda.show_topics(-1, formatted=False):
    topic_top.append([dictionary[int(tag[0])] for tag in topic[1]])
        
# 各トピックの出現頻度上位10位をcsv形式で保存
topic_data = pandas.DataFrame(topic_top)
topic_data.to_csv("topic_words1.csv", encoding="utf-8")

さて、それでは生成されたトピックモデルをcsvファイルに保存し確認してみましょう。

LDAとは

結果に移動する前にLDAについて簡単な説明をします。
LDAとはトピックモデルの中でもベイズ推論の考えを用いて文章の集合から各トピックや単語がどのから生成されたかについての分布を計算するモデルです。
以下にすこし詳しい説明を書きます。
n個の単語の集合をW、語彙(つまり単語の種類)の集合をV、文書集合をDとします。
この時、文書集合D中のある文書dは単語wの集合として表されます。また文書集合DにはK個のトピックφによって構成されるトピック集合Φが存在しトピックφは語彙vの異なる比率によって構成されています。
また文書dそのものはトピックを幾つか含んでいるのでトピックφが文書dを占める比率をθとおきます。
最後に単語がどのトピックに属するかを示す変数であるトピック割り当てZを設定します。
トピックの総数が決まっている時、単語w及びトピック割り当てはカテゴリ分布(ベルヌーイ分布を多次元に拡張したもの)によって生成されます。
{ \displaystyle p(w_{d,n}|z_{d,n,}\Phi) = \prod_{k=1}^{N-1} Cat(w_{d,n}|\phi_k)^{z_{d,n,k}}}
{ \displaystyle p(z_{d,n}|\theta_d) = Cat(z_{d,n}|\theta_d)}
つまり、単語wの生成される確率はトピック集合Φとそれらの割り当てzに依存し、割り当てzの生成される確率は文書dに含まれるトピックの比率θに依存するということです。
この時、トピック比率及びトピックφがそれぞれディリクレ分布から生成されていると仮定すると、結局全体の同時分布は以下の式で表されます。
{\displaystyle p(D,Z,\Phi,\Theta) = p(D|Z,\Phi)p(Z|\Theta)p(\Phi)p(\Theta)}
(ただし、上式において集合の確率は各要素の確率の積で表されるとします)
今求めたいのはp(D,Z,Φ,Θ)に対してp(D)が与えられた際の事後分布
{\displaystyle p(Z,\Phi,\Theta|D) = \frac{p(D,Z,\Phi,\Theta)}{p(D)}}
です。ここで変分推論を適用し先ほど求めた式を用い、潜在変数Zと他のパラメータ分解を仮定することでLDAにおける解析的な更新式が求まります。
{\displaystyle p(Z,\Phi,\Theta|D) \approx q(Z)q(\Theta, \Phi)}
この更新式はΘ及びΦのディリクレ分布のハイパーパラメータα及びβから求まります。
{\displaystyle < ln \phi_{k,v} > = \psi(\hat{\beta_{k,v}}) - \psi(\sum_{v'=1}^{V}\hat{\beta_{k,v'}})}
{\displaystyle < ln \theta_{d,k} > = \psi(\hat{\alpha_{d,k}}) - \psi(\sum_{k'=1}^{K}\hat{\alpha_{d,k'}})}
{\displaystyle < z_{d,n,k} > = \eta_{d,n,k}}
~ただし、変数はそれぞれ以下のように設定しました~
{\displaystyle \hat{\beta_{k,v}} = \sum_{d=1}^{D}\sum_{n=1}^{N} < z_{d,n,k}w_{d,n,k} > + \beta_v}
{\displaystyle \hat{\alpha_{d,k}} = \sum_{n=1}^{N} < z_{d,n,k} > + \alpha_k}
{\displaystyle \eta_{d,n,k} \approx exp({\sum_{v=1}^{V} w_{d,n,v} < ln\phi_{k,v} > + < ln\theta_{d,k} >})} ただし{\displaystyle (\sum_{k=1}^{K}\eta_{d,n,k} = 1)}
~
これらの更新式において崩壊型ギブスサンプリングというpからパラメータΘ,Φを周辺化除去しzの条件付き分布を求めるサンプリング方法によってαとβの更新が可能になります。

LDAは確率モデルであり拡張性が高いため、広く使われています。

結果

以下の画像は作成したTwitterの4つのサンプルアカウント(フォローに法則性のないアカウント,アニメ・ドラマ・ゲーム等の内容をつぶやくユーザーの多いアカウント,数学・物理・情報等の学問についてつぶやくユーザーが多いアカウント,僕自身のアカウント)から生成されたトピックを30個分取り出したものになります。

...可もなく不可もなくといったところでしょうか
f:id:noppo_eeic:20180422034431p:plain


これらのトピックのようにうまく単語をまとめられたものもありますが、
f:id:noppo_eeic:20180422034251p:plainf:id:noppo_eeic:20180422034319p:plainf:id:noppo_eeic:20180422034345p:plain

中にはとんちんかんなトピックもあります。
Twitterでは多くの著者と多くの話題が混在しているためうまくモデルを推定するのが難しいのかもしれません。


そこでモデルのパラメータであるトピック数と辞書の作成に用いたパラメータである単語の最低出現数(no_below)についてパラメータの適解を探索します。

モデルの評価に用いるのはperplexityと言う指標でトピックからある単語が生成される確率の逆数によって表されます。つまり、この数値が小さければ小さいほどモデルの予測性能は向上するのでうまく構築出来たと言うことになります。

はじめにトピック数及びに単語の最低出現数ついて大まかに探索することで大体のperplexityの傾向を探ったところ、今回はトピック数はだいたい200程度、最低出現数はだいたい18程度で極小値に収束することがわかりました。
そこでさらに細かく全探索していきます。

dictlist = []
for i in range(10):
    dictionary = corpora.Dictionary(words)
    dictionary.filter_extremes(no_below=i + 15, no_above=0.01)
    dictlist.append(dictionary)
    print(i)
corplist = []
for i in range(10):
    print(i)
    corpus = [dictlist[i].doc2bow(text) for text in words]
    corplist.append(corpus)

for k in range(10):
    for i in range(10):
        lda = gensim.models.ldamodel.LdaModel(corpus=corplist[i], num_topics=k + 215)
        perplist.append(np.exp2(-lda.log_perplexity(corpus)))
        print(np.exp2(-lda.log_perplexity(corpus)))
        print("i:",i)
        print("k:",k)

全探索をしたところ、トピック数219, 最低単語出現数19でperplexityの極小値25.48が得られました。
このパラメータを用いてもう一度トピックモデルを構築したところ以下のようになりました。
f:id:noppo_eeic:20180424051430p:plain

ちょっとは改善されているのでしょうか...?

新しく取得したツイートのトピック

では、取得したツイートのトピックモデルを用いて新たに取得したツイートがどのトピックに属しているのかを確認してみましょう。
先ほど用いたアニメ、ドラマ等のアカウントをフォローしているサンプルアカウントのタイムラインから以下のツイートが得られました。

[映画ニュース] 松坂桃李、役所広司の“魂”が込められた「孤狼の血」キーアイテムを継承!

この時、以下のコードによって新しく取得したツイート内のどのような単語が既存のトピックに反応するのか確認してみると、

print(test_words[1][2])
for i in range(len(test_words)):
    print("tweetnumber:", i)
    for j in range(len(test_words[i])):
        try:
            print(test_words[i][j], "id is", dictionary.token2id[test_words[i][j]])
        except KeyError:
            print(test_words[i][j], "is not found")
松坂桃李 id is 448
役所広司 id is 447

と、俳優の名前を含むトピックが既に存在することがわかり、

c = [(447,1), (448, 1)]
for (tpc, prob) in lda.get_document_topics(c):
    print(str(tpc) + ': '+str(prob))

146: 0.668155

と、146番目のトピックが約67%の確率で属することがわかりました。146番目のトピックは以下のようになっており

f:id:noppo_eeic:20180425211340p:plain

例えば俳優の名前なら「江口洋介」と言った単語がのちに続く可能性があることがわかります。

元ツイートを確認してみると

「江口洋介」さんが関わっていることがわかりますね。

最後に

今回はpythonを用いて身近な題材について機械学習のプログラムを実装してみました。機械学習というとハードルが高く見えるかもしれませんが、ライブラリが充実しているので簡単なものであれば誰にでも作れてしまうものです。
とはいえ、精度や学習速度など奥が深い分野であることもまた事実です。僕もトピック数の決定や評価法などまだまだ改良点があるのでまた挑戦してみようと思います。
ご清覧ありがとうございました


~今回作成したプログラムはこちらに置いておきます~
github.com

~またLDAの説明に関して以下の本を参考にさせていただきました。より詳しく機械学習について知りたい方にとてもおすすめの本です。

機械学習スタートアップシリーズ ベイズ推論による機械学習入門 (KS情報科学専門書)

機械学習スタートアップシリーズ ベイズ推論による機械学習入門 (KS情報科学専門書)