Basic Usage

OLS with high-dimensional fixed effects

Overview

This tutorial covers the fundamentals of using leanfe for fixed effects regression.

NoteUnder the Hood: YOCO + Sparse Matrices

leanfe is fast because it combines two optimizations:

  1. YOCO Compression: For IID/HC1 SEs, rows with identical (regressors, FEs) are grouped together. With binary treatment × 500 firms × 10 years, 100K rows compress to ~10K unique combinations.

  2. Sparse Matrices: FE dummies are 99%+ zeros. Sparse storage skips zeros entirely, making matrix operations 10-20x faster.

Result: 7-25x speedup with identical coefficients and standard errors.

Formula Syntax

y ~ x1 + x2 | fe1 + fe2
  • Left of ~: Dependent variable
  • Between ~ and |: Regressors
  • After |: Fixed effects to absorb
Formula Description
y ~ x Simple OLS
y ~ x \| firm One-way FE
y ~ x \| firm + year Two-way FE
y ~ x1 + x2 \| firm + year Multiple regressors

Example

import numpy as np
import polars as pl
from leanfe import leanfe

np.random.seed(42)
n_obs = 100_000
n_firms = 500
n_years = 10

# True coefficients: treatment=2.5, x1=1.5, x2=0.8
firm_fe = np.random.normal(0, 1, n_firms + 1)
year_fe = np.random.normal(0, 0.5, n_years + 1)

firm_id = np.random.randint(1, n_firms + 1, n_obs)
year = np.random.randint(1, n_years + 1, n_obs)

df = pl.DataFrame({
    "firm_id": firm_id,
    "year": year,
    "treatment": np.random.binomial(1, 0.3, n_obs).astype(float),
    "x1": np.random.normal(0, 1, n_obs),
    "x2": np.random.normal(0, 1, n_obs),
})

df = df.with_columns(
    y = (pl.lit(2.5) * pl.col("treatment") 
         + pl.lit(1.5) * pl.col("x1") 
         + pl.lit(0.8) * pl.col("x2")
         + pl.lit(firm_fe[firm_id])
         + pl.lit(year_fe[year])
         + pl.lit(np.random.normal(0, 1, n_obs)))
)

print(f"Data: {df.shape[0]:,} observations")
Data: 100,000 observations
result = leanfe(
    formula="y ~ treatment + x1 + x2 | firm_id + year",
    data=df,
    vcov="iid"
)

print("Coefficients (true: treatment=2.5, x1=1.5, x2=0.8):")
for var, coef in result['coefficients'].items():
    print(f"  {var}: {coef:.4f} (SE: {result['std_errors'][var]:.4f})")
Coefficients (true: treatment=2.5, x1=1.5, x2=0.8):
  treatment: 2.4928 (SE: 0.0069)
  x1: 1.4968 (SE: 0.0032)
  x2: 0.7953 (SE: 0.0032)

Standard Errors

# IID
result_iid = leanfe(data=df, formula="y ~ treatment + x1 + x2 | firm_id + year", vcov="iid")

# HC1 (robust)
result_hc1 = leanfe(data=df, formula="y ~ treatment + x1 + x2 | firm_id + year", vcov="HC1")

# Clustered
result_cluster = leanfe(
    data=df, formula="y ~ treatment + x1 + x2 | firm_id + year",
    vcov="cluster", cluster_cols=["firm_id"]
)

print("SE for treatment:")
print(f"  IID:       {result_iid['std_errors']['treatment']:.4f}")
print(f"  HC1:       {result_hc1['std_errors']['treatment']:.4f}")
print(f"  Clustered: {result_cluster['std_errors']['treatment']:.4f}")
SE for treatment:
  IID:       0.0069
  HC1:       0.0069
  Clustered: 0.0074

Backends

Backend Speed Memory Best For
Polars (default) Fastest Higher Data fits in RAM
DuckDB Slower Minimal Data larger than RAM
import time

start = time.time()
result_polars = leanfe(data=df, formula="y ~ treatment | firm_id + year", backend="polars")
polars_time = time.time() - start

start = time.time()
result_duckdb = leanfe(data=df, formula="y ~ treatment | firm_id + year", backend="duckdb")
duckdb_time = time.time() - start

print(f"Polars: {polars_time:.2f}s")
print(f"DuckDB: {duckdb_time:.2f}s")
Polars: 0.06s
DuckDB: 0.16s

Next Steps