初めに
音声対話システムで地味に難しいのが、相手が話し終わったかどうかの判定です。VAD は音が止まったことしか教えてくれないので、無音の閾値を短くすると言い淀んだだけで割り込み、長くすると応答が遅くなります。
前回、これを4状態で判定する Easy Turn の日本語対応モデルを作りました。
ayousanz.hatenadiary.jp
モデルは以下で公開をしています
huggingface.co
| 状態 |
意味 |
システムの取るべき動作 |
| Complete |
話し終わった |
応答を開始する |
| Incomplete |
まだ続く |
黙って聞き続ける |
| Backchannel |
相槌(「うん」「へー」) |
自分の発話を止めない |
| Wait |
「ちょっと待ってください」 |
長めに待つ |
ただし Easy Turn は VAD の後段に置く再判定器で、判定の前に ASR の転写を出し切る必要があります。実測で P95 291ms かかっていました。
FastTurn は、同じ4状態を VAD 非依存のストリーミングで判定する論文です(arXiv:2604.01897、Easy Turn と同じ ASLP-lab / Lei Xie グループ)。全文の転写を待たず、ストリーミング CTC が吐く途中の文字列とエンコーダの中間表現の両方を使います。論文の報告値は Accuracy 79.62% / 平均遅延 120.1ms で、同じ表の Easy Turn が 78.05% / 297.1ms です。精度は同等で遅延が 2.5 分の1、という位置づけになります。
前回作ったものとの違いを並べると、こうなります。
| 観点 |
Easy Turn 日本語版(exp11a) |
FastTurn 日本語版(今回) |
| 音声エンコーダ |
Whisper-Medium(オフライン) |
Nemotron FastConformer(ストリーミング) |
| 意味の取り出し方 |
全文を転写してから判定する |
CTC の途中の文字列を逐次受け取る |
| LLM |
Qwen2.5-0.5B-Instruct |
Qwen3-0.6B |
| 判定の出し方 |
LLM が状態トークンを生成する |
3層 MLP(Unified の場合) |
| 音響情報 |
暗黙(Whisper の特徴のみ) |
明示的に融合する(Acoustic Adapter) |
| 動作 |
VAD の後段に置く再判定器 |
VAD 非依存のストリーミング |
| 判定する状態 |
4状態 |
同じ4状態 |
違いの本質は3つです。
- エンコーダ: オフライン → ストリーミング(遅延と VAD 非依存性の根拠)
- 意味の取り出し: 全文の転写 → 途中の文字列(同上)
- 音響情報: 暗黙 → 明示的な融合(論文の新規性)
これを日本語で作りました。ただし FastTurn は公式の checkpoint も推論コードも公開されていないので、論文から組み直しています。
作ったもの
5つの系統を実装して、同じ評価セット(実会話150件+合成 wait 50件)で並べました。
| 系統 |
構成 |
Macro-F1 |
C/I Youden J |
相槌 F1 |
| VAD-only(τ=550ms) |
無音の長さのみ |
0.150 |
0.10 |
0.000 |
| Cascaded |
ASR の文字列を LLM へ |
0.538 |
0.02 |
0.600 |
| Semantic |
音声表現を LLM の入力に足す |
0.639 |
0.36 |
0.521 |
| Unified |
音響情報を出力側でも融合 |
0.718 |
0.44 |
0.727 |
| exp11a(前回の Easy Turn 日本語版) |
Whisper と Qwen2.5、VAD の後段 |
0.810 |
0.46 |
0.898 |
結果は3点です。
- 再現できた — 論文が示す積み上がり方(VAD-only < Cascaded < Semantic < Unified)
- 勝った — 遅延。エンコーダ込みで P95 110.2ms(エンコーダ 59.6 + 判定器 50.6)。前回の 291ms の約 2.6 分の1
- 届かなかった — 精度。前回のモデルに 0.718 対 0.810
狙いは「精度は同等、勝つのは遅延」だったので、半分の達成です。論文の中心にある「音響融合で最良になる」も検証できていません。当初は Unified が Semantic を 0.079 上回ったのですが、学習の乱数を変えて12回ずつ測ると +0.019(z=+1.25)まで縮みました(後半に書きます)。
数値の比較範囲について2点あります。論文の報告値(中国語と英語で 22,432件)とこの表(日本語で 200件)は、別のデータセットなので比較していません。前回の記事も 824件の別セットで測っていて、同じ exp11a が 0.649 と出ています。比較が成立するのは、5系統を同じ 200件で測ったこの表の中だけです。

論文が規定していること、していないこと
公式リポジトリにあるのは README と構成図、それにテストセットだけです。Easy Turn のときは checkpoint.pt が 3.45GB で降ってきたので動かして確かめられましたが、今回はできません。
論文を読んで、構造について明確に規定されているのは5点でした。Conformer の中間 hidden を使うこと、Acoustic Adapter は4層 Transformer であること、それを LLM の hidden と融合すること、3層 MLP の Turn Detector を置くこと、Fusion 段で Adapter と Detector を学習すること。
逆に、音響の系列をどう固定長にするのか、融合をどの演算でやるのかは書かれていません。ここは自分で決めました。
組み上がった全体像です。
音声 (16kHz)
→ Nemotron-3.5-asr-streaming-0.6b (24層 FastConformer, 320ms chunk)
├─ CTC の途中の文字列 → Qwen3-0.6B ────────────→ Cascaded
└─ 中間 hidden (T, 1024)
├─ LLM Adapter (4層 Transformer, 34M) → Qwen3-0.6B → Semantic
└─ Acoustic Adapter (4層 Transformer)
└→ LLM hidden と融合 → 3層 MLP Turn Detector → Unified
エンコーダは中間表現を取り出せるかで選ぶ
エンコーダに求められる条件は構成から決まります。CTC の途中の文字列と、Acoustic Adapter に渡す中間 hidden の両方を、ストリーミングで出せることです。
日本語で使える候補として ReazonSpeech の2種類を実機で比べました。
| |
k2 Zipformer |
NeMo (nemo-v2) |
| 途中の文字列 |
出る |
出る |
| 中間 hidden |
取得不可 |
[1, 1024, 112] float32 |
| 最終転写 |
完全一致(CER 0.0) |
前半が脱落 |
転写がいちばん正確だった k2 Zipformer が、この時点で失格しました。ONNX 配布で torch のエンコーダが存在せず、中間層の出力を取り出す先がありません。推論はできても Acoustic Adapter が作れません。論文どおりのアーキテクチャを組むなら、ASR の精度だけで選ぶと、あとから作れないものが出てきます。
採用したのは nvidia/nemotron-3.5-asr-streaming-0.6b(24層 FastConformer、OpenMDW-1.1)です。論文は 12層の Conformer を中英3万時間超で事前学習していますが、そこは学習済みの多言語ストリーミングモデルで置き換えました。
中間 hidden の取り出しは、公式スクリプトを書き換えず、モデルをロードした直後に hook を差し込む形にしました。
def _ft_hook(_m, _i, o):
t = o[0] if isinstance(o, (list, tuple)) else o
if hasattr(t, "shape"):
_FT["hidden"].update(captured=True, shape=list(t.shape), dtype=str(t.dtype))
asr_model.encoder.register_forward_hook(_ft_hook)
asr_model.encoder に1つ仕掛けるだけで取れます。取れた形は [1, 1024, N] で、N はチャンク内のフレーム数です。次元が 1024 と分かったので、Adapter の入力側はここで決まりました。
先読み幅を変えて測った結果です。
| lookahead |
≒遅延 |
処理時間 median |
P95 |
| 0 |
80ms |
11.8ms |
12.2ms |
| 3 |
320ms |
12.8ms |
14.2ms |
| 13 |
1120ms |
16.0ms |
19.2ms |
80ms しか先を見ていない設定でも、日本語の転写は句点が1つ落ちる程度でした。チャンクあたり 12〜19ms なので、1チャンクの予算 150ms に対して十分軽い数字です。VRAM はロード時 2.596GB、ピーク 5.15GB(fp32)でした。
論文が書いていない部分を決める
LLM Adapter は論文の指定どおり4層の Transformer Encoder です。
layer = nn.TransformerEncoderLayer(
d_model=1024, nhead=8, dim_feedforward=2048,
dropout=0.1, batch_first=True, activation="gelu", norm_first=True)
ffn_dim を 2048 にしたのはパラメータ数を論文の記述(約34M)に合わせるためで、4096 だと 50M になります。位置エンコードは学習可能なパラメータにしました。cache-aware streaming ではチャンクの長さが可変で、hidden の時間方向の長さが 1 にも 4 にもなるためです。
Acoustic Adapter は「4層 Transformer」としか書かれていないので、LLM Adapter と同型のものを再利用しています。問題はその先で、時間方向に長さのある音響系列と、固定長の LLM last-hidden をどう混ぜるかが論文にありません。決めたのは2点です。
- 音響系列は masked-mean で時間方向に潰して固定長にする
- LLM hidden と連結してから線形射影する
加算にしなかったのは次元を揃える制約が強いからで、連結なら音響と意味の寄与を層が学習できます。
あとから別方式も試せるよう切り替え式にしましたが、そうすると新しい問題が出ます。どの方式で学習したかが checkpoint から分からないと、推論時に別の計算をしてしまいます。そこで構成キーを checkpoint 自体に書き込み、復元は必ずその値を通すようにしました。load_state_dict は strict のままなので、取り違えるとキーの過不足で必ず落ちます。静かに別のモデルになりません。
Turn Detector は指定どおり3層の MLP です。
self.net = nn.Sequential(
nn.Linear(1024, 512), nn.GELU(), nn.Dropout(0.1),
nn.Linear(512, 512), nn.GELU(), nn.Dropout(0.1),
nn.Linear(512, 4),
)
Cascaded と Semantic は LLM が状態トークンを生成してその logit から確率を出しますが、Unified はこの4つの logit を softmax します。
学習データを作る
論文の学習は4段階です。省いたのは1段階目の ASR 事前学習だけで、あとは実施しました。
| 段階 |
設定 |
| Modality Alignment(Adapter を ASR 目的で) |
epochs 2 / batch 16 / lr 5e-4 |
| Joint(Adapter と LLM をターンデータで) |
epochs 3 / batch 8 / lr 2e-5 / prompt dropout 0.35 |
| Fusion(Acoustic Adapter と Turn Detector) |
epochs 6 / batch 8 / lr 1e-4 |
データ側で効いたのは3点です。句読点を落とすこと、クラス分布を揃えること、実会話データを混ぜることです。
句読点を落とす
合成テキストで4状態データを作ると、句読点が完結と未完結を分ける手がかりになります。実会話の書き起こしには句読点が無いため、そのまま学習したモデルは実会話で判別できません(合成テキストでの Macro-F1 0.864 に対し、実会話では 0.450)。
| |
平均長 |
句読点を含む割合 |
| 学習(合成) |
13.4字 |
18.7% |
| 実会話の書き起こし |
12.7字 |
0.0% |
テキスト生成の段階で句読点を落とすか、実会話の表記に寄せます。
ASR を経由する構成では、転写の際に元テキストの句読点が失われるため、この影響は小さくなります(C/I Youden J はテキスト直渡しの 0.380 に対し、ASR 経由で 0.460)。
クラス分布を揃える
音声経路を新しく学習させる場合、学習データ中の比率が小さいクラスは学習されません。相槌が 400件中4件(1%)の構成では、評価時に相槌を一件も予測しませんでした。
4状態をおおむね均等にした 1825件(complete 650 / incomplete 675 / backchannel 250 / wait 250、相槌 13.7%)で学習すると回復します。
| 指標 |
相槌 1% |
相槌 13.7% |
| 相槌 F1 |
0.000 |
0.916 |
| Macro-F1 |
0.282 |
0.552 |
学習の前にクラス分布を数え、単一クラスが 5% を切っていたら止めるようにしています。
実会話データを混ぜる
台本から作った合成音声だけで学習すると、実音声での C/I 判別が伸びません。実会話コーパス(J-CHAT)から 1841件を足し、エンコーダは凍結したまま Adapter と判定側だけを再学習しました。
| 条件 |
追加前 |
追加後 |
差 |
| clean |
+0.140 |
+0.380 |
+0.240 |
| 雑音 snr10 |
−0.080 |
+0.120 |
+0.200 |
| 重なり |
+0.020 |
+0.060 |
+0.040 |
5条件すべてで同じ方向に動きます。実会話の incomplete は、文法的には完結して聞こえるものが多く(語尾に「けどね」が付いているだけで話が続くなど)、台本から作ったデータにはこの形があまり入っていません。
ただし J-CHAT は wait と相槌が乏しいので、混ぜると学習データの構成が偏ります。クラス頻度の逆数で重みを付けると(実測で 0.291 / 0.287 / 1.611 / 1.811)wait の取りこぼしは 0.680 から 0.820 に戻ります。相槌は戻りません。クラス重みは比率の偏りを補正できますが、絶対数の不足(281件)は補正できません。
効かなかった介入
complete の判定は 0.589 で頭打ちになり、ここから先は動きませんでした。原因の切り分けとして、誤りのない書き起こしをそのまま与えた条件を測ってあります。
| 与えたテキスト |
complete F1 |
相槌 F1 |
| ASR の出力 |
0.5895 |
0.727 |
| 正解の書き起こし |
0.5934(p=1.0) |
0.923(p=0.0018) |
complete の頭打ちはテキスト由来ではないので、ASR を差し替えても上がりません。一方で相槌は大きく上がるため、ASR の改善は相槌の判定に効きます。
ほかに試して効果が無かったものです。
- 学習データに無い末尾パターン(倒置・接続助詞終わりの complete)を257件生成して置換 → complete F1 0.589 → 0.422
- 直前の発話を文脈として与える(3手法)→ 改善なし。正解を与えるオラクル条件でも −0.056
- 発話末尾だけをプーリング → −0.007(全体の平均が既に AUC 0.894)
- 重み平均(SWA)→ 標準偏差 0.0555 → 0.0526(F=1.11)で誤差内
評価
評価セットと測り方
比較の下限に VAD-only、参照上限に前回の exp11a を置き、評価セットは exp11a に合わせました。complete / incomplete / 相槌が各50件(exp11a と同一の実 TalkBank 音声、電話品質)、wait は実会話に出てこないので合成で50件。これを clean・雑音2段階・重なり・雑音と重なりの5条件に展開しています。
読み込み時にスキーマ検証を挟み、ラベル分布・正解テキストの切り詰め・音声ファイルの解決・学習データとの重複を機械的に確認します。最後の1つはコミット前のフックにも入れました。GPU 上で書いた変換スクリプトが正解ラベルを一律 complete に固定していたことがあり、人間の注意に頼らない形にしてあります。
遅延は end-to-end で エンコーダ 59.6ms + 判定器 50.6ms = P95 110.2ms。GPU 計測なので前後に torch.cuda.synchronize() が要ります。ASR の最終文字列をチャンク数で等分割して途中経過を作る方式は、時刻がデコードに対応しないため測定になりません(150件中 87件が 0.0、63件が null になります)。
測れる差の大きさ
比べたい差を、この測定系が見分けられるか。学習側の揺れと、評価セット側の分解能の両方を先に測ります。
まず学習の揺れです。構成もデータもハイパーパラメータも変えず、乱数の種だけを変えて12回学習しました。
| 指標 |
12回の平均 |
標準偏差 |
| Macro-F1 |
0.6664 |
0.0510 |
| 相槌 F1 |
0.6461 |
0.1143 |
分散を減らす手として効いたのは学習率スケジュールでした。cosine の減衰でエポックを12に増やすと標準偏差が 0.0555 → 0.0293(−47%、F=3.59、p=0.022)、平均は下がりません。相槌も 0.1675 → 0.0863 と半減します。この条件で Unified と Semantic を12回ずつ比べました。
| 条件 |
差 |
標準誤差 |
z |
| 1回対1回(最初の公表値) |
+0.079 |
— |
— |
| 12回ずつ |
+0.0306 |
0.0218 |
+1.41 |
| 分散を減らしてから12回ずつ |
+0.0190 |
0.0152 |
+1.25 |
判定の境界は z が 2 以上なので、音響融合の優位は未検証としました。符号は2条件とも正で、否定されたわけではありません。相槌の誤り方を見ると Semantic の 50件中30件が incomplete に流れていたのが Unified では10件に減っており、韻律を入れた効果そのものは観測できています。
分散があっても大きい差は検出できます。同じ12回で、末尾パターン生成の失敗(−0.167)は +3.98σ と明確に出ました。1回対1回の比較が意味を持つのは、この規模からです。
次に評価セット側です。200件でどれくらいの差を検出できるのかを、層別ブートストラップ 4000回で測ります。
| 指標 |
実測 |
95% 信頼区間 |
閾値 |
| Macro-F1 |
0.718 |
[0.655, 0.775] |
0.75 |
| C/I Youden J |
0.440 |
[0.260, 0.600] |
0.40 |
| complete F1 |
0.589 |
[0.465, 0.695] |
0.65 |
信頼区間が閾値をまたぐので、通っているように見える項目も決着していません。必要な件数は Macro-F1 で約700件、C/I Youden J で約1800件でした。
評価セット自体の質も6種類で検査しました。判定器の出力を見る前に、単純な特徴量だけで当てられないかを確かめるものです。
- wait とドメインが 100% 交絡。5〜8kHz の帯域だけで分離でき AUC 0.999(合成で足した wait を音質で当てられる)
- 相槌50件は音声長 1.08秒の単一閾値に負ける(判定器 0.742 に対し 0.860)
- 相槌の異なり表層形が7種しかない。86% が「うん」
- 誤切断の対象100件のうち50件は、無音が最大 0.400秒で閾値 0.550秒では発火しない
このうち3点はモデルを改善しても動きません。参照上限の exp11a 自身が complete F1 0.6415 で閾値 0.65 に届いていないことも、ここで分かりました。この検査を先に通すと、モデル側の伸びしろと物差し側の欠陥を切り分けられます。
まとめ
- 論文どおりの系統の序列(VAD-only < Cascaded < Semantic < Unified)と、エンコーダ込み P95 110.2ms は再現できました
- 精度は前世代のモデル(0.810)に 0.718 で届いていません
- 音響融合の優位は、12回ずつ測ると z=+1.25 で検証できていません
参考