cassionAnalyse de données

Leçon 5 sur 8

Unité · Où les comparaisons cèdent

Vingt-quatre tests, deux constats, aucun effet

Le registre d'inscription tire le sexe indépendamment de tout le reste, si bien que l'écart de retard d'âge selon le sexe vaut exactement zéro par construction. Testez-le école par école et deux des vingt-quatre ressortent significatives, ce qui est à peu près ce que le hasard vous devait.

PythonR180 minEnquête SMARTDéfinitions d'indicateurs de l'UNICEFCritères d'évaluation du CAD de l'OCDE

Une comparaison dont on connaît la réponse

Les deux leçons précédentes testaient une comparaison à la fois. Une vraie analyse s’arrête rarement à une seule : un relecteur demande le même indicateur par district, par année, par école, par statut de déplacement, et l’analyste lance chacun.

Cette leçon en lance vingt-quatre sur une comparaison dont la vraie réponse est connue, parce que le jeu de données a été produit avec le sexe tiré indépendamment de l’âge, de l’année, du redoublement et de l’école. La vraie différence de retard d’âge selon le sexe vaut exactement zéro, et tout ce qu’un test y trouve est un artefact d’échantillonnage.

import pandas as pd
import numpy as np
from statsmodels.stats.proportion import proportions_ztest

enrolment = pd.read_csv("school-enrolment-2024.v1.csv")
clean = enrolment[(enrolment["school_year"] == 2024)
                  & (enrolment["age_years"] <= 20)
                  & enrolment["grade"].between(1, 6)]
clean = clean.assign(over_age=clean["age_years"] > clean["grade"] + 5)

results = []
for school, group in clean.groupby("school_id"):
    counts = group.groupby("sex")["over_age"].agg(["sum", "size"])
    if counts["size"].min() < 5:
        continue
    stat, p = proportions_ztest(counts["sum"], counts["size"])
    results.append({"school": school, "p": p,
                    "boys": counts.loc["m", "sum"] / counts.loc["m", "size"],
                    "girls": counts.loc["f", "sum"] / counts.loc["f", "size"]})

tests = pd.DataFrame(results).sort_values("p")
print(f"{len(tests)} schools tested, {(tests['p'] < 0.05).sum()} significant")
library(dplyr)

clean |>
  summarise(k = sum(over_age), n = n(), .by = c(school_id, sex)) |>
  tidyr::pivot_wider(names_from = sex, values_from = c(k, n)) |>
  filter(n_m >= 5, n_f >= 5) |>
  rowwise() |>
  mutate(p = prop.test(c(k_m, k_f), c(n_m, n_f))$p.value)
École Garçons Filles p
SCH23 62,5 % (10/16) 25,9 % (7/27) 0,018
SCH06 39,1 % (9/23) 12,5 % (3/24) 0,036
SCH21 43,5 % (10/23) 25,8 % (8/31) 0,173
SCH03 50,0 % (11/22) 30,8 % (8/26) 0,175
SCH04 27,3 % (3/11) 55,6 % (5/9) 0,199

Vingt-quatre écoles testées, deux significatives à p < 0,05. Les deux seraient écrites. SCH23 en particulier paraît frappante — des garçons en retard d’âge à plus du double du taux des filles, dans une école de 43 enfants.

Aucune des deux n’est réelle. Le générateur n’a jamais laissé le sexe toucher quoi que ce soit.

Deux, c’est ce que le hasard vous devait

n_tests = len(tests)
print(f"tests run: {n_tests}")
print(f"expected significant if nothing is real: {0.05 * n_tests:.1f}")
print(f"chance of at least one: {1 - 0.95 ** n_tests:.1%}")
# 1 - 0.95^24. The arithmetic is one line and it is the whole lesson.

Faux positifs attendus : 1,2. Observés : 2. La probabilité d’obtenir au moins un résultat significatif à partir de vingt-quatre hypothèses nulles vraies vaut 70,8 % — le résultat surprenant aurait donc été de n’en trouver aucun.

Un seuil de 0,05 est une promesse portant sur un test. Il dit que si rien ne se passe, vous serez trompé une fois sur vingt. Lancez vingt tests et être trompé une fois est le cas attendu, non l’accident.

C’est pourquoi « nous avons regardé par école et deux écoles se détachent » n’est pas un constat. C’est une description de ce que font vingt-quatre tests.

Bonferroni : divisez le seuil par le nombre de tests

BONFERRONI = 0.05 / n_tests
print(f"corrected threshold: {BONFERRONI:.5f}")
print(f"surviving: {(tests['p'] < BONFERRONI).sum()}")
p.adjust(tests$p, method = "bonferroni") < 0.05

Seuil 0,05/24 = 0,00208. Survivants : aucun. Le plus petit p des vingt-quatre vaut 0,018, un ordre de grandeur plus loin.

La correction est grossière — elle suppose le pire cas, que tous les tests sont indépendants et toutes les hypothèses nulles vraies — et elle est grossière du bon côté. C’est le bon choix par défaut quand vous n’avez pas de raison d’en préférer un autre, et elle tient sur une ligne.

Benjamini-Hochberg, quand vingt-quatre devient deux cents

Bonferroni contrôle la probabilité d’un seul faux positif, ce qui est strict. Quand une analyse de dépistage lance des centaines de tests et s’attend à quelques effets réels parmi eux, contrôler la proportion de fausses découvertes est la garantie la plus utile.

