The simple surveillance performance index (SPI) compares a district’s
reported cases with the number expected from earlier reporting, then
compares that observed-to-expected ratio with the national ratio for the
same year. This article works through the calculation for one district
and shows how to use spi_simple() for all districts.
Every step is arithmetic that can be checked by hand.
The simple SPI has seven calculation steps. Each can be reproduced from the reported cases and population data.
1. Earlier cases Add cases from earlier years
2. Earlier child-years Add U15 populations from earlier years
3. Previous rate Cases ÷ child-years × 100 000
4. Expected cases Current population × previous rate ÷ 100 000
5. District O/E Current cases ÷ expected cases
6. National O/E Total observed ÷ total expected
7. SPI District O/E ÷ national O/E
The sections below work through each step using District A.
Part I: Build the SPI by hand
1. What does the SPI measure?
The annual non-polio acute flaccid paralysis (NPAFP) rate is:
In 2026, District A reported 6 NPAFP cases among 120 000 children under 15, a rate of 5.0 per 100 000. This rate tells us how many cases were reported for the size of the population.
The simple SPI uses the same cases and population, together with earlier years of data. It first asks: given what this district reported before, how many cases would we expect now? It compares reported cases with that expected count, then compares the district’s ratio with the national ratio.
2. Build it step by step
The example is an invented country of 30 districts, with one row per district and year. We follow District A and assess 2026.
afp |>
dplyr::filter(district == "A")
#> # A tibble: 5 x 4
#> district year npafp_cases population_u15
#> <chr> <dbl> <dbl> <dbl>
#> 1 A 2022 8 100000
#> 2 A 2023 10 105000
#> 3 A 2024 9 110000
#> 4 A 2025 11 115000
#> 5 A 2026 6 120000Step 2. Add the earlier populations
Add the under-15 populations for 2022 to 2025: 100 000 + 105 000 + 110 000 + 115 000 = 430 000 child-years. One child-year represents one child for one year. Adding annual populations accounts for both population size and the number of years covered.
Step 3. Calculate the previous rate
38 / 430 000 × 100 000 = 8.84 per 100 000.
The same three steps in R:
previous_a <- afp |>
dplyr::filter(district == "A", year < 2026) |>
dplyr::summarise(
previous_cases = sum(npafp_cases),
previous_child_years = sum(population_u15)
) |>
dplyr::mutate(
previous_rate = previous_cases / previous_child_years * 100000
)
previous_a
#> # A tibble: 1 x 3
#> previous_cases previous_child_years previous_rate
#> <dbl> <dbl> <dbl>
#> 1 38 430000 8.84Can the simple SPI be calculated?
The simple SPI requires at least one earlier year and one earlier
NPAFP case. This is a requirement for calculating the index, not a
threshold for how much historical information is sufficient. A district
with one earlier year can receive an SPI, but that value is based on
less history than one calculated from several earlier years. With no
earlier year, there is no historical rate. With earlier years but no
cases, the historical rate and expected count are zero, so O/E is
undefined. history_check records which applies, and such
district-years are left out of the national O/E in Step 6.
| District | Earlier years | Earlier cases | Earlier child-years | Previous rate |
|---|---|---|---|---|
| A | 4 | 38 | 430 000 | 8.84 |
| B | 1 | 1 | 40 000 | 2.50 |
| C | 4 | 2 | 1 000 000 | 0.20 |
Rates are NPAFP cases per 100 000 children under 15.
District A has four earlier years and 38 cases behind its previous rate. District B has only one earlier year and one case. Its SPI can still be calculated, but the historical rate is based on little data. District C has four earlier years and 1 million child-years behind its low historical rate. Each district’s previous rate is used unchanged.
Step 4. Calculate expected cases
We now have a rate for what District A would be expected to report in 2026. To turn that rate into a number of cases, multiply it by the current population.
This is the NPAFP rate calculation run in reverse. Instead of using cases and population to calculate a rate, we use the rate and population to calculate the number of cases expected.
The factor of 100 000 used to express the historical rate cancels when the rate is converted back to an expected count. The expected count can therefore be calculated directly as current population × earlier cases ÷ earlier child-years:
where is NPAFP cases, is population under 15, and covers the years before the assessment year. For District A:
District A’s expected count is therefore 10.60 cases.
Step 5. Calculate the district O/E
District A actually reported 6 cases.
Its observed-to-expected ratio is therefore 0.57. Before considering what happened nationally, District A reported about 57% of its expected count.
current_a <- afp |>
dplyr::filter(district == "A", year == 2026) |>
dplyr::mutate(
previous_rate = previous_a$previous_rate,
expected = population_u15 * previous_rate / 100000,
observed_to_expected = npafp_cases / expected
)
current_a |>
dplyr::select(npafp_cases, population_u15, previous_rate, expected,
observed_to_expected)
#> # A tibble: 1 x 5
#> npafp_cases population_u15 previous_rate expected observed_to_expected
#> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 6 120000 8.84 10.6 0.566Step 6. Calculate the national O/E
Reporting may also have fallen nationally in 2026. To see how District A compares, calculate expected cases for every district, then divide the total reported cases by the total expected cases. Only districts with an expected count above zero are included, and the same set of eligible districts is used in both the national observed and expected totals.
Part II: Understand the result
3. What does the SPI mean?
| SPI | Meaning |
|---|---|
| 1.00 | The district’s observed-to-expected ratio equals the national ratio |
| 0.80 | The district’s observed-to-expected ratio is 20% below the national ratio |
| 0.60 | The district’s observed-to-expected ratio is 40% below the national ratio |
| 1.20 | The district’s observed-to-expected ratio is 20% above the national ratio |
The observed-to-expected ratio (O/E) is reported cases divided by expected cases. SPI divides the district’s O/E by the national O/E for the same year. An SPI below 1 means the district’s ratio is lower than the national ratio. An SPI below 1 does not necessarily mean the district reported fewer cases than expected. For example, the district O/E could be 1.10 while the national O/E is 1.20.
A district can report fewer cases than expected and still have an SPI above 1, if the whole country fell further. For example, a district that reported 90% of its expected cases in a year when the country reported 75% has an SPI of 1.20:
The simple SPI compares current reporting with earlier reporting and with the national ratio. It does not establish that AFP cases were missed. Interpret it alongside the NPAFP rate and the other surveillance indicators. See Interpreting SPI alongside AFP indicators.
4. Check the numbers behind the SPI
To check a district’s SPI, inspect the reported cases, population, earlier reporting rate, expected cases, and national ratio used in the calculation:
| Component | District A |
|---|---|
| Current NPAFP cases | 6 |
| Current population under 15 | 120 000 |
| Current NPAFP rate | 5.00 |
| Earlier cases | 38 |
| Earlier child-years | 430 000 |
| Previous rate | 8.84 |
History check (history_check) |
ok |
| Expected cases | 10.60 |
| District observed/expected | 0.57 |
| National observed/expected | 0.89 |
| SPI | 0.63 |
If the SPI looks surprising, check:
- Is the population plausible?
- How much information from earlier years does the district have?
- Is the expected number consistent with its earlier reporting?
- Did reporting also change nationally?
All rates in the table are per 100 000 children under 15. A district that has consistently reported few cases for its population can have an SPI near 1, so check its NPAFP rate as well.
Part III: Let the package do the calculation
5. Calculate it
Everything above is calculated automatically by
spi_simple(). With one row per district and year and these
column names, one line is enough:
| district | year | npafp_cases | population_u15 |
|---|---|---|---|
| A | 2022 | 8 | 100 000 |
| A | 2023 | 10 | 105 000 |
| … | … | … | … |
spi_results <- spi_simple(afp, verbose = FALSE)Printing the result gives a short summary.
spi_results
#>
#> -- Simple SPI --
#>
#> Assessment years: 2025-2026
#> Districts: 30
#> Latest assessment: 2026
#> SPI < 1: 16 (53%)
#> SPI < 0.8: 9 (30%)
#> SPI < 0.6: 1 (3%)
#> Population flags: 1
#> No SPI: 0
#> Use summary() for the full report, as_tibble() for the table and
#> spi_simple_explain() for one district.By default, assessment starts three years after the first year in the
data, providing three earlier years of history. This call therefore
calculates results for 2025 and 2026. District B has no SPI in 2025, its
first year, because it has no earlier year. To calculate only the 2026
results used above, set first_assessment = 2026.
If the national O/E is zero because none of the districts with a
positive expected count reported a case, SPI is NA for
every district. history_check can still be
"ok": it checks the district’s earlier data, not the
national ratio.
spi_simple() can also use a separate population table
and non-standard column names; see ?spi_simple and Preparing data for SPI.
6. Inspect a district
spi_simple_explain() shows one district’s inputs and
results in the same order as the worked calculation:
spi_simple_explain(spi_results, "A", year = 2026)
#>
#> -- District A, 2026 ------------------------------------------------------------
#>
#> -- Current reporting --
#>
#> Observed NPAFP cases 6
#> Current population under 15 120 000
#> NPAFP rate 5.00
#>
#> -- Previous reporting --
#>
#> Previous years 4
#> Previous NPAFP cases 38
#> Previous child-years 430 000
#> Previous rate 8.84
#>
#> -- Expected reporting --
#>
#> Expected NPAFP cases 10.60
#>
#> -- SPI --
#>
#> District observed / expected 0.57
#> National observed / expected 0.89
#> SPI 0.63
#>
#> -- Data checks --
#>
#> Population check no flagUse this output to check the inputs and follow the arithmetic from the previous rate to the final SPI. When the SPI cannot be calculated, the output says so and gives the reason. The questions in section 4 can help explain an unexpected result.
In another language
The labels are in English by default. Set language to
show them in another language, for example French:
spi_simple_explain(spi_results, "A", year = 2026, language = "fr")The labels are translated with the sntutils package, which uses
Google Translate through the gtranslate package. Both packages must be
installed and an internet connection is needed. The numbers and the
check_ flags are not translated.
7. Check the input data
The SPI uses the population to calculate expected cases, so an abrupt
change in the population can move the result. spi_simple()
checks each district’s population series before calculating and returns
unusual patterns in population_qc. It does not alter the
populations you supply, and a flag does not mean the population is
wrong.
spi_results$population_qc |>
dplyr::filter(population_qc != "ok")
#> # A tibble: 1 x 5
#> district population_qc unusual_changes largest_change years
#> <chr> <chr> <int> <dbl> <int>
#> 1 B check_history 0 0.0500 2The flags identify different patterns:
-
check_history: the district lacks population records for some years represented in the dataset, up to the assessment year. In this example, District B first appears in 2025. -
check_spike: an unusual population change is followed by an unusual change in the opposite direction among the next two recorded transitions (two years for a complete annual series). -
check_change: another unusual population change.
A flag is a reason to check the source data. A large change can reflect real population movement, a revised population estimate, or a boundary change.
8. Compare with the NPAFP target
spi_compare_npafp() places each district-year in one of
four categories, according to whether its SPI is below 1 and whether its
NPAFP rate is below the target. Pass the simple SPI result directly; it
already contains the cases and population:
comparison <- spi_compare_npafp(spi_results, npafp_target = 3, verbose = FALSE)
comparison
#>
#> -- SPI and NPAFP classification ------------------------------------------------
#> SPI cut: 1 (median rule) | NPAFP target: 3 per 100,000 person-years
#> 59 district-years | agreement 40.7% | kappa -0.10 | McNemar p 0.0000
#>
#>
#> -- Category counts --
#> # A tibble: 4 x 3
#> category n pct
#> <chr> <int> <dbl>
#> 1 Neither below 24 40.7
#> 2 SPI below threshold only 32 54.2
#> 3 NPAFP below target only 3 5.1
#> 4 Both below 0 0District-years without an SPI are left out of the comparison. Interpreting SPI explains each category.
Pass the comparison to spi_field_guide(comparison) to
review districts using STEPS. For the simple SPI, the default cutoff is
spi_cut = 0.5. This selects larger relative shortfalls for
review; it does not measure uncertainty. Using SPI for district review explains
the review labels.
Simple and model-based SPI
Both versions compare a district’s current reporting with earlier reporting and the national ratio. They calculate expected counts differently and provide different outputs.
The simple SPI uses each district’s own earlier rate unchanged. Each value can be reproduced by hand from the district’s cases and population and the national ratio, and the calculation can be done in a spreadsheet. It does not quantify uncertainty, and it cannot be calculated when there is no earlier year or no earlier NPAFP case, or when the national ratio is zero.
The model-based SPI estimates the expected count with a spatial and temporal model. It adds uncertainty intervals and monthly estimates, and gives more stable expected counts where a district’s history is short or sparse. See Calculating the model-based SPI for that workflow.
Technical details
The full specification is in How SPI works. In R, open it
with vignette("spi-methods", package = "spi").