日本語係り受け解析器 CaboCha Ruby 拡張の基本的な使い方とちょっとした応用

ari3_botの会話エンジンで使っている自然言語の処理の中から他の方にも有益そうなところだけ書いておこうと思います。
このエンジン自体はアドホックにヒーリスティクスな処理を追加しまくっていて、とても説明しにくいのですが、基本的な部分だけ抜き出して……まとめて……解説します。全部実装できるほど詳細な解説はできないので、取っ掛かりになる程度です。
ちなみにCaboChaのAPIはきちんとしたドキュメントがない?ようで、僕はソースコードを自分で読んで得た知識から解説を行っています。また日本語文法関する知識もari3_botを作るためだけに得たものであり、あやしいので、あまり信じすぎないようにしてください。

もくじです。

  • ari3_botの会話エンジンとは
  • CaboChaの基本的な使い方
  • CaboChaで主語と述語っぽいところを抜き出す
  • 述語を活用してみる
  • その他のいろいろなヒント
  • まとめ

ari3_botの会話エンジンとは

@ari3_botのエンジンです。
ari3_botは、仮想世界で暮らす有限状態マシンのペット『アリさん』に餌をやったりすることで精神を安定させるプログラムです。
オマケとして簡単な会話ができるようになっています。

エンジンの最初の構想としては、

  • 日本語文章の構造を解析してPrologの式に変換する
  • 事実(fact)であれば記録する
  • 質問(query)であれば推論を実行し結果から自然な日本語を生成して返す

という80年代っぽい人工知能を作ろうと思ったのですが、実装は結構テキトウであやしいものになっています。

こんな会話ができます。


@ari3_bot こんにちは
@ultraistter こんにちは
@ari3_bot いい天気ですね
- yes_no: 天気(アリさん, いい), negative => false
@ultraistter いい天気。
@ari3_bot 暑いですね
- yes_no: 暑い(アリさん, nil), negative => false
@ultraistter 暑くない。
@ari3_bot ソクラテスって知ってますか
- yes_no: 知る(ソクラテス, nil), negative => false
@ultraistter ソクラテスは知るよ。
@ari3_bot ソクラテスは誰
- query: ソクラテス(誰, nil), negative => false type => 0
@ultraistter 誰が..ソクラテス..。アリさん、わからない。
@ari3_bot 僕がソクラテスです
- fact: ソクラテス(ultraistter, nil), negative => false
@ultraistter が..ソクラテス..。メモした。
@ari3_bot ソクラテスは人間です
- fact: 人間(ソクラテス, nil), negative => false
@ultraistter ソクラテスは..人間..。アリさん、信じる。
@ari3_bot 人間は死ぬ?
- yes_no: 死ぬ(人間, nil), negative => false
@ultraistter 死なない。
@ari3_bot 人間は死にます
- fact: 死ぬ(人間, nil), negative => false
@ultraistter 人間は..死ぬ..。アリさん、おぼえた。
@ari3_bot ソクラテスは死ぬ?
- yes_no: 死ぬ(ソクラテス, nil), negative => false
@ultraistter そう、ソクラテスは人間だから死ぬ。
@ari3_bot 僕は死ぬ?
- yes_no: 死ぬ(ultraistter, nil), negative => false
@ultraistter は死なないよ。
@ari3_bot 僕は人間ですよ
- fact: 人間(ultraistter, nil), negative => false
@ultraistter は..人間..。メモした。
@ari3_bot 僕は死なない?
- yes_no: 死ぬ(ultraistter, nil), negative => true
@ultraistter 死ぬ。
@ari3_bot 僕は誰?
- query: ultraistter(誰, nil), negative => false type => 0
@ultraistter は、ソクラテスと人間。
@ari3_bot 人間はどうなる
- query: どうするの(人間, nil), negative => false type => 1
@ultraistter 死ぬ。

僕→ソクラテス→人間→死ぬとリンクしていますが、アリなので、再帰的に問い合わせてゴールに繋がるか判定できるほど高度な知能は持っていません。途中でやめてしまいます。アリなので、アリなので……。また「いい天気ですね」と「ソクラテスって知ってますか」にYESを返しているのは、問い合わせに対する知識を持っていない場合に質問文の評価分析を行って、相手にとってポジティブな答えを返しているからです。たとえば「私ってかわいくないよね」には「かわいい(NO)」と返し、「僕って高速ですか?」には「高速(YES)」と返すなど気を使った返答を行います。

ari3_botでは


述語(主語, 目的語や補語)

という形の式を使っていて、この式に対して、

  • YES/NOを設定(事実、規則)
  • YES/NOを問い合わせ(YES/NOの質問)
  • パラメーターのどれか、たとえば主語を指定せずにどんな主語ならYESを返すか問い合わせ(聞き出す文)

という操作が行えるようになっています。
たとえば、「鳥は空を飛ぶ」という文が入力されると


飛ぶ(鳥, 空)

という式に変換され、これがYESを返すように内部のデータが調節されます。
「鳥は空を飛びますか?」という文が入力されると

飛ぶ(鳥, 空)

に変換された後に評価してYES/NOを受け取り、結果に従って日本語を生成(飛ぶ/飛ばない)して返します。未定の場合はNOになりますが、YESと返したほうがポジティブならYESと返します。

また「空を飛ぶのはなんですか」だと


飛ぶ(X, 空)

