"""Enhanced error messages with diagnostics and recommendations.
This module provides informative error messages that help users debug
optimization failures by providing diagnostics and actionable recommendations.
The main component is the :class:`OptimizationError` exception, which is raised
when curve fitting fails to converge. Unlike generic errors, it provides:
- **Diagnostics**: Final cost, gradient norm, iterations, function evaluations
- **Reasons**: Why the optimization failed (e.g., max iterations, gradient too large)
- **Recommendations**: Actionable suggestions to fix the issue
Examples
--------
The enhanced error messages are raised automatically when optimization fails:
>>> from nlsq import curve_fit
>>> import jax.numpy as jnp
>>> import numpy as np
>>>
>>> def difficult_func(x, a, b):
... return a * jnp.exp(b * x**2)
>>>
>>> xdata = np.linspace(0, 1, 10)
>>> ydata = difficult_func(xdata, 1, -5)
>>>
>>> try:
... popt, pcov = curve_fit(difficult_func, xdata, ydata, p0=[0.1, 0.1], max_nfev=5)
... except OptimizationError as e:
... print(e)
... # Prints detailed diagnostics and recommendations
... print(f"Reasons: {e.reasons}")
... print(f"Recommendations: {e.recommendations}")
See Also
--------
nlsq.minpack.curve_fit : Main curve fitting function
nlsq.parameter_estimation : Automatic parameter estimation
"""
from typing import Any
import numpy as np
[docs]
class OptimizationDiagnostics:
"""Collect and analyze optimization diagnostics.
Parameters
----------
result : OptimizeResult
Optimization result object
Attributes
----------
cost : float or None
Final cost function value
gradient_norm : float or None
Norm of final gradient
nfev : int
Number of function evaluations
nit : int
Number of iterations
"""
[docs]
def __init__(self, result):
self.result = result
self.cost = getattr(result, "cost", None)
self.gradient_norm = getattr(result, "grad", None)
self.nfev = getattr(result, "nfev", 0)
self.nit = getattr(result, "nit", 0)
[docs]
def analyze_failure(
result, gtol: float, ftol: float, xtol: float, max_nfev: int
) -> tuple[list[str], list[str]]:
"""Analyze why optimization failed and generate recommendations.
Parameters
----------
result : OptimizeResult
Optimization result object
gtol : float
Gradient tolerance
ftol : float
Function tolerance
xtol : float
Parameter tolerance
max_nfev : int
Maximum number of function evaluations
Returns
-------
reasons : list of str
Why the optimization failed
recommendations : list of str
What the user should try
"""
reasons = []
recommendations = []
# Check gradient convergence
if hasattr(result, "grad") and result.grad is not None:
grad = np.asarray(result.grad)
grad_norm = np.linalg.norm(grad, ord=np.inf)
if grad_norm > gtol:
reasons.append(
f"Gradient norm {grad_norm:.2e} exceeds tolerance {gtol:.2e}"
)
recommendations.append(
f"✓ Try looser gradient tolerance: gtol={gtol * 10:.1e}"
)
recommendations.append("✓ Check if initial guess p0 is reasonable")
recommendations.append("✓ Consider parameter scaling with x_scale")
# Check max iterations
if hasattr(result, "nfev") and result.nfev >= max_nfev:
reasons.append(f"Reached maximum function evaluations ({max_nfev})")
recommendations.append(f"✓ Increase iteration limit: max_nfev={max_nfev * 2}")
recommendations.append("✓ Provide better initial guess p0")
recommendations.append("✓ Try different optimization method (trf/dogbox/lm)")
# Check for numerical issues
if hasattr(result, "x") and result.x is not None:
x = np.asarray(result.x)
if not np.all(np.isfinite(x)):
reasons.append("NaN or Inf in solution parameters")
recommendations.append("⚠ Numerical instability detected")
recommendations.append("✓ Add parameter bounds to constrain search")
recommendations.append("✓ Scale parameters to similar magnitudes")
recommendations.append("✓ Check if model function is well-defined")
# Check cost function value
if hasattr(result, "cost") and result.cost is not None:
if not np.isfinite(result.cost):
reasons.append("Cost function is NaN or Inf")
recommendations.append("⚠ Model evaluation failed")
recommendations.append("✓ Check model function for domain errors")
recommendations.append("✓ Verify data doesn't contain NaN/Inf")
# Generic recommendations if unclear
if not recommendations:
recommendations.append("✓ Run with verbose=2 to see iteration details")
recommendations.append("✓ Check residual plot for systematic errors")
recommendations.append("✓ Verify model function matches data pattern")
recommendations.append(
"✓ Try robust loss function if outliers present (loss='soft_l1')"
)
return reasons, recommendations
[docs]
class OptimizationError(RuntimeError):
"""Enhanced optimization error with diagnostics and recommendations.
This exception provides detailed information about why an optimization
failed and actionable recommendations for fixing the issue.
Parameters
----------
result : OptimizeResult
Optimization result object
gtol : float
Gradient tolerance used
ftol : float
Function tolerance used
xtol : float
Parameter tolerance used
max_nfev : int
Maximum function evaluations used
Attributes
----------
result : OptimizeResult
The optimization result
reasons : list of str
Reasons for failure
recommendations : list of str
Recommended actions
diagnostics : dict
Diagnostic information
Examples
--------
The error is raised automatically by curve_fit when optimization fails:
>>> from nlsq import curve_fit
>>> from nlsq.utils.error_messages import OptimizationError
>>> import jax.numpy as jnp
>>> import numpy as np
>>>
>>> def exponential(x, a, b, c):
... return a * jnp.exp(-b * x) + c
>>>
>>> x = np.linspace(0, 5, 50)
>>> y = 3 * np.exp(-0.5 * x) + 1
>>>
>>> try:
... # Force failure with very low max_nfev
... popt, pcov = curve_fit(exponential, x, y, p0=[1, 1, 1], max_nfev=3)
... except OptimizationError as e:
... # Access error attributes programmatically
... if any("maximum" in r.lower() for r in e.reasons):
... # Auto-retry with higher max_nfev
... popt, pcov = curve_fit(exponential, x, y, p0=[1, 1, 1], max_nfev=200)
... print("Auto-retry succeeded!")
See Also
--------
analyze_failure : Function that analyzes why optimization failed
format_error_message : Function that formats the error message
"""
[docs]
def __init__(self, result, gtol: float, ftol: float, xtol: float, max_nfev: int):
self.result = result
# Analyze failure
reasons, recommendations = analyze_failure(result, gtol, ftol, xtol, max_nfev)
# Collect diagnostics
diagnostics = {}
if hasattr(result, "cost") and result.cost is not None:
diagnostics["Final cost"] = f"{result.cost:.6e}"
if hasattr(result, "grad") and result.grad is not None:
grad = np.asarray(result.grad)
grad_norm = np.linalg.norm(grad, ord=np.inf)
diagnostics["Gradient norm"] = f"{grad_norm:.6e}"
diagnostics["Gradient tolerance"] = f"{gtol:.6e}"
if hasattr(result, "nfev"):
diagnostics["Function evaluations"] = f"{result.nfev} / {max_nfev}"
if hasattr(result, "nit"):
diagnostics["Iterations"] = result.nit
if hasattr(result, "message") and result.message:
diagnostics["Status"] = result.message
# Format message
msg = format_error_message(reasons, recommendations, diagnostics)
super().__init__(msg)
self.reasons = reasons
self.recommendations = recommendations
self.diagnostics = diagnostics
[docs]
class ConvergenceWarning(UserWarning):
"""Warning for optimization that converged but may have issues.
This warning is raised when optimization technically converged but
there are potential quality issues (e.g., poor fit, covariance issues).
"""
[docs]
def check_convergence_quality(result, pcov) -> list[str]:
"""Check quality of converged solution and generate warnings.
Parameters
----------
result : OptimizeResult
Optimization result
pcov : ndarray or None
Parameter covariance matrix
Returns
-------
warnings : list of str
Warning messages about solution quality
"""
warnings = []
# Check covariance matrix
if pcov is None or np.any(np.isnan(pcov)) or np.any(np.isinf(pcov)):
warnings.append(
"⚠ Parameter covariance could not be estimated. "
"This may indicate:\n"
" - Parameters are at bounds\n"
" - Singular or ill-conditioned Jacobian\n"
" - Optimization converged to local minimum\n"
" Try: check bounds, improve p0, or use different method"
)
# Check for parameters at bounds
if hasattr(result, "x") and hasattr(result, "active_mask"):
active_mask = getattr(result, "active_mask", None)
if active_mask is not None:
at_bounds = np.any(active_mask != 0)
if at_bounds:
warnings.append(
"⚠ One or more parameters are at bounds. "
"Consider relaxing bounds or checking if model is appropriate."
)
# Check residuals if available
if hasattr(result, "fun") and result.fun is not None:
residuals = np.asarray(result.fun)
if np.any(np.abs(residuals) > 1e3):
warnings.append(
"⚠ Large residuals detected. "
"Model may not fit data well. Check:\n"
" - Is the model appropriate for this data?\n"
" - Are there outliers? (try robust loss function)\n"
" - Is data properly scaled?"
)
return warnings