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,860 @@
1
+ """Prior utilities (core priors + unified builder).
2
+
3
+ Priors are represented as functions that evaluate how compatible a parameter vector is
4
+ with the prior assumptions. They return a single log-prior value, with negative infinity
5
+ used to exclude invalid parameter values.
6
+
7
+ The return value is interpreted as a log-density defined up to an additive
8
+ constant. Returning ``-np.inf`` denotes zero probability (hard exclusion).
9
+
10
+ These priors are intended for use when constructing log-posteriors for sampling
11
+ or when evaluating posterior surfaces. Plotting tools such as GetDist only
12
+ visualize the distributions they are given and do not apply priors implicitly.
13
+ As a result, priors must be included explicitly: either by adding them when
14
+ generating samples, or, in the case of Gaussian approximations, by incorporating
15
+ them directly into the Fisher matrix.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from functools import partial
21
+ from typing import Any, Callable, Sequence
22
+
23
+ import numpy as np
24
+ from numpy.typing import NDArray
25
+
26
+ from derivkit.utils.linalg import invert_covariance, normalize_covariance
27
+ from derivkit.utils.numerics import (
28
+ apply_hard_bounds,
29
+ get_index_value,
30
+ logsumexp_1d,
31
+ sum_terms,
32
+ )
33
+
34
+ __all__ = [
35
+ "prior_none",
36
+ "prior_uniform",
37
+ "prior_gaussian",
38
+ "prior_gaussian_diag",
39
+ "prior_log_uniform",
40
+ "prior_jeffreys",
41
+ "prior_half_normal",
42
+ "prior_half_cauchy",
43
+ "prior_log_normal",
44
+ "prior_beta",
45
+ "prior_gaussian_mixture",
46
+ "build_prior",
47
+ ]
48
+
49
+
50
+ def _prior_none_impl(
51
+ _theta: NDArray[np.floating]
52
+ ) -> float:
53
+ """Returns a constant log-prior (improper flat prior).
54
+
55
+ This prior is constant in parameter space (up to an additive constant), so it
56
+ contributes zero to the log-posterior at every point.
57
+
58
+ Args:
59
+ _theta: Parameter vector. This argument is unused
60
+ and present only for signature compatibility.
61
+
62
+ Returns:
63
+ Log-prior value (always ``0.0``).
64
+ """
65
+ return 0.0
66
+
67
+
68
+ def _prior_1d_impl(
69
+ theta: NDArray[np.floating],
70
+ *,
71
+ index: int,
72
+ domain: str,
73
+ kind: str,
74
+ a: float | np.floating = 0.0,
75
+ b: float | np.floating = 1.0,
76
+ ) -> float:
77
+ """A generic 1D prior implementation.
78
+
79
+ Args:
80
+ theta: Parameter vector.
81
+ index: Index of the parameter to which the prior applies.
82
+ domain: Domain restriction (``"positive"``, ``"nonnegative"``, ``"unit_open"``).
83
+ kind: Prior kind (``"log_uniform"``, ``"half_normal"``, ``"half_cauchy"``, ``"log_normal"``, ``"beta"``).
84
+ a: First prior parameter (meaning depends on ``kind``).
85
+ b: Second prior parameter (meaning depends on ``kind``).
86
+
87
+ Returns:
88
+ Log-prior value for the specified parameter.
89
+
90
+ Raises:
91
+ ValueError: If ``domain`` is unknown, if ``kind`` is unknown, or if the
92
+ distribution parameters are invalid (e.g., non-positive scale/sigma or
93
+ non-positive ``a/b``).
94
+ """
95
+ x = get_index_value(theta, index, name="theta")
96
+
97
+ match domain:
98
+ case "positive":
99
+ if x <= 0.0:
100
+ return -np.inf
101
+ case "nonnegative":
102
+ if x < 0.0:
103
+ return -np.inf
104
+ case "unit_open":
105
+ if x <= 0.0 or x >= 1.0:
106
+ return -np.inf
107
+ case _:
108
+ raise ValueError(f"unknown domain '{domain}'")
109
+
110
+ logp: float
111
+ match kind:
112
+ case "log_uniform":
113
+ logp = float(-np.log(x))
114
+
115
+ case "half_normal":
116
+ sigma = float(a)
117
+ if sigma <= 0.0:
118
+ raise ValueError("sigma must be > 0")
119
+ logp = float(-0.5 * (x / sigma) ** 2)
120
+
121
+ case "half_cauchy":
122
+ scale = float(a)
123
+ if scale <= 0.0:
124
+ raise ValueError("scale must be > 0")
125
+ t = x / scale
126
+ logp = float(-np.log1p(t * t))
127
+
128
+ case "log_normal":
129
+ mean = float(a)
130
+ sigma = float(b)
131
+ if sigma <= 0.0:
132
+ raise ValueError("sigma must be > 0")
133
+ lx = np.log(x)
134
+ z = (lx - mean) / sigma
135
+ logp = float(-0.5 * z * z - lx)
136
+
137
+ case "beta":
138
+ alpha = float(a)
139
+ beta = float(b)
140
+ if alpha <= 0.0 or beta <= 0.0:
141
+ raise ValueError("alpha and beta must be > 0")
142
+ logp = float((alpha - 1.0) * np.log(x) + (beta - 1.0) * np.log1p(-x))
143
+
144
+ case _:
145
+ raise ValueError(f"unknown prior kind '{kind}'")
146
+
147
+ return logp
148
+
149
+
150
+ def _prior_gaussian_impl(
151
+ theta: NDArray[np.floating],
152
+ *,
153
+ mean: NDArray[np.floating],
154
+ inv_cov: NDArray[np.floating],
155
+ ) -> float:
156
+ """Evaluates a multivariate Gaussian log-prior (up to an additive constant).
157
+
158
+ Args:
159
+ theta: Parameter vector.
160
+ mean: Mean vector.
161
+ inv_cov: Inverse covariance matrix.
162
+
163
+ Returns:
164
+ Log-prior value.
165
+
166
+ Raises:
167
+ ValueError: If ``theta``/``mean`` are not 1D or if ``inv_cov`` does not have
168
+ shape ``(p, p)`` consistent with ``mean``.
169
+ """
170
+ thetas = np.asarray(theta, dtype=np.float64)
171
+ means = np.asarray(mean, dtype=np.float64)
172
+ inv_cov = np.asarray(inv_cov, dtype=np.float64)
173
+
174
+ if means.ndim != 1:
175
+ raise ValueError(f"mean must be 1D, got shape {means.shape}")
176
+ if inv_cov.ndim != 2 or inv_cov.shape[0] != inv_cov.shape[1] or inv_cov.shape[0] != means.size:
177
+ raise ValueError(f"inv_cov must have shape (p, p) with p={means.size}, got {inv_cov.shape}")
178
+ if thetas.ndim != 1 or thetas.size != means.size:
179
+ raise ValueError(f"theta must have shape ({means.size},), got {thetas.shape}")
180
+
181
+ diff = thetas - means
182
+ return float(-0.5 * (diff @ inv_cov @ diff))
183
+
184
+
185
+ def _prior_gaussian_diag_impl(
186
+ theta: NDArray[np.floating],
187
+ *,
188
+ mean: NDArray[np.floating],
189
+ inv_cov: NDArray[np.floating],
190
+ ) -> float:
191
+ """Evaluates a diagonal multivariate Gaussian log-prior (up to an additive constant).
192
+
193
+ Args:
194
+ theta: Parameter vector.
195
+ mean: Mean vector.
196
+ inv_cov: Inverse covariance matrix (must be diagonal).
197
+
198
+ Returns:
199
+ Log-prior value.
200
+
201
+ Raises:
202
+ ValueError: If shapes are inconsistent, if ``inv_cov`` is not diagonal, or if
203
+ any diagonal entry of ``inv_cov`` is non-positive.
204
+ """
205
+ thetas = np.asarray(theta, dtype=np.float64)
206
+ means = np.asarray(mean, dtype=np.float64)
207
+ inv_cov = np.asarray(inv_cov, dtype=np.float64)
208
+
209
+ if means.ndim != 1:
210
+ raise ValueError(f"mean must be 1D, got shape {means.shape}")
211
+ p = means.size
212
+ if inv_cov.ndim != 2 or inv_cov.shape != (p, p):
213
+ raise ValueError(f"inv_cov must have shape ({p},{p}), got {inv_cov.shape}")
214
+ if thetas.ndim != 1 or thetas.size != p:
215
+ raise ValueError(f"theta must have shape ({p},), got {thetas.shape}")
216
+
217
+ inv_var = np.diag(inv_cov)
218
+ off_diag = inv_cov.copy()
219
+ np.fill_diagonal(off_diag, 0.0)
220
+ if not np.allclose(off_diag, 0.0, rtol=1e-12, atol=1e-12):
221
+ raise ValueError("inv_cov must be diagonal for prior_gaussian_diag")
222
+ if np.any(inv_var <= 0.0):
223
+ raise ValueError("diagonal inv_cov entries must be > 0")
224
+
225
+ diff = thetas - means
226
+ return float(-0.5 * np.sum(diff * diff * inv_var))
227
+
228
+
229
+ def _prior_gaussian_mixture_impl(
230
+ theta: NDArray[np.floating],
231
+ *,
232
+ means: NDArray[np.floating],
233
+ inv_covs: NDArray[np.floating],
234
+ log_weights: NDArray[np.floating],
235
+ log_component_norm: NDArray[np.floating],
236
+ ) -> float:
237
+ """Evaluates a Gaussian mixture log-prior at ``theta``.
238
+
239
+ This function computes the log of a weighted sum of Gaussian components::
240
+
241
+ p(theta) = sum_n w_n * N(theta | means_n, cov_n)
242
+
243
+ where ``N(theta | mean, cov)`` is the multivariate Gaussian density with the
244
+ specified mean and covariance; ``w_n` are the mixture weights (in log-space); and
245
+ the sum runs over the ``n=1..N`` components.
246
+
247
+ The result is a log-density defined up to an additive constant.
248
+
249
+ Notes:
250
+ - ``inv_covs`` are the per-component inverse covariances (precision matrices).
251
+ - ``log_weights`` are the mixture weights in log-space; they are typically
252
+ normalized so that ``logsumexp_1d(log_weights) = 0``.
253
+ - ``log_component_norm`` controls whether per-component normalization is
254
+ included. If it contains ``-0.5 * log(|C_n|)`` (or the equivalent computed
255
+ from ``C_n^{-1}``), then components with different covariances get the
256
+ correct relative normalization. If it is all zeros, the mixture keeps
257
+ only the quadratic terms, which can be useful for shape-only priors.
258
+
259
+ Args:
260
+ theta: Parameter vector ``theta`` with shape ``(p,)``.
261
+ means: Component means with shape ``(n, p)``.
262
+ inv_covs: Component inverse covariances with shape ``(n, p, p)``.
263
+ log_weights: Log-weights for the ``n`` components with shape ``(n,)``.
264
+ log_component_norm: Per-component log-normalization terms with shape
265
+ ``(n,)`` (often ``-0.5 * log(|C_n|)``). Use zeros to omit this factor.
266
+
267
+ Returns:
268
+ The mixture log-prior value at ``theta`` (a finite float or ``-np.inf`` if
269
+ the caller enforces hard bounds elsewhere).
270
+
271
+ Raises:
272
+ ValueError: If input arrays have incompatible shapes or dimensions.
273
+ """
274
+ thetas = np.asarray(theta, dtype=np.float64)
275
+ means = np.asarray(means, dtype=np.float64)
276
+ inv_covs = np.asarray(inv_covs, dtype=np.float64)
277
+ log_weights = np.asarray(log_weights, dtype=np.float64)
278
+ log_comp_norm = np.asarray(log_component_norm, dtype=np.float64)
279
+
280
+ if thetas.ndim != 1:
281
+ raise ValueError(f"theta must be 1D, got shape {thetas.shape}")
282
+
283
+ if means.ndim != 2:
284
+ raise ValueError(f"means must be (n, p), got shape {means.shape}")
285
+
286
+ n, p = means.shape
287
+ if thetas.size != p:
288
+ raise ValueError(f"theta length {thetas.size} != p {p}")
289
+
290
+ if inv_covs.ndim != 3 or inv_covs.shape != (n, p, p):
291
+ raise ValueError(f"inv_covs must be (n, p, p) with n={n}, p={p}, got shape {inv_covs.shape}")
292
+
293
+ if log_weights.ndim != 1 or log_weights.size != n:
294
+ raise ValueError(f"log_weights must be (n,), got shape {log_weights.shape}")
295
+
296
+ if log_comp_norm.ndim != 1 or log_comp_norm.size != n:
297
+ raise ValueError(f"log_component_norm must be (n,), got shape {log_comp_norm.shape}")
298
+
299
+ vals = np.empty(n, dtype=np.float64)
300
+ for i in range(n):
301
+ diff = thetas - means[i]
302
+ vals[i] = log_weights[i] + log_comp_norm[i] - 0.5 * float(diff @ inv_covs[i] @ diff)
303
+
304
+ return float(logsumexp_1d(vals))
305
+
306
+
307
+ def prior_none() -> Callable[[NDArray[np.floating]], float]:
308
+ """Constructs an improper flat prior (constant log-density).
309
+
310
+ This prior has a density proportional to 1 everywhere.
311
+
312
+ Returns:
313
+ Log-prior value (always ``0.0``).
314
+ """
315
+ return _prior_none_impl
316
+
317
+
318
+ def prior_uniform(
319
+ *,
320
+ bounds: Sequence[tuple[float | np.floating | None, float | np.floating | None]],
321
+ ) -> Callable[[NDArray[np.floating]], float]:
322
+ """Constructs a uniform prior with hard bounds.
323
+
324
+ This prior has a density proportional to 1 within the specified bounds
325
+ and zero outside. The log-density is constant (up to an additive constant)
326
+ within the bounds and ``-np.inf`` outside.
327
+
328
+ Args:
329
+ bounds: Sequence of (min, max) pairs for each parameter.
330
+ Use None for unbounded sides.
331
+
332
+ Returns:
333
+ A callable that evaluates the log-prior at a given parameter vector.
334
+ """
335
+ return apply_hard_bounds(_prior_none_impl, bounds=bounds)
336
+
337
+
338
+ def prior_gaussian(
339
+ *,
340
+ mean: NDArray[np.floating],
341
+ cov: NDArray[np.floating] | None = None,
342
+ inv_cov: NDArray[np.floating] | None = None,
343
+ ) -> Callable[[NDArray[np.floating]], float]:
344
+ """Constructs a multivariate Gaussian prior (up to an additive constant).
345
+
346
+ This prior has a density proportional to
347
+ ``exp(-0.5 * (theta - mean)^T @ inv_cov @ (theta - mean))`` with ``inv_cov`` being the
348
+ inverse of the provided covariance matrix and ``theta`` being the parameter vector.
349
+
350
+ Args:
351
+ mean: Mean vector.
352
+ cov: Covariance matrix (provide exactly one of ``cov`` or ``inv_cov``).
353
+ inv_cov: Inverse covariance matrix (provide exactly one of ``cov`` or ``inv_cov``).
354
+
355
+ Returns:
356
+ A callable that evaluates the log-prior at a given parameter vector.
357
+
358
+ Raises:
359
+ ValueError: If neither or both of ``cov`` and ``inv_cov`` are provided,
360
+ or if the provided covariance/inverse covariance cannot be
361
+ normalized/validated.
362
+ """
363
+ if (cov is None) == (inv_cov is None):
364
+ raise ValueError("Provide exactly one of `cov` or `inv_cov`.")
365
+
366
+ means = np.asarray(mean, dtype=np.float64)
367
+ if means.ndim != 1:
368
+ raise ValueError(f"mean must be 1D, got shape {means.shape}")
369
+
370
+ if inv_cov is None:
371
+ cov = normalize_covariance(cov, n_parameters=means.size)
372
+ inv_cov = invert_covariance(cov, warn_prefix="prior_gaussian")
373
+ else:
374
+ inv_cov = np.asarray(inv_cov, dtype=np.float64)
375
+ if inv_cov.ndim != 2 or inv_cov.shape != (means.size, means.size):
376
+ raise ValueError(f"inv_cov must have shape (p,p) with p={means.size}, got {inv_cov.shape}")
377
+ if not np.all(np.isfinite(inv_cov)):
378
+ raise ValueError("inv_cov contains non-finite values.")
379
+
380
+ return partial(_prior_gaussian_impl, mean=means, inv_cov=inv_cov)
381
+
382
+
383
+ def prior_gaussian_diag(
384
+ *,
385
+ mean: NDArray[np.floating],
386
+ sigma: NDArray[np.floating],
387
+ ) -> Callable[[NDArray[np.floating]], float]:
388
+ """Constructs a diagonal Gaussian prior (up to an additive constant).
389
+
390
+ This prior has a density proportional to
391
+ ``exp(-0.5 * sum_i ((x_i - mean_i) / sigma_i)^2)``, with independent components.
392
+
393
+ Args:
394
+ mean: Mean vector.
395
+ sigma: Standard deviation vector (must be positive).
396
+
397
+ Returns:
398
+ A callable that evaluates the log-prior at a given parameter vector.
399
+
400
+ Raises:
401
+ ValueError: If `mean` and `sigma` have incompatible shapes,
402
+ or if any `sigma` entries are non-positive.
403
+ """
404
+ means = np.asarray(mean, dtype=np.float64)
405
+ sigmas = np.asarray(sigma, dtype=np.float64)
406
+
407
+ if means.ndim != 1 or sigmas.ndim != 1 or means.shape != sigmas.shape:
408
+ raise ValueError("mean and sigma must be 1D arrays with the same shape")
409
+ if np.any(sigmas <= 0.0):
410
+ raise ValueError("all sigma entries must be > 0")
411
+
412
+ inv_cov = np.diag(1.0 / (sigmas ** 2))
413
+ return partial(_prior_gaussian_diag_impl, mean=means, inv_cov=inv_cov)
414
+
415
+
416
+ def prior_log_uniform(
417
+ *,
418
+ index: int,
419
+ ) -> Callable[[NDArray[np.floating]], float]:
420
+ """Constructs a log-uniform prior for a single positive parameter.
421
+
422
+ This prior assigns equal weight to multiplicative (relative) changes in the
423
+ parameter rather than additive changes. It is commonly used for positive,
424
+ scale-like parameters when no preferred scale is known.
425
+
426
+ For a positive parameter :math:`x`, the (improper) prior density is
427
+ proportional to :math:`1/x` on :math:`(0, infinity)`. This has the same
428
+ functional form as the Jeffreys prior for a positive scale parameter; see
429
+ ``prior_jeffreys`` for that interpretation.
430
+
431
+ Args:
432
+ index: Index of the parameter to which the prior applies.
433
+
434
+ Returns:
435
+ A callable that evaluates the log-prior at a given parameter vector.
436
+ """
437
+ return partial(_prior_1d_impl, index=int(index), domain="positive", kind="log_uniform")
438
+
439
+
440
+ def prior_jeffreys(
441
+ *,
442
+ index: int,
443
+ ) -> Callable[[NDArray[np.floating]], float]:
444
+ """Constructs a Jeffreys prior for a single positive scale parameter.
445
+
446
+ This prior encodes reparameterization invariance for a positive scale
447
+ parameter: inference does not depend on the choice of units used to describe
448
+ the parameter. It is commonly used to express ignorance about the absolute
449
+ scale of a quantity.
450
+
451
+ For a positive scale parameter :math:`x`, the (improper) prior density is
452
+ proportional to :math:`1/x` on :math:`(0, infinity)`. In practice, this has
453
+ the same functional form as the log-uniform prior; the separate name exists
454
+ to emphasize the statistical motivation.
455
+
456
+ Args:
457
+ index: Index of the parameter to which the prior applies.
458
+
459
+ Returns:
460
+ A callable that evaluates the log-prior at a given parameter vector.
461
+ """
462
+ return partial(_prior_1d_impl, index=int(index), domain="positive", kind="log_uniform")
463
+
464
+
465
+ def prior_half_normal(
466
+ *,
467
+ index: int,
468
+ sigma: float | np.floating,
469
+ ) -> Callable[[NDArray[np.floating]], float]:
470
+ """Constructs a half-normal prior for a single non-negative parameter.
471
+
472
+ This prior is a commonly used weakly informative choice for non-negative
473
+ amplitude or scale parameters. It concentrates probability near zero while
474
+ allowing larger values with Gaussian tails, making it suitable when the
475
+ parameter is expected to be small but not exactly zero.
476
+
477
+ The half-normal distribution is obtained by taking the absolute value of a
478
+ zero-mean normal distribution, ``|N(0, sigma)|`` with N being a normal random
479
+ variable and sigma the standard deviation.
480
+
481
+ The (unnormalized) density is proportional to
482
+ ``exp(-0.5 * (x / sigma)**2)`` for ``x >= 0``.
483
+
484
+ Args:
485
+ index: Index of the parameter to which the prior applies.
486
+ sigma: Standard deviation of the underlying normal distribution.
487
+
488
+ Returns:
489
+ A callable that evaluates the log-prior at a given parameter vector.
490
+
491
+ Raises:
492
+ ValueError: If `sigma` is not positive.
493
+ """
494
+ sigma = float(sigma)
495
+ if sigma <= 0.0:
496
+ raise ValueError("sigma must be > 0")
497
+ return partial(_prior_1d_impl, index=int(index), domain="nonnegative", kind="half_normal", a=sigma)
498
+
499
+
500
+ def prior_half_cauchy(
501
+ *,
502
+ index: int,
503
+ scale: float | np.floating
504
+ ) -> Callable[[NDArray[np.floating]], float]:
505
+ """Constructs a half-Cauchy prior for a single non-negative parameter.
506
+
507
+ This prior has a density proportional to ``1 / (1 + (x/scale)^2)`` for ``x >= 0``,
508
+ with x being the parameter.
509
+
510
+ Args:
511
+ index: Index of the parameter to which the prior applies.
512
+ scale: Scale parameter of the half-Cauchy distribution.
513
+
514
+ Returns:
515
+ A callable that evaluates the log-prior at a given parameter vector.
516
+
517
+ Raises:
518
+ ValueError: If `scale` is not positive.
519
+ """
520
+ scale = float(scale)
521
+ if scale <= 0.0:
522
+ raise ValueError("scale must be > 0")
523
+ return partial(_prior_1d_impl, index=int(index), domain="nonnegative", kind="half_cauchy", a=scale)
524
+
525
+
526
+ def prior_log_normal(
527
+ *,
528
+ index: int,
529
+ mean_log: float | np.floating,
530
+ sigma_log: float | np.floating
531
+ ) -> Callable[[NDArray[np.floating]], float]:
532
+ """Constructs a log-normal prior for a single positive parameter.
533
+
534
+ This prior has a density proportional to
535
+ ``exp(-0.5 * ((log(x) - mean_log) / sigma_log) ** 2) / x`` for ``x > 0``.
536
+
537
+ Args:
538
+ index: Index of the parameter to which the prior applies.
539
+ mean_log: Mean of the underlying normal distribution in log-space.
540
+ sigma_log: Standard deviation of the underlying normal distribution in log-space.
541
+
542
+ Returns:
543
+ A callable that evaluates the log-prior at a given parameter vector.
544
+
545
+ Raises:
546
+ ValueError: If `sigma_log` is not positive.
547
+ """
548
+ sig_log = float(sigma_log)
549
+ if sig_log <= 0.0:
550
+ raise ValueError("sigma_log must be > 0")
551
+ return partial(
552
+ _prior_1d_impl,
553
+ index=int(index),
554
+ domain="positive",
555
+ kind="log_normal",
556
+ a=float(mean_log),
557
+ b=sig_log)
558
+
559
+
560
+ def prior_beta(
561
+ *,
562
+ index: int,
563
+ alpha: float | np.floating,
564
+ beta: float | np.floating,
565
+ ) -> Callable[[NDArray[np.floating]], float]:
566
+ """Constructs a Beta distribution prior for a single parameter in ``(0, 1)``.
567
+
568
+ This prior uses the Beta density on ``x in (0, 1)``, with shape parameters
569
+ ``alpha > 0`` and ``beta > 0``. The returned callable evaluates the
570
+ corresponding log-density up to an additive constant (the normalization
571
+ constant does not depend on ``x`` and is therefore omitted).
572
+
573
+ The (unnormalized) density is proportional to::
574
+
575
+ x**(alpha - 1) * (1 - x)**(beta - 1)
576
+
577
+ Args:
578
+ index: Index of the parameter to which the prior applies.
579
+ alpha: Alpha shape parameter (must be greater than 0).
580
+ beta: Beta shape parameter (must be greater than 0).
581
+
582
+ Returns:
583
+ A callable that evaluates the log-prior at a given parameter vector.
584
+
585
+ Raises:
586
+ ValueError: If ``alpha`` or ``beta`` are not positive.
587
+ """
588
+ if alpha <= 0.0 or beta <= 0.0:
589
+ raise ValueError("alpha and beta must be > 0")
590
+ return partial(
591
+ _prior_1d_impl,
592
+ index=int(index),
593
+ domain="unit_open",
594
+ kind="beta",
595
+ a=alpha,
596
+ b=beta)
597
+
598
+
599
+ def prior_gaussian_mixture(
600
+ *,
601
+ means: NDArray[np.floating],
602
+ covs: NDArray[np.floating] | None = None,
603
+ inv_covs: NDArray[np.floating] | None = None,
604
+ weights: NDArray[np.floating] | None = None,
605
+ log_weights: NDArray[np.floating] | None = None,
606
+ include_component_norm: bool = True,
607
+ ) -> Callable[[NDArray[np.floating]], float]:
608
+ """Constructs a Gaussian mixture prior (up to an additive constant).
609
+
610
+ This prior has a density proportional to a weighted sum of Gaussian components.
611
+
612
+ The mixture is::
613
+
614
+ p(theta) = sum_n w_n * N(theta | mean_n, cov_n)
615
+
616
+ where ``N(theta | mean, cov)`` is the multivariate Gaussian density with the
617
+ specified mean and covariance; ``w_n`` are the mixture weights (non-negative,
618
+ summing to 1); and the sum runs over the n=1..N components.
619
+
620
+ Provide exactly one of:
621
+
622
+ - covs: ``(n, p, p)``
623
+ - inv_covs: ``(n, p, p)``
624
+
625
+ Here, ``n`` is the number of components and ``p`` is the parameter dimension.
626
+
627
+ Provide exactly one of:
628
+
629
+ - weights: (n,) non-negative (normalized internally)
630
+ - log_weights: (n,) (normalized internally in log-space)
631
+
632
+ Args:
633
+ means: Component means with shape ``(n, p)``.
634
+ covs: Component covariances with shape ``(n, p, p)``.
635
+ inv_covs: Component inverse covariances with shape ``(n, p, p)``.
636
+ weights: Mixture weights with shape ``(n,)``. Can include zeros.
637
+ log_weights: Log-weights with shape ``(n,)``. Can include -inf entries.
638
+ include_component_norm: If ``True`` (default), include the per-component
639
+ Gaussian normalization factor proportional to :math:`|C_n|^{-1/2}`.
640
+ This is important for *mixtures* when covariances differ.
641
+
642
+ Returns:
643
+ A callable that evaluates the log-prior at a given parameter vector.
644
+
645
+ Raises:
646
+ ValueError: If inputs have incompatible shapes, if both/neither of
647
+ ``covs``/``inv_covs`` are provided, if both/neither of ``weights``/``log_weights``
648
+ are provided, if weights are invalid, or if covariance inputs are not
649
+ compatible with ``include_component_norm=True``.
650
+ """
651
+ means = np.asarray(means, dtype=np.float64)
652
+ if means.ndim != 2:
653
+ raise ValueError(f"means must be (n, p), got shape {means.shape}")
654
+ n, p = means.shape
655
+
656
+ include = bool(include_component_norm)
657
+
658
+ if (covs is None) == (inv_covs is None):
659
+ raise ValueError("Provide exactly one of `covs` or `inv_covs`.")
660
+
661
+ if inv_covs is None:
662
+ cov = np.asarray(covs, dtype=np.float64)
663
+ if cov.ndim != 3 or cov.shape != (n, p, p):
664
+ raise ValueError(f"covs must be (n, p, p) with n={n}, p={p}, got shape {cov.shape}")
665
+
666
+ if include:
667
+ log_component_norm = np.empty(n, dtype=np.float64)
668
+ for i in range(n):
669
+ sign, logdet = np.linalg.slogdet(cov[i])
670
+ if sign <= 0 or not np.isfinite(logdet):
671
+ raise ValueError(
672
+ "include_component_norm=True requires each cov_n to be positive-definite "
673
+ "(slogdet sign>0 and finite)."
674
+ )
675
+ log_component_norm[i] = -0.5 * logdet
676
+ else:
677
+ log_component_norm = np.zeros(n, dtype=np.float64)
678
+
679
+ inv_cov_n = np.empty_like(cov)
680
+ for i in range(n):
681
+ inv_cov_n[i] = invert_covariance(cov[i], warn_prefix="prior_gaussian_mixture")
682
+
683
+ else:
684
+ inv_cov_n = np.asarray(inv_covs, dtype=np.float64)
685
+ if inv_cov_n.ndim != 3 or inv_cov_n.shape != (n, p, p):
686
+ raise ValueError(f"inv_covs must be (n, p, p) with n={n}, p={p}, got shape {inv_cov_n.shape}")
687
+
688
+ if include:
689
+ log_component_norm = np.empty(n, dtype=np.float64)
690
+ for i in range(n):
691
+ sign, logdet = np.linalg.slogdet(inv_cov_n[i])
692
+ if sign <= 0 or not np.isfinite(logdet):
693
+ raise ValueError(
694
+ "include_component_norm=True requires each inv_cov_n to have positive determinant "
695
+ "(slogdet sign>0 and finite)."
696
+ )
697
+ # -0.5 log|C| = +0.5 log|C^{-1}|
698
+ log_component_norm[i] = 0.5 * logdet
699
+ else:
700
+ log_component_norm = np.zeros(n, dtype=np.float64)
701
+
702
+ if (weights is None) == (log_weights is None):
703
+ raise ValueError("Provide exactly one of `weights` or `log_weights`.")
704
+
705
+ if weights is not None:
706
+ w = np.asarray(weights, dtype=np.float64)
707
+ if w.ndim != 1 or w.size != n:
708
+ raise ValueError(f"weights must be (n,) with n={n}, got shape {w.shape}")
709
+ if np.any(w < 0.0):
710
+ raise ValueError("weights must be non-negative")
711
+ s = float(np.sum(w))
712
+ if s <= 0.0:
713
+ raise ValueError("weights must sum to a positive value")
714
+ w = w / s
715
+ lw = np.full_like(w, -np.inf, dtype=np.float64)
716
+ np.log(w, out=lw, where=(w > 0))
717
+ else:
718
+ lw_in = np.asarray(log_weights, dtype=np.float64)
719
+ if lw_in.ndim != 1 or lw_in.size != n:
720
+ raise ValueError(f"log_weights must be (n,) with n={n}, got shape {lw_in.shape}")
721
+ lw = lw_in - logsumexp_1d(lw_in)
722
+
723
+ if not (np.all(np.isfinite(means)) and np.all(np.isfinite(inv_cov_n))):
724
+ raise ValueError("mixture prior inputs must be finite")
725
+
726
+ if np.any(np.isnan(lw)) or np.any(lw == np.inf):
727
+ raise ValueError("log_weights must not contain nan or +inf")
728
+
729
+ return partial(
730
+ _prior_gaussian_mixture_impl,
731
+ means=means,
732
+ inv_covs=inv_cov_n,
733
+ log_weights=lw,
734
+ log_component_norm=log_component_norm,
735
+ )
736
+
737
+ _PRIOR_REGISTRY: dict[str, Callable[..., Callable[[NDArray[np.floating]], float]]] = {
738
+ "none": prior_none,
739
+ "uniform": prior_uniform,
740
+ "gaussian": prior_gaussian,
741
+ "gaussian_diag": prior_gaussian_diag,
742
+ "log_uniform": prior_log_uniform,
743
+ "jeffreys": prior_jeffreys,
744
+ "half_normal": prior_half_normal,
745
+ "half_cauchy": prior_half_cauchy,
746
+ "log_normal": prior_log_normal,
747
+ "beta": prior_beta,
748
+ "gaussian_mixture": prior_gaussian_mixture,
749
+ }
750
+
751
+
752
+ def _make_prior_term(spec: dict[str, Any]) -> Callable[[NDArray[np.floating]], float]:
753
+ """Builds one log-prior contribution from a configuration dictionary.
754
+
755
+ Args:
756
+ spec: Prior specification dictionary with the form
757
+ ``{"name": "<prior_name>", "params": {...}, "bounds": optional_bounds}``.
758
+ The ``"name"`` selects a registered prior constructor and ``"params"``
759
+ are forwarded to that constructor. The optional top-level ``"bounds"``
760
+ apply additional hard bounds to the resulting callable. For
761
+ ``name="uniform"``, bounds must be provided via either
762
+ ``params={"bounds": ...}`` or the top-level ``"bounds"`` key, but not
763
+ both.
764
+
765
+ Returns:
766
+ A callable that evaluates the specified log-prior term.
767
+
768
+ Raises:
769
+ ValueError: If ``spec`` is missing a valid prior name, if the name is not
770
+ registered, or if a uniform prior does not include bounds (or includes
771
+ bounds twice).
772
+ """
773
+ name = str(spec.get("name", "")).strip().lower()
774
+ if not name:
775
+ raise ValueError("prior spec must include non-empty 'name'")
776
+ if name not in _PRIOR_REGISTRY:
777
+ raise ValueError(f"Unknown prior name '{name}'")
778
+
779
+ params = dict(spec.get("params", {}))
780
+ term_bounds = spec.get("bounds", None)
781
+
782
+ if name == "uniform":
783
+ # allow either params["bounds"] or spec["bounds"] (but not both)
784
+ pb = params.get("bounds", None)
785
+ if pb is not None and term_bounds is not None:
786
+ raise ValueError(
787
+ "uniform prior: provide bounds via either params['bounds'] or top-level 'bounds', not both")
788
+ if pb is None and term_bounds is None:
789
+ raise ValueError("uniform prior requires bounds")
790
+ b = pb if pb is not None else term_bounds
791
+ return prior_uniform(bounds=b)
792
+
793
+ term = _PRIOR_REGISTRY[name](**params)
794
+ return apply_hard_bounds(term, bounds=term_bounds)
795
+
796
+
797
+ def build_prior(
798
+ *,
799
+ terms: Sequence[tuple[str, dict[str, Any]] | dict[str, Any]] | None = None,
800
+ bounds: Sequence[tuple[float | np.floating | None, float | np.floating | None]] | None = None,
801
+ ) -> Callable[[NDArray[np.floating]], float]:
802
+ """Build and return a single log-prior callable from a simple specification.
803
+
804
+ This function combines one or more prior components into a single
805
+ ``logprior(theta) -> float`` callable by summing their log-densities and
806
+ optionally applying global hard bounds.
807
+
808
+ Each prior component (a “term”) specifies one distribution, such as a
809
+ Gaussian or log-uniform prior, and may also include its own hard bounds.
810
+
811
+ Args:
812
+ terms: Optional list of prior terms. Each term is specified either as
813
+ ``("prior_name", params)`` or as a dictionary with keys ``"name"``,
814
+ ``"params"``, and optional per-term ``"bounds"``.
815
+ bounds: Optional global hard bounds applied to the combined prior.
816
+
817
+ Returns:
818
+ A callable that evaluates the combined log-prior at a given parameter
819
+ vector.
820
+
821
+ Behavior:
822
+ - If ``terms`` is ``None`` or empty:
823
+ * If ``bounds`` is ``None``, returns an improper flat prior.
824
+ * If ``bounds`` is provided, returns a uniform prior over ``bounds``.
825
+ - Prior terms are summed in log-space.
826
+ - Global ``bounds`` are applied after all terms are combined.
827
+
828
+ Examples:
829
+ ```
830
+ build_prior()
831
+ build_prior(bounds=[(0.0, None), (None, None)])
832
+ build_prior(terms=[("gaussian_diag", {"mean": mu, "sigma": sig})])
833
+ ```
834
+ """
835
+ term_list = [] if terms is None else list(terms)
836
+
837
+ # If empty then use either flat or bounded-uniform
838
+ if len(term_list) == 0:
839
+ base = prior_none() if bounds is None else prior_uniform(bounds=bounds)
840
+ return base
841
+
842
+ specs: list[dict[str, Any]] = []
843
+ for t in term_list:
844
+ if isinstance(t, dict):
845
+ specs.append(t)
846
+ continue
847
+
848
+ if not isinstance(t, (tuple, list)) or len(t) != 2:
849
+ raise TypeError(
850
+ "Each term must be either a dict spec or a (name, params) tuple/list of length 2."
851
+ )
852
+ name, params = t
853
+ if not isinstance(params, dict):
854
+ raise TypeError("Term params must be a dict.")
855
+ specs.append({"name": str(name), "params": dict(params)})
856
+
857
+ built_terms = [_make_prior_term(s) for s in specs]
858
+ combined = sum_terms(*built_terms)
859
+
860
+ return apply_hard_bounds(combined, bounds=bounds)