Choosing a Scan Model for Count Versus Rate Data
This guide, part of Spatial Scan Statistics Configuration, is about the first setting in any scan analysis — the probability model — and why it has to follow from the data rather than from the last analysis someone ran.
Problem Context & Constraints
A spatial scan statistic compares the observed data inside each candidate window with what the null hypothesis predicts. What “predicts” means is set by the probability model, and each model asks for different inputs.
The Poisson model takes case counts and a population, or expected counts, for each area. It asks whether the case count inside a window exceeds what the population share predicts. It suits registry and surveillance counts with a reliable denominator. The Bernoulli model takes individual cases and controls, each at a location, and asks whether the case proportion inside a window is elevated. It suits case-control studies and screening programs where every tested person is recorded. The normal model takes a continuous value per location — an average blood lead level, a rate that is itself an estimate — and scans for windows with a higher mean. The space-time permutation model needs only case counts by area and time, with no denominator at all; it looks for windows where the space-time interaction is unusual.
Choosing wrongly is easy. Running a normal model on area rates ignores that small areas have noisy rates and treats a rate from 40 people the same as one from 40,000. Running a Poisson model with an out-of-date denominator turns population growth into a cluster. Running a space-time permutation model on data whose reporting coverage changed over the period turns the change in reporting into a cluster.
Prerequisites
python3.11pandas2.2.2,numpy1.26.4- SaTScan 10.1 installed on the analysis host, driven by parameter files
- The data in whichever structure you hold: counts and population by area, case and control locations, counts by area and date, or continuous values with their precision
- The configuration workflow in Configuring SaTScan for Retrospective Cluster Detection
Step-by-Step Solution
Inspect the data to decide which structure it has, check the assumptions that model needs, then write the matching SaTScan input files and model parameter. Encoding the decision in code keeps it from being made by habit.
# Choose and prepare a SaTScan probability model from the data structure.
# Pinned: pandas==2.2.2, numpy==1.26.4
import logging
from pathlib import Path
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("scanmodel")
# SaTScan ModelType codes: 0 Poisson, 1 Bernoulli, 2 space-time permutation, 5 normal
MODEL_CODE = {"poisson": 0, "bernoulli": 1, "st_permutation": 2, "normal": 5}
def choose_model(df: pd.DataFrame) -> str:
cols = set(df.columns)
if {"is_case", "x", "y"} <= cols:
return "bernoulli"
if {"value", "variance"} <= cols or {"value", "weight"} <= cols:
return "normal"
if {"cases", "population"} <= cols:
return "poisson"
if {"cases", "date"} <= cols:
return "st_permutation"
raise ValueError(f"cannot infer a scan model from columns {sorted(cols)}")
def check_poisson(df: pd.DataFrame, pop_year: int, case_years: tuple[int, int]) -> None:
gap = max(abs(case_years[0] - pop_year), abs(case_years[1] - pop_year))
if gap > 3:
log.warning("population year %d is %d years from case period; "
"growth areas may appear as clusters", pop_year, gap)
if (df["population"] <= 0).any():
raise ValueError("areas with zero population but a case record")
def check_st_permutation(df: pd.DataFrame, reporters: pd.DataFrame) -> None:
"""Coverage must be stable: reporting sites per area should not trend over time."""
by = reporters.groupby(["area_id", pd.Grouper(key="date", freq="QS")]).size().unstack(fill_value=0)
first, last = by.iloc[:, 0], by.iloc[:, -1]
changed = ((last - first).abs() / first.replace(0, np.nan) > 0.25).sum()
if changed:
log.warning("%d areas changed reporting coverage by >25%%; "
"space-time permutation may report the change as a cluster", int(changed))
def write_inputs(df: pd.DataFrame, model: str, out: Path) -> dict:
out.mkdir(parents=True, exist_ok=True)
if model == "poisson":
df[["area_id", "cases", "date"]].to_csv(out / "cases.cas", sep=" ", header=False, index=False)
df[["area_id", "year", "population"]].drop_duplicates().to_csv(out / "pop.pop", sep=" ", header=False, index=False)
elif model == "bernoulli":
df[df.is_case][["loc_id"]].assign(n=1).to_csv(out / "cases.cas", sep=" ", header=False, index=False)
df[~df.is_case][["loc_id"]].assign(n=1).to_csv(out / "controls.ctl", sep=" ", header=False, index=False)
elif model == "st_permutation":
df[["area_id", "cases", "date"]].to_csv(out / "cases.cas", sep=" ", header=False, index=False)
elif model == "normal":
df[["area_id"]].assign(n=1, value=df["value"], weight=df.get("weight", 1 / df["variance"])) \
.to_csv(out / "cases.cas", sep=" ", header=False, index=False)
log.info("model=%s (SaTScan ModelType=%d) inputs written to %s", model, MODEL_CODE[model], out)
return {"ModelType": MODEL_CODE[model]}
The function refuses to guess when the columns do not match a known structure. In particular, a table of area rates without counts or populations has no scan model that suits it well: the right move is to go back to the counts and denominators the rates were computed from, not to scan the rates with a normal model.
The normal model is legitimate for continuous measurements — mean blood lead levels by block group, average body-mass index from a survey — provided each value is weighted by its precision. SaTScan’s weighted normal model accepts a weight per location; using the inverse of the variance stops imprecise values from dominating the scan.
Validation & Edge Cases
Stale denominators. A Poisson scan on 2025 cases with 2020 population turns every fast-growing suburb into a candidate cluster. Use intercensal estimates, or covariate-adjust for growth, and log the population year alongside the case period, as check_poisson does.
Expected counts instead of population. When age-standardisation matters, give SaTScan expected counts computed from age-specific reference rates rather than raw population. The Poisson model accepts either; with an older-skewed rural population, the difference decides whether rural areas appear elevated for cancer outcomes.
Space-time permutation with changing coverage. The permutation model assumes that, without clustering, the spatial share of cases is the same in every period. A new urgent care network, a laboratory onboarding or a change in who submits reports violates that. check_st_permutation flags areas whose reporting sites changed by more than a quarter; exclude those periods or use a Poisson model with a reporting-adjusted denominator.
Bernoulli with a biased control frame. The Bernoulli model’s validity depends on controls representing the at-risk population. Controls from a single provider network over-represent its catchment and make everywhere else look like a case cluster.
Overdispersion. Surveillance counts often vary more than a Poisson model allows, especially for infectious disease. Overdispersed data give too many significant windows. Check the ratio of variance to mean in stratified counts; where it is well above one, report that the p-values are optimistic, or use a method that models overdispersion directly, such as a negative binomial scan or a Bayesian model.
Zero-heavy data. Many areas with zero cases are fine for the Poisson and Bernoulli models but make the normal model on rates meaningless, because a zero rate from 12 people and a zero rate from 12,000 carry very different evidence.
Mixed data sources. Combining laboratory-confirmed counts from one system with syndromic counts from another in the same scan mixes two reporting processes with different coverage. Scan them separately, or model the source explicitly, rather than summing counts that were never on the same footing.
INFO scanmodel model=poisson (SaTScan ModelType=0) inputs written to runs/2026-09-18
WARNING scanmodel population year 2020 is 5 years from case period; growth areas may appear as clusters
Compliance Notes
Record the probability model and the reason it was chosen, the checks run and their results, the denominator source and year, the control frame for Bernoulli analyses, the coverage check for space-time permutation, and the weighting for normal models.
The model choice belongs in the methods statement of every published cluster result. A reader who knows a cluster came from a space-time permutation model will ask about reporting changes; one who knows it came from a Poisson model will ask about the denominator. Making those questions easy to ask is part of making the result defensible.
Related Topics
- Spatial Scan Statistics Configuration — the parent guide.
- Interpreting Overlapping SaTScan Clusters — reading the output once the model is right.
- Estimating Intercensal Population for Trend Analysis — current denominators for the Poisson model.