Source code for labtoolbox.numerical.numerical

import numpy as _np
from numpy.typing import ArrayLike
import warnings
from inspect import signature as _signature
from typing import Callable, Tuple, List, Union, Optional
from .._helper import GenericError

# __all__ = ["bool", "romberg", "newton", "lebesgue", "dblebesgue", "nlebesgue", "lagrange"]

[docs] def boole( f: Callable[[Union[float, _np.ndarray]], Union[float, _np.ndarray]], a: float, b: float, n: Optional[int] = None, varname: Optional[str] = None, max_step: float = 0.1, **kwargs ) -> float: """ Approximate the definite integral of a function using Boole's Rule. Parameters ---------- f : callable The function to integrate. Must accept the integration variable as a named argument. a : float Lower limit of integration. b : float Upper limit of integration. n : int, optional Number of Boole segments. Must be equal or greater than 1. If not provided, an optimal value is estimated to ensure segment width ≤ max_step. varname : str, optional Name of the integration variable as expected by `f`. If not provided and `f` is a lambda or function with one positional argument, it is inferred automatically. max_step : float, optional Maximum width of a Boole segment. Only used if `n` is not provided. Default is `0.1`. **kwargs Additional parameters passed to `f`. Returns ------- float Approximation of the definite integral using Boole's Rule. """ try: if not callable(f): raise TypeError("'f' must be a callable function.") if not isinstance(a, (int, float)) or not (_np.isfinite(a) or _np.isinf(a)): raise TypeError("'a' must be a real number or ±inf.") if not isinstance(b, (int, float)) or not (_np.isfinite(b) or _np.isinf(b)): raise TypeError("'b' must be a real number or ±inf.") if a == b: warnings.warn("Integration limits 'a' and 'b' are equal; integral is zero.", UserWarning) return 0.0 if varname is None: try: sig = _signature(f) pos_params = [p.name for p in sig.parameters.values() if p.kind in (p.POSITIONAL_OR_KEYWORD, p.POSITIONAL_ONLY)] if len(pos_params) == 1: varname = pos_params[0] else: raise ValueError("Unable to infer integration variable name. Pass it explicitly via varname.") except Exception: raise ValueError("Function signature inspection failed. Pass varname explicitly.") if max_step <= 0: raise ValueError("'max_step' must be positive.") if n is None: if _np.isinf(a) or _np.isinf(b): n = max(1, int(_np.ceil(1.0 / (4 * max_step)))) else: total_length = abs(b - a) n = max(1, int(_np.ceil(total_length / (4 * max_step)))) else: if not isinstance(n, int) or n < 1: raise ValueError("'n' must be a positive integer.") n = int(n) if _np.isinf(a) or _np.isinf(b): if _np.isinf(a) and _np.isinf(b): # (-inf, inf) # Transform x = t / (1 - t^2), t in [-1, 1] h = 2.0 / (4 * n) t = _np.linspace(-1, 1, 4 * n + 1) x = t / (1 - t**2) dx_dt = (1 + t**2) / (1 - t**2)**2 elif _np.isinf(a): # (-inf, b] # Transform x = b - (1-t)/t, t in (0, 1] h = 1.0 / (4 * n) t = _np.linspace(1e-10, 1, 4 * n + 1) # Avoid t=0 x = b - (1 - t) / t dx_dt = 1 / t**2 else: # [a, inf) # Transform x = a + t / (1-t), t in [0, 1) h = 1.0 / (4 * n) t = _np.linspace(0, 1 - 1e-10, 4 * n + 1) # Avoid t=1 x = a + t / (1 - t) dx_dt = 1 / (1 - t)**2 if len(t) < 5: raise ValueError("The number of points must be at least 5 for Boole's rule.") vector_f = _np.vectorize(lambda ti: f(**{varname: x[ti]}, **kwargs) * dx_dt[ti]) y = vector_f(_np.arange(len(t))) if not _np.all(_np.isfinite(y)): raise ValueError("Function evaluations contain non-finite values after transformation.") total = 0.0 for i in range(n): j = 4 * i weights = _np.array([7, 32, 12, 32, 7]) segment = y[j:j+5] total += _np.dot(weights, segment) return (2 * h / 45) * total # Finite limits if b < a: a, b = b, a warnings.warn("Integration limits 'a' and 'b' have been swapped.", UserWarning) h = (b - a) / (4 * n) x = _np.linspace(a, b, 4 * n + 1) if len(x) < 5: raise ValueError("The number of points must be at least 5 for Boole's rule.") vector_f = _np.vectorize(lambda xi: f(**{varname: xi}, **kwargs)) y = vector_f(x) if not _np.all(_np.isfinite(y)): raise ValueError("Function evaluations contain non-finite values (NaN or inf).") total = 0.0 for i in range(n): j = 4 * i weights = _np.array([7, 32, 12, 32, 7]) segment = y[j:j+5] total += _np.dot(weights, segment) return (2 * h / 45) * total except Exception as e: raise GenericError( message=f"Error computing integral with Boole's rule: {str(e)}", context="executing boole", original_error=e, details={"a": a, "b": b, "n": n, "varname": varname} )
[docs] def romberg( f: Callable[[Union[float, _np.ndarray]], Union[float, _np.ndarray]], a: float, b: float, varname: Optional[str] = None, tol: float = 1e-8, max_iter: int = 10, **kwargs ) -> float: """ Perform numerical integration using Romberg's method. Parameters ---------- f : callable The function to integrate. Must accept the integration variable as a named argument. a : float Lower limit of integration. b : float Upper limit of integration. varname : str, optional Name of the integration variable. If `None`, it's inferred automatically (only if `f` has one arg). tol : float, optional Desired absolute tolerance. Default is `1e-8`. max_iter : int, optional Maximum number of Romberg iterations. Default is `10`. **kwargs Additional keyword arguments passed to `f`. Returns ------- float Approximation of the integral. """ try: if not callable(f): raise TypeError("'f' must be a callable function.") if not isinstance(a, (int, float)) or not (_np.isfinite(a) or _np.isinf(a)): raise TypeError("'a' must be a real number or ±inf.") if not isinstance(b, (int, float)) or not (_np.isfinite(b) or _np.isinf(b)): raise TypeError("'b' must be a real number or ±inf.") if a == b: warnings.warn("Integration limits 'a' and 'b' are equal; integral is zero.", UserWarning) return 0.0 if not isinstance(tol, (int, float)) or tol <= 0: raise ValueError("'tol' must be a positive real number (int or float).") if not isinstance(max_iter, int) or max_iter < 1: raise ValueError("'max_iter' must be a real number (int or float) greater than 1.") if varname is None: try: sig = _signature(f) pos_args = [p.name for p in sig.parameters.values() if p.kind in (p.POSITIONAL_OR_KEYWORD, p.POSITIONAL_ONLY)] if len(pos_args) == 1: varname = pos_args[0] else: raise ValueError("Unable to infer variable of integration; provide 'varname' explicitly.") except Exception: raise ValueError("Could not inspect function signature. Pass 'varname' explicitly.") def eval_f(x): result = f(**{varname: x}, **kwargs) if not _np.isfinite(result): raise ValueError(f"Function evaluation at x = {x} returned non-finite value (NaN or inf).") return result if _np.isinf(a) or _np.isinf(b): n = max_iter # Use max_iter as number of segments for trapezoid rule if _np.isinf(a) and _np.isinf(b): # (-inf, inf) # Transform x = t / (1 - t^2), t in [-1, 1] h = 2.0 / n t = _np.linspace(-1, 1, n + 1) x = t / (1 - t**2) dx_dt = (1 + t**2) / (1 - t**2)**2 y = _np.array([eval_f(xi) * dx_dti for xi, dx_dti in zip(x, dx_dt)]) elif _np.isinf(a): # (-inf, b] # Transform x = b - (1-t)/t, t in (0, 1] h = 1.0 / n t = _np.linspace(1e-10, 1, n + 1) # Avoid t=0 x = b - (1 - t) / t dx_dt = 1 / t**2 y = _np.array([eval_f(xi) * dx_dti for xi, dx_dti in zip(x, dx_dt)]) else: # [a, inf) # Transform x = a + t / (1-t), t in [0, 1) h = 1.0 / n t = _np.linspace(0, 1 - 1e-10, n + 1) # Avoid t=1 x = a + t / (1 - t) dx_dt = 1 / (1 - t)**2 y = _np.array([eval_f(xi) * dx_dti for xi, dx_dti in zip(x, dx_dt)]) if not _np.all(_np.isfinite(y)): raise ValueError("Function evaluations contain non-finite values after transformation.") # Composite trapezoid rule total = 0.5 * (y[0] + y[-1]) + _np.sum(y[1:-1]) result = h * total if abs(result) < tol: return result warnings.warn(f"Integration with infinite limits did not converge to tolerance {tol}.", UserWarning) return result # Finite limits if b < a: a, b = b, a warnings.warn("Integration limits 'a' and 'b' have been swapped.", UserWarning) # Romberg integration table R = _np.zeros((max_iter, max_iter)) h = b - a # First row: trapezoid rule R[0, 0] = 0.5 * h * (eval_f(a) + eval_f(b)) for k in range(1, max_iter): h /= 2 if h < _np.finfo(float).eps: warnings.warn(f"Step size h = {h} is below machine precision; stopping early.", UserWarning) return R[k-1, k-1] # Composite Trapezoid Rule refinement subtotal = sum(eval_f(a + (2 * i - 1) * h) for i in range(1, 2**(k-1)+1)) R[k, 0] = 0.5 * R[k - 1, 0] + h * subtotal # Romberg extrapolation for j in range(1, k + 1): R[k, j] = (4**j * R[k, j - 1] - R[k - 1, j - 1]) / (4**j - 1) # Convergence check if abs(R[k, k] - R[k - 1, k - 1]) < tol: return R[k, k] warnings.warn(f"Romberg integration did not converge after {max_iter} iterations.", UserWarning) return R[max_iter - 1, max_iter - 1] except Exception as e: raise GenericError( message=f"Error computing integral with Romberg's method: {str(e)}", context="executing romberg", original_error=e, details={"a": a, "b": b, "varname": varname} )
[docs] def newton(f: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]], x0: float, fprime: Optional[Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]]] = None, varname: Optional[str] = None, tol: float = 1e-10, maxiter: int = 50, dx: float = 1e-6, **kwargs) -> float: """ Find the root of a scalar function using the Newton-Raphson method. Parameters ---------- f : callable Function whose root is to be found. Must accept the variable of interest as a named argument. x0 : float Initial guess. fprime : callable, optional Derivative function. If `None`, numerical differentiation is used. varname : str, optional Name of the variable with respect to which we take the root. Required if `f` has multiple arguments. tol : float, optional Absolute tolerance for convergence. Default is `1e-10`. maxiter : int, optional Maximum number of iterations. Default is `50`. dx : float, optional Step size for numerical differentiation. Default is `1e-6`. **kwargs Additional keyword arguments passed to `f` (and `fprime` if provided). Returns ------- float Approximated root of the function. """ if not callable(f): raise TypeError("'f' must be a callable function.") if fprime is not None and not callable(fprime): raise TypeError("'fprime' must be a callable function.") if not isinstance(x0, (int, float)): raise TypeError("'x0' must be a real number (int or float).") if tol <= 0: raise ValueError("'tol' must be positive.") if not isinstance(maxiter, int) or maxiter < 1: raise ValueError("'maxiter' must be a positive integer.") if dx <= 0: raise ValueError("'dx' must be positive.") # Infer variable name if needed if varname is None: try: sig = _signature(f) pos_args = [p.name for p in sig.parameters.values() if p.kind in (p.POSITIONAL_OR_KEYWORD, p.POSITIONAL_ONLY)] if len(pos_args) == 1: varname = pos_args[0] else: raise ValueError("Unable to infer variable of integration; provide 'varname' explicitly.") except Exception: raise ValueError("Could not inspect function signature. Pass 'varname' manually.") def eval_f(x): result = f(**{varname: x}, **kwargs) if not _np.isfinite(result): raise ValueError(f"Function evaluation at x={x} returned non-finite value (NaN or inf).") return result def eval_df(x): if fprime: result = fprime(**{varname: x}, **kwargs) if not _np.isfinite(result): raise ValueError(f"Derivative evaluation at x={x} returned non-finite value (NaN or inf).") return result else: # Central finite difference return (eval_f(x + dx) - eval_f(x - dx)) / (2 * dx) x = x0 for i in range(maxiter): fx = eval_f(x) dfx = eval_df(x) if dfx == 0: raise ZeroDivisionError(f"Derivative is zero at iteration {i} (x = {x}).") if abs(dfx) < _np.finfo(float).eps: print(f"Warning: Derivative is near zero at iteration {i} (x = {x}); stopping early.") return x dx_newton = fx / dfx x_new = x - dx_newton if abs(dx_newton) < tol: return x_new x = x_new raise RuntimeError(f"Newton-Raphson did not converge after {maxiter} iterations.")
# def lebesgue(func: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]], # interval: Tuple[float, float], # num_samples: int = 10000, # use_quasi_mc: bool = True, # indicator_func: Optional[Callable[[float], bool]] = None, # return_error: bool = False) -> Union[float, Tuple[float, float]]: # """ # Compute the 1D Lebesgue integral of a function over a specified interval using Monte Carlo or Quasi-Monte Carlo. # Parameters # ---------- # func : callable # The integrand function, which must be Lebesgue measurable. # interval : tuple # The integration interval `[a, b]`, where a < b. # num_samples : int, optional # Number of Monte Carlo samples. Defaults to `10000`. # use_quasi_mc : bool, optional # If `True`, uses Quasi-Monte Carlo (Sobol sequence) for sampling. Defaults to `True`. # indicator_func : callable, optional # A function that returns `True` if a point is in the integration domain, `False` otherwise. # If `None`, the entire interval `[a, b]` is used. Defaults to `None`. # return_error : bool, optional # If `True`, returns a tuple (integral, error_estimate). Defaults to `False`. # Returns # ------- # float or tuple # The approximate value of the Lebesgue integral. If `return_error` is `True`, returns `(integral, error_estimate)`. # """ # from scipy.stats import qmc # # Input validation # if not callable(func): # raise TypeError("'func' must be callable.") # if not isinstance(interval, (tuple, list)) or len(interval) != 2: # raise TypeError("'interval' must be a tuple of two floats (a, b).") # a, b = interval # if not (isinstance(a, (int, float)) and isinstance(b, (int, float))): # raise ValueError("'interval' bounds must be numeric.") # if a >= b: # raise ValueError("'interval' must satisfy a < b.") # if not isinstance(num_samples, int) or num_samples <= 0: # raise ValueError("'num_samples' must be a positive integer.") # if indicator_func is not None and not callable(indicator_func): # raise TypeError("'indicator_func' must be callable.") # # Compute the Lebesgue measure of the interval # measure = b - a # # Generate samples # if use_quasi_mc: # sampler = qmc.Sobol(d=1, scramble=True) # samples = sampler.random(n=num_samples) # points = a + (b - a) * samples[:, 0] # else: # points = _np.random.uniform(a, b, num_samples) # try: # # Evaluate function at sample points # values = _np.array([func(x) for x in points], dtype=float) # # Apply indicator function if provided # if indicator_func is not None: # indicator_values = _np.array([indicator_func(x) for x in points], dtype=_np.bool_) # # Estimate measure of the domain # measure = measure * _np.mean(indicator_values) # values = values * indicator_values # # Check for non-finite values # if not _np.all(_np.isfinite(values)): # raise ValueError("Function evaluations contain non-finite values.") # # Compute Monte Carlo estimate # integral = measure * _np.mean(values) # # Compute error estimate if requested # if return_error: # variance = _np.var(values) # error_estimate = _np.sqrt(variance / num_samples) * measure # return integral, error_estimate # return integral # except Exception as e: # raise GenericError(f"Error during integration: {str(e)}") # def dblebesgue(func: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]], # domain: Tuple[Tuple[float, float], Tuple[float, float]], # num_samples: int = 10000, # use_quasi_mc: bool = True, # indicator_func: Optional[Callable[[Tuple[float, float]], bool]] = None, # return_error: bool = False) -> Union[float, Tuple[float, float]]: # """ # Compute the 2D Lebesgue integral of a function over a rectangular domain using Monte Carlo or Quasi-Monte Carlo. # Parameters # ---------- # func : callable # The integrand function `f(x, y)`, which must be Lebesgue measurable. # domain : tuple # The integration domain `([a, b], [c, d])`. # num_samples : int, optional # Number of Monte Carlo samples. Defaults to `10000`. # use_quasi_mc : bool, optional # If `True`, uses Quasi-Monte Carlo (Sobol sequence) for sampling. Defaults to `True`. # indicator_func : callable, optional # A function that returns True if a point `(x, y)` is in the integration domain, False otherwise. # If `None`, the entire domain `([a, b], [c, d])` is used. Defaults to `None`. # return_error : bool, optional # If `True`, returns a tuple `(integral, error_estimate)`. Defaults to `False`. # Returns: # ---------- # float or tuple # The approximate value of the Lebesgue integral. If `return_error` is `True`, returns `(integral, error_estimate)`. # """ # from scipy.stats import qmc # # Input validation # if not callable(func): # raise TypeError("'func' must be callable.") # if not isinstance(domain, (tuple, list)) or len(domain) != 2: # raise TypeError("'domain' must be a tuple of two intervals ([a, b], [c, d]).") # (a, b), (c, d) = domain # if not all(isinstance(x, (int, float)) for x in [a, b, c, d]): # raise ValueError("'domain' bounds must be numeric.") # if a >= b or c >= d: # raise ValueError("'domain' intervals must satisfy a < b and c < d.") # if not isinstance(num_samples, int) or num_samples <= 0: # raise ValueError("'num_samples' must be a positive integer.") # if indicator_func is not None and not callable(indicator_func): # raise TypeError("'indicator_func' must be callable.") # # Compute the Lebesgue measure of the domain # measure = (b - a) * (d - c) # # Generate samples # if use_quasi_mc: # sampler = qmc.Sobol(d=2, scramble=True) # samples = sampler.random(n=num_samples) # x_points = a + (b - a) * samples[:, 0] # y_points = c + (d - c) * samples[:, 1] # else: # x_points = _np.random.uniform(a, b, num_samples) # y_points = _np.random.uniform(c, d, num_samples) # try: # # Evaluate function at sample points # values = _np.array([func(x, y) for x, y in zip(x_points, y_points)], dtype=float) # # Apply indicator function if provided # if indicator_func is not None: # indicator_values = _np.array([indicator_func((x, y)) for x, y in zip(x_points, y_points)], dtype=_np.bool_) # # Estimate measure of the domain # measure = measure * _np.mean(indicator_values) # values = values * indicator_values # # Check for non-finite values # if not _np.all(_np.isfinite(values)): # raise ValueError("Function evaluations contain non-finite values.") # # Compute Monte Carlo estimate # integral = measure * _np.mean(values) # # Compute error estimate if requested # if return_error: # variance = _np.var(values) # error_estimate = _np.sqrt(variance / num_samples) * measure # return integral, error_estimate # return integral # except Exception as e: # raise GenericError(f"Error during integration: {str(e)}") # def nlebesgue(func: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]], # domain: List[Tuple[float, float]], # num_samples: int = 10000, # use_quasi_mc: bool = True, # indicator_func: Optional[Callable[[List[float]], bool]] = None, # return_error: bool = False) -> Union[float, Tuple[float, float]]: # """ # Compute the n-dimensional Lebesgue integral of a function over a rectangular domain using Monte Carlo or Quasi-Monte Carlo. # Parameters # ---------- # func : callable # The integrand function `f(x_1, ..., x_n)`, which must be Lebesgue measurable. # domain : list # List of intervals `[(a_1, b_1), ..., (a_n, b_n)]` defining the domain. # num_samples : int, optional # Number of Monte Carlo samples. Defaults to `10000`. # use_quasi_mc : bool, optional # If `True`, uses Quasi-Monte Carlo (Sobol sequence) for sampling. Defaults to `True`. # indicator_func : callable, optional # A function that returns `True` if a point `(x_1, ..., x_n)` is in the integration domain, `False` otherwise. # If `None`, the entire domain is used. Defaults to `None`. # return_error : bool, optional # If `True`, returns a tuple `(integral, error_estimate)`. Defaults to `False`. # Returns: # ---------- # float or tuple # The approximate value of the Lebesgue integral. If `return_error` is `True`, returns `(integral, error_estimate)`. # """ # from scipy.stats import qmc # # Input validation # if not callable(func): # raise TypeError("'func' must be callable.") # if not isinstance(domain, (list, tuple)) or not domain: # raise TypeError("'domain' must be a non-empty list of intervals.") # if not all(isinstance(interval, (tuple, list)) and len(interval) == 2 for interval in domain): # raise ValueError("Each domain entry must be a tuple of two floats.") # if not all(isinstance(x, (int, float)) for interval in domain for x in interval): # raise ValueError("'domain' bounds must be numeric.") # if not all(a < b for a, b in domain): # raise ValueError("Each interval must satisfy a < b.") # if not isinstance(num_samples, int) or num_samples <= 0: # raise ValueError("'num_samples' must be a positive integer.") # if indicator_func is not None and not callable(indicator_func): # raise TypeError("'indicator_func' must be callable.") # # Compute the Lebesgue measure of the domain # measure = _np.prod([b - a for a, b in domain]) # # Generate samples # n_dims = len(domain) # if use_quasi_mc: # sampler = qmc.Sobol(d=n_dims, scramble=True) # samples = sampler.random(n=num_samples) # points = _np.array([domain[i][0] + (domain[i][1] - domain[i][0]) * samples[:, i] for i in range(n_dims)]).T # else: # points = _np.array([_np.random.uniform(a, b, num_samples) for a, b in domain]).T # try: # # Evaluate function at sample points # values = _np.array([func(point) for point in points], dtype=float) # # Apply indicator function if provided # if indicator_func is not None: # indicator_values = _np.array([indicator_func(point) for point in points], dtype=_np.bool_) # # Estimate measure of the domain # measure = measure * _np.mean(indicator_values) # values = values * indicator_values # # Check for non-finite values # if not _np.all(_np.isfinite(values)): # raise ValueError("Function evaluations contain non-finite values.") # # Compute Monte Carlo estimate # integral = measure * _np.mean(values) # # Compute error estimate if requested # if return_error: # variance = _np.var(values) # error_estimate = _np.sqrt(variance / num_samples) * measure # return integral, error_estimate # return integral # except Exception as e: # raise GenericError(f"Error during integration: {str(e)}")
[docs] def lagrange(f: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]], constraints: List[Callable[[ArrayLike], float]], x0: ArrayLike, tol: float = 1e-6) -> Tuple[ArrayLike, ArrayLike]: """ Solve a constrained optimization problem using Lagrange multipliers. Parameters ---------- f : callable The objective function `f`. Must return a scalar (float). constraints : list of callables List of constraint functions `[g_1(x), ..., g_m(x)]`, where each `g_i(x)` takes a 1D numpy array and returns a scalar (float). Each g_i(x) = 0 defines a constraint. x0 : array_like Initial guess for the solution, a 1D array of length `n` (number of variables). tol : float, optional Tolerance for the solver (used in `scipy.optimize.fsolve`). Defaults to `1e-6`. Returns ------- tuple of (ndarray, ndarray) - x_opt: The optimal point (1D array of length `n`). - lambda_opt: The Lagrange multipliers (1D array of length `m`, where `m` is the number of constraints). Notes ----- The solver may not converge for poorly conditioned problems or bad initial guesses. Examples -------- >>> from labtoolbox.numerical import lagrange >>> import numpy as np >>> # Minimize f(x, y) = x^2 + y^2 subject to x + y = 1 >>> f = lambda x: x[0]**2 + x[1]**2 >>> constraints = [lambda x: x[0] + x[1] - 1] >>> x0 = np.array([0.5, 0.5]) >>> x_opt, lambda_opt = lagrange(f, constraints, x0) >>> print(x_opt, lambda_opt) # Expected: x_opt ≈ [0.5, 0.5], lambda_opt ≈ [1.0] [0.5 0.5] [1.00000002] """ from scipy.optimize import fsolve, approx_fprime # Validate f if not callable(f): raise TypeError("'f' must be a callable function.") # Validate constraints if not isinstance(constraints, list) or not all(callable(g) for g in constraints): raise TypeError("'constraints' must be a list of callable functions.") if not constraints: raise ValueError("'constraints' list cannot be empty.") # Validate x0 x0 = _np.asarray(x0, dtype=float) if x0.ndim != 1 or not _np.all(_np.isfinite(x0)): raise ValueError("'x0' must be a 1D array of finite values.") # Validate tol if not isinstance(tol, (int, float)) or tol <= 0 or not _np.isfinite(tol): raise ValueError("'tol' must be a positive finite float.") n = len(x0) # Number of variables m = len(constraints) # Number of constraints def system(vars: ArrayLike) -> ArrayLike: """System of equations: ∇f = Σ λ_i ∇g_i, g_i = 0.""" x = vars[:n] # Variables lam = vars[n:] # Lagrange multipliers # Compute ∇f grad_f = approx_fprime(x, f, epsilon=1e-8) # Compute Σ λ_i ∇g_i grad_g_sum = _np.zeros(n) for i, g in enumerate(constraints): grad_g = approx_fprime(x, g, epsilon=1e-8) grad_g_sum += lam[i] * grad_g # Equations: ∇f - Σ λ_i ∇g_i = 0 eq1 = grad_f - grad_g_sum # Equations: g_i(x) = 0 eq2 = _np.array([g(x) for g in constraints]) return _np.concatenate([eq1, eq2]) # Initial guess: x0 for variables, zeros for multipliers initial_guess = _np.concatenate([x0, _np.zeros(m)]) try: # Solve the system solution = fsolve(system, initial_guess, xtol=tol) if not _np.all(_np.isfinite(solution)): raise ValueError("'scipy.optimize.fsolve' returned non-finite values.") x_opt = solution[:n] lambda_opt = solution[n:] # Verify constraints constraint_vals = _np.array([g(x_opt) for g in constraints]) if not _np.all(_np.abs(constraint_vals) < tol * 10): raise ValueError("Solution does not satisfy constraints within tolerance.") return x_opt, lambda_opt except Exception as e: raise GenericError( message=f"Error solving Lagrange multipliers: {str(e)}", context="executing lagrange", original_error=e, details={"x0_shape": x0.shape, "n_constraints": m} )