9  支持向量机

9.1 导读

支持向量机通过间隔与核函数构造分类边界。本章从可分数据的最大间隔直觉出发,依次介绍软间隔、支持向量、核技巧和概率校准,并在同一时间切分上比较线性与 RBF 核模型。核模型是否优于线性基线取决于训练内调参与未来期评价,不由边界复杂度预先决定。

9.2 学习目标

完成本章后,读者应能够:

  1. 解释最大间隔、软间隔、支持向量和惩罚参数 \(C\) 的含义。
  2. 说明核技巧与 RBF 参数 \(\gamma\) 如何改变相似度和决策边界。
  3. 在训练折内完成标准化、\(C/\gamma\) 选择与概率校准。
  4. 用共同未来期的 AUC、Brier 分数和校准证据比较线性与核 SVM。
  5. 识别高维核方法的计算与解释限制;能选读 primal、dual 与 KKT 推导。

本章讨论支持向量机(SVM)。Cortes 和 Vapnik (1995年) 提出了软间隔支持向量网络的经典表述;其几何与优化思想影响深远,但任何具体任务上的性能仍须由适当的样本外评价确定。

支持向量机是最大间隔分类器的推广。最大间隔分类器要求训练类别可被线性边界分离,因此适用范围有限。在 小节 9.4 中,我们引入允许间隔违规的支持向量分类器小节 9.5 再通过核扩展到非线性类别边界。基本形式针对二元分类,小节 9.5.3 讨论多类别扩展,小节 9.6 比较其与逻辑回归等统计方法的联系和差异。

人们通常将最大间隔分类器、支持向量分类器和支持向量机统称为支持向量机。为了避免混淆,本章将仔细区分这三个概念。

9.3 最大间隔分类器

在本节中,我们定义超平面并引入最优分离超平面的概念。

9.3.1 什么是超平面

\(p\)维空间中,超平面是维度为\(p-1\)的平坦仿射子空间。例如在二维中,超平面是平坦的一维子空间——换句话说是一条线。在三维中超平面是平坦的二维子空间——即一个平面。在\(p > 3\)维中,很难想象超平面,但\((p-1)\)维平坦子空间的概念仍然适用。

在二维中,超平面由以下方程定义:

\[ \beta_0 + \beta_1 X_1 + \beta_2 X_2 = 0 \tag{9.1}\]

对于参数\(\beta_0, \beta_1, \beta_2\)。当我们说 (1) “定义”了超平面,我们的意思是任何满足 (1) 的\(X = (X_1, X_2)^T\)都是超平面上的一个点。注意 (1) 简单地是一条线的方程,因为在二维中超平面就是一条线。

  1. \(p\)维中扩展为:

\[ \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \cdots + \beta_p X_p = 0 \tag{9.2}\]

定义了一个\(p\)维超平面。如果点\(X = (X_1, X_2, \ldots, X_p)^T\)\(p\)维空间中(即长度为\(p\)的向量满足(式 9.2),那么\(X\)位于超平面上。

现在,假设\(X\)不满足(式 9.2);相反, \[ \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \cdots + \beta_p X_p > 0 \tag{9.3}\]

那么这告诉我们\(X\)位于超平面的一侧。另一方面,如果 \[ \beta_0 + \beta_1 X_1 + \beta_2 X_2 + \cdots + \beta_p X_p < 0 \tag{9.4}\]

那么\(X\)位于超平面的另一侧。因此,超平面将\(p\)维空间分成两个半空间;将点代入(式 9.2)后,左侧的符号决定它所在的一侧。

图 9.1 显示了二维空间中的超平面。

下面的 Python 代码在二维平面上构造一个线性分割场景。在二维空间里,超平面是一条由方程 \(1 + 2X_1 + 3X_2 = 0\) 定义的直线。 代码用 numpy 建立二维坐标网格,并计算每个点代入线性判别函数后的符号。函数值大于 0 与小于 0 的区域分别显示为两种颜色,等于 0 的位置就是分类边界。这个几何图说明线性分类器如何用一个超平面划分特征空间。

import numpy as np  # 构造超平面网格并计算线性判别函数符号
import matplotlib.pyplot as plt  # 让读者联读边界方程与两侧判别函数符号
plt.rcParams['font.sans-serif'] = ['Source Han Serif SC']  # 统一使用项目规定的出版字体
plt.rcParams['axes.unicode_minus'] = False  # 确保负函数间隔和坐标值的符号可辨认

fig, ax = plt.subplots(figsize=(8, 8))  # 用等比例坐标避免扭曲超平面的斜率

x1 = np.linspace(-2, 2, 400)  # 在[-2,2]区间生成400个等距X₁坐标值
x2_hyperplane = -(1 + 2 * x1) / 3  # 根据超平面方程1+2X₁+3X₂=0解出X₂

# 用网格点颜色编码判别函数的正负区域
x1_blue, x2_blue = np.meshgrid(np.linspace(-2, 2, 100), np.linspace(-2, 2, 100))  # 在共同支持域评估判别函数符号
mask_blue = 1 + 2 * x1_blue + 3 * x2_blue > 0  # 标记满足1+2X₁+3X₂>0的网格点(蓝色区域)
ax.scatter(x1_blue[mask_blue], x2_blue[mask_blue], c='blue', alpha=0.3, s=10)  # 用蓝色标识正判别区域

mask_purple = 1 + 2 * x1_blue + 3 * x2_blue < 0  # 标记满足1+2X₁+3X₂<0的网格点(紫色区域)
ax.scatter(x1_blue[mask_purple], x2_blue[mask_purple], c='purple', alpha=0.3, s=10)  # 用紫色标识负判别区域

ax.plot(x1, x2_hyperplane, 'k-', linewidth=3, label='超平面: 1 + 2X₁ + 3X₂ = 0')  # 用零等值线分隔两种判别符号

ax.set_xlim(-2, 2)  # 固定X₁支持域以对应网格计算范围
ax.set_ylim(-2, 2)  # 固定X₂支持域以保持两轴同尺度
ax.set_xlabel('X₁', fontsize=12)  # 标明判别函数的第一项输入
ax.set_ylabel('X₂', fontsize=12)  # 标明判别函数的第二项输入
ax.set_title('二维空间中的超平面', fontsize=14, fontname='Source Han Serif SC')  # 标明当前面板展示线性判别边界及两侧符号
ax.legend(loc='upper right', fontsize=10)  # 将黑色零等值线与其方程对应
ax.grid(True, alpha=0.3)  # 辅助读取边界与坐标轴的交点
ax.axhline(y=0, color='k', linewidth=0.5)  # 标出X₂为零时的边界截点
ax.axvline(x=0, color='k', linewidth=0.5)  # 标出X₁为零时的边界截点

plt.tight_layout()  # 保证边界方程与坐标标签在出版页完整可读
plt.show()  # 渲染二维超平面与法向量示意
/tmp/ipykernel_1450143/270134715.py:31: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保证边界方程与坐标标签在出版页完整可读
/tmp/ipykernel_1450143/270134715.py:31: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保证边界方程与坐标标签在出版页完整可读
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
黑色直线把二维平面分为蓝色正值区域和紫色负值区域。
图 9.1: 二维空间中的超平面示例。蓝色区域表示 \(1 + 2X_1 + 3X_2 > 0\) 的点集,紫色区域表示 \(1 + 2X_1 + 3X_2 < 0\) 的点集。

9.3.2 使用分离超平面进行分类

现在假设我们有一个\(n \times p\)的数据矩阵\(X\),它由\(p\)维空间中的\(n\)个训练观测组成 \[ x_1 = \begin{pmatrix} x_{11} \\ \vdots \\ x_{1p} \end{pmatrix}, \ldots, x_n = \begin{pmatrix} x_{n1} \\ \vdots \\ x_{np} \end{pmatrix} \]

并且这些观测落入两个类别——即\(y_1, \ldots, y_n \in \{-1, 1\}\),其中\(-1\)表示一个类别1表示另一个类别。我们还有一个测试观测即观测特征的\(p\)向量\(x^* = (x^*_1, \ldots, x^*_p)^T\)。我们的目标是基于训练数据开发一个分类器,使用其特征测量正确地对测试观测进行分类。

假设可以构造一个超平面,根据其类别标签完美地分离训练观测。我们称这样的超平面为分离超平面。我们可以将蓝色类别的观测标记为\(y_i = 1\),将紫色类别的观测标记为\(y_i = -1\)。那么分离超平面具有以下性质:

\[ \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \cdots + \beta_p x_{ip} > 0 \quad \text{if} \; y_i = 1 \tag{9.5}\]

\[ \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \cdots + \beta_p x_{ip} < 0 \quad \text{if} \; y_i = -1 \tag{9.6}\]

等价地,分离超平面具有以下性质:

\[ y_i (\beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \cdots + \beta_p x_{ip}) > 0 \quad \text{for all} \; i = 1, \ldots, n \tag{9.7}\]

如果分离超平面存在,可以据此构造一个直接的分类规则:测试观测根据它位于超平面的哪一侧来分配类别。也就是说,我们根据以下符号对测试观测 \(x^*\) 进行分类: \[ f(x^*) = \beta_0 + \beta_1 x^*_1 + \beta_2 x^*_2 + \cdots + \beta_p x^*_p \tag{9.8}\]

如果\(f(x^*)\)为正,那么我们将测试观测分配给类别\(1\);如果\(f(x^*)\)为负,那么我们将它分配给类别\(-1\)。我们也可以利用\(f(x^*)\)的大小。如果\(f(x^*)\)远离零,那么这意味着\(x^*\)远离超平面,因此我们可以对\(x^*\)的类别分配充满信心。另一方面,如果\(f(x^*)\)接近零,那么\(x^*\)位于超平面附近,我们对类别分配的确定性较低。

9.3.3 最大间隔分类器

通常,如果我们的数据可以使用超平面完美分离,那么实际上将存在无限多个这样的超平面。这是因为给定的分离超平面通常可以稍微向上或向下移动或旋转而不会与任何观测接触。图 9.2 的左面板显示了三种可能的分离超平面。

为了基于分离超平面构造分类器,我们必须有一种合理的方法来决定使用无限多个可能的分离超平面中的哪一个。一个自然的选择是最大间隔超平面(也称为最优分离超平面),它是距离训练观测最远的分离超平面。也就是说,我们可以计算每个训练观测到给定分离超平面的(垂直)距离;这些距离中最小的就是观测到超平面的最小距离,被称为间隔。最大间隔超平面是间隔最大的分离超平面——即它具有到训练观测的最远最小距离。然后我们根据测试观测位于最大间隔超平面的哪一侧对其进行分类。这被称为最大间隔分类器。我们希望训练数据上具有大间隔的分类器在测试数据上也将具有大间隔,从而正确分类测试观测

图 9.3 显示了@fig-separating-hyperplanes 数据的最大间隔超平面。比较@fig-separating-hyperplanes 的右面板和@fig-maximal-margin,我们看到 图 9.3 中显示的最大间隔超平面确实导致观测和分离超平面之间的最小距离更大——即更大的间隔。在某种意义上最大间隔超平面代表了我们可以在两个类别之间插入的最宽间隔带的中线。

图 9.3 中被红圈标出的训练观测是当前拟合器报告的支持向量。其数量和类别分布必须读取运行时的 n_support_,不能由示意图说明预先写死。在线性 SVC 的对偶解中,非零对偶系数对应支持向量;远离间隔且对偶系数为零的点不直接进入决策函数,但若数据、尺度、\(C\) 或数值容差改变,支持集合也可能改变。

然而,当真正的观测数据散落在特征空间中时(如下图左侧的蓝点和紫点),只要它们是线性可分的,能穿插在它们中间并且不碰到任何一个点的“分离超平面”将会有无数个(图中黑色实线、虚线、点划线只是其中三种可能)。 在线性可分数据中往往存在多条分割线。最大间隔原则选择使训练样本到边界的最小距离最大的那一条;它是一个明确的优化准则,不表示该边界在未知总体中“唯一正确”。 下方用线性 SVC 对固定种子样本拟合一个大惩罚软间隔近似。代码现场报告训练错分数、间隔违规数和支持向量数;只有训练错分与间隔违规均为零时,才可说该次有限精度解在这份样本上实现了硬间隔条件。

import numpy as np  # 生成两类二维样本并计算间隔诊断量
import matplotlib.pyplot as plt  # 并排展示候选分隔线及其几何间隔

np.random.seed(42)  # 固定可分几何,使候选超平面比较可重复
class1 = np.random.multivariate_normal([2, 2], [[1, 0], [0, 1]], 20)  # 以右上簇定义正类支持域
class2 = np.random.multivariate_normal([-2, -2], [[1, 0], [0, 1]], 20)  # 以左下簇定义负类支持域

利用生成的可分离数据,绘制多个分离超平面及其决策边界区域,如 图 9.2 所示:

# 并排比较分离边界的非唯一性与其中一条边界的分类区域
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))  # 让候选边界和决策区域共享同一尺度
# 左图用颜色区分两类可分观测
ax1.scatter(class1[:, 0], class1[:, 1], c='blue', label='类别 1', s=80, alpha=0.7)  # 编码正类观测的二维位置
# 紫色观测提供边界另一侧的负类支持域
ax1.scatter(class2[:, 0], class2[:, 1], c='purple', label='类别 -1', s=80, alpha=0.7)  # 编码负类观测的二维位置
# 在共同X₁支持域上计算三条候选边界
x = np.linspace(-5, 5, 100)  # 保持三条直线使用相同横坐标网格
# 超平面1: X₁ + X₂ = 0(实线)
ax1.plot(x, -x, 'k-', linewidth=2, label='超平面1: X₁ + X₂ = 0')  # 展示第一条可行零等值线
# 超平面2: X₁ + 0.5X₂ = 0(虚线)
ax1.plot(x, -2*x, 'k--', linewidth=2, label='超平面2: X₁ + 0.5X₂ = 0')  # 展示斜率不同的第二条可行边界
# 超平面3: 0.5X₁ + X₂ = 0(点划线)
ax1.plot(x, -0.5*x, 'k-.', linewidth=2, label='超平面3: 0.5X₁ + X₂ = 0')  # 展示斜率不同的第三条可行边界
# 固定左图尺度以直接比较三条边界
ax1.set(xlabel='X₁', ylabel='X₂', xlim=(-5, 5), ylim=(-5, 5))  # 固定左图的几何视窗与坐标含义
ax1.set_title('多个分离超平面', fontsize=14, fontname='Source Han Serif SC')  # 标明左图展示可分样本的边界非唯一性
ax1.legend(loc='upper left', fontsize=9)  # 将线型与三条候选方程对应
ax1.grid(True, alpha=0.3)  # 辅助比较各边界到样本的相对距离
# 右图保留同一观测以解释一条边界的分类规则
ax2.scatter(class1[:, 0], class1[:, 1], c='blue', label='类别 1', s=80, alpha=0.7)  # 对照正类观测与蓝色决策区域
ax2.scatter(class2[:, 0], class2[:, 1], c='purple', label='类别 -1', s=80, alpha=0.7)  # 对照负类观测与紫色决策区域
# 用第一条候选线说明判别函数等于零的位置
ax2.plot(x, -x, 'k-', linewidth=3)  # 标出两个决策区域的共同边界
# 在共同支持域评估判别函数符号
xx, yy = np.meshgrid(np.linspace(-5, 5, 100), np.linspace(-5, 5, 100))  # 形成区域着色所需坐标网格
# 计算决策函数值(X₁ + X₂)用于着色
decision = xx + yy  # 计算网格点在线性边界两侧的符号
# 用填充等高线标识两个决策区域
# 用填色直接对应分类器的符号规则
ax2.contourf(xx, yy, decision, levels=[-100, 0, 100], colors=['purple', 'blue'], alpha=0.2)
# 保持右图与左图相同尺度以便逐点对照
ax2.set(xlabel='X₁', ylabel='X₂', xlim=(-5, 5), ylim=(-5, 5))  # 固定右图视窗以对应同一特征空间
ax2.set_title('决策边界', fontsize=14, fontname='Source Han Serif SC')  # 标明右图展示其中一条边界及符号区域
ax2.legend(loc='upper left', fontsize=9)  # 说明两种点色对应的真实类别
ax2.grid(True, alpha=0.3)  # 辅助读取观测相对边界的位置
plt.tight_layout()  # 保持两面板坐标与标题同时可读
plt.show()  # 渲染候选边界与间隔比较
/tmp/ipykernel_1450143/2018685164.py:37: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保持两面板坐标与标题同时可读
/tmp/ipykernel_1450143/2018685164.py:37: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保持两面板坐标与标题同时可读
左侧散点图展示两类样本间三条可行分割线,右侧展示其中一条线形成的双色决策区域。
图 9.2: 左图:两个类别的观测分别显示为蓝色和紫色,每个观测在两个变量上有测量值,显示了三个可能的分离超平面。右图:基于分离超平面的分类器决策规则。

