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:
- Data overview (shape, types, missingness, target balance)
- Loyalty vs. trip context (Class, Type of Travel, Age, Flight Distance)
- Loyalty vs. service ratings (the 14 in-flight/ground experience dimensions)
- Loyalty vs. operational reliability (departure/arrival delays)
- What predicts loyalty? (feature importance)
- Takeaways
0. Setup¶
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"}
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
(103904, 23)
1. Data Overview¶
df.head()
| 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 |
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
missing = df.isna().sum()
missing[missing > 0]
Arrival Delay in Minutes 310 dtype: int64
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()
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.
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()
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()
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.
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)
| 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 |
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()
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.
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
(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.
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()
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()
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.
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
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()
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:
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()
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.