Reaching New Heights: Develop and Retain Valuable Airline Customers

The airline industry operates in a highly competitive environment where retaining customers and ensuring their loyalty is crucial for sustainable growth. Airlines often offer loyalty programs to incentivise repeated business and cultivate a loyal customer base. However, with a huge volume of customer and flight data available, airlines face the challenge of effectively analysing their customer habits, and thereby segmenting these customers, to tailor marketing strategies and enhance customer experiences.

1 Getting started¶

1.1 Import libraries¶

In [226]:
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import matplotlib.gridspec as gridspec
import colorcet as cc
import seaborn as sns
from datetime                   import datetime
from scipy.stats                import pearsonr
from scipy.stats                import pointbiserialr
from scipy.stats                import chi2_contingency
from scipy.stats                import kruskal

# Frequent pattern mining  
from mlxtend.preprocessing      import TransactionEncoder
from mlxtend.frequent_patterns  import apriori, association_rules

# Data processing
from sklearn.preprocessing      import LabelEncoder, OneHotEncoder

# Correlation Matrix
from scipy                      import stats

# Machine learning
import sklearn
from sklearn.cluster            import KMeans, AgglomerativeClustering
from sklearn.neighbors          import KNeighborsClassifier
from sklearn.linear_model       import LogisticRegression
import xgboost

# Imbalanced learning
from imblearn.over_sampling     import SMOTE
from imblearn.under_sampling    import RandomUnderSampler
from imblearn.pipeline          import make_pipeline

# Model selection and evaluation
from sklearn                    import metrics
from sklearn.metrics            import classification_report, accuracy_score, cohen_kappa_score, f1_score, recall_score, precision_score, confusion_matrix, roc_curve
from sklearn.metrics            import mean_squared_error, mean_absolute_error, r2_score
from sklearn.model_selection    import train_test_split

# Suppress all warnings
import warnings
warnings.filterwarnings('ignore')

1.2 Define helper functions & constants¶

In [227]:
# constant

RANDOM_STATE = 42
In [228]:
# calculate months between 2 dates

def calculate_month_interval(start_time, end_time):
    return (end_time.year - start_time.year)*12 + (end_time.month - start_time.month)
In [229]:
def cf_matrix_labels(cf_matrix):
    
    group_names = ['True Negatives','False Positives','False Negatives','True Positives']
    group_counts = ['{0:0.0f}'.format(value) for value in cf_matrix.flatten()]
    group_percentages = ['{0:.2%}'.format(value) for value in cf_matrix.flatten()/np.sum(cf_matrix)]
    labels = [f'{v1}\n{v2}\n{v3}' for v1, v2, v3 in zip(group_names,group_counts,group_percentages)]
    labels = np.asarray(labels).reshape(2,2)
    
    return labels
In [230]:
# customize classification report

def get_classification_report(y_true, proba_pred):
    precision, recall, threshold = metrics.precision_recall_curve(y_true, proba_pred)
    report_df = pd.DataFrame({
        'threshold': threshold,
        'precision': precision[:-1],
        'recall': recall[:-1]
    }).assign(
        f1 = lambda x: 2 * x['recall'] * x['precision'] / (x['recall'] + x['precision']),
        f2 = lambda x: (5 * x['precision'] * x['recall']) / (4 * x['precision'] + x['recall'])
    )
    fig = plt.figure(figsize=(7,7))
    plt.plot(report_df['threshold'], report_df['precision'], label = 'precision')
    plt.plot(report_df['threshold'], report_df['recall'], label = 'recall')
    # plt.plot(report_df['threshold'], report_df['f1'], '--', color='brown', label = 'f1')
    plt.plot(report_df['threshold'], report_df['f2'], '--', color='green', label = 'f2')
    plt.xlabel('threshold')
    plt.ylabel('score')
    plt.legend(loc='upper center')
    plt.show()
    return report_df

1.3 Import data¶

In [231]:
# put all data files in folder "dataset"

DATA_LOC = 'dataset'

1.3.1 Loyalty history (loyalty_df)¶

In [232]:
loyalty_file = 'Customer Loyalty History.csv'
loyalty_df = pd.read_csv(os.path.join(DATA_LOC, loyalty_file))
loyalty_df.columns
Out[232]:
Index(['Loyalty Number', 'Country', 'Province', 'City', 'Postal Code',
       'Gender', 'Education', 'Salary', 'Marital Status', 'Loyalty Card',
       'CLV', 'Enrollment Type', 'Enrollment Year', 'Enrollment Month',
       'Cancellation Year', 'Cancellation Month'],
      dtype='object')
In [233]:
# remove all space character in column name

loyalty_new_cols = [col.replace(' ', '') for col in loyalty_df.columns]
loyalty_df = loyalty_df.rename(columns=dict(zip(loyalty_df.columns, loyalty_new_cols)))

display(loyalty_df.info(), loyalty_df.head())
print('No. of unique customers:', loyalty_df['LoyaltyNumber'].nunique())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 16737 entries, 0 to 16736
Data columns (total 16 columns):
 #   Column             Non-Null Count  Dtype  
---  ------             --------------  -----  
 0   LoyaltyNumber      16737 non-null  int64  
 1   Country            16737 non-null  object 
 2   Province           16737 non-null  object 
 3   City               16737 non-null  object 
 4   PostalCode         16737 non-null  object 
 5   Gender             16737 non-null  object 
 6   Education          16737 non-null  object 
 7   Salary             12499 non-null  float64
 8   MaritalStatus      16737 non-null  object 
 9   LoyaltyCard        16737 non-null  object 
 10  CLV                16737 non-null  float64
 11  EnrollmentType     16737 non-null  object 
 12  EnrollmentYear     16737 non-null  int64  
 13  EnrollmentMonth    16737 non-null  int64  
 14  CancellationYear   2067 non-null   float64
 15  CancellationMonth  2067 non-null   float64
dtypes: float64(4), int64(3), object(9)
memory usage: 2.0+ MB
None
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard CLV EnrollmentType EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star 3839.14 Standard 2016 2 NaN NaN
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College NaN Divorced Star 3839.61 Standard 2016 3 NaN NaN
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College NaN Single Star 3839.75 Standard 2014 7 2018.0 1.0
3 608370 Canada Ontario Toronto P1W 1K4 Male College NaN Single Star 3839.75 Standard 2013 2 NaN NaN
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star 3842.79 Standard 2014 10 NaN NaN
No. of unique customers: 16737

1.3.2 Flight activity (flight_df)¶

In [234]:
flight_file = 'Customer Flight Activity.csv'
flight_df = pd.read_csv(os.path.join(DATA_LOC, flight_file))
flight_df.columns
Out[234]:
Index(['Loyalty Number', 'Year', 'Month', 'Flights Booked',
       'Flights with Companions', 'Total Flights', 'Distance',
       'Points Accumulated', 'Points Redeemed', 'Dollar Cost Points Redeemed'],
      dtype='object')
In [235]:
# remove all space character in column name

flight_new_cols = [col.replace(' ', '') for col in flight_df.columns]
flight_df = flight_df.rename(columns=dict(zip(flight_df.columns, flight_new_cols)))\
    .rename(columns={'FlightswithCompanions': 'FlightsWithCompanions'})
    
display(flight_df.info(), flight_df.head())
print('No. of unique customers:', flight_df['LoyaltyNumber'].nunique())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 405624 entries, 0 to 405623
Data columns (total 10 columns):
 #   Column                    Non-Null Count   Dtype  
---  ------                    --------------   -----  
 0   LoyaltyNumber             405624 non-null  int64  
 1   Year                      405624 non-null  int64  
 2   Month                     405624 non-null  int64  
 3   FlightsBooked             405624 non-null  int64  
 4   FlightsWithCompanions     405624 non-null  int64  
 5   TotalFlights              405624 non-null  int64  
 6   Distance                  405624 non-null  int64  
 7   PointsAccumulated         405624 non-null  float64
 8   PointsRedeemed            405624 non-null  int64  
 9   DollarCostPointsRedeemed  405624 non-null  int64  
dtypes: float64(1), int64(9)
memory usage: 30.9 MB
None
LoyaltyNumber Year Month FlightsBooked FlightsWithCompanions TotalFlights Distance PointsAccumulated PointsRedeemed DollarCostPointsRedeemed
0 100018 2017 1 3 0 3 1521 152.0 0 0
1 100102 2017 1 10 4 14 2030 203.0 0 0
2 100140 2017 1 6 0 6 1200 120.0 0 0
3 100214 2017 1 0 0 0 0 0.0 0 0
4 100272 2017 1 0 0 0 0 0.0 0 0
No. of unique customers: 16737

2 Data Preparation¶

2.1 Preliminary Data Cleaning¶

2.1.1 Loyalty history (loyalty_df)¶

Summary:

  • Convert data type of LoyaltyNumber, CancellationYear, CancellationMonth
  • Process salary colunn: consider negative salary as missing values, fill all missing values with 0
  • Create 4 columns: EnrollmentDate, EnrollmentYearMonth (YYYY-MM), CancellationDate, CancellationYearMonth (YYYY-MM)
  • Create ChurnIndicator

2.1.1.1 Correcting data type¶

In [236]:
# convert data type

loyalty_df = loyalty_df.assign(
    LoyaltyNumber = lambda x: x['LoyaltyNumber'].astype(str),
    CancellationYear = lambda x: x['CancellationYear'].astype('Int64'),
    CancellationMonth = lambda x: x['CancellationMonth'].astype('Int64')
)

2.1.1.2 Checking numeric columns¶

In [237]:
loyalty_df.describe()
Out[237]:
Salary CLV EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth
count 12499.000000 16737.000000 16737.000000 16737.000000 2067.0 2067.0
mean 79245.609409 7988.896536 2015.253211 6.669116 2016.503145 6.962748
std 35008.297285 6860.982280 1.979111 3.398958 1.380743 3.455297
min -58486.000000 1898.010000 2012.000000 1.000000 2013.0 1.0
25% 59246.500000 3980.840000 2014.000000 4.000000 2016.0 4.0
50% 73455.000000 5780.180000 2015.000000 7.000000 2017.0 7.0
75% 88517.500000 8940.580000 2017.000000 10.000000 2018.0 10.0
max 407228.000000 83325.380000 2018.000000 12.000000 2018.0 12.0
In [238]:
# records with negative salary

print('Number of records with negative salary:', loyalty_df[loyalty_df['Salary'].le(0)].shape[0])
loyalty_df[loyalty_df['Salary'].le(0)]
Number of records with negative salary: 20
Out[238]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard CLV EnrollmentType EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth
1082 542976 Canada Quebec Montreal H2Y 4R4 Male High School or Below -49830.0 Divorced Star 24127.50 2018 Promotion 2018 3 <NA> <NA>
1894 959977 Canada British Columbia Vancouver V5R 1W3 Female Bachelor -12497.0 Married Aurora 9453.00 2018 Promotion 2018 3 <NA> <NA>
2471 232755 Canada British Columbia Vancouver V1E 4R6 Female Bachelor -46683.0 Single Nova 4787.81 2018 Promotion 2018 3 <NA> <NA>
3575 525245 Canada British Columbia Victoria V10 6T5 Male Bachelor -45962.0 Married Star 2402.33 2018 Promotion 2018 3 <NA> <NA>
3932 603070 Canada British Columbia West Vancouver V6V 8Z3 Female Bachelor -19325.0 Single Star 2893.74 2018 Promotion 2018 3 <NA> <NA>
4712 491242 Canada British Columbia Dawson Creek U5I 4F1 Male Bachelor -43234.0 Married Star 7597.91 2018 Promotion 2018 3 <NA> <NA>
6560 115505 Canada Newfoundland St. John's A1C 6H9 Male Bachelor -10605.0 Married Nova 5860.17 2018 Promotion 2018 4 <NA> <NA>
6570 430398 Canada Newfoundland St. John's A1C 6H9 Male Bachelor -17534.0 Married Nova 49423.80 2018 Promotion 2018 3 <NA> <NA>
7373 152016 Canada Ontario Toronto P1J 8T7 Female Bachelor -58486.0 Married Aurora 5067.21 2018 Promotion 2018 2 <NA> <NA>
8576 194065 Canada Ontario Sudbury M5V 1G5 Female Bachelor -31911.0 Married Nova 2888.85 2018 Promotion 2018 2 <NA> <NA>
8767 212128 Canada Ontario Toronto P2T 6G3 Male Bachelor -49001.0 Married Nova 3130.68 2018 Promotion 2018 2 <NA> <NA>
10232 790475 Canada Ontario Trenton K8V 4B2 Female Bachelor -34079.0 Married Nova 12913.50 2018 Promotion 2018 2 <NA> <NA>
11635 366599 Canada Ontario Toronto M1R 4K3 Female Bachelor -9081.0 Married Star 6915.73 2018 Promotion 2018 4 <NA> <NA>
12596 436087 Canada Quebec Montreal H2T 9K8 Male Bachelor -46470.0 Married Star 4786.89 2018 Promotion 2018 4 2018 8
13564 364596 Canada Quebec Tremblant H5Y 2S9 Female Bachelor -26322.0 Single Aurora 16710.84 2018 Promotion 2018 4 2018 12
14327 239955 Canada Quebec Hull J8Y 3Z5 Female Bachelor -47310.0 Married Nova 6366.23 2018 Promotion 2018 3 <NA> <NA>
14355 347013 Canada Quebec Quebec City G1B 3L5 Male Bachelor -39503.0 Married Nova 6446.71 2018 Promotion 2018 3 <NA> <NA>
15416 729561 Canada Quebec Quebec City G1B 3L5 Female Bachelor -19332.0 Divorced Star 5308.29 2018 Promotion 2018 2 <NA> <NA>
16431 734647 Canada Saskatchewan Regina S1J 3C5 Male Bachelor -46303.0 Married Nova 11280.73 2018 Promotion 2018 4 <NA> <NA>
16735 906428 Canada Yukon Whitehorse Y2K 6R0 Male Bachelor -57297.0 Married Star 10018.66 2018 Promotion 2018 4 <NA> <NA>
In [239]:
# records with missing salary

print(
    'Number of records with missing salary:', loyalty_df['Salary'].isna().sum(),
    '\nPercentage of records with missing salary:', round(loyalty_df['Salary'].isna().sum()*100/loyalty_df.shape[0],2), '%'
)
Number of records with missing salary: 4238 
Percentage of records with missing salary: 25.32 %
In [240]:
# treat negative salary as missing values, fill all missing salary with 0

loyalty_df['Salary'] = loyalty_df['Salary'].fillna(0).where(loyalty_df['Salary'].gt(0), 0)
print('Number of records with salary = 0:', loyalty_df['Salary'].eq(0).sum())
Number of records with salary = 0: 4258

2.1.1.3 Time column¶

In [241]:
# EnrollmentDate (type: datetime), EnrollmentYearMonth (type: string, format: YYYY-MM)
# CancellationDate (type: datetime), CancellationYearMonth (type: string, format: YYYY-MM)

time_membership = ['Enrollment', 'Cancellation']

for i in time_membership: 
    loyalty_df[f'{i}Date'] = pd.to_datetime(
        loyalty_df[[f'{i}Year', f'{i}Month']].astype('Float64')\
        .rename(columns={f'{i}Year': 'YEAR', f'{i}Month': 'MONTH'}).assign(DAY=1)
    )
    loyalty_df[f'{i}YearMonth'] = loyalty_df[f'{i}Date'].dt.strftime('%Y-%m')
    
loyalty_df.head()
Out[241]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard CLV EnrollmentType EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth EnrollmentDate EnrollmentYearMonth CancellationDate CancellationYearMonth
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star 3839.14 Standard 2016 2 <NA> <NA> 2016-02-01 2016-02 NaT NaN
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star 3839.61 Standard 2016 3 <NA> <NA> 2016-03-01 2016-03 NaT NaN
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star 3839.75 Standard 2014 7 2018 1 2014-07-01 2014-07 2018-01-01 2018-01
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star 3839.75 Standard 2013 2 <NA> <NA> 2013-02-01 2013-02 NaT NaN
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star 3842.79 Standard 2014 10 <NA> <NA> 2014-10-01 2014-10 NaT NaN

2.1.1.4 Churn indicator¶

In [242]:
loyalty_df['ChurnIndicator'] = np.where(loyalty_df['CancellationMonth'].isna().eq(False), 1, 0)
In [243]:
loyalty_df.head()
Out[243]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... EnrollmentType EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth EnrollmentDate EnrollmentYearMonth CancellationDate CancellationYearMonth ChurnIndicator
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... Standard 2016 2 <NA> <NA> 2016-02-01 2016-02 NaT NaN 0
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... Standard 2016 3 <NA> <NA> 2016-03-01 2016-03 NaT NaN 0
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... Standard 2014 7 2018 1 2014-07-01 2014-07 2018-01-01 2018-01 1
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... Standard 2013 2 <NA> <NA> 2013-02-01 2013-02 NaT NaN 0
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... Standard 2014 10 <NA> <NA> 2014-10-01 2014-10 NaT NaN 0

5 rows × 21 columns

2.1.2 Flight activity (flight_df)¶

Summary:

  • Convert data type of LoyaltyNumber
  • Create 2 columns: Date, YearMonth

2.1.2.1 Converting data type¶

In [244]:
# convert data type

flight_df['LoyaltyNumber'] = flight_df['LoyaltyNumber'].astype(str)

2.1.2.2 Checking numeric column¶

In [245]:
flight_df.describe()
Out[245]:
Year Month FlightsBooked FlightsWithCompanions TotalFlights Distance PointsAccumulated PointsRedeemed DollarCostPointsRedeemed
count 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000
mean 2017.500000 6.500000 4.115052 1.031805 5.146858 1208.880059 123.692721 30.696872 2.484503
std 0.500001 3.452057 5.225518 2.076869 6.521227 1433.155320 146.599831 125.486049 10.150038
min 2017.000000 1.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 2017.000000 3.750000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
50% 2017.500000 6.500000 1.000000 0.000000 1.000000 488.000000 50.000000 0.000000 0.000000
75% 2018.000000 9.250000 8.000000 1.000000 10.000000 2336.000000 239.000000 0.000000 0.000000
max 2018.000000 12.000000 21.000000 11.000000 32.000000 6293.000000 676.500000 876.000000 71.000000

2.1.2.3 Time column¶

In [246]:
flight_df['Date'] = pd.to_datetime(flight_df[['Year', 'Month']].assign(DAY=1))
flight_df['YearMonth'] = flight_df['Date'].dt.strftime('%Y-%m')

2.1.3 Data cleansing result¶

In [247]:
# loyalty history
display(loyalty_df.info(), loyalty_df.head(), loyalty_df.describe())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 16737 entries, 0 to 16736
Data columns (total 21 columns):
 #   Column                 Non-Null Count  Dtype         
---  ------                 --------------  -----         
 0   LoyaltyNumber          16737 non-null  object        
 1   Country                16737 non-null  object        
 2   Province               16737 non-null  object        
 3   City                   16737 non-null  object        
 4   PostalCode             16737 non-null  object        
 5   Gender                 16737 non-null  object        
 6   Education              16737 non-null  object        
 7   Salary                 16737 non-null  float64       
 8   MaritalStatus          16737 non-null  object        
 9   LoyaltyCard            16737 non-null  object        
 10  CLV                    16737 non-null  float64       
 11  EnrollmentType         16737 non-null  object        
 12  EnrollmentYear         16737 non-null  int64         
 13  EnrollmentMonth        16737 non-null  int64         
 14  CancellationYear       2067 non-null   Int64         
 15  CancellationMonth      2067 non-null   Int64         
 16  EnrollmentDate         16737 non-null  datetime64[ns]
 17  EnrollmentYearMonth    16737 non-null  object        
 18  CancellationDate       2067 non-null   datetime64[ns]
 19  CancellationYearMonth  2067 non-null   object        
 20  ChurnIndicator         16737 non-null  int32         
dtypes: Int64(2), datetime64[ns](2), float64(2), int32(1), int64(2), object(12)
memory usage: 2.6+ MB
None
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... EnrollmentType EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth EnrollmentDate EnrollmentYearMonth CancellationDate CancellationYearMonth ChurnIndicator
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... Standard 2016 2 <NA> <NA> 2016-02-01 2016-02 NaT NaN 0
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... Standard 2016 3 <NA> <NA> 2016-03-01 2016-03 NaT NaN 0
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... Standard 2014 7 2018 1 2014-07-01 2014-07 2018-01-01 2018-01 1
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... Standard 2013 2 <NA> <NA> 2013-02-01 2013-02 NaT NaN 0
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... Standard 2014 10 <NA> <NA> 2014-10-01 2014-10 NaT NaN 0

5 rows × 21 columns

Salary CLV EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth EnrollmentDate CancellationDate ChurnIndicator
count 16737.000000 16737.000000 16737.000000 16737.000000 2067.0 2067.0 16737 2067 16737.000000
mean 59222.180618 7988.896536 2015.253211 6.669116 2016.503145 6.962748 2015-09-22 11:45:55.977773824 2016-12-30 23:07:03.222060800 0.123499
min 0.000000 1898.010000 2012.000000 1.000000 2013.0 1.0 2012-04-01 00:00:00 2013-01-01 00:00:00 0.000000
25% 0.000000 3980.840000 2014.000000 4.000000 2016.0 4.0 2014-01-01 00:00:00 2016-01-01 00:00:00 0.000000
50% 63654.000000 5780.180000 2015.000000 7.000000 2017.0 7.0 2015-11-01 00:00:00 2017-04-01 00:00:00 0.000000
75% 82940.000000 8940.580000 2017.000000 10.000000 2018.0 10.0 2017-07-01 00:00:00 2018-03-01 00:00:00 0.000000
max 407228.000000 83325.380000 2018.000000 12.000000 2018.0 12.0 2018-12-01 00:00:00 2018-12-01 00:00:00 1.000000
std 45781.737001 6860.982280 1.979111 3.398958 1.380743 3.455297 NaN NaN 0.329019
In [248]:
# flight activity
display(flight_df.info(), flight_df.head(), flight_df.describe())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 405624 entries, 0 to 405623
Data columns (total 12 columns):
 #   Column                    Non-Null Count   Dtype         
