作業の合間に録った音声メモを文字起こしにかけて、最初に目に入ったのが「歌川ひろしげ」でした。
浮世絵の壁紙アプリで配信する作品を選びながら、思いついたことを口で残しております。絵師の名前と作品名がそのまま作業の単位になりますので、そこが崩れると、あとで読み返しても何の話だったのか辿れません。
custom_vocabulary という指定があることは知っておりました。最大1,000語まで積めると書かれています。ですから私は、思いつく限りの絵師名と作品名を並べたリストを作って、そのまま渡しました。
結果は芳しくありませんでした。直った語もあれば、直らない語もあり、なぜそうなるのかが分からないままでした。
一覧を先に作ったところで、枠の使い道を間違えました
いま振り返ると、順序が逆だったのです。
私は「この音源に出てきそうな固有名詞」を思い出しながらリストを作りました。頭の中の在庫を書き出す作業です。けれども文字起こしが必要としているのは、在庫ではありません。実際に落ちた語です。
上限が1,000語と書かれていると、埋められるだけ埋めたくなります。ただ、同じサイトの別記事でも触れましたが、実務で守るべき予算は1,000ではなく、その十分の一くらいの感覚です(gemini-3.5-transcribe の smart モードでは、話者ラベルも単語タイムスタンプも返ってきませんに、モードごとの組み合わせと併せて書いております)。上限に当たる前に、効かない語で薄まるほうが先に来ます。
語彙は思い出して書くものではなく、落ちた語を拾って書くものです。 この一文に辿り着くまで、私は同じリストを三度書き直しました。
誤認ログは「正解と出力の対」だけで足ります
拾うためには、落ちたことが分かる記録が要ります。といっても大掛かりなものは不要でした。
必要なのは二つだけです。その音源で本当は何と言っていたか(ref)と、文字起こしが何と返したか(hyp)。この対を、短い区間ごとに並べておきます。
{
"terms": ["歌川広重", "喜多川歌麿", "東洲斎写楽", "葛飾北斎", "名所江戸百景", "浮世絵", "壁紙"],
"pairs": [
{"ref": "歌川広重の名所江戸百景から三枚選びました",
"hyp": "歌川ひろしげの名所江戸百景から三枚選びました"},
{"ref": "喜多川歌麿の美人画を壁紙にします",
"hyp": "北川うたまろの美人画を壁紙にします"},
{"ref": "東洲斎写楽は役者絵だけで十ヶ月です",
"hyp": "東洲斎写楽は役者絵だけで十ヶ月です"},
{"ref": "葛飾北斎の富嶽三十六景は別の回にします",
"hyp": "葛飾北斎の富嶽三十六景は別の回にします"},
{"ref": "歌川広重の雨の表現を残したいので浮世絵の粒を潰しません",
"hyp": "歌川ひろ重の雨の表現を残したいので浮世絵の粒を潰しません"},
{"ref": "喜多川歌麿の線は細いので拡大時に壁紙が眠くなります",
"hyp": "喜多川うたまろの線は細いので拡大時に壁紙が眠くなります"},
{"ref": "名所江戸百景の橋の回を先に出します",
"hyp": "名所江戸百景の橋の回を先に出します"}
]
}ref を作る手間を心配される方がいらっしゃるかもしれません。私も最初は身構えました。ただ、全文を書き起こす必要はないのです。固有名詞を含む区間だけで十分ですし、一本の音源から五つも取れれば、もう傾向は見えてきます。
terms のほうは、最初に作った在庫のリストをそのまま流用できます。捨てずに済みます——ただし、渡す先が変わります。渡す先は API ではなく、次のスクリプトです。
候補を並べ替える60行
やっていることは単純です。terms の各語について、ref に何回出たか、hyp に何回残ったかを数えて、差を取ります。差が出た語だけが候補になります。
import json, sys, unicodedata
from collections import defaultdict
BUDGET = 100 # 実際に積む上限(1,000 は API 側の上限です)
def norm(s):
return unicodedata.normalize("NFKC", s).strip()
def build(pairs, terms, budget=BUDGET):
seen = defaultdict(int) # 音源に登場した回数
miss = defaultdict(int) # 認識結果に残らなかった回数
for ref, hyp in pairs:
r, h = norm(ref), norm(hyp)
for t in terms:
n = r.count(norm(t))
if n == 0:
continue
seen[t] += n
got = h.count(norm(t))
if got < n:
miss[t] += n - got
rows = []
for t in terms:
if seen[t] == 0:
rows.append((t, 0, 0, None, "音源に出ていない"))
continue
rate = miss[t] / seen[t]
if miss[t] == 0:
rows.append((t, seen[t], 0, 0.0, "そのまま通る"))
else:
rows.append((t, seen[t], miss[t], rate, "候補"))
cand = [r for r in rows if r[4] == "候補"]
cand.sort(key=lambda r: (-r[2], -r[3], r[0]))
picked = [r[0] for r in cand[:budget]]
dropped = [r for r in rows if r[4] != "候補"]
return picked, cand, dropped
if __name__ == "__main__":
data = json.load(open(sys.argv[1], encoding="utf-8"))
picked, cand, dropped = build(
[(d["ref"], d["hyp"]) for d in data["pairs"]], data["terms"]
)
print(f"候補 {len(cand)} 語 / 採用 {len(picked)} 語 / 除外 {len(dropped)} 語\n")
print("順位 誤認 出現 誤認率 語")
for i, (t, s, m, rate, _) in enumerate(cand, 1):
print(f"{i:>3} {m:>3} {s:>3} {rate:>5.2f} {t}")
print("\n-- 枠を渡さなかった語 --")
for t, s, m, rate, why in dropped:
print(f" {t}({why})")
print("\ncustom_vocabulary =", json.dumps(picked, ensure_ascii=False))norm() で NFKC 正規化をかけているのは、同じ語が半角と全角で別物として数えられるのを防ぐためです。ここを省くと、数字やアルファベットを含む語で数が合わなくなります。
並べ替えの鍵は sort の行です。誤認の回数を第一の基準にして、同数なら誤認率で割ります。率だけで並べると、一度しか出ていない語が上位を占めてしまうのです。何度も落ちる語のほうが、直したときの効き目が大きくなります。
上の入力をそのまま渡すと、こう出ます。
候補 2 語 / 採用 2 語 / 除外 5 語
順位 誤認 出現 誤認率 語
1 2 2 1.00 喜多川歌麿
2 2 2 1.00 歌川広重
-- 枠を渡さなかった語 --
東洲斎写楽(そのまま通る)
葛飾北斎(そのまま通る)
名所江戸百景(そのまま通る)
浮世絵(そのまま通る)
壁紙(そのまま通る)
custom_vocabulary = ["喜多川歌麿", "歌川広重"]
七語を並べて、枠を渡す価値があったのは二語でした。
選んだ語を、どこに置くか
スクリプトが返すのは値であって、設定そのものではありません。custom_vocabulary は transcription_config の直下に置きます。language_codes と同じ階層で、話者や単語タイムスタンプの設定が入る mode オブジェクトの中ではありません。私はここを何度か取り違えました。
transcription_config = {
"language_codes": ["ja-JP"],
"custom_vocabulary": picked, # スクリプトの出力をそのまま
"mode": {"type": "verbatim",
"timestamp_granularities": ["word"]},
}言語が分かっているなら、語彙と併せて language_codes も指定しておくほうが素直です。語のリストだけでは手掛かりとして弱く、言語を固定しておくと、名前が出てくる前の段階で候補が絞られます。一文のなかで二言語を行き来する音源では、私は固定するのをやめました。自信のある誤りを返されるより、少し揺れる結果のほうが扱いやすかったからです。
ひとつ、含みを持たせずに書いておきたいことがあります。語数と精度の関係は、私は測っておりません。測っていない数字を書くわけにはまいりません。言えるのはログが見せてくれたことだけで、私が積んでいた語の多くは、そもそも一度も落ちていなかったということです。働いていない語に枠を渡していたわけです。
枠を渡さない語を、三つに分けます
除外された語は、同じ理由で除外されたわけではありません。ここを区別しておくと、次に見直すときに迷わなくなります。
| 分類 | 状態 | 扱い |
|---|---|---|
| そのまま通る語 | ref にも hyp にも同じ回数だけ出ている | 枠を渡しません。「葛飾北斎」のように広く知られた語は、指定しなくても通ることが多いのです |
| 音源に出ていない語 | ref に一度も現れない | 今回は判断材料がありません。捨てずに候補として置いておき、その語が出た音源で測り直します |
| 崩れ方が毎回違う語 | 誤認しているが、hyp 側の形が揃わない | 語彙で押すより、録り方の側を見直すほうが早いことがあります |
三つめは表だけでは分かりにくいので、補足します。「歌川広重」は「歌川ひろしげ」と「歌川ひろ重」の二通りに崩れておりました。前半の「歌川」は毎回残っています。つまり、モデルが迷っているのは後半の読みだけです。
こういう語は、語彙に入れる価値がはっきりしています。一方で、語全体が毎回違う形に散る語もあります。以前の私は、この種の語をリストに足しては消し、あと少しで揃うはずだと思い込んでおりました。そのときは、話す速さや語と語のあいだの取り方が原因であることが少なくありません。私は一度、早口で続けて読み上げていた部分をゆっくり言い直すだけで、語彙を触らずに揃ったことがありました。
残った枠は、次の録りのために空けておきます
百という予算に対して、最初の一本から採れる語はほんの少しです。埋まらないことを不安に感じるかもしれませんが、空いていて構わないのです。
私は、音源を録るたびに ref と hyp の対を数行だけ足して、同じスクリプトを回し直しています。落ちる語は録るたびに少しずつ入れ替わりますし、新しく扱い始めた絵師の名前は、当然ながら最初の一本で落ちます。語彙リストは作品ではなく、台帳です——完成させるものではなく、更新し続けるものだと考えるようにしました。
もうひとつ、運用で決めていることがあります。一度採用した語も、三本続けて誤認が出なくなったら外します。モデルの側が更新されて通るようになることがあるからです。枠を占めたままにすると、そのぶん新しい語に回せなくなります。外した記録は足した記録と同じ場所に残しておきます。あとでその語がまた落ち始めたときに、一度は積んでいたこと、そしてなぜ外したのかを辿れるからです。
長いものから始めなくて構いません。手元にある10分ほどの音源を一本選んで、固有名詞を含む区間を五行だけ書き起こし、上のスクリプトに通してみてください。
それだけで、いま自分が枠を渡すべき語が何語あるのかが見えます。私の場合は二語でした。思いついた語を全部並べていたころより、はるかに手応えがあります。
お読みいただきありがとうございました。同じように音声メモから作業を組み立てている方に、少しでも重なるところがあれば幸いです。