Daporte commited on
Commit
a645265
·
verified ·
1 Parent(s): ebcf60e

Upload whisper_decoder.py with huggingface_hub

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