在直观理解线性可分数据的几何结构之后,我们使用 SVC 拟合大惩罚软间隔近似。这里的 \(C=1000\) 只是预先声明的教学参数,不等于数学上的无穷惩罚,也不保证对任意数据实现硬间隔。

from sklearn.svm import SVC  # 用有限大惩罚拟合线性间隔边界

# 将两个类别的数据合并为一个特征矩阵
X = np.vstack([class1, class2])  # 合并两类坐标形成共同设计矩阵
# 创建标签向量:前20个为+1类,后20个为-1类
y = np.hstack([np.ones(20), -np.ones(20)])  # 按堆叠顺序生成正负类别标签
# 预先指定较大有限惩罚;是否满足硬间隔条件由下方运行时诊断决定
clf = SVC(kernel='linear', C=1000)  # 用高违规惩罚近似硬间隔示例
# 在合并数据上拟合最大间隔分类器
clf.fit(X, y)  # 在固定二维样本上估计最大间隔边界
training_signed_margins = y * clf.decision_function(X)  # 计算每个训练点的带符号函数间隔
training_error_count = int(np.sum(clf.predict(X) != y))  # 现场统计训练错分而不预写完全分离
margin_violation_count = int(np.sum(training_signed_margins < 1 - 1e-6))  # 按数值容差统计间隔违规
support_vector_count = int(clf.support_.size)  # 从拟合器读取实际支持向量总数
print({'C': clf.C, 'training_errors': training_error_count, 'margin_violations': margin_violation_count, 'support_vectors_by_class': clf.n_support_.tolist(), 'support_vectors_total': support_vector_count})  # 输出支撑正文结论的运行时证据
{'C': 1000, 'training_errors': 0, 'margin_violations': 0, 'support_vectors_by_class': [1, 2], 'support_vectors_total': 3}

基于拟合好的最大间隔分类器,绘制决策边界、间隔带和支持向量,如 图 9.3 所示:

# 用等比例坐标联读决策边界、函数间隔与支持向量
fig, ax = plt.subplots(figsize=(10, 10))  # 为边界、间隔和支持向量建立同一坐标轴
# 用蓝色编码正类训练观测
ax.scatter(class1[:, 0], class1[:, 1], c='blue', label='类别 1', s=80, alpha=0.7)  # 对照正类观测与正间隔边界
# 用紫色编码负类训练观测
ax.scatter(class2[:, 0], class2[:, 1], c='purple', label='类别 -1', s=80, alpha=0.7)  # 对照负类观测与负间隔边界
# 提取分类器的权重向量w和截距b
w = clf.coef_[0]  # 读取线性决策函数的法向量
b = clf.intercept_[0]  # 读取线性决策函数的截距
# 在共同X₁支持域求解零边界和正负单位间隔
x = np.linspace(-5, 5, 100)  # 让三条线使用相同横坐标网格
# 计算决策超平面 w·x + b = 0 对应的x2坐标
decision_boundary = -w[0]/w[1] * x - b/w[1]  # 解出决策函数等于零的纵坐标
# 计算上间隔边界(w·x + b = +1)
boundary_up = (1 - b - w[0] * x) / w[1]  # 直接求解函数间隔正边界
# 计算下间隔边界(w·x + b = -1)
boundary_down = (-1 - b - w[0] * x) / w[1]  # 直接求解函数间隔负边界
# 以实线区分零边界,并用虚线标出正负单位函数间隔
ax.plot(x, decision_boundary, 'k-', linewidth=3, label='最大间隔超平面')  # 标出分类符号改变的位置
ax.plot(x, boundary_up, 'k--', linewidth=2, label='间隔边界')  # 标出正类单位函数间隔
ax.plot(x, boundary_down, 'k--', linewidth=2)  # 标出负类单位函数间隔
# 用红色空心圆标记支持向量的位置
ax.scatter(clf.support_vectors_[:, 0], clf.support_vectors_[:, 1],  # 圈出决定线性边界位置的训练观测
           s=200, linewidth=2, facecolors='none', edgecolors='red', label='支持向量')  # 用红色空心大点突出决定间隔的观测
# 固定几何视窗以完整呈现两侧间隔
ax.set(xlabel='X₁', ylabel='X₂', xlim=(-5, 5), ylim=(-5, 5))  # 固定视窗以同时展示间隔与支持向量
ax.set_title('最大间隔分类器', fontsize=14, fontname='Source Han Serif SC')  # 标明边界、间隔线与支持向量的共同几何关系
ax.legend(loc='upper left', fontsize=10)  # 对应零边界、间隔线、类别与支持向量
ax.grid(True, alpha=0.3)  # 辅助读取支持向量到边界的距离
plt.tight_layout()  # 保持几何标签和图例在出版页完整可读
plt.show()  # 渲染最大间隔解及其支持向量
/tmp/ipykernel_1450143/2946477253.py:30: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保持几何标签和图例在出版页完整可读
/tmp/ipykernel_1450143/2946477253.py:30: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 保持几何标签和图例在出版页完整可读
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
两类二维散点由最大间隔实线分开,两条虚线界定间隔,红圈标出支持向量。
图 9.3: 固定种子二维样本上的大惩罚线性SVC边界、函数间隔与运行时支持向量

图 9.3 的实线为运行时决策边界,虚线为决策函数等于 \(\pm1\) 的函数间隔边界,红圈来自 support_vectors_。固定种子输出若显示训练错分数和间隔违规数均为零,才说明该次大惩罚解在数值容差内分离并满足单位函数间隔;支持向量总数与各类数量一律以打印值为准。几何间隔宽度为 \(2/\lVert w\rVert\),而不是由图上的固定点数定义。

9.3.4 构造最大间隔分类器

我们现在考虑基于\(n\)个训练观测\(x_1, \ldots, x_n \in \mathbb{R}^p\)和关联类别标签\(y_1, \ldots, y_n \in \{-1, 1\}\)构造最大间隔超平面的任务。简而言之,最大间隔超平面是以下优化问题的解

\[ \max_{\beta_0, \beta_1, \ldots, \beta_p, M} \quad M \tag{9.9}\]

约束于

\[ \sum_{j=1}^{p} \beta_j^2 = 1 \tag{9.10}\]

\[ y_i (\beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \dots + \beta_p x_{ip}) \geq M \quad \forall i = 1, \ldots, n \tag{9.11}\]

这个优化问题实际上比它看起来要简单。首先(式 9.11)中的约束保证每个观测都在超平面的正确一侧前提是\(M\)为正。其次注意(式 9.10)并不是真正对超平面的约束,因为如果\(\beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \cdots + \beta_p x_{ip} = 0\)定义了超平面,那么\(k(\beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \cdots + \beta_p x_{ip}) = 0\)对于任何\(k \neq 0\)也是如此。然而(式 9.10)为(式 9.11)增加了意义可以证明,有了这个约束,第\(i\)个观测到超平面的垂直距离由下式给出 \[ y_i (\beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \dots + \beta_p x_{ip}) \]

因此,约束(式 9.10)和(式 9.11)确保每个观测都在超平面的正确一侧并且距离超平面至少为\(M\)。因此\(M\)代表我们超平面的间隔,优化问题选择\(\beta_0, \beta_1, \ldots, \beta_p\)以最大化\(M\)。这正是最大间隔超平面的定义

9.3.5 不可分离的情况

如果存在分离超平面,最大间隔准则给出一种明确的分类规则。然而在许多情况下不存在分离超平面,因此优化问题 (式 9.9) 没有 \(M > 0\) 的解。图 9.4 显示了一个例子;此时不能精确分离两个类别。

然而正如我们将在下一节中看到的我们可以扩展分离超平面的概念,以开发一个几乎分离类别的超平面使用所谓的软间隔。最大间隔分类器对不可分离情况的推广被称为支持向量分类器

现实商业与金融数据通常包含类别重叠,因此未必存在能把训练样本完全分开的超平面。下面的受控二维正态样本专门展示这种重叠:硬间隔约束没有可行解,需要允许部分观测位于间隔内部或被错分。由此得到的推广称为支持向量分类器(Support Vector Classifier, SVC)

np.random.seed(42)  # 固定两类重叠样本,便于复现硬间隔不可行性
# 从均值为(0,0)的二维正态分布中采样30个类别1的数据点
class1_overlap = np.random.multivariate_normal([0, 0], [[1, 0], [0, 1]], 30)  # 以原点附近支持域定义第一类
# 从均值为(1,1)的二维正态分布中采样30个类别-1的数据点(与类别1存在重叠)
class2_overlap = np.random.multivariate_normal([1, 1], [[1, 0], [0, 1]], 30)  # 以邻近均值制造两类共同支持区域

fig, ax = plt.subplots(figsize=(10, 10))  # 用等比例坐标观察两类支持域的重叠

# 用蓝色编码第一类重叠观测
ax.scatter(class1_overlap[:, 0], class1_overlap[:, 1],  # 展示第一类在共同支持域中的位置
           c='blue', label='类别 1', s=80, alpha=0.7)  # 用蓝色编码第一类重叠观测
# 用紫色编码第二类重叠观测
ax.scatter(class2_overlap[:, 0], class2_overlap[:, 1],  # 展示第二类与第一类交叠的位置
           c='purple', label='类别 -1', s=80, alpha=0.7)  # 用紫色编码第二类重叠观测

ax.set_xlabel('X₁', fontsize=12)  # 标明重叠样本的第一项特征
ax.set_ylabel('X₂', fontsize=12)  # 标明重叠样本的第二项特征
ax.set_title('不可分离的数据', fontsize=14, fontname='Source Han Serif SC')  # 标明重叠类别不能由单一直线零错分
ax.legend(loc='upper left', fontsize=10)  # 对应两种点色与真实类别
ax.grid(True, alpha=0.3)  # 辅助识别两类观测共享的坐标区域
ax.set_xlim(-4, 5)  # 保留两类X₁尾部以显示完整重叠范围
ax.set_ylim(-4, 5)  # 保留两类X₂尾部以显示完整重叠范围

plt.tight_layout()  # 确保重叠样本、坐标名与图例同时可读
plt.show()  # 渲染存在重叠的不可分样本
/tmp/ipykernel_1450143/2861250602.py:24: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 确保重叠样本、坐标名与图例同时可读
/tmp/ipykernel_1450143/2861250602.py:24: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  plt.tight_layout()  # 确保重叠样本、坐标名与图例同时可读
蓝色与紫色二维散点相互重叠,说明不存在完全分离两类的直线。
图 9.4: 不可分离数据示例。两个类别的观测(蓝色和紫色)不能被超平面分离。

9.4 支持向量分类器

9.4.1 支持向量分类器概述

两类观测可能无法被超平面完全分离;即使完全分离可行,强制所有训练点位于正确一侧也可能使边界由少数观测决定。因此,我们需要放宽完全分离约束。

软间隔允许部分观测进入间隔带或落在分类边界的错误一侧。调节参数\(C\)在间隔宽度与训练违规惩罚之间取舍;适当允许违规可减少边界对单个观测的敏感性,但不保证样本外误差一定降低。

图 9.5 的左面板给出原始分离边界,右面板加入一个观测后,边界方向和间隔宽度均发生明显变化。该图只说明硬间隔对单点的敏感机制,不是泛化性能的实证比较。

最大间隔分类器对单个观测的敏感性(示意) 左:原始可分训练集;右:增加一点后边界方向改变、间隔变窄 左面板:原始数据 右面板:增加单个观测 新增点 类别 +1 类别 -1 新增观测 分离超平面
图 9.5: 最大间隔分类器敏感性示意图:加入单个训练观测后,候选边界方向和间隔宽度都可能改变;该示意图不直接评价泛化表现。

这一放宽得到支持向量分类器,也称为软间隔分类器。它用松弛变量记录观测进入间隔带或被错分的程度,并在下一节的目标函数中对这些违规施加惩罚。

9.4.2 支持向量分类器的细节

支持向量分类器根据测试观测位于超平面的哪一侧分类。为与 scikit-learnSVC(C=...) 保持一致,本章统一使用惩罚形式:

\[ \min_{\beta,\beta_0,\xi}\;\frac12\lVert\beta\rVert^2+C\sum_{i=1}^n\xi_i \tag{9.12}\]

约束为

\[ y_i(\beta^\top x_i+\beta_0)\ge 1-\xi_i,\qquad \xi_i\ge0. \tag{9.13}\]

松弛变量 \(\xi_i=0\) 表示观测在间隔边界上或其正确一侧;\(0<\xi_i<1\) 表示违反间隔但仍被正确分类;\(\xi_i=1\) 表示观测位于决策超平面上;\(\xi_i>1\) 才表示错分。这里的 \(C\) 是违反间隔的惩罚强度,不是松弛变量总和的预算:固定数据、特征尺度与核以后,增大 \(C\) 会提高每单位训练违规在目标函数中的代价;减小 \(C\) 则相对加强 \(\lVert\beta\rVert^2\) 项的作用。这个优化权衡不能直接翻译成测试集表现或支持向量数目的排序。

提示:调优参数C的作用

调优参数 \(C\) 控制范数惩罚与训练松弛损失之间的权衡。较大的 \(C\) 更重视减少训练违规;较小的 \(C\) 更重视控制 \(\lVert\beta\rVert\)。当训练集线性可分时,\(C\) 足够大可使解接近硬间隔解;在不可分或含噪数据中,最优活跃约束会随 \(C\) 改变。

没有单调保证:随 \(C\) 增大,支持向量数目可能下降、保持不变或因活跃约束切换而上升;验证误差、测试误差与校准质量也都不必单调改善。即使两个 \(C\) 给出相同训练错误数,它们也可能给出不同边界;反之,重复或退化的边界点还能改变求解器报告的支持向量分配而不改变预测边界。因此,应只在训练数据内部用交叉验证选择 \(C\),锁定候选后再对未参与选择的测试集评价;不能用测试误差反向调参。

[拓展] 数学推导:支持向量分类器的拉格朗日对偶

阅读桥梁:primal 是直接对模型参数和松弛变量求最小值的问题;dual 为每条约束引入非负乘子。KKT 条件把原始可行性、对偶可行性、平稳性与互补松弛合在一起。核心路线只需理解“非零乘子对应影响边界的训练点”;完整消元步骤供希望理解核技巧来源的读者选读。

为了有效地解决带有不等式约束条件的支持向量分类器优化问题,并在后续自然地引入“核技巧(Kernel Trick)”,我们通常不直接求解原始(Primal)问题,而是求解它的拉格朗日对偶(Lagrangian Dual)问题。

首先,将原始的支持向量分类器问题写成标准的凸二次规划(Convex Quadratic Programming)形式: \[ \min_{\beta, \beta_0, \xi} \frac{1}{2}\|\beta\|^2 + C \sum_{i=1}^n \xi_i \] 约束于: \[ y_i(\beta^T x_i + \beta_0) \geq 1 - \xi_i, \quad \xi_i \geq 0 \quad \forall i \]

我们引入拉格朗日乘子 \(\alpha_i \geq 0\) 对应于主要约束,\(\mu_i \geq 0\) 对应于松弛变量非负约束,构建拉格朗日函数\[ L(\beta, \beta_0, \xi, \alpha, \mu) = \frac{1}{2}\|\beta\|^2 + C \sum_{i=1}^n \xi_i - \sum_{i=1}^n \alpha_i [y_i(\beta^T x_i + \beta_0) - (1 - \xi_i)] - \sum_{i=1}^n \mu_i \xi_i \]

为了找到对偶函数的极值,我们对\(L\)分别对\(\beta\)\(\beta_0\)\(\xi_i\)求偏导并令其为零。 1. \(\frac{\partial L}{\partial \beta} = 0 \Rightarrow \beta = \sum_{i=1}^n \alpha_i y_i x_i\) (这表明法向量\(\beta\) 仅仅是数据点 \(x_i\) 的线性组合) 2. \(\frac{\partial L}{\partial \beta_0} = 0 \Rightarrow \sum_{i=1}^n \alpha_i y_i = 0\) 3. \(\frac{\partial L}{\partial \xi_i} = 0 \Rightarrow \alpha_i + \mu_i = C\)

再加上原始可行性、对偶可行性与互补松弛,KKT 条件可统一写成 \[ \begin{aligned} &y_i(\beta^\top x_i+\beta_0)\ge 1-\xi_i,\quad \xi_i\ge0,\\ &\alpha_i\ge0,\quad \mu_i\ge0,\quad \alpha_i+\mu_i=C,\\ &\alpha_i\{y_i(\beta^\top x_i+\beta_0)-1+\xi_i\}=0,\quad \mu_i\xi_i=0. \end{aligned} \tag{9.14}\] 于是 \(0\le\alpha_i\le C\)。将平稳性条件代回拉格朗日函数,消去\(\beta\)\(\beta_0\)\(\xi_i\)后,得到拉格朗日对偶优化问题(仅关于非负乘子 \(\alpha\) 的最大化任务): \[ \max_{\alpha} \left( \sum_{i=1}^n \alpha_i - \frac{1}{2} \sum_{i=1}^n \sum_{j=1}^n \alpha_i \alpha_j y_i y_j \langle x_i, x_j \rangle \right) \] 约束于: \[ 0 \leq \alpha_i \leq C, \quad \sum_{i=1}^n \alpha_i y_i = 0 \]

这个对偶形式的两个重要性质: 1. 预测函数可能稀疏:由 式 9.14\(\alpha_i>0\) 的点使间隔约束取等号,称为支持向量。预测新观测 \(x^*\) 时,\(f(x^*) = \sum_{i \in S} \alpha_i y_i \langle x_i,x^*\rangle + \beta_0\),其中 \(S=\{i:\alpha_i>0\}\)。支持向量未必总是“少数”,其数量取决于数据、核、\(C\) 与数值容差。 2. 内积形式分离:对偶目标和预测函数通过样本对内积访问特征。这允许用合法核替换内积,但并不消除计算核矩阵或求解二次规划的成本。

优化问题 式 9.12式 9.13 有一个重要性质:只有位于间隔上或违反间隔的观测会影响超平面。严格位于间隔正确一侧的观测不影响支持向量分类器;直接位于间隔上或违反间隔的观测称为支持向量

支持向量分类器的决策规则仅基于训练观测的潜在小子集支持向量)这一事实意味着它对远离超平面的观测行为相当鲁棒。这一性质与我们前面看到的其他一些分类方法如线性判别分析明显不同。回想一个LDA分类规则依赖于每个类别中所有观测的均值以及使用所有观测计算的类内协方差矩阵。

9.5 支持向量机

我们首先讨论将线性分类器转换为产生非线性决策边界的分类器的一般机制。然后我们介绍支持向量机,它以自动方式做到这一点。

9.5.1 具有非线性决策边界的分类

支持向量分类器以线性超平面表示二元分类边界。当类别结构是非线性的时,这一表示可能产生系统性错分。图 9.6 左面板显示弯曲的类别结构,右面板则显示线性边界无法表示该几何形状。

非线性决策边界示意:线性分类器的局限 左 环形/非线性可分;右 线性边界无法有效分离 左面板:数据分布 右面板:线性边界 线性边界 类别 A(外圈) 类别 B(内圈) 线性分割
图 9.6: 非线性边界示意图:当类别边界呈现明显非线性结构时,线性支持向量分类器往往无法给出合理的分割。

章节 7 中,我们遇到了类似的情况。我们看到当预测变量和结果之间存在非线性关系时,线性回归的性能会受到影响。在这种情况下我们考虑使用预测变量的函数如二次和三次项来扩大特征空间以解决这种非线性问题。

在支持向量分类器的情况下,我们可以通过类似的方式解决类别之间可能存在的非线性边界问题方法是使用二次、三次甚至更高阶多项式的预测变量来扩大特征空间。例如,不是使用\(p\)个特征\(X_1, X_2, \ldots, X_p\)拟合支持向量分类器,我们可以使用\(2p\)个特征\(X_1, X_1^2, X_2, X_2^2, \ldots, X_p, X_p^2\)拟合支持向量分类器。

在扩大的特征空间的结果分类器的决策边界实际上是线性的。但在原始特征空间中,决策边界的形式为\(q(x) = 0\),其中\(q\)是二次多项式,其解通常是非线性的。

9.5.2 支持向量机

支持向量机(SVM)是支持向量分类器的扩展,其结果是以特定方式扩大特征空间,使用。我们现在讨论这个扩展,其细节有些复杂,超出了本书的范围。然而,主要思想如@sec-non-linear-boundaries 所述,我们可能想要扩大我们的特征空间以容纳类别之间的非线性边界。支持向量机,我们接下来介绍,允许我们以导致有效计算的方式扩大支持向量分类器使用的特征空间。

事实证明,支持向量分类器问题的解仅涉及观测的内积(而不是观测本身)。两个\(r\)向量\(a\)\(b\)的内积定义为: \[ \langle a, b \rangle = \sum_{i=1}^{r} a_i b_i \]

因此,两个观测\(x_i\)\(x_i'\)的内积由下式给出:

\[ \langle x_i, x_i' \rangle = \sum_{j=1}^{p} x_{ij} x_{i'j} \tag{9.15}\]

由上面的对偶解,线性支持向量分类器可以表示为

\[ f(x) = \beta_0 + \sum_{i=1}^{n} \alpha_i y_i \langle x_i, x \rangle \tag{9.16}\]

这里的 \(\alpha_i\) 是对偶问题中满足 \(0\le\alpha_i\le C\)非负乘子,类别符号由单独的 \(y_i\in\{-1,+1\}\) 提供;不能把 \(y_i\) 吸收到 \(\alpha_i\) 后仍把后者称为非负对偶乘子。此外,为了估计 \(\alpha_1, \ldots, \alpha_n\)\(\beta_0\),对偶问题需要所有训练观测对之间的内积 \(\langle x_i,x_j\rangle\)

注意,在(式 9.16)中为了评估函数\(f(x)\),我们需要计算新点\(x\)和每个训练点\(x_i\)之间的内积。然而事实证明,只有支持向量的\(\alpha_i\)非零。因此如果\(S\)是这些支持点的索引集合我们可以将任何形式为(式 9.16)的解函数重写为

\[ f(x) = \beta_0 + \sum_{i \in S} \alpha_i y_i \langle x_i, x \rangle \tag{9.17}\]

总结起来为了表示线性分类器\(f(x)\)并计算其系数,我们只需要内积。

现在假设每次内积(式 9.15)出现在表示(式 9.16)中或支持向量分类器的解的计算中时,我们用以下形式的内积推广来替换它:

