05 其他基础监督学习方法

本章会用到的数据

  • 公开下载审计意见公司基本信息财务报表

  • 这些数据能做什么:比较样条、支持向量机和 K 近邻在金融分类或预测问题中的表现。

  • 分析时注意:只用当时已经披露的信息构造特征,并在同一组样本上比较不同方法。

  • 判断模型:先用验证数据选择参数;模型确定后,再用最终测试数据检查一次。

【课堂核心】2 学时学习安排

学习内容 分钟
动机与先修 15
样条、SVM 与 KNN 35
独立诊断 15
形成性检查与中途反馈 15
综合反馈与公开比较 30
小结 10

【课后拓展】:完整核函数推导、境外机制例与额外可视化。

第5章:超越线性模型的局限

高级监督学习方法

先修入口检查:三份说明

独立写三行,限时 5 分钟:

  1. 验证设计:标出决策时点、训练/验证/最终测试期和折内预处理。
  2. 回归基线:给出第 3 章的训练均值与 OLS 基线。
  3. 分类基线:给出第 4 章的多数类基线、校准集与阈值确定顺序。

提交纪律:写完三行后提交,不提前查看反馈页。

先修反馈与即时订正

  1. 先封存:先划定最终测试,再在每个训练折拟合预处理与候选。
  2. 再选择:用验证窗确定方案,最后一次打开测试。
  3. 回归基线:至少比较训练均值与 OLS。
  4. 分类基线:至少比较多数类,并在独立开发资料上完成校准与阈值选择。

漏时间边界者回看第 6 章,漏基线者回看第 3–4 章;修正三行后进入本章。

课程回顾:线性模型的基石与局限

我们已经掌握了强大的线性模型,它们是计量经济学的核心。

核心结构: “线性”作用在不同对象上,不能把概率曲线本身统称为线性。

  • 线性回归: 条件均值满足 \(E[y\mid x] = w^T x + b\)(误差假设另行说明)。
  • 逻辑回归的线性部分: 对数优势与决策得分满足 \(\log\frac{P(y=1\mid x)}{1-P(y=1\mid x)}=w^T x+b\)
  • 逻辑回归的非线性部分: 概率 \(\sigma(w^T x+b)\)\(x\) 并非线性。

但现实世界… 往往不是线性的。

现实的挑战:当线性假设失效

当关系是非线性时,强行使用线性模型会导致系统性的预测偏差和错误的结论。

线性模型拟合非线性数据 一张图表显示了一条二次曲线上的散点,以及一条试图拟合这些点的直线,展示了欠拟合的情况。 X (例如: 研发投入) Y (例如: 公司利润) 线性模型拟合 (欠拟合) 真实关系

本章学习目标:构建我们的非线性工具箱

本章我们将学习一系列强大的监督学习方法,以捕捉复杂的非线性关系。

  • 样条模型 (Splines): 通过分段函数灵活地拟合数据。
  • 广义可加模型 (GAMs): 将非线性模型扩展到多预测变量,同时保持可解释性。
  • 支持向量机 (SVM): 一种强大的分类技术,通过“核技巧”处理非线性边界。
  • K近邻 (KNN): 一种简单、直观、非参数的分类和回归方法。

经济学中的非线性关系无处不在

线性假设的失效并非个例,而是常态。

经济学中的非线性关系案例 四张迷你图表分别展示了累进税率、边际报酬递减、广告饱和效应和波动率微笑四种非线性关系。 税收制度 分段线性 (累进税率) 生产函数 边际报酬递减 广告效应 饱和效应 资产波动率 “波动率微笑”现象

解决方案一:回归样条 (Regression Splines)

核心思想: 与其用一个复杂的全局函数拟合所有数据,不如将数据的定义域切分成多个区域,在每个区域内用简单的函数(如多项式)来拟合。

这就像修建一条穿过山脉的铁路:我们不会试图用一个巨大的圆弧来匹配整个山脉轮廓,而是在不同路段使用不同的坡度和曲线,然后将它们平滑地连接起来。

样条的关键概念:结点 (Knots)

结点 (Knot) 是我们分割数据区域的点,也是模型函数形式发生变化的地方。

样条的结点 一条X轴上标有“区域1”,“区域2”,“区域3”,分割点被称为“结点”。 x 结点 c₁ 结点 c₂ 区域 1 区域 2 区域 3 f₁(x) f₂(x) f₃(x)

在累进税率的例子中,每个税率区间的起点就是一个天然的结点

一个简单的分段线性样条模型

假设我们只有一个结点 \(c\)。我们可以将模型定义为:

\[ \large{ f(x) = \begin{cases} w_{01} + w_{11}x, & \text{如果 } x \le c \\ w_{02} + w_{12}x, & \text{如果 } x > c \end{cases} } \]

这个模型在 \(x \le c\) 的区域使用一条直线,在 \(x > c\) 的区域使用另一条直线。

分段拟合的问题:函数不连续

如果我们独立地在每个区域估计参数,拟合出的函数在结点处可能会出现“跳跃”,这在多数经济场景中不合逻辑。

不连续的分段函数 图表显示在结点c处,两条拟合线没有连接在一起,形成了一个跳跃。 结点 c 不合逻辑的“跳跃”!

解决方案:施加连续性约束

为了让函数在结点处平滑连接,我们可以施加约束。

  • C⁰ 连续 (函数本身连续): 要求函数在结点 \(c\) 处的值必须相等。
    • 结果: 两条线在结点处相交,形成一个拐点
  • C¹ 连续 (一阶导数连续): 要求函数在结点 \(c\) 处的斜率也必须相等。
    • 结果: 曲线在结点处平滑过渡
  • C² 连续 (二阶导数连续): 要求函数在结点 \(c\) 处的曲率也必须相等。
    • 结果: 曲线看起来更加平滑自然

可视化连续性约束

不同阶的连续性 三张小图分别展示了C0(有拐点)、C1(平滑)、C2(更平滑)的曲线连接。 C⁰ 连续 (有拐点) C¹ 连续 (平滑) C² 连续 (更平滑)

对于一个 \(d\) 次多项式样条,我们通常要求函数及其直到 \(d-1\) 阶的导数在结点处都是连续的。

更灵活的选择:三次样条 (Cubic Spline)

在实践中,最常用的是三次样条

  • 形式: 在每个结点之间的区域,使用三次多项式 \(f(x) = w_0 + w_1x + w_2x^2 + w_3x^3\) 进行拟合。
  • 约束: 在每个结点处,我们施加函数值、一阶导数和二阶导数都连续的约束。
  • 优点: 这使得拟合出的曲线既非常灵活,又看起来非常平滑自然,没有奇怪的拐点。

可视化案例:税前收入与税后收入

让我们用中国居民个人综合所得年度税率表理解样条。全年应纳税所得额采用七级超额累进税率,因此税后应纳税所得额关于税前应纳税所得额是连续的分段线性函数。

  • 数据依据:综合所得年度税率表的区间与税率。
  • 图中现象:蓝线在税率变化点改变斜率,但函数保持连续。
  • 解释边界:不处理专项扣除、税收优惠或具体纳税申报。
  • 来源国家税务总局惠州市税务局综合所得税率表
代码
# 为“可视化案例:税前收入与税后收入”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“可视化案例:税前收入与税后收入”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 导入 Matplotlib 顶层配置接口并绑定为 `mpl`,用于设置本图的中文字体与全局绘图样式。
import matplotlib as mpl

# 设置统一的投影绘图样式
# 统一税前—税后收入图的投影字号、画布和边框样式。
mpl.rcParams.update({
    # 将基础文字设为 14pt,保证投影可读。
    'font.size': 14,
    # 将坐标轴标题设为 16pt,突出变量与单位。
    'axes.labelsize': 16,
    # 将图题设为 18pt,建立视觉层级。
    'axes.titlesize': 18,
    # 将横轴刻度设为 12pt,兼顾密度与可读性。
    'xtick.labelsize': 12,
    # 将纵轴刻度设为 12pt,便于读取收入数值。
    'ytick.labelsize': 12,
    # 将图例设为 14pt,清楚区分税前与税后曲线。
    'legend.fontsize': 14,
    # 固定宽屏画布比例,适配幻灯片安全区。
    'figure.figsize': [10, 3.0],
    # 使用无衬线字体,提升屏幕投影清晰度。
    'font.family': 'sans-serif',
    # 隐藏上边框,减少非数据墨水。
    'axes.spines.top': False,
    # 隐藏右边框,减少非数据墨水。
    'axes.spines.right': False,
    # 开启参考网格,便于比较两个收入尺度。
    'axes.grid': True,
    # 使用虚线网格,避免遮盖数据曲线。
    'grid.linestyle': '--',
    # 降低网格透明度,使税后曲线保持视觉主导。
    'grid.alpha': 0.6
# 完成全局绘图样式字典,统一税前—税后收入图的字号、网格和边框。
})

# 定义 `calculate_post_tax_income`,按分段税率计算税后收入。
def calculate_post_tax_income(pre_tax_income):
    # 固定中国综合所得年度超额累进税率上界与边际税率,最后一档没有有限上界。
    brackets = [(36000, 0.03), (144000, 0.10), (300000, 0.20), (420000, 0.25), (660000, 0.30), (960000, 0.35), (np.inf, 0.45)]
    # 从零税额开始按税档累计,保持每个结点处税额连续。
    tax = 0.0
    # 记录当前税档下界,使每一档只计算区间内的增量所得。
    lower_bound = 0.0
    # 依次遍历税档上界和对应边际税率,复算超额累进税额。
    for upper_bound, marginal_rate in brackets:
        # 计算落入当前税档的应纳税所得额,避免重复计税。
        taxable_in_bracket = max(min(pre_tax_income, upper_bound) - lower_bound, 0.0)
        # 按当前档边际税率累加税额,形成连续分段线性函数。
        tax += taxable_in_bracket * marginal_rate
        # 当收入没有跨过本档上界时终止循环,避免继续访问更高税档。
        if pre_tax_income <= upper_bound:
            # 结束已覆盖全部收入的税档遍历。
            break
        # 把本档上界更新为下一档下界,继续计算超出部分。
        lower_bound = upper_bound
    # 返回 `pre_tax_income - tax`,把“可视化案例:税前收入与税后收入”的计算结果交给调用方。
    return pre_tax_income - tax

# 建立 `pre_tax_incomes` 的有序取值网格,用于展示“税前收入与税后收入的关系呈现出分段线性的特征”随参数变化的比较结果。
pre_tax_incomes = np.linspace(0, 1200000, 500)
# 固定 `post_tax_incomes` 的数值分量,作为“可视化案例:税前收入与税后收入”的可手算输入。
post_tax_incomes = np.array([calculate_post_tax_income(income) for income in pre_tax_incomes])
# 固定 `knots` 的业务取值与顺序,作为“可视化案例:税前收入与税后收入”的可复算输入。
knots = [36000, 144000, 300000, 420000, 660000, 960000]  # 固定中国综合所得税率变化点
# 计算每个税档起点对应的税后收入,作为分段线性曲线上的红色结点坐标。
knot_post_tax = [calculate_post_tax_income(k) for k in knots]

