Source code for pyfli.simulator.calibration.optimizer

"""Simulation-based calibration of the pyfli simulator config."""

from dataclasses import dataclass, field, replace

import numpy as np
from scipy.optimize import differential_evolution, minimize
from scipy.special import expit, logit
from scipy.stats import beta as beta_dist

from pyfli import logging

from .expected_decay import DecayForwardModel, gated_bin_probabilities
from .irf_estimation import (
    IRFEstimate,
    apply_irf_theta,
    irf_theta_from_estimate,
    irf_theta_names,
)
from .prior_estimation import (
    MAX_CONC,
    BetaPrior,
    PriorSet,
    photon_prior_continuous,
    photon_prior_discrete,
)
from .sim_bridge import build_sim_config, simulate_batch
from .summary_stats import PreparedSim, StatsComparator, compute_stats

STEP = {  # initial Nelder-Mead simplex step per parameter
    "mu_bin": 1.0,
    "log_sigma_ns": 0.3,
    "log_tail_ns": 0.4,
    "a_center": 1.0,
    "log_a_half": 0.5,
    "log_b_max": 1.0,
    "log_dcr": 1.0,
    "log_photon_scale": 0.15,
    "tau2_logit_m": 0.4,
    "tau2_log_k": 0.5,
    "efficiency_logit_m": 0.4,
    "efficiency_log_k": 0.5,
    "alpha1_logit_m": 0.4,
    "alpha1_log_k": 0.5,
}