のような式に変換され、これが満たされるXの一覧を受け取り、結果が空でなければ「空を飛ぶのはX_1やX_2」などと返します。

入力された文から式の形に変換するためには、

  • 文が平叙文か疑問文かを判定する
  • 文を解析して主語、述語、目的語などを抜き出す

という処理を行います。

結果から日本語を生成して返すには、

  • 式と結果から述語を活用するなどして自然な日本語に変換する

という処理を行います。

この文を解析して主語、述語、目的語などを抜き出すという部分にCaboChaを使っています。また質問の式と結果から日本語を生成して返すときに述語を否定系へ変換したりと日本語文法に基づいた処理をしています。

他の話題は、http://ari3.jottit.com/にあります。

CaboCha Ruby拡張の基本的な使い方

CaboChaは日本語係り受け解析器です。日本語係り受け解析というのは、「僕はまともな人間です」から「人間です」に係っているのは「僕は」と「まともな」、というような文の構造を解析する構文解析です。どのチャンク(文節)がどのチャンクに係っているか(修飾しているか、説明しているか)という関係を解析して返してくれます。


$ cabocha
太郎はこの本を二郎を見た女性に渡した。
太郎は-----------D
この-D |
本を-------D
二郎を-D |
見た-D |
女性に-D
渡した。
EOS

次のような関係があることが分かります。

太郎は → 渡した。
この → 本を
本を → 見た
二郎を → 見た
見た → 女性に
女性に → 渡した。

インストール

http://code.google.com/p/cabocha/#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB
をよく読んでください。
Ubuntu環境にCaboChaの最新バージョンとCaboChaのインストールに必要なライブラリをインストールします。カッコ内は僕がインストールしたときのバージョンです。

細かい手順は書きませんが、この順番にインストールします。各./configure --helpを見て、文字コードの指定には注意してください。全体的に--with-charset=utf8 --enable-utf8-onlyにするのがいいと思います。

cabochaまでインストールしたら、


$ sudo ldconfig
$ cabocha
太郎はこの本を二郎を見た女性に渡した。
太郎は-----------D
この-D |
本を-------D
二郎を-D |
見た-D |
女性に-D
渡した。
EOS

としましょう。文字化けせずにそれっぽく表示されればOKです。

CaboCha Ruby拡張のインストール

Ruby拡張をSWIGで生成してインストールします。
注意として、0.60(この文章を書いている時点での最新)には、CaboCha::Token#surfaceというメソッドを何度か呼ぶとメモリでエラーで落ちるバグがあります。この文章では、代わりにCaboCha::Token#normalized_surfaceを使うことで回避します。バグ報告はしているため、0.60よりも後のバージョンでは修正されていると思います。

cabochaのソースディレクトリのswig/に移動してします。

make ruby

とするとruby/にRuby拡張のソースコードが生成されます。swigがない場合は、

sudo apt-get install libffi-dev swig

として入れときましょう。

ruby/に移動して、

ruby extconf.rb
make
sudo make install

でインストールします。

ruby test.rb

が実行されればインストールできています。

係り受け解析して結果を表示してみる

例から見てみましょう。

# -*- coding: utf-8 -*-
require 'CaboCha'

# sentenceを係り受け解析して結果ツリーを得る

sentence = "太郎はこの本を二郎を見た女性に渡した。"
parser = CaboCha::Parser.new;
tree = parser.parse(sentence)

# 全てのチャンクに対して
(0 ... tree.chunk_size).each do |i|
  chunk = tree.chunk(i)
  
  # linkが繋がっていれば
  if (chunk.link >= 0)
    # リンク元と
    chunk_from = (0 ... chunk.token_size).map do |j|
      tree.token(chunk.token_pos + j).normalized_surface
    end.join("-")
    
    # リンク先を
    chunk = tree.chunk(chunk.link)
    chunk_to = (0 ... chunk.token_size).map do |j|
      tree.token(chunk.token_pos + j).normalized_surface
    end.join("-")
    
    # 表示
    puts "#{chunk_from} => #{chunk_to}"
  end
end
太郎-は => 渡し-た-。
この => 本-を
本-を => 渡し-た-。
二-郎-を => 見-た
見-た => 女性-に
女性-に => 渡し-た-。

となります。=>で係り受け関係を表しています。-はトークンの区切りです。
このコードから分かることを箇条書きにすると……

  • CaboCha::Parser#parseで文章を解析してCaboCha::Treeを得る
  • CaboCha::Tree#chunk_sizeでチャンク数、CaboCha::Tree#chunkでCaboCha::Chunkを取得できる
  • CaboCha::Chunk#linkが0以上なら、linkの値が係っているchunkの添え字となっている
  • CaboCha::Chunkの文節は、CaboCha::Chunk#token_posからCaboCha::Chunk#token_size分のCaboCha::Tokenからなる
  • CaboCha::TokenはCaboCha::Tree#tokenで取得できる
  • CaboCha::Token#surface(normalized_surface)でトークンの文字列を取得できる

文章はトークンという単位に分けられて、連続するいくつのかトークンをつなげてチャンクにします。係り受け関係はチャンクの単位で行われます。
normalized_surfaceはsurfaceの結果を正規化したものです。たとえば、半角カナを全角カナにしたり全角数字を半角数字にしたりといった変換が行われます。surfaceが元の文章のままのトークンですが、cabocha-0.60のRuby拡張で呼び出すとエラーで落ちるため今回は使いません。