# 创建 `fig, ax` 画布,承载“税前收入与税后收入的关系呈现出分段线性的特征”的并排视觉比较。
fig, ax = plt.subplots()
# 以税前收入为横轴、税后收入为纵轴绘制蓝色分段曲线,展示边际税率变化形成的斜率折点。
ax.plot(pre_tax_incomes / 10000, post_tax_incomes / 10000, label='税后应纳税所得额', linewidth=2.5, color='#2980b9')
# 绘制税前等于税后收入的灰色虚线基准,用于衡量各收入水平的税负差距。
ax.plot(pre_tax_incomes / 10000, pre_tax_incomes / 10000, label='税前应纳税所得额', linestyle='--', color='#7f8c8d', alpha=0.8)
# 将税率变化点叠加在税后收入曲线上,显示样条结点的位置。
ax.scatter(np.array(knots) / 10000, np.array(knot_post_tax) / 10000, color='#c0392b', zorder=5, label='法定税率变化点(结点)')

# 将横轴标为“税前收入 (千美元)”,明确横向编码的变量。
ax.set_xlabel('税前应纳税所得额(万元)')
# 将纵轴标为“收入 (千美元)”,明确纵向编码的变量。
ax.set_ylabel('税后应纳税所得额(万元)')
# 将图题设为“税前与税后收入关系:一个天然的样条函数”,直接说明当前图形的比较目的。
ax.set_title('中国综合所得税率形成连续分段线性关系')
# 显示“可视化案例:税前收入与税后收入”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 为“可视化案例:税前收入与税后收入”,限定横轴范围,使比较对象使用一致尺度。
ax.set_xlim(0, 120)
# 为“可视化案例:税前收入与税后收入”,限定纵轴范围,使比较对象使用一致尺度。
ax.set_ylim(0, 120)
# 显示税后收入曲线、无税基准与税档结点,检查税负差距和分段斜率变化。
plt.show()
横轴为税前收入、纵轴为税后收入;蓝色分段直线在各税率结点改变斜率,并与无税 45 度虚线比较。
图 1: 税前收入与税后收入的关系呈现出分段线性的特征

实践中的挑战:如何选择结点?

样条的灵活性也带来了它最大的挑战:我们应该在哪里放置结点?以及,应该放置多少个结点?

  • 结点过多: 模型过于灵活,可能导致对数据的过拟合 (Overfitting)。它会去拟合数据中的噪声,而不是真实的潜在关系。
  • 结点过少: 模型不够灵活,无法捕捉到数据中的非线性结构,导致欠拟合 (Underfitting)

结点选择的影响

结点数量对拟合的影响 两张图,左边显示结点过少导致欠拟合,右边显示结点过多导致过拟合。 结点过少 (欠拟合) 结点过多 (过拟合)

常见策略: 通过交叉验证将结点数量和位置视为超参数来选择最佳组合。

解决方案二:平滑样条 (Smoothing Splines)

  • 回归样条的难点:需要手动选择少数结点。
  • 平滑样条的做法:在每个数据点 \(x^{(i)}\) 上放置结点。
  • 复杂度控制:用正则化项限制函数的平滑度或弯曲度,避免过拟合。

平滑样条的代价函数

平滑样条的目标是找到一个函数 \(f(x)\),使其最小化以下代价函数:

\[ \large{\underbrace{\sum_{i=1}^{n} (y^{(i)} - f(x^{(i)}))^2}_{\text{拟合优度项 (RSS)}} + \underbrace{\lambda \int [f''(t)]^2 dt}_{\text{平滑度惩罚项}}} \]

这个代价函数体现了一种经典的偏差-方差权衡 (Bias-Variance Tradeoff)

代价函数解析:拟合优度

第一项: \(\sum_{i=1}^{n} (y^{(i)} - f(x^{(i)}))^2\)

  • 这是我们熟悉的残差平方和 (RSS)
  • 它驱使模型尽可能地贴近数据点,以减少拟合误差。
  • 单独最小化这一项会导致模型穿过所有数据点,造成严重过拟合

代价函数解析:平滑度惩罚

第二项: \(\lambda \int [f''(t)]^2 dt\)

  • \(f''(t)\) 是函数的二阶导数,衡量其曲率弯曲度
  • \(\int [f''(t)]^2 dt\) 计算了函数在整个定义域上的总弯曲程度
  • 这一项惩罚“摇摆不定”的函数,鼓励模型选择更平滑的曲线。
函数弯曲度与二阶导数 两张图,左图显示一条摇摆的曲线,其二阶导数很大;右图显示平滑曲线,其二阶导数较小。 “摇摆”的函数 f(x) 大的 ∫[f''(t)]² dt (高惩罚) 平滑的函数 f(x) 小的 ∫[f''(t)]² dt (低惩罚)

理解平滑参数 \(\lambda\) 的作用

平滑参数 \(\lambda \ge 0\) 控制着拟合优度函数平滑度之间的权衡。

\(\lambda \to 0\) 时:

  • 惩罚项消失。
  • 模型只关心拟合数据。
  • 结果: 严重过拟合

\(\lambda \to \infty\) 时:

  • 模型极力避免任何弯曲。
  • 唯一二阶导数为0的函数是直线
  • 结果: 线性回归 (欠拟合)


结论: \(\lambda\) 是一个需要通过交叉验证等方法仔细选择的关键超参数。

Python实现:用Scipy拟合平滑样条

  • 演示数据:带正态噪声的正弦函数示例数据。
  • 拟合工具scipy.interpolate.UnivariateSpline
  • 平滑参数s 与理论中的 \(\lambda\) 概念相近。
  • 方向s 越小越接近插值;s 越大越平滑。

步骤1: 导入必要的库

  • numpy:数值计算;
  • scipy.stats.norm:生成噪声;
  • scipy.interpolate.UnivariateSpline:拟合样条;
  • matplotlib.pyplot:可视化结果。
代码
# 为“步骤1: 导入必要的库”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“步骤1: 导入必要的库”,导入正态分布函数,构造带噪声的非线性演示样本。
from scipy.stats import norm
# 为“步骤1: 导入必要的库”,导入一元平滑样条,比较不同平滑强度的曲线。
from scipy.interpolate import UnivariateSpline
# 为“步骤1: 导入必要的库”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 导入 Matplotlib 顶层配置接口并绑定为 `mpl`,用于统一样条示例的中文字体与投影样式。
import matplotlib as mpl

# 为后续图形统一设置投影样式
# 统一多项式与样条比较图的投影字号、画布和边框样式。
mpl.rcParams.update({
    # 统一基础文字、坐标标签与图题字号,保证样条图投影可读。
    'font.size': 14, 'axes.labelsize': 16, 'axes.titlesize': 18,
    # 统一刻度与图例字号,保持信息层级。
    'xtick.labelsize': 12, 'ytick.labelsize': 12, 'legend.fontsize': 14,
    # 固定宽屏画布并使用无衬线字体,适配课堂投影。
    'figure.figsize': [10, 5.5], 'font.family': 'sans-serif',
    # 隐藏顶部和右侧边框,减少对样条曲线的干扰。
    'axes.spines.top': False, 'axes.spines.right': False,
    # 使用低透明度虚线网格,辅助读取拟合偏差。
    'axes.grid': True, 'grid.linestyle': '--', 'grid.alpha': 0.6
# 完成样条图的全局样式字典,统一字号、画布和网格透明度。
})

步骤2: 生成合成数据

我们生成一个以 \(y = \sin(x)\) 为基础,并加上一些正态分布噪声 \(\epsilon\) 的数据集。这模拟了一个存在非线性关系但被随机因素干扰的真实场景。

代码
# 为“步骤2: 生成合成数据”,设置随机种子以保证结果可复现
np.random.seed(42)

# 生成x和y数据
x_synthetic = np.linspace(0, 10, 100)
# 在正弦机制曲线上叠加固定尺度噪声,形成比较样条平滑程度的教学观测。
y_synthetic = np.sin(x_synthetic) + norm.rvs(0, 0.2, size=x_synthetic.size)

步骤3: 拟合平滑样条模型

我们调用 UnivariateSpline,传入我们的 xy 数据,并设置平滑参数 s。这里我们选择 s=0.5 作为一个适中的初始值。

代码
# s参数控制平滑度,s越大越平滑
spline_synthetic = UnivariateSpline(x_synthetic, y_synthetic, s=0.5)

步骤4: 可视化拟合结果

我们将原始数据点和拟合出的样条曲线绘制在同一张图上,以直观地评估拟合效果。

代码
# 创建 `fig, ax` 画布,承载“平滑样条能够很好地捕捉到数据的非线性趋势,同时忽略噪声”的并排视觉比较。
fig, ax = plt.subplots()
# 以 `x_synthetic` 为横轴、`y_synthetic` 为纵轴绘制散点,展示“平滑样条能够很好地捕捉到数据的非线性趋势,同时忽略噪声”。
ax.scatter(x_synthetic, y_synthetic, label='原始数据点 (Synthetic Data)', color='navy', s=20, alpha=0.6)
# 在同一横轴上绘制橙色平滑样条预测,便于与蓝色带噪观测点比较整体非线性趋势。
ax.plot(x_synthetic, spline_synthetic(x_synthetic), label='平滑样条拟合 (Smoothing Spline)', color='orange', linewidth=3)
# 为“步骤4: 可视化拟合结果”,将横轴标为“x”,明确横向编码的变量。
ax.set_xlabel('x')
# 为“步骤4: 可视化拟合结果”,将纵轴标为“y”,明确纵向编码的变量。
ax.set_ylabel('y')
# 将图题设为“平滑样条拟合合成数据”,直接说明当前图形的比较目的。
ax.set_title('平滑样条拟合合成数据')
# 显示“步骤4: 可视化拟合结果”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 显示带噪散点与样条拟合,检查曲线是否捕捉点云趋势而非逐点追随噪声。
plt.show()
横轴为解释变量、纵轴为带噪观测;平滑样条穿过点云的整体非线性趋势,但不追随每个随机波动。
图 2: 平滑样条能够很好地捕捉到数据的非线性趋势,同时忽略噪声

探索平滑参数s的影响

为了更好地理解平滑参数的作用,让我们尝试不同的 s 值,并观察拟合曲线的变化。

我们将在同一张图上绘制 s 取值为 0.1 (欠平滑/过拟合), 1 (适中平滑), 和 10 (过度平滑/欠拟合) 的结果。

代码
# 拟合 `spline_overfit` 平滑曲线,用指定平滑强度展示欠拟合与过拟合差异。
spline_overfit = UnivariateSpline(x_synthetic, y_synthetic, s=0.1)
# 拟合 `spline_good` 平滑曲线,用指定平滑强度展示欠拟合与过拟合差异。
spline_good = UnivariateSpline(x_synthetic, y_synthetic, s=1)
# 拟合 `spline_underfit` 平滑曲线,用指定平滑强度展示欠拟合与过拟合差异。
spline_underfit = UnivariateSpline(x_synthetic, y_synthetic, s=10)

