JA EN
体系音声系
·無料·14分で読めます

音声認識を1から — 波形からテキストまで

マイクが拾う数字の列がどうやって文字になるのかを前提知識ゼロから。スペクトログラムによる特徴抽出、アラインメント問題を解いたCTC、自己回帰デコーダのencoder-decoder、そしてWhisperまでを歴史の順に追う。

対象textタスクspeech

空気の震えを文字にする

マイクがやっているのは、空気の圧力の上下を一定間隔で測って数字に書き留めることだけです。音声認識では1秒あたり16,000回測るので、3秒しゃべった音声は48,000個の数字が並んだ帯になります。

音声認識は、この48,000個から「こんにちは」という5文字を取り出す仕事です。厄介なことが2つあります。1つ目は時間が伸び縮みすること。同じ言葉でも早口なら1秒、ゆっくりなら3秒で、文字数は同じなのに数字の個数が3倍違う。2つ目は境目がないこと。書き言葉には単語の間に空白がありますが、波形のどこにも線は引かれていません。

波形をそのまま見ない

生の数字をそのままモデルに入れるのは筋が悪い。あなたが「あ」と「い」を聞き分けるとき、耳が使う手がかりは瞬間の気圧ではなく、どの周波数がどれくらい混ざっているかだからです。男性の低い声も女性の高い声も同じ「あ」に聞こえるのは、混ざり方のパターンが似ているためです。

そこで波形を「周波数の混ざり具合の時間変化」に変換します。これがスペクトログラムで、作り方は「短い窓を切って中の周波数成分を測り、窓を少しずらして繰り返す」だけ。短時間フーリエ変換(STFT)と呼びます。

X[t,k]=n=0N1x[n+tH]  w[n]  e2πikn/NX[t, k] = \sum_{n=0}^{N-1} x[n + tH]\; w[n]\; e^{-2\pi i k n / N}

xx が波形、NN が窓の長さ、HH がずらし幅、ww が窓の端を滑らかに落とす重み、tt が何番目の窓か、kk が何番目の周波数かです。要するに tt 番目の短い区間に kk 番目の高さの音がどれくらい入っているか」を並べた表を作っているだけです。周波数への分解が足し算と掛け算だけでできる理由はFFTを1から理解するで扱っています。

定番の設定は、16,000 Hzの音声に対し窓25ミリ秒(400サンプル)・ずらし10ミリ秒(160サンプル)。1秒が100フレームになります

ここに2つ手を加えます。メルフィルタバンクは、周波数の目盛りを人の聞こえ方に合わせて潰します。100 Hzと200 Hzは別物に聞こえるのに7,000 Hzと7,100 Hzはほぼ同じに聞こえる、あの偏りです。

m=2595log10 ⁣(1+f700)m = 2595 \log_{10}\!\left(1 + \frac{f}{700}\right)

ff がふつうの周波数(Hz)、mm が潰したあとの目盛り。低い周波数ほど細かく、高い周波数ほど粗くなる変換で、これで200本ほどの周波数を80本にまとめます。つまりこの式は、Hzという物理の目盛りを「人が違いとして感じる度合い」の目盛りに引き直す換算表です。低い側では ff が少し動くだけで mm が大きく動き、高い側では ff が大きく動いても mm はほとんど動きません。もう1つが対数。ささやき声と怒鳴り声は何千倍も違うので桁を圧縮します。人の音量の感じ方自体が対数的です。

こうしてできた「時間 × 80チャネルの対数メルスペクトログラム」が、現代の音声認識モデルが最初に見るものです。波形はもう出てきません。

歴史のメモ: 2010年代前半までの主役はMFCCで、上の対数メルに離散コサイン変換をかけて13次元程度に絞ったものでした。当時の音響モデルが「特徴量どうしに相関がない」と仮定していたからです。ニューラルネットは相関を平気で扱うのでこの工程は消えました。前処理の常識は後段のモデルの都合で変わるという例です。

昔のやり方: 3つの部品を組む

深層学習以前の出発点はベイズの定理でした。

W^=argmaxW  P(XW)P(W)\hat{W} = \arg\max_{W} \; P(X \mid W)\, P(W)

XX が音声の特徴、WW が単語列。「その単語列ならこの音になりそうか」と「そもそもその単語列を言いそうか」の掛け算が最大の候補を選ぶ、という意味です。つまり、音から単語を直接当てにいくのではなく、単語列の候補を立てて「そう言ったらこの音になるか」を逆向きに採点し、そんな言い方はまずしないという文には P(W)P(W) で罰を与えている、ということです。

実装には3部品が要りました。フレームがどの音素らしいかを出す音響モデル(長らくGMM-HMM)、単語を音素列に対応づける発音辞書、単語の並びの自然さを見る言語モデル。デコーダはこれらを巨大な探索グラフに合成して最良経路を探します。別々に学習するので全体最適にならず、発音辞書は人手なので新しい言語や固有名詞のたびに書き足す必要がある。2010年代に音響モデルがニューラルネットに変わっても、3部品構成そのものは残りました。

アラインメント問題

