专栏名称: 数据派THU
本订阅号是“THU数据派”的姊妹账号,致力于传播大数据价值、培养数据思维。
目录
相关文章推荐
软件定义世界(SDX)  ·  工业大模型的演进及落地方向 ·  22 小时前  
数据派THU  ·  AAAI2025|只根据题目和摘要就能预测论 ... ·  昨天  
玉树芝兰  ·  对科研工作者来说,OpenAI Deep ... ·  2 天前  
数据派THU  ·  GraphTeam: ... ·  3 天前  
数据派THU  ·  LossVal:一种集成于损失函数的高效数据 ... ·  4 天前  
51好读  ›  专栏  ›  数据派THU

机器学习特征工程,全面指南!(下)

数据派THU  · 公众号  · 大数据  · 2024-12-30 17:00

正文


本文约11200字,建议阅读20分钟
本指南是初学者的简明参考,提供了最简单但广泛使用的特征工程和选择技术。


4  特征工程


4.1  特征缩放


定义:特征缩放是一种用于标准化数据自变量或特征范围的方法。在数据处理中,它也被称为数据归一化,通常在数据预处理步骤中执行。


4.1.1  为什么特征缩放很重要


如果输入范围发生变化,在某些算法中,目标函数将无法正常工作。梯度下降在完成特征缩放后收敛得更快。


梯度下降是一种常用的优化算法,用于逻辑回归、支持向量机、神经网络等。


涉及距离计算的算法,如KNN、聚类,也受到特征大小的影响。只需考虑欧几里德距离的计算方法:取观测值之间平方差之和的平方根。这种距离会受到变量之间尺度差异的极大影响。方差较大的变量对这种度量的影响比方差较小的变量大。


注意:基于树的算法几乎是唯一不受输入量影响的算法,因为我们可以很容易地从树的构建方式中看出。在决定如何进行分割时,树算法会寻找诸如“特征值X是否大于3.0”之类的决策,并在分割后计算子节点的纯度,因此特征的规模并不重要。


4.1.2  如何处理特征缩放



面对异常值时三种方法的比较:



正如我们所看到的,归一化-标准化和最小-最大方法会将大多数数据压缩到一个较窄的范围,而鲁棒缩放器在保持数据分布方面做得更好,尽管它不能从处理结果中删除异常值。记住删除/输入异常值是数据清理中的另一个主题,应该提前完成。


关于如何选择特征缩放方法的经验:


  • 如果你的特征不是高斯分布,比如,具有偏斜分布或异常值,那么归一化-标准化不是一个好的选择,因为它会将大多数数据压缩到一个狭窄的范围。
  • 然而,我们可以将特征转换为高斯分布,然后使用归一化 - 标准化。特征转换将在第3.4节中讨论。
  • 在进行距离或协方差计算(如聚类、PCA和LDA等算法)时,最好使用归一化-标准化,因为它会消除尺度对方差和协方差的影响。
  • Min-Max缩放与Normalization-Standardization具有相同的缺点,并且新数据可能不会限制在[0,1],因为它们可能超出原始范围。一些算法,例如一些深度学习网络,更喜欢在0-1范围内输入,因此这是一个不错的选择。

以下是关于此主题的一些额外资源:

  • 当面对偏斜变量时,三种方法的比较可以在这里找到。
  • 关于特征缩放的深入研究可以在这里找到。

4.2  离散化

定义:离散化是通过创建一组跨越变量值范围的连续区间,将连续变量转换为离散变量的过程。

4.2.1  为什么离散化很重要

  • 通过将具有相似预测强度的相似属性进行 分组,有助于提高模型性能
  • 引入非线性,从而提高模型的拟合能力
  • 通过分组值增强可解释性
  • 尽量减少极端值/很少反转模式的影响
  • 防止数值变量可能出现的过拟合
  • 允许连续变量之间的特征交互(第4.5.5节)

4.2.2  如何处理离散化


