How to Relate Daily Crime to Temperature in Python

On this page

Problem statement

Violent crime rises in warm weather, one of the oldest findings in criminology. Testing it on a city's own data looks like a scatter plot of daily counts against daily temperature, and the correlation that comes out is strong. Most of it, though, is the calendar: summer is warm, summer has long evenings, school holidays and more people outdoors, and anything with a summer peak will correlate with temperature. The question worth answering is whether a day that is warmer than usual for its time of year has more crime than a typical day of that month.

This page joins daily counts of eight offence types in Chicago from 2019 to 2024 to the daily maximum temperature at O'Hare from NOAA's GHCN-Daily, measures the raw correlation, removes the month and the day of the week, and estimates how many more incidents each extra degree brings.

Quick answer

Subtract each month's mean and each weekday's mean from the daily counts, take temperature as its difference from the month's mean, and correlate the two anomalies:

def anomaly(col):
    a = D[col] - D.groupby("month")[col].transform("mean")
    return a - a.groupby(D.index.dayofweek).transform("mean")

t_anom = D.tmax - D.groupby("month").tmax.transform("mean")
for col in ("BATTERY", "ASSAULT", "ROBBERY", "DECEPTIVE PRACTICE"):
    a = anomaly(col)
    slope = np.polyfit(t_anom, a, 1)[0]
    print(f"{col.lower():19s} raw r = {D[col].corr(D.tmax):+.2f}, within month r = {a.corr(t_anom):+.2f}, "
          f"{slope:+.2f} a day per °C ({slope / D[col].mean():+.1%})")
battery             raw r = +0.50, within month r = +0.41, +1.27 a day per °C (+1.1%)
assault             raw r = +0.56, within month r = +0.32, +0.53 a day per °C (+0.9%)
robbery             raw r = +0.17, within month r = +0.11, +0.14 a day per °C (+0.6%)
deceptive practice  raw r = -0.10, within month r = +0.02, +0.06 a day per °C (+0.1%)

Removing the month and the weekday lowers every correlation, assault's most, from 0.56 to 0.32. What survives is a steady effect: about 1.27 more batteries a day for each degree above the month's average, 1.1% of the daily mean. Deceptive practice, the control, has a raw correlation of -0.10 and a within-month one of 0.02.

Two scatter plots of 2,192 days in Chicago, 2019 to 2024: daily batteries against O'Hare's daily maximum temperature, rising from about 75 at minus 20 degrees to about 145 at 38 degrees with r of 0.50; and batteries above the typical count for the month and weekday against degrees above the month's mean temperature, a tighter cloud around a line rising from about minus 25 to plus 20, with r of 0.41. Two scatter plots of 2,192 days in Chicago, 2019 to 2024: daily batteries against O'Hare's daily maximum temperature, rising from about 75 at minus 20 degrees to about 145 at 38 degrees with r of 0.50; and batteries above the typical count for the month and weekday against degrees above the month's mean temperature, a tighter cloud around a line rising from about minus 25 to plus 20, with r of 0.41.
Raw and within-month relationships.

Join daily series, remove the season, look at the shape

1. Join the two daily series

Weather stations report by local standard day; crime counts are by calendar day in Chicago time. For daily totals the two line up closely enough; check that no day is missing:

print(f"{len(D):,} days, {D.index.min():%d %b %Y} to {D.index.max():%d %b %Y}; days without a temperature: {D.tmax.isna().sum()}")
print(f"daily maximum at O'Hare: {D.tmax.min():.1f} to {D.tmax.max():.1f} °C; mean batteries a day: {D.BATTERY.mean():.0f}")
2,192 days, 01 Jan 2019 to 31 Dec 2024; days without a temperature: 0
daily maximum at O'Hare: -23.2 to 37.8 °C; mean batteries a day: 120

All 2,192 days have a temperature, from -23.2 °C to 37.8 °C; the city recorded 120 batteries on an average day.

2. See how much is season

The month alone explains a large part of the daily variation in battery counts, as it does for temperature:

for col in ("BATTERY", "tmax"):
    share = D.groupby("month")[col].transform("mean").var() / D[col].var()
    print(f"{col}: month means explain {share:.0%} of the day-to-day variance")
print(f"calendar months, 2019-2024: {D.month.nunique()}")
BATTERY: month means explain 38% of the day-to-day variance
tmax: month means explain 79% of the day-to-day variance
calendar months, 2019-2024: 72

