#机器学习--第2章:特征工程
一、特征工程的意义
特征工程是比赛中最至关重要的的一块,特别的传统的比赛,大家的模型可能都差不多,调参带来的效果增幅是非常有限的,但特征工程的好坏往往会决定了最终的排名和成绩。
特征工程的主要目的还是在于将数据转换为能更好地表示潜在问题的特征,从而提高机器学习的性能。比如,异常值处理是为了去除噪声,填补缺失值可以加入先验知识等。
对于知道特征含义(非匿名)的特征工程,特别是在工业类型比赛中,会基于信号处理,频域提取,丰度,偏度等构建更为有实际意义的特征,这就是结合背景的特征构建,在推荐系统中也是这样的,各种类型点击率统计,各时段统计,加用户属性的统计等等,这样一种特征构建往往要深入分析背后的业务逻辑或者说物理原理,从而才能更好的找到 magic。
当然特征工程其实是和模型结合在一起的,这就是为什么要为 LR NN 做分桶和特征归一化的原因,而对于特征的处理效果和特征重要性等往往要通过模型来验证。
总的来说,特征工程是一个入门简单,但想精通非常难的一件事。
二、特征工程
载入数据
import numpy as np
import pandas as pd
import seaborn as sns
from scipy import stats
import matplotlib.pyplot as plt
from operator import itemgetter
from sklearn import preprocessing
import utils # 这个包是自写包,将在本文末尾放出
train_data = pd.read_csv("二手车交易价格预测/used_car_train_20200313.csv", ' ')
转化数据类型,以节省空间
bdtype = utils.explore_best_datatype(train_data)
# 由上节分析可知这三个特征属于无效特征
del train_data['SaleID'], train_data['seller'], train_data['offerType'], bdtype['SaleID'], bdtype['seller'], bdtype[
'offerType']
# 将数据类型转化为最合适的数据类型
utils.cast_to_datatype(train_data, bdtype)
1、异常处理
# 1.1 通过箱线图删除异常值
def box_plot_outliers(data, scale: float = 3):
"""
利用箱型图处理异常值
:param data: 待处理的数据
:param scale: 箱线图尺度,统计 scale * (上四分位点, 下四分位点) 之外的数据
:return: 返回需要处理的数据行索引
"""
rule_low = data < (data.quantile(0.25) * scale)
rule_up = data > (data.quantile(0.75) * scale)
return np.arange(data.shape[0])[rule_low | rule_up]
# 显示处理前的箱型图
sns.boxplot(train_data['kilometer'])
plt.show()
# 将异常值用均值替代
train_data['kilometer'][box_plot_outliers(train_data['kilometer'], scale=1)] = train_data['kilometer'].mean()
# 显示处理后的箱型图
sns.boxplot(train_data['kilometer'])
plt.show()
- - -
# 1.2 BOX-COX转换
# 因为 BOX-COX 变换要求所有数据为正,而通过以往的分析得知,v_7 里有部分数据为0,因此需要提前处理
# 绘制变换前的频率分布图
sns.distplot(train_data['v_7'])
plt.show()
# 得到所有值为 0 的索引
index = np.arange(train_data['v_7'].shape[0])[train_data['v_7'] == 0]
# 将 0 改为一个极小值
train_data['v_7'][index] = 1e-5
# 进行变换
train_data['v_7'] = stats.boxcox(train_data['v_7'])[0]
# 绘制变换后的频率分布图
sns.distplot(train_data['v_7'])
plt.show()
del index
# 1.3 长尾截断
# 查看处理前的频率分布
sns.distplot(train_data['power'])
plt.show()
# 分析可知 power 大于 500 的只有 287 个,极其稀少,可以考虑使用长尾截断
# 得到大于 500 的索引
index = np.arange(train_data['power'].shape[0])[train_data['power'] > 500]
# 将大于 500 的值替换为 500
train_data['power'][index] = 500
sns.distplot(train_data['power'])
plt.show()
del index
2、特征归一化/标准化
# 2.1 标准化
# 在不知道样本的最大最小值时,将数据按比例缩放,使之落入一个小的特定区间, 即 y=(x-μ)/σ。如下:
x_train = np.array([[1., -1., 2.],
[2., 0., 0.],
[0., 1., -1.]])
x_scaled = preprocessing.scale(x_train)
print(x_scaled)
[[ 0. -1.22474487 1.33630621]
[ 1.22474487 0. -0.26726124]
[-1.22474487 1.22474487 -1.06904497]]
# 2.2 归一化
# 将训练数据区间缩放到[0-1]之间,即 min-max 归一化。如下:
x_train = np.array([[1., -1., 2.],
[2., 0., 0.],
[0., 1., -1.]])
min_max_scaler = preprocessing.MinMaxScaler()
x_train_minmax = min_max_scaler.fit_transform(x_train)
print(x_train_minmax)
[[ 0.5 0. 1. ]
[ 1. 0.5 0.33333333]
[ 0. 1. 0. ]]
3、数据分箱
数据分箱是指将连续特征离散化,多类特征合成少类特征
数据分箱分为有监督和无监督两大类
有监督是指提前人为的设置好分类条件,常见的有:卡方分箱、Best-KS分箱
无监督则无需提前设置分类条件,常见的有:等距分箱、等频分箱、自定义分箱
这里只介绍等距、等频、自定义分箱
数据分箱所带来的好处:
1.一般在建立分类模型时,需要对连续变量离散化,特征离散化后,模型会更稳定,降低了模型过拟合的风险。
2.离散特征的增加和减少都很容易,易于模型的快速迭代
3.稀疏向量内积乘法运算速度快,计算结果方便存储,容易扩展;
4.离散化后的特征对异常数据有很强的鲁棒性:比如一个特征是年龄>30是1,否则0。如果特征没有离散化,一个异常数据“年龄300岁”会给模型造成很大的干扰;
5.逻辑回归属于广义线性模型,表达能力受限;单变量离散化为N个后,每个变量有单独的权重,相当于为模型引入了非线性,能够提升模型表达能力,加大拟合;
6.离散化后可以进行特征交叉,由M+N个变量变为M*N个变量,进一步引入非线性,提升表达能力;
7.特征离散化后,模型会更稳定,比如如果对用户年龄离散化,20-30作为一个区间,不会因为一个用户年龄长了一岁就变成一个完全不同的人。当然处于区间相邻处的样本会刚好相反,所以怎么划分区间是门学问;
8.特征离散化以后,起到了简化了逻辑回归模型的作用,降低了模型过拟合的风险。
9.可以将缺失作为独立的一类带入模型。
10.将所有变量变换到相似的尺度上。
# 3.1 等距分箱,修改 bins 和 labels 后即为自定义分箱
# 注册时间是从19910001至20151212,所以这里按年份进行划分
# 设置划分区间
bins = [int(i * 1e4) for i in range(1991, 2017)]
# 设置每个区间的标签
labels = [i for i in range(1991, 2016)]
# 进行划分
train_data['regDate'] = pd.cut(train_data['regDate'], bins=bins, right=False, labels=labels)
train_data['regDate'] = train_data['regDate'].astype(utils.explore_best_datatype(train_data['regDate'])[0])
del bins, labels
# 3.2 等频分箱
# 将 regDate 按照等频分箱,分为 q=10 组
labels = [i for i in range(10)]
train_data['regDate'] = pd.qcut(train_data['regDate'], q=10, labels=labels)
train_data['regDate'] = train_data['regDate'].astype(utils.explore_best_datatype(train_data['regDate'])[0])
del labels
4、缺失值处理
缺失值处理方式有:
1.不处理
2.删除
3.插值补全,包括:均值、众数、中位数、建模预测、多重插补、压缩感知补全、矩阵补全等方法
4.单独作为一类
# 4.1 不处理
# 在上一节分析中,得知 notRepairedDamage 特征含有 ‘-’
print(train_data['notRepairedDamage'].value_counts())
# 将异常值 - 替换为 nan
train_data['notRepairedDamage'].replace('-', np.nan, inplace=True)
# 4.2 删除
# 在 0 中可以得知, model字段含有 1 个异常值,查看空的那一行
index = train_data['model'][train_data['model'].isnull()].index
print(index)
# 将其删除
train_data.drop(index=index, inplace=True)
del index
# 4.3 插值补全
# 结合实际分析,这几个特征都不太使用插值补全,但这里为了演示,所以用使用
# 补均值
train_data['bodyType'].replace(np.nan, train_data['bodyType'].mean(), inplace=True)
# 补众数
train_data['bodyType'].replace(np.nan, train_data['bodyType'].mode(), inplace=True)
# 补中位数
train_data['bodyType'].replace(np.nan, train_data['bodyType'].median(), inplace=True)
# 4.4 单独作为一类
# 由上节分析可知,notRepairedDamage 的空值很多,但是又无法删除或填补困难,因此可以考虑将其作为单独一类
# 将异常值 - 替换为 -1 , 单独作为一类
train_data['notRepairedDamage'].replace('-', -1, inplace=True)
5、特征工程
数据决定了模型预测的上限,而算法只是在逼近这个极限而已。1-4 都属于数据清洗,降低噪声干扰,增强数据的表达,并为特征工程做准备。
机器学习的核心就是特征工程。好的数据是从原始数据抽取出来对预测结果最有用的信息。好的特征应该做到少而精。特征工程就是指把原始数据转变为模型训练数据的过程。
特征工程需要一定的经验。一般认为特征工程包括:特征提取、特征选择、特征构建三部分。
特征提取主要手段是通过特征间的关系转换,如组合不同的特征得到新的特征、将某一特征经过某一函数转换成新的特征
特征选择主要手段是从特征集合中挑选一组特征作为特征子集。
两者都能帮助减少数据冗余和特征维度。特征提取有时能发现更有意义的特征属性,特征选择的过程经常能表示出每个特征的重要性对于模型构建的重要性。
特征构建主要手段是通过特征分割和结合。
结构性的表格数据,可以尝试组合二、三个不同的特征构造新的特征。如果存在时间相关属性,可以划出不同的时间窗口,得到同一特征在不同时间下的特征值。
总之特征工程是个非常麻烦的问题,书里面也很少提到具体的方法,需要对问题有比较深入的理解,从原始数据中人工的找出一些具有物理意义的特征。
需要花时间去观察原始数据,思考问题的潜在形式和数据结构,对数据敏感性和机器学习实战经验能帮助特征构建。
进行特征工程时应该考虑三个问题。一,这个特征是否对目标有用?二,如果有用,这个特征重要性如何?三,这个特征的信息是否在其他特征上体现过?
列举几个常用特征工程的特征构造方法(但是本博客只挑几个对于本问题有实际意义的方法,就不一一介绍了):
1.构造统计量特征,报告计数、求和、比例、标准差等;
2.构造时间特征,包括相对时间和绝对时间,节假日,双休日等;
3.地理信息,包括分箱,分布编码等方法;
4.非线性变换,包括 log/ 平方/ 根号等;
5.特征组合,特征交叉;
6.仁者见仁,智者见智。
# 5.1 构造相关统计量
brand = train_data.groupby("brand")
brand_info = {}
for brand_id, data in brand:
info = {}
data = data[data['price'] > 0]
info['brand_amount'] = len(data)
info['brand_price_max'] = data.price.max()
info['brand_price_min'] = data.price.min()
info['brand_price_mean'] = data.price.mean()
brand_info[brand_id] = info
brand_info = pd.DataFrame(brand_info).T.reset_index().rename(columns={"index": "brand"})
train_data = train_data.merge(brand_info, how="left", on="brand")
del brand, brand_info, brand_id, data, info
# 5.2 构造相对时间特征
# regDate 为汽车注册时间,creatDate 为汽车售卖时间,因此可以利用这两个构造出新的特征:二手车使用时间
# usedTime = creatDate - regDate
# errors="coerce" 表示强制进行转换,如果转换失败则置为NaT .dt为Series的datetimelike properties的访问器
train_data['usedTime'] = (pd.to_datetime(train_data['creatDate'], errors="coerce", format="%Y%m%d") -
pd.to_datetime(train_data['regDate'], errors="coerce", format="%Y%m%d")).dt.days
# 可以看到缺失值(转换失败)有 11k 个
print(train_data['usedTime'].isnull().sum())
# 5.6 从邮编中提取城市信息,相当于加入了先验知识
train_data['city'] = train_data['regionCode'].apply(lambda x: str(x)[:-3])
train_data['city'].replace('', np.nan, inplace=True)
# 5.6 对类别特征进行 one-hot 编码
train_data = pd.get_dummies(train_data, columns=['model', 'brand', 'bodyType', 'fuelType',
'gearbox', 'notRepairedDamage', 'power_bin'])
6、特征筛选(特征筛选属于特征工程中的特征选择)
# 相关性分析
print(train_data['power'].corr(train_data['price'], method='spearman'))
print(train_data['kilometer'].corr(train_data['price'], method='spearman'))
print(train_data['brand_amount'].corr(train_data['price'], method='spearman'))
print(train_data['brand_price_average'].corr(train_data['price'], method='spearman'))
print(train_data['brand_price_max'].corr(train_data['price'], method='spearman'))
print(train_data['brand_price_median'].corr(train_data['price'], method='spearman'))
# 当然也可以直接看图
data_numeric = train_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)
del data_numeric, f, ax, correlation
7、降维
降维就是指采用某种映射方法,将原高维空间中的数据点映射到低维度的空间中。降维的本质是学习一个映射函数 f : x->y,
其中x是原始数据点的表达,目前最多使用向量表达形式。 y是数据点映射后的低维向量表达,通常y的维度小于x的维度(当然提高维度也是可以的)。
主要算法有:
1.PCA:Principal Component Analysis
2.LDA:Linear Discriminant Analysis
3.LLE:Locally linear embedding
4.Laplacian Eigenmaps
这里的特征维度不算高,因此这次不进行降维处理,详细算法请参考 https://blog.csdn.net/qq_15719037/article/details/80454113
utils 源码
import numpy as np
import pandas
import re
def isnumber(number: str) -> bool:
"""
判断给定字符串是否为数字
:param number: 待判断字符串
:return: 是数字返回True,否则返回 False
"""
not_number = ["inf", "-inf", "nan"]
try:
if not_number.count(number):
return False
float(number)
return True
except ValueError:
return False
def isint(number: str) -> bool:
"""
判断给定的字符串是否可以在不丢失精度的前提下转化为 int
如:
number = 0.0 -> True
number = 0.1 -> False
number = 1e-1 -> False
number = 1e1 -> True
:param number: 待判断字符串
:return: bool
"""
try:
return int(str(float(number)).split('.')[1]) is 0
except ValueError:
return False
def explore_best_datatype(data: pandas.DataFrame or pandas.Series, name=None) -> dict:
"""
探索数据的最佳 numpy 数据类型,以节省存储空间
不考虑特征中的非数字特征值
:param data: 待探索数据
:param name: 指定待探索数据特征名称,仅当 data 数据类型为 pandas.Series 时生效
:return: 返回各个特征的最佳数据类型的字典,其中 __tips 为各个特征所含非数值特征的数量
"""
def _replace(_feature_dtype, _replaced_dtype):
order = [np.object, np.int8, np.int16, np.int32, np.int64, np.float32, np.float64]
return _replaced_dtype if order.index(_replaced_dtype) > order.index(_feature_dtype) else _feature_dtype
def _explore(_data: pandas.Series, name, _tips):
_feature_dtype = np.object
int_dtype = [np.int8, np.int16, np.int32, np.int64]
# 如果该特征的数据类型是 int 型
if int_dtype.count(_data.dtype):
m_max = _data.max()
m_min = abs(_data.min())
m_range = m_max if m_max > m_min else m_min
# int16
if m_range > 32767:
_feature_dtype = np.int32 if m_range < 2147483647 else np.int64
else:
_feature_dtype = np.int8 if m_range < 127 else np.int16
else:
for row in _data:
d = str(row)
# 对于非数值没必要继续检查
if not isnumber(d):
if _tips.get(name) is None:
_tips.update({name: {d: 1}})
# print("feature type '{}' contain non-numeric feature '{}'".format(name, d))
elif _tips[name].get(d) is None:
_tips[name].update({d: 1})
# print("feature type '{}' contain non-numeric feature '{}'".format(name, d))
else:
_tips[name][d] += 1
continue
# 如果 d 为浮点数且小数位不全为0
elif not isint(d):
# 如果小数位大于 6 则应用 numpy.float64 存储
_feature_dtype = _replace(_feature_dtype,
np.float64 if len(d.split('.')[1]) > 6 else np.float32)
# 如果 d 为整数或小数位全为0的浮点数
else:
t = abs(int(d.split('.')[0]))
# int16
if t > 32767:
_feature_dtype = _replace(_feature_dtype,
np.int32 if t < 2147483647 else np.int64)
else:
_feature_dtype = _replace(_feature_dtype,
np.int8 if t < 127 else np.int16)
return _feature_dtype
feature_dtype = {}
tips = {}
if isinstance(data, pandas.DataFrame):
for col in data.columns:
feature_dtype.update({col: _explore(data[col], col, tips)})
elif isinstance(data, pandas.Series):
if name is None:
name = data.name
feature_dtype.update({name: _explore(data, 0, tips)})
feature_dtype.update({'__tips': tips})
return feature_dtype
def cast_to_datatype(data: pandas.DataFrame or pandas.Series, dtype: dict):
"""
将数据转换为指定数据类型
:param data: 待转换数据
:param dtype: 目标数据类型
:return: 返回转换后的数据
"""
tips = dtype['__tips']
t = dtype.copy()
del t['__tips']
for i in t.keys():
if tips.get(i) is None:
data[i] = data[i].astype(t[i])
else:
print("'{}' has non numerical value {}, quantity is {} please handle it first.".
format(i, list(tips[i].keys()), list(tips[i].values())))