[docs] @dataclass class CalibrationState: irf: IRFEstimate priors: PriorSet dcr: float # discrete: dark counts / bin / pixel; continuous: config value (scaled by bit / 8 in pyfli) photon_scale: float = 1.0 # continuous only
[docs] def a_range(self): return self.irf.a_range
[docs] def b_range(self): return self.irf.b_range
[docs] @dataclass class ObjectiveContext: fm: DecayForwardModel detector_type: str laser_freq_mhz: float jitter: bool comparator: StatsComparator bg_bins: np.ndarray exp_counts: np.ndarray # all selected pixels exp_peaks: np.ndarray # bg-subtracted smoothed peaks (continuous) data_max: float bit_depth: int | None prior_inflation: float n_sim: int seed: int reference_moments: dict = field(default_factory=dict) reference_weight: float = 0.0 fixed_photon_prior: bool = ( False # True: keep the state's photon prior (e.g. a pooled covering prior) ) inactive_bins: np.ndarray = field(default_factory=lambda: np.array([], dtype=int))
[docs] def active_photon_fraction(state: CalibrationState, ctx: ObjectiveContext) -> float: """ Expected share of signal photons that land in active time bins, for the prior-mean lifetimes of `state` (photons in inactive bins are zeroed in data and simulation). 1 for continuous detectors or when every bin is active. """ fm = ctx.fm if ctx.detector_type != "discrete" or fm.n_active == fm.n_bins or state.irf is None: return 1.0 pr = state.priors tau2 = pr.tau2.mean if pr.efficiency is None or pr.alpha1 is None or pr.mono_fraction >= 1: taus, fracs = [tau2], [1.0] else: e, a, m = pr.efficiency.mean, pr.alpha1.mean, pr.mono_fraction taus, fracs = [tau2 * (1 - e), tau2], [(1 - m) * a, (1 - m) * (1 - a) + m] irf = fm.with_baseline(state.irf.effective_irf(), state.irf.b_max / 2) p = np.asarray(fracs) @ gated_bin_probabilities(np.asarray(taus), irf, fm.period) return float(np.clip(p[fm.active].sum(), 1e-3, 1.0))
[docs] def refresh_photon_prior( state: CalibrationState, ctx: ObjectiveContext ) -> CalibrationState: if ctx.fixed_photon_prior: return state if ctx.detector_type == "discrete": prior, cyc = photon_prior_discrete( ctx.exp_counts, ctx.fm.n_active, state.dcr, ctx.prior_inflation, active_photon_fraction(state, ctx), ) pri = replace(state.priors, photon=prior, photon_range=cyc, bit=16) else: prior, bit = photon_prior_continuous( ctx.exp_peaks, ctx.data_max, ctx.bit_depth, inflation=ctx.prior_inflation, scale_corr=state.photon_scale, ) pri = replace(state.priors, photon=prior, photon_range=(0, 2**bit - 1), bit=bit) return replace(state, priors=pri)
[docs] class ParameterVector: """Maps a flat unconstrained vector to a :class:`CalibrationState` and back.""" def __init__( self, base: CalibrationState, ctx: ObjectiveContext, names: list[str] ) -> None: self.base, self.ctx, self.names = base, ctx, names
[docs] def initial(self) -> np.ndarray: s = self.base vals = dict(zip(irf_theta_names(s.irf.source), irf_theta_from_estimate(s.irf))) vals.update( { "log_a_half": np.log(max(s.irf.a_half, 0.05)), "log_b_max": np.log(max(s.irf.b_max, 1e-6)), "log_dcr": np.log(max(s.dcr, 1e-6)), "log_photon_scale": np.log(s.photon_scale), } ) for name in ("tau2", "efficiency", "alpha1"): p = getattr(s.priors, name) if p is not None: vals[f"{name}_logit_m"] = logit(np.clip(p.mean_norm, 1e-3, 1 - 1e-3)) vals[f"{name}_log_k"] = np.log(p.conc) return np.array([vals[n] for n in self.names], float)
[docs] def state(self, x: np.ndarray) -> CalibrationState: v = dict(zip(self.names, x)) s, fm = self.base, self.ctx.fm irf = s.irf theta_names = irf_theta_names(irf.source) if any(n in v for n in theta_names): theta = irf_theta_from_estimate(irf).copy() for i, n in enumerate(theta_names): if n in v: theta[i] = v[n] if irf.source == "simulated": theta[0] = np.clip(theta[0], 0, fm.n_bins - 1) theta[1] = np.clip(theta[1], np.log(0.3 * fm.dt), np.log(1.5)) theta[2] = np.clip(theta[2], np.log(3e-3), np.log(1.0)) else: theta[0] = np.clip(theta[0], -fm.n_bins / 3, fm.n_bins / 3) irf = apply_irf_theta(irf, fm, theta) if "log_a_half" in v: irf = replace( irf, a_half=float(np.clip(np.exp(v["log_a_half"]), 0.0, fm.n_bins / 8)) ) if "log_b_max" in v: irf = replace(irf, b_max=float(np.clip(np.exp(v["log_b_max"]), 1e-7, 0.5))) dcr = ( float(np.clip(np.exp(v["log_dcr"]), 1e-7, 1e4)) if "log_dcr" in v else s.dcr ) scale = ( float(np.clip(np.exp(v["log_photon_scale"]), 0.2, 5.0)) if "log_photon_scale" in v else s.photon_scale ) priors = s.priors for name in ("tau2", "efficiency", "alpha1"): p = getattr(priors, name) if p is None or f"{name}_logit_m" not in v: continue m = float(expit(v[f"{name}_logit_m"])) k = float(np.clip(np.exp(v[f"{name}_log_k"]), 1.0, MAX_CONC)) priors = replace( priors, **{name: BetaPrior.from_mean_conc(m, k, p.offset, p.scale)} ) return refresh_photon_prior(CalibrationState(irf, priors, dcr, scale), self.ctx)
[docs] def evaluate( state: CalibrationState, ctx: ObjectiveContext, n: int | None = None, seed: int | None = None, ): """Simulate with ``state`` and return ``(distance, parts, sim_batch, sim_stats)``.""" cfg = build_sim_config( state.priors, ctx.detector_type, state.dcr, ctx.laser_freq_mhz, ctx.jitter ) sim = simulate_batch( state.irf.irf_cube(), cfg, state.a_range(), state.b_range(), n or ctx.n_sim, ctx.seed if seed is None else seed, irf_b_ranges=state.irf.b_ranges(), inactive_bins=ctx.inactive_bins, ) sim_stats = compute_stats(sim["decay"], ctx.bg_bins) dist, parts = ctx.comparator.distance(sim_stats) return dist, parts, sim, sim_stats
def _reference_penalty(state: CalibrationState, ctx: ObjectiveContext) -> float: """ ``reference_weight * sum(((prior mean - reference mean) / reference sd)^2)`` in physical units, so it does not depend on the prior's support (``tau2_beta_range`` is re-centred between rounds). The sd is floored at 5 % of the prior's support width. """ if ctx.reference_weight <= 0: return 0.0 pen = 0.0 for name, (m_ref, sd_ref) in ctx.reference_moments.items(): p = getattr(state.priors, name, None) if p is not None: pen += ((p.mean - m_ref) / max(sd_ref, 0.05 * p.scale)) ** 2 return ctx.reference_weight * pen
[docs] def optimize_stage( base: CalibrationState, ctx: ObjectiveContext, names: list[str], max_evals: int, label: str, ) -> tuple[CalibrationState, list[dict]]: if not names or max_evals <= 0: return base, [] pv = ParameterVector(base, ctx, names) x0 = pv.initial() history = [] best = {"f": np.inf, "x": x0} def objective(x): state = pv.state(x) try: dist, parts, *_ = evaluate(state, ctx) except ( Exception ) as exc: # invalid configs (e.g. degenerate IRF) count as bad, not fatal logging.warning(f"[{label}] simulation failed: {exc}") return 1e6 f = dist + _reference_penalty(state, ctx) history.append( { "stage": label, "eval": len(history), "objective": f, **parts, **{n: float(val) for n, val in zip(names, x)}, } ) if f < best["f"]: best.update(f=f, x=np.array(x)) logging.info( f"[{label}] eval {len(history)}: objective {f:.4f} " + " ".join(f"{k}={v:.3f}" for k, v in parts.items()) ) return f simplex = np.vstack( [x0] + [x0 + np.eye(len(x0))[i] * STEP.get(n, 0.3) for i, n in enumerate(names)] ) minimize( objective, x0, method="Nelder-Mead", options={ "maxfev": max_evals, "initial_simplex": simplex, "xatol": 1e-3, "fatol": 1e-3, "adaptive": True, }, ) logging.info( f"[{label}] best objective {best['f']:.4f} after {len(history)} evaluations" ) return pv.state(best["x"]), history
[docs] def stage_parameter_names( state: CalibrationState, ctx: ObjectiveContext, optimize_irf: bool, optimize_noise: bool, optimize_priors: bool, is_bi: bool, ) -> tuple[list[str], list[str]]: """(nuisance names searched with real simulations, prior names searched on the re-weighted bank).""" nuisance = [] if optimize_irf: nuisance += [*irf_theta_names(state.irf.source), "log_a_half", "log_b_max"] if optimize_noise: nuisance += ["log_dcr"] if ctx.detector_type == "continuous": nuisance += ["log_photon_scale"] prior = [] if optimize_priors: prior += ["tau2_logit_m", "tau2_log_k"] if is_bi: prior += [ "efficiency_logit_m", "efficiency_log_k", "alpha1_logit_m", "alpha1_log_k", ] return nuisance, prior
# ----------------------------------------------------------- importance-weighted bank LIFETIME_PRIORS = ("tau2", "efficiency", "alpha1") TRUTH_KEY = {"tau2": "tau2", "efficiency": "Efficiency", "alpha1": "alpha1"}
[docs] class PriorBank: """ Simulated pixels from a broad proposal, re-weighted to any candidate set of Beta priors. The bank is a defensive mixture: ``uniform_share`` of the pixels come from flat Beta(1, 1) priors on the current supports, the rest from the current priors widened about 4x in variance. For target priors ``pi`` each pixel gets the weight ``pi(theta) / q(theta)``, so the statistics of any candidate prior are obtained without re-simulating. IRF, noise and photon priors stay fixed inside one bank. """ def __init__( self, state: CalibrationState, ctx: ObjectiveContext, n: int, seed: int, uniform_share: float = 0.3, ): self.ctx = ctx names = [p for p in LIFETIME_PRIORS if getattr(state.priors, p) is not None] self.names = names cur = {p: getattr(state.priors, p) for p in names} flat = {p: BetaPrior(1.0, 1.0, cur[p].offset, cur[p].scale) for p in names} wide = { p: BetaPrior.from_mean_conc( cur[p].mean_norm, max(cur[p].conc / 4, 2.0), cur[p].offset, cur[p].scale ) for p in names } n_u = int(np.rint(n * uniform_share)) batches = [] for sub_n, pri, s in ((n_u, flat, seed), (n - n_u, wide, seed + 1)): st = replace(state, priors=replace(state.priors, **pri)) cfg = build_sim_config( st.priors, ctx.detector_type, st.dcr, ctx.laser_freq_mhz, ctx.jitter ) batches.append( simulate_batch( st.irf.irf_cube(), cfg, st.a_range(), st.b_range(), sub_n, s, inactive_bins=ctx.inactive_bins, ) ) self.sim = {k: np.concatenate([b[k] for b in batches]) for k in batches[0]} self.prepared = PreparedSim(compute_stats(self.sim["decay"], ctx.bg_bins)) self.mono = (self.sim["Efficiency"] == 1.0) & (self.sim["alpha1"] == 1.0) self.x = { p: np.clip( (self.sim[TRUTH_KEY[p]] - cur[p].offset) / cur[p].scale, 1e-6, 1 - 1e-6 ) for p in names } self.log_q = np.logaddexp( np.log(uniform_share) + self._log_density(flat), np.log(1 - uniform_share) + self._log_density(wide), ) def _log_density(self, priors: dict) -> np.ndarray: out = np.zeros(len(self.mono)) for p in self.names: lp = beta_dist.logpdf(self.x[p], priors[p].a, priors[p].b) out += lp if p == "tau2" else np.where(self.mono, 0.0, lp) return out
[docs] def weights(self, priors: PriorSet) -> np.ndarray: lw = self._log_density({p: getattr(priors, p) for p in self.names}) - self.log_q w = np.exp(lw - lw.max()) return w / w.sum()
def _pooled_rel_var(self, w: np.ndarray) -> np.ndarray: """ Relative variance per bin of the pooled decay of an i.i.d. sample of the bank's size drawn from the candidate prior: ``(sum_i w_i (d_ik - m_k)^2) / n / m_k^2``, i.e. the candidate's per-pixel variance (importance-weighted) divided by ``n``. It deliberately does not use the importance-sampling variance ``sum_i w_i^2 (d_ik - m_k)^2``: that grows as the weights degenerate (narrow candidate priors), and subtracting it would reward those priors. """ if not hasattr(self, "_decay_sq"): self._decay_sq = self.sim["decay"] ** 2 m = w @ self.sim["decay"] var = (w @ self._decay_sq - m**2) / len(w) with np.errstate(divide="ignore", invalid="ignore"): return np.where(m > 0, np.clip(var, 0.0, None) / m**2, 0.0)
[docs] def distance(self, priors: PriorSet) -> tuple[float, dict, float]: w = self.weights(priors) ess = 1.0 / np.sum(w**2) dist, parts = self.ctx.comparator.distance( self.prepared, mean_decay=w @ self.sim["decay"], pixel_weights=w, mean_decay_rel_var=self._pooled_rel_var(w), ) return dist, parts, ess
[docs] def recenter_tau2_support( state: CalibrationState, margin: float = 0.3 ) -> CalibrationState: """ Move ``tau2_beta_range`` to the fitted prior's 0.1-99.9 % range plus ``margin`` on each side, keeping the prior's mean and SD. Initial supports from noisy per-pixel fits are usually far too wide, which wastes the bank and makes the Beta very skewed. """ p = state.priors.tau2 lo_q, hi_q = p.offset + p.scale * beta_dist.ppf([1e-3, 1 - 1e-3], p.a, p.b) width = hi_q - lo_q lo, hi = max(0.05, lo_q - margin * width), hi_q + margin * width if hi_q > p.offset + 0.98 * p.scale: # mass piling at the upper bound: let it grow hi = p.offset + 1.5 * p.scale m, sd = (p.mean - lo) / (hi - lo), p.sd / (hi - lo) k = m * (1 - m) / max(sd**2, 1e-8) - 1 new = BetaPrior.from_mean_conc(m, k, lo, hi - lo) return replace(state, priors=replace(state.priors, tau2=new))
def _prior_bounds(names: list[str]) -> list[tuple[float, float]]: return [ (-6.0, 6.0) if n.endswith("_logit_m") else (0.0, float(np.log(MAX_CONC))) for n in names ]
[docs] def fit_priors_on_bank( base: CalibrationState, ctx: ObjectiveContext, names: list[str], n_bank: int, max_evals: int, round_idx: int, min_ess: float = 300.0, ) -> tuple[CalibrationState, list[dict]]: """One bank round: simulate, global search (differential evolution), Nelder-Mead polish.""" if not names: return base, [] label = f"priors_round{round_idx + 1}" bank = PriorBank(base, ctx, n_bank, seed=ctx.seed + 101 * (round_idx + 1)) pv = ParameterVector(base, ctx, names) bounds = _prior_bounds(names) x0 = np.clip(pv.initial(), [b[0] for b in bounds], [b[1] for b in bounds]) history = [] best = {"f": np.inf, "x": x0} def objective(x): st = pv.state(x) dist, parts, ess = bank.distance(st.priors) f = dist + _reference_penalty(st, ctx) + max(0.0, min_ess / max(ess, 1.0) - 1.0) history.append( { "stage": label, "eval": len(history), "objective": f, "ess": ess, **parts, **{n: float(v) for n, v in zip(names, x)}, } ) if f < best["f"]: best.update(f=f, x=np.array(x)) return f popsize = 8 de_iters = max(1, int(0.7 * max_evals / (popsize * len(names))) - 1) differential_evolution( objective, bounds, x0=x0, popsize=popsize, maxiter=de_iters, tol=1e-3, seed=ctx.seed + round_idx, polish=False, init="sobol", ) remaining = max(max_evals - len(history), 20) simplex = np.vstack( [best["x"]] + [ best["x"] + np.eye(len(names))[i] * 0.5 * STEP.get(n, 0.3) for i, n in enumerate(names) ] ) minimize( objective, best["x"], method="Nelder-Mead", options={ "maxfev": remaining, "initial_simplex": simplex, "xatol": 1e-4, "fatol": 1e-4, "adaptive": True, }, ) state = pv.state(best["x"]) _, parts, ess = bank.distance(state.priors) logging.info( f"[{label}] objective {best['f']:.4f}, ESS {ess:.0f} / {n_bank}: " + " ".join(f"{k}={v:.3f}" for k, v in parts.items()) ) if "tau2_logit_m" in names: state = recenter_tau2_support(state) return state, history
[docs] def run_calibration_rounds( state: CalibrationState, ctx: ObjectiveContext, nuisance: list[str], prior_names: list[str], n_bank: int, rounds: int, max_evals_nuisance: int, max_evals_priors: int, ) -> tuple[CalibrationState, list[dict]]: """Alternate prior fits (bank) and IRF / detector fits (real simulations).""" history = [] rounds = rounds if prior_names else 1 for r in range(rounds): state, h = fit_priors_on_bank( state, ctx, prior_names, n_bank, max_evals_priors, r ) history += h evals = max_evals_nuisance if r == 0 else max(max_evals_nuisance // 2, 20) state, h = optimize_stage( state, ctx, nuisance, evals, f"irf_noise_round{r + 1}" ) history += h return state, history