An AutoGUI for Curve Fitting in Python
One of the most basic tasks in science and engineering is fitting a model
to some data. Doing so in Python is strait forward using curve_fit
from
scipy.optimize. In the same way seaborn builds on
matplotlib by creating a high-level interface to common statistical
graphics, we can expand on the curve fitting process by building a simple, high-level interface for
defining and visualizing these sorts of optimization problems.
Preface
Where is this coming from?
I’ve had a version of this working for years, I’m sorry to say. It started back when I was in graduate school at the University of Louisville. I was doing a thesis in Astrophysics where I was trying to fit Voigt profiles (kind of like a Gaussian) over a local polynomial continuum in stellar spectra. There was already software that sort of did this but I was learning Python and wanted to program it myself so that I could be sure of it’s implementation (which resulted in a version of this being included in a python package for astronomy I put on GitHub - SLiPy a few years back).
In the years that followed I found myself teaching an advanced physics laboratory course at the University of Notre Dame, and once more this sort of analysis was important. I had students learning scientific computing using tools like Matlab and Mathematica, and I even convinced a few to give Python a try. I knew what was possible with Python using numpy, scipy, matplotlib, pandas, etc. I even demonstrated this capability to many people. The issue was the heavy lift necessary to construct such a custom interface for every lab project.
I wanted to create something that could automatically generate a simple and visually modern graphical interface to fitting data that significantly lowered the barrier to entry for novice users – but something simple enough that they could read the code and appreciate how strait forward it is to build something like this in Python!
DataPhile
A Python package for data analysis and data processing
I’m going to have to write a whole blog post dedicated specifically to what in the world this thing is – DataPhile. I’ve been collecting some of my existing code that I carry with me from project to project into a Python package.
Everything for this functionality presented here was put into one place, dataphile.statistics.regression.modeling. This module contains four things:
Parameter
A structure that associates a value with an uncertainty and bounds.
m = Parameter(value=2, bounds=(1,3), label='slope')
b = Parameter(value=1, bounds=(0,2), label='intercept')
Model
Brings together an analytical function and it’s parameters.
def linear(x, *p):
return p[0] + p[1] * x
model = Model(linear, b, m)
model.fit(xdata, ydata)
CompositeModel
Superimpose several discrete models.
A = Parameter(value=1, bounds=(1/4, 2), label='amplitude')
x0 = Parameter(value=1, bounds=(0, 2), label='centroid')
sigma = Parameter(value=1/2, bounds=(1/4, 1), label='width')
def gaussian(x, *p):
return p[0] * np.exp(-0.5 * (x - p[1])**2 / p[2]**2)
model = CompositeModel(Model(linear, b, m),
Model(gaussian, A, x0, sigma),
label='gaussian_with_background')
model.fit(xdata, ydata)
AutoGUI
Given a subregion (bounding box) of an existing plot (figure and curve) and the model, create interactive widgets on that figure. Sliders for each parameter of a model, and if a CompositeModel, radio buttons to select between each component.
graph, = plot.step(xdata, ydata, 'k-')
gui = AutoGUI(model, [graph], bbox=[0.50, 0.10, 0.40, 0.20],
data=(xdata, ydata))
The Model and Parameter objects are an unnecessary abstraction other than to make things more explicit. Even the CompositeModel which is handy for combining a larger number of more basic models (because it’s hierarchical) is not really necessary.
Be that as it may, having this well defined structure with sophisticated interfaces makes the implementation of the AutoGUI very easy to understand. Disregarding the empty lines, comments, and docstrings, the whole things is only like 250 lines of code!
In a sense, this is almost the whole point of this effort. I don’t know of any other language that could put together this interface so easily.
Complete Example
Gaussian Peaks
Let’s reproduce the movie shown at the beginning of this post. We will need to create an example (i.e., synthetic) dataset and then model it.
In this example, let’s image an actual scientific context. Let’s say we have a detector that gives us a count of events across 2400 channels. This is in a sense a histogram. If we are measuring some physical quantity, create bins across some domain of the possible values and increment each bin anytime a new value comes in within its respective subdomain.
We’re going to overlay 24 Gaussian features on top of some underlying distribution. These features would in the real world be some signal we wanted to quantify. As in all cases, the instrument system (the detector and everything connected before and after it) is not perfect. So each channel is biased to be more or less sensitive to counting.
Create a base distribution of x,y values representing the underlying bias in a detector (a polynomial with a little bit of a downward curve just to be interesting).
from dataphile.datasets import SyntheticDataset
from dataphile.statistics.distributions import polynomial1D
xdata, ydata = SyntheticDataset(polynomial1D, [100, -0.01, -1e-5], (0, 2400), linspace=True,
noise=0, samples=2400).generate()
Over top of the underlying bias distribution, create 24 gaussian features with varying heights and widths and a 0.015 S/N. These will be superimposed on top of each other and the bias curve.
from dataphile.statistics.distributions import gaussian1D
import numpy as np
np.random.seed(33) # reproducibility
N = 24
A_s = np.random.uniform(50, 150, N)
x0_s = np.random.uniform(100, 2300, N)
sig_s = np.random.uniform(10, 20, N)
peaks = [SyntheticDataset(gaussian1D, [A, x0, sig], (0, 2400), linspace=True,
noise=0.015, samples=2400).generate()[1]
for A, x0, sig in zip(A_s, x0_s, sig_s)]
Keep the bias so we can look at individual peaks later on in the graph.
bias = ydata.copy()
ydata += sum(peaks)
Create both a plot of the entire dataset (marking the location of the features) and a larger plot of the extracted region which we will optimize a model against.
from matplotlib import pyplot as plot
from matplotlib import patches
plot.style.use('seaborn-notebook')
%matplotlib notebook
figure = plot.figure('Gaussian Peaks Demonstration with AutoGUI', figsize=(9, 5))
# select subregion of dataset for larger graph
xdata_i = xdata[xdata < 400]
ydata_i = ydata[xdata < 400]
# create main plot of data
ax_1 = figure.add_axes([0.15, 0.14, 0.84, 0.70])
data_graph, = ax_1.step(xdata_i, ydata_i, color='black', lw=1, label='data')
# labels
ax_1.set_ylabel('Counts', labelpad=15, fontweight='semibold')
ax_1.set_xlabel('Channel', labelpad=15, fontweight='semibold')
# create smaller graph of entire dataset
ax_2 = figure.add_axes([0.05, 0.63, 0.45, 0.34])
little_graph, = ax_2.step(xdata, ydata, color='black', lw=1)
# overlay small markers showing location of features
xloc, yloc = x0_s, [(bias + peak).max() + 25 for peak in peaks]
markers = ax_2.scatter(xloc, yloc, marker='v', color='steelblue')
# show zoom-rectangle around region of main plot
rectangle = patches.Rectangle((0, 50), 400, 250, color='black', alpha=0.25)
ax_2.add_patch(rectangle);
The idea is pretty strait forward - we have some number of features (blended no less) over a polynomial background. Our goal is to represent that analytical model in code so we can optimize it against the current dataset and to some degree of certainty measure quantities (e.g., location of peaks, widths, etc.).
In this case I’m going to explicitly code some reasonable guesses for each parameter, but more generally, one could programmatically/algorithmically take a stab at values (especially if this is something you’ll need to do frequently).
from dataphile.statistics.regression.modeling import Parameter, Model, CompositeModel, AutoGUI
model = CompositeModel(
Model(polynomial1D,
Parameter(value=100, bounds=(0, 200), label='scale'),
Parameter(value=0, bounds=(-0.1, 0.1), label='slope'),
Parameter(value=0, bounds=(5e-5, -5e-5), label='gradient'),
label='background'),
Model(gaussian1D,
Parameter(value=100, bounds=(10, 300), label='amplitude'),
Parameter(value=170, bounds=(100, 300), label='center'),
Parameter(value=10, bounds=(5, 20), label='width'),
label='feature_1'),
Model(gaussian1D,
Parameter(value=100, bounds=(10, 300), label='amplitude'),
Parameter(value=220, bounds=(100, 300), label='center'),
Parameter(value=10, bounds=(5, 20), label='width'),
label='feature_2'),
Model(gaussian1D,
Parameter(value=100, bounds=(10, 300), label='amplitude'),
Parameter(value=280, bounds=(100, 300), label='center'),
Parameter(value=10, bounds=(5, 20), label='width'),
label='feature_3'),
label='gaussian_peaks')
Overlay a graph of the model on the data.
xsample = np.linspace(0, 400, 1500)
model_graph, = ax_1.plot(xsample, model(xsample), color='steelblue', label='model')
ax_1.legend();
figure # display the graph again
Adjust the position of the plots (shift them up) to make room for the gui widgets.
ax_1.set_position([0.15, 0.30, 0.84, 0.56])
ax_2.set_position([0.05, 0.73, 0.45, 0.24])
gui = AutoGUI(model, [model_graph], bbox=[0.20, 0.07, 0.75, 0.12], figure=figure,
slider_options={'color': 'steelblue'}, data=(xdata_i, ydata_i));
And after applying the fit, display the resulting values.
model.summary()
value | uncertainty | ||
---|---|---|---|
model | parameter | ||
background | scale | 98.957184 | 1.244100 |
slope | 0.019271 | 0.019389 | |
gradient | -0.000077 | 0.000047 | |
feature_1 | amplitude | 92.716720 | 2.125195 |
center | 170.121278 | 0.284692 | |
width | 11.666650 | 0.324439 | |
feature_2 | amplitude | 72.637965 | 2.223527 |
center | 222.065818 | 0.354500 | |
width | 11.048723 | 0.394525 | |
feature_3 | amplitude | 177.272596 | 2.106137 |
center | 276.123253 | 0.146741 | |
width | 11.375918 | 0.163587 |
from IPython.display import display, Image
display(Image(filename='auto_gui_interactive.gif'))
This entire demonstration can be reproduced by calling GaussianPeaks along with two others (Linear and Sinusoidal) from the dataphile.demos.auto_gui module.
from dataphile.demos.auto_gui import Linear, Sinusoidal, GaussianPeaks
demo = GaussianPeaks()
Whatever your particular project is, whatever your domain, this could be used to create your own custom implementation. That is, if your data consistently looks a certain way, wrap all of this up in another function so that the next time you get a dataset, you can just call that function in your notebook without having to reproduce all of this every time.
Appendix
What was up with those super awesome sliders?! (this deserves it’s own blog post)
Matplotlib has some pretty awesome functionality using the widgets module. These days, the slider widget can seem a bit dated; with Jupyter Notebooks, web based widgets via something like ipywidgets are becoming popular because they look so great compared with the matplotlib aesthetic.
What I like about Matplotlib and it’s widgets though is that you have so much control over how and where they are constructed. You can also blend them more naturally into the figures you are already creating with matplotlib (and they work without the notebook!).
So about a year ago I was thinking about this and I had a rather clever idea, why not use matplotlib itself to create a widget?! That is, create my own aesthetic with colors and shapes, and manipulate those with the existing matplotlib slider widget (above) by laying it on top and rendering it invisible?!
So that’s what I did (available with dataphile.graphics.widgets.Slider). I mirrored the matplotlib Slider interface and created a second axis with a bottom line (for the “track”), a shorter line for the slide, and a scatter plot of a single point for the “knob”. When you call the on_changed function for this slider, it wraps the update function you pass with another one that also updates the plot elements drawn as a dummy slider.
It takes a minute to sort of appreciate this. The actual slider has had all its features made to be invisible. So when your mouse interacts with it, the underlying dummy features are made to update as if a puppet.