目录
1. 学习内容
1. 异常处理
2. 特征归一化/标准化
3. 数据分桶
4. 缺失值处理
5. 特征构造
6. 特征筛选
本项目参见https://github.com/datawhalechina/team-learning
2. 导入相关模块和数据
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
train_df = pd.read_csv(r'./data/train.csv', sep = ' ')
test_df = pd.read_csv(r'./data/testA.csv', sep = ' ')
print(train_df.shape)
print(test_df.shape)
3. 判别异常值
3.1 什么是异常值
异常值指的是在数据集中存在的不合理的值,也叫离群点。结合实际意义或许能更好地理解,比如人年龄为负数,羽毛的重量为1吨等,这些都属于异常值。
3.2 常见的异常值判别方法
通常我们会结合统计情况或者实际情况对某个特征的取值设置一个合理的范围,超出这个范围的值都可以认为是异常值。而不同的异常值判别方法最主要的区别就是范围的选取方式。常见的异常值判别方法有以下几种[1]:
3.2.1 简单统计分析
对特征进行一个描述性的统计,并查看哪些值是不合理的。比如对年龄这个属性进行规约:年龄的区间在[0:200],如果样本中的年龄值不在该区间内,则表示该样本的年龄属性属于异常值。
3.2.2 3σ原则
这个方法适用于服从正太分布的数据。根据正太分布,,这代表
是一个小概率事件。如果有数据满足
,则说明该数据是一个异常值。
3.2.3 箱型图
当数据并不服从正态分布时,可以使用此方法来判别异常值。要理解这个方法的原理,首先需要知道箱型图是如何画出来的。
箱线图的绘制方法是:先找出一组数据的最大值、最小值、中位数和两个四分位数;然后,连接两个四分位数画出箱子;再将最大值和最小值与箱子相连接,中位数在箱子中间[2]。不过,这里需要注意的是,为了判别异常值,我们需要人为设置上限和下限并将本该画在最值处的盒须画在上下限处。这样,盒须之外的数据就是异常值了。(实际上,这是两种箱型图。另外,我还发现了一个关于它们的有趣的介绍[3]。)
我们按照从小到大的次序,记第一个、第二个和第三个四分位数分别为Q1、Q2和Q3。记四分位距IQR=Q3-Q1。这样,下限的值就是Q1-k·IQR,上限的值就是Q3+k·IQR。其中k的取值可根据实际情况进行调整,通常默认取1.5。
3.3 异常值处理方法
常见的方法有以下几种:
1. 删除含有异常值的数据或特征;
2. 用平均值或中位数来修正;
3. 将异常值视为缺失值,交给缺失值处理方法来处理;
4. 不处理。
3.4 异常值处理实现(箱型图+删除异常值)
# 这里是一个异常值处理的函数
def outliers_proc(data, col_name, scale = 3):
"""
清晰pandas表格中某一列中的异常值,默认用 box_plot(scale=3)进行清洗
data: 接收pandas表格
col_name: pandas表格的列名
scale: 尺度,用来划定数据的上限与下限的
返回值是处理好的数据
"""
def box_plot_outliers(data_ser, box_scale):
"""
利用箱线图去除异常值
data_ser: pandas序列
param box_scale: 箱型图划定上下限的尺度
返回值分两部分,第一部分是异常值的位置(用序列表示),第二部分是上下限的具体值
"""
iqr = box_scale * (data_ser.quantile(0.75) - data_ser.quantile(0.25))
# 计算有效数据的下限和上限
val_low = data_ser.quantile(0.25) - iqr
val_up = data_ser.quantile(0.75) + iqr
rule_low = (data_ser < val_low)
rule_up = (data_ser > val_up)
return (rule_low, rule_up), (val_low, val_up)
data_n = data.copy()
data_series = data_n[col_name]
rule, value = box_plot_outliers(data_series, box_scale = scale)
# 从原序列汇总提取出所有异常值
# 生成记录异常值索引的数组
index = np.arange(data_series.shape[0])[rule[0] | rule[1]]
print("Delete number is: {}".format(len(index)))
# 根据索引在原数据中删除对应的行
data_n = data_n.drop(index)
# 重置数据的索引
data_n.reset_index(drop = True, inplace = True)
print("Now row number is: {}".format(data_n.shape[0]))
# 输出小于下限和大于上限的异常值的统计数据
index_low = np.arange(data_series.shape[0])[rule[0]]
outliers = data_series.iloc[index_low]
print("Description of data less than the lower bound is:")
print(pd.Series(outliers).describe())
index_up = np.arange(data_series.shape[0])[rule[1]]
outliers = data_series.iloc[index_up]
print("Description of data larger than the upper bound is:")
print(pd.Series(outliers).describe())
# 绘制处理前和处理后的箱型图
fig, ax = plt.subplots(1, 2, figsize=(10, 7))
sns.boxplot(y = data[col_name], data = data, \
palette = "Set1", ax = ax[0])
sns.boxplot(y = data_n[col_name], data = data_n, \
palette = "Set1", ax = ax[1])
return data_n
对训练数据调用这个函数并设置上下限为3倍的IQR。
train_df = outliers_proc(train_df, 'power', scale = 3)
输出结果如下:
Delete number is: 963
Now row number is: 149037
Description of data less than the lower bound is:
count 0.0
mean NaN
std NaN
min NaN
25% NaN
50% NaN
75% NaN
max NaN
Name: power, dtype: float64
Description of data larger than the upper bound is:
count 963.000000
mean 846.836968
std 1929.418081
min 376.000000
25% 400.000000
50% 436.000000
75% 514.000000
max 19312.000000
Name: power, dtype: float64
左图和右图分别是处理前后的箱型图。可以发现原来许多大于上限的异常值都被去掉了。另外,需要注意的是图中的盒须仍然是画在k=1.5的位置,而非我们自己设置的k=3的位置。
4. 构造新特征并保存数据到文件
原有的特征中也许会隐含着更多的信息,因此有必要把这些信息整理出来即构造新特征。
由于不同的模型对数据的要求略有不同,所以数据需要按照未来使用的模型分别进行保存。这里我们分别以树模型和线性模型为例。
4.1 供树模型使用
4.1.1 合并训练集和测试集
train_df['train'] = 1
test_df['train'] = 0
data = pd.concat([train_df, test_df], ignore_index = True, sort = False)
print(data.shape)
4.1.2 新建“使用时间”特征
使用时间就是用广告发布日期减去注册日期。这其中涉及到了pandas的日期转换的相关操作。这里推荐一篇写得很全面的文献[4]。
data['creatDate'] = pd.to_datetime(data['creatDate'], \
format = '%Y%m%d', errors = 'coerce')
data['regDate'] = pd.to_datetime(data['regDate'], \
format = '%Y%m%d', errors = 'coerce')
data['used_time'] = (data['creatDate'] - data['regDate']).dt.days
4.1.3 新建“城市信息”特征
这里的城市信息实际上是一种先验知识,也就是处理数据的人已知的正确的知识。加入先验知识有助于提升模型的效果。
data['city'] = data['regionCode'].apply(lambda x : str(x)[:-3])
4.1.4 新建“统计信息”特征(以品牌为例)
代码中使用了pandas的groupby()方法,具体的用法参考[5]。
train_gb_df = train_df.groupby('brand')
all_info = {}
# groupby处理后的表格,遍历时有两个参数
# 第一个表示组别编号,第二个表示组别的具体表格
for kind, kind_data in train_gb_df:
info = {}
kind_data = kind_data[kind_data['price'] > 0]
info['brand_amount'] = len(kind_data)
info['brand_price_max'] = kind_data.price.max()
info['brand_price_median'] = kind_data.price.median()
info['brand_price_min'] = kind_data.price.min()
info['brand_price_sum'] = kind_data.price.sum()
info['brand_price_std'] = kind_data.price.std()
# round()是以四舍五入的方式计算浮点数的值的函数
info['brand_price_average'] = round(kind_data.price.sum() / \
(len(kind_data) + 1), 2)
all_info[kind] = info
brand_fe_df = pd.DataFrame(all_info).T.reset_index().rename(columns = \
{"index": "brand"})
data = data.merge(brand_fe_df, how = 'left', on = 'brand')
4.1.5 数据分桶(以马力为例)
数据分桶的好处:
1. 离散后稀疏向量内积乘法运算速度更快,计算结果也方便存储,容易扩展;
2. 离散后的特征对异常值更具鲁棒性,如 age>30 为 1 否则为 0,对于年龄为 200 的也不会对模型造成很大的干扰;
3. LR属于广义线性模型,表达能力有限,经过离散化后,每个变量有单独的权重,这相当于引入了非线性,能够提升模型的表达能力,加大拟合;
4. 离散后特征可以进行特征交叉,提升表达能力,由 M+N 个变量编程 M*N 个变量,进一步引入非线形,提升了表达能力;
5. 特征离散后模型更稳定,如用户年龄区间,不会因为用户年龄长了一岁就变化。
具体代码如下:
bin = [i * 10 for i in range(31)]
data['power_bin'] = pd.cut(data['power'], bin, labels = False)
data[['power_bin', 'power']].head()
另外,除了使用pandas提供的cut()方法,还可以使用sklearn.preprocessing中提供的KBinsDiscretizer来进行分桶处理[6]。
4.1.6 删除原始特征
data = data.drop(['creatDate', 'regDate', 'regionCode'], axis=1)
4.1.7 导出数据到文件
data.to_csv(r'./data/data_for_tree.csv', index = False)
4.2 供线性模型使用
供线性模型使用的数据相对于供树模型使用的数据要更加复杂。因为,首先,一些树模型可以自动处理空值。因此上文的数据并没有进行空值处理。另外,树模型使用的数据不必做归一化和标准化,也不必进行独热编码。
至于为什么树模型使用的数据不必做归一化和标准化,具体原因可以参考两位大佬的答案:
“树模型也不需要梯度下降之类的来寻找最优解,关心的是变量的概率分布而不是变量的值,所以不需要归一化。”
“树模型的话不处理数据也不太影响, 因为树模型分裂选择特征的时候是基于样本的混乱程度进行分裂的,也就是它是针对每个特征计算合适的分裂点的, 这样特征与特征之间的这种量纲对树分裂影响不大,不过归一化之后应该会收敛的快一点,毕竟数小了嘛。 一般那种需要参数估计的模型才需要归一化或者标准化,理解的话就是因为这种模型一般是wx求和的形式,这样的话如果不统一量纲,那么那个取值范围大的这种肯定就会占优势了啊,变一下子对目标产生的影响就很大。”
因此,供线性模型使用的数据,除了要进行4.1.1到4.1.7的步骤外。还要额外进行空值处理、归一化及标准化和独热编码。空值如何处理可以参考[7],这里不再赘述。如何进行归一化及标准化还有独热编码可以参考[6]。
4.2.1 观察数据分布
线性模型要求数据是服从正太分布的。因此,我们需要观察各特征的分布情况并对不符合正太分布的数据进行转换。
data['power'].plot.hist()
train_df['power'].plot.hist()
刚刚已经对训练集进行异常值处理了,但是现在还有这么奇怪的分布是因为测试集中的power存在异常值。所以我们其实刚刚测试集中的power异常值不删为好,可以用长尾分布截断来代替。
4.2.2 进行数据归一化
from sklearn.preprocessing import MinMaxScaler
min_max_scaler = MinMaxScaler()
# 我们对其取对数,再做归一化
data['power'] = np.log(data['power'] + 1)
data['power'] = min_max_scaler.fit_transform(data['power'].values.reshape(-1, 1))
data['power'].plot.hist()
此时的结果看起来就比之前好多了。
同理,我们可以对其他的特征进行类似的操作。
4.2.3 对数据做独热编码
data = pd.get_dummies(data, columns = ['model', 'brand', 'bodyType', 'fuelType',
'gearbox', 'notRepairedDamage', 'power_bin'])
print(data.shape)
data.columns
(199037, 370)
Index(['SaleID', 'name', 'power', 'kilometer', 'seller', 'offerType', 'price',
'v_0', 'v_1', 'v_2',
...
'power_bin_20.0', 'power_bin_21.0', 'power_bin_22.0', 'power_bin_23.0',
'power_bin_24.0', 'power_bin_25.0', 'power_bin_26.0', 'power_bin_27.0',
'power_bin_28.0', 'power_bin_29.0'],
dtype='object', length=370)
至此,我们就把供线性模型使用的数据处理好了。剩下的就是保存到文件。
5. 特征选择
特征选取的方法主要有以下几种[8]:
5.1 过滤法
过滤法就是对数据进行相关性分析,除去对结果影响小的特征,留下对结果影响较大的特征。一般,在进行相关性分析之前需要过滤掉方差为0的特征。
至于相关性分析,可以采用卡方过滤、F检验和互信息法等方法。它们具体的用法和特点,可以参考[8]。
这里只做最简单的相关性分析,可参考[7]。
print(data['power'].corr(data['price'], method = 'spearman'))
print(data['kilometer'].corr(data['price'], method = 'spearman'))
print(data['brand_amount'].corr(data['price'], method = 'spearman'))
print(data['brand_price_average'].corr(data['price'], method = 'spearman'))
print(data['brand_price_max'].corr(data['price'], method = 'spearman'))
print(data['brand_price_median'].corr(data['price'], method = 'spearman'))
0.5728285196051496
-0.4082569701616764
0.058156610025581514
0.3834909576057687
0.259066833880992
0.38691042393409447
data_numeric = data[['power', 'kilometer', 'brand_amount', 'brand_price_average',
'brand_price_max', 'brand_price_median']]
correlation = data_numeric.corr()
f , ax = plt.subplots(figsize = (7, 7))
plt.title('Correlation of Numeric Features with Price', y = 1, size = 16)
sns.heatmap(correlation,square = True, vmax = 0.8)
5.2 包裹法
包裹法的解决思路没有过滤法这么直接,它会选择一个目标函数来一步步的筛选特征[9]。
包裹法的效果是所有特征选择法中最利于提升模型表现的,它可以使用最少的特征达到优秀的效果。不过,它的计算速度会比较缓慢,因此不适用于太大的数据[8]。
from mlxtend.feature_selection import SequentialFeatureSelector as SFS
from sklearn.linear_model import LinearRegression
sfs = SFS(LinearRegression(),
k_features = 10,
forward = True,
floating = False,
scoring = 'r2',
cv = 0)
x = data.drop(['price'], axis = 1)
x = x.fillna(0)
y = data['price'].fillna(0)
sfs.fit(x, y)
sfs.k_feature_names_
STOPPING EARLY DUE TO KEYBOARD INTERRUPT...
('powerPS_ten',
'city',
'brand_price_std',
'vehicleType_andere',
'model_145',
'model_601',
'fuelType_andere',
'notRepairedDamage_ja')
调用相关绘图函数,可以发现数据的“边际递减”效应。
from mlxtend.plotting import plot_sequential_feature_selection as plot_sfs
import matplotlib.pyplot as plt
fig1 = plot_sfs(sfs.get_metric_dict(), kind='std_dev')
plt.grid()
plt.show()
5.3 嵌入法
嵌入法同包裹法一样,也是用机器学习的方法来选择特征。但是区别在于它不是通过不停的筛掉特征来进行训练,而是整个过程使用的都是特征全集[9]。
最常用的是使用L1正则化和L2正则化来选择特征。当正则化惩罚项大到一定的程度的时候,部分特征系数会变成0,当正则化惩罚项继续增大到一定程度时,所有的特征系数都会趋于0。 我们会发现有一部分特征系数会更容易先变成0,这部分系数就是可以筛掉的。也就是说,我们选择特征系数较大的特征。而常用于L1正则化和L2正则化来选择特征的基学习器是逻辑回归。
同时也可以使用决策树或者GBDT来选取特征。一般,越靠近根部的特征重要程度越大,越应该保留。
6. 参考文献
1. https://blog.youkuaiyun.com/xzfreewind/article/details/77014587
2. https://blog.youkuaiyun.com/opp003/article/details/84959020
3. https://baijiahao.baidu.com/s?id=1645794809874667051&wfr=spider&for=pc
4. https://blog.youkuaiyun.com/qq_35456045/article/details/105114584
5. https://blog.youkuaiyun.com/FrankieHello/article/details/97272990
6. https://blog.youkuaiyun.com/Zee_Chao/article/details/104838103
7. https://blog.youkuaiyun.com/Zee_Chao/article/details/105045879