Telecommunications Customer Churn Drivers Analysis
This repository contains the Exploratory Data Analysis (EDA) and subsequent Executive Summary for an initiative focused on identifying the primary drivers of customer attrition within the…
Project Overview
This repository contains the Exploratory Data Analysis (EDA) and subsequent Executive Summary for an initiative focused on identifying the primary drivers of customer attrition within the telecommunications customer base. The goal is to define a high-risk churn profile that can be utilized by the Retention and Marketing departments to deploy targeted intervention strategies, thereby maximizing lifetime customer value (LTV) and reducing churn costs.
Objective
-
The core objective of this analysis is to:
-
Quantify the influence of key features (e.g., contract type, service bundle, payment method, and demographics) on customer churn.
-
Define a clear, actionable High-Risk Churn Profile.
-
Provide data-driven recommendations for focused retention campaigns.
Data Source
The analysis is based on a simulated or anonymized Telecommunications Customer Dataset, which includes customer demographics, services subscribed, monthly charges, tenure, and churn status.
File: eda.ipynb (Jupyter Notebook)
Key Findings: The Churn Risk Triad
The analysis identified that the highest churn risk occurs at the confluence of three key factors, defining the most vulnerable customer segment:
-
Risk Factor
-
Description
-
Churn Implication
-
Contract Instability
-
Month-to-month contract users.
-
Single largest predictor of short-term attrition due to lack of commitment/lock-in.
-
Operational Friction
-
Customers utilizing Electronic Check as their payment method.
-
Suggests a sensitive payment experience or preference for quick exits.
-
Service Vulnerability
-
Lack of essential value-added services (e.g., Online Security, Tech Support).
-
Customers feel unprotected or unsupported, leading them to seek alternatives.
Additional Drivers:
-
Customers using Fiber Optic internet show elevated churn, pointing to potential service quality or reliability issues specific to that technology.
-
Senior Citizens and customers lacking Partners/Dependents also exhibit higher rates of attrition.
Recommendations for Action
-
The following interventions are recommended to mitigate the identified churn risks:
-
Contract Migration Campaigns: Implement aggressive promotional incentives (e.g., discounts, free upgrades) to shift Month-to-month customers onto 1-year or 2-year commitments.
-
Strategic Upsell: Prioritize offering security and technical support services to the identified high-risk segment, positioning them as essential stability features.
-
Payment Process Optimization: Encourage the migration of Electronic Check users to more stable payment methods (Credit Card, Auto-Draft) to reduce billing friction.
-
Fiber Optic Quality Audit: Conduct a service audit to address and rectify the underlying reliability or support issues driving dissatisfaction among Fiber Optic subscribers.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
df = pd.read_excel('Telco-Customer-Churn.xlsx')
df.head()
| customerID | gender | SeniorCitizen | Partner | Dependents | tenure | PhoneService | MultipleLines | InternetService | OnlineSecurity | ... | DeviceProtection | TechSupport | StreamingTV | StreamingMovies | Contract | PaperlessBilling | PaymentMethod | MonthlyCharges | TotalCharges | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | Female | 0 | Yes | No | 1 | No | No phone service | DSL | No | ... | No | No | No | No | Month-to-month | Yes | Electronic check | 29.85 | 29.85 | No |
| 1 | 5575-GNVDE | Male | 0 | No | No | 34 | Yes | No | DSL | Yes | ... | Yes | No | No | No | One year | No | Mailed check | 56.95 | 1889.5 | No |
| 2 | 3668-QPYBK | Male | 0 | No | No | 2 | Yes | No | DSL | Yes | ... | No | No | No | No | Month-to-month | Yes | Mailed check | 53.85 | 108.15 | Yes |
| 3 | 7795-CFOCW | Male | 0 | No | No | 45 | No | No phone service | DSL | Yes | ... | Yes | Yes | No | No | One year | No | Bank transfer (automatic) | 42.30 | 1840.75 | No |
| 4 | 9237-HQITU | Female | 0 | No | No | 2 | Yes | No | Fiber optic | No | ... | No | No | No | No | Month-to-month | Yes | Electronic check | 70.70 | 151.65 | Yes |
5 rows × 21 columns
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 7043 entries, 0 to 7042 Data columns (total 21 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 customerID 7043 non-null object 1 gender 7043 non-null object 2 SeniorCitizen 7043 non-null int64 3 Partner 7043 non-null object 4 Dependents 7043 non-null object 5 tenure 7043 non-null int64 6 PhoneService 7043 non-null object 7 MultipleLines 7043 non-null object 8 InternetService 7043 non-null object 9 OnlineSecurity 7043 non-null object 10 OnlineBackup 7043 non-null object 11 DeviceProtection 7043 non-null object 12 TechSupport 7043 non-null object 13 StreamingTV 7043 non-null object 14 StreamingMovies 7043 non-null object 15 Contract 7043 non-null object 16 PaperlessBilling 7043 non-null object 17 PaymentMethod 7043 non-null object 18 MonthlyCharges 7043 non-null float64 19 TotalCharges 7043 non-null object 20 Churn 7043 non-null object dtypes: float64(1), int64(2), object(18) memory usage: 1.1+ MB
## Replacing blanks with 0 as tenure is 0 and no total charges are recorded
df['TotalCharges'] = df['TotalCharges'].replace(" ","0")
df['TotalCharges'] = df['TotalCharges'].astype("float")
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 7043 entries, 0 to 7042 Data columns (total 21 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 customerID 7043 non-null object 1 gender 7043 non-null object 2 SeniorCitizen 7043 non-null int64 3 Partner 7043 non-null object 4 Dependents 7043 non-null object 5 tenure 7043 non-null int64 6 PhoneService 7043 non-null object 7 MultipleLines 7043 non-null object 8 InternetService 7043 non-null object 9 OnlineSecurity 7043 non-null object 10 OnlineBackup 7043 non-null object 11 DeviceProtection 7043 non-null object 12 TechSupport 7043 non-null object 13 StreamingTV 7043 non-null object 14 StreamingMovies 7043 non-null object 15 Contract 7043 non-null object 16 PaperlessBilling 7043 non-null object 17 PaymentMethod 7043 non-null object 18 MonthlyCharges 7043 non-null float64 19 TotalCharges 7043 non-null float64 20 Churn 7043 non-null object dtypes: float64(2), int64(2), object(17) memory usage: 1.1+ MB
df.isnull().sum()
customerID 0 gender 0 SeniorCitizen 0 Partner 0 Dependents 0 tenure 0 PhoneService 0 MultipleLines 0 InternetService 0 OnlineSecurity 0 OnlineBackup 0 DeviceProtection 0 TechSupport 0 StreamingTV 0 StreamingMovies 0 Contract 0 PaperlessBilling 0 PaymentMethod 0 MonthlyCharges 0 TotalCharges 0 Churn 0 dtype: int64
df.describe()
| SeniorCitizen | tenure | MonthlyCharges | TotalCharges | |
|---|---|---|---|---|
| count | 7043.000000 | 7043.000000 | 7043.000000 | 7043.000000 |
| mean | 0.162147 | 32.371149 | 64.761692 | 2279.734304 |
| std | 0.368612 | 24.559481 | 30.090047 | 2266.794470 |
| min | 0.000000 | 0.000000 | 18.250000 | 0.000000 |
| 25% | 0.000000 | 9.000000 | 35.500000 | 398.550000 |
| 50% | 0.000000 | 29.000000 | 70.350000 | 1394.550000 |
| 75% | 0.000000 | 55.000000 | 89.850000 | 3786.600000 |
| max | 1.000000 | 72.000000 | 118.750000 | 8684.800000 |
df['customerID'].duplicated().sum()
np.int64(0)
## Converted 0 and 1 values of senior citizens to yes/no to make it easier to understand
def conv(value):
if value == 1:
return "yes"
else:
return "no"
df['SeniorCitizen'] = df['SeniorCitizen'].apply(conv)
df.head(10)
| customerID | gender | SeniorCitizen | Partner | Dependents | tenure | PhoneService | MultipleLines | InternetService | OnlineSecurity | ... | DeviceProtection | TechSupport | StreamingTV | StreamingMovies | Contract | PaperlessBilling | PaymentMethod | MonthlyCharges | TotalCharges | Churn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | Female | no | Yes | No | 1 | No | No phone service | DSL | No | ... | No | No | No | No | Month-to-month | Yes | Electronic check | 29.85 | 29.85 | No |
| 1 | 5575-GNVDE | Male | no | No | No | 34 | Yes | No | DSL | Yes | ... | Yes | No | No | No | One year | No | Mailed check | 56.95 | 1889.50 | No |
| 2 | 3668-QPYBK | Male | no | No | No | 2 | Yes | No | DSL | Yes | ... | No | No | No | No | Month-to-month | Yes | Mailed check | 53.85 | 108.15 | Yes |
| 3 | 7795-CFOCW | Male | no | No | No | 45 | No | No phone service | DSL | Yes | ... | Yes | Yes | No | No | One year | No | Bank transfer (automatic) | 42.30 | 1840.75 | No |
| 4 | 9237-HQITU | Female | no | No | No | 2 | Yes | No | Fiber optic | No | ... | No | No | No | No | Month-to-month | Yes | Electronic check | 70.70 | 151.65 | Yes |
| 5 | 9305-CDSKC | Female | no | No | No | 8 | Yes | Yes | Fiber optic | No | ... | Yes | No | Yes | Yes | Month-to-month | Yes | Electronic check | 99.65 | 820.50 | Yes |
| 6 | 1452-KIOVK | Male | no | No | Yes | 22 | Yes | Yes | Fiber optic | No | ... | No | No | Yes | No | Month-to-month | Yes | Credit card (automatic) | 89.10 | 1949.40 | No |
| 7 | 6713-OKOMC | Female | no | No | No | 10 | No | No phone service | DSL | Yes | ... | No | No | No | No | Month-to-month | No | Mailed check | 29.75 | 301.90 | No |
| 8 | 7892-POOKP | Female | no | Yes | No | 28 | Yes | Yes | Fiber optic | No | ... | Yes | Yes | Yes | Yes | Month-to-month | Yes | Electronic check | 104.80 | 3046.05 | Yes |
| 9 | 6388-TABGU | Male | no | No | Yes | 62 | Yes | No | DSL | Yes | ... | No | No | No | No | One year | No | Bank transfer (automatic) | 56.15 | 3487.95 | No |
10 rows × 21 columns
ax = sns.countplot(x='Churn',data=df )
ax.bar_label(ax.containers[0])
plt.title("Count of customers by Churn")
plt.show()
gb = df.groupby("Churn").agg({'Churn':'count'})
plt.title("Percentage of Churned Customers")
plt.pie(gb['Churn'],labels=gb.index,autopct="%1.2f%%")
plt.show()
From the given pie chart we can conclude that 26.54% of our customers have churned out.
plt.figure(figsize=(4,4))
ax = sns.countplot(x='gender', data=df, hue="Churn")
plt.title("Churn By Gender")
# Label all bars in all containers
for container in ax.containers:
ax.bar_label(container)
plt.show()
# Create a crosstab to get counts
ct = pd.crosstab(df['SeniorCitizen'], df['Churn'])
# Calculate percentages
ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100
# Create stacked bar chart
plt.figure(figsize=(4, 4))
ax = ct_pct.plot(kind='bar', stacked=True, color=['#1f77b4', '#ff7f0e'])
plt.title("Churn By Senior Citizen")
plt.xlabel("SeniorCitizen")
plt.ylabel("Percentage (%)")
plt.xticks(rotation=0)
plt.legend(title='Churn')
# Add percentage labels on bars
for container in ax.containers:
labels = [f'{v:.1f}%' if v > 0 else '' for v in container.datavalues]
ax.bar_label(container, labels=labels, label_type='center')
plt.tight_layout()
plt.show()
<Figure size 400x400 with 0 Axes>
plt.figure(figsize=(6, 4))
ax = sns.countplot(x='SeniorCitizen', data=df, hue='Churn')
plt.title("Churn Count by Senior Citizen Status")
plt.xlabel("Senior Citizen (0=No, 1=Yes)")
plt.ylabel("Count")
# Add count labels on bars
for container in ax.containers:
ax.bar_label(container)
plt.legend(title='Churn', labels=['No', 'Yes'])
plt.tight_layout()
plt.show()
import matplotlib.pyplot as plt
import pandas as pd
# Normalize labels (supports 0/1 or yes/no)
df['SeniorCitizen'] = df['SeniorCitizen'].replace({0: 'no', 1: 'yes', '0': 'no', '1': 'yes'})
df['Churn'] = df['Churn'].astype(str).str.capitalize() # "yes"→"Yes"
# Create figure
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
fig.suptitle('Churn Analysis by Senior Citizen Status', fontsize=16, fontweight='bold')
# 1. Grouped Bar Chart (Counts)
ax1 = axes[0, 0]
ct = pd.crosstab(df['SeniorCitizen'], df['Churn'])
ct.plot(kind="bar", ax=ax1, color=['#1f77b4', '#ff7f0e'])
ax1.set_title("Count of Churn by Senior Citizen")
ax1.set_xlabel("Senior Citizen (no/yes)")
ax1.set_ylabel("Count")
ax1.set_xticklabels(ax1.get_xticklabels(), rotation=0)
# Add bar labels
for container in ax1.containers:
ax1.bar_label(container)
# 2. Stacked Percentage Chart
ax2 = axes[0, 1]
ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100
ct_pct.plot(kind='bar', stacked=True, ax=ax2, color=['#1f77b4', '#ff7f0e'])
ax2.set_title("Churn Rate (%) by Senior Citizen")
ax2.set_xlabel("Senior Citizen (no/yes)")
ax2.set_ylabel("Percentage (%)")
ax2.set_xticklabels(ax2.get_xticklabels(), rotation=0)
# Add percentage labels
for container in ax2.containers:
labels = [f"{v:.1f}%" if v > 3 else "" for v in container.datavalues]
ax2.bar_label(container, labels=labels, label_type='center', fontsize=9)
# 3. REMOVE THE EMPTY PLOT (axes[1,0])
axes[1, 0].remove()
# 4. Summary Table
ax4 = axes[1, 1]
ax4.axis('off')
senior_values = df['SeniorCitizen'].unique()
churn_values = df['Churn'].unique()
summary_data = []
for s in senior_values:
total = len(df[df['SeniorCitizen'] == s])
for c in churn_values:
count = len(df[(df['SeniorCitizen'] == s) & (df['Churn'] == c)])
pct = (count / total * 100) if total else 0
summary_data.append([f"Senior={s}", c, count, f"{pct:.1f}%"])
table = ax4.table(
cellText=summary_data,
colLabels=['Senior Citizen', 'Churn', 'Count', 'Percentage'],
loc='center',
cellLoc='center',
colWidths=[0.25, 0.2, 0.2, 0.25]
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 2)
# Style header row
for col in range(4):
table[(0, col)].set_facecolor("#4CAF50")
table[(0, col)].set_text_props(color="white", weight="bold")
ax4.set_title("Summary Statistics", pad=20, fontweight="bold")
plt.tight_layout()
plt.show()
plt.figure(figsize=(9,5))
sns.histplot(x='tenure',data=df,bins=50, hue='Churn')
plt.show()
plt.figure(figsize=(6, 4))
ax = sns.countplot(x='Contract', data=df, hue='Churn')
plt.title("Churn Count by Contract")
# Add count labels on bars
for container in ax.containers:
ax.bar_label(container)
plt.legend(title='Churn', labels=['No', 'Yes'])
plt.tight_layout()
plt.show()
People who have month-to-month contract are likely to churn than from those who have 1 or 2 year of contract.
df.columns.values
array(['customerID', 'gender', 'SeniorCitizen', 'Partner', 'Dependents',
'tenure', 'PhoneService', 'MultipleLines', 'InternetService',
'OnlineSecurity', 'OnlineBackup', 'DeviceProtection',
'TechSupport', 'StreamingTV', 'StreamingMovies', 'Contract',
'PaperlessBilling', 'PaymentMethod', 'MonthlyCharges',
'TotalCharges', 'Churn'], dtype=object)import seaborn as sns
import matplotlib.pyplot as plt
import math
# 1. Identify categorical columns
cat_cols = df.select_dtypes(include=['object']).columns.tolist()
# Remove customerID (unique values, useless for countplot)
if 'customerID' in cat_cols:
cat_cols.remove('customerID')
# Add SeniorCitizen (0/1)
if 'SeniorCitizen' in df.columns:
cat_cols.append('SeniorCitizen')
# Remove duplicates if any
cat_cols = list(dict.fromkeys(cat_cols))
# 2. Create subplot grid
n_cols = 3 # number of plots per row
n_rows = math.ceil(len(cat_cols) / n_cols)
plt.figure(figsize=(n_cols * 6, n_rows * 5)) # large grid
# 3. Loop through columns and draw subplots
for i, col in enumerate(cat_cols, 1):
ax = plt.subplot(n_rows, n_cols, i)
sns.countplot(x=col, data=df, hue='Churn', ax=ax)
ax.set_title(f"{col} by Churn")
ax.tick_params(axis='x', rotation=45)
# Add bar labels
for container in ax.containers:
ax.bar_label(container, fontsize=8)
plt.tight_layout()
plt.show()
-
Customers with Month-to-month contracts, Electronic check payments, and Fiber optic internet show the highest churn rates.
-
Lack of additional services such as OnlineSecurity, TechSupport, and DeviceProtection is strongly associated with higher churn.
-
Senior citizens and customers without partners or dependents tend to churn more compared to others.
-
Customers using DSL internet, multiple value-added services, and annual contracts show significantly lower churn.
-
Paperless billing users churn more, likely due to being tied to month-to-month + electronic check combinations.
-
Overall, churners show a clear pattern of higher vulnerability when they have no service add-ons, month-to-month billing, and inconsistent service experience.
Want an analysis like this?
Book a 20-minute call and tell me about the data and the decision behind it.