《特征工程训练营》——医疗保健:诊断COVID-19

706 阅读40分钟

在我们的第一个案例研究中,我们将专注于更经典的特征工程技术,这些技术可以应用于几乎任何表格数据(数据以经典的行和列结构呈现),例如数值填充、分类数据的虚拟化以及通过假设检验进行的特征选择。表格数据集(图3.1)很常见,毫无疑问,任何数据科学家在其职业生涯中都将不得不处理表格数据。使用表格数据有许多好处:

  1. 它是一种可解释的格式。行是观察结果,列是特征。
  2. 表格数据易于理解,不仅适用于数据科学家,大多数专业人士都可以理解。将一张包含行和列的电子表格分发给广泛的人群是很容易的。

image.png

与表格数据一起工作也有一些不利之处。主要的不利之处在于几乎有无限的特征工程技术适用于表格数据。没有一本书可以合理地覆盖每一种可行应用于表格数据的特征工程技术。本章的目标是展示一些常见和有用的技术,包括尾部填充、Box-Cox转换和特征选择,并提供足够的直觉和代码示例,使读者能够理解并根据需要应用这些技术。

我们将在本章开始时对我们的数据集进行探索性数据分析。我们将试图了解我们的每个特征,并将它们分配到我们的四个数据级别之一。我们还将进行一些可视化操作,以更好地了解我们正在处理的内容。一旦我们对数据有了很好的了解,我们将开始改进数据集中的特征,以便它们在我们的机器学习管道中更有用。一旦我们改进了我们的特征,我们将开始构建新的特征,以为我们的机器学习管道提供更多的信号。最后,我们将退后一步,查看一些特征选择过程,看看是否可以去除任何不必要的特征,以加速我们的机器学习管道并提高性能。最后,我们将总结我们的发现,并确定适合我们任务的特征工程管道。

通过按照这一程序操作,我希望您能够看到并遵循在Python中处理表格数据的端到端工作流程。我希望这将为读者思考可能遇到的其他表格数据提供一个框架。让我们开始查看我们的第一个数据集。

COVID流感诊断数据集

这个案例研究的数据集包含代表前来就诊的患者的观测数据。特征表示患者的信息以及他们所呈现的症状。数据来自各种知名来源的出版物,包括《新英格兰医学杂志》。

至于我们的响应变量,我们有两个类别可供选择。我们可以诊断:

COVID-19——由SARS-CoV-2引起的疾病 H1N1——流感的亚型 当然,这个数据集并不是一个完美的诊断数据集,但对于我们的目的来说,这是可以接受的。我们对这个数据的假设是患者出现了疾病症状,我们的模型应该能够提供建议。

我们的项目计划通常会按照以下步骤进行:

  • 首先,我们将下载/导入数据并进行初始准备,例如重命名任何列等。
  • 进行一些探索性数据分析,了解我们有哪些数据列,并为每个列分配数据级别。
  • 将数据分割成训练集和测试集,以便我们可以在训练集上训练我们的模型,并通过在测试集上评估模型来获得更少偏差的性能指标。
  • 建立一个机器学习管道,其中包括特征工程算法和学习算法,例如逻辑回归、随机森林等。
  • 在训练集上执行交叉验证,以找到管道的最佳参数集。
  • 在整个训练集上拟合我们的最佳模型,在测试集上评估它,并打印出我们的机器学习管道的性能指标。
  • 使用不同的特征工程技术重复步骤4-6,以查看我们的特征工程工作的效果如何。

注意:明确指出,这个案例研究并不意味着是COVID-19的诊断工具。我们的目标是展示特征工程工具以及它们如何在一个以医疗为导向的二分类任务中使用。

问题陈述和定义成功

对于我们所有的数据集,定义我们试图实现的目标至关重要。如果我们盲目地进入数据并开始应用转换,而没有明确了解我们认为的成功是什么,那么我们可能会改变我们的数据并恶化情况。

在我们使用机器学习来诊断疾病的情况下,我们只有两个选择:COVID-19或H1N1。我们面临着一个二分类问题。

我们可以简单地将我们的问题定义为做任何必要的事情来提高我们的机器学习模型在诊断中的准确性。这似乎是无害的,直到我们最终了解到(正如我们将在我们的探索性数据分析阶段看到的),我们的数据集中近四分之三的样本都是H1N1诊断。对于不平衡的数据集,将准确性作为成功的聚合度量是不可靠的,它将高估H1N1样本的准确性,而低估COVID-19病例的准确性,因为它将对整体数据集的准确性产生更大的影响,但我们需要更详细的信息。例如,我们希望了解我们的机器学习模型在两个类别中的性能,以便了解我们的模型在预测每个类别时的表现,而不仅仅是查看聚合的准确性指标。

为此,让我们编写一个函数,该函数接受训练和测试数据(列表3.1),以及一个我们将假设是scikit-learn Pipeline对象的特征工程管道,该管道将执行以下几项任务:

  1. 实例化一个ExtraTreesClassifier模型和一个GridSearchCV实例。
  2. 将我们的特征工程管道适合于我们的训练数据。
  3. 使用现在适合的数据管道来转换我们的测试数据。
  4. 进行快速的超参数搜索,以找到在测试集上给出最佳准确性的参数集。
  5. 计算测试数据的分类报告,以查看粒度性能。
  6. 返回最佳模型。
def simple_grid_search(
    x_train, y_train, x_test, y_test, feature_engineering_pipeline):
    ''' 
    simple helper function to grid search an 
    ExtraTreesClassifier model and print out a classification report
    for the best model where best here is defined as having 
    the best cross-validated accuracy on the training set
    '''
    
    params = {  # some simple parameters to grid search
        'max_depth': [10, None],
        'n_estimators': [10, 50, 100, 500],
        'criterion': ['gini', 'entropy']
    }
 
    base_model = ExtraTreesClassifier()
 
    model_grid_search = GridSearchCV(base_model, param_grid=params, cv=3)
    start_time = time.time()  # capture the start time
    # fit FE pipeline to training data and use it to transform test data
    if feature_engineering_pipeline:
        parsed_x_train = feature_engineering_pipeline.fit_transform(
            x_train, y_train)
        parsed_x_test = feature_engineering_pipeline.transform(x_test)
    else:
        parsed_x_train = x_train
        parsed_x_test = x_test
 
    parse_time = time.time()
    print(f"Parsing took {(parse_time - start_time):.2f} seconds")
 
    model_grid_search.fit(parsed_x_train, y_train)
    fit_time = time.time()
    print(f"Training took {(fit_time - start_time):.2f} seconds")
 
    best_model = model_grid_search.best_estimator_
 
    print(classification_report(
        y_true=y_test, y_pred=best_model.predict(parsed_x_test)))
    end_time = time.time()
    print(f"Overall took {(end_time - start_time):.2f} seconds")    
 
    return best_model

