M3: Clustered Standard Errors (Multi-Way)

M3: Clustered Standard Errors (Multi-Way)

Date: 2026-04-01 Status: Design Depends on: Robust SEs (HC0–HC3), FE computation (demeaning + encodeFEColumn)

Motivation

Nearly every panel/DiD paper clusters standard errors. Without them, even correct coefficients produce wrong inference. The HC sandwich machinery (sandwich.ts) is already built — clustering uses the same sandwich form with a different meat matrix. Multi-way clustering (Cameron-Gelbach-Miller 2011) is a thin combinator on top of one-way, so we implement both at once.

Unlocks: feols(y ~ x | fe, vcov = ~state), felm(y ~ x | fe | 0 | state + year), and all combinations of one-way and multi-way clustering for OLS and 2SLS.


1. Type System Changes

core/stats/types.ts

Extend VcovType to include clustering:

export type VcovType = 'classical' | HCType | 'cluster';

Add cluster metadata type and field on RegressionResult:

export interface ClusterInfo {
  name: string;
  nClusters: number;
}

export interface RegressionResult {
  // ... existing fields unchanged
  vcovType: VcovType;
  clusterInfo?: ClusterInfo[];  // one entry per cluster dimension
}

vcovType: 'cluster' and clusterInfo always appear together. clusterInfo is absent when vcovType !== 'cluster'.

core/pipeline/types.ts

Add clusterVars to LinearModelParams:

export interface LinearModelParams {
  formula: Formula;
  data: string;
  estimator: 'ols' | '2sls';
  endogenous?: string[];
  instruments?: string[];
  fixedEffects?: string[];
  vcovType?: HCType;         // undefined = classical; mutually exclusive with clusterVars
  clusterVars?: string[];    // NEW — cluster variable names
}

vcovType (HC type) and clusterVars are mutually exclusive on LinearModelParams. When clusterVars is present, the executor ignores vcovType and computes clustered SEs instead.

core/pipeline/types.ts — NODE_PORTS

No changes needed. LinearModelNode already has data input and model output ports.


2. Sandwich Module — computeClusteredVcov

New exports in core/stats/sandwich.ts

export function computeClusteredVcov(
  X: Matrix,
  residuals: number[],
  XtXinv: Matrix,
  clusterDims: { groupIds: number[]; nGroups: number }[],
): Matrix

One-way core: computeOneWayClusteredMeat

Private function. For cluster dimension with G groups:

  1. Allocate score matrix scores[g] as p-length zero vectors, one per group.
  2. For each observation i in group g: accumulate scores[g][j] += X[i][j] * e[i] for all j.
  3. Compute meat: M = Σ_g scores[g] ⊗ scores[g]' (outer product sum).
  4. Apply small-sample correction: c = G/(G-1) · (n-1)/(n-k) where k = X[0].length.
  5. Return c · (X'X)⁻¹ · M · (X'X)⁻¹.

This matches the default correction in fixest (dof(adj = TRUE, fixef.K = "none")), Stata’s vce(cluster), and R’s sandwich::vcovCL.

Complexity: O(n·p + G·p²) — one pass over observations to accumulate scores, one pass over groups for outer products. Dominated by the existing OLS computation for any realistic dataset.

Multi-way combinator: Cameron-Gelbach-Miller inclusion-exclusion

For D cluster dimensions, there are 2^D - 1 non-empty subsets. For each subset S:

  1. Compute intersection cluster IDs: For each observation, the intersection group is the unique combination of group IDs across all dimensions in S. Encode by concatenating groupId values into a composite key, then re-encode to sequential IDs using a Map (same approach as encodeFEColumn).
  2. Compute one-way clustered vcov for the intersection cluster assignment.
  3. Inclusion-exclusion sign: (-1)^(|S|+1). Single dimensions are added, pairwise intersections are subtracted, triple intersections are added, etc.

Final result: V = Σ_S sign(S) · V_S

For two-way (D=2): V = V₁ + V₂ - V₁₂ (3 one-way computations). For three-way (D=3): V = V₁ + V₂ + V₃ - V₁₂ - V₁₃ - V₂₃ + V₁₂₃ (7 one-way computations).

Eigenvalue check: The resulting matrix should be positive semi-definite. In practice, Cameron-Gelbach-Miller can produce non-PSD matrices with few clusters. We don’t correct for this (consistent with fixest default behavior), but if any diagonal element is negative, clamp it to a small positive value and log a warning. This is extremely rare with real data.

No changes to computeRobustVcov or computeRobustF

The existing HC functions remain unchanged. computeRobustF works with any vcov matrix and will be called with the clustered vcov output.


3. Recognizer Changes

feolsrecognizeFeolsFromSource

Extend the existing vcov=/se=/cluster= argument scanning (lines 338–353) to detect formula-style cluster specifications.

Current behavior: Regex matches vcov='hetero', se='HC1', etc. as string literals.

New behavior: Before checking for string HC types, check for formula-style cluster specs:

  1. vcov = ~stateclusterVars: ['state']
  2. vcov = ~state + yearclusterVars: ['state', 'year']
  3. cluster = ~stateclusterVars: ['state']
  4. cluster = ~state + yearclusterVars: ['state', 'year']
  5. se = 'cluster' → look for a separate cluster = ~var argument

Detection: Match ^(?:vcov|cluster)\s*=\s*~\s*(.+) on each arg part. If matched, parse the RHS with parseRHSTerms() and extract main-effect variable names. This reuses the same formula parsing used for FE extraction.

