diff --git a/CHANGELOG.md b/CHANGELOG.md index 347e32f9..db821c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ 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. +- Make MiniPCN optional. +- Default all sampling helpers to differential evolution. + ## [0.1.0b1] - 2026-10-23 First public beta release. diff --git a/docs/index.rst b/docs/index.rst index fc3fffdf..66128ad9 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 d758c080..f050232c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -7,9 +7,9 @@ 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 -MiniPCN mutation backend. +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,8 +40,9 @@ Install extras for the integrations you need, for example: * - ``corner`` - Corner plots -For example, install ``sequince[emcee]`` to select emcee mutation instead of -the default MiniPCN backend. See +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 ddb9dea7..b18bf5f1 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,9 +63,9 @@ 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 -``mutation_kwargs``: +helper: ``"differential-evolution"`` (default), ``"minipcn"``, ``"emcee"``, +``"blackjax"``, and ``"metropolis-hastings"``. Backend-specific settings are passed through +``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 f03c5f7c..38f295e9 100644 --- a/docs/user_guide/sampling_options/mutation_configuration.rst +++ b/docs/user_guide/sampling_options/mutation_configuration.rst @@ -144,9 +144,17 @@ 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. +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"``. The helper defaults to ``particle_transform="affine"``; configure it through ``particle_transform_kwargs`` or pass a transform object. With default ESS diff --git a/pyproject.toml b/pyproject.toml index 888995f3..b80b272b 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/mcmc.py b/src/sequince/mcmc.py index 15bb500d..495acd8d 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 f3fc5041..17899882 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 955dfdaa..09467900 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/src/sequince/steps/mutation/adaptive/minipcn.py b/src/sequince/steps/mutation/adaptive/minipcn.py index 391e6615..3d67cb00 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 c04752f8..8d2b94a5 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/integration_tests/test_persistent_sampling.py b/tests/integration_tests/test_persistent_sampling.py index d569869c..efc7f392 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 902916f1..42434a55 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 8911a4b5..8fa8c99c 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_optional_dependencies.py b/tests/test_optional_dependencies.py new file mode 100644 index 00000000..b2519383 --- /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 diff --git a/tests/test_posterior.py b/tests/test_posterior.py index 04f92301..3b1ab11e 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 67112e5c..994b20ef 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 8a143346..bcd36010 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,