Airline Satisfaction Analysis

Author

Tyler Johnston

Published

May 7, 2026

Setup Chunk

library(tidyverse)
library(tidymodels)

# Missingness packages
library(mice)
library(naniar, exclude = "n_complete")

source("https://github.com/jjcurtin/lab_support/blob/main/fun_eda.R?raw=true")
source("https://github.com/jjcurtin/lab_support/blob/main/fun_plots.R?raw=true")

options(conflicts.policy = "depends.ok")

path_data <- "Psych_752/Application Exams/Data"

Loading necessary packages, setting conflict policy, and defining a path.

EDA

Data Loading & Cleaning

data <- read_csv(here::here(path_data, "airline_passenger_satisfaction.csv")) |> 
  janitor::clean_names() |> 
  glimpse()
Rows: 600 Columns: 24
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr  (5): Gender, customer_type, type_of_travel, customer_class, satisfaction
dbl (19): id, age, flight_distance, inflight_wifi_service, departure_arrival...

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
Rows: 600
Columns: 24
$ id                                <dbl> 94031, 81130, 1431, 42411, 33339, 67…
$ gender                            <chr> "Male", "Male", "Female", NA, "Femal…
$ customer_type                     <chr> "Loyal Customer", "Loyal Customer", …
$ age                               <dbl> 32, 18, 43, 53, 25, 58, 51, 42, 44, …
$ type_of_travel                    <chr> "Business travel", "Business travel"…
$ customer_class                    <chr> "Business", "Business", "Business", …
$ flight_distance                   <dbl> 1389, 3456, 2899, 2694, 2419, 421, 3…
$ inflight_wifi_service             <dbl> 2, 2, 0, 1, 5, 0, 5, 2, 1, 1, 2, 5, …
$ departure_arrival_time_convenient <dbl> 2, 2, 0, 1, 5, 3, 5, 2, 1, 1, 2, 5, …
$ ease_of_online_booking            <dbl> 2, 2, 0, 1, 5, 0, 5, 3, 1, 1, 2, 5, …
$ gate_location                     <dbl> 2, 2, 5, 1, 5, 3, 5, 2, 1, 1, 2, 5, …
$ food_and_drink                    <dbl> 5, 4, 4, 3, 5, 3, 4, 2, 2, 5, 5, 3, …
$ online_boarding                   <dbl> 5, 4, 5, 5, 5, 3, 5, 4, 5, 5, 5, 5, …
$ seat_comfort                      <dbl> 5, 4, 5, 4, 4, 3, 4, 4, 4, 5, 4, 5, …
$ inflight_entertainment            <dbl> 5, 4, 2, 4, 5, 4, 4, 4, 5, 5, 4, 5, …
$ onboard_service                   <dbl> 4, 4, 2, 4, 3, 4, 4, 4, 5, 3, 4, 5, …
$ leg_room_service                  <dbl> 5, 2, 2, 4, 3, 0, 4, 4, 5, 1, 4, 5, …
$ baggage_handling                  <dbl> 5, 1, 2, 4, 5, 4, 4, 4, 5, 3, 4, 5, …
$ checkin_service                   <dbl> 5, 3, 3, 3, 3, 3, 3, 4, 3, 2, 5, 5, …
$ inflight_service                  <dbl> 5, 3, 2, 4, 4, 4, 4, 4, 5, 4, 4, 5, …
$ cleanliness                       <dbl> 5, 4, 4, 3, 5, 3, 5, 3, 4, 5, 5, 5, …
$ departure_delay_in_minutes        <dbl> 67, 0, 6, 0, 3, 0, 0, 50, 8, 21, 21,…
$ arrival_delay_in_minutes          <dbl> 79, 0, 0, 0, 2, 0, 0, 42, 5, 4, 7, 1…
$ satisfaction                      <chr> "satisfied", "satisfied", "satisfied…
data_clean <- data |>
  mutate(
    id = as.character(id)) |>
  mutate(
    gender = factor(gender,
                    levels = c("Male", "Female"),
                    labels = c("0", "1")),
    customer_type = factor(customer_type,
                           levels = c("disloyal Customer", "Loyal Customer"),
                           labels = c("0", "1")),
    type_of_travel = factor(type_of_travel,
                            levels = c("Business travel", "Personal Travel"),
                            labels = c("0", "1"))) |>
  mutate(
    customer_class = factor(customer_class,
                            levels = c("Eco", "Eco Plus", "Business"),
                            ordered = TRUE),
    satisfaction = factor(satisfaction,
                          levels = c("neutral or dissatisfied", "satisfied"),
                          labels = c("0", "1"))) |>
  glimpse()
