---
title: "Simulation evidence: what each estimator recovers and when it fails"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Simulation evidence: what each estimator recovers and when it fails}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---



This vignette preserves the original 0.1.0 worked examples. Its homogeneous
effects and transformed-outcome comparisons are not the current research
validation. See `vignette("research-validation", package = "streetlamp")`
and `inst/validation/research-upgrade` for explicit count-mean targets,
heterogeneous cohorts, dynamic effects and Monte Carlo uncertainty.

Every estimator in this package returns a number. This vignette is about
which of those numbers are right, under what conditions, and how each one
fails when its assumption does not hold. It uses `lamp_simulate()`, where the
answer is known by construction.


``` r
library(streetlamp)
```

## The generator


``` r
sim <- lamp_simulate(
  n_areas = 100, n_months = 30, design = "event",
  effect = -0.25, base_rate = 30, missing = 0.05, seed = 1
)
truth <- attr(sim, "truth")
str(truth[c("design", "effect", "effect_crime_total", "spillover_effect", "placebo_type")])
#> List of 5
#>  $ design            : chr "event"
#>  $ effect            : num -0.25
#>  $ effect_crime_total: num -0.228
#>  $ spillover_effect  : num 0
#>  $ placebo_type      : chr "bicycle_theft"
```

Crime counts are Poisson draws around area and month fixed effects. The
treatment multiplies the mean of every crime type except bicycle theft by
`exp(effect)`, so the effect on the total is smaller in size than `effect`,
and bicycle theft is a genuine placebo. A share of force-months is marked
unsubmitted, with `NA` counts, exactly as the real archive has.

## Each estimator against its own design


``` r
recover <- function(design, seed) {
  s <- lamp_simulate(
    n_areas = 120, n_months = 30, design = design,
    effect = -0.25, spillover_effect = 0.12, base_rate = 40, seed = seed
  )
  tr_truth <- attr(s, "truth")
  target <- tr_truth$effect_crime_total

  if (design == "staggered") {
    ad <- tr_truth$adoption
    tr <- lamp_treatment(s, "staggered", adoption = ad[!is.na(ad$adoption_month), ])
    fit <- suppressWarnings(lamp_did_staggered(s, "crime_total", tr))
    est <- fit$diagnostics$overall$estimate
    label <- "lamp_did_staggered"
  } else if (design == "spillover") {
    adj <- lamp_adjacency_from_nb(tr_truth$nb, tr_truth$lattice$area)
    tr <- lamp_treatment(s, "event", date = tr_truth$event_date, scope = tr_truth$treated_areas)
    fit <- lamp_spillover(s, "crime_total", tr, adjacency = adj, rings = 1)
    est <- fit$diagnostics$net$estimate
    target <- target + log((12 * exp(tr_truth$spillover_effect) + 1) / 13)
    label <- "lamp_spillover (net)"
  } else {
    tr <- lamp_treatment(s, "event", date = tr_truth$event_date, scope = tr_truth$treated_areas)
    fit <- lamp_twfe(s, "crime_total", tr)
    est <- fit$coefficients$estimate[1]
    label <- "lamp_twfe"
  }
  data.frame(design = design, estimator = label, truth = target, estimate = est, error = est - target)
}

do.call(rbind, list(
  recover("event", 11),
  recover("staggered", 12),
  recover("spillover", 13)
))
#> You have a balanced panel. Setting allow_unbalanced_panel = FALSE.
#>      design            estimator      truth   estimate        error
#> 1     event            lamp_twfe -0.2283871 -0.2094578  0.018929337
#> 2 staggered   lamp_did_staggered -0.2283871 -0.2185879  0.009799284
#> 3 spillover lamp_spillover (net) -0.1171236 -0.1183379 -0.001214289
```

Each estimator, given the design it was built for, lands close to the truth.

## Failure one: the wrong estimator for staggered adoption


