Modality II (v0.52.0)
7 parts. New task families + BitNet + EBFT/GDPO + MoE quant. +237 tests (6968 → 7205).
Since v0.76.0:
quantization: bitnet_1.58,ebft_variant,gdpo_variantandtts_family: sesame_csmare refused at config load, anddata.format: audiois refused for thesparkandoutefamilies. Those sections below are kept as the record of what v0.52.0 shipped and now say what is true at v0.76.0.
TTS (task: tts)
Five tts_family values with per-family emotion vocabularies. At v0.76.0 four are runnable (orpheus, llasa, spark, oute; spark and oute only on pre-encoded data, see below); sesame_csm is refused at config load.
task: tts
modality: audio_out
base: canopylabs/orpheus-3b-0.1-ft
data:
format: audio
train: ./data/tts.jsonl
training:
tts_family: orpheus # orpheus | llasa | spark | oute (sesame_csm is refused)
tts_emotion: happy # a single string, not a listCross-validator gates per family. canopylabs/orpheus requires tts_family: orpheus.
Only two families take an emotion tag, and each has its own list. Orpheus accepts neutral, happy, sad, angry, excited, calm, whisper and laugh; Oute accepts neutral, happy, sad, angry, calm and excited. Llasa, Spark and Sesame-CSM refuse tts_emotion outright (tts_family='llasa' does not support emotion conditioning), and a tag outside a family's list is refused when the config loads (laugh on oute, for instance). For the two families that take one, the trainer prepends a family-specific control string to the first user turn of every row: <|emotion|>happy<|/emotion|> for Orpheus and [emotion: happy] for Oute.
data.format: audio encodes raw audio at train time, and what it needs depends on the family.
- Orpheus (
pip install snac, 24 kHz) and Llasa (Soup's[audio]extra: torchaudio and soundfile; torchaudio must match the installed Torch; audio is resampled to 16 kHz and encoded withHKUSTAudio/xcodec2-hf, the ids rendered as<|s_ID|>) are live on this path. - Spark and Oute raw-audio configs are refused at config load, with the compatibility reason: Spark-TTS has no installable
sparkttspackage and its official environment pins Torch and Transformers below Soup's stack, and the currentoutettspins Transformers 4.52.3 (Oute also needs transcript or word alignment). Through v0.75.2 these were described as raising a runtime error naming a missing package. sesame_csmis refused whatever the data format, because current CSM training uses text plus 32 Mimi codebooks as parallel multimodal frames, which needs a native multimodal trainer rather than a codec-string adapter. Thesesame-csm-ttsandspark-ttsrecipes were removed.
The path that runs everywhere is the pre-encoded one: encode your audio to codec tokens yourself (in the upstream codec's own environment, for Spark and Oute), register them through data.new_special_tokens, and train with data.format: chatml (or auto) over the token text, as rows with a messages list. data.new_special_tokens is registered on backend: transformers only; backend: unsloth with it is refused at load (#1358). chat is not a value of data.format, even though Soup's own error message for a missing codec package says it is. Three TTS recipes ship: orpheus-tts-sft and llasa-tts (raw audio, data.format: audio) and oute-tts (pre-encoded, data.format: chatml).
Distillation (task: distill)
task: distill
base: meta-llama/Llama-3.2-3B
training:
teacher_model: meta-llama/Llama-3.1-70B-Instruct
distill_divergence: kl # kl | forward_kl | reverse_kl | js
distill_temperature: 2.0 # [0.05, 100]kl is an alias for forward_kl, which is also what an unset distill_divergence resolves to; an unset distill_temperature resolves to 2.0. Distillation runs on backend: transformers only (mlx and unsloth are refused at config load), and it loads the student unquantized (an unset quantization resolves to none; see Keys a task ignores). Every train and validation row must keep at least one causal-loss target after truncation at data.max_length, or the run is refused at setup naming the split and row. distill_mode: sequence (the teacher generates a completion per prompt and the student trains with plain cross-entropy on it, instead of matching logits) cannot be combined with uld_strategy or minillm_enabled: the schema accepts the pair, and the distill trainer refuses it at setup, before any model loads. In sequence mode a row with no prompt turn is skipped, with the count and first positions printed.
Memory controls (v0.76.0)
Distillation with a large vocabulary and long sequences can run out of GPU memory on the logit and probability tensors the divergence builds. Two keys bound it:
task: distill
training:
teacher_model: meta-llama/Llama-3.1-70B-Instruct
distill_chunk_size: 256 # evaluate the divergence over chunks of response tokens
distill_checkpoint: true # recompute each chunk in backward instead of storing itdistill_chunk_size (an integer of at least 1) evaluates the divergence over chunks of response tokens, which bounds the transient peak. distill_checkpoint wraps each chunk in non-reentrant activation checkpointing, which cuts the bytes held for backward at the cost of recomputation. distill_checkpoint without distill_chunk_size treats all response tokens as one chunk: it reduces what is retained and leaves the transient peak unbounded. Both are refused outside task: distill, and with uld_strategy, minillm_enabled or distill_mode: sequence. No memory figure is published for them.
Two numerics changes ride with it. The three divergences are now evaluated in FP32 and the loss scalar is FP32, so values differ slightly from the old low-precision numbers. Gradient accumulation weights by the number of shifted, non-masked targets across the whole optimizer window, so splitting the same rows into unequal-length microbatches keeps the full-batch token mean. Cross-tokenizer uld_strategy: wasserstein_aligned now leaves out a student token with no teacher counterpart (#1426); re-run a distillation whose tokenizers differ in byte-fallback or special tokens.
Classifier / Reranker / Cross-Encoder
task: classifier # | reranker | cross_encoder
training:
num_labels: 5
label_names: ["very_bad", "bad", "neutral", "good", "very_good"]
classifier_kind: single_label # or multi_label (v0.52.0)
classifier_lora: false # true wraps the head with LoRA (v0.71.12)Row shapes: classifier takes {"text": "...", "label": 1} or a label name, and for multi_label a list of active indices or names ({"text": "...", "label": [0, 2]}); reranker takes {"text": "query doc", "label": ...}; cross_encoder takes {"text_a": "query", "text_b": "doc", "label": 1} or {"question": "query", "answer": "doc", "label": 1} and, for task: cross_encoder, data.format: auto (the default) reads that pair shape; the explicit value data.format: cross_encoder (v0.76.0) is accepted only on that task and is refused on any other. These three tasks load the base unquantized.
classifier_kind picks the head: single_label is the default for task: classifier, multi_label trains one independent decision per label rather than a choice between them. classifier_lora is opt-in: it wraps the sequence-classification head as a PEFT SEQ_CLS adapter instead of full fine-tuning, and the ordinary training.lora block still supplies r / alpha / dropout when you turn it on.
BitNet 1.58-bit (schema and export only)
training.quantization: bitnet_1.58 is refused at config load on every task and backend (v0.76.0): BitNet 1.58 training is not implemented yet; the export path soup export --format tq1_0 works on an existing BitNet checkpoint. Through v0.75.2 the schema accepted the value and the quantization loader then rejected it after the config had validated; the BitNetTrainerWrapper that was meant to take over had no caller in soup train. The falcon-e-bitnet-sft recipe set this key and was removed from the catalog in v0.76.0.
The export side is separate and is wired: soup export --format bitnet or tq1_0 runs llama.cpp's converter and then llama-quantize with the TQ1_0 type (it needs a built llama.cpp). is_bitnet_model is a name heuristic that matches a few prefixes (bitnet, falcon-e, falcone, 1bitllm, onebit), not a check of the weights.
EBFT + GDPO variants (refused since v0.76.0)
Both fields are refused at config load, in every value.
training.gdpo_variant(standard,length_normalized,margin) is refused (#1309): supported TRL versions (0.29 and later) no longer expose the DPO loss hook it depended on, so on those versions it was accepted and silently trained plain DPO. Remove it. Plaintask: dpois whatstandardran;task: simpois the nearest, not identical, length-normalised objective forlength_normalized;marginhas no equivalent.training.ebft_variant(structured,strided) andtraining.ebft_temperatureare refused (#1230): the term scored each position's logits against that position's own input token with no causal shift, so it rewarded copying the input; shifted onto the next token it duplicates the model's cross-entropy.ebft_temperatureon its own is refused too.
No shipped recipe, template or example sets either field. The kernels this page described as live from v0.53.2 are not reachable from a config.
MoE expert quant
training:
moe_lora: true # required: moe_expert_quant is refused without it
moe_expert_quant: nf4 # nf4 | int8_rowwise
train_router_only: true # freeze experts, train router onlymoe_expert_quant and train_router_only are read by task: sft and tts only and are refused at load on any other task. moe_expert_quant quantizes experts that are one nn.Linear each, and it needs CUDA and bitsandbytes. On a transformers 5 fused-expert MoE (Qwen3-30B-A3B is one) the experts are fused 3-D tensors with no nn.Linear to quantize, so the setup refuses with the architecture and the fused parameters named; through v0.75.2 it printed applied to 0 expert Linear block(s) and trained in full precision. moe_lora still adapts those experts, with lora.dropout: 0.0; see Backends.
Reasoning effort dispatch
training:
reasoning_effort: medium # low | medium | high (gpt-oss family)
train_on_eot: true # also train on the end-of-turn tokenBoth keys are refused unless task is one of sft, pretrain, distill, classifier, reranker or cross_encoder: on dpo the config fails with training.reasoning_effort='high' requires task in [...]; got task='dpo'. reasoning_effort prepends <|reasoning_effort|>medium<|/reasoning_effort|> to the first system message of every row, and inserts a system message at the top when the row has none. train_on_eot extends the assistant-only loss mask to include the end-of-turn token that follows each assistant reply, so the model is trained to emit the terminator; it acts through the assistant-only masking path, which is on by default (data.train_on_responses_only: true), and with that turned off it has nothing to extend.
v0.52.0 shipped the schema; v0.53.2 lit up the trainers — task: distill (DistillTrainerWrapper with KL/forward_kl/reverse_kl/js), task: classifier | reranker | cross_encoder (single-label / multi-label / cross-encoder heads), EBFT + GDPO loss kernels, and reasoning_effort dispatch. (Since closed in v0.76.0: the EBFT and GDPO fields are refused at config load, as above.) See v0.53.2 — Modality II live.
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.