From e1be9190adbade471d6e3f8847fe8ec04f93ca93 Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 16:48:59 +0200 Subject: [PATCH 1/6] Added ScannerBit plugin for paraprof --- .../python/plugins/gambit_paraprof.py | 292 ++++++++++++++++++ yaml_files/paraprof_test.yaml | 113 +++++++ 2 files changed, 405 insertions(+) create mode 100644 ScannerBit/src/scanners/python/plugins/gambit_paraprof.py create mode 100644 yaml_files/paraprof_test.yaml diff --git a/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py new file mode 100644 index 0000000000..55c6390d57 --- /dev/null +++ b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py @@ -0,0 +1,292 @@ +""" +ParaProf scanner +================ + +ScannerBit plugin wrapping the paraprof package +(https://github.com/anderkve/paraprof) for parallel grid-based profile +likelihood scans inside GAMBIT. + +ParaProf places populations on a regular grid over a user-chosen subset of +parameters and dynamically activates the region of interest, optimising the +profiled parameters at each grid point with differential evolution or +L-BFGS-B. The plugin uses paraprof's master/worker MPI scheme: rank 0 acts as +the orchestrator and ranks 1+ evaluate the GAMBIT loglike via +``self.loglike_hypercube``. Because the bound loglike cannot be pickled, the +target function is supplied to the workers directly rather than broadcast. + +Drop this file into ``ScannerBit/src/scanners/python/plugins/`` in your GAMBIT +source tree. Requires ``mpi4py`` and the ``paraprof`` package installed in the +Python environment GAMBIT is using. +""" + +import numpy as np + +from scannerbit import with_mpi as scannerbit_with_mpi +from utils import copydoc, version, with_mpi + +try: + import paraprof + paraprof_version = version(paraprof) + paraprof_ProfileProjector = paraprof.ProfileProjector + paraprof_run_scan = paraprof.run_scan + paraprof_worker_main = paraprof.worker_main +except Exception: + __error__ = ("The paraprof package is not installed. To install it, run: " + "pip install git+https://github.com/anderkve/paraprof.git") + paraprof_version = "n/a" + paraprof_ProfileProjector = None + paraprof_run_scan = None + paraprof_worker_main = None + +import scanner_plugin as splug + + +class ParaProf(splug.scanner): + """ +Parallel grid-based profile likelihood scan with paraprof. + +See https://github.com/anderkve/paraprof for the algorithm and tuning details. + +Requires MPI with at least 2 processes (one master + one or more workers); the +master rank performs no target evaluations. + +YAML options: + like: Use the functors that correspond to the specified purpose. + run: All paraprof-native settings live here. Required key: + projections: List of projection configurations. Each entry is a + dict with required keys 'dims' (list of parameter + names or indices) and 'grid_points' (list of ints, + same length as 'dims'). Optional per-projection keys: + optimization_method: 'de' (default) or 'lbfgsb' + grid_refinement_factor: int > 1 enables a refined run + refinement_method: interpolation method (default 'linear') + patch_coarse_grid: bool (default true) + patch_refined_grid: bool (default false) + Optional ProfileProjector tuning keys: + roi_threshold: ROI cutoff in chi^2 units (default 3.0). + pop_per_grid_point: DE population size per grid cell (default 3). + n_initial_optimizations: Global L-BFGS-B starts before grid optimization + (default min(100, 20*n_dims)). + max_patching_waves: Cap on patching iterations (default 10). + lbfgsb_max_iter: Max L-BFGS-B iterations per polish (default 50). + lbfgsb_polish: Apply L-BFGS-B polish after DE (default true). + use_clustering: Detect modes during refinement (default true). + refinement_direct_eval: Skip optimisation in refinement runs (default false). + initial_points: Optional list of starting points in physical + coordinates to activate explicitly. + samples_output_file: Optional CSV path; written by rank 0 only. Note that + GAMBIT's printers already record every evaluation, + so this is purely a paraprof-side diagnostic file. + warm_start_file: Optional CSV path read at the start of each projection + to pre-populate ``initial_maxima``, skipping the + global L-BFGS-B seeding step. Set equal to + ``samples_output_file`` to round-trip samples into + the next run. + advanced_config: Forwarded as-is to ProfileProjector. Sub-dicts: + 'de', 'lbfgsb', 'clustering', 'cross_projection' + (multi-projection knowledge transfer), + 'suspect_recheck' (wrong-optimum strip recheck). + See the paraprof README for the full key list. + Optional run-time keys: + save_plots: If true, paraprof writes its diagnostic plots to + the working directory after each projection. + plot_settings: Dict forwarded to paraprof's plotting helpers. + Common keys: dpi, filetype, vmin, vmax, + contour_levels, slice_mode, plot_profiled_params. + +paraprof's ``grad_func`` is not exposed: ScannerBit's Python API surfaces +only the loglike value, so L-BFGS-B uses finite differences. +""" + + __version__ = paraprof_version + __plugin_name__ = "paraprof" + + + def __init__(self, **kwargs): + if not scannerbit_with_mpi: + raise Exception( + "GAMBIT has been compiled with MPI disabled (WITH_MPI=0), but the " + "paraprof scanner requires MPI parallelisation with >=2 MPI " + "processes (1 master + >=1 worker). Rerun CMake with " + "\"cmake -DWITH_MPI=1\" and recompile GAMBIT." + ) + if not with_mpi: + raise Exception( + "The paraprof scanner requires MPI parallelisation. Make sure " + "mpi4py is installed in the Python environment GAMBIT is using." + ) + + super().__init__(use_mpi=True, use_resume=False) + + if self.mpi_size < 2: + raise Exception( + "The paraprof scanner requires >=2 MPI processes (1 master + " + ">=1 worker); the master rank performs no target evaluations. " + f"Detected MPI size = {self.mpi_size}." + ) + + self.print_prefix = f"{ParaProf.__plugin_name__} scanner plugin:" + + # All paraprof-native settings live under the YAML 'run:' block; the + # top level of the scanner block is reserved for ScannerBit itself. + ra = self.run_args + + if "projections" not in ra: + raise RuntimeError( + f"{self.print_prefix} The required scanner option 'projections' " + "is missing from the 'run:' block." + ) + # Defensive copy so downstream mutation (string-dim resolution etc.) + # doesn't disturb the YAML dict. + self.projections = [dict(p) for p in ra["projections"]] + for p in self.projections: + for k in ('dims', 'grid_points'): + if k in p and isinstance(p[k], tuple): + p[k] = list(p[k]) + + # ProfileProjector tuning. Each is optional; we only forward keys the + # user actually set so paraprof's own defaults stay authoritative. + self.projector_kwargs = {} + for key in ( + "roi_threshold", "pop_per_grid_point", "max_patching_waves", + "lbfgsb_max_iter", "lbfgsb_polish", "n_initial_optimizations", + "initial_points", "use_clustering", "refinement_direct_eval", + "samples_output_file", "warm_start_file", "advanced_config", + ): + if key in ra: + self.projector_kwargs[key] = ra[key] + + # Plot / output controls (paraprof-side, not GAMBIT printer side). + self.save_plots = bool(ra.get("save_plots", False)) + self.plot_settings = ra.get("plot_settings", None) + + + @copydoc(paraprof_ProfileProjector) + def run(self): + from mpi4py import MPI + comm = MPI.COMM_WORLD + + # The target function on every rank is GAMBIT's loglike, called via + # the unit-hypercube convenience method. Wrapping it lets us emit a + # weight=1 entry on the standard "Posterior" stream after each call, + # mirroring grid.py / the other ScannerBit Python plugins. + plugin = self # bind into closure; avoids capturing self by name twice + + def target_func(x): + lnL = plugin.loglike_hypercube(x) + plugin.print(1.0, "Posterior") + return lnL + + if self.mpi_rank != 0: + # Worker rank: hand the target function over directly. No bcast. + paraprof_worker_main(comm, self.mpi_rank, target_func=target_func) + return 0 + + # ---- Master rank ---- + # Bounds always live on the unit hypercube; physical-space bounds come + # from the YAML Parameters node. + bounds = [(0.0, 1.0)] * self.dim + + if self.mpi_rank == 0: + print(f"{self.print_prefix} Starting paraprof scan with " + f"{len(self.projections)} projection(s) on {self.mpi_size - 1} " + f"worker rank(s).", flush=True) + + with paraprof_ProfileProjector( + target_func=target_func, + bounds=bounds, + projections=self.projections, + parameter_names=list(self.parameter_names), + **self.projector_kwargs, + ) as sampler: + + results = paraprof_run_scan( + comm=comm, + sampler=sampler, + projections=self.projections, + save_plots=self.save_plots, + plot_settings=self.plot_settings, + broadcast_target_func=False, # workers already have target_func + myrank=0, + ) + + self._print_summary(results) + + return 0 + + + def _print_summary(self, results): + """Emit a per-projection summary on rank 0, in the binminpy/scipy style.""" + prefix = self.print_prefix + print() + print(f"{prefix} === Scan summary ===", flush=True) + for i, res in enumerate(results): + cfg = res.get('projection_config', {}) + metrics = res.get('metrics', {}) + dims = cfg.get('dims', []) + dim_names = [self.parameter_names[d] for d in dims] + calls = metrics.get('total_target_calls', 'n/a') + global_max = metrics.get('global_max', float('nan')) + print(f"{prefix} - Projection {i + 1}:", flush=True) + print(f"{prefix} dims (indices): {dims}", flush=True) + print(f"{prefix} dims (names): {dim_names}", flush=True) + print(f"{prefix} grid_points: {cfg.get('grid_points')}", flush=True) + print(f"{prefix} target calls: {calls}", flush=True) + print(f"{prefix} best logL found: {global_max:.6e}", flush=True) + + # Best-fit point: prefer the refined solution if available, + # otherwise the coarse one. The exported solution dict doesn't + # carry a "global best" field, so we derive it ourselves from the + # global solution pool (capped pool of the best entries) and fall + # back to the per-cell solutions table if the pool is empty. + best_solution = (res.get('refined_solution') + or res.get('coarse_solution') + or {}) + best_x_unit = self._best_full_params(best_solution) + if best_x_unit is not None: + try: + best_x_phys = self.transform_to_vec(np.asarray(best_x_unit)) + print(f"{prefix} best-fit point (physical):", flush=True) + for name, val in zip(self.parameter_names, best_x_phys): + print(f"{prefix} {name}: {val}", flush=True) + except Exception: + # Defensive: never let pretty-printing kill the scan. + pass + print() + + + @staticmethod + def _best_full_params(exported_solution): + """Return the unit-hypercube parameter vector with the highest likelihood. + + Looks first in ``global_solution_pool`` (the capped pool of best + entries), then falls back to scanning ``solutions``. Returns None if + nothing is available. + """ + if not exported_solution: + return None + + pool = exported_solution.get('global_solution_pool') or [] + best_entry = None + best_fitness = -float('inf') + for entry in pool: + f = entry.get('fitness', -float('inf')) + if f > best_fitness: + best_fitness = f + best_entry = entry + if best_entry is not None and 'full_params' in best_entry: + return best_entry['full_params'] + + solutions = exported_solution.get('solutions') or {} + for sol in solutions.values(): + f = sol.get('likelihood', -float('inf')) + if f > best_fitness: + best_fitness = f + best_entry = sol + if best_entry is not None: + return best_entry.get('full_params') + return None + + +__plugins__ = {ParaProf.__plugin_name__: ParaProf} + diff --git a/yaml_files/paraprof_test.yaml b/yaml_files/paraprof_test.yaml new file mode 100644 index 0000000000..4a1075fb5c --- /dev/null +++ b/yaml_files/paraprof_test.yaml @@ -0,0 +1,113 @@ +########################################################################## +## Minimal GAMBIT configuration for testing the paraprof scanner plugin. +## +## Uses the 2D NormalDist toy model in ExampleBit_A so the scan stays cheap. +## +## Run with (1 master + 3 worker ranks): +## +## mpirun -n 4 ./gambit -f yaml_files/paraprof_test.yaml +## +## The paraprof plugin requires >=2 MPI ranks. +########################################################################## + + +Parameters: + NormalDist: + mu: + prior_type: flat + range: [15.0, 30.0] + sigma: + prior_type: flat + range: [0.0, 5.0] + + +Priors: + # None needed; flat priors are specified above. + + +Printer: + printer: hdf5 + options: + output_file: "paraprof_test.hdf5" + group: "/data" + delete_file_on_restart: true + buffer_length: 1000 + + +Scanner: + + use_scanner: paraprof + + scanners: + + paraprof: + plugin: paraprof + like: LogLike + run: + # One 2D projection over the full parameter space, plus two 1D + # projections. Small grids keep the test quick. + projections: + - dims: ["NormalDist::mu", "NormalDist::sigma"] + grid_points: [12, 12] + optimization_method: de + - dims: ["NormalDist::mu"] + grid_points: [25] + - dims: ["NormalDist::sigma"] + grid_points: [25] + # Light paraprof tuning suitable for a fast smoke test. + roi_threshold: 4.0 + pop_per_grid_point: 3 + n_initial_optimizations: 20 + max_patching_waves: 5 + lbfgsb_max_iter: 20 + lbfgsb_polish: true + use_clustering: false + refinement_direct_eval: false + save_plots: false + + +ObsLikes: + + - purpose: LogLike + capability: normaldist_loglike + module: ExampleBit_A + type: double + + +Rules: + + - if: + capability: normaldist_loglike + then: + options: + probability_of_validity: 1.0 + + +Logger: + + redirection: + [Default] : "default.log" + [ExampleBit_A] : "ExampleBit_A.log" + [Scanner] : "Scanner.log" + debug: true + + +KeyValues: + + debug: false + + default_output_path: "${PWD}/runs/paraprof_test" + + print_scanID: true + scanID: 1 + + rng: + generator: ranlux48 + seed: -1 + + print_timing_data: false + print_unitcube: true + + likelihood: + model_invalid_for_lnlike_below: -1e6 + use_lnlike_modifier: identity From edd0c00e2492c9fdf02e115664ee91f16e083a17 Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 19:25:49 +0200 Subject: [PATCH 2/6] Consolidate paraprof-related additions from claude/add-python-scanner-plugin-UlzKE Brings in the paraprof-specific changes from the earlier UlzKE branch that were not present on this branch. The newer gambit_paraprof.py plugin already on paraprof_plugin is kept verbatim (it tracks the current paraprof API). Added: - cmake/python_scanners.cmake: register paraprof for the Python module availability check. - yaml_files/spartan_5d.yaml: 5D Rosenbrock paraprof test scan using trivial_5d / rosenbrock from ObjectivesBit. - yaml_files/plot_spartan_5d_paraprof_2D.py: plotting helper for the 5D test's 2D projections. - yaml_files/spartan.yaml: paraprof entry alongside the other example scanners. The CMakeLists.txt PYTHONLIBS_FOUND shim from UlzKE is intentionally not brought over: with PYBIND11_FINDPYTHON=OFF (the default), find_package(pybind11) falls back to Classic mode, which loads FindPythonLibsNew.cmake and sets PYTHONLIBS_FOUND itself. The CLAUDE.md / MINIMAL_BUILD.md commits from UlzKE are also skipped here as they are unrelated to paraprof. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmake/python_scanners.cmake | 1 + yaml_files/plot_spartan_5d_paraprof_2D.py | 100 +++++++++++++++++ yaml_files/spartan.yaml | 40 +++++++ yaml_files/spartan_5d.yaml | 128 ++++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 yaml_files/plot_spartan_5d_paraprof_2D.py create mode 100644 yaml_files/spartan_5d.yaml diff --git a/cmake/python_scanners.cmake b/cmake/python_scanners.cmake index b0cfc0c300..4d7d2976cc 100644 --- a/cmake/python_scanners.cmake +++ b/cmake/python_scanners.cmake @@ -83,4 +83,5 @@ check_python_scanner_modules(scipy_minimize "scipy,numpy" "scipy,numpy") check_python_scanner_modules(reactive_ultranest "ultranest,numpy,packaging" "ultranest,numpy,packaging") check_python_scanner_modules(zeus "zeus,numpy" "zeus-mcmc,numpy") check_python_scanner_modules(binminpy "binminpy,numpy,scipy,mpi4py" "binminpy,numpy,scipy,mpi4py") +check_python_scanner_modules(paraprof "paraprof,numpy,scipy,mpi4py" "paraprof,numpy,scipy,mpi4py") diff --git a/yaml_files/plot_spartan_5d_paraprof_2D.py b/yaml_files/plot_spartan_5d_paraprof_2D.py new file mode 100644 index 0000000000..d198cc89b3 --- /dev/null +++ b/yaml_files/plot_spartan_5d_paraprof_2D.py @@ -0,0 +1,100 @@ +"""Plot the 2D profile likelihoods for the spartan_5d.yaml paraprof run, +using gambit_plotting_tools and the GAMBIT hdf5 output. + +Generates one plot per paraprof projection in spartan_5d.yaml: + (x1, x2) and (x2, x3). + +Bin counts and axis bounds are matched to the paraprof YAML settings +(200 x 200 native grid over [-3, 3] x [-3, 3]) to avoid re-binning +artefacts. + +Install gambit_plotting_tools first: + pip install --user git+https://github.com/GambitBSM/gambit_plotting_tools.git +(on Debian/Ubuntu you may need SETUPTOOLS_USE_DISTUTILS=stdlib). + +Run from the repository root: + python3 yaml_files/plot_spartan_5d_paraprof_2D.py +""" + +from copy import deepcopy +import numpy as np +import matplotlib.pyplot as plt + +import gambit_plotting_tools.gambit_plot_utils as plot_utils +import gambit_plotting_tools.gambit_plot_settings as gambit_plot_settings +from gambit_plotting_tools.annotate import add_header + + +HDF5_FILE = "runs/spartan_5d/samples/results.hdf5" +GROUP_NAME = "data" + +# Match paraprof's native (grid_points) and Parameters bounds in spartan_5d.yaml +XY_BINS = (200, 200) +PAR_RANGE = [-3.0, 3.0] + +# Keys for each parameter we plot +PARAMS = ["x1", "x2", "x3"] +datasets = [("LogLike", ("LogLike", float))] +for p in PARAMS: + datasets.append( + (p, (f"#trivial_5d_parameters @trivial_5d::primary_parameters::{p}", float)) + ) + +data = plot_utils.read_hdf5_datasets([(HDF5_FILE, GROUP_NAME)], datasets, + filter_invalid_points=True) +print(f"Read {len(data['LogLike'])} valid points; " + f"LogLike range = [{np.min(data['LogLike']):.3f}, " + f"{np.max(data['LogLike']):.3f}]") + + +confidence_levels = [0.954, 0.683] +contour_values = plot_utils.get_2D_likelihood_ratio_levels(confidence_levels) + +plot_labels = { + "x1": r"$x_1$", + "x2": r"$x_2$", + "x3": r"$x_3$", + "LogLike": r"$\ln L$", +} + +base_settings = deepcopy(gambit_plot_settings.plot_settings) +base_settings["interpolation"] = True +base_settings["interpolation_resolution"] = 400 +base_settings["contour_colors"] = ["white", "white"] +base_settings["contour_linestyles"] = ["solid", "dashed"] +base_settings["contour_linewidths"] = [1.0, 1.0] + + +def make_plot(x_key, y_key): + z_key = "LogLike" + labels = (plot_labels[x_key], plot_labels[y_key], plot_labels[z_key]) + xy_bounds = (PAR_RANGE, PAR_RANGE) + + fig, ax, _ = plot_utils.plot_2D_profile( + data[x_key], data[y_key], data[z_key], + labels, XY_BINS, + xy_bounds=xy_bounds, + z_bounds=None, + z_is_loglike=True, + plot_likelihood_ratio=True, + contour_levels=contour_values, + add_max_likelihood_marker=True, + missing_value_color=base_settings["colormap"](0.0), + plot_settings=base_settings, + ) + + header_text = r"Rosenbrock 5D, paraprof scanner" + if plt.rcParams.get("text.usetex"): + header_text = header_text.replace("paraprof scanner", + r"\textsf{paraprof} scanner") + add_header(header_text, ax=ax) + + out = f"runs/spartan_5d/plots/2D_profile_{x_key}_{y_key}.png" + plot_utils.create_folders_if_not_exist(out) + plt.savefig(out, dpi=150) + plt.close() + print(f"Wrote file: {out}") + + +for x_key, y_key in [("x1", "x2"), ("x2", "x3")]: + make_plot(x_key, y_key) diff --git a/yaml_files/spartan.yaml b/yaml_files/spartan.yaml index f65251d8c1..ba6e00e517 100644 --- a/yaml_files/spartan.yaml +++ b/yaml_files/spartan.yaml @@ -376,6 +376,46 @@ Scanner: n_tasks_per_batch: 1 print_progress_every_n_batch: 100 + # The paraprof scanner runs parallel grid-based profile likelihood scans + # using https://github.com/anderkve/paraprof. It requires GAMBIT to be + # built with MPI (cmake -DWITH_MPI=1) and >=2 MPI processes, e.g.: + # + # mpiexec -n 3 ./gambit -rf yaml_files/spartan.yaml + # + # (1 master rank + N-1 worker ranks; the master performs no evaluations.) + paraprof: + like: LogLike + plugin: paraprof + run: + # List of profile likelihood projections to compute. + projections: + - dims: ["NormalDist::mu", "NormalDist::sigma"] + grid_points: [60, 60] + optimization_method: de + grid_refinement_factor: 2 + patch_refined_grid: true + - dims: ["NormalDist::mu"] + grid_points: [50] + # Optional ProfileProjector tuning (paraprof defaults are used if omitted). + roi_threshold: 4.0 + pop_per_grid_point: 3 + n_initial_optimizations: 40 + max_patching_waves: 10 + lbfgsb_max_iter: 20 + lbfgsb_polish: true + use_clustering: true + refinement_direct_eval: false + # samples_output_file: paraprof_samples.csv + # Paraprof-side diagnostic plots. + save_plots: true + plot_settings: + dpi: 200 + filetype: png + # Contour levels drawn on the 2D/N-D profile log-likelihood plots, + # expressed as log L - log L_best-fit. Defaults to [-3.0, -1.0] + # (~68% and ~95% CL for a 1-d.o.f. chi-squared approximation). + contour_levels: [-4.5, -3.0, -1.0] + ObsLikes: diff --git a/yaml_files/spartan_5d.yaml b/yaml_files/spartan_5d.yaml new file mode 100644 index 0000000000..1bb566ff2e --- /dev/null +++ b/yaml_files/spartan_5d.yaml @@ -0,0 +1,128 @@ +########################################################################## +## GAMBIT configuration for a 5D test scan of the Rosenbrock function +## using the paraprof scanner. +## +## Uses the `trivial_5d` toy model and the `rosenbrock` log-likelihood +## from ObjectivesBit -- no physics modules, no backends. +## +## Run with (rebuild GAMBIT with -DWITH_MPI=True first): +## +## OMP_NUM_THREADS=1 mpiexec -np 3 ./gambit -rf yaml_files/spartan_5d.yaml +## +## The 2D paraprof projection over (x1, x2) profiles over (x3, x4, x5) +## at every grid point, which is what makes this a real test of paraprof's +## profiling machinery. +########################################################################## + + +Parameters: + trivial_5d: + x1: + range: [-3.0, 3.0] + x2: + range: [-3.0, 3.0] + x3: + range: [-3.0, 3.0] + x4: + range: [-3.0, 3.0] + x5: + range: [-3.0, 3.0] + + +Priors: + + # None needed: simple flat priors are specified in the 'Parameters' section above. + + +Printer: + + printer: hdf5 + options: + output_file: "results.hdf5" + group: "/data" + delete_file_on_restart: true + buffer_length: 1000 + + +Scanner: + + use_scanner: paraprof + + scanners: + + paraprof: + like: LogLike + plugin: paraprof + run: + # 2D (x1, x2) projection profiles over (x3, x4, x5). + # 1D (x1) projection profiles over (x2, x3, x4, x5). + projections: + - dims: ["trivial_5d::x1", "trivial_5d::x2"] + grid_points: [200, 200] + optimization_method: de + grid_refinement_factor: 1 + patch_refined_grid: false + - dims: ["trivial_5d::x2", "trivial_5d::x3"] + grid_points: [200, 200] + optimization_method: de + grid_refinement_factor: 1 + patch_refined_grid: false + # - dims: ["trivial_5d::x1"] + # grid_points: [80] + # Rosenbrock's curved valley is deep; keep the ROI generous. + roi_threshold: 10.0 + pop_per_grid_point: 3 + n_initial_optimizations: 40 + max_patching_waves: 15 + lbfgsb_max_iter: 50 + lbfgsb_polish: true + use_clustering: true + refinement_direct_eval: false + # Paraprof-side diagnostic plots. + save_plots: true + plot_settings: + dpi: 300 + filetype: png + # contour_levels: [-4.5, -3.0, -1.0] + contour_levels: [-4.5, -3.0, -1.0] + + +ObsLikes: + + - purpose: LogLike + capability: rosenbrock + module: ObjectivesBit + type: double + + +Rules: + + +Logger: + + redirection: + [Default] : "default.log" + [Scanner] : "Scanner.log" + debug: false + + +KeyValues: + + debug: false + + default_output_path: "${PWD}/runs/spartan_5d" + + print_scanID: true + scanID: 1 + + rng: + generator: ranlux48 + seed: -1 + + print_timing_data: false + + print_unitcube: true + + likelihood: + model_invalid_for_lnlike_below: -1e6 + use_lnlike_modifier: identity From b5e50560168801d7fb01fc84c2f62a20fa2043ad Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 21:31:44 +0200 Subject: [PATCH 3/6] Tweaks to spartan yaml files --- yaml_files/spartan.yaml | 15 ++------------- yaml_files/spartan_5d.yaml | 3 +-- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/yaml_files/spartan.yaml b/yaml_files/spartan.yaml index ba6e00e517..874f33e304 100644 --- a/yaml_files/spartan.yaml +++ b/yaml_files/spartan.yaml @@ -376,13 +376,6 @@ Scanner: n_tasks_per_batch: 1 print_progress_every_n_batch: 100 - # The paraprof scanner runs parallel grid-based profile likelihood scans - # using https://github.com/anderkve/paraprof. It requires GAMBIT to be - # built with MPI (cmake -DWITH_MPI=1) and >=2 MPI processes, e.g.: - # - # mpiexec -n 3 ./gambit -rf yaml_files/spartan.yaml - # - # (1 master rank + N-1 worker ranks; the master performs no evaluations.) paraprof: like: LogLike plugin: paraprof @@ -396,7 +389,6 @@ Scanner: patch_refined_grid: true - dims: ["NormalDist::mu"] grid_points: [50] - # Optional ProfileProjector tuning (paraprof defaults are used if omitted). roi_threshold: 4.0 pop_per_grid_point: 3 n_initial_optimizations: 40 @@ -409,12 +401,9 @@ Scanner: # Paraprof-side diagnostic plots. save_plots: true plot_settings: - dpi: 200 + dpi: 300 filetype: png - # Contour levels drawn on the 2D/N-D profile log-likelihood plots, - # expressed as log L - log L_best-fit. Defaults to [-3.0, -1.0] - # (~68% and ~95% CL for a 1-d.o.f. chi-squared approximation). - contour_levels: [-4.5, -3.0, -1.0] + contour_levels: [-3.09, -1.15] ObsLikes: diff --git a/yaml_files/spartan_5d.yaml b/yaml_files/spartan_5d.yaml index 1bb566ff2e..4ceb19287c 100644 --- a/yaml_files/spartan_5d.yaml +++ b/yaml_files/spartan_5d.yaml @@ -83,8 +83,7 @@ Scanner: plot_settings: dpi: 300 filetype: png - # contour_levels: [-4.5, -3.0, -1.0] - contour_levels: [-4.5, -3.0, -1.0] + contour_levels: [-3.09, -1.15] ObsLikes: From aad70084e168a843284646258002991569e816f4 Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 21:32:28 +0200 Subject: [PATCH 4/6] Deleted unnecessary test files --- yaml_files/paraprof_test.yaml | 113 ---------------------- yaml_files/plot_spartan_5d_paraprof_2D.py | 100 ------------------- 2 files changed, 213 deletions(-) delete mode 100644 yaml_files/paraprof_test.yaml delete mode 100644 yaml_files/plot_spartan_5d_paraprof_2D.py diff --git a/yaml_files/paraprof_test.yaml b/yaml_files/paraprof_test.yaml deleted file mode 100644 index 4a1075fb5c..0000000000 --- a/yaml_files/paraprof_test.yaml +++ /dev/null @@ -1,113 +0,0 @@ -########################################################################## -## Minimal GAMBIT configuration for testing the paraprof scanner plugin. -## -## Uses the 2D NormalDist toy model in ExampleBit_A so the scan stays cheap. -## -## Run with (1 master + 3 worker ranks): -## -## mpirun -n 4 ./gambit -f yaml_files/paraprof_test.yaml -## -## The paraprof plugin requires >=2 MPI ranks. -########################################################################## - - -Parameters: - NormalDist: - mu: - prior_type: flat - range: [15.0, 30.0] - sigma: - prior_type: flat - range: [0.0, 5.0] - - -Priors: - # None needed; flat priors are specified above. - - -Printer: - printer: hdf5 - options: - output_file: "paraprof_test.hdf5" - group: "/data" - delete_file_on_restart: true - buffer_length: 1000 - - -Scanner: - - use_scanner: paraprof - - scanners: - - paraprof: - plugin: paraprof - like: LogLike - run: - # One 2D projection over the full parameter space, plus two 1D - # projections. Small grids keep the test quick. - projections: - - dims: ["NormalDist::mu", "NormalDist::sigma"] - grid_points: [12, 12] - optimization_method: de - - dims: ["NormalDist::mu"] - grid_points: [25] - - dims: ["NormalDist::sigma"] - grid_points: [25] - # Light paraprof tuning suitable for a fast smoke test. - roi_threshold: 4.0 - pop_per_grid_point: 3 - n_initial_optimizations: 20 - max_patching_waves: 5 - lbfgsb_max_iter: 20 - lbfgsb_polish: true - use_clustering: false - refinement_direct_eval: false - save_plots: false - - -ObsLikes: - - - purpose: LogLike - capability: normaldist_loglike - module: ExampleBit_A - type: double - - -Rules: - - - if: - capability: normaldist_loglike - then: - options: - probability_of_validity: 1.0 - - -Logger: - - redirection: - [Default] : "default.log" - [ExampleBit_A] : "ExampleBit_A.log" - [Scanner] : "Scanner.log" - debug: true - - -KeyValues: - - debug: false - - default_output_path: "${PWD}/runs/paraprof_test" - - print_scanID: true - scanID: 1 - - rng: - generator: ranlux48 - seed: -1 - - print_timing_data: false - print_unitcube: true - - likelihood: - model_invalid_for_lnlike_below: -1e6 - use_lnlike_modifier: identity diff --git a/yaml_files/plot_spartan_5d_paraprof_2D.py b/yaml_files/plot_spartan_5d_paraprof_2D.py deleted file mode 100644 index d198cc89b3..0000000000 --- a/yaml_files/plot_spartan_5d_paraprof_2D.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Plot the 2D profile likelihoods for the spartan_5d.yaml paraprof run, -using gambit_plotting_tools and the GAMBIT hdf5 output. - -Generates one plot per paraprof projection in spartan_5d.yaml: - (x1, x2) and (x2, x3). - -Bin counts and axis bounds are matched to the paraprof YAML settings -(200 x 200 native grid over [-3, 3] x [-3, 3]) to avoid re-binning -artefacts. - -Install gambit_plotting_tools first: - pip install --user git+https://github.com/GambitBSM/gambit_plotting_tools.git -(on Debian/Ubuntu you may need SETUPTOOLS_USE_DISTUTILS=stdlib). - -Run from the repository root: - python3 yaml_files/plot_spartan_5d_paraprof_2D.py -""" - -from copy import deepcopy -import numpy as np -import matplotlib.pyplot as plt - -import gambit_plotting_tools.gambit_plot_utils as plot_utils -import gambit_plotting_tools.gambit_plot_settings as gambit_plot_settings -from gambit_plotting_tools.annotate import add_header - - -HDF5_FILE = "runs/spartan_5d/samples/results.hdf5" -GROUP_NAME = "data" - -# Match paraprof's native (grid_points) and Parameters bounds in spartan_5d.yaml -XY_BINS = (200, 200) -PAR_RANGE = [-3.0, 3.0] - -# Keys for each parameter we plot -PARAMS = ["x1", "x2", "x3"] -datasets = [("LogLike", ("LogLike", float))] -for p in PARAMS: - datasets.append( - (p, (f"#trivial_5d_parameters @trivial_5d::primary_parameters::{p}", float)) - ) - -data = plot_utils.read_hdf5_datasets([(HDF5_FILE, GROUP_NAME)], datasets, - filter_invalid_points=True) -print(f"Read {len(data['LogLike'])} valid points; " - f"LogLike range = [{np.min(data['LogLike']):.3f}, " - f"{np.max(data['LogLike']):.3f}]") - - -confidence_levels = [0.954, 0.683] -contour_values = plot_utils.get_2D_likelihood_ratio_levels(confidence_levels) - -plot_labels = { - "x1": r"$x_1$", - "x2": r"$x_2$", - "x3": r"$x_3$", - "LogLike": r"$\ln L$", -} - -base_settings = deepcopy(gambit_plot_settings.plot_settings) -base_settings["interpolation"] = True -base_settings["interpolation_resolution"] = 400 -base_settings["contour_colors"] = ["white", "white"] -base_settings["contour_linestyles"] = ["solid", "dashed"] -base_settings["contour_linewidths"] = [1.0, 1.0] - - -def make_plot(x_key, y_key): - z_key = "LogLike" - labels = (plot_labels[x_key], plot_labels[y_key], plot_labels[z_key]) - xy_bounds = (PAR_RANGE, PAR_RANGE) - - fig, ax, _ = plot_utils.plot_2D_profile( - data[x_key], data[y_key], data[z_key], - labels, XY_BINS, - xy_bounds=xy_bounds, - z_bounds=None, - z_is_loglike=True, - plot_likelihood_ratio=True, - contour_levels=contour_values, - add_max_likelihood_marker=True, - missing_value_color=base_settings["colormap"](0.0), - plot_settings=base_settings, - ) - - header_text = r"Rosenbrock 5D, paraprof scanner" - if plt.rcParams.get("text.usetex"): - header_text = header_text.replace("paraprof scanner", - r"\textsf{paraprof} scanner") - add_header(header_text, ax=ax) - - out = f"runs/spartan_5d/plots/2D_profile_{x_key}_{y_key}.png" - plot_utils.create_folders_if_not_exist(out) - plt.savefig(out, dpi=150) - plt.close() - print(f"Wrote file: {out}") - - -for x_key, y_key in [("x1", "x2"), ("x2", "x3")]: - make_plot(x_key, y_key) From 8f7abfb57cd194de1522da18c133aa54b36de0c6 Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 21:55:22 +0200 Subject: [PATCH 5/6] Deleted the binminpy scanner plugin. Now replaced by the paraprof scanner. --- .../python/plugins/gambit_binminpy.py | 167 ------------------ .../python/plugins/gambit_paraprof.py | 2 +- cmake/python_scanners.cmake | 1 - optional.txt | 4 +- yaml_files/spartan.yaml | 24 --- 5 files changed, 3 insertions(+), 195 deletions(-) delete mode 100644 ScannerBit/src/scanners/python/plugins/gambit_binminpy.py diff --git a/ScannerBit/src/scanners/python/plugins/gambit_binminpy.py b/ScannerBit/src/scanners/python/plugins/gambit_binminpy.py deleted file mode 100644 index 676874f6d3..0000000000 --- a/ScannerBit/src/scanners/python/plugins/gambit_binminpy.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Binminpy scanners -================= -""" - -import numpy as np -from scannerbit import with_mpi as scannerbit_with_mpi -from utils import copydoc, version, with_mpi -try: - import binminpy - binminpy_version = version(binminpy) - from binminpy.BinMinBottomUp import BinMinBottomUp as binminpy_BinMinBottomUp -except: - __error__ = "The binminpy package is not installed. To install it, run: pip install git+https://github.com/anderkve/binminpy.git" - binminpy_version = "n/a" - binminpy_BinMinBottomUp = None - -import scanner_plugin as splug - - -class BinMinBottomUp(splug.scanner): - """ -Sampling and optimization based on the "bottom-up" mode of binminpy, where the -parameter space is binned by working outwards from all identified local optima. - -See https://github.com/anderkve/binminpy - -YAML options: - like: Use the functors that correspond to the specified purpose. - run: - n_bins: Number of bins for each parameter, given as a list on the form "model::parameter: ". - sampled_parameters: List of the parameters that should be sampled within each bin, e.g ["model::par_1", "model::par_2"]. - (Remaining parameters will be optimized within each bin.) - sampler: Choice of sampler for sampling parameters within each bin. - optimizer Choice of optimizer for initial global optimization and optimizing parameters within each bin. - optimizer_kwargs: Keyword arguments to be forwarded to the optimzer. - n_initial_points: Number of starting points for the initial search for local optima. - n_sampler_points_per_bin: Number of sampled points within each bin. - accept_loglike_above: Add neighboring bins for bins that have a highest loglike above this threshold. - accept_delta_loglike: Add neighboring bins for bins that have a delta loglike (difference to best-fit point) within this threshold. - neighborhood_distance: If the current bin is accepted, how many bins in each direction should be added to the list of tasks. - inherit_best_init_point_within_bin: When optimizing parameters, start optimization from the current best point within the given bin. - n_optim_restarts_per_bin: Number of repeated attempts at optimizing parameters per bin. - n_tasks_per_batch: Number of tasks (bins) assigned to each MPI worker process at a time. - print_progress_every_n_batch: How frequently the progress message is printed. -""" - - __version__ = binminpy_version - __plugin_name__ = "binminpy" - - - def __init__(self, **kwargs): - if not scannerbit_with_mpi: - raise Exception(f"GAMBIT has been compiled with MPI disabled (WITH_MPI=0), but the " - f"binminpy scanner requires MPI parallelisation with >1 MPI processes. " - f"Rerun CMake with \"cmake -DWITH_MPI=1\" and then recompile GAMBIT.") - if not with_mpi: - raise Exception(f"The binminpy scanner requires MPI parallelisation with >1 MPI processes. " - f"Make sure that mpi4py is installed and restart GAMBIT with >1 processes.") - - super().__init__(use_mpi=True, use_resume=False) - - self.print_prefix = f"{BinMinBottomUp.__plugin_name__} scanner plugin:" - - - def run(self): - - # Define target function: this is where we call the GAMBIT loglike function - def target_function(x, *args): - return -self.loglike_hypercube(x) - - # Get the parameter ordering from GAMBIT - par_indices = {par_name:idx for idx,par_name in enumerate(self.parameter_names)} - - # Set up the list of binning tuples - binning_tuples = [] - if not "n_bins" in self.run_args: - raise RuntimeError(f"{self.print_prefix} The run argument 'n_bins' is missing.") - for param_name in self.parameter_names: - if not param_name in self.run_args["n_bins"]: - self.run_args["n_bins"][param_name] = 1 - par_n_bins = self.run_args["n_bins"][param_name] - binning_tuples.append([0., 1., par_n_bins]) # <-- Working in the unit hypercube - - # Read list of sampled parameters, and remove any duplicate entries - sampled_parameter_names = list(set(self.run_args.get("sampled_parameters", []))) - # Remove any parameter that is not scanned by GAMBIT - sampled_parameter_names = [par_name for par_name in sampled_parameter_names if par_name in self.parameter_names] - # All parameters that are not listed as sampled parameters should be optimized - optimized_parameter_names = list(set(self.parameter_names).difference(sampled_parameter_names)) - - if self.mpi_rank == 0: - print(f"{self.print_prefix} Parameters that will be *sampled* in each bin: {sampled_parameter_names}", flush=True) - print(f"{self.print_prefix} Parameters that will be *optimized* in each bin: {optimized_parameter_names}", flush=True) - - # Parse options for restricting the set of parameter bins - if ("accept_loglike_above" in self.run_args) and ("accept_delta_loglike" in self.run_args): - if self.mpi_rank == 0: - print(f"{self.print_prefix} Both 'accept_loglike_above' and 'accept_delta_loglike' have been set. " - f"Will use the weaker of the two requirements when constructing bins.", flush=True) - - accept_target_below = -np.inf - if "accept_loglike_above" in self.run_args: - accept_target_below = -1.0 * self.run_args["accept_loglike_above"] - - accept_delta_target_below = -np.inf - if "accept_delta_loglike" in self.run_args: - accept_delta_target_below = abs(self.run_args["accept_delta_loglike"]) - - if (accept_target_below == -np.inf) and (accept_delta_target_below == -np.inf): - if self.mpi_rank == 0: - print(f"{self.print_prefix} Running with no restrictions on the set of parameter space bins.", flush=True) - accept_target_below = np.inf - accept_delta_target_below = np.inf - - # Create the BinMinBottomUp instance - binned_opt = binminpy_BinMinBottomUp( - target_function, - binning_tuples, - args=(), - sampler=self.run_args.get("sampler", "latinhypercube"), - optimizer=self.run_args.get("optimizer", "minimize"), - optimizer_kwargs=self.run_args.get("optimizer_kwargs", {}), - sampled_parameters=tuple(par_indices[par_name] for par_name in sampled_parameter_names), - n_initial_points=self.run_args.get("n_initial_points", self.mpi_size), - n_sampler_points_per_bin=self.run_args.get("n_sampler_points_per_bin", 10), - inherit_best_init_point_within_bin=self.run_args.get("inherit_best_init_point_within_bin", False), - accept_target_below=accept_target_below, - accept_delta_target_below=accept_delta_target_below, - save_evals=self.run_args.get("save_evals", False), - return_evals=False, - return_bin_centers=False, - return_bin_results=False, - optima_comparison_rtol=self.run_args.get("optima_comparison_rtol", 1e-9), - optima_comparison_atol=self.run_args.get("optima_comparison_atol", 0.0), - neighborhood_distance=self.run_args.get("neighborhood_distance", 1), - n_optim_restarts_per_bin=self.run_args.get("n_optim_restarts_per_bin", 1), - n_tasks_per_batch=self.run_args.get("n_tasks_per_batch", 10), - print_progress_every_n_batch=self.run_args.get("print_progress_every_n_batch", 1000), - max_tasks_per_worker=self.run_args.get("max_tasks_per_worker", np.inf), - max_n_bins=self.run_args.get("max_n_bins", np.inf), - ) - - # Run the scan! - result = binned_opt.run() - - # Print a summary - if self.mpi_rank == 0: - best_bins = result["optimal_bins"] - print() - print(f"{self.print_prefix} # Global optima found in bin(s) {best_bins}:", flush=True) - for i, bin_index_tuple in enumerate(best_bins): - x_opt_physical = self.transform_to_vec(result['x_optimal'][i]) - x_print_dict = dict(zip(self.parameter_names, x_opt_physical.tolist())) - print(f"{self.print_prefix} - Bin {bin_index_tuple}:", flush=True) - print(f"{self.print_prefix} - Parameters:", flush=True) - for key, val in x_print_dict.items(): - print(f"{self.print_prefix} - {key}: {val}") - print(f"{self.print_prefix} - Log-likelihood: {result['y_optimal'][i]}", flush=True) - print() - print(f"{self.print_prefix} Target function calls: {result['n_target_calls']}", flush=True) - print() - - return 0 - - -__plugins__ = {BinMinBottomUp.__plugin_name__: BinMinBottomUp} diff --git a/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py index 55c6390d57..3ec8e0392c 100644 --- a/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py +++ b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py @@ -216,7 +216,7 @@ def target_func(x): def _print_summary(self, results): - """Emit a per-projection summary on rank 0, in the binminpy/scipy style.""" + """Emit a per-projection summary on rank 0, in the scipy style.""" prefix = self.print_prefix print() print(f"{prefix} === Scan summary ===", flush=True) diff --git a/cmake/python_scanners.cmake b/cmake/python_scanners.cmake index 4d7d2976cc..e2a38dabe3 100644 --- a/cmake/python_scanners.cmake +++ b/cmake/python_scanners.cmake @@ -82,6 +82,5 @@ check_python_scanner_modules(scipy_shgo "scipy,numpy" "scipy,numpy") check_python_scanner_modules(scipy_minimize "scipy,numpy" "scipy,numpy") check_python_scanner_modules(reactive_ultranest "ultranest,numpy,packaging" "ultranest,numpy,packaging") check_python_scanner_modules(zeus "zeus,numpy" "zeus-mcmc,numpy") -check_python_scanner_modules(binminpy "binminpy,numpy,scipy,mpi4py" "binminpy,numpy,scipy,mpi4py") check_python_scanner_modules(paraprof "paraprof,numpy,scipy,mpi4py" "paraprof,numpy,scipy,mpi4py") diff --git a/optional.txt b/optional.txt index d639b518c6..0f33ebe17a 100644 --- a/optional.txt +++ b/optional.txt @@ -12,9 +12,9 @@ zeus-mcmc numba pyhf pandas +paraprof @ git+https://github.com/anderkve/paraprof.git@main matplotlib tensorflow iminuit future -dill -binminpy @ git+https://github.com/anderkve/binminpy.git@main \ No newline at end of file +dill \ No newline at end of file diff --git a/yaml_files/spartan.yaml b/yaml_files/spartan.yaml index 874f33e304..7ca2c1bf84 100644 --- a/yaml_files/spartan.yaml +++ b/yaml_files/spartan.yaml @@ -352,30 +352,6 @@ Scanner: min_num_live_points: 1000 dlogz: 0.5 - binminpy: - like: LogLike - plugin: binminpy - run: - n_bins: - # For any parameters not listed under 'n_bins' binminpy will assume a single bin - NormalDist::mu: 100 - NormalDist::sigma: 100 - # sampled_parameters: ["NormalDist::mu", "NormalDist::sigma"] # By default parameters are optimized within each bin - sampler: "latinhypercube" - optimizer: "minimize" - optimizer_kwargs: - method: "L-BFGS-B" - tol: 1e-4 - n_initial_points: 10 - n_sampler_points_per_bin: 10 - # accept_loglike_above: -30.0 - accept_delta_loglike: 6.5 - neighborhood_distance: 2 - inherit_best_init_point_within_bin: False - n_optim_restarts_per_bin: 1 - n_tasks_per_batch: 1 - print_progress_every_n_batch: 100 - paraprof: like: LogLike plugin: paraprof From 7f4d770ecc3f4729f48e66d7dea94c8b797f8d25 Mon Sep 17 00:00:00 2001 From: Anders Kvellestad Date: Thu, 21 May 2026 22:25:06 +0200 Subject: [PATCH 6/6] Deleted overly defensive try-except --- .../src/scanners/python/plugins/gambit_paraprof.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py index 3ec8e0392c..abba36d3b0 100644 --- a/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py +++ b/ScannerBit/src/scanners/python/plugins/gambit_paraprof.py @@ -244,14 +244,10 @@ def _print_summary(self, results): or {}) best_x_unit = self._best_full_params(best_solution) if best_x_unit is not None: - try: - best_x_phys = self.transform_to_vec(np.asarray(best_x_unit)) - print(f"{prefix} best-fit point (physical):", flush=True) - for name, val in zip(self.parameter_names, best_x_phys): - print(f"{prefix} {name}: {val}", flush=True) - except Exception: - # Defensive: never let pretty-printing kill the scan. - pass + best_x_phys = self.transform_to_vec(np.asarray(best_x_unit)) + print(f"{prefix} best-fit point (physical):", flush=True) + for name, val in zip(self.parameter_names, best_x_phys): + print(f"{prefix} {name}: {val}", flush=True) print()