diff-diff 1.4.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {diff_diff-1.4.0 → diff_diff-2.0.1}/PKG-INFO +19 -18
  2. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/__init__.py +13 -1
  3. diff_diff-2.0.1/diff_diff/_backend.py +64 -0
  4. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/bacon.py +12 -60
  5. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/estimators.py +9 -8
  6. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/linalg.py +151 -4
  7. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/staggered.py +151 -26
  8. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/sun_abraham.py +2 -22
  9. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/twfe.py +2 -18
  10. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/utils.py +176 -9
  11. {diff_diff-1.4.0 → diff_diff-2.0.1}/pyproject.toml +14 -5
  12. diff_diff-2.0.1/rust/Cargo.lock +2396 -0
  13. diff_diff-2.0.1/rust/Cargo.toml +39 -0
  14. diff_diff-2.0.1/rust/src/bootstrap.rs +223 -0
  15. diff_diff-2.0.1/rust/src/lib.rs +33 -0
  16. diff_diff-2.0.1/rust/src/linalg.rs +229 -0
  17. diff_diff-2.0.1/rust/src/weights.rs +220 -0
  18. diff_diff-1.4.0/diff_diff.egg-info/PKG-INFO +0 -2255
  19. diff_diff-1.4.0/diff_diff.egg-info/SOURCES.txt +0 -38
  20. diff_diff-1.4.0/diff_diff.egg-info/dependency_links.txt +0 -1
  21. diff_diff-1.4.0/diff_diff.egg-info/requires.txt +0 -14
  22. diff_diff-1.4.0/diff_diff.egg-info/top_level.txt +0 -1
  23. diff_diff-1.4.0/setup.cfg +0 -4
  24. diff_diff-1.4.0/tests/test_bacon.py +0 -679
  25. diff_diff-1.4.0/tests/test_diagnostics.py +0 -674
  26. diff_diff-1.4.0/tests/test_estimators.py +0 -2719
  27. diff_diff-1.4.0/tests/test_honest_did.py +0 -699
  28. diff_diff-1.4.0/tests/test_linalg.py +0 -422
  29. diff_diff-1.4.0/tests/test_power.py +0 -691
  30. diff_diff-1.4.0/tests/test_prep.py +0 -794
  31. diff_diff-1.4.0/tests/test_pretrends.py +0 -813
  32. diff_diff-1.4.0/tests/test_staggered.py +0 -1358
  33. diff_diff-1.4.0/tests/test_sun_abraham.py +0 -732
  34. diff_diff-1.4.0/tests/test_triple_diff.py +0 -869
  35. diff_diff-1.4.0/tests/test_utils.py +0 -1270
  36. diff_diff-1.4.0/tests/test_visualization.py +0 -284
  37. diff_diff-1.4.0/tests/test_wild_bootstrap.py +0 -804
  38. {diff_diff-1.4.0 → diff_diff-2.0.1}/README.md +0 -0
  39. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/diagnostics.py +0 -0
  40. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/honest_did.py +0 -0
  41. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/power.py +0 -0
  42. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/prep.py +0 -0
  43. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/pretrends.py +0 -0
  44. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/results.py +0 -0
  45. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/synthetic_did.py +0 -0
  46. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/triple_diff.py +0 -0
  47. {diff_diff-1.4.0 → diff_diff-2.0.1}/diff_diff/visualization.py +0 -0
@@ -1,14 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 1.4.0
4
- Summary: A library for Difference-in-Differences causal inference analysis
5
- Author: diff-diff contributors
6
- License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/igerber/diff-diff
8
- Project-URL: Documentation, https://diff-diff.readthedocs.io
9
- Project-URL: Repository, https://github.com/igerber/diff-diff
10
- Project-URL: Issues, https://github.com/igerber/diff-diff/issues
11
- Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
3
+ Version: 2.0.1
12
4
  Classifier: Development Status :: 5 - Production/Stable
13
5
  Classifier: Intended Audience :: Science/Research
14
6
  Classifier: Operating System :: OS Independent