有了这个定义好的函数,我们有一个易于使用的辅助函数,我们可以传入训练和测试数据以及特征工程管道,快速了解该管道对我们的工作效果如何。更重要的是,它将帮助强调我们的目标不是在模型上执行长时间繁琐的超参数搜索,而是看到在我们的机器学习性能上操作数据的效果。

我们几乎已经准备好开始深入研究我们的第一个案例研究了!首先,我们需要讨论如何定义成功。在每一章中,我们都会花一些时间来讨论如何定义我们工作的成功。就像进行统计测试一样,非常重要的是在查看任何数据之前定义成功,以防止自己产生偏见。在大多数情况下,成功将以测量某个指标的形式出现,并且在某些情况下,会以多个指标的聚合形式出现。

与大多数健康诊断模型一样,我们希望超越简单的度量标准,如准确性,以真正了解我们的机器学习模型的性能。对于我们的目的,我们将关注每个类别的精确度——该类别中正确标记的诊断所占的百分比/该类别的尝试诊断的百分比——以及我们的召回率——该类别中正确标记的诊断所占的百分比/该类别中的所有观察的百分比。

精确度和召回率

值得快速回顾一下精确度和召回率。在二元分类任务中,精确度(即特定类别的正预测值)定义为:

True Positives / Predicted Positives

我们的精确度将告诉我们应该对我们的模型有多自信;如果COVID-19模型的精确度为91%,那么在我们的测试集中,每当模型预测COVID-19时,它在91%的时间内都是正确的,而在其余的9%情况下,我们误诊了H1N1为COVID-19。

召回率(即灵敏度)也是针对各个类别定义的,其计算公式为:

True Positives / All Positive Cases

我们的召回率告诉我们我们的模型能够捕捉多少COVID-19病例。如果COVID-19模型的召回率为85%,这意味着在我们的测试中,对于实际COVID-19的真正情况,我们的模型正确地将其中的85%分类为COVID-19,而将另外的15%错误地分类为H1N1。

探索性数据分析

在我们深入研究特征工程技术之前,让我们使用流行的数据处理工具pandas来导入我们的数据,并进行一些探索性数据分析(EDA),以了解我们的数据的外观(图3.2)。在我们所有的案例研究中,我们将依赖pandas的强大和易用性来处理我们的数据。如果您对pandas不熟悉,

import pandas as pd
covid_flu = pd.read_csv('../data/covid_flu.csv')
covid_flu.head()  # take a look at the first 5 rows

截屏2023-09-27 14.52.15.png

立即显而易见的是我们的数据中NaN(非数字)值的数量,这些值表示缺失的数据。这将是我们首先处理的事情。让我们看看每个特征缺失的值占总数的百分比:

covid_flu.isnull().mean()  # percent of missing data in each column
 
Diagnosis                      0.000000
InitialPCRDiagnosis            0.929825
Age                            0.018893
Sex                            0.051282
neutrophil                     0.930499
serumLevelsOfWhiteBloodCell    0.898111
lymphocytes                    0.894737
CReactiveProteinLevels         0.907557
DurationOfIllness              0.941296
CTscanResults                  0.892713
RiskFactors                    0.858974
GroundGlassOpacity             0.937247
Diarrhea                       0.696356
Fever                          0.377193
Coughing                       0.420378
ShortnessOfBreath              0.949393
SoreThroat                     0.547908
NauseaVomiting                 0.715924
Temperature                    0.576248
Fatigue                        0.641700

我们可以看到我们有很多工作要做。我们模型中的每个特征都有一些缺失的数据,有些特征缺失的值超过了90%!大多数机器学习模型无法处理缺失值。我们特征改进的第一部分将立即开始处理这些缺失值,通过讨论填补这些缺失值的方法,使它们对我们的机器学习模型可用。

唯一没有任何缺失数据的列是“Diagnosis”列,因为这是我们的响应变量。让我们看看我们的类别的百分比分布:

covid_flu['Diagnosis'].value_counts(normalize=True)  # percent breakdown of 
➥ response variable
 
H1N1       0.723347
COVID19    0.276653

我们最常见的类别是H1N1,超过72%的响应变量属于这个类别。我们的空准确度为72%——一个只是一次又一次地猜测最常见类别的分类模型的准确度。我们机器学习管道的绝对基线将是超越空准确度。如果我们的模型只是为每个前来的人猜测H1N1,从技术上讲,该模型将在72%的时间内准确,尽管它实际上并没有真正做任何事情。但是,哪怕是一个只会猜测的机器学习模型也有72%的正确率。

注意:如果我们的分类机器学习模型无法超越空准确度,那么我们的模型将不比只猜测最常见响应值更好。

最后,但同样重要的是,我们将希望了解哪些列是定量的,哪些是定性的。对于我们调查的几乎每个表格数据集,我们都会这样做,因为这将帮助我们更好地了解哪些特征工程技术可以应用于哪些列,如下图所示。

covid_flu.info()
 
RangeIndex: 1482 entries, 0 to 1481
Data columns (total 20 columns):
 #   Column                       Non-Null Count  Dtype  
