5 Analysing MICS6 data in R

UNICEF’s Multiple Indicator Cluster Surveys (MICS) collect comparable information on children across many African countries. Official country files use different layouts and codes, so they do not pool cleanly on their own. This guide’s harmonised file puts those surveys on one child-level footing: shared names, a common education-level map, numeracy items, and reading outcomes.

This chapter asks what that African file can show about schooling and foundational skills. The ladder is fixed: explore the sample, check over-age enrolment across countries, then dig into Ghana for private schooling, numeracy, and a gender interaction. Download and harmonisation steps live in Download the MICS Data, Harmonising MICS6 reading outcomes, and Harmonising MICS6 FS background themes.

5.1 Packages

Install once if needed, then load at the start of every session:

install.packages(c("tidyverse", "haven", "srvyr", "survey", "showtext", "sysfonts"))
library(tidyverse)
library(haven)
library(srvyr)
library(survey)   # svyglm on srvyr designs

source("R/aflearn_theme.R")

5.2 Load the harmonised file

After read_dta(), you hold one row per child aged 5–17 from the African surveys in this build: schooling background, sex, sample weights, a numeracy score, and reading skills where the assessment was completed.

dat <- read_dta("data/mics6-harmonized-v1.dta")

dat <- dat %>%
  mutate(
    survey = paste(country_iso3, year),
    female = if_else(sex == 2L, 1L, if_else(sex == 1L, 0L, NA_integer_))
  )

glimpse(dat %>% select(
  country_iso3, year, HH1, HH2, LN, age, female, private, enrolled,
  current_level_h, current_grade, numeracy_score, reading_skills, fsweight
))
Rows: 166,980
Columns: 14
$ country_iso3    <chr> "BEN", "BEN", "BEN", "BEN", "BEN", "BEN", "BEN", "BEN"…
$ year            <dbl> 2021, 2021, 2021, 2021, 2021, 2021, 2021, 2021, 2021, …
$ HH1             <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, …
$ HH2             <dbl> 1, 3, 4, 7, 8, 9, 10, 12, 13, 14, 16, 20, 21, 22, 2, 3…
$ LN              <dbl> 6, 4, 6, 3, 3, 3, 3, 3, 4, 6, 4, 2, 3, 5, 4, 3, 5, 5, …
$ age             <dbl+lbl> 15, 16,  7,  9, 13,  8, 14, 13, 13,  5,  9,  5, 10…
$ female          <int> 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, …
$ private         <dbl+lbl> NA, NA, NA, NA,  0,  0, NA,  0, NA, NA, NA, NA,  0…
$ enrolled        <dbl+lbl>  1,  2,  1, NA,  1,  1,  2,  1,  1, NA,  1, NA,  1…
$ current_level_h <dbl+lbl>  2, NA,  1, NA,  2,  1, NA,  2,  2, NA,  1, NA,  1…
$ current_grade   <dbl+lbl>  3, NA,  3, NA,  3,  4, NA,  1,  2, NA,  2, NA,  3…
$ numeracy_score  <dbl> NA, NA, 2, 0, 21, 3, 14, 17, 21, NA, 1, NA, 1, 8, NA, …
$ reading_skills  <dbl+lbl> NA, NA, NA, NA,  0,  0, NA,  0,  1, NA, NA, NA,  0…
$ fsweight        <dbl> 0.610845, 0.610845, 1.832535, 0.610845, 1.832535, 1.22…

numeracy_score counts correct answers across the 21 foundational numeracy items (0–21). reading_skills is 1 when a child met the foundational reading threshold among those assessed. private is 1 for private schools and 0 for public, religious, community, or other under the usual MICS ownership codes (COD is left missing).

5.3 Explore and unique child ID

Before any model, confirm what each row is and that children do not duplicate. A broken key silently double-counts households when you merge or tabulate.

