Python数据分析实战:从描述统计到机器学习模型(附完整代码)

如果你已经掌握了Python的基础语法,正摩拳擦掌地想用它来解决一些真实世界的数据问题,那么这篇文章就是为你准备的。很多教程会告诉你“什么是Pandas”,或者“如何调用sklearn的fit方法”,但当你真正面对一份从业务系统导出的、满是缺失值和异常值的CSV文件时,却常常感到无从下手。从数据清洗的泥潭,到模型调优的迷宫,中间每一步都可能让你踩坑。

这篇文章不会重复那些随处可见的基础概念,而是想和你分享一套我反复验证过的、从原始数据到可用模型的完整工作流。我们会从一个虚构但非常典型的电商用户数据集出发,手把手地走过数据探索、清洗、可视化、特征工程,最终构建并优化一个预测模型。更重要的是,我会解释每个步骤背后的“为什么”——为什么用箱线图而不是直方图看异常值?为什么特征缩放对某些模型至关重要?当模型过拟合时,除了调参我们还能做什么?

你会发现,数据分析远不止是运行几行代码,它更像是一次侦探工作,需要你不断提出假设,用工具验证,再根据线索调整方向。我们这就开始。

1. 环境准备与数据初窥:搭建你的分析工作台

工欲善其事,必先利其器。一个稳定、高效的分析环境能让你事半功倍。我强烈推荐使用 Anaconda 来管理你的Python环境和包依赖,它能完美解决“在我机器上能跑”的经典难题。如果你追求更轻量或自定义的环境,venvpip 也是不错的选择。

创建一个专属的数据分析环境是个好习惯:

# 使用conda创建新环境
conda create -n data_analysis python=3.11 pandas scikit-learn matplotlib seaborn jupyter -y
conda activate data_analysis

# 或者使用venv
python -m venv data_analysis_env
source data_analysis_env/bin/activate  # Linux/macOS
# data_analysis_env\Scripts\activate   # Windows
pip install pandas scikit-learn matplotlib seaborn jupyter

接下来,我们引入本次实战的核心工具库。请注意,我习惯在导入时给一些常用库设置别名,这能让后续代码更简洁。

# 数据分析的四大金刚
import pandas as pd
import numpy as np

# 可视化双雄
import matplotlib.pyplot as plt
import seaborn as sns
# 设置Seaborn的默认样式,让图表更好看
sns.set_style("whitegrid")

# 机器学习核心库
from sklearn.model_selection import train_test_split, cross_val_score, GridSearchCV
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score

# 在Jupyter Notebook中让图表内嵌显示
%matplotlib inline

现在,让我们生成一份模拟的电商用户数据集。在真实项目中,你的数据可能来自数据库查询或API接口,但这份模拟数据涵盖了你会遇到的大部分典型问题:缺失值、异常值、分类变量、数值型变量,以及需要预测的目标。

# 设置随机种子保证结果可复现
np.random.seed(42)

# 生成1000条用户记录
n_samples = 1000

data = {
    'user_id': range(1, n_samples + 1),
    'age': np.random.randint(18, 70, n_samples),
    'gender': np.random.choice(['Male', 'Female', 'Other'], n_samples, p=[0.48, 0.48, 0.04]),
    'annual_income': np.random.normal(50000, 15000, n_samples).astype(int),
    'avg_session_duration': np.random.exponential(300, n_samples).astype(int),  # 秒
    'pages_per_session': np.random.poisson(8, n_samples),
    'bounce_rate': np.random.beta(2, 5, n_samples) * 100,  # 百分比
    'device_type': np.random.choice(['Mobile', 'Desktop', 'Tablet'], n_samples, p=[0.6, 0.35, 0.05]),
    'signup_channel': np.random.choice(['Organic', 'Paid_Search', 'Social', 'Referral'], n_samples),
    'has_purchased': np.random.choice([0, 1], n_samples, p=[0.7, 0.3])  # 目标变量:是否购买
}

df = pd.DataFrame(data)

# 故意引入一些数据质量问题,模拟真实场景
# 1. 在age和income中插入缺失值
missing_mask = np.random.rand(n_samples) < 0.05
df.loc[missing_mask, 'age'] = np.nan
df.loc[np.random.rand(n_samples) < 0.03, 'annual_income'] = np.nan

# 2. 在income中插入一些异常高值(可能是数据录入错误)
df.loc[np.random.choice(df.index, 10), 'annual_income'] = np.random.randint(200000, 500000, 10)

# 3. 在bounce_rate中插入一些超出合理范围的值
df.loc[np.random.choice(df.index, 5), 'bounce_rate'] = 150

print("数据集形状:", df.shape)
print("\n前5行数据预览:")
print(df.head())

