From 835ecaa138b5b920c5ac52d4e58b9621ec5c44c6 Mon Sep 17 00:00:00 2001 From: Luca Lavezzo Date: Mon, 22 Jun 2026 15:49:07 -0400 Subject: [PATCH 1/3] Blinding groups and full-name parameter matching --blindingGroup: regex-defined groups of parameters that share a single deterministic blinding offset, seeded from the group regex string itself. This keeps relative pulls / differences between matched parameters meaningful while still blinding their absolute values (e.g. --blindingGroup '^alphaS_y\d+$' applies one common offset to all alphaS rapidity-bin parameters). Parameters not matching any group keep per-name blinding. A parameter matching both --unblind and --blindingGroup is a configuration error and aborts the fit, to avoid silently picking one interpretation. match_regexp_params now matches each expression against the FULL parameter name (re.fullmatch) and returns the union of exact and regex matches (de-duplicated, order-preserving) instead of short-circuiting on the first exact match. Full anchoring means an expression naming one parameter exactly can no longer also match parameters whose names merely extend it (a prefix match could silently unblind more than intended); match a family with an explicit pattern, e.g. 'alphaS.*'. The parameters an --unblind expression resolves to are now logged at INFO. Used by --unblind, --freezeParameters and --blindingGroup; help texts updated. Co-Authored-By: Claude Fable 5 --- rabbit/fitter.py | 103 +++++++++++++++++++++++++++--------- rabbit/parsing.py | 24 +++++++-- tests/test_external_term.py | 1 + tests/test_sparse_fit.py | 1 + 4 files changed, 102 insertions(+), 27 deletions(-) diff --git a/rabbit/fitter.py b/rabbit/fitter.py index d9258ae..b87170f 100644 --- a/rabbit/fitter.py +++ b/rabbit/fitter.py @@ -31,23 +31,32 @@ def match_regexp_params(regular_expressions, parameter_names): + # Match parameter names against a list of expressions where each entry may + # be either an exact parameter name or a regex matched against the FULL + # parameter name (re.fullmatch). Full anchoring means an expression that + # names one parameter exactly can never also match parameters whose names + # merely extend it (important for --unblind, where a prefix match would + # silently unblind more than intended); match a family of parameters with + # an explicit pattern, e.g. 'alphaS.*'. Returns the union of exact and + # regex matches, preserving the parameter_names order and de-duplicating. + # Mixing exact and regex entries in the same call is supported. if isinstance(regular_expressions, str): regular_expressions = [regular_expressions] - # Check for exact matches first - exact_matches = [ - s for expr in regular_expressions for s in parameter_names if s.decode() == expr - ] - if exact_matches: - return exact_matches - - # Fall back to regex matching + exact_lookup = set(regular_expressions) compiled_expressions = [re.compile(expr) for expr in regular_expressions] - return [ - s - for s in parameter_names - if any(regex.match(s.decode()) for regex in compiled_expressions) - ] + + matched = [] + seen = set() + for s in parameter_names: + decoded = s.decode() if hasattr(s, "decode") else s + if decoded in exact_lookup or any( + r.fullmatch(decoded) for r in compiled_expressions + ): + if decoded not in seen: + seen.add(decoded) + matched.append(s) + return matched class FitterCallback: @@ -207,6 +216,7 @@ def __init__( param_model, options.setConstraintMinimum, unblind=options.unblind, + blinding_group=options.blindingGroup, freeze_parameters=options.freezeParameters, ) @@ -219,6 +229,7 @@ def init_fit_parms( param_model, set_constraint_minimum=[], unblind=False, + blinding_group=[], freeze_parameters=[], ): self.param_model = param_model @@ -246,7 +257,7 @@ def init_fit_parms( trainable=False, name="offset_theta", ) - self.init_blinding_values(unblind) + self.init_blinding_values(unblind, blinding_group) self.parms = np.concatenate([self.param_model.params, self.indata.systs]) @@ -466,15 +477,25 @@ def defreeze_params(self, unfrozen_parmeter_expressions): ] self.update_frozen_params() - def init_blinding_values(self, unblind_parameter_expressions=[]): + def init_blinding_values( + self, unblind_parameter_expressions=[], blinding_group_expressions=[] + ): logger.debug(f"Unblind parameters with {unblind_parameter_expressions}") + all_param_names = [ + *self.param_model.params[: self.param_model.npoi], + *[self.indata.systs[i] for i in self.indata.noiidxs], + ] unblind_parameters = match_regexp_params( - unblind_parameter_expressions, - [ - *self.param_model.params[: self.param_model.npoi], - *[self.indata.systs[i] for i in self.indata.noiidxs], - ], + unblind_parameter_expressions, all_param_names ) + # unblinding is sensitive: always report exactly which parameters the + # expressions resolved to, so an over-broad pattern is visible + if unblind_parameters: + unblind_names = [ + p.decode() if isinstance(p, bytes) else str(p) + for p in unblind_parameters + ] + logger.info(f"Unblinding {len(unblind_names)} parameters: {unblind_names}") # check if dataset is an integer (i.e. if it is real data or not) and use this to choose the random seed is_dataobs_int = np.sum( @@ -498,14 +519,47 @@ def deterministic_random_from_string(s, mean=0.0, std=5.0): value = rng.normal(loc=mean, scale=std) return value + # Build a map: param name -> seed string. Default is the param's own name; for + # parameters matching a blinding group the seed is the group regex string, so all + # members of the group share an identical deterministic offset (preserving relative + # differences while blinding absolute values). + if isinstance(blinding_group_expressions, str): + blinding_group_expressions = [blinding_group_expressions] + param_to_seed = {} + param_to_group = {} + for expr in blinding_group_expressions: + matched = match_regexp_params(expr, all_param_names) + for p in matched: + if p in param_to_seed: + continue # first matching group wins + param_to_seed[p] = expr + param_to_group[p] = expr + + # Refuse to run if --unblind and --blindingGroup match the same parameter: + # the user's intent is ambiguous (unblind it, or share a group offset?), and + # silently picking one risks accidentally unblinding a sensitive parameter. + overlap = [p for p in unblind_parameters if p in param_to_group] + if overlap: + details = ", ".join( + f"{p.decode() if isinstance(p, bytes) else p}" + f" (group '{param_to_group[p]}')" + for p in overlap + ) + raise RuntimeError( + "The following parameters match both --unblind and --blindingGroup; " + "refusing to proceed to avoid an ambiguous (un)blinding. " + f"Tighten the regexes to make the intent explicit: {details}" + ) + # multiply offset to nois self._blinding_values_theta = np.zeros(self.indata.nsyst, dtype=np.float64) for i in self.indata.noiidxs: param = self.indata.systs[i] if param in unblind_parameters: continue - logger.debug(f"Blind parameter {param}") - value = deterministic_random_from_string(param) + seed = param_to_seed.get(param, param) + logger.debug(f"Blind parameter {param} (seed='{seed}')") + value = deterministic_random_from_string(seed) self._blinding_values_theta[i] = value # add offset to pois @@ -514,8 +568,9 @@ def deterministic_random_from_string(s, mean=0.0, std=5.0): param = self.param_model.params[i] if param in unblind_parameters: continue - logger.debug(f"Blind signal strength modifier for {param}") - value = deterministic_random_from_string(param) + seed = param_to_seed.get(param, param) + logger.debug(f"Blind parameter {param} (seed='{seed}')") + value = deterministic_random_from_string(seed) self._blinding_values_poi[i] = np.exp(value) def set_blinding_offsets(self, blind=True): diff --git a/rabbit/parsing.py b/rabbit/parsing.py index 085ba1e..7bf37f2 100644 --- a/rabbit/parsing.py +++ b/rabbit/parsing.py @@ -269,8 +269,25 @@ def common_parser(): nargs="*", action=OptionalListAction, help=""" - Specify list of regex to unblind matching parameters of interest. - E.g. use '--unblind ^signal$' to unblind a parameter named signal or '--unblind' to unblind all. + Specify list of expressions to unblind matching parameters of interest. + Each entry is an exact parameter name or a regex matched against the full parameter + name (use e.g. 'alphaS.*' to match a family of parameters). + E.g. use '--unblind signal' to unblind a parameter named signal or '--unblind' to unblind all. + """, + ) + parser.add_argument( + "--blindingGroup", + type=str, + default=[], + nargs="*", + help=""" + Specify list of regex (matched against the full parameter name) defining groups of + parameters that share a single deterministic + blinding offset (seeded from the regex string itself). Useful to keep relative pulls / + differences between matched parameters meaningful while still blinding their absolute + values. E.g. '--blindingGroup ^alphaS_y\\d+$' applies the same offset to all alphaS + rapidity-bin parameters. Parameters not matching any group keep per-name blinding. + Overlap with --unblind is treated as a configuration error and aborts the fit. """, ) parser.add_argument( @@ -286,7 +303,8 @@ def common_parser(): default=[], nargs="+", help=""" - Specify list of regex to freeze matching parameters of interest. + Specify list of expressions to freeze matching parameters of interest + (exact names or regex matched against the full parameter name). """, ) parser.add_argument( diff --git a/tests/test_external_term.py b/tests/test_external_term.py index bec8bf1..7342299 100644 --- a/tests/test_external_term.py +++ b/tests/test_external_term.py @@ -42,6 +42,7 @@ def make_options(**kwargs): freezeParameters=[], setConstraintMinimum=[], unblind=[], + blindingGroup=[], ) defaults.update(kwargs) return SimpleNamespace(**defaults) diff --git a/tests/test_sparse_fit.py b/tests/test_sparse_fit.py index 4023178..81b8c6d 100644 --- a/tests/test_sparse_fit.py +++ b/tests/test_sparse_fit.py @@ -153,6 +153,7 @@ def make_options(**kwargs): freezeParameters=[], setConstraintMinimum=[], unblind=[], + blindingGroup=[], ) defaults.update(kwargs) return SimpleNamespace(**defaults) From c3d719d2fe8bc78cf5de88daee8f922e3f56cb6f Mon Sep 17 00:00:00 2001 From: Luca Lavezzo Date: Mon, 22 Jun 2026 16:34:18 -0400 Subject: [PATCH 2/3] tests: per-run output dir for test_global_asym_impacts test_global_asym_impacts.py wrote to a fixed /tmp/test_global_asym_impacts path, so two CI runs touching it at the same time (e.g. several open PRs) clobbered each other's intermediate fit results -> spurious FileNotFound. Derive OUTDIR from the per-run RABBIT_OUTDIR (a unique uuid dir in CI), falling back to a fresh tempfile.mkdtemp() locally. It is the only test with a hardcoded shared output path; the fit CI jobs already use RABBIT_OUTDIR. Co-Authored-By: Claude Fable 5 --- tests/test_global_asym_impacts.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_global_asym_impacts.py b/tests/test_global_asym_impacts.py index 4d4807f..880f054 100644 --- a/tests/test_global_asym_impacts.py +++ b/tests/test_global_asym_impacts.py @@ -26,13 +26,20 @@ import shutil import subprocess import sys +import tempfile from pathlib import Path import numpy as np from rabbit import io_tools -OUTDIR = Path("/tmp/test_global_asym_impacts") +# Use a per-run output directory so concurrent CI runs (e.g. several open PRs) +# don't clobber each other's intermediate fit results on a shared path. In CI +# RABBIT_OUTDIR is a unique uuid dir; locally fall back to a fresh temp dir. +OUTDIR = ( + Path(os.environ.get("RABBIT_OUTDIR") or tempfile.mkdtemp()) + / "test_global_asym_impacts" +) TENSOR = OUTDIR / "test_tensor.hdf5" # Names taken from tests/make_tensor.py From 95fe050803cdf3cd01e659d97192153cc6268033 Mon Sep 17 00:00:00 2001 From: Luca Lavezzo <38077849+lucalavezzo@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:39:42 -0400 Subject: [PATCH 3/3] Clean up comments in test_global_asym_impacts.py Removed comments about output directory handling in CI. --- tests/test_global_asym_impacts.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_global_asym_impacts.py b/tests/test_global_asym_impacts.py index 880f054..21afd5e 100644 --- a/tests/test_global_asym_impacts.py +++ b/tests/test_global_asym_impacts.py @@ -33,9 +33,6 @@ from rabbit import io_tools -# Use a per-run output directory so concurrent CI runs (e.g. several open PRs) -# don't clobber each other's intermediate fit results on a shared path. In CI -# RABBIT_OUTDIR is a unique uuid dir; locally fall back to a fresh temp dir. OUTDIR = ( Path(os.environ.get("RABBIT_OUTDIR") or tempfile.mkdtemp()) / "test_global_asym_impacts"