Scope. We explored the Africa subset supplied by Dr Gabor Kiss. The aim was to identify patterns worth investigating, rather than fit a model to a predetermined hypothesis. Counts refer to cases recorded in the supplied file, not independently verified incidence. The full working analysis is presented here; a separate short report can draw on selected results and figures.

Reading this notebook. All ten charts and the principal summary tables are displayed within the document, including full country tables in the supplementary section. Underlying country-year grids and other intermediate objects are not printed line-by-line; they are shown through their summaries and exported as CSV files. The setup and export chunks perform actions and therefore have no substantive numerical output.

1 Data and basic checks

1.1 Reading the source file

The file contains one row per available country and reporting month. We retained the original data and created a separate, standardised copy. The source file does not provide population denominators, a case definition or a reporting schedule, so these cannot be inferred from the counts.

# The original CSV should sit beside this Rmd file in Posit Cloud.
path <- if (file.exists("Africa_Measles.csv")) {
  "Africa_Measles.csv"
} else if (file.exists(file.path("data", "Africa_Measles.csv"))) {
  file.path("data", "Africa_Measles.csv")
} else {
  stop("Africa_Measles.csv was not found. Upload it to the project folder before knitting.")
}

measles_raw <- readr::read_csv(
  path,
  col_types = readr::cols(
    Country = readr::col_character(),
    Year = readr::col_double(),
    Month = readr::col_character(),
    `Measles total` = readr::col_double()
  ),
  show_col_types = FALSE
)

measles <- measles_raw |>
  transmute(
    country = stringr::str_squish(Country),
    year = as.integer(Year),
    month = suppressWarnings(as.integer(stringr::str_trim(Month))),
    cases = `Measles total`
  ) |>
  mutate(date = lubridate::make_date(year, month, 1))

n_countries <- n_distinct(measles$country)
raw_n <- nrow(measles_raw)

metadata <- tibble(
  Measure = c("Country-month records", "Countries represented", "First year", "Last year", "Fields in original CSV"),
  Value = c(fmt_n(raw_n), n_countries, min(measles$year, na.rm = TRUE),
            max(measles$year, na.rm = TRUE), ncol(measles_raw))
)
knitr::kable(metadata, align = c("l", "r"))
Measure Value
Country-month records 6,392
Countries represented 46
First year 2012
Last year 2025
Fields in original CSV 4

The file contains 6,392 records from 46 countries, spanning 2012–2025. Country, year and month identify the reporting period. The date field is set to the first day of each month only to support chronological plotting; it is not the date of infection.

1.2 Checking the recorded values

We checked whether the record identifiers were valid, whether monthly counts were non-negative whole numbers, and whether standardising the month field introduced duplicate country-months. Invalid values were not silently removed or changed.

missing_cells <- sum(is.na(measles_raw))
blank_countries <- sum(is.na(measles$country) | measles$country == "")
invalid_years <- sum(is.na(measles$year) | is.na(measles_raw$Year) |
                       measles_raw$Year != floor(measles_raw$Year))
invalid_months <- sum(is.na(measles$month) | !(measles$month %in% 1:12))
invalid_counts <- sum(is.na(measles$cases) | !is.finite(measles$cases) |
                        measles$cases < 0 | measles$cases != floor(measles$cases))
missing_dates <- sum(is.na(measles$date))
repeated_periods <- measles |>
  count(country, date, name = "records") |>
  filter(records > 1)

quality_checks <- tibble(
  Check = c("Missing values in original CSV", "Blank country names",
            "Invalid or non-integer years", "Invalid month values",
            "Invalid / negative / non-integer case counts",
            "Dates that could not be formed", "Repeated country-months after standardisation"),
  Count = c(missing_cells, blank_countries, invalid_years, invalid_months,
            invalid_counts, missing_dates, nrow(repeated_periods))
)
knitr::kable(quality_checks, align = c("l", "r"))
Check Count
Missing values in original CSV 0
Blank country names 0
Invalid or non-integer years 0
Invalid month values 0
Invalid / negative / non-integer case counts 0
Dates that could not be formed 0
Repeated country-months after standardisation 0

if (any(quality_checks$Count != 0)) {
  stop("At least one basic data-quality check failed. Review the table before analysis.")
}

All these checks returned zero flagged observations. This supports the internal consistency of the file’s identifiers and case-count format. It does not establish the accuracy of surveillance reports or the completeness of country-month coverage.

2 Observation coverage

2.1 What is present, and what is absent?

The study spans 2012–2025, but preliminary inspection showed no country records for October–December 2025. We therefore used 2012–2024 for full-calendar-year summaries, retained 2025 in the cleaned data, and described its shorter coverage separately. No missing country-month was converted to zero.

To quantify record availability, we constructed the set of 46 countries × 12 months × 13 years. This is a calendar-based reference, not proof that every country was officially expected to report every month.

main <- measles |> filter(year >= 2012, year <= 2024)

calendar <- tidyr::crossing(
  country = sort(unique(measles$country)),
  date = seq(as.Date("2012-01-01"), as.Date("2024-12-01"), by = "month")
) |>
  mutate(year = lubridate::year(date), month = lubridate::month(date)) |>
  left_join(main |> select(country, date, cases), by = c("country", "date")) |>
  mutate(record_present = !is.na(cases))

coverage_by_year <- calendar |>
  group_by(year) |>
  summarise(
    observed = sum(record_present),
    possible = n(),
    coverage_pct = 100 * observed / possible,
    .groups = "drop"
  )

observed_main <- nrow(main)
possible_main <- nrow(calendar)
absent_main <- sum(!calendar$record_present)
overall_coverage <- 100 * observed_main / possible_main

