derivkit 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. derivkit/__init__.py +22 -0
  2. derivkit/calculus/__init__.py +17 -0
  3. derivkit/calculus/calculus_core.py +152 -0
  4. derivkit/calculus/gradient.py +97 -0
  5. derivkit/calculus/hessian.py +528 -0
  6. derivkit/calculus/hyper_hessian.py +296 -0
  7. derivkit/calculus/jacobian.py +156 -0
  8. derivkit/calculus_kit.py +128 -0
  9. derivkit/derivative_kit.py +315 -0
  10. derivkit/derivatives/__init__.py +6 -0
  11. derivkit/derivatives/adaptive/__init__.py +5 -0
  12. derivkit/derivatives/adaptive/adaptive_fit.py +238 -0
  13. derivkit/derivatives/adaptive/batch_eval.py +179 -0
  14. derivkit/derivatives/adaptive/diagnostics.py +325 -0
  15. derivkit/derivatives/adaptive/grid.py +333 -0
  16. derivkit/derivatives/adaptive/polyfit_utils.py +513 -0
  17. derivkit/derivatives/adaptive/spacing.py +66 -0
  18. derivkit/derivatives/adaptive/transforms.py +245 -0
  19. derivkit/derivatives/autodiff/__init__.py +1 -0
  20. derivkit/derivatives/autodiff/jax_autodiff.py +95 -0
  21. derivkit/derivatives/autodiff/jax_core.py +217 -0
  22. derivkit/derivatives/autodiff/jax_utils.py +146 -0
  23. derivkit/derivatives/finite/__init__.py +5 -0
  24. derivkit/derivatives/finite/batch_eval.py +91 -0
  25. derivkit/derivatives/finite/core.py +84 -0
  26. derivkit/derivatives/finite/extrapolators.py +511 -0
  27. derivkit/derivatives/finite/finite_difference.py +247 -0
  28. derivkit/derivatives/finite/stencil.py +206 -0
  29. derivkit/derivatives/fornberg.py +245 -0
  30. derivkit/derivatives/local_polynomial_derivative/__init__.py +1 -0
  31. derivkit/derivatives/local_polynomial_derivative/diagnostics.py +90 -0
  32. derivkit/derivatives/local_polynomial_derivative/fit.py +199 -0
  33. derivkit/derivatives/local_polynomial_derivative/local_poly_config.py +95 -0
  34. derivkit/derivatives/local_polynomial_derivative/local_polynomial_derivative.py +205 -0
  35. derivkit/derivatives/local_polynomial_derivative/sampling.py +72 -0
  36. derivkit/derivatives/tabulated_model/__init__.py +1 -0
  37. derivkit/derivatives/tabulated_model/one_d.py +247 -0
  38. derivkit/forecast_kit.py +783 -0
  39. derivkit/forecasting/__init__.py +1 -0
  40. derivkit/forecasting/dali.py +78 -0
  41. derivkit/forecasting/expansions.py +486 -0
  42. derivkit/forecasting/fisher.py +298 -0
  43. derivkit/forecasting/fisher_gaussian.py +171 -0
  44. derivkit/forecasting/fisher_xy.py +357 -0
  45. derivkit/forecasting/forecast_core.py +313 -0
  46. derivkit/forecasting/getdist_dali_samples.py +429 -0
  47. derivkit/forecasting/getdist_fisher_samples.py +235 -0
  48. derivkit/forecasting/laplace.py +259 -0
  49. derivkit/forecasting/priors_core.py +860 -0
  50. derivkit/forecasting/sampling_utils.py +388 -0
  51. derivkit/likelihood_kit.py +114 -0
  52. derivkit/likelihoods/__init__.py +1 -0
  53. derivkit/likelihoods/gaussian.py +136 -0
  54. derivkit/likelihoods/poisson.py +176 -0
  55. derivkit/utils/__init__.py +13 -0
  56. derivkit/utils/concurrency.py +213 -0
  57. derivkit/utils/extrapolation.py +254 -0
  58. derivkit/utils/linalg.py +513 -0
  59. derivkit/utils/logger.py +26 -0
  60. derivkit/utils/numerics.py +262 -0
  61. derivkit/utils/sandbox.py +74 -0
  62. derivkit/utils/types.py +15 -0
  63. derivkit/utils/validate.py +811 -0
  64. derivkit-1.0.0.dist-info/METADATA +50 -0
  65. derivkit-1.0.0.dist-info/RECORD +68 -0
  66. derivkit-1.0.0.dist-info/WHEEL +5 -0
  67. derivkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  68. derivkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,429 @@
