3 Harmonising MICS6 reading outcomes

Cross-country work on foundational reading needs comparable outcomes: word scores, comprehension, and skills indicators pooled across MICS6 surveys. Official country files use different names and layouts, so they do not pool cleanly on their own.

IPUMS MICS solves much of that problem. It harmonises variable names and codes and releases integrated extracts that serve most cross-country analyses well.

It has one decisive limit for reading. Where a survey administered several passages in different languages, the IPUMS extract keeps only the first passage. In multilingual countries a child’s strongest or language-matched attempt may sit on a later passage, so those extracts are a poor base for reading-achievement analysis.

This chapter builds the reading outcomes from the official per-survey fs.sav files prepared in Download the MICS Data. It harmonises names, retains every passage (and folds B/C where the scripts require), derives passage length from the data, builds scores and skills on a common footing, and writes data/mics6_reading_harmonized.dta with merge keys so you can still link background variables from an IPUMS extract.

One complete v1.1 R script accompanies the chapter. It lives in data/ next to MICS_Datasets/:

Script Language
data/harmonize-mics-reading-v1.1.R R, using the tidyverse

Download the file from AFLEARN’s GitHub page, set the working directory to the data/ folder, and run:

source("data/harmonize-mics-reading-v1.1.R")

It writes data/mics6_reading_harmonized.dta. The sections below explain what each stage does and why, so you can adapt the logic rather than treat it as a black box.

3.1 Before you start

You need data/MICS_Datasets/<SURVEY>/fs.sav in place from Download the MICS Data (prepare-mics-fs.R). The fs.sav file holds the Foundational Learning Skills questionnaire, administered to children aged 5–17, with the reading assessment itself targeted at ages 7–14. The guide workspace looks like this:

mics-guide/
└── data/
    ├── MICS_Datasets/
    │   └── BEN_2021_MICS6_v01_M/fs.sav
    └── harmonize-mics-reading-v1.1.R

Currently, 18 country codes are supported:

BEN CAF COD COM GHA GMB GNB LSO MDG MWI NGA SLE STP SWZ TCD TGO TUN ZWE

Any other survey folder in data/MICS_Datasets/ is skipped with a message.

3.2 The harmonisation pipeline at a glance

for each survey folder
  1.  identify the survey from its folder name
  2.  fix questionnaire numbering quirks
  3.  harmonise variable names 
  4.  repair known data problems (SLE swap; early refusal cleaning)
  5.  assign passage language
  6.  derive passage length from the data
  7.  Move Malawi's Chichewa-only attempts into the main slots
  8.  build reading, comprehension, and practice scores
  9.  build accuracy and foundational skills
  10. classify the assessment outcome (codes 0–9)
  11. add merge keys and keep the analysis variables
append all surveys, then label and save

3.3 Identify the survey

Folder names carry an ISO3 country code and a year, as in BEN_2021_MICS6_v01_M. A regular expression pulls both out. Anything that does not match, is not in the supported list, or has no fs.sav, is skipped.

for (folder in list.dirs(root, recursive = FALSE, full.names = FALSE)) {
  m <- regmatches(folder, regexec("^([A-Za-z]{3})_([0-9]{4})_", folder))[[1]]
  if (length(m) == 0) next
  iso  <- toupper(m[2])
  year <- m[3]
  if (!iso %in% supported) next

  d <- read_sav(file.path(root, folder, "fs.sav")) %>% zap_labels()
}

zap_labels() strips the SPSS value labels but keeps the underlying numeric codes. Fresh labels are applied at the very end.

3.4 Fix questionnaire numbering

Two surveys need repairs before renaming. Zimbabwe stores five comprehension items in FL22B through FL22F. Comoros shifts FL21BE/FL21BF by one position (it does not rename the whole FL21B* series onto FL22*).

if (iso == "ZWE") {
  d <- d %>% rename(FL22D = "FL22E", FL22E = "FL22F")
}
if (iso == "COM") {
  d <- d %>% rename(FL21BD = "FL21BE", FL21BE = "FL21BF")
}

