File size: 7,198 Bytes
3d7c238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Qwen3.5 text backbone with LoRA and one linear head per field (needs torch; runs on the pod).

Adapted from the read-only reference ~/yt_live_analysis/training/model.py (arm H). Only the text decoder is
loaded: the checkpoints are Qwen3_5ForConditionalGeneration, and loading Qwen3_5ForCausalLM with the
text config maps the language-model weights and ignores the vision tower and the MTP layer. The LM head is
dropped. Rows are right-padded (left padding would run pad tokens through the Gated DeltaNet state before
the post) and each row is pooled at its last real token. The loss is normalised per optimiser step
(``data.step_counts``), so splitting a step into micro-batches leaves its gradient unchanged.
"""
from __future__ import annotations

from pathlib import Path

import torch
from torch import nn
import torch.nn.functional as F

LORA_TARGETS = ['q_proj', 'k_proj', 'v_proj', 'o_proj', 'in_proj_qkv', 'in_proj_z', 'out_proj',
                'gate_proj', 'up_proj', 'down_proj']


def text_config(path):
    from transformers import AutoConfig
    config = AutoConfig.from_pretrained(path)
    return getattr(config, 'text_config', config)


def load_backbone(path, device, dtype=torch.bfloat16, attn='sdpa'):
    """Text-only load; refuses a checkpoint with missing weights (they would be randomly initialised)."""
    from transformers import Qwen3_5ForCausalLM
    kw = {'device_map': {'': str(device)}} if device.type == 'cuda' else {}
    lm, info = Qwen3_5ForCausalLM.from_pretrained(path, config=text_config(path), dtype=dtype, attn_implementation=attn,
                                                  output_loading_info=True, **kw)
    if info['missing_keys']:
        raise RuntimeError('Missing weights in %s: %s' % (path, sorted(info['missing_keys'])[:5]))
    return lm


def load_tokenizer(path):
    from transformers import AutoTokenizer
    tok = AutoTokenizer.from_pretrained(path, split_special_tokens=True)
    tok.padding_side = 'right'
    if tok.pad_token_id is None:
        tok.pad_token = tok.eos_token
    return tok


def tokenizer_fn(tok):
    """Plain token ids: no added special tokens, and special-token strings in the text stay ordinary text."""
    def tokenize(text: str) -> list[int]:
        return tok(text, add_special_tokens=False, split_special_tokens=True)['input_ids']
    return tokenize


def add_lora(module, r: int, alpha: int, dropout: float):
    from peft import LoraConfig, get_peft_model
    return get_peft_model(module, LoraConfig(r=r, lora_alpha=alpha, lora_dropout=dropout,
                                             target_modules=LORA_TARGETS, bias='none'))


def pad(rows: list[list[int]], pad_id: int, device):
    """Right-padded ids, attention mask and each row's last real index."""
    width = max(len(r) for r in rows)
    ids = torch.full((len(rows), width), pad_id, dtype=torch.long)
    mask = torch.zeros((len(rows), width), dtype=torch.long)
    for i, r in enumerate(rows):
        ids[i, :len(r)] = torch.tensor(r, dtype=torch.long)
        mask[i, :len(r)] = 1
    last = mask.sum(1) - 1
    return ids.to(device), mask.to(device), last.to(device)


class HeadsModel(nn.Module):
    """LoRA backbone; the final-norm hidden state at the last real token feeds one linear head per field."""

    def __init__(self, backbone, hidden: int, sizes: list[int], dropout: float = 0.1):
        super().__init__()
        self.backbone = backbone
        self.dropout = nn.Dropout(dropout)
        self.heads = nn.ModuleList([nn.Linear(hidden, k) for k in sizes])

    def forward(self, ids, mask, last) -> list[torch.Tensor]:
        h = self.backbone(input_ids=ids, attention_mask=mask, use_cache=False).last_hidden_state
        pooled = h[torch.arange(h.shape[0], device=h.device), last].float()
        pooled = self.dropout(pooled)
        return [head(pooled) for head in self.heads]


def step_loss(logits: list[torch.Tensor], labels: torch.Tensor, counts: list[int]):
    """Sum over fields of (summed cross-entropy over this micro-batch's non-null rows) / (step's non-null count).

    ``labels`` is [rows, fields] with -1 for a null target. Summing this over a step's micro-batches gives,
    per field, the mean over the step's non-null rows, whatever the split. Returns (loss, per-field sums).
    """
    total = logits[0].sum() * 0.0
    per_field = []
    for i, lg in enumerate(logits):
        y = labels[:, i]
        keep = y >= 0
        if bool(keep.any()):
            ce = F.cross_entropy(lg[keep].float(), y[keep], reduction='sum')
            total = total + ce / counts[i]
            per_field.append(float(ce.detach()))
        else:
            per_field.append(0.0)
    return total, per_field


def build(cfg: dict, sizes: list[int], device, *, train: bool, adapter_dir=None, heads_path=None):
    lm = load_backbone(cfg['model'], device, dtype=getattr(torch, cfg.get('dtype', 'bfloat16')))
    text = lm.model
    if train:
        text.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
        text.config.use_cache = False
    if adapter_dir:
        from peft import PeftModel
        text = PeftModel.from_pretrained(text, str(adapter_dir), is_trainable=train)
    else:
        text = add_lora(text, cfg['lora_r'], cfg['lora_alpha'], cfg['lora_dropout'])
    lm.lm_head = None
    model = HeadsModel(text, lm.config.hidden_size, sizes, cfg['head_dropout'])
    if heads_path:
        model.heads.load_state_dict(torch.load(heads_path, map_location='cpu'))
    model = model.to(device)
    # from_pretrained returns modules in eval mode, and gradient checkpointing only runs in training mode.
    model.train(train)
    return model


def save_checkpoint(model: HeadsModel, target: Path, extra: dict | None = None, trainer=None) -> None:
    """Atomic directory write: adapter/, heads.pt, state.json and (for resume points) trainer.pt."""
    import json
    import shutil
    tmp = target.with_name(target.name + '.tmp')
    shutil.rmtree(tmp, ignore_errors=True)
    tmp.mkdir(parents=True)
    model.backbone.save_pretrained(str(tmp / 'adapter'))
    torch.save(model.heads.state_dict(), tmp / 'heads.pt')
    if trainer is not None:
        torch.save(trainer, tmp / 'trainer.pt')
    (tmp / 'state.json').write_text(json.dumps(extra or {}))
    shutil.rmtree(target, ignore_errors=True)
    tmp.rename(target)


@torch.no_grad()
def predict(model: HeadsModel, encoded: list[list[int]], pad_id: int, batches: list[list[int]], device,
            field_names: list[str]) -> dict[int, dict[str, list[float]]]:
    """Raw logits per field for each encoded row index (dropout off)."""
    was_training = model.training
    model.eval()
    out = {}
    amp = dict(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == 'cuda')
    for b in batches:
        ids, mask, last = pad([encoded[i] for i in b], pad_id, device)
        with torch.autocast(**amp):
            logits = model(ids, mask, last)
        values = [lg.float().cpu().tolist() for lg in logits]
        for j, i in enumerate(b):
            out[i] = {f: values[k][j] for k, f in enumerate(field_names)}
    if was_training:
        model.train()
    return out