kehanlu commited on
Commit
3db44c2
·
verified ·
1 Parent(s): d9c9969

rename selfgen_only -> selfgen, add asr_gender (388180)

Browse files
Files changed (6) hide show
  1. README.md +34 -14
  2. asr_gender/model.ckpt +3 -0
  3. code/data.py +20 -38
  4. code/inference.py +18 -28
  5. code/modeling.py +14 -95
  6. selfgen/model.ckpt +3 -0
README.md CHANGED
@@ -10,24 +10,41 @@ Whisper-large-v3 encoder (frozen) → concat+MLP adapter → Qwen3-4B-Instruct-2
10
  Only the adapter and the LoRA weights are trained, so each checkpoint is ~147 MB; the base
11
  models are downloaded from their own repos at load time.
12
 
13
- | folder | training data | test-clean WER | test-clean gender |
 
 
14
  |---|---|---|---|
15
- | `selfgen_only` | 281k self-generated conversational responses (no task labels) | 3.93 (best prompt + post-processing) | 98.24 |
16
- | `selfgen_mix` | the same + 20% ASR + 5% gender SFT | 2.45 | 98.82 |
17
 
18
- The self-generation targets were written by Qwen3-4B given only the transcript and the
19
- speaker's gender: `<audio>{transcription} (Gender: {gender})</audio>` + "The audio is a
20
- passage read aloud from a book. Respond directly as a natural conversation partner. Do not
21
- mention the audio, the transcription, or the speaker attributes."
22
 
23
- `selfgen_only` has never seen a transcription instruction: it can transcribe, but needs an
24
- explicit prompt ("Transcribe the speech word for word. Output only the transcription …") and
25
- sometimes answers in the training-time metadata format. `selfgen_mix` follows the plain
26
- prompts directly.
27
 
28
- ## Usage
 
 
 
 
 
 
 
 
29
 
30
- See the Colab notebook in the tutorial repo, or:
 
 
 
 
 
 
 
 
 
 
31
 
32
  ```python
33
  from huggingface_hub import hf_hub_download, snapshot_download
@@ -37,9 +54,12 @@ code = snapshot_download("kehanlu/interspeech-tutorial", allow_patterns="code/*"
37
  sys.path.insert(0, f"{code}/code")
38
  from inference import SpeechLLMForInference
39
 
40
- ckpt = hf_hub_download("kehanlu/interspeech-tutorial", "selfgen_mix/model.ckpt")
41
  pipe = SpeechLLMForInference.from_checkpoint(ckpt, dtype=torch.float16) # float16 for a Colab T4
42
  print(pipe.generate([{"role": "user",
43
  "content": "<audio><|AUDIO|></audio>\n\nTranscribe the speech into text",
44
  "audios": [{"audio": "sample.flac"}]}]))
45
  ```
 
 
 
 
10
  Only the adapter and the LoRA weights are trained, so each checkpoint is ~147 MB; the base
11
  models are downloaded from their own repos at load time.
12
 
13
+ Code, configs and the full recipe: <https://github.com/kehanlu/interspeech-tutorial>
14
+
15
+ | folder | training data | test-clean ASR | test-clean gender |
16
  |---|---|---|---|
17
+ | `asr_gender` | 281k ASR + a fresh 30% of the gender rows each epoch | **1.81** WER | **98.85** |
18
+ | `selfgen` | 281k self-generated conversational replies, no task labels | 3.93 WER | 98.24 |
19
 
20
+ `asr_gender` is ordinary task SFT, and it matches whisper-large-v3 on ASR (1.89) while also
21
+ answering the gender question. It is the baseline.
 
 
22
 
23
+ `selfgen` is the interesting one: its targets were written by Qwen3-4B given only the
24
+ transcript and the speaker's gender, so **it has never seen a transcription or a gender
25
+ label as a training target**. The self-generation prompt was
 
26
 
27
+ <audio>{transcription} (Gender: {gender})</audio>
28
+
29
+ The audio is a passage read aloud from a book. Respond directly as a natural
30
+ conversation partner. Do not mention the audio, the transcription, or the speaker
31
+ attributes.
32
+
33
+ It can still do both tasks, but only if the prompt leaves room for a short answer. Asked the
34
+ way `asr_gender` was trained ("Transcribe the speech into text") it replies with an essay
35
+ about the passage and scores 52.35 WER; asked for a format it reaches 3.93:
36
 
37
+ | prompt | ASR |
38
+ |---|---|
39
+ | `Transcribe the speech into text` | 52.35 → 16.54 after clean-up |
40
+ | `Transcribe the speech word for word. Output only the transcription, with no explanation, in this format:\nAnswer: "<transcription>"` | 8.94 → **3.93** |
41
+
42
+ Gender goes 81.87 → **98.24** the same way, with "The audio is a passage read aloud from a
43
+ book. Is the speaker male or female? Answer with one word." The clean-up is the rule-based
44
+ `postprocess()` in the tutorial repo's `example/evaluate/evaluate_asr.py`; it is a no-op on
45
+ `asr_gender`, which already answers with a bare transcript.
46
+
47
+ ## Usage
48
 
49
  ```python
50
  from huggingface_hub import hf_hub_download, snapshot_download
 
54
  sys.path.insert(0, f"{code}/code")
55
  from inference import SpeechLLMForInference
56
 
57
+ ckpt = hf_hub_download("kehanlu/interspeech-tutorial", "selfgen/model.ckpt")
58
  pipe = SpeechLLMForInference.from_checkpoint(ckpt, dtype=torch.float16) # float16 for a Colab T4
59
  print(pipe.generate([{"role": "user",
60
  "content": "<audio><|AUDIO|></audio>\n\nTranscribe the speech into text",
61
  "audios": [{"audio": "sample.flac"}]}]))
62
  ```