These renames are deliberately not wrapped in capture. If the data ever changes shape, you want a loud error here.

3.5 Harmonise variable names

A slim set of renames maps questionnaire codes onto readable names used downstream. Each rename is conditional: a survey that never asked a question keeps its own variables and the script carries on. A B or C suffix marks the second or third reading passage.

d <- cap_rename(d, c(
  age = "CB3", consent = "FL1", child_consent = "FL3",
  likestory = "FL10", words_att = "FL20A", words_incorrect = "FL20B"
  # ... full map in data/harmonize-mics-reading-v1.1.R
))

3.5.1 Chad school language

For Chad only, FL9A can supply lang_school, and non-missing FL9B values are copied in afterwards:

if (iso == "TCD") {
  d <- cap_rename(d, c(lang_school = "FL9A"))
  d <- d %>% mutate(
    lang_school = if_else(present(FL9B), FL9B, lang_school)
  )
}

3.5.2 Country-specific comprehension renames

Most surveys store passage-1 comprehension in FL22AFL22E. Four surveys store passage 1 in FL21BAFL21BE and passage 2 in FL22AFL22E:

Surveys Passage 1 Passage 2
Default (everyone else) FL22AFL22Eread_comp_15 other codes (FL122*, FLB22*, …)
SWZ, NGA, ZWE, COM FL21BAFL21BEread_comp_15 FL22AFL22Eread_compB_15

COM is excluded from the default FL22 rename so those items remain available for passage B.

After renaming, the script guarantees that the variables the pipeline needs exist, creating them as missing where a survey omitted them (ensure).

3.5.3 A note on missing values

In R, comparisons with NA yield NA, not FALSE. Helpers make the intended tests explicit:

present <- function(x) !is.na(x)
eq      <- function(x, v) !is.na(x) & x == v
ne_obs  <- function(x, v) !is.na(x) & x != v

3.6 Repair known data problems

3.6.1 Sierra Leone word swap

Some Sierra Leone records have words-attempted and words-incorrect stored the wrong way round. The fix swaps them where attempted is smaller than incorrect, and only where the incorrect count is actually recorded (not missing). A careless “less than or equal to missing” style check would create false zeros.

3.6.2 Early refusal cleaning

In São Tomé and Príncipe, Madagascar, and the Central African Republic (CAF, not CAR), children who refused the story or failed practice can still have non-missing word counts. Those counts are cleared before scores are built:

if (iso %in% c("STP", "MDG", "CAF")) {
  drop1 <- !eq(d$likestory, 1) | ne_obs(d$practice_correct, 1)
  d <- d %>% mutate(
    words_att       = if_else(drop1, NA_real_, words_att),
    words_incorrect = if_else(drop1, NA_real_, words_incorrect)
  )
}

Madagascar applies the same idea to passages B and C using FL110 / FL210 and the practice-B/C items.

3.7 Passage language

Language is assigned next, before length. Languages for the single-language surveys are English (Ghana, Sierra Leone, Gambia), French (Benin, Central African Republic, Chad, Comoros, DR Congo, Togo), Arabic (Tunisia) and Portuguese (São Tomé, Guinea-Bissau). For these countries, the language is assigned to the passage language variable.

Group Code Surveys
English 1010 GHA, SLE, GMB
French 1020 BEN, CAF, TCD, COM, COD, TGO
Portuguese 1040 STP, GNB
Arabic 2010 TUN

For Nigeria, Eswatini, Lesotho, Madagascar, Malawi, and Zimbabwe, the variables used to identify the passage language differ across surveys. The harmonisation process takes this into account (see the scripts). For instance, Zimbabwe is the most involved, because the passage language has to be inferred from the language of instruction, then from the language spoken at home, then from the child’s own choice of story

