Python 数据科学手册第二版(六)
原文:
zh.annas-archive.org/md5/051facaf2908ae8198253e3a14b09ec1译者:飞龙
第三十五章:Matplotlib 中的三维绘图
Matplotlib 最初仅设计用于二维绘图。在 1.0 版本发布时,一些三维绘图工具建立在 Matplotlib 的二维显示之上,结果是一组便利的(虽然有些受限)用于三维数据可视化的工具。通过导入mplot3d工具包,可以启用三维绘图,这个工具包已经包含在主 Matplotlib 安装中:
In [1]: from mpl_toolkits import mplot3d
导入此子模块后,可以通过向任何常规坐标轴创建函数传递关键字projection='3d'来创建三维坐标轴,如下所示(见 Figure 35-1)。
In [2]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
In [3]: fig = plt.figure()
ax = plt.axes(projection='3d')
有了这个三维坐标轴,我们现在可以绘制各种三维图类型。三维绘图是从交互式视图中查看图形而不是静态图像中受益良多的功能之一;请记住,在运行此代码时,要使用交互式图形,可以使用%matplotlib notebook而不是%matplotlib inline。
Figure 35-1. 一个空的三维坐标轴
三维点和线
最基本的三维图是由一组(x, y, z)三元组创建的线条或散点图集合。类比之前讨论的常见二维图,可以使用ax.plot3D和ax.scatter3D函数创建这些图。这些函数的调用签名几乎与它们的二维对应物完全相同,因此您可以参考第二十六章和第二十七章以获取有关控制输出的更多信息。在这里,我们将绘制一个三角螺旋线,以及一些随机绘制在该线附近的点(见 Figure 35-2)。
In [4]: ax = plt.axes(projection='3d')
# Data for a three-dimensional line
zline = np.linspace(0, 15, 1000)
xline = np.sin(zline)
yline = np.cos(zline)
ax.plot3D(xline, yline, zline, 'gray')
# Data for three-dimensional scattered points
zdata = 15 * np.random.random(100)
xdata = np.sin(zdata) + 0.1 * np.random.randn(100)
ydata = np.cos(zdata) + 0.1 * np.random.randn(100)
ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap='Greens');
Figure 35-2. 三维中的点和线
请注意,散点的透明度已经调整,以便在页面上给人一种深度感。虽然在静态图像中有时难以看到三维效果,但交互式视图可以让您对点的布局有更好的直觉。
三维等高线图
类似于我们在 Chapter 28 中探讨的等高线图,mplot3d包含使用相同输入创建三维浮雕图的工具。与ax.contour类似,ax.contour3D要求所有输入数据都以二维规则网格的形式提供,z数据在每个点进行评估。在这里,我们将展示一个三维正弦函数的等高线图(见 Figure 35-3)。
In [5]: def f(x, y):
return np.sin(np.sqrt(x ** 2 + y ** 2))
x = np.linspace(-6, 6, 30)
y = np.linspace(-6, 6, 30)
X, Y = np.meshgrid(x, y)
Z = f(X, Y)
In [6]: fig = plt.figure()
ax = plt.axes(projection='3d')
ax.contour3D(X, Y, Z, 40, cmap='binary')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z');
Figure 35-3. 一个三维等高线图
有时默认的视角不是最佳的,这时我们可以使用view_init方法来设置仰角和方位角。在下面的例子中,可视化效果见图 35-4,我们将使用仰角 60 度(即相对于 x-y 平面上方 60 度)和方位角 35 度(即相对于 z 轴逆时针旋转 35 度):
In [7]: ax.view_init(60, 35)
fig
同样地,请注意,当使用 Matplotlib 的交互式后端时,可以通过点击和拖动来实现这种类型的旋转。
图 35-4. 调整三维绘图的视角角度
线框和表面绘图
另外两种适用于网格数据的三维绘图类型是线框图和表面绘图。它们接受值网格并将其投影到指定的三维表面上,可以使得最终的三维形态非常易于可视化。这里有一个使用线框的例子(见图 35-5)。
In [8]: fig = plt.figure()
ax = plt.axes(projection='3d')
ax.plot_wireframe(X, Y, Z)
ax.set_title('wireframe');
图 35-5. 一个线框图
表面绘图类似于线框图,但线框的每个面都是填充多边形。为填充的多边形添加颜色映射可以帮助感知所可视化表面的拓扑结构,正如您在图 35-6 中看到的那样。
In [9]: ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap='viridis', edgecolor='none')
ax.set_title('surface');
图 35-6. 一个三维表面绘图
尽管表面绘图的值网格需要是二维的,但不一定是矩形的。这里有一个创建部分极坐标网格的例子,当与surface3D绘图结合使用时,可以为我们提供所可视化函数的一个切片(见图 35-7)。
In [10]: r = np.linspace(0, 6, 20)
theta = np.linspace(-0.9 * np.pi, 0.8 * np.pi, 40)
r, theta = np.meshgrid(r, theta)
X = r * np.sin(theta)
Y = r * np.cos(theta)
Z = f(X, Y)
ax = plt.axes(projection='3d')
ax.plot_surface(X, Y, Z, rstride=1, cstride=1,
cmap='viridis', edgecolor='none');
图 35-7. 一个极坐标表面绘图
表面三角剖分
对于某些应用程序,前述例程需要均匀采样的网格太过限制。在这些情况下,基于三角剖分的绘图就很有用了。如果我们不是从笛卡尔或极坐标网格中均匀绘制,而是有一组随机绘制呢?
In [11]: theta = 2 * np.pi * np.random.random(1000)
r = 6 * np.random.random(1000)
x = np.ravel(r * np.sin(theta))
y = np.ravel(r * np.cos(theta))
z = f(x, y)
我们可以创建一个散点图来了解我们正在抽样的表面,如图 35-8 所示。
In [12]: ax = plt.axes(projection='3d')
ax.scatter(x, y, z, c=z, cmap='viridis', linewidth=0.5);
图 35-8. 一个三维采样表面
这个点云留下了许多问题。在这种情况下帮助我们的函数是ax.plot_trisurf,它通过首先在相邻点之间找到一组三角形来创建表面(请记住这里的x、y和z是一维数组);结果如图 35-9 所示(见图 35-9):
In [13]: ax = plt.axes(projection='3d')
ax.plot_trisurf(x, y, z,
cmap='viridis', edgecolor='none');
结果当然不像使用网格绘制时那么干净,但这种三角剖分的灵活性允许一些非常有趣的三维绘图。例如,实际上可以使用这种方法绘制一个三维莫比乌斯带,我们接下来会看到。
第 35-9 图。一个三角形表面绘图
示例:可视化莫比乌斯带
一个莫比乌斯带类似于一条纸条在环上粘贴成一个半扭曲的带子,结果是一个只有一个面的对象!在这里,我们将使用 Matplotlib 的三维工具可视化这样的对象。创建莫比乌斯带的关键是考虑它的参数化:它是一个二维带子,所以我们需要两个内在维度。让我们称之为 θ,它在环周围从 0 到 2 π,以及 w,它在带子宽度上从 -1 到 1:
In [14]: theta = np.linspace(0, 2 * np.pi, 30)
w = np.linspace(-0.25, 0.25, 8)
w, theta = np.meshgrid(w, theta)
现在从这个参数化中,我们必须确定嵌入带的 (x, y, z) 位置。
思考一下,我们可能会意识到有两个旋转正在发生:一个是环绕其中心的位置旋转(我们称之为 θ),而另一个是带子围绕其轴线的扭曲(我们将其称为 φ)。对于一个莫比乌斯带,我们必须使带子在完整环的过程中进行半扭曲,即 Δ φ = Δ θ / 2:
In [15]: phi = 0.5 * theta
现在我们利用我们对三角函数的记忆来推导三维嵌入。我们定义 r,每个点到中心的距离,并使用它来找到嵌入的 ( x , y , z ) 坐标:
In [16]: # radius in x-y plane
r = 1 + w * np.cos(phi)
x = np.ravel(r * np.cos(theta))
y = np.ravel(r * np.sin(theta))
z = np.ravel(w * np.sin(phi))
最后,为了绘制这个对象,我们必须确保三角剖分是正确的。最好的方法是在基本参数化内定义三角剖分,然后让 Matplotlib 将这个三角剖分投影到莫比乌斯带的三维空间中。可以通过以下方式实现这一点(见 第 35-10 图)。
In [17]: # triangulate in the underlying parametrization
from matplotlib.tri import Triangulation
tri = Triangulation(np.ravel(w), np.ravel(theta))
ax = plt.axes(projection='3d')
ax.plot_trisurf(x, y, z, triangles=tri.triangles,
cmap='Greys', linewidths=0.2);
ax.set_xlim(-1, 1); ax.set_ylim(-1, 1); ax.set_zlim(-1, 1)
ax.axis('off');
第 35-10 图。可视化莫比乌斯带
结合所有这些技术,可以在 Matplotlib 中创建和显示各种三维对象和图案。
第三十六章:可视化与 Seaborn
Matplotlib 已经是 Python 科学可视化的核心工具数十年了,但即使是忠实的用户也会承认它经常留下很多不足之处。关于 Matplotlib 经常提到的几个抱怨有:
-
一个常见的早期抱怨,现在已经过时:在 2.0 版本之前,Matplotlib 的颜色和样式默认值有时很差,并显得过时。
-
Matplotlib 的 API 相对较低级。虽然可以进行复杂的统计可视化,但通常需要大量的样板代码。
-
Matplotlib 比 Pandas 早十多年,因此不设计用于与 Pandas 的
DataFrame对象一起使用。为了可视化DataFrame中的数据,必须提取每个Series并经常将它们连接成正确的格式。更好的是有一个可以智能使用DataFrame标签进行绘图的绘图库。
解决这些问题的一个答案是Seaborn。Seaborn 在 Matplotlib 之上提供了一个 API,提供了合理的绘图样式和颜色默认设置,定义了常见统计绘图类型的简单高级函数,并与 Pandas 提供的功能集成。
公平地说,Matplotlib 团队已经适应了不断变化的环境:它添加了在第三十四章讨论的plt.style工具,并且 Matplotlib 开始更无缝地处理 Pandas 数据。但基于刚讨论的所有原因,Seaborn 仍然是一个有用的附加组件。
按照惯例,Seaborn 通常被导入为sns:
In [1]: %matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import pandas as pd
sns.set() # seaborn's method to set its chart style
注意
全彩色图像可以在GitHub 上的补充材料中找到。
探索 Seaborn 绘图
Seaborn 的主要思想是提供高级命令来创建各种对统计数据探索有用的绘图类型,甚至一些统计模型拟合。
让我们看看 Seaborn 中提供的几个数据集和绘图类型。请注意,所有以下内容都可以使用原始的 Matplotlib 命令完成(实际上,Seaborn 在幕后确实这样做),但 Seaborn 的 API 更加方便。
直方图,KDE 和密度
在统计数据可视化中,您通常只想绘制变量的直方图和联合分布。我们已经看到在 Matplotlib 中这相对比较简单(见图 36-1)。
In [2]: data = np.random.multivariate_normal([0, 0], [[5, 2], [2, 2]], size=2000)
data = pd.DataFrame(data, columns=['x', 'y'])
for col in 'xy':
plt.hist(data[col], density=True, alpha=0.5)
图 36-1 直方图可视化分布
不仅仅是提供直方图作为可视化输出,我们还可以使用核密度估计获得分布的平滑估计(在第二十八章介绍),Seaborn 通过sns.kdeplot来实现(参见图 36-2)。
In [3]: sns.kdeplot(data=data, shade=True);
图 36-2 核密度估计可视化分布
如果我们将x和y列传递给kdeplot,我们将得到一个二维可视化的联合密度(见图 36-3)。
In [4]: sns.kdeplot(data=data, x='x', y='y');
图 36-3. 一个二维核密度图
我们可以使用sns.jointplot一起查看联合分布和边缘分布,稍后在本章中我们将进一步探讨。
对角线图
当你将联合图推广到更大维度的数据集时,最终会得到对角线图。当您希望将所有值的所有对组合在一起时,这对于探索多维数据之间的相关性非常有用。
我们将使用众所周知的鸢尾花数据集演示这一点,该数据集列出了三种鸢尾花物种的花瓣和萼片的测量值:
In [5]: iris = sns.load_dataset("iris")
iris.head()
Out[5]: sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa
将样本之间的多维关系可视化就像调用sns.pairplot一样简单(见图 36-4)。
In [6]: sns.pairplot(iris, hue='species', height=2.5);
图 36-4. 显示四个变量之间关系的对角线图
分面直方图
有时查看数据的最佳方式是通过子集的直方图,如图 36-5 所示。Seaborn 的FacetGrid使得这变得简单。我们将查看一些数据,显示餐厅员工根据各种指标数据获得的小费金额:^(1)
In [7]: tips = sns.load_dataset('tips')
tips.head()
Out[7]: total_bill tip sex smoker day time size
0 16.99 1.01 Female No Sun Dinner 2
1 10.34 1.66 Male No Sun Dinner 3
2 21.01 3.50 Male No Sun Dinner 3
3 23.68 3.31 Male No Sun Dinner 2
4 24.59 3.61 Female No Sun Dinner 4
In [8]: tips['tip_pct'] = 100 * tips['tip'] / tips['total_bill']
grid = sns.FacetGrid(tips, row="sex", col="time", margin_titles=True)
grid.map(plt.hist, "tip_pct", bins=np.linspace(0, 40, 15));
图 36-5. 一个分面直方图的示例
分面图为我们提供了一些关于数据集的快速见解:例如,我们看到它包含的关于晚餐时间男服务员的数据远远多于其他类别,并且典型的小费金额似乎在约 10%到 20%之间,两端都有一些异常值。
分类图
分类图也可以用于这种类型的可视化。这允许您查看由任何其他参数定义的箱中参数的分布,如图 36-6 所示。
In [9]: with sns.axes_style(style='ticks'):
g = sns.catplot(x="day", y="total_bill", hue="sex",
data=tips, kind="box")
g.set_axis_labels("Day", "Total Bill");
图 36-6. 一个因子图的示例,比较给定各种离散因子的分布
联合分布
类似于我们之前看到的对角线图,我们可以使用sns.jointplot显示不同数据集之间的联合分布,以及相关的边缘分布(见图 36-7)。
In [10]: with sns.axes_style('white'):
sns.jointplot(x="total_bill", y="tip", data=tips, kind='hex')
图 36-7. 一个联合分布图
联合图甚至可以进行一些自动的核密度估计和回归,如图 36-8 所示。
In [11]: sns.jointplot(x="total_bill", y="tip", data=tips, kind='reg');
图 36-8. 带有回归拟合的联合分布图
条形图
可以使用sns.factorplot来绘制时间序列。在下面的示例中,我们将使用我们在第二十章中首次看到的 Planets 数据集;参见图 36-9 的结果。
In [12]: planets = sns.load_dataset('planets')
planets.head()
Out[12]: method number orbital_period mass distance year
0 Radial Velocity 1 269.300 7.10 77.40 2006
1 Radial Velocity 1 874.774 2.21 56.95 2008
2 Radial Velocity 1 763.000 2.60 19.84 2011
3 Radial Velocity 1 326.030 19.40 110.62 2007
4 Radial Velocity 1 516.220 10.50 119.47 2009
In [13]: with sns.axes_style('white'):
g = sns.catplot(x="year", data=planets, aspect=2,
kind="count", color='steelblue')
g.set_xticklabels(step=5)
图 36-9。柱状图作为因子图的特例
通过查看每个行星的发现方法,我们可以更多地了解这些行星(参见图 36-10)。
In [14]: with sns.axes_style('white'):
g = sns.catplot(x="year", data=planets, aspect=4.0, kind='count',
hue='method', order=range(2001, 2015))
g.set_ylabels('Number of Planets Discovered')
图 36-10。按年份和类型发现的行星数量
有关使用 Seaborn 绘图的更多信息,请参见Seaborn 文档,特别是示例画廊。
示例:探索马拉松完成时间
在这里,我们将使用 Seaborn 来帮助可视化和理解马拉松的完成结果。^(2) 我从网络上的来源爬取了数据,进行了汇总并删除了任何识别信息,并将其放在了 GitHub 上,可以下载。^(3)
我们将从下载数据并加载到 Pandas 开始:
In [15]: # url = ('https://raw.githubusercontent.com/jakevdp/'
# 'marathon-data/master/marathon-data.csv')
# !cd data && curl -O {url}
In [16]: data = pd.read_csv('data/marathon-data.csv')
data.head()
Out[16]: age gender split final
0 33 M 01:05:38 02:08:51
1 32 M 01:06:26 02:09:28
2 31 M 01:06:49 02:10:42
3 38 M 01:06:16 02:13:45
4 31 M 01:06:32 02:13:59
请注意,Pandas 将时间列加载为 Python 字符串(类型为object);我们可以通过查看DataFrame的dtypes属性来看到这一点:
In [17]: data.dtypes
Out[17]: age int64
gender object
split object
final object
dtype: object
让我们通过为时间提供一个转换器来修复这个问题。
In [18]: import datetime
def convert_time(s):
h, m, s = map(int, s.split(':'))
return datetime.timedelta(hours=h, minutes=m, seconds=s)
data = pd.read_csv('data/marathon-data.csv',
converters={'split':convert_time, 'final':convert_time})
data.head()
Out[18]: age gender split final
0 33 M 0 days 01:05:38 0 days 02:08:51
1 32 M 0 days 01:06:26 0 days 02:09:28
2 31 M 0 days 01:06:49 0 days 02:10:42
3 38 M 0 days 01:06:16 0 days 02:13:45
4 31 M 0 days 01:06:32 0 days 02:13:59
In [19]: data.dtypes
Out[19]: age int64
gender object
split timedelta64[ns]
final timedelta64[ns]
dtype: object
这将使时间数据更容易处理。为了我们的 Seaborn 绘图实用工具的目的,让我们接下来添加列,以秒为单位给出时间:
In [20]: data['split_sec'] = data['split'].view(int) / 1E9
data['final_sec'] = data['final'].view(int) / 1E9
data.head()
Out[20]: age gender split final split_sec final_sec
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
为了了解数据的外观,我们可以在数据上绘制一个jointplot;图 36-11 显示了结果。
In [21]: with sns.axes_style('white'):
g = sns.jointplot(x='split_sec', y='final_sec', data=data, kind='hex')
g.ax_joint.plot(np.linspace(4000, 16000),
np.linspace(8000, 32000), ':k')
虚线显示了如果某人以完全稳定的速度跑完马拉松,其时间会在哪里。分布高于此线表明(正如您所料),大多数人在马拉松比赛过程中放慢了速度。如果您曾经参加过竞争性比赛,您会知道那些在比赛的第二半段跑得更快的人被称为“负分裂”比赛。
图 36-11。半马拉松第一半分裂与全马拉松完成时间之间的关系
让我们在数据中创建另一列,即分裂比例,用于衡量每位选手进行负分裂或正分裂比赛的程度。
In [22]: data['split_frac'] = 1 - 2 * data['split_sec'] / data['final_sec']
data.head()
Out[22]: age gender split final split_sec final_sec \
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
split_frac
0 -0.018756
1 -0.026262
2 -0.022443
3 0.009097
4 0.006842
在这个分裂差小于零的地方,这个人通过这个比例进行了负分裂比赛。让我们做一个这个分裂比例的分布图(参见图 36-12)。
In [23]: sns.displot(data['split_frac'], kde=False)
plt.axvline(0, color="k", linestyle="--");
图 36-12。分裂比例的分布;0.0 表示在相同时间内完成了第一半和第二半马拉松的跑步者
In [24]: sum(data.split_frac < 0)
Out[24]: 251
在将近 40,000 名参与者中,只有 250 人实现了负分裂的马拉松。
让我们看看这种分裂比例与其他变量是否有任何相关性。我们将使用PairGrid来完成这个任务,它会绘制所有这些相关性的图表(见图 36-13)。
In [25]: g = sns.PairGrid(data, vars=['age', 'split_sec', 'final_sec', 'split_frac'],
hue='gender', palette='RdBu_r')
g.map(plt.scatter, alpha=0.8)
g.add_legend();
图 36-13. 马拉松数据集内部量之间的关系
分裂比例看起来与年龄没有特别相关性,但与最终时间相关:跑得更快的人往往在马拉松中有更接近均匀分裂的趋势。让我们来看一下按性别分隔的分裂比例直方图,显示在图 36-14 中。
In [26]: sns.kdeplot(data.split_frac[data.gender=='M'], label='men', shade=True)
sns.kdeplot(data.split_frac[data.gender=='W'], label='women', shade=True)
plt.xlabel('split_frac');
图 36-14. 按性别分布的分裂比例
这里有趣的是,有很多男性比女性更接近均匀分裂!在男性和女性中间几乎呈双峰分布。让我们看看是否可以通过年龄的函数来解析正在发生的事情。
比较分布的一个好方法是使用小提琴图,显示在图 36-15 中。
In [27]: sns.violinplot(x="gender", y="split_frac", data=data,
palette=["lightblue", "lightpink"]);
图 36-15. 显示按性别分裂比例的小提琴图
让我们深入一点,将这些小提琴图作为年龄的函数进行比较(见图 36-16)。我们将从创建一个新的列开始,该列指定每个人所在的年龄范围,按十年计算:
In [28]: data['age_dec'] = data.age.map(lambda age: 10 * (age // 10))
data.head()
Out[28]: age gender split final split_sec final_sec \
0 33 M 0 days 01:05:38 0 days 02:08:51 3938.0 7731.0
1 32 M 0 days 01:06:26 0 days 02:09:28 3986.0 7768.0
2 31 M 0 days 01:06:49 0 days 02:10:42 4009.0 7842.0
3 38 M 0 days 01:06:16 0 days 02:13:45 3976.0 8025.0
4 31 M 0 days 01:06:32 0 days 02:13:59 3992.0 8039.0
split_frac age_dec
0 -0.018756 30
1 -0.026262 30
2 -0.022443 30
3 0.009097 30
4 0.006842 30
In [29]: men = (data.gender == 'M')
women = (data.gender == 'W')
with sns.axes_style(style=None):
sns.violinplot(x="age_dec", y="split_frac", hue="gender", data=data,
split=True, inner="quartile",
palette=["lightblue", "lightpink"]);
图 36-16. 显示按性别和年龄分裂比例的小提琴图
我们可以看到男性和女性之间分布的不同之处:20 到 50 岁男性的分裂分布向较低分裂过度密集,而与同龄的女性(或者任何年龄段的女性)相比如此。
同样令人惊讶的是,80 岁的女性似乎在分裂时间方面表现出色,尽管这可能是一个小数量效应,因为该范围内的参与者寥寥无几:
In [30]: (data.age > 80).sum()
Out[30]: 7
回到有负分裂的男性:这些跑步者是谁?这种分裂比例是否与快速完成相关联?我们可以轻松地绘制这个图表。我们将使用regplot,它会自动适应数据的线性回归模型(见图 36-17)。
In [31]: g = sns.lmplot(x='final_sec', y='split_frac', col='gender', data=data,
markers=".", scatter_kws=dict(color='c'))
g.map(plt.axhline, y=0.0, color="k", ls=":");
图 36-17. 按性别比较分裂比例与完成时间
显然,无论是男性还是女性,分裂较快的人往往是在大约 15,000 秒内或约 4 小时内完成的更快的跑步者。比这慢的人很少有快速的第二分裂。
进一步的资源
一本书的一部分永远无法涵盖 Matplotlib 中所有可用的特性和绘图类型。与其他包一样,IPython 的 Tab 键补全和帮助功能(参见 第一章)在探索 Matplotlib 的 API 时非常有帮助。此外,Matplotlib 的 在线文档 是一个有用的参考。特别是查看 Matplotlib 图库,展示了数百种不同的绘图样式缩略图,每个缩略图都链接到一个页面,展示了生成它的 Python 代码片段。这使你能够视觉检查和学习各种不同的绘图风格和可视化技术。
对于 Matplotlib 的书籍级处理,我推荐 Interactive Applications Using Matplotlib(Packt),作者是 Matplotlib 核心开发者 Ben Root。
其他 Python 可视化库
尽管 Matplotlib 是最显著的 Python 可视化库,但还有其他更现代的工具也值得探索。我将在这里简要提及其中一些:
-
Bokeh 是一个 JavaScript 可视化库,具有 Python 前端,创建高度交互式的可视化,能够处理非常大和/或流式数据集。
-
Plotly 是 Plotly 公司的代表性开源产品,与 Bokeh 类似。它正在积极开发中,并提供各种交互式图表类型。
-
HoloViews 是一个更为声明性的统一 API,用于在多种后端生成图表,包括 Bokeh 和 Matplotlib。
-
Vega 和 Vega-Lite 是声明性的图形表示,是多年数据可视化和交互研究的成果。参考渲染实现为 JavaScript,而 Altair package 提供了生成这些图表的 Python API。
Python 世界中的可视化景观在不断发展,我预计这份列表在本书出版时可能已经过时。此外,由于 Python 在许多领域中被广泛使用,你会发现许多为更具体用例构建的其他可视化工具。要跟踪所有这些工具可能有些困难,但了解这些广泛的可视化工具的好资源是 PyViz,一个开放的、社区驱动的网站,包含许多不同可视化工具的教程和示例。
^(1) 本节使用的餐厅员工数据将员工分为两性:女性和男性。生物性别并非二元的,但以下讨论和可视化受到此数据的限制。
^(2) 本节中使用的马拉松数据将跑步者分为两个性别:男性和女性。虽然性别是一个光谱,但以下讨论和可视化使用这个二元性别,因为它们依赖于数据。
^(3) 如果您有兴趣使用 Python 进行网络抓取,我推荐由 O'Reilly 的 Ryan Mitchell 撰写的 Web Scraping with Python。
第五部分:机器学习
这一最终部分是对机器学习这一非常广泛的主题的介绍,主要通过 Python 的Scikit-Learn 包来进行。您可以将机器学习视为一类算法,允许程序检测数据集中的特定模式,从而“学习”并从中推断。这并不意味着要对机器学习领域进行全面介绍;这是一个庞大的主题,需要比我们这里采取的更加技术化的方法。它也不是 Scikit-Learn 包使用的全面手册(对此,您可以参考“更多机器学习资源”中列出的资源)。相反,这里的目标是:
-
引入机器学习的基本词汇和概念
-
引入 Scikit-Learn API 并展示其使用示例
-
深入研究几种更重要的经典机器学习方法的细节,培养对它们如何工作以及何时何地适用的直觉
这部分内容大部分源自我在 PyCon、SciPy、PyData 及其他会议上多次举办的 Scikit-Learn 教程和研讨会。以下页面的任何清晰度可能都归功于多年来参与这些材料反馈的许多研讨会参与者和共同指导!
第三十七章:什么是机器学习?
在我们深入了解几种机器学习方法的细节之前,让我们先来看看机器学习的定义及其非定义部分。机器学习通常被归类为人工智能的一个子领域,但我发现这种分类可能会产生误导。机器学习的研究确实起源于这一背景的研究,但在数据科学应用机器学习方法时,将机器学习视为一种构建数据模型的手段更为有帮助。
在这种情况下,“学习”进入战场时,我们为这些模型提供可调参数,这些参数可以根据观察到的数据进行调整;通过这种方式,程序可以被认为是从数据中“学习”。一旦这些模型适应于先前看到的数据,它们就可以用于预测和理解新观察到的数据的各个方面。关于这种基于数学模型的“学习”与人脑展现的“学习”在多大程度上相似,我将留给读者更多哲学的探讨。
理解机器学习中的问题设置对有效使用这些工具至关重要,因此我们将从这里讨论的一些方法类型的广泛分类开始。
注
本章中所有图表均基于实际机器学习计算生成;其背后的代码可以在在线附录中找到。
机器学习的分类
机器学习可以分为两种主要类型:监督学习和无监督学习。
监督学习涉及对数据的测量特征与与数据相关的一些标签之间关系的建模;确定了此模型后,它可以用于对新的未知数据应用标签。有时这进一步细分为分类任务和回归任务:在分类中,标签是离散类别,而在回归中,标签是连续数量。您将在以下部分看到这两种类型的监督学习的例子。
无监督学习涉及对数据集的特征进行建模,而不参考任何标签。这些模型包括诸如聚类和降维等任务。聚类算法识别数据的不同组,而降维算法寻找数据更简洁的表示。您也将在以下部分看到这两种类型的无监督学习的例子。
此外,还有所谓的半监督学习方法,介于监督学习和无监督学习之间。半监督学习方法在只有不完整标签可用时通常很有用。
机器学习应用的定性例子
为了使这些想法更具体,让我们来看一些非常简单的机器学习任务示例。这些示例旨在给出本书这部分将要讨论的机器学习任务类型的直观非量化概述。在后面的章节中,我们将更深入地讨论特定的模型以及它们的使用方式。如果想预览这些更技术性的方面,可以在在线附录中找到生成这些图表的 Python 源代码。
分类:预测离散标签
首先,我们来看一个简单的分类任务,我们会获得一组带标签的点,并希望利用这些点来对一些未标记的点进行分类。
想象一下我们拥有的数据显示在图 37-1 中。这些数据是二维的:也就是说,每个点有两个特征,由点在平面上的(x,y)位置表示。此外,每个点有两个类别标签之一,这里由点的颜色表示。通过这些特征和标签,我们希望创建一个模型,让我们能够决定一个新点应该被标记为“蓝色”还是“红色”。
图 37-1. 用于分类的简单数据集
对于这样的分类任务,有许多可能的模型,但我们将从一个非常简单的模型开始。我们假设这两组数据可以通过在它们之间绘制一条直线来分开,这样,线的两边的点都属于同一组。这里的模型是声明“一条直线分隔类别”的定量版本,而模型参数则是描述该线在我们的数据中位置和方向的特定数值。这些模型参数的最佳值是从数据中学习得来的(这就是机器学习中的“学习”),通常称为训练模型。
图 37-2 展示了这个数据的训练模型的视觉表示。
图 37-2. 一个简单的分类模型
现在这个模型已经被训练好了,它可以推广到新的未标记数据上。换句话说,我们可以拿到新的数据集,通过这条线进行划分,并根据这个模型为新点分配标签(参见图 37-3)。这个阶段通常被称为预测。
图 37-3. 将分类模型应用于新数据
这是机器学习分类任务的基本概念,其中“分类”表示数据具有离散的类标签。乍一看,这可能显得微不足道:很容易看到我们的数据并绘制这样的分界线来完成分类。然而,机器学习方法的好处在于它能够推广到更大的数据集和更多的维度。例如,这类似于电子邮件自动垃圾邮件检测的任务。在这种情况下,我们可能会使用以下特征和标签:
-
特征 1, 特征 2 等 → 重要单词或短语的标准化计数(如“伟哥”,“延长保修”等)
-
标签 → “垃圾邮件”或“非垃圾邮件”
对于训练集,这些标签可能是通过对一小部分代表性电子邮件的个别检查来确定的;对于其余的电子邮件,标签将使用模型确定。对于足够训练良好且特征构造良好的分类算法(通常有数千或数百万个单词或短语),这种方法非常有效。我们将在第四十一章中看到一个基于文本的分类的示例。
我们将详细讨论的一些重要分类算法包括高斯朴素贝叶斯(见第四十一章)、支持向量机(见第四十三章)和随机森林分类(见第四十四章)。
回归:预测连续标签
与分类算法的离散标签相比,我们接下来将看一个简单的回归任务,其中标签是连续的量。
考虑图 37-4 中显示的数据,其中包含一组具有连续标签的点。
图 37-4. 用于回归的简单数据集
就像分类示例一样,我们有二维数据:也就是说,每个数据点有两个描述特征。每个点的颜色代表该点的连续标签。
我们可以使用多种可能的回归模型来处理这类数据,但在这里我们将使用简单的线性回归模型来预测这些点。这个简单的模型假设,如果我们将标签视为第三个空间维度,我们可以将一个平面拟合到数据中。这是对将两个坐标数据拟合一条直线这一已知问题的更高级的泛化。
我们可以将这种设置视觉化,如图 37-5 所示。
图 37-5. 回归数据的三维视图
注意,这里的 特征 1–特征 2 平面与 Figure 37-4 中的二维图是相同的;然而,在这种情况下,我们通过颜色和三维轴位置表示了标签。从这个视角看,通过这三维数据拟合平面来预测任何输入参数的预期标签似乎是合理的。回到二维投影,当我们拟合这样一个平面时,我们得到了 Figure 37-6 中显示的结果。
Figure 37-6. 回归模型的表示
这个拟合平面为我们提供了预测新点标签所需的信息。从视觉上看,我们找到了在 Figure 37-7 中展示的结果。
Figure 37-7. 应用回归模型到新数据上
与分类示例一样,这个任务在低维度下可能看起来微不足道。但这些方法的力量在于它们可以在具有许多特征的数据中直接应用和评估。例如,这类似于通过望远镜观测到的星系的距离任务——在这种情况下,我们可能使用以下特征和标签:
-
特征 1、特征 2 等 → 每个星系在几个波长或颜色之一上的亮度
-
标签 → 星系的距离或红移
对于其中一小部分星系的距离可能通过独立的(通常更昂贵或复杂)观测来确定。然后可以使用适当的回归模型估计其余星系的距离,而无需在整个集合上使用更昂贵的观测。在天文学界,这被称为“光度红移”问题。
我们将讨论的一些重要回归算法包括线性回归(参见 Chapter 42)、支持向量机(参见 Chapter 43)和随机森林回归(参见 Chapter 44)。
聚类:推断未标记数据的标签
我们刚刚看到的分类和回归示例都是监督学习算法的例子,我们试图建立一个可以预测新数据标签的模型。无监督学习涉及描述数据而不涉及任何已知标签的模型。
无监督学习的一个常见情况是“聚类”,其中数据自动分配给一些离散的组。例如,我们可能有一些类似于 Figure 37-8 中所示的二维数据。
Figure 37-8. 聚类示例数据
通过目测,很明显每个点都属于一个明显的组。基于数据的内在结构,聚类模型将确定哪些点是相关的。使用非常快速和直观的k-means 算法(参见第四十七章),我们找到如图 37-9 所示的聚类。
图 37-9. 使用 k-means 聚类模型标记的数据
k-means 算法适配了一个模型,包括k个聚类中心;最优的中心被认为是最小化每个点到其分配中心距离的那些中心。再次强调,在二维数据中这可能看起来像是一个微不足道的练习,但随着数据变得更大更复杂,这样的聚类算法可以继续从数据集中提取有用信息。
我们将在第四十七章更深入地讨论k-means 算法。其他重要的聚类算法包括高斯混合模型(参见第四十八章)和谱聚类(参见Scikit-Learn 的聚类文档)。
降维:推断未标记数据的结构
降维是无监督算法的另一个示例,其中标签或其他信息是从数据集本身的结构中推断出来的。降维比我们之前看过的例子更加抽象,但通常它试图提取数据的一些低维表示,以某种方式保留完整数据集的相关特性。不同的降维例程以不同的方式衡量这些相关特性,正如我们将在第四十六章中看到的那样。
例如,考虑显示在图 37-10 中的数据。
图 37-10. 降维的示例数据
从视觉上看,很明显这些数据中存在一些结构:它们来自一个一维线,在二维空间内以螺旋的方式排列。从某种意义上说,你可以说这些数据“本质上”只有一维,尽管这些一维数据嵌入在二维空间中。在这种情况下,一个合适的降维模型应该对这种非线性嵌入结构敏感,并能够检测到这种较低维度的表示。
图 37-11 展示了 Isomap 算法的结果可视化,这是一种能够实现这一目标的流形学习算法。
请注意,颜色(代表提取的一维潜变量)沿螺旋线均匀变化,这表明算法确实检测到了我们肉眼看到的结构。与前面的例子一样,降维算法在高维情况下的作用变得更加明显。例如,我们可能希望可视化一个具有 100 或 1000 个特征的数据集中的重要关系。可视化 1000 维数据是一项挑战,我们可以通过使用降维技术将数据降低到 2 或 3 维来使其更易管理。
我们将讨论一些重要的降维算法,包括主成分分析(参见第四十五章)和各种流形学习算法,包括 Isomap 和局部线性嵌入(参见第四十六章)。
图 37-11. 通过降维学习得到的带标签数据
总结
在这里,我们看到了一些基本的机器学习方法的简单示例。不用说,有许多重要的实际细节我们没有详细讨论,但本章旨在让您了解机器学习方法可以解决哪些类型的问题的基本概念。
简而言之,我们看到了以下内容:
-
监督学习:基于标记的训练数据可以预测标签的模型。
-
分类:预测两个或更多离散类别标签的模型
-
回归:预测连续标签的模型
-
-
无监督学习:识别无标签数据中结构的模型
-
聚类:检测并识别数据中不同组的模型
-
降维:检测并识别高维数据中的低维结构的模型
-
在接下来的章节中,我们将深入探讨这些类别,并看到这些概念在哪些场景中更加有用。
第三十八章:介绍 Scikit-Learn
几个 Python 库提供了一系列机器学习算法的可靠实现。其中最著名的之一是Scikit-Learn,它提供了大量常见算法的高效版本。Scikit-Learn 具有清晰、统一和简化的 API,以及非常有用和完整的文档。统一性的好处在于,一旦你理解了 Scikit-Learn 一种类型模型的基本用法和语法,切换到新模型或算法就变得简单。
本章概述了 Scikit-Learn API。对这些 API 元素的扎实理解将为理解以下章节中关于机器学习算法和方法的深入实践讨论奠定基础。
我们将从 Scikit-Learn 中的数据表示开始讲起,然后深入到估计器 API,最后通过一个更有趣的示例,使用这些工具探索一组手写数字的图像。
Scikit-Learn 中的数据表示
机器学习是关于从数据中创建模型的;因此,我们将从讨论如何表示数据开始。在 Scikit-Learn 中理解数据的最佳方式是以表格的形式思考。
基本表格是一个二维的数据网格,其中行代表数据集中的单个元素,列代表与这些元素的每一个相关的数量。例如,考虑 1936 年由罗纳德·费舍尔著名分析的鸢尾花数据集。我们可以使用Seaborn 库以 Pandas DataFrame 的形式下载这个数据集,并查看前几个条目:
In [1]: import seaborn as sns
iris = sns.load_dataset('iris')
iris.head()
Out[1]: sepal_length sepal_width petal_length petal_width species
0 5.1 3.5 1.4 0.2 setosa
1 4.9 3.0 1.4 0.2 setosa
2 4.7 3.2 1.3 0.2 setosa
3 4.6 3.1 1.5 0.2 setosa
4 5.0 3.6 1.4 0.2 setosa
这里的每一行数据都指的是单个观察到的花朵,行数是数据集中花朵的总数。通常,我们将矩阵的行称为样本,行数称为n_samples。
同样,数据的每一列都指代描述每个样本的特定定量信息。通常,我们将矩阵的列称为特征,列数称为n_features。
特征矩阵
表格布局清晰地表明信息可以被视为二维数字数组或矩阵,我们将其称为特征矩阵。按照惯例,这个矩阵通常存储在名为X的变量中。特征矩阵被假定为二维的,形状为[n_samples, n_features],最常见的情况是包含在 NumPy 数组或 Pandas DataFrame 中,尽管一些 Scikit-Learn 模型也接受 SciPy 稀疏矩阵。
样本(即行)始终指代数据集描述的单个对象。例如,样本可以表示一朵花、一个人、一个文档、一个图像、一个声音文件、一个视频、一个天文物体,或者任何你可以用一组定量测量来描述的东西。
特征(即列)始终指的是以定量方式描述每个样本的不同观察结果。特征通常是实值,但在某些情况下可能是布尔值或离散值。
目标数组
除了特征矩阵 X 外,我们通常还使用一个标签或目标数组,按照惯例,我们通常称之为 y。目标数组通常是一维的,长度为 n_samples,通常包含在一个 NumPy 数组或 Pandas Series 中。目标数组可以具有连续的数值,也可以是离散的类别/标签。虽然一些 Scikit-Learn 估计器确实处理多个目标值的情况,形式为二维 [n_samples, n_targets] 目标数组,但我们主要处理的是一维目标数组的常见情况。
一个常见的困惑点是目标数组与其他特征列的区别。目标数组的显著特征是它通常是我们希望从特征中预测出来的数量:在统计术语中,它是因变量。例如,考虑到前述数据,我们可能希望构建一个能够基于其他测量预测花卉种类的模型;在这种情况下,species 列将被视为目标数组。
有了这个目标数组,我们可以使用 Seaborn(在 第三十六章 中讨论)方便地可视化数据(参见 图 38-1)。
In [2]: %matplotlib inline
import seaborn as sns
sns.pairplot(iris, hue='species', height=1.5);
图 38-1. 鸢尾花数据集的可视化^(1)
为了在 Scikit-Learn 中使用,我们将从 DataFrame 中提取特征矩阵和目标数组,我们可以使用一些在 第三部分 中讨论过的 Pandas DataFrame 操作来完成:
In [3]: X_iris = iris.drop('species', axis=1)
X_iris.shape
Out[3]: (150, 4)
In [4]: y_iris = iris['species']
y_iris.shape
Out[4]: (150,)
总结一下,特征和目标值的预期布局如 图 38-2 所示。
图 38-2. Scikit-Learn 的数据布局^(2)
有了这些数据正确格式化,我们可以继续考虑 Scikit-Learn 的估计器 API。
估计器 API
Scikit-Learn API 的设计遵循以下指导原则,如 Scikit-Learn API 论文 所述:
一致性
所有对象共享从有限一组方法中提取的共同接口,并提供一致的文档。
检查
所有指定的参数值都作为公共属性公开。
有限的对象层次
Python 类表示算法,数据集使用标准格式(NumPy 数组、Pandas DataFrame 对象、SciPy 稀疏矩阵),参数名称使用标准的 Python 字符串。
组成
许多机器学习任务可以表示为更基础算法的序列,并且 Scikit-Learn 在可能的情况下会利用这一点。
合理的默认值
当模型需要用户指定的参数时,库会定义一个合适的默认值。
在实践中,一旦理解了基本原则,这些原则使得 Scikit-Learn 非常易于使用。Scikit-Learn 中的每个机器学习算法都是通过估计器 API 实现的,该 API 为广泛的机器学习应用提供了一致的接口。
API 的基础知识
在使用 Scikit-Learn 估计器 API 的步骤中,最常见的步骤如下:
-
通过从 Scikit-Learn 导入适当的估计器类来选择一个模型类。
-
通过用所需值实例化这个类来选择模型超参数。
-
按照本章前面概述的方式,将数据安排为特征矩阵和目标向量。
-
通过调用模型实例的
fit方法将模型拟合到您的数据中。 -
将模型应用于新数据:
-
对于监督学习,通常我们使用
predict方法为未知数据预测标签。 -
对于无监督学习,我们经常使用
transform或predict方法来转换或推断数据的属性。
-
现在我们将逐步展示几个简单的示例,应用监督和无监督学习方法。
监督学习示例:简单线性回归
作为这一过程的示例,让我们考虑一个简单的线性回归——即,将一条直线拟合到数据(x,y)的常见情况。我们将使用以下简单的数据作为我们回归示例的数据(见图 38-3)。
In [5]: import matplotlib.pyplot as plt
import numpy as np
rng = np.random.RandomState(42)
x = 10 * rng.rand(50)
y = 2 * x - 1 + rng.randn(50)
plt.scatter(x, y);
图 38-3. 线性回归数据
有了这些数据,我们可以使用前面提到的配方。我们将在接下来的几节中详细介绍这个过程。
1. 选择一个模型类
在 Scikit-Learn 中,每个模型类都由一个 Python 类表示。因此,例如,如果我们想计算一个简单的LinearRegression模型,我们可以导入线性回归类:
In [6]: from sklearn.linear_model import LinearRegression
注意,还有其他更一般的线性回归模型存在;您可以在sklearn.linear_model模块文档中了解更多信息。
2. 选择模型超参数
一个重要的点是,一个模型类并不等同于一个模型的实例。
一旦我们决定了我们的模型类,还有一些选项是开放给我们的。根据我们正在使用的模型类,我们可能需要回答以下一个或多个类似的问题:
-
我们想要拟合偏移量(即y-截距)吗?
-
我们希望模型被归一化吗?
-
我们想要预处理我们的特征以增加模型的灵活性吗?
-
我们希望在我们的模型中使用多少程度的正则化?
-
我们想使用多少个模型组件?
这些是在选择模型类别确定后必须做出的重要选择的示例。这些选择通常被表示为超参数,或者必须在将模型拟合到数据之前设置的参数。在 Scikit-Learn 中,通过在模型实例化时传递值来选择超参数。我们将探讨如何可以量化地选择超参数在第三十九章中。
对于我们的线性回归示例,我们可以实例化LinearRegression类,并指定我们希望使用fit_intercept超参数来拟合截距:
In [7]: model = LinearRegression(fit_intercept=True)
model
Out[7]: LinearRegression()
请记住,当实例化模型时,唯一的操作是存储这些超参数值。特别是,我们还没有将模型应用于任何数据:Scikit-Learn API 非常清楚地区分了模型选择和将模型应用于数据的行为。
3. 将数据排列成特征矩阵和目标向量
之前我们研究了 Scikit-Learn 的数据表示,这需要一个二维特征矩阵和一个一维目标数组。这里我们的目标变量y已经是正确的形式(长度为n_samples的数组),但我们需要对数据x进行整理,使其成为大小为[n_samples, n_features]的矩阵。
在这种情况下,这相当于简单地重新整理一维数组:
In [8]: X = x[:, np.newaxis]
X.shape
Out[8]: (50, 1)
4. 将模型拟合到数据
现在是将我们的模型应用于数据的时候了。这可以通过模型的fit方法来完成:
In [9]: model.fit(X, y)
Out[9]: LinearRegression()
此fit命令会导致进行许多依赖于模型的内部计算,并将这些计算的结果存储在用户可以探索的模型特定属性中。在 Scikit-Learn 中,按照惯例,在fit过程中学习的所有模型参数都有尾随的下划线;例如,在这个线性模型中,我们有以下内容:
In [10]: model.coef_
Out[10]: array([1.9776566])
In [11]: model.intercept_
Out[11]: -0.9033107255311146
这两个参数表示对数据进行简单线性拟合的斜率和截距。将结果与数据定义进行比较,我们看到它们接近用于生成数据的值:斜率为 2,截距为-1。
经常出现的一个问题是关于内部模型参数的不确定性。一般来说,Scikit-Learn 不提供从内部模型参数本身得出结论的工具:解释模型参数更多是一个统计建模问题,而不是一个机器学习问题。机器学习更关注模型的预测。如果您想深入了解模型内的拟合参数含义,其他工具可用,包括statsmodels Python 包。
5. 预测未知数据的标签
一旦模型训练完成,监督机器学习的主要任务就是基于其对未曾参与训练集的新数据的预测结果进行评估。在 Scikit-Learn 中,可以使用predict方法来实现。为了本示例的目的,我们的“新数据”将是一组x值,并且我们会问模型预测什么y值:
In [12]: xfit = np.linspace(-1, 11)
与之前一样,我们需要将这些x值强制转换为[n_samples, n_features]特征矩阵,之后我们可以将其馈送给模型:
In [13]: Xfit = xfit[:, np.newaxis]
yfit = model.predict(Xfit)
最后,让我们通过首先绘制原始数据,然后是模型拟合结果来可视化结果(参见图 38-4)。
In [14]: plt.scatter(x, y)
plt.plot(xfit, yfit);
图 38-4. 简单的线性回归拟合数据
通常通过将模型的结果与某些已知基准进行比较来评估模型的效果,我们将在下一个示例中看到。
监督学习示例:鸢尾花分类
让我们再看一个这个过程的例子,使用我们之前讨论过的鸢尾花数据集。我们的问题是这样的:在一个部分鸢尾花数据上训练的模型,我们能多好地预测剩余标签?
对于这个任务,我们将使用一个称为高斯朴素贝叶斯的简单生成模型,它假设每个类别都来自于一个轴对齐的高斯分布(更多细节请参见第四十一章)。由于它非常快速且没有需要选择的超参数,高斯朴素贝叶斯通常是用作基线分类的好模型,然后可以探索是否通过更复杂的模型找到改进。
我们希望评估模型在未见过的数据上的表现,因此我们将数据分为训练集和测试集。这可以手动完成,但使用train_test_split实用函数更为方便:
In [15]: from sklearn.model_selection import train_test_split
Xtrain, Xtest, ytrain, ytest = train_test_split(X_iris, y_iris,
random_state=1)
数据整理完毕后,我们可以按照我们的步骤预测标签:
In [16]: from sklearn.naive_bayes import GaussianNB # 1\. choose model class
model = GaussianNB() # 2\. instantiate model
model.fit(Xtrain, ytrain) # 3\. fit model to data
y_model = model.predict(Xtest) # 4\. predict on new data
最后,我们可以使用accuracy_score实用函数查看预测标签与其真实值匹配的比例:
In [17]: from sklearn.metrics import accuracy_score
accuracy_score(ytest, y_model)
Out[17]: 0.9736842105263158
准确率高达 97%,我们看到即使是这种非常天真的分类算法对这个特定数据集也是有效的!
无监督学习示例:鸢尾花维度
作为无监督学习问题的例子,让我们看看如何降低鸢尾花数据的维度,以便更容易地可视化它。回想一下,鸢尾花数据是四维的:每个样本记录了四个特征。
降维的任务集中在确定是否存在一个合适的低维表示,以保留数据的基本特征。通常,降维被用作辅助可视化数据的工具:毕竟,在二维中绘制数据比在四维或更多维度中更容易!
在这里,我们将使用 主成分分析(PCA;见 第四十五章),这是一种快速的线性降维技术。我们将要求模型返回两个组件——也就是数据的二维表示。
按照前面概述的步骤序列,我们有:
In [18]: from sklearn.decomposition import PCA # 1\. choose model class
model = PCA(n_components=2) # 2\. instantiate model
model.fit(X_iris) # 3\. fit model to data
X_2D = model.transform(X_iris) # 4\. transform the data
现在让我们绘制结果。一个快速的方法是将结果插入到原始的鸢尾DataFrame中,并使用 Seaborn 的 lmplot 来显示结果(见 图 38-5)。
In [19]: iris['PCA1'] = X_2D[:, 0]
iris['PCA2'] = X_2D[:, 1]
sns.lmplot(x="PCA1", y="PCA2", hue='species', data=iris, fit_reg=False);
我们看到,在二维表示中,物种相当分离,即使 PCA 算法没有物种标签的知识!这向我们暗示,一个相对简单的分类对数据集可能是有效的,就像我们之前看到的那样。
图 38-5. 将 Iris 数据投影到二维空间^(3)
无监督学习示例:鸢尾花聚类
接下来让我们看一下将聚类应用到鸢尾数据上。聚类算法试图找到不同的数据组,而不考虑任何标签。在这里,我们将使用一个强大的聚类方法,称为 高斯混合模型(GMM),在 第四十八章 中有更详细的讨论。GMM 试图将数据建模为高斯斑点的集合。
我们可以按如下方式拟合高斯混合模型:
In [20]: from sklearn.mixture import GaussianMixture # 1\. choose model class
model = GaussianMixture(n_components=3,
covariance_type='full') # 2\. instantiate model
model.fit(X_iris) # 3\. fit model to data
y_gmm = model.predict(X_iris) # 4\. determine labels
与之前一样,我们将把集群标签添加到鸢尾DataFrame中,并使用 Seaborn 绘制结果(见 图 38-6)。
In [21]: iris['cluster'] = y_gmm
sns.lmplot(x="PCA1", y="PCA2", data=iris, hue='species',
col='cluster', fit_reg=False);
图 38-6. Iris 数据中的 k-means 聚类^(4)
通过按簇号拆分数据,我们可以看到 GMM 算法已经完美地恢复了底层标签:setosa 物种在簇 0 中完美分离,而 versicolor 和 virginica 之间仍然存在少量混合。这意味着即使没有专家告诉我们单个花的物种标签,这些花的测量也是足够明显的,以至于我们可以使用简单的聚类算法自动识别出这些不同物种群!这种算法可能进一步给领域专家提供关于他们正在观察的样本之间关系的线索。
应用:探索手写数字
为了在一个更有趣的问题上演示这些原则,让我们考虑光学字符识别问题的一部分:手写数字的识别。在实际情况中,这个问题涉及到在图像中定位和识别字符。在这里,我们将采取捷径,使用 Scikit-Learn 的预格式化数字集,这些数字集内置于库中。
加载和可视化数字数据
我们可以使用 Scikit-Learn 的数据访问接口来查看这些数据:
In [22]: from sklearn.datasets import load_digits
digits = load_digits()
digits.images.shape
Out[22]: (1797, 8, 8)
图像数据是一个三维数组:每个样本由一个 8 × 8 的像素网格组成,共 1,797 个样本。让我们可视化其中的前一百个(参见图 38-7)。
In [23]: import matplotlib.pyplot as plt
fig, axes = plt.subplots(10, 10, figsize=(8, 8),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
for i, ax in enumerate(axes.flat):
ax.imshow(digits.images[i], cmap='binary', interpolation='nearest')
ax.text(0.05, 0.05, str(digits.target[i]),
transform=ax.transAxes, color='green')
图 38-7. 手写数字数据;每个样本由一个 8 × 8 的像素网格表示
为了在 Scikit-Learn 中处理这些数据,我们需要一个二维的 [n_samples, n_features] 表示。我们可以通过将图像中的每个像素视为一个特征来实现这一点:即通过展开像素数组,使得我们有一个长度为 64 的数组,其中包含代表每个数字的像素值。此外,我们还需要目标数组,它给出了每个数字的预先确定标签。这两个量已经内置在 digits 数据集的 data 和 target 属性中了:
In [24]: X = digits.data
X.shape
Out[24]: (1797, 64)
In [25]: y = digits.target
y.shape
Out[25]: (1797,)
我们在这里看到有 1,797 个样本和 64 个特征。
无监督学习示例:降维
我们想在 64 维参数空间内可视化我们的点,但在这么高维空间中有效地可视化点是困难的。因此,我们将通过无监督方法减少维度。在这里,我们将使用一个称为 Isomap 的流形学习算法(参见第四十六章),将数据转换为二维:
In [26]: from sklearn.manifold import Isomap
iso = Isomap(n_components=2)
iso.fit(digits.data)
data_projected = iso.transform(digits.data)
print(data_projected.shape)
Out[26]: (1797, 2)
我们看到投影后的数据现在是二维的。让我们绘制这些数据,看看我们是否可以从它的结构中学到一些东西(参见图 38-8)。
In [27]: plt.scatter(data_projected[:, 0], data_projected[:, 1], c=digits.target,
edgecolor='none', alpha=0.5,
cmap=plt.cm.get_cmap('viridis', 10))
plt.colorbar(label='digit label', ticks=range(10))
plt.clim(-0.5, 9.5);
这个图表让我们对在较大的 64 维空间中各种数字的分离程度有了一些直观的认识。例如,零和一在参数空间中几乎没有重叠。直觉上这是有道理的:零在图像中间是空的,而一通常在图像中间有墨水。另一方面,一和四之间似乎有一个更或多或少连续的谱系:我们可以通过意识到有些人在一上画有“帽子”,这使它们看起来与四相似。
总体而言,尽管在边缘处有些混合,不同的组在参数空间中似乎被相当好地定位:这表明即使是非常简单的监督分类算法也应该在完整的高维数据集上表现适当。让我们试一试。
图 38-8. 数字数据的 Isomap 嵌入
数字分类
让我们对手写数字数据应用一个分类算法。与之前处理鸢尾花数据集时一样,我们将数据分为训练集和测试集,并拟合一个高斯朴素贝叶斯模型:
In [28]: Xtrain, Xtest, ytrain, ytest = train_test_split(X, y, random_state=0)
In [29]: from sklearn.naive_bayes import GaussianNB
model = GaussianNB()
model.fit(Xtrain, ytrain)
y_model = model.predict(Xtest)
现在我们有了模型的预测结果,我们可以通过比较测试集的真实值和预测值来评估其准确性:
In [30]: from sklearn.metrics import accuracy_score
accuracy_score(ytest, y_model)
Out[30]: 0.8333333333333334
即使是这个非常简单的模型,我们在数字分类上也达到了约 83%的准确率!但是,这个单一数字并不能告诉我们哪里出了错。一个很好的方法是使用混淆矩阵,我们可以用 Scikit-Learn 计算它,并用 Seaborn 绘制(参见图 38-9)。
In [31]: from sklearn.metrics import confusion_matrix
mat = confusion_matrix(ytest, y_model)
sns.heatmap(mat, square=True, annot=True, cbar=False, cmap='Blues')
plt.xlabel('predicted value')
plt.ylabel('true value');
这显示了误标记点的位置倾向:例如,这里的许多数字“2”被误分类为“1”或“8”。
图 38-9. 混淆矩阵显示分类器误分类的频率
另一种直观了解模型特性的方法是重新绘制输入数据及其预测标签。我们将使用绿色表示正确标签,红色表示错误标签;详见图 38-10。
In [32]: fig, axes = plt.subplots(10, 10, figsize=(8, 8),
subplot_kw={'xticks':[], 'yticks':[]},
gridspec_kw=dict(hspace=0.1, wspace=0.1))
test_images = Xtest.reshape(-1, 8, 8)
for i, ax in enumerate(axes.flat):
ax.imshow(test_images[i], cmap='binary', interpolation='nearest')
ax.text(0.05, 0.05, str(y_model[i]),
transform=ax.transAxes,
color='green' if (ytest[i] == y_model[i]) else 'red')
检查数据子集可以帮助我们了解算法在哪些地方可能表现不佳。为了超越我们的 83%分类成功率,我们可以转向更复杂的算法,如支持向量机(参见第 43 章)、随机森林(参见第 44 章)或其他分类方法。
图 38-10. 数据显示正确(绿色)和错误(红色)标签;查看这个图的彩色版本,请参阅书的在线版本
总结
在本章中,我们介绍了 Scikit-Learn 数据表示和估计器 API 的基本特性。无论使用何种类型的估计器,都保持着相同的导入/实例化/拟合/预测模式。掌握了这些信息,您可以探索 Scikit-Learn 文档,并在您的数据上尝试各种模型。
在下一章中,我们将探讨机器学习中可能最重要的主题:如何选择和验证您的模型。
^(1) 这个图的全尺寸、全彩色版本可以在GitHub上找到。
^(2) 可在在线附录中找到生成此图的代码。
^(3) 这个图的全彩色版本可以在GitHub上找到。
^(4) 这个图的全尺寸、全彩色版本可以在GitHub上找到。
第三十九章:超参数和模型验证
在上一章中,我们看到了应用监督机器学习模型的基本方法:
-
选择一个模型类别。
-
选择模型超参数。
-
将模型拟合到训练数据中。
-
使用模型来预测新数据的标签。
这两个部分——模型的选择和超参数的选择——可能是有效使用这些工具和技术的最重要部分。为了做出明智的选择,我们需要一种验证模型和超参数是否与数据相匹配的方法。虽然这听起来很简单,但要有效地做到这一点,你必须避免一些陷阱。
思考模型验证
原则上,模型验证非常简单:在选择了模型和其超参数之后,我们可以通过将其应用于一些训练数据并将预测结果与已知值进行比较来估计其有效性。
本节将首先展示一个关于模型验证的天真方法以及为什么它失败了,然后探讨使用保留集和交叉验证进行更健壮的模型评估。
错误的模型验证方法
让我们从在上一章中看到的鸢尾花数据集中采用天真的验证方法开始。我们将从加载数据开始:
In [1]: from sklearn.datasets import load_iris
iris = load_iris()
X = iris.data
y = iris.target
接下来,我们选择一个模型和超参数。在这里,我们将使用一个n最近邻分类器,其中n_neighbors=1。这是一个非常简单和直观的模型,它表示“未知点的标签与其最近训练点的标签相同”:
In [2]: from sklearn.neighbors import KNeighborsClassifier
model = KNeighborsClassifier(n_neighbors=1)
然后,我们训练模型,并使用它来预测我们已经知道标签的数据的标签:
In [3]: model.fit(X, y)
y_model = model.predict(X)
最后,我们计算正确标记点的比例:
In [4]: from sklearn.metrics import accuracy_score
accuracy_score(y, y_model)
Out[4]: 1.0
我们看到了一个准确度得分为 1.0,这表明我们的模型 100%正确标记了所有点!但这真的是测量预期准确度吗?我们真的找到了一个我们预计每次都会 100%正确的模型吗?
正如你可能已经了解的那样,答案是否定的。事实上,这种方法包含一个根本性的缺陷:它在相同的数据上训练和评估模型。此外,这个最近邻模型是一个基于实例的估计器,它简单地存储训练数据,并通过将新数据与这些存储的点进行比较来预测标签:除了人为的情况外,它每次都会得到 100%的准确度!
正确的模型验证方法:保留集
那么可以做什么呢?通过使用所谓的保留集可以更好地了解模型的性能:也就是说,我们从模型的训练中保留一些数据子集,然后使用这个保留集来检查模型的性能。这种分割可以使用 Scikit-Learn 中的train_test_split工具来完成:
In [5]: from sklearn.model_selection import train_test_split
# split the data with 50% in each set
X1, X2, y1, y2 = train_test_split(X, y, random_state=0,
train_size=0.5)
# fit the model on one set of data
model.fit(X1, y1)
# evaluate the model on the second set of data
y2_model = model.predict(X2)
accuracy_score(y2, y2_model)
Out[5]: 0.9066666666666666
我们在这里看到了一个更合理的结果:一对一最近邻分类器在这个保留集上的准确率约为 90%。保留集类似于未知数据,因为模型以前没有“看到”它。
通过交叉验证进行模型验证
使用留出法进行模型验证的一个缺点是我们已经失去了一部分数据用于模型训练。在前述情况下,一半的数据集并没有对模型的训练做出贡献!这并不是最优的,特别是如果初始训练数据集很小的情况下。
解决这个问题的一种方法是使用交叉验证;也就是说,进行一系列拟合,其中每个数据子集既用作训练集又用作验证集。从视觉上看,可能会像是 Figure 39-1 这样。
图 39-1. 二折交叉验证的可视化^(1)
在这里,我们进行了两个验证试验,交替使用数据的每一半作为留出集。使用之前的分割数据,我们可以这样实现:
In [6]: y2_model = model.fit(X1, y1).predict(X2)
y1_model = model.fit(X2, y2).predict(X1)
accuracy_score(y1, y1_model), accuracy_score(y2, y2_model)
Out[6]: (0.96, 0.9066666666666666)
出现的是两个准确度分数,我们可以结合(比如取平均值)来获得更好的全局模型性能衡量标准。这种特定形式的交叉验证是二折交叉验证——即,我们将数据分为两组,轮流将每一组作为验证集。
我们可以扩展这个想法,使用更多的试验和数据折叠——例如,Figure 39-2 展示了五折交叉验证的可视化描述。
图 39-2. 五折交叉验证的可视化^(2)
在这里,我们将数据分为五组,依次使用每一组来评估模型在其余四分之四的数据上的拟合情况。这样手工操作将会相当乏味,但我们可以使用 Scikit-Learn 的 cross_val_score 方便地完成:
In [7]: from sklearn.model_selection import cross_val_score
cross_val_score(model, X, y, cv=5)
Out[7]: array([0.96666667, 0.96666667, 0.93333333, 0.93333333, 1. ])
在不同的数据子集上重复验证可以更好地了解算法的性能。
Scikit-Learn 实现了许多在特定情况下有用的交叉验证方案;这些通过 model_selection 模块中的迭代器实现。例如,我们可能希望使用极端情况,即我们的折数等于数据点的数量:也就是说,在每次试验中我们训练所有点但排除一个。这种交叉验证被称为留一法交叉验证,可以如下使用:
In [8]: from sklearn.model_selection import LeaveOneOut
scores = cross_val_score(model, X, y, cv=LeaveOneOut())
scores
Out[8]: array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 0., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
1., 1., 1., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.,
0., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 0., 1., 1.,
1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])
因为我们有 150 个样本,留一法交叉验证会产生 150 次试验的分数,每个分数表示预测成功(1.0)或失败(0.0)。对这些分数求平均值可以估计误差率:
In [9]: scores.mean()
Out[9]: 0.96
其他交叉验证方案可以类似地使用。要了解 Scikit-Learn 中提供的内容,请使用 IPython 探索 sklearn.model_selection 子模块,或查看 Scikit-Learn 的交叉验证文档。
选择最佳模型
现在我们已经探讨了验证和交叉验证的基础知识,我们将更深入地讨论模型选择和超参数选择的问题。这些问题是机器学习实践中最重要的方面之一,但我发现这些信息在初学者机器学习教程中经常被忽略。
核心问题是以下问题:如果我们的估计器表现不佳,我们该如何前进? 有几种可能的答案:
-
使用一个更复杂/更灵活的模型。
-
使用一个不那么复杂/不那么灵活的模型。
-
收集更多训练样本。
-
收集更多数据以增加每个样本的特征。
对这个问题的答案通常是反直觉的。特别是,有时使用更复杂的模型会导致更差的结果,并且增加更多的训练样本可能不会改善您的结果!能够确定哪些步骤将改进您的模型是成功的机器学习从业者和不成功的区别。
偏差-方差折衷
从根本上讲,找到“最佳模型”就是在偏差和方差之间的折衷中找到一个甜蜜点。考虑图 39-3,它展示了对同一数据集的两个回归拟合。
图 39-3. 高偏差和高方差的回归模型^(3)
很明显,这两个模型都不特别适合数据,但它们以不同的方式失败。
左边的模型试图通过数据找到一条直线拟合。因为在这种情况下,一条直线不能准确地分割数据,所以直线模型永远无法很好地描述这个数据集。这样的模型被称为欠拟合数据:即它没有足够的灵活性来适当地考虑数据中的所有特征。另一种说法是,该模型具有高偏差。
右边的模型试图通过数据拟合高阶多项式。在这里,模型拟合具有足够的灵活性来几乎完美地描述数据中的细微特征,但即使它非常精确地描述了训练数据,其精确形式似乎更反映了数据的特定噪声属性,而不是生成数据的任何过程的固有属性。这样的模型被称为过拟合数据:即它具有如此高的灵活性,以至于模型最终解释了随机误差以及底层数据分布。另一种说法是,该模型具有高方差。
从另一个角度来看,考虑一下如果我们使用这两个模型来预测一些新数据的y值会发生什么。在图 39-4 中的图表中,红色/浅色点表示从训练集中省略的数据。
图 39-4. 高偏差和高方差模型中的训练和验证分数^(4)
这里的分数是R 2分数,或者确定系数,它衡量模型相对于目标值简单平均的表现。 R 2 = 1 表示完美匹配, R 2 = 0 表示模型不比简单取数据均值更好,负值则表示更差的模型。从这两个模型相关的分数中,我们可以得出一个更普遍的观察:
-
对于高偏差模型,模型在验证集上的表现与在训练集上的表现类似。
-
对于高方差模型,模型在验证集上的表现远远不及在训练集上的表现。
如果我们可以调整模型复杂度,我们会期望训练分数和验证分数表现如图 39-5 所示,通常称为验证曲线,我们可以看到以下特点:
-
训练分数始终高于验证分数。一般情况下都是如此:模型对已见数据的拟合程度比对未见数据的拟合程度更好。
-
对于非常低的模型复杂度(即高偏差模型),训练数据欠拟合,这意味着该模型对于训练数据和任何之前未见数据的预测都很差。
-
对于非常高的模型复杂度(即高方差模型),训练数据过拟合,这意味着模型对训练数据的预测非常好,但是对于任何之前未见数据都失败了。
-
对于某些中间值,验证曲线达到最大值。这种复杂度水平表明在偏差和方差之间有一个适当的权衡。
调整模型复杂度的方法因模型而异;在后面的章节中深入讨论各个模型时,我们将看到每个模型如何允许此类调整。
图 39-5. 模型复杂度、训练分数和验证分数之间关系的示意图^(5)
Scikit-Learn 中的验证曲线
让我们看一个使用交叉验证计算模型验证曲线的示例。这里我们将使用多项式回归模型,一个广义线性模型,其中多项式的次数是一个可调参数。例如,对于模型参数 a 和 b:
y = a x + b
三阶多项式对数据拟合出一个立方曲线;对于模型参数a , b , c , d:
y = a x 3 + b x 2 + c x + d
我们可以将这一概念推广到任意数量的多项式特征。在 Scikit-Learn 中,我们可以使用线性回归分类器结合多项式预处理器实现这一点。我们将使用管道将这些操作串联在一起(我们将在第 40 章中更全面地讨论多项式特征和管道):
In [10]: from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
def PolynomialRegression(degree=2, **kwargs):
return make_pipeline(PolynomialFeatures(degree),
LinearRegression(**kwargs))
现在让我们创建一些数据来拟合我们的模型:
In [11]: import numpy as np
def make_data(N, err=1.0, rseed=1):
# randomly sample the data
rng = np.random.RandomState(rseed)
X = rng.rand(N, 1) ** 2
y = 10 - 1. / (X.ravel() + 0.1)
if err > 0:
y += err * rng.randn(N)
return X, y
X, y = make_data(40)
现在我们可以可视化我们的数据,以及几个不同阶数的多项式拟合(见图 39-6)。
In [12]: %matplotlib inline
import matplotlib.pyplot as plt
plt.style.use('seaborn-whitegrid')
X_test = np.linspace(-0.1, 1.1, 500)[:, None]
plt.scatter(X.ravel(), y, color='black')
axis = plt.axis()
for degree in [1, 3, 5]:
y_test = PolynomialRegression(degree).fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test, label='degree={0}'.format(degree))
plt.xlim(-0.1, 1.0)
plt.ylim(-2, 12)
plt.legend(loc='best');
在这种情况下控制模型复杂度的旋钮是多项式的阶数,它可以是任何非负整数。一个有用的问题是:哪个多项式阶数提供了偏差(欠拟合)和方差(过拟合)之间的合适权衡点?
图 39-6. 适合数据集的三个不同多项式模型^(6)
我们可以通过可视化特定数据和模型的验证曲线来取得进展;这可以通过 Scikit-Learn 提供的validation_curve便捷程序直接完成。给定一个模型、数据、参数名称和探索范围,该函数将自动计算跨范围的训练分数和验证分数(见图 39-7)。
In [13]: from sklearn.model_selection import validation_curve
degree = np.arange(0, 21)
train_score, val_score = validation_curve(
PolynomialRegression(), X, y,
param_name='polynomialfeatures__degree',
param_range=degree, cv=7)
plt.plot(degree, np.median(train_score, 1),
color='blue', label='training score')
plt.plot(degree, np.median(val_score, 1),
color='red', label='validation score')
plt.legend(loc='best')
plt.ylim(0, 1)
plt.xlabel('degree')
plt.ylabel('score');
图 39-7. Figure 39-9 中数据的验证曲线
这清楚地展示了我们预期的定性行为:训练分数始终高于验证分数,训练分数随着模型复杂度的增加而单调改善,并且验证分数在模型过拟合后达到最大值然后下降。
从验证曲线中,我们可以确定在三阶多项式下找到了偏差和方差之间的最佳权衡点。我们可以按以下方式计算并展示这个拟合结果在原始数据上的表现(见图 39-8)。
In [14]: plt.scatter(X.ravel(), y)
lim = plt.axis()
y_test = PolynomialRegression(3).fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test);
plt.axis(lim);
图 39-8. Figure 39-6 中数据的交叉验证最优模型
注意,找到这个最优模型实际上并不需要我们计算训练分数,但是检查训练分数和验证分数之间的关系可以为我们提供模型性能的有用见解。
学习曲线
模型复杂度的一个重要方面是,最优模型通常取决于训练数据的大小。例如,让我们生成一个数据集,其点数是之前的五倍(见图 39-9)。
In [15]: X2, y2 = make_data(200)
plt.scatter(X2.ravel(), y2);
图 39-9. 展示学习曲线的数据
现在让我们复制前述代码,为这个更大的数据集绘制验证曲线;为了参考,我们也会在前面的结果上进行叠加(见图 39-10)。
In [16]: degree = np.arange(21)
train_score2, val_score2 = validation_curve(
PolynomialRegression(), X2, y2,
param_name='polynomialfeatures__degree',
param_range=degree, cv=7)
plt.plot(degree, np.median(train_score2, 1),
color='blue', label='training score')
plt.plot(degree, np.median(val_score2, 1),
color='red', label='validation score')
plt.plot(degree, np.median(train_score, 1),
color='blue', alpha=0.3, linestyle='dashed')
plt.plot(degree, np.median(val_score, 1),
color='red', alpha=0.3, linestyle='dashed')
plt.legend(loc='lower center')
plt.ylim(0, 1)
plt.xlabel('degree')
plt.ylabel('score');
实线显示新结果,而较淡的虚线显示较小数据集的结果。从验证曲线可以明显看出,较大的数据集可以支持更复杂的模型:这里的高峰可能在 6 阶左右,但即使是 20 阶模型也不会严重过拟合数据——验证和训练分数仍然非常接近。
图 39-10. 多项式模型拟合数据的学习曲线
因此,验证曲线的行为不仅仅取决于模型复杂度,还取决于训练点的数量。我们可以通过使用日益增大的数据子集来研究模型随训练点数量变化的行为,从而获得更深入的见解。有时,关于训练/验证分数与训练集大小的图称为学习曲线。
我们期望从学习曲线得到的一般行为是:
-
给定复杂度的模型会过拟合小数据集:这意味着训练分数会相对较高,而验证分数则相对较低。
-
给定复杂度的模型会欠拟合大数据集:这意味着训练分数会减少,但验证分数会增加。
-
除了偶然情况外,模型永远不会给验证集比训练集更好的分数:这意味着曲线应该会越来越接近,但永远不会交叉。
考虑到这些特征,我们期望学习曲线在质量上看起来像图 39-11 所示。
图 39-11. 示意图展示学习曲线的典型解释
学习曲线的显著特征是随着训练样本数量的增长而趋于特定分数。特别是,一旦您有足够的点使得特定模型收敛,增加更多的训练数据将不会帮助您!在这种情况下,提高模型性能的唯一方法是使用另一个(通常更复杂的)模型。
Scikit-Learn 提供了一个方便的实用工具来计算模型的学习曲线;在这里,我们将使用二阶多项式模型和九阶多项式模型来计算我们原始数据集的学习曲线(参见图 39-12)。
In [17]: from sklearn.model_selection import learning_curve
fig, ax = plt.subplots(1, 2, figsize=(16, 6))
fig.subplots_adjust(left=0.0625, right=0.95, wspace=0.1)
for i, degree in enumerate([2, 9]):
N, train_lc, val_lc = learning_curve(
PolynomialRegression(degree), X, y, cv=7,
train_sizes=np.linspace(0.3, 1, 25))
ax[i].plot(N, np.mean(train_lc, 1),
color='blue', label='training score')
ax[i].plot(N, np.mean(val_lc, 1),
color='red', label='validation score')
ax[i].hlines(np.mean([train_lc[-1], val_lc[-1]]), N[0],
N[-1], color='gray', linestyle='dashed')
ax[i].set_ylim(0, 1)
ax[i].set_xlim(N[0], N[-1])
ax[i].set_xlabel('training size')
ax[i].set_ylabel('score')
ax[i].set_title('degree = {0}'.format(degree), size=14)
ax[i].legend(loc='best')
图 39-12. 低复杂度模型的学习曲线(左)和高复杂度模型的学习曲线(右)^(9)
这是一种有价值的诊断工具,因为它直观地显示了模型如何对增加的训练数据做出反应。特别是当学习曲线已经收敛(即训练和验证曲线已经非常接近)时,增加更多的训练数据将不会显著改善拟合效果!这种情况在左侧面板中可见,对于二次模型的学习曲线。
增加收敛分数的唯一方法是使用不同(通常更复杂)的模型。我们在右侧面板中看到这一点:通过转向更复杂的模型,我们增加了收敛分数(由虚线指示),但以更高的模型方差为代价(由训练和验证分数的差异表示)。如果我们再添加更多数据点,更复杂模型的学习曲线最终会收敛。
为您选择的特定模型和数据集绘制学习曲线,可以帮助您做出关于如何继续改进分析的决定。
实线显示了新的结果,而较淡的虚线显示了之前较小数据集上的结果。从验证曲线可以清楚地看出,较大的数据集可以支持更复杂的模型:这里的峰值可能在 6 次多项式,但即使是 20 次多项式模型也没有严重过拟合数据——验证和训练分数仍然非常接近。
实践中的验证:网格搜索
前述讨论旨在让您直观地了解偏差和方差之间的权衡,以及其对模型复杂度和训练集大小的依赖。在实践中,模型通常有多个可以调整的参数,这意味着验证曲线和学习曲线的绘制从线条变为多维表面。在这些情况下,这样的可视化是困难的,我们更愿意找到能最大化验证分数的特定模型。
Scikit-Learn 提供了一些工具,使这种搜索更加方便:在这里,我们将考虑使用网格搜索来找到最优的多项式模型。我们将探索一个二维模型特性的网格,即多项式阶数和是否拟合截距的标志。可以使用 Scikit-Learn 的GridSearchCV元估计器来设置这一点:
In [18]: from sklearn.model_selection import GridSearchCV
param_grid = {'polynomialfeatures__degree': np.arange(21),
'linearregression__fit_intercept': [True, False]}
grid = GridSearchCV(PolynomialRegression(), param_grid, cv=7)
请注意,像普通估算器一样,此方法尚未应用于任何数据。调用fit方法将在每个网格点上拟合模型,并跟踪沿途的分数:
In [19]: grid.fit(X, y);
现在模型已经拟合,我们可以按如下方式获取最佳参数:
In [20]: grid.best_params_
Out[20]: {'linearregression__fit_intercept': False, 'polynomialfeatures__degree': 4}
最后,如果需要,我们可以使用最佳模型并展示我们的数据拟合结果,使用之前的代码(见 Figure 39-13)。
In [21]: model = grid.best_estimator_
plt.scatter(X.ravel(), y)
lim = plt.axis()
y_test = model.fit(X, y).predict(X_test)
plt.plot(X_test.ravel(), y_test);
plt.axis(lim);
图 39-13. 通过自动网格搜索确定的最佳拟合模型
GridSearchCV中的其他选项包括指定自定义评分函数、并行化计算、执行随机搜索等。有关更多信息,请参阅第四十九章和第五十章的示例,或参考 Scikit-Learn 的网格搜索文档。
总结
在本章中,我们开始探讨模型验证和超参数优化的概念,重点是偏差-方差折衷的直观方面,以及在拟合模型到数据时如何发挥作用。特别是,我们发现在调整参数时使用验证集或交叉验证方法对于避免对更复杂/灵活的模型进行过拟合至关重要。
在后续章节中,我们将讨论特别有用的模型的详细信息,这些模型的调整以及这些自由参数如何影响模型复杂性。在阅读并了解这些机器学习方法时,请记住本章的教训!
^(1) 生成此图的代码可以在在线附录中找到。
^(2) 生成此图的代码可以在在线附录中找到。
^(3) 生成此图的代码可以在在线附录中找到。
^(4) 生成此图的代码可以在在线附录中找到。
^(5) 生成此图的代码可以在在线附录中找到。
^(6) 此图的全彩色版本可在GitHub上找到。
^(7) 此图的全彩色版本可在GitHub上找到。
^(8) 生成此图的代码可以在在线附录中找到。
^(9) 此图的全尺寸版本可在GitHub上找到。
第四十章:特征工程
前几章概述了机器学习的基本思想,但到目前为止的所有示例都假定您有数字数据以整洁的[n_samples, n_features]格式。在现实世界中,数据很少以这种形式出现。考虑到这一点,实际应用机器学习的一个更重要的步骤之一是特征工程:即,利用您对问题的任何信息,并将其转换为您可以用来构建特征矩阵的数字。
在本章中,我们将涵盖几个常见的特征工程任务示例:我们将查看用于表示分类数据、文本和图像的特征。此外,我们还将讨论增加模型复杂性和填补缺失数据的派生特征。这个过程通常被称为向量化,因为它涉及将任意数据转换为行为良好的向量。
分类特征
一种常见的非数值数据类型是分类数据。例如,想象一下您正在探索一些关于房价的数据,除了像“价格”和“房间”这样的数值特征之外,还有“街区”信息。例如,您的数据可能如下所示:
In [1]: data = [
{'price': 850000, 'rooms': 4, 'neighborhood': 'Queen Anne'},
{'price': 700000, 'rooms': 3, 'neighborhood': 'Fremont'},
{'price': 650000, 'rooms': 3, 'neighborhood': 'Wallingford'},
{'price': 600000, 'rooms': 2, 'neighborhood': 'Fremont'}
]
您可能会被诱惑使用直接的数值映射来对这些数据进行编码:
In [2]: {'Queen Anne': 1, 'Fremont': 2, 'Wallingford': 3};
但事实证明,在 Scikit-Learn 中,这一般不是一个有用的方法。该软件包的模型假设数值特征反映了代数量,因此这样的映射会暗示,例如,Queen Anne < Fremont < Wallingford,甚至是Wallingford–Queen Anne = Fremont,这(除了小众的人口统计笑话)并没有多少意义。
在这种情况下,一个经过验证的技术是使用独热编码,它有效地创建额外的列,指示类别的存在或不存在,分别为 1 或 0。当您的数据采取字典列表的形式时,Scikit-Learn 的 DictVectorizer 将为您执行此操作:
In [3]: from sklearn.feature_extraction import DictVectorizer
vec = DictVectorizer(sparse=False, dtype=int)
vec.fit_transform(data)
Out[3]: array([[ 0, 1, 0, 850000, 4],
[ 1, 0, 0, 700000, 3],
[ 0, 0, 1, 650000, 3],
[ 1, 0, 0, 600000, 2]])
请注意,neighborhood 列已扩展为三个单独的列,表示三个街区标签,每一行都在与其街区相关联的列中具有 1。有了这些分类特征编码,您可以像正常情况下一样拟合一个 Scikit-Learn 模型。
要查看每一列的含义,您可以检查特征名称:
In [4]: vec.get_feature_names_out()
Out[4]: array(['neighborhood=Fremont', 'neighborhood=Queen Anne',
'neighborhood=Wallingford', 'price', 'rooms'], dtype=object)
这种方法有一个明显的缺点:如果您的类别有许多可能的值,这可能会大大增加数据集的大小。然而,因为编码数据主要包含零,所以稀疏输出可以是一个非常有效的解决方案:
In [5]: vec = DictVectorizer(sparse=True, dtype=int)
vec.fit_transform(data)
Out[5]: <4x5 sparse matrix of type '<class 'numpy.int64'>'
with 12 stored elements in Compressed Sparse Row format>
几乎所有的 Scikit-Learn 评估器都接受这种稀疏输入来拟合和评估模型。Scikit-Learn 包括的另外两个支持这种编码类型的工具是 sklearn.preprocessing.OneHotEncoder 和 sklearn.feature_extraction.FeatureHasher。
文本特征
特征工程中另一个常见需求是将文本转换为一组代表性的数值。例如,大多数自动挖掘社交媒体数据都依赖于某种形式的文本编码为数字。编码这种类型数据的最简单方法之一是词频:你拿到每段文本,计算其中每个词的出现次数,并将结果放入表格中。
例如,考虑以下三个短语的集合:
In [6]: sample = ['problem of evil',
'evil queen',
'horizon problem']
对于基于词频的数据向量化,我们可以构建代表词语“问题”、“of”、“evil”等的单独列。虽然在这个简单的例子中手动操作是可能的,但可以通过使用 Scikit-Learn 的CountVectorizer来避免这种单调的工作:
In [7]: from sklearn.feature_extraction.text import CountVectorizer
vec = CountVectorizer()
X = vec.fit_transform(sample)
X
Out[7]: <3x5 sparse matrix of type '<class 'numpy.int64'>'
with 7 stored elements in Compressed Sparse Row format>
结果是一个记录每个词出现次数的稀疏矩阵;如果我们将其转换为带有标记列的DataFrame,检查起来就更容易了。
In [8]: import pandas as pd
pd.DataFrame(X.toarray(), columns=vec.get_feature_names_out())
Out[8]: evil horizon of problem queen
0 1 0 1 1 0
1 1 0 0 0 1
2 0 1 0 1 0
然而,使用简单的原始词频存在一些问题:它可能会导致对出现非常频繁的词给予过多权重,这在某些分类算法中可能不是最优的。修正这一问题的一种方法称为词频-逆文档频率(TF-IDF),它通过衡量词语在文档中出现的频率来加权词频。计算这些特征的语法与前面的例子类似:
实线显示了新结果,而较淡的虚线显示了之前较小数据集的结果。从验证曲线可以明显看出,较大的数据集可以支持更复杂的模型:这里的峰值可能在 6 次方附近,但即使是 20 次方的模型也没有严重过拟合数据——验证分数和训练分数保持非常接近。
In [9]: from sklearn.feature_extraction.text import TfidfVectorizer
vec = TfidfVectorizer()
X = vec.fit_transform(sample)
pd.DataFrame(X.toarray(), columns=vec.get_feature_names_out())
Out[9]: evil horizon of problem queen
0 0.517856 0.000000 0.680919 0.517856 0.000000
1 0.605349 0.000000 0.000000 0.000000 0.795961
2 0.000000 0.795961 0.000000 0.605349 0.000000
关于在分类问题中使用 TF-IDF 的示例,请参阅第四十一章。
图像特征
另一个常见需求是为机器学习分析适当编码图像。最简单的方法是我们在第三十八章中用于数字数据的方法:仅使用像素值本身。但根据应用程序的不同,这种方法可能不是最优的。
关于图像的特征提取技术的全面总结远超出本章的范围,但你可以在Scikit-Image 项目中找到许多标准方法的出色实现。关于如何同时使用 Scikit-Learn 和 Scikit-Image 的示例,请参阅第五十章。
派生特征
另一种有用的特征类型是从某些输入特征数学推导出来的特征。我们在第三十九章中看到了一个例子,当我们从输入数据构建多项式特征时。我们看到,我们可以将线性回归转换为多项式回归,而不是改变模型,而是通过转换输入!
例如,这些数据显然不能用一条直线很好地描述(见图 40-1):
In [10]: %matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
x = np.array([1, 2, 3, 4, 5])
y = np.array([4, 2, 1, 3, 7])
plt.scatter(x, y);
图 40-1. 不能很好地用直线描述的数据
我们仍然可以使用LinearRegression对数据进行线性拟合,并获得最优结果,如图 40-2 所示:
In [11]: from sklearn.linear_model import LinearRegression
X = x[:, np.newaxis]
model = LinearRegression().fit(X, y)
yfit = model.predict(X)
plt.scatter(x, y)
plt.plot(x, yfit);
图 40-2. 一条较差的直线拟合
但显然我们需要一个更复杂的模型来描述x和y之间的关系。
对此的一种方法是转换数据,添加额外的特征列以增强模型的灵活性。例如,我们可以这样向数据中添加多项式特征:
In [12]: from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=3, include_bias=False)
X2 = poly.fit_transform(X)
print(X2)
Out[12]: [[ 1. 1. 1.]
[ 2. 4. 8.]
[ 3. 9. 27.]
[ 4. 16. 64.]
[ 5. 25. 125.]]
派生的特征矩阵有一列表示x,第二列表示x 2,第三列表示x 3。在这扩展输入上计算线性回归可以更接近我们的数据,如你在图 40-3 中所见:
In [13]: model = LinearRegression().fit(X2, y)
yfit = model.predict(X2)
plt.scatter(x, y)
plt.plot(x, yfit);
图 40-3. 对数据导出的多项式特征进行线性拟合
改进模型的一个思路不是改变模型本身,而是转换输入数据,这对许多更强大的机器学习方法至关重要。我们将在第四十二章进一步探讨这个想法,这是基函数回归的一个例子。更一般地说,这是强大技术集合——核方法的动机之一,我们将在第四十三章中探讨。
缺失数据的插补
特征工程中另一个常见需求是处理缺失数据。我们在第十六章中讨论了在DataFrame对象中处理缺失数据的方法,并看到NaN通常用于标记缺失值。例如,我们可能有一个数据集看起来像这样:
In [14]: from numpy import nan
X = np.array([[ nan, 0, 3 ],
[ 3, 7, 9 ],
[ 3, 5, 2 ],
[ 4, nan, 6 ],
[ 8, 8, 1 ]])
y = np.array([14, 16, -1, 8, -5])
当将典型的机器学习模型应用于这类数据时,我们需要首先用适当的填充值替换缺失值。这被称为缺失值的插补,策略从简单(例如,用列的平均值替换缺失值)到复杂(例如,使用矩阵完成或强健模型处理此类数据)。
高级方法往往非常依赖于特定应用场景,我们在这里不会深入讨论。对于使用均值、中位数或最频繁值的基本插补方法,Scikit-Learn 提供了SimpleImputer类:
In [15]: from sklearn.impute import SimpleImputer
imp = SimpleImputer(strategy='mean')
X2 = imp.fit_transform(X)
X2
Out[15]: array([[4.5, 0. , 3. ],
[3. , 7. , 9. ],
[3. , 5. , 2. ],
[4. , 5. , 6. ],
[8. , 8. , 1. ]])
我们看到在结果数据中,两个缺失值已经被替换为该列其余值的平均值。这些填充的数据可以直接输入到例如LinearRegression估算器中:
In [16]: model = LinearRegression().fit(X2, y)
model.predict(X2)
Out[16]: array([13.14869292, 14.3784627 , -1.15539732, 10.96606197, -5.33782027])
特征管道
使用任何上述示例,如果希望手动执行转换,尤其是希望串联多个步骤时,可能很快变得乏味。例如,我们可能希望一个处理管道看起来像这样:
-
使用均值填补缺失值。
-
将特征转换为二次项。
-
拟合线性回归模型。
为了简化这种类型的处理管道,Scikit-Learn 提供了一个Pipeline对象,可以如下使用:
In [17]: from sklearn.pipeline import make_pipeline
model = make_pipeline(SimpleImputer(strategy='mean'),
PolynomialFeatures(degree=2),
LinearRegression())
这个管道看起来和操作起来像一个标准的 Scikit-Learn 对象,将所有指定的步骤应用于任何输入数据:
In [18]: model.fit(X, y) # X with missing values, from above
print(y)
print(model.predict(X))
Out[18]: [14 16 -1 8 -5]
[14. 16. -1. 8. -5.]
模型的所有步骤都是自动应用的。请注意,为了简单起见,在这个演示中,我们已经将模型应用于它训练过的数据;这就是为什么它能够完美地预测结果(详见第三十九章进一步讨论)。
有关 Scikit-Learn 管道实际操作的一些示例,请参阅有关朴素贝叶斯分类的以下章节,以及第四十二章和第四十三章。