The 72 month means explain 79% of the variance in daily maximum temperature and 38% of the variance in daily batteries. Correlating the raw series lets that shared calendar carry much of the result.

Line chart of 14-day means of daily batteries and O'Hare daily maximum temperature from 2019 to 2024: both peak every summer and fall every winter, temperature between about minus 10 and 33 degrees and batteries between about 80 and 170 a day, with batteries lower in 2020 and 2021. Line chart of 14-day means of daily batteries and O'Hare daily maximum temperature from 2019 to 2024: both peak every summer and fall every winter, temperature between about minus 10 and 33 degrees and batteries between about 80 and 170 a day, with batteries lower in 2020 and 2021.
The calendar both series share.

3. Look at the shape

A slope assumes a straight line. Average the battery anomaly in bands of temperature anomaly:

bands = pd.cut(t_anom, [-20, -8, -4, -1, 1, 4, 8, 20])
curve = anomaly("BATTERY").groupby(bands, observed=True).agg(["mean", "size"])
print(curve.round(1).to_string())
           mean  size
tmax                 
(-20, -8] -13.0   131
(-8, -4]   -7.6   333
(-4, -1]   -3.2   458
(-1, 1]     0.8   320
(1, 4]      3.3   479
(4, 8]      5.5   327
(8, 20]    14.7   142

The battery anomaly rises through every band, from 13 fewer a day on the coldest days for the month to 14.7 more on the warmest, and the step is largest at the warm end: from 5.5 to 14.7 between the last two bands.

Hot and cool days in the same month

The data used on this page

Daily counts by offence type from the City of Chicago's Crimes - 2001 to Present (ijzp-q8t2), aggregated on the server with SoQL for 2019–2024; and the daily maximum temperature (TMAX, tenths of a degree Celsius) at Chicago O'Hare International Airport, station USW00094846, from NOAA's GHCN-Daily.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
TYPES = ["BATTERY", "ROBBERY", "THEFT", "ASSAULT", "MOTOR VEHICLE THEFT", "BURGLARY", "HOMICIDE", "DECEPTIVE PRACTICE"]
F = Path("data/daily.json")
if not F.exists():
    F.write_bytes(requests.get("https://data.cityofchicago.org/resource/ijzp-q8t2.json", timeout=300, params={
        "$select": "date_trunc_ymd(date) AS day, primary_type, count(*) AS n",
        "$where": "year BETWEEN 2019 AND 2024 AND primary_type IN (" + ", ".join(f"'{t}'" for t in TYPES) + ")",
        "$group": "day, primary_type", "$limit": 50_000}).content)
W = Path("data/USW00094846.csv")
if not W.exists():
    W.write_bytes(requests.get("https://www.ncei.noaa.gov/data/global-historical-climatology-network-daily/access/USW00094846.csv", timeout=300).content)
d = pd.read_json(F).assign(day=lambda x: pd.to_datetime(x.day))
D = d.pivot_table(index="day", columns="primary_type", values="n", aggfunc="sum").reindex(columns=TYPES).fillna(0)
w = pd.read_csv(W, usecols=["DATE", "TMAX"], parse_dates=["DATE"]).set_index("DATE")
D["tmax"] = w.TMAX.reindex(D.index) / 10                               # tenths of °C to °C
D["month"] = D.index.to_period("M")
print(f"{len(D):,} days; {int(D[TYPES].values.sum()):,} incidents of {len(TYPES)} types")
2,192 days; 1,022,038 incidents of 8 types

Example 1: hot days and cool days in the same month

The plainest version of the test: within each month, compare the five warmest days with the five coolest:

rank = D.groupby("month").tmax.rank(method="first")
size = D.groupby("month").tmax.transform("size")
hot, cool = rank > size - 5, rank <= 5
for col in ("BATTERY", "ASSAULT", "HOMICIDE", "DECEPTIVE PRACTICE"):
    print(f"{col.lower():19s} warmest 5 days {D.loc[hot, col].mean():6.1f} a day, coolest 5 {D.loc[cool, col].mean():6.1f} "
          f"({D.loc[hot, col].mean() / D.loc[cool, col].mean() - 1:+.0%})")
