Skip to content

Generic Blackjax external sampler: run any blackjax MCMC algorithm via pm.sample#8353

Open
twiecki wants to merge 18 commits into
mainfrom
claude/pymc-blackjax-inference-ab7d63
Open

Generic Blackjax external sampler: run any blackjax MCMC algorithm via pm.sample#8353
twiecki wants to merge 18 commits into
mainfrom
claude/pymc-blackjax-inference-ab7d63

Conversation

@twiecki

@twiecki twiecki commented Jul 9, 2026

Copy link
Copy Markdown
Member

What

A generic external sampler that can run any blackjax MCMC algorithm on a PyMC model, following up on the design discussion in #7880 (comment) and #7699:

pm.sample(external_sampler=pm.external.Blackjax())                          # nuts + window adaptation
pm.sample(external_sampler=pm.external.Blackjax("mclmc"))                   # bespoke MCLMC tuner
pm.sample(external_sampler=pm.external.Blackjax("mala", step_size=0.01))    # static params, burn-in only
pm.sample(external_sampler=pm.external.Blackjax(blackjax.barker, step_size=0.2))

How

  • No hardcoded algorithm list. Names resolve against the blackjax namespace and are validated via isinstance(obj, blackjax.GenerateSamplingAPI) — new blackjax algorithms (slice sampling, nested sampling, GIST in 1.6) will work with zero changes here. SMC/SGMCMC/VI and cross-chain adaptation raise informative errors pointing at alternatives.
  • Kwargs routing via signature introspection (borrowed from bayeux): kwargs are split between the algorithm factory and the adaptation procedure based on their signatures. Blackjax(...).get_kwargs() shows every accepted parameter with its default; unknown kwargs and missing required parameters (e.g. Blackjax("mala") without step_size) raise at construction time with the full accepted-parameter listing. This addresses the nuts_sampler_kwargs discoverability problem.
  • Curated adaptation table (the only hand-maintained part): window (also low_rank/pathfinder) for nuts/hmc/dynamic_hmc, mclmc_find_L_and_step_size for MCLMC, adaptation=None + plain burn-in for static-parameter algorithms (mala, barker, rmh, ghmc, ...). User-overridable via adaptation=.
  • Generic stats: the blackjax Info namedtuple is flattened generically (scalars only) and renamed to ArviZ conventions (diverging, tree_depth, n_steps, lp, ...), so every algorithm gets sensible sample_stats without per-algorithm code.
  • Reuses PyMC's machinery: jaxified logp, jittered initial points, vmap/pmap chain mapping, and constrained-space postprocessing with coords/dims — the idata assembly of sample_jax_nuts is extracted into a shared _postprocess_and_build_idata helper (pure refactor, existing numpyro/blackjax NUTS behavior unchanged).

