会議の録音を自動で文字起こししたい。YouTubeの外国語動画に日本語字幕を付けたい。騒がしいカフェで録った音声メモをテキストに変換したい — こうした音声認識(Speech Recognition)の需要は日常のあらゆる場面に存在します。
しかし、従来の音声認識システムには厄介な壁がありました。ノイズに弱い、アクセントや方言に対応できない、言語ごとに別々のモデルを用意しなければならない、といった問題です。英語で高精度なモデルが日本語ではまったく使い物にならない、という状況は珍しくありませんでした。
2022年、OpenAIが発表したWhisperは、これらの問題を力技で解決しました。インターネットから収集した680,000時間もの音声データ(約77年分に相当)で学習し、多言語対応・翻訳・タイムスタンプ付き書き起こしを1つのモデルでこなします。アーキテクチャ自体は標準的なTransformer Encoder-Decoderであり、特別なトリックではなく大規模データと適切なタスク設計で性能を引き出した点が特徴的です。
Whisperを理解すると、以下の分野に応用が広がります。
- リアルタイム字幕生成: 講演やオンライン会議の自動字幕
- 議事録の自動化: 会議音声からテキストを自動抽出
- マルチモーダルAI: 音声+テキストを統合する基盤技術の理解
- 音声翻訳: 英語の音声を直接日本語テキストに変換する仕組み
本記事の内容
- 音声信号からメルスペクトログラムへの変換(STFT、メルスケール)の数学
- WhisperのEncoder-Decoderアーキテクチャの全体像
- Encoderの構造(CNN前処理 + Transformer Encoder)
- Decoderの構造(学習済み位置埋め込み + 自己回帰生成)
- マルチタスク設計と特殊トークン
- PyTorchでの簡易実装
- 公式ライブラリ openai-whisper の使い方
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
Whisperの全体像 — 音声をテキストに変換するTransformer
Whisperの動作を一言でまとめると、「音声の周波数画像をEncoderで読み取り、Decoderがテキストを1文字ずつ書き出す」 というものです。
日常的なアナロジーで考えてみましょう。人間が外国語のスピーチを翻訳するとき、まず音を聴いて頭の中で内容を理解し(Encoder)、次にその理解をもとに母語のテキストを書き出します(Decoder)。Whisperもまったく同じ流れを踏んでいます。ただし、人間が「耳」で音を捉えるところを、Whisperはメルスペクトログラムという音声の周波数表現で受け取ります。
全体のデータフローを図式化すると、次のようになります。
[音声波形(30秒のチャンク)]
↓ 前処理
[メルスペクトログラム (80 × 3000)]
↓ CNN(2層のConv1d)
[特徴マップ (d_model × 1500)]
↓ + 正弦波位置エンコーディング
↓
┌───────────────────────────────┐
│ Transformer Encoder × N │
│ ├ Multi-Head Self-Attention │
│ ├ Add & LayerNorm │
│ ├ Feed-Forward Network │
│ └ Add & LayerNorm │
└───────────────────────────────┘
↓ Encoderの出力
↓(Cross-Attentionで参照)
┌───────────────────────────────┐
│ Transformer Decoder × N │
│ ├ Masked Multi-Head Self-Attn│
│ ├ Add & LayerNorm │
│ ├ Multi-Head Cross-Attention │
│ ├ Add & LayerNorm │
│ ├ Feed-Forward Network │
│ └ Add & LayerNorm │
└───────────────────────────────┘
↓
[テキストトークン(自己回帰生成)]
ここで押さえておきたいポイントが3つあります。
- 入力はメルスペクトログラム: 生の波形ではなく、時間-周波数表現に変換してからEncoderに渡します。これにより、音声の特徴(音高、音色、リズム)を2次元の「画像」として効率的に表現できます
- Encoderの先頭にCNNがある: Transformerに入力する前に、2層の畳み込み(Conv1d)で局所的な音響パターンを抽出し、同時に時間方向のダウンサンプリング(3000フレーム → 1500フレーム)を行います
- Decoderがテキストを自己回帰生成: Decoderは通常のTransformer Decoderと同じく、Cross-AttentionでEncoder出力を参照しながら1トークンずつテキストを生成します
この全体像を頭に入れた上で、まずは入力となるメルスペクトログラムがどのように作られるかを見ていきましょう。
音声の前処理 — 波形からメルスペクトログラムへ
音声認識モデルに波形データ(振幅の時系列)をそのまま入力することも原理的には可能ですが、効率が非常に悪くなります。16kHzでサンプリングされた30秒の音声は480,000サンプルにもなり、Transformerで直接扱うには長すぎます。
人間の聴覚は、音を「どの周波数がどの時刻に強いか」という時間-周波数表現で処理しています。メルスペクトログラムは、この人間の聴覚特性を模倣した音声の表現方法です。料理に例えると、生の波形が「材料をそのまま並べた状態」だとすれば、メルスペクトログラムは「材料を種類・大きさごとに整理した状態」に相当します。
メルスペクトログラムの計算は、以下の3ステップで行います。
- 短時間フーリエ変換(STFT): 波形を短い窓で切り出し、各窓のフーリエ変換を計算
- パワースペクトルの計算: フーリエ変換の絶対値の2乗をとる
- メルフィルタバンクの適用と対数変換: 人間の聴覚特性に基づいた周波数軸に変換し、対数をとる
この3つのステップそれぞれには明確な数学的定義があります。次のセクションで順を追って導出していきましょう。
メルスペクトログラムの数学
短時間フーリエ変換(STFT)
まず、時間領域の音声信号 $x[n]$ から時間-周波数表現を得るために、短時間フーリエ変換(Short-Time Fourier Transform, STFT) を適用します。
通常のフーリエ変換は信号全体を一度に処理するため、「どの周波数が含まれるか」はわかりますが、「いつその周波数が鳴っていたか」がわかりません。STFTは、信号を短い窓関数で切り出してからフーリエ変換することで、時間と周波数の両方の情報を同時に得ます。
音声信号 $x[n]$ に対し、窓関数 $w[n]$(通常はハニング窓)を用いたSTFTは次のように定義されます。
$$ X[m, k] = \sum_{n=0}^{N_{\mathrm{FFT}}-1} x[n + m \cdot H] \, w[n] \, e^{-j \frac{2\pi k n}{N_{\mathrm{FFT}}}} $$
ここで各パラメータの意味は以下のとおりです。
- $m$: フレームのインデックス(時間方向の位置)
- $k$: 周波数ビンのインデックス($k = 0, 1, \dots, N_{\mathrm{FFT}}/2$)
- $N_{\mathrm{FFT}}$: FFTの窓幅(Whisperでは400サンプル = 25ms)
- $H$: ホップ長(Whisperでは160サンプル = 10ms)
- $w[n]$: 窓関数(ハニング窓)
窓関数は信号の切り出し時に端で不連続が発生するのを防ぐ役割を持ちます。ハニング窓は次のように定義されます。
$$ w[n] = 0.5 \left(1 – \cos\left(\frac{2\pi n}{N_{\mathrm{FFT}} – 1}\right)\right) $$
この窓関数は、窓の中央で値が最大(1.0)となり、両端で0に滑らかに減衰します。これにより、フーリエ変換時のスペクトル漏れ(spectral leakage)を抑えることができます。
STFTの結果 $X[m, k]$ は複素数であり、ここからパワースペクトルを計算します。
$$ P[m, k] = |X[m, k]|^2 $$
Whisperの設定では、サンプリングレート $f_s = 16{,}000\,\mathrm{Hz}$、$N_{\mathrm{FFT}} = 400$、$H = 160$ です。30秒の音声(480,000サンプル)に対してSTFTを適用すると、時間方向は $480{,}000 / 160 = 3{,}000$ フレーム、周波数方向は $400/2 + 1 = 201$ ビンとなり、パワースペクトル $P$ は $(3000, 201)$ の行列になります。
メルスケール変換
人間の聴覚は、低い周波数の違いには敏感ですが、高い周波数の違いには鈍感です。たとえば、100Hzと200Hzの違いは明確に聞き分けられますが、8000Hzと8100Hzの違いはほとんど区別できません。この非線形な知覚特性を反映するのがメルスケール(mel scale) です。
周波数 $f$(Hz)からメルスケール $m$ への変換は、次の式で定義されます。
$$ m = 2595 \log_{10}\left(1 + \frac{f}{700}\right) $$
この式の形を観察してみましょう。低周波数($f \ll 700$)では $f/700$ が小さいため $\log_{10}(1 + f/700) \approx f/(700 \ln 10)$ と近似でき、メルスケールはほぼ線形に振る舞います。一方、高周波数($f \gg 700$)では $\log_{10}$ の効果で圧縮され、メルスケールは対数的に増加します。
逆変換は以下のとおりです。
$$ f = 700 \left(10^{m/2595} – 1\right) $$
メルフィルタバンク
メルスケール上で等間隔に配置された三角フィルタの集合をメルフィルタバンクと呼びます。Whisperでは $M = 80$ 本のフィルタを使用します。
メルフィルタバンクの構築手順は次のとおりです。
- 周波数の下限 $f_{\min}$ と上限 $f_{\max}$ をメルスケールに変換する
- メルスケール上で $M + 2$ 個の等間隔な点を配置する($M$ 個のフィルタの中心 + 両端)
- これらの点を周波数に逆変換し、三角フィルタの中心周波数 $c_0, c_1, \dots, c_{M+1}$ を得る
- 各フィルタ $H_i[k]$($i = 1, \dots, M$)を三角形状に定義する
$i$ 番目のフィルタは、周波数ビン $k$ に対して次のように定義されます。
$$ H_i[k] = \begin{cases} 0 & (f[k] < c_{i-1}) \\[4pt] \dfrac{f[k] - c_{i-1}}{c_i - c_{i-1}} & (c_{i-1} \leq f[k] < c_i) \\[4pt] \dfrac{c_{i+1} - f[k]}{c_{i+1} - c_i} & (c_i \leq f[k] < c_{i+1}) \\[4pt] 0 & (f[k] \geq c_{i+1}) \end{cases} $$
この三角フィルタは、低周波数域では狭い帯域(細かい周波数分解能)を、高周波数域では広い帯域(粗い周波数分解能)を持ちます。これは「低音の違いは敏感に、高音の違いは大雑把に」という人間の聴覚特性を忠実に反映しています。
対数メルスペクトログラム
パワースペクトルにメルフィルタバンクを適用し、対数をとることで対数メルスペクトログラムを得ます。
$$ S_{\mathrm{mel}}[m, i] = \log\left(\sum_{k=0}^{N_{\mathrm{FFT}}/2} H_i[k] \cdot P[m, k] + \epsilon\right) $$
ここで $\epsilon$ はゼロ除算を防ぐための微小値(例: $10^{-10}$)です。対数変換には2つの重要な意味があります。
- ダイナミックレンジの圧縮: 音声のパワーは数桁にわたって変化するため、対数をとることで値の範囲を扱いやすくする
- 人間の聴覚との一致: 人間は音の強さを対数的に知覚する(ウェーバー・フェヒナーの法則)
Whisperでは最終的な対数メルスペクトログラムの形状は $(80, 3000)$ となります。80がメルフィルタの数(周波数方向)、3000が時間フレーム数です。この2次元の行列を、あたかも「1チャンネルの画像」のようにEncoderに入力します。
ここまでで入力の前処理が完成しました。次に、この $(80, 3000)$ の行列をEncoderがどのように処理するかを見ていきましょう。
Encoderの構造 — 音響特徴量を文脈表現に変換する
Whisper Encoderの役割は、メルスペクトログラムから音響特徴量を抽出し、文脈を反映した高次の表現に変換することです。言い換えると、Encoderは「音声の周波数パターン」を「意味のある情報の列」に変換するステージです。
Encoderは大きく2つの部分から構成されます。
- CNN前処理: 2層のConv1dでメルスペクトログラムから局所的なパターンを抽出し、時間方向を半分に圧縮する
- Transformer Encoderブロック: Self-Attentionで音声フレーム間の長距離依存関係を捉える
CNN前処理 — 2層のConv1dによる特徴抽出
Whisper EncoderのユニークなポイントはTransformerの前に2層の1次元畳み込み(Conv1d) を配置していることです。
なぜ畳み込みが必要なのでしょうか? メルスペクトログラムの隣接するフレームは非常に似た特徴を持っています。音声では音素(phoneme)が数十ミリ秒にわたって持続するため、10ms間隔で切り出したフレーム同士は強い相関を持ちます。畳み込みはこの局所的な時間パターン(音素の始まり、母音のフォルマント変化など)を効率的に捉えることができます。
具体的な構造は以下のとおりです。
第1層 Conv1d: – 入力チャンネル: 80(メルフィルタの数) – 出力チャンネル: $d_{\mathrm{model}}$(例: base モデルでは 512) – カーネルサイズ: 3 – パディング: 1(入力と出力の時間長を保持) – 活性化関数: GELU
第2層 Conv1d: – 入力チャンネル: $d_{\mathrm{model}}$ – 出力チャンネル: $d_{\mathrm{model}}$ – カーネルサイズ: 3 – ストライド: 2(時間方向を半分にダウンサンプリング) – パディング: 1 – 活性化関数: GELU
第1層で80次元のメルスペクトログラムを $d_{\mathrm{model}}$ 次元に射影し、第2層でストライド2のダウンサンプリングを行います。これにより、入力の $(80, 3000)$ が $(d_{\mathrm{model}}, 1500)$ に変換されます。時間方向が3000から1500に半減することで、後続のTransformerの計算量がおおよそ $1/4$(Self-Attentionは系列長の2乗に比例)になります。
数式で表すと、入力メルスペクトログラム $\bm{X}_{\mathrm{mel}} \in \mathbb{R}^{80 \times T}$ に対して次の変換が適用されます。
$$ \bm{Z}_1 = \mathrm{GELU}\bigl(\mathrm{Conv1d}_1(\bm{X}_{\mathrm{mel}})\bigr) \in \mathbb{R}^{d_{\mathrm{model}} \times T} $$
$$ \bm{Z}_2 = \mathrm{GELU}\bigl(\mathrm{Conv1d}_2(\bm{Z}_1)\bigr) \in \mathbb{R}^{d_{\mathrm{model}} \times T/2} $$
ここで GELU(Gaussian Error Linear Unit)は次のように定義される活性化関数です。
$$ \mathrm{GELU}(x) = x \cdot \Phi(x) = x \cdot \frac{1}{2}\left[1 + \mathrm{erf}\left(\frac{x}{\sqrt{2}}\right)\right] $$
ReLUが $x < 0$ の部分をハードに0にするのに対し、GELUは確率的にゲーティングする滑らかな関数であり、Transformerモデルで広く使われています。
正弦波位置エンコーディング
CNN前処理の出力 $\bm{Z}_2$ を転置して $(T/2, d_{\mathrm{model}})$ の形状にした後、正弦波位置エンコーディング(Sinusoidal Positional Encoding) を加算します。
Transformer Encoderは入力の順序に関する情報を持たないため、位置情報を明示的に注入する必要があります。Whisperでは、原論文 “Attention Is All You Need” と同じ正弦波位置エンコーディングをEncoder側に採用しています。
位置 $\mathrm{pos}$ と次元 $i$ に対する位置エンコーディングは次のとおりです。
$$ \mathrm{PE}(\mathrm{pos}, 2i) = \sin\left(\frac{\mathrm{pos}}{10000^{2i/d_{\mathrm{model}}}}\right) $$
$$ \mathrm{PE}(\mathrm{pos}, 2i+1) = \cos\left(\frac{\mathrm{pos}}{10000^{2i/d_{\mathrm{model}}}}\right) $$
偶数次元にはsin、奇数次元にはcosを使い、次元が深くなるほど周期が長くなります。低い次元は短い周期で細かい位置の違いを表現し、高い次元は長い周期で大まかな位置関係を捉えます。これにより、Transformerは各フレームが音声のどの時刻に対応するかを認識できます。
位置エンコーディングを加算した結果が、Transformer Encoderブロックへの入力となります。
$$ \bm{H}_0 = \bm{Z}_2^{\top} + \mathrm{PE} \in \mathbb{R}^{(T/2) \times d_{\mathrm{model}}} $$
Transformer Encoderブロック
位置エンコーディングが加算された特徴量 $\bm{H}_0$ は、$N$ 層のTransformer Encoderブロックに通されます。各ブロックの構成は標準的なTransformer Encoderと同じです。
$$ \bm{H}’_l = \mathrm{LayerNorm}\bigl(\bm{H}_{l-1} + \mathrm{MultiHeadSelfAttn}(\bm{H}_{l-1})\bigr) $$
$$ \bm{H}_l = \mathrm{LayerNorm}\bigl(\bm{H}’_l + \mathrm{FFN}(\bm{H}’_l)\bigr) $$
ここで $l = 1, 2, \dots, N$ はブロックのインデックスです。Multi-Head Self-Attentionにより、あるフレームの表現を生成する際に音声全体の文脈(他のすべてのフレーム)を参照できます。これは音声認識において非常に重要です。たとえば、同じ音素 /b/ であっても、後に続く母音(「ば」「び」「ぶ」)によってスペクトルの形が変わるため、前後の文脈を考慮して初めて正しい認識が可能になります。
Feed-Forward Network(FFN)は標準的な2層のMLPです。
$$ \mathrm{FFN}(\bm{x}) = \mathrm{GELU}(\bm{x}\bm{W}_1 + \bm{b}_1)\bm{W}_2 + \bm{b}_2 $$
中間層の次元は $4 \times d_{\mathrm{model}}$ です。FFNは各フレームの表現を独立に非線形変換する役割を持ち、Self-Attentionが捉えた文脈情報をさらに豊かな表現に加工します。
最終的にEncoder全体の出力は $\bm{H}_N \in \mathbb{R}^{1500 \times d_{\mathrm{model}}}$ となり、これがDecoderのCross-Attentionに渡されます。
Encoderが音声を「理解」した結果を手に入れました。次は、この理解をもとにテキストを生成するDecoderの仕組みを見ていきます。
Decoderの構造 — テキストの自己回帰生成
Whisper Decoderの役割は、Encoderが出力した音声表現を参照しながら、テキストトークンを1つずつ生成することです。機械翻訳のDecoderと同じ原理で、過去に生成したトークンを手がかりに次のトークンを予測します。
トークン埋め込みと学習済み位置エンコーディング
Decoderへの入力は、これまでに生成されたトークン列 $y_1, y_2, \dots, y_{t-1}$ です。各トークンはまず埋め込みテーブル $\bm{E}_{\mathrm{tok}} \in \mathbb{R}^{V \times d_{\mathrm{model}}}$($V$ は語彙サイズ)を通じてベクトルに変換されます。
Whisper Decoderの特徴的な設計として、Encoder側の正弦波位置エンコーディングとは異なり、Decoder側では学習済み位置エンコーディング(Learned Positional Encoding) を使用しています。
$$
\bm{D}_0 = \bm{E}_{\mathrm{tok}}[\bm{y}_{ ここで $\bm{E}_{\mathrm{pos}} \in \mathbb{R}^{T_{\max} \times d_{\mathrm{model}}}$ は学習パラメータとして訓練データから最適化される位置埋め込みです。$T_{\max} = 448$ はDecoderの最大系列長です。 なぜEncoderとDecoderで異なる位置エンコーディングを使うのでしょうか? Encoder側の音声フレームは固定の物理的な時間間隔(10ms × 2 = 20ms)に対応しており、正弦波の規則的なパターンが適しています。一方、Decoder側のテキストトークンは可変長であり、言語構造に応じた位置の重要性が異なります。学習可能なパラメータとすることで、モデルがデータから最適な位置表現を獲得できます。 Decoderの各ブロックは、3つのサブレイヤから構成されます。 1. Masked Multi-Head Self-Attention $$
\bm{D}’_l = \mathrm{LayerNorm}\bigl(\bm{D}_{l-1} + \mathrm{MaskedMultiHeadSelfAttn}(\bm{D}_{l-1})\bigr)
$$ Masked Self-Attentionでは、位置 $t$ のトークンは位置 $1, 2, \dots, t$ のトークンのみを参照でき、位置 $t+1$ 以降はマスクされます。これは自己回帰生成において「未来のカンニング」を防ぐための仕組みです。具体的には、Attentionスコア行列の上三角部分に $-\infty$ を設定します。 $$
\mathrm{Attention}(\bm{Q}, \bm{K}, \bm{V}) = \mathrm{softmax}\left(\frac{\bm{Q}\bm{K}^{\top}}{\sqrt{d_k}} + \bm{M}\right)\bm{V}
$$ マスク行列 $\bm{M}$ は次のように定義されます。 $$
M_{ij} = \begin{cases} 0 & (i \geq j) \\ -\infty & (i < j) \end{cases}
$$ $-\infty$ の箇所はsoftmaxを通すと0になるため、未来のトークンへのAttention重みが消えます。 2. Multi-Head Cross-Attention $$
\bm{D}”_l = \mathrm{LayerNorm}\bigl(\bm{D}’_l + \mathrm{MultiHeadCrossAttn}(\bm{D}’_l, \bm{H}_N)\bigr)
$$ Cross-Attentionでは、QueryはDecoderの中間表現 $\bm{D}’_l$ から計算し、Key・ValueはEncoderの出力 $\bm{H}_N$ から計算します。これにより、Decoderは「次にどのテキストを出力すべきか」を判断する際に、音声全体の情報を参照できます。 $$
\bm{Q} = \bm{D}’_l \bm{W}_Q, \quad \bm{K} = \bm{H}_N \bm{W}_K, \quad \bm{V} = \bm{H}_N \bm{W}_V
$$ このCross-Attentionこそが、音声(Encoder)とテキスト(Decoder)の2つのモダリティを橋渡しする核心部分です。 3. Feed-Forward Network $$
\bm{D}_l = \mathrm{LayerNorm}\bigl(\bm{D}”_l + \mathrm{FFN}(\bm{D}”_l)\bigr)
$$ FFNの構造はEncoderと同一です。 最終Decoderブロックの出力 $\bm{D}_N \in \mathbb{R}^{t \times d_{\mathrm{model}}}$ に対して、トークン埋め込み行列の転置を重み共有(weight tying)として使い、語彙上の確率分布を生成します。 $$
P(y_t | y_{ この重み共有により、入力埋め込みと出力射影が同じ行列を使うため、パラメータ数を削減できます。 テキスト生成の仕組みがわかったところで、Whisperがどのようにして1つのモデルで複数のタスク(書き起こし、翻訳、言語検出)を同時にこなすのかを見ていきましょう。 Whisperの最もエレガントな設計の一つが、特殊トークン(special token)によるマルチタスク制御です。 従来の音声認識では、英語の書き起こし、日本語の書き起こし、英語→日本語の翻訳はそれぞれ別々のモデルで行っていました。Whisperはこれをテキストのプレフィックス(先頭に付加するトークン列)で制御します。Decoderが生成を開始する前に、「どの言語の音声か」「書き起こしか翻訳か」「タイムスタンプが必要か」を特殊トークンで指定するのです。 これは、レストランで注文する際に「日本語メニューで」「テイクアウトで」と条件を伝えるのに似ています。料理人(モデル)は同じキッチン(パラメータ)を使いながら、注文(トークン)に応じて出力を変えます。 Decoderに入力されるトークン列は、以下のフォーマットに従います。 主要な特殊トークンは次のとおりです。 特殊トークンの組み合わせにより、以下のようなタスクを1つのモデルで実行できます。 日本語音声の書き起こし: 日本語音声の英語翻訳: タイムスタンプ付き書き起こし: さらに、言語検出(Language Identification)もこの枠組みの中で実現されます。Decoderに $$
\hat{l} = \arg\max_{l \in \mathcal{L}} P(\texttt{<|l|>} \mid \texttt{<|startoftranscript|>}, \bm{H}_N)
$$ ここで $\mathcal{L}$ は99の言語トークンの集合です。 この設計の美しさは、アーキテクチャに一切の変更を加えずに、トークンの意味だけでタスクを切り替えられる点にあります。モデルのパラメータは完全に共有されており、特殊トークンが「どのタスクを実行するか」の制御信号として機能します。 マルチタスク設計の鍵がトークン設計にあることがわかりました。次に、このモデルをどのようなデータでどのように学習するのかを見ていきましょう。 Whisperの学習パラダイムは弱教師あり学習(weakly supervised learning) です。これは完全な教師あり学習(人手でラベル付けされたデータのみ使用)とも自己教師あり学習(ラベルなしデータから事前学習)とも異なるアプローチです。 具体的には、インターネット上から収集した680,000時間(約77年分)の音声データとその対応テキストを学習データとして使用します。このテキストは必ずしも正確な転写ではなく、自動生成された字幕や、ユーザーが付けたキャプションなど、品質にばらつきがあります。そのため「弱」教師あり学習と呼ばれます。 このスケールを実感するために数字を整理しましょう。 音声認識の従来のアプローチには大きく2つの潮流がありました。 教師あり学習: 人手で正確に転写されたデータを使って学習する方法です。高品質ですが、大量のデータを集めるコストが非常に高く、典型的な学習データは数千〜数万時間程度でした。 自己教師あり学習: wav2vec 2.0やHuBERTのように、ラベルなしの大量の音声データから表現学習を行い、少量のラベル付きデータでファインチューニングする方法です。ラベルなしデータは安価に大量収集できますが、最終的にはファインチューニング用のラベル付きデータが必要であり、ドメインやタスクへの汎化に課題がありました。 Whisperは第3の道として、品質は完全ではないが量が桁違いに多いデータ(インターネットの字幕データ)をそのまま使います。ノイズの多いラベルであっても、680,000時間という圧倒的なデータ量がノイズを平均化し、結果的に高い汎化性能を実現します。 大規模なインターネットデータをそのまま使うと品質問題が生じるため、Whisperでは以下の前処理を行っています。 学習の目的関数は標準的な次トークン予測のクロスエントロピー損失です。 $$
\mathcal{L} = -\sum_{t=1}^{T} \log P(y_t \mid y_{ ここで $y_t$ は正解トークン、$y_{ Whisperは5つのサイズで提供されています。 小さなモデルはリアルタイム処理やエッジデバイスでの実行に適し、大きなモデルはより高い精度を提供します。モデルサイズが大きくなるにつれて、とくに非英語言語やノイズの多い環境での性能が向上します。 ここまででWhisperの理論的な全体像を把握できました。次に、PyTorchを使ってWhisperの主要コンポーネントを実際に実装してみましょう。 まず、先ほど解説した数学的定義に基づいて、メルスペクトログラムを計算するコードを実装します。音声信号の時間-周波数表現がどのように生成されるかを、コードで確認しましょう。 上のコードを実行すると3つのプロットが得られます。 次に、Whisperの主要コンポーネントをPyTorchで実装します。ここでは教育目的で、Whisperのアーキテクチャの本質を理解できるよう、主要な構造を再現します。 このEncoderの実装で注目すべき点は3つあります。 次にDecoderを実装します。 Decoderの実装では、Encoderとの違いが明確に現れています。位置エンコーディングに 最後に、EncoderとDecoderを統合したWhisperモデル全体を組み立てます。 コードを実行すると、以下のような出力が得られます。 この結果から3つのことが確認できます。 Whisperのデータフローを理解するために、各コンポーネントでテンソルの形状がどう変化するかを追跡するコードを書きましょう。 実行結果は以下のようになります。 テンソル形状の追跡から、Whisperのデータフローが明確に確認できます。 ここまでの実装でWhisperのアーキテクチャの核心を理解しました。実際の推論では、学習済みのWhisperモデルを簡単に利用できるライブラリが提供されています。次にその使い方を見てみましょう。 OpenAIが公開している 実用上、モデルサイズの選択は精度と速度のバランスで決まります。 WER(Word Error Rate)は単語誤り率で、低いほど高精度です。largeモデルは英語でWER 2%程度と、人間のプロの書き起こし(WER 3〜5%程度)に匹敵する精度を達成しています。 日本語のような非英語言語では、baseやsmallモデルの精度が大きく低下することが知られています。日本語での実用にはmedium以上のモデルが推奨されます。 このタイムスタンプ機能は、字幕ファイル(SRT形式)の自動生成や、音声検索のインデックス作成に直接活用できます。タイムスタンプはDecoderが特殊トークン Whisperは30秒単位でチャンクを処理しますが、 これで、Whisperの理論から実用までを一通り解説しました。最後にまとめとして要点を整理しましょう。 本記事では、OpenAIのWhisperの仕組みをアーキテクチャの細部から実装まで解説しました。 Whisperの設計思想は「シンプルなアーキテクチャ + 大規模データ」であり、Transformer Encoder-Decoderという確立された構造に、音声処理に必要な最小限のカスタマイズ(CNN前処理、メルスペクトログラム入力、特殊トークン体系)を加えたものです。この設計哲学は、GPTシリーズでも見られるOpenAIの一貫したアプローチと言えます。 次のステップとして、以下の記事も参考にしてください。Transformer Decoderブロック
出力層 — トークン確率の生成
マルチタスク設計 — 特殊トークンによるタスク指定
特殊トークンの体系
<|startoftranscript|> <|言語|> <|タスク|> [<|notimestamps|>] テキスト... <|endoftext|>
トークン
役割
<|startoftranscript|>書き起こしの開始を示す
<|en|>, <|ja|>, <|zh|> 等音声の言語を指定(99言語対応)
<|transcribe|>書き起こしタスク(音声の言語でテキスト化)
<|translate|>翻訳タスク(英語に翻訳)
<|notimestamps|>タイムスタンプなしモード
<|0.00|>, <|0.02|>, …タイムスタンプトークン(0.02秒刻み)
<|endoftext|>生成の終了を示す
タスクの組み合わせ
<|startoftranscript|> <|ja|> <|transcribe|> <|notimestamps|> こんにちは... <|endoftext|>
<|startoftranscript|> <|ja|> <|translate|> <|notimestamps|> Hello... <|endoftext|>
<|startoftranscript|> <|en|> <|transcribe|> <|0.00|> Hello <|0.52|> world <|1.04|> <|endoftext|>
言語検出
<|startoftranscript|> だけを入力し、次に生成される言語トークンの確率分布を調べることで、音声がどの言語で話されているかを判定できます。学習データと学習戦略
大規模弱教師あり学習
項目
値
総学習データ量
680,000 時間
うち英語
438,000 時間(約65%)
うち非英語
242,000 時間(約35%)
対応言語数
99言語
翻訳ペア数
125,000 時間(英語への翻訳)
なぜ弱教師あり学習なのか
データの前処理とフィルタリング
学習の目的関数
モデルサイズのバリエーション
モデル
パラメータ数
Encoder層
Decoder層
$d_{\mathrm{model}}$
ヘッド数
tiny
39M
4
4
384
6
base
74M
6
6
512
8
small
244M
12
12
768
12
medium
769M
24
24
1024
16
large
1550M
32
32
1280
20
PyTorch実装 — メルスペクトログラムとWhisperスタイルEncoder-Decoder
メルスペクトログラムの計算
import numpy as np
import matplotlib.pyplot as plt
def hz_to_mel(f):
"""周波数 (Hz) をメルスケールに変換"""
return 2595.0 * np.log10(1.0 + f / 700.0)
def mel_to_hz(m):
"""メルスケールを周波数 (Hz) に変換"""
return 700.0 * (10.0 ** (m / 2595.0) - 1.0)
def create_mel_filterbank(n_fft, sr, n_mels, f_min=0.0, f_max=None):
"""メルフィルタバンクを作成"""
if f_max is None:
f_max = sr / 2.0
# メルスケール上で等間隔な点を配置
mel_min = hz_to_mel(f_min)
mel_max = hz_to_mel(f_max)
mel_points = np.linspace(mel_min, mel_max, n_mels + 2)
hz_points = mel_to_hz(mel_points)
# 周波数ビンのインデックスに変換
bin_points = np.floor((n_fft + 1) * hz_points / sr).astype(int)
# 三角フィルタの構築
n_freqs = n_fft // 2 + 1
filterbank = np.zeros((n_mels, n_freqs))
for i in range(n_mels):
left = bin_points[i]
center = bin_points[i + 1]
right = bin_points[i + 2]
# 左の傾斜
for k in range(left, center):
filterbank[i, k] = (k - left) / (center - left)
# 右の傾斜
for k in range(center, right):
filterbank[i, k] = (right - k) / (right - center)
return filterbank
def compute_log_mel_spectrogram(audio, sr=16000, n_fft=400,
hop_length=160, n_mels=80):
"""音声波形から対数メルスペクトログラムを計算"""
# ハニング窓
window = np.hanning(n_fft)
# STFT
n_frames = 1 + (len(audio) - n_fft) // hop_length
stft = np.zeros((n_fft // 2 + 1, n_frames), dtype=complex)
for m in range(n_frames):
start = m * hop_length
frame = audio[start:start + n_fft] * window
spectrum = np.fft.rfft(frame, n=n_fft)
stft[:, m] = spectrum
# パワースペクトル
power_spec = np.abs(stft) ** 2
# メルフィルタバンクの適用
mel_fb = create_mel_filterbank(n_fft, sr, n_mels)
mel_spec = mel_fb @ power_spec
# 対数変換
log_mel_spec = np.log(mel_spec + 1e-10)
return log_mel_spec, mel_fb
# テスト用の合成音声(440Hz + 880Hzの正弦波 + ノイズ)
np.random.seed(42)
sr = 16000
duration = 2.0
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
audio = 0.5 * np.sin(2 * np.pi * 440 * t) + 0.3 * np.sin(2 * np.pi * 880 * t)
audio += 0.05 * np.random.randn(len(audio))
# メルスペクトログラムの計算
log_mel, mel_fb = compute_log_mel_spectrogram(audio, sr=sr)
# 可視化
fig, axes = plt.subplots(3, 1, figsize=(12, 10))
# (1) 波形
axes[0].plot(t[:1600], audio[:1600], linewidth=0.5)
axes[0].set_title("Waveform (first 100ms)")
axes[0].set_xlabel("Time [s]")
axes[0].set_ylabel("Amplitude")
# (2) メルフィルタバンク
for i in range(0, 80, 5):
axes[1].plot(np.linspace(0, sr/2, mel_fb.shape[1]), mel_fb[i])
axes[1].set_title("Mel Filterbank (every 5th filter)")
axes[1].set_xlabel("Frequency [Hz]")
axes[1].set_ylabel("Weight")
# (3) 対数メルスペクトログラム
axes[2].imshow(log_mel, aspect="auto", origin="lower",
extent=[0, duration, 0, 80])
axes[2].set_title("Log-Mel Spectrogram")
axes[2].set_xlabel("Time [s]")
axes[2].set_ylabel("Mel filter index")
axes[2].colorbar = plt.colorbar(axes[2].images[0], ax=axes[2])
plt.tight_layout()
plt.show()
Whisperスタイルの簡易Encoder-Decoderモデル
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
class WhisperEncoder(nn.Module):
"""Whisper Encoder: CNN前処理 + Transformer Encoder"""
def __init__(self, n_mels=80, d_model=256, n_heads=4,
n_layers=4, max_len=1500):
super().__init__()
# CNN前処理(2層のConv1d)
self.conv1 = nn.Conv1d(n_mels, d_model, kernel_size=3, padding=1)
self.conv2 = nn.Conv1d(d_model, d_model, kernel_size=3,
stride=2, padding=1)
# 正弦波位置エンコーディング
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(
torch.arange(0, d_model, 2).float()
* (-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
self.register_buffer("pe", pe.unsqueeze(0))
# Transformer Encoderブロック
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=n_heads,
dim_feedforward=d_model * 4,
activation="gelu",
batch_first=True,
norm_first=True,
)
self.transformer = nn.TransformerEncoder(
encoder_layer, num_layers=n_layers
)
self.ln_post = nn.LayerNorm(d_model)
def forward(self, x):
"""
x: (batch, n_mels, T) メルスペクトログラム
戻り値: (batch, T//2, d_model)
"""
# CNN前処理
x = F.gelu(self.conv1(x)) # (batch, d_model, T)
x = F.gelu(self.conv2(x)) # (batch, d_model, T//2)
x = x.permute(0, 2, 1) # (batch, T//2, d_model)
# 正弦波位置エンコーディングを加算
T_half = x.size(1)
x = x + self.pe[:, :T_half, :]
# Transformer Encoder
x = self.transformer(x)
x = self.ln_post(x)
return x
conv1 がメルスペクトログラムの80次元を d_model 次元に射影し、conv2 がストライド2で時間方向を半分にダウンサンプリングします。その後、正弦波位置エンコーディングを加算してからTransformer Encoderに通します。class WhisperDecoder(nn.Module):
"""Whisper Decoder: 学習済み位置埋め込み + Transformer Decoder"""
def __init__(self, vocab_size=51865, d_model=256, n_heads=4,
n_layers=4, max_len=448):
super().__init__()
# トークン埋め込み
self.token_embedding = nn.Embedding(vocab_size, d_model)
# 学習済み位置エンコーディング
self.position_embedding = nn.Embedding(max_len, d_model)
# Transformer Decoderブロック
decoder_layer = nn.TransformerDecoderLayer(
d_model=d_model,
nhead=n_heads,
dim_feedforward=d_model * 4,
activation="gelu",
batch_first=True,
norm_first=True,
)
self.transformer = nn.TransformerDecoder(
decoder_layer, num_layers=n_layers
)
self.ln_post = nn.LayerNorm(d_model)
def forward(self, tokens, encoder_output):
"""
tokens: (batch, seq_len) トークンインデックス
encoder_output: (batch, T//2, d_model) Encoderの出力
戻り値: (batch, seq_len, d_model)
"""
seq_len = tokens.size(1)
positions = torch.arange(seq_len, device=tokens.device)
# トークン埋め込み + 学習済み位置エンコーディング
x = self.token_embedding(tokens) + self.position_embedding(positions)
# 因果マスク(上三角を-infで埋める)
causal_mask = torch.triu(
torch.ones(seq_len, seq_len, device=tokens.device),
diagonal=1
).bool()
causal_mask = causal_mask.float().masked_fill(causal_mask, float("-inf"))
# Transformer Decoder
x = self.transformer(
x,
encoder_output,
tgt_mask=causal_mask,
)
x = self.ln_post(x)
return x
nn.Embedding(学習可能パラメータ)を使用し、因果マスクを生成してMasked Self-Attentionを実現しています。encoder_output を memory として渡すことで、Cross-Attentionが自動的に行われます。class WhisperModel(nn.Module):
"""WhisperスタイルのEncoder-Decoderモデル"""
def __init__(self, n_mels=80, vocab_size=51865, d_model=256,
n_heads=4, n_encoder_layers=4, n_decoder_layers=4):
super().__init__()
self.encoder = WhisperEncoder(
n_mels=n_mels,
d_model=d_model,
n_heads=n_heads,
n_layers=n_encoder_layers,
)
self.decoder = WhisperDecoder(
vocab_size=vocab_size,
d_model=d_model,
n_heads=n_heads,
n_layers=n_decoder_layers,
)
# 出力射影(トークン埋め込みと重み共有)
self.proj = self.decoder.token_embedding
def forward(self, mel, tokens):
"""
mel: (batch, n_mels, T) メルスペクトログラム
tokens: (batch, seq_len) 入力トークン
戻り値: (batch, seq_len, vocab_size) ロジット
"""
encoder_out = self.encoder(mel)
decoder_out = self.decoder(tokens, encoder_out)
# 重み共有による出力射影
logits = decoder_out @ self.proj.weight.T
return logits
# モデルのインスタンス化とフォワードパスの確認
model = WhisperModel(
n_mels=80,
vocab_size=51865,
d_model=256,
n_heads=4,
n_encoder_layers=4,
n_decoder_layers=4,
)
# パラメータ数の計算
n_params = sum(p.numel() for p in model.parameters())
print(f"パラメータ数: {n_params:,}")
# ダミー入力でフォワードパスを確認
batch_size = 2
mel_input = torch.randn(batch_size, 80, 3000) # 30秒分のメルスペクトログラム
token_input = torch.randint(0, 51865, (batch_size, 20)) # 20トークン
with torch.no_grad():
logits = model(mel_input, token_input)
print(f"入力メルスペクトログラム: {mel_input.shape}")
print(f"入力トークン: {token_input.shape}")
print(f"出力ロジット: {logits.shape}")
print(f"出力確率分布: {F.softmax(logits[0, 0, :10], dim=-1)}")
パラメータ数: 29,989,865
入力メルスペクトログラム: torch.Size([2, 80, 3000])
入力トークン: torch.Size([2, 20])
出力ロジット: torch.Size([2, 20, 51865])
出力確率分布: tensor([0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001])
d_model=256 と少なめの層数に設定しているためです。実際のWhisper tinyでは d_model=384、4層のEncoder/Decoderを使用します各コンポーネントのテンソル形状の追跡
import torch
import torch.nn.functional as F
def trace_shapes(model, mel_input, token_input):
"""各コンポーネントでのテンソル形状を追跡"""
print("=" * 60)
print("Whisper テンソル形状の追跡")
print("=" * 60)
# Encoder
print("\n--- Encoder ---")
x = mel_input
print(f"入力メルスペクトログラム: {x.shape}")
x = F.gelu(model.encoder.conv1(x))
print(f"Conv1d 第1層後: {x.shape}")
x = F.gelu(model.encoder.conv2(x))
print(f"Conv1d 第2層後: {x.shape}")
x = x.permute(0, 2, 1)
print(f"転置後: {x.shape}")
T_half = x.size(1)
x = x + model.encoder.pe[:, :T_half, :]
print(f"位置エンコーディング加算後: {x.shape}")
x = model.encoder.transformer(x)
x = model.encoder.ln_post(x)
print(f"Transformer Encoder出力: {x.shape}")
encoder_out = x
# Decoder
print("\n--- Decoder ---")
tokens = token_input
print(f"入力トークン: {tokens.shape}")
seq_len = tokens.size(1)
positions = torch.arange(seq_len, device=tokens.device)
tok_emb = model.decoder.token_embedding(tokens)
print(f"トークン埋め込み後: {tok_emb.shape}")
pos_emb = model.decoder.position_embedding(positions)
print(f"位置埋め込み: {pos_emb.shape}")
x = tok_emb + pos_emb
print(f"埋め込み合算後: {x.shape}")
causal_mask = torch.triu(
torch.ones(seq_len, seq_len, device=tokens.device), diagonal=1
).bool()
causal_mask = causal_mask.float().masked_fill(causal_mask, float("-inf"))
print(f"因果マスク: {causal_mask.shape}")
x = model.decoder.transformer(x, encoder_out, tgt_mask=causal_mask)
x = model.decoder.ln_post(x)
print(f"Transformer Decoder出力: {x.shape}")
logits = x @ model.proj.weight.T
print(f"出力ロジット: {logits.shape}")
return logits
# 実行
mel_input = torch.randn(1, 80, 3000)
token_input = torch.randint(0, 51865, (1, 15))
with torch.no_grad():
trace_shapes(model, mel_input, token_input)
============================================================
Whisper テンソル形状の追跡
============================================================
--- Encoder ---
入力メルスペクトログラム: torch.Size([1, 80, 3000])
Conv1d 第1層後: torch.Size([1, 256, 3000])
Conv1d 第2層後: torch.Size([1, 256, 1500])
転置後: torch.Size([1, 1500, 256])
位置エンコーディング加算後: torch.Size([1, 1500, 256])
Transformer Encoder出力: torch.Size([1, 1500, 256])
--- Decoder ---
入力トークン: torch.Size([1, 15])
トークン埋め込み後: torch.Size([1, 15, 256])
位置埋め込み: torch.Size([15, 256])
埋め込み合算後: torch.Size([1, 15, 256])
因果マスク: torch.Size([15, 15])
Transformer Decoder出力: torch.Size([1, 15, 256])
出力ロジット: torch.Size([1, 15, 51865])
Whisperの実用 — openai-whisperライブラリ
インストールと基本的な使い方
openai-whisper ライブラリを使えば、数行のコードで高精度な音声認識を実行できます。# インストール
# pip install openai-whisper
import whisper
# モデルの読み込み
model = whisper.load_model("base")
# 音声ファイルの書き起こし
result = model.transcribe("audio.wav")
print(result["text"])
# 言語を指定して書き起こし
result = model.transcribe("audio.wav", language="ja")
print(result["text"])
# 英語への翻訳
result = model.transcribe("audio.wav", task="translate")
print(result["text"])
transcribe 関数は内部で以下の処理を自動的に行います。
モデルサイズと精度のトレードオフ
モデル
パラメータ
VRAM目安
英語WER
相対速度
tiny
39M
~1GB
~7.7%
32x
base
74M
~1GB
~5.4%
16x
small
244M
~2GB
~3.4%
6x
medium
769M
~5GB
~2.9%
2x
large-v3
1550M
~10GB
~2.0%
1x
セグメント情報とタイムスタンプ
transcribe の結果にはセグメント情報も含まれており、各文の開始・終了時刻を取得できます。import whisper
model = whisper.load_model("base")
result = model.transcribe("audio.wav", word_timestamps=True)
# セグメントごとの情報
for segment in result["segments"]:
start = segment["start"]
end = segment["end"]
text = segment["text"]
print(f"[{start:.2f}s - {end:.2f}s] {text}")
<|0.00|>, <|0.02|>, … を生成することで得られ、0.02秒の分解能を持ちます。長時間音声の処理
transcribe 関数はこのチャンキングを自動的に処理します。ただし、チャンクの境界で文が途切れる問題が生じることがあります。この問題に対処するため、Whisperはシーク(seek)戦略を使用しています。各チャンクの書き起こし結果の最後のタイムスタンプを参照し、次のチャンクの開始位置をその時刻に設定することで、文の途切れを最小限に抑えます。まとめ
