chebax 0.1.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.
- chebax/__init__.py +38 -0
- chebax/_src/__init__.py +0 -0
- chebax/_src/algorithms.py +74 -0
- chebax/_src/generate.py +177 -0
- chebax/_src/pytree.py +59 -0
- chebax/_src/recipes/__init__.py +0 -0
- chebax/_src/recipes/_common.py +95 -0
- chebax/_src/recipes/_gen_common.py +121 -0
- chebax/_src/recipes/besseli.py +209 -0
- chebax/_src/recipes/besseli_gen.py +84 -0
- chebax/_src/recipes/besseli_table.py +138 -0
- chebax/_src/recipes/besselj.py +176 -0
- chebax/_src/recipes/besselj_gen.py +146 -0
- chebax/_src/recipes/besselj_table.py +39 -0
- chebax/_src/recipes/besselj_table_ext.py +87 -0
- chebax/_src/recipes/besselk.py +184 -0
- chebax/_src/recipes/besselk_gen.py +107 -0
- chebax/_src/recipes/besselk_table.py +208 -0
- chebax/_src/recipes/bessely.py +185 -0
- chebax/_src/recipes/bessely_gen.py +103 -0
- chebax/_src/recipes/bessely_table.py +284 -0
- chebax/_src/recipes/betainc.py +128 -0
- chebax/_src/recipes/betainc_gen.py +78 -0
- chebax/_src/recipes/betainc_table.py +1791 -0
- chebax/_src/recipes/erf_family.py +63 -0
- chebax/_src/recipes/erf_gen.py +69 -0
- chebax/_src/recipes/erf_table.py +51 -0
- chebax/_src/recipes/lambertw.py +106 -0
- chebax/_src/recipes/quantiles.py +339 -0
- chebax/_src/recipes/spherical.py +96 -0
- chebax/_src/recipes/vonmises.py +113 -0
- chebax/_src/recipes/vonmises_gen.py +68 -0
- chebax/_src/recipes/vonmises_table.py +127 -0
- chebax/_src/series.py +223 -0
- chebax/bake/__init__.py +178 -0
- chebax/bake/_jaxpr_emit.py +264 -0
- chebax-0.1.0.dist-info/METADATA +94 -0
- chebax-0.1.0.dist-info/RECORD +41 -0
- chebax-0.1.0.dist-info/WHEEL +5 -0
- chebax-0.1.0.dist-info/licenses/LICENSE +28 -0
- chebax-0.1.0.dist-info/top_level.txt +1 -0
chebax/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""chebax: differentiable Chebyshev approximants for special functions.
|
|
2
|
+
|
|
3
|
+
Full float64 accuracy needs jax's x64 mode (jax.config.update("jax_enable_x64", True)).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from chebax._src.generate import fit
|
|
7
|
+
from chebax._src.recipes.besseli import besseli, besseli_dnu, besseli_fn
|
|
8
|
+
from chebax._src.recipes.erf_family import dawsn, erfcx
|
|
9
|
+
from chebax._src.recipes.lambertw import lambertw
|
|
10
|
+
from chebax._src.recipes.spherical import spherical_jn, spherical_yn
|
|
11
|
+
from chebax._src.recipes.besselj import besselj, besselj_dnu
|
|
12
|
+
from chebax._src.recipes.besselk import (besselk, besselk_dnu, besselk_fn,
|
|
13
|
+
log_besselk_fn)
|
|
14
|
+
from chebax._src.recipes.bessely import bessely, bessely_dnu
|
|
15
|
+
from chebax._src.recipes.betainc import betainc, betainc_fn
|
|
16
|
+
from chebax._src.recipes.quantiles import betaincinv, gammaincinv, stdtr, stdtrit
|
|
17
|
+
from chebax._src.recipes.vonmises import vonmises_cdf, vonmises_icdf
|
|
18
|
+
from chebax._src.series import ChebSeries, PiecewiseCheb
|
|
19
|
+
|
|
20
|
+
# single-sourced from pyproject.toml via package metadata
|
|
21
|
+
try:
|
|
22
|
+
from importlib.metadata import version as _pkg_version
|
|
23
|
+
__version__ = _pkg_version("chebax")
|
|
24
|
+
except Exception: # not installed (e.g. running from a source checkout)
|
|
25
|
+
__version__ = "0+unknown"
|
|
26
|
+
__all__ = ["ChebSeries", "PiecewiseCheb", "besseli", "besseli_dnu", "besseli_fn",
|
|
27
|
+
"besselj", "besselj_dnu", "besselk", "besselk_dnu", "besselk_fn",
|
|
28
|
+
"log_besselk_fn",
|
|
29
|
+
"bessely", "bessely_dnu", "betainc", "betainc_fn", "betaincinv",
|
|
30
|
+
"dawsn", "erfcx", "fit", "gammaincinv", "lambertw", "spherical_jn",
|
|
31
|
+
"spherical_yn", "stdtr", "stdtrit", "vonmises_cdf", "vonmises_icdf",
|
|
32
|
+
"__version__"]
|
|
33
|
+
|
|
34
|
+
# submodule re-export so `import chebax; chebax.bake` works as documented
|
|
35
|
+
# (bake reads __version__, so this import must stay below its definition)
|
|
36
|
+
from chebax import bake # noqa: E402,F401
|
|
37
|
+
|
|
38
|
+
__all__.append("bake")
|
chebax/_src/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The textbook algorithms, in plain numpy, on [-1, 1].
|
|
2
|
+
|
|
3
|
+
Coefficient convention is numpy.polynomial's: plain c[0], lowest order first,
|
|
4
|
+
p(t) = sum(c[k] * T_k(t)). These are the reference implementations; the jax
|
|
5
|
+
runtime in series.py must agree with them (tested against numpy.polynomial).
|
|
6
|
+
Domain handling lives in the series classes, not here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def chebpts(n):
|
|
13
|
+
"""First-kind Chebyshev points cos(pi*(2i+1)/(2n)), decreasing from ~1 to ~-1."""
|
|
14
|
+
i = np.arange(n)
|
|
15
|
+
return np.cos(np.pi * (2 * i + 1) / (2 * n))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def chebfit_dct(samples):
|
|
19
|
+
"""Interpolation coefficients from samples taken at chebpts(len(samples)).
|
|
20
|
+
|
|
21
|
+
Discrete orthogonality of cosines on the first-kind points; O(n^2), which
|
|
22
|
+
is fine at generator degrees.
|
|
23
|
+
"""
|
|
24
|
+
n = len(samples)
|
|
25
|
+
i = np.arange(n)
|
|
26
|
+
j = np.arange(n)[:, None]
|
|
27
|
+
# reduce the angle with exact integer arithmetic; the raw product reaches
|
|
28
|
+
# hundreds of radians at large n and costs ~angle*eps of cosine accuracy
|
|
29
|
+
weights = np.cos(np.pi * ((j * (2 * i + 1)) % (4 * n)) / (2 * n))
|
|
30
|
+
c = (2.0 / n) * (weights @ np.asarray(samples, dtype=float))
|
|
31
|
+
c[0] /= 2
|
|
32
|
+
return c
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def chebval(t, c):
|
|
36
|
+
"""Clenshaw evaluation of a plain Chebyshev series."""
|
|
37
|
+
t = np.asarray(t, dtype=float)
|
|
38
|
+
b1 = np.zeros_like(t)
|
|
39
|
+
b2 = np.zeros_like(t)
|
|
40
|
+
for ck in np.asarray(c, dtype=float)[:0:-1]:
|
|
41
|
+
b1, b2 = 2 * t * b1 - b2 + ck, b1
|
|
42
|
+
return t * b1 - b2 + c[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chebder(c):
|
|
46
|
+
"""Coefficients of the derivative (numpy.polynomial's exact recurrence)."""
|
|
47
|
+
c = np.array(c, dtype=float)
|
|
48
|
+
n = c.size - 1
|
|
49
|
+
if n == 0:
|
|
50
|
+
return np.zeros(1)
|
|
51
|
+
der = np.zeros(n)
|
|
52
|
+
for j in range(n, 2, -1):
|
|
53
|
+
der[j - 1] = 2 * j * c[j]
|
|
54
|
+
c[j - 2] += j * c[j] / (j - 2)
|
|
55
|
+
if n > 1:
|
|
56
|
+
der[1] = 4 * c[2]
|
|
57
|
+
der[0] = c[1]
|
|
58
|
+
return der
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def chebint(c):
|
|
62
|
+
"""Coefficients of the antiderivative that vanishes at t=0
|
|
63
|
+
(numpy.polynomial's k=0, lbnd=0 convention)."""
|
|
64
|
+
c = np.asarray(c, dtype=float)
|
|
65
|
+
n = c.size
|
|
66
|
+
out = np.zeros(n + 1)
|
|
67
|
+
out[1] = c[0]
|
|
68
|
+
if n > 1:
|
|
69
|
+
out[2] = c[1] / 4
|
|
70
|
+
for j in range(2, n):
|
|
71
|
+
out[j + 1] = c[j] / (2 * (j + 1))
|
|
72
|
+
out[j - 1] -= c[j] / (2 * (j - 1))
|
|
73
|
+
out[0] -= chebval(0.0, out)
|
|
74
|
+
return out
|
chebax/_src/generate.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Build-time fitting: sample at Chebyshev points, DCT to coefficients.
|
|
2
|
+
|
|
3
|
+
The float64 path uses numpy. Passing dps switches sampling and the DCT to
|
|
4
|
+
mpmath at that precision; mpmath is imported inside that branch only (the
|
|
5
|
+
runtime-imports rule in CLAUDE.md).
|
|
6
|
+
|
|
7
|
+
Adaptive fitting doubles the node count until the coefficient tail sits at
|
|
8
|
+
the fit's own noise floor, then truncates there. A converged-looking tail
|
|
9
|
+
on one grid is never trusted by itself: on n first-kind points T_{2n-k}
|
|
10
|
+
samples exactly as -T_k, so an aliased fit can look converged (T_34 on 17
|
|
11
|
+
nodes reproduces -T_0 to the last bit). Every candidate is therefore
|
|
12
|
+
validated against fresh samples at off-grid points before it is returned.
|
|
13
|
+
|
|
14
|
+
The noise floor is estimated from the trailing quarter of the candidate's
|
|
15
|
+
own coefficients rather than assumed: float64 sampling noise scales with
|
|
16
|
+
the function's derivative (measured: ~1e-16 relative for exp, ~2.5e-15 for
|
|
17
|
+
sin(50x)), so a fixed tolerance either fails to converge on oscillatory
|
|
18
|
+
functions or keeps hundreds of noise coefficients.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from chebax._src import algorithms
|
|
24
|
+
from chebax._src.series import ChebSeries, PiecewiseCheb
|
|
25
|
+
|
|
26
|
+
# ~2*eps: the practical floor of the float64 build path. Use dps= to go below.
|
|
27
|
+
_DEFAULT_TOL = 5e-16
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fit(f, domain=(-1.0, 1.0), *, deg=None, tol=None, breaks=None, dps=None, max_deg=256):
|
|
31
|
+
"""Chebyshev interpolant of f, as a ChebSeries (or PiecewiseCheb if breaks given).
|
|
32
|
+
|
|
33
|
+
f: callable on the domain. Vectorized over numpy arrays if possible;
|
|
34
|
+
with dps set it must accept mpmath floats instead.
|
|
35
|
+
domain: (a, b). Ignored when breaks is given.
|
|
36
|
+
deg: fixed degree; otherwise the degree adapts until tol is met.
|
|
37
|
+
tol: target for the relative coefficient tail (default 5e-16). The
|
|
38
|
+
effective chop level is max(tol, the fit's measured noise floor);
|
|
39
|
+
float64 sampling noise can sit above tol for functions with
|
|
40
|
+
large derivatives. Build with dps to control the floor.
|
|
41
|
+
breaks: strictly increasing knots, endpoints included; one series per
|
|
42
|
+
segment, each fit independently, padded to a common degree.
|
|
43
|
+
dps: mpmath working precision for sampling and the DCT. Derivatives of
|
|
44
|
+
a float64-built fit sit at a ~deg^2 * eps floor (differentiation
|
|
45
|
+
amplifies sample noise); build with dps when that matters.
|
|
46
|
+
max_deg: hard cap on the returned degree. Reaching it without a validated
|
|
47
|
+
converged fit raises: f is not smooth enough on the interval
|
|
48
|
+
(split the domain) or genuinely needs a higher degree.
|
|
49
|
+
"""
|
|
50
|
+
if tol is None:
|
|
51
|
+
tol = _DEFAULT_TOL
|
|
52
|
+
if breaks is not None:
|
|
53
|
+
br = [float(t) for t in breaks]
|
|
54
|
+
cs = [_fit_interval(f, br[i], br[i + 1], deg, tol, max_deg, dps)
|
|
55
|
+
for i in range(len(br) - 1)]
|
|
56
|
+
n = max(c.size for c in cs)
|
|
57
|
+
coef = np.zeros((len(cs), n))
|
|
58
|
+
for i, c in enumerate(cs):
|
|
59
|
+
coef[i, : c.size] = c
|
|
60
|
+
return PiecewiseCheb(coef, br)
|
|
61
|
+
a, b = float(domain[0]), float(domain[1])
|
|
62
|
+
return ChebSeries(_fit_interval(f, a, b, deg, tol, max_deg, dps), (a, b))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fit_interval(f, a, b, deg, tol, max_deg, dps):
|
|
66
|
+
dct, sample_unit = (_dct_mp(f, a, b, dps) if dps is not None
|
|
67
|
+
else _dct_np(f, a, b))
|
|
68
|
+
if deg is not None:
|
|
69
|
+
return dct(deg + 1)
|
|
70
|
+
return _adaptive(dct, sample_unit, tol, max_deg, (a, b))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _chop(c, tol):
|
|
74
|
+
"""Truncate at the noise level; returns (candidate, absolute level).
|
|
75
|
+
|
|
76
|
+
The level is max(tol, noise floor), where the noise floor is read off
|
|
77
|
+
the last eighth of the coefficients: for a still-decaying tail those
|
|
78
|
+
sit below tol and the requested tol governs (measured: exp's last
|
|
79
|
+
coefficients decay through 1e-16); for a converged fit they are the
|
|
80
|
+
sampling-noise plateau (sin(50x): ~5e-16 relative at n=129, edge-
|
|
81
|
+
singular sampling can push higher) and doubling the largest of them
|
|
82
|
+
clears the plateau's own scatter."""
|
|
83
|
+
scale = np.max(np.abs(c))
|
|
84
|
+
noise = 2.0 * float(np.max(np.abs(c[-max(2, c.size // 16):])))
|
|
85
|
+
level = max(tol * scale, noise)
|
|
86
|
+
keep = np.nonzero(np.abs(c) > level)[0]
|
|
87
|
+
return (c[: keep[-1] + 1] if keep.size else c[:1]), level
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validated(cand, level, sample_unit):
|
|
91
|
+
"""Check the candidate against fresh samples at off-grid points.
|
|
92
|
+
|
|
93
|
+
chebpts(m) for m != n shares no points with the fitting grid, so an
|
|
94
|
+
aliased fit cannot pass: its off-grid error is O(scale), ~1e15x the
|
|
95
|
+
chop level, while an honest fit's is ~1x. The 1000x margin makes this
|
|
96
|
+
a gross-error detector, not the accuracy contract.
|
|
97
|
+
"""
|
|
98
|
+
m = cand.size + 11
|
|
99
|
+
t = algorithms.chebpts(m)
|
|
100
|
+
y = sample_unit(t)
|
|
101
|
+
err = np.max(np.abs(algorithms.chebval(t, cand) - y))
|
|
102
|
+
allow = max(1000.0 * level,
|
|
103
|
+
100.0 * np.finfo(float).eps * float(np.sum(np.abs(cand))))
|
|
104
|
+
return err <= allow
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _adaptive(dct, sample_unit, tol, max_deg, interval):
|
|
108
|
+
n = min(17, max_deg + 1)
|
|
109
|
+
prev_rel = np.inf
|
|
110
|
+
while True:
|
|
111
|
+
c = dct(n)
|
|
112
|
+
scale = float(np.max(np.abs(c)))
|
|
113
|
+
if scale == 0.0:
|
|
114
|
+
return np.zeros(1)
|
|
115
|
+
cand, level = _chop(c, tol)
|
|
116
|
+
rel = level / scale
|
|
117
|
+
# The tail has settled if the chop level reached the requested tol,
|
|
118
|
+
# or if it stopped improving under node doubling while being far
|
|
119
|
+
# below the function scale: that is the sampling-noise plateau, and
|
|
120
|
+
# more nodes cannot beat it. A small-but-still-shrinking tail (a
|
|
121
|
+
# segment one doubling short of converged) must escalate, not chop.
|
|
122
|
+
settled = level <= 2.0 * tol * scale or (rel <= 1e-10 and rel >= prev_rel / 4.0)
|
|
123
|
+
if settled and cand.size <= c.size - max(2, c.size // 8):
|
|
124
|
+
if _validated(cand, level, sample_unit):
|
|
125
|
+
return cand
|
|
126
|
+
prev_rel = rel
|
|
127
|
+
if n >= max_deg + 1:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"no validated convergence to tol={tol:g} by degree {max_deg} on "
|
|
130
|
+
f"{interval}; f may not be smooth there (split the domain), may "
|
|
131
|
+
"need a higher max_deg, or float64 sampling noise may sit above "
|
|
132
|
+
"tol (build with dps)")
|
|
133
|
+
n = min(2 * n - 1, max_deg + 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _dct_np(f, a, b):
|
|
137
|
+
def sample_unit(t):
|
|
138
|
+
xk = a + (b - a) * (t + 1) / 2
|
|
139
|
+
return _sample(f, xk)
|
|
140
|
+
|
|
141
|
+
def dct(n):
|
|
142
|
+
return algorithms.chebfit_dct(sample_unit(algorithms.chebpts(n)))
|
|
143
|
+
return dct, sample_unit
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _sample(f, xk):
|
|
147
|
+
# scalar-only callables (wrapped special functions) are common; fall back.
|
|
148
|
+
try:
|
|
149
|
+
y = np.asarray(f(xk), dtype=float)
|
|
150
|
+
if y.shape == xk.shape:
|
|
151
|
+
return y
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
pass
|
|
154
|
+
return np.array([float(f(x)) for x in xk])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _dct_mp(f, a, b, dps):
|
|
158
|
+
import mpmath as mp
|
|
159
|
+
|
|
160
|
+
def sample_unit(t):
|
|
161
|
+
with mp.workdps(dps):
|
|
162
|
+
mid = (mp.mpf(b) + mp.mpf(a)) / 2
|
|
163
|
+
half = (mp.mpf(b) - mp.mpf(a)) / 2
|
|
164
|
+
return np.array([float(f(mid + half * mp.mpf(float(tk)))) for tk in t])
|
|
165
|
+
|
|
166
|
+
def dct(n):
|
|
167
|
+
with mp.workdps(dps):
|
|
168
|
+
mid = (mp.mpf(b) + mp.mpf(a)) / 2
|
|
169
|
+
half = (mp.mpf(b) - mp.mpf(a)) / 2
|
|
170
|
+
fk = [f(mid + half * mp.cos(mp.pi * (2 * i + 1) / (2 * n))) for i in range(n)]
|
|
171
|
+
c = []
|
|
172
|
+
for j in range(n):
|
|
173
|
+
s = mp.fsum(fk[i] * mp.cos(mp.pi * j * (2 * i + 1) / (2 * n)) for i in range(n))
|
|
174
|
+
c.append(2 * s / n)
|
|
175
|
+
c[0] /= 2
|
|
176
|
+
return np.array([float(x) for x in c])
|
|
177
|
+
return dct, sample_unit
|
chebax/_src/pytree.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Base class for recipe callables, retiring per-class pytree boilerplate.
|
|
2
|
+
|
|
3
|
+
A recipe instance is a pytree whose children are ChebSeries (listed in
|
|
4
|
+
_series_fields) and whose auxiliary data are static scalars (listed in
|
|
5
|
+
_static_fields). Constructors take statics first, series after, in field
|
|
6
|
+
order; _post_init derives cached constants (called by both __init__ and
|
|
7
|
+
tree_unflatten). astype casts every series; __repr__ shows the statics and
|
|
8
|
+
the coefficient dtype.
|
|
9
|
+
|
|
10
|
+
Subclasses register themselves with the standard
|
|
11
|
+
@jax.tree_util.register_pytree_node_class decorator at the class site.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
class Recipe:
|
|
15
|
+
_static_fields = ()
|
|
16
|
+
_series_fields = ()
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args):
|
|
19
|
+
names = self._static_fields + self._series_fields
|
|
20
|
+
if len(args) != len(names):
|
|
21
|
+
raise TypeError(f"{type(self).__name__} expects {names}, got {len(args)} args")
|
|
22
|
+
for name, val in zip(names, args):
|
|
23
|
+
setattr(self, name, val)
|
|
24
|
+
self._post_init()
|
|
25
|
+
|
|
26
|
+
def _post_init(self):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def astype(self, dtype):
|
|
30
|
+
return type(self)(*[getattr(self, f) for f in self._static_fields],
|
|
31
|
+
*[getattr(self, f).astype(dtype) for f in self._series_fields])
|
|
32
|
+
|
|
33
|
+
def __repr__(self):
|
|
34
|
+
statics = ", ".join(f"{f}={getattr(self, f)!r}" for f in self._static_fields)
|
|
35
|
+
dtype = "-"
|
|
36
|
+
# first series child with a coefficient array; children may be
|
|
37
|
+
# nested recipes (spherical wraps a cylindrical instance)
|
|
38
|
+
node = self
|
|
39
|
+
while getattr(node, "_series_fields", ()):
|
|
40
|
+
node = getattr(node, node._series_fields[0])
|
|
41
|
+
coef = getattr(node, "coef", None)
|
|
42
|
+
if coef is not None:
|
|
43
|
+
dtype = coef.dtype
|
|
44
|
+
break
|
|
45
|
+
return f"{type(self).__name__}({statics}, dtype={dtype})"
|
|
46
|
+
|
|
47
|
+
def tree_flatten(self):
|
|
48
|
+
return (tuple(getattr(self, f) for f in self._series_fields),
|
|
49
|
+
tuple(getattr(self, f) for f in self._static_fields))
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def tree_unflatten(cls, aux, children):
|
|
53
|
+
obj = object.__new__(cls)
|
|
54
|
+
for name, val in zip(cls._static_fields, aux):
|
|
55
|
+
setattr(obj, name, val)
|
|
56
|
+
for name, val in zip(cls._series_fields, children):
|
|
57
|
+
setattr(obj, name, val)
|
|
58
|
+
obj._post_init()
|
|
59
|
+
return obj
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Shared runtime machinery for recipes: parameter-table reconstruction,
|
|
2
|
+
domain checks, the safeguarded root solver, and a float64 digamma.
|
|
3
|
+
|
|
4
|
+
Reconstruction convention (matches _gen_common's emitted axis note): for a
|
|
5
|
+
2-D table, TABLE[k, j] is the j-th parameter-direction coefficient of the
|
|
6
|
+
k-th argument-direction coefficient, the parameter mapped affinely from
|
|
7
|
+
[lo, hi] onto [-1, 1]. `param_coefs` is the eager numpy path used by the
|
|
8
|
+
cached factories; `traced_coefs` is the jax path used by the *_fn traced
|
|
9
|
+
variants (differentiable in the parameter through the series custom_jvp).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from chebax._src import algorithms
|
|
19
|
+
from chebax._src.series import _chebval
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def canon_tag():
|
|
23
|
+
"""Cache-key tag for the canonical float dtype: factory instances built
|
|
24
|
+
under different x64 settings must not share a cache slot."""
|
|
25
|
+
return str(jnp.empty(()).dtype)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def canon_float(x):
|
|
29
|
+
"""Promote to the canonical float dtype (float64 under x64).
|
|
30
|
+
|
|
31
|
+
Solvers mix inputs with reconstructed float64 tables inside a fori_loop
|
|
32
|
+
carry; an explicit float32 input under x64 raised a carry dtype
|
|
33
|
+
mismatch instead of computing. One deliberate dtype for everything."""
|
|
34
|
+
return jnp.asarray(x, dtype=jnp.empty(()).dtype)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def param_coefs(table, lo, hi, p):
|
|
38
|
+
"""Coefficient vector at parameter p: Clenshaw across table rows. numpy."""
|
|
39
|
+
t = 2.0 * (p - lo) / (hi - lo) - 1.0
|
|
40
|
+
return np.array([algorithms.chebval(t, row) for row in table])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def param_coefs_der(table, lo, hi, p):
|
|
44
|
+
"""d/dp of the coefficient vector, via chebder along the parameter axis."""
|
|
45
|
+
t = 2.0 * (p - lo) / (hi - lo) - 1.0
|
|
46
|
+
d = np.array([algorithms.chebval(t, algorithms.chebder(row)) for row in table])
|
|
47
|
+
return d * (2.0 / (hi - lo))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def traced_coefs(table, lo, hi, p):
|
|
51
|
+
"""jax version of param_coefs: p may be a tracer (uniform per call)."""
|
|
52
|
+
t = 2.0 * (p - lo) / (hi - lo) - 1.0
|
|
53
|
+
return jax.vmap(lambda row: _chebval(t, row, (-1.0, 1.0)))(jnp.asarray(table))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_range(owner, name, v, lo, hi):
|
|
57
|
+
v = float(v)
|
|
58
|
+
if not lo <= v <= hi:
|
|
59
|
+
raise ValueError(f"{owner} table covers {name} in [{lo}, {hi}], got {v}")
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def newton_bisect(f_and_df, x0, lo0, hi0, iters):
|
|
64
|
+
"""Fixed-count safeguarded Newton for increasing f: keep [lo, hi]
|
|
65
|
+
bracketing the root, fall back to the midpoint (or doubling while hi is
|
|
66
|
+
inf) when the Newton step leaves the bracket. The bounds test is
|
|
67
|
+
INCLUSIVE: a Newton step that stalls exactly at the current iterate
|
|
68
|
+
(converged) must be accepted, or a one-sided approach whose far bound
|
|
69
|
+
never tightened catapults a converged iterate to the midpoint of the
|
|
70
|
+
half-open bracket (measured, the quantile-toolkit bug)."""
|
|
71
|
+
|
|
72
|
+
def step(_, carry):
|
|
73
|
+
x, lo, hi = carry
|
|
74
|
+
f, df = f_and_df(x)
|
|
75
|
+
lo = jnp.where(f < 0, x, lo)
|
|
76
|
+
hi = jnp.where(f < 0, hi, x)
|
|
77
|
+
xn = x - f / df
|
|
78
|
+
mid = jnp.where(jnp.isfinite(hi), 0.5 * (lo + hi), 2.0 * x + 1.0)
|
|
79
|
+
ok = (xn >= lo) & (xn <= hi) & jnp.isfinite(xn)
|
|
80
|
+
return jnp.where(ok, xn, mid), lo, hi
|
|
81
|
+
|
|
82
|
+
x, _, _ = jax.lax.fori_loop(0, iters, step, (x0, lo0, hi0))
|
|
83
|
+
return x
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def digamma64(z):
|
|
87
|
+
"""float64 psi(z) for z >= 1: recurrence up to z >= 15, then the
|
|
88
|
+
Bernoulli series through z^-12 (A&S 6.3.5 / 6.3.18). Truncation < 1e-16."""
|
|
89
|
+
acc = 0.0
|
|
90
|
+
while z < 15.0:
|
|
91
|
+
acc -= 1.0 / z
|
|
92
|
+
z += 1.0
|
|
93
|
+
zi = 1.0 / (z * z)
|
|
94
|
+
tail = 1 / 12 - zi * (1 / 120 - zi * (1 / 252 - zi * (1 / 240 - zi * (1 / 132 - zi * (691 / 32760)))))
|
|
95
|
+
return acc + math.log(z) - 0.5 / z - zi * tail
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Shared build-time machinery for all recipe generators.
|
|
2
|
+
|
|
3
|
+
Every generator uses the same two-stage (or three-stage) construction:
|
|
4
|
+
Chebyshev-Gauss nodes, a plain DCT to coefficients, a parameter-direction
|
|
5
|
+
fit per coefficient, and a writer that emits the baked table as a diffable
|
|
6
|
+
python module. All fit arithmetic stays in mpmath (the M1 floor rule:
|
|
7
|
+
rounding between stages puts eps-level sample noise under later fits).
|
|
8
|
+
|
|
9
|
+
Table axis convention, written into every emitted header: for a 2-D table,
|
|
10
|
+
TABLE[k, j] is the j-th parameter-direction coefficient of the k-th
|
|
11
|
+
argument-direction coefficient; parameters map affinely onto [-1, 1] from
|
|
12
|
+
the domain recorded in META. Emission is deterministic (repr of python
|
|
13
|
+
floats round-trips; no timestamps), so identical inputs give identical
|
|
14
|
+
bytes regardless of numpy version.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import importlib
|
|
19
|
+
import pathlib
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
DPS = 40 # mpmath working precision for every generator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def table_path(out_dir, generator_file, name):
|
|
27
|
+
"""Output location for an emitted table: the package directory by
|
|
28
|
+
default, or out_dir (the regeneration tests emit to a temp dir and
|
|
29
|
+
compare full files against the packaged ones)."""
|
|
30
|
+
base = pathlib.Path(out_dir) if out_dir is not None else pathlib.Path(generator_file).parent
|
|
31
|
+
return base / name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _generator_sha(generator_module):
|
|
35
|
+
"""sha256 over the generator's own source plus this shared module: the
|
|
36
|
+
emitted file records exactly the code that built it, so any edit to
|
|
37
|
+
either shows up as a hash mismatch until the table is regenerated."""
|
|
38
|
+
h = hashlib.sha256()
|
|
39
|
+
for mod_name in (generator_module, __name__):
|
|
40
|
+
m = importlib.import_module(mod_name)
|
|
41
|
+
h.update(pathlib.Path(m.__file__).read_bytes())
|
|
42
|
+
return h.hexdigest()[:16]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def nodes(mp, n):
|
|
46
|
+
"""First-kind Chebyshev points, the sampling nodes of every fit."""
|
|
47
|
+
return [mp.cos(mp.pi * (2 * i + 1) / (2 * n)) for i in range(n)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def dct(mp, samples):
|
|
51
|
+
"""Plain Chebyshev coefficients from samples at nodes(mp, len(samples))."""
|
|
52
|
+
n = len(samples)
|
|
53
|
+
c = []
|
|
54
|
+
for j in range(n):
|
|
55
|
+
s = mp.fsum(samples[i] * mp.cos(mp.pi * j * (2 * i + 1) / (2 * n)) for i in range(n))
|
|
56
|
+
c.append(2 * s / n)
|
|
57
|
+
c[0] /= 2
|
|
58
|
+
return c
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def param_fit(mp, rows):
|
|
62
|
+
"""Second stage: fit each coefficient across the parameter nodes, in mp."""
|
|
63
|
+
nc = len(rows[0])
|
|
64
|
+
return [dct(mp, [rows[i][k] for i in range(len(rows))]) for k in range(nc)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_f64(rows):
|
|
68
|
+
return np.array([[float(x) for x in row] for row in rows])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fmt_array(name, arr):
|
|
72
|
+
arr = np.asarray(arr)
|
|
73
|
+
lines = []
|
|
74
|
+
if arr.ndim == 1:
|
|
75
|
+
lines.append(f"{name} = np.array([")
|
|
76
|
+
for i in range(0, arr.size, 4):
|
|
77
|
+
lines.append(" " + ", ".join(repr(float(v)) for v in arr[i:i + 4]) + ",")
|
|
78
|
+
lines.append("])")
|
|
79
|
+
elif arr.ndim == 2:
|
|
80
|
+
lines.append(f"{name} = np.array([")
|
|
81
|
+
for row in arr:
|
|
82
|
+
lines.append(" [" + ", ".join(repr(float(v)) for v in row) + "],")
|
|
83
|
+
lines.append("])")
|
|
84
|
+
elif arr.ndim == 3:
|
|
85
|
+
lines.append(f"{name} = np.array([")
|
|
86
|
+
for block in arr:
|
|
87
|
+
lines.append(" [")
|
|
88
|
+
for row in block:
|
|
89
|
+
lines.append(" [" + ", ".join(repr(float(v)) for v in row) + "],")
|
|
90
|
+
lines.append(" ],")
|
|
91
|
+
lines.append("])")
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError(f"unsupported table rank {arr.ndim}")
|
|
94
|
+
return lines
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_table_module(path, generator_module, axis_note, meta, scalars, arrays):
|
|
98
|
+
"""Emit a baked table as a python module. Deterministic bytes."""
|
|
99
|
+
import mpmath
|
|
100
|
+
|
|
101
|
+
meta = dict(meta, dps=DPS, mpmath=mpmath.__version__,
|
|
102
|
+
generator_sha256=_generator_sha(generator_module))
|
|
103
|
+
lines = [
|
|
104
|
+
f'"""Baked table. Generated by {generator_module}; do not edit.',
|
|
105
|
+
f"Regenerate with: python -m {generator_module}",
|
|
106
|
+
"",
|
|
107
|
+
f"Axes: {axis_note}",
|
|
108
|
+
'"""',
|
|
109
|
+
"",
|
|
110
|
+
"import numpy as np",
|
|
111
|
+
"",
|
|
112
|
+
f"META = {meta!r}",
|
|
113
|
+
]
|
|
114
|
+
for k, v in scalars.items():
|
|
115
|
+
lines.append(f"{k} = {v!r}")
|
|
116
|
+
for name, arr in arrays.items():
|
|
117
|
+
lines.append("")
|
|
118
|
+
lines.extend(_fmt_array(name, arr))
|
|
119
|
+
path.write_text("\n".join(lines) + "\n")
|
|
120
|
+
shapes = ", ".join(f"{k} {np.asarray(v).shape}" for k, v in arrays.items())
|
|
121
|
+
print(f"wrote {path.name}: {shapes}")
|