特征工程理论:
常见的特征工程包括:
一、导入数据
mport warnings warnings.filterwarnings('ignore') import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns import missingno as msno import scipy.stats as st path = 'D:/data/car/' Train_data = pd.read_csv(path + 'used_car_train.csv', sep=' ') # 文件以空字符分隔数据,若以','分隔数据,则sep=','或省略(源文件都将数据存到了一列里面) Test_data = pd.read_csv(path + 'used_car_testA.csv', sep=' ')
二、删除异常值(仅适用于树模型的构造)
在task2中,我们仅仅是凭借观察删除掉了两个特征和一部分特征的异常值,在本次任务中,我们给出了一个比较通用的方法来处理异常值:利用箱线图来去除异常值。我将所有的代码都做了详细注释,希望能帮助到大家理解:
def outliers_proc(data, col_name, scale=3): """ 用于清洗异常值,默认用 box_plot(scale=3)进行清洗 :param data: 接收 pandas 数据格式 :param col_name: pandas 列名 :param scale: 尺度 :return: """ def box_plot_outliers(data_ser, box_scale): """ 利用箱线图去除异常值 :param data_ser: 接收 pandas.Series 数据格式 :param box_scale: 箱线图尺度, :return: """ iqr = box_scale * (data_ser.quantile(0.75) - data_ser.quantile(0.25)) # quantile为四分位函数,其中quantile(0.75)是从小到大排四分之三处的数据大小 val_low = data_ser.quantile(0.25) - iqr # 设置需要丢弃数据的上下阈值 val_up = data_ser.quantile(0.75) + iqr rule_low = (data_ser < val_low) # 比val_low更小的值要被丢弃(False和True组成的数组) rule_up = (data_ser > val_up) # 比val_up更大的值要被丢弃(False和True组成的数组) 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]] # 存储需要丢弃的过小过大的值的下标index 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])) # 清洗后该特征剩余的取值个数(原代码的column应该是笔误了) index_low = np.arange(data_series.shape[0])[rule[0]] # 因为取值过小被清洗掉的数据的index 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]] # 因为取值过大被清洗掉的数据的index 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]) # 可视化数据清洗后的箱线图 plt.show() return data_n
其中第二个print()语句中原文写的是Now column number is,我认为应该是笔者的笔误,此处应该是行数(即该特征的有效数据个数)。
其输出如下所示:
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
有结果我们可知共删除了过大和过小的值共963个,剩余的有效取值值个数为149037。
可视化的结果如下图所示:
其中左图显示的是删除异常值之前数据的箱线图,右图显示的是删除异常值之后数据的箱线图。经过对比可见,删除前的数据中位数,第一四等分点和第三四等分点都处于图像非常靠下的位置,因此存在非常多异常大的取值,对数据的整体分布产生了较大的影响;删除后的数据中位数,第一四等分点和第三四等分点相对全部的取值取值区间已有较大的区分度,整体分布得到了优化。
三、特征构造(所有模型通用)
本部分我们将应用我们的先验知识构造有助于预测price的新特征。
1.训练集与测试集的合并
# 训练集和测试集放在一起,方便构造特征 Train_data['train']=1 Test_data['train']=0 data = pd.concat([Train_data, Test_data], ignore_index=True)
注意:不熟悉pandas的同学可能会疑问这个ignore_index的作用,下面我们通过控制变量看一下他的作用,当该值取False时,训练集与测试集合并后的序号如下图所示:
当该值取True时,合并后的序号如下图所示:
经过对比可见当该值取False时,合并后的表格将序号依然使用的原表格中的序号,而当该值取True时,序号更新为连续的序号。
2.构造特征之使用时间
因为我们知道其售卖时间和出厂时间,所以两者相减可以计算出其使用时间。之所以要计算汽车使用时间,是因为一般来说价格与使用时间成反比。
data['used_time'] = (pd.to_datetime(data['creatDate'], format='%Y%m%d', errors='coerce') - pd.to_datetime(data['regDate'], format='%Y%m%d', errors='coerce')).dt.days
3.构造特征之所属城市
从邮编中可以提取城市特征(4位邮编的第一位,不足4位的邮编认为是异常的)。
data['city'] = data['regionCode'].apply(lambda x : str(x)[:-3]) data = data
4.构造特征之品牌
Train_gb = Train_data.groupby("brand") # 按照brand来聚合,brand=0的聚合在一起,brand=1的聚合在一起(总共聚合了40类) all_info = {} for kind, kind_data in Train_gb: # kind分别从0取到39,kind_data为0-39这40类的具体数据 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() info['brand_price_average'] = round(kind_data.price.sum() / (len(kind_data) + 1), 2) all_info[kind] = info # all_info存储了这40个聚合类的统计学特征 brand_fe = pd.DataFrame(all_info).T.reset_index().rename(columns={"index": "brand"}) data = data.merge(brand_fe, how='left', on='brand')
如上面代码所示,我们根据brand的值聚合为多种类(本题为40类),也就是一共40个品牌,我们分别计算每个品牌的各种数学统计量,并保存在字典all_info中,为了将新构造的特征合并到原来的data中,我们将其转化为和data相同格式的类型,然后将其合并。如下图所示:新的特征就被加入到了原来的data中了。
5.构造特征之数据分桶
本构造以power特征为例,数据分桶实际上就是数据离散化,其优点如下:
①.离散后稀疏向量内积乘法运算速度更快,计算结果也方便存储,容易扩展;
②. 离散后的特征对异常值更具鲁棒性。如 age>30 为 1 否则为 0,所以异常年龄100也不会对模型造成很大的干扰;
③. LR (逻辑斯蒂回归)属于广义线性模型,表达能力有限,经过离散化后,每个变量有单独的权重,这相当于引入了非线性,能够提升模型的表达能力,加大拟合;
④. 离散后特征可以进行特征交叉,提升表达能力,由 M+N 个变量编程 M*N 个变量,进一步引入非线形,提升了表达能力;
⑤. 特征离散后模型更稳定。
其具体实现如下所示:
bin = [i*10 for i in range(31)] # bin 是一个整数数列,标明了我们的cut标准 data['power_bin'] = pd.cut(data['power'], bin, labels=False) # 按照bin所给的数组切割,左开右闭,因此power等于0或者大于300的值都被认为是nan
以上是将power在(0,300]区间内的值都根据bin的值做一定切分处理(除以10)。其结果如下所示:
power_bin power 0 5.0 60 1 NaN 0 2 16.0 163 3 19.0 193 4 6.0 68 ... ... ... 199995 11.0 116 199996 7.0 75 199997 22.0 224 199998 NaN 334 199999 6.0 68 [200000 rows x 2 columns]
细心的同学可能发现了,power=60处理后是变成了5.0,而不是6.0。这是因为cut()函数默认的切割是左开右闭的。之所以这样切割是有好处的,因为power=0也是异常值,这样可以将0处理为Nan。
6.去除无用的特征,导出到文件
我们前面利用已有特征人为构造了一部分新的特征,所以原来特征已经变成了无用特征,我们需要将其删除,然后导出到文件作为树模型的数据使用。
data = data.drop(['creatDate', 'regDate', 'regionCode','seller','offerType'], axis=1)
data.to_csv(path+'data_for_tree.csv',index = 0)
其中seller和offerType为task2中我们寻找到的出现严重倾斜的特征,我们一并删除。
四、特征的异常值处理之二+归一化处理(供LR和NN等模型使用)
1.异常值处理
因为不同模型使用的数据特征是不同的,所以对于树模型和LR等模型的数据需要分开构造。我们看下之前用箱线图去除过异常值的特征power的数据分布。
data['power'].plot.hist() plt.show()
其分布如下图所示:
可以看到我们虽然用箱型图去除掉了部分过大过小的异常值,但是仍然有许多过大的异常值,因此我们如果用归回模型的话,不应该使用箱线图去除异常值,而应该使用如下所示的长尾分布截断。
rule = Train_data['power'] > 375 index = np.arange(Train_data.shape[0])[rule] Train_data_n = Train_data.drop(index) Train_data_n['power'].plot.hist()
其可视化结果如下所示:
从上图可见,power特征的数值已经十分接近正态分布了。
2.归一化处理
因为data中的power特征有过多的异常大值,所以我们之间归一化势必会出现问题,因此我们将其取log缩小其差距,然后在归一化。
min_max_scaler = preprocessing.MinMaxScaler() data['power'] = np.log(data['power'] +1) # +1是为了结果都是正数 data['power'] = ((data['power'] - np.min(data['power'])) / (np.max(data['power']) - np.min(data['power']))) data['power'].plot.hist() plt.show()
其中取对数的时候+1是为了结果均为正值,否则我们将无法在可视化图形中表示出来。
其他差异不大的特征不用取对数,直接归一化即可,不再赘述。
data['kilometer'] = ((data['kilometer'] - np.min(data['kilometer'])) / (np.max(data['kilometer']) - np.min(data['kilometer']))) def max_min(x): return (x - np.min(x)) / (np.max(x) - np.min(x)) data['brand_amount'] = ((data['brand_amount'] - np.min(data['brand_amount'])) / (np.max(data['brand_amount']) - np.min(data['brand_amount']))) data['brand_price_average'] = ((data['brand_price_average'] - np.min(data['brand_price_average'])) / (np.max(data['brand_price_average']) - np.min(data['brand_price_average']))) data['brand_price_max'] = ((data['brand_price_max'] - np.min(data['brand_price_max'])) / (np.max(data['brand_price_max']) - np.min(data['brand_price_max']))) data['brand_price_median'] = ((data['brand_price_median'] - np.min(data['brand_price_median'])) / (np.max(data['brand_price_median']) - np.min(data['brand_price_median']))) data['brand_price_min'] = ((data['brand_price_min'] - np.min(data['brand_price_min'])) / (np.max(data['brand_price_min']) - np.min(data['brand_price_min']))) data['brand_price_std'] = ((data['brand_price_std'] - np.min(data['brand_price_std'])) / (np.max(data['brand_price_std']) - np.min(data['brand_price_std']))) data['brand_price_sum'] = ((data['brand_price_sum'] - np.min(data['brand_price_sum'])) / (np.max(data['brand_price_sum']) - np.min(data['brand_price_sum'])))
最后看一下特征的形状,确认无误后导出到文件中供LR模型使用。
print(data.shape) data = pd.get_dummies(data, columns=['model', 'brand', 'bodyType', 'fuelType', 'gearbox', 'notRepairedDamage', 'power_bin']) print(data.shape) (199037, 37) (199037, 368)
get_dummies()函数是将编码方式转换为One-Hot方式,因此特征数量增加了许多是正常的结果。所以我们将其保存起来备用。