IDEA and Players

ベンチャー企業で働く変なエンジニアが勝手なことを書きまくるブログ

残念なベイジアンフィルタと感情モデルと"人間讃歌"の素晴らしさについて

失敗談というか、残念な話を世間から集めたいと思い、方法を模索していた今日この頃。

  1. 身の回りの人に聞いて回る。
  2. WishScopeなどの既存のサービスを活用して募集する。
  3. 絨毯爆撃的にテレアポして聞き回る。
  4. どっか別のサイトからパク借りてくる。

といくつか案を練ってみたのですが、1などは集められる数に限りがあるし、2もやってはみたものの、そして予想以上に収穫はあるものの、それでも絶対量が足りていないと感じている次第です。
3などは都内某区のハローページを入手したものの、何の脈絡もないのにテレアポしても成果が上がるとは思えません。そして、他人様に迷惑をかけることへの良心の呵責と浴びるであろう罵詈雑言による精神的ダメージへの不安のためボツ。

で、残る4についてです。
当初は、2ちゃんねるTwitterから良さげな投稿を持って来れないかなー、とも思ったわけですが、そもそもそれって著作権の侵害に当たるじゃない、とツッコミをいただき(いや、最初から気づけって話ですが)、ちょっとその辺りについて調べてみたのですよ。

これを見る限り、2ちゃんねるからの転載は完全にNGですよね。やっているところは多数あるようですが、まあ、まとめ系サイトをもう一個作るようなことをしても仕方がないし、それは本来の目的とも違うので除外。

Twitterの方はと言うと、こちらの記事によればツイートの投稿内容は本人に帰属するものの、「しかし、他ユーザーが、コピー、複製、処理、改変、修正、公表、送信することを無償で許可したものとします」とあるので、各ユーザさんに対し節度と誠実を心がければ、まあ大丈夫だろうと思ったわけですよ。

とりあえずは、専用のアカウントを用意して、残念話に類するツイートを日々Retweetして回るだけでもいいかなー、と考えたのですが、では大量のツイートの中からどうやって失敗談を見つけるか、と来ればそれはもうベイジアンフィルタだろう、と、まあプログラマという獣が世界の中心で叫んだのですよ。ベイジアンフィルタ、つまりスパムメールを振り分けるために使われるプログラムのことです。

かじった程度の知識しかないんですが、じつは前々から書いてみたかったのですよ。ベイジアンフィルタ
というわけで実際にやってみたのが下記。

  • Twitterから日本語のツイートだけを集めてきてDBに保存。

TwitterAPIを使えば楽々とできますね。 

  • ツイートを失敗談とそうでないものを振り分ける。

この振り分けたツイート群がそのまま学習データとなるので頑張り所。
ここは手作業だし、大量にこなす必要なのでけっこう大変。
なので、専用のWeb画面を作ってチャキチャキとこなしました。
※今思うとCIにしても良かったな・・・。

ちなみに今のところ、3000件以上は振り分けましたが、心が折れかけた・・・。

  • 振り分けたツイートを形態素解析して、単語ごとにどちらに振り分けられたかの回数を記録する。

形態素解析MeCabを使いました。いつもお世話になってます。

  • 学習データを元に、残りのツイートに対して失敗談である確率を計算してみる。

お手軽ナイーブベイズで。ナイーブベイズの実装についてはいくつも記事があるので、言及しません。
とりあえず、ゼロ頻度問題を回避するためのスムージングをどうするかがポイントかな、と。

一番手っ取り早いのが「加算法」ですが、精度が悪くなるし、この記事「単純グッド・チューリング推定法 (Simple Good-Turing Estimation) とは何ぞや?」を読んだら「単純グッド・チューリング推定法」を使ってみたくなったので、私はこちらで。
記事で説明されている数式は読んだけど、一部がよくわかっていない。数学、もっとやっときゃよかった・・・orz
ともあれ、上記の記事に掲載されているR言語Rubyに書き換えて使いました。一部、R言語っぽい処理をするためにStatSampleというGemを使ってます。