\[ K(x_i, x_i') \tag{9.18}\]

其中\(K\)是我们称为的函数。核是量化两个观测相似度的函数。例如我们可以简单地取

\[ K(x_i, x_i') = \sum_{j=1}^{p} x_{ij} x_{i'j} \tag{9.19}\]

这只会给我们带回支持向量分类器。方程 式 9.19 被称为线性核,因为支持向量分类器在特征中是线性的。它是欧氏空间中的点积,不是 Pearson 相关系数;只有在额外完成逐变量中心化、尺度处理,并对每个观测向量作适当归一化等特定条件下,某些点积才可与相关或余弦量联系起来。

但人们可以选择(式 9.18)的另一种形式。例如,人们可以将\(\sum_{j=1}^{p} x_{ij} x_{i'j}\)的每个实例替换为以下量

\[ K(x_i, x_i') = (1 + \sum_{j=1}^{p} x_{ij} x_{i'j})^d \tag{9.20}\]

这被称为多项式核,其中\(d\)是正整数。使用\(d > 1\)的这种核,而不是标准的线性核(式 9.19),在支持向量分类器算法中导致更灵活的决策边界。它本质上是在更高维空间中拟合支持向量分类器,该空间涉及\(d\)次多项式,而不是原始特征空间。当支持向量分类器与非线性的核如(式 9.20)结合时得到的分类器被称为支持向量机

多项式核(式 9.20)是一种非线性核。另一种常用构造是径向核,它采用以下形式

\[ K(x_i, x_i') = \exp\left(-\gamma \sum_{j=1}^{p} (x_{ij} - x_{i'j})^2\right) \tag{9.21}\]

在(式 9.21)中,\(\gamma\)是一个正常数。

将合法核代入对偶问题后,预测函数为

\[ f_K(x)=\beta_0+\sum_{i\in S}\alpha_i y_i K(x_i,x), \tag{9.22}\]

其中仍有 \(\alpha_i\ge0\),分类由 \(\operatorname{sign}\{f_K(x)\}\) 给出。这一表示与 式 9.17 使用同一组符号。

理论深度:正定核、RKHS 与 Mercer 展开

既然我们将 \(\langle x_i,x_j\rangle\) 替换为 \(K(x_i,x_j)\),是否任何二元相似度函数都可以作为核?答案是否定的。SVM 对偶需要相应的二次项保持凸性。

有限样本正定性与 RKHS 表示。 对称函数 \(K\) 是正定核,指对任意有限点集 \(\{x_1,\ldots,x_n\}\),Gram 矩阵 \(\mathbf K=[K(x_i,x_j)]\) 都是半正定的,即对任意 \(v\in\mathbb R^n\)

\[ \mathbf{v}^T \mathbf{K} \mathbf{v} = \sum_{i=1}^n \sum_{j=1}^n v_i v_j K(x_i, x_j) \geq 0 \]

Moore–Aronszajn 理论据此给出唯一的再生核 Hilbert 空间 \(\mathcal H_K\);取规范特征映射 \(\phi(x)=K(x,\cdot)\),便有 \(K(x,z)=\langle\phi(x),\phi(z)\rangle_{\mathcal H_K}\) (Aronszajn 1950年)。这一结论是有限 Gram 矩阵条件与 RKHS 表示之间的联系。

Mercer 积分展开是另一层结论。 经典 Mercer 定理还要求定义域、连续性与测度等条件;例如在紧致域上,对连续、对称、正定的核,相应积分算子可给出 \(K(x,z)=\sum_{m\ge1}\lambda_m e_m(x)e_m(z)\) 的特征函数展开,并带有相应的收敛结论 (Mercer 1909年)。不能把这个积分算子展开与“每个有限 Gram 矩阵半正定”当作同一句无条件的充分必要陈述。

计算边界。 核技巧避免显式构造 \(\phi(x)\),但不会让大样本训练自动变容易。完整 Gram 矩阵有 \(n^2\) 个元素,若显式存储需 \(O(n^2)\) 内存;分解式求解器可使用缓存,却仍要反复求核值,训练时间随求解器、容差、数据几何和支持向量数而变,通常是超线性的 (Chang 和 Lin 2011年)。Nyström 方法用抽取的列近似低秩 Gram 矩阵 (Williams 和 Seeger 2001年);随机 Fourier 特征则为平移不变核构造有限维随机映射,再调用线性求解器 (Rahimi 和 Recht 2007年)。二者都以近似误差换取内存和时间,秩或特征数必须在开发数据上选择,不能视为精确核 SVM 的无损替代。

提示:核函数的直观理解

核函数\(K(x_i, x_j)\)衡量两个观测\(x_i\)\(x_j\)之间的相似度:

  1. 线性核(\(K(x_i, x_j) = \langle x_i, x_j \rangle\)): 相似度是欧氏点积,本身不是 Pearson 相关系数
  2. 多项式核(\(K(x_i, x_j) = (1 + \langle x_i, x_j \rangle)^d\)): 考虑特征之间的多项式交互
  3. 径向核(RBF): 相似度随欧氏距离指数衰减

径向核工作原则如果测试观测\(x^*\)在欧氏距离上远离训练观测\(x_i\),那么\(\sum_{j=1}^{p} (x^*_j - x_{ij})^2\)将很大因此\(K(x^*, x_i) = \exp(-\gamma \sum_{j=1}^{p} (x^*_j - x_{ij})^2)\)将非常小。在 式 9.22 中,这会衰减该支持向量的数值贡献,但所有 \(\alpha_i y_i K(x_i,x^*)\) 仍共同决定结果,不能字面解释成只有附近训练点才有影响。

9.5.3 SVM与多类分类

到目前为止,我们的讨论限于二元分类。分离超平面本身只定义两个半空间,因此将SVM扩展到\(K>2\)类需要组合多个二分类器。下面介绍两种常用构造:一对一一对其余

9.5.3.1 一对一分类

假设我们想要使用SVM进行分类,并且有\(K > 2\)个类别。一对一成对方法构造\(\binom{K}{2}\)个SVM,每个都比较一对类别。例如,其中一个SVM可能将第\(k\)个类别编码为\(+1\),与第\(k'\)个类别编码为\(-1\)进行比较。我们使用\(\binom{K}{2}\)个分类器中的每一个对测试观测进行分类,并计算测试观测分配给每个\(K\)类别的次数。最终分类是通过将测试观测分配给它在这些成对分类中最常被分配的类别来执行的。

9.5.3.2 一对其余分类

一对其余方法是使用SVM在\(K > 2\)类情况下的替代程序。我们拟合\(K\)个SVM,每次将\(K\)个类别中的一个与剩余的\(K-1\)个类别进行比较。设\(\beta_0^k, \beta_1^k, \ldots, \beta_p^k\)表示拟合比较第\(k\)个类别(编码为\(+1\))与其他类别(编码为\(-1\))的SVM得到的结果参数。设\(x^*\)表示测试观测。我们将观测分配给\(\beta_0^k + \beta_1^k x^*_1 + \beta_2^k x^*_2 + \cdots + \beta_p^k x^*_p\)最大的类别,因为这相当于对测试观测属于第\(k\)个类别而不是任何其他类别具有高水平的置信度。

9.6 与逻辑回归的关系

SVM在1990年代中期形成了以间隔与核函数为核心的分类框架。软间隔允许训练违规,核函数则通过内积表示非线性边界。

这一框架可以改写为“损失+惩罚”的形式,从而与其他正则化分类方法在统一的优化视角下比较:

\[ \min_{\beta_0, \beta_1, \ldots, \beta_p} \left\{ \sum_{i=1}^{n} \max[0, 1 - y_i f(x_i)] + \lambda \sum_{j=1}^{p} \beta_j^2 \right\} \tag{9.23}\]

其中\(\lambda\)是一个非负调优参数。增大\(\lambda\)会提高系数范数的相对代价,减小\(\lambda\)会提高拟合铰链损失的相对作用;常见的偏差—方差直觉只能作为调参假设,实际样本外误差仍须验证。

(式 9.23)采用我们在本书中反复看到的”损失+惩罚”形式:

\[ \min_{\beta_0, \beta_1, \ldots, \beta_p} \{ L(X, y, \beta) + \lambda P(\beta) \} \tag{9.24}\]

在(式 9.24)中,\(L(X, y, \beta)\)是某个损失函数量化由参数\(\beta\)参数化的模型对数据\((X, y)\)的拟合程度,\(P(\beta)\)是对参数向量\(\beta\)的惩罚函数,其效果由非负调优参数\(\lambda\)控制。

对于(式 9.23),损失函数采用以下形式: \[ L(X, y, \beta) = \sum_{i=1}^{n} \max[0, 1 - y_i (\beta_0 + \beta_1 x_{i1} + \dots + \beta_p x_{ip})] \]

这被称为铰链损失,如图所示。然而事实证明,铰链损失函数与逻辑回归中使用的损失函数密切相关。

支持向量分类器的一个有趣特征是只有 \(\alpha_i>0\) 的支持向量出现在决策函数中。这与铰链损失对 \(y_i (\beta_0 + \beta_1 x_{i1} + \dots + \beta_p x_{ip}) \geq 1\) 的观测取零相呼应。相反,逻辑损失在有限间隔处不精确等于零,只会随正确分类间隔增加而趋近于零。两种方法可能给出相似结果,但“分离好就由 SVM 占优、重叠多就由逻辑回归占优”不是一般定理;正则化、概率校准、核、样本量和评价指标都会改变比较,必须在同一外部验证协议下判断。

图 9.7 比较了SVM(铰链)损失和逻辑回归损失。

从“损失函数加正则化”的视角看,支持向量分类器与 章节 4 的正则化逻辑回归都是基于有符号间隔的线性分类器,但二者使用不同损失,且逻辑回归直接给出参数化条件概率,SVC 的原始决策分数则不是概率。 下面的代码在同一有符号间隔坐标上比较逻辑损失与铰链损失(Hinge Loss),以说明两种目标函数对错分和正确分类样本施加的不同代价。 图中可见:当有符号间隔 \(y_i(\beta^T x_i+\beta_0)\) 为负时,两种损失都增大;当间隔达到 1 时,铰链损失精确变为零,而逻辑损失只渐近趋近于零。这一截断使非支持向量的最优对偶乘子为零,但不保证任意数据集都产生高度稀疏的支持向量集合。

import numpy as np  # 在统一有符号间隔网格上计算两种分类损失
import matplotlib.pyplot as plt  # 在同一坐标系比较铰链损失与逻辑损失

x = np.linspace(-4, 4, 400)  # 在[-4,4]区间生成400个等距点作为函数输入

hinge_loss = np.maximum(0, 1 - x)  # 计算铰链损失: max(0, 1-x)
logistic_loss = np.log(1 + np.exp(-x))  # 计算逻辑回归损失: log(1+exp(-x))

fig, ax = plt.subplots(figsize=(10, 6))  # 在同一有符号间隔尺度上比较两种损失

ax.plot(x, hinge_loss, 'b-', linewidth=3, label='SVM损失(铰链损失)')  # 展示单位间隔后截断为零的代价
ax.plot(x, logistic_loss, 'r-', linewidth=3, label='逻辑回归损失')  # 展示随间隔增大而平滑衰减的代价

ax.set_xlabel('$y_i (\\beta_0 + \\beta_1 x_{i1} + \\cdots + \\beta_p x_{ip})$', fontsize=12)  # 标明横轴是共同的有符号函数间隔
ax.set_ylabel('损失', fontsize=12)  # 标明纵轴比较每个观测的分类代价
ax.set_title('SVM与逻辑回归损失函数比较', fontsize=14, fontname='Source Han Serif SC')  # 标明面板比较同一有符号间隔上的损失形状
ax.legend(fontsize=11)  # 对应两条曲线与各自损失定义
ax.grid(True, alpha=0.3)  # 辅助比较间隔零点与单位间隔处的损失
ax.set_xlim(-4, 4)  # 覆盖明显错分到充分正确分类的间隔区间
ax.set_ylim(-0.5, 8)  # 保留零损失基线并容纳负间隔代价

ax.axhline(y=0, color='k', linewidth=0.5)  # 标出铰链损失能够达到的零代价
ax.axvline(x=0, color='k', linewidth=0.5)  # 标出预测符号改变的决策边界
ax.axvline(x=1, color='gray', linewidth=1, linestyle='--', alpha=0.5)  # 标出铰链损失停止惩罚的单位间隔
ax.text(1.1, 0.5, '间隔边界', fontsize=10, color='gray')  # 让单位间隔阈值可由图中直接定位

plt.tight_layout()  # 保持公式横轴、曲线名称与阈值标注完整可读
plt.show()  # 渲染合页损失与逻辑损失的形状差异
横轴为分类间隔,曲线比较铰链损失与逻辑损失;铰链损失在间隔达到一后为零。
图 9.7: SVM铰链损失与逻辑回归损失的比较。当 \(y_i(\beta_0 + \beta_1 x_{i1} + \cdots + \beta_p x_{ip})\) 大于1时,SVM损失为零。

9.7 实验:支持向量机

在本节中,我们使用sklearn.svm库来演示支持向量分类器和支持向量机。

9.7.1 支持向量分类器

我们现在使用 sklearn 中的 SVC() 拟合给定 \(C\) 的支持向量分类器。C 指定每单位间隔违规在目标函数中的代价;它如何改变本例的几何间隔、违规数和支持向量数,应由运行时指标读取,不能预设为随 \(C\) 单调增减。

我们使用二维数据集来演示支持向量分类器以便我们可以绘制结果决策边界。

下面用同一二维样本比较两个预先指定的违规惩罚参数。 下面的线性 SVC 用同一二维样本比较事前指定的 \(C=10\)\(C=0.1\)。这里 \(C\) 始终是间隔违规和错分的惩罚权重:较大 \(C\) 对训练违规惩罚更重,较小 \(C\) 正则化更强、容忍更多违规;它不是“松弛预算”。两种设置的支持向量数、错分数、间隔违规数与几何间隔宽度均由代码现场计算,不预设数量或胜者。

import numpy as np  # 生成固定二维样本并计算分类间隔与违规数量
import matplotlib.pyplot as plt  # 展示不同违规惩罚下的边界、间隔与支持向量
from sklearn.svm import SVC  # 拟合不同惩罚参数下的线性软间隔边界

np.random.seed(42)  # 固定软间隔样本,隔离随机变动与 C 的影响
feature_matrix_2d = np.random.randn(100, 2)  # 以同方差噪声构造重叠支持域
# 创建类别标签:前50个为-1类,后50个为+1类
class_labels_2d = np.array([-1] * 50 + [1] * 50)  # 按生成顺序登记两个各50例的类别标签
# 将+1类的数据点整体向右上方平移1个单位,制造类别间的偏移
feature_matrix_2d[class_labels_2d == 1] += 1  # 沿对角方向平移正类以形成可分几何
# 使用C=10拟合线性支持向量分类器(每单位训练违规惩罚较高)
linear_svc_model = SVC(C=10, kernel='linear')  # 用较高违规惩罚拟合首个线性边界
linear_svc_model.fit(feature_matrix_2d, class_labels_2d)  # 在同一受控样本上估计边界
SVC(C=10, kernel='linear')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

图 9.8 给出本章复用的SVM决策边界绘图函数;实际图形由后续各自带图号的调用块输出。

def visualize_svm_boundary(features, labels, model, axis, title='支持向量分类器'):  # 在共同坐标中叠加类别、零边界、单位间隔与支持向量
    '''绘制SVM的决策边界、间隔带和支持向量。'''  # 说明该教学函数只负责几何展示
    lower_class, upper_class = model.classes_  # 从拟合器读取两个真实类别编码
    # 用蓝色编码拟合器中排序较小的类别
    axis.scatter(features[labels == lower_class][:, 0], features[labels == lower_class][:, 1], c='blue', label=f'类别 {lower_class}', s=80, alpha=0.7)  # 选择并编码拟合器中排序较小的类别观测
    # 用紫色编码拟合器中排序较大的类别
    axis.scatter(features[labels == upper_class][:, 0], features[labels == upper_class][:, 1],  # 选择拟合器编码较大的类别观测
                c='purple', label=f'类别 {upper_class}', s=80, alpha=0.7)  # 使用运行时类别编码
    xlim = axis.get_xlim()  # 沿当前样本X₁支持域评估决策函数
    ylim = axis.get_ylim()  # 沿当前样本X₂支持域评估决策函数
    # 用固定密度网格定位零边界与正负单位间隔
    x_grid = np.linspace(xlim[0], xlim[1], 300)  # 在X₁支持域保持均匀分辨率
    # 在第二维使用同样分辨率以避免边界形状失真
    y_grid = np.linspace(ylim[0], ylim[1], 300)  # 在X₂支持域保持均匀分辨率
    # 组合二维坐标以逐点计算分类分数
    mesh_y, mesh_x = np.meshgrid(y_grid, x_grid)  # 对齐两项特征的规则评价位置
    # 将规则网格转为逐点特征矩阵以计算整幅决策区域
    grid_points = np.vstack([mesh_x.ravel(), mesh_y.ravel()]).T  # 保持每行对应一个二维候选点
    # 恢复网格形状以定位决策函数的三个等值水平
    decision_values = model.decision_function(grid_points).reshape(mesh_x.shape)  # 恢复网格形状供等高线定位边界
    # 以实线标出零边界,并以虚线标出正负单位函数间隔
    axis.contour(mesh_x, mesh_y, decision_values, colors='k', levels=[-1, 0, 1],  # 提取决定分类与间隔违规的三个水平
               alpha=0.5, linestyles=['--', '-', '--'])  # 让零边界与正负间隔在线型上可区分
    # 用红色空心圆标记支持向量
    axis.scatter(model.support_vectors_[:, 0], model.support_vectors_[:, 1],  # 圈出实际决定边界位置的训练观测
               s=200, linewidth=2, facecolors='none', edgecolors='red',  # 用醒目空心圆编码支持向量
               label='支持向量')  # 将红色空心标记对应到支持向量
    axis.set(xlabel='X₁', ylabel='X₂', title=title)  # 提供完整坐标与子图标题
    axis.legend(fontsize=9)  # 对应类别颜色、边界线与支持向量标记
    axis.grid(True, alpha=0.3)  # 辅助读取观测相对零边界和间隔线的位置

boundary_example_figure, boundary_example_axis = plt.subplots(figsize=(7, 6))  # 生成函数定义对应的实际调用示例
visualize_svm_boundary(feature_matrix_2d, class_labels_2d, linear_svc_model, boundary_example_axis)  # 展示该拟合器的间隔宽度、违规点与支持向量
boundary_example_figure.tight_layout()  # 避免示例标签裁切
plt.show()  # 输出具有独立图号的函数示例
/tmp/ipykernel_1450143/2733865936.py:34: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  boundary_example_figure.tight_layout()  # 避免示例标签裁切
/tmp/ipykernel_1450143/2733865936.py:34: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  boundary_example_figure.tight_layout()  # 避免示例标签裁切
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
两类二维散点、零等高决策边界、正负一间隔线及红圈支持向量。
图 9.8: 通用边界函数在固定种子线性SVC上的调用示例

使用定义好的可视化函数,在 图 9.9 分别展示 \(C=10\)\(C=0.1\) 的决策边界,并打印每种拟合的实际诊断:

# 使用C=0.1重新拟合线性SVM(每单位训练违规惩罚较低)
linear_svc_relaxed = SVC(C=0.1, kernel='linear')  # 用较低违规惩罚形成更宽松的比较边界
# 在相同数据上训练宽松约束的SVM模型
linear_svc_relaxed.fit(feature_matrix_2d, class_labels_2d)  # 在同一受控样本上估计宽松边界
svc_comparison_figure, svc_comparison_axes = plt.subplots(1, 2, figsize=(14, 6))  # 用一个带双面板的画布承载两种C设置
visualize_svm_boundary(feature_matrix_2d, class_labels_2d, linear_svc_model, svc_comparison_axes[0], '线性SVC (C=10)')  # 呈现较大违规惩罚下的边界与支持向量
visualize_svm_boundary(feature_matrix_2d, class_labels_2d, linear_svc_relaxed, svc_comparison_axes[1], '线性SVC (C=0.1)')  # 呈现较强正则下的边界与支持向量
svc_comparison_figure.tight_layout()  # 避免双面板标签重叠
plt.show()  # 输出两种C的边界与间隔诊断供并列解读
for compared_model in [linear_svc_model, linear_svc_relaxed]:  # 对两个预先指定的惩罚参数生成同口径证据
    compared_margins = class_labels_2d * compared_model.decision_function(feature_matrix_2d)  # 计算训练函数间隔
    compared_metrics = {'C': compared_model.C, 'support_vectors': int(compared_model.support_.size), 'training_errors': int(np.sum(compared_model.predict(feature_matrix_2d) != class_labels_2d)), 'margin_violations': int(np.sum(compared_margins < 1 - 1e-6)), 'geometric_margin_width': float(2 / np.linalg.norm(compared_model.coef_[0]))}  # 汇总运行时诊断
    print(compared_metrics)  # 让间隔与支持向量叙述可由输出核验
/tmp/ipykernel_1450143/2246232918.py:8: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  svc_comparison_figure.tight_layout()  # 避免双面板标签重叠
/tmp/ipykernel_1450143/2246232918.py:8: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  svc_comparison_figure.tight_layout()  # 避免双面板标签重叠
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
两幅依次输出的二维散点图比较C等于10与0.1时的决策边界、函数间隔和红圈支持向量。
图 9.9: 同一二维样本上两个惩罚参数的线性SVC边界
{'C': 10, 'support_vectors': 50, 'training_errors': 24, 'margin_violations': 49, 'geometric_margin_width': 1.5800555433262402}
{'C': 0.1, 'support_vectors': 60, 'training_errors': 22, 'margin_violations': 58, 'geometric_margin_width': 2.0563814390244857}

两图只展示当前训练样本中 \(C\) 改变间隔违规惩罚后,间隔宽度、违规数与支持向量集合如何变化。较小 \(C\) 是否带来更低方差或更好泛化不是无条件结论,必须在预先固定验证折上与较大 \(C\) 共同选择;金融噪声也不能预先指定赢家。

9.7.2 支持向量机

为了使用非线性核拟合SVM,我们再次使用SVC()估计器。但是现在我们使用参数kernel的不同值。要拟合具有多项式核的SVM,我们使用kernel='poly',要拟合具有径向核的SVM,我们使用kernel='rbf'。在前一种情况下,我们还使用degree参数指定多项式核的度数这是(式 9.20)中的\(d\)),在后一种情况下,我们使用gamma指定径向基核(式 9.21)的\(\gamma\)值。

我们首先生成一些具有非线性类别边界的数据点

当类别边界并非线性时,可以把 RBF 核作为候选。下面的受控二维模拟展示核 SVM 可形成弯曲边界;具体形状由样本、\(C\)\(\gamma\) 决定,不预设为闭合圆或优于线性模型。GridSearchCV 只在训练数据内部选择候选参数,所谓“最佳”仅指当前网格和评分规则内的验证结果。

列表 9.1: 固定种子RBF核SVM的数据、切分与拟合
np.random.seed(42)  # 固定受控边界样本,避免随机变化混淆核参数示范
non_linear_features = np.random.randn(200, 2)  # 以同方差二维噪声作为类别几何的基底
non_linear_features[:100] += 2  # 将第一组正类中心移至右上区域
non_linear_features[100:150] -= 2  # 将第二组正类中心移至左下区域
non_linear_labels = np.array([1] * 150 + [2] * 50)  # 以两个分离簇对一个中心簇形成非线性标签结构

from sklearn.model_selection import train_test_split, GridSearchCV  # 分离模拟测试集并仅在训练集选择核参数

# 预留一半样本作为锁定评价集,核参数只由训练部分选择
rbf_features_train, rbf_features_test, rbf_labels_train, rbf_labels_test = train_test_split(non_linear_features, non_linear_labels, test_size=0.5, random_state=42)  # 固定切分以便比较候选边界

rbf_svm_classifier = SVC(kernel='rbf', gamma=1, C=1)  # 固定 C=1、gamma=1 作为网格搜索前的比较候选
rbf_svm_classifier.fit(rbf_features_train, rbf_labels_train)  # 仅用训练半样本估计该候选边界
rbf_training_signs = np.where(rbf_labels_train == rbf_svm_classifier.classes_[1], 1, -1)  # 将运行时类别顺序映射为符号标签
rbf_training_margins = rbf_training_signs * rbf_svm_classifier.decision_function(rbf_features_train)  # 计算实际训练函数间隔
rbf_training_metrics = {'C': rbf_svm_classifier.C, 'gamma': rbf_svm_classifier.gamma, 'support_vectors_by_class': rbf_svm_classifier.n_support_.tolist(), 'support_vectors_total': int(rbf_svm_classifier.support_.size), 'training_errors': int(np.sum(rbf_svm_classifier.predict(rbf_features_train) != rbf_labels_train)), 'margin_violations': int(np.sum(rbf_training_margins < 1 - 1e-6))}  # 汇总固定候选证据
print(rbf_training_metrics)  # 以运行时数量约束后续叙述
{'C': 1, 'gamma': 1, 'support_vectors_by_class': [28, 14], 'support_vectors_total': 42, 'training_errors': 3, 'margin_violations': 29}

图 9.10 把完整受控样本与固定候选在训练半样本上学到的边界并列展示;它不声称精确分离,也不把 \(C=1,\gamma=1\) 称为最优。

rbf_fixed_figure, rbf_fixed_axes = plt.subplots(1, 2, figsize=(14, 6))  # 用单一双面板画布比较数据与训练边界
rbf_fixed_axes[0].scatter(non_linear_features[non_linear_labels == 1][:, 0], non_linear_features[non_linear_labels == 1][:, 1], c='blue', label='类别 1', s=55, alpha=0.65)  # 编码完整样本中的第一类支持域
rbf_fixed_axes[0].scatter(non_linear_features[non_linear_labels == 2][:, 0], non_linear_features[non_linear_labels == 2][:, 1], c='purple', label='类别 2', s=55, alpha=0.65)  # 编码完整样本中的第二类支持域
rbf_fixed_axes[0].set(xlabel='X₁', ylabel='X₂', title='完整受控样本')  # 标明左侧样本面板
rbf_fixed_axes[0].legend(fontsize=9)  # 解释完整样本类别
rbf_fixed_axes[0].grid(True, alpha=0.3)  # 辅助比较非线性类别在二维空间的局部重叠
visualize_svm_boundary(rbf_features_train, rbf_labels_train, rbf_svm_classifier, rbf_fixed_axes[1], '固定候选的训练边界')  # 对照固定RBF候选的实际软间隔边界
rbf_fixed_figure.tight_layout()  # 保持完整样本与训练边界两个面板可同时阅读
plt.show()  # 输出完整样本与固定RBF边界供并列解读
/tmp/ipykernel_1450143/48319671.py:8: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  rbf_fixed_figure.tight_layout()  # 保持完整样本与训练边界两个面板可同时阅读
/tmp/ipykernel_1450143/48319671.py:8: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  rbf_fixed_figure.tight_layout()  # 保持完整样本与训练边界两个面板可同时阅读
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8322 (\N{SUBSCRIPT TWO}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
/home/ubuntu/miniconda3/envs/peter/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 8321 (\N{SUBSCRIPT ONE}) missing from font(s) Source Han Serif SC.
  fig.canvas.print_figure(bytes_io, **kw)
左图展示完整二维受控样本的两类散点,右图展示训练半样本、RBF决策边界、函数间隔和运行时支持向量。
图 9.10: 受控二维样本与固定RBF核候选的训练边界

图 9.10 显示固定 RBF 候选确实形成弯曲边界;边界是否闭合、训练错分数、间隔违规数与支持向量数全部以图和 rbf_training_metrics 为准。该固定种子实现存在训练错分时,就必须明确称为软间隔拟合,不能写成“精确分隔”。核技巧允许通过核内积表达非线性边界,但不保证任意有限参数都能或应当完全拟合训练样本。

运用GridSearchCV对RBF核的超参数进行网格搜索,寻找最优的Cgamma组合:

cv_folds = 5  # 用五折训练内验证比较惩罚与核宽度
# 在事前候选网格内联合选择 C 与 gamma
svm_grid_search = GridSearchCV(rbf_svm_classifier,  # 用训练集内部搜索RBF核超参数
                    {'C': [0.1, 1, 10, 100, 1000],  # 预设违规惩罚候选网格
                     'gamma': [0.5, 1, 2, 3, 4]},  # 预设核宽度候选网格
                    refit=True,  # 用验证选中的组合在完整训练集重新拟合
                    cv=cv_folds,  # 使用预先固定的训练内部分折
                    scoring='accuracy')  # 以分类准确率选择候选组合
svm_grid_search.fit(rbf_features_train, rbf_labels_train)  # 严格在训练半样本内选择核参数

print(f'最佳参数 {svm_grid_search.best_params_}')  # 报告当前网格和评分规则选中的组合
print(f'最佳交叉验证准确率: {svm_grid_search.best_score_:.4f}')  # 报告选中组合的训练内平均分

optimal_rbf_svm = svm_grid_search.best_estimator_  # 取得仅由训练内验证选中的RBF管道
grid_predictions = optimal_rbf_svm.predict(rbf_features_test)  # 参数锁定后仅访问一次预留测试集
test_accuracy = np.mean(grid_predictions == rbf_labels_test)  # 汇总锁定测试集的分类正确比例
print(f'测试集准确率: {test_accuracy:.4f}')  # 报告锁定测试集的分类正确比例
最佳参数 {'C': 1, 'gamma': 0.5}
最佳交叉验证准确率: 0.9400
测试集准确率: 0.8700

最佳参数、交叉验证分数和锁定测试分数均以代码输出为准。二者的差距只是一次样本划分下的诊断,不能据此断言“没有过拟合”;可以改变随机种子或重复外层划分观察稳定性。该实验的教学目的,是展示 \(C\)\(\gamma\) 必须在训练数据内部共同调节,测试集不能参与选择。

9.7.3 补充案例:单股次日方向分类

本补充案例使用海康威视股票(002415.XSHE)说明 SVM 的基本实现,目标是下一交易日方向。它不替代本章末基于第 2 章共享样本的 20 日回撤综合任务。

下面使用海康威视的真实日度行情比较线性与核 SVM。案例只评价给定特征和时期内的下一交易日方向,不把单一证券结果推广为普遍市场规律。 特征包括滞后收益、20 日波动率和价格相对 20 日均线的偏离。数据按时间顺序切分,结论只适用于当前证券、时期和特征定义。 第 4 步的标准化必须只在训练折内拟合,因为 SVM 的距离与内积会受特征尺度影响。TimeSeriesSplit GridSearchCV 的输出是一份方向分类评价:它报告涨跌标签指标,不包含仓位、交易成本、收益或基准,因而不能称为盈利预测报告。

import pandas as pd  # 按交易日构造单股收益滞后特征与前向标签
from sklearn.svm import SVC  # 比较训练折内调参后的线性与核分类边界
from sklearn.model_selection import TimeSeriesSplit, GridSearchCV  # 以扩张窗口选择RBF核超参数
from sklearn.preprocessing import StandardScaler  # 在各训练折内估计距离尺度
from sklearn.metrics import accuracy_score, classification_report  # 汇报封存时期方向分类误差及类别召回率
import os  # 拼接文件路径并为后续独立代码块登记统一数据根
from pathlib import Path  # 用路径对象验证本地文件

BOOK_DATA_DIR = Path('/home/ubuntu/r2_data_mount/data').resolve()  # 明文定义在线教材的BOOK_DATA_DIR绝对路径
DATA_DIR = BOOK_DATA_DIR  # 保留本章后续代码使用的数据根名称
os.environ['BOOK_DATA_DIR'] = str(BOOK_DATA_DIR)  # 为本章后续财务案例与共享样本入口登记同一路径
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}'  # 在拼接行情路径前核对数据根
# 拼接后复权股价数据文件的完整路径
stock_price_path = Path(DATA_DIR) / 'stock/stock_price_post_adjusted.h5'  # 构建后复权行情路径
assert stock_price_path.is_file(), f'缺少后复权行情文件: {stock_price_path}'  # 在read_hdf前验证文件
# 在 HDF 存储层直接筛选海康威视,避免为单股示例载入全市场行情
haikang_data = pd.read_hdf(  # 选择性读取海康威视的真实后复权历史
    stock_price_path,  # 指定后复权股价文件
    where="order_book_id='002415.XSHE'",  # 只保留唯一教学标的
    columns=['date', 'order_book_id', 'close'],  # 只读取特征与标签所需字段
).reset_index()  # 恢复 HDF 的日期与证券索引列,并重建唯一行索引
# 按日期升序排列,保证时间序列顺序正确
haikang_data = haikang_data.sort_values('date')  # 按指定列排序

基于海康威视的日频股价数据,构造用于SVM预测的技术指标特征和目标变量:

# 计算日收益率(收盘价的百分比变化)
haikang_data['Ret'] = haikang_data['close'].pct_change()  # 计算百分比变化(收益率)
# 构造滞后收益率作为动量特征(分别滞后1/2/3/5个交易日)
for lag_period in [1, 2, 3, 5]:  # 依次构造四个预设收益滞后期
    haikang_data[f'Lag_{lag_period}'] = haikang_data['Ret'].shift(lag_period)  # 仅使用预测日前已实现的滞后收益
# 计算过去20个交易日收益率的滚动标准差作为波动率特征(滞后1天避免前瞻偏差)
haikang_data['Vol_20'] = haikang_data['Ret'].rolling(20).std().shift(1)  # 计算标准差
# 计算20日均线价格(滞后1天)
haikang_data['MA_20'] = haikang_data['close'].rolling(20).mean().shift(1)  # 用预测日前20日均价描述价格水平
# 计算收盘价相对于20日均线的偏离度百分比
haikang_data['Dist_MA20'] = haikang_data['close'].shift(1) / haikang_data['MA_20'] - 1  # 用前一日收盘与已知均线编码距离
# 构造目标变量:当日涨跌方向(1=上涨,-1=下跌)
haikang_data['Target'] = (haikang_data['Ret'] > 0).astype(int)  # 将当日正收益编码为1
# 将收益率为零或负值的标记为-1(下跌类别)
haikang_data.loc[haikang_data['Ret'] <= 0, 'Target'] = -1  # 将非正收益统一编码为负类
# 删除因滞后和滚动窗口产生的缺失值行
haikang_features = haikang_data.dropna()  # 保留标签与所有滞后特征齐全的日期
# 若数据量超过2000条,仅保留最近2000条以提高计算效率
if len(haikang_features) > 2000:  # 限制单股实验的近期样本窗口与计算量
    haikang_features = haikang_features.iloc[-2000:]  # 保留时间上最近的2000个完整观测
# 提取预测变量矩阵:滞后收益率 + 波动率 + 均线偏离度
# 只纳入预测时已知的滞后动量、波动率和均线距离
stock_predictors = haikang_features[[c for c in haikang_features.columns if c.startswith('Lag_') or c in ['Vol_20', 'Dist_MA20']]]  # 保留预先声明的特征族
# 提取目标变量向量
stock_targets = haikang_features['Target']  # 取得与特征日期严格对齐的涨跌标签

完成特征构造后,按时间序列顺序分割训练集和测试集,进行标准化处理,并使用网格搜索调优RBF核SVM的超参数:

表 9.1: SVM预测海康威视股价涨跌
# 按时间序列顺序分割:前80%作为训练集,后20%作为测试集
split_idx = int(len(haikang_features) * 0.8)  # 将时间上最后20%样本封存为测试期
haikang_train_x, haikang_test_x = stock_predictors.iloc[:split_idx], stock_predictors.iloc[split_idx:]  # 按时间边界分开特征
haikang_train_y, haikang_test_y = stock_targets.iloc[:split_idx], stock_targets.iloc[split_idx:]  # 使标签沿用相同的前向切分
assert len(haikang_features) >= 100 and len(haikang_train_y) >= 60 and len(haikang_test_y) >= 20  # 小样本保护
assert haikang_features['date'].is_monotonic_increasing and haikang_features['date'].is_unique  # 日期严格有序唯一
assert haikang_train_y.nunique() == haikang_test_y.nunique() == 2  # 训练测试均须含两类
from sklearn.pipeline import Pipeline  # 用管道保证每个训练折独立拟合标准化参数
# 创建时间序列交叉验证分割器(3折)
time_series_cv = TimeSeriesSplit(n_splits=3)  # 在训练期内设置三个扩张窗口分折
for fold_number, (fold_train, fold_validation) in enumerate(time_series_cv.split(haikang_train_x), start=1):  # 调参前核对每折
    assert haikang_train_y.iloc[fold_train].nunique() == haikang_train_y.iloc[fold_validation].nunique() == 2, f'第{fold_number}折必须含两类'  # 拟合与评分保护
# 事前固定惩罚与核宽度候选,避免依据封存期结果改写网格
svm_param_grid = {'svc__C': [0.1, 1, 10, 100], 'svc__gamma': [0.001, 0.01, 0.1, 1]}  # 仅由训练期前向折选择组合
# 把标准化与核模型放入同一管道,避免验证折之间泄漏
svm_pipeline = Pipeline([('scale', StandardScaler()), ('svc', SVC(kernel='rbf'))])  # 把缩放限制在每个训练折
svm_stock_grid = GridSearchCV(svm_pipeline, svm_param_grid, cv=time_series_cv, scoring='balanced_accuracy', n_jobs=1)  # 用平衡准确率调参
svm_stock_grid.fit(haikang_train_x, haikang_train_y)  # 在原始训练数据上执行折内管道
print(f'最佳参数 {svm_stock_grid.best_params_}')  # 报告训练期前向验证选出的组合
print(f'训练集CV准确率 {svm_stock_grid.best_score_:.4f}')  # 报告训练期各前向折的最佳平均分
optimal_stock_svm = svm_stock_grid.best_estimator_  # 取得已在完整训练期重拟合的最佳管道
stock_predictions = optimal_stock_svm.predict(haikang_test_x)  # 锁定后只访问一次末段测试集
print('\n测试集分类报告')  # 分隔调参证据与封存期评价
print(classification_report(haikang_test_y, stock_predictions))  # 分解封存期两类的精确率、召回率与F1
print(f'测试集准确率: {accuracy_score(haikang_test_y, stock_predictions):.4f}')  # 报告封存测试期的分类正确比例
最佳参数 {'svc__C': 10, 'svc__gamma': 0.1}
训练集CV准确率 0.5257

测试集分类报告
              precision    recall  f1-score   support

          -1       0.50      0.82      0.62       201
           1       0.47      0.17      0.25       199

    accuracy                           0.49       400
   macro avg       0.48      0.49      0.43       400
weighted avg       0.48      0.49      0.43       400

测试集准确率: 0.4925
training_majority = int(haikang_train_y.value_counts().idxmax())  # 只从训练期确定多数类
majority_predictions = np.repeat(training_majority, len(haikang_test_y))  # 训练多数类基线
print('训练多数类:', training_majority, '基线测试准确率:', accuracy_score(haikang_test_y, majority_predictions))  # 将封存期模型成绩与训练多数类基线并列
print('训练/测试类别数:', haikang_train_y.value_counts().to_dict(), haikang_test_y.value_counts().to_dict())  # 类别核对
训练多数类: -1 基线测试准确率: 0.5025
训练/测试类别数: {-1: 814, 1: 786} {-1: 201, 1: 199}

分类报告必须与“始终预测训练期多数类”的基线比较。若锁定测试集上的平衡准确率没有改善,只能说明这些特征与这一时间窗下没有观察到稳定的增量分类能力;单一股票、单一时期和单一特征集既不能证明也不能否定有效市场假说。所有具体分数以当前执行输出为准,不在正文预写可能过期的数值。

补充说明:召回率只从当次输出解释

分类报告中的召回率必须与当次训练多数类基线、测试类别数和事前登记阈值一起读取。正文不预设正类召回偏高或偏低,也不把单次阈值结果外推为交易能力;最终项目以概率 Brier 与排序 AUC 为主,离散召回仅作阈值诊断。

9.7.4 案例研究:下一连续报告期亏损代理

我们使用当前报告期财务比率预测同一公司的下一连续报告期是否亏损Distress=1 只是亏损代理,不是 ST 生效事件;本地数据没有 st_effective_date,因此全章不得把该标签称为 ST 预测。

我们首先拟合LDA和支持向量分类器。图 9.11 显示了测试集预测的ROC曲线。

代码比较 LDA、线性 SVC 和两个 RBF SVC。资源控制采用与标签无关的分期等概率下采样,不制造“500+500”的虚假平衡样本;类别不平衡由训练期权重处理。

import numpy as np  # 构造季度财务比率并处理有限值条件
import pandas as pd  # 整理公司季度财务比率、连续期标签与时间切分
from sklearn.svm import SVC  # 拟合线性与RBF核财务困境分类器
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis  # 提供共享协方差的线性分类基准
from sklearn.model_selection import train_test_split  # 固定财务样本的训练与封存测试划分
from sklearn.metrics import roc_curve, auc  # 比较封存期排序曲线与曲线下面积
from sklearn.preprocessing import StandardScaler  # 仅以训练期尺度变换连续财务特征
import matplotlib.pyplot as plt  # 比较四个候选在同一封存期的风险排序曲线
import os  # 读取本地财务报表数据根配置

book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 安全读取环境变量而不触发KeyError
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根'  # 提供可执行修复方向
DATA_DIR = os.path.abspath(os.path.expanduser(book_data_dir_value))  # 解析用户配置的数据根目录
assert os.path.isdir(DATA_DIR), f'BOOK_DATA_DIR 不存在: {DATA_DIR}'  # 在拼接财务路径前核对数据根
# 拼接财务报表数据文件的完整路径
financial_path = os.path.join(DATA_DIR, 'stock/financial_statement.h5')  # 构建数据文件的完整路径
assert os.path.isfile(financial_path), f'缺少财务报表文件: {financial_path}'  # 在HDF读取前给出具体缺失路径
# 读取明确年份与字段的真实面板,在保留下一季度标签的同时控制出版内存峰值
financial_statement_history = pd.read_hdf(  # 选择性读取连续六年真实财务面板
    financial_path,  # 指定财务报表 HDF 文件
    where="quarter>='2018q1' & quarter<='2023q4'",  # 固定前向训练与测试支持域
    columns=['quarter', 'order_book_id', 'current_assets', 'current_liabilities', 'total_assets', 'total_liabilities', 'operating_revenue', 'net_profit'],  # 只读取比率和标签所需字段
).reset_index(drop=True)  # 重建唯一索引,禁止重复 HDF 索引放大标签选择

基于原始财务报表数据,构造四个核心财务比率指标作为预测变量,并进行数据清洗与平衡采样:

# 先排除无效分母,避免用任意常数改变财务比率的经济含义
valid_denominator_mask = ((financial_statement_history['current_liabilities'] != 0) &  # 流动比率要求非零流动负债
                          (financial_statement_history['total_assets'] > 0))  # 资产比率要求正的总资产
financial_statement_history = financial_statement_history.loc[valid_denominator_mask].copy()  # 先锁定比率可定义的公司季度
financial_statement_history['Current_Ratio'] = financial_statement_history['current_assets'] / financial_statement_history['current_liabilities']  # 构造当期流动比率
financial_statement_history['Debt_Ratio'] = financial_statement_history['total_liabilities'] / financial_statement_history['total_assets']  # 构造当期负债率
financial_statement_history['Turnover'] = financial_statement_history['operating_revenue'] / financial_statement_history['total_assets']  # 构造当期资产周转率
financial_statement_history['period'] = pd.PeriodIndex(financial_statement_history['quarter'], freq='Q')  # 把季度字符串转为可比较期间
financial_statement_history = financial_statement_history.sort_values(['order_book_id', 'period']).copy()  # 在公司内按季度排序后生成前向标签
financial_statement_history['Future_Net_Profit'] = financial_statement_history.groupby('order_book_id')['net_profit'].shift(-1)  # 取得同公司下一条记录的利润
financial_statement_history['Future_Period'] = financial_statement_history.groupby('order_book_id')['period'].shift(-1)  # 保存下一条记录的季度以核验连续性
financial_statement_history['period_ordinal'] = financial_statement_history['period'].astype('int64')  # 以紧凑整数表示当前季度
financial_statement_history['future_period_ordinal'] = financial_statement_history.groupby('order_book_id')['period_ordinal'].shift(-1)  # 构造下一记录季度序号
period_gap = financial_statement_history['future_period_ordinal'] - financial_statement_history['period_ordinal']  # 直接计算季度差
financial_statement_history = financial_statement_history.loc[period_gap.eq(1)].copy()  # 仅保留公司内严格连续的下一季度
financial_statement_history['Distress'] = financial_statement_history['Future_Net_Profit'].lt(0).astype(int)  # 将下一连续季度亏损定义为代理标签
financial_statement_history['Log_Assets'] = np.log(financial_statement_history['total_assets'].clip(lower=1))  # 使用预测期可得规模变量
# 仅选取分析所需的列,避免全表dropna导致大量有效样本被误删
analysis_columns = ['order_book_id', 'quarter', 'period', 'Future_Period', 'Current_Ratio', 'Debt_Ratio', 'Turnover', 'Log_Assets', 'Future_Net_Profit', 'Distress']
# 只对分析字段实施完整案例筛选
valid_financials = financial_statement_history[analysis_columns].dropna().replace([np.inf, -np.inf], np.nan).dropna()  # 同时排除无穷比率
# 过滤异常值,仅保留合理范围内的财务指标
valid_financials = valid_financials[  # 施加事先写明的财务比率支持域
    (valid_financials['Current_Ratio'].between(0, 10)) &  # 保留非负且不超过10的流动比率
    (valid_financials['Debt_Ratio'].between(0, 2))  # 筛选合理杠杆范围
]  # 固定财务特征的样本准入边界
# 为核 SVM 的教学运行控制资源:每个报告期做与标签无关的等概率下采样,不复制少数类
sampling_rng = np.random.default_rng(42)  # 固定抽样种子以复现教学样本
valid_financials = valid_financials.assign(sampling_key=sampling_rng.random(len(valid_financials)))  # 生成与标签无关的等概率排序键
balanced_financials = (valid_financials.sort_values(['quarter', 'sampling_key'])  # 先按季度和随机键排序
                       .groupby('quarter', group_keys=False).head(300)  # 每季度最多保留300家公司
                       .sort_values(['quarter', 'order_book_id'])  # 恢复确定的季度—公司次序
                       .drop(columns='sampling_key'))  # 删除仅供抽样使用的辅助键
print(f'教学样本量: {len(balanced_financials)}; 报告期数: {balanced_financials.quarter.nunique()}')  # 报告资源控制后的规模
# 提取四个财务比率作为预测变量矩阵
# 按事前字段顺序构造模型特征
distress_predictors = balanced_financials[['Current_Ratio', 'Debt_Ratio', 'Turnover', 'Log_Assets']]  # 保持训练与测试列顺序一致
# 取得下一连续季度亏损代理标签
distress_targets = balanced_financials['Distress']  # 保持标签索引与公司季度特征对齐
教学样本量: 6900; 报告期数: 23

基于清洗后的财务指标数据,进行数据分割、标准化处理,并拟合多个分类模型进行对比:

period_boundary = np.sort(balanced_financials['period'].unique())[int(balanced_financials['period'].nunique() * 0.7)]  # 以排序后70%位置锁定时间边界
is_training_period = balanced_financials['Future_Period'] < period_boundary  # 训练标签也早于测试起点
is_test_period = balanced_financials['period'] >= period_boundary  # 将边界及之后季度封存为测试期
distress_train_x, distress_test_x = distress_predictors[is_training_period], distress_predictors[is_test_period]  # 按同一时间掩码切分特征
distress_train_y, distress_test_y = distress_targets[is_training_period].astype(int), distress_targets[is_test_period].astype(int)  # 按同一时间掩码切分标签

def make_scaled_classifier(classifier):  # 统一把标准化限制在训练数据内
    return Pipeline([('scale', StandardScaler()), ('model', classifier)])  # 返回无跨集合拟合的管道

distress_lda_model = make_scaled_classifier(LinearDiscriminantAnalysis())  # 线性判别基准
distress_linear_svc = make_scaled_classifier(SVC(kernel='linear', C=1, class_weight='balanced', random_state=42))  # 线性核;ROC 不需要概率校准
distress_rbf_svc_gamma_01 = make_scaled_classifier(SVC(kernel='rbf', gamma=0.1, C=1, class_weight='balanced', random_state=42))  # 平滑RBF核
distress_rbf_svc_gamma_1 = make_scaled_classifier(SVC(kernel='rbf', gamma=1, C=1, class_weight='balanced', random_state=42))  # 灵活RBF核
for fitted_model in [distress_lda_model, distress_linear_svc, distress_rbf_svc_gamma_01, distress_rbf_svc_gamma_1]:  # 仅用训练期拟合
    fitted_model.fit(distress_train_x, distress_train_y)  # 禁止测试公司—期间参与缩放或训练

至此,我们已经在标准化后的训练集上拟合了四个分类模型:LDA 作为线性基准方法,线性核 SVM 作为最大间隔线性分类器,以及两个不同 \(\gamma\) 参数的 RBF 核 SVM 用于捕捉可能存在的非线性决策边界。接下来,使用训练好的四个模型在测试集上计算ROC曲线,比较各方法的分类表现:

# 将四个训练好的模型放入字典,便于批量计算ROC曲线
classification_models = {  # 固定登记四个同口径测试候选模型
    'LDA': distress_lda_model,  # 线性判别基准
    'SVC (Linear)': distress_linear_svc,  # 线性最大间隔分类器
    'SVM (RBF, γ=0.1)': distress_rbf_svc_gamma_01,  # 较平滑的RBF分类器
    'SVM (RBF, γ=1)': distress_rbf_svc_gamma_1  # 按固定顺序登记最后一个候选模型
}  # 完成用于同口径比较的候选模型注册

plt.figure(figsize=(12, 6))  # 为四条测试期ROC曲线保留共同坐标空间

for name, model in classification_models.items():  # 对每个已拟合模型计算同一测试期ROC
    test_decision_scores = model.decision_function(distress_test_x)  # ROC 只需要保持风险排序的决策分数
    false_positive_rate, true_positive_rate, _ = roc_curve(distress_test_y, test_decision_scores)  # 计算ROC曲线的FPR和TPR
    current_roc_auc = auc(false_positive_rate, true_positive_rate)  # 计算AUC面积
    plt.plot(false_positive_rate, true_positive_rate, linewidth=2, label=f'{name} (AUC = {current_roc_auc:.2f})')  # 用曲线位置与AUC编码该候选的排序表现

plt.plot([0, 1], [0, 1], 'k--', linewidth=1, label='随机猜测')  # 提供无排序信息分类器的参照线
plt.xlabel('假正率 (1 - Specificity)', fontsize=12)  # 标明阈值扫描中的误报比例
plt.ylabel('真正率 (Sensitivity)', fontsize=12)  # 标明同一阈值下识别亏损代理的比例
plt.title('下一连续报告期亏损代理:测试集 ROC', fontsize=14)  # 标题限定标签与评价集合
plt.legend(loc='lower right', fontsize=10)  # 对应四个候选及其现场AUC
plt.grid(True, alpha=0.3)  # 辅助比较相同误报率下的真正率
plt.tight_layout()  # 保持轴标签与候选名称在出版页完整可读
plt.show()  # 渲染测试期排序表现比较
四条ROC曲线比较LDA、线性SVC和两个RBF SVC在锁定未来测试期的排序能力。
图 9.11: 下一连续报告期亏损代理任务上不同方法的ROC曲线。

图 9.11 的图例给出当前测试期 AUC。曲线较高只表示这一亏损代理任务中的排序较好;模型差异、核宽度影响和线性基准是否占优都必须由现场曲线判断,不能外推为 ST 风险结论。

补充说明:ROC 曲线与 AUC 应该怎样直观理解

在二元风险代理任务里,模型先输出排序分数,再按决策成本选择阈值。ROC 曲线扫描所有可能阈值,并观察两件事是否能同时兼顾:

  • 真正率 TPR:真正有风险的公司里,你抓住了多少;
  • 假正率 FPR:本来健康的公司里,你误伤了多少。

如果一条 ROC 曲线整体更靠左上方,说明模型在维持较低误伤率的同时,还能抓住更多风险公司;而 AUC 就是把这条曲线压缩成一个总分。AUC 越高,说明模型的整体排序能力越强。它可以近似理解为:随机抽一家困境公司和一家健康公司,模型把困境公司排在更高风险位置的概率有多大。

之所以这里特别适合看 ROC/AUC,而不是只看准确率,是因为风控阈值往往会随监管要求、人工审核成本和坏账承受能力而变化。今天也许希望“少误伤”,明天也许更强调“宁可多报也不能漏报”。ROC/AUC 的好处就在于:它先评价模型的总体区分能力,把具体阈值的选择留给业务层再决定。

9.8 小结

本章介绍了支持向量机这一正则化分类方法。以下是关键要点:

最大间隔分类器:

  • 在训练样本线性可分且两类都存在时,寻找使最小几何间隔最大的分离超平面
  • 硬间隔要求每个训练观测满足 \(y_i(w^Tx_i+b)\geq 1\),不允许训练违规;尺度约束固定后等价于最小化 \(\lVert w\rVert^2/2\)
  • 只有落在单位函数间隔边界上的支持向量直接决定解;远离边界的观测不改变当前最优超平面
  • 对异常点和近乎不可分的数据敏感,而且数据不可分时约束集为空,因此实际任务通常改用允许松弛变量的支持向量分类器

支持向量分类器

  • 通过允许一些违规软间隔来扩展最大间隔分类器
  • 引入调优参数\(C\)来权衡系数范数与训练松弛损失
  • 决策函数只显式依赖于对偶系数非零的支持向量
  • \(C\) 与支持向量数、测试误差或泛化质量之间没有单调保证

支持向量机

  • 通过核函数扩展到非线性决策边界
  • 常用核:线性核、多项式核、径向核(RBF)
  • 核函数避免显式构造特征映射,但精确核训练仍受 \(n\times n\) Gram 矩阵和求解成本制约

与逻辑回归的比较

  • SVM的合页损失与逻辑回归的对数损失都是基于间隔的凸损失,但具体形状不同
  • 两者的相对表现依赖任务、正则化、校准与评价指标,没有按类别重叠程度决定优劣的通则

实际应用:

  • 线性 SVM 可作为高维数据的候选,但表现须经样本外评价
  • 精确核 SVM 通常更适合 Gram 矩阵成本可承受的数据规模
  • 对特征的缩放敏感(需要标准化)

何时使用:

  • 二元或多元分类问题
  • 特征数多于观测数时,与其他正则化线性方法共同比较
  • 存在非线性决策边界假设且核成本可承受时
  • 能在训练内选择 \(C\)、核参数与近似规模,并进行独立评价时

9.9 理论来源与前沿

SVM 的理论基础是最大间隔原理与凸优化 (Cortes 和 Vapnik 1995年):硬间隔在可分训练集上选择最大几何间隔解,软间隔与 hinge loss 则允许训练违规。间隔界为泛化分析提供理论工具,但并不保证某个数据集上必然优于其他分类器。核技巧通过对偶问题把合法正定核解释为 RKHS 中的内积 (Aronszajn 1950年)

SVM 在以下场景仍是值得验证的候选:

  1. 小样本、高维:线性 SVM 的正则化几何可能适合文本或部分生物信息任务,但需与线性基线比较。
  2. 边界扰动假设:间隔可提供局部几何解释;它不是对异常值或分布漂移的无条件鲁棒保证。
  3. 可控核近似:随机特征与 Nyström 近似可降低某些核任务的资源需求,但适用核类别、近似秩和误差均须报告 (Williams 和 Seeger 2001年; Rahimi 和 Recht 2007年)

核心学习证据来自练习 1—6:学生应能在同一未来期比较线性与核模型,并说明尺度、校准、计算成本和证据边界。练习 7—10 均为拓展,其中练习 9 的 primal/dual/KKT 推导服务于希望继续理解核技巧来源的读者。

9.10 综合案例:监督模型的最终比较

本案例直接消费第 2 章的数据身份,不重新筛选公司、日期或重算目标。为保证从干净内核运行,下面在本章内定义并调用与 小节 2.7 相同的 BOOK_DATA_DIR 构造器;真实峰谷最大回撤标签、日期边界和四项返回对象均保持一致,不读取外部辅助脚本。

先定义前向最大回撤函数。对路径 \(P_0,\ldots,P_{20}\),先计算运行峰值 \(H_j=\max_{0\le k\le j}P_k\),再取 \(\min_j(P_j/H_j-1)\)。因此价格路径 \(100\to120\to105\) 的最大回撤是 \(105/120-1=-12.5\%\),即使终点仍高于预测日价格,也必须记为事件。

展开共享回撤标签函数
import os  # 读取跨平台数据根环境变量
from pathlib import Path  # 用路径对象安全拼接本地文件
import numpy as np  # 构造有限特征和时间分段
import pandas as pd  # 处理公司—日期面板

def calculate_forward_max_drawdown(price_values, horizon_days=20):  # 计算含预测日在内的前向峰谷最大回撤
    price_array = np.asarray(price_values, dtype=float)  # 转为连续数值数组以构造滑动路径
    window_size = horizon_days + 1  # 路径包含t日与其后horizon_days个交易日
    drawdowns = np.full(price_array.size, np.nan, dtype=float)  # 末端窗口不完整时保持未知
    if price_array.size < window_size:  # 保护短序列而不虚构标签
        return drawdowns  # 返回全缺失结果表示没有完整路径
    price_windows = np.lib.stride_tricks.sliding_window_view(price_array, window_size)  # 构造每个预测日的完整价格路径
    running_peaks = np.maximum.accumulate(price_windows, axis=1)  # 逐路径记录每一时点之前的最高价
    drawdowns[:price_windows.shape[0]] = np.min(price_windows / running_peaks - 1, axis=1)  # 取峰值后最深谷值跌幅
    return drawdowns  # 返回与原价格序列等长的前向最大回撤

m02_fixture_drawdown = calculate_forward_max_drawdown([100, 120, 105], horizon_days=2)[0]  # 用先涨后跌路径区分峰谷回撤与起点收益
assert np.isclose(m02_fixture_drawdown, -0.125)  # 核对峰值120到谷值105的跌幅为百分之十二点五
assert m02_fixture_drawdown <= -0.10  # 核对该路径必须被判为百分之十回撤事件

下面把数据读取、预测日特征和峰谷标签封装为一个准备函数。固定公司名单是教学研究边界,不是事后按收益挑出的“优胜者”;return_1d 等特征均以 \(t\) 日结束,只有标签计算向未来展开。

展开共享行情与标签准备函数
def prepare_m02_price_panel(book_data_dir):  # 从统一数据根构造未切分的公司—预测日样本
    data_root = Path(book_data_dir).expanduser().resolve()  # 同时接受环境变量字符串或Path对象
    assert data_root.is_dir(), f'BOOK_DATA_DIR 不存在: {data_root}'  # 在读取前验证数据挂载
    price_path = data_root / 'stock/stock_price_post_adjusted.h5'  # 指向后复权日行情
    assert price_path.is_file(), f'缺少后复权行情文件: {price_path}'  # 在read_hdf前给出明确缺失文件
    company_ids = ('600104.XSHG', '002415.XSHE', '600276.XSHG', '002230.XSHE')  # 固定四家长三角教学公司
    price_frames = [pd.read_hdf(price_path, where=f'order_book_id={company_id!r}', columns=['close', 'volume']).reset_index() for company_id in company_ids]  # 在存储层选择所需公司和字段
    price_panel = pd.concat(price_frames, ignore_index=True)  # 合并为公司—交易日面板
    price_panel['prediction_date'] = pd.to_datetime(price_panel['date'])  # 把交易日定义为收盘后预测原点
    price_panel = price_panel.loc[price_panel['prediction_date'].between('2012-01-01', '2024-12-31')].copy()  # 固定研究观察期
    price_panel = price_panel.sort_values(['order_book_id', 'prediction_date']).reset_index(drop=True)  # 保证窗口只在公司内按时间推进
    grouped_prices = price_panel.groupby('order_book_id', sort=False)  # 建立公司内窗口边界
    price_panel['return_1d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(fill_method=None))  # 计算截至预测日的一日收益
    price_panel['momentum_5d'] = grouped_prices['close'].transform(lambda prices: prices.pct_change(5, fill_method=None))  # 计算截至预测日的五日动量
    price_panel['volatility_20d'] = grouped_prices['return_1d'].transform(lambda returns: returns.rolling(20, min_periods=20).std())  # 计算过去二十日波动率
    volume_mean_20d = grouped_prices['volume'].transform(lambda volume: volume.rolling(20, min_periods=20).mean())  # 估计预测日前成交量常态
    price_panel['volume_ratio_20d'] = price_panel['volume'] / volume_mean_20d  # 表示当日成交量相对过去均值的活跃度
    feature_names = ['return_1d', 'momentum_5d', 'volatility_20d', 'volume_ratio_20d']  # 固定跨章特征名称与顺序
    price_panel['max_drawdown_20d'] = grouped_prices['close'].transform(lambda prices: calculate_forward_max_drawdown(prices, horizon_days=20))  # 计算t至t+20真实峰谷最大回撤
    price_panel['label_end_date'] = grouped_prices['prediction_date'].shift(-20)  # 记录未来第二十个公司交易日
    price_panel['drawdown_event_20d'] = price_panel['max_drawdown_20d'].le(-0.10).where(price_panel['max_drawdown_20d'].notna()).astype('Int64')  # 完整路径才生成二元事件
    price_panel = price_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=feature_names + ['max_drawdown_20d', 'drawdown_event_20d', 'label_end_date']).copy()  # 排除无效特征和未完成路径
    return price_panel, feature_names  # 把未切分样本交给公共构造函数

公共函数负责固定切分、边界断言和四项跨章交付。任何章节在干净内核中都可以用 build_m02_shared_sample(BOOK_DATA_DIR) 重建同一对象,不需要依赖前一章的内存状态。

展开共享样本切分函数
def build_m02_shared_sample(book_data_dir):  # 返回第2至第9章唯一的共享分析对象
    price_panel, feature_names = prepare_m02_price_panel(book_data_dir)  # 从统一数据根重建真实峰谷标签
    validation_start = pd.Timestamp('2020-01-01')  # 固定验证段起点
    test_start = pd.Timestamp('2022-01-01')  # 固定封存测试段起点
    study_end = pd.Timestamp('2024-12-31')  # 固定研究终点
    train_mask = (price_panel['prediction_date'] < validation_start) & (price_panel['label_end_date'] < validation_start)  # 清除伸入验证期的训练标签
    validation_mask = price_panel['prediction_date'].between(validation_start, test_start, inclusive='left') & (price_panel['label_end_date'] < test_start)  # 清除伸入测试期的验证标签
    test_mask = price_panel['prediction_date'].between(test_start, study_end, inclusive='both')  # 标记只供第九章最终评价的日期
    price_panel['split'] = np.select([train_mask, validation_mask, test_mask], ['train', 'validation', 'test'], default='unused')  # 赋予互斥时间段
    shared_columns = ['order_book_id', 'prediction_date', 'label_end_date', *feature_names, 'max_drawdown_20d', 'drawdown_event_20d', 'split']  # 复用第2章权威字段顺序
    shared_sample = price_panel.loc[price_panel['split'] != 'unused', shared_columns].copy()  # 排除边界隔离行并形成共享对象
    assert list(shared_sample.columns) == shared_columns  # 阻止同名对象发生字段或顺序漂移
    assert not shared_sample.duplicated(['order_book_id', 'prediction_date']).any()  # 验证公司—预测日键唯一
    assert np.isfinite(shared_sample['max_drawdown_20d']).all() and shared_sample['max_drawdown_20d'].le(0).all()  # 验证连续目标有限且非正
    assert shared_sample['drawdown_event_20d'].astype(bool).eq(shared_sample['max_drawdown_20d'].le(-0.10)).all()  # 验证二元标签由连续目标唯一派生
    assert set(shared_sample['drawdown_event_20d'].unique()) == {0, 1}  # 验证二元标签包含两类
    assert shared_sample.loc[shared_sample['split'] == 'train', 'label_end_date'].max() < validation_start  # 验证训练标签不触及验证期
    assert shared_sample.loc[shared_sample['split'] == 'validation', 'label_end_date'].max() < test_start  # 验证验证标签不触及测试期
    training_event_rate = float(shared_sample.loc[shared_sample['split'] == 'train', 'drawdown_event_20d'].mean())  # 只用训练标签估计概率基线
    test_keys = shared_sample.loc[shared_sample['split'] == 'test', ['order_book_id', 'prediction_date']].copy()  # 固定第九章最终比较键
    return shared_sample, feature_names, training_event_rate, test_keys  # 返回约定的四项跨章交付
book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 安全读取统一数据根配置
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根'  # 缺失时说明修复方法
BOOK_DATA_DIR = Path(book_data_dir_value).expanduser().resolve()  # 解析统一数据根
m02_shared_sample, m02_features, m02_training_event_rate, m02_test_keys = build_m02_shared_sample(BOOK_DATA_DIR)  # 在当前干净内核构造共享对象
m09_expected_columns = ['order_book_id', 'prediction_date', 'label_end_date', *m02_features, 'max_drawdown_20d', 'drawdown_event_20d', 'split']  # 声明第2章连续与二元目标的有序交接模式
assert list(m02_shared_sample.columns) == m09_expected_columns  # 在最终测试前阻止字段、目标或顺序漂移

第 9 章是首次打开共同测试段的章节。在线性 SVM、RBF 核 SVM 与训练事件率基线之间比较时,\(C\)\(\gamma\) 和概率校准先在开发期内锁定,随后才对 m02_test_keys 对应的测试行评价一次。

学生提交一张共同测试样本上的 AUC、Brier 分数与校准摘要,并解释核模型的性能增量是否足以抵偿计算成本和解释难度。

9.11 练习

9.11.1 概念题

  1. 解释“最大间隔(maximum margin)”与泛化能力之间的直觉联系。为什么更大的几何间隔通常意味着更稳健的分类边界? [核心|难度:1|分值:5|任务:独立]

  2. 对软间隔 SVM,超参数 \(C\) 起什么作用?当\(C\to\infty\)\(C\to 0\)时,模型分别倾向于什么行为? [核心|难度:1|分值:5|任务:独立]

  3. 解释支持向量(support vectors)的含义。为什么只有支持向量会影响决策边界? [核心|难度:2|分值:5|任务:独立]

  4. 为什么SVM 对特征尺度敏感?在核 SVM 中,尺度问题会如何影响核函数(例如RBF 核)的有效作用范围? [核心|难度:1|分值:5|任务:独立]

  5. 核技巧(kernel trick)解决了什么计算问题?它为什么不需要显式构造高维特征? [核心|难度:2|分值:5|任务:独立]

9.11.2 应用题

  1. 直接使用 m02_shared_samplem02_features,在统一未来留出期比较线性 SVM、RBF 核 SVM 和训练事件率基线;\(C\)\(\gamma\) 与概率校准均只在训练期内部确定。报告 drawdown_event_20d 的 AUC、Brier 分数和校准结果,并说明核模型的增量性能是否抵偿计算成本与解释难度。 [核心|难度:3|分值:20|任务:综合案例]

    • 线性SVM
    • RBF 核SVM

要求:用时间切分评估,报告AUC 与校准指标,并说明你如何选择/验证 \(C\)\(\gamma\)

  1. 在同一任务上,比较“线性SVM + 手工非线性特征(例如二次项交互项)”与“RBF 核SVM”。讨论: [拓展|难度:3|分值:15|任务:独立]

    • 训练成本
    • 可解释性
    • 泛化误差
  2. 在样本较少但特征很多(\(p\gg n\))的设置下,说明你会如何做特征筛选、正则化、核近似,以避免训练时间与过拟合问题。 [拓展|难度:3|分值:10|任务:独立]

9.11.3 理论题

  1. 写出线性可分情形的硬间隔SVM 原始问题(primal),并给出其对偶问题(dual)的形式,解释拉格朗日乘子与支持向量的关系。 [拓展|难度:3|分值:15|任务:独立]

  2. 说明 hinge loss \(\ell(y,f)=\max(0,1-yf)\) 为什么是 0-1 损失的一个上界,并据此解释SVM 的经验风险最小化视角。 [拓展|难度:2|分值:10|任务:独立]

展开完整参考解答与评分键

9.12 练习参考解答

统一评分与验收:每题分值见元数据;拓展理论题按“问题/约束 30%—对偶或不等式 50%—KKT/边界 20%”给分。代码题要求概率在 \([0,1]\),AUC/Brier 有限,复算容差 \(10^{-8}\)。日期乱序、任一拟合阶段单类、20 日窗口跨界、内部概率替代独立校准或测试集选参均不满足题目要求。

9.12.1 概念题参考解答

  1. 间隔与稳健性:在固定特征尺度和范数下,间隔大意味着训练样本到分割超平面的最小距离更大,因此足够小的输入扰动不易使这些训练点跨过边界。这是局部几何直觉,不是测试误差更低或对分布漂移更稳健的无条件保证。

  2. \(C\)的作用\(C\)控制对违反间隔(松弛变量)的惩罚强度。\(C\to\infty\)时,目标函数相对更强调减少训练松弛;若数据可分,解可趋近硬间隔解。\(C\to 0\)时,范数项相对占主导。支持向量数、验证误差和测试误差对 \(C\) 都没有一般的单调关系,二者均须在训练内部选择并独立评价。

  3. 支持向量:以非负对偶乘子定义 \(S=\{i:\alpha_i>0\}\)。互补松弛给出 \(y_i(\beta^\top x_i+\beta_0)=1-\xi_i\),而 \(\beta=\sum_i \alpha_i y_i x_i\);所以 \(\alpha_i=0\) 的点不显式进入决策函数。实际软件使用数值容差识别支持向量,退化解中乘子的分配也可能不唯一。

  4. 尺度敏感:内积与距离会被量纲主导。对 RBF 核\(k(x,x')=\exp(-\gamma\|x-x'\|^2)\),若某个特征尺度很大,会让距离变大、核值快速衰减,模型相当于在该维度上“过度敏感”。因此需要标准化。

  5. 核技巧:对偶问题只需要样本间内积 \(\langle\phi(x_i),\phi(x_j)\rangle\),用正定核直接计算该内积即可,避免显式构造高维\(\phi(x)\)。预测仍为 \(f_K(x)=\beta_0+\sum_{i\in S}\alpha_i y_iK(x_i,x)\);精确方法并未避免 \(n\times n\) Gram 矩阵和求解成本。

9.12.2 应用题参考解答

  1. 线性与 RBF 核 SVM 的完整比较

先核对 m02_shared_sampleorder_book_idprediction_date 唯一键、label_end_datedrawdown_event_20dm02_features。在每个训练折内标准化特征,用前向交叉验证选择线性模型的 \(C\) 与 RBF 模型的 \((C,\gamma)\)。概率校准必须使用独立训练期的折外决策分数,不能在最终测试段拟合。所有选择完成后,对统一测试期评价一次。

答案应包含:共同样本与时间边界(4 分);训练内调参与校准(6 分);线性、RBF 与事件率基线的 AUC、Brier 和校准表(6 分);资源、解释性和结论边界(4 分)。若 RBF 只提高训练准确率而未改善未来期 Brier 或校准,应选择较简单的线性模型。若测试期只有一个类别,AUC 不可定义,应保留 Brier 与校准证据并说明限制。

核心实现按唯一预测日期依次划分拟合、调参、校准和测试阶段;在 Pipeline(StandardScaler, SVC) 中选择 \(C\)\(\gamma\),再用独立校准段的 decision_function() 分数拟合一元逻辑回归。这是受 Platt sigmoid calibration 启发的独立样本逻辑映射 (Platt 1999年):它估计 \(P(Y=1\mid f)=\sigma(a+bf)\),但这里直接使用近似无惩罚的逻辑回归,未复现原文的目标平滑和专用优化步骤,因此准确名称是“Platt-style sigmoid 校准”,而不是原始算法的逐项复刻。每个阶段的上一段均须满足 max(label_end_date) < min(next_phase.prediction_date),且不得重新计算第 2 章定义的标签。

下面把 2012—2019 年训练段再按日期分为拟合、调参与校准三部分:2012—2016 年拟合候选,2017—2018 年选择参数,2019 年只负责概率校准。此时既不读取验证段,也不读取测试行或测试标签。

from sklearn.linear_model import LogisticRegression  # 用一元逻辑映射校准SVM分数
from sklearn.metrics import brier_score_loss, roc_auc_score  # 评价排序与概率质量
from sklearn.pipeline import make_pipeline  # 把缩放与SVM绑定
from sklearn.preprocessing import StandardScaler  # 只在模型拟合样本上估计尺度
from sklearn.svm import SVC  # 取得线性和RBF核的决策分数

m09_train_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'train'].copy()  # 取得固定训练段
m09_tuning_start = pd.Timestamp('2017-01-01')  # 预先固定训练内调参起点
m09_calibration_start = pd.Timestamp('2019-01-01')  # 预先固定训练内校准起点
m09_fit_sample = m09_train_sample.loc[(m09_train_sample['prediction_date'] < m09_tuning_start) & (m09_train_sample['label_end_date'] < m09_tuning_start)].copy()  # 清除跨入调参期的拟合标签
m09_tuning_sample = m09_train_sample.loc[m09_train_sample['prediction_date'].between(m09_tuning_start, m09_calibration_start, inclusive='left') & (m09_train_sample['label_end_date'] < m09_calibration_start)].copy()  # 取得独立调参段
m09_calibration_sample = m09_train_sample.loc[m09_train_sample['prediction_date'] >= m09_calibration_start].copy()  # 保留独立概率校准行
assert m09_fit_sample['label_end_date'].max() < m09_tuning_sample['prediction_date'].min()  # 核对拟合与调参时序
assert m09_tuning_sample['label_end_date'].max() < m09_calibration_sample['prediction_date'].min()  # 核对调参与校准时序
m09_candidates = {'linear_c_0_1': ('linear', make_pipeline(StandardScaler(), SVC(kernel='linear', C=0.1))), 'linear_c_1': ('linear', make_pipeline(StandardScaler(), SVC(kernel='linear', C=1.0))), 'rbf_c_1_g_0_1': ('rbf', make_pipeline(StandardScaler(), SVC(kernel='rbf', C=1.0, gamma=0.1))), 'rbf_c_10_g_0_5': ('rbf', make_pipeline(StandardScaler(), SVC(kernel='rbf', C=10.0, gamma=0.5)))}  # 声明有限的C与gamma网格
m09_fitted_candidates, m09_tuning_records = {}, []  # 保存训练模型与调参证据
for m09_name, (m09_family, m09_candidate) in m09_candidates.items():  # 在共同样本上拟合各候选
    m09_candidate.fit(m09_fit_sample[m02_features], m09_fit_sample['drawdown_event_20d'].astype(int))  # 只用训练段内的拟合部分估计模型
    m09_tuning_auc = roc_auc_score(m09_tuning_sample['drawdown_event_20d'], m09_candidate.decision_function(m09_tuning_sample[m02_features]))  # 用预先分出的调参段比较排序
    m09_fitted_candidates[m09_name] = m09_candidate  # 保存已拟合候选供后续锁定
    m09_tuning_records.append({'model': m09_name, 'family': m09_family, 'tuning_auc': m09_tuning_auc})  # 记录各核族训练期校准段AUC以锁定候选模型