运行上面的代码,你会看到一个包含1000行、10列的数据框。df.head() 给了我们一个初步印象,但要真正了解数据全貌,我们需要更系统的探索。

2. 深度数据探索与清洗:像侦探一样审视你的数据

拿到数据后,切忌直接套用模型。花在探索和清洗上的时间,往往能成倍地提升后续建模的效果。这一步的目标是理解每个变量的分布、发现数据问题,并建立对数据的直觉。

首先,让我们用 df.info()df.describe() 做个快速体检:

print("=== 数据基本信息 ===")
print(df.info())

print("\n=== 数值型变量描述性统计 ===")
# 注意:describe()默认只针对数值列,我们需要调整参数以包含所有列
print(df.describe(include='all').T)

df.info() 会告诉我们是否有缺失值,以及每列的数据类型。而 df.describe() 的输出则是一张信息量巨大的表格:

统计量ageannual_incomeavg_session_durationpages_per_sessionbounce_ratehas_purchased
count约950约9701000100010001000
mean~44~50150~300~8.0~28.60.3
std~15~15120~300~2.8~12.50.458
min18-?可能接近00可能为负?0
max69可能极高可能极高可能极高1501

注意:从describe的输出中,我们立刻能发现几个警报。annual_income 的count小于1000,说明有缺失值;bounce_rate 的最大值竟然达到了150%,这明显超出了0-100%的合理范围,是典型的异常值。

描述性统计给了我们宏观印象,但可视化能揭示更多细节。对于数值型变量,我习惯用直方图配合箱线图的组合拳:直方图看整体分布形状,箱线图专门揪出异常值。

# 绘制数值型变量的分布与异常值检查
numeric_cols = ['age', 'annual_income', 'avg_session_duration', 'pages_per_session', 'bounce_rate']

fig, axes = plt.subplots(len(numeric_cols), 2, figsize=(14, 5*len(numeric_cols)))
fig.suptitle('数值型变量分布与异常值检测', fontsize=16, y=1.02)

for idx, col in enumerate(numeric_cols):
    # 左子图:分布直方图 + 密度曲线
    ax1 = axes[idx, 0]
    sns.histplot(df[col], kde=True, ax=ax1, bins=30)
    ax1.axvline(df[col].mean(), color='red', linestyle='--', label=f'Mean: {df[col].mean():.1f}')
    ax1.axvline(df[col].median(), color='green', linestyle=':', label=f'Median: {df[col].median():.1f}')
    ax1.set_title(f'{col} - 分布')
    ax1.legend()
    
    # 右子图:箱线图
    ax2 = axes[idx, 1]
    sns.boxplot(x=df[col], ax=ax2)
    ax2.set_title(f'{col} - 箱线图')
    
plt.tight_layout()
plt.show()

这张组合图能立刻告诉你很多故事:annual_income 的箱线图上方是否有孤立的点(异常高收入)?bounce_rate 的直方图是否在100%处被截断?avg_session_duration 是否呈典型的指数分布(多数值较小,长尾向右)?

对于分类变量,我们关心的是类别分布是否均衡,以及是否存在罕见的类别。

categorical_cols = ['gender', 'device_type', 'signup_channel', 'has_purchased']

fig, axes = plt.subplots(2, 2, figsize=(14, 10))
axes = axes.flatten()

for idx, col in enumerate(categorical_cols):
    ax = axes[idx]
    # 计算每个类别的计数和比例
    value_counts = df[col].value_counts()
    percentages = (value_counts / len(df) * 100).round(1)
    
    # 创建柱状图,并在柱子上方标注百分比
    bars = ax.bar(value_counts.index.astype(str), value_counts.values)
    ax.set_title(f'{col} - 类别分布')
    ax.set_xlabel(col)
    ax.set_ylabel('计数')
    
    # 在柱子上方添加百分比标签
    for bar, percentage in zip(bars, percentages):
        height = bar.get_height()
        ax.text(bar.get_x() + bar.get_width()/2., height + 5,
                f'{percentage}%', ha='center', va='bottom', fontsize=9)
    
    # 如果类别过多,旋转x轴标签避免重叠
    if len(value_counts) > 4:
        plt.setp(ax.get_xticklabels(), rotation=45, ha='right')

plt.tight_layout()
plt.show()

现在,基于我们的发现,开始清洗数据。数据清洗没有唯一标准答案,它取决于你的业务知识和分析目标。以下是我通常会采取的步骤:

步骤一:处理缺失值

  • 对于 age,由于缺失比例不高(~5%),且年龄是相对稳定的属性,我倾向于用中位数填充,因为它对异常值不敏感。
  • 对于 annual_income,同样用中位数填充。
# 备份原始数据是个好习惯,以防需要回溯
df_clean = df.copy()

