Skip to content

Studio: fine-tune Laya and Cloudflare Clef decision models and serve them - #12585

Merged
danielhanchen merged 62 commits into
unslothai:mainfrom
NilayYadav:fine-tune-laya-decision-models-v2
Oct 6, 2026
Merged

danielhanchen merged 62 commits into
unslothai:mainfrom
NilayYadav:fine-tune-laya-decision-models-v2

Conversation

@NilayYadav

@NilayYadav NilayYadav commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Studio can serve Laya decision models through the Decision API, but there is no way to train one on your own decisions, and Cloudflare's Clef and Clef-flash decision models are not supported at all. Picking convaiinnovations/laya or Cloudflare/clef-flash on the Train page treats it as a chat model, and FastDecisionModel.from_pretrained("Cloudflare/clef-flash") does not exist.

This PR adds decision model fine-tuning for both families, in Studio and in Python, and serves the results through the Decision API.

What you get

  • Laya (convaiinnovations/laya): pick a checkpoint (Multilingual, English or Typed decisions) and LoRA or a full fine-tune on the Train page.
  • Clef and Clef-flash (Cloudflare/clef, Cloudflare/clef-flash): QLoRA (default), LoRA or a full fine-tune. Clef-flash is also a stock Decision API model now, next to Laya.
  • Datasets use the same format the Decision API takes: state, questions and gold columns. Decisions that cannot be used are skipped and counted.
  • Up to 400 decisions are held out to report accuracy before and after training and to calibrate confidence, so a run ends with a line like Held-out accuracy 0.68 -> 0.78, calibration error 0.03.
  • A finished run has a Use in Decision API button, and Settings > API lists your fine-tunes next to the stock models. Other accounts only see the fine-tune the owner picked.
  • The same training is available in Python:
from unsloth import FastDecisionModel, DecisionTrainer

model, tokenizer = FastDecisionModel.from_pretrained("Cloudflare/clef-flash", load_in_4bit = True)
model = FastDecisionModel.get_peft_model(model, r = 16, lora_alpha = 16)
items, report = FastDecisionModel.build_dataset(rows, tokenizer, model)
train, held = FastDecisionModel.split_holdout(items)
DecisionTrainer(model = model, args = args, train_dataset = train, processing_class = tokenizer).train()
FastDecisionModel.calibrate(model, tokenizer, held)
model.save_pretrained_merged("clef_flash_finetune", tokenizer)

A saved Clef fine-tune keeps Cloudflare's layout, so it also loads with load_release_model from the Clef repo, with the calibrated temperature folded into the head.

Clef details

  • Cloudflare's joint_schema_model.py (Apache-2.0) is vendored unmodified under unsloth/_vendor/clef with its license and a sha256 manifest. unsloth/models/clef.py imports the record encoding from it unchanged and adds a faster head forward.
  • The backbone loads through FastModel, so Qwen3.5 uses the flash-linear-attention gated delta kernels, causal-conv1d and Unsloth's compiled modules. On Clef-flash, against the plain torch path on one B200:
Clef-flash LoRA Fast path s/step Plain torch s/step Speedup Peak memory
bfloat16 1.08, 1.30 3.18, 2.31 about 2.2x 21.3 GB vs 22.4 GB
4-bit 1.25, 0.97, 0.96, 1.26 2.18, 2.67, 2.39, 2.11 about 2.1x 11.0 GB vs 12.2 GB
  • 4-bit uses Unsloth dynamic quantization (the vision tower and the gated delta in_proj layers stay in 16-bit, as in unsloth/Qwen3.8-27B-unsloth-bnb-4bit). Clef's backbone has a fine-tune merged into it, so it is quantized on load rather than taken from the stock Qwen 4-bit repos.
  • The joint head is batched over the whole batch with no host syncs, its fp32 norm and pooling run in 2048-token chunks so long rows never hold an fp32 copy of the hidden states, and it compiles with no graph breaks. Its forward and backward take half the time at 3.5K tokens per row, and its own peak memory drops from 2.41 GB to 0.83 GB at 30K tokens. The head is only 1 to 3% of a QLoRA step, so whole-step time is unchanged.
  • No vocabulary-sized logits are ever computed: the head reads the backbone's last hidden states, so no chunked cross-entropy is needed.
  • float16 and bfloat16 both work. On GPUs without bfloat16 (T4) Qwen3.5 takes Unsloth's float32 path, as in the Qwen3.5 notebooks.
  • Calibration fits the temperature against the gold labels. Fitting it to the soft consensus labels left fine-tuned models underconfident.
  • Optional DecisionTrainer terms from Cloudflare's write-up: label_smoothing, brier_weight, ordinal_weight, permute_fields and kl_weight. None beat seed noise on accuracy and calibration together in our runs, so all are off by default.
  • Clef needs an NVIDIA or AMD GPU. On a machine without one, Clef models are greyed out in Settings and training and the Decision API refuse them with a clear message.