from statsmodels.stats.multitest import multipletests

reject, adjusted, _, _ = multipletests(tests["p"], alpha=0.05, method="fdr_bh")
print(f"BH survivors: {reject.sum()}")
p.adjust(tests$p, method = "BH") < 0.05

Benjamini-Hochberg ne retient lui non plus aucune des vingt-quatre, ce qui est la bonne réponse ici — il n’y a rien à trouver. Son avantage apparaît quand il y a quelque chose : sur deux cents tests dont vingt effets réels, Bonferroni en manquera la plupart et BH non.

Employez Bonferroni pour une petite famille confirmatoire ; BH pour un large dépistage exploratoire. Les deux tiennent sur une ligne, et le choix importe beaucoup moins que le fait d’en faire un.

La correction ne détruit pas les constats réels

La crainte qui empêche de corriger est que cela enterre quelque chose de vrai. Testez un effet réel de la même façon et regardez ce qui se produit.

previous = enrolment[enrolment["school_year"] == 2023].set_index("student_id")
clean = clean.assign(last_year=clean["student_id"].map(previous["end_of_year_status"]))
returning = clean[clean["last_year"].isin(["repeated", "promoted"])]

survivors = []
for school, group in returning.groupby("school_id"):
    counts = group.groupby("last_year")["over_age"].agg(["sum", "size"])
    if len(counts) < 2 or counts["size"].min() < 5:
        continue
    stat, p = proportions_ztest(counts["sum"], counts["size"])
    survivors.append(p)

survivors = np.array(survivors)
print(f"{len(survivors)} schools, {(survivors < 0.05).sum()} significant, "
      f"{(survivors < 0.05 / len(survivors)).sum()} survive Bonferroni")
# Same loop, different comparison. The generator made this one real.
Comparaison Tests Significatifs à 0,05 Survivent à Bonferroni
Retard d’âge selon le sexe (aucun effet réel) 24 2 0
Retard d’âge selon le redoublement de 2023 (réel) 14 14 9

L’écart de 94,5 % contre 30,6 % du cours d’éducation survit à la correction dans neuf des quatorze écoles où il peut être testé. Un écart qui n’existe pas ne survit nulle part. La correction n’est pas un impôt sur les constats ; c’est ce qui les sépare des deux écoles qui allaient de toute façon paraître intéressantes.

Comptez les tests, y compris ceux que vous n’avez pas rapportés

Le nombre qui entre dans la correction est le nombre de tests que vous avez lancés, non celui que vous avez écrit. Quatre sortes de tests échappent régulièrement au compte :

Les sous-groupes regardés puis abandonnés. Découper par district, ne rien voir, et découper par année ensuite fait deux familles de tests, non une.

Les résultats essayés tour à tour. Tester le retard d’âge, puis la présence, puis l’achèvement contre le même regroupement fait trois tests.

Les seuils déplacés. « Retard d’âge » à année + 2 plutôt qu’à année + 1 est un second test de la même idée.

L’analyse que quelqu’un d’autre a lancée sur les mêmes données. Rarement connue, et à demander quand un relecteur arrive avec un résultat frappant sur un sous-groupe.

plan = {
    "outcomes": ["over_age"],
    "groupings": ["sex"],
    "subgroups": ["school_id"],
}
n_planned = 1 * 1 * 24
print(f"tests declared in the analysis plan: {n_planned}")
# Write the number down before running anything. It cannot be recovered after.

Déclarez la famille avant de la lancer. Après coup, le nombre de tests est affaire de mémoire et d’intérêt personnel, et les deux le réduisent.

Rapportez-le en entier

Over-age enrolment by sex, school-level analysis

  24 schools tested (both sexes with at least 5 students in grades 1-6).
  2 schools significant at p < 0.05: SCH23 (p = 0.018), SCH06 (p = 0.036).

  With 24 tests, 1.2 significant results are expected by chance alone and
  the probability of at least one is 71%. Neither school survives the
  Bonferroni threshold of 0.0021, and neither is reported as a finding.

  For comparison, over-age by 2023 repetition is significant in all 14
  schools testable and survives Bonferroni in 9 of them.

La ligne de comparaison est ce qui rend le bloc convaincant plutôt que défensif. Elle montre la même procédure trouvant quelque chose quand il y a quelque chose à trouver, ce qu’un responsable de programme a besoin de voir avant d’accepter que deux écoles frappantes soient du bruit.

La suite

Tous les tests jusqu’ici ont supposé qu’une ligne était une observation indépendante. La leçon suivante prend une comparaison où cette hypothèse tombe — une cantine scolaire attribuée par école, testée sur des élèves — et trouve une statistique t presque deux fois plus grande que l’honnête.

Animer cette leçon

La leçon en diaporama, la prose étant reléguée dans les notes du présentateur plutôt que projetée. Produit à partir de cette page, dont il ne peut donc pas s'écarter.

Lancer le diaporamaLire les diapositives

Le PDF ne requiert aucun logiciel et se projette depuis n'importe quel poste. Le fichier PowerPoint est fait pour être modifié : appliquez la charte de votre organisation, retirez une section pour une séance plus courte, ou fusionnez deux leçons en atelier.