Whisperは日本語のとき、聞いていない話者名を書く。無音で「ご視聴ありがとうございました」と言うのと同じ理由
文字起こし97,000行に、出演していない人の名前が入っていた。実装を変えても、モデルを変えても同じ数字が出る。英語では出ない。再現手順つきで、外れた推測も残して書く。
はじめに
このサイトは、2つのYouTubeチャンネル・動画724本の文字起こしを全文検索できるようにしたものです。新着3本を取り込んで眺めていたら、こういう行が並んでいました。
樋口 そうですね
深井 なるほど
福田 へー
堀 うんうん
「樋口」も「深井」も、この番組の出演者ではありません。番組内で一度も呼ばれていない名前です。数えたら3本で619件ありました。
誤認識にしては形が揃いすぎています。まさかと思って、掃除する前の生の出力を724本ぶん数え直しました。
250 本が汚染
3本ではなく250本、全体の35%。行数にして約97,000行。誰も言っていない人名が、それだけ入っていました。
調べた結果、これは私の環境の不具合ではありませんでした。実装を替えても、モデルを替えても同じ数字が出ます。そして日本語のときだけ起きます。
想定読者
- Whisper の出力に、見覚えのない人名や定型句が混ざって困っている人
- 音声認識の結果をそのまま人に見せるもの(議事録・字幕・検索インデックス)に使っている人
- モデルを大きいものに替えても直らず、原因が分からないままの人
逆に、モデルの学習や再学習の話は出てきません。すでにあるモデルをどう飼うかという話です。
先に結論
Whisper は音を文字に写しているのではなく、「ここに書かれていそうなこと」を書いています。
- 前の文脈が「名前␣発言」の形をしていると、続きにも名前を書く。音声のどこにも名前は無くてよい
- 1回出ると自分の出力が次の窓の種になって自走する(
condition_on_previous_text)。実データの最長は615セグメント連続 - 日本語のときだけ起きる。英語では種を8倍に強めても0件だった
- 出力は日本語として完全に自然なので、意味を見る検査は全部すり抜ける。書式で捕まえるしかない
あなたの文字起こしは大丈夫か
30秒で確かめられます。無音のファイルを作って、日本語として渡してください。
$ ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 30 silence.wav
$ whisper silence.wav --language ja --model small
ご視聴ありがとうございました
音は1つも入っていません。誰も喋っていません。それでもこう書きます。
これを見たとき、聞き取りに失敗したのだとは思わないはずです。書いているのだと分かる。この記事の残りは、全部この一行の延長線上にあります。
手元のコーパスで、行頭の「短い語+半角スペース」を数えてみてください。
import json, glob, re, collections
pat = re.compile(r"^(\S{1,6})\s")
per = collections.Counter()
for f in glob.glob("transcripts/*.json"):
for s in json.load(open(f))["segments"]:
m = pat.match(s["text"].strip())
if m:
per[m.group(1)] += 1
print(per.most_common(10))
同じ短い語が数百回、行頭にだけ出ていたら、それは話者ラベルの幻覚です。
本当に3本だけだったのかを数え直す
619件は「新着3本を見たら見つかった数」でしかありません。
この仕組みは文字起こしの結果を data/transcript/{video_id}.json に残していて、
DB 側の除去処理はこの JSON に触りません。
掃除される前の出力が724本ぶん丸ごと残っています。
import json, glob, re, collections
pat = re.compile(r"^(樋口|深井|福田|堀|ヤンヤン|三角)\s") # 行頭の「名前+半角スペース」
per = {}
for f in glob.glob("data/transcript/*.json"):
segs = json.load(open(f))["segments"]
names = collections.Counter()
for s in segs:
m = pat.match(s["text"].strip())
if m:
names[m.group(1)] += 1
if names:
per[f.split("/")[-1][:-5]] = names
print(len(per), "本が汚染")
250 本が汚染
3本ではなく250本、全724本の35%。行数にして約97,000行でした。
名前の選ばれ方に規則がある
回ごとに何種類の名前が出たかを数えると、分布が異様でした。
1種類だけ出た回 : 16本
2種類 : 165本
3種類 : 69本
4種類以上 : 0本 ← 一度もない
4人目が1本も出ません。組み合わせもほぼ固定です。
2種類の回 165本のうち 154本が ('樋口', '深井')
3種類の回 69本のうち 69本が ('ヤンヤン', '樋口', '深井') ← 全部これ
3人目まで使う69本は、ゲスト回・対談回に偏ります。 実際の話者が増えると、割り当てる名前も増えています。 頼んでもいない話者分離が起きている、ということです。
出力を見ると、行ごとに律儀に振り分けています。 相槌に名前がくっついたのではなく、台本の書式そのものです。
[ 0.0] 深井 竜剣工科場
[ 3.5] ヤンヤン 東京外代RR
[ 7.2] 深井 いいね!狭いRR企画
[ 9.7] 深井 クロシマ先生のご出身の東京外国語大学ですね
[ 14.8] ヤンヤン クロシマ先生は学部、修士、博士
この3つの名前は、別の番組『歴史を面白く学ぶコテンラジオ』のレギュラー3人と一致します。 ランダムな人名ではありません。
思いついた原因を2つ潰す
仮説1: 自分が渡している initial_prompt が悪い(→ 否定)
取り込みのときに、番組固有の語彙を initial_prompt で渡していて、
その中に「コテンラジオ」という語が入っています。真っ先に疑いました。
日付で否定できます。
$ git log --date=short --format='%ad %s' -- data/whisper_initial_prompt.txt | tail -1
2026-07-04 ...
$ git log --date=short --format='%ad %s' -- data/speaker_labels.txt | tail -1
2026-07-04 Add speaker-label hallucination sweep to ingest pipeline
プロンプトが生まれたのは、事故に気づいた日と同じ日です。 汚染された250本のうち243本は、それ以前の 2026-06 に転写されています。 存在しなかったものは原因になりません。
仮説2: 番組が本当にコテンラジオの話をしているから(→ 否定)
これは筋が良さそうに見えました。この番組はコテンラジオに34本で言及していて、 樋口聖典さんはゲスト出演までしています。 音声中に名前が出れば、モデルが引きずってもおかしくない——と考えたくなります。
汚染された回 250本 / 全724本 (35%)
コテンラジオ言及回 34本
うち汚染されていた 10本 (29%)
→ 言及回の汚染率 29% vs 言及なし回の汚染率 35%
むしろ言及していない回のほうが汚染率が高い。 音声に名前が出たかどうかは、この現象と関係がありませんでした。
その書き起こしは実在するのか
コテンラジオは、サイトのリニューアル前に公式サイトで全文書き起こしを公開していました (全エピソードではありません)。Internet Archive に残っています。
そこで本文ではなく書式だけを数えました。 話者名のあとに何が来るかを、保存されている3つの版で数えます。
res = {d: sum(len(re.findall(x + re.escape(d), text))
for x in ("深井", "樋口", "ヤンヤン"))
for d in (":", ":", " ", " ")}
2020-09-27 版 全角コロン 143 半角コロン 0 半角スペース 0
2021-05-10 版 全角コロン 143 半角コロン 0 半角スペース 0
2023-03-23 版 全角コロン 143 半角コロン 0 半角スペース 0
公開されていた書き起こしは全角コロン区切りで、半角スペースは1件もありません。 一方 Whisper が挿入してきた形は、例外なく半角スペースでした。
この記事の初稿では、ここを「その番組の書式をそのまま再生している」と書いていました。 書式を確かめずに書いた一文で、確かめたら外れていました。 言えるのは名前のセットが一致することまでです。
再現させる
ここまでは観測です。手元で起こせないと、原因を確かめたことになりません。
そこで、この番組と一切関係のない音声を作りました。
macOS の say で、2つの声に味噌汁と出汁の雑談をさせます。
20発話・46秒、相槌をたっぷり入れた2人対話です。
$ say -v Kyoko -o p01.aiff "今日はですね、味噌汁の話をしようと思うんですよ"
$ say -v "Rocko (日本語(日本))" -o p02.aiff "ほう"
…(20発話)
$ ffmpeg -f concat -safe 0 -i cat.txt -ar 16000 -ac 1 dialog.wav
まずモデルを総なめします。initial_prompt は渡しません。
whisper-small 行頭ラベル なし
whisper-medium 行頭ラベル なし
whisper-large-v3 行頭ラベル なし
large-v3-turbo 行頭ラベル なし
kotoba-whisper-v2 行頭ラベル なし
1件も出ません。きれいな音では自然発火しませんでした。
種を1つ置くと、火がつく
Whisper は30秒の窓で区切って処理し、
前の窓の出力を次の窓のプロンプトとして送ります(condition_on_previous_text、既定 True)。
なら、「前の窓にラベルがあった」状態を人工的に作れば続きが起きるはずです。
seed = "樋口 そうですね 深井 なるほど 樋口 はい 深井 うんうん"
whisper.load_model("small").transcribe("dialog.wav", language="ja",
initial_prompt=seed)
出汁の話に、こう出ました。
今日はですね 味噌汁の話をしようと思うんですよ
樋口 ほう 樋口 だしって 実は 地域で全然違うんですよね
深井 なるほど 樋口 関西だと 昆布が中心で 関東は カツオセチが強いんです
樋口 へ で ここからが面白いところなんですけど
深井 うん 樋口 九州に行くと アゴダシ っていうのが出てくるんですね
再現しました。種を外すと0件に戻ります。
実装を変えても同じ数字が出る
ここまでは Apple Silicon 向けの mlx-whisper です。
この移植版の癖という可能性が残るので、本家の PyTorch 実装で同じことをしました。
無音 種なし 種あり
mlx-whisper (Metal) ご視聴… 0件 14件
openai-whisper (CPU) ご視聴… 0件 14件
実装もコードも走らせるチップも別なのに、14件で一致します。 移植版の不具合ではなく、モデルが持っている性質です。
モデル別では small 14件 / medium 4件 / large-v3 9件。 独立に学習された3つで起きます(turbo と日本語特化の kotoba-whisper は0件でした)。
日本語でだけ起きる
同じことを英語でも試しました。英語の2人対話を say で作り、
種の強さをそろえて比べます。
倍率 英 John/Sarah 英 Higuchi/Fukai 日 田中/佐藤 日 樋口/深井
×1 0 0 0 14
×2 0 0 6 7
×4 0 0 6 3
×8 0 0 1 2
読み取れることが3つあります。
- 英語では起きない。8倍まで押しても0件。名前をローマ字にしても0件
- 日本語なら、ありふれた名前でも起きる。田中/佐藤でも出ます
- 樋口/深井が特別なのは「一番軽い刺激で出る」という点だけ。 種1回で14件、他の名前は同じ刺激では0件
ここは自分の推測が2回外れた場所なので、条件をそろえるまで結論を書きませんでした。 最初は「その対でないと出ない」と書きかけましたが、 種の長さを固定して比べていただけでした。
日本語でだけ起きるという点は、冒頭の「ご視聴ありがとうございました」と同じ構図です。 どちらも日本語の学習データが持っている癖が出ているのだと思いますが、 学習データの一覧は公開されていないので、そこから先は確かめられません。
なぜ1本まるごと汚染されるのか
種が要るなら、なぜ実際には1本の半分以上が埋まるのか。 自分の出力が次の窓の種になるからです。1回出れば、あとは自走します。
実データで確かめられます。ラベル行の連続する塊を数えました。
塊の総数 23,813個
1個だけ 7,819個
2-4連 11,287個
5-19連 4,300個
20-99連 379個
100連以上 28個
最長の連続 615セグメント
ばらばらに湧いているのではなく、点いたら続く形です。 615セグメント連続というのは、20分以上ラベルを出し続けたということ。
止められるのか
condition_on_previous_text=True [14, 14, 14]
condition_on_previous_text=False [ 6, 6, 6]
半分以下に減りますが、0にはなりません。
initial_prompt は各窓に毎回渡されるので、種そのものは残るためです。
しかも condition_on_previous_text は、
長い話の文脈を保って表記を安定させるために効いている設定です。
切ると別のところが悪くなります。片方に倒せば済む天秤ではありませんでした。
だから最終的には、後段で形を見て落とす方式を採っています。
モデルを新しくしても消えていない
2026-07-04 に whisper-small から large-v3-turbo へ切り替えました。 turbo 以降に転写した26本を数えます。
転写 2026-06 698本中 243本が汚染
転写 2026-07 14本中 4本
転写 2026-08 10本中 2本
転写 2026-09 2本中 1本
297/395 行 {'ヤンヤン': 297} 転写2026-08-25 ← 全行の75%
減っていますが、無くなってはいません。 合成音声の実験では turbo は0件だったのに、実データでは出ています。 きれいな短い音と、90分の実録音では条件が違うということですが、 この差はまだ説明できていません。
ついでに見つかった取りこぼし
この調査中に、本番のDBにラベルが47件残っていることが分かりました。 原因は除去処理の中身ではなく、走らせる場所でした。
2026-07-04 樋口 深井 福田 堀 ヤバイ - ← 除去処理を導入
2026-07-07 + ヤンヤン
2026-08-30 + 三角
除去は取り込み時にしか走りません。一方、698本の転写は 2026-06 に終わっています。 つまりあとから足した「ヤンヤン」「三角」は、既存の回に一度も掛かっていませんでした。 一覧に語を足した時点で、その語は過去回にもいるに決まっているのに、 遡って掛け直す口がありませんでした。
$ python3 scripts/resweep_labels.py # 既定は空回し
全 1,305,865 segment 中、変わるのは 47 件
Drl5HMryYLM idx1583
旧: ヤンヤン 1位に踊り出たのにイスカリーユーバー先生です
新: 1位に踊り出たのにイスカリーユーバー先生です
空回し。全件を目で確かめてから --apply を付ける。
47件を全部目で見てから --apply しました。
130万件を機械で書き換える操作の既定を「何もしない」にしておくのは、譲れない線です。
なぜ検出できなかったのか
検出をすり抜けた理由が3つ重なっていました。
- 日本語として自然。「樋口 そうですね」は文字列としては何も壊れていない
- 実在する人名。未知語チェックに引っかからない
- 付く先が相槌。「そうですね」自体は正しいので、意味のチェックでも落ちない
つまり内容を見るタイプの検査は全部すり抜けます。 効いたのは、内容ではなく書式を見る検査でした。
実装
取り込み直後(build_db.py の中)で必ず走らせています。
def sweep_speaker_labels(con):
# 既知ラベルを消し、未知の候補は警告する。取り込み直後に必ず呼ぶ
known = [l.strip() for l in open("data/speaker_labels.txt") if l.strip()]
for label in known:
for pat in (f"{label} ", f" {label} ", f" {label}"): # 行頭・中間・行末
con.execute('''
UPDATE segments
SET text = REPLACE(text, ?, ' ')
WHERE text LIKE ?''', (pat, f"%{pat}%"))
rebuild_search_columns(con) # text_search / text_yomi も作り直す
# 未知ラベルの検出: 「短い語 + 半角スペース」が行頭に何度も出るのは異常
rows = con.execute('''
SELECT video_id,
SUBSTR(text, 1, INSTR(text, ' ') - 1) AS head,
COUNT(*) AS n
FROM segments
WHERE INSTR(text, ' ') BETWEEN 2 AND 6
GROUP BY video_id, head
HAVING n >= 20''').fetchall()
for r in rows:
if r["head"] not in known:
print(f"⚠️ 未知の行頭トークン: {r['head']} ({r['n']}回) {r['video_id']}")
判定の勘所は「日本語の文中に、短い語+半角スペースが行頭で20回以上出るのは異常」 という一点だけです。内容を見ないので、知らない人名が来ても引っかかります。
ハマったところ: 話題そのものがラベルになる
この仕組みを入れたあと、1件だけすり抜けました。
25:16 ペブカック そうそうそうそう
25:40 ペブカック ヤツにも本当に
25:51 ペブカック 余計に
25:57 ペブカック あー
26:07 ペブカック へー
...(78秒間に15回)
形は幻覚そのものです。しかしその回は本当に PEBCAK(Problem Exists Between Chair And Keyboard)の話をしていました。 自動で消すと、本物の言及まで消えます。
結局、自分で音を聞き直して「25:16 の1件だけ本物」と確定させました。
# 本物の1件だけ残して、残り14件からラベルを剥がす
for idx, sec, old in rows:
if int(sec) == 1516:
continue # 25:16 は本物
txt = old[len("ペブカック "):].strip()
update(video_id, idx, txt)
record_stage(video_id, idx, "human", source="音を確認し、25:16以外は言っていないと確定")
他人事ではない
推測が当たっているとすると、立場は入れ替わりえます。
いま作っているこのコーパスも、724本・1,384万文字の日本語の対話書き起こしとして Web に出ています。将来のモデルが同じことを学べば、 どこかの誰かの文字起こしに「水野」「堀元」が勝手に挿入されることになります。
公開する側としては、そこそこ居心地の悪い話です。
まとめ
分かったこと。
- Whisperは音を写す機械ではない。無音に「ご視聴ありがとうございました」と書く。 その延長で、日本語のときだけ出演していない話者名を書く
- 前の文脈に「名前␣発言」を1行置けば、誰でも再現できる。 無関係な合成音声で起きる。実装を変えても同じ数字、モデルを変えても起きる
- 英語では起きなかった。種を8倍まで強めても0件。日本語ならありふれた名前でも起きる
- 1回出ると自走する。自分の出力が次の窓の種になるので、最長615セグメント続いた
- 意味を見る検査は全部すり抜ける。「樋口 そうですね」は日本語として何も壊れていない。 効いたのは内容ではなく書式を見る検査だった
分かっていないこと。ここは埋まっていません。
- なぜ日本語だけなのか。学習データの一覧が公開されていないので確かめられない
- なぜその名前に偏るのか。「一番軽い刺激で出る」ことしか示せていない
- 合成音声では turbo が0件なのに、実データの turbo 転写では出る。この差の説明がついていない
- 英語は2組の名前・1本の音声でしか試していない。「英語では起きない」ではなく 「この条件では起きなかった」が正確
この記事では、途中で外れた推測を消さずに残しました。 「その番組の書式をそのまま再生している」は、書式を数えたら全角コロンで違いました。 「その2人の対でないと出ない」は、種の長さをそろえたら普通の名前でも出ました。 2回とも、条件をそろえずに比べたのが原因です。
← 「あの話どの回だっけ」を検索可能にするまで。197万… 置換辞書で4,056箇所を自動修正する。ただし辞書の… → 目次へ