Assignment 5A: Airline Delays

DATA 607 · Week 5 · Tidying and Transforming Data

Author

Dillon Leeper

Published

October 4, 2026

Overview

This assignment uses the airline arrival data from Kaiser Fung’s Numbersense: on-time and delayed flight counts for two airlines, Alaska and AM West, across five destination cities. The table is laid out “wide,” which is easy for a person to read but awkward for R to analyze. The goal is to recreate it as a CSV in the same layout, tidy it with tidyr and dplyr, and then compare the two airlines’ delay rates overall and city by city.

Approach

  1. Recreate the table as a CSV, keeping the original’s messy layout.
  2. Load it into R and fill in the blank cells with code.
  3. Reshape it from wide to long, then partway back to wide to calculate delay rates.
  4. Compare the airlines overall, then city by city.
  5. Explain why those two comparisons disagree.

Load the data

Each airline’s name only appears once in the original table, on its “on time” row, and the “delayed” row underneath is left blank. There’s also an empty row separating the two airlines. I kept both of those quirks in the CSV so the cleanup happens in code instead of by hand.

library(tidyverse)   # reading, cleaning, reshaping, and charting
library(knitr)       # kable() for clean tables
library(scales)      # percent() and comma() for formatting numbers

# One color per airline, used in every chart
airline_colors <- c("ALASKA" = "#1f66a8", "AM WEST" = "#c9701a")
csv_url <- paste0(
  "https://raw.githubusercontent.com/dillonleeper/",
  "DATA-607/main/assignments/week05/5A/airline-delays.csv"
)

# Try the GitHub copy first; if it can't be reached, use the local copy
raw <- tryCatch(
  read_csv(csv_url, col_names = FALSE, show_col_types = FALSE),
  error = function(e) {
    read_csv("airline-delays.csv", col_names = FALSE, show_col_types = FALSE)
  }
)

raw

This is the table exactly as entered: the header row has two blank cells, the “delayed” rows have no airline name, and row 4 is completely empty. I read it with col_names = FALSE so the header row comes in as regular data, which makes the two blank header cells easy to handle. Because that row contains words, R reads every column as text for now, including the flight counts.

Tidy the data

Fix the header and fill in the airline names

# Save the first row as the column names, and name the two blank cells
header <- raw |> slice(1) |> unlist() |> as.character()
header[1:2] <- c("airline", "status")

airlines_wide <- raw |>
  slice(-1) |>                                      # drop the row holding the header words
  set_names(header) |>                              # use the saved words as column names
  filter(!if_all(everything(), is.na)) |>           # drop the empty separator row
  fill(airline, .direction = "down") |>             # carry each airline name down
  mutate(across(-c(airline, status), as.integer))   # turn the counts back into numbers

airlines_wide
  • filter(!if_all(everything(), is.na)) removes any row where every cell is blank, which drops the separator row no matter where it sits.
  • fill() copies the last non-blank value down into the blank cell below it, so each “delayed” row picks up its airline’s name. This works because each “delayed” row sits directly under its own airline’s “on time” row.
  • as.integer turns the five city columns back into whole numbers.

Wide to long

airlines_long <- airlines_wide |>
  pivot_longer(
    cols = -c(airline, status),   # stack every column except these two
    names_to = "city",            # the old column headers go here
    values_to = "flights"         # the numbers in the cells go here
  ) |>
  relocate(city, .after = airline)   # order columns broad to narrow

airlines_long

pivot_longer() stacks the five city columns into two: city holds the old column headers, and flights holds the numbers that were in the cells. Each wide row becomes five rows, one per city, so 4 rows × 5 cities = 20 rows. It’s the same 20 numbers as before, now one per row. I used relocate() to order the columns from broadest to narrowest (airline, city, status, flights), which makes each row read like a sentence: “Alaska, in Los Angeles, on time: 497 flights.”

Back to wide, partway

To calculate a delay rate, the on-time and delayed counts for the same airline and city need to be in the same row so they can be divided.

airlines_city <- airlines_long |>
  pivot_wider(names_from = status, values_from = flights) |>
  rename(on_time = `on time`) |>
  mutate(
    total = on_time + delayed,
    delay_rate = delayed / total
  )

airlines_city

pivot_wider() spreads the status values back into an on time column and a delayed column. Then mutate() adds the total number of flights and the delay rate (delayed ÷ total). For example, Alaska in Los Angeles is 62 ÷ 559 = 11.1%.

Analysis

Overall delay rate by airline

