Correctness First (v0.36.0)

Four silent-failure modes Soup had → loud failures.

Assistant-only loss masking

By default, Soup masks every non-assistant token with -100 so the SFT loss reflects only what the model should generate. Toggle via data.train_on_responses_only (default true):

yaml
data:
  train: data.jsonl
  train_on_responses_only: true   # default
  # OR per-message control:
  # train_on_messages_with_train_field: true

When the tokenizer ships a chat template with {% generation %} markers, the mask is exact. Without those markers, Soup falls back to an incremental tokenize-delta walk and documents the looseness.

It does not apply to vision or audio SFT. The multimodal collators supervise every token. soup doctor --config reports the key as ignored for task: sft with modality: vision (multimodal vision collator supervises all tokens; assistant-only masking is not implemented for vision datasets (#1156)) and for modality: audio (audio training supervises all tokens ...); both lines were produced on the v0.76.0 CLI.

If you trained SFT with training.packing: true while this key was on (the default), re-run it if the model should only learn the replies. With TRL 0.29.1, upstream reports, dataset packing drops the unpreserved labels column and falls back to training all input tokens except the first, so the prompt and system tokens were trained on as well, and the loss curve looked normal. Since v0.76.0 SFT datasets carry an assistant_masks column alongside labels (also under use_liger: true), and pre-tokenized caches that carry labels are augmented with it automatically (#1236, #1304).

If you trained with this before v0.73.3, re-run it. A tokenizer returns a mapping object that is not a dict, and a type guard missed that, so the label mask was built from the mapping's key strings rather than from its token ids. There was no exception and no warning: the loss curve looked entirely normal, and the run trained on zero tokens. Token ids are now read through input_ids and normalised to plain integers before they reach the collator, and a missing or non-integer id fails loudly instead of producing a garbage mask. Separately, a template that reports an all-zero assistant mask while assistant messages plainly exist now falls back to the incremental walk, rather than shipping an all--100 no-op. The same assumption was removed from soup data doctor, which had inherited it.

--trust-remote-code opt-in

Thirteen command modules require --trust-remote-code to load any HF model that ships custom Python (auto_map in config.json): train, chat, serve, infer, diff, merge, export, shrink, draft, generate, and the data, data doctor and eval groups. The default is deny, everywhere.

bash
soup train --config soup.yaml --trust-remote-code

First-party orgs (Meta, Mistral, Qwen, Google, etc. — 15 in the allowlist) suppress the warning panel; everything else prints a REMOTE CODE WARNING panel before loading.

The count is still thirteen at v0.76.0: the same modules declare the option (counted by searching src/soup_cli/commands/ at both tags), and v0.76.0 made it reach commands that dropped it (#1449, #1478):

  • soup eval auto forwards the flag to both of its steps. soup eval benchmark and soup eval custom take their own --trust-remote-code, off by default, and the flag is appended to the lm-eval model arguments from the flag alone, never from an adapter's adapter_config.json. Through v0.75.2 soup eval auto accepted the flag and nothing read the value (see Model Evaluation).
  • soup train --find-lr loads the model with the resolved flag (it used a hardcoded False), and soup draft distill and the heal step of soup shrink pass --trust-remote-code on to the child soup train instead of telling you to re-run with a flag you had already given.
  • soup data download --trust-remote-code is refused (exit 1) when the installed datasets package is version 4 or newer, because those versions dropped remote dataset code and the flag would be silently ignored. Install datasets<4 if a dataset needs its loading script. Without the flag nothing changes.

Chat-template hardening

Tokenizers without a chat template now raise a ValueError with a fix suggestion instead of silently building garbage f"{role}: {content}" strings.

yaml
data:
  train: data.jsonl
  chat_template: chatml   # or: llama3, qwen2.5, mistral, gemma3, phi4, deepseek-r1, or a raw Jinja string

Raw Jinja strings are validated: null bytes, >64 KB, and filesystem-touching directives ({% include %}, {% import %}, {% from %}, {% macro %}, {% extends %}) are rejected at config-load.

Which trainers honour data.chat_template. Through v0.75.2 only the SFT path applied it: a DPO, ORPO, SimPO, KTO, BCO, IPO, PPO, reward-model, GRPO or online-DPO run with data.chat_template: llama3 silently rendered its prompts with the tokenizer's own template (#804, #919). Since v0.76.0 those ten trainers install the override before conversational data is rendered, as SFT does. If you set data.chat_template on one of them, the override was ignored; re-run if the rendering mattered. Tasks that never render chat refuse the key at config load instead of ignoring it: pretrain, embedding, classifier, reranker, cross_encoder, prm, asr, moe_lora_routing and unlearn (not applied under stream_layers, which has its own task-specific refusal). For example, soup doctor --config on a task: pretrain config that names a chat template prints data.chat_template is not used by task='pretrain'; remove it or choose a conversational training task and exits 2 (run on the v0.76.0 CLI).

OOM-probe auto batch size

yaml
training:
  batch_size: auto                  # unchanged
  auto_batch_size_strategy: probe   # NEW: 'static' | 'probe' | 'auto' (default)

Replaces the static memory formula with a real try-halve-then-double-to-ceiling loop.

The fit criterion changed in v0.74.0, and it matters most on Windows. Until then the probe's only test was "the synthetic step did not raise". Under the WDDM driver, which is native Windows and WSL2, the allocator does not raise on an over-commit: it spills into host memory. So a batch that ran an order of magnitude slower in shared memory was approved and cached. The probe now reads max_memory_allocated after the step and refuses anything above what the process can actually reach on the device, and the cache key carries a version tag so entries written by the old probe are ignored rather than trusted. If you used batch_size: auto on Windows or WSL2 before v0.74.0, the cached size may never have been a fit.

Two floors arrived in v0.76.0, per upstream's changelog: batch_size: auto never resolves below 2 for task: kto, which refuses a batch of 1 at config load for every KTO config, and for the default contrastive embedding loss, where a one-row batch gives a loss and gradient of exactly 0.

Picked size is cached at ~/.soup/batch_cache.json keyed on (model, max_length, quantization, lora_r, gpu_name, gpu_memory_gb) so repeat runs short-circuit. Cache file gets best-effort 0o600 perms after atomic rename.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.