---  ------                       --------------  -----  
 0   Diagnosis                    1482 non-null   object 
 1   InitialPCRDiagnosis          104 non-null    object 
 2   Age                          1454 non-null   float64
 3   Sex                          1406 non-null   object 
 4   neutrophil                   103 non-null    float64
 5   serumLevelsOfWhiteBloodCell  151 non-null    float64
 6   lymphocytes                  156 non-null    float64
 7   CReactiveProteinLevels       137 non-null    object 
 8   DurationOfIllness            87 non-null     float64
 9   CTscanResults                159 non-null    object 
 10  RiskFactors                  209 non-null    object 
 11  GroundGlassOpacity           93 non-null     object 
 12  Diarrhea                     450 non-null    object 
 13  Fever                        923 non-null    object 
 14  Coughing                     859 non-null    object 
 15  ShortnessOfBreath            75 non-null     object 
 16  SoreThroat                   670 non-null    object 
 17  NauseaVomitting              421 non-null    object 
 18  Temperature                  628 non-null    float64
 19  Fatigue                      531 non-null    object 
dtypes: float64(6), object(14)
memory usage: 231.7+ KB

info方法显示了哪些列被转换为对象(pandas将其识别为定性列),哪些是float64类型(定量列)。现在我们对数据有了更多的了解,让我们开始进行特征工程的努力。

注意:Pandas会根据数据集中的值对数据做出假设。可能会发生Pandas可能将一个被转换为定量的列实际上识别为定性的情况,仅基于其值(例如,电话号码或邮政编码)。

特征改进

正如我们之前所看到的,我们的特征列中有很多缺失值。事实上,每个特征都有缺失数据,我们需要填补这些数据以使用绝大多数机器学习模型。在本案例研究中,我们将看到两种形式的特征改进:

  1. 数据填充(Imputing data)——这是改进特征最常见的方式。我们将研究一些填充数据或填补缺失值的方法,包括定性数据和定量数据。
  2. 值规范化(Value normalizing)——这涉及将值从感知值映射到硬值。对于我们的数据集,我们将看到二元特征通过字符串(如Yes和No)传递值。我们将希望将它们映射为True和False值,以便我们的机器学习模型可以使用它们。

填补缺失的定量数据

正如我们在探索性数据分析中看到的,我们有很多缺失的数据需要处理。对于处理缺失值,我们有两个选项:

  1. 我们可以删除具有缺失数据的观测和行,但这可能是扔掉很多有用数据的好方法。
  2. 我们可以填充缺失的值,这样我们就不必扔掉整个观测或行。

现在让我们学习如何使用scikit-learn填充缺失值,我们将从定量数据开始。让我们获取数值列并将它们放入一个列表中:

numeric_types = ['float16', 'float32', 'float64', 'int16', 'int32', 'int64']
➥ # the numeric types in Pandas
numerical_columns =
➥ covid_flu.select_dtypes(include=numeric_types).columns.tolist()

现在,我们应该有一个包含以下元素的列表:

['Age', 'neutrophil', 'serumLevelsOfWhiteBloodCell', 'lymphocytes', 'DurationOfIllness', 'Temperature']

我们可以利用scikit-learn中的SimpleImputer类来填补大多数缺失值。让我们看看我们可以处理这个问题的几种方式。

均值/中位数填充

我们对于数值数据填充的第一个选择是使用该特征的均值或中位数来填充所有缺失值。要使用scikit-learn来实现这一点,我们可以使用SimpleImputer:

from sklearn.impute import SimpleImputer                          ❶
num_impute = SimpleImputer(strategy='mean')                       ❷
print(covid_flu['lymphocytes'].head())                            ❸
print(f"\n\nMean of Lymphocytes column is 
     {covid_flu['lymphocytes'].mean()}\n\n")
print(num_impute.fit_transform(covid_flu[['lymphocytes']])[:5])   ❹
0   NaN
1   NaN
2   NaN
3   NaN
4   NaN
Name: lymphocytes, dtype: float64
 
Mean of Lymphocytes column is 1.8501538461538463
 
[[1.85015385]
 [1.85015385]
 [1.85015385]
 [1.85015385]
 [1.85015385]]

❶ 用于填充缺失数据的scikit-learn类

❷ 对于数值数据可以是均值或中位数

❸ 在填充前显示前五个值

❹ 转换操作将列转换为NumPy数组。

因此,我们可以看到我们的缺失值已被替换为该列的均值。

任意值填充

任意值填充是将缺失值替换为一个常数值,表示该值不是随机缺失的。通常情况下,对于数值特征,我们可以使用值如-1、0、99、999等。这些值在技术上并不是任意的,但它们对于机器学习模型来说似乎是任意的,表示这个值可能不是无意中缺失的;可能有一个缺失的原因。在选择任意值时,唯一的真正规则是选择一个不太可能出现在非缺失值中的值。例如,如果温度值范围在90-110之间,那么值99并不是完全任意的。更好的选择可能是999。

任意值填充的目标是通过使缺失值看起来不属于非缺失值来突出显示缺失值。在执行任意值填充时,最佳实践告诉我们不要填充可能看起来属于分布的值。

对于数值和分类变量,任意值填充在给缺失值赋予含义的同时,也给出了“为什么这个值缺失?”的概念。它还具有在scikit-learn中非常容易实现的好处,可以使用SimpleImputer来实现:

arbitrary_imputer = SimpleImputer(strategy='constant', fill_value=999)
arbitrary_imputer.fit_transform(covid_flu[numerical_features])

尾部值填充

尾部值填充是一种特殊类型的任意值填充,其中我们用于填充缺失值的常数值基于特征的分布。该值位于分布的尾部。这种方法仍然具有突出显示缺失值与其他值不同的好处(这是使用均值/中位数填充的效果),但还具有使我们选择的值更自动生成和更容易填充的额外好处(图3.3):

  • 如果我们的变量服从正态分布,我们的任意值是均值加上3倍标准差。使用3作为乘数是常见的,但也可以根据数据科学家的判断进行更改。
  • 如果我们的数据呈偏态分布,那么我们可以使用IQR(四分位距)规则,在分布的两端放置值,方法是将1.5倍的IQR(即第75百分位数减去第25百分位数)添加到第75百分位数,或从第25百分位数减去1.5倍的IQR。

image.png

为了实现这一点,我们将使用一个名为feature-engine的第三方包,该包具有尾部值填充的实现,可以很好地与我们的scikit-learn管道配合使用。让我们首先看一下淋巴细胞特征的原始直方图(图3.4):

covid_flu['lymphocytes'].plot(
    title='Lymphocytes', kind='hist', xlabel='cells/μL'
)

image.png

原始数据显示了一个右偏的分布,分布的左侧有一个突起,右侧有一个尾巴。现在让我们导入EndOfTailImputer(图3.5)类,并使用默认的高斯方法填充特征中的值,该方法通过以下公式计算:

arithmetic mean + 3 * standard deviation                             ❶
 
 
from feature_engine.imputation import EndOfTailImputer               ❷
 
EndOfTailImputer().fit_transform(covid_flu[['lymphocytes']]).plot(
    title='Lymphocytes (Imputed)', kind='hist', xlabel='cells/μL'
)                                                                    ❸

❶ 更多信息请参阅 feature-engine.readthedocs.io。

❷ 导入我们的尾部值填充器。

❸ 将尾部值填充器应用于淋巴细胞特征,并绘制直方图。

image.png

我们的填充器已填充了值,现在我们可以看到在值约为14附近有一个大的柱状图。这些是我们填充的值。如果我们想计算这是如何发生的,我们的特征的算术平均值是1.850154,标准差为3.956668。因此,我们的填充器正在填充以下值:

1.850154 + (3 * 3.956668) = 13.720158

这与我们直方图中看到的突起一致。

练习3.1 如果我们的算术平均值为8.34,标准差为2.35,那么EndOfTailImputer会用什么来填充缺失的值?

对于填充定量数据,我们有很多选项,但我们还需要讨论如何为定性数据填充值。这是因为执行此操作的技术虽然熟悉,但是不同。

填补缺失的定性数据

让我们把注意力转向我们的定性数据,这样我们就可以开始构建我们的特征工程管道。让我们首先获取我们的分类列并将它们放入一个列表中,如下所示:

categorical_types = ['O']  # The "object" type in pandas
categorical_columns = covid_flu.select_dtypes(
    include=categorical_types).columns.tolist()
categorical_columns.remove('Diagnosis')             ❶
for categorical_column in categorical_columns:
    print('=======')
    print(categorical_column)
    print('=======')
    print(covid_flu[categorical_column].value_counts(dropna=False))
    
=======
InitialPCRDiagnosis
=======
NaN    1378
Yes     100
No        4
Name: InitialPCRDiagnosis, dtype: int64
=======
Sex
=======
M      748
F      658
NaN     76
Name: Sex, dtype: int64
...
=======
RiskFactors
=======
NaN                          1273
asthma                         36
pneumonia                      21
immuno                         21
diabetes                       16
                             ... 
HepB                            1
pneumonia                       1
Hypertension and COPD           1
asthma, chronic, diabetes       1
Pre-eclampsia                   1
Name: RiskFactors, Length: 64, dtype: int64

❶ 我们希望从这个列表中删除我们的响应变量,因为它不是我们机器学习模型中的特征。

看起来我们的所有分类列都是二进制的,除了RiskFactors,它看起来是一个相当混乱的、以逗号分隔的因素列表。在我们尝试处理RiskFactors之前,让我们清理一下我们的二元特征。

让我们首先将性别(Sex)列转换为真/假的二进制列,然后将我们数据框中的所有Yes实例映射为True,将No映射为False。这将使这些值变得可以被机器读取,因为在Python中,布尔值被视为0和1。以下代码片段为我们执行了这两个功能。该代码将:

  • 创建一个名为Female的新列,如果Sex列表示Female,则为True,否则为False。
  • 使用pandas中的replace功能,将我们数据集中的所有Yes替换为True,将No替换为False。
covid_flu['Female'] = covid_flu['Sex'] == 'F'
del covid_flu['Sex']                                        ❶
 
covid_flu = covid_flu.replace({'Yes': True, 'No': False})   ❷

❶ 将我们的性别(Sex)列转换为二进制列。

❷ 将Yes替换为True,将No替换为False。

最常见类别填充

与数值数据一样,我们可以用多种方式填充缺失的分类数据。其中一种方法称为最常见类别填充或众数填充。顾名思义,我们只需用最常见的非缺失值替换缺失值:

cat_impute = SimpleImputer(strategy='most_frequent')   ❶
 
print(covid_flu['Coughing'].head())
 
print(cat_impute.fit_transform(
    covid_flu[['Coughing']])[:5])                      ❷
0    Yes
1    NaN
2    NaN
3    Yes
4    NaN
Name: Coughing, dtype: object
[['Yes']
 ['Yes']
 ['Yes']
 ['Yes']
 ['Yes']]

❶ 可以是最常见的或常数(任意值)用于分类值

❷ 转换操作将列转换为NumPy数组。

对于我们的数据,我认为我们可以做出一个假设,允许我们使用另一种填充方法。

任意类别填充

与数值数值的任意值填充类似,我们可以将此方法应用于分类值,方法是要么创建一个新的类别,称为"Missing"或"Unknown",由机器学习算法来学习,要么对缺失值进行假设并根据该假设来填充值。

对于我们的目的,让我们对缺失的分类数据做一个假设,即如果分类值(在我们的数据中表示症状)缺失,负责记录的医生认为他们没有表现出这种症状,因此很可能他们没有这种症状。基本上,我们将所有缺失的分类值替换为False。

使用我们的SimpleImputer可以很容易地实现这一点:

fill_with_false = SimpleImputer(strategy='constant', fill_value=False)
fill_with_false.fit_transform(covid_flu[binary_features])

特征构建

就像我们在上一章中讨论的那样,特征构建是通过直接转换现有特征手动创建新特征,我们将采取这种方法。在本节中,我们将查看我们的特征,并根据它们的数据级别(例如,有序、名义等)对它们进行转换。

数值特征转换

在本节中,我们将讨论一些从我们原始特征创建新特征的方法。我们的目标是创建比我们原始特征更有用的新特征。让我们首先对我们的特征应用一些数学转换。

对数变换

对数变换可能是最常见的特征转换技术之一,它将列中的每个值x替换为值log(1 + x)。为什么要用1 + x而不只是x?一个原因是我们希望能够处理0值,而log(0)是未定义的。事实上,对数变换仅适用于严格为正的数据。

对数变换的总体目的是使数据看起来更正态分布。在许多情况下,这是首选的,主要是因为数据正态性是数据科学中最容易被忽视的假设之一。许多基础测试和算法都假定数据呈正态分布,包括卡方测试和逻辑回归等。我们之所以更愿意将偏态数据转换为正态分布数据的另一个原因是,这种转换通常会留下较少的异常值,而机器学习算法通常不适用于异常值。

幸运的是,在NumPy中,我们有一种简单的方法来进行对数变换(图3.6和3.7显示了对数变换前后的效果):

covid_flu['lymphocytes'].plot(
    title='Lymphocytes', kind='hist', xlabel='cells/μL'
)                                                          ❶
covid_flu['lymphocytes'].map(np.log1p).plot(
    title='Lymphocytes (Log Transformed)', kind='hist', xlabel='cells/μL'
)

image.png

image.png

在应用对数变换后,我们的数据看起来更加正态(图3.7)。然而,我们还可以通过使用另一种特征转换来进一步改进这个转换。

Box-Cox变换

一种不太常见但通常更有用的变换是Box-Cox变换。Box-Cox变换是一个由参数lambda参数化的变换,它将数据的形状调整为更加正态分布。

Box-Cox的公式如下:

image.png

这里的lambda是一个参数,它被选择使数据看起来最正态。值得注意的是,Box-Cox变换仅适用于严格为正的数据。

不必完全内化Box-Cox的公式,但值得看一下以供参考。对于我们的目的,我们可以使用scikit-learn中的PowerTransformer类来执行Box-Cox变换(图3.8和3.9显示了Box-Cox变换前后的效果)。

image.png

image.png

首先,让我们解决Age列中存在一些零值的问题,这使得它不是严格为正的:

covid_flu[covid_flu['Age']==0].head(3)            ❶
 
covid_flu['Age'] = covid_flu['Age'] + 0.01        ❷
pd.DataFrame(covid_flu[numerical_columns]).hist(figsize=(10, 10))

❶ 看起来Age可能包含一些零值,这在Box-Cox中是不适用的。

❷ 为了使Age严格为正

现在,我们可以应用我们的变换:

from sklearn.preprocessing import PowerTransformer
 
boxcox_transformer = PowerTransformer(method='Box-Cox', standardize=False)
pd.DataFrame(
    boxcox_transformer.fit_transform(covid_flu[numerical_columns]), 
    columns=numerical_columns
).hist(figsize=(10, 10))

我们甚至可以查看被选择的lambda值,以使我们的数据更加正态(图3.9)。lambda值为1不会改变我们分布的形状,因此如果我们看到一个非常接近1的值,那么我们的数据已经接近正态分布了:

boxcox_transformer.lambdas_
 
array([ 0.41035252, -0.22261792,  0.12473207, 
       -0.24415703,  0.36376996, -7.01162857])

PowerTransformer类还支持Yeo-Johnson变换,它也试图扭曲分布以使其更正态,但其中有一个修改允许它用于负数据。我们的数据中没有负数,所以我们不需要使用它。

特征变换似乎是一个将数据强制变得正态的不二选择,但是使用对数和Box-Cox变换存在一些缺点:

  1. 我们扭曲了原始变量分布,这可能会导致性能下降。
  2. 我们也改变了各种统计量,包括变量之间的协方差。这可能会成为依赖协方差的技术(如PCA)的问题。
  3. 变换存在隐藏数据中异常值的风险,这一开始听起来可能很不错,但这意味着如果我们完全依赖这些变换,我们将失去手动处理异常值的控制权。

我们将在本章的后面部分应用Box-Cox变换来进行特征工程。总的来说,如果目标是强制数据呈正态分布,我建议使用Box-Cox变换,因为对数变换是Box-Cox变换的一种特殊情况。

特征缩放

在大多数具有数值特征的数据集中,我们会遇到一个问题,即数据的尺度彼此之间差异很大,有些尺度过大,效率低下。这对于那些点之间的距离很重要的算法,如k最近邻(k-NN)、k均值、或依赖梯度下降的算法,如神经网络和支持向量机(SVM)来说可能是一个问题。

在接下来,我们将讨论两种标准化方法:最小-最大标准化和z-分数标准化。最小-最大标准化将特征中的值缩放到0到1之间,而z-分数标准化将值缩放到均值为0,方差为1,允许出现负值。虽然最小-最大标准化确保每个特征位于相同的尺度上(从0到1),但z-分数标准化确保更好地处理异常值,但不保证数据最终会在完全相同的尺度上。

这两种转换不会像对数和Box-Cox变换那样影响特征的分布,它们都有助于处理模型中的异常值的影响。最小-最大标准化更难处理异常值,因此如果我们的数据中有许多异常值,通常最好使用z-分数标准化。让我们首先查看在应用任何转换之前的数据(图3.10):

covid_flu[numerical_columns].describe()

截屏2023-09-27 15.28.55.png

我们可以看到我们的尺度、最小值、最大值、标准差和均值都千差万别(图3.11)!

截屏2023-09-27 15.34.06.png

让我们从应用scikit-learn中的StandardScaler类开始标准化我们的数据:

from sklearn.preprocessing import StandardScaler, MinMaxScaler
 
pd.DataFrame(  # mean of 0 and std of 1 but ranges are different (see min and max)
    StandardScaler().fit_transform(covid_flu[numerical_columns]),
    columns=numerical_columns
).describe()

我们可以看到,现在所有的特征都具有均值为0和标准差(因此方差)为1,但如果我们查看特征的最小值和最大值,会发现它们的范围是不同的(图3.12)。现在让我们来看看最小-最大标准化:

pd.DataFrame(  # mean and std are different but min and max are 0s and 1s
    MinMaxScaler().fit_transform(covid_flu[numerical_columns]),
    columns=numerical_columns
).describe()

截屏2023-09-27 15.35.46.png

现在,我们的尺度完全符合要求,所有特征的最小值为0,最大值为1,但标准差和均值不再相同。

构建分类数据

构建定量特征通常涉及使用Box-Cox和log变换等方法来转换原始特征。然而,在构建定性数据时,我们只有几种选项可以从我们的特征中提取尽可能多的信号。其中之一是分箱,它将定量数据转换为定性数据。

开始

分箱是指从数值或分类特征中创建一个新的分类(通常是有序的)特征的行为。分箱数据的最常见方式是基于阈值截断将数值数据分组,类似于创建直方图的方式。

分箱的主要目标是降低模型过度拟合数据的机会。通常,这将以性能为代价,因为我们正在失去正在分箱的特征中的细节。

在scikit-learn中,我们可以使用KBinsDiscretizer类来进行分箱,该类可以使用三种方法为我们进行分箱:

均匀的箱子宽度(图3.13):

from sklearn.preprocessing import KBinsDiscretizer              ❶
 
binner = KBinsDiscretizer(n_bins=3, encode='ordinal', strategy='uniform')
binned_data = binner.fit_transform(covid_flu[['Age']].dropna())
pd.Series(binned_data.reshape(-1,)).plot(
    title='Age (Uniform Binning)', kind='hist', xlabel='Age'    ❷
)

image.png

  • 分位数箱子的高度相等(图3.14):
binner = KBinsDiscretizer(n_bins=3, encode='ordinal', strategy='quantile')
binned_data = binner.fit_transform(covid_flu[['Age']].dropna())
pd.Series(binned_data.reshape(-1,)).hist()     

image.png

  • K均值箱由分配到一维K均值算法结果中最近的簇来选择(图3.15):
binner = KBinsDiscretizer(n_bins=3, encode='ordinal', strategy='kmeans')
binned_data = binner.fit_transform(covid_flu[['Age']].dropna())
pd.Series(binned_data.reshape(-1,)).plot(
    title='Age (KMeans Binning)', kind='hist', xlabel='Age'     ❶
)

image.png

独热编码

RiskFactors特征有点混乱,需要我们动手创建一个自定义的特征转换器,以便在我们的机器学习流程中使用。我们的目标是将一个名义级别的特征进行转换,创建一个独热编码矩阵,其中每个特征表示一个不同的类别,值要么是1,要么是0,表示该值是否存在于原始观察中(图3.16)。

image.png

我们需要创建一个自定义的scikit-learn转换器,它将按逗号拆分RiskFactors列的值,然后将它们转换成一个矩阵,其中每列表示一个风险因素,值要么是0(表示患者没有呈现该症状或该值为空),要么是1(表示患者呈现该风险因素)。如下所示:

from sklearn.base import BaseEstimator, TransformerMixin 
   from sklearn.preprocessing import MultiLabelBinarizer
 
class DummifyRiskFactor(BaseEstimator,TransformerMixin):              ❶
    def __init__(self):
        self.label_binarizer = None
        
    def parse_risk_factors(self, comma_sep_factors):
        ''' asthma,heart disease -> ['asthma', 'heart disease'] '''
        try:
            return [s.strip().lower() for s in comma_sep_factors.split(',')]
        except:
            return []
    
    def fit(self, X, y=None):
        self.label_binarizer = MultiLabelBinarizer()                  ❷
        self.label_binarizer.fit(X.apply(self.parse_risk_factors))    ❸
        return self
    
    def transform(self, X, y=None):
        return self.label_binarizer.transform(X.apply(self.parse_risk_factors))

我们的DummifyRiskFactor转换器首先对数据应用fit方法。fit方法将会:

  1. 将RiskFactors文本转为小写。
  2. 通过逗号将现在的小写字符串分隔开。
  3. 应用scikit-learn中的MultiLabelBinarizer类,为每个风险因素创建虚拟变量。

然后,我们可以在我们的自定义转换器中使用transform方法,将一组混乱的风险因素字符串映射到一个整洁的风险因素矩阵!让我们像使用任何其他scikit-learn转换器一样使用我们的转换器,如下所示:

drf = DummifyRiskFactor()
risks = drf.fit_transform(covid_flu['RiskFactors'])
print(risks.shape)
pd.DataFrame(risks, columns=drf.label_binarizer.classes_)

我们可以在图3.17中看到生成的DataFrame。

截屏2023-09-27 15.45.21.png

我们可以看到,我们的转换器将我们的单个RiskFactors列转换成一个全新的矩阵,其中有41列。我们也可以很快注意到,这个矩阵将会非常稀疏。当我们在本章的特征选择部分时,我们将尝试删除不必要的特征,以尝试减少稀疏性。

值得注意的是,当我们将自定义转换器与数据配合使用时,它将学习训练集中的风险因素,并仅将这些因素应用于测试集。这意味着如果测试集中出现了一个在训练集中不存在的风险因素,那么我们的转换器将丢弃该风险因素,忘记它曾经存在过。

领域特定的特征构建

在不同领域,数据科学家可以选择运用领域特定的知识来创建他们认为相关的新特征。我们将尝试在每个案例研究中至少执行一次这种操作。在这个研究中,让我们创建一个名为FluSymptoms的新列,它将是另一个布尔特征,如果患者至少出现两种症状则为True,否则为False:

covid_flu['FluSymptoms'] = covid_flu[    ['Diarrhea', 'Fever', 'Coughing', 'SoreThroat',      'NauseaVomitting', 'Fatigue']].sum(axis=1) >= 2print(covid_flu['FluSymptoms'].value_counts())
False    753
True     729
 
 
print(covid_flu['FluSymptoms'].isnull().sum())         ❷
0
 
binary_features = [                                    ❸    'Female', 'GroundGlassOpacity', 'CTscanResults',     'Diarrhea', 'Fever', 'FluSymptoms',    'Coughing', 'SoreThroat', 'NauseaVomitting',     'Fatigue', 'InitialPCRDiagnosis']

如果我们决定将FLuSymptoms特征定义为至少有列表中的一个症状,那么True和False之间的分布会如何?

我们强烈鼓励所有数据科学家考虑构建新的领域特定特征,因为它们往往会导致更易解释和有用的特征。我们还可以创建的一些其他特征包括:

  • 风险因素数量(数据集中记录的风险因素计数)
  • 使用基于研究的阈值对数值进行分桶,而不是依赖于k-means或均匀分布

现在,让我们进入使用scikit-learn构建特征工程管道,以便开始看到这些技术的实际应用。

构建我们的特征工程管道

现在我们已经看过了一些特征工程的例子,让我们来测试一下它们。让我们通过将数据拆分为训练集和测试集来准备好用于机器学习的数据集。

训练/测试拆分

为了有效地训练一个能够很好地泛化到未见数据的机器学习管道,遵循训练/测试拆分范例是一个不错的方法(如图3.18所示)。我们将采取以下步骤:

  1. 将整个数据集分成一个训练集(占数据的80%)和一个测试集(占数据的20%)。
  2. 使用训练数据集来训练我们的机器学习管道,并执行交叉验证的网格搜索,从一组潜在的参数值中选择最佳参数组合。
  3. 采用最佳参数组合来在整个训练集上训练机器学习管道。
  4. 使用scikit-learn在测试集上测试我们的机器学习管道,生成一个分类报告。目前为止,我们还没有触及测试集。

image.png

这个步骤将让我们对我们的管道在预测它在训练阶段没有见过的数据方面的表现有一个很好的了解,因此也了解我们的管道如何泛化到我们试图建模的问题上。在本书的几乎所有案例研究中,我们将采用这种方法,以确保我们的特征工程技术正在导致可泛化的机器学习管道。

from sklearn.model_selection import train_test_split
X, y = covid_flu.drop(['Diagnosis'], axis=1), covid_flu['Diagnosis']
x_train, x_test, y_train, y_test = train_test_split(
    X, y, stratify=y, random_state=0, test_size=.2
)

我们可以依赖于scikit-learn的train_test_split来执行这个拆分操作。

请注意,我们希望在进行训练和测试集拆分时进行分层抽样,以使我们的训练和测试集尽可能地类似于原始数据集的响应分布。我们将大量依赖于scikit-learn的FeatureUnionPipeline类来创建灵活的特征工程技术链,这些技术链可以传递给我们的网格搜索函数,如下所示。

from sklearn.preprocessing import FunctionTransformer
from sklearn.pipeline import Pipeline, FeatureUnion 
 
 
risk_factor_pipeline = Pipeline(                                          ❶
    [
        ('select_and_parse_risk_factor', FunctionTransformer(lambda df: 
df['RiskFactors'])),
        ('dummify', DummifyRiskFactor())                                  ❷
    ]
)
 
# deal with binary columns
 
binary_pipeline = Pipeline(
    [
        ('select_categorical_features', FunctionTransformer(lambda df: 
df[binary_features])),
        ('fillna', SimpleImputer(strategy='constant', fill_value=False))  ❸
    ]
)
 
# deal with numerical columns
 
numerical_pipeline = Pipeline(
    [
        ('select_numerical_features', FunctionTransformer(lambda df: 
df[numerical_columns])),
        ('impute', SimpleImputer(strategy='median')),
    ]
)

我们有三个非常简单的特征工程流程,以获得基准度量:

  1. risk_factor_pipeline 选择 RiskFactors 列,然后应用我们的自定义变换器(图3.20)。
  2. binary_pipeline 选择二进制列,然后使用False填充每一列的缺失值(假设如果数据集没有明确表示患者出现了这种症状,那么他们就没有这种症状;图3.21)。
  3. numerical_pipeline 选择数值列,然后使用每个特征的中位数填充缺失值(图3.19)。

image.png

image.png

image.png

让我们看看单独使用每个管道时的效果,将它们传递到我们的辅助函数中:

simple_grid_search(x_train, y_train, x_test, y_test, 
numerical_pipeline)                                   ❶
simple_grid_search(x_train, y_train, x_test, y_test, 
risk_factor_pipeline)                                 ❷
simple_grid_search(x_train, y_train, x_test, y_test, 
binary_pipeline)

现在,让我们将这三个管道连接成一个数据集,并将其传递到我们的辅助函数中,以获得我们的第一个真正的基准度量。

simple_fe = FeatureUnion([                        ❶
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
simple_fe.fit_transform(x_train, y_train).shape
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

性能有了显著提高!准确率提高到了92%,COVID-19的精确度和召回率看起来也好多了。但是,让我们看看是否可以进一步改善它。让我们尝试修改我们的数值管道,不仅填充均值,还对数据进行缩放:

numerical_pipeline = Pipeline(
    [
        ('select_numerical_features', FunctionTransformer(lambda df: 
df[numerical_columns])),
        ('impute', SimpleImputer(strategy='mean')),            ❶
        ('scale', StandardScaler())  # scale our numerical features
    ]
)
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
]) 
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

我们的模型变得更慢了,性能也没有那么好。看起来这不是一个好的方法。那么,如果我们用任意值999填充我们的缺失值,然后进行缩放以减小我们引入的异常值的影响呢?

numerical_pipeline = Pipeline(
    [
        ('select_numerical_features', FunctionTransformer(lambda df: 
df[numerical_columns])),
        ('impute', SimpleImputer(strategy='constant', fill_value=999)),  ❶
        ('scale', StandardScaler())
    ]
)
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_ipeline),
    ('numerical_pipeline', numerical_pipeline)
])                                                                       ❷
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

