<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>difference-in-differences | Carlos Mendez</title><link>https://carlos-mendez.org/tag/difference-in-differences/</link><atom:link href="https://carlos-mendez.org/tag/difference-in-differences/index.xml" rel="self" type="application/rss+xml"/><description>difference-in-differences</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© 2018–2026 Carlos Mendez. All rights reserved.</copyright><lastBuildDate>Mon, 18 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://carlos-mendez.org/media/icon_huedfae549300b4ca5d201a9bd09a3ecd5_79625_512x512_fill_lanczos_center_3.png</url><title>difference-in-differences</title><link>https://carlos-mendez.org/tag/difference-in-differences/</link></image><item><title>Difference-in-Differences with Geocoded Microdata: When Distance Defines Treatment</title><link>https://carlos-mendez.org/post/r_did_ring/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/r_did_ring/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>What happens to home prices when a registered sex offender moves into a neighborhood &amp;mdash; and, just as important, how do we &lt;em>know&lt;/em> we measured it right? In a famous 2008 paper, Linden and Rockoff used a clever idea: compare homes very close to the offender&amp;rsquo;s address with homes a little farther away, before and after arrival. They concluded that prices inside one tenth of a mile dropped by &lt;strong>about 7.5 %&lt;/strong>. But that conclusion rested on a single research design choice &amp;mdash; the radius of the &amp;ldquo;treated&amp;rdquo; ring &amp;mdash; and changing that radius changed the answer.&lt;/p>
&lt;p>This tutorial reproduces and extends their analysis using two estimators in increasing order of flexibility. The first is the &lt;strong>parametric ring DiD&lt;/strong>: collapse the data into &amp;ldquo;inner ring&amp;rdquo; (treated) and &amp;ldquo;outer ring&amp;rdquo; (control), first-difference the outcome, and fit a one-line regression. The second is the &lt;strong>nonparametric ring DiD&lt;/strong> of &lt;a href="https://doi.org/10.1016/j.jue.2022.103493" target="_blank" rel="noopener">Butts (2023)&lt;/a>, which uses the partitioning-based binscatter of &lt;a href="https://doi.org/10.1257/aer.20221254" target="_blank" rel="noopener">Cattaneo, Crump, Farrell, and Feng&lt;/a> to estimate a whole &lt;strong>treatment-effect curve over distance&lt;/strong> instead of a single number. We will see that on the Linden-Rockoff data, the parametric ring DiD returns a price drop of &lt;strong>−5.78 %&lt;/strong> at the canonical 0.1-mile cutoff. The nonparametric estimator, by contrast, says homes inside the first 300 feet drop by &lt;strong>−20.6 %&lt;/strong>, and the effect fades to noise beyond ~0.094 mile. Both numbers are correct; they answer slightly different questions.&lt;/p>
&lt;p>The post follows the methodology of Butts (2023) and reuses the cleaned Linden-Rockoff data from his replication archive. Where the paper is research-grade and compact, we trade some compactness for pedagogy &amp;mdash; the same methods, the same data, but rearranged so a reader who has only seen the textbook 2 × 2 DiD can follow the argument step by step.&lt;/p>
&lt;p>&lt;strong>Learning objectives.&lt;/strong> After working through this tutorial you will be able to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Understand&lt;/strong> why a point in space can serve as a natural experiment and what the &amp;ldquo;ring&amp;rdquo; approach is doing in plain language.&lt;/li>
&lt;li>&lt;strong>Implement&lt;/strong> the parametric ring DiD in R as a one-line &lt;code>feols()&lt;/code> regression on first-differenced outcomes.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> a treatment-effect curve nonparametrically with &lt;code>binsreg&lt;/code>, without committing to a ring cutoff up front.&lt;/li>
&lt;li>&lt;strong>Assess&lt;/strong> the fragility of the parametric ring estimator when the inner-ring choice changes, on both simulated and real data.&lt;/li>
&lt;li>&lt;strong>Compare&lt;/strong> the parametric headline number with its nonparametric counterpart and articulate why the two can differ by a factor of two.&lt;/li>
&lt;/ul>
&lt;h3 id="key-concepts-at-a-glance">Key concepts at a glance&lt;/h3>
&lt;p>The tutorial leans on a small vocabulary repeatedly. The body sections assume you can move between these terms quickly. Each concept below has three parts. The &lt;strong>definition&lt;/strong> is always visible. The &lt;strong>example&lt;/strong> and &lt;strong>analogy&lt;/strong> sit behind clickable cards: open them when you need them, leave them collapsed for a quick scan. If a later section mentions &amp;ldquo;ring choice&amp;rdquo; or &amp;ldquo;local parallel trends&amp;rdquo; and the term feels slippery, this is the section to re-read.&lt;/p>
&lt;p>&lt;strong>1. Ring DiD.&lt;/strong>
A difference-in-differences design where the &amp;ldquo;treated&amp;rdquo; and &amp;ldquo;control&amp;rdquo; groups are defined by distance to a treatment point, not by policy assignment. Treated units sit inside a small radius around the point; control units sit in a donut just outside that radius.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>In the Linden-Rockoff data, an &amp;ldquo;offender&amp;rdquo; is the point. &amp;ldquo;Treated&amp;rdquo; homes are those sold within 0.1 mile of the offender&amp;rsquo;s eventual address ($\mathcal{D}_t$ in Butts&amp;rsquo;s notation); &amp;ldquo;control&amp;rdquo; homes are those between 0.1 and 0.3 mile ($\mathcal{D}_c$). The analysis sample inside 1/3 mile has &lt;strong>9,092 transactions&lt;/strong>; 1,093 of them are in the inner ring.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A speaker on a stage is loud nearby and inaudible across the building. To measure how much louder the room got, compare the people sitting in the first five rows (&amp;ldquo;treated&amp;rdquo;) with the people in rows six through twenty (&amp;ldquo;control&amp;rdquo;) just before and just after the speaker started &amp;mdash; not with the people in another building entirely.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>2. Parametric ring estimator.&lt;/strong>
A one-line regression of the &lt;em>first-differenced&lt;/em> outcome on a &amp;ldquo;treated ring&amp;rdquo; indicator. Returns a single number: the average treatment effect inside the chosen inner ring, measured against the chosen outer ring as the counterfactual trend.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>In R: &lt;code>feols(delta_log_price ~ inside_0_1_mi | srn_year, cluster = &amp;quot;neighborhood&amp;quot;)&lt;/code>. On the Linden-Rockoff sample with inner ring (0, 0.1] and outer ring (0.1, 0.3], the coefficient is &lt;strong>−0.0595 log-points = −5.78 %&lt;/strong> with cluster-robust SE 0.0225.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>It is like answering &amp;ldquo;how much did the average classroom temperature change when we opened a window&amp;rdquo; with one number for the rows near the window and one for the back of the room. You get a clean summary &amp;mdash; but you have already decided where the &amp;ldquo;near&amp;rdquo; zone ends.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>3. Nonparametric ring estimator (&lt;code>binsreg&lt;/code>).&lt;/strong>
Instead of one inner-ring number, the estimator partitions distance into a sequence of data-driven, quantile-spaced bins and reports a separate $\hat{\tau}$ in each bin. The output is a step function over distance.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>On the Linden-Rockoff data, &lt;code>binsreg&lt;/code> carves the (0, 0.3] mile sample into &lt;strong>23 quantile-spaced bins&lt;/strong>. Bin 1 (roughly the first 300 feet) returns $\hat{\tau} = -20.6\%$; bin 2 returns $-15.2\%$; bins 3 through 4 are not significantly different from zero.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Instead of asking &amp;ldquo;is it warmer near the window, yes or no?&amp;rdquo;, you walk a thermometer from window to wall in equal-population steps and write down the reading at each step. You end with a temperature &lt;em>curve&lt;/em> rather than a single label.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>4. ATT and the ring choice.&lt;/strong>
The parameter estimated by the ring DiD is the average treatment effect among the treated, $E[\tau(d) \mid d \le \bar{d}]$. Crucially, $\bar{d}$ enters this expression. Change the inner-ring cutoff and you have changed the &lt;em>estimand&lt;/em>, not just the precision.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>On Linden-Rockoff, the parametric ATT goes from &lt;strong>−6.40 %&lt;/strong> at cutoff 0.05 mi, to &lt;strong>−5.45 %&lt;/strong> at 0.10 mi, to &lt;strong>−4.21 %&lt;/strong> at 0.15 mi &amp;mdash; a 52 % relative spread driven entirely by the researcher&amp;rsquo;s choice of $\bar{d}$.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>&amp;ldquo;What fraction of voters in the city support a policy?&amp;rdquo; depends on where you draw the city limits. Move the boundary by a few blocks and you can change the answer. The boundary is not nuisance; it is part of the question.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>5. Local parallel trends.&lt;/strong>
The identifying assumption for the ring approach: absent treatment, the average change in outcomes would have been the same in the inner and outer ring. Formally (Butts 2023, Assumption 2), $E[\Delta Y_{i}(0) \mid d \le \bar{d}] = E[\Delta Y_{i}(0) \mid d &amp;gt; \bar{d}]$.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>For the Linden-Rockoff design to identify the causal effect of arrival, the neighborhood trend in inner-ring prices &amp;mdash; absent the offender &amp;mdash; must match the trend in outer-ring prices. The nonparametric estimator&amp;rsquo;s behavior past 0.1 mile (point estimates oscillating around zero) is the closest informal pre-trend test the cross-sectional data admit.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two students sitting in the same lecture hall normally take notes at similar speeds. If one is suddenly handed a coffee, you can compare their notes &amp;mdash; &lt;em>as long as&lt;/em> nothing else differentially affected the two seats that day. Local parallel trends is the &amp;ldquo;nothing else&amp;rdquo; part.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>6. Sample-weighted ATT.&lt;/strong>
When summarizing a step function into a single inner-ring scalar, average $\hat{\tau}(d)$ weighted by the &lt;strong>number of observations in each bin&lt;/strong>, not by the number of bins. Two estimators that look similar on the curve can give noticeably different scalars if one bin is very wide and another is very narrow.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>A bin-equal-weight average of the first four nonparametric bins yields &lt;strong>−11.4 %&lt;/strong>. Re-weighting by observations inside 0.1 mile (the sample-weighted ATT used in this post) shifts it to &lt;strong>−12.4 %&lt;/strong>. Same data, different summary, third significant figure moves.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>If you average the temperatures of three rooms in a building, the answer depends on whether you weight each room equally or weight by how many people are in each room. A packed lecture hall counts more than an empty closet.&lt;/p>
&lt;/details>
&lt;/div>
&lt;h3 id="methodological-flow">Methodological flow&lt;/h3>
&lt;p>The diagram below is the roadmap for everything that follows. The script (and the body of this post) starts in the safe world of simulation, where we know the right answer, and only then steps onto Linden and Rockoff&amp;rsquo;s real-world data, where we do not.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">flowchart TD
A[&amp;quot;Step 1&amp;lt;br/&amp;gt;Toy ring geometry&amp;quot;] --&amp;gt; B[&amp;quot;Step 2&amp;lt;br/&amp;gt;2×2 DiD recap&amp;quot;]
B --&amp;gt; C[&amp;quot;Step 3&amp;lt;br/&amp;gt;Simulated DGP&amp;lt;br/&amp;gt;true τ-curve known&amp;quot;]
C --&amp;gt; D[&amp;quot;Step 4&amp;lt;br/&amp;gt;Parametric ring DiD&amp;lt;br/&amp;gt;one number per ring&amp;quot;]
C --&amp;gt; E[&amp;quot;Step 5&amp;lt;br/&amp;gt;Ring-choice fragility&amp;lt;br/&amp;gt;same data, 3 answers&amp;quot;]
C --&amp;gt; F[&amp;quot;Step 6&amp;lt;br/&amp;gt;Nonparametric ring DiD&amp;lt;br/&amp;gt;whole TE curve&amp;quot;]
D --&amp;gt; G[&amp;quot;Step 7&amp;lt;br/&amp;gt;Linden-Rockoff data&amp;lt;br/&amp;gt;9,092 home sales&amp;quot;]
E --&amp;gt; G
F --&amp;gt; G
G --&amp;gt; H[&amp;quot;Steps 8–10&amp;lt;br/&amp;gt;Bandwidth, parametric,&amp;lt;br/&amp;gt;nonparametric on real data&amp;quot;]
H --&amp;gt; I[&amp;quot;Result&amp;lt;br/&amp;gt;−5.78% parametric&amp;lt;br/&amp;gt;−20.6% nonparametric (bin 1)&amp;quot;]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#6a9bcc,stroke:#141413,color:#fff
style E fill:#d97757,stroke:#141413,color:#fff
style F fill:#00d4c8,stroke:#141413,color:#141413
style G fill:#d97757,stroke:#141413,color:#fff
style H fill:#6a9bcc,stroke:#141413,color:#fff
style I fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>The first two steps build the spatial intuition and recall the textbook 2 × 2 DiD so we can re-cast the ring DiD as the same machinery with distance-defined groups. Steps 3–6 use a simulated data-generating process (DGP) where we know the true treatment-effect curve, so the estimators can be judged against ground truth. Steps 7–10 carry the same estimators onto the Linden-Rockoff data and reconcile what the two estimators say about a real neighborhood.&lt;/p>
&lt;h2 id="2-setup-and-packages">2. Setup and packages&lt;/h2>
&lt;p>The script uses &lt;code>pacman::p_load()&lt;/code> so that any missing package is installed from CRAN on first run. We set a single global seed at the top, so every simulated number in the post is reproducible.&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(42)
if (!require(&amp;quot;pacman&amp;quot;)) {
install.packages(&amp;quot;pacman&amp;quot;, repos = &amp;quot;https://cloud.r-project.org&amp;quot;)
}
pacman::p_load(
tidyverse, fixest, haven, data.table,
binsreg, KernSmooth, lpridge,
ggplot2, patchwork, sf, glue, scales, broom
)
&lt;/code>&lt;/pre>
&lt;p>The two workhorse packages are &lt;a href="https://cran.r-project.org/package=fixest" target="_blank" rel="noopener">&lt;code>fixest&lt;/code>&lt;/a> for fast fixed-effects regressions (the &lt;code>feols()&lt;/code> function) and &lt;a href="https://cran.r-project.org/package=binsreg" target="_blank" rel="noopener">&lt;code>binsreg&lt;/code>&lt;/a> for the data-driven binscatter that powers the nonparametric estimator.&lt;/p>
&lt;p>The data live in Butts&amp;rsquo;s replication archive. The script reads them from GitHub raw, with a local-file fallback so the code runs even before this post is pushed:&lt;/p>
&lt;pre>&lt;code class="language-r">data_url &amp;lt;- paste0(
&amp;quot;https://raw.githubusercontent.com/cmg777/&amp;quot;,
&amp;quot;starter-academic-v501/master/content/post/&amp;quot;,
&amp;quot;r_did_ring/linden_rockoff.dta&amp;quot;
)
linden_rockoff &amp;lt;- tryCatch(
haven::read_dta(data_url),
error = function(e) haven::read_dta(&amp;quot;linden_rockoff.dta&amp;quot;)
)
&lt;/code>&lt;/pre>
&lt;p>This pattern &amp;mdash; &lt;em>try GitHub, fall back to local&lt;/em> &amp;mdash; means the same script runs in three places without edits: on a fresh clone, in a Quarto notebook, or in a Google Colab session.&lt;/p>
&lt;h2 id="3-step-1-----picturing-the-design-who-is-treated-who-is-control-who-is-irrelevant">3. Step 1 &amp;mdash; Picturing the design: who is treated, who is control, who is irrelevant&lt;/h2>
&lt;p>Before any regression, it helps to see the design on paper. We scatter 2,000 random &amp;ldquo;homes&amp;rdquo; inside a 1.5 × 1.5 unit square, drop a treatment point at the center, and color homes by their ring membership: inside the treated disk of radius 0.2, inside the control donut from 0.2 to 0.5, or too far away to enter the comparison.&lt;/p>
&lt;pre>&lt;code class="language-r">n_points &amp;lt;- 2000
points &amp;lt;- tibble(
x = runif(n_points, -0.75, 0.75),
y = runif(n_points, -0.75, 0.75)
) |&amp;gt;
mutate(
dist = sqrt(x^2 + y^2),
group = case_when(
dist &amp;lt;= 0.2 ~ &amp;quot;Treated (inner ring)&amp;quot;,
dist &amp;lt;= 0.5 ~ &amp;quot;Control (outer ring)&amp;quot;,
TRUE ~ &amp;quot;Not used&amp;quot;
)
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 1] Toy spatial layout
Total points: 2000
Control (outer ring) Not used Treated (inner ring)
566 1308 126
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_01_ring_geometry.png" alt="Ring geometry: treatment as a point, groups as distances.">
&lt;em>Toy ring geometry: 126 treated, 566 control, 1,308 dropped out of 2,000 random points.&lt;/em>&lt;/p>
&lt;p>Out of 2,000 random homes, only &lt;strong>126 (6.3 %)&lt;/strong> fall inside the treated ring and &lt;strong>566 (28.3 %)&lt;/strong> fall inside the outer control ring; the remaining &lt;strong>1,308 (65.4 %)&lt;/strong> are too far away to enter the analysis. This 6 / 28 / 65 split is the price of the ring approach: identification rests on a small treated group, a moderate control group, and a large number of &amp;ldquo;irrelevant&amp;rdquo; observations whose only role here is to remind us that distance, not policy assignment, defines who is in and who is out. With smaller samples this can hurt; with the Linden-Rockoff data set (170,239 home sales, of which 9,092 are within 1/3 mile of some offender), the inner ring still has hundreds of transactions and the design is feasible.&lt;/p>
&lt;h2 id="4-step-2-----a-quick-refresher-the-2--2-did-in-4-cells">4. Step 2 &amp;mdash; A quick refresher: the 2 × 2 DiD in 4 cells&lt;/h2>
&lt;p>Every ring DiD is built on the same 2 × 2 difference-in-differences logic you have probably seen for a textbook policy reform. The estimand is the average treatment effect among the treated:&lt;/p>
&lt;p>$$\tau = E[\Delta Y \mid \text{treated}] - E[\Delta Y \mid \text{control}].$$&lt;/p>
&lt;p>In words, this says: the average change in outcome for the treated group, minus the average change in outcome for the control group &amp;mdash; a &lt;em>difference of differences&lt;/em>. Mapped to code, $\Delta Y$ is &lt;code>delta_y&lt;/code> (the first-differenced outcome) and &amp;ldquo;treated&amp;rdquo; is a 0/1 indicator. There are two algebraically equivalent ways to estimate $\tau$:&lt;/p>
&lt;p>$$Y_{it} = \alpha_i + \gamma_t + \tau \cdot D_i \cdot P_t + \varepsilon_{it}.$$&lt;/p>
&lt;p>This two-way fixed-effects (TWFE) form says: each unit $i$ has its own price level $\alpha_i$, each period $t$ has its own trend $\gamma_t$, and $\tau$ captures the &lt;em>extra&lt;/em> movement experienced by treated units in the post period. The TWFE coefficient on the interaction $D_i \cdot P_t$ is the same number you would get by regressing $\Delta Y$ on $D$ alone on a first-differenced panel. Section 2 of the script verifies this on a 500-unit panel with a true effect of 0.30:&lt;/p>
&lt;pre>&lt;code class="language-r">panel &amp;lt;- tibble(
i = rep(1:500, each = 2),
t = rep(c(0, 1), 500),
treat = rep(rbinom(500, 1, 0.5), each = 2),
y = rnorm(1000) + 0.3 * (treat * (t == 1))
)
fd &amp;lt;- feols(I(y[t==1] - y[t==0]) ~ treat, data = panel |&amp;gt; distinct(i, treat))
twfe &amp;lt;- feols(y ~ I(treat * (t == 1)) | i + t, data = panel)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 2] Classical 2x2 DiD (true effect = 0.3)
(a) first-differences coefficient: 0.31 (SE 0.026)
(b) two-way FE coefficient : 0.31 (SE 0.026)
&lt;/code>&lt;/pre>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Estimator&lt;/th>
&lt;th style="text-align:right">Estimate&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th style="text-align:right">True effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>First-differences (&lt;code>feols(delta_y ~ treat)&lt;/code>)&lt;/td>
&lt;td style="text-align:right">0.3097&lt;/td>
&lt;td style="text-align:right">0.0258&lt;/td>
&lt;td style="text-align:right">0.30&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Two-way FE (`feols(y ~ treat:post&lt;/td>
&lt;td style="text-align:right">i + t)`)&lt;/td>
&lt;td style="text-align:right">0.3097&lt;/td>
&lt;td style="text-align:right">0.0258&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two estimators return &lt;strong>numerically identical&lt;/strong> point estimates (0.3097 to four decimals) and SEs (0.0258), both within one SE of the true 0.30. The equivalence is algebraic, not approximate, and it is the reason the ring DiD can be written as a one-line regression on first-differenced outcomes (next section). Everything that follows is &amp;ldquo;2 × 2 DiD, but the groups are defined by distance instead of by policy assignment.&amp;rdquo;&lt;/p>
&lt;h2 id="5-step-3-----a-simulated-world-where-we-know-the-right-answer">5. Step 3 &amp;mdash; A simulated world where we know the right answer&lt;/h2>
&lt;p>To judge the estimators fairly, we first build a world where the truth is known. We draw 10,000 units, give each a distance $d$ uniform on $[0, 1.5]$ miles, and define the true treatment-effect curve as a smooth exponential that vanishes exactly at 0.75 mile:&lt;/p>
&lt;p>$$\tau(d) = 1.5 \cdot \exp(-2.3 \cdot d) \cdot \mathbf{1}{d \le 0.75}.$$&lt;/p>
&lt;p>In words, this says: the treatment effect is largest right at the offender ($\approx +1.5$ at $d = 0$), decays smoothly with distance, and is &lt;strong>exactly zero&lt;/strong> beyond 0.75 mile. The number 0.75 is what Butts calls $d_t$ &amp;mdash; the maximum distance at which treatment effects are felt. The average true effect across the affected region $[0, 0.75]$ is the integral of $\tau(d)$ divided by 0.75, which evaluates to &lt;strong>0.726&lt;/strong>. That number is the benchmark every estimator below has to recover.&lt;/p>
&lt;p>&lt;img src="r_did_ring_02_dgp_curve.png" alt="The data-generating process: true treatment-effect curve.">
&lt;em>True treatment-effect curve $\tau(d) = 1.5 \cdot \exp(-2.3 \cdot d)$, zero past 0.75 mile; mean over the affected region equals 0.726.&lt;/em>&lt;/p>
&lt;pre>&lt;code class="language-text">[Section 3] Simulated DGP for the parametric ring estimator
n units: 10000
Average true TE among d &amp;lt;= 0.75 mi: 0.726
&lt;/code>&lt;/pre>
&lt;p>The orange curve in the figure is $\tau(d)$, and the grey baseline is the counterfactual trend (zero everywhere in this simulation). Pedagogically, this is the cleanest case: the treatment effect is &lt;strong>monotonically decreasing&lt;/strong> in distance, &lt;strong>strictly positive&lt;/strong> out to $d_t = 0.75$, and &lt;strong>exactly zero&lt;/strong> beyond. A real-world spatial treatment will rarely have such a clean shape, but the point is to ask: do our estimators recover this benchmark when the answer is known?&lt;/p>
&lt;h2 id="6-step-4-----the-parametric-ring-estimator-on-simulated-data">6. Step 4 &amp;mdash; The parametric ring estimator on simulated data&lt;/h2>
&lt;p>The parametric ring DiD is a one-line &lt;code>feols()&lt;/code> call on first-differenced outcomes (or, equivalently, the TWFE form). Given a &lt;em>correct&lt;/em> inner-ring choice &amp;mdash; inner $= (0, 0.75]$, outer $= (0.75, 1.5]$ &amp;mdash; the estimator should average the true $\tau(d)$ across the inner ring and return 0.726.&lt;/p>
&lt;p>The body of &lt;code>parametric_ring_panel()&lt;/code> (and its Linden-Rockoff sibling &lt;code>parametric_ring_lr()&lt;/code>, plus the nonparametric helper &lt;code>nonparametric_ring_cs()&lt;/code> used later) lives in &lt;code>analysis.R&lt;/code>; each is a thin wrapper around a single &lt;code>feols()&lt;/code> or &lt;code>binsreg::binsreg()&lt;/code> call. The snippets below show the call signature, not the helper body.&lt;/p>
&lt;pre>&lt;code class="language-r">ring_dgp &amp;lt;- ring_data |&amp;gt;
mutate(treat_ring = as.integer(dist &amp;lt;= 0.75)) |&amp;gt;
feols(delta_y ~ treat_ring, cluster = &amp;quot;neighborhood&amp;quot;, data = _)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Parametric ring DiD (rings = 0, 0.75, 1.5):
tau_hat = 0.726 SE = 0.005 truth = 0.726
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_03_parametric_estimate.png" alt="Parametric ring DiD: one number per ring.">
&lt;em>Parametric ring DiD at the correct cutoff recovers the truth: $\hat{\tau} = 0.726$, 95 % CI $[0.716, 0.736]$.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Bin&lt;/th>
&lt;th>Distance interval (mi)&lt;/th>
&lt;th style="text-align:right">τ̂&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">1&lt;/td>
&lt;td>(0, 0.75]&lt;/td>
&lt;td style="text-align:right">0.726&lt;/td>
&lt;td style="text-align:right">0.005&lt;/td>
&lt;td>[0.716, 0.736]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2&lt;/td>
&lt;td>(0.75, 1.5]&lt;/td>
&lt;td style="text-align:right">0.000&lt;/td>
&lt;td style="text-align:right">0.000&lt;/td>
&lt;td>[0.000, 0.000]&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Given the correct ring choice, the parametric estimator recovers the true average treatment effect to &lt;strong>three decimal places&lt;/strong>: $\hat{\tau} = 0.726$, $\mathrm{SE} = 0.005$, with a 95 % CI of $[0.716, 0.736]$ centered exactly on the truth. The outer-ring coefficient is normalized to zero by construction, because the outer ring is what the estimator &lt;em>defines&lt;/em> as the counterfactual trend. This is the strongest possible internal validity check: when the inner ring is set to the exact distance at which treatment effects vanish, the parametric ring DiD is unbiased. The catch is that we know 0.75 only because we wrote the DGP ourselves. In a real application, $d_t$ is the very thing we are trying to learn.&lt;/p>
&lt;h2 id="7-step-5-----why-ring-choice-is-part-of-the-question">7. Step 5 &amp;mdash; Why ring choice is part of the question&lt;/h2>
&lt;p>Hold the data, the seed, and the regression fixed, and re-run the same parametric estimator with three different inner-ring cutoffs: $\bar{d} = 0.30$ (too narrow), $\bar{d} = 0.75$ (correct), and $\bar{d} = 1.20$ (too wide).&lt;/p>
&lt;pre>&lt;code class="language-r">choices &amp;lt;- tibble(
cut_inner = c(0.30, 0.75, 1.20),
label = c(&amp;quot;Too narrow&amp;quot;, &amp;quot;Correct&amp;quot;, &amp;quot;Too wide&amp;quot;)
)
ringchoice &amp;lt;- choices |&amp;gt;
rowwise() |&amp;gt;
mutate(fit = list(parametric_ring_panel(ring_data, cut_inner)))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 4] Ring-choice sensitivity on simulated data
# A tibble: 3 × 5
choice tau_hat se ci_lower ci_upper
1 Correct: (0, 0.75] 0.726 0.00512 0.716 0.736
2 Too narrow: (0, 0.30] 0.913 0.00598 0.902 0.925
3 Too wide: (0, 1.20] 0.456 0.0102 0.436 0.476
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_04_ringchoice_problem.png" alt="Three ring choices on the same DGP.">
&lt;em>Same data, three ring choices: 0.913 (too narrow), 0.726 (correct), 0.456 (too wide). All three 95 % CIs exclude the truth in the bad cases.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Choice&lt;/th>
&lt;th style="text-align:right">τ̂&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;th>Direction of bias&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Correct: (0, 0.75]&lt;/td>
&lt;td style="text-align:right">0.726&lt;/td>
&lt;td style="text-align:right">0.005&lt;/td>
&lt;td>[0.716, 0.736]&lt;/td>
&lt;td>none &amp;mdash; recovers the truth&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Too narrow: (0, 0.30]&lt;/td>
&lt;td style="text-align:right">&lt;strong>0.913&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.006&lt;/td>
&lt;td>[0.902, 0.925]&lt;/td>
&lt;td>upward: averages the &lt;em>steepest&lt;/em> part of $\tau(d)$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Too wide: (0, 1.20]&lt;/td>
&lt;td style="text-align:right">&lt;strong>0.456&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.010&lt;/td>
&lt;td>[0.436, 0.476]&lt;/td>
&lt;td>toward zero: absorbs unaffected units&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Same data, three answers. With a too-narrow inner ring the estimator returns &lt;strong>0.913&lt;/strong> &amp;mdash; a &lt;strong>+25.7 %&lt;/strong> upward bias, because we are averaging only the steepest part of the $\tau(d)$ curve and missing the slower decay. With a too-wide inner ring the estimator returns &lt;strong>0.456&lt;/strong> &amp;mdash; a &lt;strong>−37.1 %&lt;/strong> attenuation, because we are absorbing many units with literally zero treatment effect into the &amp;ldquo;treated&amp;rdquo; group and diluting the average. Neither number is sampling noise: both 95 % CIs strictly exclude the truth (0.726). The lesson the simulated experiment teaches before we even touch Linden-Rockoff is that &lt;strong>ring choice is part of the estimand&lt;/strong>, not just a precision lever. Pick a different ring, and the parametric estimator literally answers a different causal question. This is why we need a second estimator.&lt;/p>
&lt;h2 id="8-step-6-----letting-the-data-choose-the-nonparametric-estimator">8. Step 6 &amp;mdash; Letting the data choose: the nonparametric estimator&lt;/h2>
&lt;p>Where the parametric estimator gives one number, Butts&amp;rsquo;s nonparametric estimator gives a whole step function. The idea, formalized in Cattaneo, Crump, Farrell, and Feng (2024), is to partition the support of distance into $L$ quantile-spaced bins, fit a flat constant inside each bin, and difference each bin&amp;rsquo;s average from the average of the last (presumed-untreated) bin. The number of bins $L$ is chosen by the data via a mean-squared-error criterion in &lt;code>binsreg&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-r">np_sim &amp;lt;- binsreg::binsreg(
y = ring_data$delta_y,
x = ring_data$dist,
randcut = NULL,
cb = c(3, 3),
noplot = TRUE
)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_05_nonparametric_sim.png" alt="Nonparametric ring: recovering the whole curve.">
&lt;em>The nonparametric estimator recovers the whole TE curve from data alone &amp;mdash; 53 quantile-spaced bins, no cutoff committed up front; left-most bin $\hat{\tau} = 1.461$ vs truth 1.5.&lt;/em>&lt;/p>
&lt;pre>&lt;code class="language-text">[Section 5] Nonparametric ring estimator on simulated DGP
Number of distance bins: 53
TE estimate in left-most bin: 1.461
&lt;/code>&lt;/pre>
&lt;p>On the simulated DGP with $n = 10{,}000$ units, &lt;code>binsreg&lt;/code> chooses &lt;strong>53 quantile-spaced bins&lt;/strong>. The left-most bin (about $[0, 0.025]$ mi) returns $\hat{\tau} = 1.461$ &amp;mdash; within one SE of the truth at $d = 0$, which is 1.5. Successive bins step &lt;em>monotonically&lt;/em> downward as we move outward, eventually crossing zero around 0.75 mile where the true $\tau(d)$ vanishes. We never had to commit to a ring cutoff up front; the data revealed the shape of the curve. The price is that we now have 53 noisy bin estimates instead of one tidy headline, and CIs widen as the bins get narrower in the tails. But the methodological payoff is exactly the rebuttal to Step 5: when the data are rich enough, the answer to &amp;ldquo;which ring should I pick?&amp;rdquo; is &amp;ldquo;you don&amp;rsquo;t have to.&amp;rdquo;&lt;/p>
&lt;h2 id="9-step-7-----linden-and-rockoff-a-real-neighborhood-a-real-arrival">9. Step 7 &amp;mdash; Linden and Rockoff: a real neighborhood, a real arrival&lt;/h2>
&lt;p>We now leave the safe world of simulation and walk the same estimators onto Linden and Rockoff&amp;rsquo;s data: 170,239 home transactions in North Carolina, geocoded relative to the eventual addresses of registered sex offenders. The analysis sample is the &lt;strong>9,092 sales within 1/3 mile&lt;/strong> of an offender&amp;rsquo;s address. Each transaction records the log sale price, the distance to the offender, and whether the sale closed before or after the offender&amp;rsquo;s arrival.&lt;/p>
&lt;pre>&lt;code class="language-r">linden_rockoff &amp;lt;- haven::read_dta(&amp;quot;linden_rockoff.dta&amp;quot;) |&amp;gt;
filter(offender == 1) |&amp;gt;
mutate(
dist_mi = dist / 5280, # original distance in feet
inner = as.integer(dist_mi &amp;lt;= 0.1),
post = as.integer(t_to_arrival &amp;gt; 0)
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 6.2] Linden-Rockoff data
Rows: 170239 Cols: 51
Analysis sample (offender == 1): 9092
Mean log price: 11.73
Distance summary (miles): min 0.009 median 0.224 max 0.333
&lt;/code>&lt;/pre>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Ring&lt;/th>
&lt;th style="text-align:right">Pre-arrival&lt;/th>
&lt;th style="text-align:right">Post-arrival&lt;/th>
&lt;th style="text-align:right">Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inner (≤ 0.1 mi)&lt;/td>
&lt;td style="text-align:right">499&lt;/td>
&lt;td style="text-align:right">594&lt;/td>
&lt;td style="text-align:right">1,093&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Outer (0.1 – 0.3 mi)&lt;/td>
&lt;td style="text-align:right">3,998&lt;/td>
&lt;td style="text-align:right">4,001&lt;/td>
&lt;td style="text-align:right">7,999&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>4,497&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>4,595&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>9,092&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 2 × 2 cell counts above are the entire foundation of the analysis. Only &lt;strong>1,093 sales (12 %)&lt;/strong> fall in the inner treated ring at or under 0.1 mile, split nearly evenly between pre- and post-arrival (499 vs 594). The outer control ring carries &lt;strong>7,999 sales (88 %)&lt;/strong>, also nearly balanced across the cutoff date. Median distance is 0.224 mile and the support runs from 0.009 mile (essentially adjacent to the offender&amp;rsquo;s address) to 0.333 mile (the outer boundary). The treated cells are small but not tiny; this is what makes the nonparametric estimator viable even on a single neighborhood&amp;rsquo;s worth of data.&lt;/p>
&lt;p>&lt;img src="r_did_ring_06_lr_gradient.png" alt="Pre vs post offender-arrival price gradient over distance.">
&lt;em>Linden-Rockoff raw price gradient: a \$20–25K gap inside 0.1 mile, closing monotonically with distance.&lt;/em>&lt;/p>
&lt;p>Before any estimator runs, the raw price gradient already tells the story. Inside 0.1 mile of the offender&amp;rsquo;s eventual address, the &lt;strong>pre-arrival&lt;/strong> kernel-smoothed average home price stays near &lt;strong>\$145–\$150K&lt;/strong> out to the treated-ring boundary. The &lt;strong>post-arrival&lt;/strong> smoother dips to roughly &lt;strong>\$122K at $d \approx 0.01$ mi&lt;/strong> and climbs back to about &lt;strong>\$140K by 0.1 mile&lt;/strong>, a visible gap of &lt;strong>\$20–25K&lt;/strong> at the offender&amp;rsquo;s address that closes monotonically with distance. Outside 0.1 mile the two curves overlap. The descriptive plot is the visual argument that motivates the entire ring DiD design: the pre curve is what inner-ring sales &amp;ldquo;would have looked like&amp;rdquo; absent the offender; the post curve is what they actually look like; the area between them inside 0.1 mile is the treatment effect. The plot also justifies the choice of ~0.1 mile as the conventional treated radius &amp;mdash; it is the eyeball point where the two curves reconverge.&lt;/p>
&lt;h2 id="10-step-8-----bandwidth-fragility-why-eyeballing-the-cutoff-is-risky">10. Step 8 &amp;mdash; Bandwidth fragility: why eyeballing the cutoff is risky&lt;/h2>
&lt;p>The raw-gradient plot above used one specific bandwidth choice (0.075 mile). What happens if we move it?&lt;/p>
&lt;p>The snippet below is illustrative &amp;mdash; &lt;code>dist&lt;/code>, &lt;code>price_pre&lt;/code>, &lt;code>price_post&lt;/code>, and &lt;code>grid&lt;/code> are placeholder names for the distance vector, the pre- and post-arrival prices, and the evaluation grid; &lt;code>analysis.R&lt;/code> defines them concretely.&lt;/p>
&lt;pre>&lt;code class="language-r">bws &amp;lt;- c(0.025, 0.075, 0.125)
smooth_panels &amp;lt;- bws |&amp;gt;
map_dfr(function(b) {
pre &amp;lt;- lpridge::lpepa(dist, price_pre, bw = b, x.out = grid)
post &amp;lt;- lpridge::lpepa(dist, price_post, bw = b, x.out = grid)
tibble(dist = grid, pre = pre$y, post = post$y, bw = b)
})
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_07_lr_bandwidth.png" alt="Three bandwidths, same data.">
&lt;em>Same data, three smoothing bandwidths &amp;mdash; implied treated radius shifts from ~0.10 mi (bw 0.025) to ~0.20 mi (bw 0.125).&lt;/em>&lt;/p>
&lt;p>At bandwidth &lt;strong>0.025 mi&lt;/strong> (very local), the post curve dips sharply below the pre curve only inside about 0.10 mile and recovers fast &amp;mdash; you might read off a treated radius of 0.10 by eye. At bandwidth &lt;strong>0.075 mi&lt;/strong> (the default used above), the gap extends out to about 0.15 mile before closing. At bandwidth &lt;strong>0.125 mi&lt;/strong> (heavy smoothing), the curves diverge gently across the entire panel out to 0.30 mile, suggesting a treated radius of about 0.20 mile. Same data, three smoothers, three different visual answers about &lt;em>how far&lt;/em> the treatment effect extends. This is the bandwidth-version of the ring-choice fragility lesson from Step 5, now staring at us in real-world data. The figure is the empirical case for &lt;strong>not&lt;/strong> picking a ring cutoff by inspection of a smoothed gradient &amp;mdash; and the motivation for the more principled methods that follow.&lt;/p>
&lt;h2 id="11-step-9-----parametric-ring-did-on-linden-rockoff-and-the-ring-choice-wobble">11. Step 9 &amp;mdash; Parametric ring DiD on Linden-Rockoff (and the ring-choice wobble)&lt;/h2>
&lt;p>We now run the parametric estimator on the real data at the canonical inner-ring cutoff of 0.1 mile.&lt;/p>
&lt;pre>&lt;code class="language-r">lr_default &amp;lt;- feols(
delta_log_price ~ close_post_move | srn_year,
cluster = &amp;quot;neighborhood&amp;quot;,
data = linden_rockoff
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 6.5] Parametric ring DiD on Linden-Rockoff
close_post_move coefficient: -0.0595 SE = 0.0225
Interpreted as a percent change: -5.78%
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_08_lr_parametric.png" alt="Parametric ring DiD on Linden-Rockoff at the default ring boundary.">
&lt;em>Parametric ring DiD on Linden-Rockoff at the canonical 0.1 mi: ATT = −5.78 %, 95 % CI $[-10.4\%,\, -1.5\%]$, n = 9,029.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Inner ring&lt;/th>
&lt;th>Outer ring&lt;/th>
&lt;th style="text-align:right">ATT (log)&lt;/th>
&lt;th style="text-align:right">ATT (%)&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;th style="text-align:right">N&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>(0, 0.1]&lt;/td>
&lt;td>(0.1, 0.3]&lt;/td>
&lt;td style="text-align:right">&lt;strong>−0.0595&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>−5.78 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.0225&lt;/td>
&lt;td>[−10.4 %, −1.5 %]&lt;/td>
&lt;td style="text-align:right">9,029&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>At the canonical 0.1-mile inner ring (matching Linden and Rockoff&amp;rsquo;s original choice and Butts&amp;rsquo;s replication setup), the parametric ring DiD delivers a &lt;strong>−0.0595 log-point coefficient&lt;/strong> on &lt;code>close_post_move&lt;/code>, with cluster-robust SE 0.0225 (clustered at the neighborhood level) and a 95 % CI of $[-10.4\%,\, -1.5\%]$ that strictly excludes zero. Here &amp;ldquo;cluster-robust&amp;rdquo; means the standard-error formula allows residuals to be correlated within neighborhoods rather than assuming every transaction is statistically independent; cluster-robust SEs are usually a little larger than the default &lt;code>feols()&lt;/code> SEs and are the right choice when nearby homes plausibly share unobserved local shocks. In percent terms, this is an average price drop of &lt;strong>−5.78 %&lt;/strong> for homes inside 0.1 mile of an offender&amp;rsquo;s address after the offender arrives. Butts (2023, p. 5) reports this magnitude as &lt;em>&amp;ldquo;homes between 0 and 0.1 miles decline in value by about 7.5%&amp;quot;&lt;/em>; our −5.78 % sits about 1.7 percentage points below his approximate number, comfortably within the cluster-robust CI and well inside the spread we will see across reasonable ring choices in the next paragraph. The qualitative answer agrees with the published paper; the headline magnitude is within rounding of it.&lt;/p>
&lt;p>Now we redraw the inner-ring cutoff at 0.05, 0.10, and 0.15 mile, holding the outer ring fixed at 0.3 mile, to test how much that headline depends on the cutoff choice.&lt;/p>
&lt;pre>&lt;code class="language-r">ringchoice_lr &amp;lt;- tibble(cut_inner = c(0.05, 0.10, 0.15)) |&amp;gt;
rowwise() |&amp;gt;
mutate(fit = list(parametric_ring_lr(linden_rockoff, cut_inner)))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 6.6] Ring-choice sensitivity (Linden-Rockoff)
cut_inner att_log att_pct se ci_lower ci_upper n
1 0.05 -0.0661 -6.40 0.0383 -0.141 0.00888 7534
2 0.1 -0.0560 -5.45 0.0239 -0.103 -0.00919 7534
3 0.15 -0.0431 -4.21 0.0180 -0.0784 -0.00768 7534
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_09_lr_ringchoice.png" alt="Three inner-ring cutoffs, same data.">
&lt;em>Three inner-ring cutoffs on the same data: ATT moves from −6.40 % (0.05 mi) to −4.21 % (0.15 mi) &amp;mdash; a 52 % relative spread driven entirely by the cutoff choice.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Inner-ring cutoff&lt;/th>
&lt;th style="text-align:right">ATT (log)&lt;/th>
&lt;th style="text-align:right">ATT (%)&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;th style="text-align:right">N&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">0.05 mi&lt;/td>
&lt;td style="text-align:right">−0.0661&lt;/td>
&lt;td style="text-align:right">&lt;strong>−6.40 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.0383&lt;/td>
&lt;td>[−14.1 %, +0.9 %]&lt;/td>
&lt;td style="text-align:right">7,534&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">0.10 mi&lt;/td>
&lt;td style="text-align:right">−0.0560&lt;/td>
&lt;td style="text-align:right">&lt;strong>−5.45 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.0239&lt;/td>
&lt;td>[−10.3 %, −0.9 %]&lt;/td>
&lt;td style="text-align:right">7,534&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">0.15 mi&lt;/td>
&lt;td style="text-align:right">−0.0431&lt;/td>
&lt;td style="text-align:right">&lt;strong>−4.21 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.0180&lt;/td>
&lt;td>[−7.8 %, −0.8 %]&lt;/td>
&lt;td style="text-align:right">7,534&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The headline number wobbles from &lt;strong>−4.21 %&lt;/strong> (cutoff 0.15) to &lt;strong>−6.40 %&lt;/strong> (cutoff 0.05) &amp;mdash; a relative spread of about &lt;strong>52 %&lt;/strong> of the central estimate. The &lt;strong>sign is stable&lt;/strong> across choices, and every estimate is statistically distinguishable from zero (or borderline so) at conventional levels. But the &lt;strong>magnitude&lt;/strong> moves enough that a reader who only ever sees one of these three numbers gets a noticeably different impression of the policy-relevant effect. This is the same fragility lesson the simulated DGP taught us in Step 5, now reproduced on real data. As Butts (2023, p. 5) puts it: &lt;em>&amp;ldquo;the choice of 0.1 miles is an untestable assumption.&amp;quot;&lt;/em> The parametric ring DiD is a perfectly fine estimator &amp;mdash; conditional on a researcher choice that has no obvious right answer.&lt;/p>
&lt;h2 id="12-step-10-----the-nonparametric-estimator-on-linden-rockoff">12. Step 10 &amp;mdash; The nonparametric estimator on Linden-Rockoff&lt;/h2>
&lt;p>The nonparametric ring DiD frees us from the cutoff. We hand &lt;code>binsreg&lt;/code> the first-differenced log-price outcome and distance to the offender, and let the algorithm decide how to partition the (0, 0.3]-mile support.&lt;/p>
&lt;pre>&lt;code class="language-r">np_lr &amp;lt;- nonparametric_ring_cs(
data = linden_rockoff,
outcome = &amp;quot;delta_log_price&amp;quot;,
dist = &amp;quot;dist_mi&amp;quot;,
cb = c(3, 3)
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">[Section 6.7] Nonparametric ring on Linden-Rockoff
Number of distance bins: 23
Estimated TE averaged inside d &amp;lt;= 0.1 mi: -0.132 (-12.4%)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did_ring_10_lr_nonparametric.png" alt="Nonparametric ring DiD: the treatment-effect curve over distance.">
&lt;em>Nonparametric ring DiD on Linden-Rockoff: 23 bins, two closest bins at −20.6 % and −15.2 %; curve crosses zero at $d \approx 0.094$ mi.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Bin&lt;/th>
&lt;th>Distance interval (mi)&lt;/th>
&lt;th style="text-align:right">τ̂ (log)&lt;/th>
&lt;th style="text-align:right">τ̂ (%)&lt;/th>
&lt;th style="text-align:right">SE&lt;/th>
&lt;th>95% CI (log)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">1&lt;/td>
&lt;td>[0.011, 0.053]&lt;/td>
&lt;td style="text-align:right">&lt;strong>−0.231&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>−20.6 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.056&lt;/td>
&lt;td>[−0.340, −0.121]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2&lt;/td>
&lt;td>[0.054, 0.076]&lt;/td>
&lt;td style="text-align:right">&lt;strong>−0.165&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>−15.2 %&lt;/strong>&lt;/td>
&lt;td style="text-align:right">0.045&lt;/td>
&lt;td>[−0.254, −0.077]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">3&lt;/td>
&lt;td>[0.077, 0.094]&lt;/td>
&lt;td style="text-align:right">−0.030&lt;/td>
&lt;td style="text-align:right">−2.9 %&lt;/td>
&lt;td style="text-align:right">0.048&lt;/td>
&lt;td>[−0.124, +0.064]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">4&lt;/td>
&lt;td>[0.095, 0.110]&lt;/td>
&lt;td style="text-align:right">+0.006&lt;/td>
&lt;td style="text-align:right">+0.6 %&lt;/td>
&lt;td style="text-align:right">0.047&lt;/td>
&lt;td>[−0.087, +0.099]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">5&lt;/td>
&lt;td>[0.111, 0.127]&lt;/td>
&lt;td style="text-align:right">−0.013&lt;/td>
&lt;td style="text-align:right">−1.3 %&lt;/td>
&lt;td style="text-align:right">0.048&lt;/td>
&lt;td>[−0.108, +0.081]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">6&lt;/td>
&lt;td>[0.127, 0.140]&lt;/td>
&lt;td style="text-align:right">−0.100&lt;/td>
&lt;td style="text-align:right">−9.5 %&lt;/td>
&lt;td style="text-align:right">0.048&lt;/td>
&lt;td>[−0.194, −0.006]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">&amp;hellip;&lt;/td>
&lt;td>&amp;hellip; (23 bins total)&lt;/td>
&lt;td style="text-align:right">&lt;/td>
&lt;td style="text-align:right">&lt;/td>
&lt;td style="text-align:right">&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;code>binsreg&lt;/code> partitions the Linden-Rockoff inner sample into &lt;strong>23 quantile-spaced bins&lt;/strong>. The two closest bins &amp;mdash; homes within roughly the first &lt;strong>300 feet&lt;/strong> of the offender&amp;rsquo;s address &amp;mdash; show steep price declines: bin 1 at &lt;strong>−20.6 %&lt;/strong> with 95 % CI $[-34.0\%,\, -12.1\%]$, and bin 2 at &lt;strong>−15.2 %&lt;/strong> with CI $[-25.4\%,\, -7.7\%]$. By bin 3 (about 0.08 mile) the point estimate has collapsed to &lt;strong>−2.9 %&lt;/strong> with a CI that includes zero, and bin 4 (about 0.10 mile) is essentially zero (&lt;strong>+0.6 %&lt;/strong>). Butts (2023, p. 6) describes this exact pattern: &lt;em>&amp;ldquo;homes in the two closest rings i.e. within a few hundred feet, are most affected by sex-offender arrival with an estimated decline of home value of around 20%.&amp;quot;&lt;/em> Our bin-1 estimate of −20.6 % lands on his &amp;ldquo;around 20 %&amp;rdquo; claim almost exactly.&lt;/p>
&lt;p>Averaged across observations inside 0.1 mile (sample-weighted, so that bins with more transactions count more), the nonparametric ATT is &lt;strong>−0.132 log-points = −12.4 %&lt;/strong> &amp;mdash; about &lt;strong>2.1× the parametric estimate&lt;/strong> of −5.78 % at the same boundary. The reconciliation is not mysterious. The parametric estimator forces a single coefficient across the entire (0, 0.1] inner ring. That single coefficient averages over a very strong effect right at the offender&amp;rsquo;s address (bin 1 at −20.6 %) and a near-zero effect at the ring&amp;rsquo;s outer edge (bin 4 at +0.6 %). When we let the curve flex, we recover the &lt;em>concentration&lt;/em> of the effect in the closest few hundred feet that the parametric average hides. The two estimators are not in disagreement; they answer slightly different questions, and the gap between them is itself informative.&lt;/p>
&lt;p>A final detail worth noticing: the nonparametric curve &lt;strong>crosses zero between bins 3 and 4, at about $d \approx 0.094$ mi&lt;/strong> &amp;mdash; strikingly close to the 0.1-mile cutoff that Linden and Rockoff chose by eyeballing the smoothed gradient. The data-driven estimator validates their cutoff &lt;em>as an output of the analysis&lt;/em>, not as an input to it. Butts (2023, p. 6) makes the same point: &lt;em>&amp;ldquo;After 0.1 miles, the estimated treatment effect curve becomes centered at zero consistently.&amp;quot;&lt;/em>&lt;/p>
&lt;h2 id="13-discussion">13. Discussion&lt;/h2>
&lt;p>So: &lt;em>what happens to home prices when a registered sex offender moves into a neighborhood, and how do we know we measured it right?&lt;/em> The substantive answer, on Linden and Rockoff&amp;rsquo;s North Carolina data, is that &lt;strong>homes within a few hundred feet of the offender&amp;rsquo;s eventual address drop by about 20 %&lt;/strong> after arrival, and &lt;strong>the effect fades to noise beyond roughly 0.1 mile&lt;/strong>. A reader who is told only the parametric ring DiD &amp;mdash; &amp;ldquo;prices inside 0.1 mile drop by about 6 %&amp;rdquo; &amp;mdash; gets a correct but &lt;em>attenuated&lt;/em> picture, because the parametric estimator averages a steep close-in effect with a near-zero outer-ring effect. A reader who is told only the leftmost nonparametric bin &amp;mdash; &amp;ldquo;prices inside 300 feet drop by 20 %&amp;rdquo; &amp;mdash; gets a correct but &lt;em>localized&lt;/em> picture that does not describe the average inner-ring home. Both numbers belong in the conversation, and both come out of the same data.&lt;/p>
&lt;p>The methodological lesson is that &lt;strong>the parametric ring estimator&amp;rsquo;s headline number is conditional on the ring choice&lt;/strong>. On the real data, that choice can move the magnitude from −4.2 % to −6.4 % &amp;mdash; a 52 % relative spread driven entirely by the researcher&amp;rsquo;s pick of $\bar{d}$. The nonparametric estimator avoids the choice by letting &lt;code>binsreg&lt;/code> partition the data, and it has the further advantage of revealing the &lt;em>shape&lt;/em> of the treatment-effect curve &amp;mdash; not just its average. In the Linden-Rockoff case, that shape is exactly what one would expect from a hyper-local externality: very strong at zero distance, fading quickly, indistinguishable from zero past about 0.1 mile. This pattern is the empirical case in favor of the data-driven approach, and it is why a reader who has only ever seen the parametric ring DiD should add the nonparametric tool to their kit.&lt;/p>
&lt;p>Two &lt;strong>identification caveats&lt;/strong> are worth flagging before any of this is taken too literally. First, the design rests on &lt;strong>local parallel trends&lt;/strong>: absent the offender, the average price change inside 0.1 mile would have matched the average price change in the 0.1–0.3 mile band. There is no formal pre-trends test in this cross-sectional setting, but the nonparametric estimator&amp;rsquo;s behavior past 0.1 mile (point estimates oscillating around zero, with CIs that include zero) is suggestive evidence that the assumption is not wildly violated. Second, the design implicitly assumes &lt;strong>no anticipation&lt;/strong>: home buyers do not price the offender&amp;rsquo;s arrival into transactions &lt;em>before&lt;/em> the arrival becomes public. With a cross-section, this assumption is also untestable, and any anticipation effects would attenuate the post-arrival drop. Both caveats are present in Butts (2023) and in Linden and Rockoff (2008); the estimators here cannot resolve them.&lt;/p>
&lt;h2 id="14-summary-and-takeaways">14. Summary and takeaways&lt;/h2>
&lt;p>&lt;strong>1. Headline number depends on the estimator, not just the data.&lt;/strong> On the same 9,092 sales, the parametric ring DiD at 0.1 mile returns &lt;strong>−5.78 %&lt;/strong>; the leftmost nonparametric bin returns &lt;strong>−20.6 %&lt;/strong>; the sample-weighted nonparametric ATT inside 0.1 mile is &lt;strong>−12.4 %&lt;/strong>. All three describe the same dataset; they answer slightly different questions about &amp;ldquo;the effect of an offender arriving.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>2. Ring choice is part of the estimand.&lt;/strong> Moving the inner-ring cutoff from 0.05 to 0.15 mile changes the parametric ATT from &lt;strong>−6.40 %&lt;/strong> to &lt;strong>−4.21 %&lt;/strong> &amp;mdash; a 52 % relative spread that has nothing to do with statistical noise. A parametric ring DiD without a sensitivity analysis is reporting one corner of an answer surface and calling it the answer.&lt;/p>
&lt;p>&lt;strong>3. The data-driven approach validates and refines the classical setup.&lt;/strong> The nonparametric estimator does not contradict Linden and Rockoff&amp;rsquo;s 0.1-mile cutoff &amp;mdash; it &lt;em>corroborates&lt;/em> it, because the treatment-effect curve crosses zero at about $d \approx 0.094$ mile. The data-driven approach disciplines the cutoff instead of guessing it, and in this case it endorses the original authors' eyeballed choice.&lt;/p>
&lt;p>&lt;strong>4. The simulation should always come first.&lt;/strong> Steps 3–6 used a known DGP to confirm that the parametric ring estimator is unbiased when the cutoff is right and biased otherwise, and that the nonparametric ring estimator recovers the &lt;em>shape&lt;/em> of the true τ-curve. Without the simulation, the −20.6 % bin-1 estimate on the real data would look implausible. With the simulation, we understand why the parametric ring estimator must be attenuated whenever the true effect is concentrated near the treatment point.&lt;/p>
&lt;h2 id="15-exercises">15. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Sensitivity to the outer ring.&lt;/strong> Re-run the parametric ring DiD on Linden-Rockoff with the outer ring fixed at 0.25 mile and 0.40 mile (instead of 0.30), keeping the inner ring at 0.10 mile. How much does the headline ATT move? Does the sign survive?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Placebo offender.&lt;/strong> Pick a random non-offender address in the data and treat it as if an offender had arrived at that location. Run the parametric ring DiD as usual. The placebo coefficient should be near zero and statistically indistinguishable from zero. What does it tell you when it is not?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Bin-equal vs sample-weighted ATT.&lt;/strong> Compute the inner-0.1-mile nonparametric ATT two ways: (i) as a simple mean of $\hat{\tau}_j$ over bins inside 0.1 mile (bin-equal weight), and (ii) as the sample-weighted average used in this post. Which weighting is more defensible if you want to communicate the &amp;ldquo;average effect on the average treated home&amp;rdquo; rather than the &amp;ldquo;average effect on the average bin&amp;rdquo;?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Linden, Leigh, and Jonah E. Rockoff (2008). &lt;a href="https://www.aeaweb.org/articles?id=10.1257/aer.98.3.1103" target="_blank" rel="noopener">Estimates of the Impact of Crime Risk on Property Values from Megan&amp;rsquo;s Laws.&lt;/a> &lt;em>American Economic Review&lt;/em> 98(3), 1103–1127.&lt;/li>
&lt;li>Butts, Kyle (2023). &lt;a href="https://doi.org/10.1016/j.jue.2022.103493" target="_blank" rel="noopener">JUE Insight: Difference-in-Differences with Geocoded Microdata.&lt;/a> &lt;em>Journal of Urban Economics&lt;/em> 133, 103493.&lt;/li>
&lt;li>Cattaneo, Matias D., Richard K. Crump, Max H. Farrell, and Yingjie Feng (2024). &lt;a href="https://www.aeaweb.org/articles?id=10.1257/aer.20221254" target="_blank" rel="noopener">On Binscatter.&lt;/a> &lt;em>American Economic Review&lt;/em> 114(5), 1488–1514.&lt;/li>
&lt;li>Bergé, Laurent (2018). &lt;a href="https://cran.r-project.org/package=fixest" target="_blank" rel="noopener">Efficient estimation of maximum likelihood models with multiple fixed-effects: the R package &lt;code>FENmlm&lt;/code>.&lt;/a> (&lt;code>fixest&lt;/code> package documentation.)&lt;/li>
&lt;li>Cattaneo, Matias D., Richard K. Crump, Max H. Farrell, and Yingjie Feng (2024). &lt;a href="https://cran.r-project.org/package=binsreg" target="_blank" rel="noopener">&lt;code>binsreg&lt;/code>: Binscatter Estimation and Inference.&lt;/a> CRAN R package.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;style>
.podcast-overlay {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
animation: podSlideUp 0.35s ease-out;
}
@keyframes podSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.podcast-overlay.pod-closing {
animation: podSlideDown 0.3s ease-in forwards;
}
@keyframes podSlideDown {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
.podcast-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 18px 24px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 -4px 32px rgba(0,0,0,0.5);
border-top: 1px solid rgba(106,155,204,0.2);
}
.podcast-inner {
max-width: 800px;
margin: 0 auto;
}
.podcast-top-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.podcast-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, #d97757, #e8956a);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.podcast-icon svg {
width: 22px;
height: 22px;
fill: #fff;
}
.podcast-title-block {
flex: 1;
min-width: 0;
}
.podcast-title-block h4 {
margin: 0 0 1px 0;
color: #f0ece2;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.podcast-title-block span {
color: #8b9dc3;
font-size: 11px;
}
.podcast-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
flex-shrink: 0;
}
.podcast-close-btn:hover {
background: rgba(255,255,255,0.1);
}
.podcast-close-btn svg {
width: 20px;
height: 20px;
fill: #8b9dc3;
}
.podcast-progress-wrap {
margin-bottom: 12px;
}
.podcast-time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #8b9dc3;
margin-bottom: 5px;
font-variant-numeric: tabular-nums;
}
.podcast-bar-bg {
width: 100%;
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: height 0.15s;
}
.podcast-bar-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(106,155,204,0.25);
border-radius: 3px;
transition: width 0.3s;
}
.podcast-bar-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #6a9bcc, #00d4c8);
border-radius: 3px;
transition: width 0.1s linear;
}
.podcast-bar-bg:hover {
height: 10px;
margin-top: -2px;
}
.podcast-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.podcast-transport {
display: flex;
align-items: center;
gap: 8px;
}
.podcast-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.podcast-btn svg {
fill: #c8d0e0;
transition: fill 0.2s;
}
.podcast-btn:hover svg {
fill: #f0ece2;
}
.podcast-btn-skip {
position: relative;
}
.podcast-btn-skip span {
position: absolute;
font-size: 7px;
font-weight: 700;
color: #c8d0e0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
margin-top: 1px;
}
.podcast-btn-play {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #d97757, #e8956a);
border-radius: 50%;
box-shadow: 0 3px 12px rgba(217,119,87,0.4);
transition: all 0.2s;
}
.podcast-btn-play:hover {
transform: scale(1.08);
box-shadow: 0 5px 20px rgba(217,119,87,0.5);
}
.podcast-btn-play svg {
fill: #fff;
width: 22px;
height: 22px;
}
.podcast-extras {
display: flex;
align-items: center;
gap: 10px;
}
.podcast-volume-wrap {
display: flex;
align-items: center;
gap: 5px;
}
.podcast-volume-wrap svg {
fill: #8b9dc3;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.podcast-volume-wrap svg:hover {
fill: #c8d0e0;
}
.podcast-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 60px;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.podcast-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #6a9bcc;
border-radius: 50%;
cursor: pointer;
}
.podcast-speed-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #c8d0e0;
font-size: 11px;
font-weight: 600;
padding: 3px 9px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
min-width: 40px;
text-align: center;
}
.podcast-speed-btn:hover {
background: rgba(106,155,204,0.2);
border-color: #6a9bcc;
color: #f0ece2;
}
.podcast-download-btn {
background: none;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 4px 10px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
color: #8b9dc3;
font-size: 11px;
font-family: inherit;
text-decoration: none;
transition: all 0.2s;
}
.podcast-download-btn:hover {
border-color: #6a9bcc;
color: #f0ece2;
background: rgba(106,155,204,0.1);
}
.podcast-download-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
@media (max-width: 600px) {
.podcast-container { padding: 14px 16px 16px; }
.podcast-volume-wrap { display: none; }
.podcast-title-block h4 { font-size: 13px; }
.podcast-extras { gap: 8px; }
}
&lt;/style>
&lt;div class="podcast-overlay" id="podOverlay">
&lt;div class="podcast-container">
&lt;div class="podcast-inner">
&lt;audio id="podAudio" preload="none" src="https://files.catbox.moe/kaq4in.m4a">&lt;/audio>
&lt;div class="podcast-top-row">
&lt;div class="podcast-icon">
&lt;svg viewBox="0 0 24 24">&lt;path d="M12 1a5 5 0 0 0-5 5v4a5 5 0 0 0 10 0V6a5 5 0 0 0-5-5zm0 16a7 7 0 0 1-7-7H3a9 9 0 0 0 8 8.94V22h2v-3.06A9 9 0 0 0 21 10h-2a7 7 0 0 1-7 7z"/>&lt;/svg>
&lt;/div>
&lt;div class="podcast-title-block">
&lt;h4>AI Podcast: Ring DiD with Geocoded Microdata&lt;/h4>
&lt;span id="podDurationLabel">Click play to load&lt;/span>
&lt;/div>
&lt;button class="podcast-close-btn" onclick="podClose()" title="Close player">
&lt;svg viewBox="0 0 24 24">&lt;path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>&lt;/svg>
&lt;/button>
&lt;/div>
&lt;div class="podcast-progress-wrap">
&lt;div class="podcast-time-row">
&lt;span id="podCurrent">0:00&lt;/span>
&lt;span id="podDuration">0:00&lt;/span>
&lt;/div>
&lt;div class="podcast-bar-bg" id="podBarBg" onclick="podSeek(event)">
&lt;div class="podcast-bar-buffered" id="podBuffered">&lt;/div>
&lt;div class="podcast-bar-progress" id="podProgress">&lt;/div>
&lt;/div>
&lt;/div>
&lt;div class="podcast-controls-row">
&lt;div class="podcast-transport">
&lt;button class="podcast-btn podcast-btn-skip" onclick="podSkip(-15)" title="Back 15s">
&lt;svg width="26" height="26" viewBox="0 0 24 24">&lt;path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>&lt;/svg>
&lt;span>15&lt;/span>
&lt;/button>
&lt;button class="podcast-btn podcast-btn-play" id="podPlayBtn" onclick="podToggle()" title="Play">
&lt;svg id="podIconPlay" viewBox="0 0 24 24">&lt;path d="M8 5v14l11-7z"/>&lt;/svg>
&lt;svg id="podIconPause" viewBox="0 0 24 24" style="display:none">&lt;path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>&lt;/svg>
&lt;/button>
&lt;button class="podcast-btn podcast-btn-skip" onclick="podSkip(15)" title="Forward 15s">
&lt;svg width="26" height="26" viewBox="0 0 24 24">&lt;path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>&lt;/svg>
&lt;span>15&lt;/span>
&lt;/button>
&lt;/div>
&lt;div class="podcast-extras">
&lt;div class="podcast-volume-wrap">
&lt;svg id="podVolIcon" onclick="podMute()" viewBox="0 0 24 24">&lt;path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.47 4.47 0 0 0 2.5-3.5zM14 3.23v2.06a6.51 6.51 0 0 1 0 13.42v2.06A8.51 8.51 0 0 0 14 3.23z"/>&lt;/svg>
&lt;input type="range" class="podcast-volume-slider" id="podVolume" min="0" max="1" step="0.05" value="0.8">
&lt;/div>
&lt;button class="podcast-speed-btn" id="podSpeedBtn" onclick="podCycleSpeed()" title="Playback speed">1x&lt;/button>
&lt;a class="podcast-download-btn" href="https://files.catbox.moe/kaq4in.m4a" target="_blank" rel="noopener" title="Stream">
&lt;svg viewBox="0 0 24 24">&lt;path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>&lt;/svg>
&lt;/a>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(function(){
var overlay = document.getElementById('podOverlay');
var a = document.getElementById('podAudio');
var speeds = [0.75, 1, 1.25, 1.5, 2];
var si = 1;
var opened = false;
function fmt(s){
if(isNaN(s)) return '0:00';
var m=Math.floor(s/60), sec=Math.floor(s%60);
return m+':'+(sec&lt;10?'0':'')+sec;
}
document.addEventListener('click', function(e){
var link = e.target.closest('a.btn-page-header');
if(!link) return;
var text = link.textContent.trim();
if(text.indexOf('AI Podcast') === -1) return;
e.preventDefault();
e.stopPropagation();
overlay.style.display = 'block';
overlay.classList.remove('pod-closing');
if(!opened){
a.preload = 'metadata';
a.load();
opened = true;
}
});
a.volume = 0.8;
a.addEventListener('loadedmetadata', function(){
document.getElementById('podDuration').textContent = fmt(a.duration);
document.getElementById('podDurationLabel').textContent = fmt(a.duration) + ' minutes';
});
a.addEventListener('timeupdate', function(){
document.getElementById('podCurrent').textContent = fmt(a.currentTime);
var pct = a.duration ? (a.currentTime/a.duration)*100 : 0;
document.getElementById('podProgress').style.width = pct+'%';
});
a.addEventListener('progress', function(){
if(a.buffered.length>0){
var pct = (a.buffered.end(a.buffered.length-1)/a.duration)*100;
document.getElementById('podBuffered').style.width = pct+'%';
}
});
a.addEventListener('ended', function(){
document.getElementById('podIconPlay').style.display='';
document.getElementById('podIconPause').style.display='none';
});
window.podToggle = function(){
if(a.paused){a.play();document.getElementById('podIconPlay').style.display='none';document.getElementById('podIconPause').style.display='';}
else{a.pause();document.getElementById('podIconPlay').style.display='';document.getElementById('podIconPause').style.display='none';}
};
window.podSkip = function(s){a.currentTime = Math.max(0,Math.min(a.duration||0,a.currentTime+s));};
window.podSeek = function(e){
var rect = document.getElementById('podBarBg').getBoundingClientRect();
var pct = (e.clientX - rect.left)/rect.width;
a.currentTime = pct * (a.duration||0);
};
window.podMute = function(){
a.muted = !a.muted;
document.getElementById('podVolume').value = a.muted ? 0 : a.volume;
};
window.podCycleSpeed = function(){
si = (si+1) % speeds.length;
a.playbackRate = speeds[si];
document.getElementById('podSpeedBtn').textContent = speeds[si]+'x';
};
window.podClose = function(){
overlay.classList.add('pod-closing');
setTimeout(function(){ overlay.style.display='none'; }, 300);
a.pause();
document.getElementById('podIconPlay').style.display='';
document.getElementById('podIconPause').style.display='none';
};
document.getElementById('podVolume').addEventListener('input', function(){
a.volume = this.value;
a.muted = false;
});
if(window.location.hash === '#podcast-player'){
overlay.style.display = 'block';
a.preload = 'metadata';
a.load();
opened = true;
}
})();
&lt;/script></description></item><item><title>Difference-in-Differences for Regional Data: Did Medicaid Expansion Reduce Mortality?</title><link>https://carlos-mendez.org/post/r_did2/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/r_did2/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>Did the Affordable Care Act&amp;rsquo;s Medicaid expansion reduce adult mortality? Between 2014 and 2019, twenty-nine states (plus DC) opened Medicaid eligibility to low-income adults who had previously been uncovered; the remaining states did not. That staggered roll-out is a natural experiment, and &lt;strong>Difference-in-Differences (DiD)&lt;/strong> is the standard tool for turning it into a causal estimate of how the program affected the death rate of working-age adults. The empirical question matters: roughly twenty million people gained insurance under the expansion, and a reduction of even a few deaths per 100,000 adults would translate into thousands of lives saved each year.&lt;/p>
&lt;p>The challenge is that the unit of analysis here is the &lt;em>county&lt;/em>, not the individual &amp;mdash; and U.S. counties differ in size by three orders of magnitude. Los Angeles County has more adults than Wyoming, Vermont, and Alaska combined. When you compute an average treatment effect, you must decide whether each county should count equally (an unweighted average across counties), or whether each adult should count equally (an average weighted by county population). This is not just a precision choice. &lt;strong>Weighting changes the target parameter.&lt;/strong> The unweighted answer estimates the effect on the &lt;em>typical treated county&lt;/em>; the weighted answer estimates the effect on the &lt;em>typical treated adult&lt;/em>. When treatment effects vary across counties of different sizes, those two parameters can disagree &amp;mdash; sometimes dramatically.&lt;/p>
&lt;p>This tutorial is inspired by the empirical example from Baker, Callaway, Cunningham, Goodman-Bacon and Sant&amp;rsquo;Anna&amp;rsquo;s (2025) &lt;em>Difference-in-Differences Designs: A Practitioner&amp;rsquo;s Guide&lt;/em> (&lt;a href="https://arxiv.org/abs/2503.13323" target="_blank" rel="noopener">arXiv:2503.13323&lt;/a>). We walk through eight stages of the modern DiD pipeline using R, and at every stage we compute the answer twice, once unweighted and once weighted by county adult population in 2013. The headline finding previews what is coming: in the simplest possible four-cell 2x2 calculation, the unweighted DiD is $+0.12$ deaths per 100,000 (suggesting Medicaid did nothing, or even raised mortality slightly), while the population-weighted DiD is $-2.56$ deaths per 100,000 (suggesting it saved lives). The remainder of the post examines whether that sign reversal survives covariate adjustment, staggered cohorts, and a HonestDiD sensitivity analysis. Spoiler: it largely does &amp;mdash; and the punchline is that the two estimands are not in competition. They answer different policy questions.&lt;/p>
&lt;p>&lt;strong>Learning objectives.&lt;/strong> After working through this tutorial you will be able to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Understand&lt;/strong> the parallel-trends assumption and why it is the &lt;em>only&lt;/em> identifying restriction needed for a 2x2 DiD with two cohorts and two periods.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> the 2x2 cell-means DiD, three equivalent TWFE specifications, and the full Callaway-Sant&amp;rsquo;Anna $\text{ATT}(g, t)$ design in R using &lt;code>fixest&lt;/code> and the &lt;code>did&lt;/code> package.&lt;/li>
&lt;li>&lt;strong>Adjust&lt;/strong> for covariates via outcome regression (OR), inverse propensity weighting (IPW), and the Sant&amp;rsquo;Anna-Zhao doubly robust DiD (DRDID).&lt;/li>
&lt;li>&lt;strong>Compare&lt;/strong> unweighted and population-weighted estimands at every stage, and read the gap between them as a difference in &lt;em>target parameter&lt;/em>, not in precision.&lt;/li>
&lt;li>&lt;strong>Assess&lt;/strong> robustness to violations of parallel trends using the Rambachan-Roth &lt;code>HonestDiD&lt;/code> package, and identify the smallest pre-trend violation that would overturn the conclusion.&lt;/li>
&lt;/ul>
&lt;h3 id="key-concepts-at-a-glance">Key concepts at a glance&lt;/h3>
&lt;p>The post leans on a small vocabulary repeatedly. The rest of the tutorial assumes you can move between these terms quickly. Each concept below has three parts. The &lt;strong>definition&lt;/strong> is always visible. The &lt;strong>example&lt;/strong> and &lt;strong>analogy&lt;/strong> sit behind clickable cards: open them when you need them, leave them collapsed for a quick scan. If a later section mentions &amp;ldquo;parallel trends&amp;rdquo; or &amp;ldquo;M-bar&amp;rdquo; and the term feels slippery, this is the section to re-read.&lt;/p>
&lt;p>&lt;strong>1. Parallel-trends assumption.&lt;/strong>
Counterfactually, treated and control groups would have moved together. If Medicaid expansion had not happened, the mortality trend in expansion counties would have matched the trend in never-expansion counties.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Between 2013 and 2014, never-expansion counties saw mortality rise by $9.15$ deaths per 100,000 (unweighted) or $6.30$ (weighted). The parallel-trends assumption says expansion counties would have seen the same change &lt;em>had they not expanded&lt;/em>. The 2x2 DiD measures the actual deviation from that counterfactual trend: $+0.12$ unweighted, $-2.56$ weighted.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two identical twins grow up in different households. We assume their height curves would have stayed in sync had nothing changed. Then one twin starts a growth-hormone treatment. The height gap that opens up &lt;em>after&lt;/em> the treatment, minus any gap that was already there, is the treatment effect. Parallel trends says the gap &lt;em>would&lt;/em> have stayed constant absent the intervention.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>2. 2x2 DiD&lt;/strong> $\text{ATT}(2014) = (\bar{Y}_{T, \text{post}} - \bar{Y}_{T, \text{pre}}) - (\bar{Y}_{C, \text{post}} - \bar{Y}_{C, \text{pre}})$.
The treated group&amp;rsquo;s change minus the control group&amp;rsquo;s change. Two groups, two periods, four means &amp;mdash; no regression required.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Treated cell means: $419.23$ (2013) and $428.50$ (2014); control cell means: $474.00$ (2013) and $483.15$ (2014). The treated trend is $+9.27$; the control trend is $+9.15$; the 2x2 DiD is the difference, $+0.12$. (All values are unweighted; population-weighted versions appear in &lt;code>table_2x2_means.csv&lt;/code>.)&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two restaurants raise prices, but only one adds a delivery service. We compare the change in revenue at the delivery restaurant to the change at the non-delivery restaurant. The price increase affects both equally; the delivery effect is the &lt;em>extra&lt;/em> change at the treated restaurant.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>3. Estimand: ATT under weighting.&lt;/strong>
The Average Treatment effect on the Treated, evaluated under a specific weighting scheme. Equal weights give the ATT for the typical &lt;em>treated county&lt;/em>; population weights give the ATT for the typical &lt;em>treated adult&lt;/em>.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>In our 2x2, the equal-weight ATT is $+0.12$ deaths per 100,000 (an estimate averaged across the 978 expansion counties as units). The population-weight ATT is $-2.56$ (an estimate averaged across the 84 million adults living in those counties). Both are causal parameters; they just describe different averaging targets.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>If you survey &amp;ldquo;the average classroom&amp;rdquo; you ask each &lt;em>classroom&lt;/em> one question. If you survey &amp;ldquo;the average student&amp;rdquo; you give each &lt;em>student&lt;/em> one vote. A classroom of 30 students moves the second average thirty times as much as the first. Same data, different question.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>4. Staggered adoption&lt;/strong> $G_i \in \{2014, 2015, 2016, 2019, \infty\}$.
Different units start treatment in different years. There is no single &amp;ldquo;post&amp;rdquo; period for the whole sample; each cohort has its own clock.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>In this study, $978$ counties expanded in 2014, $171$ in 2015, $93$ in 2016, and $140$ in 2019. A further $1{,}222$ counties never expanded ($G_i = \infty$). The Callaway-Sant&amp;rsquo;Anna design estimates a separate $\text{ATT}(g, t)$ for each cohort-year cell, then aggregates them.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Four cohorts of swimmers enter a relay race at staggered start times, plus a fifth cohort that never swims. We measure each cohort&amp;rsquo;s improvement from start to finish separately, then average. We never make a swimmer who is mid-race serve as the &amp;ldquo;control&amp;rdquo; for a swimmer who hasn&amp;rsquo;t started yet &amp;mdash; a mistake that two-way fixed effects can quietly make.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>5. Doubly-robust DiD (DRDID).&lt;/strong>
A 2x2 estimator that uses both an outcome model (control-group regression) and a propensity-score model (treatment-group balancing weights). It is consistent if &lt;em>either&lt;/em> model is correctly specified.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>For the 2014 cohort, our population-weighted estimates are: outcome regression (OR) $-3.46$, inverse propensity weighting (IPW) $-3.84$, doubly robust (DRDID) $-3.76$. DRDID sits between OR and IPW because it is essentially a weighted combination; if both were correctly specified it would agree with both.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Belt-and-suspenders insurance. The belt (outcome model) holds your pants up if it works; the suspenders (propensity model) hold your pants up if they work. As long as &lt;em>at least one&lt;/em> is functional, you stay decent. DRDID is the same idea applied to causal identification.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>6. HonestDiD sensitivity&lt;/strong> with parameter $\bar{M}$.
A robustness analysis that asks &amp;ldquo;how big could a post-period parallel-trends violation be &amp;mdash; relative to the largest violation seen in the pre-period &amp;mdash; before the conclusion changes?&amp;rdquo; Smaller $\bar{M}$ is a stricter assumption; larger $\bar{M}$ is more permissive.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>At $\bar{M} = 0$ (exact parallel trends), the unweighted dynamic ATT bound is $[+2.01, +14.09]$ &amp;mdash; entirely positive &amp;mdash; while the weighted bound is $[-6.07, +6.07]$ &amp;mdash; straddling zero. By $\bar{M} = 0.25$ both bounds cross zero; by $\bar{M} = 1$ they saturate at the package&amp;rsquo;s grid limits of $\pm 66.7$.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A stress test. We do not believe parallel trends holds exactly. We ask: &amp;ldquo;If next year&amp;rsquo;s deviation is at most as large as the biggest deviation we observed in the past, would our answer change?&amp;rdquo; The smallest violation that flips the conclusion is the &lt;em>breakdown value&lt;/em>.&lt;/p>
&lt;/details>
&lt;/div>
&lt;h3 id="methodological-roadmap">Methodological roadmap&lt;/h3>
&lt;p>This tutorial walks through eight estimation stages. Each stage estimates the same ATT but under a slightly more general design; the figure below shows how the stages compose into a single pipeline. The thread that runs through all eight stages is the unweighted-vs-weighted contrast: at every step, we compute both versions side-by-side.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[Raw data:&amp;lt;br/&amp;gt;2604 counties&amp;lt;br/&amp;gt;x 11 years] --&amp;gt; B[2x2 cell means&amp;lt;br/&amp;gt;headline sign reversal]
B --&amp;gt; C[2x2 TWFE&amp;lt;br/&amp;gt;three specs, two weights]
C --&amp;gt; D[Covariate balance&amp;lt;br/&amp;gt;+ propensity scores]
D --&amp;gt; E[OR / IPW / DRDID&amp;lt;br/&amp;gt;covariate-adjusted 2x2]
E --&amp;gt; F[2xT event study&amp;lt;br/&amp;gt;2014 cohort dynamics]
F --&amp;gt; G[GxT staggered design&amp;lt;br/&amp;gt;all 4 cohorts pooled]
G --&amp;gt; H[HonestDiD&amp;lt;br/&amp;gt;parallel-trends sensitivity]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#1a3a8a,stroke:#141413,color:#fff
style E fill:#1a3a8a,stroke:#141413,color:#fff
style F fill:#00d4c8,stroke:#141413,color:#141413
style G fill:#00d4c8,stroke:#141413,color:#141413
style H fill:#141413,stroke:#6a9bcc,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The first two stages (orange) are the simplest possible DiD; they recover the headline result with arithmetic plus a regression. The next two (navy) introduce covariate adjustment, useful when treated and control groups differ on observables. The 2xT and GxT stages (teal) extend the design to multiple post-periods and multiple cohorts. The final stage (black) asks the only question a 2x2 cannot answer: &lt;em>how robust is the answer to violations of the assumption that buys identification in the first place?&lt;/em>&lt;/p>
&lt;h2 id="2-setup-and-imports">2. Setup and imports&lt;/h2>
&lt;p>The R session needs nine packages: &lt;code>tidyverse&lt;/code> for data manipulation and &lt;code>ggplot2&lt;/code>, &lt;code>fixest&lt;/code> for fast fixed-effects regression, &lt;code>did&lt;/code> for the Callaway-Sant&amp;rsquo;Anna group-time estimator, &lt;code>DRDID&lt;/code> for the doubly-robust DiD engine, &lt;code>HonestDiD&lt;/code> for the Rambachan-Roth sensitivity analysis, &lt;code>broom&lt;/code> for tidy regression output, &lt;code>scales&lt;/code> for percentage labels, &lt;code>here&lt;/code> for project-anchored paths, and &lt;code>pacman&lt;/code> to handle installation if any of those are missing. We also fix the random seed and set the bootstrap iteration count to 2,000 (the manuscript&amp;rsquo;s reference scripts use 25,000; this is fine for tutorial-grade results).&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(42)
if (!require(&amp;quot;pacman&amp;quot;)) install.packages(&amp;quot;pacman&amp;quot;, repos = &amp;quot;https://cloud.r-project.org&amp;quot;)
pacman::p_load(
tidyverse, # data manipulation + ggplot
fixest, # fast fixed-effects regression (`feols`, `feglm`)
did, # Callaway &amp;amp; Sant'Anna group-time ATT(g,t) estimator
DRDID, # the doubly-robust DiD engine used inside `did`
HonestDiD, # Rambachan-Roth sensitivity analysis
broom, # tidy regression output
scales, # nice axis labels
here # filepath helper (project root anchored)
)
BITERS &amp;lt;- 2000
&lt;/code>&lt;/pre>
&lt;p>Three of these packages deserve a brief introduction. The &lt;a href="https://bcallaway11.github.io/did/" target="_blank" rel="noopener">&lt;code>did&lt;/code>&lt;/a> package implements Callaway and Sant&amp;rsquo;Anna&amp;rsquo;s (2021) group-time ATT estimator via &lt;code>att_gt()&lt;/code> and aggregates the resulting cells via &lt;code>aggte()&lt;/code>. The &lt;a href="https://psantanna.com/DRDID/" target="_blank" rel="noopener">&lt;code>DRDID&lt;/code>&lt;/a> package is the doubly-robust DiD engine that lives underneath &lt;code>did&lt;/code>&amp;rsquo;s &lt;code>est_method = &amp;quot;dr&amp;quot;&lt;/code> option. The &lt;a href="https://github.com/asheshrambachan/HonestDiD" target="_blank" rel="noopener">&lt;code>HonestDiD&lt;/code>&lt;/a> package implements the Rambachan-Roth (2023) sensitivity analysis for parallel-trends violations. All three are CRAN-published.&lt;/p>
&lt;p>A dark-themed &lt;code>ggplot&lt;/code> palette is registered once at the top of the script so every figure inherits it; this keeps the eight figures visually consistent without per-plot styling.&lt;/p>
&lt;pre>&lt;code class="language-r">BG_DARK &amp;lt;- &amp;quot;#0f1729&amp;quot;
GRID_DARK &amp;lt;- &amp;quot;#1f2b5e&amp;quot;
TEXT_LIGHT &amp;lt;- &amp;quot;#c8d0e0&amp;quot;
TEXT_WHITE &amp;lt;- &amp;quot;#e8ecf2&amp;quot;
BLUE &amp;lt;- &amp;quot;#6a9bcc&amp;quot; # unweighted series
ORANGE &amp;lt;- &amp;quot;#d97757&amp;quot; # population-weighted series
TEAL &amp;lt;- &amp;quot;#00d4c8&amp;quot; # highlights
theme_dark_dampoostle &amp;lt;- function(base_size = 12) {
theme_minimal(base_size = base_size) +
theme(
plot.background = element_rect(fill = BG_DARK, color = NA),
panel.background = element_rect(fill = BG_DARK, color = NA),
panel.grid.major = element_line(color = GRID_DARK, linewidth = 0.35),
axis.text = element_text(color = TEXT_LIGHT),
axis.title = element_text(color = TEXT_WHITE),
legend.position = &amp;quot;bottom&amp;quot;
)
}
theme_set(theme_dark_dampoostle())
&lt;/code>&lt;/pre>
&lt;p>The two weighting regimes are color-coded throughout: steel blue (&lt;code>#6a9bcc&lt;/code>) for unweighted, warm orange (&lt;code>#d97757&lt;/code>) for population-weighted. That convention makes every comparison figure visually self-documenting. Every figure in this post uses the dark-navy theme above; if your browser is in light mode and the figures look unexpectedly dark, that is by design rather than a rendering bug.&lt;/p>
&lt;h2 id="3-data-cdc-mortality--aca-expansion-timing">3. Data: CDC mortality + ACA expansion timing&lt;/h2>
&lt;p>The source data are CDC county-level mortality counts (deaths per 100,000 adults aged 20&amp;ndash;64) merged with state-level Medicaid-expansion timing. We follow the manuscript&amp;rsquo;s inclusion criteria: drop the five jurisdictions that expanded before 2014 (DC, DE, MA, NY, VT) because they cannot serve cleanly as either treated or control in a 2014-centered design; require full mortality coverage 2009&amp;ndash;2019; and require full covariate coverage in 2013 and 2014.&lt;/p>
&lt;pre>&lt;code class="language-r">covs &amp;lt;- c(&amp;quot;perc_female&amp;quot;, &amp;quot;perc_white&amp;quot;, &amp;quot;perc_hispanic&amp;quot;,
&amp;quot;unemp_rate&amp;quot;, &amp;quot;poverty_rate&amp;quot;, &amp;quot;median_income&amp;quot;)
DATA_URL &amp;lt;- &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/r_did2/reference/data/county_mortality_data.csv&amp;quot;
df_raw &amp;lt;- read_csv(DATA_URL, show_col_types = FALSE, na = c(&amp;quot;&amp;quot;, &amp;quot;NA&amp;quot;))
df_prep &amp;lt;- df_raw %&amp;gt;%
mutate(
state_abb = str_sub(county, nchar(county) - 1, nchar(county)),
perc_white = population_20_64_white / population_20_64 * 100,
perc_hispanic = population_20_64_hispanic / population_20_64 * 100,
perc_female = population_20_64_female / population_20_64 * 100,
unemp_rate = unemp_rate * 100,
median_income = median_income / 1000,
yaca = suppressWarnings(as.numeric(yaca))
) %&amp;gt;%
filter(!(state_abb %in% c(&amp;quot;DC&amp;quot;, &amp;quot;DE&amp;quot;, &amp;quot;MA&amp;quot;, &amp;quot;NY&amp;quot;, &amp;quot;VT&amp;quot;))) %&amp;gt;%
select(state_abb, county, county_code, year, population_20_64, yaca,
crude_rate_20_64, all_of(covs)) %&amp;gt;%
drop_na(!yaca) %&amp;gt;%
group_by(county_code) %&amp;gt;%
filter(sum(year %in% c(2013, 2014)) == 2) %&amp;gt;%
filter(sum(!is.na(crude_rate_20_64)) == 11) %&amp;gt;%
ungroup() %&amp;gt;%
group_by(county_code) %&amp;gt;%
mutate(set_wt = population_20_64[which(year == 2013)]) %&amp;gt;%
ungroup() %&amp;gt;%
mutate(
treat_year = if_else(!is.na(yaca) &amp;amp; yaca &amp;lt;= 2019, yaca, 0),
Treat_2014 = if_else(!is.na(yaca) &amp;amp; yaca == 2014, 1L, 0L),
Post = if_else(year &amp;gt;= 2014, 1L, 0L)
)
&lt;/code>&lt;/pre>
&lt;p>The cleaning produces a balanced panel of 2,604 counties across 11 years (28,644 county-year rows). The &lt;code>treat_year&lt;/code> column follows the &lt;code>did&lt;/code> package convention: it holds the actual expansion year for treated counties and a literal $0$ for never-treated counties. The &lt;code>set_wt&lt;/code> column is each county&amp;rsquo;s 2013 adult population, held constant across all 11 years so that weighting does not conflate population growth with mortality change. After the cleaning, the breakdown of cohorts is:&lt;/p>
&lt;pre>&lt;code class="language-text">Loaded 31843 rows x 22 cols from county_mortality_data.csv
After cleaning: 2604 counties x 11 years = 28644 county-year rows
Treatment cohorts (treat_year):
treat_year n_counties
1 0 1222
2 2014 978
3 2015 171
4 2016 93
5 2019 140
&lt;/code>&lt;/pre>
&lt;p>The five cohorts hide an asymmetry that is the seed of everything that follows. Built on county counts, the never-expansion cohort makes up 46.9% of the sample and the 2014 cohort 37.6%. Built on 2013 adult population, the never-expansion cohort makes up only 38.2% while the 2014 cohort makes up 49.5%. Switching weighting regimes silently swings 11 percentage points of mass between the two largest cohorts. The three smaller cohorts (2015, 2016, 2019) shrink even further under weighting, from 6.6 / 3.6 / 5.4% of counties down to 7.0 / 2.0 / 3.4% of adults.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">treat_year&lt;/th>
&lt;th style="text-align:right">n_counties&lt;/th>
&lt;th style="text-align:right">n_states&lt;/th>
&lt;th style="text-align:right">pop_adult (2013)&lt;/th>
&lt;th style="text-align:right">share_counties&lt;/th>
&lt;th style="text-align:right">share_pop&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">0 (never)&lt;/td>
&lt;td style="text-align:right">1,222&lt;/td>
&lt;td style="text-align:right">17&lt;/td>
&lt;td style="text-align:right">65,171,521&lt;/td>
&lt;td style="text-align:right">46.9%&lt;/td>
&lt;td style="text-align:right">38.2%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2014&lt;/td>
&lt;td style="text-align:right">978&lt;/td>
&lt;td style="text-align:right">22&lt;/td>
&lt;td style="text-align:right">84,421,489&lt;/td>
&lt;td style="text-align:right">37.6%&lt;/td>
&lt;td style="text-align:right">49.5%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2015&lt;/td>
&lt;td style="text-align:right">171&lt;/td>
&lt;td style="text-align:right">3&lt;/td>
&lt;td style="text-align:right">11,906,556&lt;/td>
&lt;td style="text-align:right">6.6%&lt;/td>
&lt;td style="text-align:right">7.0%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2016&lt;/td>
&lt;td style="text-align:right">93&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">3,329,529&lt;/td>
&lt;td style="text-align:right">3.6%&lt;/td>
&lt;td style="text-align:right">2.0%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2019&lt;/td>
&lt;td style="text-align:right">140&lt;/td>
&lt;td style="text-align:right">2&lt;/td>
&lt;td style="text-align:right">5,811,224&lt;/td>
&lt;td style="text-align:right">5.4%&lt;/td>
&lt;td style="text-align:right">3.4%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 11-percentage-point gap between county shares and population shares for the two largest cohorts is the proximate cause of the sign reversal that the next section produces. When you switch from equal weighting to population weighting, you are quietly rebalancing the comparison toward larger, more urban expansion counties and smaller, more rural never-expansion counties. That is not a precision change; it is a different comparison.&lt;/p>
&lt;h2 id="4-the-headline-2x2-did-----four-cell-means">4. The headline 2x2 DiD &amp;mdash; four cell means&lt;/h2>
&lt;p>The simplest possible DiD uses only four numbers: mean mortality in (Expansion, Never-Expansion) $\times$ (2013, 2014). The treatment effect is the treated group&amp;rsquo;s pre-to-post change minus the control group&amp;rsquo;s pre-to-post change. We do this twice, once with equal weights and once with population weights, using a small helper that takes the weighting column as an argument.&lt;/p>
&lt;pre>&lt;code class="language-r">short_data &amp;lt;- df_prep %&amp;gt;%
filter(year %in% c(2013, 2014),
(treat_year == 2014) | (treat_year == 0)) %&amp;gt;%
mutate(D = Treat_2014)
cell_means &amp;lt;- function(d, wt = NULL) {
if (is.null(wt)) {
d %&amp;gt;% group_by(D, year) %&amp;gt;%
summarise(y = mean(crude_rate_20_64), .groups = &amp;quot;drop&amp;quot;)
} else {
d %&amp;gt;% group_by(D, year) %&amp;gt;%
summarise(y = weighted.mean(crude_rate_20_64, w = .data[[wt]]),
.groups = &amp;quot;drop&amp;quot;)
}
}
cells_unw &amp;lt;- cell_means(short_data)
cells_wt &amp;lt;- cell_means(short_data, wt = &amp;quot;set_wt&amp;quot;)
att_2x2 &amp;lt;- function(cells) {
T_pre &amp;lt;- cells$y[cells$D == 1 &amp;amp; cells$year == 2013]
T_post &amp;lt;- cells$y[cells$D == 1 &amp;amp; cells$year == 2014]
C_pre &amp;lt;- cells$y[cells$D == 0 &amp;amp; cells$year == 2013]
C_post &amp;lt;- cells$y[cells$D == 0 &amp;amp; cells$year == 2014]
list(T_pre = T_pre, T_post = T_post, C_pre = C_pre, C_post = C_post,
trend_T = T_post - T_pre, trend_C = C_post - C_pre,
att = (T_post - T_pre) - (C_post - C_pre))
}
e_unw &amp;lt;- att_2x2(cells_unw)
e_wt &amp;lt;- att_2x2(cells_wt)
cat(sprintf(&amp;quot;Unweighted 2x2 ATT(2014) = %.3f\n&amp;quot;, e_unw$att))
cat(sprintf(&amp;quot;Weighted 2x2 ATT(2014) = %.3f\n&amp;quot;, e_wt$att))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Unweighted 2x2 ATT(2014) = 0.122
Weighted 2x2 ATT(2014) = -2.563
&lt;/code>&lt;/pre>
&lt;p>The estimand here is the average treatment effect on the treated (ATT) for the 2014 expansion cohort, evaluated either under equal weights across counties or under population weights. Formally:&lt;/p>
&lt;p>$$\text{ATT}_\omega(2014) = \Big( \mathbb{E}_\omega[Y_{i, 2014} \mid D_i = 1] - \mathbb{E}_\omega[Y_{i, 2013} \mid D_i = 1] \Big) - \Big( \mathbb{E}_\omega[Y_{i, 2014} \mid D_i = 0] - \mathbb{E}_\omega[Y_{i, 2013} \mid D_i = 0] \Big)$$&lt;/p>
&lt;p>In words, this is the treated group&amp;rsquo;s change in mean mortality from 2013 to 2014, minus the control group&amp;rsquo;s change over the same period &amp;mdash; where both means are computed under weighting scheme $\omega$. The weight $\omega$ is either equal across counties or proportional to 2013 adult population. The subscript on the expectation $\mathbb{E}_\omega$ is what carries the weighting choice through the definition of the parameter itself; the manuscript discusses this at lines 169&amp;ndash;170. In our code, the four conditional means map to &lt;code>T_pre&lt;/code>, &lt;code>T_post&lt;/code>, &lt;code>C_pre&lt;/code>, &lt;code>C_post&lt;/code>; the ATT is &lt;code>(T_post - T_pre) - (C_post - C_pre)&lt;/code>.&lt;/p>
&lt;p>The full four-cell table makes the arithmetic transparent:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>row&lt;/th>
&lt;th style="text-align:right">unw_T&lt;/th>
&lt;th style="text-align:right">unw_C&lt;/th>
&lt;th style="text-align:right">unw_gap&lt;/th>
&lt;th style="text-align:right">wt_T&lt;/th>
&lt;th style="text-align:right">wt_C&lt;/th>
&lt;th style="text-align:right">wt_gap&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2013 (pre)&lt;/td>
&lt;td style="text-align:right">419.23&lt;/td>
&lt;td style="text-align:right">474.00&lt;/td>
&lt;td style="text-align:right">$-54.77$&lt;/td>
&lt;td style="text-align:right">322.72&lt;/td>
&lt;td style="text-align:right">376.40&lt;/td>
&lt;td style="text-align:right">$-53.68$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2014 (post)&lt;/td>
&lt;td style="text-align:right">428.50&lt;/td>
&lt;td style="text-align:right">483.15&lt;/td>
&lt;td style="text-align:right">$-54.65$&lt;/td>
&lt;td style="text-align:right">326.46&lt;/td>
&lt;td style="text-align:right">382.70&lt;/td>
&lt;td style="text-align:right">$-56.25$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trend (post-pre)&lt;/td>
&lt;td style="text-align:right">$+9.27$&lt;/td>
&lt;td style="text-align:right">$+9.15$&lt;/td>
&lt;td style="text-align:right">$+0.12$&lt;/td>
&lt;td style="text-align:right">$+3.74$&lt;/td>
&lt;td style="text-align:right">$+6.30$&lt;/td>
&lt;td style="text-align:right">$-2.56$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The cell-means visualization (Figure 1) plots the four points and connects them by group; the gap between the two slopes is the DiD.&lt;/p>
&lt;pre>&lt;code class="language-r">fig1_df &amp;lt;- bind_rows(
cells_unw %&amp;gt;% mutate(weighting = &amp;quot;Unweighted&amp;quot;),
cells_wt %&amp;gt;% mutate(weighting = &amp;quot;Population-weighted&amp;quot;)
) %&amp;gt;%
mutate(group = if_else(D == 1, &amp;quot;2014 Expansion counties&amp;quot;,
&amp;quot;Never-expansion counties&amp;quot;),
weighting = factor(weighting,
levels = c(&amp;quot;Unweighted&amp;quot;, &amp;quot;Population-weighted&amp;quot;)))
p1 &amp;lt;- ggplot(fig1_df, aes(x = year, y = y, color = group, group = group)) +
geom_line(linewidth = 1.2) +
geom_point(size = 3.2) +
scale_color_manual(values = c(&amp;quot;2014 Expansion counties&amp;quot; = ORANGE,
&amp;quot;Never-expansion counties&amp;quot; = BLUE)) +
facet_wrap(~ weighting) +
labs(title = &amp;quot;The 2x2 DiD flips sign when you use population weights&amp;quot;,
subtitle = &amp;quot;Mortality (per 100,000 adults aged 20-64): 2014 expanders vs never-expanders&amp;quot;,
x = NULL, y = &amp;quot;Mortality rate&amp;quot;)
ggsave(&amp;quot;r_did2_01_headline_2x2.png&amp;quot;, p1, width = 10, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_01_headline_2x2.png" alt="Figure 1: 2x2 cell-means panels show the same data plotted twice, with equal weights (left) and population weights (right). The slopes are nearly parallel on the left (DiD $\approx 0$) and visibly divergent on the right (DiD $\approx -2.6$).">&lt;/p>
&lt;p>The numbers carry the headline. Under equal weighting, 2014-expansion counties saw mortality rise by $9.27$ deaths per 100,000 between 2013 and 2014; never-expansion counties rose by $9.15$. The treated trend is essentially indistinguishable from the control trend, and the DiD is $+0.122$. Under population weighting, expansion counties rose by only $3.74$ deaths per 100,000 while never-expansion counties rose by $6.30$ &amp;mdash; a divergence that yields a DiD of $-2.563$. Crucially, the pre-period gap between treated and control means is &lt;em>essentially identical&lt;/em> across the two weightings ($-54.77$ unweighted, $-53.68$ weighted): the reversal is driven entirely by which counties dominate the 2014 averages.&lt;/p>
&lt;p>This precisely reproduces the manuscript&amp;rsquo;s flagship example (line 215, Table &lt;code>tab:two_by_two_ex&lt;/code>), which reports $+0.1$ deaths per 100,000 unweighted and $-2.6$ weighted: &amp;ldquo;Without weighting &amp;hellip; 0.1 deaths per 100,000 &amp;hellip; In contrast, the DiD result using population weights suggests that Medicaid expansion caused a reduction of 2.6 deaths per 100,000 for the average adult in expansion states.&amp;rdquo; The ATT is a &lt;em>weighted&lt;/em> average treatment effect on the treated; choosing the weight is choosing the question.&lt;/p>
&lt;h2 id="5-the-same-2x2-written-as-a-regression">5. The same 2x2, written as a regression&lt;/h2>
&lt;p>Most applied researchers reach for a regression, not cell means. The manuscript&amp;rsquo;s algebraic Result 1 (line 234) states that on a balanced 2x2 panel, three apparently different regression specifications all recover exactly the same DiD coefficient. Demonstrating that equivalence removes mystery from &amp;ldquo;Two-Way Fixed Effects&amp;rdquo; (TWFE) and makes the only substantive choice in the 2x2 case &amp;mdash; the &lt;em>weighting&lt;/em> &amp;mdash; visible.&lt;/p>
&lt;p>The three specifications are: (a) a levels regression with treatment, post, and their interaction; (b) a two-way fixed-effects regression with county and year fixed effects; and (c) a long-difference regression of the 2014-minus-2013 outcome change on the treatment indicator. We run each twice (unweighted and weighted) for a total of six fits, all using &lt;code>fixest::feols()&lt;/code> with county-clustered standard errors.&lt;/p>
&lt;pre>&lt;code class="language-r">short_long_diff &amp;lt;- short_data %&amp;gt;%
group_by(county_code) %&amp;gt;%
summarise(set_wt = mean(set_wt),
diff = crude_rate_20_64[which(year == 2014)] -
crude_rate_20_64[which(year == 2013)],
D = mean(D),
.groups = &amp;quot;drop&amp;quot;)
twfe_levels_unw &amp;lt;- feols(crude_rate_20_64 ~ D * Post,
data = short_data, cluster = ~county_code)
twfe_fe_unw &amp;lt;- feols(crude_rate_20_64 ~ D:Post | county_code + year,
data = short_data, cluster = ~county_code)
twfe_long_unw &amp;lt;- feols(diff ~ D,
data = short_long_diff, cluster = ~county_code)
twfe_levels_wt &amp;lt;- feols(crude_rate_20_64 ~ D * Post,
data = short_data, weights = ~set_wt,
cluster = ~county_code)
twfe_fe_wt &amp;lt;- feols(crude_rate_20_64 ~ D:Post | county_code + year,
data = short_data, weights = ~set_wt,
cluster = ~county_code)
twfe_long_wt &amp;lt;- feols(diff ~ D,
data = short_long_diff, weights = ~set_wt,
cluster = ~county_code)
&lt;/code>&lt;/pre>
&lt;p>The levels specification recovers the DiD as the coefficient on &lt;code>D:Post&lt;/code>; the FE specification absorbs the main effects through fixed effects and identifies the DiD off the same interaction; the long-difference specification collapses each county to one row and identifies the DiD as the coefficient on $D$. All three are algebraically equivalent on a balanced 2x2 panel:&lt;/p>
&lt;p>$$Y_{i, t} = \beta_0 + \beta_1 \mathbf{1}\{D_i = 1\} + \beta_2 \mathbf{1}\{t = 2014\} + \beta^{2 \times 2} \big( \mathbf{1}\{D_i = 1\} \times \mathbf{1}\{t = 2014\} \big) + \varepsilon_{i, t}$$&lt;/p>
&lt;p>In words, this regression says that mortality $Y_{i, t}$ for county $i$ in year $t$ depends on a baseline level $\beta_0$, a treatment-group shift $\beta_1$, a post-period shift $\beta_2$, and a treatment-and-post interaction $\beta^{2 \times 2}$ that captures the differential change for treated counties after the policy. In our code, $Y_{i, t}$ is &lt;code>crude_rate_20_64&lt;/code>, $D_i$ is the &lt;code>D&lt;/code> indicator, $t = 2014$ activates the &lt;code>Post&lt;/code> dummy, and $\beta^{2 \times 2}$ is the &lt;code>D:Post&lt;/code> coefficient that &lt;code>extract_did()&lt;/code> pulls out of each model. The manuscript&amp;rsquo;s &lt;code>eqn:twfe_2_by_2&lt;/code> at line 217 states this specification; the algebraic result we are about to demonstrate is that the same coefficient $\beta^{2 \times 2}$ is also recovered (numerically, not just in expectation) when one drops $\beta_1$ and $\beta_2$ in favor of unit and time fixed effects, or when one collapses the panel to long differences.&lt;/p>
&lt;pre>&lt;code class="language-r">extract_did &amp;lt;- function(m, label, weighting) {
co &amp;lt;- coef(m); se &amp;lt;- se(m)
did_name &amp;lt;- if (&amp;quot;D:Post&amp;quot; %in% names(co)) &amp;quot;D:Post&amp;quot; else &amp;quot;D&amp;quot;
tibble(spec = label, weighting = weighting,
est = unname(co[did_name]),
se = unname(se[did_name]),
lo95 = est - 1.96 * se, hi95 = est + 1.96 * se)
}
twfe_tbl &amp;lt;- bind_rows(
extract_did(twfe_levels_unw, &amp;quot;Levels (D:Post)&amp;quot;, &amp;quot;Unweighted&amp;quot;),
extract_did(twfe_fe_unw, &amp;quot;Two-way FE (D:Post)&amp;quot;, &amp;quot;Unweighted&amp;quot;),
extract_did(twfe_long_unw, &amp;quot;Long difference&amp;quot;, &amp;quot;Unweighted&amp;quot;),
extract_did(twfe_levels_wt, &amp;quot;Levels (D:Post)&amp;quot;, &amp;quot;Population-weighted&amp;quot;),
extract_did(twfe_fe_wt, &amp;quot;Two-way FE (D:Post)&amp;quot;, &amp;quot;Population-weighted&amp;quot;),
extract_did(twfe_long_wt, &amp;quot;Long difference&amp;quot;, &amp;quot;Population-weighted&amp;quot;)
)
print(twfe_tbl)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">2x2 TWFE estimates:
spec weighting est se lo95 hi95
1 Levels (D:Post) Unweighted 0.122 3.75 -7.23 7.47
2 Two-way FE (D:Post) Unweighted 0.122 3.75 -7.22 7.47
3 Long difference Unweighted 0.122 3.75 -7.22 7.47
4 Levels (D:Post) Population-weighted -2.56 1.49 -5.48 0.358
5 Two-way FE (D:Post) Population-weighted -2.56 1.49 -5.48 0.357
6 Long difference Population-weighted -2.56 1.49 -5.48 0.357
&lt;/code>&lt;/pre>
&lt;p>The point estimates are numerically identical within each weighting regime: $0.122$ unweighted and $-2.563$ weighted, agreeing to three decimals across all three specifications. This is the manuscript&amp;rsquo;s algebraic Result 1 in action: &amp;ldquo;the estimate of $\beta^{2 \times 2}$ is numerically the same if the regression instead contains fixed effects for each unit (columns 2 and 5) or if one regresses outcome changes on a constant and the treatment group dummy&amp;rdquo; (line 234).&lt;/p>
&lt;p>The standard errors are also indistinguishable across specifications &amp;mdash; $3.75$ unweighted, $1.49$ weighted &amp;mdash; but they differ sharply &lt;em>across weightings&lt;/em>: the weighted SE is roughly $2.5\times$ tighter than the unweighted SE. The 95% confidence interval for the weighted estimate, $[-5.48, +0.36]$, narrowly fails to exclude zero; the unweighted CI, $[-7.23, +7.47]$, is far from rejecting the null.&lt;/p>
&lt;p>The forest plot makes the point visually: within a weighting, the three rows are essentially superimposed; across weightings, the two color groups are clearly separated.&lt;/p>
&lt;pre>&lt;code class="language-r">p2 &amp;lt;- ggplot(twfe_tbl, aes(x = est, y = spec, color = weighting)) +
geom_vline(xintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_errorbar(aes(xmin = lo95, xmax = hi95), width = 0.18, linewidth = 0.9,
orientation = &amp;quot;y&amp;quot;, position = position_dodge(width = 0.55)) +
geom_point(size = 3.4, position = position_dodge(width = 0.55)) +
scale_color_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE)) +
labs(title = &amp;quot;Three TWFE specifications, two weighting choices&amp;quot;,
x = &amp;quot;DiD coefficient (deaths per 100,000)&amp;quot;, y = NULL)
ggsave(&amp;quot;r_did2_02_twfe_2x2.png&amp;quot;, p2, width = 10, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_02_twfe_2x2.png" alt="Figure 2: Six DiD estimates (three specifications x two weights), with 95% confidence intervals. The three rows within each weighting are visually superimposed &amp;mdash; the regression form is interchangeable, but the weighting moves the point estimate by 2.7 deaths per 100,000.">&lt;/p>
&lt;p>The lesson from this stage is structural. For the 2x2 design on a balanced panel, there is &lt;em>no methodological choice between Levels, TWFE, and Long Difference&lt;/em>: they are the same estimator written three ways. The only substantive choice is whether to weight. Every later stage of the pipeline carries that lesson forward.&lt;/p>
&lt;h2 id="6-covariate-balance-and-propensity-scores">6. Covariate balance and propensity scores&lt;/h2>
&lt;p>Parallel trends is easier to defend when treated and control groups look similar at baseline. If 2014-expansion counties were already very different from never-expansion counties in 2013, the assumption that they would have shared the same counterfactual trend becomes harder to justify. We assess balance using two complementary tools: the &lt;em>normalized difference&lt;/em> for each covariate, and the &lt;em>propensity score&lt;/em> (the predicted probability of treatment given covariates).&lt;/p>
&lt;p>The normalized difference is defined as&lt;/p>
&lt;p>$$\text{Norm. Diff}_{\omega, X} = \frac{\bar{X}_{\omega, T} - \bar{X}_{\omega, C}}{\sqrt{(S_{\omega, T}^2 + S_{\omega, C}^2) / 2}}$$&lt;/p>
&lt;p>In words, this is the difference of treated and control means divided by the average of their within-group standard deviations, all computed under weighting scheme $\omega$. The denominator scales the gap by the units' typical spread, so the metric is comparable across covariates with very different ranges (percentages versus dollars versus rates). The rule of thumb the manuscript adopts (line 275, following Imbens and Rubin 2015): values in excess of $0.25$ in absolute value indicate &amp;ldquo;potentially problematic imbalance.&amp;rdquo; In our code, $\bar{X}_{\omega, T}$ is &lt;code>mean_T&lt;/code> (weighted or unweighted), $\bar{X}_{\omega, C}$ is &lt;code>mean_C&lt;/code>, and the denominator combines &lt;code>var_T&lt;/code> and &lt;code>var_C&lt;/code> (with &lt;code>wtd_var()&lt;/code> substituting for &lt;code>var()&lt;/code> under weighting).&lt;/p>
&lt;pre>&lt;code class="language-r">wtd_var &amp;lt;- function(x, w) {
ok &amp;lt;- !is.na(x + w); x &amp;lt;- x[ok]; w &amp;lt;- w[ok]
xbar &amp;lt;- weighted.mean(x, w)
sum(w * (x - xbar)^2) / (sum(w) - 1)
}
balance_unw &amp;lt;- short_data %&amp;gt;% filter(year == 2013) %&amp;gt;%
pivot_longer(all_of(covs), names_to = &amp;quot;variable&amp;quot;, values_to = &amp;quot;value&amp;quot;) %&amp;gt;%
group_by(variable, D) %&amp;gt;%
summarise(mean = mean(value), var = var(value), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
pivot_wider(names_from = D, values_from = c(mean, var)) %&amp;gt;%
mutate(weighting = &amp;quot;Unweighted&amp;quot;,
norm_diff = (mean_1 - mean_0) / sqrt((var_1 + var_0) / 2))
balance_wt &amp;lt;- short_data %&amp;gt;% filter(year == 2013) %&amp;gt;%
pivot_longer(all_of(covs), names_to = &amp;quot;variable&amp;quot;, values_to = &amp;quot;value&amp;quot;) %&amp;gt;%
group_by(variable, D) %&amp;gt;%
summarise(mean = weighted.mean(value, set_wt),
var = wtd_var(value, set_wt), .groups = &amp;quot;drop&amp;quot;) %&amp;gt;%
pivot_wider(names_from = D, values_from = c(mean, var)) %&amp;gt;%
mutate(weighting = &amp;quot;Population-weighted&amp;quot;,
norm_diff = (mean_1 - mean_0) / sqrt((var_1 + var_0) / 2))
&lt;/code>&lt;/pre>
&lt;p>The full balance table (&lt;code>table_covariate_balance.csv&lt;/code>) reports the six covariates under each weighting. The point estimates of the means and their normalized differences are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>weighting&lt;/th>
&lt;th>variable&lt;/th>
&lt;th style="text-align:right">mean_C (never)&lt;/th>
&lt;th style="text-align:right">mean_T (2014)&lt;/th>
&lt;th style="text-align:right">norm_diff&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>median_income&lt;/td>
&lt;td style="text-align:right">43.04&lt;/td>
&lt;td style="text-align:right">47.97&lt;/td>
&lt;td style="text-align:right">$+0.427$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>perc_female&lt;/td>
&lt;td style="text-align:right">49.43&lt;/td>
&lt;td style="text-align:right">49.33&lt;/td>
&lt;td style="text-align:right">$-0.034$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>perc_hispanic&lt;/td>
&lt;td style="text-align:right">9.64&lt;/td>
&lt;td style="text-align:right">8.23&lt;/td>
&lt;td style="text-align:right">$-0.105$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>perc_white&lt;/td>
&lt;td style="text-align:right">81.64&lt;/td>
&lt;td style="text-align:right">90.48&lt;/td>
&lt;td style="text-align:right">$+0.586$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>poverty_rate&lt;/td>
&lt;td style="text-align:right">19.28&lt;/td>
&lt;td style="text-align:right">16.53&lt;/td>
&lt;td style="text-align:right">$-0.423$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td>unemp_rate&lt;/td>
&lt;td style="text-align:right">7.61&lt;/td>
&lt;td style="text-align:right">8.01&lt;/td>
&lt;td style="text-align:right">$+0.157$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>median_income&lt;/td>
&lt;td style="text-align:right">49.31&lt;/td>
&lt;td style="text-align:right">57.86&lt;/td>
&lt;td style="text-align:right">$+0.685$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>perc_female&lt;/td>
&lt;td style="text-align:right">50.48&lt;/td>
&lt;td style="text-align:right">50.07&lt;/td>
&lt;td style="text-align:right">$-0.238$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>perc_hispanic&lt;/td>
&lt;td style="text-align:right">17.01&lt;/td>
&lt;td style="text-align:right">18.86&lt;/td>
&lt;td style="text-align:right">$+0.107$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>perc_white&lt;/td>
&lt;td style="text-align:right">77.91&lt;/td>
&lt;td style="text-align:right">79.54&lt;/td>
&lt;td style="text-align:right">$+0.115$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>poverty_rate&lt;/td>
&lt;td style="text-align:right">17.24&lt;/td>
&lt;td style="text-align:right">15.29&lt;/td>
&lt;td style="text-align:right">$-0.375$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td>unemp_rate&lt;/td>
&lt;td style="text-align:right">7.00&lt;/td>
&lt;td style="text-align:right">8.01&lt;/td>
&lt;td style="text-align:right">$+0.503$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Six of twelve cells exceed the $\pm 0.25$ threshold in absolute value: under equal weighting, expansion counties are notably &lt;em>whiter&lt;/em> (&lt;code>perc_white&lt;/code> = $+0.586$), &lt;em>richer&lt;/em> (&lt;code>median_income&lt;/code> = $+0.427$), and &lt;em>less impoverished&lt;/em> (&lt;code>poverty_rate&lt;/code> = $-0.423$) than never-expansion counties; under population weighting, the gap shifts toward unemployment and income (&lt;code>unemp_rate&lt;/code> = $+0.503$, &lt;code>median_income&lt;/code> = $+0.685$). The manuscript flags this pattern at line 279 (Table &lt;code>tab:cov_balance&lt;/code>): &amp;ldquo;Expansion counties in 2013 were whiter and had a higher unemployment rate despite lower poverty and higher median income.&amp;rdquo; Imbalance does not invalidate parallel trends, but it makes the &lt;em>unconditional&lt;/em> parallel-trends assumption harder to swallow on its own &amp;mdash; which motivates the covariate-adjusted estimators in Section 7.&lt;/p>
&lt;p>The propensity score summarizes all six covariates in a single number: the predicted probability of being a 2014-expansion county given the 2013 covariates. We fit a logit for $P(D = 1 \mid X)$ under each weighting using &lt;code>fixest::feglm()&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-r">ps_form &amp;lt;- as.formula(paste(&amp;quot;D ~&amp;quot;, paste(covs, collapse = &amp;quot; + &amp;quot;)))
ps_unw &amp;lt;- feglm(ps_form, data = short_data %&amp;gt;% filter(year == 2013),
family = &amp;quot;binomial&amp;quot;, vcov = &amp;quot;hetero&amp;quot;)
ps_wt &amp;lt;- feglm(ps_form, data = short_data %&amp;gt;% filter(year == 2013),
family = &amp;quot;binomial&amp;quot;, vcov = &amp;quot;hetero&amp;quot;, weights = ~set_wt)
&lt;/code>&lt;/pre>
&lt;p>The propensity-score logit estimates (&lt;code>table_propensity_models.csv&lt;/code>) corroborate the normalized-difference picture: every covariate except &lt;code>poverty_rate&lt;/code> (unweighted) is significant at the 5% level, and the unemployment-rate coefficient under weighting is striking ($+0.680$, $p = 1.2 \times 10^{-15}$). To assess &lt;em>overlap&lt;/em> &amp;mdash; whether treated and control units occupy the same propensity-score region, a precondition for credible IPW &amp;mdash; we plot the density of predicted probabilities by group, faceted by weighting.&lt;/p>
&lt;pre>&lt;code class="language-r">ps_plot_df &amp;lt;- bind_rows(
short_data %&amp;gt;% filter(year == 2013) %&amp;gt;%
mutate(p = predict(ps_unw, ., type = &amp;quot;response&amp;quot;),
wt_use = 1, weighting = &amp;quot;Unweighted&amp;quot;),
short_data %&amp;gt;% filter(year == 2013) %&amp;gt;%
mutate(p = predict(ps_wt, ., type = &amp;quot;response&amp;quot;),
wt_use = set_wt, weighting = &amp;quot;Population-weighted&amp;quot;)
) %&amp;gt;%
mutate(group = if_else(D == 1, &amp;quot;Expansion&amp;quot;, &amp;quot;Non-expansion&amp;quot;),
weighting = factor(weighting,
levels = c(&amp;quot;Unweighted&amp;quot;, &amp;quot;Population-weighted&amp;quot;)))
p3 &amp;lt;- ggplot(ps_plot_df, aes(x = p, fill = group, weight = wt_use)) +
geom_density(alpha = 0.55, color = NA, adjust = 1.2) +
scale_fill_manual(values = c(&amp;quot;Expansion&amp;quot; = ORANGE,
&amp;quot;Non-expansion&amp;quot; = BLUE)) +
facet_wrap(~ weighting) +
labs(title = &amp;quot;Propensity-score overlap, by weighting&amp;quot;,
x = &amp;quot;Estimated propensity score&amp;quot;, y = &amp;quot;Density&amp;quot;)
ggsave(&amp;quot;r_did2_03_propensity.png&amp;quot;, p3, width = 10, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_03_propensity.png" alt="Figure 3: Propensity-score densities by expansion status, under equal weighting (left) and population weighting (right). Unweighted overlap is moderate; under population weighting the treated mass piles up near $0.85$ and the control mass spreads bimodally, indicating much weaker overlap.">&lt;/p>
&lt;p>Under equal weighting the two density curves overlap substantially, with treated and control units occupying similar regions of propensity-score space. Under population weighting the picture is markedly worse: treated counties pile up near a propensity of $0.85$ while non-expansion counties spread bimodally across the full range. Weighting amplifies imbalance because California and Texas (very large expansion and non-expansion counties, respectively) pull the conditional means apart. This is the reason covariate adjustment becomes &lt;em>more&lt;/em> consequential under population weighting &amp;mdash; and why the next section computes three different covariate-adjusted estimators rather than picking one.&lt;/p>
&lt;h2 id="7-covariate-adjusted-2x2-----or-ipw-and-drdid">7. Covariate-adjusted 2x2 &amp;mdash; OR, IPW, and DRDID&lt;/h2>
&lt;p>The Section 4 cell-means estimate assumed &lt;em>unconditional&lt;/em> parallel trends: treated and control counties would have moved together absent expansion, full stop. The imbalance documented in Section 6 makes that assumption brittle. The fix is &lt;em>conditional&lt;/em> parallel trends: treated and control counties with similar covariate values would have moved together. Three estimators implement this fix, each leaning on a different model:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Outcome regression (OR).&lt;/strong> Fit a model for $Y_{i, t}(0)$ on the control group as a function of covariates; predict counterfactuals for the treated group; subtract from observed outcomes.&lt;/li>
&lt;li>&lt;strong>Inverse propensity weighting (IPW).&lt;/strong> Reweight the control group so its covariate distribution matches the treated group&amp;rsquo;s; compute the DiD on the reweighted sample.&lt;/li>
&lt;li>&lt;strong>Doubly robust DiD (DRDID).&lt;/strong> Combine OR and IPW in a way that yields a consistent estimate if &lt;em>either&lt;/em> model is correctly specified.&lt;/li>
&lt;/ul>
&lt;p>All three are implemented in the &lt;code>did&lt;/code> package via the &lt;code>est_method&lt;/code> argument to &lt;code>att_gt()&lt;/code>. We wrap them in a single helper that toggles weighting on or off.&lt;/p>
&lt;pre>&lt;code class="language-r">data_cs_2x2 &amp;lt;- short_data %&amp;gt;%
mutate(treat_year_cs = if_else(D == 1, 2014, 0),
id_num = as.numeric(county_code)) %&amp;gt;%
select(id_num, year, crude_rate_20_64, treat_year_cs, set_wt, all_of(covs))
xformla &amp;lt;- as.formula(paste(&amp;quot;~&amp;quot;, paste(covs, collapse = &amp;quot; + &amp;quot;)))
cs_one &amp;lt;- function(method, weighted) {
if (weighted) {
res &amp;lt;- did::att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year_cs&amp;quot;,
xformla = xformla, data = data_cs_2x2, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = method, biters = BITERS,
weightsname = &amp;quot;set_wt&amp;quot;)
} else {
res &amp;lt;- did::att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year_cs&amp;quot;,
xformla = xformla, data = data_cs_2x2, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = method, biters = BITERS)
}
agg &amp;lt;- suppressMessages(aggte(res, type = &amp;quot;simple&amp;quot;, na.rm = TRUE))
tibble(method = method,
weighting = if (weighted) &amp;quot;Population-weighted&amp;quot; else &amp;quot;Unweighted&amp;quot;,
est = agg$overall.att, se = agg$overall.se)
}
cs_2x2_tbl &amp;lt;- bind_rows(
cs_one(&amp;quot;reg&amp;quot;, FALSE), cs_one(&amp;quot;reg&amp;quot;, TRUE),
cs_one(&amp;quot;ipw&amp;quot;, FALSE), cs_one(&amp;quot;ipw&amp;quot;, TRUE),
cs_one(&amp;quot;dr&amp;quot;, FALSE), cs_one(&amp;quot;dr&amp;quot;, TRUE)
)
&lt;/code>&lt;/pre>
&lt;p>The doubly robust DRDID estimator (Sant&amp;rsquo;Anna and Zhao 2020) takes the form&lt;/p>
&lt;p>$$\widehat{\text{ATT}}_{\text{DR}} = \frac{1}{n} \sum_{i = 1}^{n} \Big( \hat{w}_{D = 1}(D_i) - \hat{w}_{D = 0}(D_i, X_i) \Big) \Big( \Delta Y_{i} - \hat{\mu}_{\Delta, D = 0}(X_i) \Big)$$&lt;/p>
&lt;p>In words, each county contributes a weighted residual: the weight $\hat{w}_{D = 1} - \hat{w}_{D = 0}$ depends on its treatment status and (through the propensity score) its covariates, while the residual $\Delta Y_i - \hat{\mu}_{\Delta, D = 0}(X_i)$ measures how much that county&amp;rsquo;s 2013-to-2014 change differed from what the outcome regression predicted for an untreated unit with the same covariates. In our code, $\Delta Y_i$ is the long-difference outcome (&lt;code>crude_rate_20_64&lt;/code> in 2014 minus 2013), $\hat{\mu}_{\Delta, D = 0}(X_i)$ comes from the OR step under the hood of &lt;code>att_gt(est_method = &amp;quot;dr&amp;quot;)&lt;/code>, and the propensity weights $\hat{w}$ come from the same logit we fit in Section 6. The &amp;ldquo;double&amp;rdquo; in doubly robust is that the estimator stays consistent if &lt;em>either&lt;/em> the OR or the propensity model is correctly specified; it does not require both. The manuscript states the formula at line 446 (&lt;code>eqn:ATT_DR_estimator&lt;/code>).&lt;/p>
&lt;pre>&lt;code class="language-text">2x2 covariate-adjusted estimates:
method weighting est se method_label lo95 hi95
1 reg Unweighted -1.62 4.66 Outcome regression (OR) -10.7 7.51
2 reg Population-weighted -3.46 2.29 Outcome regression (OR) -7.95 1.03
3 ipw Unweighted -0.859 4.84 Inverse propensity weigh… -10.3 8.62
4 ipw Population-weighted -3.84 3.19 Inverse propensity weigh… -10.1 2.42
5 dr Unweighted -1.23 5.05 Doubly robust (DRDID) -11.1 8.68
6 dr Population-weighted -3.76 3.29 Doubly robust (DRDID) -10.2 2.69
&lt;/code>&lt;/pre>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>method&lt;/th>
&lt;th>weighting&lt;/th>
&lt;th style="text-align:right">est&lt;/th>
&lt;th style="text-align:right">se&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Outcome regression (OR)&lt;/td>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">$-1.615$&lt;/td>
&lt;td style="text-align:right">4.66&lt;/td>
&lt;td>$[-10.74, +7.51]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Outcome regression (OR)&lt;/td>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">$-3.459$&lt;/td>
&lt;td style="text-align:right">2.29&lt;/td>
&lt;td>$[-7.95, +1.03]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inverse propensity weighting (IPW)&lt;/td>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">$-0.859$&lt;/td>
&lt;td style="text-align:right">4.84&lt;/td>
&lt;td>$[-10.34, +8.62]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inverse propensity weighting (IPW)&lt;/td>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">$-3.842$&lt;/td>
&lt;td style="text-align:right">3.19&lt;/td>
&lt;td>$[-10.10, +2.42]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Doubly robust (DRDID)&lt;/td>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">$-1.226$&lt;/td>
&lt;td style="text-align:right">5.05&lt;/td>
&lt;td>$[-11.13, +8.68]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Doubly robust (DRDID)&lt;/td>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">$-3.756$&lt;/td>
&lt;td style="text-align:right">3.29&lt;/td>
&lt;td>$[-10.20, +2.69]$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The forest plot, combining the three covariate-adjusted estimators with the no-covariates TWFE long-difference baseline from Section 5, makes the comparison visual.&lt;/p>
&lt;pre>&lt;code class="language-r">forest_df &amp;lt;- bind_rows(
twfe_tbl %&amp;gt;% filter(spec == &amp;quot;Long difference&amp;quot;) %&amp;gt;%
transmute(method_label = &amp;quot;TWFE long diff (no covs)&amp;quot;,
weighting, est, se, lo95, hi95),
cs_2x2_tbl %&amp;gt;%
mutate(method_label = recode(method,
reg = &amp;quot;Outcome regression (OR)&amp;quot;,
ipw = &amp;quot;Inverse propensity weighting (IPW)&amp;quot;,
dr = &amp;quot;Doubly robust (DRDID)&amp;quot;),
lo95 = est - 1.96 * se, hi95 = est + 1.96 * se) %&amp;gt;%
select(method_label, weighting, est, se, lo95, hi95)
)
p4 &amp;lt;- ggplot(forest_df, aes(x = est, y = method_label, color = weighting)) +
geom_vline(xintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_errorbar(aes(xmin = lo95, xmax = hi95), width = 0.2, linewidth = 0.9,
orientation = &amp;quot;y&amp;quot;, position = position_dodge(width = 0.55)) +
geom_point(size = 3.3, position = position_dodge(width = 0.55)) +
scale_color_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE)) +
labs(title = &amp;quot;Covariate-adjusted 2x2 estimates&amp;quot;,
x = &amp;quot;ATT(2014) (deaths per 100,000)&amp;quot;, y = NULL)
ggsave(&amp;quot;r_did2_04_drdid_forest.png&amp;quot;, p4, width = 11, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_04_drdid_forest.png" alt="Figure 4: Four estimators (TWFE long difference, OR, IPW, DRDID) under two weights, with 95% CIs. Within a weighting, the four rows are close; across weightings, the two color groups remain clearly separated. None of the six 95% CIs excludes zero.">&lt;/p>
&lt;p>Covariate adjustment moves the unweighted point estimate from $+0.122$ (cell means) down to $-1.226$ (DRDID), and shifts the weighted estimate from $-2.563$ to $-3.756$. The unweighted-to-weighted gap remains roughly $2.5$ deaths per 100,000 &amp;mdash; &lt;em>larger&lt;/em> than the gap between estimators within each weighting (which is at most $0.8$ deaths per 100,000). The manuscript notes (line 425) that &amp;ldquo;the weighted IPW estimate is almost twice as large as the RA [regression-adjustment] estimate, despite neither being statistically significant&amp;rdquo;; we see a similar but smaller divergence ($-3.84$ vs $-3.46$, a $1.1\times$ ratio), with DRDID landing between them. Crucially, &lt;strong>none of the six 95% confidence intervals excludes zero&lt;/strong>. Covariate adjustment matters for &lt;em>interpretation&lt;/em> (the unweighted point estimate is now a small negative rather than a small positive), but it does not buy statistical significance &amp;mdash; and the weighting choice still dwarfs the methodological choice.&lt;/p>
&lt;p>A note on causal language: covariate adjustment is being deployed here for &lt;em>confounding control&lt;/em> under observational identification. This is not a randomized experiment with precision-improving covariates; the covariates change the &lt;em>target parameter&lt;/em> (from unconditional ATT to ATT conditional on $X$). The manuscript discusses this at lines 264&amp;ndash;449.&lt;/p>
&lt;h2 id="8-the-2xt-event-study-----2014-expanders-vs-never-expanders">8. The 2xT event study &amp;mdash; 2014 expanders vs never-expanders&lt;/h2>
&lt;p>The 2x2 design throws away nine of our eleven years. A &lt;em>dynamic&lt;/em> event study estimates an ATT for &lt;em>every&lt;/em> year relative to expansion, treating $e = -1$ (the year before treatment) as the omitted baseline. The leads ($e \leq -2$) double as a placebo test for parallel trends: if the assumption holds, they should hover around zero. The lags ($e \geq 0$) trace out how the effect evolves over time. We restrict the panel to 2014-expanders and never-treated counties (still no staggered cohorts yet) and use &lt;code>did::att_gt()&lt;/code> with &lt;code>est_method = &amp;quot;dr&amp;quot;&lt;/code>, then aggregate to event time via &lt;code>aggte(type = &amp;quot;dynamic&amp;quot;)&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-r">data_2xt &amp;lt;- df_prep %&amp;gt;%
filter(treat_year %in% c(0, 2014)) %&amp;gt;%
mutate(id_num = as.numeric(county_code)) %&amp;gt;%
select(id_num, year, crude_rate_20_64, treat_year, set_wt, all_of(covs))
att_2xt_unw &amp;lt;- att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year&amp;quot;,
xformla = xformla, data = data_2xt, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = &amp;quot;dr&amp;quot;, biters = BITERS)
att_2xt_wt &amp;lt;- att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year&amp;quot;,
xformla = xformla, data = data_2xt, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = &amp;quot;dr&amp;quot;,
weightsname = &amp;quot;set_wt&amp;quot;, biters = BITERS)
es_2xt_unw &amp;lt;- aggte(att_2xt_unw, type = &amp;quot;dynamic&amp;quot;, na.rm = TRUE)
es_2xt_wt &amp;lt;- aggte(att_2xt_wt, type = &amp;quot;dynamic&amp;quot;, na.rm = TRUE)
event_2xt_tbl &amp;lt;- bind_rows(
tibble(e = es_2xt_unw$egt, est = es_2xt_unw$att.egt,
se = es_2xt_unw$se.egt, weighting = &amp;quot;Unweighted&amp;quot;),
tibble(e = es_2xt_wt$egt, est = es_2xt_wt$att.egt,
se = es_2xt_wt$se.egt, weighting = &amp;quot;Population-weighted&amp;quot;)
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">2xT event study (ATT(e)):
e est se weighting lo95 hi95
1 -5 8.48 4.35 Unweighted -0.0363 17.0
2 -4 1.69 4.18 Unweighted -6.51 9.88
3 -3 3.84 4.30 Unweighted -4.59 12.3
4 -2 7.33 5.32 Unweighted -3.10 17.8
5 -1 0 NA Unweighted NA NA
6 0 -1.23 5.00 Unweighted -11.0 8.58
7 1 5.36 4.90 Unweighted -4.24 15.0
8 2 12.2 4.76 Unweighted 2.90 21.6
9 3 13.5 5.19 Unweighted 3.38 23.7
10 4 9.69 5.65 Unweighted -1.38 20.8
&lt;/code>&lt;/pre>
&lt;p>The full 22-row panel covers $e = -5$ to $+5$ for each weighting:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">e&lt;/th>
&lt;th style="text-align:right">est (unw)&lt;/th>
&lt;th style="text-align:right">se (unw)&lt;/th>
&lt;th>95% CI (unw)&lt;/th>
&lt;th style="text-align:right">est (wt)&lt;/th>
&lt;th style="text-align:right">se (wt)&lt;/th>
&lt;th>95% CI (wt)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">$-5$&lt;/td>
&lt;td style="text-align:right">$+8.48$&lt;/td>
&lt;td style="text-align:right">4.35&lt;/td>
&lt;td>$[-0.04, +17.00]$&lt;/td>
&lt;td style="text-align:right">$+1.75$&lt;/td>
&lt;td style="text-align:right">3.33&lt;/td>
&lt;td>$[-4.78, +8.27]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-4$&lt;/td>
&lt;td style="text-align:right">$+1.69$&lt;/td>
&lt;td style="text-align:right">4.18&lt;/td>
&lt;td>$[-6.51, +9.88]$&lt;/td>
&lt;td style="text-align:right">$+0.34$&lt;/td>
&lt;td style="text-align:right">3.28&lt;/td>
&lt;td>$[-6.09, +6.77]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-3$&lt;/td>
&lt;td style="text-align:right">$+3.84$&lt;/td>
&lt;td style="text-align:right">4.30&lt;/td>
&lt;td>$[-4.59, +12.26]$&lt;/td>
&lt;td style="text-align:right">$+2.87$&lt;/td>
&lt;td style="text-align:right">2.92&lt;/td>
&lt;td>$[-2.84, +8.59]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-2$&lt;/td>
&lt;td style="text-align:right">$+7.33$&lt;/td>
&lt;td style="text-align:right">5.32&lt;/td>
&lt;td>$[-3.10, +17.76]$&lt;/td>
&lt;td style="text-align:right">$+1.51$&lt;/td>
&lt;td style="text-align:right">4.51&lt;/td>
&lt;td>$[-7.33, +10.35]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-1$&lt;/td>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">&amp;ndash;&lt;/td>
&lt;td>(reference)&lt;/td>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">&amp;ndash;&lt;/td>
&lt;td>(reference)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">$-1.23$&lt;/td>
&lt;td style="text-align:right">5.00&lt;/td>
&lt;td>$[-11.03, +8.58]$&lt;/td>
&lt;td style="text-align:right">$-3.76$&lt;/td>
&lt;td style="text-align:right">3.14&lt;/td>
&lt;td>$[-9.91, +2.40]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+1$&lt;/td>
&lt;td style="text-align:right">$+5.36$&lt;/td>
&lt;td style="text-align:right">4.90&lt;/td>
&lt;td>$[-4.24, +14.96]$&lt;/td>
&lt;td style="text-align:right">$-1.31$&lt;/td>
&lt;td style="text-align:right">4.78&lt;/td>
&lt;td>$[-10.68, +8.05]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+2$&lt;/td>
&lt;td style="text-align:right">$+12.24$&lt;/td>
&lt;td style="text-align:right">4.76&lt;/td>
&lt;td>$[+2.90, +21.57]$&lt;/td>
&lt;td style="text-align:right">$+3.28$&lt;/td>
&lt;td style="text-align:right">4.02&lt;/td>
&lt;td>$[-4.60, +11.16]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+3$&lt;/td>
&lt;td style="text-align:right">$+13.54$&lt;/td>
&lt;td style="text-align:right">5.19&lt;/td>
&lt;td>$[+3.38, +23.71]$&lt;/td>
&lt;td style="text-align:right">$-4.71$&lt;/td>
&lt;td style="text-align:right">5.41&lt;/td>
&lt;td>$[-15.31, +5.89]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+4$&lt;/td>
&lt;td style="text-align:right">$+9.69$&lt;/td>
&lt;td style="text-align:right">5.65&lt;/td>
&lt;td>$[-1.38, +20.76]$&lt;/td>
&lt;td style="text-align:right">$-0.08$&lt;/td>
&lt;td style="text-align:right">5.29&lt;/td>
&lt;td>$[-10.46, +10.29]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+5$&lt;/td>
&lt;td style="text-align:right">$+16.96$&lt;/td>
&lt;td style="text-align:right">5.17&lt;/td>
&lt;td>$[+6.83, +27.09]$&lt;/td>
&lt;td style="text-align:right">$+2.48$&lt;/td>
&lt;td style="text-align:right">5.73&lt;/td>
&lt;td>$[-8.75, +13.70]$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-r">p5 &amp;lt;- ggplot(event_2xt_tbl, aes(x = e, y = est,
color = weighting, fill = weighting)) +
geom_hline(yintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_vline(xintercept = -0.5, color = ORANGE, linetype = &amp;quot;dotted&amp;quot;) +
geom_ribbon(aes(ymin = est - 1.96 * se, ymax = est + 1.96 * se),
alpha = 0.18, color = NA, na.rm = TRUE) +
geom_line(linewidth = 1.1) +
geom_point(size = 2.6) +
scale_color_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE),
aesthetics = c(&amp;quot;color&amp;quot;, &amp;quot;fill&amp;quot;)) +
labs(title = &amp;quot;Event study: 2014 expanders vs never-expanders&amp;quot;,
x = &amp;quot;Years since Medicaid expansion (e)&amp;quot;,
y = &amp;quot;ATT(e) (deaths per 100,000)&amp;quot;)
ggsave(&amp;quot;r_did2_05_event_2xT.png&amp;quot;, p5, width = 11, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_05_event_2xT.png" alt="Figure 5: Dynamic ATT(e) event study for the 2014 cohort, with shaded 95% CIs and a dotted reference line at $e = -0.5$ separating leads from lags. The unweighted (blue) and weighted (orange) trajectories sit close together pre-2014 but diverge sharply after expansion.">&lt;/p>
&lt;p>The leads tell a more nuanced parallel-trends story than the 2x2 could. The unweighted leads at $e = -5$ and $e = -2$ are $+8.48$ and $+7.33$ &amp;mdash; both visibly above zero, with the $e = -5$ CI narrowly straddling zero ($[-0.04, +17.00]$). The weighted leads are markedly flatter, ranging from $+0.34$ to $+2.87$ across the same window. After expansion, the trajectories diverge sharply: unweighted ATT(e) climbs from $-1.23$ at $e = 0$ to $+16.96$ at $e = 5$ &amp;mdash; a 95% CI of $[+6.83, +27.09]$ that &lt;em>excludes zero&lt;/em> &amp;mdash; while weighted ATT(e) wanders between $-4.71$ and $+3.28$ with every CI overlapping zero. The dynamic-aggregated ATT averaged over $e \geq 0$ is $+9.43$ unweighted versus $-0.68$ weighted, a 10-death gap that is wider than the 2x2&amp;rsquo;s $2.7$-death gap.&lt;/p>
&lt;p>The manuscript&amp;rsquo;s &lt;code>fig:2XT_ES&lt;/code> (line 535) reports the population-weighted version and concludes &amp;ldquo;the point estimates do not suggest large mortality effects from Medicaid expansion among expansion counties.&amp;rdquo; That conclusion follows from the weighted view; the unweighted view tells a strikingly different story. The 2xT design&amp;rsquo;s identifying assumption &amp;mdash; parallel trends in &lt;em>every&lt;/em> post-period, manuscript Assumption &lt;code>ass:parallel-trends-ES&lt;/code> at line 518 &amp;mdash; looks more credible under population weighting in this application, both because the pre-period leads are flatter and because the implied trend across the post-period is more stable.&lt;/p>
&lt;h2 id="9-the-full-gxt-staggered-design-----all-four-cohorts">9. The full GxT staggered design &amp;mdash; all four cohorts&lt;/h2>
&lt;p>The 2xT design used only the 2014 cohort. To use &lt;em>all&lt;/em> the variation in expansion timing, we need the Callaway-Sant&amp;rsquo;Anna $\text{ATT}(g, t)$ framework. Define $G_i$ as the year unit $i$ first expanded (or $\infty$ for never-expanders). The group-time ATT is&lt;/p>
&lt;p>$$\text{ATT}(g, t) = \mathbb{E}_\omega \big[ Y_{i, t}(g) - Y_{i, t}(\infty) \mid G_i = g \big]$$&lt;/p>
&lt;p>In words, this is the average treatment effect of starting treatment in year $g$ (relative to never starting) at calendar time $t$, restricted to units whose actual treatment year is $g$. The estimand exists separately for every cohort-year cell; aggregation comes later. The identifying assumption is parallel trends with respect to the never-treated group: $\mathbb{E}_\omega[Y_{i, t}(\infty) - Y_{i, t - 1}(\infty) \mid G_i = g] = \mathbb{E}_\omega[Y_{i, t}(\infty) - Y_{i, t - 1}(\infty) \mid G_i = \infty]$, for every cohort $g$ and every period $t$ (manuscript Assumption &lt;code>ass:gt-parallel-trends-never&lt;/code>, line 642).&lt;/p>
&lt;pre>&lt;code class="language-r">data_gxt &amp;lt;- df_prep %&amp;gt;%
mutate(id_num = as.numeric(county_code)) %&amp;gt;%
select(id_num, year, crude_rate_20_64, treat_year, set_wt, all_of(covs))
att_gxt_unw &amp;lt;- att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year&amp;quot;,
xformla = xformla, data = data_gxt, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = &amp;quot;dr&amp;quot;, biters = BITERS)
att_gxt_wt &amp;lt;- att_gt(yname = &amp;quot;crude_rate_20_64&amp;quot;, tname = &amp;quot;year&amp;quot;,
idname = &amp;quot;id_num&amp;quot;, gname = &amp;quot;treat_year&amp;quot;,
xformla = xformla, data = data_gxt, panel = TRUE,
control_group = &amp;quot;nevertreated&amp;quot;,
base_period = &amp;quot;universal&amp;quot;,
bstrap = TRUE, est_method = &amp;quot;dr&amp;quot;,
weightsname = &amp;quot;set_wt&amp;quot;, biters = BITERS)
&lt;/code>&lt;/pre>
&lt;p>The raw output contains an $\text{ATT}(g, t)$ for each of $4 \times 11 = 44$ cohort-year cells, times two weightings &amp;mdash; 88 values in total, stored in &lt;code>table_attgt_gxt.csv&lt;/code>. To extract a comprehensible summary, we aggregate two ways. First, &lt;em>by cohort&lt;/em>: average each cohort&amp;rsquo;s post-treatment ATT(g, t) values into one ATT per cohort. Second, &lt;em>by event time&lt;/em>: pool across cohorts and produce one ATT(e) per event time, the same shape as the 2xT event study but using every cohort&amp;rsquo;s variation.&lt;/p>
&lt;h3 id="9a-by-cohort-attg">9a. By-cohort ATT(g)&lt;/h3>
&lt;pre>&lt;code class="language-r">agg_grp_unw &amp;lt;- aggte(att_gxt_unw, type = &amp;quot;group&amp;quot;, na.rm = TRUE)
agg_grp_wt &amp;lt;- aggte(att_gxt_wt, type = &amp;quot;group&amp;quot;, na.rm = TRUE)
grp_tbl &amp;lt;- bind_rows(
tibble(group = agg_grp_unw$egt, est = agg_grp_unw$att.egt,
se = agg_grp_unw$se.egt, weighting = &amp;quot;Unweighted&amp;quot;),
tibble(group = agg_grp_wt$egt, est = agg_grp_wt$att.egt,
se = agg_grp_wt$se.egt, weighting = &amp;quot;Population-weighted&amp;quot;)
)
print(grp_tbl)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Group-specific ATT(g) (averaged over post periods):
group est se weighting lo95 hi95
1 2014 9.43 3.84 Unweighted 1.90 17.0
2 2015 4.94 5.90 Unweighted -6.61 16.5
3 2016 -17.3 11.0 Unweighted -38.9 4.24
4 2019 3.48 8.85 Unweighted -13.9 20.8
5 2014 -0.684 3.78 Population-weighted -8.09 6.73
6 2015 10.0 2.92 Population-weighted 4.31 15.8
7 2016 -12.6 6.18 Population-weighted -24.7 -0.451
8 2019 3.31 4.46 Population-weighted -5.44 12.1
&lt;/code>&lt;/pre>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">cohort g&lt;/th>
&lt;th style="text-align:right">est (unw)&lt;/th>
&lt;th>95% CI (unw)&lt;/th>
&lt;th style="text-align:right">est (wt)&lt;/th>
&lt;th>95% CI (wt)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">2014&lt;/td>
&lt;td style="text-align:right">$+9.43$&lt;/td>
&lt;td>$[+1.90, +16.96]$&lt;/td>
&lt;td style="text-align:right">$-0.68$&lt;/td>
&lt;td>$[-8.09, +6.73]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2015&lt;/td>
&lt;td style="text-align:right">$+4.94$&lt;/td>
&lt;td>$[-6.61, +16.50]$&lt;/td>
&lt;td style="text-align:right">$+10.04$&lt;/td>
&lt;td>$[+4.31, +15.77]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2016&lt;/td>
&lt;td style="text-align:right">$-17.31$&lt;/td>
&lt;td>$[-38.85, +4.24]$&lt;/td>
&lt;td style="text-align:right">$-12.57$&lt;/td>
&lt;td>$[-24.68, -0.45]$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">2019&lt;/td>
&lt;td style="text-align:right">$+3.48$&lt;/td>
&lt;td>$[-13.88, +20.83]$&lt;/td>
&lt;td style="text-align:right">$+3.31$&lt;/td>
&lt;td>$[-5.44, +12.06]$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-r">p6 &amp;lt;- ggplot(grp_tbl %&amp;gt;%
mutate(lo95 = est - 1.96 * se, hi95 = est + 1.96 * se),
aes(x = factor(group), y = est, fill = weighting)) +
geom_hline(yintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_col(position = position_dodge(width = 0.7), width = 0.6, alpha = 0.9) +
geom_errorbar(aes(ymin = lo95, ymax = hi95),
position = position_dodge(width = 0.7), width = 0.18,
color = TEXT_WHITE) +
scale_fill_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE)) +
labs(title = &amp;quot;By-cohort ATT(g), Callaway-Sant'Anna staggered design&amp;quot;,
x = &amp;quot;Expansion cohort (year)&amp;quot;, y = &amp;quot;ATT(g) (deaths per 100,000)&amp;quot;)
ggsave(&amp;quot;r_did2_06_attgt_groups.png&amp;quot;, p6, width = 10, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_06_attgt_groups.png" alt="Figure 6: By-cohort ATT(g) bar chart. The 2014 cohort flips sign with weighting ($+9.43 \to -0.68$); the 2015 and 2016 cohorts agree in sign across weights but the 2015 cohort is much larger under weighting; the 2019 cohort is small with wide CIs in both weights.">&lt;/p>
&lt;p>The four cohorts show four distinct patterns. The &lt;strong>2014 cohort&lt;/strong> flips sign with weighting &amp;mdash; unweighted $+9.43$ (95% CI $[+1.90, +16.96]$, significant) versus weighted $-0.68$ (95% CI $[-8.09, +6.73]$, not significant). The manuscript&amp;rsquo;s verdict on this cohort (line 723) is &amp;ldquo;Medicaid did not lead to significant changes in adult mortality rates,&amp;rdquo; which agrees with the &lt;em>weighted&lt;/em> result and disagrees with the unweighted one. The &lt;strong>2015 cohort&lt;/strong> agrees in sign across weights but grows under weighting &amp;mdash; $+4.94 \to +10.04$, the latter significant ($[+4.31, +15.77]$). The &lt;strong>2016 cohort&lt;/strong> agrees in sign under both weightings and is the only cohort whose weighted CI excludes zero in the &lt;em>negative&lt;/em> direction ($-12.57$, CI $[-24.68, -0.45]$), but it is based on only 93 counties carrying 2% of the panel&amp;rsquo;s adult population. The &lt;strong>2019 cohort&lt;/strong> has only one post-period of data and unsurprisingly produces a noisy estimate ($+3.48 \pm 8.85$ unweighted, $+3.31 \pm 4.46$ weighted) with wide CIs in both weights.&lt;/p>
&lt;p>The manuscript explicitly cautions (line 725) that &amp;ldquo;the 2015, 2016, and 2019 expansion groups are relatively small &amp;hellip; analyzing these groups separately may be &amp;lsquo;too noisy.'&amp;rdquo; That caveat is doing real work here: the 2016 cohort&amp;rsquo;s negative weighted estimate is the only thing keeping the cohort-aggregated story from being a flat &amp;ldquo;no effect.&amp;rdquo;&lt;/p>
&lt;h3 id="9b-dynamic-event-study-aggregation">9b. Dynamic event-study aggregation&lt;/h3>
&lt;p>Aggregating the same $\text{ATT}(g, t)$ cells across cohorts (rather than across time within a cohort) produces an event-study analog to Section 8, but now pooled across all four expansion cohorts. Event time $e$ ranges from $-10$ (the small 2019 cohort has the longest pre-history) to $+5$.&lt;/p>
&lt;pre>&lt;code class="language-r">es_gxt_unw &amp;lt;- aggte(att_gxt_unw, type = &amp;quot;dynamic&amp;quot;, na.rm = TRUE)
es_gxt_wt &amp;lt;- aggte(att_gxt_wt, type = &amp;quot;dynamic&amp;quot;, na.rm = TRUE)
event_gxt_tbl &amp;lt;- bind_rows(
tibble(e = es_gxt_unw$egt, est = es_gxt_unw$att.egt,
se = es_gxt_unw$se.egt, weighting = &amp;quot;Unweighted&amp;quot;),
tibble(e = es_gxt_wt$egt, est = es_gxt_wt$att.egt,
se = es_gxt_wt$se.egt, weighting = &amp;quot;Population-weighted&amp;quot;)
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">GxT dynamic event study:
e est se weighting lo95 hi95
1 -10 -23.5 10.1 Unweighted -43.4 -3.65
2 -9 -25.1 9.47 Unweighted -43.7 -6.55
3 -8 -12.8 10.6 Unweighted -33.5 7.92
4 -7 -0.341 8.25 Unweighted -16.5 15.8
5 -6 -1.27 7.96 Unweighted -16.9 14.3
6 -5 6.13 3.56 Unweighted -0.836 13.1
7 -4 2.01 3.27 Unweighted -4.39 8.41
8 -3 4.04 3.34 Unweighted -2.51 10.6
9 -2 5.62 3.84 Unweighted -1.91 13.1
10 -1 0 NA Unweighted NA NA
&lt;/code>&lt;/pre>
&lt;p>A condensed table of the GxT event-study ATT(e):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">e&lt;/th>
&lt;th style="text-align:right">est (unw)&lt;/th>
&lt;th style="text-align:right">se (unw)&lt;/th>
&lt;th style="text-align:right">est (wt)&lt;/th>
&lt;th style="text-align:right">se (wt)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">$-10$&lt;/td>
&lt;td style="text-align:right">$-23.54$&lt;/td>
&lt;td style="text-align:right">10.15&lt;/td>
&lt;td style="text-align:right">$-15.35$&lt;/td>
&lt;td style="text-align:right">8.28&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-9$&lt;/td>
&lt;td style="text-align:right">$-25.11$&lt;/td>
&lt;td style="text-align:right">9.47&lt;/td>
&lt;td style="text-align:right">$-25.79$&lt;/td>
&lt;td style="text-align:right">8.19&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-8$&lt;/td>
&lt;td style="text-align:right">$-12.81$&lt;/td>
&lt;td style="text-align:right">10.58&lt;/td>
&lt;td style="text-align:right">$-17.26$&lt;/td>
&lt;td style="text-align:right">8.33&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-7$&lt;/td>
&lt;td style="text-align:right">$-0.34$&lt;/td>
&lt;td style="text-align:right">8.25&lt;/td>
&lt;td style="text-align:right">$-3.60$&lt;/td>
&lt;td style="text-align:right">6.78&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-6$&lt;/td>
&lt;td style="text-align:right">$-1.27$&lt;/td>
&lt;td style="text-align:right">7.96&lt;/td>
&lt;td style="text-align:right">$+2.87$&lt;/td>
&lt;td style="text-align:right">7.34&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-5$&lt;/td>
&lt;td style="text-align:right">$+6.13$&lt;/td>
&lt;td style="text-align:right">3.56&lt;/td>
&lt;td style="text-align:right">$+0.75$&lt;/td>
&lt;td style="text-align:right">2.93&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-4$&lt;/td>
&lt;td style="text-align:right">$+2.01$&lt;/td>
&lt;td style="text-align:right">3.27&lt;/td>
&lt;td style="text-align:right">$+1.01$&lt;/td>
&lt;td style="text-align:right">2.74&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-3$&lt;/td>
&lt;td style="text-align:right">$+4.04$&lt;/td>
&lt;td style="text-align:right">3.34&lt;/td>
&lt;td style="text-align:right">$+2.82$&lt;/td>
&lt;td style="text-align:right">2.52&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-2$&lt;/td>
&lt;td style="text-align:right">$+5.62$&lt;/td>
&lt;td style="text-align:right">3.84&lt;/td>
&lt;td style="text-align:right">$+1.92$&lt;/td>
&lt;td style="text-align:right">3.73&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$-1$&lt;/td>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">&amp;ndash;&lt;/td>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">&amp;ndash;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$0$&lt;/td>
&lt;td style="text-align:right">$-0.45$&lt;/td>
&lt;td style="text-align:right">3.72&lt;/td>
&lt;td style="text-align:right">$-2.65$&lt;/td>
&lt;td style="text-align:right">2.62&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+1$&lt;/td>
&lt;td style="text-align:right">$+3.91$&lt;/td>
&lt;td style="text-align:right">3.97&lt;/td>
&lt;td style="text-align:right">$+0.23$&lt;/td>
&lt;td style="text-align:right">3.89&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+2$&lt;/td>
&lt;td style="text-align:right">$+8.60$&lt;/td>
&lt;td style="text-align:right">3.85&lt;/td>
&lt;td style="text-align:right">$+4.49$&lt;/td>
&lt;td style="text-align:right">3.68&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+3$&lt;/td>
&lt;td style="text-align:right">$+9.20$&lt;/td>
&lt;td style="text-align:right">4.20&lt;/td>
&lt;td style="text-align:right">$-3.74$&lt;/td>
&lt;td style="text-align:right">4.75&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+4$&lt;/td>
&lt;td style="text-align:right">$+9.28$&lt;/td>
&lt;td style="text-align:right">4.89&lt;/td>
&lt;td style="text-align:right">$+0.79$&lt;/td>
&lt;td style="text-align:right">4.70&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">$+5$&lt;/td>
&lt;td style="text-align:right">$+16.96$&lt;/td>
&lt;td style="text-align:right">5.31&lt;/td>
&lt;td style="text-align:right">$+2.48$&lt;/td>
&lt;td style="text-align:right">5.88&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-r">p7 &amp;lt;- ggplot(event_gxt_tbl, aes(x = e, y = est,
color = weighting, fill = weighting)) +
geom_hline(yintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_vline(xintercept = -0.5, color = ORANGE, linetype = &amp;quot;dotted&amp;quot;) +
geom_ribbon(aes(ymin = est - 1.96 * se, ymax = est + 1.96 * se),
alpha = 0.18, color = NA, na.rm = TRUE) +
geom_line(linewidth = 1.1) +
geom_point(size = 2.6) +
scale_color_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE),
aesthetics = c(&amp;quot;color&amp;quot;, &amp;quot;fill&amp;quot;)) +
labs(title = &amp;quot;GxT event study: all expansion cohorts pooled&amp;quot;,
x = &amp;quot;Years since each cohort's expansion (e)&amp;quot;,
y = &amp;quot;ATT(e) (deaths per 100,000)&amp;quot;)
ggsave(&amp;quot;r_did2_07_event_gxt.png&amp;quot;, p7, width = 11, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_07_event_gxt.png" alt="Figure 7: GxT dynamic event study aggregated across all four cohorts, with shaded 95% CIs. Early leads ($e = -10, -9$) are sharply negative under both weightings; from $e = -7$ onward leads settle near zero. Post-treatment, unweighted ATT(e) climbs to $+16.96$ at $e = 5$ while weighted ATT(e) stays within $\pm 5$.">&lt;/p>
&lt;p>The pre-period leads at $e = -10$ and $e = -9$ are dramatically negative under both weightings ($\approx -23$ to $-26$ deaths per 100,000, with 95% CIs excluding zero) and the leads at $e = -8$ are still sizable. These are driven &lt;em>entirely&lt;/em> by the small 2019 cohort, the only cohort with a pre-history long enough to produce data at $e = -10$. From $e = -7$ onward the leads settle near zero with confidence intervals comfortably covering it, restoring approximate parallel trends across the bulk of the comparison window.&lt;/p>
&lt;p>Post-treatment, the unweighted ATT(e) climbs from $-0.45$ at $e = 0$ to $+16.96$ at $e = 5$ &amp;mdash; a stronger upward trajectory than the 2xT-only estimate produced. The weighted ATT(e) stays much flatter, oscillating within $[-3.74, +4.49]$. The dynamic-aggregated ATT averaged over $e \geq 0$ is $+7.917$ unweighted versus $+0.266$ weighted: pooling across all four cohorts shrinks the weighting gap (from $10.1$ in the 2xT to $7.7$ in the GxT) but does not flip the sign on the weighted estimate. The 2014 cohort&amp;rsquo;s $-0.68$ is partially offset by the 2015 and 2016 cohorts under weighting; the cohort-by-cohort variation we saw in Figure 6 is the source of the pooled GxT&amp;rsquo;s small positive sign.&lt;/p>
&lt;h2 id="10-honestdid-sensitivity-to-parallel-trends-violations">10. HonestDiD sensitivity to parallel-trends violations&lt;/h2>
&lt;p>Every previous section has assumed parallel trends. The HonestDiD framework of Rambachan and Roth (2023) asks the question every honest analyst should care about: &lt;em>how badly can parallel trends be wrong before our conclusion overturns?&lt;/em> The &amp;ldquo;relative magnitudes&amp;rdquo; version of the procedure, $\Delta^{RM}$, parameterizes the worst possible post-period violation as a multiple $\bar{M}$ of the worst observed pre-period violation. $\bar{M} = 0$ assumes exact parallel trends; $\bar{M} = 0.5$ allows the post-period deviation to be up to half as large as the worst pre-deviation; $\bar{M} = 1$ allows it to be just as large; $\bar{M} = 2$ allows it to be twice as large.&lt;/p>
&lt;p>The mechanics involve mapping a &lt;code>did::aggte()&lt;/code> object into HonestDiD&amp;rsquo;s expected input format (a coefficient vector $\hat{\beta}$, its variance-covariance matrix $V$, and a linear combination vector $l$ that selects the aggregate of interest). We wrap that translation in an S3 method on &lt;code>AGGTEobj&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-r">honest_did &amp;lt;- function(es, type = &amp;quot;relative_magnitude&amp;quot;,
gridPoints = 100, ...) {
inf &amp;lt;- es$inf.function$dynamic.inf.func.e
n &amp;lt;- nrow(inf)
V &amp;lt;- t(inf) %*% inf / n / n
ref &amp;lt;- -1
idx &amp;lt;- which(es$egt == ref)
V &amp;lt;- V[-idx, -idx]
beta &amp;lt;- es$att.egt[-idx]
egt2 &amp;lt;- es$egt[-idx]
npre &amp;lt;- sum(egt2 &amp;lt; ref)
npost &amp;lt;- length(beta) - npre
l_vec &amp;lt;- matrix(rep(1 / npost, npost))
orig &amp;lt;- HonestDiD::constructOriginalCS(betahat = beta, sigma = V,
numPrePeriods = npre,
numPostPeriods = npost,
l_vec = l_vec)
rob &amp;lt;- HonestDiD::createSensitivityResults_relativeMagnitudes(
betahat = beta, sigma = V,
numPrePeriods = npre, numPostPeriods = npost,
l_vec = l_vec, gridPoints = gridPoints,
Mbarvec = c(0, 0.25, 0.5, 0.75, 1, 1.5, 2), ...)
list(robust = rob, orig = orig)
}
hd_unw &amp;lt;- honest_did(es_gxt_unw, type = &amp;quot;relative_magnitude&amp;quot;)
hd_wt &amp;lt;- honest_did(es_gxt_wt, type = &amp;quot;relative_magnitude&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">HonestDiD relative-magnitudes sensitivity:
lb ub method Delta Mbar weighting
1 2.01 14.1 C-LF DeltaRM 0 Unweighted
2 -16.8 32.9 C-LF DeltaRM 0.25 Unweighted
3 -40.9 57.0 C-LF DeltaRM 0.5 Unweighted
4 -63.7 66.4 C-LF DeltaRM 0.75 Unweighted
5 -66.4 66.4 C-LF DeltaRM 1 Unweighted
8 -6.07 6.07 C-LF DeltaRM 0 Population-weighted
9 -22.2 22.2 C-LF DeltaRM 0.25 Population-weighted
10 -42.5 43.8 C-LF DeltaRM 0.5 Population-weighted
15 1.41 14.4 Original &amp;lt;NA&amp;gt; NA Unweighted
16 -6.27 6.80 Original &amp;lt;NA&amp;gt; NA Population-weighted
&lt;/code>&lt;/pre>
&lt;p>The full bound table across the $\bar{M}$ grid:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>weighting&lt;/th>
&lt;th style="text-align:right">$\bar{M}$&lt;/th>
&lt;th style="text-align:right">lb&lt;/th>
&lt;th style="text-align:right">ub&lt;/th>
&lt;th>method&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">original&lt;/td>
&lt;td style="text-align:right">$+1.41$&lt;/td>
&lt;td style="text-align:right">$+14.43$&lt;/td>
&lt;td>Original CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">0.00&lt;/td>
&lt;td style="text-align:right">$+2.01$&lt;/td>
&lt;td style="text-align:right">$+14.09$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">0.25&lt;/td>
&lt;td style="text-align:right">$-16.77$&lt;/td>
&lt;td style="text-align:right">$+32.87$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">0.50&lt;/td>
&lt;td style="text-align:right">$-40.92$&lt;/td>
&lt;td style="text-align:right">$+57.02$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">0.75&lt;/td>
&lt;td style="text-align:right">$-63.73$&lt;/td>
&lt;td style="text-align:right">$+66.42$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">1.00&lt;/td>
&lt;td style="text-align:right">$-66.42$&lt;/td>
&lt;td style="text-align:right">$+66.42$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">1.50&lt;/td>
&lt;td style="text-align:right">$-66.42$&lt;/td>
&lt;td style="text-align:right">$+66.42$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Unweighted&lt;/td>
&lt;td style="text-align:right">2.00&lt;/td>
&lt;td style="text-align:right">$-66.42$&lt;/td>
&lt;td style="text-align:right">$+66.42$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">original&lt;/td>
&lt;td style="text-align:right">$-6.27$&lt;/td>
&lt;td style="text-align:right">$+6.80$&lt;/td>
&lt;td>Original CI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">0.00&lt;/td>
&lt;td style="text-align:right">$-6.07$&lt;/td>
&lt;td style="text-align:right">$+6.07$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">0.25&lt;/td>
&lt;td style="text-align:right">$-22.24$&lt;/td>
&lt;td style="text-align:right">$+22.24$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">0.50&lt;/td>
&lt;td style="text-align:right">$-42.46$&lt;/td>
&lt;td style="text-align:right">$+43.81$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">0.75&lt;/td>
&lt;td style="text-align:right">$-64.02$&lt;/td>
&lt;td style="text-align:right">$+64.02$&lt;/td>
&lt;td>$\Delta^{RM}$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">1.00&lt;/td>
&lt;td style="text-align:right">$-66.72$&lt;/td>
&lt;td style="text-align:right">$+66.72$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">1.50&lt;/td>
&lt;td style="text-align:right">$-66.72$&lt;/td>
&lt;td style="text-align:right">$+66.72$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population-weighted&lt;/td>
&lt;td style="text-align:right">2.00&lt;/td>
&lt;td style="text-align:right">$-66.72$&lt;/td>
&lt;td style="text-align:right">$+66.72$&lt;/td>
&lt;td>saturated&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-r">p8 &amp;lt;- ggplot(hd_tbl %&amp;gt;% filter(!is.na(Mbar)),
aes(x = Mbar, y = (lb + ub) / 2)) +
geom_hline(yintercept = 0, color = TEXT_LIGHT, linetype = &amp;quot;dashed&amp;quot;) +
geom_ribbon(aes(ymin = lb, ymax = ub, fill = weighting), alpha = 0.4) +
geom_line(aes(color = weighting), linewidth = 1.1) +
scale_color_manual(values = c(&amp;quot;Unweighted&amp;quot; = BLUE,
&amp;quot;Population-weighted&amp;quot; = ORANGE),
aesthetics = c(&amp;quot;color&amp;quot;, &amp;quot;fill&amp;quot;)) +
facet_wrap(~ weighting) +
labs(title = &amp;quot;HonestDiD: how robust is the post-period ATT to pre-trend violations?&amp;quot;,
x = expression(bar(M)),
y = &amp;quot;ATT bound (deaths per 100,000)&amp;quot;,
caption = &amp;quot;The saturation near +/- 66 is the HonestDiD grid limit, not a feature of the data.&amp;quot;)
ggsave(&amp;quot;r_did2_08_honestdid.png&amp;quot;, p8, width = 11, height = 5.5,
dpi = 300, bg = BG_DARK)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_did2_08_honestdid.png" alt="Figure 8: HonestDiD bounds across $\bar{M}$, faceted by weighting. At $\bar{M} = 0$ the unweighted bound is entirely positive ($[+2.01, +14.09]$); the weighted bound straddles zero ($[-6.07, +6.07]$). Both bounds cross zero by $\bar{M} = 0.25$; both saturate at the package grid limits ($\pm 66$) by $\bar{M} = 1$.">&lt;/p>
&lt;p>At $\bar{M} = 0$ &amp;mdash; exact parallel trends &amp;mdash; the unweighted bound on the dynamic ATT is $[+2.01, +14.09]$, &lt;em>entirely positive&lt;/em>, suggesting Medicaid expansion &lt;em>raised&lt;/em> mortality. (Recall the unweighted GxT dynamic aggregate was $+7.92$.) The weighted bound at $\bar{M} = 0$ is $[-6.07, +6.07]$, straddling zero with no clear sign. By $\bar{M} = 0.25$ both bounds already cross zero; the unweighted bound is $[-16.77, +32.87]$ and the weighted is $[-22.24, +22.24]$. By $\bar{M} = 0.5$ both bounds span $[-40, +57]$, and by $\bar{M} = 1$ both saturate at the HonestDiD package&amp;rsquo;s default grid range ($\pm 66.4$ unweighted, $\pm 66.7$ weighted). The saturation is a feature of the grid, not the data, and is annotated in the figure caption.&lt;/p>
&lt;p>The breakdown value $\bar{M}^*$ &amp;mdash; the smallest violation that overturns the conclusion &amp;mdash; is informative. For the unweighted result, the (positive-sign) conclusion breaks at $\bar{M}$ between $0$ and $0.25$ (somewhere in the first quarter-multiple of the worst pre-trend). For the weighted result, there is no sign conclusion at $\bar{M} = 0$ to break in the first place; the bound already includes zero. The manuscript&amp;rsquo;s verdict at line 556 applies symmetrically here: &amp;ldquo;Rambachan-Roth&amp;rsquo;s method underscores how little information the pre-trend estimates convey &amp;hellip; the identified set spans implausibly large effects in both directions.&amp;rdquo; &lt;strong>Even the weighted-only conclusion of a small negative ATT is fragile to modest parallel-trends violations&lt;/strong> ($\bar{M} \approx 0.25$ is enough to lose any sign), which reinforces the manuscript&amp;rsquo;s caution that this empirical case should be read as pedagogical rather than as a definitive estimate of Medicaid&amp;rsquo;s mortality effect (manuscript line 134).&lt;/p>
&lt;h2 id="11-headline-summary">11. Headline summary&lt;/h2>
&lt;p>A compact comparison of the five stages where we computed a single overall ATT, twice each:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>stage&lt;/th>
&lt;th style="text-align:right">unweighted&lt;/th>
&lt;th style="text-align:right">weighted&lt;/th>
&lt;th style="text-align:right">weighting gap&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2x2 cell-means ATT(2014)&lt;/td>
&lt;td style="text-align:right">$+0.122$&lt;/td>
&lt;td style="text-align:right">$-2.563$&lt;/td>
&lt;td style="text-align:right">$2.685$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2x2 TWFE long-difference&lt;/td>
&lt;td style="text-align:right">$+0.122$&lt;/td>
&lt;td style="text-align:right">$-2.563$&lt;/td>
&lt;td style="text-align:right">$2.685$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2x2 DRDID (Callaway-Sant&amp;rsquo;Anna)&lt;/td>
&lt;td style="text-align:right">$-1.226$&lt;/td>
&lt;td style="text-align:right">$-3.756$&lt;/td>
&lt;td style="text-align:right">$2.530$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2xT dynamic ATT (avg $e \geq 0$)&lt;/td>
&lt;td style="text-align:right">$+9.428$&lt;/td>
&lt;td style="text-align:right">$-0.684$&lt;/td>
&lt;td style="text-align:right">$10.112$&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GxT dynamic ATT (avg $e \geq 0$)&lt;/td>
&lt;td style="text-align:right">$+7.917$&lt;/td>
&lt;td style="text-align:right">$+0.266$&lt;/td>
&lt;td style="text-align:right">$7.651$&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &amp;ldquo;weighting gap&amp;rdquo; column makes the central pedagogical point explicit: across all five estimation stages, switching from equal weights to population weights moves the point estimate by between $2.5$ and $10.1$ deaths per 100,000. The gap is &lt;em>largest&lt;/em> when staggered cohort heterogeneity is in play (2xT and GxT, where the within-cohort treatment effects can disagree across cohorts of very different sizes) and &lt;em>smallest&lt;/em> when the four-cell 2x2 design forces a single ATT(2014) (where there is only one cohort and one comparison). The methodological choice between estimators within each row is much smaller than the choice between rows of the same color.&lt;/p>
&lt;h2 id="12-discussion-what-did-medicaid-expansion-do-to-mortality">12. Discussion: what did Medicaid expansion do to mortality?&lt;/h2>
&lt;p>Return to the question that opened the post: did the ACA Medicaid expansion reduce adult mortality? The cleanest empirical answer this analysis can give is &amp;ldquo;the data are not powerful enough to settle the question, and the answer depends on which estimand you have in mind.&amp;rdquo; For the &lt;strong>typical treated adult&lt;/strong> (population-weighted ATT), the GxT dynamic aggregate is $+0.27$ deaths per 100,000 with the 2014-cohort component at $-0.68$ and the cell-means 2x2 at $-2.56$; the point estimates range from a small negative to a small positive, and no 95% confidence interval at any stage excludes zero by a comfortable margin. For the &lt;strong>typical treated county&lt;/strong> (unweighted ATT), the GxT dynamic aggregate is $+7.92$ deaths per 100,000, with the 2xT post-treatment trajectory reaching $+16.96$ by year $+5$ with a CI that does exclude zero ($[+6.83, +27.09]$); HonestDiD shows that conclusion holds at $\bar{M} = 0$ but collapses by $\bar{M} = 0.25$.&lt;/p>
&lt;p>Why did weighting change the answer? The mechanical reason is the asymmetry documented in Section 3: the never-expansion cohort is 47% of counties but only 38% of adults, while the 2014 expansion cohort is 38% of counties but 50% of adults. Equal weighting overweights small, rural never-expansion counties (e.g., counties in Texas and Florida that are demographically different from the typical American adult) and overweights small, rural 2014-expansion counties. Population weighting shifts the comparison toward larger, more urban counties on both sides. When treatment effects are heterogeneous &amp;mdash; as they almost certainly are, since &amp;ldquo;Medicaid expansion&amp;rdquo; interacts with each state&amp;rsquo;s existing healthcare infrastructure and pre-expansion eligibility rules &amp;mdash; those two weighting choices produce different averages of different functions of the same data. They are answers to &lt;em>different causal questions&lt;/em>, not better and worse answers to the same question. The manuscript states this directly at lines 169&amp;ndash;170: &amp;ldquo;If interest lies in the average treatment effect of Medicaid on mortality in the average treated &lt;em>county&lt;/em> &amp;hellip; the relevant target parameter is an equally weighted average &amp;hellip; If, on the other hand, the parameter of interest is the average treatment effect of Medicaid on mortality in the county in which the average treated &lt;em>adult&lt;/em> lives, then population weights are appropriate. When treatment effect heterogeneity is related to the weights, weighted and unweighted target parameters differ meaningfully.&amp;rdquo;&lt;/p>
&lt;p>What should a policymaker take away? In a setting like Medicaid expansion, where the policy choice is &amp;ldquo;should we cover &lt;em>adults&lt;/em>?&amp;rdquo;, the population-weighted estimand is the more decision-relevant target. It answers &amp;ldquo;what was the expected effect on the typical newly covered adult?&amp;rdquo; &amp;mdash; and that estimate is small and statistically indistinguishable from zero (weighted DRDID $= -3.76 \pm 3.29$, weighted GxT dynamic $= +0.27$). The unweighted estimate, by contrast, answers &amp;ldquo;what was the average effect on the typical treated county-as-a-unit?&amp;rdquo;, which is a useful object for understanding heterogeneity but not the primary policy parameter when the policy is denominated in &lt;em>people&lt;/em>. A federal cost-benefit assessment would weight by people. A study of which county types saw the largest local effects would not weight at all. Both are legitimate; the report-writer should be explicit about which one is on offer.&lt;/p>
&lt;h2 id="13-summary-and-next-steps">13. Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Takeaways&lt;/strong> :&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The 2x2 sign reversal is real and reproduces the manuscript&amp;rsquo;s flagship example.&lt;/strong> Unweighted ATT(2014) $= +0.122$ deaths per 100,000; weighted ATT(2014) $= -2.563$ (manuscript line 215). The pre-period gap is essentially identical in both weightings ($-54.77$ vs $-53.68$), confirming the reversal is driven entirely by which counties dominate the post-period averages &amp;mdash; a feature of weighted estimands, not a bug.&lt;/li>
&lt;li>&lt;strong>Covariate adjustment closes part of the gap but does not eliminate it.&lt;/strong> DRDID under each weighting is $-1.226$ unweighted and $-3.756$ weighted; the within-weighting estimator spread (OR, IPW, DRDID) is at most $0.8$ deaths per 100,000, while the across-weighting gap remains $2.5$ deaths per 100,000. Methodology and target parameter are orthogonal axes of choice, and the second dominates the first.&lt;/li>
&lt;li>&lt;strong>Power is the binding constraint, not method.&lt;/strong> None of the six 2x2 covariate-adjusted 95% confidence intervals excludes zero. The 2xT unweighted post-period at $e = 5$ does ($[+6.83, +27.09]$), but in the opposite-of-expected direction. The weighted estimates are smaller in magnitude than the unweighted ones and never reach statistical significance.&lt;/li>
&lt;li>&lt;strong>HonestDiD breakdown values are uncomfortably low.&lt;/strong> The unweighted positive-sign conclusion at $\bar{M} = 0$ collapses by $\bar{M} = 0.25$; the weighted bound straddles zero already at $\bar{M} = 0$. Both bounds saturate at the HonestDiD package&amp;rsquo;s grid limit ($\pm 66.7$) by $\bar{M} = 1$. We learn very little from the pre-trends in this application.&lt;/li>
&lt;li>&lt;strong>Staggered cohort heterogeneity matters more than the 2x2 lets on.&lt;/strong> The 2014 cohort flips sign with weighting ($+9.43 \to -0.68$); the 2016 cohort produces a large negative effect that is significant under weighting but is based on only 93 counties; the 2015 cohort grows from $+4.94$ to $+10.04$ under weighting. The GxT dynamic aggregate ($+7.92$ unweighted, $+0.27$ weighted) hides this cohort-level variation by averaging across it.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Limitations.&lt;/strong> The bootstrap iteration count was held at $\text{BITERS} = 2{,}000$ for tutorial speed; the manuscript&amp;rsquo;s reference scripts use $25{,}000$, which would tighten the third significant figure of every confidence interval. The mortality outcome is the CDC crude death rate, not age-adjusted; an age-adjusted rate would address compositional differences across cohorts more cleanly but requires restricted data. The manuscript itself flags the case as pedagogical (line 134): &amp;ldquo;The results are pedagogical in spirit and do not represent the best possible estimates of Medicaid&amp;rsquo;s effect on adult mortality.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Next steps.&lt;/strong> The natural extensions are (1) synthetic-control estimates on the 2016 and 2019 cohorts to see whether their large weighted negatives survive a different counterfactual construction; (2) placebo tests on the 2007&amp;ndash;2013 pre-period (with a sham 2010 expansion date) to check whether the post-2014 ATT estimates exceed what one would see by chance; and (3) an age-adjusted version of the same pipeline using CDC&amp;rsquo;s standard-population weighting &amp;mdash; which is &lt;em>another&lt;/em> weighting choice that changes the estimand and would interact with the population weights examined here.&lt;/p>
&lt;h2 id="14-exercises">14. Exercises&lt;/h2>
&lt;p>The script reproduces faithfully and the post&amp;rsquo;s headline numbers carry through to four decimals; the data are publicly available. Three self-study challenges that build directly on the materials:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Switch the control group.&lt;/strong> The Callaway-Sant&amp;rsquo;Anna &lt;code>att_gt()&lt;/code> calls use &lt;code>control_group = &amp;quot;nevertreated&amp;quot;&lt;/code>. Re-run the GxT design with &lt;code>control_group = &amp;quot;notyettreated&amp;quot;&lt;/code> (which uses not-yet-treated cohorts as comparison units when never-treated counties run out). How does the dynamic event-study aggregate change? Where in the cohort structure does the comparison-group choice bite hardest?&lt;/li>
&lt;li>&lt;strong>Substitute a different outcome.&lt;/strong> The data include other mortality categories (cardiovascular, drug-related, etc., depending on what the CDC file contains in your version). Replace &lt;code>crude_rate_20_64&lt;/code> with a more narrowly defined cause of death and rerun the GxT design. Does the sign reversal still appear in the 2x2? Are the breakdown $\bar{M}^*$ values larger or smaller?&lt;/li>
&lt;li>&lt;strong>Try the smoothness sensitivity instead.&lt;/strong> The &lt;code>honest_did()&lt;/code> helper accepts &lt;code>type = &amp;quot;smoothness&amp;quot;&lt;/code>, which parameterizes parallel-trends violations as smooth functions of $t$ rather than as bounded multiples of the worst pre-period violation. Compare the bound widths at small $\Delta$ values for both weightings. Which restriction is the data more informative about?&lt;/li>
&lt;/ol>
&lt;h2 id="15-references">15. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://arxiv.org/abs/2503.13323" target="_blank" rel="noopener">Baker, A., Callaway, B., Cunningham, S., Goodman-Bacon, A., and Sant&amp;rsquo;Anna, P. H. (2025). Difference-in-Differences Designs: A Practitioner&amp;rsquo;s Guide. &lt;em>arXiv preprint&lt;/em> arXiv:2503.13323.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1016/j.jeconom.2020.12.001" target="_blank" rel="noopener">Callaway, B. and Sant&amp;rsquo;Anna, P. H. C. (2021). Difference-in-Differences with multiple time periods. &lt;em>Journal of Econometrics&lt;/em>, 225(2), 200&amp;ndash;230.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/restud/rdad018" target="_blank" rel="noopener">Rambachan, A. and Roth, J. (2023). A More Credible Approach to Parallel Trends. &lt;em>Review of Economic Studies&lt;/em>, 90(5), 2555&amp;ndash;2591.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1016/j.jeconom.2020.06.003" target="_blank" rel="noopener">Sant&amp;rsquo;Anna, P. H. C. and Zhao, J. (2020). Doubly robust difference-in-differences estimators. &lt;em>Journal of Econometrics&lt;/em>, 219(1), 101&amp;ndash;122.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://bcallaway11.github.io/did/" target="_blank" rel="noopener">&lt;code>did&lt;/code> &amp;mdash; Callaway-Sant&amp;rsquo;Anna group-time ATT estimator (R package documentation)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://psantanna.com/DRDID/" target="_blank" rel="noopener">&lt;code>DRDID&lt;/code> &amp;mdash; Doubly robust DiD (R package documentation)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/asheshrambachan/HonestDiD" target="_blank" rel="noopener">&lt;code>HonestDiD&lt;/code> &amp;mdash; Rambachan-Roth sensitivity analysis (R package documentation)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://lrberge.github.io/fixest/" target="_blank" rel="noopener">&lt;code>fixest&lt;/code> &amp;mdash; Fast fixed-effects regression (R package documentation)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1017/CBO9781139025751" target="_blank" rel="noopener">Imbens, G. W. and Rubin, D. B. (2015). &lt;em>Causal Inference for Statistics, Social, and Biomedical Sciences&lt;/em>. Cambridge University Press.&lt;/a> &amp;mdash; source of the $\pm 0.25$ normalized-difference threshold.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;style>
.podcast-overlay {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
animation: podSlideUp 0.35s ease-out;
}
@keyframes podSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.podcast-overlay.pod-closing {
animation: podSlideDown 0.3s ease-in forwards;
}
@keyframes podSlideDown {
from { transform: translateY(0); }
to { transform: translateY(100%); }
}
.podcast-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 18px 24px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 -4px 32px rgba(0,0,0,0.5);
border-top: 1px solid rgba(106,155,204,0.2);
}
.podcast-inner {
max-width: 800px;
margin: 0 auto;
}
.podcast-top-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.podcast-icon {
width: 42px;
height: 42px;
background: linear-gradient(135deg, #d97757, #e8956a);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.podcast-icon svg {
width: 22px;
height: 22px;
fill: #fff;
}
.podcast-title-block {
flex: 1;
min-width: 0;
}
.podcast-title-block h4 {
margin: 0 0 1px 0;
color: #f0ece2;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.podcast-title-block span {
color: #8b9dc3;
font-size: 11px;
}
.podcast-close-btn {
background: none;
border: none;
cursor: pointer;
padding: 6px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
flex-shrink: 0;
}
.podcast-close-btn:hover {
background: rgba(255,255,255,0.1);
}
.podcast-close-btn svg {
width: 20px;
height: 20px;
fill: #8b9dc3;
}
.podcast-progress-wrap {
margin-bottom: 12px;
}
.podcast-time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #8b9dc3;
margin-bottom: 5px;
font-variant-numeric: tabular-nums;
}
.podcast-bar-bg {
width: 100%;
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: height 0.15s;
}
.podcast-bar-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(106,155,204,0.25);
border-radius: 3px;
transition: width 0.3s;
}
.podcast-bar-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #6a9bcc, #00d4c8);
border-radius: 3px;
transition: width 0.1s linear;
}
.podcast-bar-bg:hover {
height: 10px;
margin-top: -2px;
}
.podcast-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.podcast-transport {
display: flex;
align-items: center;
gap: 8px;
}
.podcast-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.podcast-btn svg {
fill: #c8d0e0;
transition: fill 0.2s;
}
.podcast-btn:hover svg {
fill: #f0ece2;
}
.podcast-btn-skip {
position: relative;
}
.podcast-btn-skip span {
position: absolute;
font-size: 7px;
font-weight: 700;
color: #c8d0e0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
margin-top: 1px;
}
.podcast-btn-play {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #d97757, #e8956a);
border-radius: 50%;
box-shadow: 0 3px 12px rgba(217,119,87,0.4);
transition: all 0.2s;
}
.podcast-btn-play:hover {
transform: scale(1.08);
box-shadow: 0 5px 20px rgba(217,119,87,0.5);
}
.podcast-btn-play svg {
fill: #fff;
width: 22px;
height: 22px;
}
.podcast-extras {
display: flex;
align-items: center;
gap: 10px;
}
.podcast-volume-wrap {
display: flex;
align-items: center;
gap: 5px;
}
.podcast-volume-wrap svg {
fill: #8b9dc3;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.podcast-volume-wrap svg:hover {
fill: #c8d0e0;
}
.podcast-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 60px;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.podcast-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #6a9bcc;
border-radius: 50%;
cursor: pointer;
}
.podcast-speed-btn {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
color: #c8d0e0;
font-size: 11px;
font-weight: 600;
padding: 3px 9px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
min-width: 40px;
text-align: center;
}
.podcast-speed-btn:hover {
background: rgba(106,155,204,0.2);
border-color: #6a9bcc;
color: #f0ece2;
}
.podcast-download-btn {
background: none;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 4px 10px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
color: #8b9dc3;
font-size: 11px;
font-family: inherit;
text-decoration: none;
transition: all 0.2s;
}
.podcast-download-btn:hover {
border-color: #6a9bcc;
color: #f0ece2;
background: rgba(106,155,204,0.1);
}
.podcast-download-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
@media (max-width: 600px) {
.podcast-container { padding: 14px 16px 16px; }
.podcast-volume-wrap { display: none; }
.podcast-title-block h4 { font-size: 13px; }
.podcast-extras { gap: 8px; }
}
&lt;/style>
&lt;div class="podcast-overlay" id="podOverlay">
&lt;div class="podcast-container">
&lt;div class="podcast-inner">
&lt;audio id="podAudio" preload="none" src="https://files.catbox.moe/o9v6if.m4a">&lt;/audio>
&lt;div class="podcast-top-row">
&lt;div class="podcast-icon">
&lt;svg viewBox="0 0 24 24">&lt;path d="M12 1a5 5 0 0 0-5 5v4a5 5 0 0 0 10 0V6a5 5 0 0 0-5-5zm0 16a7 7 0 0 1-7-7H3a9 9 0 0 0 8 8.94V22h2v-3.06A9 9 0 0 0 21 10h-2a7 7 0 0 1-7 7z"/>&lt;/svg>
&lt;/div>
&lt;div class="podcast-title-block">
&lt;h4>AI Podcast: DiD for Regional Data&lt;/h4>
&lt;span id="podDurationLabel">Click play to load&lt;/span>
&lt;/div>
&lt;button class="podcast-close-btn" onclick="podClose()" title="Close player">
&lt;svg viewBox="0 0 24 24">&lt;path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>&lt;/svg>
&lt;/button>
&lt;/div>
&lt;div class="podcast-progress-wrap">
&lt;div class="podcast-time-row">
&lt;span id="podCurrent">0:00&lt;/span>
&lt;span id="podDuration">0:00&lt;/span>
&lt;/div>
&lt;div class="podcast-bar-bg" id="podBarBg" onclick="podSeek(event)">
&lt;div class="podcast-bar-buffered" id="podBuffered">&lt;/div>
&lt;div class="podcast-bar-progress" id="podProgress">&lt;/div>
&lt;/div>
&lt;/div>
&lt;div class="podcast-controls-row">
&lt;div class="podcast-transport">
&lt;button class="podcast-btn podcast-btn-skip" onclick="podSkip(-15)" title="Back 15s">
&lt;svg width="26" height="26" viewBox="0 0 24 24">&lt;path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>&lt;/svg>
&lt;span>15&lt;/span>
&lt;/button>
&lt;button class="podcast-btn podcast-btn-play" id="podPlayBtn" onclick="podToggle()" title="Play">
&lt;svg id="podIconPlay" viewBox="0 0 24 24">&lt;path d="M8 5v14l11-7z"/>&lt;/svg>
&lt;svg id="podIconPause" viewBox="0 0 24 24" style="display:none">&lt;path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>&lt;/svg>
&lt;/button>
&lt;button class="podcast-btn podcast-btn-skip" onclick="podSkip(15)" title="Forward 15s">
&lt;svg width="26" height="26" viewBox="0 0 24 24">&lt;path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>&lt;/svg>
&lt;span>15&lt;/span>
&lt;/button>
&lt;/div>
&lt;div class="podcast-extras">
&lt;div class="podcast-volume-wrap">
&lt;svg id="podVolIcon" onclick="podMute()" viewBox="0 0 24 24">&lt;path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.47 4.47 0 0 0 2.5-3.5zM14 3.23v2.06a6.51 6.51 0 0 1 0 13.42v2.06A8.51 8.51 0 0 0 14 3.23z"/>&lt;/svg>
&lt;input type="range" class="podcast-volume-slider" id="podVolume" min="0" max="1" step="0.05" value="0.8">
&lt;/div>
&lt;button class="podcast-speed-btn" id="podSpeedBtn" onclick="podCycleSpeed()" title="Playback speed">1x&lt;/button>
&lt;a class="podcast-download-btn" href="https://files.catbox.moe/o9v6if.m4a" target="_blank" rel="noopener" title="Stream">
&lt;svg viewBox="0 0 24 24">&lt;path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>&lt;/svg>
&lt;/a>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(function(){
var overlay = document.getElementById('podOverlay');
var a = document.getElementById('podAudio');
var speeds = [0.75, 1, 1.25, 1.5, 2];
var si = 1;
var opened = false;
function fmt(s){
if(isNaN(s)) return '0:00';
var m=Math.floor(s/60), sec=Math.floor(s%60);
return m+':'+(sec&lt;10?'0':'')+sec;
}
document.addEventListener('click', function(e){
var link = e.target.closest('a.btn-page-header');
if(!link) return;
var text = link.textContent.trim();
if(text.indexOf('AI Podcast') === -1) return;
e.preventDefault();
e.stopPropagation();
overlay.style.display = 'block';
overlay.classList.remove('pod-closing');
if(!opened){
a.preload = 'metadata';
a.load();
opened = true;
}
});
a.volume = 0.8;
a.addEventListener('loadedmetadata', function(){
document.getElementById('podDuration').textContent = fmt(a.duration);
document.getElementById('podDurationLabel').textContent = fmt(a.duration) + ' minutes';
});
a.addEventListener('timeupdate', function(){
document.getElementById('podCurrent').textContent = fmt(a.currentTime);
var pct = a.duration ? (a.currentTime/a.duration)*100 : 0;
document.getElementById('podProgress').style.width = pct+'%';
});
a.addEventListener('progress', function(){
if(a.buffered.length>0){
var pct = (a.buffered.end(a.buffered.length-1)/a.duration)*100;
document.getElementById('podBuffered').style.width = pct+'%';
}
});
a.addEventListener('ended', function(){
document.getElementById('podIconPlay').style.display='';
document.getElementById('podIconPause').style.display='none';
});
window.podToggle = function(){
if(a.paused){a.play();document.getElementById('podIconPlay').style.display='none';document.getElementById('podIconPause').style.display='';}
else{a.pause();document.getElementById('podIconPlay').style.display='';document.getElementById('podIconPause').style.display='none';}
};
window.podSkip = function(s){a.currentTime = Math.max(0,Math.min(a.duration||0,a.currentTime+s));};
window.podSeek = function(e){
var rect = document.getElementById('podBarBg').getBoundingClientRect();
var pct = (e.clientX - rect.left)/rect.width;
a.currentTime = pct * (a.duration||0);
};
window.podMute = function(){
a.muted = !a.muted;
document.getElementById('podVolume').value = a.muted ? 0 : a.volume;
};
window.podCycleSpeed = function(){
si = (si+1) % speeds.length;
a.playbackRate = speeds[si];
document.getElementById('podSpeedBtn').textContent = speeds[si]+'x';
};
window.podClose = function(){
overlay.classList.add('pod-closing');
setTimeout(function(){ overlay.style.display='none'; }, 300);
a.pause();
document.getElementById('podIconPlay').style.display='';
document.getElementById('podIconPause').style.display='none';
};
document.getElementById('podVolume').addEventListener('input', function(){
a.volume = this.value;
a.muted = false;
});
if(window.location.hash === '#podcast-player'){
overlay.style.display = 'block';
a.preload = 'metadata';
a.load();
opened = true;
}
})();
&lt;/script></description></item></channel></rss>