推理小説をグルーピングするために特徴語を抽出してみる

この記事はClassi developers Advent Calendar 2021の22日目の記事です。

私は推理小説をよく読むのですが、昔から結構読んでいるためストーリ展開、トリック、などにパターンがあると思うようになりました。 そして読んできた各小説を自分の独断と偏見によってパターン分けしました。例えばアガサ・クリスティーの小説のような王道の推理小説パターン、最近多いですが最後の最後で全てをひっくり返すような展開の大どんでん返しパターン、そして犯人視点で書かれたパターンなどです。 こうやってパターン訳すると何が嬉しいのかというと、「お勧めの推理小説は?」と聞かれた時にその人の好みを聞いてそれに近しいものを推薦しやすくなる点です。そして、これらパターン分けを人の手でなくプログラムで自動で出来ないか考えました。 ではそれをどう実現するか。考えた方法が次のような流れです。 それはまず各小説のデータを読み込み、その小説を表す特徴語を抽出します。その抽出した特徴語で近しいものをグルーピングしていくという方法を考えました。今回の記事では特徴語を抽出するところを書こうと思います。

特徴語の抽出

グルーピングをするために、まずは各推理小説の特徴を表す語句の抽出をします。今回はTF・IDFを使って各単語がその文章の中でどれほど重要な言葉であるかを数値化して表そうと思います。

TF・IDF

TFはTerm Frequencyの頭文字をとった言葉です。日本語でいうと単語出現頻度になります。意味は文章の中にどのくらい単語が出現しているかを表したものです。その単語がこの文章の中でどのくらい重要な単語なのかを示す指標の一つになります。

続いてIDFについてです。IDFはInverse Document Fequencyの頭文字を取った言葉です。日本語でいうと「逆文章頻度」となります。これは単語の出現の珍しさを表した物です。全文章の中で対象となる単語が使われている文章の数をwとし文章の数をDとした時、「log(w/D)」で表したものになります。

やってみる

では実際幾つか推理小説のTF・IDFを計算してみます。

前準備

今回小説の中の各文章からワードを抽出するために、Mecabを使おうと思います。Mecab京都大学情報学研究科と日本電信電話株式会社コミュニケーション科学基礎研究所の共同研究で開発された形態素解析器の1つです。 このMecabRubyで使用するためnattoというgemを使います。

Gemfileを以下のように書いて「bundle install」しておきます。

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.0.0'
gem 'mecab'
gem 'natto'

文章から言葉を抽出する

nattoを使って文章からワードを抽出してみます。文章は様々な品詞から成り立ちますが、今回は名詞だけに注目します。以下のサンプルのように してparseメソッドを使うと引数で与えた文章(この場合textの中身)をMecabを使って語句に分割し、そのうち名詞だけ@nounsという変数に格納しています。

require 'natto'

class WordTokenize
  def initialize
    @natto = Natto::MeCab.new
    @nouns = []
  end

  def tokenize(text)
    @words = @natto.parse(text) do |nm|
      feature = nm.feature.split(',')
      @nouns << nm.surface if feature[0] == '名詞'
    end
  end

  def nouns
    @nouns
  end
end

natto gem。便利です。 ネバネバ系のメカブ(Mecab)だから同じくネバネバ系の納豆(natto)ですね。gemの命名は面白いものが多いですね!

TFの計算

語句抽出できたら、小説全体の中で各語句がどのくらいの頻度で出現しているか計算してみます。上のサンプルコードのWordTokenizeクラスを使い、小説の各文章をtokenizeメソッドを使って語句に分解し、分解した後にnounsメソッドを使って名詞だけが格納された配列にし、countメソッドを使って気になる語句の登場回数をカウントします。そして全ての語句の中でどのくらい割合を占めるのか、計算します。

  def tf(documents, word)
    word_tokenizer = WordTokenize.new
    documents.each do |document_txt|
      word_tokenizer.tokenize(document_txt)
    end

    word_tokenizer.nouns.count(word) / word_tokenizer.nouns.count.to_f
  end

IDFの計算

