特征选择

当你创建完成千上万个特征之后,就该从中挑选一些了。当然,我们本就不应该创建成千上万个无用的特征。特征过多会带来一个众所周知的问题——维数灾难(curse of dimensionality)。如果你有大量特征,就必须有大量训练样本来覆盖所有这些特征。什么才算"大量"并没有明确的定义,这需要你自己通过正确地验证模型、检查训练模型所花的时间来摸索。

特征选择最简单的方式是移除方差(variance)极低的特征。如果特征的方差非常低(即非常接近 0),说明它几乎是个常量,因而对任何模型都没有任何价值。把它们去掉、从而降低复杂度,是再好不过的事。请注意,方差也取决于数据的缩放方式。Scikit-learn 提供了一个 VarianceThreshold 实现,做的正是这件事。

from sklearn.feature_selection import VarianceThreshold

data = ...
var_thresh = VarianceThreshold(threshold=0.1)
transformed_data = var_thresh.fit_transform(data)

# transformed data will have all columns with variance less
# than 0.1 removed

我们也可以移除相关性很高的特征。要计算不同数值特征之间的相关性,可以使用皮尔逊相关系数(Pearson correlation)。

import pandas as pd
from sklearn.datasets import fetch_california_housing

# fetch a regression dataset
data = fetch_california_housing()
X = data["data"]
col_names = data["feature_names"]
y = data["target"]

# convert to pandas dataframe
df = pd.DataFrame(X, columns=col_names)

# introduce a highly correlated column
# get correlation matrix (pearson)
df.corr()

df.loc[:, "MedInc_Sqrt"] = df.MedInc.apply(np.sqrt)

这将得到如图 1 所示的相关矩阵。

图 1:一个示例皮尔逊相关矩阵

MedIncHouseAgeAveRoomsAveBedrmsPopulationAveOccupLatitudeLongitudeMedInc_Sqrt
MedInc1.000000-0.1190340.326895-0.0620400.0048340.018766-0.079809-0.0151760.984329
HouseAge-0.1190341.000000-0.153277-0.077747-0.2962440.0131910.011173-0.108197-0.132797
AveRooms0.326895-0.1532771.0000000.847621-0.072213-0.0048520.106389-0.0275400.326688
AveBedrms-0.062040-0.0777470.8476211.000000-0.066197-0.0061810.0697210.013344-0.066910
Population0.004834-0.296244-0.072213-0.0661971.0000000.069863-0.1087850.0997730.018415
AveOccup0.0187660.013191-0.004852-0.0061810.0698631.0000000.0023660.0024760.015266
Latitude-0.0798090.0111730.1063890.069721-0.1087850.0023661.000000-0.924664-0.084303
Longitude-0.015176-0.108197-0.0275400.0133440.0997730.002476-0.9246641.000000-0.015569
MedInc_Sqrt0.984329-0.1327970.326688-0.0669100.0184150.015266-0.084303-0.0155691.000000

我们看到 MedInc_Sqrt 这个特征与 MedInc 的相关性非常高。因此我们可以移除其中的一个。

现在我们可以转向一些单变量(univariate)的特征选择方法。单变量特征选择不过就是针对给定目标为每个特征打分。互信息(mutual information)、ANOVA F 检验和卡方检验(chi²)是单变量特征选择最常用的一些方法。在 scikit-learn 中有两种使用方式。

  • SelectKBest:保留得分最高的前 k 个特征
  • SelectPercentile:保留用户指定百分比范围内的最优特征

必须注意,卡方检验只能用于非负数据。在自然语言处理中,当我们使用词袋(bag of words)或 tf-idf 特征时,这是一种特别有用的特征选择技术。最好为单变量特征选择封装一个包装器(wrapper),这样几乎任何新问题都可以直接复用。

from sklearn.feature_selection import chi2
from sklearn.feature_selection import f_classif
from sklearn.feature_selection import f_regression
from sklearn.feature_selection import mutual_info_classif
from sklearn.feature_selection import mutual_info_regression
from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import SelectPercentile

class UnivariateFeatureSelction:
    def __init__(self, n_features, problem_type, scoring):
        """
        Custom univariate feature selection wrapper on different univariate
        feature selection models from scikit-learn.
        :param n_features: SelectPercentile if float else SelectKBest
        :param problem_type: classification or regression
        :param scoring: scoring function, string
        """
        # for a given problem type, there are only
        # a few valid scoring methods
        # you can extend this with your own custom
        # methods if you wish
        if problem_type == "classification":
            valid_scoring = {
                "f_classif": f_classif,
                "chi2": chi2,
                "mutual_info_classif": mutual_info_classif
            }
        else:
            valid_scoring = {
                "f_regression": f_regression,
                "mutual_info_regression": mutual_info_regression
            }

        # raise exception if we do not have a valid scoring method
        if scoring not in valid_scoring:
            raise Exception("Invalid scoring function")

        # if n_features is int, we use selectkbest
        # if n_features is float, we use selectpercentile
        # please note that it is int in both cases in sklearn
        if isinstance(n_features, int):
            self.selection = SelectKBest(
                valid_scoring[scoring], k=n_features
            )
        elif isinstance(n_features, float):
            self.selection = SelectPercentile(
                valid_scoring[scoring], percentile=int(n_features * 100)
            )
        else:
            raise Exception("Invalid type of feature")

    # same fit function
    def fit(self, X, y):
        return self.selection.fit(X, y)

    # same transform function
    def transform(self, X):
        return self.selection.transform(X)

    # same fit_transform function
    def fit_transform(self, X, y):
        return self.selection.fit_transform(X, y)

