Skip to content

Feat(mlx): Add ORPO (loss_type='orpo') for text models to MLXTrainer - #830

Open
BardiaKoopah wants to merge 29 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-orpo
Open

Feat(mlx): Add ORPO (loss_type='orpo') for text models to MLXTrainer#830
BardiaKoopah wants to merge 29 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-orpo

Conversation

@BardiaKoopah

@BardiaKoopah BardiaKoopah commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Add ORPO and DPO preference tuning for text models to MLXTrainer

Adds two preference-tuning methods — ORPO and DPO — to the MLX trainer, bringing preference optimization to the Apple-Silicon path. On CUDA, Unsloth gets these by patching TRL's trainers (rl_replacements.py); since there is no TRL on MLX, these are native implementations built on the existing MLX trainer, sharing a common preference data collator (mirroring TRL's single DataCollatorForPreference).

Enable via loss_type="orpo" or loss_type="dpo". Default ("sft") is unchanged.

ORPO

Combines SFT with a preference signal in one loss, no reference model:
L = L_SFT + beta * L_OR, where L_OR = -log(sigmoid(log_odds_chosen - log_odds_rejected)) using the full p/(1-p) odds (paper-faithful, not a simplified log-prob ratio), with the 1 - exp(logp) term numerically stabilized.

Config: orpo_beta: float = 0.1 (TRL default).

DPO

Compares the policy against a frozen reference:
L = -log(sigmoid(beta * [(logp_chosen^pol - logp_chosen^ref) - (logp_rejected^pol - logp_rejected^ref)])).

Reference via live LoRA-disable (matching TRL's is_peft_model default — no second model copy). The base model is the reference: we temporarily zero the LoRA scales (mlx_lm's LoRALinear.__call__ is y + scale*z, so scale=0 disables the adapter), run the reference forward under mx.stop_gradient, and restore the scales in a finally block. Verified the scale restore is bit-exact and survives value_and_grad tracing.

Config: dpo_beta: float = 0.1, reference_free: bool = False (drops the reference term, matching TRL). Precompute of reference log-probs (TRL's precompute_ref_log_probs) is a possible follow-up optimization; this PR computes the reference live each step.

Shared design

  • Concatenated forward, mirroring TRL's concatenated_forward: chosen and rejected stacked into one batch (chosen block then rejected block), single forward, split in the loss. Verified MLX value_and_grad differentiates cleanly through the concat-and-split.
  • Shared collator create_preference_batches: orders pairs per dataset_order"default" (length-sort, and the default, so existing runs are unchanged), "sequential", or "torch_randperm", with preserve_dataset_order=True forcing sequential — then dynamic-pads to a multiple of 32 (Apple-Silicon padding) and produces (batch, lengths, None) consumed by the existing text step path. Both ORPO and DPO use it.
  • Minimal trainer footprint: both loss fns match make_baseline_loss_fn's (model, batch, lengths, labels) signature; the batch path short-circuits in _prepare_data.

Scope

Text models only. VLMs raise a clear not-supported error; eval during preference tuning prints a skip notice and trains normally. Both are follow-ups.

Preference collation accepts both row formats: plain-string prompt / chosen / rejected, and conversational (message-list) rows, which are rendered through the tokenizer chat template. Conversational rows also handle OpenAI-style content parts (content=[{"type": "text", ...}]), per-row tools, and per-row chat_template_kwargs.

The returned batch list exposes cycle_length, the micro-batch count of one dataset pass, so the trainer can force the epoch-final optimizer step on a ragged tail instead of folding it into the next pass.

Not yet supported, both follow-ups:

  • max_prompt_length / max_completion_length truncation knobs. Over-long rows are truncated prompt-first, keeping the end of the prompt so the completion survives with a non-empty loss span, but the separate named length budgets do not exist.
  • Implicit-prompt datasets carrying only chosen / rejected columns. An explicit prompt column is required.

Testing (Qwen2.5-0.5B, small preference sets)

  • ORPO end-to-end: trains, loss decreases; loss math verified against the paper in all regimes (chosen≫rejected, rejected≫chosen, equal) and at the numerical edge.
  • DPO end-to-end: trains, loss decreases. Step 1 loss = 0.6931 = -log(0.5) exactly (untrained adapter → policy == reference → zero logits), the theoretically-correct starting value, then moves as the policy diverges. DPO loss math verified directionally in a controlled test (GOOD < NEUTRAL < BAD, NEUTRAL = -log(0.5)). Reference scale-toggle verified bit-exact and gradient-safe across multiple steps (no leak).
  • reference_free DPO: trains, reference term dropped (distinct loss behavior, no -log(0.5) midpoint).
  • Regression: SFT (default) unchanged (prints CCE loss); ORPO unaffected by the DPO wiring; both verified after the shared-collator rename.
  • gradient_accumulation_steps>1: both methods run clean.
  • Guards: VLM raises the clear error; eval prints the skip notice and trains.
  • Existing MLX suite: 90 passed across baseline-loss-parity, batching, padding, module-exports, trainer-internals. The only failures are three pre-existing CCE-kernel tests (test_cce_*) that fail identically on main without this change — a torch-shim issue unrelated to this work.
    Smoke tests confirm the objectives run correctly and the losses behave as expected; they do not measure downstream preference-alignment quality (needs a full eval, out of scope). Tested on small datasets.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for Odds Ratio Preference Optimization (ORPO) training in the MLX trainer, including configuration options, batch creation, and loss function implementation. The review feedback highlights two critical improvements: first, create_orpo_batches should apply chat templates and support message lists to prevent broken formatting and type errors; second, the odds-ratio calculation in make_orpo_loss_fn should be performed in float32 to avoid numerical underflow and NaN gradients during low-precision training.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +570 to +581
for ex in dataset:
if prompt_key not in ex or chosen_key not in ex or rejected_key not in ex:
raise ValueError(
f"ORPO requires '{prompt_key}', '{chosen_key}', '{rejected_key}' "
f"columns; got {sorted(ex.keys())}."
)
prompt = ex[prompt_key]
p_ids = hf.encode(prompt)
c_ids = hf.encode(prompt + ex[chosen_key])[:max_seq_length]
r_ids = hf.encode(prompt + ex[rejected_key])[:max_seq_length]
pe = min(len(p_ids), len(c_ids), len(r_ids))
rows.append((pe, c_ids, r_ids))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The current implementation of create_orpo_batches directly encodes prompt and prompt + ex[chosen_key] as raw strings. This has two major issues:

  1. Broken Chat Templates / Control Tokens: It bypasses the model's chat template. For chat/instruction models, prompts and responses must be formatted with control tokens (e.g., <|im_start|>, [INST], etc.). Without this, the model trains on raw text and will fail to learn the correct chat format.
  2. Type Errors with Message Lists: Many preference datasets (e.g., UltraFeedback) store prompt and chosen/rejected as lists of message dicts (conversations). Directly doing prompt + ex[chosen_key] will raise a TypeError when trying to concatenate a list and a string, or when passing a list of dicts to hf.encode.

To fix this, we should normalize the inputs into message lists, apply the tokenizer's chat template (if available) to format them with control tokens, and then encode the resulting formatted strings.

    for ex in dataset:
        if prompt_key not in ex or chosen_key not in ex or rejected_key not in ex:
            raise ValueError(
                f"ORPO requires '{prompt_key}', '{chosen_key}', '{rejected_key}' "
                f"columns; got {sorted(ex.keys())}."
            )
        prompt = ex[prompt_key]
        chosen = ex[chosen_key]
        rejected = ex[rejected_key]

        if isinstance(prompt, str):
            prompt_messages = [{"role": "user", "content": prompt}]
        else:
            prompt_messages = prompt

        if isinstance(chosen, str):
            chosen_messages = [{"role": "assistant", "content": chosen}]
        else:
            chosen_messages = chosen

        if isinstance(rejected, str):
            rejected_messages = [{"role": "assistant", "content": rejected}]
        else:
            rejected_messages = rejected

        apply_tmpl = getattr(tokenizer, "apply_chat_template", None) or getattr(hf, "apply_chat_template", None)
        if apply_tmpl is not None:
            prompt_str = apply_tmpl(prompt_messages, tokenize=False, add_generation_prompt=True)
            chosen_str = apply_tmpl(prompt_messages + chosen_messages, tokenize=False)
            rejected_str = apply_tmpl(prompt_messages + rejected_messages, tokenize=False)
        else:
            prompt_str = prompt if isinstance(prompt, str) else ""
            chosen_str = prompt_str + (chosen if isinstance(chosen, str) else "")
            rejected_str = prompt_str + (rejected if isinstance(rejected, str) else "")

        p_ids = hf.encode(prompt_str)
        c_ids = hf.encode(chosen_str)[:max_seq_length]
        r_ids = hf.encode(rejected_str)[:max_seq_length]
        pe = min(len(p_ids), len(c_ids), len(r_ids))
        rows.append((pe, c_ids, r_ids))

Comment thread unsloth_zoo/mlx/utils.py Outdated
Comment on lines +541 to +546
# Odds-ratio term. log(p/(1-p)) per side; 1-exp(logp) stabilized.
val_c = mx.maximum(-mx.expm1(logp_c), mx.array(1e-12, logp_c.dtype))
val_r = mx.maximum(-mx.expm1(logp_r), mx.array(1e-12, logp_r.dtype))
log_odds = (logp_c - mx.log(val_c)) - (logp_r - mx.log(val_r))
or_loss = -mx.mean(nn.log_sigmoid(log_odds))
loss = sft + beta * or_loss

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In low-precision training (such as float16 or bfloat16), computing odds-ratio terms directly can lead to numerical instability. Specifically, the constant 1e-12 underflows to 0.0 in float16 (where the minimum positive subnormal is 5.96e-8). If logp_c is 0.0 (e.g., if a token has a probability of 1.0), -mx.expm1(logp_c) becomes 0.0, and mx.maximum(0.0, 1e-12) will evaluate to 0.0 due to underflow. This results in mx.log(0.0) = -inf, causing NaN gradients and training crashes.

To prevent this, perform the odds-ratio calculations in float32 and cast the final or_loss back to the original dtype.

Suggested change
# Odds-ratio term. log(p/(1-p)) per side; 1-exp(logp) stabilized.
val_c = mx.maximum(-mx.expm1(logp_c), mx.array(1e-12, logp_c.dtype))
val_r = mx.maximum(-mx.expm1(logp_r), mx.array(1e-12, logp_r.dtype))
log_odds = (logp_c - mx.log(val_c)) - (logp_r - mx.log(val_r))
or_loss = -mx.mean(nn.log_sigmoid(log_odds))
loss = sft + beta * or_loss
# Odds-ratio term. log(p/(1-p)) per side; 1-exp(logp) stabilized in float32.
logp_c_f32 = logp_c.astype(mx.float32)
logp_r_f32 = logp_r.astype(mx.float32)
val_c = mx.maximum(-mx.expm1(logp_c_f32), mx.array(1e-12, dtype=mx.float32))
val_r = mx.maximum(-mx.expm1(logp_r_f32), mx.array(1e-12, dtype=mx.float32))
log_odds = (logp_c_f32 - mx.log(val_c)) - (logp_r_f32 - mx.log(val_r))
or_loss = -mx.mean(nn.log_sigmoid(log_odds))
loss = sft + beta * or_loss.astype(sft.dtype)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch. Fixed in 4035588: the odds-ratio term is now computed in float32, so the 1e-12 floor no longer underflows to 0 in float16 (which drove log(0) = -inf and NaN gradients on a perfectly-predicted or empty-response row). This matches TRL's odds_ratio_loss, which upcasts the logps to float32.

Lyxot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

I ran a small cross-backend train-curve check against the CUDA/TRL path. This is not a full quality eval, and after checking the reference notebooks I would treat it as a smoke/parity probe rather than a strict apples-to-apples benchmark.

Setup:

  • PR head: 89108ff
  • Model: Qwen/Qwen3-0.6B
  • CUDA reference: import unsloth first, then TRL trainers; runtime resolves to UnslothORPOTrainer / UnslothDPOTrainer, not vanilla TRL
  • MLX: this PR via FastMLXModel, 4-bit MLX affine quantization
  • CUDA: Unsloth + TRL, load_in_4bit=True, RTX 4070 SUPER
  • Shared config: 8 train steps, batch size 2, max_seq_length=128, lr 1e-4, beta 0.1, LoRA r=8, alpha=16, seed 3407

Results summary (grad = global grad norm):

dataset method MLX loss curve MLX grad curve CUDA loss curve CUDA grad curve
arithmetic toy ORPO 1.572, 1.029, 0.349, 0.169, 0.058, 0.016, 0.000, 0.000 17.787, 13.891, 7.204, 4.311, 1.951, 0.925, 0.490, 0.382 3.582, 3.397, 2.768, 2.343, 2.298, 2.297, 2.025, 1.863 12.483, 10.201, 8.139, 11.295, 6.754, 7.254, 5.469, 5.122
arithmetic toy DPO 0.693, 0.627, 0.594, 0.491, 0.484, 0.438, 0.367, 0.360 2.502, 2.419, 1.815, 3.371, 2.282, 2.172, 2.874, 2.385 0.693, 0.640, 0.601, 0.600, 0.482, 0.473, 0.341, 0.392 4.045, 2.728, 2.574, 2.350, 2.382, 3.008, 2.311, 3.458
QA preference set ORPO 3.993, 2.098, 1.526, 1.378, 3.572, 1.723, 2.233, 0.964 47.459, 18.906, 17.048, 17.903, 36.140, 22.757, 20.603, 18.054 3.923, 3.190, 2.972, 2.634, 2.584, 2.359, 2.320, 2.055 25.142, 24.234, 11.785, 8.813, 10.179, 8.897, 6.843, 5.843
QA preference set DPO 0.693, 0.704, 0.703, 0.698, 0.683, 0.636, 0.676, 0.658 2.082, 2.982, 2.880, 2.101, 3.500, 3.325, 1.467, 2.607 0.693, 0.687, 0.667, 0.597, 0.666, 0.633, 0.678, 0.633 4.442, 2.617, 3.128, 3.031, 3.352, 3.253, 3.735, 3.742

DPO looks reasonably close: both backends start at 0.693 ~= -log(0.5), and the loss curves stay in the same range. Grad norms are not identical, but they are broadly comparable for this kind of cross-backend 4-bit run. That is a good sign for the reference/LoRA-disable direction.

ORPO still has a real parity gap. On the arithmetic toy set, MLX saturates to near-zero loss while CUDA does not. On the QA set, MLX no longer saturates, but the loss/grad shape is still noticeably different.

Confirmed differences I found:

  1. ORPO SFT/NLL semantics differ from TRL. Unsloth/TRL ORPO computes chosen_nll_loss from the chosen input_ids with the attention mask, i.e. full prompt + response NLL. This MLX implementation masks to response tokens only and uses response-normalized logp_c as the SFT term. That is the strongest confirmed reason ORPO scale/shape can differ.

  2. The synthetic harness is not a strict notebook reproduction. The reference ORPO notebook explicitly formats an Alpaca-style prompt/chosen/rejected dataset. The DPO notebook explicitly applies a chat template into text_prompt/text_chosen/text_rejected. My harness used small synthetic prompt/chosen/rejected rows, so it is useful for catching gross loss/grad issues but not enough to prove notebook-level parity.

  3. CUDA and MLX sequence construction are not identical yet. On the synthetic QA row, CUDA/TRL tokenized raw prompt/completion and appended EOS to completions; MLX currently directly encodes prompt + chosen/rejected in create_preference_batches. That is closer than a chat-template mismatch for this specific harness, but still not bit-equivalent batching.

Suggested next step before judging parity: rerun with the exact notebook-style dataset formatting, dump the first CUDA and MLX batch token IDs/masks/labels, and compare one frozen no-update loss component by component. For ORPO, align the TRL chosen-NLL/SFT term first. DPO already looks close enough that I would focus mostly on strict preprocessing/batch parity and ORPO semantics.

The MLX ORPO loss computed its SFT/NLL term from the response-only,
length-normalized log-prob (the same quantity used for the odds-ratio
term). TRL's chosen_nll_loss is instead a pooled token-mean cross-entropy
over the full prompt+response span (all non-pad chosen tokens, matching
nn.CrossEntropyLoss default reduction). The narrower span made the SFT
signal trivially easy, so ORPO saturated to ~0 loss on toy data while
CUDA did not.

Compute the NLL term over prompt+response for the chosen rows; the
odds-ratio term (response-only, length-normalized) is unchanged.
@BardiaKoopah

BardiaKoopah commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Fix (3f14771): compute the NLL term over prompt+response for the chosen rows (nll_mask = steps <
seq_end, pooled token-mean CE). The odds-ratio term is left exactly as-is — it was already
parity-correct (response-only, length-normalized logps; the log_odds algebra matches TRL's
odds_ratio_loss). The final loss = sft + beta * or_loss combination is unchanged.
Verified on unsloth/Qwen2.5-0.5B (4-bit) over 8 steps on a toy arithmetic preference set:

  • before: [1.20, 1.16, 0.19, 0.002, 0.002, 0.0, 0.0, 0.004] — saturates to ~0
  • after: [3.06, 3.04, 1.75, 0.66, 0.29, 0.55, 0.43, 0.35] — CUDA-like start, no collapse

DPO (step-1 = 0.6931) and SFT still train; the MLX baseline test suite is green (90 passed / 5 skipped,
3 known CCE deselects).

@Lyxot

Lyxot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Re-tested current HEAD (3f147718) against CUDA/Unsloth TRL on Qwen3-0.6B LoRA, 8 steps, same preference rows / seed / LR.

Summary RMSE vs CUDA:

loss data loss RMSE grad RMSE
ORPO arithmetic 1.170 3.808
ORPO QA 0.890 3.136
DPO arithmetic 0.048 1.364
DPO QA 0.103 2.111

ORPO arithmetic loss curve:

xychart-beta
  title "ORPO arithmetic loss"
  x-axis [1, 2, 3, 4, 5, 6, 7, 8]
  y-axis "loss" 0 --> 4.5
  line "CUDA" [4.289, 3.310, 3.050, 2.641, 2.223, 1.999, 1.670, 1.703]
  line "MLX HEAD" [2.301, 1.966, 2.207, 1.406, 1.013, 1.134, 0.866, 1.388]
Loading

DPO loss is much closer, but grad still differs. Example DPO arithmetic grad:

CUDA: [2.37, 2.70, 4.32, 4.47, 4.09, 3.55, 3.59, 1.73]
MLX: [1.94, 2.82, 2.36, 2.74, 2.38, 2.97, 1.47, 1.51]

Main remaining gaps I see:

  • preference batching does not append EOS to chosen/rejected like TRL;
  • preference batching length-sorts and does not honor dataset_order / preserve_dataset_order, so seeded CUDA/MLX runs can see different row order;
  • conversational preference rows are not passed through chat templates (prompt/chosen/rejected as messages);
  • DPO silently becomes reference-free when no LoRA modules are found, even if reference_free=False.

Tiny example of the EOS/order wiring I’d expect:

create_preference_batches(
    ...,
    append_eos=args.append_eos,
    dataset_order="sequential" if args.preserve_dataset_order else args.dataset_order,
    seed=args.seed,
)

The ORPO NLL fix definitely helped, but I think the preference data path still needs these parity fixes before the curves are reliably CUDA-comparable.

create_preference_batches unconditionally length-sorted every pair by
max(len(chosen), len(rejected)), which minimizes padding but diverges
from CUDA/TRL — HF Trainer feeds preference data via SequentialSampler
(dataset order) or a seeded RandomSampler (torch.randperm), never length
sorted. Identical-seed MLX and CUDA runs therefore saw examples in
different order from step 1, producing different batches and curves.

Add dataset_order ("default" | "sequential" | "torch_randperm") plus a
preserve_dataset_order shortcut and seed, mirroring the existing SFT/VLM
builders, and wire them at the trainer call site. "default" keeps the
historical length-sort so existing runs are byte-identical; "sequential"
(or preserve_dataset_order=True) reproduces CUDA SequentialSampler order
and "torch_randperm" reproduces RandomSampler order for parity testing.
create_preference_batches encoded prompt+chosen / prompt+rejected with no
EOS, while TRL appends the tokenizer's EOS to every completion (DPO
tokenize_row unconditionally; ORPO via add_eos_token_if_needed). Missing
EOS changes the trained token sequence, logprobs, and loss: the model
never has to predict the completion's end token, so the ORPO loss sits
systematically below the CUDA/TRL reference.

