Numerical Applications for Economics and Econometrics

Errors, Matrices, Equations, Optimization, Differential Equations,
CPU Parallelization & GPU Acceleration — in R & Python

Applied Informatics and Computational Economics Lab

3 July 2026

Outline

  •  Part I — Floating-Point Errors: how computers break arithmetic, and when it costs you an estimate
  •  Part II — Numerical Linear Algebra: solve, never invert · OLS done right
  •  Part III — Nonlinear Equations & Systems: roots, IRR, market equilibria
  •  Part IV — Optimization: gradient descent to BFGS · MLE from scratch
  •  Part V — Differentiation & Integration: finite differences · quadrature · Monte Carlo
  •  Part VI — Differential Equations: Euler, RK4 · the Solow growth model
  •  Part VII — CPU Parallelization: processes vs threads · Amdahl’s law
  •  Part VIII — GPU Acceleration: torch tensors · autograd · when the GPU wins

Required Packages

library(tidyverse)       # plotting and data handling
library(Matrix)          # sparse matrices, rcond()
library(microbenchmark)  # accurate timing of small code pieces
library(numDeriv)        # grad(), hessian() — numerical derivatives
library(nleqslv)         # Newton/Broyden solver for nonlinear systems
library(deSolve)         # ode() — differential equation solvers
library(parallel)        # mclapply() — process-based parallelism
library(torch)           # tensors, autograd, GPU acceleration
import numpy as np                    # arrays, linear algebra
import scipy.linalg, scipy.optimize   # decompositions, solvers
import scipy.integrate, scipy.sparse  # quadrature, ODEs, sparse matrices
import matplotlib.pyplot as plt       # plotting
from joblib import Parallel, delayed  # process-based parallelism
import torch                          # tensors, autograd, GPU acceleration

Part I — Floating-Point Arithmetic & Errors

How Computers Break Arithmetic — and When It Costs You an Estimate

Why Numerics Matter in Econometrics

Every estimator you trust is computed with arithmetic that is not exact.

Computers store real numbers in a finite binary format. Consequences appear exactly where econometrics lives:

  • \((\mathbf{X}^\top\mathbf{X})^{-1}\) with near-collinear regressors amplifies rounding error by factors of \(10^{10}\) or more
  • Log-likelihoods of large samples overflow or underflow without the log-sum-exp trick
  • The “textbook” one-pass variance formula returns negative variances on real price data
  • Optimizers report “convergence” at points that are not optima because gradients drown in rounding noise

The good news: a handful of rules — solve, never invert; standardise; work in logs; compare with tolerances — eliminates almost all of it.

Failure Cause Lesson
Patriot missile, Dhahran 1991 (28 dead) 0.1 s not representable in binary; drift accumulated over 100 h Representation error accumulates
Vancouver Stock Exchange index, 1982 Truncation (not rounding) at each trade; index lost ~50% in 22 months Tiny biases compound
Ariane 5 explosion, 1996 64-bit float forced into 16-bit integer — overflow Know your types and ranges
Longley (1967) regression benchmark Near-collinear macro data; programs of the era got zero correct digits Conditioning decides accuracy

Longley, J. W. (1967). An appraisal of least squares programs. JASA 62, 819–841. doi:10.1080/01621459.1967.10500896

IEEE 754 — How Computers Store Numbers

Every double-precision number is stored as sign, exponent, and a 52-bit fraction:

\[x = (-1)^{s} \times (1.f_{1}f_{2}\ldots f_{52})_2 \times 2^{e-1023}\]

Machine epsilon is the gap between 1 and the next representable number:

\[\varepsilon_{\text{mach}} = 2^{-52} \approx 2.22 \times 10^{-16}\]

Each single arithmetic operation is correctly rounded, so its relative error is bounded:

\[\text{fl}(x \circ y) = (x \circ y)(1 + \delta), \qquad |\delta| \le \tfrac{1}{2}\varepsilon_{\text{mach}}\]

Three distinct sources of numerical error — keep them apart:

  • Representation error\(0.1\) has no finite binary expansion; stored value is already wrong
  • Rounding error — each of millions of operations adds a \(\delta\); errors can accumulate or cancel
  • Truncation error — approximating limits by finite steps (derivatives, integrals, ODEs — Parts V–VI)

Absolute vs relative error of an approximation \(\hat{x}\) to true \(x\):

\[e_{\text{abs}} = |\hat{x} - x|, \qquad e_{\text{rel}} = \frac{|\hat{x} - x|}{|x|}\]

Relative error is what matters: \(15.9\) significant decimal digits is all a double ever gives you.

Machine Epsilon & Float Comparison — Code

Code
# Machine epsilon: the smallest x with 1 + x > 1
cat("machine epsilon:", .Machine$double.eps, "\n")
machine epsilon: 2.220446e-16 
Code
cat("1 + eps/2 == 1 ?", 1 + .Machine$double.eps/2 == 1, "\n")
1 + eps/2 == 1 ? TRUE 
Code
# The most famous wrong sum in computing
cat("0.1 + 0.2 == 0.3 ?", 0.1 + 0.2 == 0.3, "\n")
0.1 + 0.2 == 0.3 ? FALSE 
Code
cat("0.1 + 0.2 - 0.3   =", format(0.1 + 0.2 - 0.3, digits = 17), "\n")
0.1 + 0.2 - 0.3   = 5.5511151231257827e-17 
Code
# What is actually stored for 0.1 (first 20 decimal digits)
cat("stored value of 0.1 =", format(0.1, digits = 20), "\n")
stored value of 0.1 = 0.10000000000000000555 
Code
# Correct comparisons: tolerance-based, never ==
cat("all.equal(0.1 + 0.2, 0.3):", isTRUE(all.equal(0.1 + 0.2, 0.3)), "\n")
all.equal(0.1 + 0.2, 0.3): TRUE 
Code
cat("abs(a - b) < 1e-8        :", abs((0.1 + 0.2) - 0.3) < 1e-8, "\n")
abs(a - b) < 1e-8        : TRUE 
Code
# Range limits: overflow and underflow
cat("largest double :", .Machine$double.xmax, "\n")
largest double : 1.797693e+308 
Code
cat("exp(710)       :", exp(710), "  (overflow -> Inf)\n")
exp(710)       : Inf   (overflow -> Inf)
Code
cat("exp(-746)      :", exp(-746), " (underflow -> 0)\n")
exp(-746)      : 0  (underflow -> 0)
Code
import numpy as np

print("machine epsilon:", np.finfo(np.float64).eps)
machine epsilon: 2.220446049250313e-16
Code
print("1 + eps/2 == 1 ?", 1 + np.finfo(np.float64).eps/2 == 1)
1 + eps/2 == 1 ? True
Code
# The most famous wrong sum in computing
print("0.1 + 0.2 == 0.3 ?", 0.1 + 0.2 == 0.3)
0.1 + 0.2 == 0.3 ? False
Code
print("0.1 + 0.2 - 0.3   =", repr(0.1 + 0.2 - 0.3))
0.1 + 0.2 - 0.3   = 5.551115123125783e-17
Code
# What is actually stored for 0.1 (first 20 decimal digits)
print(f"stored value of 0.1 = {0.1:.20f}")
stored value of 0.1 = 0.10000000000000000555
Code
# Correct comparisons: tolerance-based, never ==
print("np.isclose(0.1 + 0.2, 0.3):", np.isclose(0.1 + 0.2, 0.3))
np.isclose(0.1 + 0.2, 0.3): True
Code
# float32 loses digits fast: same computation in single precision
a64 = np.float64(1/3); a32 = np.float32(1/3)
print(f"1/3 in float64: {a64:.17f}")
1/3 in float64: 0.33333333333333331
Code
print(f"1/3 in float32: {a32:.17f}   <- only ~7 correct digits")
1/3 in float32: 0.33333334326744080   <- only ~7 correct digits
Code
# Range limits: overflow and underflow
print("largest double :", np.finfo(np.float64).max)
largest double : 1.7976931348623157e+308
Code
print("np.exp(710)    :", np.exp(710.0), " (overflow -> inf)")
np.exp(710)    : inf  (overflow -> inf)
Code
print("np.exp(-746)   :", np.exp(-746.0), "(underflow -> 0)")
np.exp(-746)   : 0.0 (underflow -> 0)

Computing Machine Epsilon by Hand

You never need the built-in constant — you can measure \(\varepsilon_{\text{mach}}\) directly. Start the increment at \(1\) and halve it repeatedly; the machine epsilon is the last increment the computer can still tell apart from \(1\):

\[\varepsilon_{\text{mach}} = \min\{\,2^{-k} : \text{fl}(1 + 2^{-k}) > 1\,\} = 2^{-52}\]

Under round-to-nearest, \(1 + 2^{-53}\) ties back to \(1\), so the halving stops one step early — after exactly \(52\) halvings, at \(2^{-52}\). Repeat in single precision and the loop stops far sooner (\(2^{-23}\)): fewer mantissa bits, coarser spacing.

Code
# Shrink the increment until 1 + eps/2 is no longer distinguishable from 1
eps   <- 1
steps <- 0
while (1 + eps/2 > 1) {
  eps   <- eps/2
  steps <- steps + 1
}
cat("computed eps :", eps, " after", steps, "halvings\n")
computed eps : 2.220446e-16  after 52 halvings
Code
cat("2^-52        :", 2^-52, "\n")
2^-52        : 2.220446e-16 
Code
cat(".Machine$eps :", .Machine$double.eps, "\n")
.Machine$eps : 2.220446e-16 
Code
cat("match ?      :", eps == .Machine$double.eps, "\n")
match ?      : TRUE 
Code
import numpy as np

# float64: shrink the increment until 1 + eps/2 stops changing 1
eps, steps = 1.0, 0
while 1.0 + eps/2.0 > 1.0:
    eps   /= 2.0
    steps += 1
print(f"computed eps : {eps:.17e}  after {steps} halvings")
computed eps : 2.22044604925031308e-16  after 52 halvings
Code
print(f"np.finfo eps : {np.finfo(np.float64).eps:.17e}")
np.finfo eps : 2.22044604925031308e-16
Code
print("match ?      :", eps == np.finfo(np.float64).eps)
match ?      : True
Code
# float32 carries only ~7 digits: the same loop stops much sooner
e32, k = np.float32(1.0), 0
while np.float32(1.0) + e32/np.float32(2.0) > np.float32(1.0):
    e32 /= np.float32(2.0)
    k   += 1
print(f"\nfloat32 eps  : {e32:.8e}  after {k} halvings  (2**-23)")

float32 eps  : 1.19209290e-07  after 23 halvings  (2**-23)
Code
print("np.finfo f32 :", np.finfo(np.float32).eps)
np.finfo f32 : 1.1920929e-07

Catastrophic Cancellation

Subtracting two nearly equal numbers destroys the leading (correct) digits and promotes rounding noise to the front:

\[x = 1.234567\underbrace{89012345}_{\text{noise}}, \quad y = 1.234567\underbrace{88999999}_{\text{noise}}, \quad x - y = 0.00000000012346\ldots\]

The relative error of the difference explodes even though \(x\) and \(y\) were each accurate to 16 digits — the leading digits that agreed simply vanish, and rounding noise is promoted to the front.

The one-pass (“textbook”) variance formula:

\[s^2 = \frac{1}{n-1}\left(\sum_{i=1}^n x_i^2 - n\bar{x}^2\right)\]

When the mean is large relative to the spread, \(\sum x_i^2\) and \(n\bar{x}^2\) are nearly equal — the subtraction cancels catastrophically and can return a negative variance.

The stable two-pass formula subtracts the mean first, while numbers are still small:

\[s^2 = \frac{1}{n-1}\sum_{i=1}^n (x_i - \bar{x})^2\]

Same algebra, completely different arithmetic. Think of stock prices near 10,000 index points with daily changes of a few points — exactly this regime.

Code
set.seed(14159)
# Price-like data: huge level, tiny spread (true variance = 0.01)
x <- 1e8 + rnorm(1e6, mean = 0, sd = 0.1)

# One-pass "textbook" formula: sum(x^2) - n*xbar^2
n <- length(x)
v_onepass <- (sum(x^2) - n * mean(x)^2) / (n - 1)

# Two-pass formula: subtract the mean first
v_twopass <- sum((x - mean(x))^2) / (n - 1)

cat(sprintf("true variance      : %.6f\n", 0.01))
true variance      : 0.010000
Code
cat(sprintf("one-pass  formula  : %.6f   <- cancellation disaster\n", v_onepass))
one-pass  formula  : 0.000000   <- cancellation disaster
Code
cat(sprintf("two-pass  formula  : %.6f   <- stable\n", v_twopass))
two-pass  formula  : 0.009978   <- stable
Code
cat(sprintf("R's var() (two-pass): %.6f\n", var(x)))
R's var() (two-pass): 0.009978
Code
# Second classic: (1 - cos(x)) / x^2 -> 1/2 as x -> 0
x_small <- 1e-8
naive  <- (1 - cos(x_small)) / x_small^2        # cancels: 1 - cos(x) ~ 0
stable <- 2 * (sin(x_small / 2) / x_small)^2    # algebraically identical
cat(sprintf("\n(1-cos x)/x^2 at x = 1e-8 (true value 0.5):\n"))

(1-cos x)/x^2 at x = 1e-8 (true value 0.5):
Code
cat(sprintf("naive  : %.6f\n", naive))
naive  : 0.000000
Code
cat(sprintf("stable : %.6f\n", stable))
stable : 0.500000
Code
import numpy as np
rng = np.random.default_rng(14159)