表 9.2 对每个核族只保留调参 AUROC 最高的一个候选,然后用完全独立的 2019 年训练内分数估计校准映射。此处锁定后不再根据验证或测试结果修改模型。

表 9.2: 第9章SVM参数选择与独立校准前的开发期证据
m09_tuning_table = pd.DataFrame(m09_tuning_records)  # 汇总预测试调参表现
m09_selected_names = m09_tuning_table.sort_values('tuning_auc').groupby('family', sort=False).tail(1)['model'].tolist()  # 每个核族锁定一个候选
m09_selected_models, m09_calibrators = {}, {}  # 保存锁定模型与概率映射
for m09_name in m09_selected_names:  # 仅对锁定候选进行独立校准
    m09_selected_models[m09_name] = m09_fitted_candidates[m09_name]  # 固定不再更新的SVM
    m09_calibration_score = m09_selected_models[m09_name].decision_function(m09_calibration_sample[m02_features]).reshape(-1, 1)  # 取得独立校准分数
    m09_calibrator = LogisticRegression(C=1e6, max_iter=2000)  # 拟合Platt-style的一元sigmoid概率映射
    m09_calibrator.fit(m09_calibration_score, m09_calibration_sample['drawdown_event_20d'].astype(int))  # 仅用校准段估计概率映射
    m09_calibrators[m09_name] = m09_calibrator  # 保存锁定校准器
