2019年2月17日日曜日

コサイン類似度を学ぶ

概要

前回、自分のツイートの TFIDF を算出してツイート内から特徴語を算出してみました
今回はさらに算出した TFIDF を使って各ツイートの類似度をコサイン類似度をクラスタリングしてみたいと思います

環境

  • macOS 10.14.3
  • Ruby 2.5.1p57

コサイン類似度を算出するスクリプト

前回、算出した各ツイートの単語ごとの TFIDF の結果を読み込んで算出します

  • bundle init
  • vim Gemfile
gem "natto"
  • bundle install --path vendor
  • vim cosine_similarity.rb
require 'json'

class CosSim
  attr_accessor :all_tweet_count

  def initialize
    file = 'results.json'
    @data = JSON.parse(File.read(file))
    @all_tweet_count = @data.size
    @results = []
  end

  def calc(index: 0)
    @source = @data['results'][index]
    @data['results'].each_with_index { |target, i|
      r = Result.new(target['tweet'], cos(target))
      @results.push(r)
    }
  end

  def clear
    @results.clear
  end

  def show_result(top = 10)
    ret = {}
    ret.store(:source, @source['tweet'])
    rr = []
    sorted = @results.sort { |a, b| b.cos <=> a.cos }.first(top)[1..-1]
    sorted.each { |result|
      r = {}
      r.store(:target, result.tweet)
      r.store(:cos, result.cos)
      rr.push(r)
    }
    ret.store(:result, rr)
    puts ret.to_json
  end

  def cos(target)
    term_dimensions = []
    @source['terms'].each { |t|
      term_dimensions.push(t['term']) unless term_dimensions.include?(t['term'])
    }
    target['terms'].each { |t|
      term_dimensions.push(t['term']) unless term_dimensions.include?(t['term'])
    }
    source_tfidf_map = []
    target_tfidf_map = []
    term_dimensions.each { |dim|
      sterm = @source['terms'].select { |t| t['term'] == dim }.first
      tterm = target['terms'].select { |t| t['term'] == dim }.first
      if sterm.nil?
        source_tfidf_map.push(0.0)
      else
        source_tfidf_map.push(sterm['tfidf'].to_f)
      end
      if tterm.nil?
        target_tfidf_map.push(0.0)
      else
        target_tfidf_map.push(tterm['tfidf'].to_f)
      end
    }
    a = Math.sqrt(source_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf })
    b = Math.sqrt(target_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf })
    ab = (target_tfidf_map.zip(source_tfidf_map).map { |t, s| t * s }).sum
    cos = ab / (a * b)
  end

  class Result
    attr_accessor :tweet, :cos

    def initialize(tweet, cos)
      @tweet = tweet
      @cos = cos
    end
  end
end

cs = CosSim.new
10.times do
  i = Random.rand(0..10015)
  cs.calc(index: i)
  cs.show_result(4)
  cs.clear
end

少し解説

そもそもコサイン類似度は「ある文書 (ここではツイート) とある文書が似ている文書なのか」を算出することができる計算方法です
ツイートは全部で 10,000 ほどあるので単純にすべてのツイートに対してコサイン類似度を出そうとすると 10,000 * 10,000 = 1億回 の処理が必要になります
時間があるのであれば放置しておけばいいですが、そうもいきません
上記のスクリプトは cs.calc(index: 100) で対象のツイートのインデックスを指定することできるのであるツイートに対して類似するツイートを 10,000 件の中から探すようになっています
もし全件やりたい場合は all_tweet_count 分メイン処理でループさせれば OK です

処理の流れとしてまず類似度を比較する文書 2 つの中に出現する単語のベクトルを作成します
ベクトルの大きさは TFIDF を採用します
イメージ的には以下の通りです

文書 ギガ 使用
ツイート1 0.38 0 0.18 0.13
ツイート2 0.22 0 0.01 0

そのためにまず文書間で出現する単語のユーニク (term_dimensions) を算出します
そしてその単語ごとに前回の結果から TFIDF を算出し source_tfidf_maptarget_tfidf_map に追加します
単語が出現しない場合は 0.0 を追加します

あとは作成したそれぞれの map からコサイン類似度の計算に必要な値を算出します
Math.sqrt(source_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf }) は各単語の tfidf を二乗して、それらの sum を計算しています
更に計算された合計に対して平方根をとります
それを同様に target_tfidf_map でも行います

(target_tfidf_map.zip(source_tfidf_map).map { |t, s| t * s }).sum はそれぞれのマップの各要素の tfidf の積を計算しそれを sum しています

あとはこれらの値からコサイン類似度を求めます (cos = ab / (a * b))
ここで算出された値は -1 から 1 の間を取ります
1 に近ければ近いほど類似度が高くなります

結果を少し見てみる

先程も述べたようにすべてのツイートの類似度を見るのは時間がかかるので適当にサンプリングして見てみました

例えば以下の結果は「元気」というワードがどのツイートにも出てきており「似ていると言えば似ているな、、、」という感じなのがわかります
cos の数値を見ても 0.36 くらいなので 1 には遠いと考えるとニュアンス的にはそんな感じなのかなと思います

{
  "source": "大学生、、、元気だなw",
  "result": [
    {
      "target": "arduino ってまだまだ元気なのか、、なつかしい、、",
      "cos": 0.36860955705104625
    },
    {
      "target": "フィッツすっかり元気になっているじゃないか",
      "cos": 0.32779605613840673
    },
    {
      "target": "エシャロットって体に良いとかあるのかな。なんか食べた翌日は元気な気がする。たまたまかなw",
      "cos": 0.2422238044523076
    }
  ]
}

cos が 0.5 を超える類似度だと以下の通りです
確かに似ているような気がします、、、

{
  "source": "storyboard 落ちるわー、、、",
  "result": [
    {
      "target": "気持ち落ちるでぇ、、、",
      "cos": 0.5008608639512765
    },
    {
      "target": "なんか、storyboard もめっちゃ変わってるな",
      "cos": 0.40529191248135504
    },
    {
      "target": "立ち上がりはいつも落ちるなー。",
      "cos": 0.39170065104194474
    }
  ]
}

今回は 10 個しかサンプルリングしなかったので何とも言えませんが cos が 0.75 以上くらいで絞り込めばかなり似ているツイートを探せる印象です
ただ感じ的にはほとんどが 0.1 から 0.2 くらいだったので高スコアなペアを探せるのは結構レアなケースかなと思います

最後に

各ツイートごとに TFIDF を算出したのでコサイン類似度を使ってツイートのクラスタリングも行ってみました

この方法でも確かに似ているツイートは探せると思います
ただ今回の手法ではあくまでも「似ている」ツイートを探せているだけであり意味やコンテキストが似ているかどうかまでは判断できていません

例えばサンプリングの結果が見せた「storyboard 落ちるわー、、、」と「気持ち落ちるでぇ、、、」は確かにツイート的には似ていますがコンテキストとしては前者が Xcode に関するツイートで後者は体調や気持ちに関するツイートです
なので似てはいますが内容は全く違います

更に類似しているツイートを探したいのであれば今回クラスタリングした結果から更に TFIDF -> コサイン類似度を算出すれば良いと思います
TFIDF を算出した元データ (文書集合) がツイート全体だったので、文書集合をクラスタリング単位にすれば結果も変わってくると思います

そもそも Twitter 自体全く同じツイートはできないのはなかなか難しいですが、おもしろいアプローチかなと思います (定石かもしれませんが)

参考サイト

0 件のコメント:

コメントを投稿