可変次数 N-gram デコードのアルゴリズム

前に書いた N-gram 漢字-かな変換 - アスペ日記アルゴリズムについて。


かなり縦に長いエントリになると思う。途中までは一般的な日本語自然言語処理にかかわること。


例として、「かれがくるまでまつ」というひらがなの文をデコードして、対応する漢字かな混じり文にすることを考える。


こういう時に使われるのが「ラティス構造」。こういうやつ↓

(この図は一回しか出てきません。ちなみにこのために Keynote 買ったようなもの)


それぞれのノードで、そこに入ってくるエッジの中で一番確率が高いものとその確率を覚えていくことで、動的計画法によって最適なパスを導くことができる。


これをプログラム上でどう実現するか。

まず、共通接頭辞検索というものを使う。

これは、あるキーを渡すと、そのキーに前から一致するようなキーを持つ候補を列挙してくれるというもの。

例えば、「くるまで」をキーとして使うと、「く」「くる」「くるま」をキーとして持つ候補をすべて返してくれる。

文頭から一文字ずつずらしながらこの共通接頭辞検索を行うことで、ラティスの中に出現するノードを列挙できる。

例えば、「かれがくるまでまつ」では、まず「かれがくるまでまつ」全体を共通接頭辞検索に渡し、「か」と「かれ」に対応する候補があることを教えてもらい、その次は「れがくるまでまつ」→「れ」、「がくるまでまつ」→「が」「がく」、「くるまでまつ」→「く」「くる」「くるま」…という具合。


結果が返ってくるようなひらがな列を図にすると、例えば次のようになる。

 か れ が く る ま で ま つ 
|か|
|か れ|
  |れ|
    |が|
    |が く|
      |く|
      |く る|
      |く る ま|
        |る|
          |ま|
          |ま で|
            |で|
            |で ま|
              |ま|
              |ま つ|
                |つ|

それらに対する値である漢字かな混じり表記を加えると次のようになる。

 か れ が く る ま で ま つ 
|か|ー(か、蚊)
|か れ|ー(彼、枯れ)
  |れ|ー(レ)
    |が|ー(が、蛾)
    |が く|ー(学、額)
      |く|ー(区)
      |く る|ー(来る、繰る)
      |く る ま|ー(車)
        |る|ー(流)
          |ま|ー(間)
          |ま で|ー(まで)
            |で|ー(で、出)
            |で ま|ー(デマ)
              |ま|ー(間)
              |ま つ|ー(待つ、松)
                |つ|ー(津) 

で、値のほうだけを縦に並べると次のようになる。

 か れ が く る ま で ま つ 
|か|
|蚊|
|彼  |
|枯れ |
  |レ|
    |が|
    |蛾|
    |学  |
    |額  |
      |区|
      |来る |
      |繰る |
      |車    |
        |流|
          |間|
          |まで |
            |で|
            |出|
            |デマ |
              |間|
              |待つ |
              |松  |
                |津|

これは、本質的に一番上で描いたラティスと同じもの。

エッジは描いていないが、ある位置で始まるノードを作るときに、その位置で終わるノードとの連接を見るだけでいい。

わかりやすくするため、ノードに番号を付けてみる(文頭・文末はここでは考えない)。

 か れ が く る ま で ま つ 
|か|1
|蚊|2
|彼  |3
|枯れ |4
  |レ|5
    |が|6
    |蛾|7
    |学  |8
    |額  |9
      |区|10
      |来る |11
      |繰る |12
      |車    |13
        |流|14
          |間|15
          |まで |16
            |で|17
            |出|18
            |デマ |19
              |間|20
              |待つ |21
              |松  |22
                |津|23

例えば、17番の「で」について考える。

前からこのノードにつながるのは、「で(17)」の開始位置で終わるノード、つまり 13番の「車」と 15番の「間」。

「車(13)」までの確率に「車-で」の bigram 確率をかけたものと、「間(15)」までの確率に「間-で」の bigram 確率をかけたものとで、より大きいものを選び(ここでは「車(13)」とする)、その確率を「で(17)」の確率とし、また選んだノード(「最適な左ノード」と呼ぶことにする)がどれだったかを覚えておく。(今回の例では、実際に確率を設定しているわけではないので、最適な左ノードは適当に選んである)

前から順番にこの処理を行うと、それぞれのノードについて最適な左ノードを決めることができる。図にすると、例えば次のようになる(ノードの右の数字はそのノードの ID で、左の数字は最適な左ノードの数字であることに注意)。

 か れ が く る ま で ま つ 