overall <- airlines_city |>
  group_by(airline) |>
  summarise(
    on_time = sum(on_time),
    delayed = sum(delayed),
    total = sum(total)
  ) |>
  mutate(delay_rate = delayed / total)

overall |>
  mutate(delay_rate = percent(delay_rate, accuracy = 0.1)) |>
  kable(
    col.names = c("Airline", "On time", "Delayed", "Total flights", "Delay rate"),
    format.big.mark = ",",
    align = c("l", "r", "r", "r", "r"),
    caption = "Overall delay rate by airline"
  )
Overall delay rate by airline
Airline On time Delayed Total flights Delay rate
ALASKA 3274 501 3775 13.3%
AM WEST 6438 787 7225 10.9%

This adds up each airline’s delayed and total flights across all five cities, then divides. Because it works from totals rather than averaging the five city percentages, cities with more flights count more toward the overall rate.

ggplot(overall, aes(x = airline, y = delay_rate, fill = airline)) +
  geom_col(width = 0.55) +
  geom_text(aes(label = percent(delay_rate, accuracy = 0.1)), vjust = -0.5) +
  scale_y_continuous(labels = percent, limits = c(0, 0.16)) +
  scale_fill_manual(values = airline_colors) +
  labs(title = "Overall delay rate", x = NULL, y = "Share of flights delayed") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "none")
Figure 1

Looking at all flights together, AM WEST comes out ahead. 10.9% of AM WEST’s flights were delayed, compared to 13.3% for ALASKA. AM WEST also flew almost twice as many flights (7,225 vs. 3,775).

Delay rate by airline and city

airlines_city |>
  select(city, airline, delay_rate) |>
  pivot_wider(names_from = airline, values_from = delay_rate) |>
  mutate(
    # compare first, while the rates are still numbers...
    `Better airline` = if_else(ALASKA < `AM WEST`, "ALASKA", "AM WEST"),
    # ...then format them as text for display
    across(c(ALASKA, `AM WEST`), \(x) percent(x, accuracy = 0.1))
  ) |>
  kable(
    col.names = c("City", "ALASKA delay rate", "AM WEST delay rate", "Lower delay rate"),
    align = c("l", "r", "r", "l"),
    caption = "Delay rate by city"
  )
Delay rate by city
City ALASKA delay rate AM WEST delay rate Lower delay rate
Los Angeles 11.1% 14.4% ALASKA
Phoenix 5.2% 7.9% ALASKA
San Diego 8.6% 14.5% ALASKA
San Francisco 16.9% 28.7% ALASKA
Seattle 14.2% 23.3% ALASKA

The comparison runs before the formatting on purpose. Once percent() turns 0.086 into the text “8.6%”, R compares it alphabetically instead of numerically, and would rank “14.5%” below “8.6%”.

