From 9ac8c7cc5030703ebf43647704249dce56525475 Mon Sep 17 00:00:00 2001 From: "Michael J. Williams" Date: Wed, 28 Oct 2026 20:20:25 +0100 Subject: [PATCH 1/3] DOC(mutation): document differential-evolution proposal noise --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 347e32f..5ba18c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Documentation sidebar links to sequince-density and sequince-examples under Related Packages. +### Changed + +- Add Gaussian noise to differential evolution proposals (`noise_scale=1e-5` in + mutation coordinates) to escape collapsed ensembles. + ## [0.1.0b1] - 2026-10-23 First public beta release. From 66ca0fa35d2a071055485badeea4932562b80987 Mon Sep 17 00:00:00 2001 From: "Michael J. Williams" Date: Wed, 28 Oct 2026 20:20:54 +0100 Subject: [PATCH 2/3] API(sampling): default sampling helpers to differential evolution Use DE for standard, persistent, and nested SMC, standalone MCMC, and additional posterior draws. Update default-method tests and documentation while retaining explicit MiniPCN selection. --- CHANGELOG.md | 1 + docs/index.rst | 2 +- docs/installation.rst | 4 +-- .../advanced_workflows/mcmc_sampler.rst | 8 ++--- .../mutation_configuration.rst | 13 +++++-- src/sequince/mcmc.py | 4 +-- src/sequince/posterior.py | 5 +-- src/sequince/prebuilt_samplers.py | 17 ++++++---- .../test_persistent_sampling.py | 3 -- .../test_standard_sampling.py | 1 - tests/test_mcmc.py | 18 ++++++++++ tests/test_posterior.py | 32 +++++++++++++++++ tests/test_prebuilt_samplers.py | 34 ++++++++++++++++--- tests/test_steps/test_persistent_dynamic.py | 1 - 14 files changed, 113 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba18c8..94a3dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Add Gaussian noise to differential evolution proposals (`noise_scale=1e-5` in mutation coordinates) to escape collapsed ensembles. +- Default all sampling helpers to differential evolution. ## [0.1.0b1] - 2026-10-23 diff --git a/docs/index.rst b/docs/index.rst index fc3fffd..66128ad 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ Install from PyPI:: pip install sequince ``sequince`` targets Python 3.11+. Its core dependencies are installed -automatically. The example below uses the included MiniPCN mutation backend, +automatically. The example below uses differential-evolution mutation, see :doc:`installation` for all optional integrations supported. .. _quick-start: diff --git a/docs/installation.rst b/docs/installation.rst index d758c08..381ad9e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -9,7 +9,7 @@ Installation The core installation includes NumPy, SciPy, MiniPCN, and the Array API compatibility packages. It supports NumPy-based sampling with the default -MiniPCN mutation backend. +differential-evolution mutation method. Optional integrations --------------------- @@ -39,7 +39,7 @@ Install extras for the integrations you need, for example: - Corner plots For example, install ``sequince[emcee]`` to select emcee mutation instead of -the default MiniPCN backend. See +the default differential-evolution method. See :doc:`user_guide/sampling_options/mutation_configuration` for mutation-specific requirements. diff --git a/docs/user_guide/advanced_workflows/mcmc_sampler.rst b/docs/user_guide/advanced_workflows/mcmc_sampler.rst index ddb9dea..ad30901 100644 --- a/docs/user_guide/advanced_workflows/mcmc_sampler.rst +++ b/docs/user_guide/advanced_workflows/mcmc_sampler.rst @@ -10,7 +10,8 @@ Creating a sampler ------------------ Use :func:`~sequince.mcmc.make_mcmc` with a vectorised log-density. Each row of -the particle array is an independent chain: +the particle array is a chain. The default differential-evolution method +updates interacting chains and requires at least four particles: .. code-block:: python @@ -30,7 +31,6 @@ the particle array is an independent chain: mcmc = make_mcmc( log_prob, - mutation="minipcn", n_steps=1_000, ) result = mcmc.run(initial_state, rng=rng) @@ -63,8 +63,8 @@ Choosing a mutation backend --------------------------- ``make_mcmc`` accepts the same named mutation backends used by the standard SMC -helper: ``"minipcn"``, ``"emcee"``, ``"blackjax"``, and -``"metropolis-hastings"``. Backend-specific settings are passed through +helper: ``"differential-evolution"`` (default), ``"minipcn"``, ``"emcee"``, +``"blackjax"``, and ``"metropolis-hastings"``. Backend-specific settings are passed through ``mutation_kwargs``: .. code-block:: python diff --git a/docs/user_guide/sampling_options/mutation_configuration.rst b/docs/user_guide/sampling_options/mutation_configuration.rst index f03c5f7..6de2430 100644 --- a/docs/user_guide/sampling_options/mutation_configuration.rst +++ b/docs/user_guide/sampling_options/mutation_configuration.rst @@ -144,9 +144,16 @@ Prebuilt samplers ----------------- :func:`~sequince.prebuilt_samplers.make_standard_bayesian_smc` accepts a method -name from the table in ``mutation`` (default: ``"minipcn"``), a transition count in -``mutation_n_steps`` (default: five), and constructor options in -``mutation_kwargs``. Use ``mutation="none"`` or ``None`` to disable mutation. +name from the table in ``mutation`` (default: ``"differential-evolution"``), a +transition count in ``mutation_n_steps`` (default: five), and constructor +options in ``mutation_kwargs``. Use ``mutation="none"`` or ``None`` to disable mutation. + +Differential evolution requires at least four particles. Its default +``noise_scale=1e-5`` adds independent Gaussian noise in mutation coordinates. +Use ``mutation="minipcn"`` to select MiniPCN explicitly. The persistent SMC, +nested-sampling SMC, standalone MCMC, and additional-posterior-sample helpers +also default to differential evolution. Functional nested-sampling execution +requires an explicit ``mutation="minipcn"`` or ``mutation="blackjax"``. The helper defaults to ``particle_transform="affine"``; configure it through ``particle_transform_kwargs`` or pass a transform object. With default ESS diff --git a/src/sequince/mcmc.py b/src/sequince/mcmc.py index 15bb500..495acd8 100644 --- a/src/sequince/mcmc.py +++ b/src/sequince/mcmc.py @@ -218,14 +218,14 @@ def resume_from( def make_mcmc( log_prob_fn: Callable[..., Array | float], *, - mutation: str | MutationStep = "minipcn", + mutation: str | MutationStep = "differential-evolution", n_steps: int = 100, mutation_kwargs: dict[str, Any] | None = None, particle_transform: ParticleTransform | None = None, callbacks: Sequence[Callable[..., None]] | None = None, checkpoint_writers: Sequence[CheckpointWriter] | None = None, ) -> MarkovChainMonteCarlo: - """Construct standalone MCMC using an existing Sequince mutation backend.""" + """Construct standalone MCMC, defaulting to differential evolution.""" kwargs = dict(mutation_kwargs or {}) kwargs["store_chains"] = True mutation_step = get_mutation_step( diff --git a/src/sequince/posterior.py b/src/sequince/posterior.py index f3fc504..1789988 100644 --- a/src/sequince/posterior.py +++ b/src/sequince/posterior.py @@ -410,7 +410,7 @@ def draw_additional_samples( result: SMCResult, rng: "RandomGenerator", n_particles: int, - mutation: MutationStrategy | None = "minipcn", + mutation: MutationStrategy | None = "differential-evolution", mutation_n_steps: int = 10, mutation_kwargs: dict[str, Any] | None = None, particle_transform: ParticleTransform | str | None = "affine", @@ -440,7 +440,8 @@ def draw_additional_samples( n_particles: Desired total number of particles after drawing additional samples. mutation: - Mutation strategy to apply to the new particles. + Mutation strategy to apply to the new particles. Defaults to + ``"differential-evolution"``. mutation_n_steps: Number of mutation steps to apply if mutation is used. mutation_kwargs: diff --git a/src/sequince/prebuilt_samplers.py b/src/sequince/prebuilt_samplers.py index 955dfda..0946790 100644 --- a/src/sequince/prebuilt_samplers.py +++ b/src/sequince/prebuilt_samplers.py @@ -74,7 +74,7 @@ def make_standard_bayesian_smc( always_resample: bool | None = None, resample_at_end: bool = True, resampling_kwargs: Dict[str, Any] | None = None, - mutation: MutationStrategy | SMCStep = "minipcn", + mutation: MutationStrategy | SMCStep = "differential-evolution", mutation_n_steps: int = 5, mutation_kwargs: Dict[str, Any] | None = None, always_mutate: bool | None = None, @@ -141,8 +141,8 @@ def make_standard_bayesian_smc( Extra keyword arguments forwarded to the resampling step constructor. mutation : str or SMCStep, optional Mutation strategy to use. Accepts ``"blackjax"``, ``"emcee"``, ``"eryn"``, - ``"metropolis-hastings"``, ``"minipcn"`` (default), ``"none"``, or a - pre-built ``SMCStep`` instance. + ``"differential-evolution"`` (default), ``"metropolis-hastings"``, + ``"minipcn"``, ``"none"``, or a pre-built ``SMCStep`` instance. mutation_n_steps : int, optional Number of mutation steps to run per iteration. mutation_kwargs : dict[str, Any] or None, optional @@ -328,7 +328,7 @@ def make_persistent_bayesian_smc( resampling: ResamplingStrategy | ResamplingStep = "systematic", resample_at_end: bool = True, resampling_kwargs: Dict[str, Any] | None = None, - mutation: MutationStrategy | SMCStep = "minipcn", + mutation: MutationStrategy | SMCStep = "differential-evolution", mutation_n_steps: int = 5, mutation_kwargs: Dict[str, Any] | None = None, particle_transform: ParticleTransform | str | None = "affine", @@ -343,6 +343,9 @@ def make_persistent_bayesian_smc( ) -> tuple[SequentialMonteCarlo, TemperedPosterior, MetaData]: """Return an SMC runner configured for persistent sampling. + Mutation defaults to differential evolution; other mutation options follow + :func:`make_standard_bayesian_smc`. + ``n_particles`` is the active population size drawn from the complete persistent archive on every iteration. ``target_ess`` defaults to twice ``n_particles``. ``target_ess`` and ``target_total_ess`` are absolute @@ -561,7 +564,7 @@ def make_nested_sampling_smc( resampling_ess_threshold: float | None = None, always_resample: bool = True, resampling_kwargs: Dict[str, Any] | None = None, - mutation: MutationStrategy | SMCStep | None = "minipcn", + mutation: MutationStrategy | SMCStep | None = "differential-evolution", mutation_n_steps: int = 5, mutation_kwargs: Dict[str, Any] | None = None, particle_transform: ParticleTransform | str | None = "affine", @@ -632,7 +635,9 @@ def make_nested_sampling_smc( Functional execution requires JAX arrays, unconditional resampling with unchanged population size, logging_frequency=0, and traceable mutation - (BlackJAX or MiniPCN). Use callbacks for host-side logging. Initialize with + (BlackJAX or MiniPCN). Select one explicitly with ``mutation``; the default + differential-evolution mutation is not supported for functional nested + sampling. Use callbacks for host-side logging. Initialize with run_functional or initialize_functional before compiling iterations. Compiled schedules must use backend operations and return a scalar array; positive infinity finalizes the live tail. Python StopIteration is supported diff --git a/tests/integration_tests/test_persistent_sampling.py b/tests/integration_tests/test_persistent_sampling.py index d569869..efc7f39 100644 --- a/tests/integration_tests/test_persistent_sampling.py +++ b/tests/integration_tests/test_persistent_sampling.py @@ -34,7 +34,6 @@ def test_make_persistent_bayesian_smc_reaches_gaussian_posterior( target_ess=target_ess, resample_at_end=resample_at_end, mutation_n_steps=3, - mutation_kwargs={"step_fn": "tpcn"}, ) initial_particles = np.random.default_rng(1234).normal( loc=0.0, @@ -103,7 +102,6 @@ def test_runner_accumulates_posterior_batches_until_target_total_ess( target_total_ess=target_total_ess, resample_at_end=False, mutation_n_steps=2, - mutation_kwargs={"step_fn": "tpcn"}, ) persist = smc._steps[0] # noqa: SLF001 assert isinstance(persist, PersistParticles) @@ -200,7 +198,6 @@ def make_sampler( target_total_ess=target_total_ess, resample_at_end=resample_at_end, mutation_n_steps=2, - mutation_kwargs={"step_fn": "tpcn"}, checkpoint_writers=checkpoint_writers, callbacks=callbacks, ) diff --git a/tests/integration_tests/test_standard_sampling.py b/tests/integration_tests/test_standard_sampling.py index 902916f..42434a5 100644 --- a/tests/integration_tests/test_standard_sampling.py +++ b/tests/integration_tests/test_standard_sampling.py @@ -21,7 +21,6 @@ def test_standard_sampler_reaches_gaussian_posterior( gaussian_log_prior, gaussian_log_likelihood, mutation_n_steps=3, - mutation_kwargs={"step_fn": "tpcn"}, logging_frequency=0, ) initial_particles = np.random.default_rng(1234).normal( diff --git a/tests/test_mcmc.py b/tests/test_mcmc.py index 8911a4b..8fa8c99 100644 --- a/tests/test_mcmc.py +++ b/tests/test_mcmc.py @@ -13,6 +13,7 @@ from sequince.checkpoint import HDF5CheckpointWriter, SMCCheckpoint from sequince.steps.mutation import ( CallableMutationKernel, + DifferentialEvolutionMutation, MetropolisHastingsMutation, MutationStep, get_mutation_step, @@ -131,3 +132,20 @@ def test_direct_mutation_step_is_configured_to_store_chains( assert mutation.store_chains assert result.chain.shape == (2, len(simple_particles), 2) + + +def test_default_mcmc_matches_explicit_differential_evolution( + simple_particles, standard_normal_log_prob +) -> None: + state = ParticleState(simple_particles) + default = make_mcmc(standard_normal_log_prob, n_steps=3) + explicit = make_mcmc( + standard_normal_log_prob, + mutation="differential-evolution", + n_steps=3, + ) + assert isinstance(default.mutation, DifferentialEvolutionMutation) + actual = default.run(state, rng=321) + expected = explicit.run(state, rng=321) + assert actual.chain.shape == (3, len(simple_particles), 2) + np.testing.assert_array_equal(actual.chain, expected.chain) diff --git a/tests/test_posterior.py b/tests/test_posterior.py index 04f9230..3b1ab11 100644 --- a/tests/test_posterior.py +++ b/tests/test_posterior.py @@ -838,3 +838,35 @@ def evaluate(beta): (1.0, [-np.inf, -2.0, -2.0]), ]: np.testing.assert_allclose(evaluate(jnp.asarray(beta)), expected) + + +def test_additional_samples_default_matches_differential_evolution( + context_factory, standard_normal_log_prob +) -> None: + from orng import RandomGenerator + + particles = np.random.default_rng(12).normal(size=(16, 2)) + posterior = TemperedPosterior( + log_prior=standard_normal_log_prob, + log_likelihood=lambda values: np.zeros(values.shape[0]), + ) + + def draw(mutation: str | None = None) -> np.ndarray: + kwargs = {} if mutation is None else {"mutation": mutation} + result = SMCResult( + [ParticleState(particles.copy())], + context_factory(metadata={"beta": 1.0}), + ) + state, _ = draw_additional_samples( + result=result, + rng=RandomGenerator(seed=13), + n_particles=32, + mutation_n_steps=3, + posterior=posterior, + **kwargs, + ) + return state.particles + + np.testing.assert_array_equal( + draw(), draw(mutation="differential-evolution") + ) diff --git a/tests/test_prebuilt_samplers.py b/tests/test_prebuilt_samplers.py index 67112e5..994b20e 100644 --- a/tests/test_prebuilt_samplers.py +++ b/tests/test_prebuilt_samplers.py @@ -8,11 +8,13 @@ from sequince import ParticleState, PersistentParticleState, TemperedPosterior from sequince.prebuilt_samplers import ( + make_nested_sampling_smc, make_persistent_bayesian_smc, make_standard_bayesian_smc, ) from sequince.steps import ( AdaptiveTemperedWeighting, + DifferentialEvolutionMutation, FixedTemperedWeighting, LoggingStep, MiniPCNMutation, @@ -51,7 +53,7 @@ def test_make_standard_bayesian_smc_builds_default_steps() -> None: assert len(steps) == 4 assert isinstance(steps[0], AdaptiveTemperedWeighting) assert isinstance(steps[1], SystematicResampling) - assert isinstance(steps[2], MiniPCNMutation) + assert isinstance(steps[2], DifferentialEvolutionMutation) assert isinstance(steps[3], LoggingStep) @@ -193,9 +195,9 @@ def test_make_standard_bayesian_smc_forwards_resample_at_end() -> None: @pytest.mark.parametrize( ("weighting_criterion", "always_mutate", "expected_type"), [ - ("ess", None, MiniPCNMutation), + ("ess", None, DifferentialEvolutionMutation), ("cess", None, MutateIfResampled), - ("cess", True, MiniPCNMutation), + ("cess", True, DifferentialEvolutionMutation), ], ) def test_make_standard_bayesian_smc_configures_conditional_mutation( @@ -214,7 +216,7 @@ def test_make_standard_bayesian_smc_configures_conditional_mutation( mutation = smc._steps[2] # noqa: SLF001 assert isinstance(mutation, expected_type) if isinstance(mutation, MutateIfResampled): - assert isinstance(mutation.mutation, MiniPCNMutation) + assert isinstance(mutation.mutation, DifferentialEvolutionMutation) def test_make_standard_bayesian_smc_wraps_custom_mutation() -> None: @@ -318,7 +320,7 @@ def test_make_persistent_bayesian_smc_builds_fixed_size_pipeline() -> None: assert isinstance(steps[0], PersistParticles) assert isinstance(steps[1], PersistentAdaptiveTemperedWeighting) assert isinstance(steps[2], SystematicResampling) - assert isinstance(steps[3], MiniPCNMutation) + assert isinstance(steps[3], DifferentialEvolutionMutation) assert steps[1]._target_ess == target_ess # noqa: SLF001 assert steps[1]._target_total_ess == target_total_ess # noqa: SLF001 assert steps[2]._n_samples == n_particles # noqa: SLF001 @@ -626,3 +628,25 @@ def test_make_persistent_bayesian_smc_rejects_custom_resampling_step() -> None: n_particles=4, resampling=resampling, ) + + +@pytest.mark.parametrize( + "factory", + [ + make_standard_bayesian_smc, + make_persistent_bayesian_smc, + make_nested_sampling_smc, + ], +) +@pytest.mark.parametrize("mutation", [None, "minipcn"]) +def test_prebuilt_mutation_default_and_explicit_minipcn( + factory, mutation: str | None +) -> None: + kwargs = {} if mutation is None else {"mutation": mutation} + sampler, _, _ = factory( + _quadratic_log_prior, _zero_log_likelihood, **kwargs + ) + expected = ( + DifferentialEvolutionMutation if mutation is None else MiniPCNMutation + ) + assert any(isinstance(step, expected) for step in sampler.config.steps) diff --git a/tests/test_steps/test_persistent_dynamic.py b/tests/test_steps/test_persistent_dynamic.py index 8a14334..bcd3601 100644 --- a/tests/test_steps/test_persistent_dynamic.py +++ b/tests/test_steps/test_persistent_dynamic.py @@ -298,7 +298,6 @@ def build(writers: Any = ()) -> Any: dynamic=True, target_total_ess=8 * n, mutation_n_steps=3, - mutation_kwargs={"step_fn": "tpcn"}, logging_frequency=0, checkpoint_writers=writers, resample_at_end=False, From ecccc66ae64a8123fda362973a60eb9e3d1ba045 Mon Sep 17 00:00:00 2001 From: "Michael J. Williams" Date: Wed, 28 Oct 2026 20:21:13 +0100 Subject: [PATCH 3/3] API(deps): make MiniPCN an optional dependency Expose the minipcn extra and retain it in test environments. Keep default DE sampling usable without MiniPCN, with installation guidance when MiniPCN mutation or its adaptive wrapper is selected. --- CHANGELOG.md | 1 + docs/installation.rst | 9 +- .../advanced_workflows/mcmc_sampler.rst | 2 +- .../mutation_configuration.rst | 3 +- pyproject.toml | 5 +- .../steps/mutation/adaptive/minipcn.py | 10 ++- src/sequince/steps/mutation/minipcn.py | 10 ++- tests/test_optional_dependencies.py | 89 +++++++++++++++++++ 8 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/test_optional_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 94a3dc9..db821c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Add Gaussian noise to differential evolution proposals (`noise_scale=1e-5` in mutation coordinates) to escape collapsed ensembles. +- Make MiniPCN optional. - Default all sampling helpers to differential evolution. ## [0.1.0b1] - 2026-10-23 diff --git a/docs/installation.rst b/docs/installation.rst index 381ad9e..f050232 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -7,8 +7,8 @@ Installation python -m pip install sequince -The core installation includes NumPy, SciPy, MiniPCN, and the Array API -compatibility packages. It supports NumPy-based sampling with the default +The core installation includes NumPy, SciPy, and the Array API compatibility +packages. It supports NumPy-based sampling with the default differential-evolution mutation method. Optional integrations @@ -25,6 +25,8 @@ Install extras for the integrations you need, for example: * - Extra - Provides + * - ``minipcn`` + - MiniPCN mutation and adaptive MiniPCN wrappers * - ``jax`` - JAX arrays and functional execution * - ``blackjax`` @@ -38,7 +40,8 @@ Install extras for the integrations you need, for example: * - ``corner`` - Corner plots -For example, install ``sequince[emcee]`` to select emcee mutation instead of +For example, install ``sequince[minipcn]`` to use MiniPCN mutation, or +``sequince[emcee]`` to select emcee mutation instead of the default differential-evolution method. See :doc:`user_guide/sampling_options/mutation_configuration` for mutation-specific requirements. diff --git a/docs/user_guide/advanced_workflows/mcmc_sampler.rst b/docs/user_guide/advanced_workflows/mcmc_sampler.rst index ad30901..b18bf5f 100644 --- a/docs/user_guide/advanced_workflows/mcmc_sampler.rst +++ b/docs/user_guide/advanced_workflows/mcmc_sampler.rst @@ -65,7 +65,7 @@ Choosing a mutation backend ``make_mcmc`` accepts the same named mutation backends used by the standard SMC helper: ``"differential-evolution"`` (default), ``"minipcn"``, ``"emcee"``, ``"blackjax"``, and ``"metropolis-hastings"``. Backend-specific settings are passed through -``mutation_kwargs``: +``mutation_kwargs``. Install ``sequince[minipcn]`` before selecting MiniPCN: .. code-block:: python diff --git a/docs/user_guide/sampling_options/mutation_configuration.rst b/docs/user_guide/sampling_options/mutation_configuration.rst index 6de2430..38f295e 100644 --- a/docs/user_guide/sampling_options/mutation_configuration.rst +++ b/docs/user_guide/sampling_options/mutation_configuration.rst @@ -150,7 +150,8 @@ options in ``mutation_kwargs``. Use ``mutation="none"`` or ``None`` to disable m Differential evolution requires at least four particles. Its default ``noise_scale=1e-5`` adds independent Gaussian noise in mutation coordinates. -Use ``mutation="minipcn"`` to select MiniPCN explicitly. The persistent SMC, +Install ``sequince[minipcn]`` and use ``mutation="minipcn"`` to select MiniPCN +explicitly. The persistent SMC, nested-sampling SMC, standalone MCMC, and additional-posterior-sample helpers also default to differential evolution. Functional nested-sampling execution requires an explicit ``mutation="minipcn"`` or ``mutation="blackjax"``. diff --git a/pyproject.toml b/pyproject.toml index 888995f..b80b272 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ dependencies = [ "scipy>=1.16", "matplotlib>=3.5", "numpy>=1.25.2", - "minipcn>=0.3.0a1", "array-api-compat>=1.13", "array-api-extra>=0.10", "optree>=0.19", @@ -39,6 +38,9 @@ include = ["sequince*"] [tool.setuptools_scm] [project.optional-dependencies] +minipcn = [ + "minipcn>=0.3.0a1", +] jax = [ "jax>0.5.3", "jaxlib" @@ -62,6 +64,7 @@ hdf5 = [ [dependency-groups] test-core = [ + "sequince[minipcn]", "pytest>=7.0", "pytest-cov>=4.0", "corner", diff --git a/src/sequince/steps/mutation/adaptive/minipcn.py b/src/sequince/steps/mutation/adaptive/minipcn.py index 391e661..3d67cb0 100644 --- a/src/sequince/steps/mutation/adaptive/minipcn.py +++ b/src/sequince/steps/mutation/adaptive/minipcn.py @@ -91,7 +91,15 @@ def __init__( getattr(resolved_policy, "initialise", None) ) or not callable(getattr(resolved_policy, "update", None)): raise TypeError("policy must implement initialise() and update().") - from minipcn import Sampler + try: + from minipcn import Sampler + except ModuleNotFoundError as exc: + if exc.name != "minipcn": + raise + raise ImportError( + "MiniPCN mutation requires the 'minipcn' extra; install it " + "with `pip install 'sequince[minipcn]'`." + ) from exc if not hasattr(Sampler, "init_step_state"): raise ImportError( diff --git a/src/sequince/steps/mutation/minipcn.py b/src/sequince/steps/mutation/minipcn.py index c04752f..8d2b94a 100644 --- a/src/sequince/steps/mutation/minipcn.py +++ b/src/sequince/steps/mutation/minipcn.py @@ -309,7 +309,15 @@ def _prepare_sampler( rng: Any = None, ) -> tuple[Any, Array, ParticleTransform | None, Any, StepContext]: """Prepare the latent particles and MiniPCN sampler.""" - from minipcn import Sampler + try: + from minipcn import Sampler + except ModuleNotFoundError as exc: + if exc.name != "minipcn": + raise + raise ImportError( + "MiniPCN mutation requires the 'minipcn' extra; install it " + "with `pip install 'sequince[minipcn]'`." + ) from exc xp = namespace(state.particles) block = self._proposal_block(context) diff --git a/tests/test_optional_dependencies.py b/tests/test_optional_dependencies.py new file mode 100644 index 0000000..b251938 --- /dev/null +++ b/tests/test_optional_dependencies.py @@ -0,0 +1,89 @@ +"""Core sampling must work without optional mutation packages.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +import tomllib +from pathlib import Path + + +def test_minipcn_is_an_optional_dependency() -> None: + metadata = tomllib.loads( + (Path(__file__).parents[1] / "pyproject.toml").read_text() + )["project"] + assert not any( + dep.startswith("minipcn") for dep in metadata["dependencies"] + ) + assert metadata["optional-dependencies"]["minipcn"] == ["minipcn>=0.3.0a1"] + + +def test_core_sampling_without_minipcn() -> None: + # A fresh interpreter ensures no previously imported MiniPCN can mask a + # missing dependency. The finder also blocks any MiniPCN submodule import. + script = textwrap.dedent( + """ + import importlib.abc + import sys + + class MissingMiniPCN(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "minipcn" or fullname.startswith("minipcn."): + raise ModuleNotFoundError( + "No module named 'minipcn'", name="minipcn" + ) + + sys.meta_path.insert(0, MissingMiniPCN()) + + import numpy as np + from sequince import ParticleState, make_mcmc, make_standard_bayesian_smc + from sequince.steps import ( + AdaptiveMiniPCNMutation, MiniPCNMutation, StepContext, + ) + + def log_prob(x, context=None): + return -0.5 * np.sum(x**2, axis=-1) + + particles = np.random.default_rng(42).normal(size=(32, 2)) + result = make_mcmc(log_prob, n_steps=3).run( + ParticleState(particles.copy()), rng=43 + ) + assert result.chain.shape == (3, 32, 2) + assert np.isfinite(result.chain).all() + + sampler, _, metadata = make_standard_bayesian_smc( + log_prob, log_prob, logging_frequency=0 + ) + result = sampler.run( + ParticleState(particles.copy()), rng=44, metadata=metadata + ) + assert result.context.metadata["beta"] == 1 + assert np.isfinite(result.final_state.particles).all() + assert not any( + name == "minipcn" or name.startswith("minipcn.") + for name in sys.modules + ) + + mutation = MiniPCNMutation(log_prob) + for use_minipcn in ( + lambda: mutation(ParticleState(particles), StepContext(t=0), 45), + lambda: AdaptiveMiniPCNMutation(mutation), + ): + try: + use_minipcn() + except ImportError as exc: + assert "pip install 'sequince[minipcn]'" in str(exc), str(exc) + else: + raise AssertionError("MiniPCN must require the optional extra") + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + env={**os.environ, "OPENBLAS_NUM_THREADS": "1"}, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr