Conley (1999)
Fast Conley (1999) spatial HAC standard errors for lfe::felm() (OLS and IV/2SLS) and fixest models — feols() (OLS and IV), feglm(), and fepois() — in R.
Documentation site: https://rbluhm.github.io/fastconley/ — function reference, changelog, and the performance vignette.
fastconley is a drop-in replacement for the spatial path of rbluhm/conley that scales to large cross-sections and high-dimensional regressions. vcovSpHAC() still accepts felm fits with the same call signature it always did; numerical equivalence with upstream conley holds at machine precision for the three supported distance functions (the upstream-only dist_fn = "flatearth" option was dropped, and pixel was added as a new optional argument — see the Compatibility section for the full diff). vcovSpHAC() is also now an S3 generic with a fixest method.
The original conley package was written by Richard Bluhm with contributions from Darin Christensen. Some key speed up ideas (sorting on distances and pruning) are from Laurent Berge and Christian Düben.
# install.packages("remotes")
remotes::install_github("rbluhm/fastconley")
fastconley installs alongside the original conley package (different Package: name), so you can keep both libraries loaded in different R sessions to compare results.
The package ships with portable compiler flags (R's default -O2). The templated
inner loops benefit measurably from wider vectorization; for a machine-local
performance build, add to ~/.R/Makevars before installing:
CXXFLAGS += -O3 -march=native -funroll-loops
Results are numerically identical either way; this only affects speed.
vcovSpHAC() is an S3 generic with methods for lfe::felm and fixest fits (feols, feglm, fepois).
lfe::felmlibrary(lfe)
library(fastconley)
est <- felm(y ~ x1 + x2 | unit + time, data = panel, keepCX = TRUE)
V <- vcovSpHAC(
est,
unit = "unit",
time = "time",
lat = "lat",
lon = "lon",
kernel = "bartlett", # or "uniform"
dist_fn = "haversine", # or "spherical", "chord"
dist_cutoff = 500, # km
lag_cutoff = 0, # serial-HAC lag (set > 0 for both)
balanced_pnl = TRUE, # set TRUE only if every period has the same units in the same order, with time-invariant coordinates
ncores = NULL, # NULL = option fastconley.ncores, then all detected cores
pixel = 0 # score-pre-aggregation cell size in km; 0 = exact dedupe only
)
lfe::felm() must be called with keepCX = TRUE so cY and cX are populated; without it, both conley and fastconley will fail to extract the centered design matrix.
IV/2SLS fits — the felm multi-part formula with (endog ~ instruments) — work out of the box and produce the correct 2SLS Conley sandwich: lfe stores the projected (second-stage) design in cX and the structural residuals in residuals, which is exactly what the score construction needs. Fits made through lfe's k-class path (any kclass = argument, including kclass = 1, and therefore LIML) are rejected: lfe then stores the raw endogenous regressors in cX instead of the projected design, so the sandwich would be silently wrong. Ordinary 2SLS is verified against the fixest IV path and an independent dense-R sandwich (see tests/testthat/test-independent-references.R).
For felm, explicitly supplied unit and time names are matched to the corresponding absorbed effects; if a name is not absorbed, the aligned column is recovered from the model data. When both are omitted the data are one cross-sectional block: every pair within the cutoff enters and each row is its own unit; nothing is inferred from the absorbed effects (0.11.0 briefly used the first two absorbed effects, which restricted spatial pairs to within the second effect's groups; that rule is gone). Supplying only time divides the spatial calculation into periods while treating each row as its own unit. Serial HAC (lag_cutoff > 0) requires an explicit unit variable.
fixest::feolslibrary(fixest)
library(fastconley)
est <- feols(y ~ x1 + x2 | unit + time, data = panel, demeaned = TRUE)
fixest::feols() must be called with demeaned = TRUE so the centered design matrix is stored on the fit object — analogous to keepCX = TRUE for felm. The fixest method returns exactly the same VCOV as the felm method on identical data; the dispatch only changes how the centered design and residuals are extracted. Weighted fits (feols(..., weights = ~w) / felm(..., weights = w)) are supported since v0.9.0: the meat scores carry the weights and the bread uses X'WX, the same formula fixest's own weighted Conley vcov uses. If you fit the model in one frame and the data is no longer reachable from the call site, pass data = explicitly.
Since v0.9.0 three conveniences mirror fixest, including its defaults: ssc = TRUE (the default) applies the n / (n − K) small-sample correction — exactly fixest's default Conley correction; its cluster adjustment is a no-op for Conley vcovs — psd_fix = TRUE (the default) clamps negative eigenvalues with fixest::vcov_fix semantics, and lat / lon are auto-detected from common column names when omitted. Note the behavior change: earlier fastconley versions and upstream rbluhm/conley apply no correction; pass ssc = FALSE, psd_fix = FALSE to reproduce their output bit-for-bit.
fixest accepts external covariance functions in its vcov argument. The most natural way to use fastconley in a fixest workflow is to define a one-argument wrapper that records the spatial settings and returns vcovSpHAC():
vcov_fastconley <- function(x) {
vcovSpHAC(
x,
unit = "unit",
time = "time",
lat = "lat",
lon = "lon",
kernel = "bartlett",
dist_fn = "haversine",
dist_cutoff = 500,
lag_cutoff = 1,
balanced_pnl = TRUE,
ncores = NULL,
pixel = 0,
data = panel
)
}
summary(est, vcov = vcov_fastconley)
etable(est, vcov = vcov_fastconley)
The same wrapper can be passed directly at estimation time:
est <- feols(
y ~ x1 + x2 | unit + time,
data = panel,
demeaned = TRUE,
vcov = vcov_fastconley
)
If you will print or export the same model many times, compute the covariance matrix once and pass the matrix to fixest:
V <- vcov_fastconley(est)
summary(est, vcov = V)
etable(est, vcov = V)
This wires fastconley in as a drop-in spatial HAC for the fixest workflow while still computing the textbook within-period spatial + within-unit serial-HAC object (see the panel-comparison section below); it is structurally different from fixest::vcov_conley + vcov_NW − vcov_hetero.
IV fits (feols(y ~ x1 | fe | x2 ~ z)) work out of the box: with demeaned = TRUE, fixest stores the projected (second-stage) design and the structural residuals, which is exactly the 2SLS sandwich. The felm and feols IV paths agree with each other at machine precision.
fixest::feglm / fixest::fepois (since v0.10.0)GLM fits need no estimation flag at all — demeaned = TRUE is only a feols requirement:
est <- fepois(conflict_events ~ temp + precip | cell + year, data = panel)
V <- vcovSpHAC(
est,
unit = "cell", time = "year", lat = "lat", lon = "lon",
kernel = "bartlett", dist_fn = "haversine",
dist_cutoff = 500, lag_cutoff = 2, data = panel
)
The variance is the M-estimation sandwich H⁻¹ B H⁻¹, built from the maximum-likelihood score matrix and inverse Hessian that fixest stores on every fit (lean = TRUE fits are rejected — they carry no scores). Weights, offsets, and the fixed-effect profiling are already folded into the stored scores, and the scores ride through every engine unchanged: pairwise, grid/FFT for rasters, pixel aggregation, and — beyond what fixest::vcov_conley() offers — the panel spatial + serial HAC via lag_cutoff, now available for Poisson/GLM panels (count outcomes à la conflict or mortality, PPML gravity). Any feglm family works; femlm()/feNmlm() fits are not supported.
Time values determine both spatial blocks and serial lags. Numeric-looking character or factor labels are parsed through as.numeric(as.character(time)), so "2000" and "2002" retain a gap of two. Arbitrary labels remain valid for spatial-only blocking, but lag_cutoff > 0 rejects them because serial weights require a numeric time scale.
By default, ncores = NULL uses getOption("fastconley.ncores") when set and otherwise all detected logical cores. During checks, a true-ish _R_CHECK_LIMIT_CORES_ caps the resolved value at two. Explicit values must be a single finite positive number and are rounded to an integer. The compatibility argument maxobsmem is deprecated and ignored (supplying it warns); neighbor = "band" is also deprecated but remains operational for comparisons with the older latitude-band search.
Most users can leave method = "auto". The explicit settings below are useful when you want the call to document the intended data layout, or when you want an early error if the data do not have the structure you expected.
| Data layout | Recommended vcovSpHAC() settings |
Meaning |
|---|---|---|
| Scattered cross-section | Omit unit and time; use lag_cutoff = 0; leave method = "auto" or set method = "pairwise". |
Exact spatial Conley for ordinary point locations. |
| Repeated cross-sections | Supply time but omit unit; keep lag_cutoff = 0. |
Spatial pairs are limited to the same period, with each row treated as its own unit. |
| Repeated locations | Same as scattered data; start with pixel = 0. |
Rows with identical coordinates are combined exactly before the covariance is computed. |
| Approximate location snapping | Set a positive pixel, such as pixel = 5, only after checking sensitivity. |
Nearby locations are snapped to a kilometer grid. This can be faster, but it is an approximation. |
| Regular raster or grid | Use method = "auto", or method = "grid" if you want to require a regular latitude-longitude lattice. |
Exact Conley for complete or partly occupied regular rasters. If method = "grid" and the data are not a lattice, the call errors instead of falling back. |
| Balanced panel with fixed locations | Provide unit and time; set lag_cutoff as needed; set balanced_pnl = TRUE. |
Spatial correlation is computed within period; serial HAC is computed within unit. This is faster when every period has the same units and locations do not move. |
| Unbalanced panel or moving locations | Provide unit and time; set lag_cutoff as needed; leave balanced_pnl = FALSE. |
Uses the general panel route. This is safer when units enter, exit, duplicate within a period, or change coordinates. |
pixel argumentpixel controls score pre-aggregation, inspired by the same argument in fixest::vcov_conley. Before the pair loop runs, observations that share a (possibly pixelated) location within a time period are collapsed into a single aggregated score, so the pair loop runs on n_cases rows instead of all n.
pixel = 0 (default): exact-coordinate dedupe only. Two rows are collapsed iff their (lat, lon) match exactly. The answer is unchanged (machine precision); free win when units share coordinates (panels with time-invariant coords, point-aggregated data).pixel > 0: snap (lat, lon) to a uniform pixel-km grid before the dedupe. Nearby points collapse to a common representative. Rounding each axis can move one point by as much as roughly pixel / sqrt(2); moving both endpoints can change a pair distance by as much as roughly sqrt(2) * pixel. This is a speed/accuracy trade-off, so choose pixel well below dist_cutoff and check sensitivity.If your unit of observation is a raster cell — PRIO-GRID, gridded population or nightlights, climate cells, anything you aggregated to a regular lat/lon grid yourself — the spatial meat is computed by a dedicated engine whose cost is independent of the pair count. On dense rasters with realistic cutoffs this is two to three orders of magnitude faster than enumerating pairs (see the benchmark sections below), with no approximation: the same accept threshold and weights as the pairwise engine, exact for natively gridded data.
You don't have to do anything to use it: method = "auto" (the default)
detects the lattice and engages the engine when a flop-balance estimate
says it wins. A typical workflow from a raster:
library(terra); library(lfe); library(fastconley)
r <- rast("nightlights.tif") # any regular lat/lon raster
d <- as.data.frame(r, xy = TRUE, na.rm = TRUE) # x = lon, y = lat, holes dropped
d$gdp <- ... # your cell-level variables
fit <- felm(log(lights) ~ log(gdp), data = d, keepCX = TRUE)
V <- vcovSpHAC(fit, lat = "y", lon = "x",
kernel = "bartlett", dist_fn = "spherical",
dist_cutoff = 250, data = d)
Requirements and limits:
terra/raster satisfy this by construction.auto switches back to the pairwise
engine on its own.uniform, bartlett) and all three dist_fns are
supported. Rasters spanning the full 360° longitude circle wrap
correctly across the dateline.pixel-style snapping mode to let scattered
points ride the grid engine is on the roadmap.auto falls back to the (correct
but slower) pairwise engine.To see which engine actually ran, pass verbose = TRUE — it prints
pairwise or grid. If you believe your data is gridded but the grid
engine doesn't engage, run once with method = "grid": instead of
falling back it errors with the specific reason (no lattice detected,
degenerate lattice, or a dateline-wrap incompatibility).
rbluhm/conleyThe spatial meat used to be assembled by repeatedly building dense n × n distance matrices and doing a row-by-row sandwich update. fastconley replaces that with:
S_i = e_i X_i, C_i = 0.5 S_i + Σ_{j>i, d_ij ≤ cutoff} w_ij S_j, meat = Σ_i S_i' C_i + transpose. No k × n per-row temporaries.neighbor = "grid" buckets unit-sphere coordinates into cubic cells and inspects only the current and forward-neighbour cells. Candidate work therefore tracks accepted pairs without pole or dateline special cases. The older latitude-band scan remains available as deprecated neighbor = "band" compatibility behavior.row_ptr / col_idx / weight only when balanced_pnl = TRUE, so the graph can be built from the first period and reused. The general/unbalanced path streams accepted pairs directly into score accumulators and does not retain a neighbor list.FastSerialHacPanel call, instead of an R lapply that called into C++ once per unit.(dist_fn, kernel) dispatch. The per-pair screen + distance + kernel-weight stack is specialised at compile time for each of the six (distance, kernel) combinations, so the inner loop has no runtime branches.acos in the pairwise path.s_i = e_i · X_i are stored row-major in a flat buffer, and the coordinate cache and scores are permuted into cell order before the worker runs, keeping hot-loop reads sequential.A CoordCache precomputes angular coordinates and 3D unit-vector coordinates once per call. CSR construction and meat accumulation are parallelised with a small std::thread pool inside the engine header (src/conley_core.h, shared with the Stata port); the chunked reduction is deterministic, so results are bit-identical for any ncores.
Numerical results match upstream rbluhm/conley to machine precision on dist_fn ∈ {haversine, spherical, chord} × both kernels × cross-section / balanced / unbalanced regimes; against upstream conley the spatial path is roughly two orders of magnitude faster (the original CSR refactor was already 150-170× on n_obs = 8,000–32,000 panels, and the recent templating + dot-product + score-permutation work adds another ~5×). The live benchmark below is against fixest.
Since v0.5.0 candidate enumeration uses a 3D cell grid by default (neighbor = "grid"); the tables below were measured with the v0.4 latitude-band scan and remain its reference. On top of those numbers the grid gives (single-threaded, FastSpatialMeat, k = 10): 8.6–25× on a global-extent 1M-point cross-section at 100 km, 4.5× on CONUS 100k at 100 km, 2.1–2.3× on CONUS 100k at 500 km (true-pair accumulation dominates there), 3.6× on a 200k-unit balanced CSR build, and 16-thread scaling improves from ~5.3× to ~7.9×. The gain grows with geographic extent and shrinking cutoff because grid candidate work is proportional to accepted pairs (~3–4 candidates per accept) rather than to the latitude band (~2·L_lon/(π·r) per accept). Pair sets and weights are identical to the band scan; results differ only by floating-point summation order.
Since v0.6.0 results are bit-identical across ncores values (deterministic chunked reduction), the C++ entry points alias R memory with zero input copies (peak RSS −27 to −33% on large runs), haversine gains an exact dot-product pre-screen (bartlett/haversine ~1.4× single-threaded), and csr_weight = "float" optionally halves balanced-path bartlett weight storage.
method = "auto"/"grid")For the uniform kernel on a regular lat/lon lattice (raster data), the meat
reduces to exact sliding-window sums whose cost is independent of the pair
count. On 2.25M cells at ~1.1 km with a 250 km cutoff (~1.8×10¹¹ pairs):
0.81 s vs 244 s for the 16-core pairwise engine (302×, error 5e-15); a
9M-cell continental raster takes 3.5 s. method = "auto" (default) engages
it only when a lattice is detected and the kernel is uniform — scattered
data and bartlett stay on the pairwise engine. No approximation: natively
gridded data gets the exact answer for the selected distance geometry.
The grid engine now covers kernel = "bartlett": on a lattice the
per-ring-pair inner sum becomes a 1D convolution (the weight varies with
the longitude offset), computed via FFT with cached per-ring score
spectra. Same C1-study raster (2.25M cells, 250 km, ~1.8×10¹¹ pairs),
bartlett/spherical: 2.9 s vs 710 s for the 16-core pairwise engine
(242×, error ~1e-13); even single-threaded (20.5 s) it beats the
16-core pairwise engine 35×. Agreement with the pairwise engine is
~1e-15 (haversine) to ~1e-12 (spherical/chord — inherent acos/sqrt
conditioning at near-zero distances, not algorithm error), and results
remain bit-identical across ncores.
v0.8.0 also fixes a v0.7.0 bug: on rasters spanning the full 360°
longitude circle the grid engine clamped windows at the lattice edge and
silently missed pairs across the dateline (~5e-3 relative error on a
global test raster). Windows now wrap correctly for both kernels; if wrap
would be needed but the lon step doesn't tile 360° evenly, method = "grid" errors informatively and method = "auto" falls back to the
pairwise engine.
fixest::vcov_conley (v0.6.1, large data)Post-estimation only, kernel = "uniform", great-circle distance, k = 10,
pixel = 0. Both sides fit the model once outside the timer; only the VCOV
call is wallclock-measured (min over reps). fixest 0.14.1 with
distance = "spherical", ssc(adj = FALSE, cluster.adj = FALSE),
vcov_fix = FALSE. Machine: i7-1360P (16 threads), 32 GB. Earlier
(v0.4-era, n ≤ 100k) tables live in the git history; at those sizes the gap
was 3–4×.
Accuracy note. fastconley matches a brute-force great-circle uniform
meat to ~1e-15. fixest 0.14.1's distance = "spherical" deviates from the
brute-force reference by ~2e-2 in the VCOV on these configs (its distance
formula flips pairs near the cutoff boundary), so the two tools' results
agree only to ~1e-2–1e-3. The workload is the same; fastconley is the
numerically exact one.
Cross-section (CONUS-sized box, uniform random coordinates):
| n | cutoff | threads | fastconley | fixest | speedup | fc / fx peak RSS |
|---|---|---|---|---|---|---|
| 100,000 | 500 km | 1 | 2.53 s | 27.2 s | 10.7× | 205 / 140 MB |
| 100,000 | 500 km | 16 | 0.40 s | 4.30 s | 10.8× | |
| 250,000 | 500 km | 1 | 15.8 s | 170.8 s | 10.8× | 388 / 223 MB |
| 250,000 | 500 km | 16 | 2.34 s | 45.8 s | 19.6× | |
| 500,000 | 100 km | 1 | 3.14 s | 77.4 s | 24.7× | 629 / 393 MB |
| 500,000 | 100 km | 16 | 0.66 s | 24.8 s | 37.8× | |
| 1,000,000 | 100 km | 1 | 12.1 s | 300.7 s | 24.9× | 1.20 / 0.64 GB |
| 1,000,000 | 100 km | 16 | 2.16 s | 98.8 s | 45.8× |
The gap widens with n, with shrinking cutoff, and with threads: grid
candidate enumeration is proportional to accepted pairs (~3–4 candidates
per accept regardless of extent), and since v0.6.1 the prep path (sort,
coordinate cache, gathers) is parallel too. fixest holds a smaller peak
RSS (fastconley carries the score matrix plus one sorted copy); both stay
far below dense-matrix approaches.
This is a structural comparison, not a numerical one. fastconley computes the textbook panel Spatial HAC = within-period spatial Conley + within-unit Newey-West serial (Hsiang 2010; Christensen & Fetzer 2015; the contract upstream rbluhm/conley always honored). fixest::vcov_conley is a cross-section object; its author's recommended panel SHAC composition is vcov_conley + vcov_NW − vcov_hetero, which sums over cross-period spatial pairs that fastconley excludes. The two VCOVs differ by O(s_i·s_jᵀ) on every same-unit cross-time pair, so the numerical answers disagree by design. The fair comparison is wallclock for "the panel SHAC each tool delivers":
Balanced panel, time-invariant coordinates, 500 km cutoff, lag = 1,
kernel = "uniform", dist_fn = "spherical", pixel = 0 (fixest timed as
the full sum-of-3):
| n_unit | T | n_obs | threads | fastconley | fixest sum-of-3 | fc vs fx |
|---|---|---|---|---|---|---|
| 50,000 | 4 | 200,000 | 1 | 2.35 s | 6.82 s | 2.9× |
| 50,000 | 4 | 200,000 | 16 | 1.13 s | 1.05 s | 0.9× (fx faster) |
| 150,000 | 10 | 1,500,000 | 1 | 30.9 s | 60.9 s | 2.0× |
| 150,000 | 10 | 1,500,000 | 16 | 10.9 s | 15.7 s | 1.4× |
fastconley honors the within-period rule, so it runs the n_unit × n_unit
spatial sum once per period plus a serial term, while fixest's
exact-coordinate dedupe collapses the panel into a single spatial pass —
fastconley does ~T× more spatial work by construction and still wins on
single-threaded wallclock at scale (the v0.4-era crossover at n_obs ≈ 80k is
long gone). The 150k × 10 run holds the balanced CSR in RAM (~3.9 GB at
500 km / 6.4×10⁸ pairs); use csr_weight = "float" (bartlett) or a tighter
cutoff if memory-bound. See inst/FIXEST_COMPARISON.md for the full structural comparison and the literature pointers.
rbluhm/conleyNumerical results match upstream to machine precision for dist_fn ∈ {haversine, spherical, chord} and both kernels.
dist_fn = "flatearth" was removed. Upstream's equirectangular formula is not symmetric in (i, j) and produces order-dependent results; fastconley no longer accepts it. Use dist_fn = "spherical" instead — with the 3D dot-product threshold it runs without trig in the inner loop and is now no slower than the old flatearth path was.
balanced_pnl = TRUE is now stricter. Upstream silently produced wrong results when units had time-varying coordinates; fastconley errors with a clear message instead. It also errors on unequal period sizes, repeated unit-period rows, or changing unit membership. Use balanced_pnl = FALSE explicitly for those data.
R ≥ 4.0 with R's standard C++ toolchain (the engine uses std::thread; supported Windows Rtools releases provide the needed threading runtime). Imports data.table and Rcpp and links against RcppArmadillo; lfe and fixest are suggested — you need whichever one fits your models.
The estimator is from Conley, T. G. (1999). "GMM estimation with cross sectional dependence." Journal of Econometrics 92(1):1–45.
MIT.