Skip to content

MLX MCLMC sampler - #743

Open
jessegrabowski wants to merge 59 commits into
pymc-devs:mainfrom
jessegrabowski:mlx-mclmc
Open

jessegrabowski wants to merge 59 commits into
pymc-devs:mainfrom
jessegrabowski:mlx-mclmc

Conversation

@jessegrabowski

Copy link
Copy Markdown
Member

It's based on the blackjax implementation by @junpenglao . Highly experimental, but promising results out of the gate. I'll update with some results on the pymc-models-catalog soon.

_log = logging.getLogger(__name__)

# The dynamics, the integrator schedules, and the adaptation are ported from the blackjax
# isokinetic sampler, so its source is the reference for anything ambiguous 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.

permalink

@codecov-commenter

codecov-commenter commented Aug 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 12.31231% with 876 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.92%. Comparing base (86fac3c) to head (521d31a).
⚠️ Report is 86 commits behind head on main.

Files with missing lines Patch % Lines
pymc_extras/inference/mlx_mclmc/kernel.py 0.00% 501 Missing ⚠️
pymc_extras/inference/mlx_mclmc/mlx_mclmc.py 0.00% 124 Missing ⚠️
pymc_extras/inference/mlx_mclmc/logp.py 0.00% 123 Missing ⚠️
pymc_extras/inference/mlx_mclmc/progress.py 0.00% 90 Missing ⚠️
pymc_extras/inference/mlx_mclmc/settings.py 0.00% 23 Missing ⚠️
pymc_extras/inference/advi/training.py 88.65% 11 Missing ⚠️
pymc_extras/inference/advi/compile.py 90.90% 2 Missing ⚠️
pymc_extras/inference/mlx_mclmc/__init__.py 0.00% 2 Missing ⚠️

