# 电信用户流失预测:从数据清洗到模型部署的Python实战指南
最近在复盘一个电信行业的分析项目,客户那边最头疼的就是用户留不住,每个月看着报表上的流失数字往上跳,市场部的同事急得团团转。他们手里攒了几千条用户数据,但不知道怎么把这些数据变成能指导行动的“预警系统”。这其实是个典型的数据科学落地场景:**把业务问题转化为可建模的预测任务**。今天我就把自己从头到尾搭建这个预测系统的过程拆开揉碎了讲一遍,重点不是罗列代码,而是分享那些在文档里找不到的“踩坑经验”和“决策逻辑”。
如果你已经会用Pandas做点基础分析,但面对一个真实的、有点脏乱的业务数据集时,还不知道如何一步步构建一个稳健的机器学习管道,那么这篇文章就是为你写的。我们会超越简单的`fit`和`predict`,深入到特征工程的策略选择、模型对比的量化评估,以及最终如何把模型结果“翻译”成业务部门能听懂的行动建议。整个流程会基于一个公开的电信用户数据集来展开,但思路和方法完全通用。
## 1. 项目初始化与数据的第一眼诊断
动手写任何代码之前,先得把问题定义清楚。我们的核心目标是:**基于用户的历史行为、人口统计信息和合同属性,预测其在未来一段时间内流失(Churn)的可能性**。这是一个经典的二分类问题。我习惯在项目开始时就建立一个独立的Python环境,避免依赖冲突。这里用`conda`管理。
```bash
# 创建并激活专属环境
conda create -n telecom-churn python=3.9
conda activate telecom-churn
# 安装核心依赖
pip install pandas numpy scikit-learn matplotlib seaborn xgboost imbalanced-learn
```
接下来是数据加载和“初诊”。拿到数据别急着跑模型,先花时间“认识”它。
```python
import pandas as pd
import numpy as np
import warnings
warnings.filterwarnings('ignore')
# 加载数据
df = pd.read_csv('WA_Fn-UseC_-Telco-Customer-Churn.csv')
print(f"数据集形状: {df.shape}")
print("\n前5行数据预览:")
print(df.head())
print("\n数据基本信息:")
print(df.info())
```
运行`df.info()`后,你可能会立刻发现几个典型问题:
1. `TotalCharges`列被识别为`object`类型,但它应该是数值型。
2. 存在像`SeniorCitizen`这样用0/1表示的数值型分类变量。
3. 分类变量(如`Contract`, `PaymentMethod`)都是字符串类型。
> 注意:`customerID`是用户的唯一标识符,在建模时必须移除,否则模型会把它当作一个无意义但强相关的特征,导致严重的过拟合。
初步观察后,我通常会生成一份数据质量报告,用字典记录下来,指导后续的清洗步骤。
| 检查项 | 发现的问题 | 初步处理方案 |
| :--- | :--- | :--- |
| **缺失值** | `TotalCharges`有11个空字符串(非NaN) | 需转换为NaN后处理 |
| **数据类型** | `TotalCharges`应为数值型 | 强制转换,处理转换错误 |
| **唯一标识** | `customerID`存在 | 后续特征工程前删除 |
| **分类变量** | 多列(如`gender`, `Partner`)为`object` | 需要进行编码 |
| **数值范围** | `tenure`(在网时长)有0值 | 检查业务逻辑,可能需修正 |
## 2. 深度数据清洗与特征预处理
数据清洗不是机械地填充缺失值,每一步都要结合业务逻辑思考。以`TotalCharges`为例,它的空字符串出现在`tenure`(在网时长)为0的用户身上。这很合理:新用户还没产生总费用。所以,直接用`MonthlyCharges`(月费)填充是合适的。
```python
# 1. 处理TotalCharges:将空字符串转为NaN,再填充
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
# 确认缺失值与tenure为0的对应关系
print(df[df['TotalCharges'].isna()][['tenure', 'MonthlyCharges', 'TotalCharges']])
# 使用月费填充总费用缺失值
df['TotalCharges'].fillna(df['MonthlyCharges'], inplace=True)
# 2. 处理tenure为0的情况:通常表示当月新用户,将其视为1个月便于计算
df['tenure'] = df['tenure'].replace(0, 1)
# 3. 删除无关列
df.drop('customerID', axis=1, inplace=True)
# 4. 将目标变量‘Churn’转换为二进制数值
df['Churn'] = df['Churn'].map({'Yes': 1, 'No': 0})
```
对于分类特征,编码方式的选择直接影响模型效果。我一般遵循以下原则:
- **二分类特征**(如`gender`, `Partner`):直接用`LabelEncoder`或映射为0/1。
- **有序多分类**(如`Contract`: Month-to-month, One year, Two year):使用`OrdinalEncoder`赋予有序数值。
- **名义多分类**(如`PaymentMethod`):使用`One-Hot Encoding`(独热编码)。
但独热编码在类别很多时会急剧增加维度。这里有个技巧:对于像“是否开通了某项服务”(Yes/No/No internet service)这样的特征,可以将其拆解为一个二分类特征(是否有此服务)和一个三分类特征(服务类型),有时能提升模型表现。
```python
from sklearn.preprocessing import LabelEncoder, OrdinalEncoder
# 示例:处理二分类特征
binary_cols = ['gender', 'Partner', 'Dependents', 'PhoneService', 'PaperlessBilling']
for col in binary_cols:
le = LabelEncoder()
df[col] = le.fit_transform(df[col])
# 示例:处理有序分类特征
contract_order = [['Month-to-month', 'One year', 'Two year']]
oe = OrdinalEncoder(categories=contract_order)
df['Contract'] = oe.fit_transform(df[['Contract']])
# 对于多分类名义变量,使用pd.get_dummies
df = pd.get_dummies(df, columns=['PaymentMethod', 'InternetService'], drop_first=True)
```
## 3. 探索性数据分析与特征工程灵感
清洗后的数据,需要通过可视化来寻找规律和工程化特征的灵感。**不要为了可视化而可视化**,每一个图表都应该服务于后续的特征构造或假设验证。
首先看目标变量的分布,这是一个**类别不平衡**问题。
```python
import matplotlib.pyplot as plt
import seaborn as sns
churn_ratio = df['Churn'].value_counts(normalize=True)
print(f"流失用户比例: {churn_ratio[1]:.2%}")
fig, ax = plt.subplots(1, 2, figsize=(12, 4))
ax[0].pie(churn_ratio.values, labels=['未流失', '流失'], autopct='%1.1f%%', startangle=90)
ax[0].set_title('用户流失比例分布')
sns.kdeplot(data=df, x='tenure', hue='Churn', fill=True, ax=ax[1])
ax[1].set_title('在网时长分布的流失差异')
plt.tight_layout()
plt.show()
```
图表显示,流失用户约占26%,属于中度不平衡。这提醒我们,后续评估模型时,**准确率(Accuracy)将是一个具有误导性的指标**,应重点关注**精确率(Precision)、召回率(Recall)和F1分数**,尤其是流失类(正类)的召回率。
接下来,分析关键特征与流失的关系,为特征工程提供方向:
- **数值特征交互**:`MonthlyCharges`与`tenure`的比值,可能反映用户的“平均单月价值”或“费用敏感度”。
- **行为特征构造**:对于服务类特征(如`OnlineSecurity`, `StreamingTV`),可以构造一个“附加服务总数”的特征。
- **时间维度特征**:如果数据有时间戳,可以构造“最近一次互动距今时长”等,但本数据集有限。
下面是一个构造新特征的例子:
```python
# 构造新特征:用户平均每月贡献价值(总消费/在网时长)
df['AvgMonthlyValue'] = df['TotalCharges'] / df['tenure']
# 构造新特征:用户使用的附加服务总数
service_cols = ['OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport', 'StreamingTV', 'StreamingMovies']
# 假设这些列已编码为0/1(0表示无,1表示有)
df['NumAdditionalServices'] = df[service_cols].sum(axis=1)
```
> 提示:特征工程后,务必检查新特征与目标的相关性,并注意处理可能引入的异常值(如除零错误)。
## 4. 构建模型管道与多模型对比
数据准备好了,进入核心环节。我强烈建议使用Scikit-learn的`Pipeline`和`ColumnTransformer`来构建可复现的机器学习流程。这能确保数据预处理(如缩放)只在训练集上进行,然后一致地应用到测试集,避免数据泄露。
首先,划分特征和目标,并分割训练集与测试集。
```python
from sklearn.model_selection import train_test_split
# 假设X是特征DataFrame, y是目标Series
X = df.drop('Churn', axis=1)
y = df['Churn']
# 分层分割,以保持测试集中流失用户的比例
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
print(f"训练集大小: {X_train.shape}, 测试集大小: {X_test.shape}")
```
接着,定义预处理步骤。数值特征需要标准化,分类特征已经编码完毕(独热编码产生的是数值0/1,通常不需要再缩放)。
```python
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler
# 识别数值型特征列名(排除已经是0/1的编码列)
numeric_features = ['tenure', 'MonthlyCharges', 'TotalCharges', 'AvgMonthlyValue', 'NumAdditionalServices']
numeric_transformer = Pipeline(steps=[('scaler', StandardScaler())])
# 组合所有预处理步骤
preprocessor = ColumnTransformer(
transformers=[
('num', numeric_transformer, numeric_features)
],
remainder='passthrough' # 其他列(已编码的分类特征)直接通过
)
```
现在,我们来对比几个常用的分类器。为了公平比较,我们将它们放入同一个评估框架。
```python
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import cross_val_score
import xgboost as xgb
# 初始化模型字典
models = {
'Logistic Regression': LogisticRegression(max_iter=1000, random_state=42),
'Decision Tree': DecisionTreeClassifier(random_state=42),
'Random Forest': RandomForestClassifier(random_state=42),
'AdaBoost': AdaBoostClassifier(random_state=42),
'Gradient Boosting': GradientBoostingClassifier(random_state=42),
'XGBoost': xgb.XGBClassifier(use_label_encoder=False, eval_metric='logloss', random_state=42),
'SVM': SVC(probability=True, random_state=42), # 启用概率估计以便计算AUC
'KNN': KNeighborsClassifier()
}
# 使用管道包装预处理和模型
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
results = []
for name, model in models.items():
# 创建完整管道
pipeline = Pipeline(steps=[('preprocessor', preprocessor),
('classifier', model)])
# 训练
pipeline.fit(X_train, y_train)
# 预测
y_pred = pipeline.predict(X_test)
y_pred_proba = pipeline.predict_proba(X_test)[:, 1] if hasattr(model, "predict_proba") else None
# 计算指标
acc = accuracy_score(y_test, y_pred)
pre = precision_score(y_test, y_pred)
rec = recall_score(y_test, y_pred)
f1 = f1_score(y_test, y_pred)
auc = roc_auc_score(y_test, y_pred_proba) if y_pred_proba is not None else None
results.append({
'Model': name,
'Accuracy': acc,
'Precision': pre,
'Recall': rec,
'F1-Score': f1,
'AUC-ROC': auc
})
# 将结果转为DataFrame便于查看
results_df = pd.DataFrame(results).sort_values(by='F1-Score', ascending=False)
print(results_df.to_string(index=False))
```
运行这段代码后,你会得到一个包含各个模型在测试集上性能的表格。**F1分数通常是衡量不平衡分类问题综合性能的好指标**,因为它同时考虑了精确率和召回率。在我的多次实验中,梯度提升树家族(如XGBoost、Gradient Boosting)和随机森林通常表现稳定。
## 5. 模型调优、评估与业务解读
选定一两个表现最好的模型(比如`Random Forest`和`XGBoost`)进行深入调优。这里以`Random Forest`为例,使用`GridSearchCV`进行超参数搜索。
```python
from sklearn.model_selection import GridSearchCV
# 定义参数网格
param_grid_rf = {
'classifier__n_estimators': [100, 200, 300],
'classifier__max_depth': [10, 15, 20, None],
'classifier__min_samples_split': [2, 5, 10],
'classifier__min_samples_leaf': [1, 2, 4]
}
# 创建基础管道
rf_pipeline = Pipeline(steps=[('preprocessor', preprocessor),
('classifier', RandomForestClassifier(random_state=42, class_weight='balanced'))])
# 网格搜索
grid_search = GridSearchCV(rf_pipeline, param_grid_rf, cv=5, scoring='f1', n_jobs=-1, verbose=1)
grid_search.fit(X_train, y_train)
print(f"最佳参数: {grid_search.best_params_}")
print(f"最佳交叉验证F1分数: {grid_search.best_score_:.4f}")
# 用最佳模型在测试集上最终评估
best_model = grid_search.best_estimator_
y_pred_best = best_model.predict(X_test)
print(f"\n测试集最终评估:")
print(f"精确率: {precision_score(y_test, y_pred_best):.4f}")
print(f"召回率: {recall_score(y_test, y_pred_best):.4f}")
print(f"F1分数: {f1_score(y_test, y_pred_best):.4f}")
```
调优后,模型性能应有提升。但模型本身是个黑盒,我们需要打开它,理解它做决策的依据。**特征重要性分析**是连接模型与业务的关键桥梁。
```python
# 获取特征名称(注意:经过ColumnTransformer后顺序可能改变)
# 一种可靠的方式是从预处理器中提取
feature_names = numeric_features + [col for col in X.columns if col not in numeric_features]
# 确保顺序与模型训练时一致(这里需要根据pipeline实际步骤调整,以下为示例)
final_feature_names = feature_names # 实际情况可能更复杂,需要对齐
# 提取最佳随机森林模型
best_rf = best_model.named_steps['classifier']
importances = best_rf.feature_importances_
# 创建重要性DataFrame并排序
feat_imp_df = pd.DataFrame({
'feature': final_feature_names,
'importance': importances
}).sort_values('importance', ascending=False)
# 可视化Top 15重要特征
plt.figure(figsize=(10, 6))
sns.barplot(data=feat_imp_df.head(15), x='importance', y='feature')
plt.title('随机森林模型 - 特征重要性 Top 15')
plt.tight_layout()
plt.show()
```
根据特征重要性结果,我们可以给业务团队提供清晰的洞察。例如,如果`tenure`(在网时长)和`Contract_Month-to-month`(月付合同)的重要性最高,那么业务结论就是:
1. **核心风险因子**:新用户(在网时间短)和采用灵活月付合同的用户流失风险极高。
2. **行动建议**:
* **针对新用户**:设计“新手护航”计划,在前三个月提供专属客服、小额优惠券或使用指南,提升早期体验和黏性。
* **针对月付用户**:设计有吸引力的“年付折扣”或“合约锁定优惠”,引导用户向更稳定的合同类型迁移。计算用户终身价值(LTV),用部分未来收益补贴当前的转换成本。
最后,为了在生产环境中持续监控模型,我们需要保存完整的建模管道(包括预处理步骤)。
```python
import joblib
# 保存整个最佳管道
joblib.dump(best_model, 'telecom_churn_pipeline.pkl')
# 未来加载和使用
loaded_pipeline = joblib.load('telecom_churn_pipeline.pkl')
# 对新数据(单条或多条)进行预测
# new_data 需要是与X_train具有相同特征的DataFrame
# predictions = loaded_pipeline.predict(new_data)
# prediction_probas = loaded_pipeline.predict_proba(new_data)
```
在整个项目里,我花在数据清洗和特征工程上的时间远多于调参。一个干净、有信息量的特征集,哪怕用一个默认参数的随机森林,效果也常常优于一个脏数据上的精调复杂模型。另外,面对业务方时,比起AUC提升了0.02,他们更关心“我们应该优先给哪100个用户打电话”以及“话术该怎么说”。所以,模型输出最终要转换成**可执行的用户分群列表**和**个性化的干预策略**,这才是数据科学产生价值的最后一公里。