nrow(dat)
[1] 166980
ncol(dat)
[1] 159
count(dat, country_iso3, year)
# A tibble: 19 × 3
   country_iso3  year     n
   <chr>        <dbl> <int>
 1 BEN           2021 11710
 2 CAF           2018  6167
 3 COD           2017 14038
 4 COM           2022  4246
 5 GHA           2017  8965
 6 GMB           2018  5850
 7 GNB           2018  5849
 8 LSO           2018  5301
 9 MDG           2018 12429
10 MWI           2019 17976
11 NGA           2021 22706
12 SLE           2017 11046
13 STP           2019  2275
14 SWZ           2021  2551
15 TCD           2019 14865
16 TGO           2017  5062
17 TUN           2018  4983
18 TUN           2023  3806
19 ZWE           2019  7155
# Candidate keys
key_vars <- c("country_iso3", "year", "HH1", "HH2", "LN")
n_distinct(dat[key_vars]) == nrow(dat)
[1] TRUE
# Same idea with the compact ID copies
key_vars2 <- c("country_iso3", "year", "cluster", "hhno", "linech")
n_distinct(dat[key_vars2]) == nrow(dat)
[1] TRUE

The country–year table is the map of this guide’s Africa coverage: nineteen surveys from Benin through Zimbabwe (Tunisia appears twice). Both key combinations return TRUE — one row per child. A single childid helps when browsing or merging:

dat <- dat %>%
  group_by(country_iso3, year, HH1, HH2, LN) %>%
  mutate(childid = cur_group_id()) %>%
  ungroup() %>%
  select(childid, everything())

n_distinct(dat$childid) == nrow(dat)
[1] TRUE
sum(is.na(dat$childid))
[1] 0

5.4 Survey design

MICS draws children in clusters. Ignoring that design understates uncertainty and can distort country contrasts. Point estimates use the child weight fsweight; standard errors treat HH1 as the cluster. For pooled cross-country work, nest clusters inside each survey.

design_one <- function(df) {
  df %>%
    as_survey_design(
          ids = HH1,
      weights = fsweight,
         nest = TRUE
    )
}

design_pooled <- function(df) {
  df %>%
    as_survey_design(
          ids = HH1,
       strata = survey,
      weights = fsweight,
         nest = TRUE
    )
}

# Working sample for most examples: currently enrolled, positive weight
an <- dat %>%
  filter(
    enrolled == 1L,
    !is.na(fsweight), fsweight > 0,
    !is.na(HH1)
  )

Rebuild the design after any mutate() that creates variables you will analyse. The design object stores a snapshot of the data.

5.5 Mean age

How old are children currently enrolled in school? Start with Ghana, then scan the full African set.

gha <- an %>% 
        filter(country_iso3 == "GHA")

design_one(gha) %>%
  summarise(
    age_mean = survey_mean(age, na.rm = TRUE, vartype = "se"),
    n = unweighted(n())
  )
# A tibble: 1 × 3
  age_mean age_mean_se     n
     <dbl>       <dbl> <int>
1     10.4      0.0882  8088

Among enrolled children in Ghana 2017, mean age is about 10.4 years (SE ≈ 0.09). The FS module covers ages 5–17, so this mean mixes primary and secondary learners.

age_by_cty <- design_pooled(an) %>%
  group_by(country_iso3, year) %>%
  summarise(
    age_mean = survey_mean(age, na.rm = TRUE, vartype = NULL),
    n = unweighted(n()),
    .groups = "drop"
  ) %>%
  arrange(country_iso3, year)

age_by_cty
# A tibble: 19 × 4
   country_iso3  year age_mean     n
   <chr>        <dbl>    <dbl> <int>
 1 BEN           2021     9.94  7860
 2 CAF           2018    10.4   3988
 3 COD           2017    10.9   8976
 4 COM           2022    10.7   3704
 5 GHA           2017    10.4   8088
 6 GMB           2018    10.3   4214
 7 GNB           2018    10.6   4185
 8 LSO           2018    10.6   4428
 9 MDG           2018    10.4   7233