@@ -18,20 +10,28 @@ Classifier: Programming Language :: Python :: 3.10
18
10
  Classifier: Programming Language :: Python :: 3.11
19
11
  Classifier: Programming Language :: Python :: 3.12
20
12
  Classifier: Topic :: Scientific/Engineering :: Mathematics
21
- Requires-Python: >=3.9
22
- Description-Content-Type: text/markdown
23
13
  Requires-Dist: numpy>=1.20.0
24
14
  Requires-Dist: pandas>=1.3.0
25
15
  Requires-Dist: scipy>=1.7.0
16
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
17
+ Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
18
+ Requires-Dist: black>=23.0 ; extra == 'dev'
19
+ Requires-Dist: ruff>=0.1.0 ; extra == 'dev'
20
+ Requires-Dist: mypy>=1.0 ; extra == 'dev'
21
+ Requires-Dist: sphinx>=6.0 ; extra == 'docs'
22
+ Requires-Dist: sphinx-rtd-theme>=1.0 ; extra == 'docs'
26
23
  Provides-Extra: dev
27
- Requires-Dist: pytest>=7.0; extra == "dev"
28
- Requires-Dist: pytest-cov>=4.0; extra == "dev"
29
- Requires-Dist: black>=23.0; extra == "dev"
30
- Requires-Dist: ruff>=0.1.0; extra == "dev"
31
- Requires-Dist: mypy>=1.0; extra == "dev"
32
24
  Provides-Extra: docs
33
- Requires-Dist: sphinx>=6.0; extra == "docs"
34
- Requires-Dist: sphinx-rtd-theme>=1.0; extra == "docs"
25
+ Summary: A library for Difference-in-Differences causal inference analysis
26
+ Keywords: causal-inference,difference-in-differences,econometrics,statistics,treatment-effects
27
+ Author: diff-diff contributors
28
+ License-Expression: MIT
29
+ Requires-Python: >=3.9
30
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
31
+ Project-URL: Documentation, https://diff-diff.readthedocs.io
32
+ Project-URL: Homepage, https://github.com/igerber/diff-diff
33
+ Project-URL: Issues, https://github.com/igerber/diff-diff/issues
34
+ Project-URL: Repository, https://github.com/igerber/diff-diff
35
35
 
36
36
  # diff-diff
37
37
 
@@ -2253,3 +2253,4 @@ The `HonestDiD` module implements sensitivity analysis methods for relaxing the
2253
2253
  ## License
2254
2254
 
2255
2255
  MIT License
2256
+
@@ -5,6 +5,16 @@ This library provides sklearn-like estimators for causal inference
5
5
  using the difference-in-differences methodology.
