Aidemy Blog

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

Doc2Vecを使って小説家になろうで自分好みの小説を見つけたい話

どうもこんにちは。
Aidemy研修生の片岡(@ShotaroKataoka)です。
今回は小説をベクトルに変換して、自分好みの小説を見つけたいと思います。

[キーワード]
自然言語処理, データスクレイピング, 形態素解析, Doc2Vec, 階層的クラスタリング, 類似文書の検索


はじめに

背景

突然ですがみなさん、小説を無料で投稿できるサイトをご存知でしょうか?

小説家になろうカクヨムなどが有名ですね。

僕は(ここ数年ご無沙汰してますが、) 小説家になろうで小説を漁るのが好きです。
最近はこういうサイトからプロになる人が多いみたいで、
アマチュア小説家や出版社にとって機会獲得の場として重要視されているようです。

僕たち消費者にもありがたいサービスなのですが、不満もあります。
それは、面白い小説を見つけるのが難しいことです。
というのも、

  • ランキングがあてにならない。
    • (個人的に)面白いと思った小説は他の読者に評価されていない。
  • 検索機能は充実しているが、うまく機能しない。
    • ジャンル検索に関して、もう少し詳しく指定したい。SF⇒超能力?タイムマシン?
    • 小説のキーワードが内容に即していない場合があるのでキーワード検索もあてにならない。

ことがあるからです。
「ランキングやキーワードに頼らずに自分好みの小説を探せないか?」
と思ったので、小説の内容から直接似ている小説を探してみます。

手法

手順としては、

  1. 小説家になろうから小説データをスクレイピングする。
  2. 集めた小説をDoc2Vecによってベクトル化する。
  3. 階層的クラスタリングコサイン類似度を用いて似ている小説を探す。

こんな感じでやってみます。

環境構築

僕はノートパソコンしか持っていないので今回はGoogle Colabratryを用いました。
これはGoogleの提供するサーバでJupyter notebookが動かせちゃうというサービスです。
無料でTPUまで使える(!)ので、貧弱なPCしか持ってない僕にとっては神のようなサービスです。

まずは以下を実行してColabratory上でGoogle driveにアクセスできるようにしておきます。

from google.colab import drive
drive.mount('/content/drive')

gensimなどColaboratoryにインストールされていないライブラリは、!pip install gensimこんな感じで適宜インストールします。

学習データの準備

データの取得

小説家になろうから小説をスクレイピングしてきます。
スクレイピングについては今回の本題ではないので詳細は割愛します。
以下の関数を用いて本文のダウンロードを行いました。

import requests
from bs4 import BeautifulSoup
from time import sleep

# 本文をダウンロード
def novel_text_dler(url):
  headers = {
    'User-Agent':
    'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko'
  }
  r = requests.get(url, headers=headers)
  r.encoding = r.apparent_encoding
  soup =  BeautifulSoup(r.text)
  honbun = soup.find_all("div", class_="novel_view")
  novel = ""
  for text in honbun:
    novel += text.text
  sleep(1)
  return novel

[取得した小説について]
まずは以下の条件で検索を行い、検索ページから該当する小説のリンクをスクレイピングしました。

  • ファンタジー(異世界含む)小説である。
    • 小説家になろうにおいてファンタジー小説は圧倒的に人気があるため。
    • 内容に一定のパターン、いくつかのバリエーションがありクラスタリングしやすそう。
  • 10000文字以上の小説である。
    • すぐに更新が放棄された小説を除くため。
  • 初投稿日時が新しい順
    • 昔と今では流行が違うはずなので、ある程度のまとまりを担保するために最近の小説に絞った。

検索ページでは2000タイトル以上は遡れなかったため、取得件数を2000タイトルとしました。
スクレイピングしたリンクを辿って、各タイトル5話ずつ×2000タイトルの内容を取得しました。

データの前処理

取得した小説データを解析するためにまずは形態素解析を行います。

# coding: utf-8
import MeCab

def keitaiso(text):
  tagger = MeCab.Tagger("-Ochasen")
  tagger.parse("")
  node = tagger.parseToNode(text)
  word = ""
  pre_feature = ""
  while node:
    # 名詞、形容詞、動詞、形容動詞であるかを判定する。
    HANTEI = "名詞" in node.feature
    HANTEI = "形容詞" in node.feature or HANTEI
    HANTEI = "動詞" in node.feature or HANTEI
    HANTEI = "形容動詞" in node.feature or HANTEI
    # 以下に該当する場合は除外する。(ストップワード)
    HANTEI = (not "代名詞" in node.feature) and HANTEI
    HANTEI = (not "助動詞" in node.feature) and HANTEI
    HANTEI = (not "非自立" in node.feature) and HANTEI
    HANTEI = (not "数" in node.feature) and HANTEI
    HANTEI = (not "人名" in node.feature) and HANTEI
    if HANTEI:
      if ("名詞接続" in pre_feature and "名詞" in node.feature) or ("接尾" in node.feature):
        word += "{0}".format(node.surface)
      else:
        word += " {0}".format(node.surface)
      #print("{0} {1}".format(node.surface, node.feature))
    pre_feature = node.feature
    node = node.next
  return word[1:]

この関数では、

  1. 入力された文字列に対して形態素解析を行う。
  2. 名詞、形容詞、動詞、形容動詞のどれかであり、かつストップワードに該当しない単語を選別する。
  3. 連結すべき名詞は連結する。
  4. 単語間を半角スペースで区切って出力する。

という処理を行っています。
この関数を実際の小説に対して適用したところ、

'変哲 無い 学校 行き 眠たい 授業 受け 帰っ ゲーム する 在り 来 如く 徹夜 ゲーム ......(以下略)'

こんな感じで学習データを整形できました。

Doc2Vec

Doc2Vecについての概要

さて、本日の本題であるDoc2Vecをやっていきましょう。
Doc2Vecは単語をベクトルに変換するword2vecを応用したもので、
名前のとおり、文書をベクトルに変換することができます。
(Doc2Vecの詳しい理論については元論文[1405.4053] Distributed Representations of Sentences and Documentsをご参照ください。)

今回なぜDoc2Vecを採用したかというと、

  1. 単語の並び順を考慮してベクトル化できる。
  2. 単語の意味を考慮してベクトル化できる。

という利点があるからです。
例えば、「魔物」と「モンスター」という単語があったとき、Doc2Vecではこの2つの単語の意味は近いと判断してベクトル化してくれるので表現のブレに対して頑健なモデルになると考えられます。

Doc2Vecを使ってみる

Doc2Vecはgensimのライブラリを用いることで容易に実行することができます。

qiita.com

こちらのサイトを参考に(というかそのまま使って)Doc2Vecを行いました。

#coding: UTF-8
from gensim.models.doc2vec import Doc2Vec
from gensim.models.doc2vec import TaggedDocument

# 空白で単語を区切り、改行で文書を区切っているテキストデータ
with open('drive/My Drive/colab/narou/novel_datas.txt','r') as f:
  # 文書ごとに単語を分割してリストにする。
  trainings = [TaggedDocument(words = data.split(),tags = [i]) for i,data in enumerate(f)]

# 学習の実行
m = Doc2Vec(documents= trainings, dm = 1, size=300, window=8, min_count=10, workers=4)
# モデルのセーブ
m.save("drive/My Drive/colab/narou/doc2vec.model")

projector.tensorflow.org

上記サイトでは埋め込みベクトルを主成分分析して可視化してくれます。
学習の結果得られた文書ベクトルの分布がどうなっているか見てみましょう。

f:id:ShotaroKataoka:20181008222854j:plain

こうなりました。
もう少し偏りがあるかなと思ったのですが、思ったよりものっぺりと分布しています。

第1主成分から第3主成分までの累積寄与率を調べてみると31.8%になっていました。
累積寄与率は100%に近いほど次元削減したデータが次元削減前のデータの散らばり具合をよりよく説明できていることを表し、70~80%以上が目安だといわれています。
今回は31.8%だったので、可視化したグラフはあまりあてにならないということが分かります。

f:id:ShotaroKataoka:20181008222903j:plain f:id:ShotaroKataoka:20181008222900j:plain

タイトルからだけでは詳細な内容は分からないですが、とりあえず似た感じの小説は集まって分布しているようです。
次は得られたベクトルをクラスタリングしていこうと思います。

クラスタリング

階層的クラスタリングについて

今回は文書ベクトルをクラスタリングするためにWard法による階層的クラスタリングを用いました。
採用理由は以下の通りです。

  • 事前にクラスタ数を決定しなくてもクラスタリングができる。
  • デンドログラムによってクラスタリングの過程を可視化できる。
  • 乱数を用いないアルゴリズムなので、コントロールしやすい。

Ward法については ウォード法によるクラスタリングのやり方 - 具体例で学ぶ数学このサイトが分かりやすいです。

クラスタリングの実行

下記のプログラムによって、文書ベクトルをクラスタリングします。

def hierarchical_clustering(emb, threshold):
    # 階層型クラスタリングの実施
    # ウォード法 x ユークリッド距離
    linkage_result = linkage(emb, method='ward', metric='euclidean')
    # クラスタ分けするしきい値を決める
    threshold_distance = threshold * np.max(linkage_result[:, 2])
    # クラスタリング結果の値を取得
    clustered = fcluster(linkage_result, threshold_distance, criterion='distance')
    print("end clustering.")
    return linkage_result, threshold_distance, clustered

def plot_dendrogram(linkage_result, doc_index, threshold):
    # 階層型クラスタリングの可視化
    plt.figure(facecolor='w', edgecolor='k')
    dendrogram(linkage_result, labels=doc_index, color_threshold=threshold)
    print("end plot.")
    plt.savefig('drive/My Drive/colab/narou/hierarchy.png')

def save_cluster(doc_index, clustered):
    doc_cluster = np.array([doc_index, clustered])
    doc_cluster = doc_cluster.T
    doc_cluster = doc_cluster.astype("int64")
    doc_cluster = doc_cluster[np.argsort(doc_cluster[:,1])]
    np.savetxt('drive/My Drive/colab/narou/cluster.csv', doc_cluster, delimiter=",", fmt="%.0f")
    print("save cluster.")

threshold = 0.4
linkage_result, threshold, clustered = hierarchical_clustering(emb=doc_vecs, threshold=threshold)
plot_dendrogram(linkage_result=linkage_result, doc_index=list(range(2000)), threshold=threshold)
save_cluster(list(range(2000)), clustered)

クラスタリングの結果、以下のようなデンドログラムが出力されました。

f:id:ShotaroKataoka:20181008215820p:plain

今回は類似度0.6までを1つのクラスタと設定したため、全部で8つのクラスタができました。
この図では低い位置で結合している部分ほど類似度が高いということを表しています。
図からクラスタの大きさはクラスタによってばらばらだということが分かります。

クラスタの分析・評価

ここまでで小説をクラスタリングすることに成功しました。
しかし、クラスタリングしたはいいもののそれぞれのクラスタがどのような集まりなのかは分かっていません。
ここでは小説がジャンル(小分類)ごとにクラスタリングされていると仮定して、それぞれのクラスタがどのようなジャンルを表しているのかを分析します。

クラスタのジャンル推定

他のクラスタには少ないが特定のクラスタには頻出である単語は、クラスタを表す重要な特徴語であると考えられます。
この仮説のもとでクラスタごとにtf-idfを行い、スコア上位の単語をクラスタのジャンル推定語とします。

今回は、
tf:クラスタ内出現頻度
idf:逆文書頻度
として計算しました。
したがってtf-idf値はクラスタ内で高頻度かつクラスタ外では低頻度であるような単語が大きい値を持つようになります。

以下のプログラムでtf-idfの算出を行いました。

import collections
# 単語の出現回数
t_counter = [{},{},{},{},{},{},{},{}]
for c, words in enumerate(cluster_word):
  count = collections.Counter(words)
  t_counter[c] = count
# クラスタ内の単語総数
sum_in_c = []
for count in t_counter:
  num = 0
  for i in count.values():
    num += i
  sum_in_c += [num]
# tf計算
tf_c = [{},{},{},{},{},{},{},{}]
for i, count in enumerate(t_counter):
  for key, value in count.items():
    tf_c[i][key] = value/sum_in_c[i]
