Notebook originel: minimization.ipynb

Minimization🔗

Illustrate chi2-minimization procedures using different librairies.

[1]:
import numpy as np
import matplotlib.pyplot as plt
[2]:
%matplotlib inline

Generative model & Dataset🔗

[3]:
def model(x, a, b, c):
    return a + b * (x - c)**2
[4]:
n = 21
pmod = a, b, c = 1, 2, 0  # true values
p0 = 0, 1, 0              # initial guess
[5]:
rng = np.random.default_rng(seed=1)  # Random Number Generator with a fixed seed (for reproducibility)

data_x = np.linspace(-1, 1, n)
data_y = model(data_x, *pmod)             # Noiseless model

data_yerr = rng.lognormal(mean=-1, sigma=0.5, size=n)  # Log-normal distributed errors
data_y += data_yerr * rng.normal(size=n)               # Add Gaussian noise
[6]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"Model")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"Model: {pmod}");
../_images/Cours_minimization_7_0.png

scipy.optimize.curve_fit🔗

scipy.optimize.curve_fit is the standard minimization procedure of scipy. It is easy to use, but not necessarily the most functionnal nor the more robust.

NB: See curve_fit_utils for some useful addition tools.

[7]:
from scipy.optimize import curve_fit
from scipy.stats import distributions
[8]:
pfit, pcov, infodict, msg, ier = curve_fit(
    model,             # model(x, *params)
    data_x, data_y,    # data x and y
    p0=p0,             # initial guesses
    sigma=data_yerr, absolute_sigma=True,  # absolute (not relative) error on y
    full_output=True)  # output additional infodict, msg, ier
print(f"Status {ier}: {msg}")  # ALWAYS check the fit status
if ier in [1, 2, 3, 4]:  # Successful fit
    perr = np.diag(pcov)**0.5  # Diagonal errors on parameters
    print(f"Results: {pfit} ± {perr}")
else:
    print(f"Fit did not converge [{ier}]: {msg}")
Status 1: Both actual and predicted relative reductions in the sum of squares
  are at most 0.000000
Results: [ 1.09715969  1.55898978 -0.02976324] ± [0.11931261 0.24954372 0.04049687]
[9]:
chi2 = np.sum(((data_y - model(data_x, *pfit)) / data_yerr)**2)  # Final chi2
ndof = len(data_x) - len(pfit)                                   # Degrees of Freedom
pvalue = distributions.chi2.sf(chi2, ndof)  # p-value of the final chi2 / DoF
zscore = distributions.norm.isf(pvalue)     # equivalent z-score
[10]:
print(f"χ²/DoF = {chi2:.2f}/{ndof}, p-value={pvalue:.3g} ({zscore:.1f} σ)")
χ²/DoF = 25.76/18, p-value=0.105 (1.3 σ)
[11]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"{pmod}")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
ax.plot(data_x, model(data_x, *pfit),
        label=f"{np.round(pfit, 2)} ± {np.round(perr, 2)}")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"χ²/DoF={chi2:.2f}/{ndof} ({zscore:.1f} σ)");
../_images/Cours_minimization_13_0.png

astropy.modeling🔗

astropy.modeling offers some convenient model-building tools, but is based on standard scipy minimization routines.

[12]:
from astropy import modeling, table, __version__
print("astropy version:", __version__)
astropy version: 5.2.2
[13]:
@modeling.models.custom_model  # Convert model to astropy
def amodel(x, a=0, b=0, c=0):
    return model(x, a, b, c)
[14]:
fitter = modeling.fitting.LevMarLSQFitter()  # Levenberg-Marquardt least-squares
fit = fitter(amodel(), data_x, data_y, weights=1/data_yerr)  # chi2 (weighted lsq)
[15]:
# Parameter errors (https://github.com/astropy/astropy/issues/7202)
errors = amodel().copy()
modeling.fitting.fitter_to_model_params(errors, np.diag(fitter.fit_info['param_cov']**0.5))
/tmp/ipykernel_615550/446977705.py:3: RuntimeWarning: invalid value encountered in sqrt
  modeling.fitting.fitter_to_model_params(errors, np.diag(fitter.fit_info['param_cov']**0.5))
[16]:
table.Table([fit.param_names, fit.parameters, errors.parameters],
             names=['Names', 'Value', 'Error'])
[16]:
Table length=3
NamesValueError
str1float64float64
a1.09715969409518620.14273697695016538
b1.55898977906951040.2985360115620135
c-0.0297632421232764470.048447521510153586
[17]:
chi2 = np.sum(((data_y - fit(data_x))/data_yerr)**2)
ndof = len(data_x) - len(fit.parameters)
pvalue = distributions.chi2.sf(chi2, ndof)
zscore = distributions.norm.isf(pvalue)
[18]:
print(f"χ²/DoF = {chi2:.2f}/{ndof}, p-value={pvalue:.3g} ({zscore:.1f} σ)")
χ²/DoF = 25.76/18, p-value=0.105 (1.3 σ)
[19]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"{pmod}")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
ax.plot(data_x, fit(data_x),
        label=f"a={np.round(fit.parameters, 2)}±{np.round(errors.parameters, 2)}")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"χ²/DoF={chi2:.2f}/{ndof:.0f} ({zscore:.1f} σ)");
../_images/Cours_minimization_22_0.png

iminuit🔗

iminuit is a jupyter-friendly Python interface to the Minuit2 C++ library used in particle physics. It has more functionalities than scipy, and is generally more robust.

