File size: 10,716 Bytes
4404ffe
 
 
9035d80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
254
255
256
257
258
259
260
261
262
263
---
license: apache-2.0
---

```python

"""Create a tiny random `qwen3_asr` (Qwen3-ForcedAligner) fixture.

Produces a ``Qwen3ASRForConditionalGeneration`` whose top-level ``model_type`` is
``qwen3_asr`` and whose ``thinker_config.model_type`` is ``qwen3_forced_aligner``
(so the model builds the forced-aligner ``classify_num`` head instead of the
vocabulary head), wrapping a ``qwen3_asr_audio_encoder`` audio tower and a
``qwen3_asr_text`` decoder.

The tiny artifact preserves the real architecture. Weights are random; the
original 0.6B checkpoint is never downloaded.

Requirements: the third-party ``qwen_asr`` package (registers the trust-remote-code
architecture with ``AutoConfig``/``AutoModel``), transformers, torch, soundfile.

Usage:
    python qwen3_forced_aligner_tiny_model_generation.py
    # writes ./tiny-random-qwen3-forced-aligner next to this script.
"""

import json
import os
import sys
from pathlib import Path

import numpy as np
import torch

# qwen_asr must be imported to register the model with AutoConfig/AutoModel.
import qwen_asr  # noqa: F401
from transformers import AutoConfig, AutoProcessor
from qwen_asr.core.transformers_backend.modeling_qwen3_asr import Qwen3ASRForConditionalGeneration


SEED = 42
MODEL_ID = "Qwen/Qwen3-ForcedAligner-0.6B"
OUTPUT_DIR = Path(__file__).resolve().parent / "tiny-random-qwen3-forced-aligner"

# The forced-aligner path is selected by the instance model_type of the thinker
# sub-config. ``PretrainedConfig.to_dict`` serializes the class attribute
# ("qwen3_asr_thinker"), so the saved config.json must be patched back to this
# value for the reloaded model to keep the classify head.
FORCED_ALIGNER_MODEL_TYPE = "qwen3_forced_aligner"

MAX_PARAMS = 25_000_000
MAX_BYTES = 100 * 1024 * 1024


def register_default_rope_shim():
    """Compatibility shim for transformers >= 5.

    The ``qwen_asr`` remote code targets transformers 4.57, where the
    ``"default"`` RoPE type is an entry in ``ROPE_INIT_FUNCTIONS``. Transformers 5
    dropped that dict entry (turning it into a rotary-embedding method), so the
    remote code's ``ROPE_INIT_FUNCTIONS[self.rope_type]`` lookup and the tf5
    weight-init path both fail for ``rope_type == "default"``. Re-register an
    equivalent classic-RoPE initializer and attach it to the remote rotary
    modules under the name tf5 expects.
    """
    from transformers.modeling_rope_utils import ROPE_INIT_FUNCTIONS
    from qwen_asr.core.transformers_backend import modeling_qwen3_asr as mod

    def _compute_default_rope_parameters(config, device=None, seq_len=None, **kwargs):
        rope_params = getattr(config, "rope_parameters", None) or getattr(config, "rope_scaling", None) or {}
        base = rope_params.get("rope_theta") if isinstance(rope_params, dict) else None
        if base is None:
            base = getattr(config, "rope_theta", 10000.0)
        dim = getattr(config, "head_dim", None) or config.hidden_size // config.num_attention_heads
        inv_freq = 1.0 / (
            base ** (torch.arange(0, dim, 2, dtype=torch.int64).to(device=device, dtype=torch.float) / dim)
        )
        return inv_freq, 1.0

    ROPE_INIT_FUNCTIONS.setdefault("default", _compute_default_rope_parameters)

    # tf5 `_init_weights` calls `module.compute_default_rope_parameters` on every
    # rotary module whose `rope_type == "default"`.
    for name in dir(mod):
        obj = getattr(mod, name)
        if isinstance(obj, type) and name.endswith("RotaryEmbedding"):
            if not hasattr(obj, "compute_default_rope_parameters"):
                obj.compute_default_rope_parameters = staticmethod(_compute_default_rope_parameters)


def build_config():
    config = AutoConfig.from_pretrained(MODEL_ID, trust_remote_code=True)

    thinker = config.thinker_config
    # Preserve the forced-aligner identity + classify head width.
    assert "forced_aligner" in thinker.model_type, thinker.model_type
    text = thinker.text_config
    audio = thinker.audio_config

    # transformers 5 dropped the generic special-token attributes from the base
    # PretrainedConfig; the remote code still reads config.pad_token_id.
    if not hasattr(thinker, "pad_token_id"):
        thinker.pad_token_id = None

    # --- Text decoder (qwen3_asr_text): shrink transformer, keep tokenizer vocab. ---
    text.hidden_size = 64
    text.head_dim = 16
    text.num_attention_heads = 4
    text.num_key_value_heads = 2
    text.intermediate_size = 128
    text.num_hidden_layers = 2
    text.max_position_embeddings = 2048

    # Keep the interleaved M-RoPE path; rescale mrope_section to sum to head_dim // 2.
    # Original invariant: sum([24, 20, 20]) == 64 == original head_dim(128) // 2.
    rope_scaling = dict(text.rope_scaling)
    assert rope_scaling["mrope_interleaved"] is True, rope_scaling
    assert sum(rope_scaling["mrope_section"]) == 128 // 2, rope_scaling["mrope_section"]
    rope_scaling["mrope_section"] = [4, 2, 2]  # sum == 8 == head_dim(16) // 2
    text.rope_scaling = rope_scaling

    # --- Audio encoder (qwen3_asr_audio_encoder): shrink conformer, keep mel bins. ---
    audio.d_model = 64
    audio.encoder_layers = 2
    audio.num_hidden_layers = 2
    audio.encoder_attention_heads = 4  # head_dim = d_model / heads = 16
    audio.encoder_ffn_dim = 128
    audio.downsample_hidden_size = 32
    # output_dim must match the text hidden size so audio embeddings merge into
    # the decoder embedding space.
    audio.output_dim = 64
    # num_mel_bins stays 128: the processor always emits 128 mel-bin features.

    # Force float32 at every effective precision field.
    for sub in (config, thinker, text, audio):
        sub.dtype = "float32"
        sub.torch_dtype = "float32"

    return config


def patch_saved_thinker_model_type(output_dir: Path) -> None:
    """Restore the forced-aligner thinker model_type in the saved config.json."""
    config_path = output_dir / "config.json"
    with open(config_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    data.setdefault("thinker_config", {})["model_type"] = FORCED_ALIGNER_MODEL_TYPE
    with open(config_path, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")


def normalize_tied_weight_keys(model) -> None:
    """Convert legacy list-format ``_tied_weights_keys`` to the tf5 dict format.

    The ``qwen_asr`` remote code declares ``_tied_weights_keys`` as a list (the
    transformers 4.x format), but tf5's ``save_pretrained`` calls ``.keys()`` on
    it. Nothing is actually tied here (``tie_word_embeddings=False`` and the
    forced-aligner ``lm_head`` differs in shape from the embeddings), so an empty
    mapping is correct.
    """
    for module in model.modules():
        keys = getattr(module, "_tied_weights_keys", None)
        if isinstance(keys, list):
            module._tied_weights_keys = {}


def generate_audio_data(seconds: float = 2.0, sample_rate: int = 16000):
    np.random.seed(SEED)
    t = np.linspace(0, 1.0, int(sample_rate * seconds), endpoint=False)
    audio = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
    return audio, sample_rate


def run_inference(output_dir: Path) -> None:
    """Reload the exact saved artifact and run a forward + short generate."""
    reloaded_config = AutoConfig.from_pretrained(output_dir, trust_remote_code=True)
    assert reloaded_config.model_type == "qwen3_asr", reloaded_config.model_type
    assert "forced_aligner" in reloaded_config.thinker_config.model_type, reloaded_config.thinker_config.model_type
    assert reloaded_config.thinker_config.audio_config.model_type == "qwen3_asr_audio_encoder"
    assert reloaded_config.thinker_config.text_config.model_type == "qwen3_asr_text"

    model = Qwen3ASRForConditionalGeneration.from_pretrained(output_dir, trust_remote_code=True).float().eval()

    # The classify head width must equal classify_num for a forced aligner.
    classify_num = reloaded_config.thinker_config.classify_num
    assert model.thinker.lm_head.out_features == classify_num, (
        model.thinker.lm_head.out_features,
        classify_num,
    )

    processor = AutoProcessor.from_pretrained(output_dir, trust_remote_code=True)
    audio_data, sample_rate = generate_audio_data()
    text_prompt = processor.apply_chat_template(
        [
            {"role": "system", "content": ""},
            {"role": "user", "content": [{"type": "audio", "audio": ""}]},
        ],
        add_generation_prompt=True,
        tokenize=False,
    )
    inputs = processor(text=text_prompt, audio=audio_data, sampling_rate=sample_rate, return_tensors="pt")

    with torch.no_grad():
        outputs = model.thinker(
            input_ids=inputs["input_ids"],
            input_features=inputs["input_features"],
            feature_attention_mask=inputs["feature_attention_mask"],
            attention_mask=inputs["attention_mask"],
        )
    logits = outputs.logits
    print(f"  forward ok, logits shape: {tuple(logits.shape)} (last dim == classify_num={classify_num})")
    assert logits.shape[-1] == classify_num

    with torch.no_grad():
        generated = model.generate(
            input_ids=inputs["input_ids"],
            input_features=inputs["input_features"],
            feature_attention_mask=inputs["feature_attention_mask"],
            attention_mask=inputs["attention_mask"],
            max_new_tokens=5,
            do_sample=False,
        )
    if hasattr(generated, "sequences"):
        generated = generated.sequences
    print(f"  generate ok, output shape: {tuple(generated.shape)}")


def main():
    torch.manual_seed(SEED)

    register_default_rope_shim()

    config = build_config()
    model = Qwen3ASRForConditionalGeneration(config).float().eval()

    num_params = sum(p.numel() for p in model.parameters())
    num_bytes = sum(p.numel() * p.element_size() for p in model.parameters())
    print(f"params={num_params:,}  bytes={num_bytes:,} ({num_bytes / 1024 / 1024:.2f} MB)")
    if num_params > MAX_PARAMS:
        sys.exit(f"param budget exceeded: {num_params:,} > {MAX_PARAMS:,}")
    if num_bytes > MAX_BYTES:
        sys.exit(f"memory budget exceeded: {num_bytes:,} > {MAX_BYTES:,}")

    normalize_tied_weight_keys(model)

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(OUTPUT_DIR, safe_serialization=True)

    processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
    processor.save_pretrained(OUTPUT_DIR)

    patch_saved_thinker_model_type(OUTPUT_DIR)

    print(f"Saved tiny qwen3 forced aligner model to: {OUTPUT_DIR}")
    run_inference(OUTPUT_DIR)
    print("Inference smoke test passed.")


if __name__ == "__main__":
    main()


```