Visual analysis of UMPD incident data across time. Semester definitions: Spring = Feb–Apr, Summer = Jun–Aug, Fall = Sep–Nov.
Incident counts by day of week and hour, all time. Darker red = more incidents. Hover a cell for exact count.
What's counted: every valid incident since the log began, bucketed by the weekday and hour of its Date Occurred timestamp. Each cell is a raw count, not an average — busy cells partly reflect that some hours simply have more years of data behind them at equal rates.
Watch out for: incidents logged at exactly midnight often mean "time unknown," which inflates the 0:00 column.
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
activity |>
mutate(weekday = wday(`Date Occurred`, label = TRUE, week_start = 1),
hour = hour(`Date Occurred`)) |>
count(weekday, hour) |>
arrange(desc(n))
Incidents per month by crime category. Click legend items to show or hide a category.
What's counted: incidents per calendar month by Date Occurred,
starting January 2015 (a handful of rows carry obviously wrong dates from the 1980s–90s
and are excluded). Each line is a crime category — the keyword buckets defined in
categorize_crime() in app.py.
Watch out for: the last point is usually a partial month, so an apparent "plunge" at the right edge is an artifact, not news.
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
activity |>
mutate(month = floor_date(as_date(`Date Occurred`), "month")) |>
filter(month >= ymd("2015-01-01")) |> # drops rows with obviously wrong dates
count(month) |>
ggplot(aes(month, n)) + geom_line()
Incident totals across the last four semesters, grouped by crime category.
What's counted: incidents in each semester window — Spring = Feb–Apr, Summer = Jun–Aug, Fall = Sep–Nov — for the last four semesters. December, January and May are deliberately left out of every bucket, so the semesters compare like with like.
Why it matters: comparing a fall (40,000+ people on campus) against a summer session in raw counts is a denominator error — see the "Rate vs. count" section below.
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
activity |>
mutate(m = month(`Date Occurred`), y = year(`Date Occurred`)) |>
mutate(semester = case_when(
m %in% 2:4 ~ str_c("Spring ", y),
m %in% 6:8 ~ str_c("Summer ", y),
m %in% 9:11 ~ str_c("Fall ", y))) |>
filter(!is.na(semester), y >= 2015) |>
count(semester)
Total incidents per calendar year, stacked by crime category.
What's counted: incidents per calendar year by Date Occurred, stacked by category. The current year is a partial bar — never compare it directly to a finished year without matching the date range (that trap has its own lesson).
Worth noticing: 2020's collapsed bar is the pandemic — campus emptied and incidents fell by roughly half. Any multi-year average that includes 2020 is dragged down by it; say so if you use one.
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
activity |>
mutate(year = year(`Date Occurred`)) |>
filter(year >= 2015) |>
count(year)
Raw yearly totals (bars) against incidents per 1,000 enrolled students (line). A growing campus can log more incidents while each student's risk stays flat — counts and rates can tell different stories, and the rate is usually the fairer one.
Formula: incidents ÷ fall enrollment × 1,000, per calendar year, rounded to 1 decimal. Enrollment is total fall headcount (undergraduate + graduate) from the federal IPEDS surveys, with the newest year from UMD's Office of Institutional Research, Planning & Assessment (irpa.umd.edu); see the table above for the exact denominators used.
Watch out for: enrollment isn't a perfect denominator — staff, visitors and game-day crowds are also on campus. A rate is only as honest as its denominator, and you should always name yours.
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
# UMD fall headcount: IPEDS (unitid 163286); 2025 from irpa.umd.edu
enrollment <- tribble(
~year, ~students,
2015, 38140, 2016, 39083, 2017, 40521,
2018, 41200, 2019, 40743, 2020, 40709,
2021, 41272, 2022, 40792, 2023, 40813,
2024, 41725, 2025, 42290)
activity |>
mutate(year = year(`Date Occurred`)) |>
filter(year >= 2015) |>
count(year) |>
left_join(enrollment, by = "year") |>
mutate(rate_per_1000 = round(n / students * 1000, 1))
Days between when an incident occurred and when it was reported, by crime type. The gap between the median and the mean is the story: most reports come in the same day, but a few arrive weeks or months late and drag the mean up while the median doesn't budge.
Formula: calendar days from Date Occurred to Report Date, summarized per crime type with at least 30 cases. Rows where the report predates the occurrence (data-entry errors) are dropped. "% delayed" is the share reported on a later day than they occurred.
Why median first: reporting delays are heavily skewed — a single theft reported six months later moves the mean a lot and the median not at all. This is the standard reason journalists default to medians for skewed quantities like incomes and delays. (One day we'd like to measure how long cases take to resolve, but the log doesn't yet record when dispositions change.)
library(tidyverse)
library(lubridate)
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
activity |>
mutate(days_late = as.numeric(as_date(`Report Date`) - as_date(`Date Occurred`))) |>
filter(days_late >= 0) |> # a few reports predate the incident: data errors
group_by(`Crime Type`) |>
summarize(n = n(),
median_days = median(days_late),
mean_days = round(mean(days_late), 1),
pct_delayed = round(mean(days_late >= 1) * 100, 1)) |>
filter(n >= 30) |>
arrange(desc(mean_days))