# 创建 `fig, ax` 画布,承载“平滑参数s对拟合曲线的形态有决定性影响”的并排视觉比较。
fig, ax = plt.subplots()
# 以 `x_synthetic` 为横轴、`y_synthetic` 为纵轴绘制散点,展示“平滑参数s对拟合曲线的形态有决定性影响”。
ax.scatter(x_synthetic, y_synthetic, label='原始数据点', color='black', s=15, alpha=0.4)
# 以 `x_synthetic` 为横轴、`spline_overfit(x_synthetic)` 为纵轴绘制曲线,展示“平滑参数s对拟合曲线的形态有决定性影响”。
ax.plot(x_synthetic, spline_overfit(x_synthetic), label='欠平滑 (s=0.1, 过拟合)', color='red', linestyle='--')
# 以 `x_synthetic` 为横轴、`spline_good(x_synthetic)` 为纵轴绘制曲线,展示“平滑参数s对拟合曲线的形态有决定性影响”。
ax.plot(x_synthetic, spline_good(x_synthetic), label='适中平滑 (s=1)', color='green', linewidth=3)
# 以 `x_synthetic` 为横轴、`spline_underfit(x_synthetic)` 为纵轴绘制曲线,展示“平滑参数s对拟合曲线的形态有决定性影响”。
ax.plot(x_synthetic, spline_underfit(x_synthetic), label='过度平滑 (s=10, 欠拟合)', color='blue', linestyle='-.')
# 将图题设为“平滑参数 s 的影响”,直接说明当前图形的比较目的。
ax.set_title('平滑参数 s 的影响')
# 为“探索平滑参数s的影响”,将横轴标为“x”,明确横向编码的变量。
ax.set_xlabel('x')
# 为“探索平滑参数s的影响”,将纵轴标为“y”,明确纵向编码的变量。
ax.set_ylabel('y')
# 显示“探索平滑参数s的影响”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 显示不同平滑参数下的样条曲线,比较小 `s` 的高方差与大 `s` 的高偏差。
plt.show()
多个分面以相同散点比较不同平滑参数 s;s 小时曲线更曲折,s 大时曲线更平滑。
图 3: 平滑参数s对拟合曲线的形态有决定性影响

从单变量到多变量:广义可加模型 (GAM)

样条模型在处理单个非线性特征时非常有效。但现实中的经济模型通常有多个预测变量。我们如何将样条的思想扩展到多维情况?

答案是广义可加模型 (Generalized Additive Model, GAM)

GAM 的数学形式

  • 多元线性回归模型:

    \[ \large{y = w_0 + w_1 x_1 + w_2 x_2 + \dots + w_m x_m} \]

  • 广义可加模型 (GAM):

    \[ \large{y = w_0 + f_1(x_1) + f_2(x_2) + \dots + f_m(x_m)} \]

这里的关键区别在于,我们将线性的 \(w_j x_j\) 项替换为了一个非线性的平滑函数 \(f_j(x_j)\)。这个 \(f_j\) 通常就是一个样条函数

GAM的直观理解:模块化建模

可以把GAM想象成用乐高积木搭建模型。每个特征的平滑函数 \(f_j(x_j)\) 都是一个独立的“积木块”,模型最终的预测是把这些积木块的效果“加”起来。

广义可加模型的模块化结构 三个独立的图表(f1, f2, f3)通过加号连接,最终指向预测结果y。 f₁(x₁) + f₂(x₂) + ... y

GAM 的巨大优势:保持了可解释性

尽管GAM是一个非线性模型,但它通过其可加性 (additivity) 结构,奇迹般地保留了大部分线性模型的可解释性。

  • 我们可以独立地检查每一个函数 \(f_j(x_j)\) 的图形。
  • \(f_j(x_j)\) 的图形揭示了特征 \(x_j\) 对目标变量 \(y\)非线性影响,同时控制了其他所有变量。

模拟GAM输出:洞察非线性关系

假设我们用GAM预测房价,我们可以得到类似下图的洞察:

模拟的GAM组件图 两张图,左边显示房价与房屋面积的非线性关系,右边显示房价与建造年份的非线性关系。 f(房屋面积) 面积 对房价的影响 f(建造年份) 年份

GAM 的局限性:忽略了特征交互

  • 可加性假设:每个特征 \(x_j\)\(y\) 的影响不随其他特征 \(x_k\) 改变。
  • 缺失结构:标准 GAM 不会自动捕捉特征交互作用。
  • 例子:广告支出对冰淇淋销售的影响可能随季节变化;夏季与冬季的斜率不同。
  • 建模含义:这种“效果随另一变量变化”的加成需要显式加入交互结构。

全新视角:支持向量机 (SVM)

接下来,我们将学习一个在机器学习领域极具影响力的模型:支持向量机 (Support Vector Machine, SVM)

SVM 最初是为分类问题设计的,但也可以扩展到回归问题 (SVR)。它的核心思想与我们之前学过的模型有很大不同,它关注的是位于决策边界上的数据点。

SVM 的基本思想:哪条分界线最好?

想象一个二分类问题。我们可以找到很多条直线(或超平面)来分开两类数据点。

多条可能的决策边界 一堆蓝色和红色的点,三条不同的直线都分开了它们。 哪一条是最好的?

SVM 的回答是:那条能够以最大“间隔”(Margin)将两类数据分开的线是最好的。

SVM 的核心:最大化分类间隔

间隔 (Margin): 决策边界与离它最近的任何一类数据点之间的距离。一个更大的间隔意味着我们的决策边界更鲁棒。

SVM的最大间隔图示 图示了决策边界、间隔和支持向量。 决策边界 支持向量 最大间隔

核心概念:支持向量 (Support Vectors)

线性可分的硬间隔情形中,那些对偶系数 \(\alpha_i>0\) 且恰好位于间隔边界上的点,称为支持向量 (Support Vectors)

  • 它们是“支撑”起整个决策边界的关键点。
  • 在活动集与最优解未切换的局部范围内,移动支持向量可改变边界;小幅移动仍严格在间隔外的非支持向量通常不改变当前解。

这是 SVM 的核心洞察: 当前解由 \(\alpha_i>0\) 的活动样本支撑,但不应把“移动任意一点”的敏感性写成无条件定理。

从硬间隔到软间隔:处理线性不可分或重叠数据

  • 硬间隔前提:数据在线性特征空间中完全可分。
  • 软间隔作用:类别重叠、噪声或违约点出现时,允许部分间隔违约。
  • 边界类型:软间隔在当前特征空间仍是线性边界;非线性边界需要特征映射或核技巧。

权衡: 模型需要在最大化间隔减少分类错误之间找到平衡;超参数 \(C\) 控制这个权衡。

软间隔中的支持向量

  • 支持向量:统一定义为 \(\alpha_i>0\) 的观测。
  • 位置解释\(0<\alpha_i<C\) 通常位于间隔边界;\(\alpha_i=C\) 可在间隔内或被错分。
软间隔SVM 图示软间隔分类器,允许一些点在间隔内或者被错误分类。 错误分类! 在间隔内!

代价参数 C 的作用

  • 小的 C: 更大的间隔,容忍更多的分类错误(更“软”)。可能导致高偏差
  • 大的 C: 更小的间隔,严格惩罚分类错误。可能导致高方差(过拟合)

SVM 的代价函数:合页损失 (Hinge Loss)

SVM 的这种思想体现在其独特的损失函数——合页损失 (Hinge Loss) 中。

\[ \large{J(w) = \frac{1}{n} \sum_{i=1}^{n} \max(0, 1 - y^{(i)} f(x^{(i)})) + \lambda ||w||^2} \]

  • 合页损失项: \(\max(0, 1 - y^{(i)} f(x^{(i)}))\)
    • 对于被正确分类且与决策边界保持足够距离(\(y^{(i)} f(x^{(i)}) \ge 1\))的点,其损失为零
  • 正则化项: \(\lambda ||w||^2\)
    • 与最大化间隔在数学上是等价的。

合页损失 vs. 逻辑损失:可视化对比

图 4 直观地展示了两种损失函数的区别。\(y \cdot f(x)\) 的值越大,代表分类越正确且置信度越高。

代码
# 定义 `hinge_loss`,逐点计算合页损失。
def hinge_loss(z): return np.maximum(0, 1 - z)
# 定义 `log_loss`,逐点计算逻辑损失。
def log_loss(z): return np.log(1 + np.exp(-z))

# 建立 `z` 的有序取值网格,用于展示“合页损失(蓝线)与逻辑损失(红线)的比较”随参数变化的比较结果。
z = np.linspace(-2, 3, 200)

# 创建 `fig, ax` 画布,承载“合页损失(蓝线)与逻辑损失(红线)的比较”的并排视觉比较。
fig, ax = plt.subplots(figsize=(8.5, 5))
# 以 `z` 为横轴、`hinge_loss(z)` 为纵轴绘制曲线,展示“合页损失(蓝线)与逻辑损失(红线)的比较”。
ax.plot(z, hinge_loss(z), label='合页损失 (Hinge Loss)', color='#2980b9', linewidth=3)
# 以 `z` 为横轴、`log_loss(z)` 为纵轴绘制曲线,展示“合页损失(蓝线)与逻辑损失(红线)的比较”。
ax.plot(z, log_loss(z), label='逻辑损失 (Log Loss)', color='#c0392b', linewidth=2, linestyle='--')
# 在 `0` 处添加水平参考线,标出“合页损失(蓝线)与逻辑损失(红线)的比较”的基准或阈值。
ax.axhline(0, color='black', linewidth=0.8)
# 在 `0` 处添加垂直参考线,标出“合页损失(蓝线)与逻辑损失(红线)的比较”的基准或阈值。
ax.axvline(0, color='black', linewidth=0.8)
# 在 `1` 处添加垂直参考线,标出“合页损失(蓝线)与逻辑损失(红线)的比较”的基准或阈值。
ax.axvline(1, color='grey', linewidth=1, linestyle=':')
# 在图中标注“1”,解释“合页损失(蓝线)与逻辑损失(红线)的比较”的关键位置。
ax.text(1, -0.2, '1', ha='center', fontsize=12)
# 将横轴标为“$y \cdot f(x)$ (分类正确性得分)”,明确横向编码的变量。
ax.set_xlabel('$y \cdot f(x)$ (分类正确性得分)')
# 将纵轴标为“损失 (Loss)”,明确纵向编码的变量。
ax.set_ylabel('损失 (Loss)')
# 将图题设为“损失函数对比”,直接说明当前图形的比较目的。
ax.set_title('损失函数对比')
# 显示“合页损失 vs. 逻辑损失:可视化对比”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 为“合页损失 vs. 逻辑损失:可视化对比”,限定纵轴范围,使比较对象使用一致尺度。
ax.set_ylim(-0.2, 3)
# 为“合页损失 vs. 逻辑损失:可视化对比”,限定横轴范围,使比较对象使用一致尺度。
ax.set_xlim(-2, 3)
# 显示合页损失与逻辑损失随分类间隔的变化,比较硬间隔惩罚与平滑概率损失。
plt.show()
横轴为分类间隔 y·f(x),纵轴为单样本损失;蓝色合页损失在间隔达到 1 后降为零,红色逻辑损失随间隔增加平滑趋近零。
图 4: 合页损失(蓝线)与逻辑损失(红线)的比较

终极武器:核技巧 (The Kernel Trick)

线性SVM只能找到线性的决策边界。如果数据本身需要一个曲线边界才能分开呢?

笨方法: 手动添加特征。例如,如果原始特征是 \(x_1\),我们可以创建一个新特征 \(x_2 = x_1^2\)。然后在这个新的二维空间 \((x_1, x_1^2)\) 中,数据可能就变成线性可分的了。

问题: 我们怎么知道要添加什么特征?如果原始特征维度很高,创建新特征会导致维度爆炸,计算成本极高。

优雅的解决方案: 核技巧 (The Kernel Trick)

核技巧的可视化:从一维到二维

假设我们有一维数据,无法用一个点(一维的“线”)来分开。

图 5 展示了这种情况。红色的x和蓝色的点混杂在一起,无法线性分离。

代码
# 为“核技巧的可视化:从一维到二维”,固定随机数序列,使课堂示例可重复。
np.random.seed(0)
# 按给定均值与尺度抽取 `X_1d` 的高斯样本,固定“核技巧的可视化:从一维到二维”的连续输入。
X_1d = np.sort(np.random.normal(0, 1, 100))
# 将区间 `(-0.8, 0.8)` 内的样本标为正类,构造一维空间中不可由单阈值分开的标签。
y_1d = (X_1d > -0.8) & (X_1d < 0.8)

# 创建 `fig, ax` 画布,承载“在一维空间中,这些数据点无法被一个点线性分离”的并排视觉比较。
fig, ax = plt.subplots(figsize=(10, 2))
# 绘制区间外类别,展示它们分布在一维轴两端。
ax.scatter(X_1d[y_1d == 0], np.zeros(np.sum(y_1d==0)), c='#c0392b', marker='x', s=60, label='类别 0', linewidth=1.5)
# 绘制区间内类别,验证单个一维阈值无法同时分开两端样本。
ax.scatter(X_1d[y_1d == 1], np.zeros(np.sum(y_1d==1)), c='#2980b9', marker='o', s=50, label='类别 1', alpha=0.8)
# 将 `ax` 的纵轴刻度固定为代码给定的位置。
ax.set_yticks([])
# 为“核技巧的可视化:从一维到二维”,将横轴标为“x”,明确横向编码的变量。
ax.set_xlabel('x')
# 将图题设为“一维空间中线性不可分的数据”,直接说明当前图形的比较目的。
ax.set_title('一维空间中线性不可分的数据')
# 显示“核技巧的可视化:从一维到二维”图例,使颜色或线型与比较对象一一对应。
ax.legend(loc='center right')
# 隐藏左边框,避免误示纵轴具有度量意义。
ax.spines['left'].set_visible(False)
# 隐藏右边框,保留一维数轴的视觉焦点。
ax.spines['right'].set_visible(False)
# 隐藏上边框,保留一维数轴的视觉焦点。
ax.spines['top'].set_visible(False)
# 为“核技巧的可视化:从一维到二维”添加辅助网格,便于比较位置、斜率或组间差异。
ax.grid(False)
# 显示原始一维标签分布,检查正类位于中间、负类位于两端的非线性可分结构。
plt.show()
所有样本位于同一横轴,蓝点集中在中间、红叉分居两端;任何单一切点都无法同时分开两类。
图 5: 在一维空间中,这些数据点无法被一个点线性分离

核技巧的可视化:映射到高维空间

把每个点映射为 \((x, x^2)\) 后,两类样本落在抛物线的不同高度,可由水平直线分开(图 6)。

代码
# 将原特征及其平方项并列为二维设计矩阵,使区间型标签可在升维空间线性分隔。
X_2d = np.c_[X_1d, X_1d**2]

# 创建 `fig, ax` 画布,承载“将数据映射到二维空间 (x, x^2) 后,可以用一条直线分离”的并排视觉比较。
fig, ax = plt.subplots()
# 在映射后的抛物线上绘制区间外类别,核对其位于较高的平方项区域。
ax.scatter(X_2d[y_1d == 0, 0], X_2d[y_1d == 0, 1], c='#c0392b', marker='x', s=60, label='类别 0', linewidth=1.5)
# 绘制区间内类别,核对二维映射后可以用水平边界分离。
ax.scatter(X_2d[y_1d == 1, 0], X_2d[y_1d == 1, 1], c='#2980b9', marker='o', s=50, label='类别 1', alpha=0.8)
# 建立 `x_line` 的有序取值网格,用于展示“将数据映射到二维空间 (x, x^2) 后,可以用一条直线分离”随参数变化的比较结果。
x_line = np.linspace(-2.5, 2.5, 100)
# 在全部横坐标上设置平方项阈值 0.6,形成映射空间的水平分类边界。
y_line = np.full_like(x_line, 0.6)
# 画出平方项为 0.6 的示意决策边界,展示映射后的线性可分性。
ax.plot(x_line, y_line, color='#16713A', label='决策边界', linewidth=3)
# 为“核技巧的可视化:映射到高维空间”,将横轴标为“x”,明确横向编码的变量。
ax.set_xlabel('x')
# 将纵轴标为“$x^2$”,明确纵向编码的变量。
ax.set_ylabel('$x^2$')
# 将图题设为“映射到二维空间后数据变为线性可分”,直接说明当前图形的比较目的。
ax.set_title('映射到二维空间后数据变为线性可分')
# 显示“核技巧的可视化:映射到高维空间”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 显示平方特征映射后的二维点,检查原本非线性的类别是否可由直线边界分开。
plt.show()
横轴为 x、纵轴为 x²;两类样本落在抛物线上,水平直线可将中部蓝点与两端红叉分开。
图 6: 将数据映射到二维空间 (x, x^2) 后,可以用一条直线分离

核技巧的数学精髓

关键洞察: 在SVM的求解过程中,我们实际上不需要知道高维空间中点的具体坐标,我们只需要知道数据点在高维空间中的点积 (dot product)

核函数 (Kernel Function) \(K(x^{(i)}, x^{(j)})\) 就是一个能高效计算高维空间中点积的函数,它无需先进行显式的维度转换。

\[ \large{K(x^{(i)}, x^{(j)}) = \phi(x^{(i)}) \cdot \phi(x^{(j)})} \]

其中 \(\phi(x)\) 是将数据从低维映射到高维的函数。我们使用 \(K\) 来计算,从而避免了计算和存储巨大的 \(\phi(x)\) 向量。

常用核函数及其作用

有多种常用的核函数,它们对应着不同类型的非线性决策边界。

核函数 公式 直觉与超参数
线性核 (Linear) \(K(x^{(i)}, x^{(j)}) = x^{(i)} \cdot x^{(j)}\) 不进行维度提升,就是标准的线性SVM。
多项式核 (Polynomial) \(K(x^{(i)}, x^{(j)}) = (c + x^{(i)} \cdot x^{(j)})^d\) 创造多项式组合特征。超参数 d (degree) 控制决策边界的弯曲程度。d 越大,模型越复杂。
径向基函数核 (RBF) \(K(x^{(i)}, x^{(j)}) = \exp(-\gamma \|x^{(i)} - x^{(j)}\|^2)\) 创造非常复杂的、局部化的决策边界。可以看作是无限维度的映射。超参数 gamma 控制单个样本的影响范围。gamma 越大,影响范围越小,决策边界越“崎岖”,更容易过拟合。

形成性检查:间隔、核与 gamma

对两个点距离平方为 4 的极简例,分别计算 gamma=0.1gamma=1 时的 RBF 相似度 \(\exp(-4\gamma)\);再判断较大 gamma 是否必然提高时间外分类表现。

反馈:gamma 控制局部性,不保证泛化

  • 计算结果:两项相似度约为 0.67030.0183
  • 局部性gamma 越大,远处样本相似度衰减越快,边界可以更局部。
  • 泛化判断:是否改善时间外表现只能由开发期验证决定。
  • 学习路径:答错者回看 相关内容;两项正确者进入 KNN。

最后一种方法:K-近邻 (KNN)

最后,我们来学习一种非常不同但极其直观的方法:K-近邻 (K-Nearest Neighbors, KNN)

  • 非参数:不预设固定有限维的参数函数形式。
  • 关键依赖:距离度量、局部相似或平滑、邻域代表性。
  • 适用边界:受维数与样本密度约束。
  • 懒惰学习:没有显式的参数拟合阶段,在预测时寻找邻居。
  • 核心思想: “物以类聚,人以群分”。要预测一个新数据点的类别,我们只需查看它在特征空间中最近的 K 个邻居,然后采取“少数服从多数”的原则。

KNN算法的可视化步骤

如何用KNN预测一个新点(灰色问号)的类别? (假设 K=5)

KNN分类过程 一个新点被五个最近的邻居包围,其中三个是蓝色,两个是红色,因此新点被分类为蓝色。 ? 1. 找到 K=5 个最近的邻居 2. 投票: 3个蓝色, 2个红色. ➡️ 结论: 蓝色

KNN算法的关键要素

  1. 距离度量 (Distance Metric): 如何衡量“远近”?
    • 欧几里得距离 (Euclidean Distance): 空间中两点间的直线距离。
    • 曼哈顿距离 (Manhattan Distance): “城市街区”距离。
  2. 邻居数量 K:
    • 这是一个关键的超参数,它控制了模型的偏差-方差权衡
    • K 太小: 模型对噪声敏感,决策边界复杂,导致高方差(过拟合)
    • K 太大: 模型过度平滑,忽略局部结构,导致高偏差(欠拟合)

K值对决策边界的影响

K值对KNN决策边界的影响 两张图,左图K=1时决策边界很复杂,右图K=20时决策边界很平滑。 K = 1 (高方差) 决策边界非常“崎岖” K = 20 (高偏差) 决策边界非常平滑

尺度选择不能脱离距离语义:

  • 候选口径:Z-score、robust scaling、事先确定的业务权重,或保留原量纲。
  • 判断依据:业务量纲、异常值与所需距离含义。
  • 估计纪律:尺度和权重只能在训练折内确定。
  • 验证要求:做敏感性或时间外验证,不能无条件指定 Z-score。

代码实战:SVM 与 KNN 分类器应用

  • 任务:比较 SVM 与 KNN 对首次披露审计记录的分类。
  • 数据:公开 stock/audit_opinion.h5,key=audit_opinion;财年范围 2014—2023。
  • 观察单位:公司—财年的首次披露版本;字段为财年、info_date、审计机构与意见类型。
  • 时间切分:2021 年末前训练、2022 年验证、2023 年以后最终测试。
  • 解释边界:只检验新披露记录的时间迁移,不是提前预测,也不作因果解释。

模型:

  1. 线性SVM分类器
  2. KNN分类器

特征: 披露滞后天数、财年、审计机构名称长度与四大会计师事务所标记;标签 modified_opinion=1 表示非标准审计意见(即非纯标准无保留意见)

核心依赖只保留拟合与验证接口