if (iso == "ZWE") {
  d <- d %>% ensure(c("FL10C", "FL21D")) %>% mutate(
    passage_language = case_when(
      eq(lang_school, 1) ~ 1010,
      eq(lang_school, 2) ~ 3140,
      eq(lang_school, 3) ~ 3150,
      present(lang_school) & lang_school >= 7 & lang_school <= 9 ~ NA_real_,
      TRUE ~ as.numeric(lang_school)
    ),
    passage_language = if_else(is.na(words_att), NA_real_, passage_language),
    passage_language = case_when(
      eq(lang_home, 1) & is.na(passage_language) & present(words_att) ~ 1010,
      eq(lang_home, 2) & is.na(passage_language) & present(words_att) ~ 3140,
      eq(lang_home, 3) & is.na(passage_language) & present(words_att) ~ 3150,
      TRUE ~ passage_language
    ),
    passage_language = case_when(
      eq(FL10C, 1) & present(words_att) ~ 1010,
      eq(FL10C, 2) & present(words_att) ~ 3140,
      eq(FL10C, 3) & present(words_att) ~ 3150,
      TRUE ~ passage_language
    ),
    passage_length  = if_else(present(words_att), 72, NA_real_),
    passageB_length = if_else(present(wordsB_att), 62, NA_real_)
  )
}

3.8 Passage length from the data

For each language, passage length is the maximum words attempted among children who read that language:

\[ \text{passage_length} = \max(\text{words_att} \mid \text{passage_language}) \]

d$passage_length <- passage_len_by_lang(d$words_att, d$passage_language)
# same idea for B/C when those passages exist

This puts different languages on a common accuracy scale without maintaining a country-by-country word-count table. It assumes that, within a language, at least one child reached (or nearly reached) the end of the passage. If every child stopped early, the estimated length would be too short and accuracy could exceed 1.

3.9 Chichewa-only attempts

In Malawi, English is passage A and Chichewa is passage B. Some children attempt only Chichewa. Those records are moved into the main slots so the pooled file treats them as the child’s primary attempt, and the B slots are cleared:

if (iso == "MWI") {
  tmp <- is.na(d$words_att) & present(d$wordsB_att)
  d <- d %>% mutate(
    words_att        = if_else(tmp, wordsB_att, words_att),
    words_incorrect  = if_else(tmp, wordsB_incorrect, words_incorrect),
    passage_language = if_else(tmp, passageB_language, passage_language),
    passage_length   = if_else(tmp, passageB_length, passage_length)
    # ... comprehension items, then blank B
  )
}

3.10 Reading, comprehension, and practice scores

With cleaned word counts in place:

\[ \text{reading_score} = \text{words_att} - \text{words_incorrect} \]

Comprehension scores count how many of the five items are coded 1 (correct), and are missing when the first item was never asked. Practice is passed only if the practice sentence and both practice questions are correct. fail_practice is set if any passage’s practice was failed.

d <- d %>% mutate(reading_score = words_att - words_incorrect)
d$read_comp_score <- comp_score(d, "read_comp")
d$practice_outcome <- practice_outcome(
  d, "practice_correct", "practice_question1", "practice_question2"
)
d$fail_practice <- 1 - d$practice_outcome

3.11 Accuracy and foundational reading skills

3.11.1 Accuracy

Accuracy is the share of the passage the child read correctly:

\[ \text{reading_accuracy} = \frac{\text{reading_score}}{\text{passage_length}} \]

Dividing by passage_length puts different languages on a common scale. The same formula is used for a second passage as readingB_accuracy when that passage exists.

3.11.2 Foundational reading skills

Foundational reading skills is 1 only when both hold (among children with a reading score):

  1. All five comprehension questions are correct: \(\text{read_comp_score} = 5\).
  2. At least 90% of the passage words are correct:

\[ \text{cutoff} = \lfloor 0.9 \times \text{passage_length} \rfloor \]

\[ \text{reading_skills} = \begin{cases} 1 & \text{if } \text{read_comp_score} = 5 \text{ and } \text{reading_score} \ge \text{cutoff} \\ 0 & \text{otherwise (when } \text{reading_score} \text{ is non-missing)} \end{cases} \]