「音声を入れたら文字が出る」1つのネットワークにしたい。ここで時間の伸び縮みが壁になります。300フレームの音声に対し、正解は hello の5文字。学習には「1〜60フレーム目が h、61〜95フレーム目が e……」という対応(アラインメント)が要りますが、そんな注釈は誰も付けてくれません。データにあるのは書き起こしのテキストだけです。

CTC: 「どこで」を答えずに済ませる

2006年、Gravesらの CTC(Connectionist Temporal Classification) はこれを素直な発想で回避しました。アラインメントを1つに決めず、ありうる全部を足すのです。

仕掛けは3つ。①文字の語彙に「何も出力しない」を意味する空白記号 ε\varepsilon を足す。②モデルはフレームごとに1記号を出す(長さ300のパスになる)。③縮約規則でパスを文字列に潰す —— 連続する同じ記号を1つにまとめ、ε\varepsilon を消す。

例えば h h ε e e l l ε l o o は、まとめて h ε e l ε l oε\varepsilon を消して hello です。ll の間の ε\varepsilon が効いています。挟まないと1つの l に潰れるので、同じ文字を続けたいときは間に空白を置くという約束です。

hello に潰れるパスは無数にあります。CTCはその全部の確率を足したものを正解の確率と定義します。

P(yx)=πB1(y)  t=1Tpt(πtx)P(y \mid x) = \sum_{\pi \in B^{-1}(y)} \; \prod_{t=1}^{T} p_t(\pi_t \mid x)
(1)

yy が正解の文字列、xx が入力音声、π\pi が1本のパス、BB が縮約規則、B1(y)B^{-1}(y) が「潰すと yy になるパス全体」、TT がフレーム数、ptp_ttt 番目のフレームの確率です。正解に潰れるパスを全部持ってきて、各パスの確率(各フレームの確率の掛け算)を求め、全部足すと言っています。言い換えると、式(1)は「どのフレームでどの文字を出したか」に一切答えないまま、「結果として yy になる確率」だけを取り出す装置です。区切り方の違いは足し算の中に溶けて消えます。

式(1)には落とし穴があります。パスの数は長さに対して指数的に増えるので素直には数えられない。ここで動的計画法が効きます。途中までの合計を表に記録しながら1マスずつ埋めれば、指数個の経路の合計が O(T×U)O(T \times U) で求まる。この前向きアルゴリズムがCTC実装の中心です。

FIG 1表を1マスずつ埋めて指数個の経路をまとめて片づける — CTCの前向きアルゴリズムはこれと同じ骨格(合計を取るか最小を取るかが違うだけ)。しかもこの編集距離の表そのものが、後半のWERの計算に使われます

CTCには設計から必然的に出る弱点があります。式(1)の右辺は各フレームの確率の掛け算、つまりモデルは各フレームの出力を他フレームと無関係に決めています(条件付き独立)。綴りや文法という出力どうしの依存を内部に持てないわけです。実際のCTCが外部の言語モデルとビームサーチとセットで使われるのは、この穴を埋めるため。代わりにパスは時間を戻らないので単調性が保証され、フレームが来た端から処理できます。低遅延のストリーミングに向くのはこの性質のおかげです。

エンコーダ・デコーダ: 音声を「読み上げる」

2016年ごろ、Listen, Attend and Spell (LAS) に代表される別の切り口が出ます。発想は機械翻訳と同じで、エンコーダが音声を要約し、デコーダが1文字ずつ生成する。生成のたびに音声のどこを見るかをAttentionで決めます。

これでCTCの穴が塞がります。デコーダは自分が直前に出した文字を受け取るので出力どうしの依存を扱える。言語モデルが外付けでなくモデル内部に入りました。発音辞書も音素も要りません。

代償もはっきりしています。Attentionは音声のどこを見てもいいので単調性の保証がなく、同じ語句を繰り返したり、飛ばして脱落させたり、ひどいときは音声にない語を出します(幻覚)。原則として全部聞いてからでないと話し始められないので、ストリーミングにも工夫が要ります。

中間案が RNN-Transducer(RNN-T) です。CTCの単調性を保ったまま直前の出力を見る予測ネットワークを足した構成で、スマートフォンの音声入力のようなその場で文字が出る製品で広く使われています。

Transformerと大規模化、そしてWhisper

その後はRNNがTransformerに置き換わり、データが桁で増えます。Whisper(2022年)の構造は驚くほど素朴で、80チャネルの対数メル → Transformerエンコーダ → 自己回帰デコーダ。上で説明した encoder-decoder そのものです。目新しさはアーキテクチャではなく、68万時間の弱教師ありデータと、タスク(言語・転写か翻訳か・タイムスタンプの有無)をデコーダの入力トークンで指定するマルチタスク形式にあります。中身は論文解説: Whisperはなぜ頑健なのかで読んでいます。

振り返ると部品の消え方が見えます。発音辞書が消え、音素が消え、外付けの言語モデルも多くの用途で消えた。残ったのは特徴抽出と1つのネットワークだけです。

デコード: 一番ありそうな記号を選ぶだけでは足りない