ここまでがCaboChaモジュールによる係り受け解析の基本的な使い方です。

CaboChaモジュールを拡張して使いやすくしてみる

CaboChaを使ってこれからさまざまな処理をしていくわけですが、標準のモジュールは使いにくいので、モンキーパッチで勝手に拡張していきます。

まず、

  • CaboCha::ChunkからtokenにアクセスできるようにCaboCha::Treeへの参照を持たせてCaboCha::Tree側でセットするようにする
  • CaboCha::Tree#chunksやCaboCha::Chunk#tokensなど配列を返すメソッドを追加する
  • Cabocha::Chunk#next_chunk、Cabocha::Chunk#prev_chunksでチャンクの双方向の関係が取れるようにする
  • Cabocha::Chunk#to_s、Cabocha::Token#to_sを定義して文字列化しやすくする

という拡張を行いました。

# -*- coding: utf-8 -*-
require 'CaboCha'

# CaboChaモジュールを拡張
module CaboCha
  class Token
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        # ruby 1.9
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
  end
  class Chunk
    attr_accessor :tree
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# sentenceを係り受け解析して結果ツリーを得る
sentence = "太郎はこの本を二郎を見た女性に渡した。"

parser = CaboCha::Parser.new;
tree = parser.parse(sentence)

# 全てのチャンクに対して
tree.chunks.each do |chunk|
  # 係っているチャンクがあれば
  if (chunk.next_chunk)
    # 表示
    puts "#{chunk.prev_chunks.inspect} => #{chunk} => #{chunk.next_chunk}"
  end
end
[] => 太郎は => 渡した。
[] => この => 本を
[この] => 本を => 渡した。
[] => 二郎を => 見た
[二郎を] => 見た => 女性に
[見た] => 女性に => 渡した。

最初の例と比べてかなり簡単に書けるようになったと思います。
説明が楽なので、あとの例でも同じようにCaboCha自体を拡張していきます。

主語と述語っぽいところを抜き出す

CaboChaを使って文章から主語と述語っぽいところを抜き出してみます。
ari3_botで「(主語は).. (述語)..。アリさんおぼえた」となる部分です。ari3_botは主語がない場合に補間したり、目的語なども扱っているので、もう少し複雑になりますが、ここでは主語と述語があるものだとしてそこだけ抜き出してみます。

手順としては、

  • ルールベースによる抽出のルールを決める
  • CaboChaの解析結果からルールに基づいて抽出する

とします。

まずどういった文章から、どの部分を取り出したいのか、適当な例を列挙してみます。
たとえば、

ソクラテスは人間である。 (ソクラテス => 人間)
福沢諭吉は1万円札に出てる人間です。(福沢諭吉 => 人間)
僕も普通の人間。(僕 => 人間)
鳥が空を飛んでいる。(鳥 => 飛ぶ)
馬ってたぶんうまい。(馬 => うまい)
飛ぶのは飛行機です(飛ぶの => 飛行機)
かわいいは正義(かわいい => 正義)
お星様はとてもまぶしい (お星様 => まぶしい)
拙者は時々切腹するでござる (拙者 => 切腹する)

ようなものです。
これをcabocha -f2コマンドに渡してどういった結果になるか試して見ます。

$ cabocha -f2
かわいいは正義
           かわいいは-D
  <PERSON>正義</PERSON>
EOS
* 0 1D 0/1 0.000000
かわいい        形容詞,自立,*,*,形容詞・イ段,基本形,かわいい,カワイイ,カワイイ,かわいい/可愛い, O
は      助詞,係助詞,*,*,*,*,は,ハ,ワ,,  O
* 1 -1D 0/0 0.000000
正義    名詞,固有名詞,人名,名,*,*,正義,マサヨシ,マサヨシ,,      B-PERSON
EOS
鳥が空を飛んでいる。
        鳥が---D
          空を-D
    飛んでいる。
EOS
* 0 2D 0/1 5.261511
鳥      名詞,一般,*,*,*,*,鳥,トリ,トリ,,        O
が      助詞,格助詞,一般,*,*,*,が,ガ,ガ,,       O
* 1 2D 0/1 0.000000
空      名詞,一般,*,*,*,*,空,ソラ,ソラ,,        O
を      助詞,格助詞,一般,*,*,*,を,ヲ,ヲ,,       O
* 2 -1D 0/2 0.000000
飛ん    動詞,自立,*,*,五段・バ行,連用タ接続,飛ぶ,トン,トン,とん/飛ん,   O
で      助詞,接続助詞,*,*,*,*,で,デ,デ,,        O
いる    動詞,非自立,*,*,一段,基本形,いる,イル,イル,,    O
。      記号,句点,*,*,*,*,。,。,。,,    O
EOS

のような出力になります。チャンクの区切り、チャンクの係り受け関係、各トークンの品詞、マサヨシなどが分かります。ちなみにトークンの横のCSVっぽいものはCaboCha::Token#feature_listで取れます。
例を思いつく限り入力して、できるだけ単純で汎用性のあるルールを考えましょう。

ここでは

  • 主語は述語に係っている。主語が係っているのが述語である。
  • 主語のあるチャンクは「は」「が」「って」「も」のような助詞で終わっている。名詞が多いが動詞+「の」や形容詞(+「の」)のこともある。
  • 述語はどのチャンクにも係らない

