Nemotron 3.5 ASRを日本語音声でファインチューニングしてみた

Nemotron 3.5 ASRを日本語音声でファインチューニングしてみた

Nemotron 3.5 ASRを日本語音声でファインチューニングし、専門用語の認識精度を改善できるかを検証しました。少量データでも、ドメイン適応により認識率を向上させられれば、応用先がいろいろありそうです。
2026.08.21

こんにちは。製造ビジネステクノロジー部の中村(@nokomoro3)です。

NVIDIAが公開しているストリーミング音声認識モデル「Nemotron 3.5 ASR Streaming 0.6B」は、日本語を含む40の言語・ロケールに対応しています。モデルカードでは、対応言語・ロケールを次の3段階に分類しています。

  • Transcription-ready(19ロケール):高精度な音声認識を追加学習なしで利用可能
  • Broad-coverage(13ロケール):追加学習なしで文字起こし可能
  • Adaptation-ready(8ロケール):トークナイザーは対応しているが、本格的な文字起こしにはドメインデータによるファインチューニングが必要

日本語(ja-JP)は、このうちTranscription-readyに分類されているため、一般的な日本語音声はそのままでも認識できます。一方、実際の業務では製品名、社内用語、略語など、学習済みモデルが知らない固有名詞が頻繁に登場するケースでは、認識精度が低下する可能性があります。

そこで本記事では、Nemotron 3.5 ASRを日本語音声でファインチューニングし、専門用語の認識精度を改善できるか検証します。認識精度は、学習前後を同じストリーミング条件で推論し、専門用語の完全一致率、文字誤り率(CER)、誤挿入の有無を比較します。

Nemotron 3.5 ASRとは

Nemotron 3.5 ASR Streaming 0.6Bは、6億パラメータの多言語ストリーミング音声認識モデルです。

主な特徴と、それによって得られる利点は次のとおりです。

特徴 利点
40の言語・ロケールを単一チェックポイントで認識 言語ごとにモデルを配備する必要がなく、target_lang=autoなら言語判定も同じモデルで行える
Cache-Aware FastConformer-RNNT 過去の音声から計算した状態を再利用し、新しいチャンクだけを処理するため、連続音声を低遅延かつ効率よく認識できる
句読点をモデル自身が出力 認識結果へ別途句読点を補う後処理を減らせる
推論時にチャンクサイズを変更可能 再学習せず、用途に応じて応答の速さと認識精度のバランスを調整できる

ストリーミング推論では、att_context_size=[左コンテキスト, 右コンテキスト]で音声チャンクの参照範囲を指定します。どちらも80ms単位で指定することが可能です。

左側の56は過去の音声コンテキストを保持する長さ、右側は現在位置より未来を参照する長さを表します。
今回は[56,0]と設定し、80ms x 56 = 4480ms分の過去フレームを使用します。未来方向には音声は保持しないので、現在フレーム(80ms)がたまるごとに処理する低遅延処理となります。

別の例として、たとえば[56,3]では、現在フレームに未来の3フレームを加えた320ms分の音声がたまってから処理します。応答開始は遅くなる代わりに、モデルはより長いチャンクを利用できます。

実行環境

今回は実行環境としてNVIDIA Brevを使用します。

NVIDIA Brevは、複数のクラウド事業者からNVIDIA GPUインスタンスを選び、開発環境として利用できるサービスです。(今回の検索結果には、AWSやGoogle Cloud、Oracle Cloudに加え、CrusoeやNebiusなどのインスタンスが表示されました)

同様の作業はGoogle Colabなどでもできますが、今回は以下の観点によりNVIDIA Brevを実行環境に選択しました。

  • Brevの標準イメージにはNVIDIAドライバー、CUDA、Python、Dockerが用意されている
  • L40S 48GBを明示的に選べる
  • CLIでファイル転送 brev copy やコマンド実行 brev exec ができること
  • GPUインスタンスを停止しても同じworkspaceから再開できる(ストレージを維持できる)

一方、NeMoと今回使用するバージョンのPyTorchは、後述するセットアップスクリプトでインストールしました。今回使用した環境は次のとおりです。

項目 設定
実行環境 NVIDIA Brev
GPU NVIDIA L40S
VRAM 46,068 MiB(nvidia-smi表示)
OS Ubuntu 22.04系(jammy)
Python 3.10.12
PyTorch 2.11.0+cu129
CUDA Runtime 12.9、Driver 565.57.01
NeMo 3.1.0、commit e0a284b67db4da54f90a7873dd723aa2106a503e
学習時間 100 step約33秒、300 step約1分48秒(Trainer.fitの実測)
GPU利用料金 1.74 USD/時(作成時のBrev表示)

Brevインスタンスの起動

Brevでは、ConsoleまたはCLIからGPUインスタンスを作成できます。今回は価格順に検索し、CrusoeのL40S 48GBを選びました。

以下のコマンドで要件に合うインスタンスを検索できます。(GPUの在庫と料金は変わるため、作成前に最新の検索結果を確認します)

brev search gpu \
  --gpu-name L40S \
  --min-vram 40 \
  --min-ram 64 \
  --stoppable \
  --sort price \
  --wide

各オプションの意味は次のとおりです。

オプション 意味
--gpu-name L40S GPUをL40Sに限定する
--min-vram 40 1 GPUあたりのVRAMが40GB以上の候補に絞る
--min-ram 64 システムメモリが64GB以上の候補に絞る
--stoppable 停止と再開に対応する候補だけを表示する
--sort price 料金が安い順に並べる
--wide VRAM、RAM、ディスク、時間料金などの詳細列も表示する

実行時の検索結果は次のとおりです。