print(m09_tuning_table.to_string(index=False))  # 输出所有候选的调参证据
print('锁定候选:', m09_selected_names)  # 明确测试前已经完成选择
         model family  tuning_auc
  linear_c_0_1 linear    0.719699
    linear_c_1 linear    0.719707
 rbf_c_1_g_0_1    rbf    0.663488
rbf_c_10_g_0_5    rbf    0.605197
锁定候选: ['rbf_c_1_g_0_1', 'linear_c_1']

最后才按 m02_test_keys 打开共同测试行,并在 表 9.3 输出事件率基线、线性 SVM 与 RBF SVM 的一次性 AUC、Brier 和校准水平摘要。

表 9.3: 第9章共同封存测试期的最终模型比较
m09_test_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'test'].copy()  # 首次取得封存测试行
m09_observed_test_rate = float(m09_test_sample['drawdown_event_20d'].mean())  # 只在最终评价时读取测试事件率
m09_test_key_check = m09_test_sample[['order_book_id', 'prediction_date']].sort_values(['order_book_id', 'prediction_date']).reset_index(drop=True)  # 规范测试键顺序
assert m09_test_key_check.equals(m02_test_keys.sort_values(['order_book_id', 'prediction_date']).reset_index(drop=True))  # 核对共同测试身份
m09_test_records = [{'model': 'training_event_rate', 'test_auc': 0.5, 'test_brier': brier_score_loss(m09_test_sample['drawdown_event_20d'], np.repeat(m02_training_event_rate, len(m09_test_sample))), 'mean_probability': m02_training_event_rate, 'observed_rate': m09_observed_test_rate}]  # 建立训练事件率基线
for m09_name in m09_selected_names:  # 对两个锁定模型各评价一次
    m09_test_score = m09_selected_models[m09_name].decision_function(m09_test_sample[m02_features]).reshape(-1, 1)  # 生成未见测试分数
    m09_test_probability = m09_calibrators[m09_name].predict_proba(m09_test_score)[:, 1]  # 应用锁定概率映射
    m09_test_records.append({'model': m09_name, 'test_auc': roc_auc_score(m09_test_sample['drawdown_event_20d'], m09_test_probability), 'test_brier': brier_score_loss(m09_test_sample['drawdown_event_20d'], m09_test_probability), 'mean_probability': float(m09_test_probability.mean()), 'observed_rate': m09_observed_test_rate})  # 汇总最终排序、概率与校准水平
