VOSKによるストリーミング音声認識の使い勝手を改善した話

はじめに

Python音声認識を実行するためのツールとして、最近はVOSKが少しずつ使われるようになってきた。その大きなメリットのひとつはオフラインで動作するという点である。 インストールも容易であり、様々なプラットフォーム上で動作するが、特にMacOSの場合、M1 にも対応したようである

VOSKのサンプルスクリプトは以下のリポジトリにて公開されている。

github.com

例えば "test_microphone.py"はマイク入力からのストリーミング音声認識を実行する。 これらはvoskが提供するモジュールをただ実行するだけのシンプルなスクリプト群である。 各自がアプリケーションに組み込むうえで必要となる、使い勝手の良いインターフェースは自分で仕様を考え、コードを書いて作らねばならない。 Python初心者にはハードルがやや高いものとなっている。

本記事の主旨は表題のように、VOSKのストリーミング音声認識を実行するうえで、単に公式のサンプルスクリプトを動かすだけの何も考えていない状態から脱却し、使い勝手を少し良くしてみようと試みたので、それを紹介するわけである。

改善のヒント

ところで、最近『音声対話システム 基礎から実装まで』(以降、『音声対話システム』)なる書籍が発売された。

www.ohmsha.co.jp

本書は「Python言語による音声対話システムの実装方法を詳しく説明」しているところがウリであり、以下のリポジトリにてソースコードが公開されているのが嬉しい。

github.com

このリポジトリでは音声認識モジュールについても一章を割いて説明している。 紹介されているサンプルスクリプトの不満な点は、

  • Google CloudのSpeech-to-TextのAPIを用いており、オンライン音声認識であること
  • 上記APIを使うまでの設定が手間であること(ユーザ登録やAPIキーの取得など)

が挙げられる。一方で、嬉しい点は

  • マイク入力のためのクラスが定義されており、ストリーミング音声認識がやりやすい
  • 音声認識APIのインタフェースのクラスが定義されている

が挙げられる。

そこで、上記サンプルスクリプトを参考にすれば、VOSKの使い勝手が良くなるのではと考えたのである。

ソースコードと動かし方

デモンストレーション用スクリプトのリンクを置く。後者は発話区間検出機能がついている。

これらを動かすにあたり、以下は必要である。

python3 -m pip install vosk
python3 -m pip install sounddevice

VOSKの音声認識モデルは公式サイトで配布されているものを用いる。

alphacephei.com

vosk-model-small-ja-0.22.tar.gzをダウンロード・解凍し、デモンストレーション用スクリプトと同じところに置く。その際、音声認識モデルのフォルダ名を"model"としておく。要するに、VOSKによる音声認識がすぐ動く状態にしておく。

特徴

『音声対話システム』のサンプルスクリプトではマイク入力を受け取るためにPyAudioを用いていたが、本実装ではマイク入力にsounddeviceを用いることにした。それによりPyAudio関連のモジュールがすっかりなくなっている。

上記デモンストレーションのmain関数を示そう(asr_streaming.py)。

def main(chunk_size=8000):
    """音声認識デモンストレーションを実行.

    Args:
       chunk_size (int): 音声データを受け取る単位(サンプル数)
    """
    SetLogLevel(-1)  # VOSK起動時のログ表示を抑制

    # 入力デバイス情報に基づき、サンプリング周波数の情報を取得
    input_device_info = sd.query_devices(kind="input")
    sample_rate = int(input_device_info["default_samplerate"])

    # マイク入力を初期化
    mic_stream = MicrophoneStream(sample_rate, chunk_size)

    # 音声認識器を構築
    recognizer = KaldiRecognizer(Model("model"), sample_rate)

    # マイク入力ストリームおよび音声認識器をまとめて保持
    VoskStreamingASR = namedtuple(
        "VoskStreamingASR", ["microphone_stream", "recognizer"]
    )
    vosk_asr = VoskStreamingASR(mic_stream, recognizer)

    print("<認識開始>")
    recog_result = get_asr_result(vosk_asr)
    print(f"認識結果: {recog_result}")
    print("<認識終了>")

VOSKの公式サンプルは入力され続ける音声を、無限ループのなかでひたすら文字起こしするものであり、プログラムを終了するにはCtrl-Cで強制終了させるしかない。これはとても使い勝手が悪い。一方で上記のmain関数ではget_asr_result関数を呼び出し、「1発話」分の音声認識を実行できる。関数の実行と1発話の音声認識が紐付くので、他のモジュールでも使いやすい(はずである)。実際、このような設計が『音声対話システム』でも用いられているから。

音声認識自体はVOSKに基づくので、『音声対話システム』のオリジナルに比べて、ソースコードがスッキリしたのもメリットになるだろう。

おわりに

書籍『音声対話システム』のサンプルスクリプトのおかげで、VOSKの音声認識を使いやすくすることができた。

著者の方々にはとても感謝している。

AIミュージックバトル!『弁財天』のスターターキットをPyTorchに移植してアドリブメロディの自動生成を試してみた

はじめに

最近、下記のイベントが開催されることがアナウンスされた。

benzaiten.studio.site

AIミュージックバトル!『弁財天』は「伴奏」から「アドリブメロディ」をAIで生成し「どれだけイケてるメロディか」を競うAI自動作曲コンテストです。』 とのことである。 本コンテストではTensorFlowベースのスターターキット(Google Colabで実行可能)が提供されており、自動作曲初心者でも無理なく始められるようになっている。

筆者はPyTorchユーザなので、スターターキットのPyTorch版を作成しておきたいと思ったわけである。自動作曲自体に興味があったということもある。

注意:本記事のPyTorch移植はGoogle Colab上での実行を想定していない。すなわち、Colab Notebookを提供するものではない。あくまでGPUを搭載した各自の卓上計算機(具体的にはUbuntu)で実行されることを想定した実装である。 とはいえ、Google Driveを適切にマウントすれば、実装をすぐに使えるようになるだろう。

12月3日追記

以下の記事にて、PyTorch版スターターキットのColab Notebookを提供するようにした。

tam5917.hatenablog.com

スターターキットについて

仕様を記述したドキュメントは主催者により用意されている。ぜひ一読されたい。

スターターキットが提供するモデルの概要

コンテストでは伴奏のMIDIファイルとコード進行を表すデータ(CSV形式)が主催者より与えられ、これが自動作曲システムへの入力となる。求められる出力は伴奏にメロディーを付与したMIDIファイルである。

これを受けてスターターキットでは、サンプルのMIDIファイルおよびコード進行データ(CSV形式)が提供されている。これらを前処理し、以下のようにメロディ生成モデルへの入出力としている。

  • モデルの入力:メロディをone-hot エンコーディング(ノート番号を表現)、コード(chord)をmany-hot エンコーディング、休符の有無を0/1で表したうえで、それらを結合したベクトル(61次元)系列
  • モデルの出力:同じくone-hot エンコーディングされたメロディ + 休符からなるベクトル(49次元)系列
  • モデルの構造:LSTMを用いて入力系列を固定次元の隠れ状態ベクトルへとエンコードし、そして再びLSTMを用いて隠れ状態ベクトルから出力系列をデコードする。それら2つのRNNの間に、変分自己符号化器(Variational AutoEncoder; VAE)が挟まっている形である。

LSTMエンコーダ:(メロディ+コード系列)→LSTM → 隠れ状態ベクトル h

VAEエンコーダ:隠れ状態ベクトル h → 潜在変数zの平均μ, 分散σ

VAEデコーダ: 潜在変数のサンプリング z' 〜 N(μ, σ) → 隠れ状態ベクトル h'

LSTMデコーダ:隠れ状態ベクトル h' →LSTM→(メロディー系列)

のようにデータが流れる。スターターキットの説明・実装ではLSTM部分を含めてVAEのエンコーダ / デコーダとなっているが、上記のようにモジュールごとに分けて解釈するのが個人的には理解がスムーズだった。上記VAEにはサンプリングの手続きが入るため、メロディ生成にランダム性を持たせることが可能となる。つまり生成のたびに毎回異なるメロディが得られる。ちなみに、隠れ状態ベクトル hとは最終フレーム(時刻)におけるLSTMの出力ベクトルを指す。

