Veil of darkness · Philadelphia · Full replication write-up
In daylight an officer can see who is in a car before deciding whether to pull it over; after dark, usually not. This document reproduces two published studies that turn that difference into a statistical test, using Philadelphia's own open traffic-stop data — and walks through exactly how the reproduction was done, with runnable R code, the fitted models set against the published figures, and an honest list of every place the two differ.
Study 1 — age and gender. Lance Hannon & Molly Biddle (2025), Unequal Policing of Black Motorists in Black Communities by Age and Gender, American Journal of Criminal Justice 50:1081–1090. doi.org/10.1007/s12103-025-09879-8
Study 2 — group travel. Lance Hannon & Molly Biddle (2026), Are Young Black Men Traveling Together Targeted for Traffic Stops?, CrimRxiv / Villanova University. doi.org/10.21428/cb6ab371.f1d81a4b
The two are not comparable to one another. Different samples, different years, different geographies and different questions: Study 1 covers Black adults of any age and either gender in Philadelphia's majority-Black police districts; Study 2 covers young men only, city-wide. A coefficient from one cannot be set beside a coefficient from the other.
In daylight, an officer can see who is in a car before deciding whether to pull it over. After dark, that is much harder: the officer can see headlights and a shape, but usually not the number, age, race or gender of the people inside. People's travel habits, by contrast, barely change at dusk — the same commute at 7:15pm looks the same in June and in December. What changes at dusk is what an officer can see.
That difference is the test. If we compare traffic stops made just before nightfall with stops made just after nightfall at the same time of day, then the main thing that has changed is visibility. If the kinds of people police stop shift when the light goes, it is difficult to explain that shift by anything other than what officers could see. This design is known as the “veil of darkness”.
How to read the numbers. The charts of raw shares describe what the data looks like. The models do the actual test: they hold clock time, day of week and year constant and ask what darkness alone does to the odds of a given kind of stop. Two ways of writing the same result appear throughout. An odds ratio below 1 means the stop became less likely once officers could no longer see into the car — the direction that indicates officers were selecting on what they could see; above 1 means more likely. A coefficient is the same quantity on a scale where zero, rather than one, means no effect, so a negative coefficient and an odds ratio below 1 say the same thing. In both cases a 95% confidence interval that does not reach the no-effect mark means a result like this one would be unlikely if darkness made no difference at all.
Our own pipeline is Python, but both papers fitted their models in R, and everything here reproduces from the raw download in a few dozen lines of it. The snippets below are runnable R, following the analysis in the order it actually happens: get the data, attach the sun, build the sample, weight for seasonality, fit the model, read the darkness coefficient. The starting point is Philadelphia's published vehicle and pedestrian stops file on OpenDataPhilly.
The export has one row per person, so a stop of three people is three rows. Two things
in it are silently treacherous: the timestamps are UTC, not Philadelphia time (for a
daylight/darkness test, missing this inverts the result), and the literal string
"NA" is a real motor vehicle code value — an MVC stop whose specific code went
unrecorded — not a missing value. R's default na.strings would silently
reclassify about 11% of MVC stops as non-MVC.
library(dplyr)
library(lubridate)
# OpenDataPhilly's vehicle/pedestrian stops export: one row per PERSON,
# so a stop of three people is three rows.
#
# Two traps in the raw CSV, both silent:
# * datetimeoccur is UTC, not Philadelphia time
# * the string "NA" is a REAL mvc_code value (an MVC stop whose
# specific code went unrecorded), not a missing value. read.csv's
# default na.strings would reclassify ~11% of MVC stops as non-MVC.
stops <- read.csv("car_ped_stops.csv",
stringsAsFactors = FALSE,
na.strings = "") # keep "NA" as data
stops$ts_local <- with_tz(ymd_hms(stops$datetimeoccur),
tzone = "America/New_York")
Each stop needs the sunset and civil-dusk times of its own date. The comparison window — the inter-twilight window — runs from the earliest dusk to the latest sunset across the study period, because only inside that span can one clock time be light in June and dark in December. Stops falling in the roughly 30-minute ambiguous band between sunset and full dusk are excluded, in part because officers log stop times in round numbers far more often than chance would produce, so a stop near the boundary can sit on the wrong side of the veil.
# One row per date: sunset and civil dusk, local time. Generate with
# suncalc::getSunlightTimes(lat = 39.9526, lon = -75.1652,
# keep = c("sunset", "dusk")) or download a
# table; we commit one so the pipeline is deterministic.
sun <- read.csv("philadelphia_sun_times.csv")
sun <- sun %>%
mutate(sunset_min = hour(sunset_local) * 60 + minute(sunset_local),
dusk_min = hour(dusk_local) * 60 + minute(dusk_local))
# The inter-twilight window: earliest dusk to latest sunset across the
# study period. Only inside this span can one clock time be light in
# June and dark in December -- and that is the whole comparison.
itp_start <- min(sun$dusk_min)
itp_end <- max(sun$sunset_min)
stops <- stops %>%
mutate(stop_date = as.Date(ts_local),
clock_minutes = hour(ts_local) * 60 + minute(ts_local)) %>%
left_join(sun, by = "stop_date") %>%
mutate(lighting = case_when(
clock_minutes < sunset_min ~ "daylight",
clock_minutes >= dusk_min ~ "dark",
TRUE ~ "ambiguous")) # excluded below
The export has no vehicle or incident identifier, so a “party” is
inferred: person rows sharing a timestamp (to the minute) and a recorded location
string are treated as occupants of one stopped car. Nothing in the data says they were in one
car — we measured the over-merge risk (under 1% of inferred parties disagree on vehicle year,
make or model) rather than assume it away. Deduplication must key on objectid:
deduplicating on demographics would merge two same-age men in one car into one person,
destroying about a quarter of exactly the multi-occupant parties Study 2 is about.
# A "party" is INFERRED: person rows sharing a timestamp (to the
# minute) and a recorded location string are treated as occupants of one
# stopped car. We measured the over-merge risk rather than assume it:
# under 1% of inferred parties disagree on vehicle year/make/model.
parties <- stops %>%
distinct(objectid, .keep_all = TRUE) %>% # row identity ONLY: deduping
# on demographics would merge
# two same-age men in one car
group_by(datetimeoccur, location) %>%
summarise(
party_size = n(),
n_races = n_distinct(race),
party_race = first(race),
all_young_men = all(gender == "Male" & between(age, 18, 29)),
complete_demogs = all(!is.na(race) & !is.na(gender) & !is.na(age)),
is_mvc = any(!is.na(mvc_code) & mvc_code != ""),
any_arrest = any(individual_arrested == 1),
# sole-occupant parties keep their own age/gender for Study 1
age = first(age), gender = first(gender),
ts_local = first(ts_local), stop_date = first(stop_date),
clock_minutes = first(clock_minutes), lighting = first(lighting),
districtoccur = first(districtoccur), psa = first(psa),
assigned_unit = first(assigned_unit),
.groups = "drop")
The two papers then diverge. Study 2 (2026) keeps homogeneous parties of young men, city-wide,
2021–2024, in the paper's own 5:08pm–8:35pm window. Study 1 (2025) keeps sole Black adult
occupants in the eight majority-Black districts the paper names, January 2022 through August
2025. Each defines its own outcome variables and the dark indicator the whole
test turns on.
# Study 2 (2026 paper): homogeneous parties of young men, city-wide,
# 2021-2024, the paper's own 5:08pm-8:35pm window.
sample2 <- parties %>%
filter(is_mvc, all_young_men, complete_demogs,
n_races == 1, # homogeneous party
party_race %in% c("Black - Non-Latino",
"White - Non-Latino"),
!any_arrest,
between(year(ts_local), 2021, 2024),
between(clock_minutes, 17*60 + 8, 20*60 + 35),
lighting != "ambiguous") %>%
mutate(dark = as.integer(lighting == "dark"),
group_travel = as.integer(party_size >= 2),
year = year(ts_local),
dow = wday(ts_local),
is_summer = as.integer(month(ts_local) %in% 6:8),
# PPD numbers service areas 1-4 WITHIN each district, so the
# raw psa field has only five values city-wide; the real area
# is the district-qualified pair, e.g. "02-1".
police_area = sprintf("%02d-%d", districtoccur, psa))
# Study 1 (2025 paper): SOLE Black adult occupants, the eight
# majority-Black districts the paper names, January 2022 - August 2025,
# and the inter-twilight window derived from the sample's own dates.
sample1 <- parties %>%
filter(party_size == 1,
sprintf("%02d", districtoccur) %in%
c("12", "14", "16", "18", "19", "22", "35", "39"),
ts_local >= ymd("2022-01-01"), ts_local < ymd("2025-09-01"),
party_race == "Black - Non-Latino",
!is.na(age), age >= 18, gender %in% c("Male", "Female"),
is_mvc, lighting != "ambiguous",
between(clock_minutes, itp_start, itp_end)) %>%
mutate(dark = as.integer(lighting == "dark"),
young_male = as.integer(age <= 29 & gender == "Male"),
young_female = as.integer(age <= 29 & gender == "Female"),
older_male = as.integer(age >= 30 & gender == "Male"),
older_female = as.integer(age >= 30 & gender == "Female"),
year = year(ts_local),
dow = wday(ts_local),
is_summer = as.integer(month(ts_local) %in% 6:8),
police_area = sprintf("%02d-%d", districtoccur, psa))
Within the inter-twilight window a December date is entirely dark and a July date entirely
light, so any group that drives more in one season than another is over-represented in one
lighting condition for reasons that have nothing to do with policing. Both papers apply the
Knode et al. (2024) correction: weight each date by p(1−p), where p
is the share of that date's window spent in daylight, so the dates where light and dark are
closest to an even split — where lighting is closest to random — count the most.
# Knode, Wolfe & Carter (2024), Criminology 62(3), supplemental S.2.
# p = the share of that date's inter-twilight window spent in daylight;
# w = p(1-p), floored by its own mean so all-light and all-dark dates
# are down-weighted rather than dropped outright.
sun <- sun %>%
mutate(
daylight = pmin(pmax(sunset_min, itp_start), itp_end) - itp_start,
darkness = itp_end - pmin(pmax(dusk_min, itp_start), itp_end),
p = ifelse(daylight + darkness > 0,
daylight / (daylight + darkness), 0),
w = p * (1 - p))
sun$w <- sun$w + mean(sun$w)
sample1 <- sample1 %>% left_join(select(sun, stop_date, w),
by = "stop_date")
Each outcome is a quasi-binomial GLM: the dark indicator, a natural cubic spline
on clock time (holding the time of evening constant), and day-of-week, year, police-area,
assigned-unit and summer fixed effects. One precaution comes first: fixed-effect levels seen
only a handful of times can perfectly predict a binary outcome and send a coefficient toward
infinity while the fit still reports convergence, so levels with fewer than 100 stops are
folded into an OTHER bucket.
library(splines)
# A fixed-effect level seen only a handful of times can perfectly
# predict a binary outcome and send its coefficient toward infinity
# while the fit still reports convergence. Fold levels with fewer than
# 100 stops into "OTHER".
collapse_rare <- function(x, min_n = 100) {
rare <- names(which(table(x) < min_n))
factor(ifelse(x %in% rare, "OTHER", as.character(x)))
}
# Study 1's specification: darkness, a natural cubic spline on clock
# time, day-of-week / year / police-area / assigned-unit fixed effects,
# a summer indicator, and the seasonality weights from step 5.
# family = quasibinomial gives the overdispersion-adjusted SEs.
fit <- glm(young_male ~ dark + ns(clock_minutes, df = 6) +
factor(dow) + factor(year) + factor(police_area) +
collapse_rare(assigned_unit) + factor(is_summer),
family = quasibinomial, weights = w, data = sample1)
summary(fit)$coefficients["dark", ] # estimate, SE, z-value, p
exp(coef(fit)["dark"]) # the odds ratio
Study 2 fits the same family of models on its own sample — a base specification without location or unit controls (Model 1), a fuller one with them (Model 2), and the placebo on young white men, which should be null and is:
# Study 2's three Model 1 fits, and the fuller Model 2 specification:
black_parties <- filter(sample2, party_race == "Black - Non-Latino")
m1 <- glm(group_travel ~ dark + ns(clock_minutes, df = 6) +
factor(dow) + factor(year),
family = quasibinomial, data = black_parties)
m2 <- glm(group_travel ~ dark + ns(clock_minutes, df = 6) +
factor(dow) + factor(year) + collapse_rare(police_area) +
collapse_rare(assigned_unit) + factor(is_summer),
family = quasibinomial, data = black_parties)
# The placebo: the same model on young WHITE men. It should be null --
# and is.
placebo <- update(m1, data = filter(sample2,
party_race == "White - Non-Latino"))
The four daylight/dark panels in Study 1 below are model-adjusted predicted
probabilities, not raw shares: the fitted model evaluated twice, once with
dark set to 0 and once with it set to 1, while every control is held fixed —
clock time at its sample mean, every factor at its most common level. That is what turns a
log-odds coefficient into the percentage-point contrast a reader can actually picture. For an
average marginal effect proper — the prediction averaged over the whole sample rather than at
one reference profile — the one-line marginaleffects call at the bottom is the
standard tool.
# The four daylight/dark panels in Study 1 are MODEL-ADJUSTED PREDICTED
# PROBABILITIES, not raw shares: the fitted model evaluated with dark
# set to 0 and then to 1, clock time held at its sample mean and every
# factor held at its modal (most common) level.
modal <- function(x) names(which.max(table(x)))
newdata <- data.frame(
dark = c(0, 1),
clock_minutes = mean(sample1$clock_minutes),
dow = modal(sample1$dow),
year = modal(sample1$year),
police_area = modal(sample1$police_area),
assigned_unit = modal(collapse_rare(sample1$assigned_unit)),
is_summer = modal(sample1$is_summer))
predict(fit, newdata, type = "response") # p(daylight), p(dark)
# An average marginal effect proper -- the daylight/dark contrast
# averaged over the actual sample rather than at one reference
# profile -- is one line with the marginaleffects package:
# marginaleffects::avg_predictions(fit, variables = "dark")
The interactive year-by-year chart on the live page is Study 1's specification refit separately for each calendar year, 2014–2025 — twelve fits sharing one inter-twilight window and one weight schedule, so a year-to-year difference cannot be an artifact of a shifting comparison period. This step is our extension, not part of either paper.
# Study 1's specification refit separately for each calendar year,
# 2014-2025, sharing ONE inter-twilight window and ONE weight schedule
# across all years, so a year-to-year difference cannot be an artifact
# of a shifting comparison period. (sample1_long is the step-4 sample
# built over the full 2014-2025 window rather than the paper's window.)
per_year <- lapply(2014:2025, function(yr) {
fit <- glm(young_male ~ dark + ns(clock_minutes, df = 6) +
factor(dow) + factor(police_area) +
collapse_rare(assigned_unit) + factor(is_summer),
family = quasibinomial, weights = w,
data = filter(sample1_long, year == yr))
s <- summary(fit)$coefficients["dark", ]
data.frame(year = yr, coef = s["Estimate"], se = s["Std. Error"])
})
per_year <- do.call(rbind, per_year)
Among traffic stops of Black motorists, does daylight change the age and gender of the person who gets pulled over? This compares stops of Black motorists in daylight with stops of Black motorists after dark, and asks whether the mix of who is stopped shifts when officers can no longer see into the car before deciding to pull it over.
The sample is narrow and stated plainly: 75,879 stops of a single Black adult occupant, initiated for a motor vehicle code violation, during evening hours, in Philadelphia's majority-Black police districts (12, 14, 16, 18, 19, 22, 35, 39), from January 2022 to August 2025.
Model-adjusted predicted probability, p < .001. Darkness lowers it.
Model-adjusted predicted probability, p = .084. No detectable change.
Model-adjusted predicted probability, p = .772. No detectable change.
Model-adjusted predicted probability, p < .001. Darkness raises it.
Two of these four results move, and they move in opposite directions. Darkness cuts the odds that a stopped Black driver is a young man by about 21% (p < .001), and raises the odds that the stopped driver is an older woman by about 30% (p < .001). Those two results are the near-inverse of each other: when officers can see less, young men make up a smaller share of who gets stopped and older women make up a larger share.
The other two groups show no detected effect, and that is part of the finding, not a gap in it. Darkness does not move the odds that a stopped Black driver is a young woman (p = .084) or an older man (p = .772) — both confidence intervals span 1, and neither should be read as a small effect. If darkness were driving some general shift in who gets stopped, it is difficult to explain why it would move two of the four groups and leave the other two untouched.
| What the model predicts | Our coef | Our SE | Published coef | Published SE | Significant? |
|---|---|---|---|---|---|
| Stopped driver is under 30 (any gender) | −0.174 | 0.021 | −0.17 | 0.02 | Yes (p < .001) |
| Stopped driver is male (any age) | −0.221 | 0.021 | −0.21 | 0.02 | Yes (p < .001) |
| Stopped driver is under 30 and male | −0.242 | 0.023 | −0.23 | 0.02 | Yes (p < .001) |
| Stopped driver is under 30 and female | +0.054 | 0.031 | +0.05 | 0.03 | No (p = .084) |
| Stopped driver is 30 or older and male | −0.006 | 0.020 | −0.01 | 0.02 | No (p = .772) |
| Stopped driver is 30 or older and female | +0.261 | 0.025 | +0.25 | 0.02 | Yes (p < .001) |
The rest of this document reproduces the 2026 paper: young Black men only, 2021–2024, city-wide, asking whether a stopped driver was traveling with another young Black man. Every figure from here on is drawn from that four-year window and from the evening hours the authors studied (5:08pm–8:35pm).
Young male motorists (ages 18–29) stopped for a motor vehicle code violation between 5:08pm and 8:35pm, 2021–2024. Counted per motorist.
This gap on its own is not evidence of bias. Police decide where to deploy officers, and those decisions are driven largely by where crime is believed to be concentrated rather than by where traffic risk is highest. Sending more officers into some neighborhoods than others produces more stops of the people who live and drive in those neighborhoods, whatever each individual officer does. That mechanism — sometimes called neighborhood profiling — plausibly explains much of the difference above. The central results here are not cross-race comparisons at all: the frisk and ticket chart, the veil-of-darkness chart, and the headline model row all compare young Black men with young Black men, which removes the part of this problem that comes from Black and white motorists being stopped in different neighborhoods.
Counted per motorist: the denominator is people stopped, not stops. Counted per stop instead, both shares are materially lower, because a multi-occupant stop contributes several motorists but only one stop.
The obvious innocent explanation is that Black and white Philadelphians simply travel together at different rates. The paper's authors checked that against American Community Survey commuting data, which puts carpooling at roughly 14% for both Black and white Philadelphians. Different travel habits do not account for the difference above.
This chart, like the one before it, is a comparison across races, and so carries the neighborhood-profiling caveat above. Everything from here on compares young Black men with young Black men, with two clearly marked exceptions in the model tables.
Young Black male motorists (ages 18–29) stopped 2021–2024, 5:08pm–8:35pm. Counted per occupant: 26,761 traveling alone, 10,020 traveling with another young Black man.
This is the sharpest contrast in either study. Stops of young Black men traveling with another young Black man have nearly triple the frisk rate of stops of young Black men traveling alone — 19.3% against 7.1% — while the ticket rate is lower, 7.2% against 11.1%. These are raw shares with no controls, so they describe an association rather than establishing a cause. But if these stops were about enforcing the motor vehicle code, it is hard to see why the ticket rate would fall when a second young Black man is in the car.
Stops of young Black male motorists (ages 18–29), 2021–2024, in 15-minute clock-time bins, counted per stop. Two clock times are not shown: the sample window starts at 5:08pm and ends at 8:35pm, so the earliest dark bin (5:00pm, 108 stops) and the latest daylight bin (8:30pm, 41 stops) are clipped and rest on very few stops; any clock time with fewer than 200 stops on either side of the veil is left out. The models below use every stop, including the ones behind the omitted clock times.
Each pair of bars is a single time of evening — the same clock time, in daylight and in darkness. That is possible because sunset moves through the year: at 7:15pm it is light in June and dark in December. Commuting patterns at 7:15pm are much the same in both months; the light is not. The gap between the two bars at the same clock time is what the whole test rests on, and across the evening the daylight bars sit higher in 12 of the 13 bins shown. The models below test whether that pattern survives controls.
| What the model predicts | Odds ratio (95% CI) | Our coef | Published coef | Stops |
|---|---|---|---|---|
| A stopped young Black man was traveling with another young Black man Among stops of young Black male motorists |
0.792 (0.738–0.850) | −0.233 | −0.242 | 31,209 |
| The young man police stopped was Black rather than white Among stops of young Black and white male motorists |
0.890 (0.835–0.949) | −0.116 | −0.116 | 36,587 |
| Placebo: a stopped young white man was traveling with another young white man Among stops of young white male motorists — not significant (p = .85) |
1.037 (0.717–1.501) | +0.037 | not published | 5,378 |
Among stops of young Black men, the odds that the stopped car was carrying another young Black man were 21% lower after dark than in daylight at the same clock time. Across stops of young Black and white men together, the odds that the person stopped was Black were 11% lower after dark. Both intervals exclude 1, so neither is comfortably explained by chance.
The third row is a placebo test: the same model run on stops of young white men. Its confidence interval spans 1 (p = .85). It is not statistically significant. That null result is part of the evidence, not a gap in it: whatever darkness does to the composition of stops of young Black men, it does not do to stops of young white men. Had the placebo also moved, the likely explanation would have been some general artifact of nightfall rather than anything about race.
| What the model predicts | Odds ratio (95% CI) | Our coef | Published coef | Stops |
|---|---|---|---|---|
| A stopped young Black man was traveling with another young Black man Among stops of young Black male motorists |
0.764 (0.702–0.831) | −0.269 | −0.269 | 31,205 |
| The young man police stopped was Black rather than white Among stops of young Black and white male motorists |
0.837 (0.767–0.913) | −0.178 | −0.188 | 36,583 |
| Placebo: a stopped young white man was traveling with another young white man † Among stops of young white male motorists — not significant (p = .29) † Location control largely hollow — this estimate should not be read as location-adjusted. See the note below. |
1.271 (0.816–1.978) | +0.239 | not published | 5,378 |
Note on Model 2. Differences from the published version matter and we state them rather than bury them. First, our Model 2 omits the seasonality weight the source paper applies. The 2026 paper does not publish that weight's formula; we later obtained it from a different paper the authors cite, Knode et al. (2024), and it is applied to the Study 1 models above. The 2026 Model 2 here has not been refit with it, so its figures predate that and still lack the weight. Second, officer-assignment and service-area categories with fewer than 100 stops are collapsed into a single “other” category (38–65 categories, depending on the model), because categories that rare can perfectly predict the outcome and break the fit. In the two headline rows, collapsing costs little: areas holding just 3.1% of stops or fewer end up in that bucket, so the location control is doing real work. The placebo row — marked † above — is the exception, and it is worth being blunt about: its 5,378 stops of young white men are spread across dozens of service areas, so areas holding 40% of those stops fall below the threshold and lose their own effect. That row's location control is largely hollow, and its estimate should not be read as location-adjusted. Treat these numbers as supporting detail; the Model 1 results above are the reproduction we stand behind. Note that the placebo row here, though its odds ratio of 1.271 looks larger than in Model 1, has a confidence interval that also spans 1 (p = .29): it is not statistically significant either.
Both reproductions work from Philadelphia's published stop data on a snapshot roughly two years newer than the one each set of authors used. OpenDataPhilly revises records, so exact agreement is not the standard; landing close on both sample counts and coefficients is.
Study 1 (age and gender). Our sample is 75,879 stops against the paper's published 76,274, a difference of about 0.52%. Every one of the six coefficients in the Study 1 table above lands within 0.012 of the corresponding published figure in Table 1, and both non-significant results in the paper are non-significant here too, in the same direction.
Study 2 (group travel). Our sample counts come within about 5% of the published figures (Table 1 stops 36,699 against 37,168; Black-party stops 31,317 against 31,738; solo/group splits 26,856/4,461 against 27,056/4,682) and our Model 1 coefficients within 0.010 of the published ones. Model 2 omits a weight the paper applies, as described in the note under that table, so it is not quite the same model — how far its estimates sit from the published ones is not a measure of how well this reproduction lands.
The inter-twilight window is derived independently from Philadelphia solar geometry rather than taken from the 2026 paper, as a check on the implementation. Our derivation lands at 17:05–20:33 against the paper's 17:08–20:35; the paper's values win in the reproduction itself, so a reader comparing counts against the paper's tables is comparing like with like.
Each of these is a place where we knowingly did something other than what the paper describes. None is an accident, and each is here to be argued with.
The source data does not contain it: there is one row per person and no vehicle or incident
identifier grouping occupants. We infer a party by grouping person rows on
(datetimeoccur, location) — same timestamp to the minute, same recorded location
string — and treat 2+ as traveling together. The failure mode is over-merging: two separate
stops at the same minute at the same intersection become one multi-occupant party, inflating
group travel. That is the direction that would manufacture the headline, so it was measured
rather than assumed, over the 2021–2024 person rows deduplicated on objectid: of
74,307 groups with more than one row, 717 (0.96%) disagree on vehicle
year/make/model and only 16 disagree on district. Under 1% of inferred
parties show any sign of being two different vehicles. This is a lower bound — two
occupants of genuinely different cars of the same make and model would not register — and no
upper bound is available. It bounds the risk well below the reported effect sizes, but the
construct remains an inference, and we do not describe it as recorded fact.
The paper is explicit that the data has no reliable indicator of who is in the driver
position, and the homogeneous-party restriction makes the question moot: everyone in the car
is a young man of the same race. The outcome is therefore "the party police stopped was Black
rather than white", named party_is_black, not driver_is_black.
The paper restricts to MVC-initiated stops, "excluding 'vehicle involved in crime' and
'vehicle matches flash information'". We implement "has an MVC code" instead. Applying the
paper's exact exclusion was measured and moves our sample further from the published
76,274 — about −1.2% away, versus −0.52% under the proxy — which is evidence the proxy is the
right implementation here. An mvc_reason == "Police Investigation" category
exists in the source data (4,445 rows, 2022–2025) and is not excluded by the proxy.
This is a question for the authors, not a settled choice — see the final
section.
Both papers specify the Knode et al. (2024) weight but neither publishes its formula. We
obtained it from Knode, Wolfe & Carter (2024), Criminology 62(3), 364–375, supplemental
S.2, and applied it to the Study 1 models. It is not retrofitted onto Study
2's Model 2, so those figures predate it and still lack the weight. One implementation note:
the supplemental's prose says the daylight proportion is measured "in a day", but the authors
report 25,926 stops carrying a weight of exactly zero — impossible for a whole-day proportion
at Philadelphia's latitude, and routine for one scoped to the inter-twilight window (every
December date). We measure p within the window.
The paper fits R's glm(..., family = quasibinomial, weights = w), whose weights
are prior weights. Our fitting library's frequency-weight argument instead inflates
the sample as though each row were replicated w times, and the published standard
errors were the decisive evidence against it: under both schemes the coefficients are nearly
identical, but frequency weights drive the SEs an order of magnitude away from the published
values while variance weights land close.
PPD numbers police service areas 1–4 within each district, so the raw
psa field takes only five distinct values city-wide; controlling on it would pool
PSA 2 of the 12th District in Southwest with PSA 2 of the 7th in the Far Northeast — four
dummies standing in for 66 real areas. We control on the district-qualified key
("02-1"). District is deliberately not a second term: a PSA nests inside
exactly one district, so district dummies are a linear combination of area dummies. Separately,
fixed-effect levels with fewer than 100 stops fold into an OTHER bucket before
fitting, because categories that rare can perfectly predict a binary outcome and break the fit
silently — our fitting library once reported a coefficient of −3.6 × 10¹⁴ as a converged
model under exactly that condition. The share of rows in the OTHER bucket is
reported alongside every fit, since collapsing is only protective while that share stays
small.
They only see the decision to stop. Every veil-of-darkness design can detect selection on what officers could see before deciding to pull a car over, and nothing else. None of it speaks to what happens once the stop begins. Nor is any of it evidence about the total volume of stops: the outcome in each model is what kind of stop occurred, not how many.
The veil-of-darkness effects are modest in size. They shift the odds of a given kind of stop by roughly 10% to 30%. Statistically significant does not mean large. The big number in Study 2 is the frisk-rate gap — nearly triple — while the veil-of-darkness coefficients are the cleanly identified ones, the ones where we can be most confident about why the difference exists. They are answering different questions and should not be read as one finding.
The test is deliberately conservative. Streetlights, headlights and lit intersections mean darkness is never total; some cars are recognizable regardless of the light, and officers sometimes know a vehicle already; segregation means an officer can often infer who is likely to be in a car from where it is driving, with or without seeing inside; and existing research suggests Black drivers drive more carefully in high-visibility conditions, which would work against finding any daylight effect at all. Every one of those shrinks the measured difference between daylight and darkness, which means the test understates the role of visibility rather than overstating it.
Philadelphia's segregation strains any cross-race comparison. In our sample, only 7.0% of stops of young Black men happened in a majority-white police district — one where more than half of residents are white. So comparing Black and white motorists always means comparing different places as well as different people. That is why the within-race tests matter most: they hold race constant and ask only what changes when officers can no longer see into the car.
Recorded stop times are rounded. In our sample, 51% of recorded stop times fall on a multiple of five minutes and 19.8% on a quarter hour, far more than chance would produce (20% and 6.7%). A stop logged at 7:30pm may therefore have happened somewhat earlier or later. Near the boundary between light and dark that rounding can put a stop on the wrong side of the veil, so the roughly 30-minute window between sunset and full dusk is excluded from the analysis altogether.
These produce output that looks entirely reasonable and is wrong. None crashes or fails a test. They apply to any analysis of these CSVs.
datetimeoccur is UTC. Anything keyed on time of day is off
by four or five hours if this is missed — which, for a daylight/darkness design,
inverts the result rather than degrading it."NA" is a real mvc_code value, not a
null. It means a genuine MVC stop whose specific code went unrecorded — a distinct
category from a true blank (21,134 versus 21,593 rows in 2024 alone). Both pandas and R's
read.csv treat "NA" as a default null sentinel and silently coerce
it, which drops about 11% of the analytic sample — enough on its own to fail replication
against the published totals. This was caught only because an exploratory DuckDB query, which
preserves the string, disagreed with pandas by 3,600 stops on identical input.(datetimeoccur, location, race, gender, age) collapses two 22-year-old Black
men in one car into a single person, destroying roughly 26% of exactly the multi-occupant
parties Study 2 is about. It must key on objectid.psa alone does not identify a service area. Five distinct
values city-wide for 66 real areas; the unique beat is (districtoccur, psa).5700 BLOCK Baltimore Ave,
BALTIMORE AVE and BALTIMORE are distinct strings. Measured during
exploration: normalising moves the multi-occupant rate by 0.05 percentage points, so it is
not required for these figures, but it remains a correctness issue for anything counting
distinct locations.darkness × era interaction; ordinal
dose-response on party size (the 2026 abstract argues the effect scales with the number of
young Black men present, beyond present/absent); and a time-heaping sensitivity refit with a
wider excluded band.Both papers specify the Knode et al. (2024) weight but neither publishes the formula. We
reconstructed it from Knode et al. (2024) supplemental S.2 and scoped p to the
inter-twilight window on the evidence of the 25,926 zero weights. Is that the scoping you
used?
We use "has an MVC code" as a proxy for "MVC-initiated, excluding vehicle-involved-in-crime
and flash-information stops", because the exact restriction moves our n further from your
published 76,274. Does the proxy match your intent, and how did you treat the
Police Investigation category?
Raw psa holds only five distinct values city-wide because PPD numbers service
areas within each district. Did the published models control on the district-qualified area,
or on the raw field?
We read the 25.1% / 4.6% figures as per motorist rather than per stop, which changes the comparison substantially (per stop: 14.2% / 2.6%). Is that reading correct?