Rows: 600
Columns: 24
$ id                                <chr> "94031", "81130", "1431", "42411", "…
$ gender                            <fct> 0, 0, 1, NA, 1, 1, 0, NA, 0, 0, 1, 1…
$ customer_type                     <fct> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, …
$ age                               <dbl> 32, 18, 43, 53, 25, 58, 51, 42, 44, …
$ type_of_travel                    <fct> 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, …
$ customer_class                    <ord> Business, Business, Business, Busine…
$ flight_distance                   <dbl> 1389, 3456, 2899, 2694, 2419, 421, 3…
$ inflight_wifi_service             <dbl> 2, 2, 0, 1, 5, 0, 5, 2, 1, 1, 2, 5, …
$ departure_arrival_time_convenient <dbl> 2, 2, 0, 1, 5, 3, 5, 2, 1, 1, 2, 5, …
$ ease_of_online_booking            <dbl> 2, 2, 0, 1, 5, 0, 5, 3, 1, 1, 2, 5, …
$ gate_location                     <dbl> 2, 2, 5, 1, 5, 3, 5, 2, 1, 1, 2, 5, …
$ food_and_drink                    <dbl> 5, 4, 4, 3, 5, 3, 4, 2, 2, 5, 5, 3, …
$ online_boarding                   <dbl> 5, 4, 5, 5, 5, 3, 5, 4, 5, 5, 5, 5, …
$ seat_comfort                      <dbl> 5, 4, 5, 4, 4, 3, 4, 4, 4, 5, 4, 5, …
$ inflight_entertainment            <dbl> 5, 4, 2, 4, 5, 4, 4, 4, 5, 5, 4, 5, …
$ onboard_service                   <dbl> 4, 4, 2, 4, 3, 4, 4, 4, 5, 3, 4, 5, …
$ leg_room_service                  <dbl> 5, 2, 2, 4, 3, 0, 4, 4, 5, 1, 4, 5, …
$ baggage_handling                  <dbl> 5, 1, 2, 4, 5, 4, 4, 4, 5, 3, 4, 5, …
$ checkin_service                   <dbl> 5, 3, 3, 3, 3, 3, 3, 4, 3, 2, 5, 5, …
$ inflight_service                  <dbl> 5, 3, 2, 4, 4, 4, 4, 4, 5, 4, 4, 5, …
$ cleanliness                       <dbl> 5, 4, 4, 3, 5, 3, 5, 3, 4, 5, 5, 5, …
$ departure_delay_in_minutes        <dbl> 67, 0, 6, 0, 3, 0, 0, 50, 8, 21, 21,…
$ arrival_delay_in_minutes          <dbl> 79, 0, 0, 0, 2, 0, 0, 42, 5, 4, 7, 1…
$ satisfaction                      <fct> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, …
data_nona <- data_clean |> 
  drop_na()

After loading in the data and cleaning the names, we needed to clean the dataset itself.

1) All binary predictors were coded as labeled factors (0 and 1) to keep modeling smooth. 2) Customer class was treated as an ordered factor to preserve meaningful ordering but without assuming any specific spacing between levels. 3) All Likert-type scale variables (0-5) were left as numerics to allow for flexible modeling. 4) Outcome variable (satisfaction) was coded 0 (not satisfied/neutral) and 1 (satisfied).

Then I made a new dataset from this version that undergoes casewise deletion of missing values/NAs, this left us with just over 2/3rds of the cases since many had at least 1 missing value. We’ll retain both versions for now.

