By Rachel Logan, with updates from Derek Willis. See the code

Trends & Patterns

Visual analysis of UMPD incident data across time. Semester definitions: Spring = Feb–Apr, Summer = Jun–Aug, Fall = Sep–Nov.

When It Happens

Incident counts by day of week and hour, all time. Darker red = more incidents. Hover a cell for exact count.

Show your work

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))

Monthly Trends

Incidents per month by crime category. Click legend items to show or hide a category.

Show your work

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()

Semester Comparison

Incident totals across the last four semesters, grouped by crime category.

Show your work

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)

Year over Year

Total incidents per calendar year, stacked by crime category.

Show your work

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)

Rate vs. Count: Incidents per 1,000 Students

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.

Show your work

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))

How Fast Do Incidents Get Reported?

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.

Show your work

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))