File size: 9,600 Bytes
065ec46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Backbone + one scoring head. No lm_head, no generation.

A decision is one forward pass: render state, question and candidates; take the
backbone's final hidden state at each candidate's last token; a small MLP turns
each into a logit; softmax over the candidates. The backbone's pretraining is
where the world knowledge comes from, LoRA adapts it, and the head is the only
part that is new.

Calibration is a temperature per question type, fitted on held-out data after
training (see evaluate.py) and stored beside the weights. It changes no
answer, only how sure the model says it is.
"""

from __future__ import annotations

import json
import math
import os

import torch
from torch import nn

from .render import Renderer
from .schema import Decision


class Head(nn.Module):
    def __init__(self, hidden: int):
        super().__init__()
        mid = max(64, hidden // 4)
        self.net = nn.Sequential(nn.LayerNorm(hidden), nn.Linear(hidden, mid),
                                 nn.GELU(), nn.Linear(mid, 1))

    def forward(self, h: torch.Tensor) -> torch.Tensor:
        return self.net(h).squeeze(-1)


def lora_targets(model) -> list[str]:
    """Attention and MLP projections, by whatever names this family uses."""
    names = {n.rsplit(".", 1)[-1] for n, m in model.named_modules()
             if isinstance(m, nn.Linear) or type(m).__name__ == "Conv1D"}
    llama = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
    got = [n for n in llama if n in names]
    if got:
        return got
    return [n for n in ("c_attn", "c_proj", "c_fc") if n in names]   # gpt2


class DecisionModel(nn.Module):
    def __init__(self, backbone, tok, config: dict):
        super().__init__()
        self.backbone = backbone
        self.tok = tok
        self.config = config
        hidden = backbone.config.hidden_size
        self.head = Head(hidden)
        self.renderer = Renderer(tok, config.get("max_state_tokens", 384),
                                 config.get("max_option_tokens", 32))
        self.temperature = {t: 1.0 for t in ("choice", "score", "null")}

    # ---- construction ---------------------------------------------------- #
    @classmethod
    def create(cls, base: str, lora_rank: int = 16, lora_alpha: int = 32,
               dtype: torch.dtype = torch.float32, gradient_checkpointing: bool = False,
               **cfg) -> "DecisionModel":
        from peft import LoraConfig, get_peft_model
        from transformers import AutoModel, AutoTokenizer

        tok = AutoTokenizer.from_pretrained(base)
        if tok.pad_token_id is None:
            tok.pad_token = tok.eos_token
        bb = AutoModel.from_pretrained(base, dtype=dtype)
        if gradient_checkpointing:
            bb.gradient_checkpointing_enable(
                gradient_checkpointing_kwargs={"use_reentrant": False})
            bb.enable_input_require_grads()
        bb.config.use_cache = False
        targets = lora_targets(bb)
        bb = get_peft_model(bb, LoraConfig(
            r=lora_rank, lora_alpha=lora_alpha, lora_dropout=0.05,
            target_modules=targets, bias="none"))
        config = {"base": base, "lora_rank": lora_rank, "lora_alpha": lora_alpha,
                  "lora_targets": targets, **cfg}
        return cls(bb, tok, config)

    def save(self, path: str) -> None:
        os.makedirs(path, exist_ok=True)
        self.backbone.save_pretrained(os.path.join(path, "adapter"))
        self.tok.save_pretrained(os.path.join(path, "tokenizer"))
        # safetensors, not a pickle: loading a .pt can run code, and a model
        # meant to be downloaded should not ask anyone to trust that.
        from safetensors.torch import save_file
        save_file({k: v.detach().cpu().contiguous() for k, v in self.head.state_dict().items()},
                  os.path.join(path, "head.safetensors"))
        with open(os.path.join(path, "decisionx.json"), "w") as fh:
            json.dump({"config": self.config, "temperature": self.temperature},
                      fh, indent=2)

    @classmethod
    def load(cls, path: str, dtype: torch.dtype = torch.float32,
             device: str | None = None) -> "DecisionModel":
        from peft import PeftModel
        from transformers import AutoModel, AutoTokenizer

        meta = json.load(open(os.path.join(path, "decisionx.json")))
        cfg = meta["config"]
        tok = AutoTokenizer.from_pretrained(os.path.join(path, "tokenizer"))
        bb = AutoModel.from_pretrained(cfg["base"], dtype=dtype)
        bb = PeftModel.from_pretrained(bb, os.path.join(path, "adapter"))
        m = cls(bb, tok, cfg)
        st = os.path.join(path, "head.safetensors")
        if os.path.exists(st):
            from safetensors.torch import load_file
            m.head.load_state_dict(load_file(st))
        else:                                        # saved before v2's export
            m.head.load_state_dict(torch.load(os.path.join(path, "head.pt"),
                                              map_location="cpu", weights_only=True))
        m.temperature.update(meta.get("temperature") or {})
        m.eval()
        if device:
            m.to(device)
        return m

    # ---- the forward pass ------------------------------------------------ #
    def forward(self, input_ids, attention_mask, option_pos) -> torch.Tensor:
        """(B, K) logits; padded candidate slots (option_pos == -1) are -inf."""
        out = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
        h = out.last_hidden_state                                   # (B, L, H)
        idx = option_pos.clamp(min=0)
        picked = torch.gather(h, 1, idx.unsqueeze(-1).expand(-1, -1, h.size(-1)))
        logits = self.head(picked.float())                          # (B, K)
        return logits.masked_fill(option_pos < 0, float("-inf"))

    # ---- the API ----------------------------------------------------------- #
    @torch.no_grad()
    def decide(self, type: str, question: str, state: str = "",
               options: list[str] | None = None, scale: dict | None = None,
               calibration=None) -> dict:
        """One decision. Returns the answer, a probability per candidate, and
        a confidence; the shape of the answer depends on the type. Pass the
        result of `calibrate()` to apply a per-task calibration."""
        d = Decision(type=type, state=state, question=question,
                     options=list(options or []), scale=scale).validate()
        return self.decide_many([d], calibration)[0]

    @torch.no_grad()
    def raw_logits(self, decisions: list[Decision]) -> list[torch.Tensor]:
        """Uncalibrated logits over each decision's candidates."""
        from .data.collate import collate
        dev = next(self.parameters()).device
        batch = collate([self.renderer.render(d) for d in decisions],
                        self.tok.pad_token_id)
        logits = self(batch["input_ids"].to(dev), batch["attention_mask"].to(dev),
                      batch["option_pos"].to(dev)).float().cpu()
        return [logits[i, :len(d.candidates())] for i, d in enumerate(decisions)]

    @torch.no_grad()
    def decide_many(self, decisions: list[Decision], calibration=None) -> list[dict]:
        return [self._answer(d, lg, calibration)
                for d, lg in zip(decisions, self.raw_logits(decisions))]

    @torch.no_grad()
    def calibrate(self, examples: list[Decision], strength: float | None = None,
                  batch_size: int = 16):
        """Fit a calibration for one task from labelled examples (20-50 is
        plenty). Every example must be the same type; for a choice task they
        should share an option set, in any order."""
        from .calibration import fit, fit_auto
        if not examples or any(d.target() is None for d in examples):
            raise ValueError("calibration needs labelled examples")
        types = {d.type for d in examples}
        if len(types) != 1:
            raise ValueError(f"one task at a time; got types {sorted(types)}")
        rows = []
        for i in range(0, len(examples), batch_size):
            chunk = examples[i:i + batch_size]
            for d, lg in zip(chunk, self.raw_logits(chunk)):
                rows.append((lg.tolist(), d.candidates(), d.target()))
        t = types.pop()
        T0 = self.temperature.get(t, 1.0)
        if strength is None:         # chosen by cross-validation on these examples
            return fit_auto(rows, t, T0, examples[0].question)
        return fit(rows, t, T0, strength, examples[0].question)

    def _answer(self, d: Decision, logits: torch.Tensor, calibration=None) -> dict:
        if calibration is not None:
            p = calibration.apply(d, logits)
        else:
            p = torch.softmax(logits / self.temperature.get(d.type, 1.0), -1)
        probs = [round(float(x), 6) for x in p]
        best = int(p.argmax())
        if d.type == "null":
            return {"type": "null", "probability": probs[0], "answer": probs[0] >= 0.5}
        if d.type == "score":
            vals = d.scale_values()
            mean = sum(v * q for v, q in zip(vals, probs))
            sd = math.sqrt(sum(q * (v - mean) ** 2 for v, q in zip(vals, probs)))
            return {"type": "score", "score": round(mean, 4), "mode": vals[best],
                    "spread": round(sd, 4), "distribution": dict(zip(vals, probs))}
        return {"type": "choice", "answer": d.options[best], "index": best,
                "confidence": probs[best], "probabilities": dict(zip(d.options, probs))}