m09_final_test_table = pd.DataFrame(m09_test_records)  # 形成一次性测试结果表
print(m09_final_test_table.to_string(index=False))  # 输出最终比较所需字段
              model  test_auc  test_brier  mean_probability  observed_rate
training_event_rate  0.500000    0.224679          0.289620       0.334632
      rbf_c_1_g_0_1  0.604050    0.225459          0.267439       0.334632
         linear_c_1  0.620329    0.225828          0.242145       0.334632

结果阅读顺序是先比较 test_brier,再看 test_auc,最后比较平均预测概率与实际事件率。若 RBF 只提高 AUROC 而 Brier 或校准水平恶化,不能宣称核模型整体更好;测试结果无论是否符合预期,都不得返回前两块修改候选集合。

  1. 手工非线性特征与 RBF 核的比较

线性方案只在训练折内用 PolynomialFeatures(degree=2, include_bias=False) 生成平方项与两两交互,再标准化并选择线性 SVM 的 \(C\);RBF 方案在同一原始 m02_features、同一折和同一计算预算上选择 \((C,\gamma)\)。两者使用相同的独立校准段和测试段。满分答案应报告:展开后的特征数与训练耗时 3 分;共同测试 Brier/AUC 5 分;手工项的可解释性与共线性 3 分;RBF 的核矩阵成本 2 分;没有稳定增量时选择较简单方法的条件式结论 2 分。不能根据训练边界更弯曲就宣称泛化更好。

  1. \(p\gg n\) 的计算与过拟合控制

