library(mongolstats)
library(dplyr)
library(ggplot2)
nso_options(mongolstats.lang = "en")
# Global theme with proper margins to prevent text cutoff
theme_set(
theme_minimal(base_size = 11) +
theme(
plot.margin = margin(10, 10, 10, 10),
plot.title = element_text(size = 13, face = "bold"),
plot.subtitle = element_text(size = 10, color = "grey40"),
legend.text = element_text(size = 9),
legend.title = element_text(size = 10)
)
)Mongolia’s National Statistics Office (NSO) publishes vital statistics and disease surveillance data that are useful for research and policy analysis. This guide shows how to find the right tables, check what they measure, and turn counts into rates.
nso_search() searches table titles across the whole NSO
catalogue. Some tables are listed in more than one folder, so
distinct() keeps one row per table:
# Mortality
mortality <- nso_search("mortality")
mortality |>
distinct(tbl_id, tbl_eng_nm) |>
head(10)
# Cancer
cancer <- nso_search("cancer")
cancer |> distinct(tbl_id, tbl_eng_nm)
# Communicable diseases
infectious <- nso_search("communicable")
infectious |> distinct(tbl_id, tbl_eng_nm)Read the title before you fetch: it tells you whether a table holds counts (“NUMBER OF …”, “CASES OF …”) or rates (“per 1000 live births”, “per 10000 population”), and at which level (“aimags and the Capital”, “by soum”).
Health and education statistics are grouped together:
cancer_tbl <- "DT_NSO_2100_012V1" # New cases of cancer per 10,000 population
# Examine available dimensions
meta <- nso_table_meta(cancer_tbl)
meta
# Cancer types: codes are not in a meaningful order, so read the labels
cancer_types <- nso_dim_values(cancer_tbl, "Type malignant neoplasms", labels = "en")
cancer_types
# Year codes are internal ids; the labels hold the years
annual_meta <- nso_dim_values(cancer_tbl, "Year", labels = "en")
range(annual_meta$label_en)# The 10 most recent years
recent_years <- annual_meta |>
arrange(label_en) |>
tail(10) |>
pull(code)
# Specific sites only: drop the "Total" and "Other" categories. NSO labels
# can carry leading spaces, so trim them before comparing.
site_codes <- cancer_types |>
filter(!trimws(label_en) %in% c("Total", "Other")) |>
pull(code)
cancer_data <- nso_data(
tbl_id = cancer_tbl,
selections = list(
"Type malignant neoplasms" = site_codes,
"Year" = recent_years
),
labels = "en"
) |>
mutate(site = trimws(`Type malignant neoplasms_en`), year = as.integer(Year_en))
# One panel per site, on a shared scale, so overlapping lines do not hide each other
cancer_data |>
ggplot(aes(x = year, y = value)) +
geom_line(color = "#8e44ad", linewidth = 1) +
geom_point(color = "#8e44ad", size = 2) +
facet_wrap(~site, nrow = 1) +
scale_x_continuous(breaks = function(x) range(round(x))) +
scale_y_continuous(limits = c(0, NA), breaks = scales::breaks_width(2)) +
labs(
title = "Cancer Incidence in Mongolia by Site",
subtitle = "New cases per 10,000 population (crude rate, rounded by NSO to whole numbers)",
x = NULL,
y = "Incidence (per 10,000)",
caption = "Source: NSO Mongolia, DT_NSO_2100_012V1"
) +
theme(panel.grid.minor = element_blank(), panel.spacing = unit(1, "lines"))Over these years, one site cancer has the highest average incidence, followed by another cancer. Two cautions apply to this table:
The rates above are crude: new cases divided by the whole population. Cancer risk rises steeply with age, so a crude rate climbs as a population ages even when the risk at each age is flat. Age-standardisation removes this effect. It applies each year’s age-specific rates to one fixed standard population, here the WHO World Standard Population (2000–2025).
NSO publishes the numerators and denominators needed: new cancer
cases by age group (DT_NSO_2100_044V1) and population by
age group (DT_NSO_0300_003V1).
# New cancer cases by age group and year (counts)
case_years <- nso_dim_values("DT_NSO_2100_044V1", "Year", labels = "en")
case_ages <- nso_dim_values("DT_NSO_2100_044V1", "Age group", labels = "en")
indicator <- nso_dim_values("DT_NSO_2100_044V1", "Indicator", labels = "en")
cancer_cases <- nso_data(
"DT_NSO_2100_044V1",
selections = list(
Indicator = indicator$code[trimws(indicator$label_en) == "New cases of cancer"],
"Age group" = case_ages$code,
Year = case_years$code
),
labels = "en"
) |>
transmute(year = Year_en, age = trimws(`Age group_en`), cases = value) |>
filter(age != "Total")
# Population by age group, same years, both sexes
pop_years <- nso_dim_values("DT_NSO_0300_003V1", "Year", labels = "en")
pop_ages <- nso_dim_values("DT_NSO_0300_003V1", "Age group", labels = "en")
pop_sex <- nso_dim_values("DT_NSO_0300_003V1", "Sex", labels = "en")
population <- nso_data(
"DT_NSO_0300_003V1",
selections = list(
Sex = pop_sex$code[trimws(pop_sex$label_en) == "Total"],
"Age group" = pop_ages$code,
Year = pop_years$code[pop_years$label_en %in% case_years$label_en]
),
labels = "en"
) |>
transmute(year = Year_en, age = trimws(`Age group_en`), population = value) |>
filter(age != "Total")
# The two tables use different age groups; collapse both to the cancer
# table's groups (0-4, 5-14, 15-19, ..., 60-64, 65+)
to_cancer_groups <- function(age) {
case_when(
age %in% c("5-9", "10-14") ~ "5-14",
age %in% c("65-69", "70-74", "75-79", "80-84", "85+", "70+") ~ "65+",
.default = age
)
}
# WHO World Standard Population (2000-2025), per million
who_standard <- tibble::tibble(
age = c(
"0-4", "5-9", "10-14", "15-19", "20-24", "25-29", "30-34", "35-39", "40-44",
"45-49", "50-54", "55-59", "60-64", "65-69", "70-74", "75-79", "80-84", "85+"
),
weight = c(
88569, 86870, 85970, 84670, 82171, 79272, 76073, 71475, 65877,
60379, 53681, 45484, 37187, 29590, 22092, 15195, 9097, 6348
)
) |>
mutate(age = to_cancer_groups(age)) |>
summarise(weight = sum(weight), .by = age)
population <- population |>
mutate(age = to_cancer_groups(age)) |>
summarise(population = sum(population), .by = c(year, age))
cancer_asr <- cancer_cases |>
inner_join(population, by = join_by(year, age)) |>
inner_join(who_standard, by = join_by(age)) |>
summarise(
crude = sum(cases) / sum(population) * 1e4,
# Direct standardisation: weighted mean of age-specific rates
standardised = sum(weight * cases / population) / sum(weight) * 1e4,
.by = year
) |>
mutate(year = as.integer(year)) |>
arrange(year)
cancer_asrcancer_asr |>
tidyr::pivot_longer(c(crude, standardised), names_to = "measure", values_to = "rate") |>
mutate(measure = if_else(measure == "crude", "Crude", "Age-standardised (WHO World)")) |>
ggplot(aes(x = year, y = rate, color = measure)) +
geom_line(linewidth = 1.2) +
geom_point(size = 2) +
scale_color_manual(values = c("Crude" = "#2a78d6", "Age-standardised (WHO World)" = "#eb6834"), name = NULL) +
scale_y_continuous(limits = c(0, NA)) +
labs(
title = "All-Site Cancer Incidence: Crude and Age-Standardised",
subtitle = "New cases per 10,000 population",
x = NULL,
y = "Incidence (per 10,000)",
caption = "Source: NSO Mongolia, DT_NSO_2100_044V1 and DT_NSO_0300_003V1"
) +
theme(legend.position = "top", panel.grid.minor = element_blank())Between the first year and the latest year, the crude rate changed by -, but the age-standardised rate changed by -. Most of the rise in crude incidence reflects an older population, not a higher risk of cancer at any given age. The age-standardised rate is higher than the crude rate because Mongolia’s population is younger than the WHO standard population.
Biostatistical note: Age-standardised rates are for comparison only: between years, between countries, or between groups with different age structures. They depend on the standard population chosen. Report the crude rate, or the number of cases, when the question is about the actual burden on health services. The 2021 dip in both rates coincides with the COVID-19 pandemic, when fewer people were diagnosed.
Many NSO rate tables have matching count tables. With counts you can pool years, combine areas, and attach confidence intervals, which a published rate does not allow. For infant mortality:
DT_NSO_2100_014V1: infant deaths (count), by aimag and
monthDT_NSO_2100_018V5: live births (count), by aimag and
monthDT_NSO_2100_015V2: infant mortality rate (per 1,000
live births), by soum and yearBefore you rely on rates you build yourself, check that they reproduce the published ones:
# National annual totals from a monthly count table
national_annual <- function(tbl) {
months <- nso_dim_values(tbl, "Month", labels = "en")
nso_data(tbl, selections = list(Region = "0", Month = months$code), labels = "en") |>
mutate(year = substr(Month_en, 1, 4)) |>
summarise(count = sum(value), n_months = n(), .by = year) |>
filter(n_months == 12) # complete years only
}
infant_deaths <- national_annual("DT_NSO_2100_014V1")
live_births <- national_annual("DT_NSO_2100_018V5")
# Published annual rate. This id names two tables in the catalogue, so
# mongolstats warns and uses the first match ("by soum, and by year"),
# which includes the national total.
imr_years <- nso_dim_values("DT_NSO_2100_015V2", "Year", labels = "en")
imr_published <- nso_data(
"DT_NSO_2100_015V2",
selections = list(Region = "0", Year = imr_years$code),
labels = "en"
) |>
select(year = Year_en, published = value)
imr_check <- infant_deaths |>
select(year, deaths = count) |>
inner_join(select(live_births, year, births = count), by = join_by(year)) |>
inner_join(imr_published, by = join_by(year)) |>
mutate(calculated = round(deaths / births * 1000, 1)) |>
arrange(year)
imr_checkThe calculated rates differ from the published ones by at most - per 1,000, because NSO rounds the published rate to a whole number. The count tables can be trusted to give the same answer. The Getting Started and Mapping guides use them to compare aimags.
The monthly communicable disease table counts reported (notified) cases. To compare years, divide by the population at risk. Here we use the national mid-year population.
tb_tbl <- "DT_NSO_2100_035V1" # Cases of communicable diseases, by type and by month
# Find the code for tuberculosis
indicators <- nso_dim_values(tb_tbl, "Indicators", labels = "en")
tb_code <- indicators |>
filter(trimws(label_en) == "Tuberculosis") |>
pull(code)
tb_months <- nso_dim_values(tb_tbl, "Month", labels = "en")
tb_annual <- nso_data(
tbl_id = tb_tbl,
selections = list(Indicators = tb_code, Month = tb_months$code),
labels = "en"
) |>
mutate(year = substr(Month_en, 1, 4)) |>
summarise(cases = sum(value), n_months = n(), .by = year) |>
filter(n_months == 12)
# Mid-year population (denominator)
population <- nso_data(
"DT_NSO_0300_002V1",
selections = list(Region = "0", Year = tb_annual$year),
labels = "en"
) |>
select(year = Year_en, population = value)
tb_rate <- tb_annual |>
inner_join(population, by = join_by(year)) |>
mutate(
year = as.integer(year),
rate = cases / population * 1e5,
# Exact Poisson 95% confidence interval
lower = qchisq(0.025, 2 * cases) / 2 / population * 1e5,
upper = qchisq(0.975, 2 * (cases + 1)) / 2 / population * 1e5
) |>
arrange(year)
tb_ratetb_rate |>
ggplot(aes(x = year, y = rate)) +
geom_ribbon(aes(ymin = lower, ymax = upper), fill = "#e74c3c", alpha = 0.15) +
geom_line(color = "#e74c3c", linewidth = 1) +
geom_point(color = "#e74c3c", size = 2) +
scale_x_continuous(breaks = tb_rate$year) +
scale_y_continuous(limits = c(0, NA), expand = expansion(mult = c(0, 0.1))) + # start at 0
labs(
title = "Tuberculosis Notifications in Mongolia",
subtitle = "Reported cases per 100,000 population, with 95% confidence intervals",
x = NULL,
y = "Notifications per 100,000",
caption = "Source: NSO Mongolia, DT_NSO_2100_035V1 and DT_NSO_0300_002V1"
) +
theme(panel.grid.minor = element_blank())The notification rate fell from - per 100,000 in the first year to - in the latest year.
Biostatistical note: A notification rate counts cases that were diagnosed and reported, not all new cases (incidence). It falls when case finding falls, as well as when transmission falls. The sharp drop in 2021 coincides with the COVID-19 pandemic, which disrupted tuberculosis services in many countries. These data cannot separate a true decline from missed diagnoses. For incidence, see the World Health Organization’s country estimates, which adjust for under-reporting.
nso_table_periods() or nso_dim_values() to see
which periods a table covers. Monthly tables usually include a partial
current year; keep complete years for annual comparisons.Region dimension with nso_dim_values().