# 用中位数填充数值型变量的缺失值
for col in ['age', 'annual_income']:
    median_val = df_clean[col].median()
    df_clean[col].fillna(median_val, inplace=True)
    print(f"填充 {col} 的缺失值,使用中位数: {median_val}")

步骤二:处理异常值 异常值处理需要谨慎,不能简单地删除,因为有时它们代表了重要的业务场景(比如真正的VIP客户)。我的策略是:

  1. 对于明显错误的数据(如 bounce_rate > 100),进行修正或视为缺失值处理。
  2. 对于可能是真实但极端的数据(如极高的 annual_income),考虑是否保留或进行缩尾处理。
# 修正明显错误的bounce_rate:超过100%的设为100%
df_clean['bounce_rate'] = df_clean['bounce_rate'].clip(upper=100)

# 对于annual_income,我们使用IQR方法识别极端异常值,但不直接删除,而是标记
Q1 = df_clean['annual_income'].quantile(0.25)
Q3 = df_clean['annual_income'].quantile(0.75)
IQR = Q3 - Q1
upper_bound = Q3 + 3 * IQR  # 使用3倍IQR,比传统的1.5倍更宽松

extreme_high_income = df_clean['annual_income'] > upper_bound
print(f"发现 {extreme_high_income.sum()} 条极端高收入记录(> {upper_bound:.0f})")

# 可以选择创建一个新特征来标记这些异常值,供后续分析
df_clean['is_extreme_income'] = extreme_high_income.astype(int)

步骤三:创建衍生特征 原始特征有时不能直接反映问题,组合或转换后可能更有预测力。

# 1. 将年龄分箱,生成人生阶段标签
df_clean['age_group'] = pd.cut(df_clean['age'], 
                                 bins=[0, 25, 35, 50, 65, 100],
                                 labels=['18-25', '26-35', '36-50', '51-65', '66+'])

# 2. 创建收入与年龄的比值特征,可能反映消费能力
df_clean['income_per_age'] = df_clean['annual_income'] / df_clean['age']

# 3. 创建会话效率指标:平均每页停留时间
df_clean['sec_per_page'] = df_clean['avg_session_duration'] / (df_clean['pages_per_session'] + 1)  # +1避免除零

print("衍生特征创建完成。新增列:", [col for col in df_clean.columns if col not in df.columns])

清洗完成后,再次检查数据质量:

print("=== 清洗后数据基本信息 ===")
print(f"数据集形状: {df_clean.shape}")
print(f"剩余缺失值数量:\n{df_clean.isnull().sum()[df_clean.isnull().sum() > 0]}")

3. 多维数据可视化与洞察挖掘:让数据自己讲故事

干净的数据是基础,但真正的价值在于从数据中提取洞察。可视化不仅是“让报告更好看”,更是发现模式、验证假设的探索工具。在这一步,我们将从单变量分析深入到多变量关系探索。

首先,让我们看看目标变量 has_purchased(是否购买)在不同用户群体中的分布。这能帮助我们识别哪些用户特征可能与购买行为相关。

# 分析购买行为在不同维度上的差异
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()

# 1. 年龄与购买率的关系
# 使用年龄分箱后的组别
age_purchase_rate = df_clean.groupby('age_group')['has_purchased'].mean().sort_index()
axes[0].bar(age_purchase_rate.index.astype(str), age_purchase_rate.values * 100)
axes[0].set_title('各年龄组购买率')
axes[0].set_ylabel('购买率 (%)')
axes[0].tick_params(axis='x', rotation=45)

# 2. 性别与购买率
gender_purchase_rate = df_clean.groupby('gender')['has_purchased'].mean().sort_values(ascending=False)
axes[1].bar(gender_purchase_rate.index.astype(str), gender_purchase_rate.values * 100)
axes[1].set_title('性别购买率差异')
axes[1].set_ylabel('购买率 (%)')

# 3. 设备类型与购买率
device_purchase_rate = df_clean.groupby('device_type')['has_purchased'].mean().sort_values(ascending=False)
axes[2].bar(device_purchase_rate.index.astype(str), device_purchase_rate.values * 100)
axes[2].set_title('设备类型购买率差异')
axes[2].set_ylabel('购买率 (%)')

# 4. 注册渠道与购买率
channel_purchase_rate = df_clean.groupby('signup_channel')['has_purchased'].mean().sort_values(ascending=False)
axes[3].bar(channel_purchase_rate.index.astype(str), channel_purchase_rate.values * 100)
axes[3].set_title('注册渠道购买率差异')
axes[3].set_ylabel('购买率 (%)')
axes[3].tick_params(axis='x', rotation=45)

# 5. 收入水平与购买率(将收入分箱)
df_clean['income_group'] = pd.qcut(df_clean['annual_income'], q=5, labels=['很低', '较低', '中等', '较高', '很高'])
income_purchase_rate = df_clean.groupby('income_group')['has_purchased'].mean()
axes[4].bar(income_purchase_rate.index.astype(str), income_purchase_rate.values * 100)
axes[4].set_title('收入分组购买率差异')
axes[4].set_ylabel('购买率 (%)')

