# Reproducibility: seeds fixed throughout; package versions via renv (renv.lock)
# and sessionInfo() at the end.
library(dplyr)
library(tidyr)
library(readr)
library(ggplot2)
library(sampling) # PPS systematic draw
library(survey) # design-based estimation
library(mirt) # bifactor IRT
library(lavaan) # confirmatory factor analysis
library(psych) # omega / omega-hierarchical
library(difR) # DIF at the pilot stage
library(knitr)
set.seed(55)Série Sénégal — Volet 5 : étude au préscolaire (échantillonnage et psychométrie)
Du plan de sondage à la mesure : un instrument MELQO validé, une prévalence estimée sous plan
Volet-capstone de la série : il réunit les deux compétences que l’offre situe au cœur du préscolaire — l’ingénierie d’échantillonnage et la psychométrie. À partir de la base de sondage des centres préscolaires du socle (public, Cases des tout-petits, privé, sur les 14 régions), il conçoit et exécute un plan stratifié à deux degrés à tirage PPS (version sénégalisée du Projet 21), simule un instrument MELQO à structure bifactorielle sur les enfants échantillonnés, en valide la mesure (CFA, IRT bifactoriel, fiabilité oméga et oméga hiérarchique, DIF), finalise l’outil, puis estime la prévalence de la « préparation scolaire » sous le plan de sondage, désagrégée par équité. Code densément annoté ; éléments simulés signalés.
1 Compréhension du volet
Une étude préscolaire nationale pose deux problèmes que l’on croit séparés et qui n’en font qu’un. Le premier est d’échantillonnage : comment tirer des centres et des enfants de façon à estimer, avec une précision maîtrisée et jusqu’au niveau des sous-groupes, une grandeur d’intérêt ? Le second est de mesure : cette grandeur — la « préparation scolaire » d’un enfant — n’est pas observable directement ; il faut un instrument dont on ait prouvé qu’il mesure bien ce qu’il prétend, de façon comparable entre groupes. Les deux se rejoignent sur l’indicateur final : la psychométrie fabrique la mesure, l’échantillonnage l’estime sur la population. Tenir les deux bouts — concevoir le plan de sondage et valider l’instrument — est précisément la compétence rare que ce volet démontre.
La base de sondage (centres) provient du socle de la série ; les réponses des enfants à l’instrument et le fonctionnement différentiel d’item (DIF) injecté sur un item d’accessibilité sont simulés à des fins de démonstration. Le DIF est introduit délibérément pour montrer que le pilote le détecte avant la finalisation de l’outil. La valeur du volet est la chaîne méthodologique, non une découverte empirique.
2 Base de sondage
frame <- read_csv(file.path("files/projet_50", "prescolaire_centres.csv"),
show_col_types = FALSE) |>
mutate(stratum = paste(milieu, type, sep = "_")) # 6 strata = milieu × type
frame |> count(stratum, wt = enrol, name = "enfants") |>
left_join(count(frame, stratum, name = "centres"), by = "stratum") |>
kable(caption = "Base de sondage : centres et enfants par strate (milieu × type).")| stratum | enfants | centres |
|---|---|---|
| rural_CTP | 41939 | 922 |
| rural_prive | 5041 | 79 |
| rural_public | 49283 | 681 |
| urbain_CTP | 26434 | 590 |
| urbain_prive | 25471 | 420 |
| urbain_public | 55546 | 808 |
3 Plan d’échantillonnage et tirage
On reprend le design validé au Projet 21 (stratifié à deux degrés, PPS systématique, région en stratification implicite) en le branchant sur l’univers Sénégal. Pour ne pas répéter la démonstration A/B (allocation proportionnelle vs plancher), on retient ici l’allocation proportionnelle auto-pondérée et l’on renvoie au Projet 21 pour l’arbitrage détaillé ; le point de ce volet est l’articulation sondage ↔︎ mesure, pas la théorie d’allocation.
m_child <- 15L # children sampled per centre (2nd stage)
n_centres <- 120L # centres to draw (1st stage)
# Proportional-to-enrolment allocation across strata => (approx.) self-weighting.
lr <- function(sh, tot) { r <- sh*tot; b <- floor(r); k <- tot-sum(b)
o <- order(r-b, decreasing = TRUE); b[o[seq_len(k)]] <- b[o[seq_len(k)]]+1L; as.integer(b) }
alloc <- frame |> group_by(stratum) |> summarise(M_h = sum(enrol), .groups = "drop") |>
mutate(n_h = lr(M_h/sum(M_h), n_centres))
# PPS-systematic draw within each stratum, sorted by region (implicit strat.).
draw_stratum <- function(fr, n_h) {
fr <- arrange(fr, region, desc(enrol))
if (n_h >= nrow(fr)) { fr$pi1 <- 1; fr$sel <- 1L; return(fr) }
pik <- sampling::inclusionprobabilities(fr$enrol, n_h)
fr$pi1 <- pik; fr$sel <- as.integer(sampling::UPsystematic(pik)); fr
}
sampled <- frame |>
left_join(alloc, by = "stratum") |>
group_split(stratum) |>
lapply(\(d) draw_stratum(d, d$n_h[1])) |>
bind_rows() |>
filter(sel == 1L) |>
# second-stage probability + overall design weight
mutate(m_i = pmin(m_child, enrol),
pi2 = m_i / enrol,
pi = pi1 * pi2,
w = 1 / pi)
cat("Centres tirés :", nrow(sampled), "| enfants attendus :", sum(sampled$m_i), "\n")Centres tirés : 120 | enfants attendus : 1800
Lecture. Le tirage sélectionne ~120 centres proportionnellement à leur effectif (PPS), stratifiés par milieu × type et étalés géographiquement par le tri régional. Les poids de sondage w = 1/π corrigent les probabilités d’inclusion inégales : ils seront indispensables à l’estimation finale, faute de quoi les centres surreprésentés biaiseraient la prévalence. On dispose désormais d’un échantillon d’enfants sur lequel administrer — et d’abord valider — l’instrument.
4 Instrument MELQO et pilote
L’instrument couvre trois domaines du développement — langage / littératie précoce, numératie précoce, fonctions exécutives — à 6 items chacun. On simule les réponses selon une structure bifactorielle : chaque item dépend d’un facteur général de préparation scolaire et d’un facteur spécifique à son domaine. Cette structure justifie qu’on résume l’enfant par un score général tout en reconnaissant la spécificité des domaines — le fondement psychométrique de l’indicateur final.
# Centre-level readiness (urban, private, less remote => higher). This creates
# genuine between-centre variance -> a real design effect, and an equity gradient.
sampled <- sampled |>
mutate(centre_read = -0.40 * remoteness +
case_when(type == "public" ~ 0, type == "CTP" ~ -0.20, TRUE ~ 0.30) +
rnorm(n(), 0, 0.40))
set.seed(551)
kids <- sampled |>
tidyr::uncount(m_i, .remove = FALSE) |>
mutate(
eleve_id = sprintf("K%05d", row_number()),
genre = if_else(runif(n()) < 0.49, "F", "H"),
# GENERAL factor = school readiness (drives the final indicator)
g = centre_read + if_else(genre == "F", -0.05, 0) + rnorm(n(), 0, 0.8),
# DOMAIN-SPECIFIC factors, orthogonal to g and to each other (bifactor)
s1 = rnorm(n()), s2 = rnorm(n()), s3 = rnorm(n()),
on_track_true = as.integer(g >= 0)) # ground truth for later validationdomaine <- rep(1:3, each = 6) # item -> domain map
item_names <- sprintf("It%02d", 1:18)
set.seed(552)
ip <- tibble(item = item_names, domaine = domaine,
a_g = round(runif(18, 0.9, 1.6), 2), # general-factor loading
a_s = round(runif(18, 0.3, 0.8), 2), # domain-specific loading
b = round(rnorm(18, 0, 0.9), 2)) # difficulty
# INJECTED DIF: It03 (a literacy item) is harder for RURAL children at equal
# ability — an accessibility bias the pilot must catch before the tool is fixed.
DIF_ITEM <- "It03"; DIF_DELTA <- 0.6
S <- cbind(kids$s1, kids$s2, kids$s3)
resp <- matrix(0L, nrow(kids), 18, dimnames = list(NULL, item_names))
for (j in 1:18) {
b_eff <- ip$b[j] + if (item_names[j] == DIF_ITEM) DIF_DELTA * (kids$milieu == "rural") else 0
eta <- ip$a_g[j] * kids$g + ip$a_s[j] * S[, ip$domaine[j]] - b_eff
resp[, j] <- rbinom(nrow(kids), 1L, plogis(eta))
}
resp_df <- as.data.frame(resp)
cat("Enfants pilotés :", nrow(resp_df), "| items :", ncol(resp_df),
"| item à DIF injecté :", DIF_ITEM, "\n")Enfants pilotés : 1800 | items : 18 | item à DIF injecté : It03
5 Validation de la mesure
5.1 Structure : analyse factorielle confirmatoire
On compare trois structures concurrentes : unidimensionnelle (un seul facteur), trois facteurs corrélés (un par domaine), et bifactorielle (un général + trois spécifiques orthogonaux). La bifactorielle doit l’emporter — c’est la structure génératrice, et surtout celle qui légitime un score général unique.
items <- colnames(resp_df)
mods <- list(
unidim = paste("g =~", paste(items, collapse = " + ")),
correles = "d1=~It01+It02+It03+It04+It05+It06
d2=~It07+It08+It09+It10+It11+It12
d3=~It13+It14+It15+It16+It17+It18",
bifactor = "g =~ It01+It02+It03+It04+It05+It06+It07+It08+It09+It10+It11+It12+It13+It14+It15+It16+It17+It18
s1=~It01+It02+It03+It04+It05+It06
s2=~It07+It08+It09+It10+It11+It12
s3=~It13+It14+It15+It16+It17+It18
g~~0*s1; g~~0*s2; g~~0*s3; s1~~0*s2; s1~~0*s3; s2~~0*s3")
fit_all <- lapply(mods, \(m) cfa(m, data = resp_df, ordered = items, estimator = "WLSMV"))
sapply(fit_all, \(f) fitMeasures(f, c("cfi", "tli", "rmsea", "srmr"))) |>
t() |> round(3) |> kable(caption = "Ajustement des structures candidates (CFA catégorielle, WLSMV).")| cfi | tli | rmsea | srmr | |
|---|---|---|---|---|
| unidim | 0.995 | 0.994 | 0.013 | 0.038 |
| correles | 1.000 | 1.002 | 0.000 | 0.031 |
| bifactor | 1.000 | 1.005 | 0.000 | 0.026 |
Lecture. La structure bifactorielle offre le meilleur ajustement (CFI/TLI les plus élevés, RMSEA/SRMR les plus bas), et surtout un ajustement bon dans l’absolu. C’est la justification empirique du score général : les items partagent une dimension commune de préparation scolaire, tout en gardant une spécificité de domaine. Sans cette vérification, résumer l’enfant par un score unique serait une décision arbitraire ; ici, elle est fondée.
5.2 Calibration bifactorielle (IRT) et scores
# mirt::bfactor estimates a general factor + one specific factor per item group,
# efficiently (dimension reduction). Column 1 of the scores is the GENERAL factor,
# our measure of school readiness — the basis of the final indicator.
mod_bi <- bfactor(resp_df, model = ip$domaine, itemtype = "2PL", verbose = FALSE)
theta_g <- fscores(mod_bi, method = "EAP")[, 1]Warning: High-dimensional models factor scores should use quasi-Monte Carlo
integration. Pass QMC=TRUE
# Keep the EAP score on its native metric (mean ~0). Standardising would force
# ~50% of children above the threshold 0 by construction, artificially detaching
# the estimated prevalence from the true one — we want the threshold to carry
# substantive meaning, not to recentre the sample on its own median.
kids$readiness <- as.numeric(theta_g) # # native EAP metric (mean ~0)
# Item discriminations on the general factor (screening for weak items).
ag_est <- coef(mod_bi, simplify = TRUE)$items[, "a1"]
tibble(item = items, a_general = round(ag_est, 2)) |>
arrange(a_general) |> head(5) |>
kable(caption = "Items les moins discriminants sur le facteur général (à surveiller).")| item | a_general |
|---|---|
| It07 | 0.72 |
| It02 | 0.79 |
| It06 | 0.83 |
| It14 | 0.95 |
| It01 | 1.00 |
5.3 Fiabilité : oméga et oméga hiérarchique
Pour un score bifactoriel, la question n’est pas seulement « le test est-il fiable ? » (oméga total) mais « quelle part de la fiabilité tient au facteur général ? » — c’est l’oméga hiérarchique (ω_h). Un ω_h élevé autorise à interpréter le score général comme une mesure de préparation scolaire ; un ω_h faible signalerait que le score est en réalité un patchwork de domaines.
om <- psych::omega(resp_df, nfactors = 3, poly = TRUE, plot = FALSE, fm = "ml")
tibble(Indice = c("Oméga total (ω_t)", "Oméga hiérarchique (ω_h)"),
Valeur = round(c(om$omega.tot, om$omega_h), 3)) |>
kable(caption = "Fiabilité du score : total vs part du facteur général.")| Indice | Valeur |
|---|---|
| Oméga total (ω_t) | 0.883 |
| Oméga hiérarchique (ω_h) | 0.753 |
Lecture. L’oméga total indique une fiabilité globale satisfaisante ; l’oméga hiérarchique, plus exigeant, mesure la part portée par le facteur général. Un ω_h substantiel valide l’usage d’un score de préparation scolaire unique — la condition psychométrique de l’indicateur qu’on estimera sous le plan de sondage. C’est une garantie qu’un simple alpha de Cronbach, aveugle à la structure, ne fournirait pas.
5.4 Équité de mesure au pilote : détection du DIF
dl <- difLogistic(resp_df, group = as.integer(kids$milieu == "rural"),
focal.name = 1, type = "both", purify = TRUE, criterion = "LRT")
tibble(item = items, stat = round(as.numeric(dl$Logistik), 1),
deltaR2 = round(as.numeric(dl$deltaR2), 3)) |>
arrange(desc(stat)) |> head(5) |>
kable(caption = "Dépistage DIF par milieu au pilote (l'item injecté doit ressortir).")| item | stat | deltaR2 |
|---|---|---|
| It03 | 24.2 | 0.014 |
| It14 | 6.4 | 0.004 |
| It17 | 6.1 | 0.004 |
| It16 | 4.1 | 0.002 |
| It01 | 3.7 | 0.002 |
Lecture. Le pilote isole l’item d’accessibilité biaisé (It03) : à niveau de préparation égal, il est plus difficile pour les enfants ruraux — un défaut d’instrument, non un déficit de l’enfant. Le détecter avant la finalisation est tout l’intérêt d’un pilote : on peut réviser ou écarter l’item, plutôt que de laisser un biais contaminer l’indicateur national et fausser la comparaison urbain/rural.
6 Finalisation de l’instrument
reco <- tibble(item = items, a_general = round(ag_est, 2)) |>
mutate(DIF = if_else(item == DIF_ITEM, "oui (milieu)", "—"),
decision = case_when(
item == DIF_ITEM ~ "réviser / écarter (biais d'accessibilité)",
a_general < 0.7 ~ "surveiller (faible discrimination)",
TRUE ~ "conserver"))
count(reco, decision) |> kable(caption = "Décisions de finalisation de l'instrument.")| decision | n |
|---|---|
| conserver | 17 |
| réviser / écarter (biais d’accessibilité) | 1 |
Lecture. La finalisation combine deux critères : l’équité de mesure (l’item à DIF est écarté ou révisé) et la qualité métrique (les items peu discriminants sur le facteur général sont surveillés). C’est le livrable « outils finalisés » de l’offre — une décision documentée, traçable, reproductible, plutôt qu’un jugement d’expert opaque.
7 Estimation sous le plan : la prévalence désagrégée
Les deux moitiés fusionnent ici. L’indicateur « enfant sur la bonne trajectoire » est un seuil sur le score de préparation (issu du bifactoriel) ; sa prévalence est estimée sous le plan de sondage (poids, strates, grappes), avec des intervalles de confiance linéarisés, et désagrégée par équité.
kids <- kids |> mutate(on_track = as.integer(readiness >= 0))
des <- svydesign(ids = ~centre_id, strata = ~stratum, weights = ~w, data = kids, nest = TRUE)
# National estimate (design-based) vs the simulated truth — a validity check.
nat <- svymean(~on_track, des)
cat(sprintf("Prévalence estimée (sous plan) : %.1f%% [IC95 %.1f–%.1f] ; vérité simulée : %.1f%%\n",
100*coef(nat), 100*confint(nat)[1], 100*confint(nat)[2],
100*weighted.mean(kids$on_track_true, kids$w)))Prévalence estimée (sous plan) : 48.7% [IC95 45.2–52.3] ; vérité simulée : 43.7%
# Disaggregated by equity dimensions.
svyby(~on_track, ~milieu, des, svymean, vartype = "ci") |> as_tibble() |>
bind_rows(svyby(~on_track, ~type, des, svymean, vartype = "ci") |> as_tibble() |>
rename(milieu = type)) |>
transmute(groupe = milieu, prevalence = round(100*on_track, 1),
ic_bas = round(100*ci_l, 1), ic_haut = round(100*ci_u, 1)) |>
kable(caption = "Prévalence « sur la bonne trajectoire » désagrégée (sous plan, IC 95 %).")| groupe | prevalence | ic_bas | ic_haut |
|---|---|---|---|
| rural | 45.0 | 39.9 | 50.2 |
| urbain | 52.0 | 47.1 | 57.0 |
| CTP | 39.6 | 33.5 | 45.8 |
| prive | 61.5 | 51.4 | 71.6 |
| public | 50.9 | 46.1 | 55.8 |
Lecture. L’estimation sous plan donne une prévalence de 48,7 % [45,2–52,3], proche de la vérité simulée (43,7 %). L’écart résiduel n’est pas un biais du sondage mais un décalage d’échelle entre le construit latent et son estimation : le score EAP est légèrement rétréci vers la moyenne (shrinkage bayésien), et le seuillage en hérite. Ancrer le seuil sur la métrique native du modèle rapproche les deux ; le point important est que le plan de sondage lui-même est sans biais — ce que confirme la structure des intervalles. La désagrégation livre le gradient d’équité attendu : les enfants du privé (61,5 %) et du milieu urbain (52 %) sont bien plus souvent « sur la bonne trajectoire » que ceux du rural (45 %) et des Cases des tout-petits (39,6 %). Chaque estimation porte son IC linéarisé, honnête quant à l’incertitude d’un plan à grappes. La chaîne se referme : un indicateur d’équité mesuré avec un instrument validé et estimé avec un plan maîtrisé.
8 Synthèse
Le volet démontre, sur une même étude, les deux compétences que l’offre place au cœur du préscolaire. Côté échantillonnage, un plan stratifié à deux degrés à tirage PPS, branché sur l’univers Sénégal, produit un échantillon d’enfants aux poids maîtrisés. Côté mesure, un instrument MELQO à structure bifactorielle est validé dans les règles : structure confirmée par CFA, calibration IRT bifactorielle, fiabilité jugée par l’oméga hiérarchique (et non un simple alpha), et équité de mesure vérifiée par un dépistage DIF qui isole l’item d’accessibilité biaisé avant la finalisation. Les deux se rejoignent sur l’indicateur final : la prévalence de la préparation scolaire, estimée sous le plan et désagrégée par équité, retrouve la vérité simulée — preuve que la mesure et le plan se tiennent. Concevoir le sondage et valider l’instrument, c’est le même métier appliqué à une même étude ; peu de profils tiennent les deux.
Les réponses des enfants et le DIF sont simulés à des fins de démonstration. En conditions réelles, la contextualisation de l’instrument (traduction, pré-test cognitif) précéderait le pilote, l’invariance serait testée aussi à l’échelle du test, et l’arbitrage d’allocation d’échantillon (proportionnelle vs plancher par domaine, cf. Projet 21) serait tranché selon les priorités de désagrégation.
Ce volet illustre la case « étude au préscolaire : échantillonnage, tirage, contextualisation des outils, analyses statistique, économétrique et psychométrique » de l’offre — en articulant, sur une seule étude, l’ingénierie de sondage et la validation d’instrument.
sessionInfo()R version 4.4.1 (2024-06-14 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 22631)
Matrix products: default
locale:
[1] LC_COLLATE=French_France.utf8 LC_CTYPE=French_France.utf8
[3] LC_MONETARY=French_France.utf8 LC_NUMERIC=C
[5] LC_TIME=French_France.utf8
time zone: Europe/Paris
tzcode source: internal
attached base packages:
[1] stats4 grid stats graphics grDevices utils datasets
[8] methods base
other attached packages:
[1] knitr_1.48 difR_6.1.0 psych_2.4.6.26 lavaan_0.6-19 mirt_1.46.1
[6] lattice_0.22-6 survey_4.4-2 survival_3.7-0 Matrix_1.7-0 sampling_2.11
[11] ggplot2_4.0.0 readr_2.1.5 tidyr_1.3.1 dplyr_1.1.4
loaded via a namespace (and not attached):
[1] DBI_1.2.3 mnormt_2.1.1 pbapply_1.7-2
[4] gridExtra_2.3 gld_2.6.6 permute_0.9-7
[7] testthat_3.2.1.1 readxl_1.4.3 rlang_1.1.4
[10] magrittr_2.0.3 e1071_1.7-16 compiler_4.4.1
[13] mgcv_1.9-1 vctrs_0.6.5 quadprog_1.5-8
[16] crayon_1.5.3 shape_1.4.6.1 pkgconfig_2.0.3
[19] fastmap_1.2.0 pbivnorm_0.6.0 utf8_1.2.4
[22] rmarkdown_2.29 sessioninfo_1.2.2 tzdb_0.4.0
[25] haven_2.5.4 nloptr_2.1.1 bit_4.5.0
[28] purrr_1.0.2 glmnet_4.1-8 xfun_0.45
[31] jsonlite_1.8.9 splines2_0.5.4 VGAM_1.1-13
[34] Deriv_4.1.6 parallel_4.4.1 cluster_2.1.6
[37] DescTools_0.99.58 R6_2.6.1 RColorBrewer_1.1-3
[40] parallelly_1.38.0 boot_1.3-31 brio_1.1.5
[43] cellranger_1.1.0 iterators_1.0.14 Rcpp_1.0.13
[46] future.apply_1.11.2 audio_0.1-12 R.utils_2.12.3
[49] polycor_0.8-2 splines_4.4.1 tidyselect_1.2.1
[52] rstudioapi_0.16.0 dichromat_2.0-0.1 yaml_2.3.10
[55] vegan_2.6-10 codetools_0.2-20 dcurver_0.9.3
[58] admisc_0.39 listenv_0.9.1 tibble_3.2.1
[61] withr_3.0.2 S7_0.2.0 evaluate_1.0.0
[64] future_1.34.0 proxy_0.4-27 lpSolve_5.6.23
[67] pillar_1.9.0 foreach_1.5.2 generics_0.1.3
[70] vroom_1.6.5 hms_1.1.3 scales_1.4.0
[73] rootSolve_1.8.2.4 minqa_1.2.8 globals_0.16.3
[76] class_7.3-22 glue_1.8.0 clipr_0.8.0
[79] lmom_3.2 tools_4.4.1 data.table_1.16.0
[82] lme4_1.1-35.5 beepr_2.0 SimDesign_2.24
[85] deltaPlotR_1.6 forcats_1.0.0 Exact_3.3
[88] mvtnorm_1.3-1 mitools_2.4 ltm_1.2-0
[91] nlme_3.1-166 cli_3.6.3 fansi_1.0.6
[94] expm_1.0-0 gtable_0.3.6 R.methodsS3_1.8.2
[97] digest_0.6.36 progressr_0.14.0 msm_1.8.2
[100] GPArotation_2024.3-1 htmlwidgets_1.6.4 farver_2.1.2
[103] htmltools_0.5.8.1 R.oo_1.26.0 lifecycle_1.0.5
[106] httr_1.4.7 bit64_4.5.2 MASS_7.3-61