[TensorFlow 2] RNNをCTC Lossを使って学習してみる

# はじめに

系列を返すRNN (Recurrent Neural Network) のパラメータを、CTC (Connectionist Temporal Classification) Loss を使って学習する方法をTensorFlow 2.xで試しました。
サンプルが少なくて動かすのに苦労したのでメモしておきます。

CTC Lossに関しては以下のページなどにまとまっています。

- [Connectionist Temporal Classificationの理論と実装について – ご注文は機械学習ですか？](http://musyoku.github.io/2017/06/16/Connectionist-Temporal-Classification/)
- [Connectionist Temporal Classification (CTC) を用いた音素認識 - Qiita](https://qiita.com/hirokisince1998/items/f59d3e8434d6326c48cc)
- [音声認識と深層学習 - SlideShare](https://www.slideshare.net/pfi/ss-50580059)

# 検証環境

- Ubuntu 18.04
- Python 3.6.9
- TensorFlow 2.1.0 (CPU)

# 基にしたサンプルコード

[GitHub - igormq/ctc_tensorflow_example: CTC + Tensorflow Example for ASR](https://github.com/igormq/ctc_tensorflow_example)

TensorFlow 1.xで、Keras APIを使わずに実装しているサンプルです。
End-to-Endの音声認識サンプルといった感じで、特徴量系列とラベル（文字）系列の対応をLSTMで学習させています。

1.x版のコードではありますが、TensorFlow 2.xで動かすこと自体は難しくありません。

```bash
# 必要なパッケージをインストール
pip3 install python_speech_features --user
# コードを取得
git clone https://github.com/igormq/ctc_tensorflow_example.git
```

以下のように ``ctc_tensorflow_example.py`` を3行ほど変えればTensorFlow 2.xで動きます。

```patch:patch
diff --git a/ctc_tensorflow_example.py b/ctc_tensorflow_example.py
index 579d431..2d96d54 100644
--- a/ctc_tensorflow_example.py
+++ b/ctc_tensorflow_example.py
@@ -5,7 +5,7 @@ from __future__ import print_function
 
 import time
 
-import tensorflow as tf
+import tensorflow.compat.v1 as tf
 import scipy.io.wavfile as wav
 import numpy as np
 
@@ -20,6 +20,8 @@ except ImportError:
 from utils import maybe_download as maybe_download
 from utils import sparse_tuple_from as sparse_tuple_from
 
 # Constants
 SPACE_TOKEN = '<space>'
 SPACE_INDEX = 0
@@ -103,9 +105,9 @@ with graph.as_default():
     #   tf.nn.rnn_cell.GRUCell 
     cells = []
     for _ in range(num_layers):
-        cell = tf.contrib.rnn.LSTMCell(num_units)  # Or LSTMCell(num_units)
+        cell = tf.nn.rnn_cell.LSTMCell(num_units)  # Or LSTMCell(num_units)
         cells.append(cell)
-    stack = tf.contrib.rnn.MultiRNNCell(cells)
+    stack = tf.nn.rnn_cell.MultiRNNCell(cells)
 
     # The second output is the last state and we will no use that
     outputs, _ = tf.nn.dynamic_rnn(stack, inputs, seq_len, dtype=tf.float32)
```

```:Terminal
python3 ctc_tensorflow_example.py
Epoch 1/200, train_cost = 726.374, train_ler = 1.000, val_cost = 167.637, val_ler = 1.000, time = 0.549
（略）
Epoch 200/200, train_cost = 0.648, train_ler = 0.000, val_cost = 0.642, val_ler = 0.000, time = 0.218
Original:
she had your dark suit in greasy wash water all year
Decoded:
she had your dark suit in greasy wash water all year
```

# TensorFlow 2向けのコードに変換する

せっかくTensorFlow 2を使うならば、TensorFlow 2向けの書き方をしたほうが処理効率も上がりますし（多分）、後々のメンテナンスのためにもよいでしょう。
というわけでサンプルコードを書き換えてみようと思いますが、なかなか書き方のサンプルが見つからない…

色々なところのコードを切り貼りした感じでようやく動きました。
主な参照サイトは以下です。

1. [Effective TensorFlow 2  |  TensorFlow Core](https://www.tensorflow.org/guide/effective_tf2)
2. [TensorFlow 2.0 Alpha : 既存コードを TensorFlow 2.0 に変換する – TensorFlow 2.x](http://tensorflow.classcat.com/2019/03/11/tf20-alpha-guide-migration-guide/)
3. [TensorFlow 2.0 主な変更点 - S-Analysis](http://data-analysis-stats.jp/2019/06/09/tensorflow-2-0-%E4%B8%BB%E3%81%AA%E5%A4%89%E6%9B%B4%E7%82%B9/)

## サンプルコード(1)

```python:ctc_tensorflow_example_tf2.py
#  Compatibility imports
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import time

import tensorflow as tf
import scipy.io.wavfile as wav
import numpy as np

from six.moves import xrange as range

try:
    from python_speech_features import mfcc
except ImportError:
    print("Failed to import python_speech_features.\n Try pip install python_speech_features.")
    raise ImportError

from utils import maybe_download as maybe_download
from utils import sparse_tuple_from as sparse_tuple_from

# Constants
SPACE_TOKEN = '<space>'
SPACE_INDEX = 0
FIRST_INDEX = ord('a') - 1  # 0 is reserved to space

# Some configs
num_features = 13
num_units=50 # Number of units in the LSTM cell
# Accounting the 0th indice +  space + blank label = 28 characters
num_classes = ord('z') - ord('a') + 1 + 1 + 1

# Hyper-parameters
num_epochs = 200
num_hidden = 50
num_layers = 1
batch_size = 1
initial_learning_rate = 1e-2
momentum = 0.9

num_examples = 1
num_batches_per_epoch = int(num_examples/batch_size)

# Loading the data

audio_filename = maybe_download('LDC93S1.wav', 93638)
target_filename = maybe_download('LDC93S1.txt', 62)

fs, audio = wav.read(audio_filename)

inputs = mfcc(audio, samplerate=fs)
# Transform in 3D array
train_inputs = np.asarray(inputs[np.newaxis, :], dtype=np.float32)
train_inputs = (train_inputs - np.mean(train_inputs))/np.std(train_inputs)

train_seq_len = [train_inputs.shape[1]]

# Reading targets
with open(target_filename, 'r') as f:

    #Only the last line is necessary
    line = f.readlines()[-1]

    # Get only the words between [a-z] and replace period for none
    original = ' '.join(line.strip().lower().split(' ')[2:]).replace('.', '')
    targets = original.replace(' ', '  ')
    targets = targets.split(' ')

# Adding blank label
targets = np.hstack([SPACE_TOKEN if x == '' else list(x) for x in targets])

# Transform char into index
targets = np.asarray([SPACE_INDEX if x == SPACE_TOKEN else ord(x) - FIRST_INDEX
                      for x in targets])

train_targets = tf.sparse.SparseTensor(*sparse_tuple_from([targets], dtype=np.int32))

train_targets_len = [train_targets.shape[1]]

# We don't have a validation dataset :(
val_inputs, val_targets, val_seq_len, val_targets_len = train_inputs, train_targets, \
                                                        train_seq_len, train_targets_len


# THE MAIN CODE!

# Defining the cell
# Can be:
#   tf.nn.rnn_cell.RNNCell
#   tf.nn.rnn_cell.GRUCell 
cells = []
for _ in range(num_layers):
    cell = tf.keras.layers.LSTMCell(num_units)  # Or LSTMCell(num_units)
    cells.append(cell)
stack = tf.keras.layers.StackedRNNCells(cells)

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.RNN(stack, input_shape=(None, num_features), return_sequences=True))
# Truncated normal with mean 0 and stdev=0.1
# Zero initialization        
model.add(tf.keras.layers.Dense(num_classes,
                          kernel_initializer=tf.keras.initializers.TruncatedNormal(0.0, 0.1),
                          bias_initializer="zeros"))
optimizer = tf.keras.optimizers.SGD(initial_learning_rate, momentum)

@tf.function
def step(inputs, targets, seq_len, targets_len, flag_training):
    if flag_training:
        with tf.GradientTape() as tape:
            logits = model(inputs, training=True)
            # Time major
            logits = tf.transpose(logits, (1, 0, 2))
            cost = tf.reduce_mean(tf.nn.ctc_loss(targets, logits, targets_len, seq_len, blank_index=-1))

        gradients = tape.gradient(cost, model.trainable_variables)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))
    else:
        logits = model(inputs)
        # Time major
        logits = tf.transpose(logits, (1, 0, 2))
        cost = tf.reduce_mean(tf.nn.ctc_loss(targets, logits, targets_len, seq_len, blank_index=-1))

    # Option 2: tf.nn.ctc_beam_search_decoder
    # (it's slower but you'll get better results)
    decoded, _ = tf.nn.ctc_greedy_decoder(logits, seq_len)

    # Inaccuracy: label error rate
    ler = tf.reduce_mean(tf.edit_distance(tf.cast(decoded[0], tf.int32),
                                          targets))
    return cost, ler, decoded

for curr_epoch in range(num_epochs):
    train_cost = train_ler = 0
    start = time.time()

    for batch in range(num_batches_per_epoch):
        batch_cost, batch_ler, _ = step(train_inputs, train_targets, train_seq_len, train_targets_len, True)
        train_cost += batch_cost*batch_size
        train_ler += batch_ler*batch_size

    train_cost /= num_examples
    train_ler /= num_examples

    val_cost, val_ler, decoded = step(val_inputs, val_targets, val_seq_len, val_targets_len, False)
    log = "Epoch {}/{}, train_cost = {:.3f}, train_ler = {:.3f}, val_cost = {:.3f}, val_ler = {:.3f}, time = {:.3f}"
    print(log.format(curr_epoch+1, num_epochs, train_cost, train_ler,
                     val_cost, val_ler, time.time() - start))
# Decoding
d = tf.sparse.to_dense(decoded[0])[0].numpy()
str_decoded = ''.join([chr(x) for x in np.asarray(d) + FIRST_INDEX])
# Replacing blank label to none
str_decoded = str_decoded.replace(chr(ord('z') + 1), '')
# Replacing space label to space
str_decoded = str_decoded.replace(chr(ord('a') - 1), ' ')

print('Original:\n%s' % original)
print('Decoded:\n%s' % str_decoded)
```

1エポック目だけは時間がかかりますが、それ以降はだいたい3割程度速くなっているようです。

```
python3 ctc_tensorflow_example_tf2.py
Epoch 1/200, train_cost = 774.063, train_ler = 1.000, val_cost = 505.479, val_ler = 0.981, time = 1.547
Epoch 2/200, train_cost = 505.479, train_ler = 0.981, val_cost = 496.959, val_ler = 1.000, time = 0.158
（略）
Epoch 200/200, train_cost = 0.541, train_ler = 0.000, val_cost = 0.537, val_ler = 0.000, time = 0.143
Original:
she had your dark suit in greasy wash water all year
Decoded:
she had your dark suit in greasy wash water all year
```

## 変更点の解説

### tf.Session, tf.placeholder の廃止

オリジナルのコードは、TensorFlow 1.xの ``tf.Session`` をベースとした書き方だったので、TensorFlow 2.xでは（``tf.compat.v1`` APIを使わないと）動きません。``tf.placeholder`` も無くなり、入力された ``Tensor`` を直接操作するコードを書けばよいことになりました。

基本的には、[Effective TensorFlow 2](https://www.tensorflow.org/guide/effective_tf2)で書かれているように ``tf.Session`` と ``tf.placeholder`` の組み合わせを以下のように書き換えます。

```python
# TensorFlow 1.X
outputs = session.run(f(placeholder), feed_dict={placeholder: input})
# TensorFlow 2.0
outputs = f(input)
```

このとき、``f`` をグラフモードで動かすために ``@tf.function`` デコレータを付けておきます[^1]。

[^1]: ``@tf.function`` を付けなくても動きますが、Eager Execution となるため遅いです。デバッグ時には Eager Execution は有用なので ``@tf.function``  を外して（コメントアウトして）おき、動くようになったら ``@tf.function`` を付ける、という方針でよいと思います。

というわけで、オリジナルのコード

```python
# TensorFlow 1.X
feed = {inputs: train_inputs,
        targets: train_targets,
        seq_len: train_seq_len}

batch_cost, _ = session.run([cost, optimizer], feed)
train_cost += batch_cost*batch_size
train_ler += session.run(ler, feed_dict=feed)*batch_size
```

は、原則通りに考えるならば、引数として ``train_inputs, train_targets, train_seq_len`` を与え、戻り値として ``cost, optimizer`` を返すような関数（``@tf.function`` 付き）に書き換えることになります。
ただし、``optimizer`` は実行するだけでよいので値を返す必要はありません。また、直後の ``session.run`` で同じ ``feed`` を与えて ``ler`` を計算していたり、さらに学習が終わったあとのデコード処理で ``decoded`` を使っていたりするので、それらは一緒に返しておきます（``decoded`` は最後の1回しか使っていませんが、どのみち ``ler`` の計算のために内部で ``decoded`` は計算することになるので、無駄な処理というわけではありません（たぶん…））。

```python
# TensorFlow 2.0
@tf.function
def step(inputs, targets, seq_len, targets_len, flag_training):
    （中略）
    return cost, ler, decoded

batch_cost, batch_ler, _ = step(train_inputs, train_targets, train_seq_len, train_targets_len, True)
train_cost += batch_cost*batch_size
train_ler += batch_ler*batch_size
```

検証用途に大半の処理を使い回すため ``step`` という関数名として、追加の引数 ``flag_training`` で学習するかどうかを切り替えるように書きました。
さらに ``targets_len`` という引数が増えていますが、これはTensorFlow 2.xで ``tf.nn.ctc_loss`` に与える引数が変わったためで、Eager Execution化と直接は関係ないはずです。

可変長の正解ラベルを与えるために使っていた ``tf.sparse_placeholder`` も無くなりました。``tf.sparse_placeholder`` にデータを与えるために ``(indices, values, shape)`` のタプルを用意していましたが、外から ``tf.SparseTensor`` を直接指定できるようになったので、自分で ``tf.SparseTensor`` を作ってしまいます。``dtype`` は、元の ``tf.sparse_placeholder`` の型に合わせますが、``tf.int32`` ではなく ``np.int32`` となる点にご注意ください（微妙に引っ掛かるポイント）。

```python
# TensorFlow 1.X
train_targets = sparse_tuple_from([targets])

# TensorFlow 2.0
train_targets = tf.sparse.SparseTensor(*sparse_tuple_from([targets], dtype=np.int32))
```

### 学習部分の変更

TensorFlow 2.xでは、``Optimizer`` はKerasのものを使うように変更となりました。それに合わせて、これまで
``Optimizer.minimize()`` を使っていたところ、TensorFlow 2.xの``GradientTape`` を使うように変更しました。この処理は先ほど定義した ``step()`` の中にあります。

```python
##### TensorFlow 1.X #####
# Time major
logits = tf.transpose(logits, (1, 0, 2))

loss = tf.nn.ctc_loss(targets, logits, seq_len)
cost = tf.reduce_mean(loss)

optimizer = tf.train.MomentumOptimizer(initial_learning_rate,
                                           0.9).minimize(cost)

##### TensorFlow 2.0 #####
optimizer = tf.keras.optimizers.SGD(initial_learning_rate, 0.9)

@tf.function
def step(inputs, targets, seq_len, targets_len, flag_training):
    if flag_training:
        with tf.GradientTape() as tape:
            logits = model(inputs, training=True)
            # Time major                                                                                                                              
            logits = tf.transpose(logits, (1, 0, 2))
            cost = tf.reduce_mean(tf.nn.ctc_loss(targets, logits, targets_len, seq_len, blank_index=-1))

        gradients = tape.gradient(cost, model.trainable_variables)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))
    else:
        （以下略）
```

ここで、勾配計算の対象とする（学習する）重みのリストを与える必要があるのですが、手動で定義した ``tf.Variable`` を集めるのは面倒なので、自分でモデルの計算グラフを書いていた部分を ``tf.keras.Model`` に変えてしまいました。これにより ``model.trainable_variables`` で学習する重みのリストを簡単に取得できるようになります。また、計算グラフの作成部分も簡単になります。

```python
##### TensorFlow 1.X #####
# The second output is the last state and we will no use that
outputs, _ = tf.nn.dynamic_rnn(stack, inputs, seq_len, dtype=tf.float32)

shape = tf.shape(inputs)
batch_s, max_timesteps = shape[0], shape[1]

# Reshaping to apply the same weights over the timesteps
outputs = tf.reshape(outputs, [-1, num_hidden])

# Truncated normal with mean 0 and stdev=0.1
# Tip: Try another initialization
# see https://www.tensorflow.org/versions/r0.9/api_docs/python/contrib.layers.html#initializers
W = tf.Variable(tf.truncated_normal([num_hidden,
                                     num_classes],
                                    stddev=0.1))
# Zero initialization
# Tip: Is tf.zeros_initializer the same?
b = tf.Variable(tf.constant(0., shape=[num_classes]))

# Doing the affine projection
logits = tf.matmul(outputs, W) + b

# Reshaping back to the original shape
logits = tf.reshape(logits, [batch_s, -1, num_classes])

##### TensorFlow 2.0 #####
model = tf.keras.models.Sequential()
model.add(tf.keras.layers.RNN(stack, input_shape=(None, num_features), return_sequences=True))
# Truncated normal with mean 0 and stdev=0.1
# Zero initialization        
model.add(tf.keras.layers.Dense(num_classes,
                          kernel_initializer=tf.keras.initializers.TruncatedNormal(0.0, 0.1),
                          bias_initializer="zeros"))
```

サンプルの次元を含めて3階（3次元）以上のテンソルが ``tf.keras.layers.Dense`` に入力される場合、

- 最後の次元以外を一旦 Flatten し
- 重み行列を右から掛けて
- 計算後に元の形に戻す

といった動作になります。オリジナルのコードでは重みを書ける前後の形状操作を自分で書いていましたが、それもKerasに丸投げでよいので非常に楽です。

### その他

``dtype`` を指定して、特徴量の型を ``float32`` としました。指定しなくても動きますが WARNING が発生します。

```python
train_inputs = np.asarray(inputs[np.newaxis, :], dtype=np.float32)
```

# 可変長データを扱えるように改良

先ほどのサンプルコード(1)では学習データが1件しかありませんでしたが、もちろん実際はミニバッチに複数件のデータを入れて学習したいわけです。入力データも正解ラベル系列も長さがまちまちになるので、うまく処理しないといけません。

## サンプルコード(2)

```python:ctc_tensorflow_example_tf2_multi.py
#  Compatibility imports
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import time

import tensorflow as tf
import scipy.io.wavfile as wav
import numpy as np

from six.moves import xrange as range

try:
    from python_speech_features import mfcc
except ImportError:
    print("Failed to import python_speech_features.\n Try pip install python_speech_features.")
    raise ImportError

from utils import maybe_download as maybe_download
from utils import sparse_tuple_from as sparse_tuple_from

# Constants
SPACE_TOKEN = '<space>'
SPACE_INDEX = 0
FIRST_INDEX = ord('a') - 1  # 0 is reserved to space
FEAT_MASK_VALUE = 1e+10

# Some configs
num_features = 13
num_units = 50 # Number of units in the LSTM cell
# Accounting the 0th indice +  space + blank label = 28 characters
num_classes = ord('z') - ord('a') + 1 + 1 + 1

# Hyper-parameters
num_epochs = 400
num_hidden = 50
num_layers = 1
batch_size = 2
initial_learning_rate = 1e-2
momentum = 0.9

# Loading the data

audio_filename = maybe_download('LDC93S1.wav', 93638)
target_filename = maybe_download('LDC93S1.txt', 62)

fs, audio = wav.read(audio_filename)

# create a dataset composed of data with variable lengths
inputs = mfcc(audio, samplerate=fs)
inputs = (inputs - np.mean(inputs))/np.std(inputs)
inputs_short = mfcc(audio[fs*8//10:fs*20//10], samplerate=fs)
inputs_short = (inputs_short - np.mean(inputs_short))/np.std(inputs_short)
# Transform in 3D array
train_inputs = tf.ragged.constant([inputs, inputs_short], dtype=np.float32)
train_seq_len = tf.cast(train_inputs.row_lengths(), tf.int32)
train_inputs = train_inputs.to_sparse()

num_examples = train_inputs.shape[0]

# Reading targets
with open(target_filename, 'r') as f:

    #Only the last line is necessary
    line = f.readlines()[-1]

    # Get only the words between [a-z] and replace period for none
    original = ' '.join(line.strip().lower().split(' ')[2:]).replace('.', '')
    targets = original.replace(' ', '  ')
    targets = targets.split(' ')

# Adding blank label
targets = np.hstack([SPACE_TOKEN if x == '' else list(x) for x in targets])

# Transform char into index
targets = np.asarray([SPACE_INDEX if x == SPACE_TOKEN else ord(x) - FIRST_INDEX
                      for x in targets])
# Creating sparse representation to feed the placeholder
train_targets = tf.ragged.constant([targets, targets[13:32]], dtype=np.int32) 
train_targets_len = tf.cast(train_targets.row_lengths(), tf.int32)
train_targets = train_targets.to_sparse() 

# We don't have a validation dataset :(
val_inputs, val_targets, val_seq_len, val_targets_len = train_inputs, train_targets, \
                                                        train_seq_len, train_targets_len


# THE MAIN CODE!

# Defining the cell
# Can be:
#   tf.nn.rnn_cell.RNNCell
#   tf.nn.rnn_cell.GRUCell 
cells = []
for _ in range(num_layers):
    cell = tf.keras.layers.LSTMCell(num_units)  # Or LSTMCell(num_units)
    cells.append(cell)
stack = tf.keras.layers.StackedRNNCells(cells)

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Masking(FEAT_MASK_VALUE, input_shape=(None, num_features)))
model.add(tf.keras.layers.RNN(stack, return_sequences=True))
# Truncated normal with mean 0 and stdev=0.1
# Zero initialization        
model.add(tf.keras.layers.Dense(num_classes,
                          kernel_initializer=tf.keras.initializers.TruncatedNormal(0.0, 0.1),
                          bias_initializer="zeros"))
optimizer = tf.keras.optimizers.SGD(initial_learning_rate, momentum)

@tf.function
def step(inputs, targets, seq_len, targets_len, flag_training):
    inputs = tf.sparse.to_dense(inputs, default_value=FEAT_MASK_VALUE)
    if flag_training:
        with tf.GradientTape() as tape:
            logits = model(inputs, training=True)
            # Time major
            logits = tf.transpose(logits, (1, 0, 2))
            cost = tf.reduce_mean(tf.nn.ctc_loss(targets, logits, targets_len, seq_len, blank_index=-1))

        gradients = tape.gradient(cost, model.trainable_variables)
        optimizer.apply_gradients(zip(gradients, model.trainable_variables))
    else:
        logits = model(inputs)
        # Time major
        logits = tf.transpose(logits, (1, 0, 2))
        cost = tf.reduce_mean(tf.nn.ctc_loss(targets, logits, targets_len, seq_len, blank_index=-1))

    # Option 2: tf.nn.ctc_beam_search_decoder
    # (it's slower but you'll get better results)
    decoded, _ = tf.nn.ctc_greedy_decoder(logits, seq_len)

    # Inaccuracy: label error rate
    ler = tf.reduce_mean(tf.edit_distance(tf.cast(decoded[0], tf.int32),
                                          targets))
    return cost, ler, decoded

ds = tf.data.Dataset.from_tensor_slices((train_inputs, train_targets, train_seq_len, train_targets_len)).batch(batch_size)
for curr_epoch in range(num_epochs):
    train_cost = train_ler = 0
    start = time.time()

    for batch_inputs, batch_targets, batch_seq_len, batch_targets_len in ds:
        batch_cost, batch_ler, _ = step(batch_inputs, batch_targets, batch_seq_len, batch_targets_len, True)
        train_cost += batch_cost*batch_size
        train_ler += batch_ler*batch_size

    train_cost /= num_examples
    train_ler /= num_examples

    val_cost, val_ler, decoded = step(val_inputs, val_targets, val_seq_len, val_targets_len, False)
    log = "Epoch {}/{}, train_cost = {:.3f}, train_ler = {:.3f}, val_cost = {:.3f}, val_ler = {:.3f}, time = {:.3f}"
    print(log.format(curr_epoch+1, num_epochs, train_cost, train_ler,
                     val_cost, val_ler, time.time() - start))
# Decoding
print('Original:')
print(original)
print(original[13:32])
print('Decoded:')
d = tf.sparse.to_dense(decoded[0], default_value=-1).numpy()
for i in range(2):
    str_decoded = ''.join([chr(x) for x in np.asarray(d[i][d[i] != -1]) + FIRST_INDEX])
    # Replacing blank label to none
    str_decoded = str_decoded.replace(chr(ord('z') + 1), '')
    # Replacing space label to space
    str_decoded = str_decoded.replace(chr(ord('a') - 1), ' ')
    print(str_decoded)
```

実行結果は例えば以下のようになります。

```
Epoch 1/400, train_cost = 527.789, train_ler = 1.122, val_cost = 201.650, val_ler = 1.000, time = 1.702
Epoch 2/400, train_cost = 201.650, train_ler = 1.000, val_cost = 372.285, val_ler = 1.000, time = 0.238
（略）
Epoch 400/400, train_cost = 1.331, train_ler = 0.000, val_cost = 1.320, val_ler = 0.000, time = 0.307
Original:
she had your dark suit in greasy wash water all year
dark suit in greasy
Decoded:
she had your dark suit in greasy wash water all year
dark suit in greasy
```

## 解説

### 可変長データの準備

```python
# create a dataset composed of data with variable lengths
inputs = mfcc(audio, samplerate=fs)
inputs = (inputs - np.mean(inputs))/np.std(inputs)
inputs_short = mfcc(audio[fs*8//10:fs*20//10], samplerate=fs)
inputs_short = (inputs_short - np.mean(inputs_short))/np.std(inputs_short)
# Transform in 3D array
train_inputs = tf.ragged.constant([inputs, inputs_short], dtype=np.float32)
train_seq_len = tf.cast(train_inputs.row_lengths(), tf.int32)
train_inputs = train_inputs.to_sparse()

num_examples = train_inputs.shape[0]
```

オリジナルのコードが使っていたデータの一部を切り出したものを用意して、データを2件に増やしています。
最終的にはデータは ``SparseTensor`` にするのですが、まず ``tf.ragged.constant()`` を使って ``RaggedTensor`` を作り、そこから変換するようにすると楽に作れます。

### モデルへの可変長データの入力

別の記事でもご紹介したことがありますが、``Masking`` レイヤーを使うことで可変長の入力を表現します。
[Kerasで基本的なRNN (LSTM) を試してみる - Qiita](https://qiita.com/everylittle/items/ba821e93d275a421ca2b)

```python
model.add(tf.keras.layers.Masking(FEAT_MASK_VALUE, input_shape=(None, num_features)))
```

入力時のミニバッチの形状は最大データ長で作られるので、短いデータを入力する際には長さの不足部分に ``FEAT_MASK_VALUE`` を埋めておきます。

```python
@tf.function
def step(inputs, targets, seq_len, targets_len, flag_training):
    inputs = tf.sparse.to_dense(inputs, default_value=FEAT_MASK_VALUE)
```

入力特徴量について説明しましたが、ラベル側も同じです。
``targets[13:32]`` は、切り出した音声区間に対応するラベルを取ってきているだけです（マジックナンバーです…）。

```python
# Creating sparse representation to feed the placeholder
train_targets = tf.ragged.constant([targets, targets[13:32]], dtype=np.int32)
train_targets_len = tf.cast(train_targets.row_lengths(), tf.int32)
train_targets = train_targets.to_sparse()
```

学習を行うときには、必要なデータをまとめた ``Dataset`` を作成し、``batch()`` を使ってミニバッチを作ります。``for`` ループでミニバッチを順番に取り出すことができます。

```python
ds = tf.data.Dataset.from_tensor_slices((train_inputs, train_targets, train_seq_len, train_targets_len)).batch(batch_size)
for curr_epoch in range(num_epochs):
    （略）
    for batch_inputs, batch_targets, batch_seq_len, batch_targets_len in ds:
        （略）
```

現実的には、学習データを事前にTFRecord形式のファイルに書き込んでおき、そこから ``Dataset`` を作って使うことになるのではないかと思います。読み込み時に ``tf.io.VarLenFeature`` を使って ``SparseTensor`` として特徴量を取ってくるようにすれば、今のループの中身の処理をそのまま使えます（たぶん）。
[\[TensorFlow 2\] TFRecordからの特徴量読み込みはバッチ単位でやるのがオススメ - Qiita](https://qiita.com/everylittle/items/a7c31b08d2f76c886a92)


# Kerasで全部できないの？

TensorFlow 2.xベースの処理で動くようになったので別に良いといえば良いのですが、成り行き上モデルをKeras化してしまったので、いっそのこと学習部分もKerasのAPIで実行できないか考えてみます。

**<del>これが修羅の道の始まりであった…。
結論から言うと、無理に頑張らないほうが良いみたいです。</del>**

**(2020/04/27追記) Kerasでもうまく動かす方法を見つけました。詳しくは別記事をご覧ください。**
[\[TensorFlow 2/Keras\] KerasでもCTC Lossを使った学習を動かす方法 - Qiita](https://qiita.com/everylittle/items/24aeb19fc586ded8e218)

## サンプルコード(3)

データ1件で学習するサンプルコード(1)をベースにしています。

```python:ctc_tensorflow_example_tf2_keras.py
（TF2版と同じなので前半は略）

# Creating sparse representation to feed the placeholder
train_targets = tf.sparse.to_dense(tf.sparse.SparseTensor(*sparse_tuple_from([targets], dtype=np.int32)))

（略）

def loss(y_true, y_pred):
    #print(y_true)  # Tensor("dense_target:0", shape=(None, None, None), dtype=float32) ???
    targets_len = train_targets_len[0]
    seq_len = train_seq_len[0]
    targets = tf.cast(tf.reshape(y_true, (-1, targets_len)), tf.int32)
    # Time major
    logits = tf.transpose(y_pred, (1, 0, 2))
    return tf.reduce_mean(tf.nn.ctc_loss(targets, logits,
             tf.fill((tf.shape(targets)[0],), targets_len), tf.fill((tf.shape(logits)[1],), seq_len),
             blank_index=-1))

def metrics(y_true, y_pred):
    targets_len = train_targets_len[0]
    seq_len = train_seq_len[0]
    targets = tf.sparse.from_dense(tf.cast(tf.reshape(y_true, (-1, targets_len)), tf.int32))
    # Time major
    logits = tf.transpose(y_pred, (1, 0, 2))

    # Option 2: tf.nn.ctc_beam_search_decoder
    # (it's slower but you'll get better results)
    decoded, _ = tf.nn.ctc_greedy_decoder(logits, train_seq_len)

    # Inaccuracy: label error rate
    ler = tf.reduce_mean(tf.edit_distance(tf.cast(decoded[0], tf.int32),
                                          targets))
    return ler

model.compile(loss=loss, optimizer=optimizer, metrics=[metrics])
for curr_epoch in range(num_epochs):
    train_cost = train_ler = 0
    start = time.time()
    train_cost, train_ler = model.train_on_batch(train_inputs, train_targets)
    val_cost, val_ler = model.test_on_batch(train_inputs, train_targets)
    log = "Epoch {}/{}, train_cost = {:.3f}, train_ler = {:.3f}, val_cost = {:.3f}, val_ler = {:.3f}, time = {:.3f}"
    print(log.format(curr_epoch+1, num_epochs, train_cost, train_ler,
                     val_cost, val_ler, time.time() - start))

decoded, _ = tf.nn.ctc_greedy_decoder(tf.transpose(model.predict(train_inputs), (1, 0, 2)), train_seq_len)
d = tf.sparse.to_dense(decoded[0])[0].numpy()
str_decoded = ''.join([chr(x) for x in np.asarray(d) + FIRST_INDEX])
# Replacing blank label to none
str_decoded = str_decoded.replace(chr(ord('z') + 1), '')
# Replacing space label to space
str_decoded = str_decoded.replace(chr(ord('a') - 1), ' ')

print('Original:\n%s' % original)
print('Decoded:\n%s' % str_decoded)
```

それっぽく書けているように見えますね。
しかし実は動作がかなり怪しいです…。

## 怪しい点

### Sparseなラベルの取扱い

Kerasで普通にモデルを作ると、``Model.fit()`` や ``Model.train_on_batch()`` でSparseなラベルを使うことができません。しょうがないので普通の ``Tensor`` に変換してしまいました。

ラベル誤り率の計算時にはラベルがSparseになっていないといけないので

```python
targets = tf.sparse.from_dense(tf.cast(tf.reshape(y_true, (-1, targets_len)), tf.int32))
```

としてまたSparseに戻しているのですが、こうするとスペースに相当するID: 0の記号が消えてしまいます（そりゃそうだ、本来は0を持たないのが疎行列なのですから…）。
というわけで、正解ラベル列からスペースが抜けた状態で誤り率を計算することになり、いつまで経っても誤り率が0にならない（スペースの数だけ挿入誤りが発生する）という事態が発生しています。
直近の解決策としては、ID: 0がブランク記号（≠スペース）になるようにID体系を変えることが考えられます。しかし、わざわざSparseをDenseにしてまた戻すようなことをしている点を先に解決したほうが良いですよね…。

### 損失関数の挙動

Kerasで書くときは ``Model.compile()`` に損失関数を指定します。自分で作った ``Callable`` を指定することもできますが

```python
def loss(y_true, y_pred):
```

の2引数しか取れないので、今回は長さ情報をグローバル変数から取ってくるようにします。
そこまではまだ良いのですが。

```python
def loss(y_true, y_pred):
    #print(y_true)  # Tensor("dense_target:0", shape=(None, None, None), dtype=float32) ???
    （略）
    targets = tf.sparse.from_dense(tf.cast(tf.reshape(y_true, (-1, targets_len)), tf.int32))
```

``y_true`` って、正解ラベル（つまり ``train_targets`` と ``val_targets``）から持ってきたデータじゃないですか。これらの次元は ``(sample, time)`` の2次元であるはずなのに、なぜか3次元の ``Tensor`` になっています…。しかも、元のラベルは ``int32`` で作っているはずなのに、なぜか ``float32`` になっている…。

そういうわけで、``y_true`` に何が渡ってきているのか分からないまま

```python
targets = tf.sparse.from_dense(tf.cast(tf.reshape(y_true, (-1, targets_len)), tf.int32))
```

として2次元に変形＆型変換してしまっています。怪しすぎる。でも学習はちゃんとできているらしい？

これはKerasの仕様（設計思想？）なのかもしれず、以下のドキュメントの記述も
[tf.keras.losses.Loss  |  TensorFlow Core v2.1.0](https://www.tensorflow.org/api_docs/python/tf/keras/losses/Loss?hl=ja)

> y_true: Ground truth values. shape = [batch_size, d0, .. dN]
y_pred: The predicted values. shape = [batch_size, d0, .. dN]

のように、同じ形状であることが前提となっているように読めます。
普通の分類問題でクロスエントロピー損失などを使う場合は全く問題ないのですが、CTC Lossのように「正解ラベルと予測結果の長さが異なる」といったケースになると途端に混乱してしまいます。

…そういえば、``sparse_categorical_crossentropy`` って ``y_true`` と ``y_pred`` の形状が違いますよね？あれはどうやって実現されているのでしょうか。

- ``y_true``: カテゴリ変数 ``(batch_size,)``
- ``y_pred``: カテゴリごとのスコアの出力 ``(batch_size, num_classes)``

つまり、あちらの実装を真似すれば良いはずです。以下の実装を見ると変形と型変換が入っているので、実は今の実装でだいたい合っているのかも？（しかしそれにしても怪しい）
[tensorflow/backend.py at v2.1.0 · tensorflow/tensorflow · GitHub](https://github.com/tensorflow/tensorflow/blob/v2.1.0/tensorflow/python/keras/backend.py#L4508)

### 実行速度が遅い

```
Epoch 1/200, train_cost = 774.764, train_ler = 1.190, val_cost = 387.497, val_ler = 1.000, time = 2.212
Epoch 2/200, train_cost = 387.497, train_ler = 1.000, val_cost = 638.239, val_ler = 1.000, time = 0.459
（略）
Epoch 200/200, train_cost = 3.549, train_ler = 0.238, val_cost = 3.481, val_ler = 0.238, time = 0.461
Original:
she had your dark suit in greasy wash water all year
Decoded:
she had your dark suit in greasy wash water all year
```

Keras版に書き換える前（TensorFlow 2.x版）の3倍くらい時間が掛かっています…。[^2]
しかも前述の理由で ``train_ler`` と ``val_ler`` の値は正しく出力されません。

[^2]: データが少ないので、学習そのものが遅いのではなく、Keras化によってデータ量によらないオーバーヘッドが発生しているだけかもしれません。また、ラベルをDenseとSparseで行き来させていることなども原因として考えられます。

<del><strong>学習部分を頑張ってKerasスタイルで書いたところで、怪しいハックばかりになってしまい、今のところは良いことがありません。</strong>TensorFlowやKerasのバージョンアップに伴って解決していくかもしれませんが、どうでしょうね。</del>

# まとめ

- TensorFlow 2.xでCTC Lossを使ったパラメータの学習方法を説明しました。一応動いているみたいです。
- TensorFlowスタイルの学習ループを書きつつ部分的にKerasコードが混じったような格好になっていますが、<del>全部Kerasで書こうとするとかえって面倒なのでお勧めしません。</del>
- **(2020/04/27追記) Kerasスタイルで学習させる方法は[別記事](https://qiita.com/everylittle/items/24aeb19fc586ded8e218)をご覧ください。**