File size: 15,776 Bytes
a645265 92745d0 a645265 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | """Run the fine-tuned Whisper over a predicted mel.
Uses transformers' own `generate()` rather than a hand-rolled decoder. An
earlier ONNX export with custom beam search produced single punctuation marks
and empty strings where `generate()` produced fluent sentences from the
identical mel -- the model was being silently crippled by the decoding rather
than by the input.
The mel contract is the subtle part. The checkpoint keeps Whisper's standard
**80-channel** input even though it was fine-tuned on 12-bin prosody-predicted
mels: `num_mel_bins` in the training config truncates incoming mels, it never
resizes conv1. The training collator reconciles the two by zero-padding, and
this module reproduces that exactly:
conformer -> [T, 12] -> transpose -> [12, T] -> zero-pad -> [80, 3000]
Bins 12-79 are zeros. Getting this wrong fails at the first conv layer.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
# Whisper's encoder window: 3000 mel frames at 100 Hz = 30 s.
ENCODER_FRAMES = 3000
ENCODER_MEL_BINS = 80
# The decoder prefix the checkpoint was fine-tuned behind.
#
# Its own forced_decoder_ids is [[1, None], [2, <|transcribe|>]] -- position 1,
# the language slot, is left unset. Decoding without pinning it does not make
# the model choose a language: it skips the slot and starts from
# <|startoftranscript|> alone, so the prefix is two tokens short of what
# training used.
#
# The published inference runs set language: en, and their recorded
# snapshot_meta.json confirms the full prefix
# [50258, 50259, 50359, 50363] = <|sot|> <|en|> <|transcribe|> <|notimestamps|>.
# Matching it is the point: a different prefix is a different model condition,
# so any comparison against those runs is confounded without it.
DEFAULT_LANGUAGE = "en"
# Generation limits the published inference runs used.
DEFAULT_MAX_NEW_TOKENS = 225
DEFAULT_NO_REPEAT_NGRAM = 3
# 10, matching the published inference config for this checkpoint.
#
# Wider is cheap and was tried: 385 ms at 10 beams against 634 ms at 40 on a
# healthy phrase, four times the hypotheses for a quarter more latency. It
# made the transcriptions worse -- a wider beam searches harder for the
# highest-likelihood sequence, and on a signal as weak as a drawn contour
# that is the YouTube-caption boilerplate the checkpoint overlearned. More
# beams find the attractor more reliably rather than finding better words.
DEFAULT_BEAMS = 10
# Phrases Whisper falls back on when the input carries no usable speech.
#
# Trained on YouTube captions, it learned what tends to accompany
# unintelligible audio: "Thank you." for silence, and shouted boilerplate
# from auto-generated or burned-in captions. Feeding it pure noise returns
# "I'M NOT GOING TO SAY ANYTHING" in three of the top four beams; feeding it
# a real drawing returns it in none. Naming it in the UI keeps it from
# reading as a transcription of the drawing.
HALLUCINATION_MARKERS = (
"i'm not going to say anything",
"thank you for watching",
"subscribe",
"thanks for watching",
)
def looks_like_fallback(text: str) -> bool:
"""Whether a hypothesis is the model's no-speech boilerplate."""
lowered = text.strip().lower()
if not lowered:
return False
if len(lowered) > 3 and text.strip().isupper():
return True
return any(marker in lowered for marker in HALLUCINATION_MARKERS)
# Measured across the templates: real speech runs about 4.3 words/sec, p90
# 5.6. The drawing is the only evidence about how long the utterance is, so
# the token budget is derived from its duration rather than left at a flat
# 48 -- otherwise a half-second scribble can come back as a paragraph, which
# is the language model talking rather than a transcription.
WORDS_PER_SECOND = 5.6 # p90, so an unusually fast phrase is not truncated
TOKENS_PER_WORD = 2.5 # Whisper splits a word into roughly this many tokens
MIN_TOKENS = 4
def pad_mel_for_whisper(
mel: np.ndarray,
mel_bins: int = ENCODER_MEL_BINS,
frames: int = ENCODER_FRAMES,
) -> np.ndarray:
"""[T, bins] from the conformer -> [bins_padded, frames] for Whisper.
Transposes to Whisper's [bins, time] layout, then zero-pads both axes.
This mirrors the training collator's `pad_mel_to_expected` branch, so the
model sees the same shape it was fine-tuned on.
"""
mel = np.asarray(mel, dtype=np.float32)
if mel.ndim != 2:
raise ValueError(f"expected a 2-D mel, got {mel.shape}")
# The conformer emits [T, bins]; Whisper wants [bins, T].
mel = mel.T
out = np.zeros((mel_bins, frames), dtype=np.float32)
bins = min(mel.shape[0], mel_bins)
time = min(mel.shape[1], frames)
out[:bins, :time] = mel[:bins, :time]
return out
class Whisper:
"""Transcribes a predicted mel with the fine-tuned checkpoint."""
def __init__(
self,
model_dir: Path | str,
device: str | None = None,
num_beams: int = DEFAULT_BEAMS,
half: bool = False,
tokenizer_dir: Path | str | None = None,
) -> None:
import torch
from transformers import (WhisperForConditionalGeneration,
WhisperTokenizerFast)
model_dir = Path(model_dir)
if not (model_dir / "config.json").exists():
raise FileNotFoundError(
f"{model_dir} is not a Whisper checkpoint directory "
"(no config.json). The weights come from the model repo, so "
"load through it rather than pointing here by hand:\n"
" AutoModel.from_pretrained("
"'Daporte/prosody2text-weights', trust_remote_code=True)"
)
if device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
self.device = torch.device(device)
self.num_beams = num_beams
# fp16 is 1.8x faster (493 ms against 793 ms per phrase at beam 10)
# and halves the VRAM, and it transcribed all twelve templates
# identically -- the acoustic evidence in a drawn contour is far
# coarser than the gap between the precisions. fp32 is the default
# because that is what the published runs used.
#
# CPU stays fp32 regardless: torch has no fast half kernels there, so
# it would be slower, not faster.
self.dtype = (
torch.float16 if half and self.device.type == "cuda" else torch.float32
)
self.model = WhisperForConditionalGeneration.from_pretrained(
model_dir, dtype=self.dtype
)
self.model.eval().to(self.device)
self.mel_bins: int = self.model.config.num_mel_bins
# The checkpoint carries no tokenizer of its own; it is the stock
# whisper-medium one. Loading it from a local directory keeps first
# run offline -- otherwise this is a HuggingFace Hub fetch, which on
# a fresh Space or an air-gapped machine is a startup failure rather
# than a slow import.
source = str(tokenizer_dir) if tokenizer_dir else "openai/whisper-medium"
self.tokenizer = WhisperTokenizerFast.from_pretrained(source)
@property
def provider(self) -> str:
return str(self.device)
def _digit_token_ids(self) -> list[int]:
"""Whisper tokens containing a digit.
Banned under `constrain`: a prosody contour carries no evidence for
"15" over "fifteen", and numerals are a large, acoustically
meaningless slice of the vocabulary. Spelled-out numbers remain
available.
"""
if getattr(self, "_digits", None) is None:
# Decode each id rather than inspecting the raw token: Whisper's
# timestamp markers are spelled "<|24.08|>" and would otherwise
# match on the digits in their name. They decode to nothing and
# are suppressed already.
self._digits = [
index
for index in range(len(self.tokenizer))
if any(c.isdigit() for c in self.tokenizer.decode([index]))
]
return self._digits
@staticmethod
def tokens_for(mel: np.ndarray) -> int:
"""A token budget proportional to the drawing's length.
The mel runs at 100 Hz, so its frame count is the duration. Letting
generation run to a flat limit regardless lets a short scribble come
back as a long sentence, which is the decoder's prior rather than the
drawing's content.
"""
seconds = len(mel) / 100.0
words = seconds * WORDS_PER_SECOND
return max(MIN_TOKENS, int(round(words * TOKENS_PER_WORD)))
def token_budget(self, requested: int, language: str | None) -> int:
"""`requested`, clipped to what the decoder can actually emit.
Whisper's decoder has max_target_positions=448 and counts the prefix
against it, so a budget derived from the input length has to leave
room for the prefix or generate() raises -- which it did on a 4.8 s
clip, where the duration-derived budget came to 478 and the
four-token prefix pushed the total to 482.
"""
limit = int(getattr(self.model.config, "max_target_positions", 448))
prefix = len(self.decoder_prefix(language))
# One spare for EOS, which also occupies a position.
return max(1, min(int(requested), limit - prefix - 1))
def decoder_prefix(self, language: str | None = DEFAULT_LANGUAGE) -> list[int]:
"""The tokens the decoder is primed with before any content.
Built from the tokenizer with the language and task set, which is how
the published runs got theirs: their snapshot_meta.json records
[50258, 50259, 50359, 50363], and this reproduces exactly that.
"""
if language:
# prefix_tokens is a property derived from these two, so set them
# on the tokenizer rather than hand-assembling the sequence.
self.tokenizer.set_prefix_tokens(language=language, task="transcribe")
return [int(t) for t in self.tokenizer.prefix_tokens]
prefix = [self.model.config.decoder_start_token_id]
forced = getattr(self.model.generation_config, "forced_decoder_ids", None)
for _position, token in forced or []:
if token is not None:
prefix.append(int(token))
return prefix
def hypotheses(
self,
mel: np.ndarray,
max_tokens: int | None = None,
num_beams: int | None = None,
top_k: int | None = None,
language: str | None = DEFAULT_LANGUAGE,
no_repeat_ngram_size: int | None = None,
constrain: bool = True,
) -> list[tuple[str, float]]:
"""The beam's ranked candidates as (text, score), best first.
`language` pins the decoder prefix; see DEFAULT_LANGUAGE. Pass None
to leave the checkpoint's own forced_decoder_ids alone, which gives a
two-token-shorter prefix than training used.
On input this uncertain the top hypothesis is often a narrow winner,
so the runners-up say more about what the model heard than the single
best string does.
Scores are HuggingFace's length-normalised sequence log-probabilities
where available (higher is better), and NaN where the capture below
cannot recover them -- the ordering is always meaningful even when the
numbers are not.
"""
import torch
from transformers import GenerationMixin
beams = num_beams or self.num_beams
limits = {}
if language:
limits["language"] = language
# The published runs set no_repeat_ngram_size: 3 and nothing else.
ngram = (DEFAULT_NO_REPEAT_NGRAM if no_repeat_ngram_size is None
else no_repeat_ngram_size)
if ngram:
limits["no_repeat_ngram_size"] = ngram
# `constrain` is what the published dataset runs used: ban numerals,
# and bound the length by the utterance rather than by a flat limit.
# Letting generation run to 225 regardless lets a half-second clip
# come back as a paragraph, which is the decoder's prior talking
# rather than the input.
if constrain:
limits["bad_words_ids"] = [[i] for i in self._digit_token_ids()]
max_tokens = max_tokens or self.tokens_for(mel)
else:
max_tokens = max_tokens or DEFAULT_MAX_NEW_TOKENS
max_tokens = self.token_budget(max_tokens, language)
features = pad_mel_for_whisper(mel, self.mel_bins, ENCODER_FRAMES)
batch = torch.from_numpy(features).unsqueeze(0).to(self.device, self.dtype)
if beams <= 1:
with torch.no_grad():
ids = self.model.generate(
batch, num_beams=1, max_new_tokens=max_tokens, **limits
)
return [(self.tokenizer.batch_decode(
ids, skip_special_tokens=True)[0].strip(), float("nan"))]
# Whisper's generate() wrapper keeps only the top beam and does not
# honour num_return_sequences; asking it for all of them inflates the
# decoder cache until a 24 GB GPU runs out. The approach here is the
# one the reference inference script uses: patch
# GenerationMixin.generate, set num_return_sequences on the inner
# call, capture every beam, and hand Whisper back the single sequence
# it expects.
captured: list = []
original = GenerationMixin.generate
def capturing(model_self, input_features, **kwargs):
config = kwargs.get("generation_config")
width = config.num_beams if config else 1
if width <= 1:
return original(model_self, input_features, **kwargs)
config.num_return_sequences = width
# sequences_scores only comes back when the inner call is asked
# for a dict; Whisper's wrapper does not always pass this down.
kwargs["return_dict_in_generate"] = True
# ...and, since transformers 5, only when scores are explicitly
# requested. Without this the attribute is present but None, so
# every hypothesis came back scored NaN -- the ranking survived,
# being the beam's own order, but the numbers beside it were
# gone and nothing said so.
kwargs["output_scores"] = True
out = original(model_self, input_features, **kwargs)
config.num_return_sequences = 1
seqs = out.sequences if hasattr(out, "sequences") else out
captured.append((seqs, getattr(out, "sequences_scores", None)))
top1 = seqs[::width]
if hasattr(out, "sequences"):
out.sequences = top1
else:
out = top1
return out
GenerationMixin.generate = capturing
try:
with torch.no_grad():
self.model.generate(
batch,
num_beams=beams,
max_new_tokens=max_tokens,
return_dict_in_generate=True,
**limits,
)
finally:
GenerationMixin.generate = original
if not captured:
return []
seqs, scores = captured[0]
texts = self.tokenizer.batch_decode(seqs, skip_special_tokens=True)
values = (
scores.tolist() if scores is not None
else [float("nan")] * len(texts)
)
ranked = [(t.strip(), s) for t, s in zip(texts, values)]
return ranked[:top_k] if top_k else ranked
|