KaldiでJSUTコーパスを使う方法
- Kaldiは音声認識器を自分の好きなようにカスタマイズしながら作成することのできるツールキットです.本記事では,Kaldiの学習に日本語音声のデータセットのJSUTコーパス(ダウンロード)を用いる方法を共有しようと思います.JSUTコーパスは研究用に作成された10時間程度の音声コーパスです.商用目的の使用は作者とのコンタクトが必要なので注意してください.
テキストデータは,CC-BY-SA 4.0などにてライセンスされております.詳細は,LICENCEファイルをご覧ください.音声データは,以下の場合に限り使用可能です.
アカデミック機関での研究
非商用目的の研究(営利団体での研究も含む)
個人での利用(ブログなどを含む)
営利目的の利用を希望される場合,下記をご覧ください.この音声データの再配布は認められていませんが,あなたのウェブページやブログなどでコーパスの一部(例えば,100文程度)を公開することは可能です.できれば,あなたが論文やブログポスト等の成果を公開した際には,私まで連絡してもらえると助かります.このコーパスの貢献を調査することは,我々にとって非常に有効な情報となります.
Kaldiの学習
- 一般にKaldiやJuliusなどを学習させて日常会話などの音声を精度よく認識させようとする場合,数千時間の音声コーパスが必要となります.研究用の音声コーパスには以下のコーパスが有名です.日本語ではCSJコーパスが最も大きなコーパス(おそらく)で600時間程度のデータ量です.これらのコーパスは有料であるので手軽に使用できません.なので今回は無料配布されているJSUTコーパスを使用させていただき,Kaldiの基本的な使い方を共有していきたいと思います.
- CSJ(日本語話し言葉コーパス)
- JNAS(新聞記事読み上げ音声コーパス)
- S-JNAS(新聞記事読み上げ高齢者音声コーパス)
- CEJC(日本語日常会話コーパス)
- Kaldiを自分でゼロからCSJコーパスなどを用いて学習させることはかなり大変で難易度が高いです.なので,レシピと呼ばれるコーパスを使用するために必要なプログラム集が用意されています.現在KaldiではCSJコーパス用のCSJレシピしかないので(英語は他にもたくさんある),今回はCSJレシピをカスタマイズして,JSUTコーパスを使用できるようにしたいと思います.
Kaldiのインストール
- インストールは少し時間がかかります.インストール方法はわかりやすい記事がありますので,そちらを参考にしてください.2つ記事を紹介しますが,どちらも東工大の篠崎先生らが作成してくれたものです.(いつか詳しいインストール方法を書くかも)
- srilmなど付属して必要なものを正しくインストールしないと動かないので気をつけてください.
実行環境
- Ubuntu 18.04.5 LTS
- メモリ 32GB
- GPU GeForce RTX 2080 with Max-Q
- anaconda3-5.3.0
JSUTコーパスの整備
- まず,JSUTコーパスをKaldiで使用できるように整備する必要があります.ここさえできればあとはレシピの力で自動で学習してくれます.やらなければいけないことはシンプルで,CSJが入力される形式と同じようにJSUTを整備すればいいだけです.
- 大きく用意しなければいけないファイルは以下の5つです.
- wav.scp
- text
- lexicon.txt
- utt2spk
- spk2utt
- 作成方法を1つずつ説明していきます.
-
カレントディレクトリは常に
kaldi/egs/csj/jsut
です-
jsut
ディレクトリ はs5
ディレクトリのコピーです.コピーする際はシンボリックリンクファイルが存在するので注意してください.
-
wav.scp 作成方法
- wav.scp: 音声ファイルへのパスが書き込まれたテキストファイル
- JSUTコーパスの音声データはデフォルトでサンプリング周波数44.1kHzです.サイズが大きいので16kHzに変換します.変換しなくてもPCの性能次第では大丈夫かもしれませんが,16kHzでしかやったことないのでエラーが出るかもしれません.
- wav.scp作成用プログラムです
- wav.scpやtext,utt2spkなどほとんどのファイルは発話ID(または話者ID)についてソートされていなければならないので注意が必要です.
import os,sys
import glob
from sklearn.model_selection import train_test_split
import subprocess
import numpy as np
np.random.seed(seed=32)
def sort_file(fname):
subprocess.call(f'sort {fname} > {fname}.sorted',shell=True)
subprocess.call(f'rm {fname}',shell=True)
subprocess.call(f'mv {fname}.sorted {fname}',shell=True)
def convert_wav(wav_data_path,out_dir):
'''
* sampling frequency must be 16kHz
* wav file of JSUT is 48.1kHz, so convert to 16kHz using sox
e.g. FILE_ID sox [input_wavfilename] -r 16000 [output_wavfilename]
'''
for wav_data in wav_data_path:
fname = wav_data.split('/')[-1]
subprocess.call(f'sox {wav_data} -r 16000 {out_dir}/{fname}',shell=True)
subprocess.call(f'chmod 774 {out_dir}/{fname}',shell=True)
def make_wavscp(wav_data_path_list,out_dir,converted_jsut_data_dir):
'''
wav.scp: format -> FILE_ID cat PATH_TO_WAV |
'''
out_fname = f'{out_dir}/wav.scp'
with open(out_fname,'w') as out:
for wav_data_path in wav_data_path_list:
file_id = wav_data_path.split('/')[-1].split('.')[0]
out.write(f'{file_id} cat {converted_jsut_data_dir}/{file_id}.wav |\n')
sort_file(out_fname)
# カレントディレクトリ -> kaldi/egs/csj/jsut (jsutはs5と同じでディレクトリ名を変えただけ.s5からコピーする場合は必ずシンボリックリンクを受け継ぐようにしてください.(cp -a のようにオプションaをつける))
data_dir = './data'
train_dir = f'{data_dir}/train'
eval_dir = f'{data_dir}/eval'
original_jsut_data_dir = '/path/to/JSUT/corpus'
converted_jsut_data_dir = '/path/to/converted/JSUT/corpus'
# make wav.scp of train and eval
wav_data_path = glob.glob(f'{original_jsut_data_dir}/*/wav/*.wav')
# convert JSUT wav data to 16kHz
convert_wav(wav_data_path,converted_jsut_data_dir)
# split data [train_size = 7196, test_size = 500]
train_wav_data_list, eval_wav_data_list = train_test_split(wav_data_path, test_size=500)
make_wavscp(train_wav_data_list,train_dir,converted_jsut_data_dir)
make_wavscp(eval_wav_data_list,eval_dir,converted_jsut_data_dir)
- 44.1kHzから16kHzに変換する際にsoxコマンドを使用しています.ffmpegなどでもできます.
sox [変換したい音声ファイル名] -r 16000 [変換後の音声ファイル名]
- 音声ファイルに実行権限が付与されていない場合があるので,
chmod
で付与しておきます. - wav.scpの形式
ファイルID cat 音声ファイルへのパス |
- 文末はパイプ(|)です.cat した音声情報を次に実行されるコマンドに渡しているわけです.
- 訓練用とテスト用に別々のwav.scpを作成します.
- サンプルプログラムでは訓練用7196音声,テスト用500音声をランダムで選択しています.
- 乱数シードは固定しています.
np.random.seed(seed=32)
- 乱数シードは固定しています.
- textやutt2spkも訓練用とテスト用を作成
- サンプルプログラムでは訓練用7196音声,テスト用500音声をランダムで選択しています.
- wav.scpは以下のようになるはずです
BASIC5000_0051 cat /home/kaldi/egs/csj/jsut/JSUT/BASIC5000_0051.wav |
BASIC5000_0053 cat /home/kaldi/egs/csj/jsut/JSUT/BASIC5000_0053.wav |
BASIC5000_0082 cat /home/kaldi/egs/csj/jsut/JSUT/BASIC5000_0082.wav |
BASIC5000_0094 cat /home/kaldi/egs/csj/jsut/JSUT/BASIC5000_0094.wav |
BASIC5000_0101 cat /home/kaldi/egs/csj/jsut/JSUT/BASIC5000_0101.wav |
...
...
text 作成方法
-
text: 簡単に言えば音声に対する書き起こし(文字起こし)テキストです.つまり,発言された言葉を一言一句テキスト化したものです.書き起こしテキストはJSUTコーパスに最初から用意されています.音声コーパスならば少なくとも音声と書き起こしは含まれています.
-
textの形式
[発話ID] [品詞情報付き書き起こしテキスト]
- e.g. UTT001 明日+名詞 は+助詞/係助詞 晴れ+名詞 です+助動詞
- 句読点は除去しておきます.
- 厳密には タグ(ショートポーズタグ)と置き換える.
-
まず,品詞情報を取得するために書き起こしテキストを形態素解析(この時同時に「単語の読み」も取得)し,
transcript
ファイルを作成します.このtranscript
ファイルからtext
ファイルを作成していきます.transcript
ファイルは'単語+カタカナ読み+品詞'
の形で形態素解析した各単語を書き込みます.- 形態素解析器には***MeCab(mecab-ipadic-neologd)***を使用しました.
-
chasen_tagger = MeCab.Tagger ("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
の部分は自分の環境に合うように変更してください.
-
- 別の形態素解析器を使うと結果が異なるので注意が必要です.
- 形態素解析器には***MeCab(mecab-ipadic-neologd)***を使用しました.
def make_transcript(transcript_data_path_list,train_dir,eval_dir,error_dir,eval_wav_data_list):
'''
text: format -> UTT_ID TRANSCRIPT
* UTT_ID == FILE_ID (one wav file <-> one utterance)
transcript_data_path_list: JSUTコーパスの書き起こしテキストファイル(transcript_utf8.txt)へのパスリスト(transcript_utf8.txtは複数ある)
train_dir: 訓練用
'''
# change hankaku to zenkaku
ZEN = "".join(chr(0xff01 + i) for i in range(94))
HAN = "".join(chr(0x21 + i) for i in range(94))
HAN2ZEN = str.maketrans(HAN,ZEN)
eval_utt_id_list = []
for eval_wav_data in eval_wav_data_list:
eval_utt_id_list.append(eval_wav_data.split('/')[-1].split('.')[0])
word_reading_fname = './word_reading.txt'
word_reading_dict = {} # {'word':'reading'}
with open(word_reading_fname,'r') as f:
lines = f.readlines()
for line in lines:
split_line = line.strip().split('+')
word_reading_dict[split_line[0]] = split_line[1]
out_train_fname = f'{train_dir}/transcript'
out_eval_fname = f'{eval_dir}/transcript'
out_no_reading_word_fname = f'{error_dir}/no_reading_word.txt'
no_reading_word_list = []
chasen_tagger = MeCab.Tagger ("-d /usr/local/lib/mecab/dic/mecab-ipadic-neologd")
with open(out_train_fname,'w') as out_train, open(out_eval_fname,'w') as out_eval,\
open(out_no_reading_word_fname,'w') as no_reading:
for transcript_data_path in transcript_data_path_list:
with open(transcript_data_path,'r') as trans:
line = trans.readline()
while line:
split_line = line.strip().split(':')
utt_id = split_line[0]
transcript = split_line[1].translate(HAN2ZEN)
transcript = transcript.replace('・',' ').replace('-',' ').replace('』',' ').replace('『',' ').replace('」',' ').replace('「',' ')
node = chasen_tagger.parseToNode(transcript)
transcript_line = []
while node:
feature = node.feature
if feature != 'BOS/EOS,*,*,*,*,*,*,*,*':
surface = node.surface
split_feature = feature.split(',')
reading = split_feature[-1]
part_of_speech = '/'.join(split_feature[:2]).replace('/*','')
# extract no reading word to error/no_reading_word_list.txt
if reading == '*':
if surface not in no_reading_word_list:
no_reading_word_list.append(surface)
no_reading.write(f'{surface}\n')
if surface == '、' or surface == '。' or surface == ',' or surface == '.':
transcript_line.append('<sp>')
elif surface != 'ー':
if reading == '*':
reading = word_reading_dict[surface]
transcript_line.append('{}+{}+{}'.format(surface,reading,part_of_speech))
else:
transcript_line.append('{}+{}+{}'.format(surface,reading,part_of_speech))
node = node.next
transcript_line = ' '.join(transcript_line)
if utt_id in eval_utt_id_list:
out_eval.write(f'{utt_id} {transcript_line}\n')
else:
out_train.write(f'{utt_id} {transcript_line}\n')
line = trans.readline()
sort_file(out_train_fname)
sort_file(out_eval_fname)
data_dir = './data'
train_dir = f'{data_dir}/train'
eval_dir = f'{data_dir}/eval'
original_jsut_data_dir = '/path/to/JSUT/corpus'
# split data [train_size = 7196, test_size = 500]
train_wav_data_list, eval_wav_data_list = train_test_split(wav_data_path, test_size=500)
# make text of train and eval
transcript_data_path = glob.glob(f'{original_jsut_data_dir}/*/transcript_utf8.txt')
make_transcript(transcript_data_path,train_dir,eval_dir,error_dir,eval_wav_data_list)
make_text(train_dir,eval_dir)
-
半角・全角どちらでも良いのですが,全角にした方が都合が良いので全角に変換しています.
-
word_reading.txt
- MeCabで形態素解析した際にカタカナの読みが不明であった単語の読み情報.
- ***JSUTコーパスの場合はカタカナの読みが不明な単語がそこまで多くないので自力でなんとかなりましたが,読みが不明な単語が多すぎるコーパスは使う方法を考えなければなりません.***CSJやJNASなどはカタカナの読みが別テキストに要してあるので,それを使用すれば問題はないです.
-
次に,
transcript
ファイルから単語と品詞のペアをtext
ファイルへ書き込みます-
transcritp
ファイルに書き込まれている内容から単語の「カタカナ読み」部分を除去するだけなので簡単です.
-
def make_text(train_dir,eval_dir):
train_transcript_fname = f'{train_dir}/transcript'
eval_transcript_fname = f'{eval_dir}/transcript'
out_train_fname = f'{train_dir}/text'
out_eval_fname = f'{eval_dir}/text'
with open(train_transcript_fname,'r') as trian_trans, open(eval_transcript_fname,'r') as eval_trans, \
open(out_train_fname,'w') as out_train, open(out_eval_fname,'w') as out_eval:
train_trans_line = trian_trans.readline()
while train_trans_line:
split_train_trans_line = train_trans_line.strip().split(' ')
# if <sp> is in End of Sentence then remove it.
if split_train_trans_line[-1] == "<sp>":
split_train_trans_line.pop(-1)
out_train.write(split_train_trans_line[0]+' ') # write utt_id
for i,word in enumerate(split_train_trans_line[2:]):
if word == '<sp>':
out_train.write(' <sp>')
else:
split_word = word.split('+')
out_train.write(' {}+{}'.format(split_word[0],split_word[2]))
out_train.write('\n')
train_trans_line = trian_trans.readline()
eval_trans_line = eval_trans.readline()
while eval_trans_line:
split_eval_trans_line = eval_trans_line.strip().split(' ')
# if <sp> is in End of Sentence then remove it.
if split_eval_trans_line[-1] == "<sp>":
split_eval_trans_line.pop(-1)
out_eval.write(split_eval_trans_line[0]+' ') # write utt_id
for i,word in enumerate(split_eval_trans_line[2:]):
if word == '<sp>':
out_eval.write(' <sp>')
else:
split_word = word.split('+')
out_eval.write(' {}+{}'.format(split_word[0],split_word[2]))
out_eval.write('\n')
eval_trans_line = eval_trans.readline()
sort_file(out_train_fname)
sort_file(out_eval_fname)
data_dir = './data'
train_dir = f'{data_dir}/train'
eval_dir = f'{data_dir}/eval'
make_text(train_dir,eval_dir)
lexicon.txt 作成方法
- lexicon.txt: 1行ごとに
'単語+品詞 発音記号'
が書き込まれた単語辞書のようなテキストファイル.'カタカナ読み'情報を使ってアルファベットによる発音記号がふられます. - 作成方法は
transcript
ファイルに書き込まれた各単語を重複しないようにlexicon.txt
ファイルに書き込み,CSJレシピ内に用意してくれているkana2phone
というファイルを使って発音記号を付与していきます.
def make_lexicon(train_dir,lexicon_dir):
'''
lexicon: format -> 'word'+'part of speech'
'''
transcript_fname = f'{train_dir}/transcript'
out_lexicon_fname = f'{lexicon_dir}/lexicon.txt'
out_lexicon_htk_fname = f'{lexicon_dir}/lexicon_htk.txt'
with open(transcript_fname,'r') as trans, open(out_lexicon_fname,'w') as out:
trans_line = trans.readline()
while trans_line:
split_trans_line = trans_line.strip().split(' ')[2:]
for word in split_trans_line:
if word != '<sp>':
out.write(word+'\n')
trans_line = trans.readline()
subprocess.call(f'sort -u {out_lexicon_fname} > {out_lexicon_htk_fname}',shell=True)
subprocess.call(f'./local/csj_make_trans/vocab2dic.pl -p local/csj_make_trans/kana2phone -e ./data/lexicon/ERROR_v2d -o {out_lexicon_fname} {out_lexicon_htk_fname}',shell=True)
subprocess.call(f"cut -d'+' -f1,3- {out_lexicon_fname} >{out_lexicon_htk_fname}",shell=True)
subprocess.call(f"cut -f1,3- {out_lexicon_htk_fname} | perl -ape 's:\t: :g' >{out_lexicon_fname}",shell=True)
data_dir = './data'
train_dir = f'{data_dir}/train'
lexicon_dir = f'{data_dir}/lexicon'
# make lexicon fomr data/train/transcript
make_lexicon(train_dir,lexicon_dir)
- lexicon.txtは訓練用テキスト(jsut/data/train/transcript)のみから作成します.テスト用テキストを用いるて正しい評価ができなくなります.
utt2spk 作成方法
- utt2spk: 発話IDと話者IDのペアを保存したテキストファイルです.発話IDは
text
ファイルやwav.scp
にも使用されていました.JSUTコーパスの場合は発話IDとファイルIDは同一です.1音声ファイル1発話だからです.CSJコーパスなどは1音声ファイルに複数の発話が含まれているので,発話ID=ファイルIDにはなりません. - JSUTコーパスは一人の話者による音声コーパスなので,話者IDは1つしかありません.話者IDが1つだけだとWarningが表示されますが,無視すれば良いです.しかし,Kaldiには話者適応システムが組み込まれているので,話者は複数いた方がいいかもしれません.(偽の話者IDを作成するなど)
- 作成方法は
jsut/data/train/text
ファイルの発話IDを読み込むだけです.
def make_utt2spk(dir):
'''
In JSUT corpus, speaker number is one person.
It is not good for training Acoustic Model.
'''
text_fname = f'{dir}/text'
out_utt2spk_fname = f'{dir}/utt2spk'
speaker_id = "jsut_speaker"
with open(text_fname,'r') as text, open(out_utt2spk_fname,'w') as out:
text_line = text.readline()
while text_line:
utt_id = text_line.split(' ')[0]
out.write(f'{utt_id} {speaker_id}\n')
text_line = text.readline()
data_dir = './data'
train_dir = f'{data_dir}/train'
eval_dir = f'{data_dir}/eval'
# make utt2spk
make_utt2spk(train_dir)
make_utt2spk(eval_dir)
spk2utt 作成方法
- spk2utt: utt2spkの逆です
- 作成方法はspk2uttから簡単に作成できます.
def make_spk2utt(dir):
utt2spk_fname = f'{dir}/utt2spk'
out_spk2utt_fname = f'{dir}/spk2utt'
with open(utt2spk_fname,'r') as utt2spk, open(out_spk2utt_fname,'w') as out:
speaker_utt_dict = {} # {'speaker_id':'utt_id'}
utt2spk_line = utt2spk.readline()
while utt2spk_line:
split_utt2spk_line = utt2spk_line.strip().split(' ')
utt_id = split_utt2spk_line[0]
spk_id = split_utt2spk_line[1]
if spk_id in speaker_utt_dict:
speaker_utt_dict[spk_id].append(utt_id)
else:
speaker_utt_dict[spk_id] = [utt_id]
utt2spk_line = utt2spk.readline()
for spk_id, utt_id_list in speaker_utt_dict.items():
out.write(f'{spk_id}')
for utt_id in utt_id_list:
out.write(f' {utt_id}')
out.write('\n')
data_dir = './data'
train_dir = f'{data_dir}/train'
eval_dir = f'{data_dir}/eval'
# make spk2utt
make_ spk2utt(train_dir)
make_ spk2utt(eval_dir)
学習開始
- KaldiをGithubからクローンし,必要なツールなどをインストールしたら,まずはjsut用のディレクトリを作成しましょう.
# CSJレシピが置かれているディレクトリへ移動
cd /home/kaldi/egs/csj
# s5ディレクトリをjsutという名前でコピー(必ずオプションaをつけてコピー)
cp -a s5 jsut
-
CSJレシピ通りではJSUTコーパスで学習させることはできないので,いくつかプログラムを変更しなければなりません.
- 学習には
nnet3 TDNN+Chain
を使用します.
- 学習には
-
今回はJSUTコーパスを使用するので
run.sh
などを変更しなければいけません.- CSJコーパスを使用する場合は
kaldi/egs/csj/run.sh
というシェルスクリプトを実行すればデータの準備から音響モデル,言語モデルの学習,評価まで全て行ってくれます.
- CSJコーパスを使用する場合は
-
run.sh以外にもいくつかのファイルを変更する必要があります.以下に変更が必要なファイル名を書いておきます.
- jsut/run.sh
- csjコーパス用のコード部分除去
- jsut用のwav.scp,textなどを作成するためのコード追記
- jsut/local/csj_prepare_dict.sh
- csjコーパス用のコード部分除去
- jsut用のlexiconを使用するためのパスを追記
- jsut/local/chain/run_tdnn.sh
- パラレル処理部分のパラメータの変更
- jsut/local/nnet3/run_ivector_common.sh
- パラレル処理部分のパラメータの変更
- jsut/steps/online/nnet2/train_ivector_extractor.sh
- パラレル処理部分のパラメータの変更
- jsut/run.sh
-
JSUTコーパスは話者数が一人なので,パラレル処理数を大きくすることは基本的にできません.話者数が多い場合はパラレル数を増やすことができます.
-
変更後のプログラムは参照用にgithubに置いておきます.
wav.scp
などを作成するためのプログラムも含まれるのでそれらはkaldi/egs/csj/jsut
配下においてください.上記5つのファイルを変更したら,run.shを実行するだけです.- プログラム
- kaldi/egs/csj/jsut配下におくファイル
- prepare_data.py
- word_reading.txt
-
src/prepared_data
というディレクトリも含まれています.prepare_data.pyが実行された後に,jsut/data/lexicon
というディレクトリが作成され,ERROR_v2d
というファイルが作成されます.このファイルは音素を付与できなかった単語が含まれています.これらの単語は手動で修正する必要があります.念のため修正したものをprepare_data
に用意しておきました.jsut/data/lexicon/lexicon.txt
と差し替えれば使用できます.
-
パラレル処理により学習を高速化することは可能ですが,CPUのスレッド数などが不足してうまく学習できないことがあります.とりあえずパラレル処理などの並列処理はできるだけ行わないようにパラメータを設定してありますので,時間はかかりますが,ある程度の性能を持ったPCなら学習できると思います.
- パラレル処理数を変更するためのパラメータ(run.sh内で設定)
- --nj N
- N の部分を変更すれば良い
- パラレル処理数を変更するためのパラメータ(run.sh内で設定)
-
GPUで学習させる際は,
Exclusive mode
に設定する必要があります-
sudo nvidia-smi -c 3
とコマンドを実行すれば良いです.
-
JSUTコーパスだけで学習させた場合の精度
- データ量は10時間程度しかないので全く学習できていないです.
- WER = 70.78
- CSJコーパス(600時間)の場合,WERはだいたい
0.09
くらいになります.