IDFは上の説明で「全文章の中で対象となる語句が使われている文章の数をwとし文章の数をDとした時、「log(w/D)」で表したもの」と書いています。その通りの計算式で算出していきます。 以下のサンプルでは文章全体がdocumentsの中に入っています。このdocumentsは配列になっていて一文章が一要素になっています。そしてその一要素一要素をWordTokenizeクラスのtokenizeメソッドを使って語句に分解し、nounsメソッドで名詞の配列を取得します。その名詞の配列の中に対象となる語句が入っているか、各文章毎にチェックしていきます。そして最終的には以下のように対数を使って算出します。

 def idf(documents, word)
    df = 0
    documents.each do |document_txt|
      word_tokenizer = WordTokenize.new
      word_tokenizer.tokenize(document_txt)
      if word_tokenizer.nouns.any?(word)
        df += 1
      end
    end

    return 0 if df == 0

    Math.log(documents.size/df.to_f)
  end

実際にカウントしてみる

それでは実際幾つか小説を読み込んでTF・IDFの数値を算出して特徴語を抽出したいと思います。 今回使用するのは青空文庫推理小説の中からシャーロック・ホームズの「踊る人形」とアルセーヌ・ルパンが主人公の「奇巌城」を例として使います。

やることは、小説の中身をを読み込ませて幾つかの言葉の特徴値を出していきたいと思います。 まずは「踊る人形」からです。この話はシャーロック・ホームズの話なので、「ホームズ」「シャーロック」「ワトソン」でみてみます。すると以下のような結果になりました。

========== シャーロックホームズ: 踊る人形 ==========-
"====================="
"word: ホームズ"
"tf: 0.021139468935292602"
"idf: 1.776723303403941"
"ホームズの特徴値: 0.03755898707891806"
"====================="
"word: シャーロック"
"tf: 0.0015467904098994587"
"idf: 5.279223643933125"
"シャーロックの特徴値: 0.008165852504150231"
"====================="
"word: ワトソン"
"tf: 0.003351379221448827"
"idf: 4.163746426513188"
"ワトソンの特徴値: 0.013954293257198104"

やはり「ホームズ」という言葉が他の二つよりも特徴語として高い数値を示しています。続いて「人形」という言葉と「暗号」という言葉をみてみます。この「踊る人形」という話は人形を表したような文字が実は暗号を表していたということなのですが、実際数値上はどうなのか出してみます。

"====================="
"word: 暗号"
"tf: 0.0015467904098994587"
"idf: 5.542258049766918"
"暗号の特徴値: 0.008572711600567546"
"====================="
"word: 人形"
"tf: 0.0041247744263985565"
"idf: 3.957295549045762"
"人形の特徴値: 0.016322951478404794"

「人形」については物語の軸となるワードなので、「ワトソン」と同じくらいの数値を出しました。一方「暗号」については謎を解いてから判明する事実なので、特徴語としてはそれほど高く出ませんでした。

次にもう一つの方、「奇巌城(一部抜粋版)」をみてみたいと思います。

========== アルセーヌ・ルパン: 奇巌城 ==========-
"====================="
"word: ルパン"
"tf: 0.011904761904761904"
"idf: 3.538623786054287"
"ルパンの特徴値: 0.042126473643503415"
"====================="
"word: スパルミエント"
"tf: 0.010582010582010581"
"idf: 3.5892498591242545"
"スパルミエントの特徴値: 0.03798147999073285"
"====================="
"word: ガニマール"
"tf: 0.010582010582010581"
"idf: 3.5892498591242545"
"ガニマールの特徴値: 0.03798147999073285"
"====================="
"word: 強盗"
"tf: 0.0006613756613756613"
"idf: 7.396604781181859"
"強盗の特徴値: 0.00489193437908853"
"====================="
"word: 怪盗"
"tf: 0.0"
"idf: 0"
"怪盗の特徴値: 0.0"

登場人物でそれぞれTF・IDFの計算をしてみました。加えて「強盗」と「怪盗」という言葉についても算出してみました。 やはり主人公である「ルパン」という言葉が他の言葉に比べて数値が高いですね。また「怪盗」という言葉はなく、「強盗」の数値が僅かにありました。抜粋版だからかもしれませんが、「怪盗ルパン」の「怪盗」は0で「強盗」の方が数値が出ているのが個人的には面白かったです。機会があれば、一部抜粋でなく全部のデータで見てみたいです。

まとめ

今回は特徴語を導き出すためTF・IDFの計算処理を作成し、実際に小説の内容を幾つかパースして数値の算出を行いました。この後計画していることは、各小説の特徴語、TF・IDFの値を使って各小説のグルーピングをしていきたいと思います。

明日のClassi developers Advent Calendar 2021はホアンミンクアンさんです。