10 MWI           2019    10.5  15693
11 NGA           2021    10.5  16428
12 SLE           2017    10.5   7935
13 STP           2019    10.9   2018
14 SWZ           2021    11.2   2263
15 TCD           2019    11.0   4760
16 TGO           2017    10.6   4309
17 TUN           2018    10.5   4516
18 TUN           2023    10.8   3266
19 ZWE           2019     9.95  6015

Country means sit in a fairly tight band, but age alone is not grade. The next section fixes the grade and asks how many children are older than they should be for that grade.

5.6 Over-age children in primary grade 2

Late entry and repetition leave many African classrooms with children far above the expected age for their grade. If primary starts around age 6, primary grade 2 learners are typically 7 or 8. Ages 10 and above are clearly over-age for that grade.

Restrict to primary (current_level_h == 1) and grade 2 (current_grade == 2). Without the level filter, “grade 2” would also pull in secondary year 2.

# Typical ages 7–8; treat age 10+ as clearly over-age for primary grade 2
g2_prim <- an %>%
  filter(
    current_level_h == 1L,
    current_grade == 2L,
    !is.na(age)
  ) %>%
  mutate(overage = as.integer(age >= 10L))

design_one(g2_prim %>% filter(country_iso3 == "GHA")) %>%
  summarise(
    age_mean = survey_mean(age, vartype = "se"),
    overage_share = survey_mean(overage, vartype = "se"),
    n = unweighted(n())
  )
# A tibble: 1 × 5
  age_mean age_mean_se overage_share overage_share_se     n
     <dbl>       <dbl>         <dbl>            <dbl> <int>
1     8.28      0.0958         0.187           0.0229   825

In Ghana, mean age in primary grade 2 is about 8.3, and roughly 19% of those children are aged 10 or older.

age_gha_g2 <- design_one(g2_prim %>% filter(country_iso3 == "GHA")) %>%
  group_by(age) %>%
  summarise(
    p = survey_mean(vartype = NULL),
    n = unweighted(n()),
    .groups = "drop"
  ) %>%
  mutate(fill = if_else(age >= 10L, aflearn_amber, aflearn_neutral[["context"]]))

age_gha_g2 %>%
  ggplot(aes(x = factor(age), y = p, fill = fill)) +
  geom_col(width = 0.72, colour = NA) +
  scale_fill_identity() +
  scale_y_continuous(
    labels = scales::percent_format(accuracy = 1),
    expand = expansion(mult = c(0, 0.08))
  ) +
  labs(
    title = "Many primary grade 2 learners are older than 7–8",
    subtitle = "Ghana · weighted share by age · amber = age 10+",
    x = "Age",
    y = NULL,
    caption = "Source: MICS6, Ghana 2017."
  ) +
  theme_aflearn() +
  theme(axis.text.x = aflearn_axis_category())
Age distribution in primary grade 2 — Ghana. Ages 10 and above are highlighted as over-age.

Figure 5.1: Age distribution in primary grade 2 — Ghana. Ages 10 and above are highlighted as over-age.

The mass sits at 7–8, but the amber bars show a long right tail into the teens. That is the over-age problem in one classroom label.

overage_cty <- design_pooled(g2_prim) %>%
  group_by(country_iso3, year) %>%
  summarise(
    overage_share = survey_mean(overage, vartype = NULL),
    age_mean = survey_mean(age, vartype = NULL),
    n = unweighted(n()),
    .groups = "drop"
  ) %>%
  arrange(desc(overage_share))

overage_cty
# A tibble: 19 × 5
   country_iso3  year overage_share age_mean     n
   <chr>        <dbl>         <dbl>    <dbl> <int>
 1 GNB           2018        0.566     10.0    759
 2 TCD           2019        0.440      9.45   996
 3 MDG           2018        0.352      9.03  1168
 4 CAF           2018        0.313      8.82   786
 5 MWI           2019        0.283      8.59  2363
 6 GMB           2018        0.276      8.70   578
 7 SLE           2017        0.234      8.29  1345
 8 COD           2017        0.212      8.34  1366
 9 GHA           2017        0.187      8.28   825