# encoding: utf-8
# See: http://d.hatena.ne.jp/Fivestar/20080702/1214965996
module Bayesian
  module Discounting 
    class Base
      attr_reader :r, :nr, :n
      
      def initialize(r, nr)
        @r  = as_float_scale(r)
        @nr = as_float_scale(nr)
        @n  = (@r * @nr).sum
      end
      
      def discount_frequency(numerator,denominator)
        [numerator.to_f,denominator.to_f]
      end
    
      private
      
      def as_float_scale(v)
        case v
        when Array, Statsample::Vector
          v.map{|v1| v1.to_f }.to_scale
        else
          raise
        end
      end
    end

    class SimpleTuring < Base
      
      def discount_frequency(numerator, denominator)
        [map_of_term_frequencies[numerator.to_f],denominator.to_f]
      end
      
      def term_frequencies
        @tf ||= begin
          dcr  = renormalized_discount_coefficients
          r * dcr
        end
      end
      alias :tf :term_frequencies
      
      protected
    
      def map_of_term_frequencies
        @map_of_term_frequencies ||= begin
          map = {}
          map[0.0] = nr[0] / n
          r.to_a.each_with_index do |r1,i|
            map[r1] = tf[i]
          end
          map
        end
      end
      
      def renormalized_discount_coefficients
        dc    = switched_turing_estimate
        nr0   = nr[0]
        
        # summation of probabilities
        sump  = (dc * r * nr).sum / n
        dc.map{|dc1|
          (1 - nr0 / n) * dc1 / sump
        }.to_scale
      end
      
      def switched_turing_estimate
        dct   = turing_estimate
        dc    = dct
        dclgt = liner_good_turing_estimate
        rsd   = standard_deviation_of_term_frequencies
        for i in (0..r.size-1)
          dct1    = dct[i]
          dclgt1  = dclgt[i]
          r1      = r[i]
          rsd1    = rsd[i]
          v       = ((dct1 - dclgt1).abs * r1 / rsd1)
          if v <= 1.65
            dc1 = dct.to_a.slice(0,i)
            dc2 = dclgt.to_a.slice(i..-1)
            dc = (dc1 + dc2).to_scale
            break
          end
        end
        dc
      end
      
      def standard_deviation_of_term_frequencies
        rsd = []
        seqs = (2..nr.size+1).to_a
        nr.each_with_index do |nr1,i|
          nr2 = nr[i+1]
          break unless nr2
          seq = seqs[i]
          rsd << seq / nr1 * Math::sqrt( nr2 * (1+nr2/nr1))
        end
        rsd << 1
        rsd
      end
      
      def liner_good_turing_estimate
        @liner_good_turing_estimator ||= begin
          rc  = corrected_term_frequency
          lgt = rc._vector_ari('/', r)
          lgt
        end
      end
    
      def turing_estimate
        @turing_estimator ||= begin
          nrb = [ nr.to_a[1..-1], 0 ].flatten.to_scale
          t1  = (r+1)._vector_ari('/', r)
          t2  = nrb._vector_ari('/', nr)
          ret = t1 * t2
          ret
        end
      end
      
      def diff_each_frequencies
        @diff_each_frequencies ||= begin
          d = []
          r.each_cons(2) do |v1,v2|
            d << v2 - v1
          end
          d
        end
      end
      
      def corrected_diff_of_frequencies
        d = diff_each_frequencies
        if d.size > 2
          t = d[1..-1]
          q = d[0..-2]
          da = [t,q].transpose.map do |ti,qi|
            (ti + qi) * 0.5
          end
          [1, da, d.last].flatten
        else
          [1, d].flatten
        end
      end
      
      def zipf_of_frequencies
        df = corrected_diff_of_frequencies
        ret = nr._vector_ari("/", df )
        ret
      end
      
      def regression_coefficient_of_frequency
        zr      = zipf_of_frequencies
        log_r   = r.map{|v| Math::log(v) }.to_scale
        log_zr  = zr.map{|v| Math::log(v) }.to_scale
     
        ds      = {'x'=>log_r, 'y'=>log_zr}.to_dataset
        mlr     = Statsample::Regression.multiple(ds,'y')
        ret     = mlr.coeffs['x']
        ret
      end
    
      def corrected_term_frequency
        coef_x = regression_coefficient_of_frequency
        ret = r.map{|v|
          v * ((1 + 1/v) ** (1 + coef_x))
        }.to_scale
        ret
      end
    end
  end
end

R言語、今回はじめて触ったんですが、さすが統計解析用らしく配列処理がクールですね。
Rubyだと、eachやらmapやらとループ処理が多くなりがち。

まあ、ともあれこのような手順を踏んでツイートを自動抽出してみようとしたんですが、どうもイマイチよくない。
なぜか?

  • 「吐きそうやし」とか「眠い・・・」とか端的なツイートが引っかかる。

短いツイートの方が必然的に分類しやすいので、こういったツイートが多くひっかかるんですよね。
あと単語の繰り返し系とかもそう。

まあ、これは単語とは別に語彙数のカウントを取っておいて、語彙数でも確率を計算する、ということで回避しました。

  • ネガティブなツイートが引っかかる。

あと、当然と言えば当然ですが、あまりにネガティブなツイートが引っかかってしまうんですよね。
・・・というか、Twitter、死にたいヤツが多すぎるだろ・・・!(笑)

誰かに養われながら少しずつ衰えて孤独死するなら、少しでもマシな状態で若くて、友達に悲しまれる間に死にたい。

とかね、あまりに後ろ向きなツイートを集めても、面白くも何ともないのですよ。
とは言え、同じ「死にたい」ネタでも中にはこんな秀逸なやつもあるのでなかなか侮れない・・・。

母親「すーぱーそに子の添い寝シーツが届いたよ。ダンボール開けたよ」
19歳男性「死にたい」

ちなみに理想とする残念ツイートは下記のようなヤツ。

旦那が橋で丸いきれいな石拾ってじっと見つめてた。
しばらくして携帯を出して時間確認すると携帯を川に投げ捨てて 石をポケットにしまった後に膝から崩れ落ちてて思わず笑った