# 全ての単語の辞書を作成
all_word = {}
for c in cluster_word:
  for word in c:
    all_word[word] = 0
# 単語がいくつの文書に出いているか
for docs in docs_cluster:
  words = docs[1].split(" ")
  for word in set(words):
    all_word[word] += 1
# idfの計算
import math
idf_word = {}
for key, i in all_word.items():
  idf_word[key] = math.log(2000/i + 1)
# tf-idfの計算
tf_idf = [{},{},{},{},{},{},{},{}]
for i, c in enumerate(tf_c):
  for key, value in c.items():
    tf_idf[i][key] = value * idf_word[key]

クラスタごとにtf-idf値の大きい順に名詞を並べると、

クラスタ0
王子: 0.0061060881712401045
殿下: 0.005718617870189564
ヒウラ: 0.005291476510766952
ダリア: 0.004495722542717447
令嬢: 0.004420107112278445
アデール: 0.004393982261590932
婚約: 0.003952553843864171
婚約者: 0.0039201562684602976
アリーセ: 0.0038517461527137527
アンリエット: 0.0036834659809932492
エルドード: 0.003533883606130579

クラスタ1
♪: 0.012588412597200886
死: 0.006430398748082015
雄輝: 0.004689613059420405
ノゾミ: 0.004484975398645696
勝: 0.0028733224721530478
奏: 0.002803742480211504
シュヴァルツ様: 0.002609130174877534
世界: 0.0026081004887485997
少女: 0.0024103156993214745
目: 0.0022606678876156837
自分: 0.0022072984665251817

クラスタ2
魔王: 0.0036518093100915143
勇者: 0.0035155562258950535
ギルガメシュ: 0.0029222379980847436
アップル: 0.0028061224713445657
目: 0.0027733169025503006
剣: 0.002671828038293947
男: 0.0025752770310372526
サバタ: 0.0023949840473599733
手: 0.0023649778902316027
前: 0.002325061518001071
声: 0.0022751005162464375

クラスタ3
ラウラ: 0.004840450953776067
スパイル: 0.004821922875957397
リク: 0.004670649187861814
エアリー: 0.00458253663388859
水月ちゃん: 0.0039327739768446855
スクラ: 0.0037104867520665074
目: 0.003492403771020575
マッティア: 0.0034198034581258132
グレタ: 0.0034198034581258132
サーシャ: 0.0033941559428515935
ハラン: 0.0033879243415634573

クラスタ4
魔法: 0.004218870319281262
人: 0.0029387891637282938
自分: 0.002551964550288861
今: 0.002544968129182831
世界: 0.002477627431371742
目: 0.002457369725536437
アミラさん: 0.0023359831990176863
前: 0.0022726743363401924
ー: 0.002245830004095153
顔: 0.002038915048811068
魔力: 0.00201955514478847

クラスタ5
冒険者: 0.006514906115889164
ギルド: 0.005524287477575682
魔法: 0.0035642475431967215
ランク: 0.0028300403141417315
魔物: 0.002774861645050802
ツナ: 0.0027436145973325442
剣: 0.0026404654150312193
セ: 0.002574910917319155
シント: 0.0025600376279171543
ゴブリン: 0.0025443174242620495
男: 0.002455813586393733

クラスタ6
ジエル: 0.007642659286556954
スキル: 0.0071498280961551745
魔法: 0.004392681106475657
ステータス: 0.0038861154483809386
Lv: 0.003779603258126132
ゲーム: 0.003080817252244139
プレイヤー: 0.003022432415983866
スライム: 0.0028334924566530604
世界: 0.002810395893392919
魔力: 0.0027202175798001415
Lv: 0.002687330699013531

クラスタ7
魔王: 0.003712822589593995
勇者: 0.003529249657469718
世界: 0.0034630886629906524
魔法: 0.0030282997761136234
スキル: 0.0028791184060730957
目: 0.0028063940530826145
今: 0.0025649199509634453
前: 0.002445422848422719
人: 0.0023228743569224234
自分: 0.0021845907678362426
手: 0.0021027100914477945

こうなりました。
クラスタごとにある程度の傾向はみられますが、人名らしきものが多く分かりづらいです。
データの前処理の段階で人名は除外しているのですが、一般名詞のフリをした人名がたくさん残っていました。

これでは、ちゃんとしたジャンル推定ができないので手法を少し改良する必要があります。

ジャンル推定手法の改良

いろいろ分析した結果、上位にくる人名は1つの作品中で連呼されている、ということが分かりました。
つまり、クラスタ全体のtf-idfが1つの小説の表現に引っ張られているということです。

これを改善するためにはどうすればよいでしょう。
作品数が増えれば、1つの作品の影響力は弱まるでしょうが、現在手元には2000作品しかなく増やすのにも時間がかかります。
そこで今回はクラスタ内の単語の出現回数をカウントするときに、1つの作品中では何度出現しても1回として数えることにしました。これにより小説の独特な繰り返し表現などに結果が引っ張られないようになると考えられます。

この手法でジャンル推定をした結果を以下に示します。

クラスタ0
令嬢: 0.0012388965444556815
婚約者: 0.001176390915430671
婚約: 0.0011580712533638608
結婚: 0.0010121023875020517
王子: 0.0009963851628662695
侍女: 0.0009385851967481451
ドレス: 0.0009262200035875142
公爵家: 0.0009179217053831507
貴族: 0.0009107907352958143
殿下: 0.0008597167673604495
恋: 0.0008252621317282777

クラスタ1
自身: 0.00048519030845686257
視線: 0.0004785750684602386
開発: 0.00047510620565140014
思考: 0.0004746269931782623
死: 0.0004719990542338976
存在: 0.0004713207620421417
現象: 0.0004691243740089858
耳: 0.0004662802091167956
黒: 0.00046379072402860373
確認: 0.00045983246060690204
一つ: 0.00045791918220934076

クラスタ2
自ら: 0.0005624009121069802
名: 0.0005370385832063932
国: 0.0005303111689206766
身: 0.0005254908086802654
息: 0.0005252804386547549
表情: 0.0005114196735714632
剣: 0.0005111140827173441
血: 0.0005104912277014727
人々: 0.0005041914574535155
命: 0.000503116192775948
姿: 0.000496684969558635

クラスタ3
静か: 0.0005806335100820448
窓: 0.0005796509100803112
背: 0.0005759772861478314
息: 0.0005602155500439732
肩: 0.0005566822763802123
空: 0.0005522933680195683
頬: 0.0005475829544980129
家: 0.0005451765723259455
首: 0.000540629592020056
隣: 0.0005400951772054877
歩き: 0.0005300151322929097

クラスタ4
人: 0.0005998453047999023
自分: 0.0005978763951012037
今日: 0.0005967663244144118
家: 0.0005963373136994322
目: 0.0005944352435476357
前: 0.0005925481742344472
今: 0.000591265602428745
顔: 0.0005902228111624595
手: 0.0005865973869938234
気: 0.000583170922913859
声: 0.0005781187587719914

クラスタ5
冒険者: 0.0006820327772998922
ギルド: 0.0006656959933021106
依頼: 0.0006569543085879682
カウンター: 0.0006484489486864962
酒場: 0.0006380120783850763
武器: 0.0005927986166789168
受付: 0.0005924436671910869
剣: 0.0005715763584048435
報酬: 0.0005690778616496287
店: 0.0005639439171109118
討伐: 0.0005532194656460136

クラスタ6
ステータス: 0.0009264972963791768
スキル: 0.0008763170045696633
表示: 0.0007413753512329212
レベル: 0.0007399919222877385
アイテム: 0.0007328916159730719
ゲーム: 0.0007178219585290772
モンスター: 0.0006773289277874036
画面: 0.0006710952031576871
装備: 0.0006398252525129832
効果: 0.0006219545148276319
攻撃: 0.0006137250132128857

クラスタ7
異世界: 0.0006146139404875984
世界: 0.0006145745694244645
今: 0.0005963364779569892
前: 0.0005952176477919668
目: 0.0005932608751475265
魔法: 0.0005931994948506662
人間: 0.0005930748515631123
手: 0.0005930396129693039
名前: 0.0005903103819954934
声: 0.0005891284313381556
人: 0.0005856936537451811

人名がきれいになくなり、よりクラスタの特徴らしいものが上位に来るようになりました。
特にクラスタ[0,5,6,7]はこれだけでどういうクラスタなのかが想像できます。

観測によるジャンルの推定

ここまで、小説の文章中の特徴語を見つけることでクラスタのジャンル推定を行ってきましたが、クラスタ[1,2,3,4]は特徴語が一般的で何のクラスタなのか分かりませんでした。
そこで、実際にクラスタに属する小説をいくつか(5~10作品ずつ)読むことで傾向を測りました。

これまでの結果と合わせて考えると、

クラスタ0:お嬢様への転生,異世界,恋愛,乙女ゲーム  
クラスタ1:ローファンタジー  
クラスタ2:ハイファンタジー  
クラスタ3:ローファンタジー,地の文でキャラクター名を使っている小説が多い。  
クラスタ4:あまりまとまっていない  
クラスタ5:ハイファンタジー,現地の人が主人公?,冒険者とかダンジョンとか  
クラスタ6:ハイファンタジー,異世界転生,ゲーム風世界観  
クラスタ7:ハイファンタジー,異世界転生,ゲーム的ではない感じ  

このようなクラスタになっていると推測しました。
全体数に対して調査件数が少ないですが、クラスタごとに小説の内容の傾向が明確に違うことは確認できました。

類似小説の検索

検索機能の実装

小説のジャンルによるクラスタリングは一応できましたが、8つのクラスタに大きく分類したためクラスタ内でも内容の散らばりがありました。
そこで、小説のID(以降、Nコードと呼びます)を与えたときに、類似度の高い順に小説名を表示するプログラムを作りました。
これで気に入った小説と似た小説を探せるようになるはずです!
以下にプログラムの主要部分のみを載せておきます。

def make_docs_cluster():
  # 文書ベクトルデータを返す。

def in_cluster(n_code):
  # 指定した小説が学習済みかを調べる。

def get_novel(n_cluster, in_cluster):
  # 小説をスクレイピングしてくる。

def novel_infer(n_code):
  # クラスタデータを読み込み
  docs_cluster = make_docs_cluster()
  # 指定したn_codeが学習済みデータであるかチェック
  is_in_cluster, docs_num = in_cluster(n_code, docs_cluster)
  # 推測する小説を取ってくる
  title, text = get_novel(n_code, is_in_cluster)
  print("検索小説タイトル:"+title)
  text = keitaiso(text)
  text = text.split(" ")
  if is_in_cluster:
    text = docs_cluster[docs_num][1]
    print("検索小説クラスタ:クラスタ{}".format(docs_cluster[docs_num][2]-1))
  else:
    print("未知の小説です。")
  print()
  # モデルのロード
  m = Doc2Vec.load("drive/My Drive/colab/narou/doc2vec.model")
  if is_in_cluster:
    newvec = m.docvecs[docs_num]
  else:
    newvec = m.infer_vector(text)
  most_similar = infer_most_similar(newvec, m, top=5)
  for i, sim in most_similar:
    sim_title = novel_title_dler(docs_cluster[i][0])
    print("タイトル:{0}\nクラスタ{1}  Nコード:{3}   similarity={2}".format(sim_title, docs_cluster[i][2]-1, sim, docs_cluster[i][0]))
    print()

似ている小説を探してみる

実際に学習に用いた小説を使って類似小説を検索してみます。
まずは、追放されたワケあり魔術師~伝説の賢者として世界を救うこちらの小説について似ている小説を探してみます。

検索小説タイトル:追放されたワケあり魔術師~伝説の賢者として世界を救う
検索小説クラスタ:クラスタ5

タイトル:『無能』の俺が強奪スキルで最強へと成り上がる 〜あらゆる才能を俺はねじ伏せる〜
クラスタ5  Nコード:n7067ez   similarity=0.735064685344696

タイトル:Wizard Life
クラスタ5  Nコード:n8454ey   similarity=0.7346222996711731

