CrunchAcademy · K-12

Math for Data Science · MDS-8

Numerical Methods & Computational Math

Turn the math you know into algorithms a computer can actually run — fast, stable, and accurate enough to trust.

Real data science runs on finite-precision arithmetic, so the clean formulas from calculus and linear algebra rarely transfer to code unchanged. This course teaches the algorithms that scientific computing actually uses — how they introduce and amplify error, when they are stable, and how to make them fast. Every idea is implemented from scratch and then compared against the production routines in NumPy/SciPy or Julia.

PrereqsMDS-2 Linear AlgebraMDS-3 Single-Variable Calculus
Code inPython (NumPy/SciPy)Julia
7Units
48Lessons
99Worked examples

Course Outline

MDS-8 — units

Every lesson is self-contained: a full explanation, worked step-by-step examples (in Python and R/Julia), common mistakes, a try-it-yourself, and an interactive quiz. Jump to a unit:

Weeks 1-2 Unit 1: Floating Point & Error
explain-floating-pointdecode-ieee754diagnose-cancellationquantify-errordistinguish-forward-backward-errorimplement-stable-summation
Lecture
Why real numbers do not fit in a computer

Computers store numbers in a fixed number of bits, so the continuous real line must be approximated by a finite, unevenly spaced set of values.

The real numbers are uncountably infinite and dense, but any machine word has finitely many bits, so it can represent only finitely many values. Floating point picks a clever finite subset: numbers of the form m * 2^e where the mantissa m has fixed precision and the exponent e ranges over a wide span. This gives huge dynamic range but uneven spacing — the gap between consecutive representable numbers grows with magnitude. Most decimals (even 0.1) are not exactly representable, so storing them introduces representation error. Understanding that nearly every stored value is already slightly wrong is the foundation of numerical analysis: we never trust the last few digits, and we track how errors propagate through computation.

Worked Example 1

Problem. Show that 0.1 is not exactly representable in float64.

  1. 0.1 in binary is the repeating fraction 0.0001100110011... which cannot terminate in 52 mantissa bits.
  2. Check the stored value: `import numpy as np; print(f'{np.float64(0.1):.20f}')` gives `0.10000000000000000555`.
  3. The stored value differs from 0.1 by about 5.55e-18.

Answer. 0.1 is stored as 0.10000000000000000555..., off by ~5.55e-18; it is not exact.

Worked Example 2

Problem. Demonstrate uneven spacing: find the gap between consecutive floats near 1.0 versus near 1e9.

  1. `import numpy as np; print(np.spacing(1.0))` gives `2.220446049250313e-16`.
  2. `print(np.spacing(1e9))` gives `1.1920928955078125e-07`.
  3. The absolute gap near 1e9 is about 5.4e8 times larger than near 1.0, because spacing scales with magnitude.

Answer. Spacing is ~2.22e-16 near 1.0 but ~1.19e-7 near 1e9 — representable numbers thin out as magnitude grows.

Common mistakes
  • Assuming 0.1 + 0.2 == 0.3 in float; it is 0.30000000000000004. Compare with a tolerance (np.isclose), never exact equality.
  • Thinking floats are evenly spaced like fixed-point integers; spacing grows with magnitude, so adding a small number to a large one may have no effect.
✎ Try it yourself

Problem. Does 0.1 + 0.2 == 0.3 evaluate to True in float64? Explain and give a robust check.

Solution. `0.1 + 0.2` yields 0.30000000000000004 because each operand and the sum carry representation error, so `0.1 + 0.2 == 0.3` is False. Use `np.isclose(0.1 + 0.2, 0.3)`, which compares within a relative+absolute tolerance and returns True. Answer: False with exact ==, True with np.isclose.

IEEE 754: sign, exponent, mantissa, and machine epsilon

IEEE 754 fixes exactly how the bits of a float encode sign, exponent, and mantissa, which determines precision and the value of machine epsilon.

A float64 uses 64 bits: 1 sign bit, 11 exponent bits (biased by 1023), and 52 mantissa (fraction) bits. The value is (-1)^s * 1.f * 2^(e-1023), where the leading 1 is implicit for normal numbers, giving 53 bits of precision. Machine epsilon is the gap between 1.0 and the next representable float: 2^-52 ≈ 2.22e-16 for float64. It bounds the relative round-off of a single rounded operation. Special bit patterns encode +/-inf (all-ones exponent, zero mantissa) and nan (all-ones exponent, nonzero mantissa). Knowing the layout lets you predict precision (float32 has 23 mantissa bits, eps ≈ 1.19e-7) and explain why doubling precision roughly halves the digit count of error.

Worked Example 1

Problem. Confirm machine epsilon for float64 and float32 from NumPy.

  1. `import numpy as np; print(np.finfo(np.float64).eps)` gives `2.220446049250313e-16` = 2**-52.
  2. `print(np.finfo(np.float32).eps)` gives `1.1920929e-07` = 2**-23.
  3. float64 has 52 fraction bits, float32 has 23, matching the eps values.

Answer. float64 eps = 2**-52 ≈ 2.22e-16; float32 eps = 2**-23 ≈ 1.19e-7.

Worked Example 2

Problem. Decode the float64 bits of 1.0 and of the next float after 1.0.

  1. `import numpy as np; print(np.float64(1.0).hex())` gives `0x1.0000000000000p+0`, i.e. mantissa 1.0, exponent 0.
  2. The next float adds one to the mantissa's last bit: `print(np.nextafter(1.0, 2.0).hex())` gives `0x1.0000000000001p+0`.
  3. Their difference is 2**-52 = `print(np.nextafter(1.0,2.0) - 1.0)` → `2.220446049250313e-16`, exactly eps.

Answer. 1.0 = 1.0 * 2^0; the next float is 1.0 + 2**-52, so the step equals machine epsilon.

Common mistakes
  • Confusing machine epsilon (relative spacing near 1.0, ~2.22e-16) with the smallest positive float (~5e-324 subnormal); they answer different questions.
  • Forgetting the implicit leading 1 bit, which gives float64 53 bits of precision from only 52 stored mantissa bits.
✎ Try it yourself

Problem. How many decimal significant digits can float64 reliably represent, and how does that follow from 52 mantissa bits?

Solution. Precision is 53 bits (52 stored + 1 implicit). Decimal digits ≈ 53 * log10(2) ≈ 53 * 0.30103 ≈ 15.95. So float64 carries about 15-16 reliable significant decimal digits. Answer: ~15-16 digits.

Rounding, representation error, and catastrophic cancellation

Every operation rounds to the nearest float, and subtracting two nearly equal values can wipe out the leading correct digits.

When a real result lands between two representable floats, IEEE 754 rounds to nearest (ties to even), introducing a relative error bounded by eps/2. This per-operation round-off is usually harmless, but subtraction is dangerous: if x and y agree to k leading digits, x - y cancels those digits and leaves only the noisy trailing bits, magnifying relative error by roughly the ratio of magnitudes — catastrophic cancellation. The cure is algebraic reformulation that avoids the subtraction, such as rationalizing, using log1p/expm1, or the stable quadratic formula. The data was never lost in the inputs; cancellation merely exposes that the inputs themselves carried round-off in their low bits.

Worked Example 1

Problem. Evaluate (1 - cos(x)) / x**2 at x = 1e-8 directly and stably; the true limit is 0.5.

  1. Direct: `import numpy as np; x=1e-8; print((1-np.cos(x))/x**2)` gives `0.0` — cos(x) rounds to exactly 1.0, so the numerator cancels to 0.
  2. Stable: use the identity 1 - cos(x) = 2 sin(x/2)**2: `print(2*np.sin(x/2)**2 / x**2)` gives `0.5`.
  3. Cancellation destroyed all digits in the direct form.

Answer. Direct gives 0.0 (totally wrong); the stable form gives 0.5, the correct value.

Worked Example 2

Problem. Compute exp(x) - 1 at x = 1e-15 accurately.

  1. Naive: `import numpy as np; x=1e-15; print(np.exp(x)-1)` gives `1.1102230246251565e-15`, wrong in most digits.
  2. Stable: `print(np.expm1(x))` gives `1.0000000000000007e-15`, matching x to full precision.
  3. expm1 computes the small difference without forming exp(x) ≈ 1.0 first.

Answer. Use np.expm1(x) → ~1.0e-15; the naive exp(x)-1 loses nearly all significant digits.

Common mistakes
  • Subtracting nearly equal quantities and trusting the result; reformulate (expm1, log1p, half-angle identities) to avoid cancellation.
  • Blaming the library function (cos, exp) for the error; the function is accurate — the cancellation in the surrounding expression is the culprit.
✎ Try it yourself

Problem. Compute log(1 + x) for x = 1e-16 accurately and explain the fix.

Solution. `np.log(1 + 1e-16)` gives 0.0 because 1 + 1e-16 rounds to exactly 1.0 (1e-16 < eps). Use `np.log1p(1e-16)`, which gives 1e-16, computing log(1+x) without forming 1+x. Answer: 0.0 naively, 1e-16 with log1p.

Absolute vs. relative error and significant digits

Absolute error is the raw gap from the truth; relative error scales that gap by the magnitude and ties directly to significant digits.

If x_hat approximates true value x, the absolute error is |x_hat - x| and the relative error is |x_hat - x| / |x| (for x != 0). Absolute error answers 'how far off in raw units,' while relative error answers 'what fraction of the value is wrong,' which is scale-independent and comparable across magnitudes. A relative error near 10^-k corresponds to roughly k correct significant decimal digits. Because float64 round-off is bounded in relative terms (eps ≈ 2.22e-16), relative error is the natural currency of numerical analysis. Reporting only absolute error hides whether 0.001 is excellent (for values near 1e6) or terrible (for values near 0.001).

Worked Example 1

Problem. Approximate pi by 22/7. Find absolute and relative error and the number of correct significant digits.

  1. `import numpy as np; approx=22/7; abs_err=abs(approx-np.pi)` → `0.0012644892673496777`.
  2. `rel_err=abs_err/np.pi` → `0.0004024994347707008`.
  3. Correct digits ≈ -log10(rel_err) = `-np.log10(rel_err)` → about 3.4, so ~3 significant digits.

Answer. Absolute ≈ 1.264e-3, relative ≈ 4.02e-4, about 3 correct significant digits.

Worked Example 2

Problem. Two estimates: A = 1000.5 for true 1000.0, B = 0.0005 for true 0.0. Which is relatively better?

  1. A: abs err = 0.5, rel err = 0.5/1000.0 = 5e-4 (small relative error).
  2. B: true value is 0, so relative error is undefined; only absolute error (5e-4) is meaningful here.
  3. When the true value is 0, switch to absolute error or a mixed tolerance like np.isclose's atol + rtol*|x|.

Answer. A has relative error 5e-4 (~3 good digits); B's relative error is undefined near zero, so use absolute error there.

Common mistakes
  • Dividing by a true value of (near) zero to form relative error; guard with an absolute floor or use a combined atol+rtol criterion.
  • Quoting absolute error alone for results spanning many magnitudes; it cannot say whether the accuracy is good without the scale.
✎ Try it yourself

Problem. An estimate gives 2.71850 for e = 2.718281828. Find relative error and approximate correct significant digits.

Solution. Absolute error = |2.71850 - 2.718281828| = 0.000218172. Relative error = 0.000218172 / 2.718281828 ≈ 8.03e-5. Correct digits ≈ -log10(8.03e-5) ≈ 4.1, so about 4 significant digits. Answer: rel err ≈ 8.0e-5, ~4 digits.

Forward error, backward error, and conditioning

Forward error measures how wrong the answer is; backward error measures how wrong an input would have to be to make that answer exact; conditioning links the two.

Given a problem y = f(x) and a computed y_hat, the forward error is |y_hat - y|, how far the output is from the truth. The backward error is the smallest |delta x| such that f(x + delta x) = y_hat exactly — how much we'd have to perturb the input to justify our answer. An algorithm is backward stable if it always has tiny backward error. The condition number of the problem amplifies backward error into forward error: forward error ≲ condition number * backward error. This separation is powerful: a backward-stable algorithm on an ill-conditioned problem can still produce a large forward error, and that is the problem's fault, not the algorithm's.

Worked Example 1

Problem. For f(x) = sqrt(x) at x = 2, a routine returns y_hat = 1.41421356. Find forward and backward error.

  1. True y = sqrt(2) = 1.4142135623730951. Forward error = |1.41421356 - 1.4142135623730951| ≈ 2.37e-9.
  2. Backward error: find x+dx with sqrt(x+dx)=y_hat, so x+dx = y_hat**2 = 1.41421356**2 ≈ 1.9999999932..., giving |dx| ≈ 6.7e-9.
  3. `import numpy as np; yh=1.41421356; print(abs(yh - np.sqrt(2)), abs(yh**2 - 2))`.

Answer. Forward error ≈ 2.37e-9; backward error ≈ 6.7e-9 (the input would need to be ~2.0000000067).

Worked Example 2

Problem. Estimate the condition number of f(x) = sqrt(x) at x = 2 and relate the two errors above.

  1. Relative condition number of f is |x f'(x) / f(x)| = |x * (1/(2 sqrt(x))) / sqrt(x)| = 1/2.
  2. So relative forward error ≈ (1/2) * relative backward error: rel backward = 6.7e-9/2 ≈ 3.35e-9, times 1/2 ≈ 1.67e-9.
  3. This matches the relative forward error ≈ 2.37e-9/1.414 ≈ 1.68e-9.

Answer. Condition number is 1/2; it correctly predicts the forward error is about half the (relative) backward error.

Common mistakes
  • Blaming an algorithm for a large forward error on an ill-conditioned problem; check the backward error first — small backward error means the algorithm did its job.
  • Treating conditioning (a property of the problem) and stability (a property of the algorithm) as the same thing; they are independent.
✎ Try it yourself

Problem. A solver returns y_hat = 3.0001 for the problem y = f(x) whose true answer is 3.0. The problem's condition number is about 100. Roughly what backward error does this imply?

Solution. Relative forward error ≈ |3.0001 - 3.0| / 3.0 ≈ 3.33e-5. Since forward ≈ condition * backward, relative backward error ≈ forward/condition ≈ 3.33e-5 / 100 ≈ 3.33e-7. A tiny backward error on a moderately conditioned problem — the algorithm is fine. Answer: backward error ≈ 3.3e-7 (relative).

Accumulation of error and stable summation

Summing many floats accumulates round-off, and compensated (Kahan) summation recovers most of the lost low-order bits.

Floating-point addition is not associative: each partial sum is rounded, so adding n numbers can accumulate an error that grows like n * eps in the worst case, and small terms can vanish entirely against a large running total. Naive left-to-right summation is therefore unreliable for long or ill-scaled sequences. Kahan (compensated) summation keeps a running correction term that captures the low-order bits lost in each addition and feeds them back on the next step, reducing the error bound to roughly 2 * eps independent of n. Sorting by magnitude or pairwise (tree) summation also helps; NumPy's np.sum uses pairwise summation by default, which is why it is more accurate than a Python loop.

Worked Example 1

Problem. Sum 1.0 plus ten million copies of 1e-8. The exact answer is 1.1.

  1. `import numpy as np; x = np.full(10_000_000, 1e-8); total = 1.0`.
  2. Naive Python loop: `for v in x: total += v` — small terms partly round away against the growing total, giving ≈ 1.0999999... with visible error.
  3. `np.sum` with pairwise summation: `print(1.0 + np.sum(x))` gives `1.1` to full precision.
  4. math.fsum gives the exactly rounded result: `import math; print(1.0 + math.fsum(x))` → `1.1`.

Answer. Naive accumulation drifts from 1.1; np.sum (pairwise) and math.fsum recover 1.1.

Worked Example 2

Problem. Implement Kahan summation and compare to a naive sum on a hard case.

  1. Kahan: `def kahan(a):\n s=0.0; c=0.0\n for v in a:\n y=v-c; t=s+y; c=(t-s)-y; s=t\n return s`.
  2. The compensation c captures the rounding lost in `s+y` and corrects the next term.
  3. On the array above, `print(1.0 + kahan(x))` returns `1.1`, matching np.sum while a plain loop drifts.

Answer. Kahan summation tracks a correction term and recovers 1.1 where the naive loop loses accuracy.

Common mistakes
  • Assuming float addition is associative, so reordering a sum cannot matter; reordering and grouping change the rounded result.
  • Using a plain Python loop for long sums; prefer np.sum (pairwise) or math.fsum (exactly rounded) for accuracy.
✎ Try it yourself

Problem. Why does np.sum often beat a hand-written Python for-loop for adding a large array, even with identical inputs?

Solution. A left-to-right loop rounds after every one of n additions, so error can grow like n*eps and small terms vanish into a large running sum. np.sum uses pairwise (tree) summation, which adds in balanced groups so error grows like log(n)*eps instead of n*eps, and runs in compiled code. Answer: pairwise summation gives a much smaller, log(n)-scaled error bound than naive accumulation.

Key terms
  • Floating point — a finite representation of real numbers as a sign, a mantissa (significand), and a scaled exponent.
  • IEEE 754 — the standard defining binary float formats such as float32 and float64, including special values inf and nan.
  • Machine epsilon — the gap between 1.0 and the next representable float; the smallest relative spacing, about 2.22e-16 for float64.
  • Round-off error — the error introduced because a real value must be rounded to the nearest representable float.
  • Catastrophic cancellation — large relative error caused by subtracting two nearly equal floating-point numbers.
  • Absolute vs. relative error — the raw difference from the true value versus that difference scaled by the true value's magnitude.
  • Forward error — the difference between the computed answer and the true answer of the original problem.
  • Backward error — the smallest perturbation of the input for which the computed answer is the exact answer.
Assignment · Break the quadratic formula

Implement the textbook quadratic formula in float64 and find coefficients (such as a very small a or b much larger than 4ac) where it returns one badly inaccurate root. Use numpy.float64 and compare against a high-precision reference. Then reimplement the formula using the numerically stable variant that avoids cancellation and confirm the error disappears. Quantify both the absolute and relative error before and after.

Deliverable · A NumPy script plus a short write-up showing a failing input, the error magnitude, the stable rewrite, and a sentence explaining the cancellation that caused the failure.

Quiz · 5 questions
  1. 1. What does machine epsilon for float64 represent?

  2. 2. Catastrophic cancellation occurs when you:

  3. 3. An algorithm has small backward error when:

  4. 4. Relative error is preferred over absolute error mainly because it:

  5. 5. Summing many positive floats of widely different magnitudes naively tends to:

You'll be able to

I can explain how IEEE 754 represents numbers and where machine epsilon comes from.

I can identify and reformulate computations that suffer from catastrophic cancellation.

I can distinguish forward error, backward error, and conditioning when reasoning about an algorithm's accuracy.

Weeks 3-4 Unit 2: Root Finding
frame-root-problemimplement-bisectionanalyze-fixed-pointimplement-newtonimplement-secantchoose-stopping-criteriause-scipy-rootfinders
Lecture
What it means to solve f(x) = 0 numerically

Most equations have no closed-form root, so we replace exact algebra with an iterative process that drives a residual toward zero within a tolerance.

Solving f(x) = 0 numerically means producing a sequence of approximations x_0, x_1, ... whose residuals f(x_n) shrink toward machine precision, rather than an exact symbolic root that usually does not exist. We almost never reach the true root: we stop when the residual or the step size falls below a tolerance. Two ideas frame everything: a bracket is an interval [a, b] with f(a) and f(b) of opposite sign, which (by continuity) traps a root and gives a reliability guarantee; an open method like Newton starts from a guess and may converge fast or diverge. Numerically, beware that |f(x)| small does not always mean x is close to a root — for flat (ill-conditioned) functions a tiny residual can hide a large error.

Worked Example 1

Problem. Reformulate 'find where cos(x) equals x' as a root problem and verify a candidate.

  1. Define f(x) = cos(x) - x; a solution of cos(x)=x is exactly a root of f.
  2. Check the candidate x = 0.739085: `import numpy as np; f=lambda x: np.cos(x)-x; print(f(0.739085))` gives `9.0e-7`, a small residual.
  3. A bracket exists since `print(f(0.0), f(1.0))` gives `1.0 -0.4597`, opposite signs, so a root lies in (0, 1).

Answer. f(x)=cos(x)-x has a root near 0.739085; sign change on (0,1) confirms a bracketed root exists.

Worked Example 2