[20]:
from iminuit import Minuit, __version__
print("iminuit version:", __version__)
from iminuit.cost import LeastSquares  # standard LSQ
import jacobi  # Fast numerical derivatives (uncertainty propagation)
print("jacobi version:", jacobi.__version__)
iminuit version: 2.21.3
jacobi version: 0.8.1
[21]:
chi2fn = LeastSquares(data_x, data_y, data_yerr, model)  # chi2 objective function
m = Minuit(chi2fn, a=pmod[0], b=pmod[1], c=pmod[2])      # initial guess
m.migrad()  # finds minimum of chi2fn function
m.hesse()   # accurately computes uncertainties
[21]:
Migrad
FCN = 25.76 (χ²/ndof = 1.4) Nfcn = 86
EDM = 2.8e-07 (Goal: 0.0002)
Valid Minimum No Parameters at limit
Below EDM threshold (goal x 10) Below call limit
Covariance Hesse ok Accurate Pos. def. Not forced
Name Value Hesse Error Minos Error- Minos Error+ Limit- Limit+ Fixed
0 a 1.10 0.12
1 b 1.56 0.25
2 c -0.03 0.04
a b c
a 0.0142 -0.023 (-0.773) -0.0004 (-0.093)
b -0.023 (-0.773) 0.0623 0.0023 (0.228)
c -0.0004 (-0.093) 0.0023 (0.228) 0.00164
../_images/Cours_minimization_25_1.png
[22]:
for param in m.parameters:
    print(f"{param}: {m.values[param]:+.3f} ± {m.errors[param]:.3f}")
a: +1.097 ± 0.119
b: +1.559 ± 0.250
c: -0.030 ± 0.040
[23]:
pvalue = distributions.chi2.sf(m.fval, m.ndof)
zscore = distributions.norm.isf(pvalue)
[24]:
print(f"χ²/DoF = {m.fval:.2f}/{m.ndof}, p-value={pvalue:.3g} ({zscore:.1f} σ)")
χ²/DoF = 25.76/18.0, p-value=0.105 (1.3 σ)
[25]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"{pmod}")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
ax.plot(data_x, model(data_x, *m.values),
        label=f"a={np.round(m.values, 2)}±{np.round(m.errors, 2)}")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"χ²/DoF={m.fval:.2f}/{m.ndof:.0f} ({zscore:.1f} σ)");
../_images/Cours_minimization_29_0.png

Confidence interval🔗

[26]:
fig, axs = m.draw_mnmatrix(cl=[1, 2, 3])  # CI at 1, 2, 3 sigmas
# Add initial (true) parameters
axs[1][0].plot([m.init_params['a'].value], [m.init_params['b'].value], 'k*')
axs[2][0].plot([m.init_params['a'].value], [m.init_params['c'].value], 'k*')
axs[2][1].plot([m.init_params['b'].value], [m.init_params['c'].value], 'k*');
../_images/Cours_minimization_31_1.png

Uncertainty propagation🔗

Propagate uncertainties (and correlations) on parameters to uncertainties on model using 1st-order (linear) propagation (with jacobi library).

NB: see also luprox based on JAX.

[27]:
%%timeit

y, ycov = jacobi.propagate(lambda p: model(data_x, *p), m.values, m.covariance)
yerr = np.diag(ycov)**0.5
3.19 ms ± 640 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
[28]:
y, ycov = jacobi.propagate(lambda p: model(data_x, *p), m.values, m.covariance)
yerr_p = np.diag(ycov)**0.5  # Errors on model
[29]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"Model {pmod}")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
l, = ax.plot(data_x, model(data_x, *m.values),
             label=f"Fit {np.round(m.values, 2)}±{np.round(m.errors, 2)}")
ax.fill_between(data_x, y - yerr_p, y + yerr_p,
                facecolor=l.get_color(), alpha=0.5, label="Uncertainty")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"χ²/DoF={m.fval:.2f}/{m.ndof:.0f}");
../_images/Cours_minimization_35_0.png

Bootstrap uncertainties🔗

Estimate uncertainties on model using bootstrapping (random sampling of parameters).

[30]:
%%timeit

p_boot = rng.multivariate_normal(m.values, m.covariance, size=500)
# standard deviation of bootstrapped curves
yerr = np.std([ model(data_x, *p) for p in p_boot ], axis=0)
2.18 ms ± 82.7 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
[31]:
p_boot = rng.multivariate_normal(m.values, m.covariance, size=500)
# standard deviation of bootstrapped curves
yerr_b = np.std([ model(data_x, *p) for p in p_boot ], axis=0)
[32]:
fix, ax = plt.subplots()
ax.plot(data_x, model(data_x, *pmod), 'k:', label=f"Model {pmod}")
ax.errorbar(data_x, data_y, data_yerr, fmt="o", label="Observations")
l, = ax.plot(data_x, model(data_x, *m.values),
             label=f"Fit {np.round(m.values, 2)}±{np.round(m.errors, 2)}")
ax.fill_between(data_x, y - yerr_b, y + yerr_b,
                facecolor=l.get_color(), alpha=0.5, label="Uncertainty")
ax.legend()
ax.set(xlabel="x", ylabel="y", title=f"χ²/DoF={m.fval:.2f}/{m.ndof:.0f}");
../_images/Cours_minimization_39_0.png
[33]:
fix, ax = plt.subplots()
ax.plot(data_x, yerr_p, label="Error prop.")
ax.plot(data_x, yerr_b, label="Bootstrap")
ax.set(xlabel="x", ylabel="Model error", title="Comparison of uncertainties")
ax.legend();
../_images/Cours_minimization_40_0.png
[ ]:

Cette page a été générée à partir de minimization.ipynb.