天池二手车价格预测比赛(二)——特征工程步骤

特征工程

        • 1.删除特征中的异常值
        • 2.特征构造
          • a.训练集和测试集放在一起,方便构造特征
          • b.使用时间(天数)特征构造
            • b-1.对 pd.to_datetime的使用
            • b-2.时间特征的构造
            • b-3.对 nan 的判断
            • b-4.判断并找出日期值 nan 的数据
          • c.从邮编中提取城市信息——相当于加入了先验知识
          • d.计算品牌特征的销售统计量
            • d-1.统计每个商标品牌对应的销售信息——数据分组操作
            • d-2.查看所有的特征值,并排序(对1-d array)
            • d-3.df的转置、重置index、修改列名、数据合并操作
          • e.特征分桶
            • e-1.特征分桶函数用法
            • e-2.power特征分桶
            • e-3.特征分桶的作用
          • f.数据保存——特征用于树模型
            • f-1.删除不需要的数据——drop操作
            • f-2.数据的压缩保存和读取
          • g.特征构造2——用于 LR NN 之类的模型
            • g-1.power特征的分布图
            • g-2.power特征的重新处理——取 log,并归一化
            • g-3.kilometer 特征的归一化
            • g-4.对新构造的统计量特征的归一化——不可用元素级函数 map、apply
            • g-5.类别特征的虚拟化——独热码
            • g-6.删除部分特征,保存数据
        • 3.特征选择
          • a.过滤式特征选择
          • b.包裹式特征选择
            • b-1.基于 mlxtend 库进行特征选择
            • b-2.排查数据中的 NaN 和 inf
          • c.嵌入式特征选择

1.删除特征中的异常值