タイトル:食い扶持探し放浪譚
クラスタ7  Nコード:n9605ey   similarity=0.7337366938591003

タイトル:パーティーを追放されました。解雇理由:スキルがゴミ集めだから 2
クラスタ7  Nコード:n6956ey   similarity=0.7283002734184265

検索に使った小説とヒットした類似小説は多くが共通して以下の特徴を持っていました。
ハイファンタジー、現地主人公(異世界転生や転移でない)、成り上がり、冒険者、1話くらいで冒険者パーティから追放される

かなり細かい内容まで類似した小説を探すことに成功しました。

未知の小説に適用してみる

Doc2Vecは未知の文書も学習済みモデルを用いてベクトル化することができます。
実装した関数は未知の小説のNコードを指定した場合、自動で小説をスクレイピングして類似小説を検索することができるようにしています。
今回は学習に用いていない、累計ランキング1位の無職転生 - 異世界行ったら本気だす -に類似した小説を探してみます。

検索小説タイトル:無職転生 - 異世界行ったら本気だす -
未知の小説です。

タイトル:最弱で最強の魔法使い ~転生ガチャ大爆死!! 最強未満から最弱へ。仕方がないので弟子を育成します。再び転生するために~
クラスタ4  Nコード:n4977ez   similarity=0.6101943254470825

タイトル:未熟な果実
クラスタ4  Nコード:n6675ey   similarity=0.604122519493103

タイトル:最弱付与術師、最強武術家への道!
クラスタ4  Nコード:n5313fa   similarity=0.58991539478302

タイトル:いずれ天を刺す大賢者
クラスタ4  Nコード:n2863fa   similarity=0.5620313882827759

タイトル:虐げられた奴隷、飼い主の息子として転生する
クラスタ4  Nコード:n5414ey   similarity=0.5612924695014954

ヒットした小説の内容は、
ハイファンタジー、異世界、魔法が主要な要素となっている
という点で共通して検索小説と合致していました。

「キーワード:魔法」はファンタジー小説ならとりあえずいれとけという感じで多くの小説に設定されていますが、その中で魔法が主要要素となっている小説は一部にすぎません。
そのため、魔法がメインテーマである小説かどうかは読んでみるまで分かりません。
今回の手法では魔法がメインテーマとなっている小説をピンポイントで探し出せているので、従来の小説家になろうの検索システムよりも正確で詳細な検索が行えているのではないかと思います。

まとめ

結果について

  • 小説のDoc2Vecを行った。
  • 小説の文書ベクトルによるクラスタリングを行った。
    • ジャンルによってクラスタ分けできた。
    • しかし、意味の分からないクラスタもあった。
  • 類似小説の検索を行った。
    • 未知の小説に対しても精度よく検索出来た。

考察・今後の展望

  • Doc2Vecを行う際のパラメータは適当に決めたのでよりよくなるように調整をする。
  • クラスタリングの妥当性
    • 他の手法でクラスタリングをしたほうが良かったのではないか。
    • クラスタ数についても検討が必要。
  • 今回は2000作品で学習したが、もう少し学習データを増やして学習させたい。
    • ファンタジー以外の作品も取り入れて実験を行いたい。
    • 5話までしか用いなかったが、もう少しデータサイズを増やすとどうなるか。
  • ジャンル演算を行う。
    • word2vecは意味の演算が行える。(王ー男+女=女王)
    • ジャンル(例えば「異世界」や「恋愛」)の方向ベクトルが見つけられれば、「こんな感じの雰囲気で異世界要素を抜いた小説が読みたいなぁ」みたいなニーズに答えられるかもしれない。

以上です。
面白い結果が得られて楽しかったです。
ここまで読んでくださりありがとうございました。

ディープラーニングで文章を自動生成したい!

1. 目的

はじめまして、Aidemy研修生のいとぅー(@andmohiko)です。

自然言語処理では文章の自動生成というテーマがあります。

夏目漱石っぽい文章を自動生成してみたいのでやります。

 

2. 手法

LSTM-RNNを使います。

LSTMについては詳しくはこちら

qiita.com

簡単に説明すると、

ある長さの文字列から次の一文字を予測する ということをひたすら繰り返すことで文章が自動生成されていくというものです。

マルコフ連鎖は前の2文字しか見ないため、LSTMを使うことで文脈に沿った文章が生成されやすくなるという利点があります。  

3. データセット

青空文庫で公開されている夏目漱石の「坊ちゃん」「吾輩は猫である」「こころ」「夢十夜」を使います。

こちら→作家別作品リスト:夏目 漱石

自然言語処理はデータを集めてくるのが大変なのでとてもありがたいですね。

4. 前処理

ダウンロードしてきた本文にはルビや注釈なのど情報が含まれているため、

これらを削除し地の文のみにします。

import sys
import re

path = './bocchan.txt'
bindata = open(path, "rb")
lines = bindata.readlines()
for line in lines:
text = line.decode('Shift_JIS')
text = re.split(r'\r',text)[0]
text = re.split(r'底本',text)[0]
text = text.replace('|','')
text = re.sub(r'《.+?》','',text)
text = re.sub(r'[#.+?]','',text)
print(text)
file = open('data_bocchan.txt','a',encoding='utf-8').write(text)

これを夏目漱石の他の作品についてもやり、最後に4作品を繋げて一つの長いテキストファイルにする。

 

5. TensorFlow + KerasでLSTMを実装

いよいよモデルを実装していきます。 Kerasを使いますが、裏側はTensorFlowのお世話になります。

from keras.models import Sequential,load_model
from keras.layers import Dense, Activation, LSTM
from keras.optimizers import RMSprop
from keras.utils.data_utils import get_file
import numpy as np
import random
import sys

path = "./data_souseki.txt"
bindata = open(path, "rb").read()
text = bindata.decode("utf-8")
print("Size of text: ",len(text))
chars = sorted(list(set(text)))
print("Total chars :",len(chars))

#辞書を作成する
char_indices = dict((c,i) for i,c in enumerate(chars))
indices_char = dict((i,c) for i,c in enumerate(chars))

#40文字の次の1文字を学習させる. 3文字ずつずらして40文字と1文字というセットを作る
maxlen = 40
step = 3
sentences = []
next_chars = []
for i in range(0, len(text)-maxlen, step):
    sentences.append(text[i:i+maxlen])
    next_chars.append(text[i+maxlen])