---  ------                    --------------   -----         
 0   LoyaltyNumber             405624 non-null  object        
 1   Year                      405624 non-null  int64         
 2   Month                     405624 non-null  int64         
 3   FlightsBooked             405624 non-null  int64         
 4   FlightsWithCompanions     405624 non-null  int64         
 5   TotalFlights              405624 non-null  int64         
 6   Distance                  405624 non-null  int64         
 7   PointsAccumulated         405624 non-null  float64       
 8   PointsRedeemed            405624 non-null  int64         
 9   DollarCostPointsRedeemed  405624 non-null  int64         
 10  Date                      405624 non-null  datetime64[ns]
 11  YearMonth                 405624 non-null  object        
dtypes: datetime64[ns](1), float64(1), int64(8), object(2)
memory usage: 37.1+ MB
None
LoyaltyNumber Year Month FlightsBooked FlightsWithCompanions TotalFlights Distance PointsAccumulated PointsRedeemed DollarCostPointsRedeemed Date YearMonth
0 100018 2017 1 3 0 3 1521 152.0 0 0 2017-01-01 2017-01
1 100102 2017 1 10 4 14 2030 203.0 0 0 2017-01-01 2017-01
2 100140 2017 1 6 0 6 1200 120.0 0 0 2017-01-01 2017-01
3 100214 2017 1 0 0 0 0 0.0 0 0 2017-01-01 2017-01
4 100272 2017 1 0 0 0 0 0.0 0 0 2017-01-01 2017-01
Year Month FlightsBooked FlightsWithCompanions TotalFlights Distance PointsAccumulated PointsRedeemed DollarCostPointsRedeemed Date
count 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624.000000 405624
mean 2017.500000 6.500000 4.115052 1.031805 5.146858 1208.880059 123.692721 30.696872 2.484503 2017-12-15 23:59:59.999999744
min 2017.000000 1.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 2017-01-01 00:00:00
25% 2017.000000 3.750000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 2017-06-23 12:00:00
50% 2017.500000 6.500000 1.000000 0.000000 1.000000 488.000000 50.000000 0.000000 0.000000 2017-12-16 12:00:00
75% 2018.000000 9.250000 8.000000 1.000000 10.000000 2336.000000 239.000000 0.000000 0.000000 2018-06-08 12:00:00
max 2018.000000 12.000000 21.000000 11.000000 32.000000 6293.000000 676.500000 876.000000 71.000000 2018-12-01 00:00:00
std 0.500001 3.452057 5.225518 2.076869 6.521227 1433.155320 146.599831 125.486049 10.150038 NaN

2.2 Feature engineering¶

2.2.1 Customer level¶

In [249]:
# Number of months of membership

loyalty_df = loyalty_df.assign(
    ObservationDate = lambda x: x['CancellationDate'].where(x['CancellationDate'].isna().eq(False), flight_df['Date'].max()),
    MonthsOfMembership = lambda x: x.apply(lambda z: calculate_month_interval(z['EnrollmentDate'], z['ObservationDate']), axis=1)
).drop(columns='ObservationDate')
In [250]:
# Aggregation on flight activity (sum)

flight_cols = [col for col in flight_df.columns if flight_df[col].dtype in ['int64', 'float64']][2:]
temp_total_flight = flight_df.groupby(by='LoyaltyNumber', sort=False)[flight_cols].sum()
temp_total_flight.columns = [f'Total{col}' for col in flight_cols]
temp_total_flight
Out[250]:
TotalFlightsBooked TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed
LoyaltyNumber
100018 157 35 192 50682 5376.00 1513 123
100102 173 42 215 40222 4115.25 1195 96
100140 152 38 190 41252 4184.25 593 48
100214 79 17 96 33982 3426.00 861 70
100272 127 36 163 40872 4108.04 1007 82
... ... ... ... ... ... ... ...
998972 80 24 104 31676 3164.00 2201 178
999304 46 5 51 10662 1065.00 809 65
999731 57 16 73 15598 1555.00 407 33
999788 42 16 58 15784 1575.00 361 29
999891 1 1 2 2526 252.00 414 34

16737 rows × 7 columns

In [251]:
# Percentage of Redeemed Points / Accumulated Points

temp_total_flight['PercentagePointsRedeemed'] = temp_total_flight['TotalPointsRedeemed'].where(
    temp_total_flight['TotalPointsRedeemed'].eq(0),
    temp_total_flight['TotalPointsRedeemed'].div(temp_total_flight['TotalPointsAccumulated']).round(2)
)
temp_total_flight['PercentagePointsRedeemed'].describe()
Out[251]:
count    16737.000000
mean         0.231936
std          0.277969
min          0.000000
25%          0.000000
50%          0.190000
75%          0.350000
max          5.200000
Name: PercentagePointsRedeemed, dtype: float64
In [252]:
# Aggregation on flight activity (Monthly average)

temp_monthly_flight = temp_total_flight[['TotalTotalFlights', 'TotalPointsAccumulated']].merge(
    loyalty_df[['LoyaltyNumber', 'MonthsOfMembership']].set_index('LoyaltyNumber'),
    how='left', left_index=True, right_index=True
).assign(
    MonthlyTotalFlights = lambda x: x['MonthsOfMembership'].where(x['MonthsOfMembership'].eq(0), 
                                                                  x['TotalTotalFlights'].div(x['MonthsOfMembership']).round(2)), 
    MonthlyPointsAccumulated = lambda x: x['MonthsOfMembership'].where(x['MonthsOfMembership'].eq(0), 
                                                                       x['TotalPointsAccumulated'].div(x['MonthsOfMembership']).round(2))
).iloc[:, 3:]

temp_monthly_flight.describe()
Out[252]:
MonthlyTotalFlights MonthlyPointsAccumulated
count 16737.000000 16737.000000
mean 5.160505 123.283008
std 9.252993 221.768018
min 0.000000 0.000000
25% 2.430000 58.880000
50% 3.790000 91.390000
75% 6.250000 150.720000
max 223.000000 5176.500000

Merge all feature sets to loyalty_df¶

In [253]:
loyalty_df = loyalty_df.merge(temp_total_flight.reset_index(), how='left', on='LoyaltyNumber')\
    .merge(temp_monthly_flight.reset_index(), how='left', on='LoyaltyNumber')
In [254]:
loyalty_df.head()
Out[254]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... TotalFlightsBooked TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... 132 39 171 51877 5224.44 1418 115 0.27 5.03 153.66
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... 190 25 215 41578 4176.04 1971 159 0.47 6.52 126.55
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... 66 21 87 19664 1963.00 374 30 0.19 2.07 46.74
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... 123 36 159 36043 3626.68 1291 105 0.36 2.27 51.81
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... 132 44 176 36840 3689.68 0 0 0.00 3.52 73.79

5 rows × 32 columns

2.2.2 Flight level¶

In [255]:
flight_df = flight_df.sort_values(by=['Date', 'LoyaltyNumber'])#.set_index('LoyaltyNumber')
TIME_INDEX = pd.DatetimeIndex(flight_df['Date'])
In [256]:
# Aggregation of flight stats for 1 year, 6 months, 3 months and 1 month lookback

lookback_period = {
    '1Y': '366D',
    '6M': '183D',
    '3M': '93D',
    '1M': '31D',
}

temp_lookback_flight_dfs = []

for last, day in lookback_period.items():
    df = flight_df.groupby(by='LoyaltyNumber', sort=False)[flight_cols]\
        .rolling(window=day, on=TIME_INDEX, closed='left').sum()\
        .reset_index().sort_values(by=['Date', 'LoyaltyNumber'])\
        .drop(columns='level_1').set_index(['LoyaltyNumber', 'Date'])
    df.columns = [f'Last{last}_{col}' for col in flight_cols]
    temp_lookback_flight_dfs.append(df)

lookback_flight_df = flight_df[['LoyaltyNumber', 'Date']]

for df in temp_lookback_flight_dfs:
    lookback_flight_df = lookback_flight_df.merge(df.reset_index(), how='left', on=['LoyaltyNumber', 'Date'])

lookback_flight_df
Out[256]:
LoyaltyNumber Date Last1Y_FlightsBooked Last1Y_FlightsWithCompanions Last1Y_TotalFlights Last1Y_Distance Last1Y_PointsAccumulated Last1Y_PointsRedeemed Last1Y_DollarCostPointsRedeemed Last6M_FlightsBooked ... Last3M_PointsAccumulated Last3M_PointsRedeemed Last3M_DollarCostPointsRedeemed Last1M_FlightsBooked Last1M_FlightsWithCompanions Last1M_TotalFlights Last1M_Distance Last1M_PointsAccumulated Last1M_PointsRedeemed Last1M_DollarCostPointsRedeemed
0 100018 2017-01-01 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 100102 2017-01-01 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 100140 2017-01-01 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 100214 2017-01-01 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 100272 2017-01-01 NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
528019 999902 2018-12-01 87.0 15.0 102.0 28122.0 3149.5 384.0 31.0 37.0 ... 120.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
528020 999911 2018-12-01 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
528021 999940 2018-12-01 51.0 18.0 69.0 15929.0 1668.0 672.0 54.0 33.0 ... 615.0 0.0 0.0 12.0 5.0 17.0 3859.0 385.0 0.0 0.0
528022 999982 2018-12-01 22.0 2.0 24.0 5948.0 594.0 0.0 0.0 22.0 ... 535.0 0.0 0.0 3.0 0.0 3.0 1560.0 156.0 0.0 0.0
528023 999986 2018-12-01 49.0 10.0 59.0 13665.0 1495.0 1199.0 97.0 40.0 ... 274.0 0.0 0.0 11.0 4.0 15.0 2040.0 204.0 0.0 0.0

528024 rows × 30 columns

In [257]:
# Months of membership

temp_mom_lookback = flight_df[['LoyaltyNumber', 'Date']].merge(loyalty_df[['LoyaltyNumber', 'EnrollmentDate', 'CancellationDate']], how='left', on='LoyaltyNumber')\
    .assign(MonthsOfMembership = lambda x: x.apply(lambda z: calculate_month_interval(z['EnrollmentDate'], z['Date']), axis=1))
    
temp_mom_lookback
Out[257]:
LoyaltyNumber Date EnrollmentDate CancellationDate MonthsOfMembership
0 100018 2017-01-01 2016-08-01 NaT 5
1 100102 2017-01-01 2013-03-01 NaT 46
2 100140 2017-01-01 2016-07-01 NaT 6
3 100214 2017-01-01 2015-08-01 NaT 17
4 100272 2017-01-01 2014-01-01 NaT 36
... ... ... ... ... ...
405619 999902 2018-12-01 2014-05-01 NaT 55
405620 999911 2018-12-01 2012-08-01 NaT 76
405621 999940 2018-12-01 2017-07-01 NaT 17
405622 999982 2018-12-01 2018-07-01 NaT 5
405623 999986 2018-12-01 2018-02-01 NaT 10

405624 rows × 5 columns

3 Exploratory Data Analysis¶

3.1 Customer Distribution¶

In [258]:
cust_eda_loyalty_df = loyalty_df.copy()
In [259]:
# Confirm that dataset only contains Canadian customers
cust_eda_loyalty_df["Country"].value_counts()
Out[259]:
Country
Canada    16737
Name: count, dtype: int64
In [260]:
cust_eda_loyalty_df['ChurnIndicator'] = cust_eda_loyalty_df['ChurnIndicator'].astype("category")
In [261]:
flights_summary = cust_eda_loyalty_df.groupby('MaritalStatus').agg(
    {'TotalTotalFlights':['sum'],
     'TotalFlightsWithCompanions': ['sum']}
).reset_index()

# Calculate the percentage of flights with companions
flights_summary['PercentageWithCompanions'] = (flights_summary['TotalFlightsWithCompanions'] / flights_summary['TotalTotalFlights']) * 100

# Display the summary DataFrame
print(flights_summary)
  MaritalStatus TotalTotalFlights TotalFlightsWithCompanions  \
                              sum                        sum   
0      Divorced            311069                      62289   
1       Married           1213745                     243295   
2        Single            562875                     112941   

  PercentageWithCompanions  
                            
0                20.024175  
1                20.044985  
2                20.065023  

3.1.1 Salary Distribution¶

In [262]:
# Distribution of salary 

plt.figure(figsize=(6, 9))
sns.boxplot(data=cust_eda_loyalty_df, y='Salary', palette='viridis')
plt.title('Salary Distribution')
plt.ylabel('Salary')
plt.xticks(rotation=45)
plt.show()

3.1.2 Gender Distribution¶

In [263]:
# Check distribution by gender

x = 'Gender'
label = 'Gender'
order = ["Female","Male"]
rows, cols = 5, 3

# Calculate total flights booked by gender
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.Gender = total_flights.Gender.astype("category")
total_flights.Gender = total_flights.Gender.cat.set_categories(order)
total_flights.sort_values([x])

# Calculate proportion of churned customers by education level
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(loyalty_df['MonthsOfMembership'] >= 0) & (loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Create custom colour mapping to each gender
colour =  {'Female': 'salmon', 'Male': 'skyblue'}

# Define plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(20,25))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x, palette=colour)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x, palette=colour)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 3)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='CLV', hue=x, palette=colour)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x, palette=colour)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x, palette=colour)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x, palette=colour)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x, palette=colour)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x, palette=colour)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order, palette=colour)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=0)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue=x, ax=ax_big, palette=colour)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.3 Education Distribution¶

In [264]:
# Check distribution by education

x = 'Education'
label = 'Education Level'
order = ["Doctor", "Master", "Bachelor", "College", "High School or Below"]
rows, cols = 5, 3

# Calculate total flights booked by education level
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.Education = total_flights.Education.astype("category")
total_flights.Education = total_flights.Education.cat.set_categories(order)
total_flights.sort_values([x])

# Sort education level in cust_eda_loyalty_df in descending order
cust_eda_loyalty_df.Education = cust_eda_loyalty_df.Education.astype("category")
cust_eda_loyalty_df.Education = cust_eda_loyalty_df.Education.cat.set_categories(order)
cust_eda_loyalty_df.sort_values([x])

# Calculate proportion of churned customers by education level
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(cust_eda_loyalty_df['MonthsOfMembership'] >= 0) & (cust_eda_loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Define plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(20,25))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 3)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='CLV', hue=x)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=0)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue=x, ax=ax_big)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.4 Marital Status Distribution¶

In [265]:
# Check distribution by marital status

x = 'MaritalStatus'
label = "Marital Status"
order = ["Married","Divorced","Single"]
rows, cols = 5, 3

# Calculate total flights booked by marital status
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.MaritalStatus = total_flights.MaritalStatus.astype("category")
total_flights.MaritalStatus = total_flights.MaritalStatus.cat.set_categories(order)
total_flights.sort_values([x])

# Calculate proportion of churned customers by marital status
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(cust_eda_loyalty_df['MonthsOfMembership'] >= 0) & (cust_eda_loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Deine plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(20,25))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 3)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='CLV', hue=x)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=0)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue=x, ax=ax_big)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.5 Loyalty Card Type Distribution¶

In [266]:
# Check distribution by loyalty card type

x = 'LoyaltyCard'
label = "Loyalty Card"
order = ["Star", "Nova", "Aurora"]
rows, cols = 5, 3

# Calculate total flights booked by loyalty card type
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.LoyaltyCard = total_flights.LoyaltyCard.astype("category")
total_flights.LoyaltyCard = total_flights.LoyaltyCard.cat.set_categories(order)
total_flights.sort_values([x])

# Calculate proportion of churned customers by loyalty card type
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(cust_eda_loyalty_df['MonthsOfMembership'] >= 0) & (cust_eda_loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Define plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(20,25))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 3)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='CLV', hue=x)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=0)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue=x, ax=ax_big)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.6 City Distribution¶

In [267]:
# Check distribution by city

x = 'City'
label = "City"
rows, cols = 5, 3

# Create new df to sort in descending order of median of CLV
median_clv_df = loyalty_df.copy()
median_clv_df["City"] = pd.Categorical(median_clv_df["City"], categories=median_clv_df.groupby("City")['CLV'].median().sort_values(ascending=False).index, ordered=True)

# Calculate frequency of each city and sort in descending order
city_counts = cust_eda_loyalty_df[x].value_counts().reset_index()
city_counts.columns = [x, 'Count']
city_counts = city_counts.sort_values('Count', ascending=False)
order = city_counts[x].to_list()

# Sort city in cust_eda_loyalty_df in descending order of frequency
cust_eda_loyalty_df['City'] = pd.Categorical(cust_eda_loyalty_df['City'], categories=order, ordered=True)
cust_eda_loyalty_df = cust_eda_loyalty_df.sort_values('City')

# Calculate total flights booked by each city
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.City = total_flights.City.astype("category")
total_flights.City = total_flights.City.cat.set_categories(order)
total_flights.sort_values([x])

# Calculate proportion of churned customers by city
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(cust_eda_loyalty_df['MonthsOfMembership'] >= 0) & (cust_eda_loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Create custom colour mapping to each city
palette = cc.cm.glasbey_dark
colour = [cc.cm.glasbey_dark(i / len(cust_eda_loyalty_df["City"])) for i in range(len(cust_eda_loyalty_df["City"]))]
col_map = {city: col for city, col in zip(cust_eda_loyalty_df['City'], colour)}

# Define plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(30,40))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x, palette=col_map)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x, palette=col_map)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 3)
sns.boxplot(data=median_clv_df, x=x, y='CLV', hue=x, palette=col_map)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x, palette=col_map)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x, palette=col_map)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x, palette=col_map)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x, palette=col_map)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x, palette=col_map)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order, palette=col_map)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)
plt.xticks(rotation=80)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')
plt.xticks(rotation=80)

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=80)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue=x, ax=ax_big, palette=col_map)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.7 Enrollment Type Distribution¶

In [268]:
# Distribution by Enrollment Type

x = 'EnrollmentType'
label = "Enrollment Type"
order = ["Standard","2018 Promotion"]
rows, cols = 5, 3

# Calculate total flights booked by enrollment type
total_flights = cust_eda_loyalty_df.groupby(x)['TotalTotalFlights'].sum().reset_index()
total_flights.EnrollmentType = total_flights.EnrollmentType.astype("category")
total_flights.EnrollmentType = total_flights.EnrollmentType.cat.set_categories(order)
total_flights.sort_values([x])

# Calculate proportion of churned customers by enrollment type
churn_proportions = cust_eda_loyalty_df.groupby(x)['ChurnIndicator'].value_counts(normalize=True).unstack().fillna(0)
churn_proportions.columns = ['Not Churn', 'Churn']
churn_proportions['Not Churn'] *= 100
churn_proportions['Churn'] *= 100
churn_proportions.sort_values('Churn', ascending=False, inplace=True)

# Focus on months of membership between 0 to 12 months
mom12 = cust_eda_loyalty_df[(cust_eda_loyalty_df['MonthsOfMembership'] >= 0) & (cust_eda_loyalty_df['MonthsOfMembership'] <= 12)]
mom12[x] = pd.Categorical(mom12[x], categories=order, ordered=True)
mom12 = mom12.sort_values(x)

# Define plot area
fig, axs = plt.subplots(nrows = rows, ncols = cols, figsize=(20,25))

# Create plots
plt.subplot(rows, cols, 1)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue=x)
plt.title(f'Frequency of Customers by {label}')
plt.xlabel(label)
plt.ylabel('Count')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 2)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='Salary', hue=x)
plt.title(f'Distribution of Salary by {label}')
plt.xlabel(label)
plt.ylabel('Salary')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 3)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='CLV', hue=x)
plt.title(f'Distribution of CLV by {label}')
plt.xlabel(label)
plt.ylabel('CLV')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 4)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='MonthsOfMembership', hue=x)
plt.title(f'Distribution of Membership Length (Months) by {label}')
plt.xlabel(label)
plt.ylabel('Membership Length (Months)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 5)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsBooked', hue=x)
plt.title(f'Distribution of Individual Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Individual Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 6)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalFlightsWithCompanions', hue=x)
plt.title(f'Distribution of Flights with Companions by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights with Companions Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 7)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalTotalFlights', hue=x)
plt.title(f'Distribution of Total Flights by {label}')
plt.xlabel(label)
plt.ylabel('Total Flights Booked')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 8)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='TotalDistance', hue=x)
plt.title(f'Distribution of Total Distance Travelled by {label}')
plt.xlabel(label)
plt.ylabel('Total Distance')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 9)
sns.boxplot(data=cust_eda_loyalty_df, x=x, y='PercentagePointsRedeemed', hue=x, order=order)
plt.title(f'Distribution of Points Redeemed by {label}')
plt.xlabel(label)
plt.ylabel('Points Redeemed (%)')
plt.legend([], [], frameon=False)

plt.subplot(rows, cols, 10)
sns.countplot(data=cust_eda_loyalty_df, x=x, hue='ChurnIndicator', palette="viridis")
plt.title(f'Frequency of Churn by {label}')
plt.xlabel(label)
plt.ylabel('Churn Frequency')
plt.legend(title='Churn Indicator', loc='upper right')

churn_proportions.plot(kind='bar', stacked=True, ax = axs[3,1], color=['steelblue', 'mediumseagreen'])
axs[3,1].set(title = f'Proportion of Churn by {label}',
             xlabel = label,
             ylabel = 'Churn Proportion (%)')
axs[3,1].set_xticklabels(axs[3,1].get_xticklabels(), rotation=0)

gs = gridspec.GridSpec(rows, cols, figure=fig)
ax_big = fig.add_subplot(gs[4, 0:])
sns.countplot(data=mom12, x='MonthsOfMembership', hue='EnrollmentType', ax=ax_big)
ax_big.set(title=f'Count of Months of Membership by {label}',
           xlabel="Months of Membership",
           ylabel='Count')

axs[3,2].set_axis_off()
axs[4,0].set_axis_off()
axs[4,1].set_axis_off()
axs[4,2].set_axis_off()
plt.tight_layout()

plt.show()

3.1.8 CLV Distribution¶