我们有所进展!看起来任意值填充是有帮助的。让我们再进一步尝试尾部填充(图3.25),我们知道这是一种任意值填充。让我们对我们的数值特征应用Box-Cox变换,使它们变得正态分布,然后应用高斯尾部填充,将缺失值替换为数据的均值(在缩放后将为0)+ 3倍标准差(在缩放后标准差为1,因此为3)。

numerical_pipeline = Pipeline(
    [
        ('select_numerical_features', FunctionTransformer(lambda df: 
          ➥ df[numerical_columns])),
        ('Box-Cox', PowerTransformer(
         method='Box-Cox', standardize=True)),                  ❶
        ('turn_into_df', FunctionTransformer(lambda matrix: 
          ➥ pd.DataFrame(matrix))),  # turn back into dataframe
        ('end_of_tail', EndOfTailImputer(imputation_method='gaussian'))
 
    ]
)
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

几乎和简单的999填充一样好,但我们保留使用尾部填充的流程。让我们应用分箱处理到我们的流程中,看看它对性能的影响:

numerical_pipeline = Pipeline(                                              ❶
    [
        ('select_numerical_features', FunctionTransformer(lambda df: 
          ➥ df[numerical_columns])),
        ('Box-Cox', PowerTransformer(method='Box-Cox', standardize=True)),
        ('turn_into_df', FunctionTransformer(lambda matrix: 
          ➥ pd.DataFrame(matrix))),                                        ❷
        ('end_of_tail', EndOfTailImputer(imputation_method='gaussian')),
        ('ordinal_bins', KBinsDiscretizer(n_bins=10, encode='ordinal', strategy='kmeans'))
    ]
)
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)❸