X = np.zeros((len(sentences),maxlen,len(chars)),dtype=np.bool)
y = np.zeros((len(sentences),len(chars)),dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t ,char in enumerate(sentence):
        X[i,t,char_indices[char]] = 1
    y[i,char_indices[next_chars[i]]] = 1

#テキストのベクトル化
X = np.zeros((len(sentences),maxlen,len(chars)),dtype=np.bool)
y = np.zeros((len(sentences),len(chars)),dtype=np.bool)
for i, sentence in enumerate(sentences):
    for t ,char in enumerate(sentence):
        X[i,t,char_indices[char]] = 1
    y[i,char_indices[next_chars[i]]] = 1

#LSTMを使ったモデルを作る
model = Sequential() #連続的なデータを扱う
model.add(LSTM(128, input_shape=(maxlen,len(chars))))
model.add(Dense(len(chars)))
model.add(Activation("softmax"))
optimizer = RMSprop(lr = 0.01)
model.compile(loss="categorical_crossentropy",optimizer=optimizer)

def sample(preds, temperature=1.0):
    preds = np.asarray(preds).astype("float64")
    preds = np.log(preds) / temperature
    exp_preds = np.exp(preds)
    preds = exp_preds / np.sum(exp_preds)
    probs = np.random.multinomial(1, preds, 1)
    return np.argmax(probs)

#生成する
for iteration in range(1,30):
    print()
    print("-"*50)
    print("繰り返し回数: ",iteration)
    model.fit(X, y, batch_size=128, epochs=1)
    start_index = random.randint(0, len(text)-maxlen-1)

    for diversity in [0.2, 0.5, 1.0, 1.2]:
        print()
        print("-----diversity", diversity)
        generated =""
        sentence = text[start_index: start_index + maxlen ]
        generated += sentence
        print("-----Seedを生成しました: " + sentence + '"')
        sys.stdout.write(generated)

        #次の文字を予測して足す
        for i in range(400):
            x = np.zeros((1,maxlen,len(chars)))
            for t,char in enumerate(sentence):
                x[0, t, char_indices[char]] = 1

            preds = model.predict(x, verbose =9)[0] #次の文字を予測
            next_index = sample(preds, diversity)
            next_char = indices_char[next_index]

            generated += next_char
            sentence = sentence[1:] + next_char

            sys.stdout.write(next_char)
            sys.stdout.flush()
        print()
model.save('souseki_model.h5')
file = open('sousekigentext.txt','w+',encoding='utf-8').write(generated)

データ量が多いのでGPUを使わない場合は丸一日かかることもあります。しばらく放置しましょう。

6. 学習の様子

今回は時間の都合でepoch数を30にしています。

1回目 loss関数: 4.1994

申しょいを少しも充に落ちる語ったかど勢だ」「いらせんと従ょいに詩に至った文考を逢ってかかり方でいし出である武び少しドャ大椀屋に見て、這入ったが内にステ動りら間」
「人のく迷亭はかあした共安である。あるむを御当坊の起粧にな通ったの云き返す小ッちにも通ろ首の朝な性に学者口を高「え最がつけない」「すん、毫は食うんだ流山主人はレ一の子口学者は奥ずや。あ昔威横あが、しいくオあした 活で、学種爺とにこの作団だからパ気と云いえ面を見た。

読めない、、、

27回目 loss関数: 3.3512

大きな声を第一のはなかったから、それだから、どうかした事がない。その時の方だからしきりになっている事はないが、これからその事だから、自分でも、そのそとでない。吾輩は人間になると、吾輩には文明のごとくのはない。一るのは一際もなくなる。それなら飛び出して来たものがて来たのだが、またはなかったが、それではあいまさに、云わぬが、なるほどのところがある。ただ一人がいい。

なんとなく良さそう。 しかも夏目漱石っぽい(?)  

7. 結果と今後の発展

夏目漱石っぽい文章を生成することができました。 学習されていく過程を見ると徐々に日本語らしくなっていくところが見ていて楽しかったです。

loss関数が想定よりも小さくならなかったので、次やるときはloss関数の値を小さくするようにしたいです。 27回目の学習結果でも日本語として意味が成り立っているわけではないのがもうひといきな感じですね。loss関数が小さくなるとより日本語として意味が通ったものになりそうです。 今回は1文字ずつ予測していくことで文章を生成していましたが、単語分割をして一単語ずつ予測させるとより日本語っぽくなるかなと思いました。 また、夏目漱石っぽさを評価するために、何種類かの文章を用意してどれが夏目漱石の小説を学習させた結果かをアンケートでとるなどすると客観的に評価ができると思いました。

ドラマの視聴率を予測してみる!

機械学習を使って何か予測してみたいなと考えたとき、ドラマの視聴率は予測できないのかなと思い実際にやってみることにしました。

 

今回はJupyter notebookを使って実装しました。

 

データセット

 

https://artv.info/ ここから2015年と2016年のドラマの視聴率を取ってきてcsvファイルにする テストデータとして2017年のデータを取ってくる

データの説明

データの総数:176 目的変数:ドラマの視聴率の平均 説明変数:放送された年、曜日、時間帯、テレビ局、1回目の視聴率とした

1回目の視聴率はドラマのタイトルや出演者、CMの量などが影響してきていると考えられるので、今回はデータとして含めることができないそうした要素を加味するために加えることにした。 また、ドラマの名前は日本語でそのままでは扱いずらいので削除した。

まず必要なライブラリーをまとめてインストールして、作ったcsvファイルをインポート

 

#必要になりそうなものをまとめてインストール
import pandas as pd
import numpy as np
import seaborn as sns
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures
from sklearn.metrics import mean_squared_error
import matplotlib.pyplot as plt
%matplotlib inline
from sklearn.ensemble import RandomForestRegressor

 

# データをインポート
data = pd.read_csv('train.csv')
test=pd.read_csv("test2.csv")

#基本的な統計量を確認
data.describe()
year time 1 average
count 176.000000 176.000000 176.000000 176.000000
mean 14.920455 21.693182 10.503977 0.090645
std 0.824227 0.790775 3.527239 0.033990
min 14.000000 20.000000 2.700000 0.029300
25% 14.000000 21.000000 8.275000 0.069225
50% 15.000000 22.000000 10.100000 0.084500
75% 16.000000 22.000000 12.900000 0.107550
max 16.000000 23.000000 26.500000 0.229400
#データの相関を見る
data.corr() 
year time 1 average
year 1.000000 0.120151 -0.195264 -0.111665
time 0.120151 1.000000 -0.408886 -0.361273
1 -0.195264 -0.408886 1.000000 0.896363
average -0.111665 -0.361273 0.896363 1.000000
#可視化する
import seaborn as sns
plt.figure(figsize=(9,9))
sns.heatmap(data.corr(), annot=True, square=True, fmt='.2f')
plt.show()

sns.pairplot(data )
plt.show()

f:id:odnicesp:20180927171550p:plain

f:id:odnicesp:20180927171612p:plain

1回目の視聴率との相関がかなり高いことがわかります。(それはそうかな)

データの前処理

文字列のものはこのままでは学習できないので、ダミー変数化をします。

 

 #ダミー変数化
def dum(data,name):
dum=pd.get_dummies(data[name])
del(data[name])
data = pd.concat([data, dum], axis=1)
return data

data=dum(data,"season")
data=dum(data,"day")
data=dum(data,"TV")

同様にテストデータもダミー変数化する

 test=dum(test,"season")
test=dum(test,"day")
test=dum(test,"TV")

学習できるようにnumpyの形に変形

 

#学習データ作成
X_train=data.drop("average",axis=1).values
y_train=data.loc[:,"average"].values

#テストデータ作成

X_test=test.drop("average",axis=1).values
y_test=test.loc[:,"average"].values

学習

線形の重回帰をする

 # 線形回帰のモデルを学習させる
from sklearn.linear_model import LinearRegression
model_lin = LinearRegression()
model_lin.fit(X_train, y_train)

平均二乗平方誤差を求める

 

predict=model_lin.predict(X_test)

predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
if predict.loc[i,"predict"]<0:
predict.loc[i,"predict"]=0
elif predict.loc[i,"predict"]>1:
predict.loc[i,"predict"]=1

np.sqrt(mean_squared_error(y_test,predict))

出力:0.013120566634344053

 

視聴率にして大体1%くらい予測がずれていことがわかります。

次に2次関数で回帰を試してみます。

     

 

quad = PolynomialFeatures(degree=2) 
quad_data= quad.fit_transform(X_train)
quad_test= quad.fit_transform(X_test)
# 線形回帰のインスタンスを生成
model_quad = LinearRegression()

model_quad.fit(quad_data, y_train)

予測すると、

 

predict=model_quad.predict(quad_test)

predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
if predict.loc[i,"predict"]<0:
predict.loc[i,"predict"]=0
elif predict.loc[i,"predict"]>1:
predict.loc[i,"predict"]=1

np.sqrt(mean_squared_error(y_test,predict))

0.03321449872832119

誤差が少し増えてしまいました。 おそらく原因は1回目の視聴率と視聴率の平均の間に強い線形関係があったからだと思われます。

次に、少しアルゴリズムが異なる決定木を用いたモデルを試してみました。 今回はRandomForestを選択

#学習モデルの作成

rf=RandomForestRegressor(n_estimators=100)
rf.fit(X_train,y_train)
rf.score(X_test,y_test)
predict=rf.predict(X_test)
predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
    if predict.loc[i,"predict"]<0:
        predict.loc[i,"predict"]=0
    elif predict.loc[i,"predict"]>1:
        predict.loc[i,"predict"]=1
np.sqrt(mean_squared_error(y_test,predict))

0.011895995071281513

線形回帰より少し精度のよい予測ができました。そこで、実際に予測結果を出力してみます。

y_train=test.loc[:,"average"]
result=pd.concat([y_train,predict],axis=1)
result

予測結果を出力してみるとこのような感じになります。

 average predict
0   0.0665  0.080544
1   0.1130  0.100812
2   0.0890  0.093390
3   0.1139  0.126772
4   0.0974  0.094629
5   0.0653  0.074575
6   0.0342  0.037060
7   0.0832  0.091118
8   0.0646  0.062421
9   0.0963  0.106813
10  0.0499  0.063101
11  0.1455  0.123757
12  0.0930  0.093373
13  0.0877  0.097061
14  0.1058  0.124737
15  0.1125  0.101169
16  0.0917  0.077444
17  0.1409  0.153647
18  0.0644  0.077785
19  0.0333  0.035912
20  0.0879  0.087770
21  0.0545  0.061309
22  0.0955  0.095583
23  0.1360  0.117685
24  0.0535  0.059104
25  0.0768  0.093071
26  0.1478  0.135021
27  0.0607  0.065102
28  0.1017  0.099923
29  0.1147  0.096129
30  0.1097  0.107221
31  0.1145  0.097105
32  0.0450  0.047390
33  0.0345  0.035188
34  0.0733  0.086210
35  0.0400  0.053883
36  0.0870  0.094185
37  0.0973  0.092584
38  0.0654  0.078296
39  0.0858  0.074088
40  0.0674  0.086991
41  0.0575  0.076933
42  0.0773  0.093607
43  0.1265  0.095393
44  0.2087  0.213110
45  0.0653  0.059748
46  0.0348  0.036104
47  0.1187  0.114391
48  0.0483  0.054790
49  0.0877  0.087007
50  0.1600  0.140068
51  0.0619  0.064881

1回目の視聴率があるとかなり予測しやすいので、1回目の視聴率を除いて予測してみます。

del data["1"]
del test["1"]

#学習データ作成
X_train=data.drop("average",axis=1).values
y_train=data.loc[:,"average"].values

#テストデータ作成


X_test=test.drop("average",axis=1).values
y_test=test.loc[:,"average"].values

まず、線形回帰モデルを試してみます。

# 線形回帰のモデルを学習させる
from sklearn.linear_model import LinearRegression
model_lin = LinearRegression()
model_lin.fit(X_train, y_train)

predict=model_lin.predict(X_test)

predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
    if predict.loc[i,"predict"]<0:
        predict.loc[i,"predict"]=0
    elif predict.loc[i,"predict"]>1:
        predict.loc[i,"predict"]=1
np.sqrt(mean_squared_error(y_test,predict))

0.029758171793322862

誤差は視聴率にして3%程度であまりよくないです。

次に、2次関数で試してみます

quad = PolynomialFeatures(degree=2) 
quad_data= quad.fit_transform(X_train)
quad_test= quad.fit_transform(X_test)
# 線形回帰のインスタンスを生成
model_quad = LinearRegression()

model_quad.fit(quad_data, y_train)

predict=model_quad.predict(quad_test)

predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
    if predict.loc[i,"predict"]<0:
        predict.loc[i,"predict"]=0
    elif predict.loc[i,"predict"]>1:
        predict.loc[i,"predict"]=1
np.sqrt(mean_squared_error(y_test,predict))

0.034900994501918164

さらに精度が落ちてしまいました。 ダミー変数化している特徴量が多いので精度が下がってしまったのかもしれないです。

最後にRandomForestを試してみます

#学習モデルの作成

rf=RandomForestRegressor(n_estimators=100)
rf.fit(X_train,y_train)
predict=rf.predict(X_test)
predict=pd.DataFrame(predict, columns=["predict"])
for i in range(len(predict)):
    if predict.loc[i,"predict"]<0:
        predict.loc[i,"predict"]=0
    elif predict.loc[i,"predict"]>1:
        predict.loc[i,"predict"]=1
np.sqrt(mean_squared_error(y_test,predict))

0.020205388662540902

誤差2%程度で、そこそこ予測できているが、今回は標準偏差が大きくないデータでの予測なので、あまり精度が良くないと思われます。実際に2017年の平均視聴率を予測して比較してみると

y_train=test.loc[:,"average"]
result=pd.concat([y_train,predict],axis=1)
result
      average    predict
0   0.0665  0.094750
1   0.1130  0.084270
2   0.0890  0.090770
3   0.1139  0.108972
4   0.0974  0.130336
5   0.0653  0.076118
6   0.0342  0.036407
7   0.0832  0.074656
8   0.0646  0.065642
9   0.0963  0.097014
10  0.0499  0.072753
11  0.1455  0.099682
12  0.0930  0.087231
13  0.0877  0.088673
14  0.1058  0.080945
15  0.1125  0.085320
16  0.0917  0.124294
17  0.1409  0.116720
18  0.0644  0.064154
19  0.0333  0.035253
20  0.0879  0.088994
21  0.0545  0.072029
22  0.0955  0.083854
23  0.1360  0.147650
24  0.0535  0.066186
25  0.0768  0.084481
26  0.1478  0.100631
27  0.0607  0.071729
28  0.1017  0.077674
29  0.1147  0.114791
30  0.1097  0.085149
31  0.1145  0.102019
32  0.0450  0.072888
33  0.0345  0.032152
34  0.0733  0.061983
35  0.0400  0.061864
36  0.0870  0.074883
37  0.0973  0.100981
38  0.0654  0.065103
39  0.0858  0.082320
40  0.0674  0.086221
41  0.0575  0.086326
42  0.0773  0.122406
43  0.1265  0.122159
44  0.2087  0.191780
45  0.0653  0.074801
46  0.0348  0.035069
47  0.1187  0.104266
48  0.0483  0.071953
49  0.0877  0.085039
50  0.1600  0.115844
51  0.0619  0.077719

まとめ

1回目の視聴率は平均視聴率に大きく関係していることがわかりました つまり、ドラマの視聴率を上げるためには、1回目の視聴率がかなり大切になってくる気がします。 そのために、ドラマが始まる前にたくさんCMを打つことは視聴率を上げるうえで効果がありそうだなと感じました。

 

人工知能でTwitterのタイムラインとみんなの心を光で満たしたい

 

はじめまして!Aidemy研修生の紫垣と申します!

 

僕はよく、Twitterを眺めててネガティブなツイート(病みツイとか)を見かけて、気分がどんよりしてしまうことがあります・・・

じゃあそいつのアカウントミュートなりブロックなりしろよ!と言われるかもしれません。

だけど付き合い上そういうことができない方も多いですし、実際一つ一つミュートとかしてもキリないですよね?

 

そこで!!!!人工知能の力をお借りしたいと思います!!!!!

 

ツイートがポジティブなものかネガティブなものかを人工知能が判別して、ネガティブなツイートをタイムライン上に表示しないようなアプリを開発すれば、僕たちの心は光で満ち溢れるのではないかと思いました!!

まずこの記事では、第一歩として、ツイートがポジティブかネガティブかを判別する機械学習モデルを開発します!

というわけで、さっそく開発の流れを書いていきます!!

実行環境

 まず、今回の実行環境です。

Google Colabotaroryを使用しました。

OS : ubuntu 17.10
CPU : Intel(R) Xeon(R) CPU @ 2.30GHz
GPU : Tesla K80
メモリ : 12GB

データ集め

方法

機械学習をおこなうためには、まず大量のデータが必要です!今回必要とするデータは

  • ポジティブなツイート
  • ネガティブなツイート

この2つです!

ポジティブなツイートをどうやって探そうかと考えたところ、こんな感じのポジティブなつぶやきばかりするbotを複数発見しました!

 データ収集に使用するtwitter apiでは、1つのアカウントから1週間分のツイートしか集めることができないため、このようなアカウントを複数、収集の対象としました!

ネガティブなツイートについても、こんな感じのbotを複数集めてきました!

 あああ!!心がしんどい・・・

ですがこれも開発のためです!頑張ってデータ集めしていきたいと思います!

プログラム

今回、pythonで記述していきます!
こちらがデータ集め用のプログラムです!

def get_twitter_api(CK, CS, AK, AT):
    auth = tweepy.OAuthHandler(CK, CS)
    auth.set_access_token(AK, AT)
    api = tweepy.API(auth)
    return api

def get_tweet(user_id,count):
    API = get_twitter_api(CK, CS, AK, AT)
    data = API.user_timeline(id=user_id, count=count)
    tweets = []
    for tweet in data:
      tweets.append(format_text(tweet.text))
    
    return tweets,data[-1].id
  
def get_tweet_with_id(user_id,count,next_max_id):
    API = get_twitter_api(CK, CS, AK, AT)
    data = API.user_timeline(id=user_id, count=count, max_id=next_max_id-1)
    tweets = []
    for tweet in data:
      tweets.append(format_text(tweet.text))
    if len(data) == 0:
      return tweets,0
    return tweets,data[-1].id

こちらがツイート収集用プログラムです。
これらの関数を利用して、実際にデータ収集させるプログラムがこちらです!

def get_positive_tweets():

  pos_tweets = []
  positive_ids =[
      "positive_bot_00",
      "positivekk_bot",
      "botpositive",
      "positive_mot",
      "kami_positive",
      "positive_bot",
      "jinseiplusbot",
      "syuzou_genki",
      "genki_kotoba_m"
  ]
  
  for pos_id in positive_ids:
    tmp,max_id = get_tweet(pos_id,200)
    tmp = list(set(tmp))
    for i in range(len(tmp)):
      pos_tweets.append(tmp[i])
    while True:
      tmp,max_id = get_tweet_with_id(pos_id,100,max_id)
      tmp = list(set(tmp))
      for i in range(len(tmp)):
        pos_tweets.append(tmp[i])
      if max_id == 0:
        break
  return pos_tweets

def get_negative_tweets():
  
  neg_tweets = []
  negative_ids = [
      "negatizibu_bot",
      "inmydream19",
      "lewyfDanf",
      "positive_act_me",
      "pgmtmw",
      "yamik_bot",
      "cool_aroma",
      "nega_bot",
      "negativedbot",
      "H4Za5",
      "ymibot"
  ]
  
 for neg_id in negative_ids:
    tmp,max_id = get_tweet(neg_id,200)
    tmp = list(set(tmp))
    for i in range(len(tmp)):
      neg_tweets.append(tmp[i])
    while True:
      tmp,max_id = get_tweet_with_id(neg_id,100,max_id)
      tmp = list(set(tmp))
      for i in range(len(tmp)):
        neg_tweets.append(tmp[i])
      if max_id == 0:
        break

  return neg_tweets

こちらの関数でポジティブツイート、ネガティブツイートの収集を行っています。positive_ids,negative_ids内のIDのアカウントの、1週間分のツイートを収集できるようになっています!set()関数を使うことでbotで頻繁に表れる重複ツイートを取り除いています。そして残ったツイートをpos_tweets、neg_tweetsに格納して、返り値としています!

これでポジティブツイート、ネガティブツイートのデータは集まりましたね。
次はこれらのツイートを、機械が学習しやすいように整形していきます!
 

データの整形

不要な文字の削除

まず、URLとかよくツイートの中に入っていますが、これは学習の際、ジャマですよね!下のプログラムで削除します。

プログラム
def format_text(text):

    text=re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\-…]+', "", text)
    text=re.sub('RT', "", text)
    text=re.sub('お気に入り', "", text)
    text=re.sub('まとめ', "", text)
    text=re.sub(r'[!-~]', "", text)#半角記号,数字,英字
    text=re.sub(r'[︰-@]', "", text)#全角記号
    text=re.sub('\n', " ", text)#改行文字

    return text

 format_text()の中に文字列を引数として突っ込むと、その文字列の中のURLっぽい表現や、絶対必要ないであろう表現を削除した文字列に整形して返してあげるプログラムです。
