primat.sensitivity — logarithmic sensitivity tables

primat.sensitivity — logarithmic sensitivity of BBN observables to parameters.

This module promotes the ad-hoc finite-difference loop that used to live in notebooks/Sensitivity.ipynb into a reusable public API. Referees of BBN papers routinely ask for a sensitivity table

\[S(O, p) \equiv \frac{\partial \ln O}{\partial \ln p},\]

the dimensionless “if parameter \(p\) rises by 1 %, observable \(O\) rises by \(S\) %”. sensitivity_table() computes the whole matrix by symmetric finite-differencing full BBN solves and returns a SensitivityTable dataclass with to_markdown() / to_dataframe() views ready to paste into a paper or notebook.

Why symmetric finite differences? For a fractional step \(\delta\) the central estimate

\[S(O, p) \approx \frac{\ln O\!\left(p(1+\delta)\right) - \ln O\!\left(p(1-\delta)\right)} {2\,\ln(1+\delta)}\]

is accurate to \(O(\delta^2)\) (the linear error term cancels), so the default 1 % step (rel_step=0.01) already gives ~4 correct digits without paying for tiny-step round-off. The denominator 2 ln(1+δ) — rather than the naive — makes the result an exact logarithmic derivative: the two runs sit at \(\ln p \pm \ln(1+\delta)\) in log-parameter space.

Three flavours of parameter need three variation recipes, all expressed through the SensTarget helper (a plain string is auto-classified):

  • Nuclear reaction rates (e.g. "n_p__d_g"): varied through primat’s rescale_nuclear_rates + delta_<rxn> mechanism, which multiplies the median rate by (1 + delta) — see the Rate variation how-to. The resulting number is a genuine \(\partial\ln O/\partial\ln(\text{rate})\).

  • Multiplicative physical parameters with a non-zero fiducial value (tau_n, GN, Omegabh2, …): scaled by (1 ± δ) about their effective value, giving \(\partial\ln O/\partial\ln p\) directly.

  • Additive parameters whose fiducial value is zero, so a multiplicative step is degenerate (DeltaNeff at its SM value 0): varied by an absolute ±step about the base value. The denominator is user-controlled (default 2 ln(1+rel_step)) because the “natural” normalisation of an additive knob is a modelling choice, not a mathematical given.

Example

>>> from primat.sensitivity import sensitivity_table, SensTarget
>>> tab = sensitivity_table(
...     params={"network": "small"},
...     observables=["YPBBN", "DoH"],
...     targets=[
...         "n_p__d_g",                       # a nuclear rate  (auto -> rate)
...         "tau_n",                          # neutron lifetime (auto -> mult.)
...         "Omegabh2",                       # baryon density   (auto -> mult.)
...         SensTarget("DeltaNeff", kind="additive", step=1.0),
...     ],
... )
>>> print(tab.to_markdown())          
| Parameter | $Y_P$ | D/H |
| --- | --- | --- |
| n_p__d_g  | +0.024 | +0.207 |
| ...
class primat.sensitivity.SensTarget(param, label=None, kind='auto', step=None, denom=None)[source]

Bases: object

One row of a sensitivity table: what parameter to vary and how.

A SensTarget knows how to turn a fractional step rel_step into the two bracketing parameter overrides (plus/minus) fed to primat.backend.run_bbn(), plus the finite-difference denominator. Most callers never build one explicitly: passing a bare string to sensitivity_table() constructs SensTarget(param) with kind="auto", which classifies the string as a reaction rate (if it names a reaction in the effective config’s p_rxn table) or a multiplicative physical parameter otherwise.

Variables:
  • param (str) – config-parameter name (e.g. "tau_n", "Omegabh2", "DeltaNeff") or a nuclear reaction name (e.g. "n_p__d_g").

  • label (str | None) – display name for this row in tables. Defaults to param.

  • kind (str) – "auto" (default; classify at resolve time), "rate" (force the delta_<rxn> rescaling mechanism), "param" (force multiplicative p(1±δ)) or "additive" (absolute ±step).

  • step (float | None) – for kind="additive" only, the absolute half-step (e.g. 1.0 for DeltaNeff). Ignored otherwise; defaults to rel_step when None.

  • denom (float | None) – override the finite-difference denominator. None (default) uses 2 ln(1+rel_step), which makes rate/param rows exact logarithmic derivatives. Handy for additive rows where you want a different normalisation.

Parameters:

Example

>>> SensTarget("Omegabh2", label=r"$\Omega_b h^2$")
SensTarget(param='Omegabh2', ...)
>>> SensTarget("DeltaNeff", kind="additive", step=1.0)   # per unit ΔNeff
SensTarget(param='DeltaNeff', ...)
param: str
label: str | None = None
kind: str = 'auto'
step: float | None = None
denom: float | None = None
display_label()[source]

Row label to print — the explicit label or the parameter name.

Return type:

str

resolve(cfg, rel_step)[source]

Return (plus_params, minus_params, denom) for this target.