|か|1
|蚊|2
|彼  |3
|枯れ |4
 1|レ|5
   3|が|6
   3|蛾|7
   3|学  |8
   3|額  |9
     6|区|10
     6|来る |11
     6|繰る |12
     6|車    |13
      10|流|14
        11|間|15
        11|まで |16
          13|で|17
          13|出|18
          13|デマ |19
            17|間|20
            17|待つ |21
            17|松  |22
              20|津|23

最後に、文末ノード(ここでは省略してある)から最適な左ノードをたどることで、最適なパスを知ることができる。

例えば、文末の最適な左ノードが「待つ(21)」であれば、「彼(3)-が(6)-車(13)-で(17)-待つ(21)」となり、漢字かな混じり文は「彼が車で待つ」となる。


さて、これを単純に trigram に拡張することを考える。

bigram を見るだけなら、前のノードと今のノードを見るだけでよかったが、trigram ではそうはいかない。

単純な解決法として、それぞれのノードで前の履歴を持っておくというものがある。

そうすると次のような図になる。長いので注意。

 か れ が く る ま で ま つ 
|か|
|蚊|
|彼  |
|枯れ |
|か|レ|
|蚊|レ|
|彼  |が|
|枯れ |が|
  |レ|が|
|彼  |蛾|
|枯れ |蛾|
  |レ|蛾|
|彼  |学  |
|枯れ |学  |
  |レ|学  |
|彼  |額  |
|枯れ |額  |
  |レ|額  |
    |が|区|
    |蛾|区|
    |が|来る |
    |蛾|来る |
    |が|繰る |
    |蛾|繰る |
    |が|車    |
    |蛾|車    |
    |学  |流|
    |額  |流|
      |区|流|
      |来る |間|
      |繰る |間|
        |流|間|
      |来る |まで |
      |繰る |まで |
        |流|まで |
      |車    |で|
          |間|で|
      |車    |出|
          |間|出|
      |車    |デマ |
          |間|デマ |
            |デマ |
          |まで |間|
            |で|間|
            |出|間|
          |まで |待つ |
            |で|待つ |
            |出|待つ |
          |まで |松  |
            |で|松  |
            |出|松  |
            |デマ |津|
              |間|津| 

違う履歴を持つものは違うノードとみなすことになり、例えば「彼=が」(図中では「|彼|が|」)と「枯れ=が」は別ノードになる。

これにノードの番号を付けると次のようになる。

 か れ が く る ま で ま つ 
|か|1
|蚊|2
|彼  |3
|枯れ |4
|か|レ|5
|蚊|レ|6
|彼  |が|7
|枯れ |が|8
  |レ|が|9
|彼  |蛾|10
|枯れ |蛾|11
  |レ|蛾|12
|彼  |学  |13
|枯れ |学  |14
  |レ|学  |15
|彼  |額  |16
|枯れ |額  |17
  |レ|額  |18
    |が|区|19
    |蛾|区|20
    |が|来る |21
    |蛾|来る |22
    |が|繰る |22
    |蛾|繰る |23
    |が|車    |24
    |蛾|車    |25
    |学  |流|26
    |額  |流|27
      |区|流|28
      |来る |間|29
      |繰る |間|30
        |流|間|31
      |来る |まで |32
      |繰る |まで |33
        |流|まで |34
      |車    |で|35
          |間|で|36
      |車    |出|37
          |間|出|38
      |車    |デマ |39
          |間|デマ |40
          |まで |間|41
            |で|間|42
            |出|間|43
          |まで |待つ |44
            |で|待つ |45
            |出|待つ |46
          |まで |松  |47
            |で|松  |48
            |出|松  |49
            |デマ |津|50
              |間|津|51

ここで、例えば 「車=で(35)」ノードへの連接を考える。

bigram の場合と違い、位置だけで連接を見ることはできない。

「車=」の部分が履歴、「で」の部分がノードの本体なので、開始位置は「かれがくるま|でまつ」の「|」の位置。この位置で終わるノードは「が=車(24)」、「蛾=車(25)」、「来る=間(29)」、「繰る=間(30)」、「流=間(31)」だが、「車=で」に接続できるのは、「車」を最後に持つ「が=車(24)」、「蛾=車(24)」だけ。「が=車(24)」から「車=で(35)」への連接時には「が-車-で」という trigram の確率が使え、他の連接も同様。

そのような連接可能な左ノードの中で、最適な左ノードを決める。

それをすると、例えば次のようになる。

    か れ が く る ま で ま つ 
   |か|1
   |蚊|2
   |彼  |3
   |枯れ |4
  1|か|レ|5
  2|蚊|レ|6
  3|彼  |が|7
  4|枯れ |が|8
    5|レ|が|9
  3|彼  |蛾|10
  4|枯れ |蛾|11
    5|レ|蛾|12
  3|彼  |学  |13
  4|枯れ |学  |14
    5|レ|学  |15
  3|彼  |額  |16
  4|枯れ |額  |17
    5|レ|額  |18
      7|が|区|19
     10|蛾|区|20
      7|が|来る |21
     10|蛾|来る |22
      7|が|繰る |22
     10|蛾|繰る |23
      7|が|車    |24
     10|蛾|車    |25
     13|学  |流|26
     16|額  |流|27
       19|区|流|28
       21|来る |間|29
       22|繰る |間|30
         26|流|間|31
       21|来る |まで |32
       22|繰る |まで |33
         13|流|まで |34
       24|車    |で|35
           29|間|で|36
       24|車    |出|37
           29|間|出|38
       24|車    |デマ |39
           29|間|デマ |40
           32|まで |間|41
             35|で|間|42
             37|出|間|43
           32|まで |待つ |44
             35|で|待つ |45
             37|出|待つ |46
           32|まで |松  |47
             35|で|松  |48
             37|出|松  |49
             39|デマ |津|50
               42|間|津|51

後は、bigram の時と同じように最後から順番に最適な左ノードを見ていけば、最適なパスを決めることができる。この例でいうと、文末から見た最適な左ノードが「まで=待つ(44)」だとすると、最適パスは「彼(3)-彼=が(7)-が=来る(21)-来る=まで(32)-まで=待つ(44)」となり、漢字かな混じり文は「彼が来るまで待つ」となる。


で、この trigram のデコード法は「使える」のか。

使えない。

なぜか。


ここでのノード数を見ると、そこまで多くなっていないように見える(23->51)。

しかし、実際はとてもこんなものでは済まない。

ここでの例では省略しているが、例えば「ま」だけでも、実際には魔・真・馬…等多くの候補がある。他のかな列にしても同様。N-gram の次数を 1 増やすたびに、その候補数倍ぐらいにノード数が増え、計算量もそれに合わせて増大する。


じゃあ、どうすればいいか。

ここで、trigram デコード時のノードをよく見てみる。

…この「彼=蛾」ってノード、本当に要るの?

これだけじゃない。いかにも要らなそうなノードは他にもいっぱいある。「蛾=繰る」とか、「デマ=津」とか。


でも、「彼=蛾」ってノードを取っておかないと、例えば「彼-蛾-来る」という trigram 確率を後で計算するときに困るじゃん! と言われるかもしれない。

なるほど、もっともだ。

でも、その「彼-蛾-来る」って、本当にちゃんと trigram として計算するの?

どうせ存在しないから「彼-蛾」の backoff score 求めようとして、それも存在しないから結局「蛾-来る」の bigram でお茶濁すんじゃないの?


もし、「彼=蛾」という bigram が存在しなければ、このことは事前にわかる。

だったら、最初から「彼=蛾」とか「枯れ=蛾」といった bigram の存在しないノードは作らず、「蛾」というノードにまとめてしまえばいい。

存在しない bigram (ここでは適当に決めた)ではノードを作らないとすると、図は次のようになる。

 か れ が く る ま で ま つ 
|か|
|蚊|
|彼  |
|枯れ |
  |レ|
|彼  |が|
  |レ|が|
    |が|
    |蛾|
    |学  |
    |額  |
    |が|区|
      |区|
    |が|来る |
    |蛾|来る |
      |繰る |
    |が|車    |
      |車    |
        |流|
      |来る |間|
          |間|
      |来る |まで |
      |繰る |まで |
          |まで |
      |車    |で|
            |で|
      |車    |出|
            |出|
            |デマ |
          |まで |間|
            |で|間|
              |間|
          |まで |待つ |
            |で|待つ |
              |待つ |
          |まで |松  |
            |で|松  |
              |松  |
                |津|

