File size: 13,440 Bytes
ae8ded8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
beff3ce
 
ae8ded8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
beff3ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae8ded8
beff3ce
 
 
 
 
 
ae8ded8
 
 
beff3ce
 
 
 
 
 
ae8ded8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
beff3ce
 
 
 
 
 
 
 
 
ae8ded8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
beff3ce
ae8ded8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
#!/usr/bin/env python3
"""Graft a Whisper encoder onto Emhotob-50M via the stock Qwen2Audio shell.

Qwen2AudioConfig declares `sub_configs = {"audio_config": AutoConfig, "text_config": AutoConfig}`,
which is the same property that let the vision project compose a plain llama text config into
Lfm2Vl with no custom modelling code. So there is nothing to write here beyond the graft itself:
`generate()`, the KV cache, variable-length audio masking and `save_pretrained` all come for free.

Two things make this fit better than the hand-rolled scatter the plan allowed for:

  * `Qwen2AudioEncoder` is structurally a `WhisperEncoder` -- identical conv1/conv2/embed_positions/
    layers/layer_norm names -- so whisper-small's encoder weights load by prefix remap alone.
  * `_get_feat_extract_output_lengths` + per-audio masking means variable-length audio is handled
    natively. Whisper pads every clip to 30s, and without this every 3-second clip would spend
    ~750 tokens on silence.

Frame rate: whisper conv2 (stride 2) gives 50 Hz, then Qwen2Audio's avg_pooler (stride 2) halves it
to 25 Hz. MASC's median 3.0s clip -> ~75 audio tokens, p95 7.8s -> ~195. Well inside 2048.
"""
from __future__ import annotations

import argparse

from pathlib import Path

import torch
from transformers import (AutoConfig, AutoModelForCausalLM, AutoTokenizer, Qwen2AudioConfig,
                          Qwen2AudioEncoderConfig, Qwen2AudioForConditionalGeneration,
                          WhisperConfig, WhisperForConditionalGeneration)

WHISPER = "openai/whisper-small"
LM = "oddadmix/50M-2048-Emhotob"

AUDIO_TOKEN = "<audio>"
AUDIO_START = "<|audio_start|>"
AUDIO_END = "<|audio_end|>"
SPECIALS = [AUDIO_TOKEN, AUDIO_START, AUDIO_END]
PAD_TO_MULTIPLE = 64          # vision precedent: keep the tied embedding matrix 64-aligned


def build_tokenizer(lm_repo: str = LM):
    tok = AutoTokenizer.from_pretrained(lm_repo)
    # Emhotob's 32000-token vocab is fully packed -- there are no reserved slots to steal, so the
    # vocab genuinely has to grow. 512 params per new token, tied to lm_head.
    tok.add_special_tokens({"additional_special_tokens": SPECIALS})
    return tok


def make_projector(d_in: int, d_out: int, kind: str, mult: int = 4):
    """The stock Qwen2Audio projector is a single Linear -- 262K params and no depth is the entire
    audio->text bridge. Whisper's own decoder, which transcribes the same features correctly, uses
    stacked cross-attention. With one matmul the LM receives a weak signal and falls back on its
    text prior: feed the trained model pure silence and it still emits fluent Arabic.

    "mlp" gives it depth and an input LayerNorm (whisper hidden states and Emhotob embeddings live
    at very different scales; the single scalar in calibrate_projector was the only thing bridging
    that before). Qwen2AudioMultiModalProjector.forward just calls self.linear(x), so swapping the
    attribute for a Sequential is transparent to the rest of the stack -- but the state_dict keys
    change, so a repo trained this way no longer round-trips through vanilla from_pretrained."""
    import torch.nn as nn
    if kind == "linear":
        return nn.Linear(d_in, d_out)
    if kind == "mlp":
        h = d_out * mult
        return nn.Sequential(nn.LayerNorm(d_in), nn.Linear(d_in, h), nn.GELU(), nn.Linear(h, d_out))
    raise ValueError(f"unknown projector {kind!r}")


def _last_linear(mod):
    import torch.nn as nn
    if isinstance(mod, nn.Linear):
        return mod
    return [m for m in mod.modules() if isinstance(m, nn.Linear)][-1]