実装

以下のリポジトリに置いた。Enjoy!

github.com

必要なパッケージ

Ubuntuで動かす場合、FluidSynthに関するサウンドフォントが必要なので入れておく。

apt-get install fluidsynth

MacOSではFluidSynthに関するパッケージがbrewから提供されているので、参考まで。

brew install fluid-synth

ほか、pipでパッケージを入れておく(順番は適当)。

python -m pip install torch
python -m pip install midi2audio
python -m pip install music21
python -m pip install hydra-core
python -m pip install joblib
python -m pip install progressbar2
python -m pip install matplotlib
python -m pip install numpy
python -m pip install scipy

実装の概要

主要なファイルと機能について示しておく。

ファイル名 機能
config.yaml 各種設定を記述したYAML形式のファイル
preprocess.py 前処理を実施するスクリプト
training.py モデルの訓練を実施するスクリプト
synthesis.py 訓練済のモデルを用いてメロディーを合成するスクリプト

コードの動かし方は、

  1. config.yamlの編集
  2. preprocess.py による前処理の実施
  3. training.pyによるモデル訓練の実施
  4. synthesis.pyによるメロディー合成の実施

となる。preprocess.pyは一度だけ動かせばよい。 なお前処理にはモデル訓練に用いるMusicXML群のダウンロードや、それらからの特徴量抽出、またスターターキットが提供する伴奏データ・コード進行データのダウンロードが含まれる。

synthesis.pyには合成結果のMIDIファイルへの書き出し、Wavファイルへのポート、またメロディのピアノロール画像の作成・保存が含まれる。

メロディ生成実験

実装に基づいてスクリプトを動かしてみた。

実験条件

訓練データは下記サイトにて提供されている50個のMusicXMLファイルから準備した。 homepages.loria.fr

計算機環境を示す。

計算機環境 バージョンなど
OS Ubuntu 22.04
CPU Intel i9-9900K
GPU RTX2070
Python 3.10.6
PyTorch 1.12

訓練の基本的な設定は以下の通りである。

項目 設定
ミニバッチサイズ 32
エポック数 3000
学習率 0.0003

スターターキットでは学習率が0.0005だったが、これはやや大きかったので、本記事の実験では学習率をやや下げたうえで、MultistepLRにより学習率をスケジューリングした。エポック数は大きめに取ったが、これでも訓練に要する時間はトータルで1時間を下回った。

その他、ニューラルネットワークの細かい設定(ユニット数など)は上記リポジトリのconfig.yamlを見よ。

訓練が終わったのち、synthesis.pyを用いてメロディを生成する前に、サウンドフォントファイルへのパスもconfig.yamlで指定しておくことは注意を要する。本実験(Ubuntu環境)では以下のパスであった:

sound_font: "/usr/share/sounds/sf2/FluidR3_GM.sf2"

実験結果

「伴奏のみ」と「伴奏+メロディ」音源を併せて示す。

