This article explains what data SPI needs and how to prepare them for
the simple SPI (spi_simple()) or the model-based SPI
(spi_index()).
1. What you need
The simple SPI uses annual NPAFP counts and annual population under 15. The model-based SPI uses monthly NPAFP counts, annual population under 15 and district boundaries.
The input requirements are:
Simple SPI, spi_simple()
|
Model-based SPI, spi_index()
|
|
|---|---|---|
| NPAFP cases | One row per district and year | One row per district and month, including a zero count for months with no cases |
| Population under 15 | One row per district and year | One row per district and year |
| District boundaries | Not needed | Required, as an sf polygon layer with matching district
identifiers |
| INLA | Not needed | Required |
2. Four things to get right
Use the same district identifier in every table
Use a stable code, such as the POLIS district GUID
(adm2_guid), rather than the district name. Spelling
differences and name changes can prevent records from matching when
tables are joined by name.
Check that the identifiers match
ids_cases <- unique(synth_surveillance$cases$adm2_guid)
ids_pop <- unique(synth_surveillance$population$adm2_guid)
# districts in the case data with no population
setdiff(ids_cases, ids_pop)
#> character(0)The result is empty here, so every district identifier in the case data also appears in the population table.
Keep districts and years with zero cases
A missing row and zero cases are not necessarily the same thing. If a district was under surveillance but reported no NPAFP cases in a year, retain that district-year with a zero count.
Supply a separate population table covering every district-year to be
analysed. spi_simple() treats a district-year with a
population record but no case row as zero reported cases. If you use one
combined table, include a row with the population and a zero case count
for each such district-year.
Check the population
Population is used to calculate expected cases, so an abrupt change
can affect the SPI. spi_simple() checks each district’s
population history. After calculating spi_results in
section 3, use the following code to list districts flagged for
review:
spi_results$population_qc |>
dplyr::filter(population_qc != "ok")A flag is a reason to check the population estimate, not to replace it automatically. A large change may reflect migration, displacement, a revised estimate or a district boundary change.
Review changes in district geography
A new district may have no earlier records of its own. For a split or merged district, earlier records may cover a different area. Stable identifiers help with name changes, but they do not resolve changes in geography. Where possible, use a table linking old and new districts (a crosswalk) or prepare all years using the same district boundaries.
3. Data for the simple SPI
For the simple SPI, one table with one row per district and year is enough:
| district | year | npafp_cases | population_u15 |
|---|---|---|---|
| A | 2022 | 8 | 100 000 |
| A | 2023 | 10 | 105 000 |
The expected count uses the district’s own rate over all earlier
years. At least one earlier year and one earlier NPAFP case are needed
for a positive expectation; otherwise SPI is NA and
history_check explains why. Keep those district-years in
the data so that zero reporting and missing history remain visible.
To try this with the bundled example data, create
afp_data by adding the monthly counts for each district and
year and joining the annual population:
afp_data <- synth_surveillance$cases |>
dplyr::mutate(year = lubridate::year(month)) |>
dplyr::summarise(npafp_cases = sum(count), .by = c(adm2_guid, year)) |>
dplyr::left_join(synth_surveillance$population,
by = c("adm2_guid", "year")) |>
dplyr::rename(district = adm2_guid, population_u15 = pop_u15)
spi_results <- spi_simple(afp_data)
#> ℹ Reading district-year counts...
#> ✔ 236 districts, 10 years (2015-2024).
#> ℹ Checking population series...
#> ✔ No unusual population pattern found.
#> ℹ Calculating expected cases for 7 assessment years (2018-2024)...
#> ✔ SPI calculated for 1644 district-years.
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Simple SPI summary ──
#>
#> ── Formulas used
#> history_rate = preceding cases / preceding population x 100 000
#> expected = pop x history_rate / 100 000
#> oe = observed / expected
#> spi = oe / national_oe
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Data overview (2024)
#> Districts assessed: 236
#> Reported NPAFP cases (all districts): 6 085
#> National comparison (positive expected counts only): 6 085 reported; 4 771.9
#> expected
#> National observed/expected: 1.28
#> Districts with SPI below 1: 141 of 236
#> No SPI: 0 with no previous case, 0 with no previous year, 0 with no positive
#> national ratio
#> Population flags: 0
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Results by year
#> ────────────────────────────────────────────────────────────────────────────────If the population is in a separate table, pass it as
population:
afp_counts <- dplyr::select(afp_data, district, year, npafp_cases)
population <- dplyr::select(afp_data, district, year, population_u15)
spi_results <- spi_simple(
afp_counts,
population = population
)
#> ℹ Reading district-year counts...
#> ✔ 236 districts, 10 years (2015-2024).
#> ℹ Checking population series...
#> ✔ No unusual population pattern found.
#> ℹ Calculating expected cases for 7 assessment years (2018-2024)...
#> ✔ SPI calculated for 1644 district-years.
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Simple SPI summary ──
#>
#> ── Formulas used
#> history_rate = preceding cases / preceding population x 100 000
#> expected = pop x history_rate / 100 000
#> oe = observed / expected
#> spi = oe / national_oe
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Data overview (2024)
#> Districts assessed: 236
#> Reported NPAFP cases (all districts): 6 085
#> National comparison (positive expected counts only): 6 085 reported; 4 771.9
#> expected
#> National observed/expected: 1.28
#> Districts with SPI below 1: 141 of 236
#> No SPI: 0 with no previous case, 0 with no previous year, 0 with no positive
#> national ratio
#> Population flags: 0
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Results by year
#> ────────────────────────────────────────────────────────────────────────────────If your columns have different names, specify them in the function call:
afp_custom <- afp_data |>
dplyr::rename(adm2_guid = district, yr = year,
npafp = npafp_cases, u15_pop = population_u15)
spi_results <- spi_simple(
afp_custom,
id_col = "adm2_guid",
year_col = "yr",
count_col = "npafp",
pop_col = "u15_pop"
)
#> ℹ Reading district-year counts...
#> ✔ 236 districts, 10 years (2015-2024).
#> ℹ Checking population series...
#> ✔ No unusual population pattern found.
#> ℹ Calculating expected cases for 7 assessment years (2018-2024)...
#> ✔ SPI calculated for 1644 district-years.
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Simple SPI summary ──
#>
#> ── Formulas used
#> history_rate = preceding cases / preceding population x 100 000
#> expected = pop x history_rate / 100 000
#> oe = observed / expected
#> spi = oe / national_oe
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Data overview (2024)
#> Districts assessed: 236
#> Reported NPAFP cases (all districts): 6 085
#> National comparison (positive expected counts only): 6 085 reported; 4 771.9
#> expected
#> National observed/expected: 1.28
#> Districts with SPI below 1: 141 of 236
#> No SPI: 0 with no previous case, 0 with no previous year, 0 with no positive
#> national ratio
#> Population flags: 0
#> ────────────────────────────────────────────────────────────────────────────────
#>
#> ── Results by year
#> ────────────────────────────────────────────────────────────────────────────────Starting from monthly counts
Sum the monthly counts for each district and year before running
spi_simple(). This example uses the package’s synthetic
data.
The complete population table supplied below restores district-years with no case rows as zero counts.
afp_harad <- synth_surveillance$cases |>
dplyr::mutate(year = lubridate::year(month)) |>
dplyr::summarise(count = sum(count), .by = c(adm2_guid, year))
res_harad <- spi_simple(
afp_harad,
population = synth_surveillance$population,
id_col = "adm2_guid",
count_col = "count",
pop_col = "pop_u15",
verbose = FALSE
)
res_harad
#>
#> ── Simple SPI ──
#>
#> Assessment years: 2018-2024
#> Districts: 236
#> Latest assessment: 2024
#> SPI < 1: 141 (60%)
#> SPI < 0.8: 93 (39%)
#> SPI < 0.6: 56 (24%)
#> Population flags: 0
#> No SPI: 0
#> Use summary() for the full report, as_tibble() for the table and
#> spi_simple_explain() for one district.Use spi_check_inputs() with
method = "simple" to check the table before calculating. It
reports missing columns, duplicate district-years, invalid counts or
populations, district-years with no row, and districts whose population
series needs review:
spi_check_inputs(afp_data, method = "simple")Calculating the simple SPI shows how the calculation works and how to inspect the result for one district.
4. Data for the model-based SPI
The model-based SPI needs monthly NPAFP counts, annual population under 15 and district boundaries. Use the same district identifier in all three inputs.
| district_id | month | count |
|---|---|---|
| A | 2022-01-01 | 1 |
| A | 2022-02-01 | 0 |
Include one row for every district-month, with a zero count for months with no cases. Start with all districts in the boundary file so that districts with no case records are included. Create a row for each district and month, then join the case counts:
boundaries <- synth_surveillance$boundaries |>
dplyr::rename(district_id = adm2_guid)
monthly_counts <- synth_surveillance$cases |>
dplyr::rename(district_id = adm2_guid)
# every district in the boundary file
districts <- tibble::tibble(
district_id = unique(boundaries$district_id)
)
# every month in the period to analyse
months <- tibble::tibble(
month = seq(min(monthly_counts$month), max(monthly_counts$month), by = "month")
)
# one row for every district and month
district_months <- dplyr::cross_join(districts, months)
# add the counts; a district-month with no count becomes zero
cases <- district_months |>
dplyr::left_join(monthly_counts, by = c("district_id", "month")) |>
dplyr::mutate(count = dplyr::coalesce(count, 0L))Use spi_check_inputs() to check the case table,
population table and district boundaries together. This check does not
require INLA:
spi_check_inputs(
cases = synth_surveillance$cases,
population = synth_surveillance$population,
shapefile = synth_surveillance$boundaries,
id_col = "adm2_guid"
)
#>
#> ── spi input check ─────────────────────────────────────────────────────────────
#> ✔ All input checks passed -- 236 districts x 120 months (2015-01 to 2024-12).Calculating the model-based SPI shows how to fit the model and calculate the SPI from these inputs.
Using POLIS data?
Using SPI with POLIS data gives a complete workflow from the AFP line list and population data to both the simple and model-based SPI.