Python-数值计算-全-
Python 数值计算(全)
原文:
annas-archive.org/md5/9e81efca12eeaa9a42c1e05702b8c0c0译者:飞龙
序言
数据挖掘,或解析数据以提取有用的洞察力,是一项能够改变你作为数据科学家职业生涯的技能。Python 是一门灵活的编程语言,配备了强大的库和工具包,为你提供了完美的平台,让你能够筛选数据并挖掘所需的洞察力。这条学习路径旨在帮助你熟悉 Python 库和相关的统计学知识,使你能够轻松掌握数据挖掘。
你将学习如何使用 Pandas,Python 的流行库,分析各种数据,并利用 Matplotlib 的强大功能生成吸引人且令人印象深刻的可视化图表,展示你所提取的洞察力。你还将探索不同的机器学习技术和统计方法,帮助你构建强大的预测模型。
在完成这条学习路径后,你将具备扎实的基础,能够将你的数据挖掘技能提升到一个新的层次,并踏上成为一名炙手可热的数据科学专业人才的道路。
这条学习路径包括以下 Packt 出版的内容:
-
《机器学习中的统计学》 作者:Pratap Dangeti
-
《Matplotlib 2.x 实战》 作者:Allen Yu, Claire Chung, Aldrin Yim
-
《Pandas Cookbook》 作者:Theodore Petrou
本书适合的人群
如果你想学习如何使用 Python 的多个库从数据中提取有价值的信息,并将其呈现为引人入胜的可视化效果,那么这条学习路径是最理想的选择。只需具备一些 Python 的基础知识,就可以开始学习这条路径。
本书内容
第一章,从统计学到机器学习之旅,向你介绍了统计学和机器学习的所有必要基础和基本构建模块。本章通过 Python 和 R 代码示例,详细解释了所有基础内容。
第二章,基于树的机器学习模型,重点介绍了行业从业者使用的各种基于树的机器学习模型,包括决策树、集成法、随机森林、AdaBoost、梯度提升和 XGBoost,并通过 HR 流失示例展示两种语言的实现。
第三章,K-近邻算法与朴素贝叶斯,展示了简单的机器学习方法。K-近邻算法通过乳腺癌数据进行解释,朴素贝叶斯模型则通过使用各种 NLP 预处理技术的消息分类示例进行说明。
第四章,无监督学习,介绍了各种技术,如 K-means 聚类、主成分分析、奇异值分解和基于深度自编码器的深度学习。最后解释了为何深度自编码器比传统的 PCA 技术更强大。
第五章,强化学习,提供了学习通过周期状态达到目标的最佳路径的详尽技术,例如马尔可夫决策过程、动态规划、蒙特卡洛方法和时间差分学习。最后,提供了一些使用机器学习和强化学习的优秀应用案例。
第六章,你好,绘图世界,介绍了 Matplotlib 图形的基本组成部分,以及 Matplotlib 版本 2 的最新功能。
第七章,可视化在线数据,教你如何通过使用真实世界数据集设计直观的信息图表,以实现有效的叙述。
第八章,可视化多变量数据,为你概述了适用于多特征或多维数据集可视化的图表类型。
第九章,添加交互性和动画绘图,向你展示 Matplotlib 不仅局限于创建静态图的能力。你将学习如何创建交互式图表和动画效果。
第十章,选择数据子集,覆盖了选择不同数据子集的多种方法,可能会让人感到困惑。
第十一章,布尔索引,介绍了根据布尔条件查询数据以选择其子集的过程。
第十二章,索引对齐,针对非常重要且常被误解的index对象进行介绍。错误使用索引会导致许多错误结果,本章示范了如何正确使用它来获得强大的分析结果。
第十三章,分组聚合、过滤和转换,介绍了在数据分析过程中几乎总是必需的强大分组功能。你将构建自定义函数应用于你的分组数据。
第十四章,将数据重构为整洁形式,解释了什么是整洁数据以及其重要性,然后展示了如何将各种不同形式的混乱数据集转换为整洁数据形式。
第十五章,合并 Pandas 对象,详细讲解了合并 DataFrames 和 Series 的多种方法,包括纵向和横向合并。我们还将进行一些网页抓取,比较特朗普和奥巴马的支持率,并连接到 SQL 关系数据库。
要充分利用本书
本书假定你已掌握 Python 和 R 的基础知识,并知道如何安装相关库。但不假定你已经掌握高级统计学和数学,比如线性代数等知识。
本书中使用了以下软件版本,但任何更新版本也应能正常运行:
-
Anaconda 3–4.3.1(所有 Python 及相关包都包含在 Anaconda 中,Python 3.6.1,NumPy 1.12.1,Pandas 0.19.2,scikit-learn 0.18.1)
-
R 3.4.0 和 RStudio 1.0.143
-
Theano 0.9.0
-
Keras 2.0.2
-
推荐使用配置为 Windows 7+、macOS 10.10+ 或 Linux 的计算机,且内存为 4 GB 或以上。
下载示例代码文件
您可以通过您的账户从 www.packt.com 下载本书的示例代码文件。如果您是从其他地方购买本书的,您可以访问 www.packt.com/support,注册并直接通过电子邮件获取文件。
您可以按照以下步骤下载代码文件:
-
登录或注册 www.packt.com。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名,按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,github.com/PacktPublishing/Numerical-Computing-with-Python。如果代码有更新,将在现有的 GitHub 仓库中更新。
我们还提供来自丰富书籍和视频目录的其他代码包,您可以在 github.com/PacktPublishing/ 查看!
使用的约定
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号处理方式如下:“mode函数在numpy包中未实现。”任何命令行输入或输出如下所示:
>>> import numpy as np
>>> from scipy import stats
>>> data = np.array([4,5,1,2,7,2,6,9,3])
# Calculate Mean
>>> dt_mean = np.mean(data) ;
print ("Mean :",round(dt_mean,2))
新术语 和 重要词汇 以粗体显示。
警告或重要提示如下所示。
提示和技巧如下所示。
联系我们
我们总是欢迎读者的反馈。
一般反馈:如果您对本书的任何部分有疑问,请在邮件主题中注明书名,并通过 customercare@packtpub.com 向我们发送邮件。
勘误:尽管我们已经尽力确保内容的准确性,但错误是难免的。如果您发现本书中的错误,我们非常感激您能够报告给我们。请访问 www.packt.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,填写相关信息。
盗版:如果您在互联网上发现任何非法复制的作品,我们将非常感激您提供其地址或网站名称。请通过 copyright@packt.com 联系我们,并附上该材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专长,并且有兴趣撰写或参与撰写一本书,请访问authors.packtpub.com。
评论
请留下评论。当您阅读并使用本书后,不妨在您购买该书的网站上留下评论?潜在读者可以查看并利用您的公正意见做出购买决策,我们在 Packt 可以了解您对我们产品的看法,作者们也能看到您对他们书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问packt.com。
第一章:从统计学到机器学习的旅程
最近,机器学习(ML)和数据科学的流行程度前所未有。这一领域预计在未来几年将呈指数增长。首先,什么是机器学习?为什么有人需要费力去理解其原理呢?嗯,我们有答案给你。一个简单的例子就是电子商务网站中的图书推荐,当某人搜索特定书籍或其他产品时,网站会推荐与之相关的其他产品,以便向用户提供他们可能喜欢的建议。听起来像魔法一样,对吧?事实上,利用机器学习可以实现远比这更强大的功能。
机器学习是一个研究领域,其中模型可以基于数据中的经验自动学习,而不完全像统计模型那样进行建模。随着时间的推移和数据量的增加,模型的预测能力将不断提升。
在这一章中,我们将介绍理解统计学习所需的基本概念,并为那些希望理解机器学习方法背后统计工作原理的全职统计学家或软件工程师奠定基础。
构建和验证模型的统计术语
统计学是数学的一个分支,涉及数值数据的收集、分析、解释、展示和组织。
统计学主要分为两个子分支:
-
描述性统计学:这些用于总结数据,例如连续数据类型(如年龄)的均值、标准差,而频率和百分比则适用于分类数据(如性别)。
-
推断统计学:很多时候,收集全部数据(在统计方法中也称为总体)是不可能的,因此会收集数据点的一个子集,也称为样本,并基于此对整个总体做出结论,这就是推断统计学。通过假设检验、数值特征估计、数据内部关系的相关性分析等方式得出推论。
统计建模是将统计学应用于数据,通过分析变量的显著性来发现潜在的隐藏关系。
机器学习
机器学习是计算机科学的一个分支,利用过去的经验进行学习,并用其知识做出未来的决策。机器学习处于计算机科学、工程学和统计学的交汇点。机器学习的目标是从给定的示例中概括出可检测的模式或创建一个未知规则。机器学习领域的概述如下:

机器学习大致分为三类,但根据具体情况,这些类别可以组合在一起,以实现特定应用所需的结果:
-
监督学习:这是教机器学习变量之间的关系以及与目标变量的关系,类似于老师向学生提供反馈的方式。监督学习的主要领域如下:
-
分类问题
-
回归问题
-
-
无监督学习:在无监督学习中,算法通过自身学习,无需任何监督或目标变量的提供。关键在于从给定的数据中发现隐藏的模式和关系。无监督学习的类别如下:
-
降维
-
聚类
-
-
强化学习:这使得机器或代理能够根据来自环境的反馈来学习其行为。在强化学习中,代理采取一系列决定性的行动而不需要监督,最终会得到一个奖励,可能是+1 或-1。根据最终的回报/奖励,代理重新评估其路径。强化学习问题更接近于人工智能的方法论,而非常用的机器学习算法。
在某些情况下,当变量数量非常高时,我们最初执行无监督学习来减少维度,之后再进行监督学习。同样,在一些人工智能应用中,监督学习结合强化学习可以用来解决问题;例如,在自动驾驶汽车中,最初通过监督学习将图像转换为某种数字格式,并与驾驶动作(左、前、右、后)结合使用。
模型构建和验证的统计基础及术语
统计学本身是一个庞大的学科,足以编写一本完整的书籍;然而,在此我们试图专注于与机器学习相关的关键概念。本节涵盖了一些基础知识,其余概念将在后续章节中介绍,必要时会涉及机器学习的统计等价物。
预测分析依赖于一个主要假设:历史会重演!
通过在历史数据上拟合预测模型并验证关键指标,得到的模型将用于根据与过去数据中显著的解释变量相同的变量预测未来事件。
统计模型实施的先行者是银行业和制药业;随着时间的推移,分析方法也扩展到其他行业。
统计模型是一类数学模型,通常由数学方程指定,这些方程将一个或多个变量与现实进行近似。统计模型的假设描述了一组概率分布,这使其与非统计、数学或机器学习模型有所不同。
统计模型始终以一些所有变量都应满足的基本假设开始,然后模型提供的性能在统计上是显著的。因此,了解所有构建模块中涉及的各个方面为成为成功的统计学家提供了坚实的基础。
在接下来的部分中,我们描述了相关基础知识及相关代码:
-
总体:这是完整的观察总体或关于研究对象的所有数据点。
-
样本:样本是总体的一个子集,通常是被分析的群体的一小部分。

通常,对整个群体进行分析是昂贵的;因此,大多数统计方法都是通过分析样本来对群体做出结论。
-
参数与统计量的区别:在总体上计算的任何测量都是参数,而在样本上称为统计量。
-
平均值:这是一个简单的算术平均值,通过将值的总和除以这些值的计数来计算。平均值对数据中的异常值敏感。异常值是与同一数据集或列中的许多其他值非常不同的值;它通常具有非常高或低的值。
-
中位数:这是数据的中点,可以通过将数据按升序或降序排列来计算。如果有N个观测值。
-
众数:这是数据中最重复的数据点:

使用numpy数组和stats包计算平均值、中位数和众数的 Python 代码如下所示:
>>> import numpy as np
>>> from scipy import stats
>>> data = np.array([4,5,1,2,7,2,6,9,3])
# Calculate Mean
>>> dt_mean = np.mean(data) ; print ("Mean :",round(dt_mean,2))
# Calculate Median
>>> dt_median = np.median(data) ; print ("Median :",dt_median)
# Calculate Mode
>>> dt_mode = stats.mode(data); print ("Mode :",dt_mode[0][0])
上述代码的输出如下所示:

我们使用了 NumPy 数组而不是基本列表作为数据结构;使用这种数据结构的原因是scikit-learn包构建在 NumPy 数组之上,其中所有的统计模型和机器学习算法都是基于 NumPy 数组构建的。numpy包中没有实现mode函数,因此我们使用了 SciPy 的stats包。SciPy 也是建立在 NumPy 数组之上的。
描述性统计的 R 代码(均值、中位数和众数)如下所示:
data <- c(4,5,1,2,7,2,6,9,3)
dt_mean = mean(data) ; print(round(dt_mean,2))
dt_median = median (data); print (dt_median)
func_mode <- function (input_dt) {
unq <- unique(input_dt) unq[which.max(tabulate(match(input_dt,unq)))]
}
dt_mode = func_mode (data); print (dt_mode)
我们使用了默认的stats包进行 R 编程;然而,mode函数并不是内置的,因此我们编写了自定义代码来计算众数。
-
变异度的测量:离散度是数据中的变异性,衡量了数据变量值的不一致性。离散度实际上提供了关于扩展而不是中心值的概念。
-
范围:这是数值的最大值和最小值之间的差异。
-
方差:这是数据点与均值的平方偏差的平均值(xi = 数据点,µ = 数据的均值,N = 数据点的数量)。方差的维度是实际值的平方。之所以在样本中使用分母 N-1 而不是总体中的 N,是因为自由度的原因。在计算方差时,样本中损失的 1 个自由度是由于从样本中提取的替代:

- 标准差:这是方差的平方根。通过对方差应用平方根,我们衡量的是相对于原始变量的离散程度,而不是维度的平方:

-
分位数:这些只是数据的相等片段。分位数包括百分位数、十分位数、四分位数等。这些测量是在将数据按升序排列后计算的:
-
百分位数:这仅仅是低于原始数据值的数据点所占的百分比。中位数是第 50 百分位数,因为低于中位数的数据点约占数据的 50%。
-
十分位数:这是第 10 百分位数,意味着低于十分位数的数据点占整个数据的 10%。
-
四分位数:这是数据的四分之一,也是第 25 百分位数。第一四分位数占数据的 25%,第二四分位数占 50%,第三四分位数占 75%。第二四分位数也被称为中位数或第 50 百分位数,或第五十分位数。
-
四分位距:这是第三四分位数与第一四分位数之间的差值。它在识别数据中的异常值时非常有效。四分位距描述了数据中间的 50%部分。
-

以下是 Python 代码:
>>> from statistics import variance, stdev
>>> game_points = np.array([35,56,43,59,63,79,35,41,64,43,93,60,77,24,82])
# Calculate Variance
>>> dt_var = variance(game_points) ; print ("Sample variance:", round(dt_var,2))
# Calculate Standard Deviation
>>> dt_std = stdev(game_points) ; print ("Sample std.dev:", round(dt_std,2))
# Calculate Range
>>> dt_rng = np.max(game_points,axis=0) - np.min(game_points,axis=0) ; print ("Range:",dt_rng)
#Calculate percentiles
>>> print ("Quantiles:")
>>> for val in [20,80,100]:
>>> dt_qntls = np.percentile(game_points,val)
>>> print (str(val)+"%" ,dt_qntls)
# Calculate IQR
>>> q75, q25 = np.percentile(game_points, [75 ,25]); print ("Inter quartile range:",q75-q25)
前面的代码输出如下:

用于离散度(方差、标准差、范围、分位数和四分位距)的 R 代码如下:
game_points <- c(35,56,43,59,63,79,35,41,64,43,93,60,77,24,82)
dt_var = var(game_points); print(round(dt_var,2))
dt_std = sd(game_points); print(round(dt_std,2))
range_val<-function(x) return(diff(range(x)))
dt_range = range_val(game_points); print(dt_range)
dt_quantile = quantile(game_points,probs = c(0.2,0.8,1.0)); print(dt_quantile)
dt_iqr = IQR(game_points); print(dt_iqr)
-
假设检验:这是通过对样本进行一些统计检验,从而对总体进行推断的过程。零假设和备择假设是验证假设是否具有统计显著性的方法。
-
P 值:假设零假设为真,获得至少与实际观察到的结果一样极端的检验统计量的概率(通常在建模中,每个自变量的 p 值小于 0.05 被认为是显著的,大于 0.05 则被认为是不显著的;然而,这些值和定义可能会根据上下文有所变化)。
假设检验的步骤如下:
-
-
假设原假设(通常是没有差异、没有显著性等;原假设总是试图假定没有异常模式,且总是均匀的,等等)。
-
收集样本。
-
计算样本的检验统计量,以验证假设是否在统计上显著。
-
根据检验统计量决定是否接受或拒绝原假设。
-
- 假设检验示例:一家巧克力制造商也是你的朋友,他声称他工厂生产的所有巧克力至少重 1,000 克,你感觉这可能不是真的;你们两个收集了 30 块巧克力的样本,发现平均重量为 990 克,样本标准差为 12.5 克。在 0.05 的显著性水平下,我们能否拒绝你朋友的这个声明?
原假设是µ0 ≥ 1000(所有巧克力重量超过 1,000 克)。
收集的样本:

计算检验统计量:

t = (990 - 1000) / (12.5/sqrt(30)) = - 4.3818
来自 t 表的临界 t 值 = t0.05, 30 = 1.699 => - t0.05, 30 = -1.699
P 值 = 7.03 e-05
检验统计量为-4.3818,小于临界值-1.699。因此,我们可以拒绝原假设(你朋友的声明),即巧克力的平均重量超过 1,000 克。
另外,决定该声明的另一种方式是使用 p 值。如果 p 值小于0.05,则表示声明的值和分布的均值显著不同,因此我们可以拒绝原假设:

Python 代码如下:
>>> from scipy import stats
>>> xbar = 990; mu0 = 1000; s = 12.5; n = 30
# Test Statistic
>>> t_smple = (xbar-mu0)/(s/np.sqrt(float(n))); print ("Test Statistic:",round(t_smple,2))
# Critical value from t-table
>>> alpha = 0.05
>>> t_alpha = stats.t.ppf(alpha,n-1); print ("Critical value from t-table:",round(t_alpha,3))
#Lower tail p-value from t-table
>>> p_val = stats.t.sf(np.abs(t_smple), n-1); print ("Lower tail p-value from t-table", p_val)

T 分布的 R 代码如下:
xbar = 990; mu0 = 1000; s = 12.5 ; n = 30
t_smple = (xbar - mu0)/(s/sqrt(n));print (round(t_smple,2))
alpha = 0.05
t_alpha = qt(alpha,df= n-1);print (round(t_alpha,3))
p_val = pt(t_smple,df = n-1);print (p_val)
-
第一类和第二类错误:假设检验通常是在样本上进行的,而不是整个总体,这是由于收集所有可用数据的实际资源限制。然而,从样本推断总体会带来自身的成本,例如拒绝好的结果或接受错误的结果,更不用说当样本量增加时,会减少第一类和第二类错误:
-
第一类错误:当原假设为真时拒绝原假设
-
第二类错误:当原假设为假时接受原假设
-
-
正态分布:这在统计学中非常重要,因为中心极限定理指出,从具有均值μ和方差σ2的总体中抽取所有可能样本(样本大小为n)的总体分布接近正态分布:

示例:假设某入学考试的考试分数符合正态分布。此外,考试的平均分数为52,标准差为16.3。在考试中得分67或更高的学生的百分比是多少?




以下是 Python 代码:
>>> from scipy import stats
>>> xbar = 67; mu0 = 52; s = 16.3
# Calculating z-score
>>> z = (67-52)/16.3
# Calculating probability under the curve
>>> p_val = 1- stats.norm.cdf(z)
>>> print ("Prob. to score more than 67 is ",round(p_val*100,2),"%")

正态分布的 R 代码如下:
xbar = 67; mu0 = 52; s = 16.3
pr = 1- pnorm(67, mean=52, sd=16.3)
print(paste("Prob. to score more than 67 is ",round(pr*100,2),"%"))
- 卡方检验:这是统计分析中最基本和最常见的假设检验之一,用于检验两个类别型随机变量X和Y之间是否存在统计依赖关系。
测试通常通过计算数据中的χ2值以及表格中带有(m-1, n-1)自由度的χ2值来进行。根据实际值和表格值的比较,决定两个变量是否独立,取其较大的值:

示例:在以下表格中,计算吸烟习惯是否对运动行为产生影响:

以下是 Python 代码:
>>> import pandas as pd
>>> from scipy import stats
>>> survey = pd.read_csv("survey.csv")
# Tabulating 2 variables with row & column variables respectively
>>> survey_tab = pd.crosstab(survey.Smoke, survey.Exer, margins = True)
在使用crosstab函数创建表格时,我们会得到行和列的总计字段。但为了创建观察表,我们需要提取变量部分并忽略总计:
# Creating observed table for analysis
>>> observed = survey_tab.ix[0:4,0:3]
stats 包中的chi2_contingency函数使用观察表并随后计算期望表,再计算 p 值以检查两个变量是否相关。如果p-value < 0.05,则两个变量之间存在强依赖关系;而如果p-value > 0.05,则两个变量之间没有依赖关系:
>>> contg = stats.chi2_contingency(observed= observed)
>>> p_value = round(contg[1],3)
>>> print ("P-value is: ",p_value)

p 值为0.483,意味着吸烟习惯与运动行为之间没有依赖关系。
卡方检验的 R 代码如下:
survey = read.csv("survey.csv",header=TRUE)
tbl = table(survey$Smoke,survey$Exer)
p_val = chisq.test(tbl)
- 方差分析(ANOVA):方差分析检验两个或多个总体均值是否相等的假设。ANOVA 通过比较不同因子水平下的响应变量均值来评估一个或多个因子的影响。原假设认为所有总体均值相等,而备择假设认为至少有一个均值不同。
示例:一家公司在研究后开发了三种新型的通用肥料,可以用于种植任何类型的作物。为了了解这三种肥料是否具有相似的作物产量,他们随机选择了六种作物类型进行研究。根据随机区组设计,每种作物类型将分别使用这三种肥料进行测试。以下表格展示了每平方米的产量。在 0.05 显著性水平下,检验三种新肥料的平均产量是否相等:
| 肥料 1 | 肥料 2 | 肥料 3 |
|---|---|---|
| 62 | 54 | 48 |
| 62 | 56 | 62 |
| 90 | 58 | 92 |
| 42 | 36 | 96 |
| 84 | 72 | 92 |
| 64 | 34 | 80 |
Python 代码如下:
>>> import pandas as pd
>>> from scipy import stats
>>> fetilizers = pd.read_csv("fetilizers.csv")
使用 stats 包计算单因素方差分析(ANOVA):
>>> one_way_anova = stats.f_oneway(fetilizers["fertilizer1"], fetilizers["fertilizer2"], fetilizers["fertilizer3"])
>>> print ("Statistic :", round(one_way_anova[0],2),", p-value :",round(one_way_anova[1],3))

结果:p 值确实小于 0.05,因此我们可以拒绝原假设,即肥料的平均作物产量是相等的。肥料对作物的影响显著。
ANOVA 的 R 代码如下:
fetilizers = read.csv("fetilizers.csv",header=TRUE)
r = c(t(as.matrix(fetilizers)))
f = c("fertilizer1","fertilizer2","fertilizer3")
k = 3; n = 6
tm = gl(k,1,n*k,factor(f))
blk = gl(n,k,k*n)
av = aov(r ~ tm + blk)
smry = summary(av)
- 混淆矩阵:这是实际值与预测值之间的矩阵。这个概念可以通过使用模型进行癌症预测的例子更好地解释:

混淆矩阵中使用的一些术语有:
-
-
真正阳性(TPs):真正阳性是指当我们预测病人为阳性时,病人实际上是阳性的情况。
-
真正阴性(TNs):当我们预测病人为阴性时,而病人实际上是阴性的情况。
-
假阳性(FPs):当我们预测病人为阳性时,而实际上病人并没有得病。FPs 也被认为是 I 型错误。
-
假阴性(FNs):当我们预测病人为阴性时,而实际上病人是阳性的。FNs 也被认为是 II 型错误。
-
精确度(P):当预测为阳性时,预测正确的频率有多高?
-
(TP/TP+FP)
-
- 召回率(R)/灵敏度/真正阳性率:在实际的阳性病例中,预测为阳性的比例是多少?
(TP/TP+FN)
-
- F1 分数(F1):这是精确度和召回率的调和平均数。乘以常数 2 会在精确度和召回率都为 1 时将分数扩展到 1:

-
- 特异性:在实际的阴性病例中,预测为阴性的比例是多少?也等同于 1- 假阳性率:
(TN/TN+FP)
-
- 曲线下面积(ROC):接收者操作特征曲线用于绘制 真正阳性率(TPR)与 假阳性率(FPR)之间的关系图,也称为灵敏度与 1- 特异性 图:

曲线下面积(AUC)用于设置分类概率的截止阈值,将预测概率分类到不同的类别。
- 观察和性能窗口:在统计建模中,模型试图提前预测事件,而不是在发生时预测,以便留出一定的缓冲时间进行纠正措施。例如,信用卡公司可能会问,某位客户在未来 12 个月内违约的概率是多少?这样我可以联系他,提供折扣或根据情况制定催收策略。
为了回答这个问题,需要通过使用过去 24 个月的自变量和接下来 12 个月的因变量,开发一个违约概率模型(或在技术术语中称为行为评分卡)。在准备好包含X和Y变量的数据后,它将按 70% - 30%的比例随机划分为训练数据和测试数据;这种方法被称为时效性验证,因为训练和测试样本来自同一时间段:

-
时效性验证与非时效性验证:时效性验证意味着从同一时间段获取训练和测试数据集,而非时效性验证则意味着训练和测试数据集来自不同的时间段。通常,模型在非时效性验证中的表现会比时效性验证差,原因显而易见,即训练数据集和测试数据集的特征可能不同。
-
R 平方(决定系数):这是一个衡量模型解释响应变量变异度的百分比的指标。它也是衡量模型与仅使用均值作为估计值相比,如何最小化误差的指标。在某些极端情况下,R 平方值可能小于零,这意味着模型的预测值表现得比仅用简单均值作为所有观测值的预测还要差。


- 调整后的 R 平方:调整后的 R 平方统计量的解释几乎与 R 平方相同,但如果模型中包含了与其他变量相关性较弱的额外变量,它会对 R 平方值进行惩罚:

这里,R2 = 样本 R 平方值,n = 样本大小,k = 预测变量(或)变量数量。
调整后的 R 平方值是评估线性回归质量的关键指标。任何线性回归模型,只要其R2 调整 >= 0.7,就可以被认为是足够好的模型。
示例:某样本的 R 平方值为0.5,样本大小为50,自变量的数量为10。计算得出的调整 R 平方值:

-
最大似然估计(MLE):这是通过找到能够最大化观察结果似然的参数值来估计统计模型(精确地说是逻辑回归)参数值的方法。
-
赤池信息量准则(AIC):这是在逻辑回归中使用的,类似于线性回归中调整 R 平方的原理。它衡量给定数据集上模型的相对质量:

这里,k = 预测变量或变量数量
AIC 的思想是在模型中包含额外的没有强预测能力的变量时,惩罚目标函数。这是一种逻辑回归中的正则化方法。
- 熵:熵来源于信息论,是衡量数据杂乱度的指标。如果样本完全同质,熵为零;如果样本均匀划分,熵为1。在决策树中,具有最大异质性的预测变量将被认为最接近根节点,用于以贪心模式将数据分类成不同类别。我们将在第二章,基于树的机器学习模型中更深入地探讨这个话题:

这里,n = 类别数量。熵在中间最大,值为1,在极端最小,值为0。熵的低值是理想的,因为它能够更好地划分类别:

示例:给定两种硬币,第一种是公平的硬币(1/2为正面,1/2为反面概率),另一种是偏向的硬币(1/3为正面,2/3为反面概率),计算它们的熵,并解释哪一种硬币在建模时更好:


从这两个值中,决策树算法选择偏向的硬币而不是公平的硬币作为观察划分器,因为熵的值较小。
- 信息增益:这是通过根据给定属性划分示例所引起的熵的预期减少。这个思想是从混合类别开始,直到每个节点达到最纯的类别为止。在每个阶段,都会选择信息增益最大的变量,采用贪心的方式:
信息增益 = 父节点的熵 - 总和(加权 % * 子节点的熵)
加权 % = 特定子节点中的观察数 / 总和(所有子节点中的观察数)
- 基尼系数:基尼不纯度是一个用于多分类器中的误分类度量。基尼与熵的作用几乎相同,不同之处在于基尼计算更快。

这里,i = 类别数量。Gini 系数和熵的相似性如下所示:

偏差与方差的权衡
每个模型除了白噪声外,都有偏差和方差误差组件。偏差和方差是相互反向关系的;在试图减少一个组件时,另一个组件的值会增加。真正的艺术在于通过平衡两者来创造一个良好的拟合。理想的模型会同时具有低偏差和低方差。
来自偏差组件的错误源于底层学习算法中的错误假设。高偏差可能导致算法错过特征与目标输出之间的相关关系;这一现象会导致欠拟合问题。
另一方面,方差组件的错误来自于模型拟合的变化敏感性,即使训练数据有微小变化;高方差可能导致过拟合问题:

高偏差模型的一个例子是逻辑回归或线性回归,在这种模型中,拟合的结果只是一个直线,可能由于线性模型无法很好地逼近底层数据而导致较大的误差组件。
高方差模型的一个例子是决策树,在这种模型中,拟合的结果可能是一个曲折的曲线,即使训练数据有微小变化,也会导致曲线拟合的剧烈变化。
目前,最先进的模型正在使用高方差模型,如决策树,并在其上执行集成,以减少由高方差引起的错误,同时又不牺牲由于偏差组件引起的错误增加。这一类别的最佳例子是随机森林,其中许多决策树将独立生长并进行集成,以得出最佳拟合;我们将在后续章节中讨论这个话题:

训练数据和测试数据
在实际操作中,数据通常会随机拆分为 70-30 或 80-20 的训练数据和测试数据集,统计建模中,训练数据用于构建模型,其有效性将通过测试数据来验证:

在下面的代码中,我们将原始数据按 70% - 30%的比例拆分为训练数据和测试数据。需要注意的一点是,我们为随机数设置了种子值,以便每次生成训练和测试数据时能够重复相同的随机抽样。可重复性对于重现结果非常重要:
# Train & Test split
>>> import pandas as pd
>>> from sklearn.model_selection import train_test_split
>>> original_data = pd.read_csv("mtcars.csv")
在下面的代码中,train size为0.7,这意味着 70%的数据应该拆分到训练数据集中,剩余的 30%应该在测试数据集中。随机状态是生成伪随机数过程中的种子值,这使得每次运行时都能分割相同的观测结果,从而确保结果是可重现的:
>>> train_data,test_data = train_test_split(original_data,train_size = 0.7,random_state=42)
统计建模中的训练和测试拆分的 R 代码如下:
full_data = read.csv("mtcars.csv",header=TRUE)
set.seed(123)
numrow = nrow(full_data)
trnind = sample(1:numrow,size = as.integer(0.7*numrow))
train_data = full_data[trnind,]
test_data = full_data[-trnind,]
总结
在本章中,我们对统计建模和机器学习中涉及的各种基本构建模块和子组件有了一个高层次的了解,例如均值、方差、四分位数范围、p 值、偏差与方差的权衡、AIC、基尼系数、曲线下面积等,均与统计学背景相关。
在下一章,我们将介绍完整的基于树的模型,如决策树、随机森林、提升树、模型集成等,以提高准确性!
第二章:基于树的机器学习模型
基于树的方法的目标是将特征空间划分为多个简单的矩形区域,从而根据所属区域的训练样本的均值或众数(回归问题使用均值,分类问题使用众数)对给定的观测值进行预测。与大多数其他分类器不同,决策树生成的模型易于解释。在本章中,我们将通过人力资源数据的示例,介绍以下基于决策树的模型,用于预测某个员工是否会在近期离开公司。本章将学习以下内容:
-
决策树 - 简单模型及带类别权重调优的模型
-
自助聚合(Bagging)
-
随机森林 - 基础随机森林及超参数调优的网格搜索应用
-
提升方法(AdaBoost、梯度提升、极端梯度提升 - XGBoost)
-
集成的集成(包括异质和同质模型)
介绍决策树分类器
决策树分类器生成的规则是简单的英语句子,易于解释,并可直接呈现给高级管理层,无需编辑。决策树可以应用于分类或回归问题。根据数据中的特征,决策树模型学习一系列问题,以推断样本的类别标签。
在下图中,程序员自己设计了简单的递归决策规则,以根据每个问题的回答(是或否)执行相应的操作。

决策树中使用的术语
与逻辑回归相比,决策树的复杂度较低。这里有一些指标需要研究。我们将主要关注杂乱度度量;决策树基于设定的杂乱度标准递归地划分变量,直到达到某些停止标准(每个终端节点的最小观测数、任意节点的最小划分观测数等)。
- 熵: 熵来源于信息论,是数据杂乱度的度量。如果样本完全同质,则熵为零;如果样本均匀分布,则熵为一。在决策树中,具有最大异质性的预测变量会被视为最接近根节点的特征,用贪婪方式将给定数据分类。我们将在本章中更深入地讨论这一主题:

其中 n = 类别数。熵在中间最大,值为1,在极端情况下最小,值为0。低熵值是理想的,因为它能够更好地区分类别。
- 信息增益: 信息增益是根据给定属性对示例进行分区所导致的熵减少的期望值。这个想法是从混合类开始,并继续分区,直到每个节点达到其最纯净类别的观察值。在每个阶段,以贪婪的方式选择具有最大信息增益的变量。
信息增益 = 父节点熵 - 总和(加权% * 子节点熵)
加权% = 特定子节点中的观察次数/所有子节点中的观察次数之和
- 基尼: 基尼不纯度是一种多类分类器上的误分类度量。基尼的工作方式类似于熵,只是基尼的计算速度更快:

其中 i = 类别数。Gini 和熵之间的相似性如下图所示:

决策树的工作方法从第一原则开始
在下面的例子中,响应变量只有两类:是否打网球。但是下表是基于记录在不同天气条件下的各种情况编制的。现在,我们的任务是找出哪些输出变量最显著地导致了:是或否。
- 该示例属于分类树:
| 日期 | 外观 | 温度 | 湿度 | 风 | 打网球 |
|---|---|---|---|---|---|
| D1 | 晴天 | 炎热 | 高 | 弱 | 否 |
| D2 | 晴天 | 炎热 | 高 | 强 | 否 |
| D3 | 阴天 | 炎热 | 高 | 弱 | 是 |
| D4 | 下雨 | 温和 | 高 | 弱 | 是 |
| D5 | 下雨 | 凉爽 | 正常 | 弱 | 是 |
| D6 | 下雨 | 凉爽 | 正常 | 强 | 否 |
| D7 | 阴天 | 凉爽 | 正常 | 强 | 是 |
| D8 | 晴天 | 温和 | 高 | 弱 | 否 |
| D9 | 晴天 | 凉爽 | 正常 | 弱 | 是 |
| D10 | 下雨 | 温和 | 正常 | 弱 | 是 |
| D11 | 晴天 | 温和 | 正常 | 强 | 是 |
| D12 | 阴天 | 温和 | 高 | 强 | 是 |
| D13 | 阴天 | 炎热 | 正常 | 弱 | 是 |
| D14 | 下雨 | 温和 | 高 | 强 | 否 |
-
以湿度变量为例来对 Play Tennis 字段进行分类:
- CHAID: 湿度有两个类别,我们的期望值应该均匀分布,以便计算变量的区分度:

计算x²(卡方)值:

计算自由度 = (r-1) * (c-1)
其中 r = 行组件数/变量类别数,C = 响应变量数。
这里有两个行类别(高和正常)和两个列类别(否和是)。
因此 = (2-1) * (2-1) = 1
卡方检验的 p 值为 2.8,自由度为 1 = 0.0942
可以使用以下 Excel 公式获得 p 值:= CHIDIST (2.8, 1) = 0.0942
类似地,我们将计算所有变量的p 值,并选择具有较低 p 值的最佳变量。
- 熵:
熵 = - Σ p * log [2] p






以类似的方式,我们将计算所有变量的信息增益,并选择具有最高信息增益的最佳变量。
- GINI:
基尼 = 1- Σp²




以类似的方式,我们将计算所有变量的期望基尼值,并选择具有最低期望值的最佳变量。
为了更好地理解,我们也将对风速变量进行类似的计算:
- CHAID: 风速有两个类别,我们的期望值应该均匀分布,以便计算该变量的区分度:



- ENTROPY:




- GINI:



现在我们将比较这两个变量在所有三个指标中的表现,以便更好地理解它们。
| 变量 | CHAID(p 值) | 熵信息增益 | 基尼期望值 |
|---|---|---|---|
| 湿度 | 0.0942 | 0.1518 | 0.3669 |
| 风速 | 0.2733 | 0.0482 | 0.4285 |
| 更好 | 低价值 | 高价值 | 低价值 |
对于所有三个计算,湿度被证明比风速更适合作为分类器。因此,我们可以确认所有方法传达了类似的信息。
逻辑回归与决策树的比较
在深入了解决策树的编码细节之前,我们将快速比较逻辑回归和决策树之间的差异,以便了解哪个模型更好,以及为什么更好。
| 逻辑回归 | 决策树 |
|---|---|
| 逻辑回归模型看起来像是独立变量与其因变量之间的方程式。 | 决策树生成的是简单的英语句子的规则,可以轻松向高层管理人员解释。 |
| 逻辑回归是一种参数模型,在这种模型中,通过将参数乘以独立变量来预测因变量。 | 决策树是一种非参数模型,不存在预先假定的参数。隐式地执行变量筛选或特征选择。 |
| 对响应(或因变量)做出假设,采用二项分布或伯努利分布。 | 不对数据的潜在分布做任何假设。 |
| 模型的形状是预定义的(逻辑曲线)。 | 模型的形状不是预定义的;模型根据数据进行最佳分类。 |
| 当自变量是连续的且满足线性关系时,提供非常好的结果。 | 当大多数变量是类别型时,提供最佳结果。 |
| 很难找到变量之间的复杂交互(变量之间的非线性关系)。 | 参数之间的非线性关系不会影响树的性能。决策树常常能够揭示复杂的交互关系。决策树能够处理具有高度偏态或多峰分布的数值数据,以及具有序数或非序数结构的类别预测变量。 |
| 异常值和缺失值会降低逻辑回归的性能。 | 决策树优雅地处理异常值和缺失值。 |
比较不同模型样式下的误差组成
需要评估误差,以衡量模型的有效性,并通过调整不同的参数进一步提高模型的性能。误差组成包括偏差成分、方差成分和纯白噪声:

以下三个区域中:
-
第一个区域具有高偏差和低方差的误差组成。在这个区域,模型非常健壮,例如线性回归或逻辑回归。
-
而第三个区域具有高方差和低偏差的误差组成,在该区域,模型表现出高度的不稳定性和变化,类似于决策树,但由于形状变化的巨大变异性,这些模型往往会在训练数据上过拟合,并在测试数据上表现较差。
-
最后但同样重要的是,中间区域,也叫做第二区域,是理想的最佳区域,在这个区域,偏差和方差成分适中,导致它产生最低的总误差。

采取补救措施,将模型推向理想区域
具有高偏差或高方差误差成分的模型无法产生理想的拟合。因此,必须进行一些改进。在以下图示中,详细展示了所采用的各种方法。以线性回归为例,其会有一个高偏差成分,意味着模型没有足够的灵活性去拟合数据中的一些非线性关系。解决方法之一是将单一的线性模型分解为多个小线性段,并通过在节点处进行约束来拟合这些线段,这也叫做线性样条。而决策树则面临高方差问题,意味着即使是X值的微小变化也会导致Y值的巨大变化,这个问题可以通过决策树的集成方法来解决:

实际上,实施样条函数是一种复杂且不太流行的方法,因为它涉及到许多方程,实践者需要时刻关注这些方程,还需要检查线性假设和其他诊断 KPIs(如 p 值、AIC、多重共线性等)。相反,决策树的集成方法在数据科学界最为流行,类似于装袋法(bagging)、随机森林(random forest)和提升法(boosting),这些内容将在本章的后续部分深入讲解。集成技术通过聚合来自高度变量的单一分类器(如决策树)的结果来解决方差问题。
HR 流失数据示例
在本节中,我们将使用 IBM Watson 的 HR 流失数据(在获得数据管理员的事先许可后,本书中使用了该数据),该数据已在 Kaggle 数据集中以开源许可证共享,链接为www.kaggle.com/pavansubhasht/ibm-hr-analytics-attrition-dataset,以预测员工是否会流失,依据的自变量是解释性变量:
>>> import pandas as pd
>>> hrattr_data = pd.read_csv("WA_Fn-UseC_-HR-Employee-Attrition.csv")
>>> print (hrattr_data.head())
该数据包含大约 1470 个观测值和 35 个变量,下面显示了前五行数据,以便快速浏览这些变量:



以下代码用于将“是”或“否”类别转换为 1 和 0,以便进行建模,因为 scikit-learn 不能直接在字符/类别变量上拟合模型,因此需要执行虚拟编码以便在模型中使用这些变量:
>>> hrattr_data['Attrition_ind'] = 0
>>> hrattr_data.loc[hrattr_data['Attrition'] =='Yes', 'Attrition_ind'] = 1
为所有七个类别变量(按字母顺序排列)创建了虚拟变量,这些类别变量包括Business Travel(商务旅行)、Department(部门)、Education Field(教育领域)、Gender(性别)、Job Role(职位)、Marital Status(婚姻状况)和Overtime(加班)。我们忽略了分析中的四个变量,因为它们在所有观测值中没有变化,分别是Employee count(员工数量)、Employee number(员工编号)、Over18(超过 18 岁)和Standard Hours(标准工作时间):
>>> dummy_busnstrvl = pd.get_dummies(hrattr_data['BusinessTravel'], prefix='busns_trvl')
>>> dummy_dept = pd.get_dummies(hrattr_data['Department'], prefix='dept')
>>> dummy_edufield = pd.get_dummies(hrattr_data['EducationField'], prefix='edufield')
>>> dummy_gender = pd.get_dummies(hrattr_data['Gender'], prefix='gend')
>>> dummy_jobrole = pd.get_dummies(hrattr_data['JobRole'], prefix='jobrole')
>>> dummy_maritstat = pd.get_dummies(hrattr_data['MaritalStatus'], prefix='maritalstat')
>>> dummy_overtime = pd.get_dummies(hrattr_data['OverTime'], prefix='overtime')
连续变量被分离,并将在稍后与创建的虚拟变量合并:
>>> continuous_columns = ['Age','DailyRate','DistanceFromHome', 'Education', 'EnvironmentSatisfaction','HourlyRate','JobInvolvement','JobLevel','JobSatisfaction', 'MonthlyIncome', 'MonthlyRate', 'NumCompaniesWorked','PercentSalaryHike', 'PerformanceRating', 'RelationshipSatisfaction','StockOptionLevel', 'TotalWorkingYears', 'TrainingTimesLastYear','WorkLifeBalance', 'YearsAtCompany', 'YearsInCurrentRole', 'YearsSinceLastPromotion','YearsWithCurrManager']
>>> hrattr_continuous = hrattr_data[continuous_columns]
在以下步骤中,将从类别变量中衍生出的虚拟变量与直接的连续变量进行合并:
>>> hrattr_data_new = pd.concat([dummy_busnstrvl, dummy_dept, dummy_edufield, dummy_gender, dummy_jobrole, dummy_maritstat, dummy_overtime, hrattr_continuous, hrattr_data['Attrition_ind']],axis=1)
这里,我们没有去除每个类别变量中多余的衍生虚拟变量,因为多重共线性在决策树中并不会像在逻辑回归或线性回归中那样产生问题,因此我们可以简单地利用本章其余部分中的所有衍生变量,因为所有模型都使用决策树作为基础模型,即使是在对其进行集成后也是如此。
一旦基本数据准备好后,需要按 70-30 的比例进行训练和测试数据的划分:
# Train and Test split
>>> from sklearn.model_selection import train_test_split
>>> x_train,x_test,y_train,y_test = train_test_split( hrattr_data_new.drop (['Attrition_ind'], axis=1),hrattr_data_new['Attrition_ind'], train_size = 0.7, random_state=42)
HR 流失数据的 R 语言数据预处理代码:
hrattr_data = read.csv("WA_Fn-UseC_-HR-Employee-Attrition.csv")
str(hrattr_data);summary(hrattr_data)
hrattr_data$Attrition_ind = 0;
hrattr_data$Attrition_ind[ hrattr_data$Attrition=="Yes"]=1
hrattr_data$Attrition_ind= as.factor(hrattr_data$Attrition_ind)
remove_cols = c("EmployeeCount","EmployeeNumber","Over18", "StandardHours","Attrition")
hrattr_data_new = hrattr_data[,!(names(hrattr_data) %in% remove_cols)]
set.seed(123)
numrow = nrow(hrattr_data_new)
trnind = sample(1:numrow,size = as.integer(0.7*numrow))
train_data = hrattr_data_new[trnind,]
test_data = hrattr_data_new[-trnind,]
# Code for calculating precision, recall for 0 and 1 categories and # at overall level which will be used in all the classifiers in # later sections
frac_trzero = (table(train_data$Attrition_ind)[[1]])/nrow(train_data)
frac_trone = (table(train_data$Attrition_ind)[[2]])/nrow(train_data)
frac_tszero = (table(test_data$Attrition_ind)[[1]])/nrow(test_data)
frac_tsone = (table(test_data$Attrition_ind)[[2]])/nrow(test_data)
prec_zero <- function(act,pred){ tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[2,1]),4))}
prec_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[1,2]),4))}
recl_zero <- function(act,pred){tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[1,2]),4))}
recl_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[2,1]),4))}
accrcy <- function(act,pred){ tble = table(act,pred)
return( round((tble[1,1]+tble[2,2])/sum(tble),4))}
决策树分类器
用于建模的DecisionTreeClassifier来自 scikit-learn,位于tree子模块中:
# Decision Tree Classifier
>>> from sklearn.tree import DecisionTreeClassifier
以下代码中为决策树分类器选择的参数包括:分割标准为 Gini,最大深度为 5,进行分割所需的最小观测值为 2,终端节点应包含的最小样本数为 1:
>>> dt_fit = DecisionTreeClassifier(criterion="gini", max_depth=5,min_samples_split=2, min_samples_leaf=1,random_state=42)
>>> dt_fit.fit(x_train,y_train)
>>> print ("\nDecision Tree - Train Confusion Matrix\n\n", pd.crosstab(y_train, dt_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> from sklearn.metrics import accuracy_score, classification_report
>>> print ("\nDecision Tree - Train accuracy\n\n",round(accuracy_score (y_train, dt_fit.predict(x_train)),3))
>>> print ("\nDecision Tree - Train Classification Report\n", classification_report(y_train, dt_fit.predict(x_train)))
>>> print ("\n\nDecision Tree - Test Confusion Matrix\n\n",pd.crosstab(y_test, dt_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nDecision Tree - Test accuracy",round(accuracy_score(y_test, dt_fit.predict(x_test)),3))
>>> print ("\nDecision Tree - Test Classification Report\n", classification_report( y_test, dt_fit.predict(x_test)))

通过仔细观察结果,我们可以推断,尽管测试准确率较高(84.6%),但“流失 = 是”这一类别的精度和召回率较低(精度 = 0.39 和 召回率 = 0.20)。当管理层尝试利用该模型在实际流失发生之前,为高流失概率的员工提供额外福利时,这可能会是一个严重问题,因为该模型无法准确识别真正会离开的员工。因此,我们需要寻找其他调整方法;一种方法是通过使用类权重来控制模型。通过使用类权重,我们可以提高特定类别的重要性,代价是增加其他错误的发生。
例如,通过将类权重增加到类别1,我们可以识别出更多具有实际流失特征的员工,但这样做会将一些非潜在流失员工标记为潜在流失者(这应当是可以接受的)。
类权重的重要应用的另一个经典例子是银行场景。当发放贷款时,拒绝一些好的申请总比接受坏的贷款要好。因此,即使在这种情况下,将违约者的权重大于非违约者也是一个更好的选择:
在人力资源流失数据上应用决策树分类器的 R 代码:
# Decision Trees using C5.0 package
library(C50)
dtree_fit = C5.0(train_data[-31],train_data$Attrition_ind,costs = NULL,control = C5.0Control(minCases = 1))
summary(dtree_fit)
tr_y_pred = predict(dtree_fit, train_data,type = "class")
ts_y_pred = predict(dtree_fit,test_data,type = "class")
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred);
trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred);
trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Decision Tree Train accuracy:",tr_acc))
print(paste("Decision Tree - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",
round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Decision Tree Test accuracy:",ts_acc))
print(paste("Decision Tree - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),
"Overall_Recall",round(tsrecl_ovll,4)))
调整决策树分类器中的类权重
在下面的代码中,调整了类权重,以观察决策树在相同参数下性能的变化。创建了一个虚拟数据框,以保存各种精度-召回细节组合的结果:
>>> dummyarray = np.empty((6,10))
>>> dt_wttune = pd.DataFrame(dummyarray)
需要考虑的指标包括零类别和一类别的权重(例如,如果零类别的权重为 0.2,则一类别的权重应自动为 0.8,因为总权重应等于 1)、训练和测试准确率、零类别和一类别的精度,以及整体精度。同样,也计算零类别和一类别的召回率,以及整体召回率:
>>> dt_wttune.columns = ["zero_wght","one_wght","tr_accuracy", "tst_accuracy", "prec_zero","prec_one", "prec_ovll", "recl_zero","recl_one","recl_ovll"]
零类别的权重范围从 0.01 到 0.5 进行验证,因为我们知道我们不想探索将零类别的权重设置得比一类别更高的情况:
>>> zero_clwghts = [0.01,0.1,0.2,0.3,0.4,0.5]
>>> for i in range(len(zero_clwghts)):
... clwght = {0:zero_clwghts[i],1:1.0-zero_clwghts[i]}
... dt_fit = DecisionTreeClassifier(criterion="gini", max_depth=5, ... min_samples_split=2, min_samples_leaf=1,random_state=42,class_weight = clwght)
... dt_fit.fit(x_train,y_train)
... dt_wttune.loc[i, 'zero_wght'] = clwght[0]
... dt_wttune.loc[i, 'one_wght'] = clwght[1]
... dt_wttune.loc[i, 'tr_accuracy'] = round(accuracy_score(y_train, dt_fit.predict( x_train)),3)
... dt_wttune.loc[i, 'tst_accuracy'] = round(accuracy_score(y_test,dt_fit.predict( x_test)),3)
... clf_sp = classification_report(y_test,dt_fit.predict(x_test)).split()
... dt_wttune.loc[i, 'prec_zero'] = float(clf_sp[5])
... dt_wttune.loc[i, 'prec_one'] = float(clf_sp[10])
... dt_wttune.loc[i, 'prec_ovll'] = float(clf_sp[17])
... dt_wttune.loc[i, 'recl_zero'] = float(clf_sp[6])
... dt_wttune.loc[i, 'recl_one'] = float(clf_sp[11])
... dt_wttune.loc[i, 'recl_ovll'] = float(clf_sp[18])
... print ("\nClass Weights",clwght,"Train accuracy:",round(accuracy_score( y_train,dt_fit.predict(x_train)),3),"Test accuracy:",round(accuracy_score(y_test, dt_fit.predict(x_test)),3))
... print ("Test Confusion Matrix\n\n",pd.crosstab(y_test,dt_fit.predict( x_test),rownames = ["Actuall"],colnames = ["Predicted"]))

从上面的截图中,我们可以看到,在类权重值为 0.3(对于零类)和 0.7(对于一类)时,它能识别出更多的流失者(61 个中有 25 个),且不影响测试准确率(83.9%),使用的是决策树方法:
在人力资源流失数据上应用类权重的决策树分类器 R 代码:
#Decision Trees using C5.0 package - Error Costs
library(C50)
class_zero_wgt = c(0.01,0.1,0.2,0.3,0.4,0.5)
for (cwt in class_zero_wgt){
cwtz = cwt
cwto = 1-cwtz
cstvr = cwto/cwtz
error_cost <- matrix(c(0, 1, cstvr, 0), nrow = 2)
dtree_fit = C5.0(train_data[-31],train_data$Attrition_ind,
costs = error_cost,control = C5.0Control( minCases = 1))
summary(dtree_fit)
tr_y_pred = predict(dtree_fit, train_data,type = "class")
ts_y_pred = predict(dtree_fit,test_data,type = "class")
tr_y_act = train_data$Attrition_ind;
ts_y_act = test_data$Attrition_ind
tr_acc = accrcy(tr_y_act,tr_y_pred)
ts_acc = accrcy(ts_y_act,ts_y_pred)
print(paste("Class weights","{0:",cwtz,"1:",cwto,"}",
"Decision Tree Train accuracy:",tr_acc,
"Decision Tree Test accuracy:",ts_acc))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
}
Bagging 分类器
正如我们之前讨论过的,决策树存在较高的方差,这意味着如果我们将训练数据随机分成两部分,并为每个样本拟合两棵决策树,那么得到的规则将会非常不同。而低方差和高偏差的模型,例如线性回归或逻辑回归,在两个样本上会产生相似的结果。Bagging 是自助聚合(带替换的重复采样并执行结果聚合,准确来说),它是一种通用的方法,旨在减少模型的方差。在这个例子中,所使用的模型是决策树。
聚合能够减少方差,例如,当我们有 n 个独立观测值 x[1], x[2], ..., x[n] 每个观测值的方差为 σ² 时,所有观测值均值 x̅ 的方差为 σ²/n,这表明通过平均一组观测值可以减少方差。在这里,我们通过从训练数据中抽取多个样本(也称为自助采样),为每个样本分别构建决策树,对回归问题取平均,对分类问题计算众数,从而得到一个低方差的单一模型,既有低偏差又有低方差:


在 Bagging 过程中,行是被抽样的,而所有列/变量都被选择(而在随机森林中,行和列都会被抽样,我们将在下一节中讲解)。在下图中,两种颜色(粉色和蓝色)代表两种样本,对于每个样本,一些行被抽取,但每次都选择所有的列(变量)。由于选择了所有列,存在一个问题,即大多数树会描述相同的故事,其中最重要的变量会最初出现在分裂中,并在所有树中重复出现,这样就无法产生去相关的树,因此在应用方差减少时可能无法得到更好的性能。在随机森林中(我们将在下一节讲解),我们将同时抽取行和列,避免了这个问题:

在以下代码中,我们使用相同的 HR 数据来拟合 Bagging 分类器,以便在决策树的比较中做到公正:
# Bagging Classifier
>>> from sklearn.tree import DecisionTreeClassifier
>>> from sklearn.ensemble import BaggingClassifier
这里使用的基础分类器是决策树,参数设置与我们在决策树示例中使用的一致:
>>> dt_fit = DecisionTreeClassifier(criterion="gini", max_depth=5,min_samples_split=2, min_samples_leaf=1,random_state=42,class_weight = {0:0.3,1:0.7})
Bagging 中使用的参数为,n_estimators表示使用的单个决策树数量为 5,000,最大样本和特征分别选择 0.67 和 1.0,这意味着每棵树将选择 2/3 的观测值和所有特征。有关更多详细信息,请参考 scikit-learn 手册:scikit-learn.org/stable/modules/generated/sklearn.ensemble.BaggingClassifier.html:
>>> bag_fit = BaggingClassifier(base_estimator= dt_fit,n_estimators=5000, max_samples=0.67,
... max_features=1.0,bootstrap=True,
... bootstrap_features=False, n_jobs=-1,random_state=42)
>>> bag_fit.fit(x_train, y_train)
>>> print ("\nBagging - Train Confusion Matrix\n\n",pd.crosstab(y_train, bag_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nBagging- Train accuracy",round(accuracy_score(y_train, bag_fit.predict(x_train)),3))
>>> print ("\nBagging - Train Classification Report\n",classification_report(y_train, bag_fit.predict(x_train)))
>>> print ("\n\nBagging - Test Confusion Matrix\n\n",pd.crosstab(y_test, bag_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nBagging - Test accuracy",round(accuracy_score(y_test, bag_fit.predict(x_test)),3))
>>> print ("\nBagging - Test Classification Report\n",classification_report(y_test, bag_fit.predict(x_test)))

在分析了 Bagging 的结果后,测试准确率为 87.3%,而决策树的准确率为 84.6%。比较实际流失员工的识别数量,Bagging 方法识别了 13 名,而决策树识别了 12 名,但将 0 分类为 1 的数量显著减少,Bagging 为 8,而决策树为 19。总体而言,Bagging 方法提高了单棵树的性能:
应用于 HR 流失数据的 Bagging 分类器的 R 代码:
# Bagging Classifier - using Random forest package but all variables selected
library(randomForest)
set.seed(43)
rf_fit = randomForest(Attrition_ind~.,data = train_data,mtry=30,maxnodes= 64,classwt = c(0.3,0.7), ntree=5000,nodesize = 1)
tr_y_pred = predict(rf_fit,data = train_data,type = "response")
ts_y_pred = predict(rf_fit,newdata = test_data,type = "response")
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred);
trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Random Forest Train accuracy:",tr_acc))
print(paste("Random Forest - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",
round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred);
tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Random Forest Test accuracy:",ts_acc))
print(paste("Random Forest - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",
round(tsrecl_ovll,4)))
随机森林分类器
随机森林通过一个小的调整,利用去相关的树,提供了对 Bagging 的改进。在 Bagging 中,我们基于训练数据的自助抽样构建了多个决策树,但 Bagging 方法的一个大缺点是它选择了所有变量。通过这样做,在每棵决策树中,选择用于划分的候选变量的顺序对于所有个体树来说基本相同,因此它们看起来相互之间相关。在聚合它们时,相关个体的方差减少效果不明显。
在随机森林中,进行自助抽样(带替换的重复采样)时,从训练数据中抽取样本;不仅仅是像 Bagging 那样随机选择第二和第三个观测值,它还从所有预测变量中选择少量预测变量/列(从总 p 个预测变量中选择 m 个预测变量)。
从总变量中选择m个变量的拇指法则是,分类问题中m = sqrt(p),回归问题中m = p/3,目的是随机选择以避免个体树之间的相关性。通过这样做,可以显著提高准确性。这种 RF 的能力使其成为数据科学社区最喜欢的算法之一,作为跨各种竞赛的获胜秘诀,甚至用于解决各行业的实际问题。
在下面的图示中,不同的颜色代表不同的自助样本。在第一个样本中,选择了第 1、第 3、第 4 和第 7 列,而在第二个自助样本中,选择了第 2、第 3、第 4 和第 5 列。通过这种方式,可以随机选择任何列,无论它们是否相邻。虽然给出了sqrt (p)或p/3的拇指法则,但建议读者调整要选择的预测变量数量:

样本图显示了在改变所选参数时,测试误差变化的影响,可以明显看出,m = sqrt(p)的情形在测试数据上的表现优于m = p(我们可以称之为袋装法):

这里为了说明,使用了来自scikit-learn包的随机森林分类器:
# Random Forest Classifier
>>> from sklearn.ensemble import RandomForestClassifier
随机森林中使用的参数有:n_estimators表示使用的单个决策树数量为 5000,选择的最大特征为auto,这意味着它将自动选择sqrt(p)用于分类,p/3用于回归。但这里是一个直接的分类问题。每个叶子的最小样本数提供了终端节点中所需的最小观察值数:
>>> rf_fit = RandomForestClassifier(n_estimators=5000,criterion="gini", max_depth=5, min_samples_split=2,bootstrap=True,max_features='auto',random_state=42, min_samples_leaf=1,class_weight = {0:0.3,1:0.7})
>>> rf_fit.fit(x_train,y_train)
>>> print ("\nRandom Forest - Train Confusion Matrix\n\n",pd.crosstab(y_train, rf_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nRandom Forest - Train accuracy",round(accuracy_score(y_train, rf_fit.predict(x_train)),3))
>>> print ("\nRandom Forest - Train Classification Report\n",classification_report( y_train, rf_fit.predict(x_train)))
>>> print ("\n\nRandom Forest - Test Confusion Matrix\n\n",pd.crosstab(y_test, rf_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nRandom Forest - Test accuracy",round(accuracy_score(y_test, rf_fit.predict(x_test)),3))
>>> print ("\nRandom Forest - Test Classification Report\n",classification_report( y_test, rf_fit.predict(x_test)))

随机森林分类器产生了 87.8%的测试准确度,而袋装法为 87.3%,同时识别出 14 名实际离职的员工,而袋装法则识别出了 13 名离职员工:
# Plot of Variable importance by mean decrease in gini
>>> model_ranks = pd.Series(rf_fit.feature_importances_,index=x_train.columns, name='Importance').sort_values(ascending=False, inplace=False)
>>> model_ranks.index.name = 'Variables'
>>> top_features = model_ranks.iloc[:31].sort_values(ascending=True,inplace=False)
>>> import matplotlib.pyplot as plt
>>> plt.figure(figsize=(20,10))
>>> ax = top_features.plot(kind='barh')
>>> _ = ax.set_title("Variable Importance Plot")
>>> _ = ax.set_xlabel('Mean decrease in Variance')
>>> _ = ax.set_yticklabels(top_features.index, fontsize=13)

从变量重要性图来看,月收入变量似乎是最重要的,其次是加班、总工作年限、股票期权等级、公司年限等等。这为我们提供了一些洞察,帮助我们了解哪些主要因素决定了员工是否会留在公司或离开组织:
应用于 HR 离职数据的随机森林分类器的 R 代码:
# Random Forest
library(randomForest)
set.seed(43)
rf_fit = randomForest(Attrition_ind~.,data = train_data,mtry=6, maxnodes= 64,classwt = c(0.3,0.7),ntree=5000,nodesize = 1)
tr_y_pred = predict(rf_fit,data = train_data,type = "response")
ts_y_pred = predict(rf_fit,newdata = test_data,type = "response")
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Random Forest Train accuracy:",tr_acc))
print(paste("Random Forest - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Random Forest Test accuracy:",ts_acc))
print(paste("Random Forest - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
随机森林分类器 - 网格搜索
在机器学习模型中,调整参数起着至关重要的作用。这里展示了一个网格搜索示例,演示如何调整随机森林模型:
# Random Forest Classifier - Grid Search
>>> from sklearn.pipeline import Pipeline
>>> from sklearn.model_selection import train_test_split,GridSearchCV
>>> pipeline = Pipeline([ ('clf',RandomForestClassifier(criterion='gini',class_weight = {0:0.3,1:0.7}))])
调整参数与随机森林的参数类似,除了使用管道函数验证所有组合。需要评估的组合数量将是(3 x 3 x 2 x 2) 5 =365 = 180个组合。这里最终使用 5,是因为五折交叉验证:
>>> parameters = {
... 'clf__n_estimators':(2000,3000,5000),
... 'clf__max_depth':(5,15,30),
... 'clf__min_samples_split':(2,3),
... 'clf__min_samples_leaf':(1,2) }
>>> grid_search = GridSearchCV(pipeline,parameters,n_jobs=-1,cv=5,verbose=1, scoring='accuracy')
>>> grid_search.fit(x_train,y_train)
>>> print ('Best Training score: %0.3f' % grid_search.best_score_)
>>> print ('Best parameters set:')
>>> best_parameters = grid_search.best_estimator_.get_params()
>>> for param_name in sorted(parameters.keys()):
... print ('\t%s: %r' % (param_name, best_parameters[param_name]))
>>> predictions = grid_search.predict(x_test)
>>> print ("Testing accuracy:",round(accuracy_score(y_test, predictions),4))
>>> print ("\nComplete report of Testing data\n",classification_report(y_test, predictions))
>>> print ("\n\nRandom Forest Grid Search- Test Confusion Matrix\n\n",pd.crosstab( y_test, predictions,rownames = ["Actuall"],colnames = ["Predicted"]))

在前面的结果中,网格搜索似乎没有提供比已经探索过的随机森林结果更多的优势。但是,实际上,大多数情况下,它会提供比简单模型探索更好、更强大的结果。然而,通过仔细评估多种不同的组合,它最终会发现最佳的参数组合:
应用于 HR 离职数据的带有网格搜索的随机森林分类器的 R 代码:
# Grid Search - Random Forest
library(e1071)
library(randomForest)
rf_grid = tune(randomForest,Attrition_ind~.,data = train_data,classwt = c(0.3,0.7),ranges = list( mtry = c(5,6),
maxnodes = c(32,64), ntree = c(3000,5000), nodesize = c(1,2)
),
tunecontrol = tune.control(cross = 5) )
print(paste("Best parameter from Grid Search"))
print(summary(rf_grid))
best_model = rf_grid$best.model
tr_y_pred=predict(best_model,data = train_data,type ="response")
ts_y_pred=predict(best_model,newdata = test_data,type= "response")
tr_y_act = train_data$Attrition_ind;
ts_y_act= test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Random Forest Grid search Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Random Forest Grid Search Train accuracy:",tr_acc))
print(paste("Random Forest Grid Search - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Random Forest Grid search Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Random Forest Grid Search Test accuracy:",ts_acc))
print(paste("Random Forest Grid Search - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
AdaBoost 分类器
Boosting 是一种当前许多数据科学家在多个竞赛中获胜的前沿模型。在本节中,我们将介绍 AdaBoost 算法,随后介绍 梯度提升 和 极端梯度提升(XGBoost)。Boosting 是一种通用方法,可以应用于许多统计模型。然而,在本书中,我们将讨论 Boosting 在决策树中的应用。在 Bagging 中,我们从训练数据中提取多个样本,并将各个树的结果结合起来创建一个单一的预测模型;这种方法是并行运行的,因为每个自助样本不依赖于其他样本。Boosting 是顺序进行的,它不涉及自助抽样;相反,每棵树都是在原始数据集的修改版上拟合的,最终这些树的结果被加起来形成一个强分类器:


上图展示了 AdaBoost 工作原理的示例方法论。我们将在接下来的算法描述中详细介绍逐步流程。最初,一个简单的分类器已经拟合在数据上(也称为决策树桩,它将数据划分为两个区域),在下一次迭代(迭代 2)中,正确分类的类将给予较低的权重,而错误分类的类(观察者 + 蓝色图标)将给予较高的权重,然后再次拟合另一个决策树桩/弱分类器,并且会改变权重以进行下一次迭代(迭代 3,在这里检查 - 符号以查看哪个权重被增加)。一旦完成所有迭代,这些将与权重(根据错误率自动计算的每个分类器的权重)结合,得到一个强分类器,能够以惊人的准确度预测类别。
AdaBoost 算法包括以下步骤:
-
初始化观察值的权重 w[i] = 1/N, i=1, 2, …, N。其中 N = 观察值的数量。
-
对于 m = 1 到 M:
-
使用权重 w[i] 将分类器 Gm(x) 拟合到训练数据上
-
计算:
-

-
- 计算:

-
- 设置:

- 输出:

所有观察值赋予相同的权重。
在 Bagging 和随机森林算法中,我们处理的是数据的列;而在 Boosting 中,我们调整的是每个观察值的权重,并不会选择一些列。
我们在数据上拟合一个分类器并评估整体错误。用于计算权重的误差应在最终的加法模型中给予该分类器(α)评估。直观上,错误较少的模型会给予更高的权重。最后,每个观察值的权重会被更新。在这里,错误分类的观察值会增加权重,以便在接下来的迭代中给予更多关注,而正确分类的观察值的权重会减少。
所有弱分类器与各自的权重相结合,形成一个强分类器。在下图中,展示了与初始迭代相比,在最后一次迭代中权重变化的快速示意:

# Adaboost Classifier
>>> from sklearn.tree import DecisionTreeClassifier
>>> from sklearn.ensemble import AdaBoostClassifier
决策树桩被用作 AdaBoost 的基础分类器。如果我们观察以下代码,树的深度保持为 1,表示只能进行一次决策(也视为弱分类器):
>>> dtree = DecisionTreeClassifier(criterion='gini',max_depth=1)
在 AdaBoost 中,决策树桩被用作基本估计器,拟合整个数据集,然后将分类器的附加副本拟合到同一数据集上,最多重复 5000 次。学习率将每个分类器的贡献缩小 0.05。学习率和估计器数量之间存在权衡。通过仔细选择较低的学习率和较长的估计器数量,可以更好地收敛到最优解,但代价是计算能力的消耗:
>>>adabst_fit = AdaBoostClassifier(base_estimator= dtree,n_estimators=5000,learning_rate=0.05,random_state=42)
>>>adabst_fit.fit(x_train, y_train)
>>>print ("\nAdaBoost - Train Confusion Matrix\n\n", pd.crosstab(y_train, adabst_fit.predict(x_train), rownames = ["Actuall"],colnames = ["Predicted"]))
>>>print ("\nAdaBoost - Train accuracy",round(accuracy_score(y_train,adabst_fit.predict(x_train)), 3))
>>>print ("\nAdaBoost - Train Classification Report\n",classification_report(y_train,adabst_fit.predict(x_train)))

与已知的最佳随机森林分类器相比,AdaBoost 的结果似乎在召回 1 值的表现上要好得多。尽管与最佳准确率 87.8%相比,准确率略微下降至 86.8%,但是随机森林预测出的 1 的数量为 23,而 AdaBoost 为 14,尽管增加了 0 的预测数量,但它在识别实际流失者方面确实取得了显著进展:
在 HR 流失数据上应用的 AdaBoost 分类器的 R 代码:
# Adaboost classifier using C5.0 with trails included for boosting
library(C50)
class_zero_wgt = 0.3
class_one_wgt = 1-class_zero_wgt
cstvr = class_one_wgt/class_zero_wgt
error_cost <- matrix(c(0, 1, cstvr, 0), nrow = 2)
# Fitting Adaboost model
ada_fit = C5.0(train_data[-31],train_data$Attrition_ind,costs = error_cost, trails = 5000,control = C5.0Control(minCases = 1))
summary(ada_fit)
tr_y_pred = predict(ada_fit, train_data,type = "class")
ts_y_pred = predict(ada_fit,test_data,type = "class")
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("AdaBoost - Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("AdaBoost Train accuracy:",tr_acc))
print(paste("AdaBoost - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("AdaBoost - Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("AdaBoost Test accuracy:",ts_acc))
print(paste("AdaBoost - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
梯度提升分类器
梯度提升是一个在竞争中获胜的算法,它的原理是通过迭代地增强弱学习器,重点关注在前几次迭代中难以预测的有问题的观察数据,并执行弱学习器的集成,通常是决策树。它像其他提升方法一样分阶段构建模型,但通过允许优化任意可微分的损失函数来对这些方法进行泛化。
让我们通过一个简单的例子来理解梯度提升,因为 GB 在工作原理的理解上挑战了许多数据科学家:
- 最初,我们在观察数据上拟合模型,得到 75%的准确率,其余的未解释方差被捕捉在错误项中:

- 然后,我们将拟合另一个模型来处理误差项,提取额外的解释性成分,并将其加入到原始模型中,这应该会提高整体的准确性:

- 现在,模型提供了 80%的准确率,方程如下:

- 我们再继续使用这种方法一次,拟合一个模型到error2成分,以提取进一步的解释性成分:

- 现在,模型的准确性进一步提升至 85%,最终的模型方程如下:

- 在这里,如果我们使用加权平均(给予预测准确性更高的模型更高的重要性),而不是简单的相加,它将进一步改善结果。事实上,这正是梯度提升算法所做的!

在引入权重后,误差的名称从error3变为error4,因为这两个误差可能并不完全相同。如果我们找到更好的权重,可能会得到 90%的准确率,而不是简单的相加方法,在相加的情况下我们仅获得 85%的准确率。
梯度提升包括三个要素:
-
需要优化的损失函数: 损失函数取决于所解决问题的类型。在回归问题中,使用均方误差,而在分类问题中,将使用对数损失。在提升算法中,每个阶段都会优化之前迭代中的未解释损失,而不是从头开始。
-
弱学习器进行预测: 决策树被用作梯度提升中的弱学习器。
-
加法模型通过加入弱学习器来最小化损失函数: 树是逐一加入的,模型中已有的树不会被更改。梯度下降过程用于最小化添加树时的损失。
梯度提升算法包含以下步骤:
- 初始化:

-
对于 m = 1 到 M:
- a) 对于 i = 1, 2, …, N,计算:

-
-
b) 拟合回归树到目标 r[im],得到终端区域 R[jm],其中 j = 1, 2, …, J[m],
-
c) 对于 j = 1, 2, …, J[m],计算:
-

-
- d) 更新:

- 输出:

初始化常量最优常数模型,该模型只是一个单一的终端节点,将作为起始点,在接下来的步骤中进一步调优。(2a),通过比较实际结果与预测结果来计算残差/误差,接着是(2b和2c),在这些步骤中,下一棵决策树将在误差项上拟合,以增加模型的解释能力,最后在(2d)步骤中,在最后一次迭代中将额外的组件加入模型。最后,将所有弱学习器进行集成,创造出强学习器。
AdaBoost 与梯度提升的比较
在了解了 AdaBoost 和梯度提升之后,读者可能会好奇两者的具体差异。这里,我们正是展示了这些内容来解答你的疑问!

这里使用了来自 scikit-learn 包的梯度提升分类器进行计算:
# Gradientboost Classifier
>>> from sklearn.ensemble import GradientBoostingClassifier
梯度提升算法中使用的参数如下。由于我们正在解决的是 0/1 二分类问题,因此使用了偏差作为损失函数。学习率选定为 0.05,构建的树的数量为 5000 棵,叶节点/终端节点的最小样本数为 1,桶中进行分割的最小样本数为 2:
>>> gbc_fit = GradientBoostingClassifier (loss='deviance', learning_rate=0.05, n_estimators=5000, min_samples_split=2, min_samples_leaf=1, max_depth=1, random_state=42 )
>>> gbc_fit.fit(x_train,y_train)
>>> print ("\nGradient Boost - Train Confusion Matrix\n\n",pd.crosstab(y_train, gbc_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nGradient Boost - Train accuracy",round(accuracy_score(y_train, gbc_fit.predict(x_train)),3))
>>> print ("\nGradient Boost - Train Classification Report\n",classification_report( y_train, gbc_fit.predict(x_train)))
>>> print ("\n\nGradient Boost - Test Confusion Matrix\n\n",pd.crosstab(y_test, gbc_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nGradient Boost - Test accuracy",round(accuracy_score(y_test, gbc_fit.predict(x_test)),3)) >>> print ("\nGradient Boost - Test Classification Report\n",classification_report( y_test, gbc_fit.predict(x_test)))

如果我们分析结果,梯度提升比 AdaBoost 取得了更好的结果,最高测试准确率为 87.5%,捕获的 1 的数量最多为 24,而 AdaBoost 的测试准确率为 86.8%。因此,这证明了为什么每个数据科学家都试图使用这一算法来赢得竞赛,毫不奇怪!
应用于 HR 流失数据的梯度提升分类器的 R 代码:
# Gradient boosting
library(gbm)
library(caret)
set.seed(43)
# Giving weights to all the observations in a way that total #weights will
be euqal 1
model_weights <- ifelse(train_data$Attrition_ind == "0",
(1/table(train_data$Attrition_ind)[1]) * 0.3,
(1/table(train_data$Attrition_ind)[2]) * 0.7)
# Setting parameters for GBM
grid <- expand.grid(n.trees = 5000, interaction.depth = 1, shrinkage = .04, n.minobsinnode = 1)
# Fitting the GBM model
gbm_fit <- train(Attrition_ind ~ ., data = train_data, method = "gbm", weights = model_weights,
tuneGrid=grid,verbose = FALSE)
# To print variable importance plot
summary(gbm_fit)
tr_y_pred = predict(gbm_fit, train_data,type = "raw")
ts_y_pred = predict(gbm_fit,test_data,type = "raw")
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Gradient Boosting - Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero =
recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Gradient Boosting Train accuracy:",tr_acc))
print(paste("Gradient Boosting - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Gradient Boosting - Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero =
recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Gradient Boosting Test accuracy:",ts_acc))
print(paste("Gradient Boosting - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
# Use the following code for performing cross validation on data - At the moment commented though
#fitControl <- trainControl(method = "repeatedcv", number = 4, repeats = 4)
# gbmFit1 <- train(Attrition_ind ~ ., data = train_data,
method = # "gbm", trControl = fitControl,tuneGrid=grid,verbose = FALSE)
极限梯度提升 - XGBoost 分类器
XGBoost 是由 Tianqi Chen 基于梯度提升原理于 2014 年开发的新算法。自其问世以来,它在数据科学界掀起了风暴。XGBoost 在系统优化和机器学习原理方面都进行了深刻的思考。该库的目标是推动机器计算极限,为用户提供可扩展、可移植且准确的结果:
# Xgboost Classifier
>>> import xgboost as xgb
>>> xgb_fit = xgb.XGBClassifier(max_depth=2, n_estimators=5000,
learning_rate=0.05)
>>> xgb_fit.fit(x_train, y_train)
>>> print ("\nXGBoost - Train Confusion Matrix\n\n",pd.crosstab(y_train, xgb_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nXGBoost - Train accuracy",round(accuracy_score(y_train, xgb_fit.predict(x_train)),3))
>>> print ("\nXGBoost - Train Classification Report\n",classification_report(y_train, xgb_fit.predict(x_train)))
>>> print ("\n\nXGBoost - Test Confusion Matrix\n\n",pd.crosstab(y_test, xgb_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nXGBoost - Test accuracy",round(accuracy_score(y_test, xgb_fit.predict(x_test)),3))
>>> print ("\nXGBoost - Test Classification Report\n",classification_report(y_test, xgb_fit.predict(x_test)))

从XGBoost获得的结果几乎与梯度提升相似。获得的测试准确率为 87.1%,而梯度提升为 87.5%,并且识别到的 1 的数量为 23,而梯度提升为 24。XGBoost 相对于梯度提升的最大优势在于性能和可用于控制模型调优的选项。通过更改其中的一些参数,使 XGBoost 甚至超过了梯度提升!
应用于 HR 流失数据的极限梯度提升(XGBoost)分类器的 R 代码:
# Xgboost Classifier
library(xgboost); library(caret)
hrattr_data = read.csv("WA_Fn-UseC_-HR-Employee-Attrition.csv")
str(hrattr_data); summary(hrattr_data)
# Target variable creation
hrattr_data$Attrition_ind = 0;
hrattr_data$Attrition_ind[hrattr_data$Attrition=="Yes"]=1
# Columns to be removed due to no change in its value across observations
remove_cols = c("EmployeeCount","EmployeeNumber","Over18","StandardHours","Attrition")
hrattr_data_new = hrattr_data[,!(names(hrattr_data) %in% remove_cols)]
# List of variables with continuous values
continuous_columns = c('Age','DailyRate', 'DistanceFromHome', 'Education', 'EnvironmentSatisfaction', 'HourlyRate', 'JobInvolvement', 'JobLevel', 'JobSatisfaction','MonthlyIncome', 'MonthlyRate', 'NumCompaniesWorked', 'PercentSalaryHike', 'PerformanceRating', 'RelationshipSatisfaction', 'StockOptionLevel', 'TotalWorkingYears', 'TrainingTimesLastYear', 'WorkLifeBalance', 'YearsAtCompany', 'YearsInCurrentRole', 'YearsSinceLastPromotion', 'YearsWithCurrManager')
# list of categorical variables
ohe_feats = c('BusinessTravel', 'Department', 'EducationField','Gender','JobRole', 'MaritalStatus', 'OverTime')
# one-hot-encoding categorical features
dummies <- dummyVars(~ BusinessTravel+Department+ EducationField+Gender+JobRole+MaritalStatus+OverTime, data = hrattr_data_new)
df_all_ohe <- as.data.frame(predict(dummies, newdata = hrattr_data_new))
# Cleaning column names and replace . with _
colClean <- function(x){ colnames(x) <- gsub("\\.", "_", colnames(x)); x }
df_all_ohe = colClean(df_all_ohe)
hrattr_data_new$Attrition_ind = as.integer(hrattr_data_new$Attrition_ind)
# Combining both continuous and dummy variables from categories
hrattr_data_v3 = cbind(df_all_ohe,hrattr_data_new [,(names(hrattr_data_new) %in% continuous_columns)], hrattr_data_new$Attrition_ind)
names(hrattr_data_v3)[52] = "Attrition_ind"
# Train and Test split based on 70% and 30%
set.seed(123)
numrow = nrow(hrattr_data_v3)
trnind = sample(1:numrow,size = as.integer(0.7*numrow))
train_data = hrattr_data_v3[trnind,]
test_data = hrattr_data_v3[-trnind,]
# Custom functions for calculation of Precision and Recall
frac_trzero = (table(train_data$Attrition_ind)[[1]])/nrow(train_data)
frac_trone = (table(train_data$Attrition_ind)[[2]])/nrow(train_data)
frac_tszero = (table(test_data$Attrition_ind)[[1]])/nrow(test_data)
frac_tsone = (table(test_data$Attrition_ind)[[2]])/nrow(test_data)
prec_zero <- function(act,pred){ tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[2,1]),4) ) }
prec_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[1,2]),4) ) }
recl_zero <- function(act,pred){tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[1,2]),4) ) }
recl_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[2,1]),4) ) }
accrcy <- function(act,pred){ tble = table(act,pred)
return( round((tble[1,1]+tble[2,2])/sum(tble),4)) }
y = train_data$Attrition_ind
# XGBoost Classifier Training
xgb <- xgboost(data = data.matrix(train_data[,-52]),label = y,eta = 0.04,max_depth = 2, nround=5000, subsample = 0.5, colsample_bytree = 0.5, seed = 1, eval_metric = "logloss", objective = "binary:logistic",nthread = 3)
# XGBoost value prediction on train and test data
tr_y_pred_prob <- predict(xgb, data.matrix(train_data[,-52]))
tr_y_pred <- as.numeric(tr_y_pred_prob > 0.5)
ts_y_pred_prob <- predict(xgb, data.matrix(test_data[,-52]))
ts_y_pred <- as.numeric(ts_y_pred_prob > 0.5)
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
# XGBoost Metric predictions on Train Data
print(paste("Xgboost - Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Xgboost Train accuracy:",tr_acc))
print(paste("Xgboost - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
# XGBoost Metric predictions on Test Data
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Xgboost - Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Xgboost Test accuracy:",ts_acc))
print(paste("Xgboost - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
集成集成 - 模型堆叠
集成集成或模型堆叠是一种将不同分类器组合成一个元分类器的方法,这个元分类器的泛化性能优于单独的每个分类器。无论是在处理生活中的个人问题时,还是在感到疑虑时,向多人请教总是一个好主意!执行模型集成有两种方法:
-
使用不同类型分类器的集成: 在这种方法中,使用不同类型的分类器(例如,逻辑回归、决策树、随机森林等)在相同的训练数据上进行拟合,结果根据是否为分类问题或回归问题,采用多数投票或平均值的方式进行合并。
-
使用相同类型的分类器集成,但在不同的自助采样上分别构建: 在这种方法中,从训练数据中抽取自助采样,并且每次在抽取的样本上拟合独立的模型(这些模型可以是决策树、随机森林等),然后将所有这些结果合并在一起,形成集成。这种方法适用于处理高度灵活的模型,在这种情况下,减少方差仍然能提高性能。
使用不同类型分类器的集成集成
如前一部分简要提到的那样,使用不同的分类器在相同的训练数据上进行拟合,并根据多数投票或应用另一个分类器(也称为元分类器),该分类器是基于从各个分类器得到的结果进行拟合的。也就是说,对于元分类器X,变量将是模型输出,而 Y 变量将是实际的 0/1 结果。通过这样做,我们将获得每个分类器应该赋予的权重,并据此对未见数据进行分类。这三种集成方法的应用如下:
-
多数投票或平均值: 在这种方法中,应用一个简单的众数函数(分类问题)来选择出现次数最多的类别,作为各个分类器的输出。而对于回归问题,将计算平均值并与实际值进行比较。
-
元分类器在结果上的应用方法: 从各个分类器预测实际结果 0 或 1,然后在这些 0 和 1 的基础上应用元分类器。这个方法的小问题是,元分类器可能会变得有些脆弱和僵化。我的意思是,0 和 1 只给出结果,而没有提供准确的敏感度(比如概率)。
-
元分类器应用于概率的方法: 在此方法中,从各个分类器获得概率,而不是 0 和 1。应用元分类器于概率,使得这种方法比第一种方法更加灵活。尽管用户可以尝试这两种方法,看看哪种表现更好。毕竟,机器学习就是关于探索和试错的过程。
在以下图表中,描述了模型堆叠的完整流程,并展示了各个阶段:

以下是多个分类器集成示例的步骤:
-
四个分类器已分别在训练数据上应用(逻辑回归、决策树、随机森林和 AdaBoost)。
-
已经为所有四个分类器确定了概率,然而,只有类别 1 的概率被用于元分类器,因为类别 0 的概率加上类别 1 的概率等于 1,因此只取一个概率就足以表示,否则会出现多重共线性问题。
-
逻辑回归被用作元分类器,以建模四个概率(从每个独立分类器获得)与最终 0/1 结果之间的关系。
-
所有四个变量的系数已被计算,并在新数据上应用,用于计算最终的聚合概率,将观测值分类到各自的类别中:
#Ensemble of Ensembles - by fitting various classifiers
>>> clwght = {0:0.3,1:0.7}
# Classifier 1 – Logistic Regression
>>> from sklearn.linear_model import LogisticRegression
>>> clf1_logreg_fit = LogisticRegression(fit_intercept=True,class_weight=clwght)
>>> clf1_logreg_fit.fit(x_train,y_train)
>>> print ("\nLogistic Regression for Ensemble - Train Confusion Matrix\n\n",pd.crosstab( y_train, clf1_logreg_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nLogistic Regression for Ensemble - Train accuracy",round( accuracy_score(y_train,clf1_logreg_fit.predict(x_train)),3))
>>> print ("\nLogistic Regression for Ensemble - Train Classification Report\n", classification_report(y_train,clf1_logreg_fit.predict(x_train)))
>>> print ("\n\nLogistic Regression for Ensemble - Test Confusion Matrix\n\n",pd.crosstab( y_test,clf1_logreg_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"])) >
>> print ("\nLogistic Regression for Ensemble - Test accuracy",round( accuracy_score(y_test,clf1_logreg_fit.predict(x_test)),3))
>>> print ("\nLogistic Regression for Ensemble - Test Classification Report\n", classification_report( y_test,clf1_logreg_fit.predict(x_test)))
# Classifier 2 – Decision Tree
>>> from sklearn.tree import DecisionTreeClassifier
>>> clf2_dt_fit = DecisionTreeClassifier(criterion="gini", max_depth=5, min_samples_split=2, min_samples_leaf=1, random_state=42, class_weight=clwght)
>>> clf2_dt_fit.fit(x_train,y_train)
>>> print ("\nDecision Tree for Ensemble - Train Confusion Matrix\n\n",pd.crosstab( y_train, clf2_dt_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nDecision Tree for Ensemble - Train accuracy", round(accuracy_score( y_train,clf2_dt_fit.predict(x_train)),3))
>>> print ("\nDecision Tree for Ensemble - Train Classification Report\n", classification_report(y_train,clf2_dt_fit.predict(x_train)))
>>> print ("\n\nDecision Tree for Ensemble - Test Confusion Matrix\n\n", pd.crosstab(y_test, clf2_dt_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nDecision Tree for Ensemble - Test accuracy",round(accuracy_score(y_test, clf2_dt_fit.predict(x_test)),3))
>>> print ("\nDecision Tree for Ensemble - Test Classification Report\n", classification_report(y_test, clf2_dt_fit.predict(x_test)))
# Classifier 3 – Random Forest
>>> from sklearn.ensemble import RandomForestClassifier
>>> clf3_rf_fit = RandomForestClassifier(n_estimators=10000, criterion="gini", max_depth=6, min_samples_split=2,min_samples_leaf=1,class_weight = clwght)
>>> clf3_rf_fit.fit(x_train,y_train)
>>> print ("\nRandom Forest for Ensemble - Train Confusion Matrix\n\n", pd.crosstab(y_train, clf3_rf_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nRandom Forest for Ensemble - Train accuracy",round(accuracy_score( y_train,clf3_rf_fit.predict(x_train)),3))
>>> print ("\nRandom Forest for Ensemble - Train Classification Report\n", classification_report(y_train,clf3_rf_fit.predict(x_train)))
>>> print ("\n\nRandom Forest for Ensemble - Test Confusion Matrix\n\n",pd.crosstab( y_test, clf3_rf_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nRandom Forest for Ensemble - Test accuracy",round(accuracy_score( y_test,clf3_rf_fit.predict(x_test)),3))
>>> print ("\nRandom Forest for Ensemble - Test Classification Report\n", classification_report(y_test,clf3_rf_fit.predict(x_test)))
# Classifier 4 – Adaboost classifier
>>> from sklearn.ensemble import AdaBoostClassifier
>>> clf4_dtree = DecisionTreeClassifier(criterion='gini',max_depth=1,class_weight = clwght)
>>> clf4_adabst_fit = AdaBoostClassifier(base_estimator= clf4_dtree,
n_estimators=5000,learning_rate=0.05,random_state=42)
>>> clf4_adabst_fit.fit(x_train, y_train)
>>> print ("\nAdaBoost for Ensemble - Train Confusion Matrix\n\n",pd.crosstab(y_train, clf4_adabst_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nAdaBoost for Ensemble - Train accuracy",round(accuracy_score(y_train, clf4_adabst_fit.predict(x_train)),3))
>>> print ("\nAdaBoost for Ensemble - Train Classification Report\n", classification_report(y_train,clf4_adabst_fit.predict(x_train)))
>>> print ("\n\nAdaBoost for Ensemble - Test Confusion Matrix\n\n", pd.crosstab(y_test, clf4_adabst_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nAdaBoost for Ensemble - Test accuracy",round(accuracy_score(y_test, clf4_adabst_fit.predict(x_test)),3))
>>> print ("\nAdaBoost for Ensemble - Test Classification Report\n", classification_report(y_test, clf4_adabst_fit.predict(x_test)))
在接下来的步骤中,我们执行了分类器的集成:
>> ensemble = pd.DataFrame()
在接下来的步骤中,我们只取类别 1 的概率,因为它给出了直观的高概率,并指示了朝向更高类别 1 的值。但如果有人真的想拟合类别 0 的概率,也不应因此而止步。在这种情况下,类别 1 的低概率值更为优选,这让我们有点头疼!
>>> ensemble["log_output_one"] = pd.DataFrame(clf1_logreg_fit.predict_proba( x_train))[1]
>>> ensemble["dtr_output_one"] = pd.DataFrame(clf2_dt_fit.predict_proba(x_train))[1]
>>> ensemble["rf_output_one"] = pd.DataFrame(clf3_rf_fit.predict_proba(x_train))[1]
>>> ensemble["adb_output_one"] = pd.DataFrame(clf4_adabst_fit.predict_proba( x_train))[1]
>>> ensemble = pd.concat([ensemble,pd.DataFrame(y_train).reset_index(drop = True )],axis=1)
# Fitting meta-classifier
>>> meta_logit_fit = LogisticRegression(fit_intercept=False)
>>> meta_logit_fit.fit(ensemble[['log_output_one', 'dtr_output_one', 'rf_output_one', 'adb_output_one']],ensemble['Attrition_ind'])
>>> coefs = meta_logit_fit.coef_
>>> ensemble_test = pd.DataFrame()
>>> ensemble_test["log_output_one"] = pd.DataFrame(clf1_logreg_fit.predict_proba( x_test))[1]
>>> ensemble_test["dtr_output_one"] = pd.DataFrame(clf2_dt_fit.predict_proba( x_test))[1]
>>> ensemble_test["rf_output_one"] = pd.DataFrame(clf3_rf_fit.predict_proba( x_test))[1]
>>> ensemble_test["adb_output_one"] = pd.DataFrame(clf4_adabst_fit.predict_proba( x_test))[1]
>>> coefs = meta_logit_fit.coef_
>>> ensemble_test = pd.DataFrame()
>>> ensemble_test["log_output_one"] = pd.DataFrame(clf1_logreg_fit.predict_proba( x_test))[1]
>>> ensemble_test["dtr_output_one"] = pd.DataFrame(clf2_dt_fit.predict_proba( x_test))[1]
>>> ensemble_test["rf_output_one"] = pd.DataFrame(clf3_rf_fit.predict_proba( x_test))[1]
>>> ensemble_test["adb_output_one"] = pd.DataFrame(clf4_adabst_fit.predict_proba( x_test))[1]
>>> print ("\n\nEnsemble of Models - Test Confusion Matrix\n\n",pd.crosstab( ensemble_test['Attrition_ind'],ensemble_test['all_one'],rownames = ["Actuall"], colnames = ["Predicted"]))
>>> print ("\nEnsemble of Models - Test accuracy",round(accuracy_score (ensemble_test['Attrition_ind'],ensemble_test['all_one']),3))
>>> print ("\nEnsemble of Models - Test Classification Report\n", classification_report( ensemble_test['Attrition_ind'], ensemble_test['all_one']))

尽管代码会打印出训练、测试准确率、混淆矩阵和分类报告,但由于篇幅限制,我们在此未显示它们。建议用户在自己的计算机上运行并检查结果。测试准确率为87.5%,这是最高值(与梯度提升结果相同)。然而,通过仔细调优,集成方法确实能提供更好的结果,这得益于加入更好的模型并去除低权重模型:
>>> coefs = meta_logit_fit.coef_
>>> print ("Co-efficients for LR, DT, RF and AB are:",coefs)

令人惊讶的是,AdaBoost 似乎在拖慢集成的性能。一个建议是要么更改 AdaBoost 中的参数并重新运行整个过程,要么从集成中移除 AdaBoost 分类器,再次运行集成步骤,看看集成测试准确率、精确度和召回值是否有所改善:
在 HR 流失数据上应用不同分类器的集成方法的 R 代码:
# Ensemble of Ensembles with different type of Classifiers setwd
("D:\\Book writing\\Codes\\Chapter 4")
hrattr_data = read.csv("WA_Fn-UseC_-HR-Employee-Attrition.csv")
str(hrattr_data)
summary(hrattr_data)
hrattr_data$Attrition_ind = 0; hrattr_data$Attrition_ind[hrattr_data$Attrition=="Yes"]=1
hrattr_data$Attrition_ind = as.factor(hrattr_data$Attrition_ind)
remove_cols = c ("EmployeeCount","EmployeeNumber","Over18", "StandardHours","Attrition")
hrattr_data_new = hrattr_data[,!(names(hrattr_data) %in% remove_cols)]
set.seed(123)
numrow = nrow(hrattr_data_new)
trnind = sample(1:numrow,size = as.integer(0.7*numrow))
train_data = hrattr_data_new[trnind,]
test_data = hrattr_data_new[-trnind,]
# Ensemble of Ensembles with different type of Classifiers train_data$Attrition_ind = as.factor(train_data$Attrition_ind)
# Classifier 1 - Logistic Regression
glm_fit = glm(Attrition_ind ~.,family = "binomial",data = train_data) glm_probs = predict(glm_fit,newdata = train_data,type = "response")
# Classifier 2 - Decision Tree classifier
library(C50)
dtree_fit = C5.0(train_data[-31],train_data$Attrition_ind,
control = C5.0Control(minCases = 1))
dtree_probs = predict(dtree_fit,newdata = train_data,type = "prob")[,2]
# Classifier 3 - Random Forest
library(randomForest)
rf_fit = randomForest(Attrition_ind~., data = train_data,mtry=6,maxnodes= 64,ntree=5000,nodesize = 1)
rf_probs = predict(rf_fit,newdata = train_data,type = "prob")[,2]
# Classifier 4 - Adaboost
ada_fit = C5.0(train_data[-31],train_data$Attrition_ind,trails = 5000,control = C5.0Control(minCases = 1))
ada_probs = predict(ada_fit,newdata = train_data,type = "prob")[,2]
# Ensemble of Models
ensemble = data.frame(glm_probs,dtree_probs,rf_probs,ada_probs)
ensemble = cbind(ensemble,train_data$Attrition_ind)
names(ensemble)[5] = "Attrition_ind"
rownames(ensemble) <- 1:nrow(ensemble)
# Meta-classifier on top of individual classifiers
meta_clf = glm(Attrition_ind~.,data = ensemble,family = "binomial")
meta_probs = predict(meta_clf, ensemble,type = "response")
ensemble$pred_class = 0
ensemble$pred_class[meta_probs>0.5]=1
# Train confusion and accuracy metrics
tr_y_pred = ensemble$pred_class
tr_y_act = train_data$Attrition_ind;ts_y_act = test_data$Attrition_ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Ensemble - Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
print(paste("Ensemble Train accuracy:",tr_acc))
# Now verifing on test data
glm_probs = predict(glm_fit,newdata = test_data,type = "response")
dtree_probs = predict(dtree_fit,newdata = test_data,type = "prob")[,2]
rf_probs = predict(rf_fit,newdata = test_data,type = "prob")[,2]
ada_probs = predict(ada_fit,newdata = test_data,type = "prob")[,2]
ensemble_test = data.frame(glm_probs,dtree_probs,rf_probs,ada_probs)
ensemble_test = cbind(ensemble_test,test_data$Attrition_ind)
names(ensemble_test)[5] = "Attrition_ind"
rownames(ensemble_test) <- 1:nrow(ensemble_test)
meta_test_probs = predict(meta_clf,newdata = ensemble_test,type = "response")
ensemble_test$pred_class = 0
ensemble_test$pred_class[meta_test_probs>0.5]=1
# Test confusion and accuracy metrics
ts_y_pred = ensemble_test$pred_class
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Ensemble - Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
print(paste("Ensemble Test accuracy:",ts_acc))
使用单一类型分类器的集成集成,结合自助样本
在该方法中,从训练数据中抽取自助样本,每次将会为抽取的样本拟合单独的模型(单个模型可以是决策树、随机森林等),并且所有这些结果最终会被组合成一个集成模型。这种方法适用于处理高度灵活的模型,在这些模型中,减少方差仍然可以提高性能:

在以下示例中,AdaBoost 作为基础分类器,个别 AdaBoost 模型的结果通过袋装分类器结合生成最终结果。尽管如此,每个 AdaBoost 都是由深度为 1 的决策树(决策树桩)组成。在这里,我们想展示的是分类器内的分类器内的分类器是可能的(听起来像电影《盗梦空间》!):
# Ensemble of Ensembles - by applying bagging on simple classifier
>>> from sklearn.tree import DecisionTreeClassifier
>>> from sklearn.ensemble import BaggingClassifier
>>> from sklearn.ensemble import AdaBoostClassifier
>>> clwght = {0:0.3,1:0.7}
以下是 AdaBoost 分类器中使用的基本分类器(决策树桩):
>>> eoe_dtree = DecisionTreeClassifier(criterion='gini',max_depth=1,class_weight = clwght)
每个 AdaBoost 分类器由 500 棵决策树组成,学习率为 0.05:
>>> eoe_adabst_fit = AdaBoostClassifier(base_estimator= eoe_dtree, n_estimators=500,learning_rate=0.05,random_state=42)
>>> eoe_adabst_fit.fit(x_train, y_train)
>>> print ("\nAdaBoost - Train Confusion Matrix\n\n",pd.crosstab(y_train, eoe_adabst_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nAdaBoost - Train accuracy",round(accuracy_score(y_train, eoe_adabst_fit.predict(x_train)),3))
>>> print ("\nAdaBoost - Train Classification Report\n",classification_report(y_train, eoe_adabst_fit.predict(x_train)))
>>> print ("\n\nAdaBoost - Test Confusion Matrix\n\n",pd.crosstab(y_test, eoe_adabst_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nAdaBoost - Test accuracy",round(accuracy_score(y_test, eoe_adabst_fit.predict(x_test)),3))
>>> print ("\nAdaBoost - Test Classification Report\n",classification_report(y_test, eoe_adabst_fit.predict(x_test)))
袋装分类器由 50 个 AdaBoost 分类器组成,以集成这些集成模型:
>>> bag_fit = BaggingClassifier(base_estimator= eoe_adabst_fit,n_estimators=50,
max_samples=1.0,max_features=1.0, bootstrap=True,
bootstrap_features=False,n_jobs=-1,random_state=42)
>>> bag_fit.fit(x_train, y_train)
>>> print ("\nEnsemble of AdaBoost - Train Confusion Matrix\n\n",pd.crosstab( y_train,bag_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nEnsemble of AdaBoost - Train accuracy",round(accuracy_score(y_train, bag_fit.predict(x_train)),3))
>>> print ("\nEnsemble of AdaBoost - Train Classification Report\n", classification_report( y_train,bag_fit.predict(x_train)))
>>> print ("\n\nEnsemble of AdaBoost - Test Confusion Matrix\n\n",pd.crosstab(y_test, bag_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nEnsemble of AdaBoost - Test accuracy",round(accuracy_score(y_test,bag_fit.predict(x_test)),3))
>>> print ("\nEnsemble of AdaBoost - Test Classification Report\n", classification_report(y_test,bag_fit.predict(x_test)))

在 AdaBoost 的集成结果中,显示出了一些改进,其中获得的测试准确率为 87.1%,几乎与梯度提升的 87.5%相当,这是我们目前见过的最佳值。然而,识别出的 1 的数量为 25,超过了梯度提升的数量。因此,已经证明集成集成是有效的!不幸的是,这些类型的功能在 R 软件中不可用,因此我们在此不写相应的 R 代码。
总结
在本章中,你已学习了关于树基模型的完整细节,这些模型目前是行业中最常用的,包括使用网格搜索的单棵决策树,以及集成树模型,如袋装法(bagging)、随机森林、提升法(包括 AdaBoost、梯度提升和 XGBoost),最后是集成集成方法,也称为模型堆叠,通过进一步聚合结果来减少方差误差,从而提高准确性。在模型堆叠中,你已经学会了如何为每个模型确定权重,从而做出决策,选择哪些模型保留在最终结果中,以获得最佳的准确性。
在下一章中,你将学习 k 近邻算法和朴素贝叶斯,它们的计算开销比树基模型小。朴素贝叶斯模型将在一个 NLP 用例中进行解释。事实上,朴素贝叶斯和支持向量机(SVM)通常用于变量(维度数)非常高的分类问题。
第三章:K-最近邻和朴素贝叶斯
在前一章中,我们学习了计算量大的方法。相比之下,本章讨论了平衡这一点的简单方法!我们将介绍两种技术,分别是K-最近邻(KNN)和朴素贝叶斯。在讨论 KNN 之前,我们通过一个模拟示例解释了维度灾难的问题。随后,利用乳腺癌医学实例来预测癌症是恶性还是良性,并使用 KNN 进行分类。在本章的最后部分,介绍了朴素贝叶斯方法,并通过垃圾邮件/正常邮件分类进行讲解,这也涉及到应用自然语言处理(NLP)技术,包括以下基本的预处理和建模步骤:
-
标点符号移除
-
词语分词和小写转换
-
停用词移除
-
词干提取
-
词形还原与词性标注
-
将单词转换为 TF-IDF 以创建单词的数值表示
-
将朴素贝叶斯模型应用于 TF-IDF 向量,以预测消息是垃圾邮件还是正常邮件,适用于训练和测试数据
K-最近邻
K-最近邻是一种非参数的机器学习模型,其中模型记住训练观察数据用于分类未知的测试数据。它也可以称为基于实例的学习。这个模型通常被称为懒惰学习,因为它在训练阶段并不像回归、随机森林等那样学习任何东西。相反,它只在测试/评估阶段工作,通过将给定的测试观察数据与最接近的训练观察数据进行比较,这在比较每个测试数据点时会消耗大量时间。因此,该技术在大数据上并不高效;此外,由于维度灾难,当变量数量很高时,性能也会下降。
KNN 投票者示例
KNN 通过以下简短示例进行更好的说明。目标是根据选民的邻里情况,精确到地理位置(纬度和经度),预测选民将投给哪个政党。这里我们假设可以通过观察该地区大多数选民投票给哪个政党来判断潜在选民将投给哪个政党,从而使其有很高的可能性投给大多数党派。然而,调整 k 值(需要考虑的数量,在这些数量中统计大多数投票)是百万美元的问题(与任何机器学习算法一样):

在前面的图示中,我们可以看到该研究中的选民将投票给党派 2。因为在附近,一个邻居投票给了党派 1,另一个选民投票给了党派 3。但有三个选民投票给党派 2。事实上,通过这种方式,KNN 解决了任何给定的分类问题。回归问题通过在给定的圆圈或邻域内取邻居的均值或 k 值来解决。
维度灾难
KNN 完全依赖于距离。因此,研究“维度灾难”是值得的,目的是了解在预测所需的变量数量增加时,KNN 是如何随着维度增加而降低其预测能力的。这是一个显而易见的事实:高维空间是非常广阔的。与低维空间中的点相比,高维空间中的点通常会更加分散。虽然有很多方法可以检查维度曲线,但在这里我们使用了介于零和一之间的均匀随机值,分别生成 1D、2D 和 3D 空间中的数据,以验证这一假设。
在接下来的代码行中,我们计算了 1000 个观察点在不同维度下的平均距离。显而易见,随着维度的增加,点与点之间的距离呈对数增长,这提示我们需要在维度增加时,数据点以指数级增长,才能让机器学习算法正确工作:
>>> import numpy as np
>>> import pandas as pd
# KNN Curse of Dimensionality
>>> import random,math
以下代码生成从零到一之间均匀分布的随机数,维度相当于数组或列表的长度:
>>> def random_point_gen(dimension):
... return [random.random() for _ in range(dimension)]
以下函数计算了点之间的欧几里得距离(2-范数)的均方根和,通过计算点之间的差异并将平方求和,最后取总距离的平方根:
>>> def distance(v,w):
... vec_sub = [v_i-w_i for v_i,w_i in zip(v,w)]
... sum_of_sqrs = sum(v_i*v_i for v_i in vec_sub)
... return math.sqrt(sum_of_sqrs)
在下面的代码中,使用维度和对数来计算距离:
>>> def random_distances_comparison(dimension,number_pairs):
... return [distance(random_point_gen(dimension),random_point_gen(dimension))
for _ in range(number_pairs)]
>>> def mean(x):
... return sum(x) / len(x)
实验通过将维度从 1 改变到 201,维度每次增加 5 来检查距离的增加:
>>> dimensions = range(1, 201, 5)
我们计算了最小距离和平均距离,结果都表明了相似的趋势:
>>> avg_distances = []
>>> min_distances = []
>>> dummyarray = np.empty((20,4))
>>> dist_vals = pd.DataFrame(dummyarray)
>>> dist_vals.columns = ["Dimension","Min_Distance","Avg_Distance","Min/Avg_Distance"]
>>> random.seed(34)
>>> i = 0
>>> for dims in dimensions:
... distances = random_distances_comparison(dims, 1000)
... avg_distances.append(mean(distances))
... min_distances.append(min(distances))
... dist_vals.loc[i,"Dimension"] = dims
... dist_vals.loc[i,"Min_Distance"] = min(distances)
... dist_vals.loc[i,"Avg_Distance"] = mean(distances)
... dist_vals.loc[i,"Min/Avg_Distance"] = min(distances)/mean(distances)
... print(dims, min(distances), mean(distances), min(distances)*1.0 / mean( distances))
... i = i+1
# Plotting Average distances for Various Dimensions
>>> import matplotlib.pyplot as plt
>>> plt.figure()
>>> plt.xlabel('Dimensions')
>>> plt.ylabel('Avg. Distance')
>>> plt.plot(dist_vals["Dimension"],dist_vals["Avg_Distance"])
>>> plt.legend(loc='best')
>>> plt.show()

从前面的图表中可以证明,随着维度的增加,平均距离呈对数增长。因此,维度越高,就需要更多的数据来克服维度灾难!
1D、2D 和 3D 维度灾难示例
我们快速分析了 60 个随机点的距离,随着维度增加,这些点是如何扩展的。最初,随机点被绘制为一维数据:
# 1-Dimension Plot
>>> import numpy as np
>>> import pandas as pd
>>> import matplotlib.pyplot as plt
>>> one_d_data = np.random.rand(60,1)
>>> one_d_data_df = pd.DataFrame(one_d_data)
>>> one_d_data_df.columns = ["1D_Data"]
>>> one_d_data_df["height"] = 1
>>> plt.figure()
>>> plt.scatter(one_d_data_df['1D_Data'],one_d_data_df["height"])
>>> plt.yticks([])
>>> plt.xlabel("1-D points")
>>> plt.show()
如果我们观察下面的图表,可以发现所有 60 个数据点在一维空间中非常接近:

这里我们在 2D 空间中重复相同的实验,通过选取 60 个带有 x 和 y 坐标的随机数,并将其可视化:
# 2- Dimensions Plot
>>> two_d_data = np.random.rand(60,2)
>>> two_d_data_df = pd.DataFrame(two_d_data)
>>> two_d_data_df.columns = ["x_axis","y_axis"]
>>> plt.figure()
>>> plt.scatter(two_d_data_df['x_axis'],two_d_data_df["y_axis"])
>>> plt.xlabel("x_axis");plt.ylabel("y_axis")
>>> plt.show()
通过观察 2D 图表,我们可以看到相同的 60 个数据点之间出现了更多的间隙:

最终,我们绘制了 60 个数据点用于 3D 空间。我们可以看到空间的增加更加明显。到目前为止,这在视觉上已经证明,随着维度的增加,空间会变得非常大,这使得分类器难以检测到信号:
# 3- Dimensions Plot
>>> three_d_data = np.random.rand(60,3)
>>> three_d_data_df = pd.DataFrame(three_d_data)
>>> three_d_data_df.columns = ["x_axis","y_axis","z_axis"]
>>> from mpl_toolkits.mplot3d import Axes3D
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> ax.scatter(three_d_data_df['x_axis'],three_d_data_df["y_axis"],three_d_data_df ["z_axis"])
>>> plt.show()

使用乳腺癌威斯康星数据的 KNN 分类器示例
乳腺癌数据来自 UCI 机器学习库 archive.ics.uci.edu/ml/datasets/Breast+Cancer+Wisconsin+%28Diagnostic%29,用于示范目的。在这里,任务是基于各种收集的特征,如肿块厚度等,使用 KNN 分类器判断癌症是恶性还是良性:
# KNN Classifier - Breast Cancer
>>> import numpy as np
>>> import pandas as pd
>>> from sklearn.metrics import accuracy_score,classification_report
>>> breast_cancer = pd.read_csv("Breast_Cancer_Wisconsin.csv")
以下是前几行数据,展示数据的样子。Class值为2和4。值2和4分别代表良性和恶性类别。而所有其他变量的值在1和10之间变化,这些值在本质上是类别型的:

只有Bare_Nuclei变量有一些缺失值,在以下代码中,我们将这些缺失值替换为最常见的值(类别值1):
>>> breast_cancer['Bare_Nuclei'] = breast_cancer['Bare_Nuclei'].replace('?', np.NAN)
>>> breast_cancer['Bare_Nuclei'] = breast_cancer['Bare_Nuclei'].fillna(breast_cancer[ 'Bare_Nuclei'].value_counts().index[0])
使用以下代码将类转换为0和1的指示符,以用于分类器:
>>> breast_cancer['Cancer_Ind'] = 0
>>> breast_cancer.loc[breast_cancer['Class']==4,'Cancer_Ind'] = 1
在以下代码中,我们正在从分析中剔除无价值的变量:
>>> x_vars = breast_cancer.drop(['ID_Number','Class','Cancer_Ind'],axis=1)
>>> y_var = breast_cancer['Cancer_Ind']
>>> from sklearn.preprocessing import StandardScaler
>>> x_vars_stdscle = StandardScaler().fit_transform(x_vars.values)
>>> from sklearn.model_selection import train_test_split
由于 KNN 对距离非常敏感,在应用算法之前,我们在这里对所有列进行了标准化处理:
>>> x_vars_stdscle_df = pd.DataFrame(x_vars_stdscle, index=x_vars.index, columns=x_vars.columns)
>>> x_train,x_test,y_train,y_test = train_test_split(x_vars_stdscle_df,y_var, train_size = 0.7,random_state=42)
KNN 分类器的邻居值为3,p值表示 2 范数,也就是欧几里得距离,用于计算类别:
>>> from sklearn.neighbors import KNeighborsClassifier
>>> knn_fit = KNeighborsClassifier(n_neighbors=3,p=2,metric='minkowski')
>>> knn_fit.fit(x_train,y_train)
>>> print ("\nK-Nearest Neighbors - Train Confusion Matrix\n\n",pd.crosstab(y_train, knn_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]) )
>>> print ("\nK-Nearest Neighbors - Train accuracy:",round(accuracy_score(y_train, knn_fit.predict(x_train)),3))
>>> print ("\nK-Nearest Neighbors - Train Classification Report\n", classification_report( y_train,knn_fit.predict(x_train)))
>>> print ("\n\nK-Nearest Neighbors - Test Confusion Matrix\n\n",pd.crosstab(y_test, knn_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nK-Nearest Neighbors - Test accuracy:",round(accuracy_score( y_test,knn_fit.predict(x_test)),3))
>>> print ("\nK-Nearest Neighbors - Test Classification Report\n", classification_report(y_test,knn_fit.predict(x_test)))

从结果来看,KNN 在分类恶性和良性类别方面表现非常好,测试准确率达到了 97.6%,恶性类别的召回率为 96%。KNN 分类器唯一的缺点是,在测试阶段它计算密集,因为每个测试样本都要与训练数据中的所有可用样本进行比较,实际上,KNN 并没有从训练数据中“学习”任何东西。因此,我们也称其为懒惰分类器!
以下是 KNN 分类器的 R 代码:
# KNN Classifier
setwd("D:\\Book writing\\Codes\\Chapter 5")
breast_cancer = read.csv("Breast_Cancer_Wisconsin.csv")
# Column Bare_Nuclei have some missing values with "?" in place, we are replacing with median values
# As Bare_Nuclei is discrete variable
breast_cancer$Bare_Nuclei = as.character(breast_cancer$Bare_Nuclei)
breast_cancer$Bare_Nuclei[breast_cancer$Bare_Nuclei=="?"] = median(breast_cancer$Bare_Nuclei,na.rm = TRUE)
breast_cancer$Bare_Nuclei = as.integer(breast_cancer$Bare_Nuclei)
# Classes are 2 & 4 for benign & malignant respectively, we # have converted #
to zero-one problem, as it is easy to convert to work # around with models
breast_cancer$Cancer_Ind = 0
breast_cancer$Cancer_Ind[breast_cancer$Class==4]=1
breast_cancer$Cancer_Ind = as.factor( breast_cancer$Cancer_Ind)
# We have removed unique id number from modeling as unique # numbers does not provide value in modeling
# In addition, original class variable also will be removed # as the same has been replaced with derived variable
remove_cols = c("ID_Number","Class")
breast_cancer_new = breast_cancer[,!(names(breast_cancer) %in% remove_cols)]
# Setting seed value for producing repetitive results
# 70-30 split has been made on the data
set.seed(123)
numrow = nrow(breast_cancer_new)
trnind = sample(1:numrow,size = as.integer(0.7*numrow))
train_data = breast_cancer_new[trnind,]
test_data = breast_cancer_new[-trnind,]
# Following is classical code for computing accuracy, # precision & recall
frac_trzero = (table(train_data$Cancer_Ind)[[1]])/nrow(train_data)
frac_trone = (table(train_data$Cancer_Ind)[[2]])/nrow(train_data)
frac_tszero = (table(test_data$Cancer_Ind)[[1]])/nrow(test_data)
frac_tsone = (table(test_data$Cancer_Ind)[[2]])/nrow(test_data)
prec_zero <- function(act,pred){ tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[2,1]),4) ) }
prec_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[1,2]),4) ) }
recl_zero <- function(act,pred){tble = table(act,pred)
return( round( tble[1,1]/(tble[1,1]+tble[1,2]),4) ) }
recl_one <- function(act,pred){ tble = table(act,pred)
return( round( tble[2,2]/(tble[2,2]+tble[2,1]),4) ) }
accrcy <- function(act,pred){ tble = table(act,pred)
return( round((tble[1,1]+tble[2,2])/sum(tble),4)) }
# Importing Class package in which KNN function do present library(class)
# Choosing sample k-value as 3 & apply on train & test data # respectively
k_value = 3
tr_y_pred = knn(train_data,train_data,train_data$Cancer_Ind,k=k_value)
ts_y_pred = knn(train_data,test_data,train_data$Cancer_Ind,k=k_value)
# Calculating confusion matrix, accuracy, precision & # recall on train data
tr_y_act = train_data$Cancer_Ind;ts_y_act = test_data$Cancer_Ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("KNN Train accuracy:",tr_acc))
print(paste("KNN - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
# Calculating confusion matrix, accuracy, precision & # recall on test data
ts_tble = table(ts_y_act, ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("KNN Test accuracy:",ts_acc))
print(paste("KNN - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
KNN 分类器中的 k 值调整
在前一部分中,我们仅使用了 k 值为 3 的情况。实际上,在任何机器学习算法中,我们都需要调节参数以检查在哪些情况下可以获得更好的性能。对于 KNN,唯一需要调节的参数是 k 值。因此,在以下代码中,我们通过网格搜索来确定最佳 k 值:
# Tuning of K- value for Train & Test data
>>> dummyarray = np.empty((5,3))
>>> k_valchart = pd.DataFrame(dummyarray)
>>> k_valchart.columns = ["K_value","Train_acc","Test_acc"]
>>> k_vals = [1,2,3,4,5]
>>> for i in range(len(k_vals)):
... knn_fit = KNeighborsClassifier(n_neighbors=k_vals[i],p=2,metric='minkowski')
... knn_fit.fit(x_train,y_train)
... print ("\nK-value",k_vals[i])
... tr_accscore = round(accuracy_score(y_train,knn_fit.predict(x_train)),3)
... print ("\nK-Nearest Neighbors - Train Confusion Matrix\n\n",pd.crosstab( y_train, knn_fit.predict(x_train),rownames = ["Actuall"],colnames = ["Predicted"]) )
... print ("\nK-Nearest Neighbors - Train accuracy:",tr_accscore)
... print ("\nK-Nearest Neighbors - Train Classification Report\n", classification_report(y_train,knn_fit.predict(x_train)))
... ts_accscore = round(accuracy_score(y_test,knn_fit.predict(x_test)),3)
... print ("\n\nK-Nearest Neighbors - Test Confusion Matrix\n\n",pd.crosstab( y_test,knn_fit.predict(x_test),rownames = ["Actuall"],colnames = ["Predicted"]))
... print ("\nK-Nearest Neighbors - Test accuracy:",ts_accscore)
... print ("\nK-Nearest Neighbors - Test Classification Report\n",classification_report(y_test,knn_fit.predict(x_test)))
... k_valchart.loc[i, 'K_value'] = k_vals[i]
... k_valchart.loc[i, 'Train_acc'] = tr_accscore
... k_valchart.loc[i, 'Test_acc'] = ts_accscore
# Ploting accuracies over varied K-values
>>> import matplotlib.pyplot as plt
>>> plt.figure()
>>> plt.xlabel('K-value')
>>> plt.ylabel('Accuracy')
>>> plt.plot(k_valchart["K_value"],k_valchart["Train_acc"])
>>> plt.plot(k_valchart["K_value"],k_valchart["Test_acc"])
>>> plt.axis([0.9,5, 0.92, 1.005])
>>> plt.xticks([1,2,3,4,5])
>>> for a,b in zip(k_valchart["K_value"],k_valchart["Train_acc"]):
... plt.text(a, b, str(b),fontsize=10)
>>> for a,b in zip(k_valchart["K_value"],k_valchart["Test_acc"]):
... plt.text(a, b, str(b),fontsize=10)
>>> plt.legend(loc='upper right')
>>> plt.show()

结果表明,较小的 k 值会导致更多的过拟合问题,因为训练数据的准确性非常高,而测试数据的准确性较低。随着 k 值的增大,训练和测试的准确性逐渐趋于一致并变得更加稳定。这一现象展示了典型的机器学习现象。对于进一步分析,建议读者尝试比五更高的 k 值,看看训练和测试准确性是如何变化的。以下是 KNN 分类器中 k 值调整的 R 代码:
# Tuning of K-value on Train & Test Data
k_valchart = data.frame(matrix( nrow=5, ncol=3))
colnames(k_valchart) = c("K_value","Train_acc","Test_acc")
k_vals = c(1,2,3,4,5)
i = 1
for (kv in k_vals) {
tr_y_pred = knn(train_data,train_data,train_data$Cancer_Ind,k=kv)
ts_y_pred = knn(train_data,test_data,train_data$Cancer_Ind,k=kv)
tr_y_act = train_data$Cancer_Ind;ts_y_act = test_data$Cancer_Ind
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act, tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("KNN Train accuracy:",tr_acc))
print(paste("KNN - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("KNN Test accuracy:",ts_acc))
print(paste("KNN - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
k_valchart[i,1] =kv
k_valchart[i,2] =tr_acc
k_valchart[i,3] =ts_acc i = i+1 }
# Plotting the graph
library(ggplot2)
library(grid)
ggplot(k_valchart, aes(K_value))
+ geom_line(aes(y = Train_acc, colour = "Train_Acc")) +
geom_line(aes(y = Test_acc, colour = "Test_Acc"))+
labs(x="K_value",y="Accuracy") +
geom_text(aes(label = Train_acc, y = Train_acc), size = 3)+
geom_text(aes(label = Test_acc, y = Test_acc), size = 3)
朴素贝叶斯
贝叶斯算法的概念非常古老,源自 18 世纪。托马斯·贝叶斯(Thomas Bayes)开发了用于从已知事件中确定未知事件概率的基础数学原理。例如,如果所有的苹果都是红色的,且其平均直径约为 4 英寸,那么如果随机从一个篮子中挑选一个红色且直径为 3.7 英寸的水果,这个水果是苹果的概率是多少?朴素贝叶斯假设特定特征之间的独立性。在这种情况下,颜色和直径之间没有依赖关系。这种独立性假设使得朴素贝叶斯分类器在计算上对于某些任务(如基于单词的电子邮件分类)最为高效,即便在假设特征之间相互独立的情况下,词汇的高维度依然能够处理。朴素贝叶斯分类器在实际应用中表现得出奇地好。
贝叶斯分类器最适用于那些需要同时考虑大量属性信息来估计最终结果概率的问题。贝叶斯方法利用所有可用的证据进行预测,即使某些特征对最终结果的影响较弱,依然会被考虑。然而,我们不应忽视这样一个事实:大量相对较小影响的特征,结合在一起会形成强大的分类器。
概率基础
在深入了解朴素贝叶斯之前,回顾一下基本原理会很有帮助。一个事件的概率可以通过观察数据来估算,即将事件发生的次数除以总试验次数。例如,如果一个袋子里有红色和蓝色的球,且每次都从袋中随机抽取10个球,且每次抽取后会放回,那么在这10次试验中,出现3个红球,那么我们可以说红球的概率是0.3,p[红球] = 3/10 = 0.3。所有可能结果的总概率必须为 100%。
如果一个试验有两个可能的结果,例如电子邮件分类问题中,要么是垃圾邮件,要么是正常邮件,且这两个结果不能同时发生,那么这些事件被称为互斥事件。此外,如果这些结果涵盖了所有可能的事件,它们就被称为穷尽事件。例如,在电子邮件分类中,如果P(垃圾邮件) = 0.1,我们可以计算出P(正常邮件) = 1 - 0.1 = 0.9,这两个事件是互斥的。在下面的维恩图中,所有电子邮件的可能类别(整个宇宙)以及结果类型都被表示出来:

联合概率
尽管互斥事件的处理比较简单,但大多数实际问题属于非互斥事件的范畴。通过使用联合出现的情况,我们可以预测事件的结果。例如,如果邮件中出现了像 彩票 这样的词语,那么它很可能是垃圾邮件而非非垃圾邮件。下图显示了垃圾邮件与 彩票 的联合概率。然而,如果你仔细观察,你会发现“彩票”圆并没有完全包含在垃圾邮件圆内。这意味着并非所有垃圾邮件都包含“彩票”一词,也并非每封包含“彩票”一词的邮件都是垃圾邮件。

在下图中,我们扩展了垃圾邮件和非垃圾邮件类别,并用维恩图表示了彩票一词:

我们已经看到,10%的邮件是垃圾邮件,4%的邮件包含“彩票”一词,我们的任务是量化这两者之间的重叠程度。换句话说,我们需要确定同时发生 p(spam) 和 p(lottery) 的联合概率,可以写作 p(spam ∩ lottery)。如果这两个事件完全无关,则它们被称为独立事件,它们的值为 p(spam ∩ lottery) = p(spam) * p(lottery) = 0.1 * 0.04 = 0.004,即 0.4%的邮件是包含“彩票”一词的垃圾邮件。一般来说,对于独立事件,P(A∩ B) = P(A) * P(B)。
理解贝叶斯定理与条件概率
条件概率提供了一种利用贝叶斯定理计算依赖事件之间关系的方法。例如,A 和 B 是两个事件,我们希望计算 P(A\B),它可以理解为在事件 B 已经发生的情况下,事件 A 发生的概率,实际上,这就是条件概率,该方程可以写成如下形式:

为了更好地理解,我们现在来讨论一个邮件分类的例子。我们的目标是预测给定“彩票”一词和其他一些线索的情况下,一封邮件是否为垃圾邮件。在这个例子中,我们已经知道了垃圾邮件的总体概率,即 10%,也被称为先验概率。现在假设你得到了一个额外的信息,即“彩票”一词在所有邮件中的概率是 4%,也叫做边际似然性。现在,我们知道“彩票”一词出现在过去垃圾邮件中的概率,这被称为似然性。

通过应用贝叶斯定理到证据上,我们可以计算后验概率,从而计算消息是垃圾邮件的可能性;给定“彩票”一词出现在该消息中的事实。如果该概率大于 50%,则表示这封邮件是垃圾邮件而非非垃圾邮件。

在前面的表格中,记录了彩票在垃圾邮件和正常邮件中出现的次数及其相应的可能性。可能性表明 P(彩票\垃圾邮件)= 3/22 = 0.13,这表明垃圾邮件中包含“彩票”一词的概率为 13%。随后,我们可以计算 P(垃圾邮件 ∩ 彩票) = P(彩票\垃圾邮件) * P(垃圾邮件) = (3/22) * (22/100) = 0.03。为了计算后验概率,我们将 P(垃圾邮件 ∩ 彩票) 除以 P(彩票),即 (3/22)(22/100) / (4/100) = 0.75*。因此,给定一条信息中包含“彩票”一词,消息为垃圾邮件的概率为 75%。所以,不要相信快速致富的家伙们!
朴素贝叶斯分类
在过去的示例中,我们看到的是单个单词 彩票,然而在本例中,我们将讨论更多的额外单词,如 百万 和 退订,以展示实际分类器是如何工作的。让我们构建一个包含三个单词(W1,W2 和 W3)出现频率的可能性表,如下表所示,基于 100 封电子邮件:

当收到新邮件时,将计算后验概率,以确定该电子邮件是垃圾邮件还是正常邮件。假设我们收到一封包含 彩票 和 退订 的邮件,但没有包含 百万 一词,基于这些信息,垃圾邮件的概率是多少?
使用贝叶斯定理,我们可以定义问题为 彩票 = 是,百万 = 否 和 退订 = 是:

由于单词之间相互依赖,解决上述方程将具有较高的计算复杂度。随着单词数量的增加,这种计算复杂度将呈爆炸性增长,同时处理所有可能交集事件所需的内存也会非常庞大。最终,这导致了基于单词独立性的直观转变(条件独立性交叉),因此贝叶斯分类器被称为“朴素贝叶斯”。当两个事件相互独立时,我们可以写作 P(A ∩ B) = P(A) * P(B)。实际上,这种等式的计算更为简单,且对内存的需求更低:

以类似的方式,我们也会计算正常邮件的概率,具体如下:

通过将前述可能性表代入方程,由于垃圾邮件和正常邮件的比例,我们可以简单地忽略两个方程中的分母项。垃圾邮件的总体可能性为:


计算比率后,0.008864/0.004349 = 2.03,这意味着这条信息比正常邮件更有可能是垃圾邮件,概率是两倍。但我们可以按照以下方式计算概率:
P(垃圾邮件) = 0.008864/(0.008864+0.004349) = 0.67
P(Ham) = 0.004349/(0.008864+0.004349) = 0.33
通过将似然值转换为概率,我们可以以易于展示的方式呈现,无论是设置某些阈值,等等。
拉普拉斯估计
在前述计算中,所有值都是非零的,这使得计算顺利进行。然而,在实际情况中,有些单词在特定类别中从未出现,突然在后期阶段出现,这会导致整个计算结果为零。
例如,在前面的方程式中,W[3]的值是0而不是13,这会导致整个方程式的结果都变为0:

为了避免这种情况,拉普拉斯估计通过在频率表中的每个计数上加上一个小的常数,确保每个特征在每个类别中都有非零的出现概率。通常,拉普拉斯估计设为1,这确保每个类别-特征组合在数据中至少出现一次:

如果仔细观察方程,分子中的三个单词都加了1,同时,所有分母都加了3,以保持等式平衡。
朴素贝叶斯 SMS 垃圾邮件分类示例
使用 SMS 垃圾邮件数据集(可在www.dt.fee.unicamp.br/~tiago/smsspamcollection/找到)开发了朴素贝叶斯分类器。本章讨论了在构建朴素贝叶斯模型之前,NLP 技术中可用的各种预处理技术:
>>> import csv
>>> smsdata = open('SMSSpamCollection.txt','r')
>>> csv_reader = csv.reader(smsdata,delimiter='\t')
以下sys包的代码可以在遇到任何utf-8错误时使用,尤其是在使用旧版本 Python 时,否则在最新版本的 Python 3.6 中不必要:
>>> import sys
>>> reload (sys)
>>> sys.setdefaultendocing('utf-8')
正常编码从此开始,像往常一样:
>>> smsdata_data = []
>>> smsdata_labels = []
>>> for line in csv_reader:
... smsdata_labels.append(line[0])
... smsdata_data.append(line[1])
>>> smsdata.close()
以下代码会打印出前五行:
>>> for i in range(5):
... print (smsdata_data[i],smsdata_labels[i])

获取前述输出后,运行以下代码:
>>> from collections import Counter
>>> c = Counter( smsdata_labels )
>>> print(c)

在 5,572 个样本中,4,825 个是正常邮件,约占 86.5%,而 747 个是垃圾邮件,占余下的 13.4%。
使用 NLP 技术,我们已对数据进行预处理,以获得最终的词向量,进而映射到最终结果:垃圾邮件或正常邮件。主要的预处理阶段包括:
-
去除标点符号:在进行进一步处理之前,需要去除标点符号。
string库中的标点符号包括!"#$%&'()+,-./:;<=>?@[\]^_`{|}~*,这些标点符号会从所有消息中移除。 -
词汇分割:根据空格将句子中的单词拆分,以便进一步处理。
-
将单词转换为小写:将所有字母转换为小写可以去除重复项,例如Run和run,前者出现在句子的开头,而后者出现在句子的中间,等等,这些都需要统一处理以去除重复项,因为我们正在使用词袋模型技术。
-
停用词移除:停用词是指在文学中重复出现多次,但对句子的解释力没有太大作用的词。例如:I、me、you、this、that等,需要在进一步处理之前将其移除。
-
长度至少为三:在这里我们移除了长度小于三的单词。
-
单词的词干提取:词干提取过程将单词简化为其根词。词干提取的例子是将 running 简化为 run,或将 runs 简化为 run。通过词干提取,我们减少了重复并提高了模型的准确性。
-
词性标注(POS tagging):这一步骤为单词应用词性标签,如名词、动词、形容词等。例如,running的词性标注是动词,而run的词性标注是名词。在某些情况下,running是名词,词形还原不会将其简化为根词run,而是将running保留原样。因此,词性标注是执行词形还原操作之前必须进行的关键步骤。
-
单词的词形还原:词形还原是另一种减少维度的过程。在词形还原过程中,它将单词归结为根词,而不是仅仅截断单词。例如,将ate还原为其根词eat,当我们将ate传递给词形还原器时,词性标签为动词。
nltk包已经在所有预处理步骤中使用,因为它包含了所有必要的自然语言处理功能:
>>> import nltk
>>> from nltk.corpus import stopwords
>>> from nltk.stem import WordNetLemmatizer
>>> import string
>>> import pandas as pd
>>> from nltk import pos_tag
>>> from nltk.stem import PorterStemmer
已编写(预处理)函数,包含所有步骤,便于使用。然而,我们将在每个部分中解释这些步骤:
>>> def preprocessing(text):
以下代码行会将单词拆分并检查每个字符是否为标准标点符号,如果是,则将其替换为空格,否则不进行替换:
... text2 = " ".join("".join([" " if ch in string.punctuation else ch for ch in text]).split())
以下代码将句子根据空格分割为单词,并将它们组合成一个列表,以便应用进一步的步骤:
... tokens = [word for sent in nltk.sent_tokenize(text2) for word in
nltk.word_tokenize(sent)]
将所有的大小写(大写、小写和首字母大写)转换为小写,以减少语料库中的重复:
... tokens = [word.lower() for word in tokens]
如前所述,停用词是指那些在理解句子时没有太大意义的词;它们通常用于连接其他词等。我们已经通过以下代码行将它们移除:
... stopwds = stopwords.words('english')
... tokens = [token for token in tokens if token not in stopwds]
以下代码将只保留长度大于3的单词,用于去除那些几乎没有实际意义的小单词:
... tokens = [word for word in tokens if len(word)>=3]
对单词应用了PorterStemmer函数进行词干提取,该函数从单词中去除额外的后缀:
... stemmer = PorterStemmer()
... tokens = [stemmer.stem(word) for word in tokens]
词性标注是词形还原的前提,基于单词是名词还是动词等,它将单词简化为根词:
... tagged_corpus = pos_tag(tokens)
pos_tag函数为名词返回四种格式,为动词返回六种格式。名词包括NN(普通名词,单数)、NNP(专有名词,单数)、NNPS(专有名词,复数)、NNS(普通名词,复数);动词包括VB(动词原形)、VBD(动词过去式)、VBG(动词现在分词)、VBN(动词过去分词)、VBP(动词现在时,非第三人称单数)、VBZ(动词现在时,第三人称单数):
... Noun_tags = ['NN','NNP','NNPS','NNS']
... Verb_tags = ['VB','VBD','VBG','VBN','VBP','VBZ']
... lemmatizer = WordNetLemmatizer()
prat_lemmatize函数的创建仅为了解决pos_tag函数和词形还原函数(lemmatize)的输入值不匹配的问题。如果某个词的标签属于相应的名词或动词标签类别,则在词形还原函数中会应用n或v:
... def prat_lemmatize(token,tag):
... if tag in Noun_tags:
... return lemmatizer.lemmatize(token,'n')
... elif tag in Verb_tags:
... return lemmatizer.lemmatize(token,'v')
... else:
... return lemmatizer.lemmatize(token,'n')
在执行了分词和各种操作后,我们需要将其重新连接起来形成字符串,以下函数实现了这一操作:
... pre_proc_text = " ".join([prat_lemmatize(token,tag) for token,tag in tagged_corpus])
... return pre_proc_text
接下来的步骤是对数据应用预处理函数,并生成新的语料库:
>>> smsdata_data_2 = []
>>> for i in smsdata_data:
... smsdata_data_2.append(preprocessing(i))
数据将按 70-30 的比例分为训练集和测试集,并转换为 NumPy 数组,以应用机器学习算法:
>>> import numpy as np
>>> trainset_size = int(round(len(smsdata_data_2)*0.70))
>>> print ('The training set size for this classifier is ' + str(trainset_size) + '\n')
>>> x_train = np.array([''.join(rec) for rec in smsdata_data_2[0:trainset_size]])
>>> y_train = np.array([rec for rec in smsdata_labels[0:trainset_size]])
>>> x_test = np.array([''.join(rec) for rec in smsdata_data_2[trainset_size+1:len( smsdata_data_2)]])
>>> y_test = np.array([rec for rec in smsdata_labels[trainset_size+1:len( smsdata_labels)]])
以下代码将单词转换为向量化格式,并应用词频-逆文档频率(TF-IDF)权重,这是通过增加高频词的权重并同时惩罚常见词(如the,him,at等)的一种方法。在以下代码中,我们限制了词汇表中最频繁的 4,000 个单词,尽管我们也可以调整此参数,以检查在哪些参数下获得更好的准确率:
# building TFIDF vectorizer
>>> from sklearn.feature_extraction.text import TfidfVectorizer
>>> vectorizer = TfidfVectorizer(min_df=2, ngram_range=(1, 2), stop_words='english',
max_features= 4000,strip_accents='unicode', norm='l2')
TF-IDF 转换在训练数据和测试数据上展示如下。使用todense函数将数据转换成可视化格式:
>>> x_train_2 = vectorizer.fit_transform(x_train).todense()
>>> x_test_2 = vectorizer.transform(x_test).todense()
多项式朴素贝叶斯分类器适用于具有离散特征(例如单词计数)的分类,这通常需要大量的特征。然而,实际上,像 TF-IDF 这样的分数计数也能很好地工作。如果我们没有提及任何拉普拉斯估计器,它的默认值为1.0,意味着它将在每个词项的分子和分母总和中添加1.0:
>>> from sklearn.naive_bayes import MultinomialNB
>>> clf = MultinomialNB().fit(x_train_2, y_train)
>>> ytrain_nb_predicted = clf.predict(x_train_2)
>>> ytest_nb_predicted = clf.predict(x_test_2)
>>> from sklearn.metrics import classification_report,accuracy_score
>>> print ("\nNaive Bayes - Train Confusion Matrix\n\n",pd.crosstab(y_train, ytrain_nb_predicted,rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nNaive Bayes- Train accuracy",round(accuracy_score(y_train, ytrain_nb_predicted),3))
>>> print ("\nNaive Bayes - Train Classification Report\n",classification_report(y_train, ytrain_nb_predicted))
>>> print ("\nNaive Bayes - Test Confusion Matrix\n\n",pd.crosstab(y_test, ytest_nb_predicted,rownames = ["Actuall"],colnames = ["Predicted"]))
>>> print ("\nNaive Bayes- Test accuracy",round(accuracy_score(y_test, ytest_nb_predicted),3))
>>> print ("\nNaive Bayes - Test Classification Report\n",classification_report( y_test, ytest_nb_predicted))

根据之前的结果,似乎朴素贝叶斯算法在垃圾邮件的测试准确率达到了 96.6%,且召回值显著,为 76%,而在正常邮件(ham)的准确率几乎达到了 100%。
然而,如果我们想查看基于朴素贝叶斯的前 10 个特征及其系数,以下代码将非常有用:
# printing top features
>>> feature_names = vectorizer.get_feature_names()
>>> coefs = clf.coef_
>>> intercept = clf.intercept_
>>> coefs_with_fns = sorted(zip(clf.coef_[0], feature_names))
>>> print ("\n\nTop 10 features - both first & last\n")
>>> n=10
>>> top_n_coefs = zip(coefs_with_fns[:n], coefs_with_fns[:-(n + 1):-1])
>>> for (coef_1, fn_1), (coef_2, fn_2) in top_n_coefs:
... print('\t%.4f\t%-15s\t\t%.4f\t%-15s' % (coef_1, fn_1, coef_2, fn_2))

尽管 R 语言并不是进行自然语言处理(NLP)的热门选择,但这里我们展示了相关代码。我们鼓励读者修改代码,并观察准确率如何变化,从而更好地理解相关概念。以下是用于处理短信垃圾邮件/正常邮件数据的朴素贝叶斯分类器的 R 代码:
# Naive Bayes
smsdata = read.csv("SMSSpamCollection.csv",stringsAsFactors = FALSE)
# Try the following code for reading in case if you have
#issues while reading regularly with above code
#smsdata = read.csv("SMSSpamCollection.csv",
#stringsAsFactors = FALSE,fileEncoding="latin1")
str(smsdata)
smsdata$Type = as.factor(smsdata$Type)
table(smsdata$Type)
library(tm)
library(SnowballC)
# NLP Processing
sms_corpus <- Corpus(VectorSource(smsdata$SMS_Details))
corpus_clean_v1 <- tm_map(sms_corpus, removePunctuation)
corpus_clean_v2 <- tm_map(corpus_clean_v1, tolower)
corpus_clean_v3 <- tm_map(corpus_clean_v2, stripWhitespace)
corpus_clean_v4 <- tm_map(corpus_clean_v3, removeWords, stopwords())
corpus_clean_v5 <- tm_map(corpus_clean_v4, removeNumbers)
corpus_clean_v6 <- tm_map(corpus_clean_v5, stemDocument)
# Check the change in corpus
inspect(sms_corpus[1:3])
inspect(corpus_clean_v6[1:3])
sms_dtm <- DocumentTermMatrix(corpus_clean_v6)
smsdata_train <- smsdata[1:4169, ]
smsdata_test <- smsdata[4170:5572, ]
sms_dtm_train <- sms_dtm[1:4169, ]
sms_dtm_test <- sms_dtm[4170:5572, ]
sms_corpus_train <- corpus_clean_v6[1:4169]
sms_corpus_test <- corpus_clean_v6[4170:5572]
prop.table(table(smsdata_train$Type))
prop.table(table(smsdata_test$Type))
frac_trzero = (table(smsdata_train$Type)[[1]])/nrow(smsdata_train)
frac_trone = (table(smsdata_train$Type)[[2]])/nrow(smsdata_train)
frac_tszero = (table(smsdata_test$Type)[[1]])/nrow(smsdata_test)
frac_tsone = (table(smsdata_test$Type)[[2]])/nrow(smsdata_test)
Dictionary <- function(x) {
if( is.character(x) ) {
return (x)
}
stop('x is not a character vector')
}
# Create the dictionary with at least word appears 1 time
sms_dict <- Dictionary(findFreqTerms(sms_dtm_train, 1))
sms_train <- DocumentTermMatrix(sms_corpus_train,list(dictionary = sms_dict))
sms_test <- DocumentTermMatrix(sms_corpus_test,list(dictionary = sms_dict))
convert_tofactrs <- function(x) {
x <- ifelse(x > 0, 1, 0)
x <- factor(x, levels = c(0, 1), labels = c("No", "Yes"))
return(x)
}
sms_train <- apply(sms_train, MARGIN = 2, convert_tofactrs)
sms_test <- apply(sms_test, MARGIN = 2, convert_tofactrs)
# Application of Naïve Bayes Classifier with laplace Estimator
library(e1071)
nb_fit <- naiveBayes(sms_train, smsdata_train$Type,laplace = 1.0)
tr_y_pred = predict(nb_fit, sms_train)
ts_y_pred = predict(nb_fit,sms_test)
tr_y_act = smsdata_train$Type;ts_y_act = smsdata_test$Type
tr_tble = table(tr_y_act,tr_y_pred)
print(paste("Train Confusion Matrix"))
print(tr_tble)
tr_acc = accrcy(tr_y_act,tr_y_pred)
trprec_zero = prec_zero(tr_y_act,tr_y_pred); trrecl_zero = recl_zero(tr_y_act,tr_y_pred)
trprec_one = prec_one(tr_y_act,tr_y_pred); trrecl_one = recl_one(tr_y_act,tr_y_pred)
trprec_ovll = trprec_zero *frac_trzero + trprec_one*frac_trone
trrecl_ovll = trrecl_zero *frac_trzero + trrecl_one*frac_trone
print(paste("Naive Bayes Train accuracy:",tr_acc))
print(paste("Naive Bayes - Train Classification Report"))
print(paste("Zero_Precision",trprec_zero,"Zero_Recall",trrecl_zero))
print(paste("One_Precision",trprec_one,"One_Recall",trrecl_one))
print(paste("Overall_Precision",round(trprec_ovll,4),"Overall_Recall",round(trrecl_ovll,4)))
ts_tble = table(ts_y_act,ts_y_pred)
print(paste("Test Confusion Matrix"))
print(ts_tble)
ts_acc = accrcy(ts_y_act,ts_y_pred)
tsprec_zero = prec_zero(ts_y_act,ts_y_pred); tsrecl_zero = recl_zero(ts_y_act,ts_y_pred)
tsprec_one = prec_one(ts_y_act,ts_y_pred); tsrecl_one = recl_one(ts_y_act,ts_y_pred)
tsprec_ovll = tsprec_zero *frac_tszero + tsprec_one*frac_tsone
tsrecl_ovll = tsrecl_zero *frac_tszero + tsrecl_one*frac_tsone
print(paste("Naive Bayes Test accuracy:",ts_acc))
print(paste("Naive Bayes - Test Classification Report"))
print(paste("Zero_Precision",tsprec_zero,"Zero_Recall",tsrecl_zero))
print(paste("One_Precision",tsprec_one,"One_Recall",tsrecl_one))
print(paste("Overall_Precision",round(tsprec_ovll,4),"Overall_Recall",round(tsrecl_ovll,4)))
概述
在本章中,您已经学习了 KNN 和朴素贝叶斯技术,这些技术所需的计算能力相对较低。事实上,KNN 被称为懒惰学习器,因为它除了与训练数据点进行比较以进行分类外,不会进行任何学习。此外,您还了解了如何使用网格搜索技术调整 k 值。对于朴素贝叶斯分类器,我们提供了相关解释,并结合所有著名的 NLP 处理技术,简洁地向您展示了这个领域的一些示例。尽管在文本处理过程中,朴素贝叶斯或 SVM 技术都可以使用,因为这两种技术能够处理具有高维度的数据,这在 NLP 中非常重要,因为单词向量的维度相对较高且通常是稀疏的。
在下一章中,我们将详细介绍无监督学习,特别是聚类和主成分分析模型。
第四章:无监督学习
无监督学习的目标是发现数据中的隐藏模式或结构,其中不存在用于执行分类或回归方法的目标变量。无监督学习方法通常更具挑战性,因为结果是主观的,并且没有像预测类别或连续变量这样简单的分析目标。这些方法通常作为探索性数据分析的一部分进行。此外,由于没有公认的结果验证机制,因此很难评估无监督学习方法获得的结果。
尽管如此,无监督学习方法在各个领域的应用越来越重要,成为当前的热门话题,许多研究人员正在积极研究这些方法,探索这一新领域。以下是一些良好的应用示例:
-
基因组学:将无监督学习应用于理解基因组范围的生物学见解,从 DNA 入手更好地理解疾病和人类。这些任务本质上更具探索性。
-
搜索引擎:搜索引擎可能会根据其他相似用户的点击历史来选择显示哪些搜索结果给特定个体。
-
知识提取:从原始文本中提取概念的分类法,生成知识图谱,以创建自然语言处理(NLP)领域的语义结构。
-
客户细分:在银行业中,像聚类这样的无监督学习被应用于将相似的客户分组,并根据这些细分,营销部门设计他们的接触策略。例如,年龄较大、低风险的客户将通过定期存款产品进行目标营销,而高风险、年轻的客户则会通过信用卡或共同基金等进行营销,等等。
-
社交网络分析:识别网络中彼此更紧密连接、具有共同特征的凝聚群体。
在本章中,我们将介绍以下技术,使用公开可获得的数据进行无监督学习:
-
K-means 聚类
-
主成分分析
-
奇异值分解
-
深度自编码器
K-means 聚类
聚类是将观察值分组的任务,确保同一聚类的成员彼此之间更加相似,而不同聚类的成员之间差异较大。
聚类通常用于探索数据集,以识别其中的潜在模式或创建一组特征。在社交网络中,可以通过聚类来识别社区并建议人们之间缺失的连接。以下是一些示例:
-
在反洗钱措施中,可以通过异常检测识别可疑的活动和个人。
-
在生物学中,聚类用于寻找具有相似表达模式的基因群体。
-
在市场营销分析中,聚类用于寻找相似客户的细分群体,从而为不同的客户群体制定不同的营销策略。
k-means 聚类算法是一个迭代过程,它将聚类中心或质心移动到其组成点的均值位置,并反复将实例分配到最接近的聚类,直到聚类中心数量没有显著变化或达到最大迭代次数。
k-means 的代价函数由属于某个聚类的观测值与其相应质心之间的欧几里得距离(平方范数)决定。理解该公式的一种直观方法是,如果只有一个聚类(k=1),那么所有观测值与该单一均值的距离将被比较。而如果聚类数增加到2(k=2),则计算两个均值,一些观测值被分配到聚类1,另一些观测值根据距离分配到聚类2。随后,通过应用相同的距离度量,但分别对其聚类中心计算距离,从而得到代价函数:

从第一性原理出发的 K-means 工作方法论
k-means 的工作方法论通过以下示例进行了说明,在该示例中考虑了 12 个实例及其X和Y值。任务是从数据中确定最优的聚类。
| 实例 | X | Y |
|---|---|---|
| 1 | 7 | 8 |
| 2 | 2 | 4 |
| 3 | 6 | 4 |
| 4 | 3 | 2 |
| 5 | 6 | 5 |
| 6 | 5 | 7 |
| 7 | 3 | 3 |
| 8 | 1 | 4 |
| 9 | 5 | 4 |
| 10 | 7 | 7 |
| 11 | 7 | 6 |
| 12 | 2 | 1 |
在将数据点绘制到二维图表上后,我们可以看到大致上有两个聚类可能性,其中左下角是第一个聚类,右上角是另一个聚类。但在许多实际案例中,变量(或维度)会非常多,以至于我们无法简单地将其可视化。因此,我们需要一种数学和算法的方法来解决这些问题。

迭代 1:假设从所有12个实例中选择两个中心。这里,我们选择了实例1(X = 7, Y = 8)和实例8(X = 1, Y = 4),因为它们似乎位于两个极端。对于每个实例,我们将计算其相对于两个质心的欧几里得距离,并将其分配到最近的聚类中心。
| 实例 | X | Y | 质心 1 距离 | 质心 2 距离 | 分配的聚类 |
|---|---|---|---|---|---|
| 1 | 7 | 8 | 7.21 | 0.00 | C2 |
| 2 | 2 | 4 | 1.00 | 6.40 | C1 |
| 3 | 6 | 4 | 5.00 | 4.12 | C2 |
| 4 | 3 | 2 | 2.83 | 7.21 | C1 |
| 5 | 6 | 5 | 5.10 | 3.16 | C2 |
| 6 | 5 | 7 | 5.00 | 2.24 | C2 |
| 7 | 3 | 3 | 2.24 | 6.40 | C1 |
| 8 | 1 | 4 | 0.00 | 7.21 | C1 |
| 9 | 5 | 4 | 4.00 | 4.47 | C1 |
| 10 | 7 | 7 | 6.71 | 1.00 | C2 |
| 11 | 7 | 6 | 6.32 | 2.00 | C2 |
| 12 | 2 | 1 | 3.16 | 8.60 | C1 |
| 聚类中心 1 | 1 | 4 | ![]() |
![]() |
![]() |
| 聚类中心 2 | 7 | 8 | ![]() |
![]() |
![]() |
两点之间的欧几里得距离 A (X1, Y1) 和 B (X2, Y2) 如下所示:

聚类中心的距离计算是通过计算欧几里得距离来进行的。以下展示了一个样本计算。例如,六号实例与两个聚类中心(聚类中心 1 和聚类中心 2)之间的距离。


以下图表描述了实例到两个聚类中心的分配情况,格式与前面的表格相同:

如果仔细观察前面的图表,我们会发现,除了实例9 (X =5, Y = 4)外,所有实例似乎都已被正确分配。然而,在后续阶段,它应该被正确分配。让我们看看下面的步骤中分配是如何演变的。
迭代 2:在此迭代中,新的聚类中心由分配给该聚类或聚类中心的实例计算得出。新的聚类中心是基于所分配点的简单平均值来计算的。
| 实例 | X | Y | 分配的聚类 |
|---|---|---|---|
| 1 | 7 | 8 | C2 |
| 2 | 2 | 4 | C1 |
| 3 | 6 | 4 | C2 |
| 4 | 3 | 2 | C1 |
| 5 | 6 | 5 | C2 |
| 6 | 5 | 7 | C2 |
| 7 | 3 | 3 | C1 |
| 8 | 1 | 4 | C1 |
| 9 | 5 | 4 | C1 |
| 10 | 7 | 7 | C2 |
| 11 | 7 | 6 | C2 |
| 12 | 2 | 1 | C1 |
| 聚类中心 1 | 2.67 | 3 | ![]() |
| 聚类中心 2 | 6.33 | 6.17 | ![]() |
聚类中心 1 和 2 的样本计算如下所示。类似的方法将应用于所有后续的迭代:







更新聚类中心后,我们需要将实例重新分配给最近的聚类中心,这将在迭代 3 中执行。
迭代 3:在此迭代中,新的分配是基于实例与新聚类中心之间的欧几里得距离计算的。如果发生任何变化,将反复计算新的聚类中心,直到分配没有变化或达到迭代次数为止。下表描述了新聚类中心与所有实例之间的距离度量:
| 实例 | X | Y | 质心 1 距离 | 质心 2 距离 | 先前分配的聚类 | 新分配的聚类 | 是否变化? |
|---|---|---|---|---|---|---|---|
| 1 | 7 | 8 | 6.61 | 1.95 | C2 | C2 | No |
| 2 | 2 | 4 | 1.20 | 4.84 | C1 | C1 | No |
| 3 | 6 | 4 | 3.48 | 2.19 | C2 | C2 | No |
| 4 | 3 | 2 | 1.05 | 5.34 | C1 | C1 | No |
| 5 | 6 | 5 | 3.88 | 1.22 | C2 | C2 | No |
| 6 | 5 | 7 | 4.63 | 1.57 | C2 | C2 | No |
| 7 | 3 | 3 | 0.33 | 4.60 | C1 | C1 | No |
| 8 | 1 | 4 | 1.95 | 5.75 | C1 | C1 | No |
| 9 | 5 | 4 | 2.54 | 2.55 | C1 | C1 | No |
| 10 | 7 | 7 | 5.89 | 1.07 | C2 | C2 | No |
| 11 | 7 | 6 | 5.27 | 0.69 | C2 | C2 | No |
| 12 | 2 | 1 | 2.11 | 6.74 | C1 | C1 | No |
| 质心 1 | 2.67 | 3 | ![]() |
![]() |
![]() |
![]() |
![]() |
| 质心 2 | 6.33 | 6.17 | ![]() |
![]() |
![]() |
![]() |
![]() |
似乎没有注册到任何变化。因此,我们可以说,解决方案已经收敛。需要注意的一点是,除了实例9 (X = 5, Y = 4)外,所有实例都已非常清晰地被分类。根据直觉,它应该分配给质心 2,但经过仔细计算后,该实例实际上更接近聚类 1 而非聚类 2。然而,距离的差异非常小(质心 1 的距离为 2.54,质心 2 的距离为 2.55)。
最优聚类数量与聚类评估
尽管选择聚类的数量更像是一门艺术而非科学,但在选择最优聚类数时,若增加聚类数后,解释能力的提升非常小,则可以选择该数量。在实际应用中,通常业务方应该能够提供他们大致需要的聚类数量。
肘部法则
肘部法则用于确定 k-means 聚类中的最优聚类数量。肘部法则绘制不同k值下由代价函数产生的值。如你所知,当k增加时,平均失真度会降低,每个聚类的构成实例会减少,并且实例会更接近各自的质心。然而,随着k的增加,平均失真度的改善会逐渐减缓。失真度改善下降最明显的k值称为肘部,应该在此停止继续划分数据为更多的聚类。

使用轮廓系数评估聚类:轮廓系数是衡量聚类紧凑性和分离度的指标。较高的值代表更好的聚类质量。轮廓系数对紧凑且分离良好的聚类较高,而对重叠聚类则较低。轮廓系数值变化范围从 -1 到 +1,值越高,聚类质量越好。
轮廓系数是按实例计算的。对于一组实例,它是各个样本得分的平均值。

a 是簇内实例之间的平均距离,b 是该实例与下一个最近簇中实例之间的平均距离。
使用鸢尾花数据集进行 K-means 聚类示例
经典的鸢尾花数据集来自 UCI 机器学习库,用于演示 K-means 聚类。数据下载链接在此:archive.ics.uci.edu/ml/datasets/Iris。鸢尾花数据集包含三种花卉:山鸢尾、变色鸢尾和维吉尼亚鸢尾,以及它们的萼片长度、萼片宽度、花瓣长度和花瓣宽度的相应测量值。我们的任务是根据这些测量值将花卉分组。代码如下:
>>> import os
""" First change the following directory link to where all input files do exist """
>>> os.chdir("D:\\Book writing\\Codes\\Chapter 8")
K-means algorithm from scikit-learn has been utilized in the following example
# K-means clustering
>>> import numpy as np
>>> import pandas as pd
>>> import matplotlib.pyplot as plt
>>> from scipy.spatial.distance import cdist, pdist
>>> from sklearn.cluster import KMeans
>>> from sklearn.metrics import silhouette_score
>>> iris = pd.read_csv("iris.csv")
>>> print (iris.head())

以下代码用于将 class 变量作为依赖变量来为图中的颜色创建并应用无监督学习算法,且在给定的 x 变量上进行操作,没有目标变量:
>>> x_iris = iris.drop(['class'],axis=1)
>>> y_iris = iris["class"]
作为示例,使用了三个簇,但在现实生活中,我们无法事先知道数据将属于多少个簇,因此需要通过反复试验来测试结果。这里选择的最大迭代次数为 300,当然,这个值也可以调整并相应地检查结果:
>>> k_means_fit = KMeans(n_clusters=3,max_iter=300)
>>> k_means_fit.fit(x_iris)
>>> print ("\nK-Means Clustering - Confusion Matrix\n\n",pd.crosstab(y_iris, k_means_fit.labels_,rownames = ["Actuall"],colnames = ["Predicted"]) )
>>> print ("\nSilhouette-score: %0.3f" % silhouette_score(x_iris, k_means_fit.labels_, metric='euclidean'))

从之前的混淆矩阵中,我们可以看到所有的山鸢尾花都被正确地聚类,而 50 个变色鸢尾花中的 2 个和 50 个维吉尼亚鸢尾花中的 14 个被错误地分类。
再次强调,在现实生活中的例子中,我们事先并不知道类别名称,因此无法衡量准确性等指标。
以下代码用于执行敏感性分析,检查实际提供更好细分解释的簇的数量:
>>> for k in range(2,10):
... k_means_fitk = KMeans(n_clusters=k,max_iter=300)
... k_means_fitk.fit(x_iris)
... print ("For K value",k,",Silhouette-score: %0.3f" % silhouette_score(x_iris, k_means_fitk.labels_, metric='euclidean'))

上述结果中的轮廓系数值显示,K 值 2 和 K 值 3 的得分优于其他所有值。作为经验法则,我们需要选择轮廓系数最高的下一个 K 值。在这里,我们可以说 K 值 3 更好。此外,在得出最佳 K 值 之前,我们还需要查看每个簇内的平均变化值和肘部图。
# Avg. within-cluster sum of squares
>>> K = range(1,10)
>>> KM = [KMeans(n_clusters=k).fit(x_iris) for k in K]
>>> centroids = [k.cluster_centers_ for k in KM]
>>> D_k = [cdist(x_iris, centrds, 'euclidean') for centrds in centroids]
>>> cIdx = [np.argmin(D,axis=1) for D in D_k]
>>> dist = [np.min(D,axis=1) for D in D_k]
>>> avgWithinSS = [sum(d)/x_iris.shape[0] for d in dist]
# Total with-in sum of square
>>> wcss = [sum(d**2) for d in dist]
>>> tss = sum(pdist(x_iris)**2)/x_iris.shape[0]
>>> bss = tss-wcss
# elbow curve - Avg. within-cluster sum of squares
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111)
>>> ax.plot(K, avgWithinSS, 'b*-')
>>> plt.grid(True)
>>> plt.xlabel('Number of clusters')
>>> plt.ylabel('Average within-cluster sum of squares')

从肘部图中看,值为三时,斜率发生了剧烈变化。在这里,我们可以选择最优的 k 值为三。
# elbow curve - percentage of variance explained
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111)
>>> ax.plot(K, bss/tss*100, 'b*-')
>>> plt.grid(True)
>>> plt.xlabel('Number of clusters')
>>> plt.ylabel('Percentage of variance explained')
>>> plt.show()

最后但同样重要的是,解释的总方差百分比值应该大于 80%,以决定最优的聚类数量。即使在这里,k 值为三似乎也能提供一个合理的总方差解释值。因此,我们可以从前述的所有指标(轮廓系数、聚类内平均方差和总方差解释)得出结论,三类聚类是理想的。
使用鸢尾花数据的 k 均值聚类的 R 代码如下:
setwd("D:\\Book writing\\Codes\\Chapter 8")
iris_data = read.csv("iris.csv")
x_iris = iris_data[,!names(iris_data) %in% c("class")]
y_iris = iris_data$class
km_fit = kmeans(x_iris,centers = 3,iter.max = 300 )
print(paste("K-Means Clustering- Confusion matrix"))
table(y_iris,km_fit$cluster)
mat_avgss = matrix(nrow = 10, ncol = 2)
# Average within the cluster sum of square
print(paste("Avg. Within sum of squares"))
for (i in (1:10)){
km_fit = kmeans(x_iris,centers = i,iter.max = 300 )
mean_km = mean(km_fit$withinss)
print(paste("K-Value",i,",Avg.within sum of squares",round(mean_km, 2)))
mat_avgss[i,1] = i
mat_avgss[i,2] = mean_km
}
plot(mat_avgss[,1],mat_avgss[,2],type = 'o',xlab = "K_Value",ylab = "Avg. within sum of square")
title("Avg. within sum of squares vs. K-value")
mat_varexp = matrix(nrow = 10, ncol = 2)
# Percentage of Variance explained
print(paste("Percent. variance explained"))
for (i in (1:10)){
km_fit = kmeans(x_iris,centers = i,iter.max = 300 )
var_exp = km_fit$betweenss/km_fit$totss
print(paste("K-Value",i,",Percent var explained",round(var_exp,4)))
mat_varexp[i,1]=i
mat_varexp[i,2]=var_exp
}
plot(mat_varexp[,1],mat_varexp[,2],type = 'o',xlab = "K_Value",ylab = "Percent Var explained")
title("Avg. within sum of squares vs. K-value")
主成分分析 - PCA
主成分分析(PCA)是一种具有多种用途的降维技术。PCA 通过将数据投影到低维子空间来减少数据集的维度。例如,可以通过将点投影到一条线上来减少二维数据集。数据集中的每个实例将由单个值表示,而不是一对值。以类似的方式,可以通过将变量投影到平面上将三维数据集减少到二维。PCA 具有以下用途:
-
缓解维度灾难
-
在压缩数据的同时,尽量减少信息丢失。
-
主成分将在监督学习的下一阶段中进一步应用,如随机森林、提升方法等。
-
理解具有数百个维度的数据结构可能很困难,因此,通过将维度减少到二维或三维,可以更容易地可视化观察结果。
主成分分析(PCA)可以通过以下机械支架的示意图来轻松解释,该图已在机械工程课程的机械制图模块中绘制。图的左侧展示了组件的顶部视图、正面视图和侧面视图。而右侧则绘制了等轴测视图,其中使用了一张图像来可视化组件的外观。所以,可以想象,左侧的图像是实际的变量,右侧则是第一个主成分,其中捕获了大部分方差。
最终,通过旋转轴向,三张图像被替换为一张图像。实际上,我们在 PCA 分析中应用了相同的技术。

主成分工作方法在以下示例中进行了说明,实际数据在二维空间中展示,其中使用X和Y轴来绘制数据。主成分是捕捉数据最大变异性的部分。

下图展示了拟合主成分后的效果。第一个主成分涵盖了数据中的最大方差,第二个主成分与第一个主成分正交,正如我们所知,所有主成分彼此正交。我们可以仅用第一个主成分来表示整个数据。事实上,这就是用更少的维度表示数据的优势,不仅可以节省空间,还能抓取数据中的最大方差,这在下一阶段的监督学习中可以得到利用。这就是计算主成分的核心优势。

特征向量和特征值在线性代数、物理学、力学等领域具有重要意义。在学习主成分分析(PCA)时,刷新对特征向量和特征值的基础知识是必要的。特征向量是线性变换作用下仅通过 拉伸/压缩 和/或 翻转 的轴(方向);而特征值则告诉你压缩发生的倍数。换句话说,线性变换的特征向量是一个非零向量,在应用该线性变换时,其方向保持不变。
更正式地说,A 是从向量空间到
的线性变换,如果
是
的标量倍数,则
是 A 的特征向量。该条件可以写为以下方程:

在前面的方程中,
是一个特征向量,A 是一个方阵,λ 是一个标量,称为特征值。特征向量的方向在被 A 变换后保持不变,只有其大小发生了变化,这一变化由特征值表示。换句话说,将一个矩阵乘以其特征向量等同于对特征向量进行缩放,这是原始矩阵的紧凑表示。下图展示了特征向量和特征值在二维空间中的图形表示:

以下示例描述了如何从方阵及其理解中计算特征向量和特征值。请注意,特征向量和特征值只能针对方阵(行列数相同的矩阵)进行计算。

回想一下方程,即 A 与任何 A 的特征向量的乘积必须等于特征向量与特征值的大小相乘:


特征方程表明矩阵的行列式,即数据矩阵与单位矩阵和特征值的乘积之差为0。

前述矩阵的两个特征值均为-2。我们可以使用特征值来替代方程中的特征向量:



将特征值代入前述方程,我们将得到以下公式:

前述方程可以重写为以下方程组:

这个方程表明它可以有多个特征向量的解,我们可以用任何满足前述方程的值进行替代以验证方程。在这里,我们使用了向量[1 1]进行验证,似乎已被证明。

PCA 需要单位特征向量进行计算,因此我们需要用范数除以特征向量,或者我们需要对特征向量进行归一化处理。二范数方程如下所示:

输出向量的范数计算如下:

单位特征向量如下所示:

PCA 从基本原理出发的工作方法
PCA 工作方法在以下示例数据中描述,每个实例或数据点有两个维度。这里的目标是将二维数据降维为一维(也称为主成分):
| 实例 | X | Y |
|---|---|---|
| 1 | 0.72 | 0.13 |
| 2 | 0.18 | 0.23 |
| 3 | 2.50 | 2.30 |
| 4 | 0.45 | 0.16 |
| 5 | 0.04 | 0.44 |
| 6 | 0.13 | 0.24 |
| 7 | 0.30 | 0.03 |
| 8 | 2.65 | 2.10 |
| 9 | 0.91 | 0.91 |
| 10 | 0.46 | 0.32 |
| 列均值 | 0.83 | 0.69 |
第一步,在进行任何分析之前,是从所有观察值中减去均值,这样可以去除变量的尺度因素,并使它们在各维度之间更加统一。
| X | Y |
|---|---|
| 0.72 - 0.83 = -0.12 | 0.13 - 0.69 = - 0.55 |
| 0.18 - 0.83 = -0.65 | 0.23 - 0.69 = - 0.46 |
| 2.50 - 0.83 = 1.67 | 2.30 - 0.69 = 1.61 |
| 0.45 - 0.83 = -0.38 | 0.16 - 0.69 = - 0.52 |
| 0.04 - 0.83 = -0.80 | 0.44 - 0.69 = - 0.25 |
| 0.13 - 0.83 = -0.71 | 0.24 - 0.69 = - 0.45 |
| 0.30 - 0.83 = -0.53 | 0.03 - 0.69 = - 0.66 |
| 2.65 - 0.83 = 1.82 | 2.10 - 0.69 = 1.41 |
| 0.91 - 0.83 = 0.07 | 0.91 - 0.69 = 0.23 |
| 0.46 - 0.83 = -0.37 | 0.32 - 0.69 = -0.36 |
主成分可以通过两种不同的技术进行计算:
-
数据的协方差矩阵
-
奇异值分解
我们将在下一节中介绍奇异值分解技术。在本节中,我们将使用协方差矩阵方法求解特征向量和特征值。
协方差是衡量两个变量共同变化程度的指标,也是衡量两个变量集合之间相关性强度的度量。如果两个变量的协方差为零,我们可以得出结论,说明这两个变量集合之间没有任何相关性。协方差的公式如下:

下面的公式展示了 X 和 Y 变量的样本协方差计算。然而,它是一个 2 x 2 的矩阵,表示整个协方差矩阵(此外,它是一个方阵)。



由于协方差矩阵是方阵,我们可以从中计算特征向量和特征值。你可以参考前面章节中解释的方法。

通过求解上述方程,我们可以获得特征向量和特征值,如下所示:


前述结果可以通过以下 Python 语法获得:
>>> import numpy as np
>>> w, v = np.linalg.eig(np.array([[ 0.91335 ,0.75969 ],[ 0.75969,0.69702]]))
\>>> print ("\nEigen Values\n", w)
>>> print ("\nEigen Vectors\n", v)

一旦获得特征向量和特征值,我们可以将数据投影到主成分上。第一个特征向量具有最大的特征值,是第一个主成分,因为我们希望将原始的 2D 数据压缩成 1D 数据。

从上述结果中,我们可以看到原始 2D 数据的第一个主成分的 1D 投影。此外,特征值 1.5725 表明主成分解释了原始变量 57% 的方差。在多维数据的情况下,一般的经验法则是选择特征值或主成分的值大于某个阈值作为投影的依据。
使用 scikit-learn 对手写数字应用 PCA
PCA 示例已通过来自 scikit-learn 数据集的手写数字示例进行说明,其中手写数字从 0 到 9,并且其相应的 64 个特征(8 x 8 矩阵)表示像素强度。这里的核心思想是将原始的 64 维特征尽可能压缩到更少的维度:
# PCA - Principal Component Analysis
>>> import matplotlib.pyplot as plt
>>> from sklearn.decomposition import PCA
>>> from sklearn.datasets import load_digits
>>> digits = load_digits()
>>> X = digits.data
>>> y = digits.target
>>> print (digits.data[0].reshape(8,8))

使用 plt.show 函数绘制图形:
>>> plt.matshow(digits.images[0])
>>> plt.show()

在执行 PCA 之前,建议对输入数据进行缩放,以消除因数据维度不同而可能产生的问题。例如,在对客户数据应用 PCA 时,客户的薪水维度要大于客户的年龄维度。因此,如果我们没有将所有变量缩放到相同的维度,一个变量会解释整个变化,而不是其实际的影响。在下面的代码中,我们已对所有列分别进行了缩放:
>>> from sklearn.preprocessing import scale
>>> X_scale = scale(X,axis=0)
在下面的代码中,我们使用了两个主成分,以便将性能表示在 2D 图上。在后续部分,我们还应用了 3D。
>>> pca = PCA(n_components=2)
>>> reduced_X = pca.fit_transform(X_scale)
>>> zero_x, zero_y = [],[] ; one_x, one_y = [],[]
>>> two_x,two_y = [],[]; three_x, three_y = [],[]
>>> four_x,four_y = [],[]; five_x,five_y = [],[]
>>> six_x,six_y = [],[]; seven_x,seven_y = [],[]
>>> eight_x,eight_y = [],[]; nine_x,nine_y = [],[]
在下面的代码部分中,我们将相关的主成分分别附加到每个数字上,以便我们可以创建所有 10 个数字的散点图:
>>> for i in range(len(reduced_X)):
... if y[i] == 0:
... zero_x.append(reduced_X[i][0])
... zero_y.append(reduced_X[i][1])
... elif y[i] == 1:
... one_x.append(reduced_X[i][0])
... one_y.append(reduced_X[i][1])
... elif y[i] == 2:
... two_x.append(reduced_X[i][0])
... two_y.append(reduced_X[i][1])
... elif y[i] == 3:
... three_x.append(reduced_X[i][0])
... three_y.append(reduced_X[i][1])
... elif y[i] == 4:
... four_x.append(reduced_X[i][0])
... four_y.append(reduced_X[i][1])
... elif y[i] == 5:
... five_x.append(reduced_X[i][0])
... five_y.append(reduced_X[i][1])
... elif y[i] == 6:
... six_x.append(reduced_X[i][0])
... six_y.append(reduced_X[i][1])
... elif y[i] == 7:
... seven_x.append(reduced_X[i][0])
... seven_y.append(reduced_X[i][1])
... elif y[i] == 8:
... eight_x.append(reduced_X[i][0])
... eight_y.append(reduced_X[i][1])
... elif y[i] == 9:
... nine_x.append(reduced_X[i][0])
... nine_y.append(reduced_X[i][1])
>>> zero = plt.scatter(zero_x, zero_y, c='r', marker='x',label='zero')
>>> one = plt.scatter(one_x, one_y, c='g', marker='+')
>>> two = plt.scatter(two_x, two_y, c='b', marker='s')
>>> three = plt.scatter(three_x, three_y, c='m', marker='*')
>>> four = plt.scatter(four_x, four_y, c='c', marker='h')
>>> five = plt.scatter(five_x, five_y, c='r', marker='D')
>>> six = plt.scatter(six_x, six_y, c='y', marker='8')
>>> seven = plt.scatter(seven_x, seven_y, c='k', marker='*')
>>> eight = plt.scatter(eight_x, eight_y, c='r', marker='x')
>>> nine = plt.scatter(nine_x, nine_y, c='b', marker='D')
>>> plt.legend((zero,one,two,three,four,five,six,seven,eight,nine),
... ('zero','one','two','three','four','five','six', 'seven','eight','nine'),
... scatterpoints=1,
... loc='lower left',
... ncol=3,
... fontsize=10)
>>> plt.xlabel('PC 1')
>>> plt.ylabel('PC 2')
>>> plt.show()

尽管前面的图看起来有些杂乱,但它确实提供了一些关于数字彼此之间的远近的想法。我们可以得出结论,数字6和8非常相似,而数字4和7则远离中心组,等等。然而,我们也应该尝试使用更多的主成分,因为有时我们可能无法在二维中完全表示每个变化。
在下面的代码中,我们应用了三个主成分,以便可以在 3D 空间中更好地查看数据。这个过程与两个主成分非常相似,除了为每个数字创建一个额外的维度(X、Y 和 Z)。
# 3-Dimensional data
>>> pca_3d = PCA(n_components=3)
>>> reduced_X3D = pca_3d.fit_transform(X_scale)
>>> zero_x, zero_y,zero_z = [],[],[] ; one_x, one_y,one_z = [],[],[]
>>> two_x,two_y,two_z = [],[],[]; three_x, three_y,three_z = [],[],[]
>>> four_x,four_y,four_z = [],[],[]; five_x,five_y,five_z = [],[],[]
>>> six_x,six_y,six_z = [],[],[]; seven_x,seven_y,seven_z = [],[],[]
>>> eight_x,eight_y,eight_z = [],[],[]; nine_x,nine_y,nine_z = [],[],[]
>>> for i in range(len(reduced_X3D)):
... if y[i]==10:
... continue
... elif y[i] == 0:
... zero_x.append(reduced_X3D[i][0])
... zero_y.append(reduced_X3D[i][1])
... zero_z.append(reduced_X3D[i][2])
... elif y[i] == 1:
... one_x.append(reduced_X3D[i][0])
... one_y.append(reduced_X3D[i][1])
... one_z.append(reduced_X3D[i][2])
... elif y[i] == 2:
... two_x.append(reduced_X3D[i][0])
... two_y.append(reduced_X3D[i][1])
... two_z.append(reduced_X3D[i][2])
... elif y[i] == 3:
... three_x.append(reduced_X3D[i][0])
... three_y.append(reduced_X3D[i][1])
... three_z.append(reduced_X3D[i][2])
... elif y[i] == 4:
... four_x.append(reduced_X3D[i][0])
... four_y.append(reduced_X3D[i][1])
... four_z.append(reduced_X3D[i][2])
... elif y[i] == 5:
... five_x.append(reduced_X3D[i][0])
... five_y.append(reduced_X3D[i][1])
... five_z.append(reduced_X3D[i][2])
... elif y[i] == 6:
... six_x.append(reduced_X3D[i][0])
... six_y.append(reduced_X3D[i][1])
... six_z.append(reduced_X3D[i][2])
... elif y[i] == 7:
... seven_x.append(reduced_X3D[i][0])
... seven_y.append(reduced_X3D[i][1])
... seven_z.append(reduced_X3D[i][2])
... elif y[i] == 8:
... eight_x.append(reduced_X3D[i][0])
... eight_y.append(reduced_X3D[i][1])
... eight_z.append(reduced_X3D[i][2])
... elif y[i] == 9:
... nine_x.append(reduced_X3D[i][0])
... nine_y.append(reduced_X3D[i][1])
... nine_z.append(reduced_X3D[i][2])
# 3- Dimensional plot
>>> from mpl_toolkits.mplot3d import Axes3D
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> ax.scatter(zero_x, zero_y,zero_z, c='r', marker='x',label='zero')
>>> ax.scatter(one_x, one_y,one_z, c='g', marker='+',label='one')
>>> ax.scatter(two_x, two_y,two_z, c='b', marker='s',label='two')
>>> ax.scatter(three_x, three_y,three_z, c='m', marker='*',label='three')
>>> ax.scatter(four_x, four_y,four_z, c='c', marker='h',label='four')
>>> ax.scatter(five_x, five_y,five_z, c='r', marker='D',label='five')
>>> ax.scatter(six_x, six_y,six_z, c='y', marker='8',label='six')
>>> ax.scatter(seven_x, seven_y,seven_z, c='k', marker='*',label='seven')
>>> ax.scatter(eight_x, eight_y,eight_z, c='r', marker='x',label='eight')
>>> ax.scatter(nine_x, nine_y,nine_z, c='b', marker='D',label='nine')
>>> ax.set_xlabel('PC 1')
>>> ax.set_ylabel('PC 2')
>>> ax.set_zlabel('PC 3')
>>> plt.legend(loc='upper left', numpoints=1, ncol=3, fontsize=10, bbox_to_anchor=(0, 0))
>>> plt.show()

与 R 图等其他软件绘图相比,matplotlib 图形有一个巨大的优势。它们是交互式的,这意味着我们可以旋转它们并从不同的角度观察它们的外观。我们鼓励读者通过旋转和探索来观察图形。在 3D 图中,我们可以看到类似的情况,并得到更多的解释。数字2位于最左边,数字0位于图形的下方。而数字4位于右上角,数字6似乎更靠近PC 1轴。通过这种方式,我们可以可视化并观察数字如何分布。在使用 4 个主成分时,我们需要使用子图并分别进行可视化。
选择提取多少主成分是无监督学习中的一个开放性问题,但有一些方法可以得到一个大致的估计。我们可以通过两种方式来确定聚类的数量:
-
检查总方差解释是否在边际上开始减少
-
总方差解释大于 80%
以下代码提供了随着主成分数目变化,所解释的总方差。随着主成分数目的增加,能够解释的方差也会增多。但挑战在于尽可能限制主成分的数量,这可以通过限制方差解释的边际增量开始下降来实现。
# Choosing number of Principal Components
>>> max_pc = 30
>>> pcs = []
>>> totexp_var = []
>>> for i in range(max_pc):
... pca = PCA(n_components=i+1)
... reduced_X = pca.fit_transform(X_scale)
... tot_var = pca.explained_variance_ratio_.sum()
... pcs.append(i+1)
... totexp_var.append(tot_var)
>>> plt.plot(pcs,totexp_var,'r')
>>> plt.plot(pcs,totexp_var,'bs')
>>> plt.xlabel('No. of PCs',fontsize = 13)
>>> plt.ylabel('Total variance explained',fontsize = 13)
>>> plt.xticks(pcs,fontsize=13)
>>> plt.yticks(fontsize=13)
>>> plt.show()

从之前的图中可以看到,总方差解释度在 10 个主成分(PCA)时略微减少;而 21 个主成分时,总方差解释度大于 80%。选择哪个值取决于业务和用户的需求。
应用于手写数字数据的 PCA 的 R 代码如下:
# PCA
digits_data = read.csv("digitsdata.csv")
remove_cols = c("target")
x_data = digits_data[,!(names(digits_data) %in% remove_cols)]
y_data = digits_data[,c("target")]
# Normalizing the data
normalize <- function(x) {return((x - min(x)) / (max(x) - min(x)))}
data_norm <- as.data.frame(lapply(x_data, normalize))
data_norm <- replace(data_norm, is.na(data_norm), 0.0)
# Extracting Principal Components
pr_out =prcomp(data_norm)
pr_components_all = pr_out$x
# 2- Dimensional PCA
K_prcomps = 2
pr_components = pr_components_all[,1:K_prcomps]
pr_components_df = data.frame(pr_components)
pr_components_df = cbind(pr_components_df,digits_data$target)
names(pr_components_df)[K_prcomps+1] = "target"
out <- split( pr_components_df , f = pr_components_df$target )
zero_df = out$`0`;one_df = out$`1`;two_df = out$`2`; three_df = out$`3`; four_df = out$`4`
five_df = out$`5`;six_df = out$`6`;seven_df = out$`7`;eight_df = out$`8`;nine_df = out$`9`
library(ggplot2)
# Plotting 2-dimensional PCA
ggplot(pr_components_df, aes(x = PC1, y = PC2, color = factor(target,labels = c("zero","one","two", "three","four", "five","six","seven","eight","nine")))) +
geom_point()+ggtitle("2-D PCA on Digits Data") +
labs(color = "Digtis")
# 3- Dimensional PCA
# Plotting 3-dimensional PCA
K_prcomps = 3
pr_components = pr_components_all[,1:K_prcomps]
pr_components_df = data.frame(pr_components)
pr_components_df = cbind(pr_components_df,digits_data$target)
names(pr_components_df)[K_prcomps+1] = "target"
pr_components_df$target = as.factor(pr_components_df$target)
out <- split( pr_components_df , f = pr_components_df$target )
zero_df = out$`0`;one_df = out$`1`;two_df = out$`2`; three_df = out$`3`; four_df = out$`4`
five_df = out$`5`;six_df = out$`6`;seven_df = out$`7`;eight_df = out$`8`;nine_df = out$`9`
library(scatterplot3d)
colors <- c("darkred", "darkseagreen4", "deeppink4", "greenyellow", "orange", "navyblue", "red", "tan3", "steelblue1", "slateblue")
colors <- colors[as.numeric(pr_components_df$target)]
s3d = scatterplot3d(pr_components_df[,1:3], pch = 16, color=colors,
xlab = "PC1",ylab = "PC2",zlab = "PC3",col.grid="lightblue",main = "3-D PCA on Digits Data")
legend(s3d$xyz.convert(3.1, 0.1, -3.5), pch = 16, yjust=0,
legend = levels(pr_components_df$target),col =colors,cex = 1.1,xjust = 0)
# Choosing number of Principal Components
pr_var =pr_out$sdev ²
pr_totvar = pr_var/sum(pr_var)
plot(cumsum(pr_totvar), xlab="Principal Component", ylab ="Cumilative Prop. of Var.", ylim=c(0,1),type="b",main = "PCAs vs. Cum prop of Var Explained")
奇异值分解 - SVD
许多 PCA 实现使用奇异值分解来计算特征向量和特征值。SVD 通过以下方程给出:


U 的列称为数据矩阵的左奇异向量,V 的列是其右奇异向量,
的对角线条目是其奇异值。左奇异向量是协方差矩阵的特征向量,
的对角元素是协方差矩阵特征值的平方根。
在进行 SVD 之前,了解一些 SVD 的优点和重要点是明智的:
-
SVD 可以应用于矩形矩阵;而特征值仅定义于方阵。通过 SVD 方法获得的特征值等价物称为奇异值,得到的与特征向量等价的向量称为奇异向量。然而,由于它们本质上是矩形的,我们需要分别为它们的维度计算左奇异向量和右奇异向量。
-
如果矩阵 A 的特征向量矩阵 P 不可逆,那么 A 没有特征分解。然而,如果 A 是一个 m x n 的实矩阵且 m > n,则 A 可以通过奇异值分解表示。
-
U 和 V 都是正交矩阵,这意味着 U^T U = I(其中 I 是 m x m 的单位矩阵)或 V^T V = I(这里 I 是 n x n 的单位矩阵),两个单位矩阵的维度可能不同。
-
是一个具有 m x n 维度的非负对角矩阵。
然后,奇异值和奇异向量的计算通过以下一组方程式完成:

在第一阶段,使用方程计算奇异值/特征值。一旦获得奇异值/特征值,我们将代入计算 V 或右奇异/特征向量:

一旦我们获得了右奇异向量和对角值,我们将代入公式以获得左奇异向量 U,公式如下:

这样,我们将计算原始方程矩阵的奇异值分解。
使用 scikit-learn 对手写数字应用 SVD
可以对相同的手写数字数据应用 SVD,以进行技术的“苹果对苹果”比较。
# SVD
>>> import matplotlib.pyplot as plt
>>> from sklearn.datasets import load_digits
>>> digits = load_digits()
>>> X = digits.data
>>> y = digits.target
在下面的代码中,使用了 15 个奇异向量和 300 次迭代,但我们鼓励读者修改这些值并检查 SVD 的性能。我们使用了两种类型的 SVD 函数,其中randomized_svd函数提供了原始矩阵的分解,而TruncatedSVD函数则可以提供总方差解释比例。在实际应用中,用户可能不需要查看所有的分解,只需使用TruncatedSVD函数来满足实际需求。
>>> from sklearn.utils.extmath import randomized_svd
>>> U,Sigma,VT = randomized_svd(X,n_components=15,n_iter=300,random_state=42)
>>> import pandas as pd
>>> VT_df = pd.DataFrame(VT)
>>> print ("\nShape of Original Matrix:",X.shape)
>>> print ("\nShape of Left Singular vector:",U.shape)
>>> print ("Shape of Singular value:",Sigma.shape)
>>> print ("Shape of Right Singular vector",VT.shape)

从之前的截图中,我们可以看到,原始矩阵(维度为 1797 x 64)已经被分解为左奇异向量(1797 x 15)、奇异值(15 维的对角矩阵)和右奇异向量(15 x 64)。我们可以通过按顺序乘以这三种矩阵来恢复原始矩阵。
>>> n_comps = 15
>>> from sklearn.decomposition import TruncatedSVD
>>> svd = TruncatedSVD(n_components=n_comps, n_iter=300, random_state=42)
>>> reduced_X = svd.fit_transform(X)
>>> print("\nTotal Variance explained for %d singular features are %0.3f"%(n_comps, svd.explained_variance_ratio_.sum()))

对于 15 个奇异值特征,总方差解释为 83.4%。但是读者需要更改不同的值,以决定最优值。
以下代码说明了奇异值个数变化时总方差解释的变化:
# Choosing number of Singular Values
>>> max_singfeat = 30
>>> singfeats = []
>>> totexp_var = []
>>> for i in range(max_singfeat):
... svd = TruncatedSVD(n_components=i+1, n_iter=300, random_state=42)
... reduced_X = svd.fit_transform(X)
... tot_var = svd.explained_variance_ratio_.sum()
... singfeats.append(i+1)
... totexp_var.append(tot_var)
>>> plt.plot(singfeats,totexp_var,'r')
>>> plt.plot(singfeats,totexp_var,'bs')
>>> plt.xlabel('No. of Features',fontsize = 13)
>>> plt.ylabel('Total variance explained',fontsize = 13)
>>> plt.xticks(pcs,fontsize=13)
>>> plt.yticks(fontsize=13)
>>> plt.show()

从之前的图中,我们可以根据需求选择 8 个或 15 个奇异向量。
应用于手写数字数据的 SVD 的 R 代码如下:
#SVD
library(svd)
digits_data = read.csv("digitsdata.csv")
remove_cols = c("target")
x_data = digits_data[,!(names(digits_data) %in% remove_cols)]
y_data = digits_data[,c("target")]
sv2 <- svd(x_data,nu=15)
# Computing the square of the singular values, which can be thought of as the vector of matrix energy
# in order to pick top singular values which preserve at least 80% of variance explained
energy <- sv2$d ^ 2
tot_varexp = data.frame(cumsum(energy) / sum(energy))
names(tot_varexp) = "cum_var_explained"
tot_varexp$K_value = 1:nrow(tot_varexp)
plot(tot_varexp[,2],tot_varexp[,1],type = 'o',xlab = "K_Value",ylab = "Prop. of Var Explained")
title("SVD - Prop. of Var explained with K-value")
深度自编码器
自编码神经网络是一种无监督学习算法,它通过反向传播设置目标值,使其等于输入值 y^((i)) = x^((i))。自编码器试图学习一个函数 hw,b ≈ x,这意味着它试图学习一个近似的恒等函数,使得输出
与 x 类似。

尽管试图复制恒等函数看似一个简单的任务,通过在网络上施加约束(例如限制隐藏单元的数量),我们可以发现数据中的有趣结构。假设输入的图像大小为 10 x 10 像素,具有强度值,总共有 100 个输入值,第二层(隐藏层)中的神经元数量为 50 个,最终输出层有 100 个神经元,因为我们需要将图像传递并映射到自身,在此过程中,我们将迫使网络学习输入的压缩表示,即隐藏单元激活 a^((2)) ε R¹⁰⁰,通过这个表示,我们必须尝试重建这 100 个像素的输入 x。如果输入数据完全随机,且没有任何相关性等,那么压缩将非常困难;而如果潜在的数据具有某些相关性或可检测的结构,那么这个算法将能够发现这些相关性并紧凑地表示它们。实际上,自编码器通常最终会学习到与 PCA 非常相似的低维表示。
使用编码器-解码器架构的模型构建技术
训练自编码器模型有点复杂,因此提供了详细的说明以便更好地理解。在训练阶段,整个编码器-解码器部分会针对相同的输入进行训练,作为解码器的输出。为了实现所需的输出,特征将在中间层进行压缩,因为我们通过了收敛和发散层。一旦通过多次迭代减少错误值完成足够的训练,就可以使用训练好的编码器部分来为下一阶段的建模创建潜在特征,或进行可视化等。
在下图中,展示了一个样本。输入层和输出层有五个神经元,而中间部分的神经元数量逐渐减少。压缩层只有两个神经元,即我们希望从数据中提取的潜在维度的数量。

下图展示了如何使用训练过的编码器部分,从新的输入数据中创建潜在特征,这些特征可以用于可视化或模型的下一个阶段:

使用 Keras 应用在手写数字上的深度自编码器
深度自编码器使用相同的手写数字数据进行说明,目的是展示这种非线性方法与 PCA 和 SVD 等线性方法的区别。一般来说,非线性方法的表现要好得多,但这些方法类似黑盒模型,我们无法得知其背后的解释。本文中使用了 Keras 软件来构建深度自编码器,因为它们像乐高积木一样工作,用户可以通过不同的模型架构和参数组合进行实验,以便更好地理解:
# Deep Auto Encoders
>>> import matplotlib.pyplot as plt
>>> from sklearn.preprocessing import StandardScaler
>>> from sklearn.datasets import load_digits
>>> digits = load_digits()
>>> X = digits.data
>>> y = digits.target
>>> print (X.shape)
>>> print (y.shape)
>>> x_vars_stdscle = StandardScaler().fit_transform(X)
>>> print (x_vars_stdscle.shape)

使用 Keras 的密集神经元模块构建编码器-解码器架构:
>>> from keras.layers import Input,Dense
>>> from keras.models import Model

本文使用了 NVIDIA GTX 1060 的 GPU,并安装了cuDNN和CNMeM库,以进一步提升性能,使其比常规 GPU 性能提高 4x-5x。这些库利用了 20%的 GPU 内存,剩下的 80%内存用于处理数据。用户需要注意,如果他们使用的是内存较小的 GPU,如 3GB 到 4GB 的显卡,可能无法充分利用这些库。
读者需要考虑一个重要的点,那就是,Keras 代码的语法在 CPU 模式和 GPU 模式下是相同的。
以下几行代码是模型的核心。输入数据有 64 列,我们需要将这些列作为层的输入,因此我们给出了 64 的形状。此外,每一层的神经网络都被赋予了名称,稍后我们会在代码的后续部分解释这样做的原因。在第一隐藏层中,使用了 32 个全连接神经元,这意味着输入层的所有 64 个输入都连接到第一隐藏层的 32 个神经元。整个维度流动是 64, 32, 16, 2, 16, 32, 64。我们将输入压缩到两个神经元,以便在 2D 图上绘制分量;而如果需要绘制 3D 数据(我们将在下一部分讨论),则需要将隐藏层的层数从 2 改为 3。训练完成后,我们需要使用编码器部分来预测输出。
# 2-Dimensional Architecture
>>> input_layer = Input(shape=(64,),name="input")
>>> encoded = Dense(32, activation='relu',name="h1encode")(input_layer)
>>> encoded = Dense(16, activation='relu',name="h2encode")(encoded)
>>> encoded = Dense(2, activation='relu',name="h3latent_layer")(encoded)
>>> decoded = Dense(16, activation='relu',name="h4decode")(encoded)
>>> decoded = Dense(32, activation='relu',name="h5decode")(decoded)
>>> decoded = Dense(64, activation='sigmoid',name="h6decode")(decoded)
为了训练模型,我们需要传递架构的起点和终点。在以下代码中,我们提供了输入层为input_layer,输出为decoded,即最后一层(名称为h6decode):
>>> autoencoder = Model(input_layer, decoded)
使用了 Adam 优化算法来优化均方误差,因为我们希望在网络的输出层末端重现原始输入:
>>> autoencoder.compile(optimizer="adam", loss="mse")
网络使用了 100 个周期进行训练,每个批次的批量大小为 256 个观察值。使用了 20%的验证集来检查随机选取的验证数据的准确性,以确保模型的鲁棒性,因为如果只在训练数据上训练,可能会导致过拟合问题,这在高度非线性模型中非常常见:
# Fitting Encoder-Decoder model
>>> autoencoder.fit(x_vars_stdscle, x_vars_stdscle, epochs=100,batch_size=256, shuffle=True,validation_split= 0.2 )

从之前的结果可以看出,模型已经在 1,437 个训练样本上进行了训练,并在 360 个验证样本上进行了验证。从损失值来看,训练损失和验证损失分别从 1.2314 降至 0.9361 和从 1.0451 降至 0.7326。因此,我们正在朝着正确的方向前进。然而,建议读者尝试不同的架构、迭代次数、批次大小等,看看准确度还能进一步提高多少。
一旦编码器-解码器部分训练完成,我们只需要使用编码器部分来压缩输入特征,以获得压缩的潜在特征,这也是降维的核心思想!在以下代码中,我们构建了一个新的模型,包含了训练好的输入层和中间隐藏层(h3latent_layer)。这就是为每一层网络分配名称的原因。
# Extracting Encoder section of the Model for prediction of latent variables
>>> encoder = Model(autoencoder.input,autoencoder.get_layer("h3latent_layer").output)
Extracted encoder section of the whole model used for prediction of input variables to generate sparse 2-dimensional representation, which is being performed with the following code
# Predicting latent variables with extracted Encoder model
>>> reduced_X = encoder.predict(x_vars_stdscle)
仅检查降维后的输入变量的维度,可以看到,对于所有观察值,我们可以看到两个维度或两个列向量:
>>> print (reduced_X.shape)

以下代码部分非常类似于 2D 主成分分析(PCA):
>>> zero_x, zero_y = [],[] ; one_x, one_y = [],[]
>>> two_x,two_y = [],[]; three_x, three_y = [],[]
>>> four_x,four_y = [],[]; five_x,five_y = [],[]
>>> six_x,six_y = [],[]; seven_x,seven_y = [],[]
>>> eight_x,eight_y = [],[]; nine_x,nine_y = [],[]
# For 2-Dimensional data
>>> for i in range(len(reduced_X)):
... if y[i] == 0:
... zero_x.append(reduced_X[i][0])
... zero_y.append(reduced_X[i][1])
... elif y[i] == 1:
... one_x.append(reduced_X[i][0])
... one_y.append(reduced_X[i][1])
... elif y[i] == 2:
... two_x.append(reduced_X[i][0])
... two_y.append(reduced_X[i][1])
... elif y[i] == 3:
... three_x.append(reduced_X[i][0])
... three_y.append(reduced_X[i][1])
... elif y[i] == 4:
... four_x.append(reduced_X[i][0])
... four_y.append(reduced_X[i][1])
... elif y[i] == 5:
... five_x.append(reduced_X[i][0])
... five_y.append(reduced_X[i][1])
... elif y[i] == 6:
... six_x.append(reduced_X[i][0])
... six_y.append(reduced_X[i][1])
... elif y[i] == 7:
... seven_x.append(reduced_X[i][0])
... seven_y.append(reduced_X[i][1])
... elif y[i] == 8:
... eight_x.append(reduced_X[i][0])
... eight_y.append(reduced_X[i][1])
... elif y[i] == 9:
... nine_x.append(reduced_X[i][0])
... nine_y.append(reduced_X[i][1])
>>> zero = plt.scatter(zero_x, zero_y, c='r', marker='x',label='zero')
>>> one = plt.scatter(one_x, one_y, c='g', marker='+')
>>> two = plt.scatter(two_x, two_y, c='b', marker='s')
>>> three = plt.scatter(three_x, three_y, c='m', marker='*')
>>> four = plt.scatter(four_x, four_y, c='c', marker='h')
>>> five = plt.scatter(five_x, five_y, c='r', marker='D')
>>> six = plt.scatter(six_x, six_y, c='y', marker='8')
>>> seven = plt.scatter(seven_x, seven_y, c='k', marker='*')
>>> eight = plt.scatter(eight_x, eight_y, c='r', marker='x')
>>> nine = plt.scatter(nine_x, nine_y, c='b', marker='D')
>>> plt.legend((zero,one,two,three,four,five,six,seven,eight,nine),
... ('zero','one','two','three','four','five','six','seven','eight','nine'),
... scatterpoints=1,loc='lower right',ncol=3,fontsize=10)
>>> plt.xlabel('Latent Feature 1',fontsize = 13)
>>> plt.ylabel('Latent Feature 2',fontsize = 13)
>>> plt.show()

从之前的图中我们可以看到,数据点已经很好地分离,但问题在于视角方向,因为这些特征并没有像主成分那样沿彼此垂直的维度变化。在深度自编码器中,我们需要将视角方向从(0, 0)改变,以便可视化这种非线性分类,接下来我们将在 3D 案例中详细展示这一点。
以下是 3D 潜在特征的代码。除了h3latent_layer中的值需要从2替换为3外,其余代码保持不变,因为这是编码器部分的结束,我们将在创建潜在特征时使用它,最终它将用于绘图。
# 3-Dimensional architecture
>>> input_layer = Input(shape=(64,),name="input")
>>> encoded = Dense(32, activation='relu',name="h1encode")(input_layer)
>>> encoded = Dense(16, activation='relu',name="h2encode")(encoded)
>>> encoded = Dense(3, activation='relu',name="h3latent_layer")(encoded)
>>> decoded = Dense(16, activation='relu',name="h4decode")(encoded)
>>> decoded = Dense(32, activation='relu',name="h5decode")(decoded)
>>> decoded = Dense(64, activation='sigmoid',name="h6decode")(decoded)
>>> autoencoder = Model(input_layer, decoded)
autoencoder.compile(optimizer="adam", loss="mse")
# Fitting Encoder-Decoder model
>>> autoencoder.fit(x_vars_stdscle, x_vars_stdscle, epochs=100,batch_size=256, shuffle=True,validation_split= 0.2)

从之前的结果我们可以看到,加入三维度而不是二维度后,得到的损失值比 2D 用例中的值要低。对于两个潜在因子的训练和验证损失,在 100 轮迭代后的损失分别为 0.9061 和 0.7326,而对于三个潜在因子,在 100 轮迭代后的损失为 0.8032 和 0.6464。这表明,增加一个维度可以显著减少误差。这样,读者可以改变不同的参数,确定适合降维的理想架构:
# Extracting Encoder section of the Model for prediction of latent variables
>>> encoder = Model(autoencoder.input,autoencoder.get_layer("h3latent_layer").output)
# Predicting latent variables with extracted Encoder model
>>> reduced_X3D = encoder.predict(x_vars_stdscle)
>>> zero_x, zero_y,zero_z = [],[],[] ; one_x, one_y,one_z = [],[],[]
>>> two_x,two_y,two_z = [],[],[]; three_x, three_y,three_z = [],[],[]
>>> four_x,four_y,four_z = [],[],[]; five_x,five_y,five_z = [],[],[]
>>> six_x,six_y,six_z = [],[],[]; seven_x,seven_y,seven_z = [],[],[]
>>> eight_x,eight_y,eight_z = [],[],[]; nine_x,nine_y,nine_z = [],[],[]
>>> for i in range(len(reduced_X3D)):
... if y[i]==10:
... continue
... elif y[i] == 0:
... zero_x.append(reduced_X3D[i][0])
... zero_y.append(reduced_X3D[i][1])
... zero_z.append(reduced_X3D[i][2])
... elif y[i] == 1:
... one_x.append(reduced_X3D[i][0])
... one_y.append(reduced_X3D[i][1])
... one_z.append(reduced_X3D[i][2])
... elif y[i] == 2:
... two_x.append(reduced_X3D[i][0])
... two_y.append(reduced_X3D[i][1])
... two_z.append(reduced_X3D[i][2])
... elif y[i] == 3:
... three_x.append(reduced_X3D[i][0])
... three_y.append(reduced_X3D[i][1])
... three_z.append(reduced_X3D[i][2])
... elif y[i] == 4:
... four_x.append(reduced_X3D[i][0])
... four_y.append(reduced_X3D[i][1])
... four_z.append(reduced_X3D[i][2])
... elif y[i] == 5:
... five_x.append(reduced_X3D[i][0])
... five_y.append(reduced_X3D[i][1])
... five_z.append(reduced_X3D[i][2])
... elif y[i] == 6:
... six_x.append(reduced_X3D[i][0])
... six_y.append(reduced_X3D[i][1])
... six_z.append(reduced_X3D[i][2])
... elif y[i] == 7:
... seven_x.append(reduced_X3D[i][0])
... seven_y.append(reduced_X3D[i][1])
... seven_z.append(reduced_X3D[i][2])
... elif y[i] == 8:
... eight_x.append(reduced_X3D[i][0])
... eight_y.append(reduced_X3D[i][1])
... eight_z.append(reduced_X3D[i][2])
... elif y[i] == 9:
... nine_x.append(reduced_X3D[i][0])
... nine_y.append(reduced_X3D[i][1])
... nine_z.append(reduced_X3D[i][2])
# 3- Dimensional plot
>>> from mpl_toolkits.mplot3d import Axes3D
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> ax.scatter(zero_x, zero_y,zero_z, c='r', marker='x',label='zero')
>>> ax.scatter(one_x, one_y,one_z, c='g', marker='+',label='one')
>>> ax.scatter(two_x, two_y,two_z, c='b', marker='s',label='two')
>>> ax.scatter(three_x, three_y,three_z, c='m', marker='*',label='three')
>>> ax.scatter(four_x, four_y,four_z, c='c', marker='h',label='four')
>>> ax.scatter(five_x, five_y,five_z, c='r', marker='D',label='five')
>>> ax.scatter(six_x, six_y,six_z, c='y', marker='8',label='six')
>>> ax.scatter(seven_x, seven_y,seven_z, c='k', marker='*',label='seven')
>>> ax.scatter(eight_x, eight_y,eight_z, c='r', marker='x',label='eight')
>>> ax.scatter(nine_x, nine_y,nine_z, c='b', marker='D',label='nine')
>>> ax.set_xlabel('Latent Feature 1',fontsize = 13)
>>> ax.set_ylabel('Latent Feature 2',fontsize = 13)
>>> ax.set_zlabel('Latent Feature 3',fontsize = 13)
>>> ax.set_xlim3d(0,60)
>>> plt.legend(loc='upper left', numpoints=1, ncol=3, fontsize=10, bbox_to_anchor=(0, 0))
>>> plt.show()

深度自编码器生成的 3D 图相比于三个 PCA 图提供了更好的分类分离。这里我们得到了更好的数字分离。读者需要注意的一点是,上述图是从(0, 0, 0)的旋转视角看到的,因为数据分离并不是在正交平面(如 PCA)上发生的,因此我们需要从原点的视角来看才能看到这种非线性分类。
总结
在本章中,您已经学习了多种无监督学习方法,用于识别数据中的结构和模式,使用了 k-means 聚类、PCA、SVD 和深度自编码器。同时,使用鸢尾花数据解释了 k-means 聚类算法。展示了如何根据各种性能指标选择最优的 k 值。利用来自 scikit-learn 的手写数据对比了线性方法(如 PCA 和 SVD)与非线性技术和深度自编码器之间的差异。详细介绍了 PCA 和 SVD 之间的区别,以便读者理解 SVD,它可以应用于矩形矩阵,即用户数量和产品数量不一定相等的情况。最后,通过可视化,证明了深度自编码器在分离数字方面优于 PCA 和 SVD 等线性无监督学习方法。
在下一章中,我们将讨论各种强化学习方法及其在人工智能等领域的应用。
第五章:强化学习
强化学习(RL)是继监督学习和无监督学习之后的第三大机器学习领域。这些技术在近年来在人工智能应用中获得了很大的关注。在强化学习中,需要做出顺序决策,而不是一次性决策,这在某些情况下使得训练模型变得困难。在本章中,我们将涵盖强化学习中使用的各种技术,并提供实际示例支持。虽然涵盖所有主题超出了本书的范围,但我们确实在这里涵盖了这个主题的最重要基础知识,以激发读者对这一主题产生足够的热情。本章讨论的主题包括:
-
马尔可夫决策过程
-
贝尔曼方程
-
动态规划
-
蒙特卡洛方法
-
时间差分学习
强化学习基础知识
在深入研究强化学习的细节之前,我想介绍一些理解 RL 方法的各种要素所必需的基础知识。这些基础知识将出现在本章的各个部分中,我们将在需要时详细解释:
-
环境: 这是具有状态和状态之间转换机制的任何系统。例如,机器人的环境是其操作的景观或设施。
-
代理: 这是与环境交互的自动化系统。
-
状态: 环境或系统的状态是完全描述环境的变量或特征集。
-
目标或吸收状态或终止状态: 这是提供比任何其他状态更高折现累积奖励的状态。高累积奖励可以防止最佳策略在训练过程中依赖于初始状态。每当代理达到目标时,我们将完成一个回合。
-
动作: 这定义了状态之间的转换。代理负责执行或至少推荐一个动作。执行动作后,代理从环境中收集奖励(或惩罚)。
-
策略: 这定义了在环境的任何状态下要选择和执行的动作。换句话说,策略是代理的行为;它是从状态到动作的映射。策略可以是确定性的,也可以是随机的。
-
最佳策略: 这是通过训练生成的策略。它定义了 Q 学习中的模型,并且会随着任何新的回合不断更新。
-
奖励: 这量化了代理与环境的积极或消极交互。奖励通常是代理到达每个状态时获得的即时收益。
-
回报或值函数: 值函数(也称为回报)是对每个状态未来奖励的预测。这些用于评估状态的好坏,基于这一点,代理将选择/行动以选择下一个最佳状态:

-
回合:这定义了从初始状态到目标状态所需的步骤数。回合也称为试验。
-
视野:这是在最大化奖励过程中所考虑的未来步骤或动作的数量。视野可以是无限的,在这种情况下,未来的奖励会被折扣,以便策略的价值能够收敛。
-
探索与利用:强化学习(RL)是一种试错学习方法。目标是找到最佳策略;同时,要保持警觉,探索一些未知的策略。一个经典的例子就是寻宝:如果我们只是贪婪地去已知的位置(利用),就会忽视其他可能藏有宝藏的地方(探索)。通过探索未知的状态,尽管即时奖励较低且没有失去最大奖励,我们仍然可能实现更大的目标。换句话说,我们是在逃离局部最优解,以便达到全局最优解(即探索),而不是仅仅专注于即时奖励的短期目标(即利用)。以下是几个例子来解释二者的区别:
-
餐馆选择:通过偶尔探索一些未知的餐馆,我们可能会发现比我们常去的最喜欢的餐馆更好的餐馆:
-
利用:去你最喜欢的餐馆
-
探索:尝试一家新的餐馆
-
-
油井钻探示例:通过探索新的未开发地点,我们可能会获得比仅仅探索相同地点更有益的新见解:
-
利用:在已知最佳地点钻探石油
-
探索:在新地点钻探
-
-
-
状态值与状态-动作函数:在动作值中,Q 代表一个智能体在状态S下采取动作A并根据某一策略π(a|s)(即在给定状态下采取某一动作的概率)之后预期获得的回报(累计折扣奖励)。
在状态值中,值是智能体在状态s下根据策略π(a|s)行为所预期获得的回报。更具体地说,状态值是基于策略下各动作值的期望:

-
策略内学习与策略外学习的时间差分控制:策略外学习者独立于智能体的行动学习最优策略的值。Q 学习是一个策略外学习者。策略内学习者则学习智能体执行的策略值,包括探索步骤。
-
预测与控制问题:预测是指根据给定策略评估我的表现:也就是说,如果有人给我一个策略,我执行它后能获得多少奖励。而在控制中,问题是找到最佳策略,以便我能够最大化奖励。
-
预测:评估在给定策略下各状态的值。
对于均匀随机策略,所有状态的价值函数是多少?
- 控制: 通过找到最佳策略来优化未来。
最优价值函数是什么,如何在所有可能的策略中找到最优策略?
通常,在强化学习中,我们需要先解决预测问题,之后才能解决控制问题,因为我们需要找出所有策略,才能找出最佳或最优的策略。
-
RL 智能体分类: 一个 RL 智能体包括以下一个或多个组件:
-
策略: 智能体的行为函数(从状态到动作的映射);策略可以是确定性的或随机的
-
价值函数: 每个状态的好坏(或)每个状态的未来奖励预期值预测
-
模型: 智能体对环境的表征。模型预测环境接下来会做什么:
- 转移: p 预测下一个状态(即动态):
-

-
-
- 奖励: R 预测下一个(即时)奖励
-

让我们通过基于策略和值的组合以及用以下迷宫示例来解释 RL 智能体分类中的各种可能类别。在以下迷宫中,你既有起点,也有目标;智能体需要尽快到达目标,选择一条路径以获得最大的总奖励和最小的总负奖励。这个问题主要可以通过五种类别的方式来解决:
-
基于价值
-
基于策略
-
Actor critic
-
无模型
-
基于模型

类别 1 - 基于价值
价值函数看起来像图像的右侧(折扣未来奖励的总和),其中每个状态都有一个值。假设距离目标一步的状态值为-1;距离目标两步的状态值为-2。类似地,起始点的值为-16。如果智能体卡在错误的位置,值可能达到-24。事实上,智能体确实根据最佳值在网格中移动,以到达目标。例如,智能体处于值为-15 的状态。在这里,它可以选择向北或向南移动,因此由于高奖励,它选择向北移动(-14),而不是向南移动(值为-16)。通过这种方式,智能体选择它在网格中的路径,直到到达目标。
-
价值函数:在所有状态下仅定义值
-
无策略(隐式): 没有专门的策略;策略根据每个状态的值来选择

类别 2 - 基于策略
以下图中的箭头表示智能体在这些状态中选择的下一个移动方向。例如,智能体首先向东移动,然后向北移动,沿着所有箭头直到目标被达成。这也被称为从状态到动作的映射。一旦我们有了这个映射,智能体只需要读取它并相应地行动。
-
策略:策略或箭头,通过调整这些策略来达到最大可能的未来奖励。顾名思义,只有策略被存储并优化,以最大化奖励。
-
无价值函数:状态没有对应的价值。

第三类 - Actor-Critic
在 Actor-Critic 中,我们有策略和价值函数(或价值基和策略基的结合)。这种方法融合了两者的优点:
-
策略
-
价值函数
第四类 - 无模型
在强化学习中,一个基本的区分是是否基于模型或无模型。在无模型方法中,我们并未显式地建模环境,或者我们不了解完整环境的所有动态。相反,我们直接通过策略或价值函数来获得经验,并了解策略如何影响奖励:
-
策略和/或价值函数
- 无模型
第五类 - 基于模型
在基于模型的强化学习中,我们首先建立整个环境的动态:
-
策略和/或价值函数
-
模型
经过上述所有类别后,以下维恩图展示了强化学习智能体的分类法的整体框架。如果你拿起任何关于强化学习的论文,这些方法都可以适应这个框架中的任意部分。

顺序决策中的基本类别
顺序决策中有两种基本的类型问题:
-
强化学习(例如,自主直升机等):
-
环境最初是未知的
-
智能体与环境交互并从环境中获得策略、奖励和价值
-
智能体改善其策略
-
-
规划(例如,象棋、Atari 游戏等):
-
环境模型或完整的环境动态已知
-
智能体通过其模型进行计算(无需任何外部交互)
-
智能体改善其策略
-
这些问题也被称为推理、搜索、自省等问题
-
尽管前述的两类可以根据具体问题结合在一起,但这基本上是两种设置类型的宏观视角。
马尔可夫决策过程与贝尔曼方程
马尔可夫决策过程(MDP)正式描述了强化学习中的环境。其定义如下:
-
环境是完全可观察的
-
当前状态完全表征过程(这意味着未来状态完全依赖于当前状态,而不是历史状态或历史值)
-
几乎所有的强化学习问题都可以形式化为 MDP(例如,最优控制主要处理连续的 MDP)
MDP 的核心思想: MDP 基于状态的简单马尔可夫性属性工作;例如,S[t+1] 完全依赖于最新的状态 S[t],而不是任何历史依赖关系。在以下方程中,当前状态捕获了来自历史的所有相关信息,这意味着当前状态是未来的充分统计量:

这个特性可以通过自主直升机示例来直观解释:下一步,直升机将向右、向左、俯仰或滚动,等等,完全取决于直升机当前的位置,而不是五分钟前的位置。
MDP 的建模: 强化学习问题通过 MDP 的五元组(S, A, {P[sa]}, y, R)来建模世界
-
S - 状态集(直升机可能的所有朝向)
-
A - 动作集(可以拉动控制杆的所有可能位置的集合)
-
P[sa] - 状态转移分布(或状态转移概率分布)提供从一个状态到另一个状态的转移及所需的相应概率,供马尔可夫过程使用:


- γ - 折扣因子:

- R - 奖励函数(将状态集映射为实数,可以是正数或负数):

返回是通过折扣未来奖励计算的,直到到达终止状态为止。
贝尔曼方程在 MDP 中的应用: 贝尔曼方程用于 MDP 的数学公式化,解决这些方程可以获得环境的最优策略。贝尔曼方程也被称为动态规划方程,它是与数学优化方法——动态规划——相关的最优性所必需的条件。贝尔曼方程是线性方程,可以解出整个环境的解。然而,求解这些方程的时间复杂度是 O (n³),当环境中的状态数非常大时,计算开销会变得非常昂贵;有时,由于环境本身非常大,探索所有状态也不可行。在这种情况下,我们需要寻找其他问题求解方法。
在贝尔曼方程中,价值函数可以分解为两部分:
-
从后续状态你将得到的即时奖励 R[t+1]
-
从那个时间步开始,你将获得的后续状态的折现值 yv(S[t+1]):






MDP 的网格世界示例: 机器人导航任务生活在以下类型的网格世界中。一个障碍物位于单元格(2,2),机器人无法穿越该单元格。我们希望机器人移动到右上角的单元格(4,3),当它到达该位置时,机器人将获得+1 的奖励。机器人应该避免进入单元格(4,2),因为如果进入该单元格,它将获得-1 的奖励。

机器人可以处于以下任何位置:
-
11 个状态 - (除了 (2,2) 这个格子,那里有一个障碍物阻挡了机器人)
-
A =
在现实世界中,机器人的运动是嘈杂的,机器人可能无法精确地移动到它被要求到达的地方。例如,它的一些轮子可能打滑,部件可能连接松动,执行器可能不正确,等等。当要求它移动 1 米时,机器人可能无法精确地移动 1 米;它可能只会移动 90-105 厘米,等等。
在一个简化的网格世界中,机器人的随机动态可以如下建模。如果我们命令机器人向北移动,机器人有 10% 的概率会被拉向左侧,10% 的概率会被拉向右侧。只有 80% 的概率它才会真正向北移动。当机器人碰到墙壁(包括障碍物)并停留在原地时,什么也不会发生:

这个网格世界示例中的每个状态都由 (x, y) 坐标表示。假设机器人位于状态 (3,1),如果我们让它向北移动,则状态转移概率矩阵如下:





机器人停留在原地的概率为 0。
如我们所知,所有状态转移概率的总和等于 1:

奖励函数:



对于所有其他状态,有一些小的负奖励值,这意味着在网格中跑来跑去时会扣除机器人的电池或燃料消耗,这就产生了不浪费移动或时间的解决方案,同时尽可能快速地达到奖励 +1 的目标,这鼓励机器人以最少的燃料消耗尽可能快速地达到目标。
当机器人到达 +1 或 -1 状态时,世界结束。到达这些状态后将不再有任何奖励;这些状态可以称为吸收状态。这些是零成本的吸收状态,机器人会永远停留在那里。
MDP 工作模型:
-
在状态 S[0]
-
选择 a[0]
-
到达 S[1] ~ P[s0, a0]
-
选择 a[1]
-
到达 S[2] ~ P[s1, a1]
-
以此类推….
一段时间后,它会获得所有奖励并将其累加:


折扣因子模型化了一种经济应用,其中今天赚到的一美元比明天赚到的一美元更有价值。
机器人需要在一段时间内选择行动(a[0],a[1],a[2],......)以最大化预期回报:

在这个过程中,一个强化学习算法学习一个策略,该策略是每个状态下的行动映射,意味着这是一个推荐的行动,机器人需要根据其所处的状态来采取行动:

网格世界的最优策略: 策略是从状态到行动的映射,这意味着如果你处于某个特定状态,你需要采取这一特定行动。以下策略是最优策略,它最大化了总回报或折扣奖励的期望值。策略总是根据当前状态来决策,而不是之前的状态,这就是马尔可夫性质:

需要注意的一个问题是位置(3,1):最优策略显示应向左(西)走,而不是向北走,虽然向北走可能涉及的状态数较少;但是,我们也可能进入一个更危险的状态。因此,向左走可能需要更长时间,但可以安全到达目的地而不会陷入负面陷阱。这些类型的结论可以通过计算获得,虽然对人类而言不明显,但计算机在提出这些策略时非常擅长:
定义:V^π, V, π**
V^π = 对于任何给定的策略π,价值函数为 V^π : S -> R,使得 V^π (S) 是从状态 S 开始,执行π后的期望总回报

网格世界的随机策略: 以下是一个随机策略及其价值函数的示例。这个策略是一个相当糟糕的策略,具有负值。对于任何策略,我们都可以写出该策略的价值函数:






简单来说,Bellman 方程说明了当前状态的价值等于即时奖励和折扣因子应用于新状态(S')的期望总回报,这些回报根据它们进入这些状态的概率进行加权。
Bellman 方程用于求解策略的价值函数的闭式解,给定固定策略,如何求解价值函数方程。
Bellman 方程对价值函数施加了一组线性约束。事实证明,通过求解一组线性方程,我们可以在任何状态 S 下求解其价值函数。
Bellman 方程在网格世界问题中的示例:
为单元格 (3,1) 选择的策略是向北移动。然而,我们的系统存在随机性,大约 80%的时间它会朝着指定方向移动,而 20%的时间它会偏离,向左(10%)或向右(10%)偏移。



可以为网格中的所有 11 个 MDP 状态写出类似的方程。我们可以从中获得以下度量值,利用线性方程组的方法来解决所有未知值:
-
11 个方程
-
11 个未知值函数变量
-
11 个约束条件
这是解决一个n个变量与n个方程的问题,我们可以通过使用方程组轻松找到一个解决方案的确切形式,进而得到整个网格的 V (π)的精确解,网格包含了所有的状态。
动态规划
动态规划是一种通过将复杂问题分解为子问题并逐一解决它们来顺序求解问题的方法。一旦子问题解决,它就会将这些子问题的解组合起来解决原始的复杂问题。在强化学习中,动态规划是一种在环境的完美模型作为马尔可夫决策过程(MDP)下计算最优策略的方法论。
动态规划适用于具有以下两个性质的问题。事实上,MDP 满足这两个性质,这使得动态规划非常适合通过求解贝尔曼方程来解决它们:
-
最优子结构
-
最优性原理适用
-
最优解可以分解成子问题
-
-
子问题重叠
-
子问题重复多次
-
解可以被缓存和重用
-
-
MDP 满足这两个性质——幸运的是!
-
贝尔曼方程具有状态值的递归分解
-
值函数存储并重用解决方案
-
然而,经典的动态规划算法在强化学习中的应用有限,原因在于它们假设了一个完美的模型,并且计算开销较大。然而,它们依然很重要,因为它们为理解强化学习领域的所有方法提供了必要的基础。
使用动态规划计算最优策略的算法
计算 MDP 最优策略的标准算法利用了动态规划,以下是相关算法,我们将在本章后续部分详细讲解:
-
值迭代算法: 一种迭代算法,其中状态值不断迭代直到达到最优值;随后,最优值被用于确定最优策略
-
策略迭代算法: 一种迭代算法,其中策略评估和策略改进交替进行,以达到最优策略
值迭代算法: 值迭代算法之所以容易计算,是因为它仅对状态值进行迭代计算。首先,我们将计算最优值函数 V,然后将这些值代入最优策略方程,以确定最优策略。为了说明问题的规模,对于 11 个可能的状态,每个状态可以有四个策略(N-北,S-南,E-东,W-西),因此总共有 11⁴ 种可能的策略。值迭代算法包括以下步骤:
-
初始化 V(S) = 0 对于所有状态 S
-
对每个 S,更新:

- 通过反复计算步骤 2,我们最终会收敛到所有状态的最优值:

在算法的步骤 2 中,有两种更新值的方法
- 同步更新 - 通过执行同步更新(或贝尔曼备份操作符),我们将执行右侧计算并替换方程的左侧,如下所示:

- 异步更新 - 一次更新一个状态的值,而不是同时更新所有状态,在这种情况下,状态会按固定顺序更新(先更新状态 1,再更新状态 2,以此类推)。在收敛过程中,异步更新比同步更新稍快。
值迭代在网格世界示例中的说明: 值迭代在网格世界中的应用在下图中进行了说明,解决实际问题的完整代码会在本节末尾提供。在使用贝尔曼方程对 MDP 应用之前的值迭代算法后,我们得到了以下所有状态的最优值 V*(Gamma 值选择为 0.99):

当我们将这些值代入到我们的策略方程时,我们得到以下的策略网格:


这里,在位置 (3,1) 我们想通过数学证明为什么最优策略建议向左(西)而不是向上(北)移动:


由于墙壁,每当机器人尝试向南(下方)移动时,它会停留在原地,因此我们为当前位置分配了 0.71 的值,概率为 0.1。
同样地,对于北,我们计算了如下的总收益:


因此,向西而非向北移动会是最优的选择,因此最优策略选择了这种方式。
策略迭代算法: 策略迭代是获得 MDP 最优策略的另一种方法,在这种方法中,策略评估和策略改进算法被反复应用,直到解决方案收敛到最优策略。策略迭代算法包括以下步骤:
-
初始化随机策略 π
-
重复执行以下操作,直到收敛发生
- 对当前策略求解贝尔曼方程,以使用线性方程组获得 V^π:

-
- 根据新的价值函数更新策略,通过将新值假设为最优值,使用 argmax 公式改进策略:

- 通过重复这些步骤,值和策略将会收敛到最优值:


策略迭代通常适用于较小的问题。如果一个 MDP 的状态数量非常庞大,策略迭代会在计算上非常昂贵。因此,大型 MDP 通常使用值迭代而不是策略迭代。
如果我们在现实生活中不知道确切的状态转移概率 P[s,a] 该怎么办?
我们需要使用以下简单公式从数据中估计概率:


如果某些状态没有数据可用,导致出现 0/0 问题,我们可以从均匀分布中获取一个默认概率。
使用基本 Python 的值迭代和策略迭代算法的网格世界示例
经典的网格世界示例被用来通过动态规划说明值迭代和策略迭代,以求解 MDP 的贝尔曼方程。在以下网格中,代理从网格的西南角(1,1)位置开始,目标是向东北角(4,3)移动。一旦到达目标,代理将获得 +1 的奖励。在途中,它应该避免进入危险区(4,2),因为这会导致 -1 的负奖励。代理不能从任何方向进入有障碍物(2,2)的位置。目标区和危险区是终止状态,意味着代理会继续移动,直到到达这两个状态之一。其他所有状态的奖励为 -0.02。在这里,任务是为代理在每个状态(共 11 个状态)确定最优策略(移动方向),以使代理的总奖励最大化,或者使代理尽可能快地到达目标。代理可以朝四个方向移动:北、南、东和西。

完整的代码使用 Python 编程语言编写,带有类实现。欲了解更多,请参考 Python 中的面向对象编程,了解类、对象、构造函数等。
导入random包以生成任何 N、E、S、W 方向的动作:
>>> import random,operator
以下argmax函数根据每个状态的值计算给定状态中的最大状态:
>>> def argmax(seq, fn):
... best = seq[0]; best_score = fn(best)
... for x in seq:
... x_score = fn(x)
... if x_score > best_score:
... best, best_score = x, x_score
... return best
要在分量级别上添加两个向量,以下代码已被用于:
>>> def vector_add(a, b):
... return tuple(map(operator.add, a, b))
方位提供了需要加到代理当前位置的增量值;方位可以作用于x轴或y轴:
>>> orientations = [(1,0), (0, 1), (-1, 0), (0, -1)]
以下函数用于将代理转向正确的方向,因为我们知道在每个命令下,代理大约 80%的时间会按该方向移动,10%的时间会向右移动,10%的时间会向左移动:
>>> def turn_right(orientation):
... return orientations[orientations.index(orientation)-1]
>>> def turn_left(orientation):
... return orientations[(orientations.index(orientation)+1) % len(orientations)]
>>> def isnumber(x):
... return hasattr(x, '__int__')
马尔可夫决策过程在这里定义为一个类。每个 MDP 由初始位置、状态、转移模型、奖励函数和 gamma 值定义。
>>> class MDP:
... def __init__(self, init_pos, actlist, terminals, transitions={}, states=None, gamma=0.99):
... if not (0 < gamma <= 1):
... raise ValueError("MDP should have 0 < gamma <= 1 values")
... if states:
... self.states = states
... else:
... self.states = set()
... self.init_pos = init_pos
... self.actlist = actlist
... self.terminals = terminals
... self.transitions = transitions
... self.gamma = gamma
... self.reward = {}
返回状态的数值奖励:
... def R(self, state):
... return self.reward[state]
转移模型从某个状态和动作返回每个状态的(概率,结果状态)对的列表:
... def T(self, state, action):
... if(self.transitions == {}):
... raise ValueError("Transition model is missing")
... else:
... return self.transitions[state][action]
可以在特定状态下执行的动作集:
... def actions(self, state):
... if state in self.terminals:
... return [None]
... else:
... return self.actlist
GridMDP类用于建模一个二维网格世界,其中每个状态有网格值、终端位置、初始位置和折扣值(gamma):
>>> class GridMDP(MDP):
... def __init__(self, grid, terminals, init_pos=(0, 0), gamma=0.99):
以下代码用于反转网格,因为我们希望将第 0 行放在底部,而不是顶部:
... grid.reverse()
以下__init__命令是一个构造函数,用于在网格类中初始化参数:
... MDP.__init__(self, init_pos, actlist=orientations,
terminals=terminals, gamma=gamma)
... self.grid = grid
... self.rows = len(grid)
... self.cols = len(grid[0])
... for x in range(self.cols):
... for y in range(self.rows):
... self.reward[x, y] = grid[y][x]
... if grid[y][x] is not None:
... self.states.add((x, y))
状态转移在 80%的情况下随机朝向期望方向,10%朝左,10%朝右。这是为了模拟机器人在地板上可能滑动的随机性,等等:
... def T(self, state, action):
... if action is None:
... return [(0.0, state)]
... else:
... return [(0.8, self.go(state, action)),
... (0.1, self.go(state, turn_right(action))),
... (0.1, self.go(state, turn_left(action)))]
返回执行指定方向后产生的状态,前提是该状态在有效状态列表中。如果下一个状态不在列表中,例如撞墙,则代理应保持在同一状态:
... def go(self, state, direction):
... state1 = vector_add(state, direction)
... return state1 if state1 in self.states else state
将从(x, y)到 v 的映射转换为[[...,v,...]]网格:
... def to_grid(self, mapping):
... return list(reversed([[mapping.get((x, y), None)
... for x in range(self.cols)]
... for y in range(self.rows)]))
将方位转换为箭头,以便更好地进行图形表示:
... def to_arrows(self, policy):
... chars = {(1, 0): '>', (0, 1): '^', (-1, 0): '<', (0, -1):
'v', None: '.'}
... return self.to_grid({s: chars[a] for (s, a) in policy.items()})
以下代码用于通过值迭代求解 MDP,并返回最优状态值:
>>> def value_iteration(mdp, epsilon=0.001):
... STSN = {s: 0 for s in mdp.states}
... R, T, gamma = mdp.R, mdp.T, mdp.gamma
... while True:
... STS = STSN.copy()
... delta = 0
... for s in mdp.states:
... STSN[s] = R(s) + gamma * max([sum([p * STS[s1] for
... (p, s1) in T(s,a)]) for a in mdp.actions(s)])
... delta = max(delta, abs(STSN[s] - STS[s]))
... if delta < epsilon * (1 - gamma) / gamma:
... return STS
给定一个 MDP 和一个效用函数STS,确定最佳策略,即从状态到动作的映射:
>>> def best_policy(mdp, STS):
... pi = {}
... for s in mdp.states:
... pi[s] = argmax(mdp.actions(s), lambda a: expected_utility(a, s, STS, mdp))
... return pi
根据 MDP 和 STS,执行动作a在状态s中的预期效用:
>>> def expected_utility(a, s, STS, mdp):
... return sum([p * STS[s1] for (p, s1) in mdp.T(s, a)])
以下代码用于通过策略迭代求解 MDP,通过交替执行策略评估和策略改进步骤:
>>> def policy_iteration(mdp):
... STS = {s: 0 for s in mdp.states}
... pi = {s: random.choice(mdp.actions(s)) for s in mdp.states}
... while True:
... STS = policy_evaluation(pi, STS, mdp)
... unchanged = True
... for s in mdp.states:
... a = argmax(mdp.actions(s),lambda a: expected_utility(a, s, STS, mdp))
... if a != pi[s]:
... pi[s] = a
... unchanged = False
... if unchanged:
... return pi
以下代码用于返回从每个 MDP 状态到其效用的更新效用映射U,使用近似(修改后的策略迭代):
>>> def policy_evaluation(pi, STS, mdp, k=20):
... R, T, gamma = mdp.R, mdp.T, mdp.gamma
.. for i in range(k):
... for s in mdp.states:
... STS[s] = R(s) + gamma * sum([p * STS[s1] for (p, s1) in T(s, pi[s])])
... return STS
>>> def print_table(table, header=None, sep=' ', numfmt='{}'):
... justs = ['rjust' if isnumber(x) else 'ljust' for x in table[0]]
... if header:
... table.insert(0, header)
... table = [[numfmt.format(x) if isnumber(x) else x for x in row]
... for row in table]
... sizes = list(map(lambda seq: max(map(len, seq)),
... list(zip(*[map(str, row) for row in table]))))
... for row in table:
... print(sep.join(getattr(str(x), j)(size) for (j, size, x)
... in zip(justs, sizes, row)))
以下是一个 4 x 3 网格环境的输入网格,呈现出代理的顺序决策问题:
>>> sequential_decision_environment = GridMDP([[-0.02, -0.02, -0.02, +1],
... [-0.02, None, -0.02, -1],
... [-0.02, -0.02, -0.02, -0.02]],
... terminals=[(3, 2), (3, 1)])
以下代码用于在给定的顺序决策环境中执行价值迭代:
>>> value_iter = best_policy(sequential_decision_environment,value_iteration (sequential_decision_environment, .01))
>>> print("\n Optimal Policy based on Value Iteration\n")
>>> print_table(sequential_decision_environment.to_arrows(value_iter))

策略迭代的代码如下:
>>> policy_iter = policy_iteration(sequential_decision_environment)
>>> print("\n Optimal Policy based on Policy Iteration & Evaluation\n")
>>> print_table(sequential_decision_environment.to_arrows(policy_iter))

从前面的输出结果来看,两个结果表明,价值迭代和策略迭代为智能体提供了相同的最优策略,使其能在最短时间内通过网格到达目标状态。当问题规模足够大时,从计算角度考虑,选择价值迭代更为可取,因为在策略迭代中,每次迭代都需要进行两个步骤:策略评估和策略改进。
蒙特卡洛方法
使用蒙特卡洛(MC)方法,我们首先计算价值函数,并确定最优策略。在这种方法中,我们不假设对环境有完全的了解。蒙特卡洛方法只需要经验,这些经验包括来自与环境实际或模拟互动的状态、动作和回报的样本序列。从实际经验中学习非常具有意义,因为它不依赖于对环境动态的先验知识,但仍能获得最优行为。这与人类或动物从实际经验中学习而非依赖任何数学模型的方式非常相似。令人惊讶的是,在许多情况下,根据所需的概率分布生成样本经验是容易的,但获得这些分布的显式形式却不可行。
蒙特卡洛方法通过对每个回合中的样本回报进行平均来解决强化学习问题。这意味着我们假设经验被划分为多个回合,并且所有回合最终都会终止,无论选择什么动作。只有在每个回合完成后,才会估算价值并改变策略。蒙特卡洛方法是按回合逐步增量的,而不是按步骤增量(这属于在线学习,我们将在时间差学习部分进行相同讨论)。
蒙特卡洛方法通过对每个状态-动作对在整个回合中的回报进行采样和平均。然而,在同一回合内,采取某一动作后的回报依赖于后续状态中采取的动作。由于所有的动作选择都在进行学习,从早期状态的角度来看,问题变得非平稳。为了处理这种非平稳性,我们借用了动态规划中的策略迭代思想,其中,首先计算固定任意策略的价值函数;然后,再改进策略。
蒙特卡洛预测
如我们所知,蒙特卡洛方法预测给定策略的状态值函数。任何状态的值是从该状态开始的预期回报或预期累计未来折扣奖励。这些值在蒙特卡洛方法中通过简单地对访问该状态后的回报进行平均来估算。随着越来越多的值被观察到,平均值应该根据大数法则收敛到预期值。实际上,这是所有蒙特卡洛方法适用的原理。蒙特卡洛策略评估算法包括以下步骤:
- 初始化:

-
无限重复:
-
使用π生成一个回合
-
对回合中出现的每个状态s:
-
G 回报,跟随第一次出现的s
-
将G附加到 Returns(s)
-
V(s) 平均值(Returns(s))
-
-
蒙特卡洛预测在网格世界问题上的适用性
下图用于说明目的。然而,实际上,由于并非所有策略都能保证终止,蒙特卡洛方法不能轻易用于解决网格世界类型的问题。如果发现某个策略导致代理停留在同一状态,那么下一个回合将永远不会结束。像(状态-动作-奖励-状态-动作)(SARSA,我们将在本章后面讨论的时间差分学习控制方法中讲解)这样的逐步学习方法没有这个问题,因为它们在回合过程中迅速学习到这些策略是差的,并会切换到其他策略。

使用 Python 建模二十一点游戏的蒙特卡洛方法
流行赌场扑克牌游戏“二十一点”的目标是获得一组牌,这些牌的点数总和尽可能大,但不得超过 21 点。所有的面牌(国王、皇后和杰克)都算作 10 点,而 A 牌可以根据玩家的需求算作 1 点或 11 点,只有 A 牌具有这种灵活性选项。其他所有牌按面值计算。游戏开始时,庄家和玩家各发两张牌,其中庄家的其中一张牌是面朝上的,另一张是面朝下的。如果玩家从这两张牌中得到“自然 21 点”(即一张 A 牌和一张 10 点牌),那么玩家获胜,除非庄家也有自然 21 点,在这种情况下,游戏为平局。如果玩家没有自然 21 点,则可以要求继续抽牌,一张一张地抽(称为“要牌”),直到他选择停牌(不再要牌)或超过 21 点(称为“爆牌”)。如果玩家爆牌,则玩家失败;如果玩家选择停牌,则轮到庄家。庄家根据固定策略选择要牌或停牌,无法选择:通常庄家在总点数为 17 点或更高时选择停牌,低于 17 点时选择要牌。如果庄家爆牌,则玩家自动获胜。如果庄家停牌,则游戏结果将是胜利、失败或平局,取决于庄家和玩家的点数哪个更接近 21 点。

黑杰克问题可以被建模为一个周期性有限的马尔可夫决策过程(MDP),其中每一局黑杰克游戏为一个回合。每个回合的奖励分别为+1(胜利)、-1(失败)和 0(平局),这些奖励会在游戏结束时给出,游戏状态中的其余奖励则为 0,不进行折扣(gamma = 1)。因此,终端奖励也就是游戏的回报。我们从一个无限的牌堆中抽取卡牌,以确保没有可追踪的规律。以下是用 Python 语言建模的整个游戏代码。
以下代码片段灵感来自Shangtong Zhang的强化学习 Python 代码,并已获得Richard S. Sutton著作《Reinforcement Learning: An Introduction》学生的许可,在本书中发布(详细信息请见进一步阅读部分)。
以下包用于数组操作和可视化:
>>> from __future__ import print_function
>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> from mpl_toolkits.mplot3d import Axes3D
在每个回合中,玩家或庄家可以采取两种可能的行动:抽牌或停牌。这是唯一的两种可能状态:
>>> ACTION_HIT = 0
>>> ACTION_STAND = 1
>>> actions = [ACTION_HIT, ACTION_STAND]
玩家策略使用 21 个值的数组来建模,因为玩家总分超过 21 时将爆掉:
>>> policyPlayer = np.zeros(22)
>>> for i in range(12, 20):
... policyPlayer[i] = ACTION_HIT
如果玩家的总分为 20 或 21 点,他将选择停牌;否则,玩家将继续抽牌:
>>> policyPlayer[20] = ACTION_STAND
>>> policyPlayer[21] = ACTION_STAND
玩家目标策略的函数形式:
>>> def targetPolicyPlayer(usableAcePlayer, playerSum, dealerCard):
... return policyPlayer[playerSum]
玩家行为策略的函数形式:
>>> def behaviorPolicyPlayer(usableAcePlayer, playerSum, dealerCard):
... if np.random.binomial(1, 0.5) == 1:
... return ACTION_STAND
... return ACTION_HIT
庄家的固定策略是持续抽牌直到达到 17 点,然后在 17 到 21 点之间停牌:
>>> policyDealer = np.zeros(22)
>>> for i in range(12, 17):
... policyDealer[i] = ACTION_HIT
>>> for i in range(17, 22):
... policyDealer[i] = ACTION_STAND
以下函数用于从牌堆中抽取一张新牌,并进行替换:
>>> def getCard():
... card = np.random.randint(1, 14)
... card = min(card, 10)
... return card
让我们开始游戏吧!
>>> def play(policyPlayerFn, initialState=None, initialAction=None):
- 玩家总分、玩家轨迹以及玩家是否将王牌算作 11 点的情况:
... playerSum = 0
... playerTrajectory = []
... usableAcePlayer = False
- 庄家抽牌状态:
... dealerCard1 = 0
... dealerCard2 = 0
... usableAceDealer = False
... if initialState is None:
- 生成一个随机初始状态:
... numOfAce = 0
- 初始化玩家的手牌:
... while playerSum < 12:
- 如果玩家的总分小于 12 点,始终要抽一张牌:
... card = getCard()
... if card == 1:
... numOfAce += 1
... card = 11
... usableAcePlayer = True
... playerSum += card
- 如果玩家的总分大于 21 点,他必须至少拥有一张王牌,但也可以有两张王牌。在这种情况下,他将会把王牌当作 1 点来计算,而不是 11 点。如果玩家只有一张王牌,那么他将不再有可用的王牌:
... if playerSum > 21:
... playerSum -= 10
... if numOfAce == 1:
... usableAcePlayer = False
- 初始化庄家手牌:
... dealerCard1 = getCard()
... dealerCard2 = getCard()
... else:
... usableAcePlayer = initialState[0]
... playerSum = initialState[1]
... dealerCard1 = initialState[2]
... dealerCard2 = getCard()
- 初始化游戏状态:
... state = [usableAcePlayer, playerSum, dealerCard1]
- 初始化庄家的总分:
... dealerSum = 0
... if dealerCard1 == 1 and dealerCard2 != 1:
... dealerSum += 11 + dealerCard2
... usableAceDealer = True
... elif dealerCard1 != 1 and dealerCard2 == 1:
... dealerSum += dealerCard1 + 11
... usableAceDealer = True
... elif dealerCard1 == 1 and dealerCard2 == 1:
... dealerSum += 1 + 11
... usableAceDealer = True
... else:
... dealerSum += dealerCard1 + dealerCard2
- 游戏从这里开始,因为玩家需要从这里开始抽取额外的牌:
... while True:
... if initialAction is not None:
... action = initialAction
... initialAction = None
... else:
- 根据当前玩家的总分选择行动:
... action = policyPlayerFn(usableAcePlayer, playerSum, dealerCard1)
- 跟踪玩家轨迹以便进行重要性采样:
... playerTrajectory.append([action, (usableAcePlayer, playerSum, dealerCard1)])
... if action == ACTION_STAND:
... break
- 如果选择抽牌(hit the deck),则抽一张新牌:
... playerSum += getCard()
- 如果玩家的总分大于 21 点,玩家爆掉,游戏结束,玩家获得奖励-1。不过,如果玩家手上有王牌,他可以将王牌用作 11 点来挽救游戏,否则将会失败。
... if playerSum > 21:
... if usableAcePlayer == True:
... playerSum -= 10
... usableAcePlayer = False
... else:
... return state, -1, playerTrajectory
- 现在轮到庄家行动。他将根据总分来决定是否抽牌:如果他达到 17 点,就停牌,否则继续抽牌。如果庄家也有王牌,他可以使用王牌来达到爆掉的情况,否则庄家会爆掉:
... while True:
... action = policyDealer[dealerSum]
... if action == ACTION_STAND:
... break
... dealerSum += getCard()
... if dealerSum > 21:
... if usableAceDealer == True:
... dealerSum -= 10
... usableAceDealer = False
... else:
... return state, 1, playerTrajectory
- 现在我们将玩家的总和与庄家的总和进行比较,决定谁在不爆掉的情况下获胜:
... if playerSum > dealerSum:
... return state, 1, playerTrajectory
... elif playerSum == dealerSum:
... return state, 0, playerTrajectory
... else:
... return state, -1, playerTrajectory
以下代码演示了使用On-Policy的蒙特卡洛采样:
>>> def monteCarloOnPolicy(nEpisodes):
... statesUsableAce = np.zeros((10, 10))
... statesUsableAceCount = np.ones((10, 10))
... statesNoUsableAce = np.zeros((10, 10))
... statesNoUsableAceCount = np.ones((10, 10))
... for i in range(0, nEpisodes):
... state, reward, _ = play(targetPolicyPlayer)
... state[1] -= 12
... state[2] -= 1
... if state[0]:
... statesUsableAceCount[state[1], state[2]] += 1
... statesUsableAce[state[1], state[2]] += reward
... else:
... statesNoUsableAceCount[state[1], state[2]] += 1
... statesNoUsableAce[state[1], state[2]] += reward
... return statesUsableAce / statesUsableAceCount, statesNoUsableAce / statesNoUsableAceCount
以下代码讨论了带有探索起始的蒙特卡洛方法,其中每个状态-动作对的所有回报都会被累积并平均,不管观察到时使用的是何种策略:
>>> def monteCarloES(nEpisodes):
... stateActionValues = np.zeros((10, 10, 2, 2))
... stateActionPairCount = np.ones((10, 10, 2, 2))
行为策略是贪心策略,获取平均回报(s, a)的argmax:
... def behaviorPolicy(usableAce, playerSum, dealerCard):
... usableAce = int(usableAce)
... playerSum -= 12
... dealerCard -= 1
... return np.argmax(stateActionValues[playerSum, dealerCard, usableAce, :]
/ stateActionPairCount[playerSum, dealerCard, usableAce, :])
游戏会持续若干回合,每一回合随机初始化状态、动作,并更新状态-动作对的值:
... for episode in range(nEpisodes):
... if episode % 1000 == 0:
... print('episode:', episode)
... initialState = [bool(np.random.choice([0, 1])),
... np.random.choice(range(12, 22)),
... np.random.choice(range(1, 11))]
... initialAction = np.random.choice(actions)
... _, reward, trajectory = play(behaviorPolicy, initialState, initialAction)
... for action, (usableAce, playerSum, dealerCard) in trajectory:
... usableAce = int(usableAce)
... playerSum -= 12
... dealerCard -= 1
更新状态-动作对的值:
... stateActionValues[playerSum, dealerCard, usableAce, action] += reward
... stateActionPairCount[playerSum, dealerCard, usableAce, action] += 1
... return stateActionValues / stateActionPairCount
打印状态值:
>>> figureIndex = 0
>>> def prettyPrint(data, tile, zlabel='reward'):
... global figureIndex
... fig = plt.figure(figureIndex)
... figureIndex += 1
... fig.suptitle(tile)
... ax = fig.add_subplot(111, projection='3d')
... x_axis = []
... y_axis = []
... z_axis = []
... for i in range(12, 22):
... for j in range(1, 11):
... x_axis.append(i)
... y_axis.append(j)
... z_axis.append(data[i - 12, j - 1])
... ax.scatter(x_axis, y_axis, z_axis,c='red')
... ax.set_xlabel('player sum')
... ax.set_ylabel('dealer showing')
... ax.set_zlabel(zlabel)
使用或不使用可用 A 牌的On-Policy结果,经过 10,000 和 500,000 次迭代:
>>> def onPolicy():
... statesUsableAce1, statesNoUsableAce1 = monteCarloOnPolicy(10000)
... statesUsableAce2, statesNoUsableAce2 = monteCarloOnPolicy(500000)
... prettyPrint(statesUsableAce1, 'Usable Ace & 10000 Episodes')
... prettyPrint(statesNoUsableAce1, 'No Usable Ace & 10000 Episodes')
... prettyPrint(statesUsableAce2, 'Usable Ace & 500000 Episodes')
... prettyPrint(statesNoUsableAce2, 'No Usable Ace & 500000 Episodes')
... plt.show()
策略迭代的优化或蒙特卡洛控制:
>>> def MC_ES_optimalPolicy():
... stateActionValues = monteCarloES(500000)
... stateValueUsableAce = np.zeros((10, 10))
... stateValueNoUsableAce = np.zeros((10, 10))
# get the optimal policy
... actionUsableAce = np.zeros((10, 10), dtype='int')
... actionNoUsableAce = np.zeros((10, 10), dtype='int')
... for i in range(10):
... for j in range(10):
... stateValueNoUsableAce[i, j] = np.max(stateActionValues[i, j, 0, :])
... stateValueUsableAce[i, j] = np.max(stateActionValues[i, j, 1, :])
... actionNoUsableAce[i, j] = np.argmax(stateActionValues[i, j, 0, :])
... actionUsableAce[i, j] = np.argmax(stateActionValues[i, j, 1, :])
... prettyPrint(stateValueUsableAce, 'Optimal state value with usable Ace')
... prettyPrint(stateValueNoUsableAce, 'Optimal state value with no usable Ace')
... prettyPrint(actionUsableAce, 'Optimal policy with usable Ace', 'Action (0 Hit, 1 Stick)')
... prettyPrint(actionNoUsableAce, 'Optimal policy with no usable Ace', 'Action (0 Hit, 1 Stick)')
... plt.show()
# Run on-policy function
>>> onPolicy()

从前面的图表中,我们可以得出结论:即使在低玩家总和的组合下,手中有可用 A 牌也能带来更高的奖励,而对于没有可用 A 牌的玩家,如果回报少于 20,这些值之间的差异就非常明显。
# Run Monte Carlo Control or Explored starts
>>> MC_ES_optimalPolicy()

从最优策略和状态值中,我们可以得出结论:如果有可用的 A 牌,我们可以打得比停牌更多,而且奖励的状态值也远高于没有 A 牌时的情况。尽管我们讨论的结果是显而易见的,但我们可以看到持有 A 牌对结果的影响程度。
时序差分学习:
时序差分(TD)学习是强化学习的核心和创新主题。TD 学习结合了蒙特卡洛(MC)和动态规划(DP)的思想。与蒙特卡洛方法类似,TD 方法可以直接从经验中学习,无需环境模型。类似于动态规划,TD 方法基于其他已学习的估计值来更新估计,而不需要等待最终结果,这与 MC 方法不同,后者只有在达到最终结果后才更新估计。

时序差分预测:
TD 和 MC 都使用经验来解决z预测问题。给定某个策略π,两者都更新它们对非终结状态S[t]的估计v,该状态在经验中发生。蒙特卡洛方法会等到访问后的回报已知后,再将该回报作为V(S[t])的目标。

上述方法可以被称为常数 - α MC,其中 MC 必须等到回合结束后才能确定对V(S[t])的增量(只有到那时G[t]才会知道)。
TD 方法只需要等到下一个时间步。到达t+1时,它们立即形成目标并使用观察到的奖励R[t+1]和估计的V(S[t+1])进行有用的更新。最简单的 TD 方法是TD(0),其公式为:

MC 更新的目标是 G[t],而 TD 更新的目标是 R[t+1] + y V(S[t+1])。
在下图中,我们对比了 TD 和 MC 方法。如我们在方程 TD(0) 中所写,我们使用一步的实际数据,然后使用下一个状态的价值函数的估计值。同样,我们也可以使用两步的实际数据,以便更好地理解现实,并估计第三阶段的价值函数。然而,随着步骤的增加,最终需要越来越多的数据来进行参数更新,这将消耗更多的时间。
当我们采取无限步数,直到触及终止点以更新每个回合的参数时,TD 就变成了蒙特卡罗方法。

用于估计 v 的 TD (0) 算法包含以下步骤:
- 初始化:

-
重复(对于每个回合):
-
初始化 S
-
重复(对于回合的每一步):
-
A <- 由 π 给定的动作,针对 S
-
执行动作 A,观察 R,S'
-
![]()
-
![]()
-
-
-
直到 S 是终止状态。
驾驶办公室示例用于 TD 学习
在这个简单的示例中,你每天从家里出发到办公室,试图预测早晨到达办公室需要多长时间。当你离开家时,你记录下这个时间、星期几、天气情况(是否下雨、刮风等)以及你认为相关的其他参数。例如,在星期一早晨,你在早上 8 点整离开,并估计到达办公室需要 40 分钟。到了 8:10,你注意到一位贵宾正在经过,你需要等到完整的车队离开,因此你重新估计从那时起需要 45 分钟,总共需要 55 分钟。15 分钟后,你顺利完成了高速公路部分的行程。现在你进入了一个绕行的道路,你将总行程时间的估计减少到 50 分钟。不幸的是,这时你被一堆牛车堵住了,路太窄无法通过。你不得不跟着那些牛车走,直到你转入一条侧街,在 8:50 到达了办公室。七分钟后,你到达了办公室停车场。状态、时间和预测的顺序如下:

在这个示例中,奖励是每段行程所用的时间,我们使用了折扣因子(gamma,v = 1),因此每个状态的回报就是从该状态到目的地(办公室)的实际时间。每个状态的价值是预计的行驶时间,即前表中的第二列,也被称为每个遇到的状态的当前估计值。

在前面的图示中,蒙特卡洛方法被用来绘制在事件序列中的预测总时间。箭头始终表示由常数-α MC 方法推荐的预测变化。这些是每个阶段的估计值与实际回报(57 分钟)之间的误差。在 MC 方法中,学习仅在结束后进行,因此它需要等待直到 57 分钟过去。然而,实际上,您可以在达到最终结果之前进行估计,并相应地修正您的估计。TD 也遵循相同的原理,在每个阶段它尝试进行预测并相应地修正估计。因此,TD 方法立即学习,不需要等到最终结果。事实上,这也是人类在现实生活中的预测方式。由于这些积极的特性,TD 学习被认为是强化学习中的创新方法。
SARSA 在策略上的 TD 控制
状态-动作-奖励-状态-动作(SARSA)是一个在策略的 TD 控制问题,其中策略将使用策略迭代(GPI)进行优化,仅在 TD 方法用于评估预测策略时。首先,算法学习一个 SARSA 函数。特别地,对于一种策略方法,我们估计当前行为策略 π 下所有状态(s)和动作(a)对应的 q[π] (s, a),并使用 TD 方法学习 v[π]。
现在,我们考虑从状态-动作对到状态-动作对的转换,并学习状态-动作对的值:

此更新在每次从非终结状态 S[t] 转换后进行。如果 S[t+1] 是终结状态,则 Q (S[t+1,] A[t+1]) 定义为零。此规则使用事件五元组的每个元素(S[t],A[t],Rt,St[+1],A[t+1]),它们构成从一个状态-动作对到下一个的转换。这个五元组使得算法被命名为 SARSA。
与所有策略方法一样,我们不断估计行为策略 π 的 q[π],并同时将 π 向贪心策略调整,以符合 q[π]。SARSA 计算的算法如下:
- 初始化:

-
重复(对于每个回合):
-
初始化 S
-
使用从 Q 中得出的策略从 S 中选择 A(例如,ε-贪心策略)
-
重复(对于每一步的回合):
-
执行动作 A,观察 R, S'
-
使用从 Q 中得出的策略从 S' 中选择 A'(例如,ε-贪心策略)
-
![]()
-
![]()
-
-
-
直到 S 是终结状态
Q-learning - 非策略 TD 控制
Q-learning 是目前在许多强化学习问题中应用最广泛的方法。策略外的 TD 控制算法被称为 Q-learning。在这种情况下,学习到的动作-价值函数 Q 直接逼近最优的动作-价值函数,独立于所遵循的策略。这个逼近简化了算法的分析,并支持早期收敛性的证明。策略仍然有影响,因为它决定了哪些状态-动作对会被访问和更新。然而,对于正确的收敛性而言,只需要所有对都继续被更新。正如我们所知,这是一个最小要求,因为任何能够保证找到最优行为的算法在一般情况下都必须满足这个要求。收敛算法的步骤如下:
- 初始化:

-
重复(每个回合):
-
初始化 S
-
重复(每个步骤都进行):
-
从 S 中选择 A,使用从 Q 中推导的策略(例如,ε - 贪婪策略)
-
采取动作 A,观察R,S'
-
![]()
-
![]()
-
-
-
直到S为终止状态
TD 控制的策略内和策略外的悬崖行走示例
使用悬崖行走网格世界示例来比较 SARSA 和 Q-learning,突出策略内(SARSA)和策略外(Q-learning)方法之间的区别。这是一个标准的非折扣、回合制任务,具有起始和结束目标状态,并且允许四个方向(北、南、西、东)移动。除了标记为悬崖的区域,所有的转移都使用-1 作为奖励,踩到这个区域会使智能体受到-100 的惩罚,并立即将其送回起始位置。

以下代码片段灵感来自于 Shangtong Zhang 的强化学习 Python 代码,并已获得Richard S. Sutton(《强化学习:导论》一书的著名作者)的学生的许可,在本书中发布(更多详细信息请参阅进一步阅读部分):
# Cliff-Walking - TD learning - SARSA & Q-learning
>>> from __future__ import print_function
>>> import numpy as np
>>> import matplotlib.pyplot as plt
# Grid dimensions
>>> GRID_HEIGHT = 4
>>> GRID_WIDTH = 12
# probability for exploration, step size,gamma
>>> EPSILON = 0.1
>>> ALPHA = 0.5
>>> GAMMA = 1
# all possible actions
>>> ACTION_UP = 0; ACTION_DOWN = 1;ACTION_LEFT = 2;ACTION_RIGHT = 3
>>> actions = [ACTION_UP, ACTION_DOWN, ACTION_LEFT, ACTION_RIGHT]
# initial state action pair values
>>> stateActionValues = np.zeros((GRID_HEIGHT, GRID_WIDTH, 4))
>>> startState = [3, 0]
>>> goalState = [3, 11]
# reward for each action in each state
>>> actionRewards = np.zeros((GRID_HEIGHT, GRID_WIDTH, 4))
>>> actionRewards[:, :, :] = -1.0
>>> actionRewards[2, 1:11, ACTION_DOWN] = -100.0
>>> actionRewards[3, 0, ACTION_RIGHT] = -100.0
# set up destinations for each action in each state
>>> actionDestination = []
>>> for i in range(0, GRID_HEIGHT):
... actionDestination.append([])
... for j in range(0, GRID_WIDTH):
... destinaion = dict()
... destinaion[ACTION_UP] = [max(i - 1, 0), j]
... destinaion[ACTION_LEFT] = [i, max(j - 1, 0)]
... destinaion[ACTION_RIGHT] = [i, min(j + 1, GRID_WIDTH - 1)]
... if i == 2 and 1 <= j <= 10:
... destinaion[ACTION_DOWN] = startState
... else:
... destinaion[ACTION_DOWN] = [min(i + 1, GRID_HEIGHT - 1), j]
... actionDestination[-1].append(destinaion)
>>> actionDestination[3][0][ACTION_RIGHT] = startState
# choose an action based on epsilon greedy algorithm
>>> def chooseAction(state, stateActionValues):
... if np.random.binomial(1, EPSILON) == 1:
... return np.random.choice(actions)
... else:
... return np.argmax(stateActionValues[state[0], state[1], :])
# SARSA update
>>> def sarsa(stateActionValues, expected=False, stepSize=ALPHA):
... currentState = startState
... currentAction = chooseAction(currentState, stateActionValues)
... rewards = 0.0
... while currentState != goalState:
... newState = actionDestination[currentState[0]][currentState[1]] [currentAction]
... newAction = chooseAction(newState, stateActionValues)
... reward = actionRewards[currentState[0], currentState[1], currentAction]
... rewards += reward
... if not expected:
... valueTarget = stateActionValues[newState[0], newState[1], newAction]
... else:
... valueTarget = 0.0
... actionValues = stateActionValues[newState[0], newState[1], :]
... bestActions = np.argwhere(actionValues == np.max(actionValues))
... for action in actions:
... if action in bestActions:
... valueTarget += ((1.0 - EPSILON) / len(bestActions) + EPSILON / len(actions)) * stateActionValues[newState[0], newState[1], action]
... else:
... valueTarget += EPSILON / len(actions) * stateActionValues[newState[0], newState[1], action]
... valueTarget *= GAMMA
... stateActionValues[currentState[0], currentState[1], currentAction] += stepSize * (reward+ valueTarget - stateActionValues[currentState[0], currentState[1], currentAction])
... currentState = newState
... currentAction = newAction
... return rewards
# Q-learning update
>>> def qlearning(stateActionValues, stepSize=ALPHA):
... currentState = startState
... rewards = 0.0
... while currentState != goalState:
... currentAction = chooseAction(currentState, stateActionValues)
... reward = actionRewards[currentState[0], currentState[1], currentAction]
... rewards += reward
... newState = actionDestination[currentState[0]][currentState[1]] [currentAction]
... stateActionValues[currentState[0], currentState[1], currentAction] += stepSize * (reward + GAMMA * np.max(stateActionValues[newState[0], newState[1], :]) -
... stateActionValues[currentState[0], currentState[1], currentAction])
... currentState = newState
... return rewards
# print optimal policy
>>> def printOptimalPolicy(stateActionValues):
... optimalPolicy = []
... for i in range(0, GRID_HEIGHT):
... optimalPolicy.append([])
... for j in range(0, GRID_WIDTH):
... if [i, j] == goalState:
... optimalPolicy[-1].append('G')
... continue
... bestAction = np.argmax(stateActionValues[i, j, :])
... if bestAction == ACTION_UP:
... optimalPolicy[-1].append('U')
... elif bestAction == ACTION_DOWN:
... optimalPolicy[-1].append('D')
... elif bestAction == ACTION_LEFT:
... optimalPolicy[-1].append('L')
... elif bestAction == ACTION_RIGHT:
... optimalPolicy[-1].append('R')
... for row in optimalPolicy:
... print(row)
>>> def SARSAnQLPlot():
# averaging the reward sums from 10 successive episodes
... averageRange = 10
# episodes of each run
... nEpisodes = 500
# perform 20 independent runs
... runs = 20
... rewardsSarsa = np.zeros(nEpisodes)
... rewardsQlearning = np.zeros(nEpisodes)
... for run in range(0, runs):
... stateActionValuesSarsa = np.copy(stateActionValues)
... stateActionValuesQlearning = np.copy(stateActionValues)
... for i in range(0, nEpisodes):
# cut off the value by -100 to draw the figure more elegantly
... rewardsSarsa[i] += max(sarsa(stateActionValuesSarsa), -100)
... rewardsQlearning[i] += max(qlearning(stateActionValuesQlearning), -100)
# averaging over independent runs
... rewardsSarsa /= runs
... rewardsQlearning /= runs
# averaging over successive episodes
... smoothedRewardsSarsa = np.copy(rewardsSarsa)
... smoothedRewardsQlearning = np.copy(rewardsQlearning)
... for i in range(averageRange, nEpisodes):
... smoothedRewardsSarsa[i] = np.mean(rewardsSarsa[i - averageRange: i + 1])
... smoothedRewardsQlearning[i] = np.mean(rewardsQlearning[i - averageRange: i + 1])
# display optimal policy
... print('Sarsa Optimal Policy:')
... printOptimalPolicy(stateActionValuesSarsa)
... print('Q-learning Optimal Policy:')
... printOptimalPolicy(stateActionValuesQlearning)
# draw reward curves
... plt.figure(1)
... plt.plot(smoothedRewardsSarsa, label='Sarsa')
... plt.plot(smoothedRewardsQlearning, label='Q-learning')
... plt.xlabel('Episodes')
... plt.ylabel('Sum of rewards during episode')
... plt.legend()
# Sum of Rewards for SARSA versus Qlearning
>>> SARSAnQLPlot()

在初步的瞬态之后,Q-learning 学习最优策略的价值,沿着最优路径行走,在此过程中智能体会沿着悬崖边缘行走。不幸的是,这会因为ε-贪婪动作选择导致偶尔从悬崖上掉下去。而 SARSA 则考虑到动作选择,学习通过网格上部的更长且更安全的路径。尽管 Q-learning 学习最优策略的价值,但其在线表现不如 SARSA,后者学习了绕行的最安全策略。即便我们观察下图所显示的奖励总和,SARSA 在整个过程中获得的奖励总和也比 Q-learning 要少负。

进一步阅读
目前有许多经典的强化学习资源可供使用,我们鼓励读者进行学习:
-
R.S. Sutton 和 A.G. Barto, 强化学习:导论,MIT Press,剑桥,美国,1998 年
-
RL 课程 由 David Silver 在 YouTube 上提供:
www.youtube.com/watch?v=2pWv7GOvuf0&list=PL7-jPKtc4r78-wCZcQn5IqyuWhBZ8fOxT -
机器学习(斯坦福大学)由 Andrew NG 在 YouTube 上提供(第 16-20 讲):
www.youtube.com/watch?v=UzxYlbK2c7E&list=PLA89DCFA6ADACE599 -
强化学习算法,Csaba 著,Morgan & Claypool 出版社
-
人工智能:现代方法 第 3 版,Stuart Russell 和 Peter Norvig 著,Prentice Hall 出版
摘要
在本章中,你学习了各种强化学习技术,如马尔可夫决策过程、贝尔曼方程、动态规划、蒙特卡罗方法、时序差分学习,包括基于策略(SARSA)和非基于策略(Q-learning)的算法,并通过 Python 示例来理解其在实际中的实现。你还了解了 Q-learning 如何在许多实际应用中使用,因为这种方法通过与环境互动并通过试错学习。
最后,如果你希望全职深入学习强化学习,我们为你提供了进一步阅读的资源。我们祝你一切顺利!
第六章:你好,绘图世界!
学习编程时,我们通常从打印“Hello world!”消息开始。那么,对于包含数据、坐标轴、标签、线条和刻度的图形,我们应该如何开始呢?
本章概述了 Matplotlib 的功能和最新特性。我们将引导你完成 Matplotlib 绘图环境的设置。你将学习如何创建一个简单的折线图、查看并保存你的图形。在本章结束时,你将足够自信开始创建自己的图表,并准备好在接下来的章节中学习自定义和更高级的技巧。
来,向图表世界说“你好!”
本章涵盖的主题包括:
-
什么是 Matplotlib?
-
绘制第一个简单的折线图
-
将数据加载到 Matplotlib 中
-
导出图形
你好,Matplotlib!
欢迎来到 Matplotlib 2.0 的世界!按照本章中的简单示例,绘制你第一个“Hello world”图表。
什么是 Matplotlib?
Matplotlib 是一个多功能的 Python 库,用于生成数据可视化图形。凭借丰富的图表类型和精致的样式选项,它非常适合创建专业的图表,用于演示和科学出版物。Matplotlib 提供了一种简单的方法来生成适合不同目的的图形,从幻灯片、高清海报打印、动画到基于 Web 的交互式图表。除了典型的二维图表,Matplotlib 还支持基本的三维绘图。
在开发方面,Matplotlib 的层次化类结构和面向对象的绘图接口使绘图过程直观且系统化。虽然 Matplotlib 提供了一个用于实时交互的本地图形用户界面,但它也可以轻松集成到流行的基于 IPython 的交互式开发环境中,如 Jupyter Notebook 和 PyCharm。
Matplotlib 2.0 的新特性
Matplotlib 2.0 引入了许多改进,包括默认样式的外观、图像支持和文本渲染速度。我们选取了一些重要的变化稍后会详细介绍。所有新变化的详细信息可以在文档网站上找到:matplotlib.org/devdocs/users/whats_new.html。
如果你已经在使用 Matplotlib 的旧版本,可能需要更加关注这一部分来更新你的编码习惯。如果你完全是 Matplotlib 甚至 Python 的新手,可以直接跳过这部分,先开始使用 Matplotlib,稍后再回来查看。
默认样式的更改
Matplotlib 2.0 版本最显著的变化是默认样式的改变。你可以在这里查看更改的详细列表:matplotlib.org/devdocs/users/dflt_style_changes.html。
颜色循环
为了快速绘图而不必为每个数据系列单独设置颜色,Matplotlib 使用一个称为默认属性循环的颜色列表,每个系列将被分配循环中的一个默认颜色。在 Matplotlib 2.0 中,该列表已经从原来的红色、绿色、蓝色、青色、品红色、黄色和黑色(记作['b', 'g', 'r', 'c', 'm', 'y', 'k'])变更为 Tableau 软件引入的当前 category10 色板。顾名思义,新的色板有 10 种不同的颜色,适用于分类显示。通过导入 Matplotlib 并在 Python 中调用matplotlib.rcParams['axes.prop_cycle'],可以访问该列表。
色图
色图对于展示渐变非常有用。黄色到蓝色的“viridis”色图现在是 Matplotlib 2.0 中的默认色图。这个感知统一的色图比经典的“jet”色图更好地展示了数值的视觉过渡。下面是两种色图的对比:

除了默认的感知连续色图外,现在还可以使用定性色图将值分组为不同类别:

散点图
散点图中的点默认尺寸更大,且不再有黑色边缘,从而提供更清晰的视觉效果。如果没有指定颜色,则每个数据系列将使用默认颜色循环中的不同颜色:

图例
虽然早期版本将图例设置在右上角,但 Matplotlib 2.0 默认将图例位置设置为“最佳”。它自动避免图例与数据重叠。图例框还具有圆角、较浅的边缘和部分透明的背景,以便将读者的焦点保持在数据上。经典和当前默认样式中的平方数曲线展示了这一情况:

线条样式
线条样式中的虚线模式现在可以根据线条宽度进行缩放,以显示更粗的虚线以增加清晰度:

来自文档(https://matplotlib.org/users/dflt_style_changes.html#plot)
填充边缘和颜色
就像前面散点图中的点一样,大多数填充元素默认不再有黑色边缘,使图形看起来更简洁:

字体
默认字体已经从“Bitstream Vera Sans”更改为“DejaVu Sans”。当前的字体支持更多的国际字符、数学符号和符号字符,包括表情符号。
改进的功能或性能
Matplotlib 2.0 展示了改善用户体验的新特性,包括速度、输出质量和资源使用的优化。
改进的颜色转换 API 和 RGBA 支持
Alpha 通道,现在完全支持 Matplotlib 2.0,用于指定透明度。
改进的图像支持
Matplotlib 2.0 现在使用更少的内存和更少的数据类型转换重新采样图像。
更快的文本渲染
据称,Agg 后端的文本渲染速度提高了 20%。我们将在第九章中进一步讨论后端内容,添加交互性和动画图表。
默认动画编解码器的更改
为了生成动画图表的视频输出,现在默认使用更高效的编解码器 H.264 代替 MPEG-4。由于 H.264 具有更高的压缩率,较小的输出文件大小允许更长的视频记录时间,并减少加载它们所需的时间和网络数据。H.264 视频的实时播放通常比 MPEG-4 编码的视频更加流畅且质量更高。
设置的更改
为了方便性、一致性或避免意外结果,Matplotlib v2.0 中更改了一些设置。
新的配置参数(rcParams)
新增了一些参数,如 date.autoformatter.year 用于日期时间字符串格式化。
样式参数黑名单
不再允许样式文件配置与样式无关的设置,以防止意外后果。这些参数包括以下内容:
'interactive', 'backend', 'backend.qt4', 'webagg.port', 'webagg.port_retries', 'webagg.open_in_browser', 'backend_fallback', 'toolbar', 'timezone', 'datapath', 'figure.max_open_warning', 'savefig.directory', tk.window_focus', 'docstring.hardcopy'
Axes 属性关键字的更改
Axes 属性 axisbg 和 axis_bgcolor 被 facecolor 替代,以保持关键字一致性。
绘制我们的第一个图表
我们将从一个简单的平方曲线的线性图开始,即 y = x²。
加载数据以进行绘图
为了可视化数据,我们应该从“拥有”一些数据开始。虽然我们假设你手头有一些不错的数据可以展示,但我们将简要展示如何在 Python 中加载数据以进行绘图。
数据结构
有几个常见的数据结构我们将不断遇到。
列表
列表是 Python 中用于存储一组值的基本数据类型。列表是通过将元素值放入方括号中创建的。为了重用我们的列表,我们可以给它起个名字并像这样存储它:
evens = [2,4,6,8,10]
当我们希望获取更大范围的系列时,例如,为了让平方曲线更平滑,获取更多的数据点,我们可以使用 Python 的 range() 函数:
evens = range(2,102,2)
此命令将给出从 2 到 100(包含)的所有偶数,并将其存储在名为 evens 的列表中。
Numpy 数组
很多时候,我们处理的是更复杂的数据。如果你需要一个包含多个列的矩阵,或者想对集合中的所有元素进行数学操作,那么 numpy 就是你的选择:
import numpy as np
我们根据约定将numpy缩写为np,以保持代码简洁。
np.array() 将支持的数据类型(在此例中是列表)转换为 Numpy 数组。为了从我们的 evens 列表中生成一个 numpy 数组,我们这样做:
np.array(evens)
pandas 数据框
当我们在矩阵中有一些非数值标签或值时,pandas 数据框非常有用。它不像 Numpy 那样要求数据类型统一。列可以命名。还有一些函数,比如 melt() 和 pivot_table(),它们在重塑表格以便分析和绘图时提供了便利。
要将一个列表转换为 pandas 数据框,我们可以做如下操作:
import pandas as pd
pd.DataFrame(evens)
你也可以将一个 numpy 数组转换为 pandas 数据框。
从文件加载数据
虽然这一切能让你复习我们将要处理的数据结构,但在现实生活中,我们不是发明数据,而是从数据源读取它。制表符分隔的纯文本文件是最简单且最常见的数据输入类型。假设我们有一个名为 evens.txt 的文件,里面包含了前面提到的偶数。该文件有两列。第一列只记录不必要的信息,我们想要加载第二列的数据。
这就是假文本文件的样子:

基本的 Python 方式
我们可以初始化一个空列表,逐行读取文件,拆分每一行,并将第二个元素附加到我们的列表中:
evens = []
with open as f:
for line in f.readlines():
evens.append(line.split()[1])
当然,你也可以使用一行代码来实现:
evens = [int(x.split()[1]) for x in open('evens.txt').readlines()]
我们只是尝试一步步走,遵循 Python 的 Zen(禅哲学):简单优于复杂。
Numpy 方式
当我们有一个只有两列的文件,并且只需要读取一列时,这非常简单,但当我们拥有一个包含成千上万列和行的扩展表格,并且想要将其转换为 Numpy 矩阵时,它可能会变得更加繁琐。
Numpy 提供了一个标准的一行代码解决方案:
import numpy as np
np.loadtxt(‘evens.txt’,delimiter=’\t’,usecols=1,dtype=np.int32)
第一个参数是数据文件的路径。delimiter 参数指定用于分隔值的字符串,这里是一个制表符。因为 numpy.loadtxt() 默认情况下将任何空白符分隔的值拆分成列,所以这个参数在这里可以省略。我们为演示设置了它。
对于 usecols 和 dtype,它们分别指定要读取哪些列以及每列对应的数据类型,你可以传递单个值,也可以传递一个序列(例如列表)来读取多列。
Numpy 默认情况下还会跳过以 # 开头的行,这通常表示注释或标题行。你可以通过设置 comment 参数来更改这种行为。
pandas 方式
类似于 Numpy,pandas 提供了一种简单的方法将文本文件加载到 pandas 数据框中:
import pandas as pd
pd.read_csv(usecols=1)
这里的分隔符可以用 sep 或 delimiter 来表示,默认情况下它是逗号 ,(CSV 代表 逗号分隔值)。
关于如何处理不同的数据格式、数据类型和错误,有一长串不太常用的选项可供选择。你可以参考文档:pandas.pydata.org/pandas-docs/stable/generated/pandas.read_csv.html。除了平面 CSV 文件外,Pandas 还提供了读取其他常见数据格式的内置函数,例如 Excel、JSON、HTML、HDF5、SQL 和 Google BigQuery。
为了保持对数据可视化的关注,本书将不深入探讨数据清洗的方法,但这对于数据科学来说是一个非常有用的生存技能。如果你感兴趣,可以查阅关于使用 Python 进行数据处理的相关资源。
导入 Matplotlib 的 pyplot 模块
Matplotlib 包包含许多模块,其中包括控制美学的 artist 模块和用于设置默认值的 rcParams 模块。Pyplot 模块是我们主要处理的绘图接口,它以面向对象的方式创建数据图表。
按惯例,我们在导入时使用plt这个缩写:
import matplotlib.pylot as plt
别忘了运行 Jupyter Notebook 的单元格魔法 %matplotlib inline,以便将图形嵌入输出中。
不要使用 pylab 模块!
现在不推荐使用 pylab 模块,通常被 面向对象(OO)接口所替代。虽然 pylab 通过在一个命名空间下导入matplotlib.pyplot和numpy提供了一些便利,但现在许多在线的 pylab 示例仍然存在,但最好分别调用 Matplotlib.pyplot 和 numpy 模块。
绘制曲线
绘制列表的折线图可以简单到:
plt.plot(evens)

当只指定一个参数时,Pyplot 假定我们输入的数据位于 y 轴,并自动选择 x 轴的刻度。
要绘制图表,调用plt.plot(x,y),其中x和y是数据点的 x 坐标和 y 坐标:
plt.plot(evens,evens**2)
要为曲线添加图例标签,我们在 plot 函数中添加标签信息:
plt.plot(evens,evens**2,label = 'x²')
plt.legend()
查看图形
现在,别忘了调用plt.show()来显示图形!

保存图形
现在我们已经绘制了第一个图形。让我们保存我们的工作!当然,我们不想依赖截图。这里有一个简单的方法,通过调用 pyplot.savefig() 来完成。
如果你既想在屏幕上查看图像,又想将其保存在文件中,记得在调用 pyplot.show() 之前先调用 pyplot.savefig(),以确保你不会保存一个空白画布。
设置输出格式
pyplot.savefig() 函数接受输出文件的路径,并自动以指定的扩展名输出。例如,pyplot.savefig('output.png') 会生成一个 PNG 图像。如果没有指定扩展名,默认会生成 SVG 图像。如果指定的格式不受支持,比如.doc,会抛出一个 ValueError Python 异常:

PNG(便携式网络图形)
与另一种常见的图像文件格式 JPEG 相比,PNG 的优势在于允许透明背景。PNG 被大多数图像查看器和处理程序广泛支持。
PDF(便携式文档格式)
PDF 是一种标准的文档格式,你不必担心阅读器的可用性。然而,大多数办公软件不支持将 PDF 作为图像导入。
SVG(可伸缩矢量图形)
SVG 是一种矢量图形格式,可以在不失去细节的情况下进行缩放。因此,可以在较小的文件大小下获得更好的质量。它与 HTML5 兼容,适合用于网页。但某些基础图像查看器可能不支持它。
Post(Postscript)
Postscript 是一种用于电子出版的页面描述语言。它对于批量处理图像以进行出版非常有用。
Gimp 绘图工具包(GDK)的光栅图形渲染在 2.0 版本中已被弃用,这意味着像 JPG 和 TIFF 这样的图像格式不再由默认后端支持。我们将在后面更详细地讨论后端。
调整分辨率
分辨率衡量图像记录的细节。它决定了你可以在不失去细节的情况下放大图像的程度。具有较高分辨率的图像在较大尺寸下保持较高质量,但文件大小也会更大。
根据用途,你可能希望以不同的分辨率输出图形。分辨率是通过每英寸的颜色像素点数(dpi)来衡量的。你可以通过在pyplot.savefig()函数中指定dpi参数来调整输出图形的分辨率,例如:
plt.savefig('output.png',dpi=300)
虽然较高的分辨率能提供更好的图像质量,但它也意味着更大的文件大小,并且需要更多的计算机资源。以下是一些关于你应设置图像分辨率多高的参考:
- 幻灯片演示:96 dpi+
以下是微软针对不同屏幕大小的 PowerPoint 演示文稿图形分辨率建议:support.microsoft.com/en-us/help/827745/how-to-change-the-export-resolution-of-a-powerpoint-slide:
| 屏幕高度(像素) | 分辨率(dpi) |
|---|---|
| 720 | 96(默认) |
| 750 | 100 |
| 1125 | 150 |
| 1500 | 200 |
| 1875 | 250 |
| 2250 | 300 |
-
海报展示:300 dpi+
-
网络:72 dpi+(推荐使用可以响应式缩放的 SVG)
摘要
在本章中,你学习了如何使用 Matplotlib 绘制一个简单的折线图。我们设置了环境,导入了数据,并将图形输出为不同格式的图像。在下一章,你将学习如何可视化在线数据。
第七章:可视化在线数据
到目前为止,我们已经介绍了使用 Matplotlib 创建和定制图表的基础知识。在本章中,我们将通过在专门主题中的示例,开始了解更高级的 Matplotlib 使用方法。
在考虑可视化某个概念时,需要仔细考虑以下重要因素:
-
数据来源
-
数据过滤和处理
-
选择适合数据的图表类型:
-
可视化数据趋势:
- 折线图、区域图和堆叠区域图
-
可视化单变量分布:
- 条形图、直方图和核密度估计图
-
可视化双变量分布:
- 散点图、KDE 密度图和六边形图
-
可视化类别数据:
- 类别散点图、箱线图、蜂群图、小提琴图
-
-
调整图形美学以有效讲述故事
我们将通过使用人口统计和财务数据来讨论这些主题。首先,我们将讨论从 应用程序编程接口(API)获取数据时的典型数据格式。接下来,我们将探索如何将 Matplotlib 2.0 与 Pandas、Scipy 和 Seaborn 等其他 Python 包结合使用,以实现不同数据类型的可视化。
常见的 API 数据格式
许多网站通过 API 提供数据,API 是通过标准化架构连接应用程序的桥梁。虽然我们这里不打算详细讨论如何使用 API,因为网站特定的文档通常可以在线找到;但我们将展示在许多 API 中使用的三种最常见的数据格式。
CSV
CSV(逗号分隔值)是最古老的文件格式之一,它在互联网存在之前就已经被引入。然而,随着其他高级格式如 JSON 和 XML 的流行,CSV 格式现在逐渐被淘汰。顾名思义,数据值由逗号分隔。预安装的 csv 包和 pandas 包包含读取和写入 CSV 格式数据的类。这个 CSV 示例定义了一个包含两个国家的总人口表:
Country,Time,Sex,Age,Value
United Kingdom,1950,Male,0-4,2238.735
United States of America,1950,Male,0-4,8812.309
JSON
JSON(JavaScript 对象表示法)因其高效性和简洁性,近年来越来越受欢迎。JSON 允许指定数字、字符串、布尔值、数组和对象。Python 提供了默认的 json 包来解析 JSON。另外,pandas.read_json 类可以用于将 JSON 导入为 Pandas 数据框。前面的总人口表可以通过以下 JSON 示例表示:
{
"population": [
{
"Country": "United Kingdom",
"Time": 1950,
"Sex", "Male",
"Age", "0-4",
"Value",2238.735
},{
"Country": "United States of America",
"Time": 1950,
"Sex", "Male",
"Age", "0-4",
"Value",8812.309
},
]
}
XML
XML(可扩展标记语言)是数据格式中的瑞士军刀,已成为 Microsoft Office、Apple iWork、XHTML、SVG 等的默认容器。XML 的多功能性有其代价,因为它使得 XML 变得冗长且较慢。Python 中有多种解析 XML 的方法,但建议使用 xml.etree.ElementTree,因为它提供了 Python 风格的接口,并且有高效的 C 后端支持。本书不打算介绍 XML 解析,但其他地方有很好的教程(例如 eli.thegreenplace.net/2012/03/15/processing-xml-in-python-with-elementtree)。
例如,相同的人口表可以转换为 XML 格式:
<?xml version='1.0' encoding='utf-8'?>
<populations>
<population>
<Country>United Kingdom</Country>
<Time>1950</Time>
<Sex>Male</Sex>
<Age>0-4</Age>
<Value>2238.735</Value>
</population>
<population>
<Country>United States of America</Country>
<Time>1950</Time>
<Sex>Male</Sex>
<Age>0-4</Age>
<Value>8812.309</Value>
</population>
</populations>
介绍 pandas
除了 NumPy 和 SciPy,pandas 是 Python 中最常见的科学计算库之一。其作者旨在使 pandas 成为任何语言中最强大、最灵活的开源数据分析和处理工具,实际上,他们几乎实现了这一目标。其强大且高效的库与数据科学家的需求完美契合。像其他 Python 包一样,Pandas 可以通过 PyPI 轻松安装:
pip install pandas
Matplotlib 在 1.5 版本中首次引入,支持将 pandas DataFrame 作为输入应用于各种绘图类。Pandas DataFrame 是一种强大的二维标签数据结构,支持索引、查询、分组、合并以及其他一些常见的关系数据库操作。DataFrame 类似于电子表格,因为 DataFrame 的每一行包含一个实例的不同变量,而每一列则包含一个特定变量在所有实例中的向量。
pandas DataFrame 支持异构数据类型,如字符串、整数和浮点数。默认情况下,行按顺序索引,列由 pandas Series 组成。可以通过 index 和 columns 属性指定可选的行标签或列标签。
导入在线人口数据(CSV 格式)
让我们首先来看一下将在线 CSV 文件导入 pandas DataFrame 的步骤。在这个例子中,我们将使用联合国经济和社会事务部在 2015 年发布的年度人口总结数据集。该数据集还包含了面向 2100 年的人口预测数据:
import numpy as np # Python scientific computing package
import pandas as pd # Python data analysis package
# URL for Annual Population by Age and Sex - Department of Economic
# and Social Affairs, United Nations
source = "https://github.com/PacktPublishing/Matplotlib-2.x-By-Example/blob/master/WPP2015_DB04_Population_Annual.zip"
# Pandas support both local or online files
data = pd.read_csv(source, header=0, compression='zip', encoding='latin_1')
# Show the first five rows of the DataFrame
data.head()
代码的预期输出如下所示:
| LocID | Location | VarID | Variant | Time | MidPeriod | SexID | Sex | AgeGrp | AgeGrpStart | AgeGrpSpan | Value | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 0-4 | 0 | 5 | 630.044 |
| 1 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 5-9 | 5 | 5 | 516.205 |
| 2 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 10-14 | 10 | 5 | 461.378 |
| 3 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 15-19 | 15 | 5 | 414.368 |
| 4 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 20-24 | 20 | 5 | 374.110 |
pandas.read_csv 类极为多功能,支持列标题、自定义分隔符、各种压缩格式(例如,.gzip、.bz2、.zip 和 .xz)、不同的文本编码等。读者可以参考文档页面(pandas.pydata.org/pandas-docs/stable/generated/pandas.read_csv.html)获取更多信息。
通过调用 Pandas DataFrame 对象的 .head() 函数,我们可以快速查看数据的前五行。
在本章中,我们将把这个人口数据集与 Quandl 中的其他数据集合并。不过,Quandl 使用三字母国家代码(ISO 3166 alpha-3)来表示地理位置;因此我们需要相应地重新格式化地点名称。
pycountry 包是根据 ISO 3166 标准转换国家名称的优秀选择。同样,pycountry 可以通过 PyPI 安装:
pip install pycountry
继续之前的代码示例,我们将为数据框添加一个新的 country 列:
from pycountry import countries
def get_alpha_3(location):
"""Convert full country name to three letter code (ISO 3166 alpha-3)
Args:
location: Full location name
Returns:
three letter code or None if not found"""
try:
return countries.get(name=location).alpha_3
except:
return None
# Add a new country column to the dataframe
population_df['country'] = population_df['Location'].apply(lambda x: get_alpha_3(x))
population_df.head()
代码的预期输出如下所示:
| - | LocID | Location | VarID | Variant | Time | MidPeriod | SexID | Sex | AgeGrp | AgeGrpStart | AgeGrpSpan | Value | country |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 0-4 | 0 | 5 | 630.044 | AFG |
| 1 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 5-9 | 5 | 5 | 516.205 | AFG |
| 2 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 10-14 | 10 | 5 | 461.378 | AFG |
| 3 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 15-19 | 15 | 5 | 414.368 | AFG |
| 4 | 4 | 阿富汗 | 2 | 中等 | 1950 | 1950.5 | 1 | 男性 | 20-24 | 20 | 5 | 374.110 | AFG |
导入在线财务数据(JSON 格式)
在本章中,我们还将利用 Quandl 的 API 提取财务数据,并创建有洞察力的可视化图表。如果你不熟悉 Quandl,它是一个财务和经济数据仓库,存储了来自数百家出版商的数百万个数据集。Quandl 最棒的地方在于,这些数据集通过统一的 API 进行交付,无需担心如何正确解析数据。匿名用户每天可以进行最多 50 次 API 调用,注册用户可获得最多 500 次免费的 API 调用。读者可以在www.quandl.com/?modal=register注册免费 API 密钥。
在 Quandl 中,每个数据集都有一个唯一的 ID,这个 ID 在每个搜索结果网页上由 Quandl Code 定义。例如,Quandl 代码 GOOG/NASDAQ_SWTX 定义了 Google 财务发布的历史 NASDAQ 指数数据。每个数据集有三种格式可用——CSV、JSON 和 XML。
尽管 Quandl 提供了官方的 Python 客户端库,但为了演示导入 JSON 数据的一般过程,我们将不使用它。根据 Quandl 的文档,我们可以通过以下 API 调用获取 JSON 格式的数据表:
GET https://www.quandl.com/api/v3/datasets/{Quandl code}/data.json
让我们尝试从 Quandl 获取巨无霸指数数据。
from urllib.request import urlopen
import json
import time
import pandas as pd
def get_bigmac_codes():
"""Get a Pandas DataFrame of all codes in the Big Mac index dataset
The first column contains the code, while the second header
contains the description of the code.
for example,
ECONOMIST/BIGMAC_ARG,Big Mac Index - Argentina
ECONOMIST/BIGMAC_AUS,Big Mac Index - Australia
ECONOMIST/BIGMAC_BRA,Big Mac Index - Brazil
Returns:
codes: Pandas DataFrame of Quandl dataset codes"""
codes_url = "https://www.quandl.com/api/v3/databases/ECONOMIST/codes"
codes = pd.read_csv(codes_url, header=None, names=['Code', 'Description'],
compression='zip', encoding='latin_1')
return codes
def get_quandl_dataset(api_key, code):
"""Obtain and parse a quandl dataset in Pandas DataFrame format
Quandl returns dataset in JSON format, where data is stored as a
list of lists in response['dataset']['data'], and column headers
stored in response['dataset']['column_names'].
for example, {'dataset': {...,
'column_names': ['Date',
'local_price',
'dollar_ex',
'dollar_price',
'dollar_ppp',
'dollar_valuation',
'dollar_adj_valuation',
'euro_adj_valuation',
'sterling_adj_valuation',
'yen_adj_valuation',
'yuan_adj_valuation'],
'data': [['2017-01-31',
55.0,
15.8575,
3.4683903515687,
10.869565217391,
-31.454736135007,
6.2671477203176,
8.2697553162259,
29.626894343348,
32.714616745128,
13.625825886047],
['2016-07-31',
50.0,
14.935,
3.3478406427854,
9.9206349206349,
-33.574590420925,
2.0726096168216,
0.40224795003514,
17.56448458418,
19.76377270142,
11.643103380531]
],
'database_code': 'ECONOMIST',
'dataset_code': 'BIGMAC_ARG',
... }}
A custom column--country is added to denote the 3-letter country code.
Args:
api_key: Quandl API key
code: Quandl dataset code
Returns:
df: Pandas DataFrame of a Quandl dataset
"""
base_url = "https://www.quandl.com/api/v3/datasets/"
url_suffix = ".json?api_key="
# Fetch the JSON response
u = urlopen(base_url + code + url_suffix + api_key)
response = json.loads(u.read().decode('utf-8'))
# Format the response as Pandas Dataframe
df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']['column_names'])
# Label the country code
df['country'] = code[-3:]
return df
quandl_dfs = []
codes = get_bigmac_codes()
# Replace this with your own API key
api_key = "INSERT YOUR KEY HERE"
for code in codes.Code:
# Get the DataFrame of a Quandl dataset
df = get_quandl_dataset(api_key, code)
# Store in a list
quandl_dfs.append(df)
# Prevents exceeding the API speed limit
time.sleep(2)
# Concatenate the list of dataframes into a single one
bigmac_df = pd.concat(quandl_dfs)
bigmac_df.head()
预期的输出如下:
| - | 日期 | 本地价格 | 美元汇率 | 美元价格 | 美元 PPP | 美元估值 | 美元调整估值 | 欧元调整估值 | 英镑调整估值 | 日元调整估值 | 人民币调整估值 | 国家 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2017-01-31 | 55.0 | 15.85750 | 3.468390 | 10.869565 | -31.454736 | 6.26715 | 8.26976 | 29.6269 | 32.7146 | 13.6258 | 阿根廷 |
| 1 | 2016-07-31 | 50.0 | 14.93500 | 3.347841 | 9.920635 | -33.574590 | 2.07261 | 0.402248 | 17.5645 | 19.7638 | 11.6431 | 阿根廷 |
| 2 | 2016-01-31 | 33.0 | 13.80925 | 2.389703 | 6.693712 | -51.527332 | -24.8619 | -18.714 | -18.7209 | 0.40859 | -17.029 | 阿根廷 |
| 3 | 2015-07-31 | 28.0 | 9.13500 | 3.065134 | 5.845511 | -36.009727 | -4.7585 | -0.357918 | -6.01091 | 30.8609 | 5.02868 | 阿根廷 |
| 4 | 2015-01-31 | 28.0 | 8.61000 | 3.252033 | 5.845511 | -32.107881 | 0.540242 | -0.804495 | -2.49468 | 34.3905 | 6.01183 | 阿根廷 |
巨无霸指数是《经济学人》于 1986 年发明的,用来轻松检查货币是否在正确的水平。它基于购买力平价(PPP)理论,并被认为是货币汇率在 PPP 下的非正式衡量标准。它通过与类似商品和服务的价格对比来衡量货币的价值,在这种情况下,是巨无霸的价格。市场汇率下不同的价格意味着某种货币被低估或高估。
从 Quandl API 解析 JSON 的代码稍微复杂一些,因此额外的解释可能有助于你理解它。第一个函数get_bigmac_codes()解析 Quandl Economist 数据库中所有可用数据集代码的列表,并将其作为 pandas DataFrame 返回。与此同时,第二个函数get_quandl_dataset(api_key, code)将 Quandl 数据集 API 查询的 JSON 响应转换为 pandas DataFrame。所有获取的数据集都通过pandas.concat()进行拼接。
可视化数据趋势
一旦我们导入了这两个数据集,就可以开始进一步的可视化之旅。让我们从绘制 1950 年到 2017 年的全球人口趋势开始。为了根据某一列的值选择行,我们可以使用以下语法:df[df.variable_name == "target"]或df[df['variable_name'] == "target"],其中df是数据框对象。其他条件运算符,如大于 > 或小于 <,也支持。可以使用“与”运算符&或“或”运算符|将多个条件语句链在一起。
为了聚合某一年内所有年龄组的人口数据,我们将依赖 df.groupby().sum(),如以下示例所示:
import matplotlib.pyplot as plt
# Select the aggregated population data from the world for both genders,
# during 1950 to 2017.
selected_data = data[(data.Location == 'WORLD') & (data.Sex == 'Both') & (data.Time <= 2017) ]
# Calculate aggregated population data across all age groups for each year
# Set as_index=False to avoid the Time variable to be used as index
grouped_data = selected_data.groupby('Time', as_index=False).sum()
# Generate a simple line plot of population vs time
fig = plt.figure()
plt.plot(grouped_data.Time, grouped_data.Value)
# Label the axis
plt.xlabel('Year')
plt.ylabel('Population (thousands)')
plt.show()

区域图和堆叠区域图
有时,我们可能希望通过为线图下方的区域填充颜色来增加视觉冲击力。可以通过 fill_between 类来实现这一点:
fill_between(x, y1, y2=0, where=None, interpolate=False, step=None)
默认情况下,当未指定 y2 时,fill_between 会为 y=0 和曲线之间的区域着色。可以通过使用 where、interpolate 和 step 等关键字参数来指定更复杂的着色行为。读者可以通过以下链接获取更多信息:matplotlib.org/examples/pylab_examples/fill_between_demo.html
让我们尝试通过区分男女来绘制一个更详细的图表。我们将探讨男性和女性对人口增长的相对贡献。为此,我们可以使用 stackplot 类绘制堆叠区域图:
# Select the aggregated population data from the world for each gender,
# during 1950 to 2017.
male_data = data[(data.Location == 'WORLD') & (data.Sex == 'Male') & (data.Time <= 2017) ]
female_data = data[(data.Location == 'WORLD') & (data.Sex == 'Female') & (data.Time <= 2017) ]
# Calculate aggregated population data across all age groups for each year
# Set as_index=False to avoid the Time variable to be used as index
grouped_male_data = male_data.groupby('Time', as_index=False).sum()
grouped_female_data = female_data.groupby('Time', as_index=False).sum()
# Create two subplots with shared y-axis (sharey=True)
fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2, figsize=(12,4), sharey=True)
# Generate a simple line plot of population vs time,
# then shade the area under the line in sky blue.
ax1.plot(grouped_data.Time, grouped_data.Value)
ax1.fill_between(grouped_data.Time, grouped_data.Value, color='skyblue')
# Use set_xlabel() or set_ylabel() instead to set the axis label of an
# axes object
ax1.set_xlabel('Year')
ax1.set_ylabel('Population (thousands)')
# Generate a stacked area plot of population vs time
ax2.stackplot(grouped_male_data.Time, grouped_male_data.Value, grouped_female_data.Value)
# Add a figure legend
ax2.legend(['Male', 'Female'], loc='upper left')
# Set the x-axis label only this time
ax2.set_xlabel('Year')
plt.show()

介绍 Seaborn
Seaborn 是由 Michael Waskom 开发的一个统计可视化库,建立在 Matplotlib 之上。它提供了用于可视化类别变量、单变量分布和双变量分布的便捷函数。对于更复杂的图表,提供了多种统计方法,如线性回归模型和聚类算法。像 Matplotlib 一样,Seaborn 也支持 Pandas 数据框作为输入,并自动进行必要的切片、分组、聚合以及统计模型拟合,从而生成有用的图形。
这些 Seaborn 函数旨在通过最小化的参数集,通过 API 创建出版级质量的图形,同时保持 Matplotlib 完整的自定义功能。事实上,Seaborn 中的许多函数在调用时会返回一个 Matplotlib 轴或网格对象。因此,Seaborn 是 Matplotlib 的得力伙伴。要通过 PyPI 安装 Seaborn,可以在终端中运行以下命令:
pip install pandas
Seaborn 将在本书中以 sns 导入。本节不会是 Seaborn 的文档,而是从 Matplotlib 用户的角度,概述 Seaborn 的功能。读者可以访问 Seaborn 的官方网站 (seaborn.pydata.org/index.html) 获取更多信息。
可视化单变量分布
Seaborn 使得可视化数据集分布的任务变得更加容易。以之前讨论的人口数据为例,让我们通过绘制条形图来查看 2017 年不同国家的人口分布情况:
import seaborn as sns
import matplotlib.pyplot as plt
# Extract USA population data in 2017
current_population = population_df[(population_df.Location
== 'United States of America') &
(population_df.Time == 2017) &
(population_df.Sex != 'Both')]
# Population Bar chart
sns.barplot(x="AgeGrp",y="Value", hue="Sex", data = current_population)
# Use Matplotlib functions to label axes rotate tick labels
ax = plt.gca()
ax.set(xlabel="Age Group", ylabel="Population (thousands)")
ax.set_xticklabels(ax.xaxis.get_majorticklabels(), rotation=45)
plt.title("Population Barchart (USA)")
# Show the figure
plt.show()

Seaborn 中的条形图
seaborn.barplot() 函数显示一系列数据点作为矩形条。如果每组有多个点,则在条形顶部显示置信区间,以指示点估计的不确定性。与大多数其他 Seaborn 函数一样,支持各种输入数据格式,如 Python 列表、Numpy 数组、pandas Series 和 pandas DataFrame。
展示人口结构的更传统方式是通过人口金字塔。
那么什么是人口金字塔?顾名思义,它是显示人口年龄分布的金字塔形绘图。它可以粗略地分为三类,即压缩型、稳定型和扩张型,分别用于经历负增长、稳定增长和快速增长的人口。例如,压缩型人口的年轻人比例较低,因此金字塔底部看起来受限。稳定型人口的年轻人和中年组相对较多。而扩张型人口则有大量年轻人,从而导致金字塔底部扩大。
我们可以通过在两个共享 y 轴的子图上绘制两个条形图来构建人口金字塔:
import seaborn as sns
import matplotlib.pyplot as plt
# Extract USA population data in 2017
current_population = population_df[(population_df.Location
== 'United States of America') &
(population_df.Time == 2017) &
(population_df.Sex != 'Both')]
# Change the age group to descending order
current_population = current_population.iloc[::-1]
# Create two subplots with shared y-axis
fig, axes = plt.subplots(ncols=2, sharey=True)
# Bar chart for male
sns.barplot(x="Value",y="AgeGrp", color="darkblue", ax=axes[0],
data = current_population[(current_population.Sex == 'Male')])
# Bar chart for female
sns.barplot(x="Value",y="AgeGrp", color="darkred", ax=axes[1],
data = current_population[(current_population.Sex == 'Female')])
# Use Matplotlib function to invert the first chart
axes[0].invert_xaxis()
# Use Matplotlib function to show tick labels in the middle
axes[0].yaxis.tick_right()
# Use Matplotlib functions to label the axes and titles
axes[0].set_title("Male")
axes[1].set_title("Female")
axes[0].set(xlabel="Population (thousands)", ylabel="Age Group")
axes[1].set(xlabel="Population (thousands)", ylabel="")
fig.suptitle("Population Pyramid (USA)")
# Show the figure
plt.show()

由于 Seaborn 建立在 Matplotlib 的坚实基础之上,我们可以使用 Matplotlib 的内置函数轻松定制绘图。在前面的例子中,我们使用 matplotlib.axes.Axes.invert_xaxis() 将男性人口图水平翻转,然后使用 matplotlib.axis.YAxis.tick_right() 将刻度标签位置改为右侧。我们进一步使用 matplotlib.axes.Axes.set_title()、matplotlib.axes.Axes.set() 和 matplotlib.figure.Figure.suptitle() 组合定制了绘图的标题和轴标签。
我们尝试通过将行 population_df.Location == 'United States of America' 更改为 population_df.Location == 'Cambodia' 或 population_df.Location == 'Japan' 来绘制柬埔寨和日本的人口金字塔。你能把金字塔分类到三类人口金字塔中的一类吗?


为了看到 Seaborn 如何简化相对复杂绘图的代码,让我们看看如何使用原始 Matplotlib 实现类似的绘图。
首先,像之前基于 Seaborn 的示例一样,我们创建具有共享 y 轴的两个子图:
fig, axes = plt.subplots(ncols=2, sharey=True)
接下来,我们使用 matplotlib.pyplot.barh() 绘制水平条形图,并设置刻度的位置和标签,然后调整子图间距:
# Get a list of tick positions according to the data bins
y_pos = range(len(current_population.AgeGrp.unique()))
# Horizontal barchart for male
axes[0].barh(y_pos, current_population[(current_population.Sex ==
'Male')].Value, color="darkblue")
# Horizontal barchart for female
axes[1].barh(y_pos, current_population[(current_population.Sex ==
'Female')].Value, color="darkred")
# Show tick for each data point, and label with the age group
axes[0].set_yticks(y_pos)
axes[0].set_yticklabels(current_population.AgeGrp.unique())
# Increase spacing between subplots to avoid clipping of ytick labels
plt.subplots_adjust(wspace=0.3)
最后,我们使用相同的代码进一步定制图形的外观和感觉:
# Invert the first chart
axes[0].invert_xaxis()
# Show tick labels in the middle
axes[0].yaxis.tick_right()
# Label the axes and titles
axes[0].set_title("Male")
axes[1].set_title("Female")
axes[0].set(xlabel="Population (thousands)", ylabel="Age Group")
axes[1].set(xlabel="Population (thousands)", ylabel="")
fig.suptitle("Population Pyramid (USA)")
# Show the figure
plt.show()

与基于 Seaborn 的代码相比,纯 Matplotlib 实现需要额外的代码行来定义刻度位置、刻度标签和子图间距。对于一些其他包含额外统计计算(如线性回归、皮尔逊相关)的 Seaborn 图表类型,代码的简化更加明显。因此,Seaborn 是一个“开箱即用”的统计可视化包,使用户可以写出更简洁的代码。
Seaborn 中的直方图和分布拟合
在人口示例中,原始数据已经分为不同的年龄组。如果数据没有被分组(例如大麦指数数据),该怎么办呢?事实证明,seaborn.distplot可以帮助我们将数据分组,并显示相应的直方图。让我们看一下这个例子:
import seaborn as sns
import matplotlib.pyplot as plt
# Get the BigMac index in 2017
current_bigmac = bigmac_df[(bigmac_df.Date == "2017-01-31")]
# Plot the histogram
ax = sns.distplot(current_bigmac.dollar_price)
plt.show()

seaborn.distplot函数期望输入的是 pandas Series、单维度的 numpy.array 或者 Python 列表。然后,它根据 Freedman-Diaconis 规则确定箱子的大小,最后在直方图上拟合核密度估计(KDE)。
KDE 是一种非参数方法,用于估计变量的分布。我们还可以提供一个参数分布,例如贝塔分布、伽马分布或正态分布,作为fit参数。
在这个例子中,我们将拟合来自scipy.stats包的正态分布到大麦指数数据集:
from scipy import stats
ax = sns.distplot(current_bigmac.dollar_price, kde=False, fit=stats.norm)
plt.show()

可视化双变量分布
我们应当记住,大麦指数在不同国家之间并不能直接比较。通常,我们会预期贫穷国家的商品比富裕国家的便宜。为了更公平地呈现该指数,最好显示大麦价格与国内生产总值(GDP)人均的关系。
我们将从 Quandl 的世界银行世界发展指标(WWDI)数据集中获取人均 GDP 数据。基于之前获取 Quandl JSON 数据的代码示例,你能尝试将其修改为下载人均 GDP 数据集吗?
对于不耐烦的人,这里是完整的代码:
import urllib
import json
import pandas as pd
import time
from urllib.request import urlopen
def get_gdp_dataset(api_key, country_code):
"""Obtain and parse a quandl GDP dataset in Pandas DataFrame format
Quandl returns dataset in JSON format, where data is stored as a
list of lists in response['dataset']['data'], and column headers
stored in response['dataset']['column_names'].
Args:
api_key: Quandl API key
country_code: Three letter code to represent country
Returns:
df: Pandas DataFrame of a Quandl dataset
"""
base_url = "https://www.quandl.com/api/v3/datasets/"
url_suffix = ".json?api_key="
# Compose the Quandl API dataset code to get GDP per capita
# (constant 2000 US$) dataset
gdp_code = "WWDI/" + country_code + "_NY_GDP_PCAP_KD"
# Parse the JSON response from Quandl API
# Some countries might be missing, so we need error handling code
try:
u = urlopen(base_url + gdp_code + url_suffix + api_key)
except urllib.error.URLError as e:
print(gdp_code,e)
return None
response = json.loads(u.read().decode('utf-8'))
# Format the response as Pandas Dataframe
df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']['column_names'])
# Add a new country code column
df['country'] = country_code
return df
api_key = "INSERT YOUR KEY HERE"
quandl_dfs = []
# Loop through all unique country code values in the BigMac index DataFrame
for country_code in bigmac_df.country.unique():
# Fetch the GDP dataset for the corresponding country
df = get_gdp_dataset(api_key, country_code)
# Skip if the response is empty
if df is None:
continue
# Store in a list DataFrames
quandl_dfs.append(df)
# Prevents exceeding the API speed limit
time.sleep(2)
# Concatenate the list of DataFrames into a single one
gdp_df = pd.concat(quandl_dfs)
gdp_df.head()
预期输出:
WWDI/EUR_NY_GDP_PCAP_KD HTTP Error 404: Not Found
WWDI/SIN_NY_GDP_PCAP_KD HTTP Error 404: Not Found
WWDI/ROC_NY_GDP_PCAP_KD HTTP Error 404: Not Found
WWDI/UAE_NY_GDP_PCAP_KD HTTP Error 404: Not Found
| 日期 | 值 | 国家 | |
|---|---|---|---|
| 0 | 2015-12-31 | 10501.660269 | ARG |
| 1 | 2014-12-31 | 10334.780146 | ARG |
| 2 | 2013-12-31 | 10711.229530 | ARG |
| 3 | 2012-12-31 | 10558.265365 | ARG |
| 4 | 2011-12-31 | 10780.342508 | ARG |
我们可以看到,人均 GDP 数据集在四个地理位置上不可用,但我们现在可以忽略这一点。
接下来,我们将使用pandas.merge()合并包含大麦指数和人均 GDP 的两个 DataFrame。WWDI 人均 GDP 数据集的最新记录是在 2015 年底收集的,所以我们将其与同年对应的大麦指数数据集配对。
对于熟悉 SQL 语言的用户,pandas.merge()支持四种模式,即左连接、右连接、内连接和外连接。由于我们只关心两个 DataFrame 中都有匹配国家的行,所以我们将选择内连接:
merged_df = pd.merge(bigmac_df[(bigmac_df.Date == "2015-01-31")], gdp_df[(gdp_df.Date == "2015-12-31")], how='inner', on='country')
merged_df.head()
| Date_x | local_price | dollar_ex | dollar_price | dollar_ppp | dollar_valuation | dollar_adj_valuation | euro_adj_valuation | sterling_adj_valuation | yen_adj_valuation | yuan_adj_valuation | country | Date_y | Value | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2015-01-31 | 28.00 | 8.610000 | 3.252033 | 5.845511 | -32.107881 | 0.540242 | -0.804495 | -2.49468 | 34.3905 | 6.01183 | ARG | 2015-12-31 | 10501.660269 |
| 1 | 2015-01-31 | 5.30 | 1.227220 | 4.318705 | 1.106472 | -9.839144 | -17.8995 | -18.9976 | -20.3778 | 9.74234 | -13.4315 | AUS | 2015-12-31 | 54688.445933 |
| 2 | 2015-01-31 | 13.50 | 2.592750 | 5.206827 | 2.818372 | 8.702019 | 68.4555 | 66.2024 | 63.3705 | 125.172 | 77.6231 | BRA | 2015-12-31 | 11211.891104 |
| 3 | 2015-01-31 | 2.89 | 0.661594 | 4.368235 | 0.603340 | -8.805115 | 3.11257 | 1.73343 | 0 | 37.8289 | 8.72415 | GBR | 2015-12-31 | 41182.619517 |
| 4 | 2015-01-31 | 5.70 | 1.228550 | 4.639616 | 1.189979 | -3.139545 | -2.34134 | -3.64753 | -5.28928 | 30.5387 | 2.97343 | CAN | 2015-12-31 | 50108.065004 |
Seaborn 中的散点图
散点图是科学和商业世界中最常见的图表之一。它尤其适用于显示两个变量之间的关系。虽然我们可以简单地使用matplotlib.pyplot.scatter来绘制散点图,但我们也可以使用 Seaborn 来构建具有更多高级功能的类似图表。
seaborn.regplot()和seaborn.lmplot()这两个函数以散点图的形式显示线性关系、回归线,以及回归线周围的 95% 置信区间。两者的主要区别在于,lmplot()结合了regplot()和FacetGrid,使我们能够创建带有颜色编码或分面散点图,显示三个或更多变量对之间的交互作用。我们将在本章和下一章展示lmplot()的使用。
seaborn.regplot()的最简单形式支持 numpy 数组、pandas Series 或 pandas DataFrame 作为输入。可以通过指定fit_reg=False来移除回归线和置信区间。
我们将研究一个假设,即在较贫穷的国家巨无霸更便宜,反之亦然,并检查巨无霸指数与人均 GDP 之间是否存在相关性:
import seaborn as sns
import matplotlib.pyplot as plt
# seaborn.regplot() returns matplotlib.Axes object
ax = sns.regplot(x="Value", y="dollar_price", data=merged_df, fit_reg=False)
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")
plt.show()
预期的输出:

到目前为止一切顺利!看起来巨无霸指数与人均 GDP 正相关。让我们重新启用回归线,并标注一些巨无霸指数值极端的国家:
ax = sns.regplot(x="Value", y="dollar_price", data=merged_df)
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")
# Label the country code for those who demonstrate extreme BigMac index
for row in merged_df.itertuples():
if row.dollar_price >= 5 or row.dollar_price <= 2:
ax.text(row.Value,row.dollar_price+0.1,row.country)
plt.show()
这是预期的输出:

我们可以看到,许多国家的数据都落在回归线的置信区间内。根据每个国家的人均 GDP 水平,线性回归模型预测了相应的巨无霸指数。如果实际指数偏离回归模型,则货币价值可能出现低估或高估的迹象。
通过标注那些显示极高或极低值的国家,我们可以清晰地看到,即使考虑到 GDP 差异,巴西和瑞士的巨无霸平均价格被高估,而印度、俄罗斯和乌克兰的价格则被低估。
由于 Seaborn 并不是一个用于统计分析的包,我们需要依赖其他包,如scipy.stats或statsmodels,来获得回归模型的参数。在下一个示例中,我们将从回归模型中获取slope和intercept参数,并为高于或低于回归线的点应用不同的颜色:
from scipy.stats import linregress
ax = sns.regplot(x="Value", y="dollar_price", data=merged_df)
ax.set_xlabel("GDP per capita (constant 2000 US$)")
ax.set_ylabel("BigMac index (US$)")
# Calculate linear regression parameters
slope, intercept, r_value, p_value, std_err = linregress(merged_df.Value, merged_df.dollar_price)
colors = []
for row in merged_df.itertuples():
if row.dollar_price > row.Value * slope + intercept:
# Color markers as darkred if they are above the regression line
color = "darkred"
else:
# Color markers as darkblue if they are below the regression line
color = "darkblue"
# Label the country code for those who demonstrate extreme BigMac index
if row.dollar_price >= 5 or row.dollar_price <= 2:
ax.text(row.Value,row.dollar_price+0.1,row.country)
# Highlight the marker that corresponds to China
if row.country == "CHN":
t = ax.text(row.Value,row.dollar_price+0.1,row.country)
color = "yellow"
colors.append(color)
# Overlay another scatter plot on top with marker-specific color
ax.scatter(merged_df.Value, merged_df.dollar_price, c=colors)
# Label the r squared value and p value of the linear regression model.
# transform=ax.transAxes indicates that the coordinates are given relative
# to the axes bounding box, with 0,0 being the lower left of the axes
# and 1,1 the upper right.
ax.text(0.1, 0.9, "$r²={0:.3f}, p={1:.3e}$".format(r_value ** 2, p_value), transform=ax.transAxes)
plt.show()

与普遍观点相反,2015 年中国的货币似乎并没有显著低估,因为其标记完全落在回归线的 95%置信区间内。
为了更好地展示数值的分布,我们可以通过seaborn.jointplot()将x或y值的直方图与散点图结合起来:
# seaborn.jointplot() returns a seaborn.JointGrid object
g = sns.jointplot(x="Value", y="dollar_price", data=merged_df)
# Provide custom axes labels through accessing the underlying axes object
# We can get matplotlib.axes.Axes of the scatter plot by calling g.ax_joint
g.ax_joint.set_xlabel("GDP per capita (constant 2000 US$)")
g.ax_joint.set_ylabel("BigMac index (US$)")
# Set the title and adjust the margin
g.fig.suptitle("Relationship between GDP per capita and BigMac Index")
g.fig.subplots_adjust(top=0.9)
plt.show()

通过在jointplot中额外指定kind参数为reg、resid、hex或kde,我们可以迅速将图表类型分别更改为回归图、残差图、六边形图或 KDE 轮廓图。

在此给出一个重要声明:根据我们手头的数据,现在下结论关于货币估值仍然为时过早!劳动力成本、租金、原材料成本和税收等不同的商业因素都会对巨无霸的定价模型产生影响,但这些内容超出了本书的范围。
可视化分类数据
在本章的最后,我们来整合一下到目前为止我们处理过的所有数据集。还记得在本章开头我们简要介绍过三种人口结构类别(即收缩型、稳定型和扩展型)吗?
在本节中,我们将实现一个简单的算法,将人口分类为三种类别之一。之后,我们将探索不同的可视化分类数据的技术。
在线上,大多数参考文献只讨论了人口金字塔的可视化分类(例如,www.populationeducation.org/content/what-are-different-types-population-pyramids)。确实存在基于聚类的方法(例如,Korenjak-Cˇ erne, Kejžar, Batagelj (2008)。人口金字塔的聚类。Informatica. 32.),但是迄今为止,人口类别的数学定义很少被讨论。我们将在下一个示例中构建一个基于“0-4”和“50-54”年龄组之间人口比例的简单分类器:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
# Select total population for each country in 2015
current_population = population_df[(population_df.Time == 2015) &
(population_df.Sex == 'Both')]
# A list for storing the population type for each country
pop_type_list = []
# Look through each country in the BigMac index dataset
for country in merged_df.country.unique():
# Make sure the country also exist in the GDP per capita dataset
if not country in current_population.country.values:
continue
# Calculate the ratio of population between "0-4" and "50-54"
# age groups
young = current_population[(current_population.country == country) &
(current_population.AgeGrp == "0-4")].Value
midage = current_population[(current_population.country == country) &
(current_population.AgeGrp == "50-54")].Value
ratio = float(young) / float(midage)
# Classify the populations based on arbitrary ratio thresholds
if ratio < 0.8:
pop_type = "constrictive"
elif ratio < 1.2 and ratio >= 0.8:
pop_type = "stable"
else:
pop_type = "expansive"
pop_type_list.append([country, ratio, pop_type])
# Convert the list to Pandas DataFrame
pop_type_df = pd.DataFrame(pop_type_list, columns=['country','ratio','population type'])
# Merge the BigMac index DataFrame with population type DataFrame
merged_df2 = pd.merge(merged_df, pop_type_df, how='inner', on='country')
merged_df2.head()
期望的输出如下:
| Date_x | local_price | dollar_ex | dollar_price | dollar_ppp | dollar_valuation | dollar_adj_valuation | euro_adj_valuation | sterling_adj_valuation | yen_adj_valuation | yuan_adj_valuation | country | Date_y | Value | ratio | population type | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2015-01-31 | 28.00 | 8.610000 | 3.252033 | 5.845511 | -32.107881 | 0.540242 | -0.804495 | -2.49468 | 34.3905 | 6.01183 | ARG | 2015-12-31 | 10501.660269 | 1.695835 | 扩张 |
| 1 | 2015-01-31 | 5.30 | 1.227220 | 4.318705 | 1.106472 | -9.839144 | -17.8995 | -18.9976 | -20.3778 | 9.74234 | -13.4315 | AUS | 2015-12-31 | 54688.445933 | 0.961301 | 稳定 |
| 2 | 2015-01-31 | 13.50 | 2.592750 | 5.206827 | 2.818372 | 8.702019 | 68.4555 | 66.2024 | 63.3705 | 125.172 | 77.6231 | BRA | 2015-12-31 | 11211.891104 | 1.217728 | 扩张 |
| 3 | 2015-01-31 | 2.89 | 0.661594 | 4.368235 | 0.603340 | -8.805115 | 3.11257 | 1.73343 | 0 | 37.8289 | 8.72415 | GBR | 2015-12-31 | 41182.619517 | 0.872431 | 稳定 |
| 4 | 2015-01-31 | 5.70 | 1.228550 | 4.639616 | 1.189979 | -3.139545 | -2.34134 | -3.64753 | -5.28928 | 30.5387 | 2.97343 | CAN | 2015-12-31 | 50108.065004 | 0.690253 | 收缩 |
分类散点图
通过将数据分类,我们可以检查不同人口类型是否展示出不同的Big Mac 指数分布。
我们可以使用seaborn.lmplot来解析数据并创建一个分类散点图。回顾一下,lmplot()将regplot()与FacetGrid结合,用于在分面网格或颜色编码的散点图中可视化三对或更多的变量。在接下来的示例中,我们将把人口类型变量分配给lmplot()的col、row或hue参数。让我们来看一下结果:
# Horizontal faceted grids (col="population type")
g = sns.lmplot(x="Value", y="dollar_price", col="population type", data=merged_df2)
g.set_xlabels("GDP per capita (constant 2000 US$)")
g.set_ylabels("BigMac index (US$)")
plt.show()
上述代码片段生成了:

另外,如果我们在代码片段中将row="population type"替换为col="population type",将会生成以下图表:

最后,通过将col="population type"更改为hue="population type",将生成一个颜色编码的分类散点图:

实际上,col、row 和 hue 可以结合使用,创建丰富的分面网格。当数据中存在多个维度时,这特别有用。关于分面网格的更多讨论将在下一章中介绍。
条形图和蜂群图
条形图本质上是一个散点图,其中 x- 轴表示一个分类变量。条形图的典型用法是在每个数据点上应用一个小的随机抖动值,使得数据点之间的间隔更加清晰:
# Strip plot with jitter value
ax = sns.stripplot(x="population type", y="dollar_price", data=merged_df2, jitter=True)
ax.set_xlabel("Population type")
ax.set_ylabel("BigMac index (US$)")
plt.show()

蜂群图与条形图非常相似,然而点的位置会自动调整以避免重叠,即使没有应用抖动值。这些图像像蜜蜂围绕某个位置飞舞,因此也被称为蜂群图。
如果我们将前面代码片段中的 Seaborn 函数调用从sns.stripplot更改为sns.swarmplot,结果将会变成这样:

箱型图和小提琴图
条形图和蜂群图的数据显示方式使得比较变得困难。假设你想找出稳定型或收缩型人口类型的中位数 BigMac 指数值哪个更高。你能基于前面两个示例图进行判断吗?
你可能会认为收缩型组的中位数值较高,因为它有更高的最大数据点,但实际上,稳定型组的中位数值更高。
是否有更好的图表类型来比较分类数据的分布?来看看这个!我们来尝试一下箱型图:
# Box plot
ax = sns.boxplot(x="population type", y="dollar_price", data=merged_df2)
ax.set_xlabel("Population type")
ax.set_ylabel("BigMac index (US$)")
plt.show()
预期输出:

箱型图的框表示数据的四分位数,中心线表示中位数值,胡须表示数据的完整范围。那些偏离上四分位数或下四分位数超过 1.5 倍四分位距的数据点被视为异常值,并以飞点形式显示。
小提琴图将我们数据的核密度估计与箱型图结合在一起。箱型图和小提琴图都显示了中位数和四分位数范围,但小提琴图更进一步,通过显示适合数据的完整估计概率分布来展示更多信息。因此,我们可以判断数据中是否存在峰值,并且还可以比较它们的相对幅度。
如果我们将代码片段中的 Seaborn 函数调用从sns.boxplot更改为sns.violinplot,结果将会像这样:

我们还可以将条形图或蜂群图叠加在箱型图或蜂群图之上,从而兼得两者的优点。这里是一个示例代码:
# Prepare a box plot
ax = sns.boxplot(x="population type", y="dollar_price", data=merged_df2)
# Overlay a swarm plot on top of the same axes
sns.swarmplot(x="population type", y="dollar_price", data=merged_df2, color="w", ax=ax)
ax.set_xlabel("Population type")
ax.set_ylabel("BigMac index (US$)")
plt.show()
预期输出:

控制 Seaborn 图形美学
虽然我们可以使用 Matplotlib 自定义图形的美学,但 Seaborn 提供了几个方便的函数来简化定制。如果您使用的是 Seaborn 0.8 或更高版本,必须在导入后显式调用 seaborn.set(),以启用 Seaborn 默认的美观主题。在较早版本中,seaborn.set() 在导入时隐式调用。
预设主题
Seaborn 中的五种默认主题,即 darkgrid、whitegrid、dark、white 和 ticks,可以通过调用 seaborn.set_style() 函数来选择。
必须在发出任何绘图命令之前调用 seaborn.set_style(),以便正确显示主题。
从图形中移除脊柱
要删除或调整脊柱的位置,可以使用 seaborn.despine 函数。默认情况下,图形的顶部和右侧脊柱被移除,可以通过设置 left=True 或 bottom=True 来移除其他脊柱。通过使用偏移和修剪参数,还可以调整脊柱的位置。
seaborn.despine 必须在调用 Seaborn 绘图函数之后调用。
这里是 seaborn.despine 函数中不同参数组合的结果:

改变图形的大小
要控制图形的高度和宽度,我们也可以依赖 matplotlib.pyplot.figure(figsize=(WIDTH,HEIGHT))。
在此示例中,我们将把之前的直方图示例的大小更改为宽 8 英寸,高 4 英寸:
import seaborn as sns
import matplotlib.pyplot as plt
from scipy import stats
# Note: Codes related to data preparation are skipped for brevity
# Reset all previous theme settings to defaults
sns.set()
# Change the size to 8 inches wide and 4 inches tall
fig = plt.figure(figsize=(8,4))
# We are going to reuse current_bigmac that was generated earlier
# Plot the histogram
ax = sns.distplot(current_bigmac.dollar_price)
plt.show()
以下是前述代码的预期输出:

Seaborn 还提供了 seaborn.set_context() 函数来控制图表元素的比例。有四种预设的上下文,分别是 paper、notebook、talk 和 poster,它们按大小递增排列。默认情况下,选择的是 Notebook 风格。以下是将上下文设置为 poster 的示例:
# Reset all previous theme settings to defaults
sns.set()
# Set Seaborn context to poster
sns.set_context("poster")
# We are going to reuse current_bigmac that was generated earlier
# Plot the histogram
ax = sns.distplot(current_bigmac.dollar_price)
plt.show()
以下是前述代码的预期输出:

微调图形的样式
Seaborn 图形中的几乎每个元素都可以通过 seaborn.set 进一步自定义。以下是支持的参数列表:
-
context:预设的上下文之一——{paper, notebook, talk, poster}。 -
style:轴样式之一——{darkgrid, whitegrid, dark, white, ticks}。 -
palette:在seaborn.pydata.org/generated/seaborn.color_palette.html#seaborn.color_palette中定义的色板之一。 -
font:支持的字体或字体家族名称,如 serif、sans-serif、cursive、fantasy 或 monospace。欲了解更多信息,请访问matplotlib.org/api/font_manager_api.html。 -
font_scale:字体元素的独立缩放因子。 -
rc:额外rc参数映射的字典。要获取所有rc参数的完整列表,可以运行seaborn.axes_style()。
当前使用的预设上下文或轴样式中未定义的 RC 参数无法被覆盖。有关 seaborn.set() 的更多信息,请访问 seaborn.pydata.org/generated/seaborn.set.html#seaborn.set。
让我们尝试增加字体比例、增加 KDE 图的线宽,并改变几个图表元素的颜色:
# Get a dictionary of all parameters that can be changed
sns.axes_style()
"""
Returns
{'axes.axisbelow': True,
'axes.edgecolor': '.8',
'axes.facecolor': 'white',
'axes.grid': True,
'axes.labelcolor': '.15',
'axes.linewidth': 1.0,
'figure.facecolor': 'white',
'font.family': [u'sans-serif'],
'font.sans-serif': [u'Arial',
u'DejaVu Sans',
u'Liberation Sans',
u'Bitstream Vera Sans',
u'sans-serif'],
'grid.color': '.8',
'grid.linestyle': u'-',
'image.cmap': u'rocket',
'legend.frameon': False,
'legend.numpoints': 1,
'legend.scatterpoints': 1,
'lines.solid_capstyle': u'round',
'text.color': '.15',
'xtick.color': '.15',
'xtick.direction': u'out',
'xtick.major.size': 0.0,
'xtick.minor.size': 0.0,
'ytick.color': '.15',
'ytick.direction': u'out',
'ytick.major.size': 0.0,
'ytick.minor.size': 0.0}
"""
# Increase the font scale to 2, change the grid color to light grey,
# and axes label color to dark blue
sns.set(context="notebook",
style="darkgrid",
font_scale=2,
rc={'grid.color': '0.6',
'axes.labelcolor':'darkblue',
"lines.linewidth": 2.5})
# Plot the histogram
ax = sns.distplot(current_bigmac.dollar_price)
plt.show()
该代码生成以下直方图:

到目前为止,我们只介绍了控制全局美学的函数。如果我们只想改变某个特定图表的样式呢?
幸运的是,大多数 Seaborn 绘图函数都提供了专门的参数来定制样式。这也意味着并没有一个适用于所有 Seaborn 绘图函数的通用样式教程。然而,我们可以仔细查看这段 seaborn.distplot() 的代码示例,以了解大概:
# Note: Codes related to data preparation and imports are skipped for
# brevity
# Reset the style
sns.set(context="notebook", style="darkgrid")
# Plot the histogram with custom style
ax = sns.distplot(current_bigmac.dollar_price,
kde_kws={"color": "g",
"linewidth": 3,
"label": "KDE"},
hist_kws={"histtype": "step",
"alpha": 1,
"color": "k",
"label": "histogram"})
plt.show()
预期结果:

一些 Seaborn 函数支持更加直接的美学定制方法。例如,seaborn.barplot 可以通过关键字参数,如 facecolor、edgecolor、ecolor 和 linewidth,传递给底层的 matplotlib.pyplot.bar 函数:
# Note: Codes related to data preparation and imports are skipped
# for brevity
# Population Bar chart
sns.barplot(x="AgeGrp",y="Value", hue="Sex",
linewidth=2, edgecolor="w",
data = current_population)
# Use Matplotlib functions to label axes rotate tick labels
ax = plt.gca()
ax.set(xlabel="Age Group", ylabel="Population (thousands)")
ax.set_xticklabels(ax.xaxis.get_majorticklabels(), rotation=45)
plt.title("Population Barchart (USA)")
# Show the figure
plt.show()

更多关于颜色的内容
颜色可能是图表风格中最重要的方面,因此它值得单独设立一个小节。有许多优秀的资源讨论了选择颜色在可视化中的原则(例如,betterfigures.org/2015/06/23/picking-a-colour-scale-for-scientific-graphics/ 和 earthobservatory.nasa.gov/blogs/elegantfigures/2013/08/05/subtleties-of-color-part-1-of-6/)。官方的 Matplotlib 文档也包含了关于颜色映射的良好概述(matplotlib.org/users/colormaps.html)。
有效使用颜色可以增加足够的对比度,使某些内容突出并吸引观众的注意力。颜色还可以唤起情感;例如,红色通常与重要或激情相关,而绿色通常与自然或稳定相关。如果你想通过图表传递一个故事,务必尝试使用合适的配色方案。据估计,8%的男性和 0.5%的女性患有红绿色盲,因此我们在选择颜色时也需要考虑到这些人群。
配色方案和调色板
Seaborn 提供了三种常见的颜色调色板——定性、发散性和连续性:
-
定性调色板最适用于具有离散级别或名义/分类数据的数据。可以通过向
seaborn.color_palette提供 Matplotlib 颜色列表来创建自定义定性调色板。 -
分歧调色板用于突出图形中的低值和高值,具有中性色的中点。可以通过将两个色调值以及可选的亮度和饱和度值传递给
seaborn.diverging_palette函数来创建自定义分歧调色板。 -
顺序调色板通常用于量化数据,这些数据在低到高之间连续变化。
可以通过向
seaborn.light_palette或seaborn.dark_palette提供单一的 Matplotlib 颜色,创建自定义顺序调色板,这将生成一个从浅色或深色的去饱和值逐渐变化到种子颜色的调色板。
在下一个示例中,我们将绘制最常用的定性、分歧和顺序调色板,以及一些自定义调色板:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
def palplot(pal, ax):
"""Plot the values in a color palette as a horizontal array.
Adapted from seaborn.palplot
Args:
p : seaborn color palette
ax : axes to plot the color palette
"""
n = len(pal)
ax.imshow(np.arange(n).reshape(1, n),
cmap=ListedColormap(list(pal)),
interpolation="nearest", aspect="auto")
ax.set_xticks(np.arange(n) - .5)
ax.set_yticks([-.5, .5])
ax.set_xticklabels([])
ax.set_yticklabels([])
palettes = {"qualitative": ["deep", "pastel", "bright", "dark",
"colorblind", "Accent", "Paired",
"Set1", "Set2", "Set3", "Pastel1",
"Pastel2", "Dark2"],
"diverging": ["BrBG", "PiYG", "PRGn", "PuOr", "RdBu",
"RdBu_r", "RdGy", "RdGy_r", "RdYlGn",
"coolwarm"],
"sequential": ["husl", "Greys", "Blues", "BuGn_r",
"GnBu_d", "plasma", "viridis","cubehelix"]}
#Reset to default Seaborn style
sns.set()
# Create one subplot per palette, the x-axis is shared
fig, axarr = plt.subplots(13, 3, sharex=True, figsize=(12,11))
# Plot 9 color blocks for each palette
for i, palette_type in enumerate(palettes.keys()):
for j, palette in enumerate(palettes[palette_type]):
pal = sns.color_palette(palettes[palette_type][j], 9)
palplot(pal, axarr[j,i])
axarr[j,i].set_xlabel(palettes[palette_type][j])
# Plot a few more custom diverging palette
custom_diverging_palette = [
sns.diverging_palette(220, 20, n=9),
sns.diverging_palette(10, 220, sep=80, n=9),
sns.diverging_palette(145, 280, s=85, l=25, n=9)
]
for i, palette in enumerate(custom_diverging_palette):
palplot(palette, axarr[len(palettes["diverging"])+i,1])
axarr[len(palettes["diverging"])+i,1].set_xlabel("custom diverging
{}".format(i+1))
# Plot a few more custom sequential palette
other_custom_palette = [
sns.light_palette("green", 9),
sns.light_palette("green", 9, reverse=True),
sns.dark_palette("navy", 9),
sns.dark_palette("navy", 9, reverse=True),
sns.color_palette(["#49a17a","#4aae82","#4eb98a","#55c091","#c99b5f",
"#cbb761","#c5cc62","#accd64","#94ce65"])
]
for i, palette in enumerate(other_custom_palette):
palplot(palette, axarr[len(palettes["sequential"])+i,2])
axarr[len(palettes["sequential"])+i,2].set_xlabel("custom sequential
{}".format(i+1))
# Reduce unnecessary margin space
plt.tight_layout()
# Show the plot
plt.show()
预期的输出如下:

要更改 Seaborn 图形的配色方案,我们可以使用大多数 Seaborn 函数中提供的color或palette参数。color参数支持应用于所有元素的单一颜色;而palette参数支持一系列颜色,用于区分hue变量的不同水平。
一些 Seaborn 函数仅支持color参数(例如,分布图),而其他函数可以同时支持color和palette(例如,条形图和箱型图)。读者可以参考官方文档查看哪些参数是受支持的。
以下三个代码片段演示了如何在分布图(dist plot)、条形图(bar plot)和箱型图(box plot)中使用color或palette参数:
# Note: Codes related to data preparation and imports are skipped
# for brevity
# Change the color of histogram and KDE line to darkred
ax = sns.distplot(current_bigmac.dollar_price, color="darkred")
plt.show()

current_population = population_df[(population_df.Location == 'United States of America') &
(population_df.Time == 2017) &
(population_df.Sex != 'Both')]
# Change the color palette of the bar chart to Paired
sns.barplot(x="AgeGrp",y="Value", hue="Sex", palette="Paired", data = current_population)
# Rotate tick labels by 30 degree
plt.setp(plt.gca().get_xticklabels(), rotation=30, horizontalalignment='right')
plt.show()

# Note: Codes related to data preparation and imports are skipped
# for brevity
# Change the color palette of the bar chart to Set2 from color
# brewer library
ax = sns.boxplot(x="population type", y="dollar_price", palette="Set2", data=merged_df2)
plt.show()

总结
你刚刚学会了如何使用多功能的 Pandas 包解析 CSV 或 JSON 格式的在线数据。你进一步学习了如何筛选、子集化、合并和处理数据,以获取见解。现在,你已经掌握了可视化时间序列、单变量、双变量和分类数据的知识。本章最后介绍了若干有用的技巧,以自定义图形美学,从而有效地讲述故事。
呼!我们刚刚完成了一个长篇章节,赶紧去吃个汉堡,休息一下,放松一下吧。
第八章:可视化多变量数据
当我们拥有包含许多变量的大数据时,第七章中 可视化在线数据的图表类型可能不再是有效的数据可视化方式。我们可能会尝试在单一图表中尽可能多地压缩变量,但过度拥挤或杂乱的细节很快就会超出人类的视觉感知能力。
本章旨在介绍多变量数据可视化技术;这些技术使我们能够更好地理解数据的分布以及变量之间的关系。以下是本章的概述:
-
从 Quandl 获取日终(EOD)股票数据
-
二维分面图:
-
Seaborn 中的因子图
-
Seaborn 中的分面网格
-
Seaborn 中的配对图
-
-
其他二维多变量图:
-
Seaborn 中的热力图
-
matplotlib.finance 中的蜡烛图:
- 可视化各种股市指标
-
构建综合股票图表
-
-
三维图表:
-
散点图
-
条形图
-
使用 Matplotlib 3D 的注意事项
-
首先,我们将讨论分面图,这是一种用于可视化多变量数据的分而治之的方法。这种方法的要义是将输入数据切分成不同的分面,每个可视化面板中只展示少数几个属性。通过在减少的子集上查看变量,这样可以减少视觉上的杂乱。有时,在二维图表中找到合适的方式来表示多变量数据是困难的。因此,我们还将介绍 Matplotlib 中的三维绘图函数。
本章使用的数据来自 Quandl 的日终(EOD)股票数据库。首先让我们从 Quandl 获取数据。
从 Quandl 获取日终(EOD)股票数据
由于我们将广泛讨论股票数据,请注意,我们不保证所呈现内容的准确性、完整性或有效性;也不对可能发生的任何错误或遗漏负责。数据、可视化和分析仅以“原样”方式提供,仅用于教育目的,不附带任何形式的声明、保证或条件。因此,出版商和作者不对您使用内容承担任何责任。需要注意的是,过去的股票表现不能预测未来的表现。读者还应意识到股票投资的风险,并且不应根据本章内容做出任何投资决策。此外,建议读者在做出投资决策之前,对个别股票进行独立研究。
我们将调整第七章《可视化在线数据》中的 Quandl JSON API 代码,以便从 Quandl 获取 EOD 股票数据。我们将获取 2017 年 1 月 1 日至 2017 年 6 月 30 日之间六只股票代码的历史股市数据:苹果公司(EOD/AAPL)、宝洁公司(EOD/PG)、强生公司(EOD/JNJ)、埃克森美孚公司(EOD/XOM)、国际商业机器公司(EOD/IBM)和微软公司(EOD/MSFT)。同样,我们将使用默认的urllib和json模块来处理 Quandl API 调用,接着将数据转换为 Pandas DataFrame:
from urllib.request import urlopen
import json
import pandas as pd
def get_quandl_dataset(api_key, code, start_date, end_date):
"""Obtain and parse a quandl dataset in Pandas DataFrame format
Quandl returns dataset in JSON format, where data is stored as a
list of lists in response['dataset']['data'], and column headers
stored in response['dataset']['column_names'].
Args:
api_key: Quandl API key
code: Quandl dataset code
Returns:
df: Pandas DataFrame of a Quandl dataset
"""
base_url = "https://www.quandl.com/api/v3/datasets/"
url_suffix = ".json?api_key="
date = "&start_date={}&end_date={}".format(start_date, end_date)
# Fetch the JSON response
u = urlopen(base_url + code + url_suffix + api_key + date)
response = json.loads(u.read().decode('utf-8'))
# Format the response as Pandas Dataframe
df = pd.DataFrame(response['dataset']['data'], columns=response['dataset']
['column_names'])
return df
# Input your own API key here
api_key = "INSERT YOUR KEY HERE"
# Quandl code for six US companies
codes = ["EOD/AAPL", "EOD/PG", "EOD/JNJ", "EOD/XOM", "EOD/IBM", "EOD/MSFT"]
start_date = "2017-01-01"
end_date = "2017-06-30"
dfs = []
# Get the DataFrame that contains the EOD data for each company
for code in codes:
df = get_quandl_dataset(api_key, code, start_date, end_date)
df["Company"] = code[4:]
dfs.append(df)
# Concatenate all dataframes into a single one
stock_df = pd.concat(dfs)
# Sort by ascending order of Company then Date
stock_df = stock_df.sort_values(["Company","Date"])
stock_df.head()
| - | 日期 | 开盘 | 最高 | 最低 | 收盘 | 成交量 | 分红 | 拆股 | 调整后开盘 | 调整后最高 | 调整后最低 | 调整后收盘 | 调整后成交量 | 公司 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 124 | 2017-01-03 | 115.80 | 116.3300 | 114.76 | 116.15 | 28781865.0 | 0.0 | 1.0 | 114.833750 | 115.359328 | 113.802428 | 115.180830 | 28781865.0 | AAPL |
| 123 | 2017-01-04 | 115.85 | 116.5100 | 115.75 | 116.02 | 21118116.0 | 0.0 | 1.0 | 114.883333 | 115.537826 | 114.784167 | 115.051914 | 21118116.0 | AAPL |
| 122 | 2017-01-05 | 115.92 | 116.8642 | 115.81 | 116.61 | 22193587.0 | 0.0 | 1.0 | 114.952749 | 115.889070 | 114.843667 | 115.636991 | 22193587.0 | AAPL |
| 121 | 2017-01-06 | 116.78 | 118.1600 | 116.47 | 117.91 | 31751900.0 | 0.0 | 1.0 | 115.805573 | 117.174058 | 115.498159 | 116.926144 | 31751900.0 | AAPL |
| 120 | 2017-01-09 | 117.95 | 119.4300 | 117.94 | 118.99 | 33561948.0 | 0.0 | 1.0 | 116.965810 | 118.433461 | 116.955894 | 117.997132 | 33561948.0 | AAPL |
数据框包含每只股票的开盘价、最高价、最低价和收盘价(OHLC)。此外,还提供了额外信息;例如,分红列反映了当天的现金分红值。拆股列显示当天如果发生了拆股事件,新的股票与旧股票的比例。调整后的价格考虑了分配或公司行为引起的价格波动,假设所有这些行动已被再投资到当前股票中。有关这些列的更多信息,请查阅 Quandl 文档页面。
按行业分组公司
正如你可能注意到的,三家公司(AAPL、IBM 和 MSFT)是科技公司,而剩余三家公司则不是。股市分析师通常根据行业将公司分组,以便深入了解。让我们尝试按行业对公司进行标记:
# Classify companies by industry
tech_companies = set(["AAPL","IBM","MSFT"])
stock_df['Industry'] = ["Tech" if c in tech_companies else "Others" for c in stock_df['Company']]
转换日期为支持的格式
stock_df中的Date列以一系列 Python 字符串的形式记录。尽管 Seaborn 可以在某些函数中使用字符串格式的日期,但 Matplotlib 则不能。为了使日期更适合数据处理和可视化,我们需要将这些值转换为 Matplotlib 支持的浮动数字:
from matplotlib.dates import date2num
# Convert Date column from string to Python datetime object,
# then to float number that is supported by Matplotlib.
stock_df["Datetime"] = date2num(pd.to_datetime(stock_df["Date"], format="%Y-%m-%d").tolist())
获取收盘价的百分比变化
接下来,我们想要计算相对于前一天收盘价的收盘价变化。Pandas 中的pct_change()函数使得这个任务变得非常简单:
import numpy as np
# Calculate percentage change versus the previous close
stock_df["Close_change"] = stock_df["Close"].pct_change()
# Since the DataFrame contain multiple companies' stock data,
# the first record in the "Close_change" should be changed to
# NaN in order to prevent referencing the price of incorrect company.
stock_df.loc[stock_df["Date"]=="2017-01-03", "Close_change"] = np.NaN
stock_df.head()
二维分面图
我们将介绍三种创建分面图的主要方法:seaborn.factorplot()、seaborn.FacetGrid()和seaborn.pairplot()。在上一章当我们讨论seaborn.lmplot()时,你可能已经见过一些分面图。实际上,seaborn.lmplot()函数将seaborn.regplot()和seaborn.FacetGrid()结合在一起,并且数据子集的定义可以通过hue、col和row参数进行调整。
我们将介绍三种创建分面图的主要方法:seaborn.factorplot()、seaborn.FacetGrid()和seaborn.pairplot()。这些函数在定义分面时与seaborn.lmplot()的工作方式非常相似。
Seaborn 中的因子图
在seaborn.factorplot()的帮助下,我们可以通过调节kind参数,将类别点图、箱线图、小提琴图、条形图或条纹图绘制到seaborn.FacetGrid()上。factorplot的默认绘图类型是点图。与 Seaborn 中的其他绘图函数不同,后者支持多种输入数据格式,factorplot仅支持 pandas DataFrame 作为输入,而变量/列名可以作为字符串传递给x、y、hue、col或row:
import seaborn as sns
import matplotlib.pyplot as plt
sns.set(style="ticks")
# Plot EOD stock closing price vs Date for each company.
# Color of plot elements is determined by company name (hue="Company"),
# plot panels are also arranged in columns accordingly (col="Company").
# The col_wrap parameter determines the number of panels per row (col_wrap=3).
g = sns.factorplot(x="Date", y="Close",
hue="Company", col="Company",
data=stock_df, col_wrap=3)
plt.show()

上面的图存在几个问题。
首先,纵横比(长度与高度之比)对于时间序列图来说稍显不理想。较宽的图形将使我们能够观察到在这一时间段内的微小变化。我们将通过调整aspect参数来解决这个问题。
其次,线条和点的粗细过大,从而遮盖了一些图中的细节。我们可以通过调整scale参数来减小这些视觉元素的大小。
最后,刻度线之间太近,且刻度标签重叠。绘图完成后,sns.factorplot()返回一个 FacetGrid,在代码中表示为g。我们可以通过调用FacetGrid对象中的相关函数进一步调整图形的美学,比如刻度位置和标签:
# Increase the aspect ratio and size of each panel
g = sns.factorplot(x="Date", y="Close",
hue="Company", col="Company",
data=stock_df,
col_wrap=3, size=3,
scale=0.5, aspect=1.5)
# Thinning of ticks (select 1 in 10)
locs, labels = plt.xticks()
g.set(xticks=locs[0::10], xticklabels=labels[0::10])
# Rotate the tick labels to prevent overlap
g.set_xticklabels(rotation=30)
# Reduce the white space between plots
g.fig.subplots_adjust(wspace=.1, hspace=.2)
plt.show()

# Create faceted plot separated by industry
g = sns.factorplot(x="Date", y="Close",
hue="Company", col="Industry",
data=stock_df, size=4,
aspect=1.5, scale=0.5)
locs, labels = plt.xticks()
g.set(xticks=locs[0::10], xticklabels=labels[0::10])
g.set_xticklabels(rotation=30)
plt.show()

Seaborn 中的分面网格
到目前为止,我们已经提到过FacetGrid几次,但它到底是什么呢?
正如您所知,FacetGrid是一个用于对数据进行子集化和绘制绘图面板的引擎,由将变量分配给hue参数的行和列来确定。虽然我们可以使用lmplot和factorplot等包装函数轻松地在FacetGrid上搭建绘图,但更灵活的方法是从头开始构建 FacetGrid。为此,我们首先向FacetGrid对象提供一个 pandas DataFrame,并通过col、row和hue参数指定布局网格的方式。然后,我们可以通过调用FacetGrid对象的map()函数为每个面板分配一个 Seaborn 或 Matplotlib 绘图函数:
# Create a FacetGrid
g = sns.FacetGrid(stock_df, col="Company", hue="Company",
size=3, aspect=2, col_wrap=2)
# Map the seaborn.distplot function to the panels,
# which shows a histogram of closing prices.
g.map(sns.distplot, "Close")
# Label the axes
g.set_axis_labels("Closing price (US Dollars)", "Density")
plt.show()

我们还可以向绘图函数提供关键字参数:
g = sns.FacetGrid(stock_df, col="Company", hue="Company",
size=3, aspect=2.2, col_wrap=2)
# We can supply extra kwargs to the plotting function.
# Let's turn off KDE line (kde=False), and plot raw
# frequency of bins only (norm_hist=False).
# By setting rug=True, tick marks that denotes the
# density of data points will be shown in the bottom.
g.map(sns.distplot, "Close", kde=False, norm_hist=False, rug=True)
g.set_axis_labels("Closing price (US Dollars)", "Density")
plt.show()

FacetGrid不仅限于使用 Seaborn 绘图函数;让我们尝试将老式的Matplotlib.pyplot.plot()函数映射到FacetGrid上:
from matplotlib.dates import DateFormatter
g = sns.FacetGrid(stock_df, hue="Company", col="Industry",
size=4, aspect=1.5, col_wrap=2)
# plt.plot doesn't support string-formatted Date,
# so we need to use the Datetime column that we
# prepared earlier instead.
g.map(plt.plot, "Datetime", "Close", marker="o", markersize=3, linewidth=1)
g.add_legend()
# We can access individual axes through g.axes[column]
# or g.axes[row,column] if multiple rows are present.
# Let's adjust the tick formatter and rotate the tick labels
# in each axes.
for col in range(2):
g.axes[col].xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
plt.setp(g.axes[col].get_xticklabels(), rotation=30)
g.set_axis_labels("", "Closing price (US Dollars)")
plt.show()

Seaborn 中的 pair plot
对角线轴上将显示一系列直方图,以显示该列中变量的分布:
# Show a pairplot of three selected variables (vars=["Open", "Volume", "Close"])
g = sns.pairplot(stock_df, hue="Company",
vars=["Open", "Volume", "Close"])
plt.show()

我们可以调整绘图的许多方面。在下一个示例中,我们将增加纵横比,将对角线上的绘图类型更改为 KDE 绘图,并使用关键字参数调整绘图的美学效果:
# Adjust the aesthetics of the plot
g = sns.pairplot(stock_df, hue="Company",
aspect=1.5, diag_kind="kde",
diag_kws=dict(shade=True),
plot_kws=dict(s=15, marker="+"),
vars=["Open", "Volume", "Close"])
plt.show()

与基于FacetGrid的其他绘图类似,我们可以定义要在每个面板中显示的变量。我们还可以手动定义对我们重要的比较,而不是通过设置x_vars和y_vars参数进行全对全比较。如果需要更高的灵活性来定义比较组,也可以直接使用seaborn.PairGrid():
# Manually defining the comparisons that we are interested.
g = sns.pairplot(stock_df, hue="Company", aspect=1.5,
x_vars=["Open", "Volume"],
y_vars=["Close", "Close_change"])
plt.show()

其他二维多变量图
当我们需要可视化更多变量或样本时,FacetGrid、factor plot 和 pair plot 可能会占用大量空间。如果您希望最大化空间效率,则有两种特殊的绘图类型非常方便 - 热力图和蜡烛图。
Seaborn 中的热力图
热力图是显示大量数据的极其紧凑的方式。在金融世界中,色块编码可以让投资者快速了解哪些股票上涨或下跌。在科学世界中,热力图允许研究人员可视化成千上万基因的表达水平。
seaborn.heatmap()函数期望以 2D 列表、2D Numpy 数组或 pandas DataFrame 作为输入。如果提供了列表或数组,我们可以通过xticklabels和yticklabels分别提供列和行标签。另一方面,如果提供了 DataFrame,则将使用列标签和索引值分别标记列和行。
为了开始,我们将使用热图绘制六只股票的表现概览。我们将股票表现定义为与前一个收盘价相比的收盘价变化。这些信息在本章前面已经计算过(即 Close_change 列)。不幸的是,我们不能直接将整个 DataFrame 提供给 seaborn.heatmap(),因为它需要公司名称作为列,日期作为索引,收盘价变化作为数值。
如果你熟悉 Microsoft Excel,你可能有使用透视表的经验,这是总结特定变量水平或数值的强大技巧。pandas 也包含了类似的功能。以下代码片段使用了 Pandas.DataFrame.pivot() 函数来创建透视表:
stock_change = stock_df.pivot(index='Date', columns='Company', values='Close_change')
stock_change = stock_change.loc["2017-06-01":"2017-06-30"]
stock_change.head()
| 公司日期 | AAPL | IBM | JNJ | MSFT | PG | XOM |
|---|---|---|---|---|---|---|
| 2017-06-01 | 0.002749 | 0.000262 | 0.004133 | 0.003723 | 0.000454 | 0.002484 |
| 2017-06-02 | 0.014819 | -0.004061 | 0.010095 | 0.023680 | 0.005220 | -0.014870 |
| 2017-06-05 | -0.009778 | 0.002368 | 0.002153 | 0.007246 | 0.001693 | 0.007799 |
| 2017-06-06 | 0.003378 | -0.000262 | 0.003605 | 0.003320 | 0.000676 | 0.013605 |
| 2017-06-07 | 0.005957 | -0.009123 | -0.000611 | -0.001793 | -0.000338 | -0.003694 |
透视表创建完成后,我们可以继续绘制第一个热图:
ax = sns.heatmap(stock_change)
plt.show()

默认的热图实现并不够紧凑。当然,我们可以通过plt.figure(figsize=(width, height))来调整图形大小;我们还可以切换方形参数来创建方形的块。为了方便视觉识别,我们可以在块周围添加一条细边框。
根据美国股市的惯例,绿色表示价格上涨,红色表示价格下跌。因此,我们可以调整cmap参数来调整颜色图。然而,Matplotlib 和 Seaborn 都没有包含红绿颜色图,所以我们需要自己创建一个:
在第七章《可视化在线数据》末尾,我们简要介绍了创建自定义颜色图的函数。这里我们将使用seaborn.diverging_palette()来创建红绿颜色图,它要求我们为颜色图的负值和正值指定色调、饱和度和亮度(husl)。你还可以使用以下代码在 Jupyter Notebook 中启动交互式小部件,帮助选择颜色:
%matplotlib notebook
import seaborn as sns
sns.choose_diverging_palette(as_cmap=True)
# Create a new red-green color map using the husl color system
# h_neg and h_pos determines the hue of the extents of the color map.
# s determines the color saturation
# l determines the lightness
# sep determines the width of center point
# In addition, we need to set as_cmap=True as the cmap parameter of
# sns.heatmap expects matplotlib colormap object.
rdgn = sns.diverging_palette(h_neg=10, h_pos=140, s=80, l=50,
sep=10, as_cmap=True)
# Change to square blocks (square=True), add a thin
# border (linewidths=.5), and change the color map
# to follow US stocks market convention (cmap="RdGn").
ax = sns.heatmap(stock_change, cmap=rdgn,
linewidths=.5, square=True)
# Prevent x axes label from being cropped
plt.tight_layout()
plt.show()

当颜色是唯一的区分因素时,可能很难分辨数值间的小差异。为每个颜色块添加文本注释可能有助于读者理解差异的大小:
fig = plt.figure(figsize=(6,8))
# Set annot=True to overlay the values.
# We can also assign python format string to fmt.
# For example ".2%" refers to percentage values with
# two decimal points.
ax = sns.heatmap(stock_change, cmap=rdgn,
annot=True, fmt=".2%",
linewidths=.5, cbar=False)
plt.show()

matplotlib.finance 中的蜡烛图
正如您在本章的第一部分所看到的,我们的数据集包含每个交易日的开盘价、收盘价以及最高和最低价格。到目前为止,我们描述的任何图表都无法在单个图表中描述所有这些变量的趋势。
在金融界,蜡烛图几乎是描述股票、货币和商品在一段时间内价格变动的默认选择。每个蜡烛图由实体组成,描述开盘和收盘价,以及展示特定交易日最高和最低价格的延伸影线。如果收盘价高于开盘价,则蜡烛图通常为黑色。相反,如果收盘价低于开盘价,则为红色。交易员可以根据颜色的组合和蜡烛图实体的边界推断开盘和收盘价。
在以下示例中,我们将准备一个苹果公司在我们的 DataFrame 最近 50 个交易日的蜡烛图。我们还将应用刻度格式化程序来标记日期的刻度:
import matplotlib.pyplot as plt
from matplotlib.dates import date2num, WeekdayLocator, DayLocator, DateFormatter, MONDAY
from matplotlib.finance import candlestick_ohlc
# Extract stocks data for AAPL.
# candlestick_ohlc expects Date (in floating point number), Open, High, Low,
# Close columns only
# So we need to select the useful columns first using DataFrame.loc[]. Extra
# columns can exist,
# but they are ignored. Next we get the data for the last 50 trading only for
# simplicity of plots.
candlestick_data = stock_df[stock_df["Company"]=="AAPL"]\
.loc[:, ["Datetime", "Open", "High", "Low", "Close",
"Volume"]]\
.iloc[-50:]
# Create a new Matplotlib figure
fig, ax = plt.subplots()
# Prepare a candlestick plot
candlestick_ohlc(ax, candlestick_data.values, width=0.6)
ax.xaxis.set_major_locator(WeekdayLocator(MONDAY)) # major ticks on the mondays
ax.xaxis.set_minor_locator(DayLocator()) # minor ticks on the days
ax.xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
ax.xaxis_date() # treat the x data as dates
# rotate all ticks to vertical
plt.setp(ax.get_xticklabels(), rotation=90, horizontalalignment='right')
ax.set_ylabel('Price (US $)') # Set y-axis label
plt.show()

从 Matplotlib 2.0 开始,matplotlib.finance 已被弃用。读者应该将来使用mpl_finance(github.com/matplotlib/mpl_finance)。然而,截至本章撰写时,mpl_finance 尚未在 PyPI 上提供,因此我们暂时还是使用matplotlib.finance。
可视化各种股市指标
当前形式的蜡烛图有些单调。交易员通常会叠加股票指标,如平均真实范围(ATR)、布林带、商品通道指数(CCI)、指数移动平均(EMA)、移动平均收敛背离(MACD)、相对强弱指数(RSI)以及各种其他技术分析的统计数据。
Stockstats(github.com/jealous/stockstats)是一个用于计算这些指标/统计数据以及更多内容的优秀包。它封装了 pandas 的数据框架,并在访问时动态生成这些统计数据。要使用stockstats,我们只需通过 PyPI 安装它:pip install stockstats。
接下来,我们可以通过stockstats.StockDataFrame.retype()将 pandas DataFrame 转换为 stockstats DataFrame。然后,可以按照StockDataFrame["variable_timeWindow_indicator"]的模式访问大量股票指标。例如,StockDataFrame['open_2_sma']将给出开盘价的 2 天简单移动平均线。一些指标可能有快捷方式,请查阅官方文档获取更多信息:
from stockstats import StockDataFrame
# Convert to StockDataFrame
# Need to pass a copy of candlestick_data to StockDataFrame.retype
# Otherwise the original candlestick_data will be modified
stockstats = StockDataFrame.retype(candlestick_data.copy())
# 5-day exponential moving average on closing price
ema_5 = stockstats["close_5_ema"]
# 20-day exponential moving average on closing price
ema_20 = stockstats["close_20_ema"]
# 50-day exponential moving average on closing price
ema_50 = stockstats["close_50_ema"]
# Upper Bollinger band
boll_ub = stockstats["boll_ub"]
# Lower Bollinger band
boll_lb = stockstats["boll_lb"]
# 7-day Relative Strength Index
rsi_7 = stockstats['rsi_7']
# 14-day Relative Strength Index
rsi_14 = stockstats['rsi_14']
准备好股票指标后,我们可以将它们叠加在同一个蜡烛图上:
import datetime
import matplotlib.pyplot as plt
from matplotlib.dates import date2num, WeekdayLocator, DayLocator, DateFormatter, MONDAY
from matplotlib.finance import candlestick_ohlc
# Create a new Matplotlib figure
fig, ax = plt.subplots()
# Prepare a candlestick plot
candlestick_ohlc(ax, candlestick_data.values, width=0.6)
# Plot stock indicators in the same plot
ax.plot(candlestick_data["Datetime"], ema_5, lw=1, label='EMA (5)')
ax.plot(candlestick_data["Datetime"], ema_20, lw=1, label='EMA (20)')
ax.plot(candlestick_data["Datetime"], ema_50, lw=1, label='EMA (50)')
ax.plot(candlestick_data["Datetime"], boll_ub, lw=2, linestyle="--", label='Bollinger upper')
ax.plot(candlestick_data["Datetime"], boll_lb, lw=2, linestyle="--", label='Bollinger lower')
ax.xaxis.set_major_locator(WeekdayLocator(MONDAY)) # major ticks on
# the mondays
ax.xaxis.set_minor_locator(DayLocator()) # minor ticks on the days
ax.xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
ax.xaxis_date() # treat the x data as dates
# rotate all ticks to vertical
plt.setp(ax.get_xticklabels(), rotation=90, horizontalalignment='right')
ax.set_ylabel('Price (US $)') # Set y-axis label
# Limit the x-axis range from 2017-4-23 to 2017-7-1
datemin = datetime.date(2017, 4, 23)
datemax = datetime.date(2017, 7, 1)
ax.set_xlim(datemin, datemax)
plt.legend() # Show figure legend
plt.tight_layout()
plt.show()

创建全面的股票图表
在以下详细示例中,我们将应用到目前为止讲解的多种技巧,创建一个更全面的股票图表。除了前面的图表外,我们还将添加一条线图来显示相对强弱指数(RSI)以及一条柱状图来显示交易量。一个特殊的市场事件(markets.businessinsider.com/news/stocks/apple-stock-price-falling-new-iphone-speed-2017-6-1002082799)也将在图表中做注释:
如果你仔细观察图表,你可能会注意到一些缺失的日期。这些日期通常是非交易日或公共假期,它们在我们的数据框中没有出现。
import datetime
import matplotlib.pyplot as plt
from matplotlib.dates import date2num, WeekdayLocator, DayLocator, DateFormatter, MONDAY
from matplotlib.finance import candlestick_ohlc
from matplotlib.ticker import FuncFormatter
# FuncFormatter to convert tick values to Millions
def millions(x, pos):
return '%dM' % (x/1e6)
# Create 3 subplots spread acrosee three rows, with shared x-axis.
# The height ratio is specified via gridspec_kw
fig, axarr = plt.subplots(nrows=3, ncols=1, sharex=True, figsize=(8,8),
gridspec_kw={'height_ratios':[3,1,1]})
# Prepare a candlestick plot in the first axes
candlestick_ohlc(axarr[0], candlestick_data.values, width=0.6)
# Overlay stock indicators in the first axes
axarr[0].plot(candlestick_data["Datetime"], ema_5, lw=1, label='EMA (5)')
axarr[0].plot(candlestick_data["Datetime"], ema_20, lw=1, label='EMA (20)')
axarr[0].plot(candlestick_data["Datetime"], ema_50, lw=1, label='EMA (50)')
axarr[0].plot(candlestick_data["Datetime"], boll_ub, lw=2, linestyle="--", label='Bollinger upper')
axarr[0].plot(candlestick_data["Datetime"], boll_lb, lw=2, linestyle="--", label='Bollinger lower')
# Display RSI in the second axes
axarr[1].axhline(y=30, lw=2, color = '0.7') # Line for oversold threshold
axarr[1].axhline(y=50, lw=2, linestyle="--", color = '0.8') # Neutral RSI
axarr[1].axhline(y=70, lw=2, color = '0.7') # Line for overbought threshold
axarr[1].plot(candlestick_data["Datetime"], rsi_7, lw=2, label='RSI (7)')
axarr[1].plot(candlestick_data["Datetime"], rsi_14, lw=2, label='RSI (14)')
# Display trade volume in the third axes
axarr[2].bar(candlestick_data["Datetime"], candlestick_data['Volume'])
# Mark the market reaction to the Bloomberg news
# https://www.bloomberg.com/news/articles/2017-06-09/apple-s-new
# -iphones-said-to-miss-out-on-higher-speed-data-links
# http://markets.businessinsider.com/news/stocks/apple-stock-price
# -falling-new-iphone-speed-2017-6-1002082799
axarr[0].annotate("Bloomberg News",
xy=(datetime.date(2017, 6, 9), 155), xycoords='data',
xytext=(25, 10), textcoords='offset points', size=12,
arrowprops=dict(arrowstyle="simple",
fc="green", ec="none"))
# Label the axes
axarr[0].set_ylabel('Price (US $)')
axarr[1].set_ylabel('RSI')
axarr[2].set_ylabel('Volume (US $)')
axarr[2].xaxis.set_major_locator(WeekdayLocator(MONDAY)) # major ticks on the mondays
axarr[2].xaxis.set_minor_locator(DayLocator()) # minor ticks on the days
axarr[2].xaxis.set_major_formatter(DateFormatter('%Y-%m-%d'))
axarr[2].xaxis_date() # treat the x data as dates
axarr[2].yaxis.set_major_formatter(FuncFormatter(millions)) # Change the y-axis ticks to millions
plt.setp(axarr[2].get_xticklabels(), rotation=90, horizontalalignment='right') # Rotate x-tick labels by 90 degree
# Limit the x-axis range from 2017-4-23 to 2017-7-1
datemin = datetime.date(2017, 4, 23)
datemax = datetime.date(2017, 7, 1)
axarr[2].set_xlim(datemin, datemax)
# Show figure legend
axarr[0].legend()
axarr[1].legend()
# Show figure title
axarr[0].set_title("AAPL (Apple Inc.) NASDAQ", loc='left')
# Reduce unneccesary white space
plt.tight_layout()
plt.show()

三维(3D)图表
通过过渡到三维空间,在创建可视化时,你可能会享有更大的创作自由度。额外的维度还可以在单一图表中容纳更多信息。然而,有些人可能会认为,当三维图形被投影到二维表面(如纸张)时,三维不过是一个视觉噱头,因为它会模糊数据点的解读。
在 Matplotlib 版本 2 中,尽管三维 API 有了显著的进展,但依然存在一些令人烦恼的错误或问题。我们将在本章的最后讨论一些解决方法。确实有更强大的 Python 3D 可视化包(如 MayaVi2、Plotly 和 VisPy),但如果你希望使用同一个包同时绘制 2D 和 3D 图,或者希望保持其 2D 图的美学,使用 Matplotlib 的三维绘图功能是很好的选择。
大多数情况下,Matplotlib 中的三维图与二维图有相似的结构。因此,在本节中我们不会讨论每种三维图类型。我们将重点介绍三维散点图和柱状图。
三维散点图
在第六章,《你好,绘图世界!》中,我们已经探索了二维散点图。在这一节中,让我们尝试创建一个三维散点图。在此之前,我们需要一些三维数据点(x,y,z):
import pandas as pd
source = "https://raw.githubusercontent.com/PointCloudLibrary/data/master/tutorials/ism_train_cat.pcd"
cat_df = pd.read_csv(source, skiprows=11, delimiter=" ", names=["x","y","z"], encoding='latin_1')
cat_df.head()
| 点 | x | y | z |
|---|---|---|---|
| 0 | -17.034178 | 18.972282 | 40.482403 |
| 1 | -16.881481 | 21.815451 | 44.156799 |
| 2 | -16.749582 | 18.154911 | 34.131474 |
| 3 | -16.876919 | 20.598286 | 36.271809 |
| 4 | -16.849340 | 17.403711 | 42.993984 |
要声明一个三维图,我们首先需要从mpl_toolkits中的mplot3d扩展导入Axes3D对象,它负责在二维平面中渲染三维图表。然后,在创建子图时,我们需要指定projection='3d':
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.scatter(cat_df.x, cat_df.y, cat_df.z)
plt.show()

瞧,强大的 3D 散点图。猫目前正在占领互联网。根据《纽约时报》的报道,猫是“互联网的基本构建单元”(www.nytimes.com/2014/07/23/upshot/what-the-internet-can-see-from-your-cat-pictures.html)。毫无疑问,它们也应该在本章中占有一席之地。
与 2D 版本的 scatter() 相反,当创建 3D 散点图时,我们需要提供 X、Y 和 Z 坐标。然而,2D scatter() 支持的参数也可以应用于 3D scatter():
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
# Change the size, shape and color of markers
ax.scatter(cat_df.x, cat_df.y, cat_df.z, s=4, c="g", marker="o")
plt.show()

要更改 3D 图的视角和仰角,我们可以使用 view_init()。azim 参数指定 X-Y 平面上的方位角,而 elev 指定仰角。当方位角为 0 时,X-Y 平面将从你的北侧看起来。同时,方位角为 180 时,你将看到 X-Y 平面的南侧:
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.scatter(cat_df.x, cat_df.y, cat_df.z,s=4, c="g", marker="o")
# elev stores the elevation angle in the z plane azim stores the
# azimuth angle in the x,y plane
ax.view_init(azim=180, elev=10)
plt.show()

3D 条形图
我们引入了烛台图来展示开盘-最高-最低-收盘(OHLC)金融数据。此外,可以使用 3D 条形图来展示随时间变化的 OHLC。下图展示了绘制 5 天 OHLC 条形图的典型示例:
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d import Axes3D
# Get 1 and every fifth row for the 5-day AAPL OHLC data
ohlc_5d = stock_df[stock_df["Company"]=="AAPL"].iloc[1::5, :]
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
# Create one color-coded bar chart for Open, High, Low and Close prices.
for color, col, z in zip(['r', 'g', 'b', 'y'], ["Open", "High", "Low",
"Close"], [30, 20, 10, 0]):
xs = np.arange(ohlc_5d.shape[0])
ys = ohlc_5d[col]
# Assign color to the bars
colors = [color] * len(xs)
ax.bar(xs, ys, zs=z, zdir='y', color=colors, alpha=0.8, width=5)
plt.show()

设置刻度和标签的方法与其他 Matplotlib 绘图函数类似:
fig = plt.figure(figsize=(9,7))
ax = fig.add_subplot(111, projection='3d')
# Create one color-coded bar chart for Open, High, Low and Close prices.
for color, col, z in zip(['r', 'g', 'b', 'y'], ["Open", "High", "Low",
"Close"], [30, 20, 10, 0]):
xs = np.arange(ohlc_5d.shape[0])
ys = ohlc_5d[col]
# Assign color to the bars
colors = [color] * len(xs)
ax.bar(xs, ys, zs=z, zdir='y', color=colors, alpha=0.8)
# Manually assign the ticks and tick labels
ax.set_xticks(np.arange(ohlc_5d.shape[0]))
ax.set_xticklabels(ohlc_5d["Date"], rotation=20,
verticalalignment='baseline',
horizontalalignment='right',
fontsize='8')
ax.set_yticks([30, 20, 10, 0])
ax.set_yticklabels(["Open", "High", "Low", "Close"])
# Set the z-axis label
ax.set_zlabel('Price (US $)')
# Rotate the viewport
ax.view_init(azim=-42, elev=31)
plt.tight_layout()
plt.show()

Matplotlib 3D 的注意事项
由于缺乏真正的 3D 图形渲染后端(如 OpenGL)和适当的算法来检测 3D 对象的交叉点,Matplotlib 的 3D 绘图能力并不强大,但对于典型应用来说仅仅够用。在官方 Matplotlib FAQ 中(matplotlib.org/mpl_toolkits/mplot3d/faq.html),作者指出 3D 图可能在某些角度看起来不正确。此外,我们还报告了如果设置了 zlim,mplot3d 会无法裁剪条形图的问题(github.com/matplotlib/matplotlib/issues/8902;另见 github.com/matplotlib/matplotlib/issues/209)。在没有改进 3D 渲染后端的情况下,这些问题很难解决。
为了更好地说明后一个问题,让我们尝试在之前的 3D 条形图中的 plt.tight_layout() 上方添加 ax.set_zlim3d(bottom=110, top=150):

显然,柱状图超出了坐标轴的下边界。我们将尝试通过以下解决方法解决后一个问题:
# FuncFormatter to add 110 to the tick labels
def major_formatter(x, pos):
return "{}".format(x+110)
fig = plt.figure(figsize=(9,7))
ax = fig.add_subplot(111, projection='3d')
# Create one color-coded bar chart for Open, High, Low and Close prices.
for color, col, z in zip(['r', 'g', 'b', 'y'], ["Open", "High", "Low",
"Close"], [30, 20, 10, 0]):
xs = np.arange(ohlc_5d.shape[0])
ys = ohlc_5d[col]
# Assign color to the bars
colors = [color] * len(xs)
# Truncate the y-values by 110
ax.bar(xs, ys-110, zs=z, zdir='y', color=colors, alpha=0.8)
# Manually assign the ticks and tick labels
ax.set_xticks(np.arange(ohlc_5d.shape[0]))
ax.set_xticklabels(ohlc_5d["Date"], rotation=20,
verticalalignment='baseline',
horizontalalignment='right',
fontsize='8')
# Set the z-axis label
ax.set_yticks([30, 20, 10, 0])
ax.set_yticklabels(["Open", "High", "Low", "Close"])
ax.zaxis.set_major_formatter(FuncFormatter(major_formatter))
ax.set_zlabel('Price (US $)')
# Rotate the viewport
ax.view_init(azim=-42, elev=31)
plt.tight_layout()
plt.show()

基本上,我们将 y 值截断了 110,然后使用刻度格式化器(major_formatter)将刻度值恢复到原始值。对于三维散点图,我们可以简单地移除超过 set_zlim3d() 边界的数据点,以生成正确的图形。然而,这些解决方法可能并不适用于所有类型的三维图形。
总结
你已经成功掌握了将多变量数据以二维和三维形式可视化的技术。尽管本章中的大部分示例围绕股票交易这一主题展开,但数据处理和可视化方法也可以轻松应用于其他领域。特别是,用于在多个面上可视化多变量数据的分治法在科学领域中非常有用。
我们没有过多探讨 Matplotlib 的三维绘图功能,因为它尚未完善。对于简单的三维图形,Matplotlib 已经足够了。如果我们使用同一个库来绘制二维和三维图形,可以减少学习曲线。如果你需要更强大的三维绘图功能,建议你查看 MayaVi2、Plotly 和 VisPy。
第九章:添加交互性并制作动画图表
作为一本通过精心设计的示例讲解 Matplotlib 使用的书籍,我们选择推迟或简化内部机制的讨论。对于那些想要了解 Matplotlib 内部工作原理的读者,建议阅读 Duncan M. McGreggor 的 Mastering matplotlib。在我们 Matplotlib 的学习旅程中,讨论后端已不可避免,这些后端将绘图命令转化为图形。这些后端可以大致分为非交互式或交互式。我们将提供与每种后端类别相关的示例。
Matplotlib 并非一开始就作为动画包设计,因此在某些高级应用中可能会显得有些缓慢。对于以动画为中心的应用,PyGame 是一个非常好的替代方案(www.pygame.org);它支持 OpenGL 和 Direct3D 加速图形,在动画对象时提供极快的速度。然而,Matplotlib 在大多数时候的表现还是可以接受的,我们将指导你如何创建比静态图表更具吸引力的动画。
本章中的示例将基于失业率和按教育水平划分的收入数据(2016 年),这些数据来自data.gov并由美国劳工部劳工统计局整理。以下是本章的大纲:
-
从网站抓取信息
-
非交互式后端
-
交互式后端:Tkinter、Jupyter 和 Plot.ly
-
创建动画图表
-
将动画导出为视频
从网站抓取信息
全球各地的政府或辖区越来越重视开放数据的重要性,开放数据旨在增加公民参与,并为决策提供信息,使政策更加公开接受公众审议。全球一些开放数据计划的示例包括data.gov(美国)、data.gov.uk(英国)和data.gov.hk(香港)。
这些数据门户通常提供应用程序编程接口(API;有关更多详细信息,请参见第七章,在线数据可视化)以便编程访问数据。然而,一些数据集没有 API,因此我们需要使用经典的网页抓取技术从网站提取信息。
BeautifulSoup(www.crummy.com/software/BeautifulSoup/)是一个非常有用的工具包,用于从网站抓取信息。基本上,任何带有 HTML 标签的内容都可以用这个神奇的工具包抓取,从文本、链接、表格和样式,到图片。Scrapy 也是一个很好的网页抓取工具包,但它更像是一个编写强大网络爬虫的框架。因此,如果你只需要从页面获取一个表格,BeautifulSoup 提供了更简单的操作方法。
在本章节中,我们将一直使用 BeautifulSoup 版本 4.6。要安装 BeautifulSoup 4,我们可以再次依赖于 PyPI:
pip install beautifulsoup4
美国教育程度(2016 年)对应的失业率和收入数据可以在www.bls.gov/emp/ep_table_001.htm找到。目前,BeautifulSoup 无法处理 HTML 请求。因此,我们需要使用 urllib.request 或 requests 包来获取网页。在这两个选项中,requests 包可能更容易使用,因为它具有更高级别的 HTTP 客户端接口。如果您的系统上没有安装 requests,可以通过 PyPI 安装它:
pip install requests
在编写网页抓取代码之前,让我们先查看一下网页。如果我们使用 Google Chrome 浏览器访问劳工统计局网站,可以通过右键单击检查与我们需要的表格对应的 HTML 代码:

将显示一个用于代码检查的弹出窗口,允许我们阅读页面上每个元素的代码。

具体来说,我们可以看到列名在<thead>...</thead>部分中定义,而表格内容在<tbody>...</tbody>部分中定义。
为了指示 BeautifulSoup 抓取我们需要的信息,我们需要给它清晰的指示。我们可以右键单击代码检查窗口中的相关部分,并复制格式为 CSS 选择器的唯一标识符。
层叠样式表(CSS)选择器最初是为了将特定于元素的样式应用于网站而设计的。有关更多信息,请访问以下页面:www.w3schools.com/cssref/css_selectors.asp。
让我们尝试获取thead和tbody的 CSS 选择器,并使用 BeautifulSoup.select() 方法来抓取相应的 HTML 代码:
import requests
from bs4 import BeautifulSoup
# Specify the url
url = "https://www.bls.gov/emp/ep_table_001.htm"
# Query the website and get the html response
response = requests.get(url)
# Parse the returned html using BeautifulSoup
bs = BeautifulSoup(response.text)
# Select the table header by CSS selector
thead = bs.select("#bodytext > table > thead")[0]
# Select the table body by CSS selector
tbody = bs.select("#bodytext > table > tbody")[0]
# Make sure the code works
print(thead)
我们从前面的代码中看到以下输出:
<thead> <tr> <th scope="col"><p align="center" valign="top"><strong>Educational attainment</strong></p></th> <th scope="col"><p align="center" valign="top">Unemployment rate (%)</p></th> <th scope="col"><p align="center" valign="top">Median usual weekly earnings ($)</p></th> </tr> </thead>
接下来,我们将查找<thead>...</thead>中所有<th>...</th>的实例,其中包含每列的名称。我们将构建一个以标题为键的列表字典来存储数据:
# Get the column names
headers = []
# Find all header columns in <thead> as specified by <th> html tags
for col in thead.find_all('th'):
headers.append(col.text.strip())
# Dictionary of lists for storing parsed data
data = {header:[] for header in headers}
最后,我们解析表格主体(<tbody>...</tbody>)中剩余的行(<tr>...</tr>),并将数据转换为 pandas DataFrame:
import pandas as pd
# Parse the rows in table body
for row in tbody.find_all('tr'):
# Find all columns in a row as specified by <th> or <td> html tags
cols = row.find_all(['th','td'])
# enumerate() allows us to loop over an iterable,
# and return each item preceded by a counter
for i, col in enumerate(cols):
# Strip white space around the text
value = col.text.strip()
# Try to convert the columns to float, except the first column
if i > 0:
value = float(value.replace(',','')) # Remove all commas in
# string
# Append the float number to the dict of lists
data[headers[i]].append(value)
# Create a dataframe from the parsed dictionary
df = pd.DataFrame(data)
# Show an excerpt of parsed data
df.head()
| 教育程度 | 中位数周收入(美元) | 失业率(%) | |
|---|---|---|---|
| 0 | 博士学位 | 1664.0 | 1.6 |
| 1 | 专业学位 | 1745.0 | 1.6 |
| 2 | 硕士学位 | 1380.0 | 2.4 |
| 3 | 学士学位 | 1156.0 | 2.7 |
| 4 | 专科学位 | 819.0 | 3.6 |
现在我们已经获取了 HTML 表格并将其格式化为结构化的 pandas DataFrame。
非交互式后端
绘制图形的代码在 Matplotlib 术语中被视为前端。我们在第六章中首次提到了后端,Hello Plotting World!,当时我们在谈论输出格式。实际上,Matplotlib 的后端在支持图形格式方面远不止差异如此简单。后端在幕后处理了许多事情!而这决定了绘图能力的支持。例如,LaTeX 文本布局仅在 AGG、PDF、PGF 和 PS 后端中得到支持。
我们至今一直在使用非交互式后端,包括 AGG、Cairo、GDK、PDF、PGF、PS 和 SVG。大多数这些后端不需要额外的依赖项,但 Cairo 和 GDK 分别需要 Cairo 图形库或 GIMP 绘图工具包才能工作。
非交互式后端可以进一步分为两类——向量和光栅。向量图形通过点、路径和形状来描述图像,这些点、路径和形状是通过数学公式计算得出的。无论缩放与否,向量图形总是显得平滑,其大小通常也远小于对应的光栅图形。PDF、PGF、PS 和 SVG 后端属于“向量”类别。
光栅图形通过有限数量的小色块(像素)来描述图像。因此,如果我们足够放大,我们会开始看到模糊的图像,换句话说,就是像素化。通过增加图像的分辨率或每英寸点数(DPI),我们不太可能观察到像素化。AGG、Cairo 和 GDK 属于这一组后端。下表总结了非交互式后端的主要功能和区别:
| 后端 | 向量/光栅 | 输出格式 |
|---|---|---|
| Agg | 光栅 | PNG |
| Cairo | 向量/光栅 | PDF、PNG、PS 或 SVG |
| 向量 | ||
| PGF | 向量 | PDF 或 PGF |
| PS | 向量 | PS |
| SVG | 向量 | SVG |
| GDK(在 Matplotlib 2.0 中已弃用) | 光栅 | PNG、JPEG 或 TIFF |
通常,我们无需手动选择后端,因为默认选择在大多数任务中都能很好地工作。另一方面,我们可以通过在导入matplotlib.pyplot之前使用matplotlib.use()方法来指定后端:
import matplotlib
matplotlib.use('SVG') # Change to SVG backend
import matplotlib.pyplot as plt
import textwrap # Standard library for text wraping
# Create a figure
fig, ax = plt.subplots(figsize=(6,7))
# Create a list of x ticks positions
ind = range(df.shape[0])
# Plot a bar chart of median usual weekly earnings by educational
# attainments
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
# Set the x-axis label
ax.set_xlabel('Median weekly earnings (USD)')
# Label the x ticks
# The tick labels are a bit too long, let's wrap them in 15-char lines
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
# Give extra margin at the bottom to display the tick labels
fig.subplots_adjust(left=0.3)
# Save the figure in SVG format
plt.savefig("test.svg")

交互式后端
Matplotlib 可以构建更具互动性的图形,这对于读者更具吸引力。有时,图形可能被图形元素所淹没,使得难以分辨单个数据点。在其他情况下,一些数据点可能看起来非常相似,以至于肉眼很难分辨差异。交互式图形可以通过让我们进行放大、缩小、平移和按需探索图形来解决这两种情况。
通过使用交互式后端,Matplotlib 中的图形可以嵌入到图形用户界面(GUI)应用程序中。默认情况下,Matplotlib 支持将 Agg 光栅图形渲染器与多种 GUI 工具包配对,包括 wxWidgets(Wx)、GIMP 工具包(GTK+)、Qt 和 Tkinter(Tk)。由于 Tkinter 是 Python 的事实标准 GUI,它建立在 Tcl/Tk 之上,因此我们可以通过在独立的 Python 脚本中调用plt.show()来创建交互式图形。
基于 Tkinter 的后端
让我们尝试将以下代码复制到一个单独的文本文件中,并命名为chapter6_gui.py。之后,在终端(Mac/Linux)或命令提示符(Windows)中输入python chapter6_gui.py。如果你不确定如何打开终端或命令提示符,请参阅第六章,Hello Plotting World!,以获取更多详情:
import matplotlib
import matplotlib.pyplot as plt
import textwrap # Standard library for text wraping
import requests
import pandas as pd
from bs4 import BeautifulSoup
# Specify the url
url = "https://www.bls.gov/emp/ep_table_001.htm"
# Query the website and get the html response
response = requests.get(url)
# Parse the returned html using BeautifulSoup
bs = BeautifulSoup(response.text)
# Select the table header by CSS selector
thead = bs.select("#bodytext > table > thead")[0]
# Select the table body by CSS selector
tbody = bs.select("#bodytext > table > tbody")[0]
# Get the column names
headers = []
# Find all header columns in <thead> as specified by <th> html tags
for col in thead.find_all('th'):
headers.append(col.text.strip())
# Dictionary of lists for storing parsed data
data = {header:[] for header in headers}
# Parse the rows in table body
for row in tbody.find_all('tr'):
# Find all columns in a row as specified by <th> or <td> html tags
cols = row.find_all(['th','td'])
# enumerate() allows us to loop over an iterable,
# and return each item preceded by a counter
for i, col in enumerate(cols):
# Strip white space around the text
value = col.text.strip()
# Try to convert the columns to float, except the first column
if i > 0:
value = float(value.replace(',','')) # Remove all commas in
# string
# Append the float number to the dict of lists
data[headers[i]].append(value)
# Create a dataframe from the parsed dictionary
df = pd.DataFrame(data)
# Create a figure
fig, ax = plt.subplots(figsize=(6,7))
# Create a list of x ticks positions
ind = range(df.shape[0])
# Plot a bar chart of median usual weekly earnings by educational
# attainments
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
# Set the x-axis label
ax.set_xlabel('Median weekly earnings (USD)')
# Label the x ticks
# The tick labels are a bit too long, let's wrap them in 15-char lines
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
# Give extra margin at the bottom to display the tick labels
fig.subplots_adjust(left=0.3)
# Show the figure in a GUI
plt.show()
我们看到一个类似于下图的弹出窗口。我们可以通过点击底部工具栏上的按钮来平移、缩放选择区域、配置子图边距、保存,并在不同视图之间来回切换。如果将鼠标悬停在图形上,我们还可以在右下角查看精确的坐标。这一功能对于分析彼此接近的数据点非常有用。

接下来,我们将通过在图形上方添加一个单选按钮小部件来扩展应用程序,使我们可以在显示每周收入或失业率之间切换。该单选按钮可以在matplotlib.widgets中找到,我们将把一个数据更新函数附加到按钮的.on_clicked()事件上。你可以将以下代码粘贴到前面代码示例(chapter6_gui.py)中的plt.show()行之前。让我们看看它是如何工作的:
# Import Matplotlib radio button widget
from matplotlib.widgets import RadioButtons
# Create axes for holding the radio selectors.
# supply [left, bottom, width, height] in normalized (0, 1) units
bax = plt.axes([0.3, 0.9, 0.4, 0.1])
radio = RadioButtons(bax, ('Weekly earnings', 'Unemployment rate'))
# Define the function for updating the displayed values
# when the radio button is clicked
def radiofunc(label):
# Select columns from dataframe, and change axis label depending on
# selection
if label == 'Weekly earnings':
data = df["Median usual weekly earnings ($)"]
ax.set_xlabel('Median weekly earnings (USD)')
elif label == 'Unemployment rate':
data = df["Unemployment rate (%)"]
ax.set_xlabel('Unemployment rate (%)')
# Update the bar heights
for i, rect in enumerate(rects):
rect.set_width(data[i])
# Rescale the x-axis range
ax.set_xlim(xmin=0, xmax=data.max()*1.1)
# Redraw the figure
plt.draw()
# Attach radiofunc to the on_clicked event of the radio button
radio.on_clicked(radiofunc)

你将看到图形上方出现一个新的单选框。尝试在两个状态之间切换,看看图形是否会相应更新。完整的代码也可以在我们的代码库中找到,名为chapter6_tkinter.py。
Jupyter Notebook 的交互式后端
在我们结束本节之前,我们将介绍两种书籍中很少涉及的交互式后端。从 Matplotlib 1.4 开始,专门为 Jupyter Notebook 设计了一个交互式后端。为了启用该功能,我们只需在笔记本的开始部分粘贴%matplotlib notebook。我们将改编本章前面的一个示例来使用这个后端:
# Import the interactive backend for Jupyter notebook
%matplotlib notebook
import matplotlib
import matplotlib.pyplot as plt
import textwrap
fig, ax = plt.subplots(figsize=(6,7))
ind = range(df.shape[0])
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
ax.set_xlabel('Median weekly earnings (USD)')
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
fig.subplots_adjust(left=0.3)
# Show the figure using interactive notebook backend
plt.show()
你将看到一个交互式界面,按钮类似于基于 Tkinter 的应用程序:

基于 Plot.ly 的后端
最后,我们将讨论 Plot.ly,这是一个基于 D3.js 的交互式绘图库,支持多种编程语言,包括 Python。由于其强大的数据仪表盘、高性能以及详细的文档,Plot.ly 在在线数据分析领域迅速获得了关注。欲了解更多信息,请访问 Plot.ly 的网站(plot.ly)。
Plot.ly 通过其 Python 绑定,提供了将 Matplotlib 图形轻松转换为在线交互式图表的功能。要安装 Plotly.py,我们可以使用 PyPI:
pip install plotly
让我们通过一个快速示例向你展示如何将 Matplotlib 与 Plot.ly 集成:
import matplotlib.pyplot as plt
import numpy as np
import plotly.plotly as py
from plotly.offline import init_notebook_mode, enable_mpl_offline, iplot_mpl
# Plot offline in Jupyter Notebooks, not required for standalone script
# Note: Must be called before any plotting actions
init_notebook_mode()
# Convert mpl plots to locally hosted HTML documents, not required if you
# are a registered plot.ly user and have a API key
enable_mpl_offline()
# Create two subplots with shared x-axis
fig, axarr = plt.subplots(2, sharex=True)
# The code for generating "df" is skipped for brevity, please refer to the
# "Tkinter-based backend" section for details of generating "df"
ind = np.arange(df.shape[0]) # the x locations for the groups
width = 0.35
# Plot a bar chart of the weekly earnings in the first axes
axarr[0].bar(ind, df["Median usual weekly earnings ($)"], width)
# Plot a bar chart of the unemployment rate in the second axes
axarr[1].bar(ind, df["Unemployment rate (%)"], width)
# Set the ticks and labels
axarr[1].set_xticks(ind)
# Reduce verbosity of labels by removing " degree"
axarr[1].set_xticklabels([value.replace(" degree","") for value in df["Educational attainment"]])
# Offline Interactive plot using plot.ly
# Note: import and use plotly.offline.plot_mpl instead for standalone
# Python scripts
iplot_mpl(fig)

运行前面的 Plot.ly 示例时,可能会看到以下错误消息:
IOPub data rate exceeded. The notebook server will temporarily stop sending output to the client in order to avoid crashing it.
To change this limit, set the config variable
--NotebookApp.iopub_data_rate_limit.
若要绕过此错误,可以通过设置更高的 iopub_data_rate_limit 重新启动 Jupyter Notebook:
jupyter notebook --NotebookApp.iopub_data_rate_limit=1.0e10
你可能还注意到,尽管代码中明确指定了刻度标签,但它们无法正确显示。这个问题也在官方 GitHub 页面上有报告(github.com/plotly/plotly.py/issues/735)。遗憾的是,目前尚未修复这个问题。
我们承认,网上有大量材料描述了如何将 Matplotlib 图形集成到不同的 GUI 应用程序中。由于篇幅限制,我们不会在这里逐一介绍这些后端。对于想要深入了解这些交互式后端的读者,Alexandre Devert 在 matplotlib Plotting Cookbook 中写了一个精彩的章节(第八章,用户界面)。在那本书的 第八章,用户界面 中,Alexandre 还提供了使用 wxWidgets、GTK 和 Pyglet 创建 GUI 应用程序的方案。
创建动画图形
正如本章开头所解释的那样,Matplotlib 最初并不是为了制作动画而设计的,而且有些 GPU 加速的 Python 动画包可能更适合这一任务(例如 PyGame)。然而,由于我们已经熟悉 Matplotlib,因此将现有的图形转化为动画非常简单。
安装 FFmpeg
在开始制作动画之前,我们需要在系统中安装 FFmpeg、avconv、MEncoder 或 ImageMagick。这些额外的依赖项并未与 Matplotlib 一起打包,因此需要单独安装。我们将带你一步步安装 FFmpeg。
对于基于 Debian 的 Linux 用户,可以在终端中输入以下命令来安装 FFmpeg:
sudo apt-get install ffmpeg
在 Ubuntu 14.04 或更早版本中,可能无法安装 FFmpeg。要在 Ubuntu 14.04 上安装 FFmpeg,请按照以下步骤操作:
**sudo add-apt-repository ppa:mc3man/trusty-media**
按 Enter 确认添加仓库。
**还需要注意,在初始设置和某些包升级时,使用 apt-get 时需要执行 sudo apt-get dist-upgrade**
**更多信息:https://launchpad.net/~mc3man/+archive/ubuntu/trusty-media**
**按 [ENTER] 继续或按 ctrl-c 取消添加**
在安装 FFmpeg 之前,更新并升级一些软件包。
**sudo apt-get update**
**sudo apt-get dist-upgrade**
最后,按照正常程序通过 apt-get 安装 FFmpeg:
**sudo apt-get install ffmpeg**
对于 Mac 用户,Homebrew(brew.sh/)是搜索和安装 FFmpeg 包的最简单方法。对于没有 Homebrew 的用户,你可以将以下代码粘贴到终端中进行安装:
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
之后,我们可以通过在终端应用中执行以下命令来安装 FFmpeg:
brew install ffmpeg
另外,你也可以通过将二进制文件(evermeet.cx/ffmpeg/)复制到系统路径中(例如,/usr/local/bin)来安装 FFmpeg。读者可以访问以下页面以获取更多细节:www.renevolution.com/ffmpeg/2013/03/16/how-to-install-ffmpeg-on-mac-os-x.html
Windows 用户的安装步骤要复杂一些,因为我们需要自己下载可执行文件,然后将其添加到系统路径中。因此,我们准备了一系列屏幕截图来引导你完成整个过程。
首先,我们需要从 ffmpeg.zeranoe.com/builds/ 获取一个预构建的二进制文件。选择与你的系统相匹配的 CPU 架构,并选择最新版本和静态链接库。

接下来,我们需要将下载的 ZIP 文件解压到 C 盘,路径为 c:\ffmpeg,并将文件夹 c:\ffmpeg\bin 添加到 Path 变量中。为此,请进入控制面板,点击“系统和安全”链接,然后点击“系统”。在系统窗口中,点击左侧的“高级系统设置”链接:

在弹出的系统属性窗口中,点击“环境变量…”按钮:

选择 Path 条目,点击“编辑…”按钮:

在“编辑环境变量”窗口中,创建一个新条目,显示 c:\ffmpeg\bin。点击所有弹出窗口中的“确定”以保存更改。重新启动命令提示符和 Jupyter Notebook,之后你就可以开始使用了。

访问 Wikihow(www.wikihow.com/Install-FFmpeg-on-Windows)获取有关如何在 Windows 7 上安装 FFmpeg 的说明。
创建动画
Matplotlib 提供了两种创建动画的主要接口:TimedAnimation和FuncAnimation。TimedAnimation适用于创建基于时间的动画,而FuncAnimation则可以根据自定义函数创建动画。考虑到FuncAnimation提供了更高的灵活性,我们将在本节中仅探索FuncAnimation的使用。读者可以参考官方文档(matplotlib.org/api/animation_api.html)了解更多关于TimedAnimation的信息。
FuncAnimation通过反复调用一个函数来改变每一帧中 Matplotlib 对象的属性。在以下示例中,我们通过假设年增长率为 5%来模拟中位数周薪的变化。我们将创建一个自定义函数--animate--它返回在每一帧中发生变化的 Matplotlib 艺术对象。这个函数将与其他一些额外参数一起传递给animation.FuncAnimation():
import textwrap
import matplotlib.pyplot as plt
import random
# Matplotlib animation module
from matplotlib import animation
# Used for generating HTML video embed code
from IPython.display import HTML
# Adapted from previous example, codes that are modified are commented
fig, ax = plt.subplots(figsize=(6,7))
ind = range(df.shape[0])
rects = ax.barh(ind, df["Median usual weekly earnings ($)"], height=0.5)
ax.set_xlabel('Median weekly earnings (USD)')
ylabels=[textwrap.fill(label,15) for label in df["Educational attainment"]]
ax.set_yticks(ind)
ax.set_yticklabels(ylabels)
fig.subplots_adjust(left=0.3)
# Change the x-axis range
ax.set_xlim(0,7600)
# Add a text annotation to show the current year
title = ax.text(0.5,1.05, "Median weekly earnings (USD) in 2016",
bbox={'facecolor':'w', 'alpha':0.5, 'pad':5},
transform=ax.transAxes, ha="center")
# Animation related stuff
n=30 #Number of frames
# Function for animating Matplotlib objects
def animate(frame):
# Simulate 5% annual pay rise
data = df["Median usual weekly earnings ($)"] * (1.05 ** frame)
# Update the bar heights
for i, rect in enumerate(rects):
rect.set_width(data[i])
# Update the title
title.set_text("Median weekly earnings (USD) in {}".format(2016+frame))
return rects, title
# Call the animator. Re-draw only the changed parts when blit=True.
# Redraw all elements when blit=False
anim=animation.FuncAnimation(fig, animate, blit=False, frames=n)
# Save the animation in MPEG-4 format
anim.save('test.mp4')
# OR--Embed the video in Jupyter notebook
HTML(anim.to_html5_video())
这是其中一个视频帧的屏幕截图:

在这个示例中,我们将动画输出为 MPEG-4 编码的视频格式。视频也可以以 H.264 编码的视频格式嵌入到 Jupyter Notebook 中。你只需要调用Animation.to_html5_video()方法,并将返回的对象传递给IPython.display.HTML。视频编码和 HTML5 代码生成将在后台自动完成。
总结
在本章中,你通过使用 BeautifulSoup 网页抓取库进一步丰富了获取在线数据的技巧。你成功地学习了创建交互式图形和动画的不同方法。这些技巧将为你在更高级的应用中创建直观且引人入胜的可视化铺平道路。
第十章:选择数据的子集
Series 或 DataFrame 中的每个数据维度都通过一个索引对象进行标记。正是这个索引将 pandas 数据结构与 NumPy 的 n 维数组区分开来。索引为每一行和每一列的数据提供有意义的标签,pandas 用户可以通过使用这些标签来选择数据。此外,pandas 还允许用户通过行列的整数位置来选择数据。这种同时支持标签选择和整数位置选择的功能,使得选择数据子集的语法既强大又令人困惑。
通过标签或整数位置选择数据并非 pandas 独有。Python 字典和列表是内建的数据结构,它们的选择方式正好是这两种方式之一。字典和列表都有明确的使用说明,并且其索引运算符的使用场景有限。字典的键(即标签)必须是不可变对象,例如字符串、整数或元组。列表必须使用整数或切片对象来进行选择。字典只能通过传递键来一次选择一个对象。从某种意义上讲,pandas 将使用整数选择数据的能力(类似于列表)和使用标签选择数据的能力(类似于字典)结合在一起。
在本章中,我们将讨论以下主题:
-
选择 Series 数据
-
选择 DataFrame 的行
-
同时选择 DataFrame 的行和列
-
使用整数和标签选择数据
-
加速标量选择
-
懒惰地切片行
-
按字典序切片
选择 Series 数据
Series 和 DataFrame 是复杂的数据容器,具有多个属性,使用索引运算符可以以不同的方式选择数据。除了索引运算符本身,.iloc 和 .loc 属性也可以使用索引运算符以各自独特的方式进行数据选择。统称这些属性为索引器。
索引术语可能会让人感到困惑。这里使用索引运算符一词,是为了与其他索引器区分开来。它指的是 Series 或 DataFrame 后面的括号 []。例如,给定一个 Series s,你可以通过以下方式选择数据:s[item] 和 s.loc[item]。第一个使用的是索引运算符,第二个使用的是 .loc 索引器。
Series 和 DataFrame 的索引器允许通过整数位置(类似于 Python 列表)和标签(类似于 Python 字典)进行选择。.iloc 索引器仅按整数位置进行选择,使用方式类似于 Python 列表。.loc 索引器仅按索引标签进行选择,类似于 Python 字典的工作方式。
准备就绪
.loc 和 .iloc 都可以与 Series 和 DataFrame 一起使用。这个示例展示了如何使用 .iloc 按整数位置选择 Series 数据,以及如何使用 .loc 按标签选择数据。这些索引器不仅可以接受标量值,还可以接受列表和切片。
如何操作...
- 使用学校数据集,设定机构名称为索引,并使用索引操作符选择单一列作为 Series:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> city = college['CITY']
>>> city.head()
INSTNM
Alabama A & M University Normal
University of Alabama at Birmingham Birmingham
Amridge University Montgomery
University of Alabama in Huntsville Huntsville
Alabama State University Montgomery
Name: CITY, dtype: object
.iloc索引器仅通过整数位置进行选择。传递一个整数给它将返回一个标量值:
>>> city.iloc[3]
Huntsville
- 要选择多个不同的整数位置,将一个列表传递给
.iloc。这将返回一个 Series:
>>> city.iloc[[10,20,30]]
INSTNM
Birmingham Southern College Birmingham
George C Wallace State Community College-Hanceville Hanceville
Judson College Marion
Name: CITY, dtype: object
- 要选择一个等距分布的数据分区,使用切片符号:
>>> city.iloc[4:50:10]
INSTNM
Alabama State University Montgomery
Enterprise State Community College Enterprise
Heritage Christian University Florence
Marion Military Institute Marion
Reid State Technical College Evergreen
Name: CITY, dtype: object
- 现在我们转向
.loc索引器,它仅通过索引标签进行选择。传递一个字符串将返回一个标量值:
>>> city.loc['Heritage Christian University']
Florence
- 要选择多个不连续的标签,使用列表:
>>> np.random.seed(1)
>>> labels = list(np.random.choice(city.index, 4))
>>> labels
['Northwest HVAC/R Training Center',
'California State University-Dominguez Hills',
'Lower Columbia College',
'Southwest Acupuncture College-Boulder']
>>> city.loc[labels]
INSTNM
Northwest HVAC/R Training Center Spokane
California State University-Dominguez Hills Carson
Lower Columbia College Longview
Southwest Acupuncture College-Boulder Boulder
Name: CITY, dtype: object
- 要选择一个等距分布的数据分区,使用切片符号。确保
start和stop值为字符串。可以使用整数来指定切片的步长:
>>> city.loc['Alabama State University':
'Reid State Technical College':10]
INSTNM
Alabama State University Montgomery
Enterprise State Community College Enterprise
Heritage Christian University Florence
Marion Military Institute Marion
Reid State Technical College Evergreen
Name: CITY, dtype: object
工作原理...
Series 中的值通过从 0 开始的整数引用。第 2 步使用 .loc 索引器选择 Series 的第四个元素。第 3 步将一个包含三个整数的列表传递给索引操作符,返回一个选择了这些整数位置的 Series。这个功能是对 Python 列表的一种增强,因为 Python 列表无法以这种方式选择多个不连续的项。
在第 4 步中,使用 start、stop 和 step 指定的切片符号选择 Series 的整个部分。
第 5 步到第 7 步是使用基于标签的索引器 .loc 重复第 2 步到第 4 步的操作。标签必须与索引中的值完全匹配。为了确保标签完全匹配,我们在第 6 步从索引中随机选择四个标签,并将它们存储到一个列表中,然后选择它们的值作为 Series。使用 .loc 索引器的选择总是包含最后一个元素,正如第 7 步所示。
还有更多内容...
当将标量值传递给索引操作符时,如第 2 步和第 5 步所示,将返回一个标量值。当传递一个列表或切片时,如其他步骤所示,将返回一个 Series。这个返回值可能看起来不一致,但如果我们将 Series 看作一个类似字典的对象,它将标签映射到值,那么返回值就有意义了。为了选择一个单独的项目并保留它在 Series 中,传递一个单项列表而不是标量值:
>>> city.iloc[[3]]
INSTNM
University of Alabama in Huntsville Huntsville
Name: CITY, dtype: object
使用 .loc 时需要小心切片符号。如果 start 索引出现在 stop 索引之后,那么会返回一个空的 Series,且不会抛出异常:
>>> city.loc['Reid State Technical College':
'Alabama State University':10]
Series([], Name: CITY, dtype: object)
参见
- Pandas 官方文档关于索引的内容(
bit.ly/2fdtZWu)
选择 DataFrame 行
选择 DataFrame 行的最明确和推荐的方式是使用 .iloc 和 .loc 索引器。它们能够独立且同时选择行或列。
准备工作
这个教程展示了如何使用 .iloc 和 .loc 索引器从 DataFrame 中选择行。
操作步骤...
- 读取学校数据集,并设置索引为机构名称:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college.head()

- 向
.iloc索引器传递一个整数,选择该位置的整行:
>>> college.iloc[60]
CITY Anchorage
STABBR AK
HBCU 0
...
UG25ABV 0.4386
MD_EARN_WNE_P10 42500
GRAD_DEBT_MDN_SUPP 19449.5
Name: University of Alaska Anchorage, Length: 26, dtype: object
- 要获取与前一步相同的行,请将索引标签传递给
.loc索引器:
>>> college.loc['University of Alaska Anchorage']
CITY Anchorage
STABBR AK
HBCU 0
...
UG25ABV 0.4386
MD_EARN_WNE_P10 42500
GRAD_DEBT_MDN_SUPP 19449.5
Name: University of Alaska Anchorage, Length: 26, dtype: object
- 若要选择不连续的行作为 DataFrame,可以将整数列表传递给
.iloc索引器:
>>> college.iloc[[60, 99, 3]]

- 可以通过将精确的机构名称列表传递给
.loc,重新生成第 4 步中的同一 DataFrame:
>>> labels = ['University of Alaska Anchorage',
'International Academy of Hair Design',
'University of Alabama in Huntsville']
>>> college.loc[labels]
- 使用
.iloc的切片表示法选择数据的整个区段:
>>> college.iloc[99:102]

- 切片表示法也适用于
.loc索引器,并且包括最后一个标签:
>>> start = 'International Academy of Hair Design'
>>> stop = 'Mesa Community College'
>>> college.loc[start:stop]
工作原理...
将标量值、标量列表或切片对象传递给 .iloc 或 .loc 索引器,会导致 pandas 扫描索引标签,返回相应的行。如果传递的是单一的标量值,则返回一个 Series。如果传递的是列表或切片对象,则返回一个 DataFrame。
还有更多内容...
在第 5 步中,可以直接从第 4 步返回的 DataFrame 中选择索引标签列表,而无需复制和粘贴:
>>> college.iloc[[60, 99, 3]].index.tolist()
['University of Alaska Anchorage',
'International Academy of Hair Design',
'University of Alabama in Huntsville']
另见
- 请参考 第十二章中的 检查 Index 对象,索引对齐。
同时选择 DataFrame 的行和列
直接使用索引操作符是选择一个或多个列的正确方法。然而,它不允许同时选择行和列。要同时选择行和列,你需要通过逗号分隔有效的行选择和列选择,传递给 .iloc 或 .loc 索引器。
准备工作
选择行和列的通用形式如下所示:
>>> df.iloc[rows, columns]
>>> df.loc[rows, columns]
rows 和 columns 变量可以是标量值、列表、切片对象或布尔序列。
向索引器传递布尔序列的内容,请参阅 第十一章,布尔索引。
在本步骤中,每个步骤展示了使用 .iloc 同时选择行和列,并使用 .loc 完全复现这一操作。
如何操作...
- 读取大学数据集,并将索引设置为机构名称。使用切片表示法选择前 3 行和前 4 列:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college.iloc[:3, :4]
>>> college.loc[:'Amridge University', :'MENONLY']

- 选择两个不同列的所有行:
>>> college.iloc[:, [4,6]].head()
>>> college.loc[:, ['WOMENONLY', 'SATVRMID']].head()

- 选择不连续的行和列:
>>> college.iloc[[100, 200], [7, 15]]
>>> rows = ['GateWay Community College',
'American Baptist Seminary of the West']
>>> columns = ['SATMTMID', 'UGDS_NHPI']
>>> college.loc[rows, columns]

- 选择一个单一的标量值:
>>> college.iloc[5, -4]
>>> college.loc['The University of Alabama', 'PCTFLOAN']
-.401
- 切片行并选择单列:
>>> college.iloc[90:80:-2, 5]
>>> start = 'Empire Beauty School-Flagstaff'
>>> stop = 'Arizona State University-Tempe'
>>> college.loc[start:stop:-2, 'RELAFFIL']
INSTNM
Empire Beauty School-Flagstaff 0
Charles of Italy Beauty College 0
Central Arizona College 0
University of Arizona 0
Arizona State University-Tempe 0
Name: RELAFFIL, dtype: int64
工作原理...
同时选择行和列的一个关键是理解括号中逗号的使用。逗号左边的选择始终基于行索引选择行,逗号右边的选择始终基于列索引选择列。
并不一定需要同时选择行和列。第 2 步展示了如何选择所有行以及部分列。冒号表示一个切片对象,它只是返回该维度的所有值。
还有更多内容...
当选择部分行,同时包含所有列时,不需要在逗号后面使用冒号。如果没有逗号,默认行为是选择所有列。前面的例子就是以这种方式选择行的。你当然可以使用冒号来表示所有列的切片。以下代码是等效的:
>>> college.iloc[:10]
>>> college.iloc[:10, :]
使用整数和标签同时选择数据
.iloc和.loc索引器各自通过整数或标签位置选择数据,但不能同时处理这两种输入类型的组合。在早期版本的 pandas 中,曾经有一个索引器.ix,它可以通过整数和标签位置同时选择数据。尽管这个功能在特定情况下很方便,但它本质上存在歧义,给许多 pandas 用户带来了困惑。因此,.ix索引器已被弃用,应避免使用。
准备工作
在.ix被弃用之前,可以使用college.ix[:5, 'UGDS_WHITE':'UGDS_UNKN']来选择大学数据集中从UGDS_WHITE到UGDS_UNKN的前五行及所有列。现在,无法直接使用.loc或.iloc来做到这一点。以下示例展示了如何查找列的整数位置,然后使用.iloc完成选择。
如何操作...
- 读取大学数据集并将机构名称(
INSTNM)指定为索引:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
- 使用索引方法
get_loc找到所需列的整数位置:
>>> col_start = college.columns.get_loc('UGDS_WHITE')
>>> col_end = college.columns.get_loc('UGDS_UNKN') + 1
>>> col_start, col_end
- 使用
col_start和col_end通过整数位置选择列,使用.iloc:
>>> college.iloc[:5, col_start:col_end]

工作原理...
第 2 步首先通过columns属性检索列的索引。索引有一个get_loc方法,它接受一个索引标签并返回该标签的整数位置。我们找到希望切片的列的起始和结束整数位置。因为.iloc的切片是不包括最后一个项的,所以需要加 1。第 3 步使用切片表示法处理行和列。
还有更多内容...
我们可以进行类似的操作,使.loc也能处理整数和位置的混合。以下示例展示了如何选择第 10 到第 15 行(包含),以及UGDS_WHITE到UGDS_UNKN的列:
>>> row_start = df_college.index[10]
>>> row_end = df_college.index[15]
>>> college.loc[row_start:row_end, 'UGDS_WHITE':'UGDS_UNKN']
使用.ix(已弃用,因此不推荐这样做)执行相同操作的方式如下:
>>> college.ix[10:16, 'UGDS_WHITE':'UGDS_UNKN']
通过将.loc和.iloc链式调用,也能实现相同的结果,但通常不建议链式调用索引器:
>>> college.iloc[10:16].loc[:, 'UGDS_WHITE':'UGDS_UNKN']
提高标量选择的速度
.iloc和.loc索引器都能够从 Series 或 DataFrame 中选择单个元素,即标量值。然而,也存在.iat和.at索引器,它们分别以更快的速度完成相同的任务。像.iloc一样,.iat索引器使用整数位置来进行选择,必须传递两个以逗号分隔的整数。类似于.loc,.at索引器使用标签进行选择,必须传递一个索引和一个列标签,以逗号分隔。
准备工作
如果计算时间至关重要,这个例子是非常有价值的。它展示了在进行标量选择时,.iat和.at相较于.iloc和.loc的性能提升。
如何实现...
- 读取
college成绩单数据集,将机构名称作为索引。将大学名称和列名传递给.loc以选择标量值:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> cn = 'Texas A & M University-College Station'
>>> college.loc[cn, 'UGDS_WHITE']
.661
- 使用
.at实现相同的结果:
>>> college.at[cn, 'UGDS_WHITE']
.661
- 使用
%timeit魔法命令来找出速度差异:
>>> %timeit college.loc[cn, 'UGDS_WHITE']
8.97 µs ± 617 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
>>> %timeit college.at[cn, 'UGDS_WHITE']
6.28 µs ± 214 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
- 查找前述选择的整数位置,然后计时
.iloc和.iat之间的差异:
>>> row_num = college.index.get_loc(cn)
>>> col_num = college.columns.get_loc('UGDS_WHITE')
>>> row_num, col_num
(3765, 10)
>>> %timeit college.iloc[row_num, col_num]
9.74 µs ± 153 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
>>> %timeit college.iat[row_num, col_num]
7.29 µs ± 431 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
它是如何工作的……
标量索引器.iat和.at只接受标量值。如果传递任何其他值,它们将失败。它们在进行标量选择时是.iloc和.loc的替代品。timeit魔法命令可以在前面加上两个百分号时计时整个代码块,加上一个百分号时计时单独的部分。它显示,通过切换到标量索引器,平均节省大约 2.5 微秒。虽然这可能不多,但如果在程序中重复进行标量选择,节省的时间会快速积累。
还有更多……
.iat和.at同样适用于 Series。将一个标量值传递给它们,它们将返回一个标量:
>>> state = college['STABBR'] # Select a Series
>>> state.iat[1000]
'IL'
>>> state.at['Stanford University']
'CA'
懒惰地切片行
本章前面的示例展示了如何使用.iloc和.loc索引器在任一维度中选择 Series 和 DataFrame 的子集。使用单一索引操作符可以实现选择行的快捷方式。这只是一个展示 pandas 附加功能的快捷方式,但索引操作符的主要功能实际上是选择 DataFrame 列。如果你想选择行,最好使用.iloc或.loc,因为它们没有歧义。
准备工作
在这个例子中,我们将切片对象传递给 Series 和 DataFrame 的索引操作符。
如何实现...
- 读取包含机构名称作为索引的大学数据集,然后从索引 10 到 20 选择每隔一行:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college[10:20:2]

- 在 Series 中也有相同的切片操作:
>>> city = college['CITY']
>>> city[10:20:2]
INSTNM
Birmingham Southern College Birmingham
Concordia College Alabama Selma
Enterprise State Community College Enterprise
Faulkner University Montgomery
New Beginning College of Cosmetology Albertville
Name: CITY, dtype: object
- Series 和 DataFrame 也可以通过标签进行切片,只需使用索引操作符:
>>> start = 'Mesa Community College'
>>> stop = 'Spokane Community College'
>>> college[start:stop:1500]

- 这里是使用 Series 的相同标签切片:
>>> city[start:stop:1500]
INSTNM
Mesa Community College Mesa
Hair Academy Inc-New Carrollton New Carrollton
National College of Natural Medicine Portland
Name: CITY, dtype: object
它是如何工作的……
索引操作符的行为会根据传递给它的对象类型而改变。以下伪代码概述了 DataFrame 索引操作符如何处理传递给它的对象:
>>> df[item] # Where `df` is a DataFrame and item is some object
If item is a string then
Find a column name that matches the item exactly
Raise KeyError if there is no match
Return the column as a Series
If item is a list of strings then
Raise KeyError if one or more strings in item don't match columns
Return a DataFrame with just the columns in the list
If item is a slice object then
Works with either integer or string slices
Raise KeyError if label from label slice is not in index
Return all ROWS that are selected by the slice
If item is a list, Series or ndarray of booleans then
Raise ValueError if length of item not equal to length of DataFrame
Use the booleans to return only the rows with True in same location
前述的逻辑涵盖了所有最常见的情况,但并不是一个详尽无遗的列表。对于 Series,逻辑稍微不同,实际上比 DataFrame 更复杂。由于其复杂性,最好避免仅仅在 Series 上使用索引操作符,而是使用明确的.iloc和.loc索引器。
Series 索引操作符的一种可接受使用场景是进行布尔索引。详情请参见第十二章,索引对齐。
我将这一类型的行切片称为懒惰切片,因为它不使用更明确的.iloc或.loc。就我个人而言,每当进行行切片时,我总是使用这些索引器,因为这样做没有任何疑问,能够明确知道自己在做什么。
还有更多内容...
需要注意的是,这种懒惰切片方法只适用于 DataFrame 的行和 Series,不能用于列。此外,也不能同时选择行和列。例如,下面的代码试图选择前十行和两列:
>>> college[:10, ['CITY', 'STABBR']]
TypeError: unhashable type: 'slice'
要以这种方式进行选择,你需要使用.loc或.iloc。这里有一种可能的方式,首先选择所有学院标签,然后使用基于标签的索引器.loc:
>>> first_ten_instnm = college.index[:10]
>>> college.loc[first_ten_instnm, ['CITY', 'STABBR']]
字典顺序切片
.loc索引器通常根据索引的精确字符串标签来选择数据。然而,它也允许你根据索引中值的字典顺序来选择数据。具体来说,.loc允许你使用切片符号按字典顺序选择所有行。这只有在索引已排序的情况下才有效。
准备工作
在这个实例中,你将首先对索引进行排序,然后在.loc索引器内部使用切片符号选择两个字符串之间的所有行。
如何操作...
- 读取学院数据集,并将学院名称设置为索引:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
- 尝试选择所有名称在字典顺序上介于
'Sp'和'Su'之间的学院:
>>> college.loc['Sp':'Su']
KeyError: 'Sp'
- 由于索引未排序,之前的命令会失败。让我们对索引进行排序:
>>> college = college.sort_index()

- 现在,让我们重新运行步骤 2 中的相同命令:
>>> college.loc['Sp':'Su']

它是如何工作的...
.loc的正常行为是根据传入的精确标签来选择数据。当这些标签在索引中找不到时,它会引发KeyError。然而,当索引按字典顺序排序并传入切片时,存在一个特殊例外。在这种情况下,即使start和stop标签不是索引中的精确值,也可以在它们之间进行选择。
还有更多内容...
使用这个方法,选择字母表中两个字母之间的学院非常容易。例如,要选择所有以字母D到S开头的学院,你可以使用college.loc['D':'T']。像这样切片时,最后一个索引仍然包括在内,所以这将返回一个名称恰好为T的学院。
这种类型的切片在索引按相反方向排序时也适用。你可以通过 is_monotonic_increasing 或 is_monotonic_decreasing 属性来判断索引的排序方向。这两个属性中的任何一个必须为 True,才能使字典顺序切片生效。例如,以下代码会按字典顺序将索引从 Z 排序到 A:
>>> college = college.sort_index(ascending=False)
>>> college.index.is_monotonic_decreasing
True
>>> college.loc['E':'B']

Python 会将所有大写字母排在小写字母之前,所有整数排在大写字母之前。
第十一章:布尔索引
从数据集中筛选数据是最常见和基本的操作之一。使用布尔索引,有多种方式可以在 pandas 中筛选(或子集化)数据。布尔索引(也称为布尔选择)可能是一个让人困惑的术语,但在 pandas 中,它指的是通过为每一行提供布尔值(True 或 False)来选择行。这些布尔值通常存储在 Series 或 NumPy 的ndarray中,并通常通过对 DataFrame 中一个或多个列应用布尔条件来创建。我们首先创建布尔 Series,并对其计算统计数据,然后再通过创建更复杂的条件来使用布尔索引,以多种方式筛选数据。
在本章中,我们将讨论以下主题:
-
计算布尔统计数据
-
构造多个布尔条件
-
使用布尔索引筛选
-
使用索引选择复制布尔索引
-
使用唯一且排序的索引进行选择
-
获取股价的视角
-
转换 SQL WHERE 子句
-
确定股市回报的正态性
-
使用 query 方法提高布尔索引的可读性
-
使用
where方法保留 Series -
屏蔽 DataFrame 行
-
使用布尔值、整数位置和标签进行选择
计算布尔统计数据
在首次接触布尔 Series 时,计算其基本汇总统计信息可能会很有帮助。布尔 Series 中的每个值都被评估为 0 或 1,因此所有适用于数值的 Series 方法也适用于布尔值。
准备工作
在这个例子中,我们通过对数据列应用条件来创建布尔 Series,然后从中计算汇总统计信息。
如何操作...
- 读取
movie数据集,将索引设置为电影标题,并查看前几行:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
>>> movie.head()

- 通过使用大于比较运算符与
durationSeries,判断每部电影的时长是否超过两小时:
>>> movie_2_hours = movie['duration'] > 120
>>> movie_2_hours.head(10)
movie_title
Avatar True
Pirates of the Caribbean: At World's End True
Spectre True
The Dark Knight Rises True
Star Wars: Episode VII - The Force Awakens False
John Carter True
Spider-Man 3 True
Tangled False
Avengers: Age of Ultron True
Harry Potter and the Half-Blood Prince True
Name: duration, dtype: bool
- 现在我们可以使用这个 Series 来确定时长超过两小时的电影数量:
>>> movie_2_hours.sum()
1039
- 要找出数据集中时长超过两小时的电影百分比,可以使用
mean方法:
>>> movie_2_hours.mean()
0.2114
- 不幸的是,第 4 步的输出是误导性的。
duration列有一些缺失值。如果你回顾第 1 步的 DataFrame 输出,你会看到最后一行缺少duration的值。第 2 步的布尔条件对此返回了False。我们需要先删除缺失值,然后再评估条件并计算均值:
>>> movie['duration'].dropna().gt(120).mean()
.2112
- 使用
describe方法输出布尔 Series 的一些汇总统计信息:
>>> movie_2_hours.describe()
count 4916
unique 2
top False
freq 3877
Name: duration, dtype: object
它是如何工作的...
大多数 DataFrame 不会像我们的电影数据集那样拥有布尔列。生成布尔系列的最直接方法是将条件应用于其中一列,使用比较运算符。在步骤 2 中,我们使用大于运算符来测试每部电影的时长是否超过两小时(120 分钟)。步骤 3 和 4 计算了布尔系列的两个重要量——其总和和均值。这些方法之所以可行,是因为 Python 将False/True分别视为 0/1。
你可以通过计算布尔系列的均值来证明其表示True值的百分比。为此,可以使用value_counts方法,并将normalize参数设置为True来获取其分布情况:
>>> movie_2_hours.value_counts(normalize=True)
False 0.788649
True 0.211351
Name: duration, dtype: float64
步骤 5 提醒我们注意步骤 4 的错误结果。尽管duration列有缺失值,但布尔条件仍然将所有针对缺失值的比较结果评估为False。删除这些缺失值使我们能够计算出正确的统计量。这可以通过方法链式调用一步完成。
步骤 6 展示了 pandas 如何将布尔列与其处理对象数据类型的方式相似,显示频率信息。这是思考布尔系列的一种自然方式,而不是像处理数值数据时那样显示分位数。
还有更多内容...
可以比较来自同一 DataFrame 的两列,生成布尔系列。例如,我们可以确定电影中演员 1 是否比演员 2 拥有更多的 Facebook 点赞数。为此,我们需要选择这两列,并删除任何包含缺失值的行。然后,我们进行比较并计算均值:
>>> actors = movie[['actor_1_facebook_likes',
'actor_2_facebook_likes']].dropna()
>>> (actors['actor_1_facebook_likes'] >
actors['actor_2_facebook_likes']).mean()
.978
构建多个布尔条件
在 Python 中,布尔表达式使用内建的逻辑运算符and、or和not。这些关键字不能与 pandas 中的布尔索引一起使用,分别被替换为&、|和~。此外,每个表达式必须用括号括起来,否则会抛出错误。
准备就绪
构建一个精确的筛选器来处理你的数据集,可能需要将多个布尔表达式结合起来,提取出一个精确的子集。在这个步骤中,我们构建了多个布尔表达式,并将它们结合起来,找出所有imdb_score大于 8、content_rating为 PG-13 且title_year早于 2000 年或晚于 2009 年的电影。
如何实现...
- 加载电影数据集并将标题设置为索引:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
- 创建一个变量来独立地存储每组标准,作为一个布尔系列:
>>> criteria1 = movie.imdb_score > 8
>>> criteria2 = movie.content_rating == 'PG-13'
>>> criteria3 = ((movie.title_year < 2000) |
(movie.title_year > 2009))
>>> criteria2.head() # all criteria Series look similar
movie_title
Avatar True
Pirates of the Caribbean: At World's End True
Spectre True
The Dark Knight Rises True
Star Wars: Episode VII - The Force Awakens False
Name: content_rating, dtype: bool
- 将所有标准结合成一个布尔系列:
>>> criteria_final = criteria1 & criteria2 & criteria3
>>> criteria_final.head()
movie_title
Avatar False
Pirates of the Caribbean: At World's End False
Spectre False
The Dark Knight Rises True
Star Wars: Episode VII - The Force Awakens False
dtype: bool
工作原理...
Series 中的所有值都可以使用标准的比较运算符(<、>、==、!=、<=、>=)与标量值进行比较。表达式movie.imdb_score > 8将产生一个布尔值 Series,其中所有imdb_score大于 8 的值为True,小于或等于 8 的为False。这个布尔 Series 的索引保持与原始 Series 相同,在这个例子中,是电影的标题。
criteria3变量由两个独立的布尔表达式组成。每个表达式必须用括号括起来才能正常工作。管道字符|用于在两个 Series 的每个值之间创建逻辑or条件。
所有三个条件都需要为True,才能符合食谱的要求。它们通过与符号&结合,形成一个逻辑and条件,适用于每个 Series 值。
还有更多...
pandas 使用不同语法进行逻辑运算符的结果是,运算符的优先级不再相同。比较运算符的优先级高于and、or和not。然而,pandas 的新运算符(按位运算符&、|和~)的优先级高于比较运算符,因此需要使用括号。以下是一个示例,帮助更清楚地理解:
>>> 5 < 10 and 3 > 4
False
在前面的表达式中,5 < 10首先求值,然后是3 < 4,最后求值and。Python 按如下顺序推进表达式:
>>> 5 < 10 and 3 > 4
>>> True and 3 > 4
>>> True and False
>>> False
让我们看看如果criteria3中的表达式写成如下会发生什么:
>>> movie.title_year < 2000 | movie.title_year > 2009
TypeError: cannot compare a dtyped [float64] array with a scalar of type [bool]
由于按位运算符的优先级高于比较运算符,首先会求值2000 | movie.title_year,这是没有意义的并且会抛出错误。因此,需要使用括号来确保运算以正确的顺序进行。
为什么 pandas 不能使用and、or和not?当这些关键字被求值时,Python 试图找到对象整体的真值。由于将整个 Series 视为 True 或 False 没有意义——只有每个元素才有意义——因此 pandas 会抛出错误。
Python 中的许多对象都有布尔表示。例如,除 0 以外的所有整数都被视为True。除空字符串外的所有字符串都为True。所有非空的集合、元组、字典和列表都为True。一个空的 DataFrame 或 Series 不会被评估为 True 或 False,而是抛出错误。通常,要获取 Python 对象的真值,可以将其传递给bool函数。
另见
- Python 运算符优先级(
bit.ly/2vxuqSn)
使用布尔索引进行筛选
对于 Series 和 DataFrame 对象的布尔选择几乎是相同的。两者都是通过将一个布尔值 Series(其索引与被过滤对象相同)传递给索引运算符来实现的。
准备工作
本示例构建了两个复杂且独立的布尔标准,用于选择不同的电影集。第一组电影来自前一个示例,包含 imdb_score 大于 8,content_rating 为 PG-13,以及 title_year 在 2000 年之前或 2009 年之后的电影。第二组电影包含 imdb_score 小于 5,content_rating 为 R,并且 title_year 在 2000 年到 2010 年之间的电影。
如何操作...
- 读取
movie数据集,将索引设置为movie_title,并创建第一组标准:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
>>> crit_a1 = movie.imdb_score > 8
>>> crit_a2 = movie.content_rating == 'PG-13'
>>> crit_a3 = (movie.title_year < 2000) | (movie.title_year > 2009)
>>> final_crit_a = crit_a1 & crit_a2 & crit_a3
- 为第二组电影创建标准:
>>> crit_b1 = movie.imdb_score < 5
>>> crit_b2 = movie.content_rating == 'R'
>>> crit_b3 = ((movie.title_year >= 2000) &
(movie.title_year <= 2010))
>>> final_crit_b = crit_b1 & crit_b2 & crit_b3
- 使用 pandas
or操作符将两个标准结合起来。这将生成一个布尔序列,包含属于任一标准的所有电影:
>>> final_crit_all = final_crit_a | final_crit_b
>>> final_crit_all.head()
movie_title
Avatar False
Pirates of the Caribbean: At World's End False
Spectre False
The Dark Knight Rises True
Star Wars: Episode VII - The Force Awakens False
dtype: bool
- 一旦你有了布尔序列,只需将其传递给索引操作符来过滤数据:
>>> movie[final_crit_all].head()

- 我们已经成功地过滤了数据和数据框的所有列。我们无法轻松地手动检查过滤是否正确。让我们通过
.loc索引器同时过滤行和列:
>>> cols = ['imdb_score', 'content_rating', 'title_year']
>>> movie_filtered = movie.loc[final_crit_all, cols]
>>> movie_filtered.head(10)

它是如何工作的…
在第 1 步和第 2 步中,每组标准都由更简单的布尔表达式构建。并不需要像这里那样为每个布尔表达式创建不同的变量,但这确实使得读取和调试逻辑错误更容易。由于我们需要两组电影,第 3 步使用 pandas 逻辑 or 操作符将它们结合起来。
第 4 步展示了布尔索引的准确语法。你只需将第 3 步中创建的布尔序列直接传递给索引操作符。只有 final_crit_all 中值为 True 的电影会被选择。
布尔索引也可以与 .loc 索引器一起使用,如第 5 步所示,通过同时进行布尔索引和单独列选择。这种精简的数据框更容易手动检查逻辑是否正确实现。
布尔索引不能与 .iloc 索引操作符一起正常工作。如果将布尔序列传递给它,会引发异常。但是,如果传递布尔 ndarray,它将与其他索引器中的行为相同。
还有更多...
如前所述,可以使用一个长的布尔表达式代替多个较短的表达式。为了用一行长代码复制第 1 步中的 final_crit_a 变量,我们可以这样做:
>>> final_crit_a2 = (movie.imdb_score > 8) & \
(movie.content_rating == 'PG-13') & \
((movie.title_year < 2000) |
(movie.title_year > 2009))
>>> final_crit_a2.equals(final_crit_a)
True
另见
-
Pandas 官方文档中的 布尔索引 (
bit.ly/2v1xK77) -
检查 Python 对象的真实性 (
bit.ly/2vn8WXX)
使用索引选择复制布尔索引
通过利用索引,复制特定的布尔选择情况是可能的。通过索引进行选择更加直观,也提高了可读性。
准备工作
在本例中,我们使用college数据集通过布尔索引和索引选择分别选择来自特定州的所有机构,并将它们的性能进行比较。
如何实现...
- 读取
college数据集并使用布尔索引选择德克萨斯州(TX)的所有机构:
>>> college = pd.read_csv('data/college.csv')
>>> college[college['STABBR'] == 'TX'].head()
Pandas 官方文档

- 为了通过索引选择来复制此操作,我们需要将
STABBR列移到索引中。然后我们可以使用基于标签的选择,通过.loc索引器来选择:
>>> college2 = college.set_index('STABBR')
>>> college2.loc['TX'].head()

- 让我们比较两种方法的速度:
>>> %timeit college[college['STABBR'] == 'TX']
1.43 ms ± 53.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit college2.loc['TX']
526 µs ± 6.67 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
- 布尔索引的时间是索引选择的三倍。由于设置索引并非免费的,因此我们也需要计时这个操作:
>>> %timeit college2 = college.set_index('STABBR')
1.04 ms ± 5.37 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
工作原理...
第一步通过判断哪些数据行的STABBR等于TX来创建布尔 Series。这个 Series 被传递给索引操作符,进而对数据进行子集化。可以通过将相同的列移动到索引中,并使用基于标签的索引选择.loc来复制此过程。通过索引选择的数据比通过布尔选择的数据要快得多。
还有更多内容...
本例只选择一个州。也可以使用布尔索引和索引选择同时选择多个州。让我们选择德克萨斯(TX)、加利福尼亚(CA)和纽约(NY)。对于布尔选择,可以使用isin方法;而对于索引选择,只需将列表传递给.loc:
>>> states = ['TX', 'CA', 'NY']
>>> college[college['STABBR'].isin(states)]
>>> college2.loc[states]
这篇食谱解释的内容远不止这些。Pandas 根据索引是否唯一或已排序来实现索引。有关更多详细信息,请参阅以下食谱。
使用唯一和已排序索引进行选择
当索引唯一或已排序时,索引选择性能会大幅提高。之前的例子使用了一个未排序的索引,该索引包含重复项,这导致选择过程相对较慢。
准备工作
在本食谱中,我们使用college数据集来形成唯一或排序的索引,以提高索引选择的性能。我们将继续将其与布尔索引进行性能比较。
如何实现...
- 读取 college 数据集,创建一个以
STABBR为索引的单独 DataFrame,并检查索引是否已排序:
>>> college = pd.read_csv('data/college.csv')
>>> college2 = college.set_index('STABBR')
>>> college2.index.is_monotonic
False
- 对
college2的索引进行排序,并将其存储为另一个对象:
>>> college3 = college2.sort_index()
>>> college3.index.is_monotonic
True
- 对来自三个数据框架的德克萨斯州(TX)选择进行计时:
>>> %timeit college[college['STABBR'] == 'TX']
1.43 ms ± 53.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit college2.loc['TX']
526 µs ± 6.67 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit college3.loc['TX']
183 µs ± 3.67 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
- 排序后的索引比布尔选择快近一个数量级。现在我们来看看唯一索引。为此,我们使用机构名称作为索引:
>>> college_unique = college.set_index('INSTNM')
>>> college_unique.index.is_unique
True
- 让我们使用布尔索引选择斯坦福大学:
>>> college[college['INSTNM'] == 'Stanford University']

- 让我们使用索引选择来选择斯坦福大学:
>>> college_unique.loc['Stanford University']
CITY Stanford
STABBR CA
HBCU 0
...
UG25ABV 0.0401
MD_EARN_WNE_P10 86000
GRAD_DEBT_MDN_SUPP 12782
Name: Stanford University, dtype: object
- 它们都产生相同的数据,只是对象不同。让我们对每种方法进行计时:
>>> %timeit college[college['INSTNM'] == 'Stanford University']
1.3 ms ± 56.8 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit college_unique.loc['Stanford University']
157 µs ± 682 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
工作原理...
当索引未排序且包含重复项时,例如college2,Pandas 需要检查索引中的每个值,以确保做出正确选择。当索引已排序时,如college3,Pandas 利用一种叫做二分查找的算法,极大地提高了性能。
在这部分的食谱中,我们使用一个独特的列作为索引。Pandas 通过哈希表实现唯一索引,这使得选择操作更加高效。无论索引的长度如何,每个索引位置几乎可以在相同的时间内被查找。
还有更多内容...
与索引选择相比,布尔选择提供了更多的灵活性,因为它可以基于任意多个列进行条件筛选。在这个食谱中,我们使用了一个单一的列作为索引。实际上,也可以将多个列连接起来作为索引。例如,在下面的代码中,我们将索引设置为城市和州列的连接:
>>> college.index = college['CITY'] + ', ' + college['STABBR']
>>> college = college.sort_index()
>>> college.head()

从这里开始,我们可以选择来自特定城市和州组合的所有大学,而不使用布尔索引。让我们选择来自Miami, FL的所有大学:
>>> college.loc['Miami, FL'].head()

我们可以将这个复合索引选择的速度与布尔索引进行比较。差异的数量级超过一个:
>>> %%timeit
>>> crit1 = college['CITY'] == 'Miami'
>>> crit2 = college['STABBR'] == 'FL'
>>> college[crit1 & crit2]
2.43 ms ± 80.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit college.loc['Miami, FL']
197 µs ± 8.69 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
参见
- 二分查找算法 (
bit.ly/2wbMq20)
获取股票价格的视角
对于那些购买了长期股票头寸的投资者,显然希望在股票价格接近或达到历史最高点时卖出股票。当然,实际上这非常困难,尤其是当股票价格在其历史中只有一小部分时间位于某个阈值之上时。我们可以使用布尔索引找到股票价格超过或低于某个值的所有时间点。这个练习可能帮助我们理解某只股票的常见交易区间。
准备工作
在这个食谱中,我们研究了 2010 年初至 2017 年中期的施耐德(Schlumberger)股票。我们使用布尔索引提取了这段时间内最低和最高 10%的收盘价序列。然后,我们绘制了所有数据点,并突出显示那些属于上下 10%的数据点。
如何实现...
- 读取施耐德股票数据,将
Date列设置为索引,并将其转换为DatetimeIndex:
>>> slb = pd.read_csv('data/slb_stock.csv', index_col='Date',
parse_dates=['Date'])
>>> slb.head()

- 选择收盘价作为一个序列,并使用
describe方法返回总结统计信息:
>>> slb_close = slb['Close']
>>> slb_summary = slb_close.describe(percentiles=[.1, .9])
>>> slb_summary
count 1895.000000
mean 79.121905
std 11.767802
min 51.750000
10% 64.892000
50% 78.000000
90% 93.248000
max 117.950000
Name: Close, dtype: float64
- 使用布尔选择,选择所有位于上下第十百分位数的收盘价:
>>> upper_10 = slb_summary.loc['90%']
>>> lower_10 = slb_summary.loc['10%']
>>> criteria = (slb_close < lower_10) | (slb_close > upper_10)
>>> slb_top_bottom_10 = slb_close[criteria]
- 将筛选后的结果序列以浅灰色绘制在所有收盘价(黑色)之上。使用
matplotlib库在第十和第九十百分位数位置绘制水平线:
>>> slb_close.plot(color='black', figsize=(12,6))
>>> slb_top_bottom_10.plot(marker='o', style=' ',
ms=4, color='lightgray')
>>> xmin = criteria.index[0]
>>> xmax = criteria.index[-1]
>>> plt.hlines(y=[lower_10, upper_10], xmin=xmin,
xmax=xmax, color='black')

它是如何工作的...
第 2 步中 describe 方法的结果本身是一个 Series,标识性总结统计量作为其索引标签。这个总结 Series 用于存储第十百分位数和第九十百分位数作为独立变量。第 3 步使用布尔索引选择分布的上下第十百分位数中的值。
Series 和 DataFrame 都通过 plot 方法提供直接的绘图功能。第一次调用 plot 方法来自 slb_close Series,它包含了所有的 SLB 收盘价。这是图表中的黑线。来自 slb_filtered 的数据点作为灰色标记直接绘制在收盘价上。style 参数设置为一个空格,这样就不会绘制任何线条。ms 参数设置标记的大小。
Matplotlib 提供了一个方便的函数 hlines,用于绘制水平线。它接受一组 y 值,并从 xmin 到 xmax 绘制这些线。
从我们创建的图表的新视角来看,可以清楚地看到,尽管 SLB 的历史最高股价接近每股 $120,但过去七年中仅有 10% 的交易日股价超过每股 $93。
还有更多...
我们可以不再通过在收盘价上绘制红色(或黑色)点来表示上下第十百分位数,而是使用 matplotlib 的 fill_between 函数。这个函数填充两条线之间的所有区域。它接受一个可选的 where 参数,该参数接受一个布尔 Series,告知它确切的填充位置:
>>> slb_close.plot(color='black', figsize=(12,6))
>>> plt.hlines(y=[lower_10, upper_10],
xmin=xmin, xmax=xmax,color='lightgray')
>>> plt.fill_between(x=criteria.index, y1=lower_10,
y2=slb_close.values, color='black')
>>> plt.fill_between(x=criteria.index,y1=lower_10,
y2=slb_close.values, where=slb_close < lower_10,
color='lightgray')
>>> plt.fill_between(x=criteria.index, y1=upper_10,
y2=slb_close.values, where=slb_close > upper_10,
color='lightgray')

翻译 SQL WHERE 子句
许多 pandas 用户有直接从数据库处理数据的背景,使用的是无处不在的 结构化查询语言 (SQL) 。SQL 是一种标准化的语言,用于定义、操作和控制存储在数据库中的数据。SELECT 语句是使用 SQL 选择、过滤、聚合和排序数据的最常用方式。Pandas 可以连接到数据库并向其发送 SQL 语句。
SQL 是数据科学家必须掌握的一种非常重要的语言。世界上大部分的数据存储在需要 SQL 来检索、操作和进行分析的数据库中。SQL 语法相对简单且容易学习。许多公司如 Oracle、Microsoft、IBM 等提供了不同的 SQL 实现。尽管不同的实现之间的语法不兼容,但其核心语法非常相似。
准备工作
在 SQL SELECT 语句中,WHERE 子句是非常常见的,用于过滤数据。这个方法将编写与 SQL 查询等效的 pandas 代码,用于选择员工数据集中的某个子集。
使用此方法并不需要理解任何 SQL 语法。
假设我们被要求找到所有在警察或消防部门工作的女性员工,她们的基础薪资在 8 万到 12 万美元之间。以下 SQL 语句将为我们解答这个查询:
SELECT
UNIQUE_ID,
DEPARTMENT,
GENDER,
BASE_SALARY
FROM
EMPLOYEE
WHERE
DEPARTMENT IN ('Houston Police Department-HPD',
'Houston Fire Department (HFD)') AND
GENDER = 'Female' AND
BASE_SALARY BETWEEN 80000 AND 120000;
如何做...
- 将
employee数据集读入为 DataFrame:
>>> employee = pd.read_csv('data/employee.csv')
- 在筛选数据之前,最好先对每个被筛选的列进行一些手动检查,以了解筛选中将使用的确切值:
>>> employee.DEPARTMENT.value_counts().head()
Houston Police Department-HPD 638
Houston Fire Department (HFD) 384
Public Works & Engineering-PWE 343
Health & Human Services 110
Houston Airport System (HAS) 106
Name: DEPARTMENT, dtype: int64
>>> employee.GENDER.value_counts()
Male 1397
Female 603
>>> employee.BASE_SALARY.describe().astype(int)
count 1886
mean 55767
std 21693
min 24960
25% 40170
50% 54461
75% 66614
max 275000
Name: BASE_SALARY, dtype: int64
- 为每个标准写一个语句。使用
isin方法来测试是否等于多个值中的一个:
>>> depts = ['Houston Police Department-HPD',
'Houston Fire Department (HFD)']
>>> criteria_dept = employee.DEPARTMENT.isin(depts)
>>> criteria_gender = employee.GENDER == 'Female'
>>> criteria_sal = (employee.BASE_SALARY >= 80000) & \
(employee.BASE_SALARY <= 120000)
- 将所有布尔 Series 组合在一起:
>>> criteria_final = (criteria_dept &
criteria_gender &
criteria_sal)
- 使用布尔索引仅选择符合最终标准的行:
>>> select_columns = ['UNIQUE_ID', 'DEPARTMENT',
'GENDER', 'BASE_SALARY']
>>> employee.loc[criteria_final, select_columns].head()

它是如何工作的...
在实际进行筛选之前,你显然需要知道将使用的确切字符串名称。Series 的value_counts方法是获取确切字符串名称和该值出现次数的绝佳方式。
isin方法等同于 SQL 中的IN操作符,接受一个包含所有可能保留值的列表。虽然可以使用一系列OR条件来模拟这个表达式,但效率较低且不够规范。
薪资标准criteria_sal是通过结合两个简单的不等式表达式形成的。所有标准最终通过 pandas 的and运算符&组合在一起,产生一个单一的布尔 Series 作为筛选条件。
还有更多...
对于许多操作,pandas 提供了多种方法来做同一件事。在前面的示例中,薪资标准使用了两个独立的布尔表达式。与 SQL 类似,Series 也有between方法,薪资标准可以等价地写成如下形式:
>>> criteria_sal = employee.BASE_SALARY.between(80000, 120000)
isin的另一个有用应用是提供由其他 pandas 语句自动生成的值序列。这可以避免手动调查以查找需要存储在列表中的确切字符串名称。相反,假设我们要排除出现频率最高的前五个部门的行:
>>> top_5_depts = employee.DEPARTMENT.value_counts().index[:5]
>>> criteria = ~employee.DEPARTMENT.isin(top_5_depts)
>>> employee[criteria]
SQL 的等效查询如下:
SELECT
*
FROM
EMPLOYEE
WHERE
DEPARTMENT not in
(
SELECT
DEPARTMENT
FROM (
SELECT
DEPARTMENT,
COUNT(1) as CT
FROM
EMPLOYEE
GROUP BY
DEPARTMENT
ORDER BY
CT DESC
LIMIT 5
)
);
注意使用 pandas 的非操作符~,它对 Series 的所有布尔值取反。
另见
-
Pandas 官方文档中的
isin方法(bit.ly/2v1GPfQ)和between方法(bit.ly/2wq9YPF) -
请参考第十五章中的连接到 SQL 数据库的食谱,合并 Pandas 对象
-
W3Schools 中的 SQL 基础介绍(
bit.ly/2hsq8Wp) -
SQL 中的 IN 操作符(
bit.ly/2v3H7Bg) -
SQL 中的 BETWEEN 操作符(
bit.ly/2vn5UTP)
确定股市回报的正态性
在基础统计学教材中,正态分布被广泛用于描述许多不同的数据群体。虽然大多数时间,许多随机过程确实看起来像正态分布,但现实生活往往更加复杂。股市回报是一个典型的例子,它的分布看起来可能相当正常,但实际上却可能相差甚远。
准备工作
这份食谱描述了如何获取互联网零售巨头亚马逊的每日股市回报,并非正式地测试它们是否符合正态分布。
如何做...
- 加载亚马逊股票数据并将日期设为索引:
>>> amzn = pd.read_csv('data/amzn_stock.csv', index_col='Date',
parse_dates=['Date'])
>>> amzn.head()

- 通过仅选择收盘价并使用
pct_change方法来计算每日回报率,创建一个 Series:
>>> amzn_daily_return = amzn.Close.pct_change()
>>> amzn_daily_return.head()
Date
2010-01-04 NaN
2010-01-05 0.005900
2010-01-06 -0.018116
2010-01-07 -0.017013
2010-01-08 0.027077
Name: Close, dtype: float64
- 删除缺失值并绘制回报的直方图,以视觉检查分布:
>>> amzn_daily_return = amzn_daily_return.dropna()
>>> amzn_daily_return.hist(bins=20)

- 正态分布大致遵循 68-95-99.7 法则——意味着 68%的数据位于均值 1 个标准差范围内,95%位于 2 个标准差内,99.7%位于 3 个标准差内。我们现在将计算每日回报中有多少百分比落在均值的 1、2 和 3 个标准差范围内。为此,我们需要均值和标准差:
>>> mean = amzn_daily_return.mean()
>>> std = amzn_daily_return.std()
- 计算每个观察值的
z-score的绝对值。z-score是离均值的标准差个数:
>>> abs_z_score = amzn_daily_return.sub(mean).abs().div(std)
- 找出位于 1、2 和 3 个标准差范围内的回报百分比:
>>> pcts = [abs_z_score.lt(i).mean() for i in range(1,4)]
>>> print('{:.3f} fall within 1 standard deviation. '
'{:.3f} within 2 and {:.3f} within 3'.format(*pcts))
0.787 fall within 1 standard deviation. 0.957 within 2 and 0.985 within 3
它是如何工作的...
默认情况下,pct_change Series 方法计算当前元素与前一个元素之间的百分比变化。这将原始的股票收盘价转化为每日百分比回报。返回 Series 的第一个元素是缺失值,因为没有前一个价格。
直方图是总结和可视化一维数值数据的极佳图形。从图中可以明显看出,分布是对称的,但仍然很难判断它是否符合正态分布。有一些正式的统计方法可以用来确定分布的正态性,但我们将简单地找到数据与 68-95-99.7 法则的匹配程度。
第 5 步计算每个观察值离均值的标准差个数,这被称为z-score。这一步使用方法而非符号(-和/)来进行减法和除法。步骤 6 中也使用了小于的方法,而不是符号。
步骤 6 中取均值可能看起来有些奇怪。abs_z_score.lt(1)表达式的结果是一个布尔值的 Series。由于布尔值会被计算为 0 或 1,取该 Series 的均值返回的是True元素所占的百分比,这正是我们所需要的。
现在,我们可以通过将结果数字(78.7-95.7-98.5)与 68-95-99.7 规则进行比较,更容易判断回报的正态性。对于 1 和 3 个标准差,百分比与规则的偏差很大,我们可以得出结论:亚马逊的日股回报不遵循正态分布。
还有更多……
为了自动化此过程,我们可以编写一个函数,接受股票数据并输出日回报的直方图,以及落在均值的 1、2 和 3 个标准差范围内的百分比。以下函数完成了这一功能,并用符号对应的替代方法替换了原有方法:
>>> def test_return_normality(stock_data):
close = stock_data['Close']
daily_return = close.pct_change().dropna()
daily_return.hist(bins=20)
mean = daily_return.mean()
std = daily_return.std()
abs_z_score = abs(daily_return - mean) / std
pcts = [abs_z_score.lt(i).mean() for i in range(1,4)]
print('{:.3f} fall within 1 standard deviation. '
'{:.3f} within 2 and {:.3f} within 3'.format(*pcts))
>>> slb = pd.read_csv('data/slb_stock.csv', index_col='Date',
parse_dates=['Date'])
>>> test_return_normality(slb)
0.742 fall within 1 standard deviation. 0.946 within 2 and 0.986 within 3

另见
- Pandas 官方文档中的
pct_changeSeries 方法 (bit.ly/2wcjmqT)
使用 query 方法改进布尔索引的可读性
布尔索引不一定是最容易阅读或编写的语法,尤其是当使用一行代码写复杂过滤器时。Pandas 提供了一种替代的基于字符串的语法,通过 DataFrame query 方法,可以提供更清晰的表达方式。
query DataFrame 方法是实验性的,功能不如布尔索引强大,不应在生产代码中使用。
准备开始
这个食谱复制了本章之前的食谱,翻译 SQL WHERE 子句,但它改用了 query DataFrame 方法。目标是筛选出来自警察或消防部门的女性员工数据,这些员工的年薪在 8 万到 12 万美元之间。
如何操作……
- 读取员工数据,分配所选部门,并将列导入变量:
>>> employee = pd.read_csv('data/employee.csv')
>>> depts = ['Houston Police Department-HPD',
'Houston Fire Department (HFD)']
>>> select_columns = ['UNIQUE_ID', 'DEPARTMENT',
'GENDER', 'BASE_SALARY']
- 构建查询字符串并执行该方法:
>>> qs = "DEPARTMENT in @depts " \
"and GENDER == 'Female' " \
"and 80000 <= BASE_SALARY <= 120000"
>>> emp_filtered = employee.query(qs)
>>> emp_filtered[select_columns].head()

工作原理……
传递给 query 方法的字符串看起来更像是普通英语,而不是普通的 pandas 代码。可以使用 @ 符号引用 Python 变量,就像 depts 一样。所有的 DataFrame 列名都可以通过直接引用它们的名称来访问,无需内层引号。如果需要字符串,例如 Female,则需要使用内层引号。
query 语法的另一个优点是能够在单个表达式中编写双重不等式,并且它能够理解冗长的逻辑运算符 and、or 和 not,而不是像布尔索引那样使用按位运算符。
还有更多……
我们不需要手动输入部门名称列表,而是可以通过编程的方式创建它。例如,如果我们想找到所有不是前 10 个部门中频率最高的女性员工,可以运行以下代码:
>>> top10_depts = employee.DEPARTMENT.value_counts() \
.index[:10].tolist()
>>> qs = "DEPARTMENT not in @top10_depts and GENDER == 'Female'"
>>> employee_filtered2 = employee.query(qs)
>>> employee_filtered2.head()

另见
- Pandas 官方文档中的
query方法 (bit.ly/2vnlwXk)
使用where方法保留 Series
布尔索引必然会通过删除所有不符合标准的行来过滤数据集。与删除所有这些值不同,使用where方法可以保留它们。where方法保留 Series 或 DataFrame 的大小,并将不满足标准的值设为缺失值,或者用其他值替代。
准备就绪
在这个示例中,我们传递where方法的布尔条件,用于在movie数据集中设置演员 1 的 Facebook 点赞数的上下限。
如何操作...
- 读取
movie数据集,将电影标题设置为索引,并选择actor_1_facebook_likes列中所有非缺失的值:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
>>> fb_likes = movie['actor_1_facebook_likes'].dropna()
>>> fb_likes.head()
movie_title
Avatar 1000.0
Pirates of the Caribbean: At World's End 40000.0
Spectre 11000.0
The Dark Knight Rises 27000.0
Star Wars: Episode VII - The Force Awakens 131.0
Name: actor_1_facebook_likes, dtype: float64
- 让我们使用
describe方法来大致了解分布情况:
>>> fb_likes.describe(percentiles=[.1, .25, .5, .75, .9]) \
.astype(int)
count 4909
mean 6494
std 15106
min 0
10% 240
25% 607
50% 982
75% 11000
90% 18000
max 640000
Name: actor_1_facebook_likes, dtype: int64
- 此外,我们还可以绘制该 Series 的直方图来直观检查分布情况:
>>> fb_likes.hist()

- 这是一个相当糟糕的可视化图形,很难看出分布情况。另一方面,步骤 2 中的摘要统计量似乎告诉我们数据高度偏向右侧,且许多观测值比中位数大一个数量级。让我们创建标准来测试点赞数是否少于 20,000:
>>> criteria_high = fb_likes < 20000
>>> criteria_high.mean().round(2)
.91
- 大约 91%的电影的演员 1 点赞数少于 20,000。现在我们将使用
where方法,它接受布尔条件。默认行为是返回一个与原始 Series 大小相同的 Series,但将所有False位置替换为缺失值:
>>> fb_likes.where(criteria_high).head()
movie_title
Avatar 1000.0
Pirates of the Caribbean: At World's End NaN
Spectre 11000.0
The Dark Knight Rises NaN
Star Wars: Episode VII - The Force Awakens 131.0
Name: actor_1_facebook_likes, dtype: float64
where方法的第二个参数other允许你控制替换的值。让我们将所有缺失的值改为 20,000:
>>> fb_likes.where(criteria_high, other=20000).head()
movie_title
Avatar 1000.0
Pirates of the Caribbean: At World's End 20000.0
Spectre 11000.0
The Dark Knight Rises 20000.0
Star Wars: Episode VII - The Force Awakens 131.0
Name: actor_1_facebook_likes, dtype: float64
- 同样,我们可以创建标准来设定最小点赞数的下限。在这里,我们链式调用另一个
where方法,并将不满足条件的值替换为300:
>>> criteria_low = fb_likes > 300
>>> fb_likes_cap = fb_likes.where(criteria_high, other=20000)\
.where(criteria_low, 300)
>>> fb_likes_cap.head()
movie_title
Avatar 1000.0
Pirates of the Caribbean: At World's End 20000.0
Spectre 11000.0
The Dark Knight Rises 20000.0
Star Wars: Episode VII - The Force Awakens 300.0
Name: actor_1_facebook_likes, dtype: float64
- 原始 Series 和修改后的 Series 的长度相同:
>>> len(fb_likes), len(fb_likes_cap)
(4909, 4909)
- 让我们用修改后的 Series 制作一个直方图。由于数据的范围更加紧凑,它应该能生成一个更好的图形:
>>> fb_likes_cap.hist()

它是如何工作的...
where方法再次保留调用对象的大小和形状,并且在传入的布尔值为True时不会修改值。在步骤 1 中删除缺失值是很重要的,因为where方法最终会在后续步骤中用有效数字替换它们。
步骤 2 中的摘要统计给了我们一些直觉,表明在哪些情况下设置数据上限是有意义的。另一方面,步骤 3 中的直方图似乎将所有数据集中到一个箱子里。数据中有太多离群值,单纯的直方图很难生成好的图形。where方法允许我们为数据设置上限和下限,从而生成一个具有更多可见条形的直方图。
还有更多...
Pandas 实际上有内建的方法 clip、clip_lower 和 clip_upper,可以实现这一操作。clip 方法可以同时设置下限和上限。我们还检查了这个替代方法是否产生完全相同的 Series,结果是一样的:
>>> fb_likes_cap2 = fb_likes.clip(lower=300, upper=20000)
>>> fb_likes_cap2.equals(fb_likes_cap)
True
另见
- Pandas 官方文档关于
where方法的介绍 (bit.ly/2vmW2cv)
遮罩 DataFrame 行
mask 方法执行的操作正好是 where 方法的相反操作。默认情况下,它会在布尔条件为 True 的地方创建缺失值。本质上,它就是在你的数据集中遮罩或覆盖掉某些值。
准备工作
在这个案例中,我们将遮罩所有 2010 年之后制作的电影数据行,然后筛选出所有包含缺失值的行。
如何操作……
- 读取
movie数据集,将电影标题设为索引,并创建筛选条件:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
>>> c1 = movie['title_year'] >= 2010
>>> c2 = movie['title_year'].isnull()
>>> criteria = c1 | c2
- 使用
mask方法在 DataFrame 中将所有 2010 年以后制作的电影行中的值设为缺失值。任何原本title_year列为缺失值的电影也会被遮罩:
>>> movie.mask(criteria).head()

- 注意到前面 DataFrame 中第三、第四和第五行的所有值都缺失了。链式调用
dropna方法删除所有值都缺失的行:
>>> movie_mask = movie.mask(criteria).dropna(how='all')
>>> movie_mask.head()

- 第 3 步的操作其实就是做一个复杂的布尔索引。我们可以检查这两种方法是否产生相同的 DataFrame:
>>> movie_boolean = movie[movie['title_year'] < 2010]
>>> movie_mask.equals(movie_boolean)
False
equals方法告诉我们它们不相等。有些地方出错了。让我们做一些合理性检查,看看它们的形状是否相同:
>>> movie_mask.shape == movie_boolean.shape
True
- 当我们使用前面的
mask方法时,它创建了很多缺失值。缺失值的类型是float,因此任何原本是整数的列现在变成了浮动类型。equals方法会返回False,如果列的数据类型不同,即使它们的值是相同的。让我们检查数据类型的相等性,看看这种情况是否发生:
>>> movie_mask.dtypes == movie_boolean.dtypes
color True
director_name True
num_critic_for_reviews True
duration True
director_facebook_likes True
actor_3_facebook_likes True
actor_2_name True
actor_1_facebook_likes True
gross True
genres True
actor_1_name True
num_voted_users False
cast_total_facebook_likes False
.....
dtype: bool
- 结果发现有几列的类型不相同。Pandas 为这些情况提供了一个替代方案。在其测试模块中,主要供开发者使用,有一个函数
assert_frame_equal,允许你在不检查数据类型是否相等的情况下,检查 Series 和 DataFrame 的内容是否相等:
from pandas.testing import assert_frame_equal
>>> assert_frame_equal(movie_boolean, movie_mask, check_dtype=False)
它是如何工作的……
默认情况下,mask 方法会将缺失值覆盖掉。mask 方法的第一个参数是条件,通常是布尔类型的 Series,例如 criteria。由于 mask 方法是从 DataFrame 调用的,所以在条件为 False 的行中,所有的值都会变为缺失值。第 3 步使用这个被遮罩的 DataFrame 来删除包含所有缺失值的行。第 4 步演示了如何使用布尔索引执行相同的操作。
在数据分析过程中,不断验证结果非常重要。检查 Series 和 DataFrame 是否相等是非常常见的验证方法。我们在第 4 步中的第一次尝试得到了意外的结果。在深入分析之前,一些基本的合理性检查,比如确保行和列的数量相同,或者行列名称相同,是很好的检查方式。
第 6 步比较了两组数据类型的 Series。正是在这里,我们揭示了 DataFrames 不相等的原因。equals 方法检查值和数据类型是否相同。第 7 步中的 assert_frame_equal 函数有许多可用的参数,用于以多种方式测试相等性。请注意,调用 assert_frame_equal 后没有输出。如果两个传递的 DataFrames 相等,该方法返回 None;如果不相等,则抛出错误。
还有更多...
让我们比较掩码和删除缺失行与布尔索引之间的速度差异。在这种情况下,布尔索引速度快了一个数量级:
>>> %timeit movie.mask(criteria).dropna(how='all')
11.2 ms ± 144 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
>>> %timeit movie[movie['title_year'] < 2010]
1.07 ms ± 34.9 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
另见
-
Pandas 官方文档关于
assert_frame_equal的说明 (bit.ly/2u5H5Yl) -
Python 官方文档关于
assert语句的说明 (bit.ly/2v1YKmY)
使用布尔值、整数位置和标签进行选择
第十章,选择数据的子集,涵盖了通过 .iloc 和 .loc 索引器选择不同数据子集的多种方法。这两种索引器可以通过整数位置或标签同时选择行和列。这两种索引器还可以通过布尔索引进行数据选择,尽管布尔值既不是整数,也不是标签。
准备工作
在本例中,我们将通过布尔索引同时过滤 .iloc 和 .loc 索引器的行和列。
如何操作...
- 读取电影数据集,设置标题为索引,然后创建一个布尔 Series,匹配所有内容评级为
G且 IMDB 评分小于4的电影:
>>> movie = pd.read_csv('data/movie.csv', index_col='movie_title')
>>> c1 = movie['content_rating'] == 'G'
>>> c2 = movie['imdb_score'] < 4
>>> criteria = c1 & c2
- 我们首先将这些标准传递给
.loc索引器以过滤行:
>>> movie_loc = movie.loc[criteria]
>>> movie_loc.head()

- 让我们检查一下这个 DataFrame 是否与直接通过索引操作符生成的 DataFrame 完全相等:
>>> movie_loc.equals(movie[criteria])
True
- 现在让我们尝试使用
.iloc索引器进行相同的布尔索引:
>>> movie_iloc = movie.iloc[criteria]
ValueError: iLocation based boolean indexing cannot use an indexable as a mask
- 事实证明,我们不能直接使用布尔值的 Series,因为存在索引问题。然而,我们可以使用布尔值的 ndarray。要提取该数组,可以使用
values属性:
>>> movie_iloc = movie.iloc[criteria.values]
>>> movie_iloc.equals(movie_loc)
True
- 虽然不太常见,但可以使用布尔索引选择特定的列。在这里,我们选择所有数据类型为 64 位整数的列:
>>> criteria_col = movie.dtypes == np.int64
>>> criteria_col.head()
color False
director_name False
num_critic_for_reviews False
duration False
director_facebook_likes False
dtype: bool
>>> movie.loc[:, criteria_col].head()

- 由于
criteria_col是一个 Series,它总是有一个索引,因此你必须使用底层的 ndarray 才能与.iloc一起使用。以下操作产生与第 6 步相同的结果。
>>> movie.iloc[:, criteria_col.values].head()
- 可以使用布尔序列来选择行,并同时使用整数或标签选择列。记住,行和列选择之间需要用逗号隔开。让我们保持行标准,选择
content_rating、imdb_score、title_year和gross:
>>> cols = ['content_rating', 'imdb_score', 'title_year', 'gross']
>>> movie.loc[criteria, cols].sort_values('imdb_score')

- 可以通过
.iloc重复相同的操作,但你需要获取所有列的整数位置:
>>> col_index = [movie.columns.get_loc(col) for col in cols]
>>> col_index
[20, 24, 22, 8]
>>> movie.iloc[criteria.values, col_index]
它是如何工作的…
布尔索引可以通过.iloc和.loc索引器实现,但有一个注意点:.iloc不能传递 Series,而必须传递底层的 ndarray。让我们来看一下criteria序列的底层一维 ndarray:
>>> a = criteria.values
>>> a[:5]
array([False, False, False, False, False], dtype=bool)
>>> len(a), len(criteria)
(4916, 4916)
数组的长度与 Series 的长度相同,而 Series 的长度与电影 DataFrame 的长度相同。布尔数组的整数位置与 DataFrame 的整数位置对齐,过滤过程按预期发生。这些数组也可以与.loc操作符一起使用,但它们对于.iloc是必需的。
第 6 步和第 7 步展示了如何按列过滤,而不是按行过滤。冒号:表示选择所有行,冒号后的逗号分隔了行和列的选择。其实,还有一种更简单的方法可以选择具有整数数据类型的列,那就是通过select_dtypes方法。
第 8 步和第 9 步展示了一种非常常见且有用的方法,可以同时对行和列进行布尔索引。你只需在行和列选择之间放置一个逗号。第 9 步使用列表推导式遍历所有所需的列名,通过get_loc方法找到它们的整数位置。
还有更多…
实际上,可以将布尔数组和列表传递给 Series 对象,这些数组和列表的长度与正在进行索引操作的 DataFrame 不相等。我们通过选择第一行和第三行,以及第一列和第四列来看看这个例子:
>>> movie.loc[[True, False, True], [True, False, False, True]]

两个布尔列表的长度与它们索引的轴的长度不同。列表中没有明确给出布尔值的其余行和列会被丢弃。
另见
- 请参考第十章的选择具有整数和标签的数据方法,选择数据的子集
第十二章:索引对齐
当多个 Series 或 DataFrame 以某种方式组合时,数据的每个维度都会在每个轴上自动对齐,然后才会进行任何计算。对于未熟悉者来说,这种默默进行的自动对齐可能会引起极大的困惑,但它为高级用户提供了巨大的灵活性。本章深入探讨了索引对象,并展示了一系列利用其自动对齐功能的示例。
本章将涵盖以下主题:
-
检查索引对象
-
生成笛卡尔积
-
扩展索引
-
填充具有不等索引的值
-
从不同的 DataFrame 中追加列
-
突出显示每列的最大值
-
使用方法链复制
idxmax -
查找最常见的最大值
检查索引对象
Series 和 DataFrame 的每个轴都有一个 Index 对象,用于标记值。索引对象有许多不同类型,但它们都具有相同的公共行为。除特殊的 MultiIndex 外,所有 Index 对象都是一维数据结构,结合了 Python 集合和 NumPy ndarray 的功能和实现。
准备好
在这个示例中,我们将检查大学数据集的列索引,并探讨其许多功能。
如何实现...
- 读取大学数据集,将列索引分配给一个变量,并输出它:
>>> college = pd.read_csv('data/college.csv')
>>> columns = college.columns
>>> columns
Index(['INSTNM', 'CITY', 'STABBR', 'HBCU', ...], dtype='object')
- 使用
values属性访问底层的 NumPy 数组:
>>> columns.values
array(['INSTNM', 'CITY', 'STABBR', 'HBCU', ...], dtype=object)
- 通过标量、列表或切片根据整数位置选择索引中的项:
>>> columns[5]
'WOMENONLY'
>>> columns[[1,8,10]]
Index(['CITY', 'SATMTMID', 'UGDS'], dtype='object')
>>> columns[-7:-4]
Index(['PPTUG_EF', 'CURROPER', 'PCTPELL'], dtype='object')
- 索引与 Series 和 DataFrame 共享许多相同的方法:
>>> columns.min(), columns.max(), columns.isnull().sum()
('CITY', 'WOMENONLY', 0)
- 直接在
Index对象上使用基本的算术和比较操作符:
>>> columns + '_A'
Index(['INSTNM_A', 'CITY_A', 'STABBR_A', 'HBCU_A', ...], dtype='object')
>>> columns > 'G'
array([ True, False, True, True, ...], dtype=bool)
- 尝试在创建后直接更改索引值会失败。索引是不可变对象:
>>> columns[1] = 'city'
TypeError: Index does not support mutable operations
它是如何工作的...
正如你从许多索引对象操作中看到的,它似乎与 Series 和ndarrays有很多相似之处。最大的区别之一出现在第 6 步。索引是不可变的,一旦创建,其值不能更改。
还有更多...
索引支持集合操作:并集、交集、差集和对称差集:
>>> c1 = columns[:4]
>>> c1
Index(['INSTNM', 'CITY', 'STABBR', 'HBCU'], dtype='object')
>>> c2 = columns[2:6]
>>> c2
Index(['STABBR', 'HBCU', 'MENONLY'], dtype='object')
>>> c1.union(c2) # or `c1 | c2`
Index(['CITY', 'HBCU', 'INSTNM', 'MENONLY', 'RELAFFIL', 'STABBR'], dtype='object')
>>> c1.symmetric_difference(c2) # or `c1 ^ c2`
Index(['CITY', 'INSTNM', 'MENONLY'], dtype='object')
索引与 Python 集合共享一些相同的操作。索引在另一个重要方面与 Python 集合相似。它们(通常)使用哈希表实现,这使得从 DataFrame 中选择行或列时具有极快的访问速度。由于它们是使用哈希表实现的,索引对象的值需要是不可变的,如字符串、整数或元组,就像 Python 字典中的键一样。
索引支持重复值,如果某个索引中有重复项,那么哈希表将无法再使用
用于其实现,且对象访问速度变得更慢。
参见
- Pandas 官方文档中的
Index(bit.ly/2upfgtr)
生成笛卡尔积
每当两个 Series 或 DataFrame 与另一个 Series 或 DataFrame 进行操作时,每个对象的索引(包括行索引和列索引)首先会进行对齐,才会开始任何操作。这个索引对齐是默默发生的,对于新接触 pandas 的人来说可能会感到很惊讶。除非索引完全相同,否则这种对齐总是会在索引之间创建一个笛卡尔积。
笛卡尔积是一个数学术语,通常出现在集合论中。两个集合的笛卡尔积是两个集合中所有可能的元素对的组合。例如,标准扑克牌中的 52 张牌表示的是 13 个点数(A,2,3,...,Q,K)与四种花色之间的笛卡尔积。
准备工作
产生笛卡尔积并不总是预期的结果,但了解它是如何发生的以及何时发生,能帮助避免不期望的后果。在这个示例中,两个具有重叠但不同索引的 Series 被相加,产生了一个令人惊讶的结果。
如何实现...
按照以下步骤创建一个笛卡尔积:
- 构造两个具有不同索引但包含一些相同值的 Series:
>>> s1 = pd.Series(index=list('aaab'), data=np.arange(4))
>>> s1
a 0
a 1
a 2
b 3
dtype: int64
>>> s2 = pd.Series(index=list('cababb'), data=np.arange(6))
>>> s2
c 0
a 1
b 2
a 3
b 4
b 5
dtype: int64
- 将这两个 Series 相加,产生一个笛卡尔积:
>>> s1 + s2
a 1.0
a 3.0
a 2.0
a 4.0
a 3.0
a 5.0
b 5.0
b 7.0
b 8.0
c NaN
dtype: float64
它是如何工作的...
每个 Series 都是通过类构造函数创建的,该构造函数接受各种各样的输入,其中最简单的输入是每个参数 index 和 data 的值序列。
数学上的笛卡尔积与对两个 pandas 对象进行操作的结果略有不同。在 s1 中的每个 a 标签都会与 s2 中的每个 a 标签配对。这种配对在结果的 Series 中产生了六个 a 标签,三个 b 标签和一个 c 标签。笛卡尔积发生在所有相同的索引标签之间。
由于标签 c 仅存在于 Series s2 中,pandas 默认将其值设为缺失,因为在 s1 中没有与之对齐的标签。每当一个索引标签只属于一个对象时,pandas 会将其默认设置为缺失值。遗憾的是,这导致 Series 的数据类型从整数转换为浮点数,因为 NumPy 的缺失值对象 np.nan 只适用于浮点数,而不适用于整数。Series 和 DataFrame 列必须具有同质的数值数据类型,因此每个值都被转换为浮点数。对于这个小数据集来说差异不大,但对于较大的数据集,这可能会带来显著的内存影响。
还有更多...
上述示例的例外情况发生在索引包含完全相同的元素并且顺序相同的情况下。当发生这种情况时,不会进行笛卡尔积,索引会根据它们的位置对齐。在这里需要注意的是,每个元素都根据位置精确对齐,并且数据类型保持为整数:
>>> s1 = pd.Series(index=list('aaabb'), data=np.arange(5))
>>> s2 = pd.Series(index=list('aaabb'), data=np.arange(5))
>>> s1 + s2
a 0
a 2
a 4
b 6
b 8
dtype: int64
如果索引的元素相同,但顺序在两个 Series 之间不同,则会发生笛卡尔积。让我们改变 s2 中索引的顺序,并重新运行相同的操作:
>>> s1 = pd.Series(index=list('aaabb'), data=np.arange(5))
>>> s2 = pd.Series(index=list('bbaaa'), data=np.arange(5))
>>> s1 + s2
a 2
a 3
a 4
a 3
a 4
a 5
a 4
a 5
a 6
b 3
b 4
b 4
b 5
dtype: int64
很有趣的是,pandas 对相同操作有两个截然不同的结果。如果笛卡尔积是 pandas 的唯一选择,那么像将 DataFrame 列相加这样简单的操作也会导致返回元素数量的爆炸。
在这个示例中,每个系列的元素数量不同。通常,在 Python 和其他语言中,当操作的维度包含不同数量的元素时,不允许进行操作。但 Pandas 通过首先对齐索引,然后再执行操作,允许这种情况发生。
爆炸索引
之前的示例通过两个小系列的加法操作演示了索引不相等时的情况。当处理更大数据时,这个问题可能会产生荒谬的错误结果。
准备就绪
在这个示例中,我们添加了两个较大的系列,它们的索引有少量独特的值,但顺序不同。结果会导致索引中的值数爆炸。
如何做...
- 读取员工数据并将索引设置为种族列:
>>> employee = pd.read_csv('data/employee.csv', index_col='RACE')
>>> employee.head()

- 选择
BASE_SALARY列作为两个不同的系列。检查此操作是否确实创建了两个新对象:
>>> salary1 = employee['BASE_SALARY']
>>> salary2 = employee['BASE_SALARY']
>>> salary1 is salary2
True
salary1和salary2变量实际上引用的是同一个对象。这意味着对其中一个的任何修改都会影响另一个。为了确保获得数据的全新副本,使用copy方法:
>>> salary1 = employee['BASE_SALARY'].copy()
>>> salary2 = employee['BASE_SALARY'].copy()
>>> salary1 is salary2
False
- 让我们通过对其中一个系列进行排序来改变其索引的顺序:
>>> salary1 = salary1.sort_index()
>>> salary1.head()
RACE
American Indian or Alaskan Native 78355.0
American Indian or Alaskan Native 81239.0
American Indian or Alaskan Native 60347.0
American Indian or Alaskan Native 68299.0
American Indian or Alaskan Native 26125.0
Name: BASE_SALARY, dtype: float64
>>> salary2.head()
RACE
Hispanic/Latino 121862.0
Hispanic/Latino 26125.0
White 45279.0
White 63166.0
White 56347.0
Name: BASE_SALARY, dtype: float64
- 让我们将这两个
salary系列加在一起:
>>> salary_add = salary1 + salary2
>>> salary_add.head()
RACE
American Indian or Alaskan Native 138702.0
American Indian or Alaskan Native 156710.0
American Indian or Alaskan Native 176891.0
American Indian or Alaskan Native 159594.0
American Indian or Alaskan Native 127734.0
Name: BASE_SALARY, dtype: float64
- 操作成功完成。让我们再创建一个
salary1系列,并将其加到自身,然后输出每个系列的长度。我们将索引从 2,000 个值爆炸到超过 100 万个值:
>>> salary_add1 = salary1 + salary1
>>> len(salary1), len(salary2), len(salary_add), len(salary_add1)
(2000, 2000, 1175424, 2000)
它是如何工作的...
第 2 步乍一看好像创建了两个独特的对象,但实际上它创建了一个单独的对象,通过两个不同的变量名引用。表达式employee['BASE_SALARY']技术上创建了一个视图,而不是一个全新的副本。通过is运算符可以验证这一点。
在 pandas 中,视图不是新对象,而只是另一个对象的引用,通常是 DataFrame 的某个子集。这个共享对象可能会引发许多问题。
为了确保两个变量引用完全不同的对象,我们使用copy系列方法,并再次通过is运算符验证它们是不同的对象。第 4 步使用sort_index方法按照种族排序系列。第 5 步将这些不同的系列加在一起,产生某些结果。仅通过查看头部,仍然不清楚已产生了什么。
第 6 步将salary1与自身相加,展示两种不同 Series 相加的比较。所有 Series 的长度都已输出,我们清楚地看到series_add的值已经爆炸性地增长到超过一百万个值。由于索引不完全相同,每个唯一的索引值之间进行了笛卡尔积。这个例子极大地展示了在合并多个 Series 或 DataFrame 时,索引的影响有多大。
还有更多内容...
我们可以通过一点数学计算来验证salary_add的值的数量。由于所有相同索引值之间进行了笛卡尔积,我们可以将它们各自的计数的平方相加。即使是缺失的索引值,也会与自己进行笛卡尔积:
>>> index_vc = salary1.index.value_counts(dropna=False)
>>> index_vc
Black or African American 700
White 665
Hispanic/Latino 480
Asian/Pacific Islander 107
NaN 35
American Indian or Alaskan Native 11
Others 2
Name: RACE, dtype: int64
>>> index_vc.pow(2).sum()
1175424
使用不等索引填充值
当使用加法运算符将两个 Series 相加时,如果其中一个索引标签在另一个 Series 中不存在,结果值总是缺失的。Pandas 提供了add方法,可以选择填充缺失值。
准备工作
在这个例子中,我们使用add方法的fill_value参数将来自baseball数据集的多个 Series 相加,确保结果中没有缺失值,即使这些 Series 的索引不相等。
如何操作...
- 读取三个
baseball数据集,并将索引设置为playerID:
>>> baseball_14 = pd.read_csv('data/baseball14.csv',
index_col='playerID')
>>> baseball_15 = pd.read_csv('data/baseball15.csv',
index_col='playerID')
>>> baseball_16 = pd.read_csv('data/baseball16.csv',
index_col='playerID')
>>> baseball_14.head()

- 使用
difference索引方法来查找哪些索引标签出现在baseball_14中但不在baseball_15中,反之亦然:
>>> baseball_14.index.difference(baseball_15.index)
Index(['corpoca01', 'dominma01', 'fowlede01', 'grossro01',
'guzmaje01', 'hoeslj01', 'krausma01', 'preslal01',
'singljo02'], dtype='object', name='playerID')
>>> baseball_14.index.difference(baseball_16.index)
Index(['congeha01', 'correca01', 'gattiev01', 'gomezca01',
'lowrije01', 'rasmuco01', 'tuckepr01', 'valbulu01'],
dtype='object', name='playerID')
- 每个索引中都有相当多的独特球员。让我们找出每个球员在三年期间的总击球数。
H列包含击球次数:
>>> hits_14 = baseball_14['H']
>>> hits_15 = baseball_15['H']
>>> hits_16 = baseball_16['H']
>>> hits_14.head()
playerID
altuvjo01 225
cartech02 115
castrja01 103
corpoca01 40
dominma01 121
Name: H, dtype: int64
- 首先使用加法运算符将两个 Series 相加:
>>> (hits_14 + hits_15).head()
playerID
altuvjo01 425.0
cartech02 193.0
castrja01 174.0
congeha01 NaN
corpoca01 NaN
Name: H, dtype: float64
- 即使球员
congeha01和corpoca01在 2015 年有击球记录,他们的结果仍然是缺失的。让我们使用add方法及其参数fill_value来避免缺失值:
>>> hits_14.add(hits_15, fill_value=0).head()
playerID
altuvjo01 425.0
cartech02 193.0
castrja01 174.0
congeha01 46.0
corpoca01 40.0
Name: H, dtype: float64
- 我们通过再次链式调用
add方法将 2016 年的击球数据加到一起:
>>> hits_total = hits_14.add(hits_15, fill_value=0) \
.add(hits_16, fill_value=0)
>>> hits_total.head()
playerID
altuvjo01 641.0
bregmal01 53.0
cartech02 193.0
castrja01 243.0
congeha01 46.0
Name: H, dtype: float64
- 检查结果中是否存在缺失值:
>>> hits_total.hasnans
False
它是如何工作的...
add方法与加法运算符类似,但通过提供fill_value参数来代替不匹配的索引,从而提供更多灵活性。在这个问题中,将不匹配的索引值默认设置为 0 是有意义的,但你也可以使用其他任意数字。
有时候,每个 Series 中包含的索引标签对应着缺失值。在这个特定的例子中,当两个 Series 相加时,无论是否使用了fill_value参数,索引标签依然会对应缺失值。为了说明这一点,看看下面的例子,其中索引标签a在每个 Series 中都对应着缺失值:
>>> s = pd.Series(index=['a', 'b', 'c', 'd'],
data=[np.nan, 3, np.nan, 1])
>>> s
a NaN
b 3.0
c NaN
d 1.0
dtype: float64
>>> s1 = pd.Series(index=['a', 'b', 'c'], data=[np.nan, 6, 10])
>>> s1
a NaN
b 6.0
c 10.0
dtype: float64
>>> s.add(s1, fill_value=5)
a NaN
b 9.0
c 15.0
d 6.0
dtype: float64
还有更多内容...
本例展示了如何仅使用单一索引将多个 Series 相加。将 DataFrame 相加也是完全可行的。将 DataFrame 相加时,会先对齐索引和列,然后进行计算,并且对于不匹配的索引会生成缺失值。让我们从 2014 年的棒球数据集中选择几个列开始。
>>> df_14 = baseball_14[['G','AB', 'R', 'H']]
>>> df_14.head()

让我们从 2015 年的棒球数据集中也选择一些相同的列和一些不同的列:
>>> df_15 = baseball_15[['AB', 'R', 'H', 'HR']]
>>> df_15.head()

将两个 DataFrame 相加时,当行或列标签无法对齐时,会创建缺失值。使用 style 属性访问 highlight_null 方法,可以轻松查看缺失值的位置:
>>> (df_14 + df_15).head(10).style.highlight_null('yellow')

只有在两个 DataFrame 中都出现的 playerID 行才不会是缺失的。同样,AB、H 和 R 列是两个 DataFrame 中唯一出现的列。即使我们使用 add 方法并指定了 fill_value 参数,我们仍然会有缺失值。这是因为某些行和列的组合在输入数据中从未存在过。例如,playerID congeha01 与列 G 的交集。他只出现在没有 G 列的 2015 数据集中。因此,没有为其填充值:
>>> df_14.add(df_15, fill_value=0).head(10) \
.style.highlight_null('yellow')

从不同 DataFrame 中追加列
所有 DataFrame 都可以向自己添加新列。然而,像往常一样,每当一个 DataFrame 从另一个 DataFrame 或 Series 添加新列时,索引会先对齐,然后才会创建新列。
准备工作
本例使用 employee 数据集,在其中添加一个包含该员工所在部门最大薪水的新列。
如何操作...
- 导入
employee数据并在一个新 DataFrame 中选择DEPARTMENT和BASE_SALARY列:
>>> employee = pd.read_csv('data/employee.csv')
>>> dept_sal = employee[['DEPARTMENT', 'BASE_SALARY']]
- 按照每个部门的薪水对这个较小的 DataFrame 进行排序:
>>> dept_sal = dept_sal.sort_values(['DEPARTMENT', 'BASE_SALARY'],
ascending=[True, False])
- 使用
drop_duplicates方法保留每个DEPARTMENT的第一行:
>>> max_dept_sal = dept_sal.drop_duplicates(subset='DEPARTMENT')
>>> max_dept_sal.head()

- 将
DEPARTMENT列放入每个 DataFrame 的索引中:
>>> max_dept_sal = max_dept_sal.set_index('DEPARTMENT')
>>> employee = employee.set_index('DEPARTMENT')
- 现在索引已经包含匹配的值,我们可以向
employeeDataFrame 添加一个新列:
>>> employee['MAX_DEPT_SALARY'] = max_dept_sal['BASE_SALARY']
>>> employee.head()

- 我们可以使用
query方法验证结果,检查是否存在BASE_SALARY大于MAX_DEPT_SALARY的行:
>>> employee.query('BASE_SALARY > MAX_DEPT_SALARY')

它是如何工作的...
第 2 步和第 3 步找到每个部门的最大薪水。为了使自动索引对齐正常工作,我们将每个 DataFrame 的索引设置为部门。第 5 步之所以有效,是因为左侧 DataFrame employee 的每一行索引与右侧 DataFrame max_dept_sal 中的唯一一个索引对齐。如果 max_dept_sal 的索引中有重复的部门,则该操作将失败。
比如,假设我们在等号右侧使用一个有重复索引值的 DataFrame,会发生什么情况。我们使用 sample DataFrame 方法随机选择十行,且不进行替换:
>>> np.random.seed(1234)
>>> random_salary = dept_sal.sample(n=10).set_index('DEPARTMENT')
>>> random_salary

注意索引中有多个重复的部门。当我们尝试创建一个新列时,系统会提示一个错误,告知我们存在重复项。employee DataFrame 中至少有一个索引标签与 random_salary 中的两个或多个索引标签发生了连接:
>>> employee['RANDOM_SALARY'] = random_salary['BASE_SALARY']
ValueError: cannot reindex from a duplicate axis
还有更多……
不是所有等号左侧的索引都需要匹配,但最多只能有一个匹配项。如果左侧的 DataFrame 索引没有对应的对齐项,结果值将会缺失。我们来创建一个示例,展示这种情况的发生。我们将只使用 max_dept_sal Series 的前三行来创建一个新列:
>>> employee['MAX_SALARY2'] = max_dept_sal['BASE_SALARY'].head(3)
>>> employee.MAX_SALARY2.value_counts()
140416.0 29
100000.0 11
64251.0 5
Name: MAX_SALARY2, dtype: int64
>>> employee.MAX_SALARY2.isnull().mean()
.9775
操作成功完成,但只填充了三个部门的薪资数据。其他没有出现在 max_dept_sal Series 前三行中的部门,结果显示为缺失值。
突出显示每一列中的最大值
college 数据集包含许多数值型列,用于描述每所学校的不同指标。很多人对在某些指标上表现最佳的学校感兴趣。
准备工作
这个方案会发现每个数值型列的最大值,并对 DataFrame 进行样式设置,以突出显示信息,方便用户查看。
如何做……
- 使用机构名称作为索引来读取 college 数据集:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college.dtypes
CITY object
STABBR object
HBCU float64
MENONLY float64
...
PCTFLOAN float64
UG25ABV float64
MD_EARN_WNE_P10 object
GRAD_DEBT_MDN_SUPP object
Length: 26, dtype: object
- 除了
CITY和STABBR列外,所有其他列似乎都是数值型的。从前一步检查数据类型时,意外发现MD_EARN_WNE_P10和GRAD_DEBT_MDN_SUPP列的数据类型为对象,而非数值型。为了更好地了解这些列的值是什么,让我们检查它们的第一个值:
>>> college.MD_EARN_WNE_P10.iloc[0]
'30300'
>>> college.GRAD_DEBT_MDN_SUPP.iloc[0]
'33888'
- 这些值是字符串格式,但我们希望它们是数值型的。这意味着 Series 中可能会出现其他非数值字符。检查的一个方法是将这些列按降序排序,并检查前几行:
>>> college.MD_EARN_WNE_P10.sort_values(ascending=False).head()
INSTNM
Sharon Regional Health System School of Nursing PrivacySuppressed
Northcoast Medical Training Academy PrivacySuppressed
Success Schools PrivacySuppressed
Louisiana Culinary Institute PrivacySuppressed
Bais Medrash Toras Chesed PrivacySuppressed
Name: MD_EARN_WNE_P10, dtype: object
- 问题似乎出在一些学校对于这两列数据存在隐私问题。为了强制将这些列转换为数值型,可以使用 pandas 的
to_numeric函数:
>>> cols = ['MD_EARN_WNE_P10', 'GRAD_DEBT_MDN_SUPP']
>>> for col in cols:
college[col] = pd.to_numeric(college[col], errors='coerce')
>>> college.dtypes.loc[cols]
MD_EARN_WNE_P10 float64
GRAD_DEBT_MDN_SUPP float64
dtype: object
- 使用
select_dtypes方法筛选出只有数值型的列。这样可以排除STABBR和CITY列,因为在这个问题中,这些列没有最大值的意义:
>>> college_n = college.select_dtypes(include=[np.number])
>>> college_n.head()

- 利用数据字典,有几列仅包含二进制(0/1)值,这些列提供不了有用信息。为了程序化地找到这些列,我们可以创建布尔 Series,并通过
nunique方法查找所有具有两个唯一值的列:
>>> criteria = college_n.nunique() == 2
>>> criteria.head()
HBCU True
MENONLY True
WOMENONLY True
RELAFFIL True
SATVRMID False
dtype: bool
- 将这个布尔系列传递给列索引对象的索引运算符,并创建一个二进制列的列表:
>>> binary_cols = college_n.columns[criteria].tolist()
>>> binary_cols
['HBCU', 'MENONLY', 'WOMENONLY', 'RELAFFIL', 'DISTANCEONLY', 'CURROPER']
- 使用
drop方法移除二进制列:
>>> college_n2 = college_n.drop(labels=binary_cols, axis='columns')
>>> college_n2.head()

- 使用
idxmax方法查找每列最大值的索引标签:
>>> max_cols = college_n2.idxmax()
>>> max_cols
SATVRMID California Institute of Technology
SATMTMID California Institute of Technology
UGDS University of Phoenix-Arizona
UGDS_WHITE Mr Leon's School of Hair Design-Moscow
...
PCTFLOAN ABC Beauty College Inc
UG25ABV Dongguk University-Los Angeles
MD_EARN_WNE_P10 Medical College of Wisconsin
GRAD_DEBT_MDN_SUPP Southwest University of Visual Arts-Tucson
Length: 18, dtype: object
- 对
max_cols系列调用unique方法。这将返回一个包含唯一列名的ndarray:
>>> unique_max_cols = max_cols.unique()
>>> unique_max_cols[:5]
array(['California Institute of Technology',
'University of Phoenix-Arizona',
"Mr Leon's School of Hair Design-Moscow",
'Velvatex College of Beauty Culture',
'Thunderbird School of Global Management'], dtype=object)
- 使用
max_cols的值来选择只有最大值的学校的行,然后使用style属性突出显示这些值:
>>> college_n2.loc[unique_max_cols].style.highlight_max()

它是如何工作的...
idxmax方法非常强大,当索引有意义的标签时,它变得非常有用。没想到MD_EARN_WNE_P10和GRAD_DEBT_MDN_SUPP都是object数据类型。在导入时,如果列中至少包含一个字符串,Pandas 会将所有数字值强制转换为字符串。
通过检查第 2 步中的特定列值,我们清楚地看到这些列中包含了字符串。在第 3 步中,我们按降序排序,因为数字字符会首先出现。这将所有字母值提升到 Series 的顶部。我们发现了导致问题的PrivacySuppressed字符串。Pandas 能够使用to_numeric函数强制将仅包含数字字符的所有字符串转换为实际的数字数据类型。为了覆盖to_numeric在遇到无法转换的字符串时抛出错误的默认行为,必须将coerce传递给errors参数。这会强制所有非数字字符的字符串变为缺失值(np.nan)。
有几个列没有有用或有意义的最大值。它们在第 4 步到第 6 步中被移除。select_dtypes对于包含许多列的宽数据框非常有用。
在第 7 步中,idxmax遍历所有列,查找每列的最大值索引。它将结果输出为一个 Series。既有最高的 SAT 数学成绩,也有最高的 SAT 语文成绩的学校是加利福尼亚理工学院。东国大学洛杉矶校区的学生人数超过 25 岁的是最多的。
尽管idxmax提供的信息很有用,但它并没有给出相应的最大值。为此,我们从max_cols系列中的值中收集所有唯一的学校名称。
最后,在第 8 步中,我们使用.loc索引器根据索引标签选择行,索引标签在第一步中作为学校名称创建。这只筛选出具有最大值的学校。数据框有一个实验性的style属性,它本身有一些方法可以改变显示数据框的外观。突出显示最大值使结果更加清晰。
还有更多...
默认情况下,highlight_max方法会高亮显示每列的最大值。我们可以使用axis参数来高亮显示每行的最大值。在这里,我们只选择college数据集中的种族百分比列,并突出显示每个学校的最高百分比种族:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college_ugds = college.filter(like='UGDS_').head()
>>> college_ugds.style.highlight_max(axis='columns')

尝试在大型 DataFrame 上应用样式可能会导致 Jupyter 崩溃,这就是为什么样式只应用于 DataFrame 的头部。
参见
- Pandas 官方文档关于 DataFrame 样式的说明(
bit.ly/2hsZkVK)
使用方法链模拟idxmax
尝试自己实现一个内置的 DataFrame 方法是一个很好的练习。这种类型的模仿可以帮助你更深入地理解一些通常不会接触到的其他 pandas 方法。idxmax是一个挑战性很大的方法,仅使用本书中讲解过的方法来实现它比较困难。
准备工作
这个方法逐步链式调用基本方法,最终找到所有包含最大列值的行索引值。
如何实现……
- 加载
college数据集,并执行与前一个方法相同的操作,以获取我们感兴趣的数值列:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> cols = ['MD_EARN_WNE_P10', 'GRAD_DEBT_MDN_SUPP']
>>> for col in cols:
college[col] = pd.to_numeric(college[col], errors='coerce')
>>> college_n = college.select_dtypes(include=[np.number])
>>> criteria = college_n.nunique() == 2
>>> binary_cols = college_n.columns[criteria].tolist()
>>> college_n = college_n.drop(labels=binary_cols, axis='columns')
- 使用
max方法找到每列的最大值:
>>> college_n.max().head()
SATVRMID 765.0
SATMTMID 785.0
UGDS 151558.0
UGDS_WHITE 1.0
UGDS_BLACK 1.0
dtype: float64
- 使用
eq方法测试 DataFrame 中的每个值与其列的max值是否相等。默认情况下,eq方法会将 DataFrame 的列与传递的 Series 索引的标签对齐:
>>> college_n.eq(college_n.max()).head()

- 这个 DataFrame 中所有至少包含一个
True值的行必须包含一个列最大值。我们可以使用any方法来查找所有包含至少一个True值的行:
>>> has_row_max = college_n.eq(college_n.max()).any(axis='columns')
>>> has_row_max.head()
INSTNM
Alabama A & M University False
University of Alabama at Birmingham False
Amridge University False
University of Alabama in Huntsville False
Alabama State University False
dtype: bool
- 只有 18 列,这意味着
has_row_max中最多只有 18 个True值。让我们查找实际有多少个:
>>> college_n.shape
(7535, 18)
>>> has_row_max.sum()
401
- 这有点出乎意料,但事实证明,有些列有很多行等于最大值。这在许多百分比列中很常见,这些列的最大值为 1。
idxmax返回最大值的第一次出现。让我们退后一步,去掉any方法,查看步骤 3 的输出。我们改用cumsum方法来累积所有的True值。展示的是前后三行数据:
>>> college_n.eq(college_n.max()).cumsum()

- 有些列只有一个唯一的最大值,比如
SATVRMID和SATMTMID,而像UGDS_WHITE这样的列有很多最大值。109 所学校的本科生全部是白人。如果我们再链式调用一次cumsum方法,值 1 将在每列中只出现一次,并且它会是最大值的第一次出现:
>>> college_n.eq(college_n.max()).cumsum().cumsum()

- 现在我们可以使用
eq方法测试每个值是否等于 1,然后使用any方法查找包含至少一个True值的行:
>>> has_row_max2 = college_n.eq(college_n.max()) \
.cumsum() \
.cumsum() \
.eq(1) \
.any(axis='columns')
>>> has_row_max2.head()
INSTNM
Alabama A & M University False
University of Alabama at Birmingham False
Amridge University False
University of Alabama in Huntsville False
Alabama State University False
dtype: bool
- 测试
has_row_max2是否没有超过列数的True值:
>>> has_row_max2.sum()
16
- 我们需要所有
has_row_max2为True的机构。我们可以简单地在系列上使用布尔索引:
>>> idxmax_cols = has_row_max2[has_row_max2].index
>>> idxmax_cols
Index(['Thunderbird School of Global Management',
'Southwest University of Visual Arts-Tucson',
'ABC Beauty College Inc',
'Velvatex College of Beauty Culture',
'California Institute of Technology',
'Le Cordon Bleu College of Culinary Arts-San Francisco',
'MTI Business College Inc', 'Dongguk University-Los Angeles',
'Mr Leon's School of Hair Design-Moscow',
'Haskell Indian Nations University', 'LIU Brentwood',
'Medical College of Wisconsin', 'Palau Community College',
'California University of Management and Sciences',
'Cosmopolitan Beauty and Tech School',
'University of Phoenix-Arizona'], dtype='object', name='INSTNM')
- 这 16 个机构是至少包含一个列的首次最大值发生的索引。我们可以检查它们是否与通过
idxmax方法找到的相同:
>>> set(college_n.idxmax().unique()) == set(idxmax_cols)
True
它是如何工作的...
第一步通过将两列转换为数字并去除二进制列,复制了之前配方中的工作。在第二步中,我们找到每列的最大值。需要注意的是,pandas 会默默地丢弃它无法计算最大值的列。如果发生这种情况,那么第三步仍然会完成,但对于每个没有最大值的列,都会产生False值。
第四步使用any方法扫描每一行,寻找至少一个True值。任何包含至少一个True值的行都表示该列有一个最大值。在第五步中,我们将结果布尔系列相加,确定有多少行包含最大值。有些出乎意料的是,行数远远多于列数。第六步解释了为什么会发生这种情况。我们对第三步的输出进行累计求和,检测每列的最大值等于多少行的总数。
许多学院的学生群体中 100%属于单一种族。这是多行最大值出现的最大原因。如你所见,SAT 分数列和本科生人数列只有一行最大值,但多个种族列则有最大值的平局。
我们的目标是找到具有最大值的第一行。我们需要再次进行累计求和,这样每列只有一行等于 1。第 8 步将代码格式化为每行一个方法,并且如同第 4 步那样运行any方法。如果此步骤成功,那么我们应该没有比列数更多的True值。第 9 步验证了这一点。
为了验证我们找到的列与之前列中的idxmax相同,我们在has_row_max2上使用布尔选择。由于列的顺序会不同,因此我们将列名序列转换为集合,集合本身是无序的,用来比较相等性。
还有更多...
通过将索引操作符与匿名函数链式调用,实际上可以在一行代码中完成这个配方。这一小技巧去除了第 10 步的需要。我们可以对比直接使用idxmax方法和我们手动实现的时间差:
>>> %timeit college_n.idxmax().values
1.12 ms ± 28.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
>>> %timeit college_n.eq(college_n.max()) \
.cumsum() \
.cumsum() \
.eq(1) \
.any(axis='columns') \
[lambda x: x].index
5.35 ms ± 55.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
不幸的是,我们的努力比内置的idxmax pandas 方法慢五倍,但无论其性能回退如何,许多富有创意和实用的解决方案都使用类似cumsum的累计方法与布尔系列一起,沿着轴找到连续的序列或特定的模式。
查找最常见的最大值
该大学数据集包含超过 7,500 所大学的八种不同种族的本科生人口比例。我们可以尝试找出每所学校本科生中种族比例最高的种族,并找到这个结果在整个数据集中的分布。我们可以回答一个问题,例如,有多少比例的学校白人学生比例高于其他任何种族?
准备就绪
在这个例子中,我们使用idxmax方法找到每所学校本科生中种族比例最高的种族,然后查找这些最大值的分布。
如何操作…
- 读取大学数据集并仅选择那些包含本科生种族比例信息的列:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> college_ugds = college.filter(like='UGDS_')
>>> college_ugds.head()

- 使用
idxmax方法获取每行中种族比例最高的列名称:
>>> highest_percentage_race = college_ugds.idxmax(axis='columns')
>>> highest_percentage_race.head()
INSTNM
Alabama A & M University UGDS_BLACK
University of Alabama at Birmingham UGDS_WHITE
Amridge University UGDS_BLACK
University of Alabama in Huntsville UGDS_WHITE
Alabama State University UGDS_BLACK
dtype: object
- 使用
value_counts方法返回最大出现次数的分布:
>>> highest_percentage_race.value_counts(normalize=True)
UGDS_WHITE 0.670352
UGDS_BLACK 0.151586
UGDS_HISP 0.129473
UGDS_UNKN 0.023422
UGDS_ASIAN 0.012074
UGDS_AIAN 0.006110
UGDS_NRA 0.004073
UGDS_NHPI 0.001746
UGDS_2MOR 0.001164
dtype: float64
它是如何工作的……
这个例子的关键在于认识到这些列都代表相同的信息单位。我们可以相互比较这些列,这通常不是常见的情况。例如,直接比较 SAT 语文成绩和本科生人口比例是没有意义的。由于数据是以这种方式结构化的,我们可以对每一行数据应用idxmax方法来找到最大值所在的列。我们需要使用axis参数来修改其默认行为。
第 2 步完成了此操作并返回了一个 Series,现在我们可以简单地应用value_counts方法来返回分布。我们将True传递给normalize参数,因为我们关心的是分布(相对频率),而不是原始计数。
还有更多……
我们可能还想进一步探索并回答这个问题:对于那些黑人学生比例高于任何其他种族的学校,它们的第二高种族比例的分布是什么?
>>> college_black = college_ugds[highest_percentage_race == 'UGDS_BLACK']
>>> college_black = college_black.drop('UGDS_BLACK', axis='columns')
>>> college_black.idxmax(axis='columns').value_counts(normalize=True)
UGDS_WHITE 0.661228
UGDS_HISP 0.230326
UGDS_UNKN 0.071977
UGDS_NRA 0.018234
UGDS_ASIAN 0.009597
UGDS_2MOR 0.006718
UGDS_AIAN 0.000960
UGDS_NHPI 0.000960
dtype: float64
在应用本例中的相同方法之前,我们需要删除UGDS_BLACK列。有趣的是,似乎这些黑人学生比例较高的学校也倾向于有更高的西班牙裔学生比例。
第十三章:聚合、过滤和转换的分组
数据分析中的一项最基本任务是将数据拆分成独立的组,然后对每个组执行计算。这种方法已经存在很长时间了,但最近被称为split-apply-combine。本章将介绍强大的 groupby 方法,它允许你以任何想象得到的方式对数据进行分组,并独立地对每个组应用任何类型的函数,然后返回一个单一的数据集。
Hadley Wickham 创造了split-apply-combine这个术语,用来描述常见的数据分析模式:将数据拆分成独立且可管理的块,独立地对这些块应用函数,然后将结果重新组合。更多细节可以在他的论文中找到(bit.ly/2isFuL9)。
在我们开始具体的操作之前,我们需要了解一些基本的术语。所有基本的 groupby 操作都有分组列,这些列中每种独特的值组合代表数据的一个独立分组。语法如下:
>>> df.groupby(['list', 'of', 'grouping', 'columns'])
>>> df.groupby('single_column') # when grouping by a single column
这个操作的结果返回一个 groupby 对象。正是这个 groupby 对象将作为驱动整个章节所有计算的引擎。实际上,Pandas 在创建这个 groupby 对象时几乎不做任何事情,只是验证分组是否可能。你需要在这个 groupby 对象上链式调用方法,以释放它的强大功能。
从技术上讲,操作的结果将是 DataFrameGroupBy 或 SeriesGroupBy,但为了简便起见,本章将统一称之为 groupby 对象。
本章将涵盖以下主题:
-
定义聚合
-
使用多个列和函数进行分组和聚合
-
分组后移除 MultiIndex
-
自定义聚合函数
-
使用
*args和**kwargs自定义聚合函数 -
检查
groupby对象 -
过滤拥有少数族裔多数的州
-
通过减肥赌注进行转换
-
使用 apply 计算每个州的加权平均 SAT 分数
-
按连续变量分组
-
计算城市之间的航班总数
-
查找最长的准时航班连续记录
定义聚合
groupby 方法最常见的用途是执行聚合操作。那么,什么是聚合呢?在我们的数据分析领域,当许多输入的序列被总结或合并成一个单一的输出值时,就发生了聚合。例如,对某一列的所有值求和或找出其最大值,是对单一数据序列应用的常见聚合操作。聚合操作就是将多个值转换为一个单一的值。
除了引言中定义的分组列外,大多数聚合操作还有两个其他组件,聚合列和聚合函数。聚合列是那些其值将被聚合的列。聚合函数定义了聚合的方式。常见的聚合函数包括sum、min、max、mean、count、variance、std等。
准备工作
在这个示例中,我们查看航班数据集,并执行最简单的聚合操作,涉及一个分组列、一个聚合列和一个聚合函数。我们将计算每个航空公司的平均到达延迟。Pandas 提供了多种不同的语法来执行聚合,本示例涵盖了这些语法。
如何操作...
- 读取航班数据集,并定义分组列(
AIRLINE)、聚合列(ARR_DELAY)和聚合函数(mean):
>>> flights = pd.read_csv('data/flights.csv')
>>> flights.head()

- 将分组列放入
groupby方法中,然后使用一个字典,将聚合列与其聚合函数配对,接着调用agg方法:
>>> flights.groupby('AIRLINE').agg({'ARR_DELAY':'mean'}).head()

- 或者,你也可以将聚合列放入索引操作符中,然后将聚合函数作为字符串传递给
agg:
>>> flights.groupby('AIRLINE')['ARR_DELAY'].agg('mean').head()
AIRLINE
AA 5.542661
AS -0.833333
B6 8.692593
DL 0.339691
EV 7.034580
Name: ARR_DELAY, dtype: float64
- 前一步中使用的字符串名称是 pandas 提供的方便方式,用于引用特定的聚合函数。你也可以将任何聚合函数直接传递给
agg方法,例如 NumPy 的mean函数。输出结果与前一步相同:
>>> flights.groupby('AIRLINE')['ARR_DELAY'].agg(np.mean).head()
- 在这种情况下,完全可以跳过
agg方法,直接使用mean方法。这个输出结果与步骤 3 相同:
>>> flights.groupby('AIRLINE')['ARR_DELAY'].mean().head()
它是如何工作的...
groupby方法的语法并不像其他方法那样直接。让我们通过将groupby方法的结果存储为一个变量,来中断步骤 2 中的方法链。
>>> grouped = flights.groupby('AIRLINE')
>>> type(grouped)
pandas.core.groupby.DataFrameGroupBy
首先会生成一个全新的中间对象,具有自己独特的属性和方法。在此阶段没有任何计算发生。Pandas 仅验证分组列。这个 groupby 对象有一个agg方法用于执行聚合操作。使用这种方法的一种方式是将一个字典传递给它,将聚合列与聚合函数进行映射,正如步骤 2 所示。
有几种不同的语法可以实现相似的结果,步骤 3 展示了一种替代方法。无需在字典中指定聚合列,可以像从 DataFrame 中选择列一样将其放入索引操作符内。然后,将函数的字符串名称作为标量传递给agg方法。
你可以将任何聚合函数传递给agg方法。Pandas 为了简化操作,允许使用字符串名称,但你也可以像步骤 4 中那样显式地调用聚合函数。NumPy 提供了许多聚合函数。
第 5 步展示了最后一种语法风格。当你只应用单个聚合函数时,通常可以直接作为方法调用到 groupby 对象本身,而无需使用agg。并非所有聚合函数都有相应的方法,但许多基本的聚合函数是有的。以下是一些可以作为字符串传递给agg或直接作为方法链调用到 groupby 对象的聚合函数:
min max mean median sum count std var
size describe nunique idxmin idxmax
还有更多...
如果在使用agg时没有使用聚合函数,pandas 会抛出异常。例如,让我们看看当我们对每个组应用平方根函数时会发生什么:
>>> flights.groupby('AIRLINE')['ARR_DELAY'].agg(np.sqrt)
ValueError: function does not reduce
另见
- Pandas 官方文档关于聚合的说明 (
bit.ly/2iuf1Nc)
使用多个列和函数进行分组和聚合
可以使用多个列进行分组和聚合。语法与使用单个列进行分组和聚合仅有轻微不同。像任何分组操作一样,识别三个组成部分会有所帮助:分组列、聚合列和聚合函数。
准备就绪
在这个示例中,我们通过回答以下查询展示了groupby DataFrame 方法的灵活性:
-
查找每家航空公司在每个工作日取消的航班数量
-
查找每家航空公司在每个工作日取消和转机航班的数量和百分比
-
对于每个出发地和目的地,查找航班的总数、取消航班的数量和百分比,以及空中时间的平均值和方差
如何操作...
- 读取航班数据集,并通过定义分组列(
AIRLINE, WEEKDAY)、聚合列(CANCELLED)和聚合函数(sum)来回答第一个查询:
>>> flights.groupby(['AIRLINE', 'WEEKDAY'])['CANCELLED'] \
.agg('sum').head(7)
AIRLINE WEEKDAY
AA 1 41
2 9
3 16
4 20
5 18
6 21
7 29
Name: CANCELLED, dtype: int64
- 使用每对分组列和聚合列的列表来回答第二个查询。同时,为聚合函数使用一个列表:
>>> flights.groupby(['AIRLINE', 'WEEKDAY']) \
['CANCELLED', 'DIVERTED'].agg(['sum', 'mean']).head(7)

- 使用字典在
agg方法中映射特定的聚合列到特定的聚合函数来回答第三个查询:
>>> group_cols = ['ORG_AIR', 'DEST_AIR']
>>> agg_dict = {'CANCELLED':['sum', 'mean', 'size'],
'AIR_TIME':['mean', 'var']}
>>> flights.groupby(group_cols).agg(agg_dict).head()

它是如何工作的...
如第 1 步所示,要按多个列分组,我们将字符串列名列表传递给groupby方法。AIRLINE和WEEKDAY的每种唯一组合都形成一个独立的组。在这些组内,找到取消航班的总和并作为一个 Series 返回。
第 2 步,再次按AIRLINE和WEEKDAY分组,但这次对两列进行聚合。它将sum和mean两个聚合函数应用于每一列,从而每个组返回四列结果。
第三步更进一步,使用字典将特定的聚合列映射到不同的聚合函数。注意,size聚合函数返回每个组的总行数。这与count聚合函数不同,后者返回每个组的非缺失值数量。
还有更多...
在执行聚合时,你将遇到几种主要的语法类型。以下四个伪代码块总结了使用groupby方法进行聚合的主要方式:
- 使用
agg与字典结合是最灵活的,它允许你为每一列指定聚合函数:
>>> df.groupby(['grouping', 'columns']) \
.agg({'agg_cols1':['list', 'of', 'functions'],
'agg_cols2':['other', 'functions']})
- 使用
agg与聚合函数列表结合,应用每个函数到每个聚合列:
>>> df.groupby(['grouping', 'columns'])['aggregating', 'columns'] \
.agg([aggregating, functions])
- 直接在聚合列后使用方法,而不是
agg,将该方法仅应用于每个聚合列。这种方法不允许多个聚合函数:
>>> df.groupby(['grouping', 'columns'])['aggregating', 'columns'] \
.aggregating_method()
- 如果没有指定聚合列,那么聚合方法将应用于所有非分组列:
>>> df.groupby(['grouping', 'columns']).aggregating_method()
在前四个代码块中,当按单列进行分组或聚合时,任何列表都可以替换为字符串。
分组后移除多重索引
不可避免地,在使用groupby时,你很可能会在列或行,甚至两者中创建多重索引。具有多重索引的 DataFrame 更难以操作,偶尔还会有令人困惑的列名。
准备工作
在这个例子中,我们使用groupby方法进行聚合,创建一个具有行和列多重索引的 DataFrame,然后对其进行操作,使得索引变为单层,并且列名具有描述性。
如何操作...
- 读取航班数据集;写一个语句,找出每个航空公司在每个工作日的飞行总里程和平均里程,以及最大和最小的到达延误:
>>> flights = pd.read_csv('data/flights.csv')
>>> airline_info = flights.groupby(['AIRLINE', 'WEEKDAY'])\
.agg({'DIST':['sum', 'mean'],
'ARR_DELAY':['min', 'max']}) \
.astype(int)
>>> airline_info.head(7)

- 行和列都由两个层次的多重索引标记。我们将其压缩为单一层次。为了处理列,我们使用多重索引方法
get_level_values。我们将显示每个层次的输出,然后将这两个层次合并,最后将其设为新的列名:
>>> level0 = airline_info.columns.get_level_values(0)
Index(['DIST', 'DIST', 'ARR_DELAY', 'ARR_DELAY'], dtype='object')
>>> level1 = airline_info.columns.get_level_values(1)
Index(['sum', 'mean', 'min', 'max'], dtype='object')
>>> airline_info.columns = level0 + '_' + level1
>>> airline_info.head(7)

- 使用
reset_index将行标签恢复为单一层次:
>>> airline_info.reset_index().head(7)

它是如何工作的...
当使用 agg 方法对多个列执行聚合操作时,pandas 会创建一个具有两级的索引对象。聚合列成为顶级,聚合函数成为底级。pandas 会以不同于单级列的方式显示 MultiIndex 级别。除了 最内层 的级别外,重复的索引值不会显示在屏幕上。你可以查看步骤 1 中的 DataFrame 来验证这一点。例如,DIST 列只会显示一次,但它指代的是前两列的内容。
最内层的 MultiIndex 级别是最接近数据的级别。这通常是最底层的列级别和最右侧的索引级别。
步骤 2 通过首先使用 MultiIndex 方法 get_level_values 检索每个级别的基础值来定义新列。该方法接受一个整数,表示索引级别。索引级别从上/左开始编号,编号从零开始。索引支持向量化操作,因此我们将两个级别连接起来,并用下划线分隔。然后将这些新值分配给 columns 属性。
在步骤 3 中,我们使用 reset_index 将两个索引级别转为列。我们本可以像步骤 2 中那样将这些级别连接在一起,但将它们保留为单独的列更有意义。
还有更多...
默认情况下,在 groupby 操作结束时,pandas 会将所有分组列放入索引中。groupby 方法中的 as_index 参数可以设置为 False,以避免这种行为。你也可以在分组之后链式调用 reset_index 方法,达到与步骤 3 相同的效果。我们通过找出每个航空公司每次航班的平均行程距离来看看这个例子:
>>> flights.groupby(['AIRLINE'], as_index=False)['DIST'].agg('mean') \
.round(0)

看一下前面结果中航空公司的排序。默认情况下,pandas 会对分组列进行排序。sort 参数存在于 groupby 方法中,默认值为 True。你可以将其设置为 False,保持分组列的顺序与数据集中出现的顺序相同。通过不对数据进行排序,你还可以获得小幅的性能提升。
自定义聚合函数
Pandas 提供了许多最常用的聚合函数,供你在 groupby 对象上使用。在某些情况下,你可能需要编写自己定制的用户定义函数,这些函数在 pandas 或 NumPy 中并不存在。
准备工作
在这个例子中,我们使用大学数据集来计算每个州本科生人口的均值和标准差。然后,我们使用这些信息找到每个州中某个人口值距离均值的标准差最大值。
如何操作...
- 读取大学数据集,并按州计算本科生人口的均值和标准差:
>>> college = pd.read_csv('data/college.csv')
>>> college.groupby('STABBR')['UGDS'].agg(['mean', 'std']) \
.round(0).head()

- 这个输出结果并不是我们想要的。我们并不寻找整个组的均值和标准差,而是寻找任何一个机构与均值之间离得最远的标准差数值。为了计算这个值,我们需要从每个机构的本科生人口中减去按州划分的本科生人口均值,再除以标准差。这将标准化每个组的本科生人口。然后我们可以取这些分数的绝对值的最大值,找到与均值最远的那个值。Pandas 并没有提供能够实现这一点的函数。因此,我们需要创建一个自定义函数:
>>> def max_deviation(s):
std_score = (s - s.mean()) / s.std()
return std_score.abs().max()
- 定义函数后,将其直接传递给
agg方法以完成聚合:
>>> college.groupby('STABBR')['UGDS'].agg(max_deviation) \
.round(1).head()
STABBR
AK 2.6
AL 5.8
AR 6.3
AS NaN
AZ 9.9
Name: UGDS, dtype: float64
它是如何工作的...
没有预定义的 Pandas 函数可以计算离均值最远的标准差数值。我们被迫在步骤 2 中构建一个自定义函数。注意,这个自定义函数max_deviation接受一个参数s。看一下步骤 3,你会注意到函数名被放在agg方法内,而没有直接调用。s参数没有明确传递给max_deviation,相反,Pandas 隐式地将UGDS列作为 Series 传递给max_deviation。
max_deviation函数会对每个组调用一次。由于s是一个 Series,因此所有常规的 Series 方法都可以使用。它会从该组中每个值减去该组的均值,然后除以标准差,这一过程称为标准化。
标准化是一个常见的统计程序,用于了解个体值与均值之间的差异有多大。对于正态分布,99.7%的数据位于均值的三个标准差以内。
由于我们关注的是与均值的绝对偏差,因此我们从所有标准化分数中取绝对值,并返回最大值。agg方法要求我们的自定义函数必须返回一个标量值,否则会抛出异常。Pandas 默认使用样本标准差,而对于只有一个值的组,标准差是未定义的。例如,州缩写AS(美属萨摩亚)返回缺失值,因为数据集中只有一个机构。
还有更多...
我们可以将自定义函数应用于多个聚合列。只需将更多的列名添加到索引操作符中。max_deviation函数仅适用于数值列:
>>> college.groupby('STABBR')['UGDS', 'SATVRMID', 'SATMTMID'] \
.agg(max_deviation).round(1).head()

你还可以将自定义的聚合函数与预构建的函数一起使用。以下示例将这两者结合,并按州和宗教信仰进行分组:
>>> college.groupby(['STABBR', 'RELAFFIL']) \
['UGDS', 'SATVRMID', 'SATMTMID'] \
.agg([max_deviation, 'mean', 'std']).round(1).head()

请注意,Pandas 使用函数的名称作为返回列的名称。你可以通过rename方法直接更改列名,或者修改特殊函数属性__name__:
>>> max_deviation.__name__
'max_deviation'
>>> max_deviation.__name__ = 'Max Deviation'
>>> college.groupby(['STABBR', 'RELAFFIL']) \
['UGDS', 'SATVRMID', 'SATMTMID'] \
.agg([max_deviation, 'mean', 'std']).round(1).head()

使用*args和**kwargs自定义聚合函数
当编写自己的自定义聚合函数时,Pandas 会隐式地将每个聚合列逐一作为 Series 传递给它。偶尔,你需要向函数传递的不仅仅是 Series 本身的参数。为此,你需要了解 Python 可以向函数传递任意数量的参数。让我们借助inspect模块来看一下groupby对象的agg方法签名:
>>> college = pd.read_csv('data/college.csv')
>>> grouped = college.groupby(['STABBR', 'RELAFFIL'])
>>> import inspect
>>> inspect.signature(grouped.agg)
<Signature (arg, *args, **kwargs)>
参数*args允许你将任意数量的非关键字参数传递给自定义的聚合函数。同样,**kwargs允许你将任意数量的关键字参数传递给函数。
准备好了吗
在这个示例中,我们为大学数据集构建了一个自定义函数,计算按州和宗教信仰分类的学校百分比,这些学校的本科生人数在两个值之间。
如何做...
- 定义一个函数,返回本科生人数在 1,000 到 3,000 之间的学校百分比:
>>> def pct_between_1_3k(s):
return s.between(1000, 3000).mean()
- 按州和宗教信仰计算百分比分组:
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(pct_between_1_3k).head(9)
STABBR RELAFFIL
AK 0 0.142857
1 0.000000
AL 0 0.236111
1 0.333333
AR 0 0.279412
1 0.111111
AS 0 1.000000
AZ 0 0.096774
1 0.000000
Name: UGDS, dtype: float64
- 这个函数工作正常,但它没有给用户提供选择上下限的灵活性。我们来创建一个新函数,允许用户定义这些上下限:
>>> def pct_between(s, low, high):
return s.between(low, high).mean()
- 将这个新函数与上下限一起传递给
agg方法:
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(pct_between, 1000, 10000).head(9)
STABBR RELAFFIL
AK 0 0.428571
1 0.000000
AL 0 0.458333
1 0.375000
AR 0 0.397059
1 0.166667
AS 0 1.000000
AZ 0 0.233871
1 0.111111
Name: UGDS, dtype: float64
它是如何工作的...
第 1 步创建了一个不接受任何额外参数的函数。上下限必须硬编码到函数中,这样不够灵活。第 2 步展示了此聚合的结果。
我们在第 3 步创建了一个更灵活的函数,允许用户动态定义上下限。第 4 步是*args和**kwargs发挥作用的地方。在这个例子中,我们向agg方法传递了两个非关键字参数,1,000 和 10,000,Pandas 将这两个参数分别传递给pct_between的low和high参数。
我们可以通过几种方式在第 4 步中实现相同的结果。我们可以明确地使用参数名称,并通过以下命令产生相同的结果:
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(pct_between, high=10000, low=1000).head(9)
关键字参数的顺序并不重要,只要它们位于函数名后面。进一步说,我们可以将非关键字参数和关键字参数混合使用,只要关键字参数在最后:
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(pct_between, 1000, high=10000).head(9)
为了便于理解,最好按照函数签名中定义的顺序包括所有参数名称。
从技术上讲,当调用agg时,所有非关键字参数都会被收集到一个名为args的元组中,所有关键字参数则会被收集到一个名为kwargs的字典中。
还有更多...
不幸的是,pandas 没有直接的方法来在使用多个聚合函数时传递这些额外的参数。例如,如果你希望同时使用pct_between和mean函数进行聚合,你将遇到以下异常:
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(['mean', pct_between], low=100, high=1000)
TypeError: pct_between() missing 2 required positional arguments: 'low' and 'high'
Pandas 无法理解需要将额外的参数传递给pct_between。为了将我们的自定义函数与其他内建函数甚至其他自定义函数一起使用,我们可以定义一种特殊类型的嵌套函数,称为闭包。我们可以使用通用的闭包来构建所有的定制化函数:
>>> def make_agg_func(func, name, *args, **kwargs):
def wrapper(x):
return func(x, *args, **kwargs)
wrapper.__name__ = name
return wrapper
>>> my_agg1 = make_agg_func(pct_between, 'pct_1_3k', low=1000, high=3000)
>>> my_agg2 = make_agg_func(pct_between, 'pct_10_30k', 10000, 30000)
>>> college.groupby(['STABBR', 'RELAFFIL'])['UGDS'] \
.agg(['mean', my_agg1, my_agg2]).head()

make_agg_func函数充当工厂,创建定制化的聚合函数。它接受你已经构建的定制化聚合函数(此例中为pct_between)、name参数和任意数量的额外参数。它返回一个已经设置了额外参数的函数。例如,my_agg1是一个特定的定制化聚合函数,用于查找本科生人数在一千到三千之间的学校的百分比。额外的参数(*args和**kwargs)为你的定制函数(此例中为pct_between)指定了一组精确的参数。name参数非常重要,每次调用make_agg_func时都必须是唯一的,最终它会用于重命名聚合后的列。
闭包是一个包含内部函数(嵌套函数)并返回这个嵌套函数的函数。这个嵌套函数必须引用外部函数作用域中的变量,才能成为闭包。在这个例子中,make_agg_func是外部函数,并返回嵌套函数wrapper,后者访问外部函数中的func、args和kwargs变量。
另见
-
来自官方 Python 文档的任意参数列表(
bit.ly/2vumbTE) -
Python 闭包的教程(
bit.ly/2xFdYga)
检查 groupby 对象
使用groupby方法对 DataFrame 进行操作的即时结果将是一个 groupby 对象。通常,我们继续对该对象进行操作,进行聚合或转换,而不会将其保存到变量中。检查这个 groupby 对象的主要目的是检查单个分组。
准备工作
在这个示例中,我们通过直接调用groupby对象上的方法以及迭代其每个分组来检查该对象本身。
如何做...
- 让我们从对大学数据集中的州和宗教归属列进行分组开始,将结果保存到一个变量中,并确认其类型:
>>> college = pd.read_csv('data/college.csv')
>>> grouped = college.groupby(['STABBR', 'RELAFFIL'])
>>> type(grouped)
pandas.core.groupby.DataFrameGroupBy
- 使用
dir函数来发现它所有可用的功能:
>>> print([attr for attr in dir(grouped) if not attr.startswith('_')])
['CITY', 'CURROPER', 'DISTANCEONLY', 'GRAD_DEBT_MDN_SUPP', 'HBCU', 'INSTNM', 'MD_EARN_WNE_P10', 'MENONLY', 'PCTFLOAN', 'PCTPELL', 'PPTUG_EF', 'RELAFFIL', 'SATMTMID', 'SATVRMID', 'STABBR', 'UG25ABV', 'UGDS', 'UGDS_2MOR', 'UGDS_AIAN', 'UGDS_ASIAN', 'UGDS_BLACK', 'UGDS_HISP', 'UGDS_NHPI', 'UGDS_NRA', 'UGDS_UNKN', 'UGDS_WHITE', 'WOMENONLY', 'agg', 'aggregate', 'all', 'any', 'apply', 'backfill', 'bfill', 'boxplot', 'corr', 'corrwith', 'count', 'cov', 'cumcount', 'cummax', 'cummin', 'cumprod', 'cumsum', 'describe', 'diff', 'dtypes', 'expanding', 'ffill', 'fillna', 'filter', 'first', 'get_group', 'groups', 'head', 'hist', 'idxmax', 'idxmin', 'indices', 'last', 'mad', 'max', 'mean', 'median', 'min', 'ndim', 'ngroup', 'ngroups', 'nth', 'nunique', 'ohlc', 'pad', 'pct_change', 'plot', 'prod', 'quantile', 'rank', 'resample', 'rolling', 'sem', 'shift', 'size', 'skew', 'std', 'sum', 'tail', 'take', 'transform', 'tshift', 'var']
- 使用
ngroups属性查找分组的数量:
>>> grouped.ngroups
112
- 要查找每个组的唯一标识标签,请查看
groups属性,该属性包含一个字典,其中每个唯一组都映射到该组的所有对应索引标签:
>>> groups = list(grouped.groups.keys())
>>> groups[:6]
[('AK', 0), ('AK', 1), ('AL', 0), ('AL', 1), ('AR', 0), ('AR', 1)]
- 通过传递一个精确的组标签元组,可以使用
get_group方法检索单个组。例如,要获取佛罗里达州所有宗教附属学校,可以按以下步骤操作:
>>> grouped.get_group(('FL', 1)).head()

- 你可能想查看每个单独的组。因为 groupby 对象是可迭代的,所以这是可能的:
>>> from IPython.display import display
>>> for name, group in grouped:
print(name)
display(group.head(3))

- 你也可以在 groupby 对象上调用 head 方法,将每个组的前几行放在一个单独的 DataFrame 中。
>>> grouped.head(2).head(6)

它是如何工作的...
第一步正式创建我们的 groupby 对象。显示所有公共属性和方法很有用,这样可以揭示所有可能的功能,如第二步所示。每个组通过包含分组列中值的唯一组合的元组来唯一标识。Pandas 允许你使用第五步中展示的get_group方法选择特定的组作为 DataFrame。
你通常不需要遍历你的组,通常如果不是必要的话应该避免这样做,因为这样可能非常慢。偶尔,你可能别无选择。当遍历 groupby 对象时,你会得到一个元组,其中包含组名称和不包含分组列的 DataFrame。这个元组在第六步的 for 循环中被解包到name和group变量中。
在遍历组时,你可以做的有趣的事情之一是直接在笔记本中显示每个组的几行数据。为此,你可以使用print函数或来自IPython.display模块的display函数。使用print函数时,结果是没有任何漂亮 HTML 格式的纯文本 DataFrame。而使用display函数则会以正常且易于阅读的格式显示 DataFrame。
还有更多...
第 2 步中的列表中有几个有用的方法没有被探索。以nth方法为例,当给定一个整数列表时,它会从每个组中选择这些特定的行。例如,以下操作选择每个组的第一行和最后一行:
>>> grouped.nth([1, -1]).head(8)

另见
display函数的官方文档来自 IPython (bit.ly/2iAIogC)
筛选具有少数派多数的州
在第十章,选择数据子集,我们在过滤掉False行之前,将每一行标记为True或False。以类似的方式,也可以在过滤掉False组之前,将整个数据组标记为True或False。为此,我们首先使用groupby方法对数据进行分组,然后应用filter方法。filter方法接受一个必须返回True或False的函数,用来指示是否保留某个组。
在调用groupby方法后应用的这个filter方法与数据框(DataFrame)的filter方法是完全不同的。
准备工作
在这个示例中,我们使用大学数据集来查找所有少数族裔本科生数量超过白人数量的州。由于这是一个来自美国的数据集,而白人是多数群体,因此我们寻找的是少数族裔多数的州。
如何操作...
- 读取大学数据集,按州分组,并显示组的总数。这应该等于从
nunique系列方法中检索到的唯一州的数量:
>>> college = pd.read_csv('data/college.csv', index_col='INSTNM')
>>> grouped = college.groupby('STABBR')
>>> grouped.ngroups
59
>>> college['STABBR'].nunique() # verifying the same number
59
grouped变量有一个filter方法,该方法接受一个自定义函数来决定是否保留某个组。自定义函数会隐式地接收到当前组的一个数据框,并需要返回一个布尔值。让我们定义一个函数,计算少数族裔学生的总百分比,如果这个百分比大于用户定义的阈值,则返回True:
>>> def check_minority(df, threshold):
minority_pct = 1 - df['UGDS_WHITE']
total_minority = (df['UGDS'] * minority_pct).sum()
total_ugds = df['UGDS'].sum()
total_minority_pct = total_minority / total_ugds
return total_minority_pct > threshold
- 使用传入
check_minority函数和 50%阈值的filter方法来查找所有有少数族裔多数的州:
>>> college_filtered = grouped.filter(check_minority, threshold=.5)
>>> college_filtered.head()

- 仅仅查看输出结果可能无法反映实际发生的情况。数据框(DataFrame)从亚利桑那州(Arizona,简称 AZ)开始,而不是阿拉斯加州(Alaska,简称 AK),所以我们可以直观地确认某些内容发生了变化。让我们将这个过滤后的数据框的
shape与原始数据框进行对比。从结果来看,大约 60%的行被过滤掉了,剩下的只有 20 个州有少数族裔多数:
>>> college.shape
(7535, 26)
>>> college_filtered.shape
(3028, 26)
>>> college_filtered['STABBR'].nunique()
20
工作原理...
这个示例通过逐州查看所有院校的总人口情况。目标是保留所有那些有少数族裔多数的州的所有行。这需要我们按照州对数据进行分组,这在步骤 1 中完成。我们发现共有 59 个独立组。
filter的分组方法要么保留组中的所有行,要么将其过滤掉。它不会改变列的数量。filter的分组方法通过一个用户定义的函数执行这一筛选工作,例如本示例中的check_minority。一个非常重要的过滤方面是,它会将整个数据框传递给该组的用户定义函数,并返回每个组的一个布尔值。
在check_minority函数内部,首先计算每个机构的少数族裔学生百分比和非白人学生的总数,然后计算所有学生的总数。最后,检查整个州的非白人学生百分比是否超过给定的阈值,结果为布尔值。
最终结果是一个与原始数据框(DataFrame)具有相同列数的数据框,但其中的行已过滤掉不符合阈值的州。由于过滤后的数据框头部可能与原始数据框相同,因此需要进行检查,以确保操作成功完成。我们通过检查行数和独特州的数量来验证这一点。
还有更多...
我们的函数check_minority是灵活的,接受一个参数以降低或提高少数群体的阈值百分比。让我们检查一下其他几个阈值下的数据框形状和独特州的数量:
>>> college_filtered_20 = grouped.filter(check_minority, threshold=.2)
>>> college_filtered_20.shape
(7461, 26)
>>> college_filtered_20['STABBR'].nunique()
57
>>> college_filtered_70 = grouped.filter(check_minority, threshold=.7)
>>> college_filtered_70.shape
(957, 26)
>>> college_filtered_70['STABBR'].nunique()
10
另见
- Pandas 官方文档关于过滤(
bit.ly/2xGUoA7)
通过体重减轻比赛进行转变
增加减肥动力的一种方法是和别人打赌。在这个配方中,我们将追踪两个人在四个月期间的体重减轻情况,并确定谁是最终的赢家。
准备开始
在这个配方中,我们使用了两个人的模拟数据来追踪他们在四个月内的体重减轻百分比。在每个月结束时,根据该月体重减轻百分比最多的人来宣布赢家。为了追踪体重减轻情况,我们按月和人物对数据进行分组,然后调用transform方法来找出每周体重减轻的百分比,从而得出每月开始时的变化情况。
如何做...
- 读取原始的体重减轻数据集,并检查
Amy和Bob两个人的第一个月数据。每个月共有四次体重测量:
>>> weight_loss = pd.read_csv('data/weight_loss.csv')
>>> weight_loss.query('Month == "Jan"')

- 为了确定每个月的赢家,我们只需比较每个月从第一周到最后一周的体重减轻情况。但如果我们想要每周更新,也可以计算每个月从当前周到第一周的体重减轻情况。让我们创建一个能够提供每周更新的函数:
>>> def find_perc_loss(s):
return (s - s.iloc[0]) / s.iloc[0]
- 让我们测试一下 Bob 在 1 月期间使用这个函数的结果。
>>> bob_jan = weight_loss.query('Name=="Bob" and Month=="Jan"')
>>> find_perc_loss(bob_jan['Weight'])
0 0.000000
2 -0.010309
4 -0.027491
6 -0.027491
Name: Weight, dtype: float64
你应该忽略最后输出中的索引值。0、2、4 和 6 仅仅是原始数据框的行标签,与周次无关。
- 在第一周后,Bob 减轻了 1%的体重。在第二周,他继续减肥,但在最后一周没有进展。我们可以将此函数应用于每个人和每周的所有组合,以获取每周相对于月初的体重减轻情况。为此,我们需要按
Name和Month对数据进行分组,然后使用transform方法应用此自定义函数:
>>> pcnt_loss = weight_loss.groupby(['Name', 'Month'])['Weight'] \
.transform(find_perc_loss)
>>> pcnt_loss.head(8)
0 0.000000
1 0.000000
2 -0.010309
3 -0.040609
4 -0.027491
5 -0.040609
6 -0.027491
7 -0.035533
Name: Weight, dtype: float64
transform方法必须返回与调用的 DataFrame 具有相同数量行的对象。让我们将这个结果作为新列附加到原始 DataFrame 中。为了缩短输出,我们将选择 Bob 的前两个月的数据:
>>> weight_loss['Perc Weight Loss'] = pcnt_loss.round(3)
>>> weight_loss.query('Name=="Bob" and Month in ["Jan", "Feb"]')

- 注意,百分比体重减轻在每个月开始时会重置。通过这个新列,我们可以手动确定每个月的获胜者,但让我们看看能否找到自动执行此操作的方法。由于唯一重要的是最后一周的数据,我们来选择第 4 周:
>>> week4 = weight_loss.query('Week == "Week 4"')
>>> week4

- 这将缩小数据范围,但仍无法自动找出每个月的获胜者。让我们使用
pivot方法重新整理这些数据,这样 Bob 和 Amy 的百分比体重减轻就能并排显示在每个月:
>>> winner = week4.pivot(index='Month', columns='Name',
values='Perc Weight Loss')
>>> winner

- 这个输出让每个月的获胜者更加清晰,但我们仍然可以进一步优化。NumPy 有一个矢量化的 if-then-else 函数,叫做
where,它可以将布尔值的 Series 或数组映射到其他值。让我们创建一个列,记录获胜者的名字,并突出显示每个月的获胜百分比:
>>> winner['Winner'] = np.where(winner['Amy'] < winner['Bob'],
'Amy', 'Bob')
>>> winner.style.highlight_min(axis=1)

- 使用
value_counts方法返回最终得分,即获胜的月份数量:
>>> winner.Winner.value_counts()
Amy 3
Bob 1
Name: Winner, dtype: int64
它是如何工作的……
在整个过程中,使用query方法来过滤数据,而不是使用布尔索引。有关更多信息,请参考第十一章中的通过 query 方法提高布尔索引的可读性一节,布尔索引。
我们的目标是找出每个人每个月的百分比体重减轻。一种实现此任务的方法是计算每周的体重减轻相对于每个月开始时的情况。这个任务非常适合使用transform的 groupby 方法。transform方法接受一个函数作为其唯一的必需参数。这个函数会隐式传递每个非分组列(或者只传递在索引操作符中指定的列,如本节中对Weight的处理)。它必须返回与传入分组相同长度的值序列,否则会引发异常。从本质上讲,原始 DataFrame 中的所有值都会被转换。没有进行聚合或筛选操作。
步骤 2 创建了一个函数,它从传入的 Series 的所有值中减去第一个值,然后将结果除以第一个值。这计算了相对于第一个值的百分比损失(或增益)。在步骤 3 中,我们在一个人和一个月的数据上测试了这个函数。
在步骤 4 中,我们以相同的方式在每个人和每周的所有组合上使用这个函数。从字面意义上讲,我们正在将 Weight 列转换为当前周的体重减轻百分比。每个人的第一个月数据会被输出。Pandas 将新数据作为一个 Series 返回。这个 Series 本身并不是特别有用,最好是作为一个新列追加到原始的 DataFrame 中。我们在步骤 5 中完成这个操作。
要确定赢家,只需要每个月的第 4 周的数据。我们可以在这里停下来手动确定赢家,但 pandas 为我们提供了自动化的功能。步骤 7 中的 pivot 函数通过将一个列的唯一值转换为新的列名来重新塑形数据集。index 参数用于指定不需要透视的列。传递给 values 参数的列将在 index 和 columns 参数的每个唯一组合上铺开。
pivot 方法仅在 index 和 columns 参数中的每个唯一组合只出现一次时才有效。如果有多个唯一组合,将会抛出异常。在这种情况下,你可以使用 pivot_table 方法,它允许你聚合多个值。
在透视之后,我们利用高效且快速的 NumPy where 函数,其第一个参数是一个条件,返回一个布尔值的 Series。True 值会映射到Amy,False 值会映射到Bob。我们标出每个月的赢家,并用 value_counts 方法统计最终得分。
还有更多……
看一下步骤 7 输出的 DataFrame。你是否注意到月份的顺序是按字母顺序排列的,而不是按时间顺序?遗憾的是,至少在这种情况下,Pandas 是按字母顺序排列月份的。我们可以通过将 Month 列的数据类型更改为分类变量来解决这个问题。分类变量会将每个列中的所有值映射到整数。我们可以选择这种映射,使月份按正常的时间顺序排列。Pandas 在 pivot 方法中使用这个整数映射来按时间顺序排列月份:
>>> week4a = week4.copy()
>>> month_chron = week4a['Month'].unique() # or use drop_duplicates
>>> month_chron
array(['Jan', 'Feb', 'Mar', 'Apr'], dtype=object)
>>> week4a['Month'] = pd.Categorical(week4a['Month'],
categories=month_chron,
ordered=True)
>>> week4a.pivot(index='Month', columns='Name',
values='Perc Weight Loss')

要转换Month列,使用Categorical构造函数。将原始列作为 Series 传递给它,并将所需顺序中的所有类别的唯一序列传递给categories参数。由于Month列已经按时间顺序排列,我们可以直接使用unique方法,该方法保留顺序,从而获取所需的数组。一般来说,要按字母顺序以外的方式对对象数据类型的列进行排序,可以将其转换为分类数据类型。
另见
-
Pandas 官方文档中的
groupby转换(bit.ly/2vBkpA7) -
NumPy 官方文档中的
where函数(bit.ly/2weT21l)
使用 apply 计算按州加权的 SAT 数学成绩平均值
groupby对象有四个方法可以接受一个函数(或多个函数),对每个组进行计算。这四个方法分别是agg、filter、transform和apply。这些方法中的前三个都有非常具体的输出要求,函数必须返回特定的值。agg必须返回一个标量值,filter必须返回一个布尔值,而transform必须返回一个与传入组长度相同的 Series。然而,apply方法可以返回标量值、Series,甚至是任意形状的 DataFrame,因此非常灵活。它每次只调用一次每个组,这与transform和agg每次都调用每个非分组列不同。apply方法在同时操作多个列时能够返回单一对象,这使得本例中的计算成为可能。
准备工作
在本例中,我们计算了每个州的数学和语言 SAT 成绩的加权平均值,数据来源于大学数据集。我们根据每所学校的本科生人数对成绩进行加权。
如何操作...
- 读取大学数据集,并删除任何在
UGDS、SATMTMID或SATVRMID列中有缺失值的行。我们必须确保这三列中每一列都没有缺失值:
>>> college = pd.read_csv('data/college.csv')
>>> subset = ['UGDS', 'SATMTMID', 'SATVRMID']
>>> college2 = college.dropna(subset=subset)
>>> college.shape
(7535, 27)
>>> college2.shape
(1184, 27)
- 绝大多数学校没有我们要求的三列数据,但这些数据仍然足够用来继续。接下来,创建一个用户定义的函数来计算 SAT 数学成绩的加权平均值:
>>> def weighted_math_average(df):
weighted_math = df['UGDS'] * df['SATMTMID']
return int(weighted_math.sum() / df['UGDS'].sum())
- 按州分组,并将此函数传递给
apply方法:
>>> college2.groupby('STABBR').apply(weighted_math_average).head()
STABBR
AK 503
AL 536
AR 529
AZ 569
CA 564
dtype: int64
- 我们成功地为每个组返回了一个标量值。让我们稍作绕道,看看如果将相同的函数传递给
agg方法,结果会是什么样的:
>>> college2.groupby('STABBR').agg(weighted_math_average).head()

weighted_math_average函数应用于 DataFrame 中的每个非聚合列。如果你尝试将列限制为仅SATMTMID,你会遇到错误,因为你无法访问UGDS。因此,完成对多个列进行操作的最佳方法是使用apply:
>>> college2.groupby('STABBR')['SATMTMID'] \
.agg(weighted_math_average)
KeyError: 'UGDS'
apply的一个好功能是,你可以通过返回一个 Series 来创建多个新列。这个返回的 Series 的索引将成为新列的名称。让我们修改我们的函数,以计算两个 SAT 分数的加权平均值和算术平均值,并统计每个组中院校的数量。我们将这五个值以 Series 的形式返回:
>>> from collections import OrderedDict
>>> def weighted_average(df):
data = OrderedDict()
weight_m = df['UGDS'] * df['SATMTMID']
weight_v = df['UGDS'] * df['SATVRMID']
wm_avg = weight_m.sum() / df['UGDS'].sum()
wv_avg = weight_v.sum() / df['UGDS'].sum()
data['weighted_math_avg'] = wm_avg
data['weighted_verbal_avg'] = wv_avg
data['math_avg'] = df['SATMTMID'].mean()
data['verbal_avg'] = df['SATVRMID'].mean()
data['count'] = len(df)
return pd.Series(data, dtype='int')
>>> college2.groupby('STABBR').apply(weighted_average).head(10)

它是如何工作的……
为了让这个操作顺利完成,我们首先需要筛选出没有UGDS、SATMTMID和SATVRMID缺失值的院校。默认情况下,dropna方法会删除包含一个或多个缺失值的行。我们必须使用subset参数,限制它检查缺失值的列。
在第 2 步中,我们定义了一个函数,用来计算SATMTMID列的加权平均值。加权平均与算术平均的不同之处在于,每个值会乘以一个权重。然后将这些加权值相加,并除以权重的总和。在这个例子中,我们的权重是本科生人数。
在第 3 步中,我们将这个函数传递给apply方法。我们的函数weighted_math_average会接收每个组的所有原始列的 DataFrame,并返回一个标量值,即SATMTMID的加权平均值。此时,你可能会认为可以使用agg方法来进行此计算。直接用agg替换apply是行不通的,因为agg会为每个聚合列返回一个值。
实际上,可以通过先计算UGDS和SATMTMID的乘积,间接使用agg方法。
第 6 步真正展示了apply的多功能性。我们构建了一个新函数,计算两个 SAT 列的加权平均值、算术平均值以及每个组的行数。为了让apply创建多个列,你必须返回一个 Series。索引值将作为结果 DataFrame 中的列名。你可以用这种方法返回任意数量的值。
注意,OrderedDict类是从collections模块导入的,这个模块是标准库的一部分。这个有序字典用来存储数据。普通的 Python 字典不能用来存储这些数据,因为它不能保持插入顺序。
构造器pd.Series确实有一个index参数,你可以用它来指定顺序,但使用OrderedDict会更简洁。
还有更多……
在这个示例中,我们返回了每个组的单行数据作为一个 Series。通过返回一个 DataFrame,可以为每个组返回任意数量的行和列。除了计算算术和加权平均数之外,我们还要计算两个 SAT 列的几何平均数和调和平均数,并将结果返回为一个 DataFrame,其中行是平均数的类型名称,列是 SAT 类型。为了减轻我们的负担,我们使用了 NumPy 的average函数来计算加权平均数,使用 SciPy 的gmean和hmean函数来计算几何平均数和调和平均数:
>>> from scipy.stats import gmean, hmean
>>> def calculate_means(df):
df_means = pd.DataFrame(index=['Arithmetic', 'Weighted',
'Geometric', 'Harmonic'])
cols = ['SATMTMID', 'SATVRMID']
for col in cols:
arithmetic = df[col].mean()
weighted = np.average(df[col], weights=df['UGDS'])
geometric = gmean(df[col])
harmonic = hmean(df[col])
df_means[col] = [arithmetic, weighted,
geometric, harmonic]
df_means['count'] = len(df)
return df_means.astype(int)
>>> college2.groupby('STABBR').apply(calculate_means).head(12)

参见
-
Pandas 官方文档的
applygroupby 方法(bit.ly/2wmG9ki) -
Python 官方文档的
OrderedDict类(bit.ly/2xwtUCa) -
SciPy 官方文档的统计模块(
bit.ly/2wHtQ4L)
按连续变量分组
在 pandas 中进行分组时,通常使用具有离散重复值的列。如果没有重复值,那么分组就没有意义,因为每个组只有一行。连续的数字列通常重复值较少,通常不会用来分组。然而,如果我们能通过将每个值放入一个区间、四舍五入或使用其他映射,将连续值列转换为离散列,那么使用它们进行分组是有意义的。
准备工作
在这个示例中,我们探索了航班数据集,以发现不同旅行距离下航空公司的分布。例如,这使我们能够找到在 500 到 1000 英里之间飞行次数最多的航空公司。为此,我们使用 pandas 的cut函数来离散化每个航班的距离。
如何操作...
- 读取航班数据集,并输出前五行:
>>> flights = pd.read_csv('data/flights.csv')
>>> flights.head()

- 如果我们想要找到在不同距离范围内的航空公司分布,我们需要将
DIST列的值放入离散的区间中。让我们使用 pandas 的cut函数将数据划分为五个区间:
>>> bins = [-np.inf, 200, 500, 1000, 2000, np.inf]
>>> cuts = pd.cut(flights['DIST'], bins=bins)
>>> cuts.head()
0 (500.0, 1000.0]
1 (1000.0, 2000.0]
2 (500.0, 1000.0]
3 (1000.0, 2000.0]
4 (1000.0, 2000.0]
Name: DIST, dtype: category
Categories (5, interval[float64]): [(-inf, 200.0] < (200.0, 500.0] < (500.0, 1000.0] < (1000.0, 2000.0] < (2000.0, inf]]
- 创建了一个有序分类 Series。为了帮助理解发生了什么,让我们统计一下每个类别的值:
>>> cuts.value_counts()
(500.0, 1000.0] 20659
(200.0, 500.0] 15874
(1000.0, 2000.0] 14186
(2000.0, inf] 4054
(-inf, 200.0] 3719
Name: DIST, dtype: int64
- 现在可以使用
cutsSeries 来形成组。Pandas 允许您以任何方式形成组。将cutsSeries 传递给groupby方法,然后调用value_counts方法来查找每个距离组的分布。注意,SkyWest (OO)在 200 英里以下的航班中占比 33%,但在 200 到 500 英里的航班中仅占 16%:
>>> flights.groupby(cuts)['AIRLINE'].value_counts(normalize=True) \
.round(3).head(15)
DIST AIRLINE
(-inf, 200.0] OO 0.326
EV 0.289
MQ 0.211
DL 0.086
AA 0.052
UA 0.027
WN 0.009
(200.0, 500.0] WN 0.194
DL 0.189
OO 0.159
EV 0.156
MQ 0.100
AA 0.071
UA 0.062
VX 0.028
Name: AIRLINE, dtype: float64
它是如何工作的...
在步骤 2 中,cut函数将DIST列的每个值放入五个箱子之一。箱子的边界是通过一组六个数字定义的。你总是需要比箱子数量多一个边界。你可以将bins参数设置为一个整数,自动创建该数量的等宽箱子。负无穷和正无穷对象在 NumPy 中可用,确保所有值都会被放入箱子中。如果有值超出了箱子的边界,它们将被标记为缺失并不会放入箱子。
cuts变量现在是一个包含五个有序类别的 Series。它拥有所有常规 Series 方法,并且在步骤 3 中,使用value_counts方法来了解其分布情况。
非常有趣的是,pandas 允许你将任何对象传递给groupby方法。这意味着你可以从与当前 DataFrame 完全无关的东西中创建分组。在这里,我们根据cuts变量中的值进行分组。对于每个分组,我们通过将normalize设置为True来使用value_counts找出每个航空公司的航班百分比。
从这个结果中可以得出一些有趣的见解。查看完整结果,SkyWest 是 200 英里以下航程的领先航空公司,但没有超过 2,000 英里的航班。相比之下,美国航空在 200 英里以下的航班数量排名第五,但在 1,000 到 2,000 英里之间的航班数量遥遥领先。
还有更多...
我们可以通过对cuts变量进行分组,获得更多的结果。例如,我们可以找到每个距离分组的第 25、第 50 和第 75 百分位的飞行时间。由于飞行时间以分钟为单位,我们可以除以 60 来得到小时:
>>> flights.groupby(cuts)['AIR_TIME'].quantile(q=[.25, .5, .75]) \
.div(60).round(2)
DIST
(-inf, 200.0] 0.25 0.43
0.50 0.50
0.75 0.57
(200.0, 500.0] 0.25 0.77
0.50 0.92
0.75 1.05
(500.0, 1000.0] 0.25 1.43
0.50 1.65
0.75 1.92
(1000.0, 2000.0] 0.25 2.50
0.50 2.93
0.75 3.40
(2000.0, inf] 0.25 4.30
0.50 4.70
0.75 5.03
Name: AIR_TIME, dtype: float64
我们可以使用这些信息来创建信息性字符串标签,当使用cut函数时,这些标签将替代区间表示法。我们还可以链式调用unstack方法,将内部索引级别转置为列名:
>>> labels=['Under an Hour', '1 Hour', '1-2 Hours',
'2-4 Hours', '4+ Hours']
>>> cuts2 = pd.cut(flights['DIST'], bins=bins, labels=labels)
>>> flights.groupby(cuts2)['AIRLINE'].value_counts(normalize=True) \
.round(3) \
.unstack() \
.style.highlight_max(axis=1)

另请参见
-
Pandas 官方文档中的
cut函数(bit.ly/2whcUkJ) -
请参阅第十四章,将数据重构为整洁的形式,了解更多使用 unstack 的技巧
统计两座城市之间的航班总数
在航班数据集中,我们有关于起点和目的地机场的数据。例如,统计从休斯顿出发并降落在亚特兰大的航班数量是微不足道的。更困难的是统计两座城市之间的航班总数,而不考虑哪座城市是起点或目的地。
准备工作
在这个食谱中,我们统计了两座城市之间的航班总数,而不考虑哪一个是起点或目的地。为此,我们按字母顺序对起点和目的地机场进行排序,使得每一对机场的组合总是按照相同的顺序出现。然后,我们可以使用这种新的列排列方式来形成分组并进行计数。
如何实现...
- 读取航班数据集,并找到每个起始和目的地机场之间的总航班数:
>>> flights = pd.read_csv('data/flights.csv')
>>> flights_ct = flights.groupby(['ORG_AIR', 'DEST_AIR']).size()
>>> flights_ct.head()
ORG_AIR DEST_AIR
ATL ABE 31
ABQ 16
ABY 19
ACY 6
AEX 40
dtype: int64
- 选择休斯顿(IAH)和亚特兰大(ATL)之间两个方向的总航班数:
>>> flights_ct.loc[[('ATL', 'IAH'), ('IAH', 'ATL')]]
ORG_AIR DEST_AIR
ATL IAH 121
IAH ATL 148
dtype: int64
- 我们可以简单地将这两个数字相加以找到城市之间的总航班数,但有一种更有效和自动化的解决方案可以适用于所有航班。让我们独立地按字母顺序对每一行的起始和目的地城市进行排序:
>>> flights_sort = flights[['ORG_AIR', 'DEST_AIR']] \
.apply(sorted, axis=1)
>>> flights_sort.head()

- 现在每行都被独立排序,列名不正确。让我们将它们重命名为更通用的名称,然后再次找到所有城市之间的总航班数:
>>> rename_dict = {'ORG_AIR':'AIR1', 'DEST_AIR':'AIR2'}
>>> flights_sort = flights_sort.rename(columns=rename_dict)
>>> flights_ct2 = flights_sort.groupby(['AIR1', 'AIR2']).size()
>>> flights_ct2.head()
AIR1 AIR2
ABE ATL 31
ORD 24
ABI DFW 74
ABQ ATL 16
DEN 46
dtype: int64
- 让我们选择所有亚特兰大和休斯顿之间的航班,并验证它是否与第 2 步中值的总和相匹配:
>>> flights_ct2.loc[('ATL', 'IAH')]
269
- 如果我们尝试选择休斯顿后面的亚特兰大航班,我们会收到一个错误:
>>> flights_ct2.loc[('IAH', 'ATL')]
IndexingError: Too many indexers
工作原理...
在第一步中,我们通过起始和目的地机场列形成分组,然后将 size 方法应用于 groupby 对象,它简单地返回每个组的总行数。请注意,我们可以将字符串 size 传递给 agg 方法以达到相同的结果。在第二步中,选择了亚特兰大和休斯顿之间每个方向的总航班数。Series flights_count 具有两个级别的 MultiIndex。从 MultiIndex 中选择行的一种方法是向 loc 索引运算符传递一个确切级别值的元组。在这里,我们实际上选择了两行,('ATL', 'HOU') 和 ('HOU', 'ATL')。我们使用一个元组列表来正确执行此操作。
第 3 步是这个步骤中最相关的步骤。我们希望对亚特兰大和休斯顿之间的所有航班只有一个标签,到目前为止我们有两个标签。如果我们按字母顺序对每个起始和目的地机场的组合进行排序,那么我们将有一个单一的标签用于机场之间的航班。为此,我们使用 DataFrame apply 方法。这与 groupby apply 方法不同。在第 3 步中不形成组。
DataFrame apply 方法必须传递一个函数。在这种情况下,它是内置的 sorted 函数。默认情况下,此函数将应用于每一列作为一个 Series。我们可以通过使用 axis=1(或 axis='index')来改变计算的方向。sorted 函数将每一行数据隐式地作为一个 Series 传递给它。它返回一个排序后的机场代码列表。这里是将第一行作为一个 Series 传递给 sorted 函数的示例:
>>> sorted(flights.loc[0, ['ORG_AIR', 'DEST_AIR']])
['LAX', 'SLC']
apply 方法以这种确切的方式使用 sorted 迭代所有行。完成此操作后,每行都会被独立排序。列名现在毫无意义。我们在下一步中重命名列名,然后执行与第 2 步相同的分组和聚合操作。这次,所有亚特兰大和休斯顿之间的航班都归为同一标签。
还有更多...
你可能会想知道为什么我们不能使用更简单的sort_values系列方法。这个方法不能独立排序,而是保留每一行或每一列作为一个完整的记录,正如我们在进行数据分析时所期望的那样。步骤 3 是一个非常耗时的操作,完成需要几秒钟。虽然只有大约 60,000 行数据,但这个解决方案不适合处理更大的数据集。调用
步骤 3 是一个非常耗时的操作,完成需要几秒钟。虽然只有大约 60,000 行数据,但这个解决方案不适合处理更大的数据集。调用apply方法并使用axis=1是所有 pandas 操作中性能最差的之一。在内部,pandas 会对每一行进行循环操作,而无法借助 NumPy 的速度提升。如果可能,尽量避免使用apply和axis=1。
我们可以通过 NumPy 的sort函数显著提高速度。让我们使用这个函数并分析它的输出。默认情况下,它会独立排序每一行:
>>> data_sorted = np.sort(flights[['ORG_AIR', 'DEST_AIR']])
>>> data_sorted[:10]
array([['LAX', 'SLC'],
['DEN', 'IAD'],
['DFW', 'VPS'],
['DCA', 'DFW'],
['LAX', 'MCI'],
['IAH', 'SAN'],
['DFW', 'MSY'],
['PHX', 'SFO'],
['ORD', 'STL'],
['IAH', 'SJC']], dtype=object)
返回的是一个二维的 NumPy 数组。NumPy 不容易进行分组操作,因此我们可以使用 DataFrame 构造函数来创建一个新的 DataFrame,并检查它是否等于步骤 3 中的flights_sorted DataFrame:
>>> flights_sort2 = pd.DataFrame(data_sorted, columns=['AIR1', 'AIR2'])
>>> fs_orig = flights_sort.rename(columns={'ORG_AIR':'AIR1',
'DEST_AIR':'AIR2'})
>>> flights_sort2.equals(fs_orig)
True
由于 DataFrame 是相同的,你可以用之前更快的排序方法替代步骤 3。让我们来对比每种排序方法的时间差异:
>>> %%timeit
>>> flights_sort = flights[['ORG_AIR', 'DEST_AIR']] \
.apply(sorted, axis=1)
7.41 s ± 189 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
>>> %%timeit
>>> data_sorted = np.sort(flights[['ORG_AIR', 'DEST_AIR']])
>>> flights_sort2 = pd.DataFrame(data_sorted,
columns=['AIR1', 'AIR2'])
10.6 ms ± 453 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
NumPy 解决方案比使用 pandas 的apply快了惊人的 700 倍。
另见
- NumPy 官方文档关于
sort函数的说明(bit.ly/2vtRt0M)
查找准时航班的最长连续段
航空公司最重要的指标之一就是它们的准时航班表现。联邦航空管理局(FAA)将航班视为延误航班,如果它比计划到达时间晚了至少 15 分钟。Pandas 提供了直接的方法来计算每个航空公司准时航班的总数和百分比。虽然这些基本的总结统计数据是一个重要的指标,但也有一些其他的非平凡计算很有意思,比如查找每个航空公司在每个起始机场的连续准时航班长度。
准备开始
在这个方案中,我们找到每个航空公司在每个起始机场的最长连续准时航班段。这要求每个列中的值能够察觉到紧跟其后的值。我们巧妙地使用了diff和cumsum方法来找到连续段,在将这种方法应用于每个组之前。
如何做...
- 在我们开始处理实际的航班数据集之前,先练习使用一个小样本 Series 来计数连续的 1:
>>> s = pd.Series([0, 1, 1, 0, 1, 1, 1, 0])
>>> s
0 0
1 1
2 1
3 0
4 1
5 1
6 1
7 0
dtype: int64
- 我们最终得到的连续 1 的表示将是一个与原始数据长度相同的 Series,每一段连续 1 的计数从 1 开始。为了开始,我们使用
cumsum方法:
>>> s1 = s.cumsum()
>>> s1
0 0
1 1
2 2
3 2
4 3
5 4
6 5
7 5
dtype: int64
- 我们现在已经累积了所有沿着序列向下的 1。让我们将这个序列与原始序列相乘:
>>> s.mul(s1)
0 0
1 1
2 2
3 0
4 3
5 4
6 5
7 0
dtype: int64
- 现在我们只有在原始数据中为 1 的地方出现非零值。这个结果与我们期望的非常接近。我们只需要让每个 streak 从 1 重新开始,而不是从累积和的结果开始。让我们连接
diff方法,它会将当前值减去前一个值:
>>> s.mul(s1).diff()
0 NaN
1 1.0
2 1.0
3 -2.0
4 3.0
5 1.0
6 1.0
7 -5.0
dtype: float64
- 负值表示 streak 的结束。我们需要将负值向下传播,并用它们来从步骤 2 中减去多余的累积。为此,我们将使用
where方法将所有非负值设为缺失:
>>> s.mul(s1).diff().where(lambda x: x < 0)
0 NaN
1 NaN
2 NaN
3 -2.0
4 NaN
5 NaN
6 NaN
7 -5.0
dtype: float64
- 我们现在可以使用
ffill方法将这些值向下传播:
>>> s.mul(s1).diff().where(lambda x: x < 0).ffill()
0 NaN
1 NaN
2 NaN
3 -2.0
4 -2.0
5 -2.0
6 -2.0
7 -5.0
dtype: float64
- 最后,我们可以将这个序列加回到
s1中,清除多余的累积:
>>> s.mul(s1).diff().where(lambda x: x < 0).ffill() \
.add(s1, fill_value=0)
0 0.0
1 1.0
2 2.0
3 0.0
4 1.0
5 2.0
6 3.0
7 0.0
dtype: float64
- 现在我们有了一个可以工作的连续 streak 查找器,我们可以找到每个航空公司和起始机场的最长 streak。让我们读取航班数据集,并创建一列来表示准时到达:
>>> flights = pd.read_csv('data/flights.csv')
>>> flights['ON_TIME'] = flights['ARR_DELAY'].lt(15).astype(int)
>>> flights[['AIRLINE', 'ORG_AIR', 'ON_TIME']].head(10)

- 使用我们前七步中的逻辑,定义一个函数来返回给定序列中的最大连续 1:
>>> def max_streak(s):
s1 = s.cumsum()
return s.mul(s1).diff().where(lambda x: x < 0) \
.ffill().add(s1, fill_value=0).max()
- 找到每个航空公司和起始机场的最大准时到达 streak,以及航班总数和准时到达的百分比。首先,按照年份中的日期和计划的起飞时间排序:
>>> flights.sort_values(['MONTH', 'DAY', 'SCHED_DEP']) \
.groupby(['AIRLINE', 'ORG_AIR'])['ON_TIME'] \
.agg(['mean', 'size', max_streak]).round(2).head()

它是如何工作的...
在 pandas 中找到 streak 并不是一个简单的操作,需要使用一些前瞻或回溯的方法,比如diff或shift,或者那些能记住当前状态的方法,比如cumsum。前七步的最终结果是一个与原始序列长度相同的序列,记录了所有连续的 1。在这些步骤中,我们使用了mul和add方法,而不是其运算符等价物(*)和(+)。我认为,这样做可以让计算的过程从左到右更加简洁。你当然可以用实际的运算符来替换它们。
理想情况下,我们希望告诉 pandas 在每个 streak 开始时应用cumsum方法,并在每个 streak 结束后重置它。这需要许多步骤来传达给 pandas。步骤 2 将序列中的所有 1 累积在一起。接下来的步骤则逐渐去除多余的累积。为了识别这些多余的累积,我们需要找到每个 streak 的结束位置,并从下一个 streak 的开始位置减去这个值。
为了找到每个 streak 的结束,我们巧妙地通过将s1与第 3 步中的零和一的原始序列相乘,来将所有不属于 streak 的值变为零。跟随第一个零的非零值标志着一个 streak 的结束。这个方法不错,但我们还需要消除多余的累积。知道 streak 的结束位置并不能完全解决问题。
在第 4 步中,我们使用diff方法来找出这些多余的值。diff方法计算当前值与距离它一定行数的任何值之间的差异。默认情况下,它返回当前值与紧接着的前一个值之间的差异。
只有负值在第 4 步中才是有意义的。这些值位于连续序列的末尾。需要将这些值向下传播,直到下一个连续序列的结束。为了消除(使其缺失)我们不关心的所有值,我们使用where方法,它接受一个与调用的 Series 大小相同的条件 Series。默认情况下,所有True值保持不变,而False值则变为缺失。where方法允许你通过将一个函数作为其第一个参数来使用调用的 Series 作为条件的一部分。这里使用了一个匿名函数,它隐式地接受调用的 Series 并检查每个值是否小于零。第 5 步的结果是一个 Series,其中只有负值被保留,其余的都变成缺失值。
第 6 步中的ffill方法将缺失值替换为向前(或向下)传播的最后一个非缺失值。由于前面三个值没有跟随任何非缺失值,因此它们保持为缺失值。最终,我们得到了一个移除多余累积的 Series。我们将这个累积 Series 与第 6 步的结果相加,从而得到所有从零开始的连续序列。add方法允许我们使用fill_value参数替换缺失值。这个过程完成了在数据集中查找连续的 1 值序列。当做复杂逻辑处理时,最好使用一个小数据集,这样你可以预知最终结果。如果从第 8 步开始并在分组时构建这个查找连续序列的逻辑,那将是一个非常困难的任务。
在第 8 步中,我们创建了ON_TIME列。需要注意的一点是,取消的航班在ARR_DELAY列中有缺失值,这些缺失值无法通过布尔条件,因此在ON_TIME列中会显示为零。取消的航班与延误航班一样处理。
第 9 步将我们前七步的逻辑转化为一个函数,并链式调用max方法以返回最长的连续序列。由于我们的函数返回单一值,它正式成为一个聚合函数,并可以像第 10 步中那样传递给agg方法。为了确保我们正在查看实际的连续航班,我们使用sort_values方法按日期和预定出发时间进行排序。
还有更多内容...
现在我们已经找到了最长的按时到达连续序列,我们可以轻松地找到相反的情况——最长的延误到达连续序列。以下函数将返回传递给它的每个组的两行。第一行是连续序列的开始,最后一行是结束。每一行都包含了该序列开始/结束的月份和日期,以及连续序列的总长度:
>>> def max_delay_streak(df):
df = df.reset_index(drop=True)
s = 1 - df['ON_TIME']
s1 = s.cumsum()
streak = s.mul(s1).diff().where(lambda x: x < 0) \
.ffill().add(s1, fill_value=0)
last_idx = streak.idxmax()
first_idx = last_idx - streak.max() + 1
df_return = df.loc[[first_idx, last_idx], ['MONTH', 'DAY']]
df_return['streak'] = streak.max()
df_return.index = ['first', 'last']
df_return.index.name='type'
return df_return
>>> flights.sort_values(['MONTH', 'DAY', 'SCHED_DEP']) \
.groupby(['AIRLINE', 'ORG_AIR']) \
.apply(max_delay_streak) \
.sort_values('streak', ascending=False).head(10)

在我们使用apply的 groupby 方法时,每个组的 DataFrame 会被传递给max_delay_streak函数。在这个函数内部,DataFrame 的索引会被删除,并用RangeIndex替换,这样我们可以轻松找到连续记录的第一行和最后一行。接着,ON_TIME列会被反转,然后使用相同的逻辑来查找延误航班的连续记录。连续记录的第一行和最后一行的索引会被存储为变量。这些索引随后用于选择连续记录结束的月份和日期。我们使用 DataFrame 来返回结果,并为索引添加标签和名称,以便让最终结果更清晰。
我们的最终结果显示了最长的延迟连续记录,并伴随有起始日期和结束日期。让我们来调查一下,看看是否能找出这些延迟发生的原因。恶劣天气是航班延误或取消的常见原因。查看第一行,来自美国航空公司(AA)的航班从 2015 年 2 月 26 日开始,从达拉斯沃斯堡机场(DFW)起飞,连续 38 个航班发生延误,直到 3 月 1 日。根据 2015 年 2 月 27 日的历史天气数据,当天降雪达到两英寸,这也是当天的降雪记录(bit.ly/2iLGsCg)。这场大规模的天气事件对 DFW 造成了严重影响,并给整个城市带来了巨大的麻烦(bit.ly/2wmsHPj)。请注意,DFW 再次出现,成为第三长的延误记录,但这次发生的时间稍早一些,并且是由另一家航空公司造成的。
另见
- Pandas 官方文档的
ffill(bit.ly/2gn5zGU)
第十四章:将数据重构为整洁形式
前几章中使用的所有数据集都没有经过太多或任何结构变化的处理。我们直接开始在原始形态下处理这些数据集。许多实际中的数据集在开始更详细分析之前需要进行大量的重构。在某些情况下,整个项目可能仅仅是为了将数据格式化成某种方式,以便其他人能够轻松处理。
在本章中,我们将讨论以下主题:
-
使用
stack将变量值整洁化为列名 -
使用
melt将变量值整洁化为列名 -
同时堆叠多个变量组
-
反转堆叠数据
-
在
groupby聚合后进行反向堆叠 -
使用
groupby聚合实现pivot_table的功能 -
为了方便重构而重命名轴级
-
当多个变量作为列名存储时的整洁化
-
当多个变量存储为列值时的整洁化
-
当两个或多个值存储在同一单元格中时的整洁化
-
当变量存储在列名和列值中时的整洁化
-
当多个观察单元存储在同一张表格中时进行整洁化
用于描述数据重构过程的术语有很多,其中整洁数据是数据科学家最常用的术语。整洁数据是 Hadley Wickham 创造的术语,用来描述一种便于分析的数据形式。本章将讨论 Hadley 提出的许多想法以及如何通过 pandas 实现这些想法。要深入了解整洁数据,请阅读 Hadley 的论文(vita.had.co.nz/papers/tidy-data.pdf)。
什么是整洁数据?Hadley 提出了三个简单的指导原则来决定一个数据集是否整洁:
-
每个变量形成一列
-
每个观察形成一行
-
每种类型的观察单元形成一个表格
任何不符合这些指南的数据集都被认为是杂乱的。这个定义在我们开始将数据重构为整洁形式后会更有意义,但现在我们需要知道什么是变量、观察和观察单元。
为了更好地理解变量究竟是什么,考虑一下变量名与变量值的区别是非常有帮助的。变量名是标签,如性别、种族、薪资和职位;变量值则是那些在每次观察中可能变化的内容,如性别的“男/女”或种族的“白人/黑人”。一个单独的观察就是一个观察单元所有变量值的集合。为了帮助理解观察单元的概念,假设有一家零售店,它有每个交易、员工、顾客、商品和店铺本身的数据。每个都可以被认为是一个观察单元,需要自己独立的表格。将员工信息(如工作时长)与顾客信息(如消费金额)放在同一张表格中会破坏整洁化原则。
解决杂乱数据的第一步是识别它的存在,而杂乱数据的种类有无穷多种。Hadley 明确提到五种最常见的杂乱数据类型:
-
列名是值,而不是变量名
-
多个变量存储在列名中
-
变量同时存储在行和列中
-
不同类型的观察单元存储在同一个表格中
-
单一观察单元存储在多个表格中
需要理解的是,整理数据通常不涉及更改数据集的值、填补缺失值或进行任何类型的分析。整理数据涉及改变数据的形状或结构,以符合整洁原则。整洁数据类似于将所有工具放在工具箱里,而不是散乱地放在房子各处。将工具正确地放入工具箱,可以让所有其他任务更容易完成。一旦数据以正确的形式存在,进行进一步分析就变得更加容易。
一旦你发现了杂乱数据,就可以使用 pandas 工具来重构数据,使其变得整洁。pandas 提供的主要整洁工具包括 DataFrame 方法stack、melt、unstack和pivot。更复杂的整理需要拆解文本,这就需要使用str访问器。其他辅助方法,如rename、rename_axis、reset_index和set_index,有助于在整洁数据上做最后的修饰。
使用 stack 将变量值作为列名整理
为了帮助理解整洁数据和杂乱数据之间的区别,让我们看一下一个简单的表格,它可能是整洁形式,也可能不是:
>>> state_fruit = pd.read_csv('data/state_fruit.csv', index_col=0)
>>> state_fruit

这个表格似乎没有什么杂乱,信息也很容易读取。然而,根据整洁原则,它实际上并不整洁。每个列名实际上是一个变量的值。事实上,数据框中甚至没有出现任何变量名。将杂乱数据集转换为整洁数据的第一步是识别所有变量。在这个数据集中,我们有state和fruit两个变量。还有数字数据,它在问题的上下文中并没有被明确识别。我们可以将这个变量标记为weight或任何其他合理的名称。
准备工作
这个特别的杂乱数据集将变量值作为列名存储。我们需要将这些列名转换为列值。在这个示例中,我们使用stack方法将 DataFrame 重构为整洁形式。
如何做到这一点...
- 首先,注意到州名在 DataFrame 的索引中。这些州名正确地垂直排列,不需要重新结构化。问题出在列名上。
stack方法将所有列名转换为垂直排列,作为一个单独的索引层级:
>>> state_fruit.stack()
Texas Apple 12
Orange 10
Banana 40
Arizona Apple 9
Orange 7
Banana 12
Florida Apple 0
Orange 14
Banana 190
dtype: int64
- 注意,我们现在有一个带有 MultiIndex 的 Series。索引现在有两个级别。原始的索引被推到左侧,为旧的列名腾出空间。通过这一条命令,我们现在基本上得到了整洁的数据。每个变量、状态、水果和重量都是垂直排列的。我们可以使用
reset_index方法将结果转换为 DataFrame:
>>> state_fruit_tidy = state_fruit.stack().reset_index()
>>> state_fruit_tidy

- 现在我们的结构已经正确,但列名没有意义。让我们用适当的标识符替换它们:
>>> state_fruit_tidy.columns = ['state', 'fruit', 'weight']
>>> state_fruit_tidy

- 除了直接改变
columns属性,还可以使用不太为人所知的 Series 方法rename_axis在使用reset_index之前设置索引级别的名称:
>>> state_fruit.stack()\
.rename_axis(['state', 'fruit'])
state fruit
Texas Apple 12
Orange 10
Banana 40
Arizona Apple 9
Orange 7
Banana 12
Florida Apple 0
Orange 14
Banana 190
dtype: int64
- 从这里,我们可以简单地将
reset_index方法与name参数链式调用,以再现第三步的输出:
>>> state_fruit.stack()\
.rename_axis(['state', 'fruit'])\
.reset_index(name='weight')
它是如何工作的...
stack方法非常强大,需要一定时间才能完全理解和掌握。它将所有列名转置,使它们成为新的最内层索引级别。注意,每个旧的列名仍然通过与每个状态配对来标记其原始值。在一个 3 x 3 的 DataFrame 中有九个原始值,它们被转化成一个包含相同数量值的单列 Series。原来的第一行数据变成了结果 Series 中的前三个值。
在第二步重置索引后,pandas 会默认将我们的 DataFrame 列命名为level_0、level_1和0。这是因为调用此方法的 Series 有两个未命名的索引级别。Pandas 还会将索引从外部按从零开始的整数进行引用。
第三步展示了一种简单直观的方式来重命名列。你可以通过将columns属性设置为一个列表,直接为整个 DataFrame 设置新的列名。
另外,可以通过链式调用rename_axis方法一步设置列名。当传递一个列表作为第一个参数时,Pandas 使用这些值作为索引级别名称。Pandas 在重置索引时,会将这些索引级别名称作为新的列名。此外,reset_index方法有一个name参数,对应 Series 值的新列名。
所有 Series 都有一个name属性,可以直接设置或通过rename方法设置。正是这个属性在使用reset_index时成为列名。
还有更多...
使用stack的一个关键点是将所有不希望转换的列放在索引中。本例中的数据集最初是将状态作为索引读取的。我们来看一下如果没有将状态读入索引会发生什么:
>>> state_fruit2 = pd.read_csv('data/state_fruit2.csv')
>>> state_fruit2

由于状态名称不在索引中,在这个 DataFrame 上使用stack会将所有值重塑为一个长 Series:
>>> state_fruit2.stack()
0 State Texas
Apple 12
Orange 10
Banana 40
1 State Arizona
Apple 9
Orange 7
Banana 12
2 State Florida
Apple 0
Orange 14
Banana 190
dtype: object
此命令重新整形了所有列,这次包括状态,并且完全不符合我们的需求。为了正确地重塑这些数据,您需要首先使用set_index方法将所有非重塑的列放入索引中,然后使用stack。以下代码提供了与步骤 1 类似的结果:
>>> state_fruit2.set_index('State').stack()
参见
-
Pandas 官方文档关于重塑和透视表 (
bit.ly/2xbnNms) -
Pandas 官方文档关于
stack方法 (bit.ly/2vWZhH1)
使用 melt 方法整理变量值作为列名
像大多数大型 Python 库一样,pandas 有许多完成相同任务的不同方法--区别通常在于可读性和性能。Pandas 包含一个名为melt的 DataFrame 方法,其工作方式与前面示例中描述的stack方法类似,但提供了更多灵活性。
在 pandas 版本 0.20 之前,melt仅作为一个函数提供,需要通过pd.melt访问。Pandas 仍然是一个不断发展的库,您需要预期每个新版本都会带来变化。Pandas 一直在推动将所有仅对 DataFrame 操作的函数移动到方法中,就像他们对melt所做的那样。这是使用melt的首选方式,也是本示例使用的方式。查看 pandas 文档中的新内容部分,以了解所有更改 (bit.ly/2xzXIhG)。
准备工作
在这个示例中,我们使用melt方法来整理一个包含变量值作为列名的简单 DataFrame。
如何实现...
- 读取
state_fruit2数据集并确定哪些列需要转换,哪些不需要:
>>> state_fruit2 = pd.read_csv('data/state_fruit2.csv')
>>> state_fruit2

- 通过将适当的列传递给
id_vars和value_vars参数来使用melt方法:
>>> state_fruit2.melt(id_vars=['State'],
value_vars=['Apple', 'Orange', 'Banana'])

- 这一步为我们创建了整洁的数据。默认情况下,
melt将转换前的列名称为variable,相应的值称为value。方便地,melt还有两个额外的参数,var_name和value_name,允许您重新命名这两列:
>>> state_fruit2.melt(id_vars=['State'],
value_vars=['Apple', 'Orange', 'Banana'],
var_name='Fruit',
value_name='Weight')

工作原理...
melt方法功能强大,可以显著重塑您的 DataFrame。它最多接受五个参数,其中两个对于理解如何正确重塑数据至关重要:
-
id_vars是您想要保留为列而不重塑的列名列表 -
value_vars是您想要重塑为单列的列名列表
id_vars,或者称为标识变量,将保留在同一列中,但对于传递给 value_vars 的每一列都会重复。一项关键的 melt 特性是它会忽略索引中的值,实际上它会默默地丢弃你的索引,并用默认的 RangeIndex 代替。这意味着,如果你的索引中有你希望保留的值,你需要先重置索引,然后再使用 melt。
将水平列名转换为垂直列值的过程通常称为 melt、stacking 或 unpivoting。
还有更多...
melt 方法的所有参数都是可选的,如果你希望所有的值都在一列中,而它们原来的列标签在另一列中,你可以只使用 melt 的默认值来调用它:
>>> state_fruit2.melt()

更实际的情况是,你可能有许多需要“融化”的变量,并且只想指定标识变量。在这种情况下,以以下方式调用 melt 将得到与步骤 2 相同的结果。实际上,当只融化单列时,你甚至不需要列表,可以直接传递它的字符串值:
>>> state_fruit2.melt(id_vars='State')
另见
-
Pandas 官方文档中的
melt方法(bit.ly/2vcuZNJ) -
Pandas 开发者讨论将
melt和其他类似函数转换为方法的内容(bit.ly/2iqIQhI)
同时堆叠多个变量组
一些数据集包含多个作为列名的变量组,这些变量需要同时堆叠到它们自己的列中。以下是 movie 数据集的一个例子,可以帮助澄清这一点。我们首先选择所有包含演员姓名及其对应 Facebook 点赞数的列:
>>> movie = pd.read_csv('data/movie.csv')
>>> actor = movie[['movie_title', 'actor_1_name',
'actor_2_name', 'actor_3_name',
'actor_1_facebook_likes',
'actor_2_facebook_likes',
'actor_3_facebook_likes']]
>>> actor.head()

如果我们将变量定义为电影标题、演员姓名和 Facebook 点赞数,那么我们将需要独立堆叠两组列,这在使用单次调用 stack 或 melt 时是不可能实现的。
准备开始
在本教程中,我们将使用 wide_to_long 函数同时堆叠演员姓名和对应的 Facebook 点赞数,从而整理我们的 actor 数据框。
如何做到这一点...
- 我们将使用多功能的
wide_to_long函数将数据重塑为整洁的格式。为了使用此函数,我们需要更改要堆叠的列名,使其以数字结尾。我们首先创建一个用户定义的函数来更改列名:
>>> def change_col_name(col_name):
col_name = col_name.replace('_name', '')
if 'facebook' in col_name:
fb_idx = col_name.find('facebook')
col_name = col_name[:5] + col_name[fb_idx - 1:] \
+ col_name[5:fb_idx-1]
return col_name
- 将此函数传递给
rename方法来转换所有列名:
>>> actor2 = actor.rename(columns=change_col_name)
>>> actor2.head()

- 使用
wide_to_long函数同时堆叠演员和 Facebook 列集:
>>> stubs = ['actor', 'actor_facebook_likes']
>>> actor2_tidy = pd.wide_to_long(actor2,
stubnames=stubs,
i=['movie_title'],
j='actor_num',
sep='_')
>>> actor2_tidy.head()

它是如何工作的...
wide_to_long函数的工作方式相当具体。它的主要参数是stubnames,这是一个字符串列表。每个字符串代表一个单独的列分组。所有以此字符串开头的列将被堆叠到一个单独的列中。在这个例子中,有两组列:actor和actor_facebook_likes。默认情况下,每一组列需要以数字结尾。这个数字随后将用于标记重新塑形的数据。每个列组的列名中都有一个下划线字符,将stubname与结尾的数字分开。为了考虑到这一点,您必须使用sep参数。
原始列名与wide_to_long所需的模式不匹配。列名可以通过手动精确指定其值的列表来更改。这样可能会需要大量的输入,因此,我们定义了一个函数,自动将列转换为有效的格式。change_col_name函数从演员列中删除_name,并重新排列 Facebook 列,使它们都以数字结尾。
为了实际完成列重命名,我们在第二步中使用了rename方法。它接受多种不同类型的参数,其中之一是一个函数。当将其传递给一个函数时,每个列名会逐一隐式地传递给该函数。
我们现在已经正确创建了两组独立的列,这些列分别以actor和actor_facebook_likes开头,将被堆叠。除此之外,wide_to_long需要一个独特的列,参数i,作为标识变量,该变量不会被堆叠。另一个必需的参数是j,它仅仅是将原始列名末尾的标识数字重命名。默认情况下,前缀参数包含正则表达式,\d+,它用于搜索一个或多个数字。\d是一个特殊的标记,表示匹配数字 0-9。加号+使表达式匹配一个或多个数字。
要成为str方法的强大用户,您需要熟悉正则表达式,正则表达式是一系列字符,用于匹配文本中的特定模式。它们由元字符组成,这些字符具有特殊含义,以及字面字符。为了让自己更好地使用正则表达式,您可以查看Regular-Expressions.info的这个简短教程(bit.ly/2wiWPbz)。
还有更多...
wide_to_long函数适用于所有变量分组具有相同数字结尾的情况,就像这个例子中一样。当您的变量没有相同的结尾,或者没有以数字结尾时,您仍然可以使用wide_to_long进行列堆叠。例如,来看一下以下数据集:
>>> df = pd.read_csv('data/stackme.csv')
>>> df

假设我们想将列 a1 和 b1 堆叠在一起,同时将列 d 和 e 也堆叠在一起。除此之外,我们还希望使用 a1 和 b1 作为行标签。为此,我们需要重新命名列,使其以所需的标签结尾:
>>> df2 = df.rename(columns = {'a1':'group1_a1', 'b2':'group1_b2',
'd':'group2_a1', 'e':'group2_b2'})
>>> df2

然后,我们需要修改后缀参数,默认情况下该参数是一个正则表达式,用来选择数字。这里,我们只需告诉它查找任意数量的字符:
>>> pd.wide_to_long(df2,
stubnames=['group1', 'group2'],
i=['State', 'Country', 'Test'],
j='Label',
suffix='.+',
sep='_')

参见
- Pandas 官方文档关于
wide_to_long(bit.ly/2xb8NVP)
反转堆叠数据
DataFrame 有两个相似的方法,stack 和 melt,可以将水平的列名转换为垂直的列值。DataFrame 还可以通过 unstack 和 pivot 方法分别直接反转这两个操作。stack/unstack 是较简单的方法,只能控制列/行索引,而 melt/pivot 提供了更多的灵活性,可以选择哪些列需要被重塑。
准备工作
在这个示例中,我们将使用 stack/melt 处理数据集,并通过 unstack/pivot 迅速将其恢复到原始形式。
如何操作...
- 读取
college数据集,并将机构名称作为索引,仅包括本科种族列:
>>> usecol_func = lambda x: 'UGDS_' in x or x == 'INSTNM'
>>> college = pd.read_csv('data/college.csv',
index_col='INSTNM',
usecols=usecol_func)
>>> college.head()

- 使用
stack方法将每个水平列名转换为垂直索引级别:
>>> college_stacked = college.stack()
>>> college_stacked.head(18)
INSTNM
Alabama A & M University UGDS_WHITE 0.0333
UGDS_BLACK 0.9353
UGDS_HISP 0.0055
UGDS_ASIAN 0.0019
UGDS_AIAN 0.0024
UGDS_NHPI 0.0019
UGDS_2MOR 0.0000
UGDS_NRA 0.0059
UGDS_UNKN 0.0138
University of Alabama at Birmingham UGDS_WHITE 0.5922
UGDS_BLACK 0.2600
UGDS_HISP 0.0283
UGDS_ASIAN 0.0518
UGDS_AIAN 0.0022
UGDS_NHPI 0.0007
UGDS_2MOR 0.0368
UGDS_NRA 0.0179
UGDS_UNKN 0.0100
dtype: float64
- 使用
unstackSeries 方法将堆叠的数据恢复为其原始形式:
>>> college_stacked.unstack()
- 可以通过
melt然后pivot进行类似的操作。首先,读取数据时不将机构名称放入索引:
>>> college2 = pd.read_csv('data/college.csv',
usecols=usecol_func)
>>> college2.head()

- 使用
melt方法将所有的种族列转换为单一列:
>>> college_melted = college2.melt(id_vars='INSTNM',
var_name='Race',
value_name='Percentage')
>>> college_melted.head()

- 使用
pivot方法反转之前的结果:
>>> melted_inv = college_melted.pivot(index='INSTNM',
columns='Race',
values='Percentage')
>>> melted_inv.head()

- 请注意,机构名称现在已经被移到索引中,并且顺序发生了变化。列名也不再是原始顺序。要完全恢复步骤 4 中的原始 DataFrame,可以使用
.loc索引操作符同时选择行和列,然后重置索引:
>>> college2_replication = melted_inv.loc[college2['INSTNM'],
college2.columns[1:]]\
.reset_index()
>>> college2.equals(college2_replication)
True
工作原理...
在步骤 1 中有多种方法可以实现相同的结果。这里,我们展示了 read_csv 函数的多样性。usecols 参数接受我们想要导入的列的列表,也可以是一个动态确定列的函数。我们使用了一个匿名函数来检查列名是否包含 UGDS_ 或等于 INSTNM。该函数将列名作为字符串传入,必须返回布尔值。这样可以节省大量内存。
第 2 步中的stack方法将所有列名放入最内层的索引级别,并返回一个 Series。在第 3 步中,unstack方法通过将最内层索引级别中的所有值转换为列名,反转了这一操作。
第 3 步的结果并不是第 1 步的完全复制。存在整行缺失值,默认情况下,stack方法会在第 2 步时丢弃这些值。要保留这些缺失值并实现精确复制,可以在stack方法中使用dropna=False。
第 4 步读取与第 1 步相同的数据集,但由于melt方法无法访问,机构名称没有被放入索引中。第 5 步使用melt方法转置所有的Race列。通过将value_vars参数保持为默认值None来实现这一点。未指定时,所有不在id_vars参数中的列都会被转置。
第 6 步使用pivot方法反转第 5 步的操作,pivot方法接受三个参数。每个参数都作为字符串引用单独的列。index参数引用的列保持垂直,并成为新的索引。columns参数引用的列的值变为新的列名。values参数引用的列的值会在其原始索引和列标签交叉处进行排列。
为了用pivot实现精确复制,我们需要按照原始数据集中的顺序对行和列进行排序。由于机构名称在索引中,我们使用.loc索引运算符作为排序 DataFrame 的方式,以便按照原始索引顺序排列。
还有更多内容...
为了更好地理解stack/unstack,我们将它们用于转置college数据集。
在这个上下文中,我们使用的是矩阵转置的精确定义,其中新的行是原始数据矩阵中的旧列。
如果查看第 2 步的输出,你会注意到有两个索引级别。默认情况下,unstack方法使用最内层的索引级别作为新的列值。索引级别从外到内编号,从 0 开始。Pandas 将unstack方法的level参数默认为-1,表示最内层索引。我们可以使用level=0来unstack最外层的列:
>>> college.stack().unstack(0)

其实,有一种非常简单的方法可以转置 DataFrame,无需使用stack或unstack,只需使用transpose方法或T属性,如下所示:
>>> college.T
>>> college.transpose()
另见
-
请参阅第十章中的同时选择 DataFrame 行和列部分,选择数据子集。
-
Pandas 官方文档中的
unstack(bit.ly/2xIyFvr)和pivot(bit.ly/2f3qAWP)方法
在groupby聚合后进行 unstack 操作
按单个列对数据进行分组并对单列进行聚合,返回的结果简单直观,易于使用。当按多个列分组时,聚合结果可能不会以易于理解的方式结构化。由于groupby操作默认将唯一的分组列放入索引中,unstack方法可以非常有用,用于重新排列数据,以便以更有利于解读的方式呈现数据。
准备工作
在这个例子中,我们使用employee数据集进行聚合,按多个列分组。然后使用unstack方法重新调整结果的格式,使不同组的比较变得更加容易。
如何做到...
- 读取员工数据集并按种族计算平均薪资:
>>> employee = pd.read_csv('data/employee.csv')
>>> employee.groupby('RACE')['BASE_SALARY'].mean().astype(int)
RACE
American Indian or Alaskan Native 60272
Asian/Pacific Islander 61660
Black or African American 50137
Hispanic/Latino 52345
Others 51278
White 64419
Name: BASE_SALARY, dtype: int64
- 这是一个非常简单的
groupby操作,返回一个易于读取且无需重塑的 Series。现在让我们通过性别计算所有种族的平均薪资:
>>> agg = employee.groupby(['RACE', 'GENDER'])['BASE_SALARY'] \
.mean().astype(int)
>>> agg
RACE GENDER
American Indian or Alaskan Native Female 60238
Male 60305
Asian/Pacific Islander Female 63226
Male 61033
Black or African American Female 48915
Male 51082
Hispanic/Latino Female 46503
Male 54782
Others Female 63785
Male 38771
White Female 66793
Male 63940
Name: BASE_SALARY, dtype: int64
- 这个聚合更复杂,可以重新调整形状,使不同的比较变得更容易。例如,如果男性与女性在每个种族中的薪资并排显示,而不是像现在这样垂直显示,那么比较将会更加容易。让我们对性别索引级别进行 unstack 操作:
>>> agg.unstack('GENDER')

- 同样,我们也可以对种族索引级别进行
unstack操作:
>>> agg.unstack('RACE')

它是如何工作的...
第一步是最简单的聚合操作,只有一个分组列(RACE)、一个聚合列(BASE_SALARY)和一个聚合函数(mean)。这个结果易于消费,不需要进一步处理。第二步稍微增加了复杂性,按种族和性别一起分组。结果是一个 MultiIndex Series,所有值都在一个维度中,这使得比较变得更困难。为了使信息更容易消费,我们使用unstack方法将某一(或多个)级别的值转换为列。
默认情况下,unstack使用最内层的索引级别作为新的列。你可以通过level参数指定你想要进行 unstack 的确切级别,level参数接受级别名称(作为字符串)或级别的整数位置。为了避免歧义,最好使用级别名称而非整数位置。步骤 3 和步骤 4 对每个级别执行 unstack 操作,结果是一个具有单级索引的 DataFrame。现在,通过性别比较每个种族的薪资就容易得多。
还有更多内容...
如果有多个分组和聚合列,那么立即得到的将是一个 DataFrame,而不是 Series。例如,让我们计算多个聚合,而不仅仅是第一步中的均值:
>>> agg2 = employee.groupby(['RACE', 'GENDER'])['BASE_SALARY'] \
.agg(['mean', 'max', 'min']).astype(int)
>>> agg2

对Gender列进行 unstack 操作将导致 MultiIndex 列。从这里开始,你可以使用unstack和stack方法交换行列层级,直到达到你想要的数据结构:
>>> agg2.unstack('GENDER')

另见
- 参考使用多个列进行分组和聚合的食谱以及第十三章中的函数,用于聚合、过滤和转换的分组
使用 groupby 聚合复制 pivot_table
乍一看,pivot_table方法似乎提供了一种独特的数据分析方式。然而,经过稍微处理后,完全可以通过groupby聚合来复制其功能。了解这种等价性可以帮助缩小 pandas 功能的范围。
准备工作
在这个例子中,我们使用flights数据集创建一个透视表,然后通过groupby操作重新创建它。
如何做...
- 读取航班数据集,并使用
pivot_table方法查找每个航空公司从每个起飞机场出发的取消航班总数:
>>> flights = pd.read_csv('data/flights.csv')
>>> fp = flights.pivot_table(index='AIRLINE',
columns='ORG_AIR',
values='CANCELLED',
aggfunc='sum',
fill_value=0).round(2)
>>> fp.head()

groupby聚合不能直接复制此表。诀窍是先根据index和columns参数中的所有列进行分组:
>>> fg = flights.groupby(['AIRLINE', 'ORG_AIR'])['CANCELLED'].sum()
>>> fg.head()
AIRLINE ORG_AIR
AA ATL 3
DEN 4
DFW 86
IAH 3
LAS 3
Name: CANCELLED, dtype: int64
- 使用
unstack方法将ORG_AIR索引层级透视为列名:
>>> fg_unstack = fg.unstack('ORG_AIR', fill_value=0)
>>> fp.equals(fg_unstack)
True
它是如何工作的...
pivot_table方法非常灵活且多功能,但执行的操作与groupby聚合非常相似,第一步展示了一个简单的例子。index参数接受一个(或多个)不会被透视的列,并将这些列的唯一值放置在索引中。columns参数接受一个(或多个)将被透视的列,并将这些列的唯一值转换为列名。values参数接受一个(或多个)将被聚合的列。
还有一个aggfunc参数,它接受一个聚合函数(或多个函数),决定如何对values参数中的列进行聚合。默认情况下为均值,在这个例子中,我们将其更改为计算总和。此外,某些AIRLINE和ORG_AIR的唯一组合并不存在。这些缺失的组合将在结果 DataFrame 中默认显示为缺失值。在这里,我们使用fill_value参数将它们更改为零。
第 2 步开始复制过程,使用 index 和 columns 参数中的所有列作为分组列。这是使此方法有效的关键。透视表实际上是所有分组列唯一组合的交集。第 3 步通过使用 unstack 方法将最内层的索引级别转换为列名,完成了复制过程。就像使用 pivot_table 一样,并不是所有 AIRLINE 和 ORG_AIR 的组合都存在;我们再次使用 fill_value 参数将这些缺失的交集强制为零。
还有更多…
通过 groupby 聚合,可以复制更复杂的透视表。例如,取 pivot_table 的以下结果:
>>> flights.pivot_table(index=['AIRLINE', 'MONTH'],
columns=['ORG_AIR', 'CANCELLED'],
values=['DEP_DELAY', 'DIST'],
aggfunc=[np.sum, np.mean],
fill_value=0)

若要通过 groupby 聚合复制此操作,只需按照食谱中的相同模式,将 index 和 columns 参数中的所有列放入 groupby 方法中,然后使用 unstack 处理列:
>>> flights.groupby(['AIRLINE', 'MONTH', 'ORG_AIR', 'CANCELLED']) \
['DEP_DELAY', 'DIST'] \
.agg(['mean', 'sum']) \
.unstack(['ORG_AIR', 'CANCELLED'], fill_value=0) \
.swaplevel(0, 1, axis='columns')
有一些区别。pivot_table 方法在作为列表传递时,不像 agg 的 groupby 方法那样接受作为字符串的聚合函数。相反,您必须使用 NumPy 函数。列级别的顺序也有所不同,pivot_table 将聚合函数放在 values 参数中的列之前的一个级别。这可以通过 swaplevel 方法来统一,在此实例中,它交换了前两个级别的顺序。
截至本书撰写时,当堆叠多个列时,存在一个 bug。fill_value 参数会被忽略(bit.ly/2jCPnWZ)。为了解决这个 bug,可以在代码末尾链接 .fillna(0)。
重命名轴级别以便于重新塑形
当每个轴(索引/列)级别都有名称时,使用 stack/unstack 方法进行重新塑形要容易得多。Pandas 允许用户通过整数位置或名称引用每个轴级别。由于整数位置是隐式的而非显式的,因此建议尽可能使用级别名称。这个建议来自于 The Zen of Python(bit.ly/2xE83uC),它是 Python 的一组指导原则,其中第二条是 显式优于隐式。
准备工作
当按多个列进行分组或聚合时,结果的 pandas 对象将在一个或两个轴上具有多个级别。在这个示例中,我们将为每个轴的每个级别命名,然后使用 stack/unstack 方法大幅度地重新塑形数据,直到得到所需的形式。
如何做…
- 读取大学数据集,并根据院校和宗教背景,找到一些本科生人口和 SAT 数学成绩的基本统计数据:
>>> college = pd.read_csv('data/college.csv')
>>> cg = college.groupby(['STABBR', 'RELAFFIL']) \
['UGDS', 'SATMTMID'] \
.agg(['size', 'min', 'max']).head(6)

- 请注意,两个索引级别都有名称,并且是旧的列名。另一方面,列级别没有名称。使用
rename_axis方法为它们提供级别名称:
>>> cg = cg.rename_axis(['AGG_COLS', 'AGG_FUNCS'], axis='columns')
>>> cg

- 现在每个轴级别都有了名称,重塑变得轻而易举。使用
stack方法将AGG_FUNCS列移动到索引级别:
>>> cg.stack('AGG_FUNCS').head()

- 默认情况下,堆叠将新的列级别放在最内层的位置。使用
swaplevel方法可以交换级别的位置:
>>> cg.stack('AGG_FUNCS').swaplevel('AGG_FUNCS', 'STABBR',
axis='index').head()

- 我们可以继续通过使用
sort_index方法根据轴级别的名称对级别进行排序:
>>> cg.stack('AGG_FUNCS') \
.swaplevel('AGG_FUNCS', 'STABBR', axis='index') \
.sort_index(level='RELAFFIL', axis='index') \
.sort_index(level='AGG_COLS', axis='columns').head(6)

- 为了完全重塑数据,您可能需要在堆叠某些列的同时取消堆叠其他列。将这两个方法链式结合在一个命令中:
>>> cg.stack('AGG_FUNCS').unstack(['RELAFFIL', 'STABBR'])

- 一次性堆叠所有列以返回一个 Series:
>>> cg.stack(['AGG_FUNCS', 'AGG_COLS']).head(12)
STABBR RELAFFIL AGG_FUNCS AGG_COLS
AK 0 count UGDS 7.0
SATMTMID 0.0
min UGDS 109.0
max UGDS 12865.0
1 count UGDS 3.0
SATMTMID 1.0
min UGDS 27.0
SATMTMID 503.0
max UGDS 275.0
SATMTMID 503.0
AL 0 count UGDS 71.0
SATMTMID 13.0
dtype: float64
它是如何工作的...
groupby 聚合的结果通常会产生具有多个轴级别的 DataFrame 或 Series。在步骤 1 中的 groupby 操作产生的 DataFrame 对每个轴都有多个级别。列级别没有命名,这意味着我们只能通过其整数位置来引用它们。为了大大简化引用列级别的操作,我们使用 rename_axis 方法对其进行重命名。
rename_axis 方法有些奇怪,因为它可以根据传入的第一个参数的类型修改级别名称和级别值。传入一个列表(如果只有一个级别,则传入标量)会改变级别的名称。传入字典或函数会改变级别的值。在步骤 2 中,我们传递给 rename_axis 方法一个列表,并返回一个所有轴级别都有名称的 DataFrame。
一旦所有轴级别都有了名称,我们就可以轻松明确地控制数据结构。步骤 3 将 AGG_FUNCS 列堆叠到最内层的索引级别。步骤 4 中的 swaplevel 方法接受您希望交换的级别的名称或位置作为前两个参数。sort_index 方法被调用两次,对每个级别的实际值进行排序。注意,列级别的值是列名 SATMTMID 和 UGDS。
通过堆叠和取消堆叠(如步骤 6 中所做的),我们可以得到截然不同的输出。还可以将每个列级别堆叠到索引中,从而生成一个 Series。
还有更多内容...
如果您希望完全删除级别值,可以将其设置为 None。这种做法适用于需要减少 DataFrame 可视化输出中的杂乱,或当列级别显然表示的内容已足够清晰,且不再进行其他处理时:
>>> cg.rename_axis([None, None], axis='index') \
.rename_axis([None, None], axis='columns')

当多个变量作为列名存储时进行整理
一种特定类型的混乱数据出现在列名本身包含多个不同变量的情况。一个常见的例子是性别和年龄被合并在一起。要整理这样的数据集,我们必须使用 pandas 的str访问器操作列,这个访问器包含了额外的字符串处理方法。
准备就绪...
在这个过程里,我们首先会识别所有的变量,其中一些会作为列名被合并在一起。然后我们会重塑数据并解析文本以提取正确的变量值。
如何操作...
- 读取男性的
weightlifting数据集,并识别变量:
>>> weightlifting = pd.read_csv('data/weightlifting_men.csv')
>>> weightlifting

- 变量包括体重类别、性别/年龄类别和资格总分。性别和年龄的变量已经合并到一个单元格中。在我们将它们分离之前,先使用
melt方法将年龄和性别列名转置为一个单一的垂直列:
>>> wl_melt = weightlifting.melt(id_vars='Weight Category',
var_name='sex_age',
value_name='Qual Total')
>>> wl_melt.head()

- 选择
sex_age列,并使用str访问器提供的split方法将该列分割成两列:
>>> sex_age = wl_melt['sex_age'].str.split(expand=True)
>>> sex_age.head()

- 这个操作返回了一个完全独立的 DataFrame,列名没有意义。我们需要重命名这些列,以便能够明确地访问它们:
>>> sex_age.columns = ['Sex', 'Age Group']
>>> sex_age.head()

- 直接在
str访问器后使用索引操作符,从Sex列中选择第一个字符:
>>> sex_age['Sex'] = sex_age['Sex'].str[0]
>>> sex_age.head()

- 使用
pd.concat函数将此 DataFrame 与wl_melt连接,以生成一个整理好的数据集:
>>> wl_cat_total = wl_melt[['Weight Category', 'Qual Total']]
>>> wl_tidy = pd.concat([sex_age, wl_cat_total], axis='columns')
>>> wl_tidy.head()

- 这个相同的结果也可以通过以下方法实现:
>>> cols = ['Weight Category', 'Qual Total']
>>> sex_age[cols] = wl_melt[cols]
如何操作...
weightlifting 数据集像许多数据集一样,在其原始形式下包含易于理解的信息,但从技术角度来看,它是混乱的,因为除了一个列名,其他列名都包含性别和年龄信息。一旦识别出这些变量,我们就可以开始整理数据集。当列名包含变量时,你需要使用melt(或stack)方法。Weight Category变量已经在正确的位置,因此我们通过将其传递给id_vars参数,将其作为标识变量保留。注意,我们不需要显式列出所有被“融化”的列,默认情况下,所有未出现在id_vars中的列都会被融化。
sex_age 列需要解析,并分割为两个变量。为此,我们利用了str访问器提供的额外功能,这仅适用于 Series(单个数据框列)。split 方法在这种情况下是更常见的方法之一,它可以将字符串的不同部分分割为自己的列。默认情况下,它在空格上分割,但您也可以使用 pat 参数指定字符串或正则表达式。当 expand 参数设置为 True 时,为每个独立分割字符段形成新列。当为 False 时,返回一个包含所有段列表的单列。
在步骤 4 中重新命名列后,我们需要再次使用 str 访问器。有趣的是,索引运算符可用于选择或切片字符串的段。在这里,我们选择第一个字符,即性别的变量。我们可以进一步将年龄分为两个单独的列,最小年龄和最大年龄,但通常以这种方式引用整个年龄组。
第 6 步展示了连接所有数据的两种不同方法之一。concat 函数接受一组数据框,并且可以垂直 (axis='index') 或水平 (axis='columns') 连接它们。由于两个数据框的索引相同,在第 7 步中将一个数据框的值分配给另一个数据框的新列是可能的。
还有更多...
另一种在步骤 2 之后完成此操作的方法是直接从 sex_age 列中分配新列,而无需使用 split 方法。可以使用 assign 方法动态添加这些新列:
>>> age_group = wl_melt.sex_age.str.extract('(\d{2}-+?)',
expand=False)
>>> sex = wl_melt.sex_age.str[0]
>>> new_cols = {'Sex':sex,
'Age Group': age_group}
>>> wl_tidy2 = wl_melt.assign(**new_cols) \
.drop('sex_age',axis='columns')
>>> wl_tidy2.sort_index(axis=1).equals(wl_tidy.sort_index(axis=1))
True
Sex 列的查找方式与第 5 步完全相同。因为我们没有使用 split,所以必须以不同的方式提取 Age Group 列。extract 方法使用复杂的正则表达式提取字符串的特定部分。要正确使用 extract,您的模式必须包含捕获组。捕获组通过在模式的一部分周围加括号形成。在这个例子中,整个表达式是一个大的捕获组。它以 \d{2} 开始,搜索精确两位数字,接着是一个字面上的加号或减号,后面可以跟着两位数字。虽然表达式的最后部分 (?:\d{2})? 周围有括号,但 ?: 表示它实际上不是捕获组。这在技术上是一个非捕获组,用于表达两位数字一起作为可选项。sex_age 列现在不再需要并被丢弃。最后,这两个整洁的数据框相互比较,并且发现它们是等价的。
另请参阅
- 请参阅 Regular-Expressions.info 网站以了解更多关于非捕获组的信息 (
bit.ly/2f60KSd)
当多个变量存储为列值时进行整理
整洁的数据集必须为每个变量设置单独的列。偶尔,多个变量名被放置在一个列中,并且它们对应的值放在另一个列中。这种混乱数据的通用格式如下:

在这个例子中,前后三行表示两组不同的观测数据,它们本应分别作为单独的行。需要对数据进行透视,使其变成如下所示:

准备工作
在这个案例中,我们找出包含结构不正确的变量的列,并将其进行透视以创建整洁的数据。
如何实现...
- 读取餐厅
inspections数据集,并将Date列的数据类型转换为datetime64:
>>> inspections = pd.read_csv('data/restaurant_inspections.csv',
parse_dates=['Date'])
>>> inspections.head()

- 这个数据集包含两个变量,
Name和Date,它们分别正确地包含在单独的列中。Info列本身包含五个不同的变量:Borough、Cuisine、Description、Grade和Score。我们尝试使用pivot方法,将Name和Date列保持为竖直排列,将Info列中的所有值转换为新的列,并将Value列作为它们的交集:
>>> inspections.pivot(index=['Name', 'Date'],
columns='Info', values='Value')
NotImplementedError: > 1 ndim Categorical are not supported at this time
- 不幸的是,pandas 开发者尚未为我们实现这一功能。未来很可能这行代码会生效。幸运的是,pandas 大多数情况下有多种方式实现同一任务。我们将
Name、Date和Info放入索引中:
>>> inspections.set_index(['Name','Date', 'Info']).head(10)

- 使用
unstack方法将Info列中的所有值进行透视:
>>> inspections.set_index(['Name','Date', 'Info']) \
.unstack('Info').head()

- 使用
reset_index方法将索引级别转化为列:
>>> insp_tidy = inspections.set_index(['Name','Date', 'Info']) \
.unstack('Info') \
.reset_index(col_level=-1)
>>> insp_tidy.head()

- 数据集已经整洁,但仍然有一些令人讨厌的 pandas 残留物需要清理。我们使用 MultiIndex 方法
droplevel来删除顶部的列级别,然后将索引级别重命名为None:
>>> insp_tidy.columns = insp_tidy.columns.droplevel(0) \
.rename(None)
>>> insp_tidy.head()

- 第 4 步中创建的列 MultiIndex 本可以通过将该单列 DataFrame 转换为 Series 并使用
squeeze方法避免。以下代码产生与上一步相同的结果:
>>> inspections.set_index(['Name','Date', 'Info']) \
.squeeze() \
.unstack('Info') \
.reset_index() \
.rename_axis(None, axis='columns')
其工作原理...
在第 1 步中,我们注意到Info列中垂直排列了五个变量及其对应的Value列中的值。由于我们需要将这五个变量作为横向列名,因此看起来pivot方法应该可以工作。不幸的是,pandas 开发者尚未实现当存在多个非透视列时的这种特殊情况,我们只能使用其他方法。
unstack方法也可以对垂直数据进行透视,但仅对索引中的数据有效。第 3 步通过set_index方法将需要透视和不需要透视的列都移动到索引中,开始了这一过程。将这些列放入索引后,unstack就可以像第 3 步那样使用了。
请注意,在我们对 DataFrame 进行 unstack 操作时,pandas 会保留原来的列名(这里只有一列,Value),并用旧的列名作为上层级创建一个 MultiIndex。现在数据集基本上已经是整洁的,但我们继续使用reset_index方法,将非透视列变成常规列。因为我们有 MultiIndex 列,我们可以使用col_level参数来选择新列名属于哪个层级。默认情况下,名称会被插入到最上层(层级 0)。我们使用-1来指示最底层。
在完成这些操作后,我们有一些多余的 DataFrame 名称和索引需要被丢弃。不幸的是,没有 DataFrame 方法可以删除层级,所以我们必须深入到索引中,使用其droplevel方法。在这里,我们用单级列覆盖了旧的 MultiIndex 列。这些列仍然有一个无用的名称属性Info,我们将其重命名为None。
通过将第 3 步的结果强制转换为 Series,可以避免清理 MultiIndex 列。squeeze方法只适用于单列 DataFrame,并将其转换为 Series。
还有更多内容...
实际上可以使用pivot_table方法,该方法对非透视列的数量没有限制。pivot_table方法与pivot的不同之处在于,它对位于index和columns参数交集中的所有值执行聚合操作。由于可能存在多个值在这个交集里,pivot_table要求用户传递一个聚合函数,以便输出一个单一值。我们使用first聚合函数,它取组中第一个值。在这个特定的例子中,每个交集位置只有一个值,因此不需要进行聚合。默认的聚合函数是均值,但这里会产生错误,因为其中一些值是字符串类型:
>>> inspections.pivot_table(index=['Name', 'Date'],
columns='Info',
values='Value',
aggfunc='first') \
.reset_index() \
.rename_axis(None, axis='columns')
另请参见
- Pandas 官方文档中的
droplevel方法(bit.ly/2yo5BXf)和squeeze方法(bit.ly/2yo5TgN)
当两个或更多的值存储在同一个单元格中时的整理
表格数据天生是二维的,因此每个单元格中可以展示的信息是有限的。为了解决这个问题,您有时会看到数据集中一个单元格中存储了多个值。整洁的数据要求每个单元格恰好包含一个值。要解决这些情况,通常需要使用str系列访问器中的方法将字符串数据解析成多个列。
准备工作...
在这个示例中,我们查看了一个数据集,其中有一列包含每个单元格中多个不同的变量。我们使用str访问器将这些字符串解析成单独的列,以整理数据。
如何操作...
- 读取德克萨斯州的
cities数据集,并识别变量:
>>> cities = pd.read_csv('data/texas_cities.csv')
>>> cities

City列看起来没问题,且仅包含一个值。另一方面,Geolocation列包含四个变量:latitude、latitude direction、longitude和longitude direction。我们将Geolocation列拆分为四个单独的列:
>>> geolocations = cities.Geolocation.str.split(pat='. ',
expand=True)
>>> geolocations.columns = ['latitude', 'latitude direction',
'longitude', 'longitude direction']
>>> geolocations

- 由于
Geolocation的原始数据类型是对象,因此所有新列的类型也都是对象。现在,我们将latitude和longitude转换为浮动类型:
>>> geolocations = geolocations.astype({'latitude':'float',
'longitude':'float'})
>>> geolocations.dtypes
latitude float64
latitude direction object
longitude float64
longitude direction object
dtype: object
- 将这些新列与原始的
City列连接起来:
>>> cities_tidy = pd.concat([cities['City'], geolocations],
axis='columns')
>>> cities_tidy

原理...
在读取数据之后,我们决定数据集中有多少个变量。在这里,我们选择将Geolocation列拆分为四个变量,但我们也可以选择仅拆分为两个变量,分别表示纬度和经度,并使用负号区分东西经和南北纬。
有几种方法可以通过str访问器的方法来解析Geolocation列。最简单的方式是使用split方法。我们传入一个简单的正则表达式,定义任意字符(即句点)和空格。当一个空格出现在任何字符后面时,就会进行拆分并形成一个新列。此模式第一次出现在纬度的末尾,度数符号后有一个空格,从而形成拆分。拆分符号会被丢弃,不会出现在结果列中。下一个拆分匹配紧跟在纬度方向后的逗号和空格。
总共进行了三次拆分,生成了四个列。步骤 2 中的第二行为它们提供了有意义的名称。尽管生成的latitude和longitude列看起来像是浮动类型,但它们实际上不是。它们最初是从对象列中解析出来的,因此仍然是对象数据类型。步骤 3 使用字典将列名映射到新的数据类型。
与其使用字典(如果列名很多,打字量会很大),不如使用to_numeric函数,尝试将每个列转换为整数或浮动类型。为了在每一列上迭代应用此函数,可以使用apply方法,如下所示:
>>> geolocations.apply(pd.to_numeric, errors='ignore')
第 4 步将城市名称附加到这个新 DataFrame 的前面,以完成整洁数据的制作过程。
还有更多...
在这个例子中,split方法与简单的正则表达式配合得非常好。对于其他例子,某些列可能需要你创建多个不同模式的拆分。要搜索多个正则表达式,可以使用管道字符|。例如,如果我们只想拆分度符号和逗号,并且每个符号后面都有一个空格,我们将这样做:
>>> cities.Geolocation.str.split(pat='° |, ', expand=True)
这将返回与第 2 步相同的 DataFrame。任何数量的额外拆分模式可以通过管道字符追加到前面的字符串模式中。
extract方法是另一个非常优秀的方法,它允许你提取每个单元格内的特定组。这些捕获组必须用括号括起来。括号外匹配的任何内容都不会出现在结果中。以下这一行的输出与第 2 步相同:
>>> cities.Geolocation.str.extract('([0-9.]+). (N|S), ([0-9.]+). (E|W)',
expand=True)
这个正则表达式有四个捕获组。第一个和第三个组用于搜索至少一个或多个连续的带小数的数字。第二个和第四个组用于搜索单个字符(方向)。第一个和第三个捕获组由任何字符和一个空格分隔。第二个捕获组由逗号和空格分隔。
当变量存储在列名和列值中时的整洁化
一种特别难以诊断的凌乱数据形式出现在变量同时存储在列名横向和列值纵向时。你通常会在数据库中遇不到这种数据集,而是在某个已经生成的汇总报告中遇到。
准备工作
在这个示例中,变量既在垂直方向上又在水平方向上被识别,并通过melt和pivot_table方法重塑成整洁数据。
如何操作...
- 读取
sensors数据集并识别变量:
>>> sensors = pd.read_csv('data/sensors.csv')
>>> sensors

- 唯一正确放置在垂直列中的变量是
Group。Property列似乎有三个独特的变量,分别是Pressure、Temperature和Flow。其余的列2012到2016本身是单个变量,我们可以合理地将其命名为Year。这种凌乱的数据无法通过单一的 DataFrame 方法重构。让我们先使用melt方法,将年份转置到各自的列中:
>>> sensors.melt(id_vars=['Group', 'Property'], var_name='Year') \
.head(6)

- 这解决了我们的问题之一。我们使用
pivot_table方法将Property列转置为新的列名:
>>> sensors.melt(id_vars=['Group', 'Property'], var_name='Year') \
.pivot_table(index=['Group', 'Year'],
columns='Property', values='value') \
.reset_index() \
.rename_axis(None, axis='columns')

它是如何工作的...
一旦我们在第一步中识别出了变量,我们就可以开始进行重构。Pandas 没有一个方法可以同时旋转多个列,因此我们必须一步步地处理。我们通过将Property列传递给melt方法中的id_vars参数来保持其竖直排列,从而纠正年份数据。
结果现在正是前面“当多个变量作为列值存储时进行整洁化”章节中的混乱数据模式。如该章节中的“还有更多...”部分所述,当index参数中使用多个列时,我们必须使用pivot_table来旋转 DataFrame。在旋转之后,Group和Year变量被固定在索引中。我们将它们作为列重新提取出来。pivot_table方法会将columns参数中使用的列名保留为列索引名。重设索引后,这个名称已不再有意义,我们使用rename_axis将其删除。
还有更多...
每当解决方案涉及到melt、pivot_table或pivot时,你可以确信有一种使用stack和unstack的替代方法。诀窍是首先将当前没有被旋转的列移到索引中:
>>> sensors.set_index(['Group', 'Property']) \
.stack() \
.unstack('Property') \
.rename_axis(['Group', 'Year'], axis='index') \
.rename_axis(None, axis='columns') \
.reset_index()
当多个观察单元存储在同一表格中
当每个表格仅包含来自单一观察单元的信息时,通常更容易维护数据。另一方面,当所有数据都在一个表格中时,查找见解可能更为容易,在机器学习的情况下,所有数据必须在一个表格中。整洁数据的重点不在于直接进行分析,而是对数据进行结构化处理,以便后续分析更加简便。如果一个表格中包含多个观察单元,它们可能需要被分离成各自的表格。
准备工作
在本章中,我们使用movie数据集来识别三种观察单元(电影、演员和导演),并为每个单独创建表格。本章的一个关键点是理解演员和导演的 Facebook 点赞数与电影是独立的。每个演员和导演都有一个与之对应的值,表示他们的 Facebook 点赞数。由于这种独立性,我们可以将电影、导演和演员的数据分离到各自的表格中。数据库领域的人称这一过程为规范化,它提高了数据的完整性并减少了冗余。
如何操作...
- 读取修改后的
movie数据集,并输出前五行:
>>> movie = pd.read_csv('data/movie_altered.csv')
>>> movie.head()

- 该数据集包含电影本身、导演和演员的信息。这三种实体可以视为观察单元。在开始之前,让我们使用
insert方法创建一列,唯一标识每部电影:
>>> movie.insert(0, 'id', np.arange(len(movie)))
>>> movie.head()

- 让我们尝试使用
wide_to_long函数来整理这个数据集,将所有演员放在一列,将他们对应的 Facebook 点赞数放在另一列,同样对导演进行处理,尽管每部电影只有一个导演:
>>> stubnames = ['director', 'director_fb_likes',
'actor', 'actor_fb_likes']
>>> movie_long = pd.wide_to_long(movie,
stubnames=stubnames,
i='id',
j='num',
sep='_').reset_index()
>>> movie_long['num'] = movie_long['num'].astype(int)
>>> movie_long.head(9)

- 数据集现在已准备好被拆分成多个较小的表格:
>>> movie_table = movie_long[['id', 'title', 'year', 'duration', 'rating']]
>>> director_table = movie_long[['id', 'num',
'director', 'director_fb_likes']]
>>> actor_table = movie_long[['id', 'num',
'actor', 'actor_fb_likes']]

- 这些表格仍然存在几个问题。
movie表格每部电影重复了三次,导演表格每个 ID 有两行缺失,另外一些电影的演员数据也缺失。让我们解决这些问题:
>>> movie_table = movie_table.drop_duplicates() \
.reset_index(drop=True)
>>> director_table = director_table.dropna() \
.reset_index(drop=True)
>>> actor_table = actor_table.dropna() \
.reset_index(drop=True)

- 现在我们已经将观察单元分开到各自的表格中,让我们将原始数据集的内存与这三个表格进行对比:
>>> movie.memory_usage(deep=True).sum()
2318234
>>> movie_table.memory_usage(deep=True).sum() + \
director_table.memory_usage(deep=True).sum() + \
actor_table.memory_usage(deep=True).sum()
2627306
- 我们整理后的数据实际上占用了更多的内存。这是可以预期的,因为原始列中的所有数据只是被分散到了新的表格中。新的表格每个都有一个索引,并且其中两个表格有一个额外的
num列,这就是额外内存的原因。然而,我们可以利用 Facebook 点赞数与电影无关这一事实,即每个演员和导演对所有电影的 Facebook 点赞数是相同的。在我们进行这个操作之前,我们需要创建一个新的表格,将每部电影映射到每个演员/导演。首先,让我们为演员和导演表格创建id列,唯一标识每个演员/导演:
>>> director_cat = pd.Categorical(director_table['director'])
>>> director_table.insert(1, 'director_id', director_cat.codes)
>>> actor_cat = pd.Categorical(actor_table['actor'])
>>> actor_table.insert(1, 'actor_id', actor_cat.codes)

- 我们可以利用这些表格来形成我们的中间表格和唯一的
actor/director表格。我们首先用director表格来处理:
>>> director_associative = director_table[['id', 'director_id',
'num']]
>>> dcols = ['director_id', 'director', 'director_fb_likes']
>>> director_unique = director_table[dcols].drop_duplicates() \
.reset_index(drop=True)

- 我们用
actor表格做同样的操作:
>>> actor_associative = actor_table[['id', 'actor_id', 'num']]
>>> acols = ['actor_id', 'actor', 'actor_fb_likes']
>>> actor_unique = actor_table[acols].drop_duplicates() \
.reset_index(drop=True)

- 让我们来看看这些新表格消耗了多少内存:
>>> movie_table.memory_usage(deep=True).sum() + \
director_associative.memory_usage(deep=True).sum() + \
director_unique.memory_usage(deep=True).sum() + \
actor_associative.memory_usage(deep=True).sum() + \
actor_unique.memory_usage(deep=True).sum()
1833402
- 现在我们已经规范化了我们的表格,我们可以构建一个实体-关系图,展示所有表格(实体)、列以及它们之间的关系。这个图是通过易于使用的 ERDPlus 工具创建的(
erdplus.com):![]()
它是如何工作的...
在导入数据并识别出三个实体之后,我们必须为每个观察值创建一个唯一标识符,这样一来,当它们被分隔到不同的表格时,我们就能将电影、演员和导演关联在一起。在步骤 2 中,我们简单地将 ID 列设置为从零开始的行号。在步骤 3 中,我们使用wide_to_long函数同时melt(熔化)actor和director列。它使用列的整数后缀来将数据垂直对齐,并将此整数后缀放置在索引中。参数j用于控制其名称。列中不在stubnames列表中的值会重复,以便与已熔化的列对齐。
在步骤 4 中,我们创建了三个新的表格,每个表格都保留了id列。我们还保留了num列,用于标识它所衍生的具体director/actor列。步骤 5 通过去除重复项和缺失值来压缩每个表格。
在步骤 5 之后,三个观察单元已经各自放入了独立的表格中,但它们仍然包含与原始表格相同的数据量(甚至更多),如步骤 6 所示。为了从memory_usage方法返回正确的字节数,针对object数据类型的列,必须将deep参数设置为True。
每个演员/导演在其相应的表格中只需要一个条目。我们不能简单地创建一个仅包含演员名字和 Facebook 点赞数的表格,因为那样就无法将演员与原始电影关联起来。电影与演员之间的关系被称为多对多关系。每部电影与多个演员相关联,每个演员也可以出现在多部电影中。为了解决这个关系,创建了一个中间或关联表,它包含电影和演员的唯一标识符(主键)。
为了创建关联表,我们必须唯一标识每个演员/导演。一个技巧是使用pd.Categorical从每个演员/导演的名字创建一个分类数据类型。分类数据类型有一个内部映射,将每个值映射到一个整数。这个整数可以在codes属性中找到,它被用作唯一 ID。为了设置关联表的创建,我们将这个唯一 ID 添加到actor/director表中。
步骤 8 和步骤 9 通过选择两个唯一标识符来创建关联表。现在,我们可以将actor和director表减少到仅包含唯一名称和 Facebook 点赞数。这个新表格的布局比原始布局节省了 20%的内存。正式的关系数据库有实体关系图(ERD)来可视化表格。在步骤 10 中,我们使用简单的 ERDPlus 工具来进行可视化,这大大帮助了理解表格之间的关系。
还有更多内容...
可以通过将所有表格重新连接起来,重新创建原始的movie表。首先,将关联表连接到actor/director表。然后对num列进行透视,并将列前缀重新添加:
>>> actors = actor_associative.merge(actor_unique, on='actor_id') \
.drop('actor_id', 1) \
.pivot_table(index='id',
columns='num',
aggfunc='first')
>>> actors.columns = actors.columns.get_level_values(0) + '_' + \
actors.columns.get_level_values(1).astype(str)
>>> directors = director_associative.merge(director_unique,
on='director_id') \
.drop('director_id', 1) \
.pivot_table(index='id',
columns='num',
aggfunc='first')
>>> directors.columns = directors.columns.get_level_values(0) + '_' + \
directors.columns.get_level_values(1) \
.astype(str)


现在,这些表可以通过movie_table连接在一起:
>>> movie2 = movie_table.merge(directors.reset_index(),
on='id', how='left') \
.merge(actors.reset_index(),
on='id', how='left')
>>> movie.equals(movie2[movie.columns])
True
另请参见
-
关于数据库规范化的更多信息(
bit.ly/2w8wahQ)、关联表(bit.ly/2yqE4oh)以及主键和外键(bit.ly/2xgIvEb) -
请参考本章中的同时堆叠多个变量组配方,了解有关
wide_to_long函数的更多信息。
第十五章:合并 Pandas 对象
有多种选项可以将两个或多个 DataFrame 或 Series 组合在一起。append方法是最不灵活的,它只允许向 DataFrame 添加新行。concat方法非常灵活,可以在任意轴上合并任意数量的 DataFrame 或 Series。join方法通过将一个 DataFrame 的列与其他 DataFrame 的索引对齐来提供快速查找。merge方法提供类似 SQL 的功能,用于将两个 DataFrame 合并。
在本章中,我们将涵盖以下主题:
-
向 DataFrame 追加新行
-
合并多个 DataFrame
-
比较特朗普总统和奥巴马总统的支持率
-
理解
concat、join和merge之间的区别 -
连接到 SQL 数据库
向 DataFrame 追加新行
在进行数据分析时,创建新列比创建新行更为常见。这是因为新的一行数据通常代表一个新的观察结果,而作为分析师,通常不是你负责不断捕获新数据。数据捕获通常由其他平台,如关系数据库管理系统,来处理。然而,作为一项必要功能,你仍然需要掌握它,因为它有时会用到。
准备就绪
在本例中,我们将首先使用.loc索引器将行添加到一个小数据集中,然后过渡到使用append方法。
如何操作...
- 阅读名字数据集,并输出:
>>> names = pd.read_csv('data/names.csv')
>>> names

- 让我们创建一个包含一些新数据的列表,并使用
.loc索引器将一个行标签设置为这些新数据:
>>> new_data_list = ['Aria', 1]
>>> names.loc[4] = new_data_list
>>> names

.loc索引器使用标签来引用行。在这种情况下,行标签完全匹配整数位置。也可以使用非整数标签追加更多行:
>>> names.loc['five'] = ['Zach', 3]
>>> names

- 为了更明确地将变量与值关联,你可以使用字典。此外,在这一步中,我们可以动态选择新的索引标签为 DataFrame 的长度:
>>> names.loc[len(names)] = {'Name':'Zayd', 'Age':2}
>>> names

- 一个 Series 也可以保存新的数据,并且与字典完全相同:
>>> names.loc[len(names)] = pd.Series({'Age':32,
'Name':'Dean'})
>>> names

- 上述操作都使用
.loc索引运算符对namesDataFrame 进行原地修改。没有返回 DataFrame 的副本。在接下来的几个步骤中,我们将了解append方法,它不会修改调用的 DataFrame。相反,它会返回一个新的 DataFrame 副本,附加了行。让我们从原始的namesDataFrame 开始,尝试追加一行。append的第一个参数必须是另一个 DataFrame、Series、字典或这些的列表,而不是像步骤 2 中的列表。让我们看看当我们尝试用字典和append一起使用时会发生什么:
>>> names = pd.read_csv('data/names.csv')
>>> names.append({'Name':'Aria', 'Age':1})
TypeError: Can only append a Series if ignore_index=True or if the Series has a name
- 这个错误信息看起来稍有不准确。我们传递的是一个 DataFrame 而不是 Series,但它仍然给出了如何修正的指示:
>>> names.append({'Name':'Aria', 'Age':1}, ignore_index=True)

- 这虽然可行,但
ignore_index是一个狡猾的参数。当设置为True时,旧的索引将被完全移除,并被从 0 到 n-1 的RangeIndex所替代。例如,下面我们为namesDataFrame 指定一个索引:
>>> names.index = ['Canada', 'Canada', 'USA', 'USA']
>>> names

-
重新运行步骤 7 中的代码,你将得到相同的结果。原始索引会被完全忽略。
-
让我们继续使用这个
names数据集,其中索引是这些国家字符串,并使用一个带有name属性的 Series 来使用append方法:
>>> s = pd.Series({'Name': 'Zach', 'Age': 3}, name=len(names))
>>> s
Age 3
Name Zach
Name: 4, dtype: object
>>> names.append(s)

append方法比.loc索引器更灵活。它支持同时附加多行数据。实现这一点的一种方式是使用 Series 列表:
>>> s1 = pd.Series({'Name': 'Zach', 'Age': 3}, name=len(names))
>>> s2 = pd.Series({'Name': 'Zayd', 'Age': 2}, name='USA')
>>> names.append([s1, s2])

- 只有两列的小型 DataFrame 足够简单,可以手动写出所有的列名和值。当它们变大时,这个过程将变得相当痛苦。例如,来看一下 2016 年的棒球数据集:
>>> bball_16 = pd.read_csv('data/baseball16.csv')
>>> bball_16.head()

- 这个数据集包含 22 列,如果你手动输入新行数据时,很容易输入错误的列名或者完全忘记某些列名。为了避免这些错误,下面我们选择一行作为 Series,并链式调用
to_dict方法,将这一行作为字典提取出来:
>>> data_dict = bball_16.iloc[0].to_dict()
>>> print(data_dict)
{'playerID': 'altuvjo01', 'yearID': 2016, 'stint': 1, 'teamID': 'HOU', 'lgID': 'AL', 'G': 161, 'AB': 640, 'R': 108, 'H': 216, '2B': 42, '3B': 5, 'HR': 24, 'RBI': 96.0, 'SB': 30.0, 'CS': 10.0, 'BB': 60, 'SO': 70.0, 'IBB': 11.0, 'HBP': 7.0, 'SH': 3.0, 'SF': 7.0, 'GIDP': 15.0}
- 使用字典推导式清除旧值,将任何之前的字符串值设为空字符串,其他所有的值设为缺失值。这个字典现在可以作为你想输入任何新数据的模板:
>>> new_data_dict = {k: '' if isinstance(v, str) else
np.nan for k, v in data_dict.items()}
>>> print(new_data_dict)
{'playerID': '', 'yearID': nan, 'stint': nan, 'teamID': '', 'lgID': '', 'G': nan, 'AB': nan, 'R': nan, 'H': nan, '2B': nan, '3B': nan, 'HR': nan, 'RBI': nan, 'SB': nan, 'CS': nan, 'BB': nan, 'SO': nan, 'IBB': nan, 'HBP': nan, 'SH': nan, 'SF': nan, 'GIDP': nan}
它是如何工作的...
.loc 索引操作符用于根据行和列标签选择并分配数据。传递给它的第一个值代表行标签。在步骤 2 中,names.loc[4] 指的是标签等于整数 4 的那一行。这个标签在 DataFrame 中当前并不存在。赋值语句通过列表提供的数据创建了一行新的数据。正如食谱中所提到的,这个操作会修改 names DataFrame 本身。如果之前存在标签等于整数 4 的行,这个命令会覆盖它。就地修改使得这个索引操作符比 append 方法更具风险,后者永远不会修改原始的调用 DataFrame。
任何有效的标签都可以与 .loc 索引操作符一起使用,如步骤 3 所示。不管新的标签值是什么,新的行总是会附加到最后。即使用列表赋值有效,为了清晰起见,最好使用字典,这样我们可以清楚地知道每个值与哪些列关联,就像在步骤 4 中所做的那样。
第 5 步展示了一个小技巧,可以动态地将新标签设置为 DataFrame 当前的行数。存储在 Series 中的数据也会被正确分配,只要索引标签与列名匹配。
剩下的步骤使用 append 方法,这是一种简单的方法,只能将新行附加到 DataFrame 中。大多数 DataFrame 方法都可以通过 axis 参数同时处理行和列。唯一的例外是 append,它只能向 DataFrame 添加行。
使用列名映射到值的字典不足以使 append 操作生效,正如第 6 步的错误信息所示。要正确地附加没有行名的字典,你必须将 ignore_index 参数设置为 True。第 10 步展示了如何通过将字典转换为 Series 来保留原始索引。确保使用 name 参数,这将作为新的索引标签。在这种方式下,通过将 Series 列表作为第一个参数传递,可以添加任意数量的行。
当你想要以这种方式附加大量的行到一个 DataFrame 时,你可以通过将单行转换为字典,并使用字典推导式清除所有旧值,将它们替换为一些默认值,从而避免大量的输入错误。
还有更多...
向 DataFrame 中添加单行是一个相当昂贵的操作,如果你发现自己正在写循环逐行添加数据到 DataFrame,那么你做错了。首先,我们创建 1,000 行新数据作为 Series 列表:
>>> random_data = []
>>> for i in range(1000):
d = dict()
for k, v in data_dict.items():
if isinstance(v, str):
d[k] = np.random.choice(list('abcde'))
else:
d[k] = np.random.randint(10)
random_data.append(pd.Series(d, name=i + len(bball_16)))
>>> random_data[0].head()
2B 3
3B 9
AB 3
BB 9
CS 4
Name: 16, dtype: object
让我们来计时,看看循环通过每一项并逐行附加需要多少时间:
>>> %%timeit
>>> bball_16_copy = bball_16.copy()
>>> for row in random_data:
bball_16_copy = bball_16_copy.append(row)
4.88 s ± 190 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
仅 1,000 行数据就花了近五秒钟。如果我们改为传递整个 Series 列表,速度会大大提高:
>>> %%timeit
>>> bball_16_copy = bball_16.copy()
>>> bball_16_copy = bball_16_copy.append(random_data)
78.4 ms ± 6.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
通过传递 Series 列表,时间被减少到了不到一秒的十分之一。Pandas 内部将 Series 列表转换为一个单一的 DataFrame,然后进行 append 操作。
将多个 DataFrame 连接在一起
多功能的 concat 函数可以将两个或更多的 DataFrame(或 Series)垂直或水平地连接在一起。通常,处理多个 pandas 对象时,连接不会随意发生,而是根据索引对齐每个对象。
准备好
在这个示例中,我们使用 concat 函数水平和垂直地合并 DataFrame,然后通过更改参数值来得到不同的结果。
如何做到这一点...
- 读取 2016 年和 2017 年的股票数据集,并将它们的股票代码设置为索引:
>>> stocks_2016 = pd.read_csv('data/stocks_2016.csv',
index_col='Symbol')
>>> stocks_2017 = pd.read_csv('data/stocks_2017.csv',
index_col='Symbol')

- 将所有
stock数据集放入一个列表中,然后调用concat函数将它们连接在一起:
>>> s_list = [stocks_2016, stocks_2017]
>>> pd.concat(s_list)

- 默认情况下,
concat函数将 DataFrame 垂直连接,将其一一叠加。前述的 DataFrame 存在一个问题,就是无法识别每一行的年份。concat函数允许通过keys参数为结果 DataFrame 中的每个部分加上标签。这个标签会出现在连接后的框架的最外层索引级别,并强制创建一个多重索引。此外,names参数可以重命名每个索引级别,以便更清晰地显示:
>>> pd.concat(s_list, keys=['2016', '2017'],
names=['Year', 'Symbol'])

- 也可以通过将
axis参数设置为columns或1来进行水平连接:
>>> pd.concat(s_list, keys=['2016', '2017'],
axis='columns', names=['Year', None])

- 注意,每当某个股票代码在某一年出现而在另一年没有出现时,缺失值会出现。默认情况下,
concat函数使用外连接,保留列表中每个 DataFrame 的所有行。然而,它也提供了仅保留两个 DataFrame 中索引值相同的行的选项,这被称为内连接。我们可以将join参数设置为inner来改变这一行为:
>>> pd.concat(s_list, join='inner', keys=['2016', '2017'],
axis='columns', names=['Year', None])

它是如何工作的...
第一个参数是concat函数唯一需要的参数,必须是一个包含 pandas 对象的序列,通常是一个 DataFrame 或 Series 的列表或字典。默认情况下,所有这些对象会被垂直堆叠在一起。在这个例子中,仅连接了两个 DataFrame,但任何数量的 pandas 对象都可以工作。当我们进行垂直连接时,DataFrame 会根据列名对齐。
在这个数据集中,所有列名相同,因此 2017 年的每一列都准确地与 2016 年对应的列对齐。然而,当它们像第 4 步那样进行水平连接时,只有两个索引标签在两年间匹配——AAPL和TSLA。因此,这些股票代码在两年之间都没有缺失值。使用concat可以进行两种类型的对齐,分别是outer(默认)和inner,由join参数指定。
还有更多内容...
append方法是concat的简化版本,仅能将新行追加到 DataFrame 中。内部实现上,append只是调用了concat函数。例如,这个示例中的第二步可以通过以下方式实现:
>>> stocks_2016.append(stocks_2017)
比较特朗普和奥巴马的支持率
美国现任总统的公众支持度是一个经常出现在新闻头条的话题,并通过民意调查正式测量。近年来,这些民意调查的频率急剧增加,每周都会有大量的新数据发布。不同的民意调查公司有不同的问题和方法来收集数据,因此数据之间存在相当大的差异。加利福尼亚大学圣巴巴拉分校的美国总统项目提供了一个综合的支持度数据,每天更新一次。
与本书中的大多数示例不同,这些数据并不是直接以 CSV 文件的形式提供的。作为数据分析师,你通常需要在网上寻找数据,并使用一个能够抓取这些数据并转化为你能够在本地工作站解析的格式的工具。
准备就绪
在本节中,我们将使用 read_html 函数,它具有强大的功能,可以从网上的表格中抓取数据并将其转化为 DataFrame。你还将学习如何检查网页,找到某些元素的底层 HTML。我使用的是 Google Chrome 浏览器,建议你也使用它,或者 Firefox 浏览器,进行网页相关的操作。
如何操作...
- 访问 美国总统项目 中关于总统唐纳德·特朗普的支持度页面(
www.presidency.ucsb.edu/data/popularity.php?pres=45)。你应该会看到一个包含时间序列图的数据页面,紧跟其后的是一个表格:

read_html函数能够从网页中抓取表格并将其数据放入 DataFrame。它在处理简单的 HTML 表格时效果最佳,并提供了一些有用的参数,帮助你在同一页面有多个表格时选择你想要的具体表格。我们可以直接使用read_html的默认值,这将返回所有表格作为 DataFrame 的列表:
>>> base_url = 'http://www.presidency.ucsb.edu/data/popularity.php?pres={}'
>>> trump_url = base_url.format(45)
>>> df_list = pd.read_html(trump_url)
>>> len(df_list)
14
- 该函数返回了 14 个表格,乍一看似乎很不可思议,因为网页上似乎只显示了一个大家普遍认为是表格的元素。
read_html函数正式搜索以 <table 开头的 HTML 表格元素。让我们通过右键点击批准数据表格并选择检查或检查元素来检查 HTML 页面:

- 这会打开控制台,这是一个非常强大的网页开发工具。在本节中,我们只需要它来完成几个任务。所有控制台都允许你在 HTML 中搜索特定的词汇。让我们搜索
table这个词。我的浏览器找到了 15 个不同的 HTML 表格,与read_html返回的数量非常接近:

- 让我们开始检查
df_list中的 DataFrame:
>>> df0 = df_list[0]
>>> df0.shape
(308, 1794)
>>> df0.head(7)

- 回顾网页,从 2017 年 1 月 22 日开始,直到数据抓取的日期——2017 年 9 月 25 日,审批表中几乎每天都有一行数据。大约是八个月或 250 行数据,接近第一个表格中的 308 行。浏览其余的表格时,你会发现很多空的无意义的表格,以及一些实际上不像表格的网页其他部分。让我们使用
read_html函数的一些参数,帮助我们选择我们想要的表格。我们可以使用match参数搜索表格中的特定字符串。让我们搜索包含Start Date的表格:
>>> df_list = pd.read_html(trump_url, match='Start Date')
>>> len(df_list)
3
- 通过在表格中搜索特定的字符串,我们将表格的数量减少到了只有三个。另一个有用的参数是
attrs,它接受一组 HTML 属性及其对应的值的字典。我们希望为我们的特定表格找到一些独特的属性。为此,让我们再次右键点击数据表。这次,确保点击在表头的最上方。例如,右键点击President,然后再次选择“检查”或“检查元素”:

- 你选择的元素应该被高亮显示。但这实际上不是我们感兴趣的元素。继续查找,直到你遇到一个以<table开头的 HTML 标签。等号左侧的所有词是属性或
attrs,右侧是它们的值。让我们在搜索时使用align属性,其值为center:
>>> df_list = pd.read_html(trump_url, match='Start Date',
attrs={'align':'center'})
>>> len(df_list)
1
>>> trump = df_list[0]
>>> trump.shape
(249, 19)
>>> trump.head(8)

- 我们只匹配了一个表格,而且行数非常接近第一个和最后一个日期之间的总天数。查看数据后,似乎我们确实找到了我们想要的表格。六个列名似乎出现在第 4 行。我们可以更进一步,精确选择我们想跳过的行以及我们想用作列名的行,使用
skiprows和header参数。我们还可以使用parse_dates参数确保开始日期和结束日期被正确转换为合适的数据类型:
>>> df_list = pd.read_html(trump_url, match='Start Date',
attrs={'align':'center'},
header=0, skiprows=[0,1,2,3,5],
parse_dates=['Start Date',
'End Date'])
>>> trump = df_list[0]
>>> trump.head()

- 这几乎正是我们想要的,除了那些有缺失值的列。我们可以使用
dropna方法删除所有值缺失的列:
>>> trump = trump.dropna(axis=1, how='all')
>>> trump.head()

- 我们将使用
ffill方法按顺序填充President列中的缺失值。让我们先检查其他列中是否有缺失值:
>>> trump.isnull().sum()
President 242
Start Date 0
End Date 0
Approving 0
Disapproving 0
unsure/no data 0
dtype: int64
>>> trump = trump.ffill()
trump.head()

- 最后,检查数据类型是否正确非常重要:
>>> trump.dtypes
President object
Start Date datetime64[ns]
End Date datetime64[ns]
Approving int64
Disapproving int64
unsure/no data int64
dtype: object
- 让我们创建一个函数,将所有步骤合并为一个,自动化获取任何总统的审批数据的过程:
>>> def get_pres_appr(pres_num):
base_url =\
'http://www.presidency.ucsb.edu/data/popularity.php?pres={}'
pres_url = base_url.format(pres_num)
df_list = pd.read_html(pres_url, match='Start Date',
attrs={'align':'center'},
header=0, skiprows=[0,1,2,3,5],
parse_dates=['Start Date',
'End Date'])
pres = df_list[0].copy()
pres = pres.dropna(axis=1, how='all')
pres['President'] = pres['President'].ffill()
return pres.sort_values('End Date') \
.reset_index(drop=True)
- 唯一的参数
pres_num表示每位总统的顺序编号。巴拉克·奥巴马是美国的第 44 任总统;传递 44 给get_pres_appr函数以获取他的支持率:
>>> obama = get_pres_appr(44)
>>> obama.head()

- 我们可以追溯到 1941 年总统富兰克林·罗斯福的第三任期的总统支持率数据。通过自定义函数和
concat函数的结合,我们可以从该网站获取所有总统的支持率数据。现在,我们只获取过去五任总统的支持率数据,并输出每位总统的前三行数据:
>>> pres_41_45 = pd.concat([get_pres_appr(x) for x in range(41,46)],
ignore_index=True)
>>> pres_41_45.groupby('President').head(3)

- 在继续之前,让我们确定是否有任何日期对应多个支持率:
>>> pres_41_45['End Date'].value_counts().head(8)
1990-08-26 2
1990-03-11 2
1999-02-09 2
2013-10-10 2
1990-08-12 2
1992-11-22 2
1990-05-22 2
1991-09-30 1
Name: End Date, dtype: int64
- 只有少数几天有重复值。为了简化分析,我们只保留重复日期的第一行:
>>> pres_41_45 = pres_41_45.drop_duplicates(subset='End Date')
- 让我们获取一些数据的摘要统计:
>>> pres_41_45.shape
(3679, 6)
>>> pres_41_45['President'].value_counts()
Barack Obama 2786
George W. Bush 270
Donald J. Trump 243
William J. Clinton 227
George Bush 153
Name: President, dtype: int64
>>> pres_41_45.groupby('President', sort=False) \
.median().round(1)

- 让我们将每位总统的支持率绘制在同一张图表上。为此,我们将按每位总统分组,遍历每个组,并单独绘制每个日期的支持率:
>>> from matplotlib import cm
>>> fig, ax = plt.subplots(figsize=(16,6))
>>> styles = ['-.', '-', ':', '-', ':']
>>> colors = [.9, .3, .7, .3, .9]
>>> groups = pres_41_45.groupby('President', sort=False)
>>> for style, color, (pres, df) in zip(styles, colors, groups):
df.plot('End Date', 'Approving', ax=ax,
label=pres, style=style, color=cm.Greys(color),
title='Presedential Approval Rating')

- 这张图表将所有总统按顺序排列。我们可以通过将他们的支持率与在任天数进行对比,简化比较。让我们创建一个新的变量来表示在任天数:
>>> days_func = lambda x: x - x.iloc[0]
>>> pres_41_45['Days in Office'] = pres_41_45.groupby('President') \
['End Date'] \
.transform(days_func)
>>> pres_41_45.groupby('President').head(3)

- 我们已经成功地给每一行添加了自总统任期开始以来的相对天数。有趣的是,新列
Days in Office的值是以字符串形式表示的。让我们检查一下它的数据类型:
>>> pres_41_45.dtypes
...
Days in Office timedelta64[ns]
dtype: object
Days in Office列是一个timedelta64对象,精度为纳秒。这远远超过了所需的精度。让我们通过只取天数,将数据类型更改为整数:
>>> pres_41_45['Days in Office'] = pres_41_45['Days in Office'] \
.dt.days
>>> pres_41_45['Days in Office'].head()
0 0
1 32
2 35
3 43
4 46
Name: Days in Office, dtype: int64
- 我们可以像第 19 步中那样绘制这组数据,但还有一种完全不同的方法,它不涉及任何循环。默认情况下,当在 DataFrame 上调用
plot方法时,pandas 会尝试将每一列数据作为折线图绘制,并使用索引作为 x 轴。了解这一点后,让我们将数据透视,以便每位总统都有自己的一列支持率数据:
>>> pres_pivot = pres_41_45.pivot(index='Days in Office',
columns='President',
values='Approving')
>>> pres_pivot.head()

- 现在每位总统都有自己的一列支持率数据,我们可以直接绘制每一列,而无需分组。为了减少图表中的杂乱,我们将仅绘制巴拉克·奥巴马和唐纳德·J·特朗普的数据:
>>> plot_kwargs = dict(figsize=(16,6), color=cm.gray([.3, .7]),
style=['-', '--'], title='Approval Rating')
>>> pres_pivot.loc[:250, ['Donald J. Trump', 'Barack Obama']] \
.ffill().plot(**plot_kwargs)

如何工作...
通常会多次调用read_html,直到找到你想要的表格(或表格)。你可以使用两个主要参数来指定表格,match和attrs。提供给match的字符串用于精确匹配表格中的实际文本。这是网页上显示的文本。而attrs参数则是用来查找 HTML 表格属性,这些属性位于表格标签<table>后面。要查看更多表格属性,请访问 W3 Schools 的这个页面(bit.ly/2hzUzdD)。
一旦我们在步骤 8 中找到了表格,我们仍然可以利用一些其他参数来简化操作。HTML 表格通常不能直接转化为漂亮的 DataFrame。常常会缺少列名、额外的行以及数据错位。在这个步骤中,skiprows接收一个包含要跳过的行号的列表。当读取文件时,这些行号对应于步骤 8 中 DataFrame 输出中的缺失值行。header参数也用来指定列名的位置。注意,header等于零,乍一看可能会觉得是错误的。每当header参数与skiprows一起使用时,行会先被跳过,这样每行会得到一个新的整数标签。正确的列名在第 4 行,但由于我们跳过了第 0 到第 3 行,它的新的整数标签是 0。
在步骤 11 中,ffill方法通过用最后一个非缺失值向下填充来填补任何缺失值。这个方法只是fillna(method='ffill')的快捷方式。
步骤 13 构建了一个包含所有前面步骤的函数,用来自动获取任何总统的支持率,前提是你有订单号。这个函数有一些不同之处。我们不是对整个 DataFrame 应用ffill方法,而是只对President列应用它。在特朗普的 DataFrame 中,其他列没有缺失数据,但这并不保证所有抓取的表格在其他列中也没有缺失数据。函数的最后一行将日期按更自然的方式从旧到新排序,这样会改变索引的顺序,因此我们通过reset_index将其重置,使其重新从零开始。
步骤 16 展示了一种常见的 pandas 用法,先将多个索引相似的 DataFrame 收集到一个列表中,然后用concat函数将它们合并在一起。合并成一个 DataFrame 后,我们应该对其进行视觉检查,确保其准确性。检查的一种方法是通过对数据进行分组,然后在每个组上使用head方法来快速查看每个总统部分的前几行。
步骤 18 中的摘要统计数据很有趣,因为每位继任总统的中位支持率都低于前任。如果对数据进行外推,可能会天真地预测未来几位总统的支持率为负数。
第 19 步中的绘图代码相当复杂。你可能会想,为什么一开始就需要遍历一个groupby对象。在当前的 DataFrame 结构中,它无法基于单列的值绘制不同组的数据。然而,第 23 步会展示如何设置 DataFrame,以便 pandas 可以直接绘制每个总统的数据,而不需要像这样的循环。
要理解第 19 步中的绘图代码,首先你需要知道groupby对象是可迭代的,在遍历时,它会返回一个包含当前组(这里就是总统的名字)和该组对应的子 DataFrame 的元组。这个groupby对象与控制绘图颜色和线条样式的值一起被打包。我们从 matplotlib 导入了色彩图模块cm,它包含了几十种不同的色彩图。传递一个 0 到 1 之间的浮动值可以从色彩图中选择一个特定的颜色,我们在plot方法中使用color参数来应用它。还需要注意的是,我们必须创建图形fig和绘图表面ax,以确保每条支持率线都绘制在同一张图上。在循环的每次迭代中,我们使用相同的绘图表面和同名的参数ax。
为了更好地比较各位总统,我们创建了一个新的列,表示在职天数。我们从每位总统组的其他日期中减去第一天的日期。当两个datetime64列相减时,结果是一个timedelta64对象,表示某段时间的长度,这里是天数。如果我们保持纳秒级的精度,x 轴也会显示过多的精度,因此使用特殊的dt访问器返回天数。
关键步骤出现在第 23 步。我们重新结构化数据,使每个总统都有一个专门的列来表示其支持率。Pandas 为每一列绘制了一条单独的线。最后,在第 24 步,我们使用.loc索引器同时选择前 250 天(行)以及特朗普和奥巴马的列。ffill方法用于在总统的某一天有缺失值时进行填充。在 Python 中,可以通过在函数前加**的方式将包含参数名称及其值的字典传递给函数,这个过程叫做字典解包。
还有更多内容……
第 19 步中的绘图显示了相当多的噪音,如果对数据进行平滑处理,可能会更容易解释。一个常见的平滑方法叫做滚动平均。Pandas 为 DataFrame 和groupby对象提供了rolling方法。它的工作方式类似于groupby方法,通过返回一个等待执行额外操作的对象。当创建时,你必须将窗口大小作为第一个参数传递,这个大小可以是一个整数或者一个日期偏移字符串。
在这个例子中,我们使用日期偏移字符串90D计算 90 天的移动平均值。on参数指定了滚动窗口计算的列:
>>> pres_rm = pres_41_45.groupby('President', sort=False) \
.rolling('90D', on='End Date')['Approving'] \
.mean()
>>> pres_rm.head()
President End Date
George Bush 1989-01-26 51.000000
1989-02-27 55.500000
1989-03-02 57.666667
1989-03-10 58.750000
1989-03-13 58.200000
Name: Approving, dtype: float64
在此,我们可以使用 unstack 方法重新结构化数据,使其与步骤 23 的输出类似,然后制作我们的图表:
>>> styles = ['-.', '-', ':', '-', ':']
>>> colors = [.9, .3, .7, .3, .9]
>>> color = cm.Greys(colors)
>>> title='90 Day Approval Rating Rolling Average'
>>> plot_kwargs = dict(figsize=(16,6), style=styles,
color = color, title=title)
>>> correct_col_order = pres_41_45.President.unique()
>>> pres_rm.unstack('President')[correct_col_order].plot(**plot_kwargs)

另见
-
matplotlib 的 colormap 参考(
bit.ly/2yJZOvt) -
所有日期偏移及其别名的列表(
bit.ly/2xO5Yg0)
理解 concat、join 和 merge 之间的区别
merge 和 join 的 DataFrame 方法(而不是 Series)以及 concat 函数都提供了非常相似的功能,用于将多个 pandas 对象合并在一起。由于它们如此相似,并且在某些情况下可以互相替代,因此理解何时以及如何正确使用它们可能会让人困惑。为了帮助澄清它们之间的差异,以下是一个简要的概述:
-
concat:-
Pandas 函数
-
垂直或水平合并两个或更多 pandas 对象
-
仅在索引上对齐
-
每当索引中出现重复项时会报错
-
默认执行外连接,并可选择执行内连接
-
-
join:-
DataFrame 方法
-
水平合并两个或更多 pandas 对象
-
将调用的 DataFrame 的列/索引与其他对象的索引(而非列)对齐
-
通过执行笛卡尔积处理连接列/索引中的重复值
-
默认执行左连接,并可选择执行内连接、外连接和右连接
-
-
merge:-
DataFrame 方法
-
精确地水平合并两个 DataFrame
-
将调用的 DataFrame 的列/索引与另一个 DataFrame 的列/索引对齐
-
通过执行笛卡尔积处理连接列/索引中的重复值
-
默认执行内连接,并可选择执行左连接、外连接和右连接
-
join 方法的第一个参数是 other,它可以是单个 DataFrame/Series,或者是任何数量的 DataFrame/Series 列表。
准备就绪
在这个示例中,我们将完成合并 DataFrame 所需的步骤。第一种情况使用 concat 更为简单,而第二种情况则使用 merge 更为简单。
如何执行...
- 让我们读取 2016 年、2017 年和 2018 年的股票数据,并使用循环将它们放入一个 DataFrame 列表中,而不是通过三次不同的
read_csv调用。当前,Jupyter notebooks 只允许在一行上显示一个 DataFrame。但有一种方法可以借助IPython库自定义 HTML 输出。用户定义的display_frames函数接受一个 DataFrame 列表并将它们全部输出在同一行上:
>>> from IPython.display import display_html
>>> years = 2016, 2017, 2018
>>> stock_tables = [pd.read_csv('data/stocks_{}.csv'.format(year),
index_col='Symbol')
for year in years]
>>> def display_frames(frames, num_spaces=0):
t_style = '<table style="display: inline;"'
tables_html = [df.to_html().replace('<table', t_style)
for df in frames]
space = ' ' * num_spaces
display_html(space.join(tables_html), raw=True)
>>> display_frames(stock_tables, 30)
>>> stocks_2016, stocks_2017, stocks_2018 = stock_tables

concat函数是唯一能够垂直合并 DataFrame 的函数。让我们通过传递stock_tables列表来实现:
>>> pd.concat(stock_tables, keys=[2016, 2017, 2018])

- 还可以通过将
axis参数更改为columns来水平合并 DataFrame:
>>> pd.concat(dict(zip(years,stock_tables)), axis='columns')

- 现在我们已经开始水平合并 DataFrame,可以使用
join和merge方法来复制concat的这一功能。在这里,我们使用join方法来合并stock_2016和stock_2017两个 DataFrame。默认情况下,DataFrame 会根据它们的索引进行对齐。如果某些列有相同的名称,则必须为lsuffix或rsuffix参数提供值,以便在结果中区分它们:
>>> stocks_2016.join(stocks_2017, lsuffix='_2016',
rsuffix='_2017', how='outer')

- 为了精确复制第 3 步中
concat函数的输出,我们可以将一个 DataFrame 列表传递给join方法:
>>> other = [stocks_2017.add_suffix('_2017'),
stocks_2018.add_suffix('_2018')]
>>> stocks_2016.add_suffix('_2016').join(other, how='outer')

- 让我们检查它们是否确实完全相等:
>>> stock_join = stocks_2016.add_suffix('_2016').join(other,
how='outer')
>>> stock_concat = pd.concat(dict(zip(years,stock_tables)),
axis='columns')
>>> level_1 = stock_concat.columns.get_level_values(1)
>>> level_0 = stock_concat.columns.get_level_values(0).astype(str)
>>> stock_concat.columns = level_1 + '_' + level_0
>>> stock_join.equals(stock_concat)
True
- 现在,让我们来看一下
merge,它与concat和join不同,能够将两个 DataFrame 精确地合并在一起。默认情况下,merge会尝试根据每个 DataFrame 中相同名称的列来对齐列中的值。不过,你也可以选择通过将布尔参数left_index和right_index设置为True,让它根据索引进行对齐。让我们将 2016 年和 2017 年的库存数据合并在一起:
>>> stocks_2016.merge(stocks_2017, left_index=True,
right_index=True)

- 默认情况下,
merge使用内连接,并自动为同名列提供后缀。让我们改用外连接,然后再执行一次外连接,将 2018 年的数据合并在一起,从而完全复制concat的功能:
>>> step1 = stocks_2016.merge(stocks_2017, left_index=True,
right_index=True, how='outer',
suffixes=('_2016', '_2017'))
>>> stock_merge = step1.merge(stocks_2018.add_suffix('_2018'),
left_index=True, right_index=True,
how='outer')
>>> stock_concat.equals(stock_merge)
True
- 现在让我们转向我们关心对齐列值而非索引或列标签的数据集。
merge方法正是为这种情况而设计的。让我们来看一下两个新的小数据集,food_prices和food_transactions:
>>> names = ['prices', 'transactions']
>>> food_tables = [pd.read_csv('data/food_{}.csv'.format(name))
for name in names]
>>> food_prices, food_transactions = food_tables
>>> display_frames(food_tables, 30)

- 如果我们想要找到每笔交易的总金额,我们需要在
item和store列上连接这些表:
>>> food_transactions.merge(food_prices, on=['item', 'store'])

- 价格现在已经与对应的商品和商店正确对齐,但存在一个问题。客户 2 购买了四个
steak商品。由于steak商品在商店B的每个表格中都出现了两次,它们之间发生了笛卡尔积,导致了四行数据的出现。另请注意,coconut商品缺失,因为没有相应的价格。我们来解决这两个问题:
>>> food_transactions.merge(food_prices.query('Date == 2017'),
how='left')

- 我们可以使用
join方法来复制这一过程,但必须首先将food_pricesDataFrame 的连接列放入索引中:
>>> food_prices_join = food_prices.query('Date == 2017') \
.set_index(['item', 'store'])
>>> food_prices_join

join方法仅与传递的 DataFrame 的索引对齐,但可以使用调用 DataFrame 的索引或列。要使用列进行对齐,你需要将它们传递给on参数:
>>> food_transactions.join(food_prices_join, on=['item', 'store'])
- 输出与第 11 步的结果完全匹配。要使用
concat方法复制此操作,您需要将item和store列放入两个 DataFrame 的索引中。然而,在这个特定的案例中,由于至少一个 DataFrame 中存在重复的索引值(item为steak且store为B),因此会产生错误。
>>> pd.concat([food_transactions.set_index(['item', 'store']),
food_prices.set_index(['item', 'store'])],
axis='columns')
Exception: cannot handle a non-unique multi-index!
它是如何工作的...
在同时导入多个 DataFrame 时,重复编写read_csv函数可能会变得繁琐。自动化这个过程的一种方法是将所有文件名放入列表中,并使用for循环遍历它们。这在第 1 步中通过列表推导实现。
本步骤的其余部分构建了一个函数,用于在 Jupyter notebook 中将多个 DataFrame 显示在同一行输出上。所有 DataFrame 都有一个to_html方法,它返回表格的原始 HTML 字符串表示。每个表格的 CSS(层叠样式表)通过修改display属性为inline,使得元素水平并排显示,而不是垂直显示。为了在 notebook 中正确渲染表格,必须使用 IPython 库提供的辅助函数read_html。
在第 1 步结束时,我们将 DataFrame 列表解包到各自适当命名的变量中,以便每个单独的表格可以轻松且清晰地引用。拥有 DataFrame 列表的好处是,它正好符合concat函数的需求,如第 2 步所示。注意第 2 步如何使用keys参数为每个数据块命名。通过将字典传递给concat,也可以实现这一点,如第 3 步所示。
在第 4 步,我们必须将join的类型更改为outer,以包括传递的 DataFrame 中那些没有在调用 DataFrame 中找到的索引的所有行。在第 5 步,传递的 DataFrame 列表不能有任何重复的列。虽然有rsuffix参数,它仅在传递单个 DataFrame 时有效,而非传递 DataFrame 列表。为了解决这个限制,我们事先使用add_suffix方法更改列名,然后调用join方法。
在第 7 步,我们使用merge,它默认按照两个 DataFrame 中相同的列名进行对齐。若要更改此默认行为,并按照某个或两个 DataFrame 的索引进行对齐,可以将left_index或right_index参数设置为True。第 8 步通过两次调用merge完成了复制。如你所见,当你根据索引对齐多个 DataFrame 时,concat通常是比merge更好的选择。
在第 9 步,我们转变思路,聚焦于merge占优势的情况。merge方法是唯一能够根据列值对调用的 DataFrame 和传递的 DataFrame 进行对齐的方法。第 10 步展示了合并两个 DataFrame 的简单方法。on参数并非必需,但为了清晰起见提供。
不幸的是,当合并数据帧时很容易复制或丢弃数据,如第 10 步所示。在合并数据后进行一些合理性检查非常重要。在这个例子中,food_prices数据集在商店B中对steak的价格有重复,因此我们在第 11 步通过查询仅保留当前年份的行来删除了这一行。我们还改为左连接以确保每笔交易都被保留,无论价格是否存在。
在这些情况下可以使用联接,但是传递的数据帧中的所有列都必须首先移动到索引中。最后,当您打算根据其列中的值对齐数据时,使用concat将是一个不好的选择。
还有更多内容……
可以在不知道文件名的情况下从特定目录读取所有文件到数据帧中。Python 提供了几种迭代目录的方法,其中glob模块是一个流行的选择。天然气价格目录包含五个不同的 CSV 文件,每个文件从 2007 年开始每周记录一个特定等级的天然气价格。每个文件只有两列——每周的日期和价格。这是一个完美的情况,可以通过concat函数迭代所有文件,并将它们全部组合在一起。glob模块具有glob函数,它接受一个参数——要迭代的目录的位置作为字符串。要获取目录中的所有文件,请使用字符串 。在这个例子中,.csv 仅返回以 .csv 结尾的文件。glob函数的结果是一组字符串文件名,可以直接传递给read_csv函数:
>>> import glob
>>> df_list = []
>>> for filename in glob.glob('data/gas prices/*.csv'):
df_list.append(pd.read_csv(filename, index_col='Week',
parse_dates=['Week']))
>>> gas = pd.concat(df_list, axis='columns')
>>> gas.head()

另请参见
-
IPython
read_html函数的官方文档(bit.ly/2fzFRzd) -
参考Exploding indexes章节的食谱(来自第十二章,索引对齐)
连接到 SQL 数据库
要成为一名严肃的数据分析师,你几乎肯定需要学习一定量的 SQL。世界上大部分数据存储在接受 SQL 语句的数据库中。有许多关系型数据库管理系统,其中 SQLite 是最流行和易于使用的之一。
准备工作
我们将探索由 SQLite 提供的 Chinook 样例数据库,其中包含 11 张音乐商店数据表。首次深入研究合适的关系数据库时,最好做的事情之一是研究数据库图表(有时称为实体关系图),以更好地理解表之间的关系。在浏览此处的食谱时,以下图表将非常有帮助:

为了使这个步骤有效,您需要安装sqlalchemy Python 包。如果您安装了 Anaconda 发行版,那么它应该已经可用。在与数据库建立连接时,SQLAlchemy 是首选的 pandas 工具。在这个步骤中,您将学习如何连接到 SQLite 数据库。然后,您将提出两个不同的查询,并通过使用merge方法将表格连接在一起来回答它们。
如何做到...
- 在我们开始从
chinook数据库读取表格之前,我们需要设置我们的 SQLAlchemy 引擎:
>>> from sqlalchemy import create_engine
>>> engine = create_engine('sqlite:///data/chinook.db')
- 现在我们可以回到 pandas 的世界,并在剩余的步骤中继续待在那里。让我们完成一个简单的命令,并使用
read_sql_table函数读取tracks表格。表格的名称是第一个参数,SQLAlchemy 引擎是第二个参数:
>>> tracks = pd.read_sql_table('tracks', engine)
>>> tracks.head()
>>> genres = pd.read_sql_table('genres', engine)

- 在接下来的步骤中,我们将通过数据库图表回答一些不同的具体查询。首先,让我们找出每种流派歌曲的平均长度:
>>> genre_track = genres.merge(tracks[['GenreId', 'Milliseconds']],
on='GenreId', how='left') \
.drop('GenreId', axis='columns')
>>> genre_track.head()

- 现在我们可以轻松地找出每种流派歌曲的平均长度。为了帮助解释,我们将
Milliseconds列转换为timedelta数据类型:
>>> genre_time = genre_track.groupby('Name')['Milliseconds'].mean()
>>> pd.to_timedelta(genre_time, unit='ms').dt.floor('s')
.sort_values()
Name
Rock And Roll 00:02:14
Opera 00:02:54
Hip Hop/Rap 00:02:58
...
Drama 00:42:55
Science Fiction 00:43:45
Sci Fi & Fantasy 00:48:31
Name: Milliseconds, dtype: timedelta64[ns]
- 现在让我们找出每位顾客的总花费金额。我们将需要将
customers、invoices和invoice_items表格连接在一起:
>>> cust = pd.read_sql_table('customers', engine,
columns=['CustomerId','FirstName',
'LastName'])
>>> invoice = pd.read_sql_table('invoices', engine,
columns=['InvoiceId','CustomerId'])
>>> ii = pd.read_sql_table('invoice_items', engine,
columns=['InvoiceId', 'UnitPrice',
'Quantity'])
>>> cust_inv = cust.merge(invoice, on='CustomerId') \
.merge(ii, on='InvoiceId')
>>> cust_inv.head()

- 现在我们可以将数量乘以单价,然后找出每位顾客的总花费金额:
>>> total = cust_inv['Quantity'] * cust_inv['UnitPrice']
>>> cols = ['CustomerId', 'FirstName', 'LastName']
>>> cust_inv.assign(Total = total).groupby(cols)['Total'] \
.sum() \
.sort_values(ascending=False) \
.head()
CustomerId FirstName LastName
6 Helena Holý 49.62
26 Richard Cunningham 47.62
57 Luis Rojas 46.62
46 Hugh O'Reilly 45.62
45 Ladislav Kovács 45.62
Name: Total, dtype: float64
它的工作原理...
create_engine函数需要一个连接字符串才能正常工作。SQLite 的连接字符串非常简单,只是数据库的位置,它位于数据目录中。其他关系型数据库管理系统具有更复杂的连接字符串。您需要提供用户名、密码、主机名、端口号,以及可选的数据库。您还需要提供 SQL 方言和驱动程序。连接字符串的一般形式如下:dialect+driver://username:password@host:port/database。您的特定关系型数据库的驱动程序可能需要单独安装。
一旦我们创建了引擎,在第二步中使用read_sql_table函数将整个表格选择到 DataFrames 中非常容易。数据库中的每个表格都有一个主键,用于在图表中唯一标识每一行。在第三步中,我们通过GenreId将流派链接到音轨。由于我们只关心音轨长度,因此在执行合并之前,我们将音轨 DataFrame 减少到所需的列。表格合并后,我们可以通过基本的groupby操作回答查询。
我们更进一步,将整数形式的毫秒转换为更易于阅读的 Timedelta 对象。关键是将正确的度量单位作为字符串传递。现在我们有了一个 Timedelta Series,可以使用dt属性访问floor方法,它会将时间舍入到最接近的秒。
第五步所需的查询涉及三个表。我们可以通过将它们传递给columns参数,大大减少表格内容,仅保留我们需要的列。在使用merge时,如果连接列的名称相同,则这些列不会被保留。第六步中,我们本可以通过以下方式分配一个表示价格乘以数量的列:
cust_inv['Total'] = cust_inv['Quantity'] * cust_inv['UnitPrice']
以这种方式分配列是完全没有问题的。我们选择动态创建一个新的列,使用 assign 方法以便支持连续的方法链。
还有更多内容...
如果你熟悉 SQL,可以将 SQL 查询写成字符串并传递给read_sql_query函数。例如,以下查询将重现第四步的输出:
>>> sql_string1 = '''
select
Name,
time(avg(Milliseconds) / 1000, 'unixepoch') as avg_time
from (
select
g.Name,
t.Milliseconds
from
genres as g
join
tracks as t
on
g.genreid == t.genreid
)
group by
Name
order by
avg_time
'''
>>> pd.read_sql_query(sql_string1, engine)

要重现第六步的答案,请使用以下 SQL 查询:
>>> sql_string2 = '''
select
c.customerid,
c.FirstName,
c.LastName,
sum(ii.quantity * ii.unitprice) as Total
from
customers as c
join
invoices as i
on c.customerid = i.customerid
join
invoice_items as ii
on i.invoiceid = ii.invoiceid
group by
c.customerid, c.FirstName, c.LastName
order by
Total desc
'''
>>> pd.read_sql_query(sql_string2, engine)

另见
-
所有SQLAlchemy的引擎配置(
bit.ly/2kb07vV) -
Pandas 官方文档关于SQL 查询(
bit.ly/2fFsOQ8)




















是一个具有 m x n 维度的非负对角矩阵。






浙公网安备 33010602011771号