def set_audio_frame_rate(model, pool_stride: int = 2):
    """Set the rate of the audio tokens the LM consumes. 2 = 25 Hz (v1/v2), 1 = 50 Hz (v3).

    Qwen2Audio halves the Whisper encoder's native 50 Hz with a stride-2 average pool right
    before the final layer_norm. That pooler is built in `__init__` from nothing -- it is not
    driven by config and holds no parameters -- so a checkpoint reloaded with vanilla
    transformers ALWAYS comes back at stride 2 regardless of how it was trained. Weights would
    load without complaint and the model would silently run at half the rate it learned.

    Everything here goes through build(), so patching both the module and the length function in
    one place keeps the collator honest too: it derives the number of audio placeholder tokens
    from `_get_feat_extract_output_lengths`, and a disagreement between that and the encoder's
    real output is a shape error at best and silent misalignment at worst.

    The default stays 2 so existing v1/v2 checkpoints keep their trained behaviour; v3 opts in.
    """
    tower = model.model.audio_tower
    tower.avg_pooler = torch.nn.AvgPool1d(pool_stride, stride=pool_stride)

    def _lens(input_lengths):
        input_lengths = (input_lengths - 1) // 2 + 1          # whisper conv2, stride 2
        output_lengths = (input_lengths - pool_stride) // pool_stride + 1
        return input_lengths, output_lengths

    tower._get_feat_extract_output_lengths = _lens
    tower.config.pool_stride = pool_stride                     # recorded into config.json
    return model


def build(whisper_repo: str = WHISPER, lm_repo: str = LM, dtype=torch.float32,
          projector: str = "linear", pool_stride: int = 2, encoder_ckpt: str | None = None):
    """encoder_ckpt: a local encoder trained by train_encoder.py, used INSTEAD of whisper_repo's.

    Its meta.pt carries the WhisperConfig it was trained with, so the audio tower is shaped from
    that rather than from whisper-small -- the 50M encoder is d576, not d768, and the projector
    picks up the narrower input automatically since it is built from the tower's real width."""
    tok = build_tokenizer(lm_repo)
    audio_token_id = tok.convert_tokens_to_ids(AUDIO_TOKEN)

    if encoder_ckpt:
        meta = torch.load(Path(encoder_ckpt).parent / "meta.pt", map_location="cpu",
                          weights_only=False)
        wcfg = WhisperConfig(**meta["config"])
    else:
        wcfg = WhisperConfig.from_pretrained(whisper_repo)
    audio_config = Qwen2AudioEncoderConfig(
        d_model=wcfg.d_model,
        encoder_layers=wcfg.encoder_layers,
        encoder_attention_heads=wcfg.encoder_attention_heads,
        encoder_ffn_dim=wcfg.encoder_ffn_dim,
        num_mel_bins=wcfg.num_mel_bins,
        max_source_positions=wcfg.max_source_positions,
        activation_function=wcfg.activation_function,
        scale_embedding=wcfg.scale_embedding,
    )
    text_config = AutoConfig.from_pretrained(lm_repo)
    # Emhotob ships `use_cache: false` (a pretraining leftover). Left as-is, generate() runs
    # cacheless and Qwen2Audio re-encodes the audio through the 88M tower on EVERY decoded token.
    text_config.use_cache = True
    # VoxtralConfig-style shells inject Mistral defaults incl. rope_theta 1e8. Qwen2Audio does not,
    # but assert rather than trust: a checkpoint using the legacy flat key would inherit silently.
    assert text_config.rope_parameters["rope_theta"] == 10000, text_config.rope_parameters

    cfg = Qwen2AudioConfig(audio_config=audio_config, text_config=text_config,
                           audio_token_index=audio_token_id)
    model = Qwen2AudioForConditionalGeneration(cfg).to(dtype)

    # --- graft the encoder ---------------------------------------------------------------
    # Load donors *through* from_pretrained rather than reading safetensors, so transformers'
    # checkpoint key-conversion map applies -- the same reason the vision graft did it this way.
    if encoder_ckpt:
        # train_encoder.py saves the bare WhisperEncoder state_dict, already in tower key space.
        enc_sd = torch.load(encoder_ckpt, map_location="cpu", weights_only=False)
        enc_sd = {k: v.to(dtype) for k, v in enc_sd.items()}
        whisper = None
    else:
        whisper = WhisperForConditionalGeneration.from_pretrained(whisper_repo, dtype=dtype)
        enc_sd = {k[len("model.encoder."):]: v
                  for k, v in whisper.state_dict().items() if k.startswith("model.encoder.")}
    missing, unexpected = model.model.audio_tower.load_state_dict(enc_sd, strict=False)
    # avg_pooler is parameter-free, so a clean graft leaves nothing missing and nothing unexpected
    assert not unexpected, f"whisper keys the audio tower did not want: {unexpected[:5]}"
    assert not missing, f"audio tower keys whisper did not provide: {missing[:5]}"
    del whisper

    # --- graft the language model --------------------------------------------------------
    lm = AutoModelForCausalLM.from_pretrained(lm_repo, dtype=dtype)
    lm_sd = {k[len("model."):]: v for k, v in lm.state_dict().items() if k.startswith("model.")}
    missing, unexpected = model.model.language_model.load_state_dict(lm_sd, strict=False)
    assert not unexpected, f"LM keys the shell did not want: {unexpected[:5]}"
    assert not missing, f"LM keys Emhotob did not provide: {missing[:5]}"
    # Emhotob ties lm_head to embed_tokens, so the causal head has no separate tensor to copy --
    # initialise it FROM the embeddings but leave it untied. Tying would save 16.4M, but
    # save_pretrained then omits lm_head.weight, and any reload through the stock Qwen2Audio class
    # silently gets a randomly-initialised head. Untied also lets the output head specialise for
    # the ASR distribution, which differs from the LM one.
    model.lm_head.weight.data.copy_(lm.get_input_embeddings().weight.data)
    del lm

    # --- grow the vocab for the audio placeholders ----------------------------------------
    model.resize_token_embeddings(len(tok), pad_to_multiple_of=PAD_TO_MULTIPLE)
    model.config.text_config.vocab_size = model.lm_head.out_features
    model.config.text_config.use_cache = True
    model.generation_config.use_cache = True
    model.generation_config.pad_token_id = tok.pad_token_id
    model.generation_config.bos_token_id = tok.bos_token_id
    model.generation_config.eos_token_id = tok.eos_token_id

    if projector != "linear":
        stock = model.model.multi_modal_projector.linear
        model.model.multi_modal_projector.linear = make_projector(
            stock.in_features, stock.out_features, projector).to(stock.weight.dtype)
    set_audio_frame_rate(model, pool_stride)
    return model, tok


