Raw daily counts jump around: a loud Saturday, a dead Tuesday. A moving average replaces each day with the mean of the last k days, smoothing the jitter so real change becomes visible. It's the same technique every COVID dashboard used ("7-day average of new cases") — because reporting artifacts and day-of-week rhythms would otherwise bury the story.
Gray dots are actual daily counts; the red line is the trailing moving average. Drag the slider: a small window follows every bump, a large one flattens even real shifts. Choosing the window is a judgment call — which is exactly why you should always say what it is.
Switch the view to all time and look at spring 2020. Campus emptied out in March 2020 and incident counts collapsed — the whole year logged about 900 incidents against roughly 2,000 in a normal year. On the raw daily dots that's hard to see; on a 30-day average it's unmistakable. That's the point of smoothing: one quiet day means nothing, ninety of them are history happening.
For your story: never write "incidents doubled yesterday" from one day's count. Compare smoothed values, and name the window: "the 30-day average rose from A to B."
library(tidyverse)
library(lubridate)
library(zoo) # install.packages("zoo") -- for rollmeanr()
activity <- read_csv("https://raw.githubusercontent.com/dwillis/umpd-logs/master/data/all-police-activity.csv")
daily <- activity |>
mutate(day = as_date(`Date Occurred`)) |>
filter(day >= ymd("2015-01-01")) |>
count(day, name = "incidents") |>
complete(day = seq(min(day), max(day), by = "day"),
fill = list(incidents = 0))
daily |>
mutate(avg7 = rollmeanr(incidents, 7, fill = NA),
avg30 = rollmeanr(incidents, 30, fill = NA)) |>
ggplot(aes(day)) +
geom_point(aes(y = incidents), color = "gray80", size = 0.4) +
geom_line(aes(y = avg30), color = "firebrick") +
labs(y = "incidents per day (30-day average)")