Results

Clef-flash on LocalLLaMA/typed-decisions, 60 steps, LoRA r16, held out on 1,500 test decisions, one B200:

Peak VRAM Accuracy before Accuracy after Calibration error after calibrating
QLoRA (dynamic 4-bit), bfloat16 13.1 GB 70.9% 77.8% 0.035
QLoRA (dynamic 4-bit), float16 13.1 GB 70.7% 78.7% 0.028
LoRA, bfloat16 21.5 GB 70.5% 78.9%

A QLoRA fine-tune saved merged and reloaded scores 78.0%. Clef (27B) QLoRA after 20 steps goes from 65.3% to 68.7% on 300 decisions with 26.4 GB peak.

Laya Multilingual on the same dataset, scored on its test split (2,000 decisions) on one NVIDIA L4:

Peak VRAM Test accuracy
Base model, no training - 35%
Laya's own recipe (full fine-tune) 6.6GB 74.5%
LoRA (default) 2.5GB 77%
Full fine-tune 7GB 76%

Through Studio's Decision API over HTTP, stock Clef-flash answers the Clef README request in 84 to 160 ms once warm on a B200.

Other fixes

  • Downloading a decision checkpoint now asks the Hub for its file list first. Asking for both layouts at once made Unsloth's incomplete snapshot check reject every decision repo.
  • Saving a fine-tune over an existing folder is crash safe: files are staged and moved in with the completion marker last.
  • A local eval file that is also a training file now warns and holds out from training instead of reporting training rows as held out.
  • A cancel that arrives during calibration no longer saves the model.
  • Laya never loads through FastLanguageModel or FastModel, so DecisionTrainer now applies Unsloth's gradient accumulation fix itself. Without it, transformers 5.0 to 5.5 (Studio's default) divided accumulated gradients by gradient_accumulation_steps twice.

Tested on

  • The decision test suites pass on a B200 (bfloat16, 4-bit), and on Colab T4 (float16), L4, A100 and RTX PRO 6000. On the T4 the bfloat16 case is skipped because that GPU has no bfloat16.
  • AMD Strix Halo (gfx1151, ROCm, Linux): tiny Clef trains in bfloat16, float16 and 4-bit with bitsandbytes, and Laya LoRA trains from 33.7% to 48.1% held-out accuracy in 20 steps. The two test failures it found (the gradient accumulation scale above, and a kernel check that only understood newer transformers) are fixed here. Windows on AMD cannot import Unsloth yet because there is no Triton wheel; that predates this PR.
  • The CPU decision suite passes on both transformers 5.5 and 5.17.

Follow-ups: #12772 turns a plain language model (for example Qwen3.5-2B) into a decision model, and #12778 compiles the Laya encoder during training (about 1.23x faster LoRA steps).

Training needs Python 3.10 or newer and uses one GPU. It is not offered on Apple Silicon. If the Decision API is set to GPU, Laya answers on CPU while a run trains and moves back to the GPU when the run ends; Clef answers 503 while a run holds the GPU. Chat models, their training and the stock Decision API models work as before.

Screenshots

Before, on main: Laya is treated as a chat model.

Before: Laya on main

After: the Train page shows Laya as a decision model, with a checkpoint picker and LoRA settings.

After: Train page

A finished run reports held-out accuracy and offers Use in Decision API.

After: finished run

Settings > API lists the fine-tunes next to the stock models.

