Connectionist Temporal Classification (CTC) を用いた音素認識

# はじめに
ほとんどの音声データベースには時間情報がなく、発話内容しか与えられていません。これは、音素の時間情報を同定する作業が非常に高コストだからです。このため、ニューラルネットを用いた音声認識は、これまでは学習時にHMM(隠れマルコフモデル)を用いた従来の音声認識を援用し、はじめに各音素の区間を音声認識によって推定する必要がありました。

これに対し、近年ニューラルネットだけで音声認識を可能とする枠組がいくつか提案されています。CTC (Connectionist Temporal Classification) は、そのようなアルゴリズムの1つです。

CTCの学習では、通常の交差エントロピー損失ではなく、縮約すると正解音素列と一致するような全ての音素列の確率の和をネットワークの出力の「望ましさ」と考え、その対数にマイナスを付けた関数を損失関数とします。例えば、縮約すると /h a i/ という音素列と一致するような長さ5の音素列を全て挙げると

- \_\_hai
- \_h\_ai
- \_ha\_i
- \_hai\_
- h\_\_ai
- h\_a\_i
- h\_ai\_
- ha\_\_i
- ha\_i\_
- hai\_\_
- \_haii
- h\_aii
- ha\_ii
- haii\_
- \_haai
- h\_aai
- haa\_i
- haai\_
- \_hhai
- hh\_ai
- hha\_i
- hhai\_
- haiii
- haaii
- haaai
- hhaii
- hhaai
- hhhai

となります。ただし _ はどの音素でもない記号で、ブランクと呼びます。

音響的特徴の確率分布モデル＋音素の時系列モデル＋言語モデルといった、複数のモジュールを組み合わせて構築していた従来の音声認識システムと異なり、CTCでは音響的特徴から音素／音節／単語を直接出力する音声認識システムを構築できます。この文書では、小規模なデータからでも構築できるよう、CTCを用いた音素認識の例を示すことを目的としています。