代码
# 为“步骤1: 导入库并加载数据”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤1: 导入库并加载数据”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“步骤1: 导入库并加载数据”,导入支持向量分类器,比较线性核与 RBF 核的决策边界。
from sklearn.svm import SVC
# 为“步骤1: 导入库并加载数据”,导入 `KNeighborsClassifier`,用邻近训练样本的类别投票完成本页分类比较。
from sklearn.neighbors import KNeighborsClassifier
# 为“步骤1: 导入库并加载数据”,导入 `StandardScaler`,供后续仅用训练子集按特征列估计均值与尺度。
from sklearn.preprocessing import StandardScaler
# 为“步骤1: 导入库并加载数据”,从 `sklearn.metrics` 导入`accuracy_score` 用于计算分类预测的准确率。
# 导入不平衡分类的主指标、正类召回、固定标签顺序混淆矩阵与辅助准确率。
from sklearn.metrics import accuracy_score, balanced_accuracy_score, confusion_matrix, recall_score

首次披露版本与实际信息窗口

代码
from pathlib import Path
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择审计意见文件。
audit_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/audit_opinion.h5'), Path('C:/qiufei/data/stock/audit_opinion.h5'), Path('data/course/audit_opinion.h5')] if candidate_path.exists()), Path('data/course/audit_opinion.h5'))
if not audit_path.exists():
    audit_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/audit_opinion.h5', audit_path)
audit_versions = pd.read_hdf(audit_path, key='audit_opinion')
# 统一披露日期类型,为时间切分和披露滞后计算提供可复算索引。
audit_versions['info_date'] = pd.to_datetime(audit_versions['info_date'])
# 只保留 2014—2023 年年度财报审计意见,确定任务样本与报告类型。
audit_versions = audit_versions.query("type == 'financial_statements' and quarter.str.endswith('q4') and '2014q4' <= quarter <= '2023q4'", engine='python').copy()
# 统计存在多个版本的公司—财年键,显式量化重发污染。
audit_duplicate_keys = int(audit_versions.groupby(['order_book_id', 'quarter']).size().gt(1).sum())
# 每个公司—财年只保留首次披露版本,禁止后续重发回填早期窗口。
audit_cases = audit_versions.sort_values(['order_book_id', 'quarter', 'info_date']).drop_duplicates(['order_book_id', 'quarter'], keep='first').copy()
# 强制首次披露观察单位唯一,避免版本记录重复加权。
assert not audit_cases.duplicated(['order_book_id', 'quarter']).any(), '首次披露公司—财年键不唯一'
# 从季度字段提取财年,建立严格的训练—验证—测试时间顺序。
audit_cases['fiscal_year'] = audit_cases['quarter'].str[:4].astype(int)
# 计算财年末至报告披露日的天数,作为可解释的披露速度特征。
audit_cases['report_delay_days'] = (audit_cases['info_date'] - pd.to_datetime(audit_cases['fiscal_year'].astype(str) + '-12-31')).dt.days
# 用名称长度描述机构文本字段的简单数值编码,避免把意见类型泄漏进特征。
audit_cases['agency_name_length'] = audit_cases['audit_agency'].fillna('').str.len()
# 标记常见国际四大会计师事务所在中国的名称,形成机构类别特征。
audit_cases['is_big4_cn'] = audit_cases['audit_agency'].fillna('').str.contains('普华永道|德勤|毕马威|安永').astype(int)
# 将标准无保留意见编码为零、其余意见编码为一,明确正类业务语义。
audit_cases['modified_opinion'] = audit_cases['opinion_type'].ne('unqualified').astype(int)
# 按实际披露日标记训练、验证与最终测试窗口,而不是按报告财年假定可得性。
audit_cases['release_window'] = np.select([audit_cases['info_date'].lt('2022-01-01'), audit_cases['info_date'].lt('2023-01-01')], ['训练', '验证'], default='最终测试')
# 固定四项模型输入,不让标签或未来披露信息进入特征矩阵。
fea_cols = ['report_delay_days', 'fiscal_year', 'agency_name_length', 'is_big4_cn']
# 按事先确定字段提取数值特征矩阵。
X = audit_cases[fea_cols]
# 提取非标准意见二分类目标,与特征保持行对齐。
y = audit_cases['modified_opinion']
# 确定三个实际披露窗口的行掩码,测试掩码此时只用于隔离而不读取标签。
training_mask, validation_mask, sealed_mask = audit_cases['release_window'].eq('训练'), audit_cases['release_window'].eq('验证'), audit_cases['release_window'].eq('最终测试')
# 用 2021 年末前已首次披露记录构造训练集,保持所有预处理只在早期窗口拟合。
X_train, y_train = X.loc[training_mask], y.loc[training_mask]
# 用 2022 年首次披露记录构造验证集,只承担候选方法比较。
X_validation, y_validation = X.loc[validation_mask], y.loc[validation_mask]
# 只最终测试行索引,直到采用决策确定后才读取对应特征与标签。
sealed_indices = audit_cases.index[sealed_mask]
# 检查实际披露时点严格有序,排除迟披露记录进入较早窗口。
assert audit_cases.loc[training_mask, 'info_date'].max() < audit_cases.loc[validation_mask, 'info_date'].min() < audit_cases.loc[sealed_mask, 'info_date'].min(), '披露时点窗口不严格有序'
# 在候选确定后允许用训练与验证年份重训最终流程,不包含最终测试期。
X_development = pd.concat([X_train, X_validation], axis=0)
# 合并对应开发期标签,保持与最终开发特征逐行对齐。
y_development = pd.concat([y_train, y_validation], axis=0)
# 报告公司财年样本数量变化量、异常长滞后与三个实际披露窗口样本数。
print({'原始版本行': len(audit_versions), '重复公司财年键': audit_duplicate_keys, '首次披露行': len(audit_cases), '移除后续版本': len(audit_versions) - len(audit_cases), '披露滞后超过550天': int(audit_cases['report_delay_days'].gt(550).sum()), '窗口样本': audit_cases['release_window'].value_counts().to_dict()})
{'原始版本行': 50892, '重复公司财年键': 438, '首次披露行': 50452, '移除后续版本': 440, '披露滞后超过550天': 6467, '窗口样本': {'训练': 32562, '最终测试': 11893, '验证': 5997}}

类别分布与多数类门槛

代码
# 只汇总训练与验证窗类别分布,避免在模型确定前窥视最终测试标签。
development_class_distribution = audit_cases.loc[~sealed_mask].groupby('release_window', sort=False)['modified_opinion'].agg(['count', 'sum', 'mean']).rename(columns={'count': '样本数', 'sum': '正类数', 'mean': '正类率'})
# 展示开发期类别流,同时只报告最终测试窗行数而不读取其类别构成。
display(development_class_distribution.round(4)); print({'最终测试样本数': int(sealed_mask.sum()), '标签状态': '未打开'})
# 用训练期多数类构造固定验证窗基线,作为所有候选的共同门槛。
validation_majority = np.repeat(int(y_train.mode().iloc[0]), len(y_validation))
# 汇总基线的主指标、正类召回、辅助准确率与固定标签顺序混淆矩阵。
majority_audit = {'candidate': '训练期多数类', 'balanced_accuracy': balanced_accuracy_score(y_validation, validation_majority), 'positive_recall': recall_score(y_validation, validation_majority, zero_division=0), 'accuracy': accuracy_score(y_validation, validation_majority), 'confusion_[0,1]': confusion_matrix(y_validation, validation_majority, labels=[0, 1]).tolist()}
{'最终测试样本数': 11893, '标签状态': '未打开'}
表 1: 审计意见年度类别分布与训练期多数类验证基线
样本数 正类数 正类率
release_window
训练 32562 1330 0.0408
验证 5997 289 0.0482

多数类基线不能被高 Accuracy 掩盖

代码
# 输出共同基线,后续 gamma、K、距离和模型都用 balanced accuracy 比较。
print(majority_audit)
表 2
{'candidate': '训练期多数类', 'balanced_accuracy': 0.5, 'positive_recall': 0.0, 'accuracy': 0.9518092379523095, 'confusion_[0,1]': [[5708, 0], [289, 0]]}

模拟曲线只用于观察平滑参数

由于 SVM 和 KNN 都对特征尺度敏感,我们让 StandardScaler 只由训练样本逐特征估计均值与标准差,再复用这组统计量转换验证集和最终测试集。

代码
# 创建缩放器,统计量只由训练样本逐特征估计。
scaler = StandardScaler()

# 为“步骤2: 数据预处理 - 标准化”,逐特征估计训练样本均值与标准差,并转换训练特征。
X_train_scaled = scaler.fit_transform(X_train)

# 使用确定的训练样本逐特征统计量转换验证特征。
X_validation_scaled = scaler.transform(X_validation)
# 为了方便查看,转换为DataFrame
X_train_scaled_df = pd.DataFrame(X_train_scaled, columns=fea_cols)
# 标出只按训练集均值与标准差变换后的前五行。
print('标准化后的训练数据(前5行):')
# 输出标准化训练特征前五行,核对量纲转换后的数值范围。
print(X_train_scaled_df.head().round(4))
标准化后的训练数据(前5行):
   report_delay_days  fiscal_year  agency_name_length  is_big4_cn
0            -0.5607      -1.5600              2.3747      3.7259
1            -0.5677      -1.0519              2.3747      3.7259
2            -0.5465      -0.5437              2.3747      3.7259
3            -0.5536      -0.0356              2.3747      3.7259
4            -0.5819       0.4726              2.3747      3.7259

平滑参数控制拟合—平滑权衡

我们创建一个使用线性核的SVC(Support Vector Classifier)对象,在标准化训练数据上拟合,并在验证集上比较候选方法。测试集仍保持最终测试。

代码
# 建立类别加权线性 SVM,避免目标稀少时优化退化为只报负类。
clf_svm = SVC(kernel='linear', class_weight='balanced', random_state=42)

# 在标准化的训练数据上训练模型
clf_svm.fit(X_train_scaled, y_train)

# 在验证集上进行预测
y_pred_svm = clf_svm.predict(X_validation_scaled)

# 汇总线性 SVM 的同窗主指标、正类召回、辅助准确率与固定标签顺序混淆矩阵。
linear_svm_audit = {'candidate': '线性SVM', 'balanced_accuracy': balanced_accuracy_score(y_validation, y_pred_svm), 'positive_recall': recall_score(y_validation, y_pred_svm, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_svm), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_svm, labels=[0, 1]).tolist()}
# 动态输出线性候选,拒绝手写与当前数据版本不一致的反馈数字。
print(linear_svm_audit)
{'candidate': '线性SVM', 'balanced_accuracy': 0.5805880413091078, 'positive_recall': 0.972318339100346, 'accuracy': 0.22661330665332666, 'confusion_[0,1]': [[1078, 4630], [8, 281]]}

训练曲线不能替代时间外比较

接下来,我们创建一个KNN分类器。我们将超参数K(n_neighbors)设置为5,这是一个常见的初始值。

代码
# 创建一个K=5的KNN分类器
clf_knn = KNeighborsClassifier(n_neighbors=5)