今日日本橋行ってたまたまBLコーナーあったから友達に「ちょっと聖闘士星矢Ωあったら教えてね」って言ったら「こんなところに男と居ったら腐女子に妄想されそうで怖いからいやや」って言われたけどぶっちゃけ俺みたいなブサイクとお前みたいなフツメンで掛け算するぐらいなら床と天井でやるわと

舅さんが、引きこもり、と言う言葉をなかなか覚えられないらしく、メールの度に「また閉じこもりか?」とか「立てこもってないで〜」とか書いて来る。
色々勘違いしているようだが概ね正しいので放ってある。

あなたたちの命の輝きに、ぼくは思わず胸が熱くなりますよ。
いや、マジで。

さて、この問題はどうやって解決すべきかなー、アレコレ考えた挙げ句に思いついたのが感情モデルを導入することでした。
感情モデルとは、人間の感情をわかりやすく表現した体系のことで、心理学の用語。
自分が調べた感情モデルの中でも特に魅了されたのは、Plutchikの感情モデルでした。
これは感情=色と見立てた色相環のようなもので、その発想といい、図形といい、じつに美しい。

Plutchikの感情モデルでは、

[喜び(joy) - 悲しみ(sadness)]
[受容(trust) - 嫌悪(disgust)]
[恐れ(fear) - 怒り(anger)]
[驚き(surprise) - 期待(anticipation)]

という対となる感情の組み合わせと、それぞれの感情の強度で人間の心を理解しようとしています。
これをツイートの判定に適用できないか、と。

参考URL: http://ja.wikipedia.org/wiki/%E6%84%9F%E6%83%85%E3%81%AE%E4%B8%80%E8%A6%A7

で、ぱっと思いつくやり方は次の通り。

・まず、サンプルのツイートを上記の感情軸それぞれに対してどちらに分類されるかを判定する。
 => これが感情モデルの学習データになる。例によって手作業。ぐったり。

・このツイートを形態素解析して、単語ごとに分類回数を計上すると、単語ごとに感情の重み付けができる。
 => 感情辞書の作成。

・感情辞書に従って残念ツイートの学習データの感情分布を計測する。

で、語彙数、単語の種類、感情の分布によってツイートの合否判定を行う、と。

でもまあ、この程度のことならすでにダレかが研究しているんじゃないかと思ったところ、やはりちょっと調べたらゴロゴロとそんな事例があるんですよ。
たとえばこちらのようにすでに感情辞書を作って公開している人もいたりするくらい。

プロの研究者が思いっきり専門的で高度なことをやっているのを見て、そんな領域まで足を突っ込むべきかと正直、二の足を踏みました。
つまり、この方向性で精度を出そうとすれば、かなり専門知識や時間のコストがかかりそうだ、という予感ですね。で、どうしようかなー、と。

あと、もうひとつ。
自分の中でもしっかりと定義が固まっていなかったんですが、自分が抽出したいツイートって、ネガティブだけどポジティブ、といった矛盾をはらんだものだと気がついたわけです。つまり、ある一つだけの感情に寄ってない。

今日の英語の勉強全くしてないんだよね(´・ω・`)
うちくらいだろうなぁ(´・ω・`)
まぁ、最初っから1番下って決まってるし、そこで頑張るのみ!!!!!(`_´)

上記のようなツイートってネガティブ=>ポジティブという心の動きがあって、だからこそ読む側からすると好感が持てるんだけど、こういったツイートを、単語の出現頻度ではもちろん、それが感情の出現頻度に置き換わったとしてもたぶん捉えきれないよね、と今更ながらに気がついたわけです。
人間ってそんなに単純じゃないよね、もっと複雑で、でもそれが自然なことなんだ、と。

ま、当たり前の話なんですが、プログラムに没頭しているとその作業が楽しくて、たまに忘れてしまうんですよね。世の中のごくフツーのことがプログラムでは一番むずかしい、という事実に。
投稿している人の年代も境遇も様々で、かつ「残念だけど面白い」といういかにも人間的なツイートを抽出っのはちょっと無理難題すぎた。反省。
ま、玉石混交でよければ、ざっくりとしたものは作れはしますけどね。

考えてみると、スパムメールとか

『ネットビジネス初心者が何をやるべきなのか!?』これを見れば初心者だって確実に稼げます!

とか

無料コンテンツ盛りだくさんの恋愛出会いコミュニテイ【〇〇〇〇】で恋をはじめましょうよ♪ 安全だから安心して出会える!

というようなツイートを見ると、どこか昆虫的なんですよ。
定型的で、感情の動きが少ない。だからこそプログラムでわりと簡単に識別できるんですが、人間が感じる面白さっていうのは、そのパターンが崩れたときが現れるんですよね。

つまりは・・・

人間讃歌は「勇気」の讃歌ッ!!
人間のすばらしさは勇気のすばらしさ!!
いくら強くてもこいつらスパムは「勇気」を知らん!
ノミと同類よォーーーーーッ!!

──────────『ジョジョの奇妙な冒険』より

ってことですね。
ちょっと遠回りだったけども、そういう気づきが得られたことで良しとしますか。