rename selfgen_only -> selfgen, add asr_gender (388180)
Browse files- README.md +34 -14
- asr_gender/model.ckpt +3 -0
- code/data.py +20 -38
- code/inference.py +18 -28
- code/modeling.py +14 -95
- 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 |
-
|
|
|
|
|
|
|
| 14 |
|---|---|---|---|
|
| 15 |
-
| `
|
| 16 |
-
| `
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
`
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
prompts directly.
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 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 `
|
| 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
|
| 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
|
| 65 |
-
|
| 66 |
-
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 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,
|
| 156 |
-
self.
|
| 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 = []
|
| 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 = {}
|
| 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 = []
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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 = []
|
| 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)]
|
| 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,
|
| 319 |
-
"start_positions": shifted_starts,
|
| 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 |
-
--
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 83 |
-
# .get(): checkpoints
|
| 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 |
-
|
| 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]
|
| 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 |
-
# --
|
| 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("--
|
| 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.
|
| 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.
|
| 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,
|
| 68 |
hidden_dim: int = 0):
|
| 69 |
super().__init__()
|
| 70 |
-
self.
|
| 71 |
hidden_dim = hidden_dim or d_llm
|
| 72 |
self.proj = nn.Sequential(
|
| 73 |
-
nn.LayerNorm(d_encoder *
|
| 74 |
-
nn.Linear(d_encoder *
|
| 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 |
-
|
|
|
|
| 82 |
if pad:
|
| 83 |
hidden_states = nn.functional.pad(hidden_states, (0, 0, 0, pad))
|
| 84 |
-
stacked = hidden_states.reshape(B, (T + pad) //
|
| 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 |
-
|
| 94 |
-
|
| 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 |
-
|
| 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 |
-
|
| 113 |
-
|
| 114 |
)
|
| 115 |
-
logger.info("
|
| 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 |
-
|
| 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
|