特征选择是许多机器学习管道中的关键步骤。在实践中,我们通常有大量变量可作为模型的预测因子,但其中只有少数与我们的目标相关。特征选择包括找到这些特征的精简集,主要目的如下:
有很多技术可以用来进行特征选择,每种技术的复杂程度各不相同。在本文中,我想分享一种使用功能强大的开源优化工具 Optuna 来执行特征选择任务的创新方法。其主要思路是通过有效测试不同的特征组合(例如,不逐一尝试所有特征组合),使工具具有灵活性,能够处理各种任务的特征选择。下面,我们将通过一个实践示例来实现这种方法,并将其与其他常见的特征选择策略进行比较。要尝试使用所讨论的特征选择技术,可以使用 Colab Notebook。
在本示例中,我们将基于 Kaggle 的移动价格分类数据集,重点完成一项分类任务。我们有 20 个特征,包括' battery_power'、 ' clock_speed'和 ' ram',来预测 ' price_range'特征: 0、1、2 和 3。
我们首先将数据集分成训练集和测试集,并在训练集中进行 5 倍验证--这在以后会很有用。
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.model_selection import StratifiedKFold
SEED = 32
# Load data
filename = "train.csv" # train.csv from https://www.kaggle.com/datasets/iabhishekofficial/mobile-price-classification
df = pd.read_csv(filename)
# Train - test split
df_train, df_test = train_test_split(df, test_size=0.2, stratify=df.iloc[:,-1], random_state=SEED)
df_train = df_train.reset_index(drop=True)
df_test = df_test.reset_index(drop=True)
# The last column is the target variable
X_train = df_train.iloc[:,0:20]
y_train = df_train.iloc[:,-1]
X_test = df_test.iloc[:,0:20]
y_test = df_test.iloc[:,-1]
# Stratified kfold over the train set for cross validation
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
splits = list(skf.split(X_train, y_train))
我们在整个示例中使用的模型是随机森林分类器,使用 scikit-learn 实现和默认参数。我们首先使用所有特征对模型进行训练,以设定基准。我们要衡量的指标是所有四个价格范围的加权 F1 分数。在训练集上拟合模型后,我们在测试集上对其进行评估,得到的 F1 分数约为 0.87。
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import f1_score, classification_report
model = RandomForestClassifier(random_state=SEED)
model.fit(X_train,y_train)
preds = model.predict(X_test)
print(classification_report(y_test, preds))
print(f"Global F1: {f1_score(y_test, preds, average='weighted')}")
我们现在的目标是通过选择精简的特征集来改进这些指标。我们将首先概述基于 Optuna 的方法是如何工作的,然后将其与其他常见的特征选择策略进行测试和比较。
Optuna
Optuna 是一个优化框架,主要用于超参数调整。该框架的主要特点之一是使用贝叶斯优化技术搜索参数空间。其主要思路是,Optuna 尝试不同的参数组合,并评估目标函数在每种配置下的变化情况。从这些试验中,它建立了一个概率模型,用于估计哪些参数值可能产生更好的结果。
与网格搜索或随机搜索相比,这种策略的效率要高得多。例如,如果我们有 n 个特征,并试图尝试每个可能的特征子集,我们就必须执行 2^n 次试验。如果有 20 个特征,这将是超过一百万次的试验。而使用 Optuna,我们可以用更少的试验次数探索搜索空间。
Optuna 提供了多种采样器供尝试。对于我们的案例,我们将使用默认的 TPESampler 采样器,它基于树状结构 Parzen Estimator 算法 (TPE)。这种采样器是最常用的,推荐用于搜索分类参数,我们将在下文中看到。根据文档介绍,该算法 "将一个高斯混杂模型(GMM)l(x)拟合为与最佳目标值相关的参数值集,并将另一个 GMM g(x) 拟合为其余参数值。它选择能使 l(x)/g(x) 比率最大化的参数值 x"。
如前所述,Optuna 通常用于超参数调整。通常的做法是使用一组固定的特征在相同的数据上反复训练模型,并在每次试验中测试由采样器确定的一组新的超参数。使给定目标函数最小化的参数集将作为最佳试验返回。
不过,在我们的案例中,我们将使用一个带有预定参数的固定模型,在每次试验中,我们将允许 Optuna 选择要尝试的特征。这一过程的目的是找到损失函数最小的特征集。在我们的案例中,我们将引导算法最大化 F1 分数(或最小化 F1 负值)。此外,我们会为每个使用的特征添加一个小惩罚,以鼓励使用较小的特征集(如果两个特征集产生的结果相似,我们会优先选择特征较少的特征集)。
我们使用的数据是训练数据集,分为五个折叠。在每次试验中,我们将对分类器进行五次拟合,使用五个折叠中的四个用于训练,剩余的一个用于验证。然后,我们将平均验证指标,并添加惩罚项来计算试验损失。
下面是执行特征选择搜索的实现类:
import optuna
class FeatureSelectionOptuna:
"""
This class implements feature selection using Optuna optimization framework.
Parameters:
- model (object): The predictive model to evaluate; this should be any object that implements fit() and predict() methods.
- loss_fn (function): The loss function to use for evaluating the model performance. This function should take the true labels and the
predictions as inputs and return a loss value.
- features (list of str): A list containing the names of all possible features that can be selected for the model.
- X (DataFrame): The complete set of feature data (pandas DataFrame) from which subsets will be selected for training the model.
- y (Series): The target variable associated with the X data (pandas Series).
- splits (list of tuples): A list of tuples where each tuple contains two elements, the train indices and the validation indices.
- penalty (float, optional): A factor used to penalize the objective function based on the number of features used.
"""
def __init__(self,
model,
loss_fn,
features,
X,
y,
splits,
penalty=0):
self.model = model
self.loss_fn = loss_fn
self.features = features
self.X = X
self.y = y
self.splits = splits
self.penalty = penalty
def __call__(self,
trial: optuna.trial.Trial):
# Select True / False for each feature
selected_features = [trial.suggest_categorical(name, [True, False]) for name in self.features]
# List with names of selected features
selected_feature_names = [name for name, selected in zip(self.features, selected_features) if selected]
# Optional: adds a penalty for the amount of features used
n_used = len(selected_feature_names)
total_penalty = n_used * self.penalty
loss = 0
for split in self.splits:
train_idx = split[0]
valid_idx = split[1]
X_train = self.X.iloc[train_idx].copy()
y_train = self.y.iloc[train_idx].copy()
X_valid = self.X.iloc[valid_idx].copy()
y_valid = self.y.iloc[valid_idx].copy()
X_train_selected = X_train[selected_feature_names].copy()
X_valid_selected = X_valid[selected_feature_names].copy()
# Train model, get predictions and accumulate loss
self.model.fit(X_train_selected, y_train)
pred = self.model.predict(X_valid_selected)
loss += self.loss_fn(y_valid, pred)
# Take the average loss across all splits
loss /= len(self.splits)
# Add the penalty to the loss
loss += total_penalty
return loss
关键部分是我们要定义使用哪些特征。我们将每个特征视为一个参数,其值可以是 True 或 False。这些值表示该特征是否应包含在模型中。我们使用 suggest_categorical 方法,这样 Optuna 就会从每个特征的两个可能值中选择一个。
现在,我们初始化 Optuna 研究,并进行 100 次试验搜索。请注意,我们将使用所有特征的第一次试验作为搜索的起点,允许 Optuna 将后续试验与全特征模型进行比较:
from optuna.samplers import TPESampler
def loss_fn(y_true, y_pred):
"""
Returns the negative F1 score, to be treated as a loss function.
"""
res = -f1_score(y_true, y_pred, average='weighted')
return res
features = list(X_train.columns)
model = RandomForestClassifier(random_state=SEED)
sampler = TPESampler(seed = SEED)
study = optuna.create_study(direction="minimize",sampler=sampler)
# We first try the model using all features
default_features = {ft: True for ft in features}
study.enqueue_trial(default_features)
study.optimize(FeatureSelectionOptuna(
model=model,
loss_fn=loss_fn,
features=features,
X=X_train,
y=y_train,
splits=splits,
penalty = 1e-4,
), n_trials=100)
完成 100 次试验后,我们将从中选出最好的一次,并在其中使用特征。这些特征如下
[ 'battery_power', 'blue', 'dual_sim', 'fc', 'mobile_wt', 'px_height', 'px_width', 'ram', 'sc_w' ]
请注意,在最初的 20 个特征中,搜索结果只有 9 个,这已经大大减少了。这些特征的最小验证损失约为 -0.9117,这意味着它们在所有折叠中取得了约 0.9108 的平均 F1 分数(调整惩罚项后)。
下一步是使用这些选定的特征在整个训练集上训练模型,并在测试集上对其进行评估。这样,F1 得分为 0.882 左右:
通过选择正确的特征,我们能够将特征集减少一半以上,同时仍能获得比完整特征集更高的 F1 分数。下面我们将讨论使用 Optuna 进行特征选择的一些利弊:
优点:
缺点:
接下来,我们将把我们的方法与其他常见的特征选择策略进行比较。
其他方法
筛选方法 - 奇平方
最简单的替代方法之一是使用统计测试对每个特征进行单独评估,并根据得分保留前 k 个特征。请注意,这种方法不需要任何机器学习模型。例如,对于分类任务,我们可以选择卡方检验,它可以确定每个特征与目标变量之间是否存在统计学意义上的关联。我们将使用 scikit-learn 的 SelectKBest 类,它对每个特征应用分数函数(卡方),并返回得分最高的 k 个变量。与 Optuna 方法不同的是,特征的数量不是在选择过程中决定的,而是必须事先设置。在本例中,我们将其设置为 10 个。这些方法属于过滤方法类。它们往往是最简单、最快的计算方法,因为它们不需要任何模型支持。
from sklearn.feature_selection import SelectKBest, chi2
skb = SelectKBest(score_func=chi2, k=10)
skb.fit(X_train,y_train)
scores = pd.DataFrame(skb.scores_)
cols = pd.DataFrame(X_train.columns)
featureScores = pd.concat([cols,scores],axis=1)
featureScores.columns = ['feature','score']
featureScores.nlargest(10, 'score')
在我们的案例中,ram 在卡方检验中得分最高,其次是 px_height 和 battery_power。请注意,上述 Optuna 方法也选择了这些特性,以及 px_width、mobile_wt 和 sc_w。不过,还有一些新加入的特征,如 int_memory 和 talk_time,Optuna 研究并没有选中这些特征。使用这 10 个特征训练随机森林并在测试集上对其进行评估后,我们获得的 F1 分数略高于之前的最好成绩,约为 0.888:
优点:
缺点:
封装方法--前向搜索
封装方法是另一类特征选择策略。这些方法属于迭代法;它们包括用一组特征训练模型,评估其性能,然后决定是否添加或删除特征。我们的 Optuna 策略就属于这类方法。不过,最常见的例子包括前向选择或后向选择。在前向选择中,我们从没有任何特征开始,每一步都会贪婪地添加性能收益最高的特征,直到满足停止标准(特征数量或性能下降)为止。反之,后向选择从所有特征开始,每一步都会迭代去除最不重要的特征。
下面,我们尝试使用 scikit-learn 的 SequentialFeatureSelector 类,执行前向选择,直到找到前 10 个特征。这种方法还将利用我们上面进行的 5 倍拆分,在每一步对验证拆分的性能进行平均。
from sklearn.feature_selection import SequentialFeatureSelector
model = RandomForestClassifier(random_state=SEED)
sfs = SequentialFeatureSelector(model, n_features_to_select=10, cv=splits)
sfs.fit(X_train, y_train);
selected_features = list(X_train.columns[sfs.get_support()])
print(selected_features)
这种方法最终会选择以下功能:
[blue"、"fc"、"mobile_wt"、"px_height"、"px_width"、"ram"、"talk_time"、"three_g"、"touch_screen"。]
同样,有些特征与之前的方法相同,有些则是新特征(如 three_g 和 touch_screen)。使用这些特征,随机森林的测试 F1 分数较低,略低于 0.88。
优点:
缺点:
特征重要性
最后,我们将探讨另一种直接的选择策略,即使用模型学习到的特征重要性(如果有的话)。某些模型,如随机森林模型,会提供对预测最重要的特征的衡量标准。我们可以利用这些排名来过滤掉模型认为最不重要的特征。在这种情况下,我们在整个训练数据集上训练模型,并保留 10 个最重要的特征:
model = RandomForestClassifier(random_state=SEED)
model.fit(X_train,y_train)
importance = pd.DataFrame({'feature':X_train.columns, 'importance':model.feature_importances_})
importance.nlargest(10, 'importance')
请注意,"RAM再次排名最高,远远超过了第二重要的特征。使用这 10 个特征进行训练后,我们得到的测试 F1 分数接近 0.883,与我们之前看到的分数相近。此外,请注意通过特征重要性选择的特征与通过卡方检验选择的特征是相同的,尽管它们的排名不同。这种排序上的差异导致了略微不同的结果。
优点:
缺点:
总结
最后,我们了解了如何使用 Optuna 这一强大的优化工具来完成特征选择任务。通过有效地浏览搜索空间,它能在相对较少的试验中找到好的特征子集。不仅如此,它还非常灵活,只要我们定义了模型和损失函数,就能适用于多种情况。
在整个示例中,我们发现所有技术都产生了相似的特征集和结果。这主要是因为我们使用的数据集相当简单。在这种情况下,较简单的方法已经能产生很好的特征选择,因此使用 Optuna 方法意义不大。不过,对于更复杂的数据集,特征更多,特征之间的关系也更复杂,使用 Optuna 可能是个好主意。因此,总而言之,鉴于 Optuna 的相对易用性和提供良好结果的能力,使用 Optuna 进行特征选择是数据科学家工具包中值得添加的一项内容。