Kana解剖III

Kana解剖I
Kana解剖II
Kana解剖の第三回。話すこともないのでたぶん最終回です。ってか、今回のは読んでも(たいていの人は)役に立たないな、とか。

ほんとのKana

二重

二重マルコフ連鎖で文を生成しています。「前の前」と「前」が「次」に影響を持つ、という感じです。行列でも確率分布がかけないな。
それぞれの形態素について、「前がAだったら後は〜」、「前がBだったら後は〜」を記録しておけば、一つ前の形態素と自分自身から次の形態素を決定できる。
二重にすることでよりまともな文章を生成するようになりますが、学習が足りないとワンパターンな文しか生成できません。
三重にしてもよかったのだけど、保存しなければならないデータが増えすぎるのと、実装がmendoxaiのでやっていません。それに、文章がワンパターンになるのも恐れている、かもしれない。

品詞

同じ表記でも品詞ごとに形態素を区別することで、多少なりとも「文法」を再現しています。まぁ、あまり意味は無いのかもしれない。

双方向

「前→後」だけでなく「後→前」のつながりも保存しています。こうすることで、文頭以外からでも文章が生成可能。でも、どうせなら両者を同時に利用してより自然な文章を生成できるようにすべきだったかな、とか。できるか知らないけど。

ソースコードをちょっぴり