先删除只由未来信息构成的变量,并在训练折内完成缺失处理、低方差过滤和标准化;然后把线性 SVM 或正则逻辑回归作为基线。若需要非线性,可在训练期比较随机傅里叶特征或 Nyström 低秩近似,近似维数也必须在内层选择。满分答案应包含:信息时点与筛选边界 2 分;线性正则基线 2 分;核近似维数和复杂度说明 2 分;嵌套/前向评价 2 分;稳定性与校准 1 分;不把被选特征解释为因果因素 1 分。若 \(n\) 不足以支持独立校准,应报告概率评价不可完成,而不是复用测试标签。

9.12.3 理论题参考解答

  1. 硬间隔原始问题与对偶问题:硬间隔 SVM 的原始问题为:

$$ _{,_0};||^2y_i(^x_i+_0),;i=1,,n.

$$

其对偶问题可写为

$$ {};{i=1}^ni-{i,j}_i_j y_i y_j x_i^x_j _i,;_i_i y_i=0.

$$

KKT 条件给出:若 \(\alpha_i>0\),则约束紧(点在间隔上),这些点就是支持向量;并且\(\beta=\sum_i\alpha_i y_i x_i\)。因此预测函数为 \(f(x)=\beta_0+\sum_i\alpha_i y_i x_i^\top x\),类别为 \(\operatorname{sign}\{f(x)\}\)

  1. hinge loss 上界:当 \(yf\le 0\)(错分)时,\(\max(0,1-yf)\ge 1\),而0-1 损失为1;当 \(0<yf<1\)(正确但间隔不足)时,0-1 损失为0,但 hinge loss 仍为正,起到鼓励更大间隔的作用;当\(yf\ge 1\) 时hinge loss 为0。故 hinge loss 对0-1 损失形成上界,同时更易优化。

9.13 章末回顾

逐项自检:能否解释间隔、\(C\)、核与尺度;能否用训练折选 SVM 并在独立验证期校准;能否在访问测试标签前确定候选和阈值;能否让全部候选共用测试样本。禁止测试后改候选或阈值。常见误区是把核技巧等同显式展开、把 AUC 当校准。

不看正文回答:支持向量为何决定边界?RBF 的 \(\gamma\) 控制什么?为何测试期只能在所有模型选择完成后使用?迁移任务:为文本投诉分类设计一次性未来期评价。下一章转向神经网络,同时保留样本外纪律。

展开检索答案 非支持向量的对偶系数为零;\(\gamma\) 控制相似性随距离衰减尺度;先锁定候选集合,才能避免按测试结果事后挑选模型。

学习完成后,应能说明折内缩放、训练期内部的 \(C/\gamma\) 选择与概率校准,以及为何最终测试评价必须晚于所有模型选择。间隔概念可回看 小节 9.4.1,核尺度可回看 小节 9.5.1;评价顺序不清楚时,可用文本投诉分类重新画出训练、验证和测试期。

Aronszajn, Nachman. 1950年. 《Theory of Reproducing Kernels》. Transactions of the American Mathematical Society 68 (3): 337~404. https://doi.org/10.1090/S0002-9947-1950-0051437-7.
Chang, Chih-Chung, 和 Chih-Jen Lin. 2011年. LIBSVM: A Library for Support Vector Machines》. ACM Transactions on Intelligent Systems and Technology 2 (3): 27:1~27. https://doi.org/10.1145/1961189.1961199.
Cortes, Corinna, 和 Vladimir Vapnik. 1995年. 《Support-Vector Networks》. Machine Learning 20: 273~97. https://doi.org/10.1007/BF00994018.
Mercer, James. 1909年. 《Functions of Positive and Negative Type, and Their Connection with the Theory of Integral Equations》. Philosophical Transactions of the Royal Society of London. Series A 209 (441–458): 415~46. https://doi.org/10.1098/rsta.1909.0016.
Platt, John C. 1999年. 《Probabilistic Outputs for Support Vector Machines and Comparisons to Regularized Likelihood Methods》. 收入 Advances in Large Margin Classifiers, 编辑 Alexander J. Smola, Peter L. Bartlett, Bernhard Schölkopf, 和 Dale Schuurmans. MIT Press.
Rahimi, Ali, 和 Benjamin Recht. 2007年. 《Random Features for Large-Scale Kernel Machines》. Advances in Neural Information Processing Systems 20, 1177~84. https://proceedings.neurips.cc/paper/2007/hash/013a006f03dbc5392effeb8f18fda755-Abstract.html.
Williams, Christopher K. I., 和 Matthias Seeger. 2001年. 《Using the Nyström Method to Speed Up Kernel Machines》. Advances in Neural Information Processing Systems 13. https://proceedings.neurips.cc/paper/2000/hash/19de10adbaa1b2ee13f77f679fa1483a-Abstract.html.