63
+
64
+ A few LibriSpeech dev-clean clips are in `samples/` with their transcripts in
65
+ `samples/samples.json`.
asr_gender/model.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2b29485636775546d881df994666f4f6a2e68bc7548b75303a13279948e9bb19
3
+ size 146924946
code/data.py CHANGED
@@ -1,7 +1,6 @@
1
  """
2
  Data pipeline for the minimal SpeechLLM.
3
 
4
- The one idea to hold on to while reading this file: **the LLM never sees audio.**
5
  It sees a plain text token sequence in which every ``<|AUDIO|>`` has been replaced
6
  by exactly as many placeholder tokens as that audio will occupy after encoding.
7
  The model later overwrites those placeholders' *embeddings* with real speech
@@ -24,7 +23,7 @@ Manifest format (one JSON object per line)::
24
  "target": "A wooden door being knocked on."
25
  }
26
 
27
- `audio_filepath` is relative to `data_root`. `target` may also be called
28
  `response` (both appear in the DeSTA3 manifests). Extra keys are ignored.
29
  """
30
 
@@ -45,32 +44,25 @@ logger = logging.getLogger(__name__)
45
  SAMPLE_RATE = 16000
46
 
47
 
48
- # --------------------------------------------------------------------------
49
  # How many tokens will one audio occupy?
50
  #
51
  # Whisper's feature extractor always pads to 30 s, so we cannot use the padded
52
  # length -- a 5 s clip would reserve 25 s worth of slots. We ask the feature
53
  # extractor for an attention mask over the mel frames instead, and push the true
54
- # length through the same conv arithmetic the model uses.
55
- #
56
- # These formulas mirror Qwen2AudioEncoder._get_feat_extract_output_lengths.
57
- # --------------------------------------------------------------------------
58
 
59
  def whisper_frames(mel_len: int) -> int:
60
  """Whisper's encoder conv2 has stride 2. 3000 mel frames -> 1500 positions."""
61
  return (mel_len - 1) // 2 + 1
62
 
63
 
64
- def conv1d_out(length: int, kernel: int = 3, stride: int = 2, padding: int = 1) -> int:
65
- return (length + 2 * padding - kernel) // stride + 1
66
-
67
 
68
- def audio_token_count(mel_len: int, n_downsample_layers: int) -> int:
69
- """mel frames -> number of LLM token slots this audio needs."""
70
- length = whisper_frames(mel_len)
71
- for _ in range(n_downsample_layers):
72
- length = conv1d_out(length)
73
- return length
74
 
75
 
76
  def expand_audio_locator(tokens: List[str], audio_locator: str,
@@ -140,25 +132,15 @@ def manifest_name(path: str) -> str:
140
 
141
  class AudioTextDataset(Dataset):
142
  """Lazy jsonl reader with per-manifest mixing ratios.
143
-
144
- The DeSTA3 manifests run to hundreds of MB each, so we build a byte-offset
145
- index up front and read one line at a time instead of holding the parsed
146
- rows in memory.
147
-
148
- `ratios[i]` scales manifest i: 0.3 keeps a random 30% of its rows, 2.5 keeps two
149
- full copies plus a random 50%. The random part is drawn from (seed, epoch,
150
- manifest) only -- never the rank -- so every DDP rank builds the same index
151
- and DistributedSampler shards it consistently. Call `resample(epoch)` to draw a
152
- fresh subset for a new epoch; the length does not change.
153
  """
154
 
155
- def __init__(self, manifest_filepaths, data_root: str, ratios=None, seed: int = 0):
156
- self.data_root = data_root
157
  self.paths = [resolve_manifest(p) for p in manifest_filepaths]
158
  self.ratios = list(ratios) if ratios is not None else [1.0] * len(self.paths)
159
  assert len(self.ratios) == len(self.paths)
160
  self.seed = seed
161
- self.rows = [] # per manifest: [(file_idx, byte_offset), ...]
162
  for file_idx, path in enumerate(self.paths):
163
  rows = []
164
  with open(path, "rb") as f:
@@ -168,7 +150,7 @@ class AudioTextDataset(Dataset):
168
  rows.append((file_idx, offset))
169
  offset += len(line)
170
  self.rows.append(rows)
171
- self._handles = {} # opened lazily, per dataloader worker
172
  self.resample(epoch=0, log=True)
173
 
174
  @property
@@ -177,7 +159,7 @@ class AudioTextDataset(Dataset):
177
  return any(r != math.floor(r) for r in self.ratios)
178
 
179
  def resample(self, epoch: int, log: bool = False):
180
- self.index = [] # (file_idx, byte_offset)
181
  for file_idx, (rows, ratio) in enumerate(zip(self.rows, self.ratios)):
182
  whole = math.floor(ratio)
183
  n_extra = round((ratio - whole) * len(rows))
@@ -206,7 +188,7 @@ class AudioTextDataset(Dataset):
206
  assert target, f"row {i} has neither `response` nor `target`"
207
 
208
  audios = [{"audio_filepath": resolve_audio_filepath(
209
- os.path.join(self.data_root, audio["audio_filepath"]))}
210
  for audio in row["audios"]]
211
  return {"messages": row["messages"], "target": target, "audios": audios}
212
 
@@ -226,7 +208,7 @@ class Collator:
226
  placeholder_token: str = "<|vision_pad|>"
227
  max_seq_length: int = 1024
228
  max_audio_seconds: float = 30.0
229
- n_downsample_layers: int = 1
230
  # inference: build the prompt only, with no answer appended and no labels.
231
  # Same slot arithmetic as training -- that is the whole point of reusing this
232
  # class rather than rebuilding the expansion in the demo script.
@@ -253,7 +235,7 @@ class Collator:
253
  return_tensors="pt", return_attention_mask=True,
254
  )
255
  mel_lengths = features["attention_mask"].sum(-1).tolist()
256
- audio_lengths = [audio_token_count(n, self.n_downsample_layers) for n in mel_lengths]
257
 
258
  # 2. text -> token ids, with <|AUDIO|> expanded to placeholders
259
  context_ids, target_ids = [], []
@@ -280,7 +262,7 @@ class Collator:
280
  # placeholder can never be re-tokenized into something else
281
  ctx = self.tokenizer.convert_tokens_to_ids(tokens)
282
  if self.for_generation:
283
- tgt = [] # nothing to condition on; the model writes it
284
  else:
285
  tgt = self.tokenizer.encode(row["target"], add_special_tokens=False)
286
  tgt = tgt + [self.tokenizer.eos_token_id]
@@ -288,7 +270,7 @@ class Collator:
288
  assert len(ctx) < self.max_seq_length, (
289
  f"prompt alone is {len(ctx)} tokens (max_seq_length="
290
  f"{self.max_seq_length}); shorten the audio or raise the limit")
291
- tgt = tgt[:self.max_seq_length - len(ctx)] # only ever truncate the answer
292
 
293
  context_ids.append(ctx)
294
  target_ids.append(tgt)
@@ -315,6 +297,6 @@ class Collator:
315
  "attention_mask": attention_mask,
316
  "labels": labels,
317
  "input_features": features["input_features"],
318
- "audio_lengths": audio_lengths, # per audio, after downsampling
319
- "start_positions": shifted_starts, # per audio, (row, first placeholder)
320
  }
 
1
  """
2
  Data pipeline for the minimal SpeechLLM.
3
 
 
4
  It sees a plain text token sequence in which every ``<|AUDIO|>`` has been replaced
5
  by exactly as many placeholder tokens as that audio will occupy after encoding.
6
  The model later overwrites those placeholders' *embeddings* with real speech
 
23
  "target": "A wooden door being knocked on."
24
  }
25
 
26
+ `audio_filepath` is relative to `audio_root`. `target` may also be called
27
  `response` (both appear in the DeSTA3 manifests). Extra keys are ignored.
28
  """
29
 
 
44
  SAMPLE_RATE = 16000
45
 
46
 
 
47
  # How many tokens will one audio occupy?
48
  #
49
  # Whisper's feature extractor always pads to 30 s, so we cannot use the padded
50
  # length -- a 5 s clip would reserve 25 s worth of slots. We ask the feature
51
  # extractor for an attention mask over the mel frames instead, and push the true
52
+ # length through the same downsampling arithmetic the model uses.
 
 
 
53
 
54
  def whisper_frames(mel_len: int) -> int:
55
  """Whisper's encoder conv2 has stride 2. 3000 mel frames -> 1500 positions."""
56
  return (mel_len - 1) // 2 + 1
57
 
58
 
