Upload folder using huggingface_hub
Browse files- .gitattributes +4 -0
- README.md +45 -0
- code/data.py +320 -0
- code/inference.py +205 -0
- code/modeling.py +252 -0
- samples/1272-128104-0000.flac +3 -0
- samples/1272-128104-0001.flac +3 -0
- samples/1272-128104-0002.flac +3 -0
- samples/1272-128104-0003.flac +3 -0
- samples/samples.json +18 -0
- selfgen_mix/model.ckpt +3 -0
- selfgen_only/model.ckpt +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
samples/1272-128104-0000.flac filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
samples/1272-128104-0001.flac filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
samples/1272-128104-0002.flac filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
samples/1272-128104-0003.flac filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language: en
|
| 4 |
+
tags: [speech-llm, desta, librispeech, tutorial]
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# Interspeech tutorial — DeSTA-style SpeechLLM checkpoints
|
| 8 |
+
|
| 9 |
+
Whisper-large-v3 encoder (frozen) → concat+MLP adapter → Qwen3-4B-Instruct-2507 + LoRA r32.
|
| 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
|
| 34 |
+
import sys, torch
|
| 35 |
+
|
| 36 |
+
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 |
+
```
|
code/data.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
+
features (see modeling.py). Nothing else about the LLM changes.
|
| 9 |
+
|
| 10 |
+
Everything that turns "a manifest row" into "model inputs" happens in `Collator`.
|
| 11 |
+
The `Dataset` is a dumb jsonl reader. Keeping it in one place matters here: the
|
| 12 |
+
number of placeholders depends on the audio's true duration, which we only know
|
| 13 |
+
once the waveform is loaded -- so the expansion has to happen next to the audio.
|
| 14 |
+
|
| 15 |
+
Manifest format (one JSON object per line)::
|
| 16 |
+
|
| 17 |
+
{
|
| 18 |
+
"audios": [
|
| 19 |
+
{"audio_filepath": "ESC50/1-103999-A-30.wav"}
|
| 20 |
+
],
|
| 21 |
+
"messages": [
|
| 22 |
+
{"role": "user", "content": "What do you hear? <|AUDIO|>"}
|
| 23 |
+
],
|
| 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 |
+
|
| 31 |
+
import json
|
| 32 |
+
import logging
|
| 33 |
+
import math
|
| 34 |
+
import os
|
| 35 |
+
import random
|
| 36 |
+
from dataclasses import dataclass, field
|
| 37 |
+
from typing import Any, List, Optional
|
| 38 |
+
|
| 39 |
+
import librosa
|
| 40 |
+
import torch
|
| 41 |
+
from torch.utils.data import Dataset
|
| 42 |
+
|
| 43 |
+
logger = logging.getLogger(__name__)
|
| 44 |
+
|
| 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,
|
| 77 |
+
placeholder_token: str, slot_sizes: List[int]):
|
| 78 |
+
"""Replace each `audio_locator` with `slot_sizes[i]` placeholder tokens.
|
| 79 |
+
|
| 80 |
+
``["a", "<|AUDIO|>", "b"]`` with ``slot_sizes=[3]`` becomes
|
| 81 |
+
``["a", "<|pad|>", "<|pad|>", "<|pad|>", "b"]`` and ``start_positions=[1]``.
|
| 82 |
+
"""
|
| 83 |
+
out: List[str] = []
|
| 84 |
+
starts: List[int] = []
|
| 85 |
+
sizes = iter(slot_sizes)
|
| 86 |
+
for token in tokens:
|
| 87 |
+
if token == audio_locator:
|
| 88 |
+
starts.append(len(out))
|
| 89 |
+
out.extend([placeholder_token] * next(sizes))
|
| 90 |
+
else:
|
| 91 |
+
out.append(token)
|
| 92 |
+
return out, starts
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def resolve_manifest(path: str) -> str:
|
| 96 |
+
"""Accept a local path or a huggingface.co/datasets/... resolve URL."""
|
| 97 |
+
prefix = "https://huggingface.co/datasets/"
|
| 98 |
+
if not path.startswith(prefix):
|
| 99 |
+
return path
|
| 100 |
+
from huggingface_hub import hf_hub_download
|
| 101 |
+
|
| 102 |
+
parts = path[len(prefix):].split("/")
|
| 103 |
+
repo_id, marker, revision = "/".join(parts[:2]), parts[2], parts[3]
|
| 104 |
+
assert marker == "resolve", f"unsupported HF url: {path}"
|
| 105 |
+
return hf_hub_download(repo_id=repo_id, filename="/".join(parts[4:]),
|
| 106 |
+
revision=revision, repo_type="dataset")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def resolve_audio_filepath(path: str) -> str:
|
| 110 |
+
if os.path.exists(path):
|
| 111 |
+
return path
|
| 112 |
+
wav = os.path.splitext(path)[0] + ".wav"
|
| 113 |
+
if os.path.exists(wav):
|
| 114 |
+
return wav
|
| 115 |
+
raise FileNotFoundError(path)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def split_manifest_entries(entries):
|
| 119 |
+
"""Manifest entries are a path (ratio 1.0) or {"path": ..., "ratio": ...}.
|
| 120 |
+
|
| 121 |
+
Returns (paths, ratios).
|
| 122 |
+
"""
|
| 123 |
+
paths, ratios = [], []
|
| 124 |
+
for entry in entries:
|
| 125 |
+
if isinstance(entry, str):
|
| 126 |
+
path, ratio = entry, 1.0
|
| 127 |
+
else:
|
| 128 |
+
unknown = set(entry) - {"path", "ratio"}
|
| 129 |
+
assert "path" in entry and not unknown, f"bad manifest entry {entry!r}"
|
| 130 |
+
path, ratio = entry["path"], float(entry.get("ratio", 1.0))
|
| 131 |
+
assert ratio >= 0, f"{path}: ratio must be >= 0, got {ratio}"
|
| 132 |
+
paths.append(path)
|
| 133 |
+
ratios.append(ratio)
|
| 134 |
+
return paths, ratios
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def manifest_name(path: str) -> str:
|
| 138 |
+
return os.path.basename(path).removesuffix(".jsonl")
|
| 139 |
+
|
| 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:
|
| 165 |
+
offset = 0
|
| 166 |
+
for line in f:
|
| 167 |
+
if line.strip():
|
| 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
|
| 175 |
+
def needs_resampling(self) -> bool:
|
| 176 |
+
"""True when some ratio has a fractional part, i.e. a random subset is drawn."""
|
| 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))
|
| 184 |
+
# str seed: hashed with sha512, identical across processes and runs
|
| 185 |
+
rng = random.Random(f"{self.seed}-{epoch}-{file_idx}")
|
| 186 |
+
self.index += rows * whole + rng.sample(rows, n_extra)
|
| 187 |
+
if log:
|
| 188 |
+
logger.info(" %-32s ratio %.2f: %d of %d rows", manifest_name(self.paths[file_idx]),
|
| 189 |
+
ratio, whole * len(rows) + n_extra, len(rows))
|
| 190 |
+
if log:
|
| 191 |
+
logger.info("loaded %d rows from %d manifest(s)", len(self.index), len(self.paths))
|
| 192 |
+
|
| 193 |
+
def __len__(self):
|
| 194 |
+
return len(self.index)
|
| 195 |
+
|
| 196 |
+
def __getitem__(self, i):
|
| 197 |
+
file_idx, offset = self.index[i]
|
| 198 |
+
handle = self._handles.get(file_idx)
|
| 199 |
+
if handle is None:
|
| 200 |
+
handle = self._handles[file_idx] = open(self.paths[file_idx], "rb")
|
| 201 |
+
handle.seek(offset)
|
| 202 |
+
row = json.loads(handle.readline())
|
| 203 |
+
|
| 204 |
+
# DeSTA3 manifests use either key; `response` wins when both are present.
|
| 205 |
+
target = row.get("response") or row.get("target")
|
| 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 |
+
|
| 213 |
+
|
| 214 |
+
@dataclass
|
| 215 |
+
class Collator:
|
| 216 |
+
"""Turns a list of rows into the tensors `SpeechLLM.forward` expects.
|
| 217 |
+
|
| 218 |
+
Padding is on the **left** so that every sequence's answer ends at the same
|
| 219 |
+
index -- that keeps `generate()` simple. It also means every position we
|
| 220 |
+
record has to be shifted by that sequence's pad length.
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
tokenizer: Any
|
| 224 |
+
feature_extractor: Any
|
| 225 |
+
audio_locator: str = "<|AUDIO|>"
|
| 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.
|
| 233 |
+
for_generation: bool = False
|
| 234 |
+
|
| 235 |
+
def __post_init__(self):
|
| 236 |
+
assert self.tokenizer.padding_side == "left"
|
| 237 |
+
assert self.placeholder_token in self.tokenizer.get_vocab(), (
|
| 238 |
+
f"placeholder_token {self.placeholder_token!r} is not in the tokenizer "
|
| 239 |
+
"vocabulary; pick a reserved/unused token of your LLM")
|
| 240 |
+
self.placeholder_id = self.tokenizer.convert_tokens_to_ids(self.placeholder_token)
|
| 241 |
+
self.pad_id = self.tokenizer.pad_token_id
|
| 242 |
+
|
| 243 |
+
def __call__(self, batch):
|
| 244 |
+
# 1. audio -> mel features, and the true (unpadded) length of each
|
| 245 |
+
waveforms = []
|
| 246 |
+
for row in batch:
|
| 247 |
+
for audio in row["audios"]:
|
| 248 |
+
wav, _ = librosa.load(audio["audio_filepath"], sr=SAMPLE_RATE, mono=True)
|
| 249 |
+
waveforms.append(wav[:int(self.max_audio_seconds * SAMPLE_RATE)])
|
| 250 |
+
|
| 251 |
+
features = self.feature_extractor(
|
| 252 |
+
waveforms, sampling_rate=SAMPLE_RATE,
|
| 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 = [], []
|
| 260 |
+
start_positions, audio_index = [], 0
|
| 261 |
+
|
| 262 |
+
for row in batch:
|
| 263 |
+
n_audios = len(row["audios"])
|
| 264 |
+
# each audio reserves one slot per speech feature frame
|
| 265 |
+
slot_sizes = audio_lengths[audio_index:audio_index + n_audios]
|
| 266 |
+
|
| 267 |
+
prompt = self.tokenizer.apply_chat_template(
|
| 268 |
+
row["messages"], tokenize=False, add_generation_prompt=True,
|
| 269 |
+
enable_thinking=False,
|
| 270 |
+
)
|
| 271 |
+
tokens = self.tokenizer.tokenize(prompt)
|
| 272 |
+
assert tokens.count(self.audio_locator) == n_audios, (
|
| 273 |
+
f"{n_audios} audios but {tokens.count(self.audio_locator)} "
|
| 274 |
+
f"{self.audio_locator} in the prompt")
|
| 275 |
+
|
| 276 |
+
tokens, starts = expand_audio_locator(
|
| 277 |
+
tokens, self.audio_locator, self.placeholder_token, slot_sizes)
|
| 278 |
+
|
| 279 |
+
# straight to ids -- no convert_tokens_to_string round-trip, so the
|
| 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]
|
| 287 |
+
|
| 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)
|
| 295 |
+
start_positions.append(starts)
|
| 296 |
+
audio_index += n_audios
|
| 297 |
+
|
| 298 |
+
# 3. left-pad, build labels, shift the recorded positions
|
| 299 |
+
width = max(len(c) + len(t) for c, t in zip(context_ids, target_ids))
|
| 300 |
+
input_ids = torch.full((len(batch), width), self.pad_id, dtype=torch.long)
|
| 301 |
+
attention_mask = torch.zeros((len(batch), width), dtype=torch.long)
|
| 302 |
+
labels = torch.full((len(batch), width), -100, dtype=torch.long)
|
| 303 |
+
shifted_starts = []
|
| 304 |
+
|
| 305 |
+
for i, (ctx, tgt) in enumerate(zip(context_ids, target_ids)):
|
| 306 |
+
pad = width - len(ctx) - len(tgt)
|
| 307 |
+
input_ids[i, pad:] = torch.tensor(ctx + tgt, dtype=torch.long)
|
| 308 |
+
attention_mask[i, pad:] = 1
|
| 309 |
+
labels[i, pad + len(ctx):] = torch.tensor(tgt, dtype=torch.long) # answer only
|
| 310 |
+
for start in start_positions[i]:
|
| 311 |
+
shifted_starts.append((i, start + pad))
|
| 312 |
+
|
| 313 |
+
return {
|
| 314 |
+
"input_ids": input_ids,
|
| 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 |
+
}
|
code/inference.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
Inference for the minimal SpeechLLM -- the tutorial's demo entry point.
|
| 4 |
+
|
| 5 |
+
# one file
|
| 6 |
+
python inference.py --ckpt exp/run/checkpoints/last.ckpt --audio sample.flac
|
| 7 |
+
|
| 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
|
| 28 |
+
import json
|
| 29 |
+
import logging
|
| 30 |
+
import os
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
from transformers import AutoFeatureExtractor, AutoTokenizer
|
| 34 |
+
|
| 35 |
+
from data import Collator
|
| 36 |
+
from modeling import SpeechLLM
|
| 37 |
+
|
| 38 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
DEFAULT_PROMPT = "<audio><|AUDIO|></audio>\n\nTranscribe the speech into text"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class SpeechLLMForInference:
|
| 45 |
+
"""Checkpoint in, text out.
|
| 46 |
+
|
| 47 |
+
`generate()` takes DeSTA3-style messages so the demo reads like a chat call:
|
| 48 |
+
|
| 49 |
+
[{"role": "user",
|
| 50 |
+
"content": "<audio><|AUDIO|></audio>\\n\\nTranscribe the speech into text",
|
| 51 |
+
"audios": [{"audio": "sample.flac"}]}]
|
| 52 |
+
|
| 53 |
+
Pass a list of those to batch several utterances in one forward pass.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, model, tokenizer, collator, device, dtype):
|
| 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")
|
| 64 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 65 |
+
hp = ckpt["hyper_parameters"]
|
| 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"])
|
| 75 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 76 |
+
tokenizer.padding_side = "left"
|
| 77 |
+
tokenizer.add_tokens([hp["audio_locator"]])
|
| 78 |
+
feature_extractor = AutoFeatureExtractor.from_pretrained(hp["encoder_id"])
|
| 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"],
|
| 88 |
+
lora_dropout=hp["lora_dropout"],
|
| 89 |
+
lora_target_modules=hp["lora_target_modules"],
|
| 90 |
+
)
|
| 91 |
+
cls._load_trainable(model, ckpt["state_dict"])
|
| 92 |
+
model.to(device).eval()
|
| 93 |
+
|
| 94 |
+
collator = Collator(
|
| 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)
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _load_trainable(model, state_dict):
|
| 105 |
+
"""Load the trainable-only checkpoint, and prove every tensor landed."""
|
| 106 |
+
state = {k[len("model."):]: v for k, v in state_dict.items() if k.startswith("model.")}
|
| 107 |
+
expected = {n for n, p in model.named_parameters() if p.requires_grad}
|
| 108 |
+
missing, unexpected = expected - set(state), set(state) - expected
|
| 109 |
+
assert not unexpected, (
|
| 110 |
+
f"{len(unexpected)} tensors in the checkpoint match nothing in the model, "
|
| 111 |
+
f"e.g. {sorted(unexpected)[:3]} -- the architecture does not match")
|
| 112 |
+
assert not missing, (
|
| 113 |
+
f"{len(missing)} trainable tensors were not in the checkpoint, "
|
| 114 |
+
f"e.g. {sorted(missing)[:3]} -- they would stay randomly initialised")
|
| 115 |
+
result = model.load_state_dict(state, strict=False)
|
| 116 |
+
assert not result.unexpected_keys, result.unexpected_keys
|
| 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 = []
|
| 124 |
+
for conv in conversations:
|
| 125 |
+
audios = []
|
| 126 |
+
for message in conv:
|
| 127 |
+
for audio in message.get("audios", []):
|
| 128 |
+
path = audio["audio"] if isinstance(audio, dict) else audio
|
| 129 |
+
assert os.path.exists(path), f"no such audio: {path}"
|
| 130 |
+
audios.append({"audio_filepath": path})
|
| 131 |
+
n_locators = sum(m["content"].count(self.collator.audio_locator) for m in conv)
|
| 132 |
+
assert n_locators == len(audios), (
|
| 133 |
+
f"{len(audios)} audios but {n_locators} {self.collator.audio_locator} "
|
| 134 |
+
"in the conversation")
|
| 135 |
+
# strip `audios` before the chat template ever sees it
|
| 136 |
+
rows.append({"messages": [{"role": m["role"], "content": m["content"]} for m in conv],
|
| 137 |
+
"audios": audios, "target": ""})
|
| 138 |
+
return rows
|
| 139 |
+
|
| 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)
|
| 147 |
+
batch["attention_mask"] = batch["attention_mask"].to(self.device)
|
| 148 |
+
# SpeechLLM.encode_audio casts features to the encoder's own dtype
|
| 149 |
+
batch["input_features"] = batch["input_features"].to(self.device)
|
| 150 |
+
|
| 151 |
+
# passing inputs_embeds means `generate` returns only the new tokens --
|
| 152 |
+
# there is no prompt prefix to slice off
|
| 153 |
+
generated = self.model.generate(
|
| 154 |
+
batch, self.tokenizer, max_new_tokens=max_new_tokens,
|
| 155 |
+
do_sample=do_sample, **generation_kwargs)
|
| 156 |
+
return [t.strip() for t in
|
| 157 |
+
self.tokenizer.batch_decode(generated, skip_special_tokens=True)]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ------------------------------------------------------------------ main ---
|
| 161 |
+
|
| 162 |
+
def main():
|
| 163 |
+
ap = argparse.ArgumentParser(description=__doc__,
|
| 164 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 165 |
+
ap.add_argument("--ckpt", required=True)
|
| 166 |
+
ap.add_argument("--audio", nargs="+", help="one or more audio files")
|
| 167 |
+
ap.add_argument("--manifest", help="jsonl to run over instead of --audio")
|
| 168 |
+
ap.add_argument("--data-root", default="")
|
| 169 |
+
ap.add_argument("--limit", type=int, default=10, help="rows to take from --manifest")
|
| 170 |
+
ap.add_argument("--prompt", default=DEFAULT_PROMPT)
|
| 171 |
+
ap.add_argument("--batch-size", type=int, default=4)
|
| 172 |
+
ap.add_argument("--max-new-tokens", type=int, default=200)
|
| 173 |
+
ap.add_argument("--device", default=None)
|
| 174 |
+
args = ap.parse_args()
|
| 175 |
+
assert args.audio or args.manifest, "give --audio or --manifest"
|
| 176 |
+
|
| 177 |
+
pipe = SpeechLLMForInference.from_checkpoint(args.ckpt, device=args.device)
|
| 178 |
+
|
| 179 |
+
if args.audio:
|
| 180 |
+
items = [(os.path.join(args.data_root, p), None) for p in args.audio]
|
| 181 |
+
else:
|
| 182 |
+
items = []
|
| 183 |
+
with open(args.manifest) as f:
|
| 184 |
+
for line in f:
|
| 185 |
+
if len(items) >= args.limit:
|
| 186 |
+
break
|
| 187 |
+
row = json.loads(line)
|
| 188 |
+
items.append((os.path.join(args.data_root,
|
| 189 |
+
row["audios"][0]["audio_filepath"]),
|
| 190 |
+
row.get("response") or row.get("target")))
|
| 191 |
+
|
| 192 |
+
for start in range(0, len(items), args.batch_size):
|
| 193 |
+
chunk = items[start:start + args.batch_size]
|
| 194 |
+
convs = [[{"role": "user", "content": args.prompt,
|
| 195 |
+
"audios": [{"audio": path}]}] for path, _ in chunk]
|
| 196 |
+
for (path, reference), hypothesis in zip(chunk, pipe.generate(
|
| 197 |
+
convs, max_new_tokens=args.max_new_tokens)):
|
| 198 |
+
print(f"\n--- {os.path.basename(path)}")
|
| 199 |
+
if reference is not None:
|
| 200 |
+
print(f" ref: {reference}")
|
| 201 |
+
print(f" hyp: {hypothesis}")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
if __name__ == "__main__":
|
| 205 |
+
main()
|
code/modeling.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
A minimal SpeechLLM: Whisper encoder + connector + LLM with LoRA.
|
| 3 |
+
|
| 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
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn as nn
|
| 22 |
+
from transformers import AutoModelForCausalLM, WhisperModel
|
| 23 |
+
|
| 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,
|
| 98 |
+
lora_target_modules=("q_proj", "k_proj", "v_proj", "o_proj")):
|
| 99 |
+
super().__init__()
|
| 100 |
+
self.llm = AutoModelForCausalLM.from_pretrained(
|
| 101 |
+
llm_id, torch_dtype=dtype, cache_dir=cache_dir)
|
| 102 |
+
# We only ever need the encoder; loading WhisperModel and keeping .encoder
|
| 103 |
+
# lets the decoder weights fall out of scope immediately.
|
| 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 |
+
|
| 129 |
+
self.use_lora = use_lora
|
| 130 |
+
if use_lora:
|
| 131 |
+
from peft import LoraConfig ,get_peft_model
|
| 132 |
+
lora_config = LoraConfig(
|
| 133 |
+
r=lora_rank, lora_alpha=lora_alpha, lora_dropout=lora_dropout,
|
| 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}"
|
| 142 |
+
logger.info("LoRA r=%d on %s: %.1fM adapter params",
|
| 143 |
+
lora_rank, list(lora_target_modules), n_lora / 1e6)
|
| 144 |
+
|
| 145 |
+
self.freeze_encoder = freeze_encoder
|
| 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)
|
| 171 |
+
|
| 172 |
+
dtypes = {}
|
| 173 |
+
for name, param in self.named_parameters():
|
| 174 |
+
if param.requires_grad:
|
| 175 |
+
part = name.split(".")[0]
|
| 176 |
+
dtypes.setdefault(part, set()).add(str(param.dtype).replace("torch.", ""))
|
| 177 |
+
logger.info("trainable dtypes: %s", {k: sorted(v) for k, v in sorted(dtypes.items())})
|
| 178 |
+
|
| 179 |
+
by_part = {}
|
| 180 |
+
for name, param in self.named_parameters():
|
| 181 |
+
if param.requires_grad:
|
| 182 |
+
by_part[name.split(".")[0]] = by_part.get(name.split(".")[0], 0) + param.numel()
|
| 183 |
+
trainable = sum(by_part.values())
|
| 184 |
+
total = sum(p.numel() for p in self.parameters())
|
| 185 |
+
logger.info("trainable %.1fM / %.1fM total (%.2f%%) -- %s",
|
| 186 |
+
trainable / 1e6, total / 1e6, 100 * trainable / total,
|
| 187 |
+
{k: f"{v/1e6:.1f}M" for k, v in sorted(by_part.items())})
|
| 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)
|
| 198 |
+
|
| 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):
|
| 211 |
+
segment = speech_features[k, :audio_lengths[k]]
|
| 212 |
+
end = start + segment.size(0)
|
| 213 |
+
assert end <= inputs_embeds.size(1), (
|
| 214 |
+
f"audio {k} needs slots [{start}:{end}] but the sequence is "
|
| 215 |
+
f"{inputs_embeds.size(1)} long")
|
| 216 |
+
# outside autocast (inference) the float32 connector output must be
|
| 217 |
+
# cast to the LLM's embedding dtype before the in-place write
|
| 218 |
+
inputs_embeds[row, start:end] = segment.to(inputs_embeds.dtype)
|
| 219 |
+
|
| 220 |
+
return inputs_embeds
|
| 221 |
+
|
| 222 |
+
def forward(self, input_ids, attention_mask, input_features, audio_lengths,
|
| 223 |
+
start_positions, labels=None):
|
| 224 |
+
speech_features = self.encode_audio(input_features)
|
| 225 |
+
inputs_embeds = self.build_inputs_embeds(
|
| 226 |
+
input_ids, speech_features, audio_lengths, start_positions)
|
| 227 |
+
return self.llm(inputs_embeds=inputs_embeds,
|
| 228 |
+
attention_mask=attention_mask,
|
| 229 |
+
labels=labels)
|
| 230 |
+
|
| 231 |
+
@torch.no_grad()
|
| 232 |
+
def generate(self, batch, tokenizer, **generation_kwargs):
|
| 233 |
+
"""Greedy/sampled decoding from the same batch dict used for training.
|
| 234 |
+
|
| 235 |
+
Note we pass `inputs_embeds`, so `generate` returns only the newly
|
| 236 |
+
generated ids -- there is no prompt prefix to strip.
|
| 237 |
+
"""
|
| 238 |
+
speech_features = self.encode_audio(batch["input_features"])
|
| 239 |
+
inputs_embeds = self.build_inputs_embeds(
|
| 240 |
+
batch["input_ids"], speech_features, batch["audio_lengths"],
|
| 241 |
+
batch["start_positions"])
|
| 242 |
+
return self.llm.generate(
|
| 243 |
+
inputs_embeds=inputs_embeds,
|
| 244 |
+
attention_mask=batch["attention_mask"],
|
| 245 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 246 |
+
**generation_kwargs,
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
def trainable_state_dict(self):
|
| 250 |
+
"""Only the connector -- a few tens of MB instead of the full ~11 GB."""
|
| 251 |
+
return {name: param.detach().clone()
|
| 252 |
+
for name, param in self.named_parameters() if param.requires_grad}
|
samples/1272-128104-0000.flac
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4e25e22555cd16e90edb0a3b49fdcf1fe652b2a1250ab643634db33895c75b41
|
| 3 |
+
size 120041
|
samples/1272-128104-0001.flac
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46a9d58622b4675b29564da2d9ba73e702241c5fa969f12c387cad4aa984276a
|
| 3 |
+
size 101672
|
samples/1272-128104-0002.flac
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:59bf7bb1129c11734a8dd362ad52b4e10ae31c03f0879fc92f7107ecd513513f
|
| 3 |
+
size 255853
|
samples/1272-128104-0003.flac
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:60d72657439cd3092fc2313efe0fabd31f0cd2f28f8ca46a21380c290b2e031d
|
| 3 |
+
size 196122
|
samples/samples.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"file": "1272-128104-0000.flac",
|
| 4 |
+
"transcription": "mister quilter is the apostle of the middle classes and we are glad to welcome his gospel"
|
| 5 |
+
},
|
| 6 |
+
{
|
| 7 |
+
"file": "1272-128104-0001.flac",
|
| 8 |
+
"transcription": "nor is mister quilter's manner less interesting than his matter"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"file": "1272-128104-0002.flac",
|
| 12 |
+
"transcription": "he tells us that at this festive season of the year with christmas and roast beef looming before us similes drawn from eating and its results occur most readily to the mind"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"file": "1272-128104-0003.flac",
|
| 16 |
+
"transcription": "he has grave doubts whether sir frederick leighton's work is really greek after all and can discover in it but little of rocky ithaca"
|
| 17 |
+
}
|
| 18 |
+
]
|
selfgen_mix/model.ckpt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a40967ad40705a5fb5bc08d8d50bc8eaa5a6ef05f2915a15e68c5d60428fa4a
|
| 3 |
+
size 146925138
|
selfgen_only/model.ckpt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fbea96fded8610bd5d66b5576c3d8c5635b2ba3e063896bd32c9ec562910c548
|
| 3 |
+
size 146924882
|