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")Assignment 5A: Airline Delays
DATA 607 · Week 5 · Tidying and Transforming Data
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
- Recreate the table as a CSV, keeping the original’s messy layout.
- Load it into R and fill in the blank cells with code.
- Reshape it from wide to long, then partway back to wide to calculate delay rates.
- Compare the airlines overall, then city by city.
- 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.
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)
}
)
rawThis 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_widefilter(!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.integerturns 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_longpivot_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_citypivot_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"
)| 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")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"
)| 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")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"
)| 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))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_ifOn 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.