ggplot(airlines_city, aes(x = city, y = delay_rate, fill = airline)) +
  geom_col(position = position_dodge(width = 0.75), width = 0.7) +
  geom_text(
    aes(label = percent(delay_rate, accuracy = 0.1)),
    position = position_dodge(width = 0.75),
    vjust = -0.4,
    size = 3.2
  ) +
  scale_y_continuous(labels = percent, limits = c(0, 0.32)) +
  scale_fill_manual(values = airline_colors) +
  labs(
    title = "Delay rate by destination city",
    x = NULL, y = "Share of flights delayed", fill = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")
Figure 2

City by city, the result flips: ALASKA has the lower delay rate in all five cities. The gap is biggest in San Francisco and Seattle, but ALASKA comes out ahead even in Phoenix, where both airlines do well.

The discrepancy

The two comparisons contradict each other. Overall, AM WEST looks better, but in every single city, ALASKA has the lower delay rate. The explanation is in where each airline flies.

airlines_city |>
  group_by(airline) |>
  mutate(share_of_flights = total / sum(total)) |>
  ungroup() |>
  select(airline, city, total, share_of_flights, delay_rate) |>
  arrange(airline, desc(share_of_flights)) |>
  mutate(
    share_of_flights = percent(share_of_flights, accuracy = 1),
    delay_rate = percent(delay_rate, accuracy = 0.1)
  ) |>
  kable(
    col.names = c("Airline", "City", "Flights", "Share of airline's flights", "Delay rate"),
    format.big.mark = ",",
    align = c("l", "l", "r", "r", "r"),
    caption = "Where each airline's flights go"
  )
Where each airline’s flights go
Airline City Flights Share of airline’s flights Delay rate
ALASKA Seattle 2146 57% 14.2%
ALASKA San Francisco 605 16% 16.9%
ALASKA Los Angeles 559 15% 11.1%
ALASKA Phoenix 233 6% 5.2%
ALASKA San Diego 232 6% 8.6%
AM WEST Phoenix 5255 73% 7.9%
AM WEST Los Angeles 811 11% 14.4%
AM WEST San Francisco 449 6% 28.7%
AM WEST San Diego 448 6% 14.5%
AM WEST Seattle 262 4% 23.3%
  • About 73% of AM WEST’s flights go to Phoenix, which has the lowest delay rate of any city for both airlines (AM WEST is at 7.9% there).
  • About 57% of ALASKA’s flights go to Seattle, where delays are much more common (14.2% for ALASKA).
city_fills <- setNames(colorRampPalette(c("#fbe1d2", "#9b2a17"))(5), city_order)

mix |>
  mutate(city = factor(city, levels = city_order)) |>
  ggplot(aes(x = share, y = airline, fill = city)) +
  geom_col(width = 0.65, color = "white", linewidth = 0.8,
           position = position_stack(reverse = TRUE)) +
  geom_text(
    aes(
      label = if_else(share >= 0.1, paste0(city, "\n", percent(share, accuracy = 1)), ""),
      color = if_else(as.integer(city) >= 4, "white", "#262b33")
    ),
    position = position_stack(vjust = 0.5, reverse = TRUE),
    size = 3.3, lineheight = 0.9
  ) +
  scale_fill_manual(values = city_fills) +
  scale_color_identity() +
  scale_x_continuous(labels = percent, expand = c(0, 0)) +
  labs(
    title = "Where each airline's flights go",
    subtitle = "Cities shaded from fewest delays (light) to most delays (dark)",
    x = "Share of the airline's flights", y = NULL, fill = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", panel.grid = element_blank(),
        axis.text.y = element_text(face = "bold"),
        plot.margin = margin(5, 18, 5, 5))
Figure 3

Since the overall rate is weighted by flight volume, each airline’s overall number ends up close to the rate of the city it flies to most. AM WEST’s many flights into Phoenix pull its overall delay rate down, and ALASKA’s heavy schedule into Seattle pulls its overall rate up. The overall comparison ends up measuring where each airline flies more than how well it flies.

As a final check, here’s what AM WEST’s overall delay rate would be if it flew ALASKA’s exact schedule (the same number of flights to each city) with its own delay rate in each city:

aw_rates <- airlines_city |>
  filter(airline == "AM WEST") |>
  select(city, aw_rate = delay_rate)

what_if <- airlines_city |>
  filter(airline == "ALASKA") |>
  left_join(aw_rates, by = "city") |>
  summarise(aw_on_alaska_routes = sum(total * aw_rate) / sum(total))

what_if

On ALASKA’s routes, AM WEST’s overall delay rate would be about 21.4%, well above ALASKA’s actual 13.3%. AM WEST’s overall advantage comes from flying to easier places, not from flying better.

This is an example of Simpson’s Paradox: a pattern that holds in every group reverses when the groups are combined. It happens here because the airlines split their flights across the cities in very different proportions, and the cities themselves have very different delay rates. The city-by-city comparison is the fairer one, since it compares the airlines under the same conditions. On every one of these routes, ALASKA is the better bet.

Conclusions

  • Overall, AM WEST had the lower delay rate (10.9% vs. 13.3%).
  • City by city, ALASKA had the lower delay rate in all five cities.
  • The flip comes from route mix: AM WEST flies mostly into Phoenix, where delays are rare, and ALASKA flies mostly into Seattle, where they’re common.

The takeaway is to be careful with blended totals when the groups being combined are different sizes. The same thing comes up at work when comparing sales channels or products with very different volumes: one overall rate can hide what’s actually happening in each segment.

Data source

Fung, K. (2013). Numbersense: How to Use Big Data to Your Advantage. McGraw Hill. Recreated as airline-delays.csv in my GitHub repo.

AI Disclosure and Citation

Claude (Opus 5.5, accessed through claude.ai) helped with this assignment, including recreating the CSV in the original layout, writing the code (the fill() and pivot steps, tables, and charts), drafting and revising the write-up and the Simpson’s Paradox explanation, and uploading the CSV to my GitHub repo.

Anthropic. (2026). Claude (Opus 5.5) [Large language model]. https://claude.ai/. Accessed October 3–4, 2026.