After: Settings > API

UI: Decision API model choices

Settings > API > Decision API, the Model select (BEFORE = main at the merge base, AFTER = this branch). The three Laya checkpoints are unchanged; Clef-flash (19 GB) and Clef (55 GB) are added, and /api/settings/systemone lists 5 models instead of 3.

Decision API Model select before and after

@NilayYadav NilayYadav left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review + reproduction evidence

Pinned review: f7c5a510a6. 4 findings, fixed in 6fad6e5 and f7c5a51; 2 later suggestions listed below.

I ran the Train flow by hand on fresh upstream main and on this PR (Modal NVIDIA L4, Chromium 1440x900): pick convaiinnovations/laya, pick LocalLLaMA/typed-decisions, 30 steps, Start Training.

  • main faca4e2: Laya is set up as a chat model (QLoRA 4-bit, context 2,048, LR 2e-4, "Couldn't verify this model's settings"). Start Training opens the chat column mapping ("assign user and assistant") with Continue disabled, so no run starts.
  • PR f7c5a51: Checkpoint picker (Multilingual), LoRA 16-bit, LR 8e-4. The run completes with "Held-out accuracy 0.20 -> 0.73, calibration error 0.20". Use in Decision API selects laya-ft:convaiinnovations_laya-multilingual_1790990503, and Settings > API lists it under the stock models.

PR 12585 before/after

  • Decision API on 300 test decisions (agent_trace_observability test split): fine-tune 171/300 (57.0%), stock laya-multilingual 87/300 (29.0%).
  • With the Decision API on GPU, the loaded device goes cuda, then cpu during a training run, then cuda after it.
  • Chat models are unchanged: Qwen3-0.6B picks LoRA 16-bit 5e-5 on main and on the PR, and Qwen -> Laya -> Qwen and Llama-3.2-3B -> Laya -> Llama restore the chat settings exactly, also after a reload.
  • Tests on Modal: backend 285 passed (main 56 failed); library 32 passed on L4.

Fixed in this PR

  • use_dora / use_loftq on a decision run were accepted and silently trained plain LoRA (a request with use_dora: true returned 200). It now returns 400: "Decision models train with plain LoRA, so DoRA and LoftQ are not available for them."
  • The Hub preflight checked only the repo root for rl_agent_config.json, so a checkpoint in model_subfolder was refused. It now checks the subfolder.
  • FastDecisionModel.for_training(model) never turned gradient checkpointing back on. On the real checkpoint it was False after the call; it is now True.
  • test_toy_task_beats_the_base_model failed on transformers 5.16.1 (assert 1.0 > (0.8222 + 0.25)): the untrained tiny model already scored 0.82. The assertion now caps at 1.0.

Suggestions, not reproduced

  • unsloth/models/decision.py:496: the LoRA save deep-copies the encoder on the GPU before merging, about one extra encoder (0.6 to 0.8 GB). A run that only just fits could OOM at save time.
  • studio/backend/routes/training.py:1109: a nested checkpoint behind a root adapter_config.json or GGUF-only root is refused before the subfolder is checked.
  • A keyless caller with a mistyped model now gets 403 instead of 400 "Unknown model". This looks deliberate, so callers can't probe fine-tune names.

Not covered: Actions A/B (fork mirrors still queued), Mac, Windows, AMD, a non-owner account, and Stop/Cancel mid-run.

Conclusion: the feature works end to end on Linux + NVIDIA, and chat training is unchanged. It is ready once upstream CI is green.

@danielhanchen

Copy link
Copy Markdown
Member

Of the reds on head 95ed4bfb: (1) this PR: tests/python/test_accelerator_skip_guards.py::test_no_skip_guard_reads_a_spoofable_accelerator_probe flags test_decision_model.py:495: torch.cuda.is_available(), which unsloth's own CUDA spoofing makes true on CPU runners; the repo's real-device helpers (has_real_cuda()) are what the guard expects. (2) The Repo tests (CPU, studio) red on test_desktop_reliability_frontend_contract.py::test_the_sidebar_action_geometry_still_follows_the_ui_scale is main's since #12563 and is fixed by #12597, merged just now.

@NilayYadav NilayYadav left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review follow-up

Pinned: 79527a56d0. The automated review rounds are now clean on this head. Three more findings are fixed since my last comment:

  • LoRA save copied the encoder on the GPU (95ed4bf). Measured on the real checkpoint, peak GPU memory during save_pretrained_merged was +0.64 GiB (Multilingual) and +0.88 GiB (English). With the merge on a CPU copy it is +0.00 GiB, the encoder stays on the GPU and the adapters are kept.
  • A decision checkpoint in a subfolder was refused when the repo root held an adapter or GGUF (95ed4bf). Decision requests now go straight to the subfolder check.
  • TensorBoard was ignored for decision runs (79527a5). On a real Studio, a run with enable_tensorboard: true completed with no event files. It now writes runs/decision-tb/events.out.tfevents.*. transformers 5.5 ignores logging_dir, so the run sets TENSORBOARD_LOGGING_DIR. The chat trainer passes logging_dir the same way on main, which may be worth a separate look.

Tests on Modal: backend 287 passed (58 fail on main), library 33 passed on L4.

Conclusion: no open findings. Ready once upstream CI is green.

@Etherll Etherll self-assigned this Oct 4, 2026
@danielhanchen danielhanchen self-assigned this Oct 5, 2026
danielhanchen and others added 5 commits October 5, 2026 04:01
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.
Etherll added a commit to Etherll/unsloth that referenced this pull request Oct 6, 2026
danielhanchen pushed a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Oct 6, 2026
@LeoBorcherding

Copy link
Copy Markdown
Collaborator

Ran it again at b4be268, this time through the library on a 4 GB card: AMD Radeon RX 6500 XT (gfx1034), Windows 11, ROCm 7.14, torch 2.11.0+rocm7.14. Different path from the first review: FastDecisionModel.from_pretrained → get_peft_model → build_dataset → DecisionTrainer → calibrate → save_pretrained_merged, then the merged folder served through the vendored laya.agent.Agent, which is what the Decision API uses.

The task is "does this chat message need a calculator": 1,200 train / 300 held-out synthetic rows, each with all three question types (choice: calculator / none / web_search / code; noul: does the user expect an exact number; score 0 to 2: how hard is the arithmetic). The point was to exercise the three heads and the serving side, not the dataset.

base after 40 steps
held-out accuracy (900 decisions) 0.50 0.93
whole-row accuracy (all three right) 0.04 0.82
calibration error 0.146 0.161 before calibrate, 0.145 after
run 25 s, 0.6 s/step, 1.51 GB peak

The merged folder reloads with identical numbers, and Agent.system_one on it answers all three types (choice with probabilities, noul, score with the legend).

tool picked correctly per message kind, base vs fine-tuned

Per kind: every kind the dataset marks as calculator goes to 100%. The two drops are the dataset's doing, not the library's: web_search is 7% of the rows and neither model ever picks it, and the CSV/script rows fall from 100% to 59% because the tuned model now reads a number word as "calculator".

training loss and the shift in p(calculator) per kind

Left: training loss over the 40 steps. Right: mean p(calculator) per kind before and after. The six arithmetic kinds move right, the rest stay put or move left.

Two things from reading b4be268, both small:

  1. The Stop dialog still says "Stop and Save writes a checkpoint you can resume from later" (en.ts:3342), and that's the dialog a decision run shows, but routes/training.py:1254 returns 400 on a decision resume. The copy needs a decision variant, or the dialog should drop the resume promise for those runs. A fix with the 12 locales is on the fork mirror if it helps: LeoBorcherding/unsloth#144.
  2. laya_runtime.py:350 imports the Decision API device setting but a clef checkpoint ignores it: it always loads ClefAgent on CUDA and returns "cuda", even with the setting on cpu (the default). Either Clef shouldn't be selectable while the device is CPU, or the setting should say it only applies to Laya.

Both are code reads at b4be268, not runs; the Stop dialog was seen in Unsloth Desktop at c5cb7cb.

danielhanchen and others added 3 commits October 6, 2026 03:17
…ke decision tests portable to multi-GPU, Windows and Apple Silicon hosts
…run's training record when loading a Clef checkpoint
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants