特征工程 Task3
数据预处理
数据EDA部分我们已经对数据的大概和某些特征分布有了了解,数据预处理部分一般我们要处理一些EDA阶段分析出来的问题,这里介绍了数据缺失值的填充,时间格式特征的转化处理,某些对象类别特征的处理。
首先我们查找出数据中的对象特征和数值特征
numerical_fea = list(data_train.select_dtypes(exclude=['object']).columns)
category_fea = list(filter(lambda x: x not in numerical_fea,list(data_train.columns)))
label = 'isDefault'
numerical_fea.remove(label)
缺失值的填充
- 把所有缺失值替换为指定的值0
data_train = data_train.fillna(0)
- 向用缺失值上面的值替换缺失值
data_train = data_train.fillna(axis=0,method='ffill')
参数说明:
fillna(): pandas 用于填充缺失值(NaN)的方法
axis=0: 沿着行的方向操作(按列填充)
method='ffill': forward fill,用前一个有效值填充当前的缺失值
例如:
A B
0 1.0 NaN (第一行前面没有值,保持 NaN)
1 1.0 10.0 (A 用 1 填充,B 用 10 填充)
2 1.0 10.0 (A 继续用 1 填充,B 继续用 10 填充)
3 4.0 10.0 (A 更新为 4,B 继续用 10 填充)
4 4.0 30.0 (A 继续用 4 填充,B 更新为 30)
- 纵向用缺失值下面的值替换缺失值,且设置最多只填充两个连续的缺失值
data_train = data_train.fillna(axis=0,method='bfill',limit=2)
参数说明:
fillna(): pandas 用于填充缺失值(NaN)的方法
axis=0: 沿着行的方向操作(按列填充)
method='bfill': backward fill,用后一个有效值填充当前的缺失值
limit=2: 限制最多连续填充 2 个 缺失值
import pandas as pd
import numpy as np
df = pd.DataFrame({
'A': [1, np.nan, np.nan, np.nan, 5, np.nan]
})
# 使用后向填充,limit=2
df_filled = df.fillna(axis=0, method='bfill', limit=2)
print("原始数据:")
print(df['A'].values)
# [ 1. nan nan nan 5. nan]
print("\n填充后 (bfill, limit=2):")
print(df_filled['A'].values)
# [1. 5. 5. nan 5. nan]
# ↑ ↑ ↑ ↑ ↑
# | | | | └─ 后面没有值,保持 NaN
# | | | └───── 5 保持不变
# | | └────────── 第 3 个 NaN,超过 limit=2,不填充
# | └────────────── 用后面的 5 填充(第 2 个)
# └───────────────── 用后面的 5 填充(第 1 个)
为什么选择中位数而不是均值?
中位数比均值更 robust(稳健):
- 其他填充
按照中位数填充数值型特征
data_train[numerical_fea] = data_train[numerical_fea].fillna(data_train[numerical_fea].median())
data_train[numerical_fea].median():计算训练集中每个数值特征的中位数
train_median = data_train[numerical_fea].median()
print("训练集中位数:")
print(train_median)
# power 165.0
# mileage 70000.0
# 填充训练集
data_train[numerical_fea] = data_train[numerical_fea].fillna(train_median)
print("\n填充后的训练集:")
print(data_train)
# power mileage
# 0 100.0 50000.0
# 1 150.0 70000.0 ← 用 70000 填充
# 2 165.0 80000.0 ← 用 165 填充
# 3 200.0 60000.0
# 4 180.0 70000.0 ← 用 70000 填充
# 5 165.0 90000.0 ← 用 165 填充
为什么选择中位数而不是均值?
中位数比均值更 robust(稳健):
示例:有极端值的情况
data = [100, 150, 180, 200, 10000] # 10000 是异常值
mean_value = data.mean() # 2126.0 ← 被异常值拉高
median_value = np.median(data) # 180.0 ← 不受异常值影响
按照众数填充类别型特征
data_train[category_fea] = data_train[category_fea].fillna(data_train[category_fea].mode())
众数是一组数据中出现次数最多的数值。
假设有一组人的年龄数据:
[25, 26, 26, 26, 30, 32, 45]
25 出现 1 次
26 出现 3 次
30 出现 1 次
…
众数就是 26。
众数、平均数、中位数的区别
| 指标 | 含义 | 优缺点 | 适用场景 |
|---|---|---|---|
| 平均数 | 所有数值之和除以个数 | 最常用,但怕极端值(被马云平均工资) | 数据分布均匀时 |
| 中位数 | 排序后排在中间的那个数 | 不怕极端值,稳健 | 收入、房价等分布不均时 |
| 众数 | 出现次数最多的数 | 唯一能用于分类数据(文本)的指标 | 统计“最畅销商品”、“最热门颜色” |
如果在数据 [1, 1, 2, 2, 3] 中,1 和 2 都出现了 2 次(且是最多次),那么这组数据有两个众数(双峰分布)。
如果所有数出现次数一样多(如 [1, 2, 3]),则没有众数。
类别型缺失值(如性别、学历、省份):必须用众数填补。
因为性别是“男/女”,不能算平均性别,也不能排序找中位数。只能看哪种性别最多,就填哪种。
时间格式处理
#转化成时间格式
for data in [data_train]:
data['issueDate'] = pd.to_datetime(data['issueDate'],format='%Y-%m-%d')
startdate = datetime.datetime.strptime('2007-06-01', '%Y-%m-%d')
#构造时间特征
data['issueDateDT'] = data['issueDate'].apply(lambda x: x-startdate).dt.days
对象类型特征转换到数值
将 employmentLength(工作年限)特征从字符串格式转换为整数格式。
def employmentLength_to_int(s):
if pd.isnull(s):
return s
else:
return np.int8(s.split()[0])
for data in [data_train, data_test_a]:
# 步骤 1: 将 '10+ years' 替换为 '10 years'
data['employmentLength'].replace(to_replace='10+ years', value='10 years', inplace=True)
# 步骤 2: 将 '< 1 year' 替换为 '0 years'
data['employmentLength'].replace('< 1 year', '0 years', inplace=True)
# 步骤 3: 应用转换函数
data['employmentLength'] = data['employmentLength'].apply(employmentLength_to_int)
# 统计每个值的数量,并按索引排序
data['employmentLength'].value_counts(dropna=False).sort_index()
for data in [data_train, data_test_a]:
data['earliesCreditLine'] = data['earliesCreditLine'].apply(lambda s: int(s[-4:]))
参数说明:
earliesCreditLine 格式为:Sep-2002;Dec-1996;May-2004;Nov-1995;Sep-2000
s[-4:]:提取字符串的最后 4 个字符(即年份部分
int():将提取的年份字符串转换为整数
原因 1:简化特征
原因 2:数值化便于模型处理
原因 3:可以进一步衍生其他特征
类别特征处理
# 像等级这种类别特征,是有优先级的可以labelencode或者自映射
for data in [data_train, data_test_a]:
data['grade'] = data['grade'].map({'A':1,'B':2,'C':3,'D':4,'E':5,'F':6,'G':7})
# 类型数在2之上,又不是高维稀疏的,且纯分类特征
for data in data_train:
data = pd.get_dummies(data, columns=['subGrade', 'homeOwnership', 'verificationStatus', 'purpose', 'regionCode'], drop_first=True)
对训练集中的特定分类特征进行独热编码(One-Hot Encoding)
pd.get_dummies(...): 这是 Pandas 进行独热编码的函数。
例如:purpose 列如果有 ['education', 'business'] 两种类别,编码后会变成两列 purpose_education 和 purpose_business,值为 0 或 1
drop_first=True: 删除第一列。
原因:避免多重共线性(Multicollinearity),也称为“虚拟变量陷阱”。
解释:如果一个特征有 N 个类别,独热编码会生成 N 列。但这 N 列的信息是冗余的(知道其中 N−1 列的值,就能推断出第 N 列)。对于线性回归、逻辑回归等模型,这会导致矩阵不可逆。设置为 True 后,只保留 N−1 列
注意: 如果类别太多,独热编码会导致特征维度爆炸,但既然不是高维,get_dummies 是安全且标准的做法
在使用 get_dummies 处理训练集和测试集时,还有一个常见的坑:特征列不一致。
如果 训练集 中 purpose 有 ['A', 'B', 'C'],而 测试集 中只有 ['A', 'B']。
get_dummies 会在训练集生成 3 列(drop_first 后 2 列),在测试集生成 2 列(drop_first 后 1 列)。
这会导致后续模型训练或预测时报错(特征维度不匹配)。
可以先将 训练集 和 测试集 上下拼接 (concat),统一进行 get_dummies,然后再拆分回去。这样可以保证特征列完全一致。
# 更稳健的做法
data_train['is_train'] = 1
data_test_a['is_train'] = 0
# 拼接
all_data = pd.concat([data_train, data_test_a], axis=0)
# 统一独热编码
all_data = pd.get_dummies(all_data, columns=['subGrade', 'homeOwnership', 'verificationStatus', 'purpose', 'regionCode'], drop_first=True)
# 拆分
data_train_processed = all_data[all_data['is_train'] == 1].drop('is_train', axis=1)
data_test_a_processed = all_data[all_data['is_train'] == 0].drop('is_train', axis=1)
异常值处理
发现异常值后,一定要先分清是什么原因导致的异常值,然后再考虑如何处理。
首先,如果这一异常值并不代表一种规律性的,而是极其偶然的现象,或者说你并不想研究这种偶然的现象,这时可以将其删除。
其次,如果异常值存在且代表了一种真实存在的现象,那就不能随便删除。
在现有的欺诈场景中很多时候欺诈数据本身相对于正常数据勒说就是异常的,我们要把这些异常点纳入,重新拟合模型,研究其规律。能用监督的用监督模型,不能用的还可以考虑用异常检测的算法来做。
注意: 测试集的数据不能删
基于3segama原则
检测异常的方法一:均方差
在统计学中,如果一个数据分布近似正态,那么大约 68% 的数据值会在均值的一个标准差范围内,大约 95% 会在两个标准差范围内,大约 99.7% 会在三个标准差范围内。
简单来说,这段话的意思是:在大多数自然现象和数据中,大部分数据都集中在平均值附近,离平均值越远的数据越少。
想象一座钟形状的山峰(钟形曲线)
山顶(最高点):是数据的 平均值(Mean),这里数据最密集。
山坡:向两边延伸,数据逐渐变少。
山脚(最边缘):数据非常稀少。
用数字量化了这座“山”的分布情况:
均值 ± 1 个标准差:包含了 68% 的数据。
意思是:大部分人都很普通,处于平均水平附近。
均值 ± 2 个标准差:包含了 95% 的数据。
意思是:绝大多数人都在这个范围内,只有 5% 的人比较极端。
均值 ± 3 个标准差:包含了 99.7% 的数据。
意思是:几乎所有人都在这个范围内。剩下的 0.3% 是极少数的“异类”。
举个具体的例子
假设某次考试的成绩服从正态分布:
平均分(均值) = 75 分
标准差 = 5 分
那么根据这段话的法则:
68% 的学生 分数在 70 ~ 80 分 之间(75 ± 5)。
95% 的学生 分数在 65 ~ 85 分 之间(75 ± 10)。
99.7% 的学生 分数在 60 ~ 90 分 之间(75 ± 15)。
如果有学生考了 95 分怎么办? 95 分超过了 90 分(均值 +3 个标准差)。根据法则,只有 0.3% 的可能性出现这种情况。因此,我们可以认为这个分数是 异常值(Outlier),或者这个学生是“天才”。
它在数据分析中有两个重要用途:
判断数据是否正常: 如果你发现数据不符合这个比例(例如只有 50% 的数据在 1 个标准差内),说明数据可能不服从正态分布,或者数据质量有问题。
识别异常值(即 3-Sigma 原则): 既然 99.7% 的数据都应该在 3 个标准差以内,那么超出这个范围的数据(那 0.3%),我们就有理由怀疑它是异常值(可能是录入错误、系统故障或特殊事件),需要单独处理。
重要前提
请注意这段话的第一句前提:“如果一个数据分布近似正态”。
如果数据是偏态分布(例如收入分布,大部分人穷,少数人极富),这个法则不适用。
如果数据不符合正态分布,就不能直接用 3 倍标准差来判定异常值(这时通常用箱线图 IQR 方法)。
def find_outliers_by_3segama(data,fea):
# 计算指定列(fea)的标准差(std)和平均值(mean).这是 3-Sigma 原则的基础参数。
data_std = np.std(data[fea])
data_mean = np.mean(data[fea])
# 确定边界
# outliers_cut_off:确定范围半径,即 3 倍标准差。
outliers_cut_off = data_std * 3
# lower_rule:计算下界(平均值 - 3倍标准差)。
lower_rule = data_mean - outliers_cut_off
# upper_rule:计算上界(平均值 + 3倍标准差)。
upper_rule = data_mean + outliers_cut_off
# 边界即是 X 轴上的数值边界
# 左边界 (lower_rule):在 X 轴左侧画一条竖线,位置是 平均值 - 3倍标准差。
# 右边界 (upper_rule):在 X 轴右侧画一条竖线,位置是 平均值 + 3倍标准差。
钟形曲线(正态分布)
^
| .. ..
| . .
正常区 | . .
| . .
| . .
____________|_______________________> X轴(数值)
| | |
左边界 平均值 右边界
(异常切分点) (异常切分点)
# 标记异常
data[fea+'_outliers'] = data[fea].apply(lambda x:str('异常值') if x > upper_rule or x < lower_rule else '正常值')
# 新建一列:名字叫 原列名_outliers。
# 如果 x 大于上界,或者 x 小于下界 -> 标记为 ‘异常值’。
# 否则 -> 标记为 ‘正常值’。
return data
# 进一步分析变量异常值和目标变量 违约率(isDefault) 的关系
# numerical_fea:所有数值型特征的列表,对每个特征都执行异常值检测
for fea in numerical_fea:
# 步骤 1: 对每个数值特征检测异常
data_train = find_outliers_by_3segama(data_train,fea)
# 步骤 2: 统计正常值和异常值的数量
print(data_train[fea+'_outliers'].value_counts())
# 步骤 3: 分析异常值组的违约率(isDefault)
print(data_train.groupby(fea+'_outliers')['isDefault'].sum())
print('*'*10)
正常值 800000
Name: term_outliers, dtype: int64
term_outliers
正常值 159610
Name: isDefault, dtype: int64
**********
# interestRate(利率)
正常值 794259 --> 总数80万中正常值数据
异常值 5741 --> 总数80万中异常值数据
Name: interestRate_outliers, dtype: int64
interestRate_outliers
异常值 2916 --> 异常值数据中 isDefault=1 的有 2916.即5741人中有 2916 人违约
正常值 156694 --> 正常值数据中 isDefault=1 的有 156694.即794259人中有 156694 人违约
Name: isDefault, dtype: int64
#删除异常值(不能全部删除.要保留强因子特征,如interestRate)
for fea in numerical_fea:
data_train = data_train[data_train[fea+'_outliers']=='正常值']
data_train = data_train.reset_index(drop=True)
可以看到异常值在两个变量上的分布几乎符合整体的分布,如果异常值都属于为 isDefault=1 的用户数据里面代表什么呢?
上方 使用 3-Sigma 原则 检测异常值后,异常值群体与目标变量 isDefault(违约标签,1 代表违约,0 代表正常)之间的关系。
这代表“异常”本身就是“风险信号”
如果在某个特征上,被判定为“异常值”的样本,其 isDefault 的比例远高于正常值样本(甚至全是 1),这说明:
该特征的极端值具有极强的预测能力。
这个“异常”不是噪音,而是业务逻辑中的高风险特征。
绝对不能直接删除这些异常值! 删除它们等于删除了模型最能识别风险的一部分信息。
例如:
interestRate(利率) 为例:
正常值群体:794,259 人,违约 156,694 人 → 违约率 ≈ 19.7%
异常值群体:5,741 人,违约 2,916 人 → 违约率 ≈ 50.8%
解读: 利率极高的用户(异常值),违约概率是普通用户的 2.5 倍。
代表什么? 代表高利率本身就是高风险的体现(可能是因为用户信用差,所以被赋予了高利率,进而导致违约)。
如何处理? 不要删除。应该保留,或者将其转化为一个强特征(例如:is_high_interest_rate 标记)。
基于箱型图
四分位数会将数据分为三个点和四个区间,IQR = Q3 -Q1,下触须=Q1 − 1.5x IQR,上触须=Q3 + 1.5x IQR;
IQR 法则(四分位距法则),也是箱线图(Boxplot) 绘制的基础逻辑。
如果说之前的 3-Sigma 原则 是“正态分布专属”的异常值检测,那么这个 IQR 法则 就是**“万能型”**的异常值检测。
四分位数(Quartiles):
Q1 (下四分位数):排在第 25% 位置的数值。
Q2 (中位数):排在第 50% 位置的数值。
Q3 (上四分位数):排在第 75% 位置的数值。
这三个点确实将数据分成了四个区间,每个区间包含约 25% 的数据。
IQR (Interquartile Range,四分位距):
公式:IQR=Q3−Q1
含义:它代表了数据中间 50% 部分的离散程度。相比标准差,它不受极端值影响(更稳健)。
触须/界限(Whiskers / Fences):
下界 = Q1−1.5×IQR
上界 = Q3+1.5×IQR
判定规则:任何小于下界或大于上界的数据,都被视为异常值(Outliers)。
def find_outliers_by_iqr(data, fea):
# 1. 计算上下四分位数
# quantile(0.25) 代表 Q1,quantile(0.75) 代表 Q3
# Pandas 自带的分位数计算函数。这比手动排序再去取第几行的数据要高效且简单
Q1 = data[fea].quantile(0.25)
Q3 = data[fea].quantile(0.75)
# 2. 计算 IQR (四分位距)
IQR = Q3 - Q1
# 3. 确定边界 (箱线图的触须位置)
# 1.5这是箱线图判断异常的标准系数(通常取 1.5)。如果是极端异常值,有时也会取 3.0,但在风控清洗中 1.5 最常用。
# 下界 = Q1 - 1.5 * IQR
lower_rule = Q1 - 1.5 * IQR
# 上界 = Q3 + 1.5 * IQR
upper_rule = Q3 + 1.5 * IQR
# 4. 标记异常值
# 逻辑与 3-Sigma 一致:超出上下界即为异常
data[fea+'_outliers'] = data[fea].apply(lambda x: '异常值' if x > upper_rule or x < lower_rule else '正常值')
return data
<--异常值--> <--异常值-->
● ●
| |
---------[--------|-----|-------]---------
^ ^ ^ ^
下触须 Q1 Q2 Q3 上触须
(下界限) (上界限)
四个区间:
最小值 ~ Q1(包含 25% 的数据)
Q1 ~ Q2(包含 25% 的数据)
Q2 ~ Q3(包含 25% 的数据)
Q3 ~ 最大值(包含 25% 的数据)
数据分箱
特征分箱的目的:
从模型效果上来看,特征分箱主要是为了降低变量的复杂性,减少变量噪音对模型的影响,提高自变量和因变量的相关度。从而使模型更加稳定。
数据分箱的对象:
1. 将连续变量离散化
2. 将多状态的离散变量合并成少状态
分箱的原因:
数据的特征内的值跨度可能比较大,对有监督和无监督中如k-均值聚类它使用欧氏距离作为相似度函数来测量数据点之间的相似度。都会造成大吃小的影响,其中一种解决方法是对计数值进行区间量化即数据分桶也叫做数据分箱,然后使用量化后的结果。
分箱的优点:
处理缺失值:
当数据源可能存在缺失值,此时可以把null单独作为一个分箱。
处理异常值:
当数据中存在离群点时,可以把其通过分箱离散化处理,从而提高变量的鲁棒性(抗干扰能力)。例如,age若出现200这种异常值,可分入“age > 60”这个分箱里,排除影响。
业务解释性:
我们习惯于线性判断变量的作用,当x越来越大,y就越来越大。但实际x与y之间经常存在着非线性关系,此时可经过WOE变换。
特别要注意一下分箱的基本原则:
(1)最小分箱占比不低于5%
(2)箱内不能全部是好客户
(3)连续箱单调
固定宽度分箱
当数值横跨多个数量级时,最好按照 10 的幂(或任何常数的幂)来进行分组:0~9, 10~99, 100~999, 1000~9999,等等。固定宽度分箱非常容易计算,但如果计数值中有比较大的缺口,就会产生很多没有任何数据的空箱子。
# 通过除法映射到间隔均匀的分箱中,每个分箱的取值范围都是loanAmnt/1000
data['loanAmnt_bin1'] = np.floor_divide(data['loanAmnt'], 1000)
# 这行代码和上方代码效果完全一样
data['loanAmnt_bin1'] = data['loanAmnt'] // 1000
# 降低精度噪音:贷款 3500 和 3510 其实没太大区别,直接归为“第3档”能减少模型学习的压力。
# 数值平滑:将连续的数值变成离散的组别,有助于让模型更稳健。
# 方便计算 WOE:分箱是计算 WOE(证据权重)的前提步骤。
分位数分箱
当数值横跨多个数量级时,最好按照 10 的幂(或任何常数的幂)来进行分组:09、1099、100999、10009999,等等。固定宽度分箱非常容易计算,但如果计数值中有比较大的缺口,就会产生很多没有任何数据的空箱子。
# 通过对数函数映射到指数宽度分箱,将金额转换为 千元级,万元级,十万元级
data['loanAmnt_bin2'] = np.floor(np.log10(data['loanAmnt']))
# 将连续的借款金额(loanAmnt)按照“数量级”进行离散化处理,而不是按照固定的金额间隔(如每 1000 元一档)进行处理。
# np.log10(data['loanAmnt']):
# 计算借款金额的 常用对数(以 10 为底)。
# 作用:将巨大的数值差距压缩。例如,1,000 和 1,000,000 相差 999,000,但对数后分别是 3 和 6,只差 3。
# np.floor(...):
# 向下取整。
# 作用:将对数后的连续小数变成整数,实现“分箱”。
| 原始借款金额 (loanAmnt) | 计算 log10 | 向下取整 (floor) | 新特征值 (bin) | 含义 |
|---|---|---|---|---|
| 1,000 | 3.0 | 3 | 3 | 千元级 |
| 5,000 | 3.69 | 3 | 3 | 千元级 |
| 9,999 | 3.99 | 3 | 3 | 千元级 |
| 10,000 | 4.0 | 4 | 4 | 万元级 |
| 50,000 | 4.69 | 4 | 4 | 万元级 |
| 100,000 | 5.0 | 5 | 5 | 十万元级 |
| 1,000,000 | 6.0 | 6 | 6 | 百万元级 |
如果你使用的是 树模型(XGBoost, LightGBM, CatBoost),它们本身就能很好地处理数值特征的偏态和异常值,通常不需要手动做对数分箱,直接传入原始值或简单的 log 变换即可。
如果你使用的是 线性模型(Logistic Regression) 或 神经网络,这种分箱或 log 变换非常有帮助。
离散数值型数据分箱(降维)
数据可以在一个区间内取任意值(通常带小数点),理论上有无限个可能的值或者极其稠密的整数。
例如 annualIncome(年收入 53421.5 元)、interestRate(利率 12.35%)、dti(债务收入比 15.6)
等宽分箱:每个箱的区间长度一样
等频分箱:每个箱里的样本数量一样
对数分箱:即之前看到的 np.floor(np.log10(x))按数量级分 [1 千 -1 万], [1 万 -10 万].
卡方分箱/决策树分箱:根据风险表现自动寻找切分点。
自定义业务分箱: 根据业务经验划分。例如:age [18-25], [26-35], [36-50], [50+]。
连续数值型数据分箱(合并)
数据本身就是整数,代表数量或等级,没有小数点,且数值范围是有限的(或者说是可数的)。
例如 家庭成员数量(1, 2, 3, 4, 5…)、信用评分等级(1级, 2级, 3级…)、去一年的逾期次数(0次, 1次, 2次, 3次… 50次)
卡方分箱&决策树分箱
决策树分箱
决策树分箱 是 “自顶向下” (Top-Down):利用决策树算法(如 CART 树),自动寻找最佳的切分点。决策树的每一个节点分裂,其实就是一次分箱过程。先把所有数据当成一个大箱,然后不断切分出差异最大的箱。
假设你有一堆西瓜(样本),你要根据“重量”来分箱,目标是切开看里面是“好瓜”还是“坏瓜”。
你拿刀试着切。
第一刀:你发现 3 斤是个坎,3 斤以下的大部分是生瓜,3 斤以上熟一点。好,就在 3 斤切一刀!
第二刀:你再看 3 斤以上的瓜,发现 8 斤又是个坎,8 斤以上的容易空心。好,再在 8 斤切一刀!
结果,你的分箱边界是:[<3斤], [3-8斤], [>8斤]。这三个箱子里,好瓜坏瓜的比例差异最大,区分能力最强。
缺点:
不保证单调性:这是决策树分箱最大的坑。
它切出来的坏账率可能是:高 -> 低 -> 高(U型)。
这种分箱如果放进逻辑回归,解释性会很差(逻辑回归喜欢单调的线)。
**解决办法**:如果用逻辑回归,尽量限制树的深度,或者后期人工合并调整;如果用 XGBoost/LightGBM 模型,则不需要在乎单调性。
边界不稳定:如果数据变化大,树的切分点可能会变。
from sklearn.tree import DecisionTreeClassifier
def decision_tree_bin(data, fea, target, max_leaf_num=5):
"""
利用决策树进行分箱
:param data: 数据集
:param fea: 特征名
:param target: 目标变量名 (通常是 label: 0/1) 例如 isDefault(是否违约)
:param max_leaf_num: 最多分几个箱子 意味着最多分成 5 个箱
:param min_samples_leaf: 控制叶子节点最少样本量,防止过拟合 防止切出只有几个样本的“怪箱子”。通常设为总样本的 5% 左右。如果某个箱子样本太少,统计规律就没有意义。
:param min_impurity_decrease: 不纯度下降阈值,如果切一刀带来的纯度提升很小,就不切了。这可以用来过滤掉无意义的切分。
:return: 分箱边界列表
"""
# 1. 准备数据
X = data[[fea]].fillna(0) # 简单填充缺失值
y = data[target]
# 2. 训练决策树
# max_leaf_nodes 控制最多分成几个箱
# min_samples_leaf 控制叶子节点最少样本量,防止过拟合
dt = DecisionTreeClassifier(max_leaf_nodes=max_leaf_num, min_samples_leaf=0.05)
dt.fit(X, y)
# 3. 提取阈值
# dt.tree_.threshold 存储了所有节点的切分阈值
# 注意:tree_.threshold 里包含了叶子节点的默认值-2,需要过滤掉
thresholds = dt.tree_.threshold[dt.tree_.threshold != -2]
# 4. 整理边界
# 加上无穷大和无穷小,构成完整的区间
bins = sorted(list(thresholds))
bins = [-float('inf')] + bins + [float('inf')]
return bins
# 假设我们要把 'loanAmnt' 根据 'isDefault'(是否违约) 进行分箱
# bins = decision_tree_bin(data, 'loanAmnt', 'isDefault', max_leaf_num=4)
# 这个 bins 就是“刀痕”.说明数据要在那里切.而 pd.cut 负责“动刀切肉”.
# print("分箱边界:", bins)
# 然后配合 pd.cut 使用: data['loanAmnt_tree_bin'] = pd.cut(data['loanAmnt'], bins=bins)
卡方分箱
一种基于卡方检验的自底向上合并方法。
初始时,将每个数值看作一个独立的箱子。
计算相邻箱子的卡方值(卡方值越小,说明两个箱子的违约率越相似)。
将卡方值最小的相邻箱子合并。
重复上述步骤,直到满足停止条件(如箱子数量达到指定值)。
toad库,scorecardpy 库,optbinning库 . 这三个库的卡方分箱有什么区别
| 特性 | toad | scorecardpy | optbinning |
|---|---|---|---|
| 分箱算法核心 | 传统 卡方合并 (Chi-Merge) 为主 | 传统 卡方合并 或 ** CART 决策树** | 最优分箱 (Optimal Binning) (基于动态规划 + 混合整数规划) |
| 数学严谨性 | 中等 (启发式合并) | 中等 (启发式合并) | 高 (全局最优解) |
| 单调性约束 | 支持,但有时收敛不稳定 | 支持,但控制力度一般 | 最强 (可强制严格单调) |
| 定位 | 本土化风控工具包 | 评分卡全流程工具 (R 版移植) | 通用数学优化分箱库 |
toad (TrustData Analytics Deck)
背景:由中国风控社区开发,非常贴合国内金融业务场景。
分箱特点:
使用 Combiner 类进行分箱,默认支持卡方合并。
优点:API 极其简单,与 Pandas 结合紧密,文档中文友好,社区活跃(国内)。
缺点:算法灵活性不如 optbinning,在处理复杂约束(如同时约束最小样本率和单调性)时可能不如后者稳定。
适用:快速搭建国内标准的评分卡,适合大多数风控从业者。
# 需要先安装库: pip install toad
import toad
def chi2_bin(data, fea, target, max_leaf_num=5):
"""
利用 toad 库进行卡方分箱
"""
# 1. 初始化分箱器
combiner = toad.transform.CombinTransformer()
# 2. 训练分箱规则
# method='chi': 指定使用卡方分箱
# min_samples: 每个箱最少样本占比
combiner.fit(data[[fea, target]], target, method='chi', n_bins=max_leaf_num)
# 3. 获取分箱边界
# 返回的是字典格式,key是特征名
bins = combiner.export()[fea]
return bins
# --- 使用示例 ---
# bins = chi2_bin(data, 'loanAmnt', 'isDefault', max_leaf_num=4)
# print("分箱边界:", bins)
# 应用分箱: data['loanAmnt_chi_bin'] = combiner.transform(data[[fea]])[fea]
optbinning
背景:国际通用的数学优化库,不局限于风控,任何需要分箱的 ML 场景都可用。
分箱特点:
使用 OptimalBinning 类。
优点:算法最强。能严格保证单调性(monotonic_trend='auto'),能处理缺失值,能设定最小/最大箱数,能设定最小样本占比。它的分箱结果通常 IV 值更高,更稳定。
缺点:安装有时较麻烦(依赖求解器),学习曲线稍陡,不包含评分卡生成的后续步骤(只负责分箱)。
适用:对模型效果要求极高,或者传统分箱方法无法满足单调性约束的场景。
from optbinning import OptimalBinning
# 定义分箱器
optb = OptimalBinning(
name='loanAmnt',
dtype="numerical",
solver="cp", # 使用约束规划求解器
monotic_trend="auto", # 自动检测并强制单调性!(这是它的核心优势)
max_n_bins=5 # 最大箱数
)
# 训练
optb.fit(data['loanAmnt'], data['isDefault'])
# 查看分箱表
print(optb.binning_table.build())
# 强制画图看单调性
optb.binning_table.plot()
特征交互
交互特征的构造非常简单,使用起来却代价不菲。如果线性模型中包含有交互特征对,那它的训练时间和评分时间就会从 O(n) 增加到 O(n2),其中 n 是单一特征的数量。
特征和特征之间组合
# 循环处理:对 grade(主等级)和 subGrade(子等级)这两个类别特征进行处理。
for col in ['grade', 'subGrade']:
# 计算统计量(制作“字典”):
# groupby([col]):按等级分组。
# ['isDefault'].agg(['mean']):计算每个等级下,违约标签 isDefault 的平均值。
# 因为违约标签是 0(好人)和 1(坏人),平均值其实就是违约率。
# 比如:A等级有100人,5人违约,均值就是 0.05。
# reset_index().rename(...):整理数据格式,把计算出来的均值列改名为 grade_target_mean
temp_dict = data_train.groupby([col])['isDefault'].agg(['mean']).reset_index().rename(columns={'mean': col + '_target_mean'})
# 生成映射字典:
# 把等级(如 ‘A’, ‘B’)作为索引。
# 把刚才算出来的均值列转换成 Python 的字典格式。
# 结果示例:{'A': 0.05, 'B': 0.12, 'C': 0.25, ...}。这就做好了“等级”到“违约风险”的映射表。
temp_dict.index = temp_dict[col].values
temp_dict = temp_dict[col + '_target_mean'].to_dict()
# 应用映射(查字典):
# 使用 map 函数,根据刚才做好的字典,把原始数据中的 ‘A’, ‘B’ 替换成对应的数值。
# 注意:训练集和测试集使用的是同一个 temp_dict。这一点至关重要。
data_train[col + '_target_mean'] = data_train[col].map(temp_dict)
data_test_a[col + '_target_mean'] = data_test_a[col].map(temp_dict)
为什么要这么做?
(1) 解决“类别无法排序”的问题
grade 是类别特征,原本是 ‘A’, ‘B’, ‘C’。
如果直接变成数字 1, 2, 3,模型会误以为 ‘C’(3) 比 ‘A’(1) 大三倍,这是没有道理的。
目标编码后:‘A’ 变成 0.05(低风险),‘C’ 变成 0.25(高风险)。数值大小直接代表了风险高低,赋予了类别特征真实的业务含义。
(2) 防止数据泄露
temp_dict 是只用训练集 (data_train) 计算出来的。
测试集 (data_test_a) 只是被动地使用这个字典去映射 (map)。
如果你用全量数据(训练+测试)去算均值,那就是作弊,叫做数据泄露。这段代码避开了这个坑。
假设训练集数据如下:
| grade | isDefault |
|---|---|
| A | 0 |
| A | 1 |
| B | 1 |
| B | 1 |
执行过程:
计算均值:
A等级:一个0一个1,均值 = 0.5
B等级:两个1,均值 = 1.0
生成字典:{'A': 0.5, 'B': 1.0}
映射:
训练集和测试集中所有的 ‘A’ 都会被替换成 0.5。
所有的 ‘B’ 都会被替换成 1.0。
特征和特征之间衍生
这段代码是在做特征交互,具体来说是构建了“相对性特征”。
# 其他衍生变量 mean 和 std
for df in [data_train, data_test_a]:
for item in ['n0','n1','n2','n2.1','n4','n5','n6','n7','n8','n9','n10','n11','n12','n13','n14']:
# groupby([item]):把数据按照 item(比如 n0)进行分组。
# transform('mean'):这是 Pandas 的高级用法。
# 普通的 groupby + mean 会把数据聚合成几行(每组一行)。
# transform 不会减少行数。它计算每组的平均值,然后把这个平均值**广播(填充)**回该组的每一行。
# *** 简单说:给每一行数据打上了一个标签,这个标签是“它所在小组的平均等级 ***
df['grade_to_mean_' + item] = df['grade'] / df.groupby([item])['grade'].transform('mean')
df['grade_to_std_' + item] = df['grade'] / df.groupby([item])['grade'].transform('std')
# 这段代码里最难懂但也最核心的一行是: df.groupby([item])['grade'].transform('mean')
以 item = 'n0' 为例:
第一步: df.groupby([item])['grade'].transform('mean') # 计算分母(组内平均等级)
假设 n0 代表“地区编号”。
如果 n0=1 的地区有 3 个人,grade 分别是 A(1), B(2), C(3)。
那么这个组的平均等级是 2。
transform 会让这 3 行数据都得到数值 2。
第二步:df['grade_to_mean_' + item] = df['grade'] / (第一步的结果) # 计算衍生特征(比率)
这行代码在做:“我的等级” / “我所在组的平均等级”。
|n0(地区)|grade(我的等级)| 组平均等级 | 结果 | 含义
| 1 | 3 (C级) | 2 (平均B级) | 1.5 | 我比同地区平均水平差 (等级C比B差)
| 1 | 1 (A级) | 2 (平均B级) | 0.5 | 我比同地区平均水平好
| 2 | 5 (E级) | 5 (平均E级) | 1.0 | 我处于平均水平
特征编码
| 特征类型 | 推荐编码方式 | 原因 |
|---|---|---|
| 有大小关系的类别(如: 小、中、大; 小学、初中、高中) | Label Encoding (或者 Ordinal Encoding) | 1 < 2 < 3,这种顺序是有意义的,应该保留 |
| 无大小关系且类别少(如: 性别、季节、血型; 类别 < 10个) | One-Hot Encoding | 类别少,不会造成维度灾难,且能保证模型不产生误解 |
| 无大小关系且类别多(如: 邮编、省份、用户ID; 类别 > 20个) | Target Encoding (均值编码)或 Label Encoding (仅限树模型) | One-Hot 会炸内存。对于树模型,可以直接用 Label Encoding(树模型不看重数值大小,只看分裂点);对于线性模型,推荐 Target Encoding |
one-hot编码
在 类别特征处理 中使用了One-Hot.这里在说一下
One-Hot Encoding(独热编码) 是处理类别特征最经典、最常用的方法。
如果说 Label Encoding(标签编码)是把类别“压缩”成数字,那么 One-Hot Encoding 就是把类别“展开”成二进制开关。
假设有一个特征 颜色,它有 3 个取值:[红色, 绿色, 蓝色]。
| 原始数据 | 编码后的结果 (One-Hot) |
|---|---|
| 红色 | 1, 0, 0 (红色的开关打开,其他关闭) |
| 绿色 | 0, 1, 0 (绿色的开关打开,其他关闭) |
| 蓝色 | 0, 0, 1 (蓝色的开关打开,其他关闭) |
在实际数据表中,它会变成三列新特征:颜色_红、颜色_绿、颜色_蓝。
它的核心价值在于消除了“数值大小”带来的误导。
对比 Label Encoding:
如果我们用 Label Encoding 处理上面的 颜色:
红色 = 1
绿色 = 2
蓝色 = 3
问题来了:
模型(特别是线性回归、逻辑回归、神经网络)会误以为:蓝色(3) 比 红色(1) 大,甚至 蓝色 是 红色 的 3 倍。它会在模型里学习一个权重 w,试图计算 w × 3。
但实际上,红色和绿色只是不同的类别,没有大小之分,也没有顺序。
One-Hot 的优势:
红色 = [1, 0, 0]
绿色 = [0, 1, 0]
蓝色 = [0, 0, 1]
在几何空间上,这三个向量是正交的。它们之间的距离相等,没有谁比谁大,谁比谁小。这完美符合类别特征的本质。
主要有两种方法,推荐使用 Pandas 的 get_dummies(最简单)或 Sklearn 的 OneHotEncoder(适合生产环境)。
import pandas as pd
# 假设 data 是你的数据框
# 对 'color' 列进行独热编码
data = pd.get_dummies(data, columns=['color'], prefix='color')
# prefix 是给新列加前缀,比如生成的列叫 'color_红', 'color_绿'
##############################################################################
from sklearn.preprocessing import OneHotEncoder
enc = OneHotEncoder()
# 这里的 X 需要是二维数组
X = [['红色'], ['绿色'], ['蓝色']]
# 训练并转换
result = enc.fit_transform(X).toarray()
print(result)
# 输出: [[1. 0. 0.], [0. 1. 0.], [0. 0. 1.]]
One-Hot 虽然好,但有一个巨大的副作用:太占地方了。
场景:如果你的特征是 邮编,全国有 3000 多个邮编。
Label Encoding:只需要 1 列。
One-Hot Encoding:会瞬间生成 3000 列 新数据。
后果:
内存爆炸:数据表变得极其巨大,电脑跑不动。
稀疏矩阵:这 3000 列里,绝大部分都是 0,只有一个是 1。数据非常稀疏。
过拟合:对于线性模型,特征太多容易导致过拟合( Curse of Dimensionality,维度灾难)。
树模型表现差:对于决策树、XGBoost 等树模型,One-Hot 会把一个本该在一起分裂的特征(邮编)切得稀碎,导致树分裂效率极低,模型效果反而变差。
label-encode编码
# 对高基数类别特征进行标签编码
# 使用 tqdm 显示进度条,因为这些特征可能数据量很大
for col in tqdm(['employmentTitle', 'postCode', 'title','subGrade']):
# 步骤 1: 创建 LabelEncoder 对象
le = LabelEncoder()
# 步骤 2: 在训练集和测试集的并集上拟合
le.fit(list(data_train[col].astype(str).values) + list(data_test_a[col].astype(str).values))
# 步骤 3: 转换训练集
data_train[col] = le.transform(list(data_train[col].astype(str).values))
# 步骤 4: 转换测试集
data_test_a[col] = le.transform(list(data_test_a[col].astype(str).values))
print('Label Encoding 完成')
# 把原本是文本的类别(如 “Manager”, “Engineer” 或者邮编 “10001”),转换成数字(如 0, 1, 2…),让计算机能够读懂。
为什么要写这段代码?
employmentTitle(职称)、postCode(邮编)、title 等特征,它们的类别非常多(可能有几千甚至上万个不同的值)。
如果用 One-Hot Encoding(独热编码),会产生几千列新特征,导致数据极其稀疏,计算量爆炸,这就是“维度灾难”。
许多模型(如 Sklearn 的逻辑回归、神经网络、XGBoost、LightGBM)输入数据必须是数字,不能是字符串。
使用 Label Encoding,给每个不同的类别分配一个唯一的整数 ID。
为什么步骤2 要在训练集和测试集的并集上拟合?
因为要确保训练集和测试集使用相同的映射关系
假设训练集里没有 “邮编 88888”。
如果只用训练集 fit,模型认识的字典里就没有 “88888”。
当你处理测试集时,遇到了 “88888”,程序就会报错,因为它不知道该转成哪个数字。
把训练集和测试集拼在一起 fit,保证了所有可能出现过的类别,都被编入了字典,后续转换绝对不会报错。
为什么要 .astype(str)?
要确保所有值都是字符串类型,避免类型不一致
# 可能有混合类型
mixed_data = ['Engineer', 123, None, 'Manager']
# 转为字符串后统一处理
str_data = ['Engineer', '123', 'nan', 'Manager']
transform 是什么意思?
fit 是“学习字典”(比如:{‘A’:0, ‘B’:1, ‘C’:2})。
transform 是“查字典替换”(把数据里的 ‘A’ 全部换成 0,‘B’ 换成 1)。
注意事项(模型的坑)
虽然 Label Encoding 解决了维度爆炸的问题,但它引入了一个“虚假的序关系”。
模型可能会误以为:类别 2(邮编 30003)比类别 0(邮编 10001)大,或者它们之间有距离关系。
对于树模型(XGBoost, LightGBM, CatBoost):它们很聪明,能自动处理这种类别编码,通常能很好地工作。
对于线性模型(逻辑回归 LR):这种编码方式通常是错误的。因为 LR 会认为数值越大权重越大,这不符合逻辑。LR 处理这种高维特征通常不用 Label Encoding,而是用 One-Hot 或者前面提到的 Target Encoding。
Label Encoding 原理
from sklearn.preprocessing import LabelEncoder
# 原始数据
data = pd.DataFrame({
'subGrade': ['A1', 'A2', 'B1', 'A1', 'C1', 'B2', 'A2']
})
# 创建并拟合 LabelEncoder
le = LabelEncoder()
le.fit(data['subGrade'].astype(str).values)
# 转换
data['subGrade_encoded'] = le.transform(data['subGrade'].astype(str).values)
print("原始值:", data['subGrade'].values)
print("编码后:", data['subGrade_encoded'].values)
print("类别映射:", dict(zip(le.classes_, le.transform(le.classes_))))
# 输出:
# 原始值:['A1' 'A2' 'B1' 'A1' 'C1' 'B2' 'A2']
# 编码后:[0 1 2 0 3 4 1]
# 类别映射:{'A1': 0, 'A2': 1, 'B1': 2, 'C1': 3, 'B2': 4}
逻辑回归等模型要单独增加的特征工程
逻辑回归(LR) 和 树模型(如 XGBoost、决策树) 在特征工程上的要求截然不同。
逻辑回归对多重共线性非常敏感。
对特征做归一化,去除相关性高的特征
归一化目的是让训练过程更好更快的收敛,避免特征大吃小的问题
去除相关性是增加模型的可解释性,加快预测过程。
| 维度 | 逻辑回归 (LR) | 树模型 |
|---|---|---|
| 归一化 | 必须做。影响收敛速度、权重解释、正则化效果 | 不需要做。树模型只看数值排序和切分点,对数值大小不敏感 |
| 相关性 | 必须处理。会导致系数不稳定、方差膨胀 | 相对宽容。树模型会自动选择其中一个特征进行分裂,另一个特征的作用会被掩盖,但不会报错 |
| 类别特征 | 必须做 One-Hot 或 Target Encoding | 支持 Label Encoding,甚至可以直接处理类别(如 CatBoost/LightGBM) |
| 缺失值 | 通常不能直接处理,需填充 | 可以直接处理(学习缺失值方向) |
逻辑回归是一个“娇气”的模型,需要通过特征工程把数据“喂”成标准的样子(归一化、低相关、无缺失),它才能发挥最大威力。而树模型是“皮实”的模型,对数据质量要求低很多。
归一化
Min-Max 归一化,将数据压缩到 [0, 1] 区间。
# 手动实现
import numpy as np
import pandas as pd
# 假设我们要处理 'loanAmnt' 列
fea = 'loanAmnt'
# 注意:这里有个细节,为了防止分母为0,通常会加一个极小值 1e-10
data[fea + '_minmax'] = (data[fea] - np.min(data[fea])) / (np.max(data[fea]) - np.min(data[fea]) + 1e-10)
# Sklearn 标准实现通常使用 MinMaxScaler 或者 StandardScaler(Z-Score标准化),因为它们能更好地处理测试集的转换。
from sklearn.preprocessing import MinMaxScaler, StandardScaler
# Min-Max 归一化 (0-1)
scaler = MinMaxScaler()
data[fea + '_minmax'] = scaler.fit_transform(data[[fea]])
# Z-Score 标准化 (均值0,方差1) —— 逻辑回归中更常用
# 适用于数据有明显离群点的情况
scaler_std = StandardScaler()
data[fea + '_std'] = scaler_std.fit_transform(data[[fea]])
损失函数的形状(等高线图):
如果不归一化,特征 A 范围是 0-1,特征 B 范围是 0-100000。
损失函数的等高线会变成一个非常扁平的椭圆。梯度下降算法会在椭圆里来回震荡,走“之”字形,很难找到最低点。
归一化后,等高线变成了正圆,梯度下降可以直接冲向圆心,收敛速度极快。
正则化惩罚项:
逻辑回归通常带有 L1 或 L2 正则化。
如果不归一化,取值大的特征(如金额)对应的权重系数会非常小。正则化项为了惩罚大系数,会误判这个特征不重要,或者导致权重系数被压缩得过小,影响模型效果。
如何找出并删除相关性高的特征
通常的做法是计算相关系数矩阵,然后剔除其中之一
def remove_correlated_features(data, threshold=0.9):
"""
删除相关性超过阈值的特征
:param threshold: 相关性阈值,通常设为 0.9 或 0.85
:return: 剔除后的特征列表
"""
# 1. 计算相关系数矩阵
corr_matrix = data.corr().abs()
# 2. 提取上三角矩阵(避免重复处理)
upper = corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool))
# 3. 找出相关性大于阈值的列
to_drop = [column for column in upper.columns if any(upper[column] > threshold)]
print(f"需要删除的高相关特征: {to_drop}")
# 4. 删除特征
return data.drop(columns=to_drop)
# 使用示例
# data = remove_correlated_features(data, threshold=0.9)
特征选择
特征选择技术可以精简掉无用的特征,以降低最终模型的复杂性,它的最终目的是得到一个简约模型,在不降低预测准确率或对预测准确率影响不大的情况下提高计算速度。特征选择不是为了减少训练时间(实际上,一些技术会增加总体训练时间),而是为了减少模型评分时间。
1 Filter
基于特征间的关系进行筛选
方差选择法
方差选择法中,先要计算各个特征的方差,然后根据设定的阈值,选择方差大于阈值的特征
from sklearn.feature_selection import VarianceThreshold
#其中参数threshold为方差的阈值,删除方差小于 3 的特征
VarianceThreshold(threshold=3).fit_transform(train)
核心思想:
方差低的特征 → 取值变化小 → 包含信息少 → 对模型预测贡献小
方差高的特征 → 取值变化大 → 包含信息多 → 可能更重要
手动计算方差:
总体方差计算步骤:
如果使用总体方差适用于你拥有全部数据(即这个列表就是全部数据),
- 计算平均值 (μ)
- 计算每个数据点与平均值的差(xᵢ− μ)
- 计算差值的平方 ( x i − μ ) 2 (xᵢ− μ)^2 (xi−μ)2
- 计算平方和
- 计算方差 (
σ
2
σ^2
σ2)
公式为:

# 步骤 1:计算均值(μ)
μ = (1 + 2 + 1 + 2 + 1) / 5
= 7 / 5
= 1.4
# 步骤 2:计算每个数据点与均值的离差
x₁ = 1, 离差 = 1 - 1.4 = -0.4
x₂ = 2, 离差 = 2 - 1.4 = +0.6
x₃ = 1, 离差 = 1 - 1.4 = -0.4
x₄ = 2, 离差 = 2 - 1.4 = +0.6
x₅ = 1, 离差 = 1 - 1.4 = -0.4
# 步骤 3:离差平方
(-0.4)² = 0.16
(+0.6)² = 0.36
(-0.4)² = 0.16
(+0.6)² = 0.36
(-0.4)² = 0.16
# 步骤 4:离差平方和
Σ(xᵢ - μ)² = 0.16 + 0.36 + 0.16 + 0.36 + 0.16
= 1.20
# 步骤 5:计算方差
σ² = 1.20 / 5 = 0.2
样本方差计算步骤:
如果使用样本方差适用于这只是从更大总体中抽取的一个样本,
- 计算平均值( x ˉ \bar{x} xˉ)
- 计算差值( x i xᵢ xi - x ˉ \bar{x} xˉ)
- 计算平方和
- 除以 n n n-1
公式为:

# 第一步:计算平均值
(1 + 2 + 1 + 2 + 1) / 5 = 7 / 5 = 1.4
# 第二步:计算离差
离差 = 1 - 1.4 = -0.4
离差 = 2 - 1.4 = +0.6
离差 = 1 - 1.4 = -0.4
离差 = 2 - 1.4 = +0.6
离差 = 1 - 1.4 = -0.4
# 第三步: 计算平方和
Sum of Squares=0.16+0.36+0.16+0.36+0.16=1.2
# 第四步:除以 n−1
# 这里 n = 5 (样本个数)。
# 分母是 n−1 = 5−1=4
1.2/4 = 0.3
相关系数法
Pearson 相关系数(皮尔逊相关系数)是一种最简单的,可以帮助理解特征和响应变量之间关系的方法,该方法衡量的是变量之间的线性相关性。 结果的取值区间为 [-1,1] , -1 表示完全的负相关, +1表示完全的正相关,0 表示没有线性相关。
#选择K个最好的特征,返回选择特征后的数据,什么是"最好":由评分函数决定。
#第一个参数为计算评估特征是否好的函数,该函数输入特征矩阵和目标向量,
#输出二元组(评分,P值)的数组,数组第i项为第i个特征的评分和P值。在此定义为计算相关系数
#参数k为选择的特征个数
from sklearn.feature_selection import SelectKBest
from scipy.stats import pearsonr
import numpy as np
# 这段代码缺少一个评分函数
SelectKBest(k=5).fit_transform(train,target_train)
# 示例数据
np.random.seed(42)
n_samples = 100
train = pd.DataFrame({
'feature_1': np.random.randn(n_samples), # 与目标相关
'feature_2': np.random.randn(n_samples), # 与目标相关
'feature_3': np.random.randn(n_samples), # 无关
'feature_4': np.random.randn(n_samples), # 无关
'feature_5': np.random.randn(n_samples), # 相关
'feature_6': np.random.randn(n_samples), # 无关
'feature_7': np.random.randn(n_samples), # 无关
})
# 构造目标变量(与 feature_1, feature_2, feature_5 相关)
target_train = (train['feature_1'] * 2 +
train['feature_2'] * 1.5 +
train['feature_5'] * 3 +
np.random.randn(n_samples) * 0.1)
# 定义一个符合 SelectKBest 要求的评分函数
def pearson_score(X, y):
"""
X: 特征矩阵
y: 标签
返回:(scores, pvalues)
"""
scores = []
p_values = []
# 遍历每一列特征
for i in range(X.shape[1]):
# 计算该列特征与目标变量的皮尔森相关系数
# pearsonr 返回 (相关系数, p值)
corr, p = pearsonr(X[:, i], y)
# 注意:SelectKBest 默认是分数越大越好。
# 相关系数可能为负,但我们通常认为绝对值越大越好。
# 或者直接取绝对值,或者这里只存相关系数(如果你关心方向)
# 为了保险起见,通常取绝对值代表相关性强度
scores.append(abs(corr))
p_values.append(p)
return np.array(scores), np.array(p_values)
selector = SelectKBest(score_func=pearson_score, k=5)
train_selected = selector.fit_transform(train.values, target_train.values)
print("筛选后的特征形状:", train_selected.shape)
卡方检验
经典的卡方检验是用于检验自变量对因变量的相关性。 假设自变量有N种取值,因变量有M种取值,考虑自变量等于i且因变量等于j的样本频数的观察值与期望的差距。 其统计量如下: χ2=∑(A−T)2T,其中A为实际值,T为理论值
(注:卡方只能运用在正定矩阵上,否则会报错Input X must be non-negative)
from sklearn.feature_selection import SelectKBest, chi2
from sklearn.preprocessing import MinMaxScaler
# 1. 数据准备
# 假设 X_train 是特征,y_train 是标签
# 2. 预处理:确保数据非负 (chi2 的硬性要求)
# 如果原数据有负数,必须先归一化到 [0, 1]
X_train_non_neg = MinMaxScaler().fit_transform(X_train)
# 3. 特征选择
selector = SelectKBest(score_func=chi2, k=5)
X_train_selected = selector.fit_transform(X_train_non_neg, y_train)
# 4. 查看结果
# 获取被选中的特征索引
selected_indices = selector.get_support(indices=True)
print("被选中的特征索引:", selected_indices)
# 查看每个特征的卡方值得分 (得分越高越好)
scores = selector.scores_
print("特征得分:", scores)
互信息法
经典的互信息也是评价自变量对因变量的相关性的。 在feature_selection库的SelectKBest类结合最大信息系数法可以用于选择特征,相关代码如下:
from sklearn.feature_selection import SelectKBest
from minepy import MINE
import numpy as np
# 1. 定义评分函数 (使用列表推导式,更符合Python习惯)
def mic_score(X, y):
# 初始化 MINE 对象
m = MINE()
scores = []
# 遍历每一列特征
for i in range(X.shape[1]):
m.compute_score(X[:, i], y)
scores.append(m.mic())
# SelectKBest 需要 (scores, pvalues),这里构造虚拟P值
return np.array(scores), np.array([0.5] * X.shape[1])
# 2. 特征选择
# 假设 train 是 DataFrame 或 numpy array
selector = SelectKBest(score_func=mic_score, k=2)
train_selected = selector.fit_transform(train.values, target_train.values)
print("筛选出的特征形状:", train_selected.shape)
2 Wrapper (RFE)
(Recursive feature elimination,RFE)递归特征消除法 递归消除特征法使用一个基模型来进行多轮训练,每轮训练后,消除若干权值系数的特征,再基于新的特征集进行下一轮训练。 在feature_selection库的RFE类可以用于选择特征,相关代码如下(以逻辑回归为例):
import numpy as np
from sklearn.feature_selection import RFE
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
#递归特征消除法,返回特征选择后的数据
#参数estimator为基模型
#参数n_features_to_select为选择的特征个数
# 创建示例数据
X, y = make_classification(
n_samples=100,
n_features=10,
n_informative=5, # 只有 5 个特征是有信息的
n_redundant=3, # 3 个冗余特征
n_repeated=2, # 2 个重复特征
random_state=42
)
print("=" * 70)
print("RFE 递归特征消除详细过程")
print("=" * 70)
# 创建 RFE 对象,每次消除 1 个特征
estimator = LogisticRegression(max_iter=1000)
rfe = RFE(estimator=LogisticRegression(), n_features_to_select=2)
# 拟合
rfe.fit(X, y)
# 查看结果
print(f"\n原始特征数:{X.shape[1]}")
print(f"选择的特征数:{rfe.n_features_}")
# 哪些特征被选中
selected_mask = rfe.support_
print(f"\n选中的特征索引:{np.where(selected_mask)[0]}")
# 特征排名(1 表示最好,越大越差)
ranking = rfe.ranking_
print(f"\n特征排名:")
for i, rank in enumerate(ranking):
status = "✓" if rank == 1 else "✗"
print(f" 特征{i}: 排名={rank} {status}")
# 查看消除过程
print(f"\nRFE 属性:")
print(f" n_features_: {rfe.n_features_}")
print(f" support_: {rfe.support_}")
print(f" ranking_: {rfe.ranking_}")
┌─────────────────────────────────────────────────┐
│ RFE 递归特征消除流程 │
├─────────────────────────────────────────────────┤
│ │
│ 初始:10 个特征 [F1, F2, F3, F4, F5, │
│ F6, F7, F8, F9, F10] │
│ ↓ │
│ 第 1 轮:训练模型,评估特征重要性 │
│ 剔除最差的 2 个特征 (F9, F10) │
│ ↓ │
│ 第 2 轮:用剩余 8 个特征训练 │
│ 剔除最差的 2 个特征 (F7, F8) │
│ ↓ │
│ 第 3 轮:用剩余 6 个特征训练 │
│ 剔除最差的 2 个特征 (F3, F5) │
│ ↓ │
│ ... 继续迭代 ... │
│ ↓ │
│ 最终:剩下 2 个特征 [F1, F2] ✓ │
│ │
└─────────────────────────────────────────────────┘
注意点:
# ❌ 错误:LogisticRegression 是分类模型
RFE(estimator=LogisticRegression(), n_features_to_select=2).fit_transform(train, target_train)
# ↑
# 如果 target_train 是连续值(价格),这是分类模型!
# 方案 A:线性回归
RFE(estimator=LinearRegression(), n_features_to_select=2).fit_transform(train, target_train)
# 方案 B:随机森林(推荐,能捕捉非线性)
RFE(estimator=RandomForestRegressor(n_estimators=100), n_features_to_select=2).fit_transform(train, target_train)
# 方案 C:XGBoost(推荐)
RFE(estimator=XGBRegressor(), n_features_to_select=2).fit_transform(train, target_train)
# 默认 step=1,每次消除 1 个特征
# 如果特征很多,会很慢
# ✅ 建议:指定 step 加快消除速度
RFE(estimator=RandomForestRegressor(),
n_features_to_select=2,
step=5 # 每次消除 5 个特征
).fit_transform(train, target_train)
自动选择最优特征数
from sklearn.feature_selection import RFECV
from sklearn.ensemble import RandomForestRegressor
# RFECV 会自动通过交叉验证找到最优的特征数量
rfecv = RFECV(
estimator=RandomForestRegressor(n_estimators=100, random_state=42),
step=5, # 每次消除
cv=5, # 5 折交叉验证
scoring='neg_mean_absolute_error',
min_features_to_select=1,
n_jobs=-1
)
rfecv.fit(train, target_train)
print(f"最优特征数量:{rfecv.n_features_}")
print(f"选中的特征:{train.columns[rfecv.support_].tolist()}")
# 可视化
plt.figure(figsize=(12, 6))
plt.plot(range(1, len(rfecv.grid_scores_) + 1), rfecv.grid_scores_)
plt.axvline(rfecv.n_features_, color='r', linestyle='--', label=f'最优:{rfecv.n_features_}个特征')
plt.xlabel('特征数量')
plt.ylabel('交叉验证得分 (MAE)')
plt.title('RFECV - 最优特征数量选择')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
3 Embedded(嵌入法)
基于惩罚项的特征选择法 使用带惩罚项的基模型,除了筛选出特征外,同时也进行了降维。 在feature_selection库的SelectFromModel类结合逻辑回归模型可以用于选择特征,它利用了逻辑回归模型中的 L1正则化(Lasso) 特性:L1正则化会产生稀疏解,即会让许多特征的系数变为 0。SelectFromModel 正是利用这一点,自动剔除系数为 0 的特征。相关代码如下:
from sklearn.feature_selection import SelectFromModel
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
# 创建示例数据
X, y = make_classification(
n_samples=100,
n_features=10,
n_informative=5, # 只有 5 个特征是有信息的
n_redundant=3, # 3 个冗余特征
n_repeated=2, # 2 个重复特征
random_state=42
)
# 必须指定 solver='liblinear' 或 'saga'
# dual=False 是 liblinear 的默认设置,通常不需要显式写,但加上更保险
# penalty='l1' 这种写法将在未来版本(1.10+)移除
# liblinear 比较老旧,不支持 l1_ratio 参数。
# 推荐使用 saga(它是 liblinear 的升级版,支持所有正则化且速度更快)
# lr = LogisticRegression(penalty="l1", C=0.1, solver='liblinear')
# 1. solver 改为 'saga' (推荐用于现代逻辑回归)
# 2. penalty 改为 'elasticnet' (因为 l1_ratio 只在 elasticnet 模式下生效)
# 3. l1_ratio=1.0 表示 100% 使用 L1 正则化 (等同于原来的 penalty='l1')
lr = LogisticRegression(
penalty='elasticnet',
solver='saga',
l1_ratio=1.0,
C=0.1,
random_state=42
)
selector = SelectFromModel(lr)
train_selected = selector.fit_transform(X, y)
# 查看结果
print(f"原始特征数量: {X.shape[1]}")
print(f"筛选后特征数量: {train_selected.shape[1]}")
l1_ratio=1.0:纯 L1 正则化(你想要的效果,产生稀疏解,筛选特征)。
l1_ratio=0.0:纯 L2 正则化(传统的岭回归,系数趋近于0但不为0)。
l1_ratio=0.5:混合正则化(同时享受 L1 的筛选能力和 L2 的稳定性)
129

被折叠的 条评论
为什么被折叠?



