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.
@@ -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
+ )