M3 Step 1: Robust Standard Errors (HC0–HC3)

Date: 2026-03-26 Milestone: 3 — Core Econometrics Scope: Heteroskedasticity-consistent (sandwich) standard errors for OLS and 2SLS

Motivation

8/10 benchmark applied econ papers use robust or clustered standard errors. Without them, even correct coefficients produce wrong inference. Robust SEs are also the foundation for clustered SEs (same sandwich machinery, different meat matrix) — building this right now makes clustered SEs a small extension later.

What Changes

1. New module: src/core/stats/sandwich.ts

Pure function computing the heteroskedasticity-consistent variance-covariance matrix.

type HCType = 'HC0' | 'HC1' | 'HC2' | 'HC3';

function computeRobustVcov(
  X: number[][],
  residuals: number[],
  XtXinv: number[][],
  type: HCType
): number[][];

Math:

All HC types use the sandwich form: V = (X'X)⁻¹ · M · (X'X)⁻¹

The meat matrix M differs by type:

Where eᵢ are OLS residuals and hᵢ = Xᵢ (X'X)⁻¹ Xᵢ' are hat matrix diagonal elements (leverage values). HC0/HC1 don't need leverage, so skip hat computation for those types.

Inputs: The function reuses the already-computed (X'X)⁻¹ from the OLS pass — no extra QR decomposition. The design matrix X and residuals are already available inside computeRegression.

Returns: Full k×k variance-covariance matrix (not just diagonal), since downstream uses (clustered SEs, Wald tests) need the full matrix.

2. Modify: src/core/stats/regression.ts

computeRegression() gains an optional vcovType parameter:

function computeRegression(
  formula: Formula,
  dataset: Dataset,
  vcovType?: HCType
): RegressionResult;

When vcovType is specified:

  1. Compute β, residuals, and (X'X)⁻¹ as usual (unchanged)
  2. Call computeRobustVcov(X, residuals, XtXinv, vcovType) to get robust vcov
  3. Replace classical SEs with √(diag(robustVcov))
  4. Recompute t-statistics and p-values from robust SEs
  5. Set result.vcovType on the result

Classical model statistics (R², adj-R², residual SE) are unchanged — they don't depend on the vcov.

The F-statistic should use the robust vcov when available (Wald F-test: β' V⁻¹ β / k), but this is a refinement — initial implementation can keep the classical F and note it in result metadata.

3. Modify: src/core/stats/regression-2sls.ts

compute2SLS() gains the same optional vcovType parameter. The sandwich form applies the same way, using 2SLS residuals and the projection-corrected X. Same meat matrix formulas, different residuals.

4. Modify: src/core/stats/types.ts

Add vcovType to RegressionResult:

export type VcovType = 'classical' | 'HC0' | 'HC1' | 'HC2' | 'HC3';

export interface RegressionResult {
  type: 'regression';
  coefficients: CoefficientRow[];
  rSquared: number;
  adjustedRSquared: number;
  fStatistic: number;
  fPValue: number;
  dfModel: number;
  dfResidual: number;
  residualStandardError: number;
  residuals: number[];
  fittedValues: number[];
  ivDiagnostics?: IVDiagnostics;
  vcovType: VcovType;                // NEW — always set, defaults to 'classical'
}

Export VcovType and the HCType subset (excludes 'classical') for use in params.

5. Modify: src/core/pipeline/types.ts

Add vcovType to LinearModelParams:

export interface LinearModelParams {
  formula: Formula;
  data: string;
  estimator: 'ols' | '2sls';
  endogenous?: string[];
  instruments?: string[];
  fixedEffects?: string[];
  vcovType?: 'HC0' | 'HC1' | 'HC2' | 'HC3';  // NEW — undefined = classical
}

6. Modify: src/core/pipeline/param-schema.ts

Add vcovType ParamDef to the linear-model schema:

{
  key: 'vcovType',
  label: 'Std. Errors',
  kind: 'select',
  multivaluable: true,          // can vary across specifications
  defaultValue: 'classical',
  options: [
    { value: 'classical', label: 'Classical (iid)' },
    { value: 'HC0', label: 'Robust (HC0)' },
    { value: 'HC1', label: 'Robust (HC1)' },
    { value: 'HC2', label: 'Robust (HC2)' },
    { value: 'HC3', label: 'Robust (HC3)' },
  ],
}

This makes vcovType editable in the property sheet and variable across specifications in the spec explorer. Comparing classical vs robust SEs for the same model is a natural spec curve use case.

7. Modify: src/core/pipeline/executor.ts

Pass vcovType from LinearModelParams through to computeRegression() / compute2SLS(). One extra argument.

8. Modify: src/core/parsers/r/recognizer.ts

Recognize vcovType from inline arguments on feols() and felm():

feols patterns:

felm pattern:

lm pattern:

Implementation: in recognizeFeolsFromSource(), extract the vcov or se argument value after parsing the formula parts. Map string values to HCType.

9. UI changes (minimal)

What Doesn't Change

Testing

R validation script

library(sandwich); library(lmtest)

# Generate test data
set.seed(42)
n <- 100
x1 <- rnorm(n)
x2 <- rnorm(n)
e <- rnorm(n) * (1 + abs(x1))  # heteroskedastic errors
y <- 2 + 3*x1 - 1.5*x2 + e

d <- data.frame(y, x1, x2)
mod <- lm(y ~ x1 + x2, data=d)

# Extract for each HC type
for (hc in c("HC0", "HC1", "HC2", "HC3")) {
  ct <- coeftest(mod, vcov=vcovHC(mod, type=hc))
  cat(hc, ":\n")
  print(ct)
}

Test cases

  1. HC0–HC3 with heteroskedastic data — verify SEs, t-stats, p-values for each type match R within tolerance (<0.00005 for statistics, <0.00001 for p-values)
  2. HC types differ from each other — verify HC0 ≠ HC1 ≠ HC2 ≠ HC3 (they should for heteroskedastic data)
  3. Homoskedastic data — robust SEs should be close to classical (sanity check)
  4. Multi-regressor model — verify full coefficient vector, not just intercept
  5. 2SLS with robust SEs — verify robust 2SLS SEs match R's ivreg with vcov.=vcovHC
  6. Integration testfeols(y ~ x, data=d, vcov='hetero') parses → maps → executes with HC1 SEs
  7. Spec explorer — two specifications differing only in vcovType group correctly on a "Std. Errors" axis

Deferred