``` r
s <- lamp_simulate(n_areas = 120, n_months = 36, design = "staggered", effect = -0.3, base_rate = 50, seed = 21)
tr_truth <- attr(s, "truth")
ad <- tr_truth$adoption
tr <- lamp_treatment(s, "staggered", adoption = ad[!is.na(ad$adoption_month), ])

right <- suppressWarnings(lamp_did_staggered(s, "crime_total", tr))
#> You have a balanced panel. Setting allow_unbalanced_panel = FALSE.
wrong <- suppressWarnings(lamp_twfe(s, "crime_total", tr, family = "ols_log"))
data.frame(
  estimator = c("lamp_did_staggered", "lamp_twfe"),
  estimate = c(right$diagnostics$overall$estimate, wrong$coefficients$estimate[1]),
  truth = tr_truth$effect_crime_total
)
#>            estimator   estimate      truth
#> 1 lamp_did_staggered -0.2331457 -0.2734435
#> 2          lamp_twfe -0.2728181 -0.2734435
```

Two-way fixed effects uses already-treated areas as controls. It is closer to
zero here, and there are data-generating processes where it has the wrong
sign entirely. `lamp_twfe()` warns when the treatment has several adoption
dates, and `lamp_event_study()` refuses outright.

## Failure two: ignoring a spillover


``` r
sp <- lamp_simulate(
  n_areas = 100, n_months = 30, design = "spillover",
  effect = -0.3, spillover_effect = 0.2, base_rate = 40, seed = 22
)
sp_truth <- attr(sp, "truth")
adj <- lamp_adjacency_from_nb(sp_truth$nb, sp_truth$lattice$area)
tr_sp <- lamp_treatment(sp, "event", date = sp_truth$event_date, scope = sp_truth$treated_areas)

with_spill <- lamp_spillover(sp, "crime_total", tr_sp, adjacency = adj, rings = 1)
without <- lamp_twfe(sp, "crime_total", tr_sp)
data.frame(
  estimator = c("lamp_spillover (own area)", "lamp_twfe (no spillover term)"),
  estimate = c(with_spill$coefficients$estimate[1], without$coefficients$estimate[1])
)
#>                       estimator    estimate
#> 1     lamp_spillover (own area) -0.16489810
#> 2 lamp_twfe (no spillover term) -0.09837556
```

When the intervention pushes crime into the control areas, the comparison
group moves the wrong way and the plain estimate understates the effect.

## Failure three: treating an allocation response as an effect

Police surge where crime is spiking now, so searching and crime are
determined together. That is simultaneity, and it biases the elasticity
upward: areas and months with unusually high crime also have unusually high
searching.


``` r
ec <- lamp_simulate(n_areas = 60, n_months = 40, design = "continuous", effect = -0.2, base_rate = 30, seed = 23)
clean <- lamp_elasticity(ec, "crime_total", lags = 0)$diagnostics$long_run$estimate

# the same panel, with officers sent to this month's hot spots
responsive <- ec
responsive$stops <- as.integer(responsive$stops + round(0.6 * responsive$crime_total))
contaminated <- lamp_elasticity(responsive, "crime_total", lags = 0)$diagnostics$long_run$estimate

data.frame(
  panel = c("searching independent of crime", "searching sent to this month's hot spots"),
  elasticity = c(clean, contaminated),
  allocation_response = c(
    lamp_allocation(ec, crime_lags = 1)$diagnostics$total$estimate,
    lamp_allocation(responsive, crime_lags = 1)$diagnostics$total$estimate
  )
)
#>                                      panel elasticity allocation_response
#> 1           searching independent of crime -0.1544429         0.049056850
#> 2 searching sent to this month's hot spots  0.9243121         0.002690155
```

The true deterrent effect is identical in both panels: the second one simply
has police responding to crime as well. The estimated elasticity moves sharply
toward zero, and can cross it, because the regression reports the net of the
deterrent effect and the deployment response. The allocation column is the
warning sign, which is why `lamp_allocation()` belongs beside every
elasticity.

## Missing force-months are not zeros

A force changes its records system and stops submitting for a stretch of
months. The archive simply has no file, and what is done with that absence
decides what the panel says.