一般来说,没有最好的离散化方法。这确实取决于数据集和后续的学习算法。在决定之前,仔细研究你的特性和上下文。你也可以尝试不同的方法并比较模型的性能。

4.3  特征编码

4.3.1  为什么特征编码很重要

我们必须将分类变量的字符串转换为数字,以便算法能够处理这些值。即使你看到一个算法可以接受分类输入,最有可能的是,该算法将编码过程纳入其中。

4.3.2  如何处理特征编码




注意:如果我们在线性回归中使用one-hot编码,我们应该保留k-1个二进制变量以避免多重共线性。这对于在训练期间同时查看所有特征的任何算法都是如此。包括SVM、神经网络和聚类。另一方面,基于树的算法需要整个二进制变量集来选择最佳分割。


注意:不建议在树算法中使用one-hot编码。one-hot将导致分裂高度不平衡(因为原始分类特征的每个标签现在都是一个新特征),结果导致两个子节点中的任何一个都不会有很好的纯度增益。由于one-hot特征被分解为许多部分,因此one-hot特征的预测能力将弱于原始特征。

可以在这里找到关于WOE的详细介绍。

4.4  特征转换

4.4.1  为什么特征变换很重要

4.4.1.1  线性假设

回归

线性回归是一种直接的方法,用于预测定量响应Y,基于不同的预测变量X1、X2、... Xn。它假设X(s)和Y之间存在线性关系。从数学上讲,我们可以将这种线性关系写成Y≈β0+β1X1+β2X2+…+βnXn。

分类

同样,对于分类,逻辑回归假设变量与对数机率之间存在线性关系。

赔率=p/(1-p),其中p是y=1的概率

对数(概率)=β0 + β1X1 + β2X2 + ... + βnXn

为什么遵循线性假设很重要

如果机器学习模型假设预测值Xs和结果Y之间存在线性关系,当不存在这种线性关系时,模型的表现会较差。在这种情况下,我们最好尝试另一种不作这种假设的机器学习模型。

如果没有线性关系,我们必须使用线性/逻辑回归模型,数学变换/离散化可能有助于建立关系,尽管它不能保证更好的结果。

4.4.1.2 变量分布

线性回归假设

线性回归对预测变量X有以下假设:

  • 与结果Y之间存在线性关系
  • 多元正态性
  • 无或很少多重共线性
  • 同方差性

正态假设意味着每个变量X都应遵循高斯分布。

同方差性,也称为方差齐性,描述了一种情况,即误差项(即独立变量(Xs)和因变量(Y)之间的关系中的“噪声”或随机干扰)在所有独立变量的值中都是相同的。

违反同方差性和/或正态性的假设(假设数据分布是同方差或高斯的,而实际上不是)可能会导致模型性能不佳。

其余的机器学习模型,包括神经网络、支持向量机、基于树的方法和PCA,对自变量的分布没有任何假设。然而,在许多情况下,模型性能可能会受益于“类高斯”分布。

为什么模型可以从“高斯型”分布中受益?在正态分布的变量中,可用于预测Y的X观测值在更大的值范围内变化,即X的值在更大的范围内“扩散”。

在上述情况下,对原始变量的转换可以帮助使变量更接近高斯分布的钟形。

4.4.2  如何处理特征变换

对数变换在应用于偏斜分布时非常有用,因为它们往往会扩大落在较低幅度范围内的 值,并倾向于压缩或减少落在较高幅度范围内的值,这有助于使偏斜分布尽可能地接近正态分布。

平方根变换在这个意义上做类似的事情。

sklearn中的Box-Cox变换是另一种属于幂变换函数家族的流行函数。该函数有一个先决条件,即要转换的数值必须是正数(类似于对数变换所期望的)。如果它们是负数,则使用常数值进行移位会有所帮助。从数学上讲,Box-Cox变换函数可以表示如下。

