読者です 読者をやめる 読者になる 読者になる

Re: マルコフ連鎖で日本語をもっともらしく要約する

ruby

以前から気になっていたマルコフ連鎖をザリガニさんの記事を元に試してみました。

マルコフ連鎖で日本語をもっともらしく要約する - ザリガニが見ていた...。

マルコフ連鎖とは

マルコフ連鎖を説明してみる。 | 分析のおはなし。
個人的にはこちらの図と確率の話で理解しました。
wikipediaは分かりにくいです。
 

マルコフ連鎖分かち書きで文章を要約っぽくする

(ザリガニさんの記事に書いてありますが)
分かち書きしたデータを元にマルコフ連鎖を適用し文章を生成していきます。
ここで複数候補があった場合は、乱数を用いて選択していくことで
要約っぽいことが出来てしまいます。
結構自然な文章になるんですよね。

補足:マルコフ連鎖のロジック

ザリガニさんの記事内のマルコフ連鎖の記事はリンクが切れていましたが
ググってみると以下のtumblrにURLが変更されているようです。
こちらの記事はとてもわかりやすかったです。
netbookerの貯蔵庫 — 人工無能を作ろう~マルコフ連鎖(2接頭語と1接尾語の場合) ...

書いてみた

ザリガニさんの記事のコードは、今は動かないのでちょっと改良して手元で試してみました。

require 'open-uri'

# require 'mecab'
require 'natto'
require 'nokogiri'

module Asahicom
  TOP_URL = 'http://www.asahi.com/'

  class MarkovChain
    MIN_TEXT_SIZE = 80
    MAX_TEXT_SIZE = 180

    def summarize_headline
      url = scrape_headline_top_url
      sleep 1 # 念のため1s待機
      text = scrape_article_body(url)
      data = generate_mecab_tagger(text)

      result = ''
      loop do
        result = summarize(data)
        text_size = result.size
        break if text_size >= MIN_TEXT_SIZE && text_size <= MAX_TEXT_SIZE
      end
      puts result.gsub(/EOS$/, '')
    end

    private

    def scrape_headline_top_url
      doc = Nokogiri::HTML.parse(open(TOP_URL))
      first_url = doc.css('.HeadlineTop a')[0][:href]
      URI.join(TOP_URL, first_url).to_s
    end

    def scrape_article_body(url)
      doc = Nokogiri::HTML.parse(open(url))
      doc.css('.ArticleText').text.tr("\n", '').gsub(/\A /, '')
    end

    def generate_mecab_tagger(text)
      # mecab = MeCab::Tagger.new('-Owakati')
      mecab = Natto::MeCab.new('-Owakati')
      mecab.parse(text + 'EOS').split(' ').each_cons(3).map do |a|
        { head: a[0], middle: a[1], end: a[2] }
      end
    end

    # マルコフ連鎖で要約
    def summarize(data)
      t1 = data[0][:head]
      t2 = data[0][:middle]
      new_text = t1 + t2
      loop do
        candidates = data.select { |d| d[:head] == t1 && d[:middle] == t2 }
        break if candidates.size == 0
        num = rand(candidates.size) # 乱数で次の文節を決定する
        new_text << candidates[num][:end]
        break if candidates[num][:end] == 'EOS'
        t1 = candidates[num][:middle]
        t2 = candidates[num][:end]
      end
      new_text
    end
  end
end

if $0 == __FILE__
  Asahicom::MarkovChain.new.summarize_headline
end

結果

今のTop記事は以下。
佐野氏謝罪「スタッフが第三者デザイン写す」 景品問題:朝日新聞デジタル
 
結果がこちら。
202020年とか、えらいことになってますが、年代とか固有名詞はtuningすればよさそうですね。

202020年東京五輪・パラリンピックのエンブレムを手掛けた佐野研二郎氏がデザインをトレースし、
そのまま使用するということ自体が、デザイナーとして決してあってはならない」との共同制作でなく、
個人応募だったと強調。ベルギーのデザイナーらが提訴 佐野氏は14日、スタッフが第三者のものと
思われるデザインを写して使ったことを明らかにし、謝罪した。

ポイント

乱数で候補の選択をおこなっているため、毎回結果が異なりますが
結構文字数に大きな差が出てきます。
そこで、そこそこの文字数に収まるように、min/max値を設定するようにしたところ
なかなかいい感じに要約っぽくなりました。

余談 ffi

gemは mecab でも動くのですが natto というライブラリを使ってみました。
このキラキラネームならぬネバネバネームは気になります。
この実装が面白く ffi/ffi を利用してます。
これを機にちょろっと ffi 調べてみたのですが、これはすごいですね。
コンパイル不要でCのライブラリが扱えてしまうんですね。