# 在标准化的训练数据上训练模型
clf_knn.fit(X_train_scaled, y_train)

# 在验证集上进行预测
y_pred_knn = clf_knn.predict(X_validation_scaled)

# 汇总固定 KNN 的同窗不平衡分类检查。
knn_five_audit = {'candidate': 'KNN K=5', 'balanced_accuracy': balanced_accuracy_score(y_validation, y_pred_knn), 'positive_recall': recall_score(y_validation, y_pred_knn, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_knn), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_knn, labels=[0, 1]).tolist()}
# 动态输出 KNN 基准规格,供后续 K 候选对照。
print(knn_five_audit)
{'candidate': 'KNN K=5', 'balanced_accuracy': 0.5980906419206455, 'positive_recall': 0.21107266435986158, 'accuracy': 0.9478072369518092, 'confusion_[0,1]': [[5623, 85], [228, 61]]}

习题解析与代码实现

现在,我们来完成编程练习,以加深对这些模型超参数的理解。

独立任务:三项超参数诊断

在运行参考实现前,分别完成三项比较:

  1. RBF-SVM:gamma={0.01,0.1,1,5,10,20}
  2. KNN:K={1,3,6,10,20},并声明并列规则;
  3. 固定 K=6:比较欧氏距离与曼哈顿距离。

共同纪律:三项都只能使用开发期验证窗,最终测试不得参与选择。

提交项 权重
三张候选表 45%
指标与并列规则 20%
退化分类器与过拟合判读 20%
一次性最终测试说明 15%

共同指标:balanced accuracy 为主;同时报告正类召回、辅助 Accuracy、固定 labels=[0,1] 混淆矩阵、年度类别分布和训练期多数类基线。

练习:先完成再查看答案:三项方案齐全后再看实现

提交三项候选、指标、选择规则和结果不理想时如何解释。任一项缺失时返回上一页补齐;完成全部三项尝试前不要进入参考实现。

参考实现1:RBF-SVM 的 gamma 参数

任务: 使用 rbf 核重新训练SVM模型,并尝试将 gamma 参数分别设为 0.01, 0.1, 1, 5, 10, 20,观察预测表现的变化。gamma 定义了单个训练样本的影响范围。

代码
gamma_values = [0.01, 0.1, 1, 5, 10, 20]  # 事先确定由平滑到局部的 RBF 核宽度候选
# 初始化 SVM 候选结果列表,逐个保存 gamma 与验证准确率以选择核宽度。
results = []

# 遍历 `gamma_values` 的候选或观测,为“练习1: 探索SVM中RBF核与gamma参数”逐项更新结果。
for g in gamma_values:
    # 建立 RBF 核 SVM,并把本轮候选宽度 `g` 设为 gamma。
    clf_svm_rbf = SVC(kernel='rbf', gamma=g, class_weight='balanced', random_state=42)
    # 在标准化训练集上拟合当前 gamma 的 RBF-SVM,估计支持向量与分类边界供验证选择。
    clf_svm_rbf.fit(X_train_scaled, y_train)
    # 用当前 gamma 的 RBF-SVM 预测固定验证集,保持候选模型使用同一评分样本。
    y_pred_rbf = clf_svm_rbf.predict(X_validation_scaled)
    # 按事先确定 balanced accuracy 计算当前 gamma 的主选择指标。
    balanced_score = balanced_accuracy_score(y_validation, y_pred_rbf)
    # 保存正类召回、辅助准确率与固定标签顺序混淆矩阵,识别全负类退化。
    results.append({'gamma': g, 'balanced_accuracy': balanced_score, 'positive_recall': recall_score(y_validation, y_pred_rbf, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_rbf), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_rbf, labels=[0, 1]).tolist()})

# 输出各组 `gamma` 的验证准确率,比较 RBF 核边界复杂度。
print(pd.DataFrame(results).to_string(index=False))
 gamma  balanced_accuracy  positive_recall  accuracy           confusion_[0,1]
  0.01           0.585055         0.972318  0.235118  [[1129, 4579], [8, 281]]
  0.10           0.594516         0.958478  0.265633 [[1316, 4392], [12, 277]]
  1.00           0.650992         0.920415  0.407537 [[2178, 3530], [23, 266]]
  5.00           0.513403         0.055363  0.927297  [[5545, 163], [273, 16]]
 10.00           0.495949         0.003460  0.940970    [[5642, 66], [288, 1]]
 20.00           0.498752         0.003460  0.946306    [[5674, 34], [288, 1]]
  • 数值来源:以上动态表是当前数据版本的唯一数值来源。
  • 退化判据:正类召回为 0 且 balanced accuracy 为 0.5,即使 Accuracy 很高也属于多数类退化。
  • 证据边界:单个验证窗的变化不能证明模型已经记忆训练数据。

参考实现2:KNN 的近邻数量 K

任务: 在KNN分类器中,将近邻数量 n_neighbors (K) 分别调整为 1, 3, 6, 10, 20,并输出模型的预测表现。

代码
k_values = [1, 3, 6, 10, 20]  # 事先确定由高方差到高平滑的近邻数候选
# 初始化 KNN 候选结果列表,逐个保存近邻数与验证准确率以比较偏差—方差权衡。
results_knn = []

# 遍历 `k in k_values` 的候选超参数,逐个拟合并记录“练习2: 探索KNN中近邻数量K的影响”的评价量。
for k in k_values:
    # 用当前邻居数建立 KNN 分类器,比较局部平滑程度对验证表现的影响。
    clf_knn_k = KNeighborsClassifier(n_neighbors=k)
    # 用标准化训练样本建立当前 `K` 的近邻索引,供验证集分类与模型选择。
    clf_knn_k.fit(X_train_scaled, y_train)
    # 用当前近邻数模型预测固定验证集,隔离 `K` 对分类结果的影响。
    y_pred_k = clf_knn_k.predict(X_validation_scaled)
    # 按事先确定 balanced accuracy 计算当前邻居数的主选择指标。
    balanced_score_k = balanced_accuracy_score(y_validation, y_pred_k)
    # 保存正类召回、辅助准确率与固定标签顺序混淆矩阵,暴露类别不平衡失败。
    results_knn.append({'K': k, 'balanced_accuracy': balanced_score_k, 'positive_recall': recall_score(y_validation, y_pred_k, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_k), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_k, labels=[0, 1]).tolist()})

# 输出各邻居数的验证准确率,识别过小或过大的 K 所造成的偏差。
print(pd.DataFrame(results_knn).to_string(index=False))
 K  balanced_accuracy  positive_recall  accuracy          confusion_[0,1]
 1           0.585237         0.262976  0.876438 [[5180, 528], [213, 76]]
 3           0.597302         0.211073  0.946306  [[5614, 94], [228, 61]]
 6           0.595068         0.204152  0.948307  [[5628, 80], [230, 59]]
10           0.598091         0.211073  0.947807  [[5623, 85], [228, 61]]
20           0.568569         0.141869  0.954144  [[5681, 27], [248, 41]]

动态反馈:按表中 balanced accuracy 选 K;主指标并列时预先取更小 K。Accuracy 只作辅助,不能覆盖正类召回或混淆矩阵暴露的失败。

形成性检查:单次验证分数能证明过拟合吗?

独立作答:若大 gamma 的验证 balanced accuracy 下降,但没有训练分数或重复验证,能否写“模型已经记忆训练数据”?若任意两个 K 的主指标并列,应何时声明 tie-break?

形成性检查中途反馈:结论必须由输出支持

不能证明记忆训练数据,只能报告当前验证窗的条件性变化;tie-break 必须在查看最终测试前预先声明。答错者返回练习 1/2 的完整结果表重写结论。

参考实现3:KNN 的距离度量

任务: KNN分类器默认使用欧几里得距离。请在K=6时,改用曼哈顿距离 (cityblock),并检验结果是否有不同。

代码
# 欧几里得距离 (默认)
knn_euclidean = KNeighborsClassifier(n_neighbors=6, metric='euclidean')
# 用标准化训练样本建立欧氏距离近邻索引,作为距离度量比较基准。
knn_euclidean.fit(X_train_scaled, y_train)
# 用欧氏距离 KNN 预测固定验证集,生成距离度量比较的第一组标签。
y_pred_euc = knn_euclidean.predict(X_validation_scaled)
# 汇总欧氏距离规格的同窗不平衡分类指标。
euclidean_audit = {'distance': 'euclidean', 'balanced_accuracy': balanced_accuracy_score(y_validation, y_pred_euc), 'positive_recall': recall_score(y_validation, y_pred_euc, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_euc), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_euc, labels=[0, 1]).tolist()}

# 曼哈顿距离
knn_manhattan = KNeighborsClassifier(n_neighbors=6, metric='cityblock')
# 用同一训练样本建立曼哈顿距离近邻索引,隔离距离度量对验证表现的影响。
knn_manhattan.fit(X_train_scaled, y_train)
# 用曼哈顿距离 KNN 预测同一验证集,生成只改变距离口径的第二组标签。
y_pred_man = knn_manhattan.predict(X_validation_scaled)
# 汇总曼哈顿距离规格的同窗不平衡分类指标。
manhattan_audit = {'distance': 'cityblock', 'balanced_accuracy': balanced_accuracy_score(y_validation, y_pred_man), 'positive_recall': recall_score(y_validation, y_pred_man, zero_division=0), 'accuracy': accuracy_score(y_validation, y_pred_man), 'confusion_[0,1]': confusion_matrix(y_validation, y_pred_man, labels=[0, 1]).tolist()}
# 并列展示两种距离的动态指标,不写死历史数据版本数字。
display(pd.DataFrame([euclidean_audit, manhattan_audit]))
distance balanced_accuracy positive_recall accuracy confusion_[0,1]
0 euclidean 0.595068 0.204152 0.948307 [[5628, 80], [230, 59]]
1 cityblock 0.595068 0.204152 0.948307 [[5628, 80], [230, 59]]

分析:只比较当前验证窗的 balanced accuracy、正类召回和错误类型;若两者都不胜多数类主指标,则结论是“不采用”,而不是按辅助 Accuracy 强行选优。

独立任务综合反馈

  • gammaK 与距离的结论只来自上方动态表;主指标统一为 balanced accuracy,并同时检查正类召回与混淆矩阵。
  • 候选主指标并列时取更小 gamma 或更小 K;任何候选若正类召回为 0,必须明确标为退化分类器。
  • 只有开发窗主指标严格优于训练期多数类基线才采用复杂候选;否则确定“不采用”,不能借助 Accuracy 或测试结果翻案。
  • 三项全部完成且通过评分标准后,才允许进入一次性最终测试;否则保留 no-increment 并返回开发期修订。

最终最终测试:只评估一次

  1. 用验证集 balanced accuracy 分别选择 RBF-SVM 的 gamma 与 KNN 的 K
  2. 把最佳复杂候选与训练期多数类基线比较,并在打开测试前确定采用路径。
  3. 合并训练与验证数据重拟合所选路径。
  4. 最终测试只查看一次主指标、正类召回与混淆矩阵。
代码
from sklearn.pipeline import Pipeline  # 将最终缩放与分类器封装为同一流程
best_svm_row = max(results, key=lambda row: (row['balanced_accuracy'], -row['gamma']))  # 按主指标选择 gamma,并列取更小值
best_knn_row = max(results_knn, key=lambda row: (row['balanced_accuracy'], -row['K']))  # 按主指标选择 K,并列取更小值
selected_classifier = SVC(kernel='rbf', gamma=best_svm_row['gamma'], class_weight='balanced') if best_svm_row['balanced_accuracy'] >= best_knn_row['balanced_accuracy'] else KNeighborsClassifier(n_neighbors=best_knn_row['K'])  # 确定开发窗赢家
selected_validation_score = max(best_svm_row['balanced_accuracy'], best_knn_row['balanced_accuracy'])  # 取得复杂候选的最佳开发窗主指标
adoption_decision = '采用候选' if selected_validation_score > majority_audit['balanced_accuracy'] else '不采用:未胜多数类基线'  # 在开测试前确定采用门槛
X_test, y_test = X.loc[sealed_indices], y.loc[sealed_indices]  # 决策确定后仅此一次打开最终测试特征与标签
assert len(y_test) > 0 and y_test.nunique() == 2  # 最终测试窗必须非空且含两类,确保指标可解释
if adoption_decision == '采用候选':  # 只有开发窗严格胜多数类时才重训复杂候选
    final_pipeline = Pipeline([('scaler', StandardScaler()), ('classifier', selected_classifier)]).fit(X_development, y_development)  # 只由开发样本拟合缩放与分类器
    sealed_prediction = final_pipeline.predict(X_test)  # 对最终测试期执行一次复杂模型预测
    sealed_model_name = str(selected_classifier)  # 记录实际进入测试检查的复杂规格
else:  # 未过采用门槛时按确定规则执行朴素决策
    development_majority = int(y_development.mode().iloc[0])  # 只由开发期确定最终多数类
    sealed_prediction = np.repeat(development_majority, len(y_test))  # 用确定多数类规则进入一次性测试
    sealed_model_name = f'开发期多数类={development_majority}'  # 明确没有暗中实际使用复杂模型
sealed_audit = {'decision': adoption_decision, 'model': sealed_model_name, 'balanced_accuracy': balanced_accuracy_score(y_test, sealed_prediction), 'positive_recall': recall_score(y_test, sealed_prediction, zero_division=0), 'accuracy': accuracy_score(y_test, sealed_prediction), 'confusion_[0,1]': confusion_matrix(y_test, sealed_prediction, labels=[0, 1]).tolist()}  # 汇总唯一测试证据
print(sealed_audit)  # 报告确定决策与一次性最终测试指标,不据测试结果改选
{'decision': '采用候选', 'model': "SVC(class_weight='balanced', gamma=1)", 'balanced_accuracy': 0.5243141813589363, 'positive_recall': 0.061488673139158574, 'accuracy': 0.9390397712940385, 'confusion_[0,1]': [[11130, 145], [580, 38]]}

开放性思考:广义可加模型(GAM)的应用场景

问题: 请思考在什么具体的(经济学或金融学)场景中我们需要用到广义可加模型?

GAM 场景答案:非线性且需要分项解释

回答思路: GAM 的核心优势在于处理非线性关系的同时保持可解释性。因此,它最适用于那些我们不仅想预测结果,更想理解各个因素如何影响结果的场景。

场景 变量关系 为何适用GAM?
个人薪酬模型 预测薪酬 (y) 经验(x1): 薪酬随经验增长,但增速可能放缓 (非线性)。年龄(x2): 薪酬与年龄可能是倒U型关系。GAM可以分别捕捉这两种效应并展示其曲线。
房地产定价 预测房价 (y) 房屋面积(x1): 存在边际效用递减。楼层(x2): 过低或过高可能不受欢迎,呈非线性关系。建造年份(x3): 老房子可能因历史价值而贵,也可能因老旧而便宜。
信用评分 预测违约概率 (y) 债务收入比(x1): 风险并非线性增加,可能在某个阈值后急剧上升。账户历史长度(x2): 历史过短或过长都可能与风险相关。

阶段小结:我们的非线性工具箱

今天,我们为自己的计量经济学工具箱增添了四件强大的新武器。

  • 样条 (Splines):
    • 通过分段多项式提供了极大的灵活性,是构建更复杂模型的基础。
  • 广义可加模型 (GAMs):
    • 将非线性建模扩展到多变量,同时通过可加性保持了宝贵的可解释性。
  • 支持向量机 (SVM):
    • 基于“最大间隔”思想的强大分类器,通过核技巧能有效处理复杂的非线性边界。
  • K-近邻 (KNN):
    • 一种简单、直观的非参数方法,其核心是基于“邻居”进行投票。

如何选择合适的模型?

模型 主要优点 主要缺点 适用场景
样条/GAM 可解释性强,能灵活捕捉非线性关系 难以捕捉特征交互 当理解变量如何影响结果至关重要时
SVM (核) 对高维数据有效,能构建非常复杂的决策边界 计算成本高,可解释性较差(“黑箱”) 分类问题,特别是特征维度高、边界复杂时
KNN 模型简单直观,非参数 对大数据集预测慢,对特征尺度敏感,需要选择K 数据量不大,需要一个快速的基线模型时

核心思想: 每种模型都有其独特的优势和适用场景。作为经济学家和数据科学家,我们的任务是理解这些工具的原理和权衡,为我们的问题选择最合适的模型。

公开中国数据练习:非线性样条

代码
from pathlib import Path  # 管理公司基础信息数据版本路径
from urllib.request import urlretrieve  # 复用本章已安装的浏览器标识下载器
import numpy as np  # 对发行价做对数变换
import pandas as pd  # 清洗上市日期与发行价格
from sklearn.linear_model import Ridge  # 为线性和样条基准使用同一正则化回归
from sklearn.metrics import mean_absolute_error  # 比较时间外绝对误差
from sklearn.pipeline import make_pipeline  # 绑定变换和模型以避免泄漏
from sklearn.preprocessing import SplineTransformer, StandardScaler  # 构造非线性基函数,并只由训练公司逐特征估计缩放统计量
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择公司基础信息文件。
basic_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/stock_basic_data.h5'), Path('C:/qiufei/data/stock/stock_basic_data.h5'), Path('data/course/stock_basic_data.h5')] if candidate_path.exists()), Path('data/course/stock_basic_data.h5'))
if not basic_path.exists():
    basic_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/stock_basic_data.h5', basic_path)
listing_data = pd.read_hdf(basic_path, key='stock_basic_info', columns=['listed_date', 'issue_price'])  # 选择性读取最小字段
listing_data['listing_year'] = pd.to_datetime(listing_data['listed_date'], errors='coerce').dt.year  # 构造时间变量
listing_data = listing_data.query('issue_price > 0 and 1991 <= listing_year <= 2024').dropna().sort_values('listing_year')  # 固定有效样本和时间顺序
listing_data['log_issue_price'] = np.log(listing_data['issue_price'])  # 缩减目标右偏
training_listings = listing_data.query('listing_year <= 2018')  # 较早上市公司用于拟合
validation_listings = listing_data.query('listing_year > 2018')  # 较晚上市公司用于时间外检查
linear_pipeline = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(training_listings[['listing_year']], training_listings['log_issue_price'])  # 只由训练公司逐特征估计缩放统计量并拟合线性基准
spline_pipeline = make_pipeline(SplineTransformer(n_knots=5, degree=3), Ridge(alpha=1.0)).fit(training_listings[['listing_year']], training_listings['log_issue_price'])  # 拟合样条候选
linear_mae = mean_absolute_error(validation_listings['log_issue_price'], linear_pipeline.predict(validation_listings[['listing_year']]))  # 计算线性时间外误差
spline_mae = mean_absolute_error(validation_listings['log_issue_price'], spline_pipeline.predict(validation_listings[['listing_year']]))  # 计算样条时间外误差
print({'文件': basic_path.name, '训练样本': len(training_listings), '验证样本': len(validation_listings), '线性MAE': round(linear_mae, 4), '样条MAE': round(spline_mae, 4)})  # 输出同窗比较而不预宣称样条胜出
表 3: 中国上市公司发行价的线性与样条时间外比较
{'文件': 'stock_basic_data.h5', '训练样本': 3662, '验证样本': 1740, '线性MAE': 0.6558, '样条MAE': 0.823}

公开中国数据练习:SVM 与 KNN

  • 预测任务:用财年 \(t\) 决策日前最后披露版本的负债率、净利率与收入规模,预测 \(t+1\) 财年首次披露净利润是否为正。
  • 决策时点:当前数据的 info_date 整体约滞后三年,故设为 \(t+4\) 年 6 月 30 日。
  • 时间窗口:2017—2019 特征年训练、2020 验证、2021 最终测试。
  • 标签开放:开发标签截止 2025-06-30;测试标签到 2026-06-30 才打开一次。
  • 解释边界:只验证 PIT 版本选择与最终测试流程,不是实时经营预测,也不作因果或投资解释。
代码
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择财务报表文件。
financial_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/financial_statement.h5'), Path('C:/qiufei/data/stock/financial_statement.h5'), Path('data/course/financial_statement.h5')] if candidate_path.exists()), Path('data/course/financial_statement.h5'))
if not financial_path.exists():
    financial_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
financial_quarters = [f'{year}q4' for year in range(2015, 2024)]  # 确定特征与下一年标签所需年度
financial_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'operating_revenue', 'net_profit_parent_company']  # 只读取分类所需字段
financial_versions = pd.read_hdf(financial_path, key='financial_data', where='quarter in financial_quarters', columns=financial_columns)  # 选择性读取年度财报全部版本
financial_versions['info_date'] = pd.to_datetime(financial_versions['info_date'])  # 统一披露日类型
financial_versions['year'] = financial_versions['quarter'].str[:4].astype(int)  # 提取财年用于点时对齐
financial_duplicate_keys = int(financial_versions.groupby(['order_book_id', 'year']).size().gt(1).sum())  # 统计存在重发的公司财年键
feature_versions = financial_versions.query('2017 <= year <= 2021').copy()  # 确定五个特征财年候选版本
feature_versions['decision_date'] = pd.to_datetime((feature_versions['year'] + 4).astype(str) + '-06-30')  # 按数据版本实际披露滞后确定逐公司年决策日
eligible_feature_versions = feature_versions.query('info_date <= decision_date').copy()  # 只保留决策日已披露版本
point_in_time_features = eligible_feature_versions.sort_values(['order_book_id', 'year', 'info_date']).drop_duplicates(['order_book_id', 'year'], keep='last')  # 截止日内选择最后可得版本
label_versions = financial_versions.rename(columns={'year': 'target_year', 'info_date': 'label_info_date', 'net_profit_parent_company': 'next_profit'}).copy()  # 建立下一财年标签版本池
first_release_labels = label_versions.sort_values(['order_book_id', 'target_year', 'label_info_date']).drop_duplicates(['order_book_id', 'target_year'], keep='first')  # 目标只取首次披露版本

点时特征与标签实现日必须有序