When clusterVars is detected, do not set vcovType — they are mutually exclusive.

Emit: args.clusterVars = ['state'] (or ['state', 'year'] for multi-way).

felmrecognizeFelmFromSource

Replace // Part 4: cluster — ignored (line 478) with actual extraction:

felm(y ~ x | fe | 0 | state)         → clusterVars: ['state']
felm(y ~ x | fe | 0 | state + year)  → clusterVars: ['state', 'year']

When pipeParts.length >= 4 and part 4 is not '0': 1. Parse with parseRHSTerms(pipeParts[3].trim()) 2. Extract main-effect variable names (same pattern as FE extraction) 3. Set args.clusterVars = varNames

No changes to ivreg

ivreg() has no native cluster argument. Clustered SEs for ivreg come from vcovCL() post-estimation — that’s a separate recognizer pattern (backlog item).


4. Mapper

core/pipeline/mapper.tscreateNode linear-model case

Add clusterVars threading, same pattern as vcovType:

...(call.args['clusterVars'] ? { clusterVars: call.args['clusterVars'] as string[] } : {}),

5. Executor

core/pipeline/executor.tsrealLinearModel

Thread clusterVars to both computeRegression and compute2SLS:

// 2SLS path
return compute2SLS(
  lmNode.params.formula, inputDataset,
  lmNode.params.endogenous, lmNode.params.instruments,
  lmNode.params.vcovType, fe,
  lmNode.params.clusterVars,  // NEW
);

// OLS path
return computeRegression(
  lmNode.params.formula, inputDataset,
  lmNode.params.vcovType, fe,
  lmNode.params.clusterVars,  // NEW
);

6. Regression Execution

core/stats/regression.tscomputeRegression

New optional parameter: clusterVars?: string[].

When clusterVars is present:

  1. Encode cluster dimensions: For each variable name in clusterVars, get the column from dataset, filter to validRows, call encodeFEColumn(values, validRows){ groupIds, nGroups }. Collect as clusterDims.
  2. Compute clustered vcov: Call computeClusteredVcov(X, residuals, XtXinv, clusterDims).
  3. Compute robust F: Call computeRobustF(beta, clusteredVcov, hasIntercept) — same as HC path.
  4. Build ClusterInfo: Map each dim to { name: varName, nClusters: dim.nGroups }.
  5. Set result fields: vcovType: 'cluster', clusterInfo: [...].

The cluster path replaces the HC path — they are mutually exclusive. If both vcovType and clusterVars are somehow present, clusterVars wins.

core/stats/regression-2sls.tscompute2SLS

Same pattern. New optional parameter clusterVars?: string[].

When present, use XProj (the projected design matrix from the 2SLS correction) instead of raw X in the clustered sandwich. This is the same substitution already done for HC robust SEs on line 340:

// Current (HC):
robustVcov = computeRobustVcov(XProj, residuals, XProjTXProjInv, vcovType);

// Clustered:
clusteredVcov = computeClusteredVcov(XProj, residuals, XProjTXProjInv, clusterDims);

Cluster encoding uses the same validRows filtering as the OLS path.


7. Parameter Schema

core/pipeline/param-schema.ts

Add clusterVars to PARAM_SCHEMAS['linear-model']:

{
  key: 'clusterVars',
  label: 'Cluster',
  kind: 'identifier',
  multivaluable: true,
},

This displays cluster variable names in the property sheet. Read-only for now (editable params are M6).

No changes to the vcovType select options — HCType values remain as-is. The “Clustered” display in UI comes from detecting clusterVars presence on the node params, not from a vcovType select value. When clusterVars is set, the property sheet shows Std. Errors: Clustered (state) by reading both fields.


8. UI / Display

Results panel

The results panel already shows vcovType. When vcovType === 'cluster':

Comparison table

The SE type indicator in comparison tables should display the cluster variable names. When comparing models with different SE types (e.g., classical vs clustered), this creates a natural axis for the spec explorer.

Spec curve

No changes needed. The spec curve already handles vcovType as a parameter axis. 'cluster' becomes a new value on that axis.


9. Tests

Unit: sandwich.test.ts

One-way cluster test: Small dataset (~20 rows, ~4 clusters). Compute clustered SEs and validate against R:

library(fixest)
d <- data.frame(y = ..., x = ..., cl = ...)
m <- feols(y ~ x, data = d, vcov = ~cl)
summary(m)$se  # expected SEs

Two-way cluster test: Same dataset with a second cluster variable. Validate against:

m2 <- feols(y ~ x, data = d, vcov = ~cl1 + cl2)
summary(m2)$se

Tolerance: < 0.00005 for SEs (same as existing stats tests).

Unit: recognizer.test.ts

Unit: regression.test.ts

Unit: regression-2sls.test.ts

Integration: pipeline/integration.test.ts

End-to-end: R code string → parse → recognize → map → execute → verify clustered SEs match R.

feols(y ~ x | state, vcov = ~state, data = d)

10. Degree-of-Freedom Correction

The small-sample correction for clustered SEs matches fixest defaults:

c = G/(G-1) · (n-1)/(n-k)

Where: - G = number of clusters in the dimension - n = number of observations - k = number of estimated parameters (columns of X, after FE absorption if applicable)

For multi-way, each one-way computation in the inclusion-exclusion uses the correction for its own cluster count. The intersection cluster’s G is the number of unique combinations.

This matches fixest::feols with default dof() settings and is the most common convention. Stata uses G/(G-1) only (no (n-1)/(n-k) factor), but the fixest convention is standard in modern applied econ.


Deferred