File size: 10,498 Bytes
30b1727 | 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 | """Decision Index engine for the dense LoRA checkpoints trained by dense/train_dense.py (Qwen3.5-9B, Gemma-4-12B, ...).
Usage, from the decision-index checkout (PYTHONPATH must include this directory and /mnt/datasets/pylibs):
python -m decision_index pipeline --engine decision_engine_dense:DenseSlotEngine \
--option model=Qwen/Qwen3.5-9B --option run=/mnt/datasets/dense-runs/qwen35-9b-pilot --suite-dir <suite-0.3> --out runs/qwen35-9b-pilot
Options (all optional except model): model run (training output dir; omit for the stock model + zero residual = zero-shot baseline) ckpt (best|final|dir)
quant (none|nf4) merge (auto: merge the LoRA into the weights for bf16) temperature (auto|none|global|bucket|feat|hidden) prefix_cache (true) max_tokens (32768)
Every question is answered on its own with the fixed answer-slot prompt. A row's questions share the state, so the state prefix is run once and its
cache (attention KV + recurrent states) is copied per question. Prompts longer than max_tokens, or that run out of memory, are `Unsupported`; nothing is truncated.
"""
import copy
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import numpy as np # noqa: E402
import torch # noqa: E402
import answer_slot as AS # noqa: E402
from common import QTYPES, temp_bucket # noqa: E402
from decision_index.engines.base import Engine, Unsupported # noqa: E402
from dense import temperature as TM # noqa: E402
from dense.train_dense import DensePipeline, load_model # noqa: E402
LO, HI = 0.5, 5.0
class DenseSlotEngine(Engine):
name = "dense-slot"
latency = "In-process request wall time including prompt construction, one cached state prefix per row and one suffix pass per question; excludes model loading."
def __init__(self, model="Qwen/Qwen3.5-9B", run=None, ckpt="best", quant="none", merge="auto", temperature="auto", prefix_cache=True, max_tokens=32768, **options):
super().__init__(**options)
from transformers import AutoTokenizer
self.tok = AutoTokenizer.from_pretrained(model)
if self.tok.pad_token_id is None:
self.tok.pad_token_id = self.tok.eos_token_id
self.codec = AS.SlotCodec(self.tok, state_max=None, option_max=None, template_kwargs={"enable_thinking": False},
mask_id=self.tok.pad_token_id or 0, strip_label_keys=True, autoregressive=True)
lm = load_model(model, quant)
lm.config.use_cache = True
softcap = getattr(lm.config.get_text_config(), "final_logit_softcapping", None)
ckpt_dir = None
if run:
from peft import PeftModel
ckpt_dir = ckpt if os.path.isdir(ckpt) else os.path.join(run, ckpt)
lm = PeftModel.from_pretrained(lm, os.path.join(ckpt_dir, "lora"))
if merge is True or (merge == "auto" and quant == "none"):
lm = lm.merge_and_unload()
self.pipe = DensePipeline(lm, self.codec.cand, softcap).to("cuda").eval()
if ckpt_dir:
self.pipe.head.load_state_dict(torch.load(os.path.join(ckpt_dir, "head.pt"), map_location="cuda"))
self.limit = int(max_tokens)
self.prefix_cache = bool(prefix_cache)
self.model_id = model
self._load_temperature(ckpt_dir, temperature)
self.provenance = {
"kind": "dense-answer-slot", "repo": model, "run": run, "checkpoint": ckpt_dir, "quant": quant, "merged": bool(run and (merge is True or (merge == "auto" and quant == "none"))),
"temperature": self.t_kind, "context_limit_tokens": self.limit, "prefix_cache": self.prefix_cache, "layout": AS.LAYOUT,
"policy": "One chat prompt per question (state in <text> tags, options as letters A-Z up to 26 options, one forced-' No' Yes/No slot per option beyond that, a Yes/No answer for noul); "
"the answer is the next token read through the frozen LM-head rows of the answer tokens plus a trained residual; softmax over options with the fitted temperature. "
"A row's questions share a cached state prefix. Prompts over the limit are refused as unsupported, never truncated; no option is filtered.",
}
# ------------------------------------------------------------------ temperature
def _load_temperature(self, ckpt_dir, mode):
self.t_kind, self.t_json, self.t_head = "none", None, None
if not ckpt_dir or mode == "none":
return
path = os.path.join(ckpt_dir, "temperature.json")
if not os.path.isfile(path):
if mode != "auto":
raise FileNotFoundError(f"{path} (run python -m dense.temperature fit first)")
return
self.t_json = json.load(open(path))
kind = self.t_json["selected"] if mode == "auto" else mode
self.t_kind = kind
if kind in ("feat", "hidden"):
blob = torch.load(os.path.join(ckpt_dir, "temperature.pt"), map_location="cuda")
if blob["kind"] != kind:
raise ValueError(f"temperature.pt holds a '{blob['kind']}' head, not '{kind}'")
self.t_head = TM.TempHead(hidden=blob["hidden"]).to("cuda")
self.t_head.load_state_dict(blob["state"])
self.t_head.eval()
def _temperature(self, logits: torch.Tensor, pooled: torch.Tensor, qtype: int, k: int) -> float:
kind = self.t_kind
if kind == "none":
return 1.0
if kind == "global":
t = self.t_json["global"]
elif kind == "bucket":
b = self.t_json["bucket"]
t = b["temperatures"].get(temp_bucket(qtype, k), b["global"])
else:
mask = torch.ones(1, k, dtype=torch.bool, device="cuda")
mode = TM.MODES[AS.slot_mode(qtype, k)]
feats = TM.make_features(logits[None, :k], mask, torch.tensor([qtype], device="cuda"), torch.tensor([mode], device="cuda"))
t = self.t_head.log_t(feats, pooled[None]).exp().item()
return float(min(HI, max(LO, t)))
# ------------------------------------------------------------------ forward
def _render(self, state, q):
if q["type"] not in ("choice", "noul"):
raise Unsupported("Unsupported question type " + str(q["type"]))
keys = list(q["criteria"]) if q["type"] == "choice" else ["false", "true"]
if not keys:
raise Unsupported("question without options")
crit = q["criteria"] if q["type"] == "choice" else (q.get("criteria") or {})
k = len(keys)
text = "" if state in (None, "", {}, []) else (state if isinstance(state, str) else json.dumps(state, ensure_ascii=False, separators=(",", ":")))
item = {"qtype": QTYPES[q["type"]], "target": [1.0 / k] * k, "label": 0, "state": text,
"qdef": {"type": q["type"], "instructions": q.get("instructions", ""), "criteria": crit}}
r = self.codec.render_ar(item)
if len(r["ids"]) > self.limit:
raise Unsupported(f"prompt of {len(r['ids'])} tokens exceeds the {self.limit}-token limit")
return keys, r
def _tensors(self, r):
dev = "cuda"
t = lambda x: torch.tensor([x], device=dev)
k = len(r["target"])
return t(r["slot_of_opt"]), t(r["pos_idx"]), t(r["neg_idx"]), torch.ones(1, k, dtype=torch.bool, device=dev)
@torch.inference_mode()
def _logits(self, r, base):
"""(logits (K,), pooled hidden (H,)). `base` = (state_end, cache) of the row's shared prefix, or None."""
bb = self.pipe.backbone
ids = torch.tensor([r["ids"]], device="cuda")
with torch.amp.autocast("cuda", dtype=torch.bfloat16):
if base is None:
h = bb(input_ids=ids, use_cache=False).last_hidden_state
hs = h[:, r["slot_pos"]]
else:
end, cache = base
h = bb(input_ids=ids[:, end:], past_key_values=copy.deepcopy(cache), use_cache=True).last_hidden_state
hs = h[:, [p - end for p in r["slot_pos"]]]
logits = self.pipe.readout(hs, *self._tensors(r))[0]
return logits.float(), hs.float().mean(1)[0]
@torch.inference_mode()
def _base(self, r):
with torch.amp.autocast("cuda", dtype=torch.bfloat16):
out = self.pipe.backbone(input_ids=torch.tensor([r["ids"][:r["state_end"]]], device="cuda"), use_cache=True)
return r["state_end"], out.past_key_values
def __call__(self, state, questions):
answers, raw = {}, {}
rendered = {qid: self._render(state, q) for qid, q in questions.items()}
base = None
try:
if self.prefix_cache and len(rendered) >= 2:
first = next(iter(rendered.values()))[1]
if first["state_end"] >= 128 and all(r["ids"][:first["state_end"]] == first["ids"][:first["state_end"]] for _, r in rendered.values()):
base = self._base(first)
for qid, q in questions.items():
keys, r = rendered[qid]
logits, pooled = self._logits(r, base)
t = self._temperature(logits, pooled, r["qtype"], len(keys))
p = torch.softmax(logits / t, -1).cpu().numpy().astype(np.float64)
p = p / p.sum()
probs = dict(zip(keys, (float(x) for x in p)))
if q["type"] == "choice":
answers[qid] = {"type": "choice", "choice": max(keys, key=lambda x: probs[x]), "probabilities": probs}
else:
answers[qid] = {"type": "noul", "noul": probs["true"]}
raw[qid] = {"prompt_tokens": len(r["ids"]), "temperature": round(t, 4), "cached_prefix_tokens": base[0] if base else 0}
except torch.OutOfMemoryError:
torch.cuda.empty_cache()
raise Unsupported("out of memory on a long prompt")
finally:
del base
return {"model": self.model_id, "answers": answers, "usage": {"input_tokens": sum(v["prompt_tokens"] for v in raw.values())}}, raw
def runtime(self):
info = {"torch": torch.__version__, "device": "cuda", "cuda": torch.version.cuda, "gpu": torch.cuda.get_device_name()}
try:
import transformers
info["transformers"] = transformers.__version__
except Exception:
pass
return info
def synchronize(self):
torch.cuda.synchronize()
|