Additional EDA

data_clean |> 
  skim_some()
Data summary
Name data_clean
Number of rows 600
Number of columns 24
_______________________
Column type frequency:
character 1
factor 5
numeric 18
________________________
Group variables None

Variable type: character

skim_variable n_missing complete_rate min max empty n_unique whitespace
id 0 1 3 6 0 600 0

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
gender 117 0.80 FALSE 2 1: 262, 0: 221
customer_type 0 1.00 FALSE 2 1: 496, 0: 104
type_of_travel 0 1.00 FALSE 2 0: 480, 1: 120
customer_class 5 0.99 TRUE 3 Bus: 359, Eco: 197, Eco: 39
satisfaction 0 1.00 FALSE 2 1: 400, 0: 200

Variable type: numeric

skim_variable n_missing complete_rate p0 p100
age 0 1.00 8 85
flight_distance 0 1.00 56 3989
inflight_wifi_service 0 1.00 0 5
departure_arrival_time_convenient 0 1.00 0 5
ease_of_online_booking 0 1.00 0 5
gate_location 0 1.00 1 5
food_and_drink 0 1.00 1 5
online_boarding 41 0.93 0 5
seat_comfort 0 1.00 1 5
inflight_entertainment 0 1.00 1 5
onboard_service 22 0.96 1 5
leg_room_service 0 1.00 0 5
baggage_handling 0 1.00 1 5
checkin_service 0 1.00 1 5
inflight_service 0 1.00 1 5
cleanliness 0 1.00 1 5
departure_delay_in_minutes 7 0.99 0 360
arrival_delay_in_minutes 3 1.00 0 409
# Quite a bit of missingness (mostly from gender), let's see if we can determine type of missingness and potentially move forward without simply excluding all cases with any missing.

d <- data_clean |> select(gender, customer_class, customer_type, age, flight_distance, online_boarding, onboard_service, departure_delay_in_minutes, arrival_delay_in_minutes)

skimr::skim(d)
Data summary
Name d
Number of rows 600
Number of columns 9
_______________________
Column type frequency:
factor 3
numeric 6
________________________
Group variables None

Variable type: factor

skim_variable n_missing complete_rate ordered n_unique top_counts
gender 117 0.80 FALSE 2 1: 262, 0: 221
customer_class 5 0.99 TRUE 3 Bus: 359, Eco: 197, Eco: 39
customer_type 0 1.00 FALSE 2 1: 496, 0: 104

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
age 0 1.00 39.97 14.08 8 28 40.5 51.0 85 ▃▆▇▃▁
flight_distance 0 1.00 1318.55 1061.79 56 431 974.5 1998.5 3989 ▇▅▂▂▂
online_boarding 41 0.93 3.56 1.33 0 3 4.0 5.0 5 ▂▃▃▇▆
onboard_service 22 0.96 3.51 1.27 1 3 4.0 5.0 5 ▂▃▆▇▇
departure_delay_in_minutes 7 0.99 13.82 37.28 0 0 0.0 9.0 360 ▇▁▁▁▁
arrival_delay_in_minutes 3 1.00 13.51 38.21 0 0 0.0 8.0 409 ▇▁▁▁▁
gg_miss_upset(d)
Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
ℹ Please use tidy evaluation idioms with `aes()`.
ℹ See also `vignette("ggplot2-in-packages")` for more information.
ℹ The deprecated feature was likely used in the UpSetR package.
  Please report the issue to the authors.
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.
ℹ The deprecated feature was likely used in the UpSetR package.
  Please report the issue to the authors.
Warning: The `size` argument of `element_line()` is deprecated as of ggplot2 3.4.0.
ℹ Please use the `linewidth` argument instead.
ℹ The deprecated feature was likely used in the UpSetR package.
  Please report the issue to the authors.

Most missingness comes from gender, 105 of the 181 variables that are missing anything are only missing gender. 32 are missing only online boarding, 18 are missing only onboard_service, and very few (13) participants had missingness on more than 1 variable, with none having more than 2. Would be great to be able to retain gender by imputing realistic “values” using correlated variables, then we could compare performance of our eventual models trained on each version and see if imputation helps our model performance.