knitr::kable(
  coverage_by_year |> mutate(coverage_pct = round(coverage_pct, 1)),
  col.names = c("Year", "Recorded country-months", "Calendar combinations", "Coverage (%)"),
  align = c("r", "r", "r", "r")
)
Year Recorded country-months Calendar combinations Coverage (%)
2012 457 552 82.8
2013 442 552 80.1
2014 451 552 81.7
2015 472 552 85.5
2016 475 552 86.1
2017 482 552 87.3
2018 460 552 83.3
2019 483 552 87.5
2020 447 552 81.0
2021 452 552 81.9
2022 471 552 85.3
2023 487 552 88.2
2024 480 552 87.0

Of 7,176 possible country-month combinations during 2012–2024, 6,059 are recorded (84.4%), leaving 1,117 absent. Coverage varied between 80.1% and 88.2% across years. These are proportions of a constructed calendar, not official surveillance reporting rates.

coverage_country_year <- calendar |>
  group_by(country, year) |>
  summarise(months_present = sum(record_present), .groups = "drop") |>
  mutate(coverage_pct = 100 * months_present / 12)

p_coverage <- ggplot(
  coverage_country_year,
  aes(x = factor(year), y = forcats::fct_rev(factor(country)), fill = coverage_pct)
) +
  geom_tile(color = "white", linewidth = 0.18) +
  scale_fill_gradient(low = "#ece8e0", high = ink, limits = c(0, 100),
                      breaks = c(0, 25, 50, 75, 100), name = "Coverage (%)") +
  labs(title = "Monthly record availability varies across countries",
       subtitle = "Percentage of available country-month records in each calendar year",
       x = "Year", y = NULL,
       caption = "Calendar coverage does not establish surveillance system completeness.") +
  base_theme +
  theme(axis.text.y = element_text(size = 7.4),
        axis.text.x = element_text(angle = 45, hjust = 1),
        panel.grid = element_blank(), legend.position = "right")

print(p_coverage)
Availability of country-month records, 2012–2024. Each tile summarises the proportion of twelve months represented for one country-year; missing entries were not assumed to be zero cases.

Availability of country-month records, 2012–2024. Each tile summarises the proportion of twelve months represented for one country-year; missing entries were not assumed to be zero cases.

save_figure(p_coverage, "01_record_coverage_heatmap.png", width = 12, height = 11)

The heatmap shows that missing records are not evenly distributed across countries or years. A lower annual case total can therefore reflect both differences in reported activity and differences in the months represented in the file.

2.2 The partial 2025 period

coverage_2025 <- measles |>
  filter(year == 2025) |>
  count(month, name = "countries_with_records") |>
  tidyr::complete(month = 1:12, fill = list(countries_with_records = 0L)) |>
  mutate(month_name = month.abb[month]) |>
  select(month_name, countries_with_records)

knitr::kable(coverage_2025,
             col.names = c("Month in 2025", "Countries with records"),
             align = c("l", "r"))
Month in 2025 Countries with records
Jan 40
Feb 41
Mar 40
Apr 41
May 38
Jun 37
Jul 37
Aug 35
Sep 24
Oct 0
Nov 0
Dec 0

Records for 2025 extend from January to September. September includes only 24 countries, compared with 41 in February; October–December have no records. We retained the 333 available observations for descriptive checking but did not compare the 2025 partial-year total against full calendar years.

3 Distribution of recorded cases

3.1 Recorded zeros and unusually large counts

A recorded zero is not the same as an absent record. We kept zero observations in all descriptive analyses and examined the magnitude of the upper tail before using annual or monthly averages.

case_stats <- main |>
  summarise(
    observations = n(),
    total_cases = sum(cases),
    mean_cases = mean(cases),
    median_cases = median(cases),
    sd_cases = sd(cases),
    zero_records = sum(cases == 0),
    zero_pct = 100 * mean(cases == 0),
    maximum = max(cases)
  )

knitr::kable(tibble(
  Measure = c("Available country-months", "Total reported cases", "Mean per record",
              "Median per record", "Standard deviation", "Recorded zeros",
              "Records with zero cases", "Largest monthly count"),
  Value = c(fmt_n(case_stats$observations), fmt_n(case_stats$total_cases),
            round(case_stats$mean_cases, 1), case_stats$median_cases,
            round(case_stats$sd_cases, 1), fmt_n(case_stats$zero_records),
            fmt_pct(case_stats$zero_pct), fmt_n(case_stats$maximum))
), align = c("l", "r"))
Measure Value
Available country-months 6,059
Total reported cases 884,675
Mean per record 146
Median per record 9
Standard deviation 1452.5
Recorded zeros 1,237
Records with zero cases 20.4%
Largest monthly count 67,929

largest_months <- main |>
  arrange(desc(cases)) |>
  select(country, year, month, cases) |>
  slice_head(n = 10)
knitr::kable(largest_months, col.names = c("Country", "Year", "Month", "Cases"))
Country Year Month Cases
Madagascar 2019 2 67929
Madagascar 2019 3 59761
Madagascar 2019 1 50012
Madagascar 2019 4 23931
Nigeria 2013 3 17797
Nigeria 2013 2 12246
Nigeria 2013 4 9416
Nigeria 2019 3 7344
Ethiopia 2024 3 7012
Democratic Republic of the Congo 2020 1 6690

The median (9) is far below the mean (146) and 20.4% of the recorded country-months contain zero cases. The highest individual observations occur in Madagascar in early 2019. We retained these counts: their magnitude merits investigation, but an unusually large value is not itself evidence of a data-entry error.