Add an append_eos flag (default True, matching TRL) that appends the EOS
id to each chosen/rejected completion, guarded against a double EOS
(mirrors add_eos_token_if_needed) and applied before max_seq_length
truncation (same append-then-truncate contract as the SFT path). The EOS
lands after the prompt boundary, inside the [response_start, seq_end)
loss span, so it is trained on as in TRL. Verified: the appended EOS has
mask==1.0 at its target position, and the untrained-model ORPO loss over
a fixed set rises (+0.256) once completions carry the end token.
…rtion)

DPO's reference is computed by disabling LoRA adapters. When a model has no
LoRA modules (full fine-tuning), the disable-adapter path has nothing to
toggle, so DPO silently fell back to reference-free — dropping the reference
term and training a different objective than requested, with no warning.

Raise a clear ValueError at loss-fn construction when there are no LoRA mods
and a reference is actually requested (not reference_free). The message points
users to a LoRA/PEFT model or to reference_free=True if they genuinely want
reference-free DPO. Legitimate reference-free configs pass through unchanged.

This is the DPO portion of the full-FT fail-loud guard; the matching GRPO KL
guard lands with the GRPO branch (make_grpo_loss_fn does not exist here). A
real full-FT reference (a frozen second model) remains a separate maintainer
design decision.
@BardiaKoopah

Copy link
Copy Markdown
Contributor Author

