Skip to content

Commit 852d0f7

Browse files
feat: add polynomial least-squares fitting (#175)
* Add Polynomial Least-Squares Fitting * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent ffd17bb commit 852d0f7

12 files changed

Lines changed: 408 additions & 165 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Refer to the documentation to quickly integrate and utilize the library's signal
2020
| [Control Analysis](doc/control_analysis/README.md) | Frequency Response, Root Locus |
2121
| [Controllers](doc/controllers/README.md) | Bang-Bang/Hysteresis, PID, LQR, MPC, Saturation, Rate Limiter, Slew-Limited Saturation, Feedforward/2-DOF, Gain-Scheduled Controller |
2222
| [Dynamics](doc/dynamics/README.md) | Euler-Lagrange, Newton-Euler, Recursive Newton-Euler, ABA |
23-
| [Estimators](doc/estimators/README.md) | Linear Regression, Yule-Walker (offline), Recursive Least Squares (online) |
23+
| [Estimators](doc/estimators/README.md) | Linear Regression, Polynomial Fitting, Yule-Walker (offline), Recursive Least Squares (online) |
2424
| [Filters](doc/filters/README.md) | Kalman, Extended Kalman, Unscented Kalman, Alpha-Beta/Alpha-Beta-Gamma, FIR, IIR, Exponential Moving Average, Moving Average, Complementary, Median Filter |
2525
| [Kinematics](doc/kinematics/README.md) | Forward Kinematics |
2626
| [Neural Network](doc/neural_network/README.md) | Layers, activations, losses, model |

ROADMAP.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ Difficulty legend:
2727

2828
| # | Component | Target module | Difficulty |
2929
|----|------------------------------------------------------|---------------------------|------------|
30-
| 10 | Gain-scheduled controller | `controllers` | ★★☆☆☆ |
31-
| 12 | Polynomial least-squares curve fitting | `estimators/offline` | ★★☆☆☆ |
3230
| 13 | Goertzel algorithm | `analysis` | ★★☆☆☆ |
3331
| 14 | CIC (Cascaded Integrator-Comb) filter | `filters/passive` | ★★☆☆☆ |
3432
| 15 | Biquad / Second-Order-Section cascade | `filters/passive` | ★★★☆☆ |
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Polynomial Least-Squares Fitting
2+
3+
## Overview & Motivation
4+
5+
Sensor calibration curves, ADC linearization, thermistor transfer functions, and drift trends
6+
all require fitting a smooth curve to a discrete set of measured points. A degree-$d$ polynomial
7+
captures these behaviors with only $d+1$ coefficients, making evaluation at runtime a handful of
8+
multiply-adds rather than a table lookup or expensive transcendental.
9+
10+
The least-squares formulation finds the polynomial that minimizes the sum of squared residuals
11+
across all measurement samples. Unlike exact interpolation, it is robust to measurement noise:
12+
extra samples average out errors rather than being forced to pass through noisy points.
13+
14+
## Mathematical Theory
15+
16+
### The Model
17+
18+
Given $n$ scalar observations $\{(x_i, y_i)\}_{i=0}^{n-1}$, the degree-$d$ polynomial model is
19+
20+
$$p(x) = c_0 + c_1 x + c_2 x^2 + \cdots + c_d x^d$$
21+
22+
The goal is to find the coefficient vector $\mathbf{c} \in \mathbb{R}^{d+1}$ that minimizes
23+
24+
$$\min_{\mathbf{c}} \sum_{i=0}^{n-1} \bigl(y_i - p(x_i)\bigr)^2$$
25+
26+
### Vandermonde Design Matrix
27+
28+
Stacking the model evaluations at all sample abscissae gives the Vandermonde matrix
29+
30+
$$\mathbf{V} \in \mathbb{R}^{n \times (d+1)}, \quad V_{i,j} = x_i^j$$
31+
32+
The least-squares problem then becomes $\min_{\mathbf{c}} \|\mathbf{V}\mathbf{c} - \mathbf{y}\|^2$.
33+
34+
### Normal Equations
35+
36+
Setting the gradient of the squared residual with respect to $\mathbf{c}$ to zero yields
37+
38+
$$(\mathbf{V}^\top \mathbf{V})\,\mathbf{c} = \mathbf{V}^\top \mathbf{y}$$
39+
40+
The $(d+1)\times(d+1)$ matrix $\mathbf{V}^\top\mathbf{V}$ is symmetric and, when the abscissae are
41+
distinct and $n \geq d+1$, positive-definite. Its small size allows direct solution by Gaussian
42+
elimination or Cholesky factorization in bounded time on embedded hardware.
43+
44+
### Horner Evaluation
45+
46+
Once $\mathbf{c}$ is known, evaluating $p(x)$ at a new point uses Horner's method
47+
48+
$$p(x) = c_0 + x\bigl(c_1 + x\bigl(c_2 + \cdots + x\,c_d\bigr)\cdots\bigr)$$
49+
50+
This requires exactly $d$ multiplications and $d$ additions — optimal for a degree-$d$ polynomial.
51+
52+
## Complexity Analysis
53+
54+
| Phase | Time | Space | Notes |
55+
|----------------------------------|-------------|-----------|-----------------------------------|
56+
| Build $\mathbf{V}$ | $O(n\,d)$ | $O(n\,d)$ | Incremental powers, no `pow()` |
57+
| Form $\mathbf{V}^\top\mathbf{V}$ | $O(n\,d^2)$ | $O(d^2)$ | Symmetric, only upper half needed |
58+
| Form $\mathbf{V}^\top\mathbf{y}$ | $O(n\,d)$ | $O(d)$ | Matrix-vector product |
59+
| Solve $(d+1)\times(d+1)$ system | $O(d^3)$ | $O(d^2)$ | Gaussian elimination |
60+
| Predict (Horner) | $O(d)$ | $O(1)$ | One MAC per coefficient |
61+
62+
All dimensions are compile-time constants; no heap allocation is required.
63+
64+
## Step-by-Step Walkthrough
65+
66+
**Data:** $n = 4$ samples, $d = 2$ (quadratic fit).
67+
68+
| $x_i$ | $y_i$ |
69+
|-------|-------|
70+
| 0 | 1 |
71+
| 1 | 0.75 |
72+
| 2 | 1 |
73+
| 3 | 1.75 |
74+
75+
**Step 1 — Build $\mathbf{V}$:**
76+
77+
$$\mathbf{V} = \begin{bmatrix} 1 & 0 & 0 \\ 1 & 1 & 1 \\ 1 & 2 & 4 \\ 1 & 3 & 9 \end{bmatrix}$$
78+
79+
**Step 2 — Normal equations:**
80+
81+
$$\mathbf{V}^\top\mathbf{V} = \begin{bmatrix} 4 & 6 & 14 \\ 6 & 14 & 36 \\ 14 & 36 & 98 \end{bmatrix}, \qquad \mathbf{V}^\top\mathbf{y} = \begin{bmatrix} 4.5 \\ 7.25 \\ 19.75 \end{bmatrix}$$
82+
83+
**Step 3 — Solve:** Gaussian elimination → $\mathbf{c} \approx [1,\,-0.5,\,0.25]^\top$.
84+
85+
**Result:** $p(x) = 1 - 0.5\,x + 0.25\,x^2$.
86+
87+
**Prediction at $x = 1.5$:**
88+
89+
$$p(1.5) = 0.25\cdot1.5^2 - 0.5\cdot1.5 + 1 = 0.5625 - 0.75 + 1 = 0.8125$$
90+
91+
## Pitfalls & Edge Cases
92+
93+
- **Ill-conditioning of the Vandermonde system.** The condition number of $\mathbf{V}^\top\mathbf{V}$
94+
grows exponentially with $d$ and with the spread of abscissae. Center and scale the abscissa
95+
$x \leftarrow (x - \bar{x})/\sigma_x$ before fitting to reduce condition numbers by orders of
96+
magnitude. Recommended for $d \geq 3$ or when abscissae are far from the origin.
97+
98+
- **Degree selection.** Over-fitting occurs when $d$ is too large relative to $n$ or to the
99+
signal-to-noise ratio. Keep $d \leq 4$ for typical embedded calibration tasks.
100+
101+
- **Exactly $n = d+1$ points.** The normal equation system has a unique solution equal to the
102+
interpolating polynomial; the residual is zero. The system is well-posed only if all abscissae
103+
are distinct.
104+
105+
- **Repeated or nearly-coincident abscissae.** $\mathbf{V}^\top\mathbf{V}$ becomes singular or
106+
nearly so. Partial-pivoting in the Gaussian solver will flag this via `really_assert`; avoid
107+
duplicate $x$ values in practice.
108+
109+
- **Large degree with `float` arithmetic.** Powers $x^d$ for $|x| \gg 1$ can exceed the `float`
110+
dynamic range. Centering/scaling eliminates this risk.
111+
112+
## Variants & Generalizations
113+
114+
| Variant | Key Difference |
115+
|-----------------------------|------------------------------------------------------------------------------|
116+
| Orthogonal polynomial basis | Uses Legendre/Chebyshev basis instead of monomials; much better conditioning |
117+
| Weighted least squares | Each sample weighted differently (e.g., by measurement precision) |
118+
| Regularized (Ridge) fitting | Adds $\lambda\|\mathbf{c}\|^2$ to damp large coefficients |
119+
| Constrained fitting | Enforces derivative constraints at endpoints |
120+
| Savitzky-Golay smoothing | Sliding-window polynomial fit for real-time derivative estimation |
121+
122+
## Applications
123+
124+
- **Sensor linearization** — converting thermistor resistance or pressure-sensor ADC counts to
125+
engineering units via a quadratic or cubic polynomial.
126+
- **Drift and aging compensation** — fitting a polynomial to sampled drift data and subtracting
127+
the trend from future measurements.
128+
- **Compact lookup-table replacement** — replacing a 256-entry table with a degree-3 polynomial
129+
evaluated in four MACs.
130+
- **Calibration curve storage** — a handful of coefficients in flash replace a bulky lookup table.
131+
132+
## Connections to Other Algorithms
133+
134+
```mermaid
135+
graph LR
136+
PF["Polynomial Fitting"]
137+
GE["Gaussian Elimination"]
138+
LR["Linear Regression"]
139+
SG["Savitzky-Golay (planned)"]
140+
RLS["Recursive Least Squares"]
141+
142+
PF --> GE
143+
PF -.->|"polynomial features = special case"| LR
144+
SG -.->|"local polynomial fit per window"| PF
145+
RLS -.->|"online counterpart"| PF
146+
```
147+
148+
| Algorithm | Relationship |
149+
|-----------------------------------------------------------|------------------------------------------------------------------|
150+
| [Gaussian Elimination](../solvers/GaussianElimination.md) | Solves the normal equations |
151+
| [Linear Regression](LinearRegression.md) | Polynomial fitting is linear regression with polynomial features |
152+
| [Recursive Least Squares](RecursiveLeastSquares.md) | Online / streaming counterpart for time-varying models |
153+
154+
## References & Further Reading
155+
156+
- Press, W. H., Teukolsky, S. A., Vetterling, W. T. and Flannery, B. P., *Numerical Recipes in C*, 3rd ed., Cambridge University Press, 2007 — Chapter 15 (Modeling of Data).
157+
- Golub, G. H. and Van Loan, C. F., *Matrix Computations*, 4th ed., Johns Hopkins University Press, 2013 — Chapter 5 (orthogonal factorizations and least squares).
158+
- Hildebrand, F. B., *Introduction to Numerical Analysis*, 2nd ed., Dover, 1987 — Chapter 7 (least-squares approximation).

doc/estimators/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ Statistical estimation algorithms for fitting models to observed data and making
44

55
## Offline Estimators (Batch)
66

7-
| Algorithm | Description |
8-
|-------------------------------------------------------------------|-------------------------------------------------------------------------|
9-
| [Linear Regression](LinearRegression.md) | Ordinary least-squares regression using the normal equation |
10-
| [Yule-Walker](YuleWalker.md) | Autoregressive model parameter estimation via the Yule-Walker equations |
11-
| [Expectation-Maximization](ExpectationMaximization.md) | EM algorithm for Kalman filter parameter identification (Shumway-Stoffer) |
7+
| Algorithm | Description |
8+
|--------------------------------------------------------|---------------------------------------------------------------------------|
9+
| [Linear Regression](LinearRegression.md) | Ordinary least-squares regression using the normal equation |
10+
| [Polynomial Fitting](PolynomialFitting.md) | Degree-d polynomial fit via Vandermonde normal equations |
11+
| [Yule-Walker](YuleWalker.md) | Autoregressive model parameter estimation via the Yule-Walker equations |
12+
| [Expectation-Maximization](ExpectationMaximization.md) | EM algorithm for Kalman filter parameter identification (Shumway-Stoffer) |
1213

1314
## Online Estimators (Streaming)
1415

numerical/estimators/offline/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ target_link_libraries(numerical.estimators.offline ${NUMERICAL_VISIBILITY}
1515
target_sources(numerical.estimators.offline PRIVATE
1616
ExpectationMaximization.hpp
1717
LinearRegression.hpp
18+
PolynomialFitting.hpp
1819
YuleWalker.hpp
1920
)
2021

2122
numerical_add_coverage_sources(numerical.estimators.offline
2223
ExpectationMaximization.cpp
2324
LinearRegression.cpp
25+
PolynomialFitting.cpp
2426
YuleWalker.cpp
2527
)
2628

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#include "numerical/estimators/offline/PolynomialFitting.hpp"
2+
3+
namespace estimators
4+
{
5+
template class PolynomialFitting<float, 8, 2>;
6+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#pragma once
2+
3+
#if defined(__GNUC__) || defined(__clang__)
4+
#pragma GCC optimize("O3", "fast-math")
5+
#endif
6+
7+
#include "numerical/math/CompilerOptimizations.hpp"
8+
#include "numerical/math/Matrix.hpp"
9+
#include "numerical/solvers/GaussianElimination.hpp"
10+
#include <type_traits>
11+
12+
namespace estimators
13+
{
14+
template<typename T, std::size_t Samples, std::size_t Degree>
15+
class PolynomialFitting
16+
{
17+
static_assert(std::is_floating_point_v<T>, "PolynomialFitting supports floating-point types");
18+
static_assert(Samples >= Degree + 1, "Samples must be >= Degree + 1");
19+
20+
public:
21+
using CoefficientsVector = math::Matrix<T, Degree + 1, 1>;
22+
using SamplesVector = math::Matrix<T, Samples, 1>;
23+
24+
PolynomialFitting() = default;
25+
26+
OPTIMIZE_FOR_SPEED void Fit(const SamplesVector& x, const SamplesVector& y);
27+
T Predict(T xVal) const;
28+
const CoefficientsVector& Coefficients() const;
29+
30+
private:
31+
CoefficientsVector coefficients;
32+
};
33+
34+
template<typename T, std::size_t Samples, std::size_t Degree>
35+
OPTIMIZE_FOR_SPEED void PolynomialFitting<T, Samples, Degree>::Fit(const SamplesVector& x, const SamplesVector& y)
36+
{
37+
math::Matrix<T, Samples, Degree + 1> v;
38+
39+
for (std::size_t i = 0; i < Samples; ++i)
40+
{
41+
v.at(i, 0) = T{ 1 };
42+
for (std::size_t j = 1; j <= Degree; ++j)
43+
v.at(i, j) = v.at(i, j - 1) * x.at(i, 0);
44+
}
45+
46+
auto vt = v.Transpose();
47+
auto normalMatrix = vt * v;
48+
auto rhs = vt * y;
49+
50+
coefficients = solvers::SolveSystem<T, Degree + 1, 1>(normalMatrix, rhs);
51+
}
52+
53+
template<typename T, std::size_t Samples, std::size_t Degree>
54+
T PolynomialFitting<T, Samples, Degree>::Predict(T xVal) const
55+
{
56+
T acc = coefficients.at(Degree, 0);
57+
for (std::size_t j = Degree; j > 0; --j)
58+
acc = acc * xVal + coefficients.at(j - 1, 0);
59+
return acc;
60+
}
61+
62+
template<typename T, std::size_t Samples, std::size_t Degree>
63+
const typename PolynomialFitting<T, Samples, Degree>::CoefficientsVector&
64+
PolynomialFitting<T, Samples, Degree>::Coefficients() const
65+
{
66+
return coefficients;
67+
}
68+
69+
#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD
70+
extern template class PolynomialFitting<float, 8, 2>;
71+
#endif
72+
}

numerical/estimators/offline/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ target_link_libraries(numerical.estimators.offline_test PUBLIC
1010
target_sources(numerical.estimators.offline_test PRIVATE
1111
TestExpectationMaximization.cpp
1212
TestLinearRegression.cpp
13+
TestPolynomialFitting.cpp
1314
TestYuleWalker.cpp
1415
)

0 commit comments

Comments
 (0)