代码
point_in_time_features['target_year'] = point_in_time_features['year'] + 1  # 对齐严格相邻的下一财年标签
classification_panel = point_in_time_features.merge(first_release_labels[['order_book_id', 'target_year', 'label_info_date', 'next_profit']], on=['order_book_id', 'target_year'], how='inner', validate='one_to_one')  # 一对一合并点时特征与首次披露标签
classification_panel['score_cutoff'] = pd.to_datetime(np.where(classification_panel['year'].le(2020), '2025-06-30', '2026-06-30'))  # 确定开发选择与最终测试评分截止日
classification_panel = classification_panel.query('decision_date < label_info_date <= score_cutoff').copy()  # 标签必须在决策后实现且在对应评分日已知
classification_panel['debt_ratio'] = classification_panel['total_liabilities'] / classification_panel['total_assets']  # 构造偿债压力特征
classification_panel['net_margin'] = classification_panel['net_profit_parent_company'] / classification_panel['operating_revenue']  # 构造盈利质量特征
classification_panel['log_revenue'] = np.log(classification_panel['operating_revenue'].clip(lower=1))  # 缩减公司规模偏态
classification_panel['next_profit_positive'] = classification_panel['next_profit'].gt(0).astype(int)  # 用首次披露利润固定正类语义
classification_panel = classification_panel.replace([np.inf, -np.inf], np.nan).dropna(subset=['debt_ratio', 'net_margin', 'log_revenue', 'next_profit']).copy()  # 删除不可计算特征与缺失标签
assert len(classification_panel) > 0, '点时相邻财年分类面板为空'  # 阻止空表让时点断言真空通过
assert not classification_panel.duplicated(['order_book_id', 'year']).any(), '点时公司—特征年键不唯一'  # 强制观察单位唯一
assert classification_panel['info_date'].le(classification_panel['decision_date']).all(), '存在决策日后特征版本'  # 阻止未来修订进入特征
assert classification_panel['label_info_date'].gt(classification_panel['decision_date']).all(), '标签在决策日前已实现'  # 保证预测先于目标实现
assert classification_panel['label_info_date'].le(classification_panel['score_cutoff']).all(), '标签晚于评分截止日'  # 保证各阶段只用当时已知标签
classification_features = ['debt_ratio', 'net_margin', 'log_revenue']  # 确定模型输入顺序
print({'文件': financial_path.name, '键': 'financial_data', '重复公司财年键': financial_duplicate_keys, '决策日前候选版本': len(eligible_feature_versions), '点时特征行': len(point_in_time_features), '移除特征后续版本': len(eligible_feature_versions) - len(point_in_time_features), '首次披露标签行': len(first_release_labels), '最终样本': len(classification_panel)})  # 报告版本与点时筛选流量
{'文件': 'financial_statement.h5', '键': 'financial_data', '重复公司财年键': 0, '决策日前候选版本': 25823, '点时特征行': 25823, '移除特征后续版本': 0, '首次披露标签行': 45943, '最终样本': 1270}

SVM/KNN 验证选择

代码
from sklearn.metrics import balanced_accuracy_score  # 处理类别不平衡的同窗比较
from sklearn.pipeline import Pipeline  # 将缩放与分类器绑定以防泄漏
training_classification = classification_panel.query('year <= 2019')  # 较早公司财年用于训练
validation_classification = classification_panel.query('year == 2020')  # 2020年特征对应2021盈利标签,用于选模型
testing_classification = classification_panel.query('year == 2021')  # 2021年特征对应2022盈利标签,作为最终测试
assert all(window['next_profit_positive'].nunique() == 2 for window in [training_classification, validation_classification]), '训练或验证窗口缺少一个类别'  # 开测试前只检查开发期类别可解释性
candidate_classifiers = {'RBF-SVM': SVC(kernel='rbf', C=1.0, gamma='scale'), 'KNN-7': KNeighborsClassifier(n_neighbors=7)}  # 事先确定两项章内核心方法
training_majority_class = int(training_classification['next_profit_positive'].mode().iloc[0])  # 只由训练窗确定朴素类别
baseline_validation_predictions = np.repeat(training_majority_class, len(validation_classification))  # 构造共同验证窗多数类基线
validation_rows = [{'model': '训练窗多数类', 'balanced_accuracy': balanced_accuracy_score(validation_classification['next_profit_positive'], baseline_validation_predictions), 'positive_recall': recall_score(validation_classification['next_profit_positive'], baseline_validation_predictions, zero_division=0), 'confusion_[0,1]': confusion_matrix(validation_classification['next_profit_positive'], baseline_validation_predictions, labels=[0, 1]).tolist()}]  # 先登记基线门槛
for model_name, classifier in candidate_classifiers.items():  # 对两项方法使用相同时间窗口
    candidate_pipeline = Pipeline([('scaler', StandardScaler()), ('classifier', classifier)])  # 每个候选只由训练窗样本逐特征估计均值与标准差
    candidate_pipeline.fit(training_classification[classification_features], training_classification['next_profit_positive'])  # 拟合较早公司财年
    validation_predictions = candidate_pipeline.predict(validation_classification[classification_features])  # 生成2020特征窗预测
    validation_rows.append({'model': model_name, 'balanced_accuracy': balanced_accuracy_score(validation_classification['next_profit_positive'], validation_predictions), 'positive_recall': recall_score(validation_classification['next_profit_positive'], validation_predictions, zero_division=0), 'confusion_[0,1]': confusion_matrix(validation_classification['next_profit_positive'], validation_predictions, labels=[0, 1]).tolist()})  # 保存验证指标与固定标签混淆矩阵
validation_table = pd.DataFrame(validation_rows)  # 形成模型选择表
selected_local_row = validation_table.query("model != '训练窗多数类'").sort_values(['balanced_accuracy', 'model'], ascending=[False, True]).iloc[0]  # 候选并列时按名称固定顺序
baseline_validation_score = float(validation_table.query("model == '训练窗多数类'")['balanced_accuracy'].iloc[0])  # 读取共同朴素门槛
local_adoption_decision = '采用候选' if selected_local_row['balanced_accuracy'] > baseline_validation_score else '不采用:未胜多数类基线'  # 在开测试前确定采用决定
selected_local_classifier = selected_local_row['model']  # 确定复杂候选规格供完成要求时重训
development_class_flow = classification_panel.query('year <= 2020').assign(窗口=np.where(classification_panel.query('year <= 2020')['year'].le(2019), '训练', '验证')).groupby('窗口', sort=False)['next_profit_positive'].agg(样本数='size', 正类数='sum', 正类率='mean')  # 只汇总开发期类别流
display(development_class_flow.round(4)); print({'最终测试样本数': len(testing_classification), '标签状态': '未打开'})  # 测试窗在确定前只交样本数
display(validation_table.round(4))  # 展示全部候选而非只报赢家
print({'确定候选': selected_local_classifier, '确定决定': local_adoption_decision, '验证多数类门槛': round(baseline_validation_score, 4)})  # 在打开最终测试期前记录完整决策
{'最终测试样本数': 63, '标签状态': '未打开'}
{'确定候选': 'KNN-7', '确定决定': '采用候选', '验证多数类门槛': 0.5}
表 4: 点时盈利方向任务的候选与多数类验证窗比较
样本数 正类数 正类率
窗口
训练 1078 987 0.9156
验证 129 117 0.9070
model balanced_accuracy positive_recall confusion_[0,1]
0 训练窗多数类 0.5000 1.0 [[0, 12], [0, 117]]
1 RBF-SVM 0.5417 1.0 [[1, 11], [0, 117]]
2 KNN-7 0.5417 1.0 [[1, 11], [0, 117]]

SVM/KNN 最终测试与结果不理想时如何解释

代码
development_classification = classification_panel.query('year <= 2020')  # 合并训练与验证公司财年用于最终重训
majority_class = int(development_classification['next_profit_positive'].mode().iloc[0])  # 只由开发期确定多数类
majority_class_predictions = np.repeat(majority_class, len(testing_classification))  # 构造同窗朴素基线
if local_adoption_decision == '采用候选':  # 只有验证窗严格胜基线才允许复杂候选进入最终测试
    final_classifier = candidate_classifiers[selected_local_classifier]  # 读取已确定的复杂候选规格
    final_classification_pipeline = Pipeline([('scaler', StandardScaler()), ('classifier', final_classifier)]).fit(development_classification[classification_features], development_classification['next_profit_positive'])  # 仅由开发窗重拟合完整流程
    sealed_class_predictions = final_classification_pipeline.predict(testing_classification[classification_features])  # 一次性预测最终测试标签
    deployed_model_name = selected_local_classifier  # 记录实际进入最终测试的复杂候选
else:  # 未过门槛时执行事先确定的朴素路径
    sealed_class_predictions = majority_class_predictions  # 不暗中测试复杂候选
    deployed_model_name = '开发期多数类'  # 明确最终测试执行路径为基线
sealed_labels = testing_classification['next_profit_positive'].copy()  # 确定路径完成后仅此一次打开最终测试标签
assert len(sealed_labels) > 0 and sealed_labels.nunique() == 2  # 确认最终测试窗非空且两类指标可定义
sealed_rows = [{'模型': deployed_model_name, '角色': '确定执行路径', 'balanced_accuracy': balanced_accuracy_score(sealed_labels, sealed_class_predictions), 'positive_recall': recall_score(sealed_labels, sealed_class_predictions, zero_division=0), 'confusion_[0,1]': confusion_matrix(sealed_labels, sealed_class_predictions, labels=[0, 1]).tolist()}, {'模型': '开发期多数类', '角色': '同窗参考基线', 'balanced_accuracy': balanced_accuracy_score(sealed_labels, majority_class_predictions), 'positive_recall': recall_score(sealed_labels, majority_class_predictions, zero_division=0), 'confusion_[0,1]': confusion_matrix(sealed_labels, majority_class_predictions, labels=[0, 1]).tolist()}]  # 汇总测试主指标、正类召回和固定标签混淆矩阵
display(pd.DataFrame(sealed_rows))  # 展示唯一最终测试执行路径与参考基线,不据测试翻案
表 5: 确定路径与多数类参考的2022盈利方向最终测试检查
模型 角色 balanced_accuracy positive_recall confusion_[0,1]
0 KNN-7 确定执行路径 0.576923 1.0 [[2, 11], [0, 50]]
1 开发期多数类 同窗参考基线 0.500000 1.0 [[0, 13], [0, 50]]
  • 停止条件:任一窗口只有单一类别、样本为空,或验证候选未胜多数类。
  • 执行路径:确定“不采用”,让多数类路径进入最终测试。
  • 测试纪律:测试结果不得推翻开发期决定。
  • 后续改进:扩大点时样本或重新定义业务标签,不得改用随机切分。

本章小结

  • 能在确定候选上比较 gamma、K 与同窗基线,并明确并列处理规则。
  • 单窗分数只与复杂度机制相容,不足以诊断过拟合;需要训练—验证比较结果或重复时间外窗。
  • 当前公开结果不证明某模型家族普遍更优。
  • 下一必修章进入第 7 章正则化:验证说明已在第 6 章建立,现在把复杂度控制放入同一开发窗。