Case Study 2: StreamFlow Subscriber Segments and Churn Rate Differences
Background
StreamFlow, the streaming platform from Chapters 11-19, has a churn model in production. It predicts which subscribers will cancel, and the Customer Success team uses it to prioritize outreach. But the model treats all at-risk subscribers the same: everyone gets the same retention offer (a 20% discount for 3 months).
That offer works for some subscribers and not for others. The retention team's data tells the story:
| Offer | Acceptance Rate | 90-Day Retention After Acceptance |
|---|---|---|
| 20% discount for 3 months | 38% | 52% |
A 38% acceptance rate sounds reasonable until you realize that 62% of at-risk subscribers are declining the offer entirely. The hypothesis: those subscribers are at risk for different reasons, and a discount does not address them all. A subscriber who is leaving because of payment failures needs a billing fix, not a discount. A subscriber who is leaving because they ran out of content needs recommendations, not price cuts.
StreamFlow needs to segment its subscriber base to understand the different types of at-risk behavior, then match each segment to the right intervention.
The Data
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans, MiniBatchKMeans
from sklearn.metrics import silhouette_score, silhouette_samples
import matplotlib.pyplot as plt
np.random.seed(42)
n = 50_000
# Simulate 4 latent subscriber types
# 0: Power users (high engagement, low churn)
# 1: Casual viewers (moderate engagement, moderate churn)
# 2: Billing friction (decent engagement but payment problems)
# 3: Fading away (declining engagement, high churn)
subscriber_type = np.random.choice([0, 1, 2, 3], n, p=[0.25, 0.35, 0.15, 0.25])
streamflow = pd.DataFrame({
'monthly_hours_watched': np.where(
subscriber_type == 0, np.random.exponential(35, n),
np.where(subscriber_type == 1, np.random.exponential(14, n),
np.where(subscriber_type == 2, np.random.exponential(20, n),
np.random.exponential(6, n)))
).clip(0.1).round(1),
'sessions_last_30d': np.where(
subscriber_type == 0, np.random.poisson(22, n),
np.where(subscriber_type == 1, np.random.poisson(10, n),
np.where(subscriber_type == 2, np.random.poisson(15, n),
np.random.poisson(4, n)))
),
'content_completion_rate': np.where(
subscriber_type == 0, np.random.beta(5, 2, n),
np.where(subscriber_type == 1, np.random.beta(3, 3, n),
np.where(subscriber_type == 2, np.random.beta(4, 2.5, n),
np.random.beta(2, 5, n)))
).round(3),
'unique_titles_watched': np.where(
subscriber_type == 0, np.random.poisson(15, n),
np.where(subscriber_type == 1, np.random.poisson(6, n),
np.where(subscriber_type == 2, np.random.poisson(10, n),
np.random.poisson(3, n)))
),
'hours_change_pct': np.where(
subscriber_type == 0, np.random.normal(5, 15, n),
np.where(subscriber_type == 1, np.random.normal(-5, 20, n),
np.where(subscriber_type == 2, np.random.normal(0, 18, n),
np.random.normal(-30, 20, n)))
).round(1),
'months_active': np.where(
subscriber_type == 0, np.random.randint(12, 60, n),
np.where(subscriber_type == 1, np.random.randint(3, 36, n),
np.where(subscriber_type == 2, np.random.randint(6, 48, n),
np.random.randint(1, 24, n)))
),
'plan_price': np.where(
subscriber_type == 0,
np.random.choice([14.99, 19.99, 24.99], n, p=[0.30, 0.40, 0.30]),
np.where(subscriber_type == 1,
np.random.choice([9.99, 14.99, 19.99], n, p=[0.50, 0.35, 0.15]),
np.where(subscriber_type == 2,
np.random.choice([9.99, 14.99, 19.99, 24.99], n, p=[0.25, 0.35, 0.25, 0.15]),
np.random.choice([9.99, 14.99], n, p=[0.60, 0.40])))
),
'devices_used': np.where(
subscriber_type == 0, np.random.randint(2, 6, n),
np.where(subscriber_type == 1, np.random.randint(1, 4, n),
np.where(subscriber_type == 2, np.random.randint(1, 5, n),
np.random.randint(1, 3, n)))
),
'days_since_last_session': np.where(
subscriber_type == 0, np.random.exponential(2, n),
np.where(subscriber_type == 1, np.random.exponential(7, n),
np.where(subscriber_type == 2, np.random.exponential(4, n),
np.random.exponential(18, n)))
).clip(0, 60).round(0).astype(int),
'support_tickets_90d': np.where(
subscriber_type == 0, np.random.poisson(0.3, n),
np.where(subscriber_type == 1, np.random.poisson(0.5, n),
np.where(subscriber_type == 2, np.random.poisson(2.5, n),
np.random.poisson(1.2, n)))
),
'payment_failures_6m': np.where(
subscriber_type == 0, np.random.poisson(0.1, n),
np.where(subscriber_type == 1, np.random.poisson(0.2, n),
np.where(subscriber_type == 2, np.random.poisson(2.8, n),
np.random.poisson(0.4, n)))
),
'binge_sessions_30d': np.where(
subscriber_type == 0, np.random.poisson(5, n),
np.where(subscriber_type == 1, np.random.poisson(1.5, n),
np.where(subscriber_type == 2, np.random.poisson(3, n),
np.random.poisson(0.5, n)))
),
})
# Simulate churn outcome
churn_prob = np.where(
subscriber_type == 0, 0.03,
np.where(subscriber_type == 1, 0.12,
np.where(subscriber_type == 2, 0.18, 0.35))
)
# Add noise based on features
churn_logit = (
np.log(churn_prob / (1 - churn_prob))
+ 0.04 * streamflow['days_since_last_session']
- 0.01 * streamflow['monthly_hours_watched']
+ 0.2 * streamflow['payment_failures_6m']
+ np.random.normal(0, 0.3, n)
)
streamflow['churned'] = (np.random.random(n) < 1 / (1 + np.exp(-churn_logit))).astype(int)
print(f"Dataset: {n:,} subscribers, {streamflow.shape[1] - 1} features")
print(f"Overall churn rate: {streamflow['churned'].mean():.3f}")
Step 1: Feature Selection and Scaling
We cluster on behavioral features only. The churn label is the outcome we want to compare across segments --- it must not be an input to clustering.
cluster_features = [
'monthly_hours_watched', 'sessions_last_30d', 'content_completion_rate',
'unique_titles_watched', 'hours_change_pct', 'months_active',
'plan_price', 'devices_used', 'days_since_last_session',
'support_tickets_90d', 'payment_failures_6m', 'binge_sessions_30d',
]
scaler = StandardScaler()
X_scaled = scaler.fit_transform(streamflow[cluster_features])
print(f"Clustering on {len(cluster_features)} features (churn excluded)")
Critical Point --- If you include the churn label as a clustering feature, you will discover that "churned subscribers" form their own cluster. This is trivially true and completely useless. Clustering is unsupervised. The whole point is to find structure that the churn model did not reveal.
Step 2: Choosing k
inertias = []
silhouette_scores = []
K_range = range(2, 9)
for k in K_range:
km = MiniBatchKMeans(n_clusters=k, random_state=42, batch_size=2048, n_init=10)
labels_k = km.fit_predict(X_scaled)
inertias.append(km.inertia_)
sil = silhouette_score(X_scaled, labels_k, sample_size=20_000, random_state=42)
silhouette_scores.append(sil)
print(f"k={k}: inertia={km.inertia_:,.0f}, silhouette={sil:.4f}")
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(K_range, inertias, 'bo-', linewidth=2, markersize=8)
axes[0].set_xlabel('k')
axes[0].set_ylabel('Inertia')
axes[0].set_title('Elbow Method')
axes[0].set_xticks(list(K_range))
axes[1].plot(K_range, silhouette_scores, 'bo-', linewidth=2, markersize=8)
axes[1].set_xlabel('k')
axes[1].set_ylabel('Mean Silhouette Score')
axes[1].set_title('Silhouette Analysis')
axes[1].set_xticks(list(K_range))
plt.tight_layout()
plt.show()
Per-Cluster Silhouette Plots
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
for idx, k in enumerate([3, 4, 5]):
km = MiniBatchKMeans(n_clusters=k, random_state=42, batch_size=2048, n_init=10)
labels_k = km.fit_predict(X_scaled)
# Subsample for silhouette plot speed
rng = np.random.RandomState(42)
sub_idx = rng.choice(len(X_scaled), 15_000, replace=False)
sil_vals = silhouette_samples(X_scaled[sub_idx], labels_k[sub_idx])
y_lower = 10
for c in range(k):
c_vals = np.sort(sil_vals[labels_k[sub_idx] == c])
y_upper = y_lower + len(c_vals)
axes[idx].fill_betweenx(np.arange(y_lower, y_upper), 0, c_vals, alpha=0.7)
axes[idx].text(-0.05, y_lower + 0.5 * len(c_vals), str(c))
y_lower = y_upper + 10
avg = silhouette_score(X_scaled[sub_idx], labels_k[sub_idx])
axes[idx].axvline(x=avg, color='red', linestyle='--')
axes[idx].set_title(f'k={k}, silhouette={avg:.3f}')
axes[idx].set_xlabel('Silhouette Coefficient')
plt.tight_layout()
plt.show()
The silhouette analysis favors k=4. At k=3, one cluster is bloated (too many diverse subscribers lumped together). At k=5, one cluster has substantial negative silhouette values. k=4 produces four well-shaped, roughly balanced blades.
Decision: k=4.
Step 3: Final Clustering and Churn Rate Comparison
km_final = MiniBatchKMeans(n_clusters=4, random_state=42, batch_size=2048, n_init=10)
streamflow['segment'] = km_final.fit_predict(X_scaled)
# The key table: churn rate by segment
churn_by_segment = streamflow.groupby('segment').agg(
n=('churned', 'count'),
churn_rate=('churned', 'mean'),
avg_hours=('monthly_hours_watched', 'mean'),
avg_sessions=('sessions_last_30d', 'mean'),
avg_completion=('content_completion_rate', 'mean'),
avg_titles=('unique_titles_watched', 'mean'),
avg_hours_change=('hours_change_pct', 'mean'),
avg_tenure=('months_active', 'mean'),
avg_price=('plan_price', 'mean'),
avg_devices=('devices_used', 'mean'),
avg_inactive_days=('days_since_last_session', 'mean'),
avg_tickets=('support_tickets_90d', 'mean'),
avg_payment_fails=('payment_failures_6m', 'mean'),
avg_binges=('binge_sessions_30d', 'mean'),
).round(3)
print("StreamFlow Subscriber Segments:")
print(churn_by_segment.to_string())
Step 4: Naming and Interpreting the Segments
The cluster profiles reveal four distinct behavioral patterns with dramatically different churn rates:
# Assign human-readable names based on profiles
segment_names = {
# Map segment numbers to names based on the profile output
# (adjust mapping based on actual cluster order in your run)
}
# Identify segments by their dominant characteristics
for seg in range(4):
seg_data = streamflow[streamflow['segment'] == seg]
print(f"\n--- Segment {seg} ---")
print(f" Size: {len(seg_data):,} ({100*len(seg_data)/n:.1f}%)")
print(f" Churn rate: {seg_data['churned'].mean():.1%}")
print(f" Hours/month: {seg_data['monthly_hours_watched'].mean():.1f}")
print(f" Sessions/month: {seg_data['sessions_last_30d'].mean():.1f}")
print(f" Hours change: {seg_data['hours_change_pct'].mean():+.1f}%")
print(f" Days inactive: {seg_data['days_since_last_session'].mean():.1f}")
print(f" Payment failures: {seg_data['payment_failures_6m'].mean():.2f}")
print(f" Support tickets: {seg_data['support_tickets_90d'].mean():.2f}")
print(f" Tenure (months): {seg_data['months_active'].mean():.1f}")
The four segments map to distinct retention strategies:
Power Users
- Profile: High hours watched, many sessions, strong completion rate, long tenure, multiple devices.
- Churn rate: ~3-5%. These subscribers are deeply engaged.
- Retention strategy: Leave them alone. Do not waste retention budget on subscribers who are not leaving. If they do show signs of disengagement (a rare event), escalate to a premium retention offer (free upgrade for 6 months).
Casual Viewers
- Profile: Moderate engagement across all metrics. Average tenure. Low discount sensitivity.
- Churn rate: ~12-15%. They like the service but are not locked in.
- Retention strategy: Content nudges. These subscribers churn when they run out of things to watch, not because of price or billing issues. Personalized "because you watched X" recommendations (Chapter 24) are the intervention.
Billing Friction
- Profile: Decent engagement (20 hours/month, 15 sessions), but high payment failures and high support ticket volume. Engagement metrics look fine --- the problem is operational.
- Churn rate: ~18-22%. They want to stay but the billing system is pushing them out.
- Retention strategy: Fix the billing issue. This is the highest-ROI intervention because it costs almost nothing. Automatically retry failed payments, offer alternative payment methods, and proactively reach out when a payment fails. The 20% discount offer is irrelevant to this segment.
Fading Away
- Profile: Low and declining engagement. Few sessions, low hours, negative hours-change, high days since last session. Short tenure.
- Churn rate: ~30-40%. These subscribers have already mentally canceled.
- Retention strategy: The hardest segment. By the time they appear in this cluster, they may be past the point of intervention. Early detection (catching them while they are still in the "Casual Viewers" segment and starting to decline) is more effective than late-stage retention offers. For those already here: a "we miss you" campaign with a curated content package targeting their past viewing preferences, combined with a significant discount (50% for 1 month) as a last resort.
Step 5: Churn Rate Lift Analysis
The clustering reveals that churn is not uniformly distributed. This has direct implications for the retention team's capacity allocation:
# How should the retention team allocate its monthly outreach budget?
total_budget = 5000 # monthly outreach capacity (number of contacts)
segment_summary = streamflow.groupby('segment').agg(
n=('churned', 'count'),
churn_rate=('churned', 'mean'),
expected_churners=('churned', 'sum'),
).round(2)
# Allocate budget proportional to expected churners, weighted by intervention ROI
segment_summary['pct_of_churners'] = (
segment_summary['expected_churners'] / segment_summary['expected_churners'].sum()
).round(3)
print("Churn Distribution by Segment:")
print(segment_summary.to_string())
print(f"\nTotal expected churners: {segment_summary['expected_churners'].sum():,.0f}")
# Expected impact analysis
# Assume different intervention success rates by segment
intervention_rates = {
# Power users: rarely need intervention, high success when they do
# Casual viewers: content nudge works ~25% of the time
# Billing friction: billing fix works ~60% of the time
# Fading away: win-back works ~10% of the time
}
# Assign segment-specific intervention success rates
# (adjust segment indices based on your actual cluster profiles)
for seg in range(4):
seg_data = streamflow[streamflow['segment'] == seg]
seg_churners = seg_data['churned'].sum()
seg_churn_rate = seg_data['churned'].mean()
print(f"Segment {seg}: {len(seg_data):,} subscribers, "
f"{seg_churners:,} churners ({seg_churn_rate:.1%})")
Key Finding --- The "Billing Friction" segment has the best ratio of churn rate to intervention difficulty. These subscribers want to stay. Fixing their payment issue has a high success rate and low cost. Allocating outreach budget to this segment first, before spending on harder-to-retain "Fading Away" subscribers, maximizes retention ROI.
Step 6: Segment Stability Over Time
# Simulate 3 months of data to check segment stability
from sklearn.metrics import adjusted_rand_score
monthly_labels = [streamflow['segment'].values]
for month in range(1, 4):
# Simulate feature drift
np.random.seed(42 + month)
noise = np.random.normal(0, 0.1, X_scaled.shape)
X_drifted = X_scaled + noise
labels_month = km_final.predict(X_drifted)
monthly_labels.append(labels_month)
ari = adjusted_rand_score(monthly_labels[0], labels_month)
print(f"Month {month}: ARI vs. baseline = {ari:.4f}")
# Migration matrix: month 0 to month 3
migration = pd.crosstab(
pd.Series(monthly_labels[0], name='Baseline'),
pd.Series(monthly_labels[3], name='Month 3'),
normalize='index'
).round(3)
print("\nSegment Migration (Baseline -> Month 3):")
print(migration.to_string())
Operational Note --- Segment membership should be refreshed monthly. The retention team's CRM should store both the current segment and the previous segment, enabling detection of segment transitions. A subscriber moving from "Casual Viewer" to "Fading Away" is a high-priority intervention target --- they are still reachable, but the window is closing.
Step 7: From Segments to the Churn Model Pipeline
The clustering does not replace the churn model --- it complements it. The churn model answers "Who will churn?" The clustering answers "Why are they different?" Together, they enable targeted interventions:
# Integrate segments into the churn prediction pipeline
streamflow['churn_risk'] = np.where(
streamflow['churned'] == 1, 'high', # simplified; in production, use model probabilities
'low'
)
# Cross-tabulation: segment x risk level
cross_tab = pd.crosstab(
streamflow['segment'], streamflow['churn_risk'],
normalize='index'
).round(3)
print("Churn Risk Distribution by Segment:")
print(cross_tab.to_string())
The production pipeline would look like:
1. Churn model scores all subscribers daily (Chapter 11-18 model)
2. High-risk subscribers (probability > threshold) are flagged
3. Clustering assigns each flagged subscriber to a segment
4. Segment determines the intervention:
- Power User at risk -> Premium retention offer (rare)
- Casual Viewer at risk -> Content recommendation email
- Billing Friction -> Billing support outreach
- Fading Away -> Win-back campaign or accept the loss
5. Customer Success team executes the intervention
6. Outcomes are tracked per segment for continuous optimization
Results Summary
| Segment | Size | Churn Rate | Primary Churn Driver | Recommended Intervention | Est. Success Rate |
|---|---|---|---|---|---|
| Power Users | ~25% | ~3-5% | Rare; life events | Premium retention (escalated) | 80%+ |
| Casual Viewers | ~35% | ~12-15% | Content exhaustion | Personalized recommendations | ~25% |
| Billing Friction | ~15% | ~18-22% | Payment failures | Billing fix + payment retry | ~60% |
| Fading Away | ~25% | ~30-40% | Disengagement | Win-back campaign (or accept loss) | ~10% |
The one-size-fits-all 20% discount had a 38% acceptance rate across all segments. Segment-specific interventions are expected to improve the weighted acceptance rate to 45-55% because they address the actual reason each subscriber is at risk, not a generic price objection.
Key Lessons
-
Clustering + classification is more powerful than either alone. The churn model identifies who is at risk. The clustering identifies why they are different. The combination enables the right intervention for the right subscriber.
-
The highest-ROI segment is not the one with the highest churn rate. "Fading Away" has the highest churn rate but the lowest intervention success rate. "Billing Friction" has a lower churn rate but the highest ROI because the fix is simple and effective. Prioritize segments by expected impact, not by raw risk.
-
Exclude the target variable from clustering features. If you cluster on churn, you will find churners. That is circular. Cluster on behavior, then compare churn rates across segments.
-
Mini-Batch K-Means is the pragmatic choice at scale. With 50,000 subscribers and 12 features, standard K-Means is fine. At 500,000 subscribers (StreamFlow's trajectory), Mini-Batch K-Means provides the same segments 5x faster.
-
Segment transitions are as important as segment membership. A subscriber who moves from "Casual Viewer" to "Fading Away" is a signal to act now. Monthly re-segmentation with migration tracking turns static clusters into a dynamic early warning system.
This case study supports Chapter 20: Clustering. Return to the chapter for the full algorithmic treatment, or see Case Study 1 for the ShopSmart e-commerce segmentation.