伴奏のみ(主催者から提供されたMIDI 伴奏+メロディ

メロディ生成結果を図1に示す(縦軸と横軸にラベルが書かれていないので、減点なのだが)。ピアノロールもどきであり、横軸は時間を表す認識でよい。縦軸の下に向かうほど音が高くなっている点は注意を要する。一番下は休符要素を表す。

図1: 生成されたメロディ

自動生成メロディもくり返し聞くとジワジワ来るものはある。これはこれでアリではないかと(評価が難しい!コンテスト実施の必要性を実感)。

実装の舞台裏やTIPS、TensorFlow版との違いなど

  • training.pyは結局オレオレ実装になってしまった 。main()に全てをベタ書きでもよいとは思ったが、ひとつひとつの関数をコンパクト(行数少なめ)にしたかった。
  • スターターキットにおいて、VAEの全結合層(隠れ状態ベクトル h, h'とμ, σの間; input-latent)は特に活性化関数を与えていなかった。一方、本記事の実装では、エンコーダとデコーダに全結合層がそれぞれ一つ余分に増える形となった(input-hidden-latent)。そこで、input-hidden間とhidden-latent間に活性化関数としてReLU関数をそれぞれ1つ入れてみた。完全移植でない所以でもある(これぐらいの変更は別に構わないだろう!気になるひとは取り除けばよい)。
  • デコーダの入力は、VAEから生成された隠れ状態ベクトル h'を全フレーム(時刻)に渡って複製することで与えている。この点も改良のしどころであり、例えば、直前時刻のデコーダ出力を当該時刻のデコーダ入力として与えるよう変更するのは容易である(いわゆるseq2seqの定式化)。
  • VAEにバッチ正規化は入れていない(入れると訓練誤差が減らない)。本記事の読者が色々と試行錯誤する際は、バッチ正規化やDropoutをはじめとする各種の正則化テクニックを組み込んでみるのも良いだろう。
  • スターターキットではグローバル変数が定義され、それぞれの関数の中に埋め込まれている。今回のPyTorch実装ではそれらグローバル変数たちをソースコードから全て追い出し、YAML形式の設定ファイル(config.yaml)に書くことにした。
  • 今回は組み込み関数のgetattrとhydraパッケージの力を借りて、YAMLに基づいてオプティマイザや学習率スケジューラを構築できるように実装した。ソースコードには具体的なオプティマイザのクラス名、例えば"Adam"や"RAdam"は現れてこないので、汎用性が増す利点がある。その際、ttslearnの実装を大いに参考にした。
  • PyTorchではクロスエントロピー損失関数(CrossEntropy)への与え方がTensorFlowと異なる。予測データはsoftmaxを取る直前のいわゆるlogitsの形で与える必要があり、さらにターゲットラベルはone-hotエンコードしておく必要はない。この仕様のため、スターターキットが提供する一部の関数に仕様の変更が生じた(calc_xy()など)。
  • 主催者より提供された伴奏MIDIをmidi2audioによりwavへ出力すると、曲後に約1分間の無音が挿入される現象を確認している。実体のfluidsynthコマンド単体でも同様の現象が確認された。別のMIDIファイルで試してみると、そのような無音挿入は生じなかった。よって少なくとも伴奏MIDIに特有の現象であるが、原因は不明である。
  • 補助的に作成したモジュールたちは以下の通りである:
ファイル名 機能
dataset.py データセットのクラスBenzaitenDatasetを定義
factory.py オプティマイザ、スケジューラ、損失関数クラスの各インタフェースを定義
model.py メロディを生成するためのクラスSeq2SeqMelodyComposerを定義
util.py スターターキットで提供される関数群を定義

おわりに

スターターキットをPyTorchに移植してみて、確かに色々と改良を図れる箇所がいくつもあることを確認した。コンテストへのエントリー自体はさておき、自動作曲AIでしばらく遊んでみることにする。

最後に、AIミュージックバトル!『弁財天』の関係者各位に深く感謝したい。スターターキットの提供は英断だったと思う。コンテストの成功を祈る。

Let's ad-lib!

Transformerを用いてオノマトペから環境音を合成する手法をPyTorchで実装した(Transformer版 Onoma-to-Wave)

はじめに

以前、Onoma-to-Waveを実装した記事を書いたことがあった:

tam5917.hatenablog.com

Onoma-to-Waveとはオノマトペ(文字列)を環境音(スペクトログラム)に変換するモデルである。RNNに基づくencoderとdecoderから構成されており、いわゆるSequence-to-Sequence(Seq2Seq)の構造を持つ。 それらをTransformerによって置き換えたモデルが、Onoma-to-Waveの著者らによって実はすでに提案されている。

岡本 悠希,井本 桂右,高道 慎之介,福森 隆寛,山下 洋一,"Transformerを用いたオノマトペからの環境音合成," 日本音響学会2021年秋季研究発表会,pp. 943-946,2021.

上記は国内学会における査読なしの予稿論文であり、2022年10月時点でTransformerに基づくモデルの提案自体は査読付き論文として出版されていない。ただし、著者らによる以下の論文では、本モデルを含む形で主観評価実験が実施されている。

arxiv.org

著者による環境音合成のデモンストレーションは以下から試聴できる。

y-okamoto1221.github.io

モデルの全容を把握するには以下のPDFファイルが役に立つだろう。

Transformer版Onoma-to-Waveの実装は2022年の本記事執筆時点では公開されていないため、自分で実装して環境音合成の再現実験を試みたというわけである。

事前準備

データセットは冒頭に示した記事と同様に、RWCP 実環境音声・音響データベース (Real World Computing Partnership-Sound Scene Database; RWCP-SSD) を利用した。

research.nii.ac.jp

実装の概要

ソースコードは以下のリポジトリに置いた。Enjoy!

github.com

前回と同様にフォルダを分けている。

フォルダ名 説明
unconditional 音響イベントによる条件づけなし(オノマトペのみを入力)
conditional 音響イベントによる条件づけあり(オノマトペと音響イベントラベルを入力)

それぞれのフォルダには以下の主要なスクリプトたちを用意した。

ファイル名 機能
preprocess.py 各種の前処理を実施するスクリプト
training.py モデルの訓練を実施するスクリプト
inference.py 訓練済のモデルを用いて環境音を合成するスクリプト(訓練に用いなかったテストデータを対象とした合成)
synthesis.py 訓練済のモデルを用いて環境音を合成するスクリプト

preprocess.pyでは前回記事で紹介した前処理に加えて、今回は音響特徴量をオーディオデータから抽出して保存する処理を含んでいる。その理由については本記事の最後で述べているので、参考まで。

synthesis.pyではDataset, DataLoaderが使われず、またオノマトペ音素文字列として自由に与えることができる点に、inference.pyとの違いがある。例えば、オノマトペが"ビイイイイ"(/b i i i i i/)なら "b i i i i i" のように与えることができる、といった具合である。手元で色々とオノマトペを変えながら環境音合成を試す際にはsynthesis.pyのほうが便利である。

各種の設定は前回記事に引き続きyamlファイル(config.yaml)に記述し、それを読み込んで使う形とした。したがってコードの動かし方は、

  1. config.yamlの編集
  2. preprocess.py による前処理の実施
  3. training.pyによるモデル訓練の実施
  4. inference.py / synthesis.pyによる環境音合成の実施

となる。preprocess.pyは一度だけ動かせばよい

モデルの条件付けに必要な音響イベントラベルの埋め込みにはいくつかの選択肢が考えられるが、本記事では以下の埋め込み方法で得られた結果を紹介する。

memory(エンコーダ出力)とイベントラベルを結合したのちに線形変換する

『RWCP 音声・音響データベースを用いた環境音・効果音合成の検討とオノマトペ拡張データセットの構築』では、 これ以外に「デコーダ入力とイベントラベルを結合し、線形変換する」ことでイベントラベルを埋め込む方法を併せて採用している。 しかしながら本記事に先立つ予備実験の結果から、memoryに埋め込むだけで環境音の合成にとって十分な効果をもたらすことを確認している。 ちなみに今回の実装ではどちらの埋め込み方法も選択できる(両方ON、両方OFF、どちらか片方のみONという切り替えが可能)。

環境音合成実験

実験条件

RWCP-SSDから以下の10種類の環境音を実験に採用した(カッコ内はデータベースにおける識別子)。

  • 金属製のゴミ箱を叩いたときの音(trashbox)
  • カップを叩いたときの音(cup1)
  • ベルを鳴らしたときの音(bells5)
  • 紙を破るときの音(tear)
  • ドラムを叩いたときの音(drum)
  • マラカスを振ったときの音(maracas)
  • ホイッスルを吹いたときの音(whistle3)
  • コーヒー豆をミルで挽いたときの音(coffmill)
  • 電気シェーバーを動かしたときの音(shaver)
  • 目覚まし時計の音(clock1)

各音響イベントにつき95個のオーディオファイルを訓練に用いた。さらに各オーディオファイルに付随するオノマトペを15個ランダムに選択して訓練に用いた。

記事が長くなるので、実験条件の詳細は折りたたみ形式で示す。ネットワークの設定など、どうしても気になるひとだけクリック。

実験条件を表示する

計算機環境を示す。

計算機環境 バージョンなど
OS Ubuntu 22.04
CPU Intel i9-9900K
GPU RTX2070
Python 3.10.6
PyTorch 1.12

訓練の基本的な設定は以下の通りである。

項目 設定
ミニバッチサイズ 32
エポック数 Seq2SeqTransformerは1500
Mel2linearは1000
学習率 Seq2SeqTransformerは0.0003
Mel2linearは0.0001
勾配クリッピングしきい値 1.0

なおTransformerの訓練には"Attention is All You Need"(アテンションしか勝たん)の論文で採用された学習率スケジューリングを採用した。warm-upのエポックは300とした。Mel2linearはスケジューリングなし。

音響特徴量の設定は以下の通りである。

項目 設定
音響特徴量 80次元のメルスペクトログラム
FFTの窓長 2048
フレーム長 2048
フレームシフト 512

続いてTransformerまわりの設定を示す。

設定項目 設定値
埋め込み次元 512
Multi-head attentionのヘッド数 4
オプティマイザ RAdam
エンコーダの層数 3
デコーダの層数 3
Position-wise Feed-Forward Networkの次元数 1536
Activation function ReLU
Dropout率 0.1

Scaled Positional EncodingについてはDropout率を0.1に設定した。

Encoder Prenetの設定は以下の通りである。1次元のCNNを3層重ねている。

項目 設定
埋め込み次元 512
1d-CNNのチャネル数 512
1d-CNNのカーネルサイズ 3
1d-CNNの層数 3
活性化関数 ReLU
Dropout率 0.5

Decoder Prenetは以下の通りである。全結合層が2層からなる。また訓練時及び推論時で常にDropoutを有効にしている。 これにより、合成音の多様性を確保する狙いがある。

項目 設定
埋め込み次元 512
層数 2
活性化関数 ReLU
Dropout率 0.5

Postnetの設定は以下の通りである。

項目 設定
埋め込み次元 512
1d-CNNのチャネル数 512
1d-CNNのカーネルサイズ 5
1d-CNNの層数 5
活性化関数 tanh
Dropout率 0.5

CBHGの設定は以下の通りである。

項目 設定
畳み込みネットのバンク数 8
Highway Netの層数 4
プロジェクションの次元数 512
Dropout率 0.5

実験結果

音響イベントによる条件付けを行わない場合、すなわちオノマトペのみが入力の場合、異なるクラス(音響イベント)の音として合成されたり、他のクラスの音が冒頭に混じるなど、合成失敗となるケースが多く確認された。しかしながら条件付けを行うことで、クラスの食い違いのない合成音が得られた。

  • bells5(オノマトペ: リンリーン 音素列: / r i N r i: N /)
条件付けなし 条件付けあり


  • clock1(オノマトペ: チリリリリ 音素列: / ch i r i r i r i r i /)
条件付けなし 条件付けあり


  • coffmill(オノマトペ: ガサガサ 音素列: / g a s a g a s a /)
条件付けなし 条件付けあり


条件付けなし 条件付けあり


条件付けなし 条件付けあり


条件付けなし 条件付けあり


条件付けなし 条件付けあり


  • tear(オノマトペ: スウィイイイイン 音素列: / s u w i i i i i N /)
条件付けなし 条件付けあり


条件付けなし 条件付けあり


条件付けなし 条件付けあり


続いて、共通のオノマトペを与えて、条件付けのクラスを色々と変えた場合の合成例を示す。ここでSeq2Seqとは前回Onoma-to-Waveによる合成法を指す。

Seq2Seq Transformer
"whistle3"で条件付け "whistle3"で条件付け
"shaver"で条件付け "shaver"で条件付け
"tear"で条件付け "tear"で条件付け


Seq2Seq Transformer
"maracas"で条件付け "maracas"で条件付け
"coffmill"で条件付け "coffmill"で条件付け


Seq2Seq Transformer
"cup1"で条件付け "cup1"で条件付け
"whislte3"で条件付け "whislte3"で条件付け
"shaver"で条件付け "shaver"で条件付け


Seq2Seq Transformer
"drum"で条件付け "drum"で条件付け
"transhbox"で条件付け "trashbox"で条件付け


Seq2Seq Transformer
"bells5"で条件付け "bells5"で条件付け
"clock1"で条件付け "clock1"で条件付け

実装の舞台裏など

  • モデルのEncoder Prenet、Decoder Prenet、Postnetの実装にあたり、Ryuichi Yamamoto氏が制作したttslearnを大いに参考にした。多少の修正を加えたが、ほぼそのままadaptした。ttslearnにかかるコピーライト表記はもちろん忘れずに行っている。
  • Transformerの実装はPyTorch公式のモジュールを用いた。そのモジュールまわりで最初に戸惑うのは「マスク」の与え方である。今回はsource側の入力にオノマトペ(文字列)、target側の入力にメルスペクトログラム(スペクトル列)という構成であり、系列データに対するベーシックな定式化を踏襲した。このとき、source self-attentionのためのマスク(src_mask)はNonesource-target attentionのためのマスク(memory_mask)もNoneでよい。つまりattentionの計算に関して特定のsource入力を除外するマスクは必要としない。target self-attentionのためのマスク(tgt_mask)はいわゆる先読みを防ぐ階段状のマスクを与えている。ほか、ミニバッチ内のオノマトペ長やフレーム長は保持しておき、パディングに応じたマスク(src_key_padding_mask, tgt_key_padding_mask, memory_key_padding_mask)を与えている。
  • preprocess.pyに特徴量抽出処理を追加した理由:前回記事のOnoma-to-Waveの実装では、モデル訓練の際、Dataset構築時に音響特徴量の抽出処理を毎回やり直していた。しかしながら、訓練の度に同じ処理を繰り返すのは無駄が大きく、またバグの温床になるため改善が必要と考えた。そこで、抽出処理をDataset構築時に行うのでなく、preprocess.pyの中で一度だけ行う方式に変更した。以降の訓練では抽出済の特徴量を毎回ロードするだけでよい。ただ、毎回抽出し直すにせよ、事前に抽出してロードするにせよ、特徴量全体がメモリに乗ることを暗黙の前提としている(今回は130MBほど)。なお前処理に関する議論は以下のスライドを参考にするとよい(15枚目あたり)。

speakerdeck.com

  • 補助的に作成したモジュールたちは以下の通りである。
ファイル名 機能
dataset.py データセットのクラスOnomatopoeiaDatasetを定義
mapping_dict.py オノマトペ(英字)の列と数値列を相互に変換する辞書を与えるクラスMappingDictを定義
models.py オノマトペから対数メルスペクトログラムに変換するSeq2SeqTransformerクラス、対数メルスペクトログラムから対数リニア(スケール)スペクトログラムに変換するMel2Linearクラスを定義
module.py Seq2SeqTransformerクラスとMel2Linearクラスを定義するのに必要なモジュール群(クラス)を定義
scheduler.py Seq2SeqTransformerクラスで利用可能な、学習率を調整するスケジューラーのクラスTransformerLRを定義
trainer.py ミニバッチによるモデル訓練のループを回し、また訓練済みのモデルを用いて環境音を生成するクラスTrainerを定義
util.py マスク作成や音響特徴量抽出など補助的な関数群を定義
  • Seq2SeqTransformerというクラス名はPyTorchのチュートリアル由来である。せっかくなら今回の環境音合成に特化したクラス名が良いと考え、Onoma2WaveTransformerOnoma2Melといった代案を考えてみたが、どうもしっくりこなかった。前者はクラス名が長すぎる気がしており、後者は具体的な処理内容(オノマトペから対数メルスペクトログラムへの変換)が反映され悪くないが、Transformerを使っている感が薄れる気がした。

  • Seq2SeqTransformerクラスとMel2Linearクラスは独立に訓練させるため、それぞれ専用のTrainerクラスを用意して別々のモジュール(スクリプト)にすることも実装当初に考えた。しかし、モジュールをむやみに増やすのはあまり美しくない設計と考え、両クラスの訓練と推論をひとつのTrainerクラスに統合したのである。それぞれの訓練のON/OFFはconfigファイルで切り替える設計とした。

  • TransformerLRの実装は以下の記事で紹介したものである。実は本記事の布石であった。

tam5917.hatenablog.com

おわりに

実装を通じて、Transformerの気持ちが少し分かった気がする。特にself-attentionやMulti-head attentionまわりはマスクの使い方を含めて勉強になった。やはり実装は理解への近道。

Pythonによる日本語チャットボットの構築 in 2022 〜ChatterBotインストール編〜

はじめに

Pythonでチャットボットを作りたくなった。

ChatterBotが便利に使えそうだということで、インストールログを残しておく。

動作環境

  • Python3.9.13
  • macOS BigSur 11.6

インストール

まず仮想環境を準備する。

python3 -m venv chatbot

chatbotディレクトリができるので、移動する。その後、binディレクトリにactivateコマンドがあるので、sourceで仮想環境を起動する。

cd chatbot
source bin/activate

以下の手順を実行する。

git clone  https://github.com/feignbird/ChatterBot-spacy_fixed
pip3 install ./ChatterBot-spacy_fixed
pip3 install chatterbot-corpus
pip3 uninstall pyYAML
pip3 install pyYAML==5.3.1
python -m spacy download en_core_web_sm

pip3 install chatterbot-corpus実行時、chatterbot-corpusが要求するpyyamlのバージョンが低く、一時的にpyyaml(実行時はver 5.3.1)がアンインストールされ、ver 3.13がインストールされる。以下はその際のメッセージ抜粋。

Installing collected packages: PyYAML, chatterbot-corpus
  Attempting uninstall: PyYAML
    Found existing installation: PyYAML 5.3.1
    Uninstalling PyYAML-5.3.1:
      Successfully uninstalled PyYAML-5.3.1
  Running setup.py install for PyYAML ... done
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
chatterbot 1.1.0a7 requires pyyaml<5.4,>=5.3, but you have pyyaml 3.13 which is incompatible.
Successfully installed PyYAML-3.13 chatterbot-corpus-1.2.0

しかしながら、これはchatterbot-corpusのメンテがほぼされていないためであって、pyyamlは新しくしてもOKなのである。 そこで古いpyyamlをアンインストールし、改めて新しいものを入れるわけである。

以上のコマンド群が走れば、あとは日本語用のコーパス(会話テンプレート)をセットすれば完了する。

chatterbot-corpusのリポジトリをzipでダウンロードする。 github.com

ダウンロードされたファイルはchatterbot-corpus-master.zipである。 これを適当なところで解凍する。そして chatterbot_corpus-master/chatterbot_corpus/data/から、japaneseディレクトリをコピーする。 その後、venvの仮想環境フォルダ(chatbot)以下の chatbot/lib/python3.9/site-packages/chatterbot_corpus/data/ にコピーする。

テスト

from chatterbot import ChatBot
from chatterbot.trainers import ChatterBotCorpusTrainer

chatbot = ChatBot('test')
trainer = ChatterBotCorpusTrainer(chatbot)
trainer.train("chatterbot.corpus.japanese")
response = chatbot.get_response("こんにちは")
print(response)

これを適当な名前で保存する(test_chat.pyとか)。

そして実行する。

python3 test_chat.py

すると以下の実行結果が得られる。

Training ai.yml: [####################] 100%
Training botprofile.yml: [####################] 100%
Training computers.yml: [####################] 100%
Training conversations.yml: [####################] 100%
Training emotion.yml: [####################] 100%
Training food.yml: [####################] 100%
Training gossip.yml: [####################] 100%
Training greetings.yml: [####################] 100%
Training health.yml: [####################] 100%
Training history.yml: [####################] 100%
Training humor.yml: [####################] 100%
Training literature.yml: [####################] 100%
Training money.yml: [####################] 100%
Training movies.yml: [####################] 100%
Training politics.yml: [####################] 100%
Training psychology.yml: [####################] 100%
Training science.yml: [####################] 100%
Training sports.yml: [####################] 100%
Training trivia.yml: [####################] 100%
お元気ですか?

最後の「お元気ですか?」の部分はランダムに変わる(「こんにちは」に変わったりなど)。その返答の候補たちはgreetings.yamlに書いてある。つまりこれらのyamlファイルを調整することで返答は作り込める。

さらに進んで

上記のテストスクリプトは実行のたびにtraining(訓練)が走るので、非効率である。しかしながらchatbotを「保存」するということはできない。厳密には保存という概念ではなく、データベースの「更新」なのである。これがChatterBotの仕様である。

テストスクリプトを実行した後、db.sqlite3というファイルが保存されていることに気づく。これが先ほど訓練したchatbotのデータベースである。ファイル名はデフォルトの状態であり、例えば以下のようにすれば保存ファイルを変更できる。

from chatterbot import ChatBot
from chatterbot.trainers import ChatterBotCorpusTrainer
 
chatbot = ChatBot(
    'test',
    database_uri='sqlite:///database.db'
)

trainer = ChatterBotCorpusTrainer(chatbot)
trainer.train("chatterbot.corpus.japanese")

データベース名はdatabase.dbである。このデータベースを再度読み込んでchatbotを構築する際には、

from chatterbot import ChatBot
 
chatbot = ChatBot(
    'test',
    database_uri='sqlite:///database.db',
    # read_only=True  # 読み込み専用、上書きしないときにはこれを指定する
)

response = chatbot.get_response("こんにちは")
print(response)

を使う。データベースを更新済なので、再度の訓練は不要である。当然、新たにyamlファイルを更新すれば、訓練してデータベースを更新する必要はある。

ChatterBotにはまだいくつか訓練するためのクラスが存在しており、必ずしもyamlの編集・更新が必要というわけではないらしい。

おわりに

ひとまずインストールは完了した。

ChatterBotを「賢く」するには、何らかの方法でデータベースを更新する必要がある(自動・半自動・手動)。 それはまた記事を改めて書くことにする。

参考

インストール全般はこちらのスレッドを参考にした。 github.com

言語モデルを導入して賢くしていくには、以下の記事が参考になろう。 zerofromlight.com

Transformerの学習率を調整するSchedulerクラスをPyTorchで書いた

はじめに

Attention is All You Needという論文で「warmup & ステップ数の逆平方根で学習率を減衰」させる学習率スケジューリングが提案されたが、そのようなスケジューリングを手軽に行うスケジューラを書いたということである。

ソースコード

from torch.optim.lr_scheduler import _LRScheduler

class TransformerLR(_LRScheduler):
    """TransformerLR class for adjustment of learning rate.

    The scheduling is based on the method proposed in 'Attention is All You Need'.
    """

    def __init__(self, optimizer, warmup_epochs=1000, last_epoch=-1, verbose=False):
        """Initialize class."""
        self.warmup_epochs = warmup_epochs
        self.normalize = self.warmup_epochs**0.5
        super().__init__(optimizer, last_epoch, verbose)

    def get_lr(self):
        """Return adjusted learning rate."""
        step = self.last_epoch + 1
        scale = self.normalize * min(step**-0.5, step * self.warmup_epochs**-1.5)
        return [base_lr * scale for base_lr in self.base_lrs]

学習率のスケジューラをオリジナルで書きたい場合は、_LRSchedulerを継承し、get_lr関数を自作するのが良い。PyTorch本家がそのような実装となっている。

github.com

実装において、min(step**-0.5, step * self.warmup_epochs**-1.5)の部分が学習率調整の本質である。なおself.normalizeは値の範囲を0から1に収めるための正規化定数の役割を果たす。 これらの積によりscaleが計算され、それをoptimizerの構築時に指定した学習率に掛けることで学習率が更新される。

注意すべきは、上記の実装はepoch単位で学習率を調整することが前提になっているという点である (step = self.last_epoch + 1というあたり)。

学習の初期段階では0に近い係数(scale)が学習率に掛けられるが、徐々に増えていき、warmup_epochsまでエポック数が達すると係数は1で最大となる。 その後は係数が1より小さくなるため、学習率が減衰されていく。

参考文献

arxiv.org

オノマトペ(擬音語)から環境音を合成するニューラルネットワーク(Onoma-to-Wave)をPyTorchで実装した

はじめに

下記の雑誌論文が最近出版された。

Yuki Okamoto, Keisuke Imoto, Shinnosuke Takamichi, Ryosuke Yamanishi, Takahiro Fukumori and Yoichi Yamashita, "Onoma-to-wave: Environmental Sound Synthesis from Onomatopoeic Words", APSIPA Transactions on Signal and Information Processing: Vol. 11: No. 1, e13, 2022.

www.nowpublishers.com

この論文では「オノマトペ」(擬音語)から環境音を合成する手法が提案されている。手法の核となる部分を一言で要約すれば、「オノマトペからスペクトログラムへのSeq2Seqに基づく系列変換モデル」である。

以下、著者らによるスライドに目を通したうえで本記事を読まれたい。

www.slideshare.net

2022年7月現在、著者らによる提案手法「Onoma-to-Wave」の実装は公開されていないが、ただ論文自体はこのように公開されているので、自分でOnoma-to-Waveを実装し、本当にオノマトペから環境音が合成可能なのか追実験してみよう、というわけである。

ちなみに論文著者らによるデモンストレーション用のページが用意されており、オノマトペから合成された環境音が試聴できる。果たしてこのクオリティの環境音合成が自分の実装で再現できるのかを本記事では検証する。

y-okamoto1221.github.io

事前準備

まず第一に、RWCP 実環境音声・音響データベース (Real World Computing Partnership-Sound Scene Database; RWCP-SSD) を入手すること。これがないと始まらない。以下のページを参考に。オンライン配布にも対応しており、アカデミア関係者ならば入手は容易である。

research.nii.ac.jp

www.nii.ac.jp

RWCP-SSDはVol1, Vol2, Vol3と分かれているが、実験ではVol1に同梱されている環境音データを用いる。続いて各環境音に対してオノマトペを付与したデータセットであるRWCP-SSD-Onomatopoeiaを入手する。

github.com

1つの環境音につき、15以上のオノマトペが付与されている。jpのほうがカナ表記されたオノマトペ、enのほうはカナを音素表記に変換したオノマトペである。ちなみにOnoma-to-Waveへの入力は音素表記のものを用いる。

ソースコード

以下のリポジトリに置いた。Enjoy!

実装の概要と動かし方

論文では音響イベントラベルによる条件付けのあり・なしで2通りのモデルが提案されている。そこで、

  • unconditional ... 音響イベントによる条件付けなし(オノマトペのみを入力)
  • conditional ... 音響イベントによる条件付けあり(オノマトペと音響イベントラベルを入力)

として実装を分けておいた。

それぞれのフォルダには以下のスクリプトたちがある。

  • preprocess.py ... 前処理を担当するスクリプトである。

    • raw_to_wav(): データベース内の環境音データはraw形式になっており、扱いやすさを考えてRIFFヘッダをくっつけてwav形式にする。
    • make_csv(): カナ表記のオノマトペおよび音素表記(英字)のオノマトペ、さらにオノマトペに対応するwavファイルのパス情報と音響イベント情報をcsvファイルにパッキングする。
    • make_symlink(): 訓練および推論に用いるwavファイルをシンボリックリンクの形で分けておく。
    • mapping_dict.make() & mapping_dict.save() : オノマトペの音素表現(文字列)と数値表現を相互に変換する辞書を作成&保存する。後述するMappingDictクラスのメソッドを利用している。
  • training.py ... モデルの訓練を実施するスクリプトである。

  • inference.py ... 訓練済のモデルを用いて環境音を合成するスクリプトである。

前処理として、上記のようにcsvファイルを中間的に作成しておく。実際のcsvファイルの中身の例を以下に示す。

ポンッ,p o N q,/path/to/your/dataset/nospeech/drysrc/a1/cherry1/16khz/005.wav,cherry1
トッ,t o q,/path/to/your/dataset/nospeech/drysrc/a1/cherry1/16khz/005.wav,cherry1
コンッ,k o N q,/path/to/your/dataset/nospeech/drysrc/a1/cherry1/16khz/005.wav,cherry1
トッ,t o q,/path/to/your/dataset/nospeech/drysrc/a1/cherry1/16khz/005.wav,cherry1
...

各行は「オノマトペ(カナ)、オノマトペ(英字)、対応するwavへのパス、音響イベント名」の順でカラムが並ぶ。 上記の例では

  • オノマトペ(カナ): "ポンッ"
  • オノマトペ(英字): "p o N q"
  • wavへのパス: "/path/to/your/dataset/nospeech/drysrc/a1/cherry1/16khz/005.wav"
  • 音響イベント名: "cherry1"

となる。

以上の前処理はデータセットに含まれる全てのオノマトペ、全てのオーディオファイルが対象であり、実験に先駆けて一度だけ行えばよい。

そしてこれらを動かす前に、config.yamlを編集しておく必要がある。config.yamlには実験条件に関する設定が書かれているが、取り急ぎ動かす分には主にディレクトリのパスまわりを各自の環境に応じて編集すれば良い。実験で具体的に使う音響イベントはconfig.yamlの中で指定される。対象となる音響イベントを変えて実験するときには、yamlファイルを編集するだけでよい。

補助的に作成したモジュールの概要

training.py, inference.pyにおいてimportされる、補助モジュールについても簡単な説明をしておこう。

  • models.py ... 主にSeq2Seqモデルを定義している。Encoder, Decoder, Seq2Seqクラスがそれである。音響イベントラベルの埋め込み用にEventEmbeddingクラス、オノマトペの開始記号を埋め込むためのBosEmbeddingクラスも用意した。

  • dataset.py ... データセットを定義している。OnomatopoeiaDatasetクラスがそれである。データセットおよびデータローダをインスタンス化する関数や、collate_fnも定義している。

  • optimizer.py ... オプティマイザおよびスケジューラをインスタンス化する際のインタフェースを提供している。

  • mapping_dict.py ... オノマトペ(英字)の列と数値列を相互に変換する辞書を与えるクラスを定義している。MappingDictクラスがそれである。

  • trainer.py ... モデル訓練のループを回すためのクラスを定義している。 Trainerクラスがそれである。

  • generator.py ... 訓練済みのモデルを用いた推論により環境音を生成するためのクラスを定義している。Generatorクラスがそれである。

おまけ:訓練済みモデルのリリース

デモンストレーション用に訓練済みモデルもリポジトリ上で公開した。手軽(?)に合成を試すことのできるスクリプトも用意した。

  • demo.py ... 訓練済みモデルを用いた環境音合成用スクリプトである。inference.pyとは異なり、yamlファイル内で指定されたオノマトペと音響イベントに基づき手軽に合成できる。DataSetとDataLoaderを使わない分、実装もややシンプルになる。

  • pretrained_uncond.pt ... 音響イベントによる条件付けなしの訓練済みモデル

  • pretrained_cond.pt ... 音響イベントによる条件付けありの訓練済みモデル

まずはconfig.yamlを編集し、各自の環境に応じて訓練済みモデルへのパスを与えておく。続いて合成したいオノマトペ(音素表現)と、必要に応じて音響イベントもconfig.yaml内で与えておく。カナ表記でも合成可能にすることはTODOとしたい(実装の後回し)。ESPnetのようにGoogle Colab上で動くデモンストレーションも用意していない。本記事の読者ならば独自に用意できるだろう!

環境音合成実験

今回は論文を参考にして以下の環境音を実験に採用した(カッコ内はデータベースにおける識別子)。

  • 金属製のゴミ箱を叩いたときの音(trashbox)
  • カップを叩いたときの音(cup1)
  • ベルを鳴らしたときの音(bells5)
  • 紙を破るときの音(tear)
  • ドラムを叩いたときの音(drum)
  • マラカスを振ったときの音(maracas)
  • ホイッスルを吹いたときの音(whistle3)
  • コーヒー豆をミルで挽いたときの音(coffmill)
  • 電気シェーバーを動かしたときの音(shaver)
  • 目覚まし時計の音(clock1)

オーディオデータの特徴量抽出にかかる分析条件、訓練に用いるデータ量、ニューラルネットワークの構成など、訓練にかかる実験条件は原論文と揃えている。 エポック数、学習率は論文に記載がなかった。今回の実験ではエポック数を350とした。また学習率の初期値を0.0001として、 MultiStepLRによりエポック200, 250において学習率をそれぞれ0.5倍するようにした(0.0001 -> 0.5 * 0.0001 -> 0.5 * 0.5 * 0.0001)。

音響イベントによる条件付けなし

環境音を概ね期待通りに合成できた例を以下に示す。入力に使ったオノマトペはすべてテストデータである(訓練には用いていない)。

  • shaver (オノマトペ: ビイイイン 音素列: / b i i i i N /)
自然音 合成音


自然音 合成音


自然音 合成音


  • bells5(オノマトペ: チーンチーン 音素列: / c h i: N c h i: N /)
自然音 合成音


自然音 合成音


マラカスの合成音のクオリティは著者のデモンストレーションよりも良いような気がしているが、果たしてどうか。 またベルの音については音色はよく再現できているものの、音の「繰り返し」は合成できていない。

一方、論文でも指摘されている通り、オノマトペだけでは十分にスペクトログラムを条件づけできず、別の環境音が合成されてしまうケースが確認できた。コーヒーミルではそれが特に顕著だった。以下に当該オノマトペと合成音の例を示す。

  • coffmill
ガサガサ (/ g a s a g a s a /) →マラカスの音 ジビビビビッ (/ j i b i b i b i b i q /) → 目覚まし時計の音
チャラチャラ (/ ch a r a ch a r a /) →目覚まし時計の音


  • tear
スウィイイイイン (/ s u w i i i i i N /) →マラカスの音

音響イベントによる条件付けあり

合成がうまくいかなかった例を上で示したが、音響イベントによる条件付けをして合成した例を以下に示す。比較のため、「条件付けなし」の合成音も並べておく。条件付けにより音響イベントと合致した環境音が合成できていることが分かる。

  • coffmill(オノマトペ: ガサガサ 音素列: / g a s a g a s a /)
条件付けなし 条件付けあり


  • coffmill(オノマトペ: ジビビビビッ 音素列: / j i b i b i b i b i q /)
条件付けなし 条件付けあり


  • coffmill(オノマトペ: チャラチャラ 音素列: / ch a r a ch a r a /)
条件付けなし 条件付けあり


  • tear(オノマトペ: スウィイイイイン 音素列: / s u w i i i i i N /)
条件付けなし 条件付けあり


続いて、論文にもある例だが 、"ビイイイイイ (b i i i i i i)"を共通のオノマトペとして入力し、3つの音響イベント"whistle3", "shaver", "tear"によりそれぞれ条件付けして合成したときの例を以下に示す。

条件付けなし "whistle3"で条件付け
"shaver"で条件付け "tear"で条件付け

条件付けを行わないと、つねに"shaver"の音が合成されてしまう。条件付けを行うことで、たとえオノマトペは共通でも、音響イベントに対応して環境音が合成されていることが分かる。

ほか著者のデモンストレーションページを参考に、いくつかのオノマトペに加えて条件付けのあり/なしで変化する合成音を示しておく。

条件付けなし
"maracas"で条件付け "coffmill"で条件付け


  • チィッ (/ ch i: q /)
条件付けなし
"cup1"で条件付け "shaver"で条件付け
"whislte3"で条件付け "tear"で条件付け


  • ボンッ (/ b o N q /)
条件付けなし
"drum"で条件付け "trashbox"で条件付け


  • リンリン (/ r i N r i N /)
条件付けなし
"bells5"で条件付け "clock"で条件付け

実装の舞台裏とかTIPS

  • オノマトペ(英字)は数値列に変換したうえでエンコーダに入力する。モデル入力前にone-hotベクトルの系列へとexplicitに変換しておく必要はない。エンコーダ内部でnn.Embeddingにより埋め込み表現が得られるという実装上の仕掛けがあり、その埋め込み表現はone-hotベクトルと重み行列との行列積を計算した結果に相当する。これがいわゆる「lookup table」とか「lookup embedding」などと呼ばれるテクニックである。とにかく必要なのはone-hotベクトルと一意に対応する数値表現である。オノマトペ(英字)ならばそれは各音素記号に通し番号を割り振ること、つまりone-hotベクトルの何次元目を1にするか、ということに対応する。音素記号に通し番号を割り振るため、変換用の辞書を用意するクラス(MappingDict)を実装した。ちなみに音響イベントラベルの埋め込みについては、エンコーダの最終フレームにおけるhidden stateと結合したうえでデコーダに入力する必要があるので、nn.Embeddingは使わずに事前にone-hotベクトル化しておく実装を採用した(nn.functional.one_hot 関数)。

  • 訓練時にはscheduled samplingを導入している。本来、seq2seqのモデル構造としては直前の時刻におけるデコーダ出力を、当該時刻のデコーダ入力に用いるのが正しい。しかし訓練初期におけるデコーダ出力はtargetからの乖離が大きく、それをさらに次時刻のデコーダ入力に用いれば訓練誤差がさらに拡大されるため、訓練誤差の収束には時間がかかる。このような訓練の困難さを緩和し、早期の誤差収束による効率化を図るため、直前時刻のtarget(教師データ)をデコーダ入力に用いる手法をteacher forcingと呼んでいる。しかしながら、teacher forcingを適用すると、デコーダの訓練時と推論時で入力データの分布が食い違ってしまうため、デコーダの予測精度がかえって低下してしまうおそれがある。これがいわゆるexposure biasと呼ばれる問題点である。そこでさらにexposure biasを緩和すべく、各時刻でtargetをデコーダ入力に利用するか否かを確率的に決定する手法が提案されており、これをscheduled samplingと呼んでいる。その率は論文中に0.6という記載があり、つまり60%の確率でtargetをデコーダ入力に使うことを意味する。実装では[0, 1)の範囲で乱数を発生し、0.6を下回ったらtargetをデコーダ入力にする、といった具合である。またデコーダが出力したテンソルを次時刻の入力に利用する際には、あらかじめ当該のテンソルdetach関数を適用し、計算グラフから切り離したうえで入力する必要があることも実装上は注意すべき点である。

  • 系列長の異なるデータをミニバッチにまとめて訓練を行うためには、系列に沿ったパディングを施して見かけの系列長を揃える必要がある(オノマトペおよびスペクトログラムそれぞれに対して)。筆者は以前、ミニバッチ構成前つまりDataSet作成時の前処理の一部として、パディングを全データに対して一斉に行っていた。しかしながらデータセット中の最大系列長にパディングを合わせることになるため、パディングの無駄が多くミニバッチ学習時の計算コストが不必要に大きくなる。つまり系列方向に計算グラフが不必要に伸びるため、forward / backwardの計算時間とメモリ消費量が増大してしまう。そこでパディングをDataSet作成時に行うのではなく、DataLoaderにおけるミニバッチ作成時に都度行うようにした。ミニバッチ内の最大系列長に合わせることでパディングの無駄が減り、訓練時間の削減と効率化が望める。そのために大いに役に立ったのがPyTorchにおけるcollate_fnの仕組みであり、本実装ではこれを自作して用いている。

  • スペクトログラムにパディングを施した箇所を損失関数の計算から除外するためにマスクをかけている。マスク作成のための関数はttslearnの実装を使った(ttslearn/util.py)。本記事の実装ではdataset.py内で借用した。同スクリプト内ではライセンス表記も配慮している(ttslearn作者のYamamoto氏のコピーライトを併記)。マスク作成時にはミニバッチを構成する各スペクトログラムのフレーム長のリストが必要なので、上記collate_fnの内部でリストを作成するようにした。

  • モデルの損失関数にL1 lossを適用することは効果的である。今回は音響特徴量が対数振幅スペクトログラムなので、L1 lossで十分に動くということだろう。初代TacotronでもL1 lossが使われていた実績があるため、その周辺で探せば損失関数に関連した議論が見つかるかもしれない。

  • デコーダに入力するスペクトログラムの先頭フレームには"<BOS>"という特別な記号を埋め込んでいる。その埋め込み表現を得るためのクラスがBosEmbeddingである。このクラスの初期化時にnn.Embeddingインスタンス化しているが、引数のpadding_idxに"<BOS>"に相当するものを指定する実装を採用した。ゆえに当該の埋め込みベクトル(テンソル型オブジェクト)における各要素は常に0からなる。要素が全て0からなるテンソルを手に入れるだけならば、クラスという大道具を持ち出す必要はないかもしれないが、コードがスッキリするメリットを優先した。常に0からなる埋め込みベクトルを得るクラスなので、訓練の対象にはならず、パラメータの保存対象にもならない。

  • 推論時、1フレームごとにデコーダを駆動してスペクトログラムを逐次生成しているが、その生成回数(駆動回数)は経験的に与える必要がある。generator.pyではtargetとなるオーディオデータのフレーム数を参照して決めている。今回のモデルにはTacotron2のようなstop tokenを計算する仕組みは組み込まれていないので、デコーダの駆動回数にヒューリスティックな上限値を設けておくのは一つの妥協案である。例えば入力オノマトペの音素系列長を定数倍した値により出力スペクトログラムの最大フレーム数を決定する、などの便法が考えられる。

  • RWCP-SSDの各オーディオデータには、少なからぬ「無音区間」(振幅がほぼゼロとなる区間)が環境音の前後に存在している。ゆえに訓練前にある程度除去しておくのが望ましい。「無音」を表現するオノマトペは存在しないため、除去しなければモデル訓練に好ましくない影響を及ぼすおそれがある。ちなみに無音区間の除去にはlibrosaのtrimメソッドを用いた。このメソッドでは無音判定のしきい値をdBで与えるのだが(top_dbという引数)、この値を小さく設定しすぎると環境音のアタックやリリースの部分まで削ってしまうので、気をつけなければならない(特にcupやbellは振幅の小さいリリース区間が長めなので注意)。

  • スペクトログラムは振幅を線形スケールから対数スケールに変換している。対数スケールへの変換にはlibrosaのamplitude_to_dbメソッドを用いた。そのうえで訓練前に事前に標準化を施した。実装ではscikit-learnのStandardScalerを用いた。デコーダ出力から波形に戻す前にはinverse_transformメソッドによりスペクトログラムのscaleを戻す必要があり、さらにlibrosaのdb_to_amplitudeメソッドを用いて線形スケールへと変換しておく。

  • スペクトログラムから波形に戻す際にはlibrosaのgriffinlimメソッドを用いた。

  • エポック数は300〜400程度あれば十分、つまり訓練誤差がほぼ収束するという感触を得ている。

  • 学習率は0.0001程度で十分な気がする。本記事の再現実験ではMultiStepLRにより学習率をスケジューリングした。

  • ミニバッチのサイズは論文にもあるとおり、5程度が適切のようだ。サイズを15や20に増やせば訓練時間は短縮されるが、推論時にモデルから合成される音がノイズのようになってしまうことがあった。

  • 実験条件の設定と管理にはHydraパッケージを利用した。yamlファイルには実験条件を構造化して書いておくことができるが、今回の実装では論文の追実験が目的なので当該のyamlを読み出す程度にしかHydraを活用しなかった。参考文献『Pythonで学ぶ音声合成』にはもっとカッコいい使い方が紹介されており、いつか試してみたい。

おわりに

本記事ではOnoma-to-WaveのPyTorch実装を紹介した。オノマトペからの環境音合成には無事に成功したので安心した。個人的にはlookup embeddingによる埋め込み表現の取得、collate_fn活用によるデータローダ作成、パディングの実施、損失関数計算時のマスク作成が特に勉強になった。

今後はKanaWaveのようにGUIをつけたうえで環境音合成を実施してみたい。PySimpleGUIなどGUIに特化したモジュールを用いたスタンドアロンなアプリや、クライアントサーバーモデルに基づくウェブアプリケーションの実装も考えられる。GUIプログラムへの組み込みを容易にするためにはOnoma-to-Waveのモジュール化もしておきたいが(pyonoma?)、かなりのリファクタリングが必要だろう。

合成可能な音のバリエーションを増やすことも今後の課題だろう。単純に合成可能な音響イベントの数を増やすならば、RWCP-SSDのオーディオデータ全体を用いてモデルを訓練すればよいが、オリジナル論文のネットワークアーキテクチャでどこまで合成音のクオリティがキープできるかは未知数である。アーキテクチャの吟味も必要になってくるだろう。もし仮にモデル訓練が成功裏に終われば、pre-trainedモデルとして公開し、上記のpyonomaモジュールからロードして使うことも視野に入ってくる。合成音のバリエーションの増やし方はいくつか考えられ、検討の余地は大いに残されている。

しかしながら現状はどれも工数が足りていない。そのうち、やれたらやる。

最後に、Onoma-to-Waveの論文著者各位に深い感謝の意を表したい。

参考文献

Seq2Seqの勉強に『ゼロから作るDeep Learning ❷ ――自然言語処理編』は大いに役立った。

www.oreilly.co.jp

『深層学習による自然言語処理』も参考になる。

www.kspub.co.jp

PyTorchによるSeq2Seqの実装は以下のリポジトリが参考になった。

github.com

その他、実装に関しては『Pythonで学ぶ音声合成』も大いに役立った。この本は熟読すべき。

r9y9.github.io

collate_fnの自作には以下の記事が参考になった。

qiita.com

プログレスバーを表示するためのPythonライブラリ progressbar2を使ってみた

はじめに

Yoshipon氏の以下のツイートからprogressbar2の存在を知ったので、使ってみたということ。

インストール

pipでインストール可能。

progressbar2 · PyPI

テスト

まずは、こんなスクリプトを書いてみた。

# -*- coding: utf-8 -*-

from time import sleep

from progressbar import progressbar as prg

for i in prg(range(10), prefix="A demonstration", suffix="\n"):
    sleep(0.1)
    print(i)

ターミナル出力はこんな感じ(アニメーションを用意したい!)。

A demonstration  0% (0 of 10) |                                                                           | Elapsed Time: 0:00:00 ETA:  --:--:--
0
A demonstration 10% (1 of 10) |#######                                                                    | Elapsed Time: 0:00:00 ETA:   0:00:00
1
A demonstration 20% (2 of 10) |###############                                                            | Elapsed Time: 0:00:00 ETA:   0:00:00
2
A demonstration 30% (3 of 10) |######################                                                     | Elapsed Time: 0:00:00 ETA:   0:00:00
3
A demonstration 40% (4 of 10) |##############################                                             | Elapsed Time: 0:00:00 ETA:   0:00:00
4
A demonstration 50% (5 of 10) |#####################################                                      | Elapsed Time: 0:00:00 ETA:   0:00:00
5
A demonstration 60% (6 of 10) |#############################################                              | Elapsed Time: 0:00:00 ETA:   0:00:00
6
A demonstration 70% (7 of 10) |####################################################                       | Elapsed Time: 0:00:00 ETA:   0:00:00
7
A demonstration 80% (8 of 10) |############################################################               | Elapsed Time: 0:00:00 ETA:   0:00:00
8
A demonstration 90% (9 of 10) |###################################################################        | Elapsed Time: 0:00:00 ETA:   0:00:00
9
A demonstration100% (10 of 10) |##########################################################################| Elapsed Time: 0:00:01 Time:  0:00:01

シェルのリダイレクトを活用してテキストファイルにログとして残す場合はちょっと注意が必要である。 例えば、上記のスクリプトをdemo.pyとして保存して実行するならば、

python3 demo.py > log.txt 2>&1

と行った具合に標準出力と標準エラー出力を束ねるわけである。progressbarは標準エラー出力に向いているので。

redirect_stdout=Falseのとき(デフォルト)、log.txtの中身は

A demonstration  0% (0 of 10) |          | Elapsed Time: 0:00:00 ETA:  --:--:--
A demonstration 10% (1 of 10) |#         | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 20% (2 of 10) |##        | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 30% (3 of 10) |###       | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 40% (4 of 10) |####      | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 50% (5 of 10) |#####     | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 60% (6 of 10) |######    | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 70% (7 of 10) |#######   | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 80% (8 of 10) |########  | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration 90% (9 of 10) |######### | Elapsed Time: 0:00:00 ETA:   0:00:00
A demonstration100% (10 of 10) |#########| Elapsed Time: 0:00:01 Time:  0:00:01
0
1
2
3
4
5
6
7
8
9

となる(標準エラー出力と標準出力が分離しているものをリダイレクトで束ねたので、こういう見かけ)。

redirect_stdout=Trueのときは、スクリプトはこうなる(デフォルトではFalseなので明示的にTrueにする)。

# -*- coding: utf-8 -*-

from time import sleep

from progressbar import progressbar as prg

for i in prg(range(10), prefix="A demonstration", suffix="\n", redirect_stdout=True):
    sleep(0.1)
    print(i)

その際、log.txtの中身は

A demonstration  0% (0 of 10) |                                                                           | Elapsed Time: 0:00:00 ETA:  --:--:--
0                                                                                                                                                
A demonstration  0% (0 of 10) |                                                                           | Elapsed Time: 0:00:00 ETA:  --:--:--
1                                                                                                                                                
A demonstration 10% (1 of 10) |#######                                                                    | Elapsed Time: 0:00:00 ETA:   0:00:01
2                                                                                                                                                
A demonstration 20% (2 of 10) |###############                                                            | Elapsed Time: 0:00:00 ETA:   0:00:01
3                                                                                                                                                
A demonstration 30% (3 of 10) |######################                                                     | Elapsed Time: 0:00:00 ETA:   0:00:00
4                                                                                                                                                
A demonstration 40% (4 of 10) |##############################                                             | Elapsed Time: 0:00:00 ETA:   0:00:00
5                                                                                                                                                
A demonstration 50% (5 of 10) |#####################################                                      | Elapsed Time: 0:00:00 ETA:   0:00:00
6                                                                                                                                                
A demonstration 60% (6 of 10) |#############################################                              | Elapsed Time: 0:00:00 ETA:   0:00:00
7                                                                                                                                                
A demonstration 70% (7 of 10) |####################################################                       | Elapsed Time: 0:00:00 ETA:   0:00:00
8                                                                                                                                                
A demonstration 80% (8 of 10) |############################################################               | Elapsed Time: 0:00:00 ETA:   0:00:00
9                                                                                                                                                
A demonstration 90% (9 of 10) |###################################################################        | Elapsed Time: 0:00:01 ETA:   0:00:00
A demonstration100% (10 of 10) |##########################################################################| Elapsed Time: 0:00:01 Time:  0:00:01

となっており、 冒頭に示したターミナル上での実行と見かけは一緒になる(それはそう)。

深層学習系のスクリプトを回す時、エポック毎のlossを表示させながらprogressbarも出したい時、便利ではなかろうか。tqdmだとテキストファイルに出力したときに表示が崩れるので。

以上。