Problem. Show that a small residual can mislead for a flat function f(x) = (x - 1)**3.

  1. True root is x = 1. Take x_hat = 1.01: `f=lambda x:(x-1)**3; print(f(1.01))` gives `1e-06`, looks tiny.
  2. Yet the actual error is |1.01 - 1| = 0.01, ten thousand times the residual.
  3. Because f' = 3(x-1)^2 vanishes at the root, the residual underestimates the error — the root is ill-conditioned.

Answer. Residual 1e-6 but error 1e-2: at a flat/multiple root, small |f| does not imply a close x.

Common mistakes
  • Trusting |f(x)| < tol as proof x is accurate; for flat or multiple roots a tiny residual hides a large error — also check the step |x_{n+1}-x_n|.
  • Assuming a closed-form solution exists; most nonlinear equations require iteration, and 'solve' means converge to a tolerance, not find an exact value.
✎ Try it yourself

Problem. Turn 'x e^x = 1' into f(x) = 0, find a bracket, and confirm the root lies inside.

Solution. Let f(x) = x*np.exp(x) - 1. Evaluate f(0) = -1 and f(1) = e - 1 ≈ 1.718, opposite signs, so a root is bracketed in (0, 1). Checking x = 0.567143: `f=lambda x: x*np.exp(x)-1; print(f(0.567143))` gives about 1.8e-7. Answer: root ≈ 0.567143 (the Omega constant), bracketed in (0, 1).

Bisection and the intermediate value theorem

Bisection repeatedly halves a sign-change interval, guaranteed by the intermediate value theorem to enclose a root.

If f is continuous on [a, b] and f(a) and f(b) have opposite signs, the intermediate value theorem guarantees a root inside. Bisection exploits this: evaluate the midpoint m = (a+b)/2, keep whichever half still shows a sign change, and repeat. The interval width halves each step, so after n steps the uncertainty is (b-a)/2^n — linear convergence with rate 1/2, gaining about one bit (0.3 decimal digits) per iteration. It is slow but bulletproof: it cannot diverge and needs no derivative, only continuity and a valid bracket. Numerically, compute the midpoint as a + (b-a)/2 rather than (a+b)/2 to avoid overflow, and test the sign with the product's sign (or np.sign) rather than multiplying, which can underflow.

Worked Example 1

Problem. Do two bisection steps on f(x) = x**2 - 2 over [1, 2] to bracket sqrt(2).

  1. f(1) = -1, f(2) = 2: sign change, root in [1, 2]. Midpoint m = 1.5, `f(1.5)=0.25` > 0, so keep [1, 1.5].
  2. New midpoint m = 1.25, `f(1.25) = -0.4375` < 0, so keep [1.25, 1.5].
  3. After two steps the root sqrt(2) ≈ 1.41421 is bracketed in [1.25, 1.5], width 0.25.

Answer. After two steps the bracket is [1.25, 1.5]; width halved twice from 1 to 0.25.

Worked Example 2

Problem. How many bisection steps are needed to locate a root in [0, 1] to within 1e-6?

  1. Error after n steps is (b-a)/2^(n+1) = 1/2^(n+1). Require 1/2^(n+1) <= 1e-6.
  2. Solve: 2^(n+1) >= 1e6, so n+1 >= log2(1e6) = `import numpy as np; print(np.log2(1e6))` → 19.93.
  3. Thus n+1 = 20, n = 19 steps suffice (rounding up).

Answer. About 20 halvings; n = 19 iterations guarantee error <= 1e-6 on a unit interval.

Worked Example 3

Problem. Implement bisection and find the root of f(x)=x**3 - x - 2 on [1, 2].

  1. `import numpy as np\ndef bisect(f,a,b,tol=1e-10):\n fa=f(a)\n while (b-a)/2>tol:\n m=a+(b-a)/2; fm=f(m)\n if np.sign(fm)==np.sign(fa): a,fa=m,fm\n else: b=m\n return a+(b-a)/2`
  2. `f=lambda x: x**3 - x - 2; print(bisect(f,1,2))` gives `1.5213797068037558`.
  3. Check residual: `print(f(1.5213797068037558))` gives about `-2e-10`, within tolerance.

Answer. Root ≈ 1.5213797068, residual ~2e-10; bisection converged reliably.

Common mistakes
  • Starting without a sign change (f(a) and f(b) same sign); bisection's guarantee fails and it may converge to a non-root or a discontinuity.
  • Computing the midpoint as (a+b)/2, which can overflow for large a, b; use a + (b-a)/2 and test signs with np.sign instead of multiplying f(a)*f(m).
✎ Try it yourself

Problem. Use bisection logic to bracket the root of f(x) = cos(x) - x in [0, 1] for three steps.

Solution. f(0)=1>0, f(1)=cos(1)-1≈-0.4597<0: root in [0,1]. m=0.5, f(0.5)=cos(0.5)-0.5≈0.3776>0, keep [0.5,1]. m=0.75, f(0.75)≈cos(0.75)-0.75≈-0.0183<0, keep [0.5,0.75]. m=0.625, f(0.625)≈0.1860>0, keep [0.625,0.75]. Answer: after three steps the root (~0.739085) is bracketed in [0.625, 0.75].

Fixed-point iteration and convergence

Rewriting f(x)=0 as x = g(x) and iterating x_{n+1} = g(x_n) converges when g is a local contraction, |g'| < 1.

A fixed point of g satisfies x* = g(x*). To solve f(x) = 0 we algebraically rearrange it into x = g(x) and iterate x_{n+1} = g(x_n) from a starting guess. Convergence is governed by the derivative at the fixed point: if |g'(x*)| < 1 the map is locally contracting and the error shrinks by roughly that factor each step (linear convergence with rate |g'(x*)|); if |g'(x*)| > 1 the iteration diverges no matter how close you start. The same equation can be rearranged many ways, and only some give a convergent g — choosing a good rearrangement (small |g'|) is the whole art. When g'(x*) happens to be 0, convergence becomes faster than linear, which is exactly what Newton's method engineers.

Worked Example 1

Problem. Solve x = cos(x) by fixed-point iteration from x_0 = 1; check the contraction condition.

  1. g(x) = cos(x), g'(x) = -sin(x). At the root x* ≈ 0.739, |g'| = sin(0.739) ≈ 0.673 < 1, so it converges (linearly).
  2. `import numpy as np; x=1.0\nfor _ in range(20): x=np.cos(x)\nprint(x)` gives `0.7390851332151607`.
  3. Each step multiplies the error by ~0.67, so it takes many iterations for high accuracy.

Answer. Converges to x* ≈ 0.739085 because |g'(x*)| ≈ 0.67 < 1; convergence is linear.

Worked Example 2

Problem. For x**2 = 2, compare the divergent rearrangement g(x)=2/x with the convergent g(x)=(x + 2/x)/2.

  1. g(x) = 2/x: g'(x) = -2/x^2, at x*=sqrt(2), |g'| = 2/2 = 1 — borderline; iterating from 1.0 oscillates 1, 2, 1, 2 and never settles.
  2. g(x) = (x + 2/x)/2 (Newton/Babylonian): g'(x) = (1 - 2/x^2)/2, at sqrt(2) g'=0, so convergence is quadratic.
  3. `x=1.0\nfor _ in range(5): x=(x+2/x)/2\nprint(x)` gives `1.414213562373095`, full precision in ~5 steps.