というルールで抽出することにします。
プログラム的には、

  • 接続先があってかつ主語っぽいチャンクを抽出
  • 接続先のチャンクを述語とする
  • 述語には接続先がない

となります。

# -*- coding: utf-8 -*-
require 'CaboCha'

if (RUBY_VERSION < "1.9.0")
  $KCODE = 'u'
end

# CaboChaモジュールを拡張
module CaboCha
  class Token
    # 名詞?
    def noun?
      feature_list(0) == '名詞'
    end
    # 名詞接続? (「お星様」の「お」など)
    def meishi_setsuzoku?
      feature_list(0) == '接頭詞' &&
        feature_list(1) == '名詞接続'
    end
    # 動詞?
    def verb?
      feature_list(0) == '動詞'
    end
    # 形容詞?
    def adjective?
      feature_list(0) == '形容詞'
    end
    # サ変接続? (掃除する 洗濯する など)
    def sahen_setsuzoku?
      feature_list(0) == '名詞' &&
        feature_list(1) == 'サ変接続'
    end
    # サ変する?
    def sahen_suru?
      feature_list(4) == 'サ変・スル'
    end
    
    # 基本形へ
    def to_base
      if (feature_list_size > 6 && feature_list(6) != "*")
        feature_list(6)
      else
        to_s
      end
    end
    
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
    
    alias :feature_list_org :feature_list
    def feature_list(i)
      if (@feature_list)
        @feature_list[i]
      else
        if ("".respond_to?("force_encoding"))
          @feature_list ||= (0 ... feature_list_size).map do |j|
            feature = feature_list_org(j)
            feature.force_encoding("utf-8")
          end
          @feature_list[i]
        else
          @feature_list ||= (0 ... feature_list_size).map{|j| feature_list_org(j) }
          @feature_list[i]
        end
      end
    end
  end
  class Chunk
    attr_accessor :tree

    # 動詞?
    def verb?
      tokens[0].verb? || verb_sahen?
    end
    # 名詞サ変接続+スル
    def verb_sahen?
      (tokens.length > 1 &&
       tokens[0].sahen_setsuzoku? && tokens[1].sahen_suru?)
    end
    # 名詞?
    def noun?
      (!verb_sahen? && (tokens[0].noun? || tokens[0].meishi_setsuzoku?))
    end
    # 形容詞?
    def adjective?
      tokens[0].adjective?
    end
    # 主語っぽい?
    def subject?
      (((noun? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (adjective? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (verb? && %w(は って も が).include?(tokens[-1].to_s))))
    end
    # 基本形へ変換
    def to_base
      @to_base ||=
      if (noun?)
        # 連続する名詞、・_や名詞接続をくっつける
        base = ""
        tokens.each do |token|
          if (token.meishi_setsuzoku?)
            base += token.to_base
          elsif (token.noun?)
            base += token.to_base
          elsif (["_",""].include?(token.to_s))
            base += token.to_base
          elsif (base.length > 0)
            break
          end
        end
        base
      elsif (verb_sahen?)
        # 名詞サ変接続 + スル
        tokens[0].to_base + tokens[1].to_base
      elsif (verb?)
        tokens[0].to_base
      elsif (adjective?)
        tokens[0].to_base
      else
        to_s
      end
    end
    
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# 主語と述語っぽいのを抜き出してみる

sentences = %w(
ソクラテスは人間です。
福沢諭吉は1万円札に出てる人間です。
僕も普通の人間。
鳥が空を飛んでいる。
馬ってたぶんうまい。
飛ぶのは飛行機です。
かわいいは正義。
お星様はとてもまぶしい。
拙者は時々切腹するでござる。
)

parser = CaboCha::Parser.new;
sentences.each do |sentence|
  puts "+ #{sentence}"
  tree = parser.parse(sentence)
  # 全てのチャンクに対して
  tree.chunks.each do |chunk|
    # 接続先があって主語っぽくて接続先の接続先がないチャンクを抽出
    if (chunk.next_chunk && chunk.subject? && chunk.next_chunk.next_chunk.nil?)
      # 主語 => 述語を表示
      puts "-- #{chunk.to_base} => #{chunk.next_chunk.to_base}"
    end
  end
end
+ ソクラテスは人間です。
-- ソクラテス => 人間
+ 福沢諭吉は1万円札に出てる人間です。
-- 福沢諭吉 => 人間
+ 僕も普通の人間。
-- 僕 => 人間
+ 鳥が空を飛んでいる。
-- 鳥 => 飛ぶ
+ 馬ってたぶんうまい。
-- 馬 => うまい
+ 飛ぶのは飛行機です。
-- 飛ぶ => 飛行機
+ かわいいは正義。
-- かわいい => 正義
+ お星様はとてもまぶしい。
-- お星様 => まぶしい
+ 拙者は時々切腹するでござる。
-- 拙者 => 切腹する

注意することは

  • 名詞サ変接続 + スルはひとつの動詞ということにする (掃除する 洗濯する など)
  • 連続する名詞や接頭詞はつなげてひとつの名詞ということにする

としている点です。こうするといろいろ捗ります。
コードはローマ字と英語が混じっててファーと思うかもしれませんが、コーディング上の細かい話は気にせずにいきましょう。

述語を活用してみる

抽出した主語と述語から自然な文章が意のままに生成できると便利です。
たとえば、

  • 否定文にする
  • 疑問文にする
  • 命令文にする

などがあります。他いろいろ。

これらは述部を変形することで可能です。
変形の方法として、IPA辞書を使うこともできますが、ここでは単純なルールと動詞の場合は活用表を使うことにしてみます。
CaboChaとはあまり関係ありませんが、トークンの品詞などが取れているのでそれをヒントに独自実装を行います。

否定する

まず例として否定する場合を考えてみます。
前章と同様に、どういった文章をどのように変換したいか、適当な例を列挙してみます。

+ 太郎はこの本を二郎を見た女性に渡した。
-- 太郎は渡さない。
+ ソクラテスは人間です。
-- ソクラテスは人間じゃない。
+ ネコはかわいい。
-- ネコはかわいくない。
+ イヌはこわい。
-- イヌはこわくない。
+ 鳥って飛ぶらしい。
-- 鳥は飛ばない。
+ おじいさんはいつものように山へ芝刈りに行きました。
-- おじいさんは行かない。
+ 僕は死にます。
-- 僕は死なない。
+ 拙者が切腹します。
-- 拙者は切腹しない。
+ 君がご飯を食べます。
-- 君は食べない。
+ 魚は泳ぐ。
-- 魚は泳がない。
+ 台風が来る。
-- 台風は来ない。
+ 金がある。
-- 金はない。
+ 金がない。
-- 金はある。
+ かわいいは正義。
-- かわいいのは正義じゃない。
  • 名詞の場合は、「じゃない(ではない、にあらず)」をつける
  • 形容詞の場合は、最後の1文字(「い」)を消して「くない」をつける
  • 動詞の場合は、未然形にして「ない」をつける
  • ない <=> ある のような例外処理もいる

というルールである程度変換できそうです。
難しそうなのは、動詞を未然形にするという部分だと思いますが、トークンの品詞情報と活用表を使えば実装できます。

以下が実装です。動詞の活用を実装すれば、否定以外の変形も簡単にできるので、いくつか適当に変形してみます。
この活用表は、僕がMeCabの辞書にあわせて独自の調査により作成したもので、ちょっとあやしいかもしれません。

# -*- coding: utf-8 -*-
require 'CaboCha'

if (RUBY_VERSION < "1.9.0")
  $KCODE = 'u'
end

# CaboChaモジュールを拡張
module CaboCha
  class Token
    # 名詞?
    def noun?
      feature_list(0) == '名詞'
    end
    # 名詞接続? (「お星様」の「お」など)
    def meishi_setsuzoku?
      feature_list(0) == '接頭詞' &&
        feature_list(1) == '名詞接続'
    end
    # 動詞?
    def verb?
      feature_list(0) == '動詞'
    end
    # 形容詞?
    def adjective?
      feature_list(0) == '形容詞'
    end
    # 名詞サ変接続?
    def sahen_setsuzoku?
      feature_list(0) == '名詞' &&
        feature_list(1) == 'サ変接続'
    end
    # サ変する?
    def sahen_suru?
      feature_list(4) == 'サ変・スル'
    end
    
    # 基本形へ
    def to_base
      if (feature_list_size > 6 && feature_list(6) != "*")
        feature_list(6)
      else
        to_s
      end
    end

    # 動詞の活用
    
    ## 一段命令形の形 (0:食べよ 1:食べれ 2:食べろ)
    V_ICHIDAN_MEIREI = ['', '', '']
    V_ICHIDAN_MEIREI_TYPE = 2
    
    def v_godan?
      @v_godan ||= (feature_list(4) =~ /^五段/)
    end
    def v_rahen?
      @v_rahen ||= (feature_list(4) =~ /^ラ変/)
    end
    def v_ichidan?
      @v_ichidan ||= (feature_list(4) =~ /^一段/)
    end
    def v_yodan?
      @v_yodan ||= (feature_list(4) =~ /^四段/)
    end
    def v_kahen?
      @v_kahen ||= (feature_list(4) =~ /^カ変/)
    end
    def v_sahen?
      @v_sahen ||= (feature_list(4) =~ /^サ変/)
    end
    def v_kami_nidan?
      @v_kami_nidan ||= (feature_list(4) =~ /^上二/)
    end

    ## 活用
    def v_inflect(type)
      if (v_godan?)
        base = to_base
        v_type = feature_list(4)
        case (v_type)
        when /カ行/
          base.gsub(/\w$/, '') + %w(か き く く け け)[type]
        when /ガ行/
          base.gsub(/\w$/, '') + %w(が ぎ ぐ ぐ げ げ)[type]
        when /サ行/
          base.gsub(/\w$/, '') + %w(さ し す す せ せ)[type]
        when /タ行/
          base.gsub(/\w$/, '') + %w(た ち つ つ て て)[type]
        when /ナ行/
          base.gsub(/\w$/, '') + %w(な に ぬ ぬ ね ね)[type]
        when /バ行/
          base.gsub(/\w$/, '') + %w(ば び ぶ ぶ べ べ)[type]
        when /マ行/
          base.gsub(/\w$/, '') + %w(ま み む む め め)[type]
        when /ラ行/
          base.gsub(/\w$/, '') + %w(ら り る る れ れ)[type]
        when /ワ行/
          base.gsub(/\w$/, '') + %w(わ い う う え え)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_sahen?)
        v_type = feature_list(4)
        case v_type
        when /スル/
          %w(し し する する すれ しろ)[type]
        when /ズル/
          to_base.gsub(/\w$/, '') + %w(ぜ ず ずる ずる ずれ ぜよ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_ichidan?)
        to_base.gsub(/\w$/, '') +
          ['', '', '', '', '', V_ICHIDAN_MEIREI[V_ICHIDAN_MEIREI_TYPE]][type]
      elsif (v_rahen?)
        to_base.gsub(/\w$/,'') + %w(ら り り る れ れ)[type]
      elsif (v_yodan?)
        v_type = feature_list(4)
        case v_type
        when /カ行/
          to_base.gsub(/\w$/,'') + %w(か き く く け け)[type]
        when /ガ行/
          to_base.gsub(/\w$/,'') + %w(が ぎ ぐ ぐ げ げ)[type]
        when /サ行/
          to_base.gsub(/\w$/,'') + %w(さ し す す せ せ)[type]
        when /タ行/
          to_base.gsub(/\w$/,'') + %w(た ち つ つ て て)[type]
        when /ハ行/
          to_base.gsub(/\w$/,'') + %w(は ひ ふ ふ へ へ)[type]
        when /バ行/
          to_base.gsub(/\w$/,'') + %w(ば び ぶ ぶ べ べ)[type]
        when /マ行/
          to_base.gsub(/\w$/,'') + %w(ま み む む め め)[type]
        when /ラ行/
          to_base.gsub(/\w$/,'') + %w(ら り る る れ れ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      elsif (v_kahen?)
        v_type = feature_list(4)
        if (v_type == 'カ変・クル' || v_type == 'カ変・来ル')
          %w(来 来 来る 来る 来れ 来い)[type]
        end
      elsif (v_kami_nidan?)
        v_type = feature_list(4)
        case v_type
        when /カ行/
          to_base.gsub(/\w$/,'') + %w(き き く くる くれ きよ)[type]
        when /ガ行/
          to_base.gsub(/\w$/,'') + %w(ぎ ぎ ぐ ぐる ぐれ ぎよ)[type]
        when /タ行/
          to_base.gsub(/\w$/,'') + %w(ち ち つ つる つれ ちよ)[type]
        when /ダ行/
          to_base.gsub(/\w$/,'') + %w(ぢ ぢ づ づる づれ ぢよ)[type]
        when /ハ行/
          to_base.gsub(/\w$/,'') + %w(ひ ひ ふ ふる ふれ ひよ)[type]
        when /バ行/
          to_base.gsub(/\w$/,'') + %w(び び ぶ ぶる ぶれ びよ)[type]
        when /マ行/
          to_base.gsub(/\w$/,'') + %w(み み む むる むれ みよ)[type]
        when /ヤ行/
          to_base.gsub(/\w$/,'') + %w(い い ゆ ゆる ゆれ いよ)[type]
        when /ラ行/
          to_base.gsub(/\w$/,'') + %w(り り る るる るれ りよ)[type]
        else
          raise "unknown feature_list(4) #{feature_list(4)}"
        end
      else
        raise "unknown feature_list(4) #{feature_list(4)}"
      end
    end
    
    def to_s
      @to_s ||=
      if ("".respond_to?("force_encoding"))
        normalized_surface.force_encoding("utf-8")
      else
        normalized_surface # 本当はsurface        
      end
    end
    
    alias :feature_list_org :feature_list
    def feature_list(i)
      if (@feature_list)
        @feature_list[i]
      else
        if ("".respond_to?("force_encoding"))
          @feature_list ||= (0 ... feature_list_size).map do |j|
            feature = feature_list_org(j)
            feature.force_encoding("utf-8")
          end
          @feature_list[i]
        else
          @feature_list ||= (0 ... feature_list_size).map{|j| feature_list_org(j) }
          @feature_list[i]
        end
      end
    end
  end
  class Chunk
    attr_accessor :tree

    # 活用
    def v_inflect(type)
      if (verb_sahen?)
        tokens[0].to_base + tokens[1].v_inflect(type)
      else
        tokens[0].v_inflect(type)
      end
    end
    # 未然形
    def to_mizen
      @to_mizen ||= v_inflect(0)
    end
    # 連用形
    def to_renyo
      @to_renyo ||= v_inflect(1)
    end
    # 終止形
    def to_syushi
      @to_syushi ||= v_inflect(2)
    end
    # 連体形
    def to_rentai
      @to_rentai ||= v_inflect(3)
    end
    # 仮定形
    def to_katei
      @to_katei ||= v_inflect(4)
    end
    # 命令形
    def to_meirei
      @to_meirei ||= v_inflect(5)
    end
    # 否定形
    def to_negative
      @to_negative ||=
      if (noun?)
        to_base + 'じゃない'
      elsif (adjective?)
        if (to_base == "ない" || to_base == "無い")
          "ある"
        else
          to_base.gsub(/\w$/,'') + 'くない'
        end
      elsif (verb?)
        if (to_base == 'ある')
          "ない"
        else
          to_mizen + "ない"
        end
      else
        to_base
      end
    end
    def to_noun
      @to_noun ||=
      if (noun?)
        to_base
      elsif (verb?)
        # 飛ぶの, 泳ぐの
        to_base + ''
      elsif (adjective?)
        # かわいいの, 大きいの
        to_base + ''
      else
        to_base
      end
    end
    
    # 動詞?
    def verb?
      tokens[0].verb? || verb_sahen?
    end
    # 名詞サ変接続+スル 動詞 (掃除する 洗濯する など)
    def verb_sahen?
      (tokens.length > 1 &&
       tokens[0].sahen_setsuzoku? && tokens[1].sahen_suru?)
    end
    # 名詞?
    def noun?
      (!verb_sahen? && (tokens[0].noun? || tokens[0].meishi_setsuzoku?))
    end
    # 形容詞?
    def adjective?
      tokens[0].adjective?
    end
    # 主語っぽい?
    def subject?
      (((noun? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (adjective? && %w(は って も が).include?(tokens[-1].to_s)) ||
        (verb? && %w(は って も が).include?(tokens[-1].to_s))))
    end
    # 基本形へ
    def to_base
      @to_base ||=
      if (noun?)
        # 連続する名詞、・_や名詞接続をくっつける
        base = ""
        tokens.each do |token|
          if (token.meishi_setsuzoku?)
            base += token.to_base
          elsif (token.noun?)
            base += token.to_base
          elsif (["_",""].include?(token.to_s))
            base += token.to_base
          elsif (base.length > 0)
            break
          end
        end
        base
      elsif (verb_sahen?)
        tokens[0].to_base + tokens[1].to_base
      elsif (verb?)
        tokens[0].to_base
      elsif (adjective?)
        tokens[0].to_base
      else
        to_s
      end
    end
    
    def tokens
      @tokens ||= (0 ... token_size).map{|i| tree.token(token_pos + i) }
    end
    def next_chunk
      @next_chunk ||= (link >= 0) ? tree.chunk(link) : nil
    end
    def prev_chunks
      @prev_chunks ||= tree.chunks.select{|chunk| chunk.link == self_index }
    end
    def to_s
      @to_s ||= tokens.map{|t| t.to_s }.join
    end
    def self_index
      @self_index ||= tree.chunks.reduce([nil, 0]) do |argv, chunk| 
        if (chunk.token_pos == self.token_pos)
          argv[0] = argv[1]
        else
          argv[1] += 1
        end
        argv
      end.shift
    end
  end
  class Tree
    alias :chunk_org :chunk
    def chunk(i)
      if (@chunks)
        @chunks[i]
      else
        chunk = chunk_org(i)
        chunk.tree = self
        chunk
      end
    end
    def chunks
      @chunks ||= (0 ... chunk_size).map {|i| chunk(i)}
    end
  end
end

# 主語と述語っぽいのを抜き出して、述語をテキトウに変形してみる

sentences = %w(
太郎はこの本を二郎を見た女性に渡した。
ソクラテスは人間です。
ネコはかわいい。
イヌもこわい。
鳥って飛ぶらしい。
おじいさんはいつものように山へ芝刈りに行きました。
僕は死にます。
拙者が切腹します。
君がご飯を食べます。
魚は泳ぐ。
台風が来る。
金がある。
金がない。
かわいいは正義。
)

parser = CaboCha::Parser.new;
sentences.each do |sentence|
  puts "+ #{sentence}"
  tree = parser.parse(sentence.to_s)
  # 全てのチャンクに対して
  tree.chunks.each do |chunk|
    # 主語と述語を抜き出す
    if (chunk.next_chunk && chunk.subject? && chunk.next_chunk.next_chunk.nil?)
      # 主語 => 述語を表示
      puts "-- #{chunk.to_base} => #{chunk.next_chunk.to_base}"
      # 否定
      puts "--- 否定: #{chunk.to_noun}#{chunk.next_chunk.to_negative}よ。"
      
      # テキトウに変換してみます
      if (chunk.next_chunk.verb?)
        puts "--- 命令: #{chunk.to_noun}#{chunk.next_chunk.to_meirei}"
        puts "--- 仮定: #{chunk.to_noun}#{chunk.next_chunk.to_katei}ばよくね?"
        puts "--- したい: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}たい。」"
        puts "--- 敬語: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}ます。"
        puts "--- はじめた: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}はじめた。"
        puts "--- 続けてる: #{chunk.to_noun}#{chunk.next_chunk.to_renyo}続けている。"
        puts "--- ふり: #{chunk.to_noun}#{chunk.next_chunk.to_rentai}ふりをしている。"
      elsif (chunk.next_chunk.noun?)
        puts "--- なった: #{chunk.to_noun}#{chunk.next_chunk.to_base}になった。"
      elsif (chunk.next_chunk.adjective?)
        puts "--- なった: #{chunk.to_noun}#{chunk.next_chunk.to_base.gsub(/い$/,'')}くなった。"
      end
    end
  end
end

結果

+ 太郎はこの本を二郎を見た女性に渡した。
-- 太郎 => 渡す
--- 否定: 太郎は渡さないよ。
--- 命令: 太郎が渡せ!
--- 仮定: 太郎が渡せばよくね?
--- したい: 太郎「渡したい。」
--- 敬語: 太郎が渡します。
--- はじめた: 太郎が渡しはじめた。
--- 続けてる: 太郎が渡し続けている。
--- ふり: 太郎が渡すふりをしている。
+ ソクラテスは人間です。
-- ソクラテス => 人間
--- 否定: ソクラテスは人間じゃないよ。
--- なった: ソクラテスは人間になった。
+ ネコはかわいい。
-- ネコ => かわいい
--- 否定: ネコはかわいくないよ。
--- なった: ネコはかわいくなった。
+ イヌもこわい。
-- イヌ => こわい
--- 否定: イヌはこわくないよ。
--- なった: イヌはこわくなった。
+ 鳥って飛ぶらしい。
-- 鳥 => 飛ぶ
--- 否定: 鳥は飛ばないよ。
--- 命令: 鳥が飛べ!
--- 仮定: 鳥が飛べばよくね?
--- したい: 鳥「飛びたい。」
--- 敬語: 鳥が飛びます。
--- はじめた: 鳥が飛びはじめた。
--- 続けてる: 鳥が飛び続けている。
--- ふり: 鳥が飛ぶふりをしている。
+ おじいさんはいつものように山へ芝刈りに行きました。
-- おじいさん => 行く
--- 否定: おじいさんは行かないよ。
--- 命令: おじいさんが行け!
--- 仮定: おじいさんが行けばよくね?
--- したい: おじいさん「行きたい。」
--- 敬語: おじいさんが行きます。
--- はじめた: おじいさんが行きはじめた。
--- 続けてる: おじいさんが行き続けている。
--- ふり: おじいさんが行くふりをしている。
+ 僕は死にます。
-- 僕 => 死ぬ
--- 否定: 僕は死なないよ。
--- 命令: 僕が死ね!
--- 仮定: 僕が死ねばよくね?
--- したい: 僕「死にたい。」
--- 敬語: 僕が死にます。
--- はじめた: 僕が死にはじめた。
--- 続けてる: 僕が死に続けている。
--- ふり: 僕が死ぬふりをしている。
+ 拙者が切腹します。
-- 拙者 => 切腹する
--- 否定: 拙者は切腹しないよ。
--- 命令: 拙者が切腹しろ!
--- 仮定: 拙者が切腹すればよくね?
--- したい: 拙者「切腹したい。」
--- 敬語: 拙者が切腹します。
--- はじめた: 拙者が切腹しはじめた。
--- 続けてる: 拙者が切腹し続けている。
--- ふり: 拙者が切腹するふりをしている。
+ 君がご飯を食べます。
-- 君 => 食べる
--- 否定: 君は食べないよ。
--- 命令: 君が食べろ!
--- 仮定: 君が食べればよくね?
--- したい: 君「食べたい。」
--- 敬語: 君が食べます。
--- はじめた: 君が食べはじめた。
--- 続けてる: 君が食べ続けている。
--- ふり: 君が食べるふりをしている。
+ 魚は泳ぐ。
-- 魚 => 泳ぐ
--- 否定: 魚は泳がないよ。
--- 命令: 魚が泳げ!
--- 仮定: 魚が泳げばよくね?
--- したい: 魚「泳ぎたい。」
--- 敬語: 魚が泳ぎます。
--- はじめた: 魚が泳ぎはじめた。
--- 続けてる: 魚が泳ぎ続けている。
--- ふり: 魚が泳ぐふりをしている。
+ 台風が来る。
-- 台風 => 来る
--- 否定: 台風は来ないよ。
--- 命令: 台風が来い!
--- 仮定: 台風が来ればよくね?
--- したい: 台風「来たい。」
--- 敬語: 台風が来ます。
--- はじめた: 台風が来はじめた。
--- 続けてる: 台風が来続けている。
--- ふり: 台風が来るふりをしている。
+ 金がある。
-- 金 => ある
--- 否定: 金はないよ。
--- 命令: 金があれ!
--- 仮定: 金があればよくね?
--- したい: 金「ありたい。」
--- 敬語: 金があります。
--- はじめた: 金がありはじめた。
--- 続けてる: 金があり続けている。
--- ふり: 金があるふりをしている。
+ 金がない。
-- 金 => ない
--- 否定: 金はあるよ。
--- なった: 金はなくなった。
+ かわいいは正義。
-- かわいい => 正義
--- 否定: かわいいのは正義じゃないよ。
--- なった: かわいいのは正義になった。

大体できてる気がしますね。ダイタイできればよい。
この活用表だけではできない表現もありますが、日本語は自由度が高いので、がんばれば同じような意味の文がこの活用表の範囲でも生成できることがあります。

その他いろいろなヒント

これまでやったように

  • 例文と得たい結果を思いつくだけ書いてみる
  • それをcabochaに突っ込んで解析結果を得る
  • 解析結果を眺めながら例文を得たい結果に変換するルールを考える

ということをすれば、大体うまくいくルールは大体作れると思います。
難しければ、機械学習するというのもありだと思います。たとえば、疑問文か平叙文か分類する処理は、機械学習のほうがうまくいきそうです。ari3_botはルールベースですけど。

あと基本的なこととして、日本語の文法を知っておくとよいルールを思いつきやすいというのがあります。中学校でやった国文法レベルでも結構役に立ちます。

まとめ

ari3_botのエンジンで使っているCaboChaの基本的な使い方と、ちょっとした応用を解説しました。
中学校で習う国語はとても役に立ちますから、プログラミング言語やOSのシステムコールと同様に勉強しといたほうがいいと思いますけど、完全に忘れてても本1冊読めばとりあえずどうにかなります。この本を買いましょう。

日本語の文法

日本語の文法


僕はari3_botを作ったときにこの本を参考にしました。もうほとんど覚えていませんが、手元にあるとまた読むことができます。

追記

Ruby 1.9だと、.gsub(/\w$/,'') (末尾のひらがなを消したい)が置換されないので、.gsub(/[ぁ-ん]$/,'') 等に……