59
+ def audio_token_count(mel_len: int, n_downsample: int) -> int:
60
+ """mel frames -> number of LLM token slots this audio needs.
 
61
 
62
+ The connector concatenates every `n_downsample` adjacent frames and right-pads to
63
+ a multiple of that, so the count is ceil(frames / n_downsample), rounding up.
64
+ """
65
+ return -(-whisper_frames(mel_len) // n_downsample)
 
 
66
 
67
 
68
  def expand_audio_locator(tokens: List[str], audio_locator: str,
 
132
 
133
  class AudioTextDataset(Dataset):
134
  """Lazy jsonl reader with per-manifest mixing ratios.
 
 
 
 
 
 
 
 
 
 
135
  """
136
 
137
+ def __init__(self, manifest_filepaths, audio_root: str, ratios=None, seed: int = 0):
138
+ self.audio_root = audio_root
139
  self.paths = [resolve_manifest(p) for p in manifest_filepaths]
140
  self.ratios = list(ratios) if ratios is not None else [1.0] * len(self.paths)
141
  assert len(self.ratios) == len(self.paths)
142
  self.seed = seed
143
+ self.rows = [] # per manifest: [(file_idx, byte_offset), ...]
144
  for file_idx, path in enumerate(self.paths):
145
  rows = []
146
  with open(path, "rb") as f:
 
150
  rows.append((file_idx, offset))
151
  offset += len(line)
152
  self.rows.append(rows)
153
+ self._handles = {} # opened lazily, per dataloader worker
154
  self.resample(epoch=0, log=True)
155
 
156
  @property
 
159
  return any(r != math.floor(r) for r in self.ratios)
160
 
161
  def resample(self, epoch: int, log: bool = False):
162
+ self.index = [] # (file_idx, byte_offset)
163
  for file_idx, (rows, ratio) in enumerate(zip(self.rows, self.ratios)):
164
  whole = math.floor(ratio)
165
  n_extra = round((ratio - whole) * len(rows))
 
188
  assert target, f"row {i} has neither `response` nor `target`"
189
 
190
  audios = [{"audio_filepath": resolve_audio_filepath(
191
+ os.path.join(self.audio_root, audio["audio_filepath"]))}
192
  for audio in row["audios"]]
193
  return {"messages": row["messages"], "target": target, "audios": audios}
194
 
 
208
  placeholder_token: str = "<|vision_pad|>"
209
  max_seq_length: int = 1024
210
  max_audio_seconds: float = 30.0
211
+ n_downsample: int = 2
212
  # inference: build the prompt only, with no answer appended and no labels.
213
  # Same slot arithmetic as training -- that is the whole point of reusing this
214
  # class rather than rebuilding the expansion in the demo script.
 
235
  return_tensors="pt", return_attention_mask=True,
236
  )
237
  mel_lengths = features["attention_mask"].sum(-1).tolist()
238
+ audio_lengths = [audio_token_count(n, self.n_downsample) for n in mel_lengths]
239
 
240
  # 2. text -> token ids, with <|AUDIO|> expanded to placeholders
241
  context_ids, target_ids = [], []
 
262
  # placeholder can never be re-tokenized into something else
263
  ctx = self.tokenizer.convert_tokens_to_ids(tokens)
264
  if self.for_generation:
265
+ tgt = [] # nothing to condition on; the model writes it
266
  else:
267
  tgt = self.tokenizer.encode(row["target"], add_special_tokens=False)
268
  tgt = tgt + [self.tokenizer.eos_token_id]
 
270
  assert len(ctx) < self.max_seq_length, (
271
  f"prompt alone is {len(ctx)} tokens (max_seq_length="
272
  f"{self.max_seq_length}); shorten the audio or raise the limit")
273
+ tgt = tgt[:self.max_seq_length - len(ctx)] # only ever truncate the answer
274
 
275
  context_ids.append(ctx)
276
  target_ids.append(tgt)
 
297
  "attention_mask": attention_mask,
298
  "labels": labels,
299
  "input_features": features["input_features"],
300
+ "audio_lengths": audio_lengths, # per audio, after downsampling
301
+ "start_positions": shifted_starts, # per audio, (row, first placeholder)
302
  }
code/inference.py CHANGED
@@ -8,20 +8,8 @@ Inference for the minimal SpeechLLM -- the tutorial's demo entry point.
8
  # score a manifest: prints reference vs hypothesis side by side
9
  python inference.py --ckpt exp/run/checkpoints/last.ckpt \
10
  --manifest data/Librispeech-dev-test/test-clean_asr.jsonl \
