Instructions to use kongyo/Ketsudan-27B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Local Apps Settings
- Unsloth Desktop
Download code/train_dm.py from kongyo/Ketsudan-27B: direct link, hf CLI and curl.
- Browser
- Download file 15.7 kB
-
https://huggingface.co/kongyo/Ketsudan-27B/resolve/main/code/train_dm.py
- Command line
-
hf download hf://kongyo/Ketsudan-27B/code/train_dm.py
-
curl -L -o train_dm.py https://huggingface.co/kongyo/Ketsudan-27B/resolve/main/code/train_dm.py
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() | |