Methodology
How CityCrimeMap ingests, normalizes, classifies, and models crime data for 22 US cities. Every step below is implemented in publicly readable Python. Nothing is proprietary.
1. Data ingest
We consume each city's official open-data portal directly. The three portal families we support cover roughly 95% of US cities that publish crime data at all:
- Socrata (Chicago, Seattle, San Francisco, Cincinnati, Cleveland, Detroit, Denver, Minneapolis, Rochester, and others)
- ArcGIS FeatureServer / MapServer (Washington DC, Houston, Pittsburgh, Indianapolis, Boston, Hartford, Gainesville, and others)
- CKAN (fallback for a handful of smaller cities)
Ingest is handled by the open-source tidycop library (MIT), a Python port of Anthony Galvan's R tidycops package. We add nothing over the wire that the city hasn't already published; we simply pull, normalize, and cache. The registry that defines each city's field mapping is a single YAML file you can inspect.
2. Offense classification
Every city defines its own offense codes — NIBRS, UCR variants, custom vocabularies, or plain-English descriptions. Direct passthrough would make cross-city comparison impossible. We normalize into eight categories:
Classification uses the open-source tidycop-spotcrime package. Rows that don't map cleanly are labelled Unclassified rather than force-fit into a bucket. Coverage varies by city; see each city's page for the current breakdown.
3. Geocoding (where needed)
Most feeds publish latitude/longitude. When a city publishes addresses but no coordinates (currently: Boston), we resolve them via the U.S. Census Bureau batch geocoder, with a SQLite cache to avoid re-hitting the API. Unmatched rows are counted honestly as "N incidents could not be located" beneath the map.
4. Predictive risk model
The predicted-risk overlay is generated by tidycop-hotspots, a pure-Python implementation of the machine-learning Risk Terrain Modeling methodology published by Wheeler and Steenbeek in the Journal of Quantitative Criminology (2021). No ArcGIS Pro license required. The implementation is MIT-licensed and readable.
Grid
Cities are gridded into square cells of 200–300 meters depending on incident density. Denser cities get larger cells so cell counts stay uniformly-distributed enough for training; smaller cities get tighter cells so the map has visible contrast. Hex grids are also supported upstream and are a possible future refinement.
Features
- Kernel density estimate of prior incidents at each cell centroid, with a Gaussian kernel and bandwidth of 350–500 m depending on city size. Values are log-transformed (log1p of the normalized density) before being fed to the model, because raw KDE values on city-scale grids sit in the 1e-10 range and cause the RF to learn a constant.
- Cell centroid X/Y coordinates for spatial context.
- Lagged incident counts per cell from the training window.
Model
sklearn.ensemble.RandomForestRegressor with n_estimators=400, min_samples_leaf=5. Random forests are the baseline model in the Wheeler & Steenbeek paper and continue to perform competitively with gradient-boosted trees at this problem scale, with a fraction of the tuning burden.
Training / test split
We split by time, not by row — the first ≈65% of the window is training, the last ≈35% is held-out test. This is the leakage-free protocol Wheeler & Steenbeek recommend. Fitting the KDE feature on the training window only is important; refitting on the whole window would leak test-window information into the feature vector.
Inference
We deliberately do inference twice. The fitted tree is applied first to the training-window KDE to compute the honest test-set PAI. It is applied a second time to a KDE built from the full window, and that surface is what we ship on the map — so the user sees where things are hot right now, not where they were hot last month. The fitted decision function is the same both times; only the input feature vector changes.
Output
We keep only the top 10% of positive-risk cells and drop the rest, which shrinks the payload from ≈15,000 cells to a few dozen. Each output cell carries risk (0–1), rank / rank_of for ordering, and pred_count — the raw predicted incident count per cell over the training-window duration.
5. Validation — Predictive Accuracy Index (PAI)
Every model is validated with the Predictive Accuracy Index:
PAI = (incidents in flagged cells / total incidents) ÷ (flagged cell area / total area)
A PAI of 1.0 is random. 2.0 is twice as good as random; 4.0 is four times. We report PAI at 10% of the area (the standard reporting convention in the RTM literature) for every city. See the predictions page for the current per-city numbers.
6. Bias and limitations
Reported crime data does not equal real crime. It reflects both criminal activity and where policing looks. Predictive models built on that data inherit both. We flag this explicitly rather than pretend otherwise:
- The risk overlay is place-based, not person-based. We do not model or predict individuals.
- The overlay is advisory, not decisional. It is not a policing product and does not recommend deployment.
- Cities with sparse training data produce noisy models; some score at or below the random baseline. We publish those numbers alongside the good ones.
7. Reproducibility
Everything on this site is reproducible from three open-source Python packages:
- tidycop — city-agnostic data ingest
- tidycop-spotcrime — offense classifier
- tidycop-hotspots — predictive model
All MIT licensed. Clone the repo, install the packages, and you can regenerate every map on this site.