cfg is the effective PRIMATConfig built from the caller’s params (so fiducial values honour user overrides, e.g. a non-default tau_n). rel_step is the fractional step \(\delta\). The two returned dicts are merged onto the base params for the + and - runs; denom divides ln O+ - ln O-.

Raises:

ValueError – for a multiplicative target whose fiducial value is 0 (a proportional step can never move it), or an unknown kind.

Parameters:
Return type:

tuple[dict, dict, float]

class primat.sensitivity.SensitivityTable(row_labels, observables, obs_labels, values, fiducial, rel_step=0.01)[source]

Bases: object

Result of sensitivity_table(): a (targets × observables) matrix.

Holds the logarithmic-sensitivity matrix plus enough context to render it. values[i, j] is \(\partial \ln O_j / \partial \ln p_i\) (or the additive analogue for additive targets).

Variables:
  • row_labels (list[str]) – display label of each varied parameter (table rows).

  • observables (list[str]) – result-dict keys of each observable (table columns).

  • obs_labels (list[str]) – pretty column headers, aligned with observables.

  • values (numpy.ndarray) – (n_targets, n_observables) float array of sensitivities.

  • fiducial (dict[str, float]) – observable-key → fiducial (unperturbed) value, from the single shared central solve.

  • rel_step (float) – the fractional step used for the finite differences.

Parameters:

Example

>>> tab.to_dataframe()                       
>>> print(tab.to_markdown())                 
>>> tab.values[0, 1]                         # dln(D/H)/dln(first param)
row_labels: list[str]
observables: list[str]
obs_labels: list[str]
values: ndarray
fiducial: dict[str, float]
rel_step: float = 0.01
to_dataframe()[source]

Return the sensitivity matrix as a pandas DataFrame.

Rows are indexed by parameter label, columns by observable label. Requires pandas (an optional dependency); raised as an ImportError with an actionable message if it is missing.

Example

>>> df = tab.to_dataframe()              
>>> df.loc[r"$\tau_n$", "D/H"]          
to_markdown(fmt='+.4f')[source]

Render the table as a GitHub-flavoured Markdown string.

Hand-rolled (no pandas/tabulate dependency) so it always works. fmt is the per-cell format spec (default "+.4f" — signed, 4 decimals, which resolves the ~1e-2–1e-3 sensitivities BBN cares about).

Example

>>> print(tab.to_markdown())             
| Parameter | $Y_P$ | D/H |
| --- | --- | --- |
| $\tau_n$ | +0.7290 | +0.4130 |
Parameters:

fmt (str)

Return type:

str

primat.sensitivity.sensitivity_table(params, observables, targets, rel_step=0.01, *, obs_labels=None, force_backend=None, progress=False)[source]

Compute the logarithmic-sensitivity matrix of BBN observables.

For each target parameter this runs two full BBN solves bracketing the fiducial point (p(1±δ) for multiplicative targets, ±delta for rate targets, base±step for additive targets) and forms the symmetric finite-difference logarithmic derivative \(\partial\ln O/\partial\ln p\). A single shared central solve at the fiducial parameters is run once and stored in the result for reference (the symmetric estimate itself uses only the two bracketing runs).

Cost is 2 * len(targets) + 1 solves; with the default C backend a small-network solve is ~sub-second, so a full 12-rate + 4-parameter table is a few tens of seconds.

Parameters:
  • params (dict[str, Any] | None) – base (“fiducial”) parameter overrides, exactly as accepted by primat.backend.run_bbn() / PRIMAT(params=...). None == all defaults. Fiducial values of multiplicative targets are read from the config built from these params, so overriding e.g. tau_n here shifts the point about which it is differentiated.

  • observables (list[str]) – result-dict keys to differentiate, e.g. ["YPBBN", "DoH", "He3oH", "Li7oH"].

  • targets (list[Any]) – parameters to vary. Each item is either a plain string (auto-classified into a rate or multiplicative parameter — see SensTarget) or an explicit SensTarget for full control (additive knobs, custom labels, custom denominators).

  • rel_step (float) – fractional finite-difference step \(\delta\) (default 0.01 = 1 %). Small enough for ~4-digit accuracy, large enough to stay clear of solver round-off.

  • obs_labels (list[str] | None) – optional pretty column headers aligned with observables; defaults to DEFAULT_OBS_LABELS (falling back to the raw key).

  • force_backend (str | None) – forwarded to primat.backend.run_bbn() (None/"auto" prefers the fast C backend; "c"/"python" force one).

  • progress (bool) – forwarded to run_bbn — leave False to silence the per-solve phase markers during the (many) sensitivity runs.

Returns:

SensitivityTable – with .values the (len(targets), len(observables)) matrix, .to_markdown() / .to_dataframe() views, and .fiducial the shared central observable values.

Return type:

SensitivityTable

Example

>>> tab = sensitivity_table(
...     {"network": "small"},
...     observables=["YPBBN", "DoH"],
...     targets=["tau_n", "Omegabh2", "n_p__d_g"],
... )
>>> tab.values.shape
(3, 2)