# 6. 跳出率与购买率的关系(散点图)
axes[5].scatter(df_clean['bounce_rate'], df_clean['has_purchased'], alpha=0.5)
axes[5].set_xlabel('跳出率 (%)')
axes[5].set_ylabel('是否购买 (0/1)')
axes[5].set_title('跳出率与购买行为关系')
# 添加趋势线
z = np.polyfit(df_clean['bounce_rate'], df_clean['has_purchased'], 1)
p = np.poly1d(z)
axes[5].plot(df_clean['bounce_rate'], p(df_clean['bounce_rate']), "r--", alpha=0.8)

plt.tight_layout()
plt.show()

从这些图表中,你可能已经发现了一些初步模式:也许某个年龄段的购买率显著更高,或者来自特定注册渠道的用户转化更好。这些洞察可以直接指导业务决策,比如调整营销策略。

接下来,让我们探索数值型特征之间的相关性,以及它们与目标变量的关系。热力图是展示相关性的绝佳工具。

# 选择数值型特征计算相关性矩阵
numeric_features = ['age', 'annual_income', 'avg_session_duration', 
                    'pages_per_session', 'bounce_rate', 'income_per_age', 
                    'sec_per_page', 'has_purchased']

corr_matrix = df_clean[numeric_features].corr()

plt.figure(figsize=(12, 10))
# 使用Seaborn的热力图,并添加数值标注
sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', 
            center=0, square=True, linewidths=1, cbar_kws={"shrink": 0.8})
plt.title('特征相关性热力图', fontsize=16)
plt.tight_layout()
plt.show()

提示:关注与 has_purchased 相关性较高的特征(绝对值大于0.1或0.15)。同时也要检查特征之间的高相关性(如大于0.8),这可能导致多重共线性问题,在后续建模时可能需要处理。

除了静态图表,有时我们需要更动态的探索方式。散点图矩阵(pairplot)能一次性展示多个数值变量两两之间的关系,特别适合中等数量特征的数据集。

# 选择关键数值特征绘制散点图矩阵
key_numeric_features = ['annual_income', 'avg_session_duration', 'pages_per_session', 'bounce_rate', 'has_purchased']

# 为了更清晰,我们抽样一部分数据点
sample_df = df_clean.sample(n=300, random_state=42)

pairplot = sns.pairplot(sample_df[key_numeric_features], 
                        hue='has_purchased',  # 按是否购买着色
                        palette={0: 'blue', 1: 'red'},
                        plot_kws={'alpha': 0.6, 's': 30},
                        diag_kind='kde')  # 对角线用核密度估计图
pairplot.fig.suptitle('关键特征散点图矩阵(按购买行为着色)', y=1.02, fontsize=16)
plt.show()

从散点图矩阵中,你可以直观地看到购买用户(红色)与非购买用户(蓝色)在特征空间中的分布差异。例如,购买用户是否集中在某个收入区间?他们的会话时长是否普遍更长?

最后,让我们用一个小技巧来可视化高维数据:t-SNE降维。它能将高维特征压缩到2维,让我们在平面上观察样本的聚集情况。

from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler

# 准备特征:选择重要的数值特征,并进行标准化
tsne_features = ['age', 'annual_income', 'avg_session_duration', 
                 'pages_per_session', 'bounce_rate', 'income_per_age', 'sec_per_page']
X_tsne = df_clean[tsne_features].fillna(df_clean[tsne_features].mean())
X_tsne_scaled = StandardScaler().fit_transform(X_tsne)

# 使用t-SNE降维到2D
tsne = TSNE(n_components=2, random_state=42, perplexity=30, n_iter=1000)
X_tsne_2d = tsne.fit_transform(X_tsne_scaled)

# 绘制结果
plt.figure(figsize=(12, 8))
scatter = plt.scatter(X_tsne_2d[:, 0], X_tsne_2d[:, 1], 
                      c=df_clean['has_purchased'], 
                      cmap='coolwarm', alpha=0.6, s=30)
plt.colorbar(scatter, label='是否购买 (0=否, 1=是)')
plt.title('t-SNE可视化:用户特征降维(按购买行为着色)', fontsize=16)
plt.xlabel('t-SNE 维度 1')
plt.ylabel('t-SNE 维度 2')
plt.grid(True, alpha=0.3)
plt.show()

如果红色点(购买用户)和蓝色点(非购买用户)在t-SNE图中形成了相对分离的簇,那是个好迹象,说明我们的特征可能足以区分这两类用户。

4. 特征工程与预处理:为机器学习模型准备食材