11
- --data-root /home/u8915687/work/data/audios2 --limit 20
12
-
13
- The architecture is read back out of the checkpoint's `hyper_parameters`, never
14
- hand-written here. That matters more than it looks: `SpeechLLMModule` loads with
15
- `strict=False`, and the connector's downsampling stack is an `nn.Sequential`
16
- whose keys are positional (`downsample.0.*`, `downsample.2.*`). Build the model
17
- with the wrong `n_downsample_layers` and those tensors become "missing keys",
18
- which `strict=False` silently drops -- you get a randomly-initialised connector,
19
- no error, and fluent nonsense out. `_load_trainable` below turns that into a
20
- hard failure instead.
21
-
22
- Unlike DeSTA3 there is no VAD and no ASR decoder in the loop: DeSTA3 runs Whisper's
23
- decoder to produce a transcript, and needs VAD to decide which clips are speech
24
- at all. Here the LLM only sees the speech features, exactly as in training.
25
  """
26
 
27
  import argparse
@@ -57,7 +45,7 @@ class SpeechLLMForInference:
57
  self.model, self.tokenizer = model, tokenizer
58
  self.collator, self.device, self.dtype = collator, device, dtype
59
 
60
- # -- loading ----------------------------------------------------------
61
  @classmethod
62
  def from_checkpoint(cls, ckpt_path, device=None, dtype=None):
63
  device = device or ("cuda" if torch.cuda.is_available() else "cpu")
@@ -66,9 +54,12 @@ class SpeechLLMForInference:
66
  dtype = dtype or getattr(torch, hp["dtype"])
67
  logger.info("checkpoint %s (epoch %s, step %s)",
68
  os.path.basename(ckpt_path), ckpt.get("epoch"), ckpt.get("global_step"))
69
- logger.info(" llm=%s encoder=%s adapter=%s n_downsample=%d frozen_encoder=%s",
70
- hp["llm_id"], hp["encoder_id"], hp.get("adapter_type", "cnn"),
71
- hp["n_downsample_layers"], hp.get("freeze_encoder", False))
 
 
 
72
 
73
  # rebuilt exactly as SpeechLLMModule.__init__ does, or the ids shift
74
  tokenizer = AutoTokenizer.from_pretrained(hp["llm_id"])
@@ -79,9 +70,8 @@ class SpeechLLMForInference:
79
 
80
  model = SpeechLLM(
81
  llm_id=hp["llm_id"], encoder_id=hp["encoder_id"],
82
- n_downsample_layers=hp["n_downsample_layers"], dtype=dtype,
83
- # .get(): checkpoints from before these options were cnn + trained encoder
84
- adapter_type=hp.get("adapter_type", "cnn"),
85
  adapter_hidden_dim=hp.get("adapter_hidden_dim", 0),
86
  freeze_encoder=hp.get("freeze_encoder", False), use_lora=True,
87
  lora_rank=hp["lora_rank"], lora_alpha=hp["lora_alpha"],
@@ -95,7 +85,7 @@ class SpeechLLMForInference:
95
  tokenizer=tokenizer, feature_extractor=feature_extractor,
96
  audio_locator=hp["audio_locator"], placeholder_token=hp["placeholder_token"],
97
  max_seq_length=hp["max_seq_length"], max_audio_seconds=hp["max_audio_seconds"],
98
- n_downsample_layers=hp["n_downsample_layers"],
99
  for_generation=True,
100
  )
101
  return cls(model, tokenizer, collator, device, dtype)
@@ -117,7 +107,7 @@ class SpeechLLMForInference:
117
  logger.info(" restored %d trainable tensors (%.1fM params)", len(state),
118
  sum(v.numel() for v in state.values()) / 1e6)
119
 
120
- # -- generation -------------------------------------------------------
121
  def _to_rows(self, conversations):
122
  """DeSTA3-style messages -> the row dicts `Collator` consumes."""
123
  rows = []
@@ -140,7 +130,7 @@ class SpeechLLMForInference:
140
  @torch.no_grad()
141
  def generate(self, conversations, max_new_tokens=200, do_sample=False, **generation_kwargs):
142
  if conversations and isinstance(conversations[0], dict):
143
- conversations = [conversations] # a single conversation
144
  batch = self.collator(self._to_rows(conversations))
145
 
146
  batch["input_ids"] = batch["input_ids"].to(self.device)
@@ -163,7 +153,7 @@ class SpeechLLMForInference:
163
  self.tokenizer.batch_decode(generated, skip_special_tokens=True)]
164
 
165
 
166
- # ------------------------------------------------------------------ main ---
167
 
168
  def main():
169
  ap = argparse.ArgumentParser(description=__doc__,
@@ -171,7 +161,7 @@ def main():
171
  ap.add_argument("--ckpt", required=True)
172
  ap.add_argument("--audio", nargs="+", help="one or more audio files")
173
  ap.add_argument("--manifest", help="jsonl to run over instead of --audio")
174
- ap.add_argument("--data-root", default="")
175
  ap.add_argument("--limit", type=int, default=10, help="rows to take from --manifest")
176
  ap.add_argument("--prompt", default=DEFAULT_PROMPT)
177
  ap.add_argument("--batch-size", type=int, default=4)
@@ -183,7 +173,7 @@ def main():
183
  pipe = SpeechLLMForInference.from_checkpoint(args.ckpt, device=args.device)
184
 
185
  if args.audio:
186
- items = [(os.path.join(args.data_root, p), None) for p in args.audio]
187
  else:
188
  items = []
189
  with open(args.manifest) as f:
@@ -191,7 +181,7 @@ def main():
191
  if len(items) >= args.limit:
192
  break
193
  row = json.loads(line)
194
- items.append((os.path.join(args.data_root,
195
  row["audios"][0]["audio_filepath"]),
196
  row.get("response") or row.get("target")))
197
 
 
8
  # score a manifest: prints reference vs hypothesis side by side
9
  python inference.py --ckpt exp/run/checkpoints/last.ckpt \
10
  --manifest data/Librispeech-dev-test/test-clean_asr.jsonl \
11
+ --audio-root /path/to/data --limit 20
12
+
 
 
 
 
 
 
 
 
 
 
 
 
13
  """
14
 
15
  import argparse
 
45
  self.model, self.tokenizer = model, tokenizer
46
  self.collator, self.device, self.dtype = collator, device, dtype
47
 
48
+ # -- loading --
49
  @classmethod
50
  def from_checkpoint(cls, ckpt_path, device=None, dtype=None):
51
  device = device or ("cuda" if torch.cuda.is_available() else "cpu")
 
54
  dtype = dtype or getattr(torch, hp["dtype"])
55
  logger.info("checkpoint %s (epoch %s, step %s)",
56
  os.path.basename(ckpt_path), ckpt.get("epoch"), ckpt.get("global_step"))
57
+ # n_downsample used to be n_downsample_layers, an exponent: 1 meant 2 frames
58
+ # per token. Map the old key so earlier checkpoints still load.
59
+ n_downsample = hp.get("n_downsample") or 2 ** hp["n_downsample_layers"]
60
+ logger.info(" llm=%s encoder=%s n_downsample=%d frozen_encoder=%s",
61
+ hp["llm_id"], hp["encoder_id"],
62
+ n_downsample, hp.get("freeze_encoder", False))
63
 
64
  # rebuilt exactly as SpeechLLMModule.__init__ does, or the ids shift
65
  tokenizer = AutoTokenizer.from_pretrained(hp["llm_id"])
 
70
 
71
  model = SpeechLLM(
72
  llm_id=hp["llm_id"], encoder_id=hp["encoder_id"],
73
+ n_downsample=n_downsample, dtype=dtype,
74
+ # .get(): older checkpoints predate these options
 
75
  adapter_hidden_dim=hp.get("adapter_hidden_dim", 0),
76
  freeze_encoder=hp.get("freeze_encoder", False), use_lora=True,
77
  lora_rank=hp["lora_rank"], lora_alpha=hp["lora_alpha"],
 
85
  tokenizer=tokenizer, feature_extractor=feature_extractor,
86
  audio_locator=hp["audio_locator"], placeholder_token=hp["placeholder_token"],
87
  max_seq_length=hp["max_seq_length"], max_audio_seconds=hp["max_audio_seconds"],
88
+ n_downsample=n_downsample,
89
  for_generation=True,
90
  )
91
  return cls(model, tokenizer, collator, device, dtype)
 
