---
title: "Présence scolaire et programme de cantine"
subtitle: "Présence scolaire, 2024 · R"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
---

## La question, et la réponse trop facile

Les écoles dotées d'un programme de cantine ont-elles une meilleure présence ?
Les moyennes brutes répondent oui, d'environ cinq points. L'intérêt est de savoir
combien de ces cinq points survivent à une question correctement posée.

Tous les jeux de données de cette plateforme sont synthétiques. Aucun élève réel
n'est représenté.

## Mise en place

```{r}
#| message: false
library(readr)
library(dplyr)
library(tidyr)
library(ggplot2)

BASE <- "https://data-analysis.cassion.dev/datasets/files/"

attendance <- read_csv(paste0(BASE, "school-attendance-2024.v1.csv"),
  col_types = cols(student_id = col_character(), present = col_character(),
                   attendance_date = col_date()))

roster <- read_csv(paste0(BASE, "school-roster-2024.v1.csv"),
  col_types = cols(student_id = col_character(), school_id = col_character(),
                   .default = col_guess()))

dim(attendance); dim(roster)
```

## Nettoyer d'abord le booléen et les doubles inscriptions

Une école a employé `Y` et `N` ; deux élèves figurent deux fois sur la liste
après un transfert jamais désinscrit.

```{r}
count(attendance, present)
```

```{r}
attendance <- attendance |>
  mutate(present_clean = case_when(
    tolower(trimws(present)) %in% c("true", "y", "yes")  ~ TRUE,
    tolower(trimws(present)) %in% c("false", "n", "no")  ~ FALSE,
    TRUE ~ NA
  ))

roster_resolved <- roster |>
  arrange(is.na(grade)) |>          # garder l'inscription qui porte un niveau
  distinct(student_id, .keep_all = TRUE)

cat("lignes de liste :", nrow(roster), " eleves uniques :", nrow(roster_resolved), "\n")

daily <- attendance |>
  inner_join(roster_resolved, by = "student_id") |>
  filter(!is.na(present_clean))

cat("jours-eleves analyses :", nrow(daily), "\n")
```

## Exclure la grève pour qu'une fermeture ne se lise pas comme une absence

Deux écoles n'ont aucune ligne de présence pour quinze jours de classe en mars.
Les lignes étant absentes plutôt que fausses, elles ne tirent pas la moyenne vers
le bas — mais toute analyse qui réindexerait sur un calendrier complet les
transformerait en absences, et l'une des deux écoles dispose d'une cantine.

```{r}
school_days <- daily |>
  group_by(school_id) |>
  summarise(days = n_distinct(attendance_date), .groups = "drop") |>
  arrange(days)

head(school_days, 4)
```

```{r}
closed <- school_days |> filter(days < max(days)) |> pull(school_id)

roster_resolved |>
  filter(school_id %in% closed) |>
  distinct(school_id, feeding_programme)
```

```{r}
strike_window <- as.Date(c("2024-03-11", "2024-03-29"))

no_strike <- daily |>
  filter(!(attendance_date >= strike_window[1] & attendance_date <= strike_window[2]))

bind_rows(
  daily     |> group_by(feeding_programme) |> summarise(scope = "all days",           rate = mean(present_clean), .groups = "drop"),
  no_strike |> group_by(feeding_programme) |> summarise(scope = "strike window dropped", rate = mean(present_clean), .groups = "drop")
) |>
  mutate(rate = round(rate, 4)) |>
  pivot_wider(names_from = feeding_programme, values_from = rate)
```

L'écart bouge à peine, ce qui est le résultat rassurant : la fermeture est
invisible parce que les lignes n'ont jamais été écrites. Faites tout de même la
vérification — c'est ainsi que l'on constate que la fermeture a été correctement
traitée, au lieu de le supposer.

## La comparaison qui se surestime

```{r}
daily |>
  group_by(feeding_programme) |>
  summarise(
    student_days = n(),
    attendance   = round(mean(present_clean), 4),
    .groups = "drop"
  )
```

Cinq points, sur soixante-dix mille observations. Il est tentant de tester cela
directement, et la valeur p serait spectaculaire — et dépourvue de sens.

**Le programme de cantine est attribué aux écoles, non aux élèves.** Soixante-dix
mille jours-élèves ne constituent pas soixante-dix mille observations
indépendantes du programme : il y en a vingt-quatre. Tester au niveau du
jour-élève traite chaque enfant d'une école comme une preuve indépendante
concernant le programme de cette école, et c'est ainsi qu'un petit effet acquiert
une valeur p invraisemblable.

```{r}
school_means <- daily |>
  group_by(school_id, feeding_programme) |>
  summarise(students = n_distinct(student_id), attendance = mean(present_clean), .groups = "drop")

school_means |>
  group_by(feeding_programme) |>
  summarise(
    schools   = n(),
    mean_rate = round(mean(attendance), 4),
    sd        = round(sd(attendance), 4),
    .groups = "drop"
  )
```

## Tester à l'unité à laquelle le programme a été attribué

```{r}
t.test(attendance ~ feeding_programme, data = school_means)
```

L'écart survit — environ cinq points — mais l'intervalle de confiance s'étend
d'environ 1,6 à 8,9 points. C'est la précision honnête d'une comparaison entre
neuf écoles et quinze, et c'est une affirmation très différente de « la cantine
augmente la présence de 5,0 points ».

```{r}
#| fig-width: 7
#| fig-height: 4.5
ggplot(school_means, aes(x = feeding_programme, y = 100 * attendance)) +
  geom_boxplot(width = 0.45, outlier.shape = NA, colour = "#5A6B66") +
  geom_jitter(width = 0.09, size = 2.2, colour = "#2F5D50", alpha = 0.8) +
  labs(
    x = "Programme de cantine", y = "Presence (%)",
    title = "Chaque point est une ecole, non un eleve",
    subtitle = "24 ecoles : voila la taille d'echantillon de cette question"
  ) +
  theme_minimal(base_size = 11) +
  theme(panel.grid.minor = element_blank())
```

Le recouvrement entre les deux groupes constitue le constat. Plusieurs écoles
sans cantine affichent une meilleure présence que plusieurs écoles qui en ont
une : le programme n'est donc pas le seul facteur de présence — et une décision
d'intervention au niveau des écoles doit le savoir.

## Ce que cela ne peut pas établir

Les écoles n'ont pas été attribuées au programme de façon aléatoire. Si la
cantine est allée vers des écoles déjà mieux gérées, mieux desservies ou aux
parents plus impliqués, cette comparaison mesure aussi ces facteurs.

```{r}
school_means |>
  arrange(desc(attendance)) |>
  mutate(rank = row_number()) |>
  select(rank, school_id, feeding_programme, students, attendance) |>
  mutate(attendance = round(attendance, 3)) |>
  head(10)
```

Rien dans ce jeu de données ne permet de séparer le programme de ce qui a
sélectionné les écoles pour en bénéficier. La phrase défendable est « les écoles
dotées d'une cantine présentent une assiduité supérieure d'environ cinq points,
IC 95 % de 1,6 à 8,9, dans une comparaison non appariée de 24 écoles » — et non
« la cantine augmente la présence de cinq points ».

## Ce qu'il faut rapporter

L'effet avec l'intervalle issu du test au niveau école, le nombre d'écoles de
chaque côté, leur recouvrement, et la phrase indiquant que l'attribution n'était
pas aléatoire. Une évaluation de programme qui rapporte une valeur p au niveau du
jour-élève a répondu à une question que personne n'a posée.