经过探索和清洗,我们有了干净的数据,但还不能直接喂给机器学习算法。特征工程就像烹饪前的食材处理,同样的原料,不同的处理方式会极大影响最终菜肴的味道。这一步的目标是创建对模型预测更有帮助的特征表示。

第一步:处理分类变量 大多数机器学习算法只能处理数值输入,所以我们需要将分类变量(如gender、device_type)转换为数值形式。这里有几种常见策略:

编码方法适用场景优点缺点
标签编码有序分类变量简单,只增加一列可能引入虚假的顺序关系
独热编码无序分类变量,类别少无顺序假设,信息完整维度爆炸(类别多时)
目标编码任何分类变量能捕捉类别与目标的关系容易过拟合,需谨慎使用

对于我们的数据,我采用混合策略:

  • 二分类变量(如gender中的Male/Female)使用标签编码
  • 多分类无序变量(如device_type)使用独热编码
  • 高基数分类变量(如果有)考虑目标编码或频率编码
# 备份清洗后的数据
df_encoded = df_clean.copy()

# 1. 标签编码:适用于二分类或有序分类变量
# 这里我们对gender进行编码(假设Other视为独立类别)
gender_mapping = {'Male': 0, 'Female': 1, 'Other': 2}
df_encoded['gender_encoded'] = df_encoded['gender'].map(gender_mapping)

# 2. 独热编码:适用于无序分类变量
# 对device_type和signup_channel进行独热编码
df_encoded = pd.get_dummies(df_encoded, columns=['device_type', 'signup_channel', 'age_group'], 
                            prefix=['device', 'channel', 'age'], drop_first=True)
# drop_first=True可以避免多重共线性,删除第一个类别作为基准

print("编码后的特征数量:", df_encoded.shape[1])
print("新增的特征列:", [col for col in df_encoded.columns if col not in df_clean.columns])

第二步:数值特征缩放 许多机器学习算法(如KNN、SVM、神经网络)对特征的尺度敏感。例如,annual_income的范围在数万,而bounce_rate在0-100之间,这种量级差异可能导致模型过度关注大尺度特征。常见的缩放方法有:

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

# 选择需要缩放的数值特征
scale_features = ['annual_income', 'avg_session_duration', 'pages_per_session', 
                  'bounce_rate', 'income_per_age', 'sec_per_page']

# 方法1: 标准化 (Z-score标准化) - 使特征均值为0,标准差为1
scaler_standard = StandardScaler()
df_encoded[[f'{col}_std' for col in scale_features]] = scaler_standard.fit_transform(df_encoded[scale_features])

# 方法2: 归一化 (Min-Max缩放) - 将特征缩放到[0,1]区间
scaler_minmax = MinMaxScaler()
df_encoded[[f'{col}_minmax' for col in scale_features]] = scaler_minmax.fit_transform(df_encoded[scale_features])

# 方法3: 鲁棒缩放 - 使用中位数和IQR,对异常值不敏感
scaler_robust = RobustScaler()
df_encoded[[f'{col}_robust' for col in scale_features]] = scaler_robust.fit_transform(df_encoded[scale_features])

print("缩放完成。你可以根据后续模型的表现选择最适合的缩放方式。")

注意:在实际项目中,缩放应该在训练集上拟合,然后同时转换训练集和测试集,避免数据泄露。我们稍后在划分数据集后会重新演示正确的做法。

第三步:特征选择 不是所有特征都对预测目标有帮助。无关或冗余的特征不仅增加计算成本,还可能引入噪声,降低模型性能。我们可以从多个角度进行特征选择:

from sklearn.feature_selection import SelectKBest, f_classif, RFE
from sklearn.ensemble import RandomForestClassifier

# 准备特征矩阵X和目标向量y
# 首先排除非特征列和目标列
exclude_cols = ['user_id', 'gender', 'age', 'annual_income', 'avg_session_duration', 
                'pages_per_session', 'bounce_rate', 'income_per_age', 'sec_per_page',
                'age_group', 'income_group', 'is_extreme_income', 'has_purchased']

feature_cols = [col for col in df_encoded.columns if col not in exclude_cols]
X = df_encoded[feature_cols]
y = df_encoded['has_purchased']

print(f"特征矩阵形状: {X.shape}")

# 方法1: 基于统计检验的特征选择(ANOVA F值)
selector_anova = SelectKBest(score_func=f_classif, k=15)  # 选择最好的15个特征
X_anova = selector_anova.fit_transform(X, y)

# 查看特征得分
anova_scores = pd.DataFrame({
    'feature': feature_cols,
    'anova_score': selector_anova.scores_
}).sort_values('anova_score', ascending=False)

print("=== ANOVA F值特征排名(前10) ===")
print(anova_scores.head(10))

# 方法2: 基于树模型的特征重要性
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X, y)