6
6
  """
7
7
 
8
+ # Import backend detection from dedicated module (avoids circular imports)
9
+ from diff_diff._backend import (
10
+ HAS_RUST_BACKEND,
11
+ _rust_bootstrap_weights,
12
+ _rust_compute_robust_vcov,
13
+ _rust_project_simplex,
14
+ _rust_solve_ols,
15
+ _rust_synthetic_weights,
16
+ )
17
+
8
18
  from diff_diff.bacon import (
9
19
  BaconDecomposition,
10
20
  BaconDecompositionResults,
@@ -103,7 +113,7 @@ from diff_diff.visualization import (
103
113
  plot_sensitivity,
104
114
  )
105
115
 
106
- __version__ = "1.4.0"
116
+ __version__ = "2.0.1"
107
117
  __all__ = [
108
118
  # Estimators
109
119
  "DifferenceInDifferences",
@@ -187,4 +197,6 @@ __all__ = [
187
197
  "compute_pretrends_power",
188
198
  "compute_mdv",
189
199
  "plot_pretrends_power",
200
+ # Rust backend
201
+ "HAS_RUST_BACKEND",
190
202
  ]
@@ -0,0 +1,64 @@
1
+ """
2
+ Backend detection and configuration for diff-diff.
3
+
4
+ This module handles:
5
+ 1. Detection of optional Rust backend
6
+ 2. Environment variable configuration (DIFF_DIFF_BACKEND)
7
+ 3. Exports HAS_RUST_BACKEND and Rust function references
8
+
9
+ Other modules should import from here to avoid circular imports with __init__.py.
10
+ """
11
+
12
+ import os
13
+
14
+ # Check for backend override via environment variable
15
+ # DIFF_DIFF_BACKEND can be: 'auto' (default), 'python', or 'rust'
16
+ _backend_env = os.environ.get('DIFF_DIFF_BACKEND', 'auto').lower()
17
+
18
+ # Try to import Rust backend for accelerated operations
19
+ try:
20
+ from diff_diff._rust_backend import (
21
+ generate_bootstrap_weights_batch as _rust_bootstrap_weights,
22
+ compute_synthetic_weights as _rust_synthetic_weights,
23
+ project_simplex as _rust_project_simplex,
24
+ solve_ols as _rust_solve_ols,
25
+ compute_robust_vcov as _rust_compute_robust_vcov,
26
+ )
27
+ _rust_available = True
28
+ except ImportError:
29
+ _rust_available = False
30
+ _rust_bootstrap_weights = None
31
+ _rust_synthetic_weights = None
32
+ _rust_project_simplex = None
33
+ _rust_solve_ols = None
34
+ _rust_compute_robust_vcov = None
35
+
36
+ # Determine final backend based on environment variable and availability
37
+ if _backend_env == 'python':
38
+ # Force pure Python mode - disable Rust even if available
39
+ HAS_RUST_BACKEND = False
40
+ _rust_bootstrap_weights = None
41
+ _rust_synthetic_weights = None
42
+ _rust_project_simplex = None
43
+ _rust_solve_ols = None
44
+ _rust_compute_robust_vcov = None
45
+ elif _backend_env == 'rust':
46
+ # Force Rust mode - fail if not available
47
+ if not _rust_available:
48
+ raise ImportError(
49
+ "DIFF_DIFF_BACKEND=rust but Rust backend is not available. "
50
+ "Install with: pip install diff-diff[rust]"
51
+ )
52
+ HAS_RUST_BACKEND = True
53
+ else:
54
+ # Auto mode - use Rust if available
55
+ HAS_RUST_BACKEND = _rust_available
56
+
57
+ __all__ = [
58
+ 'HAS_RUST_BACKEND',
59
+ '_rust_bootstrap_weights',
60
+ '_rust_synthetic_weights',
61
+ '_rust_project_simplex',
62
+ '_rust_solve_ols',
63
+ '_rust_compute_robust_vcov',
64
+ ]
@@ -17,6 +17,8 @@ from typing import Any, Dict, List, Optional, Tuple
17
17
  import numpy as np
18
18
  import pandas as pd
19
19
 
20
+ from diff_diff.utils import within_transform as _within_transform_util
21
+
20
22
 
21
23
  @dataclass
22
24
  class Comparison2x2:
@@ -573,66 +575,16 @@ class BaconDecomposition:
573
575
  treat_col: str = '__bacon_treated_internal__',
574
576
  ) -> float:
575
577
  """Compute TWFE estimate using within-transformation."""
576
- # Demean by unit and time
577
- y = df[outcome].values
578
- d = df[treat_col].astype(float).values
579
-
580
- # Create unit and time dummies for demeaning
581
- units = df[unit].values
582
- times = df[time].values
583
-
584
- # Unit means
585
- unit_map = {u: i for i, u in enumerate(df[unit].unique())}
586
- unit_idx = np.array([unit_map[u] for u in units])
587
- n_units = len(unit_map)
588
-
589
- # Time means
590
- time_map = {t: i for i, t in enumerate(df[time].unique())}
591
- time_idx = np.array([time_map[t] for t in times])
592
- n_times = len(time_map)
593
-
594
- # Compute means
595
- y_unit_mean = np.zeros(n_units)
596
- d_unit_mean = np.zeros(n_units)
597
- unit_counts = np.zeros(n_units)
598
-
599
- for i in range(len(y)):
600
- u = unit_idx[i]
601
- y_unit_mean[u] += y[i]
602
- d_unit_mean[u] += d[i]
603
- unit_counts[u] += 1
604
-
605
- y_unit_mean /= np.maximum(unit_counts, 1)
606
- d_unit_mean /= np.maximum(unit_counts, 1)
607
-
608
- y_time_mean = np.zeros(n_times)
609
- d_time_mean = np.zeros(n_times)
610
- time_counts = np.zeros(n_times)
611
-
612
- for i in range(len(y)):
613
- t = time_idx[i]
614
- y_time_mean[t] += y[i]
615
- d_time_mean[t] += d[i]
616
- time_counts[t] += 1
617
-
618
- y_time_mean /= np.maximum(time_counts, 1)
619
- d_time_mean /= np.maximum(time_counts, 1)
620
-
621
- # Overall mean
622
- y_mean = np.mean(y)
623
- d_mean = np.mean(d)
624
-
625
- # Within transformation: y_it - y_i - y_t + y
626
- y_within = np.zeros(len(y))
627
- d_within = np.zeros(len(d))
628
-
629
- for i in range(len(y)):
630
- u = unit_idx[i]
631
- t = time_idx[i]
632
- y_within[i] = y[i] - y_unit_mean[u] - y_time_mean[t] + y_mean
633
- d_within[i] = d[i] - d_unit_mean[u] - d_time_mean[t] + d_mean
634
-
635
- # OLS on demeaned data
578
+ # Apply two-way within transformation
579
+ df_dm = _within_transform_util(
580
+ df, [outcome, treat_col], unit, time, suffix="_within"
581
+ )
582
+
583
+ # Extract within-transformed values
584
+ y_within = df_dm[f"{outcome}_within"].values
585
+ d_within = df_dm[f"{treat_col}_within"].values
586
+
587
+ # OLS on demeaned data: beta = sum(d * y) / sum(d^2)
636
588
  d_var = np.sum(d_within ** 2)
637
589
  if d_var > 0:
638
590
  beta = np.sum(d_within * y_within) / d_var
@@ -23,6 +23,7 @@ from diff_diff.utils import (
23
23
  WildBootstrapResults,
24
24
  compute_confidence_interval,
25
25
  compute_p_value,
26
+ demean_by_group,
26
27
  validate_binary,
27
28
  wild_bootstrap_se,
28
29
  )
@@ -227,10 +228,10 @@ class DifferenceInDifferences:
227
228
  # unit-invariant, so demeaning them would create multicollinearity
228
229
  vars_to_demean = [outcome] + (covariates or [])
229
230
  for ab_var in absorb:
230
- n_absorbed_effects += working_data[ab_var].nunique() - 1
231
- for var in vars_to_demean:
232
- group_means = working_data.groupby(ab_var)[var].transform("mean")
233
- working_data[var] = working_data[var] - group_means
231
+ working_data, n_fe = demean_by_group(
232
+ working_data, vars_to_demean, ab_var, inplace=True
233
+ )
234
+ n_absorbed_effects += n_fe
234
235
  absorbed_vars.append(ab_var)
235
236
 
236
237
  # Extract variables (may be demeaned if absorb was used)
@@ -828,10 +829,10 @@ class MultiPeriodDiD(DifferenceInDifferences):
828
829
  if absorb:
829
830
  vars_to_demean = [outcome] + (covariates or [])
830
831
  for ab_var in absorb:
831
- n_absorbed_effects += working_data[ab_var].nunique() - 1
832
- for var in vars_to_demean:
833
- group_means = working_data.groupby(ab_var)[var].transform("mean")
834
- working_data[var] = working_data[var] - group_means
832
+ working_data, n_fe = demean_by_group(
833
+ working_data, vars_to_demean, ab_var, inplace=True
834
+ )
835
+ n_absorbed_effects += n_fe
835
836
 
836
837
  # Extract outcome and treatment
837
838
  y = working_data[outcome].values.astype(float)
@@ -1,15 +1,17 @@
1
1
  """