这实际上是到目前为止我们的最佳结果之一!分箱似乎提高了我们模型的精度,但损失了一点召回率。虽然我们已经成功地创建和转换了特征,但让我们进一步探讨如何通过一些特征选择来改进我们的流程。

image.png

特征选择

在过去的几个部分中,我们一直强调添加特征并改进它们,以使我们的模型更加有效。但是,我们最终得到了数十个特征,其中许多可能并没有太多的预测能力。让我们应用一些特征选择技术来减少我们数据集的维度。

互信息

互信息是一种衡量两个变量之间关系的指标,它度量了在已知第二个变量的情况下,第一个变量不确定性的减少。换句话说,它度量了两个变量之间的依赖关系。在应用这个概念进行特征工程时,我们希望保留与我们的响应变量具有最高互信息的特征,这意味着在已知有用特征的情况下,我们知道响应变量的不确定性最小化。然后,我们排除那些不在前n个特征中的底部特征。

让我们将这个概念应用到我们的风险因素管道中,因为它是迄今为止最大的特征子集(图3.27):

from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import mutual_info_classif
risk_factor_pipeline = Pipeline(                                   ❶
    [
        ('select_risk_factor', FunctionTransformer(
                               lambda df: df['RiskFactors'])),
        ('dummify', DummifyRiskFactor()),
        ('mutual_info', SelectKBest(mutual_info_classif, k=20)),   ❷
    ]
)
 
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