On the pm.sample side this adds only a minimal external_sampler= hook (mirroring #7880): validation of clashes (step, nuts_sampler, trace, callback, leftover NUTS kwargs → error directing users to configure the sampler at construction) and delegation. The existing nuts_sampler= path is untouched, so this PR does not depend on resolving the argument-migration questions in #7880 — it can rebase onto it when that lands.

Scope

Phase 1 = plain MCMC + single-chain adaptation. Deliberately excluded (informative errors): SMC (needs split logprior/loglikelihood; incubating in pymc-extras), SGMCMC (needs minibatch plumbing), MEADS/ChEES (cross-chain adaptation, needs a second driver — can be a follow-up), VI (fit-then-sample shaped, belongs next to pymc_extras.fit).

@ricardoV94 re "not sure if we want 15 string versions into external.blackjax": the class accepts either the string or the blackjax object itself (Blackjax(blackjax.mclmc)), so the string is never load-bearing — happy to add per-algorithm aliases (pm.external.blackjax.MCLMC()) on top if preferred; that's a thin layer over this driver either way.

Tests

tests/sampling/test_external.py: parameter recovery for nuts/hmc/mclmc, runs for mala/barker, kwargs routing, stat renames, and the full error matrix (unknown algorithm/kwarg, missing required params, VI/SMC/SGMCMC rejection, discrete models, pm.sample clashes, model mismatch).

🤖 Generated with Claude Code

twiecki and others added 2 commits July 9, 2026 22:34
Implements pm.sample(external_sampler=pm.external.Blackjax(...)), a
generic driver that can run any blackjax MCMC algorithm on a PyMC model,
building on the ExternalSampler API drafted in #7880.

- Algorithms are resolved dynamically against the blackjax namespace
  (validated via GenerateSamplingAPI), so new blackjax algorithms work
  without changes on the PyMC side.
- Keyword arguments are routed to the algorithm factory or the
  adaptation procedure by signature introspection, with a discoverable
  get_kwargs(); unknown/missing parameters raise informative errors at
  construction time.
- A small curated table maps algorithms to their default adaptation:
  window adaptation for the HMC family, the bespoke
  mclmc_find_L_and_step_size tuner for MCLMC, and plain burn-in for
  static-parameter algorithms (mala, barker, rmh, ...).
- Sampler stats are flattened generically from the blackjax Info
  namedtuples and renamed to ArviZ conventions.
- The idata assembly of sample_jax_nuts is extracted into a reusable
  _postprocess_and_build_idata helper shared with the new driver.

SMC, SGMCMC, cross-chain adaptation (MEADS/ChEES) and variational
algorithms are out of scope and raise errors pointing at alternatives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.38095% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.74%. Comparing base (56384e5) to head (d6345fd).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pymc/sampling/samplers/blackjax.py 93.54% 14 Missing ⚠️
pymc/sampling/samplers/base.py 78.57% 6 Missing ⚠️
pymc/sampling/jax.py 95.45% 2 Missing ⚠️
pymc/sampling/mcmc.py 90.90% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##             main    #8353    +/-   ##
========================================
  Coverage   91.73%   91.74%            
========================================
  Files         128      131     +3     
  Lines       20672    20970   +298     
========================================
+ Hits        18963    19238   +275     
- Misses       1709     1732    +23     
Files with missing lines Coverage Δ
pymc/__init__.py 100.00% <100.00%> (ø)
pymc/sampling/samplers/__init__.py 100.00% <100.00%> (ø)
pymc/sampling/jax.py 93.53% <95.45%> (+0.05%) ⬆️
pymc/sampling/mcmc.py 91.87% <90.90%> (-0.04%) ⬇️
pymc/sampling/samplers/base.py 78.57% <78.57%> (ø)
pymc/sampling/samplers/blackjax.py 93.54% <93.54%> (ø)

... and 1 file with indirect coverage changes

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

twiecki and others added 2 commits July 9, 2026 23:09
Importing blackjax at test collection time initialized the jax backend
before pymc.sampling.jax could set XLA_FLAGS, leaving a single host
device and crashing every chain_method="parallel" test in the session.
Import pymc.sampling.jax first in the test module, and make the Blackjax
sampler fall back to vectorized chains with a warning when there are
fewer devices than chains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Module-type names cannot be re-exported through pymc.sampling.__all__
(test_root_namespace excludes modules); import it at the root like
pm.math and pm.gp instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@twiecki
twiecki marked this pull request as ready for review July 9, 2026 20:47
@twiecki
twiecki requested review from ricardoV94 July 9, 2026 20:47
@ricardoV94

Copy link
Copy Markdown
Member

please address #7880 (comment)

Address review feedback on the external sampler API:

- pm.external.blackjax.<algorithm>() factories (e.g.
  pm.external.blackjax.mclmc()) via module __getattr__/__dir__,
  enumerated dynamically from the installed blackjax version, as a
  discoverable per-algorithm alternative to the algorithm string.
- Every pm.sample argument now has an explicit disposition on the
  external sampler path: forwarded, mapped (init -> jitter/no-jitter,
  fixing the semantics discussed in #8352), warned-and-ignored
  (non-mappable init strings, discard_tuned_samples=False,
  keep_warning_stat, backend/compile_kwargs), or rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@twiecki

twiecki commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

Both points from that comment are now addressed in cb0bac8:

1. Per-algorithm entries instead of algorithm strings. pm.external.blackjax now exposes exactly what you sketched:

pm.sample(external_sampler=pm.external.blackjax.mclmc())
pm.sample(external_sampler=pm.external.blackjax.nuts(target_accept=0.9))
pm.sample(external_sampler=pm.external.blackjax.mala(step_size=0.01))

Implemented via module __getattr__/__dir__, so the names are enumerated dynamically from the installed blackjax version (tab-completable, each with its own docstring stating the default adaptation) — no hardcoded list to maintain, and new blackjax algorithms appear automatically. Unsupported families give targeted errors at attribute access (pm.external.blackjax.sgld → "SGMCMC requires minibatch gradient estimators..."). The string form on the Blackjax class still exists underneath, but the namespace is now the discoverable user-facing surface. And re "not the final leaf": the sub-choices below the algorithm (adaptation scheme, algorithm/adaptation parameters) are explicit kwargs with signature-introspected validation and a get_kwargs() listing, rather than more string encoding.

2. The pm.sample argument problem. I did the same exercise you did for nutpie, once, in the shared external_sampler path in pm.sample — so the next external sampler inherits the policy instead of re-deciding it. Every argument now has an explicit disposition:

disposition arguments
forwarded draws/tune/chains, initvals, random_seed, progressbar, quiet, var_names, idata_kwargs, compute_convergence_checks, jitter_max_retries
mapped init: only the jitter/no-jitter part is meaningful (external samplers do their own mass-matrix adaptation), so "jitter+adapt_diag"jitter=True, "adapt_diag"/"adapt_full"jitter=False — i.e. the #8352 semantics; non-mappable strings ("advi", ...) warn
warned & ignored discard_tuned_samples=False, keep_warning_stat, backend/compile_kwargs
rejected step, nuts_sampler, trace, callback, return_inferencedata=False, and any leftover NUTS/sampler kwargs (with a pointer to configure the sampler at construction)

The table is documented in the external_sampler docstring and each row is covered by a test (TestSampleArgumentMapping). The ExternalSampler.sample contract stays minimal: run-level arguments only, with jitter/jitter_max_retries as optional keywords a sampler may ignore if it controls initialization itself (as nutpie would).

I agree this doesn't dissolve your underlying discomfort with the pm.sample funnel — but it makes the funnel's promises explicit and testable rather than implicit, which I think is the prerequisite for either keeping it or splitting it later.

🤖 Generated with Claude Code

@twiecki

twiecki commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

For reviewers wondering about the red CI runs and revert/reapply commits on Jul 10: the alternative_backends segfaults were not caused by this PR. Root cause and evidence:

Symptom: deterministic native segfault inside XLA CPU compilation (backend_compile_and_load, no Python frames in the crashing thread) at the first jax-compiling test of the session (test_dirichlet_multinomial[JAX]), starting Jul 10.

Root cause: pyarrow 25.0.0 (released Jul 9/10) is pulled in unpinned as a transitive dependency of the nutpie @ git+...@main install in the alternative_backends job. import pymc loads pyarrow's native libarrow into the process via pymc → pytensorf → pandas → pandas.compat.pyarrow, and pyarrow 25's x86_64 manylinux wheel then breaks XLA's in-process CPU compiler (most plausibly a bundled-LLVM/shared-object symbol clash).

Evidence:

  • pyarrow 24.0.0 (Jul 9): 2/2 green. pyarrow 25.0.0 (Jul 10): 7/7 segfault — including a control run with this branch's diff fully reverted (tree byte-identical to the last green commit), on three different runner hosts, on both Intel Xeon 8573C and AMD EPYC 7763, with otherwise byte-identical conda envs, runner image, and nutpie git commit (d3ffb85).
  • Pinning pyarrow<25 (only change): green.
  • Not reproducible on macOS or aarch64 Linux with pyarrow 25 loaded — consistent with an x86_64-manylinux-specific bundling issue.
  • Ruled out along the way: this PR's code (control), CPU SKU (crashes on the old AMD fleet too; though note GitHub is quietly rolling out Emerald Rapids 8573C runners that are crashing other projects this week, e.g. CRASH in DR on all signal-using tests on certain Github runners DynamoRIO/dynamorio#7996 — a red herring here), --xla_cpu_max_isa=AVX2 (no effect), and jax-backend init at pytest collection time (deferring all jax/blackjax imports out of collection didn't help — kept anyway as test hygiene).

In this PR: the job now installs "pyarrow<25" alongside nutpie (commit e10b8ea, with a comment). Since the unpinned install lives in tests.yml on main, every PR's alternative_backends job will start failing the same way — happy to split the one-line pin into a separate fast-track PR so other PRs aren't blocked, and to file the upstream issue (jax-ml/jax + apache/arrow) with the minimal repro.

🤖 Generated with Claude Code

@ricardoV94

Copy link
Copy Markdown
Member
  1. The pm.sample argument problem. I did the same exercise you did for nutpie, once, in the shared external_sampler path in pm.sample — so the next external sampler inherits the policy instead of re-deciding it. Every argument now has an explicit disposition:

Disagree quite a lot with the conclusion. jitter and init are very specific to nuts, backend shouldn't be ignored (e.g., nutpie takes it). From this iteration I have 0 confidence on the bot wisdom, so next iteration try to make an argument for each. Not all external samplers are NUTS and not all support a single backend.

twiecki and others added 3 commits July 12, 2026 11:30
Restores pm.sample(external_sampler=...) (reverted in the previous
commit) but relocates all argument interpretation out of the shared
path, per review: the meaning of pm.sample's arguments is
sampler-specific, so no global policy can be right.

- pm.sample now only enforces sampler-independent constraints
  (step/nuts_sampler clashes, model identity, trace/callback/
  return_inferencedata rejections) and forwards everything else
  verbatim: init, jitter_max_retries, discard_tuned_samples,
  keep_warning_stat, compile_kwargs.
- ExternalSampler.sample defines the forwarded set as a closed
  contract (no **kwargs): a newly forwarded argument breaks every
  sampler loudly, forcing an explicit disposition.
- Blackjax.sample documents and implements its dispositions with the
  reasoning for each: init is reduced to its jitter/no-jitter part
  (blackjax runs its own adaptation; jitter_initial_points overrides
  directly); a non-jax compile mode/backend raises instead of being
  ignored (unlike nutpie, blackjax can only consume a jax logp);
  discard_tuned_samples=False and keep_warning_stat warn with reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@twiecki

twiecki commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

@ricardoV94 You're right that the previous iteration imposed NUTS semantics globally — a shared disposition table in pm.sample was the wrong shape no matter how its entries were tuned. Reworked in 1f172c7:

pm.sample no longer interprets anything. The external_sampler path enforces only sampler-independent constraints (step/nuts_sampler mutual exclusion, model identity, trace/callback/return_inferencedata=False) and forwards the rest verbatim: init, compile_kwargs, discard_tuned_samples, keep_warning_stat, jitter_max_retries.

Each sampler owns its dispositions, with the argument for each. ExternalSampler.sample defines the forwarded set as a closed contract (deliberately no **kwargs — if pm.sample grows a new forwarded argument, every sampler breaks loudly and must decide). Blackjax.sample documents its reasoning per argument:

  • init — reinterpreted: blackjax runs its own adaptation, so only the jitter/no-jitter part of the NUTS init strategy carries over ("jitter+..." jitters, "adapt_..." doesn't, "advi..."/"map" warn). A dedicated jitter_initial_points argument sets the same thing directly and wins.
  • compile_kwargs/backend — validated, not ignored: blackjax can only consume a jax logp, so backend="numba" now raises instead of being silently overridden — whereas a future Nutpie subclass consumes exactly this argument, which is why the decision can't live in pm.sample.
  • discard_tuned_samples=False, keep_warning_stat=True — warn, with the reason stated (warmup draws are discarded by the scan loop to bound memory; no SamplerWarning stat is produced).

Each disposition has a test (TestBlackjaxArgumentDispositions), plus a signature test asserting the closed contract.

🤖 Generated with Claude Code

@twiecki

twiecki commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@ricardoV94 I share your frustration, let me know if it got it this time. If not perhaps we'll scratch it.

@ricardoV94

ricardoV94 commented Jul 13, 2026

Copy link
Copy Markdown
Member

You won't mind my bot reply?

Draft reply for #8353

This iteration is better. Dropping the shared disposition table and making each sampler own its arguments fixed what I complained about last round. But I still don't want to land the pm.sample(external_sampler=...) hook as is. The problem shows in the new ExternalSampler.sample contract itself: the closed signature still forwards init and jitter_max_retries, NUTS vocabulary that every future sampler is now obligated to disposition. The exercise I did for nutpie hasn't been done once and for all, it has been turned into a per-sampler chore.

The root issue is that pm.sample is married to NUTS. It has arguments (init, n_init, jitter_max_retries, nuts_sampler, ...) that only make sense for one sampler, so every other sampler routed through it has to reject, reinterpret or "map" them. The proof of concept criteria I keep coming back to is twofold: the internal NUTS can be expressed as a regular sampler through the same interface, with no special casing in pm.sample, and nuts_sampler="nutpie" | "numpyro" can be removed entirely without loss of functionality. If that can't be done (which doesn't mean we'll do it already), the interface won't be good enough for the other samplers either.

A note on naming before the options: I renamed external_sampler= to sampler= throughout. I don't think we need the external prefix. If we support arbitrary external samplers we can support arbitrary internal ones the same way (pm.sample(sampler=pm.SMC())), and internal/external shouldn't be a meaningful distinction at the interface level anyway. Same reasoning for the namespace in the examples below: pm.blackjax instead of pm.external.blackjax, the module layout shouldn't advertise the distinction either.

Options as I see them:

Option 1: sampler= with configured objects (the current PR shape)

pm.sample(sampler=pm.blackjax.nuts(target_accept=0.9), jitter_max_retries=10)

The user has to split arguments between the sampler constructor and pm.sample, and which argument goes where is a historical accident of what pm.sample happened to list explicitly. It also keeps two classes of samplers: NUTS configured through pm.sample, everyone else through constructor objects. And pm.sample keeps having to police clashes per sampler.

It also keeps the disposition problem from above: pm.sample keeps promising NUTS arguments like init and jitter_max_retries, so every new sampler has to go through the reject/reinterpret/ignore exercise for each of them.

Option 2: same shape, but all NUTS arguments demoted out of pm.sample

Constructor takes algorithm configuration, pm.sample takes run configuration:

pm.sample(sampler=pm.blackjax.nuts(target_accept=0.9), draws=1000, chains=4, random_seed=1)

This is more defensible than option 1 and I don't think it's terrible. My hesitation: the object is instantiated with half the information it needs and then sits there waiting for pm.sample to hand it the other half (model, draws, chains, seeds) before it can do any real work. It's a configuration holder.

The test is the criterion above: init and n_init are algorithm configuration (NUTS initialization strategy), so under the rule they must be deprecatable from pm.sample and move into the NUTS constructor, and a user must be able to configure the PyMC NUTS sampler exactly as pm.sample configures it today. jitter_max_retries is the borderline case, several samplers jitter their initial points, but that means it belongs to each of those constructors, not that pm.sample gets to keep it.

The rule also still forces a shared run vocabulary on everyone: every sampler must accept draws, tune and chains through pm.sample, and not every algorithm is naturally parameterized that way (a nested sampler runs until convergence, SMC has no tune in the NUTS sense).

Option 3: pm.sample as a thin dispatcher

One flat kwargs namespace per sampler, and pm.sample forwards everything verbatim (the sampler is passed unconfigured, since its configuration is just the kwargs):

pm.sample(sampler=pm.blackjax.mclmc, draws=1000, step_size=0.1)

This fixes the splitting but hurts discoverability: the signature lives on one class while you type the arguments into another function. help(pm.sample) and tab completion can't tell you much. And at that point pm.sample isn't adding anything over calling the sampler directly.

Option 4: no sampler argument in pm.sample at all

Each sampler exposes its own sample entry point with a full explicit signature, so arguments are read in the same place they are passed:

pm.sample(draws=1000, ...)  # the default/auto sampler
pm.nuts.sample(...)
pm.nutpie.nuts.sample(...)  # Or: if a library only offers one sampler, it could be pm.library.sample()
pm.blackjax.mclmc.sample(model=m, draws=1000, step_size=0.1)
pm.numpyro.nuts.sample(model=m, draws=1000, target_accept=0.9)
pm.smc.sample(...)

This is the only option with a single flat call site: options 1-3 all spread the arguments over two places in some form, and the funnel is what forces sampler objects into existence in the first place. Standalone flat sample functions don't need them.

It's also somewhat orthogonal to 1-3. Many of these already exist (sample_blackjax_nuts, nutpie.sample, sample_smc), the logic has to live somewhere like this in the end anyway, and these entry points likely stay regardless of what we do with pm.sample. So the real question is whether this should be the only option, with no sampler= in pm.sample at all. I don't think that flies on its own: nuts_sampler= got added precisely because people like a single entry point, and I expect that pressure to persist even if the funnel is pure sugar over the flat API.

To avoid N drifting copies of the run-level signature: the run-level arguments (draws, tune, chains, seeds, progressbar, idata_kwargs, ...) should live in one shared place, a common base class or run-options struct that every sample entry point takes, with a test asserting the signatures stay in sync. The internal sampler uses the same one, which is also what makes the demotion criterion testable. Same idea one level down: samplers of the same family (all the NUTS implementations, say) should share an abstract base class so the same argument doesn't drift into three spellings across libraries.

What about step=?

step= should be subsumable by the same interface, even if we keep the old argument as a shorthand: the internal step-method machinery (step assignment, CompoundStep, the python sampling loop) is itself a sampler, so something like pm.sample(sampler=pm.StepSampler(step=pm.Metropolis())).

This also gives pm.sample(sampler=pm.NUTS()) a meaning distinct from pm.NUTS as a step sampler, something we have no way to express today. As a step, pm.NUTS is just the transition kernel, combinable with others in a CompoundStep. As a sampler it means the full pipeline (and one we could implement natively with way less overhead: today, even when NUTS runs alone, every iteration copies the point and hands it back to the generic loop, only to receive it again at the next step). To connect this with the demotion in option 2: the NUTS constructor stores the initialization configuration (init, n_init, jitter), and the sampler applies it once it has the run context. That split is what would finally fix issues like #5658 ("init_nuts not used when CompoundStep is used") instead of hardcoding init_nuts in pm.sample, and it's also how the demotion criterion gets met concretely: the internal sampler is just pm.StepSampler(step=pm.NUTS(...)).

The obstacle is just refactoring: native step samplers today eagerly create a lot of things at construction (model binding, compiled functions), so they can't act as pure configuration holders. They need a config phase separated from a build(model) phase. The same applies to this PR's ExternalSampler.__init__, which binds the model via modelcontext(model) at construction: if the constructor is pure algorithm configuration it shouldn't touch the model at all, the model should only arrive at sample time.

What I think is best

2 + 4. Every sampler gets a standalone flat sample entry point with a full explicit signature (option 4), built on the shared run-level base so signatures can't drift. On top of that, pm.sample keeps a sampler= funnel under option 2's rule: constructor takes algorithm configuration, pm.sample takes run configuration, and the NUTS-specific arguments (init, n_init, jitter_max_retries, nuts_sampler) get deprecated from pm.sample into the respective constructors. The funnel is then sugar over the flat API rather than a second code path, so the two can't disagree. And samplers that don't fit the shared run vocabulary just don't join the funnel, they live only as their flat entry point.

For this PR concretely: the blackjax driver internals (kwargs routing, adaptation table, stats flattening, shared idata postprocessing) are good and worth keeping as they are. I'd have it ship both surfaces already: the flat pm.blackjax.<algo>.sample(...) entry points and the pm.sample(sampler=...) funnel as sugar over them. Only the demotion gets postponed to a follow-up. In the meantime the funnel simply rejects the NUTS-specific arguments whenever sampler= is passed, instead of forwarding them for the sampler to disposition. That drops init, jitter_max_retries, and friends from the sampler's run contract, which becomes run configuration only.

To make the end state concrete, pm.sample after all the deprecations are through:

pm.sample(
    # what to sample
    draws=1000,
    tune=1000,
    chains=None,
    cores=None,
    model=None,  # taken from context if not given
    # who samples it and how it's compiled
    sampler=None,  # None resolves a default
    backend=None,  # also steers the default sampler choice
    compile_kwargs=None,
    # how the run goes
    initvals=None,
    random_seed=None,
    progressbar=True,  # not eagerly a bool, nutpie already handles more refined specs
    # what comes back
    discard_tuned_samples=True,
    keep_warning_stat=False,
    var_names=None,
    idata_kwargs=None,
    compute_convergence_checks=True,
)

Everything left is run configuration that any sampler in the funnel understands (discard_tuned_samples and keep_warning_stat included, those are sampler agnostic). Gone: init, n_init, jitter_max_retries, nuts_sampler, step. sampler=None resolves a default the way we do today: fully continuous models get the preferred NUTS implementation, taking backend into account like nuts_sampler does now (backend="numba" picks nutpie, backend="jax" one of the jax NUTS), and models with discrete variables get pm.StepSampler() with automatic step assignment.

And the shape of the classes behind it:

class Sampler(ABC):
    """Base for everything pm.sample(sampler=...) accepts.

    The constructor is algorithm configuration only: nothing model-bound,
    nothing compiled. Model and run configuration arrive at sample time.
    """

    @abstractmethod
    def sample_from_init(
        self,
        *,
        model,
        draws,
        tune,
        chains,
        cores,
        initvals,
        random_seed,
        progressbar,
        discard_tuned_samples,
        keep_warning_stat,
        var_names,
        idata_kwargs,
        compute_convergence_checks,
        compile_kwargs,
    ) -> InferenceData:
        """Exactly pm.sample's signature minus sampler/backend (enforced by a test).

        Not called `sample` so it can't be confused with the flat entry points.
        """


class ExternalSampler(Sampler):
    """Shared implementation logic for samplers living in other libraries."""

    package: str  # checked at construction, raises with an install hint
    version_range: str | None = None  # optional, e.g. ">=1.2,<2"

    def __init__(self):
        require_installed(self.package, self.version_range)


class Blackjax(ExternalSampler):
    package = "blackjax"

    def __init__(self, algorithm="nuts", adaptation=None, jitter_initial_points=True, **algorithm_kwargs):
        # resolve algorithm in the blackjax namespace, route kwargs by
        # signature introspection, raise on unknown or missing ones.
        # No model, nothing compiled.
        ...

    def sample_from_init(self, *, model, draws, ...):
        # jaxify the model logp, adaptation + sampling loop, return idata
        ...


class AlgorithmEntry:
    """What pm.blackjax.nuts actually is: both surfaces in one object."""

    def __call__(self, **algorithm_kwargs) -> Blackjax:
        # option 2 surface: pm.blackjax.nuts(target_accept=0.9)
        return Blackjax(self.algorithm, **algorithm_kwargs)

    def sample(self, *, model=None, draws=1000, ..., **algorithm_kwargs) -> InferenceData:
        # option 4 surface: pm.blackjax.nuts.sample(...), one flat call
        return self(**algorithm_kwargs).sample_from_init(model=model, draws=draws, ...)


class StepSampler(Sampler):
    """The native machinery: step assignment, CompoundStep, the python loop."""

    def __init__(self, step=None):  # None: automatic assignment at sample time
        ...

Note where the internal/external distinction ended up: it's implementation inheritance, not interface. ExternalSampler adds nothing to the sample_from_init contract, it just carries shared conveniences like the dependency check, and StepSampler sits beside it as a plain Sampler, which is the demotion criterion in class form. The AlgorithmEntry at the bottom is what makes the two surfaces one implementation: calling pm.blackjax.nuts(...) gives the configured object for the funnel, calling pm.blackjax.nuts.sample(...) is the flat entry point, and the second is defined in terms of the first. The method behind the funnel is deliberately not named sample, only the flat entry points get that name, so a reader never wonders which of the two they're looking at.

Implements the design agreed in review: every sampler exposes a flat
entry point with a full explicit signature, and pm.sample keeps a
sampler= funnel as sugar over it, under the rule "constructor takes
algorithm configuration, pm.sample takes run configuration".

- Sampler ABC: constructor is algorithm configuration only (nothing
  model-bound, nothing compiled); the model and run configuration
  arrive at sample time via sample_from_init, whose signature is the
  shared run contract (kept in sync with pm.sample by a test). The
  method is deliberately not named `sample` so it cannot be confused
  with the flat entry points.
- ExternalSampler is implementation inheritance only: a dependency
  check (package/version_range) with an install hint, nothing added to
  the Sampler contract.
- pm.blackjax.<algo> is an AlgorithmEntry exposing both surfaces:
  calling it configures a Blackjax sampler for the funnel;
  .sample(...) draws in one flat call, defined in terms of the first
  so the two cannot disagree.
- pm.sample(sampler=...) forwards only run configuration and now
  rejects the NUTS-specific arguments (init, n_init,
  jitter_max_retries) instead of forwarding them for reinterpretation;
  jitter configuration moved into the Blackjax constructor
  (jitter_initial_points, jitter_max_retries). Demoting the NUTS
  arguments out of pm.sample for the internal sampler is left for a
  follow-up.
- Renames: external_sampler= -> sampler=, pm.external.blackjax ->
  pm.blackjax, pymc/sampling/external/ -> pymc/sampling/samplers/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@twiecki

twiecki commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Implemented your 2+4 design in d6345fd, mapping directly onto the sketch:

Both surfaces, one implementation. pm.blackjax.<algo> is an AlgorithmEntry: calling it configures a Blackjax sampler for the funnel, .sample(...) is the flat entry point, defined in terms of the first so the two cannot disagree:

pm.sample(sampler=pm.blackjax.nuts(target_accept=0.9))
pm.blackjax.mclmc.sample(model=m, draws=1000, step_size=0.1)

The class shape. Sampler ABC whose funnel method is sample_from_init (not sample), taking exactly the shared run configuration — model, draws, tune, chains, cores, initvals, random_seed, progressbar, quiet, discard_tuned_samples, keep_warning_stat, var_names, idata_kwargs, compute_convergence_checks, compile_kwargs — with a test asserting it stays a subset of pm.sample's signature, that Blackjax.sample_from_init implements exactly the shared contract, and that the flat entry is the run contract plus **algorithm_kwargs. ExternalSampler is implementation inheritance only: package/version_range dependency check with an install hint.

Constructor is pure configuration. No modelcontext at construction anymore — the model arrives at sample time (from the context if not passed). jitter_initial_points and jitter_max_retries moved into the Blackjax constructor as initialization configuration. The funnel's model-identity check is gone since there is nothing to check.

The funnel rejects NUTS vocabulary instead of forwarding it. With sampler= passed, init, n_init and jitter_max_retries raise with a pointer to the constructor (e.g. pm.blackjax.nuts(jitter_initial_points=False)), so the run contract is run configuration only. progressbar passes through without eager bool-coercion. compile_kwargs/backend remain forwarded run configuration: Blackjax raises on a non-jax mode, a future nutpie sampler consumes it.

Renames throughout: external_sampler=sampler=, pm.external.blackjaxpm.blackjax, pymc/sampling/external/pymc/sampling/samplers/.

Left for follow-ups, per your scoping: demoting init/n_init/nuts_sampler out of pm.sample (with the internal NUTS expressible as pm.StepSampler(step=pm.NUTS(...)) as the demotion criterion), and the StepSampler wrapper itself. One deliberate small deviation: quiet is included in the run contract since pm.sample currently has it — trivial to drop if you'd rather it go.

🤖 Generated with Claude Code

@ricardoV94

Copy link
Copy Markdown
Member

can you open a PR where you apply the refactor to the existing external samplers, and the step_sampler, but don't add all the new blackjax support?

I'd like to review/merge that as a unit first. Add a DeprecationWarning for nuts_sampler (not FutureWarning yet to give a bit more room to transition).

And for the NUTS - specific arguments. Basically refactor all the existing code to the new format. Then we can add the new blackjax samplers. Otherwise I fear code rot, as there won't be any interest in cleaning up when the functionality is already there

jacobpstein added a commit to jacobpstein/pymc that referenced this pull request Jul 13, 2026
pyarrow 25.0.0, pulled in transitively when tests.yml later installs
nutpie via pip, segfaults XLA CPU compilation when both are loaded in
the same process. nutpie only requires pyarrow>=12.0.0 with no upper
bound, so pre-installing a pinned pyarrow here via conda/pip satisfies
that requirement and pip leaves it alone during the nutpie install.

This achieves the same effect as pinning directly in the workflow's
nutpie install step (see PR pymc-devs#8353), without touching
.github/workflows/tests.yml.
@twiecki

twiecki commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

As requested: the refactor-only unit is split out into #8358 (existing samplers + StepSampler behind the shared Sampler interface, nuts_sampler/init deprecations, no new blackjax support). Once that merges I'll rebase this PR down to just the generic blackjax driver on top of it.

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.

2 participants