107
  logger.info(" restored %d trainable tensors (%.1fM params)", len(state),
108
  sum(v.numel() for v in state.values()) / 1e6)
109
 
110
+ # -- generation --
111
  def _to_rows(self, conversations):
112
  """DeSTA3-style messages -> the row dicts `Collator` consumes."""
113
  rows = []
 
130
  @torch.no_grad()
131
  def generate(self, conversations, max_new_tokens=200, do_sample=False, **generation_kwargs):
132
  if conversations and isinstance(conversations[0], dict):
133
+ conversations = [conversations] # a single conversation
134
  batch = self.collator(self._to_rows(conversations))
135
 
136
  batch["input_ids"] = batch["input_ids"].to(self.device)
 
153
  self.tokenizer.batch_decode(generated, skip_special_tokens=True)]
154
 
155
 
156
+ # -- main --
157
 
158
  def main():
159
  ap = argparse.ArgumentParser(description=__doc__,
 
161
  ap.add_argument("--ckpt", required=True)
162
  ap.add_argument("--audio", nargs="+", help="one or more audio files")
163
  ap.add_argument("--manifest", help="jsonl to run over instead of --audio")
164
+ ap.add_argument("--audio-root", default="")
165
  ap.add_argument("--limit", type=int, default=10, help="rows to take from --manifest")
166
  ap.add_argument("--prompt", default=DEFAULT_PROMPT)
167
  ap.add_argument("--batch-size", type=int, default=4)
 
173
  pipe = SpeechLLMForInference.from_checkpoint(args.ckpt, device=args.device)
174
 
175
  if args.audio:
176
+ items = [(os.path.join(args.audio_root, p), None) for p in args.audio]
177
  else:
178
  items = []
179
  with open(args.manifest) as f:
 
181
  if len(items) >= args.limit:
182
  break
183
  row = json.loads(line)
184
+ items.append((os.path.join(args.audio_root,
185
  row["audios"][0]["audio_filepath"]),
186
  row.get("response") or row.get("target")))
187
 
code/modeling.py CHANGED
@@ -4,15 +4,6 @@ A minimal SpeechLLM: Whisper encoder + connector + LLM with LoRA.
4
  audio ──► Whisper encoder (trained or frozen) ──► connector (trained) ──┐
5
  ├──► LLM + LoRA ──► text
6
  text ────────────────────────────────────────► embedding table ────────┘
7
-
8
- Two connectors (`adapter_type`), both 50 fps -> 25 fps per downsampling step:
9
- cnn strided Conv1d + GELU, then LayerNorm + Linear
10
- concat_mlp concatenate adjacent frames, then LayerNorm + Linear + GELU + Linear
11
-
12
- A frozen LLM does *not* make training cheap: the speech features enter at layer 0,
13
- so gradients still flow back through every LLM layer to reach the connector, and
14
- every LLM activation has to be kept for the backward pass. Turn on
15
- `gradient_checkpointing` if that hurts.
16
  """
17
 
18
  import logging
@@ -24,74 +15,36 @@ from transformers import AutoModelForCausalLM, WhisperModel
24
  logger = logging.getLogger(__name__)
25
 
26
 
27
- class SpeechConnector(nn.Module):
28
- """Whisper hidden states → LLM-width embeddings.
29
-
30
- Takes the encoder's last hidden state and applies two steps:
31
-
32
- 1. **Strided Conv1d downsampling.** Whisper emits 50 frames/s, which is far
33
- too many tokens to hand an LLM. One stride-2 convolution brings that to
34
- 25 frames/s -- 40 ms per token, the same rate as Qwen2-Audio. Each extra
35
- layer halves the rate again.
36
-
37
- 2. **Linear projection to the LLM's width** (from Qwen2-Audio, whose entire
38
- connector is ``AvgPool1d(2)`` + ``Linear``).
39
- """
40
-
41
- def __init__(self, d_encoder: int, d_llm: int, n_downsample_layers: int = 1):
42
- super().__init__()
43
- layers = []
44
- for _ in range(n_downsample_layers):
45
- layers += [nn.Conv1d(d_encoder, d_encoder, kernel_size=3, stride=2, padding=1),
46
- nn.GELU()]
47
- self.downsample = nn.Sequential(*layers)
48
-
49
- self.proj = nn.Sequential(nn.LayerNorm(d_encoder), nn.Linear(d_encoder, d_llm))
50
-
51
- def forward(self, hidden_states): # (B, T, d_encoder)
52
- hidden_states = self.downsample(hidden_states.transpose(1, 2)).transpose(1, 2)
53
- return self.proj(hidden_states) # (B, T', d_llm)
54
-
55
-
56
  class ConcatMLPConnector(nn.Module):
57
  """Whisper hidden states → LLM-width embeddings by frame concatenation.
58
-
59
- Every `2 ** n_downsample_layers` adjacent frames are concatenated into one
60
- feature (d_encoder * k), which a two-layer MLP maps to the LLM's width. This is
61
- the SLAM-ASR / LLaVA-style projector: no convolution, all mixing happens in the
62
- MLP. The sequence is right-padded to a multiple of k, so a length-L input gives
63
- ceil(L / k) tokens -- the same count as the CNN, so `data.audio_token_count`
64
- serves both.
65
  """
66
 
67
- def __init__(self, d_encoder: int, d_llm: int, n_downsample_layers: int = 1,
68
  hidden_dim: int = 0):
69
  super().__init__()
70
- self.k = 2 ** n_downsample_layers
71
  hidden_dim = hidden_dim or d_llm
72
  self.proj = nn.Sequential(
73
- nn.LayerNorm(d_encoder * self.k),
74
- nn.Linear(d_encoder * self.k, hidden_dim),
75
  nn.GELU(),
76
  nn.Linear(hidden_dim, d_llm),
77
  )
78
 
79
  def forward(self, hidden_states): # (B, T, d_encoder)
80
  B, T, D = hidden_states.shape
81
- pad = (-T) % self.k
 
82
  if pad:
83
  hidden_states = nn.functional.pad(hidden_states, (0, 0, 0, pad))
84
- stacked = hidden_states.reshape(B, (T + pad) // self.k, D * self.k)
85
  return self.proj(stacked) # (B, ceil(T/k), d_llm)
86
 
87
 
88
- ADAPTERS = {"cnn": SpeechConnector, "concat_mlp": ConcatMLPConnector}
89
-
90
-
91
  class SpeechLLM(nn.Module):
92
  def __init__(self, llm_id: str, encoder_id: str,
93
- n_downsample_layers: int = 1, dtype=torch.bfloat16,
94
- adapter_type: str = "cnn", adapter_hidden_dim: int = 0,
95
  gradient_checkpointing: bool = False, cache_dir=None,
96
  freeze_encoder: bool = True, use_lora: bool = False,
97
  lora_rank: int = 32, lora_alpha: int = 64, lora_dropout: float = 0.05,
@@ -104,25 +57,15 @@ class SpeechLLM(nn.Module):
104
  self.encoder = WhisperModel.from_pretrained(
105
  encoder_id, torch_dtype=dtype, cache_dir=cache_dir).encoder
106
 
107
- assert adapter_type in ADAPTERS, f"adapter_type must be one of {list(ADAPTERS)}"
108
- extra = {"hidden_dim": adapter_hidden_dim} if adapter_type == "concat_mlp" else {}
109
- self.connector = ADAPTERS[adapter_type](
110
  d_encoder=self.encoder.config.d_model,
111
  d_llm=self.llm.config.hidden_size,
112
- n_downsample_layers=n_downsample_layers,
113
- **extra,
114
  )
115
- logger.info("adapter %s: %.1fM params", adapter_type,
116
  sum(p.numel() for p in self.connector.parameters()) / 1e6)
117
 
118
- # --- what trains ---------------------------------------------------
119
- # The connector always does. The LLM trains only through LoRA adapters,
120
- # if at all. The encoder is a switch: leaving it frozen is much cheaper
121
- # than it looks, because with every encoder parameter frozen and
122
- # `input_features` not requiring grad, PyTorch builds no graph for the
123
- # encoder at all -- its activations are never stored. Unfreezing it
124
- # turns 32 Whisper layers back into stored activations, which costs far
125
- # more memory than the 635M parameters themselves.
126
  for param in self.llm.parameters():
127
  param.requires_grad = False
128
 
@@ -134,8 +77,7 @@ class SpeechLLM(nn.Module):
134
  target_modules=list(lora_target_modules), bias="none",
135
  task_type="CAUSAL_LM",
136
  )
137
- # unwrap back to a plain module: we call the LLM directly (with
138
- # inputs_embeds) rather than through the PeftModel wrapper
139
  self.llm = get_peft_model(self.llm, lora_config).base_model.model
140
  n_lora = sum(p.numel() for n, p in self.llm.named_parameters() if "lora_" in n)
141
  assert n_lora > 0, f"LoRA matched no modules in {lora_target_modules}"
@@ -146,25 +88,12 @@ class SpeechLLM(nn.Module):
146
  for param in self.encoder.parameters():
147
  param.requires_grad = not freeze_encoder
148
 
149
- # Standard mixed precision: frozen weights stay in `dtype`, everything that
150
- # trains is stored in float32 and autocast does the bf16 compute. bf16 has
151
- # only 7 fraction bits (~0.8% between neighbouring values), so AdamW's
152
- # small per-step updates would round to zero on bf16 weights.
153
- # The connector and LoRA are created in float32 already; the encoder is
154
- # loaded in `dtype`, so cast it when it trains.
155
  if not freeze_encoder:
156
  self.encoder.float()
157
 
158
  if gradient_checkpointing:
159
- # use_reentrant=False matters under DDP: the reentrant implementation
160
- # runs the recomputation outside autograd's view, so DDP can mark a
161
- # gradient ready twice and abort with "Expected to mark a variable
162
- # ready only once". The non-reentrant path composes with DDP.
163
  ckpt_kwargs = {"use_reentrant": False}
164
  self.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs=ckpt_kwargs)
165
- # deliberately no enable_input_require_grads(): the spliced speech
166
- # features already make inputs_embeds require grad, and that hook
167
- # would make it a leaf, breaking the splice (see build_inputs_embeds)
168
  if not freeze_encoder:
169
  self.encoder.gradient_checkpointing_enable(
170
  gradient_checkpointing_kwargs=ckpt_kwargs)
@@ -188,10 +117,6 @@ class SpeechLLM(nn.Module):
188
 
189
  def encode_audio(self, input_features):
190
  """mel features → speech embeddings in the LLM's width.
191
-
192
- We run the encoder over the full 30 s window including padding, and crop
193
- afterwards. Whisper is *trained* that way, so this is its native regime;
194
- masking the padding would be off-distribution for a frozen encoder.
195
  """
196
  input_features = input_features.to(self.encoder.conv1.weight.dtype)
197
  return self.connector(self.encoder(input_features).last_hidden_state)
@@ -199,12 +124,6 @@ class SpeechLLM(nn.Module):
199
  def build_inputs_embeds(self, input_ids, speech_features, audio_lengths, start_positions):
200
  """Overwrite each audio's placeholder embeddings with its speech features."""
