Skip to content

Commit 86b6e6b

Browse files
Merge pull request #51 from SciNim/docNumericalnim
Numericalnim tutorials
2 parents b3a355d + c69d547 commit 86b6e6b

File tree

7 files changed

+981
-2
lines changed

7 files changed

+981
-2
lines changed

book/numerical_methods/curve_fitting.nim

Lines changed: 296 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import nimib, nimibook
22
import mpfit, ggplotnim
33

44
nbInit(theme = useNimibook)
5+
nb.useLatex
56

6-
nbText: """
7+
# Vindaar's part:
8+
#[ nbText: """
79
# Curve fitting using [mpfit](https://github.com/Vindaar/nim-mpfit)
810
911
This section will cover a curve fitting example. It assumes you are familiar with the
@@ -29,6 +31,299 @@ yes.
2931
"""
3032
3133
nbText: """
34+
""" ]#
35+
36+
nbText: hlMd"""
37+
## Curve fitting using `numericalnim`
38+
[Curve fitting](https://en.wikipedia.org/wiki/Curve_fitting) is the task of finding the "best" parameters for a given function,
39+
such that it minimizes the error on the given data. The simplest example being,
40+
finding the slope and intersection of a line $y = ax + b$ using such that it minimizes the squared errors
41+
between the data points and the line.
42+
[numericalnim](https://github.com/SciNim/numericalnim) implements the [Levenberg-Marquardt algorithm](https://en.wikipedia.org/wiki/Levenberg%E2%80%93Marquardt_algorithm)(`levmarq`)
43+
for solving non-linear least squares problems. One such problem is curve fitting, which aims to find the parameters that (locally) minimizes
44+
the error between the data and the fitted curve.
45+
46+
The required imports are:
47+
"""
48+
49+
nbCode:
50+
import numericalnim, ggplotnim, arraymancer, std / [math, sequtils]
51+
52+
import std / random
53+
randomize(1337)
54+
55+
nbText: hlMd"""
56+
In some cases you know the actual form of the function you want to fit,
57+
but in other cases you may have to guess and try multiple different ones.
58+
In this tutorial we will assume we know the form but it works the same regardless.
59+
The test curve we will sample points from is
60+
$$f(t) = \alpha + \sin(\beta t + \gamma) e^{-\delta t}$$
61+
with $\alpha = 0.5, \beta = 6, \gamma = 0.1, \delta = 1$. This will be a decaying sine wave with an offset.
62+
We will add a bit of noise to it as well:
63+
"""
64+
65+
nbCode:
66+
proc f(alpha, beta, gamma, delta, t: float): float =
67+
alpha + sin(beta * t + gamma) * exp(-delta*t)
68+
69+
let
70+
alpha = 0.5
71+
beta = 6.0
72+
gamma = 0.1
73+
delta = 1.0
74+
let t = arraymancer.linspace(0.0, 3.0, 20)
75+
let yClean = t.map_inline:
76+
f(alpha, beta, gamma, delta, x)
77+
let noise = 0.025
78+
let y = yClean + randomNormalTensor(t.shape[0], 0.0, noise)
79+
80+
let tDense = arraymancer.linspace(0.0, 3.0, 200)
81+
let yDense = tDense.map_inline:
82+
f(alpha, beta, gamma, delta, x)
83+
84+
block:
85+
let df = toDf(t, y, tDense, yDense)
86+
df.ggplot(aes("t", "y")) +
87+
geom_point() +
88+
geom_line(aes("tDense", "yDense")) +
89+
ggsave("images/levmarq_rawdata.png")
90+
91+
nbImage("images/levmarq_rawdata.png")
92+
93+
nbText: hlMd"""
94+
Here we have the original function along with the sampled points with noise.
95+
Now we have to create a proc that `levmarq` expects. Specifically it
96+
wants all the parameters in a `Tensor` instead of by themselves:
97+
"""
98+
99+
nbCode:
100+
proc fitFunc(params: Tensor[float], t: float): float =
101+
let alpha = params[0]
102+
let beta = params[1]
103+
let gamma = params[2]
104+
let delta = params[3]
105+
result = f(alpha, beta, gamma, delta, t)
106+
107+
nbText: hlMd"""
108+
As we can see, all the parameters that we want to fit, are passed in as
109+
a single 1D Tensor that we unpack for clarity here. The only other thing
110+
that is needed is an initial guess of the parameters. We will set them to all 1 here:
111+
"""
112+
113+
nbCode:
114+
let initialGuess = ones[float](4)
115+
116+
nbText: "Now we are ready to do the actual fitting:"
117+
118+
nbCodeInBlock:
119+
let solution = levmarq(fitFunc, initialGuess, t, y)
120+
echo solution
121+
122+
nbText: hlMd"""
123+
As we can see, the found parameters are very close to the actual ones. But maybe we can do better,
124+
`levmarq` accepts an `options` parameter which is the same as the one described in [Optimization](./optimization.html)
125+
with the addition of the `lambda0` parameter. We can reduce the `tol` and see if we get an even better fit:
126+
"""
127+
128+
nbCode:
129+
let options = levmarqOptions(tol=1e-10)
130+
let solution = levmarq(fitFunc, initialGuess, t, y, options=options)
131+
echo solution
132+
133+
nbText: hlMd"""
134+
As we can see, there isn't really any difference. So we can conclude that the
135+
found solution has in fact converged.
136+
137+
Here's a plot comparing the fitted and original function:
138+
"""
139+
140+
block:
141+
let ySol = tDense.map_inline:
142+
fitFunc(solution, x)
143+
144+
var df = toDf({"t": tDense, "original": yDense, "fitted": ySol, "tPoint": t, "yPoint": y})
145+
df = df.gather(@["original", "fitted"], key="Class", value = "y")
146+
df.ggplot(aes("t", "y", color="Class")) +
147+
geom_line() +
148+
geom_point(aes("tPoint", "yPoint")) +
149+
ggsave("images/levmarq_comparision.png")
150+
151+
nbImage("images/levmarq_comparision.png")
152+
153+
nbText: hlMd"""
154+
As we can see, the fitted curve is quite close to the original one.
155+
156+
## Errors & Uncertainties
157+
We might also want to quantify exactly how good of a fit the computed weights give.
158+
One measure commonly used is [$\chi^2$](https://en.m.wikipedia.org/wiki/Pearson%27s_chi-squared_test):
159+
$$\chi^2 = \sum_i \frac{(y_i - \hat{y}(x_i))^2}{\sigma_i^2}$$
160+
where $y_i$ and $x_i$ are the measurements, $\hat{y}$ is the fitted curve and $\sigma_i$
161+
is the standard deviation (uncertainty/error) of each of the measurements. We will use the
162+
noise size we used when generating the samples here, but in general you will have to
163+
figure out yourself what errors are in your situation. It may for example be the resolution
164+
of your measurements or you may have to approximate it using the data itself.
165+
166+
We now create the error vector and sample the fitted curve:
167+
"""
168+
169+
nbCode:
170+
let yError = ones_like(y) * noise
171+
let yCurve = t.map_inline:
172+
fitFunc(solution, x)
173+
174+
nbText: """
175+
Now we can calculate the $\chi^2$ using numericalnim's `chi2` proc:
176+
"""
177+
178+
nbCode:
179+
let chi = chi2(y, yCurve, yError)
180+
echo "χ² = ", chi
181+
182+
183+
#[ nbCodeInBlock:
184+
var chi2 = 0.0
185+
for i in 0 ..< y.len:
186+
chi2 += ((y[i] - yCurve[i]) / yError[i]) ^ 2
187+
echo "χ² = ", chi2 ]#
188+
189+
nbText: hlMd"""
190+
Great! Now we have a measure of how good the fit is, but what if we add more points?
191+
Then we will get a better fit, but we will also get more points to sum over.
192+
And what if we choose another curve to fit with more parameters?
193+
Then we may be able to get a better fit but we risk overfitting.
194+
[Reduced $\chi^2$](https://en.m.wikipedia.org/wiki/Reduced_chi-squared_statistic)
195+
is a measure which adjusts $\chi^2$ to take these factors into account.
196+
The formula is:
197+
$$\chi^2_{\nu} = \frac{\chi^2}{n_{\text{obs}} - m_{\text{params}}}$$
198+
where $n_{\text{obs}}$ is the number of observations and $m_{\text{params}}$
199+
is the number of parameters in the curve. The difference between them is denoted
200+
the degrees of freedom (dof).
201+
This is simplified, the mean $\chi^2$
202+
score adjusted to penalize too complex curves.
203+
204+
Let's calculate it!
205+
"""
206+
207+
nbCode:
208+
let reducedChi = chi / (y.len - solution.len).float
209+
echo "Reduced χ² = ", reducedChi
210+
211+
nbText: hlMd"""
212+
As a rule of thumb, values around 1 are desirable. If it is much larger
213+
than 1, it indicates a bad fit. And if it is much smaller than 1 it means
214+
that the fit is much better than the uncertainties suggested. This could
215+
either mean that it has overfitted or that the errors were overestimated.
216+
217+
### Parameter uncertainties
218+
To find the uncertainties of the fitted parameters, we have to calculate
219+
the covariance matrix:
220+
$$\Sigma = \sigma^2 H^{-1}$$
221+
where $\sigma^2$ is the standard deviation of the residuals and $H$ is
222+
the Hessian of the objective function (we used $\chi^2$).
223+
`numericalnim` can compute the covariance matrix for us using `paramUncertainties`:
224+
"""
225+
226+
nbCode:
227+
let cov = paramUncertainties(solution, fitFunc, t, y, yError, returnFullCov=true)
228+
echo cov
229+
230+
nbText: """
231+
That is the full covariance matrix, but we are only interested in the diagonal elements.
232+
By default `returnFullCov` is false and then we get a 1D Tensor with the diagonal instead:
233+
"""
234+
235+
nbCode:
236+
let variances = paramUncertainties(solution, fitFunc, t, y, yError)
237+
echo variances
238+
239+
nbText: """
240+
It's important to note that these values are the **variances** of the parameters.
241+
So if we want the standard deviations we will have to take the square root:
242+
"""
243+
244+
nbCode:
245+
let paramUncertainty = sqrt(variances)
246+
echo "Uncertainties: ", paramUncertainty
247+
248+
nbText: """
249+
All in all, these are the values and uncertainties we got for each of the parameters:
250+
"""
251+
252+
nbCode:
253+
echo "α = ", solution[0], " ± ", paramUncertainty[0]
254+
echo "β = ", solution[1], " ± ", paramUncertainty[1]
255+
echo "γ = ", solution[2], " ± ", paramUncertainty[2]
256+
echo "δ = ", solution[3], " ± ", paramUncertainty[3]
257+
258+
259+
#[ nbCode:
260+
proc objectiveFunc(params: Tensor[float]): float =
261+
let yCurve = t.map_inline:
262+
fitFunc(params, x)
263+
result = chi2(y, yCurve, yError) #sum(((y - yCurve) /. yError) ^. 2)
264+
265+
nbText: hlMd"""
266+
Now we approximate $\sigma^2$ by the reduced $\chi^2$ at the fitted parameters:
267+
"""
268+
269+
nbCode:
270+
let sigma2 = objectiveFunc(solution) / (y.len - solution.len).float
271+
echo "σ² = ", sigma2
272+
273+
nbText: """
274+
The Hessian at the solution can be calculated numerically using `tensorHessian`:
275+
"""
276+
277+
nbCode:
278+
let H = tensorHessian(objectiveFunc, solution)
279+
echo "H = ", H
280+
281+
nbText: "Now we calculate the covariance matrix as described above:"
282+
283+
nbCode:
284+
proc eye(n: int): Tensor[float] =
285+
result = zeros[float](n, n)
286+
for i in 0 ..< n:
287+
result[i,i] = 1
288+
289+
proc inv(t: Tensor[float]): Tensor[float] =
290+
result = solve(t, eye(t.shape[0]))
291+
292+
let cov = sigma2 * H.inv()
293+
echo "Σ = ", cov
294+
295+
nbText: """
296+
The diagonal elements of the covariance matrix is the uncertainty (variance) in our parameters,
297+
so we take the square root of them to get the standard deviation:
298+
"""
299+
300+
nbCode:
301+
proc getDiag(t: Tensor[float]): Tensor[float] =
302+
let n = t.shape[0]
303+
result = newTensor[float](n)
304+
for i in 0 ..< n:
305+
result[i] = t[i,i]
306+
307+
let paramUncertainty = sqrt(cov.getDiag())
308+
echo "Uncertainties: ", paramUncertainty
309+
310+
nbCodeInBlock:
311+
let uncertainties = sqrt(paramUncertainties(solution, fitFunc, y, t, yError))
312+
echo "Uncertainties: ", uncertainties
313+
314+
nbText: """
315+
All in all, these are the values and uncertainties we got for each of the parameters:
316+
"""
317+
318+
nbCode:
319+
echo "α = ", solution[0], " ± ", paramUncertainty[0]
320+
echo "β = ", solution[1], " ± ", paramUncertainty[1]
321+
echo "γ = ", solution[2], " ± ", paramUncertainty[2]
322+
echo "δ = ", solution[3], " ± ", paramUncertainty[3] ]#
323+
324+
nbText: hlMd"""
325+
## Further reading
326+
- [numericalnim's documentation on optimization](https://scinim.github.io/numericalnim/numericalnim/optimize.html)
32327
"""
33328

34329
nbSave

book/numerical_methods/integration1d.nim

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,9 @@ only `cumtrapz` and `cumsimpson` are available and that you pass in `y` and `x`
345345

346346
nbImage("images/discreteHumpsComparaision.png")
347347

348+
nbText: hlMd"""
349+
## Further reading
350+
- [numericalnim's documentation on integration](https://scinim.github.io/numericalnim/numericalnim/integrate.html)
351+
"""
352+
348353
nbSave()

0 commit comments

Comments
 (0)