Add LoRA fine-tuning to the MLX backend#52
Draft
ovuruska wants to merge 2 commits into
Draft
Conversation
Add tabfm/src/mlx/ following the existing backend pattern (model.py + tabfm_v1_0_0.py). The MLX port mirrors the PyTorch module/parameter naming and Linear [out, in] layout, so it loads the PyTorch v1.0.0 safetensors release directly with no weight conversion step. - Faithful forward-path port with the same fp32 upcasts as JAX/PyTorch (RMSNorm, Fourier expansion, PerDimScale softplus, RoPE phases); SDPA at scale=1.0 with checkpoint-loaded RoPE frequencies; weights materialized eagerly at load(). - sklearn wrappers dispatch MLX models through the shared eager execution path (_predict_step_mlx); JAX path untouched. - torch<->mlx parity test asserts < 1e-4 max abs diff in float32 for classification and regression; sklearn fit/predict integration tests. - mlx extra pinned to >=0.31; CI installs it so the tests run on ubuntu-latest. - Also fixes the pytorch extra missing safetensors (load() raised NameError inside PyTorchModelHubMixin on a bare .[pytorch] install) and adds the missing safetensors pin to requirements.txt. Full suite: 76 passed with jax+torch+mlx installed.
Parameter-efficient fine-tuning on top of the frozen pre-trained model: apply_lora freezes the base and wraps target attention projections (default: q_proj/v_proj in the ICL predictor) with trainable low-rank adapters; train_lora optimizes only the adapters with in-context episodes (context/target row splits, loss on targets only, -100 sentinel masking of target labels); fit_lora reuses the sklearn wrapper's fit() preprocessing so training matches the predict-time input distribution; merge_lora folds adapters back into plain Linears for zero-overhead inference; save_adapters/load_adapters round-trip only the adapter tensors. Adapters are float32 regardless of the bf16 base compute (adapter gradients are smaller than bf16 accumulation noise). LoRA-B starts at zero, so an adapted model is exactly the base model at init. Tests: base-frozen invariant (bit-identical after training), loss descent, merge parity, adapter save/load round-trip, wrapper integration, regression path smoke. Full suite: 82 passed. Constraint: mlx core ships no LoRA layer (mlx-lm keeps its own) -- implemented LoRALinear locally with the standard init. Rejected: adapting all towers by default -- the ICL predictor holds most parameters (24 blocks at 8x base width in v1.0.0) and is where label-conditioned adaptation acts; scope="all" remains available. Confidence: high Scope-risk: low -- new module; no existing code path changes except conftest test gating. Not-tested: accuracy gain over zero-shot on the real checkpoint (validation experiment queued; results to be added to the PR).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Parameter-efficient LoRA fine-tuning for the MLX backend. The pre-trained base weights are frozen and never updated — training optimizes only low-rank adapter matrices.
lora.apply_lora(model, rank, alpha, target_keys, scope)— freezes the model first, then wraps target attention projections (defaultq_proj/v_projin the ICL predictor) withLoRALinear;trainable_parameters()is exactly the adapter set.lora.train_lora(...)— in-context episode training: rows split into context (labels visible) and targets, loss on targets only, target labels masked with the-100sentinel (leakage structurally impossible).lora.fit_lora(estimator, X, y, ...)— runs the sklearn wrapper's ordinaryfit()(encoders + ensemble prep; never touches weights), then trains adapters on the same preprocessed matrix the wrapper feeds the model at predict time.lora.merge_lora(model)— folds adapters into the base for zero-overhead inference;save_adapters/load_adaptersround-trip only adapter tensors.Bstarts at zero → adapted model is bit-equivalent to base until trained.Tests (full suite: 82 passed)
Base-frozen invariant (bit-identical after training) · loss descent · merge parity (<1e-4) · adapter save/load round-trip · wrapper end-to-end (
fit_lora→predict) · regression path smoke.Validation on real weights (Apple M1 Pro, 6.5GB checkpoint)
Validation results (real weights, Apple M1 Pro) — honest assessment
The mechanism is verified; an out-of-the-box accuracy gain is not. Across every regime tested, LoRA with this recipe (rank 8, ICL scope, episode training) tracked zero-shot within noise:
Observations:
merge_lora).Where gains may still exist (untested): distribution shift / domain adaptation (train adapters on one distribution, deploy on drifted data), datasets far outside the pre-training prior, larger adaptation budgets (higher rank,
scope="all", more epochs), and regression tasks. Keeping this PR as draft until a gain regime is demonstrated is a reasonable call.