具体的な作り方は、

  1. ある位置から始まる検索結果(3文字目だとすると「が」、「蛾」、「学」、「額」等)のそれぞれについて、その位置で終わる既存ノードとくっつけて N-gram による確率を求める
  2. 存在した N-gram を超える分の履歴を削る(「彼-蛾」で N-gram 検索して、「蛾」の unigram しか見つからなかったら「彼」は捨てる)。また、最高次まで見つかった場合は最初のひとつだけ捨てる(trigram モデルの場合、「彼-が-来る」という trigram が見つかっても、すべて記憶するのではなく「彼」は捨てる。次に使うのは「が-来る-まで」といった trigram になり、「彼」は使わない)。
  3. 削った結果を新しいノードとする。もしそのノードがすでに存在していたら、既存ノードの確率と今計算した確率とを比較して、今のものが高ければ上書きする。

上の図にノード番号を振ると、次のようになる。

 か れ が く る ま で ま つ 
|か|1
|蚊|2
|彼  |3
|枯れ |4
  |レ|5
|彼  |が|6
  |レ|が|7
    |が|8
    |蛾|9
    |学  |10
    |額  |11
    |が|区|12
      |区|13
    |が|来る |14
    |蛾|来る |15
      |繰る |16
    |が|車    |17
      |車    |18
        |流|19
      |来る |間|20
          |間|21
      |来る |まで |22
      |繰る |まで |23
          |まで |24
      |車    |で|25
            |で|26
      |車    |出|27
            |出|28
            |デマ |29
          |まで |間|30
            |で|間|31
              |間|32
          |まで |待つ |33
            |で|待つ |34
              |待つ |35
          |まで |松  |36
            |で|松  |37
              |松  |38
                |津|39

さらに、選ばれた最適な左ノードは次のようになる。

  か れ が く る ま で ま つ 
 |か|1
 |蚊|2
 |彼  |3
 |枯れ |4
  1|レ|5
3|彼  |が|6
  1|レ|が|7
    4|が|8
    3|蛾|9
    3|学  |10
    3|額  |11
    6|が|区|12
      9|区|13
    6|が|来る |14
    9|蛾|来る |15
      6|繰る |16
    6|が|車    |17
      9|車    |18
       10|流|19
     14|来る |間|20
         16|間|21
     14|来る |まで |22
     16|繰る |まで |23
         19|まで |24
     17|車    |で|25
           20|で|26
     17|車    |出|27
           20|出|28
           17|デマ |29
         22|まで |間|30
           25|で|間|31
             27|間|32
         22|まで |待つ |33
           25|で|待つ |34
             27|待つ |35
         22|まで |松  |36
           25|で|松  |37
             27|松  |38
               29|津|39

これで完成…なのだが、まだ無駄がある。

例えば、「まで=待つ(33)」の履歴、「まで=」は、実際には持つ必要がない。最適な左ノードが「来る=まで(22)」ということを覚えているので。

それでは省略してしまおう。その代わり、そのノードの N-gram 次数だけ覚えておくことにする。そうすると、次のようになる。

    か れ が く る ま で ま つ 
   |か|1
   |蚊|2
   |彼  |3
   |枯れ |4
  1/1|レ|5
    3/2|が|6
    5/2|が|7
    4/1|が|8
    3/1|蛾|9
    3/1|学  |10
    3/1|額  |11
      6/2|区|12
      9/1|区|13
      6/2|来る |14
      9/2|来る |15
      6/1|繰る |16
      6/2|車    |17
      9/1|車    |18
       10/1|流|19
         14/2|間|20
         16/1|間|21
         14/2|まで |22
         16/2|まで |23
         19/1|まで |24
           17/2|で|25
           20/1|で|26
           17/2|出|27
           20/1|出|28
           17/1|デマ |29
             22/2|間|30
             25/2|間|31
             27/1|間|32
             22/2|待つ |33
             25/2|待つ |34
             27/1|待つ |35
             22/2|松  |36
             25/2|松  |37
             27/1|松  |38
               29/1|津|39

「/」の後ろが次数。これがあることで、33番の「待つ」は bigram ノードで、実際は 22番と合わせて「まで=待つ」というノードだということがわかる。それに対して、35番の「待つ」は unigram ノードで、bigram が存在しない場合のノードだということがわかる。
(厳密には、この次数は必要がない。例えば実際に unigram ノードの左ノードを見に行けば、bigram が存在しないということは後からでもわかる)

このようにすることで、例えば 4-gram モデルでデコードしても、そこまで遅くならないようになっている。4-gram デコードをナイーブにやったら、時間がかかってしょうがないはず。

(追記:一番上の図で「車」のノード忘れたけど本筋に関わらないからまあいいか)