@@ -2,8 +2,10 @@ import nimib, nimibook
22import mpfit, ggplotnim
33
44nbInit (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
911This section will cover a curve fitting example. It assumes you are familiar with the
2931"""
3032
3133nbText: """
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= 1 e-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
34329nbSave
0 commit comments