# Price-like data: huge level, tiny spread (true variance = 0.01)
x = 1e8 + rng.normal(0, 0.1, size=1_000_000)
n = x.size

# One-pass "textbook" formula vs stable two-pass formula
v_onepass = (np.sum(x**2) - n * x.mean()**2) / (n - 1)
v_twopass = np.sum((x - x.mean())**2) / (n - 1)

print(f"true variance      : {0.01:.6f}")
true variance      : 0.010000
Code
print(f"one-pass  formula  : {v_onepass:.6f}   <- cancellation disaster")
one-pass  formula  : 6.291462   <- cancellation disaster
Code
print(f"two-pass  formula  : {v_twopass:.6f}   <- stable")
two-pass  formula  : 0.009993   <- stable
Code
print(f"np.var(ddof=1)     : {np.var(x, ddof=1):.6f}")
np.var(ddof=1)     : 0.009993
Code
# Second classic: (1 - cos(x)) / x^2 -> 1/2 as x -> 0
xs = 1e-8
naive  = (1 - np.cos(xs)) / xs**2
stable = 2 * (np.sin(xs / 2) / xs)**2
print(f"\n(1-cos x)/x^2 at x = 1e-8 (true value 0.5):")

(1-cos x)/x^2 at x = 1e-8 (true value 0.5):
Code
print(f"naive  : {naive:.6f}")
naive  : 0.000000
Code
print(f"stable : {stable:.6f}")
stable : 0.500000

Overflow, Underflow & the Log-Sum-Exp Trick

The likelihood of even a modest sample underflows: \(1000\) observations with density values around \(0.3\) give \(L = 0.3^{1000} \approx 10^{-523} \to 0\). All likelihood work happens in logs — but logs alone are not enough when you must sum exponentials (mixtures, logit denominators, particle filters):

\[\log \sum_{j=1}^{J} e^{a_j} = M + \log \sum_{j=1}^{J} e^{a_j - M}, \qquad M = \max_j a_j\]

Shifting by the max makes the largest exponent exactly \(e^0 = 1\): no overflow, no total underflow.

Code
# Log-likelihood contributions of a mixture component: large negative numbers
a <- c(-1000, -1001, -1002)   # log f_j(x_i): perfectly ordinary in MLE work

# Naive: exponentiate first -> everything underflows to 0 -> log(0) = -Inf
naive <- log(sum(exp(a)))
cat("naive  log(sum(exp(a))):", naive, "\n")
naive  log(sum(exp(a))): -Inf 
Code
# Log-sum-exp: shift by the max before exponentiating
logsumexp <- function(a) {
  M <- max(a)
  M + log(sum(exp(a - M)))
}
cat("stable logsumexp(a)    :", logsumexp(a), "\n")
stable logsumexp(a)    : -999.5924 
Code
# Same trick under the hood of a logit log-likelihood: use log1p / plogis
# log(1 + exp(eta)) overflows for eta > ~710; the stable form never does
eta <- 800
cat("naive  log(1+exp(eta)) :", log(1 + exp(eta)), "\n")
naive  log(1+exp(eta)) : Inf 
Code
cat("stable via max trick   :", max(eta, 0) + log1p(exp(-abs(eta))), "\n")
stable via max trick   : 800 
Code
import numpy as np
from scipy.special import logsumexp   # library version of the trick

a = np.array([-1000.0, -1001.0, -1002.0])   # log-density values

# Naive: exponentiate first -> underflow -> log(0) = -inf
print("naive  log(sum(exp(a))):", np.log(np.sum(np.exp(a))))
naive  log(sum(exp(a))): -inf
Code
# Stable: shift by the max before exponentiating
M = a.max()
print("manual logsumexp       :", M + np.log(np.sum(np.exp(a - M))))
manual logsumexp       : -999.5923940355556
Code
print("scipy.special.logsumexp:", logsumexp(a))
scipy.special.logsumexp: -999.5923940355556
Code
# Logit likelihood term log(1 + exp(eta)): naive overflows for eta > ~710
eta = 800.0
print("naive  log(1+exp(eta)) :", np.log(1 + np.exp(eta)))
naive  log(1+exp(eta)) : inf
Code
print("stable np.logaddexp    :", np.logaddexp(0.0, eta))
stable np.logaddexp    : 800.0

Conditioning vs Stability

Two different questions, always keep them apart:

  • Conditioning is a property of the problem: how much does the exact answer move when inputs move a little?
  • Stability is a property of the algorithm: does it add errors beyond what conditioning forces?

The condition number of a function \(f\) at input \(x\) measures the relative-error amplification:

