使用Optuna进行功能选择

2024年05月14日 由 alex 发表 136 0

特征选择是许多机器学习管道中的关键步骤。在实践中,我们通常有大量变量可作为模型的预测因子,但其中只有少数与我们的目标相关。特征选择包括找到这些特征的精简集,主要目的如下:


  • 提高泛化能力--使用较少数量的特征可最大限度地降低过度拟合的风险。
  • 更好的推理--通过去除冗余特征(例如,两个相互关联度很高的特征),我们可以只保留其中一个特征,并更好地捕捉其效果。
  • 高效训练--减少特征意味着缩短训练时间。
  • 更好的解释--减少特征的数量可以产生更简洁的模型,更容易理解。


有很多技术可以用来进行特征选择,每种技术的复杂程度各不相同。在本文中,我想分享一种使用功能强大的开源优化工具 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')}")


16


我们现在的目标是通过选择精简的特征集来改进这些指标。我们将首先概述基于 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 左右:


17


通过选择正确的特征,我们能够将特征集减少一半以上,同时仍能获得比完整特征集更高的 F1 分数。下面我们将讨论使用 Optuna 进行特征选择的一些利弊:


优点:

  • 高效搜索特征集,考虑到哪些特征组合最有可能产生好结果。
  • 适用于多种场景: 只要有模型和损失函数,我们就能将其用于任何特征选择任务。
  • 纵观全局: 与单独评估特征的方法不同,Optuna 会考虑到哪些特征会相互配合,哪些不会。
  • 在优化过程中动态确定特征数量。这可以通过惩罚项进行调整。


缺点:

  • 它不像更简单的方法那么直接,对于较小和较简单的数据集来说,可能不值得使用。
  • 虽然它所需的试验次数比其他方法(如穷举搜索)少得多,但通常仍需要 100 到 1000 次左右的试验。根据模型和数据集的不同,这可能会耗费大量时间和计算成本。


接下来,我们将把我们的方法与其他常见的特征选择策略进行比较。


其他方法


筛选方法 - 奇平方

最简单的替代方法之一是使用统计测试对每个特征进行单独评估,并根据得分保留前 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')


18


在我们的案例中,ram 在卡方检验中得分最高,其次是 px_height 和 battery_power。请注意,上述 Optuna 方法也选择了这些特性,以及 px_width、mobile_wt 和 sc_w。不过,还有一些新加入的特征,如 int_memory 和 talk_time,Optuna 研究并没有选中这些特征。使用这 10 个特征训练随机森林并在测试集上对其进行评估后,我们获得的 F1 分数略高于之前的最好成绩,约为 0.888:


19


优点:

  • 与模型无关:不需要机器学习模型。
  • 实施和运行简单快捷。


缺点:

  • 必须针对每项任务进行调整。例如,有些分数函数只适用于分类任务,有些只适用于回归任务。
  • 贪婪:根据所使用的替代方法,它通常会逐个查看特征,而不会考虑哪些特征已经包含在特征集中。
  • 需要事先设定要选择的特征数量。


封装方法--前向搜索

封装方法是另一类特征选择策略。这些方法属于迭代法;它们包括用一组特征训练模型,评估其性能,然后决定是否添加或删除特征。我们的 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。


20


优点:

  • 只需几行代码即可轻松实现。
  • 它还可用于确定要使用的特征数量(使用容差参数)。


缺点:

  • 耗时: 从零特征开始,它每次都使用不同的变量训练模型,并保留最好的变量。下一步,它会再次尝试所有特征(现在包括前一个特征),并再次选择最佳特征。如此反复,直到达到所需的特征数量。
  • 贪婪: 一旦包含某个特征,它就会一直存在。这可能会导致次优结果,因为在早期回合中提供最高个体收益的特征,在其他特征交互的情况下可能并不是最佳选择。


特征重要性

最后,我们将探讨另一种直接的选择策略,即使用模型学习到的特征重要性(如果有的话)。某些模型,如随机森林模型,会提供对预测最重要的特征的衡量标准。我们可以利用这些排名来过滤掉模型认为最不重要的特征。在这种情况下,我们在整个训练数据集上训练模型,并保留 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')


21


请注意,"RAM再次排名最高,远远超过了第二重要的特征。使用这 10 个特征进行训练后,我们得到的测试 F1 分数接近 0.883,与我们之前看到的分数相近。此外,请注意通过特征重要性选择的特征与通过卡方检验选择的特征是相同的,尽管它们的排名不同。这种排序上的差异导致了略微不同的结果。


22


优点:

  • 实施简单快捷:只需对模型进行一次训练,并直接使用得出的特征导入值。
  • 它可以改编成递归版本,每一步都会去除最不重要的特征,然后再次对模型进行训练。
  • 包含在模型中: 如果我们使用的模型提供了特征导入值,那么我们就已经有了一个无需额外费用的特征选择方案。


缺点:

  • 特征重要性可能与我们的最终目标不一致。例如,某个特征本身可能并不重要,但由于它与其他特征的相互作用,可能会变得至关重要。此外,一个重要的特征可能会影响其他有用预测因子的表现,从而在整体上起到反作用。
  • 并非所有模型都提供特征重要性估算。
  • 需要预定义要选择的特征数量。


总结

最后,我们了解了如何使用 Optuna 这一强大的优化工具来完成特征选择任务。通过有效地浏览搜索空间,它能在相对较少的试验中找到好的特征子集。不仅如此,它还非常灵活,只要我们定义了模型和损失函数,就能适用于多种情况。


在整个示例中,我们发现所有技术都产生了相似的特征集和结果。这主要是因为我们使用的数据集相当简单。在这种情况下,较简单的方法已经能产生很好的特征选择,因此使用 Optuna 方法意义不大。不过,对于更复杂的数据集,特征更多,特征之间的关系也更复杂,使用 Optuna 可能是个好主意。因此,总而言之,鉴于 Optuna 的相对易用性和提供良好结果的能力,使用 Optuna 进行特征选择是数据科学家工具包中值得添加的一项内容。

文章来源:https://towardsdatascience.com/feature-selection-with-optuna-0ddf3e0f7d8c
欢迎关注ATYUN官方公众号
商务合作及内容投稿请联系邮箱:bd@atyun.com
评论 登录
热门职位
Maluuba
20000~40000/月
Cisco
25000~30000/月 深圳市
PilotAILabs
30000~60000/年 深圳市
写评论取消
回复取消