曖昧な日本語判定を作って音声認識結果を判定させてみる

音声認識を使ったアプリをつくるなかで、音声認識アプリの、**漢字、カタカナ・ひらがな混じりに**悩まされたので、推定プログラムを書いて見ました。

# 日本語の音声性認識の漢字誤変換問題
日本語の文章の音声認識で難しいポイントに、漢字の誤変換問題があります。文章の構造としては正しく認識されているのに、漢字が意図しない変換になってるために、単純には正誤判定ができない問題があります。

#### 例題
- 正解:  鈴木さんに代わって**もらおう**と思います
- 認識結果: 鈴木さんに代わって**貰おう**と思います。

今回作ってるアプリでは、複数の選択肢の日本語の中から、正解をテキストで提示し音声入力で読み上げて選択肢を選ぶタイプだったため、提示するテキストデータを使って、どの選択肢が読み上げられたのかを推定しました。

#### 選択肢
 - 1. 田中さんに代わってもらいます。
 - 2. 鈴木さんに代わってもらいます。
 - 3. 私が代わりに出席します
 - 4. やっぱりやめます

#### 音声認識の結果
  鈴木さんに変って貰います

期待値は選択肢2の「鈴木さんに代わってもらいます」が選ばれることです。

# 実装方針
認識結果の文字列と選択肢(choice)の一致する文字数をスコアとしてもっとも一致数の多い選択肢を選びます。

| 選択肢 | 一致する部分(太字)| スコア |
|:-----------:|:------------:|:------------:|
| 田中さんに代わってもらいます| 田中**さんに**代わ**って**もら**います**。 |    8     |
| 鈴木さんに代わってもらいます| **鈴木さんに**代わ**って**もら**います** |    10     |
| 私が代わりに出席します| 私が**代**わりに出席し**ます** |    3     |
| やっぱりやめます| やっぱりやめ**ます** |    2     |

これを実現するために認識結果の文字列を、各選択肢の文字の出現位置で置き換えていきます。見つからない文字は-1で埋めます。

| 選択肢 | 出現位置| スコア |
|:-----------:|:------------:|:------------:|
| 田中さんに代わってもらいます| [-1, -1, 2, 3, 4, -1, 7, 8, -1, 12, 13, 14 ] |    8     |
| 鈴木さんに代わってもらいます|[0, 1, 2, 3, 4, -1, 7, 8, -1, 12, 13, 14 ] |    10     |
| 私が代わりに出席します| [-1, -1, -1, -1, -1, -1, -1, -1, -1, 12, 13, 14 ]|    3     |
| やっぱりやめます| [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 13, 14 ] |2  |


# 実装
アプリをUnityで実装している都合上、言語はC#ですが、SwiftやKotlnにも簡単に移植できると思います。
コードの全容はGistで公開しています(https://gist.github.com/sarukun99/3e11a08c9b9aef0e07164fcf47bcff88)

#### 1.転置インデックスクラスを作る
選択肢の１文字をkey、出現位置の配列をValueとするハッシュを内部に文字、ある文字Xを渡した時に、その出現位置を返すメソッドを持つクラスを作成します

```c#:InversededIndex.cs

 class InversedIndex {

        public string source;

        public InversedIndex(List<string> source) {
            for (var i = 0; i < source.Count(); i ++ ) {
                var key = source[i];
                if (dict.ContainsKey(key) == false)
                {
                    dict[key] = new List<int>();
                }
                dict[key].Add(i);
            }
            this.source = string.Join("", source.ToArray());
        }

        public int[] Positions(string s) {
            if (dict.ContainsKey(s) == false ) {
                return new int[] {};
            } else {
                return dict[s].ToArray();
            }
        }

        private Dictionary<string, List<int>> dict = new Dictionary<string, List<int>>();
    }
```

#### 2. Pathクラスを作成する
転置インデックスを使って、選択肢上の出現位置を保持するPathクラスを作成します。
コンストラクターで、使用するindexとsearch上の位置を渡します。

Selectメソッドに、元文章中の出現位置posと文字sを渡します。文字を使って転置インデックス
から、位置の候補を選択しもっとも近い場所を採用します。枝狩りされたパスは後述する、全パスを作成することでカバーしています。

```c#:Path.cs
class Path {

        public Path(InversedIndex index, int node, int pos){
            mPos = pos;
            mNodes.Add(node);
            mIndex = index;
        }

        public void Select(int pos, string s) {
            if (pos < mPos) { return; }
            var newPos = mIndex.Positions(s)
                               .Where(p => mPos < pos)
                               .OrderBy(p => pos - mPos)
                               .FirstOrDefault();
            if (newPos > 0)
            {
                mNodes.Add(newPos);
            } else {
                mNodes.Add(-1);
            }
        }

        public float Score() {
            return mNodes.Where(node => node > -1).Count() / mIndex.source.Length;
        }

        public string Source() {
            return mIndex.source;
        }

        private InversedIndex mIndex;
        private List<int> mNodes = new List<int>();
        private int mPos = 0;
    }
```

### 3. 選択肢を推定する
この2つのクラスを使って選択肢を推定します。まずはじめに、全ての選択肢の転置インデックスを作成します。次に認識文字列を１文字づつずらしながら、Pathクラスを生成します。
これは認識結果の先頭に無関係の文章が入ることがあるため、下の展開の例のように、search途中から始まるPathを用意しています。また、認識文字列の１文字が複数の出現位置を持つ場合は、その全てについてPathを作成することで、途中分岐の枝狩りによる欠落をカバーします。


```text:文字列の展開例
鈴木さんに変って貰います
木さんに変って貰います
さんに変って貰います
んに変って貰います
に変って貰います
変って貰います
って貰います
て貰います
貰います
います
ます
```


```c#:TextPredicator
 public class TextPredicator {
    
    public static void Predicate(List<string> search, List<List<string>> choices) 
    {

        //全ての候補の転置インデックスを作成する
        var indexs = choices.Select(choice => new InversedIndex(choice));


        // 初期のPathを作成する
        var paths = new List<Path>();
        for (int i = 0; i < search.Count; i++)
        {
            foreach (var index in indexs)
            {
                var nodes = index.Positions(search[i]);
                foreach (var node in nodes)
                {
                    paths.Add(new Path(index, node, i));
                }
            }
        }

        // Searchの全文字列を各Pathに渡してPathを更新する
        for (int i = 0; i < search.Count; i++)
        {
            foreach (var path in paths)
            {
                path.Select(i, search[i]);
            }
        }

        //一番スコアの高いPathを採択
        var selectedChoice = paths.OrderByDescending(Path => Path.Score()).First().Source();
        Debug.Log(selectedChoice);
    }
}
```
### まとめ
日本語の音声認識の漢字問題は忌まわしき問題ですが、文法構造は正しく取れるようになってきてるため、簡単な推測を挟むことで格段に認識率を向上させることができました。