Skip to contents

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:

NPAFP rate=NPAFP casespopulation under 15×100000 \text{NPAFP rate} = \frac{\text{NPAFP cases}}{\text{population under 15}} \times 100\,000

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         120000

Step 1. Add the earlier cases

Only the years before 2026 are used: 8 + 10 + 9 + 11 = 38 cases.

Step 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.84

Can 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.

expected cases=current population×previous rate100000 \text{expected cases} = \text{current population} \times \frac{\text{previous rate}}{100\,000}

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:

Eit=Pit∑s<tYis∑s<tPis E_{it} = P_{it} \, \frac{\sum_{s<t} Y_{is}}{\sum_{s<t} P_{is}}

where YY is NPAFP cases, PP is population under 15, and s<ts < t covers the years before the assessment year. For District A:

E=120000×38430000=10.60 E = 120\,000 \times \frac{38}{430\,000} = 10.60

District A’s expected count is therefore 10.60 cases.

Step 5. Calculate the district O/E

District A actually reported 6 cases.

O/E=610.60=0.57 O/E = \frac{6}{10.60} = 0.57

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.566

Step 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.

O/Enational=∑j:Ej>0Oj∑j:Ej>0Ej=456510.7=0.89 O/E_{\text{national}} = \frac{\sum_{j:E_j>0} O_j}{\sum_{j:E_j>0} E_j} = \frac{456}{510.7} = 0.89

national <- res$summary |>
  dplyr::filter(expected > 0) |>
  dplyr::summarise(
    observed = sum(observed),
    expected = sum(expected)
  ) |>
  dplyr::mutate(national_oe = observed / expected)

national
#> # A tibble: 1 x 3
#>   observed expected national_oe
#>      <dbl>    <dbl>       <dbl>
#> 1      456     511.       0.893

Step 7. Calculate the SPI

The SPI divides the district’s ratio by the national ratio:

SPI=O/EdistrictO/Enational=0.570.89=0.63 \text{SPI} = \frac{O/E_{\text{district}}}{O/E_{\text{national}}} = \frac{0.57}{0.89} = 0.63

District A’s SPI is therefore 0.63. Its observed-to-expected ratio was lower than the national ratio.

current_a |>
  dplyr::mutate(spi = observed_to_expected / national$national_oe) |>
  dplyr::select(district, observed_to_expected, spi)
#> # A tibble: 1 x 3
#>   district observed_to_expected   spi
#>   <chr>                   <dbl> <dbl>
#> 1 A                       0.566 0.634

The whole chain for District A

Earlier cases=38Earlier child-years=430000Previous rate=38430000×100000=8.84Expected=120000×38430000=10.60O/Edistrict=610.60=0.57O/Enational=∑O∑E=0.89SPI=0.570.89=0.63 \begin{aligned} \text{Earlier cases} &= 38 \\ \text{Earlier child-years} &= 430\,000 \\ \text{Previous rate} &= \frac{38}{430\,000} \times 100\,000 = 8.84 \\ \text{Expected} &= 120\,000 \times \frac{38}{430\,000} = 10.60 \\ O/E_{\text{district}} &= \frac{6}{10.60} = 0.57 \\ O/E_{\text{national}} &= \frac{\sum O}{\sum E} = 0.89 \\ \text{SPI} &= \frac{0.57}{0.89} = 0.63 \end{aligned}

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:

SPI=district O/Enational O/E=0.900.75=1.20 \text{SPI} = \frac{\text{district O/E}}{\text{national O/E}} = \frac{0.90}{0.75} = 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 flag

Use 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     2

The 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   0

District-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").