Missingness EDA continued

d |> 
  mutate(across(where(is.factor), ~ as.numeric(.))) |> 
  cor(use = "pairwise.complete.obs") |> 
  corrplot::corrplot.mixed()

Seems gender is not associated with any variables, so we’ll probably just have to drop all cases missing gender. Other variables don’t have much missingness so won’t try to impute for those either. We’ll use data_nona from here on out as we also have no reason to assume any coding errors for the NAs.

Univariate EDA & Class Balance

data_nona |> 
  select(where(is.numeric)) |> 
  summary()
      age        flight_distance  inflight_wifi_service
 Min.   : 8.00   Min.   :  67.0   Min.   :0.000        
 1st Qu.:29.00   1st Qu.: 417.5   1st Qu.:2.000        
 Median :41.00   Median : 946.0   Median :3.000        
 Mean   :40.05   Mean   :1342.1   Mean   :2.907        
 3rd Qu.:51.00   3rd Qu.:2111.0   3rd Qu.:4.000        
 Max.   :76.00   Max.   :3989.0   Max.   :5.000        
 departure_arrival_time_convenient ease_of_online_booking gate_location 
 Min.   :0.000                     Min.   :0.000          Min.   :1.00  
 1st Qu.:2.000                     1st Qu.:2.000          1st Qu.:2.00  
 Median :3.000                     Median :3.000          Median :3.00  
 Mean   :3.002                     Mean   :2.866          Mean   :3.01  
 3rd Qu.:4.000                     3rd Qu.:4.000          3rd Qu.:4.00  
 Max.   :5.000                     Max.   :5.000          Max.   :5.00  
 food_and_drink  online_boarding  seat_comfort   inflight_entertainment
 Min.   :1.000   Min.   :0.00    Min.   :1.000   Min.   :1.000         
 1st Qu.:2.000   1st Qu.:3.00    1st Qu.:3.000   1st Qu.:3.000         
 Median :3.000   Median :4.00    Median :4.000   Median :4.000         
 Mean   :3.301   Mean   :3.58    Mean   :3.644   Mean   :3.578         
 3rd Qu.:4.000   3rd Qu.:5.00    3rd Qu.:5.000   3rd Qu.:5.000         
 Max.   :5.000   Max.   :5.00    Max.   :5.000   Max.   :5.000         
 onboard_service leg_room_service baggage_handling checkin_service
 Min.   :1.000   Min.   :0.000    Min.   :1.000    Min.   :1.000  
 1st Qu.:3.000   1st Qu.:3.000    1st Qu.:3.000    1st Qu.:3.000  
 Median :4.000   Median :4.000    Median :4.000    Median :3.000  
 Mean   :3.547   Mean   :3.585    Mean   :3.768    Mean   :3.332  
 3rd Qu.:5.000   3rd Qu.:5.000    3rd Qu.:5.000    3rd Qu.:4.000  
 Max.   :5.000   Max.   :5.000    Max.   :5.000    Max.   :5.000  
 inflight_service  cleanliness    departure_delay_in_minutes
 Min.   :1.000    Min.   :1.000   Min.   :  0.00            
 1st Qu.:3.000    1st Qu.:3.000   1st Qu.:  0.00            
 Median :4.000    Median :3.000   Median :  0.00            
 Mean   :3.831    Mean   :3.322   Mean   : 13.97            
 3rd Qu.:5.000    3rd Qu.:4.000   3rd Qu.:  8.00            
 Max.   :5.000    Max.   :5.000   Max.   :360.00            
 arrival_delay_in_minutes
 Min.   :  0.00          
 1st Qu.:  0.00          
 Median :  0.00          
 Mean   : 13.45          
 3rd Qu.:  6.50          
 Max.   :409.00          
data_nona |> 
  pivot_longer(where(is.numeric)) |> 
  ggplot(aes(x = value)) +
  geom_histogram(bins = 30) +
  facet_wrap(~ name, scales = "free")

data_nona |> count(satisfaction)
# A tibble: 2 × 2
  satisfaction     n
  <fct>        <int>
1 0              138
2 1              281

Ranges all look reasonable, we are seeing that we do have imbalanced classes as 67% of respondents were satisfied, so we’ll want to use something besides raw accuracy as our performance metric (F1, balanced accuracy, kappa, or auROC all considered at this point). In our specific context here, we are looking to predict passenger satisfaction. As such, we may want to minimize false positives (we predict satisfied for customers that aren’t satisfied) as we might want to reach out to potentially unsatisfied customers to improve customer relations. We will later consider increasing our classification threshold so that we miss fewer unsatisfied customers, focusing on a higher specificity. We can simply use auROC to tune, but then evaluate based on auROC and specificity. Ideally we’d speak with the rest of the team to determine beforehand what our optimizing and satisficing metrics are, along with what value to use for our satisficing metric, but unfortunately everyone else is out of office we are unable to predetermine these.

Bivariate EDA

data_nona |>
  select(where(is.numeric)) |>
  cor(use = "pairwise.complete.obs") |> 
  corrplot::corrplot.mixed()

data_nona <- data_nona |> 
  mutate(delay_max = pmax(departure_delay_in_minutes, arrival_delay_in_minutes))

Departure delay and arrival delay have correlation of .98, we’re going to make a new variable called delay_max that simply takes the maximum delay value of either departure or arrival for each participant. This seems preferable to taking the mean of the two as we expect that satisfaction is likely to be largely driven by the worst delay-related experience. Keeping both provides very little additional information and would mean we have high multicollinearity.

Fitting & Evaluating models

Splitting data

set.seed(123)

split <- initial_split(data_nona, prop = 0.8, strata = satisfaction)

train_data <- training(split)
test_data  <- testing(split)

We’ll want a performance estimate of our model on held-out data, so we split the data here, will only touch the training data. Since we have 419 observations it seems setting aside 20% as held-out will be just fine here. Making our training set any smaller may inflate bias and making the held-out set smaller might give is a high variance estimate, to combat this we will additionally perform repeated kfold cross-validation on the training set, using that final held-out 20% as a pure test set/final check. We considered a single validation or nested cross-validation approach as well, but due to high variance and unnecessary complexity we decided against them. We’ll do 5 folds as we don’t want to do too many since having the sample sizes within each split be any smaller may cause variance problems, and we’ll do 10 repeats for additional variance reduction, but not any more for computational cost/time considerations.

Cross-validation setup/folds

folds <- vfold_cv(train_data, v = 5, repeats = 10, strata = satisfaction)

Recipe

rec <- recipe(satisfaction ~ ., data = train_data) |>
  step_rm(c("id", "departure_delay_in_minutes", "arrival_delay_in_minutes")) |>
  step_dummy(all_nominal_predictors()) |>
  step_zv(all_predictors()) |>
  step_normalize(all_numeric_predictors())

Here we make our recipe for our model. We’ll be training on the held-in training data, but first we’ve removed id (no predictive information) and also both highly redundant delay metrics as we have combined them into a single feature. We then dummy coded numerics so they can be used and removed all zero variance predictors as they also contain no useful information. Finally we normalized the numerics because we’ll be tuning a glmnet model in which large coefficients are penalized, so they all must be on the same scale to avoid unfairly penalizing coefficients for flight distance, age, etc.

Model Specification & Workflow

m1_log_reg <- logistic_reg(
  penalty = tune(),
  mixture = tune()) |>
  set_engine("glmnet")

wf <- workflow() |>
  add_recipe(rec) |>
  add_model(m1_log_reg)

We’re choosing to tune a glmnet as it gives us a simple, interpretable, and semi-flexible classification model (flexible in terms of regularization and potentially feature selection, though still assumes linear DGP). If this linear classsification baseline performs poorly, we’ll likely transition to a nonparametric approach. We then combine the preprocessing recipe and model specification into a workflow so that the preprocessing steps are concisely and consistently applied within our cross-validation folds.

Hyperparameter Grid

grid <- grid_regular(
  penalty(range = c(-3, -1)),
  mixture(),
  levels = 5)

We define a tuning grid for the penalty and mixture hyperparameters. We started with a more coarse grid before refining our search for optimal penalty values, we find that small to moderate penalties are ideal. This left us with -3 to -1 (log scale) as our penalty consideration range. The mixture simply tries different blends of L2 and L1 regularization, along with pure L2 and pure L1.

Tuning

# About 2 minutes
tuned_results <- tune_grid(
  wf,
  resamples = folds,
  grid = grid,
  metrics = metric_set(roc_auc, specificity, accuracy))

Results

autoplot(tuned_results)

tuned_results |>
  collect_metrics() |>
  select(penalty, mixture, .metric, mean) |>
  pivot_wider(names_from = .metric, values_from = mean) |>
  arrange(desc(roc_auc)) |>
  slice_head(n = 20)
# A tibble: 20 × 5
   penalty mixture accuracy roc_auc specificity
     <dbl>   <dbl>    <dbl>   <dbl>       <dbl>
 1 0.0316     1       0.759   0.867       0.880
 2 0.0316     0.75    0.764   0.866       0.877
 3 0.1        0.5     0.750   0.865       0.902
 4 0.0316     0.5     0.770   0.864       0.875
 5 0.1        0.25    0.751   0.863       0.883
 6 0.0316     0.25    0.780   0.861       0.871
 7 0.1        0.75    0.754   0.861       0.922
 8 0.01       1       0.785   0.861       0.872
 9 0.1        1       0.756   0.861       0.940
10 0.01       0.75    0.786   0.860       0.871
11 0.01       0.5     0.788   0.858       0.869
12 0.0316     0       0.785   0.858       0.867
13 0.1        0       0.777   0.857       0.874
14 0.01       0.25    0.791   0.857       0.867
15 0.001      0       0.785   0.857       0.867
16 0.00316    0       0.785   0.857       0.867
17 0.01       0       0.785   0.857       0.867
18 0.00316    1       0.790   0.855       0.866
19 0.00316    0.75    0.788   0.854       0.864
20 0.00316    0.5     0.788   0.854       0.863

Here we have our results, we’ve printed the top 20 in regard to auroc because we are also very interesting in specificity. Looking through these we see relatively stable auroc in that there aren’t big gaps in performance, but there are quite big gaps in specificity. Model 7 specifically stuck out to us as it is performing only .005 units lower on auroc, but has an extremely good specificity of .942, which is .06 higher than the top performing model in respect to auroc. In other words, we got a substantial gain in specificity with only a minor reduction in auroc. This model also happens to be pure L1 regularized, so we’ll have an even simpler model, and this will allow us to require less data from customers and still be able to use the model. If we aren’t as interested in specificity as I have outlined, we can easily come back and select a different model irrespective of specificity, but for now this will be our model to continue with.

Further, though the model’s overall accuracy (≈0.755) is slightly lower than the maximum achievable, this reflects a deliberate tradeoff aligned with our objective here. We prioritized correctly identifying dissatisfied customers, which leads us to favor models with higher specificity even at the cost of some accuracy. This results in a more conservative classification rule where some satisfied customers may be flagged for follow-up. However, the cost of unnecessarily contacting a satisfied customer is relatively low and still may be beneficial regarding costumer relations, whereas failing to identify a dissatisfied customer may lead to lost future business. As such, the chosen model sacrifices a small amount of overall accuracy in order to better capture customers at risk of dissatisfaction, which is more valuable in this context.

Fit our best model to the full training data and evaluate on held-out data

best_params <- tibble(
  penalty = 0.1,
  mixture = 1)

final_wf <- finalize_workflow(wf, best_params)

final_model <- fit(final_wf, data = train_data)

preds <- predict(final_model, test_data, type = "prob") |>
  bind_cols(predict(final_model, test_data, type = "class")) |>
  bind_cols(test_data)

Held-out performance

roc_auc(preds, truth = satisfaction, .pred_0)
# A tibble: 1 × 3
  .metric .estimator .estimate
  <chr>   <chr>          <dbl>
1 roc_auc binary         0.846
spec(preds, truth = satisfaction, estimate = .pred_class)
# A tibble: 1 × 3
  .metric .estimator .estimate
  <chr>   <chr>          <dbl>
1 spec    binary         0.930

Evaluating our model on test we see that there was some slight optimization bias in our earlier performance estimate, but only marginally so. We still had an extremely good specificity of .93 and an acceptable auROC of .846. Our model seems to be quite low variance if we compare these to the earlier values of .94 and .86. Our bias is still quite low, though I’d make the argument that we don’t mind it being where it is as there’s such a low cost to reaching out to additional customers. However, if we are providing compensation or travel vouchers based upon the model, this would change quite a bit. Depending on what we want we could make the necessary changes, but this appears to serve as a fantastic baseline model.

fullglmnet_fit <- final_model$fit$fit$fit
coefficients <- coef(fullglmnet_fit, s = 0.1)

coefficients <- as.matrix(coefficients)
coefficients <- data.frame(
  term = rownames(coefficients),
  estimate = coefficients[,1])

coefficients
                                                               term
(Intercept)                                             (Intercept)
age                                                             age
flight_distance                                     flight_distance
inflight_wifi_service                         inflight_wifi_service
departure_arrival_time_convenient departure_arrival_time_convenient
ease_of_online_booking                       ease_of_online_booking
gate_location                                         gate_location
food_and_drink                                       food_and_drink
online_boarding                                     online_boarding
seat_comfort                                           seat_comfort
inflight_entertainment                       inflight_entertainment
onboard_service                                     onboard_service
leg_room_service                                   leg_room_service
baggage_handling                                   baggage_handling
checkin_service                                     checkin_service
inflight_service                                   inflight_service
cleanliness                                             cleanliness
delay_max                                                 delay_max
gender_X1                                                 gender_X1
customer_type_X1                                   customer_type_X1
type_of_travel_X1                                 type_of_travel_X1
customer_class_1                                   customer_class_1
customer_class_2                                   customer_class_2
                                      estimate
(Intercept)                        0.759336701
age                                0.000000000
flight_distance                    0.006232991
inflight_wifi_service              0.000000000
departure_arrival_time_convenient  0.000000000
ease_of_online_booking             0.000000000
gate_location                      0.000000000
food_and_drink                     0.000000000
online_boarding                    0.260454027
seat_comfort                       0.000000000
inflight_entertainment             0.095860015
onboard_service                    0.000000000
leg_room_service                   0.000000000
baggage_handling                   0.000000000
checkin_service                    0.000000000
inflight_service                   0.000000000
cleanliness                        0.000000000
delay_max                          0.000000000
gender_X1                          0.000000000
customer_type_X1                   0.000000000
type_of_travel_X1                 -0.426792723
customer_class_1                   0.065387423
customer_class_2                   0.000000000
coefficients_used <- coefficients[coefficients$estimate != 0, ]

coefficients_used
                                         term     estimate
(Intercept)                       (Intercept)  0.759336701
flight_distance               flight_distance  0.006232991
online_boarding               online_boarding  0.260454027
inflight_entertainment inflight_entertainment  0.095860015
type_of_travel_X1           type_of_travel_X1 -0.426792723
customer_class_1             customer_class_1  0.065387423

Importantly, we looked into our selected model to see what information it’s using and what we found may be extremely interesting/valuable to us. The model only retained flight distance, online boarding, inflight entertainment, type of travel, and the dummy variable coding for Economy vs Economy plus. What this means is that if we move forward with this model, these are the only datapoints we’ll need to collect and all but the inflight entertainment rating are things we are likely collecting for all passengers already. Therefore, implementing this model means there is no need for collecting the other data if doing so is expensive or time-consuming, and simply asking for a rating of inflight entertainment would suffice. Further, we may look into additionally prioritizing inflight entertainment, though we should be careful in taking away resources from any other aspects of the experience, especially without looking deeper into specific feature importance metrics like shapley values.

Prediction summary:

Spending your data:

We split the dataset into an 80/20 train–test split using stratification on the outcome to preserve class balance. The training set was used for all model fitting and model selection, while the test set was held out as a final, unbiased estimate of model performance. Within the training data, we used repeated 5-fold cross-validation (10 repeats) to evaluate model configurations. This approach balances bias and variance trade-off considerations. Holding out a test set prevents optimization bias in performance estimates, while repeated cross-validation reduces the variance of model evaluation compared to a single split. Given the moderate sample size (n = 419 after casewise deletion), this strategy allows us to efficiently use the data while still maintaining a reliable estimate of generalization performance. The main tradeoff is that reserving 20% of the data reduces the amount available for model fitting, potentially increasing bias slightly. However, this is mitigated by cross-validation, and the benefit of having a clean, unbiased test evaluation outweighs this cost.

Model configurations:

We focused on a regularized logistic regression model (glmnet), tuning two key hyperparameters: the penalty, which controls the strength of regularization, and the mixture parameter, which determines the balance between ridge (L2) and lasso (L1) penalties. We explored a range of penalty values (on a log scale) and multiple mixture values spanning ridge to lasso, beginning with a broader grid and then refining to smaller penalty values where performance was strongest. This ensured that we considered models ranging from L2 regularization with little to no penalty all the way to large penalties and L1 regularization/feature selection. We determined that we had explored a sufficient range when performance was less variable (in terms of auROC, specificity, and accuracy) across a region of hyperparameters, indicating that further expansion of the grid was unlikely to yield meaningful improvements. Earlier grid values showed sporatic and massive decreases in these metrics. Additionally, comparing ridge, lasso, and elastic net configurations ensured that both shrinkage-based and feature-selection-based models were considered.

Model selection:

Model performance during tuning was evaluated primarily using auROC, specificity, and accuracy. We didn’t overtly set values for any of these values beforehand which would’ve been ideal. If we had, we could have set either auROC or specificity as our satisficing metric and the other as our optimization metric. In practice, we basically did this post-hoc by focusing only on models with an acceptable auROC , before selecting the model out of those that maximized specificity. auROC was chosen because it is threshold-independent and provides a robust measure of the model’s ability to discriminate between satisfied and unsatisfied customers, particularly in the presence of class imbalance (67/33). Specificity was a big part of the model decision-making process as we supposed that minimizing false positives (incorrectly classifying dissatisfied customers as satisfied) was worthwhile given the potential uses for the model and the ramifications, as we explained in more depth previously. While the model with the highest auROC achieved slightly better overall discrimination, an alternative model was selected that sacrificed a small amount of auROC (≈0.005) in exchange for a substantial increase in specificity (≈0.06). This reflected a deliberate, cost-sensitive decision where maximizing overall discrimination was less important than correctly identifying dissatisfied customers.

Model characterization

The expected model performance for new data was approximated by evaluating on the held-out test set. The model achieved an auROC of 0.846 and a specificity of 0.93, indicating strong predictive discrimination and a high rate of correctly identifying dissatisfied customers. Comprehensively characterizing performance depends on our ultimate goals with the model, but we give a comprehensive performance characterization through auROC which measures overall discrimination, specificity which measures the models ability to correctly identify dissatisfied customers (potentially highly relevant to us), and accuracy which tells us the general correctness but is less informative under imbalance, though highly interpretable. In real-world terms, this model is intentionally conservative, it prioritizes identifying dissatisfied customers, even if that means sometimes flagging satisfied customers for follow-up. The validity of this choice depends entirely on what our goals with the model are, if we are simply contacting potentially dissatisfied customers for feedback/questions versus if we are offering compensation or vouchers based on predictions is paramount. The cost of contacting a satisfied customer is very low (and may even improve customer relations), whereas failing to identify a dissatisfied customer may result in lost future business. For this baseline model, we’ve assumed simple-contacting as the goal, which is reflected by the models asymmetric error structure (false positives as low cost but false negatives as potentially high cost), but this can be easily changed in future models if our objectives change.