"""Liquid d1 decision models (d1-3B, d1-omni-600M) on the `decisions` config: zero-shot answers, fine-tuning, answers. python liquid_d1.py zeroshot --model LiquidAI/d1-3B --split test --out runs/d1-3b/zeroshot.test.jsonl python liquid_d1.py train --model LiquidAI/d1-3B --ckpt runs/d1-3b/ckpt [--epochs 1] python liquid_d1.py predict --ckpt runs/d1-3b/ckpt --split test --out runs/d1-3b/finetuned.test.jsonl d1-3B is a causal LFM2-VL that reads each answer off the logits at the answer slot. It trains with LoRA on its language model, the state read once and the 13 questions as branches of its `hybrid.py` tree. d1-omni-600M is an encoder with a decision head over option markers; it trains in full. Vision and audio towers stay frozen. Answers always come from the model's own `system_one`, so a fine-tuned checkpoint is used exactly as the released one. """ from __future__ import annotations import argparse import math import random import shutil import sys import time from contextlib import nullcontext from pathlib import Path import torch import torch.nn.functional as F from common import PredictionWriter, gold_index, load_split, log, to_answer LORA_TARGETS = r".*language_model.*\.(q_proj|k_proj|v_proj|out_proj|in_proj|w1|w2|w3)" CODE_FILES = ("*.py", "*.json", "*.jinja", "*.txt", "LICENSE") WEIGHTS = ("model.safetensors", "model.safetensors.index.json") def device_of(name: str | None) -> torch.device: return torch.device(name or ("cuda" if torch.cuda.is_available() else "cpu")) def is_omni(path: str | Path) -> bool: import json from huggingface_hub import hf_hub_download config = Path(path) / "config.json" if Path(path).is_dir() else Path(hf_hub_download(str(path), "config.json")) return json.loads(config.read_text())["model_type"] == "d1_omni" def load(path: str | Path, device: torch.device, dtype: torch.dtype | None = None): from transformers import AutoModel if dtype is None: # omni was trained in fp32 and its card runs fp16 on GPUs; d1-3B ships in bf16 dtype = torch.float32 if device.type == "cpu" else torch.float16 if is_omni(path) else torch.bfloat16 return AutoModel.from_pretrained(str(path), trust_remote_code=True, dtype=dtype).to(device).eval() def remote(model, name: str): """A module of the model's own remote code (`prompt`, `hybrid`).""" return sys.modules[type(model).__module__.rsplit(".", 1)[0] + "." + name] def sync(device: torch.device) -> None: if device.type == "cuda": torch.cuda.synchronize() elif device.type == "mps": torch.mps.synchronize() # --------------------------------------------------------------------------- answers @torch.inference_mode() def answer_case(model, case: dict) -> dict: """The 13 answers for one case from the model's `system_one`, in `common.options_of` order.""" response = model.system_one(case["state"], case["questions"])["answers"] out = {} for name, q in case["questions"].items(): a = response[name] if q["type"] == "noul": probs = [a["noul"], 1 - a["noul"]] elif q["type"] == "choice": probs = [a["probabilities"][k] for k in q["criteria"]] else: probs = [a["probabilities"][str(i)] for i in range(len(q["criteria"]))] out[name] = to_answer(q, probs) return out def answer_split(path: str | Path, split: str, out: Path, limit: int | None, device: torch.device) -> None: cases = load_split(split, limit) writer = PredictionWriter(out) todo = writer.pending(cases) if not todo: return log(None, done=str(out), cases=len(cases)) model = load(path, device) for _ in range(2): # warm-up: kernels, caches, the engine's tokenizer answer_case(model, todo[0]) for i, case in enumerate(todo): sync(device) tick = time.perf_counter() answers = answer_case(model, case) sync(device) writer.write(case["id"], answers, 1000 * (time.perf_counter() - tick)) if i % 200 == 0: log(None, split=split, answered=len(writer.done), of=len(cases)) writer.close() log(None, done=str(out), cases=len(cases)) # --------------------------------------------------------------------------- training: d1-3B class Tree3B: """d1-3B training views: the state as the trunk, the 13 questions as branches, each read at its answer slot.""" def __init__(self, model, questions: dict): engine, prompt = model.engine, remote(model, "prompt") self.model, self.tok, self.engine, self.prompt = model, engine.tokenizer, engine, prompt self.names, self.questions = list(questions), questions qs = [prompt.as_question(questions[n]) for n in self.names] self.branches = [self.encode(prompt.suffix_text(self.tok, q, engine.lead, engine.option_style)) for q in qs] self.groups = [prompt.readout_ids(self.tok, q) for q in qs] # the fused flash kernels return a log-sum-exp without a gradient; the explicit fp32 path keeps it exact remote(model, "hybrid")._fused = lambda q: False def encode(self, text: str) -> list[int]: return self.tok.encode(text, add_special_tokens=False) def option_logits(self, case: dict, device: torch.device) -> list[torch.Tensor]: e = self.engine trunk = self.encode(self.prompt.prefix_text(self.tok, case["state"], e.bos, e.state_style, e.system)) packed = torch.tensor([t for b in self.branches for t in b], device=device) lengths = torch.tensor([len(b) for b in self.branches], device=device) logits = self.model.answer(torch.tensor([trunk], device=device), packed, lengths).float() return [torch.stack([logits[j, g].max() for g in groups]) for j, groups in enumerate(self.groups)] def loss(self, cases: list[dict], device: torch.device) -> torch.Tensor: total = [] for case in cases: for name, scores in zip(self.names, self.option_logits(case, device)): target = torch.tensor([gold_index(self.questions[name], case["gold"][name])], device=device) total.append(F.cross_entropy(scores[None], target)) return torch.stack(total).mean() # --------------------------------------------------------------------------- training: d1-omni-600M class Omni: """d1-omni training views: one sequence per question, scored at its option markers (a noul reads [false, true]).""" def __init__(self, model, questions: dict): self.model, self.prompt = model, remote(model, "prompt") self.names, self.questions = list(questions), questions self.qs = [self.prompt.as_question(questions[n]) for n in self.names] def rows(self, case: dict) -> list[tuple[list[int], list[int], int, int]]: out = [] for name, q in zip(self.names, self.qs): ids, markers = self.prompt.encode(self.model.tokenizer, case["state"], q, self.model.config.max_length) target = gold_index(self.questions[name], case["gold"][name]) out.append((ids, markers, self.prompt.QTYPES[q.type], 1 - target if q.type == "noul" else target)) return out def loss(self, cases: list[dict], device: torch.device) -> torch.Tensor: m = self.model rows = [row for case in cases for row in self.rows(case)] seqs = [m.encoder.embed_tokens(torch.tensor(ids, device=device)) for ids, *_ in rows] h = torch.nn.utils.rnn.pad_sequence(seqs, batch_first=True) lengths = torch.tensor([len(ids) for ids, *_ in rows], device=device) pad = torch.arange(h.shape[1], device=device)[None] < lengths[:, None] h = m.encoder(h, pad, torch.zeros(len(rows), dtype=torch.long, device=device)) k = max(len(markers) for _, markers, *_ in rows) marker_pos = torch.zeros(len(rows), k, dtype=torch.long, device=device) marker_mask = torch.zeros(len(rows), k, dtype=torch.bool, device=device) for i, (_, markers, *_) in enumerate(rows): marker_pos[i, :len(markers)] = torch.tensor(markers, device=device) marker_mask[i, :len(markers)] = True qtype = torch.tensor([r[2] for r in rows], device=device) logits = m.head(h, pad, marker_pos, marker_mask, qtype) return F.cross_entropy(logits, torch.tensor([r[3] for r in rows], device=device)) # --------------------------------------------------------------------------- training loop def trainable(model, omni: bool): """Omni: everything but the vision and audio towers. d1-3B: LoRA on the language model's linear layers.""" if omni: for name, p in model.named_parameters(): p.requires_grad = not name.startswith(("vision.", "audio.")) return model from peft import LoraConfig, get_peft_model for p in model.parameters(): p.requires_grad = False return get_peft_model(model, LoraConfig(r=32, lora_alpha=64, lora_dropout=0.05, target_modules=LORA_TARGETS)) def trainable_state(model) -> dict: return {n: p.detach().cpu() for n, p in model.named_parameters() if p.requires_grad} def save_atomic(obj, path: Path) -> None: tmp = path.with_suffix(".tmp") torch.save(obj, tmp) tmp.replace(path) def export(model, omni: bool, source: str, best: Path, final: Path) -> None: """The best weights as a standalone checkpoint that `AutoModel.from_pretrained(final, trust_remote_code=True)` loads, with the source repo's code and tokenizer files.""" from huggingface_hub import snapshot_download model.load_state_dict(torch.load(best, map_location="cpu"), strict=False) if omni: model.config.temperatures = {} # the released per-type temperatures were fitted to the released weights else: model = model.merge_and_unload() tmp = final.with_name(final.name + ".tmp") shutil.rmtree(tmp, ignore_errors=True) src = Path(snapshot_download(source, allow_patterns=list(CODE_FILES))) shutil.copytree(src, tmp, ignore=shutil.ignore_patterns("README.md", *WEIGHTS)) model.save_pretrained(tmp) shutil.rmtree(final, ignore_errors=True) tmp.replace(final) @torch.no_grad() def val_loss(view, cases: list[dict], device: torch.device, batch: int, autocast) -> float: losses = [] for i in range(0, len(cases), batch): with autocast(): losses.append(float(view.loss(cases[i:i + batch], device)) * len(cases[i:i + batch])) return sum(losses) / len(cases) def train(args) -> None: device, ckpt = device_of(args.device), Path(args.ckpt) ckpt.mkdir(parents=True, exist_ok=True) if (ckpt / "final").exists(): return log(args.log, done=str(ckpt / "final")) omni = is_omni(args.model) dtype = torch.float32 if omni or device.type == "cpu" else torch.bfloat16 # omni keeps fp32 master weights model = load(args.model, device, dtype) train_cases, val_cases = load_split("train", args.limit), load_split("validation", args.val_cases) questions = train_cases[0]["questions"] view = Omni(model, questions) if omni else Tree3B(model, questions) model = trainable(model, omni) model.train() lr = args.lr or (2e-5 if omni else 2e-4) micro = args.micro or (2 if omni else 1) accum = max(1, args.batch // micro) params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(params, lr=lr, weight_decay=0.0) per_epoch = math.ceil(len(train_cases) / (micro * accum)) total = min(per_epoch * args.epochs, args.max_steps or 10**9) warmup = max(1, int(0.05 * total)) schedule = torch.optim.lr_scheduler.LambdaLR( optimizer, lambda s: min(1.0, (s + 1) / warmup) * max(0.0, (total - s) / max(1, total - warmup))) state = {"epoch": 0, "step": 0, "best": float("inf")} if (ckpt / "last.pt").exists(): saved = torch.load(ckpt / "last.pt", map_location="cpu") model.load_state_dict(saved["weights"], strict=False) optimizer.load_state_dict(saved["optimizer"]) schedule.load_state_dict(saved["schedule"]) state = saved["state"] log(args.log, resumed=state) autocast = (lambda: torch.autocast("cuda", dtype=torch.bfloat16)) if device.type == "cuda" else nullcontext def checkpoint() -> None: save_atomic({"weights": trainable_state(model), "optimizer": optimizer.state_dict(), "schedule": schedule.state_dict(), "state": state}, ckpt / "last.pt") started = time.time() while state["epoch"] < args.epochs and state["step"] < total: order = list(range(len(train_cases))) random.Random(args.seed + state["epoch"]).shuffle(order) done_in_epoch = state["step"] - state["epoch"] * per_epoch for s in range(done_in_epoch, per_epoch): if state["step"] >= total: break chunk = [train_cases[i] for i in order[s * micro * accum:(s + 1) * micro * accum]] for i in range(0, len(chunk), micro): with autocast(): loss = view.loss(chunk[i:i + micro], device) * len(chunk[i:i + micro]) / len(chunk) loss.backward() norm = torch.nn.utils.clip_grad_norm_(params, 1.0) optimizer.step() schedule.step() optimizer.zero_grad(set_to_none=True) state["step"] += 1 if state["step"] % 20 == 0 or state["step"] <= 3: log(args.log, step=state["step"], of=total, loss=round(float(loss) * len(chunk) / micro, 4), grad_norm=round(float(norm), 3), lr=schedule.get_last_lr()[0], minutes=round((time.time() - started) / 60, 1)) if state["step"] % args.save_every == 0: checkpoint() state["epoch"] += 1 model.eval() loss = val_loss(view, val_cases, device, micro, autocast) model.train() log(args.log, epoch=state["epoch"], val_loss=round(loss, 4), minutes=round((time.time() - started) / 60, 1)) if loss < state["best"]: state["best"] = loss save_atomic(trainable_state(model), ckpt / "best.pt") checkpoint() model.eval() export(model, omni, args.model, ckpt / "best.pt", ckpt / "final") log(args.log, done=str(ckpt / "final"), best_val_loss=state["best"], minutes=round((time.time() - started) / 60, 1)) def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) for name in ("zeroshot", "train", "predict"): p = sub.add_parser(name) p.add_argument("--device") p.add_argument("--limit", type=int) if name != "predict": p.add_argument("--model", default="LiquidAI/d1-3B") if name != "train": p.add_argument("--split", default="test", choices=("validation", "test")) p.add_argument("--out", type=Path, required=True) if name != "zeroshot": p.add_argument("--ckpt", required=True) t = sub.choices["train"] t.add_argument("--epochs", type=int, default=1) t.add_argument("--max-steps", type=int) t.add_argument("--batch", type=int, default=8, help="cases per optimizer step") t.add_argument("--micro", type=int, help="cases per forward pass (default 2 for omni, 1 for d1-3B)") t.add_argument("--lr", type=float) t.add_argument("--val-cases", type=int, default=300) t.add_argument("--save-every", type=int, default=400) t.add_argument("--seed", type=int, default=0) t.add_argument("--log") args = parser.parse_args() if args.command == "train": return train(args) path = args.model if args.command == "zeroshot" else Path(args.ckpt) / "final" answer_split(path, args.split, args.out, args.limit, device_of(args.device)) if __name__ == "__main__": main()