Repository navigation
Train a decision model from a plain language model - #12772
Conversation
…memory test on a real CUDA device
Clef and Clef-flash repos (config.json plus joint_head.*) are detected as decision models next to Laya. Training goes through FastDecisionModel with QLoRA allowed and the Qwen3.5 fast-path kernel setup the worker skips for Laya. The Decision API lists clef and clef-flash and Clef fine-tunes, and serves them from a spawn child that loads the backbone through Unsloth, so unloading returns the GPU memory. Clef waits for the GPU while a run trains instead of loading on CPU.
… on CPU when causal_conv1d is installed
…les, honour a cancel during calibration
# Conflicts: # studio/backend/core/training/worker.py
…ure into its head
…-op accumulation wrapper
…un, and give its asserts messages
… a precision flag
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8e4eb8cb97
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if load_in_4bit and kwargs.get("quantization_config") is None: | ||
| kwargs["quantization_config"] = _clef_bnb_config(dtype) |
There was a problem hiding this comment.
Disable the explicit 4-bit config for full finetuning
When callers combine load_in_4bit=True with full_finetuning=True, this installs an explicit bitsandbytes configuration. FastModel.from_pretrained disables its load_in_4bit flag for full finetuning, but the explicit configuration remains and still quantizes the backbone, so the requested full finetune may either fail or train only the remaining non-quantized parameters. Match the existing Clef loader by adding not full_finetuning to this condition.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: full finetuning no longer installs the 4-bit config, matching _load_clef.
| max_len = int(max_seq_length or CLEF_MAX_LEN) | ||
| if revision is not None: | ||
| kwargs["revision"] = revision | ||
| backbone, processor, fast = _load_backbone( |
There was a problem hiding this comment.
Honor the requested revision and offline mode
The new loading path never forwards local_files_only to the actual backbone load, so local_files_only=True can still contact the Hub on both GPU and CPU. In addition, revision is placed in kwargs here but the CPU branch ignores kwargs, causing CPU callers to load the repository default rather than the requested revision. Forward these controls to both the FastModel and Transformers config/model/tokenizer loads so offline use and reproducible checkpoint selection work as advertised.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: revision and local_files_only reach FastModel and every CPU config, tokenizer and model load.
| if new_id in questions: | ||
| new_id = qid | ||
| questions[new_id], gold[new_id] = question, answer |
There was a problem hiding this comment.
Preserve questions when renamed IDs collide
For a multi-question row where a later original ID is one of the generated IDs (for example q1), an earlier question can first be renamed to that ID. When the later question is processed, this collision fallback assigns qid, which is the same already-occupied key, and the following assignment silently overwrites the earlier question and gold label. Check candidates against all original IDs or generate a genuinely unused fallback instead of reusing an occupied qid.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: a rename never takes another question's original id, and collisions get a fresh id.
| ), | ||
| ) | ||
|
|
||
| torch.cuda.reset_peak_memory_stats() |
There was a problem hiding this comment.
Guard CUDA memory reset on CPU runs
When this script is run on a CPU-only PyTorch installation, the unconditional torch.cuda.reset_peak_memory_stats() call raises before training starts. The rest of the script explicitly supports the CPU case when choosing precision and reporting memory/device details, so this call should likewise be guarded by torch.cuda.is_available().
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: the peak memory reset only runs when CUDA is available.
| if kind == "score" and isinstance(label, int): | ||
| levels = question["criteria"] | ||
| threshold = rng.randint(1, len(levels) - 1) |
There was a problem hiding this comment.
Skip derived thresholds for single-level scores
A score question with one criterion is accepted by the decision-schema validation, but when derived-question augmentation is selected this calls randint(1, 0) and aborts the entire augmentation. This can occur with typed/custom decision rows under the default probabilistic augmentation; return no derived question when len(levels) < 2.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: a one-level score gets no derived threshold question.
| out = {k: v for k, v in row.items() if k != "_variants"} | ||
| if max_options is not None: | ||
| rng = random.Random(seed) | ||
| for qid, question in list(out["questions"].items()): | ||
| if question["type"] == "choice" and isinstance(question.get("criteria"), dict): | ||
| out["questions"][qid], out["gold"][qid] = _subsample_options( | ||
| question, out["gold"][qid], rng, max_options |
There was a problem hiding this comment.
Copy nested mappings before canonical subsampling
When max_options is supplied, out["questions"] and out["gold"] still reference the input row's mappings, so these assignments permanently replace entries in row as well. A caller that later reuses the converted row for another evaluation or augmentation therefore sees the already-subsampled option set. Copy the nested question and gold mappings before modifying them.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: canonical_row copies the questions and gold mappings before subsampling.
| share = max(1, round(n_rows * weights[name] / total)) | ||
| picks = ( | ||
| rng.sample(pool, share) | ||
| if share <= len(pool) | ||
| else [rng.choice(pool) for _ in range(share)] |
There was a problem hiding this comment.
Allocate mixture remainders instead of returning too few rows
Independent rounding can make the source shares sum to less than n_rows, and the final slice cannot restore the missing rows. For example, the documented default of 12,000 rows across 13 equal nonempty pools allocates 923 each and returns 11,999 rows; smaller requests such as 5 rows across 2 pools return only 4. Allocate the rounding remainder across sources so the function fulfills its requested row count whenever any pool is available.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: shares are allocated by largest remainder, so the mixture returns exactly n_rows.
| per_source = max(1, args.rows // max(1, len(sources))) | ||
| for name in sources: | ||
| try: | ||
| pools[name] = dd.load_source(name, "train", limit = per_source, seed = args.seed) |
There was a problem hiding this comment.
Recompute source limits after unavailable datasets are skipped
The per-source limit is calculated from every requested source before gated or unavailable sources are removed. If one default source such as gated xLAM fails, each remaining pool is still capped at rows / 13, while build_decision_mixture recomputes larger shares across 12 pools and fills the difference by sampling those truncated pools with replacement. This injects avoidable duplicate training examples; load enough rows based on the surviving sources or avoid pre-truncating before failures are known.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: unavailable sources are dropped first, then the per-source limit is computed from the survivors.
| if args.typed_decisions: | ||
| rng = random.Random(args.seed) | ||
| rows += [dd.augment_row(r, rng) for r in dd.load_source("typed_decisions", "train")] | ||
| random.Random(args.seed).shuffle(rows) |
There was a problem hiding this comment.
Decontaminate typed-decisions before adding it to training
With the default --typed-decisions setting, these training rows are appended only after build_decision_mixture has applied decontaminate_against. Consequently, any typed-decisions training state that overlaps its test split or the BANKING77/CLINC150 evaluation states remains in training and can leak benchmark content into the reported tuned metrics. Apply the same decontaminator to this source before augmentation and concatenation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: typed-decisions training rows go through the same decontaminator as the mixture.
| return load_lm_as_decision_model( | ||
| model_name, | ||
| max_seq_length = max_seq_length, |
There was a problem hiding this comment.
Forward subfolder when converting a plain language model
When decision_head is set, the public subfolder argument is consumed by FastDecisionModel.from_pretrained but omitted from this delegated call. A plain LM stored in a repository or local root subfolder is therefore loaded from the root instead, producing either a missing-config error or the wrong model. Pass the requested subfolder through to the underlying backbone loader.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 084b6e6: a subfolder is resolved (local or Hub snapshot) before the plain LM loads.
…and mixture sizes - 4-bit plus full finetuning no longer installs a bitsandbytes config - revision, local_files_only and subfolder reach the plain LM load (CPU too) - a renamed question never overwrites another; one-level scores get no threshold - canonical_row copies before subsampling; the mixture returns n_rows exactly - the training script sizes sources after unavailable ones drop out, decontaminates typed-decisions, and runs on CPU-only torch
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 084b6e69c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| dd.canonical_row(r) for r in dd.load_source(name, "test", limit = args.eval_rows, seed = 0) | ||
| ] | ||
| eval_texts = [ | ||
| json.dumps(r["state"], ensure_ascii = False) for rows in evals.values() for r in rows |
There was a problem hiding this comment.
Canonicalize evaluation states before decontamination
When a typed-decisions state is a multi-key mapping with fewer than 13 tokens and its insertion order is not sorted, serializing it here preserves insertion order, whereas _words serializes the corresponding training mapping with sort_keys=True. Identical logical states therefore produce different fallback shingles and the train/test duplicate survives, contaminating the tuned metric. The earlier typed-decisions decontamination fix still misses this representation mismatch; pass the structured state directly or canonicalize both sides identically.
Useful? React with 👍 / 👎.
| pools = { | ||
| name: dd.load_source(name, "train", limit = per_source, seed = args.seed) for name in pools | ||
| } |
There was a problem hiding this comment.
Decontaminate before truncating source pools
When decontamination removes even one of these per_source rows, build_decision_mixture fills the source's assigned share by sampling the truncated remainder with replacement, despite additional clean examples being available in the underlying dataset; converter-filtered rows cause the same problem. The unavailable-source fix still pre-truncates before this later filtering, introducing avoidable duplicates and potentially placing augmentations of the same raw row in both training and calibration holdout.
Useful? React with 👍 / 👎.
| if not folder.is_dir(): | ||
| from huggingface_hub import snapshot_download | ||
| folder = Path( | ||
| snapshot_download(str(head_init), token = token, allow_patterns = list(HEAD_FILES)) |
There was a problem hiding this comment.
Forward Hub controls when loading head_init
When head_init is a Hub repository, this download ignores both the supplied revision and local_files_only=True, so an offline load can still contact the network and a warm-start head can come from the repository default rather than the requested revision. The earlier forwarding fix covers the backbone loads, but this separate head download remains uncontrolled; pass the same Hub controls into snapshot_download.
Useful? React with 👍 / 👎.
| from transformers import AutoModelForCausalLM as AutoClass | ||
| AutoProcessor = AutoTokenizer |
There was a problem hiding this comment.
Support causal backbones without a
.model attribute
When the CPU path loads a valid causal architecture such as GPT2LMHeadModel, this generic AutoModelForCausalLM call and the output-embedding checks succeed, but the resulting decision model fails on its first forward because ClefDecisionModel.forward unconditionally accesses backbone.model; GPT-2, GPT-NeoX, BLOOM, and similar classes expose their transformer under other attributes. Either resolve the base model through the Transformers base-model interface or reject unsupported architectures during loading.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in #12876: forward falls back to backbone.base_model when there is no .model.
| share = max( | ||
| 1, |
There was a problem hiding this comment.
Exclude zero-weight sources from the mixture
When callers use a zero weight to disable a source, max(1, ...) still loads at least one row and the later allocation also grants that source at least one row. This can make a supposedly disabled gated source such as xLAM fail the entire build, or silently include its examples in the mixture; if every weight is zero, the denominator also becomes zero. Skip zero-weight entries and reject negative or all-zero weight maps before loading.
Useful? React with 👍 / 👎.
| out = t.train() | ||
| steps = t.state.global_step | ||
| losses = [h["loss"] for h in t.state.log_history if "loss" in h] | ||
| calibration = FastDecisionModel.calibrate(model, processor, holdout) |
There was a problem hiding this comment.
Guard calibration when the holdout is empty
When a small requested mixture or heavy filtering leaves fewer decisions than the holdout target can accommodate, split_holdout rounds its target down and may return an empty holdout because it only keeps whole rows. This unconditional calibration then evaluates empty lists, producing NaN accuracy, ECE, and loss (and a non-standard JSON result) while saving an uncalibrated model; either ensure at least one row is held out or skip calibration with an explicit result.
Useful? React with 👍 / 👎.
|
Clef head trained from a plain LM with
Mix = 12 sources (ag_news, arc, banking77, boolq, clinc150, commonsense_qa, mmlu, mnli, prompt_injections, snli, sst5, wanli) + typed-decisions. Test rows: 2000 typed-decisions, 500 BANKING77, 500 CLINC150. |
…, unslothai#12939 (unslothai#12949) * Decision models: follow-up fixes for mixture weights, offline head download, GGUF-only no-torch serving and GPU order * Keep Vulkan device order and reject non-finite mixture weights * Follow HIP visibility order for ROCm and keep the Clef fallback reason until Laya serves * Report the fallback reason only for a resident Clef, and describe the Qwen3.5 precision path without a weight dtype * Resolve UUID masks when choosing a GPU and detect GPUs through llama.cpp on no-torch installs * Let ROCm fall back to CUDA_VISIBLE_DEVICES when choosing a GPU * Skip the shebang llama-server stub tests on Windows * Ask llama-server for its GPUs on no-torch installs, cached, and fix the idle unload test race * Key the no-torch GPU probe cache by llama-server path and mtime * Use a standalone ROCR_VISIBLE_DEVICES mask as the ROCm device order
Builds on #12585 (decision model fine-tuning for Laya and Clef). Until that merges, this PR also shows its commits; the new work is the last six commits, starting at "Turn a plain language model into a Clef-style decision model".
#12585 fine-tunes existing decision models. This PR turns a plain language model, for example unsloth/Qwen3.5-2B or unsloth/Qwen3.5-0.8B, into a Jev style decision model: one prefill over the state and the questions, then a scored distribution over every option of every question, with no generation.
Usage
Or end to end:
python scripts/train_decision_from_lm.py --model unsloth/Qwen3.5-2B --rows 12000 --out outputs/qwen35_2b_decision.What it adds
decision_head = "clef"inFastDecisionModel.from_pretrained: loads the backbone with the same rules as Clef in Studio: fine-tune Laya and Cloudflare Clef decision models and serve them #12585 (Unsloth's Qwen3.5 fast path, dynamic 4-bit, the float32 path for float16 requests) and attaches a fresh joint schema head sized from the backbone. Text-only backbones load throughAutoModelForCausalLM.DecisionTrainer,calibrateandsave_pretrained_mergedfrom Studio: fine-tune Laya and Cloudflare Clef decision models and serve them #12585 unchanged.freeze_backbone/unfreeze_backboneallow an optional head-only warm-up.joint_schema_model.py, so it also loads with Cloudflare'sload_release_model. The test converts a tiny Qwen3.5, saves it and checks that Cloudflare's loader picks the same option as ours on every question.unsloth/models/decision_datasets.py: converters from public datasets (intent: banking77, clinc150; NLI: MNLI, SNLI, WANLI; BoolQ, AG News, SST-5, MMLU, CommonsenseQA, ARC, prompt injection, xLAM tool calls, typed-decisions) into the Decision API row format. Mixing adds schema augmentation (renamed option codes, subsampled and shuffled options, derived questions) so the head learns to read the schema rather than memorise label strings, plus n-gram decontamination against held-out sets. BFCL and When2Call are evaluation only.Results
One B200, LoRA r16, 4,096 tokens, accuracy on held-out test decisions (typed-decisions 2,000, banking77 and clinc150 500 each):
For reference, Cloudflare's Clef-flash scores 70.9% on typed-decisions before fine-tuning and 77.8% after QLoRA in #12585. A head-only warm-up of 150 steps before LoRA made no difference (75.8% / 76.2% / 78.6%), so it is off by default.
Tests
tests/test_decision_from_lm.py(tiny Qwen3 and Qwen3.5: load, train, calibrate, save, reload, Cloudflare loader parity) andtests/test_decision_datasets.py(every converter on fixture rows, augmentation keeps the gold answer, decontamination) pass on GPU and CPU.