Loading and cleaning the data

I read in the Eastern PA schools data and did a little cleanup before plotting. A few of the numeric columns came in as text, and the grade level columns use -2 as a placeholder for “not applicable,” so I left those alone since I am not using them here. I did build two new fields: a community education rate (percent of adults 25 and older with at least a high school diploma) and a readable label for school level, since the raw LEVEL codes are just numbers.

schools <- read_excel("Eastern_PA_Schools.xlsx", sheet = "ccdPSSA")

schools <- schools %>%
  mutate(
    Census_AttainDiplomHigher = as.numeric(Census_AttainDiplomHigher),
    Census_Pop25older = as.numeric(Census_Pop25older),
    MEMBER = as.numeric(MEMBER),
    PctFreeLunch = as.numeric(PctFreeLunch),
    diploma_rate = Census_AttainDiplomHigher / Census_Pop25older * 100,
    LEVEL = as.character(LEVEL),
    level_label = recode(LEVEL,
      "1" = "Elementary",
      "2" = "Middle",
      "3" = "High",
      "4" = "Other",
      .default = NA_character_
    )
  ) %>%
  filter(!is.na(level_label))

Plot 1: Poverty and community education level

My first question was whether schools with higher poverty (measured through the free lunch percentage) tend to sit in communities with lower rates of high school completion among adults. This is basically the same relationship a school finance office would care about when arguing for Title I funding: poverty and community education levels usually move together, and funding formulas are built around that link.

For this plot I used geom_point for the raw relationship and layered a geom_smooth trend line on top, split by school level. I kept the color palette limited to four flat colors, took out the default gray background, and let the trend lines do the talking instead of adding extra labels or gridlines. That follows Tufte’s idea of maximizing the data-ink ratio: every line and dot on this plot is carrying information, and nothing is there just for decoration.

ggplot(schools, aes(x = diploma_rate, y = PctFreeLunch, color = level_label)) +
  geom_point(alpha = 0.35, size = 1.6) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.1) +
  scale_color_manual(values = c("Elementary" = "#2b6cb0", "Middle" = "#c05621",
                                 "High" = "#276749", "Other" = "#805ad5")) +
  labs(
    title = "Higher community diploma rates line up with lower school poverty",
    subtitle = "Each point is a school in eastern Pennsylvania",
    x = "Percent of community (25+) with a high school diploma or higher",
    y = "Percent of students receiving free lunch",
    color = "School Level"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor = element_blank(),
    plot.title = element_text(face = "bold")
  )

My analysis: The trend lines all slope downward, which means the pattern holds across every school level: as the share of adults with a diploma goes up in a community, the share of kids on free lunch goes down. High schools show the steepest drop, and elementary schools show the most scatter around the line, probably because elementary attendance zones are smaller and pick up more neighborhood-level variation than the district-wide zones that usually feed into a single high school. The relationship is not perfect. There are plenty of schools sitting well off the trend line in both directions, so community education is a useful predictor of poverty, but it is not the whole story.

Plot 2: Distribution of poverty by school level

The first plot showed an average trend, but it hid how much the free lunch percentage actually varies within each level. For this plot I wanted to show the full distribution, not just a mean or a trend line.

I used a geom_boxplot to show the median, quartiles, and outliers, and added geom_jitter underneath at low opacity so the individual schools are still visible instead of getting summarized away. This follows Wexler’s point that summary statistics like a median line can hide real variation in the underlying data. Showing the raw points along with the summary box lets the reader judge for themselves how spread out or clustered the schools really are, rather than trusting a single number.

ggplot(schools, aes(x = level_label, y = PctFreeLunch, fill = level_label)) +
  geom_jitter(width = 0.15, alpha = 0.2, color = "gray30", size = 1) +
  geom_boxplot(alpha = 0.7, outlier.shape = NA, width = 0.5) +
  scale_fill_manual(values = c("Elementary" = "#2b6cb0", "Middle" = "#c05621",
                                "High" = "#276749", "Other" = "#805ad5")) +
  labs(
    title = "Free lunch rates vary widely within every school level",
    x = "School Level",
    y = "Percent of students receiving free lunch"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")

My analysis: Elementary schools have the highest median free lunch rate and the widest spread, with a long tail of high-poverty schools stretching up toward 100%. High schools sit lower overall, which makes sense given they are larger and pull from a bigger, more mixed attendance area, so the poverty in one poor neighborhood gets diluted across a bigger student body. The “Other” category, which likely includes vocational and alternative schools, has the fewest data points but some of the highest individual poverty rates in the whole dataset. That is worth flagging even though the sample size there is small.

Plot 3: Which districts serve the most students in poverty?

My last plot moves from schools to districts, and asks a more practical question: among the largest school districts in the dataset, which ones are also carrying the heaviest poverty load? I grouped schools by district, summed total enrollment, and calculated an enrollment weighted free lunch percentage, then kept the fifteen largest districts by total enrollment.

I used geom_col with coord_flip and sorted the bars from highest to lowest, rather than leaving them in alphabetical order. Tufte would call alphabetical sorting a wasted opportunity, since it forces the reader to scan the whole chart to find the largest value. Ordering the bars means the story is visible at a glance. I also skipped a legend entirely since color is not encoding a separate variable here, which keeps the chart from including a marker that has no informational job to do, another one of Tufte’s core arguments against chartjunk.

district_summary <- schools %>%
  group_by(LEA_NAME) %>%
  summarize(
    total_enroll = sum(MEMBER, na.rm = TRUE),
    weighted_free_lunch = sum(PctFreeLunch * MEMBER, na.rm = TRUE) / total_enroll
  ) %>%
  arrange(desc(total_enroll)) %>%
  slice_head(n = 15)

ggplot(district_summary, aes(x = reorder(LEA_NAME, weighted_free_lunch), y = weighted_free_lunch)) +
  geom_col(fill = "#2b6cb0", width = 0.7) +
  geom_text(aes(label = round(weighted_free_lunch, 1)), hjust = -0.2, size = 3.2) +
  coord_flip(clip = "off") +
  scale_y_continuous(limits = c(0, max(district_summary$weighted_free_lunch) * 1.15)) +
  labs(
    title = "Poverty rates among the 15 largest eastern PA districts",
    subtitle = "Enrollment-weighted percent of students receiving free lunch",
    x = NULL,
    y = "Percent receiving free lunch"
  ) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.major.y = element_blank())

My analysis: Even among the largest districts in the region, the poverty rate ranges from under 20% to well over 60%. That is a big spread for districts that are all considered “large” by enrollment, and it means district size alone tells you almost nothing about the poverty a district is dealing with. From a school finance standpoint, this is exactly the kind of chart a state funding office would want before deciding how to allocate Title I dollars: enrollment size and poverty need to be considered separately, since a big district is not automatically a well-resourced one.

Summary

Across all three plots, I tried to use a different geom each time (points and a trend line, a boxplot with jitter, and a sorted bar chart) so the report shows a range of ggplot tools rather than three versions of the same chart. I leaned on Tufte’s data-ink principle throughout by cutting unnecessary gridlines, legends, and background shading, and I used Wexler’s guidance on showing real data alongside summaries so the reader is not just trusting an average. Together the three plots tell a consistent story: poverty in these schools is closely tied to the education level of the surrounding community, that poverty varies a lot within any single school level, and that even large, well established districts can be dealing with a very heavy poverty load.