Ketsudan-27B / code /train_dm.py
kongyo's picture
Ketsudan-27B: decision model on Qwen3.8-27B (adapters, head, calibration, card, eval metrics, code)
a86df9f
Raw History Blame Contribute Delete
15.7 kB
"""Train a Jev-style decision model on Qwen3.8-27B with Unsloth's FastDecisionModel (Clef head).
One LoRA run, sized to a wall-clock budget measured on the GPU it runs on:
* LocalLLaMA/typed-decisions train (soft labels), always all of it
* n4ze3m/typed-decisions-synth (DeepSeek soft labels, 100 other workflows)
* Unsloth's public decision mixture (12 human-labelled sources, augmented)
Temperatures are fitted on a typed-decisions train holdout. Evaluated on typed-decisions test and
on held-out ANLI / WinoGrande rows that no training source contains.
No TypeSafe / Jev output is used anywhere in training, calibration or model selection.
python train_dm.py --model /content/models/Qwen3.8-27B --train-minutes 40
"""
import argparse
import json
import os
import random
import sys
import time
T0 = time.time()
def log(*parts):
print(f"[{time.strftime('%H:%M:%S')} +{(time.time() - T0) / 60:.1f}m]", *parts, flush = True)
parser = argparse.ArgumentParser()
parser.add_argument("--model", default = "Qwen/Qwen3.8-27B")
parser.add_argument("--base-name", default = "Qwen/Qwen3.8-27B", help = "recorded as the adapters' base")
parser.add_argument("--load-in-4bit", action = "store_true")
parser.add_argument("--max-seq-length", type = int, default = 2048)
parser.add_argument("--lora-r", type = int, default = 32)
parser.add_argument("--lr", type = float, default = 1e-4)
parser.add_argument("--head-lr", type = float, default = 3e-4)
parser.add_argument("--batch-size", type = int, default = 8)
parser.add_argument("--grad-accum", type = int, default = 2)
parser.add_argument("--synth-rows", type = int, default = 3000)
parser.add_argument("--public-rows", type = int, default = 2400)
parser.add_argument("--train-minutes", type = float, default = 40.0)
parser.add_argument("--max-items", type = int, default = None, help = "cap on training items (smoke)")
parser.add_argument("--probe-steps", type = int, default = 3)
parser.add_argument("--eval-limit", type = int, default = None)
parser.add_argument("--heldout", default = "/content/out/heldout_rows.jsonl")
parser.add_argument("--seed", type = int, default = 3407)
parser.add_argument("--out", default = "/content/out")
parser.add_argument("--name", default = "final")
parser.add_argument("--ckpt-repo", default = None, help = "private HF repo for the checkpoint")
args = parser.parse_args()
os.makedirs(args.out, exist_ok = True)
from unsloth import DecisionTrainer, FastDecisionModel # noqa: E402 (Unsloth first)
from unsloth.models import decision_datasets as dd # noqa: E402
import torch # noqa: E402
from datasets import load_dataset # noqa: E402
from transformers import TrainerCallback, TrainingArguments # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from td_metrics import parsed, score_cases # noqa: E402
PUBLIC_SOURCES = (
"banking77",
"clinc150",
"mnli",
"snli",
"wanli",
"boolq",
"ag_news",
"sst5",
"mmlu",
"commonsense_qa",
"arc",
"prompt_injections",
)
class Progress(TrainerCallback):
def __init__(self, name, limit_s = None):
self.name, self.limit_s, self.start, self.steps = name, limit_s, None, []
def on_train_begin(self, args, state, control, **kwargs):
self.start = time.time()
log(f"{self.name}: train begin, max_steps={state.max_steps}")
def on_step_end(self, args, state, control, **kwargs):
self.steps.append(time.time())
if self.limit_s and time.time() - self.start > self.limit_s:
log(f"{self.name}: time limit {self.limit_s / 60:.1f}m reached at step {state.global_step}")
control.should_training_stop = True
def on_log(self, args, state, control, logs = None, **kwargs):
if not logs:
return
recent = self.steps[-6:]
per_step = (recent[-1] - recent[0]) / (len(recent) - 1) if len(recent) > 1 else None
left = (state.max_steps - state.global_step) * per_step if per_step else None
log(
f"{self.name}: step {state.global_step}/{state.max_steps}",
json.dumps({k: round(v, 5) if isinstance(v, float) else v for k, v in logs.items()}),
f"s/step={per_step:.2f}" if per_step else "",
f"eta={left / 60:.1f}m" if left else "",
)
def gpu_mem():
if not torch.cuda.is_available():
return {}
return {
"allocated_gb": round(torch.cuda.memory_allocated() / 1e9, 1),
"peak_gb": round(torch.cuda.max_memory_allocated() / 1e9, 1),
}
def synth_rows(limit, seed, cleaner):
# n4ze3m/typed-decisions-synth: cases with several questions and DeepSeek soft labels ("teacher").
rows = []
for row in load_dataset("n4ze3m/typed-decisions-synth", split = "train"):
state = parsed(row["state"]) if row.get("state_is_json") else row["state"]
questions, teacher = parsed(row["questions"]), parsed(row["teacher"])
if not isinstance(questions, dict) or not isinstance(teacher, dict):
continue
gold = {}
for qid, question in questions.items():
answer = teacher.get(qid)
if not isinstance(answer, dict):
continue
if question.get("type") == "noul" and answer.get("noul") is not None:
p = float(answer["noul"])
gold[qid] = {"probabilities": {"false": 1.0 - p, "true": p}}
elif answer.get("probabilities"):
gold[qid] = {"probabilities": answer["probabilities"]}
questions = {qid: q for qid, q in questions.items() if qid in gold}
if not questions:
continue
candidate = {"source": "synth", "state": state, "questions": questions, "gold": gold}
if not cleaner.contaminated(candidate):
rows.append(candidate)
random.Random(seed).shuffle(rows)
rng = random.Random(seed + 1)
return [dd.augment_row(row, rng) for row in rows[:limit]]
def public_rows(n_rows, seed, eval_texts):
per_source = max(1, -(-n_rows // len(PUBLIC_SOURCES)))
pools = {}
for name in PUBLIC_SOURCES:
try:
pools[name] = dd.load_source(name, "train", limit = 2 * per_source, seed = seed)
except Exception as exc: # unavailable sources are reported and skipped
log(f"skipping {name}: {type(exc).__name__}: {str(exc)[:200]}")
log("public sources", json.dumps({k: len(v) for k, v in pools.items()}))
return dd.build_decision_mixture(pools, n_rows = n_rows, seed = seed, decontaminate_against = eval_texts)
def predict_cases(model, tokenizer, rows, name):
FastDecisionModel.for_inference(model)
cases, start = [], time.time()
for i, row in enumerate(rows, 1):
answers = FastDecisionModel.predict(model, tokenizer, parsed(row["state"]), parsed(row["questions"]))
cases.append(
{
"id": row["id"],
"workflow": row["workflow"],
"questions": row["questions"],
"gold": row["gold"],
"pred": {qid: {k: v for k, v in a.items() if k != "legend"} for qid, a in answers.items()},
}
)
if i % 100 == 0:
log(f"{name}: predicted {i}/{len(rows)} ({(time.time() - start) / i:.2f}s/case)")
metrics = score_cases(cases)
with open(f"{args.out}/{name}_preds.jsonl", "w", encoding = "utf-8") as f:
for case in cases:
f.write(json.dumps(case) + "\n")
with open(f"{args.out}/{name}_metrics.json", "w", encoding = "utf-8") as f:
json.dump(metrics, f, indent = 1)
log(f"{name.upper()}_METRICS", json.dumps(metrics))
return metrics
def training_args(max_steps, lr, head_lr_unused = None):
return TrainingArguments(
output_dir = f"{args.out}/_trainer",
per_device_train_batch_size = args.batch_size,
gradient_accumulation_steps = args.grad_accum,
learning_rate = lr,
lr_scheduler_type = "cosine",
warmup_steps = max(1, round(0.05 * max_steps)),
weight_decay = 0.01,
max_steps = max_steps,
bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
logging_steps = 1 if max_steps <= 10 else 5,
report_to = "none",
save_strategy = "no",
seed = args.seed,
dataloader_num_workers = 0,
)
def main():
log("args", json.dumps(vars(args)))
model, tokenizer = FastDecisionModel.from_pretrained(
args.model,
decision_head = "clef",
max_seq_length = args.max_seq_length,
load_in_4bit = args.load_in_4bit,
random_state = args.seed,
)
model.decision_config["base_model"] = args.base_name
log("loaded", type(model.encoder).__name__, json.dumps(model.head.config), json.dumps(gpu_mem()))
td_test = list(load_dataset("LocalLLaMA/typed-decisions", "all", split = "test"))
if args.eval_limit:
td_test = td_test[: args.eval_limit]
heldout = []
if args.heldout and os.path.exists(args.heldout):
heldout = [json.loads(line) for line in open(args.heldout, encoding = "utf-8") if line.strip()]
if args.eval_limit:
heldout = heldout[: args.eval_limit]
eval_texts = [parsed(row["state"]) for row in td_test + heldout]
cleaner = dd.Decontaminator(eval_texts)
# typed-decisions train: all rows, a holdout for the temperatures. Its cases share JSON boilerplate
# with the test cases, so 13-word shingles would drop most of them; the dataset already guarantees
# that no case id or state is in both splits, so only exact state matches are checked here.
test_states = {json.dumps(parsed(row["state"]), sort_keys = True) for row in td_test}
rng = random.Random(args.seed + 2)
td_rows = [
dd.augment_row(r, rng)
for r in dd.load_source("typed_decisions", "train")
if json.dumps(r["state"], sort_keys = True) not in test_states
]
td_items, td_report = FastDecisionModel.build_dataset(td_rows, tokenizer, model)
td_train, td_holdout = FastDecisionModel.split_holdout(td_items, args.seed)
synth_items, synth_report = FastDecisionModel.build_dataset(synth_rows(args.synth_rows, args.seed, cleaner), tokenizer, model)
pub_items, pub_report = FastDecisionModel.build_dataset(public_rows(args.public_rows, args.seed, eval_texts), tokenizer, model)
for name, items, report in (("typed", td_items, td_report), ("synth", synth_items, synth_report), ("public", pub_items, pub_report)):
lengths = sorted(len(item["input_ids"]) for item in items) or [0]
log(f"data {name}: items={len(items)} skipped={report['skipped']} truncated={report['truncated']} "
f"tokens p50={lengths[len(lengths) // 2]} p90={lengths[int(len(lengths) * 0.9)]} max={lengths[-1]}")
log(f"typed train={len(td_train)} holdout={len(td_holdout)}")
model = FastDecisionModel.get_peft_model(model, r = args.lora_r, lora_alpha = args.lora_r, random_state = args.seed)
log(f"trainable params: {sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.1f}M", json.dumps(gpu_mem()))
# Throughput probe: a few real steps at learning rate 0 on a mixed sample.
per_step = args.batch_size * args.grad_accum
pool = td_train + synth_items + pub_items
probe = random.Random(args.seed + 3).sample(pool, min(len(pool), per_step * args.probe_steps))
progress = Progress("probe")
DecisionTrainer(
model = model, tokenizer = tokenizer, train_dataset = probe, head_learning_rate = 0.0,
args = training_args(args.probe_steps, 0.0), callbacks = [progress],
).train()
stamps = progress.steps
probe_tokens = sum(len(item["input_ids"]) for item in probe) / args.probe_steps
s_step = (stamps[-1] - stamps[0]) / (len(stamps) - 1) if len(stamps) > 1 else stamps[0] - progress.start
s_token = s_step / probe_tokens
log(f"probe: {s_step:.2f}s/step for {probe_tokens:.0f} tokens/step -> {s_token * 1e3:.3f} ms/token", json.dumps(gpu_mem()))
# Fill the budget: all of typed-decisions train, then synth and public items alternately.
budget_tokens = args.train_minutes * 60 / s_token / 1.03
rng = random.Random(args.seed + 4)
rng.shuffle(synth_items)
rng.shuffle(pub_items)
selected = list(td_train)
used = sum(len(item["input_ids"]) for item in selected)
extra = [x for pair in zip(synth_items, pub_items) for x in pair] + synth_items[len(pub_items):] + pub_items[len(synth_items):]
for item in extra:
if used >= budget_tokens or (args.max_items and len(selected) >= args.max_items):
break
selected.append(item)
used += len(item["input_ids"])
if args.max_items:
selected = selected[: args.max_items]
rng.shuffle(selected)
steps = max(1, len(selected) // per_step)
typed_ids = {id(x) for x in td_train}
counts = {"typed": sum(id(x) in typed_ids for x in selected), "total": len(selected)}
log(f"selected {len(selected)} items ({used / 1e6:.2f}M tokens) -> {steps} steps, est {used * s_token / 60:.1f}m", json.dumps(counts))
progress = Progress("train", limit_s = args.train_minutes * 60 * 1.15)
trainer = DecisionTrainer(
model = model, tokenizer = tokenizer, train_dataset = selected, head_learning_rate = args.head_lr,
args = training_args(steps, args.lr), callbacks = [progress],
)
out = trainer.train()
log("train done", json.dumps(out.metrics), json.dumps(gpu_mem()))
calibration = FastDecisionModel.calibrate(model, tokenizer, td_holdout) if td_holdout else {}
calibration = {k: v for k, v in calibration.items() if not isinstance(v, (list, dict))}
log("calibration", json.dumps(calibration), "temperatures", model.decision_config.get("temperature"))
metrics = {"typed_decisions_test": predict_cases(model, tokenizer, td_test, f"{args.name}_td_test")}
if heldout:
metrics["heldout"] = predict_cases(model, tokenizer, heldout, f"{args.name}_heldout")
report = {
"base_model": args.base_name,
"train_items": len(selected),
"train_tokens": used,
"steps": trainer.state.global_step,
"planned_steps": steps,
"probe_s_per_step": s_step,
"train_metrics": out.metrics,
"data": {"typed_train": len(td_train), "typed_holdout": len(td_holdout), "synth_pool": len(synth_items), "public_pool": len(pub_items)},
"calibration_on_typed_holdout": calibration,
"temperatures": model.decision_config.get("temperature"),
"eval": metrics,
"gpu": torch.cuda.get_device_name() if torch.cuda.is_available() else "cpu",
"args": vars(args),
}
folder = f"{args.out}/{args.name}"
model.decision_config["base_model"] = args.base_name
model.save_pretrained(folder, tokenizer)
with open(f"{folder}/train_report.json", "w", encoding = "utf-8") as f:
json.dump(report, f, indent = 1)
log(f"saved {folder}: {sorted(os.listdir(folder))}")
if args.ckpt_repo:
from huggingface_hub import HfApi
api = HfApi()
api.create_repo(args.ckpt_repo, private = True, exist_ok = True)
api.upload_folder(folder_path = folder, repo_id = args.ckpt_repo, path_in_repo = args.name)
for path in os.listdir(args.out):
if path.startswith(args.name) and path.endswith((".json", ".jsonl")):
api.upload_file(path_or_fileobj = f"{args.out}/{path}", path_in_repo = f"eval/{path}", repo_id = args.ckpt_repo)
log(f"pushed {args.name} to {args.ckpt_repo} (private)")
log("ALL_DONE", json.dumps({k: v.get("accuracy") for k, v in metrics.items()}))
if __name__ == "__main__":
main()