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-CSDN博客) (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数据集-CSDN博客)。例如: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
# 示例:对 ps_car_11_cat 做目标编码
kfold = KFold(n_splits=5, shuffle=True, random_state=42)
train['ps_car_11_cat_te'] = 0
test = 0
for train_idx, val_idx in kfold.split(train):
# 用每次折的训练部分计算编码映射
mean_target = train.iloc[train_idx].groupby('ps_car_11_cat').mean()
# 应用到验证部分
train.iloc[val_idx, train.columns.get_loc('ps_car_11_cat_te')] = train.iloc[val_idx].map(mean_target)
# 同时根据整套训练映射计算测试均值(不同折求平均)
test += test.map(mean_target)
# 测试集取5折均值
test /= 5
如上,我们创建了新特征 ps_car_11_cat_te
存放编码。注意:这只是示例,真实还需平滑、加权等技巧(如根据类别频次调整)。目标编码如果正确应用,往往能带来提升,但实现复杂且容易泄漏,这里点到为止。
目前,我们已基本完成对缺失值和分类变量的处理,接下来进行初步的特征筛选和构造。
2.3 特征选择
在拥有几十个特征的数据集中,特征选择可以去除无效或冗余的特征,从而降低过拟合风险,提高训练速度。根据EDA结果,我们考虑以下策略进行特征筛选:
-
去除近似常量特征:如前述,
ps_ind_10_bin
、ps_ind_11_bin
、ps_ind_12_bin
、ps_ind_13_bin
这些二元变量 99%以上 都是0 。它们几乎不提供区分信息,可以删除。 -
去除重复特征:虽然按名称看没有重复,但有时不同特征可能取值完全相同(这里unlikely),可检查如任何两列的相关系数是否为1或值是否完全一样。我们可验证但估计不会有。
-
去除强相关特征:若两个特征高度相关(比如相关系数>0.98),则它们提供的几乎是重复信息,可删除其一。但从前面的相关矩阵看并无此类情况,故本数据不需要这一步。
-
基于模型的重要性:训练一个初步模型(比如随机森林或决策树)看各特征的重要性,将重要性几乎为0的特征去除。这种方法能发现对目标无贡献的特征。不过因为boosting模型自带正则,我们也可以不显式筛选,交由模型调参过程处理。
-
专业经验:结合外部信息或比赛讨论。例如有选手发现
ps_calc_**
系列特征对成绩贡献较小,可以选择性剔除一部分以减少噪音。我们可以验证这些特征的重要性再决定。
综合考虑,我们至少删除前述4个近乎恒定的二元特征。另外,ps_calc_10
至 ps_calc_14
这些计算特征若被证明效果差,也可移除或在模型中降低权重。先做基础的删除操作:
# 删除近乎常量的bin特征
drop_cols = ['ps_ind_10_bin','ps_ind_11_bin','ps_ind_12_bin','ps_ind_13_bin']
train.drop(columns=drop_cols, inplace=True)
test.drop(columns=drop_cols, inplace=True)
bin_cols = [c for c in bin_cols if c not in drop_cols] # 更新bin_cols列表
这样我们删除了4列。在这个比赛中,许多top方案也提到适度删减低信息量特征可以稍微提升模型的稳定性 。
如果希望进一步筛选,可用一个简单XGBoost模型查看特征重要性。例如:
from xgboost import XGBClassifier
X = train.drop(, axis=1)
y = train
xgb = XGBClassifier(n_estimators=100, random_state=42, n_jobs=-1, eval_metric='auc')
xgb.fit(X, y)
imp = pd.Series(xgb.feature_importances_, index=X.columns).sort_values(ascending=False)
print(imp.tail(10))
假设输出最后若干特征的重要性靠近0,那便是候选删除对象。但为了高分,我们一般宁可保留潜在有用的特征,然后通过正则化降低它们的影响,而不冒险删掉可能微弱有用的信息。因此除非非常确定无用(如上面那4个),其余特征我们暂保留。
2.4 特征构造
有了清洗和筛选后的基础特征,我们还可以运用创造力,构造新特征来提供模型额外的信息。结合我们的数据特点,考虑以下几类新特征:
**(1) 利用缺失值模式构造特征:**先前EDA发现了几组特征的缺失情况彼此关联 。我们可以将这些组的缺失情况统计为特征:比如每个样本在 (ps_ind_02_cat
, ps_ind_04_cat
, ps_car_01_cat
) 这三列中缺失值的总数 。一共有三组关联,我们可构造 MissingCount_Group1
, Group2
, Group3
。如果缺失就是以 -1 填充,那么统计 -1 的个数即可。
# 基于缺失模式的组合缺失计数特征
train['missing_group1'] = ((train['ps_ind_02_cat'] == -1).astype(int)
+ (train['ps_ind_04_cat'] == -1).astype(int)
+ (train['ps_car_01_cat'] == -1).astype(int))
train['missing_group2'] = ((train['ps_car_07_cat'] == -1).astype(int)
+ (train['ps_ind_05_cat'] == -1).astype(int))
train['missing_group3'] = ((train['ps_car_03_cat'] == -1).astype(int)
+ (train['ps_car_05_cat'] == -1).astype(int))
# 测试集同样处理
test = ((test == -1).astype(int)
+ (test == -1).astype(int)
+ (test == -1).astype(int))
test = ((test == -1).astype(int)
+ (test == -1).astype(int))
test = ((test == -1).astype(int)
+ (test == -1).astype(int))
上述 missing_group1
取值可能是0、1、2、3,表示三列中缺失了几个。直觉上,如果某组特征一起缺失可能表明某种共同原因,这对预测有帮助。事实上,在比赛中有人验证这些特征与目标有一定关系,例如某组缺失数为2或3可能对应更高或更低的出险概率。
除了分组计数,我们也可以简单计算每个样本所有特征的缺失总数。不过由于我们已单独标记缺失和填充,这一特征可能与之前的missing指示有冗余,但也无妨加入:
train['missing_total'] = train.isin([-1]).sum(axis=1)
test = test.isin.sum(axis=1)
(2) 二元特征计数:有些样本可能在多个二元特征上都为1,可能反映某类风险积累。我们可以将所有二元变量求和,得到每个样本“1”的个数。这类似于一种简单的交叉特征(虽然不指明哪些位是1,但统计了总数)。
train['bin_sum'] = train[bin_cols].sum(axis=1)
test = test.sum(axis=1)
如果二元变量代表某种标记,则 bin_sum
高的用户拥有更多正标记,可能风险较高或较低,要交给模型去学。【补充】我们也可以针对特定子集,比如把 ps_ind_*_bin
子集和 ps_calc_*_bin
子集分开计数,视情况而定。
(3) 连续特征组合:为了捕捉特征间的交互信息,可以尝试构造一些连续变量之间的组合特征,例如乘积、比值、差值等。由于没有明确业务逻辑指导,我们可以从重要性最高的特征入手组合。据 Kaggle 社区分析,ps_car_13
和 ps_reg_03
是两个非常重要的数值特征 (Car Insurance Claim Prediction - Kaggle)。我们可以构造它们的乘积或比值作为新特征,看看能否提升模型判别力。另外,先前推测 ps_car_12
和 ps_car_15
是平方根形式,我们可以把它们平方以得到原值近似。例如:
# 高重要性连续特征交互
train['reg03_x_car13'] = train['ps_reg_03'] * train['ps_car_13']
test = test * test
# 试验平方还原特征
train['ps_car_12_sq'] = train['ps_car_12']**2
train['ps_car_15_sq'] = train['ps_car_15']**2
test = test**2
test = test**2
这里我们增加了 reg03_x_car13
(等于ps_reg_03 * ps_car_13
),以及平方后的 ps_car_12_sq
, ps_car_15_sq
。这样做的动机是:若 ps_car_12 = sqrt(X)/10
,则平方乘10后大致还原为原始值X,可以让模型直接学习更线性的关系 。
当然,还有许多可能的组合:例如连续特征之间的比例、差、或不同类别特征的交叉。如将某些 bin
标记和连续变量相乘,等等。由于组合空间巨大,我们需要基于经验和验证选择有用的组合。在实际比赛中,一些Top选手也使用了特征二次多项式或特征交叉的方法来扩充特征空间,不过要注意控制数量防止过拟合。
(4) 分组统计特征:考虑对类别特征按某种群体求数值特征的统计量,例如每个 ps_car_11_cat
类别对应的平均 ps_car_13
值,作为新的特征。这实际上类似于目标编码,只不过用的是特征本身。鉴于时间,我们不深入展开,但这是特征工程常用技巧之一,叫做 平滑的跨特征统计(如均值编码、频次编码等)。例如频次编码已经隐含在Label Encoding里(数值大小不代表频次,但我们可以另外加一列记录类别频率)。
综上,我们挑选了几项来实施。将所有新特征加入后,我们再次汇总现在的特征集大小:
print("新增特征后训练集维度:", train.shape)
确保特征数量增加且训练集长度不变。此时,数据预处理与特征工程就绪,可以进入模型训练阶段了。
三、处理类别不平衡的方法
如前所述,本比赛一个显著难点是正负样本极度不平衡:仅约3.6%为正例 。不平衡数据会导致模型训练时偏向多数类,使少数类难以预测。我们需要在训练策略上做出调整,以强调正例的重要性。常用的应对方法有:
-
欠采样 (Undersampling):随机删除一部分多数类样本,从而平衡正负比例 (Random Oversampling and Undersampling for Imbalanced Classification - MachineLearningMastery.com)。例如把负样本随机删减到和正样本数量相当。优点是缓解模型被多数类淹没的问题,训练速度也加快;缺点是丢失了大量信息,可能降低模型上限 。在本比赛,有人尝试过下采样并训练随机森林 。
-
过采样 (Oversampling):复制或合成少数类样本,使其数量增加到接近多数类 。最简单的是随机复制正例,但可能导致过拟合,因为模型可能记忆重复的样本 。更先进的方法是 SMOTE(合成少数过采样)等,通过插值生成新的正例 (Porto Seguro: balancing samples in mini-batches with Keras) (Porto Seguro’s Safe Driver Prediction – Dealing with Unbalanced Data)。过采样可充分利用少数类信息,但生成的合成样本质量不易保证,且增加训练量。
-
调整样本权重 (Cost-Sensitive Learning):在不改变数据的情况下,提高少数类样本的损失权重,使模型在训练时更重视这些样本的分类准确。例如在损失函数中对正例乘以较大的权重,或者在模型中指定
class_weight
。很多算法直接提供参数:如XGBoost
有scale_pos_weight
,sklearn
的分类器有class_weight='balanced'
等。权重调节不会损失信息,是一种优雅的方法,但需要找到合适的权重比例。
这些方法可以单独使用,也可以结合。例如先欠采样再过采样(对不同数据块),或者轻微欠采样配合权重。需要根据验证结果调整。下面我们具体实践:
3.1 欠采样
我们尝试构建一个欠采样的数据集供某些模型训练。例如,我们把负样本按一定比例随机抽取,使得正:负比例从1:26提高到1:3左右。
from sklearn.utils import resample
# 将训练数据按照target拆分
df_majority = train[train.target == 0]
df_minority = train[train.target == 1]
# 下采样 majority 类使其数量为少数类的3倍(比例1:3)
desired_ratio = 3
n_majority = len(df_minority) * desired_ratio
df_majority_downsampled = resample(df_majority, replace=False,
n_samples=n_majority, random_state=42)
# 合并新的训练集
train_downsampled = pd.concat([df_minority, df_majority_downsampled])
print("下采样后新训练集正例比例: {:.2%}".format(train_downsampled.target.mean()))
输出:
下采样后新训练集正例比例: 25.00%
现在新数据集正例占25%,比例大大改善。我们可以用这个下采样集训练模型。由于减少了许多负例,模型训练速度也会更快。但要注意,我们不能在验证/测试时也用下采样,评估仍需在原始分布上进行,否则指标会失真。所以常用做法是对训练集下采样,验证集保持完整。在预测阶段,为了得到概率,我们仍然在完整数据上预测。如果模型输出概率需要调整,可以基于贝叶斯理论做校准,但对于AUC/Gini评估,其实无须特别校准,因为AUC与概率的单调性有关,不受绝对阈值影响。
3.2 过采样
过采样相比欠采样更复杂些。最简单的过采样就是重复正例。我们可以尝试把正例复制若干倍拼接到训练集中。不过这种方法容易使模型过度拟合那些重复样本。更好的方法是使用 SMOTE 等算法(需要 imblearn
库)。这里简单演示重复过采样:
# 简单过采样:重复少数类
desired_ratio = 0.5 # 希望正例占50%
n_minority = int(len(df_majority) * desired_ratio / (1 - desired_ratio))
df_minority_upsampled = resample(df_minority, replace=True,
n_samples=n_minority, random_state=42)
train_upsampled = pd.concat([df_majority, df_minority_upsampled])
print("过采样后新训练集正例比例: {:.2%}".format(train_upsampled.target.mean()))
输出:
过采样后新训练集正例比例: 50.00%
现在正负均衡。但要提醒,这种复制正例的策略在训练集大时效益有限,而且重复数据可能被模型记忆。更好的 SMOTE 会根据正例特征附近插值生成合成样本 。有兴趣的读者可使用 imblearn.over_sampling.SMOTE
实现,在此不详细展开。
3.3 加权损失
调整样本权重是非常有效且常用的方法,因为它不用改变数据分布,保持了全部信息。很多模型框架支持类权重:
-
XGBoost/LightGBM:可以在参数中设置
scale_pos_weight = (负样本数/正样本数)
。对于本训练集,负样本数≈573518,正样本≈21694,比例约26.45:1,所以设置scale_pos_weight=26
左右比较合理(可微调)。这会让模型在计算损失时,相当于正样本的梯度被乘以26,增强它们的影响。XGBoost官方建议scale_pos_weight
设为这个比例值作为起点。 -
sklearn系模型:如
LogisticRegression
,RandomForestClassifier
等,可以传入class_weight='balanced'
,它会自动按照反比于类频率的权重给样本赋权 。对于3.64%的正例,它会给正例约26.4倍于负例的权重,等价于上述比值。我们也可以手动传class_weight={0:1, 1:26}
等。 -
神经网络:在使用 Keras/TF 等框架时,可以在
.fit
中指定class_weight
字典或提供每个样本的权重数组。这样在计算loss(如binary_crossentropy)时,正例的loss会被乘以更大的系数,从而梯度更大。
实践:我们以 XGBoost 为例,在初始化模型时应用 scale_pos_weight
:
ratio = (train.target == 0).sum() / (train.target == 1).sum()
xgb = XGBClassifier(scale_pos_weight=ratio,
n_estimators=100, max_depth=5, learning_rate=0.1,
objective='binary:logistic', eval_metric='auc', use_label_encoder=False)
如上,ratio
大概是26.45,此时 XGBoost 将更加关注正例。**Jeremy Howard(fast.ai创始人)**也建议对随机森林之类简单模型,最方便的处理不平衡办法就是加 class_weight="balanced"
。
需要注意:使用类权重后,模型输出的概率不再是未调权情况下的真实概率,需要反偏置。比如逻辑回归加权后输出的概率会偏高,需要校准。但是对于 AUC 来说,不需要管概率校准,只要排序好就行。而对于阈值敏感的情况(比如你真的要分类),可能要根据预期正负比例调整阈值或做后处理。Kaggle此题以排名指标Gini(相当于AUC)为准,不用担心概率校准,只需保证排序好即可。
3.4 综合策略
可以将上述方法结合使用。例如,一种折中做法是适度欠采样再配合样本权重。欠采样过头会损失太多多数类信息,但不采样又学习困难,我们可以在两者中取平衡。比如将负样本减少一半,同时仍给予正例一定权重。这需要在验证集上多试验找到最佳组合。
在本次比赛中,许多top选手实际上并未采用过采样,而是依赖于树模型自带的参数以及集成来处理不平衡。他们会调节 scale_pos_weight
,并且更多地关注优化AUC而非准确率。这提醒我们:选择正确的评价指标和相应的优化方式可以部分缓解不平衡困扰。例如AUC天然对不平衡鲁棒,因为它不受整体正负比例影响(它只看排序)。
小结:对于不平衡问题,没有一刀切的最佳方案,需要结合模型和数据调试。下采样提供一个简单快速的路径,可以在EDA和调参初期使用 ;上采样/SMOTE可能需要更复杂的验证;加权是推荐的通用方案,特别对树模型,设置正确的 scale_pos_weight
往往能取得不错效果。
在后续模型训练中,我们会对不同模型分别考虑这些策略。例如,对XGBoost我们会使用权重,对神经网络或许会采用过采样+权重的混合方案。现在,我们准备进入模型选择与优化阶段。
四、模型选择与优化
有了处理好的特征和数据,我们可以尝试多种模型来拟合预测。本次比赛属于二分类问题(是否出险),常用的模型选择包括:
-
基于决策树的集成方法:如 XGBoost、LightGBM、CatBoost、Random Forest 等。其中 XGBoost 和 LightGBM 是 Kaggle 比赛的常青树算法,在表格数据上表现优秀。特别是 LightGBM 在大数据集上速度更快,效果也不错。很多参赛者会使用这类模型作为主力 (kaggle-Porto Seguro’s Safe Driver Prediction_porto seguro鈥檚 safe driver prediction-CSDN博客)。
-
神经网络:深度学习模型可以通过多层网络自动拟合特征交互,对于大数据也有一定潜力。不过在纯表格数据竞赛中,单一神经网络通常不如树模型,但与树模型融合往往能提高成绩。2nd名方案就结合了神经网络和GBM模型 (GitHub - xiaozhouwang/kaggle-porto-seguro: 2nd Place Solution for Kaggle Porto Seguro’s Safe Driver Prediction)。
-
线性模型:如逻辑回归(Logistic Regression)。对于高度非线性的匿名特征,这类模型效果有限,通常作为baseline或在stacking中作为次级学习器使用(例如作为元模型) 。
-
其他:例如支持向量机(SVM)、朴素贝叶斯等。在几十万样本的情况下SVM等不太实用;一些参赛者还尝试过更复杂的模型如 Field-aware Factorization Machines (FFM)、Regularized Greedy Forest (RGF) 等 ,这些属于进阶技术,如果有兴趣可以研究。
本节我们重点介绍 XGBoost、LightGBM 和 神经网络 的使用和调优技巧,并简述其他方法。最后,我们会考虑将多模型进行集成。
4.1 XGBoost 模型
XGBoost(Extreme Gradient Boosting)是实现梯度提升树的经典库 。它通过迭代训练弱学习器(CART树)并累加提升,使模型不断逼近真实值。XGBoost 在Kaggle上大放异彩,因为其预测精度高、灵活性强,可以处理缺失值、自定义目标函数等。
在本问题中,XGBoost的优势是能自动处理部分缺失(我们也提供了缺失指示)、不需要特征标准化、对类别变量用整数编码即可。缺点是在接近百万样本、数百特征时,训练速度相对较慢(相比LightGBM),但可以通过减少树深度、使用早停等方式加速。
参数设置:训练一个XGBoost需要调很多超参数,包括:
n_estimators
:树的数量(提升迭代次数)。太大会过拟合,太小会欠拟合。常和learning_rate
配合调,小学习率需要更多树。max_depth
:树最大深度。控制模型复杂度,通常在3-10之间为宜,深度越大越容易过拟合。min_child_weight
:节点分裂所需最小样本权重和,值大则限制分裂,防止过拟合。subsample
和colsample_bytree
:分别控制随机采样的行比例和列比例(特征子采样),用于减少过拟合。典型值0.5~0.9之间。gamma
:分裂节点的最小损失减少要求,值大则更保守分裂。lambda
和alpha
:L2、L1正则项系数,可防止叶子权重过大过拟合。scale_pos_weight
:不平衡权重,前面讨论过,设为26左右 。
还有一些参数如 tree_method
(树构建算法)、eval_metric
(评估指标)等。一般我们用默认的 tree_method=auto
(CPU下会选用贪心算法),eval_metric="auc"
来跟踪性能。
训练与验证:我们使用先前处理好的数据(注意如果用了下采样或其他,需要指定)。这里先不下采样,直接利用类权重。我们做一个简单的 80/20 划分本地验证,以评估模型AUC:
from sklearn.model_selection import train_test_split
from xgboost import XGBClassifier
from sklearn.metrics import roc_auc_score
# 使用未欠采样的数据,带权重训练
X = train.drop(, axis=1)
y = train
X_tr, X_val, y_tr, y_val = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
ratio = (y_tr == 0).sum() / (y_tr == 1).sum()
xgb_model = XGBClassifier(n_estimators=500, max_depth=5, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
min_child_weight=10, gamma=0,
scale_pos_weight=ratio,
objective='binary:logistic', eval_metric='auc',
use_label_encoder=False, random_state=42, n_jobs=-1)
xgb_model.fit(X_tr, y_tr, early_stopping_rounds=30, eval_set=[(X_val, y_val)], verbose=False)
# 预测验证集并计算AUC
val_pred = xgb_model.predict_proba(X_val)[:,1]
print("XGBoost 验证集 AUC:", roc_auc_score(y_val, val_pred))
输出(示例):
XGBoost 验证集 AUC: 0.6401
这个AUC大约0.64,对应Gini约0.280。考虑到顶尖方案AUC也就0.65左右 ,0.64已是不错的起点。这显示XGBoost强大的建模能力。
调优:在上述代码中我们设置了一组参数,其实并未经严格调优,只是经验值。我们稍后会系统讨论超参数调优方法。但一般经验:降低 learning_rate
并增加 n_estimators
往往能提升一点,但训练更久;增大 max_depth
也许提高训练AUC但风险过拟合;正则参数和采样参数可以控制过拟合、也影响性能,需要搜索平衡。鉴于速度,在调参时可以先 固定 learning_rate 较高值(如0.1)快速试探重要参数,然后再降低学习率并增大树数精调。
使用 GPU:如果有GPU环境,XGBoost可以通过设置 tree_method='gpu_hist'
利用GPU加速训练,在如此大量数据时能快很多。Kaggle内核经常提供GPU,在本地如果有资源也可利用。
总之,XGBoost作为老牌冠军模型,我们一定要充分发挥其性能。实际很多方案也用XGBoost作为ensemble的一部分 。接下来看看LightGBM。
4.2 LightGBM 模型
LightGBM 是由微软开发的梯度提升树工具,特点是速度快、内存省,在大数据集上非常有优势,同时在性能上往往与XGBoost相当甚至略胜。LightGBM的关键改进包括基于直方的决策树算法和叶子生长策略,这使得在相同树数情况下训练更快。很多 Kaggle 选手在本比赛中最终都倾向使用 LightGBM 做大量模型的训练 。
LightGBM 的许多参数与 XGBoost 类似,但名字略有不同。例如:
num_leaves
:叶节点数。相当于树复杂度(2^depth),控制模型复杂度。应小于2^(max_depth)
,否则相当于允许更深。max_depth
:最大深度,可以防止过深。LightGBM可以不限制深度而通过num_leaves
控制,但加上max_depth安全一些。learning_rate
,n_estimators
,subsample
(又称 bagging_fraction),colsample_bytree
(feature_fraction),min_child_weight
(min_sum_hessian_in_leaf) 等,对应XGBoost的类似概念。lambda_l1
,lambda_l2
:L1/L2正则。max_bin
:直方桶数量,默认255,可调大提高精度但变慢。categorical_feature
:可以指定哪些列是分类变量(按名称或索引),LightGBM会对它们自动做优化的one-hot或找最优分裂。由于我们已经label encode,指定这些列,LightGBM能处理比XGBoost更好。scale_pos_weight
或is_unbalance
:也提供不平衡处理参数,作用同前。
使用 LightGBM 非常类似,我们直接用 Python 接口的 LGBMClassifier:
from lightgbm import LGBMClassifier
lgb_model = LGBMClassifier(n_estimators=500, learning_rate=0.05,
num_leaves=32, max_depth=5,
subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.1, reg_lambda=0.1,
class_weight={0:1, 1:ratio}, # 或 scale_pos_weight=ratio
random_state=42, n_jobs=-1)
# 指定分类特征列索引
cat_feat_indices = [X_tr.columns.get_loc(c) for c in cat_cols]
lgb_model.fit(X_tr, y_tr, eval_set=, eval_metric='auc',
early_stopping_rounds=30, categorical_feature=cat_feat_indices, verbose=False)
val_pred_lgb = lgb_model.predict_proba(X_val)
print("LightGBM 验证集 AUC:", roc_auc_score(y_val, val_pred_lgb))
输出(示例):
LightGBM 验证集 AUC: 0.6412
可以看到LightGBM结果类似,甚至略好一点点。这只是初步模型,相信经过调参还可提高。LightGBM一般对 num_leaves
和 max_depth
敏感,要注意不要让叶子数过多,否则容易过拟合。
训练速度对比:在默认设置下,LightGBM往往比XGBoost快几倍。在我们的示例中,500棵深度5的树LightGBM大概几秒就训练完,而XGBoost可能十几秒甚至更多(因实现和默认设置差异)。随着树的增多和特征增多,这个差距更明显。因此在有限提交次数的比赛里,LightGBM能更快速迭代试验。
特征重要性:可以和XGBoost一样查看 lgb_model.feature_importances_
了解特征贡献。如果我们发现某些特征始终重要性为0,可以考虑删掉或不纳入模型。通常 ps_calc_**
特征重要性普遍偏低 ,一些方案干脆删去了所有calc特征来减少过拟合风险。我们没删是为了尽量保留信息,后续可观察其权重决定。
CatBoost:值得一提另一个GBDT工具CatBoost,在处理高基数分类和减少过拟合上有一些独特之处,但其在此比赛未明显胜出,不少选手以LightGBM为主。不过CatBoost对于包含大量类别变量的数据是很好选择,这里就不详细展开。
小结:XGBoost和LightGBM在此任务中都表现优秀。实际很多高分方案是同时训练多个LGB和XGB模型然后集成 。我们的后续集成部分也会考虑将二者的预测结合。接下来我们探讨神经网络的应用。
4.3 神经网络模型
虽然决策树类模型在表格数据中占主导,但神经网络(尤其深度多层感知机,MLP)依然是一个值得尝试的模型,特别是在融合中提供异质性。神经网络的优点是可以学到一些非树模型无法轻易学到的平滑关系或更复杂组合,缺点是对数据准备要求高,训练需要更多尝试不同架构,且单模型效果往往不如Boosting。但在Top方案中,经常能看到神经网络的身影,与树模型一起提高最终成绩 。
网络架构:对表格数据,一般使用 全连接前馈网络(Fully Connected Neural Network)。可以包含若干隐藏层,每层若干神经元。关键是防止过拟合,可采用 Dropout、Batch Normalization 等。对于分类特征较多的数据,一个技巧是使用 Embedding Layer 将高维稀疏独热向量压缩,类似于推荐系统那样处理离散特征。但embedding需要较长训练和仔细调参。
这里我们构建一个相对简单的MLP:两层隐藏层,每层例如 64 个神经元,使用 ReLU 激活,并在之间加入 Dropout(0.2) 防止过拟合。
数据准备:神经网络通常对输入有如下要求:
- 连续特征需要标准化(减均值除以标准差)或归一化。因为未经处理的不同量纲会使训练不稳定。我们可以采用 StandardScaler。
- 分类特征需要转换为独热或embedding**。直接输入标签编码的整数会让网络误以为相邻类别有大小顺序,不合适。One-Hot会导致输入维度变很大(我们大约14个分类特征,总dummy维度可能数百),但我们可以减少维度的方法:embedding每个分类特征为一个低维向量,然后将这些embedding拼接,或对one-hot矩阵做 PCA 压缩。但embedding需要特殊实现,Keras提供直接对整数类别embedding的层,很适合这种情况。
为简化,我们这里对所有分类变量做One-Hot,连续变量则标准化。虽维度大一些,但一个两层网络还能应付。
import numpy as np
from sklearn.preprocessing import StandardScaler
# 准备神经网络数据:独热编码分类,连续特征标准化
X = train.drop(, axis=1)
y = train.values
# 区分连续和需要One-Hot的列
cont_cols = [c for c in X.columns if c not in cat_cols]
# 先对连续列标准化
scaler = StandardScaler()
X_cont = scaler.fit_transform(X[cont_cols])
# 再对分类列One-Hot
X_cat = pd.get_dummies(X[cat_cols].astype(int), columns=cat_cols, drop_first=False, sparse=True)
# 注意: 使用稀疏矩阵可以提高效率
X_cat = X_cat.sparse.to_coo() # 转换为SciPy稀疏矩阵
# 最后组合连续和独热部分
# 转换稀疏为稠密(数据量大时应直接用稀疏存储以节省内存,不过Keras暂不支持稀疏输入)
X_cat_dense = X_cat.toarray()
X_nn = np.hstack([X_cont, X_cat_dense])
print("NN 输入维度:", X_nn.shape)
输出(假设):
NN 输入维度: 216
输入维度增加到两百多,这对神经网络来说不算太糟。我们取20%数据用于验证:
X_tr_nn, X_val_nn, y_tr_nn, y_val_nn = train_test_split(X_nn, y, test_size=0.2, stratify=y, random_state=42)
**定义和训练模型:**使用 Keras (TensorFlow) 构建模型:
import tensorflow as tf
from tensorflow import keras
model = keras.Sequential([
keras.layers.Dense(64, activation='relu', input_shape=(X_tr_nn.shape[1],)),
keras.layers.Dropout(0.2),
keras.layers.Dense(32, activation='relu'),
keras.layers.Dropout(0.2),
keras.layers.Dense(1, activation='sigmoid')
])
# 编译模型
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=[keras.metrics.AUC(name='auc')])
# 训练模型 (使用类权重应对不平衡)
class_weights = {0:1, 1: ratio}
history = model.fit(X_tr_nn, y_tr_nn, epochs=10, batch_size=2048,
validation_data=(X_val_nn, y_val_nn),
class_weight=class_weights, verbose=2)
我们设置较大的 batch_size(2048)以充分利用并行,训练10个epoch作为示范。如果准确率不上升可多跑一些epoch并关注val_auc。由于启用了 class_weight
,正例的loss被放大约26倍,使模型更关注正例。观察训练日志,可以看到 AUC 指标:
Epoch 1/10
234/234 - 4s - loss: 0.5493 - auc: 0.515 - val_loss: 0.2741 - val_auc: 0.610
Epoch 2/10
234/234 - 3s - loss: 0.2837 - auc: 0.614 - val_loss: 0.2624 - val_auc: 0.626
...
Epoch 10/10
234/234 - 2s - loss: 0.2601 - auc: 0.640 - val_loss: 0.2570 - val_auc: 0.637
(此为模拟输出)可以看到验证集AUC达到0.637左右,接近我们XGBoost的水平。这对于一个简单两层网络来说相当不错了。实际中,调整网络结构和训练过程(学习率调度、更长训练、加入更多特征交互如embedding)有望进一步提升。比如embedding技巧:我们可以不给每个类别做one-hot,而是用 Keras 的 Embedding(input_dim=n_categories, output_dim=k)
层,将类别变量映射为长度k的实向量,再将所有embedding展开连接连续变量一起送入后续Dense层。限于篇幅,这里不具体实现embedding版。但embedding通常对高基数分类(如ps_car_11_cat)非常有效,能让网络自主学习该类别的“意义”。
神经网络小结:在表格数据上,NN需要精心的特征预处理(标准化、编码)和架构调试,效果可能不如树模型,但它提供了不同的思路。在这个比赛中,如果单纯追求最高分,可能多个调优过的树模型融合更简单有效。然而,将NN纳入集成,可以增加预测的多样性,从而提升最终成绩 。因此我们不会单独依赖NN,但会将其预测与其他模型结合,这在下一节讨论。
4.4 其他可选模型
随机森林:作为 Bagging 的代表,随机森林对不平衡也需要设定class_weight
或下采样。单个随机森林通常性能不及Boosting,但它可以作为stacking一层的基础模型提供不一样的视角(不过由于RF和GBM都基于树,差异不如NN大)。如果时间充裕,可以尝试 RandomForestClassifier(n_estimators=100+),看CV AUC,大概率会比XGB低一些。
逻辑回归:线性模型只能分离线性可分部分,对高度非线性数据效果有限。可以尝试在One-Hot后跑一个L2正则的LogisticRegression,得到可能0.5左右的AUC,不具竞争力。但正如一些解决方案,把LR用作最终stacking的元模型融合多个模型输出,是很好的选择 。在最后集成部分我们会这样做。
Naive Bayes、SVM 等:NB假设特征独立在此不成立,SVM对几十万数据训练会非常慢而且需要核技巧,通常不考虑。
集成学习其实已经开始在我们选择模型的时候体现:树模型和神经网络都各有千秋。下一步,我们重点关注如何调优超参数,然后进入模型融合的阶段。
五、超参数调优
无论是 XGBoost、LightGBM 还是神经网络,都有许多超参数需要调整。良好的超参数组合往往是提升模型表现的关键因素之一。调参主要有三种常用方法:
- 网格搜索 (Grid Search) (Bayesian optimization xgboost hyperparameter tuning)
- 随机搜索 (Random Search)
- 贝叶斯优化 (Bayesian Optimization)
我们分别介绍它们的原理和应用。
5.1 网格搜索
网格搜索即穷举搜索:人为设定每个待调参数的一系列候选值,然后算法遍历所有组合,训练模型并评估性能 。最终选择表现最好的组合。优点是可以不遗漏地找到在给定网格上的最优点,缺点是计算量爆炸。例如4个参数每个试5个值,总共5^4=625 种组合,若每次训练耗时较长,总时间就很可观。
在本次比赛数据上,单次模型训练就不算非常快,因此全局网格搜索并不现实。网格搜索更适合小数据集或参数很少的情形。我们可以用分段网格策略:先固定某些参数,只对一两个重要参数网格搜索;确定后再针对下一个参数搜索局部范围。
例如,我们可以对 XGBoost 先网格搜索 max_depth
和 min_child_weight
,因为这两个对模型复杂度影响大且互相关联。设 depth在{4,6,8}, min_child_weight在{5,10,15} 九种组合,找出最佳,然后再固定它们去搜索 subsample
和 colsample_bytree
等。
Sklearn 提供 GridSearchCV
可以方便完成:
from sklearn.model_selection import GridSearchCV
param_grid = {
'max_depth': [4, 6, 8],
'min_child_weight': [5, 10, 15],
'n_estimators': [100] # 固定树数为100以加快调参
}
xgb_clf = XGBClassifier(learning_rate=0.1, subsample=0.8, colsample_bytree=0.8,
scale_pos_weight=ratio, eval_metric='auc', use_label_encoder=False)
grid_search = GridSearchCV(xgb_clf, param_grid, cv=3, scoring='roc_auc', verbose=1)
grid_search.fit(X_tr, y_tr)
print("最佳参数:", grid_search.best_params_)
print("最佳CV分数:", grid_search.best_score_)
这会做3折交叉验证,训练9*3=27次XGB (100棵树每次)。输出例如:
最佳参数: {'max_depth': 6, 'min_child_weight': 10}
最佳CV分数: 0.6392
假设如此,则我们选 depth=6, min_child_weight=10。然后进一步搜索 subsample 和 colsample。这样分阶段网格既减少组合数量,又基本能找到好的参数。不过GridSearchCV对每种组合都完全重新训练模型,不充分利用以往信息。
5.2 随机搜索
随机搜索是指不是遍历所有组合,而是在参数空间中随机采样一定数量组合来试验 。这样在计算预算固定的情况下,可以探索更大的参数空间,而且研究表明随机采样往往比等距网格更高效,尤其当只有部分参数对结果敏感时 。比如4个参数中也许2个很重要,需要精细调,另2个不敏感,随机搜索能更快碰巧把重要参数搜对,而网格会把资源浪费在不敏感参数组合上 。
Python中,RandomizedSearchCV
与GridSearchCV类似用法,只是需要指定采样次数而不是穷举:
from sklearn.model_selection import RandomizedSearchCV
import scipy.stats as st
param_dist = {
'max_depth': [4,5,6,7,8],
'min_child_weight': [1,5,10,20],
'subsample': [0.6, 0.8, 1.0],
'colsample_bytree': ,
'gamma': [0, 0.2, 0.5],
'n_estimators': [200]
}
random_search = RandomizedSearchCV(xgb_clf, param_dist, n_iter=10, cv=3, scoring='roc_auc', random_state=42)
random_search.fit(X_tr, y_tr)
print("随机搜索最佳参数:", random_search.best_params_)
print("最佳CV分数:", random_search.best_score_)
这里我们指定了参数的离散集合,也可以用scipy.stats
定义连续分布来从中采样。比如 st.uniform(0.5, 0.5)
表示区间[0.5,1.0]均匀采样一个值。随机搜索一般迭代次数比网格组合数少很多,例如我们这里只尝试10组。根据经验,随机搜索 50-100 次往往可以得到比较不错的结果。对大型模型考虑时间,可少一些。每次试验独立,因此可以并行执行提高效率(sklearn会利用n_jobs并行不同CV折)。
5.3 贝叶斯优化
贝叶斯优化是一种更智能的超参数搜索方法。它利用已经尝试过的参数结果来推断目标函数(验证得分)关于参数的分布,并选择可能提升最大的点进行下一次尝试 。简单说,贝叶斯优化不是盲目或均匀地搜索,而是有记忆地探索和利用,争取用更少的试验达到最优。
常用的库有 Hyperopt(TPE算法)、scikit-optimize、Optuna 等。它们对每次试验的反馈进行建模(比如用高斯过程或Tree Parzen Estimator),然后决定下一个参数组合。相比随机搜索,贝叶斯优化能够更快收敛到较优区域 。
举例,用 Hyperopt 调 XGBoost 参数:
from hyperopt import fmin, tpe, hp, Trials
# 定义参数空间
space = {
'max_depth': hp.quniform('max_depth', 4, 8, 1), # 4到8的整数
'min_child_weight': hp.quniform('min_child_weight', 5, 15, 1),
'subsample': hp.uniform('subsample', 0.6, 1.0),
'colsample_bytree': hp.uniform('colsample_bytree', 0.6, 1.0),
'gamma': hp.uniform('gamma', 0, 0.5),
'learning_rate': hp.loguniform('learning_rate', -3, -1), # log空间采样[0.05, 0.37]
'n_estimators': 200,
# 不搜索的固定参数也可以写在space里
'objective': 'binary:logistic',
'eval_metric': 'auc',
'scale_pos_weight': ratio,
'seed': 42
}
# 定义目标函数
def objective(params):
# 将可能是float的整参数转换
params['max_depth'] = int(params)
params['min_child_weight'] = int(params)
# 建立模型做3折CV评估AUC均值为 -score
xgb_cv = XGBClassifier(**params, use_label_encoder=False)
scores = cross_val_score(xgb_cv, X_tr, y_tr, cv=3, scoring='roc_auc')
return -scores.mean() # hyperopt最小化目标
# 优化参数
trials = Trials()
best = fmin(fn=objective, space=space, algo=tpe.suggest, max_evals=20, trials=trials, rstate=np.random.RandomState(42))
print("Bayes优化得到的最佳参数:", best)
上述使用Hyperopt的TPE算法(Tree-structured Parzen Estimator) (XGBoost Hyperparameter Bayesian Optimisation - Kaggle)进行20次尝试,将返回最优参数组合。每次尝试选择的参数不是随机,而是依据过去结果推断最有希望提升AUC的区域 。Optuna等库的接口类似甚至更简洁。
Bayes优化的优势在于少试验次数下就能有较好参数,但要注意:
- 需要合理设置参数空间,否则会局部收敛在次优区域。通常根据经验限定合理范围,留一定探索度。
- 需要尽可能噪声小的反馈,最好用足够大的CV或多次重复稳定AUC,否则优化过程可能被噪声误导。
- 在时间充裕时,可逐步缩小范围、多次运行。
在 Kaggle 比赛中,很多高分选手利用 Hyperopt/Optuna 自动调参,例如有方案用 Optuna 进行7.5小时的XGBoost参数搜索,进行了近200次试验 (Solução Final - Porto Seguro Data Challenge [3º lugar])才确定最终参数,可见下了很大工夫。贝叶斯优化能减轻人工调参负担,但也要小心避免过拟合CV(对参数调得过于针对CV而不泛化),所以一般会留一个独立验证集最终确认。
5.4 超参数调优总结
对于我们的问题,推荐的调参步骤是:
- 确定baseline:先用经验参数训练模型得到一个CV AUC,如前面XGB ~0.64 AUC。
- 重要参数粗调:用随机搜索或贝叶斯优化找出较优的
max_depth
,min_child_weight
,num_leaves
等控制模型容量的参数。 - 次要参数微调:调节
subsample
,colsample
,reg_alpha
,reg_lambda
,gamma
等正则化/采样参数,通过验证集观察过拟合情况决定是否加强正则。 - 学习率和树数:通常先fix较高学习率找参数,然后降低学习率增加树数以提高分数(learning_rate小到一定程度收益变小)。
- 不平衡参数:调整
scale_pos_weight
或 class_weight,看对AUC是否提升或减弱。一般取默认(样本比例)即可,但也可尝试略微加大或减小看看AUC波动。 - 重复验证:在不同随机种子下、或不同时期(Public LB vs CV)验证参数是否稳健。
对于神经网络,调参又是另一番风景,包括网络架构、优化器学习率等。可使用类似思路,用 Keras Tuner 或 Optuna 的集成来优化网络超参(层数、神经元数、dropout率等)。神经网络调参更费时间且不确定性大,这里不深入了。
超参数调优需要与特征工程和模型选择一起迭代:比如发现模型复杂度高容易过拟合,那也许需要添加正则或减少一些高相关特征;反过来如果模型一直欠拟合,或许可以增加特征交互或增加模型复杂度。比赛过程中,这几个环节是穿插进行、不断修正的。
调好参数的单模型通常在CV上接近可以提交的成绩了。下一步,我们考虑如何集成多个模型来进一步提升Kaggle分数。
六、模型集成
模型集成(Ensemble)是提升Kaggle比赛成绩的有效手段。不同模型各有偏差和方差,通过集成可以取长补短,提高泛化能力。常见的集成方法包括:
-
Bagging(装袋):对同一种模型训练多个不同子模型(如不同随机种子、不同子样本),然后平均预测结果 。随机森林本身就是bagging思想的应用。装袋主要降低模型方差。
-
Boosting(提升):前面使用的XGBoost、LightGBM实际上就是提升方法的代表,通过逐步迭代弱学习器提高整体性能。boosting更像一种单模型训练策略,所以在集成章节我们指的是将boosting模型与其他模型结合,而不是再去开发新的boost算法。
-
Stacking(堆叠):用不同类型的基础模型先对训练集进行预测(通常通过K折产生对每个样本的OOF预测),把这些预测作为新特征,再训练上层的“元模型”做最终预测 。这种两级或多级的模型堆叠可以融合模型的差异,常比简单平均表现更好。
-
Blending(混合法):Stacking的简化版,一般是留出一部分训练集作为“融合数据”,用所有模型在这留出集上预测,再训练一个简单模型融合。例如将 10% 数据留作验证,90%上训练基础模型,然后在10%上学习融合。这避免了复杂的K折OOF,但牺牲了一部分训练数据。
在比赛中,Stacking 和 Blending 是常用的后期提分手段。大家会训练多种模型如XGB、LGB、NN、LR等等,然后尝试加权平均或训练一个次级模型融合。Top方案常常是集成了大量模型,比如18th名的选手用到了16个不同base模型再堆叠 ;2nd名方案提供了简单平均和完整堆叠的两种实现 。
6.1 简单集成:模型平均
最基本的集成就是平均各模型预测值。平均可分为等权平均 和 加权平均。如果模型性能差异不大,用等权平均就可以;如果有强弱,可以给强模型高一点权重。平均可以降低随机误差,提升稳定性,尤其是多个模型错误不完全相关时,平均能提高AUC。
例如,我们已经训练了一个XGBoost (xgb_model
)、一个LightGBM (lgb_model
)、一个NN (model
)。我们可以直接将它们对验证集的预测概率平均,看看AUC:
# 获取各模型验证集预测
pred_val_xgb = xgb_model.predict_proba(X_val)
pred_val_lgb = lgb_model.predict_proba(X_val)
pred_val_nn = model.predict(X_val_nn).ravel() # NN的验证集输入需是预处理后的
# 简单平均
avg_pred = (pred_val_xgb + pred_val_lgb + pred_val_nn) / 3
print("简单平均融合 AUC:", roc_auc_score(y_val, avg_pred))
如果各模型AUC相近且错误不完全一致,平均后AUC通常会略高于最好的单模型。这种提升在Kaggle提交分数上经常能看到。比如2nd方案报告:单LGBM 0.291X,单NN 0.290X,简单平均后达到0.2938 。这0.002~0.003的Gini提升在排行榜上就很关键。
可以尝试调整权重,比如如果XGB和LGB比NN强,可以 avg = 0.4*xgb + 0.4*lgb + 0.2*nn
等等,看AUC变化。确定加权可用网格搜索或直接暴力尝试一些组合。
6.2 Stacking 实践
Stacking通常效果更佳,但实现相对复杂。我们这里示范一个两层 stacking:
**第一层(base models):**选择多种模型作为基础,例如:
- Model1: 调参后的 XGBoost
- Model2: 调参后的 LightGBM
- Model3: 调参后的 Neural Network
- Model4: 或者可以加一个 RandomForest / ExtraTrees 等(用不同算法增加多样性)
**第二层(meta model):**可以用一个简单但稳健的模型来融合第一层输出。常用的是 逻辑回归 或 线性回归 用于回归/分类概率融合,因为它们不易过拟合输出并能平滑地分配权重 。也有人用小型神经网络或树模型作为次级模型,但逻辑回归足够常见。
生成第一层的预测:我们需要对每个基础模型获取在训练集上的“伪预测”,即对每个训练样本,预测其输出但不使用该样本本身训练的模型(防止泄漏)。实现方法是K折 OOF:
- 将训练集划为K折(比如K=5)。
- 对于每个模型和每折:用其他4折的数据训练模型,在该折上预测得到这一折每个样本的预测值。
- 拼接5折的预测,即得到与训练集一一对应的预测(OOF预测)。这样我们相当于有了每个样本经模型预测的概率,而且这些预测对该样本来说是“独立”的(模型未见该样本)。
- 同时,一般还会训练K个模型(每折一个),然后用这K个模型对测试集预测并平均,得到该模型对测试集的融合预测。
我们来示范Stacking的流程(以两个模型为例简化):
from sklearn.model_selection import KFold
# 准备第一层模型列表
base_models = [
XGBClassifier(**best_xgb_params, use_label_encoder=False),
LGBMClassifier(**best_lgb_params)
]
base_model_preds = [np.zeros(len(X)) for _ in base_models] # 用于存储每个模型的OOF预测
test_preds = [np.zeros(len(test)) for _ in base_models] # 用于存储每个模型对测试的预测
kf = KFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, val_idx in kf.split(X):
X_train_kf, X_val_kf = X.iloc, X.iloc
y_train_kf, y_val_kf = y.iloc, y.iloc
# 针对每个基础模型训练并预测
for i, model in enumerate(base_models):
model.fit(X_train_kf, y_train_kf)
# 记录验证折的预测
base_model_preds[i] = model.predict_proba(X_val_kf)
# 记录对测试集的预测(取多折平均)
test_preds += model.predict_proba(test.drop('id', axis=1)) / kf.n_splits
结束后,我们将得到例如:
base_model_preds[0]
是 XGB 的 OOF 预测(长度=训练集长度)。base_model_preds
是 LGB 的 OOF 预测。
现在我们以这些预测作为新特征,构建元模型训练集:
# 创建二层训练集特征:每个基础模型的OOF预测
stack_X = np.column_stack(base_model_preds)
stack_y = y.values # 仍然是原来的target
# 创建二层测试集特征:每个基础模型对测试集的平均预测
stack_X_test = np.column_stack(test_preds)
# 训练元模型(用简单逻辑回归为例)
meta_clf = LogisticRegression()
meta_clf.fit(stack_X, stack_y)
# 元模型对测试集预测
stack_test_pred = meta_clf.predict_proba(stack_X_test)
元模型LR会自动根据AUC给两个输入分配权重。如果XGB比LGB好,LR会倾向于系数更大给XGB的预测。此外,LR也可以学到两者间的互补关系。如果我们有更多模型,stack_X就是N列,每列对应一个模型预测,LR会学习一个N维权重向量来最佳区分目标 。
最后的 stack_test_pred
就是我们融合模型的最终预测,对应每个测试样本一个概率。这就是我们可以提交的结果(还需要生成submission CSV文件,后面会做)。
Stacking可以进一步有多层,比如把第一层输出再加原始特征一起给第二层模型,或者再叠一层。但每多一层增加过拟合风险且收益递减。两层已经是常用且有效的方案。
需要注意Stacking一些细节:
- 同质模型的bagging:对于同一种模型,也可以通过Stacking的形式实现bagging。例如我们可以训练5个不同参数或不同seed的LightGBM,把他们的OOF平均作为一列特征。但更简单的bagging是直接平均,这种情况下不如直接平均提交。所以stacking更多用于融合不同类型模型。
- 防泄漏:务必确保第一级OOF预测是严格训练集外的。如果偷用了该样本训练信息,元模型会过拟合。我们的KFold做法是正确的。
- 数据泄漏检查:Stacking还有一种隐形风险,如果不同模型的错误高度相关,元模型可能学不到新东西。但通常不同算法有多样性,可以避免这点。
- 计算成本:Stacking需要训练多次模型,我们上面的例子每模型训练6次(5折 + 全量1次用于测试预测平均)。如果模型很慢,可以减少折数或并行训练。
6.3 Blending方法
Blending思路是在划分的validation set上融合。举例:
- 保留训练集的最后10%(或一部分)作为blend验证集,其余90%训练各模型。
- 用各模型对这10%验证集预测,得到每模型一列预测。用这10%的真实标签来训练元模型。
- 用各模型在测试集的预测作特征,通过元模型得到测试集最终预测。
这跟上面的Stacking几乎一样,只是Stacking利用了K折更充分,没有浪费训练数据。而Blending牺牲了一部分训练数据当融合集,但实现更简单,不用搞OOF。Blending的劣势是对于训练集较小的情况,会损失精度,不过在大数据下10%损失不大。有些团队可能为了省事用了blending,或者在极限情况怕OOF实现有bug,也用blending。但通常Stacking是更“贪心”地用满数据的做法。
6.4 提交结果的集成
在Kaggle中,还有一种集成是提交结果层面的。比如选择多次提交的结果作平均。当已经生成了多个模型的提交文件(CSV),可以读取它们的预测列,求平均后再提交。这等价于模型平均,只是事后操作。这样做可以融合不同队友的模型、不同训练周期的模型等等。必须注意Public LB上看平均分高并不保证Private LB也高,要尽量相信CV。
小技巧:为了防止过拟合Public LB,一些选手会在最后提交一个多模型bag的结果,而非单一最好CV模型,以求更稳定的排名。因为单模型可能恰巧对Public部分表现特别好,到了Private就跌落,而平均多个模型(尤其是CV性能相近的)一般Public/Private差异不会太夸张。
总之,通过模型集成,我们希望将各个模型的长处结合,取得比单模型更高的Kaggle分数。在实践中,一定要用交叉验证评估集成效果,不要仅凭Public LB结果选择,以免overfit LB。下一节,我们将讨论Kaggle比赛中特有的一些排名优化策略 和注意事项。
七、Kaggle 排名优化策略
要在 Kaggle 竞赛中取得优异成绩,除了掌握技术细节,还需要一些策略层面的考量。这里结合 Porto Seguro 比赛和普遍经验,谈谈如何优化Kaggle评分,避免掉坑。
7.1 信任本地验证,谨慎对待Public LB
Public Leaderboard (公开榜单) 成绩仅基于部分测试集(通常是50%)。过度针对Public LB调参可能导致Private LB(最终榜单)名次大幅滑落,这被称为 “LB shake-up”。为避免此,一定要以本地CV为主要指导,Public LB仅作辅助验证 。具体来说:
- 如果本地CV和Public LB趋势一致(某模型CV高LB也高),可以比较放心。如果出现背离,比如某模型CV高但Public分低,那么要怀疑可能过拟合或特例,不能贸然依赖Public调优。
- 控制提交次数,不要每天频繁在LB上试参数,这相当于用测试集调参,会导致过拟合测试集,扰乱判断。Kaggle通常提供每日 5 次提交限制,应珍惜名额。
- 在赛后总结中,很多人提到重视本地交叉验证的重要性 。例如ZSY的方案就教训说由于后期过分依赖LB,导致Public 0.282跌到Private 0.279,损失名次。因此,坚持CV为王是一条铁律。
7.2 防止过拟合和模型失衡
过拟合在比赛后期很常见,因为我们不断增加特征、增加模型复杂度来追求CV极限分。要缓解过拟合:
- 使用适当的正则化:对于树模型,可限制深度、叶子数、增大
min_child_weight
、使用reg_lambda/reg_alpha
等。当发现训练集AUC远高于验证集AUC时,这是典型过拟合迹象,应加强正则。 - 特征选择:移除明显过拟合的特征。例如某特征在训练上和目标高度相关,但在验证上并无,此时可能是偶然关联。尤其匿名数据,有时某些编码可能无意义或泄漏(虽然本赛题没有明确泄漏)。
- 模型集成:集成有助于降低单模型的过拟合。Stacking第二层用简单模型,也是一种正则形式,使得极端的单模型预测被平滑整合。18th名方案通过16模型stacking把CV方差降得很低 ,泛化更稳健。
- 交叉验证策略:使用多折CV、多次重复CV评估稳定性。如果一个模型仅在某一折表现好,均匀CV后可能下降。可以通过不同随机种子多跑几次CV计算均值。
- 训练集大小利用:尽量利用全部训练数据。Stacking已做到这点;如果是blending或单模型,可以在确定参数后用全体训练集重新训练最终模型,以最大化利用数据。
7.3 利用Kaggle特性提高分数
Kaggle竞赛提供一些独特的环境,可以利用:
- Kaggle Kernels:查看公共Kernel(notebook)的思路,有时能发现新的特征工程方法或模型调参技巧。比如本比赛有很多公共EDA和baseline kernel,可以借鉴他人发现的模式 (但要注意避免抄袭直接导致思维局限)。
- 论坛和团队:关注讨论区,有经验的选手会分享一些见解,如本题有人分享了目标编码、特征关系等。中后期可以选择组队,大家融合方案。组队可以把不同的强项结合,也可以增加模型多样性。不过要注意团队合作需要整合代码和结果,最好在赛前就协商好分工。
- 调低期望:Public LB高分不代表Private也高。有时前排Public队伍其实过拟合public test,最后排名会跌。相反,如果你的方案CV稳定,即使Public分不拔尖,也可能在Private实现反超。所以心态上不要被Public名次干扰过多,专注提升CV和稳定性。
- 提交选择:通常比赛允许同时选取2个最终提交,一个主力、一个备选。一个策略是:选取CV最好的作为主力,同时选一个略有差异(比如更保守的集成)的方案作为备选。如果主力overfit了,备选也许表现更稳健。
具体到Porto Seguro比赛,由于没有时间序列等复杂因素,CV稳定性还是比较好的。Private LB没有发生剧烈shake-up(冠军Private 0.2919, Public 0.2922,非常接近)。但中低名次仍有波动,所以稳健方案依然重要。
7.4 关注评测指标与业务
本比赛评测指标是Gini(即2*AUC-1)。充分理解指标有助于优化:AUC/Gini衡量排序质量,所以Calibration(概率校准)并不影响成绩,只要排序对就好。因此可以放心调整样本权重、阈值,不用纠结预测值偏移。另外AUC对类不平衡相对友好,只要正负排序相对正确,类别比例影响不大。不过提交时必须提交概率,不能提交硬分类,否则几乎肯定成绩很低(因为排序信息全丢了)。
业务上,知道这是保险理赔预测,可以猜测正例很少且某些组合会成为“雷区”。虽然特征匿名,但我们通过数据探索已经发现了一些模式,比如缺失情况和目标的关联等 。有时结合背景猜测也能带来灵感:例如保险理赔通常跟年龄、驾驶纪录、车价等有关,我们可以猜想 ps_ind_15
(范围0-15整数)可能是年龄段或驾龄,那么高低值或许风险不同,模型可以自动捕捉但我们也可以给模型一些monotonic constraint如果确信关系单调(XGBoost支持monotonic_constraints参数)。不过这需要非常确定的先验,否则易适得其反。
7.5 排除数据泄漏风险
数据泄漏指的是模型在训练时不正当地利用了目标相关的信息,导致过拟合而测试不可用。虽然比赛主办方一般会处理,但仍要提高警惕:
- 检查是否有和ID有关的特征。例如ID顺序是否与target相关(此赛题应该随机打乱了)。有的比赛ID隐藏了时间信息等,如果有,不能当普通特征用。
- 检查类别编码是否泄漏目标。例如自己做目标编码时,必须严格CV,不要把整体均值泄漏给本折。类似地,Stacking时OOF正确就不会泄漏。
- 分析异常高的特征重要性。如某模型发现某特征重要性远高于其他且AUC奇高,可能该特征隐含了答案。例如曾有比赛出现过测试集独有的类别,这种在训练不可见的信息若误用会泄漏。(本赛题未听说有这种坑,但总之要当心。)
- 不要使用测试集的信息做训练上的决定。哪怕统计测试集特征分布都算轻微泄漏(尤其是在必须防范的比赛)。当然,在Kaggle规则允许下,有人用测试集无标签数据做半监督也算方法的一种,不视为泄漏。不过一般要慎用,怕引入噪音。
Porto Seguro比赛数据已经匿名和规整,没有明显泄漏。但养成检查习惯依然重要。ZSY方案中特别提到避免过拟合和重视CV正是为了防止我们无意识利用了测试集信息 。
7.6 差异化Public/Private的对策
有时Public和Private的数据分布可能稍有不同(虽然总体都是随机抽,但是可能抽到的类别比例略差异)。如果怀疑这种情况,可以在训练时加入一些噪声扰动或交叉验证袋外预测稳定性,保证模型不是专门针对一部分测试微调的。还有一种办法是对最终结果做平滑:比如对预测概率做一下 logit 平滑或排序混合,以减少偶然偏差。但这些一般影响不大。
对Public/Private差异最有效的还是前面说的:不要针对Public过调,另外可以准备多样化的模型。比如Public好的模型A和次好的模型B可能在Private顺序换了,那如果我们最后提交的是A和B的平均,至少不会太差。很多队伍在最后阶段会融合一些表现稍次但风格不同的模型,只为保险(保险比赛里讲究保险策略,挺契合的)。
总而言之,在Kaggle冲刺阶段,稳健胜过冒进。特别当私榜成绩揭晓时,一些Public前列队伍栽掉往往是因为他们没有遵循这些策略。而坚持CV验证、避免过拟合的团队会收获稳定的Private成绩 。
八、比赛经验分享
最后,我们结合本次竞赛,总结一些经验教训和整体策略方面的心得:
1. 制定明确的方案和里程碑: Kaggle比赛时间有限(一两个月常见),应该把握节奏。例如:
- 第1周:深入EDA,了解数据特点,跑几个baseline模型(如原始特征下的XGB、LR等) 。这能建立对问题难度和潜在方法的直觉。
- 第2-3周:重点进行特征工程尝试和单模型调优。比如对各种特征处理方法做实验,挑选有效的。调优一两个主要模型直到CV不错。这个阶段多阅读讨论和核分享,很可能发现别人提供的灵感(ZSY提到他参考了几个notebook的技巧,比如Plotly交互EDA、stacking思路等 )。
- 第4周以后:模型融合和反复验证。当单模型接近瓶颈,就尝试融合同级别的模型。留出至少最后1-2周调整集成、做CV vs LB对比、以及撰写报告。如果和别人组队,这时要整合彼此工作。
2. 团队合作: 团队 (Team) 在Kaggle是合法且鼓励的。在合适时候组队往往能冲击更高名次。合作经验:
- 选择互补的队友:比如一个擅长特征工程、一个擅长模型调参。这样可以分工又融合。
- 及时整合:不要各做各的到最后一天才融合,那样风险很大。应提前共享部分数据或模型输出,测试融合效果。
- 沟通和版本管理:团队需要频繁沟通进展,使用Git等工具管理代码防冲突。明确谁跑哪些模型,谁负责最终提交。
- 决策提交:团队各自可能有若干模型,最后挑选哪几个提交要统一意见。可以采用投票或看CV表现。一些队友如果思路非常不同(比如一人用深度学习一人用树),融合通常更有效果。
3. 利用外部资源学习: 除了自己实验,阅读获胜方案 和 相关文献也很重要。比如了解当前最好的分类算法和特征工程trick。有些理论知识如Imbalanced Learning专门有书和文章介绍各种策略 。本比赛中,理解Gini/AUC、理解模型原理,对做对事是有帮助的 。Kaggle过去类似比赛的top解法也可参考(比如Santander客户满足预测,也是不平衡二分类)。
4. 保持创新与谨慎: 比赛后期大多人收敛于相似的方法,要脱颖而出可能需要一些创新。比如引入不同的算法、不同的特征来源、或不同的问题变形(有人会尝试stacking里引入rank模型等)。但创新要有度,太冒险的思路要充分验证,否则可能弄巧成拙。ZSY分享说他学到很多新东西如模型融合、参数调优、EDA技巧 ,其实也是因为不断尝试新方法从他人那学来再实践。
5. 工程能力与自动化: Kaggle不只是比拼模型,还有谁更能高效地试验。写干净模块化的代码,便于快速调整特征/模型 pipeline,会节省很多时间。可以考虑使用sklearn的Pipeline或者自己写函数来重复实验。自动化调参(Optuna等)也属于工程上的优势,它能解放你的时间关注策略层面。
6. 心态和精力管理: Kaggle比赛往往烧脑又耗时。保证良好心态和健康,不要熬夜过度。后期遇到瓶颈时,多和队友讨论或暂时休息一下。经常复盘:哪些思路有效,哪些无效,不盲目陷入某个死胡同。合理安排时间去实现收益最高的步骤,不纠结细微提升而错过大方向优化。
最后,分享一句 Kaggle 老将的心得:“不断从他人身上学习是成功的关键” 。在这个比赛中,我们通过观察公共notebook和论坛,加上自己的实践,逐步完善方案,成绩有了明显提高 。其实机器学习也是这样一个不断学习、融合知识的过程。希望读者通过这次实战演练,不仅掌握了解题方法,也体会到比赛中解决问题的思路方式。
总结: Porto Seguro Safe Driver Prediction 这场比赛综合考察了不平衡数据处理、特征工程巧思、模型调优及集成等技能。高分方案往往在每个环节都做到极致且均衡——既挖掘了数据潜力,又控制了过拟合风险,并通过集成得到稳定的提升 。通过本文的详尽步骤,相信读者已经了解如何一步步复现并改进这样的方案。从比赛经验看,没有单一的银弹,胜利属于那些对细节精雕细琢并保持大局观的人。祝愿大家在Kaggle征途上不断成长,取得佳绩!
九、完整代码示例
最后,我们提供一个整合上述步骤的完整代码(整理自各节代码片段),以便读者参考运行。请注意实际运行需根据电脑配置调整参数(如减少模型迭代次数等):
# 完整流程代码示例
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, KFold
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score
from xgboost import XGBClassifier
from lightgbm import LGBMClassifier
from tensorflow import keras
import tensorflow as tf
# 1. 读取数据
train = pd.read_csv('train.csv', na_values=-1)
test = pd.read_csv('test.csv', na_values=-1)
print("Train shape:", train.shape, "Test shape:", test.shape)
# 分离ID和target
train_id = train['id']
test_id = test
y = train
X = train.drop(, axis=1)
X_test = test.drop('id', axis=1)
# 2. 缺失值处理
cat_cols = [c for c in X.columns if c.endswith('cat')]
bin_cols = [c for c in X.columns if c.endswith('bin')]
num_cols = [c for c in X.columns if c not in cat_cols + bin_cols]
# 填充分类缺失
for col in cat_cols:
X.fillna(-1, inplace=True)
X_test.fillna(-1, inplace=True)
# 连续缺失: 指示+中位数
for col in num_cols:
if X.isnull().any():
X = X.isnull().astype(int)
X_test = X_test.isnull().astype(int)
median_val = X.median()
X.fillna(median_val, inplace=True)
X_test.fillna(median_val, inplace=True)
bin_cols.append(col + '_missing') # 新增的指示列为二元特征
# 3. 特征选择: 删掉近常量bin列
constant_bins =
X.drop(columns=constant_bins, inplace=True)
X_test.drop(columns=constant_bins, inplace=True)
bin_cols = [c for c in bin_cols if c not in constant_bins]
# 4. 特征构造
# 缺失组合计数
X = ((X == -1).astype(int)
+ (X == -1).astype(int)
+ (X == -1).astype(int))
X = ((X == -1).astype(int)
+ (X == -1).astype(int))
X = ((X == -1).astype(int)
+ (X == -1).astype(int))
X_test = ((X_test == -1).astype(int)
+ (X_test == -1).astype(int)
+ (X_test == -1).astype(int))
X_test = ((X_test == -1).astype(int)
+ (X_test == -1).astype(int))
X_test = ((X_test == -1).astype(int)
+ (X_test == -1).astype(int))
num_cols += ['missing_group1','missing_group2','missing_group3']
# 二元总和
X = X.sum(axis=1)
X_test = X_test.sum(axis=1)
num_cols.append('bin_sum')
# 连续特征交互
X['reg03_car13'] = X * X
X_test = X_test * X_test
X = X**2
X = X**2
X_test = X_test**2
X_test = X_test**2
num_cols += ['reg03_car13','ps_car_12_sq','ps_car_15_sq']
# 更新特征列表
all_features = X.columns.tolist()
print("Total features after engineering:", len(all_features))
# 5. 数据拆分(用于本地验证)
X_tr, X_val, y_tr, y_val = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
# 6. 模型训练 - XGBoost
ratio = (y_tr == 0).sum() / (y_tr == 1).sum()
xgb_model = XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
reg_lambda=1, reg_alpha=0,
min_child_weight=10, gamma=0,
scale_pos_weight=ratio,
objective='binary:logistic', eval_metric='auc',
use_label_encoder=False, random_state=42, n_jobs=-1)
xgb_model.fit(X_tr, y_tr, early_stopping_rounds=20, eval_set=, verbose=False)
pred_val_xgb = xgb_model.predict_proba(X_val)
print("XGB Val AUC:", roc_auc_score(y_val, pred_val_xgb))
# 7. 模型训练 - LightGBM
lgb_model = LGBMClassifier(n_estimators=300, learning_rate=0.05,
num_leaves=32, max_depth=6,
subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.1, reg_lambda=0.1,
class_weight={0:1, 1: ratio},
random_state=42, n_jobs=-1)
cat_feats =
lgb_model.fit(X_tr, y_tr, eval_set=, eval_metric='auc',
early_stopping_rounds=20, categorical_feature=cat_feats, verbose=False)
pred_val_lgb = lgb_model.predict_proba(X_val)
print("LGB Val AUC:", roc_auc_score(y_val, pred_val_lgb))
# 8. 模型训练 - Neural Network
# 神经网络数据预处理
scaler = StandardScaler()
X_tr_cont = scaler.fit_transform(X_tr)
X_val_cont = scaler.transform(X_val)
X_test_cont = scaler.transform(X_test)
X_tr_cat = pd.get_dummies(X_tr.astype(int), columns=cat_cols, drop_first=False)
X_val_cat = pd.get_dummies(X_val.astype(int), columns=cat_cols, drop_first=False)
X_test_cat = pd.get_dummies(X_test.astype(int), columns=cat_cols, drop_first=False)
# 对齐one-hot列
X_tr_cat, X_val_cat = X_tr_cat.align(X_val_cat, join='left', axis=1, fill_value=0)
X_tr_cat, X_test_cat = X_tr_cat.align(X_test_cat, join='left', axis=1, fill_value=0)
X_val_cat, X_test_cat = X_val_cat.align(X_test_cat, join='left', axis=1, fill_value=0)
# 转numpy数组
X_tr_nn = np.hstack([X_tr_cont, X_tr_cat.values])
X_val_nn = np.hstack([X_val_cont, X_val_cat.values])
X_test_nn = np.hstack([X_test_cont, X_test_cat.values])
model = keras.Sequential([
keras.layers.Dense(64, activation='relu', input_shape=(X_tr_nn.shape[1],)),
keras.layers.Dropout(0.3),
keras.layers.Dense(32, activation='relu'),
keras.layers.Dropout(0.3),
keras.layers.Dense(1, activation='sigmoid')
])
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=)
class_weights = {0:1, 1: ratio}
model.fit(X_tr_nn, y_tr, epochs=10, batch_size=1024,
validation_data=(X_val_nn, y_val), class_weight=class_weights, verbose=0)
pred_val_nn = model.predict(X_val_nn).ravel()
print("NN Val AUC:", roc_auc_score(y_val, pred_val_nn))
# 9. 简单集成 - 平均
avg_pred = (pred_val_xgb + pred_val_lgb + pred_val_nn) / 3
print("Average Ensemble Val AUC:", roc_auc_score(y_val, avg_pred))
# 10. 模型融合 - Stacking (第二层用LR)
# 准备一级模型列表
base_models = [
XGBClassifier(**{**xgb_model.get_params(), 'n_estimators':100}, use_label_encoder=False),
LGBMClassifier(**{**lgb_model.get_params(), 'n_estimators':100})
]
# OOF预测矩阵
oof_preds = np.zeros((len(X), len(base_models)))
test_preds = np.zeros((len(X_test), len(base_models)))
kf = KFold(n_splits=5, shuffle=True, random_state=42)
for i, model in enumerate(base_models):
fold = 0
for train_idx, val_idx in kf.split(X):
fold += 1
X_train_kf, X_val_kf = X.iloc, X.iloc
y_train_kf, y_val_kf = y.iloc, y.iloc
model.fit(X_train_kf, y_train_kf)
oof_preds[val_idx, i] = model.predict_proba(X_val_kf)
test_preds[:, i] += model.predict_proba(X_test) / kf.n_splits
print(f"Model {i} OOF CV AUC:", roc_auc_score(y, oof_preds[:,i]))
# 元模型训练
meta_X = oof_preds
meta_y = y.values
from sklearn.linear_model import LogisticRegression
meta_clf = LogisticRegression()
meta_clf.fit(meta_X, meta_y)
final_pred = meta_clf.predict_proba(test_preds)
# 11. 生成提交
submission = pd.DataFrame({'id': test_id, 'target': final_pred})
submission.to_csv('submission.csv', index=False)
print("Submission file saved.")
这份代码依次执行了数据读取、预处理、特征工程、模型训练(XGB、LGB、NN)、验证集评估、简单平均融合、Stacking融合以及最终生成提交文件的步骤。读者可以根据需要调整参数(如减少树数、减少NN层数等)以在合理时间内运行。实际比赛中应使用完整训练集训练最终模型(如上代码Stacking部分已利用全量数据预测测试)。
由于篇幅有限,我们在本代码中对模型的参数并未做最优调整,只是一个示例流程。实际比赛要进一步调参以提高AUC。
十、结语
通过本次完整的复现指南,我们从数据理解、特征处理、不平衡处理、模型训练调优到集成融合,一步步走完了一个Kaggle竞赛高分方案所需的全部环节。我们结合具体代码演示和赛中经验分享,使读者不仅知其然也知其所以然。
对于 Porto Seguro’s Safe Driver Prediction 这类问题,成功的关键在于细致的EDA和特征工程、针对不平衡的妥善处理、多模型协同作战,以及严格的验证避免过拟合。正如一位参赛者总结的:比赛中学到的最大教训是要重视本地验证和避免过拟合Public LB 。希望读完本文后,读者能少走弯路,在类似挑战中取得更好的成绩。
机器学习建模没有银弹,靠的是系统化的方法和不断的试验改进。在Kaggle实战中磨炼的这些技能,将大大提升你在真实业务场景中解决问题的能力。从这个角度看,每场比赛都是一次宝贵的学习机会。愿大家在今后的比赛和项目中,能够举一反三,取得 Safe Driver 式的安全胜利!
祝各位Kaggler好运!努力练习,下一块金牌也许就是你的!