rf_importance = pd.DataFrame({
    'feature': feature_cols,
    'importance': rf.feature_importances_
}).sort_values('importance', ascending=False)

print("\n=== 随机森林特征重要性排名(前10) ===")
print(rf_importance.head(10))

# 方法3: 递归特征消除(RFE)
# 使用逻辑回归作为基模型
from sklearn.linear_model import LogisticRegression

estimator = LogisticRegression(max_iter=1000, random_state=42)
selector_rfe = RFE(estimator, n_features_to_select=10, step=1)
selector_rfe.fit(X, y)

rfe_selected = pd.DataFrame({
    'feature': feature_cols,
    'rfe_ranking': selector_rfe.ranking_,
    'rfe_support': selector_rfe.support_
})

print("\n=== RFE选择的特征(排名=1) ===")
print(rfe_selected[rfe_selected['rfe_support']].sort_values('rfe_ranking'))

综合以上方法的结果,我们可以选择那些在多种选择方法中都排名靠前的特征。例如,创建一个特征重要性综合评分:

# 综合三种方法的结果
feature_rankings = anova_scores.merge(rf_importance, on='feature').merge(rfe_selected, on='feature')

# 归一化评分
feature_rankings['anova_norm'] = (feature_rankings['anova_score'] - feature_rankings['anova_score'].min()) / \
                                 (feature_rankings['anova_score'].max() - feature_rankings['anova_score'].min())
feature_rankings['importance_norm'] = (feature_rankings['importance'] - feature_rankings['importance'].min()) / \
                                      (feature_rankings['importance'].max() - feature_rankings['importance'].min())
# RFE排名转换为分数(排名越小分数越高)
feature_rankings['rfe_score'] = 1 / feature_rankings['rfe_ranking']

# 计算综合得分(简单平均)
feature_rankings['composite_score'] = (feature_rankings['anova_norm'] + 
                                        feature_rankings['importance_norm'] + 
                                        feature_rankings['rfe_score']) / 3

top_features = feature_rankings.sort_values('composite_score', ascending=False).head(15)
print("=== 综合特征重要性排名(前15) ===")
print(top_features[['feature', 'composite_score', 'anova_norm', 'importance_norm', 'rfe_score']])

基于综合评分,我们可以选择前10-15个特征用于最终建模,这通常能在保留大部分预测能力的同时显著减少过拟合风险。

5. 构建与优化机器学习模型:从基线到最优

现在到了最激动人心的环节:构建预测模型。我们将遵循一个系统化的流程:从简单的基线模型开始,逐步尝试更复杂的算法,并通过交叉验证和超参数调优寻找最佳配置。

第一步:准备训练集和测试集 这是至关重要的一步,必须确保测试集完全独立,不参与任何训练过程。

from sklearn.model_selection import train_test_split

# 使用综合评分选择的前12个特征
selected_features = top_features['feature'].head(12).tolist()
X_selected = df_encoded[selected_features]

# 划分数据集:70%训练,30%测试
X_train, X_test, y_train, y_test = train_test_split(
    X_selected, y, test_size=0.3, random_state=42, stratify=y  # stratify确保类别比例一致
)

print(f"训练集大小: {X_train.shape}")
print(f"测试集大小: {X_test.shape}")
print(f"训练集中正例比例: {y_train.mean():.2%}")
print(f"测试集中正例比例: {y_test.mean():.2%}")

# 正确的特征缩放:只在训练集上拟合,然后转换训练集和测试集
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)  # 注意:使用训练集的参数,不是重新拟合

第二步:建立基线模型 在尝试复杂模型前,先建立一个简单的基线模型。这给了我们一个性能基准,也帮助验证整个流程是否正常。

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score

# 基线模型:总是预测多数类
baseline = DummyClassifier(strategy='most_frequent')
baseline.fit(X_train_scaled, y_train)
y_pred_baseline = baseline.predict(X_test_scaled)

# 评估基线模型
def evaluate_model(y_true, y_pred, y_pred_proba=None, model_name="模型"):
    metrics = {
        '准确率': accuracy_score(y_true, y_pred),
        '精确率': precision_score(y_true, y_pred, zero_division=0),
        '召回率': recall_score(y_true, y_pred, zero_division=0),
        'F1分数': f1_score(y_true, y_pred, zero_division=0)
    }
    
    if y_pred_proba is not None:
        metrics['AUC'] = roc_auc_score(y_true, y_pred_proba)
    
    print(f"=== {model_name} 性能 ===")
    for metric, value in metrics.items():
        print(f"{metric}: {value:.4f}")
    
    return metrics

baseline_metrics = evaluate_model(y_test, y_pred_baseline, model_name="基线模型(总是预测不购买)")

