← Projects
Data SciencePythonEDAChurn

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:

  1. Risk Factor

  2. Description

  3. Churn Implication

  4. Contract Instability

  5. Month-to-month contract users.

  6. Single largest predictor of short-term attrition due to lack of commitment/lock-in.

  7. Operational Friction

  8. Customers utilizing Electronic Check as their payment method.

  9. Suggests a sensitive payment experience or preference for quick exits.

  10. Service Vulnerability

  11. Lack of essential value-added services (e.g., Online Security, Tech Support).

  12. 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.

Notebook
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()
Notebook output figure
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()
Notebook output figure

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()
Notebook output figure
# 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>
Notebook output figure
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()
Notebook output figure
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()
Notebook output figure
plt.figure(figsize=(9,5))
sns.histplot(x='tenure',data=df,bins=50, hue='Churn')
plt.show()
Notebook output figure
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()
Notebook output figure

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()
Notebook output figure
  • 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.