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,176 @@
1
+ """Poissonian likelihoods function module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy.stats import poisson
7
+
8
+ __all__ = [
9
+ "build_poissonian_likelihood",
10
+ ]
11
+
12
+
13
+ def build_poissonian_likelihood(
14
+ data: float | np.ndarray[float],
15
+ model_parameters: float | np.ndarray[float],
16
+ return_log: bool = True,
17
+ ) -> tuple[np.ndarray[float], np.ndarray[float]]:
18
+ """Constructs the Poissonian likelihoods function.
19
+
20
+ The shape of the data products depend on the shape of ``model_parameters``.
21
+ The assumption is that ``model_parameters`` contains the expectation value
22
+ of some quantity which is either uniform for the entire distribution or is
23
+ distributed across a grid of bins. It is uniform for the entire distribution
24
+ if it is a scalar.
25
+
26
+ The function will try to reshape ``data`` to align with ``model_parameters``.
27
+ If ``model_parameters`` is a scalar, then ``data`` will be flattened. Otherwise,
28
+ the grid can contain any number of axes, but currently the number of axes
29
+ is hardcoded to 2. Supplying a higher-dimensional array to
30
+ ``model_parameters`` may produce unexpected results.
31
+
32
+ This hardcoded limit means that, while it is possible to supply
33
+ ``model_parameters`` along a 1D grid, the output shape will always be a
34
+ 2D row-major array. See Examples for more details.
35
+
36
+ Args:
37
+ data: an array representing the given data values.
38
+ model_parameters: an array representing the means of the data samples.
39
+ return_log: when set to ``True``, returns the log-likelihoods instead of
40
+ the probability mass function.
41
+
42
+ Returns:
43
+ A tuple of arrays containing (in order):
44
+
45
+ - the data, reshaped to align with the model parameters.
46
+ - the values of the Poissonian probability mass function computed
47
+ from the data and model parameters.
48
+
49
+ Raises:
50
+ ValueError: If any of the model_parameters are negative or non-finite,
51
+ or the data points cannot be reshaped to align with
52
+ model_parameters.
53
+
54
+ Examples:
55
+ Scalar mean + scalar data:
56
+
57
+ >>> import numpy as np
58
+ >>> from scipy.stats import poisson
59
+ >>> from derivkit.likelihoods.poisson import build_poissonian_likelihood
60
+ >>> x, y = build_poissonian_likelihood(2, 1.4, return_log=False)
61
+ >>> x.shape, y.shape
62
+ ((1,), (1,))
63
+ >>> x[0].item()
64
+ 2
65
+ >>> np.allclose(y[0], poisson.pmf(2, 1.4))
66
+ True
67
+
68
+ Vector data + scalar mean (data are flattened):
69
+
70
+ >>> data = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
71
+ >>> model_parameters = 2.4
72
+ >>> x, y = build_poissonian_likelihood(
73
+ ... data, model_parameters, return_log=False
74
+ ... )
75
+ >>> x.shape, y.shape
76
+ ((10,), (10,))
77
+ >>> np.array_equal(x, data)
78
+ True
79
+ >>> np.allclose(y, poisson.pmf(data, 2.4))
80
+ True
81
+
82
+ Shape follows ``model_parameters``:
83
+
84
+ >>> data = np.array([1, 2])
85
+ >>> model_parameters = np.array([3])
86
+ >>> x, y = build_poissonian_likelihood(
87
+ ... data, model_parameters, return_log=False
88
+ ... )
89
+ >>> x.shape, y.shape
90
+ ((2, 1), (2, 1))
91
+ >>> np.array_equal(x[:, 0], data)
92
+ True
93
+ >>> np.allclose(y[:, 0], poisson.pmf(data, 3))
94
+ True
95
+
96
+ 1D grid of bins produces a row-major 2D output:
97
+
98
+ >>> model_parameters = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6])
99
+ >>> data = np.array([1, 2, 3, 4, 5, 6])
100
+ >>> x, y = build_poissonian_likelihood(
101
+ ... data, model_parameters, return_log=False
102
+ ... )
103
+ >>> x.shape, y.shape
104
+ ((1, 6), (1, 6))
105
+ >>> np.array_equal(x[0], data)
106
+ True
107
+ >>> np.allclose(y[0], poisson.pmf(data, model_parameters))
108
+ True
109
+
110
+ 2D grid:
111
+
112
+ >>> data = np.array([[1, 2, 3], [4, 5, 6]])
113
+ >>> model_parameters = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
114
+ >>> x, y = build_poissonian_likelihood(
115
+ ... data, model_parameters, return_log=False
116
+ ... )
117
+ >>> x.shape, y.shape
118
+ ((1, 2, 3), (1, 2, 3))
119
+ >>> np.array_equal(x[0], data)
120
+ True
121
+ >>> np.allclose(y[0], poisson.pmf(data, model_parameters))
122
+ True
123
+
124
+ Stacked data on the same grid:
125
+
126
+ >>> val1 = np.array([[1, 2, 3], [4, 5, 6]])
127
+ >>> val2 = np.array([[7, 8, 9], [10, 11, 12]])
128
+ >>> data = np.array([val1, val2])
129
+ >>> model_parameters = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
130
+ >>> x, y = build_poissonian_likelihood(
131
+ ... data, model_parameters, return_log=False
132
+ ... )
133
+ >>> x.shape, y.shape
134
+ ((2, 2, 3), (2, 2, 3))
135
+ >>> np.array_equal(x[0], val1) and np.array_equal(x[1], val2)
136
+ True
137
+ >>> np.allclose(y[0], poisson.pmf(val1, model_parameters))
138
+ True
139
+ >>> np.allclose(y[1], poisson.pmf(val2, model_parameters))
140
+ True
141
+
142
+ Same result when supplying flattened data:
143
+
144
+ >>> data_flat = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
145
+ >>> x2, y2 = build_poissonian_likelihood(
146
+ ... data_flat, model_parameters, return_log=False
147
+ ... )
148
+ >>> np.array_equal(x2, x) and np.allclose(y2, y)
149
+ True
150
+ """
151
+ values_to_reshape = np.asarray(data)
152
+ parameters = np.asarray(model_parameters)
153
+
154
+ if np.any(values_to_reshape < 0):
155
+ raise ValueError("values of data must be non-negative.")
156
+ if np.any(~np.isfinite(values_to_reshape)):
157
+ raise ValueError("values of data must be finite.")
158
+ if np.any(parameters < 0):
159
+ raise ValueError("values of model_parameters must be non-negative.")
160
+ if np.any(~np.isfinite(parameters)):
161
+ raise ValueError("values of model_parameters must be finite.")
162
+
163
+ try:
164
+ counts = values_to_reshape.reshape(-1, *parameters.shape[-2:])
165
+ except ValueError:
166
+ raise ValueError(
167
+ "data cannot be reshaped to align with model_parameters: "
168
+ f"data.shape={values_to_reshape.shape} is incompatible with "
169
+ f"model_parameters.shape={parameters.shape}."
170
+ )
171
+
172
+ probabilities = poisson.logpmf(counts, parameters) \
173
+ if return_log \
174
+ else poisson.pmf(counts, parameters)
175
+
176
+ return counts, probabilities
@@ -0,0 +1,13 @@
1
+ """Utility functions for DerivKit package."""
2
+
3
+ from derivkit.utils.linalg import (
4
+ invert_covariance,
5
+ normalize_covariance,
6
+ solve_or_pinv,
7
+ )
8
+
9
+ __all__ = [
10
+ "solve_or_pinv",
11
+ "invert_covariance",
12
+ "normalize_covariance",
13
+ ]
@@ -0,0 +1,213 @@
1
+ """Concurrency management for derivative computations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import os
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import contextmanager
9
+ from typing import Any, Callable, Iterator, Sequence, Tuple
10
+
11
+ __all__ = [
12
+ "set_default_inner_derivative_workers",
13
+ "set_inner_derivative_workers",
14
+ "resolve_inner_from_outer",
15
+ "parallel_execute",
16
+ "_inner_workers_var",
17
+ "normalize_workers",
18
+ "resolve_workers",
19
+ ]
20
+
21
+
22
+ # Context-var and default
23
+ _inner_workers_var: contextvars.ContextVar[int | None] = contextvars.ContextVar(
24
+ "derivkit_inner_workers", default=None
25
+ )
26
+ _DEFAULT_INNER_WORKERS: int | None = None
27
+
28
+
29
+ def set_default_inner_derivative_workers(n: int | None) -> None:
30
+ """Sets the module-wide default for inner derivative workers.
31
+
32
+ Args:
33
+ n: Number of inner derivative workers, or None for automatic policy.
34
+
35
+ Returns:
36
+ None
37
+ """
38
+ global _DEFAULT_INNER_WORKERS
39
+ _DEFAULT_INNER_WORKERS = None if n is None else int(n)
40
+
41
+
42
+ @contextmanager
43
+ def set_inner_derivative_workers(n: int | None) -> Iterator[int | None]:
44
+ """Temporarily sets the number of inner derivative workers.
45
+
46
+ Args:
47
+ n: Number of inner derivative workers, or ``None`` for automatic policy.
48
+
49
+ Yields:
50
+ int | None: The previous worker setting (restored on exit).
51
+ """
52
+ prev = _inner_workers_var.get()
53
+ token = _inner_workers_var.set(None if n is None else int(n))
54
+ try:
55
+ yield prev
56
+ finally:
57
+ _inner_workers_var.reset(token)
58
+
59
+
60
+ def _int_env(name: str) -> int | None:
61
+ """Reads a positive integer from an environment variable, or None if unset/invalid.
62
+
63
+ Args:
64
+ name: Environment variable name.
65
+
66
+ Returns:
67
+ Positive integer value, or None.
68
+ """
69
+ v = os.getenv(name)
70
+ if not v:
71
+ return None
72
+ try:
73
+ i = int(v)
74
+ return i if i > 0 else None
75
+ except ValueError:
76
+ return None
77
+
78
+ def _detect_hw_threads() -> int:
79
+ """Detects the number of hardware threads, capped by relevant environment variables.
80
+
81
+ Returns:
82
+ Number of hardware threads (at least 1).
83
+ """
84
+ hints = [
85
+ _int_env("OMP_NUM_THREADS"),
86
+ _int_env("MKL_NUM_THREADS"),
87
+ _int_env("OPENBLAS_NUM_THREADS"),
88
+ _int_env("VECLIB_MAXIMUM_THREADS"),
89
+ _int_env("NUMEXPR_NUM_THREADS"),
90
+ ]
91
+ env_cap = min([h for h in hints if h is not None], default=None)
92
+ hw = os.cpu_count() or 1
93
+ return max(1, min(hw, env_cap) if env_cap else hw)
94
+
95
+
96
+ def resolve_inner_from_outer(w_params: int) -> int | None:
97
+ """Resolves the number of inner derivative workers based on outer workers and defaults.
98
+
99
+ Args:
100
+ w_params: Number of outer derivative workers.
101
+
102
+ Returns:
103
+ Number of inner derivative workers, or None for automatic policy.
104
+ """
105
+ w = _inner_workers_var.get()
106
+ if w is not None:
107
+ return w
108
+ if _DEFAULT_INNER_WORKERS is not None:
109
+ return _DEFAULT_INNER_WORKERS
110
+ cores = _detect_hw_threads()
111
+ if w_params > 1:
112
+ return min(4, max(1, cores // w_params))
113
+ return min(4, cores)
114
+
115
+
116
+ def parallel_execute(
117
+ worker: Callable[..., Any],
118
+ arg_tuples: Sequence[Tuple[Any, ...]],
119
+ *,
120
+ outer_workers: int = 1,
121
+ inner_workers: int | None = None,
122
+ backend: str = "threads",
123
+ ) -> list[Any]:
124
+ """Applies a function to groups of arguments in parallel.
125
+
126
+ Inner worker setting is applied to the context, so calls inside worker
127
+ will see the resolved inner worker count.
128
+
129
+ Args:
130
+ worker: Function applied to each entry in ``arg_tuples`` (called as ``worker(*args)``).
131
+ arg_tuples: Argument tuples; each tuple is expanded into one ``worker(*args)`` call.
132
+ outer_workers: Parallelism level for outer execution.
133
+ inner_workers: Inner derivative worker setting to propagate via contextvar.
134
+ backend: Parallel backend. Currently supported: "threads".
135
+
136
+ Returns:
137
+ List of worker return values.
138
+ """
139
+ backend_l = str(backend).lower()
140
+ if backend_l not in {"threads"}:
141
+ raise NotImplementedError(
142
+ f"parallel_execute backend={backend!r} not supported yet."
143
+ f" Use backend='threads'."
144
+ )
145
+
146
+ with set_inner_derivative_workers(inner_workers):
147
+ if outer_workers > 1:
148
+ with ThreadPoolExecutor(max_workers=outer_workers) as ex:
149
+ futures = []
150
+ for args in arg_tuples:
151
+ # Each task gets its own copy of the current context
152
+ ctx = contextvars.copy_context()
153
+ futures.append(ex.submit(ctx.run, worker, *args))
154
+ return [f.result() for f in futures]
155
+ else:
156
+ return [worker(*args) for args in arg_tuples]
157
+
158
+
159
+ def normalize_workers(
160
+ n_workers: Any
161
+ ) -> int:
162
+ """Ensures n_workers is a positive integer, defaulting to 1.
163
+
164
+ Args:
165
+ n_workers: Input number of workers (can be None, float, negative, etc.)
166
+
167
+ Returns:
168
+ int: A positive integer number of workers (at least 1).
169
+
170
+ Raises:
171
+ None: Invalid inputs are coerced to 1.
172
+ """
173
+ try:
174
+ n = int(n_workers)
175
+ except (TypeError, ValueError):
176
+ n = 1
177
+ return 1 if n < 1 else n
178
+
179
+
180
+ def resolve_workers(
181
+ n_workers: Any,
182
+ dk_kwargs: dict[str, Any],
183
+ ) -> tuple[int, int | None, dict[str, Any]]:
184
+ """Decides how parallel work is split between outer calculus routines and the inner derivative engine.
185
+
186
+ Outer workers parallelize across independent derivative tasks (e.g. parameters,
187
+ output components, Hessian entries). Inner workers control parallelism inside
188
+ each derivative evaluation (within DerivativeKit).
189
+
190
+ If both levels spawn workers simultaneously, nested parallelism can cause
191
+ oversubscription. By default, the inner worker count is derived from the
192
+ outer worker count to avoid that. You can override this by passing
193
+ ``inner_workers=<int>`` via ``dk_kwargs``.
194
+
195
+ Args:
196
+ n_workers: Number of outer workers. If ``None``, defaults to 1.
197
+ dk_kwargs: Keyword arguments forwarded to DerivativeKit.differentiate.
198
+ May include ``inner_workers`` to override the default policy.
199
+
200
+ Returns:
201
+ (outer_workers, inner_workers, dk_kwargs_cleaned), where ``dk_kwargs_cleaned``
202
+ has any ``inner_workers`` entry removed.
203
+ """
204
+ dk_kwargs_cleaned = dict(dk_kwargs)
205
+ inner_override = dk_kwargs_cleaned.pop("inner_workers", None)
206
+
207
+ outer = normalize_workers(n_workers)
208
+ if inner_override is None:
209
+ inner = resolve_inner_from_outer(outer)
210
+ else:
211
+ inner = normalize_workers(inner_override)
212
+
213
+ return outer, inner, dk_kwargs_cleaned
@@ -0,0 +1,254 @@
1
+ """Extrapolation methods for numerical approximations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+ __all__ = [
11
+ "richardson_extrapolate",
12
+ "ridders_extrapolate",
13
+ "gauss_richardson_extrapolate",
14
+ ]
15
+
16
+
17
+ def richardson_extrapolate(
18
+ base_values: Sequence[NDArray[np.float64] | float],
19
+ p: int,
20
+ r: float = 2.0,
21
+ ) -> NDArray[np.float64] | float:
22
+ """Computes Richardson extrapolation on a sequence of approximations.
23
+
24
+ Richardson extrapolation improves the accuracy of a sequence of
25
+ numerical approximations that converge with a known leading-order error
26
+ term. Given a sequence of approximations computed with decreasing step sizes,
27
+ this method combines them to eliminate the leading error term, yielding
28
+ a more accurate estimate of the true value.
29
+
30
+ Args:
31
+ base_values:
32
+ Sequence of approximations at different step sizes.
33
+ The step sizes are assumed to decrease by a factor of ``r``
34
+ between successive entries.
35
+ p:
36
+ The order of the leading error term in the approximations.
37
+ r:
38
+ The step-size reduction factor between successive entries
39
+ (default is ``2.0``).
40
+
41
+ Returns:
42
+ The extrapolated value with improved accuracy.
43
+
44
+ Raises:
45
+ ValueError: If ``base_values`` has fewer than two entries.
46
+ """
47
+ # Work on float arrays for both scalar and vector cases
48
+ n = len(base_values)
49
+ if n < 2:
50
+ raise ValueError("richardson_extrapolate requires at least two base values.")
51
+
52
+ vals = [np.asarray(v, dtype=float) for v in base_values]
53
+
54
+ for j in range(1, n):
55
+ factor = r ** (p * j)
56
+ for k in range(n - 1, j - 1, -1):
57
+ vals[k] = (factor * vals[k] - vals[k - 1]) / (factor - 1.0)
58
+
59
+ result = vals[-1]
60
+ return float(result) if result.ndim == 0 else result
61
+
62
+
63
+ def ridders_extrapolate(
64
+ base_values: Sequence[NDArray[np.float64] | float],
65
+ r: float = 2.0,
66
+ *,
67
+ extrapolator = richardson_extrapolate,
68
+ p: int = 2,
69
+ ) -> tuple[NDArray[np.float64] | float, float]:
70
+ """Computes a Ridders-style extrapolation on a sequence of approximations.
71
+
72
+ This builds the usual Ridders diagonal assuming a central finite-difference
73
+ scheme (leading error is approximately O(h^2)) by repeatedly extrapolating
74
+ prefixes of ``base_values``. By default it uses
75
+ :func:`derivkit.utils.extrapolation.richardson_extrapolate`
76
+ with ``p=2``, but a different extrapolator can be passed if needed.
77
+
78
+ Args:
79
+ base_values:
80
+ Sequence of derivative approximations at step sizes
81
+ h, h/r, h/r^2, ... (all same shape: scalar, vector, or tensor).
82
+ r:
83
+ Step-size reduction factor (default ``2.0``).
84
+ extrapolator:
85
+ Function implementing the extrapolation step. Must have the
86
+ signature ``extrapolator(base_values, p, r) -> array_like``.
87
+ Defaults to
88
+ :func:`derivkit.utils.extrapolation.richardson_extrapolate`.
89
+ p:
90
+ Leading error order passed to ``extrapolator`` (default ``2``).
91
+
92
+ Returns:
93
+ A tuple ``(best_value, error_estimate)`` where:
94
+
95
+ * ``best_value`` is the extrapolated estimate chosen from the
96
+ diagonal entries.
97
+ * ``error_estimate`` is a heuristic scalar error scale given by the
98
+ minimum difference between consecutive diagonal elements.
99
+
100
+ Raises:
101
+ ValueError:
102
+ If fewer than two base values are provided.
103
+ """
104
+ n = len(base_values)
105
+ if n < 2:
106
+ raise ValueError("ridders_extrapolate requires at least two base values.")
107
+
108
+ diag: list[NDArray[np.float64]] = []
109
+ err_estimates: list[float] = []
110
+
111
+ for j in range(n):
112
+ if j == 0:
113
+ d_j = np.asarray(base_values[0], dtype=float)
114
+ else:
115
+ # Use the chosen extrapolator on the first (j+1) base values
116
+ d_j = np.asarray(
117
+ extrapolator(base_values[: j + 1], p=p, r=r),
118
+ dtype=float,
119
+ )
120
+
121
+ diag.append(d_j)
122
+
123
+ if j == 0:
124
+ err_estimates.append(np.inf)
125
+ else:
126
+ diff = np.asarray(diag[j] - diag[j - 1], dtype=float)
127
+ err_estimates.append(float(np.max(np.abs(diff))))
128
+
129
+ # Pick the diagonal element with the smallest estimated error
130
+ best_idx = int(np.argmin(err_estimates))
131
+ best_val = diag[best_idx]
132
+ best_err = err_estimates[best_idx]
133
+
134
+ if best_val.ndim == 0:
135
+ return float(best_val), float(best_err)
136
+ return best_val, float(best_err)
137
+
138
+
139
+ def _rbf_kernel_1d(x: NDArray[np.float64],
140
+ y: NDArray[np.float64],
141
+ length_scale: float) -> NDArray[np.float64]:
142
+ """Compute the RBF kernel matrix between 1D inputs x and y.
143
+
144
+ Args:
145
+ x: 1D array of shape (n,).
146
+ y: 1D array of shape (m,).
147
+ length_scale: Length scale parameter for the RBF kernel.
148
+
149
+ Returns:
150
+ Kernel matrix of shape (n, m).
151
+ """
152
+ x = np.atleast_1d(x).astype(float)
153
+ y = np.atleast_1d(y).astype(float)
154
+ diff2 = (x[:, None] - y[None, :]) ** 2
155
+ return np.exp(-0.5 * diff2 / (length_scale**2))
156
+
157
+
158
+ def gauss_richardson_extrapolate(
159
+ base_values: Sequence[NDArray[np.float64] | float],
160
+ h_values: Sequence[float],
161
+ p: int,
162
+ jitter: float = 1e-10,
163
+ ) -> tuple[NDArray[np.float64] | float, NDArray[np.float64] | float]:
164
+ """Gauss–Richardson extrapolation for a sequence of approximations f(h_i).
165
+
166
+ This method uses a Gaussian-process model with a radial-basis-function (RBF)
167
+ kernel to perform Richardson extrapolation, providing both an improved estimate
168
+ of the true value at h=0 and an uncertainty estimate. For more details, see arXiv:2401.07562.
169
+
170
+ Args:
171
+ base_values: Sequence of approximations at different step sizes h_i.
172
+ h_values: Corresponding step sizes (must be positive and same length as base_values).
173
+ p: The order of the leading error term in the approximations.
174
+ jitter: Small positive value added to the diagonal of the kernel matrix for numerical stability.
175
+ Defaults to ``1e-10``.
176
+
177
+ Returns:
178
+ A tuple (extrapolated_value, error_estimate) where:
179
+
180
+ - extrapolated_value is the Gauss–Richardson extrapolated estimate at h=0.
181
+ - error_estimate is a heuristic uncertainty estimate for the extrapolated value.
182
+
183
+ Raises:
184
+ ValueError:
185
+ If h_values and base_values have different lengths or if any h_value is non-positive.
186
+ """
187
+ h = np.asarray(h_values, dtype=float).ravel()
188
+ if len(base_values) != h.size:
189
+ raise ValueError("base_values and h_values must have the same length.")
190
+ if np.any(h <= 0):
191
+ raise ValueError("All h_values must be > 0.")
192
+
193
+ y = np.stack([np.asarray(v, dtype=float) for v in base_values], axis=0)
194
+ n = h.size
195
+
196
+ # Error bound b(h) = h^p
197
+ b = h**p
198
+
199
+ # crude length scale from spacing
200
+ h_sorted = np.sort(h)
201
+ # then we compute the differences between consecutive sorted h values
202
+ diffs = np.diff(h_sorted)
203
+ # if there are any positive differences, take the median of those
204
+ if np.any(diffs > 0):
205
+ char = np.median(diffs[diffs > 0])
206
+ else:
207
+ char = max(h.max() - h.min(), 1e-12)
208
+
209
+ ell = char
210
+
211
+ # we then build the kernel matrix kb
212
+ ke = _rbf_kernel_1d(h, h, ell)
213
+ kb = (b[:, None] * b[None, :]) * ke
214
+ kb += jitter * np.eye(n)
215
+
216
+ # we then precompute the matrix-vector product kb^{-1} 1
217
+ one = np.ones(n)
218
+ kb_inv_1 = np.linalg.solve(kb, one)
219
+
220
+ flat = y.reshape(n, -1)
221
+ means = []
222
+ errs = []
223
+
224
+ denom = float(one @ kb_inv_1)
225
+ for j in range(flat.shape[1]):
226
+ col = flat[:, j]
227
+
228
+ # reuse kb_inv_1 or recompute:
229
+ kb_inv_y = np.linalg.solve(kb, col)
230
+
231
+ num = float(one @ kb_inv_y)
232
+ mean0 = num / denom # μ̂
233
+
234
+ # Residuals
235
+ resid = col - mean0 * one
236
+ kb_inv_resid = np.linalg.solve(kb, resid)
237
+
238
+ # Noise variance estimate
239
+ sigma2 = float(resid @ kb_inv_resid) / max(n - 1, 1)
240
+
241
+ # Variance at h=0
242
+ var0 = sigma2 / denom if denom > 0 else 0.0
243
+ var0 = max(var0, 0.0)
244
+ std0 = float(np.sqrt(var0))
245
+
246
+ means.append(mean0)
247
+ errs.append(std0)
248
+
249
+ means_arr = np.array(means).reshape(y.shape[1:])
250
+ errs_arr = np.array(errs).reshape(y.shape[1:])
251
+
252
+ if means_arr.ndim == 0:
253
+ return float(means_arr), float(errs_arr)
254
+ return means_arr, errs_arr