d <- d %>% mutate(
  reading_accuracy = reading_score / passage_length,
  cutoff = trunc(0.9 * passage_length),
  reading_skills = if_else(
    present(reading_score),
    as.numeric(eq(read_comp_score, 5) & ge_obs(reading_score, cutoff)),
    NA_real_
  )
)

3.12 Classify the assessment outcome

reading_status records why a child does or does not have a reading score. Codes 2–4 only fill gaps; codes 5–8 overwrite; code 9 catches anyone still unclassified (inconsistent data, for example comprehension without word counts):

Code Meaning
0 Not interviewed
1 Age under 7 or over 14
2 Caregiver consent not given
3 Child consent not given
4 Assessment language does not match the child’s language
5 Child does not want to read the story
6 Failed the practice sentence and questions
7 Attempted a reading passage
8 Read 90% of words and answered all comprehension questions correctly
9 Not classified — data inconsistent
d <- d %>% mutate(
  reading_status = if_else(present(interview_result) & interview_result > 1, 0, NA_real_),
  reading_status = if_else(present(age) & (age < 7 | age > 14), 1, reading_status),
  reading_status = if_else(ne_obs(consent, 1) & is.na(reading_status), 2, reading_status),
  reading_status = if_else(ne_obs(child_consent, 1) & is.na(reading_status), 3, reading_status),
  reading_status = if_else(eq(lang_mismatch, 1) & is.na(reading_status), 4, reading_status),
  reading_status = if_else(eq(child_refuses_read, 1), 5, reading_status),
  reading_status = if_else(eq(fail_practice, 1), 6, reading_status),
  reading_status = if_else(present(max_reading_score), 7, reading_status),
  reading_status = if_else(eq(max_reading_skills, 1), 8, reading_status),
  reading_status = if_else(is.na(reading_status), 9, reading_status)
)

3.13 Merge keys and variable selection

Each survey is uniquely identified by cluster, household and line number. Those are copied to the IPUMS-MICS names cluster, hhno, and linech. Country and year are added, then the file is cut to a slim analysis set:

d %>% select(
  any_of(c("country_iso3", "year", "cluster", "hhno", "linech",
           "HH1", "HH2", "LN", "reading_status")),
  matches("^reading"), matches("^read_comp"), matches("^practice"),
  matches("^passage"), matches("^words.*_att$"), matches("^words.*_incorrect$")
)

3.14 What the harmonized dataset contains

One row per child in the Foundational Learning Skills module, across all supported surveys.

Variable Meaning
country_iso3, year Survey identification
cluster, hhno, linech Merge keys for IPUMS-MICS (copies of HH1, HH2, LN)
reading_status Outcome of the reading assessment, coded 0–9
words_att, words_incorrect Raw word counts for passage 1
reading_score Words read correctly, passage 1
reading_accuracy reading_score / passage_length
reading_skills 90% of words plus all comprehension correct
read_comp_1read_comp_5, read_comp_score Comprehension items and total
practice_*, practice_outcome Practice sentence and questions
passage_language, passage_length Passage characteristics (length from data)
*B_* equivalents The same measures for the second passage
max_reading_score, max_reading_accuracy, max_reading_skills Best result across passages

3.15 Known limitations

  • Data-driven passage length uses the maximum words attempted within each language. If nobody in that language reached the end of the passage, the estimated length is too short and accuracy can exceed 1.
  • Year differences within a country are not modelled. Tunisia appears twice (2018 and 2023) and both use the same language code.

3.16 Checks after running

library(tidyverse)
library(haven)

d <- read_dta("data/mics6_reading_harmonized.dta")

count(d, country_iso3, year)
count(d, reading_status)
summary(d$reading_accuracy)
d %>% filter(reading_status == 8) %>% nrow()

Accuracy should sit between 0 and 1. Values above 1 point to an underestimated passage length for that language.

3.17 Next steps

With data/mics6_reading_harmonized.dta in place:

Because the file carries cluster, hhno and linech, it can also be merged onto an IPUMS-MICS extract to combine these reading outcomes with harmonised household and background variables.