1
+ """Provides GetDist sampling helpers for DALI approximate posteriors.
2
+
3
+ This module converts DALI-expanded posteriors into GetDist-compatible
4
+ :class:`getdist.MCSamples` for plotting and analysis.
5
+
6
+ Two backends are provided:
7
+
8
+ - Importance sampling using a Fisher-Gaussian kernel centered on ``theta0``.
9
+ - ``emcee`` ensemble MCMC targeting the same DALI log-posterior.
10
+
11
+ The target log-posterior is evaluated with
12
+ :func:`derivkit.forecasting.expansions.build_logposterior_dali`, optionally
13
+ including user-defined priors and parameter support bounds.
14
+
15
+ Note: GetDist's ``loglikes`` field stores ``-log(posterior)``, not ``-log(likelihoods)``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from functools import partial
21
+ from typing import Any, Callable, Sequence
22
+
23
+ import emcee
24
+ import numpy as np
25
+ from getdist import MCSamples
26
+ from numpy.typing import NDArray
27
+
28
+ from derivkit.forecasting.expansions import build_logposterior_dali
29
+ from derivkit.forecasting.priors_core import build_prior
30
+ from derivkit.forecasting.sampling_utils import (
31
+ apply_parameter_bounds,
32
+ init_walkers_from_fisher,
33
+ kernel_samples_from_fisher,
34
+ log_gaussian_kernel,
35
+ )
36
+ from derivkit.utils.validate import validate_dali_shape
37
+
38
+ __all__ = [
39
+ "dali_to_getdist_importance",
40
+ "dali_to_getdist_emcee",
41
+ ]
42
+
43
+
44
+ def _resolve_support_bounds(
45
+ *,
46
+ n_params: int,
47
+ prior_bounds: Sequence[tuple[float | None, float | None]] | None,
48
+ sampler_bounds: Sequence[tuple[float | None, float | None]] | None,
49
+ ) -> Sequence[tuple[float | None, float | None]] | None:
50
+ """Computes the effective parameter-support bounds used by the sampler.
51
+
52
+ This function combines two optional sources of support constraints:
53
+
54
+ - ``prior_bounds``, which truncate the prior (and therefore the posterior support).
55
+ - ``sampler_bounds``, restrict the sampling domain used for rejection/initialization.
56
+
57
+ When both are provided, the returned bounds are their intersection, computed
58
+ component-wise for each parameter. Each bound is a ``(low, high)`` pair where
59
+ either endpoint may be ``None`` to indicate no constraint.
60
+
61
+ Args:
62
+ n_params: Number of parameters (expected length of any provided bounds sequence).
63
+ prior_bounds: Optional per-parameter prior truncation bounds.
64
+ sampler_bounds: Optional per-parameter sampling-domain bounds.
65
+
66
+ Returns:
67
+ The effective per-parameter bounds to use for support filtering, or ``None``
68
+ if neither set of bounds is provided.
69
+
70
+ Raises:
71
+ ValueError: If a provided bounds sequence does not have length ``n_params``,
72
+ or if the intersection of ``prior_bounds`` and ``sampler_bounds`` is empty
73
+ for any parameter (i.e., the resulting lower bound exceeds the upper bound).
74
+ """
75
+ if sampler_bounds is not None and len(sampler_bounds) != n_params:
76
+ raise ValueError(
77
+ f"sampler_bounds must have length p={n_params}; got len(sampler_bounds)={len(sampler_bounds)}."
78
+ )
79
+
80
+ if prior_bounds is not None and len(prior_bounds) != n_params:
81
+ raise ValueError(
82
+ f"prior_bounds must have length p={n_params}; got len(prior_bounds)={len(prior_bounds)}."
83
+ )
84
+
85
+ if sampler_bounds is None:
86
+ return prior_bounds
87
+ if prior_bounds is None:
88
+ return sampler_bounds
89
+
90
+ bounds = []
91
+ for i, ((hlo, hhi), (plo, phi)) in enumerate(zip(sampler_bounds, prior_bounds, strict=True)):
92
+ lo = None if (hlo is None and plo is None) else max(x for x in (hlo, plo) if x is not None)
93
+ hi = None if (hhi is None and phi is None) else min(x for x in (hhi, phi) if x is not None)
94
+
95
+ if lo is not None and hi is not None and lo > hi:
96
+ raise ValueError(
97
+ f"Empty support for parameter index {i}: "
98
+ f"intersection gives ({lo}, {hi}). "
99
+ "Check prior_bounds and sampler_bounds."
100
+ )
101
+
102
+ bounds.append((lo, hi))
103
+
104
+ return bounds
105
+
106
+
107
+ def dali_to_getdist_importance(
108
+ theta0: NDArray[np.floating],
109
+ dali: dict[int, tuple[NDArray[np.floating], ...]],
110
+ *,
111
+ forecast_order: int | None = 2,
112
+ names: Sequence[str],
113
+ labels: Sequence[str],
114
+ n_samples: int = 50_000,
115
+ kernel_scale: float = 1.5,
116
+ seed: int | None = None,
117
+ prior_terms: Sequence[tuple[str, dict[str, Any]] | dict[str, Any]] | None = None,
118
+ prior_bounds: Sequence[tuple[float | None, float | None]] | None = None,
119
+ logprior: Callable[[NDArray[np.floating]], float] | None = None,
120
+ sampler_bounds: Sequence[tuple[float | None, float | None]] | None = None,
121
+ label: str = "DALI (importance)",
122
+ ) -> MCSamples:
123
+ """Returns :class:`getdist.MCSamples` for a DALI posterior via importance sampling.
124
+
125
+ The target log-posterior is evaluated with
126
+ :func:`derivkit.forecasting.expansions.build_logposterior_dali`. Samples
127
+ are drawn from a Fisher–Gaussian kernel centered on ``theta0`` and
128
+ reweighted by the difference between the target log-posterior and the
129
+ kernel log-density.
130
+
131
+ Args:
132
+ theta0: Fiducial parameter vector with shape ``(p,)`` for ``p`` parameters.
133
+ dali: Dictionary returned by :func:`derivkit.forecasting.build_dali`.
134
+ forecast_order: Maximum order of the DALI expansion to include
135
+ (e.g., 2 for doublet, 3 for triplet).
136
+ names: Parameter names used to label the returned samples (length ``p``).
137
+ labels: LaTeX-formatted parameter labels used to label the returned samples (length ``p``).
138
+ n_samples: Number of importance samples to draw.
139
+ kernel_scale: Scale factor applied to the Fisher covariance for the kernel.
140
+ seed: Random seed for kernel sampling.
141
+ prior_terms: Optional prior term specifications used to build a prior via
142
+ :func:`derivkit.forecasting.priors.core.build_prior`. Mutually exclusive with ``logprior``.
143
+ Can be combined with ``prior_bounds`` to truncate prior support.
144
+ prior_bounds: Optional per-parameter bounds passed to
145
+ :func:`derivkit.forecasting.priors.core.build_prior` to truncate the prior support.
146
+ If provided with no ``prior_terms``, this corresponds to a bounded-uniform (top-hat) prior.
147
+ A bounded-uniform prior is proper (normalizable), unlike an unbounded flat prior which is improper.
148
+ Mutually exclusive with ``logprior``.
149
+ logprior: Optional custom log-prior ``logprior(theta)``. Mutually exclusive with
150
+ ``prior_terms``/``prior_bounds``. If none of these are provided, a flat (typically improper)
151
+ prior is used.
152
+ sampler_bounds: Optional per-parameter sampling-domain bounds used for early rejection
153
+ and walker initialization.
154
+ label: Label attached to the returned samples output (e.g., used by GetDist in plot legends/titles).
155
+
156
+ Returns:
157
+ :class:`getdist.MCSamples` containing the importance ``weights``.
158
+ :attr:`getdist.MCSamples.loglikes` stores ``-log(posterior) (likelihoods x prior)``
159
+ up to an additive constant, consistent with GetDist's convention.
160
+
161
+ Raises:
162
+ ValueError: If shapes are inconsistent, mutually exclusive options are provided,
163
+ or the effective support bounds are invalid (e.g., an empty intersection).
164
+ RuntimeError: If all samples are rejected by bounds or prior support.
165
+ """
166
+ fiducial = np.asarray(theta0, dtype=float).reshape(-1)
167
+
168
+ if not isinstance(dali, dict):
169
+ raise TypeError(
170
+ "dali must be the dict form returned by build_dali.")
171
+
172
+ validate_dali_shape(fiducial, dali)
173
+
174
+ # Fisher for the Gaussian proposal kernel comes from dali[1]
175
+ if 1 not in dali.keys():
176
+ raise ValueError("dali must contain key 1 with dali[1] == (F,).")
177
+
178
+ fisher_matrix = np.asarray(dali[1][0], dtype=float)
179
+
180
+ n_params = int(fiducial.size)
181
+ n_names = len(names)
182
+ n_labels = len(labels)
183
+ if n_names != n_params:
184
+ raise ValueError(f"names must have length p={n_params}; got len(names)={n_names}.")
185
+ if n_labels != n_params:
186
+ raise ValueError(f"labels must have length p={n_params}; got len(labels)={n_labels}.")
187
+
188
+ if logprior is not None and (prior_terms is not None or prior_bounds is not None):
189
+ raise ValueError(
190
+ "Ambiguous prior specification: pass either `logprior` or (`prior_terms` and/or `prior_bounds`), not both."
191
+ )
192
+
193
+ support_bounds = _resolve_support_bounds(
194
+ n_params=n_params,
195
+ prior_bounds=prior_bounds,
196
+ sampler_bounds=sampler_bounds,
197
+ )
198
+
199
+ # Computing the prior once prevents rebuilding inside logposterior_dali
200
+ logprior_fn = logprior
201
+ if logprior_fn is None and (
202
+ prior_terms is not None or prior_bounds is not None):
203
+ logprior_fn = build_prior(terms=prior_terms, bounds=prior_bounds)
204
+
205
+ kernel_samples = kernel_samples_from_fisher(
206
+ fiducial,
207
+ fisher_matrix,
208
+ n_samples=int(n_samples),
209
+ kernel_scale=float(kernel_scale),
210
+ seed=seed,
211
+ )
212
+
213
+ kernel_samples = apply_parameter_bounds(kernel_samples, support_bounds)
214
+ if kernel_samples.shape[0] == 0:
215
+ raise RuntimeError("All kernel samples rejected by bounds.")
216
+
217
+ target_logpost = np.array(
218
+ [
219
+ build_logposterior_dali(
220
+ theta,
221
+ fiducial,
222
+ dali,
223
+ forecast_order=forecast_order,
224
+ logprior=logprior_fn,
225
+ )
226
+ for theta in kernel_samples
227
+ ],
228
+ dtype=float,
229
+ )
230
+
231
+ keep = np.isfinite(target_logpost)
232
+ kernel_samples = kernel_samples[keep]
233
+ target_logpost = target_logpost[keep]
234
+ if kernel_samples.shape[0] == 0:
235
+ raise RuntimeError(
236
+ "All kernel samples were rejected: the target log-posterior "
237
+ "was -inf for every sample "
238
+ "(outside prior/support bounds or invalid model evaluation)."
239
+ )
240
+
241
+ kernel_logpdf = log_gaussian_kernel(
242
+ kernel_samples,
243
+ fiducial,
244
+ fisher_matrix,
245
+ kernel_scale=float(kernel_scale),
246
+ )
247
+
248
+ log_weights = target_logpost - kernel_logpdf
249
+ log_weights -= np.max(log_weights)
250
+ weights = np.exp(log_weights)
251
+
252
+ loglikes = -target_logpost
253
+
254
+ ranges = None
255
+ if support_bounds is not None:
256
+ ranges = {n: [lo, hi] for n, (lo, hi) in zip(names, support_bounds, strict=True)}
257
+
258
+ return MCSamples(
259
+ samples=kernel_samples,
260
+ weights=weights,
261
+ loglikes=loglikes,
262
+ names=list(names),
263
+ labels=list(labels),
264
+ ranges=ranges,
265
+ label=label,
266
+ )
267
+
268
+
269
+ def dali_to_getdist_emcee(
270
+ theta0: NDArray[np.floating],
271
+ dali: dict[int, tuple[NDArray[np.floating], ...]],
272
+ *,
273
+ forecast_order: int | None = 2,
274
+ names: Sequence[str],
275
+ labels: Sequence[str],
276
+ n_steps: int = 10_000,
277
+ burn: int = 2_000,
278
+ thin: int = 2,
279
+ n_walkers: int | None = None,
280
+ init_scale: float = 0.5,
281
+ seed: int | None = None,
282
+ prior_terms: Sequence[tuple[str, dict[str, Any]] | dict[str, Any]] | None = None,
283
+ prior_bounds: Sequence[tuple[float | None, float | None]] | None = None,
284
+ logprior: Callable[[NDArray[np.floating]], float] | None = None,
285
+ sampler_bounds: Sequence[tuple[float | None, float | None]] | None = None,
286
+ label: str = "DALI (emcee)",
287
+ ) -> MCSamples:
288
+ """Returns :class:`getdist.MCSamples` from ``emcee`` sampling of a DALI posterior.
289
+
290
+ The target log-posterior is evaluated with
291
+ :func:`derivkit.forecasting.expansions.build_logposterior_dali`.
292
+ Walkers are initialized from a Fisher–Gaussian cloud around ``theta0`` and
293
+ evolved with :class:`emcee.EnsembleSampler`. Optional priors and support
294
+ bounds are applied through the target log-posterior.
295
+
296
+ Args:
297
+ theta0: Fiducial parameter vector with shape ``(p,)`` with ``p`` parameters.
298
+ dali: Dictionary returned by :func:`derivkit.forecasting.build_dali`.
299
+ forecast_order: Maximum order of the DALI expansion to include (e.g., 2 for doublet, 3 for triplet).
300
+ names: Parameter names used to label the returned samples (length ``p``).
301
+ labels: LaTeX-formatted parameter labels used to label the returned samples (length ``p``).
302
+ n_steps: Total number of MCMC steps.
303
+ burn: Number of initial MCMC steps discarded as burn-in, allowing the chain to
304
+ forget its initial starting positions before samples are retained.
305
+ thin: Thinning factor applied after burn-in; only every ``thin``-th sample is kept
306
+ to reduce autocorrelation in the chain.
307
+ n_walkers: Number of walkers. Defaults to ``max(32, 8 * p)``.
308
+ init_scale: Initial scatter scale for walker initialization.
309
+ seed: Random seed for walker initialization.
310
+ prior_terms: Optional prior term specifications used to build a prior via
311
+ :func:`derivkit.forecasting.priors.core.build_prior`. Mutually exclusive with ``logprior``.
312
+ Can be combined with ``prior_bounds`` to truncate prior support.
313
+ prior_bounds: Optional per-parameter bounds passed to
314
+ :func:`derivkit.forecasting.priors.core.build_prior` to truncate the prior support.
315
+ If provided with no ``prior_terms``, this corresponds to a bounded-uniform (top-hat) prior.
316
+ A bounded-uniform prior is proper (normalizable), unlike an unbounded flat prior which is improper.
317
+ Mutually exclusive with ``logprior``.
318
+ logprior: Optional custom log-prior ``logprior(theta)``. Mutually exclusive with
319
+ ``prior_terms``/``prior_bounds``. If none of these are provided, a flat (typically improper)
320
+ prior is used.
321
+ sampler_bounds: Optional per-parameter sampling-domain bounds used for early rejection
322
+ and walker initialization.
323
+ label: Label attached to the returned samples output (e.g., used by GetDist in plot legends/titles).
324
+
325
+ Returns:
326
+ :class:`getdist.MCSamples` containing MCMC chains.
327
+ :attr:`getdist.MCSamples.loglikes` stores ``-log(posterior) (likelihoods x prior)``
328
+ up to an additive constant, consistent with GetDist's convention.
329
+
330
+ Raises:
331
+ ValueError: If shapes are inconsistent, mutually exclusive options are provided,
332
+ or the effective support bounds are invalid (e.g., an empty intersection).
333
+ RuntimeError: If walker initialization fails (no valid starting points).
334
+ """
335
+ fiducial = np.asarray(theta0, dtype=float).reshape(-1)
336
+
337
+ if not isinstance(dali, dict):
338
+ raise TypeError(
339
+ "dali must be the dict form returned by build_dali.")
340
+
341
+ validate_dali_shape(fiducial, dali)
342
+
343
+ if 1 not in dali.keys():
344
+ raise ValueError("dali must contain key 1 with dali[1] == (F,).")
345
+
346
+ fisher_matrix = np.asarray(dali[1][0], dtype=float)
347
+
348
+ n_params = int(fiducial.size)
349
+ n_names = len(names)
350
+ n_labels = len(labels)
351
+ if n_names != n_params:
352
+ raise ValueError(f"names must have length p={n_params}; got len(names)={n_names}.")
353
+ if n_labels != n_params:
354
+ raise ValueError(f"labels must have length p={n_params}; got len(labels)={n_labels}.")
355
+
356
+ if n_walkers is None:
357
+ n_walkers = max(32, 8 * n_params)
358
+
359
+ if logprior is not None and (prior_terms is not None or prior_bounds is not None):
360
+ raise ValueError(
361
+ "Ambiguous prior specification: pass either `logprior` or (`prior_terms` and/or `prior_bounds`), not both."
362
+ )
363
+
364
+ support_bounds = _resolve_support_bounds(
365
+ n_params=n_params,
366
+ prior_bounds=prior_bounds,
367
+ sampler_bounds=sampler_bounds,
368
+ )
369
+
370
+ # Compute prior once, as this prevents rebuilding inside logposterior_dali.
371
+ logprior_fn = logprior
372
+ if logprior_fn is None and (
373
+ prior_terms is not None or prior_bounds is not None):
374
+ logprior_fn = build_prior(terms=prior_terms, bounds=prior_bounds)
375
+
376
+ walker_init = init_walkers_from_fisher(
377
+ fiducial,
378
+ fisher_matrix,
379
+ n_walkers=int(n_walkers),
380
+ init_scale=float(init_scale),
381
+ seed=seed,
382
+ sampler_bounds=support_bounds,
383
+ )
384
+
385
+ walker_init = np.asarray(walker_init, dtype=float)
386
+ if walker_init.ndim != 2 or walker_init.shape[0] == 0 or walker_init.shape[1] != n_params:
387
+ raise RuntimeError(
388
+ "Walker initialization failed: no valid starting points. "
389
+ "The provided bounds/prior support likely exclude the Fisher kernel around theta0. "
390
+ "Try loosening bounds, increasing init_scale, or checking theta0 is inside support."
391
+ )
392
+
393
+ _base_log_prob = partial(
394
+ build_logposterior_dali,
395
+ theta0=fiducial,
396
+ dali=dali,
397
+ forecast_order=forecast_order,
398
+ logprior=logprior_fn,
399
+ )
400
+
401
+ def log_prob(theta: NDArray[np.floating]) -> float:
402
+ """Target log-posterior with hard support bounds (returns -inf outside)."""
403
+ if support_bounds is not None:
404
+ th = np.asarray(theta, dtype=float).reshape(1, -1)
405
+ if apply_parameter_bounds(th, support_bounds).shape[0] == 0:
406
+ return -np.inf
407
+ return float(_base_log_prob(theta))
408
+
409
+ sampler = emcee.EnsembleSampler(int(n_walkers), n_params, log_prob)
410
+ sampler.run_mcmc(walker_init, int(n_steps), progress=True)
411
+
412
+ chains = sampler.get_chain(discard=int(burn), thin=int(thin))
413
+ log_posteriors = sampler.get_log_prob(discard=int(burn), thin=int(thin))
414
+
415
+ chain_list = [chains[:, walker_idx, :] for walker_idx in range(chains.shape[1])]
416
+ loglikes_list = [-log_posteriors[:, walker_idx] for walker_idx in range(log_posteriors.shape[1])]
417
+
418
+ ranges = None
419
+ if support_bounds is not None:
420
+ ranges = {n: [lo, hi] for n, (lo, hi) in zip(names, support_bounds, strict=True)}
421
+
422
+ return MCSamples(
423
+ samples=chain_list,
424
+ loglikes=loglikes_list,
425
+ names=list(names),
426
+ labels=list(labels),
427
+ ranges=ranges,
428
+ label=label,
429
+ )
@@ -0,0 +1,235 @@
1
+ """Provides conversion helpers for Fisher–Gaussian forecasts and GetDist objects.
2
+
3
+ This module converts Fisher-matrix Gaussian approximations into
4
+ GetDist-compatible representations for plotting and analysis.
5
+
6
+ Two outputs are supported:
7
+
8
+ - An analytic Gaussian approximation via :class:`getdist.gaussian_mixtures.GaussianND`
9
+ with mean ``theta0`` and covariance from the (pseudo-)inverse Fisher matrix.
10
+ - Monte Carlo samples drawn from the Fisher Gaussian as :class:`getdist.MCSamples`, with
11
+ optional prior support hard bounds, and :attr:`getdist.MCSamples.loglikes`.
12
+
13
+ These helpers are intended for quick visualization (e.g. triangle plots) and
14
+ simple prior truncation without running an MCMC sampler.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Callable, Sequence
20
+
21
+ import numpy as np
22
+ from getdist import MCSamples
23
+ from getdist.gaussian_mixtures import GaussianND
24
+ from numpy.typing import NDArray
25
+
26
+ from derivkit.forecasting.priors_core import build_prior
27
+ from derivkit.forecasting.sampling_utils import (
28
+ apply_parameter_bounds,
29
+ fisher_to_cov,
30
+ kernel_samples_from_fisher,
31
+ )
32
+ from derivkit.utils.validate import validate_fisher_shape
33
+
34
+ __all__ = [
35
+ "fisher_to_getdist_gaussiannd",
36
+ "fisher_to_getdist_samples",
37
+ ]
38
+
39
+
40
+ def fisher_to_getdist_gaussiannd(
41
+ theta0: NDArray[np.floating],
42
+ fisher: NDArray[np.floating],
43
+ *,
44
+ names: Sequence[str] | None = None,
45
+ labels: Sequence[str] | None = None,
46
+ label: str = "Fisher (Gaussian)",
47
+ rcond: float | None = None,
48
+ ) -> GaussianND:
49
+ """Returns :class:`getdist.gaussian_mixtures.GaussianND` for the Fisher Gaussian.
50
+
51
+ Args:
52
+ theta0: Fiducial parameter vector with shape ``(p,)`` with ``p`` parameters.
53
+ fisher: Fisher matrix with shape ``(p, p)``.
54
+ names: Optional parameter names (length ``p``).
55
+ Defaults to ``["p" + str(x) for x in range(len(theta0))]``.
56
+ labels: Optional parameter labels (length ``p``).
57
+ Defaults to ``["p" + str(x) for x in range(len(theta0))]``.
58
+ label: Label attached to the returned object.
59
+ rcond: Cutoff passed to the Fisher (pseudo-)inverse when forming the covariance.
60
+
61
+ Returns:
62
+ A :class:`getdist.gaussian_mixtures.GaussianND` with mean ``theta0`` and covariance from ``fisher``.
63
+
64
+ Raises:
65
+ ValueError: If ``names``/``labels`` lengths do not match ``p``.
66
+ """
67
+ theta0 = np.asarray(theta0, dtype=float)
68
+ fisher = np.asarray(fisher, dtype=float)
69
+ validate_fisher_shape(theta0, fisher)
70
+
71
+ n_params = int(theta0.size)
72
+
73
+ default_names = [f"p{i}" for i in range(n_params)]
74
+ default_labels = [rf"p_{{{i}}}" for i in range(n_params)]
75
+
76
+ param_names = list(default_names if names is None else names)
77
+ param_labels = list(default_labels if labels is None else labels)
78
+
79
+ if len(param_names) != n_params:
80
+ raise ValueError(
81
+ f"`names` must have length {n_params} (number of parameters), got {len(param_names)}."
82
+ )
83
+ if len(param_labels) != n_params:
84
+ raise ValueError(
85
+ f"`labels` must have length {n_params} (number of parameters), got {len(param_labels)}."
86
+ )
87
+
88
+ covariance = fisher_to_cov(fisher, rcond=rcond)
89
+
90
+ return GaussianND(
91
+ mean=theta0,
92
+ cov=covariance,
93
+ names=param_names,
94
+ labels=param_labels,
95
+ label=label,
96
+ )
97
+
98
+
99
+ def fisher_to_getdist_samples(
100
+ theta0: NDArray[np.floating],
101
+ fisher: NDArray[np.floating],
102
+ *,
103
+ names: Sequence[str],
104
+ labels: Sequence[str],
105
+ n_samples: int = 30_000,
106
+ seed: int | None = None,
107
+ kernel_scale: float = 1.0,
108
+ prior_terms: Sequence[tuple[str, dict[str, Any]] | dict[str, Any]] | None = None,
109
+ prior_bounds: Sequence[tuple[float | None, float | None]] | None = None,
110
+ logprior: Callable[[NDArray[np.floating]], float] | None = None,
111
+ hard_bounds: Sequence[tuple[float | None, float | None]] | None = None,
112
+ store_loglikes: bool = True,
113
+ label: str = "Fisher (samples)",
114
+ ) -> MCSamples:
115
+ """Draws samples from the Fisher Gaussian as :class:`getdist.MCSamples`.
116
+
117
+ Samples are drawn from a multivariate Gaussian with mean ``theta0`` and
118
+ covariance ``kernel_scale**2 * pinv(fisher)``. Optionally, samples are
119
+ truncated by hard bounds and/or by a prior (via ``logprior`` or
120
+ ``prior_terms``/``prior_bounds``).
121
+
122
+ GetDist stores :attr:`getdist.MCSamples.loglikes` as ``-log(posterior)`` up to an additive
123
+ constant. When ``store_loglikes=True``, this function stores::
124
+
125
+ -log p(theta|d) = 0.5 * (theta-theta0)^T F (theta-theta0) - logprior(theta) + C
126
+
127
+ where ``C`` is an arbitrary additive constant and ``F`` defines the Fisher-Gaussian
128
+ approximation to ``-log(likelihoods)`` around ``theta0``.
129
+ In this implementation, ``C`` is effectively zero since no additional shifting is applied.
130
+ If no prior is provided, the prior term is omitted.
131
+
132
+ Args:
133
+ theta0: Fiducial parameter vector with shape ``(p,)`` with ``p`` parameters.
134
+ fisher: Fisher matrix with shape ``(p, p)``.
135
+ names: Parameter names (length ``p``).
136
+ labels: Parameter labels (length ``p``).
137
+ n_samples: Number of samples to draw.
138
+ seed: Random seed.
139
+ kernel_scale: Multiplicative scale applied to the Gaussian covariance.
140
+ prior_terms: Optional prior term specifications used to build a prior.
141
+ Can be provided with or without ``prior_bounds``.
142
+ prior_bounds: Optional bounds used to truncate prior support.
143
+ Can be provided with or without ``prior_terms``.
144
+ logprior: Custom log-prior callable. Mutually exclusive with
145
+ ``prior_terms``/``prior_bounds``.
146
+ hard_bounds: Hard bounds applied by rejection (samples outside are dropped).
147
+ Mutually exclusive with encoding support via ``prior_terms``/``prior_bounds``.
148
+ store_loglikes: If ``True``, compute and store :attr:`getdist.MCSamples.loglikes`.
149
+ label: Label for the returned :class:`getdist.MCSamples`.
150
+
151
+ Returns:
152
+ :class:`getdist.MCSamples` containing the retained samples and
153
+ optional :attr:`getdist.MCSamples.loglikes`.
154
+
155
+ Raises:
156
+ ValueError: If ``n_samples`` is non-positive, if ``names`` or ``labels`` have
157
+ incorrect length, or if mutually exclusive options are provided.
158
+ RuntimeError: If all samples are rejected by bounds or prior support.
159
+ """
160
+ theta0 = np.asarray(theta0, dtype=float)
161
+ fisher = np.asarray(fisher, dtype=float)
162
+ validate_fisher_shape(theta0, fisher)
163
+
164
+ n_params = int(theta0.size)
165
+
166
+ if len(names) != n_params:
167
+ raise ValueError(
168
+ f"`names` must have length {n_params} (number of parameters), got {len(names)}."
169
+ )
170
+ if len(labels) != n_params:
171
+ raise ValueError(
172
+ f"`labels` must have length {n_params} (number of parameters), got {len(labels)}."
173
+ )
174
+
175
+ n_samples = int(n_samples)
176
+ if n_samples <= 0:
177
+ raise ValueError("n_samples must be a positive integer")
178
+
179
+ if logprior is not None and (prior_terms is not None or prior_bounds is not None):
180
+ raise ValueError(
181
+ "Ambiguous prior specification: pass either `logprior` or `prior_terms` and `prior_bounds`, not both. "
182
+ "`prior_terms` and `prior_bounds` can be provided independently."
183
+ )
184
+
185
+ if hard_bounds is not None and (prior_terms is not None or prior_bounds is not None):
186
+ raise ValueError(
187
+ "Ambiguous support: choose either `hard_bounds` or prior-based support "
188
+ "via (`prior_terms`/`prior_bounds`)."
189
+ )
190
+
191
+ samples = kernel_samples_from_fisher(
192
+ theta0,
193
+ fisher,
194
+ n_samples=n_samples,
195
+ kernel_scale=float(kernel_scale),
196
+ seed=seed,
197
+ )
198
+
199
+ samples = apply_parameter_bounds(samples, hard_bounds)
200
+ if samples.shape[0] == 0:
201
+ raise RuntimeError("All samples rejected by hard bounds (no samples left).")
202
+
203
+ delta = samples - theta0[None, :]
204
+ theta_quad = np.einsum("ni,ij,nj->n", delta, fisher, delta).astype(float, copy=False)
205
+
206
+ loglikes: NDArray[np.floating] | None = None
207
+ if store_loglikes:
208
+ logprior_fn: Callable[[NDArray[np.floating]], float] | None = None
209
+ if logprior is not None:
210
+ logprior_fn = logprior
211
+ elif prior_terms is not None or prior_bounds is not None:
212
+ logprior_fn = build_prior(terms=prior_terms, bounds=prior_bounds)
213
+
214
+ log_prior_vals: NDArray[np.floating] | None = None
215
+ if logprior_fn is not None:
216
+ log_prior_vals = np.array([float(logprior_fn(s)) for s in samples], dtype=float)
217
+ keep = np.isfinite(log_prior_vals)
218
+ samples = samples[keep]
219
+ theta_quad = theta_quad[keep]
220
+ log_prior_vals = log_prior_vals[keep]
221
+ if samples.shape[0] == 0:
222
+ raise RuntimeError(
223
+ "All samples were rejected by the prior: logprior evaluated to -inf for every sample, "
224
+ "indicating zero prior density outside the allowed prior support."
225
+ )
226
+
227
+ loglikes = 0.5 * theta_quad if log_prior_vals is None else (0.5 * theta_quad - log_prior_vals)
228
+
229
+ return MCSamples(
230
+ samples=samples,
231
+ loglikes=loglikes,
232
+ names=list(names),
233
+ labels=list(labels),
234
+ label=label,
235
+ )