Predicting Flight Delays
Logistic regression on over one million U.S. flights from January and February 2024. The model uses origin airport, departure time, day of week and distance, and is tested on a held-out 20%. JFK, LaGuardia and Newark add the most delay risk.
Code · 192 lines
# Predicting Flight Delays: A Logistic Regression Approach
#
# Question: can we predict the likelihood of a flight delay from its origin
# airport, scheduled departure time, day of the week and flight distance?
# Data: over one million U.S. flights, January-February 2024 (BTS on-time data).
# A flight counts as "delayed" when wheels-off is 15+ minutes after scheduled departure.
library(tidyverse)
library(pROC)
library(caret)
library(broom)
# 1.0 Load and profile ----
flight_data_raw <- read_csv("flight_data_2024.csv")
glimpse(flight_data_raw)
sum(is.na(flight_data_raw))
(sum(is.na(flight_data_raw)) / (nrow(flight_data_raw) * ncol(flight_data_raw))) * 100
# 2.0 Clean ----
# Drop cancelled flights and delays caused by weather or a late inbound aircraft,
# so the model only sees delays the airport and schedule could explain.
airplane_data <- flight_data_raw %>%
filter(cancelled == 0,
weather_delay == 0,
late_aircraft_delay == 0,
!is.na(dep_time), # model uses this
!is.na(taxi_out) # model uses this
) %>%
select(-air_time, -taxi_in, -late_aircraft_delay, -weather_delay,
-cancelled, -origin_city_name, -origin_state_nm, -month,
-day_of_month, -wheels_on, -year)
removed_rows <- nrow(flight_data_raw) - nrow(airplane_data)
paste(removed_rows, "flights removed.")
# 3.0 Build the target ----
# HHMM integers -> real datetimes; a wheels-off time earlier than departure
# means the flight crossed midnight, so add a day.
airplane_data <- airplane_data %>%
mutate(
dep_time_str = sprintf("%02d:%02d", dep_time %/% 100, dep_time %% 100),
wheels_off_str = sprintf("%02d:%02d", wheels_off %/% 100, wheels_off %% 100),
dep_datetime = mdy_hm(paste(fl_date, dep_time_str)),
wheels_off_datetime = mdy_hm(paste(fl_date, wheels_off_str)),
wheels_off_datetime = if_else(wheels_off_datetime < dep_datetime,
wheels_off_datetime + days(1), wheels_off_datetime),
delay_time = as.numeric(difftime(wheels_off_datetime, dep_datetime, units = "mins")),
delayed = ifelse(delay_time >= 15, 1, 0),
day = factor(day_of_week, levels = 1:7,
labels = c("Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"))
)
# 4.0 Explore ----
delay_summary <- airplane_data %>%
count(delayed) %>%
mutate(prop = n / sum(n))
ggplot(delay_summary, aes(x = "", y = prop, fill = factor(delayed))) +
geom_bar(stat = "identity", width = 1) +
coord_polar("y", start = 0) +
geom_text(aes(label = scales::percent(prop)), color = "white",
position = position_stack(vjust = 0.5)) +
scale_fill_manual(values = c("0" = "darkgreen", "1" = "darkred"),
labels = c("On-Time", "Delayed")) +
labs(title = "More Than Half of All Flights Were Delayed",
subtitle = "On-Time vs. Delayed Flights (Jan-Feb 2024)",
fill = "Status", caption = "Source: flight_data_2024.csv") +
theme_void(base_size = 14)
# Delay rate by origin airport (airports with 100+ flights only)
delay_by_origin <- airplane_data %>%
group_by(origin) %>%
summarise(total_flights = n(),
delayed_flights = sum(delayed),
delay_percentage = (delayed_flights / total_flights) * 100) %>%
arrange(desc(delay_percentage)) %>%
filter(total_flights > 100)
plot_data_origin <- bind_rows(head(delay_by_origin, 5), tail(delay_by_origin, 5)) %>%
mutate(origin = fct_reorder(origin, delay_percentage))
ggplot(plot_data_origin, aes(x = origin, y = delay_percentage, fill = delay_percentage)) +
geom_col(width = 0.8) +
geom_text(aes(label = paste0(round(delay_percentage, 1), "%")),
hjust = 1.07, size = 3, color = "white") +
labs(title = "Flight Delays Vary Significantly by Origin Airport",
subtitle = "Top 5 and Bottom 5 Airports by Delay Percentage (airports with >100 flights)",
caption = "Source: flight_data_2024.csv",
x = "Origin Airport", y = "Percentage of Flights Delayed") +
scale_fill_gradient(low = "darkgreen", high = "darkred") +
coord_flip() +
theme_minimal() +
theme(plot.title = element_text(hjust = 0.5, face = "bold", size = 18),
plot.subtitle = element_text(hjust = 0.5, size = 12),
axis.title = element_text(size = 12),
axis.text = element_text(size = 15),
legend.position = "none")
# Best and worst day of the week
delay_by_day <- airplane_data %>%
group_by(day) %>%
summarise(pct_delay = mean(delayed) * 100)
max_delay_by_day <- delay_by_day %>%
slice_max(pct_delay, n = 1) %>%
mutate(type = "Highest Delay Day")
min_delay_by_day <- delay_by_day %>%
slice_min(pct_delay, n = 1) %>%
mutate(type = "Lowest Delay Day")
delay_day_summary <- rbind(max_delay_by_day, min_delay_by_day)
delay_day_summary <- delay_day_summary[, c("type", "day", "pct_delay")]
colnames(delay_day_summary) <- c("Type", "Day", "Percent_Delayed")
delay_day_summary
# 5.0 Model ----
# Keep the 50 busiest airports as their own levels; group the rest as "Other"
top_50_airports <- airplane_data %>%
count(origin, sort = TRUE) %>%
slice_max(n, n = 50) %>%
pull(origin)
model_data <- airplane_data %>%
mutate(origin = ifelse(origin %in% top_50_airports, origin, "Other")) %>%
select(delayed, origin, dep_time, distance, day) %>%
na.omit()
model_data$origin <- as.factor(model_data$origin)
model_data$day <- as.factor(model_data$day)
model_data$day <- relevel(model_data$day, ref = "Friday") # Friday = baseline
set.seed(123)
train_index <- createDataPartition(model_data$delayed, p = 0.8, list = FALSE)
train_data <- model_data[train_index, ]
test_data <- model_data[-train_index, ]
logit_model <- glm(delayed ~ ., data = train_data, family = "binomial")
coefs <- coef(logit_model)
sort(abs(coefs), decreasing = TRUE)[1:5] # strongest effects
sort(abs(coefs), decreasing = FALSE)[1:5] # weakest effects
# 6.0 Evaluate on the held-out 20% ----
predictions <- predict(logit_model, test_data, type = "response")
predicted_classes <- ifelse(predictions > 0.5, 1, 0)
conf_matrix <- confusionMatrix(factor(predicted_classes), factor(test_data$delayed))
print(conf_matrix)
roc_obj <- roc(test_data$delayed, predictions)
auc_value <- auc(roc_obj)
plot_data <- data.frame(FPR = 1 - roc_obj$specificities, TPR = roc_obj$sensitivities)
ggplot(plot_data, aes(x = FPR, y = TPR)) +
geom_line(color = "black", size = 1) +
geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "darkred") +
labs(title = paste0("ROC Curve (AUC = ", round(auc_value, 2), ")"),
x = "Specificity (False Positive Rate)",
y = "Sensitivity (True Positive Rate)",
caption = "Source: flight_data_2024.csv") +
theme_minimal(base_size = 14)
# 7.0 Which factors move the odds of a delay? ----
model_summary <- tidy(logit_model) %>%
filter(term != "(Intercept)") %>%
mutate(odds_ratio = exp(estimate),
variable_group = case_when(
grepl("origin", term) ~ "Airport (Origin)",
grepl("day", term) ~ "Day of Week",
TRUE ~ "Numeric"))
top_pos <- model_summary %>% arrange(desc(estimate)) %>% slice(1:5)
ggplot(top_pos, aes(x = reorder(term, estimate), y = estimate)) +
geom_col(fill = "darkred") +
coord_flip() +
labs(title = "Top 5 Factors Increasing Delay Odds",
x = "Predictor", y = "Log-Odds Effect",
caption = "Source: flight_data_2024.csv") +
theme_minimal(base_size = 14)
top_neg <- model_summary %>% arrange(estimate) %>% slice(1:5)
ggplot(top_neg, aes(x = reorder(term, estimate), y = estimate)) +
geom_col(fill = "darkgreen") +
coord_flip() +
labs(title = "Top 5 Factors Decreasing Delay Odds",
x = "Predictor", y = "Log-Odds Effect",
caption = "Source: flight_data_2024.csv") +
theme_minimal(base_size = 14)













