streetlamp is an R package for estimating the effects of police stop and search activity on recorded crime in England and Wales, published by Black Thrive Global. It builds an area-by-month panel of recorded crime from the data.police.uk archive, attaches stop and search intensity as a treatment variable, and provides a panel-econometrics toolkit for evaluating policing interventions: event studies, difference-in-differences with staggered adoption, synthetic control, spatial spillover and displacement models, and stop-crime elasticities by offence type.
streetlamp is about what stops achieve, not who is stopped. It never computes an ethnic disparity measure and reads stop and search files only to count stops by area and month.
Installation
Install the released version from CRAN:
install.packages("streetlamp")or the development version from GitHub:
# install.packages("pak")
pak::pak("BlackThrive/streetlamp")Status
Version 0.2.0 adds count-mean elasticities, calendar-aware lags, heterogeneous simulation designs, covariance-aware effect summaries, design audits, complete robustness grids and maps. The validation evidence for 0.1.0 is retained in inst/validation, and the evidence for 0.2.0 is in inst/validation/research-upgrade. A causal empirical case still needs independently documented intervention assignment: the package audits and estimates associations, and labels them as such.
What it estimates
| Question | Function |
|---|---|
| Did crime fall after a dated intervention? |
lamp_event_study(), lamp_twfe()
|
| Areas adopted at different dates | lamp_did_staggered() |
| Did crime move next door instead? |
lamp_spillover(), lamp_displacement_quotient()
|
| One treated force, many comparison areas | lamp_synth() |
| How does crime respond to search intensity? | lamp_elasticity() |
| How does searching follow crime? | lamp_allocation() |
| How many crimes per thousand searches? | lamp_crimes_prevented() |
| Is the design supported by the observed data? | lamp_design_audit() |
| Is the result sensitive to specifications or individual areas? | lamp_elasticity_robustness() |
| What is the uncertainty of an average or sum of effects? | lamp_effect_summary() |
| How would an assumed differential trend change an event estimate? | lamp_trend_sensitivity() |
| Is the estimate worth anything? |
lamp_pretrends(), lamp_placebo()
|
lamp_elasticity(method = "fe") now defaults to Poisson pseudo-likelihood for the conditional count mean. Use family = "ols_log" for the former response scale, which CCE methods retain. The covariate is log(1 + stops): its slope is not automatically a conventional stops elasticity. At stop intensity S, a count-mean slope becomes slope * S / (1 + S). Observational associations do not identify the effect of deciding to search more.
Quick start on the bundled panel
The package ships a ready-made panel: every 2021 LSOA in West Yorkshire and Dyfed-Powys over August 2024 to July 2026, with crime by type, anti-social behaviour, stops, population and deprivation deciles, and a contract that records where every number came from.
library(streetlamp)
panel <- lamp_sample_panel()
panel
#> streetlamp panel: 1710 lsoa21 area x 24 months; see `lamp_contract()` and
#> `lamp_coverage()`.
#> # A tibble: 41,040 × 27
#> area month force_id bicycle_theft burglary criminal_damage_and_…¹ drugs
#> <chr> <date> <chr> <int> <int> <int> <int>
#> 1 E010… 2024-08-01 west-yo… 0 0 2 0
#> 2 E010… 2024-09-01 west-yo… 0 0 0 0
#> 3 E010… 2024-10-01 west-yo… 0 0 1 0
#> 4 E010… 2024-11-01 west-yo… 0 4 2 0
#> 5 E010… 2024-12-01 west-yo… 0 0 1 0
#> 6 E010… 2025-01-01 west-yo… 0 2 1 0
#> 7 E010… 2025-02-01 west-yo… 0 1 1 0
#> 8 E010… 2025-03-01 west-yo… 0 2 0 0
#> 9 E010… 2025-04-01 west-yo… 0 2 0 0
#> 10 E010… 2025-05-01 west-yo… 0 0 0 0
#> # ℹ 41,030 more rows
#> # ℹ abbreviated name: ¹criminal_damage_and_arson
#> # ℹ 20 more variables: other_crime <int>, other_theft <int>,
#> # possession_of_weapons <int>, public_order <int>, robbery <int>,
#> # shoplifting <int>, theft_from_the_person <int>, vehicle_crime <int>,
#> # violence_and_sexual_offences <int>, crime_total <int>, asb <int>,
#> # stops <int>, stops_s60 <int>, population <dbl>, stop_rate <dbl>, …
lamp_contract(panel)
# Coverage: which force-months exist, and where stop files are missing while
# crime files are present
cov <- lamp_coverage(panel)
table(cov$file_type, cov$status)
#>
#> missing submitted
#> stop-and-search 8 40
#> street 0 48
lamp_coverage_compare(cov, c("2024-08", "2025-07"), c("2025-08", "2026-07"))
#> # A tibble: 2 × 12
#> force_id n_months_a n_usable_a n_months_b n_usable_b n_partial_a n_partial_b
#> <chr> <int> <int> <int> <int> <int> <int>
#> 1 dyfed-pow… 12 12 12 12 0 0
#> 2 west-york… 12 12 12 12 0 0
#> # ℹ 5 more variables: n_refreshed_a <int>, n_refreshed_b <int>,
#> # n_mismatch_a <int>, n_mismatch_b <int>, comparable <lgl>
# Spatial structure for the spillover estimators
adj <- lamp_adjacency(lamp_sample_boundaries())
adj
#> streetlamp adjacency: 1710 areas, queen contiguity, 8214 links, 0 islands.The plot methods draw force-month totals with missing months shaded (plot(panel)) and the coverage grid as a heat map (plot(cov)).
Estimating, and checking the estimate
# a dated intervention on a simulated panel whose true effect is known
sim <- lamp_simulate(n_areas = 60, n_months = 24, design = "event", effect = -0.25, seed = 1)
truth <- attr(sim, "truth")
tr <- lamp_treatment(sim, "event", date = truth$event_date, scope = truth$treated_areas)
fit <- lamp_twfe(sim, "crime_total", tr)
fit
#>
#> ── streetlamp estimate: lamp_twfe
#> Outcome: crime_total; family: Poisson pseudo-likelihood on counts
#> Treatment: event; clustered by area
#> Identifying assumption: Parallel trends: treated and control areas would have
#> moved together in the outcome, net of area and month fixed effects.
#> Sample: 1440 area-months in 60 areas; 0 rows dropped for coverage.
#> Dispersion: 0.942
#> Term Estimate Std. error 95% CI p
#> ─────────────── ──────── ────────── ──────────────── ──────
#> Treated × after −0.242 0.0386 [−0.318, −0.166] <0.001
# the true effect on the crime total, for comparison
truth$effect_crime_total
#> [1] -0.2283871Every estimate prints the assumption it rests on and the force-months it had to drop. The diagnostics are the point of the package as much as the estimators are:
es <- lamp_event_study(sim, "crime_total", tr, window = c(-6, 6))
# how large a pre-trend would this test have missed, and what would it cost?
lamp_pretrends(es)$power
#> # A tibble: 2 × 3
#> power slope bias_mean_post
#> <dbl> <dbl> <dbl>
#> 1 0.5 0.0376 0.150
#> 2 0.8 0.0509 0.204
# does the specification produce effects where none exist?
lamp_placebo(fit, type = "space", n = 50, seed = 1)$p_value
#> [1] 0Reading the results honestly
Police send officers where crime has risen, so searching responds to crime as well as possibly reducing it. lamp_allocation() measures that channel, and nothing else in the package should be read without it.
lamp_allocation(sim, crime_lags = 1:3)$diagnostics$interpretation
#> [1] "Searching falls where recent crime has risen, which is the opposite of the usual allocation pattern and worth checking before relying on it."inst/NOTES/methods.md sets out the equations, the assumption behind each estimator, and the limits that apply to all of them: recording practice, anonymised locations, and the fact that recorded crime is what the police wrote down.
Reading the archive
The data.police.uk archive publishes one zip per month, each 1 to 2.6 GB. streetlamp reads a zip’s table of contents with an HTTP byte-range request and fetches only the force-month files you ask for, so a three-year pull for two forces is tens of megabytes. The package ships two miniature archive snapshots (real records for a subset of West Yorkshire and Dyfed-Powys neighbourhoods, May to July 2026), so everything below runs offline.
cache <- tempfile("streetlamp-cache-")
zips <- list.files(
system.file("extdata", "archive", package = "streetlamp"),
pattern = "zip$", full.names = TRUE
)
for (z in zips) lamp_archive_register(z, dir = cache)
#> Registered archive "2026-06" from
#> '.../2026-06.zip'.
#> Registered archive "2026-07" from
#> '.../2026-07.zip'.
snap <- lamp_archive_snapshot(cache)
snap
#>
#> ── streetlamp archive snapshot
#> Cache:
#> '.../streetlamp-cache-7c741d3f28c5'
#> 2 archives: "2026-06" and "2026-07"
#> 25 force-month files covering 2 forces, 2026-05 to 2026-07; 25 available
#> locally.
#> outcomes: 10 of 10 available
#> stop-and-search: 5 of 5 available
#> street: 10 of 10 available
# The same force-month can appear in several snapshots with different content
versions <- lamp_list_versions(snap)
versions[versions$differs, c("force_id", "month", "file_type", "n_versions")]
#> # A tibble: 7 × 4
#> force_id month file_type n_versions
#> <chr> <date> <chr> <int>
#> 1 dyfed-powys 2026-05-01 outcomes 2
#> 2 dyfed-powys 2026-05-01 street 2
#> 3 dyfed-powys 2026-06-01 outcomes 2
#> 4 dyfed-powys 2026-06-01 street 2
#> 5 west-yorkshire 2026-05-01 outcomes 2
#> 6 west-yorkshire 2026-05-01 street 2
#> 7 west-yorkshire 2026-06-01 street 2
lamp_version_diff(snap, "west-yorkshire", "2026-05", "outcomes")
#> # A tibble: 2 × 5
#> archive n_rows n_ids ids_not_in_others crc32
#> <chr> <int> <int> <int> <chr>
#> 1 2026-06 1761 1728 3 efe93414
#> 2 2026-07 1756 1725 0 34133345
# Readers return tibbles with a fixed schema and a contract
crime <- lamp_read_crime(snap, forces = "dyfed-powys")
table(crime$crime_type)
#>
#> Anti-social behaviour Bicycle theft
#> 317 16
#> Burglary Criminal damage and arson
#> 99 286
#> Drugs Other crime
#> 77 71
#> Other theft Possession of weapons
#> 170 29
#> Public order Robbery
#> 227 8
#> Shoplifting Theft from the person
#> 169 14
#> Vehicle crime Violence and sexual offences
#> 47 1342
#> Public disorder and weapons
#> 0
lamp_contract(crime)$coverage
#> # A tibble: 3 × 8
#> force_id month file_type status archive n_records n_versions
#> <chr> <date> <chr> <chr> <chr> <int> <int>
#> 1 dyfed-powys 2026-05-01 street submitted 2026-07 968 2
#> 2 dyfed-powys 2026-06-01 street submitted 2026-07 925 2
#> 3 dyfed-powys 2026-07-01 street submitted 2026-07 979 1
#> # ℹ 1 more variable: versions_differ <lgl>
stops <- lamp_read_stop_counts(snap)
stops
#> streetlamp records with a contract; see `lamp_contract()`.
#> # A tibble: 3 × 8
#> area force_id month archive stops stops_s60 stops_no_location
#> <chr> <chr> <date> <chr> <int> <int> <int>
#> 1 west-yorkshire west-york… 2026-05-01 2026-07 1254 0 10
#> 2 west-yorkshire west-york… 2026-06-01 2026-07 1095 0 10
#> 3 west-yorkshire west-york… 2026-07-01 2026-07 1130 0 10
#> # ℹ 1 more variable: stops_no_legislation <int>With network access, lamp_archive_download() fetches force-month files from the live archive into the cache, lamp_boundaries() and lamp_population() fetch ONS boundaries and Census 2021 population once, and lamp_panel() builds the balanced panel:
snap <- lamp_archive_download(
archives = "latest",
months = c("2026-05", "2026-06", "2026-07"),
forces = c("west-yorkshire", "dyfed-powys")
)
crime <- lamp_read_crime(snap)
stops <- lamp_read_stop_counts(snap, area = "lsoa21", boundaries = lamp_boundaries("lsoa21"))
panel <- lamp_panel(crime, stops = stops, population = lamp_population("lsoa21"))Bundled reference tables
Everything in the package runs without network access at check time. The reference tables shipped with it are verified against their sources, as recorded in inst/NOTES/data_sources.md.
library(streetlamp)
# The fourteen data.police.uk categories with package groupings
lamp_crime_types()
#> # A tibble: 14 × 8
#> crime_type key api_slug is_asb in_crime_total group broad_group since
#> <chr> <chr> <chr> <lgl> <lgl> <chr> <chr> <date>
#> 1 Anti-socia… anti… anti-so… TRUE FALSE asb asb 2010-12-01
#> 2 Bicycle th… bicy… bicycle… FALSE TRUE theft acquisitive 2013-05-01
#> 3 Burglary burg… burglary FALSE TRUE theft acquisitive 2010-12-01
#> 4 Criminal d… crim… crimina… FALSE TRUE crim… damage 2011-09-01
#> 5 Drugs drugs drugs FALSE TRUE drugs drugs 2011-09-01
#> 6 Other crime othe… other-c… FALSE TRUE other other 2010-12-01
#> 7 Other theft othe… other-t… FALSE TRUE theft acquisitive 2011-09-01
#> 8 Possession… poss… possess… FALSE TRUE poss… violent 2013-05-01
#> 9 Public ord… publ… public-… FALSE TRUE publ… violent 2013-05-01
#> 10 Robbery robb… robbery FALSE TRUE robb… violent 2010-12-01
#> 11 Shoplifting shop… shoplif… FALSE TRUE theft acquisitive 2011-09-01
#> 12 Theft from… thef… theft-f… FALSE TRUE theft acquisitive 2013-05-01
#> 13 Vehicle cr… vehi… vehicle… FALSE TRUE theft acquisitive 2010-12-01
#> 14 Violence a… viol… violent… FALSE TRUE viol… violent 2013-05-01
# Dated national shocks for use as default intervention definitions
shocks <- lamp_shocks()
shocks[shocks$kind == "stop_search_policy", c("name", "start", "end", "scope")]
#> # A tibble: 11 × 4
#> name start end scope
#> <chr> <date> <date> <chr>
#> 1 stop_search_reform_announcement 2014-04-30 NA national
#> 2 buss_launch 2014-08-26 2021-07-27 national
#> 3 pace_code_a_2015 2015-03-19 2023-01-16 national
#> 4 met_violent_crime_task_force 2018-04-01 NA forces
#> 5 s60_pilot_seven_forces 2019-04-01 2019-08-10 forces
#> 6 s60_relaxation_all_forces 2019-08-11 NA national
#> 7 beating_crime_plan_s60_permanent 2021-07-27 NA national
#> 8 home_secretary_s60_letter_2022 2022-05-16 NA national
#> 9 pace_code_a_2023 2023-01-17 NA national
#> 10 svro_pilot 2023-04-19 2025-04-18 forces
#> 11 public_order_act_2023_protest_search 2023-12-20 NA national
# Bank holidays in England and Wales, for seasonality controls
range(lamp_bank_holidays()$date)
#> [1] "2010-01-01" "2028-12-26"
# ONS LSOA code lists and the 2011 to 2021 lookup
length(lamp_lsoa_codes("lsoa11"))
#> [1] 34753
length(lamp_lsoa_codes("lsoa21"))
#> [1] 35672
table(lamp_lsoa_lookup()$change)
#>
#> U S M X
#> 33647 1900 239 10Design principles
- Every panel carries a panel contract recording provenance, coverage, LSOA vintage and treatment definition. Estimators read it and refuse or warn when their assumptions are violated.
- “Force did not submit” is never a zero.
- Anti-social behaviour is not a crime: it is kept in a separate series and excluded from crime totals by default.
- Every estimator names its identifying assumption in its output and ships with a placebo or pre-trend diagnostic.
Data attribution
Contains public sector information licensed under the Open Government Licence v3.0: recorded crime, outcomes and stop and search data from data.police.uk, geography from the Office for National Statistics, and bank holidays from GOV.UK.