---
title: "Attendance and the school feeding programme"
subtitle: "School attendance, 2024 · R"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
---

## The question, and the answer that is too easy

Do schools running a feeding programme have higher attendance? The raw means say
yes, by about five points. The interesting part is how much of that five points
survives being asked properly.

Every dataset on this platform is synthetic. No real student is represented.

## Setup

```{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)
```

## Clean the boolean and the duplicate registrations first

One school used `Y` and `N`; two students appear on the roster twice after a
transfer that was never de-registered.

```{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)) |>          # keep the registration that has a grade
  distinct(student_id, .keep_all = TRUE)

cat("roster rows:", nrow(roster), " unique students:", nrow(roster_resolved), "\n")

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

cat("student-days analysed:", nrow(daily), "\n")
```

## Exclude the strike so a closure does not read as absence

Two schools have no attendance rows for fifteen school days in March. Because the
rows are absent rather than false, they do not drag the mean down — but any
analysis that reindexes to a full calendar would turn them into absences, and one
of the two runs a feeding programme.

```{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)
```

The gap barely moves, which is the reassuring outcome: the closure is invisible
because the rows were never written. Run the check anyway — it is how you find
out that the closure was handled correctly rather than assuming it.

## The comparison that overstates itself

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

Five points, on seventy thousand observations. It is tempting to test that
directly, and the p-value would be spectacular — and meaningless.

**The feeding programme is assigned to schools, not to students.** Seventy
thousand student-days are not seventy thousand independent observations of the
programme; they are twenty-four. Testing at the student-day level treats every
child in a school as independent evidence about that school's programme, which is
how a small effect acquires an impossible-looking p-value.

```{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"
  )
```

## Test at the unit the programme was assigned to

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

The difference survives — about five points — but the confidence interval runs
from roughly 1.6 to 8.9 points. That is the honest precision of a comparison
between nine schools and fifteen, and it is a very different claim from "feeding
raises attendance by 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 = "School feeding programme", y = "Attendance (%)",
    title = "Each point is a school, not a student",
    subtitle = "24 schools is the sample size for this question"
  ) +
  theme_minimal(base_size = 11) +
  theme(panel.grid.minor = element_blank())
```

The overlap between the two groups is the finding. Several schools without a
feeding programme out-attend several with one, so the programme is not the only
thing driving attendance — and a school-level intervention decision needs to know
that.

## What this cannot establish

Schools were not randomly assigned to the programme. If feeding went to schools
that already had stronger management, better roads or more engaged parents, this
comparison measures those things as well.

```{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)
```

Nothing in this dataset lets you separate the programme from whatever selected
schools into it. The defensible sentence is "schools with a feeding programme
attend about five points higher, 95% CI 1.6 to 8.9, in an unmatched comparison of
24 schools" — not "feeding raises attendance by five points".

## What to report

The effect with the interval from the school-level test, the number of schools on
each side, the overlap between them, and the sentence saying assignment was not
random. A programme evaluation that reports a student-day p-value has answered a
question nobody asked.