10 NGA           2021        0.173      7.93  2006
11 TGO           2017        0.111      7.81   583
12 BEN           2021        0.0977     7.21  1401
13 COM           2022        0.0782     7.31   416
14 STP           2019        0.0496     7.88   205
15 LSO           2018        0.0447     7.45   431
16 ZWE           2019        0.0393     7.51   672
17 SWZ           2021        0.0198     7.69   200
18 TUN           2018        0.0198     7.34   387
19 TUN           2023        0.0105     7.29   294
overage_cty %>%
  mutate(label = paste(country_iso3, year)) %>%
  ggplot(aes(x = overage_share, y = reorder(label, overage_share))) +
  geom_col(width = 0.72, fill = aflearn_electric_blue, colour = NA) +
  scale_x_continuous(
    labels = scales::percent_format(accuracy = 1),
    expand = expansion(mult = c(0, 0.08))
  ) +
  labs(
    title = "Over-age enrolment in primary grade 2 varies by country",
    subtitle = "Weighted share aged 10+ · enrolled primary grade 2",
    x = "Share of over-age children",
    y = NULL,
    caption = "Source: MIC6. Africa "
  ) +
  theme_aflearn() +
  theme(axis.text.y = aflearn_axis_category())
Share of primary grade 2 children aged 10 or older, by survey.

Figure 5.2: Share of primary grade 2 children aged 10 or older, by survey.

The ranking is stark. Guinea-Bissau and Chad sit near the top because more than two in five primary grade 2 children are aged 10+. Tunisia and Eswatini sit near the bottom (about 1–2%). Same grade label, very different age profiles across this African set.

5.7 Numeracy and reading skills

What do foundational skills look like in one country before we compare school types? Ghana is a useful case: a large enrolled sample and clear private-school coding.

# Ghana: mean numeracy among enrolled children with a score
design_one(gha %>% filter(!is.na(numeracy_score))) %>%
  summarise(
    numeracy_mean = survey_mean(numeracy_score, vartype = "se"),
    n = unweighted(n())
  )
# A tibble: 1 × 3
  numeracy_mean numeracy_mean_se     n
          <dbl>            <dbl> <int>
1          15.0            0.241  5237
# Share meeting foundational reading skills (where assessed)
design_one(gha %>% filter(!is.na(reading_skills))) %>%
  summarise(
    reading_share = survey_mean(reading_skills, vartype = "se"),
    n = unweighted(n())
  )
# A tibble: 1 × 3
  reading_share reading_share_se     n
          <dbl>            <dbl> <int>
1         0.342           0.0157  3512

Enrolled Ghanaian children with a numeracy score average about 15 of 21 items correct (SE ≈ 0.24). Among those with a reading-skills indicator, about 34% meet the foundational reading threshold (SE ≈ 0.02). Note that the two samples differ: numeracy and reading are not always completed for the same children.

num_share <- design_one(gha %>% filter(!is.na(numeracy_score))) %>%
  group_by(numeracy_score) %>%
  summarise(
    p = survey_mean(vartype = NULL),
    n = unweighted(n()),
    .groups = "drop"
  ) |>
  ungroup()

num_share %>%
   mutate(fill = ifelse(numeracy_score == 21, aflearn_cyan, aflearn_neutral[["context"]])) |> 
  ggplot(aes(x = numeracy_score, y = p, fill = fill)) +
  geom_col(width = 0.9, colour = NA) +
  scale_fill_identity() +
  scale_y_continuous(
    labels = scales::percent_format(accuracy = 1),
    expand = expansion(mult = c(0, 0.08))
  ) +
  labs(
    title = "Numeracy score distribution of Ghanaian children",
    subtitle = "Weighted share · enrolled children with a numeracy score",
    x = "Numeracy score (0–21)",
    y = NULL,
    caption = "Source: MICS6, Ghana 2017."
  ) +
  theme_aflearn()
