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