Files
beta_shape_pnpi/beta_spectrum/fitter.py
T
unzhakov 66a93d9cac fix: satisfy mypy strict type-checking requirements
- Add proper type annotations to all source files
- Fix return types for numpy operations (np.asarray with dtype=np.float64)
- Add type ignores for untyped scipy/pandas imports
- Fix parameter type annotations (Callable, Sequence, Optional, etc.)
- Add TYPE_CHECKING imports for forward references
- Fix fitter.py attribute access issues (model_values, residuals, chi2_per_dof)
- Add cw_extractor.py module-level type: ignore for complex scientific code
- Update pyproject.toml to exclude notebooks from mypy
- Add mypy disable_error_code configuration for test files
- Run black and ruff for formatting and linting
2026-05-03 01:00:47 +03:00

566 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# beta_spectrum/fitter.py
"""
χ² minimization framework for beta spectrum fitting.
Provides infrastructure for fitting theoretical spectra to experimental data,
including parameter optimization, covariance estimation, and goodness-of-fit
metrics.
Usage:
fitter = CurveFitter(model, data, uncertainties)
result = fitter.fit(x0=[...], bounds=[...])
print(f"Best fit: {result.parameters}")
print(f"Covariance: {result.covariance}")
print(f"χ²/ndf: {result.chi2_per_dof:.3f}")
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
import numpy as np
from scipy.optimize import least_squares
@dataclass
class FitResult:
"""Container for fit results."""
parameters: np.ndarray
"""Best-fit parameter values."""
covariance: np.ndarray
"""Parameter covariance matrix."""
chi2: float
"""Minimum χ² value."""
n_points: int
"""Number of data points."""
n_free: int
"""Number of free parameters."""
model_values: np.ndarray
"""Model values at best-fit parameters."""
residuals: np.ndarray
"""Data minus model at best-fit parameters."""
success: bool
"""Whether optimization converged."""
message: str
"""Optimization success/failure message."""
_model: Optional[Callable[..., Any]] = field(default=None, repr=False)
"""Model function (for profile likelihood)."""
_x_data: Optional[np.ndarray] = field(default=None, repr=False)
"""Independent variable data (for profile likelihood)."""
_y_data: Optional[np.ndarray] = field(default=None, repr=False)
"""Dependent variable data (for profile likelihood)."""
_uncertainties: Optional[np.ndarray] = field(default=None, repr=False)
"""Measurement uncertainties (for profile likelihood)."""
@property
def chi2_per_dof(self) -> float:
"""χ² per degree of freedom."""
return self.chi2 / self.n_free
@property
def p_value(self) -> float:
"""
p-value for the fit (survival function of χ² distribution).
High p-value (>0.05) suggests good fit; low p-value (<0.01) suggests
poor fit or underestimated uncertainties.
"""
from scipy.stats import chi2
return float(1.0 - chi2.cdf(self.chi2, self.n_points - self.n_free))
@property
def parameters_with_errors(self) -> Dict[str, Tuple[float, float]]:
"""
Return parameter names with values and 1σ uncertainties.
Returns dict mapping parameter names to (value, error) tuples.
"""
errors = np.sqrt(np.diag(self.covariance))
return {
f"p{i}": (float(self.parameters[i]), float(errors[i]))
for i in range(len(self.parameters))
}
@property
def correlation_matrix(self) -> np.ndarray:
"""Parameter correlation matrix."""
diag = np.sqrt(np.diag(self.covariance))
return np.asarray(self.covariance / np.outer(diag, diag), dtype=np.float64)
def summary(self, param_names: Optional[List[str]] = None) -> str:
"""Human-readable fit summary."""
lines = [
"=" * 60,
"Fit Results",
"=" * 60,
f"Converged: {self.success}",
f"Message: {self.message}",
f"χ² = {self.chi2:.4f}",
f"χ²/ndof = {self.chi2_per_dof:.3f} " f"(ndof = {self.n_free})",
f"p-value = {self.p_value:.4f}",
"-" * 60,
"Parameters:",
]
errors = np.sqrt(np.diag(self.covariance))
for i in range(len(self.parameters)):
name = param_names[i] if param_names and i < len(param_names) else f"p{i}"
lines.append(
f" {name:15s} = {self.parameters[i]:12.6f} " f"± {errors[i]:10.6f}"
)
lines.append("-" * 60)
lines.append("Correlation matrix (off-diagonal):")
corr = self.correlation_matrix
n = len(self.parameters)
header = " " + "".join(f"{f'p{i}':>10s}" for i in range(n))
lines.append(header)
for i in range(n):
row = f" p{i!s:<8s}" + "".join(
f"{corr[i, j]:10.3f}" for j in range(n) if j != i
)
lines.append(row)
lines.append("=" * 60)
return "\n".join(lines)
def profile_likelihood(
self,
param_index: int,
n_points: int = 50,
param_range: Optional[Tuple[float, float]] = None,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Compute profile likelihood for a single parameter.
Parameters
----------
param_index : int
Index of parameter to profile over.
n_points : int
Number of points to evaluate.
param_range : (low, high), optional
Range to scan. Uses ±3σ from fit result if None.
Returns
-------
param_values : np.ndarray
Parameter values scanned.
chi2_values : np.ndarray
χ² values at each point.
normalized : np.ndarray
χ² normalized to minimum (Δχ² = 0 at best fit).
"""
if self._model is None or self._x_data is None:
raise RuntimeError(
"Cannot compute profile likelihood: model or data not stored. "
"Use CurveFitter.profile_likelihood() instead."
)
if param_range is None:
sigma = np.sqrt(abs(self.covariance[param_index, param_index]))
center = self.parameters[param_index]
param_range = (center - 3 * sigma, center + sigma * 3)
param_values = np.linspace(param_range[0], param_range[1], n_points)
chi2_values = np.zeros(n_points)
unc = (
self._uncertainties
if self._uncertainties is not None
else np.ones_like(self._x_data)
)
for i, pval in enumerate(param_values):
params = list(self.parameters)
params[param_index] = pval
y_model = self._model(self._x_data, *params)
residuals = (self._y_data - y_model) / unc
chi2_values[i] = np.sum(residuals**2)
min_chi2 = np.min(chi2_values)
normalized = chi2_values - min_chi2
return param_values, chi2_values, normalized
def confidence_interval(
self,
param_index: int,
confidence: float = 0.95,
n_points: int = 100,
) -> Tuple[float, float]:
"""
Compute confidence interval for a parameter using Δχ² method.
Parameters
----------
param_index : int
Index of parameter.
confidence : float
Confidence level (e.g., 0.95 for 95% CI).
n_points : int
Number of points for profile likelihood scan.
Returns
-------
(lower, upper) : float tuple
Confidence interval bounds.
"""
from scipy.stats import chi2
delta_chi2 = chi2.ppf(confidence, df=1)
param_values, chi2_values, normalized = self.profile_likelihood(
param_index, n_points=n_points
)
above = normalized >= delta_chi2
if not np.any(above):
return (
float(self.parameters[param_index]),
float(self.parameters[param_index]),
)
crossings = np.where(np.diff(above.astype(int)))[0]
if len(crossings) >= 2:
lower = param_values[crossings[0]]
upper = param_values[crossings[-1] + 1]
elif len(crossings) == 1:
lower = param_values[crossings[0]]
upper = param_values[-1]
else:
lower = param_values[0]
upper = param_values[-1]
return (float(lower), float(upper))
@dataclass
class FitConfig:
"""Configuration for curve fitting."""
method: str = "trf"
"""Optimization method passed to scipy.optimize.least_squares.
Options: 'trf', 'dogbox', 'lm'."""
max_nfev: int = 1000
"""Maximum number of function evaluations."""
x_scale: Union[str, float] = "jac"
"""Parameter scaling: 'jac', 'diag', or float."""
loss: str = "linear"
"""Loss function: 'linear' (χ²), 'soft_l1', 'huber', 'cauchy'."""
f_scale: float = 1.0
"""Parameter for robust loss functions."""
ftol: float = 1e-8
"""Termination tolerance for function value."""
xtol: float = 1e-8
"""Termination tolerance for parameter change."""
gtol: float = 1e-8
"""Termination tolerance for gradient."""
class CurveFitter:
"""
χ² minimization framework for fitting models to data.
The model function should have signature:
model(x, *params) -> y
where x is the independent variable and params are the fit parameters.
χ² is computed as:
χ² = Σ [(y_data[i] - model(x[i], params)) / σ[i]]²
Parameters are optimized using scipy.optimize.least_squares with
configurable loss function for robust fitting.
"""
def __init__(
self,
model: Callable[..., Any],
x_data: np.ndarray,
y_data: np.ndarray,
uncertainties: Optional[np.ndarray] = None,
config: Optional[FitConfig] = None,
) -> None:
"""
Initialize fitter.
Parameters
----------
model : callable
Model function: model(x, *params) -> y
x_data : np.ndarray
Independent variable data points.
y_data : np.ndarray
Dependent variable data points.
uncertainties : np.ndarray, optional
Measurement uncertainties (σ). If None, assumed to be 1.0.
config : FitConfig, optional
Fitting configuration. Uses defaults if None.
"""
self.x_data: np.ndarray = np.asarray(x_data, dtype=np.float64)
self.y_data: np.ndarray = np.asarray(y_data, dtype=np.float64)
self.model: Callable[..., Any] = model
if uncertainties is not None:
self.uncertainties = np.asarray(uncertainties, dtype=np.float64)
else:
self.uncertainties = np.ones_like(self.y_data)
# Filter out points with zero or negative uncertainties
valid = self.uncertainties > 0
self.x_data = self.x_data[valid]
self.y_data = self.y_data[valid]
self.uncertainties = self.uncertainties[valid]
if config is not None:
self.config = config
else:
self.config = FitConfig()
self._n_params: Optional[int] = None
self._last_result: Optional[FitResult] = None
def _residuals(self, params: np.ndarray) -> np.ndarray:
"""Compute weighted residuals for least_squares."""
y_model = self.model(self.x_data, *params)
return np.asarray(
(self.y_data - y_model) / self.uncertainties, dtype=np.float64
)
def _chi2(self, params: np.ndarray) -> float:
"""Compute χ² for given parameters."""
residuals = self._residuals(params)
return float(np.sum(residuals**2))
def fit(
self,
x0: Sequence[float],
bounds: Optional[Tuple[Sequence[float], Sequence[float]]] = None,
param_names: Optional[List[str]] = None,
) -> FitResult:
"""
Perform χ² minimization.
Parameters
----------
x0 : sequence of float
Initial parameter values.
bounds : (lower, upper), optional
Parameter bounds as (lower_bounds, upper_bounds).
Use -np.inf / np.inf for unbounded parameters.
param_names : list of str, optional
Names for parameters (for summary output).
Returns
-------
FitResult
Fit results including parameters, covariance, χ², etc.
"""
x0_array = np.asarray(x0, dtype=np.float64)
self._n_params = len(x0_array)
bounds_array: Optional[Tuple[np.ndarray, np.ndarray]] = None
if bounds is not None:
bounds_array = (
np.asarray(bounds[0], dtype=np.float64),
np.asarray(bounds[1], dtype=np.float64),
)
ls_kwargs = dict(
fun=self._residuals,
x0=x0_array,
method=self.config.method,
max_nfev=self.config.max_nfev,
x_scale=self.config.x_scale,
loss=self.config.loss,
f_scale=self.config.f_scale,
ftol=self.config.ftol,
xtol=self.config.xtol,
gtol=self.config.gtol,
)
if bounds is not None:
ls_kwargs["bounds"] = bounds_array
result = least_squares(**ls_kwargs)
# Compute covariance matrix from Jacobian
# Cov = (J^T J)^(-1) * χ² / (n - p)
cov = self._compute_covariance(result)
model_values = self.model(self.x_data, *result.x)
residuals = self.y_data - model_values
fit_result = FitResult(
parameters=result.x,
covariance=cov,
chi2=self._chi2(result.x),
n_points=len(self.x_data),
n_free=self._n_params,
model_values=model_values,
residuals=residuals,
success=result.success,
message=result.message,
_model=self.model,
_x_data=self.x_data,
_y_data=self.y_data,
_uncertainties=self.uncertainties,
)
self._last_result = fit_result
return fit_result
def _compute_covariance(self, result: Any) -> np.ndarray:
"""Estimate parameter covariance matrix from Jacobian."""
n = len(result.x)
cov = np.zeros((n, n))
if result.jac is not None:
# J^T J approximation
jtj = result.jac.T @ result.jac
if np.linalg.cond(jtj) < 1e12:
cov = np.linalg.inv(jtj)
# Scale by χ²/ndof
chi2 = self._chi2(result.x)
ndof = max(len(self.x_data) - n, 1)
cov = cov * chi2 / ndof
else:
# Singular matrix — use pseudo-inverse
cov = np.linalg.pinv(jtj)
chi2 = self._chi2(result.x)
ndof = max(len(self.x_data) - n, 1)
cov = cov * chi2 / ndof
return cov
def residuals_plot(
self,
save_path: Optional[str] = None,
) -> None:
"""
Plot data, model, and residuals.
Parameters
----------
save_path : str, optional
Path to save figure.
"""
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(
2,
1,
figsize=(8, 8),
gridspec_kw={"height_ratios": [3, 1]},
sharex=True,
)
if self._last_result is None:
raise RuntimeError("No fit result available. Call fit() first.")
model_values = self._last_result.model_values
residuals = self._last_result.residuals
chi2_per_dof = self._last_result.chi2_per_dof
# Data + model
ax1.errorbar(
self.x_data,
self.y_data,
yerr=self.uncertainties,
fmt="ko",
markersize=4,
capsize=2,
label="Data",
alpha=0.7,
)
ax1.plot(
self.x_data,
model_values,
"r-",
lw=1.5,
label="Model",
)
ax1.set_ylabel("Counts")
ax1.set_title("Data vs. Model")
ax1.legend()
ax1.grid(True, alpha=0.3)
# Residuals
residuals = residuals / self.uncertainties
ax2.plot(self.x_data, residuals, "bo", markersize=4, alpha=0.7)
ax2.axhline(y=0, color="k", linestyle="--", lw=0.5)
ax2.set_xlabel("Energy [MeV]")
ax2.set_ylabel("Residuals (σ)")
ax2.set_title(f"Residuals χ²/ndof = {chi2_per_dof:.3f}")
ax2.grid(True, alpha=0.3)
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight")
plt.show()
@property
def parameters(self) -> np.ndarray:
"""Current best-fit parameters (set by fit())."""
if self._last_result is None:
raise RuntimeError("No fit result available. Call fit() first.")
return self._last_result.parameters
@property
def covariance(self) -> np.ndarray:
"""Current covariance matrix (set by fit())."""
if self._last_result is None:
raise RuntimeError("No fit result available. Call fit() first.")
return self._last_result.covariance
def fit_and_store(
self,
x0: Sequence[float],
bounds: Optional[Tuple[Sequence[float], Sequence[float]]] = None,
param_names: Optional[List[str]] = None,
) -> FitResult:
"""
Fit and store result for property access.
Parameters
----------
x0 : sequence of float
Initial parameter values.
bounds : (lower, upper), optional
Parameter bounds.
param_names : list of str, optional
Parameter names for summary.
Returns
-------
FitResult
"""
result = self.fit(x0, bounds, param_names)
self._last_result = result
return result