包装的异常值处理的代码,可以随便调用。

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:
        """
        # 判断标准:四分位距 * 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
        # 下界过滤一次,生成bool索引
        rule_low = (data_ser < val_low)
        # 上界过滤一次,生成bool索引
        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]
    
    # 删除条件取并集,满足任何一个异常值删除标准都 ok
    rule, value = box_plot_outliers(data_series, box_scale=scale)
    # 根据 bool 索引找出被删除数据的 index
    index = np.arange(data_series.shape[0])[rule[0] | rule[1]]
    print("被删除的异常值数量为: {}".format(len(index)))
    # 根据 index 删除对应的数据
    data_n = data_n.drop(index)
    data_n.reset_index(drop=True, inplace=True)
    print("删除异常特征值后,数据总量为: {}".format(data_n.shape[0]))
    
    # data_series 未被改动,对异常值进行统计描述
    index_low = np.arange(data_series.shape[0])[rule[0]]
    outliers = data_series.iloc[index_low]
    print("对小于异常值下界的异常特征值进行统计描述:")
    print(pd.Series(outliers).describe())
    index_up = np.arange(data_series.shape[0])[rule[1]]
    outliers = data_series.iloc[index_up]
    print("对大于异常值上界的异常特征值进行统计描述:")
    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

可以删掉一些异常数据,以 power 为例,但是最终删不删自行判断
但是要注意只能删除训练集的数据, 测试集的数据不能删(掩耳盗铃)!!!

# 被删除的异常值数量为: 963
Train_data = outliers_proc(Train_data, 'power', scale=3)

2.特征构造

a.训练集和测试集放在一起,方便构造特征

测试集的 price 特征为 nan

Train_data['train'] = 1
Test_data['train'] = 0
# 默认 axis=0
data = pd.concat([Train_data, Test_data], ignore_index=True)
# (199037, 32)
print(data.shape) 
b.使用时间(天数)特征构造

反应汽车使用时间,一般来说价格与使用时间成反比,公式为:data[‘creatDate’] - data[‘regDate’]

b-1.对 pd.to_datetime的使用
# 首先介绍日期格式于python的格式转化:
1/17/07 has the format "%m/%d/%y"
17-1-2007 has the format "%d-%m-%Y"

# 通过以上的格式,可以将DataFrame中的时间格式转换为以下等python格式:
0   2007-03-02
1   2007-03-22
2   2007-04-06
3   2007-04-14
4   2007-04-15
Name: date_parsed, dtype: datetime64[ns]
b-2.时间特征的构造

由于原数据有问题(部分时间并不是统一的格式),直接计算会报错,提醒年月日不匹配

# 报错:ValueError: time data '20070009' does not match format '%Y%m%d' (match)
data['used_time'] = (pd.to_datetime(data['creatDate'], format='%Y%m%d') - pd.to_datetime(data['regDate'], format='%Y%m%d')).dt.days

检索出报错的数据

# 有不少,几十个,除此之外,还有其他的错误格式 
data[data.regDate == 20070009]

针对日期格式不一致,正确的构造方式

# 加入参数errors,errors='coerce'
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
b-3.对 nan 的判断

查看某个日期数据错误处理后的结果

data[14:15].used_time
> 14   NaN
> Name: used_time, dtype: float64

type(data[14:15].used_time.values[0])
> <class 'numpy.float64'>
b-4.判断并找出日期值 nan 的数据

不可行的尝试

data[14:15].used_time.values[0] == np.nan
> False

data[14:15].used_time.values[0] == nan
> NameError: name 'nan' is not defined

data[14:15].used_time.values[0] == 'NaN'
> False

data[14:15].used_time.values[0] == 'nan'
> False

data[14:15].used_time.values[0] == float('nan')
> False

可行的方法1

from math import isnan
isnan(data[14:15].used_time.values[0])
> True

# 但是该函数不可以直接用于整个series
data[ isnan(data['used_time']) ]
> TypeError: cannot convert the series to <class 'float'>

# 但是可以作用于Series的每个元素,生成一个bool类型的Series(dytpe=bool),然后series进行过滤
data[ data['used_time'].apply(isnan) ]  # 15072 rows × 33 columns

可行的方法2

# isnull() 可以直接找出所有的缺失值,不需要知道缺失值的数据类型
data['used_time'].isnull().sum()  # 15072

数据中有 15072 个样本的时间是有问题的,我们可以选择删除,也可以选择放着。
但是这里不建议删除,因为删除缺失数据占总样本量过大,为7.5%。可以先放着,因为XGBoost 之类的决策树本身就能处理缺失值,所以可以不用管。

c.从邮编中提取城市信息——相当于加入了先验知识

其中第四个数据处理后不是缺失值(nan),而是空的字符串

data['regionCode'][:5]

data['regionCode'][:5].apply(lambda x : str(x)[:-3])

天池二手车价格预测比赛(二)——特征工程步骤_第1张图片
天池二手车价格预测比赛(二)——特征工程步骤_第2张图片

data['city'] = data['regionCode'].apply(lambda x : str(x)[:-3])
data.city.isnull().sum()
> 0
data.city[123808:123809].values[0] == ''
> True
d.计算品牌特征的销售统计量

针对训练集,也可以计算其他特征的统计量

d-1.统计每个商标品牌对应的销售信息——数据分组操作
# 分组结果不可直接查看,但可以迭代查看
Train_groups = Train_data.groupby("brand")
all_info = {}
# 对于每个商标种类和其对应的分组数据
for kind, kind_data in Train_groups:
    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()
    # Series 自带的不包括求均值,round 四舍五入保留两位小数
    info['brand_price_average'] = round(kind_data.price.sum() / (len(kind_data) + 1), 2)
    all_info[kind] = info

# 数据展示: index是每个指标的名称,columns是商标名称(0-39)
pd.DataFrame(all_info)
d-2.查看所有的特征值,并排序(对1-d array)
a = data.brand.unique()
# sort方法不返回任何参数
a.sort()
a
> array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33,
       34, 35, 36, 37, 38, 39], dtype=int64)
d-3.df的转置、重置index、修改列名、数据合并操作
brand_stats = pd.DataFrame(all_info).T.reset_index().rename(columns={"index": "brand"})
# 基于 brand 特征(on='brand')将 brand_stats 的数据合并到 data(how='left')
data = data.merge(brand_stats, how='left', on='brand')

商标种类对应的数字本来就是0到39,重置 index后仍然不变(本来的index就是0到39,多余的一步)

e.特征分桶
e-1.特征分桶函数用法

用来把一组数据分割成离散的区间,并打上标签

# 部分默认参数如下:
pd.cut(x, bins, labels=None)

部分参数的含义
x:被切分的类数组(array-like)数据,必须是1维的(不能用DataFrame);
bins:bins是被切割后的区间(或叫“桶”),有3种形式:一个int型的标量、标量序列(数组)或者pandas.IntervalIndex 。

  • 一个int型的标量
    当bins为一个int型的标量时,代表将x平分成bins份。x的范围在每侧扩展0.1%,以包括x的最大值和最小值。
  • 标量序列
    标量序列定义了被分割后每一个bin的区间边缘,此时x没有扩展,如 [1, 10, 100]。
  • pandas.IntervalIndex
    定义要使用的精确区间。

labels:给分割后的bins打标签,但长度必须和划分后的区间长度相等,比如把年龄x分割成2个年龄段bins后,可以给年龄段打上诸如青年、中年的标签。如果指定labels=False,则返回x中的数据在第几个bin中(从0开始)。

e-2.power特征分桶
data.power.isnull().sum()
> 0

bin = [i*10 for i in range(31)]
data['power_bin'] = pd.cut(data['power'], bin, labels=False)
data[['power_bin', 'power']].head(3).append(data[['power_bin', 'power']].tail(3))

天池二手车价格预测比赛(二)——特征工程步骤_第3张图片
可以看到,有的特征被分到了NaN桶,包括小的特征值0和大的特征值334,而且缺失值也进桶了!!!

e-3.特征分桶的作用

做数据分桶的原因有很多(也有很多其他原因):

  1. 离散后稀疏向量内积乘法运算速度更快,计算结果也方便存储,容易扩展;
  2. 离散后的特征对异常值更具鲁棒性,如 age>30 为 1 否则为 0,对于年龄为 200 的也不会对模型造成很大的干扰;
  3. LR 属于广义线性模型,表达能力有限,经过离散化后,每个变量有单独的权重,这相当于引入了非线性,能够提升模型的表达能力,加大拟合;
  4. 离散后特征可以进行特征交叉,提升表达能力,由 M+N 个变量变成 M*N 个变量,进一步引入非线形,提升了表达能力;
  5. 特征离散后模型更稳定,如用户年龄区间,不会因为用户年龄长了一岁就变化
  6. 增强模型的泛化性能,如:LightGBM 在改进 XGBoost 时就增加了数据分桶,增强了模型的泛化性(与第二条的区别??)
f.数据保存——特征用于树模型
f-1.删除不需要的数据——drop操作

drop函数

# 几个默认参数
DataFrame.drop(self, labels=None, axis=0, index=None, columns=None,  inplace=False)
  • 1.是 DataFrame.drop 而不是 pd.drop
  • labels:single label or list-like
    Index or column labels to drop(根据index删除行还是根据columns删除列).
  • axis:, default 0
  • columns:single label or list-like
    Alternative to specifying axis (labels, axis=1 is equivalent to columns=labels) 与axis搭配使用.
  • inplace:default False
data = data.drop(['creatDate', 'regDate', 'regionCode'], axis=1)
data.shape
> (199037, 39)
f-2.数据的压缩保存和读取

目前的数据其实已经可以给树模型使用了,将文件压缩保存,相比于csv格式,会剩一半内存(81M—32M)

# index=0,不保存行索引
data.to_csv('data_for_tree.gz', index=0)
  • index(是否保存行索引): default True
  • header(是否保留列名 ): default True
    压缩数据的读取
pd.read_csv('data_for_tree.gz')
g.特征构造2——用于 LR NN 之类的模型

分开构造是因为不同模型对数据集的要求不同,LR、NN需要特征归一化和分类特征独热编码??
此外,归一化(Normalization)、标准化(Standardization)和 中心化/零均值化(Zero-centered)是不同的

g-1.power特征的分布图
# pandas作图,分布图是hist,而不是distplot
data['power'].plot.hist()  

前面已经对 train 数据集进行异常值处理了,现在分布极其不均的原因是 test 数据集中的 power 异常值没被处理。所以刚刚 train 中的 power 异常值还是不删为好,可以用长尾分布截断来代替

 # 不能直接加载Train_data,需要进行异常值处理 ,否则也是分布极其不均
Train_data['power'].plot.hist()
g-2.power特征的重新处理——取 log,并归一化

对该特征取 log(原因??),再做归一化,原始的 power 特征不再保留

对特征取 log

data['power'] = np.log(data['power'] + 1) 

注:np.log 的结果仍是Series,而不是 array

特征的归一化——方法一,基于 numpy 的实现

data['power'] = ((data['power'] - np.min(data['power'])) / (np.max(data['power']) - np.min(data['power'])))
data['power'].plot.hist()

注:np.min(data[‘power’]) 的结果是个标量,所以会和 data[‘power’] 进行广播

特征的归一化——方法二,基于 sklearn 的实现

# fit 或者 transform 的数据必须是二维的,单独的一个特征列或元素需要reshape为(-1,1)(1)
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
scaler.fit( data['power'].values.reshape(-1,1) )
# 序列要求数据是一维的
data['power'] = scaler.transform(data['power'].values.reshape(-1,1)).reshape(-1,)
data['power'].plot.hist()

注:sklearn 的 MinMaxScaler 方法处理的是二维数据,而特征取 log 后仍是Series,必须加 .values 转换为 numpy 后才有 reshape方法(一维转化为二维)

g-3.kilometer 特征的归一化

该特征分布比较正常,应该是已经做过分桶了

data['kilometer'].plot.hist()

该特征可以直接做归一化,分布图的形状没变化

data['kilometer'] = (   (data['kilometer'] - np.min(data['kilometer']) ) / 
                        ( np.max(data['kilometer']) - np.min(data['kilometer']))    )
data['kilometer'].plot.hist()
g-4.对新构造的统计量特征的归一化——不可用元素级函数 map、apply
def max_min_Normalization(x):
    """minmax归一化,极差变换,直接作用于series,而不是series的元素"""
    return (x - np.min(x)) / (np.max(x) - np.min(x))

stats_cols = ['brand_amount', 'brand_price_average', 'brand_price_max', 'brand_price_median', 
              'brand_price_min', 'brand_price_std', 'brand_price_sum']
for col in stats_cols:
    data[col] = max_min_Normalization(data[col])

注:series 的 map 是元素级函数,DataFrame中对应的是applymap()函数,当然 DataFrame 的 apply() 函数也是元素级函数

g-5.类别特征的虚拟化——独热码
data = pd.get_dummies(data, columns=['model', 'brand', 'bodyType', 'fuelType', 
					'gearbox', 'notRepairedDamage', 'power_bin'])
data.shape
> (199037, 370)
g-6.删除部分特征,保存数据

由于在取对数及归一化那里进行了两种方法,多生成了两组特征,尾缀分别是 _1 和 _2
方法一:特征挨个删除

for col in stats_cols:
    data.drop(axis=1, columns=[col + '_2'], inplace=True)
    data.drop(axis=1, columns=[col + '_1'], inplace=True)

方法二:特征整组删除,速度更快

# 对列表中的每个字符串元素进行相同的操作
stats_cols1 = [col + '_1' for col in stats_cols]
stats_cols2 = [col + '_2' for col in stats_cols]

data.drop(columns=stats_cols1, axis=1, inplace=True)
data.drop(columns=stats_cols2, axis=1, inplace=True)

删除单个特征

del data['power_log']

数据保存和再读取

data.to_csv('data_for_lr.gz', index=0)
data = pd.read_csv('data_for_lr.gz')

3.特征选择

a.过滤式特征选择

每个数值特征与预测目标相关性分析

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'))

注意:上面是两个 series之间求相关矩阵

绘制 df 的相关矩阵热力图

data_numeric = data[['power', 'kilometer', 'brand_amount', 'brand_price_average', 
                     'brand_price_max', 'brand_price_median', 'price']]
correlation = data_numeric.corr()

# plt 设置画板,sns 作图
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, annot=True, fmt='.3f')
b.包裹式特征选择
b-1.基于 mlxtend 库进行特征选择

原始方法

# pip 下载 mlxtend 库,速度很快
from mlxtend.feature_selection import SequentialFeatureSelector as SFS
from sklearn.linear_model import LinearRegression
sfs = SFS(LinearRegression(),
           k_features=10,  # 选择10个特征,怎么选的?
           forward=True,
           floating=False,
           scoring = 'r2',
           cv = 0)
# 去除预测目标和填充缺失值,并未改变 data 数据
x = data.drop(['price'], axis=1)  
x = x.fillna(0)
y = data['price']
sfs.fit(x, y)
sfs.k_feature_names_ 

存在问题:只是填充了特征中的缺失值,但是没有填充预测目标的缺失值,所以报错:ValueError: Input contains NaN, infinity or a value too large for dtype(‘float64’).

新的方法

# 前面都一致,不同点在后面
x = data.drop(['price'], axis=1)  
x = x.fillna(0)
y = data['price'].fillna(0)
sfs.fit(x, y)
sfs.k_feature_names_ 

将特征选择结果绘制出来,可以看到边际效益

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()

天池二手车价格预测比赛(二)——特征工程步骤_第4张图片

b-2.排查数据中的 NaN 和 inf

排查NaN

a = data.fillna(0).isnull().sum()
a[a > 0]
b = np.isnan(data.fillna(0)).sum()
b[b > 0]

> Series([], dtype: int64) # 结果一致,都没有

排查inf

# False:不包含,所以加总和应该为样本数量,判断的标准应该是0
c = np.isfinite(data.fillna(0)).sum()
c[c == 0]
# True:包含,判断的标准应该大于0
d = np.isinf(data.fillna(0)).sum()
d[d > 0]

> Series([], dtype: int64) # 结果一致,都没有

类似的函数还有

# 正无穷(np.inf)和负无穷(-np.inf)
np.isneginf 
np.isposinf

刚开始忘记找预测目标里的缺失值,导致一直找不到任何缺失值和无穷大值,所以对报错很不解!!

c.嵌入式特征选择

下一章介绍,Lasso 回归和决策树可以完成嵌入式特征选择

参考
[1] https://tianchi.aliyun.com/notebook-ai/detail?spm=5176.12586969.1002.3.1cd8593aK2i841&postId=95501

你可能感兴趣的:(天池二手车价格预测比赛(二)——特征工程步骤)