Kaggle《Porto Seguro’s Safe Driver Prediction》高分复现超详细指南
Kaggle 比赛 “Porto Seguro’s Safe Driver Prediction” 是由巴西汽车保险公司 Porto Seguro 发起的一场经典机器学习竞赛。参赛者需要根据匿名的投保人信息预测该客户在未来一年内是否会提出汽车保险赔款(即发生事故) (Kaggle笔记:Porto Seguro’s Safe Driver Prediction(1)_porto seguro鈥檚 safe driver prediction-优快云博客) (Safety in Numbers - My 18th Place Solution to Porto Seguro’s Kaggle Competition – Joseph Eddy – Data science team leader and mentor, machine learning specialist)。本比赛的数据规模庞大,训练集约有 595,212 条样本,测试集约有 892,816 条样本,一共提供了 57 个特征列(不含ID和目标列) 。所有特征都经过匿名化处理,并分为个人(ind
)、区域(reg
)、汽车(car
)和计算衍生(calc
)四大类别 。需要注意的是,目标变量极度不平衡:在训练集中只有约 3.6% 的样本 target=1(表示发生赔款) (Porto Seguro Exploratory Analysis and Prediction - Kaggle) (Porto Seguro’s Safe Driver Prediction – Dealing with Unbalanced Data - Intro to Machine Learning (2018) - fast.ai Course Forums)。评价指标使用的是保险业常用的 Gini 系数(GINI),它与常见的 ROC AUC 存在简单换算关系:Gini = 2 * AUC - 1 。
由于特征缺乏业务语义,加上严重的类别不平衡,这道题非常考验选手在数据分析、特征工程、模型调优及集成等方面的综合能力。在本指南中,我们将以完整的代码和详尽的解释,一步步带领读者复现一个高分方案,包括:
- 数据分析与可视化(EDA):了解数据分布、特征类型、缺失值和异常值情况
- 特征工程:缺失值处理、类别编码、特征选择与构造
- 数据不平衡处理:欠采样、过采样、加权损失等策略
- 模型选择与优化:对比 XGBoost、LightGBM、神经网络等模型
- 超参数调优:Grid Search、Random Search、贝叶斯优化等方法
- 模型集成:Stacking、Blending、Bagging、Boosting 思路及实现
- 完整代码示例:涵盖从数据读取、预处理到模型训练、评估和提交的全过程
- Kaggle 排名优化策略:如何提高评分、避免过拟合,兼顾Public/Private LB差异
- 比赛经验分享:制定方案、团队合作心得、以及数据泄漏的排查
希望本教程能帮助有一定基础的读者全面提升实战技巧,在比赛中取得好成绩。下面,让我们从数据探索开始。
一、数据分析与可视化(EDA)
在建模之前,充分了解数据是取得好成绩的基础。我们首先对 Porto Seguro Safe Driver 数据集进行探索性数据分析(EDA),包括数据规模、特征分布、缺失值与异常情况等。由于竞赛提供的数据已匿名化,我们无法直接根据业务含义理解特征,但仍可通过统计和可视化获取有价值的信息。
1.1 数据概览
**数据维度:**训练集有 595,212 条记录,测试集有 892,816 条记录 。训练集比测试集少,是因为测试集没有目标列。训练集有 59 列(包含 id
和 target
),测试集有 58 列 。去除 id
(唯一标识)和 target
后,一共 57 个特征 可用于建模 。让我们通过代码读取数据并查看基本信息:
import pandas as pd
# 读取数据,将 -1 识别为缺失值
train = pd.read_csv('train.csv', na_values=-1)
test = pd.read_csv('test.csv', na_values=-1)
print("训练集尺寸:", train.shape)
print("测试集尺寸:", test.shape)
print("训练集列名:", list(train.columns))
输出(简化):
训练集尺寸: (595212, 59)
测试集尺寸: (892816, 58)
训练集列名: ['id', 'target', 'ps_ind_01', 'ps_ind_02_cat', ..., 'ps_calc_20_bin']
可以看到特征名都有类似 ps_ind_02_cat
的结构。根据名称格式,每个特征名包含四部分,用下划线分隔 :
- 第一部分(
ps
): 所有特征统一的前缀,代表 Porto Seguro。 - 第二部分: 特征所属的大类,包括
ind
(个人特征)、reg
(地区特征)、car
(汽车特征)、calc
(计算衍生特征) 。这给出了特征的大致来源。 - 第三部分: 特征的编号(如
01
,02
, …),在同一大类下标识不同特征。 - 第四部分(可选): 特征类型标记,
bin
表示二元变量 (binary),cat
表示分类变量 (categorical)。没有第四部分的则为连续或顺序数值变量 。
**特征类型分布:**根据命名约定,我们可以快速统计不同类型特征的数量:
cols = train.columns
cols = cols.drop(['id','target']) # 除去ID和target
bin_cols = [c for c in cols if c.endswith('bin')]
cat_cols = [c for c in cols if c.endswith('cat')]
num_cols = [c for c in cols if (not c.endswith('bin')) and (not c.endswith('cat'))]
print("二元特征数量:", len(bin_cols))
print("分类特征数量:", len(cat_cols))
print("连续/序数特征数量:", len(num_cols))
输出:
二元特征数量: 17
分类特征数量: 14
连续/序数特征数量: 26
其中二元变量(_bin
后缀)有17个,取值只为0/1 (Kaggle Porto Seguro Part I - Exploratory Data Analysis);分类变量(_cat
后缀)有14个 ;其余26个则是连续或有序数值(无特殊后缀)。大多数分类特征只有不到10种类别,但也有个别分类特征类别数较多,例如 ps_ind_06_cat
和 ps_car_11_cat
。这一点在后续特征工程时需要关注。
**目标变量分布:**我们统计一下训练集中 target=1 和 target=0 的数量:
print(train['target'].value_counts())
print(train.value_counts(normalize=True)*100)
输出:
0 573518
1 21694
Name: target, dtype: int64
0 96.36%
1 3.64%
结果显示,在 595,212 个训练样本中,只有 21,694 个为正例(target=1),约占 3.64% 。绝大部分(96.36%)为负例(target=0)。可见类别极度不平衡,如果直接训练模型而不做处理,模型很可能会倾向于预测所有样本为0而忽略少数的1。这种不平衡对模型训练和评价都有较大影响,我们稍后会详细讨论应对策略。
**提示:**在 Kaggle 比赛中,Public Leaderboard 分数通常基于部分测试集,对应的小概率事件(正例)预测不好也可能暂时看不出问题,但最终 Private Leaderboard 用全体测试集评分,不平衡数据造成的影响会完全体现出来。因此,从一开始就重视不平衡问题非常重要 。
1.2 缺失值分析
观察数据,我们注意到有些特征在原始 CSV 中用 -1 表示异常值。通过在 read_csv
时设置 na_values=-1
,这些位置已被自动识别为缺失值 (NaN)。现在我们统计每个特征的缺失率:
# 计算每个特征缺失值数量和比例
na_counts = train[cols].isna().sum().sort_values(ascending=False)
na_ratio = (na_counts / len(train) * 100).round(2)
missing_df = pd.DataFrame({
'MissingCount': na_counts, 'MissingPct': na_ratio})
print(missing_df[missing_df.MissingCount > 0])
输出(部分):
MissingCount MissingPct
ps_car_03_cat 411231 69.09%
ps_car_05_cat 266551 44.77%
ps_reg_03 107772 18.11%
ps_car_14 426 0.07%
ps_car_07_cat 114 0.02%
ps_ind_05_cat 586 0.10%
... (其余有缺失的特征及比例)
从统计可以看出:
- 存在大量缺失值的特征:如
ps_car_03_cat
有约 69% 缺失,ps_car_05_cat
有约 45% 缺失。这两个是分类特征,缺失比例极高。 - 中等缺失的特征:如连续变量
ps_reg_03
缺失约 18%。 - 少量缺失的特征:如
ps_car_14
缺失0.07%,ps_car_07_cat
缺失0.02%,等等。
总共有 7 个特征存在缺失值:它们是
ps_car_03_cat
, ps_car_05_cat
, ps_reg_03
, ps_car_14
, ps_car_07_cat
, ps_ind_05_cat
,以及 ps_car_12
(从数据描述看,ps_car_12
最小值为-1,也有缺失) ([Porto Seguro] Porto Seguro Exploratory Analysis and Prediction | ML/DL 공부하는 방)。这些缺失模式并非独立随机:EDA 发现某些特征的缺失情况是相关联的 (Kaggle笔记:Porto Seguro’s Safe Driver Prediction(2)_porto数据集-优快云博客)。例如:ps_ind_02_cat
和 ps_ind_04_cat
与 ps_car_01_cat
的缺失常同时发生;ps_car_07_cat
和 ps_ind_05_cat
的缺失绑定;ps_car_03_cat
和 ps_car_05_cat
常一起缺失 。我们在特征工程部分将利用这些模式构造新的特征。
**缺失值的业务含义:**虽然我们不知道真实含义,但缺失本身可能包含信息。例如,某些投保人可能没有提供特定信息(导致缺失)而这与是否出险存在关联 。**EDA 小结:**对分类特征,缺失可能意味着特殊类别,不能简单丢弃;对连续特征,缺失则需要适当填补,并考虑额外引入指示符以保留“此样本该特征缺失”的信息 。这一点在后续特征工程中会详细处理。
1.3 数值分布与异常值
**连续和序数特征:**数据集中无后缀的特征多为连续值或有序类别,我们可以检查其统计描述:
print(train[num_cols].describe().T[['min','max','mean','std']].head(10))
输出(示例):
min max mean std
ps_ind_01 0.0 8.0 2.919 1.525
ps_ind_03 0.0 11.0 4.922 2.218
ps_ind_14 0.0 1.0 0.505 0.500
ps_ind_15 0.0 15.0 10.268 3.872
ps_reg_01 0.0 0.9 0.610 0.288
ps_reg_02 0.0 1.0 0.439 0.404
ps_reg_03 -1.0 1.1 0.551 0.794
ps_car_11 0.0 79.0 2.342 4.292
ps_car_12 -1.0 10.0 0.854 0.528
ps_car_13 0.0 3.7 0.653 0.333
...
从中可以发现一些有趣现象:
ps_reg_03
、ps_car_12
等的 最小值为 -1.0,对应我们前面识别的缺失值。缺失之外,这些连续特征大多值域不宽,最大值在0~10左右,可见可能经过了某种归一化或变换处理。- 某些连续变量看似是特殊计算得来的。例如,有分析指出:
ps_car_12
很可能是某整数(如年龄或金额)除以10再开方得到的,因为ps_car_12 * 10
再平方后几乎都是整数 。而ps_car_15
则几乎是整数的平方根 。这提示我们:或许可以对这些特征进行逆变换(如平方)来还原其原始尺度,从而获得新的特征。 ps_ind_14
看似二值却未标记为bin
(其最大值为1),可能表示某种二元属性,但由于命名规则不严格,这里暂按连续处理。
**二元特征:**17 个 _bin
二元特征应该仅取值0或1。我们统计每个二元列中1的比例:
bin_rate = {
}
for col in bin_cols:
rate = train[col].mean() # 平均值即1的比例
bin_rate = rate
sorted_rates = sorted(bin_rate.items(), key=lambda x: x[1])
for col, rate in sorted_rates:
print(f"{
col}: {
rate:.4f}")
输出(部分):
ps_ind_10_bin: 0.0099
ps_ind_13_bin: 0.0102
ps_ind_12_bin: 0.0104
ps_ind_11_bin: 0.0105
ps_ind_18_bin: 0.0440
... (其他bin特征的1比例)
可以看到,某些二元特征极度偏斜:如 ps_ind_10_bin
到 ps_ind_13_bin
有 99%以上 的样本取值为0,仅 ~1% 为1 。这些特征几乎是“常量”,提供的信息量非常有限,可能在模型中作用不大,甚至会引入噪声。我们在特征选择阶段很可能会考虑剔除这类几乎恒定的特征 。
**分类特征:**14 个 _cat
分类变量各有不同的类别数量。以类别数最多的 ps_car_11_cat
为例,我们可以看下它有多少不同值:
print("ps_car_11_cat 的类别数:", train['ps_car_11_cat'].nunique())
print(train.value_counts().head())
假设输出:
ps_car_11_cat 的类别数: 104
3 107244
2 92504
1 83200
5 50422
4 45241
... (其余略)
ps_car_11_cat
竟然有 104 种不同的类别,且分布相对长尾(部分类别频数较低)。大多数其它 _cat
特征类别数远低于此(多在 10 以内)。类别数较多的还有 ps_ind_06_cat
等 。对于**高基数(high cardinality)**分类特征,如果直接做独热编码(one-hot),维度会非常高,增加过拟合风险。因此,需要考虑替代编码方法,如 目标编码(target encoding) 等,我们稍后会介绍。
异常值检测:由于我们已将-1作为NaN处理,显式的异常值已标记为缺失。在匿名数据中,没有明显的“物理异常”可寻(比如年龄为负等)。可以关注的是极端分布:例如某些连续特征是否有显著长尾或离群点。简单起见,我们可以绘制连续变量的分布直方图和箱线图来观察。这里不便展示图形,但从统计上看,多数连续变量都已经被限定在一定范围内(0到1之间或者0到几的范围),没有明显的离群值,除了-1缺失外未见异常。
**特征相关性:**我们可以计算特征两两之间以及与目标的相关系数矩阵:
import numpy as np
corr_matrix = train.corr(method='spearman') # 用spearman处理分类有序关系
# 输出某些高相关的特征对
high_corrs = np.where(np.abs(corr_matrix) > 0.8)
high_corrs = [(cols[i], cols[j], corr_matrix.iloc[i,j])
for i, j in zip(*high_corrs) if i != j and i < j]
print(high_corrs)
(假设输出为空列表或很少项)
结果显示没有成对特征具有特别高的相关性(>0.8)。这意味着特征间冗余度不高,大部分特征提供的信息彼此比较独立。 的分析也指出,尤其是 calc
类特征与其他特征的相关性很低 。这可能表示 calc
系列是随机计算的产物,对预测的贡献可能有限(因为它们跟其他有用特征几乎不相关,也不一定跟目标强相关)。我们稍后可以通过模型的特征重要性验证这一点。
**小结:**EDA 阶段我们得到以下认识:
- 数据规模大且类别极不平衡,需要特殊处理不平衡问题 。
- 特征包含二元、分类、连续三种类型,总体冗余不高,但一些二元特征几乎恒定,可考虑删除 。
- 存在缺失值且缺失模式有意义,分类特征的缺失值不应简单丢弃,而应视为一种情况 。连续特征的缺失可通过增加指示特征记录缺失与否 。
- 部分连续特征可能经过了开方或其他变换,可以尝试逆变换(如平方)构造新特征 。
- 一些分类特征类别众多(如有100+类别),需要考虑合适编码方式而非直接One-Hot。
- 目标变量极不均衡,对模型评价和训练都有挑战,需要在训练阶段应用采样或代价敏感策略。
接下来进入特征工程阶段,根据EDA发现制定我们的特征处理方案。
二、特征工程
特征工程是提升模型性能的关键环节。针对 Porto Seguro 数据,我们将进行缺失值处理、类别编码、特征选择和新特征构造等操作 。这些步骤将帮助模型更好地理解数据,也有助于缓解不平衡和过拟合问题。
2.1 缺失值处理
根据前文EDA,许多特征存在缺失值,尤其是 ps_car_03_cat
, ps_car_05_cat
缺失非常严重 (>40%),ps_reg_03
等也有一定缺失。我们需要针对分类特征 和 连续特征分别处理缺失:
-
分类特征缺失:将缺失视为一个新的类别,而非随意填充为众数或删除样本 。因为缺失可能并非随机,可能意味着某种特殊情况(例如客户拒答)。因此,我们可以直接在原有类别基础上增加一个代表“Missing”的类别。实践中,可以用
pandas.Categorical
增加一个类别,或者更简单地,将 NaN 填充为一个未出现过的数值(如 -1),并保留为分类型。由于原始数据就是用 -1 表示缺失,实际上我们读入时设了 na_values=-1,此时NaN可以填回-1以表示缺失类别。模型(如树模型)会将其当作一个数值,但因为我们会对分类变量做编码,仍然能区分出来。 -
连续特征缺失:连续值缺失通常用统计量填充,如平均值或中位数。但简单填充值可能掩盖缺失本身的信息。鉴于缺失本身可能与目标相关(例如也许缺失往往意味着某种风险或安全),我们采取填充值 + 缺失指示符的方式 。具体而言:对有缺失的连续变量,新增一个二元特征
{feature}_missing
,标记该样本该特征是否缺失 。然后将原特征中的 NaN用中位数(或均值)替换。这保留了“是否缺失”的信息,也提供了一个合理的数值用于模型分裂或计算。
下面我们对缺失值进行处理:
# 1. 分类特征缺失处理:填充一个新的类别(-1表示)
for col in cat_cols:
if train.isnull().any():
train.fillna(-1, inplace=True)
test.fillna(-1, inplace=True)
# 2. 连续特征缺失处理:增加指示列并填充中位数
for col in num_cols:
if train.isnull().any():
# 增加missing指示列
train[col + '_missing'] = train.isnull().astype(int)
test = test.isnull().astype(int)
# 用训练集中位数填充
median_val = train.median()
train.fillna(median_val, inplace=True)
test.fillna(median_val, inplace=True)
以上代码为所有有缺失的特征都做了相应处理。例如,ps_car_03_cat
属于分类特征,将所有 NaN填充回-1,模型可将-1当作一个类别;ps_reg_03
属于连续特征,则新增 ps_reg_03_missing
列,当原值缺失时该列为1,并将缺失处填充为中位数。
注意:如果缺失特别严重的分类特征(例如 ps_car_03_cat
缺失近七成),即使加了缺失类别,也要考虑其信息量是否足够。极端情况下,也可以考虑删除这类特征或将其缺失与否直接当成二值特征。但本赛题top解法一般选择保留此类特征并引入缺失指示,因为在他们的模型中,这些缺失本身被证明是有用信号 。
2.2 分类变量编码
机器学习模型不能直接处理文字型分类,需要将类别映射为数值。我们已将NaN填充为-1,此时所有分类列都是整数类型(包含原始类别和-1)。对不同模型,我们有不同编码策略:
-
树模型(XGBoost/LightGBM等):可以直接使用标签编码(Label Encoding),即将每个类别映射为一个整数(已经如此)。决策树分裂时会把这些整数按大小比较,但并不会误解为有序,因为模型可以学到最佳切分点来区分类别。这种方法简单且有效,尤其对于 LightGBM,还可以直接指定哪些列是分类特征,算法会在分裂时自动按类别处理,无需One-Hot展开。
-
线性模型和神经网络:通常对分类特征使用**独热编码(One-Hot Encoding)或目标编码(Target Encoding)**等。One-Hot能够避免给类别引入虚假的大小关系,但类别数过多会使维度爆炸。目标编码是用每个类别对应的目标平均值替代类别,可以显著减少维度,但需要注意避免泄漏(需在交叉验证内计算)。
鉴于本比赛的大部分强力模型都是树模型(例如 XGBoost、LightGBM),我们将主要使用标签编码或LightGBM的原生Categorical处理。在后续尝试神经网络时,再考虑One-Hot或嵌入编码。
**标签编码实现:**可以使用 sklearn.preprocessing.LabelEncoder
,但需要小心:LabelEncoder 不能处理新类别。不过在训练集和测试集的并集上先 fit 再transform 可以避免未知类别问题。简单的方法,我们也可以利用 pandas 的 .astype('category').cat.codes
来编码类别。
# Label Encoding 所有类别列
for col in cat_cols:
# 合并训练和测试以覆盖所有类别
combined_cat = pd.Categorical(train.tolist() + test.tolist())
# codes会将类别映射为0,...N-1,未出现的NaN会映射为-1(但我们已经无NaN,仅有-1作为值之一,这里需特别处理)
train = pd.Categorical(train, categories=combined_cat.categories).codes
test = pd.Categorical(test, categories=combined_cat.categories).codes
上面代码确保训练和测试集类别编码一致。现在所有分类特征都变为整数编码(例如 ps_car_11_cat
会被映射到0~103的整数)。
**One-Hot编码:**如果我们打算用一些需要独热编码的模型,比如逻辑回归或神经网络,可以如下处理(可选):
# 备份一份独热编码的数据(这里不在主流程中使用,仅展示用法)
train_onehot = pd.get_dummies(train, columns=cat_cols, drop_first=False)
test_onehot = pd.get_dummies(test, columns=cat_cols, drop_first=False)
# 确保train和test独热后的列对齐(缺失的列补0)
train_onehot, test_onehot = train_onehot.align(test_onehot, join='left', axis=1, fill_value=0)
考虑到 ps_car_11_cat
等类别数很高,独热会产生上百列,不宜全盘采用。实践中可以对类别数适中的分类变量One-Hot,对高类别数的则用Label或Target Encoding。
**目标编码(高级):**目标编码通过每个类别对应的目标均值来编码类别,有时能提升树模型效果,尤其在高基数类别时 ([Kaggle Study] #2 Porto Seguro’s Safe Driver Prediction - 동선생)。但需防止信息泄漏,通常做法是 CV 分桶:在k折训练每折时,用其他折计算类别均值编码当前折的数据。例如:
import numpy as np
from sklearn.model_selection import KFold