# Recipes Short user stories for the ground-up `rxmc` described in `groundup_design.md`. Each recipe says what a user wants, how it is spelled, and what behaviour they should expect. Together they are the minimal set of use cases the rewrite must support; every one of them is backed by a notebook or a test in the current repository, or by a design decision recorded in the design document. Every recipe is also a test: `test/recipes/test_recipe_NN_.py` asserts its *Expected behaviour* bullets, and the richest recipes are tutorial notebooks (design document §7 and §9). Snippets assume: ```python import numpy as np, rxmc as rx from rxmc import terms as T, transforms as tf from scipy import stats from sklearn.gaussian_process.kernels import Matern, RBF ``` --- ## 1. Fit a model to data with reported statistical errors *I have `x`, `y`, and a statistical error per point, and a model with a few parameters. I want the posterior.* ```python m, b = rx.Parameter("m", prior=stats.norm(0, 5)), rx.Parameter("b", prior=stats.norm(0, 5)) line = rx.Model(lambda x, m, b: m * x + b, [m, b]) d = rx.Dataset(x, y, y_err) problem = rx.Problem([rx.Constraint([rx.Comparison(d, line)])]) ``` Expected behaviour: - The covariance is `diag(y_err**2)` and nothing else. `problem.chi2(theta)` equals `sum(((y - ym) / y_err)**2)` exactly. - `problem.ndim == 2`, `problem.names == ["m", "b"]`, in declaration order. - `problem.log_posterior`, `problem.sample_prior(n)`, `problem.prior_transform` are all that emcee or dynesty need (recipe 16). ## 2. Infer an unknown noise level *My data have no usable error bars, or I do not trust them. I want to infer the noise magnitude alongside the model.* ```python log_eps = rx.Parameter("log_eps", prior=stats.norm(-2, 2)) c = rx.Constraint([rx.Comparison(d, line)], terms=[T.noise(log_eps)], statistical=False) # or fractional noise: T.noise_fraction(log_eps) # or model error on the average of data and prediction: T.model_error(log_gamma) # or noise growing along x: T.noise(log_eps, basis=T.exp_growth(np.pi), basis_params=(slope,)) ``` Expected behaviour: - With `statistical=False` the inferred noise *replaces* the reported errors; with the default `statistical=True` it is *added* to them. - The posterior of `log_eps` reflects the residual scatter. In the `sampling_algos` scenario its truth is recovered. - `noise_fraction` and `model_error` scale with the prediction, so the covariance changes with the model parameters. That is allowed and costs nothing extra. ## 3. Use the reported systematic errors *The measurement reports a fractional normalisation error and an absolute offset error. I want them in the likelihood as correlated modes.* ```python d = rx.from_measurement(m, reaction=reaction, quantity="dXS/dA") # norm_err, offset_err filled comp = rx.Comparison(d, omp) c = rx.Constraint([comp], terms=comp.reported_terms()) ``` Expected behaviour: - `reported_terms()` returns the offset mode first, then the prediction-scaled normalisation mode. Zero magnitudes yield no term. - Without the call, neither systematic enters the covariance. Nothing is folded in silently. - Re-adding both terms reproduces the pre-0.1 auto-folded covariance to the last digit (`test_regression`, log-likelihood `1.195784087817536`). - Under `space=tf.log` the modes are propagated by the delta method: the offset at the data, the normalisation at the prediction. ## 4. Infer a normalisation or offset the experiment did not report *I suspect an unreported normalisation (or background offset) and want its magnitude as a nuisance parameter.* ```python log_eta = rx.Parameter("log_eta", prior=stats.norm(-3, 1)) c = rx.Constraint([comp], terms=[T.normalization(parameter=log_eta)]) # absolute offset instead: T.offset(parameter=log_omega) # a mode with any shape: T.systematic(log_s, basis=T.x_basis(np.pi)) ``` Expected behaviour: - One rank-one mode `exp(log_eta)**2 * outer(ym, ym)` is added. - The model parameters decorrelate from the overall scale of the data; the data's normalisation pull moves into `log_eta`. ## 5. Share an error model between datasets, or couple them *Two datasets. Case B: each has its own independent normalisation measurement, but I believe the two magnitudes are the same. Case A: both were normalised against the same uncertain flux, so their errors are correlated.* ```python comp1, comp2 = rx.Comparison(d1, omp), rx.Comparison(d2, omp) log_eta = rx.Parameter("log_eta", prior=stats.norm(-3, 1)) # case B: one parameter, two comparison-local modes; the comparisons stay independent cB = rx.Constraint([comp1, comp2], terms=[T.normalization(log_eta, on=comp1), T.normalization(log_eta, on=comp2)]) # equivalently, two constraints sharing the parameter object cB1, cB2 = rx.Constraint([comp1], terms=[T.normalization(log_eta)]), rx.Constraint([comp2], terms=[T.normalization(log_eta)]) # case A: one mode spanning both comps; the comps are correlated cA = rx.Constraint([comp1, comp2], terms=[T.normalization(log_eta, on=[comp1, comp2])]) ``` Expected behaviour: - All three spellings have exactly one nuisance parameter. - Case B's covariance is block diagonal; case A's has a non-zero off-diagonal block. The two likelihoods differ, and treating case A data as case B is overconfident (`correlated_observations`). - Sharing is by object: two `Parameter("log_eta")` objects would be two parameters and a compile error for the duplicate name. - Case A costs no more than case B: the cross-comparison mode goes through the low-rank path, never a dense factorisation. ## 6. One latent scale per dataset *Each dataset has its own unknown normalisation. I want a Kennedy–O'Hagan scale factor on the model prediction, one per dataset, sampled with the model parameters.* ```python rhos = [rx.Parameter(f"log_rho_{i}", prior=stats.norm(0, np.log1p(s))) for i, s in enumerate(sys_errs)] comps = [rx.Comparison(d_i, omp | tf.scale(rho_i)) for d_i, rho_i in zip(datasets, rhos)] # one global scale instead: omp | tf.scale(rho) on every comparison ``` Expected behaviour: - The scale multiplies the *mean*, so it is not a covariance term. Its parameters follow the model's in `problem.names`. - A masked or held-out view of the comparison keeps the same `rho_i` because the comparison, not the data, carries the model. - Fitting the ρᵢ recovers the true normalisations by MAP (`normalization_inference`); folding the reported σ_sys in as fixed normalisation modes (recipe 3) gives statistically the same model posterior. ## 7. Absorb model deficiency with a Gaussian process *My model is missing physics. I want a smooth correlated discrepancy, in angle or in momentum transfer, learned from the residuals.* ```python log_A = rx.Parameter("log_A", prior=stats.norm(0, 2)) gp = T.kernel(Matern(1.0, nu=2.5), on=comp, coords=lambda x: x / np.pi, amplitude=T.constant_amplitude, amplitude_params=(log_A,)) # in momentum transfer with amplitude A q^(r/2): q = lambda x: rx.reactions.momentum_transfer(x, d.meta["k"]) gp_q = T.kernel(RBF(1.0), on=comp, coords=q, amplitude=lambda c, lA, r: np.exp(lA) * c.x ** (r / 2), amplitude_params=(log_A, r)) c = rx.Constraint([comp], terms=[gp]) band = rx.predictive.total_predictive_band(problem, gp, omp.bind(x_fine, d.meta), x_fine, samples) ``` Expected behaviour: - One parameter per free kernel hyperparameter element, named `discrepancy_`, sampled in sklearn's log-theta space. - The model parameters relax from their biased values toward the truth (`gp_discrepancy`); the learned discrepancy tracks the true defect. - `total_predictive_band` finds the kernel's columns from the term itself; no column arithmetic. - Every error-model form of the α+Ca study reproduces a hand-built dense matrix (`TestStudyForms`). ## 8. Sample a mean discrepancy explicitly *Instead of marginalising the discrepancy, I want to sample an additive correction with a parametric shape.* ```python phi = [rx.Parameter(f"c{i}", prior=stats.norm(0, 1)) for i in range(3)] delta = rx.Model(lambda x, c0, c1, c2: c0 + c1 * x + c2 * x**2, phi) comp = rx.Comparison(d, omp + delta) ``` Expected behaviour: - The prediction is `omp(x) + delta(x)` in physical space; `problem.names` lists `omp.params` then `phi`. - `(omp + delta) | tf.scale(rho)` scales the sum; `(omp | tf.scale(rho)) + delta` scales only the model. - A multiplicative, `x`-dependent correction is `omp * Model(g_fn, phi)`; an additive discrepancy in log space is exactly that. `scale(rho)` is its constant special case. - The GP of recipe 7 may be used alongside it. ## 9. Heavy tails *A few points are gross outliers. I do not want them to drag the fit; I want a likelihood that widens instead of breaking.* ```python nu = rx.Parameter("nu", bounds=(1.0, 100.0)) # uniform on the bounds c = rx.Constraint([comp], likelihood=rx.StudentT(nu=nu)) ``` Expected behaviour: - Same covariance, different functional. The Gaussian version puts the truth at several σ; the Student-t version covers it (`robust_likelihoods`), and the posterior of `nu` is small. - The multivariate t applies one radial tail to the whole stacked residual of the constraint. A comparison that needs Gaussian tails goes in its own constraint. - `rx.Chi2()` drops the log-determinant for a pure chi-squared objective. - The Student-t value is pinned to its closed form (`test_likelihood`). ## 10. Compare in log space *Cross sections span orders of magnitude. I want the Gaussian to live in log space, with the model still written in physical units.* ```python comp = rx.Comparison(d, omp, space=tf.log) c = rx.Constraint([comp], terms=[T.noise(log_eps)], statistical=False) # constant noise in log space ``` Expected behaviour: - `comp.y == log(d.y)` and `comp.y_err == d.y_err / d.y` (delta method). The prediction is transformed the same way at every evaluation, so it can never be double-transformed. - A non-positive prediction gives `log_likelihood == -inf` and `chi2 == +inf`. A non-positive datum on an active point is a compile error naming the dataset. - `problem.log_jacobian()` is `-sum(log y)` over active points; add it to the log-evidence of the log-space fit before comparing with a linear-space fit of the same data. - Constant noise in log space and fractional noise in linear space are different models with different evidences. That is the point. ## 11. Hold out data and score it *I want to fit below an angular cut and score the prediction above it, or compare error models by their held-out predictive density.* ```python fit = c.masked_where(lambda x: x < cut) held = fit.complement() problem = rx.Problem([fit], priors=priors) heldout = rx.Problem([held], priors=problem.priors) lp = rx.diagnostics.heldout_log_predictive(heldout, samples) score = rx.diagnostics.log_posterior_predictive(lp, logw=res.logwt) # weighted for nested sampling ``` Expected behaviour: - `fit` and `held` share every `Comparison`, `Term`, and `Parameter`; the two problems have identical `names`, so a chain from one scores the other. - Active sets are disjoint, their union is every point, and `ll(fit) + ll(held) == ll(full)` for a block-local covariance. - Terms are authored once over all points; masking selects rows, it never rebuilds anything. ## 12. Temper the likelihood *I have many points and worry the posterior is overconfident, or I want a power posterior.* ```python c = rx.Constraint([comp], weight=2 / n) ``` Expected behaviour: - The log-likelihood of that constraint is multiplied by `weight`; the prior is never touched. One knob, honoured by every driver. - Empirical coverage of the posterior predictive (recipe 17) moves toward the nominal level as the weight decreases (`overconfidence`). ## 13. Declare priors *I want each nuisance parameter to carry its own prior, the optical potential to have a correlated multivariate normal prior, and everything to work with nested sampling.* ```python log_eps = rx.Parameter("log_eps", prior=stats.halfnorm(scale=1)) # marginal nu = rx.Parameter("nu", bounds=(1, 100)) # uniform on bounds V = rx.Parameter("V", prior=stats.norm(50, 5), bounds=(30, 70)) # truncated marginal problem = rx.Problem([c], priors=[(omp.params, stats.multivariate_normal(mu, cov))]) # joint block ``` Expected behaviour: - Every slot is covered exactly once, or `Problem` raises naming the parameter (uncovered) or the pair of priors (double covered). - `problem.prior_transform(u)` exists for all of the above: rescaled `ppf` for marginals, whitening for the multivariate normal. It is finite at `u = 0` and `u = 1`. - `problem.sample_prior(n)` gives an `(n, ndim)` array suitable as emcee's starting positions. ## 14. From an EXFOR measurement to a dataset *I have an `exfor_tools` distribution in mb/sr, or as a ratio to Rutherford, with its reported errors. I want a dataset in the library's units with nothing lost.* ```python d = rx.from_measurement(m, reaction=reaction, quantity="dXS/dA") # or "dXS/dRuth", "Ay" d_ias = rx.from_measurement(m, reaction=reaction, ExIAS=Ex) # (p,n) IAS channel ``` Expected behaviour: - `y`, `y_err` and `offset_err` are divided by the unit conversion factor; `norm_err` (fractional) passes through untouched. Angles are stored in radians. - Requesting `dXS/dRuth` from a `dXS/dA` measurement (or the reverse) uses the Rutherford cross section on the data angles, a closed form of the kinematics; the factor is then a per-angle array. - `d.meta` carries `reaction`, `Elab`, `quantity`, `k` (and `ExIAS`), which is everything a reaction model needs to bind. - Incompatible units, or a quantity the measurement cannot be converted to, raise at conversion time. ## 15. Evaluate a reaction model on any grid *I want the model on the data angles for the likelihood and on a fine grid for plotting, with the solver set up once per grid.* ```python omp = rx.reactions.ElasticXS("dXS/dA", central, spin_orbit, args_from_params, params, lmax=20, wavelengths_beyond_range=2.0, zeros_per_node=5) comp = rx.Comparison(d, omp) # bound to d.x, d.meta fine = omp.bind(np.deg2rad(np.linspace(0.5, 179.5, 200)), d.meta) ys = [fine(*s[problem.columns(omp.params)]) for s in samples[::50]] band = rx.predictive.predictive_band(ys, levels=(5, 50, 95)) ``` Expected behaviour: - The model owns the jitr workspace; the dataset never does. Solver settings on the model reach jitr (`TestSolverSettingsForwarding`). - The predictor is a pure function of the potential. A compound-elastic contribution is subtracted from `d.y` before the dataset is built (recipe 20); there is no correction hook on the model. - Cross sections are in b/sr; `dXS/dRuth` and `Ay` are dimensionless. - `problem.columns(omp.params)` is the only chain bookkeeping a user does. ## 16. Drive the calibration with an external sampler *I want to use emcee, dynesty, or the `black-box-bayes` CLI, and read the chain back without positional arithmetic.* ```python # emcee p0 = problem.sample_prior(32) sampler = emcee.EnsembleSampler(32, problem.ndim, problem.log_posterior) samples = sampler.run_mcmc(p0, 5000) and sampler.get_chain(discard=1000, flat=True) # dynesty ns = dynesty.NestedSampler(problem.log_likelihood, problem.prior_transform, problem.ndim) ns.run_nested(); samples = ns.results.samples_equal() logz = rx.diagnostics.logz_summary(ns.results.logz[-1], ns.results.logzerr[-1]) # black-box-bayes dill.dump(problem, open("problem.pkl", "wb")) # posterior.py delegates six names to it ``` Expected behaviour: - Chains from all three are `(n, ndim)` arrays in `problem.names` order. Every analysis function takes `(problem, samples)`. - `log_posterior` evaluates the prior first and never calls the forward model when the prior is `-inf`. - `dill.loads(dill.dumps(problem)).log_posterior(theta)` equals `problem.log_posterior(theta)`, workspaces included. - `problem.NDIM`, `problem.parameter_names`, `problem.starting_location`, `problem.log_posterior_batch` are the bbb spellings of the same things. ## 17. Check the posterior predictive *I want to know whether my error model is calibrated: do 68 % intervals contain 68 % of the points, and how wide are they?* ```python draws = rx.diagnostics.predictive_draws(problem, samples, constraint=0, n_rep=4) cov = rx.diagnostics.coverage_curve(draws, problem.constraints[0].y[problem.constraints[0].active]) err = rx.diagnostics.coverage_error(draws, y_active) width = rx.diagnostics.sharpness(draws, transform=np.exp) # widths in physical space for a log fit ``` Expected behaviour: - Draws are `ym(theta) + L z` on the active points in comparison space; `model_only=True` returns `ym(theta)` and assembles no covariance. - Coverage is near nominal for a correct error model and clearly below it for an overconfident one. - `logz_summary` reports the max of the replicate half-range and the sampler's own error; `compare_logz` returns `"tie"` unless the difference exceeds `sigma * hypot(err_a, err_b)`. ## 18. Compare error models by evidence *The α+Ca study: several error models for data without reported errors. I want the evidence for each, comparable across comparison spaces.* ```python models = { "L0": rx.Constraint([comp_log], terms=[T.noise(log_eps)], statistical=False), "E0": rx.Constraint([comp_lin], terms=[T.noise_fraction(log_eps)], statistical=False), "L2y": rx.Constraint([comp_log], terms=[T.noise(log_eps), T.normalization(log_sys)], statistical=False), "Lgp": rx.Constraint([comp_log], terms=[T.noise(log_eps), gp], statistical=False), "L0t": rx.Constraint([comp_log], terms=[T.noise(log_eps)], statistical=False, likelihood=rx.StudentT()), } logz = {} for name, c in models.items(): p = rx.Problem([c.masked_where(lambda x: x < cut)], priors=priors) res = run_dynesty(p) logz[name] = rx.diagnostics.logz_summary(res.logz[-1] + p.log_jacobian(), res.logzerr[-1]) verdict = rx.diagnostics.compare_logz(logz["Lgp"], logz["L0"]) ``` Expected behaviour: - The same `log_eps` object is reused across models without conflict: each `Problem` is compiled independently. - Adding `log_jacobian` makes the log-space and linear-space evidences comparable. - Every term list in the table reproduces the corresponding hand-built covariance in `TestStudyForms`. ## 19. Bring my own covariance or term *I have a full covariance matrix from a correlated measurement, or a noise model no helper expresses.* ```python fixed = rx.Term(C, on=comp) # any symmetric PD matrix stat = rx.Term(sig, kind="diag", on=comp) # a fixed std-dev vector custom = rx.Term(lambda c, e, l: np.exp(e) * np.exp(l * c.x / np.pi), (log_e, slope), kind="diag") ``` Expected behaviour: - A plain array is a fixed contribution, factored once. Shape and symmetry are checked at construction against the term's `on`. - A callable sees a `TermContext` with `x` (through `coords`), `y`, `ym`, and `len(c)`; it returns a vector for `diag`/`mode` or a matrix. - Fitting correlated data with the correct `Term(C)` instead of its diagonal is the difference between an honest and an overconfident posterior (`normalization_inference` gallery). ## 20. Preprocess instead of asking for a feature *I want to mean-subtract, standardise, or project both data and model onto principal components of a prior predictive ensemble.* ```python mu = ens.mean(0); A = np.linalg.svd(ens - mu, full_matrices=False)[2][:k] d_pc = rx.Dataset(np.arange(k), A @ (d.y - mu), np.zeros(k), label=f"{d.label} PC") native = omp.bind(d.x, d.meta) proj = rx.Model(lambda x, *theta: A @ (native(*theta) - mu), omp.params) stat = rx.Term(A @ np.diag(d.y_err**2) @ A.T, on=d_pc) c = rx.Constraint([rx.Comparison(d_pc, proj)], terms=[stat, T.noise(log_eps, on=d_pc)], statistical=False) ``` Expected behaviour: - `Dataset.x` is opaque to the library; a model may ignore its `x` and close over a predictor on another grid; `y_err` may be zero when `statistical=False`. None of these is "fixed" later. - Subtracting a known contribution from the data, such as a compound- elastic cross section, is the same pattern: `replace(d, y=d.y - cn)`. A reported *fractional* normalisation error refers to the measured value, so build that mode from the unsubtracted prediction if it matters; the offset error is unaffected. - Pointwise maps (mean subtraction, standardisation) can equally be a `space=` transform built from closed-over arrays, which keeps the delta method and `log_jacobian` correct. - Terms that scale with the native prediction see the projected `ym`; they must call the native predictor themselves if they need it. - A projected fit's evidence is not comparable to the unprojected one. ## 21. Get a useful error, not a `LinAlgError` *My measurement reports no statistical error and I forgot to add any term.* ```python d = rx.from_measurement(m_without_errors, reaction=reaction, quantity="dXS/dA") rx.Problem([rx.Constraint([rx.Comparison(d, omp)])]) # ValueError: covariance of constraint 0 is singular on comparison 'E1234-002': the dataset # reports zero statistical error and no term covers its points. Add # comp.reported_terms(), a noise term, or a fixed Term; or set statistical=False and # compose the covariance explicitly. ``` Expected behaviour: - Raised by `Problem`, before any sampler runs, naming the comparison by its label. A parametric covariance is not checked eagerly (it may be fine at some parameter values). - The same compile step reports duplicate parameter names, an uncovered prior slot, an `on=` outside its constraint, and a non-finite comparison-space value on an active point. ## 22. Share hyperparameters across datasets, with values that depend on the dataset *I have elastic data at several energies. I want one GP discrepancy per dataset with a common length scale and an amplitude that runs with energy, so that two shared parameters describe every dataset.* ```python log_A0 = rx.Parameter("log_A0", prior=stats.norm(0, 2)) p = rx.Parameter("p", prior=stats.norm(0, 1)) ell = rx.Parameter("gp_length", prior=stats.norm(0, 1)) amp = lambda c, lA, p: np.exp(lA) * (c.meta("Elab") / 50.0) ** p terms = [T.kernel(Matern(1.0, nu=2.5), on=comp, params=[ell], amplitude=amp, amplitude_params=(log_A0, p)) for comp in comps] c = rx.Constraint(comps, terms=terms) ``` Expected behaviour: - `problem.names` contains one `log_A0`, one `p`, one `gp_length`, however many comparisons there are. Sharing is by object; the loop reuses the same three. - `c.meta("Elab")` is the comparison's energy broadcast to every point of the support, so the amplitude function is written once and works on any comparison. On a term spanning comparisons it is the per-point concatenation. - Without `params=[ell]`, each `kernel` call would derive its own length scale, one per dataset. That is also a legitimate model; it is just not this one. - The comparisons stay uncorrelated, so the constraint is on the structured path. ## 23. A discrepancy correlated across energies and angles *I believe the model's defect varies smoothly in both energy and angle. I want one GP over (E, θ) that correlates the datasets at different energies.* ```python kE, kθ = RBF(10.0), Matern(0.3, nu=2.5) lE, lθ, log_A = (rx.Parameter(n, prior=stats.norm(0, 1)) for n in ("log_lE", "log_ltheta", "log_A")) def fn(c, lE, lθ, lA): E, θ = c.meta("Elab")[:, None], c.x[:, None] K = kE.clone_with_theta([lE])(E) * kθ.clone_with_theta([lθ])(θ) # separable product return np.exp(2 * lA) * K + 1e-10 * np.eye(len(c)) md = rx.Term(fn, (lE, lθ, log_A), kind="matrix", on=comps) c = rx.Constraint(comps, terms=[md]) ``` Expected behaviour: - The term spans comparisons, so the constraint's covariance has non-zero off-diagonal blocks between energies and takes the dense path. Cost grows as the cube of the total number of points; per-comparison GPs with a linked amplitude (recipe 22) are the cheap alternative when cross-energy correlation is not needed. - Any kernel built from `c.meta` and `c.x` is allowed; the product of two one-dimensional kernels is the factorised form. There is no Kronecker speed-up because datasets share no angle grid. - The dense matrix equals the hand-built product kernel on the stacked `(E, θ)` inputs. ## 24. Per-dataset parameters drawn from a sampled hyperprior *Each dataset has its own normalisation, and I want to learn how spread out those normalisations are, rather than fix the spread.* ```python rhos = [rx.Parameter(f"log_rho_{i}") for i in range(len(comps))] # no marginal: the joint covers them log_tau = rx.Parameter("log_tau") class RhoHierarchy: def logpdf(self, v): # v ordered as (rhos..., log_tau) *r, lt = v return (stats.norm(0, np.exp(lt)).logpdf(r).sum() + stats.halfnorm(scale=0.3).logpdf(np.exp(lt)) + lt) # hyperprior on tau, with Jacobian def prior_transform(self, u): # hyperparameter first, children given it lt = np.log(stats.halfnorm(scale=0.3).ppf(u[-1])) return np.append(stats.norm(0, np.exp(lt)).ppf(u[:-1]), lt) comps = [rx.Comparison(d_i, omp | tf.scale(rho_i)) for d_i, rho_i in zip(datasets, rhos)] problem = rx.Problem([rx.Constraint(comps)], priors=[(rhos + [log_tau], RhoHierarchy())]) ``` Expected behaviour: - `log_tau` is an ordinary column of the chain. Its posterior is the learned spread of the normalisations; the `rho_i` shrink toward zero when `tau` is small. - Forgetting the joint block is a compile error: the `rho_i` carry no marginal and have infinite bounds, so no prior covers them. - Nested sampling works because the comparison supplies its own unit-cube map, drawing the hyperparameter before the children. - The same mechanism gives per-dataset GP amplitudes with a shared spread, or any other partially pooled parameter. ## 25. SafeBayes: learn the tempering exponent *I suspect my model is misspecified and want the tempering exponent η chosen by the data rather than by hand, following Grünwald's SafeBayes (arXiv:1412.3730): minimise the prequential log-loss of the η-generalised posterior over prefixes of the data.* ```python from dataclasses import replace order = rng.permutation(n) # one ordering; average a few prefix = lambda i: [np.isin(np.arange(n), order[:i])] etas = [1.0, 0.5, 0.25, 0.125] loss = dict.fromkeys(etas, 0.0) for i in range(n0, n): before = rx.Problem([c.masked(prefix(i))], priors) # untempered, for scoring after = rx.Problem([c.masked(prefix(i + 1))], priors) for eta in etas: post = rx.Problem([replace(c.masked(prefix(i)), weight=eta)], priors) samples = run(post) # emcee or dynesty ll_i = np.array([after.log_likelihood(s) - before.log_likelihood(s) for s in samples]) loss[eta] -= ll_i.mean() # R-log-loss eta_hat = min(loss, key=loss.get) final = rx.Problem([replace(c, weight=eta_hat)], priors) ``` Expected behaviour: - `replace(c, weight=eta)` and `c.masked(...)` share every comparison, term and parameter with `c`, so no workspace is rebuilt across the `n × |η|` problems and every problem has the same `names`. - `after.log_likelihood(θ) − before.log_likelihood(θ)` is `log p(y_i | y_