Skip to content

Commit 142a572

Browse files
yiyiclaude
authored andcommitted
feat(twfeweights): plot_twfe_weights() + full docs surface
Plotting (replacing upstream's ggtwfeweights S3 methods) and every documentation surface the new API owes. plot_twfe_weights(result, kind="auto"|"weights"|"balance") lives beside plot_bacon in visualization/_diagnostic.py and dispatches on either result type. The weights view puts weight on x and ATT(g,t) on y with zero lines, so negative-weight cells sit visibly left of the axis; the balance view plots unweighted against implicitly-weighted covariate differences with a no-improvement diagonal. "auto" picks balance when a balance table is present. Docs: a REGISTRY.md section carrying the weight equations, the cross-surface identity, the tolerance table with per-gate rationale, and eleven explicit Note/Deviation-from-R entries - including the fixest zero-column segfault and its root cause, the annihilated-covariate drop, and the 0/0-cell limit, so the two places we deliberately differ from R are recorded rather than discovered later by a reviewer. Two paragraphs separate this surface from `twowayfeweights` (dCDH, weights (unit, time) cells) and from BaconDecomposition (decomposes into 2x2 comparisons), since all three are "TWFE weight" diagnostics and the distinction is the thing a reader most needs. Also: docs/api/twfe_weights.rst with runnable examples, four api/index.rst registrations (2 result classes, the plot, 2 functions, toctree), doc-deps.yaml group + sources entries, a README one-liner in Diagnostics & Sensitivity, llms.txt catalog entry, llms-full.txt API + result blocks, a references.rst sub-entry naming the upstream package and its MIT copyright, and a changelog.d fragment. This closes the doc-deps gate the attgt_weights commit left red. Verified: 14378 tests collect clean; docs IA, doc-deps integrity, diagnostic roster, guides, changelog-fragment, serialization and all visualization suites green (903 passed, 43 skipped). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3ac0a79 commit 142a572

12 files changed

Lines changed: 581 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
130130
- [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html) - Cattaneo, Jansson & Ma (2020) density-discontinuity test (`RDDensityTest`): rddensity 3.0 parity, robust bias-corrected inference, unrestricted/restricted models, mass-point adjustment
131131
- [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST)
132132
- [Placebo Tests](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html) - placebo timing, group, permutation, leave-one-out
133+
- [TWFE Weight Diagnostics](https://diff-diff.readthedocs.io/en/stable/api/twfe_weights.html) - Baker, Callaway, Cunningham, Goodman-Bacon & Sant'Anna (2025) implicit weights on ATT(g,t): `attgt_weights(cs_result, aggregation='twfe'|'overall'|'simple')` shows what a TWFE regression (vs ATT^O / ATT^simple) implicitly puts on each group-time effect, including negative weights; `decompose_twfe_weights(panel, method='fwl')` re-derives the estimate from its building blocks with the pre-trend-violation contribution and implicit covariate balance. Ported from Brantly Callaway's `twfeweights` R package (MIT)
133134
- [Honest DiD](https://diff-diff.readthedocs.io/en/stable/api/honest_did.html) - Rambachan & Roth (2023) sensitivity analysis: robust CI under PT violations, breakdown values
134135
- [Pre-Trends Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/pretrends.html) - Roth (2022) minimum detectable violation and power curves
135136
- [Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/power.html) - analytical and simulation-based MDE, sample size, power curves for study design
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
### Added
2+
- **TWFE weight diagnostics** (port of Brantly Callaway's `twfeweights` R
3+
package, MIT): what a two-way fixed effects regression *implicitly* weights
4+
on staggered-adoption data.
5+
- `attgt_weights(results, aggregation="twfe"|"overall"|"simple")` reports the
6+
weight a TWFE regression, ATT^O, or ATT^simple places on each ATT(g,t),
7+
plus the negative-weight share. Takes a fitted `CallawaySantAnnaResults`
8+
(reading cohort masses off its aggregation bookkeeping, so no raw panel is
9+
needed); a `(gt_frame, data=, unit=, time=, first_treat=)` fallback
10+
consumes `result.to_dataframe("group_time")` verbatim. Returns
11+
`ATTGTWeightsResult`. `aggregation="twfe"` requires
12+
`base_period="universal"` and `control_group="never_treated"`, matching
13+
the restrictions R enforces.
14+
- `decompose_twfe_weights(data, outcome=, unit=, time=, first_treat=,
15+
method="fwl", covariates=, base_period="first_period"|"gmin1")` re-derives
16+
the estimate from its ATT(g,t) building blocks and returns
17+
`TWFEDecompositionResult` with `pretrend_bias` — the contribution of
18+
pre-treatment cells, i.e. of parallel-trends violations rather than of
19+
treatment. With `balance_covariates=`, `result.covariate_balance()`
20+
reports whether the implicit weights actually balance those covariates.
21+
- `plot_twfe_weights()` renders either view.
22+
23+
Names are deliberately separate from the existing `twowayfeweights` /
24+
`TWFEWeightsResult` (de Chaisemartin & D'Haultfoeuille) surface, which
25+
weights (unit, time) cells rather than ATT(g,t) parameters.
26+
27+
Validated against R `twfeweights` 0.9.0 output on three fixtures (`mpdta`
28+
plus two simulated panels); goldens at
29+
`benchmarks/data/twfeweights_golden.json`, regenerated by
30+
`benchmarks/R/generate_twfeweights_golden.R`. R is never needed to run the
31+
test suite. Methodology: Baker, Callaway, Cunningham, Goodman-Bacon &
32+
Sant'Anna (2025); Callaway & Sant'Anna (2021) for the ATT^O / ATT^simple
33+
weights.

diff_diff/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@
329329
plot_sensitivity,
330330
plot_staircase,
331331
plot_synth_weights,
332+
plot_twfe_weights,
332333
)
333334
from diff_diff.wooldridge import WooldridgeDiD
334335
from diff_diff.wooldridge_results import WooldridgeDiDResults
@@ -488,6 +489,7 @@ def __getattr__(name: str) -> _Any:
488489
"SieveLearner",
489490
# Visualization
490491
"plot_bacon",
492+
"plot_twfe_weights",
491493
"plot_event_study",
492494
"plot_group_effects",
493495
"plot_sensitivity",

diff_diff/guides/llms-full.txt

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,44 @@ results.print_summary()
15031503
plot_bacon(results)
15041504
```
15051505

1506+
### TWFE Weight Diagnostics
1507+
1508+
What a TWFE regression implicitly weights on staggered data. Distinct from
1509+
`twowayfeweights` (dCDH), which weights (unit, time) cells: these weight
1510+
ATT(g,t) parameters. Ported from Brantly Callaway's `twfeweights` R package
1511+
(MIT); methodology Baker, Callaway, Cunningham, Goodman-Bacon & Sant'Anna
1512+
(2025).
1513+
1514+
```python
1515+
attgt_weights(
1516+
results, # CallawaySantAnnaResults, or a (g,t) frame
1517+
aggregation="twfe", # "twfe" | "overall" (ATT^O) | "simple"
1518+
data=None, unit=None, time=None, first_treat=None, # frame path only
1519+
weights=None, # unit-level sampling weights
1520+
) -> ATTGTWeightsResult
1521+
1522+
decompose_twfe_weights(
1523+
data, # balanced long panel (it re-estimates)
1524+
outcome=, unit=, time=, first_treat=,
1525+
method="fwl",
1526+
covariates=None,
1527+
base_period="first_period", # or "gmin1"
1528+
balance_covariates=None, # enables result.covariate_balance()
1529+
weights=None,
1530+
) -> TWFEDecompositionResult
1531+
1532+
plot_twfe_weights(result, kind="auto") # "weights" | "balance"
1533+
```
1534+
1535+
`aggregation="twfe"` requires a fit with `base_period="universal"` and
1536+
`control_group="never_treated"`; it raises otherwise. ATT^O and ATT^simple
1537+
weights are non-negative and sum to one, so comparing `implied_att` across
1538+
the three aggregations shows what the TWFE specification costs.
1539+
1540+
`decompose_twfe_weights` takes the raw panel rather than a fitted result
1541+
because it re-estimates. It is tied to `attgt_weights` by an identity:
1542+
`attgt_weights(cs, aggregation="twfe").implied_att == decompose_twfe_weights(panel, ...).estimate`.
1543+
15061544
### StaggeredTripleDifference
15071545

15081546
DEPRECATED in 3.9, removed in 4.0 (ledger row M-013). Use
@@ -1967,6 +2005,33 @@ Returned by `BaconDecomposition.fit()` (and the deprecated `bacon_decompose()` w
19672005

19682006
**Methods:** `summary()`, `print_summary()`, `to_dataframe()`
19692007

2008+
### ATTGTWeightsResult
2009+
2010+
Diagnostic result from `attgt_weights`. No inference quintet - the
2011+
decomposition is an algebraic identity.
2012+
2013+
- `weights`: DataFrame with `group`, `time`, `post`, `weight`, `att`
2014+
- `implied_att`: `sum(weight * att)` - the TWFE coefficient when
2015+
`aggregation="twfe"`
2016+
- `n_negative`, `negative_weight_share`: the staggered-TWFE pathology
2017+
- `aggregation`, `source`, `control_group`, `base_period`, `n_cells`
2018+
- `summary()`, `to_dataframe()`, `to_dict()`
2019+
2020+
### TWFEDecompositionResult
2021+
2022+
Diagnostic result from `decompose_twfe_weights`.
2023+
2024+
- `cells`: DataFrame with `group`, `time`, `post`, `att`, `weight`, `ess`,
2025+
`remainder`
2026+
- `estimate` == `decomposition` + `remainder`
2027+
- `pretrend_bias`: contribution of PRE-treatment cells, i.e. of
2028+
parallel-trends violations rather than of treatment
2029+
- `post_only`, `effective_sample_size`, `covariates`, `base_period`
2030+
- `covariate_balance(level="summary"|"cell", standardize=True,
2031+
post_only=True)`: implicit-weight covariate balance; raises when
2032+
`balance_covariates=` was not requested
2033+
- `summary()`, `to_dataframe()`, `to_dict()`
2034+
19702035
### Comparison2x2
19712036

19722037
Individual 2x2 DiD comparison (used in BaconDecompositionResults).

diff_diff/guides/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ The site is organized into 5 sections, each with a landing page:
9090
- [Manipulation Testing](https://diff-diff.readthedocs.io/en/stable/api/regression_discontinuity.html): Cattaneo, Jansson & Ma (2020) density-discontinuity manipulation test (`RDDensityTest`), parity with R rddensity 3.0 - boundary-adaptive local polynomial density estimation at the cutoff, robust bias-corrected inference, unrestricted/restricted models, jackknife/plugin variances, data-driven bandwidths, mass-point adjustment
9191
- [Parallel Trends Testing](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html): Simple and Wasserstein-robust parallel trends tests, equivalence testing (TOST)
9292
- [Placebo Tests](https://diff-diff.readthedocs.io/en/stable/api/diagnostics.html): Placebo timing, group, permutation, and leave-one-out diagnostics
93+
- [TWFE Weight Diagnostics](https://diff-diff.readthedocs.io/en/stable/api/twfe_weights.html): Baker et al. (2025) implicit weights on ATT(g,t) - `attgt_weights(results, aggregation='twfe'|'overall'|'simple')` takes a fitted `CallawaySantAnnaResults` (raw ATT(g,t) frame + panel as fallback) and returns the weight each estimand places on each group-time effect, with the negative-weight share; `decompose_twfe_weights(data, outcome=, unit=, time=, first_treat=, method='fwl', covariates=)` re-derives the TWFE estimate from its ATT(g,t) building blocks with `pretrend_bias`, and `result.covariate_balance()` reports implicit-weight covariate balance. Plot with `plot_twfe_weights`. R `twfeweights` 0.9.0 output parity
9394
- [Honest DiD](https://diff-diff.readthedocs.io/en/stable/api/honest_did.html): Rambachan & Roth (2023) sensitivity analysis — robust CI under parallel trends violations, breakdown values
9495
- [Pre-Trends Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/pretrends.html): Roth (2022) Section II.A-B no-individually-significant (NIS) box-probability pretest power + minimum detectable violation; `pretest_form='nis'` (default) implements the paper's primary form, `pretest_form='wald'` retained as paper-supported alternative (Propositions 1+3+4 all apply); linear-violation MDV in Roth's γ units when relative-time labels are threaded through `fit()`; full Σ_22 routing on non-bootstrap CallawaySantAnna and SunAbraham adapters and on admitted CS-/StackedDiD-sourced `aggregate('event_study')` containers (StackedDiD persists its ES VCV in every inference mode)
9596
- [Power Analysis](https://diff-diff.readthedocs.io/en/stable/api/power.html): Analytical and simulation-based power analysis — MDE, sample size, power curves for study design

diff_diff/visualization/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from diff_diff.visualization._diagnostic import (
1616
plot_bacon,
1717
plot_sensitivity,
18+
plot_twfe_weights,
1819
)
1920
from diff_diff.visualization._event_study import (
2021
PlottableResults,
@@ -48,6 +49,7 @@
4849
"plot_group_effects",
4950
"plot_sensitivity",
5051
"plot_bacon",
52+
"plot_twfe_weights",
5153
"plot_power_curve",
5254
"plot_pretrends_power",
5355
# New public functions

diff_diff/visualization/_diagnostic.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,3 +817,176 @@ def _render_bacon_plotly(
817817
fig.show()
818818

819819
return fig
820+
821+
822+
def plot_twfe_weights(
823+
results: Any,
824+
*,
825+
kind: str = "auto",
826+
standardize: bool = True,
827+
absolute_value: bool = True,
828+
figsize: Tuple[float, float] = (10, 6),
829+
title: Optional[str] = None,
830+
xlabel: Optional[str] = None,
831+
ylabel: Optional[str] = None,
832+
post_color: str = "#2563eb",
833+
pre_color: str = "#dc2626",
834+
markersize: int = 80,
835+
alpha: float = 0.8,
836+
annotate: bool = False,
837+
ax: Optional[Any] = None,
838+
show: bool = True,
839+
) -> Any:
840+
"""Visualize implicit TWFE weights on ATT(g, t), or their covariate balance.
841+
842+
Two views, matching upstream's ``ggtwfeweights`` methods:
843+
844+
- ``kind="weights"`` plots weight against ATT(g, t), one point per
845+
group-time cell, coloured by pre/post. Points to the LEFT of the
846+
vertical zero line carry negative weight - the staggered-TWFE
847+
pathology.
848+
- ``kind="balance"`` plots unweighted against implicitly-weighted
849+
covariate differences. Points near zero on the vertical axis are
850+
covariates the implicit weights balance.
851+
852+
Parameters
853+
----------
854+
results : ATTGTWeightsResult or TWFEDecompositionResult
855+
Output of :func:`diff_diff.attgt_weights` or
856+
:func:`diff_diff.decompose_twfe_weights`.
857+
kind : {"auto", "weights", "balance"}, default "auto"
858+
``"auto"`` picks ``"balance"`` when the result carries a balance
859+
table and ``"weights"`` otherwise.
860+
standardize : bool, default True
861+
Balance view: divide differences by the pooled standard deviation.
862+
absolute_value : bool, default True
863+
Balance view: plot absolute differences, so "closer to zero is
864+
better" reads the same for every covariate.
865+
figsize : tuple, default (10, 6)
866+
Figure size in inches. Ignored when ``ax`` is supplied.
867+
title, xlabel, ylabel : str, optional
868+
Overrides for the defaults chosen per ``kind``.
869+
post_color, pre_color : str
870+
Colors for post- and pre-treatment cells (weights view).
871+
markersize : int, default 80
872+
Scatter marker area.
873+
alpha : float, default 0.8
874+
Marker opacity.
875+
annotate : bool, default False
876+
Label each point with its ``(group, time)`` or covariate name.
877+
ax : matplotlib Axes, optional
878+
Axes to draw on. A new figure is created when omitted.
879+
show : bool, default True
880+
Call ``plt.show()`` before returning.
881+
882+
Returns
883+
-------
884+
matplotlib.axes.Axes
885+
886+
Raises
887+
------
888+
ValueError
889+
On an unknown ``kind``, or when ``kind="balance"`` is requested for a
890+
result that carries no balance table.
891+
892+
Examples
893+
--------
894+
>>> import diff_diff # doctest: +SKIP
895+
>>> w = diff_diff.attgt_weights(cs_result) # doctest: +SKIP
896+
>>> diff_diff.plot_twfe_weights(w) # doctest: +SKIP
897+
"""
898+
if kind not in ("auto", "weights", "balance"):
899+
raise ValueError(f"kind must be one of ['auto', 'weights', 'balance'], got {kind!r}")
900+
has_balance = getattr(results, "balance", None) is not None
901+
if kind == "auto":
902+
kind = "balance" if has_balance else "weights"
903+
if kind == "balance" and not has_balance:
904+
raise ValueError(
905+
"this result carries no covariate balance table, so kind='balance' "
906+
"has nothing to plot. Recompute with "
907+
"decompose_twfe_weights(..., balance_covariates=[...])."
908+
)
909+
910+
from diff_diff.visualization._common import _require_matplotlib
911+
912+
plt = _require_matplotlib()
913+
if ax is None:
914+
_, ax = plt.subplots(figsize=figsize)
915+
916+
if kind == "weights":
917+
table = getattr(results, "weights", None)
918+
if table is None:
919+
table = results.cells
920+
post = table["post"].to_numpy().astype(bool)
921+
weight = table["weight"].to_numpy()
922+
att = table["att"].to_numpy()
923+
ax.axhline(0, color="0.4", linewidth=1.2, zorder=1)
924+
ax.axvline(0, color="0.4", linewidth=1.2, zorder=1)
925+
for mask, color, label in (
926+
(post, post_color, "post-treatment"),
927+
(~post, pre_color, "pre-treatment"),
928+
):
929+
if mask.any():
930+
ax.scatter(
931+
weight[mask],
932+
att[mask],
933+
s=markersize,
934+
alpha=alpha,
935+
color=color,
936+
label=label,
937+
zorder=3,
938+
)
939+
if annotate:
940+
for w, a, g, t in zip(weight, att, table["group"], table["time"]):
941+
ax.annotate(
942+
f"({g}, {t})", (w, a), fontsize=8, xytext=(4, 4), textcoords="offset points"
943+
)
944+
ax.set_xlabel(xlabel or "Implicit weight")
945+
ax.set_ylabel(ylabel or "ATT(g, t)")
946+
default_title = "Implicit weights on group-time effects"
947+
n_negative = int((weight < 0).sum())
948+
if n_negative:
949+
default_title += f" ({n_negative} negative)"
950+
ax.set_title(title or default_title)
951+
ax.legend(frameon=False)
952+
else:
953+
balance = results.covariate_balance(level="summary", standardize=standardize)
954+
suffix = "_std_diff" if standardize else "_diff"
955+
unweighted = balance["unweighted" + suffix].to_numpy(dtype=float)
956+
weighted = balance["weighted" + suffix].to_numpy(dtype=float)
957+
if absolute_value:
958+
unweighted = np.abs(unweighted)
959+
weighted = np.abs(weighted)
960+
ax.axhline(0, color="0.4", linewidth=1.2, zorder=1)
961+
ax.scatter(
962+
unweighted,
963+
weighted,
964+
s=markersize,
965+
alpha=alpha,
966+
color=post_color,
967+
zorder=3,
968+
)
969+
limit = float(np.nanmax(np.abs(np.concatenate([unweighted, weighted]))) or 1.0)
970+
ax.plot(
971+
[0, limit],
972+
[0, limit],
973+
color="0.6",
974+
linestyle="--",
975+
linewidth=1.0,
976+
zorder=2,
977+
label="no improvement",
978+
)
979+
if annotate:
980+
for x, y, name in zip(unweighted, weighted, balance["covariate"]):
981+
ax.annotate(
982+
str(name), (x, y), fontsize=8, xytext=(4, 4), textcoords="offset points"
983+
)
984+
kindword = "standardized " if standardize else ""
985+
ax.set_xlabel(xlabel or f"Unweighted {kindword}difference")
986+
ax.set_ylabel(ylabel or f"Implicitly-weighted {kindword}difference")
987+
ax.set_title(title or "Covariate balance under the implicit weights")
988+
ax.legend(frameon=False)
989+
990+
if show:
991+
plt.show()
992+
return ax

docs/api/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Result containers returned by estimators:
7676
diff_diff.TwoStageBootstrapResults
7777
diff_diff.SpilloverDiDResults
7878
diff_diff.BaconDecompositionResults
79+
diff_diff.ATTGTWeightsResult
80+
diff_diff.TWFEDecompositionResult
7981
diff_diff.wooldridge_results.WooldridgeDiDResults
8082
diff_diff.lpdid_results.LPDiDResults
8183
diff_diff.changes_in_changes_results.ChangesInChangesResults
@@ -119,6 +121,7 @@ Plotting functions and plot builders:
119121
diff_diff.plot_honest_event_study
120122
diff_diff.RDPlot
121123
diff_diff.plot_bacon
124+
diff_diff.plot_twfe_weights
122125
diff_diff.plot_power_curve
123126
diff_diff.plot_pretrends_power
124127

@@ -138,6 +141,8 @@ Placebo tests and model diagnostics:
138141
diff_diff.leave_one_out_test
139142
diff_diff.run_all_placebo_tests
140143
diff_diff.PlaceboTestResults
144+
diff_diff.attgt_weights
145+
diff_diff.decompose_twfe_weights
141146
diff_diff.RDDensityTest
142147

143148
Panel Profiling
@@ -400,6 +405,7 @@ Diagnostics & Inference
400405
honest_did
401406
power
402407
pretrends
408+
twfe_weights
403409

404410
Reporting
405411
~~~~~~~~~

0 commit comments

Comments
 (0)