pycodemath 0.2.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.
- pycodemath/__init__.py +91 -0
- pycodemath/__main__.py +10 -0
- pycodemath/bench/__init__.py +0 -0
- pycodemath/bench/benchmark.py +61 -0
- pycodemath/cli/__init__.py +0 -0
- pycodemath/cli/mcp_server.py +82 -0
- pycodemath/cli/repl.py +620 -0
- pycodemath/codegen/__init__.py +0 -0
- pycodemath/codegen/emit.py +554 -0
- pycodemath/codegen/pipeline.py +139 -0
- pycodemath/core/__init__.py +0 -0
- pycodemath/core/errors.py +15 -0
- pycodemath/core/ir.py +307 -0
- pycodemath/engine/__init__.py +0 -0
- pycodemath/engine/linalg.py +197 -0
- pycodemath/engine/numerics.py +973 -0
- pycodemath/engine/ode.py +1279 -0
- pycodemath/engine/symbolic.py +204 -0
- pycodemath/frontend/__init__.py +0 -0
- pycodemath/frontend/parser.py +299 -0
- pycodemath-0.2.0.dist-info/METADATA +327 -0
- pycodemath-0.2.0.dist-info/RECORD +26 -0
- pycodemath-0.2.0.dist-info/WHEEL +5 -0
- pycodemath-0.2.0.dist-info/entry_points.txt +3 -0
- pycodemath-0.2.0.dist-info/licenses/LICENSE +21 -0
- pycodemath-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
"""Numerical engine (module 4) — where symbolic methods fall short.
|
|
2
|
+
|
|
3
|
+
Operates on IR (``Expr``). When an expression has no closed-form root,
|
|
4
|
+
integral or minimum — or when we simply want a number — we drop down to
|
|
5
|
+
numerical methods. Derivatives are computed SYMBOLICALLY (``Expr.diff``), and
|
|
6
|
+
we iterate through COMPILED functions ``Expr.compiled`` (lambdify): compile
|
|
7
|
+
once before the loop, iterate without SymPy overhead. ``Expr.evalf`` remains the
|
|
8
|
+
contract for a single evaluation in IR — here, inside loops, it would be orders
|
|
9
|
+
of magnitude too slow.
|
|
10
|
+
|
|
11
|
+
1D methods (module 4):
|
|
12
|
+
root_find — root f(x)=0: Newton with a fallback to bisection.
|
|
13
|
+
integrate_num — definite integral: composite Simpson quadrature on samples.
|
|
14
|
+
minimize — 1D minimum: gradient descent by default; method="newton"
|
|
15
|
+
or "bfgs" (module 15) gives 2nd-order methods with a
|
|
16
|
+
backtracking Armijo line search.
|
|
17
|
+
|
|
18
|
+
Multidimensional methods (module 5, 2nd-order methods — module 15):
|
|
19
|
+
gradient — vector of partial derivatives (symbolically, IR Matrix).
|
|
20
|
+
jacobian — matrix of derivatives of a system of functions (IR Matrix).
|
|
21
|
+
hessian — matrix of second derivatives (IR Matrix).
|
|
22
|
+
root_find_nd — system of nonlinear equations: multidimensional Newton (J·Δ = -F).
|
|
23
|
+
minimize_nd — minimum of a function of several variables: gradient descent
|
|
24
|
+
by default; method="newton" (Hessian symbolically,
|
|
25
|
+
H·d = -∇f) or "bfgs" (approximation of the inverse Hessian)
|
|
26
|
+
— both with a backtracking Armijo line search instead of a fixed lr.
|
|
27
|
+
|
|
28
|
+
Divergence / bad domain / singular Jacobian -> ``PycodemathError``
|
|
29
|
+
with a readable message (consistent with ``evalf`` and the parser).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import math
|
|
35
|
+
from typing import Callable, Sequence
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
from ..core.errors import PycodemathError
|
|
40
|
+
from ..core.ir import Expr, Matrix
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- shared sampling layer ---------------------------------------------
|
|
44
|
+
#: Exceptions from a compiled function meaning "outside the domain" (math
|
|
45
|
+
#: raises ValueError; division by zero and overflow — their own types).
|
|
46
|
+
_DOMAIN_ERRORS = (ValueError, TypeError, ZeroDivisionError, OverflowError)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _scalar(expr: Expr, var: str) -> Callable[[float], float]:
|
|
50
|
+
"""Return a COMPILED function ``x -> float`` (``Expr.compiled``).
|
|
51
|
+
|
|
52
|
+
Validates that the expression is a function of the single variable ``var`` —
|
|
53
|
+
otherwise the compiled form would leave unsubstituted symbols — here we give
|
|
54
|
+
a readable message right away.
|
|
55
|
+
"""
|
|
56
|
+
extra = [s for s in expr.symbol_names() if s != var]
|
|
57
|
+
if extra:
|
|
58
|
+
raise PycodemathError(
|
|
59
|
+
f"a numerical method requires a function of the single variable {var!r}, "
|
|
60
|
+
f"but the expression also depends on {', '.join(extra)}"
|
|
61
|
+
)
|
|
62
|
+
return expr.compiled([var])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe(f: Callable[[float], float], x: float) -> "float | None":
|
|
66
|
+
"""Sample ``f(x)``; return ``None`` when the result is outside the domain.
|
|
67
|
+
|
|
68
|
+
We catch the compiled form's domain exceptions (e.g. ``math.sqrt(-1)`` ->
|
|
69
|
+
ValueError), complex results and infinite/NaN values — iterations treat
|
|
70
|
+
``None`` as a "not possible here" signal, instead of tearing down the whole
|
|
71
|
+
method.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
y = f(x)
|
|
75
|
+
except _DOMAIN_ERRORS:
|
|
76
|
+
return None
|
|
77
|
+
if isinstance(y, complex):
|
|
78
|
+
return None
|
|
79
|
+
y = float(y)
|
|
80
|
+
return y if math.isfinite(y) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _sample(f: Callable[[float], float], x: float) -> float:
|
|
84
|
+
"""Like ``_safe``, but outside the domain it raises a readable ``PycodemathError``.
|
|
85
|
+
|
|
86
|
+
Used where a missing value means an input error (e.g. quadrature over a
|
|
87
|
+
point outside the domain), not an opportunity to change strategy.
|
|
88
|
+
"""
|
|
89
|
+
y = _safe(f, x)
|
|
90
|
+
if y is None:
|
|
91
|
+
raise PycodemathError(
|
|
92
|
+
f"cannot sample the function at x={x:g} "
|
|
93
|
+
f"(outside the domain or complex result)"
|
|
94
|
+
)
|
|
95
|
+
return y
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --- shared DIVERGENCE threshold (R4–R7, unified in R8) -----------------
|
|
99
|
+
#: Scale of the divergence threshold. Every iterative solver (``root_find``,
|
|
100
|
+
#: ``root_find_nd``, ``_descent_min`` and ``minimize``/``minimize_nd`` in the
|
|
101
|
+
#: gradient-descent variant) treats an iterate as an escape to infinity when its
|
|
102
|
+
#: norm exceeds ``_DIVERGE_SCALE·max(1, ‖x0‖)``. Rounds R4–R7 introduced the same
|
|
103
|
+
#: number FIVE separate times — R8 lifts it into a single source, so the family
|
|
104
|
+
#: of detections cannot quietly drift apart under future tuning (one solver with
|
|
105
|
+
#: a different threshold = a regression trap).
|
|
106
|
+
_DIVERGE_SCALE: float = 1e12
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _diverge_limit(x0_norm: float) -> float:
|
|
110
|
+
"""Norm threshold above which we treat an iterate as DIVERGING.
|
|
111
|
+
|
|
112
|
+
``x0_norm`` is the norm of the starting point: ``abs(x0)`` in 1D, ``‖x0‖`` in
|
|
113
|
+
many dimensions. Returns ``_DIVERGE_SCALE·max(1.0, x0_norm)`` — generous (a
|
|
114
|
+
convergent run to a distant but finite solution has time to return a result
|
|
115
|
+
before touching it). Bit-identical to the former ``1e12·max(1.0, x0_norm)``
|
|
116
|
+
scattered across the solvers: R8 only centralizes the formula, it does not
|
|
117
|
+
change a single number.
|
|
118
|
+
"""
|
|
119
|
+
return _DIVERGE_SCALE * max(1.0, x0_norm)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _diverge_limit_for(x0: "float | np.ndarray") -> float:
|
|
123
|
+
"""Divergence threshold matched to the DIMENSIONALITY of the starting point ``x0`` (R10).
|
|
124
|
+
|
|
125
|
+
Closes the centralization from R8/R9. R8 lifted the threshold SCALE
|
|
126
|
+
(``_diverge_limit``), R9 lifted the PREDICATE (``_has_diverged``) — but each of
|
|
127
|
+
the five solvers still computed the start NORM itself, manually matched to the
|
|
128
|
+
dimension: ``abs(float(x0))`` in 1D vs ``float(np.linalg.norm(x))`` in nd. This
|
|
129
|
+
choice ("which norm formula fits this dimensionality") was open-coded five
|
|
130
|
+
times — a risk that someone writes the vector norm for a scalar solver (or vice
|
|
131
|
+
versa) under future tuning. R10 lifts it HERE: scalar (``ndim == 0``) →
|
|
132
|
+
``abs``; vector → ``‖x0‖``. Bit-identical to the former five calls — the same
|
|
133
|
+
norm, the same ``_diverge_limit``, not a single new number."""
|
|
134
|
+
arr = np.asarray(x0, dtype=float)
|
|
135
|
+
if arr.ndim == 0:
|
|
136
|
+
return _diverge_limit(abs(float(arr)))
|
|
137
|
+
return _diverge_limit(float(np.linalg.norm(arr)))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _has_diverged(x: "float | np.ndarray", diverge_limit: float) -> bool:
|
|
141
|
+
"""Is the iterate ``x`` DIVERGING — one predicate for ALL solvers (R9).
|
|
142
|
+
|
|
143
|
+
``x`` may be a scalar (1D: ``root_find``, ``_descent_min``) or a vector/ndarray
|
|
144
|
+
(nd: ``root_find_nd``, ``minimize_nd``). Returns ``True`` when the iterate has
|
|
145
|
+
escaped to infinity/NaN OR when its norm has exceeded ``diverge_limit`` (see
|
|
146
|
+
``_diverge_limit``).
|
|
147
|
+
|
|
148
|
+
Closes the centralization from R8. R8 unified the THRESHOLD (a single source
|
|
149
|
+
``_diverge_limit``), but the divergence PREDICATE itself — ``not isfinite(...)
|
|
150
|
+
or norm > limit`` — still lived open-coded in five solvers with minor
|
|
151
|
+
differences (1D: ``abs`` + ``math.isfinite``; nd: ``np.all(np.isfinite)`` +
|
|
152
|
+
``np.linalg.norm``). R9 lifts it HERE, so the detection family cannot quietly
|
|
153
|
+
drift apart in the details under future tuning. Bit-identical to each of the
|
|
154
|
+
former versions: the same conditions, the same ``or``, the same norm."""
|
|
155
|
+
arr = np.asarray(x, dtype=float)
|
|
156
|
+
if arr.ndim == 0:
|
|
157
|
+
# scalar — former 1D: ``not math.isfinite(x) or abs(x) > limit``
|
|
158
|
+
v = float(arr)
|
|
159
|
+
return not math.isfinite(v) or abs(v) > diverge_limit
|
|
160
|
+
# vector — former nd: ``not np.all(np.isfinite(x)) or norm(x) > limit``
|
|
161
|
+
if not bool(np.all(np.isfinite(arr))):
|
|
162
|
+
return True
|
|
163
|
+
return float(np.linalg.norm(arr)) > diverge_limit
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --- shared STAGNATION threshold (R11) ---------------------------------
|
|
167
|
+
#: The opposite side of DIVERGENCE. Sometimes a solver neither converges nor
|
|
168
|
+
#: explodes (``_has_diverged`` stays silent) — it simply STALLS: successive
|
|
169
|
+
#: iterations move neither the iterate nor the function value, and the residual
|
|
170
|
+
#: stays above ``tol``. Formerly such a run ground through ``max_iter`` and ended
|
|
171
|
+
#: with the GENERIC "does not converge (unbounded below?)" — misleading, because
|
|
172
|
+
#: the function may well be bounded (e.g. ``x**4`` with too large an ``lr`` falls
|
|
173
|
+
#: into a period-2 cycle between ±x0, with ``f`` stuck at a constant). R11 gives
|
|
174
|
+
#: this a SEPARATE, apt STALL message.
|
|
175
|
+
#:
|
|
176
|
+
#: Design note: a "vanished step" alone (‖x_next−x‖ ≈ 0) is NOT a safe signal in
|
|
177
|
+
#: gradient descent — convergence to a DISTANT minimum (‖x*‖≫1) inherently takes a
|
|
178
|
+
#: small step relative to ‖x‖, yet still makes real progress. That is why the gd
|
|
179
|
+
#: solvers detect stagnation by the LACK OF RELATIVE PROGRESS in the function value
|
|
180
|
+
#: (see ``minimize``/``minimize_nd``), while the ``_has_stagnated`` below — the
|
|
181
|
+
#: "iterate stopped moving" predicate — is a shared primitive, ready for a solver
|
|
182
|
+
#: for which the vanishing step IS decisive (the twin of ``_has_diverged`` on the
|
|
183
|
+
#: other side of the scale).
|
|
184
|
+
_STAG_SCALE: float = 4.0
|
|
185
|
+
#: How many CONSECUTIVE iterations without progress (with residual > tol) we treat
|
|
186
|
+
#: as a stall — a single step without improvement (e.g. a momentary overshoot of a
|
|
187
|
+
#: convergent run) must not overturn the result, only a persistent streak.
|
|
188
|
+
_STAG_PATIENCE: int = 8
|
|
189
|
+
#: float64 machine epsilon — the scale of "rounding noise" below which a
|
|
190
|
+
#: difference carries no information about progress.
|
|
191
|
+
_EPS: float = float(np.finfo(np.float64).eps)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _has_stagnated(
|
|
195
|
+
x_next: "float | np.ndarray",
|
|
196
|
+
x: "float | np.ndarray",
|
|
197
|
+
scale: float = _STAG_SCALE,
|
|
198
|
+
) -> bool:
|
|
199
|
+
"""Did the step ``x → x_next`` VANISH at machine precision — the STAGNATION
|
|
200
|
+
primitive, the twin of ``_has_diverged`` on the other side of the scale (R11).
|
|
201
|
+
|
|
202
|
+
Returns ``True`` when ``‖x_next − x‖ ≤ scale · eps · max(1, ‖x‖)``: the step
|
|
203
|
+
length has dropped to the level of rounding noise, so the iterate practically
|
|
204
|
+
stands still. Scalar (1D) and vector (nd) — like ``_has_diverged`` (the norm
|
|
205
|
+
via ``np.linalg.norm`` works for both). The ``max(1, ‖x‖)`` term gives a safe
|
|
206
|
+
RELATIVE threshold (without it, it would collapse to zero near zero),
|
|
207
|
+
consistent with the divergence-threshold convention.
|
|
208
|
+
|
|
209
|
+
This predicate alone does NOT determine an error and — deliberately — is NOT
|
|
210
|
+
wired into the gd solvers: convergence to a distant minimum can be
|
|
211
|
+
"step-stagnant" while still making progress (see the note at ``_STAG_SCALE``).
|
|
212
|
+
It is a primitive available to a solver for which the vanishing step is
|
|
213
|
+
decisive."""
|
|
214
|
+
a = np.asarray(x_next, dtype=float)
|
|
215
|
+
b = np.asarray(x, dtype=float)
|
|
216
|
+
step = float(np.linalg.norm(a - b))
|
|
217
|
+
base = float(np.linalg.norm(b))
|
|
218
|
+
return step <= scale * _EPS * max(1.0, base)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _value_progressed(fx: float, f_next: float, scale: float = _STAG_SCALE) -> bool:
|
|
222
|
+
"""Did the gd step reduce the function VALUE significantly: ``fx − f_next >
|
|
223
|
+
scale·eps·|fx|`` (R12: predicate lifted from ``minimize``/``minimize_nd``, the
|
|
224
|
+
mirror of the ``_has_diverged``/``_diverge_limit_for`` centralization from R8–R10).
|
|
225
|
+
|
|
226
|
+
This — and NOT the step-wise ``_has_stagnated`` — is the predicate the gd
|
|
227
|
+
solvers actually use to detect a stall: convergence to a DISTANT minimum takes
|
|
228
|
+
a small step relative to ‖x‖ (``_has_stagnated`` would raise a false alarm), but
|
|
229
|
+
still cuts f by >> eps of relative improvement, so here the streak resets and
|
|
230
|
+
convergent runs stay bit-identical. The negation over ``_STAG_PATIENCE``
|
|
231
|
+
consecutive steps with residual > tol = STAGNATION (cycle/oscillation with too
|
|
232
|
+
large an lr). ``fx``/``f_next`` are scalar — gd computes a one-dimensional
|
|
233
|
+
objective value in both solvers."""
|
|
234
|
+
return fx - f_next > scale * _EPS * abs(fx)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- R13: FINDING — why the stagnation family STOPS at gd -------------
|
|
238
|
+
# R4–R10 closed DIVERGENCE across all five solvers, so the natural reflex is:
|
|
239
|
+
# "close STAGNATION the same way on ``root_find`` and ``_descent_min``". Probing
|
|
240
|
+
# (the R11 pattern) says: NO — and not out of laziness, but because in neither of
|
|
241
|
+
# them is there anything to fix. Wiring in detection would be dead code posing as
|
|
242
|
+
# safety.
|
|
243
|
+
#
|
|
244
|
+
# The bug R11 fixed has EXACTLY one cause: a FIXED ``lr``. Too large a step throws
|
|
245
|
+
# gd into a period-2 cycle (``x^4`` bounces ±x0, ``f`` stuck at a constant), the
|
|
246
|
+
# run grinds through ``max_iter`` and ends with the MISLEADING "unbounded below?" —
|
|
247
|
+
# even though the function has a minimum.
|
|
248
|
+
#
|
|
249
|
+
# * ``_descent_min`` (newton/bfgs) — has NO ``lr`` to overshoot. The step is
|
|
250
|
+
# chosen by the backtracking Armijo, which accepts it ONLY when ``f`` truly
|
|
251
|
+
# decreases (``cand_f ≤ fx + 1e-4·α·gᵀd`` with ``gᵀd < 0``), so ``f`` is
|
|
252
|
+
# strictly monotone — a "f stuck" cycle is STRUCTURALLY impossible. When no α
|
|
253
|
+
# descends, the line search ends the run RIGHT AWAY (without grinding). Probe:
|
|
254
|
+
# 20 runs (newton/bfgs × ``x^4``, Rosenbrock, ill-conditioned, distant/flat
|
|
255
|
+
# minimum, kink, unbounded functions) — the longest streak of no value progress
|
|
256
|
+
# = 0 at ``_STAG_PATIENCE`` = 8, zero steps with rising ``f``. The only run
|
|
257
|
+
# grinding through ``max_iter`` is ``-x-y``: constant gradient, the iterate
|
|
258
|
+
# marches LINEARLY (norm ~1e2 against a threshold of 1e12, so rightly does not
|
|
259
|
+
# "diverge"), and ``f`` drops by a constant each step — the function REALLY is
|
|
260
|
+
# unbounded below, so the generic message is TRUE there. There is no misleading
|
|
261
|
+
# case to fix.
|
|
262
|
+
# * ``root_find`` (1D) — a Newton cycle exists (``x^3-2x+2`` from x0=0 bounces
|
|
263
|
+
# 0→1→0), but does NOT reach the message: bisection is the fallback and, given a
|
|
264
|
+
# real sign change, rescues the run, returning the correct root (−1.769). And
|
|
265
|
+
# when no real root exists (``x^2+1``), today's "no root nearby?" is simply
|
|
266
|
+
# TRUE. Stagnation would not add a single more accurate diagnosis.
|
|
267
|
+
#
|
|
268
|
+
# That is why stagnation is wired ONLY into gd (``minimize``/``minimize_nd``) — the
|
|
269
|
+
# only solvers with a fixed step. This is the same decision as the deliberately
|
|
270
|
+
# unwired ``_has_stagnated`` in R11: the primitive exists, but forcing it where the
|
|
271
|
+
# solver's structure already rules out the problem adds the risk of a false alarm
|
|
272
|
+
# for zero diagnosis. The R13 tests guard this FINDING behaviorally (Newton cycle →
|
|
273
|
+
# root; Armijo → no stagnation), and the anti-drift counter from R12 (1 definition +
|
|
274
|
+
# 2 gd solvers) guards it mechanically: wiring the predicate into a third solver
|
|
275
|
+
# would break that test.
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# --- roots -------------------------------------------------------------
|
|
279
|
+
def root_find(
|
|
280
|
+
expr: Expr,
|
|
281
|
+
var: str,
|
|
282
|
+
x0: float,
|
|
283
|
+
tol: float = 1e-10,
|
|
284
|
+
max_iter: int = 100,
|
|
285
|
+
) -> float:
|
|
286
|
+
"""Find a root ``f(x) = 0`` starting from ``x0``.
|
|
287
|
+
|
|
288
|
+
First Newton (derivative computed SYMBOLICALLY via ``Expr.diff``, evaluated
|
|
289
|
+
numerically). When Newton fails to deliver — the derivative vanishes, the
|
|
290
|
+
step runs outside the domain, or there is no convergence within ``max_iter``
|
|
291
|
+
— we drop down to bisection over an interval with a sign change found around
|
|
292
|
+
``x0``.
|
|
293
|
+
|
|
294
|
+
No real root nearby -> ``PycodemathError``.
|
|
295
|
+
"""
|
|
296
|
+
f = _scalar(expr, var)
|
|
297
|
+
df = _scalar(expr.diff(var), var)
|
|
298
|
+
|
|
299
|
+
# DIVERGENCE threshold — consistent with ``root_find_nd`` (module R4). Newton
|
|
300
|
+
# with a bad start can blow up: the step ``fx/dfx`` grows exponentially, the
|
|
301
|
+
# iterate escapes to infinity and would quietly fall back to bisection, which —
|
|
302
|
+
# finding no sign-change interval in range — ended with the misleading "no real
|
|
303
|
+
# root nearby?". This is the same trap R4 fixed for root_find_nd: divergence
|
|
304
|
+
# confused with an actual absence of a root. We track ``diverged`` and, when the
|
|
305
|
+
# iterate exceeds the threshold (scaled by |x0|, generous — a convergent run to
|
|
306
|
+
# a large finite root has time to return a result), we distinguish DIVERGENCE
|
|
307
|
+
# from a real absence of a root in the message. We keep bisection as a fallback
|
|
308
|
+
# for a REAL sign change (Newton may diverge despite an existing root — then the
|
|
309
|
+
# interval catches it and we return a result).
|
|
310
|
+
diverge_limit = _diverge_limit_for(x0)
|
|
311
|
+
diverged = False
|
|
312
|
+
x = float(x0)
|
|
313
|
+
for _ in range(max_iter):
|
|
314
|
+
fx = _safe(f, x)
|
|
315
|
+
if fx is None:
|
|
316
|
+
break
|
|
317
|
+
if abs(fx) < tol:
|
|
318
|
+
return x
|
|
319
|
+
dfx = _safe(df, x)
|
|
320
|
+
if dfx is None or dfx == 0.0:
|
|
321
|
+
break # derivative vanishes/diverges — Newton cannot proceed
|
|
322
|
+
x = x - fx / dfx
|
|
323
|
+
if _has_diverged(x, diverge_limit):
|
|
324
|
+
# iterate escaped to inf/NaN OR its norm exploded before reaching
|
|
325
|
+
# overflow — one shared predicate (R9), bit-identical to the former
|
|
326
|
+
# two branches (not isfinite / abs>limit, both: diverged+break)
|
|
327
|
+
diverged = True
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
# Fallback: a real sign change in range rescues the run even after divergence.
|
|
331
|
+
root = _bisect(f, float(x0), tol)
|
|
332
|
+
if root is not None:
|
|
333
|
+
return root
|
|
334
|
+
if diverged:
|
|
335
|
+
raise PycodemathError(
|
|
336
|
+
f"root_find DIVERGES from x0={x0} for {expr}: the Newton step escaped "
|
|
337
|
+
f"to |x|>{diverge_limit:.3g} and there is no sign change within reach of "
|
|
338
|
+
f"bisection — this is divergence, not proof of no root. Provide a starting "
|
|
339
|
+
f"point closer to the expected root."
|
|
340
|
+
)
|
|
341
|
+
raise PycodemathError(
|
|
342
|
+
f"root_find does not converge for {expr} at x0={x0} "
|
|
343
|
+
f"(no real root nearby?)"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _find_bracket(
|
|
348
|
+
f: Callable[[float], float],
|
|
349
|
+
x0: float,
|
|
350
|
+
step: float = 0.5,
|
|
351
|
+
max_steps: int = 2000,
|
|
352
|
+
) -> "tuple[float, float] | None":
|
|
353
|
+
"""Find an interval ``[lo, hi]`` with a sign change, marching from ``x0``.
|
|
354
|
+
|
|
355
|
+
We go separately right and left, checking successive samples — this way we
|
|
356
|
+
catch a root even for an even function (a symmetric window would have the
|
|
357
|
+
same sign at both ends). ``None`` when there is no sign change in range.
|
|
358
|
+
"""
|
|
359
|
+
f0 = _safe(f, x0)
|
|
360
|
+
for sign in (1.0, -1.0):
|
|
361
|
+
prev_x, prev_f = x0, f0
|
|
362
|
+
for k in range(1, max_steps + 1):
|
|
363
|
+
xk = x0 + sign * k * step
|
|
364
|
+
fk = _safe(f, xk)
|
|
365
|
+
if prev_f is not None and fk is not None and prev_f * fk <= 0.0:
|
|
366
|
+
return (prev_x, xk) if prev_x <= xk else (xk, prev_x)
|
|
367
|
+
prev_x, prev_f = xk, fk
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _bisect(
|
|
372
|
+
f: Callable[[float], float],
|
|
373
|
+
x0: float,
|
|
374
|
+
tol: float,
|
|
375
|
+
max_iter: int = 200,
|
|
376
|
+
) -> "float | None":
|
|
377
|
+
"""Bisection over an interval found around ``x0`` (fallback for Newton).
|
|
378
|
+
|
|
379
|
+
``None`` when there is no sign-change interval — the signal of no real root
|
|
380
|
+
in range.
|
|
381
|
+
"""
|
|
382
|
+
bracket = _find_bracket(f, x0)
|
|
383
|
+
if bracket is None:
|
|
384
|
+
return None
|
|
385
|
+
lo, hi = bracket
|
|
386
|
+
flo = _safe(f, lo)
|
|
387
|
+
if flo is None:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
for _ in range(max_iter):
|
|
391
|
+
mid = 0.5 * (lo + hi)
|
|
392
|
+
fm = _safe(f, mid)
|
|
393
|
+
if fm is None:
|
|
394
|
+
return None
|
|
395
|
+
if abs(fm) < tol or 0.5 * (hi - lo) < tol:
|
|
396
|
+
return mid
|
|
397
|
+
if flo * fm <= 0.0:
|
|
398
|
+
hi = mid
|
|
399
|
+
else:
|
|
400
|
+
lo, flo = mid, fm
|
|
401
|
+
return mid
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# --- integration -------------------------------------------------------
|
|
405
|
+
def integrate_num(
|
|
406
|
+
expr: Expr,
|
|
407
|
+
var: str,
|
|
408
|
+
a: float,
|
|
409
|
+
b: float,
|
|
410
|
+
n: int = 100,
|
|
411
|
+
) -> float:
|
|
412
|
+
"""Definite integral ``∫_a^b f(var) d(var)`` by composite Simpson quadrature.
|
|
413
|
+
|
|
414
|
+
A numerical alternative to ``symbolic.integrate`` when the integral has no
|
|
415
|
+
closed form. Simpson is exact for polynomials of degree ≤ 3 regardless of
|
|
416
|
+
``n``; for the rest ``n`` controls accuracy (even — we round up). A point
|
|
417
|
+
outside the domain -> ``PycodemathError``.
|
|
418
|
+
"""
|
|
419
|
+
if n <= 0:
|
|
420
|
+
raise PycodemathError("the number of subintervals n must be positive")
|
|
421
|
+
if n % 2:
|
|
422
|
+
n += 1 # Simpson requires an even number of subintervals
|
|
423
|
+
|
|
424
|
+
a, b = float(a), float(b)
|
|
425
|
+
if a == b:
|
|
426
|
+
return 0.0
|
|
427
|
+
|
|
428
|
+
f = _scalar(expr, var)
|
|
429
|
+
h = (b - a) / n
|
|
430
|
+
total = _sample(f, a) + _sample(f, b)
|
|
431
|
+
for i in range(1, n):
|
|
432
|
+
total += (4.0 if i % 2 else 2.0) * _sample(f, a + i * h)
|
|
433
|
+
return h / 3.0 * total
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# --- minimization ------------------------------------------------------
|
|
437
|
+
def minimize(
|
|
438
|
+
expr: Expr,
|
|
439
|
+
var: str,
|
|
440
|
+
x0: float,
|
|
441
|
+
lr: float = 0.1,
|
|
442
|
+
tol: float = 1e-9,
|
|
443
|
+
max_iter: int = 10000,
|
|
444
|
+
method: str = "gd",
|
|
445
|
+
) -> float:
|
|
446
|
+
"""Find the argument of the 1D minimum.
|
|
447
|
+
|
|
448
|
+
By default (``method="gd"``) gradient descent: the gradient is the SYMBOLIC
|
|
449
|
+
derivative (``Expr.diff``), the step ``lr`` is halved on overshoot (historical
|
|
450
|
+
behavior, results bit-identical to versions before module 15).
|
|
451
|
+
``method="newton"`` / ``"bfgs"`` are 2nd-order methods with an Armijo line
|
|
452
|
+
search (the shared ``_descent_min`` core with ``minimize_nd``) — Newton hits
|
|
453
|
+
the minimum of a quadratic in one step; ``lr`` does not apply to them. Returns
|
|
454
|
+
the ``x`` minimizing ``expr``.
|
|
455
|
+
|
|
456
|
+
Function unbounded below / no convergence / unknown method ->
|
|
457
|
+
``PycodemathError``.
|
|
458
|
+
"""
|
|
459
|
+
_check_method(method, "minimize")
|
|
460
|
+
if method != "gd":
|
|
461
|
+
e = Expr(expr)
|
|
462
|
+
_check_vars([e], [var], "minimize")
|
|
463
|
+
return _descent_min(e, [var], [x0], tol, int(max_iter), method, "minimize")[0]
|
|
464
|
+
f = _scalar(expr, var)
|
|
465
|
+
grad = _scalar(expr.diff(var), var)
|
|
466
|
+
|
|
467
|
+
# DIVERGENCE threshold — closes the detection family (R4 root_find_nd, R5
|
|
468
|
+
# 2nd-order minimize, R6 1D root_find) for the last missing solver: historical
|
|
469
|
+
# gradient descent. A function unbounded below has no minimum — gd finds a
|
|
470
|
+
# descent at every step, so the iterate marches to infinity and f goes to -inf.
|
|
471
|
+
# Without this the loop ground through max_iter steps and ended with the generic
|
|
472
|
+
# "does not converge", confusing DIVERGENCE with slow oscillation. We detect the
|
|
473
|
+
# norm explosion early and give an unambiguous error. The threshold is generous
|
|
474
|
+
# (scaled by |x0|), and the check fires ONLY on explosion: a convergent run
|
|
475
|
+
# keeps the iterate near the minimum and never touches it -> results
|
|
476
|
+
# bit-identical to the behavior before R7.
|
|
477
|
+
diverge_limit = _diverge_limit_for(x0)
|
|
478
|
+
x = float(x0)
|
|
479
|
+
fx = _sample(f, x)
|
|
480
|
+
stag = 0 # consecutive accepted steps without a significant (relative) drop in f (R11)
|
|
481
|
+
for _ in range(max_iter):
|
|
482
|
+
g = _safe(grad, x)
|
|
483
|
+
if g is None:
|
|
484
|
+
break
|
|
485
|
+
if abs(g) < tol:
|
|
486
|
+
return x
|
|
487
|
+
x_next = x - lr * g
|
|
488
|
+
# Iterate explosion = function unbounded below. We catch it HERE, before
|
|
489
|
+
# _safe samples f at a gigantic point (overflow -> None -> misleading "bad
|
|
490
|
+
# step"). A convergent run never reaches here (|x_next| stays small).
|
|
491
|
+
if _has_diverged(x_next, diverge_limit):
|
|
492
|
+
raise PycodemathError(
|
|
493
|
+
f"minimize DIVERGES from x0={x0} for {expr}: the gradient-descent "
|
|
494
|
+
f"iterate escaped to |x|>{diverge_limit:.3g}, and the function value "
|
|
495
|
+
f"goes to -inf — the function is most likely unbounded below "
|
|
496
|
+
f"(no minimum). Check the function or provide a starting point near "
|
|
497
|
+
f"the expected minimum."
|
|
498
|
+
)
|
|
499
|
+
f_next = _safe(f, x_next)
|
|
500
|
+
if f_next is None:
|
|
501
|
+
break
|
|
502
|
+
if f_next > fx:
|
|
503
|
+
lr *= 0.5 # overshot — shorten the step (transient, we do not count
|
|
504
|
+
if lr < 1e-15: # it toward stagnation — this is an lr correction, not a stall)
|
|
505
|
+
break
|
|
506
|
+
continue
|
|
507
|
+
# STAGNATION (R11): we accepted the step, but the function value did not
|
|
508
|
+
# drop SIGNIFICANTLY (improvement below rounding noise, relative to |f|).
|
|
509
|
+
# Since the residual is still > tol (otherwise we would have returned above
|
|
510
|
+
# on abs(g) < tol) and f stands still, the run makes no progress. K such
|
|
511
|
+
# steps in a row is a STALL — SEPARATE from divergence (the norm does not
|
|
512
|
+
# explode) and from unboundedness: the classic is x^4 with too large an lr,
|
|
513
|
+
# falling into a period-2 cycle (±x0, f at a constant). The RELATIVE-progress
|
|
514
|
+
# test is crucial for safety: convergence to a DISTANT minimum makes >> eps
|
|
515
|
+
# of relative improvement at every step (despite a small step relative to
|
|
516
|
+
# ‖x‖), so the streak resets and the results of convergent runs stay
|
|
517
|
+
# bit-identical.
|
|
518
|
+
if _value_progressed(fx, f_next):
|
|
519
|
+
stag = 0
|
|
520
|
+
else:
|
|
521
|
+
stag += 1
|
|
522
|
+
if stag >= _STAG_PATIENCE:
|
|
523
|
+
raise PycodemathError(
|
|
524
|
+
f"minimize STALLED for {expr} at x0={x0}: over {stag} "
|
|
525
|
+
f"consecutive steps the function value did not drop significantly "
|
|
526
|
+
f"(f≈{f_next:.6g}), and |g|={abs(g):.3g} ≥ tol — this is STAGNATION, "
|
|
527
|
+
f"not divergence and not unboundedness: the run stalled (cycle / "
|
|
528
|
+
f"oscillation with too large a step). Reduce lr, reduce tol "
|
|
529
|
+
f"or provide a better starting point."
|
|
530
|
+
)
|
|
531
|
+
if abs(x_next - x) < tol:
|
|
532
|
+
return x_next
|
|
533
|
+
x, fx = x_next, f_next
|
|
534
|
+
|
|
535
|
+
raise PycodemathError(
|
|
536
|
+
f"minimize does not converge for {expr} at x0={x0} "
|
|
537
|
+
f"(function unbounded below or bad step?)"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --- multidimensional numerics (module 5) -------------------------------
|
|
542
|
+
def _check_vars(exprs: "Sequence[Expr]", vars: "Sequence[str]", what: str) -> None:
|
|
543
|
+
"""Validate that the expressions depend solely on the given variables.
|
|
544
|
+
|
|
545
|
+
The same contract as ``_scalar`` in 1D: an unsubstituted symbol would break
|
|
546
|
+
``evalf`` only in the middle of an iteration — here we give a readable error
|
|
547
|
+
right away.
|
|
548
|
+
"""
|
|
549
|
+
if not vars:
|
|
550
|
+
raise PycodemathError(f"{what} requires at least one variable")
|
|
551
|
+
if len(set(vars)) != len(vars):
|
|
552
|
+
raise PycodemathError(f"{what}: the variable list contains duplicates")
|
|
553
|
+
needed: set[str] = set()
|
|
554
|
+
for e in exprs:
|
|
555
|
+
needed |= set(e.symbol_names())
|
|
556
|
+
extra = sorted(needed - set(vars))
|
|
557
|
+
if extra:
|
|
558
|
+
raise PycodemathError(
|
|
559
|
+
f"{what}: the expression also depends on {', '.join(extra)} — "
|
|
560
|
+
f"provide all variables"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _vector_fn(
|
|
565
|
+
entries: "Sequence[Expr]", vars: "Sequence[str]"
|
|
566
|
+
) -> "Callable[[np.ndarray], np.ndarray | None]":
|
|
567
|
+
"""Compile a list of expressions into a function ``x -> vector`` (once, before the loop).
|
|
568
|
+
|
|
569
|
+
The multidimensional counterpart of ``_scalar`` + ``_safe`` in one: the
|
|
570
|
+
returned function samples all entries at the point ``x`` and gives ``None``
|
|
571
|
+
when any is outside the domain (exception, complex result, NaN/inf) —
|
|
572
|
+
iterations treat ``None`` as a "not possible here" signal.
|
|
573
|
+
"""
|
|
574
|
+
fns = [e.compiled(vars) for e in entries]
|
|
575
|
+
|
|
576
|
+
def sample(x: np.ndarray) -> "np.ndarray | None":
|
|
577
|
+
out = np.empty(len(fns), dtype=float)
|
|
578
|
+
for i, fn in enumerate(fns):
|
|
579
|
+
try:
|
|
580
|
+
y = fn(*x)
|
|
581
|
+
except _DOMAIN_ERRORS:
|
|
582
|
+
return None
|
|
583
|
+
if isinstance(y, complex) or not math.isfinite(y):
|
|
584
|
+
return None
|
|
585
|
+
out[i] = y
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
return sample
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _point(x0: "Sequence[float]", n: int, what: str) -> np.ndarray:
|
|
592
|
+
"""Validate and cast the starting point to a ``float`` vector of length ``n``."""
|
|
593
|
+
x = np.asarray([float(v) for v in x0], dtype=float)
|
|
594
|
+
if x.shape != (n,):
|
|
595
|
+
raise PycodemathError(
|
|
596
|
+
f"{what}: the starting point has {x.size} coordinates, "
|
|
597
|
+
f"but there are {n} variables"
|
|
598
|
+
)
|
|
599
|
+
return x
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def gradient(expr: Expr, vars: "Sequence[str]") -> Matrix:
|
|
603
|
+
"""Gradient ``∇f`` — a column of partial derivatives (symbolically).
|
|
604
|
+
|
|
605
|
+
The derivatives are computed by the existing engine (``Expr.diff``); the
|
|
606
|
+
result is an IR ``Matrix`` (a column vector, consistent with module 3), ready
|
|
607
|
+
for numerical evaluation.
|
|
608
|
+
"""
|
|
609
|
+
e = Expr(expr)
|
|
610
|
+
vars = list(vars)
|
|
611
|
+
if not vars:
|
|
612
|
+
raise PycodemathError("gradient requires at least one variable")
|
|
613
|
+
return Matrix([[e.diff(v).sy] for v in vars])
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def jacobian(exprs: "Sequence[Expr]", vars: "Sequence[str]") -> Matrix:
|
|
617
|
+
"""Jacobian of the system — a row per function, a column per variable (symbolically)."""
|
|
618
|
+
fs = [Expr(e) for e in exprs]
|
|
619
|
+
vars = list(vars)
|
|
620
|
+
if not fs or not vars:
|
|
621
|
+
raise PycodemathError("jacobian requires at least one function and variable")
|
|
622
|
+
return Matrix([[f.diff(v).sy for v in vars] for f in fs])
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def hessian(expr: Expr, vars: "Sequence[str]") -> Matrix:
|
|
626
|
+
"""Hessian ``∇²f`` — the matrix of second partial derivatives (symbolically)."""
|
|
627
|
+
e = Expr(expr)
|
|
628
|
+
vars = list(vars)
|
|
629
|
+
if not vars:
|
|
630
|
+
raise PycodemathError("hessian requires at least one variable")
|
|
631
|
+
return Matrix([[e.diff(vi).diff(vj).sy for vj in vars] for vi in vars])
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def root_find_nd(
|
|
635
|
+
exprs: "Sequence[Expr]",
|
|
636
|
+
vars: "Sequence[str]",
|
|
637
|
+
x0: "Sequence[float]",
|
|
638
|
+
tol: float = 1e-10,
|
|
639
|
+
max_iter: int = 100,
|
|
640
|
+
) -> list[float]:
|
|
641
|
+
"""Solve the nonlinear system ``F(x) = 0`` with multidimensional Newton.
|
|
642
|
+
|
|
643
|
+
The Jacobian is computed SYMBOLICALLY (``jacobian``); at each iteration we
|
|
644
|
+
solve the linear system ``J·Δ = -F`` via ``np.linalg.solve`` (the same
|
|
645
|
+
mechanics as the numerical path ``linalg.solve_system``). A singular Jacobian
|
|
646
|
+
or no convergence -> ``PycodemathError``.
|
|
647
|
+
"""
|
|
648
|
+
fs = [Expr(e) for e in exprs]
|
|
649
|
+
vars = list(vars)
|
|
650
|
+
_check_vars(fs, vars, "root_find_nd")
|
|
651
|
+
if len(fs) != len(vars):
|
|
652
|
+
raise PycodemathError(
|
|
653
|
+
f"root_find_nd requires a square system: "
|
|
654
|
+
f"{len(fs)} equations, {len(vars)} variables"
|
|
655
|
+
)
|
|
656
|
+
J = [f.diff(v) for f in fs for v in vars] # flattened, row by row
|
|
657
|
+
n = len(vars)
|
|
658
|
+
F_fn = _vector_fn(fs, vars) # compile once — iterate without SymPy overhead
|
|
659
|
+
J_fn = _vector_fn(J, vars)
|
|
660
|
+
|
|
661
|
+
x = _point(x0, n, "root_find_nd")
|
|
662
|
+
# DIVERGENCE threshold. Newton with a bad start can escape to infinity: the
|
|
663
|
+
# iterate grows exponentially, spews RuntimeWarning "overflow", eventually
|
|
664
|
+
# becomes inf/NaN — and the method ground through max_iter steps in vain and
|
|
665
|
+
# ended with the generic "no solution nearby", confusing divergence with an
|
|
666
|
+
# actual absence of a root. We detect the norm explosion early (before it
|
|
667
|
+
# reaches overflow) and give an unambiguous error with a hint about the
|
|
668
|
+
# starting point. The threshold is generous (scaled by ‖x0‖), so a convergent
|
|
669
|
+
# run to a large but finite root has time to return a result (the residual
|
|
670
|
+
# drops to zero) before the norm exceeds it.
|
|
671
|
+
diverge_limit = _diverge_limit_for(x)
|
|
672
|
+
for _ in range(max_iter):
|
|
673
|
+
# Divergence takes priority over any sampling: a non-finite iterate is
|
|
674
|
+
# hard proof that Newton escaped — do not sample at NaN/inf, just report
|
|
675
|
+
# divergence right away (not the misleading "outside the domain").
|
|
676
|
+
if not np.all(np.isfinite(x)):
|
|
677
|
+
raise PycodemathError(
|
|
678
|
+
f"root_find_nd DIVERGES from x0={list(x0)}: the Newton iterate "
|
|
679
|
+
f"escaped to infinity (inf/NaN). Provide a starting point "
|
|
680
|
+
f"closer to the expected root."
|
|
681
|
+
)
|
|
682
|
+
F = F_fn(x)
|
|
683
|
+
if F is None:
|
|
684
|
+
raise PycodemathError(
|
|
685
|
+
f"cannot sample the system at x={x.tolist()} "
|
|
686
|
+
f"(outside the domain or complex result)"
|
|
687
|
+
)
|
|
688
|
+
if float(np.max(np.abs(F))) < tol:
|
|
689
|
+
return [float(v) for v in x]
|
|
690
|
+
Jx = J_fn(x)
|
|
691
|
+
if Jx is None:
|
|
692
|
+
raise PycodemathError(
|
|
693
|
+
f"cannot sample the Jacobian at x={x.tolist()} "
|
|
694
|
+
f"(outside the domain or complex result)"
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
delta = np.linalg.solve(Jx.reshape(n, n), -F)
|
|
698
|
+
except np.linalg.LinAlgError as exc:
|
|
699
|
+
raise PycodemathError(
|
|
700
|
+
f"singular Jacobian at x={x.tolist()} — Newton cannot proceed "
|
|
701
|
+
f"(try a different starting point)"
|
|
702
|
+
) from exc
|
|
703
|
+
x = x + delta
|
|
704
|
+
# A norm explosion (and, via the shared R9 predicate, also an escape to
|
|
705
|
+
# inf/NaN) = divergence. We catch it HERE, before the next turn samples the
|
|
706
|
+
# system at a gigantic point and triggers overflow. The upper finiteness
|
|
707
|
+
# guard (before sampling) STAYS — it catches a non-finite START with a
|
|
708
|
+
# separate message; here delta is solve() of finite F/J, so it really
|
|
709
|
+
# closes only the norm explosion (behavior bit-identical).
|
|
710
|
+
if _has_diverged(x, diverge_limit):
|
|
711
|
+
raise PycodemathError(
|
|
712
|
+
f"root_find_nd DIVERGES from x0={list(x0)}: the iterate norm "
|
|
713
|
+
f"exceeded {diverge_limit:.3g} (‖x‖={float(np.linalg.norm(x)):.3g}) "
|
|
714
|
+
f"— Newton is escaping the solution. Provide a starting point closer "
|
|
715
|
+
f"to the expected root."
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
raise PycodemathError(
|
|
719
|
+
f"root_find_nd does not converge in {max_iter} iterations for the system "
|
|
720
|
+
f"{[str(f) for f in fs]} at x0={list(x0)} — the iterate oscillates without "
|
|
721
|
+
f"dropping below the tolerance. Try a starting point closer to the expected "
|
|
722
|
+
f"root (or check whether a real solution exists at all)."
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# --- 2nd-order minimization (module 15): Newton / BFGS + Armijo ----------
|
|
727
|
+
#: available minimization methods; "gd" = historical gradient descent
|
|
728
|
+
_MIN_METHODS = ("gd", "newton", "bfgs")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _check_method(method: str, what: str) -> None:
|
|
732
|
+
"""Validate the minimization method name — an unknown one is a readable error."""
|
|
733
|
+
if method not in _MIN_METHODS:
|
|
734
|
+
raise PycodemathError(
|
|
735
|
+
f"{what}: unknown method {method!r} — available: "
|
|
736
|
+
+ ", ".join(_MIN_METHODS)
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _descent_min(
|
|
741
|
+
f: Expr,
|
|
742
|
+
vars: "Sequence[str]",
|
|
743
|
+
x0: "Sequence[float]",
|
|
744
|
+
tol: float,
|
|
745
|
+
max_iter: int,
|
|
746
|
+
method: str,
|
|
747
|
+
what: str,
|
|
748
|
+
) -> list[float]:
|
|
749
|
+
"""2nd-order directional minimization: Newton or BFGS + backtracking Armijo.
|
|
750
|
+
|
|
751
|
+
Descent direction:
|
|
752
|
+
* ``newton`` — solves ``H·d = -∇f`` with the Hessian computed
|
|
753
|
+
SYMBOLICALLY (``Expr.diff``, compiled ONCE before the loop — the
|
|
754
|
+
``root_find_nd`` pattern); a singular or non-positive-definite Hessian
|
|
755
|
+
(the direction does not descend: ``∇fᵀd ≥ 0``) -> fallback to ``-∇f``.
|
|
756
|
+
* ``bfgs`` — maintains an approximation of the INVERSE Hessian (starting
|
|
757
|
+
from the identity, a rank-2 update in inverse form — a double
|
|
758
|
+
Sherman-Morrison formula) with the curvature condition
|
|
759
|
+
``sᵀy > 0``; a degenerate direction -> restart from the identity.
|
|
760
|
+
|
|
761
|
+
The step length is chosen by a BACKTRACKING Armijo line search
|
|
762
|
+
(``f(x+αd) ≤ f(x) + c·α·∇fᵀd``, c=1e-4, shrink 0.5) instead of a fixed
|
|
763
|
+
``lr`` — the full Newton/BFGS step passes as soon as it gives a descent.
|
|
764
|
+
No convergence / unbounded function -> ``PycodemathError``.
|
|
765
|
+
"""
|
|
766
|
+
n = len(vars)
|
|
767
|
+
f_fn = _vector_fn([f], vars) # compile once — iterate without SymPy overhead
|
|
768
|
+
g_fn = _vector_fn([f.diff(v) for v in vars], vars)
|
|
769
|
+
H_fn = None
|
|
770
|
+
if method == "newton":
|
|
771
|
+
H_fn = _vector_fn([f.diff(vi).diff(vj) for vi in vars for vj in vars], vars)
|
|
772
|
+
|
|
773
|
+
x = _point(x0, n, what)
|
|
774
|
+
f_vec = f_fn(x)
|
|
775
|
+
if f_vec is None:
|
|
776
|
+
raise PycodemathError(
|
|
777
|
+
f"cannot sample the function at x={x.tolist()} "
|
|
778
|
+
f"(outside the domain or complex result)"
|
|
779
|
+
)
|
|
780
|
+
fx = float(f_vec[0])
|
|
781
|
+
g = g_fn(x)
|
|
782
|
+
H_inv = np.eye(n) # BFGS: current approximation of the inverse Hessian
|
|
783
|
+
|
|
784
|
+
# DIVERGENCE threshold — consistent with ``root_find_nd``. A function unbounded
|
|
785
|
+
# below has no minimum: the backtracking Armijo will ALWAYS find a descent, so
|
|
786
|
+
# the iterate marches to infinity and the function value goes to -inf (along the
|
|
787
|
+
# way RuntimeWarning "overflow", then inf/NaN). Instead of grinding through
|
|
788
|
+
# max_iter steps and ending with the generic "does not converge", we detect the
|
|
789
|
+
# iterate's norm explosion early and give an unambiguous error with a hint about
|
|
790
|
+
# the start — exactly like Newton in ``root_find_nd``. The threshold is generous
|
|
791
|
+
# (scaled by ‖x0‖), so a convergent run to a distant but finite minimum has time
|
|
792
|
+
# to return a result (the gradient drops below the tolerance) before the norm
|
|
793
|
+
# exceeds it.
|
|
794
|
+
diverge_limit = _diverge_limit_for(x)
|
|
795
|
+
|
|
796
|
+
for _ in range(max_iter):
|
|
797
|
+
if g is None:
|
|
798
|
+
break # gradient outside the domain — cannot proceed
|
|
799
|
+
if float(np.max(np.abs(g))) < tol:
|
|
800
|
+
return [float(v) for v in x]
|
|
801
|
+
|
|
802
|
+
d = -g # shared fallback: the gradient direction
|
|
803
|
+
if method == "newton":
|
|
804
|
+
Hx = H_fn(x) if H_fn is not None else None
|
|
805
|
+
if Hx is not None:
|
|
806
|
+
try:
|
|
807
|
+
cand = np.linalg.solve(Hx.reshape(n, n), -g)
|
|
808
|
+
# ∇fᵀd < 0 guarantees a DESCENT direction — a non-positive-
|
|
809
|
+
# definite Hessian does not give one, we keep -∇f
|
|
810
|
+
if np.all(np.isfinite(cand)) and float(g @ cand) < 0.0:
|
|
811
|
+
d = cand
|
|
812
|
+
except np.linalg.LinAlgError:
|
|
813
|
+
pass # singular Hessian -> the gradient direction
|
|
814
|
+
else: # bfgs
|
|
815
|
+
cand = -H_inv @ g
|
|
816
|
+
if float(g @ cand) < 0.0:
|
|
817
|
+
d = cand
|
|
818
|
+
else: # degenerate approximation — restart from the identity
|
|
819
|
+
H_inv = np.eye(n)
|
|
820
|
+
|
|
821
|
+
# backtracking Armijo: full step, then halves, until we descend
|
|
822
|
+
gTd = float(g @ d)
|
|
823
|
+
alpha = 1.0
|
|
824
|
+
x_new = None
|
|
825
|
+
f_new = fx
|
|
826
|
+
while alpha >= 1e-15:
|
|
827
|
+
cand_x = x + alpha * d
|
|
828
|
+
cand_f = f_fn(cand_x)
|
|
829
|
+
if cand_f is not None and float(cand_f[0]) <= fx + 1e-4 * alpha * gTd:
|
|
830
|
+
x_new, f_new = cand_x, float(cand_f[0])
|
|
831
|
+
break
|
|
832
|
+
alpha *= 0.5
|
|
833
|
+
if x_new is None:
|
|
834
|
+
break # the line search found no descent — we give up readably
|
|
835
|
+
|
|
836
|
+
# An iterate norm explosion = function unbounded below (no minimum): the
|
|
837
|
+
# line search still descends, so x escapes to infinity and f -> -inf. We
|
|
838
|
+
# catch it HERE, before the next turn samples the gradient/Hessian at a
|
|
839
|
+
# gigantic point and triggers overflow — consistent with ``root_find_nd``.
|
|
840
|
+
xn_norm = float(np.linalg.norm(x_new))
|
|
841
|
+
if _has_diverged(x_new, diverge_limit):
|
|
842
|
+
raise PycodemathError(
|
|
843
|
+
f"{what} DIVERGES from x0={list(x0)} with method {method!r}: the "
|
|
844
|
+
f"iterate norm exceeded {diverge_limit:.3g} (‖x‖={xn_norm:.3g}), "
|
|
845
|
+
f"and the function value goes to -inf — the function is most likely "
|
|
846
|
+
f"unbounded below (no minimum). Check the function or provide a "
|
|
847
|
+
f"starting point near the expected minimum."
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
g_new = g_fn(x_new)
|
|
851
|
+
if method == "bfgs" and g_new is not None:
|
|
852
|
+
s = x_new - x
|
|
853
|
+
yv = g_new - g
|
|
854
|
+
sy = float(yv @ s)
|
|
855
|
+
# curvature condition sᵀy > 0 — without it the update would spoil the
|
|
856
|
+
# positive-definiteness of the approximation (we skip the step, not break it)
|
|
857
|
+
if sy > 1e-12 * float(np.linalg.norm(s) * np.linalg.norm(yv)):
|
|
858
|
+
rho = 1.0 / sy
|
|
859
|
+
V = np.eye(n) - rho * np.outer(s, yv)
|
|
860
|
+
H_inv = V @ H_inv @ V.T + rho * np.outer(s, s)
|
|
861
|
+
|
|
862
|
+
if float(np.max(np.abs(x_new - x))) < tol:
|
|
863
|
+
return [float(v) for v in x_new]
|
|
864
|
+
x, fx, g = x_new, f_new, g_new
|
|
865
|
+
|
|
866
|
+
raise PycodemathError(
|
|
867
|
+
f"{what} does not converge for {f} at x0={list(x0)} with method {method!r} "
|
|
868
|
+
f"(function unbounded below or bad starting point?)"
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def minimize_nd(
|
|
873
|
+
expr: Expr,
|
|
874
|
+
vars: "Sequence[str]",
|
|
875
|
+
x0: "Sequence[float]",
|
|
876
|
+
lr: float = 0.1,
|
|
877
|
+
tol: float = 1e-9,
|
|
878
|
+
max_iter: int = 10000,
|
|
879
|
+
method: str = "gd",
|
|
880
|
+
) -> list[float]:
|
|
881
|
+
"""Find the argument of the minimum of a function of several variables.
|
|
882
|
+
|
|
883
|
+
By default (``method="gd"``) gradient descent — a SYMBOLIC gradient
|
|
884
|
+
(``Expr.diff`` for each variable), numerical iteration, the step ``lr``
|
|
885
|
+
halved on overshoot (historical behavior, results bit-identical to versions
|
|
886
|
+
before module 15). ``method="newton"`` / ``"bfgs"`` are 2nd-order methods
|
|
887
|
+
with an Armijo line search (``_descent_min``) — Newton with a symbolic
|
|
888
|
+
Hessian converges on smooth problems in a few iterations, BFGS copes where
|
|
889
|
+
gradient descent chokes (e.g. the Rosenbrock valley); the ``lr`` parameter
|
|
890
|
+
does not apply to them. Returns the point ``x`` minimizing ``expr``.
|
|
891
|
+
|
|
892
|
+
Function unbounded below / no convergence / unknown method ->
|
|
893
|
+
``PycodemathError``.
|
|
894
|
+
"""
|
|
895
|
+
_check_method(method, "minimize_nd")
|
|
896
|
+
f = Expr(expr)
|
|
897
|
+
vars = list(vars)
|
|
898
|
+
_check_vars([f], vars, "minimize_nd")
|
|
899
|
+
if method != "gd":
|
|
900
|
+
return _descent_min(f, vars, x0, tol, int(max_iter), method, "minimize_nd")
|
|
901
|
+
f_fn = _vector_fn([f], vars) # compile once — iterate without SymPy overhead
|
|
902
|
+
g_fn = _vector_fn([f.diff(v) for v in vars], vars)
|
|
903
|
+
|
|
904
|
+
x = _point(x0, len(vars), "minimize_nd")
|
|
905
|
+
fx_vec = f_fn(x)
|
|
906
|
+
if fx_vec is None:
|
|
907
|
+
raise PycodemathError(
|
|
908
|
+
f"cannot sample the function at x={x.tolist()} "
|
|
909
|
+
f"(outside the domain or complex result)"
|
|
910
|
+
)
|
|
911
|
+
fx = float(fx_vec[0])
|
|
912
|
+
# DIVERGENCE threshold — as in 1D minimize (R7) and _descent_min (R5): closing
|
|
913
|
+
# the detection family for multidimensional gradient descent. A function
|
|
914
|
+
# unbounded below has no minimum, gd descends at every step, the iterate escapes
|
|
915
|
+
# to infinity. The check fires only on a norm explosion — a convergent run never
|
|
916
|
+
# touches it, so results are bit-identical to the behavior before R7.
|
|
917
|
+
diverge_limit = _diverge_limit_for(x)
|
|
918
|
+
stag = 0 # consecutive accepted steps without a significant (relative) drop in f (R11)
|
|
919
|
+
for _ in range(max_iter):
|
|
920
|
+
g = g_fn(x)
|
|
921
|
+
if g is None:
|
|
922
|
+
break
|
|
923
|
+
if float(np.max(np.abs(g))) < tol:
|
|
924
|
+
return [float(v) for v in x]
|
|
925
|
+
x_next = x - lr * g
|
|
926
|
+
# A norm explosion = function unbounded below. We catch it HERE, before f_fn
|
|
927
|
+
# samples at a gigantic point (overflow). A convergent run does not reach it.
|
|
928
|
+
xn_norm = float(np.linalg.norm(x_next))
|
|
929
|
+
if _has_diverged(x_next, diverge_limit):
|
|
930
|
+
raise PycodemathError(
|
|
931
|
+
f"minimize_nd DIVERGES from x0={list(x0)}: the gradient-descent "
|
|
932
|
+
f"iterate norm exceeded {diverge_limit:.3g} (‖x‖={xn_norm:.3g}), "
|
|
933
|
+
f"and the function value goes to -inf — the function is most likely "
|
|
934
|
+
f"unbounded below (no minimum). Check the function or provide a "
|
|
935
|
+
f"starting point near the expected minimum."
|
|
936
|
+
)
|
|
937
|
+
f_next_vec = f_fn(x_next)
|
|
938
|
+
if f_next_vec is None:
|
|
939
|
+
break
|
|
940
|
+
f_next = float(f_next_vec[0])
|
|
941
|
+
if f_next > fx:
|
|
942
|
+
lr *= 0.5 # overshot — shorten the step (lr correction, not stagnation)
|
|
943
|
+
if lr < 1e-15:
|
|
944
|
+
break
|
|
945
|
+
continue
|
|
946
|
+
# STAGNATION (R11): the accepted step did not cut f significantly
|
|
947
|
+
# (improvement below rounding noise relative to |f|), and the residual is
|
|
948
|
+
# still > tol — the run makes no progress. K such steps is a STALL, separate
|
|
949
|
+
# from divergence and unboundedness (e.g. x^4+y^4 with too large an lr falls
|
|
950
|
+
# into a period-2 cycle). The RELATIVE-progress test protects convergence to
|
|
951
|
+
# a distant minimum: it makes >> eps of relative improvement per step, so the
|
|
952
|
+
# streak resets (bit-identical).
|
|
953
|
+
if _value_progressed(fx, f_next):
|
|
954
|
+
stag = 0
|
|
955
|
+
else:
|
|
956
|
+
stag += 1
|
|
957
|
+
if stag >= _STAG_PATIENCE:
|
|
958
|
+
raise PycodemathError(
|
|
959
|
+
f"minimize_nd STALLED for {f} at x0={list(x0)}: over {stag} "
|
|
960
|
+
f"consecutive steps the function value did not drop significantly "
|
|
961
|
+
f"(f≈{f_next:.6g}), and ‖g‖∞={float(np.max(np.abs(g))):.3g} ≥ tol "
|
|
962
|
+
f"— this is STAGNATION, not divergence and not unboundedness: the "
|
|
963
|
+
f"run stalled (cycle / oscillation with too large a step). Reduce "
|
|
964
|
+
f"lr, reduce tol or provide a better starting point."
|
|
965
|
+
)
|
|
966
|
+
if float(np.max(np.abs(x_next - x))) < tol:
|
|
967
|
+
return [float(v) for v in x_next]
|
|
968
|
+
x, fx = x_next, f_next
|
|
969
|
+
|
|
970
|
+
raise PycodemathError(
|
|
971
|
+
f"minimize_nd does not converge for {f} at x0={list(x0)} "
|
|
972
|
+
f"(function unbounded below or bad step?)"
|
|
973
|
+
)
|