これでいい感じのデータになりましたが、まだまだいいデータに整形できます。そこで役立つのが、「形態素解析」です!!

形態素解析

形態素解析とは!
ざっくりかみ砕いていうと、文章を単語ごとに細かく切って、それらの単語の品詞(名詞とか動詞とか)などの情報を得ることです!
例えば、「明日は晴れるでしょう。」という文章を形態素解析すると、こんな感じになります!

明日     アシタ    明日     名詞-副詞可能        
は      ハ      は      助詞-係助詞        
晴れる    ハレル    晴れる    動詞-自立    一段       基本形
でしょ    デショ    です     助動詞      特殊・デス    未然形
う      ウ      う      助動詞      不変化型     基本形
。      。      。      記号-句点

なんかいろいろ書いてあって難しそうですが、左から4列目を見てください!今回、こちらに書いてある品詞の情報を使って、データを整形していきます!

プログラム
def tokenize(tweets):
  t = Tokenizer()
  tokenized_tweets = []
  for tw in tweets:
    tokens = t.tokenize(tw)
    tmp = ""
    for token in tokens:
      
      noun_flag = 0
      
      partOfSpeech = token.part_of_speech.split(",")[0]
      
      if partOfSpeech == "名詞":
        noun_flag = 1
      if partOfSpeech == "動詞":
        noun_flag = 1
      if partOfSpeech == "形容詞":
        noun_flag = 1
      if partOfSpeech == "形容動詞":
        noun_flag = 1
      if partOfSpeech == "感動詞":
        noun_flag = 1
      
      if noun_flag == 1:
        tmp += token.surface + " "
        
    tmp = tmp.rstrip(" ")    
    tokenized_tweets.append(tmp)
    
  return tokenized_tweets

 まず、janomeライブラリの中のTokenizer()というインスタンスを作成します。このインスタンスが形態素解析をおこなってくれます。
次に、Tokenizer()インスタンスのtokenize()関数を使って、集めたツイート一つ一つを、先ほどの文章みたいに細かく切り刻みます。
そしてfor文で1つ1つの単語の品詞を変数partOfSpeechに代入し、その品詞が学習で使えそうな品詞(名詞、動詞、形容詞、形容動詞のいずれか)なら、noun_flagというフラグを1(このフラグの値は元々0)にします。
最後に、1つ1つの単語に対してその単語のnoun_flagが1なら(上に挙げた品詞に該当する)残す、0なら(上に挙げた品詞に該当しない)その単語を削除、といった具合にツイートをゴリゴリ削っていきます!また、今後のために各単語を半角スぺース" "で区切っています。
そうして整形されたツイートを返り値として返す関数になっています!
例えば、「明日は晴れるでしょう。」というツイートがもしあった場合、こうなります。
「明日 晴れる」
これで機械に学習させる準備は整いました!早速学習させていきましょう!

機械学習

ランダムフォレスト

学習モデルにはいろいろありますが、今回、ランダムフォレストという学習モデルを使用します。なぜこのモデルを選んだかについては後述します。
このランダムフォレストを理解するためには、まず決定木分析について知ることが必要です!

決定木分析とは?

下の図のような決定木と呼ばれるものをデータの学習によって作成し、条件分岐によってグループ(ツイートの集まり)を分割し、グループ内の各要素(ツイート)を分類する(ポジティブか?ネガティブか?)手法です。
f:id:shigashan:20180924183518p:plain
上の決定木だと、「ゲーム楽しい!最高!」というツイートはPositive!に分類されます。
また、「ゲーム楽しいけど、負けるとイライラする...」というツイートはNegative...に分類されます。
この決定木は元の学習データ(ツイート)と、教師データ(そのツイートがポジティブなものかネガティブなものかの答え)によって作成されます。
膨大な量の学習データ、教師データから決定木を作成し、人間がポジティブかネガティブかを教えずにツイートを与え、先ほどの学習によって作られた決定木によってポジティブかネガティブかを判定させる、これを決定木分析といいます。

ランダムフォレストとは?

上記の決定木分析で作られる決定木は一本のみです。しかし、ランダムフォレストでは、この決定木を複数作成します。
そうして得られた複数の決定木から一番判定の精度が良いものを選抜し、ポジティブかネガティブかがわからないツイートの判定に用いる。この学習方法をランダムフォレストといいます。

BOW_tf-idfによる文字→数値表現

ランダムフォレストは、文字を解析することはできません。そのため、文字をうまい具合に数値で表現する必要があります。そこで有効なのが、BOWという表現方法です。BOWの表現方法の中でも、単語の出現頻度・その単語の希少性に着目した数値表現をしてくれる、tf-idfという表現方法を用います。

学習開始!

それではランダムフォレストを使って学習を開始していきます!

プログラム
pos_tweets = get_positive_tweets()
neg_tweets = get_negative_tweets()

tokenized_pos_tweets = tokenize(pos_tweets)
tokenized_neg_tweets = tokenize(neg_tweets)

まず前項のプログラムの関数を総動員して、ポジティブ、ネガティブなツイートを取得、そして整形します。
そうして得られる整形済みツイートをtokenized_pos_tweets,tokenized_neg_tweetsに格納します。

tweet_datas = []
tweet_labels = []

for tw in tokenized_pos_tweets:
  tweet_datas.append(tw)
  tweet_labels.append("positive")
for tw in tokenized_neg_tweets:
  tweet_datas.append(tw)
  tweet_labels.append("negative")

その次に、学習用ツイートデータ配列tweet_datas,教師データtweet_labelsを用意し、先ほど得られた整形済みツイートを格納していきます。tokenized_pos_tweetsのデータが格納されたときはtweet_labelsに"positive"を、tokenized_neg_tweetsが格納されたときは"negative"を格納しています。学習データの順番と教師データの順番が対応するよう格納しています。

train_data, test_data, train_labels, test_labels = train_test_split(
    tweet_datas, tweet_labels, test_size=0.2,random_state=0)

そして得られた学習用データ、教師用データを、トレーニングデータ、テストデータに分割します。
トレーニングデータとは、学習モデルを構築するためのデータです。
テストデータとは、構築された学習モデルの精度を判定するためのデータです。
今回がトレーニングデータ:テストデータ=8:2で分割します。

vectorizer = TfidfVectorizer(use_idf=True, token_pattern="(?u)\\b\\w+\\b")
train_matrix =  vectorizer.fit_transform(train_data)
test_matrix = vectorizer.transform(test_data)

BOW_tf-idf表現を用いて、ツイートの各単語を数値で表現します。

clf = RandomForestClassifier(n_estimators=1000,random_state=20,max_depth=75)
clf.fit(train_matrix, train_labels)

ランダムフォレストで先ほどの文字→数値表現データを学習させます。
n_estimatorsは決定木の候補の本数、random_stateはデータの取り出し方や分割方法、max_depthは決定木の深さを決定します。シミュレーションで得られた最も精度の良い値を採用しています。

print("トレーニングデータ精度:",clf.score(train_matrix, train_labels))
print("テストデータ精度:",clf.score(test_matrix, test_labels))

得られた学習モデルの、トレーニングデータ、テストデータの精度を出力します。

test_tweets = ["生まれてきて良かった",
              "このツイート最高に面白い!すごくいいね!",
              "君のおかげでいろいろ助かった!ありがとう!",
              "ごめんなさい今そういう気分じゃないんです",
              "最近いろいろつまらんわ",
              "なんか何もかもやる気が出ない・・・",
              "君が友達で俺は幸せだ!",
              "イライラして壁破壊してしまいそう",
              "あの人なんか気持ち悪い・・・",
              "君のことを考えるだけで目の前が光で包まれるよ!最高だ!"
             ]
test_tokenized = tokenize(test_tweets)

test_matrix2 = vectorizer.transform(test_tokenized)

test_result = pd.DataFrame()
test_result["テスト対象ツイート"] = test_tweets
test_result["判定"] = clf.predict(test_matrix2)
test_result

試験用に考えた各ツイートの判定結果を表にして出力します。

以上!プログラムでした!

実際に動かしてみた

それでは、完成したプログラムを動かしてみたいと思います!
こちらです!!
f:id:shigashan:20180925155635p:plain

トレーニングデータ、テストデータに対する出力はなかなかなものではないでしょうか!
試験用ツイートの判定もまあまあといったところですね。

考察

誤判定について

今回、試験用ツイートに2つ誤判定がありました。
「君が友達で俺は幸せだ!」
「君のおかげでいろいろ助かった!ありがとう!」
この2つはpositiveとして判定してほしかったのですが、negativeとして判定されてしまいました。

誤ツイート1は、"幸せ"という如何にもポジティブそうな単語があるにも関わらず、negative判定されています。
誤ツイート2は、"ありがとう"とかのフレーズはポジティブっぽいのにnegaitve判定されています。