基线模型通常表现很差(在我们的案例中,准确率可能只有70%,但精确率和召回率都是0),但这没关系,它的价值在于提供了一个最低参考线。

第三步:尝试多种分类算法 现在让我们尝试几种不同的机器学习算法,看看哪种最适合我们的数据。我们将使用默认参数快速比较。

from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.naive_bayes import GaussianNB

# 定义要比较的模型
models = {
    '逻辑回归': LogisticRegression(max_iter=1000, random_state=42),
    '决策树': DecisionTreeClassifier(random_state=42),
    '随机森林': RandomForestClassifier(random_state=42),
    '梯度提升': GradientBoostingClassifier(random_state=42),
    'SVM': SVC(probability=True, random_state=42),  # probability=True以便计算AUC
    'K近邻': KNeighborsClassifier(),
    '朴素贝叶斯': GaussianNB()
}

# 训练并评估每个模型
results = {}
for name, model in models.items():
    # 训练模型
    model.fit(X_train_scaled, y_train)
    
    # 预测
    y_pred = model.predict(X_test_scaled)
    y_pred_proba = model.predict_proba(X_test_scaled)[:, 1] if hasattr(model, "predict_proba") else None
    
    # 评估
    metrics = evaluate_model(y_test, y_pred, y_pred_proba, model_name=name)
    results[name] = metrics
    
    # 打印分类报告
    print(f"\n{name}分类报告:")
    print(classification_report(y_test, y_pred, target_names=['未购买', '已购买']))
    print("="*60)

第四步:模型调优与交叉验证 从初步结果中,选择表现最好的1-2个模型进行深入调优。我们以随机森林为例,演示如何使用网格搜索和交叉验证寻找最优超参数。

from sklearn.model_selection import GridSearchCV, StratifiedKFold

# 创建随机森林模型
rf = RandomForestClassifier(random_state=42)

# 定义参数网格
param_grid = {
    'n_estimators': [100, 200, 300],
    'max_depth': [10, 20, 30, None],
    'min_samples_split': [2, 5, 10],
    'min_samples_leaf': [1, 2, 4],
    'max_features': ['sqrt', 'log2', 0.5]  # 考虑的特征数量
}

# 使用分层K折交叉验证(保持每折的类别比例)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

# 创建网格搜索对象
grid_search = GridSearchCV(
    estimator=rf,
    param_grid=param_grid,
    cv=cv,
    scoring='roc_auc',  # 使用AUC作为评估指标
    n_jobs=-1,  # 使用所有可用的CPU核心
    verbose=1
)

# 执行网格搜索
print("开始网格搜索...")
grid_search.fit(X_train_scaled, y_train)

print(f"\n最佳参数: {grid_search.best_params_}")
print(f"最佳交叉验证AUC: {grid_search.best_score_:.4f}")

# 使用最佳模型在测试集上评估
best_rf = grid_search.best_estimator_
y_pred_best = best_rf.predict(X_test_scaled)
y_pred_proba_best = best_rf.predict_proba(X_test_scaled)[:, 1]

best_metrics = evaluate_model(y_test, y_pred_best, y_pred_proba_best, model_name="调优后的随机森林")

# 绘制ROC曲线
from sklearn.metrics import roc_curve, auc

fpr, tpr, _ = roc_curve(y_test, y_pred_proba_best)
roc_auc = auc(fpr, tpr)

plt.figure(figsize=(10, 8))
plt.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC曲线 (AUC = {roc_auc:.4f})')
plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--', label='随机猜测')
plt.xlim([0.0, 1.0])
plt.ylim([0.0, 1.05])
plt.xlabel('假正率')
plt.ylabel('真正率')
plt.title('随机森林模型ROC曲线')
plt.legend(loc="lower right")
plt.grid(True, alpha=0.3)
plt.show()

第五步:模型集成 有时,结合多个模型的预测能获得比单个模型更好的性能。让我们尝试一个简单的投票集成:

from sklearn.ensemble import VotingClassifier

# 选择三个表现最好的模型
best_models = [
    ('rf', RandomForestClassifier(**grid_search.best_params_, random_state=42)),
    ('gb', GradientBoostingClassifier(n_estimators=200, max_depth=5, random_state=42)),
    ('lr', LogisticRegression(C=0.1, max_iter=1000, random_state=42))
]

# 创建投票分类器(软投票:基于概率平均)
voting_clf = VotingClassifier(estimators=best_models, voting='soft')

# 训练集成模型
voting_clf.fit(X_train_scaled, y_train)

# 评估
y_pred_voting = voting_clf.predict(X_test_scaled)
y_pred_proba_voting = voting_clf.predict_proba(X_test_scaled)[:, 1]

voting_metrics = evaluate_model(y_test, y_pred_voting, y_pred_proba_voting, model_name="投票集成模型")