p_hist <- ggplot(main, aes(x = log10(cases + 1))) +
  geom_histogram(bins = 42, fill = ink, color = "white", linewidth = 0.25) +
  labs(title = "Most recorded country-months contain relatively few cases",
       subtitle = "Log display accommodates both zero counts and exceptional peaks",
       x = expression(log[10](cases + 1)), y = "Country-month observations",
       caption = "The first bar includes recorded zeros; absent months do not enter the histogram.") +
  base_theme
print(p_hist)
Distribution of country-month case counts (2012–2024) on a log10(cases + 1) display scale. The original counts were not transformed for summary statistics.

Distribution of country-month case counts (2012–2024) on a log10(cases + 1) display scale. The original counts were not transformed for summary statistics.

save_figure(p_hist, "02_case_distribution.png", width = 9, height = 5)

4 Annual variation and the 2019 peak

4.1 Aggregated annual counts

We first examined the annual series using the available records. We retained the number of contributing country-months alongside each total because participation in the dataset varied from year to year.

annual <- main |>
  group_by(year) |>
  summarise(
    total_cases = sum(cases),
    months_with_records = n(),
    countries_with_records = n_distinct(country),
    mean_per_record = mean(cases),
    .groups = "drop"
  ) |>
  rename(cases = total_cases)

knitr::kable(
  annual |> mutate(mean_per_record = round(mean_per_record, 1)),
  col.names = c("Year", "Reported cases", "Country-months", "Countries", "Mean per record"),
  align = "r"
)
Year Reported cases Country-months Countries Mean per record
2012 26760 457 44 58.6
2013 77593 442 43 175.5
2014 42957 451 43 95.2
2015 46103 472 43 97.7
2016 36753 475 43 77.4
2017 24845 482 44 51.5
2018 42167 460 43 91.7
2019 289766 483 45 599.9
2020 46747 447 42 104.6
2021 26492 452 41 58.6
2022 64922 471 44 137.8
2023 73443 487 44 150.8
2024 86127 480 43 179.4

The annual series has an exceptional peak in 2019, while reported counts also rise between 2021 and 2024. The increase is visible in the average per available record as well as in the raw totals, but those averages are not population-adjusted incidence estimates.

p_annual <- ggplot(annual, aes(year, cases)) +
  geom_line(color = ink, linewidth = 0.95) +
  geom_point(color = ink, size = 2.4) +
  scale_x_continuous(breaks = 2012:2024) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(title = "The annual series is dominated by an exceptional 2019 peak",
       x = "Year", y = "Reported measles cases",
       caption = "Totals are based on available records, not a balanced 46-country panel.") +
  base_theme +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
print(p_annual)
Annual case totals from recorded country-months, 2012–2024. Observation coverage differs by year.

Annual case totals from recorded country-months, 2012–2024. Observation coverage differs by year.

save_figure(p_annual, "03_annual_measles_totals.png", width = 9.5, height = 5.1)

4.2 How much of 2019 came from Madagascar?

Madagascar’s four highest monthly observations were concentrated between January and April 2019. We quantified its full-year contribution and compared the regional series with a companion series omitting Madagascar. This is an influence check, not an argument for discarding the country.

mad_2019 <- main |>
  filter(country == "Madagascar", year == 2019) |>
  summarise(cases = sum(cases)) |>
  pull(cases)
africa_2019 <- annual |> filter(year == 2019) |> pull(cases)
mad_2019_pct <- 100 * mad_2019 / africa_2019

influence_table <- tibble(
  Series = c("All countries, 2019", "Madagascar, 2019", "All other countries, 2019"),
  Cases = c(africa_2019, mad_2019, africa_2019 - mad_2019)
)
knitr::kable(influence_table, align = c("l", "r"))
Series Cases
All countries, 2019 289766
Madagascar, 2019 213291
All other countries, 2019 76475

annual_without_mad <- main |>
  filter(country != "Madagascar") |>
  group_by(year) |>
  summarise(cases = sum(cases), .groups = "drop")

annual_compare <- bind_rows(
  annual |> transmute(year, cases, group = "All countries"),
  annual_without_mad |> mutate(group = "Excluding Madagascar")
)

Madagascar contributed 73.6% of the 289,766 reported cases in 2019. Without Madagascar, the reported total for that year was 76,475. The regional maximum therefore tells us considerably more about one country’s epidemic episode than about a synchronous increase across all countries.

p_annual_contrast <- ggplot(annual_compare, aes(year, cases, color = group)) +
  geom_line(linewidth = 1.05) +
  geom_point(size = 2.2) +
  scale_color_manual(values = c("All countries" = ink,
                                "Excluding Madagascar" = contrast)) +
  scale_x_continuous(breaks = 2012:2024) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(title = "Madagascar explains most of the 2019 aggregate spike",
       x = "Year", y = "Reported cases", color = NULL,
       caption = "The comparison illustrates one country's contribution; coverage varies across years.") +
  base_theme +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
print(p_annual_contrast)
Annual series before and after separating Madagascar's contribution. The two curves are mathematically related and are not independent samples.

Annual series before and after separating Madagascar’s contribution. The two curves are mathematically related and are not independent samples.

save_figure(p_annual_contrast, "04_annual_with_without_madagascar.png",
            width = 10, height = 5.4)

4.3 A like-for-like check of the recent increase

The 2021–2024 rise could partly reflect changes in the countries or months represented. As a modest check, we compared only countries with all twelve records in both 2021 and 2024. This holds the set of countries and the count of observed months fixed, but it still does not account for population growth, changes in surveillance sensitivity or differences in case definition.