使用这个类非常简单。

ufs = UnivariateFeatureSelction(
    n_features=0.1,
    problem_type="regression",
    scoring="f_regression"
)
ufs.fit(X, y)
X_transformed = ufs.transform(X)

这应该能覆盖你大部分单变量特征选择的需求。请注意,一开始就创建更少而重要的特征,通常比先创建几百个特征要好。单变量特征选择未必总是表现良好。大多数时候,人们更倾向于使用机器学习模型来做特征选择。让我们看看这是怎么做的。

使用模型进行选择的最简单形式叫做贪心特征选择(greedy feature selection)。在贪心特征选择中,第一步是选择一个模型。第二步是选择一个损失/评分函数。第三步也是最后一步,是迭代地评估每个特征,如果它改善了损失/评分,就把它加入"好"特征的列表。没有比这更简单的了。但你必须记住,它之所以叫贪心特征选择是有原因的。这个特征选择过程每次评估一个特征时都会拟合一次给定模型。这类方法的计算成本非常高,完成这种特征选择也要花很多时间。而且,如果你没有正确使用这种特征选择,甚至可能最终导致模型过拟合(overfit)。

让我们看看它的实现,来理解它是如何工作的。

# greedy.py
import pandas as pd
from sklearn import linear_model
from sklearn import metrics
from sklearn.datasets import make_classification

class GreedyFeatureSelection:
    """
    A simple and custom class for greedy feature selection.
    You will need to modify it quite a bit to make it suitable for your dataset.
    """
    def evaluate_score(self, X, y):
        """
        This function evaluates model on data and returns Area Under ROC Curve (AUC)
        NOTE: We fit the data and calculate AUC on same data.
        WE ARE OVERFITTING HERE.
        But this is also a way to achieve greedy selection.
        k-fold will take k times longer.
        If you want to implement it in really correct way,
        calculate OOF AUC and return mean AUC over k folds.
        This requires only a few lines of change and has been shown a few times in this book.
        :param X: training data
        :param y: targets
        :return: overfitted area under the roc curve
        """
        # fit the logistic regression model,
        # and calculate AUC on same data
        # again: BEWARE
        # you can choose any model that suits your data
        model = linear_model.LogisticRegression()
        model.fit(X, y)
        predictions = model.predict_proba(X)[:, 1]
        auc = metrics.roc_auc_score(y, predictions)
        return auc

    def _feature_selection(self, X, y):
        """
        This function does the actual greedy selection
        :param X: data, numpy array
        :param y: targets, numpy array
        :return: (best scores, best features)
        """
        # initialize good features list
        # and best scores to keep track of both
        good_features = []
        best_scores = []

        # calculate the number of features
        num_features = X.shape[1]

        # infinite loop
        while True:
            # initialize best feature and score of this loop
            this_feature = None
            best_score = 0

            # loop over all features
            for feature in range(num_features):
                # if feature is already in good features,
                # skip this for loop
                if feature in good_features:
                    continue

                # selected features are all good features till now
                # and current feature
                selected_features = good_features + [feature]

                # remove all other features from data
                xtrain = X[:, selected_features]

                # calculate the score, in our case, AUC
                score = self.evaluate_score(xtrain, y)

                # if score is greater than the best score
                # of this loop, change best score and best feature
                if score > best_score:
                    this_feature = feature
                    best_score = score

            # if we have selected a feature, add it
            # to the good feature list and update best scores list
            if this_feature != None:
                good_features.append(this_feature)
                best_scores.append(best_score)

            # if we didnt improve during the previous round,
            # exit the while loop
            if len(best_scores) > 2:
                if best_scores[-1] < best_scores[-2]:
                    break

        # return best scores and good features
        # why do we remove the last data point?
        return best_scores[:-1], good_features[:-1]

    def __call__(self, X, y):
        """
        Call function will call the class on a set of arguments
        """
        # select features, return scores and selected indices
        scores, features = self._feature_selection(X, y)

        # transform data with selected features
        return X[:, features], scores

if __name__ == "__main__":
    # generate binary classification data
    X, y = make_classification(n_samples=1000, n_features=100)

    # transform data by greedy feature selection
    X_transformed, scores = GreedyFeatureSelection()(X, y)

这样实现的贪心特征选择会返回得分和一个特征索引列表。图 2 展示了每轮迭代加入新特征后得分如何提升。我们看到,在某个点之后得分再也无法提高,而我们就在那里停下来。