In [269]:
# Check distribution by CLV

plt.figure(figsize=(20,25))

plt.subplot(3, 3, 1)
sns.lineplot(data=cust_eda_loyalty_df, x='EnrollmentYear', y='CLV', ci=None, marker='o', sort=True)
plt.title('Customer Lifetime Value by Enrollment Year')
plt.xlabel('Enrollment Year')
plt.ylabel('Customer Lifetime Value')

plt.subplot(3, 3, 2)
sns.lineplot(data=cust_eda_loyalty_df, x='City', y='CLV', ci=None, marker='o', sort=True)
plt.title('Customer Lifetime Value by City')
plt.xlabel('City')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.subplot(3, 3, 4)
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['Salary'], cust_eda_loyalty_df['CLV'])
sns.regplot(data=cust_eda_loyalty_df, x='Salary', y='CLV', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Customer Lifetime Value by Salary')
plt.xlabel('Salary')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.subplot(3, 3, 5)
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['TotalTotalFlights'], cust_eda_loyalty_df['CLV'])
sns.regplot(data=cust_eda_loyalty_df, x='TotalTotalFlights', y='CLV', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Customer Lifetime Value by Total Flights Booked')
plt.xlabel('Total Flights Booked')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.subplot(3, 3, 6)
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['TotalDistance'], cust_eda_loyalty_df['CLV'])
sns.regplot(data=cust_eda_loyalty_df, x='TotalDistance', y='CLV', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Customer Lifetime Value by Total Distance Travelled')
plt.xlabel('Total Distance Travelled')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.subplot(3, 3, 7)
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['MonthsOfMembership'], cust_eda_loyalty_df['CLV'])
sns.regplot(data=cust_eda_loyalty_df, x='MonthsOfMembership', y='CLV', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Customer Lifetime Value by Months of Membership')
plt.xlabel('Months of Membership')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.subplot(3, 3, 8)
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['TotalPointsAccumulated'], cust_eda_loyalty_df['CLV'])
sns.regplot(data=cust_eda_loyalty_df, x='TotalPointsAccumulated', y='CLV', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Customer Lifetime Value by Total Points Accumulated')
plt.xlabel('Total Points Accumulated')
plt.ylabel('Customer Lifetime Value')
plt.xticks(rotation=80)

plt.show()

3.1.8.1 Kruskal-Wallis Test for Categorical Features and CLV¶

In [270]:
# Assuming 'continuous_var' is your continuous variable and 'nominal_var' is your nominal variable
def kruskal_wallis(df, cont_var, nom_var):
    groups = [df[df[f'{nom_var}'] == category][f'{cont_var}'] for category in df[f'{nom_var}'].unique()]
    kruskal_result = stats.kruskal(*groups)
    print(f"Kruskal-Wallis H-statistic for {nom_var}: {kruskal_result.statistic}, p-value: {kruskal_result.pvalue}")
In [271]:
for i in ['MaritalStatus', 'Education', 'LoyaltyCard']:
    kruskal_wallis(loyalty_df, 'CLV', i)
Kruskal-Wallis H-statistic for MaritalStatus: 32.67197728755935, p-value: 8.042114934653465e-08
Kruskal-Wallis H-statistic for Education: 82.73214822370325, p-value: 4.5915624634674436e-17
Kruskal-Wallis H-statistic for LoyaltyCard: 2204.896056033002, p-value: 0.0
In [272]:
### 3.2.4 Relationship between Points Accumulated and Points Redeemed
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['TotalPointsAccumulated'], cust_eda_loyalty_df['TotalPointsRedeemed'])
sns.regplot(data=cust_eda_loyalty_df, x='TotalPointsAccumulated', y='TotalPointsRedeemed', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Points Accumulated by Points Redeemed')
plt.xlabel('Total Points Accumulated')
plt.ylabel('Total Points Redeemed')
plt.xticks(rotation=80)
Out[272]:
(array([-2000.,     0.,  2000.,  4000.,  6000.,  8000., 10000., 12000.]),
 [Text(-2000.0, 0, '−2000'),
  Text(0.0, 0, '0'),
  Text(2000.0, 0, '2000'),
  Text(4000.0, 0, '4000'),
  Text(6000.0, 0, '6000'),
  Text(8000.0, 0, '8000'),
  Text(10000.0, 0, '10000'),
  Text(12000.0, 0, '12000')])

3.1.9 Relationship between Points Accumulated and Points Redeemed¶

In [273]:
corr_coeff, _ = pearsonr(cust_eda_loyalty_df['TotalPointsAccumulated'], cust_eda_loyalty_df['TotalPointsRedeemed'])
sns.regplot(data=cust_eda_loyalty_df, x='TotalPointsAccumulated', y='TotalPointsRedeemed', 
            scatter_kws={'s': 50, 'alpha': 0.9, 'edgecolor': 'white'}, 
            line_kws={'color': 'red'}, marker="o")
plt.text(0.1, 0.9, f'Correlation Coefficient: {corr_coeff:.2f}', 
         ha='left', va='baseline', transform=plt.gca().transAxes)
plt.title('Points Accumulated by Points Redeemed')
plt.xlabel('Total Points Accumulated')
plt.ylabel('Total Points Redeemed')
plt.xticks(rotation=80)
Out[273]:
(array([-2000.,     0.,  2000.,  4000.,  6000.,  8000., 10000., 12000.]),
 [Text(-2000.0, 0, '−2000'),
  Text(0.0, 0, '0'),
  Text(2000.0, 0, '2000'),
  Text(4000.0, 0, '4000'),
  Text(6000.0, 0, '6000'),
  Text(8000.0, 0, '8000'),
  Text(10000.0, 0, '10000'),
  Text(12000.0, 0, '12000')])

3.2 Potential impact of 2018 Promotion¶

In [274]:
year_enrollment = cust_eda_loyalty_df.groupby('EnrollmentYear')['EnrollmentYear'].value_counts().reset_index()
year_enrollment['growth'] = year_enrollment['count'].pct_change()*100
year_enrollment = year_enrollment.fillna(0)
year_enrollment['EnrollmentYear'] = year_enrollment['EnrollmentYear'].astype('str')
year_enrollment
Out[274]:
EnrollmentYear count growth
0 2012 1686 0.000000
1 2013 2397 42.170819
2 2014 2370 -1.126408
3 2015 2331 -1.645570
4 2016 2456 5.362505
5 2017 2487 1.262215
6 2018 3010 21.029353
In [275]:
fig, axs = plt.subplots(nrows = 1, ncols = 3, figsize = (15,5))


sns.lineplot(year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])], x = 'EnrollmentYear', y='growth',label='growth', ax=axs[0])
axs[0].set_title('Percentage change in # of customers')
for x, y in zip(year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])]['EnrollmentYear'], year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])]['growth']):
    axs[0].text(x, y, f'{y:.2f}%', ha='center', va='bottom')

sns.lineplot(year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])], x = 'EnrollmentYear', y='count',label='count', ax=axs[1])
axs[1].set_title('Change in # of customers')
for x, y in zip(year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])]['EnrollmentYear'], year_enrollment[~year_enrollment['EnrollmentYear'].isin(['2012','2013'])]['count']):
    axs[1].text(x, y, f'{y}', ha='center', va='bottom')

pivot_plot = loyalty_df.groupby(['EnrollmentYear','EnrollmentType']).size().reset_index().pivot(columns='EnrollmentType',index = 'EnrollmentYear', values = 0)
# Reorder the columns
desired_order = ['Standard', '2018 Promotion']
pivot_plot = pivot_plot[desired_order]
# sns.countplot(loyalty_df[~loyalty_df['EnrollmentYear'].isin(['2012','2013'])], x = 'EnrollmentYear', hue = 'EnrollmentType', multiple='stack', hue_order=['2018 Promotion', 'Standard'])
pivot_plot.plot(kind='bar', stacked=True, ax = axs[2])
axs[2].set_title('Change in # of customers')

plt.tight_layout()
In [276]:
# Cancellation by year and month

cancellations_heatmap = cust_eda_loyalty_df.pivot_table(values='LoyaltyNumber', index='CancellationYear', columns='CancellationMonth', aggfunc='count', fill_value=0)

plt.figure(figsize=(12, 8))
sns.heatmap(cancellations_heatmap, annot=True, fmt='d', cmap='viridis')
plt.title('Heatmap of Cancellations by Year and Month')
plt.xlabel('Cancellation Month')
plt.ylabel('Cancellation Year')
plt.show()

3.2.1 Number of Enrollments and Cancellations¶

In [277]:
enrollments = cust_eda_loyalty_df['EnrollmentDate'].dt.to_period('M').value_counts().sort_index()
cancellations = cust_eda_loyalty_df['CancellationDate'].dropna().dt.to_period('M').value_counts().sort_index()

plt.figure(figsize=(14, 8))
plt.plot(enrollments.index.astype(str), enrollments.values, label='Enrollments', marker='o')
plt.plot(cancellations.index.astype(str), cancellations.values, label='Cancellations', marker='o')
plt.title('Number of Enrollments and Cancellations Over Time')
plt.xlabel('Date (Year-Month)')
plt.ylabel('Count')
plt.xticks(rotation=90)
plt.legend()
plt.show()

3.2.2 Number of Enrollments with/without 2018 Promotions¶

In [278]:
enrollments_2018 = cust_eda_loyalty_df.groupby([loyalty_df['EnrollmentDate'].dt.to_period('M'), 'EnrollmentType']).size().unstack(fill_value=0)
enrollment_order = ['Standard', '2018 Promotion']
enrollments_2018 = enrollments_2018[enrollment_order]

plt.figure(figsize=(14, 8))
for column in enrollments_2018.columns:
    plt.plot(enrollments_2018.index.astype(str), enrollments_2018[column], label=column, marker='o')
plt.xlabel('Date')
plt.ylabel('Enrollments')
plt.title('Enrollments by Enrollment Type')
plt.legend(title='Enrollment Type')
plt.xticks(rotation=90)
plt.show()

3.2.3 Additional Exploration on 2018 Promotion¶

In [279]:
loyalty_df2018 = cust_eda_loyalty_df[cust_eda_loyalty_df['EnrollmentYear'] == 2018]
In [280]:
loyalty_df2018.info()
<class 'pandas.core.frame.DataFrame'>
Index: 3010 entries, 10966 to 13406
Data columns (total 32 columns):
 #   Column                         Non-Null Count  Dtype         
---  ------                         --------------  -----         
 0   LoyaltyNumber                  3010 non-null   object        
 1   Country                        3010 non-null   object        
 2   Province                       3010 non-null   object        
 3   City                           3010 non-null   category      
 4   PostalCode                     3010 non-null   object        
 5   Gender                         3010 non-null   object        
 6   Education                      3010 non-null   category      
 7   Salary                         3010 non-null   float64       
 8   MaritalStatus                  3010 non-null   object        
 9   LoyaltyCard                    3010 non-null   object        
 10  CLV                            3010 non-null   float64       
 11  EnrollmentType                 3010 non-null   object        
 12  EnrollmentYear                 3010 non-null   int64         
 13  EnrollmentMonth                3010 non-null   int64         
 14  CancellationYear               159 non-null    Int64         
 15  CancellationMonth              159 non-null    Int64         
 16  EnrollmentDate                 3010 non-null   datetime64[ns]
 17  EnrollmentYearMonth            3010 non-null   object        
 18  CancellationDate               159 non-null    datetime64[ns]
 19  CancellationYearMonth          159 non-null    object        
 20  ChurnIndicator                 3010 non-null   category      
 21  MonthsOfMembership             3010 non-null   int64         
 22  TotalFlightsBooked             3010 non-null   int64         
 23  TotalFlightsWithCompanions     3010 non-null   int64         
 24  TotalTotalFlights              3010 non-null   int64         
 25  TotalDistance                  3010 non-null   int64         
 26  TotalPointsAccumulated         3010 non-null   float64       
 27  TotalPointsRedeemed            3010 non-null   int64         
 28  TotalDollarCostPointsRedeemed  3010 non-null   int64         
 29  PercentagePointsRedeemed       3010 non-null   float64       
 30  MonthlyTotalFlights            3010 non-null   float64       
 31  MonthlyPointsAccumulated       3010 non-null   float64       
dtypes: Int64(2), category(3), datetime64[ns](2), float64(6), int64(9), object(10)
memory usage: 721.7+ KB
In [281]:
# Calculate count and percentage for each enrollment type
enrollment_counts = loyalty_df2018['EnrollmentType'].value_counts()
enrollment_percentage = (enrollment_counts / len(loyalty_df2018)) * 100

# Combine count and percentage into a DataFrame
enrollment_summary = pd.DataFrame({'Count': enrollment_counts, 'Percentage': enrollment_percentage})

print(enrollment_summary)
                Count  Percentage
EnrollmentType                   
Standard         2039   67.740864
2018 Promotion    971   32.259136
In [282]:
# Plot churn indicator by enrollment type
plt.figure(figsize=(10, 6))
ax = sns.countplot(data=loyalty_df2018, x='EnrollmentType', hue='ChurnIndicator')

# Calculate total count of each enrollment type
total_counts = loyalty_df2018['EnrollmentType'].value_counts()

# Add percentage labels
for container in ax.containers:
    total_height = sum([patch.get_height() for patch in container.patches])
    for patch, en_type in zip(container.patches, total_counts.index):
        height = patch.get_height()
        percentage = height / total_counts[en_type]
        ax.annotate(f'{percentage:.1%}', 
                    xy=(patch.get_x() + patch.get_width() / 2, height), 
                    xytext=(0, 3),  # 3 points vertical offset
                    textcoords="offset points",
                    ha='center', va='bottom', fontsize=8)

plt.title('Churn Indicator by Enrollment Type')
plt.xlabel('Enrollment Type')
plt.ylabel('Count')
plt.legend(title='Churn Indicator', loc='upper right')
plt.show()
In [283]:
from scipy.stats import chi2_contingency

# Create a contingency table of churn indicator by enrollment type
contingency_table = pd.crosstab(loyalty_df2018['EnrollmentType'], loyalty_df2018['ChurnIndicator'])

# Perform the chi-square test
chi2, p, dof, expected = chi2_contingency(contingency_table)

print("Chi-square statistic:", chi2)
print("P-value:", p)
print("Degrees of freedom:", dof)
print("Expected frequencies table:")
print(expected)
Chi-square statistic: 121.39824875372153
P-value: 3.126313190708848e-28
Degrees of freedom: 1
Expected frequencies table:
[[ 919.70797342   51.29202658]
 [1931.29202658  107.70797342]]
In [284]:
plt.figure(figsize=(10, 10))
sns.boxplot(data=loyalty_df2018, x='EnrollmentType', y='CLV')
plt.title('CLV by Enrollment Type in 2018')
plt.xlabel('Enrollment Type')
plt.ylabel('CLV')
plt.show()
In [285]:
from scipy.stats import ttest_ind

# Extract CLV values for each EnrollmentType group
group1 = loyalty_df2018[loyalty_df2018['EnrollmentType'] == 'Standard']['CLV']
group2 = loyalty_df2018[loyalty_df2018['EnrollmentType'] == '2018 Promotion']['CLV']

# Perform independent t-test
t_statistic, p_value = ttest_ind(group1, group2, equal_var=False)

print(f'T-statistic: {t_statistic}')
print(f'p-value: {p_value}')
T-statistic: -0.13740317545689854
p-value: 0.8907276449119362

3.3 Churn customers¶

In [286]:
# get all churn customers

churn_monthsofmembership = loyalty_df[loyalty_df['ChurnIndicator'].eq(1)]['MonthsOfMembership'].value_counts().reset_index()\
    .sort_values(by='MonthsOfMembership')
    
# distribution of months of membership

plt.figure(figsize=(7,5))
sns.lineplot(data=churn_monthsofmembership, x='MonthsOfMembership', y='count')
plt.xlabel('Months of membership')
plt.ylabel('Number of customers')
plt.yticks(np.arange(0, 1400, 200))
plt.title('Distribution of all customers by months of membership')
Out[286]:
Text(0.5, 1.0, 'Distribution of all customers by months of membership')
In [287]:
# get all churn customers with Standard Enrollment Type

churn_monthsofmembership_standard = loyalty_df[loyalty_df['ChurnIndicator'].eq(1) & loyalty_df['EnrollmentType'].ne('2018 Promotion')]\
    ['MonthsOfMembership'].value_counts().reset_index()\
    .sort_values(by='MonthsOfMembership')

# distribution of months of membership

plt.figure(figsize=(7,5))
sns.lineplot(data=churn_monthsofmembership_standard, x='MonthsOfMembership', y='count')
plt.xlabel('Months of membership')
plt.ylabel('Number of customers')
plt.yticks(np.arange(0, 1400, 200))
plt.title('Distribution of standard customers by months of membership')
Out[287]:
Text(0.5, 1.0, 'Distribution of standard customers by months of membership')

3.3.1 Additional Exploration on Churn¶

In [288]:
continuous_vars = [
    'Salary', 'CLV', 'MonthsOfMembership', 'TotalFlightsBooked',
    'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
    'TotalPointsAccumulated', 'TotalPointsRedeemed', 'TotalDollarCostPointsRedeemed',
    'PercentagePointsRedeemed'
]

categorical_vars = ['Gender', 'Education', 'MaritalStatus', 'LoyaltyCard', 'EnrollmentType', 'Province', 'City']

# Convert `ChurnIndicator` to boolean for point-biserial correlation
loyalty_df['ChurnIndicator_bool'] = loyalty_df['ChurnIndicator'].astype(bool)
In [289]:
# Point-biserial correlation between ChurnIndicator and continuous variables
for var in continuous_vars:
    correlation, p_value = pointbiserialr(loyalty_df['ChurnIndicator'], loyalty_df[var])
    print(f'Point-biserial correlation between ChurnIndicator and {var}: {correlation:.2f}, p-value: {p_value}')
Point-biserial correlation between ChurnIndicator and Salary: -0.00, p-value: 0.8618576547393644
Point-biserial correlation between ChurnIndicator and CLV: 0.01, p-value: 0.3118873451918476
Point-biserial correlation between ChurnIndicator and MonthsOfMembership: -0.30, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalFlightsBooked: -0.48, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalFlightsWithCompanions: -0.42, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalTotalFlights: -0.48, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalDistance: -0.49, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalPointsAccumulated: -0.49, p-value: 0.0
Point-biserial correlation between ChurnIndicator and TotalPointsRedeemed: -0.27, p-value: 1.0250023372563784e-286
Point-biserial correlation between ChurnIndicator and TotalDollarCostPointsRedeemed: -0.27, p-value: 5.016401724085526e-287
Point-biserial correlation between ChurnIndicator and PercentagePointsRedeemed: -0.11, p-value: 7.426373817580738e-48
In [290]:
# Calculate point-biserial correlations and p-values
correlation_results = []

for var in continuous_vars:
    correlation, p_value = pointbiserialr(loyalty_df['ChurnIndicator'], loyalty_df[var])
    correlation_results.append({'Variable': var, 'Correlation': correlation, 'P-value': p_value})

# Convert the results into a DataFrame
correlation_df = pd.DataFrame(correlation_results)
correlation_df.sort_values(by='Correlation', inplace=True, ascending=False)

# Plot the correlations
plt.figure(figsize=(12, 8))
sns.barplot(x='Correlation', y='Variable', data=correlation_df, palette='coolwarm', orient='h')
plt.axvline(x=0, color='gray', linestyle='--')
plt.title('Point-Biserial Correlations between ChurnIndicator and Continuous Variables')
plt.xlabel('Correlation Coefficient')
plt.ylabel('Continuous Variable')

# Add p-value annotations 
for i, row in correlation_df.iterrows():
    # Format p-values in scientific notation with three decimal places
    p_value_str = "{:.3e}".format(row["P-value"])
    color = 'black' 
    x_coord = -0.005
    plt.text(x_coord, i, f'p={p_value_str}', color=color, va='center', ha='right', fontsize=8)

plt.show()
In [291]:
def cramers_v(confusion_matrix):
    chi2 = chi2_contingency(confusion_matrix)[0]
    n = confusion_matrix.sum()
    r, k = confusion_matrix.shape
    return np.sqrt(chi2 / (n * (min(r, k) - 1)))

for var in categorical_vars:
    contingency_table = pd.crosstab(loyalty_df['ChurnIndicator'], loyalty_df[var])
    cramers_v_value = cramers_v(contingency_table.to_numpy())
    print(f"Cramér's V between ChurnIndicator and {var}: {cramers_v_value:.2f}")
Cramér's V between ChurnIndicator and Gender: 0.01
Cramér's V between ChurnIndicator and Education: 0.01
Cramér's V between ChurnIndicator and MaritalStatus: 0.01
Cramér's V between ChurnIndicator and LoyaltyCard: 0.02
Cramér's V between ChurnIndicator and EnrollmentType: 0.00
Cramér's V between ChurnIndicator and Province: 0.03
Cramér's V between ChurnIndicator and City: 0.03
In [292]:
def cramers_v(confusion_matrix):
    chi2 = chi2_contingency(confusion_matrix)[0]
    n = confusion_matrix.sum()
    r, k = confusion_matrix.shape
    return np.sqrt(chi2 / (n * (min(r, k) - 1)))

# Assuming categorical_vars contains the list of categorical variables
cramers_v_values = []

for var in categorical_vars:
    contingency_table = pd.crosstab(loyalty_df['ChurnIndicator'], loyalty_df[var])
    cramers_v_value = cramers_v(contingency_table.to_numpy())
    print(f"Cramér's V between ChurnIndicator and {var}: {cramers_v_value:.2f}")
    cramers_v_values.append(cramers_v_value)

# Plot all Cramér's V values in one plot
plt.figure(figsize=(10, 6))
bars = plt.barh(categorical_vars, cramers_v_values, color='skyblue')
plt.xlabel("Cramér's V Value")
plt.title("Cramér's V Values between ChurnIndicator and Categorical Variables")
plt.xlim(0, 1)  # Adjust the limit if needed
plt.grid(axis='x')

# Add value labels to the bars
for bar, value in zip(bars, cramers_v_values):
    plt.text(value, bar.get_y() + bar.get_height()/2, f'{value:.3f}', ha='left', va='center', fontsize=10)