这里我们失去了一些性能,所以我们可以增加要选择的特征数量,或者尝试下一个技术。

假设检验

另一种特征选择方法是利用卡方检验,它是一种仅适用于分类数据的统计检验,用于检验两个变量之间的独立性。在机器学习中,我们可以使用卡方检验来选择检验认为与响应变量最相关的特征,暗示着这些特征在预测响应变量方面是有用的。让我们将卡方检验应用于风险因素(图3.28):

from sklearn.feature_selection import chi2
 
risk_factor_pipeline = Pipeline(               ❶
    [
        ('select_risk_factor', FunctionTransformer(
                               lambda df: df['RiskFactors'])),
        ('dummify', DummifyRiskFactor()),
        ('chi2', SelectKBest(chi2, k=20))      ❷
    ]
)
 
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)

image.png

性能基本与我们的互信息运行相同。

使用机器学习

过去两种特征选择方法有一个共同点,可能制约了我们的性能。它们独立地处理每个特征,因此不考虑特征之间的相互关系。我们可以使用一种方法来考虑特征之间的相关性,那就是使用一个次要的机器学习模型,该模型具有feature_importances或coef属性,并使用这些值来选择特征。

from sklearn.feature_selection import SelectFromModel
from sklearn.tree import DecisionTreeClassifier
 
