Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Deferred items from PR reviews that were not addressed before merge.

| Issue | Location | PR | Priority |
|-------|----------|----|----------|
| CallawaySantAnna: consider materializing NaN entries for non-estimable (g,t) cells in group_time_effects dict (currently omitted with consolidated warning); would require updating downstream consumers (event study, balance_e, aggregation) | `staggered.py` | #256 | Low |
| ImputationDiD dense `(A0'A0).toarray()` scales O((U+T+K)^2), OOM risk on large panels | `imputation.py` | #141 | Medium (deferred — only triggers when sparse solver fails) |
| Multi-absorb weighted demeaning needs iterative alternating projections for N > 1 absorbed FE with survey weights; unweighted multi-absorb also uses single-pass (pre-existing, exact only for balanced panels) | `estimators.py` | #218 | Medium |
| Replicate-weight survey df — **Resolved**. `df_survey = rank(replicate_weights) - 1` matching R's `survey::degf()`. For IF paths, `n_valid - 1` when dropped replicates reduce effective count. | `survey.py` | #238 | Resolved |
Expand Down
179 changes: 124 additions & 55 deletions diff_diff/staggered.py

Large diffs are not rendered by default.

15 changes: 4 additions & 11 deletions diff_diff/staggered_aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,18 +105,11 @@ def _aggregate_simple(
weights = np.array(weights_list, dtype=float)
groups_for_gt = np.array(groups_for_gt)

# Exclude NaN effects from aggregation (R's aggte() convention)
# Exclude NaN effects from aggregation (R's aggte() convention).
# No warning here — fit() emits a consolidated skip warning covering
# all estimation paths (vectorized, covariate, general, RC).
finite_mask = np.isfinite(effects)
n_nan = int(np.sum(~finite_mask))
if n_nan > 0:
import warnings

warnings.warn(
f"{n_nan} group-time effect(s) are NaN and excluded from overall ATT "
"aggregation. Inspect group_time_effects for details.",
UserWarning,
stacklevel=2,
)
if not np.all(finite_mask):
effects = effects[finite_mask]
weights = weights[finite_mask]
gt_pairs = [gt for gt, m in zip(gt_pairs, finite_mask) if m]
Expand Down
10 changes: 9 additions & 1 deletion diff_diff/survey.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,15 @@ def resolve(self, data: pd.DataFrame) -> "ResolvedSurveyDesign":
if self.replicate_weights is not None:
weights = raw_weights.copy()
elif self.weight_type in ("pweight", "aweight"):
weights = raw_weights * (n / np.sum(raw_weights))
raw_sum = float(np.sum(raw_weights))
weights = raw_weights * (n / raw_sum)
if not np.isclose(raw_sum, n):
warnings.warn(
f"{self.weight_type} weights normalized to mean=1 "
f"(sum={n}). Original sum was {raw_sum:.4g}.",
UserWarning,
stacklevel=2,
)
else:
weights = raw_weights.copy()
else:
Expand Down
18 changes: 11 additions & 7 deletions diff_diff/trop.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
_rust_loocv_grid_search,
)
from diff_diff.trop_global import TROPGlobalMixin
from diff_diff.trop_local import TROPLocalMixin
from diff_diff.trop_local import TROPLocalMixin, _validate_and_pivot_treatment
from diff_diff.trop_results import (
_LAMBDA_INF,
_PrecomputedStructures,
Expand Down Expand Up @@ -518,13 +518,10 @@ def fit(
.values
)

# For D matrix, track missing values BEFORE fillna to support unbalanced panels
# Issue 3 fix: Missing observations should not trigger spurious violations
D_raw = data.pivot(index=time, columns=unit, values=treatment).reindex(
index=all_periods, columns=all_units
# For D matrix, validate observed treatment and handle unbalanced panels
D, missing_mask = _validate_and_pivot_treatment(
data, time, unit, treatment, all_periods, all_units
)
missing_mask = pd.isna(D_raw).values # True where originally missing
D = D_raw.fillna(0).astype(int).values

# Validate D is monotonic non-decreasing per unit (absorbing state)
# D[t, i] must satisfy: once D=1, it must stay 1 for all subsequent periods
Expand Down Expand Up @@ -652,6 +649,13 @@ def fit(
except Exception as e:
# Fall back to Python implementation on error
logger.debug("Rust LOOCV grid search failed, falling back to Python: %s", e)
warnings.warn(
f"Rust backend failed for LOOCV grid search; "
f"falling back to Python. Performance may be reduced. "
f"Error: {e}",
UserWarning,
stacklevel=2,
)
best_lambda = None
best_score = np.inf

Expand Down
29 changes: 24 additions & 5 deletions diff_diff/trop_global.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
_rust_bootstrap_trop_variance_global,
_rust_loocv_grid_search_global,
)
from diff_diff.trop_local import _soft_threshold_svd
from diff_diff.trop_local import _soft_threshold_svd, _validate_and_pivot_treatment
from diff_diff.trop_results import TROPResults
from diff_diff.utils import safe_inference

Expand Down Expand Up @@ -370,6 +370,13 @@ def _solve_global_no_lowrank(
coeffs, _, _, _ = np.linalg.lstsq(X_weighted, y_weighted, rcond=None)
except np.linalg.LinAlgError:
# Fallback: use pseudo-inverse
warnings.warn(
"Least-squares solver failed in TROP global estimation; "
"falling back to pseudo-inverse. Results may be less "
"numerically stable.",
UserWarning,
stacklevel=2,
)
coeffs = np.dot(np.linalg.pinv(X_weighted), y_weighted)

# Extract parameters
Expand Down Expand Up @@ -556,11 +563,9 @@ def _fit_global(
.values
)

D_raw = data.pivot(index=time, columns=unit, values=treatment).reindex(
index=all_periods, columns=all_units
D, missing_mask = _validate_and_pivot_treatment(
data, time, unit, treatment, all_periods, all_units
)
missing_mask = pd.isna(D_raw).values
D = D_raw.fillna(0).astype(int).values

# Validate absorbing state
violating_units = []
Expand Down Expand Up @@ -692,6 +697,13 @@ def _fit_global(
logger.debug(
"Rust LOOCV grid search (global) failed, falling back to Python: %s", e
)
warnings.warn(
f"Rust backend failed for LOOCV grid search (global); "
f"falling back to Python. Performance may be reduced. "
f"Error: {e}",
UserWarning,
stacklevel=2,
)
best_lambda = None
best_score = np.inf

Expand Down Expand Up @@ -952,6 +964,13 @@ def _bootstrap_variance_global(

except Exception as e:
logger.debug("Rust bootstrap (global) failed, falling back to Python: %s", e)
warnings.warn(
f"Rust backend failed for bootstrap variance (global); "
f"falling back to Python. Performance may be reduced. "
f"Error: {e}",
UserWarning,
stacklevel=2,
)

# Python fallback implementation
rng = np.random.default_rng(self.seed)
Expand Down
46 changes: 46 additions & 0 deletions diff_diff/trop_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@
from diff_diff.trop_results import _PrecomputedStructures


def _validate_and_pivot_treatment(data, time, unit, treatment, all_periods, all_units):
"""Validate treatment column and create D matrix with missing mask.

Rejects observed rows with missing treatment values (data quality error),
then pivots to (time x unit) matrix. Structural gaps from unbalanced panels
are filled with 0 (assumed untreated) and flagged with a warning.

Returns
-------
D : ndarray
Treatment matrix (n_periods x n_units), int.
missing_mask : ndarray
Boolean mask of structurally absent cells (n_periods x n_units).
"""
n_nan_observed = int(data[treatment].isna().sum())
if n_nan_observed > 0:
raise ValueError(
f"{n_nan_observed} observation(s) have missing treatment values. "
f"TROP requires non-missing treatment indicators for all observed "
f"rows. Remove or impute missing values before fitting."
)

D_raw = data.pivot(index=time, columns=unit, values=treatment).reindex(
index=all_periods, columns=all_units
)
missing_mask = pd.isna(D_raw).values
n_missing_structural = int(missing_mask.sum())
if n_missing_structural > 0:
warnings.warn(
f"{n_missing_structural} missing treatment indicator(s) in the "
f"(time x unit) panel matrix filled with 0 (assumed "
f"untreated). This typically occurs in unbalanced panels.",
UserWarning,
stacklevel=3,
)
D = D_raw.fillna(0).astype(int).values
return D, missing_mask


# Module-level convergence tolerance for SVD singular value truncation.
# Singular values below this threshold after soft-thresholding are treated
# as zero to improve numerical stability.
Expand Down Expand Up @@ -928,6 +967,13 @@ def _bootstrap_variance(
)
except Exception as e:
logger.debug("Rust bootstrap variance failed, falling back to Python: %s", e)
warnings.warn(
f"Rust backend failed for bootstrap variance; "
f"falling back to Python. Performance may be reduced. "
f"Error: {e}",
UserWarning,
stacklevel=2,
)

# Python implementation (fallback)
rng = np.random.default_rng(self.seed)
Expand Down
Loading
Loading