在本章中,你将学习如何使用Python构建分类模型来预测客户流失,使用两个流行的机器学习库:scikit-learn和Keras。首先,你将使用Pandas探索和清理数据。然后,你将学习如何使用scikit-learn进行独热编码以准备分类特征用于训练,训练逻辑回归模型,使用评估指标了解模型性能,并改进模型性能。你还将学习如何使用Keras执行相同的步骤,使用已经准备好的数据构建神经网络分类模型。在学习的过程中,你将更多地了解分类模型的性能指标以及如何更好地理解混淆矩阵以更好地评估你的分类模型。
本章使用的数据集是IBM Telco客户流失数据集,这是一个用于学习如何建模客户流失的常见数据集。在完成本章的练习后,鼓励你查看其他关于如何处理这个数据集的示例,以扩展你的知识。
业务用例:客户流失预测
在这个项目中,你的目标是预测一家电信公司的客户流失情况。客户流失被定义为客户的流失率,或者换句话说,选择停止使用服务的客户比例。电信公司通常以每月费用或年度合同的方式销售其产品,因此这里的流失代表客户在下个月取消了他们的订阅或合同。
数据最初以CSV文件的形式提供,因此你需要花一些时间将数据加载到Pandas中,然后才能探索数据并最终使用不同的框架创建你的机器学习模型。数据集包含数值变量和分类变量,其中分类变量的取值来自一组离散的可能性。
数据集中有21列。表7-1列出了这些列的列名、数据类型以及有关这些列可能值的一些信息。
你会发现许多特征可以合并或省略,以便用于训练你的机器学习模型。然而,许多特征需要清洗和进一步转换,以准备进行训练过程。
选择无代码、低代码或自定义代码机器学习解决方案之间的选择
在探讨如何使用自定义训练工具,如scikit-learn、Keras或其他在第3章中讨论的选项之前,值得讨论一下自定义解决方案何时以及应该在本书中迄今讨论的其他选项之上使用:
- 无代码解决方案
无代码解决方案在两种情况下特别适用。首先,当你需要构建一个机器学习模型,但没有任何机器学习专业知识时。本书的目标是为你提供有关如何在机器学习中做出正确决策的更多见解,但无代码解决方案通常存在以简化决策并减少与更复杂解决方案一起工作的需求。另一个无代码解决方案脱颖而出的地方是模型的快速原型设计。由于无代码解决方案,如AutoML解决方案,为用户管理特征工程和超参数调整等步骤,这可以是快速训练基准模型的简便方式。不仅如此,在第4章和第5章中所示,可以使用Vertex AI AutoML轻松部署这些模型。在许多情况下,这些无代码解决方案可能足够强大,可以立即投入生产使用。在实践中,自定义解决方案可以在足够的时间和努力下胜过无代码解决方案,但通常通过使用无代码解决方案投入生产所节省的时间可以抵消模型性能的渐进增益。
- 低代码解决方案
当你需要一些自定义化并且正在处理符合你所使用工具约束的数据时,低代码解决方案非常适用。例如,如果你正在处理结构化数据,而你希望解决的问题类型受到BigQuery ML支持,那么BigQuery ML可能是一个很好的选择。在这些情况下,低代码解决方案的优势在于构建模型需要花费较少的时间,更多的时间可以用于实验数据和调整模型。使用许多低代码解决方案,模型可以直接在产品内部投入生产,或者通过模型导出并使用其他工具,如Vertex AI,进行生产化。
- 自定义代码解决方案
这些解决方案是最灵活的,通常由数据科学家和其他AI从业者来利用,他们喜欢构建自己的定制模型。使用像TensorFlow、XGBoost、PyTorch和scikit-learn这样的机器学习框架,你可以使用任何类型的数据和你选择的目标来构建模型。在某种程度上,灵活性和部署选项是无限的。如果需要自定义转换,你可以构建它。如果需要将模型部署为Web应用程序的一部分,你也可以做到。在正确的数据、专业知识和足够的时间的情况下,你可以使用自定义代码解决方案实现最佳结果。然而,其中一个权衡是需要花时间学习各种不同的工具和技术来实现这一点。
你应该选择哪个解决方案?对于每种可能的用例,没有单一的正确答案。要考虑培训、调整和部署模型所需的时间。还要考虑数据集和问题目标。无代码或低代码解决方案是否支持你的用例?如果不支持,那么自定义代码解决方案可能是唯一的选择。最后,要考虑你自己的专业知识。如果你很熟悉SQL但对Python不熟悉,那么像BigQuery ML可能是支持你要解决的问题的最佳选择。
本书的目标不是让你成为使用各种不同自定义代码机器学习框架的专家。然而,本书采取的方法是,对这些工具的了解和一些基本知识可以在解决问题和与数据科学家和机器学习工程师协作方面发挥积极作用。如果你对Python不熟悉,那么Bill Lubanovic的《Introducing Python》(O’Reilly,2019)是一个入门的好资源。此外,如果你想深入学习本章介绍的机器学习框架,Aurélien Géron的《Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow》(第二版,O’Reilly,2022)是一个非常好的资源,被实践中的数据科学家和机器学习工程师引用。
使用Pandas、Matplotlib和Seaborn探索数据集
在开始学习scikit-learn和Keras之前,你应该按照前几章讨论的工作流程来了解和准备机器学习的数据。虽然在前几章中你已经简要使用Google Colab加载数据从BigQuery到一个DataFrame中,并进行了一些基本的可视化,但你还没有完全在Jupyter Notebook环境中经历数据准备和模型训练的过程。
本节重新介绍了如何使用Pandas将数据加载到Google Colab笔记本中。一旦数据加载到DataFrame中,你将在创建用于训练机器学习模型的数据集之前,对数据进行探索、清理和转换。正如你在前几章中所看到的,大部分工作不是用于训练模型,而是用于理解和准备训练数据。
本节中的所有代码,包括一些附加示例,都包含在GitHub上的low-code-ai存储库中的Jupyter笔记本中。
在Google Colab笔记本中将数据加载到Pandas DataFrame
首先,前往colab.research.google.com并打开一个新的笔记本,按照第2章中讨论的过程操作。你可以通过点击如图7-1所示的名称,然后将当前名称替换为一个更有意义的名称,比如”Customer_Churn_Model.ipynb”。
现在在第一个代码块中键入以下代码,以导入分析和可视化客户流失数据集所需的包:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
import sklearn
import tensorflow as tf
在第2章中,当首次探索使用Colab笔记本时,你已经看到了其中一些包,但在这里有一些包对你来说可能是新的。导入scikit-learn的行导入了scikit-learn,这是一个流行的机器学习框架。Scikit-learn首次发布于2007年,构建在其他Python库,如NumPy和SciPy之上。它旨在成为一个易于使用的框架,用于构建机器学习模型,包括线性模型、基于树的模型和支持向量机。接下来的一行,导入tensorflow as tf,导入了TensorFlow。TensorFlow是一个高性能的数值计算库,专为深度神经网络的训练和部署而设计。TensorFlow包括Keras,这是一个旨在简化深度神经网络开发和相应数据转换的库。你将在本章后面使用Keras来训练神经网络模型。
现在执行包含导入语句的单元格以导入这些包。要执行这个操作,请点击单元格左侧的“运行单元格”按钮,如图7-2所示,或者按Shift + Enter来运行这个单元格。
你可以通过检查这些包的版本来快速确认导入语句是否成功执行。每个包都包含一个特殊的属性__version__,它返回包的版本。在一个新的单元格中键入以下代码,并执行该单元格来检查你的scikit-learn和TensorFlow包的版本:
print("scikit-learn version:", sklearn.__version__)
print("TensorFlow version:", tf.__version__)
你应该看到如图7-3所示的版本被打印出来。请注意,你的确切版本号将取决于你进行这个练习的时间。
现在你已经准备好导入你的数据了。回想一下,数据集以CSV格式存储,所以你需要下载这些数据,然后上传到你的笔记本,再导入到Pandas DataFrame中,对吗?实际上,Pandas的一个非常好的特性是,你可以直接从互联网上的位置将CSV文件导入到DataFrame中,而不需要首先下载文件。要做到这一点,请在一个新的单元格中键入以下代码并执行该单元格:
file_loc = 'https://storage.googleapis.com/low-code-ai-book/churn_dataset.csv'
df_raw = pd.read_csv(file_loc)
一般来说,查看DataFrame的前几行是一个好主意。使用df_raw.head()
来探索DataFrame的前几行。你可以快速滚动查看数据的列,并一目了然地看到它似乎与预期相符。表7-2显示了一些列的示例。查看前几行是一个很好的快速第一步,但当然,这个数据集不仅仅是前几行,而且可能存在一些问题,你无法在这里看到。
理解和清理客户流失数据集
现在数据已经加载到DataFrame df_raw中,你可以开始探索和理解它。立即的目标是了解数据可能存在的问题,以便在继续之前解决这些问题。但是,你还应该密切关注DataFrame列的整体分布和其他属性,因为这将在稍后转换数据时很重要。
检查和转换数据类型
首先,你将检查由Pandas推断的数据类型是否与表7-1中的预期数据类型相匹配。这有什么用呢?这可以是一种检查数据输入错误的简单方式,这种错误通常来自数据本身的问题。例如,如果在整数列中有一个字符串值会怎样呢?Pandas将把这一列导入为字符串列,因为整数可以转换为字符串,但在大多数情况下反之不成立。要检查DataFrame的数据类型,键入df_raw.dtypes
到一个新单元格中,并执行该单元格。
请注意,dtypes
后面没有括号。这是因为dtypes
不是Pandas DataFrame df_raw
的函数,而是一个属性。在DataFrame中,除了浮点数和整数以外的任何数据类型都被导入为对象。这是Pandas DataFrame的正常行为。然而,如果你仔细查看输出,几乎每一列都与预期的类型匹配,除了TotalCharges
列。你可以在表7-3中查看最后几列的输出,并在你的笔记本环境中确认你看到的与表中的内容相同。
这是一个好的迹象,说明TotalCharges
列与预期的不同。在继续之前,你应该探索这一列并了解发生了什么。回想一下,你可以使用语法df['ColumnName']
来处理Pandas DataFrame的单个列,其中df
是DataFrame的名称,ColumnName
是列的名称。
首先,使用describe()
方法获取有关TotalCharges
列的一些高级统计信息。尝试在查看提供的代码之前自己尝试一下,但如果需要,下面是代码:
df_raw['TotalCharges'].describe()
你的输出应该与图7-4中的输出相同。由于TotalCharges
被视为一个分类变量(在这种情况下是一个字符串),因此你只能看到元素的计数、唯一值的数量、出现频率最高的顶部值以及该值出现的次数。
在这种情况下,你几乎可以立刻看出问题所在。顶部值要么是空白或空字符串,它出现了11次。这很可能是Pandas将TotalCharges
视为意外数据类型的原因,也导致你发现了数据的问题。
当你有缺失数据时,你可以提出问题:“应该在那里吗?”为了尝试理解这一点,查看DataFrame中的数据相应行,看看是否存在缺失行的模式。为此,你将创建一个掩码并将其应用于DataFrame。掩码将是一个简单的语句,根据输入值返回true或false。在这种情况下,你的掩码将采用以下形式:mask=(df_raw['TotalCharges']==' ')
。==
运算符用于检查TotalCharges
列的值是否等于一个包含单个空格的字符串。如果值是一个包含单个空格的字符串,运算符返回true;否则返回false。在一个新的单元格中键入以下代码并执行该单元格:
mask = (df_raw['TotalCharges']==' ')
df_raw[mask].head()
该单元格的输出如表7-4所示。现在探索这个单元格的结果。你是否注意到有什么可以解释为什么这些客户的TotalCharges
列为空的原因?看一下tenure
列,注意到这些客户的tenure
值都为0。
如果tenure
为0,那么这对于这些电信客户来说是他们的第一个月,他们还没有被收费。这解释了为什么这些客户的TotalCharges
没有值。现在,通过使用不同的掩码来检查tenure
等于0的行来验证这个假设。尝试自己编写此单元格的代码,但如果需要帮助,下面是解决方案:
mask = (df_raw['tenure']==0)
df_raw[mask][['tenure','TotalCharges']]
注意,在上面的代码中,你指定了一个列的列表[‘tenure’, ‘TotalCharges’]。由于你只关注tenure
和TotalCharges
之间的关系,这将使结果更容易解析。所有11行中,TotalCharges
等于’ ‘的行,tenure
列的值都为0。所以,的确,关系如预期一样。现在你知道这些奇怪的字符串值对应于零的TotalCharges
,可以用浮点数0.0替换这些字符串值。这样做的最简单方法是使用df.replace()
方法。这个函数的语法可能需要一些时间来解析,所以首先在一个新的单元格中键入以下代码并执行该单元格以查看结果:
df_1 = df_raw.replace({'TotalCharges': {' ': 0.0}})
mask = (df_raw['tenure']==0)
df_1[mask][['tenure','TotalCharges']]
你的结果应该与表7-5中的结果相同。现在你可以看到以前的TotalCharges
的字符串值已经被浮点值0.0替代。
有了这些结果,你更容易理解代码的第一行中使用的语法df_raw.replace({'TotalCharges': {' ': 0.0}})
。该方法接受一个名为字典的Python数据结构。字典是无序的键-值对列表,其中每对的第一个元素是值的名称,第二个元素是值本身。在这种情况下,第一个元素是TotalCharges
,你想要替换值的列名。第二个元素是一个字典本身,{' ':0.0}
。这对的第一个元素是你想要替换的值,这对的第二个元素是你想要插入的新值。
在探索TotalCharges
列和其他数值列的摘要统计信息之前,确保Pandas知道TotalCharges
是一个浮点数列。为了这样做,键入以下代码到一个新的单元格中,并执行该单元格:
df_2 = df_1.astype({'TotalCharges':'float64'})
df_2.dtypes
请注意,astype()
方法使用与replace()
方法类似的参数。输入是一个字典,其中每对的第一个元素是要更改数据类型的列,第二个参数(在这里是float64
)是该列的新数据类型。你的单元格的输出应该类似于表7-6中显示的内容。
探索摘要统计信息
现在你已经解决了遇到的数据类型问题,可以查看数值列的摘要统计信息。你在第2章中已经看到了如何做到这一点,所以尝试在查看代码之前自己尝试一下,但如果需要帮助,下面是代码,结果见表7-7:
df_2.describe()
乍一看,从表7-7中的结果来看,除了SeniorCitizen
列可能存在一些问题外,没有异常值或其他问题。请回忆一下,SeniorCitizen
的值要么是0,要么是1。SeniorCitizen
列的平均值(均值)0.162…,表示是老年公民的客户的百分比。尽管这个特征可能更好地被视为一个分类变量,但它是一个二进制的0或1,这意味着像均值这样的摘要统计信息仍然可以提供有用的信息。
说到分类特征,你如何探索这些特征的摘要统计信息呢?describe()
方法默认只显示数值特征的统计信息。你可以使用可选的关键字参数include='object'
,让它包括分类特征的统计信息。这指定你只想包括类型为object
的列,这是Pandas中所有非数值列的默认数据类型。在describe()
方法中包括这个可选参数,并在一个新的单元格中执行该单元格。以下是代码,以防你需要帮助:
df_2.describe(include='object')
现在你将看到分类特征的统计信息。这些摘要统计信息更为简单,因为你处理的是离散值而不是数值值。你可以看到具有非空值的行数或计数,唯一值的数量,最常见的值(或在并列情况下最常见的值之一),以及该值的频率。
例如,考虑customerID
列。这一列具有与行数相同的唯一值的数量。另一种解释这一信息的方式是,该列中的每个值都是唯一的。你还可以通过查看最常见值出现的频率来进一步确认这一点。
探索摘要统计信息,看看还注意到了什么。以下是一些观察结果的集合,这些观察结果将有助于进一步工作,但并不是从这些结果中可用的有用信息的完整列表:
gender
和Partner
列在两个不同值之间相当平衡。- 绝大多数客户有电话服务,但几乎一半的客户没有多线电话。
- 许多列具有三种不同的可能值。虽然你有关于顶级类别的信息,但目前你还不知道不同值的分布情况。
- 数据集的标签
Churn
在No和Yes值之间略显不平衡,大约是5:2的比率。 - 所有列,包括数值列,都有7,043个元素。可能存在其他缺失值,类似于你为
TotalCharges
发现的情况,但没有任何空值。
探索分类列的组合
如你在第6章中看到的,查看不同特征之间的交互通常有助于理解哪些特征最重要以及它们如何相互作用。然而,在那个项目中,所有的特征都是数值的。在这个项目中,大多数特征都是分类的。你将探索一种方法,通过查看跨多个列的不同特征值组合的分布来理解这种情况下的特征交互。
首先看一下PhoneService
和MultipleLines
列。常识告诉我们,如果客户没有电话服务,他们就不能有多个电话线。你可以使用value_counts()
方法来确认数据集中是否如此。value_counts()
方法将DataFrame中的列的列表作为参数,并返回唯一值组合的计数。在一个新的单元格中键入以下代码并执行该单元格,以返回PhoneService
和MultipleLines
列之间的唯一值组合:
df_2.value_counts(['PhoneService','MultipleLines'])
你的结果应该与以下结果相同。请注意,MultipleLines
有三个不同的值,No、Yes和No phone service。毫不奇怪,只有当PhoneService
特征的值为No时,才会出现No phone service。这意味着MultipleLines
特征包含了PhoneService
特征的所有信息。PhoneService
是冗余的,你将稍后从训练数据集中移除这个特征。
PhoneService MultipleLines
Yes No 3390
Yes 2971
No No phone service 682
你的数据集中的其他特征是否以类似的方式“相关”?毫不奇怪,的确是这样。作为一个练习,在一个新的单元格中编写代码来探索InternetService
、OnlineSecurity
、OnlineBackup
、StreamingTV
和StreamingMovies
之间的关系。
再次,你会发现一些特征值之间存在冗余,但在这种情况下不太明显。当InternetService
的值为No时,所有其他列的值也都是No internet service。然而,有两种不同的互联网类型,光纤和DSL,对于这些情况,是否存在冗余不太清楚。虽然这里没有包括DeviceProtection
和TechSupport
列,但它们与InternetService
具有相同的关系。你也应该自己探索一下。在下一节进行特征转换时,你将考虑如何考虑这些信息。
你还应该探索分类特征与标签Churn
之间的关系。例如,考虑Contract
特征。这个特征有三种可能的值:Month-to-month,One year和Two year。你的直觉告诉你这个特征与Churn
有什么关系吗?合理地期望,较长的合同期限会使客户较不可能流失,至少如果客户没有处于合同期末的话。你可以像之前一样使用value_counts()
方法来查看这种关系,但通常更容易通过可视化来理解关系,而不是查看一张值表。要可视化这一点,将以下代码写入一个新的单元格并执行该单元格:
(df_2.groupby('Contract')['Churn'].value_counts(normalize=True)
.unstack('Churn')
.plot.bar(stacked=True))
这实际上是一行非常长的代码,需要解析。括号的开始和结束告诉Python将其视为一行代码,而不是三行独立的代码。首先使用groupby()
函数按不同的Contract
值对值进行分组。你想查看与Churn
的关系,所以选择Churn
列,然后应用value_counts
函数。请注意额外的normalize=True
参数,它将用百分比而不是数量替换每对的值计数。这样做的好处是,你可以看到在每个Contract
值内,有多少客户流失与没有流失的百分比,而不是在不均匀的组之间比较计数。unstack()
函数用于将表格格式化为更易阅读的格式,然后使用内置的Pandas绘图功能绘制数据。在这种情况下,Figure 7-5 使用堆叠条形图快速可视化比较不同的Contract
值。
你可以看到,与一年或两年合同相比,月付合同的客户流失率较高。从可视化中,你可以看到超过40%的月付合同客户取消了服务,而一年合同约为15%,两年合同则不到5%。这意味着合同类型几乎肯定将成为未来的一个有用特征。
作为一个练习,进行这种类型的分析以针对其他分类特征。注意哪些特征在不同的值之间具有不同的流失百分比,以及哪些特征在各个值之间差异较小。这将有助于以后选择特征。
在Python中多次执行类似的代码块时,创建一个函数来执行可能更有效。例如,你可以创建一个函数来创建上面的分布图,使用以下代码:
def plot_cat_feature_dist(feature_name):
(df_2.groupby(feature_name)['Churn'].value_counts(normalize=True)
.unstack('Churn')
.plot.bar(stacked=True))
def
是Python中定义函数的关键字,函数名是plot_cat_feature_dist
,feature_name
是输入变量。这样,plot_cat_feature_dist('Contract')
将生成与图7-5中相同的图表。然后,你可以对所有的分类变量使用这个函数。
在探索分类特征时,你应该注意到一些观察结果:
- 老年人的流失率大约是非老年人的两倍。
- 性别、StreamingTV和StreamingMovies特征的值似乎对流失率没有影响。
- 家庭规模越大,流失率越低。换句话说,家庭中有伴侣或受抚养者会降低流失率。
- 对于有电话线的人,拥有多条线路会增加流失率。
- InternetService特征影响流失率。光纤互联网服务的流失率远高于DSL。没有互联网服务的人具有最低的流失率。
- 互联网附加服务(如OnlineSecurity和DeviceProtection)降低了流失率。
- 使用纸质账单会增加流失率。大多数PaymentMethod的值都相同,除了Electronic Check,它的流失率明显高于其他支付方式。
你还注意到了其他什么吗?务必记下这些观察结果以备后用。
探索数值和分类列之间的交互作用
在最终思考如何转换特征之前,你还应该探索数值特征和标签之间的关系。请记住,SeniorCitizen实际上是一个分类列,因为这两个值代表了两个离散的类别。剩下的数值列是tenure,MonthlyCharges和TotalCharges。如果客户每个月支付相同的金额,这些列将具有简单的关系。也就是说,tenure × MonthlyCharges = TotalCharges。这在tenure为0的情况下已经明确看到了。
这种情况有多少次?直观地说,也许从经验上来说,月度费用往往会随时间变化。这可能是由于促销定价结束,也可能是由于改变你支付的服务。你可以使用Pandas函数来验证这一直观感觉。将以下代码写入新的单元格并执行该单元格以查看比较tenure × MonthlyCharges和TotalCharges的新列的摘要统计信息:
df_2['AvgMonthlyCharge'] = df_2['TotalCharges']/df_2['tenure']
df_2['DiffCharges'] = df_2['MonthlyCharges']-df_2['AvgMonthlyCharge']
df_2['DiffCharges'].describe()
请注意,你在DataFrame df_2中创建了两个新列。AvgMonthlyCharge列捕获了客户在服务期内的平均月费,而DiffCharges列捕获了平均月费和当前月费之间的差异。以下是结果:
count 7032.000000
mean -0.001215
std 2.616165
min -18.900000
25% -1.160179
50% 0.000000
75% 1.147775
max 19.125000
Name: DiffCharges, dtype: float64
从这些摘要统计中,你应该得出一些观察结果:首先,请注意计数比总行数少11。为什么会这样?回想一下,你有11行的tenure为零。在Pandas中,如果除以零,该值将记录为NaN,而不是引发错误。否则,请注意分布似乎相当对称。均值几乎为零,中位数为零,最小值和最大值接近彼此的相反值。
消除NaN值的一种方法是使用replace()方法来替换未定义的值。在新的单元格中使用以下代码来执行此任务:
df_2['AvgMonthlyCharge'] = (df_2['TotalCharges'].div(df_2['tenure'])
.replace(np.nan,0))
df_2['DiffCharges'] = df_2['MonthlyCharges']-df_2['AvgMonthlyCharge']
df_2['DiffCharges'].describe()
将null值替换为零值的选择是一个填充策略的示例。填充的过程是将未知值替换为在手头问题上合理的替代值的过程。因为你想查看月费和平均月费之间的差异,所以说“没有差异”是一个合理的方法,以避免不得不放弃可能有用的数据。如果不进行填充,你将失去所有tenure为零的行,因此你的模型将无法准确预测这些情况。在足够大的数据集中,如果缺失数据没有聚焦在一个特定的组上,通常会采取忽略该数据的策略。这是第6章中采取的方法。
DiffCharges的值与Churn列有什么关系?你用于理解分类列之间关系的方法在这里不太适用,因为DiffCharges是数值型的。但你可以将DiffCharges列的值分桶化,并使用之前使用过的方法。分桶化的思想是将数值列分成称为桶的值范围。数值特征通过问“这个值属于哪个桶?”而成为一个分类特征。在Pandas中,你可以使用cut()函数来为数值列定义桶。你可以提供要使用的桶数,或指定一个截止点的列表。要对DiffCharges列进行分桶化并探索其对Churn的影响,请在新单元格中输入以下代码并执行该单元格:
df_2['DiffBuckets'] = pd.cut(df_2['DiffCharges'], bins=5)
plot_cat_feature_dist('DiffBuckets')
得到的图表(如图7-6所示)显示,MonthlyCharges和AvgMonthlyCharge之间的差异(无论是正数还是负数)越大,相应范围的流失率越高。作为练习,尝试使用不同数量的桶,并观察你注意到的模式。
请注意,每个桶的流失率并不遵循良好的线性趋势。也就是说,流失率在接近中心之前会下降,然后再上升,具体取决于桶距离中心的距离有多远。在这种情况下,将桶成员视为分类变量可能比将特征保留为数值特征对机器学习更有利。
你也可以探索未经任何处理的数值特征。例如,让我们通过使用以下代码来探索MonthlyCharges和Churn之间的关系。这种关系在图7-7中可视化:
在图7-7中,你可以看到随着MonthlyCharges的值增加,流失率倾向于增加。这意味着MonthlyCharges列将对预测流失很有用。
作为练习,自行对tenure和TotalCharges列进行这种分析。你应该会发现,随着tenure和TotalCharges的增加,流失率会减少。这两列与流失的关系相似是有道理的,因为较长的tenure应该导致在tenure期间支付更多的费用。使用前几章的代码,检查这两个特征之间的相关性,看看它们确实高度相关,相关性约为0.82。
使用Pandas和Scikit-Learn进行特征转换
到目前为止,您已经探讨了数据集中的不同列,它们之间的相互关系,以及它们与标签之间的相互关系。现在,您将准备将数据用于自定义模型。首先,您将选择要用于训练ML模型的列。之后,您将转换这些特征,使其更适合用于训练。请记住,您的特征必须是具有有意义的幅度的数字。在选择要用于此项目的特征时,您将考虑这一点。
特征选择
在上一部分中,探讨了客户流失数据集中不同特征与客户流失列 “Churn” 之间的交互作用。您发现一些特征要么不具有预测性,即不同值对流失率没有影响,要么是与其他特征具有冗余性。您应该首先复制数据框 df_2,然后删除不会用于训练模型的列。为什么要创建一个副本?如果您从 df_2 中删除列,那么您可能需要重新查看创建该数据框的代码,以便再次访问那些数据。尽管没有明确说明,但这就是为什么创建数据框 df_2 而不是更改原始数据框 df_raw 的原因。通过在数据框的副本中删除列,您可以在需要访问原始数据时仍然可以访问它。
在上一部分中,您发现 gender、StreamingTV 和 StreamingMovies 列对标签 Churn 没有预测作用。此外,您还发现 PhoneLine 特征是多余的,并已包含在 MultipleLines 特征中,因此您希望将其删除,以避免与共线性相关的问题。在第 6 章中,您了解到,当预测变量之间存在高度相关性时,会出现共线性,导致回归系数的估计不稳定和不可靠。当使用线性模型时,这些问题会被放大,相对于更复杂的模型类型而言。对抗这一问题的一种方法是仅使用一组共线性列中的一个。
在 Pandas 数据框中删除列的最简单方法是使用 drop() 函数。将以下代码键入新的单元格并执行,以创建 Pandas 数据框的副本并删除您不再需要的列:
df_3 = df_2.copy()
df_3 = df_3.drop(columns=['gender','StreamingTV',
'StreamingMovies','PhoneService'])
df_3.columns
包含 df_3.columns 的这行用于检查剩下哪些列。确切的输出将取决于您之前的探索,但作为示例,您可能会看到如下输出:
Index(['customerID', 'SeniorCitizen', 'Partner', 'Dependents',
'tenure', 'MultipleLines', 'InternetService', 'OnlineSecurity',
'OnlineBackup','DeviceProtection', 'TechSupport', 'Contract',
'PaperlessBilling', 'PaymentMethod', 'MonthlyCharges',
'TotalCharges', 'Churn', 'AvgMonthlyCharge', 'DiffCharges',
'DiffBuckets', 'MonthlyBuckets', 'TenureBuckets,
'TotalBuckets'], dtype='object')
在这里显示的 DataFrame 列中,添加了 AvgMonthlyCharge、DiffCharges、DiffBuckets、MonthlyBuckets、TotalBuckets 和 TenureBuckets。您看到 DiffBuckets 特征将是一个有用的特征,而 Tenure 特征与 TotalCharges 特征高度相关。为了防止多重共线性问题,删除 TotalCharges 特征以及除 DiffBuckets 之外的所有额外添加的特征。所需的代码可能会根据您的探索不同而有所不同,但以下代码可作为示例:
df_3 =df_3.drop(columns=['TotalCharges','AvgMonthlyCharge', 'DiffCharges','MonthlyBuckets', 'TenureBuckets', 'TotalBuckets'])
最后,customerID 列怎么处理?这一列过于精细化,无法在预测模型中发挥作用。为什么呢?请记住,customerID 列唯一标识每一行。存在着风险,让模型学习将此特征的值与 Churn 的值建立直接关系,尤其考虑到随后的特征转换。这对于您的训练数据集来说是很好的,但一旦您的模型首次看到 customerID 的新值,它将无法以有意义的方式使用该值。因此,最好在训练模型时删除此列。作为练习,编写代码将 customerID 列删除到新的单元格,并执行该单元格以删除该列。以下是解决方案代码,但请尽量在不查看的情况下完成此任务:
df_3 = df_3.drop(columns=['customerID'])
df_3.dtypes
最终,您得到了 15 个特征列和 1 个标签列 Churn。以下是最终的 df_3.dtypes 行的输出供您参考:
SeniorCitizen int64
Partner object
Dependents object
tenure int64
MultipleLines object
InternetService object
OnlineSecurity object
OnlineBackup object
DeviceProtection object
TechSupport object
Contract object
PaperlessBilling object
PaymentMethod object
MonthlyCharges float64
Churn object
DiffBuckets category
DiffBuckets 是一个类别列,而不是一个对象列。这是因为分桶过程包括表示分桶的区间的额外信息。
利用 scikit-learn 编码分类特征
在开始训练过程之前,您需要将分类特征编码为数值特征。SeniorCitizen 就是一个很好的示例,它的值不再是 Yes 和 No,而是分别编码为 1 和 0。实际上,这就是您将来要使用 scikit-learn 进行的操作。
首先要注意的是,您的许多分类特征都是二元特征。Partner、Dependents、OnlineSecurity、OnlineBackup、DeviceProtection、TechSupport 和 PaperlessBilling 都是二元特征。需要注意的是,对于 OnlineSecurity、OnlineBackup、DeviceProtection 和 TechSupport,这并不是严格正确的,但No internet service 的值已经在 InternetService 列中捕获。在对特征进行编码之前,使用以下代码将不同列中的所有 No internet service 值替换为 No 值:
df_prep = df_3.replace('No internet service', 'No')
df_prep[['OnlineSecurity', 'OnlineBackup', 'DeviceProtection', 'TechSupport']].nunique()
nunique() 方法用于计算每列的唯一值数。在这个单元的输出中,您应该看到 OnlineSecurity、OnlineBackup、DeviceProtection 和 TechSupport 有两个唯一值,分别对应 No 和 Yes。您将保留这个 DataFrame,df_prep,以备稍后进行任何其他特征工程。
现在,您可以执行独热编码(one-hot encoding)。独热编码是将具有独立值的分类特征转换为数值表示的过程。这个表示是一个整数列表,每个可能的特征值对应一个整数。例如,InternetService 特征有三个可能的值:No、DSL 和 Fiber Optic。这些值的独热编码分别为 [1,0,0]、[0,1,0] 和 [0,0,1]。另一种思考这个问题的方式是,我们为每个特征值创建了一个新的特征列。也就是说,第一列询问:“InternetService 的值是否等于 No?”如果是,值为1,否则为0。其他两列对应于同样的问题,但分别对应 DSL 和 Fiber Optic 的值。按照这种独热编码的思维方式,通常,只有两个值 No 和 Yes 的特征(比如 Partner)将被编码为 0 和 1,而不是 [1,0] 和 [0,1]。
Scikit-learn 包含一个专门用于特征和标签的预处理库,用于将分类特征转换为独热编码特征,您将使用 scikit-learn 中的 OneHotEncoder 类。以下代码示例演示了如何对您在本示例中使用的分类列进行独热编码:
from sklearn.preprocessing import OneHotEncoder
numeric_columns = ['SeniorCitizen', 'tenure', 'MonthlyCharges']
categorical_columns = ['Partner', 'Dependents', 'MultipleLines',
'InternetService','OnlineSecurity',
'OnlineBackup','DeviceProtection',
'TechSupport','Contract','PaperlessBilling',
'PaymentMethod','DiffBuckets']
X_num = df_prep[numeric_columns]
X_cat = df_prep[categorical_columns]
ohe = OneHotEncoder(drop='if_binary')
X_cat_trans = ohe.fit_transform(X_cat)
在继续之前,逐行理解这段代码是值得的。首先,您通过 from sklearn.preprocessing import OneHotEncoder
从 scikit-learn 中导入 OneHotEncoder 类。接下来,您将列分为数值列和分类列。由于 SeniorCitizen 已经被编码,您可以直接将其包括在数值列中。然后,接下来的两行代码将DataFrame拆分为两个单独的DataFrame:X_num 用于数值特征,X_cat 用于分类特征。
最后,您准备使用 scikit-learn 的 OneHotEncoder。首先,通过 ohe = OneHotEncoder(drop='if_binary')
这一行创建了一个独热编码器。drop='if_binary'
参数将二进制特征值替换为0或1,而不返回完整的独热编码。
最后一行是实际的转换发生的地方。fit_transform
函数执行两件不同的事情。fit_transform
中的 fit
部分涉及到 OneHotEncoder 学习不同特征的值和分配独热编码值。这是重要的,因为有时您可能希望逆转这个过程,回到原始的值。例如,做出预测后,您想知道客户使用的付款方式是什么。您可以使用 OneHotEncoder 的 inverse_transform()
方法将编码后的数值输入转换回原始输入。例如,考虑以下两行分别在不同单元中运行的代码:
X_cat_trans.toarray()[0]
ohe.inverse_transform(X_cat_trans.toarray())[0]
第一行代码的输出如下:
[1., 0., 0., 1., 0., 1., 0., 0., 0., 1., 0., 0., 1., 0., 0., 1., 0., 0., 1.,
0., 0., 0., 1., 0., 0., 0.]
第二行代码的输出如下:
Partner Yes
Dependents No
MultipleLines No phone service
InternetService DSL
OnlineSecurity No
OnlineBackup Yes
DeviceProtection No
TechSupport No
Contract Month-to-month
PaperlessBilling Yes
PaymentMethod Electronic check
DiffBuckets (-3.69, 3.915]
一旦 OneHotEncoder 已经适应了数据,你可以在原始值和编码后的值之间来回切换,使用 transform() 和 inverse_transform()。
最后,你需要将数值特征和编码后的分类特征合并成一个单一的对象。编码后的分类特征以 NumPy 数组的形式返回,所以你需要将 Pandas DataFrame 转换为 NumPy 数组,并将这些数组连接成一个单一的数组。另外,你还需要创建一个 NumPy 数组用于标签 Churn。要执行此操作,请在新单元格中运行以下代码:
X = np.concatenate((X_num.values,X_cat_trans.toarray()), axis=1)
y = df_prep['Churn'].values
NumPy 中的 concatenate() 函数接受两个数组并返回一个单一数组。X_num 是一个 Pandas DataFrame,但实际的 DataFrame 值存储为 NumPy 数组。你可以通过查看 DataFrame 的 values 属性来访问该数组。X_cat_trans 是一种特殊类型的 NumPy 数组,称为稀疏数组。稀疏数组,即大多数条目为 0 的数组,可能很方便,因为可以使用许多巧妙的优化方法来更有效地存储它们。然而,你需要相应的实际数组。你可以使用 toarray() 方法来访问它。最后,你希望以“水平”的方式连接数据框,将列并排合并在一起,因此你需要指定附加参数 axis=1。类似地,axis=0 对应于“垂直”堆叠数组,即将一行的列表附加到另一行。
概括和数据拆分
在所有准备数据集的工作之后,您是否已经准备好开始训练模型了?不完全正确。您需要执行训练-测试数据拆分,以确保您可以正确评估模型。 Scikit-learn有一个很好的助手函数来执行这个操作,称为train_test_split,位于model_selection模块中。尽管在scikit-learn和其他自定义训练框架中,必须自行管理数据集的拆分,但大多数(如果不是全部)框架都提供了工具来简化这个过程。 要将数据集拆分为训练和测试数据集,请在新的单元格中执行以下代码:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X,y,test_size=0.20,
random_state=113)
X_train.shape
第一行从scikit-learn的model_selection库中导入train_test_split函数。第二行是拆分操作所在。train_test_split接受您要拆分的数组列表(这里是X和y),以及test_size参数,指定训练数据集的大小作为一个百分比。您还可以选择提供random_state,这样执行此代码的其他人将得到相同的训练和测试数据集行。最后,您可以看到训练数据集的最终大小,通过X_train.shape来表示。这是一个形状为(5634, 29)的数组。也就是说,经过独热编码后,训练数据集中有5,634个示例,包含29个特征。这意味着测试数据集中有1,409个示例。
构建使用Scikit-Learn的逻辑回归模型
在手头有准备好的训练和测试数据集后,你可以开始训练你的机器学习模型了。这一部分介绍了你将要训练的模型类型,即逻辑回归,以及如何在scikit-learn中开始训练模型。此后,你将了解不同的方法来评估和改进你的分类模型。最后,你将介绍scikit-learn中的管道,这是一种将你对数据集进行的不同转换和你想要使用的训练算法整合在一起的方式。