これらの誤判定の原因を3つ考えました。

原因(予想)1. 単語の学習不足?

今回収集したポジティブツイートの数は15551ツイートですが、その中に"幸せ" "ありがとう" これらの単語が含まれたツイートが少ないため、学習モデルがこれらの単語をポジティブな単語として認識してくれなかったのではないかと考えました。
また、"君"という言葉をネガティブな単語として判定しているのでは?とも感じました。しかし、
「君のことを考えるだけで目の前が光で包まれるよ!最高だ!」というツイートはpositive判定されています。これは、"光" "最高" など、ポジティブ要素が非常に強い単語でネガティブ要素を相殺しているため、positive判定されたのではないか、と考えました。

原因(予想)2. 決定木が不完全?

"幸せ"という単語が、決定木のより先端に近い方に配置されていて、"幸せ"という単語があるかないかの判定をされる前に他の関係のない単語でnegative判定されてしまっているのではないかと考えました。

原因(予想)3. 単語同士の繋がり(文脈)が考慮されていない

今回の学習方法は、単語1つ1つを学習に用いています。
「君のおかげでいろいろ助かった!ありがとう!」
こちらのツイートは、一見ポジティブ感がありますが、このツイートを上のプログラムで整形してみると、
「君 おかげ 助かっ ありがとう」
となり、"ありがとう"以外の単語は、ポジティブともなんともいえない単語です。
そのため、学習不足などにより"ありがとう"がpositive判定されないと、他の単語の影響により、一気にnegative判定の方へといってしまいそうです。

今後の展望

以上の考察から、今後の展望として、
・学習ツイート数を増やす(twitter apiの制限をなんとかしないといけない)
・決定木の再構築(random_stateの値を見直す)
・どうにかして単語同士のつながりも特徴量に含まれるようなアルゴリズムを考える(Word2VecとBOWの併用とか?)

このあたりをなんとかしていけたらいいなと思います!

最後まで読んでいただき、ありがとうございました!

自然言語処理でベストセラーのタイトルを分析してみた

f:id:wake05410:20180919203928j:plain

はじめに

はじめまして。Aidemy研修生のこんどうです。

機械学習を始めて2週間そこらでこんな記事を書かせてもらってありがたい限りです




 本屋に行くと並んでいる無数のビジネス書を見ながらいつもこんなことを考えています

 「『人生がうまくいくたった3つの考え方』!?!?!?『効率が100倍になる新時代の働き方』!?!?!?『成功者の法則』!?!?!?この本を読んだだけで人生がうまくいって効率が100倍になって成功者になれるのか!?!?!?」

(※本のタイトルは創作しました)
 
そしておもむろに3冊の本を手に持ちレジへと向かうのです。




いまだ本の効果を実感できてはいませんが、ふとこんなことを思いました


「売れてるビジネス書のタイトルを分析して最強のタイトルを考えれば たとえ中身がなんであろうと爆発的に売れるのではないか」
 




そんな妄想を背景として本記事では

「自然言語処理を用いたビジネス書タイトルの分析」
 

について書いていきたいと思います。

 

全体の流れ

 

ランキングサイトからスクレイピングでデータ収集
      ↓
形態素解析で品詞分解
      ↓
単語ごとに出現回数を表示して分析

 

0.環境

Python 3.6.4
jupyter notebook 4.4.0
windows 10

1.スクレイピング


東洋経済ONLINEのサイトでAmazonのビジネス書売り上げランキングが

毎週1位~200位まで発表されているので

今回はそれをスクレイピングで自動収集していきます

toyokeizai.net



f:id:wake05410:20180919154920p:plain


本のタイトルの取得と同時に順位に応じてポイントをつけていきます

データについて

期間:2016/2/9~2018/9/19
各週のデータ数:1位~200位
各週ごとに1位に200pt,2位に199pt....とポイントをつける
本のタイトル総数:2983タイトル

スクレイピングに用いるライブラリ:BeautifulSoup

import pandas as pd
import urllib.request
from bs4 import BeautifulSoup
import json
import time
import numpy as np

スクレイピングしてタイトルとポイントをjson形式で出力

#リンク一覧ページからランキングサイトのリンクを取得
link_list=[]
for i in range(1,11):
    url_ranking_list_root="https://toyokeizai.net/category/weeklyranking"
    page_url="?page="+str(i)+"&per_page=15"
    url_ranking_list=url_ranking_list_root+page_url

    html=urllib.request.urlopen(url_ranking_list)
    soup=BeautifulSoup(html,"lxml")
    link_html_list=soup.find_all("a",class_="link-box")
    for link_html in link_html_list:
        link_list.append(link_html.get("href"))
    time.sleep(1)
link_list=list(set(link_list))


#辞書作成
dict={}
for n in range(0,len(link_list)):
    for page_num in range(2,6):
        root_url="https://toyokeizai.net"
        page_url="?page="+str(page_num)
        url=root_url+link_list[n]+page_url
        html=urllib.request.urlopen(url)
        soup=BeautifulSoup(html,"lxml")
        title_list=soup.find("tbody").find_all("a")
        
        for i in range(len(title_list)):
            if title_list[i].text not in dict:
                dict[title_list[i].text]=200-(i+50*(page_num-2))
            else:
                dict[title_list[i].text]+=200-(i+50*(page_num-2))
        time.sleep(1)

#json形式で出力
with open("book_data.json","w",encoding="utf-8") as f:
    json.dump(dict,f,indent=4,ensure_ascii=False)

2.形態素解析で品詞分解

スクレイピングで収集したタイトルを形態素解析していきます


形態素解析とは...

文章を意味のある単語ごとに分解する技術です


f:id:wake05410:20180919153713p:plain



今回は本のタイトルを形態素解析したあと

それぞれの単語の出現回数をカウントしていきます


用いるライブラリ:janome

from janome.tokenizer import Tokenizer
import numpy as np
import pandas as pd

 

with open("book_data.json","r",encoding="utf-8") as f:
    dict=json.load(f)
title_list=list(dict.keys())
point_list=np.array(list(dict.values()))

title_wakati=[]

for i in range(len(title_list)):
    #正規表現で不要頻出語を削除
    title=title_list[i]
    title=re.sub("新書","",title)
    title=re.sub("単行本","",title)
    title=re.sub("雑誌","",title)
    title=re.sub("文庫","",title)
    title=re.sub("[。!-/:-@[-`{-~]","",title)

    t=Tokenizer()
    tokens = t.tokenize(title)
    for token in tokens:
        partOfSpeech = token.part_of_speech.split(",")[0]
        surface = token.surface
        #1語の単語を削除して名詞と動詞のみを取り出し
        if len(surface) > 1:
            if partOfSpeech == "名詞":
                title_wakati.append(surface)        
            if partOfSpeech == "動詞":        
                title_wakati.append(surface)
        
#単語数をカウント
d_count=collections.Counter(title_wakati)
d_count=d_count.most_common
#DataFrame化
word_dct={"単語":[],
                  "出現回数":[]}
for i in d_count:
    word_dct["単語"].append(i[0])
    word_dct["出現回数"].append(i[1])

df=pd.DataFrame(word_dct)
df=df_top.sort_values("出現回数",ascending=False)
df.reset_index(drop=True,inplace=True)
df.to_csv("book_data.csv")

3.結果発表~~~~!!!!

3-1.ビジネス書に出てくる単語ランキング


出力結果


f:id:wake05410:20180920223641p:plain

上位一部だけ抜粋


「会社」「仕事」「経済」
などの一般的によく使われる単語から


「ビジネス」「教科書」「最強」「リーダー」「戦略」

などのビジネス書にありがちな単語も多く見られます



3-2.売れ行きで重みづけしたランキング


ここで先ほど集計したポイントで単語を重みづけしてみます

つまり売れている本に出ている単語をより重要な単語として扱うということです

with open("book_data.json","r",encoding="utf-8") as f:
    dict=json.load(f)
#ポイントに√をかける
title_list=list(dict.keys())
point_list=np.array(list(dict.values()))
point_list_root=np.sqrt(np.abs(point_list))
#ポイントに応じて単語を重みづけ
title_list_ex=[]
for i in range(len(point_list_root)):
    title_list_ex.append(title_list[i]*int(point_list_root[i]))
title_wakati=[]
for i in range(len(title_list_ex)):
    #正規表現で不要頻出語を削除
    title=title_list_ex[i]
    title=re.sub("新書","",title)
    title=re.sub("単行本","",title)
    title=re.sub("雑誌","",title)
    title=re.sub("文庫","",title)
    title=re.sub("[。!-/:-@[-`{-~]","",title)

    t=Tokenizer()
    tokens = t.tokenize(title)
    for token in tokens:
        partOfSpeech = token.part_of_speech.split(",")[0]
        surface = token.surface
        #1語の単語を削除して名詞と動詞のみを取り出し
        if len(surface) > 1:
            if partOfSpeech == "名詞":
                title_wakati.append(surface)        
            if partOfSpeech == "動詞":        
                title_wakati.append(surface)
        
#単語数をカウント
d_count=collections.Counter(title_wakati)
d_count=d_count.most_common()
#DataFrame化
word_dct={"単語":[],
                  "出現頻度":[]}
for i in d_count:
    word_dct["単語"].append(i[0])
    word_dct["出現頻度"].append(i[1])

df_ex=pd.DataFrame(word_dct)
df_ex=df_ex.sort_values("出現頻度",ascending=False)
df_ex.reset_index(drop=True,inplace=True)
df_ex.to_csv("book_data_ex.csv")

結果
f:id:wake05410:20180920230719p:plain


あまり変わりませんね....



3-3.上位と下位の単語出現頻度の比較


売上で重みづけしてもあまり順位が変わらなくてやや焦ってきましたが


じゃあ売上の上位と下位で使われている単語に違いはないんですか!?


と思ったので売れ行き上位10%と下位10%の本のタイトルを比較することにしました(先ほど扱ったポイントの上位10%と下位10%を抽出)

with open("book_data.json","r",encoding="utf-8") as f:
    dict=json.load(f)
#ポイントによって辞書をソート
dict=sorted(dict.items(),key=lambda x:x[1],reverse=True)
title_list=[]
point_list=[]
for i in dict:
    title_list.append(i[0])
    point_list.append(i[1])
#上位10%と下位10%を抽出
title_list_top=title_list[:300]
title_list_bottom=title_list[2683:]
#以下同じ


結果

上位10%---------------------------------下位10%--------------------------------------
f:id:wake05410:20180920231208p:plainf:id:wake05410:20180920231458p:plain



ご覧のとおり全然変わらないです....


「ビジネス書のタイトルどいつもこいつも同じかよ」



ともはや理不尽極まりないいらだちをおぼえてきました




3-4. 売上に貢献している単語は?




そこで続いては
売上ポイントに寄与している単語はなにかを調べてみることにします



最頻出単語の上位400個(上位10%)を取り出して、各タイトルについて
含まれていれば1、含まれていなければ0をつけてカテゴリー化します
ダミー変数については以下のリンクを参考にしてください
xica.net

with open("book_data.json","r",encoding="utf-8") as f:
    dict=json.load(f)
title_list=list(dict.keys())
point_list=np.array(list(dict.values()))
df=pd.read_csv("book_data_new.csv",index_col=0)
words_list=list(df["単語"][:100])
title_score=[[] for i in range(len(title_list))]
for i in range(len(title_list)):
    title=title_list[i]
    for word in words_list:
        if word in title:
            title_score[i].append(1)
        else:
            title_score[i].append(0)
df_score=pd.DataFrame(title_score)
df_score.columns=words_list
df_score.index=title_list


出力結果はこのようなデータフレームになります

f:id:wake05410:20180920185037p:plain


Scikit-learnのライブラリfeature_selectionを用いて

売上ポイントに最も寄与した単語を指定した個数(今回は20個)だけ取り出します


変数選択手法について
aotamasaki.hatenablog.com

feature_selectionについて
qiita.com


変数選択手法のなかで今回はFilter Methodの分散分析とWrapper MethodのRFEを使用しています