深層学習フレームワークとしては、 Chainer を採用します。Chainer では CTC のための関数 [`connectionist_temporal_classification`](http://docs.chainer.org/en/stable/reference/generated/chainer.functions.connectionist_temporal_classification.html) が予め定義されているので便利です。というより、この文書は Chainer の `connectionist_temporal_classification` の利用例がどこにもないので、自分でやってみた結果をまとめたものです。

# 音声データ
今回は、日本語の連続音声データとして、名工大音声データベース "NIT ATR503 M001" を利用します。GalateaTalkのページからダウンロードしてください。
https://ja.osdn.net/projects/galateatalk/releases/22207

# ラベルデータから音素リストを作る
各発話の音素転記が 音声データのディレクトリ atr_503 の下の label/monophone の下に入っているので、これを全部読み込んで音素の集合を作っておきます。促音は、私の趣味で /Q/ に変えておきました。

Scikit-learn の LabelEncoder を使って、音素から整数への変換テーブルを作っておきます。

```python:
from os.path import join, splitext, basename
from glob import glob
from sklearn.preprocessing import LabelEncoder

dbhome = "atr_503"

phones = set()
for labfn in (glob(join(dbhome,"label","monophone","*.lab"))):
    with open(labfn) as f:
        phs = f.read().splitlines()
        phones.update(['Q' if ph == 'cl' else ph for ph in phs])

phones.add('_')
nsymbol = len(phones)
labelEncoder = LabelEncoder()
labelEncoder.fit(list(phones))
```

# データセットの作成
音声データは16ビットPCMなので、shortとして読み込んでから $[-1,1]$ にスケーリングしておきます。

音声認識をする時には波形の情報はあまり役立たないので、まず波形から音声の特徴をよく表す特徴を抽出するのが一般的です。今回は、パワーと12次のMFCC(メル周波数ケプストラム係数)の合計13次元の特徴、およびそれらの傾き(差分)の、計26次元の特徴を使います。

MFCCの抽出に先立って、 $1-0.97z^{-1}$ のプリエンファシスフィルタをかけておきます。librosa の mfcc 関数と delta 関数を使って13次のMFCCとその傾きを計算し、つなげて26次元の特徴ベクトル列 `X` を作ります。教師信号となる音素列 `labs` は、 音素転記を LabelEncoder で整数に変換して作ります。`X, labs` を発話の数だけつなげて仮データセットを作ります。
 
その後、Scikit-learn の StandardScaler を利用して `X` の標準化（平均0, 分散1）を行い、データセットは完成です。

```python:
import numpy as np
import librosa
from sklearn.preprocessing import StandardScaler

dataset = []
Xs = []

pattern = join(dbhome,"speech","*.ad")
for wavfn in sorted(glob(pattern)):
    wav = np.fromfile(wavfn, dtype=">i2").astype(np.floating) / 32768.0
    preemp = 0.97
    wav = np.append(wav[0], wav[1:] - preemp * wav[:-1])
    mfcc = librosa.feature.mfcc(wav, sr=16000, n_mfcc = 13, \
                                n_fft = 400, hop_length = 80)
    mfcc_d = librosa.feature.delta(mfcc)
    X = np.hstack([mfcc.T, mfcc_d.T]).astype(np.float32)
    Xs.append(X)
    bn, ext = splitext(basename(wavfn))
    labfn = join(dbhome,"label","monophone","{}.lab".format(bn))
    with open(labfn) as f:
        monophonelab = f.read().splitlines()
        monophonelab = ['Q' if ph == 'cl' else ph for ph in monophonelab]
    labs = labelEncoder.transform(monophonelab)
    dataset.append((X, labs))

standardScaler = StandardScaler()
standardScaler.fit(np.concatenate(Xs, axis=0))

dataset = [(standardScaler.transform(X), labs) for X, labs in dataset]
```
伝統に則り、AセットからIセットまでの450文を訓練セットに、Jセットの53文をテストセットにします。

```python:
train = dataset[0:450]
test = dataset[450:]
```

# ネットワーク構造の記述

ネットワークのクラス `RNN` を定義します。今回のネットワークは、入力層から1番目の隠れ層が線形変換+ReLU, 次が双方向LSTM, 最後に出力層まで線形変換、という構造を持っています。`n_lstm_layers`は、双方向LSTMを何層重ねるかのパラメータです。

`l1`は入力から1番目の隠れ層への結合を表しており、実体は $F$ 次元ベクトルから $H$ (=`n_mid_units`) 次元ベクトルへの線形変換(正確にいうとアフィン変換)ですから、  $F\times H$ 行列です。

`l2`は双方向LSTMです。音声は時系列ですから、音素認識のためには記憶を持つニューラルネットが必要です。今回は記憶を持つニューラルネットをLSTM (Long-Short Term Memory)で構成します。双方向LSTMは、時間的に順方向の記憶と逆方向の記憶を別個のユニットで保持するので、出力の次元数は `out_size` の2倍(ここでは $2H$)になります。

`l3`は2番目の隠れ層から出力層への結合を表す $2H\times D$ 行列です。($D$ は音素の数)

```python:
import chainer
import chainer.links as L
import chainer.functions as F

class RNN(chainer.Chain):

    def __init__(self, n_lstm_layers, n_mid_units, n_out, dropout=0.1):
        super(RNN, self).__init__()

        with self.init_scope():
            self.l1 = L.Linear(None, n_mid_units)
            self.l2 = L.NStepBiLSTM(n_lstm_layers,
                                    in_size=n_mid_units, \
                                    out_size=n_mid_units,
                                    dropout=dropout)
            self.l3 = L.Linear(n_mid_units * 2, n_out)

    def __call__(self, x):
        # forward calculation
        h1 = [ F.relu(self.l1(X)) for X in x ]
        hy, cy, ys = self.l2(None, None, h1)
        h2 = F.concat(ys, axis=0)
        return self.l3(h2)
```
`converter`では、上記`RNN`で読み込めるよう、データを行列のリストに変換しています。

```python:
from chainer.dataset import to_device
from chainer.dataset import concat_examples
import numpy as np

def converter(batch, device):
    # alternative to chainer.dataset.concat_examples
    
    Xs = [to_device(device, X) for X, _ in batch]

    lab_batch = [lab.astype(np.int32) for _, lab in batch]
    labs = concat_examples(lab_batch, device, padding=0)
    
    return Xs, labs
```

# 損失関数

CTCの損失関数は、`connectionist_temporal_classification`関数で計算できます。この関数は、フレームごとでなく発話全体に対して計算されます。各フレームの正解音素ラベルを必要とせず、発話全体の音素ラベル列 `lab` さえあればよい点が重要なポイントです。

```python:
import numpy as np
from chainer.backends import cuda

def calculate_loss(model, batch, blank_symbol, gpu_id=0, train_mode=True):
    numframes = [X.shape[0] for (X, lab) in batch]
    
    # lab を行列に変形
    label_length = xp.array([len(lab) for (X, lab) in batch],dtype=np.int32)
    Xs, labs = converter(batch, gpu_id)

    with chainer.using_config('train', train_mode), \
         chainer.using_config('enable_backprop', train_mode):
        ys = net(Xs)
    
    # 発話ごとに分割
    split_point = np.cumsum(numframes)[:-1]
    y4utt = F.split_axis(ys, split_point, axis=0)
    input_length = xp.array(numframes, dtype=np.int32)
    
    # 第iフレームの確率行列(B,V)となるよう整形
    y4utt = F.pad_sequence(y4utt)
    y4frame = F.stack(y4utt, axis=1)
    x = [xi for xi in y4frame]
    
    # ロスの計算
    loss = F.connectionist_temporal_classification(x, labs, blank_symbol, input_length, label_length)

    return loss
```
乱数を初期化する関数を定義します。

```python:
import random
import numpy as np
import chainer

def reset_seed(seed=0):
    random.seed(seed)
    np.random.seed(seed)
    if gpu_id >= 0:
        chainer.cuda.cupy.random.seed(seed)
```

# ネットワークの学習

以下がネットワークを学習するプログラムです。

設定可能なパラメータを上の方にまとめておきました。

1. `gpu_id`:
0にするとGPUで実行します。GPUがない場合は-1とします。
1. `batchsize`:
確率的勾配降下法(SGD)などを用いて学習する場合には、データをいくつかの固まり(ミニバッチ)に分割し、それぞれのミニバッチで1回のパラメータ更新を実行します。`batchsize`はミニバッチの大きさで、GPUに乗る限り大きくした方が計算が速くなります。
1. `max_epoch`:
学習データ全体にわたってミニバッチ学習を1回終えることを1エポックといいます。`max_epoch`は学習回数で、何エポック学習するかを設定します。
1. `n_mid_units`:
中間層ユニット数です。

whileループが学習のループです。ニューラルネットの学習で使われる勾配降下法は繰り返しアルゴリズムですので、何度も学習を繰り返してパラメータを調整します。

ミニバッチに対して損失 `loss` を計算します。

ニューラルネットの学習では、一般に誤差逆伝播アルゴリズムが使われます。`loss` に対して `backward()` メソッドを実行すると、誤差逆伝播アルゴリズムによりパラメータ更新に必要な誤差が計算されます。次に `optimizer.update()` メソッドを実行すると、計算された誤差をもとにパラメータが更新されます。これでミニバッチ1回の学習が終わりです。

```python:
import numpy as np
from chainer import iterators
from chainer import optimizers
from chainer.backends import cuda
from chainer.cuda import to_cpu
from chainer.dataset import concat_examples

gpu_id = 0 # CPUを用いる場合は、この値を-1にしてください

batchsize = 100
max_epoch = 200

xp = np
if gpu_id >= 0:
    xp = chainer.cuda.cupy

reset_seed(1234) # 乱数の種をセット

train_iter = iterators.SerialIterator(train, batchsize)

net = RNN(n_lstm_layers=1, n_mid_units=200, n_out=nsymbol, dropout=0.1)

optimizer = optimizers.Adam().setup(net)

if gpu_id >= 0:
    net.to_gpu(gpu_id)

blank_symbol = np.asscalar(labelEncoder.transform(['_'])[0])

while train_iter.epoch < max_epoch:

    train_batch = train_iter.next()

    loss = calculate_loss(net, train_batch, blank_symbol, gpu_id, train_mode = True)

    net.cleargrads()
    loss.backward()

    loss.unchain_backward() # for efficient memory use

    optimizer.update()
    
    if train_iter.is_new_epoch:
        print('epoch:{:02d} train_loss:{:.04f} '.format(
            train_iter.epoch, float(to_cpu(loss.data))), end='')

        valid_losses = []
        
        loss_valid = calculate_loss(net, test, blank_symbol, gpu_id, train_mode=False)
        valid_losses.append(to_cpu(loss_valid.array))

        print('val_loss:{:.04f}'.format(np.mean(valid_losses)))
```

# 音素認識結果の観察
```python:
test_utterance_number = 0

if gpu_id >= 0:
    net.to_gpu(gpu_id)

Xs, _ = converter(test, gpu_id)

# テストデータを1つ取り出します
X_test = Xs[test_utterance_number]
_, lab_test = test[test_utterance_number]

with chainer.using_config('train', False), \
     chainer.using_config('enable_backprop', False):
    y_test = net([X_test])

y_test = y_test.array
y_test = to_cpu(y_test)

# 各フレームに対し確率最大となる音素番号を求める
pred_label = y_test.argmax(axis=1)
```

テストデータの0番目 (Jセット第1文「小さな鰻屋に、熱気のようなものがみなぎる。」) の正解音素列は次の通りです。

```python:
>>> labelEncoder.inverse_transform(lab_test)
array(['sil', 'ch', 'i', 'i', 's', 'a', 'n', 'a', 'u', 'n', 'a', 'g', 'i',
       'y', 'a', 'n', 'i', 'pau', 'n', 'e', 'Q', 'k', 'i', 'n', 'o', 'y',
       'o', 'o', 'n', 'a', 'm', 'o', 'n', 'o', 'g', 'a', 'm', 'i', 'n',
       'a', 'g', 'i', 'r', 'u', 'sil'], dtype='<U3')
```

これに対し、まず各フレームに対する音素認識結果を見てみます。

```python:
>>> labelEncoder.inverse_transform(pred_label)
array(['_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'sil', 'sil',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'ch', 'ch', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', 'i', 'i', 'i', 'i', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'i', 'i', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', 's', 's', 's', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', 'a', 'a', 'a', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', 'n', 'n', 'n', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', 'a', 'a', 'a', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'N', 'N', 'N',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       'n', 'n', 'n', '_', '_', '_', '_', '_', '_', 'a', 'a', 'a', 'a',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', 'g', 'g', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', 'i', 'i', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       'a', 'a', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'n', 'n', 'n', 'n', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', 'i', 'i', 'i', 'i', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'pau', 'pau', 'pau', '_', '_', '_', '_', '_', '_',
       '_', 'm', 'm', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'e', 'e', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', 'Q', 'Q', 'Q', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', 'k', 'k', 'k', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', 'i', 'i', 'i', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', 'n', 'n', 'n', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'e',
       'e', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'o', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'o', 'o', 'o',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', 'n', 'n',
       'n', '_', '_', '_', '_', '_', '_', 'a', 'a', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', 'm', 'm', 'm', 'm', '_', '_', '_',
       '_', '_', '_', '_', 'o', 'o', 'o', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', 'n', 'n', 'n', '_', '_', '_', '_', '_', '_',
       'o', 'o', 'o', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       'g', 'g', '_', '_', '_', '_', '_', '_', '_', 'a', 'a', 'a', 'a',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', 'm', 'm', 'm', '_', '_', '_', '_', '_', 'i', 'i', 'i',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', 'n', 'n', 'n', '_',
       '_', '_', '_', '_', '_', '_', 'a', 'a', 'a', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', 'g', 'g', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       'i', 'i', 'i', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', 'r', 'r', 'r', '_', '_', '_', '_', '_', '_', '_', '_', 'u',
       'u', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_', '_',
       '_', '_', '_', '_', '_', '_', '_', '_', '_', 'sil'], dtype='<U3')
```

多くのフレームで _ (ブランク) が出力されているのがわかります。これは、最も「自信がある」フレームに対してだけ具体的な音素が出力され、音素境界に近い曖昧な部分では何も出力していないものと解釈することができます。

出力された冗長な音素列を縮約することで、次のように最終的な音素認識結果が得られます。

```python:
>>> mask = pred_label[:-1] != pred_label[1:]
>>> tmp_label = pred_label[np.append(mask,True)]
>>> mask = tmp_label != blank_symbol
>>> labelEncoder.inverse_transform(tmp_label[mask])
array(['sil', 'ch', 'i', 'i', 's', 'a', 'n', 'a', 'N', 'n', 'a', 'g', 'i',
       'a', 'n', 'i', 'pau', 'm', 'e', 'Q', 'k', 'i', 'n', 'e', 'o', 'o',
       'n', 'a', 'm', 'o', 'n', 'o', 'g', 'a', 'm', 'i', 'n', 'a', 'g',
       'i', 'r', 'u', 'sil'], dtype='<U3')
```

「小さなンなぎ屋」 （「ンマーイ」みたい？）のように、ところどころ間違ってはいるものの、うまく行ったようですね。


#参考文献
1. 河原達也 “[音声認識技術の変遷と最先端－深層学習によるEnd-to-Endモデル－](https://www.jstage.jst.go.jp/article/jasj/74/7/74_381/_article/-char/ja)”， 日本音響学会誌, **74** (7) 381–386