TYPE                            PROVIDER  GPU   COUNT  VRAM/GPU  TOTAL VRAM  CAPABILITY  RAM      ARCH    DISK       $/GB/MO  BOOT  FEATURES  VCPUS  $/HR
l40s-48gb.1x                    crusoe    L40S      1  48 GB     48 GB       8.9         147 GB   x86_64  128GB      -        7m    SP            8  $1.74
gpu-l40s-a.1gpu-16vcpu-64gb     nebius    L40S      1  48 GB     48 GB       8.9         64 GB    x86_64  50GB-3TB   $0.10    -     S            16  $2.10
gpu-l40s-d.1gpu-16vcpu-96gb     nebius    L40S      1  48 GB     48 GB       8.9         96 GB    x86_64  50GB-3TB   $0.10    -     S            16  $2.18
gpu-l40s-a.1gpu-24vcpu-96gb     nebius    L40S      1  48 GB     48 GB       8.9         96 GB    x86_64  50GB-3TB   $0.10    -     S            24  $2.33
gpu-l40s-a.1gpu-32vcpu-128gb    nebius    L40S      1  48 GB     48 GB       8.9         128 GB   x86_64  50GB-3TB   $0.10    -     S            32  $2.57
g6e.2xlarge                     aws       L40S      1  45 GB     45 GB       8.9         64 GB    x86_64  10GB-16TB  $0.10    7m    SRP           8  $2.69
gpu-l40s-d.1gpu-32vcpu-192gb    nebius    L40S      1  48 GB     48 GB       8.9         192 GB   x86_64  50GB-3TB   $0.10    -     S            32  $2.74
gpu-l40s-a.1gpu-40vcpu-160gb    nebius    L40S      1  48 GB     48 GB       8.9         160 GB   x86_64  50GB-3TB   $0.10    -     S            40  $2.81
gpu-l40s-d.1gpu-48vcpu-288gb    nebius    L40S      1  48 GB     48 GB       8.9         288 GB   x86_64  50GB-3TB   $0.10    -     S            48  $3.30
l40s-48gb.2x                    crusoe    L40S      2  48 GB     96 GB       8.9         294 GB   x86_64  128GB      -        7m    SP           16  $3.48
g6e.4xlarge                     aws       L40S      1  45 GB     45 GB       8.9         128 GB   x86_64  10GB-16TB  $0.10    7m    SRP          16  $3.61
g6e.8xlarge                     aws       L40S      1  45 GB     45 GB       8.9         256 GB   x86_64  10GB-16TB  $0.10    7m    SRP          32  $5.43
gpu-l40s-d.2gpu-64vcpu-384gb    nebius    L40S      2  48 GB     96 GB       8.9         384 GB   x86_64  50GB-3TB   $0.10    -     S            64  $5.48
gpu-l40s-d.2gpu-96vcpu-576gb    nebius    L40S      2  48 GB     96 GB       8.9         576 GB   x86_64  50GB-3TB   $0.10    -     S            96  $6.60
g6e.16xlarge                    aws       L40S      1  45 GB     45 GB       8.9         512 GB   x86_64  10GB-16TB  $0.10    7m    SRP          64  $9.09
gpu-l40s-d.4gpu-128vcpu-768gb   nebius    L40S      4  48 GB     192 GB      8.9         768 GB   x86_64  50GB-3TB   $0.10    -     S           128  $10.97
g6e.12xlarge                    aws       L40S      4  45 GB     179 GB      8.9         384 GB   x86_64  10GB-16TB  $0.10    7m    SRP          48  $12.59
gpu-l40s-d.4gpu-192vcpu-1152gb  nebius    L40S      4  48 GB     192 GB      8.9         1152 GB  x86_64  50GB-3TB   $0.10    -     S           192  $13.21
g6e.24xlarge                    aws       L40S      4  45 GB     179 GB      8.9         768 GB   x86_64  10GB-16TB  $0.10    7m    SRP          96  $18.08
g6e.48xlarge                    aws       L40S      8  45 GB     358 GB      8.9         1536 GB  x86_64  10GB-16TB  $0.10    7m    SRP         192  $36.16

この中から、検索時点で最も時間料金が安かったCrusoeのl40s-48gb.1xを選びました。

続いて、検索で判明したTYPEを指定してインスタンスを作成します。

brev create cm-nakamura-sample-instance --type l40s-48gb.1x

cm-nakamura-sample-instanceは任意のインスタンス名を指定できます。
--type l40s-48gb.1xは検索結果のTYPE列で確認した構成です。

再作成後は、次の起動状態を確認できました。

brev list --json

# {
#   "workspaces": [
#     {
#       "name": "cm-nakamura-sample-instance",
#       "id": "eqebkk9l6",
#       "status": "RUNNING",
#       "build_status": "COMPLETED",
#       "shell_status": "READY",
#       "instance_type": "l40s-48gb.1x",
#       "gpu": "L40S"
#     }
#   ]
# }

クラウド側の状況によっては、作成処理が失敗することもあります。

末尾にSuccessfully createdと表示されても、brev list --jsonstatus=FAILUREなら利用できないため、失敗したインスタンスを削除して再作成されてください。

status=RUNNINGbuild_status=COMPLETEDshell_status=READYがそろえば、コマンドを実行できます。

(途中で作業を中断したい場合は、brev stop cm-nakamura-sample-instanceを実行し、status=STOPPEDになるまで確認することでインスタンス料金を抑えられます)

セットアップ

今回は作業のための検証ディレクトリnemotron35-ja-localをBrevへ転送する前提で進めます。

検証ディレクトリnemotron35-ja-localには、本記事で使用するdatascriptsが入ったディレクトリを指定します。

nemotron35-ja-local/
├── data/
│   ├── discovery_sentences.csv
│   ├── finetune_sentences.csv
│   └── phrase_boosting_terms.txt
└── scripts/
    ├── setup_environment.sh
    ├── convert_m4a_to_wav.sh
    ├── create_manifest.py
    ├── download_base_model.py
    ├── evaluate_term_recognition.py
    ├── evaluate_cer.py
    ├── run_finetune.sh
    └── run_streaming_eval.sh

datascriptsGitHubリポジトリで公開しています。

以下のように出力先のディレクトリ名をnemotron35-ja-localとしてcloneすれば、以降の手順と同じパスで使用できます。

git clone \
  https://github.com/cm-nakamura-shogo/nemotron-35-asr-japanese-finetuning.git \
  nemotron35-ja-local

Brevへの転送処理は以下で行います。

# フォルダを作成
brev exec cm-nakamura-sample-instance \
  "mkdir -p /home/ubuntu/workspace/nemotron35-ja"

# 転送
brev copy \
  nemotron35-ja-local/ \
  cm-nakamura-sample-instance:/home/ubuntu/workspace/nemotron35-ja/

brev copyはSCPコマンドと似たような操作で使用することができます。

上のコマンドでは、1つ目のnemotron35-ja-local/がローカルの検証ディレクトリ、コロンより前のcm-nakamura-sample-instanceがBrevのインスタンス名です。

以後同じコマンドを実行すれば上書き保存されます。

続いて以下のようにコマンドを入力すれば、Brevの中で作業できます。(SSHみたいな感じです)

brev shell cm-nakamura-sample-instance

続いて以下を実行して、必要なOSパッケージ、PyTorch、NeMoをセットアップします。

# ここからBrev上での作業
cd /home/ubuntu/workspace/nemotron35-ja

# Brev で実行してください
bash scripts/setup_environment.sh

scripts/setup_environment.shの処理は次のとおりです。

sudo apt-get update

# Brevイメージには`python`と`pip`がなく`python3`だけが入っていたため、`python3-venv`で仮想環境を作成
sudo apt-get install -y \
  ffmpeg libsndfile1 sox libsox-fmt-mp3 git \
  python3 python3-pip python3-venv

# 仮想環境の構築と有効化
mkdir -p work
python3 -m venv work/.venv
source work/.venv/bin/activate

# 初期設定
python -m pip install --upgrade pip setuptools wheel Cython packaging

# CUDA 12.9用のPyTorchを先に固定でインストール
# (CUDA 13系はドライバー580以上が必要ですが今回のドライバーは565のため、NeMoの`cu12`構成に合わせてPyTorch 2.11.0+cu129を先に固定)
python -m pip install \
  --index-url https://download.pytorch.org/whl/cu129 \
  "torch==2.11.0+cu129"

# その他のPythonパッケージをインストール
python -m pip install \
  "datasets[audio]>=4.0" \
  "huggingface-hub>=0.30" \
  "jiwer>=4.0" \
  "matplotlib>=3.8" \
  "scipy>=1.12" \
  "soundfile>=0.13"

# NeMoを今回動作確認したハッシュでGitHubから取得
mkdir -p work/deps
git init work/deps/NeMo
git -C work/deps/NeMo remote add origin https://github.com/NVIDIA-NeMo/NeMo.git
git -C work/deps/NeMo fetch --depth 1 origin e0a284b67db4da54f90a7873dd723aa2106a503e
git -C work/deps/NeMo checkout --detach FETCH_HEAD

# NeMoのインストール
python -m pip install \
  --extra-index-url https://download.pytorch.org/whl/cu129 \
  -e "work/deps/NeMo[asr,cu12]"

パッケージバージョンの固定など注意点がありますのでコメントに説明を記載しております。

