Generalized synthetic control — age verification laws (Lang et al. 2026)#
- Estimator:
Generalized Synthetic Control (GSYNTH) — generalized synthetic control, no additive effects, rank chosen by Xu’s Algorithm 1.
- Source:
Lang, D., Listyg, B., Ross, B. V., Musquera, A. V., & Sanderson, Z. (2026), “Age Verification and Public Adaptation: A Pre-Registered Synthetic Control Multiverse,” Journal of Law and Empirical Analysis 3(1):23-50, 10.1177/2755323X251415424. Replication package: davidnathanlang/internet_regulation_synth_project.
- Replication type:
Cross-validation against a commit-pinned
gsynth1.2.1 — the version the authors ran — with the published Table 2 carried as a secondary, looser comparison.- Status:
Verified against the reference at machine precision over 96 fits, and on Algorithm 1’s criterion and its selected rank in all sixteen outcome-by-force combinations. Three of the four published ATTs reproduce within 0.15; the fourth misses by 0.70 because the paper’s rank is not the one the paper’s own cross-validation selects.
What the paper estimates#
Between 2023 and 2024, fourteen US states began requiring age verification to access pornographic websites. The stated aim is to keep minors off those sites. The question the paper asks is what adults did in response, and it answers it from Google Trends: weekly state-level search interest in four terms, each standing for a different adaptation.
pornhub is a platform that complied by blocking the state outright, so
searches for it measure the law biting. xvideos is a platform that did not
comply, so searches for it measure substitution toward a site still reachable.
vpn measures circumvention — a virtual private network makes traffic appear
to originate elsewhere, defeating the check. porn is the generic term, and
measures whether the category-level demand moved at all.
The pre-registered finding is that the laws redirected traffic without reducing it. Pornhub searches fall about 34 points on the 0–100 Trends scale, xvideos rises about 28, VPN interest rises about 13, and generic pornography searches move about 3.
Why this panel#
Generalized synthetic control — Election Day Registration (Xu 2017) — the estimator’s own Xu (2017) replication — is a 47-state panel of 24 quadrennial elections with four adoption dates. This one is 46 states by 149 weekly periods with ten adoption dates and fourteen adopters. Six times the periods, weekly frequency instead of four-yearly, staggering that is genuinely staggered, and four outcomes whose signal-to-noise ranges over a factor of three.
That is the reason it is here. An estimator can look correct on a short panel with a near-simultaneous treatment and still be wrong about how it aligns event time, how it averages across cohorts, or how it holds out a period for cross-validation. This panel exercises all three.
The reference#
gsynth 1.2.1 is the version the paper used, and the last release before the
package became a shell over fect. Installing current gsynth would run a
different codebase than the paper did, so
benchmarks/R/install_gsynth_121.sh pins the commit behind the 1.2.1
tag, along with pinned lfe and nanoparquet.
The two generations agree here, so either could have served. At matched rank
and force, gsynth 1.2.1 and fect 2.4.5 return the same numbers to fect’s
print precision — pornhub −35.185 both ways, xvideos 28.403, vpn 11.435, porn
3.018. This case pins the one the authors ran, so a disagreement is about
mlsynth and not about a version gap.
The R side reads basedata/lang_av_laws.parquet through nanoparquet, the
same file the Python side reads, so the two cannot run on different inputs.
Results#
Four outcomes by four force settings by ranks zero through five is 96 fits.
Each is compared on three quantities: the overall ATT, the mean over the
paper’s estimand window, and the pre-treatment mean absolute error.
Quantity |
Fits compared |
Max \(|\) mlsynth − gsynth 1.2.1 \(|\) |
|---|---|---|
Overall ATT |
96 |
6.4e-14 |
ATT over the paper’s window |
96 |
4.6e-14 |
Pre-treatment MAE |
96 |
2.8e-14 |
Algorithm 1 criterion, by rank |
96 |
1.3e-12 |
Rank Algorithm 1 selects |
16 |
all agree |
The criterion agrees far more tightly here than on the turnout panel, where the
two implementations separate by 7.5e-4. That case fits covariates, so its
alternating least squares iterates and fect’s looser in-cross-validation
tolerance bites. This panel has no covariates, so there is nothing to iterate
and the tolerance never applies.
The published table#
Table 2’s four ATTs are carried at a much looser tolerance, and the reason is a finding.
Outcome |
Published ATT |
At the CV rank |
Published MAE |
mlsynth MAE |
|---|---|---|---|---|
|
27.85 |
27.91 |
1.17 |
1.15 |
|
3.48 |
3.59 |
0.76 |
0.77 |
|
13.33 |
13.18 |
2.29 |
2.34 |
|
−33.84 |
−33.14 |
0.70 |
0.78 |
Three of four land within 0.15 on a 0–100 scale, and the pre-treatment errors
line up to 0.08 across the board, which is what confirms the specification was
identified correctly — the MAE column depends on the donor pool, the rank, the
force setting and the pre-window all at once, so four of them agreeing is
not something a differently specified fit produces.
Pornhub misses by 0.70, and the cause is rank selection. The published figure sits between \(r = 2\) (−33.93) and \(r = 3\) (−33.48), while Algorithm 1 selects \(r = 5\) (−33.14). Every published value lies inside its outcome’s \(r = 0, \dots, 5\) band, so the gap is a choice of rank and not a different estimator.
Three other explanations were tested and ruled out:
Data vintage. The upstream repository has three committed versions of the Trends pulls. The paper’s analysis window is byte-identical across the two that contain it, so it does not matter which is checked out.
Package generation. gsynth 1.2.1 and fect 2.4.5 agree to print precision, as above.
Donor pool. The authors’
03_preregistered_hypotheses.Rdrops five states (ND, MO, AZ, OH, GA). Running with and without that exclusion moves the estimates by 0.1 to 0.5 — wrong direction and wrong size to explain 0.70.
Re-running the authors’ call verbatim, with se = TRUE,
inference = "parametric" and seed = 12345, still selects \(r = 5\).
So published_att_max_diff is held to 0.8: loose enough to admit the pornhub
rank gap, tight enough that a broken estimator could not pass it, since the four
outcomes span −34 to +28. This is not Path A. Pinning the printed values tightly
would pin something neither implementation returns.
Two incidental findings#
Reading the authors’ script closely turned up two things that do not change the published conclusions but do affect anyone re-running it.
cumuEff(period = c(0, 12))$est.catt returns 65 rows, not 13, so the
downstream filter(rn + 1 == 13) does not select what its form suggests. And
at line 119, hyperparameter_search(keyword = keyword) lets the function’s
own default for time_range shadow the value set at the top of the script.
Both were found by trying to reproduce the estimand exactly, which is the kind of thing a cross-validation case surfaces and a headline-number comparison does not.
Two index conventions#
The estimand is the mean over gsynth relative periods 0 through 12. gsynth numbers the last pre-treatment period 0 and the first treated period 1; mlsynth numbers the first treated period 0. The same thirteen cells are therefore mlsynth’s horizons −1 through 11.
The pre-treatment window differs too. gsynth’s relative-time index is truncated
below at the earliest horizon where every treated unit is still observed — −53
here, the first adopter having 54 pre-periods — so its MAE is a balanced-window
average. mlsynth’s event study reports the full range, down to −145, where a
single late adopter carries the average alone. Those thin horizons are noisier,
and including them inflates the MAE by a third. The case restricts to the
balanced block, which n_units marks exactly.
Reading the data#
basedata/lang_av_laws.parquet is data/{pornhub,xvideos,vpn,porn}.csv
from the authors’ repository at commit 38ab54b, restricted to the paper’s
analysis window (time == "2022-01-01 2024-10-31") and dropping the five
states the authors’ own script drops. Each of the four frames is 46 × 149 and
balanced; fourteen states adopt across ten staggered dates and thirty-two never
do, and adoption is absorbing.
Running the reference#
The committed gold under benchmarks/reference/gsynth_av_laws/ is what the
case reads by default, so it runs in CI without R. To re-run the reference
live:
bash benchmarks/R/install_gsynth_121.sh
MLSYNTH_BENCH_LIVE_R=1 python benchmarks/run_benchmarks.py \
--case gsynth_av_laws
That regenerates the gold into a temporary directory and checks it against the committed copy. A regenerated run currently reproduces it at 0.0, and the case raises if that moves by more than 1e-8, because a row that exists only when R does could not be pinned and an unpinned row is one nobody checks.
Case#
benchmarks/cases/gsynth_av_laws.py. Every distance row is against the
reference, so a regression moves it and cannot be absorbed by re-fitting.