print(f"temperature: warmest 5 average {D.loc[hot, 'tmax'].mean():.1f} °C, coolest 5 {D.loc[cool, 'tmax'].mean():.1f} °C")
battery             warmest 5 days  128.5 a day, coolest 5  111.3 (+15%)
assault             warmest 5 days   60.5 a day, coolest 5   53.6 (+13%)
homicide            warmest 5 days    2.2 a day, coolest 5    1.6 (+42%)
deceptive practice  warmest 5 days   49.3 a day, coolest 5   49.0 (+1%)
temperature: warmest 5 average 23.5 °C, coolest 5 8.7 °C

Within the same months, the five warmest days averaged 23.5 °C against 8.7 °C for the five coolest, and had 15% more batteries and 13% more assaults. Homicides were 42% more frequent, on averages of 2.2 and 1.6 a day; deceptive practice differed by 1%.

Incidents a day on the five warmest days of each month compared with the five coolest, Chicago 2019 to 2024. warmest 5 days against coolest 5, same monthhomicide+42% (2.2 vs 1.6 a day)battery+15% (128.5 vs 111.3)assault+13% (60.5 vs 53.6)deceptive practice+1% (49.3 vs 49.0)025%50%The warm days averaged 23.5 °C, the cool days 8.7 °C spatialworkflow.io
Warm days against cool days in the same month.

Season and temperature are mixed together

What the raw correlation between daily crime and temperature mixes together. two things in one correlationSeasoneverything January to July changesdaylight, holidays, schoolmonth means: 79% of temperature varianceremoved by subtracting month meansWeathera day warmer than usual for its monthmore people out, for longerbattery +1.1% per °Cfraud: +0.1%, the controlRaw r for battery 0.50; within month 0.41 spatialworkflow.io
Season and weather in one correlation.

The raw correlation between daily crime and temperature mixes two things. One is the season: everything that changes between January and July, of which temperature is only one part. The other is the effect of weather itself: on a day warmer than usual for the time of year, more people are out, for longer, which raises the chances of confrontations and of street crime. Removing the month's mean from both series takes out the season, and what is left is the day-to-day relationship. Removing the weekday mean as well stops the weekend peak from blurring it.

The method is a simple version of the fixed-effects regressions used in the research literature, with month and weekday as the fixed effects. Its result is smaller than the raw correlation suggests, but not small: battery and assault rise by about one percent per degree on a day warmer than usual, while deceptive practice, mostly fraud reported after the fact, shows almost nothing, which is what a weather effect predicts and a calendar artefact would not.

Temperature is also a stand-in. Hot days are often dry and long, and rain keeps people indoors; precipitation, daylight and holidays are the next variables to add, and the lagged effect of a heatwave is a question of its own.

One station, counts and overdispersion

Relating crime to temperature: problems and fixes. crime and weather trapsTrapInsteadraw daily correlationremove month and weekday meansa straight line assumedbin the anomaly: steepest whenhottestno control seriesfraud: no weather effect, 0.02temperature as the only causeadd rain, daylight, holidaysChicago 2019 to 2024, O'Hare GHCN-Daily spatialworkflow.io
Crime and weather traps.
  • One station for a city: O'Hare sits at the city's north-west edge; neighbourhood temperatures differ, especially near the lake, but the day-to-day variation is shared.
  • Counts, not rates: daily counts assume a stable population over the period, which holds for a few years.
  • 2020: lockdowns changed crime and mobility at once; month means absorb the level shift, and a sensitivity check without 2020 is worth running.
  • Midnight records: incidents recorded at exactly 00:00 have uncertain dates as well as times; for daily counts the effect is small.
  • Overdispersion: for a formal model, use a negative binomial regression with month and weekday terms, which gives standard errors as well as the slope.

FAQ

Does hot weather increase crime?

In Chicago from 2019 to 2024, a day warmer than usual for its month had about 1% more batteries and assaults per degree Celsius; fraud showed no such effect.

How do I remove seasonality from daily crime data?

Subtract each month's mean, and each weekday's mean, from the daily counts, and use the temperature's difference from its monthly mean.

Where can I get daily temperature data for a city?

NOAA's GHCN-Daily has daily maximum and minimum temperature and precipitation for thousands of stations, one CSV per station.

Why is the raw correlation between crime and temperature misleading?

Because both follow the season; much of the correlation is summer against winter, which includes daylight, holidays and other changes as well as temperature.