<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>transport infrastructure | Carlos Mendez</title><link>https://carlos-mendez.org/tag/transport-infrastructure/</link><atom:link href="https://carlos-mendez.org/tag/transport-infrastructure/index.xml" rel="self" type="application/rss+xml"/><description>transport infrastructure</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>© 2018–2026 Carlos Mendez. All rights reserved.</copyright><lastBuildDate>Wed, 05 Aug 2026 00:00:00 +0000</lastBuildDate><image><url>https://carlos-mendez.org/media/icon_huedfae549300b4ca5d201a9bd09a3ecd5_79625_512x512_fill_lanczos_center_3.png</url><title>transport infrastructure</title><link>https://carlos-mendez.org/tag/transport-infrastructure/</link></image><item><title>Evaluating the Impact of Infrastructure: A Beginner's Guide to Difference-in-Differences with the Jamuna Bridge</title><link>https://carlos-mendez.org/post/python_bridge_impact/</link><pubDate>Wed, 05 Aug 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_bridge_impact/</guid><description>&lt;div style="background:#0e1545; border-radius:12px; padding:8px;">
&lt;iframe style="border-radius:8px" src="https://open.spotify.com/embed/episode/4U2j7kAwgmzWuugvm2cbav?utm_source=generator&amp;theme=0" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy">&lt;/iframe>
&lt;/div>
&lt;h2 id="abstract">Abstract&lt;/h2>
&lt;p>Transport megaprojects absorb a large share of development finance, yet economists still disagree about whether connecting a poor region to a rich one revives the periphery or hollows it out. This tutorial works that question through one case, replicating in Python the evaluation of the Jamuna Bridge — a 4.8-kilometre crossing that opened in June 1998, cost about US\$985 million, and linked 26 million isolated Bangladeshis to Dhaka while cutting freight costs roughly in half. The analysis compares 123 treated upazilas in the Jamuna hinterland with 125 comparison upazilas in the Padma hinterland, a symmetric region whose own bridge was not begun until 2015. Four panels carry the evidence: satellite nighttime lights for 359 upazilas over seven three-year periods from 1992 to 2013, three population censuses, district rice yields back to 1988, and DHS and HIES village records. Two-way fixed-effects difference-in-differences is estimated with the &lt;code>diff-diff&lt;/code> library and cross-checked in &lt;code>pyfixest&lt;/code>, and the paper&amp;rsquo;s two doubly robust estimators are rebuilt by hand in NumPy. The bridge raised nighttime lights 10.9 percent, rice yields 6.3 percent and the services employment share 2.3 percentage points, while the manufacturing share fell 1.0 percentage point; population density fell 2.5 percent in the short run and rose 5.9 percent in the long run. Because density rose rather than fell, the loss of manufacturing here is the signature of comparative advantage rather than of backwash — a region can lose its factories and still be better off.&lt;/p>
&lt;p>&lt;a href="https://colab.research.google.com/github/cmg777/starter-academic-v501/blob/master/content/post/python_bridge_impact/notebook.ipynb" target="_blank" rel="noopener">&lt;img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab">&lt;/a>&lt;/p>
&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;h3 id="11-a-bridge-two-rivers-and-three-predictions">1.1 A bridge, two rivers, and three predictions&lt;/h3>
&lt;p>Bangladesh is a delta sliced into three by two of the largest rivers on earth. The Jamuna — the local name for the Brahmaputra, ninth in the world by discharge — cut the poor northwest off from Dhaka. The Ganges, locally the Padma, cut off the south. Before 1998, crossing the Jamuna meant a ferry that took more than three hours on a good day and, during Eid, could mean waiting thirty-six. A truck from Bogra to Dhaka took twenty hours.&lt;/p>
&lt;p>Then the bridge opened, and that truck took six.&lt;/p>
&lt;p>The question is what a shock like that does to the region on the far side. There are three answers in the literature, and they are not variations on a theme — they point in opposite directions.&lt;/p>
&lt;p>The &lt;strong>big push&lt;/strong> view is the one that gets megaprojects funded. Integrating a segmented market raises competition and allocational efficiency, and the lagging region revives.&lt;/p>
&lt;p>The &lt;strong>backwash&lt;/strong> view, which runs from Myrdal in 1957 through Krugman in 1991, says the opposite. With increasing returns and mobile factors, lowering trade costs lets the core capture the gains. Manufacturing concentrates where the market already is, and the newly connected periphery is hollowed out — deindustrialised, drained of people, worse off than before the road arrived.&lt;/p>
&lt;p>The third view is the one this paper contributes, and it is the reason the case is worth studying carefully. Suppose the hinterland has a &lt;strong>comparative advantage&lt;/strong> in agriculture. Then even with no increasing returns and no spatial sorting at all, a fall in trade costs pulls labour out of manufacturing and into the things the region is relatively good at. Manufacturing declines — but as specialisation, not as decay.&lt;/p>
&lt;p>Here is the trap. Backwash and comparative advantage make the &lt;em>same&lt;/em> prediction about factories. A study that measured only manufacturing would see the share fall, write &amp;ldquo;deindustrialisation&amp;rdquo;, and conclude backwash. The two stories only separate on a second outcome, and getting that second outcome right is the whole methodological lesson of this tutorial.&lt;/p>
&lt;h3 id="12-learning-objectives">1.2 Learning objectives&lt;/h3>
&lt;p>By the end of this post you will be able to:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Compute&lt;/strong> a difference-in-differences estimate by hand from four group means, and explain what each subtraction removes.&lt;/li>
&lt;li>&lt;strong>State&lt;/strong> the estimand you are targeting — the ATT — and say why it is not the ATE.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> two-way fixed-effects DiD with the &lt;code>diff-diff&lt;/code> library, and cross-check it in &lt;code>pyfixest&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Build&lt;/strong> an event study and read its pre-treatment coefficients as a test rather than a result.&lt;/li>
&lt;li>&lt;strong>Construct&lt;/strong> two doubly robust estimators from scratch: propensity-odds weights and Kline&amp;rsquo;s Oaxaca-Blinder reweighting.&lt;/li>
&lt;li>&lt;strong>Assess&lt;/strong> how badly parallel trends would have to fail before your conclusion changes, using HonestDiD bounds and randomisation inference.&lt;/li>
&lt;li>&lt;strong>Audit&lt;/strong> a published paper against its own replication package, and read a regression footer for the tell that something has gone wrong.&lt;/li>
&lt;/ol>
&lt;h3 id="13-the-road-ahead">1.3 The road ahead&lt;/h3>
&lt;p>The tutorial runs in eight stages. Each is a section below, and each either adds an assumption or tests one. Nighttime lights are the running example — they are the richest panel, with seven periods and 247 upazilas — and once the machinery is built, the other three outcomes go through it quickly.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
A[&amp;quot;&amp;lt;b&amp;gt;Four data families&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Night lights, census,&amp;lt;br/&amp;gt;rice yields, DHS and HIES&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;The design&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Jamuna hinterland treated&amp;lt;br/&amp;gt;Padma hinterland comparison&amp;lt;br/&amp;gt;Dhaka core excluded&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Baseline&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;A 2x2 by hand, then&amp;lt;br/&amp;gt;two-way fixed effects&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Dynamics&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Short run versus long run,&amp;lt;br/&amp;gt;and a full event study&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Doubly robust&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;LWDR logit-odds weights&amp;lt;br/&amp;gt;KOBDR Oaxaca-Blinder weights&amp;quot;]
E --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Two engines, one answer&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;diff-diff with SurveyDesign&amp;lt;br/&amp;gt;and pyfixest with weights&amp;quot;]
F --&amp;gt; G[&amp;quot;&amp;lt;b&amp;gt;Robustness&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Placebos, HonestDiD,&amp;lt;br/&amp;gt;public-goods placebo&amp;quot;]
G --&amp;gt; H[&amp;quot;&amp;lt;b&amp;gt;Verdict&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Density rises, so backwash fails.&amp;lt;br/&amp;gt;Comparative advantage survives&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:#d97757,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#141413
style F fill:#00d4c8,stroke:#141413,color:#141413
style G fill:#141413,stroke:#141413,color:#fff
style H fill:#141413,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Notice that the estimator gets more sophisticated as you move down, but the question never changes. Stages three through five all estimate the same thing; they differ only in how hard they work to make the Padma hinterland a fair stand-in for the Jamuna hinterland. Stages six and seven then try to break the answer.&lt;/p>
&lt;h2 id="2-key-concepts-at-a-glance">2. Key concepts at a glance&lt;/h2>
&lt;p>The rest of the post leans on a small vocabulary. Each concept 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 a term feels slippery.&lt;/p>
&lt;p>&lt;strong>1. Difference-in-differences&lt;/strong> Two subtractions, one estimate.
Compare how much the treated group changed with how much an untreated group changed over the same period. Whatever moved both groups equally cancels out.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The Jamuna hinterland&amp;rsquo;s mean log luminosity rose 0.0719 between the pre- and post-bridge periods. The Padma hinterland&amp;rsquo;s rose 0.0078. The difference, 0.0641, is the raw estimate.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two bakeries raise prices the same week. One also changed its recipe. Subtract the other&amp;rsquo;s price rise to isolate the recipe.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>2. Parallel trends&lt;/strong> The assumption that carries everything.
Absent the treatment, the treated and comparison groups would have moved by the same amount. Not to the same level — by the same amount.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The pre-bridge trend difference in nightlights is 0.008. The conventional test cannot reject a difference — its standard error is 0.092, wide enough to hide almost anything — but the equivalence test, which is the sharper instrument, rejects a difference larger than the margin at $p = 0.001$.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Two hikers on parallel ridges a hundred metres apart in height. As long as the terrain runs parallel, you can measure what a helicopter lift did to one of them. The permanent height gap cancels; only a divergence in the slope would break it.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>3. ATT (average treatment effect on the treated)&lt;/strong> The estimand here.
The average effect among the units that actually got treated — not among a randomly chosen unit.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>Every estimate in this post answers &amp;ldquo;what did this bridge do to the 123 upazilas behind it&amp;rdquo;, not &amp;ldquo;what would a bridge do to a randomly chosen upazila in Bangladesh&amp;rdquo;.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Asking how much a specific medicine helped the patients who took it, rather than how much it would help the general population.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>4. Two-way fixed effects&lt;/strong> Grading on two curves at once.
Remove each unit&amp;rsquo;s permanent level and each period&amp;rsquo;s common shock; whatever is left is unit-and-period specific.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>&lt;code>absorb=[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;]&lt;/code> removes 247 upazila levels and 7 period shocks. The satellite recalibration that dimmed every pixel in 2005 is soaked up by the year effect.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A teacher compares each student to their own past average, then compares each exam to the class average on that exam. What survives both is the textbook effect.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>5. Event study&lt;/strong> Every period gets its own coefficient.
Instead of one post-treatment dummy, estimate a separate effect for each period relative to a baseline. The pre-treatment ones are a test; the post-treatment ones are the answer.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The nightlights event study gives $-0.008$ before the bridge, then $0.007 \rightarrow 0.033 \rightarrow 0.050 \rightarrow 0.083 \rightarrow 0.128$ across the five post-bridge periods.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Instead of asking &amp;ldquo;was the patient better after treatment&amp;rdquo;, chart the temperature every day and look at whether it was already falling before the pill.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>6. Propensity score&lt;/strong> The probability of being treated, given what you can observe.
Used to reweight the comparison group so that it looks like the treated group on measured characteristics.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>A logit of treatment on 1991 log population and log distance to the bridge foot. The two hinterlands overlap almost completely, with a median score near 0.50.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Before comparing two schools&amp;rsquo; exam results, work out how likely each pupil was to have enrolled at the better-funded one, and weight accordingly.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>7. Doubly robust&lt;/strong> Two parachutes.
Combine a model of who got treated with a model of the outcome. The estimator is consistent if &lt;em>either&lt;/em> model is right.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>LWDR and KOBDR both weight the comparison group and &lt;em>also&lt;/em> include the same covariates in the regression. They land at 0.106 and 0.109 against the unweighted 0.088.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>A skydiver carries a main chute and a reserve. Only if both fail does the jump end badly. But two chutes do not help if you jumped over the wrong country — double robustness protects against getting a model&amp;rsquo;s shape wrong, never against a confounder you never measured.&lt;/p>
&lt;/details>
&lt;/div>
&lt;p>&lt;strong>8. HonestDiD sensitivity&lt;/strong> How wrong can the assumption be?
Rather than testing parallel trends and declaring victory, bound how large a post-treatment violation the conclusion can survive.&lt;/p>
&lt;div class="concept-pair">
&lt;details class="concept-card concept-example">
&lt;summary>Example&lt;/summary>
&lt;p>The nightlights result survives until $M \approx 1$ — the post-bridge violation would have to be as large as the largest violation seen before the bridge.&lt;/p>
&lt;/details>
&lt;details class="concept-card concept-analogy">
&lt;summary>Analogy&lt;/summary>
&lt;p>Instead of asking whether the bridge cable is fraying, ask how many strands could snap before the bridge falls down.&lt;/p>
&lt;/details>
&lt;/div>
&lt;h2 id="3-the-research-design">3. The research design&lt;/h2>
&lt;h3 id="31-why-the-padma-hinterland-is-the-comparison">3.1 Why the Padma hinterland is the comparison&lt;/h3>
&lt;p>Everything below rests on one choice: which places stand in for the Jamuna hinterland&amp;rsquo;s missing counterfactual. Four things make the Padma hinterland unusually well suited.&lt;/p>
&lt;p>&lt;strong>It has the same problem and no solution.&lt;/strong> The Padma hinterland is cut off from Dhaka by the other great river. A Padma bridge had been discussed since before independence, but the government could not afford two at once. Construction began only in 2015, and it was still incomplete when the paper was written. Since the data end in 2013, the comparison region stayed isolated for the entire study window.&lt;/p>
&lt;p>&lt;strong>The choice of which river to bridge first was political, not economic.&lt;/strong> President Ershad&amp;rsquo;s political base was in Rangpur and Prime Minister Khaleda Zia&amp;rsquo;s in Bogra — both in the Jamuna hinterland. That is a threat only if bridge priority tracked &lt;em>economic shocks&lt;/em> in the northwest, and the historical record says it tracked personal geography instead.&lt;/p>
&lt;p>&lt;strong>They are agro-climatically almost the same place.&lt;/strong> The northernmost point of the Jamuna hinterland sits at latitude 26.62, the southernmost point of the comparison at 23.81 — under three degrees apart. Florida spans more than five.&lt;/p>
&lt;p>&lt;strong>The imbalances that do exist are measurable and correctable.&lt;/strong> We will see in section 7.5 that the two regions differ significantly in the pre-bridge services and agriculture shares, and that conditioning on 1991 population, distance to the bridge foot, and rainfall removes those differences entirely.&lt;/p>
&lt;p>A third region — the Dhaka and Chittagong core — appears in the data but is excluded from every regression. It is neither treated nor a credible comparison, and the authors dropped it from the design after a referee pointed out exactly that.&lt;/p>
&lt;h3 id="32-the-estimand-what-number-are-we-actually-after">3.2 The estimand: what number are we actually after?&lt;/h3>
&lt;p>Before touching an estimator, state the target. This tutorial estimates the &lt;strong>average treatment effect on the treated (ATT)&lt;/strong>: the average effect of the Jamuna Bridge on the 123 upazilas that actually sit in its hinterland. It is not the ATE — it does not tell you what a bridge would do to a randomly chosen upazila in Bangladesh.&lt;/p>
&lt;p>$$\tau_{ATT} = E\left[ Y_{it}(1) - Y_{it}(0) \mid D_J = 1, \, t &amp;gt; 1998 \right]$$&lt;/p>
&lt;p>In words, this says: take the upazilas behind the Jamuna Bridge, and only the years after it opened; compare the luminosity they actually recorded against the luminosity they would have recorded had the bridge never been built; average the difference. The second quantity is never observed for anyone, which is the entire problem.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symbol&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Code&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$Y_{it}(1)$&lt;/td>
&lt;td>outcome with the bridge&lt;/td>
&lt;td>observed &lt;code>lmn&lt;/code> where &lt;code>treat == 1&lt;/code> and &lt;code>post == 1&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$Y_{it}(0)$&lt;/td>
&lt;td>outcome without the bridge&lt;/td>
&lt;td>never observed; DiD reconstructs its &lt;em>change&lt;/em> from the comparison group&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$D_J$&lt;/td>
&lt;td>Jamuna hinterland indicator&lt;/td>
&lt;td>&lt;code>treat&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\tau_{ATT}$&lt;/td>
&lt;td>the estimand&lt;/td>
&lt;td>the coefficient on &lt;code>treat_post&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The distinction matters for policy. The ATT answers &amp;ldquo;was this bridge worth building?&amp;rdquo;. The ATE would answer &amp;ldquo;should we build bridges generally?&amp;rdquo; — a different and much harder question, and not one this design can address.&lt;/p>
&lt;p>This is observational data, not a randomised experiment. Bridge placement was not assigned by a coin flip, and the covariates below are not there to improve precision — they are there to address confounding. If bridge priority had been driven by pre-existing economic shocks in the northwest, and those shocks had persistent effects, the design would fail regardless of how many controls we add.&lt;/p>
&lt;h3 id="33-what-difference-in-differences-assumes">3.3 What difference-in-differences assumes&lt;/h3>
&lt;p>The estimator itself is four numbers and two subtractions.&lt;/p>
&lt;p>$$\widehat{\tau}_{2 \times 2} = \left( \bar{Y}_{J,post} - \bar{Y}_{J,pre} \right) - \left( \bar{Y}_{P,post} - \bar{Y}_{P,pre} \right)$$&lt;/p>
&lt;p>In words, this says: take how much the Jamuna hinterland changed, take how much the Padma hinterland changed over the same years, and subtract the second from the first. A national fertiliser subsidy, a monsoon, a change in how the satellite was calibrated — anything that moved both regions equally drops out of the subtraction.&lt;/p>
&lt;p>The assumption that licenses it:&lt;/p>
&lt;p>$$E\left[ Y_{it}(0) - Y_{i,t-1}(0) \mid D_J = 1 \right] = E\left[ Y_{it}(0) - Y_{i,t-1}(0) \mid D_J = 0 \right]$$&lt;/p>
&lt;p>In words: had the bridge never been built, luminosity in the Jamuna hinterland would have changed period to period by exactly the same amount as luminosity in the Padma hinterland. Note what it does &lt;em>not&lt;/em> say. The two regions need not sit at the same level — only move at the same rate. And because it is a statement about a world that never happened, it can never be proven. Everything in sections 10.3 and 16 is an attempt to make it more or less plausible.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
J0[&amp;quot;&amp;lt;b&amp;gt;Jamuna hinterland&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;1992-1997&amp;lt;br/&amp;gt;pre-bridge mean&amp;quot;] --&amp;gt;|&amp;quot;observed change&amp;lt;br/&amp;gt;in the treated&amp;quot;| J1[&amp;quot;&amp;lt;b&amp;gt;Jamuna hinterland&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;1998-2013&amp;lt;br/&amp;gt;post-bridge mean&amp;quot;]
P0[&amp;quot;&amp;lt;b&amp;gt;Padma hinterland&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;1992-1997&amp;lt;br/&amp;gt;pre-bridge mean&amp;quot;] --&amp;gt;|&amp;quot;observed change&amp;lt;br/&amp;gt;in the comparison&amp;quot;| P1[&amp;quot;&amp;lt;b&amp;gt;Padma hinterland&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;1998-2013&amp;lt;br/&amp;gt;post-bridge mean&amp;quot;]
P0 -.-&amp;gt;|&amp;quot;parallel trends&amp;lt;br/&amp;gt;assumption&amp;quot;| CF[&amp;quot;&amp;lt;b&amp;gt;Counterfactual Jamuna&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;where Jamuna would have&amp;lt;br/&amp;gt;landed with no bridge&amp;quot;]
P1 -.-&amp;gt; CF
J1 --&amp;gt; ATT[&amp;quot;&amp;lt;b&amp;gt;ATT&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;treated change minus&amp;lt;br/&amp;gt;comparison change&amp;quot;]
CF --&amp;gt; ATT
style J0 fill:#6a9bcc,stroke:#141413,color:#fff
style J1 fill:#6a9bcc,stroke:#141413,color:#fff
style P0 fill:#d97757,stroke:#141413,color:#fff
style P1 fill:#d97757,stroke:#141413,color:#fff
style CF fill:#141413,stroke:#141413,color:#fff
style ATT fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>The two solid arrows are things we measure. The two dashed arrows are the assumption. Every robustness check later in the post is an attempt to make those dashed arrows more credible, and none of them can make the assumption disappear.&lt;/p>
&lt;h3 id="34-big-push-backwash-or-comparative-advantage">3.4 Big push, backwash, or comparative advantage&lt;/h3>
&lt;p>Now put the three theories into the same picture and find where they can be told apart.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Q[&amp;quot;&amp;lt;b&amp;gt;Trade costs to the core fall by half&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;What happens to the hinterland?&amp;quot;]
Q --&amp;gt; T1[&amp;quot;&amp;lt;b&amp;gt;Big push&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Integration raises efficiency&amp;lt;br/&amp;gt;and revives the lagging region&amp;quot;]
Q --&amp;gt; T2[&amp;quot;&amp;lt;b&amp;gt;Backwash&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Myrdal 1957, Krugman 1991&amp;lt;br/&amp;gt;The core captures the&amp;lt;br/&amp;gt;increasing returns&amp;quot;]
Q --&amp;gt; T3[&amp;quot;&amp;lt;b&amp;gt;Comparative advantage&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;No increasing returns needed.&amp;lt;br/&amp;gt;The hinterland specialises in what&amp;lt;br/&amp;gt;it is relatively good at&amp;quot;]
T1 --&amp;gt; P1[&amp;quot;Predicts&amp;lt;br/&amp;gt;manufacturing share up,&amp;lt;br/&amp;gt;or at worst flat&amp;quot;]
T2 --&amp;gt; P2[&amp;quot;Predicts&amp;lt;br/&amp;gt;manufacturing share DOWN&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;and&amp;lt;/b&amp;gt; population density DOWN&amp;quot;]
T3 --&amp;gt; P3[&amp;quot;Predicts&amp;lt;br/&amp;gt;manufacturing share DOWN&amp;lt;br/&amp;gt;&amp;lt;b&amp;gt;and&amp;lt;/b&amp;gt; population density UP or flat&amp;quot;]
P1 --&amp;gt; E1{&amp;quot;Did the manufacturing&amp;lt;br/&amp;gt;share fall?&amp;quot;}
P2 --&amp;gt; E1
P3 --&amp;gt; E1
E1 --&amp;gt;|&amp;quot;Yes, minus 1.2 pp&amp;quot;| OUT1[&amp;quot;&amp;lt;b&amp;gt;Big push rejected&amp;lt;/b&amp;gt;&amp;quot;]
E1 --&amp;gt;|&amp;quot;Yes, minus 1.2 pp&amp;quot;| E2{&amp;quot;&amp;lt;b&amp;gt;The discriminating test&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;What did population&amp;lt;br/&amp;gt;density do?&amp;quot;}
E2 --&amp;gt;|&amp;quot;Fell&amp;quot;| OUT2[&amp;quot;Backwash supported&amp;quot;]
E2 --&amp;gt;|&amp;quot;Rose, plus 5.9 percent&amp;lt;br/&amp;gt;in the long run&amp;quot;| OUT3[&amp;quot;&amp;lt;b&amp;gt;Backwash rejected&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Comparative advantage survives&amp;quot;]
style Q fill:#141413,stroke:#141413,color:#fff
style T1 fill:#6a9bcc,stroke:#141413,color:#fff
style T2 fill:#d97757,stroke:#141413,color:#fff
style T3 fill:#00d4c8,stroke:#141413,color:#141413
style P1 fill:#1f2b5e,stroke:#6a9bcc,color:#e8ecf2
style P2 fill:#1f2b5e,stroke:#d97757,color:#e8ecf2
style P3 fill:#1f2b5e,stroke:#00d4c8,color:#e8ecf2
style E1 fill:#141413,stroke:#141413,color:#fff
style E2 fill:#141413,stroke:#141413,color:#fff
style OUT1 fill:#d97757,stroke:#141413,color:#fff
style OUT2 fill:#d97757,stroke:#141413,color:#fff
style OUT3 fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>Formally, the discriminating test is a joint sign restriction:&lt;/p>
&lt;p>$$\text{backwash} \implies \theta_1^{ind} &amp;lt; 0 \quad \text{and} \quad \theta_1^{dens} &amp;lt; 0$$&lt;/p>
&lt;p>$$\text{comparative advantage} \implies \theta_1^{ind} &amp;lt; 0 \quad \text{and} \quad \theta_1^{dens} \geq 0$$&lt;/p>
&lt;p>In words: both stories predict the factories leave, so the manufacturing coefficient alone is useless for telling them apart. They disagree about people. Backwash means the periphery is being emptied — capital &lt;em>and&lt;/em> labour move to the core. Comparative advantage means the periphery is specialising, not emptying. So the sign of the population-density effect decides the case.&lt;/p>
&lt;p>The key insight lives in that second diamond. A study that measured only factories would have declared backwash and stopped. Adding population density — one extra outcome, from a census that was already sitting there — turns an ambiguous finding into a decisive one. The lesson generalises well beyond bridges: when two theories predict the same sign on your headline outcome, go looking for the outcome where they disagree.&lt;/p>
&lt;h2 id="4-setup-and-imports">4. Setup and imports&lt;/h2>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import statsmodels.api as sm
import matplotlib.pyplot as plt
import pyfixest as pf
from diff_diff import (
DifferenceInDifferences,
MultiPeriodDiD,
SurveyDesign,
check_parallel_trends,
compute_honest_did,
equivalence_test_trends,
placebo_timing_test,
)
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Both libraries warn about the same harmless thing all the way through: `post` is
# collinear with the year fixed effects, so it gets dropped. Section 9.1 explains why
# that is expected. `analysis.py` silences them for the same reason.
import warnings
warnings.filterwarnings(&amp;quot;ignore&amp;quot;)
&lt;/code>&lt;/pre>
&lt;p>Two libraries do the estimation. &lt;a href="https://github.com/igerber/diff-diff" target="_blank" rel="noopener">&lt;code>diff-diff&lt;/code>&lt;/a> is a
difference-in-differences toolkit with a scikit-learn-style API — you instantiate an estimator, call
&lt;code>.fit()&lt;/code>, and get a results object — plus a full diagnostic suite for parallel trends, placebo tests
and sensitivity bounds. &lt;a href="https://py-econometrics.github.io/pyfixest/" target="_blank" rel="noopener">&lt;code>pyfixest&lt;/code>&lt;/a> is a fast
high-dimensional fixed-effects regression package modelled on R&amp;rsquo;s &lt;code>fixest&lt;/code>; it appears here as an
independent second opinion. Install both with &lt;code>pip install diff-diff pyfixest&lt;/code>.&lt;/p>
&lt;p>A handful of code blocks below call helper functions rather than repeating boilerplate. &lt;code>stata_fe&lt;/code>
is a weighted unit fixed-effects regression with Stata&amp;rsquo;s exact cluster-robust degrees-of-freedom
correction, which is what makes the reproduction audit in section 17 possible; &lt;code>stata_ols&lt;/code> is its
pooled sibling; &lt;code>event_study&lt;/code> and &lt;code>diffdiff_mean&lt;/code> are thin wrappers around &lt;code>diff-diff&lt;/code>;
&lt;code>add_common&lt;/code> is written out in full in section 6.1, and &lt;code>build_weights&lt;/code> is simply the twelve lines
of section 11.2 to 11.4 packaged as a function so the other datasets can reuse them. Every one of
them is defined in &lt;a href="analysis.py">&lt;code>analysis.py&lt;/code>&lt;/a>, and apart from those definitions the code blocks
below run in the order they appear.&lt;/p>
&lt;p>The figures use the site&amp;rsquo;s dark palette, set once:&lt;/p>
&lt;pre>&lt;code class="language-python">DARK_NAVY, GRID_LINE = &amp;quot;#0f1729&amp;quot;, &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT, WHITE_TEXT = &amp;quot;#c8d0e0&amp;quot;, &amp;quot;#e8ecf2&amp;quot;
STEEL_BLUE, WARM_ORANGE, TEAL = &amp;quot;#6a9bcc&amp;quot;, &amp;quot;#d97757&amp;quot;, &amp;quot;#00d4c8&amp;quot;
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY, &amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT, &amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.grid&amp;quot;: True, &amp;quot;grid.color&amp;quot;: GRID_LINE, &amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT, &amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;text.color&amp;quot;: WHITE_TEXT, &amp;quot;font.size&amp;quot;: 12, &amp;quot;legend.frameon&amp;quot;: False,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;h2 id="5-loading-the-data">5. Loading the data&lt;/h2>
&lt;h3 id="51-four-data-families">5.1 Four data families&lt;/h3>
&lt;p>The evaluation uses five files covering four kinds of outcome. All are committed as tidy CSVs
alongside this post, so the notebook runs without the original Stata package.&lt;/p>
&lt;pre>&lt;code class="language-python">BASE = (&amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/&amp;quot;
&amp;quot;master/content/post/python_bridge_impact/data/&amp;quot;)
nl_raw = pd.read_csv(BASE + &amp;quot;bridge_nightlights.csv&amp;quot;) # satellite luminosity
emp_raw = pd.read_csv(BASE + &amp;quot;bridge_employment.csv&amp;quot;) # population censuses
yld_raw = pd.read_csv(BASE + &amp;quot;bridge_yield.csv&amp;quot;) # Boro rice yield
hh_raw = pd.read_csv(BASE + &amp;quot;bridge_dhs_household.csv&amp;quot;) # DHS/HIES households
vill_raw = pd.read_csv(BASE + &amp;quot;bridge_dhs_village.csv&amp;quot;) # DHS village questionnaire
for nm, d, unit in [(&amp;quot;employment&amp;quot;, emp_raw, &amp;quot;geocode&amp;quot;), (&amp;quot;nightlights&amp;quot;, nl_raw, &amp;quot;geocode&amp;quot;),
(&amp;quot;yield&amp;quot;, yld_raw, &amp;quot;dist&amp;quot;), (&amp;quot;dhs household&amp;quot;, hh_raw, &amp;quot;District&amp;quot;),
(&amp;quot;dhs village&amp;quot;, vill_raw, &amp;quot;District&amp;quot;)]:
ins = d[d[&amp;quot;smp1&amp;quot;].notna()]
print(f&amp;quot; {nm:15s} rows={len(d):5d} units={d[unit].nunique():4d}&amp;quot;
f&amp;quot; periods={d['year'].nunique()}&amp;quot;
f&amp;quot; treated units={ins.loc[ins.treat == 1, unit].nunique():4d}&amp;quot;
f&amp;quot; comparison units={ins.loc[ins.treat == 0, unit].nunique():4d}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> employment rows= 1053 units= 351 periods=3 treated units= 123 comparison units= 125
nightlights rows= 2513 units= 359 periods=7 treated units= 127 comparison units= 125
yield rows= 128 units= 16 periods=8 treated units= 5 comparison units= 6
dhs household rows= 1543 units= 37 periods=7 treated units= 16 comparison units= 21
dhs village rows= 1455 units= 41 periods=7 treated units= 20 comparison units= 21
&lt;/code>&lt;/pre>
&lt;p>Four things to notice. The nightlights panel is by far the richest — 359 upazilas observed in seven periods — which is why it carries the tutorial. The yield panel is the poorest: sixteen former districts, of which only eleven enter the estimation. That is not a typo; agricultural statistics in Bangladesh are published at the old district level, so the entire rice-yield result rests on nine to eleven clusters. Third, &lt;code>smp1&lt;/code> is the sample filter: it is missing for the Dhaka-Chittagong core, which is neither treated nor a valid comparison. And fourth, the treated and comparison groups are almost exactly balanced in size — 123 against 125 upazilas in the census panel.&lt;/p>
&lt;h3 id="52-what-one-row-means-and-why-year-is-not-a-year">5.2 What one row means, and why &lt;code>year&lt;/code> is not a year&lt;/h3>
&lt;p>This is the single detail most likely to break a replication of this paper.&lt;/p>
&lt;pre>&lt;code class="language-python">NL_YEARS = {1: &amp;quot;1992-94&amp;quot;, 2: &amp;quot;1995-97&amp;quot;, 3: &amp;quot;1998-00&amp;quot;, 4: &amp;quot;2001-04&amp;quot;,
5: &amp;quot;2005-07&amp;quot;, 6: &amp;quot;2008-10&amp;quot;, 7: &amp;quot;2011-13&amp;quot;}
YLD_YEARS = {1: &amp;quot;1988-91&amp;quot;, 2: &amp;quot;1992-94&amp;quot;, 3: &amp;quot;1995-97&amp;quot;, 4: &amp;quot;1998-00&amp;quot;,
5: &amp;quot;2001-04&amp;quot;, 6: &amp;quot;2005-07&amp;quot;, 7: &amp;quot;2008-10&amp;quot;, 8: &amp;quot;2011-13&amp;quot;}
EMP_YEARS = {1: &amp;quot;1991&amp;quot;, 2: &amp;quot;2001&amp;quot;, 3: &amp;quot;2011&amp;quot;}
peek = nl_raw[[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;, &amp;quot;mn&amp;quot;, &amp;quot;treat&amp;quot;, &amp;quot;smp1&amp;quot;]].head(4)
print(peek.astype({&amp;quot;geocode&amp;quot;: int, &amp;quot;year&amp;quot;: int, &amp;quot;treat&amp;quot;: int}).to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> geocode year mn treat smp1
10409 1 1.016667 0 0.0
10409 2 1.053333 0 0.0
10409 3 1.045000 0 0.0
10409 4 1.083333 0 0.0
&lt;/code>&lt;/pre>
&lt;p>One row is one upazila in one three-year window. The &lt;code>year&lt;/code> column holds the integers 1 through 7, not calendar years: annual nightlights and yields are averaged into three-year blocks to smooth out transitory shocks. The bridge opened in June 1998, which falls inside block 3, so blocks 1 and 2 are the pre-period.&lt;/p>
&lt;p>This matters beyond labelling, because the controls interact baseline characteristics with &lt;code>year&lt;/code>. If you substitute calendar years there, every coefficient changes.&lt;/p>
&lt;p>The &lt;code>.astype(int)&lt;/code> in that snippet is cosmetic — the CSVs store every numeric column as a float — but notice which column is &lt;em>not&lt;/em> cast. &lt;code>smp1&lt;/code> has to stay floating point because it holds missing values for the Dhaka-Chittagong core, and missingness is the whole point of it.&lt;/p>
&lt;h3 id="53-the-outcome-variables">5.3 The outcome variables&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Outcome&lt;/th>
&lt;th>Built from&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Periods&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Nighttime lights&lt;/td>
&lt;td>&lt;code>lmn&lt;/code> = $\ln(mn + 1)$, and its growth &lt;code>D_lmn&lt;/code>&lt;/td>
&lt;td>DMSP-OLS satellite, 1 km pixels averaged to upazila&lt;/td>
&lt;td>upazila&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rice yield&lt;/td>
&lt;td>&lt;code>lyld&lt;/code> = $\ln(yld)$, and &lt;code>D_lyld&lt;/code>&lt;/td>
&lt;td>Bangladesh Bureau of Statistics yearbooks&lt;/td>
&lt;td>former district&lt;/td>
&lt;td>8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Population and employment&lt;/td>
&lt;td>&lt;code>ldensity&lt;/code>, and the shares &lt;code>sagr&lt;/code>, &lt;code>sind&lt;/code>, &lt;code>sserv&lt;/code>&lt;/td>
&lt;td>Population censuses 1991, 2001, 2011 (IPUMS)&lt;/td>
&lt;td>upazila&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Public goods&lt;/td>
&lt;td>electricity access, distances to schools, clinics, banks&lt;/td>
&lt;td>DHS 1993-2014 and HIES 1995/96&lt;/td>
&lt;td>village-year&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>+1&lt;/code> inside the nightlights log is not cosmetic. Luminosity is bottom-coded at 1.0 in this
dataset, and most rural upazilas sit near the floor, so the transformation keeps the many near-dark
observations from dominating.&lt;/p>
&lt;h2 id="6-data-preparation">6. Data preparation&lt;/h2>
&lt;p>Nothing below is exotic, but three of these steps decide whether the replication lands on the
published numbers or somewhere nearby, so they are worth doing slowly.&lt;/p>
&lt;h3 id="61-from-raw-files-to-working-frames">6.1 From raw files to working frames&lt;/h3>
&lt;p>Every panel dataset needs the same five derived variables, so they go in one function. The first
line of it is the one the whole design rests on.&lt;/p>
&lt;pre>&lt;code class="language-python">def add_common(d, rain_plus_one=False, dist_scale=1.0):
&amp;quot;&amp;quot;&amp;quot;The five derived variables every panel dataset needs.&amp;quot;&amp;quot;&amp;quot;
d = d.copy()
# Distance to whichever crossing is the relevant one: the Jamuna bridge for a
# treated upazila, the Padma site for a comparison one. Taking the minimum of
# the two is what makes the two hinterlands mirror images of each other.
d[&amp;quot;mdist&amp;quot;] = np.minimum(d[&amp;quot;jamuna_m&amp;quot;], d[&amp;quot;padma_m&amp;quot;]) / 1000.0 # metres to km
d[&amp;quot;lmdist&amp;quot;] = np.log(d[&amp;quot;mdist&amp;quot;] + 1) / dist_scale
d[&amp;quot;lpop91&amp;quot;] = np.log(d[&amp;quot;pop91&amp;quot;]) # 1991 population, fixed pre-bridge
# Stata's ln() returns missing for zero; NumPy returns -inf and the row survives.
# The replace() is what keeps 24 zero-rainfall rows out of the estimation sample.
d[&amp;quot;lrainm&amp;quot;] = (np.log(d[&amp;quot;rainm&amp;quot;] + 1) if rain_plus_one
else np.log(d[&amp;quot;rainm&amp;quot;].replace(0, np.nan)))
d[&amp;quot;lrainsd&amp;quot;] = np.log(d[&amp;quot;rainsd&amp;quot;].replace(0, np.nan))
return d
nl = add_common(nl_raw, rain_plus_one=True) # nite_2021.do uses log(rainm + 1)
emp = add_common(emp_raw) # employment_2021.do uses plain log(rainm)
yld = add_common(yld_raw, dist_scale=10.0) # Yield_2021.do rescales lmdist by 10
&lt;/code>&lt;/pre>
&lt;p>Two of those three calls carry a keyword, and both keywords come from reading the original do-files
rather than from any statistical principle. &lt;code>nite_2021.do&lt;/code> writes &lt;code>gen lrainm = ln(rainm+1)&lt;/code> while
&lt;code>employment_2021.do&lt;/code> writes &lt;code>gen lrainm = ln(rainm)&lt;/code>; the yield do-file divides its distance log by
ten. None of the three changes a treatment coefficient by much, but all three change it in the third
decimal, which is the difference between reproducing a table and almost reproducing it.&lt;/p>
&lt;p>The &lt;code>replace(0, np.nan)&lt;/code> deserves its own sentence, because it is the first of the three traps in
section 17. Twenty-four employment rows record zero rainfall. Stata&amp;rsquo;s &lt;code>ln(0)&lt;/code> is missing and the row
drops out; NumPy&amp;rsquo;s &lt;code>np.log(0)&lt;/code> is &lt;code>-inf&lt;/code> and the row stays, quietly corrupting every sample size
downstream. It fails silently in exactly the way that is hardest to notice.&lt;/p>
&lt;h3 id="62-outcomes-periods-and-the-post-indicator">6.2 Outcomes, periods and the post indicator&lt;/h3>
&lt;pre>&lt;code class="language-python">nl = nl.sort_values([&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;])
nl[&amp;quot;lmn&amp;quot;] = np.log(nl[&amp;quot;mn&amp;quot;] + 1)
nl[&amp;quot;D_lmn&amp;quot;] = nl.groupby(&amp;quot;geocode&amp;quot;)[&amp;quot;lmn&amp;quot;].diff() # growth: a triple difference
emp[&amp;quot;ldensity&amp;quot;] = np.log(emp[&amp;quot;density&amp;quot;])
for share, num in [(&amp;quot;sagr&amp;quot;, &amp;quot;pop_agr&amp;quot;), (&amp;quot;sind&amp;quot;, &amp;quot;pop_ind&amp;quot;), (&amp;quot;sserv&amp;quot;, &amp;quot;pop_serv&amp;quot;)]:
emp[share] = emp[num] / emp[&amp;quot;emp&amp;quot;] # sector shares of employment
yld = yld.sort_values([&amp;quot;dist&amp;quot;, &amp;quot;year&amp;quot;])
yld[&amp;quot;lyld&amp;quot;] = np.log(yld[&amp;quot;yld&amp;quot;])
yld[&amp;quot;D_lyld&amp;quot;] = yld.groupby(&amp;quot;dist&amp;quot;)[&amp;quot;lyld&amp;quot;].diff()
# Each file has its own period grid, so each gets its own pre/post cut.
nl[&amp;quot;post&amp;quot;], nl[&amp;quot;sr&amp;quot;], nl[&amp;quot;lr&amp;quot;] = nl.year.gt(2), nl.year.between(3, 4), nl.year.gt(4)
emp[&amp;quot;post&amp;quot;], emp[&amp;quot;sr&amp;quot;], emp[&amp;quot;lr&amp;quot;] = emp.year.gt(1), emp.year.eq(2), emp.year.eq(3)
yld[&amp;quot;post&amp;quot;], yld[&amp;quot;sr&amp;quot;], yld[&amp;quot;lr&amp;quot;] = yld.year.ge(4), yld.year.between(4, 5), yld.year.gt(5)
for d in (nl, emp, yld):
for h in (&amp;quot;post&amp;quot;, &amp;quot;sr&amp;quot;, &amp;quot;lr&amp;quot;):
d[h] = d[h].astype(int)
d[f&amp;quot;treat_{h}&amp;quot;] = d[&amp;quot;treat&amp;quot;] * d[h]
&lt;/code>&lt;/pre>
&lt;p>Because three of the four outcomes are logarithms, their coefficients read as proportional changes. The exact conversion is&lt;/p>
&lt;p>$$g(\widehat{\theta}_1) = 100 \left( \exp(\widehat{\theta}_1) - 1 \right)$$&lt;/p>
&lt;p>In words: a coefficient of 0.109 on log nightlights means luminosity is $100(e^{0.109} - 1) = 11.5$ percent higher. Below about 0.05 the exact and approximate readings differ by under half a percentage point, so the coefficient can simply be read as a percentage. At 0.109 the gap has grown to 0.6 points, so when this post and the original paper both call that estimate &amp;ldquo;10.9 percent&amp;rdquo; they are quoting the approximation, not the exact figure. The employment shares are &lt;em>not&lt;/em> logged, so those coefficients are already in percentage points and need no conversion at all.&lt;/p>
&lt;h3 id="63-controls-initial-conditions-on-a-trend">6.3 Controls: initial conditions on a trend&lt;/h3>
&lt;pre>&lt;code class="language-python">for d in (nl, emp, yld):
d[&amp;quot;lpop91_t&amp;quot;] = d[&amp;quot;lpop91&amp;quot;] * d[&amp;quot;year&amp;quot;] # initial size, interacted with the trend
d[&amp;quot;lmdist_t&amp;quot;] = d[&amp;quot;lmdist&amp;quot;] * d[&amp;quot;year&amp;quot;] # initial remoteness, likewise
CONTROLS = [&amp;quot;lpop91_t&amp;quot;, &amp;quot;lrainm&amp;quot;, &amp;quot;lrainsd&amp;quot;, &amp;quot;lmdist_t&amp;quot;]
&lt;/code>&lt;/pre>
&lt;p>These two lines are the subtle ones and they are worth dwelling on. &lt;code>lpop91&lt;/code> and &lt;code>lmdist&lt;/code> are fixed characteristics — they never change over the panel — so a unit fixed effect already absorbs them completely. Including them alone would do nothing. Interacting them with the time index is different: it allows an upazila that was large or remote &lt;em>in 1991&lt;/em> to be on a permanently different trajectory thereafter.&lt;/p>
&lt;p>That relaxes the identifying assumption in a useful way. Instead of &amp;ldquo;all upazilas would have trended alike&amp;rdquo;, we now need only &amp;ldquo;upazilas that started at the same size and remoteness would have trended alike&amp;rdquo;. Since size and remoteness are the two things most obviously correlated with bridge placement, this is exactly the relaxation the design needs.&lt;/p>
&lt;p>Note that &lt;code>year&lt;/code> here is the integer period index from section 5.2, not a calendar year. Substituting calendar years into these two lines is the single most common way to fail to reproduce this paper.&lt;/p>
&lt;h3 id="64-the-estimation-samples">6.4 The estimation samples&lt;/h3>
&lt;pre>&lt;code class="language-python"># `smp1` is missing for the Dhaka-Chittagong core, which is neither treated nor a
# credible comparison. These three lines are section 3.1's design decision, in code.
NL = nl[nl[&amp;quot;smp1&amp;quot;].notna()].dropna(subset=CONTROLS).copy()
EMP = emp[emp[&amp;quot;smp1&amp;quot;].notna()].dropna(subset=CONTROLS).copy()
YLD = yld[yld[&amp;quot;smp1&amp;quot;].notna()].dropna(subset=CONTROLS).copy()
for d, unit in [(NL, &amp;quot;geocode&amp;quot;), (EMP, &amp;quot;geocode&amp;quot;), (YLD, &amp;quot;dist&amp;quot;)]:
d[[&amp;quot;year&amp;quot;, unit, &amp;quot;treat&amp;quot;]] = d[[&amp;quot;year&amp;quot;, unit, &amp;quot;treat&amp;quot;]].astype(int)
# The two DHS files never use smp1 -- they are already restricted to the two hinterlands.
HH = hh_raw.copy()
HH[&amp;quot;mdist&amp;quot;] = np.minimum(HH[&amp;quot;jamuna_m&amp;quot;], HH[&amp;quot;padma_m&amp;quot;]) / 1000.0
HH[&amp;quot;lmdist_t&amp;quot;] = np.log(HH[&amp;quot;mdist&amp;quot;] + 1) * HH[&amp;quot;year&amp;quot;]
HH[&amp;quot;treat_sr&amp;quot;] = HH[&amp;quot;treat&amp;quot;] * HH[&amp;quot;year&amp;quot;].eq(4)
HH[&amp;quot;treat_lr&amp;quot;] = HH[&amp;quot;treat&amp;quot;] * HH[&amp;quot;year&amp;quot;].ge(5)
VILL = vill_raw.copy()
VILL[&amp;quot;mdist&amp;quot;] = np.minimum(VILL[&amp;quot;jamuna_m&amp;quot;], VILL[&amp;quot;padma_m&amp;quot;]) / 1000.0
VILL[&amp;quot;lmdist_t&amp;quot;] = np.log(VILL[&amp;quot;mdist&amp;quot;] + 1) * VILL[&amp;quot;year&amp;quot;]
VILL[&amp;quot;treat_sr&amp;quot;] = VILL[&amp;quot;treat&amp;quot;] * VILL[&amp;quot;year&amp;quot;].eq(4)
VILL[&amp;quot;treat_lr&amp;quot;] = VILL[&amp;quot;treat&amp;quot;] * VILL[&amp;quot;year&amp;quot;].ge(5)
for nm, d, unit in [(&amp;quot;NL&amp;quot;, NL, &amp;quot;geocode&amp;quot;), (&amp;quot;EMP&amp;quot;, EMP, &amp;quot;geocode&amp;quot;), (&amp;quot;YLD&amp;quot;, YLD, &amp;quot;dist&amp;quot;)]:
print(f&amp;quot; {nm:4s} rows={len(d):5d} units={d[unit].nunique():4d}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> NL rows= 1729 units= 247
EMP rows= 738 units= 246
YLD rows= 88 units= 11
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>One convention for the rest of the post: lower case is the full frame, upper case is the estimation sample.&lt;/strong> &lt;code>nl&lt;/code> still holds all 359 upazilas including the core; &lt;code>NL&lt;/code> holds the 247 that the regressions actually see. The distinction matters more than it looks, and section 15 turns on it — the distance terciles have to be cut on &lt;code>nl&lt;/code>, before rows are lost to missing controls, not on &lt;code>NL&lt;/code>.&lt;/p>
&lt;p>Those three sample sizes are worth checking against the paper before going any further. 1729 and 247 are the N and the upazila count in the first column of the published Table 1; 738 and 246 are the census panel&amp;rsquo;s; 88 and 11 are the yield panel&amp;rsquo;s. If a replication is going to go wrong, it usually goes wrong here, and it is far cheaper to find out now than after the coefficients disagree.&lt;/p>
&lt;h2 id="7-exploratory-analysis">7. Exploratory analysis&lt;/h2>
&lt;h3 id="71-the-identification-geometry">7.1 The identification geometry&lt;/h3>
&lt;p>You do not need a shapefile to see the design. Plot every upazila by its distance to each of the two
river crossings, and the map draws itself.&lt;/p>
&lt;pre>&lt;code class="language-python">geo = emp_raw.drop_duplicates(&amp;quot;geocode&amp;quot;).copy()
geo[&amp;quot;grp&amp;quot;] = np.where(geo[&amp;quot;treatd&amp;quot;] == 1, &amp;quot;core (excluded)&amp;quot;,
np.where(geo[&amp;quot;treat&amp;quot;] == 1, &amp;quot;Jamuna hinterland (treated)&amp;quot;,
&amp;quot;Padma hinterland (comparison)&amp;quot;))
fig, ax = plt.subplots(figsize=(9.5, 7.5))
for lab, color, mk in [(&amp;quot;Jamuna hinterland (treated)&amp;quot;, WARM_ORANGE, &amp;quot;o&amp;quot;),
(&amp;quot;Padma hinterland (comparison)&amp;quot;, STEEL_BLUE, &amp;quot;o&amp;quot;),
(&amp;quot;core (excluded)&amp;quot;, &amp;quot;#7a8399&amp;quot;, &amp;quot;x&amp;quot;)]:
s = geo[geo[&amp;quot;grp&amp;quot;] == lab]
ax.scatter(s[&amp;quot;jamuna_m&amp;quot;] / 1000, s[&amp;quot;padma_m&amp;quot;] / 1000,
s=np.sqrt(s[&amp;quot;pop91&amp;quot;]) / 6, color=color, marker=mk, alpha=0.75,
edgecolors=&amp;quot;none&amp;quot;, label=f&amp;quot;{lab} (n={len(s)})&amp;quot;)
ax.plot([0, 400], [0, 400], color=WHITE_TEXT, ls=&amp;quot;:&amp;quot;, lw=1.4)
ax.set_xlabel(&amp;quot;Distance to the Jamuna bridge (km)&amp;quot;)
ax.set_ylabel(&amp;quot;Distance to the Padma crossing (km)&amp;quot;)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_01_hinterland_geography.png" alt="Scatter of each upazila&amp;amp;rsquo;s distance to the Jamuna bridge against its distance to the Padma crossing, coloured by treatment group, with marker size proportional to 1991 population.">&lt;/p>
&lt;p>&lt;em>Figure 1. Every upazila plotted by its distance to the Jamuna bridge against its distance to the Padma crossing. Marker area is proportional to 1991 population; the dotted line is the equidistance diagonal. The two hinterlands separate on either side of it, and the excluded Dhaka-Chittagong core sits away from both.&lt;/em>&lt;/p>
&lt;p>The two hinterlands separate cleanly on either side of the equidistance diagonal, and the excluded core sits away from both. Treated upazilas run from 8.4 km to 269.5 km from the bridge foot; the comparison upazilas span the same range on their own river. That symmetry is what makes the comparison credible — the Padma hinterland is not a generic control group, it is the same kind of place with the same kind of river problem and no bridge.&lt;/p>
&lt;h3 id="72-luminosity-paths">7.2 Luminosity paths&lt;/h3>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(10, 6))
for grp, color, lab in [(1, WARM_ORANGE, &amp;quot;Jamuna hinterland (treated)&amp;quot;),
(0, STEEL_BLUE, &amp;quot;Padma hinterland (comparison)&amp;quot;)]:
m = NL[NL[&amp;quot;treat&amp;quot;] == grp].groupby(&amp;quot;year&amp;quot;)[&amp;quot;lmn&amp;quot;].mean()
ax.plot(m.index, m.to_numpy(), marker=&amp;quot;o&amp;quot;, ms=5, lw=2, color=color, label=lab)
ax.axvline(2.5, color=TEAL, ls=&amp;quot;--&amp;quot;, lw=1.5) # the bridge opens inside period 3
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_02_trends_nightlights.png" alt="Mean log nighttime luminosity by three-year period for the Jamuna and Padma hinterlands, with the bridge opening marked.">&lt;/p>
&lt;p>&lt;em>Figure 2. Mean log nighttime luminosity by three-year period, Jamuna hinterland in orange against the Padma hinterland in blue. The teal dashed line marks period 3, inside which the bridge opened in June 1998.&lt;/em>&lt;/p>
&lt;p>Before 1998 the two lines track each other closely; afterwards the Jamuna line pulls away. The gap in levels is small — luminosity is bottom-coded and most of these upazilas are dark — but the divergence is monotone across all five post-bridge periods. A one-off shock would produce a step; this produces a ramp.&lt;/p>
&lt;h3 id="73-the-other-three-outcomes">7.3 The other three outcomes&lt;/h3>
&lt;p>&lt;img src="python_bridge_impact_03_trends_yield.png" alt="Mean log Boro rice yield by period for the two hinterlands, 1988-2013.">&lt;/p>
&lt;p>&lt;em>Figure 3. Mean log Boro rice yield by period, 1988–2013. Both series climb with the late Green Revolution; only the gap between them is evidence.&lt;/em>&lt;/p>
&lt;p>Rice yields rise everywhere in Bangladesh over this period — the long tail of the Green Revolution — so the treated series climbing is not evidence of anything on its own. What matters is that the two series climb &lt;em>together&lt;/em> until the late 1990s and then separate. That common component is exactly what the subtraction removes.&lt;/p>
&lt;p>&lt;img src="python_bridge_impact_04_trends_census.png" alt="Four-panel figure of log population density and the agriculture, industry and services employment shares by census year for the two hinterlands.">&lt;/p>
&lt;p>&lt;em>Figure 4. Log population density and the agriculture, industry and services employment shares, by census year. Only 1991 is pre-bridge, which is why these four outcomes support a level-balance test but no pre-trend test.&lt;/em>&lt;/p>
&lt;p>The census panel is the thinnest of the four, and only one of its three years is pre-bridge. That single pre-period is a real limitation: no pre-trend test is possible for population density or the employment shares, only a level-balance test. It is worth flagging early because it is the outcome that later decides the theoretical question.&lt;/p>
&lt;p>&lt;img src="python_bridge_impact_05_sectoral_composition.png" alt="Stacked area charts of the agriculture, industry and services employment shares for the treated and comparison hinterlands, 1991-2011.">&lt;/p>
&lt;p>&lt;em>Figure 5. Employment composition of the two hinterlands, 1991–2011. Agriculture&amp;rsquo;s share falls and services rises in both — the question is whether it happened faster on the bridged side.&lt;/em>&lt;/p>
&lt;p>Structural transformation is visible in both regions: agriculture&amp;rsquo;s share falls, services rises. Both hinterlands are developing. The question is whether it happened faster on the bridged side, and by how much — which is a question about the difference between two slopes, not about either slope.&lt;/p>
&lt;h3 id="74-distance-gradients-before-the-bridge">7.4 Distance gradients before the bridge&lt;/h3>
&lt;p>This next figure is the one that explains the paper&amp;rsquo;s most surprising result before we get to it.&lt;/p>
&lt;pre>&lt;code class="language-python">pre = EMP[(EMP[&amp;quot;year&amp;quot;] == 1) &amp;amp; (EMP[&amp;quot;treat&amp;quot;] == 1)] # 1991, treated upazilas only
for col in [&amp;quot;sagr&amp;quot;, &amp;quot;sind&amp;quot;, &amp;quot;sserv&amp;quot;, &amp;quot;ldensity&amp;quot;]:
slope = np.polyfit(pre[&amp;quot;mdist&amp;quot;], pre[col], 1)[0]
print(f&amp;quot; {col:10s} {slope:+.6f} per km&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> sagr +0.000692 per km
sind -0.000278 per km
sserv -0.000414 per km
ldensity -0.001864 per km
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_06_pre_bridge_distance_gradient.png" alt="Three scatter panels of pre-bridge 1991 employment shares against distance to the bridge foot for treated upazilas, with fitted lines.">&lt;/p>
&lt;p>&lt;em>Figure 6. 1991 employment shares against distance to the bridge foot, treated upazilas only. Remote upazilas were more agricultural and less dense before the bridge existed — which is why they later had the most to gain.&lt;/em>&lt;/p>
&lt;p>Before the bridge existed, the agriculture share rose by about 0.07 percentage points per kilometre of distance from the bridge foot, while manufacturing and services both fell. Remote upazilas were more agricultural and less dense. Hold on to that: it means the places furthest from the bridge had the most agricultural output to ship, and therefore the most to gain from a fall in the cost of shipping it. This is why the effects turn out to be largest at the far end of the line rather than next to the bridge — a result that looks backwards until you have seen this figure.&lt;/p>
&lt;h3 id="75-balance-and-pre-trends">7.5 Balance and pre-trends&lt;/h3>
&lt;p>Do the two hinterlands actually look alike before 1998? Partly.&lt;/p>
&lt;pre>&lt;code class="language-python">pre_emp = EMP[EMP[&amp;quot;year&amp;quot;] == 1] # 1991 cross-section
for y in [&amp;quot;ldensity&amp;quot;, &amp;quot;sind&amp;quot;, &amp;quot;sserv&amp;quot;, &amp;quot;sagr&amp;quot;]:
naive = stata_ols(pre_emp, y, [&amp;quot;treat&amp;quot;], cluster=&amp;quot;geocode&amp;quot;)
cond = stata_ols(pre_emp, y, [&amp;quot;treat&amp;quot;, &amp;quot;lpop91&amp;quot;, &amp;quot;lrainm&amp;quot;, &amp;quot;lrainsd&amp;quot;, &amp;quot;lmdist&amp;quot;],
cluster=&amp;quot;geocode&amp;quot;)
print(f&amp;quot; {y:9s} naive {naive['coef']['treat']:+.4f} (p={naive['p']['treat']:.3f})&amp;quot;
f&amp;quot; conditional {cond['coef']['treat']:+.4f} (p={cond['p']['treat']:.3f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> ldensity naive -0.0236 (p=0.730) conditional +0.1741 (p=0.106)
sind naive +0.0002 (p=0.967) conditional +0.0065 (p=0.294)
sserv naive -0.0879 (p=0.000) conditional -0.0179 (p=0.334)
sagr naive +0.0877 (p=0.000) conditional +0.0114 (p=0.581)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_14_balance_pretrends.png" alt="Forest plot of pre-bridge treated-comparison differences in levels and trends, across all four estimators.">&lt;/p>
&lt;p>&lt;em>Figure 7. Pre-bridge treated-comparison differences in levels and in trends, under all four estimators. The services and agriculture level gaps are large unconditionally and vanish once the four controls enter; the one significant trend difference does not survive reweighting.&lt;/em>&lt;/p>
&lt;p>The raw pre-bridge differences in the services and agriculture shares are large and unambiguous: the Jamuna hinterland was 8.8 percentage points more agricultural and 8.8 points less service-oriented than the Padma hinterland in 1991. Conditioning on 1991 population, distance to the bridge foot and the two rainfall variables collapses both to statistical insignificance — services to $-0.018$ ($p = 0.33$), agriculture to $+0.011$ ($p = 0.58$).&lt;/p>
&lt;p>That result is the reason those four controls appear in every specification for the rest of the post. The imbalance is real, and it is entirely explained by observable initial conditions.&lt;/p>
&lt;p>One pre-trend difference &lt;em>is&lt;/em> significant, and it should be said plainly rather than waved away: the &lt;strong>unweighted&lt;/strong> nightlights trend, at $+0.043$ with a standard error of 0.019 ($p = 0.022$). Taken alone it says the Jamuna hinterland was already brightening slightly faster than the Padma hinterland before the bridge, which is exactly the kind of thing that invalidates a DiD.&lt;/p>
&lt;p>It does not survive the reweighting that every headline specification uses. Under LWDR the same trend difference falls to $+0.027$ ($p = 0.16$), and under KOBDR to $+0.027$ ($p = 0.16$). No trend difference is significant at 5 percent under either doubly robust estimator, in any panel. That is a better argument for the weights than anything in section 11 — they were introduced to fix a level imbalance, and they turn out to fix the trend imbalance too.&lt;/p>
&lt;h2 id="8-the-2x2-four-numbers-and-no-library">8. The 2x2: four numbers and no library&lt;/h2>
&lt;p>Before any estimator, do it by hand. This is the whole idea, and it fits in three lines.&lt;/p>
&lt;pre>&lt;code class="language-python">cell = NL.groupby([&amp;quot;treat&amp;quot;, &amp;quot;post&amp;quot;])[&amp;quot;lmn&amp;quot;].mean().unstack()
d_treated = cell.loc[1, 1] - cell.loc[1, 0]
d_control = cell.loc[0, 1] - cell.loc[0, 0]
print(f&amp;quot; treated pre {cell.loc[1,0]:.4f} post {cell.loc[1,1]:.4f} &amp;quot;
f&amp;quot;change {d_treated:+.4f}&amp;quot;)
print(f&amp;quot; comparison pre {cell.loc[0,0]:.4f} post {cell.loc[0,1]:.4f} &amp;quot;
f&amp;quot;change {d_control:+.4f}&amp;quot;)
print(f&amp;quot; difference-in-differences = {d_treated - d_control:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> treated pre 1.2551 post 1.3270 change +0.0719
comparison pre 1.2113 post 1.2191 change +0.0078
difference-in-differences = +0.0641
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_07_did_2x2.png" alt="Line diagram showing the treated and comparison group means before and after the bridge, with the counterfactual path and the ATT gap marked.">&lt;/p>
&lt;p>&lt;em>Figure 8. The 2×2 in one picture. Solid lines are the two observed changes; the teal dashed line is where the Jamuna hinterland would have landed at the comparison group&amp;rsquo;s growth rate. The bracket is the ATT.&lt;/em>&lt;/p>
&lt;p>The Jamuna hinterland brightened by 0.072 log points across the bridge opening; the Padma hinterland by 0.008. The difference, 0.064, is the estimate. The teal dashed line in the figure is the counterfactual: where the treated group would have landed if it had grown at the comparison group&amp;rsquo;s rate. The gap between that line and where it actually landed is the ATT.&lt;/p>
&lt;p>Everything the rest of this post does is a refinement of these four numbers. It is worth registering now that the fully specified doubly robust estimate will come out at 0.109 — &lt;em>larger&lt;/em> than the naive 2x2, not smaller. Adjustment does not always shrink an effect.&lt;/p>
&lt;h2 id="9-baseline-two-way-fixed-effects">9. Baseline: two-way fixed effects&lt;/h2>
&lt;h3 id="91-the-specification">9.1 The specification&lt;/h3>
&lt;p>The 2x2 uses two group means and two period means. With 247 upazilas and 7 periods we can do much better: give every upazila its own level and every period its own shock.&lt;/p>
&lt;p>$$Y_{it} = \theta_0 + \mu_i + \mu_t + \theta_1 \left( D_J \times D_{post} \right) + \sum_q \beta_q X_{qit} + \sum_m \pi_m \left( Z_{mi0} \times t \right) + \varepsilon_{it}$$&lt;/p>
&lt;p>In words: each upazila carries a permanent level $\mu_i$, each period carries a national shock $\mu_t$, and after removing both we ask whether the Jamuna upazilas moved differently once the bridge opened. The final sum is the trend-interacted initial conditions from section 6.3.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symbol&lt;/th>
&lt;th>Meaning&lt;/th>
&lt;th>Code&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$Y_{it}$&lt;/td>
&lt;td>outcome&lt;/td>
&lt;td>&lt;code>lmn&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\mu_i$, $\mu_t$&lt;/td>
&lt;td>upazila and period effects&lt;/td>
&lt;td>&lt;code>absorb=[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;]&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$D_J \times D_{post}$&lt;/td>
&lt;td>the treatment indicator&lt;/td>
&lt;td>&lt;code>treat_post&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$X_{qit}$&lt;/td>
&lt;td>time-varying controls&lt;/td>
&lt;td>&lt;code>lrainm&lt;/code>, &lt;code>lrainsd&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$Z_{mi0} \times t$&lt;/td>
&lt;td>initial conditions on a trend&lt;/td>
&lt;td>&lt;code>lpop91_t&lt;/code>, &lt;code>lmdist_t&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\theta_1$&lt;/td>
&lt;td>the ATT&lt;/td>
&lt;td>the coefficient we report&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\varepsilon_{it}$&lt;/td>
&lt;td>error&lt;/td>
&lt;td>clustered on &lt;code>geocode&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Note that $D_J$ and $D_{post}$ do not appear on their own. They cannot: $D_J$ never varies within an upazila, so the upazila effects absorb it, and $D_{post}$ is a function of the period, so the period effects absorb that. Only the interaction survives, which is a useful reminder that DiD identification lives entirely in the interaction.&lt;/p>
&lt;h3 id="92-estimating-it-with-diff-diff">9.2 Estimating it with diff-diff&lt;/h3>
&lt;pre>&lt;code class="language-python">res = DifferenceInDifferences(cluster=&amp;quot;geocode&amp;quot;).fit(
NL, outcome=&amp;quot;lmn&amp;quot;, treatment=&amp;quot;treat&amp;quot;, time=&amp;quot;post&amp;quot;,
covariates=[&amp;quot;lpop91_t&amp;quot;, &amp;quot;lrainm&amp;quot;, &amp;quot;lrainsd&amp;quot;, &amp;quot;lmdist_t&amp;quot;],
absorb=[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;], unit=&amp;quot;geocode&amp;quot;)
print(res)
res.print_summary()
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">DiDResults(ATT=0.0881***, SE=0.0220, p=0.0001)
&lt;/code>&lt;/pre>
&lt;p>The bridge raised nighttime luminosity by 0.088 log points — about 9.2 percent — with a standard error of 0.022. That is a t-statistic near 4, and it is a substantially larger estimate than the raw 2x2 of 0.064, which tells us the fixed effects and controls were doing real work.&lt;/p>
&lt;p>Three API details are worth learning here rather than discovering the hard way.&lt;/p>
&lt;p>&lt;code>treatment&lt;/code> and &lt;code>time&lt;/code> are the &lt;em>group&lt;/em> and &lt;em>period&lt;/em> indicators, and &lt;code>diff-diff&lt;/code> forms the interaction itself. Here &lt;code>time=&amp;quot;post&amp;quot;&lt;/code> is the binary pre/post switch, not the seven-period index. Passing &lt;code>time=&amp;quot;year&amp;quot;&lt;/code> to &lt;code>DifferenceInDifferences&lt;/code> would ask it to treat the period index as the post indicator, which is not the model we want.&lt;/p>
&lt;p>Use &lt;code>absorb=&lt;/code> rather than &lt;code>fixed_effects=&lt;/code>. Both return the same coefficient, but &lt;code>absorb&lt;/code> partials the fixed effects out before the degrees-of-freedom correction, which is what Stata&amp;rsquo;s &lt;code>xtreg, fe&lt;/code> does; &lt;code>fixed_effects=&lt;/code> builds explicit dummies and counts them in $K$, giving 0.0238 here instead of 0.0220. The published standard error is 0.022, so &lt;code>absorb&lt;/code> is the one that reproduces it.&lt;/p>
&lt;p>The sibling estimator &lt;code>TwoWayFixedEffects&lt;/code> is not a drop-in substitute in this design. Calling it with &lt;code>time=&amp;quot;year&amp;quot;&lt;/code> returns 0.0184 (0.0040) — a different model entirely, because it interprets the period index rather than a pre/post switch. Calling it with &lt;code>time=&amp;quot;post&amp;quot;&lt;/code> returns 0.1339 (0.0200), which differs from the 0.0881 above because it does not absorb the seven period effects, only a single post dummy. Neither is a bug; both are the answer to a different question. When a library offers several routes to &amp;ldquo;the DiD estimate&amp;rdquo;, check which one reproduces a number you already know.&lt;/p>
&lt;h3 id="93-the-same-regression-in-pyfixest">9.3 The same regression in pyfixest&lt;/h3>
&lt;pre>&lt;code class="language-python">fit = pf.feols(&amp;quot;lmn ~ treat_post + post + lpop91_t + lrainm + lrainsd + lmdist_t&amp;quot;
&amp;quot; | geocode + year&amp;quot;, data=NL, vcov={&amp;quot;CRV1&amp;quot;: &amp;quot;geocode&amp;quot;})
print(f'{fit.coef()[&amp;quot;treat_post&amp;quot;]:.10f} {fit.se()[&amp;quot;treat_post&amp;quot;]:.10f}')
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">0.0880677516 0.0220101168
&lt;/code>&lt;/pre>
&lt;p>A second library, a completely different implementation, and the same number to seven decimal places. This is worth doing routinely. A DiD estimate is a small number extracted from a large panel through several layers of transformation, and the cheapest insurance against a coding error is to reproduce it in software that shares none of your code.&lt;/p>
&lt;p>&lt;code>pyfixest&lt;/code> also prints a warning on that call — &lt;code>1 variables dropped due to multicollinearity. The following variables are dropped: ['post']&lt;/code>. That is not a problem, it is the library confirming the point made at the end of section 9.1: &lt;code>post&lt;/code> is a function of the period, so the year fixed effects have already absorbed it. Leaving it in the formula costs nothing and makes the redundancy visible.&lt;/p>
&lt;h2 id="10-dynamics-short-run-long-run-and-the-event-study">10. Dynamics: short run, long run, and the event study&lt;/h2>
&lt;h3 id="101-one-post-bridge-dummy-is-not-enough">10.1 One post-bridge dummy is not enough&lt;/h3>
&lt;p>Migration, credit and supply chains all take time. Splitting the post-bridge window in two lets the data report a path instead of an average.&lt;/p>
&lt;p>$$Y_{it} = \delta_0 + \mu_i + \mu_t + \delta_1 \left( D_J \times D_{SR} \right) + \delta_2 \left( D_J \times D_{LR} \right) + \sum_q \beta_q X_{qit} + \sum_m \pi_m \left( Z_{mi0} \times t \right) + \varsigma_{it}$$&lt;/p>
&lt;p>In words: replace the single post-bridge switch with two — one for the years just after opening, one for the years well after — and estimate two effects instead of one.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symbol&lt;/th>
&lt;th>Code&lt;/th>
&lt;th>Nightlights&lt;/th>
&lt;th>Census&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$D_{SR}$&lt;/td>
&lt;td>&lt;code>sr&lt;/code>&lt;/td>
&lt;td>periods 3-4, 1998-2004&lt;/td>
&lt;td>2001&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$D_{LR}$&lt;/td>
&lt;td>&lt;code>lr&lt;/code>&lt;/td>
&lt;td>periods 5-7, 2005-2013&lt;/td>
&lt;td>2011&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\delta_1$&lt;/td>
&lt;td>&lt;code>treat_sr&lt;/code>&lt;/td>
&lt;td>short-run ATT&lt;/td>
&lt;td>short-run ATT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\delta_2$&lt;/td>
&lt;td>&lt;code>treat_lr&lt;/code>&lt;/td>
&lt;td>long-run ATT&lt;/td>
&lt;td>long-run ATT&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="102-the-event-study">10.2 The event study&lt;/h3>
&lt;p>Better still: give &lt;em>every&lt;/em> period its own coefficient, measured relative to the last pre-bridge one.&lt;/p>
&lt;p>$$Y_{it} = \mu_i + \mu_t + \sum_{k \neq 2} \gamma_k \, D_J \cdot \mathbf{1}[t = k] + \sum_q \beta_q X_{qit} + u_{it}$$&lt;/p>
&lt;p>In words: $\gamma_k$ is the treated-comparison gap in period $k$, normalised to zero in period 2, the last pre-bridge window. The coefficients for $k &amp;lt; 3$ are a &lt;strong>test&lt;/strong> — if parallel trends is reasonable they should be indistinguishable from zero. The coefficients for $k \geq 3$ are the &lt;strong>answer&lt;/strong> — they trace the whole path of the effect.&lt;/p>
&lt;pre>&lt;code class="language-python">ev = MultiPeriodDiD(cluster=&amp;quot;geocode&amp;quot;).fit(
NL, outcome=&amp;quot;lmn&amp;quot;, treatment=&amp;quot;treat&amp;quot;, time=&amp;quot;year&amp;quot;,
post_periods=[3, 4, 5, 6, 7], covariates=CONTROLS,
absorb=[&amp;quot;geocode&amp;quot;], reference_period=2, unit=&amp;quot;geocode&amp;quot;)
for p in sorted(ev.period_effects):
e = ev.get_effect(p)
print(f&amp;quot; period {p} ({NL_YEARS[p]}): {e.effect:+.4f} (se {e.se:.4f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> period 1 (1992-94): -0.0082 (se 0.0167)
period 3 (1998-00): +0.0068 (se 0.0179)
period 4 (2001-04): +0.0328 (se 0.0217)
period 5 (2005-07): +0.0501 (se 0.0216)
period 6 (2008-10): +0.0831 (se 0.0238)
period 7 (2011-13): +0.1279 (se 0.0271)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_10_event_study_nightlights.png" alt="Event study of log nighttime lights by three-year period, with the pre-bridge window shaded and 95 percent confidence intervals.">&lt;/p>
&lt;p>&lt;em>Figure 9. Event study for log nighttime lights, normalised to period 2 with 95 percent confidence intervals. The single pre-bridge coefficient sits on zero; the five post-bridge ones climb monotonically. The original paper never drew this figure.&lt;/em>&lt;/p>
&lt;p>This is the most persuasive figure in the analysis, and the original paper never drew it. The one available pre-bridge coefficient is $-0.008$ against a standard error of 0.017 — sitting on zero, exactly what parallel trends requires. Then the effect climbs monotonically across all five post-bridge periods, from $+0.7$ percent in 1998-2000 to $+12.8$ percent in 2011-13.&lt;/p>
&lt;p>Think about what a confounder would have to look like to generate this. It would need to be absent before June 1998, appear at the right moment, and then grow steadily for fifteen years without ever reversing. Such things exist, but the list is short, and every candidate on it is easier to argue about once you have seen this picture than once you have seen a single pooled coefficient.&lt;/p>
&lt;h3 id="103-testing-parallel-trends-formally">10.3 Testing parallel trends formally&lt;/h3>
&lt;pre>&lt;code class="language-python">pt = check_parallel_trends(NL, outcome=&amp;quot;lmn&amp;quot;, time=&amp;quot;year&amp;quot;,
treatment_group=&amp;quot;treat&amp;quot;, pre_periods=[1, 2])
eq = equivalence_test_trends(NL, outcome=&amp;quot;lmn&amp;quot;, time=&amp;quot;year&amp;quot;,
treatment_group=&amp;quot;treat&amp;quot;, unit=&amp;quot;geocode&amp;quot;,
pre_periods=[1, 2])
def show(title, d, keys):
print(f&amp;quot; {title}&amp;quot;)
for k in keys:
v = d[k]
print(f&amp;quot; {k:28s} {v:.5f}&amp;quot; if isinstance(v, float) else f&amp;quot; {k:28s} {v}&amp;quot;)
show(&amp;quot;check_parallel_trends (nightlights, 1992-97):&amp;quot;, pt,
[&amp;quot;trend_difference&amp;quot;, &amp;quot;trend_difference_se&amp;quot;, &amp;quot;p_value&amp;quot;, &amp;quot;parallel_trends_plausible&amp;quot;])
print()
show(&amp;quot;equivalence_test_trends:&amp;quot;, eq,
[&amp;quot;equivalence_margin&amp;quot;, &amp;quot;tost_p_value&amp;quot;, &amp;quot;equivalent&amp;quot;])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> check_parallel_trends (nightlights, 1992-97):
trend_difference 0.00844
trend_difference_se 0.09242
p_value 0.92725
parallel_trends_plausible True
equivalence_test_trends:
equivalence_margin 0.04019
tost_p_value 0.00107
equivalent True
&lt;/code>&lt;/pre>
&lt;p>The two tests do different jobs and the second is the more useful one. &lt;code>check_parallel_trends&lt;/code> fails to reject a difference in trends, with a p-value of 0.93. That is reassuring but weak: failing to reject is not evidence of similarity, especially when the standard error is 0.092 and could hide almost anything.&lt;/p>
&lt;p>The equivalence test flips the null around. It asks whether the trend difference is &lt;em>smaller&lt;/em> than a pre-specified margin, and rejects the hypothesis that it is larger at $p = 0.001$. That is a positive finding rather than an absence of one. When you have the option, report both — and treat an insignificant pre-trend on its own as the weakest form of the evidence, not the strongest.&lt;/p>
&lt;h2 id="11-two-doubly-robust-estimators-built-by-hand">11. Two doubly robust estimators, built by hand&lt;/h2>
&lt;h3 id="111-why-doubly-robust">11.1 Why doubly robust&lt;/h3>
&lt;p>So far the comparison group has been used as-is. But we know the two hinterlands differ on measured characteristics — section 7.5 showed the imbalance. There are two classical ways to fix that.&lt;/p>
&lt;p>&lt;strong>Reweight&lt;/strong> the comparison group so that its covariate distribution matches the treated group&amp;rsquo;s. This needs a model of &lt;em>who got treated&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Regression-adjust&lt;/strong>, putting the covariates on the right-hand side. This needs a model of &lt;em>how the outcome depends on covariates&lt;/em>.&lt;/p>
&lt;p>A doubly robust estimator does both, and is consistent if &lt;em>either&lt;/em> model is correct. That is a much weaker requirement than getting both right.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Z[&amp;quot;&amp;lt;b&amp;gt;Pre-bridge covariates&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;log population in 1991&amp;lt;br/&amp;gt;log distance to bridge foot&amp;quot;] --&amp;gt; L[&amp;quot;&amp;lt;b&amp;gt;Logit model&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;probability of being in the&amp;lt;br/&amp;gt;Jamuna hinterland, given Z&amp;quot;]
L --&amp;gt; P[&amp;quot;&amp;lt;b&amp;gt;Fitted propensity p&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;one number per upazila&amp;quot;]
P --&amp;gt; TR[&amp;quot;&amp;lt;b&amp;gt;Trim&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;drop comparison upazilas in the&amp;lt;br/&amp;gt;bottom 5 percent of p&amp;quot;]
TR --&amp;gt; W1[&amp;quot;&amp;lt;b&amp;gt;LWDR weight&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;odds of p, rescaled.&amp;lt;br/&amp;gt;Treated units weighted 1&amp;quot;]
Z --&amp;gt; W2[&amp;quot;&amp;lt;b&amp;gt;KOBDR weight&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Oaxaca-Blinder projection of the&amp;lt;br/&amp;gt;treated covariate mean onto controls&amp;quot;]
TR --&amp;gt; W2
W2 --&amp;gt; NEG[&amp;quot;Drop comparison units with&amp;lt;br/&amp;gt;negative KOBDR weight&amp;quot;]
W1 --&amp;gt; REG[&amp;quot;&amp;lt;b&amp;gt;Weighted two-way fixed effects&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;the same covariates enter again&amp;lt;br/&amp;gt;as regression adjustment&amp;quot;]
NEG --&amp;gt; REG
REG --&amp;gt; DR[&amp;quot;&amp;lt;b&amp;gt;Doubly robust ATT&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;consistent if EITHER the weight model&amp;lt;br/&amp;gt;OR the outcome model is right&amp;quot;]
style Z fill:#6a9bcc,stroke:#141413,color:#fff
style L fill:#6a9bcc,stroke:#141413,color:#fff
style P fill:#6a9bcc,stroke:#141413,color:#fff
style TR fill:#d97757,stroke:#141413,color:#fff
style W1 fill:#d97757,stroke:#141413,color:#fff
style NEG fill:#d97757,stroke:#141413,color:#fff
style W2 fill:#00d4c8,stroke:#141413,color:#141413
style REG fill:#141413,stroke:#141413,color:#fff
style DR fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>The two branches out of the covariate box are the two protections. The left branch models who got treated; the right branch models what the outcome would have been. Note also that trimming and negative-weight dropping happen &lt;em>before&lt;/em> the regression: both are sample decisions, and both must be reported.&lt;/p>
&lt;h3 id="112-the-propensity-model-and-the-5-percent-trim">11.2 The propensity model and the 5 percent trim&lt;/h3>
&lt;p>$$p_i = \Pr\left( D_J = 1 \mid Z_i \right) = \frac{\exp\left( \alpha_0 + \alpha_1 \ln P_i + \alpha_2 \ln d_i \right)}{1 + \exp\left( \alpha_0 + \alpha_1 \ln P_i + \alpha_2 \ln d_i \right)}$$&lt;/p>
&lt;p>In words: estimate how likely each upazila was to end up on the Jamuna side, using only two things that were fixed before the bridge existed — how many people lived there in 1991, and how far it sits from the nearest bridge foot.&lt;/p>
&lt;pre>&lt;code class="language-python">s = nl[nl[&amp;quot;smp1&amp;quot;].notna()].dropna(subset=[&amp;quot;lpop91&amp;quot;, &amp;quot;lmdist&amp;quot;]).copy()
X = sm.add_constant(s[[&amp;quot;lpop91&amp;quot;, &amp;quot;lmdist&amp;quot;]].astype(float))
D = s[&amp;quot;treat&amp;quot;].to_numpy(float)
logit = sm.Logit(D, X).fit(disp=0)
s[&amp;quot;p&amp;quot;] = logit.predict(X)
cut = np.percentile(s[&amp;quot;p&amp;quot;], 5)
print(f&amp;quot; logit N={len(s)} coefs={logit.params.to_numpy().round(6)}&amp;quot;)
print(f&amp;quot; 5% propensity trim at p={cut:.7f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> logit N=1743 coefs=[-7.206172 0.313503 0.741378]
5% propensity trim at p=0.2918769
&lt;/code>&lt;/pre>
&lt;p>Then the trimming rule:&lt;/p>
&lt;p>$$w_i = 0 \quad \text{whenever} \quad D_{J,i} = 0 \quad \text{and} \quad p_i &amp;lt; q_{0.05}(p)$$&lt;/p>
&lt;p>Discard the comparison upazilas whose estimated likelihood of treatment sits in the bottom 5 percent. Why? Because a comparison unit with a propensity of 0.03 has to be multiplied by roughly thirty to stand in for a treated one, and it then speaks with the voice of thirty upazilas. It is like settling a national budget at an exchange rate of thirty to one: a mis-measured rainfall figure or one enumerator&amp;rsquo;s mistake arrives in your final accounts multiplied thirtyfold. Trimming is the rule that you will not trade at rates above a certain level.&lt;/p>
&lt;p>There is a cost, and it is worth being explicit about it. Trimming shifts the estimand slightly: you are now estimating the ATT on the region of common support, not on all 123 treated upazilas.&lt;/p>
&lt;h3 id="113-lwdr-propensity-odds-as-att-weights">11.3 LWDR: propensity odds as ATT weights&lt;/h3>
&lt;p>$$w_i^{LW} = \frac{p_i}{1 - p_i} \cdot \frac{1 - \pi}{\pi}, \qquad \pi = \frac{1}{N} \sum_j D_{J,j}$$&lt;/p>
&lt;p>In words: weight each comparison upazila by its &lt;em>odds&lt;/em> of having been treated, rescaled so the weights average about one. A comparison unit that looks almost exactly like a Jamuna upazila gets a large weight; one that looks nothing like a Jamuna upazila gets a small one.&lt;/p>
&lt;pre>&lt;code class="language-python">pi = D.mean()
s[&amp;quot;ipw1&amp;quot;] = np.where(D == 1, 1.0, s[&amp;quot;p&amp;quot;] / (1 - s[&amp;quot;p&amp;quot;]) * (1 - pi) / pi)
s[&amp;quot;ipw3&amp;quot;] = np.where((s[&amp;quot;p&amp;quot;] &amp;lt; cut) &amp;amp; (D == 0), np.nan, s[&amp;quot;ipw1&amp;quot;]) # trimmed
&lt;/code>&lt;/pre>
&lt;p>The line &lt;code>np.where(D == 1, 1.0, ...)&lt;/code> is not a formatting convenience. Treated units get a weight of exactly one because we want the effect &lt;em>on the treated&lt;/em>: the treated distribution is the target, and only the comparison group is reshaped to match it. Weighting the treated units by $1/p$ instead would target the ATE. That single line is what makes these ATT weights.&lt;/p>
&lt;h3 id="114-kobdr-klines-oaxaca-blinder-reweighting">11.4 KOBDR: Kline&amp;rsquo;s Oaxaca-Blinder reweighting&lt;/h3>
&lt;p>$$w_i^{KOB} = \frac{1 - D_{J,i}}{N_1} \left( \sum_{j : D_{J,j} = 1} X_j \right)^{\prime} \left( \sum_{j : D_{J,j} = 0} X_j X_j^{\prime} \right)^{-1} X_i$$&lt;/p>
&lt;p>In words: run the outcome regression on the comparison upazilas only, then evaluate it at the &lt;em>average treated&lt;/em> covariate profile. Kline (2011) showed that this two-step Oaxaca-Blinder procedure is algebraically identical to taking a weighted average of the comparison outcomes, and this is the weight it implies.&lt;/p>
&lt;pre>&lt;code class="language-python">n1, Xm, nD = D.sum(), X.to_numpy(float), 1.0 - D
ob = ((D @ Xm) @ np.linalg.inv(Xm.T @ (Xm * nD[:, None])) @ Xm.T / n1) * nD * n1
s[&amp;quot;ipw2&amp;quot;] = np.where(D == 1, 1.0, np.where(ob &amp;lt; 0, np.nan, ob)) # negatives dropped
s[&amp;quot;ipw4&amp;quot;] = np.where((s[&amp;quot;p&amp;quot;] &amp;lt; cut) &amp;amp; (D == 0), np.nan, s[&amp;quot;ipw2&amp;quot;]) # trimmed
print(f&amp;quot; [nightlights] negative Oaxaca-Blinder weights: {int((ob &amp;lt; 0).sum())} obs dropped&amp;quot;)
# Merge the four weight columns back onto the estimation sample. `analysis.py`
# wraps the twelve lines above into build_weights() and runs it once per family:
# a 5 percent trim for nightlights and the census, none for the yield panel,
# where sixteen districts leave nothing to trim.
NL = NL.merge(s[[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;, &amp;quot;p&amp;quot;, &amp;quot;ipw1&amp;quot;, &amp;quot;ipw2&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;]],
on=[&amp;quot;geocode&amp;quot;, &amp;quot;year&amp;quot;], how=&amp;quot;left&amp;quot;)
EMP = EMP.merge(build_weights(emp[emp[&amp;quot;smp1&amp;quot;].notna()], trim_pct=5)
.groupby(&amp;quot;geocode&amp;quot;)[[&amp;quot;p&amp;quot;, &amp;quot;ipw1&amp;quot;, &amp;quot;ipw2&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;]]
.first().reset_index(), on=&amp;quot;geocode&amp;quot;, how=&amp;quot;left&amp;quot;)
YLD = YLD.merge(build_weights(yld[yld[&amp;quot;smp1&amp;quot;].notna()], trim_pct=None)
.groupby([&amp;quot;dist&amp;quot;, &amp;quot;year&amp;quot;])[[&amp;quot;p&amp;quot;, &amp;quot;ipw1&amp;quot;, &amp;quot;ipw2&amp;quot;]]
.first().reset_index(), on=[&amp;quot;dist&amp;quot;, &amp;quot;year&amp;quot;], how=&amp;quot;left&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> [nightlights] negative Oaxaca-Blinder weights: 35 obs dropped
&lt;/code>&lt;/pre>
&lt;p>Think of it as casting understudies. A director needs to stage the Jamuna hinterland but may only use Padma actors. She writes down the profile of the average Jamuna upazila — this much 1991 population, this far from a bridge foot — and asks what blend of Padma actors reproduces that profile exactly. The blend proportions are the Oaxaca-Blinder weights.&lt;/p>
&lt;p>Two things follow immediately. The blend is chosen to match the &lt;em>treated&lt;/em> average, which is again what makes this an ATT estimator. And nothing in the algebra forces the proportions to be positive: sometimes the best way to hit the target is to weight one actor at 1.4 and another at $-0.4$. Negative casting is not interpretable, so those units are dismissed — 35 upazila-periods here.&lt;/p>
&lt;h3 id="115-does-the-reweighting-actually-work">11.5 Does the reweighting actually work?&lt;/h3>
&lt;pre>&lt;code class="language-python">nlp = NL.drop_duplicates(&amp;quot;geocode&amp;quot;) # one row per upazila; weights are time-invariant
for var in [&amp;quot;lpop91&amp;quot;, &amp;quot;lmdist&amp;quot;]:
t = nlp.loc[nlp.treat == 1, var]
for lab, wcol in [(&amp;quot;unweighted&amp;quot;, None), (&amp;quot;LWDR&amp;quot;, &amp;quot;ipw3&amp;quot;), (&amp;quot;KOBDR&amp;quot;, &amp;quot;ipw4&amp;quot;)]:
c = nlp[nlp.treat == 0].dropna(subset=[var] + ([wcol] if wcol else []))
w = np.ones(len(c)) if wcol is None else c[wcol].to_numpy(float)
cm = np.average(c[var], weights=w)
sd = np.sqrt((t.var() + c[var].var()) / 2)
print(f&amp;quot; {var:8s} {lab:11s} std. diff {(t.mean() - cm) / sd:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> lpop91 unweighted std. diff +0.0969
lpop91 LWDR std. diff -0.0476
lpop91 KOBDR std. diff -0.0127
lmdist unweighted std. diff +0.4041
lmdist LWDR std. diff +0.0793
lmdist KOBDR std. diff +0.0541
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_09_covariate_balance.png" alt="Horizontal bar chart of standardised covariate differences between treated and comparison upazilas, unweighted and under each weighting scheme.">&lt;/p>
&lt;p>&lt;em>Figure 10. Standardised treated-comparison differences in the two covariates, unweighted and under each weighting scheme. Distance starts at 0.404, well above the conventional 0.10 threshold, and KOBDR cuts it to 0.054.&lt;/em>&lt;/p>
&lt;p>This is the clearest evidence that the reweighting does what it claims. The distance covariate starts badly imbalanced at 0.404 — well above the conventional 0.10 threshold — because Jamuna upazilas sit systematically farther from their bridge foot than Padma upazilas do from theirs. KOBDR cuts that to 0.054, and the population imbalance to 0.013. Both move from clearly imbalanced to clearly balanced.&lt;/p>
&lt;p>&lt;img src="python_bridge_impact_08_propensity_and_weights.png" alt="Two panels: propensity-score overlap between the two hinterlands with the 5 percent trim line, and a scatter of the Oaxaca-Blinder weight against the logit-odds weight for comparison upazilas.">&lt;/p>
&lt;p>&lt;em>Figure 11. Left: propensity-score overlap between the two hinterlands, with the 5 percent trim line. Right: the Oaxaca-Blinder weight against the logit-odds weight for each comparison upazila; the two schemes correlate at 0.971.&lt;/em>&lt;/p>
&lt;p>The left panel is the overlap check, and it is unusually healthy: the two propensity distributions sit almost on top of each other with a median near 0.50. This is the quantitative version of the claim that the two hinterlands are hard to tell apart. The right panel shows the two weighting schemes agree closely — they correlate at 0.971 — which is why the LWDR and KOBDR columns land within 0.003 of each other on the nightlights and census panels. On the nine-cluster yield panel, where every estimate is noisier, the two drift as far apart as 0.009.&lt;/p>
&lt;h3 id="116-running-the-weights-through-diff-diff">11.6 Running the weights through diff-diff&lt;/h3>
&lt;p>&lt;code>diff-diff&lt;/code> accepts external weights through a &lt;code>SurveyDesign&lt;/code> object. Setting &lt;code>weight_type=&amp;quot;aweight&amp;quot;&lt;/code> reproduces Stata&amp;rsquo;s analytic weights, and &lt;code>psu&lt;/code> sets the clustering unit.&lt;/p>
&lt;pre>&lt;code class="language-python">e4 = NL[NL[&amp;quot;ipw4&amp;quot;].notna()].copy()
dums = pd.get_dummies(e4[&amp;quot;year&amp;quot;], prefix=&amp;quot;yd&amp;quot;, drop_first=True).astype(float)
for c in dums.columns:
e4[c] = dums[c].to_numpy()
res_dr = DifferenceInDifferences(cluster=&amp;quot;geocode&amp;quot;).fit(
e4, outcome=&amp;quot;lmn&amp;quot;, treatment=&amp;quot;treat&amp;quot;, time=&amp;quot;post&amp;quot;,
covariates=CONTROLS + list(dums.columns), absorb=[&amp;quot;geocode&amp;quot;], unit=&amp;quot;geocode&amp;quot;,
survey_design=SurveyDesign(weights=&amp;quot;ipw4&amp;quot;, weight_type=&amp;quot;aweight&amp;quot;, psu=&amp;quot;geocode&amp;quot;))
print(res_dr)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">DiDResults(ATT=0.1088***, SE=0.0230, p=0.0000)
&lt;/code>&lt;/pre>
&lt;p>One implementation detail is worth knowing before you hit it. &lt;code>diff-diff&lt;/code> refuses to absorb two fixed-effect dimensions at once when survey weights are supplied — weighted sequential demeaning is not the same operation as unweighted sequential demeaning, and the library declines to pretend otherwise. The workaround is to absorb the unit and pass explicit year dummies as covariates, which is what the code above does.&lt;/p>
&lt;h3 id="117-three-engines-one-answer">11.7 Three engines, one answer&lt;/h3>
&lt;p>Since we now have three ways to run the same regression, run all three.&lt;/p>
&lt;pre>&lt;code class="language-python"># All 24 mean-effect specifications: four outcome families x three estimators.
# The yield panel has no propensity trim, so its weights are ipw1 / ipw2.
SPECS = [(NL, &amp;quot;lmn&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;), (NL, &amp;quot;D_lmn&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;),
(EMP, &amp;quot;ldensity&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;), (EMP, &amp;quot;sind&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;),
(EMP, &amp;quot;sserv&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;), (EMP, &amp;quot;sagr&amp;quot;, &amp;quot;geocode&amp;quot;, &amp;quot;ipw3&amp;quot;, &amp;quot;ipw4&amp;quot;),
(YLD, &amp;quot;lyld&amp;quot;, &amp;quot;dist&amp;quot;, &amp;quot;ipw1&amp;quot;, &amp;quot;ipw2&amp;quot;), (YLD, &amp;quot;D_lyld&amp;quot;, &amp;quot;dist&amp;quot;, &amp;quot;ipw1&amp;quot;, &amp;quot;ipw2&amp;quot;)]
comparison = []
for data, y, unit, w_lw, w_kob in SPECS:
for est, wcol in [(&amp;quot;OLS&amp;quot;, None), (&amp;quot;LWDR&amp;quot;, w_lw), (&amp;quot;KOBDR&amp;quot;, w_kob)]:
sub = data if wcol is None else data[data[wcol].notna()]
manual = stata_fe(data, y, [&amp;quot;treat_post&amp;quot;, &amp;quot;post&amp;quot;] + CONTROLS,
unit=unit, time=&amp;quot;year&amp;quot;, weight=wcol)
fx = pf.feols(f&amp;quot;{y} ~ treat_post + post + lpop91_t + lrainm + lrainsd + lmdist_t&amp;quot;
f&amp;quot; | {unit} + year&amp;quot;, data=sub, weights=wcol, vcov={&amp;quot;CRV1&amp;quot;: unit})
dd = diffdiff_mean(data, y, unit, wcol)
comparison.append((est,
(manual[&amp;quot;coef&amp;quot;][&amp;quot;treat_post&amp;quot;], manual[&amp;quot;se&amp;quot;][&amp;quot;treat_post&amp;quot;]),
(float(fx.coef()[&amp;quot;treat_post&amp;quot;]), float(fx.se()[&amp;quot;treat_post&amp;quot;])),
(dd[0], dd[1])))
print(f&amp;quot; {len(comparison)} specifications compared across three engines.&amp;quot;)
def spread(triple, i):
a, b, c = (t[i] for t in triple)
return max(abs(a - b), abs(a - c), abs(b - c))
print(&amp;quot; Largest coefficient disagreement across the three engines: &amp;quot;
f&amp;quot;{max(spread(row[1:], 0) for row in comparison):.6f}&amp;quot;)
print(&amp;quot; Largest standard-error disagreement: &amp;quot;
f&amp;quot;{max(spread(row[1:], 1) for row in comparison):.6f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> 24 specifications compared across three engines.
Largest coefficient disagreement across the three engines: 0.000000
Largest standard-error disagreement: 0.024989
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_17_estimator_agreement.png" alt="Two scatter panels comparing coefficients and standard errors from diff-diff and pyfixest against the Stata-identical estimator.">&lt;/p>
&lt;p>&lt;em>Figure 12. Coefficients and standard errors from diff-diff and pyfixest against the Stata-identical estimator, all 24 mean-effect specifications. The coefficients agree to nine decimals; the standard errors separate on the weighted rows, where diff-diff switches to a design-based variance.&lt;/em>&lt;/p>
&lt;p>All three engines return the same point estimate to nine decimal places across all 24 mean-effect specifications. The standard errors are a different and more interesting story. &lt;code>pyfixest&lt;/code> matches the Stata recipe to within 0.0007 everywhere. &lt;code>diff-diff&lt;/code> matches on the unweighted specifications but diverges on the weighted ones, because supplying a &lt;code>SurveyDesign&lt;/code> switches it to a design-based Taylor-linearisation variance rather than the classical cluster-robust sandwich.&lt;/p>
&lt;p>The gap is usually small — the median ratio is 0.999 — but the extreme case is instructive. On the rice-yield panel with nine clusters, &lt;code>diff-diff&lt;/code> reports 0.034 where the Stata recipe reports 0.023, a standard error 50 percent larger. Neither is a bug. They are two defensible variance conventions disagreeing precisely where the asymptotics are thinnest, and when a design has nine clusters you should probably prefer the more conservative one.&lt;/p>
&lt;h2 id="12-results-i-nighttime-lights">12. Results I: nighttime lights&lt;/h2>
&lt;pre>&lt;code class="language-python">for est, wcol in [(&amp;quot;OLS&amp;quot;, None), (&amp;quot;LWDR&amp;quot;, &amp;quot;ipw3&amp;quot;), (&amp;quot;KOBDR&amp;quot;, &amp;quot;ipw4&amp;quot;)]:
r = stata_fe(NL, &amp;quot;lmn&amp;quot;, [&amp;quot;treat_post&amp;quot;, &amp;quot;post&amp;quot;] + CONTROLS,
unit=&amp;quot;geocode&amp;quot;, time=&amp;quot;year&amp;quot;, weight=wcol)
print(f&amp;quot; {est:6s} {r['coef']['treat_post']:+.4f} ({r['se']['treat_post']:.4f})&amp;quot;
f&amp;quot; N={r['n']} upazilas={r['g']}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> OLS +0.0881 (0.0220) N=1729 upazilas=247
LWDR +0.1059 (0.0222) N=1673 upazilas=239
KOBDR +0.1088 (0.0223) N=1673 upazilas=239
&lt;/code>&lt;/pre>
&lt;p>Averaged over the whole post-bridge period, the bridge raised nighttime luminosity by 10.9 percent under the preferred doubly robust estimator. Both reweighted estimates are &lt;em>larger&lt;/em> than the unweighted one, by about two percentage points — reweighting toward comparison upazilas that resemble the treated ones raises the estimated effect rather than deflating it, which is the opposite of what people often expect adjustment to do.&lt;/p>
&lt;p>The sample falls from 247 to 239 upazilas when the weights are applied: eight comparison upazilas are lost to the propensity trim and to negative Oaxaca-Blinder weights. That is a 3 percent reduction, small enough not to worry about and large enough to report.&lt;/p>
&lt;h2 id="13-results-ii-population-density-and-employment-shares">13. Results II: population density and employment shares&lt;/h2>
&lt;h3 id="131-mean-effects">13.1 Mean effects&lt;/h3>
&lt;p>&lt;img src="python_bridge_impact_20_forest_table1.png" alt="Forest plot of the mean post-bridge effect for every outcome under all three estimators.">&lt;/p>
&lt;p>&lt;em>Figure 13. Mean post-bridge effect for every outcome under all three estimators, with 95 percent confidence intervals.&lt;/em>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Outcome&lt;/th>
&lt;th>OLS&lt;/th>
&lt;th>LWDR&lt;/th>
&lt;th>KOBDR&lt;/th>
&lt;th>N&lt;/th>
&lt;th>Upazilas&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Log nightlights&lt;/td>
&lt;td>0.088 (0.022)&lt;/td>
&lt;td>0.106 (0.022)&lt;/td>
&lt;td>&lt;strong>0.109 (0.022)&lt;/strong>&lt;/td>
&lt;td>1673&lt;/td>
&lt;td>239&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Nightlights growth&lt;/td>
&lt;td>0.016 (0.016)&lt;/td>
&lt;td>0.032 (0.016)&lt;/td>
&lt;td>&lt;strong>0.033 (0.016)&lt;/strong>&lt;/td>
&lt;td>1434&lt;/td>
&lt;td>239&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Log population density&lt;/td>
&lt;td>0.032 (0.015)&lt;/td>
&lt;td>0.025 (0.015)&lt;/td>
&lt;td>0.025 (0.015)&lt;/td>
&lt;td>714&lt;/td>
&lt;td>238&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Industry empl. share&lt;/td>
&lt;td>−0.010 (0.004)&lt;/td>
&lt;td>−0.009 (0.004)&lt;/td>
&lt;td>&lt;strong>−0.010 (0.004)&lt;/strong>&lt;/td>
&lt;td>714&lt;/td>
&lt;td>238&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Services empl. share&lt;/td>
&lt;td>0.017 (0.006)&lt;/td>
&lt;td>0.022 (0.005)&lt;/td>
&lt;td>&lt;strong>0.023 (0.005)&lt;/strong>&lt;/td>
&lt;td>714&lt;/td>
&lt;td>238&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Agriculture empl. share&lt;/td>
&lt;td>−0.008 (0.007)&lt;/td>
&lt;td>−0.013 (0.007)&lt;/td>
&lt;td>&lt;strong>−0.013 (0.007)&lt;/strong>&lt;/td>
&lt;td>714&lt;/td>
&lt;td>238&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Log rice yield&lt;/td>
&lt;td>0.049 (0.031)&lt;/td>
&lt;td>0.059 (0.026)&lt;/td>
&lt;td>&lt;strong>0.063 (0.023)&lt;/strong>&lt;/td>
&lt;td>72&lt;/td>
&lt;td>9&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rice yield growth&lt;/td>
&lt;td>−0.042 (0.085)&lt;/td>
&lt;td>0.049 (0.049)&lt;/td>
&lt;td>0.053 (0.049)&lt;/td>
&lt;td>63&lt;/td>
&lt;td>9&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The services share rose 2.3 percentage points and the industry share fell 1.0. That industry number looks negligible until you check the base: the 1991 manufacturing share in the treated hinterland was 2.8 percent, so a 1.0 point fall removes roughly a third of the sector. Deindustrialisation is real here, and it is not small.&lt;/p>
&lt;p>Population density comes out at $+2.5$ percent and is not distinguishable from zero ($p = 0.10$). Hold that thought, because the null is an artefact of averaging.&lt;/p>
&lt;h3 id="132-the-discriminating-test">13.2 The discriminating test&lt;/h3>
&lt;pre>&lt;code class="language-python">for y in [&amp;quot;ldensity&amp;quot;, &amp;quot;sind&amp;quot;, &amp;quot;sserv&amp;quot;, &amp;quot;sagr&amp;quot;]:
r = stata_fe(EMP, y, [&amp;quot;treat_sr&amp;quot;, &amp;quot;treat_lr&amp;quot;] + CONTROLS,
unit=&amp;quot;geocode&amp;quot;, time=&amp;quot;year&amp;quot;, weight=&amp;quot;ipw4&amp;quot;)
print(f&amp;quot; {y:9s} SR {r['coef']['treat_sr']:+.4f} ({r['se']['treat_sr']:.4f})&amp;quot;
f&amp;quot; LR {r['coef']['treat_lr']:+.4f} ({r['se']['treat_lr']:.4f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> ldensity SR -0.0248 (0.0142) LR +0.0590 (0.0164)
sind SR -0.0060 (0.0049) LR -0.0120 (0.0047)
sserv SR +0.0204 (0.0052) LR +0.0242 (0.0076)
sagr SR -0.0144 (0.0061) LR -0.0122 (0.0089)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_21_forest_table2.png" alt="Forest plot contrasting short-run and long-run KOBDR effects across all outcomes.">&lt;/p>
&lt;p>&lt;em>Figure 14. Short-run against long-run effects under KOBDR. Population density is the row to read: negative in the short run, positive and significant in the long run.&lt;/em>&lt;/p>
&lt;p>Here is the answer to the question the post opened with. Population density falls 2.5 percent in the short run and rises 5.9 percent in the long run — a genuine sign reversal that the pooled mean effect averaged into an uninformative $+2.5$ percent. In the years right after the bridge, people left; over the following decade, more came than had left.&lt;/p>
&lt;p>Now apply the discriminating test. The manufacturing share falls 1.2 percentage points in the long run, which both backwash and comparative advantage predict. But backwash requires the region to be emptying, and the density coefficient is $+0.059$ with a standard error of $0.016$ — positive and significant at the 0.1 percent level. The region gained people while losing factories.&lt;/p>
&lt;p>That combination is what the core-periphery model cannot produce and the comparative-advantage story predicts directly. The Jamuna hinterland did not decline; it specialised.&lt;/p>
&lt;p>The services result adds the mechanism. The share rises 2.0 points in the short run and 2.4 in the long run, and services here means trading, transport and processing — the activities that a region takes on when it starts shipping its agricultural output somewhere. The short-run agriculture decline of 1.4 points, which partly reverses later, looks like overshooting: labour left farming faster than was sustainable when migration was still costly.&lt;/p>
&lt;h2 id="14-results-iii-rice-yields">14. Results III: rice yields&lt;/h2>
&lt;pre>&lt;code class="language-python">es_yld, es_yld_res = event_study(YLD, &amp;quot;lyld&amp;quot;, &amp;quot;dist&amp;quot;, [4, 5, 6, 7, 8], 3,
&amp;quot;Rice yield&amp;quot;, YLD_YEARS)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> period label effect se is_post
1 1988-91 -0.0605 0.0954 False
2 1992-94 0.0622 0.0670 False
3 1995-97 0.0000 0.0000 False
4 1998-00 -0.0422 0.0523 True
5 2001-04 -0.0613 0.0722 True
6 2005-07 0.0329 0.0648 True
7 2008-10 0.0544 0.0716 True
8 2011-13 0.0733 0.0688 True
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_11_event_study_others.png" alt="Three event-study panels for rice yield, population density and the services employment share.">&lt;/p>
&lt;p>&lt;em>Figure 15. Event studies for rice yield, population density and the services employment share. The yield effect is negative for two post-bridge windows before turning; its wide pre-period bands are a warning about how little a nine-cluster panel can rule out.&lt;/em>&lt;/p>
&lt;p>Rice yields are $+1.2$ percent ($p = 0.64$) in the short run and $+7.9$ percent ($p = 0.001$) in the long run. The event study shows why: the effect is actually &lt;em>negative&lt;/em> for the first two post-bridge windows and only turns positive from 2005-07.&lt;/p>
&lt;p>The delay has a documented mechanism. Average fertiliser prices in the Jamuna hinterland were 9 percent below the Padma hinterland during 2006-2009 and 13 percent below during 2010-2013 — the input distribution networks took years to reorganise around the new road. Add credit constraints that only relax as crop prices improve, and the short-run labour outflow reducing land productivity, and a decade-long lag is unsurprising.&lt;/p>
&lt;p>This event study is also the honest counterweight to the nightlights one. Its two pre-bridge coefficients are $-0.061$ (0.095) and $+0.062$ (0.067). Both are statistically insignificant, but with standard errors that wide the test has very little power to detect a pre-trend even if one existed. With nine clusters there is not much this panel can rule out, and it would be wrong to present its clean pre-period as strong evidence.&lt;/p>
&lt;h2 id="15-results-iv-distance-from-the-bridge">15. Results IV: distance from the bridge&lt;/h2>
&lt;p>The average effect turns out to hide almost everything interesting. Split the sample into distance terciles — recomputed within the estimation sample, exactly as the original code does.&lt;/p>
&lt;p>Where the cut happens is not a detail. &lt;code>nite_2021.do&lt;/code> runs its &lt;code>xtile&lt;/code> on the whole &lt;code>smp1&lt;/code> sample —
that is, on &lt;code>nl&lt;/code>, before any row is lost to a missing control — while &lt;code>employment_2021.do&lt;/code> drops
first and cuts afterwards. Two upazilas sit close enough to a boundary that the order decides which
band they land in, which is enough to move every nightlights coefficient in this section in the
third decimal. So the nightlights bands are cut on &lt;code>nl&lt;/code> and mapped onto &lt;code>NL&lt;/code>; the other two are cut
in place.&lt;/p>
&lt;pre>&lt;code class="language-python"># Nightlights: cut on the full frame, BEFORE rows are lost to missing controls.
bands = pd.qcut(nl.loc[nl[&amp;quot;smp1&amp;quot;].notna(), &amp;quot;lmdist&amp;quot;], 3, labels=[&amp;quot;near&amp;quot;, &amp;quot;mid&amp;quot;, &amp;quot;far&amp;quot;])
band_map = (nl.loc[nl[&amp;quot;smp1&amp;quot;].notna(), [&amp;quot;geocode&amp;quot;]].assign(band=bands.to_numpy())
.drop_duplicates(&amp;quot;geocode&amp;quot;).set_index(&amp;quot;geocode&amp;quot;)[&amp;quot;band&amp;quot;])
NL[&amp;quot;band&amp;quot;] = NL[&amp;quot;geocode&amp;quot;].map(band_map)
# Census and yield: their do-files drop first, so cut on the estimation sample.
for D_ in (EMP, YLD):
D_[&amp;quot;band&amp;quot;] = pd.qcut(D_[&amp;quot;lmdist&amp;quot;], 3, labels=[&amp;quot;near&amp;quot;, &amp;quot;mid&amp;quot;, &amp;quot;far&amp;quot;])
for D_ in (NL, EMP, YLD):
for b in [&amp;quot;near&amp;quot;, &amp;quot;mid&amp;quot;, &amp;quot;far&amp;quot;]:
D_[f&amp;quot;tsr_{b}&amp;quot;] = D_[&amp;quot;treat&amp;quot;] * D_[&amp;quot;sr&amp;quot;] * (D_[&amp;quot;band&amp;quot;] == b)
D_[f&amp;quot;tlr_{b}&amp;quot;] = D_[&amp;quot;treat&amp;quot;] * D_[&amp;quot;lr&amp;quot;] * (D_[&amp;quot;band&amp;quot;] == b)
print(EMP.groupby(&amp;quot;band&amp;quot;, observed=True)[&amp;quot;mdist&amp;quot;].agg([&amp;quot;min&amp;quot;, &amp;quot;max&amp;quot;, &amp;quot;count&amp;quot;]).round(2))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> min max count
band
near 8.44 83.42 246
mid 83.73 128.15 246
far 128.33 269.47 246
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_12_heterogeneity_by_distance.png" alt="Eight-panel grid of short-run and long-run effects by distance tercile for every outcome.">&lt;/p>
&lt;p>&lt;em>Figure 16. Short-run and long-run effects by distance tercile, all outcomes. Read the agriculture and services rows across: the average effect reverses sign between the nearest and the farthest band.&lt;/em>&lt;/p>
&lt;p>Long-run effects by band:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Outcome&lt;/th>
&lt;th>Nearest (&amp;lt;84 km)&lt;/th>
&lt;th>Middle (84-128 km)&lt;/th>
&lt;th>Farthest (128-270 km)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Log rice yield&lt;/td>
&lt;td>0.049 (0.023)&lt;/td>
&lt;td>0.065 (0.022)&lt;/td>
&lt;td>&lt;strong>0.265 (0.025)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rice yield growth&lt;/td>
&lt;td>0.025 (0.036)&lt;/td>
&lt;td>0.011 (0.034)&lt;/td>
&lt;td>&lt;strong>0.344 (0.091)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Log nightlights&lt;/td>
&lt;td>0.026 (0.034)&lt;/td>
&lt;td>&lt;strong>0.149 (0.040)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0.102 (0.038)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Log population density&lt;/td>
&lt;td>&lt;strong>0.069 (0.024)&lt;/strong>&lt;/td>
&lt;td>0.005 (0.029)&lt;/td>
&lt;td>&lt;strong>0.093 (0.024)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Industry empl. share&lt;/td>
&lt;td>−0.006 (0.010)&lt;/td>
&lt;td>&lt;strong>−0.025 (0.007)&lt;/strong>&lt;/td>
&lt;td>−0.001 (0.008)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Services empl. share&lt;/td>
&lt;td>&lt;strong>−0.026 (0.013)&lt;/strong>&lt;/td>
&lt;td>0.017 (0.012)&lt;/td>
&lt;td>&lt;strong>0.059 (0.016)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Agriculture empl. share&lt;/td>
&lt;td>&lt;strong>0.032 (0.015)&lt;/strong>&lt;/td>
&lt;td>0.008 (0.014)&lt;/td>
&lt;td>&lt;strong>−0.057 (0.017)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Read the bottom two rows across, and the average effect reverses sign. In the nearest band labour moves &lt;em>into&lt;/em> agriculture ($+3.2$ points) and &lt;em>out of&lt;/em> services ($-2.6$). In the farthest band it moves the other way and three times harder: agriculture $-5.7$, services $+5.9$. Rice yields in the farthest band rise 26.5 percent, four times the middle band and five times the nearest. The manufacturing decline is not spread evenly either — it sits almost entirely in the middle band at $-2.5$ points.&lt;/p>
&lt;p>This looks backwards at first. The upazilas nearest the bridge got the largest proportional cut in travel time, roughly 40 percent, against about 17 percent at the far end. Why do the distant ones gain more?&lt;/p>
&lt;p>Think about two discounts. A 40 percent cut on a ten-dollar taxi saves four dollars; a 17 percent cut on a five-hundred-dollar flight saves eighty-five. The percentage is smaller, the base is enormous, and the saving is much larger. Upazilas near the bridge foot were already reasonably connected — the ferry was an inconvenience, not a wall. Upazilas 250 kilometres out were close to autarky, where fertiliser rarely arrived and rice rarely left, and where a modest proportional cut on a very high delivered cost is still a very large absolute cut. Trade responds to the level of the barrier, not to the percentage change in it.&lt;/p>
&lt;p>This is also why the nearest band moves labour &lt;em>into&lt;/em> farming. Those upazilas are close to Dhaka with good onward links, so what they gained access to was the high-value fruit, flower and vegetable market — agriculture, but not the kind that shows up as subsistence rice.&lt;/p>
&lt;p>The practical lesson is blunt. An evaluation reporting only the average would tell a minister to build near the demand centre. The heterogeneity says the payoff was at the end of the line.&lt;/p>
&lt;h2 id="16-validation-and-robustness">16. Validation and robustness&lt;/h2>
&lt;h3 id="161-placebo-timing-and-randomisation-inference">16.1 Placebo timing and randomisation inference&lt;/h3>
&lt;pre>&lt;code class="language-python">pl = placebo_timing_test(NL, outcome=&amp;quot;lmn&amp;quot;, treatment=&amp;quot;treat&amp;quot;, time=&amp;quot;year&amp;quot;,
fake_treatment_period=2, post_periods=[3, 4, 5, 6, 7],
cluster=&amp;quot;geocode&amp;quot;)
print(f&amp;quot; placebo effect = {pl.placebo_effect:+.5f} (se {pl.se:.5f}), &amp;quot;
f&amp;quot;p = {pl.p_value:.4f}, significant = {pl.is_significant}&amp;quot;)
print(f&amp;quot; for comparison, the real effect is {pl.original_effect:+.5f} &amp;quot;
f&amp;quot;(se {pl.original_se:.5f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> placebo effect = +0.00844 (se 0.01025), p = 0.4106, significant = False
for comparison, the real effect is +0.06409 (se 0.01850)
&lt;/code>&lt;/pre>
&lt;p>Move the bridge one period earlier, into a window when it did not exist, and the estimated effect collapses from $+0.064$ to $+0.008$ and loses significance. If some slow-moving regional divergence were driving the result, this test would find it.&lt;/p>
&lt;pre>&lt;code class="language-python">rng = np.random.default_rng(RANDOM_SEED)
units = NL.drop_duplicates(&amp;quot;geocode&amp;quot;)[[&amp;quot;geocode&amp;quot;, &amp;quot;treat&amp;quot;]]
null = []
for _ in range(500):
perm = units.assign(ptreat=rng.permutation(units[&amp;quot;treat&amp;quot;].to_numpy()))
tmp = NL.merge(perm[[&amp;quot;geocode&amp;quot;, &amp;quot;ptreat&amp;quot;]], on=&amp;quot;geocode&amp;quot;)
tmp[&amp;quot;treat_post&amp;quot;] = tmp[&amp;quot;ptreat&amp;quot;] * tmp[&amp;quot;post&amp;quot;]
null.append(stata_fe(tmp, &amp;quot;lmn&amp;quot;, [&amp;quot;treat_post&amp;quot;, &amp;quot;post&amp;quot;] + CONTROLS,
unit=&amp;quot;geocode&amp;quot;, time=&amp;quot;year&amp;quot;)[&amp;quot;coef&amp;quot;][&amp;quot;treat_post&amp;quot;])
true = 0.0881
share = np.mean(np.abs(null) &amp;gt;= abs(true))
print(&amp;quot; Randomisation inference over 500 placebo assignments:&amp;quot;)
print(f&amp;quot; true estimate {true:+.4f}; placebo |effect| &amp;gt;= |true| in {share:.1%} of draws&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Randomisation inference over 500 placebo assignments:
true estimate +0.0881; placebo |effect| &amp;gt;= |true| in 0.0% of draws
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_16_randomization_inference.png" alt="Histogram of 500 placebo difference-in-differences estimates from randomly reassigned treatment, with the actual estimate marked.">&lt;/p>
&lt;p>&lt;em>Figure 17. The null distribution from 500 random reassignments of treatment, with the actual estimate marked. Not one placebo draw reaches it.&lt;/em>&lt;/p>
&lt;p>Randomly reassign which upazilas count as &amp;ldquo;treated&amp;rdquo;, re-estimate, and repeat 500 times. The resulting null distribution is centred on zero and not one draw reaches the magnitude of the real estimate, giving a randomisation p-value below 1/500. This inference makes no asymptotic assumptions at all, which is worth having alongside the cluster-robust standard errors.&lt;/p>
&lt;h3 id="162-honestdid-how-much-violation-can-it-survive">16.2 HonestDiD: how much violation can it survive?&lt;/h3>
&lt;p>Passing the pre-trend test is a low bar. The more useful question is how badly parallel trends would have to fail before the conclusion changes.&lt;/p>
&lt;pre>&lt;code class="language-python">for M in [0.0, 0.25, 0.5, 1.0, 1.5, 2.0]:
h = compute_honest_did(ev, method=&amp;quot;relative_magnitude&amp;quot;, M=M)
verdict = &amp;quot;includes zero&amp;quot; if h.ci_lb &amp;lt;= 0 &amp;lt;= h.ci_ub else &amp;quot;excludes zero&amp;quot;
print(f&amp;quot; M={M:&amp;lt;4} CI = [{h.ci_lb:+.4f}, {h.ci_ub:+.4f}] {verdict}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> M=0.0 CI = [+0.0236, +0.0967] excludes zero
M=0.25 CI = [+0.0174, +0.1028] excludes zero
M=0.5 CI = [+0.0113, +0.1090] excludes zero
M=1.0 CI = [-0.0010, +0.1212] includes zero
M=1.5 CI = [-0.0132, +0.1335] includes zero
M=2.0 CI = [-0.0255, +0.1458] includes zero
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_15_honest_did_sensitivity.png" alt="HonestDiD confidence bands for the nightlights effect as the allowed parallel-trends violation M increases.">&lt;/p>
&lt;p>&lt;em>Figure 18. Rambachan-Roth relative-magnitude bounds for the nightlights effect. The confidence set widens as the allowed post-treatment violation M grows; the breakdown value sits just under M = 1.&lt;/em>&lt;/p>
&lt;p>The Rambachan-Roth relative-magnitude bounds allow the post-treatment violation of parallel trends to be up to $M$ times the largest violation observed before treatment, and report the widest confidence set consistent with that. The breakdown value here sits just under $M = 1$.&lt;/p>
&lt;p>In plain terms: the post-bridge violation would have to be as large as the largest pre-bridge violation for the nightlights result to become inconclusive. That is a moderate robustness margin, not a spectacular one, and it is better to say so than to dress it up. A result that survived to $M = 3$ would be much stronger; a result that broke at $M = 0.3$ would be fragile. This one sits in between.&lt;/p>
&lt;h3 id="163-the-public-goods-placebo">16.3 The public-goods placebo&lt;/h3>
&lt;p>The most serious alternative explanation is political rather than economic. A prime minister with roots in the Jamuna hinterland might simply have sent more schools, clinics and electricity there, and the &amp;ldquo;bridge effect&amp;rdquo; could be a public-spending effect wearing a disguise.&lt;/p>
&lt;pre>&lt;code class="language-python">VILL_VARS = [&amp;quot;dist_Thana&amp;quot;, &amp;quot;dist_district&amp;quot;, &amp;quot;dist_satellite_clinic&amp;quot;, &amp;quot;dist_hos&amp;quot;,
&amp;quot;primary_school&amp;quot;, &amp;quot;high_school&amp;quot;, &amp;quot;madrassa_school&amp;quot;,
&amp;quot;grameen_bank&amp;quot;, &amp;quot;cinema&amp;quot;, &amp;quot;post_office&amp;quot;, &amp;quot;co_operative_soc&amp;quot;, &amp;quot;NGO&amp;quot;]
hits = n = 0
for data, unit, outcomes in [(HH, &amp;quot;District&amp;quot;, [&amp;quot;Electricity&amp;quot;]),
(VILL, &amp;quot;District&amp;quot;, VILL_VARS)]:
for v in outcomes:
r = stata_fe(data, v, [&amp;quot;treat_sr&amp;quot;, &amp;quot;treat_lr&amp;quot;, &amp;quot;lmdist_t&amp;quot;],
unit=unit, time=&amp;quot;year&amp;quot;)
for h in (&amp;quot;treat_sr&amp;quot;, &amp;quot;treat_lr&amp;quot;):
if h in r[&amp;quot;p&amp;quot;] and np.isfinite(r[&amp;quot;p&amp;quot;][h]):
n += 1
hits += r[&amp;quot;p&amp;quot;][h] &amp;lt; 0.05
print(f&amp;quot; Public-goods outcomes significant at 5%: {hits} of {n} estimates&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Public-goods outcomes significant at 5%: 0 of 21 estimates
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_13_public_goods_placebo.png" alt="Forest plot of t-statistics for all public-goods outcomes with the 5 percent critical values marked.">&lt;/p>
&lt;p>&lt;em>Figure 19. t-statistics for all 21 public-goods estimates, with the 5 percent critical values marked. None crosses. If the bridge effect were really a public-spending effect, its fingerprints would be here.&lt;/em>&lt;/p>
&lt;p>Twenty-one estimates across household electricity access and eleven village infrastructure measures, and not one is significant at 5 percent. The closest is the long-run distance to a high school at $+0.535$ ($0.290$, $p = 0.065$) — and it has the wrong sign for the story, since it says schools got &lt;em>farther&lt;/em> away in treated villages.&lt;/p>
&lt;p>This is a well-designed placebo. It is not a test of the outcome we care about; it is a test of a specific rival mechanism that would produce the same headline result for a different reason. Good robustness checks name the alternative explanation and go looking for its fingerprints.&lt;/p>
&lt;h2 id="17-reproduction-audit">17. Reproduction audit&lt;/h2>
&lt;p>Every headline coefficient in the paper&amp;rsquo;s four main tables was re-estimated and compared with the authors&amp;rsquo; own Stata output. &lt;code>analysis.py&lt;/code> writes that comparison to a CSV as it goes; here it is, summarised.&lt;/p>
&lt;pre>&lt;code class="language-python">audit = pd.read_csv(&amp;quot;python_bridge_impact_audit_reproduction.csv&amp;quot;)
n_tot = len(audit)
n_coef = int(audit[&amp;quot;match_coef&amp;quot;].sum()) # coefficient matches
n_exact = int((audit[&amp;quot;match_flag&amp;quot;] == &amp;quot;exact&amp;quot;).sum()) # coefficient AND se match
print(f&amp;quot; {n_coef} of {n_tot} coefficients reproduce to the printed precision &amp;quot;
f&amp;quot;({n_coef / n_tot:.1%})&amp;quot;)
print(f&amp;quot; {n_exact} of {n_tot} reproduce both the coefficient and the standard error &amp;quot;
f&amp;quot;({n_exact / n_tot:.1%})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> 122 of 122 coefficients reproduce to the printed precision (100.0%)
113 of 122 reproduce both the coefficient and the standard error (92.6%)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_19_reproduction_audit.png" alt="Two scatter panels of replicated coefficients and standard errors against the published Stata values, with the 45-degree line.">&lt;/p>
&lt;p>&lt;em>Figure 20. All 122 published coefficients and standard errors against the replication, with the 45-degree line. Every coefficient lands on it; nine standard errors sit fractionally off, all in the thinnest panels.&lt;/em>&lt;/p>
&lt;p>All 122 coefficients across Tables 1, 2, 3 and 4 reproduce to the printed three decimals, with a maximum absolute deviation of 0.0005 — inside the tolerance implied by three-decimal rounding. The nine cells that match on the coefficient but not the standard error differ in the third decimal by between 0.0006 and 0.0013, all in the nine-cluster yield panel and the two nightlights growth specifications.&lt;/p>
&lt;p>Getting to 122 out of 122 took care in three places worth naming, because each of them is a trap that any replication of a Stata paper can fall into:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>ln(0)&lt;/code> must become missing, not negative infinity.&lt;/strong> Twenty-four employment rows have zero recorded rainfall. Stata&amp;rsquo;s &lt;code>ln()&lt;/code> returns missing and the row drops; NumPy returns &lt;code>-inf&lt;/code> and the row survives, corrupting the sample.&lt;/li>
&lt;li>&lt;strong>The degrees-of-freedom correction counts only the non-absorbed regressors.&lt;/strong> Stata&amp;rsquo;s &lt;code>xtreg, fe&lt;/code> uses $\frac{G}{G-1} \cdot \frac{N-1}{N-K}$ with $K$ excluding the fixed effects. Counting them inflates every standard error by roughly 20 percent.&lt;/li>
&lt;li>&lt;strong>Distance terciles are computed at different points in different do-files.&lt;/strong> &lt;code>nite_2021.do&lt;/code> builds them before dropping rows with missing controls; &lt;code>employment_2021.do&lt;/code> drops first. Getting that order wrong shifts every nightlights heterogeneity coefficient in the third decimal — it was the last discrepancy resolved here.&lt;/li>
&lt;/ol>
&lt;h2 id="18-notes-from-inside-the-replication-package">18. Notes from inside the replication package&lt;/h2>
&lt;p>Replication is not only about confirming numbers. Working through someone else&amp;rsquo;s code teaches you things that reading their paper cannot, and the Jamuna package has one lesson in it that is worth more than the rest of this section combined.&lt;/p>
&lt;p>The authors deserve credit before any of this: they published a complete package — four do-files, five datasets, the logs, and every intermediate table. Almost nothing below would be knowable otherwise. That is the point.&lt;/p>
&lt;h3 id="181-the-macro-that-was-never-defined">18.1 The macro that was never defined&lt;/h3>
&lt;p>&lt;code>employment_2021.do&lt;/code> opens with &lt;code>global trimL 5&lt;/code>. &lt;code>nite_2021.do&lt;/code> does not — but it still contains the line &lt;code>gen cut11 = r(p$trimL)&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;code&amp;gt;global trimL&amp;lt;/code&amp;gt; never defined&amp;lt;br/&amp;gt;in nite_2021.do&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;code&amp;gt;gen cut11 = r(p$trimL)&amp;lt;/code&amp;gt;&amp;lt;br/&amp;gt;expands to &amp;lt;code&amp;gt;r(p)&amp;lt;/code&amp;gt;,&amp;lt;br/&amp;gt;which does not exist&amp;quot;]
B --&amp;gt; C[&amp;quot;cut11 is missing for&amp;lt;br/&amp;gt;all 1,743 observations&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;code&amp;gt;replace ipw4 = . if p &amp;amp;lt; cut11&amp;lt;/code&amp;gt;&amp;lt;br/&amp;gt;In Stata any number is less&amp;lt;br/&amp;gt;than missing, so this is TRUE&amp;lt;br/&amp;gt;for every comparison unit&amp;quot;]
D --&amp;gt; E[&amp;quot;Every comparison upazila&amp;lt;br/&amp;gt;loses its weight&amp;quot;]
E --&amp;gt; F[&amp;quot;The regression runs on&amp;lt;br/&amp;gt;treated units only&amp;lt;br/&amp;gt;N = 868, 124 upazilas&amp;quot;]
F --&amp;gt; G[&amp;quot;&amp;lt;b&amp;gt;treat_yr = 1.064, se 0.710&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;an unidentified number&amp;lt;br/&amp;gt;that still prints&amp;quot;]
style A fill:#d97757,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#d97757,stroke:#141413,color:#fff
style E fill:#141413,stroke:#141413,color:#fff
style F fill:#141413,stroke:#141413,color:#fff
style G fill:#141413,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>We can reproduce both branches exactly:&lt;/p>
&lt;pre>&lt;code class="language-python"># The bug: an undefined macro means the cutoff is missing, and in Stata
# every real number is smaller than a missing value.
NL_BUG = NL.copy()
NL_BUG[&amp;quot;ipw4_bug&amp;quot;] = np.where(NL_BUG[&amp;quot;treat&amp;quot;] == 1, 1.0, np.nan)
for label, data, wcol in [(&amp;quot;published (trim = 5th pctile)&amp;quot;, NL, &amp;quot;ipw4&amp;quot;),
(&amp;quot;as shipped (trimL undefined)&amp;quot;, NL_BUG, &amp;quot;ipw4_bug&amp;quot;)]:
for y in (&amp;quot;lmn&amp;quot;, &amp;quot;D_lmn&amp;quot;):
r = stata_fe(data, y, [&amp;quot;treat_post&amp;quot;, &amp;quot;post&amp;quot;] + CONTROLS,
unit=&amp;quot;geocode&amp;quot;, time=&amp;quot;year&amp;quot;, weight=wcol)
print(f&amp;quot; {label:32s} {y:7s} {r['coef']['treat_post']:+.4f} &amp;quot;
f&amp;quot;({r['se']['treat_post']:.4f}) N={r['n']:5d} upazilas={r['g']}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> published (trim = 5th pctile) lmn +0.1088 (0.0223) N= 1673 upazilas=239
published (trim = 5th pctile) D_lmn +0.0326 (0.0163) N= 1434 upazilas=239
as shipped (trimL undefined) lmn +1.0636 (0.7097) N= 868 upazilas=124
as shipped (trimL undefined) D_lmn -0.5193 (0.2861) N= 744 upazilas=124
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="python_bridge_impact_18_trimL_forensics.png" alt="Two panels comparing the published nightlights estimate against the degenerate one produced by the shipped do-file, with sample sizes.">&lt;/p>
&lt;p>&lt;em>Figure 21. The published nightlights estimate against the one the shipped do-file actually produces. The coefficient is ten times larger and the standard error thirty times larger — but the tell is the sample: 124 upazilas where there should be 239.&lt;/em>&lt;/p>
&lt;p>The reproduction lands on 1.0636 (0.7097) against the archived &lt;code>nlite_mean.txt&lt;/code> value of 1.064 (0.710), on 868 observations and 124 upazilas — an exact match to the degenerate output sitting in the package. The published paper carries the correct numbers, which the package also contains in a parallel set of files named &lt;code>nlite2_*&lt;/code>. So the bug never reached print; it survives only in the shipped code.&lt;/p>
&lt;p>Every step in that chain is legal Stata. Nothing warns. The comparison group does not vanish with an error — it dissolves into missing values, and the regression cheerfully estimates a within-treated-group time contrast and calls it a treatment effect.&lt;/p>
&lt;p>The tell is not in the coefficient, which is merely large. It is in the footer: 124 upazilas where there should be 239. &lt;strong>The first thing to read in any regression output is the sample size.&lt;/strong> If you take one habit from this post, take that one.&lt;/p>
&lt;h3 id="182-a-one-row-shift-in-published-table-3">18.2 A one-row shift in published Table 3&lt;/h3>
&lt;p>Comparing the published Table 3 against &lt;code>results/did_vill.txt&lt;/code> shows the coefficient column slipping one row down from &amp;ldquo;Hospitals&amp;rdquo; onward. The paper dropped two rows — satellite clinics and madrassa schools — from the printed table but did not drop their coefficients.&lt;/p>
&lt;p>The printed &amp;ldquo;Cooperatives&amp;rdquo; short-run estimate of 0.420 (0.263) is in fact &lt;code>post_office&lt;/code>. The true &lt;code>co_operative_soc&lt;/code> short-run estimate is 0.090 (0.108). The N column follows the correct labels while the coefficients follow the original positions, so the misalignment is visible by cross-checking the two.&lt;/p>
&lt;p>No conclusion changes, because every estimate in the block is insignificant either way. But it is a good reminder that a replication which reproduced the &lt;em>printed&lt;/em> table rather than the underlying output would have concluded, wrongly, that it had failed.&lt;/p>
&lt;h3 id="183-where-the-text-and-the-table-disagree">18.3 Where the text and the table disagree&lt;/h3>
&lt;p>Two smaller inconsistencies, both in the article rather than the code.&lt;/p>
&lt;p>Section 8.1.2 states that long-run agricultural productivity gains are &amp;ldquo;strongest in the intermediate distance from the bridge&amp;rdquo;. Table 4 shows the farthest band at 0.265 against the middle band&amp;rsquo;s 0.065 — the farthest band dominates by a factor of four, and our replication confirms it.&lt;/p>
&lt;p>Section 7.3 describes the long-run effect on total agricultural labour as &amp;ldquo;a numerically small and statistically significant impact&amp;rdquo;. The estimate is $-0.017$ with a standard error near $0.021$, and the surrounding sentence — which says agricultural labour &amp;ldquo;gained back most of its lost ground&amp;rdquo; — only makes sense if the word should be &lt;em>insignificant&lt;/em>.&lt;/p>
&lt;h3 id="184-what-replication-is-for">18.4 What replication is for&lt;/h3>
&lt;p>None of the four items above changes a single conclusion of the paper. The bridge still raised luminosity, yields and services employment; density still rose; backwash is still rejected. That is the honest summary.&lt;/p>
&lt;p>But notice what made each of them findable. The &lt;code>$trimL&lt;/code> bug is visible because the authors shipped both the buggy output and the corrected output. The Table 3 shift is visible because they shipped &lt;code>did_vill.txt&lt;/code>. The text-table inconsistencies are visible because the tables are reproducible from the data.&lt;/p>
&lt;p>A paper that published only its conclusions would be opaque on all four counts, and a reader would have no way to tell an honest slip from a substantive error. The correct reaction to this section is not &amp;ldquo;the paper is unreliable&amp;rdquo;; it is that this paper is unusually &lt;em>checkable&lt;/em>, and that checkability is what made a 122-of-122 reproduction possible at all.&lt;/p>
&lt;h2 id="19-discussion">19. Discussion&lt;/h2>
&lt;p>The bridge worked, and it worked in a way that neither of the two textbook predictions anticipated.&lt;/p>
&lt;p>Nighttime lights rose 10.9 percent on average and 11.2 percent in the long run. Rice yields rose 7.9 percent in the long run. The services employment share rose 2.4 points. Population density fell 2.5 percent in the short run and then rose 5.9. Manufacturing&amp;rsquo;s share fell 1.2 points — a third of a small sector.&lt;/p>
&lt;p>Read the manufacturing number alone and you would write the backwash story. Read it alongside population density and you cannot: a region being hollowed out by its metropolitan neighbour does not gain residents. The pattern is what you get when a place stops making things it was never especially good at and starts doing more of what it was — growing rice, and moving, processing and trading what it grows.&lt;/p>
&lt;p>The spatial results carry the sharper policy lesson. Almost everything interesting happens away from the bridge. Yields in the farthest tercile rise 26.5 percent against 4.9 percent nearest; services employment rises 5.9 points farthest and &lt;em>falls&lt;/em> 2.6 points nearest. An evaluation that stopped at the average effect — or worse, that studied only the districts adjacent to the bridge, which is the intuitive place to look — would have produced a materially misleading answer.&lt;/p>
&lt;p>Three limitations deserve to be stated plainly.&lt;/p>
&lt;p>&lt;strong>Displacement.&lt;/strong> If the long-run density and luminosity gains partly reflect people leaving the still-isolated Padma hinterland, the comparison group is contaminated downward and these estimates are upper bounds. The authors say so themselves. It does not rescue the backwash story, which requires the &lt;em>treated&lt;/em> region to lose people, but it does mean the national welfare gain is smaller than the regional one — one region&amp;rsquo;s gain is partly another&amp;rsquo;s loss, even as regional inequality is magnified.&lt;/p>
&lt;p>&lt;strong>Thin clusters.&lt;/strong> The entire rice-yield result rests on nine to eleven former districts. Cluster-robust inference with nine clusters is fragile, and this is precisely where the two libraries&amp;rsquo; standard errors diverged most (0.023 against 0.034). Treat the yield magnitudes as indicative.&lt;/p>
&lt;p>&lt;strong>One pre-period for the census outcomes.&lt;/strong> Population density and the employment shares — including the density variable that settles the theoretical question — have exactly one pre-bridge observation. No pre-trend test is possible for them, only a level-balance test. The nightlights and yield panels support pre-trend testing and pass it, which is reassuring by association but is not the same as testing the outcome that does the work.&lt;/p>
&lt;h2 id="20-summary-and-next-steps">20. Summary and next steps&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Difference-in-differences is two subtractions.&lt;/strong> Everything else — fixed effects, controls, reweighting — is a refinement of four group means, and it is worth computing those four numbers by hand before running any estimator.&lt;/li>
&lt;li>&lt;strong>The assumption is about trends, not levels.&lt;/strong> Parallel trends permits the treated group to start anywhere; it requires only that it would have moved the same way. It is untestable in principle, which is why the post spends more effort bounding violations than testing for them.&lt;/li>
&lt;li>&lt;strong>An event study is a test and a result at once.&lt;/strong> The pre-treatment coefficients check the assumption; the post-treatment ones trace the effect. The nightlights event study — flat before, monotone climb after — carries more conviction than any single pooled coefficient.&lt;/li>
&lt;li>&lt;strong>Doubly robust means two chances, not immunity.&lt;/strong> Weighting protects you if the treatment model is right; regression adjustment protects you if the outcome model is right. Neither protects against a confounder you never measured.&lt;/li>
&lt;li>&lt;strong>Averages hide reversals.&lt;/strong> Population density was insignificant on average because it was negative then positive. Services employment was positive on average because a large gain far from the bridge outweighed a loss near it. Split by time and by space before believing a null.&lt;/li>
&lt;li>&lt;strong>Read the sample size first.&lt;/strong> The most instructive thing in the replication package is a bug that changed a coefficient from 0.109 to 1.064 without producing a single warning, and whose only visible symptom was 124 upazilas in a table that should have shown 239.&lt;/li>
&lt;/ol>
&lt;p>Where to go next. The design here is a clean two-group, single-date DiD, so heterogeneity-robust staggered estimators — Callaway and Sant&amp;rsquo;Anna, Sun and Abraham, and the imputation approaches — are not needed. They become essential the moment treatment timing varies across units, and &lt;code>diff-diff&lt;/code> implements all of them: &lt;code>CallawaySantAnna&lt;/code>, &lt;code>SunAbraham&lt;/code>, &lt;code>ImputationDiD&lt;/code>, &lt;code>StackedDiD&lt;/code>. A natural extension of this analysis is synthetic control (&lt;code>SyntheticControl&lt;/code>, &lt;code>SyntheticDiD&lt;/code>), which would build a weighted combination of Padma upazilas to match each Jamuna upazila&amp;rsquo;s pre-bridge luminosity path rather than reweighting on two covariates. And the spatial dimension invites a spillover-aware design: with &lt;code>SpilloverDiD&lt;/code> and the spatial HAC variance in &lt;code>diff_diff.conley&lt;/code>, one could ask whether the comparison hinterland was affected at all — the displacement concern from section 19, tested rather than assumed.&lt;/p>
&lt;h2 id="21-exercises">21. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Change the clustering level.&lt;/strong> Re-run the mean-effect nightlights DiD clustering on &lt;code>dist&lt;/code> rather than &lt;code>geocode&lt;/code>. Does the standard error on &lt;code>treat_post&lt;/code> rise or fall from 0.022? Which level is defensible, and what does the answer imply about the significance stars in Table 1?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Interrogate the plus one.&lt;/strong> The outcome is $\ln(mn + 1)$. Recompute the KOBDR mean effect with $\ln(mn + 0.01)$ and $\ln(mn + 5)$. How much of the 10.9 percent headline depends on that constant, and which upazilas drive the difference?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Reproduce the bug on purpose.&lt;/strong> Set the trimming cutoff so that every comparison unit fails it, and confirm you recover 1.064 (0.710) on 868 observations and 124 upazilas. Then write one sentence saying what that 1.064 is actually estimating.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Trim sensitivity.&lt;/strong> Re-estimate the nightlights mean effect trimming at 1, 5, 10 and 20 percent. Plot the KOBDR coefficient and its confidence interval against the trim fraction. At what point, if any, does the effect stop being significant, and how many comparison upazilas remain?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Two roads to the same number.&lt;/strong> Fit &lt;code>MultiPeriodDiD&lt;/code> on &lt;code>ldensity&lt;/code> with the three census years and 1991 as reference. Show that the two period effects equal the short-run and long-run coefficients of $-0.025$ and $+0.059$. Then try the same on the nightlights panel and explain why they do &lt;em>not&lt;/em> coincide there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Redefine the bands.&lt;/strong> The terciles pool treated and comparison upazilas on distance to the nearer bridge foot. Recut them using only the treated upazilas&amp;rsquo; distance to the Jamuna foot, assigning each comparison unit to its nearest treated neighbour&amp;rsquo;s band. Does the farthest-band long-run yield effect of 26.5 percent survive?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Stress the &amp;ldquo;doubly&amp;rdquo;.&lt;/strong> Rebuild both weight vectors with log mean rainfall added as a third covariate, and report how far the mean effects move. Then break the outcome model by dropping &lt;code>lmdist_t&lt;/code> while keeping correct weights, and separately break the weights while keeping the correct outcome model. Which failure does the estimator survive, and does that match the promise of double robustness?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>How much pre-trend can it take?&lt;/strong> Run &lt;code>compute_honest_did&lt;/code> with &lt;code>method=&amp;quot;smoothness&amp;quot;&lt;/code> instead of &lt;code>&amp;quot;relative_magnitude&amp;quot;&lt;/code>. Does the breakdown value move? Translate the answer into a sentence a minister could act on.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Swap the treatment.&lt;/strong> Pretend the Padma hinterland was treated in 1998 and the Jamuna hinterland was the comparison, holding everything else fixed. What sign should the estimate take, and what would you conclude if the placebo came back significant with the &lt;em>same&lt;/em> sign as the real estimate?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Rebuild Table 3 correctly.&lt;/strong> Using &lt;code>bridge_dhs_village.csv&lt;/code>, reproduce all twelve village public-goods regressions. Show that the published &amp;ldquo;Cooperatives&amp;rdquo; short-run coefficient of 0.420 is in fact &lt;code>post_office&lt;/code>, that &lt;code>co_operative_soc&lt;/code> is 0.090 (0.108), and produce the corrected table. Does the paper&amp;rsquo;s conclusion change?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="22-references">22. References&lt;/h2>
&lt;ol>
&lt;li>Blankespoor, B., Emran, M. S., Shilpi, F., &amp;amp; Xu, L. (2021). Bridge to bigpush or backwash? Market integration, reallocation and productivity effects of Jamuna Bridge in Bangladesh. &lt;em>Journal of Economic Geography&lt;/em>. Accepted 11 May 2021.&lt;/li>
&lt;li>Blankespoor, B., Emran, M. S., Shilpi, F., &amp;amp; Xu, L. (2018). Bridge to bigpush or backwash? Policy Research Working Paper 8508, The World Bank. &lt;a href="https://doi.org/10.1596/1813-9450-8508" target="_blank" rel="noopener">https://doi.org/10.1596/1813-9450-8508&lt;/a>&lt;/li>
&lt;li>Myrdal, G. (1957). &lt;em>Economic Theory and Underdeveloped Regions&lt;/em>. New York: Harper and Row.&lt;/li>
&lt;li>Krugman, P. (1991). Increasing returns and economic geography. &lt;em>Journal of Political Economy&lt;/em>, 99(3), 483-499. &lt;a href="https://doi.org/10.1086/261763" target="_blank" rel="noopener">https://doi.org/10.1086/261763&lt;/a>&lt;/li>
&lt;li>Fujita, M., &amp;amp; Thisse, J.-F. (2002). &lt;em>Economics of Agglomeration: Cities, Industrial Location, and Regional Growth&lt;/em>. Cambridge University Press.&lt;/li>
&lt;li>Baldwin, R., Forslid, R., Martin, P., Ottaviano, G., &amp;amp; Robert-Nicoud, F. (2005). &lt;em>Economic Geography and Public Policy&lt;/em>. Princeton University Press.&lt;/li>
&lt;li>Kline, P. (2011). Oaxaca-Blinder as a reweighting estimator. &lt;em>American Economic Review&lt;/em>, 101(3), 532-537. &lt;a href="https://doi.org/10.1257/aer.101.3.532" target="_blank" rel="noopener">https://doi.org/10.1257/aer.101.3.532&lt;/a>&lt;/li>
&lt;li>Kline, P., &amp;amp; Moretti, E. (2014). Local economic development, agglomeration economies, and the big push: 100 years of evidence from the Tennessee Valley Authority. &lt;em>Quarterly Journal of Economics&lt;/em>, 129(1), 275-331. &lt;a href="https://doi.org/10.1093/qje/qjt034" target="_blank" rel="noopener">https://doi.org/10.1093/qje/qjt034&lt;/a>&lt;/li>
&lt;li>Busso, M., Gregory, J., &amp;amp; Kline, P. (2013). Assessing the incidence and efficiency of a prominent place based policy. &lt;em>American Economic Review&lt;/em>, 103(2), 897-947. &lt;a href="https://doi.org/10.1257/aer.103.2.897" target="_blank" rel="noopener">https://doi.org/10.1257/aer.103.2.897&lt;/a>&lt;/li>
&lt;li>Robins, J. M., Rotnitzky, A., &amp;amp; Zhao, L. P. (1994). Estimation of regression coefficients when some regressors are not always observed. &lt;em>Journal of the American Statistical Association&lt;/em>, 89(427), 846-866. &lt;a href="https://doi.org/10.1080/01621459.1994.10476818" target="_blank" rel="noopener">https://doi.org/10.1080/01621459.1994.10476818&lt;/a>&lt;/li>
&lt;li>Wooldridge, J. M. (2007). Inverse probability weighted estimation for general missing data problems. &lt;em>Journal of Econometrics&lt;/em>, 141(2), 1281-1301. &lt;a href="https://doi.org/10.1016/j.jeconom.2007.02.002" target="_blank" rel="noopener">https://doi.org/10.1016/j.jeconom.2007.02.002&lt;/a>&lt;/li>
&lt;li>Callaway, B., &amp;amp; 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-230. &lt;a href="https://doi.org/10.1016/j.jeconom.2020.12.001" target="_blank" rel="noopener">https://doi.org/10.1016/j.jeconom.2020.12.001&lt;/a>&lt;/li>
&lt;li>Rambachan, A., &amp;amp; Roth, J. (2023). A more credible approach to parallel trends. &lt;em>Review of Economic Studies&lt;/em>, 90(5), 2555-2591. &lt;a href="https://doi.org/10.1093/restud/rdad018" target="_blank" rel="noopener">https://doi.org/10.1093/restud/rdad018&lt;/a>&lt;/li>
&lt;li>Roth, J. (2022). Pretest with caution: event-study estimates after testing for parallel trends. &lt;em>American Economic Review: Insights&lt;/em>, 4(3), 305-322. &lt;a href="https://doi.org/10.1257/aeri.20210236" target="_blank" rel="noopener">https://doi.org/10.1257/aeri.20210236&lt;/a>&lt;/li>
&lt;li>Donaldson, D. (2018). Railroads of the Raj: estimating the impact of transportation infrastructure. &lt;em>American Economic Review&lt;/em>, 108(4-5), 899-934. &lt;a href="https://doi.org/10.1257/aer.20101199" target="_blank" rel="noopener">https://doi.org/10.1257/aer.20101199&lt;/a>&lt;/li>
&lt;li>Faber, B. (2014). Trade integration, market size, and industrialization: evidence from China&amp;rsquo;s National Trunk Highway System. &lt;em>Review of Economic Studies&lt;/em>, 81(3), 1046-1070. &lt;a href="https://doi.org/10.1093/restud/rdu010" target="_blank" rel="noopener">https://doi.org/10.1093/restud/rdu010&lt;/a>&lt;/li>
&lt;li>Storeygard, A. (2016). Farther on down the road: transport costs, trade and urban growth in sub-Saharan Africa. &lt;em>Review of Economic Studies&lt;/em>, 83(3), 1263-1295. &lt;a href="https://doi.org/10.1093/restud/rdw020" target="_blank" rel="noopener">https://doi.org/10.1093/restud/rdw020&lt;/a>&lt;/li>
&lt;li>Ahsan, R., et al. (2008). Assessment of the economic impact of the Jamuna Multipurpose Bridge. Bangladesh Bridge Authority.&lt;/li>
&lt;li>World Bank (1994). &lt;em>Staff Appraisal Report: Bangladesh — Jamuna Bridge Project&lt;/em>. Washington, DC: The World Bank.&lt;/li>
&lt;li>DMSP-OLS Nighttime Lights Time Series, Version 4. NOAA National Centers for Environmental Information, Earth Observation Group. &lt;a href="https://www.ncei.noaa.gov/products/dmsp-operational-linescan-system" target="_blank" rel="noopener">https://www.ncei.noaa.gov/products/dmsp-operational-linescan-system&lt;/a>&lt;/li>
&lt;li>IPUMS International, Minnesota Population Center. Bangladesh population censuses 1991, 2001 and 2011. &lt;a href="https://international.ipums.org/international/" target="_blank" rel="noopener">https://international.ipums.org/international/&lt;/a>&lt;/li>
&lt;li>The DHS Program. Bangladesh Demographic and Health Surveys 1993, 1997, 2003, 2007, 2011, 2014; and Bangladesh Household Income and Expenditure Survey 1995/96, Bangladesh Bureau of Statistics. &lt;a href="https://dhsprogram.com/" target="_blank" rel="noopener">https://dhsprogram.com/&lt;/a>&lt;/li>
&lt;li>NOAA Precipitation Reconstruction over Land (PREC/L). NOAA Physical Sciences Laboratory. &lt;a href="https://psl.noaa.gov/data/gridded/data.precl.html" target="_blank" rel="noopener">https://psl.noaa.gov/data/gridded/data.precl.html&lt;/a>&lt;/li>
&lt;li>&lt;code>diff-diff&lt;/code>: Difference-in-Differences causal inference in Python. Documentation: &lt;a href="https://diff-diff.readthedocs.io" target="_blank" rel="noopener">https://diff-diff.readthedocs.io&lt;/a>. Source: &lt;a href="https://github.com/igerber/diff-diff" target="_blank" rel="noopener">https://github.com/igerber/diff-diff&lt;/a>&lt;/li>
&lt;li>&lt;code>pyfixest&lt;/code>: Fast high-dimensional fixed effects regression in Python. &lt;a href="https://py-econometrics.github.io/pyfixest/" target="_blank" rel="noopener">https://py-econometrics.github.io/pyfixest/&lt;/a>&lt;/li>
&lt;/ol>
&lt;h2 id="acknowledgements">Acknowledgements&lt;/h2>
&lt;p>This tutorial replicates work by Brian Blankespoor, M. Shahe Emran, Forhad Shilpi and Lu Xu, whose complete and well-documented replication package made a coefficient-by-coefficient audit possible. All errors in the Python port are mine.&lt;/p></description></item></channel></rss>