Applied Informatics and Computational Economics Lab
4 July 2026
Required Packages
library(tidyverse) # data wrangling and ggplot2library(patchwork) # combining plotslibrary(parallel) # mclapply() — fork-based parallel MClibrary(tseries) # adf.test() for the unit-root power studylibrary(MASS) # mvrnorm() for correlated drawslibrary(glue) # string interpolation
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltfrom scipy import statsimport statsmodels.api as smfrom statsmodels.tsa.stattools import adfuller
* Everything used here is built-in Stata:* simulate, dfuller, regress, ivregress, _pctile, rnormal(), rt(), rchi2()* No ssc installs required forthis deck.
About This Deck
Part I: Foundations — what Monte Carlo is, why economists need it, the MC estimator and its 1/√B error law
Part II: The CLT laboratory — verifying (and breaking) the Central Limit Theorem by simulation
Part III: Estimators under the microscope — OLS bias, RMSE and t-test size in finite samples; endogeneity and heteroskedasticity
Part IV: Designing tests — how new tests are born: simulated critical values, size and power studies (ADF case study)
Part VI: Finance applications — option pricing by simulation, variance reduction, Value-at-Risk and Expected Shortfall
All computational slides run in , and ; R generates every shared dataset
Literature Review
Metropolis, N., & Ulam, S. (1949). The Monte Carlo method. Journal of the American Statistical Association, 44(247), 335–341. DOI: 10.1080/01621459.1949.10483310
Hendry, D. F. (1984). Monte Carlo experimentation in econometrics. In Handbook of Econometrics, Vol. 2, Ch. 16. North-Holland. DOI: 10.1016/S1573-4412(84)01012-8
Davidson, R., & MacKinnon, J. G. (2004). Econometric Theory and Methods. Oxford University Press. Ch. 21 (Monte Carlo). Publisher page
Granger, C. W. J., & Newbold, P. (1974). Spurious regressions in econometrics. Journal of Econometrics, 2(2), 111–120. DOI: 10.1016/0304-4076(74)90034-7
Dickey, D. A., & Fuller, W. A. (1979). Distribution of the estimators for autoregressive time series with a unit root. JASA, 74(366), 427–431. DOI: 10.1080/01621459.1979.10482531
Because the DGP is under our control, we know the true parameter — the one thing real data never reveals.
Verify asymptotic theory in finite samples: does “as \(n\to\infty\)” already work at \(n = 50\)? (Parts II–III)
Evaluate estimator properties — bias, variance, RMSE — under any DGP we choose, including ones that break the textbook assumptions (Parts III & V)
Compute what theory cannot deliver in closed form: critical values of non-standard distributions, test power curves, option prices, risk quantiles (Parts IV & VI)
Simulation does not prove theorems
A Monte Carlo experiment establishes what happens for the specific DGPs, sample sizes and parameter values simulated — nothing more. It illustrates theorems and reveals where they break down, but a result that holds at \(n=100\) under Normal errors may fail at \(n=30\) under skewed errors. Good MC design therefore varies the DGP over the region of the parameter space that matters for practice. “If in doubt, simulate” (MacKinnon) — but simulate widely.
We estimate \(\theta = \mathbb{E}[\bar X_{50}]\) with \(X \sim \text{Exp}(1)\) at increasing budgets \(B\), and for each \(B\) measure the spread of \(\hat\theta_{\text{MC}}\) across 200 independent repetitions of the whole experiment:
On log-log axes the observed SE should fall on a straight line with slope −1/2.
Code
set.seed(14159)B_seq <-c(50, 100, 200, 500, 1000, 2000, 5000)# for each budget B: repeat the whole MC experiment 200 times, record its SDobserved_se <-numeric(length(B_seq))for (j inseq_along(B_seq)) { reps <-numeric(200)for (r in1:200) { draws <-numeric(B_seq[j])for (b in1:B_seq[j]) draws[b] <-mean(rexp(50)) reps[r] <-mean(draws) } observed_se[j] <-sd(reps)}df_err <-tibble(B = B_seq, observed = observed_se,theory = (1/sqrt(50)) /sqrt(B_seq))ggplot(df_err, aes(x = B)) +geom_line(aes(y = theory, color ="Theory: sigma/sqrt(B)"), linewidth =1.1, linetype ="dashed") +geom_line(aes(y = observed, color ="Observed SE"), linewidth =1.3) +geom_point(aes(y = observed), color ="#185FA5", size =3) +scale_x_log10() +scale_y_log10() +scale_color_manual(values =c("Observed SE"="#185FA5", "Theory: sigma/sqrt(B)"="#D85A30"), name =NULL) +labs(x ="Replications B (log scale)", y ="SE of the MC estimator (log scale)",title ="Monte Carlo error decays at rate 1/sqrt(B)",subtitle ="Log-log slope = -1/2: each extra digit of precision costs 100x more work") + theme_lecture
Code
import numpy as npimport matplotlib.pyplot as pltrng = np.random.default_rng(14159)B_seq = [50, 100, 200, 500, 1000, 2000, 5000]observed_se = []for B in B_seq: reps = np.empty(200)for r inrange(200): draws = rng.exponential(size=(B, 50)).mean(axis=1) reps[r] = draws.mean() observed_se.append(reps.std(ddof=1))theory = (1/ np.sqrt(50)) / np.sqrt(B_seq)fig, ax = plt.subplots(figsize=(9, 4.5))ax.loglog(B_seq, theory, "--", color="#D85A30", lw=2, label="Theory: sigma/sqrt(B)")ax.loglog(B_seq, observed_se, "-o", color="#185FA5", lw=2, ms=6, label="Observed SE")ax.set_xlabel("Replications B (log scale)")ax.set_ylabel("SE of the MC estimator (log scale)")ax.set_title("Monte Carlo error decays at rate 1/sqrt(B)")ax.legend()plt.tight_layout(); plt.show()
Code
* SDof the MC estimator across 200 repeats of the whole experiment.* Shortcut: the meanof 50 iid Exp(1) draws is exactly Gamma(50, 1/50).setseed 14159tempfile resultstempnamehpostfile`h' B se using`results'foreach B of numlist 50 100 200 500 1000 2000 5000 {quietly {clearsetobs`=200*`B''genm = rgamma(50, 1/50)gen rep = ceil(_n / `B')collapse (mean) est = m, by(rep)summarizeest }post`h' (`B') (r(sd))}postclose`h'use`results', cleargen theory = (1/sqrt(50)) / sqrt(B)format se theory %8.5flist B se theory, noobstwoway (line theory B, lcolor(red) lpattern(dash) lwidth(medthick)) /// (connected se B, lcolor(navy) mcolor(navy) msize(medium)), ///xscale(log) yscale(log) ///legend(order(2 "Observed SE" 1 "Theory: sigma/sqrt(B)")) ///xtitle("Replications B (log scale)") ytitle("SE of the MC estimator (log scale)") ///title("Monte Carlo error decays at rate 1/sqrt(B)")
The distribution \(F\) is irrelevant in the limit — Exponential, Bernoulli, \(t(3)\), all give the same \(\mathcal{N}(0,1)\) standardised sample mean. That is the miracle, and it is why so much of econometrics “works”.
All three must hold — and each fails for data economists actually use:
Condition
Meaning
Fails for…
iid
independent, identical draws
time series, clustered data
\(\mu < \infty\)
finite mean
Cauchy, Pareto \((\alpha \le 1)\)
\(\sigma^2 < \infty\)
finite variance
\(t_\nu\) for \(\nu \le 2\), Cauchy
The convergence rate is bounded by the Berry–Esseen theorem:
R generates all \(4 \times 5000\) standardised means and writes them to ../data/monte-carlo-clt.csv; Python and Stata read the same file, so the three languages plot literally the same simulated draws.
Code
set.seed(14159)n_vals <-c(5, 10, 30, 100)B <-5000rows <-list()for (nv in n_vals) { z <-numeric(B)for (b in1:B) z[b] <-sqrt(nv) * (mean(rexp(nv)) -1) rows[[length(rows) +1]] <-tibble(n = nv, b =1:B, z = z)}clt_df <-bind_rows(rows)write.csv(clt_df, "../data/monte-carlo-clt.csv", row.names =FALSE)
(encoding automatically selected: ISO-8859-9)
(3 vars, 20,000 obs)
Summary for variables: z
Group variable: n
n | Mean SD Skewness
---------+------------------------------
5 | 0.005 0.986 0.829
10 | 0.008 1.011 0.666
30 | 0.022 1.017 0.393
100 | -0.022 0.993 0.193
---------+------------------------------
Total | 0.003 1.002 0.519
----------------------------------------
CLT Convergence — Histograms
Code
clt_df <-read.csv("../data/monte-carlo-clt.csv")clt_df$n_lab <-factor(paste0("n = ", clt_df$n), levels =paste0("n = ", c(5, 10, 30, 100)))ggplot(clt_df, aes(x = z)) +geom_histogram(aes(y =after_stat(density)), bins =60,fill ="#185FA5", alpha =0.75, color ="white", linewidth =0.15) +stat_function(fun = dnorm, color ="#D85A30", linewidth =1.2) +facet_wrap(~n_lab, nrow =1) +coord_cartesian(xlim =c(-4.5, 4.5)) +labs(x ="Standardised sample mean Z_n", y ="Density",title ="Exp(1) sample means approach N(0,1) as n grows",subtitle ="Red curve = theoretical N(0,1) - 5,000 replications per panel") + theme_lecture
Code
import pandas as pdimport numpy as npimport matplotlib.pyplot as pltfrom scipy import statsclt = pd.read_csv("../data/monte-carlo-clt.csv")xs = np.linspace(-4.5, 4.5, 300)fig, axes = plt.subplots(1, 4, figsize=(12, 3.5), sharey=True)for ax, nv inzip(axes, [5, 10, 30, 100]): z = clt.loc[clt["n"] == nv, "z"] ax.hist(z, bins=60, density=True, color="#185FA5", alpha=0.75) ax.plot(xs, stats.norm.pdf(xs), color="#D85A30", lw=2) ax.set_xlim(-4.5, 4.5) ax.set_title(f"n = {nv}") ax.set_xlabel(r"$Z_n$")axes[0].set_ylabel("Density")fig.suptitle("Exp(1) sample means approach N(0,1) as n grows")plt.tight_layout(); plt.show()
Code
import delimited "../data/monte-carlo-clt.csv", clearquietlydestring_all, replacetwoway (histogram z if n == 5, density bin(60) color(navy%60)) /// (histogram z if n == 100, density bin(60) color(red%50)) /// (functiony = normalden(x), range(-4.5 4.5) lcolor(black) lwidth(medthick)), ///legend(label(1 "n = 5") label(2 "n = 100") label(3 "N(0,1)")) ///title("CLT convergence: n = 5 vs n = 100") xtitle("Z_n")
for nv, z in [(5, z5), (100, z100)]: D, p = stats.kstest(z, "norm")print(f"KS test n={nv:<3}: D = {D:.4f}, p = {p:.4g}")
KS test n=5 : D = 0.0571, p = 1.316e-14
KS test n=100: D = 0.0243, p = 0.005434
Code
import delimited "../data/monte-carlo-clt.csv", clearquietlydestring_all, replaceqnorm z if n == 5, mcolor(navy%40) msize(tiny) rlopts(lcolor(red)) ///title("QQ plot of Z_n, n = 5")display"Skewness/kurtosis normality test, n = 5:"sktest z if n == 5display"Skewness/kurtosis normality test, n = 100:"sktest z if n == 100
(encoding automatically selected: ISO-8859-9)
(3 vars, 20,000 obs)
Skewness/kurtosis normality test, n = 5:
Skewness and kurtosis tests for normality
----- Joint test -----
Variable | Obs Pr(skewness) Pr(kurtosis) Adj chi2(2) Prob>chi2
-------------+-----------------------------------------------------------------
z | 5,000 0.0000 0.0000 440.80 0.0000
Skewness/kurtosis normality test, n = 100:
Skewness and kurtosis tests for normality
----- Joint test -----
Variable | Obs Pr(skewness) Pr(kurtosis) Adj chi2(2) Prob>chi2
-------------+-----------------------------------------------------------------
z | 5,000 0.0000 0.8258 27.99 0.0000
The Cauchy distribution is symmetric but has no finite mean (\(\mathbb{E}[|X|] = \infty\)), so both CLT moment conditions fail. By its characteristic function:
\[
\bar{X}_n \sim \text{Cauchy}(0, 1) \quad \text{for every } n
\]
The sample mean of \(n\) Cauchy draws is again Cauchy — averaging produces zero concentration, no matter how large \(n\) gets. Any MC study that quietly assumes a finite variance (heavy-tailed returns! Pareto firm sizes!) can fall into this trap.
Code
set.seed(14159)B <-5000; n <-1000means_normal <-numeric(B)means_cauchy <-numeric(B)for (b in1:B) { means_normal[b] <-mean(rnorm(n)) means_cauchy[b] <-mean(rcauchy(n))}df_c <-bind_rows(tibble(mean = means_normal, dist ="Normal(0,1): concentrates as 1/sqrt(n)"),tibble(mean = means_cauchy, dist ="Cauchy(0,1): no concentration at all"))ggplot(df_c, aes(x = mean, fill = dist)) +geom_histogram(aes(y =after_stat(density)), bins =120, color ="white", linewidth =0.1) +facet_wrap(~dist, scales ="free_y") +coord_cartesian(xlim =c(-3, 3)) +scale_fill_manual(values =c("#D85A30", "#185FA5"), guide ="none") +labs(x ="Sample mean (n = 1000)", y ="Density",title ="The sample mean of 1,000 Cauchy draws is still Cauchy",subtitle ="5,000 replications - note the Cauchy panel's fat tails despite n = 1000") + theme_lecture
Code
import numpy as npimport matplotlib.pyplot as pltrng = np.random.default_rng(14159)B, n =5000, 1000means_normal = rng.standard_normal((B, n)).mean(axis=1)means_cauchy = rng.standard_cauchy((B, n)).mean(axis=1)fig, axes = plt.subplots(1, 2, figsize=(10, 4))axes[0].hist(means_normal, bins=120, density=True, color="#185FA5")axes[0].set_title("Normal: concentrates as 1/sqrt(n)")axes[1].hist(np.clip(means_cauchy, -3, 3), bins=120, density=True, color="#D85A30")axes[1].set_title("Cauchy: still Cauchy at n = 1000")for ax in axes: ax.set_xlim(-3, 3); ax.set_xlabel("Sample mean")plt.tight_layout(); plt.show()
Code
print(f"SD across replications Normal: {means_normal.std(ddof=1):.4f} (theory 1/sqrt(1000) = {1/np.sqrt(1000):.4f})")
SD across replications Normal: 0.0314 (theory 1/sqrt(1000) = 0.0316)
Code
print(f"IQR across replications Cauchy: {np.subtract(*np.percentile(means_cauchy, [75, 25])):.4f} (Cauchy IQR = 2, any n)")
IQR across replications Cauchy: 1.9859 (Cauchy IQR = 2, any n)
Code
* 5000 replications of the meanof n = 1000 draws, Normal vs Cauchy.* A Cauchy draw is the ratiooftwoindependent standard Normals.quietly {clearsetseed 14159setobs 5000000gen g = ceil(_n / 1000)gen zn = rnormal()gen zc = rnormal() / rnormal()collapse (mean) m_normal = zn m_cauchy = zc, by(g)}display"Across 5000 replications of the mean (n = 1000 each):"summarize m_normal m_cauchy, detaildisplay"Normal SD ~ " %6.4f 1/sqrt(1000) " as theory predicts; Cauchy quartiles stay O(1)."twoway (histogram m_cauchy ifabs(m_cauchy) < 3, density bin(120) color(red%50)) /// (histogram m_normal, density bin(60) color(navy%60)), ///legend(label(2 "Normal: concentrates as 1/sqrt(n)") label(1 "Cauchy: still Cauchy")) ///xtitle("Sample mean (n = 1000)") ///title("The sample mean of 1000 Cauchy draws is still Cauchy")
Across 5000 replications of the mean (n = 1000 each):
(mean) zn
-------------------------------------------------------------
Percentiles Smallest
1% -.0742439 -.1140527
5% -.0538611 -.1116729
10% -.0420627 -.1022158 Obs 5,000
25% -.0217698 -.1002853 Sum of wgt. 5,000
50% .0000873 Mean -.0003209
Largest Std. dev. .0320061
75% .0213199 .098508
90% .0407568 .1038475 Variance .0010244
95% .052478 .1072775 Skewness -.0346208
99% .0736517 .1261754 Kurtosis 2.924206
(mean) zc
-------------------------------------------------------------
Percentiles Smallest
1% -28.89108 -979.2396
5% -6.542495 -720.3051
10% -3.096384 -370.7444 Obs 5,000
25% -.9885845 -328.3892 Sum of wgt. 5,000
50% .012767 Mean .0154416
Largest Std. dev. 28.0314
75% 1.00101 253.863
90% 3.171221 341.6121 Variance 785.7593
95% 6.879598 365.36 Skewness -.0021905
99% 31.77426 1093.115 Kurtosis 873.2744
Normal SD ~ 0.0316 as theory predicts; Cauchy quartiles stay O(1).
We measure the left side by the Kolmogorov–Smirnov statistic of simulated \(Z_n\) against \(\Phi\), for \(n \in \{5, 10, 20, 50, 100, 200\}\). The bound is conservative, but the slope on log-log axes must be −1/2.
Code
set.seed(14159)n_be <-c(5, 10, 20, 50, 100, 200)B <-20000emp <-numeric(length(n_be))for (j inseq_along(n_be)) { z <-sqrt(n_be[j]) * (colMeans(matrix(rexp(n_be[j] * B), nrow = n_be[j])) -1) emp[j] <-unname(ks.test(z, "pnorm")$statistic)}df_be <-tibble(n = n_be, empirical = emp, bound =0.4748*2/sqrt(n_be))ggplot(df_be, aes(x = n)) +geom_line(aes(y = bound, color ="Berry-Esseen bound"), linewidth =1.2, linetype ="dashed") +geom_line(aes(y = empirical, color ="Empirical KS distance"), linewidth =1.3) +geom_point(aes(y = empirical), color ="#185FA5", size =3) +scale_x_log10() +scale_y_log10() +scale_color_manual(values =c("Empirical KS distance"="#185FA5", "Berry-Esseen bound"="#D85A30"), name =NULL) +labs(x ="n (log scale)", y ="sup |F_n - Phi| (log scale)",title ="Berry-Esseen: the bound is loose but the O(1/sqrt(n)) rate is exact") + theme_lecture
Prediction from theory: bias ≈ 0 everywhere (A3 holds); RMSE larger for heavy tails; \(t\)-test size distorted at small \(n\) with skewed errors, correct as \(n\) grows
What Gauss–Markov does not say: anything about finite-sample size when A5 fails — that is exactly the gap MC fills
Before the full MC loop, one single simulated dataset (\(n = 200\), skewed errors) that all three languages estimate, to confirm the engines agree to machine precision:
For each error distribution \(d \in \{\text{Normal},\ t(3),\ \chi^2(2)-2\}\) and each \(n \in \{30, 50, 100, 300\}\):
Repeat \(B = 2000\) times: draw fresh \(\mathbf{X}\) and \(\boldsymbol{\varepsilon}\), build \(y\), fit OLS, store \(\hat\beta_1\) and the \(p\)-value of \(H_0\!: \beta_1 = 2\) (the truth — so rejections are false positives)
Summarise each cell: bias, RMSE, empirical size, plus the MC standard error of each
Plot the three metrics against \(n\), one line per error distribution
R runs the 12 cells in parallel with mclapply on 6 cores; Python loops the same grid; Stata runs the most distorted cell (\(\chi^2\), \(n = 30\)) with simulate
print("Endogenous bias converges to Cov(x,e)/Var(x) = 0.5")
Endogenous bias converges to Cov(x,e)/Var(x) = 0.5
Code
captureprogramdrop endogmcprogramdefine endogmc, rclasssyntax [, nobs(integer 100)]clearquietlysetobs`nobs'gen z = rnormal()gene = 0.5*z + sqrt(1 - 0.25)*rnormal() // Corr(x,e) = 0.5gen x = zgeny = 1 + 2*x + equietlyregressy xreturnscalar b1 = _b[x]endsetseed 14159tempfile resultstempnamehpostfile`h' n bias using`results'foreach n in 30 100 300 1000 3000 {quietlysimulate b1 = r(b1), reps(500) nodots: endogmc, nobs(`n')quietlysummarize b1display"n = " %5.0f `n'" bias of b1 = " %7.4f r(mean) - 2post`h' (`n') (r(mean) - 2)}postclose`h'display"Bias stays at ~0.5 no matter how large n gets."use`results', cleartwoway (connected bias n, lcolor(red) mcolor(red) msize(medium)), ///xscale(log) yline(0.5, lpattern(dash) lcolor(gs10)) yline(0, lpattern(dash) lcolor(gs10)) ///ylabel(0(0.1)0.6) xtitle("n (log scale)") ytitle("Bias of b1") ///title("Endogeneity bias does not shrink with n")
n = 30 bias of b1 = 0.4990
n = 100 bias of b1 = 0.4975
n = 300 bias of b1 = 0.4981
n = 1000 bias of b1 = 0.4990
n = 3000 bias of b1 = 0.5003
Bias stays at ~0.5 no matter how large n gets.
Heteroskedasticity — Classical vs Robust Standard Errors
Size\(= \pi(\theta_0)\) — the rejection rate when \(H_0\) is true. Should equal \(\alpha\); if \(\pi(\theta_0) > \alpha\) the test over-rejects (false positives), if \(<\alpha\) it is conservative (wasted power)
Power\(= \pi(\theta_1)\) for \(\theta_1\) under \(H_1\) — the probability of catching a real effect; should rise toward 1 as \(n\) grows or \(\theta_1\) moves away from \(\theta_0\)
Both are estimated by the same MC device: count rejections over B replications
Every published test went through exactly this pipeline:
Derive the statistic and (if possible) its asymptotic distribution
Simulate under \(H_0\) to tabulate critical values when the distribution is non-standard — the Dickey–Fuller tables, MacKinnon response surfaces, KPSS quantiles all come from MC
Size study: simulate under \(H_0\) across sample sizes and nuisance DGPs (heavy tails, GARCH, serial correlation) — a test with fragile size is dead on arrival
Power study: simulate under a grid of alternatives, compare against competitor tests
Report the full size/power tables — this is the “empirical section” of every econometric theory paper
Next: we replay steps 2–4 for the most famous case, the Dickey–Fuller unit root test.
For \(y_t = \rho y_{t-1} + \varepsilon_t\), the test of \(H_0\!: \rho = 1\) uses the \(t\)-ratio \(\tau = \hat\gamma / \text{SE}(\hat\gamma)\) from \(\Delta y_t = \alpha + \gamma y_{t-1} + \varepsilon_t\). Under \(H_0\),
\[
\tau \;\xrightarrow{d}\; \frac{\int_0^1 W(r)\, dW(r)}{\left[\int_0^1 W(r)^2\, dr\right]^{1/2}} \;\;\neq\;\; t \text{ or } \mathcal{N}(0,1)
\]
The limit is a functional of Brownian motion — asymmetric, shifted left, with no closed-form CDF
Using the \(\pm 1.96\) Normal critical values would reject a true unit root far too often
Dickey & Fuller (1979) obtained their critical values by Monte Carlo; MacKinnon (1996) refined them with response-surface regressions on millions of simulations
Simulating Critical Values — Rebuilding the DF Table
Grid: \(\rho \in \{0.80, 0.85, 0.90, 0.95, 1.00\}\) × \(T \in \{50, 100, 200\}\) — the \(\rho = 1\) row is the size row
Per cell: simulate an AR(1) with that \(\rho\), run the ADF test (adf.test / adfuller / dfuller), record whether \(p < 0.05\)
\(B = 200\) replications per cell (MC SE ≤ 0.035 — enough for the qualitative picture; scale up for research)
R distributes the 15 cells over 6 cores with mclapply — each adf.test() call costs ~2–5 ms, so this is exactly the task-size regime where parallelism pays
Read the surface: power rises as \(\rho\) falls and \(T\) grows; near the unit root (\(\rho = 0.95\)) power is painfully low even at \(T = 200\)
Code
library(parallel)library(tseries)power_cell <-function(cell, B =200) { rej <-0Lfor (b in1:B) { y <-numeric(cell$T) y[1] <-rnorm(1)for (t in2:cell$T) y[t] <- cell$rho * y[t -1] +rnorm(1) pv <-suppressWarnings(adf.test(y, alternative ="stationary")$p.value) rej <- rej + (pv <0.05) }data.frame(rho = cell$rho, T = cell$T, power = rej / B)}grid <-expand.grid(rho =c(0.80, 0.85, 0.90, 0.95, 1.00), T =c(50, 100, 200))cells <-lapply(seq_len(nrow(grid)), function(i) list(rho = grid$rho[i], T = grid$T[i]))set.seed(14159)pow <-do.call(rbind, mclapply(cells, power_cell, mc.set.seed =TRUE, mc.cores =6))pow$T_lab <-factor(paste0("T = ", pow$T), levels =paste0("T = ", c(50, 100, 200)))ggplot(pow, aes(x = rho, y = power, color = T_lab, group = T_lab)) +geom_hline(yintercept =0.05, linetype ="dashed", color ="grey55") +geom_line(linewidth =1.3) +geom_point(size =3.5) +scale_x_reverse() +scale_color_manual(values =c("#1D9E75", "#185FA5", "#D85A30"), name =NULL) +scale_y_continuous(labels = scales::percent_format(accuracy =1), limits =c(0, 1.02)) +labs(x ="rho (unit root on the left)", y ="Rejection rate",title ="ADF power function (B = 200 per cell, 6 cores via mclapply)",subtitle ="At rho = 1 the curve shows size (~5%). Near-unit roots are very hard to reject.") + theme_lecture
Code
import numpy as npimport pandas as pdfrom statsmodels.tsa.stattools import adfullerrng = np.random.default_rng(14159)B =200rows = []for T in [50, 100, 200]:for rho in [0.80, 0.90, 0.95, 1.00]: rej =0for b inrange(B): e = rng.standard_normal(T) y = np.empty(T); y[0] = e[0]for t inrange(1, T): y[t] = rho * y[t -1] + e[t] pv = adfuller(y, regression="c", autolag="AIC")[1] rej += pv <0.05 rows.append({"T": T, "rho": rho, "power": rej / B})tab = pd.DataFrame(rows).pivot(index="rho", columns="T", values="power")print("ADF rejection rates (rho = 1.00 row = size):")
A good test keeps its size when the innovation distribution is not the Gaussian it was derived under. We re-run the size row (\(\rho = 1\), \(H_0\) true) with four innovation processes:
print("True beta1 = 2; the MC mean matches 2 x reliability ratio")
True beta1 = 2; the MC mean matches 2 x reliability ratio
Code
captureprogramdrop memcprogramdefine memc, rclasssyntax [, s2(real 0.5)]clearquietlysetobs 500gen xstar = rnormal()gen u = rnormal()gen x = xstar + sqrt(`s2')*u // sqrt(0)*u = 0 handles the no-noise casegeny = 1 + 2*xstar + rnormal()quietlyregressy xreturnscalar b1 = _b[x]endsetseed 14159tempfile resultstempnamehpostfile`h' s2 meanb1 theory using`results'foreach s2 in 0 0.25 0.5 1 {quietlysimulate b1 = r(b1), reps(1000) nodots: memc, s2(`s2')quietlysummarize b1display"sigma_u^2 = `s2' MC mean b1 = " %6.4f r(mean) ///" theory = " %6.4f 2/(1 + `s2')post`h' (`s2') (r(mean)) (2/(1 + `s2'))}postclose`h'use`results', cleartwoway (line theory s2, lcolor(red) lpattern(dash) lwidth(medthick)) /// (scatter meanb1 s2, mcolor(navy) msize(large)), ///yline(2, lpattern(dash) lcolor(gs10)) ///legend(order(2 "MC mean of b1" 1 "Theory: 2 x reliability")) ///xtitle("Measurement-error variance sigma_u^2") ytitle("E[b1]") ///title("Attenuation bias: noisier regressors, smaller coefficients")
5. post `h' (`s2') (r(mean)) (2/(1 + `s2'))
6. }
sigma_u^2 = 0 MC mean b1 = 1.9991 theory = 2.0000
sigma_u^2 = 0.25 MC mean b1 = 1.5996 theory = 1.6000
sigma_u^2 = 0.5 MC mean b1 = 1.3363 theory = 1.3333
sigma_u^2 = 1 MC mean b1 = 1.0024 theory = 1.0000
Staiger & Stock (1997): with weak \(\pi\), 2SLS is biased toward OLS and its distribution is wildly non-Normal. The famous rule of thumb — first-stage \(F > 10\) — comes straight from Monte Carlo evidence.
Code
set.seed(14159)B <-2000; n <-200pi_grid <-c(0.05, 0.1, 0.25, 0.5) # instrument strengthresults <-list()for (pi_z in pi_grid) { b_iv <-numeric(B); F1 <-numeric(B)for (b in1:B) { z <-rnorm(n) err <- MASS::mvrnorm(n, c(0, 0), matrix(c(1, 0.8, 0.8, 1), 2, 2)) v <- err[, 1]; e <- err[, 2] x <- pi_z * z + v y <-1* x + e b_iv[b] <-sum(z * y) /sum(z * x) fs <-summary(lm(x ~ z))$coefficients F1[b] <- (fs["z", "t value"])^2 } results[[length(results) +1]] <-tibble(pi = pi_z, median_F =median(F1),median_bias =median(b_iv) -1,p10 =quantile(b_iv, 0.10), p90 =quantile(b_iv, 0.90),b_iv =list(b_iv))}res_iv <-bind_rows(results)print(res_iv |>select(-b_iv))df_dens <- res_iv |>select(pi, b_iv) |>unnest(b_iv) |>mutate(pi_lab =factor(glue("pi = {pi} (median F = {round(res_iv$median_F[match(pi, res_iv$pi)], 1)})")))ggplot(df_dens, aes(x = b_iv, color = pi_lab)) +geom_density(linewidth =1.1) +geom_vline(xintercept =1, linetype ="dashed", color ="grey40") +coord_cartesian(xlim =c(-1, 3)) +scale_color_manual(values =c("#D85A30", "#BA7517", "#185FA5", "#1D9E75"), name =NULL) +labs(x ="2SLS estimate of beta (true = 1)", y ="Density",title ="Weak instruments: the 2SLS sampling distribution collapses toward the OLS bias",subtitle ="As pi shrinks the distribution widens, skews, and centres away from 1") + theme_lecture
import numpy as nprng = np.random.default_rng(14159)B, n =2000, 200cov = np.array([[1.0, 0.8], [0.8, 1.0]])print(f"{'pi':>6}{'median F':>10}{'median bias':>12}{'10-90% range':>18}")
pi median F median bias 10-90% range
Code
for pi_z in [0.05, 0.1, 0.25, 0.5]: b_iv = np.empty(B); F1 = np.empty(B)for b inrange(B): z = rng.standard_normal(n) err = rng.multivariate_normal([0, 0], cov, size=n) v, e = err[:, 0], err[:, 1] x = pi_z * z + v y = x + e b_iv[b] = (z @ y) / (z @ x) pi_hat = (z @ x) / (z @ z) resid = x - pi_hat * z se_pi = np.sqrt(resid @ resid / (n -1) / (z @ z)) F1[b] = (pi_hat / se_pi) **2 p10, p90 = np.percentile(b_iv, [10, 90])print(f"{pi_z:>6}{np.median(F1):>10.1f}{np.median(b_iv) -1:>12.3f} "f"[{p10:>7.2f}, {p90:>6.2f}]")
7. quietly gen double pi = `p'
8. if `first' == 0 quietly append using `all'
9. quietly save `all', replace
10. local first = 0
11. }
pi = 0.05 median F = 0.7 median 2SLS bias = 0.521
pi = 0.25 median F = 12.3 median 2SLS bias = 0.003
pi = 0.5 median F = 49.0 median 2SLS bias = -0.007
(simulate: wivmc)
\[
y_t = a + b\,x_t + w_t \quad\text{— true } b = 0, \text{ yet…}
\]
Granger & Newbold (1974) discovered by simulation that the \(t\)-test on \(b\) rejects far more than 5% — and Phillips (1986) later proved that the rejection rate tends to 1 as \(T \to \infty\). The MC came first; the theory followed twelve years later. Classic diagnostic: \(R^2 > DW\) signals a spurious regression.
Code
set.seed(14159)B <-1000T_grid <-c(50, 100, 200, 500)rej_rw <-numeric(length(T_grid))rej_iid <-numeric(length(T_grid))for (j inseq_along(T_grid)) { T_len <- T_grid[j] r1 <-0; r2 <-0for (b in1:B) { y <-cumsum(rnorm(T_len)); x <-cumsum(rnorm(T_len)) p_rw <-summary(lm(y ~ x))$coefficients[2, 4] r1 <- r1 + (p_rw <0.05) yi <-rnorm(T_len); xi <-rnorm(T_len) # iid benchmark p_iid <-summary(lm(yi ~ xi))$coefficients[2, 4] r2 <- r2 + (p_iid <0.05) } rej_rw[j] <- r1 / B; rej_iid[j] <- r2 / B}df_sp <-bind_rows(tibble(T = T_grid, rate = rej_rw, case ="Independent random walks"),tibble(T = T_grid, rate = rej_iid, case ="Independent iid series"))ggplot(df_sp, aes(x = T, y = rate, color = case)) +geom_hline(yintercept =0.05, linetype ="dashed", color ="grey55") +geom_line(linewidth =1.3) +geom_point(size =3.5) +scale_color_manual(values =c("#1D9E75", "#D85A30"), name =NULL) +scale_y_continuous(labels = scales::percent_format(accuracy =1), limits =c(0, 1)) +labs(x ="T", y ="Rejection rate of H0: b = 0 (nominal 5%)",title ="Spurious regression: false positives grow with the sample",subtitle ="The iid benchmark stays at 5%; the random-walk pair heads to 100%") + theme_lecture
print("Nominal level 5% - the random-walk pair over-rejects massively.")
Nominal level 5% - the random-walk pair over-rejects massively.
Code
captureprogramdrop spurmcprogramdefine spurmc, rclasssyntax [, tlen(integer 100)]clearquietlysetobs`tlen'geny = sum(rnormal())gen x = sum(rnormal())quietlyregressy xreturnscalar rej = (abs(_b[x]/_se[x]) > invttail(`tlen' - 2, 0.025))returnscalar r2 = e(r2)endsetseed 14159tempfile resultstempnamehpostfile`h' T rate using`results'foreach T in 50 100 200 500 {quietlysimulate rej = r(rej) r2 = r(r2), reps(500) nodots: spurmc, tlen(`T')quietlysummarize rejlocal rr = r(mean)quietlysummarize r2display"T = " %4.0f `T'" rejection rate = " %5.3f `rr'///" mean R2 = " %5.3f r(mean) " (both should be ~0.05 if the test worked)"post`h' (`T') (`rr')}postclose`h'use`results', cleartwoway (connected rate T, lcolor(red) mcolor(red) msize(medium)), ///yline(0.05, lpattern(dash) lcolor(gs10)) ylabel(0(0.2)1) ///xtitle("T") ytitle("Rejection rate of H0: b = 0 (nominal 5%)") ///title("Spurious regression: false positives grow with the sample")
7. post `h' (`T') (`rr')
8. }
T = 50 rejection rate = 0.688 mean R2 = 0.241 (both should be ~0.05 if the test worked)
T = 100 rejection rate = 0.766 mean R2 = 0.231 (both should be ~0.05 if the test worked)
T = 200 rejection rate = 0.834 mean R2 = 0.242 (both should be ~0.05 if the test worked)
T = 500 rejection rate = 0.898 mean R2 = 0.255 (both should be ~0.05 if the test worked)
Parameters throughout: \(S_0 = 100\), \(K = 105\), \(r = 0.03\), \(\sigma = 0.2\), \(T = 1\). Boyle (1977) introduced this method precisely because most exotic payoffs have no closed form — MC is then the only general pricing tool.
Draw \(Z_b \sim \mathcal{N}(0,1)\) and form the terminal price \(S_T^{(b)} = S_0\, e^{(r - \sigma^2/2)T + \sigma\sqrt{T} Z_b}\)
Compute the discounted payoff \(e^{-rT}\max(S_T^{(b)} - K, 0)\)
Average over \(b = 1, \dots, B\); report the MC standard error \(\hat\sigma_{\text{payoff}}/\sqrt{B}\)
Variance reduction — antithetic variates: reuse every draw as \(-Z_b\); the pair’s payoffs are negatively correlated, so their average has lower variance at the same cost
Compare against \(C_{BS}\)
Why antithetic variates work
For a pair \((h(Z), h(-Z))\) the variance of the average is
If \(h\) is monotone in \(Z\) (a call payoff is), the correlation is negative, so the paired estimator beats two independent draws — same number of random numbers, strictly smaller MC error. Other classical tricks in the same family: control variates (use a correlated payoff with known price), importance sampling (oversample the tail that matters), quasi-Monte Carlo (low-discrepancy sequences).
Simulation estimates both by reading quantiles and tail means off B simulated returns. The experiment: a two-asset portfolio (60/40, correlation 0.3, both with daily volatility 1.5%), returns drawn either Normal or Student-\(t(4)\) scaled to the same variance:
\[
R = 0.6\,r_1 + 0.4\,r_2
\]
The Normal model underestimates tail risk: same variance, but \(t(4)\) tails hold far more mass beyond the 1% quantile
ES reacts more strongly than VaR — it looks into the tail, not just at its edge; this is why Basel moved from VaR to ES
Code
set.seed(14159)B <-200000w <-c(0.6, 0.4); vol <-0.015; rho_a <-0.3Sigma <- vol^2*matrix(c(1, rho_a, rho_a, 1), 2, 2)# Normal returnsrets_n <- MASS::mvrnorm(B, mu =c(0, 0), Sigma = Sigma)R_n <- rets_n %*% w# t(4) returns scaled to the same covariance: multiply by sqrt((nu-2)/nu) adjustmentnu <-4chi <-sqrt(rchisq(B, nu) / nu)rets_t <- (MASS::mvrnorm(B, mu =c(0, 0), Sigma = Sigma * (nu -2) / nu)) / chiR_t <- rets_t %*% wrisk <-function(R, a) { q <-quantile(R, a)c(VaR =-q, ES =-mean(R[R <= q]))}for (a inc(0.05, 0.01)) { rn <-risk(R_n, a); rt <-risk(R_t, a)cat(sprintf("alpha = %d%%: Normal VaR %.3f%% ES %.3f%% | t(4) VaR %.3f%% ES %.3f%%\n",round(100* a), 100* rn["VaR"], 100* rn["ES"], 100* rt["VaR"], 100* rt["ES"]))}df_var <-bind_rows(tibble(R =as.numeric(R_n), model ="Normal"),tibble(R =as.numeric(R_t), model ="t(4), same variance"))ggplot(df_var, aes(x =100* R, color = model)) +geom_density(linewidth =1.1) +geom_vline(xintercept =100*quantile(R_n, 0.01), color ="#185FA5", linetype ="dashed", linewidth =1) +geom_vline(xintercept =100*quantile(R_t, 0.01), color ="#D85A30", linetype ="dashed", linewidth =1) +coord_cartesian(xlim =c(-8, 8)) +scale_color_manual(values =c("#185FA5", "#D85A30"), name =NULL) +labs(x ="Portfolio return (%)", y ="Density",title ="Same variance, very different 1% VaR (dashed lines)",subtitle ="The t(4) tail pushes the 1% quantile far beyond the Normal one") + theme_lecture
alpha = 5%: Normal VaR 2.021% ES 2.537% | t(4) VaR 1.838% ES 2.769%
alpha = 1%: Normal VaR 2.864% ES 3.295% | t(4) VaR 3.237% ES 4.519%
Code
import numpy as nprng = np.random.default_rng(14159)B =200000w = np.array([0.6, 0.4]); vol =0.015; rho =0.3Sigma = vol**2* np.array([[1, rho], [rho, 1]])R_n = rng.multivariate_normal([0, 0], Sigma, size=B) @ wnu =4chi = np.sqrt(rng.chisquare(nu, B) / nu)R_t = (rng.multivariate_normal([0, 0], Sigma * (nu -2) / nu, size=B) / chi[:, None]) @ wdef risk(R, a): q = np.quantile(R, a)return-q, -R[R <= q].mean()for a in [0.05, 0.01]: vn, en = risk(R_n, a) vt, et = risk(R_t, a)print(f"alpha = {a:.0%}: Normal VaR {vn:.3%} ES {en:.3%} | "f"t(4) VaR {vt:.3%} ES {et:.3%}")
alpha = 5%: Normal VaR 2.007% ES 2.520% | t(4) VaR 1.837% ES 2.782%
alpha = 1%: Normal VaR 2.844% ES 3.262% | t(4) VaR 3.267% ES 4.564%
Code
print("Fat tails: identical variance, much larger 1% VaR and ES.")
Fat tails: identical variance, much larger 1% VaR and ES.
Code
quietly {clearsetseed 14159setobs 200000 * two correlated normals via Cholesky: r2 = rho*r1 + sqrt(1-rho^2)*z2gen z1 = rnormal()gen z2 = 0.3*z1 + sqrt(1 - 0.09)*rnormal()gen r1n = 0.015*z1gen r2n = 0.015*z2gen Rn = 0.6*r1n + 0.4*r2n * t(4) version, scaled to the same variancegen chi = sqrt(rchi2(4)/4)gen Rt = (0.6*0.015*sqrt(0.5)*z1 + 0.4*0.015*sqrt(0.5)*z2) / chi _pctile Rn, percentiles(1 5)scalar vn1 = -r(r1)scalar vn5 = -r(r2) _pctile Rt, percentiles(1 5)scalar vt1 = -r(r1)scalar vt5 = -r(r2)}display"Normal: 5% VaR = " %6.3f 100*vn5 "% 1% VaR = " %6.3f 100*vn1 "%"display"t(4) : 5% VaR = " %6.3f 100*vt5 "% 1% VaR = " %6.3f 100*vt1 "%"display"Same variance, but the fat-tailed 1% VaR is much larger."quietlygen Rn_pct = 100*Rnquietlygen Rt_pct = 100*Rtlocal qn1 = -100*vn1local qt1 = -100*vt1twoway (kdensity Rn_pct ifinrange(Rn_pct, -8, 8), lcolor(navy) lwidth(medthick)) /// (kdensity Rt_pct ifinrange(Rt_pct, -8, 8), lcolor(red) lwidth(medthick)), ///xline(`qn1', lcolor(navy) lpattern(dash)) xline(`qt1', lcolor(red) lpattern(dash)) ///legend(label(1 "Normal") label(2 "t(4), same variance")) ///xtitle("Portfolio return (%)") ytitle("Density") ///title("Same variance, very different 1% VaR (dashed lines)")
Normal: 5% VaR = 2.015% 1% VaR = 2.844%
t(4) : 5% VaR = 1.836% 1% VaR = 3.228%
Same variance, but the fat-tailed 1% VaR is much larger.
Exercises — Estimation
Modify the Part I π-estimator to compute \(\int_0^1 e^{-x^2}\,dx\) by MC and report the estimate with its MC standard error at \(B = 10^4\) and \(B = 10^6\). Verify the 1/√B law between the two runs.
In the CLT laboratory, replace \(\text{Exp}(1)\) with \(\text{Bernoulli}(0.05)\) (rare events). How large must \(n\) be before the histogram of \(Z_n\) looks Normal? Relate your answer to the Berry–Esseen bound with \(\rho = p(1-p)\left[(1-p)^2 + p^2\right]\).
Extend the OLS study with a fourth error distribution, \(\varepsilon \sim \text{Exp}(1) - 1\), and a lognormal regressor \(x_1 = e^{Z}\). Which change damages t-test size more — skewed errors or a skewed regressor?
In the measurement-error slide, add classical measurement error to \(y\) instead of \(x\). Show by simulation that the slope stays unbiased and explain why only the standard errors grow.
Reproduce the weak-IV densities with 3 instruments instead of 1 (2SLS with over-identification). Does the median bias at \(\pi = 0.05\) get better or worse as instruments are added?
In the AR(1) bias study, apply the analytical correction \(\tilde\rho = \hat\rho + (1 + 3\hat\rho)/T\) and re-compute the bias. How much of the distortion does the first-order correction remove at \(T = 25\)?
Price an Asian call (payoff on the average price over 12 monthly observations) by simulating full GBM paths. There is no closed form — report the MC price, its SE, and the antithetic-variates improvement.
Exercises — Testing
Rebuild the Dickey–Fuller critical values for the no-constant case \(\Delta y_t = \gamma y_{t-1} + \varepsilon_t\) and for the trend case. Compare your 5% quantiles with MacKinnon’s \(-1.95\) and \(-3.41\).
Simulate the size of the naive strategy “use \(\pm 1.96\) Normal critical values for the ADF \(\tau\)”. How many true unit roots does it wrongly reject at \(T = 100\)?
Estimate the ADF power against the local alternative\(\rho_T = 1 - c/T\) for \(c \in \{5, 10, 20\}\) and \(T \in \{100, 200, 400\}\). Confirm that power depends on \(c\) but not on \(T\) — the Pitman-drift signature.
Compare ADF and KPSS by simulation: for \(\rho \in \{0.9, 0.95, 1\}\), tabulate both tests’ rejection rates and show they make opposite errors near the unit root.
Design your own test: propose a statistic for \(H_0\!: \text{median}(X) = 0\) based on the sign of the sample median, simulate its distribution under the null for \(n = 25\), extract 5% critical values, then estimate its power against a shifted \(t(3)\) alternative. Compare with the standard \(t\)-test.
In the spurious-regression study, add a lagged dependent variable to the regression (\(y_t\) on \(y_{t-1}\) and \(x_t\)). Show by simulation that the over-rejection largely disappears and explain why.
Compute the size of the heteroskedasticity study’s classical \(t\)-test as the heteroskedasticity strength varies: \(\varepsilon_i = e^{\kappa x_i} u_i\) for \(\kappa \in \{0, 0.25, 0.5, 1\}\). Plot size against \(\kappa\) for classical and HC3 standard errors.
Davidson, R., & MacKinnon, J. G. (2004). Econometric Theory and Methods, Ch. 21. Oxford University Press.
Efron, B., & Hastie, T. (2016). Computer Age Statistical Inference. Cambridge University Press. DOI: 10.1017/CBO9781316576533
L’Ecuyer, P., & Simard, R. (2007). TestU01: a C library for empirical testing of random number generators. ACM TOMS, 33(4). DOI: 10.1145/1268776.1268777
Granger, C. W. J., & Newbold, P. (1974). Spurious regressions in econometrics. Journal of Econometrics, 2(2), 111–120. DOI: 10.1016/0304-4076(74)90034-7
Phillips, P. C. B. (1986). Understanding spurious regressions in econometrics. Journal of Econometrics, 33(3), 311–340. DOI: 10.1016/0304-4076(86)90001-1
Elliott, G., Rothenberg, T. J., & Stock, J. H. (1996). Efficient tests for an autoregressive unit root. Econometrica, 64(4), 813–836. DOI: 10.2307/2171846
Staiger, D., & Stock, J. H. (1997). Instrumental variables regression with weak instruments. Econometrica, 65(3), 557–586. DOI: 10.2307/2171753
Nickell, S. (1981). Biases in dynamic models with fixed effects. Econometrica, 49(6), 1417–1426. DOI: 10.2307/1911408
Boyle, P. P. (1977). Options: a Monte Carlo approach. Journal of Financial Economics, 4(3), 323–338. DOI: 10.1016/0304-405X(77)90005-8
Black, F., & Scholes, M. (1973). The pricing of options and corporate liabilities. Journal of Political Economy, 81(3), 637–654. DOI: 10.1086/260062
Longstaff, F. A., & Schwartz, E. S. (2001). Valuing American options by simulation. Review of Financial Studies, 14(1), 113–147. DOI: 10.1093/rfs/14.1.113
Artzner, P., Delbaen, F., Eber, J.-M., & Heath, D. (1999). Coherent measures of risk. Mathematical Finance, 9(3), 203–228. DOI: 10.1111/1467-9965.00068
McNeil, A. J., Frey, R., & Embrechts, P. (2015). Quantitative Risk Management, 2nd ed. Princeton University Press.
Thank You
Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab Department of Economics University of Ioannina, Greece