@torch.no_grad()
def calibrate_projector(model, audio_features, feature_lengths=None):
    """Scale the projector so audio tokens land at the same RMS as Emhotob's text embeddings.

    Ported verbatim in spirit from the vision graft, where a freshly-initialised projector emitted
    modality tokens ~36x the magnitude of the token embeddings and alignment simply never started.
    One scalar, measured once on a real batch. If training stalls, check this before the LR.
    """
    tower = model.model.audio_tower
    hidden = tower(audio_features).last_hidden_state
    projected = model.model.multi_modal_projector(hidden)

    target = model.get_input_embeddings().weight.float().pow(2).mean().sqrt()
    measured = projected.float().pow(2).mean().sqrt()
    factor = (target / measured).item()
    proj = _last_linear(model.model.multi_modal_projector.linear)
    proj.weight.data.mul_(factor)
    if proj.bias is not None:
        proj.bias.data.mul_(factor)

    after = model.model.multi_modal_projector(hidden).float().pow(2).mean().sqrt()
    return {"target_rms": target.item(), "before_rms": measured.item(),
            "factor": factor, "after_ratio": (after / target).item()}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--whisper", default=WHISPER)
    ap.add_argument("--lm", default=LM)
    ap.add_argument("--save", default=None)
    ap.add_argument("--projector", default="linear", choices=["linear", "mlp"])
    a = ap.parse_args()

    model, tok = build(a.whisper, a.lm, projector=a.projector)
    n = lambda m: sum(p.numel() for p in m.parameters())
    tower, proj, lang = model.model.audio_tower, model.model.multi_modal_projector, model.model.language_model
    print(f"[+] audio_tower  {n(tower)/1e6:8.2f}M   (frozen in training)")
    print(f"[+] projector    {n(proj)/1e6:8.2f}M   {proj.linear}")
    print(f"[+] language     {n(lang)/1e6:8.2f}M   + lm_head {n(model.lm_head)/1e6:.2f}M")
    print(f"[+] TOTAL        {n(model)/1e6:8.2f}M")
    print(f"[+] vocab {len(tok)} -> embedding {model.get_input_embeddings().weight.shape[0]}"
          f"  audio_token_id={tok.convert_tokens_to_ids(AUDIO_TOKEN)}")

    # 3 seconds of audio through the real feature pipeline, to prove the wiring and the frame math
    from transformers import WhisperFeatureExtractor
    fe = WhisperFeatureExtractor.from_pretrained(a.whisper)
    import numpy as np
    feats = fe([np.zeros(16000 * 3, dtype=np.float32)], sampling_rate=16000, return_tensors="pt")
    stats = calibrate_projector(model, feats.input_features)
    print(f"[+] calibration  target_rms {stats['target_rms']:.4f}  before {stats['before_rms']:.4f}"
          f"  x{stats['factor']:.3f}  -> ratio {stats['after_ratio']:.3f}")
    _, out_len = model.model.audio_tower._get_feat_extract_output_lengths(torch.tensor([16000 * 3 // 160]))
    print(f"[+] 3.0s audio -> {int(out_len[0])} audio tokens  ({int(out_len[0])/3.0:.1f} tokens/sec)")

    if a.save:
        model.save_pretrained(a.save); tok.save_pretrained(a.save)
        print(f"[+] saved -> {a.save}")


if __name__ == "__main__":
    main()