❌ Your patch status has failed because the patch coverage (12.31%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main     #743       +/-   ##
===========================================
+ Coverage   51.60%   85.92%   +34.32%     
===========================================
  Files          73      113       +40     
  Lines        8003    11063     +3060     
===========================================
+ Hits         4130     9506     +5376     
+ Misses       3873     1557     -2316     
Files with missing lines Coverage Δ
pymc_extras/inference/advi/__init__.py 100.00% <100.00%> (ø)
pymc_extras/inference/advi/autoguide.py 98.14% <100.00%> (+98.14%) ⬆️
pymc_extras/inference/advi/fit.py 94.44% <100.00%> (ø)
pymc_extras/inference/advi/objective.py 100.00% <100.00%> (+100.00%) ⬆️
pymc_extras/inference/fit.py 93.33% <ø> (+75.15%) ⬆️
pymc_extras/inference/advi/compile.py 94.00% <90.90%> (ø)
pymc_extras/inference/mlx_mclmc/__init__.py 0.00% <0.00%> (ø)
pymc_extras/inference/advi/training.py 86.86% <88.65%> (+86.86%) ⬆️
pymc_extras/inference/mlx_mclmc/settings.py 0.00% <0.00%> (ø)
pymc_extras/inference/mlx_mclmc/progress.py 0.00% <0.00%> (ø)
... and 3 more

... and 45 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ricardoV94

Copy link
Copy Markdown
Member

CC @reubenharry in case you'd like to take a look

@reubenharry

Copy link
Copy Markdown

Very exciting! A quick comment on this code comment:

"Microcanonical Langevin Monte Carlo evolves an isokinetic Hamiltonian: the momentum is held
on the unit sphere and partially refreshed each step, so trajectories decorrelate without a
Metropolis accept step."

This is misleading. The MH step is not what makes standard HMC decorrelate - rather, the resampling of momentum that happens after the MH step is. In fact, HMC with MH can be extended to have Langevin noise.

Let me know if there are any specific questions that I can help with!

Deliberately not registered with fit() or exported from pymc_extras.inference, because importing it requires mlx, which installs only on Apple Silicon.
The previous estimate omitted the -1/(n-1) correction and the trailing rho_hat_even term, which damped the seed-to-seed variation in the adapted L.
Blackjax bumps num_steps2 only for the reported tuning-step total; the adaptation itself runs the unbumped count in both the scan and the readjust.
Blackjax applies handle_nans in the kernel, so it protects sampling as well as adaptation; sample() previously let a nan log-density propagate for the rest of the run.
MLX has no reverse rule for Inverse or Cholesky, so differentiating the funcified logp with mx.value_and_grad failed on any model using them; PyTensor already knows those gradients symbolically.
A finite log-density with a non-finite gradient means the start sits on a boundary or removable singularity; left unchecked it turned the whole run into nan draws with a collapsed step size.
Folding a nan gradient into the Adam moments poisoned every later step, so the ascent silently stopped moving; this holds the whole optimizer state instead, as optax's apply_if_finite does.
The textbook form loses up to 13% in float32 at small delta by subtracting two near-equal log 2s, and the cancellation-free rewrite leans on expm1, which MLX computes less accurately above roughly 0.15.
Hand-written special functions such as gammaln expand the graph enough to exceed the limit, so a model can hit it without doing anything unusual.
…ction

Ports nuts-rs's windowed sqrt(Var[x]/Var[grad]) diagonal and its diagonal-plus-low-rank fit; only the low-rank form can precondition a posterior whose ridges are not axis-aligned, and it is rejected in favour of the diagonal whenever the window does not support it.
Freezing dim lengths and data is the caller's choice, not the sampler's: it rules out pm.set_data, and the frozen copy was what everything downstream reported results against.
Read the Docs builds on Linux, where mlx does not install, and autosummary has to import the function to render its page.
…does

The trainer reseeded a compiled function by overwriting its RNG input storage with jax_typify hardcoded, which only worked on JAX. pymc's rule replaces it: a linker that copies RNGs at compile time gets a fresh compile per seed, and every other linker reseeds the cached function's shared variables through the graph.
@jessegrabowski

Copy link
Copy Markdown
Member Author

step_size_max only ever moves down. A non-finite step sets it to step_size * 0.8 at kernel.py:1138, and because the step size is clipped to it at 1145, a run of bad steps walks the pair down geometrically with nothing to bring it back. Over 200 warmup steps that is 0.8^200, around 4e-20, so it lands on the 1e-12 floor and stays there. _COLLAPSED_STEP_SIZE = 1e-11 in mlx_mclmc.py catches the result and warns.

The finite branch can multiply the cap instead of passing it through unchanged:

step_size_max = mx.where(is_finite, state.step_size_max * 1.02, state.step_size * 0.8)

Once the cap can relax, the Var[E] controller sets the step size on its own merits again. step_size_max starts at inf, so on a run that never diverges the multiply does nothing.

@jessegrabowski

Copy link
Copy Markdown
Member Author

_optimize_to_mode returns the last position rather than the best one it saw. Adam's update is lr * m_hat / sqrt(v_hat), about lr per coordinate whatever the gradient does, so the ascent can travel a long way and nothing in the loop rejects a step that lowers the log-density. On one model the ascent walks 480k logp units downhill, from -17,650 to -496,956, with finite gradients throughout. The controller phases then start from there, which is the condition that drives the step_size_max ratchet above.

The existing guards key on non-finite steps: usable gates the moment update, max_consecutive_skips=5 ends the ascent, and tolerance=1e-4 stops it early. Ordinary finite steps in the wrong direction trip none of them.

Carrying the best point through the loop:

best_logdensity, best_position = -mx.inf, position
...
improved = usable & (logdensity > best_logdensity)
best_logdensity = mx.where(improved, logdensity, best_logdensity)
best_position = mx.where(improved, position, best_position)
...
final_logdensity = logp_and_grad(position)[0]
position = mx.where(
    mx.isfinite(final_logdensity) & (final_logdensity >= best_logdensity), position, best_position
)

Where the ascent behaves the last point is the best point and this changes nothing. On the model above the controller starts at -11,213 instead.

An update-norm threshold does not substitute for it. Adam normalizes the magnitude out of the update, so over a 200-step ascent a run that collapses and one that does not have p99 update norms of 0.1937 and 0.1890 against lr * sqrt(dim) = 0.2000.

This branch has not been deployed

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

Labels

enhancements New feature or request inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants