Skip to content

feat(mlx): add KTO preference tuning - #912

Open
BardiaKoopah wants to merge 19 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-kto
Open

feat(mlx): add KTO preference tuning#912
BardiaKoopah wants to merge 19 commits into
unslothai:mainfrom
BardiaKoopah:feat/mlx-kto

Conversation

@BardiaKoopah

Copy link
Copy Markdown
Contributor

Summary

Adds KTO preference tuning to the MLX path on Apple Silicon, mirroring TRL's KTOTrainer (loss_type="kto", Eqn 7 of arXiv:2402.01306).

The selling point is the data. KTO trains on unpaired examples — a single completion per row with a binary desirable/undesirable label — instead of the chosen/rejected pairs DPO and ORPO require. Binary-labelled completions are far cheaper to collect than matched preference pairs (any "was this output good or bad?" signal works: thumbs up/down, task success/failure, filters), which makes KTO the practical choice when you don't have paired data. This is the first unpaired preference method on the MLX trainer.

What's included

  • MLXKTOTrainer / MLXKTOConfig — standalone trainer + config mirroring TRL's API, reusing MLXTrainer's optimizer / schedule / gradient-clip / weight-decay helpers. The SFT compile/accumulate loop is left completely untouched.
  • Unpaired batch builder with the mismatched-pair KL variant (completions rolled +1 within the batch, matching TRL's _get_kl_dataset), the summed-logp extractor (_kto_sum_logp), the clamped batch KL baseline (_kto_kl_baseline), and the KTO loss (_kto_loss).
  • Reference model = the LoRA-disabled forward (adapter scale=0), so no second model copy. Reference and KL forwards are stop_gradient'd; adapter scales are restored in a finally so a throwing forward can't leave the model adapter-disabled.
  • Fail-loud guards: per_device_train_batch_size < 2 raises (the mismatched-pair roll would self-pair and collapse the KL term to the reward); a non-PEFT model raises (no reference forward without adapters).

Correctness evidence

_kto_loss matches both numpy and a torch reimplementation of TRL's kto_loss to ~1e-8, across three weight settings:

(desirable_weight, undesirable_weight) loss
(1.0, 1.0) 0.47720963
(1.33, 1.0) 0.56631130
(1.0, 1.5) 0.58081198
  • KL baseline matches TRL exactly (0.31288), including the clamp(min=0) branch (raw negative estimate → 0.0).
  • _kto_sum_logp vs numpy log-softmax reference: max|Δ| = 7e-07.
  • Real Qwen2.5-0.5B 4-bit + LoRA: deterministic across reruns; 336 LoRA leaves all finite and nonzero; trains 0.4997 → 0.2146 over 8 steps.

Tests

  • tests/test_mlx_kto_loss.py — pure-logic, runs under the torch shim in normal CI (no Metal needed): loss vs numpy and vs the pinned TRL values, the KL clamp branch, single-label (all-desirable / all-undesirable) batches, and the batch_size < 2 guard. 5 passed.
  • tests/test_mlx_kto_train_metal.py — Metal-gated e2e (skips cleanly off Apple Silicon): finite & decreasing loss, LoRA-scale restoration including the scales-restored-when-a-forward-throws finally path, the non-PEFT fail-loud error, and _kto_sum_logp vs numpy. 5 passed.
  • Canonical MLX baseline: 128 passed, 0 failures.
    (The _kto_sum_logp numeric check lives in the Metal file rather than the shim file — its .astype(mx.float32) resolves a real mlx dtype under the torch shim's pytest import order; it runs against real MLX instead. A code comment explains this in place.)

Limitations (please read — not buried)

  • LoRA-only. The reference is the adapter-off forward, so full-finetune KTO is not supported and raises a clear error rather than silently running reference-free.
  • Not mx.compile'd. The KTO step runs eager with 4 forwards/step (policy, policy-KL, reference, reference-KL). Correct and verified, but slower per step than the compiled SFT path. The blocker is structural: the LoRA-disable reference mutates module scale state, which under mx.compile would execute at trace time only and freeze the traced scale. Threading scale as a compiled input is a plausible follow-up, but it needs its own benchmark — compiling 1 of 4 forwards may not pay for the added risk.
  • Within-batch KL roll vs TRL's global roll — affects reproducibility, not the optimization. TRL builds the KL set by rolling the whole dataset; this rolls completions +1 within each batch, so boundary examples pair with a different mismatched completion than a specific TRL run would. This changes the exact per-batch KL scalar (and thus bit-exact reproducibility against a given TRL run), but not the training method: the KL baseline is a detached, clamped, batch-level scalar (no gradient flows through it — it only shifts the loss threshold), and both rolls estimate the same expectation over the batch's completions. The within-batch variant in fact satisfies TRL's own documented "best results" condition (the mismatched y′ set equals the reward y set for that batch) more cleanly. This is a mechanism-based claim from the loss structure; I have not run a head-to-head TRL-vs-MLX training comparison to measure end-task parity.
  • kl = 0 is expected often. The raw mismatched-pair estimate is frequently negative early in training and gets clamped to 0. This is TRL-faithful, not a bug (verified: the LoRA is active in the KL forward; the raw estimate was e.g. −4.82 → clamped).

KTO trains on UNPAIRED data - one completion per row with a binary
desirable/undesirable label, not chosen/rejected pairs - mirroring TRL's
KTOTrainer (loss_type="kto", Eqn 7 of arXiv:2402.01306) on Apple Silicon.

- MLXKTOTrainer / MLXKTOConfig: standalone trainer + config mirroring TRL's
  API, reusing MLXTrainer's optimizer/schedule/clip/decay helpers. The SFT
  compile/accumulate loop is left untouched.
- Unpaired batch builder with the mismatched-pair KL variant (completions
  rolled +1 within the batch, TRL _get_kl_dataset), the summed-logp extractor
  (_kto_sum_logp), the clamped batch KL baseline (_kto_kl_baseline), and the
  KTO loss (_kto_loss). Reference model is the LoRA-disabled (scale=0) forward;
  reference and KL forwards are detached and scales restored in a finally.
- Guards: per_device_train_batch_size < 2 fails loud (the mismatched-pair roll
  would self-pair and collapse the KL term); non-PEFT models fail loud (no
  reference forward without adapters).

Correctness (Step 1): _kto_loss matched a torch reimplementation of TRL's
kto_loss and a pure-numpy reference to ~1e-8 across weight settings, with the
KL baseline matching TRL at 0.31288.

Tests:
- tests/test_mlx_kto_loss.py: pure-logic under the torch shim (loss vs numpy
  and pinned TRL values, KL clamp, single-label batches, batch-size guard).
- tests/test_mlx_kto_train_metal.py: Metal-gated e2e (finite/decreasing loss,
  scale restoration incl. the forward-throws finally path, non-PEFT error,
  sum-logp vs numpy).

Limitations: LoRA-only (reference is the adapter-off forward; full-finetune
KTO raises); not mx.compile'd (eager, 4 forwards/step); within-batch KL roll
vs TRL's global roll (equivalent for the batch-level estimate, not
row-identical); kl=0 is expected often (raw estimate frequently negative ->
clamped), which is TRL-faithful.

@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 implements Kahneman-Tversky Optimization (KTO) for unpaired preference tuning in MLX, introducing the MLXKTOTrainer, configuration classes, batching utilities, and associated unit tests. Feedback on the implementation highlights two key issues: first, the train method should return an MLXTrainOutput object rather than a list of floats to maintain compatibility with the parent MLXTrainer interface; second, _kto_tokenize_row should utilize tokenizer.encode instead of calling the tokenizer directly to prevent crashes with certain tokenizer wrappers, and should properly truncate completion lengths to avoid potential out-of-memory errors.

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/trainer.py Outdated
Comment on lines +4358 to +4360
step += 1

return self._train_loss_history

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 train method currently returns self._train_loss_history (a list of floats). However, MLXTrainer.train (which this class inherits from) returns an MLXTrainOutput object (a dict subclass with properties like metrics, global_step, and training_loss).

Any downstream pipeline or training script that expects MLXTrainOutput and accesses these properties (e.g., output.metrics or output.global_step) will crash with an AttributeError: 'list' object has no attribute 'metrics'.

We should wrap the return value in MLXTrainOutput to maintain compatibility with the MLXTrainer interface.

Suggested change
step += 1
return self._train_loss_history
step += 1
avg_loss = (
sum(self._train_loss_history) / len(self._train_loss_history)
if self._train_loss_history else 0.0
)
return MLXTrainOutput({
"train_loss": avg_loss,
"train_steps": step,
"total_train_steps": total_steps,
})

Comment on lines +4151 to +4166
def _kto_tokenize_row(tokenizer, prompt, completion, args):
"""Tokenize one KTO row to (prompt_ids, completion_ids), truncated to the
configured lengths. Prompt is truncated (kept-completion) if the combined
length exceeds max_length, matching TRL's keep-completion policy."""
p = tokenizer(prompt, add_special_tokens=False)["input_ids"]
c = tokenizer(completion, add_special_tokens=False)["input_ids"]
max_completion = args.max_completion_length
if max_completion is not None and max_completion > 0:
c = c[:max_completion]
if args.max_prompt_length and args.max_prompt_length > 0:
p = p[-args.max_prompt_length:]
if args.max_length and args.max_length > 0 and len(p) + len(c) > args.max_length:
# Keep the whole completion; trim the prompt from the left.
keep = max(args.max_length - len(c), 0)
p = p[-keep:] if keep > 0 else []
return p, c

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

The current implementation of _kto_tokenize_row calls tokenizer(prompt, add_special_tokens=False) directly. If the tokenizer is an unpatched TokenizerWrapper from mlx_lm (which is common if the trainer is instantiated directly without going through FastMLXModel.from_pretrained), it will raise a TypeError: 'TokenizerWrapper' object is not callable because TokenizerWrapper does not implement __call__ by default.

Additionally, if max_completion_length is not set or is larger than max_length, and the completion itself exceeds max_length, the sequence length of the batch will exceed max_length.

Using tokenizer.encode is much safer and more robust as it works for both TokenizerWrapper and Hugging Face tokenizers directly without dict indexing. We should also truncate c to args.max_length if it exceeds it to prevent out-of-memory (OOM) issues.

def _kto_tokenize_row(tokenizer, prompt, completion, args):
    """Tokenize one KTO row to (prompt_ids, completion_ids), truncated to the
    configured lengths. Prompt is truncated (kept-completion) if the combined
    length exceeds max_length, matching TRL's keep-completion policy."""
    try:
        p = tokenizer.encode(prompt, add_special_tokens=False)
    except TypeError:
        p = tokenizer.encode(prompt)
    try:
        c = tokenizer.encode(completion, add_special_tokens=False)
    except TypeError:
        c = tokenizer.encode(completion)
    max_completion = args.max_completion_length
    if max_completion is not None and max_completion > 0:
        c = c[:max_completion]
    if args.max_length and args.max_length > 0 and len(c) > args.max_length:
        c = c[:args.max_length]
    if args.max_prompt_length and args.max_prompt_length > 0:
        p = p[-args.max_prompt_length:]
    if args.max_length and args.max_length > 0 and len(p) + len(c) > args.max_length:
        # Keep the whole completion; trim the prompt from the left.
        keep = max(args.max_length - len(c), 0)
        p = p[-keep:] if keep > 0 else []
    return p, c

@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: 3cafce8e9a

ℹ️ 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/trainer.py Outdated
Comment on lines +4344 to +4345
self._set_optimizer_lr_for_step(optimizer, step)
optimizer.update(model, grad)

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 gradient accumulation before updating

When gradient_accumulation_steps > 1 (the inherited default is 4), the KTO loop still advances the LR and calls optimizer.update for every individual batch. That silently trains with only per_device_train_batch_size examples per optimizer step instead of the requested effective batch size, which changes the optimization dynamics for any KTO run using the shared MLX training config defaults or an explicit accumulation setting.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
)
step += 1

return self._train_loss_history

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 Persist KTO adapters at the end of training

When a KTO run finishes successfully, this return exits without the final-save/checkpoint path that MLXTrainer uses (save_steps=0 means save at end in the inherited config). In contexts where users rely on the inherited output_dir/save_steps behavior, trainer.train() updates only the in-memory model and leaves no adapters under output_dir, so the trained result is lost once the process exits unless they know to call save_model() manually.

Useful? React with 👍 / 👎.

MLXKTOTrainer.train() returned self._train_loss_history, a list of floats,
while the MLXTrainer.train() it overrides returns an MLXTrainOutput. Any
caller written against the base contract -- output.metrics,
output.global_step, output.training_loss -- hit AttributeError on the KTO
path, and downstream code that treats every trainer's result uniformly
broke on KTO alone.

Return an MLXTrainOutput with the same core keys (train_loss as the mean
over the run, train_runtime, train_steps, total_train_steps). The per-step
losses remain on self._train_loss_history for anything that wants them.

Update the finite-and-decreasing test to read the loss history off the
trainer and additionally assert the MLXTrainOutput contract.

@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: c55e435b51

ℹ️ 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/trainer.py Outdated
"non-empty completions per batch)."
)

total_steps = args.max_steps if args.max_steps and args.max_steps > 0 else len(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 Honor num_train_epochs in KTO

When callers disable step-based training with max_steps <= 0 and set num_train_epochs > 1, KTO still sets total_steps to just len(batches). The outer epoch loop does not compensate because it stops as soon as step >= total_steps, so an epoch-based KTO run silently trains for one pass instead of the requested number of epochs.

Useful? React with 👍 / 👎.

self._eval_callbacks = []
self._kl_history = []

def train(self, resume_from_checkpoint: str | None = 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 Honor KTO resume checkpoints

When a caller passes resume_from_checkpoint, this override accepts the argument but never stores, validates, or loads it, so KTO starts at step 0 with a fresh optimizer even though the caller requested a resumed run. That can silently redo interrupted training or overwrite the intended continuation; this should either load the checkpoint like MLXTrainer.train() or reject the unsupported argument explicitly.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4162 to +4165
if args.max_length and args.max_length > 0 and len(p) + len(c) > args.max_length:
# Keep the whole completion; trim the prompt from the left.
keep = max(args.max_length - len(c), 0)
p = p[-keep:] if keep > 0 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 Enforce max_length on long KTO completions

When max_completion_length is unset or larger than max_length and a completion alone exceeds max_length, this branch computes keep = 0 and only removes the prompt; the overlong completion is still padded into the batch. That silently ignores the configured sequence cap and can create unexpectedly huge tensors or OOM on a single long completion instead of truncating or rejecting it.

Useful? React with 👍 / 👎.

self._is_vlm = False
# Raw KTO rows: do NOT wrap in the SFT tokenized dataset view.
self.train_dataset = train_dataset
self.eval_dataset = eval_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 Honor eval_dataset in KTO

When a KTO run is configured with eval_dataset plus eval_steps, load_best_model_at_end, or early stopping, the dataset stored here is never read by train(): the loop only builds training batches and returns. Users therefore get no eval metrics, eval callbacks, best-model restore, or early stopping despite passing the standard trainer arguments.

Useful? React with 👍 / 👎.

Comment on lines +4155 to +4156
p = tokenizer(prompt, add_special_tokens=False)["input_ids"]
c = tokenizer(completion, add_special_tokens=False)["input_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 Tokenize KTO rows without calling the MLX wrapper

With the normal tokenizer returned by FastMLXModel.from_pretrained(), this calls the mlx-lm TokenizerWrapper object directly, but that wrapper exposes encode/the inner HF tokenizer rather than being callable. As a result the advertised KTO path fails during batch construction with a TypeError before training starts unless the caller happens to pass a raw Hugging Face tokenizer instead.

Useful? React with 👍 / 👎.

mx.eval(model.parameters(), optimizer.state, loss)

train_loss = float(loss)
self._train_loss_history.append(train_loss)

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 Reset KTO run state before each train call

When the same MLXKTOTrainer instance is trained more than once, this append reuses _train_loss_history from the previous run because train() never calls _reset_run_state() at the start. The returned train_loss is then averaged across old and new runs, and callbacks/consumers reading the history see stale losses mixed with the current training session.

Useful? React with 👍 / 👎.

MLXKTOTrainer.train() updated the model in memory and returned without ever
writing to output_dir. The KTO config defaults save_steps=0, whose
documented meaning is "save at end of training" and which MLXTrainer honors
via a final save_model() call. On the KTO path the trained adapters were
therefore lost on process exit unless the caller knew to call save_model()
by hand.

Mirror the base trainer's end-of-run save: on the main process, call
save_model() (KTO is LoRA-only, so this takes the adapter path) and report
the output directory. A ValueError from save_model is downgraded to a
skip message, as in MLXTrainer.

Add a Metal regression asserting adapters.safetensors and
adapter_config.json land in output_dir after a run.
The KTO loop called optimizer.update() once per micro-batch and never read
gradient_accumulation_steps, so a run with the config default of 4 (or any
explicit value > 1) silently trained at an effective batch size of
per_device_train_batch_size, stepping the optimizer and LR schedule 4x more
often than requested -- different optimization dynamics from every other
MLX trainer and from TRL's KTOTrainer.

Accumulate grad_accum micro-batches, average their gradients, and take one
optimizer step, so the effective batch size is
per_device_train_batch_size * grad_accum. step/total_steps and the LR
schedule now count optimizer steps; when max_steps is unset, one epoch is
len(batches) // grad_accum steps, matching MLXTrainer. Gradient clipping,
weight decay, and the LR step run once per optimizer step on the mean
gradient.

A simple mean is used rather than the token-weighted accumulation
MLXTrainer applies to SFT: the KTO loss is a per-example preference
objective, so each micro-batch contributes equally.

Tests: pin the existing tests to grad_accum=1 (unchanged one-step-per-batch
behavior) and add coverage that grad_accum=2 halves the optimizer steps per
epoch; the new test fails on the pre-fix loop (6 steps instead of 3).
@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: 61691d1a2f

ℹ️ 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/trainer.py Outdated
Comment on lines +4162 to +4165
if args.max_length and args.max_length > 0 and len(p) + len(c) > args.max_length:
# Keep the whole completion; trim the prompt from the left.
keep = max(args.max_length - len(c), 0)
p = p[-keep:] if keep > 0 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 Clamp completions to max_length

When a completion alone is longer than args.max_length (or max_completion_length is set above max_length), this branch sets keep = 0 and drops only the prompt, leaving the overlong completion untouched. _kto_pad will then emit sequences longer than the configured context cap, so KTO can unexpectedly exceed the model's positional limit or OOM despite max_length being set; truncate the completion as well so len(p) + len(c) <= max_length always holds.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4289 to +4292
if args.max_steps and args.max_steps > 0:
total_steps = args.max_steps
else:
total_steps = max(len(batches) // grad_accum, 1)

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 num_train_epochs when max_steps is unset

For MLXKTOConfig(max_steps=0, num_train_epochs=N), this computes total_steps from only a single pass over batches, so epoch-based KTO training silently runs one epoch no matter what num_train_epochs is set to. Since the config inherits num_train_epochs from MLXTrainingConfig and the base trainer uses it when max_steps <= 0, users requesting multiple epochs will get substantially under-trained adapters unless this multiplies by the requested epoch count.

Useful? React with 👍 / 👎.

self._eval_callbacks = []
self._kl_history = []

def train(self, resume_from_checkpoint: str | None = 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 Honor resume_from_checkpoint instead of restarting

This method accepts resume_from_checkpoint, but the argument is never used before creating a fresh optimizer and starting step = 0, so a caller resuming a failed KTO run silently restarts from scratch and can overwrite the output adapters as if training had resumed. Either load the adapter/optimizer/trainer state like MLXTrainer.train() does or reject the argument loudly to avoid corrupting resumed experiments.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4383 to +4384
self._apply_manual_weight_decay(model, optimizer, grad)
self._set_optimizer_lr_for_step(optimizer, step)

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 Set the scheduled LR before decoupled decay

With AdamW/Muon/Lion and a warmup/linear/cosine schedule, _apply_manual_weight_decay reads optimizer.learning_rate before this step's LR is installed, so every step after the first applies decoupled decay using the previous step's LR while the gradient update uses the current one. The default KTO config has weight_decay > 0 and a scheduled LR, so move _set_optimizer_lr_for_step before the decay helper.

Useful? React with 👍 / 👎.

grad = self._apply_coupled_weight_decay(model, grad)
self._apply_manual_weight_decay(model, optimizer, grad)
self._set_optimizer_lr_for_step(optimizer, step)
optimizer.update(model, grad)

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 Average gradients under MLX DDP

When run with mlx.distributed world_size > 1, this update applies each rank's gradient directly; the KTO loop never shards KTO batches or calls _distributed_sum_gradient_tree() / nn.average_gradients like MLXTrainer does, yet only rank 0 saves at the end. Distributed KTO therefore produces independent per-rank training and persists one rank's adapters instead of the global-batch result.

Useful? React with 👍 / 👎.

Comment on lines +4331 to +4334
for _epoch in range(max_epochs):
if stop:
break
for batch in 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 Switch the model back to training mode

If the model was left in eval mode before MLXKTOTrainer.train() (for example after generation/evaluation), this loop starts taking optimizer steps without model.train(), so MLX modules gated on training such as LoRA dropout remain disabled for the whole run. The base trainer explicitly enters train mode before its loop; KTO should do the same before the first forward.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
# Accumulate the mean gradient. Dividing each micro-batch by
# grad_accum before summing keeps the accumulator at update
# scale; sum(g_i / N) == mean(g_i).
contrib = tree_map(lambda g: g / grad_accum, grad)

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 LoRA+ per-leaf scaling

When lora_plus_ratio is set, this accumulation treats every gradient leaf the same (g / grad_accum) and never applies the inherited LoRA+ multiplier to lora_b leaves, so KTO silently ignores a supported MLXTrainingConfig knob and trains with the wrong per-parameter LR. Mirror the base trainer's per-leaf scaling before accumulation/update or reject the option for KTO.

Useful? React with 👍 / 👎.

self._kl_history = []

def train(self, resume_from_checkpoint: str | None = None):
args = self.args

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 Reset run state before retraining

When the same MLXKTOTrainer instance is trained more than once, this method starts a new local step counter but never clears _train_loss_history, _kl_history, or the prior global-step state. The second run's metrics are therefore averaged together with stale losses/KL from the previous run, unlike MLXTrainer.train() which resets per-run state before entering its loop.

Useful? React with 👍 / 👎.

self._is_vlm = False
# Raw KTO rows: do NOT wrap in the SFT tokenized dataset view.
self.train_dataset = train_dataset
self.eval_dataset = eval_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 Evaluate eval_dataset when eval_steps is set

The constructor accepts and stores eval_dataset, but the KTO training loop never builds eval batches or calls _evaluate, so eval_steps, load_best_model_at_end, and early stopping are silently ignored whenever users pass validation data. This can leave KTO experiments with no validation metrics or best-model selection even though the inherited config exposes those controls.

Useful? React with 👍 / 👎.

configured lengths. Prompt is truncated (kept-completion) if the combined
length exceeds max_length, matching TRL's keep-completion policy."""
p = tokenizer(prompt, add_special_tokens=False)["input_ids"]
c = tokenizer(completion, add_special_tokens=False)["input_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 Append EOS when append_eos is enabled

Completions are tokenized verbatim here and the inherited append_eos=True default is never applied, so common KTO rows whose completion text does not already include an EOS never train the adapter to end the answer. Since MLXTrainingConfig documents append_eos as enabled by default and the SFT path honors it, KTO should append tokenizer.eos_token_id before padding/truncation or explicitly reject the option.

Useful? React with 👍 / 👎.

Three issues in MLXKTOTrainer surfaced by the follow-up review, all on the
paths the recent KTO commits touched:

- LR/decay ordering: _apply_manual_weight_decay reads
  optimizer.learning_rate, but the KTO loop installed this step's LR after
  the decay helpers, so decoupled weight decay used the previous step's LR
  every step past the first. Move _set_optimizer_lr_for_step ahead of the
  decay calls, matching the base trainer.

- num_train_epochs: when max_steps is unset, total_steps was one pass over
  the data // grad_accum, ignoring num_train_epochs, so epoch-based KTO ran
  a single epoch regardless of the requested count. Multiply by
  num_train_epochs, matching MLXTrainer.

- LoRA+: the accumulation scales every gradient leaf by 1/grad_accum and
  never applies the lora_plus_ratio multiplier to lora_b, so a set
  lora_plus_ratio was silently ignored. Per-leaf LoRA+ scaling is woven into
  the base trainer's update path and is not reimplemented here; reject the
  option loudly instead of training lora_b at the wrong LR.

Tests: num_train_epochs=2 doubles the optimizer steps; lora_plus_ratio>0
raises. Both fail on the pre-fix loop.
The standalone KTO loop does not implement checkpoint resume, an eval loop,
or MLX distributed training, yet MLXKTOTrainer accepted all three and
silently ignored them: resume_from_checkpoint would restart from scratch and
overwrite the output adapters, eval_dataset/eval_steps/load_best_model_at_end
produced no evaluation, and under world_size > 1 each rank trained
independently while only rank 0 saved.

Raise a clear error for each at the start of train() rather than return a
result that looks trained but ignored the request. These are the pre-existing
KTO limitations flagged in review; implementing them (mirroring MLXTrainer's
resume/eval/DDP paths) is left to a follow-up.

Tests: resume_from_checkpoint and eval_dataset both raise.
Two small correctness gaps in MLXKTOTrainer.train(), matching MLXTrainer:

- The loop began taking optimizer steps without model.train(), so if the
  model was left in eval mode (e.g. after generation/evaluation) training-
  gated modules like LoRA dropout stayed disabled for the whole run. The
  reference forward disables adapters by scale rather than eval mode, so
  entering train mode does not affect it.

- Per-run metric state (_train_loss_history, _kl_history, _global_step) was
  never reset, so re-running the same trainer instance blended the new run's
  loss/KL with the previous run's -- and train() reports the mean over
  _train_loss_history in its MLXTrainOutput.

Reset the metric state and call model.train() before the loop. Test asserts
a second train() call reports 3 steps of history, not 6.

@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: 7e1bcc90ed

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

batches.append(dict(
comp_ids=_kto_pad([p + c for p, c in zip(prompts, comps)], pad_id),
comp_labels=_kto_pad([[-100] * len(p) + list(c) for p, c in zip(prompts, comps)], -100),
kl_ids=_kto_pad([p + cr for p, cr in zip(prompts, rolled)], pad_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.

P2 Badge Re-trim rolled KL sequences to max_length

When completions in the same KTO batch have different lengths, each prompt is truncated only against its own completion, but this KL path concatenates that prompt with a different rolled completion without applying max_length again. Fresh evidence here is that the rolled KL pair can exceed max_length even when every original prompt+completion row fit, so the KL forward can allocate past the configured context window or OOM.

Useful? React with 👍 / 👎.

pad_id = 0

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 streaming KTO datasets before materializing them

When args.streaming=True or an HF IterableDataset is passed, this loop tries to exhaust the dataset to build all KTO rows before training starts. Unlike the base MLXTrainer streaming path, an unbounded stream will hang or OOM and finite streams lose streaming semantics, so KTO should either implement an iterator-based batcher or fail loudly for streaming datasets.

Useful? React with 👍 / 👎.

# only in memory and are lost on process exit unless the caller knows to
# call save_model() by hand. KTO is LoRA-only, so save_model() takes the
# adapter path.
if self.is_main_process:

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 save_steps for KTO checkpoints

When callers set the inherited save_steps > 0 to keep intermediate checkpoints, the KTO loop never checks it after optimizer steps and only reaches this final adapter save. Interrupted runs therefore leave no checkpoint-* directory, optimizer state, or trainer state despite the standard MLX training config requesting checkpointing; either mirror the base checkpoint path or reject save_steps > 0 as unsupported for KTO.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
p, c = _kto_tokenize_row(tokenizer, ex["prompt"], ex["completion"], args)
if len(c) == 0:
continue # nothing to score
rows.append((p, c, bool(ex["label"])))

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 Parse KTO labels instead of using truthiness

When KTO data comes from a CSV or other source that stores binary labels as strings such as "False" or "0", this truthiness conversion turns every non-empty string into True. That silently moves undesirable examples into the desirable branch and trains on corrupted preference labels; parse accepted boolean/0-1 encodings explicitly or reject unsupported label types.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
self._train_loss_history.append(train_loss)
self._kl_history.append(acc_kl)
self._global_step = step + 1
if args.logging_steps and (step % max(int(args.logging_steps), 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 Invoke registered step callbacks after KTO steps

When a caller registers add_step_callback() to drive progress reporting or callback-based cancellation, the KTO loop records the step and only enters this print branch; it never iterates self._step_callbacks like MLXTrainer.train() does. Those consumers see no progress events and cannot stop from a callback during KTO training, despite the inherited callback API being present.

Useful? React with 👍 / 👎.

self._eval_callbacks = []
self._kl_history = []

def train(self, resume_from_checkpoint: str | None = 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 Reject or install NEFTune for KTO runs

Because this override takes over train() without calling the base setup path, a KTO run configured with the inherited neftune_noise_alpha > 0 never calls _install_neftune() and trains without the requested embedding noise. Since the KTO guards already reject unsupported inherited options, this should either install/remove NEFTune around the loop or fail loudly instead of silently ignoring the knob.

Useful? React with 👍 / 👎.

Comment on lines +4344 to +4345
ref_comp = mx.stop_gradient(_kto_sum_logp(model(batch["comp_ids"]), batch["comp_labels"]))
ref_kl = mx.stop_gradient(_kto_sum_logp(model(batch["kl_ids"]), batch["kl_labels"]))

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 adapter-off reference forwards

When the model has any nonzero training-mode dropout, these adapter-off reference forwards still run while the surrounding KTO loop has put the model in training mode. The reference log-probs and KL baseline then include fresh dropout masks instead of the deterministic frozen reference policy, which corrupts the KTO log-ratios; temporarily switch to eval mode for the reference forwards and restore training mode afterwards.

Useful? React with 👍 / 👎.

acc_loss = 0.0 # running mean of the window's losses
acc_kl = 0.0 # running mean of the window's KL diagnostics
stop = False
max_epochs = 1_000_000

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 Remove the hard epoch cap from max_steps training

When max_steps is larger than 1_000_000 * len(batches) (for example a tiny smoke dataset repeated for a long adapter run), this fixed epoch cap exits the outer loop before step reaches total_steps and returns a successful MLXTrainOutput with fewer updates than requested. The loop should be driven directly by step < total_steps instead of a silent epoch ceiling.

Useful? React with 👍 / 👎.

args.beta, args.desirable_weight, args.undesirable_weight,
)

loss, grad = nn.value_and_grad(model, loss_fn)(model)

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 MLX model patches before KTO gradients

For models that rely on the base MLX training setup to patch missing VJPs or disable fused training paths (for example gated-delta Qwen3.5/qwen3_next/Kimi-style layers or Qwen VL fused MRoPE), KTO jumps straight into value_and_grad without that setup. Those supported model families can therefore fail gradient computation or train through the wrong fused path under KTO; mirror the base pre-train patching or reject those models explicitly.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
# Accumulate the mean gradient. Dividing each micro-batch by
# grad_accum before summing keeps the accumulator at update
# scale; sum(g_i / N) == mean(g_i).
contrib = tree_map(lambda g: g / grad_accum, grad)

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 embedding_learning_rate in KTO gradients

When callers LoRA the lm_head/embed_tokens paths and set the inherited embedding_learning_rate, the base trainer scales those gradient leaves relative to the main LR, but KTO averages every gradient uniformly here after only rejecting LoRA+. That silently trains embedding/LM-head adapter leaves at the main learning rate instead of the configured embedding LR; apply the same per-leaf scaling or reject this option for KTO.

Useful? React with 👍 / 👎.

@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: 7e1bcc90ed

ℹ️ 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 +4155 to +4156
p = tokenizer(prompt, add_special_tokens=False)["input_ids"]
c = tokenizer(completion, add_special_tokens=False)["input_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 Tokenize each prompt-completion pair jointly

For boundary-sensitive BPE/SentencePiece tokenizers, concatenating tokens produced by two independent calls is not generally equivalent to tokenizing the actual prompt + completion text. This also bypasses the inherited append_eos=True behavior, so both reward and KL paths optimize synthetic token streams without the completion's EOS probability. The existing _tokenize_mlx_prompt_completion helper in unsloth_zoo/mlx/utils.py handles joint encoding, boundary masking, and EOS; use equivalent processing here.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4162 to +4165
if args.max_length and args.max_length > 0 and len(p) + len(c) > args.max_length:
# Keep the whole completion; trim the prompt from the left.
keep = max(args.max_length - len(c), 0)
p = p[-keep:] if keep > 0 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.

P1 Badge Cap completions that exceed max_length

When max_completion_length is unset (the default) and a completion alone exceeds max_length, this branch sets keep to zero and removes the prompt but never truncates c; the resulting sequence therefore still exceeds the configured cap. A long completion can consequently cause unexpectedly large allocations or OOM despite the caller setting max_length, so the completion itself must also be capped.

Useful? React with 👍 / 👎.

Comment on lines +4221 to +4222
kl_ids=_kto_pad([p + cr for p, cr in zip(prompts, rolled)], pad_id),
kl_labels=_kto_pad([[-100] * len(p) + list(cr) for p, cr in zip(prompts, rolled)], -100),

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 Retruncate prompts for rolled KL completions

The prompt is truncated against its own completion before batching, but the KL sequence replaces that completion with another row's potentially much longer one. For example, a long-prompt/short-completion row followed by a short-prompt/long-completion row can make p + cr exceed max_length even though both original pairs fit, inflating memory and computing the KL baseline outside the configured context limit. Retruncate each prompt against its rolled completion before constructing kl_ids and kl_labels.

Useful? React with 👍 / 👎.

Comment on lines +4362 to +4366
# Enter training mode so training-gated modules (e.g. LoRA dropout) are
# active, in case the model was left in eval mode by a prior
# generation/evaluation. The reference forward disables adapters by
# scale, not by eval mode, so this does not affect it.
model.train()

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 Honor gradient checkpointing in the KTO loop

MLXKTOConfig inherits gradient_checkpointing=True, but this standalone path enters training without calling the base trainer's apply_gradient_checkpointing setup or providing an equivalent implementation. On the larger models for which this default is needed—especially with KTO's multiple forwards—the setting is silently ignored and training can OOM; apply and clean up checkpointing around the loop, or reject the option explicitly.

Useful? React with 👍 / 👎.

Comment on lines +4458 to +4463
# Honor the documented save_steps=0 contract (save at end of training),
# matching MLXTrainer.train(). Without this the trained adapters live
# only in memory and are lost on process exit unless the caller knows to
# call save_model() by hand. KTO is LoRA-only, so save_model() takes the
# adapter path.
if self.is_main_process:

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 Save periodic KTO checkpoints when requested

When save_steps > 0, the KTO loop still performs only this final save and never creates checkpoint-N directories, so the inherited save_steps and save_total_limit settings are silently ineffective. A long KTO run interrupted before completion loses all persisted progress; add the periodic adapter-save branch used by MLXTrainer, or reject nonzero save_steps rather than accepting it.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
self._train_loss_history.append(train_loss)
self._kl_history.append(acc_kl)
self._global_step = step + 1
if args.logging_steps and (step % max(int(args.logging_steps), 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 Apply logging_steps to the one-based step number

This condition tests the zero-based step, so logging_steps=10 logs optimizer steps 1, 11, 21, and so on instead of 10, 20, 30, and it may omit the final step entirely. Test (step + 1) % logging_steps and include the final-step case to match the base trainer's logging contract.

Useful? React with 👍 / 👎.

args.beta, args.desirable_weight, args.undesirable_weight,
)

loss, grad = nn.value_and_grad(model, loss_fn)(model)

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 Apply required architecture patches before KTO gradients

This direct value_and_grad path bypasses the architecture setup performed by MLXTrainer.train, including patch_gated_delta for Qwen3.5, Qwen3-Next, and Kimi Linear models and the differentiable fallback for fused MRoPE. On those supported architectures the unpatched fused kernels have no usable VJP, so KTO reaches this line and fails on its first backward pass. Run the same model-specific training setup and cleanup around the KTO loop.

Useful? React with 👍 / 👎.

Comment on lines +4192 to +4193
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.

P1 Badge Reject or implement streaming KTO datasets

MLXKTOConfig inherits the streaming option, but _build_kto_batches always exhausts the entire dataset into rows before training. With a true streaming or unbounded iterable and streaming=True, training never reaches the first optimizer step or grows memory without bound even when max_steps is finite. Reject streaming mode explicitly or consume and batch the iterator incrementally.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4411 to +4417
contrib = tree_map(lambda g: g / grad_accum, grad)
if acc_grad is None:
acc_grad = contrib
else:
acc_grad = tree_map(lambda a, g: a + g, acc_grad, contrib)
acc_loss += float(loss) / grad_accum
acc_kl += float(kl_scalar) / grad_accum

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 Weight accumulated gradients by micro-batch size

_build_kto_batches retains a trailing batch whenever it has at least two rows, so an accumulation window can contain both a full batch and a smaller tail. Because each batch loss is already a per-example mean and this code gives every micro-batch the same 1 / grad_accum weight, examples in the smaller tail receive disproportionately large gradients; for batch sizes four and two, each tail example is weighted twice as much. Accumulate loss sums and example counts, or scale each contribution by its row count.

Useful? React with 👍 / 👎.

Comment thread unsloth_zoo/mlx/trainer.py Outdated
Comment on lines +4080 to +4081
@dataclass
class MLXKTOConfig(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 Preserve MLXTrainingConfig warmup initialization

Decorating this subclass with @dataclass generates a new initializer and bypasses MLXTrainingConfig.__init__, so _unsloth_mlx_warmup_steps_explicit is never recorded. In particular, when a caller explicitly sets warmup_steps=5 (the class default) together with a positive warmup_ratio, _resolve_warmup_steps treats the step count as implicit and uses the ratio instead, unlike the base config. Delegate through the base initializer or reproduce its explicit-field bookkeeping.

Useful? React with 👍 / 👎.

Bring KTO up to date with main (incl. unslothai#873 HF-style callbacks). Clean
auto-merge, 0 conflicts: KTO's additions live in a self-contained trainer.py
region main did not touch.
CSV-ingested KTO datasets store the desirable/undesirable label as a string.
bool("false") and bool("0") are both True, so every undesirable row was
silently flipped to desirable. Add _kto_parse_label to parse the common string
spellings (and pass real bool/int/float through), and reject ambiguous values.
Regression test asserts string labels "false"/"0" -> [False, False].
_kto_tokenize_row set keep=0 when a completion was longer than max_length,
dropping the prompt but leaving the whole completion, so the returned sequence
exceeded max_length whenever max_completion_length was unset. Cap the completion
to max_length in that case. Regression test asserts len(p)+len(c) <= max_length.
_build_kto_batches materializes the whole dataset to form the mismatched-pair
KL batches, so a streaming/IterableDataset was silently exhausted up front.
Reject it (via args.streaming or a bare iterable-without-__len__) with a clear
NotImplementedError, mirroring the accepted ORPO/DPO streaming guard. Regression
test covers both the config flag and a generator passed directly.
MLXKTOConfig accepts any loss_type, but the training path always calls _kto_loss
and never reads the field, so a caller selecting another TRL KTO variant would
silently get plain KTO. Fail fast in __init__. Regression test asserts a non-kto
loss_type raises.
MLXKTOTrainer accepted arbitrary **kwargs and discarded them, so a caller passing
TRL-style ref_model=... had it silently dropped while KTO compared against its
own adapter-off forward. Reject ref_model loudly in __init__. Regression test
asserts it raises.
_kto_tokenize_row tokenized completions with add_special_tokens=False and never
appended EOS, so the inherited append_eos=True was ignored and the model was
never trained to emit EOS. Append it (guarded against a double EOS / no-eos
tokenizer, gated on append_eos), and -- deviating from TRL, which truncates then
appends and can drop it -- preserve the trailing EOS through both the
max_completion_length and max_length caps. Regression test covers append,
no-double, disabled, and EOS survival under each cap.
The KTO loop bypasses the base trainer's architecture setup, so a gated-delta
model (Qwen3.5/Qwen3-Next) fails in the backward with a missing custom-kernel
VJP, and a VLM fails at batch-build (no pad_token_id) and forward
(LanguageModelOutput, not raw logits). Both were already hard failures; reject
them up front (model_has_gated_delta_layers / a processor with image_processor)
for UX clarity. Not a correctness fix. Regression test covers both.
KTO's reference is the adapter-off (LoRA scale=0) forward of the same model, so
a trainable non-LoRA tensor (directly-trained lm_head/embed_tokens, non-LoRA
bias) drifts during training and corrupts the reference. Add a local
_kto_model_has_non_lora_trainable_params (verbatim mirror of unslothai#832's helper, kept
local to avoid depending on that unmerged branch) and reject unconditionally in
train(). Unlike DPO/GRPO there is no reference_free escape -- this is a
structural limit, per Daniel's 16:20Z unslothai#832 reversal. Regression test covers the
predicate and the guard.
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.
The log condition tested the zero-based step counter, so logging_steps=N logged
at steps 1, N+1, 2N+1, ... instead of N, 2N, ... Gate on (step + 1) to match
MLXTrainer's one-based cadence. Metal regression test asserts logging_steps=2
over 4 steps logs at [2,4].
_kto_loss is a mean over the batch's rows, but the grad-accum loop weighted each
micro-batch equally (grad / grad_accum). A window mixing a full batch and the
trailing size-2 batch therefore over-weighted the small one. Weight each
micro-batch's gradient/loss/KL by its row count and normalize by the window
total, matching the pair/example-count accumulation Daniel adopted for
ORPO/DPO/GRPO. Metal regression test (lr=0) asserts the window loss equals the
example-weighted mean, not the equal mean.

@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: c2f1057217

ℹ️ 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 +8577 to +8578
epochs = max(int(getattr(args, "num_train_epochs", 1) or 1), 1)
total_steps = max((len(batches) * epochs) // grad_accum, 1)

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 Count ragged KTO epoch windows

When max_steps is unset and the number of prepared KTO batches is not divisible by gradient_accumulation_steps, this floors away the epoch tail. For example, 5 batches with gradient_accumulation_steps=2 and num_train_epochs=1 reports total_steps=2; after two full windows the loop stops before the 5th batch, so that data never contributes. The preceding int(...) also truncates fractional epoch counts. Use the base trainer's ceil-per-epoch step calculation and flush partial epoch windows instead of dropping them.

Useful? React with 👍 / 👎.

"trainer's patch_gated_delta setup, so the backward fails with a "
"missing custom-kernel VJP. Use a non-gated-delta model for KTO."
)
if hasattr(self.tokenizer, "image_processor"):

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 VLM processors passed separately

When a VLM caller passes the text tokenizer as tokenizer and the multimodal object via the existing processor= argument, this check misses it because image_processor is on self.processor, not on self.tokenizer. The unsupported KTO VLM run then proceeds into text-only batch construction/model calls instead of raising the intended clear error, ignoring image inputs or failing downstream; include self.processor/model-based VLM detection in this guard.

Useful? React with 👍 / 👎.

if stop:
break
for batch in batches:
if step >= total_steps or self.stop_requested:

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 Clear stale KTO stop requests before retraining

When a KTO run is cancelled through trainer.stop_requested = True, the loop breaks here but train() never clears the flag or advances _run_generation on exit. Reusing the same trainer then immediately satisfies this condition at step 0 and returns a successful output after saving the previous adapters, even though the metric state is reset above; mirror the base trainer's prior-generation stop clearing and run-end generation update.

Useful? React with 👍 / 👎.

else:
epochs = max(int(getattr(args, "num_train_epochs", 1) or 1), 1)
total_steps = max((len(batches) * epochs) // grad_accum, 1)
optimizer = self._build_optimizer(total_steps)

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 the Metal memory guard on KTO runs

On Apple Silicon, the base MLX trainer applies _configure_memory_limits() before training and restores the process-global caps in a finally, honoring both the default guard and explicit memory_limit_gb / cache_limit_gb / wired_limit_gb. This KTO setup goes straight from optimizer construction into the multi-forward training loop, so those inherited guard settings are ignored and large KTO runs can exceed the configured Metal cap instead of failing cleanly; configure and restore the guard around this loop, or reject those options for KTO.

Useful? React with 👍 / 👎.

Comment on lines +8426 to +8427
def __init__(self, model, tokenizer, train_dataset, args=None,
eval_dataset=None, processor=None, **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 Reject unsupported KTO constructor kwargs

Because this constructor still accepts arbitrary **kwargs but only inspects ref_model, a ported KTO call with unsupported standard arguments such as data_collator= or callbacks= succeeds while those objects are discarded; the run then uses the built-in raw-row batcher and has no HF callback handler, so custom collation, cancellation, or logging hooks never run. After handling the supported kwargs, reject any leftovers or wire them through explicitly.

Useful? React with 👍 / 👎.

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.

1 participant