``` r
gappy <- lamp_simulate(
  n_areas = 100, n_months = 30, design = "event", effect = -0.25,
  base_rate = 30, seed = 24
)
g_truth <- attr(gappy, "truth")

# one force submits nothing for the last eight months
late <- sort(unique(gappy$month))[23:30]
gone <- gappy$force_id == "sim-south" & gappy$month %in% late
count_cols <- setdiff(lamp_crime_types()$key, "anti_social_behaviour")
for (k in c(count_cols, "crime_total")) gappy[[k]][gone] <- NA_integer_
gappy$coverage_status[gone] <- "missing"

as_zero <- gappy
for (k in c(count_cols, "crime_total")) as_zero[[k]][is.na(as_zero[[k]])] <- 0L
as_zero$coverage_status <- "submitted"

south <- function(d) {
  s <- d[d$force_id == "sim-south", ]
  round(tapply(s$crime_total, s$month, mean, na.rm = TRUE)[c(20, 24, 28)], 1)
}
rbind(`NA` = south(gappy), `zero-filled` = south(as_zero))
#>             2019-08-01 2019-12-01 2020-04-01
#> NA                25.5        NaN        NaN
#> zero-filled       25.5          0          0
```

Zero-filling invents a collapse in that force's crime. Any level statistic,
any trend line, any map of the last eight months is now wrong, and nothing in
the table says so.


``` r
tr_g <- lamp_treatment(gappy, "event", date = g_truth$event_date, scope = g_truth$treated_areas)
data.frame(
  handling = c("NA, dropped and counted", "zero-filled"),
  estimate = c(
    lamp_twfe(gappy, "crime_total", tr_g)$coefficients$estimate[1],
    lamp_twfe(as_zero, "crime_total", tr_g)$coefficients$estimate[1]
  ),
  truth = g_truth$effect_crime_total,
  rows_dropped = c(lamp_twfe(gappy, "crime_total", tr_g)$diagnostics$n_dropped_coverage, 0)
)
#>                  handling   estimate      truth rows_dropped
#> 1 NA, dropped and counted -0.2187129 -0.2283871          400
#> 2             zero-filled -0.2212403 -0.2283871            0
```

The difference-in-differences estimate survives here, but only by luck: this
force holds treated and control areas in equal measure, so the fictitious drop
cancels. When a surge is force-wide, which is the usual case, the gap falls
entirely on one side of the comparison and the estimate goes with it. The
package does not rely on that luck: `lamp_panel()` writes `NA`, never zero,
for a month with no file, and every estimator reports how many rows it
dropped.

## Do the intervals mean what they say?


``` r
covered <- vapply(seq_len(40), function(i) {
  s <- lamp_simulate(
    n_areas = 60, n_months = 24, design = "event", effect = -0.2,
    base_rate = 25, seed = 500 + i
  )
  t_i <- attr(s, "truth")
  tr_i <- lamp_treatment(s, "event", date = t_i$event_date, scope = t_i$treated_areas)
  f <- lamp_twfe(s, "crime_total", tr_i)
  f$coefficients$conf_low[1] <= t_i$effect_crime_total &&
    t_i$effect_crime_total <= f$coefficients$conf_high[1]
}, logical(1))
c(replications = length(covered), covered = sum(covered), rate = mean(covered))
#> replications      covered         rate 
#>       40.000       37.000        0.925
```

A 95 percent interval should contain the truth about 95 times in 100. The full
study across all designs, with and without missingness, is in
`inst/scripts/01-simulation-study.R`, and its output in `inst/validation/`.

## Which estimator when

| Situation | Use | Why not the others |
|---|---|---|
| One date, one set of treated areas | `lamp_event_study()`, then `lamp_twfe()` for a summary | Nothing else is needed |
| Areas adopt at different dates | `lamp_did_staggered()` | Two-way fixed effects uses already-treated areas as controls |
| Treatment may move crime next door | `lamp_spillover()`, `lamp_displacement_quotient()` | Others count the displacement as a success |
| One treated force or area, many donors | `lamp_synth()` | Fixed effects cannot build a counterfactual from one unit |
| Continuous intensity, no discrete event | `lamp_elasticity()` with `lamp_allocation()` | Read the pair, never the elasticity alone |
| Areas move together over time | Consider `lamp_elasticity(method = "cce_mg")` on the legacy response scale | Shared movements need investigation; a CD statistic alone does not establish invalid clustered intervals |

Every row assumes the treatment is not simply a response to the outcome. When
it is, and for stop and search it usually is, none of these estimators
recovers an effect, and the honest output is the association plus the
allocation model that explains why it is not more than that.
