2019年2月16日土曜日

tfidf を学ぶ

概要

前回、自分のツイートデータを分析してみました
その際に単語の出現回数はカウントしましたが各ツイートに含まれる単語の tfidf は計算しませんでした
せっかくなので各ツイートの tfidf を算出して結果を考察してみました

環境

  • macOS 10.14.3
  • Ruby 2.5.1p57

tfidf を算出するスクリプト

とりあえずコードにしたので紹介します
勉強がてら作成してので参考程度に見てください

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

class TFIDF
  attr_accessor :tweets

  def initialize
    file = 'data/tweet.js'
    @data = JSON.parse(File.read(file).sub('window.YTD.tweet.part0 = ', ''))
    @all_tweet_count = @data.size
    @tweets = []
  end

  def exec
    @data.each_with_index { |d, i|
      tweet = Tweet.new(d['full_text'])
      tweet.analyze
      add(tweet)
    }
    calc
  end

  def debug(all = false)
    ret = []
    @tweets.each { |tweet|
      r = {}
      rr = []
      if all
        tweet.terms.each { |term|
          rr.push(term.to_h)
        }
      else
        max = tweet.max_tfidf
        max.each { |term|
          rr.push(term.to_h)
        }
      end
      r.store(:tweet, tweet.tweet)
      r.store(:terms, rr)
      ret.push(r)
    }
    puts({ :results => ret }.to_json)
  end

  private
  def add(tweet)
    @tweets.push(tweet)
  end

  def calc
    @tweets.each { |t1|
      t1.terms.each { |term|
        c = 0
        @tweets.each { |t2|
          c += 1 if t2.tweet.include?(term.term)
        }
        term.df = c
        term.idf = Math.log(@all_tweet_count / c)
        term.tfidf = term.tf * term.idf
      }
    }
  end

  class Tweet
    attr_accessor :tweet, :terms

    def initialize(tweet)
      @tweet = tweet
      @terms = []
      @mecab = Natto::MeCab.new("-F%f[0]%f[1]")
    end

    def analyze
      @mecab.parse(@tweet) do |n|
        term = get(n.surface)
        if term.nil?
          term = Term.new(n.surface)
          add(term)
        else
          term.inc
        end
      end
      @terms.each { |t|
        t.tf = t.count / count_all_terms.to_f
      }
    end

    def max_tfidf
      max = @terms.max { |a, b| a.tfidf <=> b.tfidf }
      @terms.select { |t| t.tfidf == max.tfidf }
    end

    private
    def add(term)
      @terms.push(term)
    end

    def count_all_terms
      @terms.sum { |t| t.count }
    end

    def get(surface)
      @terms.select { |t| t.term == surface }.first
    end
  end

  class Term
    attr_accessor :term, :count, :tf, :df, :idf, :tfidf

    def initialize(term)
      @term = term
      @count = 1
      @tf = 0.0
      @df = 0
      @idf = 0.0
      @tfidf = 0.0
    end

    def inc
      @count += 1
    end

    def to_h
      {
        :term => @term,
        :count => @count,
        :tf => @tf,
        :df => @df,
        :idf => @idf,
        :tfidf => @tfidf
      }
    end
  end
end

tfidf = TFIDF.new
tfidf.exec
tfidf.debug

標準出力に JSON を出力するのでリダイレクトでファイルに書き込みましょう

  • bundle exec ruby tfidf.rb > results.json

今回は名詞だけに絞らず、すべての品詞を対象にしています
また解析対象のツイートは前回同様 10016 ツイートにします
つまり 10016 文書あるのと同じです

tfidf の値は特に正規化していません

結果は JSON で出力してくれます
すべての単語に対する tfidf を出力することもできますし文書内で最も tfidf が高かった単語だけを出力することもできます

tf, df, idf の計算式は定義通りです

  • tf・・・対象の単語 / 文書内の単語
  • df・・・対象の単語が全ツイートを対象に何ツイート出現したか
  • idf・・・Math.log(全ツイート数 / df)
  • tfidf・・・tf * idf

たぶん実装もあっていると思います

処理の流れ

まず各ツイートを対象に形態素解析を掛け単語を抽出します
各ツイート内で抽出した単語の出現回数をカウントします
抽出した単語とカウントは Term クラスの配列に追加していきます
出現回数と配列に追加した全単語を元に tf を計算します
これを全ツイート先に繰り返します

すべてツイートの単語の tf を算出できたら次はその単語が 1 回でも出現している文書 (ツイート) 数をカウントします (df)
df がカウントできたら idf を計算し、そのまま tfidf も計算します
計算結果は各単語を管理する Term クラスのフィールドとして管理します

あとは結果を JSON で出力して終了です

結果を見てみた

10,000 ツイート以上あるのですべてを見ていませんが適当にサンプリングして特徴語ってぽくなっているか見てみました

例えば以下のツイートは特徴語がちゃんと取得できているなと思いました

{
  "tweet": "Swift4 へのマイグレーションはできたけど Swift4 が勉強できたわけではない",
  "terms": [
    {
      "term": "Swift",
      "count": 2,
      "tf": 0.1,
      "df": 66,
      "idf": 5.017279836814924,
      "tfidf": 0.5017279836814924
    }
  ]
}

見るからに Swift に関数するツイートなので特徴語もバッチリだと思います
逆に特徴語が微妙な感じだったのは以下のようなツイートです

{
  "tweet": "lambda 使いになるには如何に layers を駆使できるかって感じがするなー。",
  "terms": [
    {
      "term": "駆使",
      "count": 1,
      "tf": 0.05,
      "df": 2,
      "idf": 8.518791912779934,
      "tfidf": 0.4259395956389967
    }
  ]
}

ツイートの雰囲気的には「lambda」とか「layers」が特徴語になってほしいところですが「駆使」になってしまいました
これは lambda や layers が他のツイートにも頻出しているせいで特徴語としての価値が下がっているせいで「駆使」が特徴語になっています

今回の場合、1 ツイートを文書として定義しました
Twitter の場合、連続して同じようなことをつぶやくケースが多く、そうなると単語の価値が下がる傾向があることがわかりました

なので 1 ツイート = 文書として扱うのではなく例えば 1 週間分のツイートを 1 文書として扱うことで 1 週間分のツイートでの特徴語を算出するようにすると良いのかなと思いました

あまり変な結果にはならなかった

全体をざっくり見ても変な結果になったツイートはありませんでした
例えば自分の場合「、、、」などツイートに多く含めるのですが「、、、」が特徴語になるようなケースは 1 度しか出ませんでした

他におかしそうなのはかぎかっこの記号が特徴語になるツイートもありましたがツイートを見ると確かにかぎかっこを多用していたので、それは正常かなと思いました

最後に

tfidf を使って自分のツイートの特徴語を抽出してみました
自然言語処理の分野になると思いますが、かなり基本的な手法になるので覚えておいて損はないかなと思います
またここから派生する手法としてクラスタリング手法であるコサイン類似度を使って同じような内容のツイートを発見したり、分類手法としてナイブベイズにも応用できると思います

自分もやってみて感じましたがやはり自分の手で動かして計算してみることで理解もより深まると思います

参考サイト

0 件のコメント:

コメントを投稿