201
  embed = self.llm.get_input_embeddings()
202
- # The embedding table is frozen, so `embed(input_ids)` comes back as a
203
- # leaf with requires_grad=False -- and writing in-place into a leaf that
204
- # requires grad is illegal, which is exactly what happens once gradient
205
- # checkpointing turns that flag on. Clone to get a non-leaf we may write
206
- # into. Splicing the speech features in is what makes the result require
207
- # grad, which is also what checkpointing needs from the LLM's input.
208
  inputs_embeds = embed(input_ids).clone()
209
 
210
  for k, (row, start) in enumerate(start_positions):
 
4
  audio ──► Whisper encoder (trained or frozen) ──► connector (trained) ──┐
5
  ├──► LLM + LoRA ──► text
6
  text ────────────────────────────────────────► embedding table ────────┘
 
 
 
 
 
 
 
 
 
7
  """
8
 
9
  import logging
 
15
  logger = logging.getLogger(__name__)
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  class ConcatMLPConnector(nn.Module):
19
  """Whisper hidden states → LLM-width embeddings by frame concatenation.
 
 
 
 
 
 
 
20
  """
21
 
22
+ def __init__(self, d_encoder: int, d_llm: int, n_downsample: int = 2,
23
  hidden_dim: int = 0):
24
  super().__init__()
25
+ self.n_downsample = n_downsample
26
  hidden_dim = hidden_dim or d_llm
27
  self.proj = nn.Sequential(
28
+ nn.LayerNorm(d_encoder * n_downsample),
29
+ nn.Linear(d_encoder * n_downsample, hidden_dim),
30
  nn.GELU(),
31
  nn.Linear(hidden_dim, d_llm),
32
  )
33
 
34
  def forward(self, hidden_states): # (B, T, d_encoder)
35
  B, T, D = hidden_states.shape
36
+ k = self.n_downsample
37
+ pad = (-T) % k
38
  if pad:
39
  hidden_states = nn.functional.pad(hidden_states, (0, 0, 0, pad))
40
+ stacked = hidden_states.reshape(B, (T + pad) // k, D * k)
41
  return self.proj(stacked) # (B, ceil(T/k), d_llm)
42
 
43
 
 
 
 
44
  class SpeechLLM(nn.Module):
45
  def __init__(self, llm_id: str, encoder_id: str,
46
+ n_downsample: int = 2, dtype=torch.bfloat16,
47
+ adapter_hidden_dim: int = 0,
48
  gradient_checkpointing: bool = False, cache_dir=None,
49
  freeze_encoder: bool = True, use_lora: bool = False,
50
  lora_rank: int = 32, lora_alpha: int = 64, lora_dropout: float = 0.05,
 
57
  self.encoder = WhisperModel.from_pretrained(
58
  encoder_id, torch_dtype=dtype, cache_dir=cache_dir).encoder
59
 
60
+ self.connector = ConcatMLPConnector(
 
 
61
  d_encoder=self.encoder.config.d_model,
62
  d_llm=self.llm.config.hidden_size,
63
+ n_downsample=n_downsample,
64
+ hidden_dim=adapter_hidden_dim,
65
  )
66
+ logger.info("connector: %.1fM params",
67
  sum(p.numel() for p in self.connector.parameters()) / 1e6)
68
 
 
 
 
 
 
 
 
 
69
  for param in self.llm.parameters():
70
  param.requires_grad = False
71
 
 
77
  target_modules=list(lora_target_modules), bias="none",
78
  task_type="CAUSAL_LM",
79
  )
80
+
 
81
  self.llm = get_peft_model(self.llm, lora_config).base_model.model
82
  n_lora = sum(p.numel() for n, p in self.llm.named_parameters() if "lora_" in n)
83
  assert n_lora > 0, f"LoRA matched no modules in {lora_target_modules}"
 
88
  for param in self.encoder.parameters():
89
  param.requires_grad = not freeze_encoder
90
 
 
 
 
 
 
 
91
  if not freeze_encoder:
92
  self.encoder.float()
93
 
94
  if gradient_checkpointing:
 
 
 
 
95
  ckpt_kwargs = {"use_reentrant": False}
96
  self.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs=ckpt_kwargs)
 
 
 
97
  if not freeze_encoder:
98
  self.encoder.gradient_checkpointing_enable(
99
  gradient_checkpointing_kwargs=ckpt_kwargs)
 
117
 
118
  def encode_audio(self, input_features):
119
  """mel features → speech embeddings in the LLM's width.
 
 
 
 
120
  """
121
  input_features = input_features.to(self.encoder.conv1.weight.dtype)
122
  return self.connector(self.encoder(input_features).last_hidden_state)
 
124
  def build_inputs_embeds(self, input_ids, speech_features, audio_lengths, start_positions):
125
  """Overwrite each audio's placeholder embeddings with its speech features."""
126
  embed = self.llm.get_input_embeddings()
 
 
 
 
 
 
127
  inputs_embeds = embed(input_ids).clone()
128
 
129
  for k, (row, start) in enumerate(start_positions):
selfgen/model.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fbea96fded8610bd5d66b5576c3d8c5635b2ba3e063896bd32c9ec562910c548
3
+ size 146924882