complete_both <- main |>
  filter(year %in% c(2021, 2024)) |>
  count(country, year, name = "recorded_months") |>
  tidyr::pivot_wider(names_from = year, values_from = recorded_months,
                     names_prefix = "year_", values_fill = 0) |>
  filter(year_2021 == 12, year_2024 == 12) |>
  pull(country)

matched_totals <- main |>
  filter(country %in% complete_both, year %in% c(2021, 2024)) |>
  group_by(year) |>
  summarise(cases = sum(cases), country_months = n(), .groups = "drop")

knitr::kable(matched_totals, align = "r")
year cases country_months
2021 25098 276
2024 73234 276

Among the 23 countries with twelve recorded months in both years, the totals were 25,098 in 2021 and 73,234 in 2024. This supports the observation that the rise is not explained only by a changing number of country-month records. It remains a comparison of reported counts, not estimated incidence.

5 Differences between countries

5.1 Cumulative counts and contributing months

We ranked countries by their summed reported cases for 2012–2024, while keeping the number of available monthly records alongside the totals. This is a ranking of recorded case numbers, not measles risk.

country_totals <- main |>
  group_by(country) |>
  summarise(cases = sum(cases), months_with_records = n(), .groups = "drop") |>
  mutate(share_pct = 100 * cases / sum(cases),
         calendar_coverage_pct = 100 * months_with_records / (13 * 12)) |>
  arrange(desc(cases))

top10 <- country_totals |> slice_head(n = 10)
knitr::kable(
  top10 |>
    mutate(share_pct = round(share_pct, 1),
           calendar_coverage_pct = round(calendar_coverage_pct, 1)),
  col.names = c("Country", "Recorded cases", "Recorded months", "Share (%)", "Calendar coverage (%)"),
  align = c("l", "r", "r", "r", "r")
)
Country Recorded cases Recorded months Share (%) Calendar coverage (%)
Madagascar 225884 156 25.5 100.0
Nigeria 207569 156 23.5 100.0
Ethiopia 113044 153 12.8 98.1
Democratic Republic of the Congo 82661 156 9.3 100.0
Angola 34200 146 3.9 93.6
Cameroon 21463 156 2.4 100.0
Liberia 17483 98 2.0 62.8
Burkina Faso 16246 154 1.8 98.7
Côte d’Ivoire 13267 156 1.5 100.0
South Sudan 13074 127 1.5 81.4

The four leading countries contribute 71.1% of all reported cases during 2012–2024. This concentration is important, but different population sizes and the uneven availability of country-month records prevent us from interpreting these totals as relative infection risk. The complete 46-country table appears in the supplementary results and is also exported as a CSV.

p_country <- ggplot(top10, aes(x = cases, y = forcats::fct_reorder(country, cases))) +
  geom_col(fill = ink, width = 0.72) +
  geom_text(aes(label = sprintf("%.1f%%", share_pct)),
            hjust = -0.12, size = 3.2) +
  scale_x_continuous(labels = scales::label_comma(),
                     expand = expansion(mult = c(0, 0.19))) +
  labs(title = "Reported cases are concentrated in a few countries",
       x = "Cumulative reported cases, 2012–2024", y = NULL,
       caption = "Absolute counts, not population-standardised incidence.") +
  base_theme +
  theme(panel.grid.major.y = element_blank())
print(p_country)
The ten largest national case totals in the supplied records, 2012–2024. Labels are percentages of all recorded cases across the 46 countries.

The ten largest national case totals in the supplied records, 2012–2024. Labels are percentages of all recorded cases across the 46 countries.

save_figure(p_country, "05_leading_country_totals.png", width = 10, height = 6)

5.2 Distinct annual patterns behind large totals

Cumulative totals can conceal very different histories. We compared the four countries with the highest totals using separate vertical scales; the panels show within-country changes, not a common scale for comparing levels.

top4_names <- top10$country[1:4]
top4_panel <- tidyr::crossing(country = top4_names, year = 2012:2024) |>
  left_join(
    main |>
      filter(country %in% top4_names) |>
      group_by(country, year) |>
      summarise(cases = sum(cases), months_with_records = n(), .groups = "drop"),
    by = c("country", "year")
  ) |>
  mutate(months_with_records = replace_na(months_with_records, 0L))

p_top4 <- ggplot(top4_panel, aes(year, cases)) +
  geom_line(color = ink, linewidth = 0.9, na.rm = TRUE) +
  geom_point(color = ink, size = 1.7, na.rm = TRUE) +
  facet_wrap(~ country, ncol = 2, scales = "free_y") +
  scale_x_continuous(breaks = seq(2012, 2024, 2)) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(title = "Similar cumulative totals conceal different time profiles",
       x = "Year", y = "Reported cases",
       caption = "Different y-axes; missing country-years would remain missing, not zero.") +
  base_theme +
  theme(strip.text = element_text(face = "bold", size = 10),
        axis.text.x = element_text(angle = 45, hjust = 1))
print(p_top4)
Annual case totals for the four leading countries. Note the independent y-axis in each panel.

Annual case totals for the four leading countries. Note the independent y-axis in each panel.

save_figure(p_top4, "06_four_country_annual_patterns.png", width = 11, height = 7)

Madagascar’s cumulative total is strongly concentrated in 2019. Nigeria shows repeated higher-count years, while Ethiopia has a marked increase towards 2024. These contrasting shapes would be difficult to see from the country ranking alone.

6 Monthly patterns: an exploratory sensitivity analysis

6.1 Pooled month-of-year summaries