plt.show()
Cramér's V between ChurnIndicator and Gender: 0.01
Cramér's V between ChurnIndicator and Education: 0.01
Cramér's V between ChurnIndicator and MaritalStatus: 0.01
Cramér's V between ChurnIndicator and LoyaltyCard: 0.02
Cramér's V between ChurnIndicator and EnrollmentType: 0.00
Cramér's V between ChurnIndicator and Province: 0.03
Cramér's V between ChurnIndicator and City: 0.03

4 Maintaining Loyalty with Perks¶

4.1 Filter active customers in 2017-2018 with Standard Enrollment¶

In [293]:
# total number of customers in dataset

loyalty_df['LoyaltyNumber'].nunique()
Out[293]:
16737
In [294]:
# calculate total flights in 2017 and 2018

total_flight_1718 = flight_df[['LoyaltyNumber', 'TotalFlights']].groupby(by='LoyaltyNumber', sort=False).sum()['TotalFlights']\
    .reset_index()
total_flight_1718
Out[294]:
LoyaltyNumber TotalFlights
0 100018 192
1 100102 215
2 100140 190
3 100214 96
4 100272 163
... ... ...
16732 999902 225
16733 999911 0
16734 999940 86
16735 999982 24
16736 999986 138

16737 rows × 2 columns

In [295]:
# get active customers in 2017 and 2018

active_cx_1718 = total_flight_1718[total_flight_1718['TotalFlights'].gt(0)]['LoyaltyNumber']
len(active_cx_1718)
Out[295]:
15236
In [296]:
# get active customers in 2017 and 2018 enrolling through Standard Enrollment

active_standard_cx_1718 = loyalty_df[
    loyalty_df['LoyaltyNumber'].isin(active_cx_1718) 
    & loyalty_df['EnrollmentType'].ne('2018 Promotion')
]['LoyaltyNumber']
len(active_standard_cx_1718)
Out[296]:
14303
In [297]:
active_loyalty_df = loyalty_df[loyalty_df['LoyaltyNumber'].isin(active_standard_cx_1718)]
active_loyalty_df.shape
Out[297]:
(14303, 33)

4.2 Lifelong Loyalty Scoring System¶

4.2.1 Pre-process data on flight level¶

In [298]:
# Filter active standard customers with: 
# - Year 2018 
# - Customers with more than 6 months of membership 
# - Remove records after customers cancel memberships

predict_retention_df = lookback_flight_df.merge(temp_mom_lookback, how='left', on=['LoyaltyNumber', 'Date'])\
    .pipe(lambda x: x[x['Date'].ge('2018-01-01') & x['Date'].ge(x['EnrollmentDate'])])\
    .pipe(lambda x: x[~x['Date'].gt(x['CancellationDate'])])\
    .assign(ChurnIndicator = lambda x: np.where(x['Date'].eq(x['CancellationDate']), 1, 0))\
    .pipe(lambda x: x[x['LoyaltyNumber'].isin(active_standard_cx_1718)])
    
predict_retention_df
Out[298]:
LoyaltyNumber Date Last1Y_FlightsBooked Last1Y_FlightsWithCompanions Last1Y_TotalFlights Last1Y_Distance Last1Y_PointsAccumulated Last1Y_PointsRedeemed Last1Y_DollarCostPointsRedeemed Last6M_FlightsBooked ... Last1M_FlightsWithCompanions Last1M_TotalFlights Last1M_Distance Last1M_PointsAccumulated Last1M_PointsRedeemed Last1M_DollarCostPointsRedeemed EnrollmentDate CancellationDate MonthsOfMembership ChurnIndicator
332052 100018 2018-01-01 81.0 16.0 97.0 26663.0 2664.00 1128.0 92.0 44.0 ... 0.0 6.0 1908.0 190.0 0.0 0.0 2016-08-01 NaT 17 0
332053 100102 2018-01-01 83.0 18.0 101.0 16933.0 1691.00 1195.0 96.0 49.0 ... 3.0 14.0 3542.0 354.0 510.0 41.0 2013-03-01 NaT 58 0
332054 100140 2018-01-01 72.0 18.0 90.0 24681.0 2466.00 0.0 0.0 41.0 ... 0.0 15.0 4500.0 450.0 0.0 0.0 2016-07-01 NaT 18 0
332055 100214 2018-01-01 37.0 3.0 40.0 15259.0 1523.00 861.0 70.0 30.0 ... 0.0 11.0 4334.0 433.0 0.0 0.0 2015-08-01 NaT 29 0
332056 100272 2018-01-01 66.0 19.0 85.0 22711.0 2268.00 393.0 32.0 15.0 ... 6.0 17.0 4539.0 453.0 0.0 0.0 2014-01-01 NaT 48 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
664094 999631 2018-12-01 44.0 19.0 63.0 17336.0 1756.92 0.0 0.0 29.0 ... 5.0 10.0 2160.0 216.0 0.0 0.0 2014-01-01 NaT 59 0
664096 999758 2018-12-01 15.0 4.0 19.0 7822.0 782.00 573.0 46.0 15.0 ... 0.0 10.0 3570.0 357.0 0.0 0.0 2018-08-01 NaT 4 0
664099 999902 2018-12-01 87.0 15.0 102.0 28122.0 3149.50 384.0 31.0 37.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 2014-05-01 NaT 55 0
664101 999940 2018-12-01 51.0 18.0 69.0 15929.0 1668.00 672.0 54.0 33.0 ... 5.0 17.0 3859.0 385.0 0.0 0.0 2017-07-01 NaT 17 0
664102 999982 2018-12-01 22.0 2.0 24.0 5948.0 594.00 0.0 0.0 22.0 ... 0.0 3.0 1560.0 156.0 0.0 0.0 2018-07-01 NaT 5 0

250425 rows × 34 columns

In [299]:
predict_retention_df['LoyaltyNumber'].nunique()
Out[299]:
13814
In [300]:
# Join demographic profiles

predict_retention_df = predict_retention_df.merge(loyalty_df.iloc[:, :9], how='left', on='LoyaltyNumber')\
    .drop(columns=['EnrollmentDate', 'CancellationDate', 'Country'])
    
predict_retention_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 250425 entries, 0 to 250424
Data columns (total 39 columns):
 #   Column                           Non-Null Count   Dtype         
---  ------                           --------------   -----         
 0   LoyaltyNumber                    250425 non-null  object        
 1   Date                             250425 non-null  datetime64[ns]
 2   Last1Y_FlightsBooked             250425 non-null  float64       
 3   Last1Y_FlightsWithCompanions     250425 non-null  float64       
 4   Last1Y_TotalFlights              250425 non-null  float64       
 5   Last1Y_Distance                  250425 non-null  float64       
 6   Last1Y_PointsAccumulated         250425 non-null  float64       
 7   Last1Y_PointsRedeemed            250425 non-null  float64       
 8   Last1Y_DollarCostPointsRedeemed  250425 non-null  float64       
 9   Last6M_FlightsBooked             250425 non-null  float64       
 10  Last6M_FlightsWithCompanions     250425 non-null  float64       
 11  Last6M_TotalFlights              250425 non-null  float64       
 12  Last6M_Distance                  250425 non-null  float64       
 13  Last6M_PointsAccumulated         250425 non-null  float64       
 14  Last6M_PointsRedeemed            250425 non-null  float64       
 15  Last6M_DollarCostPointsRedeemed  250425 non-null  float64       
 16  Last3M_FlightsBooked             250425 non-null  float64       
 17  Last3M_FlightsWithCompanions     250425 non-null  float64       
 18  Last3M_TotalFlights              250425 non-null  float64       
 19  Last3M_Distance                  250425 non-null  float64       
 20  Last3M_PointsAccumulated         250425 non-null  float64       
 21  Last3M_PointsRedeemed            250425 non-null  float64       
 22  Last3M_DollarCostPointsRedeemed  250425 non-null  float64       
 23  Last1M_FlightsBooked             250425 non-null  float64       
 24  Last1M_FlightsWithCompanions     250425 non-null  float64       
 25  Last1M_TotalFlights              250425 non-null  float64       
 26  Last1M_Distance                  250425 non-null  float64       
 27  Last1M_PointsAccumulated         250425 non-null  float64       
 28  Last1M_PointsRedeemed            250425 non-null  float64       
 29  Last1M_DollarCostPointsRedeemed  250425 non-null  float64       
 30  MonthsOfMembership               250425 non-null  int64         
 31  ChurnIndicator                   250425 non-null  int32         
 32  Province                         250425 non-null  object        
 33  City                             250425 non-null  object        
 34  PostalCode                       250425 non-null  object        
 35  Gender                           250425 non-null  object        
 36  Education                        250425 non-null  object        
 37  Salary                           250425 non-null  float64       
 38  MaritalStatus                    250425 non-null  object        
dtypes: datetime64[ns](1), float64(29), int32(1), int64(1), object(7)
memory usage: 73.6+ MB
In [301]:
# Label encoding for categorical features

label_encoder = LabelEncoder()

for col in predict_retention_df.columns[1:]:
    if predict_retention_df[col].dtype == 'object':
        predict_retention_df[col] = label_encoder.fit_transform(predict_retention_df[col])
In [302]:
predict_retention_df = predict_retention_df.reset_index()
predict_retention_df
Out[302]:
index LoyaltyNumber Date Last1Y_FlightsBooked Last1Y_FlightsWithCompanions Last1Y_TotalFlights Last1Y_Distance Last1Y_PointsAccumulated Last1Y_PointsRedeemed Last1Y_DollarCostPointsRedeemed ... Last1M_DollarCostPointsRedeemed MonthsOfMembership ChurnIndicator Province City PostalCode Gender Education Salary MaritalStatus
0 0 100018 2018-01-01 81.0 16.0 97.0 26663.0 2664.00 1128.0 92.0 ... 0.0 17 0 0 4 43 0 0 92552.0 1
1 1 100102 2018-01-01 83.0 18.0 101.0 16933.0 1691.00 1195.0 96.0 ... 41.0 58 0 6 20 20 1 1 0.0 2
2 2 100140 2018-01-01 72.0 18.0 90.0 24681.0 2466.00 0.0 0.0 ... 0.0 18 0 1 3 45 0 1 0.0 0
3 3 100214 2018-01-01 37.0 3.0 40.0 15259.0 1523.00 861.0 70.0 ... 0.0 29 0 1 23 49 1 0 63253.0 1
4 4 100272 2018-01-01 66.0 19.0 85.0 22711.0 2268.00 393.0 32.0 ... 0.0 48 0 6 20 31 0 0 91163.0 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
250420 250420 999631 2018-12-01 44.0 19.0 63.0 17336.0 1756.92 0.0 0.0 ... 0.0 59 0 1 23 49 0 0 47349.0 0
250421 250421 999758 2018-12-01 15.0 4.0 19.0 7822.0 782.00 573.0 46.0 ... 0.0 4 0 1 23 49 0 1 0.0 2
250422 250422 999902 2018-12-01 87.0 15.0 102.0 28122.0 3149.50 384.0 31.0 ... 0.0 55 0 6 20 20 1 1 0.0 1
250423 250423 999940 2018-12-01 51.0 18.0 69.0 15929.0 1668.00 672.0 54.0 ... 0.0 17 0 8 15 6 0 0 47670.0 1
250424 250424 999982 2018-12-01 22.0 2.0 24.0 5948.0 594.00 0.0 0.0 ... 0.0 5 0 1 24 47 1 1 0.0 1

250425 rows × 40 columns

4.2.2 Split data¶

In [303]:
predict_retention_df = predict_retention_df.set_index(['LoyaltyNumber', 'Date'])
predict_retention_df['ChurnIndicator'].value_counts(normalize=True) # churn rate
Out[303]:
ChurnIndicator
0    0.996642
1    0.003358
Name: proportion, dtype: float64
In [304]:
predict_retention_df.index.get_level_values('LoyaltyNumber').nunique() # number of customers
Out[304]:
13814
In [305]:
# get X and y

X_retention_data = predict_retention_df.drop(columns='ChurnIndicator')
y_retention_data = predict_retention_df[['ChurnIndicator']]
In [306]:
# get training and test sets

X_retention_train, X_retention_test, y_retention_train, y_retention_test = train_test_split(
    X_retention_data,
    y_retention_data,
    test_size=0.3,
    random_state=RANDOM_STATE,
    stratify=y_retention_data
)

print(
    'Train set:',
    '\n x_train:', X_retention_train.shape, 
    '\n y_train:', y_retention_train.shape, 
    '\n train size:', y_retention_train.shape[0]/y_retention_data.shape[0], 
    '\n churn rate:', y_retention_train.sum()/y_retention_train.count(),
    '\nTest set:',
    '\n x_test:', X_retention_test.shape, 
    '\n y_test:', y_retention_test.shape, 
    '\n test size:', y_retention_test.shape[0]/y_retention_data.shape[0], 
    '\n churn rate:', y_retention_test.sum()/y_retention_test.count()
)
Train set: 
 x_train: (175297, 37) 
 y_train: (175297, 1) 
 train size: 0.6999980033942298 
 churn rate: ChurnIndicator    0.00336
dtype: float64 
Test set: 
 x_test: (75128, 37) 
 y_test: (75128, 1) 
 test size: 0.3000019966057702 
 churn rate: ChurnIndicator    0.003354
dtype: float64

4.2.3 Train model¶

In [307]:
# define random under-sampling

rus = RandomUnderSampler(
    sampling_strategy=0.05,
    random_state=RANDOM_STATE
)

# define XGBoost algorithm

algo_xgb = xgboost.XGBClassifier(
    max_depth=6,
    learning_rate=0.3,
    objective='binary:logistic',
    tree_method='hist',
    sub_sample=1.0,
    random_state=RANDOM_STATE
)

# create pipeline with random sampling

pipe_rus_xgb = make_pipeline(rus, algo_xgb)
In [308]:
# fit model on training set