Answer. g=2/x stalls (|g'|=1); the averaged form has g'=0 at the root and converges quadratically to sqrt(2).

Common mistakes
  • Picking any algebraic rearrangement and assuming it converges; if |g'(x*)| >= 1 it diverges. Check the derivative magnitude before trusting the iteration.
  • Confusing the fixed point of g with a root of g; you want x = g(x), i.e. a root of g(x) - x, not a root of g itself.
✎ Try it yourself

Problem. To solve x = e^{-x}, will iterating x_{n+1} = e^{-x_n} from x_0 = 0.5 converge? Find the fixed point.

Solution. g(x) = e^{-x}, g'(x) = -e^{-x}. The fixed point is near x* ≈ 0.567; there |g'| = e^{-0.567} ≈ 0.567 < 1, so it converges (linearly). Iterating: `x=0.5\nfor _ in range(30): x=np.exp(-x)` gives x ≈ 0.5671432904. Answer: converges to x* ≈ 0.567143 since |g'(x*)| ≈ 0.567 < 1.

Newton's method and quadratic convergence

Newton's method linearizes f at the current guess and jumps to the line's root, doubling the number of correct digits each step near a simple root.

Newton's method approximates f near x_n by its tangent line and takes the next iterate where that line crosses zero: x_{n+1} = x_n - f(x_n)/f'(x_n). For a simple root (f(x*)=0, f'(x*) != 0) the error obeys e_{n+1} ≈ (f''/2f') e_n^2, so it converges quadratically — the number of correct digits roughly doubles each step. The cost is one f and one f' evaluation per step, plus fragility: a near-zero derivative sends the step to infinity, a bad initial guess can diverge or cycle, and at a multiple root convergence drops to linear. There is no bracket, so Newton offers speed without a reliability guarantee. In practice it is often safeguarded by falling back to bisection when a step leaves the bracket.

Worked Example 1

Problem. Apply two Newton steps to f(x) = x**2 - 2 from x_0 = 1 to approximate sqrt(2).

  1. f' = 2x, so x_1 = x_0 - (x_0^2 - 2)/(2 x_0) = 1 - (1-2)/2 = 1.5.
  2. x_2 = 1.5 - (1.5^2 - 2)/(2*1.5) = 1.5 - 0.25/3 = 1.4166666...
  3. x_3 = 1.41666... - (1.41666^2 - 2)/(2*1.41666) ≈ 1.4142156, matching sqrt(2)=1.4142135 to 5 digits.

Answer. x_1=1.5, x_2≈1.41667, x_3≈1.4142157 — digits roughly double each step (quadratic).

Worked Example 2

Problem. Implement Newton's method for f(x)=x**3 - x - 2 from x_0=1.5 and observe quadratic convergence.

  1. `import numpy as np\nf=lambda x:x**3-x-2; fp=lambda x:3*x**2-1\nx=1.5\nfor _ in range(5):\n x=x-f(x)/fp(x); print(x)`
  2. Iterates: 1.5217391..., 1.5213798..., 1.5213797068045..., 1.5213797068045676, 1.5213797068045676.
  3. The error drops from ~4e-4 to ~1e-7 to ~1e-14 in successive steps — squaring each time.

Answer. Converges to 1.5213797068045676 in ~4 steps; error squares each iteration.

Worked Example 3

Problem. Show Newton failing: f(x) = x**3 - 2x + 2 starting at x_0 = 0.

  1. f' = 3x^2 - 2. x_1 = 0 - (0 - 0 + 2)/(0 - 2) = 0 - (2)/(-2) = 1.
  2. x_2 = 1 - (1 - 2 + 2)/(3 - 2) = 1 - 1/1 = 0. We are back at 0.
  3. The iteration cycles 0, 1, 0, 1, ... forever and never converges — a known Newton failure case.

Answer. Newton enters a 2-cycle (0 → 1 → 0 → ...) and never converges; a poor starting point defeats it.

Common mistakes
  • Dividing by a derivative that is near zero; the step f/f' blows up. Safeguard with a bracket or switch to bisection when a step is rejected.
  • Expecting quadratic convergence at a multiple root; when f'(x*)=0 too, convergence degrades to linear unless you use the modified Newton step m*f/f'.
✎ Try it yourself

Problem. Use Newton's method to find a root of f(x) = cos(x) - x from x_0 = 0.5; do two steps.

Solution. f'(x) = -sin(x) - 1. x_1 = 0.5 - (cos(0.5)-0.5)/(-sin(0.5)-1) = 0.5 - (0.37758)/(-1.47943) ≈ 0.5 + 0.25522 = 0.75522. x_2 = 0.75522 - (cos(0.75522)-0.75522)/(-sin(0.75522)-1) ≈ 0.75522 - (-0.01865)/(-1.68507) ≈ 0.73914. Answer: x_2 ≈ 0.739085, converging quadratically to the true root.

The secant method and derivative-free roots

The secant method replaces Newton's analytic derivative with a finite difference of the two latest iterates, needing no derivative.

When the derivative is unavailable or expensive, the secant method approximates f'(x_n) by the slope through the last two points: x_{n+1} = x_n - f(x_n) * (x_n - x_{n-1}) / (f(x_n) - f(x_{n-1})). It is Newton with a numerical derivative, so it needs two starting values and only one new function evaluation per step. Its convergence order is the golden ratio, about 1.618 — superlinear, slower than Newton's 2 but faster than bisection's 1, and often more efficient per function evaluation since it never computes f'. Like Newton it can diverge without a bracket, and it breaks down if two consecutive iterates give nearly equal f values (the denominator cancels). It underlies SciPy's derivative-free newton when no fprime is supplied.

Worked Example 1

Problem. Take two secant steps on f(x) = x**2 - 2 with x_0 = 1, x_1 = 2.

  1. f(1) = -1, f(2) = 2. x_2 = 2 - 2*(2-1)/(2-(-1)) = 2 - 2/3 = 1.33333.
  2. f(1.33333) = -0.22222. x_3 = 1.33333 - (-0.22222)*(1.33333-2)/(-0.22222-2) = 1.33333 - (-0.22222)*(-0.66667)/(-2.22222).
  3. = 1.33333 - (0.14815)/(-2.22222) = 1.33333 + 0.06667 = 1.4 (approx); next steps approach 1.41421.

Answer. x_2 = 1.33333, x_3 ≈ 1.4 — superlinear approach to sqrt(2) using no derivative.

Worked Example 2

Problem. Implement the secant method for f(x)=x**3 - x - 2 with x_0=1, x_1=2.

  1. `f=lambda x:x**3-x-2\nx0,x1=1.0,2.0\nfor _ in range(6):\n x2=x1-f(x1)*(x1-x0)/(f(x1)-f(x0)); x0,x1=x1,x2; print(x1)`
  2. Iterates: 1.3333, 1.4626, 1.5359, 1.5208, 1.52138, 1.5213797068045..., converging to the root.
  3. It reached ~10 digits in 6 steps with no derivative, versus ~4 for Newton — slightly more steps, but no f' needed.

Answer. Converges to 1.5213797068 in ~6 steps; order ≈ 1.618 (golden ratio), no derivative required.

Common mistakes
  • Letting f(x_n) and f(x_{n-1}) become nearly equal; the denominator f(x_n)-f(x_{n-1}) suffers cancellation and the step explodes. Add a guard for a tiny denominator.
  • Treating the secant method as bracketing; it is not — the two points need not surround the root, so it can diverge like Newton.
✎ Try it yourself

Problem. Do one secant step for f(x) = e^x - 3 with x_0 = 1, x_1 = 1.5.

Solution. f(1) = e - 3 ≈ -0.28172, f(1.5) = e^1.5 - 3 ≈ 1.48169. x_2 = 1.5 - 1.48169*(1.5-1)/(1.48169-(-0.28172)) = 1.5 - 1.48169*0.5/1.76341 = 1.5 - 0.42010 = 1.07990. The true root is ln(3) ≈ 1.09861. Answer: x_2 ≈ 1.0799, already close to ln(3) ≈ 1.0986 after one step.

Convergence order, tolerances, and stopping criteria

Convergence order quantifies how fast error shrinks, and sensible stopping rules combine step size, residual, and an iteration cap.

An iteration converges with order p if |e_{n+1}| ≈ C |e_n|^p near the root: p = 1 is linear (constant digit gain), p ≈ 1.618 is the secant's superlinear rate, p = 2 is Newton's quadratic (digits double). To decide when to stop without knowing the true root, you combine criteria: a step tolerance |x_{n+1} - x_n| < xtol, a residual tolerance |f(x_n)| < ftol, and always a maximum iteration count to bound runaway cases. Use a mixed absolute-plus-relative tolerance (xtol + rtol*|x|) so the test scales with the root's magnitude. Demanding a tolerance below machine epsilon times the value is futile — the iteration stalls on round-off. Estimating p empirically from log-error ratios is a good sanity check on an implementation.

Worked Example 1

Problem. From the Newton errors 1e-2, 1e-4, 1e-8, estimate the convergence order p.

  1. Order satisfies e_{n+1} ≈ C e_n^p, so p ≈ log(e_{n+1}/e_n) / log(e_n/e_{n-1}).
  2. Using the last three: p ≈ log(1e-8/1e-4)/log(1e-4/1e-2) = log(1e-4)/log(1e-2) = (-4)/(-2) = 2.
  3. `import numpy as np; print(np.log(1e-8/1e-4)/np.log(1e-4/1e-2))` confirms `2.0`.

Answer. p = 2: the errors square each step, confirming quadratic (Newton) convergence.

Worked Example 2

Problem. Design a robust stopping rule for a Newton loop on a root near x ≈ 1000.

  1. Use a mixed tolerance: stop when |x_{n+1}-x_n| <= xtol + rtol*|x_{n+1}|, e.g. xtol=1e-12, rtol=1e-10.
  2. Near x ≈ 1000 the effective step threshold is ~1e-10*1000 = 1e-7, sensible given ~16 digits of float64.
  3. Always cap iterations: `for k in range(50): ...; if abs(dx) <= 1e-12 + 1e-10*abs(x): break` so a divergent case cannot loop forever.

Answer. Combine xtol + rtol*|x| with a hard iteration cap (e.g. 50); the relative term scales the test to the root's magnitude.

Common mistakes
  • Using only an absolute step tolerance for large-magnitude roots; 1e-12 is unreachable near x=1e6 in float64. Add a relative term: xtol + rtol*|x|.
  • Omitting a maximum-iteration safeguard; a diverging or cycling method (Newton at a bad guess) will loop forever without one.
✎ Try it yourself

Problem. A method gives errors 0.2, 0.04, 0.008, 0.0016. Is it linear, and what is the rate?

Solution. Each error is the previous times 0.2 (0.04/0.2 = 0.2, 0.008/0.04 = 0.2, 0.0016/0.008 = 0.2). A constant ratio means linear convergence (p = 1) with rate C = 0.2. Order estimate: log(0.008/0.0016... ) approach gives ratio constant, so p=1. Answer: linear convergence, rate 0.2 (each step gains ~log10(1/0.2) ≈ 0.7 digits).

Robust solving with SciPy's brentq and root_scalar

SciPy's brentq blends bisection's safety with fast interpolation, and root_scalar offers a unified interface to many methods.

Brent's method (scipy.optimize.brentq) is the workhorse for a known bracket: it combines guaranteed bisection convergence with faster inverse-quadratic interpolation and secant steps, automatically falling back to bisection whenever the fast step misbehaves. Given f(a), f(b) of opposite sign it cannot fail and typically converges superlinearly — the best of both worlds, with no derivative needed. scipy.optimize.root_scalar is a dispatcher: pass method='brentq'/'bisect' with a bracket, or method='newton'/'secant' with a starting point (and fprime for Newton), and it returns a result object with the root, iteration count, and convergence flag. Rule of thumb: if you can bracket the root, use brentq for robustness; reach for Newton only when the derivative is cheap and a good initial guess is available.

Worked Example 1

Problem. Solve x**3 - x - 2 = 0 with scipy.optimize.brentq on the bracket [1, 2].

  1. `from scipy.optimize import brentq; f=lambda x:x**3-x-2; r=brentq(f,1,2); print(r)` gives `1.5213797068045751`.
  2. Confirm the residual: `print(f(r))` gives about `0.0` (within ~1e-15).
  3. Brent used a handful of interpolation steps but kept the root bracketed the whole time, so it could not diverge.

Answer. brentq returns 1.5213797068045751 reliably; it stays bracketed and converges superlinearly.

Worked Example 2

Problem. Use scipy.optimize.root_scalar with Newton and with brentq, and inspect the result object.

  1. Newton: `from scipy.optimize import root_scalar\nsol=root_scalar(lambda x:x**3-x-2, x0=1.5, fprime=lambda x:3*x**2-1, method='newton')\nprint(sol.root, sol.iterations, sol.converged)` gives `1.5213797068045751 4 True`.
  2. Brentq: `sol2=root_scalar(lambda x:x**3-x-2, bracket=[1,2], method='brentq'); print(sol2.root, sol2.iterations)` gives the same root in more (but safe) steps.
  3. The RootResults object exposes .root, .iterations, .function_calls, and .converged for diagnostics.

Answer. Newton converged in 4 iterations; brentq took more steps but is bracket-safe — both return 1.52138 with converged=True.

Common mistakes
  • Calling brentq without a sign change on [a, b]; it raises ValueError because it requires f(a) and f(b) to have opposite signs.
  • Reaching for Newton via root_scalar when you have no derivative and no good guess; prefer brentq with a bracket for robustness, or pass method='secant'.
✎ Try it yourself

Problem. Use scipy.optimize.brentq to find where cos(x) = x on a suitable bracket.

Solution. Define f(x) = np.cos(x) - x. A bracket is [0, 1] since f(0)=1>0 and f(1)=cos(1)-1<0. `from scipy.optimize import brentq; import numpy as np; r = brentq(lambda x: np.cos(x)-x, 0, 1); print(r)` gives 0.7390851332151607. Answer: brentq returns x ≈ 0.7390851332, the unique solution of cos(x)=x.

Key terms
  • Root (zero) — a value x for which f(x) = 0; the target of a root-finding algorithm.
  • Bisection — a bracketing method that repeatedly halves an interval known to contain a sign change.
  • Fixed-point iteration — solving x = g(x) by iterating x_{n+1} = g(x_n); converges when |g'| < 1 near the root.
  • Newton's method — iterating x_{n+1} = x_n - f(x_n)/f'(x_n); converges quadratically near a simple root.
  • Secant method — Newton's method with the derivative approximated by a finite difference of recent iterates.
  • Convergence order — the exponent p in |e_{n+1}| ~ C|e_n|^p describing how fast error shrinks per step.
  • Bracketing — keeping the root trapped inside an interval with a guaranteed sign change for reliability.
  • Tolerance — the threshold on error or residual at which iteration is declared converged and stops.
Assignment · Race the root finders

Pick a nonlinear function with a known root (for example f(x) = x**3 - x - 2) and implement bisection, Newton's method, and the secant method yourself. Track the error at each iteration and verify the expected convergence orders by plotting log error against iteration. Then solve the same problem with scipy.optimize.brentq and scipy.optimize.newton and compare iteration counts and final accuracy.

Deliverable · A script and short report with a convergence plot, a table of iterations-to-tolerance per method, and one sentence on why Newton converged fastest yet was least robust.

Quiz · 5 questions
  1. 1. Bisection is guaranteed to converge as long as:

  2. 2. Near a simple root, Newton's method typically converges:

  3. 3. Fixed-point iteration x_{n+1} = g(x_n) converges to a fixed point when, near it:

  4. 4. The main advantage of the secant method over Newton's method is that it:

  5. 5. Why is scipy.optimize.brentq often preferred for a known bracket?

You'll be able to

I can implement bisection, Newton's method, and the secant method from scratch and explain their convergence rates.

I can choose sensible tolerances and stopping criteria and recognize when a method diverges.

I can select an appropriate SciPy root finder for a given problem and justify the choice.

Weeks 5-6 Unit 3: Linear Systems Numerically
solve-linear-systemsimplement-luapply-partial-pivotinguse-choleskycompute-normsinterpret-condition-numberuse-library-solvers
Lecture
Solving Ax = b without inverting A

We solve linear systems by factoring and substitution, never by forming A^-1, which is slower, less accurate, and rarely needed.

The clean math says x = A^-1 b, but numerically computing the inverse is a mistake: it costs about three times the work of a direct solve, amplifies round-off, and discards structure (sparsity, symmetry). Instead we factor A once (e.g. into triangular factors) and solve by substitution, which is both cheaper and more accurate. NumPy's np.linalg.solve and SciPy's lu_solve do exactly this. Forming the inverse is justified only when you genuinely need the matrix itself (rare). A useful diagnostic is the residual r = b - A x_hat: it is cheap to compute, but for ill-conditioned A a small residual does not guarantee a small error in x. Always prefer solve(A, b) over inv(A) @ b.

Worked Example 1

Problem. Solve the 2x2 system [[3,2],[1,2]] x = [5,5] correctly in NumPy.

  1. `import numpy as np; A=np.array([[3.,2.],[1.,2.]]); b=np.array([5.,5.])`.
  2. `x = np.linalg.solve(A, b); print(x)` gives `[0. 2.5]`.
  3. Check: `print(A @ x)` gives `[5. 5.]`, matching b.

Answer. x = [0, 2.5]; solve(A, b) factors A and substitutes, no inverse formed.

Worked Example 2

Problem. Show that np.linalg.solve beats inv(A) @ b in accuracy and cost on a random system.

  1. `import numpy as np; n=500; A=np.random.rand(n,n); b=np.random.rand(n)`.
  2. `x1=np.linalg.solve(A,b); x2=np.linalg.inv(A)@b; print(np.linalg.norm(A@x1-b), np.linalg.norm(A@x2-b))`.
  3. The solve residual is typically smaller, and solve is ~3x faster because inv computes n extra right-hand sides then multiplies.

Answer. solve gives a smaller residual and runs faster; inv(A)@b does strictly more work for a worse answer.

Common mistakes
  • Writing x = np.linalg.inv(A) @ b; it is slower and less accurate. Use np.linalg.solve(A, b), which factors and substitutes.
  • Judging accuracy by the residual alone; for ill-conditioned A a tiny residual can coexist with a large error in x — check the condition number too.
✎ Try it yourself

Problem. Solve [[2,1,1],[1,3,2],[1,0,0]] x = [4,5,6] with NumPy and verify.

Solution. `import numpy as np; A=np.array([[2.,1.,1.],[1.,3.,2.],[1.,0.,0.]]); b=np.array([4.,5.,6.]); x=np.linalg.solve(A,b); print(x)` gives `[ 6. 15. -23.]`. Verify `A @ x` returns `[4. 5. 6.]`. Answer: x = [6, 15, -23], obtained by direct solve without inverting A.

Gaussian elimination and LU decomposition

Gaussian elimination systematically zeros out subdiagonal entries; recording the multipliers yields the LU factorization A = LU.

Gaussian elimination reduces A to upper-triangular U by subtracting multiples of each pivot row from the rows below. If you store the multipliers used (the factor by which each row was scaled) in a unit lower-triangular matrix L, you obtain A = LU. This costs about (2/3)n^3 flops — the dominant expense. Once factored, solving Ax = b becomes two cheap O(n^2) triangular sweeps: forward substitution solves Ly = b, then back substitution solves Ux = y. The big win is reuse: factor A once, then solve for many right-hand sides b cheaply. Numerically, LU without pivoting can fail if a pivot is zero or tiny (dividing by it magnifies error), which is why partial pivoting (the next lesson) is essential in practice.

Worked Example 1

Problem. Hand-compute the LU factorization of A = [[2,1],[6,4]] (no pivoting).

  1. Multiplier to eliminate the (2,1) entry: m = 6/2 = 3. Subtract 3*row1 from row2: [6,4]-3*[2,1] = [0,1].
  2. So U = [[2,1],[0,1]] and L = [[1,0],[3,1]] (the 3 is the stored multiplier).
  3. Check L @ U: `import numpy as np; L=np.array([[1.,0],[3,1]]); U=np.array([[2.,1],[0,1]]); print(L@U)` gives `[[2. 1.] [6. 4.]]` = A.

Answer. L = [[1,0],[3,1]], U = [[2,1],[0,1]]; their product reconstructs A.

Worked Example 2

Problem. Use the LU factors to solve [[2,1],[6,4]] x = [3, 10].

  1. Forward solve Ly = b: y1 = 3; y2 = 10 - 3*y1 = 10 - 9 = 1. So y = [3, 1].
  2. Back solve Ux = y: from row2, 1*x2 = 1 -> x2 = 1; row1, 2*x1 + 1*x2 = 3 -> x1 = (3-1)/2 = 1.
  3. `import numpy as np; print(np.linalg.solve(np.array([[2.,1],[6,4]]), [3.,10]))` confirms `[1. 1.]`.

Answer. x = [1, 1], found by forward then back substitution on the LU factors.

Common mistakes
  • Re-factoring A for every new right-hand side; factor once with lu_factor, then reuse the factors via lu_solve for each b in O(n^2).
  • Doing LU without pivoting on a matrix with a zero or tiny pivot; it divides by ~0 and produces garbage. Use partial pivoting (scipy.linalg.lu_factor does).
✎ Try it yourself

Problem. Factor A = [[4,3],[8,7]] into LU by hand and verify with NumPy.

Solution. Multiplier m = 8/4 = 2. Row2 - 2*Row1 = [8,7]-[8,6] = [0,1]. So U = [[4,3],[0,1]], L = [[1,0],[2,1]]. Check: L@U = [[4,3],[8,7]] = A. In SciPy, `from scipy.linalg import lu; P,L,U = lu(A)` gives the pivoted version (here it pivots since 8>4). Answer: L=[[1,0],[2,1]], U=[[4,3],[0,1]] for the unpivoted factorization.

Partial pivoting and why it matters

Partial pivoting swaps rows to put the largest-magnitude pivot on the diagonal, preventing small pivots from amplifying round-off.

Plain Gaussian elimination divides by the pivot; a tiny pivot produces huge multipliers that blow up rounding error, and a zero pivot halts the algorithm entirely. Partial pivoting fixes this: before eliminating each column, swap in the row with the largest absolute entry in that column to serve as the pivot. This keeps all multipliers <= 1 in magnitude, bounding error growth and making the factorization backward stable in practice. The result is recorded as PA = LU, where P is a permutation matrix tracking the row swaps. SciPy's scipy.linalg.lu_factor returns the LU factors and a pivot index array; lu_solve then applies the same permutation when solving. Partial pivoting is standard in every production solver — you almost never want LU without it.

Worked Example 1

Problem. Show how a tiny pivot wrecks accuracy: solve [[1e-20, 1],[1, 1]] x = [1, 2] with and without pivoting.

  1. Without pivoting, the multiplier is 1/1e-20 = 1e20; the (2,2) entry becomes 1 - 1e20 ≈ -1e20, and the small original 1 is lost to rounding, giving x1 ≈ 0 (wrong).
  2. With pivoting, swap rows so the pivot is 1: the system is well-scaled and the multiplier is 1e-20, harmless.
  3. `import numpy as np; A=np.array([[1e-20,1],[1,1]]); b=np.array([1.,2.]); print(np.linalg.solve(A,b))` (which pivots) gives `[1. 1.]`.

Answer. Pivoting gives the correct [1, 1]; the naive no-pivot version loses the data and returns ~[0, 1].

Worked Example 2

Problem. Use scipy.linalg.lu_factor / lu_solve (which pivot) to solve a system and inspect the pivots.

  1. `from scipy.linalg import lu_factor, lu_solve; import numpy as np; A=np.array([[2.,1,1],[4,3,3],[8,7,9]]); b=np.array([4.,10,30])`.
  2. `lu, piv = lu_factor(A); print(piv)` shows the pivot row indices (e.g. `[2 2 2]`), meaning row 0 was swapped with row 2 first.
  3. `x = lu_solve((lu, piv), b); print(x)` returns the solution; `print(A @ x - b)` is ~0.

Answer. lu_factor pivots automatically (piv records swaps); lu_solve applies them to return an accurate x.

Common mistakes
  • Implementing LU without pivoting and assuming a nonsingular matrix is safe; a small (not just zero) pivot still amplifies round-off catastrophically.
  • Forgetting to apply the permutation P when solving with hand-rolled LU; the row swaps must also be applied to b, or lu_solve handles it for you.
✎ Try it yourself

Problem. For A = [[0, 2],[1, 1]], why is partial pivoting required, and what swap occurs?

Solution. The (1,1) pivot is 0, so elimination would divide by zero. Partial pivoting scans column 1 for the largest magnitude entry, finds 1 in row 2, and swaps rows: PA = [[1,1],[0,2]], which is already upper triangular. P swaps rows 1 and 2. Answer: pivoting swaps the two rows to avoid the zero pivot, giving PA = LU with U = [[1,1],[0,2]].

Cholesky and other specialized factorizations

For symmetric positive-definite matrices, Cholesky factors A = L L^T at half the cost of LU and with guaranteed stability.

When A is symmetric positive-definite (SPD) — symmetric with all positive eigenvalues, common for covariance matrices and normal-equation matrices A^T A — you should not use general LU. The Cholesky factorization writes A = L L^T with L lower-triangular and positive diagonal, exploiting symmetry to use about (1/3)n^3 flops, half of LU, and needs no pivoting because SPD guarantees stability. Solving then uses two triangular sweeps as usual. A bonus: Cholesky is a cheap positive-definiteness test — scipy.linalg.cholesky raises LinAlgError exactly when A is not SPD. Other specialized factorizations match other structure: QR for least squares and orthogonalization, and banded/sparse solvers for matrices with mostly-zero entries. Matching the factorization to the matrix structure is how you get both speed and accuracy.

Worked Example 1

Problem. Compute the Cholesky factor of A = [[4, 2],[2, 3]] by hand.

  1. L is lower-triangular with L = [[l11,0],[l21,l22]]. From A = L L^T: l11^2 = 4 -> l11 = 2.
  2. l21*l11 = 2 -> l21 = 1. Then l21^2 + l22^2 = 3 -> 1 + l22^2 = 3 -> l22 = sqrt(2) ≈ 1.41421.
  3. `import numpy as np; print(np.linalg.cholesky([[4.,2],[2,3]]))` gives `[[2. 0.] [1. 1.41421356]]`.

Answer. L = [[2, 0],[1, sqrt(2)]]; A = L L^T reconstructs [[4,2],[2,3]].

Worked Example 2

Problem. Use Cholesky to detect that A = [[1, 2],[2, 1]] is NOT positive-definite.

  1. Eigenvalues are 1 +/- 2 = {3, -1}; the negative eigenvalue means A is symmetric but indefinite.
  2. `import numpy as np; np.linalg.cholesky([[1.,2],[2,1]])` raises `LinAlgError: Matrix is not positive definite`.
  3. So a failed Cholesky is a reliable SPD test — useful before assuming a covariance matrix is valid.

Answer. Cholesky raises LinAlgError because A has a negative eigenvalue; it is symmetric but not positive-definite.

Common mistakes
  • Using Cholesky on a symmetric but indefinite matrix; it fails (LinAlgError) because no real L exists. Confirm positive-definiteness first or use LU/LDL^T.
  • Applying general LU to a known SPD system; you pay double the flops and lose the stability guarantee that Cholesky gives for free.
✎ Try it yourself

Problem. Find the Cholesky factor of A = [[9, 3],[3, 5]].

Solution. l11 = sqrt(9) = 3. l21 = 3/3 = 1. l22 = sqrt(5 - 1^2) = sqrt(4) = 2. So L = [[3,0],[1,2]]. Verify L L^T = [[9,3],[3,5]] = A. Check: `import numpy as np; print(np.linalg.cholesky([[9.,3],[3,5]]))` gives `[[3. 0.] [1. 2.]]`. Answer: L = [[3,0],[1,2]].

Vector and matrix norms

Norms assign a single nonnegative size to a vector or matrix, and they are the language for measuring errors and sensitivity.

A vector norm measures length: the 2-norm (Euclidean) is sqrt(sum of squares), the 1-norm sums absolute values, and the infinity-norm takes the max absolute entry. A matrix norm measures how much a matrix can stretch a vector; the induced p-norm is max over x of ||Ax||_p / ||x||_p. The induced 2-norm equals the largest singular value of A, the 1-norm is the max absolute column sum, and the infinity-norm is the max absolute row sum. The Frobenius norm treats the matrix as a long vector (sqrt of sum of squared entries). Norms are the foundation of error analysis: relative error ||x_hat - x|| / ||x|| and the condition number ||A|| ||A^-1|| are both defined through them. Compute them with np.linalg.norm.

Worked Example 1

Problem. Compute the 1-, 2-, and inf-norms of v = [3, -4, 12].

  1. 1-norm = |3|+|-4|+|12| = 19. inf-norm = max(3,4,12) = 12. 2-norm = sqrt(9+16+144) = sqrt(169) = 13.
  2. `import numpy as np; v=np.array([3.,-4,12]); print(np.linalg.norm(v,1), np.linalg.norm(v), np.linalg.norm(v,np.inf))`.
  3. Output: `19.0 13.0 12.0`, matching the hand computation.

Answer. 1-norm = 19, 2-norm = 13, inf-norm = 12.

Worked Example 2

Problem. Compute the induced 1-, inf-, and 2-norms of A = [[1, 2],[3, 4]].

  1. 1-norm = max column abs sum = max(1+3, 2+4) = max(4, 6) = 6. inf-norm = max row abs sum = max(1+2, 3+4) = max(3, 7) = 7.
  2. 2-norm = largest singular value: `import numpy as np; A=np.array([[1.,2],[3,4]]); print(np.linalg.norm(A,2))` gives `5.464985704...`.
  3. Confirm 1- and inf-norms: `print(np.linalg.norm(A,1), np.linalg.norm(A,np.inf))` gives `6.0 7.0`.

Answer. 1-norm = 6, inf-norm = 7, 2-norm ≈ 5.465 (the largest singular value).

Common mistakes
  • Confusing the matrix 2-norm (largest singular value) with the Frobenius norm (sqrt of sum of squares); np.linalg.norm(A) defaults to Frobenius, np.linalg.norm(A,2) gives the spectral 2-norm.
  • Reading the induced 1-norm as a row sum or the inf-norm as a column sum; it is the opposite (1-norm = max column sum, inf-norm = max row sum).
✎ Try it yourself

Problem. For A = [[2, 0],[0, -3]], find the induced 2-norm and the Frobenius norm.

Solution. Singular values are the absolute eigenvalues here: 2 and 3, so the 2-norm (largest singular value) is 3. Frobenius norm = sqrt(2^2 + 0 + 0 + (-3)^2) = sqrt(13) ≈ 3.606. Check: `import numpy as np; A=np.array([[2.,0],[0,-3]]); print(np.linalg.norm(A,2), np.linalg.norm(A))` gives `3.0 3.6055...`. Answer: 2-norm = 3, Frobenius ≈ 3.606.

The condition number and sensitivity of solutions

The condition number kappa(A) bounds how much relative input error is amplified into relative solution error, predicting digit loss.

For Ax = b, the condition number kappa(A) = ||A|| ||A^-1|| (in the 2-norm, the ratio of largest to smallest singular value) bounds the worst-case error amplification: the relative error in x can be up to kappa(A) times the relative error in b or A. A rule of thumb is that you lose about log10(kappa) significant decimal digits when solving in float64, which carries ~16. So kappa ≈ 1e8 risks losing ~8 digits, and kappa near 1/eps ≈ 1e16 means the answer may have no correct digits. Conditioning is a property of the problem, not the algorithm — even a perfect solver cannot beat it. Compute it with np.linalg.cond. The Hilbert matrix is the classic ill-conditioned example, with kappa exploding as its size grows.

Worked Example 1

Problem. Compute the condition number of the 4x4 Hilbert matrix and predict digit loss.

  1. `from scipy.linalg import hilbert; import numpy as np; H=hilbert(4); k=np.linalg.cond(H); print(k)` gives about `15513.7`.
  2. Predicted digits lost ≈ log10(k): `print(np.log10(k))` ≈ 4.19, so ~4 of ~16 float64 digits are at risk.
  3. Solving H x = b for a known x and measuring the relative error confirms roughly that loss.

Answer. kappa ≈ 1.55e4, so log10 ≈ 4.2: expect to lose about 4 significant digits.

Worked Example 2

Problem. Contrast a well-conditioned identity-like matrix with an ill-conditioned near-singular one.

  1. Well-conditioned: `import numpy as np; print(np.linalg.cond(np.eye(3)))` gives `1.0` (perfect, no amplification).
  2. Ill-conditioned: `A=np.array([[1.,1],[1,1.0001]]); print(np.linalg.cond(A))` gives about `40002`, near-singular.
  3. A perturbation of b of size 1e-6 could be amplified by ~4e4, giving an x error near 4e-2 despite a perfect solver.

Answer. kappa(I) = 1 (ideal); the near-singular 2x2 has kappa ≈ 4e4, amplifying input error ~40000-fold.

Common mistakes
  • Blaming the solver when an ill-conditioned system gives a poor x; conditioning is the problem's fault — even an exact algorithm inherits kappa(A) amplification.
  • Assuming a small determinant means ill-conditioned; det depends on scaling. Use np.linalg.cond (singular-value ratio), not the determinant, to judge sensitivity.
✎ Try it yourself

Problem. If a system has kappa(A) ≈ 1e6 and the input data is accurate to ~1e-10 relative, roughly how accurate is the solution, and how many digits remain?

Solution. Relative solution error <= kappa * relative input error ≈ 1e6 * 1e-10 = 1e-4. Correct digits ≈ -log10(1e-4) = 4, but you started with ~16 from float64 and lose log10(1e6)=6 just from conditioning. Answer: solution accurate to ~1e-4 relative, about 4 correct significant digits remain.

Solving and factoring with SciPy and Julia

Production libraries wrap LAPACK: use scipy.linalg for factorizations and Julia's backslash operator for an automatic best-method solve.

In practice you call optimized LAPACK routines rather than hand-rolled loops. SciPy exposes them: scipy.linalg.lu_factor / lu_solve for reusable LU with pivoting, scipy.linalg.cholesky / cho_solve for SPD systems, scipy.linalg.solve for a one-shot solve (with assume_a='pos' or 'sym' to pick the right algorithm), and scipy.linalg.qr for least squares. NumPy's np.linalg.solve covers the common case. Julia makes this idiomatic: the backslash operator A \ b inspects A's structure and dispatches to the appropriate factorization (Cholesky if SPD, LU otherwise, QR for non-square) automatically, and you can precompute factorize(A) to reuse it. The lesson is to let the library choose: pass structural hints so it can select Cholesky over LU, and reuse factorizations across right-hand sides.

Worked Example 1

Problem. Solve an SPD system with scipy.linalg.cho_factor / cho_solve and reuse the factor for two b vectors.

  1. `from scipy.linalg import cho_factor, cho_solve; import numpy as np; A=np.array([[4.,2],[2,3]]); c=cho_factor(A)`.
  2. `x1=cho_solve(c, [1.,2]); x2=cho_solve(c, [0.,1]); print(x1, x2)` solves both with one factorization.
  3. Reusing c avoids re-factoring A; each solve is just two triangular sweeps.

Answer. cho_factor once, cho_solve per right-hand side: efficient SPD solving with the factor reused across b's.

Worked Example 2

Problem. Use scipy.linalg.solve with a structural hint and show the Julia equivalent.

  1. Python: `from scipy.linalg import solve; import numpy as np; A=np.array([[4.,2],[2,3]]); print(solve(A, [1.,2], assume_a='pos'))` uses Cholesky internally.
  2. Output: `[-0.125 0.75 ]`; assume_a='pos' tells SciPy the matrix is SPD so it picks the faster, stabler routine.
  3. Julia: `A = [4.0 2.0; 2.0 3.0]; b = [1.0, 2.0]; x = A \\ b` dispatches to Cholesky automatically since A is SPD.

Answer. solve(A, b, assume_a='pos') -> [-0.125, 0.75] via Cholesky; Julia's A \ b auto-selects the same factorization.

Common mistakes
  • Calling scipy.linalg.solve without assume_a for a known SPD or symmetric matrix; you forgo the faster Cholesky/LDL path the hint would enable.
  • Recomputing a factorization for each right-hand side; use lu_factor/cho_factor (or Julia's factorize(A)) once and reuse it across all b's.
✎ Try it yourself

Problem. Solve A x = b for A = [[3,1],[1,2]] (SPD) and b = [9, 8] using SciPy's Cholesky path; what is the Julia one-liner?

Solution. `from scipy.linalg import cho_factor, cho_solve; import numpy as np; A=np.array([[3.,1],[1,2]]); c=cho_factor(A); x=cho_solve(c, [9.,8]); print(x)` gives `[2. 3.]`. Verify A@x = [9, 8]. Julia: `[3.0 1.0; 1.0 2.0] \ [9.0, 8.0]` returns `[2.0, 3.0]`. Answer: x = [2, 3].

Key terms
  • LU decomposition — factoring A into a lower-triangular L and upper-triangular U so Ax = b solves by two triangular sweeps.
  • Partial pivoting — swapping rows to put the largest available pivot on the diagonal, improving stability (PA = LU).
  • Triangular solve — forward/back substitution that solves Ly = b and Ux = y in O(n^2) operations.
  • Cholesky decomposition — A = L L^T for symmetric positive-definite matrices; about twice as fast as LU.
  • Matrix norm — a measure of a matrix's size, such as the induced 2-norm equal to the largest singular value.
  • Condition number — kappa(A) = ||A|| ||A^-1||; bounds how much relative input error is amplified in the solution.
  • Ill-conditioned — a system with large condition number whose solution is highly sensitive to small input changes.
  • Residual — b - A x_hat, a cheap-to-compute diagnostic that is small even when the solution error is large for ill-conditioned A.
Assignment · Conditioning under a microscope

Build a Hilbert matrix of size n (use scipy.linalg.hilbert) for n from 2 to 12 and solve H x = b for a known true x. Use scipy.linalg.lu_factor / lu_solve and record both the condition number (numpy.linalg.cond) and the relative solution error as n grows. Show that the error tracks the condition number, and contrast with solving a well-conditioned random system.

Deliverable · A script and short report plotting condition number and relative error versus n, plus a sentence relating the observed digit loss to log10 of the condition number.

Quiz · 5 questions
  1. 1. Why solve Ax = b with LU decomposition instead of computing A^-1 and multiplying?

  2. 2. Partial pivoting is used during Gaussian elimination to:

  3. 3. A condition number of about 1e8 for a float64 system suggests you may lose roughly:

  4. 4. Cholesky decomposition applies to matrices that are:

  5. 5. A small residual b - A x_hat guarantees a small solution error only when A is:

You'll be able to

I can solve Ax = b with LU decomposition and explain why this beats forming the inverse.

I can apply partial pivoting and explain how it improves numerical stability.

I can compute and interpret a condition number to predict how sensitive a solution is to input error.

Weeks 7-8 Unit 4: Interpolation & Regression Numerically
distinguish-interpolation-approximationdiagnose-rungebuild-splinesformulate-least-squarescompare-normal-equations-qrcontrol-overfittinguse-scipy-interpolate
Lecture
Interpolation vs. approximation: fit exactly or fit well

Interpolation forces a curve through every data point exactly; approximation (regression) seeks a best overall fit that may miss every point.

Interpolation builds a function passing exactly through all n data points — appropriate when the data is essentially noise-free (e.g. table lookups, smooth physical samples). Approximation, typically least-squares regression, fits a lower-dimensional model that minimizes total squared error without touching every point — the right choice when data is noisy, because forcing the curve through noisy points fits the noise. The key trade-off: interpolation has zero residual at the nodes but can oscillate wildly between them (especially high-degree polynomials), while regression smooths through noise but leaves residuals. Numerically, choose by asking whether your data is exact (interpolate) or noisy (regress). Overfitting — using too flexible a model on noisy data — is the failure mode of treating an approximation problem as an interpolation problem.

Worked Example 1

Problem. Given exact points (0,1), (1,2), (2,5), find the interpolating quadratic.

  1. Fit y = a x^2 + b x + c through all three: at x=0, c=1; at x=1, a+b+1=2; at x=2, 4a+2b+1=5.
  2. From a+b=1 and 4a+2b=4 -> 2a+b=2; subtract: a=1, b=0. So y = x^2 + 1.
  3. `import numpy as np; print(np.polyfit([0,1,2],[1,2,5],2))` gives `[1. 0. 1.]`, confirming y = x^2 + 1.

Answer. y = x^2 + 1 passes exactly through all three points (zero residual).

Worked Example 2

Problem. For noisy data y ≈ 2x + noise, contrast interpolating vs. regressing 5 points.

  1. Noisy points: `import numpy as np; x=np.array([0,1,2,3,4.]); y=np.array([0.1,1.9,4.2,5.8,8.1])`.
  2. Interpolation needs a degree-4 polynomial to hit all 5: `np.polyfit(x,y,4)` wiggles to fit noise exactly (overfits).
  3. Regression with a line: `m,b=np.polyfit(x,y,1); print(m,b)` gives about `2.0 0.02`, recovering the true trend y≈2x.

Answer. Interpolation fits the noise with wiggles; the degree-1 regression recovers the true slope ~2 — better for noisy data.

Common mistakes
  • Interpolating noisy data with a high-degree polynomial; it fits the noise exactly and oscillates badly. Use regression (lower degree, least squares) for noisy data.
  • Assuming a zero residual at the data points means a good model; an interpolant can be perfect at the nodes yet wildly wrong between them.
✎ Try it yourself

Problem. You have 50 sensor readings with measurement noise and want the underlying linear trend. Interpolate or regress, and why?

Solution. Regress. The data is noisy, so forcing a curve through all 50 points (a degree-49 interpolant) would fit the noise and oscillate uselessly. A least-squares line `m, b = np.polyfit(x, y, 1)` minimizes total squared error and recovers the trend while ignoring noise. Answer: use regression (low-degree least squares) because the data is noisy; interpolation would overfit.

Polynomial interpolation and the Runge phenomenon

A unique degree n-1 polynomial passes through n points, but on equally spaced nodes high degree causes huge edge oscillations — the Runge phenomenon.

Through n points there is exactly one polynomial of degree at most n-1; it can be built via the Lagrange or Newton form, or fit with np.polyfit. The trap is that raising the degree does not always improve accuracy. On equally spaced nodes, interpolating a smooth function like Runge's f(x) = 1/(1+25x^2) with a high-degree polynomial produces enormous oscillations near the interval ends — the error grows, not shrinks, as the degree increases. The cause is that equally spaced nodes give a poorly conditioned interpolation; the cure is to cluster nodes toward the endpoints using Chebyshev points (denser near +/-1), which tame the oscillation and give near-optimal interpolation. Alternatively, abandon a single global polynomial for piecewise splines.

Worked Example 1

Problem. Fit a degree-2 polynomial through (-1, 1), (0, 0), (1, 1) and evaluate at x = 0.5.

  1. Symmetry suggests y = a x^2. From (1,1): a = 1, and (0,0) is satisfied. So p(x) = x^2.
  2. `import numpy as np; p=np.polyfit([-1,0,1],[1,0,1],2); print(p)` gives `[1. 0. 0.]` = x^2.
  3. Evaluate: `print(np.polyval(p, 0.5))` gives `0.25`.

Answer. The interpolating polynomial is p(x) = x^2; p(0.5) = 0.25.

Worked Example 2

Problem. Demonstrate the Runge phenomenon: interpolate 1/(1+25x^2) at 15 equally spaced nodes and check the max error.

  1. `import numpy as np; f=lambda x:1/(1+25*x**2); xn=np.linspace(-1,1,15); p=np.polyfit(xn,f(xn),14)`.
  2. `xt=np.linspace(-1,1,400); err=np.max(np.abs(np.polyval(p,xt)-f(xt))); print(err)` gives a large error (~7), worst near x=+/-1.
  3. Using Chebyshev nodes `xc=np.cos(np.linspace(0,np.pi,15))` instead dramatically reduces the max error.

Answer. Equally spaced degree-14 interpolation has max error ~7 near the edges; Chebyshev nodes fix it — classic Runge phenomenon.

Common mistakes
  • Increasing polynomial degree on equally spaced nodes to improve accuracy; it worsens edge oscillation (Runge). Use Chebyshev nodes or splines instead.
  • Building the Lagrange/Vandermonde system at high degree without noticing it is ill-conditioned; small node changes then swing the polynomial wildly.
✎ Try it yourself

Problem. Why does interpolating a smooth function at 20 equally spaced points often give a worse result than at 5 points near the edges?

Solution. Higher degree on equally spaced nodes triggers the Runge phenomenon: the interpolating polynomial of degree 19 develops large oscillations near +/-1 because the node distribution makes the problem ill-conditioned. With 5 points the degree is only 4, so the oscillation is mild. The fix is Chebyshev nodes (clustered at the ends) or piecewise splines. Answer: more equally spaced nodes raise the degree and amplify edge oscillations, so the error grows.

Piecewise and cubic spline interpolation

Splines interpolate with low-degree polynomial pieces stitched together smoothly, avoiding high-degree oscillation entirely.

Instead of one global high-degree polynomial, a spline uses a separate low-degree polynomial on each interval between knots, joined for smoothness. A linear spline connects points with straight segments (continuous but kinked). The cubic spline is the standard: each piece is a cubic, and the pieces match in value, first derivative, and second derivative at the interior knots, giving a curve that looks smooth to the eye and has no Runge oscillation. The continuity conditions plus boundary conditions (natural: second derivative zero at the ends; or not-a-knot, SciPy's default) determine all the cubic coefficients via a tridiagonal linear system that solves in O(n). Use scipy.interpolate.CubicSpline. Splines are the go-to interpolant for smooth-looking, well-behaved curves through many points.

Worked Example 1

Problem. Build a cubic spline through (0,0), (1,1), (2,0), (3,1) and evaluate at x = 1.5.

  1. `from scipy.interpolate import CubicSpline; import numpy as np; x=np.array([0,1,2,3.]); y=np.array([0,1,0,1.])`.
  2. `cs = CubicSpline(x, y); print(cs(1.5))` gives `0.34375` (a smooth value between the knots).
  3. The spline passes exactly through the data: `print(cs(x))` returns `[0. 1. 0. 1.]`.

Answer. The cubic spline interpolates the points and gives cs(1.5) ≈ 0.344, smoothly between knots.

Worked Example 2

Problem. Compare a cubic spline to a high-degree polynomial on Runge's function (max error).

  1. `import numpy as np; from scipy.interpolate import CubicSpline; f=lambda x:1/(1+25*x**2); xn=np.linspace(-1,1,15); cs=CubicSpline(xn,f(xn))`.
  2. `xt=np.linspace(-1,1,400); print(np.max(np.abs(cs(xt)-f(xt))))` gives a small error (~0.02), far below the polynomial's ~7.
  3. The spline's low-degree pieces never oscillate, so it tracks the smooth function closely everywhere.

Answer. The cubic spline's max error is ~0.02 versus ~7 for the degree-14 polynomial — splines defeat the Runge problem.

Common mistakes
  • Expecting a spline's derivatives beyond the second to be continuous; a cubic spline matches only up to the second derivative, so the third derivative jumps at knots.
  • Extrapolating with a spline far outside the data range; the end cubics are not constrained out there and can diverge — splines are for interpolation, not extrapolation.
✎ Try it yourself

Problem. Fit a natural cubic spline to (0,1), (1,3), (2,2) and evaluate at x = 0.5.

Solution. `from scipy.interpolate import CubicSpline; import numpy as np; x=np.array([0,1,2.]); y=np.array([1,3,2.]); cs=CubicSpline(x,y,bc_type='natural'); print(cs(0.5))`. The natural spline (zero second derivative at the ends) gives cs(0.5) ≈ 2.0625. It passes through all three points: cs([0,1,2]) = [1,3,2]. Answer: cs(0.5) ≈ 2.06 with the natural boundary condition.

Least squares as an overdetermined system

When there are more equations than unknowns, no exact solution exists; least squares finds the x minimizing ||Ax - b||_2.

Fitting a model with p parameters to m > p data points gives an overdetermined system Ax = b that generally has no exact solution. Least squares instead finds the x that minimizes the residual norm ||Ax - b||_2^2 — the closest fit in the Euclidean sense. Geometrically, A x* is the orthogonal projection of b onto the column space of A, so the residual b - A x* is perpendicular to every column of A. This orthogonality gives the normal equations A^T A x = A^T b, whose solution is the least-squares estimate. The columns of A are the basis functions of the model (for a polynomial fit, powers of x — a Vandermonde matrix). NumPy's np.linalg.lstsq solves it stably; the residual sum of squares measures fit quality.

Worked Example 1

Problem. Fit a line y = a x + b to (1,1), (2,2), (3,2) by least squares.

  1. Design matrix A = [[1,1],[2,1],[3,1]] (columns x and 1), b = [1,2,2].
  2. `import numpy as np; A=np.array([[1,1],[2,1],[3,1.]]); b=np.array([1,2,2.]); sol=np.linalg.lstsq(A,b,rcond=None)[0]; print(sol)` gives `[0.5 0.6667]`.
  3. So the best-fit line is y = 0.5 x + 0.667; it minimizes the sum of squared vertical residuals.

Answer. Best-fit line y ≈ 0.5 x + 0.667; least squares gives slope 0.5, intercept 0.667.

Worked Example 2

Problem. Fit y = c (a single constant) to data [4, 6, 8, 10] by least squares and show it is the mean.

  1. A is a column of ones; minimizing sum (c - y_i)^2 gives c = mean(y).
  2. `import numpy as np; y=np.array([4,6,8,10.]); A=np.ones((4,1)); c=np.linalg.lstsq(A,y,rcond=None)[0]; print(c)` gives `[7.]`.
  3. Indeed mean([4,6,8,10]) = 7, confirming the least-squares constant is the arithmetic mean.

Answer. The least-squares constant fit is c = 7, exactly the mean of the data.

Common mistakes
  • Expecting an exact solution to an overdetermined system; with m > p there usually is none, so you minimize the residual norm instead of solving Ax = b exactly.
  • Forgetting to include a column of ones in the design matrix for an intercept; without it the fit is forced through the origin.
✎ Try it yourself

Problem. Set up and solve the least-squares line fit for (0,1), (1,1), (2,3), (3,4).

Solution. Design matrix A = [[0,1],[1,1],[2,1],[3,1]], b = [1,1,3,4]. `import numpy as np; A=np.array([[0,1],[1,1],[2,1],[3,1.]]); b=np.array([1,1,3,4.]); print(np.linalg.lstsq(A,b,rcond=None)[0])` gives `[1.1 0.5]`. So y ≈ 1.1 x + 0.5. Answer: best-fit line y = 1.1 x + 0.5 (slope 1.1, intercept 0.5).

Normal equations vs. QR for stable fitting

The normal equations are simple but square the condition number; QR factorization solves least squares stably without forming A^T A.

The normal equations A^T A x = A^T b give the least-squares solution directly, but forming A^T A roughly squares the condition number (kappa(A^T A) = kappa(A)^2), so a moderately ill-conditioned A becomes severely so and accuracy collapses. The stable alternative factors A = QR with Q orthogonal and R upper-triangular; since orthogonal transforms preserve norms, ||Ax - b|| = ||Rx - Q^T b||, and you solve R x = Q^T b (the relevant top block) by back substitution — all at condition number kappa(A), not its square. np.linalg.lstsq and scipy.linalg.lstsq use a stable SVD/QR-based method internally, which is why you should call them rather than hand-coding the normal equations. Reserve the normal equations for small, well-conditioned problems.

Worked Example 1

Problem. Solve a least-squares fit via QR for A=[[1,1],[1,2],[1,3]], b=[1,2,2].

  1. `import numpy as np; A=np.array([[1,1],[1,2],[1,3.]]); b=np.array([1,2,2.])`.
  2. `Q,R=np.linalg.qr(A); x=np.linalg.solve(R, Q.T @ b); print(x)` gives `[0.6667 0.5]` (intercept, slope).
  3. This matches np.linalg.lstsq but avoids forming A^T A, so it is stable even when A is ill-conditioned.

Answer. QR gives x = [0.667, 0.5]; it solves R x = Q^T b stably without squaring the condition number.

Worked Example 2

Problem. Show the normal equations lose accuracy where QR does not, on an ill-conditioned fit.

  1. Build a nearly collinear design: `import numpy as np; eps=1e-7; A=np.array([[1,1],[1,1+eps],[1,1+2*eps]]); b=np.array([1,2,3.])`.
  2. kappa(A) is large, so kappa(A^T A) ≈ kappa(A)^2 is enormous; `np.linalg.solve(A.T@A, A.T@b)` returns a wildly inaccurate or singular result.
  3. `np.linalg.lstsq(A,b,rcond=None)[0]` (SVD/QR-based) returns a sensible answer despite the ill-conditioning.

Answer. Normal equations blow up (kappa squared) on the near-collinear design; lstsq's QR/SVD path stays accurate.

Common mistakes
  • Hand-coding x = inv(A.T @ A) @ A.T @ b; this squares kappa(A) and is the least stable approach. Use np.linalg.lstsq (QR/SVD) instead.
  • Assuming the normal equations are always fine because they are exact in real arithmetic; in floating point the squared condition number can destroy all accuracy.
✎ Try it yourself

Problem. Explain why QR is preferred over the normal equations when A has condition number 1e4, in float64.

Solution. The normal equations form A^T A, whose condition number is kappa(A)^2 = (1e4)^2 = 1e8. In float64 (~16 digits) that risks losing ~8 digits. QR works directly with A at condition number 1e4, risking only ~4 digits lost, because orthogonal transformations preserve the 2-norm and never square the conditioning. Answer: QR keeps kappa at 1e4 (lose ~4 digits) while the normal equations inflate it to 1e8 (lose ~8 digits).

Polynomial fitting, regularization, and overfitting

Raising model flexibility lowers training error but risks fitting noise; regularization penalizes large coefficients to restore generalization.

Fitting a degree-d polynomial by least squares (np.polyfit) gets more flexible as d grows. Past a point, extra flexibility fits the noise rather than the signal — overfitting — yielding tiny training error but large error on new data and huge, oscillating coefficients. The standard remedy is regularization: ridge (L2) regression minimizes ||Ax - b||^2 + lambda ||x||^2, shrinking coefficients toward zero and trading a little bias for much lower variance. The penalty lambda is a hyperparameter chosen by validation (e.g. cross-validation). Ridge also improves conditioning: the regularized normal equations (A^T A + lambda I) x = A^T b are always well-posed even when A^T A is singular. Lasso (L1) instead drives some coefficients exactly to zero, doubling as feature selection.

Worked Example 1

Problem. Fit degree-1 and degree-9 polynomials to 10 noisy points and compare coefficient sizes.

  1. `import numpy as np; np.random.seed(0); x=np.linspace(0,1,10); y=2*x+0.1*np.random.randn(10)`.
  2. `c1=np.polyfit(x,y,1); c9=np.polyfit(x,y,9); print(np.abs(c1).max(), np.abs(c9).max())`.
  3. The degree-9 fit hits every point (near-zero training residual) but its coefficients are enormous (thousands), a hallmark of overfitting.

Answer. Degree-1 keeps small coefficients (~2); degree-9 has huge oscillating coefficients despite zero training error — overfitting.

Worked Example 2

Problem. Apply ridge regularization to the regularized normal equations and watch coefficients shrink.

  1. Build a design matrix A (e.g. polynomial features) and solve `x = np.linalg.solve(A.T@A + lam*np.eye(A.shape[1]), A.T@b)` for lam=0 then lam=1e-2.
  2. `import numpy as np; A=np.vander(np.linspace(0,1,10), 6); b=2*np.linspace(0,1,10)+0.1*np.random.randn(10)`.
  3. `x0=np.linalg.solve(A.T@A, A.T@b); x1=np.linalg.solve(A.T@A+1e-2*np.eye(6), A.T@b); print(np.abs(x0).max(), np.abs(x1).max())` shows the lam=1e-2 coefficients are markedly smaller.

Answer. Adding lambda*I shrinks the coefficient magnitudes and makes the system well-conditioned, reducing overfitting.

Common mistakes
  • Choosing the polynomial degree by minimizing training error; that drives you to overfit. Use a validation set or cross-validation to pick degree and lambda.
  • Forgetting to scale features before ridge; the L2 penalty is not scale-invariant, so unscaled features get penalized unevenly.
✎ Try it yourself

Problem. You fit a degree-12 polynomial to 13 noisy points and get zero training error but terrible predictions. Diagnose and fix.

Solution. This is overfitting: a degree-12 polynomial interpolates all 13 points exactly (zero residual) but fits the noise, so coefficients are huge and predictions oscillate. Fix by (1) lowering the degree to match the true signal complexity, and/or (2) adding ridge regularization: solve (A^T A + lambda I) x = A^T b with lambda chosen by cross-validation to shrink coefficients. Answer: reduce model flexibility (lower degree) and/or regularize with L2 to restore generalization.

Interpolation and curve fitting in SciPy

SciPy offers ready interpolators (CubicSpline, interp1d) and nonlinear curve fitting (curve_fit) so you rarely hand-code these.

For interpolation, scipy.interpolate provides CubicSpline (smooth C2 cubic interpolant), interp1d (linear/cubic/nearest with a callable result), and PchipInterpolator (shape-preserving, no overshoot). For approximation, numpy.polyfit handles polynomial least squares, np.linalg.lstsq handles general linear least squares, and scipy.optimize.curve_fit fits arbitrary nonlinear models y = f(x; params) by minimizing squared residuals via Levenberg-Marquardt, returning best-fit parameters and a covariance matrix for uncertainty estimates. The decision tree: noise-free smooth data -> CubicSpline; linear-in-parameters model on noisy data -> polyfit/lstsq; nonlinear model (exponentials, sigmoids) -> curve_fit with a good initial guess p0. Always supply p0 for curve_fit and sanity-check the returned covariance for parameter reliability.

Worked Example 1

Problem. Use scipy.optimize.curve_fit to fit y = a*exp(b*x) to sampled data.

  1. `import numpy as np; from scipy.optimize import curve_fit; x=np.linspace(0,2,20); y=3*np.exp(0.5*x)+0.05*np.random.randn(20)`.
  2. `model=lambda x,a,b: a*np.exp(b*x); popt,pcov=curve_fit(model, x, y, p0=[1,1]); print(popt)` recovers about `[3.0, 0.5]`.
  3. Parameter uncertainties: `print(np.sqrt(np.diag(pcov)))` gives the standard errors of a and b.

Answer. curve_fit recovers a ≈ 3.0, b ≈ 0.5; pcov's diagonal square-roots give the parameter standard errors.

Worked Example 2

Problem. Build a callable linear interpolant with scipy.interpolate.interp1d and evaluate between samples.

  1. `import numpy as np; from scipy.interpolate import interp1d; x=np.array([0,1,2,3.]); y=np.array([0,1,4,9.])`.
  2. `f=interp1d(x, y, kind='linear'); print(f(1.5))` gives `2.5` (the straight-line value between (1,1) and (2,4)).
  3. Use kind='cubic' for a smoother curve: `g=interp1d(x,y,kind='cubic'); print(g(1.5))` returns a smoother estimate.

Answer. interp1d returns a callable; linear interp gives f(1.5) = 2.5, cubic gives a smoother value.

Common mistakes
  • Calling curve_fit without an initial guess p0 for a nonlinear model; it may converge to a wrong local minimum or fail. Provide a sensible p0.
  • Using interp1d (or any interpolator) to extrapolate beyond the data range; by default it errors or behaves unreliably outside [x.min(), x.max()].
✎ Try it yourself

Problem. Fit y = a / (1 + exp(-b*(x - c))) (a logistic) to data with scipy.optimize.curve_fit; what must you supply?

Solution. You must give curve_fit the model function and an initial guess p0 for [a, b, c]: `from scipy.optimize import curve_fit; model=lambda x,a,b,c: a/(1+np.exp(-b*(x-c))); popt,pcov=curve_fit(model, x, y, p0=[max(y), 1.0, np.median(x)])`. A reasonable p0 (a near the data max, c near the midpoint) is essential so Levenberg-Marquardt converges to the right minimum. Answer: supply the model and a sensible p0; popt holds the fitted parameters, pcov their covariance.

Key terms
  • Interpolation — constructing a function that passes exactly through every given data point.
  • Runge phenomenon — large oscillations near the edges of an interval when interpolating with a high-degree polynomial on equally spaced nodes.
  • Cubic spline — a piecewise cubic interpolant that is twice continuously differentiable at the interior knots.
  • Least squares — finding parameters that minimize the sum of squared residuals for an overdetermined system.
  • Normal equations — A^T A x = A^T b; mathematically correct but squares the condition number, hurting stability.
  • QR decomposition — factoring A = QR to solve least squares stably without forming A^T A.
  • Overfitting — fitting noise as if it were signal, giving low training error but poor generalization.
  • Regularization — adding a penalty (e.g. ridge / L2) to discourage large coefficients and reduce overfitting.
Assignment · Interpolate, then regress

Sample Runge's function f(x) = 1/(1 + 25 x**2) at equally spaced points and interpolate with both a single high-degree polynomial (numpy.polyfit) and a cubic spline (scipy.interpolate.CubicSpline). Plot both against the true function to expose the Runge oscillations. Then add Gaussian noise to the samples and fit a low-degree least-squares polynomial, comparing the normal-equations solution to numpy.linalg.lstsq.

Deliverable · A script and report with two figures (interpolation comparison and noisy regression fit) and a paragraph on when you would prefer a spline, a regression, or an exact interpolant.

Quiz · 5 questions
  1. 1. The Runge phenomenon is best avoided by:

  2. 2. A cubic spline interpolant is constructed to be:

  3. 3. Solving least squares via the normal equations is numerically risky because it:

  4. 4. Interpolation differs from regression in that interpolation:

  5. 5. A high-degree polynomial that fits training points perfectly but predicts new points poorly is exhibiting:

You'll be able to

I can interpolate data with polynomials and splines and explain when high-degree polynomials fail.

I can set up and solve a least-squares problem and explain why QR is more stable than the normal equations.

I can recognize overfitting and choose between interpolation and regression for a given dataset.

Weeks 9-10 Unit 5: Numerical Differentiation & Integration
derive-finite-differencescompare-difference-schemesbalance-truncation-roundoffapply-newton-cotesanalyze-order-of-accuracyuse-adaptive-quadratureuse-scipy-quad
Lecture
Finite differences from the Taylor series

Taylor expansion turns a derivative into a difference quotient and reveals exactly how big the truncation error is.

A finite-difference formula approximates a derivative using nearby function values, and the Taylor series both derives the formula and quantifies its error. Expanding f(x+h) = f(x) + h f'(x) + (h^2/2) f''(x) + ... and solving for f'(x) gives the forward difference f'(x) ≈ (f(x+h) - f(x))/h with leading error (h/2) f''(x), so it is first-order accurate, O(h). Combining expansions cleverly cancels low-order terms to raise the order: subtracting f(x-h) from f(x+h) cancels the f'' term, yielding the second-order central difference. The Taylor remainder makes the error term explicit, which is how we predict that halving h shrinks a p-th order error by 2^p. This is the workhorse for numerical derivatives and the building block of ODE and PDE solvers.

Worked Example 1

Problem. Derive the forward-difference formula for f'(x) and its leading error from Taylor's series.

  1. Taylor: f(x+h) = f(x) + h f'(x) + (h^2/2) f''(xi). Rearrange: (f(x+h)-f(x))/h = f'(x) + (h/2) f''(xi).
  2. So forward difference D = (f(x+h)-f(x))/h approximates f'(x) with error (h/2) f''(xi) = O(h).
  3. Numerically for f(x)=e^x at x=0, h=0.01: `import numpy as np; print((np.exp(0.01)-1)/0.01)` gives `1.00501...`, off from 1 by ~5e-3 ≈ h/2.

Answer. Forward difference is first-order, O(h); the e^x test gives 1.00501, error ~h/2 = 0.005 as predicted.

Worked Example 2

Problem. Derive the second-order central difference by combining f(x+h) and f(x-h).

  1. f(x+h) = f + h f' + (h^2/2) f'' + (h^3/6) f'''; f(x-h) = f - h f' + (h^2/2) f'' - (h^3/6) f'''.
  2. Subtract: f(x+h) - f(x-h) = 2h f' + (h^3/3) f''' + ..., so (f(x+h)-f(x-h))/(2h) = f' + (h^2/6) f''' = O(h^2).
  3. For f=sin at x=1, h=0.01: `import numpy as np; print((np.sin(1.01)-np.sin(0.99))/0.02)` gives `0.5403...`, matching cos(1)=0.5403 to ~1e-5.

Answer. Central difference is second-order, O(h^2); the f' and odd terms reinforce while f'' cancels — error ~h^2/6 f'''.

Common mistakes
  • Quoting an error order without deriving it from the Taylor remainder; the leading uncancelled term sets the order (h for forward, h^2 for central).
  • Assuming a smaller h always means smaller error; truncation error shrinks but round-off grows, so there is an optimal h (covered later).
✎ Try it yourself

Problem. Use a forward difference with h = 0.1 to approximate f'(2) for f(x) = x^2, and compare to the exact value.

Solution. Forward difference: (f(2.1) - f(2))/0.1 = (4.41 - 4)/0.1 = 0.41/0.1 = 4.1. Exact f'(2) = 2*2 = 4. The error is 0.1, equal to (h/2)*f'' = (0.1/2)*2 = 0.1, confirming O(h) accuracy. Answer: approximation 4.1, exact 4, error 0.1 = (h/2)*f''.

Forward, backward, and central differences

The three basic stencils trade accuracy for which points they use; central is second-order, forward and backward are first-order.

Three standard first-derivative stencils exist. Forward difference (f(x+h)-f(x))/h uses the point ahead; backward difference (f(x)-f(x-h))/h uses the point behind; both are first-order O(h). The central difference (f(x+h)-f(x-h))/(2h) straddles x symmetrically, cancelling the second-order Taylor term to give second-order O(h^2) accuracy at the same cost. Use forward/backward at domain boundaries where you cannot reach both sides (e.g. the start or end of a time series), and central everywhere in the interior for the extra accuracy. The same idea extends to the second derivative: (f(x+h) - 2f(x) + f(x-h))/h^2 is a second-order central approximation to f''(x). These stencils underpin finite-difference solvers for differential equations.

Worked Example 1

Problem. Approximate f'(1) for f(x)=ln(x) with forward, backward, and central differences, h=0.1; exact is 1.

  1. `import numpy as np; f=np.log; h=0.1; x=1.0`. Forward: `print((f(x+h)-f(x))/h)` gives `0.9531`.
  2. Backward: `print((f(x)-f(x-h))/h)` gives `1.0536`. Central: `print((f(x+h)-f(x-h))/(2*h))` gives `1.0033`.
  3. Errors: forward -0.047, backward +0.054, central +0.0033 — central is ~15x more accurate at the same h.

Answer. Forward 0.953, backward 1.054, central 1.003; central's O(h^2) error (0.003) beats the O(h) one-sided errors (~0.05).

Worked Example 2

Problem. Approximate f''(0) for f(x)=cos(x) with the central second-difference, h=0.01; exact is -1.

  1. Stencil: (f(h) - 2 f(0) + f(-h))/h^2. `import numpy as np; h=0.01; print((np.cos(h)-2*np.cos(0)+np.cos(-h))/h**2)`.
  2. Output: `-0.99999...`, very close to the exact f''(0) = -cos(0) = -1.
  3. The error is O(h^2) ≈ (h^2/12) f'''' , here about 8e-6, tiny for h=0.01.

Answer. Second central difference gives ≈ -1.0000, matching f''(0) = -1 to ~1e-5 (O(h^2) error).

Common mistakes
  • Using a one-sided (forward/backward) difference in the interior where a central difference is available; you needlessly drop from O(h^2) to O(h) accuracy.
  • Forgetting the 2h denominator in the central difference (writing /h instead of /(2h)); it halves and doubles the estimate incorrectly.
✎ Try it yourself

Problem. For f(x) = x^3 at x = 1 with h = 0.1, compute the central difference and compare to f'(1) = 3.

Solution. Central difference = (f(1.1) - f(0.9))/(2*0.1) = (1.331 - 0.729)/0.2 = 0.602/0.2 = 3.01. Exact f'(1) = 3*1^2 = 3. The error is 0.01 = (h^2/6)*f'''(1) = (0.01/6)*6 = 0.01, confirming O(h^2). Answer: central difference = 3.01, exact 3, error 0.01.

Truncation error vs. round-off: the optimal step size

Truncation error falls as h shrinks while round-off rises, so total error is V-shaped with an optimal nonzero h.

Two competing errors govern finite differences. Truncation error comes from cutting off the Taylor series and decreases as h shrinks (like h for forward, h^2 for central). Round-off error comes from cancellation: subtracting two nearly equal function values f(x+h) and f(x), each carrying relative error ~eps, leaves an absolute error ~eps that gets divided by h, so it grows like eps/h as h shrinks. Total error is therefore U/V-shaped: minimized at an optimal h. For the forward difference, balancing (h/2)|f''| against eps|f|/h gives h_opt ≈ sqrt(eps) ≈ 1.5e-8 in float64; for the central difference the balance gives h_opt ≈ eps^(1/3) ≈ 6e-6. Picking h far below these (e.g. 1e-15) makes the answer worse, not better — a classic beginner trap.

Worked Example 1

Problem. Estimate the optimal h for a forward-difference derivative in float64 and explain the scaling.

  1. Total error ≈ (h/2)|f''| + eps|f|/h. Minimize: d/dh = |f''|/2 - eps|f|/h^2 = 0 -> h^2 = 2 eps |f|/|f''|.
  2. With |f| ≈ |f''| ≈ 1, h_opt ≈ sqrt(2 eps) = `import numpy as np; print(np.sqrt(2*2.22e-16))` ≈ `2.1e-8`.
  3. At that h the minimum total error is ~sqrt(eps) ≈ 1.5e-8.

Answer. Forward-difference h_opt ≈ sqrt(eps) ≈ 1.5e-8, giving a best achievable error of about sqrt(eps).

Worked Example 2

Problem. Show the V curve numerically for the central difference of sin at x=1 across h.

  1. `import numpy as np; x=1.0\nfor h in [1e-1,1e-3,1e-6,1e-9,1e-12]:\n d=(np.sin(x+h)-np.sin(x-h))/(2*h); print(h, abs(d-np.cos(x)))`
  2. Errors fall as h decreases (truncation, ~h^2) until about h≈1e-6, then rise again (round-off, ~eps/h).
  3. The minimum near h ≈ 6e-6 = eps^(1/3) gives the smallest total error (~1e-11); h=1e-12 is much worse.

Answer. Error is V-shaped: best near h ≈ eps^(1/3) ≈ 6e-6 for the central difference; smaller h worsens it via round-off.

Common mistakes
  • Driving h toward machine zero to 'improve' a numerical derivative; below h_opt, cancellation round-off (~eps/h) dominates and the error grows.
  • Using the same optimal h for forward and central differences; they scale differently (sqrt(eps) vs eps^(1/3)) because their truncation orders differ.
✎ Try it yourself

Problem. For the central difference, why is h = 1e-12 a worse choice than h = 1e-6 in float64?

Solution. Central-difference total error ≈ (h^2/6)|f'''| + eps|f|/h. At h=1e-6 truncation is ~1.7e-13 and round-off ~2.2e-10, near the V's bottom. At h=1e-12 truncation is negligible but round-off is ~eps/h ≈ 2.2e-4, far larger. So h=1e-12 sits on the round-off-dominated side of the V. Answer: h=1e-12 amplifies cancellation round-off (~2e-4) while h=1e-6 is near optimal (~2e-10).

Newton-Cotes rules: trapezoid and Simpson

Newton-Cotes rules integrate by replacing the integrand with an interpolating polynomial: lines (trapezoid) or parabolas (Simpson).

Newton-Cotes quadrature approximates an integral by integrating a polynomial that interpolates the integrand at equally spaced points. The trapezoidal rule fits a straight line on each subinterval, giving (h/2)(f0 + f1) per panel and O(h^2) accuracy. Simpson's rule fits a parabola over each pair of subintervals, giving (h/3)(f0 + 4f1 + f2) and O(h^4) accuracy — a big jump because the symmetric parabola also integrates cubics exactly (it has one extra order of precision than its degree suggests). Higher-order Newton-Cotes rules exist (Simpson's 3/8, Boole's) but the weights eventually go negative and become unstable, so in practice trapezoid and Simpson dominate, applied in composite form (next lesson). Simpson requires an even number of subintervals.

Worked Example 1

Problem. Estimate the integral of x^2 from 0 to 2 with the single trapezoidal rule; exact is 8/3.

  1. Trapezoid: (b-a)/2 * (f(a)+f(b)) = (2-0)/2 * (0 + 4) = 1 * 4 = 4.
  2. Exact integral = 8/3 ≈ 2.667; the single trapezoid overestimates because x^2 is convex (the line lies above the curve).
  3. `import numpy as np; print(np.trapz([0,4],[0,2]))` gives `4.0`, matching the hand result.

Answer. Single trapezoid gives 4 vs. exact 8/3 ≈ 2.667; the convex curve makes the line overestimate.

Worked Example 2

Problem. Estimate the same integral of x^2 from 0 to 2 with Simpson's rule (one parabola); exact is 8/3.

  1. Simpson uses midpoint x=1: (h/3)(f0 + 4 f1 + f2) with h=1: (1/3)(0 + 4*1 + 4) = (1/3)(8) = 8/3.
  2. This is exact because Simpson integrates quadratics (and cubics) with no error.
  3. `from scipy.integrate import simpson; import numpy as np; print(simpson([0,1,4], x=[0,1,2]))` gives `2.6667`.

Answer. Simpson's rule gives exactly 8/3 ≈ 2.667; it integrates the quadratic with zero error.

Common mistakes
  • Applying Simpson's rule with an odd number of subintervals; it requires an even number (pairs of panels), or you must handle the last panel separately.
  • Reaching for very high-order Newton-Cotes rules for accuracy; their weights turn negative and become numerically unstable — use composite Simpson or Gaussian quadrature instead.
✎ Try it yourself

Problem. Use Simpson's rule with nodes at 0, 1, 2 to integrate f(x) = x^3 from 0 to 2; exact is 4.

Solution. Simpson: (h/3)(f(0) + 4 f(1) + f(2)) with h=1 = (1/3)(0 + 4*1 + 8) = (1/3)(12) = 4. Exact integral of x^3 from 0 to 2 is 2^4/4 = 4. They match exactly because Simpson is exact for cubics (degree of precision 3). Answer: Simpson gives 4, exactly the true integral.

Composite rules and order of accuracy

Composite rules apply the basic stencil on many small panels; the error inherits the panel order, so halving h cuts error by 2^p.

A single trapezoid or Simpson panel over a wide interval is crude; the composite rules split [a,b] into n subintervals of width h = (b-a)/n and sum the basic rule over each, dramatically improving accuracy. The composite trapezoid rule sums (h/2)(f_i + f_{i+1}) and is globally O(h^2); composite Simpson sums parabolic panels and is globally O(h^4). Order of accuracy is the exponent p in error ~ C h^p: it tells you the payoff of refinement. For a p-th order rule, halving h (doubling n) multiplies the error by (1/2)^p — a factor of 4 for trapezoid (p=2) and 16 for Simpson (p=4). You can verify the order empirically by computing the error at h and h/2 and checking their ratio against 2^p; this is the standard convergence test.

Worked Example 1

Problem. Integrate sin(x) from 0 to pi with composite trapezoid at n=4 and n=8; exact is 2.

  1. `import numpy as np; f=np.sin\nfor n in [4,8]:\n x=np.linspace(0,np.pi,n+1); print(n, np.trapz(f(x),x))`
  2. n=4 gives `1.8961` (error 0.104); n=8 gives `1.9742` (error 0.0258).
  3. Error ratio 0.104/0.0258 ≈ 4.0, confirming O(h^2): halving h cut the error by ~4.

Answer. Composite trapezoid: 1.896 (n=4), 1.974 (n=8); the 4x error reduction confirms second-order accuracy.

Worked Example 2

Problem. Integrate sin(x) from 0 to pi with composite Simpson at n=4 and n=8; verify O(h^4).

  1. `from scipy.integrate import simpson; import numpy as np\nfor n in [4,8]:\n x=np.linspace(0,np.pi,n+1); print(n, simpson(np.sin(x), x=x))`
  2. n=4 gives `2.00456` (error 4.6e-3); n=8 gives `2.000269` (error 2.7e-4).
  3. Error ratio 4.6e-3/2.7e-4 ≈ 17 ≈ 16, confirming O(h^4): halving h cut the error by ~16.

Answer. Composite Simpson: 2.00456 (n=4), 2.000269 (n=8); the ~16x error reduction confirms fourth-order accuracy.

Common mistakes
  • Comparing error at only one resolution; to confirm order p you must compute the error at h and h/2 and check the ratio is ~2^p.
  • Assuming Simpson always beats trapezoid; for non-smooth integrands (kinks, discontinuities) the high-order advantage collapses and both degrade to low order.
✎ Try it yourself

Problem. Composite trapezoid gives error 0.08 at n=10 and 0.02 at n=20 for some integral. What is the order of accuracy?

Solution. Doubling n from 10 to 20 halves h. The error ratio is 0.08/0.02 = 4 = 2^p, so 2^p = 4 gives p = 2. This is second-order accuracy, exactly what the composite trapezoidal rule predicts. Answer: order p = 2 (second-order), since halving h reduced the error by a factor of 4.

Adaptive quadrature and error control

Adaptive quadrature concentrates sample points where the integrand is hard, refining only until a local error estimate meets the tolerance.

Uniform composite rules waste effort: they use the same fine spacing everywhere, even where the integrand is nearly straight. Adaptive quadrature instead estimates the local error on each subinterval (typically by comparing a coarse and a fine rule, e.g. Simpson on the whole interval vs. on its two halves) and recursively subdivides only the panels whose estimated error exceeds their share of the tolerance. This places many points near singularities, peaks, or rapid oscillation and few where the function is smooth, reaching a target accuracy with far fewer evaluations. scipy.integrate.quad implements an adaptive Gauss-Kronrod scheme and returns both the integral and an estimated absolute error. It handles infinite limits and integrable singularities far better than fixed-grid rules, which is why it is the default general-purpose integrator.

Worked Example 1

Problem. Integrate the peaked function 1/(1+25x^2) from -1 to 1 with scipy.integrate.quad and read the error estimate.

  1. `from scipy.integrate import quad; import numpy as np; val, err = quad(lambda x: 1/(1+25*x**2), -1, 1)`.
  2. `print(val, err)` gives `0.5493603067780064 7.0e-12`; the exact value is (2/5) arctan(5) = 0.54936.
  3. quad clustered points near the sharp peak at x=0 and reports a tiny estimated error (~7e-12).

Answer. quad returns 0.549360 with estimated error ~7e-12, matching (2/5)arctan(5); it auto-refined near the peak.

Worked Example 2

Problem. Integrate a function with an integrable singularity, 1/sqrt(x) from 0 to 1; exact is 2.

  1. `from scipy.integrate import quad; val, err = quad(lambda x: 1/np.sqrt(x), 0, 1)`.
  2. `print(val, err)` gives about `2.0000000000000004 5.8e-15`; a uniform grid would converge slowly here.
  3. Adaptive subdivision packs points toward x=0 where the integrand blows up, achieving full accuracy.

Answer. quad handles the 1/sqrt(x) singularity and returns ≈ 2.0; adaptive refinement near x=0 makes it accurate where a fixed grid struggles.

Common mistakes
  • Using a fixed uniform grid for a sharply peaked or singular integrand; you either miss the feature or waste points. Use scipy.integrate.quad's adaptive scheme.
  • Ignoring the second return value of quad (the error estimate); it tells you whether the requested tolerance was actually met.
✎ Try it yourself

Problem. Use scipy.integrate.quad to integrate exp(-x^2) from 0 to infinity and compare to the known value sqrt(pi)/2.

Solution. `from scipy.integrate import quad; import numpy as np; val, err = quad(lambda x: np.exp(-x**2), 0, np.inf); print(val, err)` gives about (0.8862269254527579, 7.1e-09). The exact value is sqrt(pi)/2 = `np.sqrt(np.pi)/2` ≈ 0.8862269254527580. Answer: quad returns ≈ 0.886227 with tiny error, matching sqrt(pi)/2; it handles the infinite limit via adaptive transformation.

Differentiation and quadrature in SciPy

SciPy and NumPy provide ready tools: np.trapz, scipy.integrate.simpson/quad for integrals, and finite-difference helpers for derivatives.

For integration, NumPy's np.trapz integrates sampled data with the composite trapezoidal rule, scipy.integrate.simpson does composite Simpson on samples, and scipy.integrate.quad adaptively integrates a callable function (the default choice when you have f as a function and want accuracy with an error estimate). For higher dimensions there are dblquad/tplquad and nquad. For numerical differentiation, scipy.differentiate.derivative (modern SciPy) or scipy.optimize.approx_fprime computes finite-difference gradients with sensible step control, and numpy.gradient differentiates sampled arrays with second-order central differences in the interior. The practical rule: integrate sampled data with trapz/simpson, integrate a function with quad; and for derivatives prefer a well-implemented helper that manages the optimal step size rather than hand-picking h.

Worked Example 1

Problem. Integrate a function with quad and the same data sampled with np.trapz; compare.

  1. Function form: `from scipy.integrate import quad; import numpy as np; print(quad(np.sin, 0, np.pi)[0])` gives `2.0` (to ~1e-14).
  2. Sampled form: `x=np.linspace(0,np.pi,1001); print(np.trapz(np.sin(x), x))` gives `1.9999983...`.
  3. quad (adaptive, on the callable) is essentially exact; trapz on 1000 samples is accurate but limited by its O(h^2) grid.

Answer. quad gives 2.0 (machine accuracy); np.trapz on 1001 samples gives 1.9999983 — use quad for callables, trapz for fixed samples.

Worked Example 2

Problem. Differentiate sampled data with numpy.gradient and a callable with scipy.optimize.approx_fprime.

  1. Sampled: `import numpy as np; x=np.linspace(0,np.pi,100); y=np.sin(x); dydx=np.gradient(y, x); print(dydx[50])` ≈ `np.cos(x[50])`.
  2. numpy.gradient uses second-order central differences inside and one-sided at the edges, returning an array of derivatives.
  3. Callable: `from scipy.optimize import approx_fprime; print(approx_fprime([1.0], lambda v: np.sin(v[0]), 1e-6))` ≈ `[0.5403]` = cos(1).

Answer. np.gradient returns the central-difference derivative array; approx_fprime gives the finite-difference gradient of a callable (cos(1) ≈ 0.5403).

Common mistakes
  • Using scipy.integrate.quad on already-sampled data; quad needs a callable. For arrays use np.trapz or scipy.integrate.simpson instead.
  • Hand-picking h for a finite-difference derivative when a library helper (approx_fprime, scipy.differentiate.derivative) already manages the optimal step and error.
✎ Try it yourself

Problem. You have measured arrays t and v (velocity) and want total distance traveled. Which SciPy/NumPy tool, and how?

Solution. Distance is the integral of velocity over time, and you have sampled data (not a callable), so use the trapezoidal rule: `import numpy as np; distance = np.trapz(v, t)`, or `from scipy.integrate import simpson; distance = simpson(v, x=t)` for higher accuracy if the samples are smooth and evenly spaced. Use quad only if you had v as a function. Answer: np.trapz(v, t) (or scipy.integrate.simpson(v, x=t)) integrates the sampled velocity to give distance.

Key terms
  • Finite difference — approximating a derivative with a difference quotient derived from a Taylor expansion.
  • Central difference — (f(x+h) - f(x-h)) / (2h); second-order accurate, with error O(h^2).
  • Truncation error — the error from cutting off the Taylor series; it shrinks as the step size h decreases.
  • Round-off floor — the error from finite precision that grows as h shrinks, setting an optimal nonzero h.
  • Trapezoidal rule — approximating an integral by straight-line segments between sample points.
  • Simpson's rule — fitting parabolas over pairs of intervals; fourth-order accurate for smooth functions.
  • Order of accuracy — the exponent p such that the error scales like O(h^p) as the step or spacing shrinks.
  • Adaptive quadrature — refining the sampling only where the estimated error is large to meet a tolerance efficiently.
Assignment · Find the sweet-spot step size

For a known function such as f(x) = sin(x), approximate f'(x) at a point with the central-difference formula across step sizes h from 1e-1 down to 1e-12. Plot the absolute error versus h on log-log axes and show the characteristic V shape where truncation error dominates for large h and round-off dominates for small h. Then estimate the integral of f over an interval with numpy.trapz, scipy.integrate.simpson, and scipy.integrate.quad and compare their errors.

Deliverable · A script and report with the error-vs-h log-log plot, the empirically best h, and a small table comparing the three integration methods against the exact integral.

Quiz · 5 questions
  1. 1. The central-difference approximation to a first derivative has truncation error of order:

  2. 2. As the step size h is made extremely small, the error in a finite-difference derivative eventually:

  3. 3. Compared with the trapezoidal rule, Simpson's rule on smooth functions is:

  4. 4. Adaptive quadrature like scipy.integrate.quad improves efficiency by:

  5. 5. A method described as second-order accurate means that halving the step size should reduce the error by roughly a factor of:

You'll be able to

I can derive finite-difference formulas from Taylor expansions and state their order of accuracy.

I can choose a step size that balances truncation error against round-off error.

I can integrate functions numerically with trapezoid, Simpson, and adaptive quadrature and estimate the error.

Weeks 11-12 Unit 6: ODE Solvers
model-ivpimplement-eulerimplement-rk-methodsanalyze-ode-erroruse-adaptive-steppingidentify-stiffnessuse-solve-ivp
Lecture
Initial value problems and the flow of a system

An initial value problem pairs an ODE y' = f(t, y) with a starting point; its solution is the trajectory the system follows from that point.

An initial value problem (IVP) asks: given the rate of change y' = f(t, y) and a starting state y(t0) = y0, what is y(t) for later times? The function f defines a slope field; the solution is the unique curve threading that field from the initial point, often called the flow of the system. Existence and uniqueness are guaranteed when f is Lipschitz continuous in y (a smoothness condition). Higher-order ODEs are handled by rewriting them as a first-order system: y'' = g(t, y, y') becomes a vector ODE for the state [y, y']. Numerical solvers march forward in steps, evaluating f to advance the state; they never produce the closed-form solution, only sampled trajectory points. Casting any model as y' = f(t, y) with a state vector is the first step before calling a solver.

Worked Example 1

Problem. Write the second-order ODE y'' + y = 0, y(0)=1, y'(0)=0 as a first-order system.

  1. Let the state be u = [y, v] where v = y'. Then u[0]' = v and u[1]' = y'' = -y = -u[0].
  2. So f(t, u) = [u[1], -u[0]] with u(0) = [1, 0].
  3. `import numpy as np; f=lambda t,u: np.array([u[1], -u[0]]); print(f(0,[1,0]))` gives `[0 -1]` (the initial slopes).

Answer. State u=[y,y'], f(t,u)=[u[1],-u[0]], u(0)=[1,0]; the true solution is y(t)=cos(t).

Worked Example 2

Problem. Verify that y(t) = e^{-2t} solves the IVP y' = -2y, y(0) = 1.

  1. Differentiate: y'(t) = -2 e^{-2t} = -2 y(t), so the ODE is satisfied for all t.
  2. Initial condition: y(0) = e^0 = 1, which matches.
  3. Numerically: `import numpy as np; t=0.5; print(-2*np.exp(-2*t), (np.exp(-2*0.5001)-np.exp(-2*0.4999))/0.0002)` shows the analytic slope matches a finite difference.

Answer. y(t)=e^{-2t} satisfies both y'=-2y and y(0)=1, so it is the unique solution of the IVP.

Common mistakes
  • Trying to feed a second-order ODE directly to a first-order solver; rewrite it as a first-order system with a state vector [y, y'] first.
  • Assuming a solution always exists and is unique; it requires f to be (locally) Lipschitz in y — otherwise trajectories can branch or blow up.
✎ Try it yourself

Problem. Convert the pendulum equation theta'' = -sin(theta) into a first-order system suitable for solve_ivp.

Solution. Introduce the state u = [theta, omega] where omega = theta'. Then u[0]' = omega and u[1]' = theta'' = -sin(theta) = -sin(u[0]). So f(t, u) = [u[1], -np.sin(u[0])]. In code: `import numpy as np; f = lambda t, u: [u[1], -np.sin(u[0])]`, with an initial state like u0 = [theta0, omega0]. Answer: state [theta, omega], f(t,u) = [u[1], -sin(u[0])].

Euler's method and local vs. global error

Euler's method takes a single tangent-line step per interval; its per-step (local) error is O(h^2) but the accumulated (global) error is only O(h).

The forward Euler method is the simplest IVP solver: from (t_n, y_n) it follows the slope f for one step, y_{n+1} = y_n + h f(t_n, y_n). Geometrically it walks along the tangent line, so it drifts off curved solutions. The local truncation error — the error made in one step from exact data — is O(h^2), from the dropped Taylor term. But over the interval [t0, T] you take N = (T-t0)/h steps, and the per-step errors accumulate, so the global error is O(h): Euler is first-order. This one-power drop from local to global order is a general pattern. First-order accuracy is poor — halving h only halves the error — which motivates the higher-order Runge-Kutta methods. Euler is also only conditionally stable, limiting h for stiff problems.

Worked Example 1

Problem. Take two Euler steps for y' = y, y(0) = 1 with h = 0.5; compare to e^1.

  1. Step 1: y_1 = y_0 + h f = 1 + 0.5*1 = 1.5. Step 2: y_2 = 1.5 + 0.5*1.5 = 2.25.
  2. Exact y(1) = e^1 = 2.71828; Euler gives 2.25, an error of ~0.47 (large because h is big and only first-order).
  3. `y=1.0; h=0.5\nfor _ in range(2): y+=h*y\nprint(y)` confirms `2.25`.

Answer. Euler gives y(1) ≈ 2.25 vs. exact 2.718; the big first-order error reflects the crude tangent steps.

Worked Example 2

Problem. Empirically confirm Euler is first-order on y' = -y, y(0)=1 by halving h.

  1. `import numpy as np\ndef euler(h):\n y=1.0; t=0.0\n while t<1-1e-9: y+=h*(-y); t+=h\n return y`
  2. `print(abs(euler(0.1)-np.exp(-1)), abs(euler(0.05)-np.exp(-1)))` gives errors ~0.0173 and ~0.0088.
  3. Ratio 0.0173/0.0088 ≈ 1.97 ≈ 2, confirming O(h): halving h halves the global error.

Answer. Errors ~0.017 (h=0.1) and ~0.009 (h=0.05), ratio ~2 — first-order global accuracy confirmed.

Common mistakes
  • Confusing local and global error; Euler's local error is O(h^2) per step but the global error after accumulating O(1/h) steps is only O(h).
  • Using a large step with Euler on a fast-decaying (stiff) equation; Euler is only conditionally stable and can oscillate or blow up if h is too big.
✎ Try it yourself

Problem. Take one Euler step for y' = t + y, y(0) = 1 with h = 0.1, and give y(0.1).

Solution. Euler: y_1 = y_0 + h*f(t_0, y_0) = 1 + 0.1*(0 + 1) = 1 + 0.1 = 1.1. So the estimate at t=0.1 is y(0.1) ≈ 1.1. (The exact solution is y(t) = 2e^t - t - 1, giving y(0.1) ≈ 1.1103, so the one-step error is ~0.01 = O(h^2).) Answer: y(0.1) ≈ 1.1.

Midpoint and Heun's predictor-corrector methods

Second-order Runge-Kutta methods (midpoint, Heun) sample the slope twice per step to cancel Euler's leading error term.

Euler uses only the slope at the start of the interval, which is biased for curved solutions. Second-order Runge-Kutta methods evaluate f twice to get a better average slope, achieving global O(h^2) accuracy. The midpoint method takes a half Euler step to t_n + h/2, evaluates the slope there, and uses that midpoint slope for the full step. Heun's method (improved Euler) is a predictor-corrector: it predicts y* with an Euler step, evaluates the slope at the endpoint, and corrects using the average of the start and end slopes — k1 = f(t_n, y_n), k2 = f(t_n+h, y_n+h k1), y_{n+1} = y_n + (h/2)(k1 + k2). Both cost two function evaluations per step but halving h now reduces error fourfold, a clear improvement over Euler for the modest extra cost.

Worked Example 1

Problem. Take one Heun step for y' = y, y(0)=1, h=0.5; compare to Euler and to e^0.5.

  1. k1 = f(0,1) = 1. Predictor: y* = 1 + 0.5*1 = 1.5. k2 = f(0.5, 1.5) = 1.5.
  2. Corrector: y_1 = 1 + (0.5/2)(1 + 1.5) = 1 + 0.25*2.5 = 1.625.
  3. Exact e^0.5 = 1.6487; Heun's 1.625 (error 0.024) is far better than Euler's 1.5 (error 0.149).

Answer. Heun gives 1.625 vs. exact 1.6487 (error 0.024), much closer than Euler's 1.5 — second-order pays off.

Worked Example 2

Problem. Take one midpoint-method step for y' = -2y, y(0)=1, h=0.1; exact y(0.1)=e^{-0.2}.

  1. k1 = f(0,1) = -2. Half step: y_mid = 1 + (0.1/2)*(-2) = 0.9 at t=0.05.
  2. Midpoint slope k2 = f(0.05, 0.9) = -1.8. Full step: y_1 = 1 + 0.1*(-1.8) = 0.82.
  3. Exact e^{-0.2} = 0.81873; midpoint's 0.82 has error ~0.0013 (vs. Euler's 0.8 with error ~0.019).

Answer. Midpoint gives 0.82 vs. exact 0.81873 (error ~0.001), ten times better than Euler at the same h.

Common mistakes
  • Using the predicted endpoint value y* as the final answer in Heun's method; y* is only the predictor — you must apply the corrector averaging k1 and k2.
  • Expecting midpoint/Heun to be free; they need two f evaluations per step. The payoff is O(h^2) accuracy, but per-evaluation RK4 is usually a better deal.
✎ Try it yourself

Problem. Take one Heun step for y' = t - y, y(0) = 1 with h = 0.2.

Solution. k1 = f(0, 1) = 0 - 1 = -1. Predictor: y* = 1 + 0.2*(-1) = 0.8. k2 = f(0.2, 0.8) = 0.2 - 0.8 = -0.6. Corrector: y_1 = 1 + (0.2/2)(-1 + -0.6) = 1 + 0.1*(-1.6) = 1 - 0.16 = 0.84. (Exact y(0.2) ≈ 0.8375.) Answer: Heun gives y(0.2) ≈ 0.84.

Runge-Kutta methods (RK4) and order

The classic RK4 evaluates the slope four times per step in a weighted average, achieving fourth-order accuracy — the standard non-stiff workhorse.

The classic fourth-order Runge-Kutta method (RK4) samples the slope at four points and combines them with carefully chosen weights so that error terms through h^4 cancel: k1 = f(t_n, y_n); k2 = f(t_n+h/2, y_n+h k1/2); k3 = f(t_n+h/2, y_n+h k2/2); k4 = f(t_n+h, y_n+h k3); then y_{n+1} = y_n + (h/6)(k1 + 2k2 + 2k3 + k4). The midpoint slopes k2, k3 get double weight, mimicking Simpson's rule. RK4 is globally O(h^4), so halving h reduces error sixteenfold, at the cost of four function evaluations per step. This excellent accuracy-to-cost ratio makes it the default for smooth, non-stiff problems. Higher-order and embedded (adaptive) Runge-Kutta variants build on the same multi-stage idea.

Worked Example 1

Problem. Take one RK4 step for y' = y, y(0)=1, h=0.5; compare to e^0.5.

  1. k1=f(0,1)=1; k2=f(0.25, 1+0.25*1)=1.25; k3=f(0.25, 1+0.25*1.25)=1.3125; k4=f(0.5, 1+0.5*1.3125)=1.65625.
  2. y_1 = 1 + (0.5/6)(1 + 2*1.25 + 2*1.3125 + 1.65625) = 1 + (0.08333)(7.78125) = 1.6484.
  3. Exact e^0.5 = 1.64872; RK4's 1.6484 has error ~3e-4 in a single step — far beyond Heun or Euler.

Answer. RK4 gives 1.64844 vs. exact 1.64872 (error ~3e-4) in one step; fourth-order accuracy in action.

Worked Example 2

Problem. Implement RK4 for y' = -y, y(0)=1 over [0,1] and check fourth-order convergence by halving h.

  1. `import numpy as np\ndef rk4(f,h):\n y=1.0;t=0.0\n while t<1-1e-9:\n k1=f(t,y);k2=f(t+h/2,y+h*k1/2);k3=f(t+h/2,y+h*k2/2);k4=f(t+h,y+h*k3)\n y+=h/6*(k1+2*k2+2*k3+k4); t+=h\n return y`
  2. `f=lambda t,y:-y; print(abs(rk4(f,0.1)-np.exp(-1)), abs(rk4(f,0.05)-np.exp(-1)))` gives errors ~8e-7 and ~5e-8.
  3. Ratio ≈ 16, confirming O(h^4): halving h cut the error by ~16.

Answer. Errors ~8e-7 (h=0.1) and ~5e-8 (h=0.05), ratio ~16 — fourth-order global accuracy confirmed.

Common mistakes
  • Using y_n (not the updated stage value) when computing k2, k3, k4; each stage must use the previous stage's predicted point, e.g. k2 uses y_n + h*k1/2.
  • Assuming RK4's high order makes it suitable for stiff problems; it is explicit and only conditionally stable, so stiffness still forces tiny steps.
✎ Try it yourself

Problem. Why does RK4 typically beat Euler even when both are given the same total number of function evaluations?

Solution. RK4 uses 4 evaluations per step but is O(h^4); Euler uses 1 per step but is O(h). For the same total evaluations, Euler takes 4x more steps (h/4), giving error ~ (h/4) = h/4. RK4 keeps step h with error ~ h^4. For small h, h^4 is vastly smaller than h/4, so RK4 wins decisively. Answer: RK4's fourth-order accuracy overwhelms its 4x per-step cost; its error (h^4) shrinks far faster than Euler's (h).

Adaptive step size and error estimation

Adaptive solvers estimate each step's local error from two embedded methods and grow or shrink h to keep it near a tolerance.

A fixed step size is wasteful: it uses tiny steps everywhere even where the solution is smooth. Adaptive Runge-Kutta methods estimate the local error cheaply by computing two solutions of different order from shared stage evaluations — an embedded pair like RK45 (Dormand-Prince) produces a 4th- and 5th-order estimate from the same k's. The difference between them approximates the local error. If that error exceeds the tolerance (atol + rtol*|y|), the step is rejected and retried with a smaller h; if it is comfortably below, h is enlarged for the next step. This automatically takes small steps through rapid transients and large steps through smooth stretches, hitting the requested accuracy with minimal evaluations. scipy.integrate.solve_ivp uses this with method='RK45' by default; you set rtol and atol.

Worked Example 1

Problem. Solve y' = -y, y(0)=1 on [0,5] with scipy.integrate.solve_ivp and inspect the adaptive steps.

  1. `from scipy.integrate import solve_ivp; import numpy as np; sol=solve_ivp(lambda t,y:-y, [0,5], [1.0], rtol=1e-8, atol=1e-10)`.
  2. `print(sol.t.size, sol.y[0,-1], np.exp(-5))` shows it used a modest number of steps and y(5) ≈ 0.006738 ≈ e^{-5}.
  3. The step sizes np.diff(sol.t) grow as the solution flattens — adaptivity at work.

Answer. solve_ivp (RK45) hits y(5) ≈ e^{-5} = 0.006738 with few adaptive steps, enlarging h as the solution smooths out.

Worked Example 2

Problem. Tighten tolerances and observe more steps and higher accuracy.

  1. `from scipy.integrate import solve_ivp; import numpy as np\nfor rt in [1e-3, 1e-9]:\n s=solve_ivp(lambda t,y:-y,[0,5],[1.0],rtol=rt,atol=1e-12); print(rt, s.t.size, abs(s.y[0,-1]-np.exp(-5)))`
  2. Looser rtol=1e-3 uses fewer steps but larger error; rtol=1e-9 uses more steps for a smaller error.
  3. The solver trades evaluations for accuracy exactly as the tolerance dictates.

Answer. Tighter rtol means more adaptive steps and smaller error; the solver self-adjusts h to meet the requested tolerance.

Common mistakes
  • Setting atol to 0 (or far too small) for components that legitimately pass through zero; the relative-only test then forces absurdly tiny steps. Keep a sensible atol floor.
  • Assuming a tighter rtol always means a trustworthy answer; on an ill-conditioned or chaotic system, accumulated error can still be large despite small per-step tolerances.
✎ Try it yourself

Problem. In scipy.integrate.solve_ivp, what do rtol and atol control, and how is the per-step error test formed?

Solution. rtol (relative tolerance) and atol (absolute tolerance) set the accuracy target. The solver accepts a step when the estimated local error for each component is below atol + rtol*|y|. rtol scales the allowed error with the solution's magnitude (good for large values), while atol provides a floor so components near zero are not held to an impossible relative standard. If the estimate exceeds this, the step is rejected and h is reduced. Answer: rtol/atol define the threshold atol + rtol*|y| that the embedded error estimate must satisfy each step.

Stiff equations and implicit solvers

Stiff systems have fast and slow components together; explicit methods need impractically tiny steps, so implicit solvers are required.

A stiff ODE has dynamics on widely separated time scales — a fast-decaying transient alongside slow evolution. Explicit methods (Euler, RK45) have a bounded stability region, so even after the fast transient has died, they must keep h tiny just to stay stable, making them painfully slow. Implicit methods solve for y_{n+1} on both sides of the update equation, e.g. backward Euler y_{n+1} = y_n + h f(t_{n+1}, y_{n+1}), which requires solving a (possibly nonlinear) system each step but is unconditionally stable (A-stable) for decaying problems, allowing large steps. The cost per step is higher (a Newton solve, needing the Jacobian), but far fewer steps are needed. SciPy's solve_ivp offers implicit methods 'BDF' and 'Radau' for stiff problems; the explicit 'RK45' will crawl or fail.

Worked Example 1

Problem. Show backward Euler is stable for the test equation y' = -50y, y(0)=1, h=0.1, where forward Euler is not.

  1. Forward Euler amplification factor is (1 + h*lambda) = 1 + 0.1*(-50) = -4, magnitude 4 > 1, so it blows up and oscillates.
  2. Backward Euler factor is 1/(1 - h*lambda) = 1/(1 + 5) = 1/6, magnitude < 1, so it decays stably.
  3. `import numpy as np; lam=-50; h=0.1; print(abs(1+h*lam), abs(1/(1-h*lam)))` gives `4.0 0.1667`.

Answer. Forward Euler factor 4 (unstable, blows up); backward Euler factor 1/6 (stable) — implicit wins for stiff decay.

Worked Example 2

Problem. Solve a stiff system with solve_ivp using 'BDF' and note why 'RK45' struggles.

  1. Stiff system (Robertson-like): `from scipy.integrate import solve_ivp; import numpy as np\nf=lambda t,y: [-1000*y[0]+y[1], 1000*y[0]-y[1]-y[1]]`.
  2. `sol=solve_ivp(f, [0,5], [1.0,0.0], method='BDF'); print(sol.t.size, sol.status)` solves it in modest steps.
  3. With method='RK45' the same call uses vastly more steps (or hits the step limit) because the fast -1000 mode forces tiny h for stability.

Answer. BDF solves the stiff system efficiently; RK45 needs far more steps (stability-limited by the fast mode) — use implicit methods when stiff.

Common mistakes
  • Diagnosing a slow explicit solver as a bug; if it takes thousands of tiny steps on a smooth-looking solution, the system is stiff — switch to 'BDF' or 'Radau'.
  • Expecting implicit methods to be cheaper per step; they solve a (nonlinear) system each step. They win because they take far fewer, larger steps, not cheaper ones.
✎ Try it yourself

Problem. For y' = -1000(y - cos(t)) - sin(t), why will solve_ivp with method='RK45' be slow, and what should you use?

Solution. The factor -1000 makes the system stiff: deviations from cos(t) decay on a time scale ~1/1000, while the solution itself varies slowly with t. Explicit RK45's stability region forces h ~ 1/1000 throughout, so it takes enormous numbers of tiny steps even where the solution is smooth. Use an implicit stiff solver: method='BDF' or method='Radau', which are stable at large h. Answer: RK45 is stability-limited by the -1000 mode; use method='BDF' or 'Radau'.

Solving IVPs with scipy.integrate.solve_ivp

solve_ivp is the modern unified interface: choose a method, set tolerances, request dense output and event detection.

scipy.integrate.solve_ivp(fun, t_span, y0, method=..., t_eval=..., rtol=..., atol=..., events=..., dense_output=...) is the recommended IVP solver. fun(t, y) returns the derivative vector, t_span = (t0, tf), and y0 is the initial state. Pick the method by character: 'RK45' (default) or 'DOP853' for smooth non-stiff problems, 'BDF' or 'Radau' for stiff ones, 'LSODA' to auto-switch. Use t_eval to get the solution at specific points, dense_output=True to obtain a continuous interpolant sol.sol(t), and events for stopping or recording when a condition (like a zero crossing) occurs. The returned object exposes sol.t, sol.y (shape n_states x n_times), sol.success, and sol.t_events. Always check sol.success and read the message if it failed.

Worked Example 1

Problem. Solve the harmonic oscillator y'' + y = 0, y(0)=1, y'(0)=0 and evaluate at chosen times.

  1. `from scipy.integrate import solve_ivp; import numpy as np; f=lambda t,u:[u[1], -u[0]]`.
  2. `sol=solve_ivp(f, [0, 2*np.pi], [1,0], t_eval=np.linspace(0,2*np.pi,5)); print(sol.y[0])`.
  3. The first row (position) traces cos(t): values ≈ [1, 0, -1, 0, 1] at t = 0, pi/2, pi, 3pi/2, 2pi.

Answer. sol.y[0] ≈ [1, 0, -1, 0, 1], matching cos(t); t_eval pins the output to the requested times.

Worked Example 2

Problem. Use an event to detect when a falling object reaches the ground (height = 0).

  1. `from scipy.integrate import solve_ivp; f=lambda t,u:[u[1], -9.81] # u=[height, velocity]`.
  2. Event with terminal stop: `def hit(t,u): return u[0]\nhit.terminal=True; hit.direction=-1\nsol=solve_ivp(f,[0,10],[100,0],events=hit)`.
  3. `print(sol.t_events[0])` gives the impact time ≈ 4.515 s (since 100 = 0.5*9.81*t^2 -> t = sqrt(200/9.81)).

Answer. solve_ivp stops at the ground via the terminal event; sol.t_events[0] ≈ 4.515 s, matching sqrt(200/9.81).

Common mistakes
  • Ignoring sol.success; if the solver failed or hit the step limit, sol.y is unreliable — always check the flag and sol.message.
  • Confusing t_eval with the step size; t_eval only chooses output sample points (via interpolation) and does not control the solver's internal adaptive steps.
✎ Try it yourself

Problem. Solve y' = -2y + 1, y(0) = 0 on [0, 3] with solve_ivp and compare the endpoint to the exact solution.

Solution. The exact solution is y(t) = 0.5(1 - e^{-2t}). `from scipy.integrate import solve_ivp; import numpy as np; sol = solve_ivp(lambda t, y: -2*y + 1, [0, 3], [0.0], rtol=1e-9); print(sol.y[0, -1], 0.5*(1-np.exp(-6)))` gives about (0.4987..., 0.4987...). The numerical endpoint matches the exact value 0.5(1 - e^{-6}) ≈ 0.49876. Answer: y(3) ≈ 0.4988, matching the closed-form solution.

Key terms
  • Initial value problem (IVP) — an ODE y' = f(t, y) together with a starting value y(t0) = y0.
  • Euler's method — the simplest step y_{n+1} = y_n + h f(t_n, y_n); first-order accurate.
  • Local truncation error — the error introduced in a single step assuming exact prior values.
  • Global error — the accumulated error over all steps from start to end of the integration.
  • Runge-Kutta (RK4) — a four-stage method that is fourth-order accurate and widely used as a default.
  • Adaptive step size — adjusting h on the fly using an error estimate to meet a tolerance efficiently.
  • Stiff equation — a system with widely separated time scales that forces tiny steps for explicit solvers.
  • Implicit method — a scheme (e.g. backward Euler) that solves for y_{n+1} on both sides, gaining stability for stiff problems.
Assignment · From Euler to RK4 and beyond

Take an IVP with a known closed-form solution (for example y' = -y, y(0) = 1) and implement Euler's method and classic RK4 yourself. Plot the global error versus step size on log-log axes for both and confirm the expected slopes (order 1 vs. order 4). Then re-solve the problem with scipy.integrate.solve_ivp using an explicit method, and apply it to a stiff system to show why a stiff solver (such as 'Radau' or 'BDF') is needed.

Deliverable · A script and report with the order-of-accuracy plot, the measured convergence slopes, and a short explanation of when you switched solvers for the stiff system.

Quiz · 5 questions
  1. 1. Euler's method is which order of accuracy in its global error?

  2. 2. The classic RK4 method is popular mainly because it:

  3. 3. A stiff ODE system is characterized by:

  4. 4. Adaptive step-size solvers decide how big each step should be by:

  5. 5. For a stiff problem, which scipy.integrate.solve_ivp method is appropriate?

You'll be able to

I can implement Euler and Runge-Kutta methods and explain the difference between local and global error.

I can recognize a stiff system and explain why an implicit solver is needed.

I can solve an initial value problem with scipy.integrate.solve_ivp and choose an appropriate method.

Weeks 13-14 Unit 7: Stability & Performance
assess-algorithm-stabilityexplain-interpreter-overheadvectorize-with-numpyapply-broadcastingreason-about-memory-layoutprofile-and-benchmarkchoose-compiled-acceleration
Lecture
Numerical stability of algorithms revisited

Stability is a property of an algorithm — whether it amplifies rounding errors — distinct from conditioning, which is a property of the problem.

Revisiting Unit 1's vocabulary: conditioning describes how sensitive a problem's true answer is to input perturbations (it is intrinsic to the problem), while stability describes whether an algorithm amplifies the rounding errors it makes along the way. A stable (specifically backward-stable) algorithm produces the exact answer to a slightly perturbed input, so its forward error is bounded by roughly condition number times machine epsilon — the best one can hope for. An unstable algorithm injects extra error growth beyond what the conditioning forces. The practical test: when an answer is bad, ask whether the problem is ill-conditioned (nothing to fix in the code) or the algorithm is unstable (reformulate it). The same mathematical result can have several algorithms of differing stability — e.g. classical vs. modified Gram-Schmidt, or the naive vs. stable quadratic formula.

Worked Example 1

Problem. Show classical Gram-Schmidt loses orthogonality where modified Gram-Schmidt (or QR) does not.

  1. Build an ill-conditioned matrix and orthogonalize: `import numpy as np; A=np.array([[1,1,1],[1e-8,0,0],[0,1e-8,0],[0,0,1e-8]],float)`.
  2. Classical GS computes all projections against the original vectors at once, so round-off makes the resulting Q columns non-orthogonal (Q^T Q strays from I).
  3. Modified GS (and np.linalg.qr) orthogonalizes against each updated vector in turn: `Q,_=np.linalg.qr(A); print(np.linalg.norm(Q.T@Q - np.eye(3)))` is ~1e-16, near machine precision.

Answer. Classical GS yields a non-orthogonal Q on ill-conditioned input; modified GS / np.linalg.qr keep Q^T Q ≈ I — same math, different stability.

Worked Example 2

Problem. Distinguish conditioning from stability for a sample sum and a sample subtraction.

  1. Subtraction 1 - cos(1e-8) is an ill-conditioned problem near a cancellation; even a perfect algorithm inherits the loss — that is conditioning.
  2. Computing a variance via the naive E[x^2] - E[x]^2 formula is an unstable algorithm for clustered data; the two-pass formula is stable.
  3. `import numpy as np; x=np.array([1e8,1e8+1,1e8+2]); naive=np.mean(x**2)-np.mean(x)**2; print(naive, np.var(x))` shows the naive value can go negative while np.var is correct.

Answer. The cancellation in 1-cos is a conditioning issue; the naive variance formula is an algorithm-stability issue fixed by a two-pass method (np.var).

Common mistakes
  • Blaming a stable algorithm for a poor result on an ill-conditioned problem; check the conditioning first — a backward-stable method already did its best.
  • Using the naive one-pass variance E[x^2]-E[x]^2 on large-magnitude data; cancellation can produce a negative variance. Use a two-pass or Welford algorithm (np.var).
✎ Try it yourself

Problem. An algorithm gives 12 correct digits on a problem with condition number ~1e10 in float64. Is the algorithm stable?

Solution. float64 carries ~16 digits. With condition number ~1e10, even a perfectly backward-stable algorithm loses about log10(1e10) = 10 digits, leaving ~6 correct. Getting 12 correct digits would be better than the conditioning allows, which is impossible — so either the condition number is overestimated or the problem is locally better conditioned. A stable algorithm should give ~6 correct digits here; 12 is suspiciously good, not a sign of instability. Answer: a stable algorithm should yield ~6 correct digits (16 - 10); 12 correct digits exceeds what kappa=1e10 permits, so re-examine the conditioning estimate.

Why Python loops are slow: the interpreter and dynamic typing

Each Python loop iteration pays interpreter dispatch, dynamic type checks, and object boxing overhead that compiled array operations avoid.

Python is interpreted and dynamically typed, so a simple a + b inside a loop is expensive: the interpreter dispatches bytecode, checks the runtime types of a and b, finds the right __add__, allocates a new boxed object for the result, and manages reference counts — all per element. A C loop over a typed array does none of this; it runs a single machine instruction per element on contiguous memory. That overhead is why a pure-Python numeric loop can be 10-100x slower than the equivalent compiled operation. NumPy sidesteps it by storing data in homogeneous, contiguous C arrays and performing whole-array operations in compiled loops (ufuncs), paying the per-element overhead once at the C level instead of once per Python iteration. The fix for slow numeric Python is almost always to push the loop down into compiled code via vectorization.

Worked Example 1

Problem. Time summing a million numbers with a Python loop versus np.sum.

  1. `import numpy as np, timeit; a=np.arange(1_000_000)`.
  2. Loop: `t1=timeit.timeit(lambda: sum(int(x) for x in a), number=1)` is on the order of 0.1-0.3 s.
  3. Vectorized: `t2=timeit.timeit(lambda: a.sum(), number=1)` is ~0.001 s — roughly 100x faster, same result.

Answer. np.sum is ~100x faster than the Python loop; the speedup is the eliminated per-element interpreter and boxing overhead.

Worked Example 2

Problem. Explain why list-of-floats arithmetic is slower and more memory-heavy than a NumPy array.

  1. A Python list stores pointers to individual boxed float objects scattered in memory; each float is ~24+ bytes plus a pointer.
  2. A float64 NumPy array stores raw 8-byte values contiguously, enabling cache-friendly, vectorized C loops.
  3. `import numpy as np, sys; lst=[1.0]*1000; arr=np.ones(1000); print(sys.getsizeof(lst), arr.nbytes)` shows the array's 8000 bytes vs. the list's much larger footprint plus separate float objects.

Answer. Lists hold scattered boxed objects (slow, memory-heavy); NumPy arrays hold contiguous typed values (fast, compact), enabling compiled loops.

Common mistakes
  • Optimizing a Python numeric loop by micro-tuning the loop body; the dominant cost is interpreter/type overhead per iteration — vectorize to remove the loop entirely.
  • Storing numeric data in Python lists and converting per operation; keep it in NumPy arrays so operations stay in compiled code and memory is contiguous.
✎ Try it yourself

Problem. A colleague's element-by-element Python loop computing c[i] = a[i]*b[i] for a million elements is slow. What is the root cause and the fix?

Solution. The root cause is per-iteration interpreter overhead: each step dispatches bytecode, type-checks a[i] and b[i], finds __mul__, and allocates a boxed result — millions of times. The fix is vectorization: with NumPy arrays, `c = a * b` performs the whole multiplication in one compiled C loop over contiguous memory, typically 50-100x faster. Answer: interpreter/dynamic-typing overhead per element; replace the loop with the vectorized c = a * b.

Vectorization with NumPy arrays and ufuncs

Vectorization expresses computation as whole-array operations (ufuncs) that run in compiled loops, replacing explicit Python iteration.

Vectorization means writing array expressions like a*b + np.sin(c) instead of element loops; NumPy executes each as a universal function (ufunc) — a compiled, type-specialized loop over contiguous memory. ufuncs apply elementwise (np.add, np.exp, np.maximum), support reductions (sum, mean, max along an axis), and accumulate (np.cumsum). The wins are speed (compiled C, often SIMD-vectorized) and clarity. Reductions take an axis argument to collapse specific dimensions. Replace conditional loops with np.where(cond, a, b) or boolean-mask indexing. Many problems that look inherently sequential can be vectorized by reformulating them as array algebra — pairwise distances via broadcasting, running statistics via cumulative ufuncs. The mental shift is from 'loop over elements' to 'operate on whole arrays at once.'

Worked Example 1

Problem. Vectorize the computation of sum of squares of 1..n and compare to a loop.

  1. Loop: `s=0\nfor i in range(1,1001): s+=i*i`.
  2. Vectorized: `import numpy as np; a=np.arange(1,1001); print((a*a).sum())` gives `333833500`, identical result.
  3. The vectorized form runs one compiled multiply and one compiled reduction, no Python iteration.

Answer. Both give 333833500; the vectorized (a*a).sum() does it in compiled ufuncs with no Python loop.

Worked Example 2

Problem. Replace an if/else loop that clips negatives to zero with a vectorized ufunc.

  1. Loop version sets each negative element to 0 one at a time — slow and verbose.
  2. Vectorized: `import numpy as np; x=np.array([-2.,3,-1,5]); print(np.maximum(x, 0))` gives `[0. 3. 0. 5.]` (a ReLU).
  3. Or `np.where(x<0, 0, x)` for the same result; both run elementwise in compiled code.

Answer. np.maximum(x, 0) (or np.where(x<0,0,x)) gives [0,3,0,5] in one vectorized call, replacing the if/else loop.

Worked Example 3

Problem. Vectorize column-wise normalization of a matrix (subtract column mean, divide by column std).

  1. `import numpy as np; X=np.array([[1.,2,3],[4,5,6],[7,8,9]])`.
  2. `Xn = (X - X.mean(axis=0)) / X.std(axis=0); print(Xn)` standardizes each column in one expression.
  3. axis=0 reduces over rows, giving per-column statistics; broadcasting then applies them down each column with no loop.

Answer. (X - X.mean(axis=0)) / X.std(axis=0) standardizes every column at once via reductions plus broadcasting.

Common mistakes
  • Calling np.vectorize and expecting a speedup; it is a convenience wrapper that still loops in Python at C-call overhead — true speed comes from real ufuncs and array ops.
  • Forgetting the axis argument on reductions; np.sum(X) collapses everything, while np.sum(X, axis=0) keeps the per-column structure you usually want.
✎ Try it yourself

Problem. Vectorize computing the Euclidean norm of each row of a matrix X (shape m x n).

Solution. Square elementwise, sum along the columns (axis=1), and take the square root — all vectorized: `import numpy as np; norms = np.sqrt((X**2).sum(axis=1))`, or more directly `norms = np.linalg.norm(X, axis=1)`. For X = [[3,4],[6,8]] this gives [5, 10]. No Python loop is needed; the reduction over axis=1 collapses each row to one number. Answer: np.linalg.norm(X, axis=1) (or np.sqrt((X**2).sum(axis=1))).

Broadcasting and avoiding explicit loops

Broadcasting lets NumPy combine arrays of different shapes by virtually stretching size-1 dimensions, eliminating loops without copying data.

Broadcasting is NumPy's rule for operating on arrays of different shapes. Aligning shapes from the right, two dimensions are compatible if they are equal or one of them is 1; a size-1 (or missing) dimension is virtually stretched to match the other. No data is copied — the stretched axis is read with a zero stride — so it is both fast and memory-light. This turns nested loops into single expressions: add a (m,1) column vector to a (1,n) row vector to get an (m,n) outer sum; subtract a (n,) mean from an (m,n) matrix to center it. Insert new axes with arr[:, None] or np.newaxis to control how shapes line up. The classic use is pairwise computations: X[:, None, :] - Y[None, :, :] forms all difference vectors at once. Mismatched shapes that satisfy no rule raise a ValueError.

Worked Example 1

Problem. Build a multiplication table (outer product) for 1..4 without loops.

  1. `import numpy as np; a=np.arange(1,5)`. Make a column and a row: a[:,None] is shape (4,1), a[None,:] is (1,4).
  2. `print(a[:,None] * a[None,:])` broadcasts to (4,4): the multiplication table.
  3. Result rows are [1,2,3,4],[2,4,6,8],[3,6,9,12],[4,8,12,16] — no explicit loop.

Answer. a[:,None]*a[None,:] broadcasts (4,1) and (1,4) into the (4,4) multiplication table.

Worked Example 2

Problem. Compute all pairwise squared distances between rows of X (m x d) and Y (n x d) via broadcasting.

  1. Insert axes so shapes align: X[:, None, :] is (m,1,d), Y[None, :, :] is (1,n,d).
  2. `import numpy as np; diff = X[:,None,:] - Y[None,:,:]; D2 = (diff**2).sum(axis=2)` gives the (m,n) squared-distance matrix.
  3. For X=[[0,0],[1,1]], Y=[[0,1]], `print(D2)` gives `[[1],[1]]`, the squared distances.

Answer. X[:,None,:]-Y[None,:,:] then sum over axis 2 yields the full (m,n) pairwise squared-distance matrix without any loop.

Common mistakes
  • Assuming broadcasting copies the stretched data; it uses zero-stride views, so it is cheap — but materializing a huge intermediate (e.g. an m x n x d difference tensor) can still exhaust memory.
  • Misaligning shapes and getting silent wrong results or a ValueError; add explicit axes with [:, None] / np.newaxis so dimensions line up from the right as intended.
✎ Try it yourself

Problem. You have a (1000, 3) array of points and want to subtract a single (3,) centroid from every point. How does broadcasting handle it?

Solution. Just write `centered = points - centroid`. Aligning from the right, points is (1000, 3) and centroid is (3,), treated as (1, 3). The size-1 leading dimension is stretched to 1000, so the (3,) centroid is virtually subtracted from each of the 1000 rows — no loop, no copy of the centroid. Answer: points - centroid broadcasts the (3,) centroid across all 1000 rows automatically.

Memory layout, views, and cache-friendly access

Array element order (C vs. Fortran) and the view/copy distinction govern cache efficiency and whether writes alias the original data.

NumPy arrays store elements in a flat buffer with a memory layout: C-order (row-major, the default) keeps each row contiguous; Fortran-order (column-major) keeps each column contiguous. Accessing memory contiguously is far faster because the CPU loads whole cache lines, so iterate along the contiguous axis (rows for C-order) and prefer operations that stride sequentially. A slice or reshape often returns a view — a new array object sharing the original buffer — so writing through it modifies the parent (aliasing); fancy/boolean indexing returns a copy. Check arr.flags['C_CONTIGUOUS'] and use arr.base or np.shares_memory to tell views from copies. Operations on non-contiguous arrays may silently copy. The performance lesson: keep data contiguous in the access direction, and the correctness lesson: know when you hold a view that can mutate shared data.

Worked Example 1

Problem. Show that a slice is a view that aliases the parent, while fancy indexing copies.

  1. `import numpy as np; a=np.arange(6); b=a[1:4]; b[0]=99; print(a)` gives `[ 0 99 2 3 4 5]` — the slice view wrote through to a.
  2. `c=a[[1,2,3]]; c[0]=-1; print(a)` leaves a unchanged — fancy indexing returned a copy.
  3. `print(np.shares_memory(a, b), np.shares_memory(a, c))` gives `True False`.

Answer. Slicing returns an aliasing view (writes hit the parent); fancy indexing returns an independent copy.

Worked Example 2

Problem. Demonstrate that summing along the contiguous axis is faster than along the strided axis.

  1. `import numpy as np, timeit; A=np.ascontiguousarray(np.random.rand(2000,2000)) # C-order, rows contiguous`.
  2. Sum along rows (contiguous): `timeit.timeit(lambda: A.sum(axis=1), number=10)` is faster than along columns.
  3. Sum along columns (strided): `A.sum(axis=0)` jumps across rows, touching more cache lines, so it is slower for large C-order arrays.

Answer. A.sum(axis=1) (along contiguous rows) is faster than A.sum(axis=0) for a C-ordered array, due to cache-line reuse.

Common mistakes
  • Modifying a slice and not realizing it aliases the parent array; use .copy() when you need an independent array, and check np.shares_memory if unsure.
  • Iterating across the non-contiguous axis on a large array; reorder the loop (or transpose to the right layout) so memory access is sequential and cache-friendly.
✎ Try it yourself

Problem. You take b = a.reshape(3, 4) and set b[0, 0] = 100; a is a 12-element array. Does a change, and why?

Solution. Yes, a changes: reshape returns a view (when the data is contiguous, as a fresh arange is), so b shares a's buffer. Writing b[0,0]=100 sets the first element of the shared buffer, which is a[0]. You can confirm with `np.shares_memory(a, b)` returning True. To avoid aliasing, use a.reshape(3,4).copy(). Answer: yes — reshape returns a view sharing memory, so a[0] becomes 100.

Profiling, benchmarking, and finding bottlenecks

Measure before optimizing: time whole runs (timeit), find hot functions (cProfile), and inspect line-level costs to target the real bottleneck.

Optimization without measurement wastes effort on code that is not the bottleneck. Benchmarking times a snippet repeatedly to get a stable estimate: use timeit (or %timeit in IPython) for microbenchmarks, taking the minimum to reduce noise. Profiling attributes time across a whole program: cProfile reports per-function call counts and cumulative time, revealing which functions dominate; line_profiler (the @profile / kernprof tool) shows time per source line for the suspected hotspot. The standard workflow is profile -> find the function consuming most time -> drill into its lines -> optimize only that -> re-measure to confirm a real speedup. Beware microbenchmark pitfalls: warm up caches, avoid timing one-off allocations, and use realistic input sizes. Premature optimization guided by intuition rather than data is the classic mistake.

Worked Example 1

Problem. Benchmark two ways to square an array with timeit and pick the faster.

  1. `import numpy as np, timeit; a=np.random.rand(1_000_000)`.
  2. `t1=timeit.timeit(lambda: a**2, number=100); t2=timeit.timeit(lambda: a*a, number=100); print(t1, t2)`.
  3. Both are fast and close; a*a is sometimes marginally faster than a**2, but the difference is tiny — measure rather than guess.

Answer. timeit shows a*a and a**2 are nearly identical; the data, not intuition, settles which to use.

Worked Example 2

Problem. Use cProfile to find the dominant function in a small pipeline.

  1. `import cProfile, numpy as np\ndef slow(): return sum(x*x for x in range(1_000_000))\ndef fast(): a=np.arange(1_000_000); return (a*a).sum()`.
  2. `cProfile.run('slow(); fast()')` prints a table; the cumulative-time column shows slow() dominating the runtime.
  3. That points you to replace slow()'s Python generator with fast()'s vectorized version — then re-profile to confirm.

Answer. cProfile's cumulative-time column flags slow() as the bottleneck, directing optimization to the function that actually costs the most.

Common mistakes
  • Optimizing based on a guess about where time is spent; profile first — the real hotspot is frequently not where intuition expects.
  • Trusting a single timing run; runtimes vary, so use timeit's repeated measurement and take the minimum, with realistic input sizes and warmed caches.
✎ Try it yourself

Problem. Your program is slow and you suspect a particular function. What is the correct sequence of tools and steps?

Solution. First run cProfile on the whole program (cProfile.run('main()')) and read the cumulative-time column to confirm which function truly dominates — do not assume. Then apply line_profiler (@profile with kernprof) to that one function to find the costliest lines. Optimize only those lines (often by vectorizing), then re-run timeit/cProfile to verify the speedup is real. Answer: profile with cProfile to locate the hot function, drill in with line_profiler, optimize that, then re-measure to confirm.

When to reach for compiled speed (Numba, Julia)

When an algorithm is inherently sequential or hard to vectorize, JIT compilation (Numba) or a compiled language (Julia) recovers C-level speed.

Vectorization solves most performance problems, but some algorithms resist it — tight sequential dependencies (recurrences, iterative solvers with branching), element-wise control flow, or cases where the vectorized form would allocate huge temporaries. Then you reach for compilation. Numba JIT-compiles a Python function to machine code with the @njit decorator; written as plain loops over NumPy arrays, the compiled loop runs at near-C speed without leaving Python, and supports parallelism and SIMD. Julia takes this further: it compiles every function by default via LLVM, so idiomatic loops are already fast and there is no two-language split. The decision order is: vectorize with NumPy first; if that is impossible or memory-prohibitive, JIT the hotspot with Numba; if the whole project is performance-critical numerics, consider writing it in Julia. Always profile to confirm the hotspot before compiling.

Worked Example 1

Problem. Speed up a sequential recurrence with Numba's @njit.

  1. A logistic-map recurrence x_{n+1}=r x_n(1-x_n) is inherently sequential, so it cannot be vectorized over n.
  2. `from numba import njit\n@njit\ndef logistic(r, x0, n):\n x=x0\n for _ in range(n): x=r*x*(1-x)\n return x`
  3. `print(logistic(3.9, 0.5, 10_000_000))` runs the compiled loop ~50-100x faster than the same loop in pure Python.

Answer. @njit compiles the sequential recurrence to machine code, giving a large speedup where vectorization is impossible.

Worked Example 2

Problem. Decide between vectorization, Numba, and Julia for three scenarios.

  1. Elementwise math on big arrays (c = a*b + sin(d)): vectorize with NumPy — already compiled, no extra tooling.
  2. A tight, branchy iterative stencil that allocates huge temporaries if vectorized: profile, then @njit the hotspot to keep memory low and speed high.
  3. A new, entirely performance-critical numerical codebase: consider Julia, where `function f(x); s=0.0; for v in x; s+=v*v; end; s; end` is fast by default with no vectorization needed.

Answer. Vectorize the array math; Numba-JIT the branchy sequential hotspot; choose Julia when the whole numerics project demands compiled speed.

Common mistakes
  • Reaching for Numba/Julia before vectorizing or profiling; most slow code is fixed by NumPy vectorization, and you should confirm the hotspot first.
  • Expecting @njit to accelerate code full of Python objects or unsupported calls; Numba is fast on numeric loops over NumPy arrays, not on arbitrary dynamic Python.
✎ Try it yourself

Problem. An iterative solver with data-dependent branching is your profiled hotspot and cannot be vectorized without enormous temporaries. What do you do?

Solution. Since vectorization is ruled out (it would allocate huge temporaries) and profiling already confirmed this is the hotspot, JIT-compile it. Wrap the function with Numba's @njit and write it as plain loops over NumPy arrays; the data-dependent branches stay, but the loop now runs at near-C speed. If the entire project is performance-critical, porting to Julia (compiled by default) is the longer-term option. Answer: apply Numba @njit to the hotspot (loops over NumPy arrays); consider Julia if the whole codebase needs compiled speed.

Key terms
  • Numerical stability — a property of an algorithm: it does not unduly amplify rounding errors during computation.
  • Vectorization — expressing operations on whole arrays so they run in fast compiled loops instead of Python loops.
  • ufunc — a NumPy universal function that applies an operation elementwise over arrays at C speed.
  • Broadcasting — NumPy's rules for combining arrays of different but compatible shapes without copying data.
  • View vs. copy — a view shares the original array's memory; a copy allocates new memory, affecting speed and aliasing.
  • Memory layout — how array elements are ordered (row-major C vs. column-major Fortran), which affects cache efficiency.
  • Profiling — measuring where a program spends its time to locate the bottleneck before optimizing.
  • JIT compilation — just-in-time compiling hot code (e.g. with Numba) to machine speed; Julia compiles by default.
Assignment · Vectorize and measure

Take a numeric kernel written as an explicit Python double loop (for example a pairwise distance matrix or a finite-difference stencil) and rewrite it using NumPy vectorization and broadcasting. Benchmark both versions with timeit across growing input sizes and report the speedup. Verify the two implementations agree with numpy.allclose, and discuss whether memory layout or broadcasting created any hidden array copies.

Deliverable · A script and report with a timing table or plot of loop vs. vectorized runtime, the measured speedup factor, and one paragraph on a case where vectorizing would cost too much memory.

Quiz · 5 questions
  1. 1. A numerically stable algorithm is one that:

  2. 2. NumPy vectorization is faster than an equivalent Python loop mainly because it:

  3. 3. Broadcasting in NumPy lets you:

  4. 4. Before optimizing performance, the recommended first step is to:

  5. 5. A NumPy slice that returns a view rather than a copy means that:

You'll be able to

I can distinguish a stable algorithm from an unstable one and explain the difference from conditioning.

I can rewrite scalar Python loops as vectorized NumPy operations and measure the speedup.

I can profile code to find bottlenecks and decide when compiled acceleration is worth it.

Where this leads

Course milestones

Floating-point literacy checkpoint: explain IEEE 754 and machine epsilon, and reformulate a cancellation-prone computation into a stable one (Unit 1).
Root-finding lab: implement bisection, Newton, and secant methods from scratch and verify their convergence orders against SciPy (Unit 2).
Linear systems & conditioning project: solve Ax = b with LU and connect the condition number to observed digit loss on an ill-conditioned system (Unit 3).
Numerical calculus toolkit: fit data with splines and least squares, and integrate functions with adaptive quadrature while estimating error (Units 4-5).
Capstone — a simulation that solves a stiff ODE system with an adaptive/implicit solver and is then profiled and vectorized for performance (Units 6-7).

Free, forever. Math you can actually use.

Part of the open-source Crunch Academy tech-education path. Pair it with the data-science and ML tracks when you're ready.

All Math for Data Science courses Crunch Academy home