Mean cases per available record help account for different numbers of observations by month, but they are still sensitive to large outbreaks and to the mix of contributing countries. We therefore examined month-of-year summaries both with and without the exceptional year 2019.

monthly_all <- main |>
  group_by(month) |>
  summarise(total_cases = sum(cases), n_records = n(),
            mean_cases = mean(cases), median_cases = median(cases),
            .groups = "drop") |>
  rename(cases = total_cases) |>
  mutate(month_name = month.abb[month])

knitr::kable(
  monthly_all |>
    transmute(Month = month_name, `Recorded cases` = cases,
              `Country-months` = n_records,
              `Mean per record` = round(mean_cases, 1),
              `Median per record` = median_cases),
  align = c("l", "r", "r", "r", "r")
)
Month Recorded cases Country-months Mean per record Median per record
Jan 138450 537 257.8 10
Feb 178369 544 327.9 12
Mar 179660 543 330.9 15
Apr 102218 536 190.7 11
May 63165 530 119.2 11
Jun 38844 512 75.9 9
Jul 32257 501 64.4 8
Aug 31982 493 64.9 6
Sep 28805 491 58.7 7
Oct 31361 484 64.8 7
Nov 33619 470 71.5 8
Dec 25945 418 62.1 7

March and February have the highest pooled mean counts, but the medians are much smaller. We did not treat these averages alone as proof of a common seasonal pattern.

6.2 Separating Madagascar and excluding 2019

period_data <- bind_rows(
  main |> mutate(period = "All years"),
  main |> filter(year != 2019) |> mutate(period = "Excluding 2019")
)

monthly_sensitivity <- period_data |>
  mutate(group = if_else(country == "Madagascar", "Madagascar", "Other countries")) |>
  group_by(group, period, month) |>
  summarise(mean_cases = mean(cases), median_cases = median(cases),
            n_records = n(), .groups = "drop")

p_monthly_sensitivity <- ggplot(
  monthly_sensitivity,
  aes(month, mean_cases, color = period)
) +
  geom_line(linewidth = 0.95) +
  geom_point(size = 1.8) +
  facet_wrap(~ group, ncol = 1, scales = "free_y") +
  scale_x_continuous(breaks = 1:12, labels = month.abb) +
  scale_y_continuous(labels = scales::label_comma()) +
  scale_color_manual(values = c("All years" = ink, "Excluding 2019" = contrast)) +
  labs(title = "The apparent early-year peak is sensitive to exceptional epidemics",
       x = "Calendar month", y = "Mean reported cases per available record", color = NULL,
       caption = "The 'excluding 2019' curve is a sensitivity check, not a second independent sample.") +
  base_theme +
  theme(strip.text = element_text(face = "bold"))
print(p_monthly_sensitivity)
Pooled monthly means with and without 2019, shown separately for Madagascar and the remaining countries. The y-axis varies between panels.

Pooled monthly means with and without 2019, shown separately for Madagascar and the remaining countries. The y-axis varies between panels.

save_figure(p_monthly_sensitivity, "07_monthly_sensitivity_2019.png",
            width = 10.5, height = 7.8)

For Madagascar, omitting 2019 substantially reduces the February–March peak. Among the other countries the broad early-year concentration remains visible, although this group pools many different countries and outbreaks. These curves alone cannot establish a shared seasonal mechanism.

6.3 Is the early-year pattern repeated within countries?

We made a narrower comparison within individual country-years: January–March versus July–September. We used country-years with all six selected months present and excluded 2019, as above. This equalises the number of observed months within each comparison, but excludes country-years with gaps and does not describe the entire annual cycle.

compare_windows <- function(data) {
  data |>
    filter(month %in% c(1:3, 7:9)) |>
    mutate(window = if_else(month <= 3, "Jan-Mar", "Jul-Sep")) |>
    group_by(country, year, window) |>
    summarise(n_months = n_distinct(month), cases = sum(cases), .groups = "drop") |>
    filter(n_months == 3) |>
    select(-n_months) |>
    pivot_wider(names_from = window, values_from = cases) |>
    filter(!is.na(`Jan-Mar`), !is.na(`Jul-Sep`)) |>
    mutate(comparison = case_when(
      `Jan-Mar` > `Jul-Sep` ~ "Higher Jan-Mar",
      `Jan-Mar` < `Jul-Sep` ~ "Higher Jul-Sep",
      TRUE ~ "Equal"
    ))
}

paired_all <- compare_windows(main)
paired_no2019 <- compare_windows(main |> filter(year != 2019))

window_results <- paired_no2019 |>
  count(comparison, name = "country_years") |>
  mutate(share_pct = 100 * country_years / sum(country_years))

knitr::kable(window_results |> mutate(share_pct = round(share_pct, 1)),
             col.names = c("Comparison", "Eligible country-years", "Share (%)"),
             align = c("l", "r", "r"))
Comparison Eligible country-years Share (%)
Equal 29 6.9
Higher Jan-Mar 269 63.6
Higher Jul-Sep 125 29.6

window_sensitivity <- bind_rows(
  paired_all |> mutate(period = "All years"),
  paired_no2019 |> mutate(period = "Excluding 2019")
) |>
  group_by(period) |>
  summarise(
    eligible_country_years = n(),
    early_higher_pct = 100 * mean(comparison == "Higher Jan-Mar"),
    .groups = "drop"
  )

knitr::kable(window_sensitivity |>
               mutate(early_higher_pct = round(early_higher_pct, 1)),
             col.names = c("Period", "Eligible country-years", "Jan–Mar higher (%)"))