# 比较所有模型的AUC
model_names = list(results.keys()) + ['调优随机森林', '投票集成']
auc_scores = [results[name].get('AUC', 0) for name in results.keys()] + [best_metrics['AUC'], voting_metrics['AUC']]

plt.figure(figsize=(12, 6))
bars = plt.bar(model_names, auc_scores, color=['skyblue' if 'AUC' in results[name] else 'lightcoral' for name in results.keys()] + ['lightgreen', 'orange'])
plt.axhline(y=0.5, color='red', linestyle='--', alpha=0.5, label='随机水平 (AUC=0.5)')
plt.xlabel('模型')
plt.ylabel('AUC分数')
plt.title('不同模型的AUC性能比较')
plt.ylim([0.4, 1.0])
plt.xticks(rotation=45, ha='right')
plt.legend()

# 在柱子上方添加数值标签
for bar, score in zip(bars, auc_scores):
    height = bar.get_height()
    plt.text(bar.get_x() + bar.get_width()/2., height + 0.01,
             f'{score:.3f}', ha='center', va='bottom', fontsize=10)

plt.tight_layout()
plt.show()

第六步:模型解释与业务洞察 一个高精度的黑箱模型对业务帮助有限。我们需要理解模型是如何做出预测的,哪些特征最重要,以及这些预测对业务意味着什么。

# 特征重要性分析(使用调优后的随机森林)
feature_importance = pd.DataFrame({
    'feature': selected_features,
    'importance': best_rf.feature_importances_
}).sort_values('importance', ascending=False)

plt.figure(figsize=(12, 8))
bars = plt.barh(feature_importance['feature'][:15], feature_importance['importance'][:15])
plt.xlabel('特征重要性')
plt.title('Top 15 特征重要性(随机森林)')
plt.gca().invert_yaxis()  # 最重要的特征在顶部

# 添加数值标签
for bar in bars:
    width = bar.get_width()
    plt.text(width + 0.001, bar.get_y() + bar.get_height()/2,
             f'{width:.3f}', ha='left', va='center', fontsize=10)

plt.tight_layout()
plt.show()

# 部分依赖图:查看单个特征如何影响预测
from sklearn.inspection import PartialDependenceDisplay

# 选择最重要的两个特征
top_features_names = feature_importance['feature'].head(2).tolist()
top_features_indices = [list(selected_features).index(f) for f in top_features_names]

fig, ax = plt.subplots(figsize=(12, 5))
PartialDependenceDisplay.from_estimator(
    best_rf, X_train_scaled, features=top_features_indices,
    feature_names=selected_features, ax=ax
)
plt.suptitle('部分依赖图:关键特征对预测概率的影响')
plt.tight_layout()
plt.show()

最后,让我们看看模型在哪些样本上犯错,这能揭示模型的局限性和改进方向:

# 分析错误预测
test_results = pd.DataFrame({
    '实际': y_test.values,
    '预测': y_pred_best,
    '预测概率': y_pred_proba_best
}, index=X_test.index)

# 添加原始特征以便分析
for feature in ['age', 'gender', 'annual_income', 'device_type_Mobile']:
    if feature in df_clean.columns:
        test_results[feature] = df_clean.loc[X_test.index, feature]
    elif feature in df_encoded.columns:
        test_results[feature] = df_encoded.loc[X_test.index, feature]

# 识别错误分类
test_results['正确'] = test_results['实际'] == test_results['预测']
test_results['错误类型'] = '正确'
test_results.loc[(test_results['实际'] == 1) & (test_results['预测'] == 0), '错误类型'] = '漏报(实际购买预测未购)'
test_results.loc[(test_results['实际'] == 0) & (test_results['预测'] == 1), '错误类型'] = '误报(实际未购预测购买)'

print("错误分类统计:")
print(test_results['错误类型'].value_counts())

# 分析漏报和误报样本的特征
error_analysis = test_results[test_results['错误类型'] != '正确'].groupby('错误类型').agg({
    'age': 'mean',
    'annual_income': 'mean',
    '预测概率': 'mean',
    '实际': 'count'
}).rename(columns={'实际': '数量'})

print("\n错误样本特征分析:")
print(error_analysis)

走到这里,你已经完成了一个完整的数据分析到建模的全流程。从最初杂乱的数据,到最终的预测模型,每一步都需要仔细思考和反复验证。我个人的经验是,在真实业务场景中,模型上线后的监控和迭代比初始开发更重要。数据会随时间变化,用户行为会演变,定期用新数据重新训练模型,才能保持预测的准确性。

记住,没有“最好”的模型,只有“最适合”当前业务需求和数据特性的模型。有时候,一个简单的逻辑回归加上精心设计的特征,可能比复杂的深度学习模型更实用、更可解释。关键在于理解你的数据,明确你的目标,然后选择合适的方法解决问题。

更多推荐