CTCの一番単純な取り出し方は貪欲デコード(各フレームで最尤の記号を選んで潰す)ですが、式(1)の意味では最良ではありません。「各フレームの勝者を並べたパス」と「潰した結果の確率が最大の文字列」は別物だからです。実用ではビームサーチで複数候補を並行に保ちます。

自己回帰デコーダには別の問題、繰り返しループがあります。同じ語を2回出すと「直前の出力」がその語なので、また出したくなる。対策の1つが温度で、softmaxを exp(zi/T)\exp(z_i / T) とすると TT が小さいほど分布は上位1つに尖り、上げるほど平らになって別の候補が選ばれる余地が生まれます。

FIG 2温度を1.0から下げると分布が1本に潰れ、上げると平らになる。繰り返しループから抜けるために温度を上げるのは、この「平らさ」で別の候補が選ばれる余地を作る操作です

コードで書く

前処理はnumpyだけで骨格が書けます。

import numpy as np

N, H = 400, 160                                             # 25ms窓 / 10msシフト(16kHz)
frames = np.stack([x[i:i+N] * np.hanning(N)
                   for i in range(0, len(x) - N, H)])       # (T, 400)
power  = np.abs(np.fft.rfft(frames, n=N))**2                # (T, 201) 各窓の周波数成分
logmel = np.log(power @ mel_fb.T + 1e-10)                   # mel_fb: (80, 201) → (T, 80)

+ 1e-10 を忘れると無音区間で log(0)-inf になり、以降の計算が全部壊れます。数式に現れないのに実装には必ず要る一行です。CTCの貪欲デコードのほうは、縮約規則をそのままコードにしただけ。

def ctc_greedy(logits, blank=0):
    ids, out, prev = logits.argmax(axis=-1), [], -1
    for k in ids:
        if k != prev and k != blank:    # ①連続重複をまとめ ②空白を捨てる
            out.append(k)
        prev = k
    return out

k != prevblank の判定より先に書くのが要点です。h ε hhh に、h hh になる —— あの約束がこの2行に入っています。

精度をどう測るか

標準指標は WER(単語誤り率) です。

WER=S+D+IN\mathrm{WER} = \frac{S + D + I}{N}

SS が置換、DD が脱落、II が挿入、NN が正解側の単語数。正解の文に直すのに必要な最小の修正回数を、正解の単語数で割った値です。この最小回数が編集距離で、計算にはさきほどの動的計画法の表をそのまま使います。つまり、認識結果を赤ペンで直したときの訂正箇所を数え、原稿の単語数で割っただけの値です。0%なら一字一句そのまま、値が小さいほど良い。挿入が多いと分子が NN を超えるので、WERは100%を超えることがあります

注意点は、WERが表記の違いも誤りとして数えること。「2026年」と「二〇二六年」、句読点の有無 —— 揃える正規化を通すかで数字が変わります。

現場ではこう使う

音声認識を組み込むのは、議事録・字幕・コールセンターの通話解析・音声アシスタント・動画検索を作る人たちです。

入口はサンプリングレート。 標準は16 kHzで、理論上は8 kHzまでの音しか記録できません(標本化定理)。電話回線の8 kHz音声を16 kHzにアップサンプルしても失われた高域は戻りません。摩擦音(sshf)は高い周波数に情報が集中するので、電話音声で s 絡みの誤りが増えるのは仕組み上の必然です。逆に44.1 kHzを16 kHzモノラルに落とすのは正しい前処理で、ffmpeg -i in.wav -ar 16000 -ac 1 out.wav が定番。

ストリーミングかバッチかで選ぶ系統が変わる。 ライブ字幕のように話しながら出す要件なら単調性のあるCTC系・RNN-T系、会議後のバッチ処理なら精度の出やすい encoder-decoder 系。後から変えると設計ごと変わる分岐です。

長い音声はチャンクに切られる。 Whisper系は30秒窓で学習されているので長時間音声は必ず分割され、継ぎ目が語の途中に来るとその語が両側で欠けます。先にVAD(音声区間検出)で無音を見つけ、無音を境に切ると事故が減ります。

固有名詞は学習データにない。 社名・製品名・社内用語はモデルが見たことのない語です。CTC系なら外部言語モデルやホットワードでスコアを持ち上げ、encoder-decoder系なら文脈プロンプト(openai-whisper なら initial_prompt)に用語を並べる。どちらも再学習せずに語彙を寄せる入口です。

落とし穴。 ①無音や音楽の区間で幻覚が出る(VADで先に落とすのが確実)。②正規化を揃えずに2モデルを比較する(数字が動いた原因がモデルか正規化か分からなくなる)。③何度もリサンプル・再エンコードする(劣化は累積するので原音を必ず保存)。

設計レビューで問われるのは「CTCとencoder-decoderの違いは何か」です。CTCは出力どうしが条件付き独立で単調なのでストリーミングに向く代わり、言語的依存を外部の言語モデルで補う必要がある。encoder-decoderは自己回帰で言語モデルを内包でき精度が出やすいが、単調性がないため繰り返し・脱落・幻覚が出る。RNN-Tはその折衷案です。どれが優れているかではなく、遅延・精度・失敗モードのどれを取るかの選択になります。

まとめ

コメント

コメントにはログインが必要です