pipe_rus_xgb.fit(X_retention_train, y_retention_train)
Out[308]:
Pipeline(steps=[('randomundersampler',
                 RandomUnderSampler(random_state=42, sampling_strategy=0.05)),
                ('xgbclassifier',
                 XGBClassifier(base_score=None, booster=None, callbacks=None,
                               colsample_bylevel=None, colsample_bynode=None,
                               colsample_bytree=None, device=None,
                               early_stopping_rounds=None,
                               enable_categorical=False, eval_metric=None,
                               feature_types=None, gamma=None, grow_policy=None,
                               importance_type=None,
                               interaction_constraints=None, learning_rate=0.3,
                               max_bin=None, max_cat_threshold=None,
                               max_cat_to_onehot=None, max_delta_step=None,
                               max_depth=6, max_leaves=None,
                               min_child_weight=None, missing=nan,
                               monotone_constraints=None, multi_strategy=None,
                               n_estimators=None, n_jobs=None,
                               num_parallel_tree=None, random_state=42, ...))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Pipeline(steps=[('randomundersampler',
                 RandomUnderSampler(random_state=42, sampling_strategy=0.05)),
                ('xgbclassifier',
                 XGBClassifier(base_score=None, booster=None, callbacks=None,
                               colsample_bylevel=None, colsample_bynode=None,
                               colsample_bytree=None, device=None,
                               early_stopping_rounds=None,
                               enable_categorical=False, eval_metric=None,
                               feature_types=None, gamma=None, grow_policy=None,
                               importance_type=None,
                               interaction_constraints=None, learning_rate=0.3,
                               max_bin=None, max_cat_threshold=None,
                               max_cat_to_onehot=None, max_delta_step=None,
                               max_depth=6, max_leaves=None,
                               min_child_weight=None, missing=nan,
                               monotone_constraints=None, multi_strategy=None,
                               n_estimators=None, n_jobs=None,
                               num_parallel_tree=None, random_state=42, ...))])
RandomUnderSampler(random_state=42, sampling_strategy=0.05)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric=None, feature_types=None,
              gamma=None, grow_policy=None, importance_type=None,
              interaction_constraints=None, learning_rate=0.3, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=6, max_leaves=None,
              min_child_weight=None, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=None, n_jobs=None,
              num_parallel_tree=None, random_state=42, ...)
In [309]:
# get classification report on test set at maximum F2

y_retention_prob = pipe_rus_xgb.predict_proba(X_retention_test)[:, 1]
xgb_test = get_classification_report(y_retention_test, y_retention_prob)
xgb_test[xgb_test['f1'].eq(xgb_test['f1'].max())]
Out[309]:
threshold precision recall f1 f2
54249 0.988101 0.913043 0.333333 0.488372 0.381818

4.2.4 Customer churn prediction¶

In [310]:
# get churn score threshold at maximum F1 score

lifelong_score_threshold = xgb_test[xgb_test['f1'].eq(xgb_test['f1'].max())]['threshold'].iloc[0]
lifelong_score_threshold
Out[310]:
0.98810136
In [311]:
# get probability prediction for each customer

lifelong_churn_score_df = X_retention_data.copy()
lifelong_churn_score_df['predict_proba'] = pipe_rus_xgb.predict_proba(lifelong_churn_score_df)[:, 1]
In [312]:
# get customers predicted as churn - customers who have score (probability) > churn threshold

lifelong_high_churn_score_df = lifelong_churn_score_df[lifelong_churn_score_df['predict_proba'].ge(lifelong_score_threshold)]
lifelong_churn_cx = lifelong_high_churn_score_df.index.get_level_values('LoyaltyNumber').unique().to_list()
len(lifelong_churn_cx)
Out[312]:
36

4.3 Dual-phase retention framework¶

4.3.1 Stage 0 - Exploratory data analysis¶

In [313]:
# get data of active standard customers

active_loyalty_df = loyalty_df[loyalty_df['LoyaltyNumber'].isin(active_standard_cx_1718)]
active_loyalty_df.shape
Out[313]:
(14303, 33)
In [314]:
# get all churn active customers

all_active = loyalty_df[loyalty_df['LoyaltyNumber'].isin(active_cx_1718)]

churn_monthsofmembership = all_active[all_active['ChurnIndicator'].eq(1)]['MonthsOfMembership'].value_counts().reset_index()\
    .sort_values(by='MonthsOfMembership')
    
# distribution of months of membership

plt.figure(figsize=(7,5))
sns.lineplot(data=churn_monthsofmembership, x='MonthsOfMembership', y='count')
plt.xlabel('Months of membership')
plt.ylabel('Number of customers')
plt.yticks(np.arange(0, 700, 100))
plt.title('Distribution of all active customers')
Out[314]:
Text(0.5, 1.0, 'Distribution of all active customers')
In [315]:
# get all churn active standard customers

churn_monthsofmembership_standard = active_loyalty_df[active_loyalty_df['ChurnIndicator'].eq(1)]\
    ['MonthsOfMembership'].value_counts().reset_index()\
    .sort_values(by='MonthsOfMembership')

# distribution of months of membership

plt.figure(figsize=(7,5))
sns.lineplot(data=churn_monthsofmembership_standard, x='MonthsOfMembership', y='count')
plt.xlabel('Months of membership')
plt.ylabel('Number of customers')
plt.yticks(np.arange(0, 700, 100))
plt.title('Distribution of active standard customers')
Out[315]:
Text(0.5, 1.0, 'Distribution of active standard customers')

4.3.2 Stage 1 - Frequent pattern mining¶

In [316]:
# choose features for frequent pattern mining

fpm_8m = active_loyalty_df.pipe(lambda x: x[x['MonthsOfMembership'].le(8)]).pipe(lambda x: x[[
    'ChurnIndicator', 
    'Education', 'Salary', 'MaritalStatus', 'LoyaltyCard',
    'MonthlyTotalFlights', 'PercentagePointsRedeemed'
]])

fpm_8m.describe()
Out[316]:
ChurnIndicator Salary MonthlyTotalFlights PercentagePointsRedeemed
count 2075.000000 2075.000000 2075.000000 2075.000000
mean 0.254458 58952.838072 11.597942 0.276236
std 0.435661 44711.063915 23.614560 0.544584
min 0.000000 0.000000 0.000000 0.000000
25% 0.000000 0.000000 4.670000 0.000000
50% 0.000000 64381.000000 7.200000 0.000000
75% 1.000000 82338.000000 10.330000 0.390000
max 1.000000 298851.000000 223.000000 5.200000
In [317]:
fpm_8m[fpm_8m['ChurnIndicator'].eq(1)].describe()
Out[317]:
ChurnIndicator Salary MonthlyTotalFlights PercentagePointsRedeemed
count 528.0 528.000000 528.000000 528.000000
mean 1.0 58272.257576 6.142140 0.281761
std 0.0 46077.435863 2.988565 0.501163
min 1.0 0.000000 0.120000 0.000000
25% 1.0 0.000000 4.120000 0.000000
50% 1.0 64162.500000 6.120000 0.000000
75% 1.0 81685.500000 8.152500 0.422500
max 1.0 270797.000000 17.000000 5.200000
In [318]:
# bin numeric features

salary_bins = [-np.inf, 0, 15000, 60000, 80000, np.inf] # min, mean-std, mean, 75%, max
monthlyflights_bins = [-np.inf, 0, 4, 6, 8, np.inf] # min, 25%, mean, max 
pctpointsredeemed_bins = [-np.inf, 0, 0.4, np.inf] # min, 25%, 50%, >75%, pct=1, max

fpm_8m = fpm_8m.assign(
    SalaryBins = lambda x: pd.cut(x=x['Salary'], bins=salary_bins, right=True),
    # MonthsOfMembershipBins = lambda x: pd.cut(x=x['MonthsOfMembership'], bins=membership_bins, right=True),
    MonthlyFlightsBins = lambda x: pd.cut(x=x['MonthlyTotalFlights'], bins=monthlyflights_bins, right=True),
    PctPointsRedeemedBins = lambda x: pd.cut(x=x['PercentagePointsRedeemed'], bins=pctpointsredeemed_bins, right=True)
).astype(str).replace({'(-inf, 0.0]': '0'})\
.drop(columns=['Salary', 'MonthlyTotalFlights', 'PercentagePointsRedeemed'])\

fpm_8m.head()
Out[318]:
ChurnIndicator Education MaritalStatus LoyaltyCard SalaryBins MonthlyFlightsBins PctPointsRedeemedBins
21 0 College Married Star 0 (8.0, inf] 0
34 0 Bachelor Married Star (60000.0, 80000.0] (6.0, 8.0] (0.0, 0.4]
45 1 Bachelor Married Star (80000.0, inf] (0.0, 4.0] 0
46 0 Bachelor Married Star (80000.0, inf] (6.0, 8.0] 0
47 0 Bachelor Married Star (80000.0, inf] (8.0, inf] 0
In [319]:
# one-hot encoding

fpm_churn_6m = pd.get_dummies(fpm_8m[fpm_8m['ChurnIndicator'].eq('1')].drop(columns='ChurnIndicator'))
fpm_nonchurn_6m = pd.get_dummies(fpm_8m[fpm_8m['ChurnIndicator'].eq('0')].drop(columns='ChurnIndicator'))
display(fpm_churn_6m.shape, fpm_nonchurn_6m.shape)
(528, 22)
(1547, 23)
In [320]:
# get churn customers' unique frequent patterns for various scenarios with minimum support from 0.1 to 0.9

for i in np.arange(0.1, 1, 0.1):
    min_support = round(i, ndigits=2)
    patterns_churn = apriori(fpm_churn_6m, min_support=min_support, use_colnames=True).assign(length = lambda x: x['itemsets'].str.len())
    patterns_nonchurn = apriori(fpm_nonchurn_6m, min_support=min_support, use_colnames=True).assign(length = lambda x: x['itemsets'].str.len())
    unique_patterns_churn = patterns_churn[~patterns_churn['itemsets'].isin(patterns_nonchurn['itemsets'])]
    print('min support =', min_support)
    display(unique_patterns_churn)
min support = 0.1
support itemsets length
27 0.143939 (MonthlyFlightsBins_(0.0, 4.0], Education_Bach... 2
43 0.136364 (MonthlyFlightsBins_(0.0, 4.0], MaritalStatus_... 2
44 0.145833 (MonthlyFlightsBins_(4.0, 6.0], MaritalStatus_... 2
47 0.104167 (PctPointsRedeemedBins_(0.0, 0.4], MaritalStat... 2
50 0.100379 (LoyaltyCard_Nova, MaritalStatus_Single) 2
53 0.102273 (MaritalStatus_Single, PctPointsRedeemedBins_(... 2
56 0.111742 (LoyaltyCard_Nova, SalaryBins_(80000.0, inf]) 2
61 0.106061 (MonthlyFlightsBins_(0.0, 4.0], LoyaltyCard_Star) 2
62 0.123106 (LoyaltyCard_Star, MonthlyFlightsBins_(6.0, 8.0]) 2
74 0.106061 (LoyaltyCard_Aurora, Education_Bachelor, Marit... 3
80 0.102273 (MonthlyFlightsBins_(4.0, 6.0], Education_Bach... 3
87 0.117424 (MonthlyFlightsBins_(0.0, 4.0], Education_Bach... 3
94 0.104167 (MaritalStatus_Married, MonthlyFlightsBins_(0.... 3
min support = 0.2
support itemsets length
4 0.215909 (LoyaltyCard_Aurora) 1
10 0.240530 (MonthlyFlightsBins_(0.0, 4.0]) 1
11 0.255682 (MonthlyFlightsBins_(4.0, 6.0]) 1
12 0.240530 (MonthlyFlightsBins_(6.0, 8.0]) 1
min support = 0.3
support itemsets length
2 0.306818 (MaritalStatus_Single) 1
min support = 0.4
support itemsets length
min support = 0.5
support itemsets length
min support = 0.6
support itemsets length
min support = 0.7
support itemsets length
min support = 0.8
support itemsets length
min support = 0.9
support itemsets length

4.3.3 Stage 2 - Predictive modelling on customer retention¶

4.3.3.1 Pre-process data on flight level¶

In [321]:
# Filter active standard customers with: 
# - Year 2018 
# - Customers with more than 6 months of membership 
# - Remove records after customers cancel memberships

predict_retention_df = lookback_flight_df.merge(temp_mom_lookback, how='left', on=['LoyaltyNumber', 'Date'])\
    .pipe(lambda x: x[x['Date'].ge('2018-01-01') & x['Date'].ge(x['EnrollmentDate'])])\
    .pipe(lambda x: x[~x['Date'].gt(x['CancellationDate'])])\
    .assign(ChurnIndicator = lambda x: np.where(x['Date'].eq(x['CancellationDate']), 1, 0))\
    .pipe(lambda x: x[x['MonthsOfMembership'].gt(6)])\
    .pipe(lambda x: x[x['LoyaltyNumber'].isin(active_standard_cx_1718)])
    
predict_retention_df
Out[321]:
LoyaltyNumber Date Last1Y_FlightsBooked Last1Y_FlightsWithCompanions Last1Y_TotalFlights Last1Y_Distance Last1Y_PointsAccumulated Last1Y_PointsRedeemed Last1Y_DollarCostPointsRedeemed Last6M_FlightsBooked ... Last1M_FlightsWithCompanions Last1M_TotalFlights Last1M_Distance Last1M_PointsAccumulated Last1M_PointsRedeemed Last1M_DollarCostPointsRedeemed EnrollmentDate CancellationDate MonthsOfMembership ChurnIndicator
332052 100018 2018-01-01 81.0 16.0 97.0 26663.0 2664.00 1128.0 92.0 44.0 ... 0.0 6.0 1908.0 190.0 0.0 0.0 2016-08-01 NaT 17 0
332053 100102 2018-01-01 83.0 18.0 101.0 16933.0 1691.00 1195.0 96.0 49.0 ... 3.0 14.0 3542.0 354.0 510.0 41.0 2013-03-01 NaT 58 0
332054 100140 2018-01-01 72.0 18.0 90.0 24681.0 2466.00 0.0 0.0 41.0 ... 0.0 15.0 4500.0 450.0 0.0 0.0 2016-07-01 NaT 18 0
332055 100214 2018-01-01 37.0 3.0 40.0 15259.0 1523.00 861.0 70.0 30.0 ... 0.0 11.0 4334.0 433.0 0.0 0.0 2015-08-01 NaT 29 0
332056 100272 2018-01-01 66.0 19.0 85.0 22711.0 2268.00 393.0 32.0 15.0 ... 6.0 17.0 4539.0 453.0 0.0 0.0 2014-01-01 NaT 48 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
664091 999524 2018-12-01 69.0 30.0 99.0 20676.0 2371.50 0.0 0.0 26.0 ... 5.0 13.0 2379.0 237.0 0.0 0.0 2015-05-01 NaT 43 0
664092 999550 2018-12-01 52.0 15.0 67.0 18741.0 1923.48 479.0 39.0 32.0 ... 0.0 11.0 2046.0 204.0 0.0 0.0 2014-08-01 NaT 52 0
664094 999631 2018-12-01 44.0 19.0 63.0 17336.0 1756.92 0.0 0.0 29.0 ... 5.0 10.0 2160.0 216.0 0.0 0.0 2014-01-01 NaT 59 0
664099 999902 2018-12-01 87.0 15.0 102.0 28122.0 3149.50 384.0 31.0 37.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 2014-05-01 NaT 55 0
664101 999940 2018-12-01 51.0 18.0 69.0 15929.0 1668.00 672.0 54.0 33.0 ... 5.0 17.0 3859.0 385.0 0.0 0.0 2017-07-01 NaT 17 0

231031 rows × 34 columns

In [322]:
predict_retention_df['LoyaltyNumber'].nunique()
Out[322]:
12480
In [323]:
# Join demographic profiles

predict_retention_df = predict_retention_df.merge(loyalty_df.iloc[:, :9], how='left', on='LoyaltyNumber')\
    .drop(columns=['EnrollmentDate', 'CancellationDate', 'Country'])
    
predict_retention_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 231031 entries, 0 to 231030
Data columns (total 39 columns):
 #   Column                           Non-Null Count   Dtype         
---  ------                           --------------   -----         
 0   LoyaltyNumber                    231031 non-null  object        
 1   Date                             231031 non-null  datetime64[ns]
 2   Last1Y_FlightsBooked             231031 non-null  float64       
 3   Last1Y_FlightsWithCompanions     231031 non-null  float64       
 4   Last1Y_TotalFlights              231031 non-null  float64       
 5   Last1Y_Distance                  231031 non-null  float64       
 6   Last1Y_PointsAccumulated         231031 non-null  float64       
 7   Last1Y_PointsRedeemed            231031 non-null  float64       
 8   Last1Y_DollarCostPointsRedeemed  231031 non-null  float64       
 9   Last6M_FlightsBooked             231031 non-null  float64       
 10  Last6M_FlightsWithCompanions     231031 non-null  float64       
 11  Last6M_TotalFlights              231031 non-null  float64       
 12  Last6M_Distance                  231031 non-null  float64       
 13  Last6M_PointsAccumulated         231031 non-null  float64       
 14  Last6M_PointsRedeemed            231031 non-null  float64       
 15  Last6M_DollarCostPointsRedeemed  231031 non-null  float64       
 16  Last3M_FlightsBooked             231031 non-null  float64       
 17  Last3M_FlightsWithCompanions     231031 non-null  float64       
 18  Last3M_TotalFlights              231031 non-null  float64       
 19  Last3M_Distance                  231031 non-null  float64       
 20  Last3M_PointsAccumulated         231031 non-null  float64       
 21  Last3M_PointsRedeemed            231031 non-null  float64       
 22  Last3M_DollarCostPointsRedeemed  231031 non-null  float64       
 23  Last1M_FlightsBooked             231031 non-null  float64       
 24  Last1M_FlightsWithCompanions     231031 non-null  float64       
 25  Last1M_TotalFlights              231031 non-null  float64       
 26  Last1M_Distance                  231031 non-null  float64       
 27  Last1M_PointsAccumulated         231031 non-null  float64       
 28  Last1M_PointsRedeemed            231031 non-null  float64       
 29  Last1M_DollarCostPointsRedeemed  231031 non-null  float64       
 30  MonthsOfMembership               231031 non-null  int64         
 31  ChurnIndicator                   231031 non-null  int32         
 32  Province                         231031 non-null  object        
 33  City                             231031 non-null  object        
 34  PostalCode                       231031 non-null  object        
 35  Gender                           231031 non-null  object        
 36  Education                        231031 non-null  object        
 37  Salary                           231031 non-null  float64       
 38  MaritalStatus                    231031 non-null  object        
dtypes: datetime64[ns](1), float64(29), int32(1), int64(1), object(7)
memory usage: 67.9+ MB
In [324]:
# Label encoding for categorical features

label_encoder = LabelEncoder()

for col in predict_retention_df.columns[1:]:
    if predict_retention_df[col].dtype == 'object':
        predict_retention_df[col] = label_encoder.fit_transform(predict_retention_df[col])
In [325]:
predict_retention_df = predict_retention_df.reset_index()
predict_retention_df
Out[325]:
index LoyaltyNumber Date Last1Y_FlightsBooked Last1Y_FlightsWithCompanions Last1Y_TotalFlights Last1Y_Distance Last1Y_PointsAccumulated Last1Y_PointsRedeemed Last1Y_DollarCostPointsRedeemed ... Last1M_DollarCostPointsRedeemed MonthsOfMembership ChurnIndicator Province City PostalCode Gender Education Salary MaritalStatus
0 0 100018 2018-01-01 81.0 16.0 97.0 26663.0 2664.00 1128.0 92.0 ... 0.0 17 0 0 4 43 0 0 92552.0 1
1 1 100102 2018-01-01 83.0 18.0 101.0 16933.0 1691.00 1195.0 96.0 ... 41.0 58 0 6 20 20 1 1 0.0 2
2 2 100140 2018-01-01 72.0 18.0 90.0 24681.0 2466.00 0.0 0.0 ... 0.0 18 0 1 3 45 0 1 0.0 0
3 3 100214 2018-01-01 37.0 3.0 40.0 15259.0 1523.00 861.0 70.0 ... 0.0 29 0 1 23 49 1 0 63253.0 1
4 4 100272 2018-01-01 66.0 19.0 85.0 22711.0 2268.00 393.0 32.0 ... 0.0 48 0 6 20 31 0 0 91163.0 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
231026 231026 999524 2018-12-01 69.0 30.0 99.0 20676.0 2371.50 0.0 0.0 ... 0.0 43 0 8 12 7 1 1 0.0 1
231027 231027 999550 2018-12-01 52.0 15.0 67.0 18741.0 1923.48 479.0 39.0 ... 0.0 52 0 8 12 8 0 2 125167.0 0
231028 231028 999631 2018-12-01 44.0 19.0 63.0 17336.0 1756.92 0.0 0.0 ... 0.0 59 0 1 23 49 0 0 47349.0 0
231029 231029 999902 2018-12-01 87.0 15.0 102.0 28122.0 3149.50 384.0 31.0 ... 0.0 55 0 6 20 20 1 1 0.0 1
231030 231030 999940 2018-12-01 51.0 18.0 69.0 15929.0 1668.00 672.0 54.0 ... 0.0 17 0 8 15 6 0 0 47670.0 1

231031 rows × 40 columns

4.3.3.2 Split data¶

In [326]:
predict_retention_df = predict_retention_df.set_index(['LoyaltyNumber', 'Date'])
predict_retention_df['ChurnIndicator'].value_counts(normalize=True)
Out[326]:
ChurnIndicator
0    0.996438
1    0.003562
Name: proportion, dtype: float64
In [327]:
predict_retention_df.index.get_level_values('LoyaltyNumber').nunique()
Out[327]:
12480
In [328]:
# get X and y

X_retention_data = predict_retention_df.drop(columns='ChurnIndicator')
y_retention_data = predict_retention_df[['ChurnIndicator']]
In [329]:
# get training and test sets

X_retention_train, X_retention_test, y_retention_train, y_retention_test = train_test_split(
    X_retention_data,
    y_retention_data,
    test_size=0.3,
    random_state=RANDOM_STATE,
    stratify=y_retention_data
)

print(
    'Train set:',
    '\n x_train:', X_retention_train.shape, 
    '\n y_train:', y_retention_train.shape, 
    '\n train size:', y_retention_train.shape[0]/y_retention_data.shape[0], 
    '\n churn rate:', y_retention_train.sum()/y_retention_train.count(),
    '\nTest set:',
    '\n x_test:', X_retention_test.shape, 
    '\n y_test:', y_retention_test.shape, 
    '\n test size:', y_retention_test.shape[0]/y_retention_data.shape[0], 
    '\n churn rate:', y_retention_test.sum()/y_retention_test.count()
)
Train set: 
 x_train: (161721, 37) 
 y_train: (161721, 1) 
 train size: 0.6999969701035792 
 churn rate: ChurnIndicator    0.003562
dtype: float64 
Test set: 
 x_test: (69310, 37) 
 y_test: (69310, 1) 
 test size: 0.3000030298964208 
 churn rate: ChurnIndicator    0.003564
dtype: float64

4.3.3.3 Train model¶

In [330]:
# define random under-sampling

rus = RandomUnderSampler(
    sampling_strategy=0.05,
    random_state=RANDOM_STATE
)

# define XGBoost algorithm

algo_xgb = xgboost.XGBClassifier(
    max_depth=6,
    learning_rate=0.3,
    objective='binary:logistic',
    tree_method='hist',
    sub_sample=1.0,
    random_state=RANDOM_STATE
)

# create pipeline with random sampling

pipe_rus_xgb = make_pipeline(rus, algo_xgb)
In [331]:
# fit model on training set

pipe_rus_xgb.fit(X_retention_train, y_retention_train)
Out[331]:
Pipeline(steps=[('randomundersampler',
                 RandomUnderSampler(random_state=42, sampling_strategy=0.05)),
                ('xgbclassifier',
                 XGBClassifier(base_score=None, booster=None, callbacks=None,
                               colsample_bylevel=None, colsample_bynode=None,
                               colsample_bytree=None, device=None,
                               early_stopping_rounds=None,
                               enable_categorical=False, eval_metric=None,
                               feature_types=None, gamma=None, grow_policy=None,
                               importance_type=None,
                               interaction_constraints=None, learning_rate=0.3,
                               max_bin=None, max_cat_threshold=None,
                               max_cat_to_onehot=None, max_delta_step=None,
                               max_depth=6, max_leaves=None,
                               min_child_weight=None, missing=nan,
                               monotone_constraints=None, multi_strategy=None,
                               n_estimators=None, n_jobs=None,
                               num_parallel_tree=None, random_state=42, ...))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Pipeline(steps=[('randomundersampler',
                 RandomUnderSampler(random_state=42, sampling_strategy=0.05)),
                ('xgbclassifier',
                 XGBClassifier(base_score=None, booster=None, callbacks=None,
                               colsample_bylevel=None, colsample_bynode=None,
                               colsample_bytree=None, device=None,
                               early_stopping_rounds=None,
                               enable_categorical=False, eval_metric=None,
                               feature_types=None, gamma=None, grow_policy=None,
                               importance_type=None,
                               interaction_constraints=None, learning_rate=0.3,
                               max_bin=None, max_cat_threshold=None,
                               max_cat_to_onehot=None, max_delta_step=None,
                               max_depth=6, max_leaves=None,
                               min_child_weight=None, missing=nan,
                               monotone_constraints=None, multi_strategy=None,
                               n_estimators=None, n_jobs=None,
                               num_parallel_tree=None, random_state=42, ...))])
RandomUnderSampler(random_state=42, sampling_strategy=0.05)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric=None, feature_types=None,
              gamma=None, grow_policy=None, importance_type=None,
              interaction_constraints=None, learning_rate=0.3, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=6, max_leaves=None,
              min_child_weight=None, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=None, n_jobs=None,
              num_parallel_tree=None, random_state=42, ...)
In [332]:
# get classification report on test set at maximum F2

y_retention_prob = pipe_rus_xgb.predict_proba(X_retention_test)[:, 1]
xgb_test = get_classification_report(y_retention_test, y_retention_prob)
xgb_test[xgb_test['f1'].eq(xgb_test['f1'].max())]
Out[332]:
threshold precision recall f1 f2
48992 0.987892 0.839286 0.380567 0.523677 0.427273
In [333]:
xgb_test[xgb_test['f1'].eq(xgb_test['f1'].max())].to_clipboard()

4.3.4 Customer churn prediction¶

4.3.4.1 Stage 1¶

In [334]:
# get customers predicted as churn in stage 1 (with minimum_support = 0.3)
stage1_churn_cx = active_loyalty_df.pipe(lambda x: x[x['MonthsOfMembership'].le(8)]).pipe(lambda x: x[
    x['MaritalStatus'].eq('Single')
])['LoyaltyNumber'].to_list()
len(stage1_churn_cx)
Out[334]:
569

4.3.4.2 Stage 2¶

In [335]:
# get churn threshold at maximum F1 score

stage2_threshold = xgb_test[xgb_test['f1'].eq(xgb_test['f1'].max())]['threshold'].iloc[0]
stage2_threshold
Out[335]:
0.9878924
In [336]:
# get probability prediction for each customer

stage2_churn_score_df = X_retention_data.copy()
stage2_churn_score_df['predict_proba'] = pipe_rus_xgb.predict_proba(stage2_churn_score_df)[:, 1]
In [337]:
# get customers predicted as churn in stage 2 - customers who have score (probability) > churn threshold

stage2_high_churn_score_df = stage2_churn_score_df[stage2_churn_score_df['predict_proba'].ge(stage2_threshold)]
stage2_churn_cx = stage2_high_churn_score_df.index.get_level_values('LoyaltyNumber').unique().to_list()
len(stage2_churn_cx)
Out[337]:
80

4.3.4.3 Both stages¶

In [338]:
# all customers predicted as churn

dual_phase_churn_cx = stage1_churn_cx + stage2_churn_cx

4.4 Customer retention statistics¶

In [339]:
# Original churn rate

display(
    active_loyalty_df[['ChurnIndicator']].value_counts(normalize=False),
    active_loyalty_df[['ChurnIndicator']].value_counts(normalize=True)
)
ChurnIndicator
0                 13288
1                  1015
Name: count, dtype: int64
ChurnIndicator
0                 0.929036
1                 0.070964
Name: proportion, dtype: float64
In [340]:
# add columns of churn prediction from 2 approaches

active_loyalty_df = active_loyalty_df.assign(
    lifelong_ChurnPrediction = lambda x: np.where(x['LoyaltyNumber'].isin(lifelong_churn_cx), 1, 0),
    dualphase_ChurnPrediction = lambda x: np.where(x['LoyaltyNumber'].isin(dual_phase_churn_cx), 1, 0)
)
In [341]:
# churn_ind_list = ['ChurnIndicator', 'lifelong_ChurnPrediction', 'dualphase_ChurnPrediction']
total_number_of_customers = active_loyalty_df['LoyaltyNumber'].nunique()
retention_strategies = {
    'all': 'ChurnIndicator',
    'lifelong': 'lifelong_ChurnPrediction',
    'dualphase': 'dualphase_ChurnPrediction'
}

retention_stats_dict = {}

for strategy, col in retention_strategies.items():
    # number of unique customers predicted as churn
    if strategy == 'all':
        predicted_churn = total_number_of_customers
    else:
        predicted_churn = active_loyalty_df[col].sum()
    # true positive - number of churn correctly predicted as churn (to receive promotion -> convert to nonchurn)
    true_positive = active_loyalty_df[active_loyalty_df['ChurnIndicator'].eq(1) & active_loyalty_df[col].eq(1)].shape[0]
    # false negative - number of predicted nonchurn but are actual churn (missing alert)
    false_negative = active_loyalty_df[active_loyalty_df['ChurnIndicator'].eq(1) & active_loyalty_df[col].eq(0)].shape[0]
    # churn rate after promotion = false negative / total number of customers
    churn_rate_after_promo = round(false_negative * 100 / total_number_of_customers, ndigits=1)
    # cost per successful retention
    cost_efficiency = round(predicted_churn / true_positive, ndigits=1)
    
    retention_stats_dict[strategy] = [predicted_churn, true_positive, false_negative, churn_rate_after_promo, cost_efficiency]
    

retention_stats_dict_df = pd.DataFrame(
    retention_stats_dict, 
    index=['predicted_churn', 'true_positive', 'false_negative', 'churn_rate_after_promo', 'cost_efficiency']    
)    
retention_stats_dict_df
Out[341]:
all lifelong dualphase
predicted_churn 14303.0 36.0 643.0
true_positive 1015.0 10.0 180.0
false_negative 0.0 1005.0 835.0
churn_rate_after_promo 0.0 7.0 5.8
cost_efficiency 14.1 3.6 3.6

5 Encouraging Additional Expenditure with Coupon & Customer Segmentation¶

In [342]:
loyalty_df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 16737 entries, 0 to 16736
Data columns (total 33 columns):
 #   Column                         Non-Null Count  Dtype         
---  ------                         --------------  -----         
 0   LoyaltyNumber                  16737 non-null  object        
 1   Country                        16737 non-null  object        
 2   Province                       16737 non-null  object        
 3   City                           16737 non-null  object        
 4   PostalCode                     16737 non-null  object        
 5   Gender                         16737 non-null  object        
 6   Education                      16737 non-null  object        
 7   Salary                         16737 non-null  float64       
 8   MaritalStatus                  16737 non-null  object        
 9   LoyaltyCard                    16737 non-null  object        
 10  CLV                            16737 non-null  float64       
 11  EnrollmentType                 16737 non-null  object        
 12  EnrollmentYear                 16737 non-null  int64         
 13  EnrollmentMonth                16737 non-null  int64         
 14  CancellationYear               2067 non-null   Int64         
 15  CancellationMonth              2067 non-null   Int64         
 16  EnrollmentDate                 16737 non-null  datetime64[ns]
 17  EnrollmentYearMonth            16737 non-null  object        
 18  CancellationDate               2067 non-null   datetime64[ns]
 19  CancellationYearMonth          2067 non-null   object        
 20  ChurnIndicator                 16737 non-null  int32         
 21  MonthsOfMembership             16737 non-null  int64         
 22  TotalFlightsBooked             16737 non-null  int64         
 23  TotalFlightsWithCompanions     16737 non-null  int64         
 24  TotalTotalFlights              16737 non-null  int64         
 25  TotalDistance                  16737 non-null  int64         
 26  TotalPointsAccumulated         16737 non-null  float64       
 27  TotalPointsRedeemed            16737 non-null  int64         
 28  TotalDollarCostPointsRedeemed  16737 non-null  int64         
 29  PercentagePointsRedeemed       16737 non-null  float64       
 30  MonthlyTotalFlights            16737 non-null  float64       
 31  MonthlyPointsAccumulated       16737 non-null  float64       
 32  ChurnIndicator_bool            16737 non-null  bool          
dtypes: Int64(2), bool(1), datetime64[ns](2), float64(6), int32(1), int64(9), object(12)
memory usage: 4.1+ MB
In [343]:
loyalty_df.describe()
Out[343]:
Salary CLV EnrollmentYear EnrollmentMonth CancellationYear CancellationMonth EnrollmentDate CancellationDate ChurnIndicator MonthsOfMembership TotalFlightsBooked TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated
count 16737.000000 16737.000000 16737.000000 16737.000000 2067.0 2067.0 16737 2067 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000
mean 59222.180618 7988.896536 2015.253211 6.669116 2016.503145 6.962748 2015-09-22 11:45:55.977773824 2016-12-30 23:07:03.222060800 0.123499 35.451933 99.728984 25.005975 124.734958 29297.410826 2997.713823 743.943837 60.212344 0.231936 5.160505 123.283008
min 0.000000 1898.010000 2012.000000 1.000000 2013.0 1.0 2012-04-01 00:00:00 2013-01-01 00:00:00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
25% 0.000000 3980.840000 2014.000000 4.000000 2016.0 4.0 2014-01-01 00:00:00 2016-01-01 00:00:00 0.000000 11.000000 60.000000 13.000000 75.000000 17722.000000 1822.480000 0.000000 0.000000 0.000000 2.430000 58.880000
50% 63654.000000 5780.180000 2015.000000 7.000000 2017.0 7.0 2015-11-01 00:00:00 2017-04-01 00:00:00 0.000000 33.000000 113.000000 26.000000 142.000000 33815.000000 3454.280000 576.000000 47.000000 0.190000 3.790000 91.390000
75% 82940.000000 8940.580000 2017.000000 10.000000 2018.0 10.0 2017-07-01 00:00:00 2018-03-01 00:00:00 0.000000 57.000000 139.000000 36.000000 174.000000 40809.000000 4181.000000 1182.000000 96.000000 0.350000 6.250000 150.720000
max 407228.000000 83325.380000 2018.000000 12.000000 2018.0 12.0 2018-12-01 00:00:00 2018-12-01 00:00:00 1.000000 80.000000 354.000000 103.000000 448.000000 101959.000000 10587.500000 4479.000000 363.000000 5.200000 223.000000 5176.500000
std 45781.737001 6860.982280 1.979111 3.398958 1.380743 3.455297 NaN NaN 0.329019 24.384892 54.324783 15.722451 67.894347 15770.007792 1616.039377 719.150591 58.179758 0.277969 9.252993 221.768018
In [344]:
loyalty_df.head()
Out[344]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated ChurnIndicator_bool
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... 39 171 51877 5224.44 1418 115 0.27 5.03 153.66 False
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... 25 215 41578 4176.04 1971 159 0.47 6.52 126.55 False
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... 21 87 19664 1963.00 374 30 0.19 2.07 46.74 True
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... 36 159 36043 3626.68 1291 105 0.36 2.27 51.81 False
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... 44 176 36840 3689.68 0 0 0.00 3.52 73.79 False

5 rows × 33 columns

In [345]:
c_df = loyalty_df.drop(loyalty_df.columns[12:20], axis=1)
c_df = c_df.sort_index()

5.1 Defining the number of clusters¶

In [346]:
c_df_k1 = c_df.copy()
In [347]:
kmeans = KMeans(n_clusters=4)
labels =['low','mid-low','mid-high', 'high']

X = np.array(c_df_k1['CLV']).reshape(-1,1)
kmeans.fit(X)
y_kmeans = kmeans.predict(X)
print(y_kmeans)

clist=np.array([y_kmeans[i] for i in c_df_k1['CLV'].sort_index().sort_values().index])
print(clist)

values, counts = np.unique(y_kmeans, return_counts=True)
print(values,counts)

mapping = np.append(clist[0], clist[1:][clist[1:] != clist[:-1]])
c_df_k1['CLV_cluster']=[labels[int(np.where(mapping == i)[0])] for i in y_kmeans]
c_df_k1.head()
[0 0 0 ... 3 2 3]
[0 0 0 ... 3 3 3]
[0 1 2 3] [9570 1437 5386  344]
Out[347]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated ChurnIndicator_bool CLV_cluster
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... 171 51877 5224.44 1418 115 0.27 5.03 153.66 False low
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... 215 41578 4176.04 1971 159 0.47 6.52 126.55 False low
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... 87 19664 1963.00 374 30 0.19 2.07 46.74 True low
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... 159 36043 3626.68 1291 105 0.36 2.27 51.81 False low
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... 176 36840 3689.68 0 0 0.00 3.52 73.79 False low

5 rows × 26 columns

In [348]:
from sklearn import datasets

X = np.array(c_df_k1['CLV']).reshape(-1,1)

# Calculate within-cluster sum of squares (WCSS) for different values of k
wcss = []
for i in range(1, 11):
    kmeans = KMeans(n_clusters=i, init='k-means++', max_iter=300, n_init=10, random_state=0)
    kmeans.fit(X)
    wcss.append(kmeans.inertia_)

# Plot the elbow method curve
plt.plot(range(1, 11), wcss)
plt.title('Elbow Method')
plt.xlabel('Number of Clusters')
plt.ylabel('WCSS')
plt.show()
In [349]:
c_df_k2 = c_df.copy()
In [350]:
c_df_k2['AvgDistPerFlight'] = c_df_k2['TotalDistance'] / c_df_k2['TotalTotalFlights']
c_df_k2['AvgDistPerFlight'].fillna(0, inplace=True)
In [351]:
kmeans = KMeans(n_clusters=5)
labels =['short','mid-short', 'mid', 'mid-high', 'long']

X = np.array(c_df_k2['AvgDistPerFlight']).reshape(-1,1)
kmeans.fit(X)
y_kmeans = kmeans.predict(X)
print(y_kmeans)

clist=np.array([y_kmeans[i] for i in c_df_k2['AvgDistPerFlight'].sort_values().index])
print(clist)

values, counts = np.unique(y_kmeans, return_counts=True)
print(values,counts)

mapping = np.append(clist[0], clist[1:][clist[1:] != clist[:-1]])
c_df_k2['AvgDist_cluster']=[labels[int(np.where(mapping == i)[0])] for i in y_kmeans]
c_df_k2.head()
[4 0 0 ... 0 4 2]
[2 2 2 ... 1 1 1]
[0 1 2 3 4] [9736   13 1578   92 5318]
Out[351]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated ChurnIndicator_bool AvgDistPerFlight AvgDist_cluster
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... 51877 5224.44 1418 115 0.27 5.03 153.66 False 303.374269 mid
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... 41578 4176.04 1971 159 0.47 6.52 126.55 False 193.386047 mid-short
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... 19664 1963.00 374 30 0.19 2.07 46.74 True 226.022989 mid-short
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... 36043 3626.68 1291 105 0.36 2.27 51.81 False 226.685535 mid-short
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... 36840 3689.68 0 0 0.00 3.52 73.79 False 209.318182 mid-short

5 rows × 27 columns

In [352]:
X = np.array(c_df_k2['AvgDistPerFlight']).reshape(-1,1)

# Calculate within-cluster sum of squares (WCSS) for different values of k
wcss = []
for i in range(1, 11):
    kmeans = KMeans(n_clusters=i, init='k-means++', max_iter=300, n_init=10, random_state=0)
    kmeans.fit(X)
    wcss.append(kmeans.inertia_)

# Plot the elbow method curve
plt.plot(range(1, 11), wcss)
plt.title('Elbow Method')
plt.xlabel('Number of Clusters')
plt.ylabel('WCSS')
plt.show()
In [353]:
c_df_k3 = c_df.copy()
In [354]:
c_df_k3['PointUsage'] = c_df_k3['TotalPointsRedeemed'] / c_df_k3['TotalPointsAccumulated']
c_df_k3['PointUsage'].fillna(0, inplace=True)
In [355]:
X = np.array(c_df_k3['PointUsage']).reshape(-1,1)

# Calculate within-cluster sum of squares (WCSS) for different values of k
wcss = []
for i in range(1, 11):
    kmeans = KMeans(n_clusters=i, init='k-means++', max_iter=300, n_init=10, random_state=0)
    kmeans.fit(X)
    wcss.append(kmeans.inertia_)

# Plot the elbow method curve
plt.plot(range(1, 11), wcss)
plt.title('Elbow Method')
plt.xlabel('Number of Clusters')
plt.ylabel('WCSS')
plt.show()
In [356]:
kmeans = KMeans(n_clusters=3)
labels =['low', 'mid', 'high']

X = np.array(c_df_k3['PointUsage']).reshape(-1,1)
kmeans.fit(X)
y_kmeans = kmeans.predict(X)
print(y_kmeans)

clist=np.array([y_kmeans[i] for i in c_df_k3['PointUsage'].sort_values().index])
print(clist)

values, counts = np.unique(y_kmeans, return_counts=True)
print(values,counts)

mapping = np.append(clist[0], clist[1:][clist[1:] != clist[:-1]])
c_df_k3['PU_cluster']=[labels[int(np.where(mapping == i)[0])] for i in y_kmeans]
c_df_k3.head()
[1 2 1 ... 2 1 1]
[1 1 1 ... 0 0 0]
[0 1 2] [  106 10740  5891]
Out[356]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed PercentagePointsRedeemed MonthlyTotalFlights MonthlyPointsAccumulated ChurnIndicator_bool PointUsage PU_cluster
0 480934 Canada Ontario Toronto M2Z 4K1 Female Bachelor 83236.0 Married Star ... 51877 5224.44 1418 115 0.27 5.03 153.66 False 0.271417 low
1 549612 Canada Alberta Edmonton T3G 6Y6 Male College 0.0 Divorced Star ... 41578 4176.04 1971 159 0.47 6.52 126.55 False 0.471978 mid
2 429460 Canada British Columbia Vancouver V6E 3D9 Male College 0.0 Single Star ... 19664 1963.00 374 30 0.19 2.07 46.74 True 0.190525 low
3 608370 Canada Ontario Toronto P1W 1K4 Male College 0.0 Single Star ... 36043 3626.68 1291 105 0.36 2.27 51.81 False 0.355973 mid
4 530508 Canada Quebec Hull J8Y 3Z5 Male Bachelor 103495.0 Married Star ... 36840 3689.68 0 0 0.00 3.52 73.79 False 0.000000 low

5 rows × 27 columns

5.2 K-means Clustering¶

5.2.1 Distribution of CLV Cluster by Gender¶

In [357]:
# Group by CLV_cluster and Gender
grouped = c_df_k1.groupby(["CLV_cluster", "Gender"])

# Count occurrences of each gender within each cluster
cluster_gender_counts = grouped.size().unstack()

# Plot bar chart
cluster_gender_counts.plot(kind='bar', color=['b', 'r'], alpha=0.7)

# Set labels and title
plt.xlabel('CLV Cluster')
plt.ylabel('Count')
plt.title('Distribution of CLV Cluster by Gender')

# Show plot
plt.legend(title='Gender')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.2 Distribution of CLV Cluster by Salary¶

In [358]:
c = np.array(['b', 'g', 'r', 'c'])
labels =['low','mid-low','mid-high', 'high']

grouped = c_df_k1.groupby("CLV_cluster")

for name, group in grouped:
    plt.plot(group["Salary"],color = c[np.array(labels) == name][0], marker='o', 
             linestyle ='', data = c_df_k1)

5.2.3 Distribution of CLV Cluster by Education¶

In [359]:
grouped = c_df_k1.groupby(["CLV_cluster", "Education"])

cluster_edu_counts = grouped.size().unstack()

cluster_edu_counts.plot(kind='bar', color=['b', 'r', 'g', 'c', 'm'], alpha=0.7)

plt.xlabel('CLV Cluster')
plt.ylabel('Count')
plt.title('Distribution of CLV Cluster by Education')

plt.legend(title='Education')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.4 Distribution of CLV Cluster by Loyalty Card¶

In [360]:
grouped = c_df_k1.groupby(["CLV_cluster", "LoyaltyCard"])

cluster_loyalty_counts = grouped.size().unstack()

cluster_loyalty_counts.plot(kind='bar', color=['b', 'r', 'g'], alpha=0.7)

plt.xlabel('CLV Cluster')
plt.ylabel('Count')
plt.title('Distribution of CLV Cluster by LoyaltyCard')

plt.legend(title='LoyaltyCard')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.5 Distribution of CLV Cluster by Marital Status¶

In [361]:
grouped = c_df_k1.groupby(["CLV_cluster", "MaritalStatus"])

cluster_loyalty_counts = grouped.size().unstack()

cluster_loyalty_counts.plot(kind='bar', color=['b', 'r', 'g'], alpha=0.7)

plt.xlabel('CLV Cluster')
plt.ylabel('Count')
plt.title('Distribution of CLV Cluster by MaritalStatus')

plt.legend(title='LoyaltyCard')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.6 Distribution of Average Distance Cluster by Gender¶

In [362]:
# Group by AvgDist_cluster and Gender
grouped = c_df_k2.groupby(["AvgDist_cluster", "Gender"])

cluster_gender_counts = grouped.size().unstack()
cluster_gender_counts.plot(kind='bar', color=['b', 'r'], alpha=0.7)
plt.xlabel('AvgDist Cluster')
plt.ylabel('Count')
plt.title('Distribution of AvgDist Cluster by Gender')
plt.legend(title='Gender')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.7 Distribution of Average Distance Cluster by Salary¶

In [363]:
c = np.array(['b', 'g', 'r', 'c', 'm'])
labels =['short','mid-short', 'mid', 'mid-high', 'long']

grouped = c_df_k2.groupby("AvgDist_cluster")

for name, group in grouped:
    plt.plot(group["Salary"],color = c[np.array(labels) == name][0], marker='o', 
             linestyle ='', data = c_df_k1)

5.2.8 Distribution of Average Distance Cluster by Education¶

In [364]:
grouped = c_df_k2.groupby(["AvgDist_cluster", "Education"])

cluster_edu_counts = grouped.size().unstack()

cluster_edu_counts.plot(kind='bar', color=['b', 'r', 'g', 'c', 'm'], alpha=0.7)

plt.xlabel('AvgDist Cluster')
plt.ylabel('Count')
plt.title('Distribution of AvgDist Cluster by Education')

plt.legend(title='Education')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.9 Distribution of Average Distance Cluster by Loyalty Card¶

In [365]:
grouped = c_df_k2.groupby(["AvgDist_cluster", "LoyaltyCard"])

cluster_loyalty_counts = grouped.size().unstack()

cluster_loyalty_counts.plot(kind='bar', color=['b', 'r', 'g'], alpha=0.7)

plt.xlabel('AvgDist Cluster')
plt.ylabel('Count')
plt.title('Distribution of AvgDist Cluster by LoyaltyCard')

plt.legend(title='LoyaltyCard')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.10 Distribution of Average Distance Cluster by Enrollment Type¶

In [366]:
grouped = c_df_k2.groupby(["AvgDist_cluster", "EnrollmentType"])

cluster_enrol_counts = grouped.size().unstack()

cluster_enrol_counts.plot(kind='bar', color=['b', 'r'], alpha=0.7)

plt.xlabel('AvgDist Cluster')
plt.ylabel('Count')
plt.title('Distribution of AvgDist Cluster by EnrollmentType')

plt.legend(title='EnrollmentType')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.11 Distribution of Point Usage Cluster by Loyalty Card¶

In [367]:
grouped = c_df_k3.groupby(["PU_cluster", "LoyaltyCard"])

cluster_loyalty_counts = grouped.size().unstack()

cluster_loyalty_counts.plot(kind='bar', color=['b', 'r', 'g'], alpha=0.7)

plt.xlabel('PU Cluster')
plt.ylabel('Count')
plt.title('Distribution of PU Cluster by LoyaltyCard')

plt.legend(title='LoyaltyCard')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.12 Distribution of Point Usage Cluster by Enrollment Type¶

In [368]:
grouped = c_df_k3.groupby(["PU_cluster", "EnrollmentType"])

cluster_enrol_counts = grouped.size().unstack()

cluster_enrol_counts.plot(kind='bar', color=['b', 'r'], alpha=0.7)

plt.xlabel('PU Cluster')
plt.ylabel('Count')
plt.title('Distribution of PU Cluster by EnrollmentType')

plt.legend(title='EnrollmentType')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.13 Distribution of Point Usage Cluster by Education¶

In [369]:
grouped = c_df_k3.groupby(["PU_cluster", "Education"])

cluster_edu_counts = grouped.size().unstack()

cluster_edu_counts.plot(kind='bar', color=['b', 'r', 'g', 'c', 'm'], alpha=0.7)

plt.xlabel('PU Cluster')
plt.ylabel('Count')
plt.title('Distribution of PU Cluster by Education')

plt.legend(title='Education')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.2.14 Distribution of Point Usage Cluster by Marital Status¶

In [370]:
grouped = c_df_k3.groupby(["PU_cluster", "MaritalStatus"])

cluster_edu_counts = grouped.size().unstack()

cluster_edu_counts.plot(kind='bar', color=['b', 'r', 'g', 'c', 'm'], alpha=0.7)

plt.xlabel('PU Cluster')
plt.ylabel('Count')
plt.title('Distribution of PU Cluster by Marital Status')

plt.legend(title='Education')
plt.xticks(rotation=0)  # Rotate x-axis labels if needed
plt.tight_layout()  # Adjust layout to prevent clipping of labels
plt.show()

5.3 Clustering based on all features¶

In [371]:
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
In [372]:
loyalty_df_cluster = loyalty_df.copy()
In [373]:
loyalty_df_cluster.columns
Out[373]:
Index(['LoyaltyNumber', 'Country', 'Province', 'City', 'PostalCode', 'Gender',
       'Education', 'Salary', 'MaritalStatus', 'LoyaltyCard', 'CLV',
       'EnrollmentType', 'EnrollmentYear', 'EnrollmentMonth',
       'CancellationYear', 'CancellationMonth', 'EnrollmentDate',
       'EnrollmentYearMonth', 'CancellationDate', 'CancellationYearMonth',
       'ChurnIndicator', 'MonthsOfMembership', 'TotalFlightsBooked',
       'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed', 'PercentagePointsRedeemed',
       'MonthlyTotalFlights', 'MonthlyPointsAccumulated',
       'ChurnIndicator_bool'],
      dtype='object')
In [374]:
# Extracting only the numerical features
cluster_features = ['Salary','CLV','MonthsOfMembership','TotalFlightsBooked',
       'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed']
In [375]:
# Select features for clustering
X = loyalty_df_cluster.loc[:,cluster_features]
In [376]:
# Fit standard scaler
scaler = StandardScaler()
scaled_features = scaler.fit_transform(X)
In [377]:
# Apply PCA to reduce dimensionality to 2D
pca = PCA(n_components=3)
X_pca = pca.fit_transform(scaled_features)

# Get the components
components = pca.components_

# Create a DataFrame to view component values in order to identify more important features for clustering
component_df = pd.DataFrame(components, columns=cluster_features, index=['PC1', 'PC2', 'PC3'])
component_df
Out[377]:
Salary CLV MonthsOfMembership TotalFlightsBooked TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed
PC1 0.001779 -0.002746 0.256215 0.391827 0.370512 0.399315 0.395435 0.394964 0.292972 0.293061
PC2 -0.040422 0.018458 -0.217314 -0.201056 -0.061576 -0.175131 -0.183925 -0.183603 0.635855 0.635660
PC3 0.713647 0.699477 0.027271 -0.008601 -0.012483 -0.009772 -0.005293 -0.000071 0.013082 0.013134
In [378]:
# Create a graph to determine the number of Principal Components and the features which contribute to its explained variance
explained_variance = pca.explained_variance_ratio_
plt.plot(range(1, len(explained_variance) + 1), explained_variance.cumsum(), marker='o')
plt.xlabel('Number of Principal Components')
plt.ylabel('Cumulative Explained Variance')
plt.show()

The first 2 Principal Components explain the majority of variance in the dataset. Only the features which are major contributors to the first 2 Princpal Components will be kept for clustering.

In [379]:
# Keeping only certain features based on their importance in the PCA
cluster_features = ['TotalFlightsBooked', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed']
In [380]:
# Select features for clustering
X = loyalty_df_cluster.loc[:,cluster_features]
In [381]:
# Fit standard scaler
scaler = StandardScaler()
scaled_features = scaler.fit_transform(X)
In [382]:
# Calculate within-cluster sum of squares (WCSS) for different values of k
wcss = []
for i in range(1, 11):
    kmeans = KMeans(n_clusters=i, init='k-means++', max_iter=300, n_init=10, random_state=2024)
    kmeans.fit(scaled_features)
    wcss.append(kmeans.inertia_)

# Plot the elbow method curve
plt.plot(range(1, 11), wcss)
plt.title('Elbow Method')
plt.xlabel('Number of Clusters')
plt.ylabel('WCSS')
plt.show()
In [383]:
# Calculate  silhouette coefficient for different values of k
silhoutte_coeff = []
for i in range(2, 11):
    kmeans = KMeans(n_clusters=i, init='k-means++', max_iter=300, n_init=10, random_state=2024)
    kmeans.fit(scaled_features)
    silhoutte_coeff.append(silhouette_score(X,kmeans.labels_))

# Plot the elbow method curve
plt.plot(range(2, 11), silhoutte_coeff)
plt.title('Elbow Method')
plt.xlabel('Number of Clusters')
plt.ylabel('silhouette')
plt.show()
In [384]:
# Number of clusters
k = 3

# Initialize model
kmean = KMeans(n_clusters=k, init='k-means++', max_iter=300, n_init=10, random_state=2024)

# fit model
kmean.fit(scaled_features)

# get labels
cluster_labels = kmean.labels_

# Add cluster labels to the DF and adjust the cluster number so that they are more intuitive (cluster 0 will become cluster 1, for example)
loyalty_df_cluster['cluster'] = cluster_labels+1
In [385]:
# Print the count of points in each cluster
pd.concat([loyalty_df_cluster['cluster'].value_counts().sort_index().reset_index(),
loyalty_df_cluster['cluster'].value_counts(normalize=True).sort_index().reset_index()],axis = 1)
Out[385]:
cluster count cluster proportion
0 1 7414 1 0.442971
1 2 4627 2 0.276453
2 3 4696 3 0.280576
In [386]:
# Graph by loyalty card
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(20, 20));

c = 0
for i in range(0,3):
    for j in range(0, 2):
        # This determines the x_lim and y_lim by considering the values of the features
        cluster_0 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 1][cluster_features[c]]
        cluster_1 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 2][cluster_features[c]]
        cluster_2 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 3][cluster_features[c]]
        cluster_3 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 4][cluster_features[c]]
        cluster_4 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 5][cluster_features[c]]
        cluster_5 = loyalty_df_cluster[loyalty_df_cluster['cluster'] == 6][cluster_features[c]]
        min_c = min(cluster_0.min(),cluster_1.min(),cluster_2.min(),cluster_3.min(),cluster_4.min(),cluster_5.min())
        max_c = max(cluster_0.max(),cluster_1.max(),cluster_2.max(),cluster_3.max(),cluster_4.max(),cluster_5.max())

        # This automatically divides the feature into bins based on the entire dataset
        num_bin = list(np.histogram(loyalty_df_cluster[cluster_features[c]],bins=10)[1])
        
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==1], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_1')
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==2], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_2')
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==3], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_3')
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==4], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_4')
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==5], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_5')
        sns.histplot(loyalty_df_cluster[loyalty_df_cluster['cluster']==6], x = cluster_features[c], ax = axs[i,j], stat = 'probability', alpha = 0.3, bins = num_bin, label = 'cluster_6')
        axs[i, j].set_xlim(min_c,max_c)
        axs[i, j].legend(title='Cluster')
        c += 1
                

plt.tight_layout()
In [387]:
# numerical features for aggregation
num_features = ['Salary','CLV','MonthsOfMembership','TotalFlightsBooked',
       'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed']
In [388]:
median_clusters = loyalty_df_cluster.groupby('cluster')[num_features].median()
In [389]:
# Function to apply colors based on value positions
def highlight_positions(s):
    is_min = s.isin(s.nsmallest(1))
    is_max = s.isin(s.nlargest(1))
    is_mid = ~is_min & ~is_max

    result = ['background-color: #dddcdc' if v else '' for v in is_min]
    result = ['background-color: #ffaf93' if v else res for v, res in zip(is_max, result)]
    result = ['background-color: #93fff5' if v else res for v, res in zip(is_mid, result)]
    
    return result
In [390]:
median_clusters = median_clusters.style.apply(highlight_positions)
In [391]:
median_clusters
Out[391]:
  Salary CLV MonthsOfMembership TotalFlightsBooked TotalFlightsWithCompanions TotalTotalFlights TotalDistance TotalPointsAccumulated TotalPointsRedeemed TotalDollarCostPointsRedeemed
cluster                    
1 63985.000000 5768.460000 43.000000 122.000000 28.000000 151.000000 35968.000000 3674.920000 523.000000 42.000000
2 63863.000000 5780.180000 47.000000 137.000000 36.000000 174.000000 40788.000000 4168.250000 1552.000000 126.000000
3 62866.000000 5796.580000 8.000000 21.000000 4.000000 27.000000 6152.000000 616.500000 0.000000 0.000000
In [392]:
fig, axs = plt.subplots(nrows = 3, ncols= 2, figsize = (20,20))

c = 0

for i in range(0,3):
    for j in range(0,2):
        if i*j == 2:
            sns.scatterplot(loyalty_df_cluster, x = cluster_features[0], y = cluster_features[-1], hue='cluster', ax = axs[i,j])
        else:    
            sns.scatterplot(loyalty_df_cluster, x = cluster_features[c], y = cluster_features[c+1], hue='cluster', ax = axs[i,j])
        c += 1

plt.show()
plt.tight_layout()
<Figure size 640x480 with 0 Axes>

5.4 Determining Optimal Coupon Values by different segmentation methods¶

In [393]:
loyalty_df_metric = loyalty_df_cluster.copy()
In [394]:
# Remove all customers with 0 months of membership and 0 flight flown
loyalty_df_metric = loyalty_df_metric[loyalty_df_metric['TotalDistance']!=0]
In [395]:
# Calculate Customers' Average Monthly CLV
loyalty_df_metric['MonthsOfMembership'] = loyalty_df_metric['MonthsOfMembership'].apply(lambda x: 1 if x== 0 else x)
loyalty_df_metric['AvgCLV'] = loyalty_df_metric['CLV']/loyalty_df_metric['MonthsOfMembership']
In [396]:
# Calculate Customers' Unit Revenue based on the Total Flights over the last 2 years
loyalty_df_metric['UnitRevenue'] = (loyalty_df_metric['AvgCLV']*24)/loyalty_df_metric['TotalTotalFlights']

loyalty_df_metric['UnitRevenue'] = np.where(
    (loyalty_df_metric['MonthsOfMembership'] == 1) & (loyalty_df_metric['TotalTotalFlights']==1),
    loyalty_df_metric['AvgCLV'],
    loyalty_df_metric['UnitRevenue']
)

5.4.1 Promotions based on no differentiation (one large customer group)¶

In [397]:
# This function will determine the optimal customer index for the optimal coupon value
def promo_ob3_v2(customers):  
    marketing_cost = 0.2
    l = np.array(customers['UnitRevenue'].sort_values())
    return l[np.argmax(np.add.accumulate(l) - l*marketing_cost*len(customers))]*marketing_cost
In [398]:
# Calculate profit and revenue based on no segmentation
m_cost = 0.2

revenue_unsegmented = sum(loyalty_df_metric['UnitRevenue'][loyalty_df_metric['UnitRevenue']*m_cost <= promo_ob3_v2(loyalty_df_metric)])
profit_unsegmented = revenue_unsegmented - promo_ob3_v2(loyalty_df_metric)*len(loyalty_df_metric)

print(f"Without customer segmentation:\nProfit is {profit_unsegmented}") 
print(f"Profit margin is {profit_unsegmented/revenue_unsegmented}")
print(f'Total revenue is {revenue_unsegmented}')
print(f'Total cost is:', promo_ob3_v2(loyalty_df_metric)*len(loyalty_df_metric))
Without customer segmentation:
Profit is 38505.985186105885
Profit margin is 0.17774345978361278
Total revenue is 216637.9861907919
Total cost is: 178132.00100468603

5.4.2 Differentiated promotions based on categorical characteristics¶

In [399]:
loyalty_df_metric['target_customer'] = loyalty_df_metric['Education'] + loyalty_df_metric['LoyaltyCard'] + loyalty_df_metric['MaritalStatus']
loyalty_df_metric['target_customer_groups'] = loyalty_df_metric['target_customer'].apply(lambda x: 'target' if x == 'BachelorAuroraMarried' else 'other')
In [400]:
labels = list(set(loyalty_df_metric['target_customer']))
In [401]:
# Calculate profit and revenue based on grouped segmentations

def promo_ob3_v3(customers, group_label): 
    marketing_cost = 0.2
    l = np.array(customers[customers['target_customer']==group_label]['UnitRevenue'].sort_values())
    return l[np.argmax(np.add.accumulate(l) - l*marketing_cost*len(customers[customers['target_customer']==group_label]))]*marketing_cost
In [402]:
profits = []
revenue = []
cost = []
for i in labels:
    m_cost = 0.2
    revenue_segmented = sum(loyalty_df_metric['UnitRevenue'][(loyalty_df_metric['UnitRevenue']*m_cost <= promo_ob3_v3(loyalty_df_metric,i)) & (loyalty_df_metric['target_customer']==i)])
    profit_segmented = revenue_segmented - promo_ob3_v3(loyalty_df_metric,i)*len(loyalty_df_metric[loyalty_df_metric['target_customer']==i])
    cost.append(promo_ob3_v3(loyalty_df_metric,i)*len(loyalty_df_metric[loyalty_df_metric['target_customer']==i]))
    revenue.append(revenue_segmented)
    profits.append(profit_segmented)

print(f"With customer segmentation:\nProfit is {sum(profits)}") 
print(f"Profit margin is {sum(profits)/sum(revenue)}")
print(f'Total revenue is {sum(revenue)}')
print(f'Total cost is {sum(cost)}')
With customer segmentation:
Profit is 50930.078002717484
Profit margin is 0.2185888835412032
Total revenue is 232994.82195816856
Total cost is 182064.74395545103

5.4.3 Differentiated promotions based on k-means clusters¶

In [403]:
labels = [1,2,3]
In [404]:
# Calculate profit and revenue based on grouped segmentations

def promo_ob3_v3(customers, cluster_num): 
    marketing_cost = 0.2
    l = np.array(customers[customers['cluster']==cluster_num]['UnitRevenue'].sort_values())
    return l[np.argmax(np.add.accumulate(l) - l*marketing_cost*len(customers[customers['cluster']==cluster_num]))]*marketing_cost
In [405]:
profits = []
revenue = []
cost = []
for i in labels:
    m_cost = 0.2
    revenue_segmented = sum(loyalty_df_metric['UnitRevenue'][(loyalty_df_metric['UnitRevenue']*m_cost <= promo_ob3_v3(loyalty_df_metric,i)) & (loyalty_df_metric['cluster']==i)])
    profit_segmented = revenue_segmented - promo_ob3_v3(loyalty_df_metric,i)*len(loyalty_df_metric[loyalty_df_metric['cluster']==i])
    cost.append(promo_ob3_v3(loyalty_df_metric,i)*len(loyalty_df_metric[loyalty_df_metric['cluster']==i]))
    revenue.append(revenue_segmented)
    profits.append(profit_segmented)

print(f"With customer segmentation:\nProfit is {sum(profits)}") 
print(f"Profit margin is {sum(profits)/sum(revenue)}")
print(f'Total revenue is {sum(revenue)}')
print(f'Total cost is {sum(cost)}')
With customer segmentation:
Profit is 185374.4748100365
Profit margin is 0.18146255851987536
Total revenue is 1021557.7049175833
Total cost is 836183.2301075468

5.4.4 Crafting a new metric to balance raw profit and profit margin¶

Instead of just measuring solutions based on profit margin as a metric, we can create a new metric to add in the raw profit amount as an additional factor to add nuance to the metric. This can be caculated with the following formula:

$Margin Profit Score = log_{10}(Profit^2)*\frac{Profit}{Revenue}$

In [406]:
test_df = pd.DataFrame({'method':['m1','m2','m3'],
                        'profit':[38505,50930,185147],
                        'revenue':[216637,232994,1021297]})
In [407]:
test_df['log'] = np.log(50*test_df['profit'])
test_df['margin'] = (test_df['profit']/test_df['revenue'])
weight = 2
test_df['margin_profit_score'] = (np.log10(test_df['profit'])**weight)*((test_df['profit'])/test_df['revenue'])
test_df
Out[407]:
method profit revenue log margin margin_profit_score
0 m1 38505 216637 14.470566 0.177740 3.737327
1 m2 50930 232994 14.750230 0.218589 4.842978
2 m3 185147 1021297 16.040928 0.181286 5.030098

6 Service Upgrades for Customer Satisfaction¶

6.1 Merging with Airline Passenger Satisfaction dataset¶

In [408]:
satisfaction_file = 'satisfaction.csv'
satisfaction_df = pd.read_csv(os.path.join(DATA_LOC, satisfaction_file))
satisfaction_df.columns
Out[408]:
Index(['Unnamed: 0', 'id', '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'],
      dtype='object')
In [409]:
satisfaction_df['Customer Type']
Out[409]:
0            Loyal Customer
1         disloyal Customer
2            Loyal Customer
3            Loyal Customer
4            Loyal Customer
                ...        
129875    disloyal Customer
129876       Loyal Customer
129877       Loyal Customer
129878       Loyal Customer
129879       Loyal Customer
Name: Customer Type, Length: 129880, dtype: object
In [410]:
# we only need the columns with demo characteristics and survey results in satisfaction_df, we can discard the churn
keep_columns = ['id', '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']

satisfaction_df = satisfaction_df.loc[:,keep_columns]
In [411]:
# Create copy of loyalty_df_cluster (with cluster labels) for manipulation
loyalty_df_copy = loyalty_df_cluster.copy()
In [412]:
# Identify the Average distance per flight in loyalty df
loyalty_df_copy['AverageDistancePerFlight'] = loyalty_df_copy['TotalDistance']/loyalty_df_copy['TotalFlightsBooked']
In [413]:
# keep only average distances in satisfaction with a distance that is the same or lesser than the max average distance in loyalty_df
satisfaction_df_match = satisfaction_df[satisfaction_df['Flight Distance']<=max(loyalty_df_copy['AverageDistancePerFlight'])]
In [414]:
# Fill all missing values with 0
loyalty_df_copy['AverageDistancePerFlight'].fillna(0, inplace=True)
In [415]:
# Get the bins of average flight distance in the loyalty df
np.histogram(loyalty_df_copy['AverageDistancePerFlight'])
Out[415]:
(array([14422,  2227,    45,    15,    12,     3,     2,     8,     1,
            2], dtype=int64),
 array([   0. ,  352.7,  705.4, 1058.1, 1410.8, 1763.5, 2116.2, 2468.9,
        2821.6, 3174.3, 3527. ]))
In [416]:
# Create a new column to put the average flight distance into different bins based on the bins identified in loyalty_df
bins = [ 0 ,  352.7,  705.4, 1058.1, 1410.8, 1763.5, 2116.2, 2468.9,
        2821.6, 3174.3, 3527. ]
loyalty_df_copy['AverageDistancePerFlight_bin'] = pd.cut(loyalty_df_copy['AverageDistancePerFlight'],bins=bins, include_lowest=True)
satisfaction_df_match['AverageDistancePerFlight_bin'] = pd.cut(satisfaction_df_match['Flight Distance'],bins=bins, include_lowest=True)
In [417]:
# Define the age group for the people in the loyalty program dataframe
# If someone is Married or Divorced, they must be at least 16 years of age
# If someone has finished their bachelor degree, it is reasonable to assume they are at least 16 years of age?

# Define a function to apply the conditions
def age_group(row):
    if row['MaritalStatus'] in ['Married', 'Divorced'] or row['Education'] in ['Bachelor', 'Master', 'Doctor']:
        return '>=16'
    else:
        return 'Unknown'

# Apply the function to create the AgeGroup column
loyalty_df_copy['AgeGroup'] = loyalty_df_copy.apply(age_group, axis=1)
In [418]:
# We can considered customers who have churned as 'neutral or dissatisfied' in the satisfaction file
def satisfaction_loyalty(row):
    if row['ChurnIndicator'] == 0:
        return 'churned'
    else:
        return 'not-churned'

def satisfaction_satisfaction(row):
    if row['Customer Type'] == 'Loyal Customer':
        return 'churned'
    else:
        return 'not-churned'    
    
# Apply the function to create the satisfaction column in loyalty_df
loyalty_df_copy['churn_type'] = loyalty_df_copy.apply(satisfaction_loyalty, axis=1)
satisfaction_df_match['churn_type'] = satisfaction_df_match.apply(satisfaction_satisfaction, axis=1)
In [419]:
# Define the age group for the people in the satisfaction survey
satisfaction_df_match['AgeGroup'] = satisfaction_df_match['Age'].apply(lambda x: '>=16' if x >= 16 else 'Unknown')
In [420]:
# This function will combine the gender, avg flight distance, satisfaction, and age group into one column, just for sampling later on
def age_flight_gender_func(row):
    return str(row['Gender'] + str(row['AverageDistancePerFlight_bin']) + row['churn_type'] + row['AgeGroup'])

loyalty_df_copy['gender_age_flight'] = loyalty_df_copy.apply(age_flight_gender_func,axis = 1)
satisfaction_df_match['gender_age_flight'] = satisfaction_df_match.apply(age_flight_gender_func,axis = 1)
In [421]:
# Extract the proportions of each type of Gender + Flight bin + satisfaction + Age from the loyalty card dataframe
age_flight_gender = loyalty_df_copy.groupby('gender_age_flight')['gender_age_flight'].value_counts().reset_index()
age_flight_gender = dict(zip(age_flight_gender['gender_age_flight'],age_flight_gender['count']))

# Sample from the satisfaction df
result_df = []
for i,u in age_flight_gender.items():
    subset_df = satisfaction_df_match[satisfaction_df_match['gender_age_flight']==i]
    sampled_df = subset_df.sample(n = u, replace=True, random_state=2024)
    result_df.append(sampled_df)

sampled_df = pd.concat(result_df)
sampled_df = sampled_df.drop(['id', 'Gender', 'Customer Type', 'Age', 'Type of Travel',
       'Class', 'Flight Distance', 'AverageDistancePerFlight_bin', 'churn_type',
       'AgeGroup'], axis=1)

# Merge the survey results from the sampled df with the loyalty df
sampled_df = sampled_df.sort_values('gender_age_flight').reset_index()
loyalty_df_copy = loyalty_df_copy.sort_values('gender_age_flight').reset_index()

loyalty_satisfaction_df = pd.concat([loyalty_df_copy,sampled_df], axis=1)
In [422]:
loyalty_satisfaction_df.columns
Out[422]:
Index(['index', 'LoyaltyNumber', 'Country', 'Province', 'City', 'PostalCode',
       'Gender', 'Education', 'Salary', 'MaritalStatus', 'LoyaltyCard', 'CLV',
       'EnrollmentType', 'EnrollmentYear', 'EnrollmentMonth',
       'CancellationYear', 'CancellationMonth', 'EnrollmentDate',
       'EnrollmentYearMonth', 'CancellationDate', 'CancellationYearMonth',
       'ChurnIndicator', 'MonthsOfMembership', 'TotalFlightsBooked',
       'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed', 'PercentagePointsRedeemed',
       'MonthlyTotalFlights', 'MonthlyPointsAccumulated',
       'ChurnIndicator_bool', 'cluster', 'AverageDistancePerFlight',
       'AverageDistancePerFlight_bin', 'AgeGroup', 'churn_type',
       'gender_age_flight', 'index', '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', 'gender_age_flight'],
      dtype='object')
In [423]:
loyalty_satisfaction_lean = loyalty_satisfaction_df.drop(['index', 'gender_age_flight'], axis=1)
# loyalty_satisfaction_lean = loyalty_satisfaction_lean.drop_duplicates()
# loyalty_satisfaction_lean = loyalty_satisfaction_lean.iloc[:,:-1]
In [424]:
loyalty_satisfaction_lean
Out[424]:
LoyaltyNumber Country Province City PostalCode Gender Education Salary MaritalStatus LoyaltyCard ... 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 811333 Canada Ontario Toronto M1R 4K3 Female Bachelor 82937.0 Married Aurora ... 5 3 4 5 5 5 5 0 20.0 neutral or dissatisfied
1 882874 Canada Ontario Toronto M2M 7K8 Female Bachelor 53559.0 Married Star ... 4 4 5 1 1 3 4 5 4.0 satisfied
2 504525 Canada Quebec Montreal H2Y 4R4 Female Bachelor 52808.0 Married Nova ... 5 5 4 5 5 5 3 0 10.0 satisfied
3 574330 Canada Quebec Hull J8Y 3Z5 Female Bachelor 64968.0 Married Star ... 5 5 5 5 1 5 4 30 19.0 satisfied
4 607405 Canada Quebec Tremblant H5Y 2S9 Female Bachelor 72820.0 Married Nova ... 2 2 1 3 4 3 2 0 0.0 neutral or dissatisfied
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
16732 430221 Canada Manitoba Winnipeg R3R 3T4 Male Bachelor 90071.0 Divorced Star ... 5 3 2 5 1 4 5 0 0.0 satisfied
16733 867284 Canada Nova Scotia Halifax B3C 2M8 Male College 0.0 Single Nova ... 1 3 4 5 2 4 1 0 0.0 neutral or dissatisfied
16734 341904 Canada Manitoba Winnipeg R2C 0M5 Male College 0.0 Single Star ... 2 1 3 4 1 3 2 0 0.0 neutral or dissatisfied
16735 124377 Canada British Columbia Whistler V6T 1Y8 Male College 0.0 Single Nova ... 5 4 2 2 2 2 5 0 1.0 neutral or dissatisfied
16736 971608 Canada Manitoba Winnipeg R2C 0M5 Male Bachelor 73945.0 Single Star ... 1 5 5 4 3 4 1 0 0.0 neutral or dissatisfied

16737 rows × 55 columns

In [425]:
loyalty_satisfaction_lean.columns
Out[425]:
Index(['LoyaltyNumber', 'Country', 'Province', 'City', 'PostalCode', 'Gender',
       'Education', 'Salary', 'MaritalStatus', 'LoyaltyCard', 'CLV',
       'EnrollmentType', 'EnrollmentYear', 'EnrollmentMonth',
       'CancellationYear', 'CancellationMonth', 'EnrollmentDate',
       'EnrollmentYearMonth', 'CancellationDate', 'CancellationYearMonth',
       'ChurnIndicator', 'MonthsOfMembership', 'TotalFlightsBooked',
       'TotalFlightsWithCompanions', 'TotalTotalFlights', 'TotalDistance',
       'TotalPointsAccumulated', 'TotalPointsRedeemed',
       'TotalDollarCostPointsRedeemed', 'PercentagePointsRedeemed',
       'MonthlyTotalFlights', 'MonthlyPointsAccumulated',
       'ChurnIndicator_bool', 'cluster', 'AverageDistancePerFlight',
       'AverageDistancePerFlight_bin', 'AgeGroup', 'churn_type',
       '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'],
      dtype='object')
In [426]:
survey_columns = ['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', 'satisfaction']
In [427]:
sns.set_style('whitegrid', {'axes.grid' : False})
sns.set_palette('colorblind')
In [428]:
# Graph by of distribution of answers for satisfaction
fig, axs = plt.subplots(nrows=3, ncols=5, figsize=(20, 10))

c = 0
for i in range(3):
    for j in range(5):
        if c < len(survey_columns):  # Ensure we don't go out of bounds

            tally_survey = loyalty_satisfaction_lean[survey_columns[c]].value_counts(normalize=False).sort_index()
            axs[i,j].bar(tally_survey.index, tally_survey.values, alpha = 0.5)
            axs[i,j].set_title(survey_columns[c])
            
            if survey_columns[c] == 'satisfaction':
                unique_values = sorted(loyalty_satisfaction_lean[survey_columns[c]].unique())
                axs[i, j].set_title('Overall Satisfaction')
                axs[i, j].set_xticks(unique_values)
                axs[i, j].set_xticklabels(unique_values)
            else:
                axs[i, j].set_xticks([0, 1, 2, 3, 4, 5])
                axs[i, j].set_xticklabels([0,1,2,3,4,5])
       
            c += 1
        else:
            axs[i, j].axis('off')

plt.tight_layout()
plt.show()

6.2 Multivariate Analysis / Correlation Matrix¶

In [429]:
# Retain relevant columns for analysis
col = ['cluster', 'CLV', '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']

loyalty_satisfaction_rf = loyalty_satisfaction_lean[col]
loyalty_satisfaction_rf
Out[429]:
cluster CLV 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
0 2 20946.19 4 0 4 3 5 4 5 5 3 4 5 5 5 5
1 3 2599.31 1 1 1 1 4 4 4 4 4 5 1 1 3 4
2 1 2999.34 4 4 4 4 3 1 4 5 5 4 5 5 5 3
3 1 2599.97 5 4 4 4 1 5 4 5 5 5 5 1 5 4
4 1 2994.35 2 3 2 3 2 2 3 2 2 1 3 4 3 2
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
16732 3 4626.80 5 1 1 1 5 4 5 5 3 2 5 1 4 5
16733 3 13144.49 2 4 2 1 1 2 1 1 3 4 5 2 4 1
16734 3 2254.40 2 1 2 3 2 2 4 2 1 3 4 1 3 2
16735 3 8175.32 3 2 3 3 5 3 5 5 4 2 2 2 2 5
16736 3 12157.33 4 4 4 3 1 4 1 1 5 5 4 3 4 1

16737 rows × 16 columns

In [430]:
def corr_sig(df=None):
    p_matrix = np.zeros(shape=(df.shape[1],df.shape[1]))
    for col in df.columns:
        for col2 in df.drop(col,axis=1).columns:
            _ , p = stats.pearsonr(df[col],df[col2])
            p_matrix[df.columns.to_list().index(col),df.columns.to_list().index(col2)] = p
    return p_matrix

def plot_cor_matrix(corr, mask=None, annot_fontsize=9, label_fontsize=9):
    f, ax = plt.subplots(figsize=(11, 9))
    sns.heatmap(corr, ax=ax,
                mask=mask,
                # cosmetics
                annot=True, vmin=-1, vmax=1, center=0,
                cmap='PuBu_r', #linewidths=0.1, linecolor='grey', 
                cbar_kws={'orientation': 'vertical', 'shrink': 0.5, 'aspect': 30, 'pad': 0.02},
                annot_kws={"size": annot_fontsize})
    
    # Adjust axis labels font size
    ax.set_xticklabels(ax.get_xticklabels(), fontsize=label_fontsize)
    ax.set_yticklabels(ax.get_yticklabels(), fontsize=label_fontsize)
In [431]:
def corr_spear(df=None):
    p_matrix = np.zeros(shape=(df.shape[1],df.shape[1]))
    for col in df.columns:
        for col2 in df.drop(col,axis=1).columns:
            _ , p = stats.spearmanr(df[col],df[col2])
            p_matrix[df.columns.to_list().index(col),df.columns.to_list().index(col2)] = p
    return p_matrix
In [432]:
# Plotting without significance filtering
corr = loyalty_satisfaction_rf.corr()
mask = np.triu(corr)
plot_cor_matrix(corr,mask)
plt.show()
In [433]:
# Plotting with significance filter
corr = loyalty_satisfaction_rf.corr()                            # get correlation
p_values = corr_sig(loyalty_satisfaction_rf)                     # get p-Value
mask = np.invert(np.tril(p_values<0.05))    # mask - only get significant corr
plot_cor_matrix(corr,mask)  
In [434]:
loyalty_satisfaction_rf.describe()
Out[434]:
cluster CLV 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
count 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000 16737.000000
mean 1.837605 7988.896536 2.693493 3.130848 2.569696 2.961463 3.173090 3.001016 3.319113 3.283683 3.290076 3.226325 3.542272 3.201828 3.561630 3.210014
std 0.834995 6860.982280 1.334514 1.578784 1.469881 1.250481 1.351464 1.435995 1.336952 1.355295 1.309316 1.360522 1.216873 1.302305 1.201464 1.335839
min 1.000000 1898.010000 0.000000 0.000000 0.000000 1.000000 0.000000 0.000000 1.000000 1.000000 1.000000 0.000000 1.000000 1.000000 1.000000 1.000000
25% 1.000000 3980.840000 2.000000 2.000000 1.000000 2.000000 2.000000 2.000000 2.000000 2.000000 2.000000 2.000000 3.000000 2.000000 3.000000 2.000000
50% 2.000000 5780.180000 3.000000 4.000000 3.000000 3.000000 3.000000 3.000000 4.000000 3.000000 3.000000 3.000000 4.000000 3.000000 4.000000 3.000000
75% 3.000000 8940.580000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000 4.000000
max 3.000000 83325.380000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000 5.000000

6.3 Regression - Service Upgrades to Improve Customer Satisfaction¶

6.3.1 Baseline Model¶

In [435]:
loyalty_satisfaction_clean = loyalty_satisfaction_lean.copy()

# Replace 0 with NaN for Survey Questions
survey_columns = ['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']
loyalty_satisfaction_clean[survey_columns] = loyalty_satisfaction_clean[survey_columns].replace(0, np.nan)

# Retain relevant columns
keep_columns = [ 
       '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', 'satisfaction']
loyalty_satisfaction_clean = loyalty_satisfaction_clean[keep_columns]

# Drop rows where response = 0
loyalty_satisfaction_clean = loyalty_satisfaction_clean.dropna()

# Create Target Variable 'Overall Satisfaction'
loyalty_satisfaction_clean.loc[loyalty_satisfaction_clean['satisfaction'] == 'satisfied', 'Overall Satisfaction'] = 1
loyalty_satisfaction_clean.loc[loyalty_satisfaction_clean['satisfaction'] == 'neutral or dissatisfied', 'Overall Satisfaction'] = 0
loyalty_satisfaction_clean['Overall Satisfaction'] = loyalty_satisfaction_clean['Overall Satisfaction'].astype(int)
loyalty_satisfaction_clean = loyalty_satisfaction_clean.drop(columns=['satisfaction'])

loyalty_satisfaction_clean
Out[435]:
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 Overall Satisfaction
1 1.0 1.0 1.0 1 4.0 4.0 4 4 4 5.0 1 1 3 4 1
2 4.0 4.0 4.0 4 3.0 1.0 4 5 5 4.0 5 5 5 3 1
3 5.0 4.0 4.0 4 1.0 5.0 4 5 5 5.0 5 1 5 4 1
4 2.0 3.0 2.0 3 2.0 2.0 3 2 2 1.0 3 4 3 2 0
5 5.0 1.0 5.0 3 4.0 3.0 3 2 2 5.0 4 3 2 4 1
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
16732 5.0 1.0 1.0 1 5.0 4.0 5 5 3 2.0 5 1 4 5 1
16733 2.0 4.0 2.0 1 1.0 2.0 1 1 3 4.0 5 2 4 1 0
16734 2.0 1.0 2.0 3 2.0 2.0 4 2 1 3.0 4 1 3 2 0
16735 3.0 2.0 3.0 3 5.0 3.0 5 5 4 2.0 2 2 2 5 0
16736 4.0 4.0 4.0 3 1.0 4.0 1 1 5 5.0 4 3 4 1 0

14395 rows × 15 columns

In [436]:
# Checking for distribution of target variable
targetcount = loyalty_satisfaction_clean["Overall Satisfaction"].value_counts()
targetcount.plot(kind="bar", title="Class distribution of the target variable")
Out[436]:
<Axes: title={'center': 'Class distribution of the target variable'}, xlabel='Overall Satisfaction'>
In [437]:
#Split Dataset for Training and Testing
trainDF_lr, testDF_lr = train_test_split(loyalty_satisfaction_clean, 
                                   test_size=0.2, 
                                   random_state=1234, 
                                   stratify=loyalty_satisfaction_clean[["Overall Satisfaction"]])

X_train_lr = trainDF_lr.iloc[:, trainDF_lr.columns != "Overall Satisfaction"]
y_train_lr = trainDF_lr.iloc[:, trainDF_lr.columns == "Overall Satisfaction"]
X_test_lr = testDF_lr.iloc[:, testDF_lr.columns != "Overall Satisfaction"]
y_test_lr = testDF_lr.iloc[:, testDF_lr.columns == "Overall Satisfaction"]
In [438]:
# Train a Logistic Regression model
lr_model = LogisticRegression(max_iter=1000)
lr_model.fit(X_train_lr, y_train_lr)

y_pred_lr = lr_model.predict(X_test_lr)
    
#  Model Evaluation
print(classification_report(y_test_lr, y_pred_lr))
              precision    recall  f1-score   support

           0       0.88      0.90      0.89      1942
           1       0.79      0.74      0.77       937

    accuracy                           0.85      2879
   macro avg       0.84      0.82      0.83      2879
weighted avg       0.85      0.85      0.85      2879

In [439]:
# Predict probabilities on the test set
baseline_probabilities = lr_model.predict_proba(X_test_lr)[:, 1]  # Predicted probabilities of satisfaction
baseline_avg_satisfaction = np.mean(baseline_probabilities)
print(f"Baseline Average Satisfaction: {baseline_avg_satisfaction:.4f}")
Baseline Average Satisfaction: 0.3268
In [440]:
# Get coefficients
lr_coefficients = lr_model.coef_[0]

# Create a DataFrame for better visualization
lr_coefficients_df = pd.DataFrame({
    'Feature': X_train_lr.columns,
    'Coefficient': lr_coefficients
})

# Sort the DataFrame by absolute value of coefficients
lr_coefficients_df = lr_coefficients_df.reindex(lr_coefficients_df.Coefficient.abs().sort_values(ascending=False).index)

# Plot the coefficients
colors = ['lightblue' if coef > 0 else 'pink' for coef in lr_coefficients_df['Coefficient']]

plt.figure(figsize=(12, 8))
bars = plt.barh(lr_coefficients_df['Feature'], lr_coefficients_df['Coefficient'], color=colors)
plt.xlabel('Coefficient')
plt.title('Feature Coefficients from Logistic Regression Model')
plt.gca().invert_yaxis()  # Invert y-axis to have the most significant feature on top

# Add values at the end of the bars
for bar in bars:
    width = bar.get_width()
    plt.text(width, bar.get_y() + bar.get_height() / 2, f'{width:.2f}', 
             va='center', ha='left', color='black')

plt.show()

# Display the coefficients DataFrame
lr_coefficients_df
Out[440]:
Feature Coefficient
0 Inflight wifi service 0.877880
5 Online boarding 0.786087
7 Inflight entertainment 0.741962
1 Departure/Arrival time convenient -0.542590
9 Leg room service 0.417182
8 On-board service 0.334594
11 Checkin service 0.220951
4 Food and drink -0.219847
2 Ease of Online booking 0.137866
12 Inflight service -0.088342
13 Cleanliness -0.086174
3 Gate location 0.058092
6 Seat comfort 0.054074
10 Baggage handling -0.035825

6.3.2 Target All Features Equally¶

In [441]:
# 1. Allocate Budget
# Define Equal Budget Allocation
num_features = 14
total_budget = 1000000

# Budget per feature
budget_per_feature_equal = total_budget / num_features
In [442]:
# 2. Apply improvements

# Ensure that the features used in the model are correctly referenced
features = X_train_lr.columns

# Define an assumed improvement value per unit budget (based on domain knowledge or assumptions)
unit_budget_to_improvement = 0.1  # Assume each $100,000 investment increases the feature by 0.1 unit

# Calculate the improvement factor based on this assumption
improvement_factor = unit_budget_to_improvement / 100_000

print(f"Derived Improvement Factor: {improvement_factor}")

# Create budget allocation dictionary for equal allocation
budget_allocation_equal = {feature: budget_per_feature_equal for feature in X_train_lr.columns}

# Function to apply improvements based on budget allocation
def apply_improvements(X, budget_allocation, improvement_factor):
    X_improved = X.copy()
    
    for feature, budget in budget_allocation.items():
        improvement_value = budget * improvement_factor
        if feature in X_improved.columns:
            X_improved[feature] = np.clip(X_improved[feature] + improvement_value, 1, 5)  # Assuming scale of 1 to 5
    
    return X_improved

# Apply improvements to the test set
X_test_improved_equal = apply_improvements(X_test_lr, budget_allocation_equal, improvement_factor)
Derived Improvement Factor: 1e-06
In [443]:
# 3. Measure Impact
# Calculate new predicted probabilities after feature improvements
predicted_probabilities_improved_equal = lr_model.predict_proba(X_test_improved_equal)[:, 1]

# Calculate new average satisfaction
new_avg_satisfaction_equal = np.mean(predicted_probabilities_improved_equal)
print(f"New Average Satisfaction (Equal Allocation): {new_avg_satisfaction_equal:.4f}")

# Measure the impact
impact_equal = new_avg_satisfaction_equal - baseline_avg_satisfaction
print(f"Impact of Equal Allocation on Satisfaction: {impact_equal:.4f}")
New Average Satisfaction (Equal Allocation): 0.3443
Impact of Equal Allocation on Satisfaction: 0.0175

6.3.3 Target Poorest Performing Features¶

In [444]:
# Remove 'Overall Satisfaction' column
loyalty_satisfaction_clean_filtered = loyalty_satisfaction_clean.drop(columns=['Overall Satisfaction'])

# Calculate the mean score for each feature excluding 'Overall Satisfaction'
mean_scores = loyalty_satisfaction_clean_filtered.mean()

# Create a DataFrame for better visualization
mean_scores_df = pd.DataFrame(mean_scores, columns=['Mean Score'])

# Sort the DataFrame by mean scores in ascending order
mean_scores_df = mean_scores_df.sort_values(by='Mean Score')

mean_scores_df.style.set_caption("Features and their Mean Scores").background_gradient(cmap='PiYG')
Out[444]:
Features and their Mean Scores
  Mean Score
Inflight wifi service 2.828135
Ease of Online booking 2.838694
Gate location 2.977075
Online boarding 3.156999
Checkin service 3.176659
Food and drink 3.181938
Cleanliness 3.209726
Leg room service 3.265787
On-board service 3.272873
Inflight entertainment 3.306982
Departure/Arrival time convenient 3.316568
Seat comfort 3.325113
Baggage handling 3.537478
Inflight service 3.554498
In [445]:
# 1. Allocate Budget
# Rank features based on mean scores (ascending order)
ranked_features = mean_scores.sort_values()

# Select the lowest 3 performing features (for example)
lowest_features = ranked_features.index[:3]

# Budget per lowest performing feature
budget_per_feature_lowest = total_budget / len(lowest_features)
In [446]:
# 2. Apply Improvements
# Create budget allocation dictionary for lowest performing features
budget_allocation_lowest = {feature: budget_per_feature_lowest for feature in lowest_features}

# Apply improvements to the test set
X_test_improved_lowest = apply_improvements(X_test_lr, budget_allocation_lowest, improvement_factor)
In [447]:
# 3. Measure Impact
# Calculate new predicted probabilities after feature improvements
predicted_probabilities_improved_lowest = lr_model.predict_proba(X_test_improved_lowest)[:, 1]

# Calculate new average satisfaction
new_avg_satisfaction_lowest = np.mean(predicted_probabilities_improved_lowest)
print(f"New Average Satisfaction (Lowest Performing Features): {new_avg_satisfaction_lowest:.4f}")

# Measure the impact
impact_lowest = new_avg_satisfaction_lowest - baseline_avg_satisfaction
print(f"Impact of Targeting Lowest Performing Features on Satisfaction: {impact_lowest:.4f}")
New Average Satisfaction (Lowest Performing Features): 0.3625
Impact of Targeting Lowest Performing Features on Satisfaction: 0.0357

6.3.4 Target Most Important Features¶

In [448]:
# 1. Calculate Feature Importance

# Calculate the absolute values of the coefficients
lr_coefficients_df['Abs_Coefficient'] = lr_coefficients_df['Coefficient'].abs()

# Sort the DataFrame by absolute value of coefficients in descending order
# Separate positive and negative coefficients
positive_coef_df = lr_coefficients_df[lr_coefficients_df['Coefficient'] > 0].sort_values(by='Coefficient', ascending=False)
negative_coef_df = lr_coefficients_df[lr_coefficients_df['Coefficient'] < 0].sort_values(by='Coefficient')
print("Feature importance based on logistic regression coefficients:")
print(positive_coef_df)
Feature importance based on logistic regression coefficients:
                   Feature  Coefficient  Abs_Coefficient
0    Inflight wifi service     0.877880         0.877880
5          Online boarding     0.786087         0.786087
7   Inflight entertainment     0.741962         0.741962
9         Leg room service     0.417182         0.417182
8         On-board service     0.334594         0.334594
11         Checkin service     0.220951         0.220951
2   Ease of Online booking     0.137866         0.137866
3            Gate location     0.058092         0.058092
6             Seat comfort     0.054074         0.054074
In [449]:
# 2. Allocate Budget

# Calculate total sum of absolute positive coefficients
total_positive_abs_coef = positive_coef_df['Coefficient'].sum()

# Calculate budget allocation for each positive feature based on its importance
positive_coef_df['Budget_Allocation'] = (positive_coef_df['Coefficient'] / total_positive_abs_coef) * total_budget

# Display the budget allocation
print("Budget Allocation based on Positive Feature Importance:")
print(positive_coef_df[['Feature', 'Budget_Allocation']])
Budget Allocation based on Positive Feature Importance:
                   Feature  Budget_Allocation
0    Inflight wifi service      241927.682828
5          Online boarding      216631.319705
7   Inflight entertainment      204471.181712
9         Leg room service      114967.691932
8         On-board service       92207.988369
11         Checkin service       60889.933058
2   Ease of Online booking       37993.469594
3            Gate location       16008.961634
6             Seat comfort       14901.771168
In [450]:
# 3. Apply Improvements
# Create budget allocation dictionary for positive features
budget_allocation_positive = positive_coef_df.set_index('Feature')['Budget_Allocation'].to_dict()

# Apply improvements to the test set
X_test_improved_positive = apply_improvements(X_test_lr, budget_allocation_positive, improvement_factor)
In [451]:
# 4. Measure Impact
# Calculate new predicted probabilities after feature improvements
predicted_probabilities_improved_positive = lr_model.predict_proba(X_test_improved_positive)[:, 1]

# Calculate new average satisfaction
new_avg_satisfaction_positive = np.mean(predicted_probabilities_improved_positive)
print(f"New Average Satisfaction (Positive Features): {new_avg_satisfaction_positive:.4f}")

# Measure the impact
impact_positive = new_avg_satisfaction_positive - baseline_avg_satisfaction
print(f"Impact of Targeting Positive Features on Satisfaction: {impact_positive:.4f}")
New Average Satisfaction (Positive Features): 0.3868
Impact of Targeting Positive Features on Satisfaction: 0.0600