另一种贪心方法叫做递归特征消除(recursive feature elimination,RFE)。在前一种方法中,我们从 1 个特征开始不断添加新特征;而在 RFE 中,我们从全部特征开始,每轮迭代移除一个对给定模型价值最小的特征。但我们怎么知道哪个特征提供的价值最小呢?如果我们使用线性支持向量机(support vector machine,SVM)或逻辑回归(logistic regression)这类模型,每个特征都会得到一个系数,它决定了特征的重要性。对于任何基于树的模型,我们得到的是特征重要性(feature importance),而不是系数。在每轮迭代中,我们可以剔除最不重要的特征,一直剔除到达到所需特征数量为止。所以,是的,我们能够决定要保留多少个特征。

图 2:贪心特征选择中 AUC 得分随新特征加入的变化

Image

当我们做递归特征消除时,每轮迭代都移除特征重要性较低、或系数接近 0 的特征。请记住,当你使用逻辑回归这类模型做二分类时,对正类重要的特征系数更正,对负类重要的特征系数更负。把我们的贪心特征选择类稍加修改,就能创建一个用于递归特征消除的新类,不过 scikit-learn 也直接提供了 RFE。下面这个例子展示了它的简单用法。

import pandas as pd
from sklearn.feature_selection import RFE
from sklearn.linear_model import LinearRegression
from sklearn.datasets import fetch_california_housing

# fetch a regression dataset
data = fetch_california_housing()
X = data["data"]
col_names = data["feature_names"]
y = data["target"]

# initialize the model
model = LinearRegression()

# initialize RFE
rfe = RFE(
    estimator=model,
    n_features_to_select=3
)

# fit RFE
rfe.fit(X, y)

# selected columns
# get the transformed data with
X_transformed = rfe.transform(X)

我们看到了两种从模型中选特征的贪心方法。但你也可以把模型拟合到数据上,然后根据特征的系数或特征重要性从模型中选特征。如果使用系数,你可以设定一个阈值:系数高于该阈值就保留特征,否则就剔除。

让我们看看如何从随机森林(random forest)这类模型中获得特征重要性。

import pandas as pd
from sklearn.datasets import load_diabetes
from sklearn.ensemble import RandomForestRegressor

# fetch a regression dataset
# in diabetes data we predict diabetes progression
# after one year based on some features
data = load_diabetes()
X = data["data"]
col_names = data["feature_names"]
y = data["target"]

# initialize the model
model = RandomForestRegressor()

# fit the model
model.fit(X, y)

随机森林(或任何模型)的特征重要性可以这样绘制:

importances = model.feature_importances_
idxs = np.argsort(importances)
plt.title('Feature Importances')
plt.barh(range(len(idxs)), importances[idxs], align='center')
plt.yticks(range(len(idxs)), [col_names[i] for i in idxs])
plt.xlabel('Random Forest Feature Importance')
plt.show()

得到的图如图 3 所示。

图 3:特征重要性图

Image

好吧,从模型中挑选最好的特征并不是什么新鲜事。你可以从一个模型中选特征,再用另一个模型来训练。例如,你可以用逻辑回归的系数来选特征,然后用随机森林在选出的特征上训练模型。Scikit-learn 还提供了 SelectFromModel 类,帮助你直接从给定模型中选择特征。你还可以按需指定系数或特征重要性的阈值,以及想要选择的最大特征数。

看看下面这段代码,我们用 SelectFromModel 的默认参数来选择特征。

import pandas as pd
from sklearn.datasets import load_diabetes
from sklearn.ensemble import RandomForestRegressor
from sklearn.feature_selection import SelectFromModel

# fetch a regression dataset
# in diabetes data we predict diabetes progression
# after one year based on some features
data = load_diabetes()
X = data["data"]
col_names = data["feature_names"]
y = data["target"]

# initialize the model
model = RandomForestRegressor()

# select from the model
sfm = SelectFromModel(estimator=model)
X_transformed = sfm.fit_transform(X, y)

# see which features were selected
support = sfm.get_support()

# get feature names
print([ x for x, y in zip(col_names, support) if y == True ])

它打印出:[‘bmi’, ‘s5’]。当我们看下图 3,会发现这正是最重要的前 2 个特征。因此,我们本也可以直接从随机森林提供的特征重要性中选取。这里我们还漏掉了一点:使用具有 L1(Lasso)惩罚的模型进行特征选择。当我们使用 L1 惩罚做正则化时,大部分系数都会是 0(或接近 0),我们选择系数非零的特征。只需把上面"从模型中选择"的代码片段里的随机森林替换成支持 L1 惩罚的模型(例如 lasso 回归)即可。所有基于树的模型都提供特征重要性,因此本章展示的所有基于模型的代码片段都可以用于 XGBoost、LightGBM 或 CatBoost。特征重要性的函数名可能不同,返回结果的格式也可能不同,但用法是一样的。最后,做特征选择时你必须小心。要在训练数据上选择特征,并在验证数据上验证模型,这样才能在不使模型过拟合的情况下正确选择特征。