詳しくはリンクを参照していただきたいのですが


・分散分析:説明変数のどれを選ぶかを変えることで予測値がどれくらい変わるかを計算して重要変数を見つける手法

・Wrapper Method:変数を選んだらモデルを作成して予測値を計算することで重要変数を見つける手法



Filter Method

from sklearn.feature_selection import SelectKBest,f_regression

selector=SelectKBest(k=20)
selector.fit(df_score,point_list) 
X_selected=selector.transform(df_score)
mask=selector.get_support()
scores=selector.scores_
score_dct={}
for i in range(len(mask)):
    if mask[i]==True:
        score_dct[words_list[i]]=scores[i]
        
for k, v in sorted(score_dct.items(), key=lambda x: -x[1]):
    print(str(k) + ": " + str(v))

出力結果がこちらです

f:id:wake05410:20180921115333p:plain



「NewsPicks」「Book」「業界」

のスコアが高いのでこれらの単語が入っているとポイントが上がる傾向があるようです



Wrapper Method

from sklearn.feature_selection import RFE
from sklearn.linear_model import LinearRegression

selector=RFE(LinearRegression(),n_features_to_select=20)
selector.fit(df_score,point_list)
mask=selector.get_support()

for i in range(len(mask)):
    if mask[i]==True:
        print(words_list[i])

出力結果

f:id:wake05410:20180921115342p:plain

大方は一致していますね
RFEのほうでは重回帰分析モデルをいちいち作成してるのでこっちのほうが正確な気もします



これらの結果から売上と相関の強い単語だけを集めたビジネス書タイトルを考えてみました

「ハーバード式 本質的な考え方の授業」
「生き方を変える行動の科学(NewsPicks)」


なんか普通にありそうなタイトルですね...





4. 結果の分析と反省


・ビジネス書のタイトルによく出てくる単語は相当偏ってそうです

直感的には「似たようなタイトル多いなぁ」と感じますが定量的に見てみると明らかです



・売上の上位も下位もタイトルに使われている単語は大方共通でした

ここに大きな差が出てくれると面白かったのですが...



ベストセラーに共通の単語は見つかりませんでしたが、

売上と相関のある単語が見つかったのは今回1番の収穫でした

もちろん相関関係と因果関係は別ですが...



今回はランキングの1位~200位までしか取れなかったので単語レベルでも似たようなものしか出てこなかったのかと思います

もっと幅広いビジネス書のタイトルと売上を集計できればよりおもしろい結果が出そうです

データ収集の重要性と難しさを改めて感じますね


5. (おまけ)ビジネス書のトレンド


先ほどは売れ行き順にクラスを分割しましたが

時系列順に分割してみたらおもしろいことが分かるのではないか

つまり


ビジネス書のタイトルのトレンドからその時々のビジネスパーソンの関心事もわかるのではないか


ということです

単語を抽出する操作は大体同じなのでコードは割愛します

期間は半年をひと区切りとして計5つの期間に分けました



6-1.ブロックチェーンとビットコイン


まず近年注目の集まっている単語「ブロックチェーン」「ビットコイン」に注目してみました

2つの単語が含まれているタイトル数を各区間で週計してグラフにしたもの(上段)と

Googleトレンドで調べた2つの単語の日本での検索数(下段)をグラフにしました


f:id:wake05410:20180919192550p:plain


ビジネス書のトレンドも世間のトレンドと同時に上がっていますが、

世間ではすぐに冷めた関心がビジネス書ではより大きくなっているところが興味深いです



6-2.TOYOTAとGoogle


「トヨタ」と「Google」の出現回数の推移も調べてみました

f:id:wake05410:20180919194151p:plain


2年前まで20近い出現数だった「トヨタ」は近年数を落とし、

「Google」が数を伸ばしてきています


経営システムのトレンドがトヨタ流からシリコンバレー流に変化していることがビジネス書のタイトルからも定量的に分かるというのはひとつおもしろい発見でした



7.おわりに

直感的に感じていることを定量的に表すというのは新しいことを発見する良い手がかりになると思いますのでぜひ色々な場面で挑戦してみてほしいと思います


この記事が少しでもその助けになれれば幸いです

機械学習で金の価格を予想してみた

 こんにちは。Aidemy研修生の藤川です。

 

機械学習というと何かを予測するということを考える人が多いのではないでしょうか。

 

株やFX、仮想通貨の価格を予想しているブログも多々ありますが、金の価格はどうでしょうか。
f:id:t_aisu_ke:20180912011005p:plain
 

なぜ金か?

というと、以下のリンクにある通り、金の価格予想自体は昔からされています。


jpyforecast.com


 

しかし、機械学習を使った予測はどうやらあまり行われていないようです。

 

ということで、この価格予想に負けじと、機械学習を用いて予測を行ってみたいと思います!



 今回はGoogle Colaboratoryを使用しています。
もし使用する場合は、ランタイムの変更でGPUを指定しておくことを忘れないようにしてください。

実行環境(lshwを使用して確認)

OS : ugbuntu17.10
CPU : Intel(R) Xeon(R) CPU @ 2.20GHz
GPU : Tesla K80
メモリ : 12GB

このようにある程度のスペックの環境であることがわかります。
自分のPCのスペックが足りない場合には非常に役立ちそうですね。



import部分はこのようになっています。

import pandas as pd
import numpy as np
import io
from google.colab import files
import matplotlib.pyplot as plt
import statsmodels.api as sm
from datetime import datetime
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.arima_model import ARIMA
import warnings
import itertools
%matplotlib inline

 

データの取得

まず、金の価格のデータを取得しなければいけません。

データは以下のサイトから取得できます。
lets-gold.net

今回は2016年と2017年のデータをもとに学習を進めていきます。
 

データの読み込みを行います。時系列解析では連続した日付のデータが求められますが、
金の価格は土日・祝日に更新がされないので日付を適当につけなおしてあげます。

#ファイルをアップロードする
uploaded = files.upload()

#アップロードされたファイルを読み込む
gold_data = pd.read_csv("historical_data_2016.csv")
gold_data2 = pd.read_csv("historical_data_2017.csv")

#必要のない列は削除しておく。
gold_ = gold_data.drop(columns = ["DATE","PT_TOKYO","GOLD_NY","PT_NY","USDJPY"])
gold_.dropna()
gold2_ = gold_data2.drop(columns = ["DATE","PT_TOKYO","GOLD_NY","PT_NY","USDJPY"])
#データの結合
gold = pd.concat([gold_,gold2_])

#ここで日付を付け直してあげる
gold.index = pd.date_range("2016-01-01","2017-05-06",freq = "D")


 

データの関係を調べる

ここで、データの関係を確認することにします。
このブログを参考に進めていきます。
www.kumilog.net

まずは、自己相関係数を求めます。
簡単に言うと、自己相関係数を調べることで何個前のデータが大きな影響を及ぼしているかがわかります。

fig=plt.figure(figsize=(12, 8))
ax = fig.add_subplot(212)
#自己相関係数
sm.graphics.tsa.plot_acf(gold_data["GOLD_TOKYO"], lags=80,ax = ax) 
plt.show()

f:id:t_aisu_ke:20180912001857p:plain

ここで、色が濃くなっているところが95%信頼区間です。
だいたい10個前までのデータに大きく影響を受けていることがわかります。

次に偏自己相関係数を求めます。

fig=plt.figure(figsize=(12, 8))
ax = fig.add_subplot(212)
#偏自己相関係数
fig = sm.graphics.tsa.plot_pacf(gold_data["GOLD_TOKYO"], lags=80, ax=ax)
plt.show()

f:id:t_aisu_ke:20180912002520p:plain

偏自己相関係数では、ある点とある点の間の関係をダイレクトに調べることが出来ます。
例えば、今日と2日前のデータの関係を調べる時、1日前のデータの影響も受けますが、
偏自己相関係数を用いると1日前の影響を取り除いて考えることが出来ます。
この結果を見る限り、前日の影響を大きく受けることがわかります。
また、6日目もある程度大きな影響を与えていることもわかります。
69日目は95%信頼区間をギリギリ超えています。ここも何か関係がある可能性がありますね。


最後に、ADF検定を行います。
ADF検定では単位根過程でないかどうかを判定できます。
詳しくは以下の記事をご覧ください。
logics-of-blue.com

pythonではstatsmodelsで簡単にADF検定を行うことが出来ます。

#ADF検定を行う
adf_result = sm.tsa.stattools.adfuller(gold_data["GOLD_TOKYO"],autolag='AIC')
adf = pd.Series(adf_result[0:4], index=['Test Statistic','p-value','#Lags Used','Number of Observations Used'])
print(adf)  

結果は以下のようになります

Test Statistic                  -3.089297
p-value                          0.027355
#Lags Used                       5.000000
Number of Observations Used    239.000000

p値は0.027355と、 p < 0.05であるので単位根過程ではないことがわかります。
単位根過程ではないということでここから安心して学習を進めていきます。

モデルの決定・学習

今回は時系列のデータなので、1番基礎となるSARIMAモデルを使用します。
SARIMAモデルについてはこちらのサイトでよく理解できると思います。
deepage.net
学習部分についてはこのようにスマートにかくことが出来ます。

N = 420
#420点までを学習とし、その先50点を予測する
test = gold[:]
gold = gold[:N]
#グラフよりs=1,6,69あたりが良い?
SARIMA_gold = sm.tsa.statespace.SARIMAX(gold,order=(1, 0, 1),seasonal_order = (1,1,1,69), enforce_stationarity = False, enforce_invertibility = False,trend = "n").fit(trend='nc',disp=False)
print(SARIMA_gold.summary())
pred = SARIMA_gold.predict()
pred2 = SARIMA_gold.forecast(50)

自己相関係数より、q = 1
偏自己相関係数より p = 1

gold_diff = gold_.diff()
gold_diff.index = gold_data.index
gold_diff = gold_diff.dropna()

また、季節成分はありませんが、1回階差の季節成分を抽出し、グラフを参照します。
f:id:t_aisu_ke:20180912225747p:plain

また、移動平均を青色で表示します。
sin波とならべてみると、微妙に季節性を感じます。
f:id:t_aisu_ke:20180913125348p:plain

グラフをよく見ると3ヶ月ごとの周期を感じるので、周期s = 69を決定しました。

以上よりSARIMA(1,0,1)(1,1,1,69)を決定しました。

モデルの学習結果はこのようになります。
f:id:t_aisu_ke:20180912230131p:plain
AIC,BICともに2000超えという非常に残念な結果となっています。
一般に、AIC、BICは小さい方が良いモデルとされる傾向があります。
AIC,BICについては以下の記事に詳しく説明があります。
www.atmarkit.co.jp


一応、データをプロットしてみます。

plt.plot(gold_,color = "b")
plt.plot(gold2_,color = "b")
plt.plot(pred,color = "r")
plt.plot(pred2,color="y")

plt.xlim(["2016-05-01","2017-06-06"])
plt.ylim([4400,5200])
plt.show()

青が学習データ、赤が学習させた結果、そして黄色が予測です。
f:id:t_aisu_ke:20180912231602p:plain

あまりグラフの形は似ていませんね、改善が必要なようです。

騰落も調べてみます。

data_score = 0
for i in range(49):
  if pred[i+1] - pred[i] > 0:
    if test.values[N+1+i] - test.values[N+i] > 0:
      data_score += 1
  if pred[i+1] - pred[i] < 0:
    if test.values[N+1+i] - test.values[N+i] < 0:
      data_score += 1

print("正解率:" + str(100*data_score/50) + "%")

正解率は50%となり、ランダムウォークですね...といった結果です...

モデルの精度を上げる

標準化を行います。
標準化を行うので、データを \frac{X-μ}{σ}に従わせます。

gold_mean = np.mean(gold.values)
gold_std = np.std(gold.values)
gold_b = (gold - gold_mean)/gold_std
SARIMA_std = sm.tsa.statespace.SARIMAX(gold_b,order=(1, 0, 1),seasonal_order = (1,1,0,69), enforce_stationarity = False, enforce_invertibility = False,trend = "n").fit(trend='nc',disp=False)
print(SARIMA_std.summary())
pred_b = SARIMA_std.predict()
pred2_b = SARIMA_std.forecast(50)

モデルの学習結果です。
f:id:t_aisu_ke:20180912231407p:plain
AICは4,BICは24まで落ちました!!!
この数値は小さい方が良いので標準化が非常に効果的であることが確認できました。

グラフを表示します。
f:id:t_aisu_ke:20180912231447p:plain
赤が標準化する前の予測で、黄色が標準化したあとの予測です。
グラフの形はあまりフィットしていませんね。もう少しうまくいくといいのですが・・・
騰落の正解率は54%!!!!!!!!
少しだけ精度が上がっているようです。

ランダムウォークを実装

実際の金の価格もランダムウォークとなっています。

こちらの記事を参考にランダムウォークをSARIMAモデルで実装してみます。
stats.stackexchange.com

SARIMA(0,1,0)(1,1,1,69)としてみます。

f:id:t_aisu_ke:20180913151348p:plain

比較的フィットするグラフを描くことができました。

Introduction to ARIMA models
こちらの記事を参考にすると、ARIMA(0,1,0)はAR(1)過程に従うようです。
d.hatena.ne.jp

たしかに今回のデータも自己相関係数がこちらの記事の通りの形となっています。


番外編

 ランダムフォレストで学習を進めてみました。
今回は、4日前までの価格をもとに学習を進めています。

#4日目くらいまでのデータに意味がありそうだったので使ってみる
gold["lag1"] = gold["GOLD_TOKYO"].shift(1)
gold["lag2"] = gold["GOLD_TOKYO"].shift(2)
gold["lag3"] = gold["GOLD_TOKYO"].shift(3)
gold["lag4"] = gold["GOLD_TOKYO"].shift(4)
gold.dropna()

N = 420

X_train = np.delete(gold[['lag1', 'lag2', 'lag3']][:N].values,[0,1,2],0)
X_test = np.delete(gold[['lag1', 'lag2', 'lag3']][N:].values,[0,1,2],0)
y_train = np.delete(gold['GOLD_TOKYO'][:N].values,[0,1,2],0)                
y_test = np.delete(gold['GOLD_TOKYO'][N:].values,[0,1,2],0)

from sklearn.ensemble import RandomForestRegressor
r_forest = RandomForestRegressor(
            n_estimators=100,
            criterion='mse',
            random_state=1,
            n_jobs=-1
)
r_forest.fit(X_train, y_train)
y_train_pred = r_forest.predict(X_train)
y_test_pred = r_forest.predict(X_test)

結果は、、、
f:id:t_aisu_ke:20180912015038p:plain
赤が元データ、黄色が予測です。

めちゃくちゃフィットしてる...!!!!

...騰落の正解率は26.25%...

上下動の精度は落ちるがグラフにはフィットしています。

まとめ


ランダムウォークについてですが、こちらのブログを参考に考えてみます。
omedstu.jimdo.com

たしかに、金の価格は前日の影響を非常に大きく受けるため、AR(1)過程が最もフィットするというのも納得です。
これは金だけでなく、株やFX、仮想通貨でも同様にAR(1)過程での予測も効果的なのではないでしょうか。


ただ、実際株やFX、仮想通貨、金などの予測は単一の手法では十分な予測精度が得られないと思います。
いくつかのモデルを構築し、総合的な判断をすることが必要となります。
正解率を中心に結果を見ていますが、実際に金の投資として考えた時、長期的にどれほど上昇するかが大事になります。
つまり、ランダムフォレストのような形式で長期の見立てをつけ、SARIMAモデルなどで上昇の信ぴょう性を見ていくことがもっとも良いのではないでしょうか。


今回は手軽に行えるSARIMAモデル、ランダムフォレストを紹介しましたが、
先日のブログのようにLSTMを使用した方が良い結果が得られると思います。
前処理としてFFTでローパスフィルタをかける方法も金融工学では一般的なようです。
blog.aidemy.net
こちらのブログを参考に、是非LSTMも実装してみてください。



 

CNNで好きなアーティストを分類させてAIに良さを伝えたい

初めまして、AIdemy研修生のまえくら(@R25fgtSx)です。

皆さんは好きなアーティストもちろんいますよね!?

しかし、自分の好きなアーティストが全て同じ人に会ったことのある人はめちゃくちゃ少ないと思います.

そこで僕は何でも理解してくれる?コンピュータにその良さを伝えたいと思いました。伝えると言っても、楽曲データを学習させて分類させるだけですが...

ここで、僕の好きなアーティスト5組を紹介します(唐突)。

  1. AAA

    f:id:RyoMa:20180907193845j:plain

    https://avex.jp/aaa/aaak/
  2. UNISON SQUARE GARDEN

    f:id:RyoMa:20180907194015j:plain

    https://realsound.jp/2016/05/post-7509.html
  3. f:id:RyoMa:20180907194153j:plain

    https://www.johnnys-net.jp/page?id=artistTop&artist=10
  4. NEWS

    f:id:RyoMa:20180907194352j:plain

    https://kyun2-girls.com/archives/164
  5. SEKAI NO OWARI

    f:id:RyoMa:20180907194324j:plain

    https://abematimes.com/posts/2675644

これでいかに僕がイケメン好きか分かりましたね(苦笑)。

前置きが長くなりましたがいよいよ本題に入ります。

学習データの用意

まずはスマホの中にあるアーティスト5組の楽曲をPCに移します。今回はそれぞれに対して10曲ずつ用意しました。

f:id:RyoMa:20180907205543p:plain

f:id:RyoMa:20180907210156p:plain

 10曲では少ない(過学習を起こすため)、曲の長さが異なるので水増しと長さを揃えるために10秒ずつにデータを分割します。ここでは、音声信号処理に良く使われるlibrosaとpydubというライブラリを用いています。

import librosa
from pydub import AudioSegment

artist = ["AAA", "UNISON_SQUARE_GARDEN", "嵐", "NEWS", "SEKAI_NO_OWARI"]

for i in  range(len(artist)):
    file_index = 0
    pre_time = 0
    rea_time = 10000

    for j in range(10): 
        file_dir = "./music_data/" + artist[i] + "/raw_data/raw_" + str(j) + ".wav"
        music, fs = librosa.audio.load(file_dir)
        play_time = int(librosa.samples_to_time(len(music), fs) / 10)
        
        for k in range(play_time):
            audio_data = AudioSegment.from_file(file_dir, format="wav")
            split_audio = audio_data[pre_time:rea_time]
            pre_time += 10000
            rea_time += 10000
            
            split_audio.export("./music_data/" + artist[i] + "/split_data/" + str(file_index) + ".wav", format = "wav")
            
            file_index += 1

 次に10秒に分割した楽曲データをメル周波数スペクトルという音声認識に良く使われる特徴量をスペクトル画像として保存します。メル周波数について少し説明をします。

まずメル尺度というものがあります。

メル尺度とは音高の知覚的尺度のことで、メル尺度の差が同じであれば人間が感じる音高の差が同じになることを意味します。

メル周波数スペクトルを求めるには、大まかに以下のような流れになります。

  1. 窓関数を使って信号処理をした後に離散フーリエ変換する。
  2. メル周波数で均等にスムージングする。

詳しくはこのページを参考にしてください。

aidiary.hatenablog.com

文字だけだと分かりづらいので可視化してみます。

普通の音声データはこんな感じです。(AAAのNo Way Backという曲の冒頭10秒)

f:id:RyoMa:20180909231451p:plain

 

この信号をメル周波数スペクトルに変換すると...

f:id:RyoMa:20180909231502p:plain

 librosaを使ったコードは以下のように書きました。

split_num = [266, 273, 250, 232, 292]

for i in range(len(artist)):
    for j in range(split_num[i]):
        split_file_dir = "./music_data/" + artist[i] + "/split_data/" + str(j) + ".wav"
        export_dir = "./music_data/" + artist[i] + "/spectrum/" + str(j) + ".jpg"
        audio_data, sr = librosa.load(split_file_dir)
        S = librosa.feature.melspectrogram(audio_data, sr = sr, n_mels = 128)
        log_S = librosa.power_to_db(S)
        
        plt.figure(figsize=(8, 8))
        librosa.display.specshow(y=audio_data, sr=sr, hop_length = 2068)
        plt.savefig(export_dir)

次にOpenCVとnumpyを使って保存したスペクトルをRGB画像に変換して,学習データを作っていきます。ラベル作成も同時にやります。

from keras.utils.np_utils import to_categorical
improt cv2
import numpy as np

spe_dir = "./music_data/AAA/spectrum/"
X_train=[]
y_train=[]

for i in range(266):
    img = cv2.imread(spe_dir + str(i) + ".png")
    b,g,r = cv2.split(img)
    img = cv2.merge([r,g,b])
    X_train.append(img)
    y_train.append(0)

これを各データに対して行い,最後にX_trainをnumpyのリストにしy_trainは転置してOne-hot-表現に変換します.

X_train = np.array(X_train)
y_train = np.array(y_train)
y_train = y_train.T
y_train = to_categorical(y_train)

 これで学習データが完成しました。テストデータも今までの流れと同じようにして作成できます。

次はいよいよ学習モデルの構築です。今回はCNNで分類を行うことにしました。

理由としてはコーネル大学が発表した"Deep convolutional networks on the pitch spiral for musical instrument recognition"という論文を少し読んだからです。なので、この記事は僕の検証記事でもあるわけですね(笑)。

[1605.06644] Deep convolutional networks on the pitch spiral for musical instrument recognition

モデル作成にはkerasを使いました。以下がモデル構築のコードになっています。

from keras.layers import Activation, Conv2D, Dense, Flatten, MaxPooling2D
from keras.models import Sequential, load_model
from keras.utils.np_utils import to_categorical
from keras import optimizers
from keras.layers import Dense, Dropout, Flatten, Input, BatchNormalization
from keras.models import Model, Sequential

model = Sequential()
model.add(Conv2D(input_shape=(576, 576, 3), filters=32,kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Conv2D(filters=32, kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Conv2D(filters=32, kernel_size=(2, 2), strides=(1, 1), padding="same"))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(256))
model.add(Activation("relu"))
model.add(Dense(128))
model.add(Activation('relu'))
model.add(BatchNormalization())
model.add(Dropout(0.5))
model.add(Dense(5))
model.add(Activation('softmax'))

次はデータを学習させます。kerasに最適化のライブラリは色々用意されているのですが一番精度の良かった"Adam"にし、損失関数は"categorical_crossentropy"にしました。

ここで少し最適化について説明します。

最適化とは与えられた条件のもとで何らかの関数を最小化(もしくは最大化)することです。機械学習で言えば損失関数(ここでは交差エントロピー)は最小にしたい(過学習防止)のですが、その時の最適解を求める方法をoptimizerで指定しています。

デフォルトでは"SGD(確率的勾配降下法)"が指定されています。

詳しい数式などの説明は以下のリンクからお願いします。また、kerasで使える最適化アルゴリズム一覧(Keras Documentation)も貼っておきます。

qiita.com

最適化 - Keras Documentation

model.compile(optimizer='Adam', loss='categorical_crossentropy',metrics=['accuracy'])
model.fit(X_train, y_train, batch_size=32, epochs=10

f:id:RyoMa:20180909234342p:plain

 学習データに対しては98%くらいの精度が出ていますね。最初の方は19%をうろうろしていたので大きな進歩です。肝心のテストデータに対しては...

score = model.evaluate(X_test, y_test, verbose=1)
print()
print("Test loss:", score[0])
print("Test accuracy:", score[1])

f:id:RyoMa:20180909235911p:plain

 テストデータに対しても92%くらいになりました。

これで僕の好きなアーティストの良さが伝わったと思います。AIもイケメン好きになったかな??

これからは音楽の話はこのAIとしかしません(大嘘)。

感想として、普段は学習データ,テストデータが用意されているので前処理を行うだけですが、今回はデータセットの作成から行うことができたので苦労はしましたが自信がつきました。今後はアーティストの数を増やして分類させてみたいです。また、音声信号処理に興味が出てきたのでもう少し勉強したいと思います。

研修生の方が書いた前回の記事と組み合わせて良いサービス作れそう...

blog.aidemy.net