一部分載せておきます。多少の修正は加えてるけど、わりと勘でかいたコードそのままなので、ちょっぴり恥ずかしい(ぉ

ノード

クラスNode形態素一つを格納。クラスにする必要があったのかはなはだ疑問。

<?php
class Node
{
  public $word = "";        //単語
  public $id = "";          //ID
  public $isSTART = FALSE;  //最初ですか?
  public $isFINAL = FALSE;  //最後ですか?

  public $prev = array();   //前の単語
  public $next = array();   //後の単語

  //コンストラクタ
  function __construct($w)
  {
    $this -> word = $w;
    $this -> id = $w;
    
    if($w == "S_T_A_R_T")
      $this -> isSTART = TRUE;
    elseif($w == "F_I_N_A_L")
      $this -> isFINAL = TRUE;
  }
}
?>

各ノードは、「前の形態素が〜だったら次は〜」という順方向の配列nextと、「次の形態素が〜だったら前は〜」という逆方向の配列prevを持っている。

学習

クラスMarcovlearn()関数の内部。

<?php
$word = array();  //形態素が押し込まれていく配列
$kaiseki = array();  //解析途中でちょっぴり使われる配列
//このwhileループは、Kana解剖Iで載せたコードの七行目に相当します。
while($ret = fgets($handle))
{
  //改行コードを削除
  $ret = str_replace("\r","\n",$ret);
  $ret = str_replace("\n","",$ret);
  if($ret == "")  //do nothing
  elseif($ret == "EOS")  array_push($word,"F_I_N_A_L");  //文末
  else
  {
    //単語だけ抜き出して$wordに押し込む
    //実際のKanaは品詞ごとに分けたりしているのだけど、面倒なので。
    $kaiseki = split("\t",$ret);
    array_push($word,$kaiseki[0]);
  }
}
pclose($handle);
array_unshift($word,"S_T_A_R_T");  //文頭
$id = array();  //ノードIDの一覧(のはず)

//三単語(形態素)以上なら学習
if(count($word) >= 3)
{
  //IDを生成
  for($i = 0;$i < count($word);$i++)
  {
    $id[$i] = $word[$i]:
    //別に無駄なことをしているのではなくて、
    //実際にはIDは「形態素_品詞_品詞細分類1_読み」という形式なので。
    //今思えばハッシュ値とればよかった気がする
  }
  //単語リストにノードを追加
  for($i = 0;$i < count($word);$i++)
  {
    if(isset($this -> wordlist[$id[$i]]) == FALSE)
      $this -> wordlist[$id[$i]] = new Node($word[$i]);
    //wordlistはMarcovクラスの内部フィールド。
  }
  //二番目から順方向にノードをつないでいく
  for($i = 2;$i < count($word);$i++)
  {
    if($i < count($word))
    {
      //($i-1)番目のノードを掴む
      $nd = &$this -> wordlist[$id[$i-1]];
      //「次」にまだノードが連結されてなければ配列作成
      if(count($nd -> next[$id[$i-2]]) == 0)
        $nd -> next[$id[$i-2]] = array();
      //順方向配列に加える
      array_push($nd -> next[$id[$i-2]],$id[$i]);
    }
  }
  //逆方向にも同様につないでいく
  for($i = (count($word) - 1);$i > 1;$i--)
  {
    if($i > 1)
    {
      //($i-1)番目のノードを掴む
      $nd =& $this -> wordlist[$id[$i-1]];
      //「前」にまだノードが連結されてなければ配列作成
      if(count($nd -> prev[$id[$i]]) == 0)
        $nd -> prev[$id[$i]] = array();
      //逆方向配列に加える
      array_push($nd -> prev[$id[$i]],$id[$i-2]);
    }
  }
}
?>
文章生成

とうとう実際の文章生成です。わくわく。
クラスMarcovoneword()関数の中身です。$idは始点となる形態素のID。
ってか、読めば読むほど無駄が多い…

<?php
$marker = $id;      //順方向マーカ
$rmarker = $id;     //逆方向マーカ
$buffer = array();  //バッファ
$lc = 0;           //ループカウンタ
$nd = $this -> wordlist[$id];   //順方向現在位置
$rnd = $this -> wordlist[$id];  //逆方向現在位置
//文末と文頭にたどり着くまで無限ループ
while(TRUE)
{
  //まず順方向。現在のノードが最終ノードなら無視。
  if($nd -> isFINAL == FALSE)
  {
    if($lc == 0)  //一周目
    {
      //次のノードを適当に決定するために、
      //逆方向リストのキーから適当に選ぶ。
      //なんか不自然な方法だと思う。
      $pre2 = array_rand($nd -> prev);
      //逆方向にたどるときに整合性を保つために残しておく
      $pre4 = $pre2;
    }
    //現在位置を残しておく
    $pre1 = $marker;
    //現在のノードをバッファにつけたす
    array_push($buffer,$nd -> word);
    //マーカを一つ後ろに移動
    $marker = $pre2;
    //マーカの指すノードへ移動
    $nd = $this -> wordlist[$marker];
    //ノードがつながってないなら最後じゃん
    if(count($nd -> next[$pre1]) == 0)
      $nd -> isFINAL = TRUE;
    else
    {
      //この時点で$pre1は一つ前のノードを指している。
      //二つ前が$pre1で、一つ前が$markerのノードから次のノードをランダムに選択
      $pre2 = $nd->next[$pre1][rand(0,count($nd->next[$pre1]) - 1)];
    }
  }
  
  //次に逆方向。現在のノードが最初のノードなら無視。
  if($rnd -> isSTART == FALSE)
  {
    if($lc > 0)  //一周目以外
    {
      //現在のノードをバッファの反対側につけたす
      if($lc > 1)  //一つ目は順方向で出力済みなので放置
        array_unshift($buffer,$rnd -> word);
      //現在位置を残しておく
      $pre4 = $rmarker;
      //マーカを一つ前に移動
      $rmarker = $pre3;
      //マーカの指すノードへ移動
      $rnd = $this -> wordlist[$rmarker];
    }
    //ノードがつながってないなら最初じゃん
    if(count($rnd->prev[$pre4]) == 0)
      $rnd -> isSTART = TRUE;
    else
    {
      //この時点で$pre4は一つ後のノードを指している。
      //二つ後が$pre4で、一つ後が$rmarkerのノードから前のノードをランダムに選択
      $pre3 = $rnd->prev[$pre4][rand(0,count($rnd->prev[$pre4]) - 1)];
    }
  }
  
  //どちらも最後までつないだら停止
  if($nd -> isFINAL == TRUE && $rnd -> isSTART == TRUE)
    break;
  $lc++;
  //伸びすぎたら停止
  if($lc > 1000)  break;
}
//バッファの形態素を順に並べれば完成
return implode("",$buffer);
?>

あとは、ランダムにIDを選んでoneword()関数に渡してやるだけで適当な文章が生成されます。

考えられる改良点

いまいちやる気がないのだけど、メモ。

  • データの保存は今のところテキストファイルなのだけど、量が多くなると何かとアレだから、ちゃんとデータベースを使うようにしたらどうか、とか。でも、面倒だし負荷が大きくなりすぎないか不安。
  • この方式で生成するのは品詞の並び(つまり文法)だけで、単語は後からそこに当てはめるようにしたらどうか、とか。そうすれば、出現する単語の間につながりを持たせられて、支離滅裂な文が減るかもしれない*1
  • ニューラルネットワークだとか、とにかくちゃんと人工知能勉強しようよ。

まとめ

いや、別にまとめることとか、ないですけど、何か世界の真実的なものがこの連載で明かされるのを期待していた皆々様には心からお詫び申し上げます。半分くらいは僕の知識および説明力不足によるものですが、そもそも所詮Kana解剖ですから、ねぇ。

*1:それが醍醐味なんじゃん、という説もある