今回は趣向を変えて音声認識について紹介します。分野的には自然言語処理(NLP)でなくて自動音声認識(ASR)なのはわかっているんですが、「人間の発する言葉を機械で処理する」枠には収まっているので、まぁ良いかということで。手法としては NVIDIA の QuartzNet を用いて、日本語音声の認識に挑戦します。
1. はじめに
今回は趣向を変えて音声認識を扱います。いつものように日本語のデータセットを用いて学習や推論のコード例と実験結果を紹介していきますので、興味のある方は試して頂けると良いかと思います。手法としては NVIDIA が開発した End-to-End の音声認識モデルである QuartzNet 1 を用います。最近は End-to-End の音声認識ですと 日本の方が多く開発に携わっている ESPnet 2 の方が情報が多い気がしますが、最近は Transformer がらみの話が多くて食傷気味だったので QuartzNet を紹介することにしました。
正直、手元で試すと認識精度は ESPnet で Transformer を使った方が良かったのですがデコードが重くなります。その点 QuartzNet は自己回帰的な処理がない分だけ有利になりそうですね。今回の実験で使用した QuartzNet 15x5 と呼ばれるサイズのモデルは Jetson Nano でリアルタイムストリーミングが出来るそうです。
2. QuarzNet
QuartzNet は End-to-End の音声認識手法です。まずは、"End-to-End" とはどういう意味か押さえておきましょう。
End-to-End とは
“End-to-End” というのは音声特徴量の系列を X、音響特徴量から認識される文字列を W としたときに P(W|X) をニューラルネットでモデル化したと言えるでしょう。平たく言えば、「モデルに音響特徴量の系列を入れるとテキストになって出てくる」という意味です。
「普通の話をやけにもったいぶってしてるな」と思われる方もおられるでしょうが、この “End-to-End” の音声認識が登場したのは 2014年の DeepSpeech 3 で、それまでは GMM-HMM や DMM-HMM と呼ばれる手法が長らく主役でした。これらは数式で書くと以下のような感じで、音響モデル、発音辞書、言語モデルと3つの組み合わせで成り立っています。
前述の GMM-HMM, DMM-HMM は音響モデルの役割を担います。ここで、S は音素系列です。音素というのは音声の音的な単位であり、「里親」であれば“/satooja/” であり、「日本語」であれば “/niqpoNgo/” となります4, 5。これらの部品を個別に学習、調整する必要があったのですが、"End-to-End" では音素をすっ飛ばして、直接 P(W|X) をモデル化したわけです。
話として随分シンプルになったので、"End-to-End" と冠を付けたくなるのもわかりますね。それでは本題に戻って QuartzNet の構造を見ていきましょう。
QuartzNet の構造
QuartzNet は以下のような構造をしています。一部に残差接続がありますが、畳み込みブロックの繰り返し構造がある、入力から出力まで一直線のシンプルな構造です。
各ブロックには “C1”, “B1” といった名前が付けられています。図中で “B?” と加筆したところは “Repeat B times” とあるので “BB” で良いのではないかと思われるかもしれませんが、そうしなかったのは少し理由があります。QuartzNet は BERT の BASE, LARGE のように、その大きさによって “5x5”, “10x5”, “15x5” などの種類があります。
“15x5” であれば上図で言えば B = 15 で R = 5 になります。ただ15個のブロックにはグループがあり、グループ単位で構造が異なります。"5x5", “10x5”, “15x5” の構成は論文1の Table.1 に記載があります。
ブロックのグループとして、B1~B5 の5つがあり、各グループが S 回繰り返されます。"15x5" なら S = 3 ですから、5グループが各3回なので 5×3 = 15 となるわけです 6。
次に QuartzNet の特徴である Time-Channel Separable Convolution について見ていきましょう。
Time-Channel Separable Convolution
Time-Channel Separable Convolution は チャネル毎のタイムフレーム方向の畳み込みとタイムフレーム毎のチャネル方向の畳み込みを繋げたものです。言葉だと良くわからないので、図で見てみましょう。入力次元数 Cin=4, 出力次元数 Cout=3, 時間方向の畳み込み幅 K=4 でのイメージです。
通常の1次元畳み込みでは Cin × Cout × K のパラメータ数が必要になりますが、Time-Channel Separable Convolution では (Cin× K) + (Cin × Cout) に抑えることができます。効用として、チャネル毎のタイムフレーム方向の畳み込みのパラメータ数(Cin× K)が全パラメータ数に比して比較的小さくなる為、畳み込み幅の K を一世代前のモデルの3倍程度まで拡大できたとのことです。
次に損失である CTC (Connectionist Temporal Classification) を見ていきましょう。
Connectionist Temporal Classification
Connectionist Temporal Classification(以後、CTC)は入力と出力の系列長が異なる場合に利用する、入力と出力のアライメント情報が不要な損失関数です。
例えば、 QuartzNet への入力は音響特徴量の次元数×タイムフレーム長 の行列です。そして出力したいのは文字列です。QuartzNet からの出力は畳み込み等でタイムフレーム方向には短くなりますが、正解ラベルである文字列長とは違ってそうです。アライメント情報が不要というのは、「おはよう」という音声を入力したら、出力系列のどこかが「お」になるはずですが、その対応の情報が不要ということです。
ポイントは、
- 同じ記号が複数ステップにわたって連続して出力されることを許容すること
- blank記号(図中では“_"で表記)を導入したこと
の二つです。以下は入力された音響特徴量系列が "THE CAT ” である確率を計算する場合のイメージです。
つまり、最終的に“THE CAT "であると解釈される出力には、
__TH____E_-_C__AAA__TT__-
__T_HH___EE-CC__AAAA_TT--
TTT__H_E__--C__A___TTT-__
-
...
_T__H__EE__-_C__AA__T___-
のように様々なパターンがあり、この個々のパターンの確率の合計が、"THE CAT ” の確率となります。「"apple" とかはどうするの?」と心配になりますが、この場合、"aapple_"
という出力では “aple” になってしまうので、モデルは "ap_pple"
のように p の間に “_” を挟んで出力する必要があるということになります7。
この “THE CAT ” になる全パターンの確率合計の効率の良い計算には CTC Forward-Backward アルゴリズムが用いられます。説明は省きますが、"ご注文は機械学習ですか?"さんのページ8に分かりやすい説明があったので、興味のある方は見てみてください。
次は QuartzNet に入力する音響特徴量について見ていきましょう。
3. メルスペクトログラム
QuartzNet に入力する音響特徴量について論文中に明確な記載がありませんでしたが、ソースコード9やチュートリアル10を見る限りではメルスペクトログラムを使用していました。より正確には対数メルフィルタバンク特徴量のスペクトログラムであり、最近の End-to-End の音響認識ではお馴染みの手法です。
いきなり馴染みのないカタカナですが、まず音声は以下のような連続的な波形データです。処理の大枠として、この波形から図中の網掛けのように一定長の区間(タイムフレーム)を切り出し、フレーム単位で特徴量を計算します。そして、フレームをある程度重なるようにずらしていって波形全体を処理します。
それでは各タイムフレームの特徴量をどのように抽出するか見ていきましょう。以下はタイムフレームの部分波形を処理していく過程です。上図の網掛け部分を切り出したのが、下図左上の部分波形です。
左上の部分波形は複雑な形をしていますが、周期性を持つ全ての波形は周波数が異なる複数の単純な波に分解することができます。音声認識で用いられる特徴量は、この性質を利用してタイムフレームの部分波形を複数の周波数の波に分解し、「どの周波数(の波)がどの程度の強さ(振幅)で含まれているか」を示すものになります11。 この「複数の単純な波に分解する」処理がフーリエ変換です。以下がそのイメージで、複雑な波(赤線)が周波数が異なる複数の単純な波(青線)に分解されています。各周波数での振幅の大きさをとって棒グラフにすれば、赤線の波の特徴量です。
実際の計算は以下の手順で波形に処理を加えていきます。
1. ディザリング
アナログ→デジタル変換での量子化誤差を最小化する為、量子化時の切り捨て/切り上げがランダムになるよう少量のノイズを加算する処理です。
2. プレエンファシスフィルタ
高域成分を強調するプレエンファシスフィルタをかけます。部分波形を f(t) とすると以下のような処理です。 p は係数で今回は 0.97 を使用しました。
3. STFT
前述の「フレームをある程度重なるようにずらしていって波形全体を処理」に相当するのが STFT (短時間フーリエ変換)です。個々のタイムフレームの部分波形にフーリエ変換をかけ複数の周波数の波に分解します。
前述のとおり、フーリエ変換で分解する波形は周期性があることが前提ですが、ここは部分波形が無限に繰り返されるものと仮定します。ただし単純に部分波形を繰り返すと右端と左端の接合部で不連続になるので、部分波形と同じ長さで両端が 0 になる窓関数を掛け合わせ、滑らかに接合するようにした上でフーリエ変換を行います。(上図の「フーリエ変換への入力波形」)。
フーリエ変換の結果は分解した周波数毎の振幅と位相が複素数の形で得られます。この複素数を a + bi とし、複素平面上にプロットすると、原点との距離が振幅、実軸との角度が位相になります。
4. パワースペクトルの計算
フーリエ変換で分解した複数の波を周波数を横軸、波の持つパワーを縦軸としてプロットしたものがパワースペクトルです。ここでパワーの定義はフーリエ変換の絶対値の2乗です。こう書くと何を意味するか分かりにくいですが、複素数 a + bi の絶対値は (a2 + b2)½ ですから、上の図の振幅にあたります。振幅の2乗であれば感覚的にもパワーな感じがしますよね。
5. メルフィルタバンクの適用
ここからパワースペクトルに対してメルフィルタバンクを適用して、より人間の聴覚に近い特徴を抽出します。
上図で複数の三角形が重なりあって配置されているのがメルフィルタバンクです12。 この三角形それぞれがバンドパスフィルタであり、一つずつパワースペクトルに適用します13。この操作でスカラー値が一つ得られるので、メルフィルタバンクにN個のバンドパスフィルタがあれば、N次元の特徴量が得られることになります。
「人間の聴覚に近い」についても触れておきましょう。上図で三角形の頂点は周波数に対して均等な感覚ではありません。 これは人間の聴覚が低音域では敏感ですが高音域では鈍感なことを反映しています。この特性を示すものとしてメル尺度があります。
ようやく「メル」が出てきました。メル尺度の差が同じであれば、人間は音高の差が同じに感じられます。上図の三角形はメル尺度的に等間隔に並べてあるわけです。
6. 対数化
最後も人間の聴覚の特性に関連した話です。音の高さ(周波数)と同じく音のパワーに対する感度もパワーが小さいと敏感で、パワーが強くなるにつれて鈍感になっていきます。メルフィルタバンク特徴量の対数をとることで、この特性を反映します。
ようやくタイムフレームの特徴量が計算できました。あとは時間方向に各タイムフレームの特徴量を並べればメルスペクトログラムの完成です。
上図で横軸はタイムフレームのインデックス(意味的には時間)、縦軸は対数メルフィルタバンク特徴量のインデックス(意味的には周波数)です。ちなみに波形全体で「おはようございますー」です。確かにそれっぽいですね。
QuartzNet に入力する特徴量がおさえられたので、次は今回使用したオーグメンテーションについてみていきましょう。
4. SpecAugment
音声認識のオーグメンテーションは、分かりやすいところでは再生速度を速めたり遅めたり(speed perturbation)ですが、今回は SpecAugment 14 を使いました。
SpecAugment はネットワークへの入力(今回はメルスペクトログラム)に適用出来る為、学習時のデータを水増し時にメルスペクトログラムの再計算が必要ないのがポイントです。 データの変形の仕方は time warping, frequency masking, timestep masking の3通りが提案されています。
1. time warping
「時間方向にワープ」では分かりにくいので、絵で説明すると以下のような感じになります。 まず水色の点を薄灰色の網掛け範囲のオレンジの線上からランダムにピックアップします。この点を左か右のどちらかに w だけ動かした点(白点)に移動させて全体を歪めます。ここで w は 0 ~ W の範囲の一様分布から選び、両サイドのオレンジの点はアンカーポイントとして固定されます。
2. frequency masking
frequency masking は特定の周波数帯をマスクします。横長の帯で塗りつぶす感じですね。f を 0 ~ F の範囲、f0 を 0 ~ ν の一様分布から選びます。νは メルフィルタバンク特徴量の次元数です。
3. timestep masking
timestep masking は特定の連続するタイムステップをマスクします。こんどは縦長の帯で塗りつぶしです。t を 0 ~ T の範囲、t0 を 0 ~ τ-t の一様分布から選びます。
提案された3種類の中では time warping が一番効果が小さく、その割に計算負荷が高いとされています。また、 SpecAugument とラベルスムージングを併用すると学習が不安定になり、学習レートが低くなるとそれが顕著になってくるとのことでした。SpecAugment を適用することによって、音声認識タスクが過学習から過少学習となり、より大きなネットワークの採用、より長い訓練という過少学習への基本的なアプローチが効果的に機能することになります。
さて、ここからは実際の日本語データを使って実験をしていきます。
5. 日本語データでの実験
例によって Google Colaboratory を使用します。"ランタイム“ => "ランタイムのタイプを変更” で “ハードウェアアクセラレータ” に “GPU” を選んでおいて下さい。
環境のセットアップ
まずデータ加工やビルドに必要なライブラリをインストールします。
!apt-get update !apt-get install sox swig pkg-config libflac-dev libogg-dev libvorbis-dev libboost-dev \ libsndfile1-dev python-setuptools libboost-all-dev python-dev cmake
QuartzNet の実装は NeMo 15 に含まれています。 NeMo は NVIDIA の開発する対話AI構築用のツールキットで、音声認識の他に翻訳等の自然言語処理、音声合成の機能が含まれています。 まず、GitHub から NeMo のリポジトリを clone します。
!git clone https://github.com/NVIDIA/NeMo
あとは、pip でインストールするだけ…だったのですが、 Colab の Python がアップデートされたようで、学習時に以下のようなエラーがでるようになってしまいました(2020/8/3時点)。
RuntimeError: Integer division of tensors using div or / is no longer supported, and in a future release div will perform true division as in Python 3. Use true_divide or floor_divide (// in Python) instead.
ですので、とりあえず以下のようにコードを修正しました(ですが、この記事が公開されるころには対応済みで不要かもしれませんね)。
!cp NeMo/nemo/collections/asr/parts/jasper.py jasper.py !cat jasper.py | sed -e '158s#/#//#g' > jasper.py.mod !cp jasper.py.mod NeMo/nemo/collections/asr/parts/jasper.py !diff jasper.py NeMo/nemo/collections/asr/parts/jasper.py # 158c158 # < ) / self.conv.stride[0] + 1 # --- # > ) // self.conv.stride[0] + 1
pip でインストールします。
!cd NeMo; pip install . !cd NeMo; pip install .[asr]
NeMo では音声合成でのデコード時に KenLM を組み合わせて精度を向上させることができます。
KenLM は名前のとおり言語モデルの実装です。こう書くと冒頭の「End-to-End とは」では言語モデルが不要になったと説明したので混乱するかもしれませんね。 これは QuartzNet は言語モデルなしで音響特徴量のシーケンスからテキストへの変換を学習できますが、推論時は QuartzNet の推論に言語モデルの推論を組み合わせたほうが精度がよいということです。この辺りは他の “End-to-End” の音声認識モデルでも事情は同じです。
話を元に戻して KenLM のビルドです。
!cd NeMo/scripts; ./install_decoders.sh
ここからはデータセットの用意です。
JSUT コーパス
今回は日本語の音声コーパスとして JSUT コーパス16を使用します。一人の女性話者による10時間分のデータであり小規模ではありますが、非商用目的や個人での利用は無料なので手軽に音声認識を試すことができます。
以下のようにしてダウンロード、展開して下さい。
!wget https://ss-takashi.sakura.ne.jp/corpus/jsut_ver1.1.zip !unzip jsut_ver1.1.zip !ls jsut_ver1.1 # basic5000 LICENCE.txt precedent130 repeat500 voiceactress100 # ChangeLog.txt loanword128 README_en.txt travel1000 # countersuffix26 onomatopee300 README_ja.txt utparaphrase512
データセットの分割
以下のようにして JSUT コーパスを訓練データセット、検証データセット、テストデータセットに分割します。 この分割とラベルは ESPnet に含まれる JSUT レシピ17に合わせてあります。こちらの実験結果を見るとサンプリングレート 16000Hz で CER = 21.3 が出ていますので、これを目標にしてみましょう18。
%%bash rm -rf data mkdir -p data PWD=`pwd` find -L jsut_ver1.1 -name "*.wav" | sort | while read -r filename; do id=$(basename ${filename} | sed -e "s/\.[^\.]*$//g") echo "${PWD}/${filename}" >> data/wav sox ${filename} -n stat 2>&1 | awk -F " " '/^Length/{print $3}' >> data/duration done find -L jsut_ver1.1 -name "transcript_utf8.txt" | sort | while read -r filename; do cat ${filename} | sed 's/^[A-Za-z0-9\_\-]*://' >> data/text done paste data/wav data/duration data/text |\ sed 's/^\(.*\)\t\(.*\)\t\(.*\)$/{"audio_filepath": "\1", "duration": \2, "text": "\3"}/' \ > data/all_data.json # Split to tr_no_dev, dev, eval1 head -500 data/all_data.json > data/deveval.json head -250 data/deveval.json > data/eval1.json tail -250 data/deveval.json > data/dev.json n=$(( $(wc -l < data/all_data.json) - 500 )) tail -$n data/all_data.json > data/tr_no_dev.json
ただ、 JSUT コーパスはサンプリングレートが 48000Hz で収録されています。これをサンプリングレート 16000 Hz で実験すると変換処理で待たされるのか GPU 利用率が上がりませんでした。その為、以下のようにして、 16000Hz バージョンも作成しておきます19。
%%bash cd data for wav in `cat wav`; do sox $wav -r 16000 `echo $wav | sed 's/\.wav/_16k.wav/'`; done cat tr_no_dev.json | sed 's/.wav"/_16k.wav"/' > tr_no_dev_16k.json cat dev.json | sed 's/.wav"/_16k.wav"/' > dev_16k.json cat eval1.json | sed 's/.wav"/_16k.wav"/' > eval1_16k.json
出来上がったデータはこんな感じです。
!cd data && head -3 tr_no_dev_16k.json # {"audio_filepath": "/content/jsut_ver1.1/basic5000/wav/BASIC5000_0501_16k.wav", "duration": 4.040000, "text": "アヤが完璧なドイツ語を話すのは、少しも不思議でない。"} # {"audio_filepath": "/content/jsut_ver1.1/basic5000/wav/BASIC5000_0502_16k.wav", "duration": 3.930000, "text": "アラン君は、運良く税理士試験に合格しました。"} # {"audio_filepath": "/content/jsut_ver1.1/basic5000/wav/BASIC5000_0503_16k.wav", "duration": 3.680000, "text": "協定が結ばれる可能性は、極めて少ない。"}
最大長の決定
学習時のシーケンス長は最も長いサンプルに合わせることになります。必要メモリサイズに大きい影響があるので極端に長いデータは除外してしまいます。訓練データの再生時間をヒストグラムで見るとこんな感じです。
import json import numpy as np import pandas as pd import json with open("data/tr_no_dev.json", "r") as f: lines = f.readlines() lines = [line.strip() for line in lines] train = [json.loads(line) for line in lines] durations = np.array([example['duration'] for example in train]) df = pd.DataFrame(durations) df.columns = ["duration"] df.hist(bins=100)
ヒストグラムの頻度を割合(%)にして再生時間の短い方からの累積頻度が99%を越えない最大のビンの右端を今回の最大長とします。
histgram = df['duration'].value_counts(bins=1000, normalize=True) rights = [idx.right for idx in histgram.index] values = histgram.values bins = [bin for bin in zip(rights, values)] bins.sort(key=lambda bin: bin[0]) accumulated = np.cumsum([bin[1] for bin in bins]) accumulated_bins = [bin for bin in zip([bin[0] for bin in bins], accumulated)] max_duration = [bin for bin in accumulated_bins if bin[1] <= 0.99][-1][0] max_duration # 12.078
なので、最大長の設定は 12.0 で行ってみましょう。
音声認識の評価指標
学習を始める前に評価指標の話もしておきましょう。音声認識では WER (Word Error Rate)を使うのが一般的です。
正解の単語列と比較して、以下の意味です。
- S は入れ替わった単語数
- I は挿入された単語数
- D は削除された単語数
- R は正解文の単語数
ですが、日本語の音声認識では単語分割に曖昧性がある等の理由で CER(Character Error Rate) を用いるようです。ESPNet の論文2 でも日本語や中国語のコーパスに対する実験データは CER で報告されているので、今回の実験も CER を使います。CER は WER の定義における単語を文字に置き変えたものだと考えてもらえれば OK です。
学習の準備
ここからようやく学習に入ります。まずは NeMo のパッケージをインポートです。 何やら警告が出てますが、特に問題ありませんでした。
import nemo import nemo.collections.asr as nemo_asr from functools import partial from nemo.collections.asr.helpers import monitor_asr_train_progress, process_evaluation_batch, process_evaluation_epoch from nemo.collections.asr.helpers import post_process_predictions, post_process_transcripts, word_error_rate # ################################################################################ # ### WARNING, path does not exist: KALDI_ROOT=/mnt/matylda5/iveselyk/Tools/kaldi-trunk # ### (please add 'export KALDI_ROOT=<your_path>' in your $HOME/.profile) # ### (or run as: KALDI_ROOT=<your_path> python <your_script>.py) # ################################################################################ # # [NeMo W 2020-06-10 00:11:48 audio_preprocessing:56] Could not import torchaudio. Some features might not work. # [NeMo W 2020-06-10 00:11:48 audio_preprocessing:61] Unable to import APEX. Mixed precision and distributed training will not work.
次にファクトリを作ります。
nf = nemo.core.NeuralModuleFactory(log_dir='./log', create_tb_writer=True) tb_writer = nf.tb_writer
QuartzNet 15x5 のモデル定義をロードします。
from ruamel.yaml import YAML yaml = YAML(typ="safe") with open("/content/NeMo/examples/asr/configs/quartznet15x5.yaml") as f: config = yaml.load(f) model_def = config["init_params"]["encoder_params"]["init_params"]
model_def
に C1, B1x3, B2x3, B3x3, B4x3, B5x3, C2, C3 の18層分が定義されています。
続いてラベル(出力候補の文字集合)です。これは前述のとおり ESPnet の JSUT レシピに合わせました。
%%bash export LANG=C.UTF-8 echo " " > labels.txt cat data/tr_no_dev.json | sed 's/.*"text": "\(.*\)"}/\1/' | sed 's/\(.\)/\1 /g' | sed 's/ $//' | tr " " "\n" \ | sort | uniq | grep -v -e '^\s*$' >> labels.txt
作ったラベルを読み込みます。
with open("labels.txt", "r") as f: lines = f.readlines() labels = [line.strip() for line in lines]
続いて事前学習済みモデルをダウンロードします。今回は中国語で学習したモデルから転移しました。中国語だろうが英語だろうが、入力は周波数×時間×メルフィルタバンクのフィルタ数の次元を持つ連続値データであることにかわりはありませんから転移学習が可能です。今回は英語と中国語のモデルを試しましたが中国語の方が良い結果がでました。
!wget https://api.ngc.nvidia.com/v2/models/nvidia/aishell2_quartznet15x5/versions/2/files/aishell2_quartznet15x5/JasperEncoder-STEP-197050.pt
今回はチェックポイントを GCS ではなく Colab ランタイムのディスク上に書き出すので、念のため Google Drive をマウントしてそちらに書き込むようにしましょう。
from google.colab import drive drive.mount('/gdrive')
マウントした Google Drive にチェックポイントを出力するディレクトリを作っておきます。
!cd "/gdrive/My Drive" && mkdir -p asr/nemo
基本的なパラメータは以下のとおりとしました。転移学習の時は学習レートは小さめ、ウォームアップはなしにするのがおすすめだそうです。
num_epochs = 100 batch_size = 16 batches_per_step = 2 dropout = 0.2 warmup_steps = 0 lr = 0.002 weight_decay = 0.0001 sample_rate = 16000 max_duration = 12.0 n_fft = 512 encoder_ckpt = "./JasperEncoder-STEP-197050.pt" output_dir = "/gdrive/My Drive/asr/nemo/ckpt" epochs_per_eval = 5
先ほど読み込んだモデル定義に dropout の値を反映します。
for i, layer in enumerate(model_def["jasper"]): layer['dropout'] = dropout
訓練データセットと検証データセットのファイル名です。
train_dataset = "data/tr_no_dev_16k.json" val_dataset = "data/dev_16k.json"
エポック毎にステップ数を計算しておきます。
with open(train_dataset) as f: lines = f.readlines() num_train_samples = len(lines) steps_per_epoch = num_train_samples // batch_size
計算グラフの構築
ここからは計算グラフを構築していきます。まずは訓練データセット、検証データセットのデータ読込レイヤです。
ポイントは normalize_transcripts=False
です。これがないと日本語ではテキストが化けてしまいます。また訓練データは shuffle=True
です。
data_layer = nemo_asr.AudioToTextDataLayer( manifest_filepath=train_dataset, labels=labels, sample_rate=sample_rate, trim_silence=True, batch_size=batch_size, normalize_transcripts=False, shuffle=True, max_duration=max_duration) # [NeMo I 2020-06-10 00:14:45 collections:158] Dataset loaded with 7120 files totalling 9.45 hours # [NeMo I 2020-06-10 00:14:45 collections:159] 76 files were filtered totalling 0.27 hours data_layer_val = nemo_asr.AudioToTextDataLayer( manifest_filepath=val_dataset, labels=labels, sample_rate=sample_rate, trim_silence=True, batch_size=batch_size, normalize_transcripts=False, shuffle=False, max_duration=max_duration) # [NeMo I 2020-06-10 00:14:45 collections:158] Dataset loaded with 250 files totalling 0.27 hours # [NeMo I 2020-06-10 00:14:45 collections:159] 0 files were filtered totalling 0.00 hours
続いてメルスペクトログラムへの変換と SpecAugument です。メルスペクトログラムの節であんなに説明したのにコードにするとたったコレだけです。
data_preprocessor = nemo_asr.AudioToMelSpectrogramPreprocessor( n_fft=n_fft, sample_rate=sample_rate, features=model_def["feat_in"], stft_conv=True) spec_augment = nemo_asr.SpectrogramAugmentation(**config["init_params"]["spec_augment_params"]["init_params"])
エンコーダとデコーダを定義します。エンコーダが C1 ~ C3、デコーダが C4 に相当します。エンコーダには事前学習済みモデルのパラメータをロードします。 デコーダは出力ラベル(文字集合)が全然違うので、最初からの学習となります。
encoder = nemo_asr.JasperEncoder(**model_def) encoder.restore_from(path=encoder_ckpt) decoder = nemo_asr.JasperDecoderForCTC(feat_in=model_def["jasper"][-1]['filters'], num_classes=len(labels))
CTCロスとデコーダを定義して、
ctc_loss = nemo_asr.CTCLossNM(num_classes=len(labels)) greedy_decoder = nemo_asr.GreedyCTCDecoder()
訓練用の計算グラフを組み立てます。訓練用のグラフは data_preprocessor
と encoder
の間に spec_augment
を挟んでいます。
audio_signal, audio_signal_len, transcript, transcript_len = data_layer() processed_signal, processed_signal_len = data_preprocessor(input_signal=audio_signal, length=audio_signal_len) processed_signal = spec_augment(input_spec=processed_signal) encoded, encoded_len = encoder(audio_signal=processed_signal, length=processed_signal_len) log_probs = decoder(encoder_output=encoded) predictions = greedy_decoder(log_probs=log_probs) loss = ctc_loss(log_probs=log_probs, targets=transcript, input_length=encoded_len, target_length=transcript_len)
こちらは検証用のグラフ
audio_signal_v, audio_signal_len_v, transcript_v, transcript_len_v = data_layer_val() processed_signal_v, processed_signal_len_v = data_preprocessor(input_signal=audio_signal_v, length=audio_signal_len_v) encoded_v, encoded_len_v = encoder(audio_signal=processed_signal_v, length=processed_signal_len_v) log_probs_v = decoder(encoder_output=encoded_v) predictions_v = greedy_decoder(log_probs=log_probs_v) loss_v = ctc_loss(log_probs=log_probs_v, targets=transcript_v, input_length=encoded_len_v, target_length=transcript_len_v)
ログ出力、チェックポイント、検証のコールバック関数を定義します。評価指標( eval_metric
)には CER を指定します。
logger_cb = nemo.core.SimpleLossLoggerCallback( tb_writer=tb_writer, tensors=[loss, predictions, transcript, transcript_len], print_func=partial(monitor_asr_train_progress, eval_metric='CER', labels=labels)) ckpt_cb = nemo.core.CheckpointCallback(folder=output_dir, step_freq=steps_per_epoch) eval_cb = nemo.core.EvaluatorCallback( eval_tensors=[loss_v, predictions_v, transcript_v, transcript_len_v], user_iter_callback=partial(process_evaluation_batch, labels=labels), user_epochs_done_callback=partial(process_evaluation_epoch, tag="DEV", eval_metric='CER'), eval_step=steps_per_epoch * epochs_per_eval, tb_writer=tb_writer) callbacks = [logger_cb, ckpt_cb, eval_cb]
学習ループの実行
ようやく学習ループを実行します。実行中は訓練サンプルのデコード結果や検証データに対する精度が出力されます。
optimization_params={"num_epochs": num_epochs, "lr": lr, "weight_decay": weight_decay} nf.train(tensors_to_optimize=[loss], callbacks=callbacks, optimizer="novograd", optimization_params=optimization_params, batches_per_step=batches_per_step) # ... # [NeMo I 2020-06-09 08:50:18 deprecated_callbacks:232] Step: 22200 # [NeMo I 2020-06-09 08:50:18 helpers:70] Loss: 18.415924072265625 # [NeMo I 2020-06-09 08:50:18 helpers:71] training_batch_CER: 18.71% # [NeMo I 2020-06-09 08:50:18 helpers:72] Prediction: 道うで彼らが、彼女のし出をったけだ。 # [NeMo I 2020-06-09 08:50:18 helpers:73] Reference: 道理で彼らが、彼女の申し出を断ったわけだ。 # [NeMo I 2020-06-09 08:50:18 deprecated_callbacks:247] Step time: 1.2948219776153564 seconds # [NeMo I 2020-06-09 08:50:47 deprecated_callbacks:215] Finished epoch 99 in 0:05:30.736569 # [NeMo I 2020-06-09 08:50:47 deprecated_callbacks:203] Done in 8:20:47.411364 # [NeMo I 2020-06-09 08:50:48 callbacks:458] Saved checkpoint: /gdrive/My Drive/asr/nemo/ckpt/trainer-STEP-22220.pt # [NeMo I 2020-06-09 08:50:48 deprecated_callbacks:339] Final Evaluation .............................. # [NeMo I 2020-06-09 08:50:51 helpers:187] ==========>>>>>>Evaluation Loss DEV: 37.45111083984375 # [NeMo I 2020-06-09 08:50:51 helpers:188] ==========>>>>>>Evaluation CER DEV: 23.64% # [NeMo I 2020-06-09 08:50:51 deprecated_callbacks:344] Evaluation time: 3.0644726753234863 seconds
これで音声認識の学習ができました。上記のログからは検証セットに対する CER = 23.64% だったことも分かります。ESPnet で Transformer を使った精度には及びませんでしたが、それなりに認識できてはいるようです。
KenLM の学習
次は LM を併用してさらに高い精度を目指してみましょう。
NeMo がサポートしているのは KenLM なので、これを使うしかないのですが単語ベースのモデルとするか文字ベースのモデルとするか、少々迷いました。この辺りの実装は Baidu の Deep Speech 2 を使用しており20、中国語は文字ベースの言語モデルのようなので、それに倣うことにしました。言語モデルを文字ベースとするのは入力するテキストを文字単位に空白で分割すれば OK のようです。
KenLM の学習には Wikipedia 日本語版 を使いました。全角英数を半角に変換するので、 nkf をインストールしておきます。
!apt-get install nkf
TFDS を使って Wikipedia 日本語版の内容をテキストにダンプします。TFDS に収録されている Wikipedia、現在は 20200301 版が追加されてますが、「まぁいいや」と古いのを使ってます。
import tensorflow_datasets as tfds import tensorflow as tf ds = tfds.load(name='wikipedia/20190301.ja', shuffle_files=False, download=True, try_gcs=True) train_ds = ds["train"].batch(128).prefetch(10) all_titles = [] all_texts = [] for example in tfds.as_numpy(train_ds): titles, texts = example["title"], example["text"] #if len(all_titles) > 1000 : break for title, text in zip(titles, texts): all_titles.append(title.decode('utf-8')) all_texts.append(text.decode('utf-8')) #if len(all_titles) > 1000 : break with open("input.txt", "w") as f: for text in all_texts: lines = [line.strip() for line in text.split("\n")] for line in lines: if len(line) == 0 or not line.endswith("。"): continue f.write(line + "\n") f.write("\n") del all_titles del all_texts
ダンプしたテキストから、空白のみの行、「。」で終わらない行を空行にしたりする処理です。
%%bash cat << EOF > preprocess.sh #!/bin/bash FILE=\$1 if [ \$# -ne 1 ]; then echo "Usage: ./preprocess.sh INPUT_TEXT" exit 1 fi echo "Processing \${FILE}" sed -i -e '/<doc id/,+1d; s/<\/doc>//g' \${FILE} sed -i -e 's/ *$//g; s/。\([^」|)|)|"]\)/。\n\1/g; s/^[ ]*//g' \${FILE} sed -i -e '/^。/d' \${FILE} sed -i -e '/^Category/d; /thumb|/d' \${FILE} EOF chmod 744 preprocess.sh ./preprocess.sh input.txt
この時点でこんな感じです。
!head -5 input.txt # 株式会社東経システムは、東京都港区三田に本社を置く、福祉・介護系システムの開発および販売、運用支援を行う企業である。 # # プリント基板(プリントきばん、短縮形PWB, PCB)とは、基板の一種で、以下のふたつをまとめて指す総称。 # 絶縁体でできた板の上や内部に、導体の配線が施された(だけの)もの。 # 電子部品が取り付けられる前の状態。
全角を半角に変換して空行を除去します。
%%bash cat << EOF > conv_z2h_and_del_empty_line.sh #!/bin/bash FILE=\$1 cat \${FILE} | nkf -Z | sed -e '/^$/d' > \${FILE}.out EOF chmod 755 conv_z2h_and_del_empty_line.sh ./conv_z2h_and_del_empty_line.sh input.txt
こんな感じになりました。
!head -5 input.txt.out # 株式会社東経システムは、東京都港区三田に本社を置く、福祉・介護系システムの開発および販売、運用支援を行う企業である。 # プリント基板(プリントきばん、短縮形PWB, PCB)とは、基板の一種で、以下のふたつをまとめて指す総称。 # 絶縁体でできた板の上や内部に、導体の配線が施された(だけの)もの。 # 電子部品が取り付けられる前の状態。 # プリント配線板(PWB = printed wiring board)と呼ばれる。
行単位でシャッフルします。
!shuf input.txt.out > preprocessed.txt
こうなりました。
!head -5 preprocessed.txt # しかし、1996年4月からは前後半のセールスが統一され、積水化学を筆頭とする複数社提供に移行された。 # 一部のチェアーは座ることができる。 # テムジンがオン・カンとの戦いに勝利しケレイト部を征服するとケレイト部民のほとんどはテムジンの親族や君臣に分割されたが、ジャカ・ガンボの一族のみはそのまま存続することを許された。 # しかし、これは満州国をいそいで承認したことその他、今まで日本がやってきたあらゆることの線に、ちゃんと沿っているのである。 # また偶然出会った舞々とは美を追求する者同士、通じ合っている。
最後に文字単位で分かち書きしてしまいます。
with open("preprocessed.txt", "r") as f: lines = f.readlines() print("Loaded %d lines of text." % (len(lines))) with open("lm_text_char", 'w') as f: for i, line in enumerate(lines): line = " ".join(line.strip()) f.write(line + "\n") if i % 2000 == 0: print("%d/%d lines are written." % (i, len(lines)))
KenLM の学習データはこんな感じになりました。
!head -5 lm_text_char # し か し 、 1 9 9 6 年 4 月 か ら は 前 後 半 の セ ー ル ス が 統 一 さ れ 、 積 水 化 学 を 筆 頭 と す る 複 数 社 提 供 に 移 行 さ れ た 。 # 一 部 の チ ェ ア ー は 座 る こ と が で き る 。 # テ ム ジ ン が オ ン ・ カ ン と の 戦 い に 勝 利 し ケ レ イ ト 部 を 征 服 す る と ケ レ イ ト 部 民 の ほ と ん ど は テ ム ジ ン の 親 族 や 君 臣 に 分 割 さ れ た が 、 ジ ャ カ ・ ガ ン ボ の 一 族 の み は そ の ま ま 存 続 す る こ と を 許 さ れ た 。 # し か し 、 こ れ は 満 州 国 を い そ い で 承 認 し た こ と そ の 他 、 今 ま で 日 本 が や っ て き た あ ら ゆ る こ と の 線 に 、 ち ゃ ん と 沿 っ て い る の で あ る 。 # ま た 偶 然 出 会 っ た 舞 々 と は 美 を 追 求 す る 者 同 士 、 通 じ 合 っ て い る 。
準備したテキストを使って KenLM を学習してバイナリ化しておきます。 KenLM は N-GRAM ベースのモデルで、今回は4GRAMを用いました21。
!./NeMo/scripts/decoders/kenlm/build/bin/lmplz --text ./lm_text_char --arpa kenlm_wiki_4gram.arpa --o 4 --discount_fallback !./NeMo/scripts/decoders/kenlm/build/bin/build_binary trie -q 8 -b 7 -a 256 kenlm_wiki_4gram.arpa kenlm_wiki_4gram.bin
言語モデルができたので、推論してみましょう。
!echo "本日は晴天なり。" | sed -e 's/\(.\)/\1 /g' -e 's/ $//' | ./NeMo/scripts/decoders/kenlm/build/bin/query kenlm_wiki_4gram.bin # This binary file contains trie with quantization and array-compressed pointers. # 本=10709 2 -2.0954018 日=4269 3 -3.4911103 は=4556 4 -0.7275773 晴=4565 4 -0.49129653 天=5239 4 -0.30171803 な=6824 4 -0.70218295 り=1486 4 -0.074147776 。=1965 4 -0.87043136 </s>=6785 4 -0.05406491 Total: -8.80793 OOV: 0 # Perplexity including OOVs: 9.52048094355136 # Perplexity excluding OOVs: 9.52048094355136 # OOVs: 0 # Tokens: 9 # Name:query VmPeak:692168 kB VmRSS:6264 kB RSSMax:655956 kB user:0 sys:0.05672 CPU:0.0567657 real:0.0628073
“本日は晴天なり。” の Perplexity は 9.52 でした。とりあえず、文字ベースでちゃんと動いていることは確認できました。 それでは先ほど学習した QuartzNet と KenLM を使って推論してみましょう。
テストデータセットでの推論
テストデータセットとロードするチェックポイントディレクトリのパスです。
test_dataset = "data/eval1.json" load_dir = "/gdrive/My Drive/asr/nemo/ckpt"
学習時と同様に計算グラフを構築していきます。
data_layer_test = nemo_asr.AudioToTextDataLayer( manifest_filepath=test_dataset, labels=labels, sample_rate=sample_rate, trim_silence=True, batch_size=batch_size, normalize_transcripts=False, shuffle=False, max_duration=max_duration) encoder = nemo_asr.JasperEncoder(**model_def) decoder = nemo_asr.JasperDecoderForCTC(feat_in=model_def['jasper'][-1]['filters'], num_classes=len(labels)) greedy_decoder = nemo_asr.GreedyCTCDecoder() audio_signal, audio_signal_len, transcript, transcript_len = data_layer_test() processed_signal, processed_signal_len = data_preprocessor(input_signal=audio_signal, length=audio_signal_len) encoded, encoded_len = encoder(audio_signal=processed_signal, length=processed_signal_len) log_probs = decoder(encoder_output=encoded) predictions = greedy_decoder(log_probs=log_probs) eval_tensors = [log_probs, predictions, transcript, transcript_len, encoded_len]
nf.infer()
で推論を実行します。
evaluated_tensors = nf.infer(tensors=eval_tensors, checkpoint_dir=load_dir) # [NeMo I 2020-06-10 00:22:13 actions:1562] Restoring JasperEncoder from /gdrive/My Drive/asr/nemo/ckpt/JasperEncoder-STEP-22220.pt # [NeMo I 2020-06-10 00:22:18 actions:1562] Restoring JasperDecoderForCTC from /gdrive/My Drive/asr/nemo/ckpt/JasperDecoderForCTC-STEP-22220.pt # [NeMo I 2020-06-10 00:22:22 actions:687] Evaluating batch 0 out of 16 # /pytorch/aten/src/ATen/native/BinaryOps.cpp:81: UserWarning: Integer division of tensors using div or / is deprecated, and in a future release div will perform true division as in Python 3. Use true_divide or floor_divide (// in Python) instead. # [NeMo I 2020-06-10 00:22:25 actions:687] Evaluating batch 1 out of 16 # [NeMo I 2020-06-10 00:22:28 actions:687] Evaluating batch 2 out of 16 # [NeMo I 2020-06-10 00:22:31 actions:687] Evaluating batch 3 out of 16 # [NeMo I 2020-06-10 00:22:34 actions:687] Evaluating batch 4 out of 16 # [NeMo I 2020-06-10 00:22:37 actions:687] Evaluating batch 5 out of 16 # [NeMo I 2020-06-10 00:22:40 actions:687] Evaluating batch 6 out of 16 # [NeMo I 2020-06-10 00:22:43 actions:687] Evaluating batch 7 out of 16 # [NeMo I 2020-06-10 00:22:46 actions:687] Evaluating batch 8 out of 16 # [NeMo I 2020-06-10 00:22:49 actions:687] Evaluating batch 9 out of 16 # [NeMo I 2020-06-10 00:22:51 actions:687] Evaluating batch 10 out of 16 # [NeMo I 2020-06-10 00:22:54 actions:687] Evaluating batch 11 out of 16 # [NeMo I 2020-06-10 00:22:57 actions:687] Evaluating batch 12 out of 16 # [NeMo I 2020-06-10 00:22:59 actions:687] Evaluating batch 13 out of 16 # [NeMo I 2020-06-10 00:23:02 actions:687] Evaluating batch 14 out of 16 # [NeMo I 2020-06-10 00:23:04 actions:687] Evaluating batch 15 out of 16
まずは、KenLM の補助なしでデコードしてみましょう。
import os import pickle greedy_hypotheses = post_process_predictions(evaluated_tensors[1], labels) references = post_process_transcripts(evaluated_tensors[2], evaluated_tensors[3], labels) wer = word_error_rate(hypotheses=greedy_hypotheses, references=references) print("Greedy WER {:.2f}%".format(wer * 100)) cer = word_error_rate(hypotheses=greedy_hypotheses, references=references, use_cer=True) cer_str="Greedy CER {:.2f}%".format(cer * 100) print(cer_str) infer_dir = os.path.join(load_dir, 'infer') os.makedirs(infer_dir, exist_ok=True) with open(os.path.join(infer_dir, 'cer'), 'w') as f: f.write(cer_str+"\n") logprob = [] for i, batch in enumerate(evaluated_tensors[0]): for j in range(batch.shape[0]): logprob.append(batch[j][: evaluated_tensors[4][i][j], :].cpu().numpy()) with open(os.path.join(infer_dir, 'logprob.pkl'), 'wb') as f: pickle.dump(logprob, f) with open(os.path.join(infer_dir, 'hypotheses'), 'w') as f: f.write("\n".join(greedy_hypotheses)) # Greedy WER 99.20% # Greedy CER 25.80%
CER = 25.8 なので4文字に一文字間違うレベルですね。次は KenLM も使ってみましょう。
ここで alpha
はどの程度 LM を重視するか、 beta
は出力の長さに対して与えるペナルティのパラメータです。*_min
, *_max
, *_step
で範囲指定するとグリッドサーチしてくれます。
import numpy as np import os lm_path = "./kenlm_wiki_4gram.bin" alpha_min = 1.55 alpha_max = 1.56 alpha_step = 0.1 beta_min = 1.6 beta_max = 1.61 beta_step = 0.1 beam_width = 128 beam_cers = [] logprobexp = [np.exp(p) for p in logprob] for alpha in np.arange(alpha_min, alpha_max, alpha_step): for beta in np.arange(beta_min, beta_max, beta_step): print("alpha=%4.2f, beta=%4.2f" % (alpha, beta)) print('================================') print(f'Infering with (alpha, beta): ({alpha}, {beta})') beam_search_with_lm = nemo_asr.BeamSearchDecoderWithLM( vocab=labels, beam_width=beam_width, alpha=alpha, beta=beta, lm_path=lm_path, num_cpus=max(os.cpu_count(), 1), input_tensor=False, ) beam_predictions = beam_search_with_lm(log_probs=logprobexp, log_probs_length=None, force_pt=True) beam_predictions = [b[0][1] for b in beam_predictions[0]] lm_cer = word_error_rate(hypotheses=beam_predictions, references=references, use_cer=True) print("Beam CER {:.2f}%".format(lm_cer * 100)) beam_cers.append(((alpha, beta), lm_cer * 100)) # alpha=1.55, beta=1.60 # ================================ # Infering with (alpha, beta): (1.55, 1.6) # Beam CER 22.52%
CER = 22.52% まで改善されました22。
推論結果のサンプルも見てみましょう。ちらほら間違いが散見されますが、聞き取れてはいる感じでしょうか。10時間程度のデータなのでこんなものかとも思います。CSJ コーパスなどさらに大規模なデータセットでどこまでいけるか試したくなりますね。
import pandas as pd df = pd.DataFrame([x for x in zip(references, beam_predictions)]) df.columns = ["reference", "beam_prediction"] df.head(10)
6. おわりに
今回は音声認識に挑戦してみました。音声認識モデルを作るとついつい自分で話しかけ、ストリーミングでリアルタイムに認識できるか試したくなります。じつは今回のとりあえずコードを書いて試してみましたが結果はボロボロでした。単一女性の声で学習させておいて、私が話かけるんだから当然ですね。
でもストリーミングを試したい!ということで、問題を音声コマンド認識に置き換えて試してみることにしました。これなら私一人の声を認識するくらいのデータは何とかなりそうです。 「ただ分類してもつまらないな。。。」と思っていたところ、会社の片隅に誰かが組み立てた JetBot を見つけたので、次回はこれを使って実験をしてみようと思います。
the paths" としか書いてませんが、直後の例に“e.g. B(a−ab−) = B(−aa−−abb) = aab” と示されているので、そういうことなんでしょう。
-
Deep Speech: https://arxiv.org/abs/1412.5567 本記事は“End-to-End"を「音声(特徴量)を文字列に変換する」として記述してます。じつは、HMM を廃し重複のない音素列を出力する Chorowski, et al. 2014 (https://arxiv.org/abs/1412.1602) というのがあって、こっちの方が arxiv に投稿された日付が少し若いです。正直、"End-to-End” => P(W|X) として、この節を書き上げた後に気付いてしまって。。。 ↩
-
この辺りの表記や細部はいろいろあるみたいです。https://ja.wikipedia.org/wiki/%E9%9F%B3%E7%B4%A0 ↩
-
実際は単独の音素ではなく前後の音を考慮したトライフォンを用いるようですが、本記事の範囲を逸脱するので詳しい説明は省きます。 ↩
-
最初の“B?"は"BB"と書くと"B15"になってしまって論文みた方が混乱するかと思い。。。 ↩
-
CTC の論文(https://arxiv.org/abs/1412.5567)には "We do this by simply removing all blanks and repeated labels from ↩
-
https://musyoku.github.io/2017/06/16/Connectionist-Temporal-Classification/ ↩
-
Wikipedia のフーリエ変換の記事にあるアニメーションを見るとイメージが沸くと思います。 https://ja.wikipedia.org/wiki/%E3%83%95%E3%83%BC%E3%83%AA%E3%82%A8%E5%A4%89%E6%8F%9B ↩
-
「他ののページでみたメルフィルタバンクは三角形の高さがそろってたけど?」と思われたかもしれませんが、今回使用したライブラリ(librosa)が三角形の面積が等しくなるように正規化するのがデフォルトだったので、それをそのままプロットしています。 ↩
-
分解された各周波数においてバンドパスフィルタの値とパワースペクトルの値を掛け合わせて足し合わせる操作になります。バンドパスフィルタとパワースペクトルをそれぞれベクトルだとすると内積をとるということです。 ↩
-
https://sites.google.com/site/shinnosuketakamichi/publication/jsut ↩
-
https://github.com/espnet/espnet/blob/master/egs/jsut/asr1/ ↩
-
ちなみにレシピをそのまま動かしてもこの数値はでませんでした。transformer-warmup-steps の設定がポイントなようで 12500 あたりがよさそうです。また LM は使用しません。 ↩
-
いまさらですが、実行時に "Could not import torchaudio. Some features might not work.” と警告がでていたので、 torchaudio を入れておけばよかったかも知れません。試してませんが。。。 ↩
-
じつは6GRAMの方が精度が良かったのですが、モデルがかなり大きくなります。あと、パラメータは正直あまり考えていません。。。 ↩
-
何度かやっていると結構スコアは上下にぶれます。最良値は 6GRAM の KenLM を使って Beam CER = 20.26 でした。 ↩