Distribution of numeracy scores among enrolled Ghanaian children.

Figure 5.3: Distribution of numeracy scores among enrolled Ghanaian children.

The distribution piles toward the top of the 0–21 scale, with a thinner left tail of low scorers. That shape is the backdrop for the private-school contrast.

5.8 Private versus non-private schools

Do privately schooled children score higher on numeracy? Compare weighted means before adding controls.

gha_num <- gha %>%
  filter(!is.na(numeracy_score), !is.na(private))

design_one(gha_num) %>%
  group_by(private) %>%
  summarise(
    numeracy_mean = survey_mean(numeracy_score, vartype = "se"),
    n = unweighted(n()),
    .groups = "drop"
  )
# A tibble: 2 × 4
  private         numeracy_mean numeracy_mean_se     n
  <dbl+lbl>               <dbl>            <dbl> <int>
1 0 [Non-private]          15.4            0.296   895
2 1 [Private]              18.5            0.283   425

In Ghana the raw gap is large: about 15.4 among non-private learners versus 18.5 among private-school learners. That is not a causal estimate. Families that choose private schools also differ in age mix, gender, and mother’s education. Regression holds those factors fixed.

5.9 Multivariate regression (Ghana)

Stay in Ghana for the rest of the chapter. A single-country design keeps the story readable; the same steps extend to other surveys in the file. Restrict to enrolled children aged 7–14 with non-missing numeracy, private-school status, sex, and mother’s education (codes 0–5).

gha_reg <- gha %>%
  filter(
    age %in% 7:14,
    !is.na(numeracy_score),
    !is.na(private),
    !is.na(female),
    mother_edu_h %in% 0:5
  ) %>%
  mutate(mother_edu_f = factor(mother_edu_h))

des_gha <- design_one(gha_reg)

m1 <- svyglm(numeracy_score ~ private, design = des_gha)
summary(m1)

Call:
svyglm(formula = numeracy_score ~ private, design = des_gha)

Survey design:
Called via srvyr

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  15.3579     0.2958  51.928  < 2e-16 ***
private       3.1187     0.4133   7.547 1.79e-13 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for gaussian family taken to be 28.75241)

Number of Fisher Scoring iterations: 2
m2 <- svyglm(
  numeracy_score ~ private + age + female + mother_edu_f,
  design = des_gha
)
summary(m2)

Call:
svyglm(formula = numeracy_score ~ private + age + female + mother_edu_f, 
    design = des_gha)

Survey design:
Called via srvyr

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)    4.18043    1.28956   3.242  0.00126 ** 
private        2.93151    0.49012   5.981 3.95e-09 ***
age            0.87693    0.09499   9.232  < 2e-16 ***
female        -0.19087    0.40149  -0.475  0.63469    
mother_edu_f1  2.42129    0.73919   3.276  0.00112 ** 
mother_edu_f2  2.58272    0.60624   4.260 2.39e-05 ***
mother_edu_f3  2.92275    0.67750   4.314 1.89e-05 ***
mother_edu_f5  4.15190    0.96519   4.302 2.00e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for gaussian family taken to be 23.8924)

Number of Fisher Scoring iterations: 2

In m1, the intercept is mean numeracy in non-private schools; the coefficient on private is the associated gap. In m2, that gap is conditional on age, sex, and mother’s education. If the private coefficient shrinks, part of the raw gap was composition — not an independent school-type effect.

5.10 Private × female interaction

Does the private-school association differ for girls and boys?

m_int <- svyglm(
  numeracy_score ~ private * female + age + mother_edu_f,
  design = des_gha
)
summary(m_int)

Call:
svyglm(formula = numeracy_score ~ private * female + age + mother_edu_f, 
    design = des_gha)

Survey design:
Called via srvyr

Coefficients:
               Estimate Std. Error t value Pr(>|t|)    