2
2
  Unified linear algebra backend for diff-diff.
3
3
 
4
- This module provides optimized OLS and variance estimation that can be
5
- swapped to a compiled backend (Rust/C++) for maximum performance.
4
+ This module provides optimized OLS and variance estimation with an optional
5
+ Rust backend for maximum performance.
6
6
 
7
7
  The key optimizations are:
8
8
  1. scipy.linalg.lstsq with 'gelsy' driver (QR-based, faster than SVD)
9
9
  2. Vectorized cluster-robust SE via groupby (eliminates O(n*clusters) loop)
10
10
  3. Single interface for all estimators (reduces code duplication)
11
+ 4. Optional Rust backend for additional speedup (when available)
11
12
 
12
- Future: This module can be extended with a Rust backend for additional speedup.
13
+ The Rust backend is automatically used when available, with transparent
14
+ fallback to NumPy/SciPy implementations.
13
15
  """
14
16
 
15
17
  from typing import Optional, Tuple, Union
@@ -18,6 +20,13 @@ import numpy as np
18
20
  import pandas as pd
19
21
  from scipy.linalg import lstsq as scipy_lstsq
20
22
 
23
+ # Import Rust backend if available (from _backend to avoid circular imports)
24
+ from diff_diff._backend import (
25
+ HAS_RUST_BACKEND,
26
+ _rust_compute_robust_vcov,
27
+ _rust_solve_ols,
28
+ )
29
+
21
30
 
22
31
  def solve_ols(
23
32
  X: np.ndarray,
@@ -119,6 +128,87 @@ def solve_ols(
119
128
  "Clean your data or set check_finite=False to skip this check."
120
129
  )
121
130
 
131
+ # Use Rust backend if available
132
+ # Note: Fall back to NumPy if check_finite=False since Rust's LAPACK
133
+ # doesn't support non-finite values
134
+ if HAS_RUST_BACKEND and check_finite:
135
+ # Ensure contiguous arrays for Rust
136
+ X = np.ascontiguousarray(X, dtype=np.float64)
137
+ y = np.ascontiguousarray(y, dtype=np.float64)
138
+
139
+ # Convert cluster_ids to int64 for Rust (if provided)
140
+ cluster_ids_int = None
141
+ if cluster_ids is not None:
142
+ cluster_ids_int = pd.factorize(cluster_ids)[0].astype(np.int64)
143
+
144
+ try:
145
+ coefficients, residuals, vcov = _rust_solve_ols(
146
+ X, y, cluster_ids_int, return_vcov
147
+ )
148
+ except ValueError as e:
149
+ # Translate Rust LAPACK errors to consistent Python error messages
150
+ error_msg = str(e)
151
+ if "Matrix inversion failed" in error_msg or "Least squares failed" in error_msg:
152
+ raise ValueError(
153
+ "Design matrix is rank-deficient (singular X'X matrix). "
154
+ "This indicates perfect multicollinearity. Check your fixed effects "
155
+ "and covariates for linear dependencies."
156
+ ) from e
157
+ raise
158
+
159
+ if return_fitted:
160
+ fitted = X @ coefficients
161
+ return coefficients, residuals, fitted, vcov
162
+ else:
163
+ return coefficients, residuals, vcov
164
+
165
+ # Fallback to NumPy/SciPy implementation
166
+ return _solve_ols_numpy(
167
+ X, y, cluster_ids=cluster_ids, return_vcov=return_vcov, return_fitted=return_fitted
168
+ )
169
+
170
+
171
+ def _solve_ols_numpy(
172
+ X: np.ndarray,
173
+ y: np.ndarray,
174
+ *,
175
+ cluster_ids: Optional[np.ndarray] = None,
176
+ return_vcov: bool = True,
177
+ return_fitted: bool = False,
178
+ ) -> Union[
179
+ Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]],
180
+ Tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray]],
181
+ ]:
182
+ """
183
+ NumPy/SciPy fallback implementation of solve_ols.
184
+
185
+ Uses scipy.linalg.lstsq with 'gelsy' driver (QR with column pivoting)
186
+ for fast and stable least squares solving.
187
+
188
+ Parameters
189
+ ----------
190
+ X : np.ndarray
191
+ Design matrix of shape (n, k).
192
+ y : np.ndarray
193
+ Response vector of shape (n,).
194
+ cluster_ids : np.ndarray, optional
195
+ Cluster identifiers for cluster-robust SEs.
196
+ return_vcov : bool
197
+ Whether to compute variance-covariance matrix.
198
+ return_fitted : bool
199
+ Whether to return fitted values.
200
+
201
+ Returns
202
+ -------
203
+ coefficients : np.ndarray
204
+ OLS coefficients of shape (k,).
205
+ residuals : np.ndarray
206
+ Residuals of shape (n,).
207
+ fitted : np.ndarray, optional
208
+ Fitted values if return_fitted=True.
209
+ vcov : np.ndarray, optional
210
+ Variance-covariance matrix if return_vcov=True.
211
+ """
122
212
  # Solve OLS using scipy's optimized solver
123
213
  # 'gelsy' uses QR with column pivoting, faster than default 'gelsd' (SVD)
124
214
  # Note: gelsy doesn't reliably report rank, so we don't check for deficiency
@@ -131,7 +221,7 @@ def solve_ols(
131
221
  # Compute variance-covariance matrix if requested
132
222
  vcov = None
133
223
  if return_vcov:
134
- vcov = compute_robust_vcov(X, residuals, cluster_ids)
224
+ vcov = _compute_robust_vcov_numpy(X, residuals, cluster_ids)
135
225
 
136
226
  if return_fitted:
137
227
  return coefficients, residuals, fitted, vcov
@@ -176,6 +266,63 @@ def compute_robust_vcov(
176
266
  The cluster-robust computation is vectorized using pandas groupby,
177
267
  which is much faster than a Python loop over clusters.
178
268
  """
269
+ # Use Rust backend if available
270
+ if HAS_RUST_BACKEND:
271
+ X = np.ascontiguousarray(X, dtype=np.float64)
272
+ residuals = np.ascontiguousarray(residuals, dtype=np.float64)
273
+
274
+ cluster_ids_int = None
275
+ if cluster_ids is not None:
276
+ cluster_ids_int = pd.factorize(cluster_ids)[0].astype(np.int64)
277
+
278
+ try:
279
+ return _rust_compute_robust_vcov(X, residuals, cluster_ids_int)
280
+ except ValueError as e:
281
+ # Translate Rust LAPACK errors to consistent Python error messages
282
+ error_msg = str(e)
283
+ if "Matrix inversion failed" in error_msg:
284
+ raise ValueError(
285
+ "Design matrix is rank-deficient (singular X'X matrix). "
286
+ "This indicates perfect multicollinearity. Check your fixed effects "
287
+ "and covariates for linear dependencies."
288
+ ) from e
289
+ raise
290
+
291
+ # Fallback to NumPy implementation
292
+ return _compute_robust_vcov_numpy(X, residuals, cluster_ids)
293
+
294
+
295
+ def _compute_robust_vcov_numpy(
296
+ X: np.ndarray,
297
+ residuals: np.ndarray,
298
+ cluster_ids: Optional[np.ndarray] = None,
299
+ ) -> np.ndarray:
300
+ """
301
+ NumPy fallback implementation of compute_robust_vcov.
302
+
303
+ Computes HC1 (heteroskedasticity-robust) or cluster-robust variance-covariance
304
+ matrix using the sandwich estimator.
305
+
306
+ Parameters
307
+ ----------
308
+ X : np.ndarray
309
+ Design matrix of shape (n, k).
310
+ residuals : np.ndarray
311
+ OLS residuals of shape (n,).
312
+ cluster_ids : np.ndarray, optional
313
+ Cluster identifiers. If None, uses HC1. If provided, uses
314
+ cluster-robust with G/(G-1) small-sample adjustment.
315
+
316
+ Returns
317
+ -------
318
+ vcov : np.ndarray
319
+ Variance-covariance matrix of shape (k, k).
320
+
321
+ Notes
322
+ -----
323
+ Uses vectorized groupby aggregation for cluster-robust SEs to avoid
324
+ the O(n * G) loop that would be required with explicit iteration.
325
+ """
179
326
  n, k = X.shape
180
327
  XtX = X.T @ X
181
328