Period Eligible country-years Jan–Mar higher (%)
All years 461 63.6
Excluding 2019 423 63.6

Of the 423 eligible country-years excluding 2019, 269 (63.6%) had higher January–March totals. This describes a selected three-month comparison, not a test of seasonality. The eligible records are not independent across years within countries.

country_consistency <- paired_no2019 |>
  group_by(country) |>
  summarise(
    eligible_years = n(),
    jan_mar_higher = sum(comparison == "Higher Jan-Mar"),
    jul_sep_higher = sum(comparison == "Higher Jul-Sep"),
    equal = sum(comparison == "Equal"),
    jan_mar_pct = 100 * jan_mar_higher / eligible_years,
    .groups = "drop"
  ) |>
  arrange(desc(jan_mar_pct), desc(eligible_years))

country_examples <- country_consistency |>
  filter(country %in% c("Benin", "Niger", "Nigeria", "Burkina Faso",
                        "Cameroon", "Ethiopia", "Kenya", "Madagascar",
                        "Mozambique", "United Republic of Tanzania")) |>
  mutate(jan_mar_pct = round(jan_mar_pct, 1))

knitr::kable(country_examples,
             col.names = c("Country", "Eligible years", "Jan–Mar higher",
                           "Jul–Sep higher", "Equal", "Jan–Mar higher (%)"),
             align = c("l", "r", "r", "r", "r", "r"))
Country Eligible years Jan–Mar higher Jul–Sep higher Equal Jan–Mar higher (%)
Benin 12 12 0 0 100.0
Niger 12 12 0 0 100.0
Nigeria 12 12 0 0 100.0
Burkina Faso 11 11 0 0 100.0
Cameroon 12 11 1 0 91.7
Ethiopia 12 10 2 0 83.3
Kenya 12 8 4 0 66.7
Mozambique 12 4 8 0 33.3
United Republic of Tanzania 12 4 8 0 33.3
Madagascar 12 2 7 3 16.7

Benin, Niger and Nigeria show higher January–March totals in all 12 eligible years. Madagascar shows that pattern in only 2 of 12 eligible years when 2019 is excluded. This contrast is a reminder that the pooled early-year peak need not describe a typical year in each country.

# The full 43-country table is saved below; this plot uses a minimum of
# eight eligible years to avoid visually over-emphasising 1/1 or 2/2 patterns.
plot_consistency <- country_consistency |> filter(eligible_years >= 8)

p_consistency <- ggplot(
  plot_consistency,
  aes(x = jan_mar_pct, y = forcats::fct_reorder(country, jan_mar_pct))
) +
  geom_segment(aes(x = 0, xend = jan_mar_pct,
                   yend = forcats::fct_reorder(country, jan_mar_pct)),
               color = "#c7cfd5", linewidth = 0.45) +
  geom_point(aes(size = eligible_years), color = ink) +
  geom_text(aes(label = paste0(jan_mar_higher, "/", eligible_years)),
            hjust = -0.18, size = 2.6) +
  scale_x_continuous(breaks = seq(0, 100, 20), labels = function(x) paste0(x, "%"),
                     limits = c(0, 118)) +
  scale_size_continuous(range = c(1.8, 3.7), name = "Eligible years") +
  labs(title = "The early-year pattern differs considerably between countries",
       subtitle = "Selected countries with at least eight eligible years; 2019 excluded",
       x = "Years with higher January–March counts (%)", y = NULL,
       caption = "Complete Jan–Mar and Jul–Sep data required within each eligible country-year.") +
  base_theme +
  theme(axis.text.y = element_text(size = 8),
        panel.grid.major.y = element_blank(), legend.position = "right")
print(p_consistency)
Country-specific frequency of higher January–March totals among countries with at least eight eligible years. Ratios indicate the number of years with higher early counts out of all eligible years.

Country-specific frequency of higher January–March totals among countries with at least eight eligible years. Ratios indicate the number of years with higher early counts out of all eligible years.

save_figure(p_consistency, "08_country_window_consistency.png",
            width = 11, height = 10.8)

7 Supplementary results: complete tables and figures

The main sections contain the most compact summaries. For completeness, the full country rankings, month-by-group statistics, all country-specific window comparisons and the two additional figures are reproduced here. These supplementary outputs are descriptive and are not intended for inclusion in the 4–5-page report without selection and editing.

7.1 Full 46-country case totals

The complete country-level summary is retained so that the ranking is transparent, including the contribution of countries outside the leading ten.

