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.

Which Probability Model? A decision flow. If the data are individual cases and controls with locations, use the Bernoulli model. If the data are counts by area with a trustworthy population or expected count, use the Poisson model. If the data are counts by area and time with no reliable denominator, use the space-time permutation model, provided reporting coverage was stable over the period. If the data are a continuous measurement per location, use the normal model, weighted by precision where each value is an estimate. Let the data structure pick the model What do you hold? cases + controls at locations Bernoulli counts by area good denominator Poisson counts by area + time no denominator ST permutation stable coverage only continuous value per location Normal weight by precision rates are not a data type — they are counts over a denominator; scan the counts

Prerequisites

  • python 3.11
  • pandas 2.2.2, numpy 1.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.

Why Rates Should Not Be Scanned Directly Two areas compared. A rural area with forty residents and two cases has a rate of five thousand per hundred thousand. A city area with forty thousand residents and sixty cases has a rate of one hundred and fifty per hundred thousand. An unweighted normal model on rates flags the rural area as the top cluster. A Poisson model on counts and population recognises that two cases in forty people is consistent with chance and instead identifies a real excess in the city area. A rate from 40 people is not a rate from 40,000 Normal model on rates rural: 2 / 40 = 5,000 per 100k city: 60 / 40,000 = 150 per 100k top cluster: rural (noise) Poisson on counts rural: 2 observed, 0.04 expected city: 60 observed, 40 expected top cluster: city (real excess) the likelihood ratio accounts for how much evidence each count carries

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
The Assumption Each Model Must Pass Four columns, one per model, each naming the check that must pass before running. Poisson: denominator year close to the case period. Bernoulli: control frame represents the at-risk population. Space-time permutation: reporting coverage stable across the period. Normal: each value weighted by its precision. Failing the check means fixing the data or switching model, not running anyway. One check per model, before the run Poisson denominator year near case period Bernoulli controls represent population at risk ST permutation reporting coverage stable over time Normal values weighted by precision a failed check means fix the data or change model — not run anyway

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.