sklearn中的分位数变换将特征转换为遵循均匀分布或正态分布。因此,对于给定的特征,这种变换往往会分散最常见的值。它还降低了(边际)异常值的影响:因此,这是一种稳健的预处理方案。然而,这种变换是非线性的。它可能会扭曲以相同尺度测量的变量之间的线性相关性,但会使以不同尺度测量的变量更直接地具有可比性。
我们可以用QQ图来检查变量在转换后是否呈正态分布(理论分位数上值的45度直线)。

下面是一个例子,展示了sklearn的箱线图/Yeo-johnson/分位数变换的效果,将各种分布的数据映射到正态分布。

在“小”数据集(少于几百个点)上,分位数转换器很容易过度拟合。建议使用幂变
换。

4.5  特征生成

定义:通过现有功能的组合创建新功能。这是向数据集添加领域知识的好方法。

4.5.1  缺失数据派生特征

如第3.1节所述,我们可以创建一个新的二进制特征,用0/1表示原始特征上的观察值是否有缺失值。

4.5.2  简单统计派生特征

通过对原始特征进行简单的统计计算来创建新特征,包括:

  • 计数/求和
  • 平均值/中值/众数
  • 最大值/最小值/标准偏差/方差/范围/四分位数间距/变异系数
  • 时间跨度/间隔

以通话记录为例,我们可以创建新的功能,如通话次数、呼入/呼出次数、平均通话时长、每月平均通话时长、最长通话时长等。

4.5.3  特征交叉

在获得一些简单的统计衍生特征后,我们可以将它们交叉在一起。用于交叉的常见维度包括:

  • 时间
  • 区域
  • 业务类型

还是以通话记录为例,我们可以拥有交叉特征,如:夜间/日间通话次数、不同业务类型(银行/出租车服务/旅行/酒店)下的通话次数、过去3个月的通话次数等。第4.5.2节中提到的许多统计计算可以再次用于创建更多特征。

注意:可以在此处找到一个名为Featuretools的开源python框架,它可以帮助自动生成这些特征。

4.5.4  比率和比例

常见技术。例如,为了预测一个分支机构信用卡销售的未来表现,信用卡销售/销售人员或信用卡销售/营销支出等比率将比仅使用分支机构销售的绝对卡数更有说服力。

4.5.5  类别特征之间的叉积

考虑一个类别特征 A,有两个可能的值 {A1, A2}。假设 B 是一个具有可能性 {B1, B2} 的特征。那么,A 和 B 之间的特征交叉将采用以下值之一:{(A1, B1), (A1, B2), (A2, B1), (A2, B2)}。你基本上可以给这些“组合”任何你喜欢的名字。只要记住,每个组合都表示 A 和 B 的相应值所包含的信息之间的协同作用。

这是一种非常有用的技术,当某些特征共同表示一个属性时,比单独表示更好。从数学上讲,你正在对分类特征的所有可能值进行叉积。这个概念类似于第3.5.3节的特征交叉,但这个概念特别指的是两个分类特征之间的交叉。

4.5.6  多项式展开

叉积也可以应用于数值特征,从而在A和B之间产生新的交互特征。这可以通过sklearn的多项式特征轻松实现,它生成一个新的特征集,由所有特征的多项式组合组成,其次数小于或等于指定的次数。例如,三个原始特征{X1,X2,X3}可以生成一个特征集{1,X1X2,X1X3,X2X3,

4.5.7  通过树进行特征学习

在基于树的算法中,每个样本将被分配到一个特定的叶子节点。每个节点的决策路径可以被视为一个新的非线性特征,我们可以创建N个新的二元特征,其中n等于树或树集合中的叶子节点总数。然后,这些特征可以被馈送到其他算法,如逻辑回归。

本文中首次引入了使用树算法生成新特征的想法。

这种方法的好处是我们可以将几个特征的复杂组合组合在一起,这很有意义(正如树的学习算法所构造的那样)。与手动进行特征交叉相比,这为我们节省了大量时间,并且广泛用于在线广告行业的点击率(CTR)。

4.5.8  通过深度网络进行特征学习

从以上内容中我们可以看出,人工生成特征需要付出大量努力,并且可能无法保证良好的回报,特别是在我们拥有大量特征的情况下。使用树进行特征学习可以被视为自动创建特征的早期尝试,随着深度学习方法在2016年左右流行起来,它们也在这一领域取得了一些成功,如自动编码器和受限玻尔兹曼机。它们已被证明可以自动并以无监督或半监督的方式学习特征的抽象表示(压缩形式),这反过来又支持了语音识别、图像分类、物体识别等领域最先进的结果。然而,这些特征的可解释性有限,深度学习需要更多的数据才能提取高质量的结果。

4.5.9 特征生成代码

以下是一些常用的特征加工函数:

# pandas自带的聚合函数




    

mean(): Compute mean of groupssum(): Compute sum of group valuessize(): Compute group sizescount(): Compute count of groupstd(): Standard deviation of groupsvar(): Compute variance of groupssem(): Standard error of the mean of groupsfirst(): Compute first of group valueslast(): Compute last of group valuesnth() : Take nth value, or a subset if n is a listmin(): Compute min of group valuesmax(): Compute max of group values# 自定义函数def median(x): return np.median(x)def variation_coefficient(x): mean = np.mean(x) if mean != 0: return np.std(x) / mean else: return np.nandef variance(x): return np.var(x)def skewness(x): if not isinstance(x, pd.Series): x = pd.Series(x) return pd.Series.skew(x)def kurtosis(x): if not isinstance(x, pd.Series): x = pd.Series(x) return pd.Series.kurtosis(x)def standard_deviation(x): return np.std(x)def large_standard_deviation(x): if (np.max(x)-np.min(x)) == 0: return np.nan else: return np.std(x)/(np.max(x)-np.min(x))def variation_coefficient(x): mean = np.mean(x) if mean != 0: return np.std(x) / mean else: return np.nandef variance_std_ratio(x): y = np.var(x) if y != 0: return y/np.sqrt(y) else: return np.nandef ratio_beyond_r_sigma(x, r): if x.size == 0: return np.nan else: return np.sum(np.abs(x - np.mean(x)) > r * np.asarray(np.std(x))) / x.sizedef range_ratio(x): mean_median_difference = np.abs(np.mean(x) - np.median(x)) max_min_difference = np.max(x) - np.min(x) if max_min_difference == 0: return np.nan else: return mean_median_difference / max_min_difference
def has_duplicate_max(x): return np.sum(x == np.max(x)) >= 2def has_duplicate_min(x): return np.sum(x == np.min(x)) >= 2def has_duplicate(x): return x.size != np.unique(x).sizedef count_duplicate_max(x): return np.sum(x == np.max(x))def count_duplicate_min(x): return np.sum(x == np.min(x))def count_duplicate(x): return x.size - np.unique(x).sizedef sum_values(x): if len(x) == 0: return 0 return np.sum(x)def log_return(list_stock_prices): return np.log(list_stock_prices).diff() def realized_volatility(series): return np.sqrt(np.sum(series**2))def realized_abs_skew(series): return np.power(np.abs(np.sum(series**3)),1/3)def realized_skew(series): return np.sign(np.sum(series**3))*np.power(np.abs(np.sum(series**3)),1/3)def realized_vol_skew(series): return np.power(np.abs(np.sum(series**6)),1/6)def realized_quarticity(series): return np.power(np.sum(series**4),1/4)def count_unique(series): return len(np.unique(series))def count(series): return series.size#drawdons functions are minedef maximum_drawdown(series): series = np.asarray(series) if len(series)<2: return 0 k = series[np.argmax(np.maximum.accumulate(series) - series)] i = np.argmax(np.maximum.accumulate(series) - series) if len(series[:i])<1: return np.NaN else: j = np.max(series[:i]) return j-kdef maximum_drawup(series): series = np.asarray(series) if len(series)<2: return 0
series = - series k = series[np.argmax(np.maximum.accumulate(series) - series)] i = np.argmax(np.maximum.accumulate(series) - series) if len(series[:i])<1: return np.NaN else: j = np.max(series[:i]) return j-kdef drawdown_duration(series): series = np.asarray(series) if len(series)<2: return 0 k = np.argmax(np.maximum.accumulate(series) - series) i = np.argmax(np.maximum.accumulate(series) - series) if len(series[:i]) == 0: j=k else: j = np.argmax(series[:i]) return k-jdef drawup_duration(series): series = np.asarray(series) if len(series)<2: return 0 series=-series k = np.argmax(np.maximum.accumulate(series) - series) i = np.argmax(np.maximum.accumulate(series) - series) if len(series[:i]) == 0: j=k else: j = np.argmax(series[:i]) return k-jdef max_over_min(series): if len(series)<2: return 0 if np.min(series) == 0: return np.nan return np.max(series)/np.min(series)def mean_n_absolute_max(x, number_of_maxima = 1): """ Calculates the arithmetic mean of the n absolute maximum values of the time series.""" assert ( number_of_maxima > 0 ), f" number_of_maxima={number_of_maxima} which is not greater than 1" n_absolute_maximum_values = np.sort(np.absolute(x))[-number_of_maxima:] return np.mean(n_absolute_maximum_values) if len(x) > number_of_maxima else np.NaNdef count_above(x, t): if len(x)==0: return np.nan else: return np.sum(x >= t) / len(x)def count_below(x, t): if len(x)==0: return np.nan else: return np.sum(x <= t) / len(x)#number of valleys = number_peaks(-x, n)def number_peaks(x, n): """ Calculates the number of peaks of at least support n in the time series x. A peak of support n is defined as a subsequence of x where a value occurs, which is bigger than its n neighbours to the left and to the right. """ x_reduced = x[n:-n] res = None for i in range(1, n + 1): result_first = x_reduced > _roll(x, i)[n:-n] if res is None: res = result_first else: res &= result_first res &= x_reduced > _roll(x, -i)[n:-n] return np.sum(res)def mean_abs_change(x): return np.mean(np.abs(np.diff(x)))def mean_change(x): x = np.asarray(x) return (x[-1] - x[0]) / (len(x) - 1) if len(x) > 1 else np.NaNdef mean_second_derivative_central(x): x = np.asarray(x) return (x[-1] - x[-2] - x[1] + x[0]) / (2 * (len(x) - 2)) if len(x) > 2 else np.NaNdef root_mean_square(x): return np.sqrt(np.mean(np.square(x))) if len(x) > 0 else np.NaNdef absolute_sum_of_changes(x): return np.sum(np.abs(np.diff(x)))def longest_strike_below_mean(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) return np.max(_get_length_sequences_where(x < np.mean(x))) if x.size > 0 else 0def longest_strike_above_mean(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) return np.max(_get_length_sequences_where(x > np.mean(x))) if x.size > 0 else 0def count_above_mean(x): m = np.mean(x) return np.where(x > m)[0].sizedef count_below_mean(x): m = np.mean(x) return np.where(x < m)[0].sizedef last_location_of_maximum(x): x = np.asarray(x) return 1.0 - np.argmax(x[::-1]) / len(x) if len(x) > 0 else np.NaNdef first_location_of_maximum(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) return np.argmax(x) / len(x) if len(x) > 0 else np.NaNdef last_location_of_minimum(x): x = np.asarray(x) return 1.0 - np.argmin(x[::-1]) / len(x) if len(x) > 0 else np.NaNdef first_location_of_minimum(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) return np.argmin(x) / len(x) if len(x) > 0 else np.NaN# Test non-consecutive non-reoccuring values ?def percentage_of_reoccurring_values_to_all_values(x): if len(x) == 0: return np.nan unique, counts = np.unique(x, return_counts=True) if counts.shape[0] == 0: return 0 return np.sum(counts > 1) / float(counts.shape[0])def percentage_of_reoccurring_datapoints_to_all_datapoints(x): if len(x) == 0: return np.nan if not isinstance(x, pd.Series): x = pd.Series(x) value_counts = x.value_counts() reoccuring_values = value_counts[value_counts > 1].sum() if np.isnan(reoccuring_values): return 0 return reoccuring_values / x.sizedef sum_of_reoccurring_values(x): unique, counts = np.unique(x, return_counts=True) counts[counts < 2] = 0 counts[counts > 1] = 1 return np.sum(counts * unique)def sum_of_reoccurring_data_points(x): unique, counts = np.unique(x, return_counts=True) counts[counts < 2] = 0 return np.sum(counts * unique)def ratio_value_number_to_time_series_length(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) if x.size == 0: return np.nan return np.unique(x).size / x.sizedef abs_energy(x): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) return np.dot(x, x)def quantile(x, q): if len(x) == 0: return np.NaN return np.quantile(x, q)# crossing the mean ? other levels ? def number_crossing_m(x, m): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) # From https://stackoverflow.com/questions/3843017/efficiently-detect-sign-changes-in-python positive = x > m return np.where(np.diff(positive))[0].sizedef absolute_maximum(x): return np.max(np.absolute(x)) if len(x) > 0 else np.NaNdef value_count(x, value): if not isinstance(x, (np.ndarray, pd.Series)): x = np.asarray(x) if np.isnan(value): return np.isnan(x).sum() else: return x[x == value].sizedef range_count(x, min, max): return np.sum((x >= min) & (x < max))def mean_diff(x): return np.nanmean(np.diff(x.values))

5  特征选择


定义:特征选择是为机器学习模型构建选择相关特征子集的过程。


并非总是存在“数据越多,结果越好”这一真理。包含无关特征(对预测毫无帮助的特征)和冗余特征(与其他特征无关的特征)只会使学习过程不堪重负,并容易导致过拟合。


通过特征选择,我们可以:


  • 简化模型,使其更容易解释
  • 更短的训练时间和更低的计算成本
  • 数据收集成本更低
  • 避免维度诅咒
  • 通过减少过拟合来增强泛化能力

我们应该记住,不同的特征子集可以为不同的算法提供最佳性能。因此,它不是机器学习模型训练的一个单独过程。因此,如果我们为线性模型选择特征,最好使用针对这些模型的选择程序,如回归系数或套索的重要性。如果我们为树选择特征,最好使用树导出的重要性。

5.1  过滤方法

筛选方法根据性能指标选择特征,而不管以后采用哪种机器学习算法。

单变量过滤器根据特定标准对单个特征进行评估和排名,而多变量过滤器则对整个特征空间进行评估。过滤器方法有:

  • 选择变量,不考虑模型
  • 计算成本更低
  • 通常会带来较低的预测性能

因此,过滤方法适合第一步快速筛选和去除无关特征。


在记分卡开发中,WOE编码(见第4.3.2节)和IV通常齐头并进。这两个概念都来自
逻辑回归,是信用卡行业的标准做法。IV是一种流行且广泛使用的度量方法,因为与IV相关的变量选择有非常方便的经验法则,如下所述


然而,所有这些过滤方法都没有考虑特征之间的相互作用,可能会降低我们的预测能
力。我个人只使用方差和相关性来过滤一些绝对不必要的特征。

注意:在使用卡方检验或单变量选择方法时,要记住一件事,即在非常大的数据集中,大多数特征将显示较小的p值,因此看起来它们具有很高的预测性。这实际上是样本大小的影响。因此,在使用这些程序选择特征时应该小心。超小的p值并不能突出超重要的特征,而是表明数据集包含太多的样本。






请到「今天看啥」查看全文