Fitting spectral line models to data in MASS
Joe Fowler, January 2020.
Mass uses use the LMFIT package by wrapping it in the Mass2 class GenericLineModel
and its subclasses.
LMFit vs Scipy
LMFIT has numerous advantages over the basic scipy.optimize
module. Quoting from the LMFIT documentation, the user can:
- forget about the order of variables and refer to Parameters by meaningful names.
- place bounds on Parameters as attributes, without worrying about preserving the order of arrays for variables and boundaries.
- fix Parameters, without having to rewrite the objective function.
- place algebraic constraints on Parameters.
The one disadvantage of the core LMFIT package for our purposes is that it minimizes the sum of squares of a vector instead of maximizing the Poisson likelihood. This is easily remedied, however, by replacing the usual computation of residuals with one that computes the square root of the Poisson likelihood contribution from each bin. Voilá! A maximum likelihood fitter for histograms.
Advantages of LMFIT over our earlier, homemade approach to fitting line shapes also include:
- The interface for setting upper/lower bounds on parameters and for varying or fixing them is much more elegant, memorable, and simple than our homemade version.
-
It ultimately wraps the
scipy.optimize
package and therefore inherits all of its advantages: -
a choice of over a dozen optimizers with highly technical documentation,
- some optimizers that aim for true global (not just local) optimization, and
- the countless expert-years that have been invested in perfecting it.
- LMFIT automatically computes numerous statistics of each fit including estimated uncertainties, correlations, and multiple quality-of-fit statistics (information criteria as well as chi-square) and offers user-friendly fit reports. See the
MinimizerResult
object. - It's the work of Matt Newville, an x-ray scientist responsible for the excellent
ifeffit
and its successorLarch
. - Above all, its documentation is complete, already written, and maintained by not-us.
Usage guide
This overview is hardly complete, but we hope it can be a quick-start guide and also hint at how you can convert your own analysis work from the old to the new, preferred fitting methods.
The underlying spectral line shape models
Objects of the type SpectralLine
encode the line shape of a fluorescence line, as a sum of Voigt or Lorentzian distributions. Because they inherit from scipy.stats.rv_continuous
, they allow computation of cumulative distribution functions and the simulation of data drawn from the distribution. An example of the creation and usage is:
# mkdocs: render
import numpy as np
import pylab as plt
import mass2
import mass2.materials
# Known lines are accessed by:
line = mass2.spectra["MnKAlpha"]
rng = np.random.default_rng(1066)
N = 100000
energies = line.rvs(size=N, instrument_gaussian_fwhm=2.2, rng=rng) # draw from the distribution
plt.clf()
sim, bin_edges, _ = plt.hist(energies, 120, range=[5865, 5925], histtype="step", lw=2);
binsize = bin_edges[1] - bin_edges[0]
e = bin_edges[:-1] + 0.5*binsize
plt.plot(e, line(e, instrument_gaussian_fwhm=2.2)*N*binsize, "k", lw=0.5)
plt.xlabel("Energy (eV)")
plt.title("Mn K$\\alpha$ random deviates and theory curve")
The SpectralLine
object is useful to you if you need to generate simulated data, or to plot a line shape, as shown above. The objects that perform line fitting use the SpectralLine
object to hold line shape information. You don't need to create a SpectralLine
object for fitting, though; it will be done automatically.
How to use the LMFIT-based models for fitting
The simplest case of line fitting requires only 3 steps: create a model instance from a SpectralLine
, guess its parameters from the data, and perform a fit with this guess. Plotting is not done as part of the fit--you have to do that separately.
# mkdocs: render
model = line.model()
params = model.guess(sim, bin_centers=e, dph_de=1)
resultA = model.fit(sim, params, bin_centers=e)
# Fit again but with dPH/dE held at 1.
# dPH/dE will be a free parameter for the fit by default, largely due to the history of MnKAlpha fits being so critical during development.
# This will not work for nearly monochromatic lines, however, as the resolution (fwhm) and scale (dph_de) are exactly degenerate.
# In practice, most fits are done with dph_de fixed.
params = resultA.params.copy()
resultB = model.fit(sim, params, bin_centers=e, dph_de=1)
params["dph_de"].set(1.0, vary=False)
# There are two plotting methods. The first is an LMfit built-in; the other ("mass-style") puts the
# fit parameters on the plot.
resultB.plot()
resultB.plotm()
# The best-fit params are found in resultB.params
# and a dictionary of their values is resultB.best_values.
# The parameters given as an argument to fit are unchanged.
You can print a nicely formatted fit report with the method fit_report()
:
print(resultB.fit_report())
[[Model]]
GenericLineModel(MnKAlpha)
[[Fit Statistics]]
# fitting method = least_squares
# function evals = 15
# data points = 120
# variables = 4
chi-square = 100.565947
reduced chi-square = 0.86694782
Akaike info crit = -13.2013653
Bayesian info crit = -2.05139830
R-squared = 0.99999953
[[Variables]]
fwhm: 2.21558094 +/- 0.02687437 (1.21%) (init = 2.217155)
peak_ph: 5898.79525 +/- 0.00789761 (0.00%) (init = 5898.794)
dph_de: 1 (fixed)
integral: 99986.5425 +/- 314.455266 (0.31%) (init = 99985.8)
background: 5.0098e-16 +/- 0.80578112 (160842446370819488.00%) (init = 2.791565e-09)
bg_slope: 0 (fixed)
[[Correlations]] (unreported correlations are < 0.100)
C(integral, background) = -0.3147
C(fwhm, peak_ph) = -0.1121
Fitting with exponential tails (to low or high energy)
Notice when you report the fit (or check the contents of the params
or resultB.params
objects), there are no parameters referring to exponential tails of a Bortels response. That's because the default fitter assumes a Gaussian response. If you want tails, that's a constructor argument:
# mkdocs: render
model = line.model(has_tails=True)
params = model.guess(sim, bin_centers=e, dph_de=1)
params["dph_de"].set(1.0, vary=False)
resultC = model.fit(sim, params, bin_centers=e)
resultC.plot()
# print(resultC.fit_report())
By default, the has_tails=True
will set up a non-zero low-energy tail and allow it to vary, while the high-energy tail is set to zero amplitude and doesn't vary. Use these numbered examples if you want to fit for a high-energy tail (1), to fix the low-E tail at some non-zero level (2) or to turn off the low-E tail completely (3):
# 1. To let the low-E and high-E tail both vary simultaneously
params["tail_share_hi"].set(.1, vary=True)
params["tail_tau_hi"].set(30, vary=True)
# 2. To fix the sum of low-E and high-E tail at a 10% level, with low-E tau=30 eV, but
# the share of the low vs high tail can vary
params["tail_frac"].set(.1, vary=False)
params["tail_tau"].set(30, vary=False)
# 3. To turn off low-E tail
params["tail_frac"].set(.1, vary=True)
params["tail_share_hi"].set(1, vary=False)
params["tail_tau"].set(vary=False)
Fitting with a quantum efficiency model
If you want to multiply the line models by a model of the quantum efficiency, you can do that. You need a qemodel
function or callable function object that takes an energy (scalar or vector) and returns the corresponding efficiency. For example, you can use the "Raven1 2019" QE model from mass2.materials
. The filter-stack models are not terribly fast to run, so it's best to compute once, spline the results, and pass that spline as the qemodel
to line.model(qemodel=qemodel)
.
# mkdocs: render
raven_filters = mass2.materials.efficiency_models.filterstack_models["RAVEN1 2019"]
eknots = np.linspace(100, 20000, 1991)
qevalues = raven_filters(eknots)
qemodel = mass2.mathstat.interpolate.CubicSpline(eknots, qevalues)
model = line.model(qemodel=qemodel)
resultD = model.fit(sim, resultB.params, bin_centers=e)
resultD.plotm()
# print(resultD.fit_report())
fit_counts = resultD.params["integral"].value
localqe = qemodel(mass2.STANDARD_FEATURES["MnKAlpha"])
fit_observed = fit_counts*localqe
fit_err = resultD.params["integral"].stderr
count_err = fit_err*localqe
print("Fit finds {:.0f}±{:.0f} counts before QE, or {:.0f}±{:.0f} observed. True value {:d}.".format(
round(fit_counts, -1), round(fit_err, -1), round(fit_observed, -1), round(count_err, -1), N))
Fit finds 168810±530 counts before QE, or 100020±320 observed. True value 100000.
When you fit with a non-trivial QE model, all fit parameters that refer to intensities of signal or background refer to a sensor with an ideal QE=1. These parameters include:
integral
background
bg_slope
That is, the fit values must be multiplied by the local QE to give the number of observed signal counts, background counts per bin, or background slope. With or without a QE model, "integral" refers to the number of photons that would be seen across all energies (not just in the range being fit).
Fitting a simple Gaussian, Lorentzian, or Voigt function
# mkdocs: render
import dataclasses
from mass2.calibration.fluorescence_lines import SpectralLine
e_ctr = 1000.0
Nsig = 10000
Nbg = 1000
sigma = 1.0
x_gauss = rng.standard_normal(Nsig)*sigma + e_ctr
hwhm = 1.0
x_lorentz = rng.standard_cauchy(Nsig)*hwhm + e_ctr
x_voigt = rng.standard_cauchy(Nsig)*hwhm + rng.standard_normal(Nsig)*sigma + e_ctr
bg = rng.uniform(e_ctr-5, e_ctr+5, size=Nbg)
# Gaussian fit
c, b = np.histogram(np.hstack([x_gauss, bg]), 50, [e_ctr-5, e_ctr+5])
bin_ctr = b[:-1] + (b[1]-b[0]) * 0.5
line = SpectralLine.quick_monochromatic_line("testline", e_ctr, 0, 0)
line = dataclasses.replace(line, linetype="Gaussian")
model = line.model()
params = model.guess(c, bin_centers=bin_ctr, dph_de=1)
params["fwhm"].set(2.3548*sigma)
params["background"].set(Nbg/len(c))
resultG = model.fit(c, params, bin_centers=bin_ctr)
resultG.plotm()
# print(resultG.fit_report())
# mkdocs: render
# Lorentzian fit
c, b = np.histogram(np.hstack([x_lorentz, bg]), 50, [e_ctr-5, e_ctr+5])
bin_ctr = b[:-1] + (b[1]-b[0]) * 0.5
line = SpectralLine.quick_monochromatic_line("testline", e_ctr, hwhm*2, 0)
line = dataclasses.replace(line, linetype="Lorentzian")
model = line.model()
params = model.guess(c, bin_centers=bin_ctr, dph_de=1)
params["fwhm"].set(2.3548*sigma)
params["background"].set(Nbg/len(c))
resultL = model.fit(c, params, bin_centers=bin_ctr)
resultL.plotm()
# print(resultL.fit_report())
# mkdocs: render
# Voigt fit
c, b = np.histogram(np.hstack([x_voigt, bg]), 50, [e_ctr-5, e_ctr+5])
bin_ctr = b[:-1] + (b[1]-b[0]) * 0.5
line = SpectralLine.quick_monochromatic_line("testline", e_ctr, hwhm*2, sigma)
line = dataclasses.replace(line, linetype="Voigt")
model = line.model()
params = model.guess(c, bin_centers=bin_ctr, dph_de=1)
params["fwhm"].set(2.3548*sigma)
params["background"].set(Nbg/len(c))
resultV = model.fit(c, params, bin_centers=bin_ctr)
resultV.plotm()
# print(resultV.fit_report())
More details of fitting fluorescence-line models
- Use
p=model.guess(data, bin_centers=e, dph_de=dph_de)
to create a heuristic for the starting parameters. - Change starting values and toggle the
vary
attribute on parameters, as needed. For example:p["dph_de"].set(1.0, vary=False)
- Use
result=model.fit(data, p, bin_centers=e)
to perform the fit and store the result. - The result holds many attributes and methods (see
MinimizerResult
for full documentation). These include:result.params
= the model's best-fit parameters objectresult.best_values
= a dictionary of the best-fit parameter valuesresult.best_fit
= the model's y-values at the best-fit parameter valuesresult.chisqr
= the chi-squared statistic of the fit (here, -2log(L))result.covar
= the computed covarianceresult.fit_report()
= return a pretty-printed string reporting on the fitresult.plot_fit()
= make a plot of the data and fitresult.plot_residuals()
= make a plot of the residuals (fit-data)result.plotm()
= make a plot of the data, fit, and fit params with dataset filename in titleresult.plot()
= make a plot of the data, fit, and residuals, generallyplotm
is preferred
The tau values (scale lengths of exponential tails) are in eV units. The parameter "integral" refers to the integrated number of counts across all energies (whether inside or beyond the fitted energy range).