knitr::kable(
  country_totals |>
    mutate(share_pct = round(share_pct, 1),
           calendar_coverage_pct = round(calendar_coverage_pct, 1)),
  col.names = c("Country", "Recorded cases", "Recorded months", "Share (%)", "Calendar coverage (%)"),
  align = c("l", "r", "r", "r", "r")
)
Country Recorded cases Recorded months Share (%) Calendar coverage (%)
Madagascar 225884 156 25.5 100.0
Nigeria 207569 156 23.5 100.0
Ethiopia 113044 153 12.8 98.1
Democratic Republic of the Congo 82661 156 9.3 100.0
Angola 34200 146 3.9 93.6
Cameroon 21463 156 2.4 100.0
Liberia 17483 98 2.0 62.8
Burkina Faso 16246 154 1.8 98.7
Côte d’Ivoire 13267 156 1.5 100.0
South Sudan 13074 127 1.5 81.4
Niger 12898 153 1.5 98.1
Ghana 11753 152 1.3 97.4
Chad 11113 139 1.3 89.1
Uganda 8654 156 1.0 100.0
Central African Republic 8471 153 1.0 98.1
Kenya 7782 152 0.9 97.4
United Republic of Tanzania 7763 155 0.9 99.4
Burundi 6821 152 0.8 97.4
Mali 6449 156 0.7 100.0
Mozambique 5994 148 0.7 94.9
Gabon 5329 154 0.6 98.7
Zimbabwe 5306 147 0.6 94.2
Benin 5278 156 0.6 100.0
Guinea 5242 140 0.6 89.7
Equatorial Guinea 4021 121 0.5 77.6
South Africa 3703 149 0.4 95.5
Congo 3456 154 0.4 98.7
Algeria 3449 118 0.4 75.6
Togo 3236 156 0.4 100.0
Senegal 2932 154 0.3 98.7
Zambia 2879 146 0.3 93.6
Namibia 2562 144 0.3 92.3
Sierra Leone 975 134 0.1 85.9
Mauritania 923 96 0.1 61.5
Rwanda 675 153 0.1 98.1
Eritrea 548 125 0.1 80.1
Malawi 535 140 0.1 89.7
Gambia 290 122 0.0 78.2
Guinea-Bissau 219 24 0.0 15.4
Lesotho 200 148 0.0 94.9
Botswana 159 135 0.0 86.5
Comoros 99 95 0.0 60.9
Seychelles 32 12 0.0 7.7
Eswatini 27 146 0.0 93.6
Cabo Verde 6 7 0.0 4.5
Sao Tome and Principe 5 9 0.0 5.8

7.2 Madagascar and other countries: complete monthly summary

The following table shows the aggregated and average count for each calendar month in the two groups. Group means are based on the available observations; they are not population-adjusted rates.

monthly_groups <- main |>
  mutate(group = if_else(country == "Madagascar", "Madagascar", "Other countries")) |>
  group_by(group, month) |>
  summarise(mean_cases = mean(cases), median_cases = median(cases),
            cases = sum(cases), n_records = n(), .groups = "drop") |>
  arrange(group, month)

knitr::kable(
  monthly_groups |>
    transmute(Group = group, Month = month.abb[month],
              `Reported cases` = cases, `Recorded country-months` = n_records,
              `Mean per record` = round(mean_cases, 1),
              `Median per record` = median_cases),
  align = c("l", "l", "r", "r", "r", "r")
)
Group Month Reported cases Recorded country-months Mean per record Median per record
Madagascar Jan 50071 13 3851.6 2.0
Madagascar Feb 67958 13 5227.5 3.0
Madagascar Mar 59786 13 4598.9 1.0
Madagascar Apr 23961 13 1843.2 3.0
Madagascar May 6265 13 481.9 3.0
Madagascar Jun 2047 13 157.5 1.0
Madagascar Jul 2323 13 178.7 3.0
Madagascar Aug 843 13 64.8 3.0
Madagascar Sep 351 13 27.0 4.0
Madagascar Oct 2173 13 167.2 6.0
Madagascar Nov 6463 13 497.2 5.0
Madagascar Dec 3643 13 280.2 1.0
Other countries Jan 88379 524 168.7 11.5
Other countries Feb 110411 531 207.9 14.0
Other countries Mar 119874 530 226.2 15.0
Other countries Apr 78257 523 149.6 11.0
Other countries May 56900 517 110.1 12.0
Other countries Jun 36797 499 73.7 10.0
Other countries Jul 29934 488 61.3 8.0
Other countries Aug 31139 480 64.9 6.0
Other countries Sep 28454 478 59.5 7.0
Other countries Oct 29188 471 62.0 7.0
Other countries Nov 27156 457 59.4 8.0
Other countries Dec 22302 405 55.1 7.0

7.3 Pooled monthly variation with Madagascar shown separately

This is the simpler monthly plot from the original exploration. The main sensitivity figure provides the more informative comparison for the short report.

p_monthly_groups <- ggplot(monthly_groups, aes(month, mean_cases)) +
  geom_line(color = ink, linewidth = 0.95) +
  geom_point(color = ink, size = 1.8) +
  facet_wrap(~group, ncol = 1, scales = "free_y") +
  scale_x_continuous(breaks = 1:12, labels = month.abb) +
  scale_y_continuous(labels = scales::label_comma()) +
  labs(title = "Pooled monthly variation: Madagascar and other countries",
       x = "Calendar month", y = "Mean cases per available country-month",
       caption = "Different y-axis scales; values based on recorded months, 2012–2024.") +
  base_theme
print(p_monthly_groups)
Pooled calendar-month mean counts in Madagascar and the remaining countries, 2012–2024. Both panels use their own y-axis scale.

Pooled calendar-month mean counts in Madagascar and the remaining countries, 2012–2024. Both panels use their own y-axis scale.

save_figure(p_monthly_groups, "09_monthly_means_by_group.png", 10, 7)

7.4 Full country-level consistency table

Every country with at least one eligible year is retained here. The number of eligible years is essential context for interpreting extreme percentages.