仮想環境のアクティベートに関する注意

前述のsetup_environment.shwork/.venvを作成し、スクリプトの実行中に有効化します。ただし、bash scripts/setup_environment.shは子シェルで実行されるため、終了後の現在のShellには有効化状態が引き継がれません。

以降のpythonコマンドは、以下に示したsource work/.venv/bin/activateを実行して仮想環境で実行されるようにしてください。BrevのShellへ入り直した場合も、同じsourceコマンドを再実行する必要があります。

# Brevに入り直した後はアクティベートしなおす
source work/.venv/bin/activate

初期評価(苦手語の確認)

まずは初期評価でデフォルト状態の苦手なキーワードを発見し、ファインチューニングの改善度合いを検証するベースとします。

音声データの録音

NVIDIA・音声AI関連の27用語を2文ずつ、対照文を6文、合計60文録音しました。
(ここは再現のためにはご自身で録音をしていただく必要があります)

  • 対象語を含む発話: 54件
  • 対象語を含まない対照発話: 6件
  • 録音形式: M4A
  • 評価時の形式: 16kHz、mono、16-bit PCM WAV(ffmpegで変換)
  • 合計音声時間: 約7分49秒

実際に使用した60文のCSVの冒頭5件は次のとおりです。data/discovery_sentences.csvとして保存して、その後の処理でも使用します。

id,category,target_term,spoken_hint,reference_text,role
D001,model,Nemotron,ネモトロン,Nemotron 3.5 ASRを使って日本語音声を認識します。,discovery
D002,model,Nemotron,ネモトロン,今回の検証ではNemotronの認識精度を比較します。,discovery
D003,framework,NeMo,ネモ,NeMoを使って音声認識モデルを学習します。,discovery
D004,framework,NeMo,ネモ,学習にはNVIDIA NeMo Frameworkを利用しました。,discovery
D005,product,Riva,リーヴァ,NVIDIA Rivaで音声認識サービスを構築します。,discovery
...

完全版はGitHubのdata/discovery_sentences.csvで公開しています。

こちらをスマホアプリなどで1文ずつ録音し、CSVのidとM4Aのファイル名を一致させて保存します。
たとえばD001の文章はD001.m4aとして保存します。

録音後のBrevへ転送する前のディレクトリは、次の構成となるイメージです。

nemotron35-ja-local/
├── data/
│   └── discovery_sentences.csv
└── work/
    └── recordings/
        └── discovery-m4a/
            ├── D001.m4a
            ├── D002.m4a
            ├── ...
            └── C006.m4a

録音が終わったら、次を実行して録音ファイルごと転送して上書きします。

# ローカルPCで実行してください
brev exec cm-nakamura-sample-instance \
  "mkdir -p /home/ubuntu/workspace/nemotron35-ja"

brev copy \
  nemotron35-ja-local/ \
  cm-nakamura-sample-instance:/home/ubuntu/workspace/nemotron35-ja/

転送後、BrevのShellへ戻って音声変換とmanifest作成を行います。

M4AをWAVへ変換

以下を実行して、録音アプリが出力したM4Aを、Nemotron 3.5 ASRへ入力する16kHz、mono、16-bit PCMのWAVへ変換します。

# Brev で実行してください
bash scripts/convert_m4a_to_wav.sh \
  work/recordings/discovery-m4a \
  work/recordings/discovery-wav

scripts/convert_m4a_to_wav.shの内容は以下のようにffmpegを使った変換となっています。

#!/usr/bin/env bash
set -euo pipefail

if [ "$#" -ne 2 ]; then
  echo "Usage: $0 INPUT_DIR OUTPUT_DIR" >&2
  exit 2
fi

INPUT_DIR="$1"
OUTPUT_DIR="$2"

if [ ! -d "${INPUT_DIR}" ]; then
  echo "入力ディレクトリが見つかりません: ${INPUT_DIR}" >&2
  exit 1
fi

if ! command -v ffmpeg >/dev/null 2>&1; then
  echo "ffmpegが見つかりません" >&2
  exit 1
fi

mkdir -p "${OUTPUT_DIR}"

shopt -s nullglob
INPUT_FILES=("${INPUT_DIR}"/*.m4a "${INPUT_DIR}"/*.M4A)

if [ "${#INPUT_FILES[@]}" -eq 0 ]; then
  echo "M4Aファイルが見つかりません: ${INPUT_DIR}" >&2
  exit 1
fi

for input_path in "${INPUT_FILES[@]}"; do
  filename="$(basename "${input_path}")"
  sample_id="${filename%.*}"
  output_path="${OUTPUT_DIR}/${sample_id}.wav"

  ffmpeg -v error -y \
    -i "${input_path}" \
    -ar 16000 \
    -ac 1 \
    -c:a pcm_s16le \
    "${output_path}"

  echo "${input_path} -> ${output_path}"
done

echo "Converted ${#INPUT_FILES[@]} file(s)."

変換後はwork/recordings/discovery-wav/D001.wavからC006.wavまでの60ファイルが生成されます(変換前のM4Aは削除されません)。

manifestファイルの作成

次に、以下を実行してCSVのidと同名のWAVを対応付けて、NeMoへ渡すJSONL manifestを作成します。

# Brev で実行してください
python scripts/create_manifest.py \
  --sentences data/discovery_sentences.csv \
  --audio-dir work/recordings/discovery-wav \
  --output work/manifests/discovery_manifest.jsonl

scripts/create_manifest.pyの内容は以下のようになっています。

#!/usr/bin/env python3
"""Create a NeMo JSONL manifest from an ID-based sentence CSV and WAV files."""

from __future__ import annotations

import argparse
import csv
import json
import wave
from pathlib import Path

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--sentences", type=Path, required=True)
    parser.add_argument("--audio-dir", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument(
        "--split",
        help="Only include rows whose split column matches this value.",
    )
    return parser.parse_args()

def read_wav_duration(path: Path) -> float:
    with wave.open(str(path), "rb") as audio:
        return audio.getnframes() / audio.getframerate()

def main() -> None:
    args = parse_args()

    with args.sentences.open(encoding="utf-8", newline="") as source:
        rows = list(csv.DictReader(source))

    if args.split is not None:
        if not rows or "split" not in rows[0]:
            raise ValueError("--split was specified, but the CSV has no split column")
        rows = [row for row in rows if row["split"] == args.split]

    if not rows:
        raise ValueError("No rows matched the requested manifest")

    sample_ids = [row["id"] for row in rows]
    if len(sample_ids) != len(set(sample_ids)):
        raise ValueError("The CSV contains duplicate IDs")

    audio_dir = args.audio_dir.resolve()
    records: list[dict[str, object]] = []

    for row in rows:
        audio_path = audio_dir / f"{row['id']}.wav"
        if not audio_path.is_file():
            raise FileNotFoundError(audio_path)

        record: dict[str, object] = {
            "audio_filepath": str(audio_path),
            "duration": read_wav_duration(audio_path),
            "text": row["reference_text"],
            "lang": "ja-JP",
            "target_lang": "ja-JP",
            "sample_id": row["id"],
            "target_term": row.get("target_term", ""),
            "category": row.get("category", ""),
            "role": row.get("role", ""),
        }
        if "split" in row:
            record["split"] = row["split"]
        records.append(record)

    args.output.parent.mkdir(parents=True, exist_ok=True)
    with args.output.open("w", encoding="utf-8") as output:
        for record in records:
            output.write(json.dumps(record, ensure_ascii=False) + "\n")

    print(f"Created {len(records)} records: {args.output.resolve()}")

if __name__ == "__main__":
    main()

作成されるmanifestは1行につき1音声を表します。生成されるJSONLの一行分の例は次のとおりです。(サンプルのため見やすいように整形しています)

{
  "audio_filepath":"/home/ubuntu/workspace/nemotron35-ja/work/recordings/discovery-wav/D001.wav",
  "duration":10.579625,
  "text":"Nemotron 3.5 ASRを使って日本語音声を認識します。",
  "lang":"ja-JP"
}

音声ファイルのパスや長さ、発話したテキスト、ターゲットとする言語などが格納されます。

ベースモデルで推論

続いて以下を実行して、推論対象となるベースモデルをダウンロードして.nemo形式で保存します。

# Brev で実行してください
python scripts/download_base_model.py

scripts/download_base_model.pyの内容は以下のようになっています。

#!/usr/bin/env python3
"""Download Nemotron 3.5 ASR from Hugging Face and save a local NeMo checkpoint."""

from __future__ import annotations

import argparse
from pathlib import Path

from nemo.collections.asr.models import ASRModel

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--model",
        default="nvidia/nemotron-3.5-asr-streaming-0.6b",
        help="Hugging Face model ID",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("work/models/nemotron-3.5-asr-streaming-0.6b.nemo"),
        help="Output .nemo checkpoint path",
    )
    return parser.parse_args()

def main() -> None:
    args = parse_args()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    model = ASRModel.from_pretrained(args.model)
    model.save_to(str(args.output))
    print(args.output.resolve())

if __name__ == "__main__":
    main()

使用感はHugging Faceに非常に近いことがよくわかりますね。

保存したベースモデルと60件のmanifestを指定し、冒頭に説明したatt_context_size="[56,0]"の設定でストリーミング推論します。

# Brev で実行してください
mkdir -p work/evaluation/discovery-base-80ms

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path=work/models/nemotron-3.5-asr-streaming-0.6b.nemo \
  dataset_manifest=work/manifests/discovery_manifest.jsonl \
  output_path=work/evaluation/discovery-base-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

推論結果はwork/evaluation/discovery-base-80ms内のstreaming_out_*.jsonへ保存されます。

認識結果と誤挿入を集計

出力されたJSONを指定し、対象語の完全一致と対照文への誤挿入を集計します。

# Brev で実行してください
PREDICTIONS=$(find work/evaluation/discovery-base-80ms \
  -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/discovery_sentences.csv \
  --predictions "${PREDICTIONS}" \
  --details work/results/discovery-base-80ms-details.csv \
  --summary work/results/discovery-base-80ms-summary.json

evaluate_term_recognition.pyの実装はGitHubのscripts/evaluate_term_recognition.pyで公開しています。

このスクリプトは台本CSVと推論結果を受け取り、対象語の一致率、対照文への対象語誤挿入、対象文への別の候補語の誤挿入を集計します。

--splitを指定すれば、ファインチューニング用CSVのtrain、validation、testにも同じ処理を利用できます。

CERの計算

以下を実行して、同じ推論結果からCERも計算します。

# Brev で実行してください
python scripts/evaluate_cer.py \
  --input "${PREDICTIONS}" \
  --summary work/results/discovery-base-80ms-cer.json \
  --errors work/results/discovery-base-80ms-errors.csv

evaluate_cer.pyの実装はGitHubのscripts/evaluate_cer.pyで公開しています。

このスクリプトは、表記込みCERと、空白・句読点も除去した正規化CERを同じ推論結果から計算します。

初期評価の結果まとめ

初期評価の結果は次のとおりでした。

指標 結果
対象語を含む54文の完全一致 5/54(9.26%)
対照文への対象語誤挿入 0/6
表記込みCER 29.92%
正規化CER 30.23%
60件のストリーミング推論時間 17.63秒

27語のうち、2文とも期待した表記になったのはCTCDGX、1文だけ期待した表記になったのはNGCでした。

残り24語は2文とも期待した表記になりませんでした。つまり、ベースモデルのままでは24語の認識が困難であることがわかります。

これで苦手語が判明したため、以降ではこれらの苦手語を改善するためにファインチューニングを行います。

ファインチューニングの実施

初期評価で2文とも認識できなかった24語のうち、今回はNemotronNeMoRivaCanaryNIMCERFastConformerTensorRTの8語をファインチューニングの対象にしました。

学習・評価用音声の録音

選択した8語について、合計で学習用48文、validation用16文、test用16文の計80文を用意しました。
(ここは再現のためにはご自身で録音をしていただく必要があります)

split 文数 用途
train 48 パラメーターの更新
validation 16 学習中の確認と学習ステップ数の判断
test 16 学習前後の比較
合計 80

train、validation、testでは文章を重複させていません。

一方、今回はすべて1名分の録音データしか準備していため、検証できるのは同一話者へのドメイン適応です。実際には別話者にも効果があるかを別途検証する必要があります。

台本はdata/finetune_sentences.csvへ記載されています。冒頭5件は次のとおりです。

id,split,category,target_term,spoken_hint,reference_text,role
TR001,train,model,Nemotron,ネモトロン,Nemotronで会議音声をリアルタイムに文字起こしします。,domain_adaptation
TR002,train,model,Nemotron,ネモトロン,新しいNemotronモデルの日本語性能を確認しました。,domain_adaptation
TR003,train,model,Nemotron,ネモトロン,音声認識基盤にNemotronを組み込みます。,domain_adaptation
TR004,train,model,Nemotron,ネモトロン,Nemotronのストリーミング推論を有効にします。,domain_adaptation
TR005,train,model,Nemotron,ネモトロン,今回はNemotronを業務用語に適応させます。,domain_adaptation
...

完全版はGitHubのdata/finetune_sentences.csvで公開しています。

初期評価と同じく、スマートフォンの録音アプリなどでM4A形式で録音します。
CSVのidをそのままファイル名にして、次のようにフォルダに配置してBrevに転送します。

nemotron35-ja-local/
├── data/
│   └── finetune_sentences.csv
└── work/
    └── recordings/
        └── finetune-m4a/
            ├── TR001.m4a
            ├── TR002.m4a
            ├── ...
            ├── TR048.m4a
            ├── VA001.m4a
            ├── ...
            ├── VA016.m4a
            ├── TE001.m4a
            ├── ...
            └── TE016.m4a

録音が終わったら、先ほどと同様にBrevへ再転送します。

# ローカルPCで実行してください
brev exec cm-nakamura-sample-instance \
  "mkdir -p /home/ubuntu/workspace/nemotron35-ja"

brev copy \
  nemotron35-ja-local/ \
  cm-nakamura-sample-instance:/home/ubuntu/workspace/nemotron35-ja/

転送後はBrevのShellへ戻り、以降の変換、manifest作成、学習を実行します。

M4AをWAVへ変換

初期評価で使用したconvert_m4a_to_wav.shを再利用し、80件を16kHz、モノラル、16-bit PCMのWAVへ変換します。

# Brev で実行してください
bash scripts/convert_m4a_to_wav.sh \
  work/recordings/finetune-m4a \
  work/recordings/finetune-wav

train、validation、testのmanifestを作成

初期評価で使用したcreate_manifest.pyを再利用します。

--splitでCSVから対象行を抽出し、3つのmanifestを作成します。

# Brev で実行してください
python scripts/create_manifest.py \
  --sentences data/finetune_sentences.csv \
  --audio-dir work/recordings/finetune-wav \
  --split train \
  --output work/manifests/finetune/finetune_train_manifest.jsonl

python scripts/create_manifest.py \
  --sentences data/finetune_sentences.csv \
  --audio-dir work/recordings/finetune-wav \
  --split validation \
  --output work/manifests/finetune/finetune_validation_manifest.jsonl

python scripts/create_manifest.py \
  --sentences data/finetune_sentences.csv \
  --audio-dir work/recordings/finetune-wav \
  --split test \
  --output work/manifests/finetune/finetune_test_manifest.jsonl

学習スクリプトへ渡すのはtrainとvalidationだけです。testは学習には使用せず、学習前後の推論結果を比較するために残します。

testデータをベースモデルで推論・評価

チューニング前にまずベースモデルでtest 16件を推論します。推論条件は初期評価と同じです。

# Brev で実行してください
mkdir -p work/evaluation/finetune-test-base-80ms

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path=work/models/nemotron-3.5-asr-streaming-0.6b.nemo \
  dataset_manifest=work/manifests/finetune/finetune_test_manifest.jsonl \
  output_path=work/evaluation/finetune-test-base-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

推論結果に対して、初期評価と同じevaluate_term_recognition.pyevaluate_cer.pyを実行します。

--split testを指定すると、80行のCSVからtest 16行だけを抽出して推論結果と突き合わせることができます。

# Brev で実行してください
BASE_TEST_PREDICTIONS=$(find \
  work/evaluation/finetune-test-base-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/finetune_sentences.csv \
  --split test \
  --predictions "$BASE_TEST_PREDICTIONS" \
  --details work/evaluation/finetune-test-base-80ms/term-details.csv \
  --summary work/evaluation/finetune-test-base-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$BASE_TEST_PREDICTIONS" \
  --summary work/evaluation/finetune-test-base-80ms/cer-summary.json \
  --errors work/evaluation/finetune-test-base-80ms/cer-errors.csv

ベースモデルでは、test 16文の対象語を期待表記で認識できたものは0件でした。表記込みCERは26.10%、正規化CERは26.90%です。

指標 ベースモデル
test対象語の完全一致 0/16(0%)
testの表記込みCER 26.10%
testの正規化CER 26.90%

これをファインチューニング前のベースラインとします。

300 stepでファインチューニング

NeMoに含まれるspeech_to_text_finetune.pyと、FastConformer Transducerのストリーミング用設定を利用します。ベースモデルから初期化し、1GPU、bf16、AdamWで300 step学習しました。

このときtrainは48件で、この件数ではデフォルトでは1 epochあたり5 batchとなります。そのため300 stepは60 epochに相当します。(振り返るとデータ量に対しては長めの学習となっています)

# Brev で実行してください
mkdir -p work/logs

NEMOTRON35_OUTPUT_DIR=work/training/domain-300-v2 \
NEMOTRON35_EXPERIMENT_VERSION=ja-domain-300-v2 \
NEMOTRON35_MAX_STEPS=300 \
NEMOTRON35_VALIDATION_INTERVAL=100 \
bash scripts/run_finetune.sh \
  2>&1 | tee work/logs/finetune-domain-300-v2.log

run_finetune.shの内容は次のとおりです。

#!/usr/bin/env bash
set -euo pipefail

PROJECT_DIR="${NEMOTRON35_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
WORK_DIR="${NEMOTRON35_WORK_DIR:-${PROJECT_DIR}/work}"
NEMO_DIR="${NEMOTRON35_NEMO_DIR:-${WORK_DIR}/deps/NeMo}"
PYTHON_BIN="${NEMOTRON35_PYTHON_BIN:-${WORK_DIR}/.venv/bin/python}"
BASE_MODEL="${NEMOTRON35_BASE_MODEL:-${WORK_DIR}/models/nemotron-3.5-asr-streaming-0.6b.nemo}"
TRAIN_MANIFEST="${NEMOTRON35_TRAIN_MANIFEST:-${WORK_DIR}/manifests/finetune/finetune_train_manifest.jsonl}"
VALIDATION_MANIFEST="${NEMOTRON35_VALIDATION_MANIFEST:-${WORK_DIR}/manifests/finetune/finetune_validation_manifest.jsonl}"
OUTPUT_DIR="${NEMOTRON35_OUTPUT_DIR:-${WORK_DIR}/training}"
MAX_STEPS="${NEMOTRON35_MAX_STEPS:-300}"
BATCH_DURATION="${NEMOTRON35_BATCH_DURATION:-120}"
LEARNING_RATE="${NEMOTRON35_LEARNING_RATE:-0.1}"
WEIGHT_DECAY="${NEMOTRON35_WEIGHT_DECAY:-0.001}"
WARMUP_STEPS="${NEMOTRON35_WARMUP_STEPS:-100}"
VALIDATION_INTERVAL="${NEMOTRON35_VALIDATION_INTERVAL:-1.0}"
CHECK_VAL_EVERY_N_EPOCH="${NEMOTRON35_CHECK_VAL_EVERY_N_EPOCH:-5}"
CHECKPOINT_SAVE_TOP_K="${NEMOTRON35_CHECKPOINT_SAVE_TOP_K:-1}"
EXPERIMENT_VERSION="${NEMOTRON35_EXPERIMENT_VERSION:-ja-domain-300}"

test -f "${BASE_MODEL}"
test -f "${TRAIN_MANIFEST}"
test -f "${VALIDATION_MANIFEST}"
test -f "${NEMO_DIR}/examples/asr/speech_to_text_finetune.py"
test -x "${PYTHON_BIN}"

mkdir -p "${OUTPUT_DIR}"

"${PYTHON_BIN}" "${NEMO_DIR}/examples/asr/speech_to_text_finetune.py" \
  --config-path="../asr/conf/fastconformer/cache_aware_streaming" \
  --config-name=fastconformer_transducer_bpe_streaming_prompt.yaml \
  +init_from_nemo_model="${BASE_MODEL}" \
  ++model.train_ds.manifest_filepath="${TRAIN_MANIFEST}" \
  ++model.validation_ds.manifest_filepath="${VALIDATION_MANIFEST}" \
  ++model.train_ds.default_prompt_mode=unified \
  ++model.train_ds.unified_auto_ratio=0.5 \
  ++model.train_ds.batch_duration="${BATCH_DURATION}" \
  ++model.validation_ds.batch_size=4 \
  ++model.optim.name=adamw \
  ++model.optim.lr="${LEARNING_RATE}" \
  ++model.optim.weight_decay="${WEIGHT_DECAY}" \
  ++model.optim.sched.d_model=1024 \
  ++model.optim.sched.warmup_steps="${WARMUP_STEPS}" \
  ++trainer.devices=1 \
  ++trainer.accelerator=gpu \
  ++trainer.max_epochs=-1 \
  ++trainer.max_steps="${MAX_STEPS}" \
  ++trainer.precision=bf16 \
  ++trainer.log_every_n_steps=10 \
  ++trainer.val_check_interval="${VALIDATION_INTERVAL}" \
  ++trainer.check_val_every_n_epoch="${CHECK_VAL_EVERY_N_EPOCH}" \
  ++trainer.enable_checkpointing=false \
  ++exp_manager.exp_dir="${OUTPUT_DIR}" \
  ++exp_manager.version="${EXPERIMENT_VERSION}" \
  ++exp_manager.use_datetime_version=false \
  ++exp_manager.checkpoint_callback_params.save_top_k="${CHECKPOINT_SAVE_TOP_K}" \
  ++exp_manager.resume_if_exists=false \
  ++exp_manager.resume_ignore_no_checkpoint=true

find "${OUTPUT_DIR}" -type f \( -name '*.nemo' -o -name '*.ckpt' \) -print

主なパラメーターと役割は次のとおりです。

パラメーター 今回の値 役割
config-name fastconformer_transducer_bpe_streaming_prompt.yaml プロンプト対応のストリーミングFastConformer-RNNT設定を読み込む
init_from_nemo_model ベースモデルの.nemo 事前学習済みの重み、tokenizer、モデル構造を引き継ぐ
train_ds.manifest_filepath train 48文 パラメーター更新に使う音声と正解文を指定する
validation_ds.manifest_filepath validation 16文 学習中の性能確認に使う音声を指定する
default_prompt_mode unified 言語ID指定と自動言語判定用のプロンプトを混ぜて学習する
unified_auto_ratio 0.5 unified時にautoプロンプトを選ぶ割合を50%にする
batch_duration 120秒 1 batchへ入れる音声の合計時間を上限として動的にbatchを作る
optim AdamW、学習率0.1、weight decay 0.001 重みの更新方法と正則化を指定する
optim.sched Noam、d_model=1024、warmup 100 step 学習開始直後に学習率を徐々に上げ、その後減衰させる
trainer 1 GPU、bf16 L40S 1基で混合精度学習する
max_steps 300 epoch数ではなくoptimizer更新回数で学習量を固定する
val_check_interval 100 100 training batchごとのvalidationを意図した設定。後述のとおり今回は間隔が長すぎた
enable_checkpointing false Lightning側のcheckpointを無効にし、NeMoのexp_managerへ一元化する
save_top_k 1 validation指標が最良のcheckpointを1つだけ保持する

学習は約1分48秒で完了し、学習済みモデルは次の場所に保存されました。

work/training/domain-300-v2/
└── FastConformer-Transducer-BPE-Prompt-Streaming/
    └── ja-domain-300-v2/
        └── checkpoints/
            └── FastConformer-Transducer-BPE-Prompt-Streaming.nemo

補足1:チェックポイントの重複

最初の実行では、PyTorch Lightningのcheckpoint機能とNeMoのexp_managerが同時にcheckpoint callbackを作成し、CheckpointMisconfigurationErrorになりました。

CheckpointMisconfigurationError:
Trainer was configured with enable_checkpointing=True
but found multiple ModelCheckpoint callbacks

そこで、Trainer側のcheckpointを無効にし、NeMoのexp_managerだけに管理させました。

++trainer.enable_checkpointing=false

validationの未実施

また、300 step実行時はval_check_interval=100にしていました。しかし1 epochは5 batchしかないため、意図していた途中でのvalidationは実行されません。

そのため、この300 stepモデルは「validationで選んだ最良checkpoint」ではなく、固定した300 stepまで学習した最終モデルとして扱います。

300 stepモデルで推論

学習済みモデルで、未学習のtest 16件と初期評価60件を推論します。

初期評価データには対象8語以外の語と、専門用語を含まない対照文もあるため、学習した語の誤挿入が増えていないかも確認できます。

# Brev で実行してください
MODEL_300=work/training/domain-300-v2/FastConformer-Transducer-BPE-Prompt-Streaming/ja-domain-300-v2/checkpoints/FastConformer-Transducer-BPE-Prompt-Streaming.nemo

mkdir -p \
  work/evaluation/finetune-test-tuned300-80ms \
  work/evaluation/discovery-tuned300-80ms

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path="$MODEL_300" \
  dataset_manifest=work/manifests/finetune/finetune_test_manifest.jsonl \
  output_path=work/evaluation/finetune-test-tuned300-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path="$MODEL_300" \
  dataset_manifest=work/manifests/discovery_manifest.jsonl \
  output_path=work/evaluation/discovery-tuned300-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

300 stepモデルの推論結果を評価

推論結果は、学習前と同じ2つの評価スクリプトで集計します。

testには--split testを指定し、初期評価にはdata/discovery_sentences.csvを指定します。

# Brev で実行してください
TEST_300_PREDICTIONS=$(find \
  work/evaluation/finetune-test-tuned300-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/finetune_sentences.csv \
  --split test \
  --predictions "$TEST_300_PREDICTIONS" \
  --details work/evaluation/finetune-test-tuned300-80ms/term-details.csv \
  --summary work/evaluation/finetune-test-tuned300-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$TEST_300_PREDICTIONS" \
  --summary work/evaluation/finetune-test-tuned300-80ms/cer-summary.json \
  --errors work/evaluation/finetune-test-tuned300-80ms/cer-errors.csv

DISCOVERY_300_PREDICTIONS=$(find \
  work/evaluation/discovery-tuned300-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/discovery_sentences.csv \
  --predictions "$DISCOVERY_300_PREDICTIONS" \
  --details work/evaluation/discovery-tuned300-80ms/term-details.csv \
  --summary work/evaluation/discovery-tuned300-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$DISCOVERY_300_PREDICTIONS" \
  --summary work/evaluation/discovery-tuned300-80ms/cer-summary.json \
  --errors work/evaluation/discovery-tuned300-80ms/cer-errors.csv

結果は以下のようになりました。

評価データ・指標 ベースモデル 300 step
test対象語の完全一致 0/16(0%) 16/16(100%)
testの表記込みCER 26.10% 6.10%
testの正規化CER 26.90% 6.35%
初期評価54文の対象語完全一致 5/54(9.26%) 20/54(37.04%)
初期評価の表記込みCER 29.92% 25.89%
初期評価の正規化CER 30.23% 26.34%
対照文への対象語誤挿入 0/6 2/6
対象文への別対象語誤挿入 0/54 5/54

300 stepモデルはtestの対象語を16/16で認識し、testの表記込みCERも6.10%まで下がりました。

一方、初期評価の対照文6件のうち2件に、学習した専門用語を誤挿入しました。たとえば、会議のメモ会議のNeMoチーム内の情報共有Rivaの情報共有と認識しました。

対象語を覚えた一方で、無関係な発話にも出力しやすくなっています。

100 stepへ短縮して再学習

300 stepは60 epoch相当で、少量データに対して学習しすぎた可能性があります。そこで学習を100 step、20 epoch相当へ短縮しました。

validationはepoch終了時に実行し、5 epochごとに確認する設定へ変更します。

# Brev で実行してください
mkdir -p work/logs

NEMOTRON35_OUTPUT_DIR=work/training/domain-100-v3 \
NEMOTRON35_EXPERIMENT_VERSION=ja-domain-100-v3 \
NEMOTRON35_MAX_STEPS=100 \
NEMOTRON35_VALIDATION_INTERVAL=1.0 \
NEMOTRON35_CHECK_VAL_EVERY_N_EPOCH=5 \
bash scripts/run_finetune.sh \
  2>&1 | tee work/logs/finetune-domain-100-v3.log

学習は約33秒で完了しました。学習済みモデルは次の場所に保存されます。

work/training/domain-100-v3/
└── FastConformer-Transducer-BPE-Prompt-Streaming/
    └── ja-domain-100-v3/
        └── checkpoints/
            └── FastConformer-Transducer-BPE-Prompt-Streaming.nemo

100 stepモデルで推論

100 stepモデルについても、validation、test、初期評価の3セットを同じ条件で推論します。

# Brev で実行してください
MODEL_100=work/training/domain-100-v3/FastConformer-Transducer-BPE-Prompt-Streaming/ja-domain-100-v3/checkpoints/FastConformer-Transducer-BPE-Prompt-Streaming.nemo

mkdir -p \
  work/evaluation/finetune-validation-tuned100-80ms \
  work/evaluation/finetune-test-tuned100-80ms \
  work/evaluation/discovery-tuned100-80ms

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path="$MODEL_100" \
  dataset_manifest=work/manifests/finetune/finetune_validation_manifest.jsonl \
  output_path=work/evaluation/finetune-validation-tuned100-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path="$MODEL_100" \
  dataset_manifest=work/manifests/finetune/finetune_test_manifest.jsonl \
  output_path=work/evaluation/finetune-test-tuned100-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

python work/deps/NeMo/examples/asr/asr_cache_aware_streaming/\
speech_to_text_cache_aware_streaming_infer.py \
  model_path="$MODEL_100" \
  dataset_manifest=work/manifests/discovery_manifest.jsonl \
  output_path=work/evaluation/discovery-tuned100-80ms \
  target_lang=ja-JP \
  att_context_size="[56,0]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size=8 \
  cuda=0

100 stepモデルの推論結果を評価

validation、test、初期評価の推論結果をそれぞれ集計します。validationとtestではfinetune_sentences.csv--splitを指定し、初期評価ではdiscovery_sentences.csvを指定します。

# Brev で実行してください
VALIDATION_100_PREDICTIONS=$(find \
  work/evaluation/finetune-validation-tuned100-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/finetune_sentences.csv \
  --split validation \
  --predictions "$VALIDATION_100_PREDICTIONS" \
  --details work/evaluation/finetune-validation-tuned100-80ms/term-details.csv \
  --summary work/evaluation/finetune-validation-tuned100-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$VALIDATION_100_PREDICTIONS" \
  --summary work/evaluation/finetune-validation-tuned100-80ms/cer-summary.json \
  --errors work/evaluation/finetune-validation-tuned100-80ms/cer-errors.csv

TEST_100_PREDICTIONS=$(find \
  work/evaluation/finetune-test-tuned100-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/finetune_sentences.csv \
  --split test \
  --predictions "$TEST_100_PREDICTIONS" \
  --details work/evaluation/finetune-test-tuned100-80ms/term-details.csv \
  --summary work/evaluation/finetune-test-tuned100-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$TEST_100_PREDICTIONS" \
  --summary work/evaluation/finetune-test-tuned100-80ms/cer-summary.json \
  --errors work/evaluation/finetune-test-tuned100-80ms/cer-errors.csv

DISCOVERY_100_PREDICTIONS=$(find \
  work/evaluation/discovery-tuned100-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/discovery_sentences.csv \
  --predictions "$DISCOVERY_100_PREDICTIONS" \
  --details work/evaluation/discovery-tuned100-80ms/term-details.csv \
  --summary work/evaluation/discovery-tuned100-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$DISCOVERY_100_PREDICTIONS" \
  --summary work/evaluation/discovery-tuned100-80ms/cer-summary.json \
  --errors work/evaluation/discovery-tuned100-80ms/cer-errors.csv

ファインチューニング結果

ベースモデル、300 stepモデル、100 stepモデルの結果を比較します。

評価データ・指標 ベースモデル 300 step 100 step
validation対象語の完全一致 未計測 未計測 16/16(100%)
validationの表記込みCER 未計測 未計測 3.06%
validationの正規化CER 未計測 未計測 3.22%
test対象語の完全一致 0/16(0%) 16/16(100%) 16/16(100%)
testの表記込みCER 26.10% 6.10% 2.20%
testの正規化CER 26.90% 6.35% 2.03%
初期評価54文の対象語完全一致 5/54(9.26%) 20/54(37.04%) 21/54(38.89%)
初期評価の表記込みCER 29.92% 25.89% 21.07%
初期評価の正規化CER 30.23% 26.34% 20.92%
対照文への対象語誤挿入 0/6 2/6 0/6
対象文への別対象語誤挿入 0/54 5/54 0/54

この実行では、100 stepでもtestの対象語は16/16を維持し、300 stepよりもCERが良くなりました。

さらに、300 stepで発生した対照文への誤挿入と、対象文への別対象語の誤挿入はいずれも0件でした。

そのため、この結果に基づくと100 stepモデルが最良と言えそうです。

ただし、データや実行条件によって結果は変わるため、100 stepが常に300 stepより良くなるとは限らないためご注意ください。

また100 stepと300 stepの両方を同じtestで評価しているため、このtestは一度だけ使う厳密なholdoutではありませんのでその点もご留意ください。

参考:Phrase Boostingの実施

Phrase Boostingとは

NeMoのPhrase Boosting(GPU-PB)は、モデルを再学習せず、デコード時に指定した語句の出力スコアへバイアスを加える機能です。

候補語のリストからtoken単位のboosting treeを作り、RNN-Tのgreedy decodingへshallow fusionします。

固有名詞や専門用語の候補を事前に把握できる場合、音声データを追加録音せずに認識率を改善できる可能性があります。

一方、boostを強くしすぎると、音響的に似ていない発話にも候補語を挿入する可能性があるため、対象語の認識率と誤挿入を同時に評価します。

Phrase Boostingの推論処理

Phrase Boostingを有効にするには、通常のストリーミング推論の引数に加えて、rnnt_decoding.greedy.boosting_tree配下へ候補語ファイル、context_scoredepth_scalingboosting_tree_alphaをHydra overrideとして渡す必要があります。

これらはデコーダー内部の階層が深いパラメーターで、alphaを変えるたびに推論コマンド全体を記述すると指定漏れが起きやすくなります。

そこで、NeMoがRNN-T向けに推奨するcontext_score=1.0depth_scaling=2.0を固定し、候補語ファイルとalphaだけを環境変数で切り替えられるよう、run_streaming_eval.shへまとめました。

scripts/run_streaming_eval.shの内容は次のとおりです。

#!/usr/bin/env bash
set -euo pipefail

if [ "$#" -lt 4 ]; then
  echo "Usage: $0 MODEL_PATH MANIFEST_PATH OUTPUT_DIR RIGHT_CONTEXT" >&2
  echo "RIGHT_CONTEXT: 0, 1, 3, 6, 13" >&2
  exit 2
fi

MODEL_PATH="$1"
MANIFEST_PATH="$2"
OUTPUT_DIR="$3"
RIGHT_CONTEXT="$4"
PROJECT_DIR="${NEMOTRON35_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
WORK_DIR="${NEMOTRON35_WORK_DIR:-${PROJECT_DIR}/work}"
NEMO_DIR="${NEMOTRON35_NEMO_DIR:-${WORK_DIR}/deps/NeMo}"
PYTHON_BIN="${NEMOTRON35_PYTHON_BIN:-${WORK_DIR}/.venv/bin/python}"
EVAL_BATCH_SIZE="${NEMOTRON35_EVAL_BATCH_SIZE:-8}"
BOOST_PHRASES="${NEMOTRON35_BOOST_PHRASES:-}"
BOOST_ALPHA="${NEMOTRON35_BOOST_ALPHA:-1.0}"

case "${RIGHT_CONTEXT}" in
  0|1|3|6|13) ;;
  *) echo "RIGHT_CONTEXTは0, 1, 3, 6, 13のいずれかです" >&2; exit 2 ;;
esac

test -f "${MODEL_PATH}"
test -f "${MANIFEST_PATH}"
test -x "${PYTHON_BIN}"
mkdir -p "${OUTPUT_DIR}"

EXTRA_ARGS=()
if [ -n "${BOOST_PHRASES}" ]; then
  test -f "${BOOST_PHRASES}"
  EXTRA_ARGS+=(
    "rnnt_decoding.greedy.boosting_tree.key_phrases_file=${BOOST_PHRASES}"
    "rnnt_decoding.greedy.boosting_tree.context_score=1.0"
    "rnnt_decoding.greedy.boosting_tree.depth_scaling=2.0"
    "rnnt_decoding.greedy.boosting_tree_alpha=${BOOST_ALPHA}"
  )
  echo "Phrase Boosting: ${BOOST_PHRASES} (alpha=${BOOST_ALPHA})"
fi

"${PYTHON_BIN}" "${NEMO_DIR}/examples/asr/asr_cache_aware_streaming/speech_to_text_cache_aware_streaming_infer.py" \
  model_path="${MODEL_PATH}" \
  dataset_manifest="${MANIFEST_PATH}" \
  output_path="${OUTPUT_DIR}" \
  target_lang=ja-JP \
  att_context_size="[56,${RIGHT_CONTEXT}]" \
  strip_lang_tags=true \
  decoder_type=rnnt \
  pad_and_drop_preencoded=true \
  batch_size="${EVAL_BATCH_SIZE}" \
  cuda=0 \
  "${EXTRA_ARGS[@]}"

find "${OUTPUT_DIR}" -maxdepth 1 -type f -name 'streaming_out_*.json' -print

Phrase Boostingを実行

初期評価で2文とも正解したCTCDGXを除き、1文だけ正解したNGCと、2文とも失敗した24語を合わせた25語をdata/phrase_boosting_terms.txtへ1行1フレーズで記載しました。

たとえばalpha=2.0では、次のように実行します。ほかの強度はNEMOTRON35_BOOST_ALPHAと出力先を変更して同じ音声を推論しました。

# Brev で実行してください
NEMOTRON35_BOOST_PHRASES=data/phrase_boosting_terms.txt \
NEMOTRON35_BOOST_ALPHA=2.0 \
bash scripts/run_streaming_eval.sh \
  work/models/nemotron-3.5-asr-streaming-0.6b.nemo \
  work/manifests/discovery_manifest.jsonl \
  work/evaluation/discovery-pb-a20-80ms \
  0

推論後は、ファインチューニングと同じ2つの評価スクリプトで対象語の一致とCERを集計します。

# Brev で実行してください
PB_PREDICTIONS=$(find \
  work/evaluation/discovery-pb-a20-80ms \
  -maxdepth 1 -type f -name 'streaming_out_*.json' -print -quit)

python scripts/evaluate_term_recognition.py \
  --sentences data/discovery_sentences.csv \
  --predictions "$PB_PREDICTIONS" \
  --details work/evaluation/discovery-pb-a20-80ms/term-details.csv \
  --summary work/evaluation/discovery-pb-a20-80ms/term-summary.json

python scripts/evaluate_cer.py \
  --input "$PB_PREDICTIONS" \
  --summary work/evaluation/discovery-pb-a20-80ms/cer-summary.json \
  --errors work/evaluation/discovery-pb-a20-80ms/cer-errors.csv

ほかのalphaでも、推論先のディレクトリ名に合わせてPB_PREDICTIONSと各出力先を変更し、同じように集計します。

結果は次のとおりです。

条件 対象語完全一致 表記込みCER 正規化CER 対照文への誤挿入 対象文への別語誤挿入
ベース 5/54(9.26%) 29.92% 30.23% 0/6 0/54
alpha=0.5 5/54(9.26%) 29.92% 30.23% 0/6 0/54
alpha=1.0 5/54(9.26%) 29.92% 30.23% 0/6 0/54
alpha=2.0 5/54(9.26%) 29.79% 30.16% 0/6 0/54
alpha=4.0 3/54(5.56%) 34.87% 35.16% 0/6 8/54

alpha=0.5と1.0はベースモデルと同じ出力で、alpha=2.0でも対象語の完全一致は増えませんでした。

alpha=4.0では、Nemotron 3.5 ASRを使って……へ無関係なSpecAugmentが挿入され、GPU上で学習しますの一部がPersonaPlexで学習しますへ変化しました。

新規Workspaceで再検証した際も、alpha=0.5と1.0はベースと同一、2.0は対象語完全一致が増えず、4.0では対象語完全一致が3/54へ減り、対象文への別語誤挿入が7/54に増えました。初期評価音声に再エンコードを挟んだため絶対値は異なりますが、Phrase Boostingを強くしても改善せず、誤挿入が増える傾向は再現しました。

対照文だけでは検出できない誤挿入もあるため、対象語を含む文へ別の候補語が入っていないかも集計しています。

今回の日本語音声から英字表記を出力させる条件では、Phrase Boostingによる改善は確認できませんでした。

後片付け

検証を一時中断して同じWorkspaceから再開する場合は、Brevインスタンスを停止し、GPU課金が継続しないようにします。

# ローカルPCで実行してください
brev stop cm-nakamura-sample-instance
brev list --json

brev list --jsonstatus=STOPPEDになったことまで確認します。Workspaceのストレージを残す場合、停止後もストレージ料金が発生する可能性があるため、Brevの料金表示も確認します。

今後再開しない場合は、Workspaceを削除すると関連ボリュームも削除できます。
ただし、Workspace内のファイルは復元できないため、必要に応じて先に学習済みモデルなどをローカルPCへ退避します。今回採用した100 stepモデルは次のように取得します。

# ローカルPCで実行してください
mkdir -p nemotron35-ja-local/work/models

brev copy \
  cm-nakamura-sample-instance:/home/ubuntu/workspace/nemotron35-ja/work/training/domain-100-v3/FastConformer-Transducer-BPE-Prompt-Streaming/ja-domain-100-v3/checkpoints/FastConformer-Transducer-BPE-Prompt-Streaming.nemo \
  nemotron35-ja-local/work/models/nemotron35-ja-domain-100.nemo

必要な成果物を取得したら、Workspace名を指定して削除します。
brev deleteはWorkspaceとその関連ボリュームを完全に削除する、取り消しできない操作です。

# ローカルPCで実行してください
brev delete cm-nakamura-sample-instance
brev list --json

brev list --jsonworkspacesからcm-nakamura-sample-instanceが消えたことまで確認します。

まとめ

Nemotron 3.5 ASRはそのままでも日本語を認識できますが、専門用語や固有名詞を含む業務音声にはドメイン適応の余地があります。

独自音声60件のベースラインでは期待する英字表記が5/54にとどまり、Phrase Boostingでも改善しませんでした。そこで失敗した8語を選び、train 48文、validation 16文、test 16文を追加録音しました。

100 stepのファインチューニング後、testの対象語表記は0/16から16/16、表記込みCERは26.10%から2.20%へ改善しました。一方、300 stepモデルでは無関係な発話への誤挿入が発生しました。少量データによる学習では、対象語の認識率だけでなく、CERと誤挿入も合わせて学習量を判断する必要があります。

今回の結果は、単一話者、8語、合計8分43秒の追加音声による小規模な検証です。それでも、日本語音声と期待表記を追加学習することで、カタカナや漢字へ誤変換されるドメイン語を少量データで改善できることを確認できました。

本記事がNemotron ASRのファインチューニングの基本を理解するための参考になれば幸いです。

参考資料

この記事をシェアする

関連記事