特征工程
什么是特征工程
“数据决定了机器学习的上限,而算法只是尽可能逼近这个上限”,这里的数据指的就是经过特征工程得到的数据。特征工程指的是把原始数据转变为模型的训练数据的过程,它的目的就是获取更好的训练数据特征,使得机器学习模型逼近这个上限。特征工程在机器学习中占有非常重要的作用,一般认为括特征构建、特征提取、特征选择三个部分。
特征构建
特征构建是指从原始数据中人工的找出一些具有物理意义的特征。需要花时间去观察原始数据,思考问题的潜在形式和数据结构,对数据敏感性和机器学习实战经验能帮助特征构建。
特征提取、特征选择
特征提取与特征选择都是为了从原始特征中找出最有效的特征。它们之间的区别是特征提取强调通过特征转换的方式得到一组具有明显物理或统计意义的特征;而特征选择是从特征集合中挑选一组具有明显物理或统计意义的特征子集。
常见的特征工程
-
异常处理:
- 通过箱线图(或 3-Sigma)分析删除异常值;
- BOX-COX 转换(处理有偏分布);
- 长尾截断;
-
特征归一化/标准化:
- 标准化(转换为标准正态分布);
- 归一化(抓换到 [0,1] 区间);
-
针对幂律分布,可以采用公式:
lo
g
(
1
+
x
1
+
m
e
d
i
a
n
)
log(\frac{1+x}{1+median})
l
o
g
(
1
+
m
e
d
i
a
n
1
+
x
)
-
数据分桶:
- 等频分桶;
- 等距分桶;
- Best-KS 分桶(类似利用基尼指数进行二分类);
- 卡方分桶;
-
缺失值处理:
- 不处理(针对类似 XGBoost 等树模型);
- 删除(缺失数据太多);
- 插值补全,包括均值/中位数/众数/建模预测/多重插补/压缩感知补全/矩阵补全等;
- 分箱,缺失值一个箱;
-
特征构造:
- 构造统计量特征,报告计数、求和、比例、标准差等;
- 时间特征,包括相对时间和绝对时间,节假日,双休日等;
- 地理信息,包括分箱,分布编码等方法;
- 非线性变换,包括 log/ 平方/ 根号等;
- 特征组合,特征交叉;
- 仁者见仁,智者见智。
-
特征筛选
- 过滤式(filter):先对数据进行特征选择,然后在训练学习器,常见的方法有 Relief/方差选择发/相关系数法/卡方检验法/互信息法;
- 包裹式(wrapper):直接把最终将要使用的学习器的性能作为特征子集的评价准则,常见方法有 LVM(Las Vegas Wrapper) ;
- 嵌入式(embedding):结合过滤式和包裹式,学习器训练过程中自动进行了特征选择,常见的有 lasso 回归;
-
降维
- PCA/ LDA/ ICA;
- 特征选择也是一种降维。
实战演练
导入数据
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
from operator import itemgetter
%matplotlib inline
#显示所有列
pd.set_option('display.max_columns', None)
#显示所有行
pd.set_option('display.max_rows', None)
#设置value的显示长度为100,默认为50
pd.set_option('max_colwidth',100)
train = pd.read_csv('data/used_car_train_20200313.csv', sep=' ')
test = pd.read_csv('data/used_car_testA_20200313.csv', sep=' ')
print(train.shape)
print(test.shape)
train.head()
train.columns
test.columns
train的列名:
Index(['SaleID', 'name', 'regDate', 'model', 'brand', 'bodyType', 'fuelType',
'gearbox', 'power', 'kilometer', 'notRepairedDamage', 'regionCode',
'seller', 'offerType', 'creatDate', 'price', 'v_0', 'v_1', 'v_2', 'v_3',
'v_4', 'v_5', 'v_6', 'v_7', 'v_8', 'v_9', 'v_10', 'v_11', 'v_12',
'v_13', 'v_14'],
dtype='object')
删除异常值
这里定义了一个异常值处理的代码
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))
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 column 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
这里我们可以参考EDA中的分析,删除power中的异常值:
train = outliers_proc(train, 'power', scale=3)
Delete number is: 963
Now column number is: 149037
可以对比删除前后的箱型图,明显正常了很多。
特征构建
特征可以分为三个部分:日期特征、类别特征、数值特征。
使用时间:data[‘creatDate’] – data[‘regDate’],反应汽车使用时间,一般来说价格与使用时间成反比,不过数据里有时间出错的格式,所以我们需要 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
再看一下缺失数据:
data['used_time'].isnull().sum()
有15k个样本的数据有问题,可以删除但不建议删除,毕竟占了总数将近十分之一。
从邮编中提取城市信息,因为是德国的数据,所以参考德国的邮编。
data['city'] = data['regionCode'].apply(lambda x : str(x)[:-3])
计算某品牌的销售统计量还有其他特征的统计量,这里要以 train 的数据计算统计量:
train_gb = train.groupby("brand")
all_info = {}
for kind, kind_data in train_gb:
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
brand_fe = pd.DataFrame(all_info).T.reset_index().rename(columns={"index": "brand"})
data = data.merge(brand_fe, how='left', on='brand')
print(all_info)
这边的结果提取一小部分:
brand:
{0: {'brand_amount': 31429, 'brand_price_max': 68500, 'brand_price_median': 3199.0, 'brand_price_min': 13, 'brand_price_sum': 173719698, 'brand_price_std': 6261.371627193883, 'brand_price_average': 5527.19},
1: {'brand_amount': 13656, 'brand_price_max': 84000, 'brand_price_median': 6399.0, 'brand_price_min': 15, 'brand_price_sum': 124044603, 'brand_price_std': 8988.865406006838, 'brand_price_average': 9082.86},
2: {'brand_amount': 318, 'brand_price_max': 55800, 'brand_price_median': 7500.0, 'brand_price_min': 35, 'brand_price_sum': 3766241, 'brand_price_std': 10576.224443852676, 'brand_price_average': 11806.4},
model:
{0.0: {'model_amount': 11743, 'model_price_max': 40000, 'model_price_median': 2900.0, 'model_price_min': 13, 'model_price_sum': 60634760, 'model_price_std': 5737.838198390163, 'model_price_average': 5163.04},
1.0: {'model_amount': 5926, 'model_price_max': 99999, 'model_price_median': 1650.0, 'model_price_min': 11, 'model_price_sum': 22521045, 'model_price_std': 5939.746711764458, 'model_price_average': 3799.74},
2.0: {'model_amount': 283, 'model_price_max': 55800, 'model_price_median': 6390.0, 'model_price_min': 150, 'model_price_sum': 2549787, 'model_price_std': 9036.969110088265, 'model_price_average': 8978.12},
数据分桶
分区提供了一个隔离数据和优化查询的便利方式,不过并非所有的数据都可形成合理的分区,尤其是需要确定合适大小的分区划分方式,(不合理的数据分区划分方式可能导致有的分区数据过多,而某些分区没有什么数据的尴尬情况)试试分桶是将数据集分解为更容易管理的若干部分的另一种技术。
以 power 为例,这时候我们的缺失值也进桶了,为什么要做数据分桶呢,原因有很多:
- 离散后稀疏向量内积乘法运算速度更快,计算结果也方便存储,容易扩展;
- 离散后的特征对异常值更具鲁棒性,如 age>30 为 1 否则为 0,对于年龄为 200 的也不会对模型造成很大的干扰;
- LR 属于广义线性模型,表达能力有限,经过离散化后,每个变量有单独的权重,这相当于引入了非线性,能够提升模型的表达能力,加大拟合;
- 离散后特征可以进行特征交叉,提升表达能力,由 M+N 个变量编程 M*N 个变量,进一步引入非线形,提升了表达能力;
-
特征离散后模型更稳定,如用户年龄区间,不会因为用户年龄长了一岁就变化
当然还有很多原因,LightGBM 在改进 XGBoost 时就增加了数据分桶,增强了模型的泛化性
bin = [i*10 for i in range(31)]
data['power_bin'] = pd.cut(data['power'], bin, labels=False)
data[['power_bin', 'power']].head()
利用好了就可以删除原始数据了:
data = data.drop(['creatDate', 'regDate', 'regionCode'], axis=1)
print(data.shape)
data.columns
(199037, 53)
27
Index(['SaleID', 'name', 'model', 'brand', 'bodyType', 'fuelType', 'gearbox',
'power', 'kilometer', 'notRepairedDamage', 'seller', 'offerType',
'price', 'v_0', 'v_1', 'v_2', 'v_3', 'v_4', 'v_5', 'v_6', 'v_7', 'v_8',
'v_9', 'v_10', 'v_11', 'v_12', 'v_13', 'v_14', 'train', 'used_time',
'city', 'brand_amount_x', 'brand_price_max_x', 'brand_price_median_x',
'brand_price_min_x', 'brand_price_sum_x', 'brand_price_std_x',
'brand_price_average_x', 'brand_amount_y', 'brand_price_max_y',
'brand_price_median_y', 'brand_price_min_y', 'brand_price_sum_y',
'brand_price_std_y', 'brand_price_average_y', 'model_amount',
'model_price_max', 'model_price_median', 'model_price_min',
'model_price_sum', 'model_price_std', 'model_price_average',
'power_bin'],
dtype='object')
目前的数据其实已经可以给树模型使用了,所以我们导出一下
data.to_csv('data_for_tree.csv', index=0)
总结
总的来说,特征工程是一个入门简单,但想精通非常难的一件事。
与EDA不同,特征工程这一环节基本覆盖我的知识盲区,几乎每个名词都要查询,不过不会的越多说明收获的就越多,加油!