knitr::kable(
  country_consistency |>
    mutate(jan_mar_pct = round(jan_mar_pct, 1)),
  col.names = c("Country", "Eligible years", "Jan–Mar higher", "Jul–Sep higher", "Equal", "Jan–Mar higher (%)"),
  align = c("l", "r", "r", "r", "r", "r")
)
Country Eligible years Jan–Mar higher Jul–Sep higher Equal Jan–Mar higher (%)
Benin 12 12 0 0 100.0
Niger 12 12 0 0 100.0
Nigeria 12 12 0 0 100.0
Burkina Faso 11 11 0 0 100.0
Mauritania 2 2 0 0 100.0
Cameroon 12 11 1 0 91.7
Mali 12 11 1 0 91.7
Togo 12 11 1 0 91.7
Central African Republic 11 10 1 0 90.9
South Sudan 10 9 1 0 90.0
Ethiopia 12 10 2 0 83.3
Senegal 11 9 2 0 81.8
Chad 9 7 1 1 77.8
Democratic Republic of the Congo 12 9 3 0 75.0
Sierra Leone 8 6 2 0 75.0
Algeria 4 3 0 1 75.0
Liberia 4 3 1 0 75.0
Côte d’Ivoire 12 8 4 0 66.7
Ghana 12 8 4 0 66.7
Kenya 12 8 4 0 66.7
Uganda 12 8 4 0 66.7
Congo 11 7 4 0 63.6
Zambia 11 7 4 0 63.6
Eritrea 8 5 2 1 62.5
Guinea 10 6 3 1 60.0
Rwanda 12 7 4 1 58.3
Lesotho 9 5 1 3 55.6
Gabon 11 6 4 1 54.5
Burundi 11 5 4 2 45.5
South Africa 11 5 6 0 45.5
Botswana 10 4 4 2 40.0
Equatorial Guinea 5 2 3 0 40.0
Gambia 5 2 2 1 40.0
Angola 11 4 7 0 36.4
Malawi 11 4 7 0 36.4
Zimbabwe 11 4 5 2 36.4
Mozambique 12 4 8 0 33.3
United Republic of Tanzania 12 4 8 0 33.3
Namibia 11 3 7 1 27.3
Eswatini 9 2 0 7 22.2
Comoros 5 1 2 2 20.0
Madagascar 12 2 7 3 16.7
Guinea-Bissau 1 0 1 0 0.0

7.5 All-country consistency figure

Unlike the preceding condensed figure, this graphic includes countries with as few as one eligible year. We retained it for auditability rather than treating small-denominator percentages as definitive.

p_consistency_full <- ggplot(
  country_consistency,
  aes(jan_mar_pct, forcats::fct_reorder(country, jan_mar_pct))
) +
  geom_segment(aes(x = 0, xend = jan_mar_pct,
                   yend = forcats::fct_reorder(country, jan_mar_pct)),
               color = "#c7cfd5", linewidth = 0.4) +
  geom_point(aes(size = eligible_years), color = ink) +
  geom_text(aes(label = paste0(jan_mar_higher, "/", eligible_years)),
            hjust = -0.18, size = 2.5) +
  scale_x_continuous(breaks = seq(0, 100, 20),
                     labels = function(x) paste0(x, "%"), limits = c(0, 118)) +
  scale_size_continuous(range = c(1.5, 3.5), name = "Eligible years") +
  labs(title = "Consistency of early-year counts: all eligible countries",
       subtitle = "January–March compared with July–September; 2019 excluded",
       x = "Country-years with higher Jan–Mar counts (%)", y = NULL,
       caption = "Full exploratory version includes countries with fewer than eight eligible years.") +
  base_theme +
  theme(axis.text.y = element_text(size = 7.5),
        panel.grid.major.y = element_blank(), legend.position = "right")
print(p_consistency_full)
Full exploratory comparison showing all countries with eligible January–March and July–September records, excluding 2019. Labels give the number of years with higher early counts / eligible years.

Full exploratory comparison showing all countries with eligible January–March and July–September records, excluding 2019. Labels give the number of years with higher early counts / eligible years.

save_figure(p_consistency_full, "10_country_consistency_full_43.png", 12, 13)

8 Synthesis and limits

The exploration pointed to three linked findings. First, we identified meaningful gaps in the available country-month records; a missing row was never treated as a reported zero. Second, the exceptional 2019 regional total was strongly shaped by Madagascar. Third, the timing of relatively high reported cases differed by country, and pooled monthly averages were sensitive to large epidemic episodes.

These results concern the supplied surveillance records. We could not confirm national reporting obligations, case definitions, population denominators, or whether missing records represent non-reporting rather than extraction choices. For this reason, the figures should not be interpreted as population-standardised incidence, definitive seasonal effects, or evidence of causal relationships. The short accompanying report can concentrate on the few findings that survive these checks rather than reproduce every exploratory table.

8.1 Saved outputs and reproducibility

The original CSV was not modified. Tables and publication-sized PNGs are written to the outputs/ folder. The R Markdown document can be knitted from a fresh Posit Cloud session when the CSV is in the project folder.

readr::write_csv(quality_checks, "outputs/tables/data_quality_checks.csv")
readr::write_csv(coverage_by_year, "outputs/tables/annual_record_coverage.csv")
readr::write_csv(coverage_country_year, "outputs/tables/country_year_coverage.csv")
readr::write_csv(annual, "outputs/tables/annual_measles_totals.csv")
readr::write_csv(country_totals, "outputs/tables/country_case_totals.csv")
readr::write_csv(monthly_all, "outputs/tables/monthly_summary.csv")
readr::write_csv(country_consistency, "outputs/tables/country_monthly_consistency_all_eligible.csv")
readr::write_csv(window_sensitivity, "outputs/tables/seasonal_window_sensitivity.csv")
readr::write_csv(matched_totals, "outputs/tables/matched_2021_2024_comparison.csv")
readr::write_csv(monthly_groups, "outputs/tables/monthly_madagascar_vs_others.csv")
writeLines(capture.output(sessionInfo()), "outputs/session_info.txt")

cat("Saved tables to outputs/tables/ and figures to outputs/figures/.\n")
#> Saved tables to outputs/tables/ and figures to outputs/figures/.