\[\kappa_f(x) = \left|\frac{x\, f'(x)}{f(x)}\right|, \qquad \frac{|\Delta f|}{|f|} \approx \kappa_f(x)\, \frac{|\Delta x|}{|x|}\]

For solving the linear system \(\mathbf{A}\mathbf{x} = \mathbf{b}\) (the heart of OLS):

\[\kappa(\mathbf{A}) = \|\mathbf{A}\|\,\|\mathbf{A}^{-1}\| = \frac{\sigma_{\max}(\mathbf{A})}{\sigma_{\min}(\mathbf{A})}\]

The rule of thumb that runs all of Part II:

\[\text{correct digits in } \hat{\mathbf{x}} \;\approx\; 16 - \log_{10} \kappa(\mathbf{A})\]

  • \(\kappa = 10^2\): ~14 digits — harmless
  • \(\kappa = 10^8\): ~8 digits — noticeable
  • \(\kappa = 10^{15}\): zero correct digits — the near-collinear regression case
  • A stable algorithm on an ill-conditioned problem still gives a bad answer — but the best possible bad answer. An unstable algorithm (normal equations!) makes it needlessly worse by squaring \(\kappa\).

Floating-Point Pitfalls — A Checklist

Pitfall Symptom Fix
x == y for floats Tests randomly TRUE/FALSE abs(x-y) < tol, all.equal(), np.isclose()
One-pass variance / covariance Negative variance on high-level data Two-pass: centre first
log(1 + x) for tiny x Returns 0 (1+x rounds to 1) log1p(x); likewise expm1(x)
Likelihoods in levels Underflow to 0, -Inf log-lik Work in logs + log-sum-exp
Summation order Different totals in parallel runs Sums are not associative in floats — sort or use compensated (Kahan) summation
Subtracting near-equal numbers Digits vanish Algebraic rewrite (e.g. \(1-\cos x = 2\sin^2\frac{x}{2}\))
Integer overflow (R: .Machine$integer.max = 2³¹−1) NA or silent wraparound Use doubles / bit64 for counts beyond 2×10⁹
float32 defaults on GPU Estimates match R to only ~7 digits Request float64 when precision matters (Part VIII)
Code
# Two quick demonstrations from the checklist

# 1. Summation is not associative: same numbers, different order, different sum
set.seed(14159)
v <- rnorm(1e6) * 1e8
cat("sum ascending :", format(sum(sort(v)), digits = 17), "\n")
sum ascending : -100483359443.37851 
Code
cat("sum descending:", format(sum(sort(v, decreasing = TRUE)), digits = 17), "\n")
sum descending: -100483359443.37836 
Code
# 2. log1p vs log(1 + x) for tiny x (true value ~ 1e-16)
x <- 1e-16
cat("log(1 + 1e-16):", log(1 + x), "   <- 1 + x rounded to 1\n")
log(1 + 1e-16): 0    <- 1 + x rounded to 1
Code
cat("log1p(1e-16)  :", log1p(x), "\n")
log1p(1e-16)  : 1e-16 

Part II — Numerical Linear Algebra

Solve, Never Invert — OLS Done Right

Matrices Are Where Econometrics Computes

Nearly every estimator reduces to solving a linear system:

\[\hat{\boldsymbol\beta}^{OLS} : \; (\mathbf{X}^\top\mathbf{X})\,\hat{\boldsymbol\beta} = \mathbf{X}^\top\mathbf{y} \qquad \text{GLS, IV, GMM, Newton steps, Kalman filters — all the same shape } \mathbf{A}\mathbf{x} = \mathbf{b}\]

The textbook writes \(\hat{\boldsymbol\beta} = (\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}\) — but the textbook formula is notation, not an algorithm.

  • Computing \(\mathbf{A}^{-1}\) explicitly costs ~3× the flops of solving the system, and is less accurate
  • Forming \(\mathbf{X}^\top\mathbf{X}\) squares the condition number: \(\kappa(\mathbf{X}^\top\mathbf{X}) = \kappa(\mathbf{X})^2\)
  • Good software (R’s lm(), statsmodels) never inverts — it factorises: QR, Cholesky, or SVD
Rule Instead of Do
Never invert to solve solve(A) %*% b solve(A, b)
Never form X’X if avoidable solve(t(X) %*% X, t(X) %*% y) qr.solve(X, y) / lstsq
Exploit structure generic solve() on SPD matrix Cholesky: chol() / cho_solve
Check conditioning trust any output kappa(X), np.linalg.cond(X)
Exploit sparsity dense dummies for 10,000 FE Matrix::sparseMatrix, scipy.sparse

The Four Decompositions

Factor once, then solve cheaply by forward/back-substitution (\(O(n^2)\) per right-hand side):

LU with partial pivoting — general square systems:

\[\mathbf{P}\mathbf{A} = \mathbf{L}\mathbf{U}, \qquad \text{cost } \tfrac{2}{3}n^3 \text{ flops}\]

Cholesky — symmetric positive definite (covariance matrices, \(\mathbf{X}^\top\mathbf{X}\), GLS weights):

\[\mathbf{A} = \mathbf{L}\mathbf{L}^\top, \qquad \text{cost } \tfrac{1}{3}n^3 \text{ flops — twice as fast as LU, fails loudly if not PD}\]

QR — least squares without forming \(\mathbf{X}^\top\mathbf{X}\):

\[\mathbf{X} = \mathbf{Q}\mathbf{R}, \quad \mathbf{Q}^\top\mathbf{Q} = \mathbf{I} \;\Rightarrow\; \mathbf{R}\hat{\boldsymbol\beta} = \mathbf{Q}^\top\mathbf{y}, \qquad \text{cost } 2np^2 \text{ flops}\]

SVD — the most robust, reveals conditioning and rank:

\[\mathbf{X} = \mathbf{U}\boldsymbol\Sigma\mathbf{V}^\top, \qquad \kappa(\mathbf{X}) = \frac{\sigma_{\max}}{\sigma_{\min}}, \qquad \text{cost } \approx 4np^2 + 8p^3 \text{ flops}\]

Method Use for Speed Robustness
LU (solve, lu) General \(\mathbf{A}\mathbf{x}=\mathbf{b}\) fast good
Cholesky (chol, cho_factor) SPD systems, GLS, simulation of MVN fastest good (PD only)
QR (qr, np.linalg.qr) Least squares medium very good
SVD (svd, np.linalg.svd) Rank, PCA, ill-conditioned LS slowest best

Solve vs Invert — Speed and Accuracy

Code
set.seed(14159)
n <- 1000
A <- crossprod(matrix(rnorm(n * n), n, n)) + n * diag(n)  # SPD, well-conditioned
b <- rnorm(n)

# Three ways to get x with A x = b
mb <- microbenchmark(
  invert   = solve(A) %*% b,          # explicit inverse: never do this
  solve    = solve(A, b),             # LU solve
  cholesky = backsolve(chol(A), forwardsolve(t(chol(A)), b)),
  times = 10
)
print(summary(mb)[, c("expr", "median")], digits = 3)
      expr median
1   invert   40.8
2    solve   12.3
3 cholesky   21.5
Code
# Accuracy: residual norm ||A x - b|| for each method
x_inv  <- solve(A) %*% b
x_slv  <- solve(A, b)
R <- chol(A)
x_chl  <- backsolve(R, forwardsolve(t(R), b))
cat(sprintf("residual  invert  : %.3e\n", norm(A %*% x_inv - b, "2")))
residual  invert  : 3.807e-14
Code
cat(sprintf("residual  solve   : %.3e\n", norm(A %*% x_slv - b, "2")))
residual  solve   : 3.716e-14
Code
cat(sprintf("residual  cholesky: %.3e\n", norm(A %*% x_chl - b, "2")))
residual  cholesky: 2.267e-14
Code
import numpy as np
from scipy.linalg import cho_factor, cho_solve
import time

rng = np.random.default_rng(14159)
n = 1000
G = rng.standard_normal((n, n))
A = G.T @ G + n * np.eye(n)          # SPD, well-conditioned
b = rng.standard_normal(n)

def timeit(f, reps=10):
    ts = []
    for _ in range(reps):
        t0 = time.perf_counter(); f(); ts.append(time.perf_counter() - t0)
    return np.median(ts) * 1000       # ms

t_inv = timeit(lambda: np.linalg.inv(A) @ b)
t_slv = timeit(lambda: np.linalg.solve(A, b))
t_chl = timeit(lambda: cho_solve(cho_factor(A), b))
print(f"median time  inv(A) @ b : {t_inv:7.2f} ms   <- never do this")
median time  inv(A) @ b :   29.50 ms   <- never do this
Code
print(f"median time  solve(A, b): {t_slv:7.2f} ms")
median time  solve(A, b):   16.25 ms
Code
print(f"median time  cholesky   : {t_chl:7.2f} ms   <- SPD structure exploited")
median time  cholesky   :    6.03 ms   <- SPD structure exploited
Code
# Accuracy: residual norm ||A x - b||
for name, x in [("invert  ", np.linalg.inv(A) @ b),
                ("solve   ", np.linalg.solve(A, b)),
                ("cholesky", cho_solve(cho_factor(A), b))]:
    print(f"residual {name}: {np.linalg.norm(A @ x - b):.3e}")
residual invert  : 3.754e-14
residual solve   : 3.671e-14
residual cholesky: 2.647e-14

OLS on Ill-Conditioned Data — Normal Equations vs QR

The classic Longley (1967) macro data: 6 near-collinear regressors (GNP, deflator, population, …), \(\kappa(\mathbf{X}) \approx 10^{10}\). Normal equations work with \(\kappa^2 \approx 10^{20}\)beyond double precision. The normal-equations coefficients drift from lm()’s; QR stays exact.

Code
data(longley)
X <- cbind(1, as.matrix(longley[, c("GNP.deflator", "GNP", "Unemployed",
                                    "Armed.Forces", "Population", "Year")]))
y <- longley$Employed

cat(sprintf("condition number kappa(X)    : %.3e\n", kappa(X, exact = TRUE)))
condition number kappa(X)    : 2.385e+07
Code
cat(sprintf("condition number kappa(X'X)  : %.3e   <- squared!\n\n",
            kappa(crossprod(X), exact = TRUE)))
condition number kappa(X'X)  : 5.686e+14   <- squared!
Code
# Method 1: normal equations (X'X)^{-1} X'y  -- the unstable textbook route
beta_ne <- solve(crossprod(X), crossprod(X, y))

# Method 2: QR decomposition -- what lm() does
beta_qr <- qr.solve(X, y)

# Reference: lm() with full pivoting
beta_lm <- coef(lm(Employed ~ GNP.deflator + GNP + Unemployed +
                   Armed.Forces + Population + Year, data = longley))

tab <- data.frame(normal_eq = as.numeric(beta_ne),
                  qr        = beta_qr,
                  lm        = as.numeric(beta_lm))
rownames(tab) <- c("(Intercept)", "GNP.deflator", "GNP", "Unemployed",
                   "Armed.Forces", "Population", "Year")
print(round(tab, 6))
                normal_eq           qr           lm
(Intercept)  -3482.258599 -3482.258635 -3482.258635
GNP.deflator     0.015062     0.015062     0.015062
GNP             -0.035819    -0.035819    -0.035819
Unemployed      -0.020202    -0.020202    -0.020202
Armed.Forces    -0.010332    -0.010332    -0.010332
Population      -0.051104    -0.051104    -0.051104
Year             1.829151     1.829151     1.829151
Code
cat(sprintf("\nmax |normal_eq - lm| : %.3e\n", max(abs(tab$normal_eq - tab$lm))))

max |normal_eq - lm| : 3.604e-05
Code
cat(sprintf("max |qr        - lm| : %.3e\n", max(abs(tab$qr - tab$lm))))
max |qr        - lm| : 0.000e+00
Code
import numpy as np
import statsmodels.api as sm

# Longley dataset ships with statsmodels
data = sm.datasets.longley.load_pandas()
X = sm.add_constant(data.exog.to_numpy())
y = data.endog.to_numpy()

print(f"condition number kappa(X)  : {np.linalg.cond(X):.3e}")
condition number kappa(X)  : 4.859e+09
Code
print(f"condition number kappa(X'X): {np.linalg.cond(X.T @ X):.3e}   <- squared!\n")
condition number kappa(X'X): 2.384e+19   <- squared!
Code
# Method 1: normal equations -- unstable
beta_ne = np.linalg.solve(X.T @ X, X.T @ y)

# Method 2: QR decomposition
Q, R = np.linalg.qr(X)
beta_qr = np.linalg.solve(R, Q.T @ y)

# Method 3: SVD-based lstsq -- what statsmodels uses (pinv)
beta_ls = np.linalg.lstsq(X, y, rcond=None)[0]

names = ["const"] + list(data.exog.columns)
print(f"{'variable':<12} {'normal_eq':>15} {'qr':>15} {'lstsq(SVD)':>15}")
variable           normal_eq              qr      lstsq(SVD)
Code
for nm, b1, b2, b3 in zip(names, beta_ne, beta_qr, beta_ls):
    print(f"{nm:<12} {b1:15.6f} {b2:15.6f} {b3:15.6f}")
const        -3482258.655822 -3482258.634598 -3482258.634598
GNPDEFL            15.061873       15.061872       15.061872
GNP                -0.035819       -0.035819       -0.035819
UNEMP              -2.020230       -2.020230       -2.020230
ARMED              -1.033227       -1.033227       -1.033227
POP                -0.051104       -0.051104       -0.051104
YEAR             1829.151475     1829.151465     1829.151465
Code
print(f"\nmax |normal_eq - lstsq| : {np.max(np.abs(beta_ne - beta_ls)):.3e}")

max |normal_eq - lstsq| : 2.122e-02
Code
print(f"max |qr        - lstsq| : {np.max(np.abs(beta_qr - beta_ls)):.3e}")
max |qr        - lstsq| : 3.073e-08

Sparse Matrices — Fixed Effects Without the Memory Bill

A panel with \(10{,}000\) individual dummies gives a design matrix that is \(>99.9\%\) zeros. Dense storage wastes memory and flops; sparse storage keeps only the non-zeros — this is how fixest/reghdfe/pyfixest scale.

\[\text{dense: } n \times p \times 8 \text{ bytes} \qquad \text{sparse (CSC): } \approx \text{nnz} \times 12 \text{ bytes}\]

Code
set.seed(14159)
n_id <- 1000; t_per <- 5; n <- n_id * t_per   # 5,000 obs, 1,000 FE dummies
id <- factor(rep(seq_len(n_id), each = t_per))
x  <- rnorm(n)
y  <- 1.5 * x + rep(rnorm(n_id), each = t_per) + rnorm(n)

# Dense vs sparse dummy design matrix
X_dense  <- model.matrix(~ x + id)                    # dense: huge
X_sparse <- Matrix::sparse.model.matrix(~ x + id)     # sparse: tiny

cat(sprintf("dense  size: %8.1f MB\n", as.numeric(object.size(X_dense)) / 1e6))
dense  size:     40.4 MB
Code
cat(sprintf("sparse size: %8.1f MB\n", as.numeric(object.size(X_sparse)) / 1e6))
sparse size:      0.6 MB
Code
cat(sprintf("share of non-zeros: %.4f%%\n\n",
            100 * Matrix::nnzero(X_sparse) / prod(dim(X_sparse))))
share of non-zeros: 0.2996%
Code
# Sparse least squares: solve the (sparse) normal equations via Cholesky
XtX <- Matrix::crossprod(X_sparse)     # stays sparse
Xty <- Matrix::crossprod(X_sparse, y)
t_sparse <- system.time(beta_s <- Matrix::solve(XtX, Xty))["elapsed"]
cat(sprintf("sparse solve   : %.3f s,  beta_x = %.4f\n", t_sparse, beta_s["x", 1]))
sparse solve   : 0.001 s,  beta_x = 1.4967
Code
# Same via dense lm.fit for comparison
t_dense <- system.time(beta_d <- .lm.fit(X_dense, y)$coefficients)["elapsed"]
cat(sprintf("dense  lm.fit  : %.3f s,  beta_x = %.4f\n", t_dense, beta_d[2]))
dense  lm.fit  : 1.397 s,  beta_x = 1.4967
Code
import numpy as np
import scipy.sparse as sp
from scipy.sparse.linalg import spsolve
import time

rng = np.random.default_rng(14159)
n_id, t_per = 1000, 5
n = n_id * t_per
id_ = np.repeat(np.arange(n_id), t_per)
x = rng.standard_normal(n)
y = 1.5 * x + np.repeat(rng.standard_normal(n_id), t_per) + rng.standard_normal(n)

# Sparse design: [x | 1000 dummy columns] in CSR format
D = sp.csr_matrix((np.ones(n), (np.arange(n), id_)), shape=(n, n_id))
X = sp.hstack([sp.csr_matrix(x[:, None]), D]).tocsc()

dense_mb  = n * (n_id + 1) * 8 / 1e6
sparse_mb = (X.data.nbytes + X.indices.nbytes + X.indptr.nbytes) / 1e6
print(f"dense  size: {dense_mb:8.1f} MB (if materialised)")
dense  size:     40.0 MB (if materialised)
Code
print(f"sparse size: {sparse_mb:8.1f} MB")
sparse size:      0.1 MB
Code
print(f"share of non-zeros: {100 * X.nnz / (n * (n_id + 1)):.4f}%\n")
share of non-zeros: 0.1998%
Code
# Sparse normal equations
t0 = time.perf_counter()
beta = spsolve((X.T @ X).tocsc(), X.T @ y)
print(f"sparse solve: {time.perf_counter() - t0:.3f} s,  beta_x = {beta[0]:.4f}")
sparse solve: 0.005 s,  beta_x = 1.4634

Part II — Take-Home Notes

  • The inverse is notationsolve(A, b), never solve(A) %*% b
  • Least squares via QR or SVD, never via \((\mathbf{X}^\top\mathbf{X})^{-1}\): forming \(\mathbf{X}^\top\mathbf{X}\) squares \(\kappa\)
  • Exploit structure: Cholesky for SPD (2× faster), sparse formats for dummies
  • Correct digits \(\approx 16 - \log_{10}\kappa\)check \(\kappa\) before trusting output on collinear data
  • Part I: conditioning decides how many digits any algorithm can deliver
  • Part IV: every Newton step in MLE solves \(\mathbf{H}\,\mathbf{d} = -\nabla\ell\) — same rules apply to the Hessian
  • Part VIII: matrix products are exactly what GPUs accelerate — same math, thousandfold parallelism
  • Regularisation (ridge) is numerical stabilisation too: \(\mathbf{X}^\top\mathbf{X} + \lambda\mathbf{I}\) lifts \(\sigma_{\min}\)

When you report an estimation where collinearity is plausible, include:

  • \(\kappa(\mathbf{X})\) (scaled design) and what it implies for trustworthy digits
  • the solver actually used (QR / Cholesky / SVD)
  • whether variables were standardised before factorisation

Part III — Nonlinear Equations & Systems

Roots, Internal Rates of Return, and Market Equilibria

Root-Finding — Where It Appears in Economics

Find \(x^*\) such that \(f(x^*) = 0\). No closed form exists for most economic equations:

  • Internal rate of return: the \(r\) that zeroes the NPV of a cash-flow stream
  • Bond yields: yield-to-maturity solves a polynomial of degree \(T\)
  • Market equilibrium: excess demand \(D(p) - S(p) = 0\)
  • Structural estimation: first-order conditions of agents’ problems
  • Break-even analysis, Ramsey steady states, implied volatilities…

Every estimator defined by a first-order condition — GMM, MLE, IV — is a root-finding problem in disguise:

\[\nabla_\theta \ell(\theta) = 0 \qquad \text{(Part IV solves exactly this)}\]

Method Needs Convergence rate Guarantees
Bisection sign change on \([a,b]\) linear (1 bit / step) always converges
Newton–Raphson \(f'(x)\) quadratic (digits double) may diverge
Secant two starting points superlinear (\(\approx 1.62\)) may diverge
Brent (uniroot, brentq) sign change on \([a,b]\) superlinear always converges — the default choice

Practical rule: if you can bracket the root, use Brent. Use pure Newton only when you can supply a reliable derivative and a good start.

Root-Finding — The Mathematics

Bisection — halve a sign-changing interval; error after \(k\) steps:

\[|x_k - x^*| \le \frac{b - a}{2^k}\]

Newton–Raphson — follow the tangent line to its zero:

\[x_{k+1} = x_k - \frac{f(x_k)}{f'(x_k)}\]

Quadratic convergence near a simple root — the number of correct digits doubles each step:

\[|x_{k+1} - x^*| \le C\,|x_k - x^*|^2\]

But: divergence when \(f'(x_k) \approx 0\), cycling, or capture by the wrong root when started far away.

Secant — replace the derivative by a finite-difference slope through the last two iterates:

\[x_{k+1} = x_k - f(x_k)\,\frac{x_k - x_{k-1}}{f(x_k) - f(x_{k-1})}\]

Stopping rules — always combine both, with tolerances well above \(\varepsilon_{\text{mach}}\) (Part I):

\[|f(x_k)| < \tau_f \qquad \text{and} \qquad |x_{k+1} - x_k| < \tau_x\,(1 + |x_k|)\]

Internal Rate of Return — Code

An investment costs 1000 today and pays \((300, 350, 400, 450)\) over four years. The IRR solves

\[\text{NPV}(r) = -1000 + \sum_{t=1}^{4} \frac{CF_t}{(1+r)^t} = 0\]

Code
cf <- c(-1000, 300, 350, 400, 450)
npv <- function(r) sum(cf / (1 + r)^(0:4))

# Newton-Raphson from scratch, with the analytic derivative
npv_prime <- function(r) sum(-(0:4) * cf / (1 + r)^(1:5))
r_k <- 0.10                                     # starting guess
cat("Newton iterations:\n")
for (k in 1:8) {
  step <- npv(r_k) / npv_prime(r_k)
  r_k  <- r_k - step
  cat(sprintf("  k=%d  r=%.12f  |step|=%.2e\n", k, r_k, abs(step)))
  if (abs(step) < 1e-12) break                  # digits double each step
}

# Brent's method via uniroot: bracket, then trust the library
sol <- uniroot(npv, interval = c(0, 1), tol = 1e-12)
cat(sprintf("\nuniroot (Brent) : IRR = %.8f  (%.4f%%)\n", sol$root, 100 * sol$root))
cat(sprintf("check NPV(IRR)  = %.2e\n", npv(sol$root)))
Code
import numpy as np
from scipy.optimize import brentq, newton

cf = np.array([-1000, 300, 350, 400, 450], dtype=float)
t  = np.arange(5)

def npv(r):       return np.sum(cf / (1 + r)**t)
def npv_prime(r): return np.sum(-t * cf / (1 + r)**(t + 1))

# Newton-Raphson from scratch
r_k = 0.10
print("Newton iterations:")
for k in range(1, 9):
    step = npv(r_k) / npv_prime(r_k)
    r_k -= step
    print(f"  k={k}  r={r_k:.12f}  |step|={abs(step):.2e}")
    if abs(step) < 1e-12:
        break

# Library versions: brentq needs a bracket, newton needs a start
r_brent  = brentq(npv, 0.0, 1.0, xtol=1e-12)
r_newton = newton(npv, x0=0.10, fprime=npv_prime, tol=1e-12)
print(f"\nbrentq : IRR = {r_brent:.8f}  ({100*r_brent:.4f}%)")
print(f"newton : IRR = {r_newton:.8f}")
print(f"check NPV(IRR) = {npv(r_brent):.2e}")

Nonlinear Systems — Newton–Raphson in \(\mathbb{R}^n\)

For a system \(\mathbf{F}(\mathbf{x}) = \mathbf{0}\) with \(\mathbf{F}: \mathbb{R}^n \to \mathbb{R}^n\), Newton generalises via the Jacobian:

\[\mathbf{J}(\mathbf{x}_k)\,\mathbf{d}_k = -\mathbf{F}(\mathbf{x}_k), \qquad \mathbf{x}_{k+1} = \mathbf{x}_k + \mathbf{d}_k\]

Each iteration solves a linear system (Part II: factorise, never invert). Quasi-Newton (Broyden) replaces \(\mathbf{J}\) with a cheap rank-one update when derivatives are expensive.

Economic application — equilibrium in two interdependent markets. Demand and supply for goods 1 and 2 (substitutes, nonlinear in prices):

\[ \begin{aligned} \text{excess demand}_1 &= 40 - 2p_1 + 1.2\,p_2 - \left(3\sqrt{p_1} + 2\right) = 0\\ \text{excess demand}_2 &= 30 + 0.8\,p_1 - 1.5\,p_2 - \left(2\sqrt{p_2} + 5\right) = 0 \end{aligned} \]

No closed form — but Newton finds \((p_1^*, p_2^*)\) in a handful of iterations.

Market Equilibrium System — Code

Code
library(nleqslv)

# Excess demand system for two substitute goods
excess_demand <- function(p) {
  c(40 - 2.0 * p[1] + 1.2 * p[2] - (3 * sqrt(p[1]) + 2),
    30 + 0.8 * p[1] - 1.5 * p[2] - (2 * sqrt(p[2]) + 5))
}

# Newton with analytic Jacobian (2x2)
jac <- function(p) {
  matrix(c(-2.0 - 1.5 / sqrt(p[1]),  1.2,
            0.8,                    -1.5 - 1 / sqrt(p[2])),
         nrow = 2, byrow = TRUE)
}

sol <- nleqslv(x = c(10, 10), fn = excess_demand, jac = jac, method = "Newton")
cat(sprintf("equilibrium prices : p1* = %.6f, p2* = %.6f\n", sol$x[1], sol$x[2]))
cat(sprintf("excess demands     : %.2e, %.2e\n", sol$fvec[1], sol$fvec[2]))
cat(sprintf("Newton iterations  : %d\n\n", sol$iter))

# Verify quantities are equal in each market at p*
p <- sol$x
cat(sprintf("market 1: D = %.4f, S = %.4f\n",
            40 - 2 * p[1] + 1.2 * p[2], 3 * sqrt(p[1]) + 2))
cat(sprintf("market 2: D = %.4f, S = %.4f\n",
            30 + 0.8 * p[1] - 1.5 * p[2], 2 * sqrt(p[2]) + 5))

# Broyden (derivative-free) from the same start: a few more iterations
sol_b <- nleqslv(x = c(10, 10), fn = excess_demand, method = "Broyden")
cat(sprintf("\nBroyden: same root (%.6f, %.6f) in %d iterations\n",
            sol_b$x[1], sol_b$x[2], sol_b$iter))
Code
import numpy as np
from scipy.optimize import root

def excess_demand(p):
    return [40 - 2.0 * p[0] + 1.2 * p[1] - (3 * np.sqrt(p[0]) + 2),
            30 + 0.8 * p[0] - 1.5 * p[1] - (2 * np.sqrt(p[1]) + 5)]

def jac(p):
    return np.array([[-2.0 - 1.5 / np.sqrt(p[0]),  1.2],
                     [ 0.8,                       -1.5 - 1 / np.sqrt(p[1])]])

# Newton via scipy.optimize.root (method 'hybr' = MINPACK's Powell hybrid)
sol = root(excess_demand, x0=[10, 10], jac=jac, method="hybr")
p = sol.x
print(f"equilibrium prices : p1* = {p[0]:.6f}, p2* = {p[1]:.6f}")
print(f"excess demands     : {sol.fun[0]:.2e}, {sol.fun[1]:.2e}")
print(f"converged          : {sol.success}\n")

# Verify quantities are equal in each market at p*
print(f"market 1: D = {40 - 2*p[0] + 1.2*p[1]:.4f}, S = {3*np.sqrt(p[0]) + 2:.4f}")
print(f"market 2: D = {30 + 0.8*p[0] - 1.5*p[1]:.4f}, S = {2*np.sqrt(p[1]) + 5:.4f}")

# Broyden (derivative-free quasi-Newton)
sol_b = root(excess_demand, x0=[10, 10], method="broyden1", tol=1e-10)
print(f"\nBroyden: same root ({sol_b.x[0]:.6f}, {sol_b.x[1]:.6f})")

Root-Finding Pitfalls

  • Bracket first when possible — Brent (uniroot / brentq) is guaranteed and nearly as fast as Newton
  • Plot \(f\) before solving — see how many roots exist and where
  • Scale variables so the solution is \(O(1)\) — tolerances are absolute numbers
  • Check the residual \(|f(x^*)|\) and economic sense of the root (an IRR of \(-180\%\) solves the equation too)
  • Try several starting points when the equation may have multiple roots
  • Multiple IRRs: cash flows with more than one sign change can have several real roots (Descartes’ rule) — report all of them or use NPV directly
  • Newton divergence: near-flat \(f\) (small \(|f'|\)) sends the iterate to infinity
  • Wrong root capture: Newton converges to whichever root the basin of the start point belongs to
  • Tolerances below machine precision: asking tol = 1e-20 in doubles is asking for an infinite loop — solvers clamp it, but know why (Part I)
  • Reporting “converged” without checking sol$termcd / sol.success — solvers do fail silently in pipelines

Part IV — Optimization

From Gradient Descent to BFGS — Maximum Likelihood from Scratch

Optimization — The Workhorse of Estimation

The optimisers and ODE solvers built here are tools without an application. The companion deck Numerical Dynamic Programming and Heterogeneous Agents is where they get used on a real economic problem — value-function iteration, policy functions, and the stationary distributions of heterogeneous-agent models.

Almost every estimator beyond OLS is defined as an optimum:

\[\hat{\theta} = \arg\min_{\theta \in \Theta} Q_n(\theta)\]

  • MLE: \(Q_n = -\ell_n(\theta)\) — logit, probit, tobit, GARCH, mixed logit
  • GMM: \(Q_n = \bar{g}_n(\theta)^\top \mathbf{W} \bar{g}_n(\theta)\)
  • NLS: \(Q_n = \sum_i (y_i - f(x_i, \theta))^2\)
  • Structural models: nested fixed points, dynamic programming

When optim() or scipy.minimize reports “convergence”, it has satisfied a numerical criterion — not found the truth. Understanding the algorithms tells you when to trust it.

Method Uses Cost per step Convergence Library
Gradient descent \(\nabla Q\) cheap linear, rate depends on step size (by hand, torch)
Newton–Raphson \(\nabla Q\), \(\mathbf{H}\) \(O(p^3)\) solve quadratic glm() (Fisher scoring)
BFGS (quasi-Newton) \(\nabla Q\) only \(O(p^2)\) superlinear optim(method="BFGS"), scipy
Nelder–Mead \(Q\) only cheap slow, no rate optim() default, scipy
L-BFGS-B \(\nabla Q\), bounds \(O(mp)\) superlinear both — large \(p\) default

Rule: derivatives available → BFGS/Newton. Noisy or non-smooth objective → Nelder–Mead. Thousands of parameters → L-BFGS or gradient methods (Part VIII).

Optimization — The Mathematics

Gradient descent — step against the gradient with learning rate \(\gamma\):

\[\theta_{k+1} = \theta_k - \gamma\,\nabla Q(\theta_k)\]

Converges linearly for convex \(Q\) if \(\gamma < 2/L\) (\(L\) = largest Hessian eigenvalue). Too large \(\gamma\) diverges; too small crawls. The convergence rate is governed by the Hessian’s condition number \(\kappa\) (Part II again):

\[\|\theta_k - \theta^*\| \le \left(\frac{\kappa - 1}{\kappa + 1}\right)^{k}\|\theta_0 - \theta^*\|\]

Newton–Raphson — use curvature; each step solves a linear system:

\[\mathbf{H}(\theta_k)\,\mathbf{d}_k = -\nabla Q(\theta_k), \qquad \theta_{k+1} = \theta_k + \mathbf{d}_k\]

BFGS — build up an approximation \(\mathbf{B}_k \approx \mathbf{H}\) from gradient differences only:

\[\mathbf{B}_{k+1} = \mathbf{B}_k + \frac{\mathbf{y}_k\mathbf{y}_k^\top}{\mathbf{y}_k^\top\mathbf{s}_k} - \frac{\mathbf{B}_k\mathbf{s}_k\mathbf{s}_k^\top\mathbf{B}_k}{\mathbf{s}_k^\top\mathbf{B}_k\mathbf{s}_k}, \qquad \mathbf{s}_k = \theta_{k+1}-\theta_k, \quad \mathbf{y}_k = \nabla Q_{k+1}-\nabla Q_k\]

For MLE, the payoff at the optimum: the inverse Hessian is the covariance of the estimates:

\[\widehat{\text{Var}}(\hat\theta) = \left[-\mathbf{H}(\hat\theta)\right]^{-1} = \left[\sum_i \nabla^2_\theta \ell_i(\hat\theta)\right]^{-1}\]

Logit MLE from Scratch — Newton–Raphson

Logit log-likelihood, gradient, and Hessian (\(\mu_i = \Lambda(\mathbf{x}_i^\top\boldsymbol\beta)\), numerically stable via Part I’s tricks):

\[\ell(\boldsymbol\beta) = \sum_i \left[y_i\,\mathbf{x}_i^\top\boldsymbol\beta - \log\!\left(1 + e^{\mathbf{x}_i^\top\boldsymbol\beta}\right)\right], \qquad \nabla\ell = \mathbf{X}^\top(\mathbf{y} - \boldsymbol\mu), \qquad \mathbf{H} = -\mathbf{X}^\top \mathbf{W} \mathbf{X}\]

with \(\mathbf{W} = \text{diag}\{\mu_i(1-\mu_i)\}\). Simulated data (\(n = 5000\), true \(\boldsymbol\beta = (-0.5, 1.2, -0.8, 0.6)\)) shared by both languages via ../data/numerical-logit.csv.

Code
dat <- read.csv("../data/numerical-logit.csv")
X <- cbind(1, as.matrix(dat[, c("x1", "x2", "x3")]))
y <- dat$y

# Stable log(1 + exp(eta)) — avoids overflow for large |eta| (Part I)
log1pexp <- function(eta) pmax(eta, 0) + log1p(exp(-abs(eta)))
loglik <- function(b) sum(y * (X %*% b) - log1pexp(X %*% b))

# Newton-Raphson: each step solves H d = -grad (Part II: solve, never invert)
b <- rep(0, 4)
cat("Newton-Raphson iterations:\n")
for (k in 1:25) {
  mu   <- plogis(as.numeric(X %*% b))
  grad <- crossprod(X, y - mu)
  H    <- -crossprod(X * (mu * (1 - mu)), X)
  d    <- solve(-H, grad)                       # ascent direction
  b    <- b + as.numeric(d)
  cat(sprintf("  k=%d  loglik=%.6f  |grad|=%.2e\n",
              k, loglik(b), max(abs(grad))))
  if (max(abs(d)) < 1e-10) break
}

# Standard errors from the inverse negative Hessian at the optimum
mu <- plogis(as.numeric(X %*% b))
H  <- -crossprod(X * (mu * (1 - mu)), X)
se <- sqrt(diag(solve(-H)))

# Compare with glm() — same algorithm (IRLS = Fisher scoring)
fit <- glm(y ~ x1 + x2 + x3, data = dat, family = binomial)
tab <- data.frame(true    = c(-0.5, 1.2, -0.8, 0.6),
                  newton  = round(b, 6),
                  glm     = round(as.numeric(coef(fit)), 6),
                  se_newton = round(se, 6),
                  se_glm  = round(as.numeric(summary(fit)$coef[, 2]), 6))
rownames(tab) <- c("(Intercept)", "x1", "x2", "x3")
print(tab)
Code
import numpy as np
import pandas as pd
import statsmodels.api as sm

dat = pd.read_csv("../data/numerical-logit.csv")   # same CSV as R
X = np.column_stack([np.ones(len(dat)), dat[["x1", "x2", "x3"]].to_numpy()])
y = dat["y"].to_numpy(dtype=float)

def loglik(b):
    eta = X @ b
    return np.sum(y * eta - np.logaddexp(0.0, eta))   # stable (Part I)

# Newton-Raphson: solve H d = -grad each step (Part II)
b = np.zeros(4)
print("Newton-Raphson iterations:")
for k in range(1, 26):
    mu   = 1 / (1 + np.exp(-X @ b))
    grad = X.T @ (y - mu)
    H    = -(X * (mu * (1 - mu))[:, None]).T @ X
    d    = np.linalg.solve(-H, grad)
    b   += d
    print(f"  k={k}  loglik={loglik(b):.6f}  |grad|={np.max(np.abs(grad)):.2e}")
    if np.max(np.abs(d)) < 1e-10:
        break

# Standard errors from the inverse negative Hessian
mu = 1 / (1 + np.exp(-X @ b))
H  = -(X * (mu * (1 - mu))[:, None]).T @ X
se = np.sqrt(np.diag(np.linalg.inv(-H)))

# Compare with statsmodels Logit
fit = sm.Logit(y, X).fit(disp=0)
true_b = [-0.5, 1.2, -0.8, 0.6]
names  = ["const", "x1", "x2", "x3"]
print(f"\n{'param':<8} {'true':>7} {'newton':>10} {'sm.Logit':>10} {'se_newton':>10} {'se_sm':>8}")
for nm, tb, bb, sb, fb, fs in zip(names, true_b, b, se, fit.params, fit.bse):
    print(f"{nm:<8} {tb:7.2f} {bb:10.6f} {fb:10.6f} {sb:10.6f} {fs:8.6f}")

The Same MLE with Library Optimizers — BFGS & Nelder–Mead

Real projects hand the objective to a general optimizer. Two things matter: supply the gradient when you can, and know that different algorithms take very different iteration counts to the same optimum.

Code
dat <- read.csv("../data/numerical-logit.csv")
X <- cbind(1, as.matrix(dat[, c("x1", "x2", "x3")]))
y <- dat$y

log1pexp <- function(eta) pmax(eta, 0) + log1p(exp(-abs(eta)))
negll  <- function(b) -sum(y * (X %*% b) - log1pexp(X %*% b))
neggr  <- function(b) -as.numeric(crossprod(X, y - plogis(as.numeric(X %*% b))))

# Before optimising: verify the analytic gradient against numDeriv
b_test <- c(0.1, -0.2, 0.3, 0.05)
cat("gradient check |analytic - numerical| :",
    format(max(abs(neggr(b_test) - numDeriv::grad(negll, b_test))), digits = 3), "\n\n")

# BFGS with analytic gradient
fit_bfgs <- optim(rep(0, 4), fn = negll, gr = neggr, method = "BFGS",
                  control = list(maxit = 500), hessian = TRUE)
# Nelder-Mead: derivative-free, many more function evaluations
fit_nm   <- optim(rep(0, 4), fn = negll, method = "Nelder-Mead",
                  control = list(maxit = 5000))

cat(sprintf("BFGS        : negll = %.6f, evals(fn, gr) = %d, %d, conv = %d\n",
            fit_bfgs$value, fit_bfgs$counts[1], fit_bfgs$counts[2],
            fit_bfgs$convergence))
cat(sprintf("Nelder-Mead : negll = %.6f, evals(fn)     = %d,      conv = %d\n\n",
            fit_nm$value, fit_nm$counts[1], fit_nm$convergence))

# SEs from optim's numerical Hessian vs glm
se_opt <- sqrt(diag(solve(fit_bfgs$hessian)))
fit_glm <- glm(y ~ x1 + x2 + x3, data = dat, family = binomial)
print(round(data.frame(bfgs  = fit_bfgs$par,
                       nm    = fit_nm$par,
                       glm   = as.numeric(coef(fit_glm)),
                       se_bfgs = se_opt,
                       se_glm  = as.numeric(summary(fit_glm)$coef[, 2])), 5))
Code
import numpy as np
import pandas as pd
from scipy.optimize import minimize, check_grad
import statsmodels.api as sm

dat = pd.read_csv("../data/numerical-logit.csv")
X = np.column_stack([np.ones(len(dat)), dat[["x1", "x2", "x3"]].to_numpy()])
y = dat["y"].to_numpy(dtype=float)

def negll(b):
    eta = X @ b
    return -np.sum(y * eta - np.logaddexp(0.0, eta))

def neggr(b):
    return -(X.T @ (y - 1 / (1 + np.exp(-X @ b))))

# Verify the analytic gradient before trusting any optimizer output
print("gradient check (scipy.check_grad):",
      f"{check_grad(negll, neggr, np.array([0.1, -0.2, 0.3, 0.05])):.3e}\n")

fit_bfgs = minimize(negll, np.zeros(4), jac=neggr, method="BFGS")
fit_nm   = minimize(negll, np.zeros(4), method="Nelder-Mead",
                    options={"maxiter": 5000, "xatol": 1e-8, "fatol": 1e-8})

print(f"BFGS        : negll = {fit_bfgs.fun:.6f}, n_fev = {fit_bfgs.nfev}, "
      f"n_jev = {fit_bfgs.njev}, success = {fit_bfgs.success}")
print(f"Nelder-Mead : negll = {fit_nm.fun:.6f}, n_fev = {fit_nm.nfev}, "
      f"success = {fit_nm.success}\n")

# SEs from BFGS's inverse-Hessian approximation vs statsmodels
se_bfgs = np.sqrt(np.diag(fit_bfgs.hess_inv))
fit_sm  = sm.Logit(y, X).fit(disp=0)
print(f"{'param':<7} {'bfgs':>10} {'nelder':>10} {'sm':>10} {'se_bfgs':>9} {'se_sm':>9}")
for i, nm in enumerate(["const", "x1", "x2", "x3"]):
    print(f"{nm:<7} {fit_bfgs.x[i]:10.5f} {fit_nm.x[i]:10.5f} "
          f"{fit_sm.params[i]:10.5f} {se_bfgs[i]:9.5f} {fit_sm.bse[i]:9.5f}")

Gradient Descent — Learning Rate Is Everything

Minimise the OLS loss \(Q(\boldsymbol\beta) = \frac{1}{2n}\|\mathbf{y}-\mathbf{X}\boldsymbol\beta\|^2\) by gradient descent — the algorithm behind all of deep learning (and Part VIII’s torch code). Convergence hinges on the step size \(\gamma\) relative to the curvature \(L = \sigma_{\max}^2/n\).

Code
set.seed(14159)
n <- 500
X <- cbind(1, rnorm(n), rnorm(n))
beta_true <- c(1, 2, -1.5)
y <- as.numeric(X %*% beta_true + rnorm(n))

L <- max(eigen(crossprod(X) / n)$values)   # Lipschitz constant of the gradient
gd <- function(gamma, iters = 60) {
  b <- rep(0, 3); path <- numeric(iters)
  for (k in seq_len(iters)) {
    b <- b - gamma * as.numeric(crossprod(X, X %*% b - y)) / n
    path[k] <- 0.5 * mean((y - X %*% b)^2)
  }
  path
}

rates <- c(0.05, 0.5, 1.9, 2.1) / L        # fractions of the stability limit 2/L
labs  <- c("0.05/L (crawls)", "0.5/L (good)", "1.9/L (fast)", "2.1/L (diverges!)")
dd <- data.frame(iter  = rep(1:60, 4),
                 loss  = unlist(lapply(rates, gd)),
                 gamma = factor(rep(labs, each = 60), levels = labs))
cat(sprintf("stability limit: gamma < 2/L = %.4f\n", 2 / L))

ggplot(dd, aes(iter, pmin(loss, 1e3), colour = gamma)) +
  geom_line(linewidth = 1.2) +
  scale_y_log10() +
  scale_colour_manual(values = c("#BA7517", "#185FA5", "#1D9E75", "#C0132C")) +
  labs(x = "iteration", y = "loss (log scale, capped)",
       title = "Gradient descent on OLS loss: four learning rates",
       colour = "step size")
Code
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(14159)
n = 500
X = np.column_stack([np.ones(n), rng.standard_normal(n), rng.standard_normal(n)])
beta_true = np.array([1.0, 2.0, -1.5])
y = X @ beta_true + rng.standard_normal(n)

L = np.linalg.eigvalsh(X.T @ X / n).max()
print(f"stability limit: gamma < 2/L = {2/L:.4f}")

def gd(gamma, iters=60):
    b = np.zeros(3); path = []
    for _ in range(iters):
        b -= gamma * X.T @ (X @ b - y) / n
        path.append(0.5 * np.mean((y - X @ b)**2))
    return np.array(path)

rates = [0.05 / L, 0.5 / L, 1.9 / L, 2.1 / L]
labs  = ["0.05/L (crawls)", "0.5/L (good)", "1.9/L (fast)", "2.1/L (diverges!)"]
cols  = ["#BA7517", "#185FA5", "#1D9E75", "#C0132C"]

fig, ax = plt.subplots(figsize=(9, 4.5))
for g, lab, c in zip(rates, labs, cols):
    ax.plot(np.minimum(gd(g), 1e3), label=lab, color=c, lw=2)
ax.set_yscale("log")
ax.set_xlabel("iteration"); ax.set_ylabel("loss (log scale, capped)")
ax.set_title("Gradient descent on OLS loss: four learning rates")
ax.legend(); plt.tight_layout(); plt.show()

Part V — Numerical Differentiation & Integration

Finite Differences, Quadrature, and Monte Carlo

Numerical Differentiation — The Step-Size Dilemma

Forward and central finite differences approximate derivatives with truncation error that shrinks with \(h\):

\[f'(x) \approx \frac{f(x+h) - f(x)}{h} + O(h), \qquad f'(x) \approx \frac{f(x+h) - f(x-h)}{2h} + O(h^2)\]

But shrinking \(h\) revives Part I: \(f(x+h) - f(x)\) is a subtraction of nearly equal numbers — rounding error grows as \(\varepsilon_{\text{mach}}/h\). Total error is U-shaped, with the optimum at

\[h^*_{\text{forward}} \approx \sqrt{\varepsilon_{\text{mach}}} \approx 10^{-8}, \qquad h^*_{\text{central}} \approx \varepsilon_{\text{mach}}^{1/3} \approx 6\times10^{-6}\]

Truncation error and rounding error pull in opposite directions — you can never have both. This is why numDeriv uses Richardson extrapolation and why marginal effects, delta-method SEs, and numerical Hessians are all sensitive to step size.

Code
# d/dx exp(x) at x = 1: true value is e
f <- exp; x0 <- 1; true_d <- exp(1)

h <- 10^seq(-1, -15, by = -0.25)
err_fwd <- abs((f(x0 + h) - f(x0)) / h - true_d) / true_d
err_ctr <- abs((f(x0 + h) - f(x0 - h)) / (2 * h) - true_d) / true_d

dd <- data.frame(h = rep(h, 2), err = c(err_fwd, err_ctr),
                 scheme = rep(c("forward O(h)", "central O(h^2)"), each = length(h)))
ggplot(dd, aes(h, err, colour = scheme)) +
  geom_line(linewidth = 1.1) + geom_point(size = 1.6) +
  scale_x_log10() + scale_y_log10() +
  scale_colour_manual(values = c("#185FA5", "#C0132C")) +
  geom_vline(xintercept = c(sqrt(.Machine$double.eps),
                            .Machine$double.eps^(1/3)),
             linetype = "dashed", colour = "grey55") +
  labs(x = "step size h (log)", y = "relative error (log)",
       title = "The U-curve: truncation error vs rounding error",
       colour = NULL)
Code
# numDeriv gets ~13-14 digits via Richardson extrapolation
cat(sprintf("naive best (central, h = 6e-6): rel. err = %.2e\n",
            min(abs((f(x0 + h) - f(x0 - h)) / (2 * h) - true_d) / true_d)))
cat(sprintf("numDeriv::grad                : rel. err = %.2e\n",
            abs(numDeriv::grad(f, x0) - true_d) / true_d))
Code
import numpy as np
import matplotlib.pyplot as plt
from scipy.differentiate import derivative

f = np.exp; x0 = 1.0; true_d = np.e

h = 10.0 ** np.arange(-1, -15.25, -0.25)
err_fwd = np.abs((f(x0 + h) - f(x0)) / h - true_d) / true_d
err_ctr = np.abs((f(x0 + h) - f(x0 - h)) / (2 * h) - true_d) / true_d

fig, ax = plt.subplots(figsize=(9, 4.2))
ax.loglog(h, err_fwd, "o-", ms=3, color="#185FA5", label="forward O(h)")
ax.loglog(h, err_ctr, "o-", ms=3, color="#C0132C", label="central O(h$^2$)")
eps = np.finfo(float).eps
ax.axvline(np.sqrt(eps), ls="--", color="grey")
ax.axvline(eps ** (1/3), ls="--", color="grey")
ax.set_xlabel("step size h (log)"); ax.set_ylabel("relative error (log)")
ax.set_title("The U-curve: truncation error vs rounding error")
ax.legend(); plt.tight_layout(); plt.show()

# scipy's adaptive derivative: ~13-14 digits
res = derivative(f, x0)
print(f"naive best (central)      : rel. err = {err_ctr.min():.2e}")
print(f"scipy derivative (adaptive): rel. err = {abs(res.df - true_d)/true_d:.2e}")

Numerical Integration — Quadrature & Monte Carlo

Consumer surplus under demand \(q(p) = 100\,p^{-1.5}\) between the market price \(p_0 = 4\) and the choke region:

\[CS = \int_{4}^{\infty} 100\,p^{-1.5}\,dp = \left[-200\,p^{-0.5}\right]_4^\infty = 100\]

Three routes: adaptive quadrature (fast, ~machine precision for smooth 1-D integrands), fixed-node Simpson’s rule (\(O(h^4)\)), and Monte Carlo (slow \(O(1/\sqrt{S})\) — but dimension-free, which is why simulated MLE and mixed logit use it).

Code
demand <- function(p) 100 * p^(-1.5)

# 1. Adaptive quadrature: handles the infinite limit directly
q_adapt <- integrate(demand, lower = 4, upper = Inf)
cat(sprintf("integrate()      : CS = %.10f  (abs.error < %.1e)\n",
            q_adapt$value, q_adapt$abs.error))

# 2. Simpson's rule on [4, 4000] with n = 1000 intervals
simpson <- function(f, a, b, n = 1000) {
  h <- (b - a) / n
  x <- seq(a, b, length.out = n + 1)
  h / 3 * sum(f(x) * c(1, rep(c(4, 2), (n - 2) / 2), 4, 1))
}
cat(sprintf("Simpson [4,4000] : CS = %.10f\n", simpson(demand, 4, 4000)))

# 3. Monte Carlo with importance sampling: p = 4/U^2, U ~ Uniform(0,1)
set.seed(14159)
for (S in c(1e3, 1e5, 1e7)) {
  u  <- runif(S)
  p  <- 4 / u^2                    # maps (0,1) onto (4, Inf)
  w  <- 8 / u^3                    # Jacobian |dp/du|
  cs <- mean(demand(p) * w)
  cat(sprintf("Monte Carlo S=%.0e: CS = %.6f  (error %.1e)\n",
              S, cs, abs(cs - 100)))
}
cat("\ntrue value: 100 — MC error shrinks at rate 1/sqrt(S)\n")
Code
import numpy as np
from scipy.integrate import quad, simpson

demand = lambda p: 100 * p ** (-1.5)

# 1. Adaptive quadrature: handles the infinite limit directly
val, err = quad(demand, 4, np.inf)
print(f"scipy quad       : CS = {val:.10f}  (abs.error < {err:.1e})")

# 2. Simpson's rule on [4, 4000] with 1001 nodes
x = np.linspace(4, 4000, 1001)
print(f"Simpson [4,4000] : CS = {simpson(demand(x), x=x):.10f}")

# 3. Monte Carlo with importance sampling: p = 4/U^2
rng = np.random.default_rng(14159)
for S in [10**3, 10**5, 10**7]:
    u = rng.uniform(size=S)
    p = 4 / u**2
    w = 8 / u**3
    cs = np.mean(demand(p) * w)
    print(f"Monte Carlo S=1e{int(np.log10(S))}: CS = {cs:.6f}  (error {abs(cs-100):.1e})")

print("\ntrue value: 100 — MC error shrinks at rate 1/sqrt(S)")

Part VI — Differential Equations

Euler, Runge–Kutta, and the Solow Growth Model

ODEs in Economics — And How Computers Solve Them

Continuous-time dynamics are everywhere in macro and finance:

  • Solow–Swan growth: capital accumulation \(\dot{k} = s f(k) - (n + \delta)k\)
  • Ramsey–Cass–Koopmans: coupled system in \((k, c)\) with a saddle path
  • Interest-rate models: Vasicek, CIR — SDEs discretised by the same schemes
  • Epidemic-economics, resource extraction, advertising competition

Only toy versions have closed forms. Numerical time-stepping is the general tool: start at \(y_0\), advance step by step with step size \(h\).

Euler’s method — follow the tangent (the “gradient descent” of ODEs):

\[y_{t+h} = y_t + h\,f(t, y_t), \qquad \text{global error } O(h)\]

Classical Runge–Kutta 4 — average four slope evaluations per step:

\[y_{t+h} = y_t + \frac{h}{6}\left(k_1 + 2k_2 + 2k_3 + k_4\right), \qquad \text{global error } O(h^4)\]

\[k_1 = f(t, y_t), \quad k_2 = f\!\left(t+\tfrac{h}{2},\, y_t+\tfrac{h}{2}k_1\right), \quad k_3 = f\!\left(t+\tfrac{h}{2},\, y_t+\tfrac{h}{2}k_2\right), \quad k_4 = f(t+h,\, y_t+h\,k_3)\]

Halving \(h\) cuts Euler’s error by 2 but RK4’s by 16. Production solvers (deSolve::ode, solve_ivp) add adaptive step size and stiffness detection (LSODA switches methods automatically).

The Solow Growth Model — Setup

Capital per effective worker \(k(t)\) with Cobb–Douglas production \(f(k) = k^\alpha\):

\[\dot{k} = s\,k^\alpha - (n + g + \delta)\,k, \qquad k(0) = k_0\]

Parameters: \(s = 0.25\), \(\alpha = 0.36\), \(n + g + \delta = 0.1\), \(k_0 = 1\).

The steady state has a closed form — our accuracy benchmark:

\[k^* = \left(\frac{s}{n + g + \delta}\right)^{\frac{1}{1-\alpha}} = 2.5^{1/0.64} \approx 4.1902\]

The exact transition path is also known (a Bernoulli ODE — rare luxury), so we can measure each scheme’s error exactly:

\[k(t)^{1-\alpha} = \frac{s}{n+g+\delta} + \left(k_0^{1-\alpha} - \frac{s}{n+g+\delta}\right)e^{-(1-\alpha)(n+g+\delta)t}\]

Plan: solve on \(t \in [0, 100]\) with hand-rolled Euler and RK4 at a coarse step \(h = 5\), plus the adaptive library solver; compare against the exact path.

Solow Model — Euler vs RK4 vs Library Solver

Code
s <- 0.25; alpha <- 0.36; ngd <- 0.1; k0 <- 1
f <- function(t, k) s * k^alpha - ngd * k

# Exact solution (Bernoulli ODE) for the error benchmark
k_exact <- function(t) {
  A <- s / ngd
  (A + (k0^(1 - alpha) - A) * exp(-(1 - alpha) * ngd * t))^(1 / (1 - alpha))
}

# Euler and RK4 with the SAME coarse step h = 5
h <- 5; times <- seq(0, 100, by = h); m <- length(times)
k_euler <- k_rk4 <- numeric(m); k_euler[1] <- k_rk4[1] <- k0
for (i in 1:(m - 1)) {
  t <- times[i]
  # Euler: one slope
  k_euler[i + 1] <- k_euler[i] + h * f(t, k_euler[i])
  # RK4: four slopes, weighted average
  k1 <- f(t,         k_rk4[i])
  k2 <- f(t + h / 2, k_rk4[i] + h / 2 * k1)
  k3 <- f(t + h / 2, k_rk4[i] + h / 2 * k2)
  k4 <- f(t + h,     k_rk4[i] + h * k3)
  k_rk4[i + 1] <- k_rk4[i] + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
}

# Library solver: adaptive LSODA via deSolve
sol <- deSolve::ode(y = c(k = k0), times = times,
                    func = function(t, y, p) list(f(t, y)), parms = NULL)

cat(sprintf("steady state k* (closed form)  : %.6f\n", (s / ngd)^(1 / (1 - alpha))))
cat(sprintf("max |error| Euler  (h = 5)     : %.2e\n", max(abs(k_euler - k_exact(times)))))
cat(sprintf("max |error| RK4    (h = 5)     : %.2e\n", max(abs(k_rk4  - k_exact(times)))))
cat(sprintf("max |error| deSolve lsoda      : %.2e\n", max(abs(sol[, "k"] - k_exact(times)))))

dd <- data.frame(t = rep(times, 3),
                 k = c(k_euler, k_rk4, k_exact(times)),
                 method = rep(c("Euler h=5", "RK4 h=5", "exact"), each = m))
ggplot(dd, aes(t, k, colour = method, linetype = method)) +
  geom_line(linewidth = 1.1) +
  geom_hline(yintercept = (s / ngd)^(1 / (1 - alpha)),
             linetype = "dotted", colour = "grey55") +
  scale_colour_manual(values = c("#1D9E75", "#C0132C", "#185FA5")) +
  annotate("text", x = 90, y = 4.35, label = "k*", colour = "grey40", size = 5) +
  labs(x = "time", y = "capital per effective worker k(t)",
       title = "Solow transition path: Euler visibly off at coarse steps, RK4 on top of exact",
       colour = NULL, linetype = NULL)
Code
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

s, alpha, ngd, k0 = 0.25, 0.36, 0.1, 1.0
f = lambda t, k: s * k**alpha - ngd * k

def k_exact(t):
    A = s / ngd
    return (A + (k0**(1 - alpha) - A) * np.exp(-(1 - alpha) * ngd * t))**(1 / (1 - alpha))

# Euler and RK4 with the SAME coarse step h = 5
h = 5.0
times = np.arange(0, 100 + h, h)
m = len(times)
k_euler = np.empty(m); k_rk4 = np.empty(m)
k_euler[0] = k_rk4[0] = k0
for i in range(m - 1):
    t = times[i]
    k_euler[i + 1] = k_euler[i] + h * f(t, k_euler[i])
    k1 = f(t,         k_rk4[i])
    k2 = f(t + h / 2, k_rk4[i] + h / 2 * k1)
    k3 = f(t + h / 2, k_rk4[i] + h / 2 * k2)
    k4 = f(t + h,     k_rk4[i] + h * k3)
    k_rk4[i + 1] = k_rk4[i] + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)

# Library solver: adaptive RK45 via solve_ivp
sol = solve_ivp(f, [0, 100], [k0], t_eval=times, rtol=1e-8, atol=1e-10)

print(f"steady state k* (closed form): {(s/ngd)**(1/(1-alpha)):.6f}")
print(f"max |error| Euler (h = 5)    : {np.max(np.abs(k_euler - k_exact(times))):.2e}")
print(f"max |error| RK4   (h = 5)    : {np.max(np.abs(k_rk4  - k_exact(times))):.2e}")
print(f"max |error| solve_ivp RK45   : {np.max(np.abs(sol.y[0] - k_exact(times))):.2e}")

fig, ax = plt.subplots(figsize=(9, 4.5))
ax.plot(times, k_exact(times), color="#185FA5", lw=2.5, label="exact")
ax.plot(times, k_euler, "o--", color="#C0132C", lw=1.5, ms=4, label="Euler h=5")
ax.plot(times, k_rk4, "s-", color="#1D9E75", lw=1.5, ms=4, label="RK4 h=5")
ax.axhline((s/ngd)**(1/(1-alpha)), ls=":", color="grey")
ax.set_xlabel("time"); ax.set_ylabel("capital per effective worker k(t)")
ax.set_title("Solow transition path: Euler visibly off at coarse steps, RK4 on top of exact")
ax.legend(); plt.tight_layout(); plt.show()

Convergence Order in Action — Halve \(h\), Watch the Error

Theory says: Euler error \(\propto h\), RK4 error \(\propto h^4\). Verify it empirically on the Solow model — the log-log error curve’s slope is the order of the method.

Code
s <- 0.25; alpha <- 0.36; ngd <- 0.1; k0 <- 1
f <- function(t, k) s * k^alpha - ngd * k
k_exact_T <- (s / ngd + (k0^(1 - alpha) - s / ngd) *
              exp(-(1 - alpha) * ngd * 50))^(1 / (1 - alpha))

solve_scheme <- function(h, scheme) {
  times <- seq(0, 50, by = h); k <- k0
  for (i in 1:(length(times) - 1)) {
    t <- times[i]
    if (scheme == "euler") {
      k <- k + h * f(t, k)
    } else {
      k1 <- f(t, k);          k2 <- f(t + h/2, k + h/2 * k1)
      k3 <- f(t + h/2, k + h/2 * k2); k4 <- f(t + h, k + h * k3)
      k  <- k + h / 6 * (k1 + 2*k2 + 2*k3 + k4)
    }
  }
  abs(k - k_exact_T)
}

hs <- c(5, 2.5, 1.25, 0.625, 0.3125)
err <- data.frame(
  h      = rep(hs, 2),
  error  = c(sapply(hs, solve_scheme, scheme = "euler"),
             sapply(hs, solve_scheme, scheme = "rk4")),
  method = rep(c("Euler", "RK4"), each = length(hs)))

# Empirical order = slope of log(error) on log(h)
for (mth in c("Euler", "RK4")) {
  e <- err[err$method == mth, ]
  cat(sprintf("%-5s empirical order: %.2f\n", mth,
              coef(lm(log(error) ~ log(h), data = e))[2]))
}

ggplot(err, aes(h, error, colour = method)) +
  geom_line(linewidth = 1.1) + geom_point(size = 2.5) +
  scale_x_log10() + scale_y_log10() +
  scale_colour_manual(values = c("#C0132C", "#1D9E75")) +
  labs(x = "step size h (log)", y = "error at t = 50 (log)",
       title = "Slopes on log-log axes reveal the order: ~1 for Euler, ~4 for RK4",
       colour = NULL)
Code
import numpy as np

s, alpha, ngd, k0 = 0.25, 0.36, 0.1, 1.0
f = lambda t, k: s * k**alpha - ngd * k
A = s / ngd
k_exact_T = (A + (k0**(1 - alpha) - A) * np.exp(-(1 - alpha) * ngd * 50))**(1 / (1 - alpha))

def solve_scheme(h, scheme):
    times = np.arange(0, 50 + h, h)
    k = k0
    for t in times[:-1]:
        if scheme == "euler":
            k = k + h * f(t, k)
        else:
            k1 = f(t, k);            k2 = f(t + h/2, k + h/2 * k1)
            k3 = f(t + h/2, k + h/2 * k2); k4 = f(t + h, k + h * k3)
            k  = k + h / 6 * (k1 + 2*k2 + 2*k3 + k4)
    return abs(k - k_exact_T)

hs = np.array([5, 2.5, 1.25, 0.625, 0.3125])
for scheme in ["euler", "rk4"]:
    errs  = np.array([solve_scheme(h, scheme) for h in hs])
    order = np.polyfit(np.log(hs), np.log(errs), 1)[0]
    print(f"{scheme:5s} errors:", " ".join(f"{e:.2e}" for e in errs),
          f"| empirical order: {order:.2f}")

Part VII — CPU Parallelization

Processes vs Threads · Amdahl’s Law · Monte Carlo at Scale

Parallel Computing — Concepts First

Threads Processes
Memory shared separate copies
Startup cost negligible fork/spawn + data transfer
Where you meet it BLAS/OpenMP inside %*%, NumPy, glmnet mclapply, future, joblib
Best for inner linear algebra independent replications
Danger oversubscription copying huge data to workers

Econometric workloads are embarrassingly parallel at the replication level: Monte Carlo draws, bootstrap resamples, cross-validation folds, grid points. Each unit is independent — perfect for process-based parallelism.

Do not stack them: 6 worker processes each spawning 12 BLAS threads = 72 threads on 16 logical CPUs — slower than serial. Pin BLAS to 1 thread inside workers.

If a fraction \(f\) of runtime is parallelizable over \(c\) cores, the maximal speedup is

\[S(c) = \frac{1}{(1 - f) + \dfrac{f}{c}} \qquad \xrightarrow{\;c \to \infty\;} \qquad \frac{1}{1 - f}\]

  • \(f = 0.9\): at most 10×, ever — even with 1000 cores
  • \(f = 0.5\): at most — parallelizing is barely worth it
  • The serial part (data loading, result assembly, rendering) always wins in the end

Measure before parallelizing: profile, find \(f\), then decide.

Parallel random numbers are a trap: workers must get independent, reproducible streams, not copies of the same seed.

  • R: mclapply(..., mc.set.seed = TRUE) with RNGkind("L'Ecuyer-CMRG") and set.seed(14159) — statistically independent streams per worker
  • Python: np.random.default_rng(SeedSequence(14159).spawn(n)) — one child generator per task
  • Never set.seed(14159) inside every worker: all workers then produce identical draws and your “10,000 replications” are 6 unique ones

Parallel Monte Carlo — Bootstrap of a Median

The median has no simple SE formula — bootstrap it. \(B = 2000\) resamples of \(n = 10{,}000\) incomes (lognormal): independent tasks, ideal for parallel workers. We time serial vs 6 processes.

Code
library(parallel)
set.seed(14159)
income <- rlnorm(1e4, meanlog = 10, sdlog = 0.75)   # skewed income data
B <- 2000

boot_median <- function(b) {
  # each task: resample and add a small deterministic workload
  idx <- sample.int(length(income), replace = TRUE)
  for (j in 1:30) m <- median(income[idx])           # repeat to make work visible
  m
}

# Serial
t_serial <- system.time(res_s <- lapply(1:B, boot_median))["elapsed"]

# Parallel: 6 forked processes with proper parallel RNG streams
RNGkind("L'Ecuyer-CMRG")
set.seed(14159)
t_par <- system.time(
  res_p <- mclapply(1:B, boot_median, mc.cores = 6, mc.set.seed = TRUE)
)["elapsed"]

ci_s <- quantile(unlist(res_s), c(0.025, 0.975))
ci_p <- quantile(unlist(res_p), c(0.025, 0.975))
cat(sprintf("serial  : %6.2f s   95%% CI for median: [%.0f, %.0f]\n",
            t_serial, ci_s[1], ci_s[2]))
cat(sprintf("parallel: %6.2f s   95%% CI for median: [%.0f, %.0f]\n",
            t_par, ci_p[1], ci_p[2]))
cat(sprintf("speedup : %.1fx on 6 cores\n", t_serial / t_par))
cat(sprintf("implied parallel fraction (Amdahl): f = %.2f\n",
            (1 - t_par / t_serial) / (1 - 1 / 6)))
Code
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import time
from joblib import Parallel, delayed

rng = np.random.default_rng(14159)
income = rng.lognormal(mean=10, sigma=0.75, size=10_000)
B = 2000

# One independent child generator per bootstrap task (reproducible streams)
child_seeds = np.random.SeedSequence(14159).spawn(B)

def boot_median(seed_seq):
    r = np.random.default_rng(seed_seq)
    idx = r.integers(0, income.size, income.size)
    for _ in range(30):                     # repeat to make work visible
        m = np.median(income[idx])
    return m

t0 = time.perf_counter()
res_s = [boot_median(s) for s in child_seeds]
t_serial = time.perf_counter() - t0

t0 = time.perf_counter()
res_p = Parallel(n_jobs=6)(delayed(boot_median)(s) for s in child_seeds)
t_par = time.perf_counter() - t0

ci_s = np.percentile(res_s, [2.5, 97.5])
ci_p = np.percentile(res_p, [2.5, 97.5])
print(f"serial  : {t_serial:6.2f} s   95% CI for median: [{ci_s[0]:.0f}, {ci_s[1]:.0f}]")
print(f"parallel: {t_par:6.2f} s   95% CI for median: [{ci_p[0]:.0f}, {ci_p[1]:.0f}]")
print(f"speedup : {t_serial / t_par:.1f}x on 6 cores")
print(f"implied parallel fraction (Amdahl): f = {(1 - t_par/t_serial) / (1 - 1/6):.2f}")

Amdahl’s Law — Visualised

Code
cores <- 1:64
dd <- expand.grid(c = cores, f = c(0.50, 0.75, 0.90, 0.95, 0.99))
dd$speedup <- 1 / ((1 - dd$f) + dd$f / dd$c)
dd$f_lab   <- factor(sprintf("f = %.2f (max %.0fx)", dd$f, 1 / (1 - dd$f)))

ggplot(dd, aes(c, speedup, colour = f_lab)) +
  geom_line(linewidth = 1.2) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", colour = "grey55") +
  scale_colour_manual(values = c("#C0132C", "#BA7517", "#185FA5",
                                 "#1D9E75", "#7B3FA0")) +
  annotate("text", x = 55, y = 58, label = "ideal linear", colour = "grey40",
           angle = 38, size = 4) +
  coord_cartesian(ylim = c(0, 64)) +
  labs(x = "number of cores", y = "speedup S(c)",
       title = "Amdahl's law: the serial fraction caps the speedup",
       colour = "parallel fraction")
Code
import numpy as np
import matplotlib.pyplot as plt

cores = np.arange(1, 65)
fracs = [0.50, 0.75, 0.90, 0.95, 0.99]
cols  = ["#C0132C", "#BA7517", "#185FA5", "#1D9E75", "#7B3FA0"]

fig, ax = plt.subplots(figsize=(9, 4.5))
for f, c in zip(fracs, cols):
    ax.plot(cores, 1 / ((1 - f) + f / cores), color=c, lw=2,
            label=f"f = {f:.2f} (max {1/(1-f):.0f}x)")
ax.plot(cores, cores, "--", color="grey", label="ideal linear")
ax.set_ylim(0, 64)
ax.set_xlabel("number of cores"); ax.set_ylabel("speedup S(c)")
ax.set_title("Amdahl's law: the serial fraction caps the speedup")
ax.legend(); plt.tight_layout(); plt.show()

Part VIII — GPU Acceleration with torch

Tensors, Autograd, and When the GPU Actually Wins

GPU Computing — Concepts First

A CPU has ~8–16 powerful cores optimised for sequential logic. A GPU has thousands of simple cores (an RTX 3050: 2560 CUDA cores) optimised for doing the same operation on many numbers at once — exactly the shape of linear algebra.

\[\text{matmul: } \mathbf{C} = \mathbf{A}\mathbf{B} \quad \text{— } n^3 \text{ multiply-adds, all independent across } (i,j) \text{ cells}\]

Workload GPU payoff
Large dense matmul, batched OLS 10–100×
Gradient-based MLE with big \(n\) large
Neural nets, causal ML learners the reason torch exists
Loops with branching, small data slower than CPU
Anything dominated by data transfer slower than CPU

Transfer tax — data must cross the PCIe bus to GPU memory and back:

\[T_{\text{total}} = \underbrace{T_{\text{transfer}}}_{\text{once per dataset}} + \underbrace{T_{\text{compute}}}_{\text{the fast part}}\]

Move data once, keep it on the device, do many operations there, bring back only results. A single small matmul is not worth the trip.

Precision tax — consumer GPUs are built for float32; float64 units are few:

  • float32: ~7 correct digits — fine for gradient steps, dangerous for near-singular solves
  • Part I’s rules bite harder: cancellation happens 10⁹ times sooner in float32
  • Rule: explore in float32, verify in float64, compare with a CPU reference

Same API in both languages (R torch is a native re-implementation, not a Python wrapper):

Operation R Python
Create tensor torch_tensor(x, device = "cuda") torch.tensor(x, device="cuda")
Check GPU cuda_is_available() torch.cuda.is_available()
Matrix product torch_matmul(A, B) / A$matmul(B) A @ B
Track gradients x$requires_grad_(TRUE) x.requires_grad_(True)
Backpropagate loss$backward() loss.backward()
Read gradient x$grad x.grad
Back to CPU/R as.numeric(x$cpu()) x.cpu().numpy()

A systems pitfall: R torch and Python torch cannot live in one process — two libtorch builds collide on the same shared library. This deck’s Python tabs drive the real GPU, so each R torch snippet is dispatched to an isolated subprocess via callr::r() (the r_torch() helper in setup). Same code, its own process.

Matrix Multiplication — CPU vs GPU Benchmark

\(4000 \times 4000\) matrix products: ~\(1.3 \times 10^{11}\) floating-point operations each. This machine: 16-thread CPU vs an NVIDIA RTX 3050 (CUDA). Note the synchronisation: GPU calls return before the work finishes — always synchronize() before reading the clock.

Code
import torch, time

n = 4000
A = torch.randn(n, n)                     # float32 on CPU
B = torch.randn(n, n)

def bench(fn, sync=None, reps=3):
    fn()                                   # warm-up (CUDA kernels compile lazily)
    if sync: sync()
    t0 = time.perf_counter()
    for _ in range(reps):
        fn()
    if sync: sync()
    return (time.perf_counter() - t0) / reps

t_cpu = bench(lambda: A @ B)
print(f"CPU  (float32, 16 threads): {t_cpu*1000:8.1f} ms")

if torch.cuda.is_available():
    print("GPU:", torch.cuda.get_device_name(0))
    # Transfer tax: moving the inputs to the device, once
    t0 = time.perf_counter()
    Ag, Bg = A.cuda(), B.cuda()
    torch.cuda.synchronize()
    print(f"transfer to GPU (once)    : {(time.perf_counter()-t0)*1000:8.1f} ms")

    t_gpu = bench(lambda: Ag @ Bg, sync=torch.cuda.synchronize)
    print(f"GPU  (float32)            : {t_gpu*1000:8.1f} ms")
    print(f"speedup (compute only)    : {t_cpu / t_gpu:8.1f}x")

    # The precision tax: same product in float64 on the GPU
    Ad, Bd = Ag.double(), Bg.double()
    t_gpu64 = bench(lambda: Ad @ Bd, sync=torch.cuda.synchronize)
    print(f"GPU  (float64)            : {t_gpu64*1000:8.1f} ms  "
          f"({t_gpu64/t_gpu:.0f}x slower than float32)")
else:
    print("No CUDA device found — GPU timings skipped")
Code
# Same benchmark with R torch. The API mirrors Python torch one-to-one;
# device = "cuda" works identically when R torch has a CUDA backend installed.
# r_torch() runs this in an isolated subprocess (see the setup note).
r_torch(function() {
  suppressPackageStartupMessages(library(torch))
  dev <- if (cuda_is_available()) "cuda" else "cpu"
  cat("R torch device:", dev,
      if (dev == "cpu") "(this machine's R backend is CPU-only; Python tab shows the GPU)" else "", "\n\n")

  n <- 2000
  A <- torch_randn(n, n, device = dev)      # float32 tensors
  B <- torch_randn(n, n, device = dev)

  # torch matmul (all 16 threads via its own BLAS)
  invisible(torch_matmul(A, B))             # warm-up
  t_torch <- system.time(for (i in 1:3) invisible(torch_matmul(A, B)))["elapsed"] / 3

  # base R %*% in float64 for reference
  A_r <- matrix(rnorm(n * n), n, n); B_r <- matrix(rnorm(n * n), n, n)
  t_base <- system.time(invisible(A_r %*% B_r))["elapsed"]

  cat(sprintf("torch matmul (float32, %s): %7.1f ms\n", dev, 1000 * t_torch))
  cat(sprintf("base R %%*%%  (float64, cpu): %7.1f ms\n", 1000 * t_base))

  # The tensor API in brief: same ops, chainable, autograd-ready
  x <- torch_tensor(c(1, 2, 3), device = dev)
  cat("\nx * 2 + 1  ->", as.numeric((x * 2 + 1)$cpu()), "\n")
})

Autograd — Derivatives Without Deriving

torch records every tensor operation in a computational graph and applies the chain rule backwards — exact gradients (to float precision), no pencil work, no finite-difference U-curve (Part V):

\[\text{loss} = f(g(h(\theta))) \qquad \Rightarrow \qquad \nabla_\theta\,\text{loss} = h'(\theta)^\top g'(h)^\top f'(g) \quad \text{computed by } \texttt{backward()}\]

This is reverse-mode automatic differentiation: one backward pass costs about as much as one forward pass, regardless of the number of parameters — why it powers all of deep learning and, increasingly, structural econometrics.

Code
# r_torch() runs this R torch snippet in an isolated subprocess (setup note).
r_torch(function() {
  suppressPackageStartupMessages(library(torch))
  # d/dx of f(x) = x^3 + 2x at x = 2: analytic answer 3*4 + 2 = 14
  x <- torch_tensor(2, requires_grad = TRUE)
  f <- x^3 + 2 * x
  f$backward()
  cat("autograd df/dx at x=2 :", as.numeric(x$grad), " (analytic: 14)\n")

  # Gradient of a vector function: OLS loss gradient in one line
  set.seed(14159)
  X <- torch_tensor(cbind(1, matrix(rnorm(200), 100, 2)))
  y <- torch_tensor(rnorm(100))
  b <- torch_zeros(3, requires_grad = TRUE)
  loss <- torch_mean((y - torch_matmul(X, b))^2)
  loss$backward()
  cat("autograd  gradient:", round(as.numeric(b$grad), 6), "\n")
  # Check against the analytic gradient -2/n X'(y - Xb) at b = 0
  g_analytic <- -2 * as.numeric(torch_matmul(torch_t(X), y)) / 100
  cat("analytic  gradient:", round(g_analytic, 6), "\n")
})
Code
import torch

# d/dx of f(x) = x^3 + 2x at x = 2: analytic answer 14
x = torch.tensor(2.0, requires_grad=True)
f = x**3 + 2 * x
f.backward()
print(f"autograd df/dx at x=2 : {x.grad.item():.1f}  (analytic: 14)")

# Gradient of a vector function: OLS loss gradient in one line
g = torch.Generator().manual_seed(14159)
X = torch.cat([torch.ones(100, 1), torch.randn(100, 2, generator=g)], dim=1)
y = torch.randn(100, generator=g)
b = torch.zeros(3, requires_grad=True)
loss = torch.mean((y - X @ b)**2)
loss.backward()
print("autograd  gradient:", b.grad.numpy().round(6))
g_analytic = (-2 * X.T @ y / 100).numpy()
print("analytic  gradient:", g_analytic.round(6))

Logit MLE on the GPU — Autograd Gradient Descent

The Part IV logit, re-estimated with torch: define the stable negative log-likelihood, let autograd differentiate it, take gradient steps on the device. Same data (../data/numerical-logit.csv), so the coefficients must match Newton’s to float precision.

Code
import torch
import pandas as pd
import numpy as np

dat = pd.read_csv("../data/numerical-logit.csv")
dev = "cuda" if torch.cuda.is_available() else "cpu"

# Move the data to the device ONCE, in float64 for full precision
X = torch.tensor(np.column_stack([np.ones(len(dat)),
                                  dat[["x1", "x2", "x3"]].to_numpy()]),
                 dtype=torch.float64, device=dev)
y = torch.tensor(dat["y"].to_numpy(), dtype=torch.float64, device=dev)

b = torch.zeros(4, dtype=torch.float64, device=dev, requires_grad=True)
opt = torch.optim.Adam([b], lr=0.05)      # adaptive gradient descent

for k in range(1, 601):
    opt.zero_grad()
    eta  = X @ b
    negll = -(y * eta - torch.logaddexp(torch.zeros_like(eta), eta)).sum()
    negll.backward()                      # autograd: exact gradient
    opt.step()
    if k % 150 == 0:
        print(f"  iter {k:4d}  negll = {negll.item():.6f}  "
              f"|grad| = {b.grad.abs().max().item():.2e}")

b_hat = b.detach().cpu().numpy()
print(f"\ndevice used: {dev}")
print("torch Adam :", b_hat.round(6))
print("true beta  : [-0.5, 1.2, -0.8, 0.6]")
print("(compare the Newton-Raphson estimates in Part IV — identical to ~6 digits)")
Code
# r_torch() runs this R torch snippet in an isolated subprocess (setup note).
r_torch(function() {
  suppressPackageStartupMessages(library(torch))
  dat <- read.csv("../data/numerical-logit.csv")
  dev <- if (cuda_is_available()) "cuda" else "cpu"

  X <- torch_tensor(cbind(1, as.matrix(dat[, c("x1", "x2", "x3")])),
                    dtype = torch_float64(), device = dev)
  y <- torch_tensor(dat$y, dtype = torch_float64(), device = dev)

  b   <- torch_zeros(4, dtype = torch_float64(), device = dev, requires_grad = TRUE)
  opt <- optim_adam(list(b), lr = 0.05)

  for (k in 1:600) {
    opt$zero_grad()
    eta   <- torch_matmul(X, b)
    negll <- -torch_sum(y * eta - torch_logaddexp(torch_zeros_like(eta), eta))
    negll$backward()
    opt$step()
    if (k %% 150 == 0)
      cat(sprintf("  iter %4d  negll = %.6f\n", k, negll$item()))
  }

  b_hat <- as.numeric(b$detach()$cpu())
  cat("\ndevice used:", dev, "\n")
  cat("torch Adam :", round(b_hat, 6), "\n")
  cat("true beta  : -0.5  1.2  -0.8  0.6\n")
  cat("glm() check:", round(as.numeric(coef(glm(y ~ x1 + x2 + x3,
                                                data = dat, family = binomial))), 6), "\n")
})

When to Reach for Which Tool

Situation Tool Part
Results differ across machines / runs check float comparisons, summation order I
Collinear regressors, huge SEs check \(\kappa(\mathbf{X})\), standardise, QR/SVD II
Estimator defined by an equation bracket + Brent; Newton with checked derivative III
Estimator defined by an optimum BFGS with analytic gradient; multiple starts IV
Need derivatives / integrals of black-box \(f\) numDeriv / quadrature; MC in high dimension V
Continuous-time model RK4 / lsoda — never hand-rolled Euler in production VI
Thousands of independent replications mclapply / joblib on 6 cores, proper RNG streams VII
Dense linear algebra dominates; \(n\) huge torch on GPU, float32 explore / float64 verify VIII

One sentence per part: floats are finite (I) — so factor, never invert (II) — roots and optima are iterations built on solves (III, IV) — derivatives and integrals trade truncation against rounding (V) — dynamics compound one step’s error into a path (VI) — and when one core is not enough, replicate across cores (VII) or vectorise onto thousands of them (VIII).

Exercises — Errors, Matrices & Equations

  1. Machine epsilon by hand — write a loop that starts at \(h = 1\) and halves until \(1 + h == 1\). Compare the result with .Machine$double.eps / np.finfo(float).eps. Repeat in float32 (np.float32 / torch_float()).
  2. Quadratic roots stably — for \(x^2 + 10^8 x + 1 = 0\), compute both roots with the textbook formula, then with the stable variant \(x_1 = q/a\), \(x_2 = c/q\) where \(q = -\tfrac{1}{2}(b + \text{sign}(b)\sqrt{b^2 - 4ac})\). Which root loses all digits and why?
  3. Kahan summation — implement compensated summation and compare against naive sum() on \(10^7\) values of 0.1 in float32. How many digits does each retain?
  4. Longley by hand — standardise the Longley regressors (mean 0, sd 1) and recompute \(\kappa(\mathbf{X})\). How many digits does standardisation buy back? Does the normal-equations route now agree with lm()?
  5. Ridge as stabiliser — add \(\lambda\mathbf{I}\) to \(\mathbf{X}^\top\mathbf{X}\) for the Longley data with \(\lambda \in \{10^{-8}, 10^{-4}, 1\}\) and track \(\kappa\) and the coefficients. Connect numerical stabilisation to shrinkage bias.
  6. Multiple IRRs — the cash flow \((-1000, 3600, -4310, 1716)\) has three sign changes. Plot NPV\((r)\) on \([0, 1]\), find all roots with repeated bracketed uniroot / brentq calls, and explain which (if any) is economically meaningful.
  7. Cournot equilibrium — two firms with costs \(c_i q_i^2\) face inverse demand \(P = 100 - Q\). Write the two best-response first-order conditions and solve the system with nleqslv / scipy.optimize.root for \((c_1, c_2) = (1, 2)\). Verify second-order conditions.

Exercises — Optimization, Dynamics & Acceleration

  1. Probit from scratch — repeat the Part IV Newton–Raphson for a probit on the same CSV (gradient and Hessian involve the normal pdf/cdf ratio — mind the tails: use log(pnorm()) via pnorm(log.p=TRUE) / scipy.special.log_ndtr). Compare with glm(family = binomial(link = "probit")) / sm.Probit.
  2. Bad starting values — start the logit Newton iteration at \(\boldsymbol\beta_0 = (20, 20, 20, 20)\). What happens and why? Add step-halving (halve \(\mathbf{d}\) until the log-likelihood improves) and show it repairs convergence.
  3. Gradient check discipline — introduce a deliberate bug (drop the minus sign on one gradient component) and show that BFGS still reports “convergence”. What do the estimates look like? What does check_grad / numDeriv report?
  4. Step-size U-curve for the Hessian — compute the logit Hessian by finite differences with \(h \in \{10^{-1}, \ldots, 10^{-12}\}\) and plot the error against the analytic Hessian. Where is the optimum \(h\)? Compare with Part V’s theory.
  5. Ramsey in two equations — solve the system \(\dot{k} = k^{0.36} - c - 0.1k\), \(\dot{c} = c\,(0.36 k^{-0.64} - 0.15)\) with deSolve / solve_ivp from several initial \(c(0)\) and visualise the saddle path instability.
  6. Stiff test — solve \(\dot{y} = -1000(y - \cos t)\) with hand-rolled Euler at \(h = 0.01\) and with lsoda / LSODA. Explain the explosion and the fix.
  7. Parallel bootstrap of a regression — bootstrap the logit coefficient of x1 (\(B = 1000\)) serially and with 6 workers. Report the speedup and the implied Amdahl fraction. Verify both give the same CI with proper RNG streams.
  8. GPU break-even — time torch matmul for \(n \in \{100, 500, 1000, 2000, 4000\}\) on CPU and GPU (remember to synchronise). Plot both curves: at what \(n\) does the GPU overtake, and why not sooner?
  9. float32 vs float64 MLE — rerun the torch logit in float32. How many digits of the coefficients survive? Which part of the computation loses them?

Further Reading

The numerical-analysis canon:

The algorithms this deck implements:

  • Nelder & Mead (1965). A simplex method for function minimization. Computer Journal 7(4), 308–313. doi:10.1093/comjnl/7.4.308
  • Broyden (1970). The convergence of a class of double-rank minimization algorithms. IMA J. Applied Mathematics 6(1), 76–90 — the B in BFGS. doi:10.1093/imamat/6.1.76
  • Dennis & Schnabel (1996). Numerical Methods for Unconstrained Optimization and Nonlinear Equations. SIAM — the reference for the Newton–Raphson and quasi-Newton slides. doi:10.1137/1.9781611971200
  • Hairer, Nørsett & Wanner (1993). Solving Ordinary Differential Equations I: Nonstiff Problems, 2nd ed. Springer — Runge–Kutta and the convergence-order demonstration. doi:10.1007/978-3-540-78862-1

Computation for economists:

  • Judd (1998). Numerical Methods in Economics. MIT Press. mitpress link
  • Miranda & Fackler (2002). Applied Computational Economics and Finance. MIT Press. mitpress link
  • Longley (1967). An appraisal of least squares programs for the electronic computer. JASA 62, 819–841. doi:10.1080/01621459.1967.10500896
  • McCullough & Vinod (1999). The numerical reliability of econometric software. JEL 37(2), 633–665. doi:10.1257/jel.37.2.633
  • Fernández-Villaverde & Valencia (2018). A practical guide to parallelization in economics. NBER WP 24561. doi:10.3386/w24561

Software:

  • Paszke et al. (2019). PyTorch: an imperative style, high-performance deep learning library. NeurIPS 32. arXiv:1912.01703
  • Falbel & Luraschi (2023). torch for R. torch.mlverse.org
  • Soetaert, Petzoldt & Setzer (2010). Solving differential equations in R: package deSolve. JSS 33(9). doi:10.18637/jss.v033.i09
  • Virtanen et al. (2020). SciPy 1.0: fundamental algorithms for scientific computing in Python. Nature Methods 17, 261–272. doi:10.1038/s41592-019-0686-2

Thank You

Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab
Department of Economics
University of Ioannina, Greece

astavrak@uoi.gr · linkedin.com/in/astavrakoudis