Case Study 2: Building a Least-Squares Curve Fitter
"Fit the model you can defend, with the numerics you can trust."
Executive Summary
Fitting a polynomial to data is one of the most common jobs in a scientist's day: you have measurements and
you want the smooth curve that best explains them. This study builds a small, reusable polynomial-fitting
module from the pieces this chapter gave you — the transpose and matmul intrinsics to assemble the
normal equations, and dgesv to solve them — wraps it behind a clean polyfit(x, y, deg, coef)
interface, and then verifies it the only honest way: by handing it data sampled from a polynomial we
already know and checking that it recovers the coefficients exactly. Finally it delivers the warning that
separates a toolbox routine from a trap: the normal-equations approach we build is the readable one and the
numerically weakest one, because it squares the condition number of the problem — so we end by showing when
to reach past dgesv for the least-squares driver dgels or the SVD instead. You will finish with a working
fitter and, more importantly, with the judgment to know its limits.
Skills applied: designing a reusable numerical routine with a clean interface; assembling the Vandermonde
matrix and normal equations with transpose/matmul (§21.1); solving with dgesv and checking info
(§21.3); the leading dimension for allocatable matrices (§21.3); the conditioning cost of the normal
equations (§21.4 and Chapter 20); choosing dgels/SVD for
robustness (§21.4).
Background
You are given $m$ data points $(x_i, y_i)$ and want the degree-$d$ polynomial $p(x) = c_1 + c_2 x + \dots + c_{d+1} x^{d}$ whose values come closest, in the least-squares sense, to the $y_i$. Stack the "design" (Vandermonde) matrix $A$, whose $i$-th row is $[1,\ x_i,\ x_i^2,\ \dots,\ x_i^d]$, so that $A\mathbf{c} \approx \mathbf{y}$ is an overdetermined system — more equations ($m$) than unknowns ($d+1$). The least-squares solution is the $\mathbf{c}$ minimizing $\lVert A\mathbf{c} - \mathbf{y}\rVert^2$, and the classic route to it is the normal equations
$$ A^{\mathsf{T}} A\,\mathbf{c} = A^{\mathsf{T}}\mathbf{y}, $$
a square $(d{+}1)\times(d{+}1)$ system you can hand straight to dgesv. Everything you need is in this
chapter: transpose and matmul build $A^{\mathsf{T}}A$ and $A^{\mathsf{T}}\mathbf{y}$, and dgesv solves.
Phase 1 — Design the Interface
Before writing a line of arithmetic, decide what the routine promises. A good fitter hides all the linear algebra and exposes only the data, the degree, and the answer:
! polyfit(x, y, deg, coef, info)
! x, y : the data points (length m)
! deg : polynomial degree; coef has deg+1 entries
! coef : OUTPUT, the coefficients [c0, c1, ..., c_deg]
! info : OUTPUT, 0 on success (passed straight through from dgesv)
Passing info out rather than stopping inside is a deliberate design choice: a library routine reports
failure and lets the caller decide what to do, exactly as dgesv reports to us. The routine allocates its
own workspace from deg, so the caller never sees a Vandermonde matrix or a pivot array.
Phase 2 — Assemble the Normal Equations
The Vandermonde matrix has one column per power of $x$: column $j$ is $x^{\,j-1}$, which a whole-array
exponentiation writes in one line. Then $A^{\mathsf{T}}A$ and $A^{\mathsf{T}}\mathbf{y}$ are two matmuls
with a transpose — the §21.1 intrinsics doing exactly the linear algebra they are for:
do j = 1, ncoef
vand(:, j) = x**(j - 1) ! columns: 1, x, x^2, ... (x**0 = 1)
end do
ata = matmul(transpose(vand), vand) ! (d+1)x(d+1) normal-equations matrix
aty = matmul(transpose(vand), y) ! (d+1) right-hand side
Note the shapes line up because transpose(vand) is $(d{+}1)\times m$ and vand is $m\times(d{+}1)$, so
their product is the square $(d{+}1)\times(d{+}1)$ that dgesv wants. This is why matrix–matrix intrinsics
earn their place: the alternative is a double loop of accumulations you would have to index by hand.
Phase 3 — Build the Module and Solve
Here is the complete, self-contained fitter. The leading dimension passed to dgesv is ncoef, the declared
first dimension of the square ata — the point from §21.3 in a routine with allocatable arrays:
module polyfit_mod
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
contains
subroutine polyfit(x, y, deg, coef, info)
real(dp), intent(in) :: x(:), y(:)
integer, intent(in) :: deg
real(dp), intent(out) :: coef(deg+1)
integer, intent(out) :: info
integer :: ncoef, j
real(dp), allocatable :: vand(:,:), ata(:,:), aty(:)
integer, allocatable :: ipiv(:)
ncoef = deg + 1
allocate(vand(size(x), ncoef), ata(ncoef, ncoef), aty(ncoef), ipiv(ncoef))
do j = 1, ncoef
vand(:, j) = x**(j - 1)
end do
ata = matmul(transpose(vand), vand)
aty = matmul(transpose(vand), y)
call dgesv(ncoef, 1, ata, ncoef, ipiv, aty, ncoef, info) ! aty <- coefficients
coef = aty
end subroutine polyfit
end module polyfit_mod
program fit_demo
use, intrinsic :: iso_fortran_env, only: dp => real64
use polyfit_mod
implicit none
real(dp) :: x(4) = [ -1.0_dp, 0.0_dp, 1.0_dp, 2.0_dp ]
real(dp) :: y(4) = [ 0.0_dp, 2.0_dp, 6.0_dp, 12.0_dp ] ! sampled from 2 + 3x + x^2
real(dp) :: coef(3)
integer :: info
call polyfit(x, y, 2, coef, info)
if (info /= 0) then
print '(a, i0)', 'fit failed, info = ', info
error stop 1
end if
print '(a)', 'fitted coefficients [c0, c1, c2]:'
print '(f8.4)', coef
end program fit_demo
$ gfortran -std=f2018 -Wall polyfit.f90 -o fit -llapack -lblas && ./fit
fitted coefficients [c0, c1, c2]:
2.0000
3.0000
1.0000
(The module precedes the program in the single source file, so gfortran compiles them in the right order;
the driver uses polyfit_mod.)
Phase 4 — Verify Against a Known Answer
A fitter that returns numbers is not a fitter you trust; a fitter that recovers a polynomial you chose is. The data was sampled exactly from $p(x) = 2 + 3x + x^2$: at $x = -1, 0, 1, 2$ that gives $y = 0, 2, 6, 12$ (check $x=2$: $2 + 6 + 4 = 12$). Because the points lie exactly on a quadratic, the least-squares fit is exact, and the coefficients must come back as $(2, 3, 1)$ — which they do. Work the normal equations by hand to see the machinery is right. The Vandermonde columns give the sums
$$ A^{\mathsf{T}}A = \begin{bmatrix} 4 & 2 & 6 \\ 2 & 6 & 8 \\ 6 & 8 & 18 \end{bmatrix}, \qquad A^{\mathsf{T}}\mathbf{y} = \begin{bmatrix} 20 \\ 30 \\ 54 \end{bmatrix}, $$
where, for instance, the top-left $4$ is $\sum 1 = 4$, the $6$ in the corner is $\sum x^2 = 1+0+1+4 = 6$, and the last right-hand entry is $\sum x^2 y = 0 + 0 + 6 + 48 = 54$. Substituting $\mathbf{c} = (2, 3, 1)$ into the first row: $4\cdot2 + 2\cdot3 + 6\cdot1 = 8 + 6 + 6 = 20$ — matches. The routine reproduces the polynomial it was fed, so the assembly and the solve are both correct. This "recover a known model" check is the unit test every fitting routine should ship with.
Phase 5 — The Catch: Normal Equations Square the Conditioning
The routine is correct, readable, and — for high degrees or clustered data — the least accurate way to fit.
The reason is exactly the conditioning idea from Chapter 20, sharpened
by a fact about the normal equations: forming $A^{\mathsf{T}}A$ squares the condition number. If the
design matrix $A$ has condition number $\kappa(A)$, then $\kappa(A^{\mathsf{T}}A) \approx \kappa(A)^2$. A
Vandermonde matrix on a wide range of $x$ values is already ill-conditioned — its columns $1, x, x^2, \dots$
grow nearly parallel — so a degree-8 fit whose $A$ has $\kappa(A) \approx 10^{6}$ hands dgesv a normal-equations
matrix with $\kappa \approx 10^{12}$, and in double precision (about 16 digits) you have thrown away twelve
of them before the solve begins. The fit still returns numbers; they are just increasingly meaningless as the
degree climbs.
The professional fix is to not form the normal equations at all. LAPACK's dgels solves the
least-squares problem directly from $A$ by a QR factorization, and the SVD routine dgesvd (§21.4) does
it even more robustly and tells you, through the singular values, exactly how rank-deficient the fit is.
Both work on the design matrix $A$ itself, so they see $\kappa(A)$, not $\kappa(A)^2$ — the difference between
six trustworthy digits and none. Our dgesv-on-normal-equations fitter is the right tool for low degrees and
well-spread data, where it is simplest and perfectly accurate; the moment the degree grows or the fit looks
unstable, you swap the solver — the same "call the right specialist" judgment §21.6 made for sparse matrices,
now for least squares.
| Approach | What it factors | Conditioning it sees | Use when |
|---|---|---|---|
Normal equations + dgesv (this build) |
$A^{\mathsf{T}}A$ | $\kappa(A)^2$ | low degree, well-spread data; simplicity matters |
QR + dgels |
$A$ directly | $\kappa(A)$ | the default robust choice for least squares |
SVD + dgesvd |
$A$ directly | $\kappa(A)$, and reports rank | ill-conditioned or rank-deficient fits; you need diagnostics |
Discussion Questions
- The build allocates
vand,ata,aty, andipivfromdegon every call. For a routine called thousands of times in a loop, what would you change, and what doesintent(out)oncoefalready guarantee about aliasing that makes the change safe? - We verified the fitter by recovering a polynomial from exact samples. Why is that a stronger test than fitting real noisy data and eyeballing the curve — and what property of the least-squares solution makes the exact-recovery test pass to the last digit?
dgelsavoids squaring the condition number but needs a workspace query (likedsyev) and returns the solution in the firstnentries of the right-hand-side array. Sketch how thepolyfitinterface would change if you swappeddgesvfordgels, and what would not change for the caller.
Your Turn: Extensions
- Option A. Add a residual and goodness-of-fit report: after solving, evaluate the fitted polynomial at
each
x(Horner's rule, or a second Vandermondematmul), and print the maximum residual $\max_i |p(x_i) - y_i|$. On the exact-quadratic data it should be at the rounding floor; verify that. - Option B. Push the degree until it breaks. Fit degree 2, 4, 6, 8 to points sampled from a known high-degree polynomial on $x \in [0, 10]$, and watch the recovered coefficients drift from the true ones as the normal-equations conditioning degrades. Where does double precision give out?
- Option C. Rebuild
polyfitondgelsinstead of the normal equations (workspace query and all), and repeat Option B. Show that the QR-based fit holds accuracy several degrees further — the payoff of seeing $\kappa(A)$ instead of $\kappa(A)^2$.
Key Takeaways
- A least-squares polynomial fit is
transpose+matmulto build the normal equations and onedgesvto solve them — the chapter's intrinsics and driver composed into a genuine, reusable tool. - Wrap the linear algebra behind a clean interface (
polyfit(x, y, deg, coef, info)) and passinfoout: a library routine reports failure and lets the caller decide, the waydgesvreports to you. - Verify a numerical routine by recovering a known answer — sample a polynomial you chose and confirm the fitter returns its coefficients. That is the unit test, not a plot you squint at.
- The normal equations are the readable route and the numerically weakest one: they square the condition
number (Chapter 20). For high degree or ill-conditioned data, solve the least-squares problem directly with
dgels(QR) ordgesvd(SVD) — see $\kappa(A)$, not $\kappa(A)^2$.