From cd38a32807967524f06d83aaa08c886906190b3c Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:24:23 +0100 Subject: [PATCH 1/7] refactor: remove dead search code (SneakierPool, iterations, Zeus discard/thin) and dedupe log_process_state - Delete NonLinearSearch.make_sneakier_pool and the now-orphaned SneakierPool with its FunctionCache helpers. - Delete the unread NonLinearSearch.iterations attribute (the updater owns the count). - Delete the unused discard/thin/chain computation in Zeus._fit. - Keep one log_process_state, a module function in updater.py used by both. Co-Authored-By: Claude Fable 5.1 --- autofit/non_linear/parallel/__init__.py | 1 - autofit/non_linear/parallel/sneaky.py | 133 ------------------ autofit/non_linear/search/abstract_search.py | 45 +----- autofit/non_linear/search/mcmc/zeus/search.py | 8 -- autofit/non_linear/search/updater.py | 53 ++++--- 5 files changed, 33 insertions(+), 207 deletions(-) diff --git a/autofit/non_linear/parallel/__init__.py b/autofit/non_linear/parallel/__init__.py index a856b7989..d222b0c35 100644 --- a/autofit/non_linear/parallel/__init__.py +++ b/autofit/non_linear/parallel/__init__.py @@ -4,4 +4,3 @@ from .process import Process from .sneaky import SneakyJob from .sneaky import SneakyPool -from .sneaky import SneakierPool diff --git a/autofit/non_linear/parallel/sneaky.py b/autofit/non_linear/parallel/sneaky.py index 78c8ca567..d29bf88ca 100644 --- a/autofit/non_linear/parallel/sneaky.py +++ b/autofit/non_linear/parallel/sneaky.py @@ -306,136 +306,3 @@ def __del__(self): process.join(0.5) except Exception as e: logger.exception(e) - - -class FunctionCache: - """ - Singleton class to cache the functions and optional arguments between calls - """ - - -def initializer( - fitness, - prior_transform, - fitness_args, - fitness_kwargs, - prior_transform_args, - prior_transform_kwargs, -): - """ - Initialized function used to initialize the - singleton object inside each worker of the pool - """ - FunctionCache.fitness = fitness - FunctionCache.prior_transform = prior_transform - FunctionCache.fitness_args = fitness_args - FunctionCache.fitness_kwargs = fitness_kwargs - FunctionCache.prior_transform_args = prior_transform_args - FunctionCache.prior_transform_kwargs = prior_transform_kwargs - - -def fitness_cache(x): - """ - Likelihood function call - """ - return FunctionCache.fitness( - x, *FunctionCache.fitness_args, **FunctionCache.fitness_kwargs - ) - - -def prior_transform_cache(x): - """ - Prior transform call - """ - - return FunctionCache.prior_transform( - x, *FunctionCache.prior_transform_args, **FunctionCache.prior_transform_kwargs - ) - - -class SneakierPool: - def __init__( - self, - processes: int, - fitness: Callable, - prior_transform: Optional[Callable] = None, - fitness_args: Optional[Iterable] = None, - fitness_kwargs: Optional[dict] = None, - prior_transform_args: Optional[Iterable] = None, - prior_transform_kwargs: Optional[dict] = None, - ): - self.fitness_init = fitness - self.prior_transform_init = prior_transform - self.fitness = fitness_cache - self.prior_transform = prior_transform_cache - self.fitness_args = fitness_args or () - self.fitness_kwargs = fitness_kwargs or {} - self.prior_transform_args = prior_transform_args or () - self.prior_transform_kwargs = prior_transform_kwargs or {} - self.processes = processes - self.pool = None - - init_args = ( - self.fitness_init, - self.prior_transform_init, - self.fitness_args, - self.fitness_kwargs, - self.prior_transform_args, - self.prior_transform_kwargs, - ) - initializer(*init_args) - - def __enter__(self): - """ - Activate the mp / mpi pool - """ - - logger.info("... using multiprocessing") - - self.pool = fork_context().Pool( - processes=self.processes, - ) - - return self - - def map(self, function: Callable, iterable: Iterable): - """ - Map a function over an iterable using the map method - of the initialized pool. - - Parameters - ---------- - function - A function to map - iterable - An iterable to map over - - """ - return self.pool.map(function, iterable) - - def __exit__(self, exc_type, exc_val, exc_tb): - try: - self.pool.terminate() - except: # noqa - pass - try: - del ( - FunctionCache.fitness, - FunctionCache.prior_transform, - FunctionCache.fitness_args, - FunctionCache.fitness_kwargs, - FunctionCache.prior_transform_args, - FunctionCache.prior_transform_kwargs, - ) - except: # noqa - pass - - @property - def size(self): - return self.njobs - - def close(self): - self.pool.close() - - def join(self): - self.pool.join() diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index b24a9ac81..fcc852546 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -13,7 +13,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Optional, Union, Tuple, List, Dict -import psutil if TYPE_CHECKING: from autofit.database.sqlalchemy_ import sa @@ -38,7 +37,8 @@ # `ITERATIONS_NEVER` is defined in `fitness.py` (so `Fitness.manage_quick_update` can # use it) and re-exported here for existing importers, e.g. `multi_start_gradient`. from autofit.non_linear.fitness import ITERATIONS_NEVER # noqa: F401 -from autofit.non_linear.parallel import SneakyPool, SneakierPool, fork_context +from autofit.non_linear.parallel import SneakyPool, fork_context +from autofit.non_linear.search.updater import log_process_state from autofit.non_linear.paths.abstract import AbstractPaths from autofit.non_linear.paths.database import DatabasePaths from autofit.non_linear.paths.directory import DirectoryPaths @@ -259,8 +259,6 @@ def __init__( "iterations_per_full_update" ]) - self.iterations = 0 - self.silence = silence if conf.instance["general"]["hpc"]["hpc_mode"]: @@ -655,7 +653,7 @@ class represented by model M and gives a score for their fitness. else: logger.info(f"Starting non-linear search with {self.number_of_cores} cores.") logger.info(self.quick_update_message) - self._log_process_state() + log_process_state() model = analysis.modify_model(model) self.paths.model = model @@ -712,28 +710,6 @@ class represented by model M and gives a score for their fitness. return result - @staticmethod - def _log_process_state(): - total_files = 0 - - for process in psutil.process_iter(attrs=["pid"]): - try: - proc_info = process.as_dict(attrs=["pid"]) - logger.debug( - f"Process ID: {proc_info['pid']} has the following open files:" - ) - - open_files = process.open_files() - for file in open_files: - logger.debug(file) - total_files += 1 - - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - if conf.instance["logging"]["total_files_open"]: - logger.info(f"Total Files Open: {total_files}") - def pre_fit_output( self, analysis: Analysis, model: AbstractPriorModel, info: Optional[Dict] = None ): @@ -1669,21 +1645,6 @@ def make_sneaky_pool(self, fitness: Fitness) -> Optional[SneakyPool]: processes=self.number_of_cores, paths=self.paths, fitness=fitness ) - def make_sneakier_pool(self, fitness_function: Fitness, **kwargs) -> SneakierPool: - - self.logger.info(f"number of cores == {self.number_of_cores}") - - if self.number_of_cores > 1: - self.logger.info("Creating SneakierPool...") - else: - self.logger.info("Creating multiprocessing Pool of size 1...") - - pool = SneakierPool( - processes=self.number_of_cores, fitness=fitness_function, **kwargs - ) - - return pool - def __eq__(self, other): return isinstance(other, NonLinearSearch) and self.__dict__ == other.__dict__ diff --git a/autofit/non_linear/search/mcmc/zeus/search.py b/autofit/non_linear/search/mcmc/zeus/search.py index a03908cd9..378987d37 100644 --- a/autofit/non_linear/search/mcmc/zeus/search.py +++ b/autofit/non_linear/search/mcmc/zeus/search.py @@ -281,14 +281,6 @@ def _fit(self, model: AbstractPriorModel, analysis): if samples.converged: iterations_remaining = 0 - auto_correlation_time = zeus.AutoCorrTime( - samples=search_internal.get_chain() - ) - - discard = int(3.0 * np.max(auto_correlation_time)) - thin = int(np.max(auto_correlation_time) / 2.0) - chain = search_internal.get_chain(discard=discard, thin=thin, flat=True) - if self.maxcall is not None: if search_internal.ncall_total > self.maxcall: iterations_remaining = 0 diff --git a/autofit/non_linear/search/updater.py b/autofit/non_linear/search/updater.py index fe71bb12f..b5a08f7da 100644 --- a/autofit/non_linear/search/updater.py +++ b/autofit/non_linear/search/updater.py @@ -27,6 +27,35 @@ logger = logging.getLogger(__name__) +def log_process_state(): + """ + Log the number of files open across all running processes. + + Each process's open files are logged at DEBUG; the total is logged at INFO when + ``logging.total_files_open`` is enabled in the config. Called at the start of a + fit and after every full update, to help diagnose file-handle leaks. + """ + total_files = 0 + + for process in psutil.process_iter(attrs=["pid"]): + try: + proc_info = process.as_dict(attrs=["pid"]) + logger.debug( + f"Process ID: {proc_info['pid']} has the following open files:" + ) + + open_files = process.open_files() + for file in open_files: + logger.debug(file) + total_files += 1 + + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + if conf.instance["logging"]["total_files_open"]: + logger.info(f"Total Files Open: {total_files}") + + class SearchUpdater: """ Handles periodic output updates during a non-linear search. @@ -124,7 +153,7 @@ def update( visualization_time=visualization_time, ) - self._log_process_state() + log_process_state() return samples @@ -334,25 +363,3 @@ def _profile_and_summarize( ) except exc.FitException: pass - - @staticmethod - def _log_process_state(): - total_files = 0 - - for process in psutil.process_iter(attrs=["pid"]): - try: - proc_info = process.as_dict(attrs=["pid"]) - logger.debug( - f"Process ID: {proc_info['pid']} has the following open files:" - ) - - open_files = process.open_files() - for file in open_files: - logger.debug(file) - total_files += 1 - - except (psutil.NoSuchProcess, psutil.AccessDenied): - pass - - if conf.instance["logging"]["total_files_open"]: - logger.info(f"Total Files Open: {total_files}") From d52ea88689d3ba89538c44db15515c3ecd3ad124 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:25:24 +0100 Subject: [PATCH 2/7] fix: Drawer runs under NullPaths; Nautilus no longer mutates iterations_per_full_update - Drawer guards its timer (None under NullPaths) and builds samples from the search_internal argument when given, so a default-paths fit completes. - Nautilus call_search computes the effective update interval locally and passes it to iterations_from instead of assigning to self. - AbstractNest defaults number_of_cores to 1, matching every concrete search. Co-Authored-By: Claude Fable 5.1 --- .../non_linear/search/mle/drawer/search.py | 21 ++++++++++++++-- .../non_linear/search/nest/abstract_nest.py | 2 +- .../non_linear/search/nest/nautilus/search.py | 22 +++++++++++++---- .../non_linear/search/optimize/test_drawer.py | 24 +++++++++++++++++++ 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/autofit/non_linear/search/mle/drawer/search.py b/autofit/non_linear/search/mle/drawer/search.py index f4c9550ee..8d1753537 100644 --- a/autofit/non_linear/search/mle/drawer/search.py +++ b/autofit/non_linear/search/mle/drawer/search.py @@ -144,7 +144,7 @@ def _fit(self, model: AbstractPriorModel, analysis): search_internal = { "parameter_lists": parameter_lists, "log_posterior_list": log_posterior_list, - "time": self.timer.time, + "time": self.timer.time if self.timer else None, } self.paths.save_search_internal( @@ -156,7 +156,24 @@ def _fit(self, model: AbstractPriorModel, analysis): return search_internal, fitness def samples_via_internal_from(self, model, search_internal=None): - search_internal_dict = self.paths.load_search_internal() + """ + Returns a `Samples` object from the Drawer internal results. + + The `search_internal` dictionary returned by `_fit` is used when passed, so a fit with `NullPaths` + (which writes nothing to disk) can still build its samples; otherwise it is loaded from the output folder. + + Parameters + ---------- + model + Maps input vectors of unit parameter values to physical values and model instances via priors. + search_internal + The dictionary of parameter lists, log posteriors and run time that `_fit` returns. + """ + search_internal_dict = ( + search_internal + if search_internal is not None + else self.paths.load_search_internal() + ) parameter_lists = search_internal_dict["parameter_lists"] log_posterior_list = search_internal_dict["log_posterior_list"] diff --git a/autofit/non_linear/search/nest/abstract_nest.py b/autofit/non_linear/search/nest/abstract_nest.py index d86050a51..483a7d807 100644 --- a/autofit/non_linear/search/nest/abstract_nest.py +++ b/autofit/non_linear/search/nest/abstract_nest.py @@ -25,7 +25,7 @@ def __init__( unique_tag: Optional[str] = None, iterations_per_quick_update: Optional[int] = None, iterations_per_full_update: Optional[int] = None, - number_of_cores: Optional[int] = None, + number_of_cores: int = 1, silence: bool = False, session: Optional[sa.orm.Session] = None, initializer: Optional[AbstractInitializer] = None, diff --git a/autofit/non_linear/search/nest/nautilus/search.py b/autofit/non_linear/search/nest/nautilus/search.py index 8dd1054ba..13320851a 100644 --- a/autofit/non_linear/search/nest/nautilus/search.py +++ b/autofit/non_linear/search/nest/nautilus/search.py @@ -581,9 +581,13 @@ def call_search(self, search_internal, model, analysis, fitness): minimum_iterations_per_full_updates = 3 * self.n_live - if self.iterations_per_full_update < minimum_iterations_per_full_updates: + # The effective update interval is local to this call: the search's own + # `iterations_per_full_update` is a constructor argument and is never mutated. + iterations_per_full_update = self.iterations_per_full_update - self.iterations_per_full_update = minimum_iterations_per_full_updates + if iterations_per_full_update < minimum_iterations_per_full_updates: + + iterations_per_full_update = minimum_iterations_per_full_updates logger.info( f""" @@ -600,7 +604,8 @@ def call_search(self, search_internal, model, analysis, fitness): while not finished: iterations, total_iterations = self.iterations_from( - search_internal=search_internal + search_internal=search_internal, + iterations_per_full_update=iterations_per_full_update, ) converged = search_internal.run( @@ -637,7 +642,7 @@ def call_search(self, search_internal, model, analysis, fitness): return search_internal def iterations_from( - self, search_internal + self, search_internal, iterations_per_full_update: Optional[float] = None ) -> Tuple[int, int]: """ Return the next cumulative likelihood-call budget and calls performed. @@ -649,6 +654,10 @@ def iterations_from( ---------- search_internal The Nautilus sampler. + iterations_per_full_update + The likelihood calls between full updates; defaults to the search's + `iterations_per_full_update`. `call_search` passes its effective value, + which is raised to at least three times the number of live points. Returns ------- @@ -662,7 +671,10 @@ def iterations_from( return int(self.n_like_max), total_iterations return int(1e99), total_iterations - iterations = total_iterations + self.iterations_per_full_update + if iterations_per_full_update is None: + iterations_per_full_update = self.iterations_per_full_update + + iterations = total_iterations + iterations_per_full_update if self.n_like_max is not None and self.n_like_max != float("inf"): if iterations > self.n_like_max: diff --git a/test_autofit/non_linear/search/optimize/test_drawer.py b/test_autofit/non_linear/search/optimize/test_drawer.py index e4c40f616..9a2ee0d84 100644 --- a/test_autofit/non_linear/search/optimize/test_drawer.py +++ b/test_autofit/non_linear/search/optimize/test_drawer.py @@ -36,3 +36,27 @@ def test__dict_round_trip_with_number_of_cores(): assert isinstance(restored, af.Drawer) assert restored.total_draws == 3 assert restored.number_of_cores == 1 + + +def test__fit_with_default_null_paths(): + # A Drawer built without a name or path_prefix fits with `NullPaths`, which has no + # timer and writes no search internal; the fit must build its samples from the + # in-memory search internal instead of crashing on `self.timer.time`. + import numpy as np + + from autofit.non_linear.paths.null import NullPaths + + search = af.Drawer(total_draws=5) + + assert isinstance(search.paths, NullPaths) + + model = af.Model(af.ex.Gaussian) + analysis = af.ex.Analysis( + data=np.full(20, 1.0), + noise_map=np.full(20, 1.0), + ) + + result = search.fit(model=model, analysis=analysis) + + assert len(result.samples.parameter_lists) == 5 + assert result.samples.samples_info["time"] is None From 7f9144ad8f72432c8416e1306111263675af7416 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:38:50 +0100 Subject: [PATCH 3/7] fix: warn on unknown search kwargs and narrow the samples_from fallback - NonLinearSearch logs a WARNING naming each unknown constructor kwarg captured in self.kwargs (never raises); save_all_samples and the search.json-recorded initial_values/inplace are known. - samples_from falls back to samples.csv only on FileNotFoundError or NotImplementedError (no longer AttributeError), logging the fallback at WARNING. - plot_start_point outputs only the analysis visuals (plot_search=False): the search has no samples yet, which the AttributeError catch used to hide (an empty Emcee backend). - SettingsSearch.search_dict_for(search_cls) drops use_jax_vmap for searches whose __init__ does not accept it; search_dict keeps its shape. Co-Authored-By: Claude Fable 5.1 --- autofit/non_linear/search/abstract_search.py | 27 ++++++++++- autofit/non_linear/search/updater.py | 9 +++- autofit/non_linear/settings.py | 28 +++++++++++ .../non_linear/search/test_abstract_search.py | 48 +++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index fcc852546..4fca2c101 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -144,6 +144,13 @@ class NonLinearSearch(AbstractFactorOptimiser, ABC): _visualize_fit = True _visualize_before_fit = True + # Keyword arguments the base class expects in ``**kwargs``: ``save_all_samples`` is + # read by ``__init__``, and ``initial_values`` / ``inplace`` are the + # ``AbstractFactorOptimiser`` arguments ``search.json`` records, so a search loaded + # from it passes them back. Any other key reaching the base class is logged as + # unknown (a warning, never an error). + _known_kwargs = ("save_all_samples", "initial_values", "inplace") + def __init__( self, name: Optional[str] = None, @@ -266,6 +273,14 @@ def __init__( self.kwargs = kwargs + for key in kwargs: + if key not in self._known_kwargs: + logger.warning( + f"{type(self).__name__} received the unknown keyword argument " + f"{key!r}, which is ignored. Check its spelling against the " + f"search's constructor." + ) + self.number_of_cores = number_of_cores if number_of_cores > 1 and any( @@ -1513,6 +1528,7 @@ def perform_visualization( instance: Optional[ModelInstance] = None, paths_override: Optional[AbstractPaths] = None, search_internal=None, + plot_search: bool = True, ): """ Perform visualization of the non-linear search's model-fitting results. @@ -1527,6 +1543,7 @@ def perform_visualization( instance=instance, paths_override=paths_override, search_internal=search_internal, + plot_search=plot_search, ) @property @@ -1567,12 +1584,15 @@ def plot_start_point( paths = copy.copy(self.paths) paths.image_path_suffix = "_start" + # The search has no samples before it starts, so only the analysis visuals + # of the starting point are output, not the search's own plots. self.perform_visualization( model=model, analysis=analysis, instance=instance, during_analysis=False, paths_override=paths, + plot_search=False, ) def samples_from(self, model: AbstractPriorModel, search_internal=None) -> Samples: @@ -1596,7 +1616,12 @@ def samples_from(self, model: AbstractPriorModel, search_internal=None) -> Sampl return self.samples_via_internal_from( model=model, search_internal=search_internal ) - except (FileNotFoundError, NotImplementedError, AttributeError): + except (FileNotFoundError, NotImplementedError) as e: + logger.warning( + f"The samples of {type(self).__name__} could not be loaded from its " + f"internal results ({type(e).__name__}: {e}), so they are loaded from " + f"the samples.csv in the output folder instead." + ) return self.paths.samples def samples_via_internal_from( diff --git a/autofit/non_linear/search/updater.py b/autofit/non_linear/search/updater.py index b5a08f7da..208372288 100644 --- a/autofit/non_linear/search/updater.py +++ b/autofit/non_linear/search/updater.py @@ -166,12 +166,15 @@ def visualize( instance: Optional[ModelInstance] = None, paths_override: Optional[AbstractPaths] = None, search_internal=None, + plot_search: bool = True, ): """ Perform visualization of the non-linear search's model-fitting results. Delegates to the analysis object for model-specific plots and to the - search's ``plot_results`` for search-specific plots. + search's ``plot_results`` for search-specific plots. ``plot_search=False`` + outputs only the analysis visuals (used for the starting point, before the + search has samples). Returns immediately when ``visualization_enabled`` is False. """ @@ -207,7 +210,9 @@ def visualize( during_analysis=during_analysis, ) - if analysis.should_visualize(paths=paths, during_analysis=during_analysis): + if plot_search and analysis.should_visualize( + paths=paths, during_analysis=during_analysis + ): if not isinstance(paths, NullPaths): try: samples = self._samples_from( diff --git a/autofit/non_linear/settings.py b/autofit/non_linear/settings.py index f608ecea5..04d2537f0 100644 --- a/autofit/non_linear/settings.py +++ b/autofit/non_linear/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from typing import Optional, List, TYPE_CHECKING if TYPE_CHECKING: @@ -51,6 +52,13 @@ def __init__( @property def search_dict(self): + """ + Every search setting as a dictionary, including ``use_jax_vmap``. + + Only searches whose constructor accepts ``use_jax_vmap`` (e.g. ``Nautilus``) use it; any other search + logs it as an unknown keyword argument. Use ``search_dict_for`` to get the settings filtered to what a + given search accepts. + """ return { "path_prefix": self.path_prefix, "unique_tag": self.unique_tag, @@ -59,6 +67,26 @@ def search_dict(self): "use_jax_vmap": self.use_jax_vmap, } + def search_dict_for(self, search_cls) -> dict: + """ + The search settings as a dictionary for the search class ``search_cls``. + + ``use_jax_vmap`` is included only when the signature of ``search_cls.__init__`` names it, so a search + that does not vectorise its likelihood does not receive (and warn about) an argument it ignores. Use as + ``search_cls(**settings_search.search_dict_for(search_cls), ...)``. + + Parameters + ---------- + search_cls + The non-linear search class the settings are passed to. + """ + search_dict = self.search_dict + + if "use_jax_vmap" not in inspect.signature(search_cls.__init__).parameters: + search_dict.pop("use_jax_vmap") + + return search_dict + @property def fit_dict(self): return {"info": self.info} diff --git a/test_autofit/non_linear/search/test_abstract_search.py b/test_autofit/non_linear/search/test_abstract_search.py index 08a2f2fa0..9823854ee 100644 --- a/test_autofit/non_linear/search/test_abstract_search.py +++ b/test_autofit/non_linear/search/test_abstract_search.py @@ -871,3 +871,51 @@ def test__test_mode_1__bounded_failure_is_clear(self, monkeypatch): model=model, analysis=af.m.MockAnalysis(), ) + + +def _unknown_kwarg_messages(caplog): + return [ + record.getMessage() + for record in caplog.records + if "unknown keyword argument" in record.getMessage() + ] + + +def test_unknown_constructor_kwarg_logs_a_warning(caplog): + import logging + + with caplog.at_level(logging.WARNING): + search = af.DynestyStatic(nlive_typo=10) + + assert search.kwargs == {"nlive_typo": 10} + messages = _unknown_kwarg_messages(caplog) + assert len(messages) == 1 + assert "'nlive_typo'" in messages[0] + assert "DynestyStatic" in messages[0] + + +def test_search_json_round_trip_logs_no_unknown_kwarg(caplog): + import json + import logging + + loaded_dict = json.loads(json.dumps(af.to_dict(af.DynestyStatic()))) + + with caplog.at_level(logging.WARNING): + af.from_dict(loaded_dict) + + assert _unknown_kwarg_messages(caplog) == [] + + +def test_settings_search_dict_for_filters_use_jax_vmap(caplog): + import logging + + settings_search = af.SettingsSearch(path_prefix="settings_search_test") + + assert "use_jax_vmap" in settings_search.search_dict + assert settings_search.search_dict_for(af.Nautilus)["use_jax_vmap"] is True + assert "use_jax_vmap" not in settings_search.search_dict_for(af.DynestyStatic) + + with caplog.at_level(logging.WARNING): + af.DynestyStatic(**settings_search.search_dict_for(af.DynestyStatic)) + + assert _unknown_kwarg_messages(caplog) == [] From 315548863624227e416d814fe4575929751c27db Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:38:54 +0100 Subject: [PATCH 4/7] perf: convert samples once per update and compute Emcee auto-correlations once - SearchUpdater.update passes its converted samples to visualize, which no longer converts them a second time for plot_results. - Emcee.samples_via_internal_from computes the auto-correlation times once and shares them with the burn-in, samples_info_from and SamplesMCMC. - Unit test: every Emcee update computes the auto-correlations exactly once. Co-Authored-By: Claude Fable 5.1 --- .../non_linear/search/mcmc/emcee/search.py | 26 +++++---- autofit/non_linear/search/updater.py | 16 ++++-- .../non_linear/search/mcmc/test_emcee.py | 57 +++++++++++++++++++ 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/autofit/non_linear/search/mcmc/emcee/search.py b/autofit/non_linear/search/mcmc/emcee/search.py index e8963fd7b..912eb207e 100644 --- a/autofit/non_linear/search/mcmc/emcee/search.py +++ b/autofit/non_linear/search/mcmc/emcee/search.py @@ -261,10 +261,13 @@ def output_search_internal(self, search_internal): """ pass - def samples_info_from(self, search_internal=None): + def samples_info_from(self, search_internal=None, auto_correlations=None): search_internal = search_internal or self.backend - auto_correlations = self.auto_correlations_from(search_internal=search_internal) + if auto_correlations is None: + auto_correlations = self.auto_correlations_from( + search_internal=search_internal + ) return { "check_size": auto_correlations.check_size, @@ -293,15 +296,17 @@ def samples_via_internal_from(self, model, search_internal=None): search_internal = search_internal or self.backend + # Computed once per conversion and shared by the burn-in, `samples_info` and + # `SamplesMCMC`; each computation integrates the whole chain twice. + auto_correlations = self.auto_correlations_from( + search_internal=search_internal + ) + if is_test_mode(): discard = 5 thin = 5 else: - auto_correlations = self.auto_correlations_from( - search_internal=search_internal - ) - discard = int(3.0 * np.max(auto_correlations.times)) thin = int(np.max(auto_correlations.times) / 2.0) @@ -348,11 +353,12 @@ def samples_via_internal_from(self, model, search_internal=None): return SamplesMCMC( model=model, sample_list=sample_list, - samples_info=self.samples_info_from(search_internal=search_internal), - auto_correlation_settings=self.auto_correlation_settings, - auto_correlations=self.auto_correlations_from( - search_internal=search_internal + samples_info=self.samples_info_from( + search_internal=search_internal, + auto_correlations=auto_correlations, ), + auto_correlation_settings=self.auto_correlation_settings, + auto_correlations=auto_correlations, ) def auto_correlations_from(self, search_internal=None): diff --git a/autofit/non_linear/search/updater.py b/autofit/non_linear/search/updater.py index 208372288..5bcb50d3e 100644 --- a/autofit/non_linear/search/updater.py +++ b/autofit/non_linear/search/updater.py @@ -140,6 +140,7 @@ def update( samples_summary=samples_summary, during_analysis=during_analysis, search_internal=search_internal, + samples=samples, ) visualization_time = time.time() - start @@ -166,15 +167,17 @@ def visualize( instance: Optional[ModelInstance] = None, paths_override: Optional[AbstractPaths] = None, search_internal=None, + samples: Optional[Samples] = None, plot_search: bool = True, ): """ Perform visualization of the non-linear search's model-fitting results. Delegates to the analysis object for model-specific plots and to the - search's ``plot_results`` for search-specific plots. ``plot_search=False`` - outputs only the analysis visuals (used for the starting point, before the - search has samples). + search's ``plot_results`` for search-specific plots. ``update`` passes the + ``samples`` it has already converted; without them they are converted from + ``search_internal`` here. ``plot_search=False`` outputs only the analysis + visuals (used for the starting point, before the search has samples). Returns immediately when ``visualization_enabled`` is False. """ @@ -215,9 +218,10 @@ def visualize( ): if not isinstance(paths, NullPaths): try: - samples = self._samples_from( - model, search_internal, - ) + if samples is None: + samples = self._samples_from( + model, search_internal, + ) self._plot_results(samples=samples) except FileNotFoundError: diff --git a/test_autofit/non_linear/search/mcmc/test_emcee.py b/test_autofit/non_linear/search/mcmc/test_emcee.py index 999f5d071..2ab719373 100644 --- a/test_autofit/non_linear/search/mcmc/test_emcee.py +++ b/test_autofit/non_linear/search/mcmc/test_emcee.py @@ -96,3 +96,60 @@ def test__log_posteriors_are_aligned_with_the_thinned_chain(monkeypatch): assert samples.max_log_likelihood_sample.log_likelihood == pytest.approx( analysis.log_likelihood_function(instance=instance), rel=1.0e-8 ) + + +def test__update_computes_auto_correlations_once(monkeypatch): + """ + One full update converts the Emcee chain to samples once, and that conversion + computes the auto-correlation times once: the burn-in, ``samples_info`` and the + ``SamplesMCMC`` share one result, and visualization reuses the converted samples. + """ + monkeypatch.setenv("PYAUTO_TEST_MODE", "1") + + np.random.seed(1) + + model = af.Model(af.ex.Gaussian) + analysis = af.ex.Analysis( + data=np.full(100, 5.0), + noise_map=np.full(100, 1.0), + ) + + search = af.Emcee( + name="emcee_auto_correlations_once", + unique_tag="auto_correlations_once_test", + auto_correlation_settings=af.AutoCorrelationsSettings( + check_for_convergence=False, + check_size=5, + required_length=2, + ), + number_of_cores=1, + ) + + calls = [] + auto_correlations_from = af.Emcee.auto_correlations_from + + def counting_auto_correlations_from(self, search_internal=None): + calls.append(search_internal) + return auto_correlations_from(self, search_internal=search_internal) + + monkeypatch.setattr( + af.Emcee, "auto_correlations_from", counting_auto_correlations_from + ) + + from autofit.non_linear.search.updater import SearchUpdater + + calls_per_update = [] + update = SearchUpdater.update + + def recording_update(self, *args, **kwargs): + calls_before = len(calls) + samples = update(self, *args, **kwargs) + calls_per_update.append(len(calls) - calls_before) + return samples + + monkeypatch.setattr(SearchUpdater, "update", recording_update) + + search.fit(model=model, analysis=analysis) + + assert len(calls_per_update) > 0 + assert calls_per_update == [1] * len(calls_per_update) From a71cf725da528eaf9d72ad50d40c9b0e9f1de8b1 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:40:28 +0100 Subject: [PATCH 5/7] fix: BlackJAXNUTS and SMC load from the search.json they write search.json records the inverse_mass_matrix kind string ('none' by default), which both constructors rejected. The constructors now accept the serialised kinds: 'none' maps back to None, and 'array'/'result' (a seed whose values were not saved) are accepted on load and raise a clear error only at fit time. Identifiers are unchanged; the ROUND_TRIP_XFAIL strict xfails are removed. Co-Authored-By: Claude Fable 5.1 --- .../non_linear/search/mcmc/blackjax/chains.py | 29 +++++++++++++++++-- .../search/mcmc/blackjax/nuts/search.py | 15 ++++++++-- .../search/mcmc/blackjax/smc/search.py | 16 ++++++++-- .../non_linear/search/test_conformance.py | 13 +-------- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/autofit/non_linear/search/mcmc/blackjax/chains.py b/autofit/non_linear/search/mcmc/blackjax/chains.py index 705c8e0b6..284e965d0 100644 --- a/autofit/non_linear/search/mcmc/blackjax/chains.py +++ b/autofit/non_linear/search/mcmc/blackjax/chains.py @@ -22,6 +22,25 @@ _VALID_STRING_KINDS = ("diagonal", "dense") +# The kinds `inverse_mass_matrix_kind_from` returns for a `None`, array or `Result` +# specification. `search.json` records the kind string, not the specification, so a +# search loaded from it receives one of these: "none" is equivalent to `None`, while +# "array" / "result" name a seed whose values were not recorded and cannot be resolved. +SERIALISED_KINDS = ("none", "array", "result") + + +def inverse_mass_matrix_spec_from(spec: InverseMassMatrixSpec) -> InverseMassMatrixSpec: + """ + Normalise an ``inverse_mass_matrix`` specification passed to a constructor. + + The kind string ``"none"`` that ``search.json`` records for a ``None`` specification is mapped back to + ``None``, so a search loaded from ``search.json`` behaves as the one that wrote it. Every other value is + returned unchanged. + """ + if isinstance(spec, str) and spec == "none": + return None + return spec + def inverse_mass_matrix_kind_from(spec: InverseMassMatrixSpec) -> str: """ @@ -42,7 +61,7 @@ def inverse_mass_matrix_kind_from(spec: InverseMassMatrixSpec) -> str: return "none" if isinstance(spec, str): - if spec not in _VALID_STRING_KINDS: + if spec not in _VALID_STRING_KINDS + SERIALISED_KINDS: raise ValueError( f"inverse_mass_matrix string must be one of {_VALID_STRING_KINDS}, got {spec!r}" ) @@ -96,10 +115,16 @@ def resolve_inverse_mass_matrix( return True, None if isinstance(spec, str): - if spec == "diagonal": + if spec in ("diagonal", "none"): return True, None if spec == "dense": return False, None + if spec in SERIALISED_KINDS: + raise ValueError( + f"inverse_mass_matrix={spec!r} is the kind recorded in search.json for a seed whose values " + "were not saved, so this search (loaded from search.json) cannot be fitted with it. Pass " + "the original seed array or Result as inverse_mass_matrix instead." + ) raise ValueError( f"inverse_mass_matrix string must be one of {_VALID_STRING_KINDS}, got {spec!r}" ) diff --git a/autofit/non_linear/search/mcmc/blackjax/nuts/search.py b/autofit/non_linear/search/mcmc/blackjax/nuts/search.py index 4b4eda976..b4f10ba67 100644 --- a/autofit/non_linear/search/mcmc/blackjax/nuts/search.py +++ b/autofit/non_linear/search/mcmc/blackjax/nuts/search.py @@ -19,6 +19,7 @@ from autofit.non_linear.search.mcmc.blackjax.chains import ( InverseMassMatrixSpec, inverse_mass_matrix_kind_from, + inverse_mass_matrix_spec_from, resolve_inverse_mass_matrix, split_chain_diagnostics, stack_initial_positions, @@ -163,6 +164,12 @@ def __init__( ``samples.covariance_matrix``. Raises ``ValueError`` if that covariance looks MLE-only (too few samples, or non-finite / identity) -- pass an explicit array in that case. + + ``search.json`` records only the kind (``"none"``, ``"diagonal"``, + ``"dense"``, ``"array"`` or ``"result"``), so a search loaded from + it receives that string: ``"none"`` behaves as ``None``, while + ``"array"`` / ``"result"`` raise at fit time because the seed was + not saved. mass_matrix_shrinkage Forwarded to ``blackjax.window_adaptation``'s ``imm_shrinkage_to_previous``: shrinkage of each warmup window's @@ -222,8 +229,12 @@ def __init__( # persisted onto `search_internal` / `samples_info`. Validated eagerly # here (`inverse_mass_matrix_kind_from` raises on an unrecognised # spec) so a bad value fails at construction, not mid-fit. - self._inverse_mass_matrix_spec = inverse_mass_matrix - self.inverse_mass_matrix = inverse_mass_matrix_kind_from(inverse_mass_matrix) + self._inverse_mass_matrix_spec = inverse_mass_matrix_spec_from( + inverse_mass_matrix + ) + self.inverse_mass_matrix = inverse_mass_matrix_kind_from( + self._inverse_mass_matrix_spec + ) self.mass_matrix_shrinkage = mass_matrix_shrinkage self.share_adaptation = share_adaptation diff --git a/autofit/non_linear/search/mcmc/blackjax/smc/search.py b/autofit/non_linear/search/mcmc/blackjax/smc/search.py index d18e4ef7c..d6f18659a 100644 --- a/autofit/non_linear/search/mcmc/blackjax/smc/search.py +++ b/autofit/non_linear/search/mcmc/blackjax/smc/search.py @@ -18,7 +18,9 @@ from autofit.non_linear.search.mcmc.auto_correlations import AutoCorrelationsSettings from autofit.non_linear.search.mcmc.blackjax.chains import ( InverseMassMatrixSpec, + SERIALISED_KINDS, inverse_mass_matrix_kind_from, + inverse_mass_matrix_spec_from, resolve_inverse_mass_matrix, stack_initial_positions, ) @@ -219,6 +221,10 @@ def __init__( The ``"diagonal"`` / ``"dense"`` strings `BlackJAXNUTS` accepts are **rejected** here: they name an adaptation strategy, and SMC does not adapt its metric, so they would silently give a cold run. + + ``search.json`` records only the kind (``"none"``, ``"array"`` or ``"result"``), so a search loaded + from it receives that string: ``"none"`` behaves as ``None``, while ``"array"`` / ``"result"`` raise at + fit time because the covariance was not saved. auto_correlation_settings Kept for API parity with the other MCMC searches. SMC particles are not a chain, so no auto-correlation diagnostics are computed and ``check_for_convergence`` defaults to ``False``. @@ -253,7 +259,7 @@ def __init__( f"SMC kernel must be one of {VALID_KERNELS}, got {kernel!r}" ) - if isinstance(inverse_mass_matrix, str): + if isinstance(inverse_mass_matrix, str) and inverse_mass_matrix not in SERIALISED_KINDS: raise ValueError( "SMC does not adapt its metric, so the 'diagonal' / 'dense' strings BlackJAXNUTS accepts " "carry no information here and would silently give a cold (prior-whitened) run. Pass a " @@ -275,8 +281,12 @@ def __init__( # at fit time, when `n_dim` is known) and the public attribute is the small descriptive string used by # `__identifier_fields__` and persisted onto `search_internal` / `samples_info`. Validated eagerly here so # a bad value fails at construction, not mid-fit. - self._inverse_mass_matrix_spec = inverse_mass_matrix - self.inverse_mass_matrix = inverse_mass_matrix_kind_from(inverse_mass_matrix) + self._inverse_mass_matrix_spec = inverse_mass_matrix_spec_from( + inverse_mass_matrix + ) + self.inverse_mass_matrix = inverse_mass_matrix_kind_from( + self._inverse_mass_matrix_spec + ) if is_test_mode(): self.apply_test_mode() diff --git a/test_autofit/non_linear/search/test_conformance.py b/test_autofit/non_linear/search/test_conformance.py index 54a3f4339..b513ced5b 100644 --- a/test_autofit/non_linear/search/test_conformance.py +++ b/test_autofit/non_linear/search/test_conformance.py @@ -35,17 +35,6 @@ ROSTER = searches_under_test() -ROUND_TRIP_XFAIL = { - "BlackJAXNUTS": ( - "The serialised inverse_mass_matrix is a kind string ('none') the " - "constructor rejects; repaired in search-extensibility phase A0b." - ), - "SMC": ( - "The serialised inverse_mass_matrix is a kind string SMC's constructor " - "rejects; repaired in search-extensibility phase A0b." - ), -} - CONFIG_MUTATION_XFAIL = { name: ( "__init__ sets conf.instance['output']['search_internal'] = True; " @@ -144,7 +133,7 @@ def test_constructor_argument_set(entry): assert arguments == GOLDEN[entry.name].constructor_arguments -@pytest.mark.parametrize("entry", _params(ROUND_TRIP_XFAIL, raises=ValueError)) +@pytest.mark.parametrize("entry", _params()) def test_search_json_round_trip(entry): """ A default-constructed search serialises with ``NullPaths``, so the dictionary From 812057a807f3c9fca0c2f2850eae268aa150d961 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:41:03 +0100 Subject: [PATCH 6/7] docs: fix stale AbstractNest and apply_test_mode docstrings AbstractNest described a removed auto-terminate-when-stuck feature; the base apply_test_mode claimed the base __init__ calls it, but each search calls its own. Co-Authored-By: Claude Fable 5.1 --- autofit/non_linear/search/abstract_search.py | 7 ++++--- autofit/non_linear/search/nest/abstract_nest.py | 12 +++--------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/autofit/non_linear/search/abstract_search.py b/autofit/non_linear/search/abstract_search.py index 4fca2c101..837d145f0 100644 --- a/autofit/non_linear/search/abstract_search.py +++ b/autofit/non_linear/search/abstract_search.py @@ -1376,9 +1376,10 @@ def apply_test_mode(self): """ Override in subclasses to reduce sampler iterations for test mode. - Called during __init__ when test mode is active (level 1). - Subclasses should directly mutate instance attributes to minimize - the number of iterations the sampler performs. + The base class never calls this method: each search that overrides it + calls it at the end of its own ``__init__`` when ``is_test_mode()`` is + true (test mode level 1). Overrides directly mutate instance attributes + to minimize the number of iterations the sampler performs. """ pass diff --git a/autofit/non_linear/search/nest/abstract_nest.py b/autofit/non_linear/search/nest/abstract_nest.py index 483a7d807..32bfae58d 100644 --- a/autofit/non_linear/search/nest/abstract_nest.py +++ b/autofit/non_linear/search/nest/abstract_nest.py @@ -32,16 +32,10 @@ def __init__( **kwargs ): """ - Abstract class of a nested sampling `NonLinearSearch` (e.g. MultiNest, Dynesty). + Abstract class of a nested sampling `NonLinearSearch` (e.g. Dynesty, Nautilus, NSS). - **PyAutoFit** allows a nested sampler to automatically terminate when the acceptance ratio falls below an input - threshold value. When this occurs, all samples are accepted using the current maximum log likelihood value, - irrespective of how well the model actually fits the data. - - This feature should be used for non-linear searches where the nested sampler gets 'stuck', for example because - the log likelihood function is stochastic or varies rapidly over small scales in parameter space. The results of - samples using this feature are not realiable (given the log likelihood is being manipulated to end the run), but - they are still valid results for linking priors to a new search and non-linear search. + It defaults the initializer to `InitializerPrior` (nested sampling draws its initial live points from the + prior), rejects `InitializerParamBounds`, and provides the corner plot of the nested sampling results. Parameters ---------- From 8df1754922cf9b98e62143d5072ed9946409eea7 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Thu, 8 Oct 2026 09:41:19 +0100 Subject: [PATCH 7/7] test: move Drawer and LBFGS tests from search/optimize/ to search/mle/ Move-only: the tests now sit beside the other MLE search tests, matching autofit/non_linear/search/mle/. The empty optimize/ package is removed. Co-Authored-By: Claude Fable 5.1 --- test_autofit/non_linear/search/{optimize => mle}/test_drawer.py | 0 test_autofit/non_linear/search/{optimize => mle}/test_lbfgs.py | 0 test_autofit/non_linear/search/optimize/__init__.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename test_autofit/non_linear/search/{optimize => mle}/test_drawer.py (100%) rename test_autofit/non_linear/search/{optimize => mle}/test_lbfgs.py (100%) delete mode 100644 test_autofit/non_linear/search/optimize/__init__.py diff --git a/test_autofit/non_linear/search/optimize/test_drawer.py b/test_autofit/non_linear/search/mle/test_drawer.py similarity index 100% rename from test_autofit/non_linear/search/optimize/test_drawer.py rename to test_autofit/non_linear/search/mle/test_drawer.py diff --git a/test_autofit/non_linear/search/optimize/test_lbfgs.py b/test_autofit/non_linear/search/mle/test_lbfgs.py similarity index 100% rename from test_autofit/non_linear/search/optimize/test_lbfgs.py rename to test_autofit/non_linear/search/mle/test_lbfgs.py diff --git a/test_autofit/non_linear/search/optimize/__init__.py b/test_autofit/non_linear/search/optimize/__init__.py deleted file mode 100644 index e69de29bb..000000000