catch-img

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

今回は小説をベクトルに変換して、自分好みの小説を見つけたいと思います。 [キーワード] 自然言語処理, データスクレイピング, 形態素解析, Doc2Vec, 階層的クラスタリング, 類似文書の検索

目次[非表示]

  1. 1.はじめに
    1. 1.1.背景
    2. 1.2.手法
  2. 2.環境構築
  3. 3.学習データの準備
    1. 3.1.データの取得
    2. 3.2.取得した小説について
    3. 3.3.データの前処理
  4. 4.Doc2Vec
    1. 4.1.Doc2Vecについての概要
    2. 4.2.Doc2Vecを使ってみる
  5. 5.クラスタリング
    1. 5.1.階層的クラスタリングについて
    2. 5.2.クラスタリングの実行
  6. 6.クラスタの分析・評価
    1. 6.1.クラスタのジャンル推定
    2. 6.2.ジャンル推定手法の改良
    3. 6.3.観測によるジャンルの推定
  7. 7.類似小説の検索
    1. 7.1.検索機能の実装
    2. 7.2.似ている小説を探してみる
    3. 7.3.未知の小説に適用してみる
  8. 8.まとめ
    1. 8.1.結果について
    2. 8.2.考察・今後の展望

はじめに

背景

突然ですがみなさん、小説を無料で投稿できるサイトをご存知でしょうか? 小説家になろうやカクヨムなどが有名ですね。 僕は(ここ数年ご無沙汰してますが、) 小説家になろうで小説を漁るのが好きです。

最近はこういうサイトからプロになる人が多いみたいで、 アマチュア小説家や出版社にとって機会獲得の場として重要視されているようです。 僕たち消費者にもありがたいサービスなのですが、不満もあります。 それは、面白い小説を見つけるのが難しいことです。 というのも、

  • ランキングがあてにならない。
    • (個人的に)面白いと思った小説は他の読者に評価されていない。
  • 検索機能は充実しているが、うまく機能しない。
    • ジャンル検索に関して、もう少し詳しく指定したい。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のライブラリを用いることで容易に実行することができます。Qlita[gensim]Doc2Vecの使い方
こちらのサイトを参考に(というかそのまま使って)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")


Embedding Projector
上記サイトでは埋め込みベクトルを主成分分析して可視化してくれます。 学習の結果得られた文書ベクトルの分布がどうなっているか見てみましょう。 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は意味の演算が行える。(王ー男+女=女王)
    • ジャンル(例えば「異世界」や「恋愛」)の方向ベクトルが見つけられれば、「こんな感じの雰囲気で異世界要素を抜いた小説が読みたいなぁ」みたいなニーズに答えられるかもしれない。

以上です。 面白い結果が得られて楽しかったです。 
AI、機械学習それからディープラーニングの経験はないけど、「AIをどのように活用するのだろう?」や「Aidemy Premium Planを受講して、私でもきちんと続けられるだろうか?」など、少しでも気になることがございましたら、ぜひ一度気軽に無料相談会にお越しいただき、お悩みをお聞かせください!

最後までご覧くださりありがとうございました。