risk_factor_pipeline = Pipeline(
    [
        ('select_risk_factor', FunctionTransformer(
                               lambda df: df['RiskFactors'])),
        ('dummify', DummifyRiskFactor()),
        ('tree_selector', SelectFromModel(
                 max_features=20, estimator=DecisionTreeClassifier()))      ❶
    ]
)
 
simple_fe = FeatureUnion([
    ('risk_factors', risk_factor_pipeline),
    ('binary_pipeline', binary_pipeline),
    ('numerical_pipeline', numerical_pipeline)
])
 
best_model = simple_grid_search(x_train, y_train, x_test, y_test, simple_fe)❷

image.png

看起来我们可能已经接近了所选模型的性能峰值(图3.26)。让我们在这里停下来评估我们的发现。

作为最后一步,让我们看看我们的特征工程流程的当前状态:

simple_fe.transformer_list
[('risk_factors',  Pipeline(steps=[('select_risk_factor',                   FunctionTransformer(func=<function <lambda>)),                  ('dummify', DummifyRiskFactor()),                  ('tree_selector',                   SelectFromModel(estimator=DecisionTreeClassifier(),                                   max_features=20))])),
 ('binary_pipeline',
  Pipeline(steps=[('select_categorical_features',
                   FunctionTransformer(func=<function <lambda>)),
                  ('fillna',
                   SimpleImputer(fill_value=False, strategy='constant'))])),
 ('numerical_pipeline',
  Pipeline(steps=[('select_numerical_features',
                   FunctionTransformer(func=<function <lambda>)),
                  ('Box-Cox', PowerTransformer(method='Box-Cox')),
                  ('turn_into_df',
                   FunctionTransformer(func=<function <lambda>)),
                  ('end_of_tail', EndOfTailImputer(variables=[0, 1, 2, 3, 4,  
                    ➥ 5])),
                  ('ordinal_bins',
                   KBinsDiscretizer(encode='ordinal', n_bins=10,
                                    strategy='kmeans'))]))]

image.png

在处理表格数据时,有一系列可供选择的特征工程技术。本案例研究仅是我们可选方法的冰山一角,希望为如何从头到尾处理表格数据提供一个框架。我们的总体目标是:

  1. 导入数据。
  2. 探索数据以了解我们拥有哪些特征。
  3. 将特征分配给数据级别,以最大程度地理解它们。
  4. 应用特征改进来修复我们希望使用的列。
  5. 应用特征构建和选择来微调我们的数据。
  6. 对经过工程处理的特征应用ML模型,以测试我们的特征工程工作效果如何。