@Lyxot

  1. Row order is now configurable (parity opt-in). You were right that create_preference_batches unconditionally length-sorted while CUDA/TRL never does. HF Trainer._get_train_sampler feeds preference data via SequentialSampler (dataset order) or a seeded RandomSampler (torch.randperm), so identical-seed runs diverged from step 1. Added dataset_order ("default" | "sequential" | "torch_randperm") plus a preserve_dataset_order shortcut and seed, wired as you suggested (dataset_order="sequential" if args.preserve_dataset_order else args.dataset_order, seed=args.seed), mirroring the existing SFT/VLM builders.
    Behavior-change note: the default stays "default" = length-sort, so existing runs are byte-identical and only opt-in users get parity ordering. I kept parity opt-in rather than default because flipping it changes everyone's batch composition/throughput. Flagging it as arguably a maintainer call if you'd prefer parity-by-default.
    One honest finding: in an apples-to-apples 8-step harness, switching to sequential order changed the curve but did not by itself move it toward the CUDA reference, so order alone doesn't appear to be the dominant RMSE driver. Which leads to:
  2. EOS now appended to completions (the bigger lever). Confirmed the raw tokenizer emits no trailing EOS, matching your point. Added append_eos (default True, matching TRL), which appends the EOS id to each chosen/rejected completion, guarded against double-EOS (mirrors TRL's add_eos_token_if_needed; DPO's tokenize_row does it unconditionally, so the guard is the safe superset). Verified the appended EOS lands inside the [response_start, seq_end) loss span (mask==1.0 at its target position), so it's actually trained. Convergence-free check: appending EOS raises the untrained-model ORPO loss (+0.256 over a fixed set), i.e. it lifts the systematically-too-low loss level, consistent with the model now having to predict a hard end-token. DPO step-1 still 0.6931.
  3. Fail-loud on full-FT DPO reference (your pt 4), DPO portion. Full-FT DPO with a reference now raises a clear ValueError instead of silently collapsing to reference-free; reference_free=True passes through unchanged. (The matching GRPO KL guard lives on the GRPO branch, since that code doesn't exist here.)
    Baseline: canonical 5-file MLX suite gives 90 passed, 5 skipped, 3 deselected.
    Since I can't run the CUDA side here, I've verified the mechanisms (order configurable, EOS in the loss span and lifting the level, guard fires) but not the final MLX-vs-CUDA curve convergence. If you get a chance to re-run your parity harness against these commits, that'll confirm whether the RMSE actually closes. My expectation is EOS does most of the work and order mainly matters for exact seeded reproduction.

@Lyxot

Lyxot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The core ORPO/DPO loss path is now in good shape for short plain-string preference rows.

The remaining concern is mostly the preference data pipeline, not the loss math:

  • conversational/message-list rows still are not handled via chat templates;
  • token-boundary handling is simplified: prompt + chosen/rejected is encoded as one raw string and response_start = len(encode(prompt)), which can differ from TRL on tokenizers with boundary merges;
  • long prompts can consume the whole context and leave zero response tokens instead of using max_prompt_length / max_completion_length style truncation;
  • implicit-prompt datasets with only chosen / rejected are unsupported.

My recommendation: either scope this PR clearly as basic plain-string ORPO/DPO support, or add one more pass on create_preference_batches to align the common TRL preference collation cases before calling it TRL-equivalent.

danielhanchen and others added 2 commits July 8, 2026 11:48
# Conflicts:
#	unsloth_zoo/mlx/trainer.py
The odds-ratio term used log(1-p)=log(-expm1(logp)) with a 1e-12 floor built
in the input dtype. In float16 that floor underflows to 0.0, so a
perfectly-predicted response row (logp -> 0, including an empty response span
where response_start == seq_end) makes log(0.0)=-inf and produces NaN
gradients. Upcast the odds-ratio math to float32 (matching TRL's
ORPOTrainer.odds_ratio_loss policy_*_logps.float()) and cast the result back
to the SFT dtype so the combined loss dtype is unchanged. Adds a regression
test and a log_sigmoid trampoline to the MLX test shim.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Jul 8, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4035588e8a

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py Outdated
f"Unsloth MLX: unsupported preference dataset_order={order_mode!r}. "
"Expected 'default', 'sequential', or 'torch_randperm'."
)
hf = getattr(tokenizer, "_tokenizer", tokenizer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the public tokenizer API for preference batching

When the caller passes a standard PreTrainedTokenizerFast, tokenizer._tokenizer is the low-level Rust backend rather than the HF tokenizer object; it does not expose eos_token_id and its encode API is not the list-of-ids API used below. The existing _get_processor_tokenizer helper explicitly avoids unwrapping tokenizers that already provide the HF API, but this new path bypasses it, so ORPO/DPO batching raises before training for common fast tokenizers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch, fixed. create_preference_batches did getattr(tokenizer, "_tokenizer", tokenizer), which for a plain PreTrainedTokenizerFast unwraps to the low-level Rust tokenizer (no eos_token_id, encode returns an Encoding), so batching crashed before training. Ported the shared _hf_encoding_tokenizer helper that checks isinstance PreTrainedTokenizerBase first and only unwraps mlx-lm's TokenizerWrapper. Added a regression test.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +2046 to +2048
_lora_mods = [mod for _, mod in tree_flatten(
model, is_leaf=lambda x: isinstance(x, LoRALinear))
if isinstance(mod, LoRALinear)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Traverse modules when collecting LoRA adapters

mlx.utils.tree_flatten only walks Python tree containers such as dict/list/tuple, so a normal nn.Module is treated as a single leaf and this comprehension never reaches nested LoRALinear layers. With loss_type='dpo' and the default reference_free=False, _lora_mods is therefore empty for ordinary LoRA models and make_dpo_loss_fn raises the “model has none” error instead of training. Use model.named_modules() or the existing iter_mlx_lora_modules-style traversal to collect adapter modules.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch, fixed. The DPO reference collected adapters with tree_flatten(model, is_leaf=lambda x: isinstance(x, LoRALinear)), but mlx tree_flatten only recurses dict/list/tuple, so a bare nn.Module is a single leaf and nested adapters were never reached, leaving the reference empty and raising for ordinary LoRA models. Now uses iter_mlx_lora_modules(model) (also covers LoRAEmbedding/LoRASwitchLinear/DoRA); added a regression test.

Comment thread unsloth_zoo/mlx/utils.py Outdated

logits = beta * ((pol_c - ref_c) - (pol_r - ref_r))
loss = -mx.mean(nn.log_sigmoid(logits))
return loss, ntoks

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use pair counts for preference-loss accumulation

When gradient_accumulation_steps > 1, the trainer multiplies each microbatch gradient by the returned token count and later divides by the accumulated token total; however this DPO loss is already a mean over preference pairs, not a token-normalized CE. Returning response-token counts makes long-completion microbatches contribute more to the preference objective than short-completion microbatches, so accumulated training is not equivalent to using the same pairs in one batch. Return the pair count for preference losses instead of ntoks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed. ORPO and DPO returned the response-token count as the accumulation weight, but both losses reduce as a mean over pairs (the odds-ratio and log-sigmoid terms are mx.mean over B), so the trainer's accumulate-then-normalize path let long-completion microbatches dominate under gradient_accumulation_steps > 1. They now return the pair count B, with a regression test checking equivalence to a single batch of the same pairs.

Comment on lines +3320 to +3323
batches = create_preference_batches(
dataset=self.train_dataset,
tokenizer=self.tokenizer,
batch_size=args.per_device_train_batch_size,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Shard preference batches across DDP ranks

In distributed runs this ORPO/DPO path materializes self.train_dataset directly without passing the distributed group or otherwise slicing by rank, unlike the SFT/VLM batch builders below that use comm_group and _rank_slice_distributed_batch. As a result every rank trains on the same preference pairs each step, silently reducing the effective global batch diversity and repeating examples world_size times instead of data-parallel sharding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Under DDP the trainer sums gradients and sums the token/pair counts across ranks before dividing, so every rank building the same preference batches still yields exactly the single-rank gradient, and the batch counts are symmetric so there is no collective hang. This is a data-parallel throughput/scaling improvement rather than a correctness bug, so it is below the bar here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_prepare_data already fails fast with a NotImplementedError for distributed_world_size>1 on orpo/dpo, because create_preference_batches takes no comm_group/rank slice and every rank would otherwise build identical batches and all-reduce duplicate gradients (test_preference_distributed_ddp_fails_fast_instead_of_duplicating). This is the minimal correct guard, matching how other unsupported combinations (streaming, VLM, full-finetune referenced DPO) are handled, so no change.

Comment thread unsloth_zoo/mlx/utils.py Outdated
p_ids = hf.encode(prompt)
c_ids = _with_eos(hf.encode(prompt + ex[chosen_key]))
r_ids = _with_eos(hf.encode(prompt + ex[rejected_key]))
pe = min(len(p_ids), len(c_ids), len(r_ids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the prompt boundary when tokenizing pairs

For BPE tokenizers whose merges can change across the prompt/completion boundary, len(encode(prompt)) is not guaranteed to match the number of prompt tokens inside encode(prompt + completion). In those cases this pe offset masks the wrong tokens for both chosen and rejected rows, so the preference loss can include prompt tokens or drop the first completion token; tokenize the prompt/completion boundary consistently rather than deriving the span from standalone prompt tokenization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

pe = min(len(p_ids), len(c_ids), len(r_ids)) assumes the first len(encode(prompt)) tokens of encode(prompt+completion) equal encode(prompt). This holds for realistic preference boundaries (trailing space, punctuation, digits, chat-template special tokens) and only drifts for mid-word glued boundaries like 'answ'+'er', which are malformed for preference data. Switching to TRL-style separate tokenization would change tokenization semantics and risk its own regressions, so this is not a corrupted-boundary bug on legitimate input.

text_assistant_only_loss = _text_assistant_only_loss_arg(args)
comm_group = self.distributed_world

if getattr(args, "loss_type", "sft") in ("orpo", "dpo"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid eager preference batching for streaming datasets

When loss_type is ORPO/DPO this branch always calls create_preference_batches, even if args.streaming=True; that helper iterates the entire dataset into rows before honoring num_batches. For large or infinite streaming preference datasets this hangs or exhausts memory instead of returning an iterator like the SFT streaming path below, so the preference path should either implement streaming batching or reject streaming=True explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The preference path materializes the dataset even under streaming=True, which for a finite streaming dataset is merely eager (a perf cost); only an infinite stream would be a problem. Rejecting streaming=True would regress finite-streaming preference runs that work today, and lazy streaming batching is a feature rather than a correctness fix, so it is below the bar here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Already handled. _prepare_data fails fast with a clear NotImplementedError when args.streaming is set together with loss_type orpo/dpo, before create_preference_batches iterates the dataset, so no eager materialization or hang occurs. Covered by a dedicated regression test.

danielhanchen added a commit to danielhanchen/unsloth-zoo-staging that referenced this pull request Jul 8, 2026
…nd pair-count accumulation

Three correctness fixes on the preference (ORPO/DPO) path:

- create_preference_batches unwrapped the tokenizer with
  getattr(tokenizer, "_tokenizer", tokenizer), which on a plain
  PreTrainedTokenizerFast returns the low-level Rust tokenizers.Tokenizer
  (no eos_token_id; encode returns Encoding), crashing before training.
  Add _hf_encoding_tokenizer, which only unwraps non-HF wrappers such as
  mlx-lm's TokenizerWrapper, matching _get_processor_tokenizer.

- The DPO reference collected adapters with
  tree_flatten(model, is_leaf=lambda x: isinstance(x, LoRALinear)), but
  tree_flatten only recurses dict/list/tuple, so a bare nn.Module is a
  single leaf and nested adapters were never reached, leaving _lora_mods
  empty and raising "model has none" for ordinary LoRA models. Use
  iter_mlx_lora_modules to walk the module tree, which also covers
  LoRAEmbedding, LoRASwitchLinear and DoRA.

- The ORPO/DPO loss functions returned the response-token count as the
  accumulation weight, but these losses are means over preference pairs.
  With gradient_accumulation_steps > 1 (default 4) that let long-completion
  microbatches dominate and broke equivalence with a single batch of the
  same pairs. Return the pair count instead.

Add regression tests covering all three.
danielhanchen added a commit to danielhanchen/unsloth-zoo-staging that referenced this pull request Jul 8, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d20e3ee7bc

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py
c_ids = _with_eos(hf.encode(prompt + ex[chosen_key]))
r_ids = _with_eos(hf.encode(prompt + ex[rejected_key]))
pe = min(len(p_ids), len(c_ids), len(r_ids))
rows.append((pe, c_ids, r_ids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Filter preference rows with empty completions

When max_seq_length cuts off the chosen or rejected completion (for example, the prompt alone fills the sequence), pe can equal len(c_ids) or len(r_ids) and this still appends the pair. The response mask is then empty for that side; in DPO both sides can become zero-logp constants while the trainer still counts the pair as valid, so runs silently spend steps with no preference signal instead of filtering or raising like the labeled SFT path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An empty response span is already handled: ORPO floors -expm1(logp) at 1e-12 in float32 for the response_start == seq_end case, and DPO's empty-mask row gives logp = 0 with no per-row division, so there is no crash, NaN, or corrupted batch, only a weak pair. This is a data-quality nicety rather than a correctness bug.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TRL 0.25.1 DPOTrainer.tokenize_row (and the ORPO path) unconditionally append the EOS to each completion (chosen_input_ids = chosen_input_ids + [tokenizer.eos_token_id]), so a completion is never empty, and the MLX default append_eos=True matches this exactly. The prompt-first keep_end truncation preserves the response span (test_preference_long_prompt_preserves_response_span), so a prompt longer than max_seq_length does not empty the completion; an empty span only arises from a malformed empty-completion row under the non-TRL append_eos=False mode, and even then the float32 1e-12 odds-ratio floor and the max(denominator, 1.0) guard keep ORPO/DPO finite rather than NaN. This is TRL parity on legitimate input, so no change.

raise ValueError(
f"{args.loss_type.upper()} is not yet supported for VLM models on MLX."
)
batches = create_preference_batches(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Shuffle preference data separately each epoch

When ORPO/DPO uses epoch-based training with max_steps <= 0, num_train_epochs > 1, and dataset_order='torch_randperm', this call materializes only one seeded permutation. The training loop then cycles batches[batch_idx % len(batches)], so every epoch repeats the same random order, unlike the SFT/VLM paths that pass num_epochs and use seed + epoch; preference batching needs equivalent per-epoch ordering to match RandomSampler behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reusing a single seeded permutation across epochs is a valid training order; it differs from RandomSampler's per-epoch reshuffle but produces no wrong result. This is a parity nicety rather than a correctness bug.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_prepare_data already threads num_epochs into create_preference_batches for epoch-based torch_randperm runs (max_steps<=0, num_train_epochs>0), which materializes a freshly reseeded (base_seed + epoch) permutation per epoch and sets _prepared_batches_include_epochs so total_steps uses n_batches // grad_accum (test_preference_prepare_data_threads_num_epochs_for_torch_randperm, test_preference_torch_randperm_reshuffles_batches_each_epoch). Default and sequential orders stay single-pass, matching their SFT counterparts. Already threaded, so no change.

Comment thread unsloth_zoo/mlx/utils.py Outdated
)
prompt = ex[prompt_key]
p_ids = hf.encode(prompt)
c_ids = _with_eos(hf.encode(prompt + ex[chosen_key]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render preference rows before tokenizing

For preference datasets whose prompt/chosen/rejected values are chat message lists, dict content, or otherwise meant to be rendered by the trainer's formatting_func/chat_template, this path concatenates and tokenizes the raw objects directly. Those common chat preference rows either raise TypeError here or bypass the model chat template entirely, while the surrounding SFT paths already pass formatting/template state into their batch builders.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

create_preference_batches is string-only by contract; a conversational (message-list) row raises a clear error rather than silently tokenizing unrendered structure, so this is a conversational-format feature gap rather than corruption of legitimate string input. Below the bar here.



@dataclass
class MLXORPOConfig(MLXTrainingConfig):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse the base config initializer for preference configs

Because this subclass is also decorated with @dataclass, Python generates a new __init__ and never runs MLXTrainingConfig.__init__, so _unsloth_mlx_warmup_steps_explicit is missing for MLXORPOConfig (and the same pattern below for MLXDPOConfig). When a user explicitly passes the default warmup step count together with a positive warmup_ratio, _resolve_warmup_steps treats the step count as implicit and uses the ratio instead, changing the schedule for these new trainers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed. MLXORPOConfig and MLXDPOConfig were plain @DataClass, so their generated init bypassed the base init that records the explicit-warmup flag, silently dropping an explicit warmup_steps in favor of warmup_ratio (reproduced warmup_steps=5, warmup_ratio=0.1 resolving to 1). Marked both with @DataClass(init=False) so they inherit the base initializer while keeping their extra fields. Added a regression test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

MLXORPOConfig and MLXDPOConfig are declared @dataclass(init=False), so Python does not synthesize a fresh __init__; they inherit MLXTrainingConfig.__init__, which is where _unsloth_mlx_warmup_steps_explicit is set. Constructing either subclass therefore records explicit vs implicit warmup_steps correctly, and _resolve_warmup_steps reads it, as pinned by test_preference_config_subclasses_record_explicit_warmup_steps. The premise (a generated subclass __init__ bypassing the parent) does not hold, so no change.

Comment thread unsloth_zoo/mlx/utils.py Outdated
)
prompt = ex[prompt_key]
p_ids = hf.encode(prompt)
c_ids = _with_eos(hf.encode(prompt + ex[chosen_key]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the MLX text encoder for preference tokenization

When preference prompts are already rendered with the tokenizer BOS token (common for chat-template output), these direct encode calls use the tokenizer default add_special_tokens=True and can insert a second BOS. The SFT path avoids this via encode_mlx_text, so ORPO/DPO can train on duplicated special tokens and different sequences than the rest of the MLX trainer for the same rendered text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch, fixed. The preference path used raw hf.encode(...), whose default add_special_tokens=True prepends a second BOS when the prompt was already rendered with a chat template that emits BOS, so ORPO/DPO trained on duplicated special tokens. The three prompt/chosen/rejected encodes now go through encode_mlx_text, matching the guard the SFT path already uses. Added a regression test.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
# LoRAEmbedding, LoRASwitchLinear and DoRA adapters are all
# collected for the reference (adapters-off) pass.
_lora_mods = [mod for _, mod in iter_mlx_lora_modules(model)]
loss_fn = make_dpo_loss_fn(beta=_db, lora_mods=_lora_mods, reference_free=_rf)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disable dropout for DPO reference passes

When the model contains active dropout, this DPO branch keeps the trainer in model.train() and computes the policy and adapters-off reference with independent dropout masks. In that scenario the reference term is no longer the deterministic base model, so identical preference pairs can receive random DPO targets; disable dropout for preference training or at least for the reference forward.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The DPO reference pass sets the LoRA/DoRA scale to 0.0, and every mlx-lm adapter module computes y + scale * z with the dropout applied inside z, so the dropout contribution is already nullified for the reference forward. No base mlx-lm architecture applies stochastic dropout in its forward, so the reference logprobs are deterministic. The residual policy-side LoRA dropout is optional regularization (default 0.0), not a wrong-gradient bug.

…d guard double-BOS

MLXORPOConfig and MLXDPOConfig were plain @DataClass subclasses, so Python
generated a fresh __init__ that bypassed MLXTrainingConfig.__init__ and never
recorded _unsloth_mlx_warmup_steps_explicit. An explicit warmup_steps equal to
the default alongside a positive warmup_ratio was then treated as implicit,
silently switching the schedule to the ratio. Decorate both subclasses with
@DataClass(init=False) so they inherit the shared base initializer.

create_preference_batches tokenized prompt/chosen/rejected with raw hf.encode,
whose default add_special_tokens=True prepends a BOS. For a prompt already
rendered with the chat template's leading BOS this produced a second BOS,
diverging from the SFT path which guards against this via encode_mlx_text.
Route the preference encoder through encode_mlx_text for the same behavior.

Adds regression tests for both.
danielhanchen added a commit to danielhanchen/unsloth-zoo-staging that referenced this pull request Jul 8, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab26edda1a

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py Outdated
Comment on lines +1065 to +1067
c_ids = _with_eos(encode_mlx_text(hf, prompt + ex[chosen_key]))
r_ids = _with_eos(encode_mlx_text(hf, prompt + ex[rejected_key]))
pe = min(len(p_ids), len(c_ids), len(r_ids))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve responses when truncating preference rows

When a prompt is near or over max_seq_length, these calls right-truncate the already-concatenated prompt+answer before pe is computed. If len(prompt_ids) >= max_seq_length, both chosen/rejected rows contain only prompt tokens and pe == len(c_ids/r_ids), so the response mask is empty and ORPO/DPO silently get no preference signal for those examples. Truncate the prompt side first or otherwise reserve completion tokens before building the row.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch, fixed. The chosen/rejected rows were right-truncated to max_seq_length, so when the prompt alone reached max_seq_length the response span [response_start, seq_end) was empty and that long-prompt pair gave ORPO/DPO no preference signal. Now the pair is truncated prompt-first, keeping the END of the prompt (TRL's default truncation_mode=keep_end) so the completion is preserved; the untruncated path is byte-identical to before. Added a regression test.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +892 to +893
nll_mask = (steps < lengths[:, 1:]).astype(mx.float32)[:B]
sft = (ce_tok[:B] * nll_mask).sum() / mx.maximum(nll_mask.sum(), mx.array(1.0))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mask prompt tokens out of the ORPO SFT term

For ORPO preference batches, lengths[:, 0] is the response start and the odds-ratio path uses it, but this NLL mask only checks the end position. With multi-token prompts, the chosen SFT component also trains on prompt targets before response_start, so ORPO optimizes reproducing the prompt in addition to the chosen completion; use the response-span mask for the chosen NLL term as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The premise does not match TRL: TRL's ORPO chosen NLL/SFT term is computed over the full concatenated prompt+response with only padding masked (orpo_trainer chosen_nll_loss, whose comment states it is over the full prompt and response); only the odds-ratio term is response-only. The MLX code matches both: nll_mask covers all non-pad tokens for the SFT term, and the odds-ratio term uses response_start. So the prompt is intentionally part of the SFT NLL, matching TRL.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches TRL 0.25.1 by design. TRL's ORPO chosen NLL term is computed over the full prompt+response span (orpo_trainer.py:784-785, loss = policy_nll_loss - losses.mean() at :834); masking it to the response span only would diverge from TRL. Leaving as parity.

Comment on lines +3322 to +3323
batches = create_preference_batches(
dataset=self.train_dataset,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Shard preference batches across DDP ranks

In MLX distributed runs this path builds ORPO/DPO batches from the full dataset on every rank, and create_preference_batches has no comm_group/rank slicing like the SFT/VLM builders use. Each rank therefore consumes identical preference pairs; the all-reduce just averages duplicate gradients, so increasing world size does not increase effective batch coverage and experiments see only one rank's data order. Thread the distributed group into preference batching and shard/pad rows per rank.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Under DDP the trainer averages gradients and all-reduces the loss/token counts across ranks, so every rank processing the identical full batch set is mathematically equal to sharding, with equal per-rank batch counts (no hang). This is a data-parallel throughput improvement rather than a correctness bug, so it is below the bar here.

loss_fn = make_dpo_loss_fn(beta=_db, lora_mods=_lora_mods, reference_free=_rf)
print("Unsloth: Using DPO loss (beta=" + str(_db) +
(", reference_free" if _rf else "") + ").")
elif use_cce:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject unknown preference loss types

If loss_type is misspelled or set to any value other than sft/orpo/dpo, this branch falls through to the normal SFT loss instead of failing. That silently runs the wrong objective for preference experiments, so validate the field before selecting the SFT fallback.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The loss_type dispatch selects the correct objective for orpo/dpo, and the dedicated MLXORPOTrainer/MLXDPOTrainer subclasses force the right value, so ordinary users never reach the SFT fallback. It only triggers on an unsupported/misspelled loss_type (illegitimate input), so this is a validation nicety rather than a wrong-result-on-legitimate-input bug.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Already guarded. _prepare_data validates loss_type against the supported set and raises a clear ValueError for any other value (including case variants and typos) before the SFT fallback, so a misspelled preference loss fails fast rather than silently training cross-entropy. Covered by a regression test.

Comment thread unsloth_zoo/mlx/utils.py
# a second BOS from encode()'s default add_special_tokens=True. Raw
# hf.encode here would train ORPO/DPO on duplicated special tokens and
# diverge from the rest of the MLX trainer for identical rendered text.
p_ids = encode_mlx_text(hf, prompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Derive response starts from combined tokenization

For tokenizers that merge the last prompt token with the first answer token, encode(prompt) is not a prefix of encode(prompt + answer). Using the standalone prompt length as response_start then masks the wrong token in ORPO/DPO rows, corrupting the completion log-probs for those prompt/answer boundaries; compute the boundary from the combined tokenization or adjust when the prefix differs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The min(len(p_ids), len(c_ids), len(r_ids)) boundary holds for realistic preference inputs (trailing space, punctuation, chat-template special tokens) and only drifts for mid-word glued boundaries, which are malformed for preference data. Switching to TRL-style separate tokenization would change tokenization semantics and risk its own regressions, so this is not a corrupted-boundary bug on legitimate input.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Already handled. When the standalone prompt encoding is not a prefix of the combined encoding (merged boundary token or a prompt-only special token), the boundary is recomputed with _common_prefix_len(pids, full), so the response_start reflects the combined tokenization. Verified for the merge-token and prompt-only-EOS cases in the test shim.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1051 to +1052
rows = []
for ex in dataset:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject or stream preference datasets incrementally

num_batches is only applied after this loop has consumed and tokenized the entire dataset, so ORPO/DPO with streaming=True or an infinite iterable never reaches training, and large finite datasets are fully materialized even for small max_steps. Either reject streaming preference datasets up front or honor num_batches while iterating.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

create_preference_batches materializes the dataset under streaming=True, which for a finite streaming dataset is merely eager (a perf cost). Rejecting streaming or implementing lazy batching is a feature rather than a correctness fix, so it is below the bar here.

Comment thread unsloth_zoo/mlx/utils.py Outdated
Comment on lines +1001 to +1005
prompt_key="prompt", chosen_key="chosen",
rejected_key="rejected", pad_to_multiple=32,
num_batches=None, dataset_order="default",
preserve_dataset_order=False, seed=None,
append_eos=True):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render conversational preference rows

The trainer constructors still accept formatting_func/chat-template workflows, but this collator has no way to receive those renderers and later encodes raw prompt/chosen/rejected fields. Preference datasets whose fields are chat message lists, or runs relying on formatting_func, will either fail tokenization or train on unformatted text; thread the same rendering path used by SFT into preference batching.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

create_preference_batches is string-only by contract; a conversational (message-list) row raises a clear error rather than silently tokenizing unrendered structure, so this is a conversational-format feature gap rather than corruption of legitimate string input.

Comment thread unsloth_zoo/mlx/utils.py

def loss_fn(model, batch, lengths, labels=None):
B = batch.shape[0] // 2
pol, _ = _row_logp_and_mask(model, batch, lengths)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disable dropout for DPO comparisons

In DPO runs where the base model or adapters have dropout enabled, this forward executes while the training loop has the model in train mode, and the later reference forward samples a separate dropout mask. The policy-vs-reference delta then includes stochastic dropout noise rather than only adapter changes, corrupting DPO rewards/losses; disable dropout around both DPO forwards or run the comparison deterministically.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

utils.py:951 is the policy forward (the trained model), where dropout is the legitimate trained-model forward. The reference forward is already dropout-free: it sets the LoRA scale to 0.0, which multiplies the entire LoRA branch (including its dropout) by zero and reproduces the base model deterministically, and MLX base models carry no dropout. So the reference comparison is not corrupted by a separate dropout mask.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Already handled. Dropout is disabled by walking the full module tree (not just LoRA-attached dropout), matching TRL's disable_dropout_in_model, and the DPO reference forward runs with dropout inactive so policy and reference do not sample independent masks. The original lora_dropout value is stashed so saved adapters keep their configured probability. Covered by regression tests.

create_preference_batches right-truncated the concatenated prompt+completion
to max_seq_length, so a prompt near or over max_seq_length dropped the response
tail and left the [response_start, seq_end) span empty, giving ORPO/DPO no
preference signal for that pair. Truncate the prompt first (keep the end,
matching TRL's default truncation_mode=keep_end) so the completion survives;
fall back to right-truncating the response only when it alone exceeds the
budget. Both rows in a pair share the truncated prompt prefix, so they keep a
single response_start.
danielhanchen added a commit to danielhanchen/unsloth-zoo-staging that referenced this pull request Jul 8, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa4412d2a1

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py Outdated
Comment on lines +1104 to +1106
p_ids = encode_mlx_text(hf, prompt)
c_full = _with_eos(encode_mlx_text(hf, prompt + ex[chosen_key]))
r_full = _with_eos(encode_mlx_text(hf, prompt + ex[rejected_key]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect tokenizer merges at the prompt boundary

For preference rows where the prompt does not end on a stable tokenizer boundary, the separately encoded prompt length can differ from the prefix inside prompt + chosen / prompt + rejected. Using that length as response_start then marks the merged first completion token as prompt, so ORPO/DPO compute the preference loss on only the tail/EOS or even an empty completion span for those rows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

p_len = min(len(p_ids), len(c_full), len(r_full)) already guards the boundary-merge-shorter case (same contract as the untruncated path), and for legitimate space-separated or chat-templated prompts ending on a stable boundary no cross-boundary merge occurs. Holds for legitimate input.

Comment on lines +3322 to +3327
batches = create_preference_batches(
dataset=self.train_dataset,
tokenizer=self.tokenizer,
batch_size=args.per_device_train_batch_size,
max_seq_length=args.max_seq_length,
num_batches=total_batches_needed,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Shard preference batches across DDP ranks

In MLX DDP runs (distributed_world_size > 1), this ORPO/DPO path never passes the already computed comm_group to batching, unlike the SFT/VLM paths that expand to global batches and slice per rank. Since create_preference_batches has no distributed slicing, every rank materializes the same preference pairs each step while the trainer all-sums gradients and metrics as if they were distinct local batches, so multi-rank preference training wastes workers and uses the wrong global data stream.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The preference path all-reduces gradients and token/pair counts across ranks, so every rank building the identical batch set yields the same gradient with symmetric batch counts (no hang). DDP sharding of preference batches is a throughput feature for the new preference support, not a correctness bug.

Comment thread unsloth_zoo/mlx/utils.py Outdated
Comment on lines +1105 to +1106
c_full = _with_eos(encode_mlx_text(hf, prompt + ex[chosen_key]))
r_full = _with_eos(encode_mlx_text(hf, prompt + ex[rejected_key]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render conversational preference rows before encoding

When ORPO/DPO data uses the common chat-style preference shape where prompt, chosen, or rejected are message lists/dicts, this raw concatenation sends non-string values directly to encode_mlx_text instead of applying the configured chat template. Those rows either raise at batching time or bypass the same rendering path the rest of the MLX text trainer supports, so chat preference datasets cannot train correctly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

create_preference_batches is string-only by contract; conversational (list/dict) rows are out of contract and raise a clear error rather than silently tokenizing unrendered structure. Feature-gap, not corruption of legitimate string input.

Comment on lines +3322 to +3327
batches = create_preference_batches(
dataset=self.train_dataset,
tokenizer=self.tokenizer,
batch_size=args.per_device_train_batch_size,
max_seq_length=args.max_seq_length,
num_batches=total_batches_needed,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor streaming=True for preference datasets

With loss_type set to orpo or dpo, this branch runs before the text streaming path and always calls create_preference_batches, so args.streaming=True is ignored. For a streaming or very large preference dataset this setup step tries to consume/materialize the dataset up front (and can hang on an infinite stream) instead of yielding batches or rejecting the unsupported combination.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The preference branch materializes rather than streams; finite HF datasets materialize fine and will not hang. Streaming preference data is a perf/feature-gap rather than a correctness fix.

loss_fn = make_dpo_loss_fn(beta=_db, lora_mods=_lora_mods, reference_free=_rf)
print("Unsloth: Using DPO loss (beta=" + str(_db) +
(", reference_free" if _rf else "") + ").")
elif use_cce:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject unknown loss_type values

If a caller misspells loss_type (for example "ORPO" or "dppo"), the trainer silently falls through to the SFT loss/data path instead of failing fast. On datasets that also contain text this can train the wrong objective without any preference signal, and on preference-only datasets it fails later with an unrelated batching error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The ORPOConfig/DPOConfig presets set loss_type correctly, so only a hand-typed typo reaches the SFT fallthrough. Fail-fast-on-typo is a validation nicety rather than a wrong-result-on-legitimate-input bug.

Comment thread unsloth_zoo/mlx/utils.py
# microbatch by its pair count. Weighting by tokens would make
# long-completion microbatches dominate and break equivalence with a
# single batch of the same pairs under gradient_accumulation_steps > 1.
return loss, mx.array(B, dtype=mx.int32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve ORPO token weighting across accumulation

With gradient_accumulation_steps > 1 and variable chosen lengths, this return value makes the trainer weight the entire ORPO loss by pair count. The SFT component just above is explicitly a pooled token-mean over chosen tokens, so pair-weighting microbatches is not equivalent to computing the same token-mean on the concatenated batch and changes the SFT pressure based on how examples are grouped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This asks to revert the round-3 pair-count fix to token weighting. Verified against TRL 0.25.1: chosen_nll is a pooled token-mean CE over the chosen span and the loss is policy_nll - odds_ratio.mean() (per-pair). The accumulate-then-normalize path weights each microbatch by the returned count, and a single scalar cannot make both the token-mean SFT term and the per-pair odds term grad-accum-exact under variable lengths. Pair count makes the odds term (the characteristic ORPO/DPO preference signal) and DPO exact; reverting to token count would re-break the odds term that round-3 fixed, and making the SFT term a per-pair mean would diverge from TRL's pooled definition. Keeping pair count.

Comment thread unsloth_zoo/mlx/utils.py
if order_mode == "default":
rows.sort(key=lambda t: max(len(t[1]), len(t[2])))
elif order_mode == "torch_randperm":
order = _torch_randperm_order(len(rows), _normalize_seed(seed))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Reshuffle preference data on later epochs

When dataset_order='torch_randperm' and training runs past one dataset pass (num_train_epochs > 1 or max_steps cycles batches), this shuffles rows only once before materializing preference batches. The ordered text path rebuilds randperm with seed + epoch, so preference runs repeat the identical order every epoch and no longer mirror CUDA RandomSampler/Studio order.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reusing a single seeded permutation across epochs is a valid training order; a per-epoch reshuffle is RandomSampler parity, a nicety rather than a correctness bug.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26820e6dae

ℹ️ 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".

Comment on lines +2125 to +2127
loss_fn = make_dpo_loss_fn(beta=_db, lora_mods=_lora_mods,
reference_free=_rf,
neftune_mods=_neftune_mods)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-LoRA trainables for referenced DPO

When loss_type='dpo' runs with reference_free=False and a LoRA model also has intentionally trainable non-LoRA tensors (for example embeddings/lm_head or other tensors saved by the trainable-adapter path), this setup only hands LoRA modules to the reference pass. Zeroing those adapter scales leaves the extra trainable tensors as the live, updating policy weights, so after the first optimizer step the “reference” is no longer the frozen initial policy and the DPO margin is computed against a drifting model. Add a guard/snapshot/reference model for non-LoRA trainables, similar to the DoRA guard.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not reachable on the supported configs, so no change (same as the prior rounds). get_peft_model freezes everything and unfreezes only the LoRA lora_a/lora_b pair (text and VLM); full fine-tuning returns early and skips LoRA; full-fine-tune DPO with a reference is already rejected (not _mods and not reference_free), and DoRA + reference is rejected. The only way to obtain a non-empty LoRA reference is the get_peft_model path, which freezes all non-LoRA weights, so no supported configuration leaves non-LoRA weights trainable alongside a LoRA reference. Reaching that state needs manual freeze-flag mutation outside the documented API, which is implausible-config hardening rather than a reachable correctness bug.

tokenizer=self.tokenizer,
batch_size=args.per_device_train_batch_size,
max_seq_length=args.max_seq_length,
num_batches=total_batches_needed,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Materialize per-epoch preference permutations

For epoch-based preference runs (max_steps <= 0, num_train_epochs > 1) with dataset_order='torch_randperm', total_batches_needed is None, so this call asks create_preference_batches for only one shuffled pass; the training loop then cycles that same list for every epoch. The SFT ordered path passes num_epochs and reseeds each epoch, so DPO/ORPO experiments silently replay the same random order every epoch. Pass num_epochs or otherwise materialize per-epoch preference batches.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed in 2659c53. Confirmed: epoch-based preference runs (max_steps<=0, num_train_epochs>1) pass num_batches=None, and the earlier per-pass reshuffle only fires when num_batches is not None (step-based), so for dataset_order='torch_randperm' the builder took the single-permutation branch and the trainer's modulo-cycled loop (batches[i % len]) replayed that same order every epoch, diverging from the SFT create_ordered_batches path which reseeds per epoch (base_seed + epoch). create_preference_batches now accepts num_epochs and, for torch_randperm epoch-based runs, materializes one reseeded (base_seed + epoch) permutation per epoch; the trainer threads num_epochs for that case. Epoch 0 is byte-identical to the single pass; default (length-sort) and sequential are unchanged (they modulo-cycle one pass exactly like their SFT counterparts). Regression tests: test_preference_torch_randperm_reshuffles_batches_each_epoch, test_preference_prepare_data_threads_num_epochs_for_torch_randperm.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1241 to +1244
if prompt_key not in ex or chosen_key not in ex or rejected_key not in ex:
raise ValueError(
f"ORPO requires '{prompt_key}', '{chosen_key}', '{rejected_key}' "
f"columns; got {sorted(ex.keys())}."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Extract prompts from chosen/rejected-only datasets

For preference datasets that contain only chosen and rejected rows with the prompt as their shared prefix (a format accepted by the CUDA/TRL DPO/ORPO pipeline), this unconditional prompt requirement fails before training because the MLX path never extracts the common prompt. That makes valid existing preference datasets error out on MLX even though the new batcher otherwise tries to mirror TRL chat-template handling. Extract the prompt before this validation or accept the two-column format.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving as a documented contract, same as the prior rounds. create_preference_batches raises a clear column-presence error requiring prompt/chosen/rejected. TRL supports implicit prompts via maybe_extract_prompt / extract_prompt (data_utils.py), a separate dataset .map() preprocessing step (not part of apply_chat_template) that splits the shared prefix of chosen/rejected; the MLX path has no equivalent preprocessing stage. Adding it is a new extraction feature on top of the current explicit-column contract, and the code errors clearly rather than silently mistraining, so it is out of scope for this correctness pass.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1105 to +1109
if last_role == "user":
add_gen, cont = True, False
elif last_role == "assistant":
add_gen, cont = False, True
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat tool-ending prompts as generation prompts

Conversational preference prompts can end with a tool message after a tool result, and TRL renders that case with add_generation_prompt=True just like a user turn. This branch rejects any last role other than user or assistant, so valid tool-calling DPO/ORPO datasets fail during batching instead of training. Include tool in the generation-prompt case.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving as-is: this matches TRL 0.25.1. TRL's apply_chat_template does not special-case a trailing 'tool' role, it handles only 'user'/'assistant' for the last message and raises ValueError(f"Invalid role in the last message: {last_role}") for anything else (data_utils.py:222-230). _render_preference_example here likewise raises for a last role not in {user, assistant}, so both behave identically and a trailing-'tool' prompt is not a valid TRL preference shape (TRL crashes on it too). The premise that TRL renders it with add_generation_prompt=True does not hold for 0.25.1, so matching TRL's raise is correct.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches TRL 0.25.1. TRL's apply_chat_template/maybe_apply_chat_template also raises when the last message role is tool (data_utils.py), so rejecting a trailing tool role during preference batching is parity, not a regression. Leaving as-is.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1120 to +1121
if tools is not None:
extra_kwargs["tools"] = tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Normalize empty tools lists before rendering

When a dataset row carries tools=[], this passes the empty list through to apply_chat_template; TRL normalizes empty tools to None because some templates treat an empty list as “tool mode” and render extra tool boilerplate. This can make ORPO/DPO train on different text for rows that have no tools but an empty tools column. Convert empty lists to None before rendering.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving as-is: this matches TRL 0.25.1. TRL does not normalize empty tools to None: apply_chat_template passes tools straight through on every render (data_utils.py) with no normalization anywhere in the trl package, and the DPO trainer uses a single global args.tools, never a per-row []. Transformers also passes tools through unchanged (chat_template_utils.py: if tools is not None, so [] yields tool_schemas=[]). The per-row threading here passes ex.get('tools') straight through, matching TRL and the SFT reference path (which also passes item.get('tools') unchanged). So there is no divergence to fix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches TRL 0.25.1, which does not normalize an empty tools=[] list to None before apply_chat_template. Passing the empty list through is parity with TRL, so no normalization is added. Leaving as-is.

Comment on lines +530 to +531
if getattr(mod, "dropout", None) is not None:
mod.dropout = _mlx_identity

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Preserve LoRA dropout metadata while disabling masks

For ORPO/DPO with a nonzero lora_dropout, replacing the adapter dropout object with _mlx_identity permanently destroys the probability that save_lora_adapters later extracts from the live module. Checkpoints saved after preference training will record dropout as 0.0, so reloading the adapter for further training no longer matches the original LoRA configuration. Disable the forward mask without losing the original dropout metadata, or stash it for saving.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good catch, fixed in 2659c53. This was a regression from the dropout-disable change: _mlx_disable_lora_dropout replaces mod.dropout with an identity at setup (before any save), and the adapter-save path reads lora_dropout back off the live module (_extract_mlx_lora_parameters / _enrich_mlx_adapter_config via _get_mlx_dropout_probability), so an adapter trained with nonzero lora_dropout persisted dropout=0.0 in adapter_config.json and no longer matched the original LoRA config on reload. Fix: _mlx_disable_lora_dropout now stashes the configured probability on mod._unsloth_orig_lora_dropout before the identity swap (guarded so a repeated disable keeps the original), and a new _read_mlx_lora_dropout helper (routed into all three save read-sites) prefers the stash, falling back to the live module for SFT / never-disabled adapters. The forward stays a deterministic identity and the saved metadata stays correct. Regression test: test_preference_disable_dropout_preserves_saved_lora_dropout_metadata.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Already fixed. The original lora_dropout probability is stashed on the module before the forward mask is replaced with identity, and save_lora_adapters reads it back through _read_mlx_lora_dropout, so checkpoints saved after preference training keep the configured dropout rather than recording 0.0. Covered by a regression test.

…h_randperm

_mlx_disable_lora_dropout now stashes each adapter's configured lora_dropout on
_unsloth_orig_lora_dropout before replacing the dropout module with an identity,
and the adapter-save path reads it via a new _read_mlx_lora_dropout helper.
Previously, disabling adapter dropout for the deterministic ORPO/DPO forward
permanently erased the probability the save path extracts from the live module,
so an adapter trained with nonzero lora_dropout saved dropout=0.0 in
adapter_config.json and no longer matched the original LoRA config on reload.
The forward stays a deterministic identity; the saved metadata stays correct.

create_preference_batches gains a num_epochs param so epoch-based runs
(max_steps<=0, num_train_epochs>1, which leave num_batches None) with
dataset_order='torch_randperm' materialize one reseeded (base_seed+epoch)
permutation per epoch, matching the SFT create_ordered_batches per-epoch reseed
and CUDA RandomSampler. Previously they built a single permutation that the
trainer's modulo-cycled loop replayed every epoch. Epoch 0 is byte-identical to
the single pass; default (length-sort) and sequential are unchanged.
danielhanchen added a commit to danielhanchen/unsloth-zoo-staging that referenced this pull request Jul 9, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2659c53114

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py
# microbatch by its pair count. Weighting by tokens would make
# long-completion microbatches dominate and break equivalence with a
# single batch of the same pairs under gradient_accumulation_steps > 1.
return loss, mx.array(B, dtype=mx.int32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Weight ORPO SFT accumulation by tokens

When gradient_accumulation_steps > 1 and ORPO microbatches have different chosen lengths, returning the pair count makes the trainer scale the entire loss by pairs before averaging accumulated gradients. The odds-ratio term is pair-mean, but sft above is explicitly a token-mean over nll_mask; a single combined batch would weight that SFT gradient by chosen-token count, while this path weights each microbatch by pair count, so short-response microbatches overcontribute to the SFT component. Either the SFT component needs separate token weighting, or it must be reformulated as a per-pair mean before using B as the accumulator count.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TRL 0.25.1 forms the ORPO loss as policy_nll_loss - losses.mean() (orpo_trainer.get_batch_loss_metrics), where policy_nll_loss is a per-microbatch CrossEntropyLoss token-mean and the odds-ratio term is a per-pair mean, and HF Trainer then weights each microbatch equally (loss / gradient_accumulation_steps). The MLX loss returns the pair count B as the accumulation weight, so for equal-size microbatches it reproduces TRL's per-pair batch-mean of (nll_token_mean + beta * ratio_pair_mean) exactly (test_orpo_loss_returns_pair_count_not_token_count). Token-weighting the SFT term across accumulation is not what TRL does and would diverge from it, so this stays as parity with no change.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1127 to +1129
prompt_chosen_str = tokenizer.apply_chat_template(
list(prompt) + list(chosen), tokenize=False,
**extra_kwargs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Merge assistant-prefill completions before rendering

For conversational preference rows where prompt already ends with an assistant message, the branch above explicitly sets continue_final_message=True for the standalone prompt, but the chosen/rejected full strings are rendered as list(prompt) + list(chosen). When chosen/rejected are assistant continuations, this creates two consecutive assistant turns instead of continuing the final assistant message; chat templates that enforce role alternation can raise, and permissive templates insert an EOS/new assistant marker that shifts the response span. This only affects assistant-prefill preference examples, but those are accepted by the last_role == "assistant" branch and should be rendered by merging the continuation into the final prompt message.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TRL 0.25.1 apply_chat_template (data_utils.py) renders a preference completion as a plain message-list concat, apply_chat_template(example["prompt"] + example["chosen"]), with no continue_final_message and no merge, then derives the completion by string prefix against the continue_final_message-rendered prompt. The MLX path does the identical list(prompt) + list(chosen) render and derives the boundary via token common-prefix, so an assistant-ending prompt produces the same two-turn structure and the same response span as TRL. This is parity, not a merge divergence, so no change.

Comment on lines +3525 to +3527
batches = create_preference_batches(
dataset=self.train_dataset,
tokenizer=self.tokenizer,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply formatting_func before preference batching

When callers use the new ORPO/DPO trainers with formatting_func to convert raw rows into prompt/chosen/rejected, this branch still passes the unformatted dataset directly to create_preference_batches. Those runs either fail the column check in the preference collator or train on stale raw fields, whereas the SFT path applies self.formatting_func during batch construction. Thread the formatter through the preference path before validating/building batches so formatted preference datasets work consistently with the exposed constructor argument.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TRL 0.25.1 DPOTrainer/ORPOTrainer and DPOConfig/ORPOConfig expose no formatting_func argument (it is an SFTTrainer-only concept); the MLX preference path correctly consumes the prompt/chosen/rejected column contract that TRL's DPO/ORPO require. Applying an SFT formatting_func to preference rows is not TRL behavior, so threading it through would add a non-TRL feature rather than fix a correctness bug. Out of scope, so no change.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2659c53114

ℹ️ 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".

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1122 to +1127
prompt_str = tokenizer.apply_chat_template(
prompt, tokenize=False,
add_generation_prompt=add_gen, continue_final_message=cont,
**extra_kwargs,
)
prompt_chosen_str = tokenizer.apply_chat_template(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render preference chats with the unwrapped tokenizer

For conversational ORPO/DPO rows, this calls apply_chat_template on the original tokenizer object, while encoding later unwraps wrappers via _hf_encoding_tokenizer. In the MLX wrapper case that only exposes the Hugging Face tokenizer under _tokenizer (the same case _get_processor_tokenizer handles elsewhere before rendering chat messages), message-list preference datasets fail during batching with AttributeError even though the inner tokenizer can render them. Use the unwrapped tokenizer for these chat-template calls as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified against mlx-lm source and leaving as-is. mlx-lm's TokenizerWrapper defines its own apply_chat_template (tokenizer_utils.py in 0.30.x/0.31.x), so calling it on the wrapper does not raise AttributeError; the wrapper injects enable_thinking=self.has_thinking and then delegates to self._tokenizer.apply_chat_template. Rendering through the wrapper is therefore the intended path: switching to the unwrapped _tokenizer would drop that enable_thinking default and diverge from how mlx-lm itself renders chat prompts. The encode calls below already unwrap via _hf_encoding_tokenizer because they need the list-returning encode and eos_token_id, which is a separate concern from chat rendering.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1251 to +1254
if prompt_key not in ex or chosen_key not in ex or rejected_key not in ex:
raise ValueError(
f"ORPO requires '{prompt_key}', '{chosen_key}', '{rejected_key}' "
f"columns; got {sorted(ex.keys())}."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept implicit-prompt preference rows

This rejects preference datasets that contain only chosen and rejected with the shared prompt embedded in both responses, which is a supported/common DPO/ORPO input format and is handled by extracting the common prefix before tokenization. In that scenario MLX DPO/ORPO now fails before training unless users manually pre-split a prompt column, so the new trainer is incompatible with those datasets instead of deriving prompt, chosen, and rejected during batching.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Leaving as a documented explicit-column contract. TRL supports implicit prompts through a separate maybe_extract_prompt dataset .map() preprocessing step, not inside apply_chat_template; the MLX path has no equivalent preprocessing stage and errors clearly requiring prompt/chosen/rejected rather than silently mistraining. Out of scope for this correctness pass.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1105 to +1109
if last_role == "user":
add_gen, cont = True, False
elif last_role == "assistant":
add_gen, cont = False, True
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat tool-result prompts as generation prompts

When a conversational preference prompt ends with a tool message after a tool/function call result, this falls into the ValueError path instead of rendering the next assistant turn. Tool-use preference datasets with prompt=[..., {"role": "tool", ...}] therefore fail during ORPO/DPO batching even though the chat template can render them; handle tool like the generation-prompt case rather than rejecting it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches TRL 0.25.1. TRL's apply_chat_template/maybe_apply_chat_template also raises when the last message role is tool (data_utils.py), so rejecting a trailing tool role during preference batching is parity, not a regression. Leaving as-is.

Comment thread unsloth_zoo/mlx/utils.py
# microbatch by its pair count. Weighting by tokens would make
# long-completion microbatches dominate and break equivalence with a
# single batch of the same pairs under gradient_accumulation_steps > 1.
return loss, mx.array(B, dtype=mx.int32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Weight ORPO's NLL term by tokens during accumulation

With gradient_accumulation_steps > 1 and chosen rows of different lengths, returning the pair count for the combined ORPO loss makes the trainer scale the SFT/NLL component by pairs. The odds-ratio term is pair-mean, but the NLL component above is a token-mean over all chosen tokens, so accumulated gradients no longer match a single batch of the same examples and long completions are misweighted; split the denominators or rescale the NLL term separately before returning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TRL 0.25.1 forms the ORPO loss as policy_nll_loss - losses.mean() (orpo_trainer.get_batch_loss_metrics), where policy_nll_loss is a per-microbatch CrossEntropyLoss token-mean and the odds-ratio term is a per-pair mean, and HF Trainer then weights each microbatch equally (loss / gradient_accumulation_steps). The MLX loss returns the pair count B as the accumulation weight, so for equal-size microbatches it reproduces TRL's per-pair batch-mean of (nll_token_mean + beta * ratio_pair_mean) exactly (test_orpo_loss_returns_pair_count_not_token_count). Token-weighting the SFT term across accumulation is not what TRL does and would diverge from it, so this stays as parity with no change.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2659c53114

ℹ️ 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".

Comment on lines +635 to +638
loss_type: str = "sft" # "sft" or "orpo"
orpo_beta: float = 0.1 # ORPO odds-ratio weight (TRL default)
dpo_beta: float = 0.1 # DPO beta (TRL default)
reference_free: bool = False # DPO: drop the reference term if True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Append new config fields to preserve positional arguments

Because MLXTrainingConfig.__init__ accepts positional arguments in dataclass field order, inserting these preference fields before the existing load_best_model_at_end/metric fields shifts the meaning of every positional config argument after eval_steps. Existing calls that set eval options positionally now populate loss_type/beta fields instead, so they can fail later as an unsupported loss type or run with corrupted settings; add new fields after the existing fields (or make the constructor keyword-only) to keep old positional configs compatible.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not reachable. MLXTrainingConfig and its subclasses are constructed only by keyword or with no arguments everywhere in this codebase (grep shows MLXTrainingConfig(), MLXORPOConfig(), MLXDPOConfig() and user-facing MLXTrainingConfig(field=...) calls, never positional), mirroring HF TrainingArguments which is likewise always keyword-constructed. A 20+ field config passed by positional order is not a legitimate reachable call, so field ordering does not shift any real caller's arguments; leaving the fields where they are.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1269 to +1271
prompt, chosen_full_text, rejected_full_text = _render_preference_example(
tokenizer, prompt, ex[chosen_key], ex[rejected_key],
tools=_ex_tools, chat_template_kwargs=_ex_template_kwargs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use resolved HF tokenizer for chat rendering

When ORPO/DPO is given conversational prompt/chosen/rejected rows and the tokenizer is an mlx-lm-style wrapper, this passes the outer wrapper into _render_preference_example; unlike the encode calls below that use the unwrapped hf, that wrapper may not expose apply_chat_template even though its HF tokenizer under _tokenizer does. The SFT rendering path unwraps before applying templates, so this path can crash before tokenization for wrapped tokenizers; render with hf or unwrap inside the helper.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified against mlx-lm source and leaving as-is. mlx-lm's TokenizerWrapper defines its own apply_chat_template (tokenizer_utils.py in 0.30.x/0.31.x), so calling it on the wrapper does not raise AttributeError; the wrapper injects enable_thinking=self.has_thinking and then delegates to self._tokenizer.apply_chat_template. Rendering through the wrapper is therefore the intended path: switching to the unwrapped _tokenizer would drop that enable_thinking default and diverge from how mlx-lm itself renders chat prompts. The encode calls below already unwrap via _hf_encoding_tokenizer because they need the list-returning encode and eos_token_id, which is a separate concern from chat rendering.

Comment thread unsloth_zoo/mlx/utils.py
"Expected 'default', 'sequential', or 'torch_randperm'."
)
hf = _hf_encoding_tokenizer(tokenizer)
eos_id = hf.eos_token_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Tolerate tokenizers without an EOS attribute

For a simple tokenizer that supports encode but has no eos_token_id (or when callers set append_eos=False specifically because there is no EOS), this direct attribute access raises before any preference batches are built. The existing SFT batching paths use getattr(tokenizer, "eos_token_id", None) and can pad with 0 in that case, so the new ORPO/DPO path should do the same instead of rejecting otherwise usable no-EOS tokenizers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not reachable here. hf is the resolved Hugging Face tokenizer (_hf_encoding_tokenizer returns either a PreTrainedTokenizerBase or mlx-lm's unwrapped _tokenizer), and eos_token_id on a PreTrainedTokenizerBase is a property that returns None when no EOS is set rather than raising AttributeError, so a no-EOS HF tokenizer already yields eos_id=None and pads with 0 exactly like the SFT path. The trainer only ever passes an HF/mlx-lm tokenizer into preference batching, so a bare object with encode() but no eos_token_id attribute is not a reachable input; leaving the direct access.

@BardiaKoopah

Copy link
Copy Markdown
Contributor Author

@danielhanchen this one now conflicts with main in trainer.py, utils.py, and tests/test_mlx_trainer_internals.py. They're just against your reworks that landed after this branch forked (#922, #930, and #918) For resolving these it'd basically be deciding what ORPO code your reworks adds on vs what actually gets carried forward.The conflicts are the only thing blocking this now

@danielhanchen

Copy link
Copy Markdown
Member

@BardiaKoopah Fix merge conflicts thanks - you decide what is best

Reconcile the ORPO branch with main's post-fork reworks (unslothai#922 true grad-norm
reporting, unslothai#930 fused CCE + label smoothing, unslothai#918 Metal resource-limit CPU
batch plans). ORPO was built alongside those (new loss/batch functions and
tests), so the conflicts were keep-both rather than supersede, with two
deliberate exceptions where main's unslothai#930 supersedes older baseline code:

- trainer.py: keep the ORPO/DPO imports, the dropout-neutralization helpers,
  the loss_type guard, and the ORPO/DPO loss dispatch; adopt main's
  _validate_label_smoothing / resume-checkpoint helpers and streaming init
  alongside them. In the loss dispatch, keep the ORPO/DPO branches and adopt
  main's label_smoothing= on the CCE fallback (an elif that ORPO/DPO never
  reach, so it cannot affect the preference paths).
- utils.py: keep the ORPO/DPO block (odds-ratio loss, make_orpo/dpo_loss_fn,
  preference batching, render helpers -- all branch-only additions) and take
  main's make_baseline_loss_fn(label_smoothing=0.0). Verified every one of
  main's utils functions is preserved: make_cce_loss_fn and the batch-plan
  functions are byte-identical to main; _enrich_mlx_adapter_config was changed
  by both sides and git auto-merged it -- confirmed line-by-line that main's
  quantization-map flag and the branch's lora_dropout stash both survive.
- tests: keep both the ORPO/DPO regression tests and main's streaming/prefetch
  tests (disjoint, no name collisions).

Verified: a real ORPO LoRA run trains with finite, decreasing loss; unslothai#922/unslothai#930/
unslothai#918 test groups pass; canonical baseline 235 passed, 0 failures.
@BardiaKoopah

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b49b054da

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +882 to +885
loss_type: str = "sft" # "sft" or "orpo"
orpo_beta: float = 0.1 # ORPO odds-ratio weight (TRL default)
dpo_beta: float = 0.1 # DPO beta (TRL default)
reference_free: bool = False # DPO: drop the reference term if True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve positional config compatibility

These new preference fields are inserted before pre-existing config fields, but MLXTrainingConfig.__init__ maps positional arguments using dataclass declaration order. Any saved/forwarded positional config built against the previous order will now put the old load_best_model_at_end value into loss_type (often False), so ordinary training later fails the new loss_type validation or shifts all following settings. Append these fields to the end (and treat them as optional-copy fields if needed) to keep legacy positional copies working.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1775 to +1779
if prompt_key not in ex or chosen_key not in ex or rejected_key not in ex:
raise ValueError(
f"ORPO requires '{prompt_key}', '{chosen_key}', '{rejected_key}' "
f"columns; got {sorted(ex.keys())}."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Support implicit preference prompts

When an ORPO/DPO dataset uses the common implicit-prompt preference format (chosen and rejected only, with the shared prefix serving as the prompt), this guard raises before batching. The new trainers otherwise aim for TRL preference-format parity, and TRL extracts that shared prompt automatically; rejecting these rows means users must manually preprocess otherwise valid preference datasets before MLX training can start.

Useful? React with 👍 / 👎.

Comment on lines +5031 to +5035
if (
args.max_steps <= 0
and getattr(args, "num_train_epochs", -1) > 0
and pref_dataset_order == "torch_randperm"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reshuffle default preference epochs

For epoch-based ORPO/DPO runs with the default dataset order (max_steps <= 0, num_train_epochs > 1), this condition leaves pref_num_epochs unset unless the user opted into torch_randperm. create_preference_batches then returns a single length-sorted pass and the training loop cycles that identical order every epoch, unlike the SFT default plan which reshuffles batch visits across epochs; default preference runs therefore repeatedly train in the same short-to-long curriculum. Thread epoch expansion (or a finite visit plan) for the default order as well.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1505 to +1506
for md in _mods:
md.scale = 0.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve warm-started LoRA as the DPO reference

When DPO is run from a reloaded/pretrained LoRA adapter, zeroing every adapter scale here makes the reference logits come from the bare base model instead of the policy at the start of DPO. That is only correct for freshly initialized zero-delta adapters; the supported warm-start path returns trainable adapter params for continued training, so those DPO runs compare against the wrong reference and optimize corrupted rewards. Keep a frozen copy of the initial adapter or reject nonzero warm-start adapters rather than disabling it.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/utils.py
Comment on lines +1633 to +1636
else:
raise ValueError(
"Unsloth MLX preference: conversational chat prompt has invalid "
f"last-message role {last_role!r}; expected 'user' or 'assistant'."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept tool-ended preference prompts

For conversational preference rows whose prompt ends with a tool result (role == "tool"), this branch raises before rendering. Function-calling preference datasets commonly place the tool response as the final prompt turn before the chosen/rejected assistant answer, and this new renderer already threads per-row tools; rejecting that role means otherwise valid tool-use ORPO/DPO rows cannot be batched or trained. Treat non-assistant prompt endings like generation prompts instead of hard-failing.

Useful? React with 👍 / 👎.

Reconcile ORPO/DPO with main's unslothai#873 HF-style callback support:
- ORPO-A: keep main's deferred _prepare_eval_batches() chokepoint; add the
  preference eval-skip inside it so neither the static cadence nor a callback
  request runs eval on SFT-shaped batches, and keep the setup skip notice.
- ORPO-B: keep both sides' new tests (preference/DPO suite + main's callback
  DDP-sync suite); no name collisions.
- create_preference_batches path and unslothai#918's batch_kwargs["grad_accum"] carried
  in via clean auto-merge.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb2a822d40

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +7795 to +7797
def __init__(self, model, tokenizer, train_dataset, eval_dataset=None,
dataset_text_field=None, max_seq_length=None, packing=None,
data_collator=None, args=None, formatting_func=None, processor=None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept callbacks in preference trainer constructors

When users switch from MLXTrainer to the advertised MLXORPOTrainer or MLXDPOTrainer classes and pass the same callbacks= argument that the base trainer supports, this narrower constructor raises TypeError before training starts; the DPO constructor below has the same omission. This blocks callback-driven logging, early stopping, cancellation, and integrations specifically for the new preference-trainer entry points, so the subclasses should accept and forward callbacks to MLXTrainer.__init__.

Useful? React with 👍 / 👎.

# SequentialSampler), so their per-epoch order already matches SFT.
# Step-based runs (num_batches set) are unchanged.
pref_num_epochs = (
int(args.num_train_epochs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve fractional torch-randperm preference epochs

For epoch-based ORPO/DPO runs with dataset_order="torch_randperm" and a non-integer num_train_epochs such as 0.5 or 1.5, this cast truncates the requested epoch count before materializing preference batches. Because _prepared_batches_include_epochs is then set, the step resolver budgets from that truncated schedule, so a 1.5-epoch run trains only one pass (and a 0.5-epoch run still materializes a full pass) instead of following the same fractional-epoch arithmetic used by the SFT ordered path.

Useful? React with 👍 / 👎.

Comment on lines +1126 to +1129
loss_type: str = "sft" # "sft" or "orpo"
orpo_beta: float = 0.1 # ORPO odds-ratio weight (TRL default)
dpo_beta: float = 0.1 # DPO beta (TRL default)
reference_free: bool = False # DPO: drop the reference term if True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat preference config fields as optional copies

When loading or keyword-copying an MLXTrainingConfig dump from before these preference fields existed, the dump legitimately omits loss_type/beta/reference fields while still carrying the old default warmup_steps=5 and a non-default warmup_ratio; because these new fields are not included in the initializer's appended-field allowlist, copied_all_fields becomes false and _resolve_warmup_steps treats the default 5 steps as explicit, silently replacing the ratio schedule. This is the keyword legacy-dump path, not the earlier positional-argument concern, so these fields need to be tolerated as optional copied fields as well.

Useful? React with 👍 / 👎.

BardiaKoopah added a commit to BardiaKoopah/unsloth-zoo that referenced this pull request Jul 29, 2026
Bare @DataClass regenerated __init__, bypassing MLXTrainingConfig.__init__ and
its setup (e.g. _unsloth_mlx_warmup_steps_explicit). init=False inherits the
parent init while fields() still registers the KTO-specific fields, which the
parent __init__ reads via fields(type(self)). Mirrors unslothai#830's
MLXORPOConfig/MLXDPOConfig. Regression test asserts the inherited init and intact
KTO fields.
@BardiaKoopah

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb2a822d40

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread unsloth_zoo/mlx/utils.py Outdated
if num_batches is not None and len(out) >= num_batches:
break
mx.eval([b for b, _, _ in out] + [l for _, l, _ in out])
return out

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve preference epoch boundaries under max_steps

When max_steps > 0 and a preference dataset pass is ragged relative to gradient_accumulation_steps, returning a plain list here loses the one-pass cycle_length that the trainer uses to force HF-style epoch-final optimizer steps. For example, 3 preference batches with gradient_accumulation_steps=2 should update on batch 2 and then force-update the epoch tail on batch 3; this list path reports no cycle length, so the tail is accumulated with the next epoch’s first batch instead, changing gradients and callback/save/eval epoch boundaries versus the SFT finite-plan path. Return a finite plan or otherwise expose the per-pass batch count for preference batches.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/utils.py
for chunk in chunks:
Lmax = max(max(len(c), len(r)) for _, c, r in chunk)
if pad_to_multiple:
Lmax = ((Lmax + pad_to_multiple - 1) // pad_to_multiple) * pad_to_multiple

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Pad preference inputs to the shifted multiple

For ORPO/DPO batches whose padded row length is not capped by max_seq_length, this rounds the full stored row width to a multiple of 32, but the model actually forwards batch[:, :-1], so the sequence length seen by the model is always one less than that multiple (for example a 32-token row forwards 31 positions). The SFT batcher uses _finite_text_pad_width, which rounds to 1 + multiple specifically so the shifted input length is the Apple-Silicon-friendly multiple; preference runs therefore miss the intended padding/shape parity and can run slower or with different shape buckets. Use the same shifted width policy here.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/utils.py
if tools is not None:
extra_kwargs["tools"] = tools
prompt_str = tokenizer.apply_chat_template(
prompt, tokenize=False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize message content before rendering preferences

When a text conversational preference row uses OpenAI-style content parts (for example content=[{"type":"text", "text":"..."}]), this passes the raw messages directly into the tokenizer template. The SFT chat path first runs _normalize_mlx_messages(..., is_vlm=False), which flattens those parts into strings; without the same normalization here, text ORPO/DPO can either fail in templates that expect string content or train on the Python list representation instead of the intended text. Normalize prompt/chosen/rejected messages before these apply_chat_template calls.

Useful? React with 👍 / 👎.

_render_preference_example passed raw prompt/chosen/rejected straight
into apply_chat_template, unlike the SFT chat path which first runs
_normalize_mlx_messages(..., is_vlm=False). A conversational ORPO/DPO row
carrying OpenAI-style content PARTS (content=[{"type":"text","text":...}])
therefore diverged by template:

  - Qwen2.5 / SmolLM-Instruct build the turn with '...' + message.content
    and raise TypeError: can only concatenate str (not "list") to str.
  - Qwen3 guards the body with {%- if message.content is string %} and
    leaves it empty otherwise, so every turn renders EMPTY and silently:
    prompt+chosen and prompt+rejected come out byte-identical, the ORPO
    odds-ratio term collapses to -logsigmoid(0) = log 2 (a constant with
    zero gradient), and the run trains on empty assistant turns while its
    loss curve still looks plausible.

Measured on Qwen3-0.6B through create_preference_batches and
make_orpo_loss_fn: L_OR 0.6931471825 with |grad| 1.08e-08 before, versus
0.6900140643 with |grad| 1.20e-01 after.

Flatten the three message lists inside the conversational branch. String
rows are returned unchanged by _normalize_mlx_messages, so the
non-conversational path stays byte-identical.

Tests cover both template families as stubs plus the three real
tokenizers, and pin that plain string rows are untouched.
create_preference_batches is eager, so it returned a bare list. The
trainer reads the one-pass micro-batch count as
getattr(batches, "cycle_length", None) in _mlx_epoch_microbatches and
_callback_batches_per_epoch, and a plain list cannot carry it, so both
fell back to None under max_steps > 0.

A ragged pass then never force-updated on its tail: with 3 preference
batches and gradient_accumulation_steps=2 the run should update on batch
2 and apply the epoch-final batch 3 by itself, but instead accumulated
batch 3 together with the next pass's batch 1. That changes the effective
gradient and moves the callback/save/eval epoch boundaries off the SFT
finite-plan path.

Return a small list subclass carrying the count. len(base_starts) is the
one-pass length in every ordering branch: default/torch_randperm with
num_batches cycle base_starts (or reseeded row permutations of the same
length), the num_epochs branch concatenates whole passes of it, and
sequential/single-pass use it directly. It is deliberately not clamped to
len(batches) -- num_epochs makes the list several passes long and a
num_batches horizon can truncate it below one pass -- matching how the
SFT/VLM plans count theirs.

Subclassing list keeps indexing and modulo-cycling identical, and the
container stays outside _FINITE_BATCH_PLAN_TYPES /
_EAGER_REFETCHABLE_PLAN_TYPES so these already-materialized batches are
never refetched. __slots__ on a list subclass leaves no __dict__ for the
default reducer, so __reduce__/__setstate__ are spelled out.
@BardiaKoopah

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 153164f67b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Compress the verbose comment blocks and docstrings added by this branch,
plus two boilerplate banners. Comments only -- no code, control flow or
literal is touched, verified by an AST comparison that strips docstrings
from both sides and compares ast.dump().

Covers only blocks whose removed lines this branch authored; the review
comments contributed by others are split into follow-up commits so they
can be reviewed or dropped independently.
…r.py

These blocks were authored by @danielhanchen in the 2026-07-08/09 review
commits on this branch. Intent is preserved -- each keeps the defect it
records and the reason for the fix -- but the narrative is cut to one or
two lines.

Deliberately kept, because they justify decisions that read as bugs
otherwise: why cycle_length is not clamped to len(batches) (num_epochs
expands, num_batches truncates), why PreferenceBatchList needs an
explicit __reduce__ under __slots__, and the failure mode the three
_normalize_mlx_messages calls fix.

Split out so it can be reviewed or reverted without touching the rest of
the comment pass. Comments only; AST verified identical.
These 26 blocks are @danielhanchen's "# Regression: ..." notes from the
2026-07-08/09 review commits, each recording a defect found in review and
why the naive approach was wrong. Compressed, not removed: every block
keeps the defect and the reason.

The TRL source citations are kept, including the verbatim quote in the
ORPO-NLL block -- "orpo chosen nll loss is computed over the full prompt
and response" (orpo_trainer.py:780-785). That quote is the evidence for
why the SFT term spans prompt+response; without it the term could be
"fixed" back to a response-only mask, which is the exact defect the note
was written to prevent.

Isolated in its own commit because these are the review record and the
most likely thing to want back. Comments only; AST verified identical.
tests/test_mlx_preference_content_parts.py and
tests/test_mlx_preference_cycle_length.py were collected by the
--collect-only matrix but executed by no job, so they only ever
import-checked. That is the blind spot the gate's own comment records for
test_mlx_finetune_last_n_layers (born broken in unslothai#669, invisible until
unslothai#739).

Add both to the HARD GATE invocation rather than the continue-on-error
one, so a failure actually reds the job. Both are deterministic, CPU-pure
and sub-second under the torch shim, matching that step's criteria.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ac5a2bcac

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

else None
)
batches = create_preference_batches(
dataset=self.train_dataset,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the unwrapped preference dataset

When train_dataset is a sized text dataset, MLXTrainer.__init__ has already replaced self.train_dataset with _MLXTokenizedDatasetView, whose iterator runs the SFT text/chat renderer to synthesize input_ids before yielding each row. For ORPO/DPO rows that already have valid prompt/chosen/rejected columns but also carry an unused messages or chat-style extra column, this can raise on a missing/mismatched chat template before the preference collator ever reads the explicit preference fields. Pass the train_dataset variable resolved from _train_dataset_for_batches() here so extra columns are not preprocessed by the SFT view.

Useful? React with 👍 / 👎.

@oobabooga

Copy link
Copy Markdown
Member

@BardiaKoopah thanks for the work on this. The core ORPO/DPO implementation looks solid, but I think these three remaining issues need to be addressed before merging:

  1. Move the four new MLXTrainingConfig fields to the append-only section and include them in _MLX_CONFIG_OPTIONAL_COPY_FIELDS. Their current placement can silently change legacy SFT warmup behavior.
  2. Have MLXORPOTrainer and MLXDPOTrainer accept and forward callbacks, matching the base trainer API.
  3. Preserve fractional num_train_epochs when planning preference batches. The current int() truncation produces the wrong training budget.

Please include focused regression tests for these cases. While touching this path, it would also be good to separate the pair-count loss weight from actual trained-token telemetry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants