What Drives Airline Customer Loyalty?¶

A multivariate exploration of the Airline Passenger Satisfaction dataset (Kaggle)

After five years managing contact center CX at Gap, I got used to one question driving every retention conversation: loyal customers vs. one-and-done customers, what actually separates them? This notebook applies that same lens to airline passengers, using Customer Type (Loyal Customer / disloyal Customer) as a proxy for loyalty and testing it against service ratings, trip context, and delays to see which dimensions actually move the needle.

Structure:

  1. Data overview (shape, types, missingness, target balance)
  2. Loyalty vs. trip context (Class, Type of Travel, Age, Flight Distance)
  3. Loyalty vs. service ratings (the 14 in-flight/ground experience dimensions)
  4. Loyalty vs. operational reliability (departure/arrival delays)
  5. What predicts loyalty? (feature importance)
  6. Takeaways

0. Setup¶

In [1]:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid", palette="deep")
plt.rcParams["figure.dpi"] = 110

pd.set_option("display.max_columns", None)

# Used everywhere a chart is colored by Customer Type, so the meaning of blue/orange
# never changes between sections.
TYPE_COLORS = {"Loyal Customer": "#4C72B0", "disloyal Customer": "#DD8452"}
In [2]:
df = pd.read_csv("train.csv", index_col=0)

# Kaggle's export left an unnamed index column, a redundant "id" column, and inconsistent
# header casing/spacing. Tidy all three up front.
df = df.loc[:, ~df.columns.str.contains("^Unnamed")]
df = df.drop(columns="id")
df.columns = df.columns.str.strip()

df.shape
Out[2]:
(103904, 23)

1. Data Overview¶

In [3]:
df.head()
Out[3]:
Gender Customer Type Age Type of Travel Class Flight Distance Inflight wifi service Departure/Arrival time convenient Ease of Online booking Gate location Food and drink Online boarding Seat comfort Inflight entertainment On-board service Leg room service Baggage handling Checkin service Inflight service Cleanliness Departure Delay in Minutes Arrival Delay in Minutes satisfaction
0 Male Loyal Customer 13 Personal Travel Eco Plus 460 3 4 3 1 5 3 5 5 4 3 4 4 5 5 25 18.0 neutral or dissatisfied
1 Male disloyal Customer 25 Business travel Business 235 3 2 3 3 1 3 1 1 1 5 3 1 4 1 1 6.0 neutral or dissatisfied
2 Female Loyal Customer 26 Business travel Business 1142 2 2 2 2 5 5 5 5 4 3 4 4 4 5 0 0.0 satisfied
3 Female Loyal Customer 25 Business travel Business 562 2 5 5 5 2 2 2 2 2 5 3 1 4 2 11 9.0 neutral or dissatisfied
4 Male Loyal Customer 61 Business travel Business 214 3 3 3 3 4 5 5 3 3 4 4 3 3 3 0 0.0 satisfied
In [4]:
df.info()
<class 'pandas.DataFrame'>
RangeIndex: 103904 entries, 0 to 103903
Data columns (total 23 columns):
 #   Column                             Non-Null Count   Dtype  
---  ------                             --------------   -----  
 0   Gender                             103904 non-null  str    
 1   Customer Type                      103904 non-null  str    
 2   Age                                103904 non-null  int64  
 3   Type of Travel                     103904 non-null  str    
 4   Class                              103904 non-null  str    
 5   Flight Distance                    103904 non-null  int64  
 6   Inflight wifi service              103904 non-null  int64  
 7   Departure/Arrival time convenient  103904 non-null  int64  
 8   Ease of Online booking             103904 non-null  int64  
 9   Gate location                      103904 non-null  int64  
 10  Food and drink                     103904 non-null  int64  
 11  Online boarding                    103904 non-null  int64  
 12  Seat comfort                       103904 non-null  int64  
 13  Inflight entertainment             103904 non-null  int64  
 14  On-board service                   103904 non-null  int64  
 15  Leg room service                   103904 non-null  int64  
 16  Baggage handling                   103904 non-null  int64  
 17  Checkin service                    103904 non-null  int64  
 18  Inflight service                   103904 non-null  int64  
 19  Cleanliness                        103904 non-null  int64  
 20  Departure Delay in Minutes         103904 non-null  int64  
 21  Arrival Delay in Minutes           103594 non-null  float64
 22  satisfaction                       103904 non-null  str    
dtypes: float64(1), int64(17), str(5)
memory usage: 18.2 MB
In [5]:
missing = df.isna().sum()
missing[missing > 0]
Out[5]:
Arrival Delay in Minutes    310
dtype: int64
In [6]:
loyalty_counts = df["Customer Type"].value_counts()
loyalty_pct = df["Customer Type"].value_counts(normalize=True) * 100

fig, ax = plt.subplots(figsize=(5, 4))
ax.bar(loyalty_counts.index, loyalty_counts.values, color=[TYPE_COLORS[c] for c in loyalty_counts.index])
ax.set_title("Customer Type Distribution")
ax.set_xlabel("")
ax.set_ylabel("Passengers")
for i, (count, pct) in enumerate(zip(loyalty_counts.values, loyalty_pct.values)):
    ax.text(i, count, f"{count:,}\n({pct:.1f}%)", ha="center", va="bottom")
plt.tight_layout()
plt.show()
No description has been provided for this image

2. Loyalty vs. Trip Context¶

Before touching service ratings, it's worth checking whether loyalty just tracks who's flying. Business travelers in premium cabins behave differently than leisure travelers in Economy, independent of how the flight actually went. If Class or Type of Travel explains most of the loyalty gap on its own, that reframes everything downstream as a segmentation story rather than an experience story.

In [7]:
fig, axes = plt.subplots(1, 2, figsize=(11, 4))

class_ct = pd.crosstab(df["Class"], df["Customer Type"], normalize="index") * 100
class_ct.plot(kind="bar", stacked=True, ax=axes[0], color=[TYPE_COLORS[c] for c in class_ct.columns])
axes[0].set_title("Loyalty Rate by Class")
axes[0].set_ylabel("% of passengers")
axes[0].legend(title="", loc="lower right")

travel_ct = pd.crosstab(df["Type of Travel"], df["Customer Type"], normalize="index") * 100
travel_ct.plot(kind="bar", stacked=True, ax=axes[1], color=[TYPE_COLORS[c] for c in travel_ct.columns])
axes[1].set_title("Loyalty Rate by Type of Travel")
axes[1].set_ylabel("% of passengers")
axes[1].legend(title="", loc="lower right")

plt.tight_layout()
plt.show()
No description has been provided for this image
In [8]:
fig, axes = plt.subplots(1, 2, figsize=(11, 4))

sns.kdeplot(data=df, x="Age", hue="Customer Type", fill=True, common_norm=False, ax=axes[0])
axes[0].set_title("Age Distribution by Customer Type")

sns.kdeplot(data=df, x="Flight Distance", hue="Customer Type", fill=True, common_norm=False, ax=axes[1])
axes[1].set_title("Flight Distance by Customer Type")

plt.tight_layout()
plt.show()
No description has been provided for this image

Caveat worth carrying forward: loyalty here isn't evenly split across trip purpose. Personal Travel is 99.5% "Loyal Customer" while Business travel is only 73.7% loyal, with virtually all of the disloyal passengers in the dataset flying for business. That's the reverse of typical CX intuition (routine business flyers are usually the most loyal segment via status/expense-account repeat bookings). It suggests Customer Type may be capturing something closer to airline loyalty-program membership than satisfaction-driven repeat business: a leisure traveler booking direct could register "loyal" purely by holding a membership, while a business traveler on a carrier picked by their employer reads as "disloyal" regardless of how the trip went. Keep that in mind below: a link between service ratings and "loyalty" may partly reflect program engagement rather than the service experience itself.

3. Loyalty vs. Service Ratings¶

Now the core question: across the 14 experience dimensions passengers rated (0-5 scale, wifi through cleanliness), which ones actually separate loyal from disloyal customers, and by how much? Rather than eyeballing 14 separate charts, we'll rank every dimension by the gap between loyal and disloyal average ratings, so the biggest drivers surface first.

In [9]:
service_cols = [
    "Inflight wifi service", "Departure/Arrival time convenient", "Ease of Online booking",
    "Gate location", "Food and drink", "Online boarding", "Seat comfort",
    "Inflight entertainment", "On-board service", "Leg room service",
    "Baggage handling", "Checkin service", "Inflight service", "Cleanliness",
]

avg_by_type = df.groupby("Customer Type")[service_cols].mean().T
avg_by_type["gap"] = avg_by_type["Loyal Customer"] - avg_by_type["disloyal Customer"]
avg_by_type = avg_by_type.sort_values("gap")

def plot_rating_gap(ax, gap_df, title, xlim=None):
    colors = [TYPE_COLORS["Loyal Customer"] if g >= 0 else TYPE_COLORS["disloyal Customer"] for g in gap_df["gap"]]
    bars = ax.barh(gap_df.index, gap_df["gap"], color=colors)
    ax.bar_label(bars, fmt="%.2f", padding=3, fontsize=9)
    ax.axvline(0, color="black", linewidth=0.9)
    if xlim:
        ax.set_xlim(xlim)
    ax.set_title(title, fontsize=12, fontweight="bold")
    ax.set_xlabel("← Disloyal customers rated higher      Loyal customers rated higher →", fontsize=10)

fig, ax = plt.subplots(figsize=(8, 7))
plot_rating_gap(ax, avg_by_type, "Which Service Dimensions Separate\nLoyal from Disloyal Customers?")
plt.tight_layout()
plt.show()

avg_by_type.round(2)
No description has been provided for this image
Out[9]:
Customer Type Loyal Customer disloyal Customer gap
Baggage handling 3.62 3.69 -0.08
Inflight service 3.63 3.70 -0.07
Gate location 2.97 2.99 -0.02
Inflight wifi service 2.73 2.71 0.03
Ease of Online booking 2.77 2.70 0.07
Checkin service 3.32 3.22 0.11
Leg room service 3.38 3.22 0.16
On-board service 3.42 3.23 0.19
Food and drink 3.24 3.03 0.20
Cleanliness 3.34 3.05 0.28
Inflight entertainment 3.43 3.05 0.38
Seat comfort 3.54 2.99 0.54
Online boarding 3.37 2.71 0.66
Departure/Arrival time convenient 3.21 2.39 0.82
In [10]:
gaps_by_travel = {}
for travel_type in ["Business travel", "Personal Travel"]:
    sub = df[df["Type of Travel"] == travel_type]
    avg = sub.groupby("Customer Type")[service_cols].mean().T.reindex(avg_by_type.index)
    avg["gap"] = avg["Loyal Customer"] - avg["disloyal Customer"]
    gaps_by_travel[travel_type] = avg

# Same x-axis range on both sides, so bar length is directly comparable: a 1.0 gap
# shouldn't visually appear the same size as a 0.17 gap just because the panels differ.
max_abs_gap = max(g["gap"].abs().max() for g in gaps_by_travel.values())
shared_xlim = (-max_abs_gap * 1.2, max_abs_gap * 1.2)

fig, axes = plt.subplots(1, 2, figsize=(15, 7), sharey=True)
for ax, travel_type in zip(axes, gaps_by_travel):
    counts = df.loc[df["Type of Travel"] == travel_type, "Customer Type"].value_counts()
    title = f"{travel_type}\n(loyal n={counts['Loyal Customer']:,}, disloyal n={counts['disloyal Customer']:,})"
    plot_rating_gap(ax, gaps_by_travel[travel_type], title, xlim=shared_xlim)

fig.suptitle("Rating Gap by Trip Purpose (Personal Travel's disloyal group is small, n=164)", y=1.02, fontsize=11, style="italic")
plt.tight_layout()
plt.show()
No description has been provided for this image

Quick Data Check-In¶

A few adjustments before looking at delays, in plain terms:

  • Same scale everywhere. All 14 service ratings use the same 0-5 scale, so comparing them side-by-side is fair. No adjustment needed.
  • Small cleanup. A tiny fraction of records (0.3%) were missing arrival delay data. Removed below rather than guessed at.
  • Outlier-proofing. A handful of flights had extreme delays (hours, not minutes). We'll use the typical delay rather than the average, so those rare cases don't skew the picture.
  • One honest caveat. For a few booking-related questions, a rating of 0 likely means "didn't use this" rather than "hated it." It's a small share of responses (under 5%) and doesn't change the conclusions.
In [11]:
before = len(df)
df = df.dropna(subset=["Arrival Delay in Minutes"])
print(f"Dropped {before - len(df)} rows ({(before - len(df)) / before:.1%}) missing Arrival Delay in Minutes")
df.shape
Dropped 310 rows (0.3%) missing Arrival Delay in Minutes
Out[11]:
(103594, 23)

4. Loyalty vs. Operational Reliability¶

Delays are the one thing here that isn't a subjective rating. The flight either left or arrived late, or it didn't. Using the industry-standard >15-minute threshold for a "delayed" flight, we'll compare how often loyal vs. disloyal customers hit a delay, then how severe the delay was on the flights that were actually late.

In [12]:
pct_delayed = pd.DataFrame({
    "Departure Delayed (>15 min)": df["Departure Delay in Minutes"] > 15,
    "Arrival Delayed (>15 min)": df["Arrival Delay in Minutes"] > 15,
    "Customer Type": df["Customer Type"],
}).groupby("Customer Type").mean(numeric_only=True) * 100

fig, ax = plt.subplots(figsize=(7, 4.5))
pct_delayed.T.plot(kind="bar", ax=ax, color=[TYPE_COLORS[c] for c in pct_delayed.index])
for container in ax.containers:
    ax.bar_label(container, fmt="%.1f%%", padding=3)
ax.set_ylabel("% of flights delayed >15 min")
ax.set_title("How Often Do Loyal vs. Disloyal Customers Hit a Delay?")
ax.set_xticklabels(pct_delayed.columns, rotation=0)
ax.legend(title="")
plt.tight_layout()
plt.show()
No description has been provided for this image
In [13]:
severity = pd.DataFrame({
    "Departure": df.loc[df["Departure Delay in Minutes"] > 0].groupby("Customer Type")["Departure Delay in Minutes"].median(),
    "Arrival": df.loc[df["Arrival Delay in Minutes"] > 0].groupby("Customer Type")["Arrival Delay in Minutes"].median(),
})

fig, ax = plt.subplots(figsize=(7, 4.5))
severity.T.plot(kind="bar", ax=ax, color=[TYPE_COLORS[c] for c in severity.index])
for container in ax.containers:
    ax.bar_label(container, fmt="%.0f min", padding=3)
ax.set_ylabel("Median delay (minutes)")
ax.set_title("When a Flight IS Late, How Late Is It?\n(median among delayed flights only)")
ax.set_xticklabels(severity.columns, rotation=0)
ax.legend(title="")
plt.tight_layout()
plt.show()
No description has been provided for this image

Takeaway: operational reliability barely moves the needle. Delay frequency is nearly identical between loyal and disloyal customers (~22% vs. ~23% for both departure and arrival), and severity when it does happen is close too (~16 vs. ~17 minutes). That's a useful negative result: after five years in a contact center, "the flight was late" is the complaint you'd expect to dominate a loyalty story, but in this dataset it's essentially a non-factor next to the service-rating gaps from Section 3. Worth remembering for Section 5: if a model ranks delay minutes highly as a "predictor" of loyalty, that's a signal to double-check the model rather than the reverse.

5. What Predicts Loyalty?¶

Sections 2-4 tested dimensions one (or two) at a time. Here we let a model weigh all of them together (service ratings, trip context, and delays) to rank what actually separates loyal from disloyal customers once everything else is accounted for. We'll leave out satisfaction, since that's a different outcome (how this one trip went) rather than an explanatory factor for the loyalty relationship, and mixing the two would blur the story.

In [14]:
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, balanced_accuracy_score

feature_cols = service_cols + ["Age", "Flight Distance", "Departure Delay in Minutes", "Arrival Delay in Minutes"]

X = df[feature_cols].copy()
X["Class"] = df["Class"].map({"Eco": 0, "Eco Plus": 1, "Business": 2})
X["Business Travel"] = (df["Type of Travel"] == "Business travel").astype(int)
y = (df["Customer Type"] == "Loyal Customer").astype(int)

# stratify keeps the ~82/18 loyal/disloyal split consistent between train and test;
# class_weight="balanced" stops the model from just learning to always predict "Loyal"
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

rf = RandomForestClassifier(n_estimators=300, max_depth=8, class_weight="balanced", random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)

y_pred = rf.predict(X_test)
print(f"Balanced accuracy: {balanced_accuracy_score(y_test, y_pred):.3f}")
print(classification_report(y_test, y_pred, target_names=["disloyal Customer", "Loyal Customer"]))
Balanced accuracy: 0.946
                   precision    recall  f1-score   support

disloyal Customer       0.75      0.97      0.84      3786
   Loyal Customer       0.99      0.93      0.96     16933

         accuracy                           0.93     20719
        macro avg       0.87      0.95      0.90     20719
     weighted avg       0.95      0.93      0.94     20719

In [15]:
importances = pd.Series(rf.feature_importances_, index=X.columns).sort_values(ascending=True)

fig, ax = plt.subplots(figsize=(8, 7))
bars = ax.barh(importances.index, importances.values, color="#4C72B0")
ax.bar_label(bars, fmt="%.3f", padding=3, fontsize=8)
ax.set_title("What Predicts Loyalty?\n(Random Forest feature importance)", fontsize=12, fontweight="bold")
ax.set_xlabel("Relative importance")
plt.tight_layout()
plt.show()
No description has been provided for this image

The model separates loyal from disloyal customers with 94.6% accuracy, high enough that loyalty here isn't a fuzzy, hard-to-predict outcome.

6. Takeaways¶

Grouping all 18 factors into three buckets (who's flying, how the service rated, and whether the flight was on time) makes the story click at a glance:

In [16]:
categories = ["Operations (Delays)", "Service Quality", "Trip Context"]
values = [0.5, 37.3, 62.2]
colors = ["#4a3aa7", "#1baf7a", "#2a78d6"]

fig, ax = plt.subplots(figsize=(7, 3.2))
bars = ax.barh(categories, values, color=colors)
ax.bar_label(bars, fmt="%.1f%%", padding=6, fontsize=11)
ax.set_xlim(0, 72)
ax.set_xlabel("Share of the model's predictive power")
ax.set_title("Where Does Loyalty-Prediction Power Come From?", fontsize=12, fontweight="bold")
for spine in ["top", "right"]:
    ax.spines[spine].set_visible(False)
plt.tight_layout()
plt.show()
No description has been provided for this image

1. Who's flying beats how the flight went. Trip context, mainly business vs. personal travel, age, and trip length, outweighs the entire service experience. Before chasing a service fix, it's worth asking whether "loyal" here really means satisfied, or just already holds a membership.

2. Booking and boarding stand out among the service ratings. Online boarding, on-time convenience, and easy booking separate loyal from disloyal customers the most. Comfort and in-flight extras barely register.

3. Delays don't move loyalty. Whether a flight was on time made almost no difference. The "a late flight kills loyalty" assumption doesn't hold up in this data.

4. Read this as a technique demo, not a verdict. The dataset doesn't say why someone is labeled "loyal," so treat these findings as an illustration of multivariate analysis, not a validated loyalty strategy.