(Intercept)     4.06624    1.25944   3.229  0.00132 ** 
private         3.24888    0.78474   4.140 4.01e-05 ***
female          0.03200    0.50820   0.063  0.94982    
age             0.87792    0.09383   9.357  < 2e-16 ***
mother_edu_f1   2.41563    0.73075   3.306  0.00101 ** 
mother_edu_f2   2.58423    0.60501   4.271 2.28e-05 ***
mother_edu_f3   2.91001    0.67670   4.300 2.01e-05 ***
mother_edu_f5   4.12109    0.97329   4.234 2.68e-05 ***
private:female -0.63539    0.82734  -0.768  0.44282    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for gaussian family taken to be 23.86947)

Number of Fisher Scoring iterations: 2
coef(m_int)
   (Intercept)        private         female            age  mother_edu_f1 
     4.0662418      3.2488781      0.0319954      0.8779173      2.4156286 
 mother_edu_f2  mother_edu_f3  mother_edu_f5 private:female 
     2.5842257      2.9100140      4.1210917     -0.6353917 

Read the coefficients as follows:

  • private — association for boys (female = 0)
  • female — girl–boy difference in non-private schools
  • private:female — how much the private-school association differs for girls

The private-school association for girls is private + private:female. A negative interaction means the private premium is smaller for girls than for boys (or the reverse if positive). These are associations in Ghana’s MICS sample, not a multi-country causal claim.

5.11 Predicted means

Coefficients on interactions are easy to misread. Predicted means at the four private × female cells turn the same model into a table you can plot. Hold age and mother’s education at the sample profile (modal mother’s education).

mf <- model.frame(m_int)

mom_mode <- names(sort(table(mf$mother_edu_f), decreasing = TRUE))[1]

newdata <- expand.grid(
  private = c(0, 1),
  female = c(0, 1),
  age = mean(mf$age, na.rm = TRUE),
  mother_edu_f = factor(mom_mode, levels = levels(mf$mother_edu_f))
)

newdata$predicted <- as.numeric(
  predict(m_int, newdata = newdata, type = "response")
)

newdata %>%
  mutate(
    school = if_else(private == 1, "Private", "Non-private"),
    sex_lab = if_else(female == 1, "Girl", "Boy")
  ) %>%
  select(school, sex_lab, age, mother_edu_f, predicted)
       school sex_lab      age mother_edu_f predicted
1 Non-private     Boy 10.41016            2  15.78973
2     Private     Boy 10.41016            2  19.03860
3 Non-private    Girl 10.41016            2  15.82172
4     Private    Girl 10.41016            2  18.43521
pred_plot <- newdata %>%
  mutate(
    school = factor(
      if_else(private == 1, "Private", "Non-private"),
      levels = c("Non-private", "Private")
    ),
    sex_lab = factor(
      if_else(female == 1, "Girl", "Boy"),
      levels = c("Boy", "Girl")
    )
  )

cols_sex <- aflearn_pal_categorical(2)
names(cols_sex) <- c("Boy", "Girl")

pred_plot %>%
  ggplot(aes(x = school, y = predicted, fill = sex_lab)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.72, colour = NA) +
  scale_fill_manual(values = cols_sex, name = NULL) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.08))) +
  labs(
    title = "Predicted numeracy by school type and sex",
    subtitle = "Ghana · age and mother’s education held at the sample profile",
    x = NULL,
    y = "Predicted numeracy score",
    caption = "Source: MIC6, Ghana 2017"
  ) +
  theme_aflearn() +
  theme(
    legend.position = "top",
    axis.text.x = aflearn_axis_category()
  )
Predicted mean numeracy by school type and sex — Ghana, controls at sample profile.

Figure 5.4: Predicted mean numeracy by school type and sex — Ghana, controls at sample profile.

Compare the four bars: the height gap between private and non-private within each sex is the private association; the gap between girl and boy within each school type is the gender difference. That is the interaction in one picture.