4  分类 (Classification)

4.1 导读

分类方法回答“属于哪一类”以及“属于某一类的概率是多少”。在信用风险、客户流失和上市公司风险预警中,概率质量、类别排序与最终行动是三个不同问题。本章从逻辑回归出发,再比较 LDA、QDA 与朴素贝叶斯;真实 A 股案例用于评价模型,受控模拟只用于展示决策边界的几何机制。

4.2 学习目标

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

  1. 在概率、几率、对数几率和线性预测子之间转换。
  2. 比较逻辑回归、LDA、QDA 与朴素贝叶斯的分布假设和决策边界。
  3. 区分 AUROC/PR-AUC、Brier 分数、校准与分类代价。
  4. 只利用训练期验证证据选择代价敏感阈值。
  5. 为未来风险预测定义事件日、信息可得日与时间切分,避免把同期关联误写成预测。

4.3 引言 (Introduction)

分类问题(classification problems)是统计学习和机器学习中最重要的问题类型之一。与回归问题(响应变量是定量的)不同,分类问题的响应变量是定性的(qualitative)或分类的(categorical)。

分类 vs. 回归的对比

特征 回归 (Regression) 分类 (Classification)
响应变量 \(Y\) 定量(如收入、房价、销售量) 定性(如是否违约、涨跌方向、信用等级)
预测目标 预测具体的数值 估计类别概率,再由决策规则映射为标签或行动
评估指标 MSE, \(R^2\) 对数损失、AUROC/PR-AUC、Brier/校准、分类代价
示例 预测房价、预测销量 信用评分、股票涨跌预测、客户流失判别

独立示例:A股市场涨跌预测

本章后半部分使用本地真实 A 股行情评价涨跌概率模型。此前的 OLS—Logit 与 LDA—QDA 图形使用受控模拟,只展示概率范围和决策边界的机制,不能作为市场可预测性的证据。投资者希望根据过去几天的市场表现估计下一交易日上涨(Up)的概率,这有助于:

  1. 量化交易:构建自动化交易策略
  2. 风险管理:及时对冲市场下行风险
  3. 市场择时:优化买卖时机

数据集:本示例使用 BOOK_DATA_DIR 下的本地 A 股日度行情,涵盖以下变量:

  • 方向 (Direction):响应变量,1=上涨,0=下跌
  • Lag1:前一日的收益率(%)
  • Lag2:前两日的收益率(%)
  • Volume:成交量(手)
  • Today:当日收益率(我们希望预测其符号)

线性判别分析的经典形式及其鸢尾花示例可追溯到 Fisher (Fisher 1936年)。本章不按历史年表展开,而是比较逻辑回归、LDA、QDA 与朴素贝叶斯在假设、概率输出和决策边界上的差异。

4.4 分类问题概述 (An Overview of Classification)

在分类问题中,我们有一个响应变量\(Y\),它取值为\(K\)个不同的类别。令\(\mathcal{G} = \{1, 2, \ldots, K\}\)表示这\(K\)个类别的集合。

目标:基于预测变量\(X = (X_1, X_2, \ldots, X_p)\),构建一个分类器\(C(X)\),使得\(C(X)\)能够准确预测\(Y\)的类别。

4.4.1 两分类 vs. 多分类

  • 两分类问题 (Binary Classification):\(K = 2\)。例如:
    • 是否违约(是/否)
    • 股票涨跌方向(涨/跌)
    • 客户是否流失(是/否)
  • 多分类问题 (Multi-class Classification):\(K > 2\)。例如:
    • 信用等级评定(AAA/AA/A/BBB等)
    • 行业分类(金融、科技、消费、制造等)
    • 投资评级(买入、持有、卖出)

:大多数分类方法最初是为两分类问题设计的,但可以通过”一对多”(One-vs-Rest)或”一对一”(One-vs-One)策略扩展到多分类问题。

4.4.2 为什么不能直接使用线性回归? (Why Not Linear Regression?)

一个自然的想法是:既然我们已经学习了线性回归,为什么不能对分类问题也使用线性回归呢?

问题1:编码方式影响结果

假设我们有一个三分类问题(类别A、B、C),我们尝试将它们编码为数字:

  • 方案1:A=1, B=2, C=3
  • 方案2:A=1, B=0, C=-1

不同的编码方式会导致完全不同的回归结果,这在定性变量的情况下是不合理的。

问题2:预测值超出类别范围

假设我们用0/1编码来预测违约(0=未违约,1=违约),线性回归模型可能预测出\(\hat{Y} = 1.2\)\(\hat{Y} = -0.3\)这样的值,这些值没有明确的解释。

问题3:难以处理多分类问题

对于多于两个类别的情况,使用线性回归需要创建多个哑变量,且预测结果可能不属于任何一类。

图 4.1 展示了为什么线性回归不适合分类问题。

下面用模拟数据比较 OLS 的无界拟合值与逻辑回归的概率输出。逻辑回归对线性预测子(log-odds)保持线性,通过 sigmoid 映射得到 \([0,1]\) 内的概率;“概率映射非线性”不等于决策边界在所有设定下都是非线性的。

# 在同一受控二元样本上比较无界线性拟合与合法概率拟合

import numpy as np  # 生成两类受控二维样本并计算逻辑函数
import pandas as pd  # 将模拟分类结果组织为可核对表格
import matplotlib.pyplot as plt  # 对照概率边界与硬分类边界
# LinearRegression:普通最小二乘线性回归(用于回归问题)
# LogisticRegression:逻辑回归(专门用于分类问题)
from sklearn.linear_model import LinearRegression, LogisticRegression  # 对比无界线性拟合与伯努利概率模型

# 设置随机种子为 42,确保每次运行结果完全一致(可复现性)
np.random.seed(42)  # 设置随机种子确保结果可复现

# 生成已知逻辑概率机制的二元响应样本
# 模拟 200 个交易日的数据(每个数据点代表一天)
sample_count = 200  # 设定模拟交易日数量为200天
# 生成前一日收益率 Lag1,服从均值为 0、标准差为 1.5% 的正态分布
# 这是模拟股市日收益率的合理分布特征
daily_lag1_return = np.random.normal(0, 1.5, sample_count)  # 生成正态分布随机样本

# 设定真实的数据生成机制:涨跌概率取决于前一日收益率 Lag1
# 对数几率(log-odds) = -0.5 + 0.8 * Lag1
# 即:前一日收益率越高,今日上涨的对数几率越大
log_odds = -0.5 + 0.8 * daily_lag1_return  # 按DGP计算对数几率:截距-0.5 + 斜率0.8 × Lag1
# 通过 sigmoid 函数将对数几率转换为上涨概率,范围严格在 (0, 1) 之间
upward_probability = 1 / (1 + np.exp(-log_odds))  # 计算指数值
# 根据上涨概率进行伯努利抽样,生成实际的涨跌方向
# 1 = 上涨(Up),0 = 下跌(Down)
market_direction = np.random.binomial(1, upward_probability)  # 按概率进行伯努利抽样得到涨跌标签(1=涨,0=跌)

# 用平方损失拟合二元标签以暴露概率越界
# 用平方损失拟合二元标签以展示概率越界风险
linear_regression_model = LinearRegression()  # 以无边界直线作为不适合概率任务的对照
# 用 Lag1 作为自变量 X,涨跌方向(0/1)作为因变量 Y 进行拟合
# reshape(-1, 1) 将一维数组转为二维列向量,这是 sklearn 的输入格式要求
linear_regression_model.fit(daily_lag1_return.reshape(-1, 1), market_direction)  # 对模拟方向标签做最小二乘投影
# 生成一组均匀分布的 x 值,用于绘制平滑的预测曲线
# 在Lag1的取值范围内均匀生成300个点,用于绘制平滑的预测曲线
x_range = np.linspace(daily_lag1_return.min(), daily_lag1_return.max(), 300).reshape(-1, 1)
# 线性回归的预测值:注意这些值可能小于 0 或大于 1!
linear_predictions = linear_regression_model.predict(x_range)  # 在扩展网格上暴露直线越出概率区间的问题

# 用伯努利似然拟合同一响应作为概率对照
# 用逻辑链接把二元响应拟合值限制在概率区间
logistic_regression_model = LogisticRegression()  # 以有界逻辑概率作为分类对照
# 同样用 Lag1 预测涨跌方向
logistic_regression_model.fit(daily_lag1_return.reshape(-1, 1), market_direction)  # 在同一模拟样本上估计逻辑概率
# predict_proba 返回每个类别的概率,[:, 1] 取 "上涨" 类(类别1)的概率
# 逻辑回归的预测值始终在 [0, 1] 区间内,可以解释为概率
logistic_predictions = logistic_regression_model.predict_proba(x_range)[:, 1]  # 预测类别概率

上面的代码完成了数据生成和两个模型的训练:我们创建了200个模拟交易日的涨跌数据,然后分别用线性回归逻辑回归对同一份二元分类数据进行拟合。线性回归产生一条直线预测,逻辑回归产生一条S形曲线预测。下面的代码通过并排对比图来直观展示两者的关键差异——为什么线性回归不适合处理分类问题。

# 在共同横轴上比较两类拟合的取值范围
# 创建 1 行 2 列的子图,图像大小为 16×6 英寸
fig, axes = plt.subplots(1, 2, figsize=(16, 6))  # 并排保持相同样本与横轴便于比较拟合范围

# --- 左图:线性回归(展示其缺陷)---
ax1 = axes[0]  # 获取左侧子图(线性回归)
# 绘制实际观测数据点:灰色散点,0=下跌,1=上涨
# 绘制涨跌观测值散点:0=下跌聚集在底部,1=上涨聚集在顶部
ax1.scatter(daily_lag1_return, market_direction, alpha=0.4, s=60, color='gray', label='Observed (0=Down, 1=Up)')
# 绘制线性回归拟合线:红色直线
# 问题:预测值可以超出 [0,1] 的范围,无法解释为概率
ax1.plot(x_range, linear_predictions, 'r-', linewidth=2.5, label='Linear Fit')  # 展示无界线性拟合如何越过概率范围
# 绘制 y=0 和 y=1 的虚线参考线,清晰标示概率的合理边界
ax1.axhline(0, color='k', ls='--'); ax1.axhline(1, color='k', ls='--')  # 添加水平参考线
ax1.set_xlabel('Lag1 Return (%)', fontsize=12)  # 标明横轴为受控滞后收益输入
ax1.set_ylabel('Predicted Direction', fontsize=12)  # 标明纵轴为未约束的线性拟合值
ax1.set_title('Linear Regression Issue', fontsize=14)  # 标明左图展示二元响应线性拟合的越界问题
ax1.legend()  # 区分二元观测、拟合直线与概率边界

# --- 右图:逻辑回归(展示其优势)---
ax2 = axes[1]  # 获取右侧子图(逻辑回归)
# 绘制同样的实际观测数据点
# 绘制涨跌观测值散点作为对照参考
ax2.scatter(daily_lag1_return, market_direction, alpha=0.4, s=60, color='gray', label='Observed')
# 绘制逻辑回归拟合曲线:蓝色 S 形曲线(sigmoid)
# 优势:预测值自动限制在 [0,1] 内,天然可解释为上涨概率
# 绘制逻辑回归S形概率曲线:预测值始终限制在[0,1]区间
ax2.plot(x_range, logistic_predictions, 'b-', linewidth=2.5, label='Logistic Fit (Prob)')
# 同样绘制概率边界参考线
ax2.axhline(0, color='k', ls='--'); ax2.axhline(1, color='k', ls='--')  # 添加水平参考线
ax2.set_xlabel('Lag1 Return (%)', fontsize=12)  # 保持与线性对照相同的输入尺度
ax2.set_ylabel('Probability of Up', fontsize=12)  # 明确逻辑链接输出为上涨概率
ax2.set_title('Logistic Regression Solution', fontsize=14)  # 标明右图展示逻辑链接的概率约束
ax2.legend()  # 区分二元观测、概率曲线与概率边界

plt.tight_layout()  # 避免两面板的轴标签与标题相互遮挡
# 输出共享样本上的两类拟合供概率边界比较
plt.show()  # 渲染线性与逻辑拟合的并排证据
左侧线性拟合穿过二元涨跌点并超出概率范围,右侧 S 形逻辑曲线始终位于零到一之间。
图 4.1: 线性回归 vs 逻辑回归在股票涨跌预测上的对比。左图:线性回归的预测值超出[0,1]范围。右图:逻辑回归将预测映射为[0,1]区间的概率值。

图 4.1 可以清楚地看到:

  1. 线性回归(左图):
    • 产生的预测值可以小于0或大于1,这在概率意义上没有意义
    • 对离群点敏感
    • 无法刻画类别之间的非线性边界
  2. 逻辑回归(右图):
    • 通过S形曲线(sigmoid function)将预测值限制在\((0,1)\)区间
    • 在模型设定与校准充分时可解释为概率
    • 并不天然对离群点或高杠杆点鲁棒;极端特征、标签异常和完全分离都可能显著改变估计

逻辑回归避免的是 OLS 概率越界问题,而不是自动解决异常值问题。实际应用应检查影响点和分离,必要时采用正则化、稳健诊断或重新审视数据质量。

4.5 逻辑回归 (Logistic Regression)

逻辑回归(Logistic Regression)是最广泛使用的分类方法之一,尽管名称中包含”回归”,但它实际上是一种分类方法。逻辑回归通过建模\(P(Y=1|X)\)来预测概率。

4.5.1 逻辑模型 (The Logistic Model)

在逻辑回归中,我们使用逻辑函数(logistic function)来建模\(Y=1\)的概率:

\[ p(X) = P(Y=1|X) = \frac{e^{\beta_0 + \beta_1 X}}{1 + e^{\beta_0 + \beta_1 X}} \tag{4.1}\]

这个函数也称为S形函数(sigmoid function),因为它呈现S形状。

Tip: 理解逻辑函数的推导

逻辑函数的推导可以从对数几率(log-odds)的概念开始:

  1. 几率(Odds):事件发生的概率与不发生的概率之比 \[ \text{Odds} = \frac{p}{1-p} \]

  2. 对数几率(Log-odds/Logit):几率的对数 \[ \text{logit}(p) = \log\left(\frac{p}{1-p}\right) \]

  3. 关键假设:对数几率与\(X\)呈线性关系 \[ \log\left(\frac{p(X)}{1-p(X)}\right) = \beta_0 + \beta_1 X \]

  4. 解出\(p(X)\)\[ \frac{p(X)}{1-p(X)} = e^{\beta_0 + \beta_1 X} \] \[ p(X) = e^{\beta_0 + \beta_1 X}(1-p(X)) \] \[ p(X)(1 + e^{\beta_0 + \beta_1 X}) = e^{\beta_0 + \beta_1 X} \] \[ p(X) = \frac{e^{\beta_0 + \beta_1 X}}{1 + e^{\beta_0 + \beta_1 X}} \]

这就是逻辑函数!它将线性预测\((-\infty, +\infty)\)映射到概率区间\((0, 1)\)

逻辑函数的性质

  1. 取值范围\(p(X) \in (0, 1)\),适合概率解释
  2. 单调性:如果\(\beta_1 > 0\)\(p(X)\)\(X\)增加而单调递增
  3. 对称性\(p(-x) = 1 - p(x)\)(当\(\beta_0 = 0\)时)
  4. S形曲线:在中间变化快,两端趋近于0和1

补充说明:逻辑回归到底是怎样一步一步做分类的

很多同学在第一次接触逻辑回归时,会把“公式会写”和“算法会用”混为一谈。真正做分类时,逻辑回归至少经历下面四步:

  1. 把多个特征压缩成一个线性分数。模型先计算 \[ z = \beta_0 + \beta_1 x_1 + \cdots + \beta_p x_p. \] 这个 \(z\) 可以理解成“样本有多像正类”的原始打分,但它本身不是概率,可能是任意实数。

  2. 把线性分数映射为概率。通过 sigmoid 函数把 \(z\) 压缩到 \((0,1)\) 区间,得到 \[ p(x)=\frac{e^z}{1+e^z}. \] 于是,模型就能回答“这个客户违约的概率大概是多少”这样的问题。

  3. 计算伯努利对数似然贡献。如果某个真实违约客户的预测概率只有 0.15,该观测贡献 \(\log(0.15)\);如果某个正常客户被预测为 0.90,该观测贡献 \(\log(1-0.90)\)。这两个值都会显著降低总对数似然。这里衡量的是概率与观测结果的对数评分,不是把概率先切成类别后计算的分类错误率。

  4. 优化明确的似然目标。最大似然估计选择使伯努利对数似然最大的参数,等价地最小化 式 4.2 所示的负对数似然:

    \[ -\ell(\boldsymbol{\beta})=-\sum_{i=1}^{n}\left[y_i\log p_i+(1-y_i)\log(1-p_i)\right]. \tag{4.2}\]

    Newton–Raphson、迭代加权最小二乘、拟牛顿法或一阶梯度法都可以作为数值求解手段;MLE 定义的是目标函数,而不是某一种更新算法。求解器在收敛且有限解存在时得到同一目标的极值,完全分离等情形则需要另行诊断或正则化。

因此,逻辑回归的优势不只在于能给出分类结果,还在于它能给出可解释的概率输出。在风控、医疗诊断和营销转化率预测中,这个概率往往比单纯的“判0还是判1”更有业务价值,因为它允许我们结合成本函数自行选择阈值。

4.5.2 估计回归系数 (Estimating the Regression Coefficients)

与线性回归使用最小二乘法不同,逻辑回归使用最大似然估计(Maximum Likelihood Estimation, MLE)来估计系数。

似然函数 (Likelihood Function):

假设我们有\(n\)个独立的观测\((x_1, y_1), \ldots, (x_n, y_n)\),其中\(y_i \in \{0, 1\}\)。似然函数为:

\[ L(\beta_0, \beta_1) = \prod_{i: y_i=1} p(x_i) \prod_{i: y_i=0} (1 - p(x_i)) \tag{4.3}\]

这可以写成更简洁的形式:

\[ L(\beta_0, \beta_1) = \prod_{i=1}^{n} p(x_i)^{y_i} (1 - p(x_i))^{1-y_i} \tag{4.4}\]

对数似然函数 (Log-Likelihood):

\[ \ell(\beta_0, \beta_1) = \sum_{i=1}^{n} [y_i \log p(x_i) + (1-y_i) \log(1-p(x_i))] \tag{4.5}\]

最大似然估计选择使对数似然函数最大的 \((\hat{\beta}_0, \hat{\beta}_1)\)

\[ (\hat{\beta}_0, \hat{\beta}_1) = \arg\max_{\beta_0, \beta_1} \ell(\beta_0, \beta_1) \tag{4.6}\]

数学推导:逻辑回归的梯度公式

为了优化这个对数似然函数,我们需要对其求导。令 \(p_i = p(x_i) = \sigma(\beta_0 + \beta_1 x_i)\)。 首先,回忆 logistic 函数的导数性质:\(\frac{\partial p_i}{\partial \beta_1} = p_i(1 - p_i)x_i\)

\(\ell(\beta_0, \beta_1)\) 关于 \(\beta_1\) 求偏导:

\[ \begin{aligned} \frac{\partial \ell}{\partial \beta_1} &= \sum_{i=1}^{n} \left[ \frac{y_i}{p_i} - \frac{1-y_i}{1-p_i} \right] \frac{\partial p_i}{\partial \beta_1} \\ &= \sum_{i=1}^{n} \left[ \frac{y_i(1-p_i) - (1-y_i)p_i}{p_i(1-p_i)} \right] p_i(1-p_i)x_i \\ &= \sum_{i=1}^{n} (y_i - y_i p_i - p_i + y_i p_i)x_i \\ &= \sum_{i=1}^{n} (y_i - p_i)x_i \end{aligned} \]

这个梯度具有直观含义:\((y_i-p_i)\) 是观测结果与预测概率之差,但它不是 0—1 分类错误。对数似然的得分方程要求特征加权残差之和为零;一阶优化会沿得分方向更新,而 Newton–Raphson 等方法还会利用曲率信息。

Clarification on MLE vs. OLS

最小二乘法(OLS)和最大似然估计(MLE)是两种不同的参数估计方法:

特性 OLS (线性回归) MLE (逻辑回归)
目标 最小化残差平方和 最大化似然函数
解析解(闭式) 无解析解,需迭代优化
概率假设的作用 正态误差只在有限样本似然推断或 OLS=MLE 时需要;OLS 点估计本身不要求正态 条件响应服从伯努利分布,概率由 logit 线性预测子给出
计算效率 高(直接计算) 较低(迭代算法如Newton-Raphson)
适用范围 平方损失下的线性条件均值估计 任何已明确指定似然的参数概率模型;逻辑回归是其中一个例子

OLS 是平方损失下的估计准则:给定满秩设计矩阵即可计算点估计。零条件均值保证无偏,一致性和标准误还需要相应的抽样条件;误差正态仅使经典同方差线性模型的 OLS 同时成为 MLE,并给出精确有限样本 \(t/F\) 推断。逻辑回归直接指定伯努利条件分布,MLE 因而是自然估计法;MLE 本身不是“非线性模型”的同义词。

案例应用:预测A股市场涨跌

让我们使用真实的A股日度行情数据来构建逻辑回归模型。

下面读取浦发银行(600000.XSHG)的真实日度行情,计算收益率并构造过去三天的滞后收益作为特征。标签表示下一交易日是否上涨;数据按日期切分,逻辑回归用 MLE 拟合。输出的系数和 \(p\) 值描述该模型与样本中的条件关系,是否有预测价值还要看未来测试期的概率损失与基线比较。

# 用真实A股行情估计过去三日收益与当日方向的条件关联

import numpy as np  # 构造滞后收益并清洗非有限值
import pandas as pd  # 读取并按交易日整理 A 股日行情
import statsmodels.api as sm  # 估计带截距的二项 Logit 并输出推断诊断
import os  # 将在线教材的固定数据根同步给本章后续独立代码块
from pathlib import Path  # 用路径对象解析数据根目录

# 从统一数据根读取浦发银行日行情
BOOK_DATA_DIR = Path('/home/ubuntu/r2_data_mount/data').resolve()  # 明文构造在线教材的绝对数据根
os.environ['BOOK_DATA_DIR'] = str(BOOK_DATA_DIR)  # 为后续 ST 案例与共享样本入口登记同一路径
assert BOOK_DATA_DIR.is_dir(), f'BOOK_DATA_DIR 不存在: {BOOK_DATA_DIR}'  # 在读取前验证目录
# 本地数据路径:前复权的日度行情数据(h5 是一种高效的二进制存储格式)
stock_price_path = BOOK_DATA_DIR / 'stock/stock_price_pre_adjusted.h5'  # 定位浦发银行滞后收益所需前复权行情
assert stock_price_path.is_file(), f'缺少前复权行情文件: {stock_price_path};请检查 BOOK_DATA_DIR'  # 在HDF查询前定位本例缺失输入
# 按股票代码选择性读取浦发银行的日度行情数据,避免全量载入全市场价格面板
stock_price_data = pd.read_hdf(  # 从本地h5文件中按股票代码选择性读取浦发银行行情数据
    stock_price_path,  # 使用已通过存在性检查的行情输入
    where="order_book_id='600000.XSHG'",  # 仅保留浦发银行的历史交易记录
    columns=['date', 'order_book_id', 'close']  # 只读取时序建模需要的日期、股票代码与收盘价字段
).reset_index()  # 将MultiIndex重置为普通列,便于后续排序与特征工程

# 筛选浦发银行(代码 600000.XSHG)的历史数据
# 浦发银行是上海的大型股份制商业银行,交易活跃,数据完整
# 筛选浦发银行并创建副本避免修改原始数据
stock_price_data = stock_price_data[stock_price_data['order_book_id'] == '600000.XSHG'].copy()
# 按交易日期排序,确保时序正确(时间序列分析的基本要求)
stock_price_data = stock_price_data.sort_values('date')  # 固定时间顺序以防收益率跨期错位

# 构造截至预测日可得的三项滞后收益特征
# 计算日收益率(百分比形式):(今日收盘价 - 昨日收盘价) / 昨日收盘价 × 100
# pct_change() 是 pandas 内置的百分比变化计算函数
stock_price_data['pct_chg'] = stock_price_data['close'].pct_change() * 100  # 用相邻收盘价形成百分数口径的一日收益

# 构造滞后特征:用过去几天的收益率作为预测因子
# Lag1:前一个交易日的收益率(shift(1) 表示向下移动 1 行,即取前一天的值)
stock_price_data['Lag1'] = stock_price_data['pct_chg'].shift(1)  # 构造Lag1滚后变量:前1个交易日的收益率
# Lag2:前两个交易日的收益率
stock_price_data['Lag2'] = stock_price_data['pct_chg'].shift(2)  # 构造Lag2滚后变量:前2个交易日的收益率
# Lag3:前三个交易日的收益率
stock_price_data['Lag3'] = stock_price_data['pct_chg'].shift(3)  # 构造Lag3滚后变量:前3个交易日的收益率

# 将当日收益方向编码为伯努利响应
# Direction = 1 表示当日上涨,Direction = 0 表示当日下跌或平盘
# (pct_chg > 0) 生成布尔值 True/False,astype(int) 转换为数字 1/0
stock_price_data['Direction'] = (stock_price_data['pct_chg'] > 0).astype(int)  # 转换数据类型

# 去除因 shift 操作产生的缺失值(前 3 行没有完整的滞后数据)
# 去除因shift操作产生的前3行缺失值,确保每行数据完整
cleaned_analysis_data = stock_price_data[['Direction', 'Lag1', 'Lag2', 'Lag3']].dropna()

数据准备工作已完成:我们从浦发银行(600000.XSHG)的真实日度行情中计算了日收益率,并构造了Lag1、Lag2、Lag3三个滞后期收益率作为预测因子,以及”今日涨跌方向”作为二元分类标签。接下来,我们将用statsmodels的Logit模型进行最大似然估计,并检验各滞后变量系数的统计显著性。

表 4.1: 逻辑回归模型预测股市涨跌 (Direction ~ Lag1 + Lag2 + Lag3)
# 形成带截距的三项滞后收益设计矩阵
# 提取自变量矩阵 X:Lag1, Lag2, Lag3 三列
input_features_matrix = cleaned_analysis_data[['Lag1', 'Lag2', 'Lag3']]  # 提取三个滚后期收益率作为自变量矩阵
# add_constant 添加一列全为 1 的截距项(常数项)
# 这是因为 statsmodels 不会自动添加截距,需要手动指定
input_features_matrix = sm.add_constant(input_features_matrix)  # 添加常数项(截距项)
# 提取因变量向量 Y:涨跌方向 (0 或 1)
target_direction_vector = cleaned_analysis_data['Direction']  # 提取涨跌方向作为因变量向量(0或1)

# 用最大似然估计条件上涨概率
# sm.Logit 创建一个逻辑回归模型对象,传入因变量 Y 和自变量 X
# .fit(disp=0) 执行最大似然估计(MLE),disp=0 表示不显示迭代过程
logit_model = sm.Logit(target_direction_vector, input_features_matrix).fit(disp=0)  # 估计滞后收益与当日方向的条件关联

# 打印完整的模型统计摘要,包括:
# - 各变量的回归系数(coef)
# - 标准误差(std err)
# - z 统计量和 p 值(判断系数是否显著不为零)
# - 95% 置信区间
print(logit_model.summary())  # 输出模型摘要统计
print('\n解读规则:系数符号、区间与 p 值必须从本次输出读取。')  # 不在代码中预写市场机制
                           Logit Regression Results                           
==============================================================================
Dep. Variable:              Direction   No. Observations:                 5097
Model:                          Logit   Df Residuals:                     5093
Method:                           MLE   Df Model:                            3
Date:                Tue, 01 Sep 2026   Pseudo R-squ.:               0.0005808
Time:                        08:37:53   Log-Likelihood:                -3525.3
converged:                       True   LL-Null:                       -3527.4
Covariance Type:            nonrobust   LLR p-value:                    0.2512
==============================================================================
                 coef    std err          z      P>|z|      [0.025      0.975]
------------------------------------------------------------------------------
const         -0.0917      0.028     -3.262      0.001      -0.147      -0.037
Lag1          -0.0255      0.013     -1.892      0.058      -0.052       0.001
Lag2          -0.0076      0.013     -0.569      0.569      -0.034       0.019
Lag3           0.0055      0.013      0.413      0.680      -0.021       0.032
==============================================================================

解读规则:系数符号、区间与 p 值必须从本次输出读取。

表 4.1 展示本次全样本拟合的伪 \(R^2\)、似然比检验、系数、区间与 \(p\) 值;所有数字应直接从当次输出读取。若某系数区间不含零,只能在当前模型、样本与推断假设下描述条件关联;符号本身不识别短期反转机制。这里还没有独立样本外评价、交易成本或市场有效性联合检验,因而不对 EMH、稳定预测力或经济机制作外推。

4.5.3 进行预测 (Making Predictions)

一旦估计了系数,我们就可以使用逻辑回归模型进行预测:

\[ \hat{p}(X) = \frac{e^{\hat{\beta}_0 + \hat{\beta}_1 X}}{1 + e^{\hat{\beta}_0 + \hat{\beta}_1 X}} \tag{4.7}\]

决策规则

通常,如果\(\hat{p}(X) > 0.5\),我们预测为类别1(违约);否则预测为类别0(未违约)。

然而,这个阈值可以根据具体问题调整:

  • 保守策略(银行可能采用):使用较低的阈值(如0.3),宁可误判为违约也不愿冒险
  • 激进策略:使用较高的阈值(如0.7),只有在非常确定时才预测为违约

Tip: 理解阈值选择的影响

阈值选择涉及第一类错误(Type I Error)和第二类错误(Type II Error)的权衡:

  • 第一类错误(假阳性):实际未违约,但预测为违约
    • 后果:失去潜在客户,减少利润
  • 第二类错误(假阴性):实际违约,但预测为未违约
    • 后果:坏账损失,可能非常严重

银行通常对第二类错误的容忍度更低,因此会选择较低的阈值(如0.3或0.4),宁可错杀一千,不可放过一个潜在违约者。

在医疗诊断中,类似的原则也适用:对于严重疾病,宁可误诊为患病(进一步检查),也不能漏诊。

4.5.4 多元逻辑回归 (Multiple Logistic Regression)

与多元线性回归类似,我们可以有多个预测变量:

\[ p(X) = P(Y=1|X) = \frac{e^{\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p}}{1 + e^{\beta_0 + \beta_1 X_1 + \cdots + \beta_p X_p}} \tag{4.8}\]

系数解释\(\beta_j\)表示在其他变量不变的情况下,\(X_j\)每增加1单位,违约的对数几率的变化量。

4.5.5 多项逻辑回归 (Multinomial Logistic Regression)

对于\(K > 2\)的多分类问题,我们使用多项逻辑回归

\[ P(Y=k|X) = \frac{e^{\beta_{k0} + \beta_{k1} X_1 + \cdots + \beta_{kp} X_p}}{\sum_{l=1}^{K} e^{\beta_{l0} + \beta_{l1} X_1 + \cdots + \beta_{lp} X_p}} \tag{4.9}\]

为了可识别性,通常将一个类别设为参照类别(reference category),其系数设为0。

4.6 判别分析的生成式模型 (Generative Models for Classification)

逻辑回归是一种判别式模型(discriminative model),它直接对\(P(Y|X)\)建模。另一类方法是生成式模型(generative models),它们对\(P(X|Y)\)建模,然后使用贝叶斯定理得到\(P(Y|X)\)

4.6.1 贝叶斯分类器 (Bayes Classifier)

“贝叶斯最优”必须相对于损失函数表述。若采取动作 \(a\)、真实类别为 \(k\) 时损失为 \(L(a,k)\),给定 \(X=x\) 的后验风险为

\[ R(a\mid x)=\sum_{k=1}^{K}L(a,k)P(Y=k\mid X=x), \]

因此贝叶斯动作是

\[ a^*(x)=\arg\min_a\sum_{k=1}^{K}L(a,k)P(Y=k\mid X=x). \tag{4.10}\]

只有在 0—1 损失(所有误分类代价相同、正确分类损失为 0)下,最小后验风险才等价于把观测分到后验概率最大的类别:

\[ C(X) = \arg\max_{k} P(Y=k|X) \tag{4.11}\]

若误报与漏报代价不对称,最大后验类别不再必然是最优动作。二分类中,令误报代价为 \(C_{FP}\)、漏报代价为 \(C_{FN}\) 且正确决策损失为 0,则当 \(P(Y=1\mid X=x)>C_{FP}/(C_{FP}+C_{FN})\) 时选择动作 1;这正是本章后文成本敏感阈值选择的理论依据。

使用贝叶斯定理:

\[ P(Y=k|X) = \frac{P(X|Y=k) P(Y=k)}{P(X)} = \frac{\pi_k f_k(X)}{\sum_{l=1}^{K} \pi_l f_l(X)} \tag{4.12}\]

补充说明:贝叶斯定理的直观理解与详细推导 {#sec-bayes-theorem-detail}

贝叶斯定理(Bayes’ Theorem)是概率论中最深刻、应用最广泛的定理之一。它为我们提供了一个在获得新证据后更新信念的数学框架。下面我们从最基本的概率公理出发,逐步推导并解释这一定理。

第一步:从条件概率出发。 贝叶斯定理的起点是条件概率的定义。给定事件\(B\)已经发生,事件\(A\)发生的概率定义为:

\[ P(A|B) = \frac{P(A \cap B)}{P(B)} \tag{4.13}\]

同理,给定\(A\)发生时\(B\)的条件概率为:

\[ P(B|A) = \frac{P(A \cap B)}{P(A)} \]

从上面两个等式中,我们都可以得到联合概率\(P(A \cap B)\)的表达式:

\[ P(A \cap B) = P(A|B) \cdot P(B) = P(B|A) \cdot P(A) \]

第二步:推导贝叶斯定理。 将上述等式变形,用\(P(B|A) \cdot P(A)\)替换\(P(A \cap B)\),代入 式 4.13 的分子:

\[ P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)} \tag{4.14}\]

这就是贝叶斯定理的基本形式。其中\(P(B)\)可以通过全概率公式(Law of Total Probability)展开。如果事件\(A\)\(K\)个互斥且穷举的取值\(A_1, A_2, \ldots, A_K\),则:

\[ P(B) = \sum_{k=1}^{K} P(B|A_k) \cdot P(A_k) \]

由此,贝叶斯定理的完整形式为:

\[ P(A_k|B) = \frac{P(B|A_k) \cdot P(A_k)}{\sum_{l=1}^{K} P(B|A_l) \cdot P(A_l)} \tag{4.15}\]

第三步:理解各组成部分的含义。 贝叶斯定理中每个组成部分都有明确的概率含义:

术语 符号 含义
先验概率 (Prior) \(P(A_k)\) 在观察到任何数据之前,我们对某个假设(类别)的初始信念
似然 (Likelihood) \(P(B \mid A_k)\) 假设该假设(类别)成立,观测到当前数据的可能性
边际似然 (Evidence) \(P(B)\) 观测到当前数据的总概率(归一化常数)
后验概率 (Posterior) \(P(A_k \mid B)\) 在观察到数据之后,我们对假设(类别)的更新信念

贝叶斯定理的核心思想可以概括为一句话:后验 \(\propto\) 似然 \(\times\) 先验。也就是说,我们对一个事件的最终判断(后验)取决于两方面的信息:(1) 我们事先的经验和知识(先验),以及 (2) 新获得的数据证据(似然)。

第四步:金融案例——一个直观的数值例子。 假设某家银行的贷款客户中,有5%最终会违约(\(P(\text{违约})=0.05\)),95%正常还款(\(P(\text{正常})=0.95\))。银行开发了一个信用评分系统:

  • 在真正违约的客户中,80%被系统标记为”高风险”:\(P(\text{高风险}|\text{违约}) = 0.80\)
  • 在正常还款的客户中,10%被误标为”高风险”:\(P(\text{高风险}|\text{正常}) = 0.10\)

现在有一位客户被标记为”高风险”。利用贝叶斯定理,该客户实际违约的概率为:

\[ P(\text{违约}|\text{高风险}) = \frac{P(\text{高风险}|\text{违约}) \cdot P(\text{违约})}{P(\text{高风险}|\text{违约}) \cdot P(\text{违约}) + P(\text{高风险}|\text{正常}) \cdot P(\text{正常})} \]

\[ = \frac{0.80 \times 0.05}{0.80 \times 0.05 + 0.10 \times 0.95} = \frac{0.04}{0.04 + 0.095} = \frac{0.04}{0.135} \approx 0.296 \]

即使客户被标记为“高风险”,在给定数值设定下其违约后验概率也只有约 30%。原因是违约的基础比率仅为 5%;正常客户数量很大,10% 的误报也可能超过真正违约者数量。这个计算说明先验概率会直接影响后验概率,也解释了低基础率任务中精确率与召回率为何需要同时报告。

补充说明:把贝叶斯结果翻译成精确率与召回率的语言

上面的结论之所以常常让学生吃惊,是因为它同时牵涉了“模型发现风险的能力”和“风险警报的可信度”这两个不同维度。

如果把“被系统标为高风险”看成预测为正类,那么:

  • \(P(\text{高风险}|\text{违约})=0.80\) 描述的是模型抓到真正违约客户的能力,它对应的正是召回率
  • \(P(\text{违约}|\text{高风险})\approx 0.296\) 描述的是高风险警报到底有多可信,它对应的正是精确率

这两个方向不能混为一谈。前者是“真正有风险的人里,你抓住了多少”;后者是“被你抓出来的人里,真有风险的比例是多少”。贝叶斯定理的价值就在于,它把这两个方向联系了起来:只知道召回率还不够,还必须结合先验概率误报率,才能知道一条警报究竟值不值得业务团队认真对待。

对金融学生来说,这一点尤其重要。因为在 ST 预警、欺诈识别、坏账筛查这类任务中,正类通常都很稀少。如果忽略先验概率,只看到“模型抓住了80%的风险客户”,就很容易高估模型的实际业务价值。

推导:贝叶斯定理在分类中的应用

理解了贝叶斯定理后,我们来看它在分类问题中的具体应用。在下述标准 0—1 损失约定下,贝叶斯分类器通过比较后验概率 \(P(Y=k|X)\) 来做决策——将观测分到后验概率最大的类别中;若类别错误代价不相同,则应改用 式 4.10 比较动作。

将贝叶斯定理 式 4.15 应用到分类问题中,令\(A_k\)代表类别\(Y=k\)\(B\)代表观测到的特征向量\(X\)

\[ P(Y=k|X) = \frac{P(X|Y=k) \cdot P(Y=k)}{\sum_{l=1}^{K} P(X|Y=l) \cdot P(Y=l)} = \frac{\pi_k f_k(X)}{\sum_{l=1}^{K} \pi_l f_l(X)} \]

因为分母 \(P(X) = \sum_{l} \pi_l f_l(X)\) 对所有类别 \(k\) 是相同的常数,所以在比较不同类别的后验概率时,我们只需要最大化分子 \(P(X|Y=k) \cdot \pi_k = \pi_k f_k(X)\)

其中:

  • \(\pi_k = P(Y=k)\)是第\(k\)类的先验概率(prior probability),反映了该类别在总体中的占比
  • \(f_k(X) = P(X|Y=k)\)是第\(k\)类的类条件概率密度(class-conditional density),描述了属于第\(k\)类的观测的特征分布

不同的生成式分类方法(LDA、QDA、朴素贝叶斯)的区别在于:它们对类条件密度 \(f_k(X)\) 做出了不同的参数化假设。

4.6.2 线性判别分析 (Linear Discriminant Analysis, LDA)

LDA假设:

  1. 每个类的观测服从多元正态分布
  2. 所有类的协方差矩阵相同

在这些假设下,由多元正态分布的密度函数: \[ f_k(X) = \frac{1}{(2\pi)^{p/2}|\boldsymbol{\Sigma}|^{1/2}} \exp\left(-\frac{1}{2}(X - \boldsymbol{\mu}_k)^T \boldsymbol{\Sigma}^{-1} (X - \boldsymbol{\mu}_k)\right) \]

将该密度函数代入贝叶斯公式 式 4.12,并取对数: \[ \begin{aligned} \log P(Y=k|X) &= \log f_k(X) + \log \pi_k - \log \left(\sum_l \pi_l f_l(X)\right) \\ &= -\frac{1}{2}(X - \boldsymbol{\mu}_k)^T \boldsymbol{\Sigma}^{-1} (X - \boldsymbol{\mu}_k) + \log \pi_k + \text{Const} \\ &= -\frac{1}{2} X^T \boldsymbol{\Sigma}^{-1} X + X^T \boldsymbol{\Sigma}^{-1} \boldsymbol{\mu}_k - \frac{1}{2} \boldsymbol{\mu}_k^T \boldsymbol{\Sigma}^{-1} \boldsymbol{\mu}_k + \log \pi_k + \text{Const} \end{aligned} \]

由于 \(-\frac{1}{2} X^T \boldsymbol{\Sigma}^{-1} X\) 和归一化常数对所有类 \(k\) 都是相同的,在比较大小时可以被忽略。因此,提取出与 \(k\) 相关的项,可以推导出线性判别函数

\[ \delta_k(X) = X^T \boldsymbol{\Sigma}^{-1} \boldsymbol{\mu}_k - \frac{1}{2} \boldsymbol{\mu}_k^T \boldsymbol{\Sigma}^{-1} \boldsymbol{\mu}_k + \log \pi_k \tag{4.16}\]

其中:

  • \(\boldsymbol{\mu}_k\)是第\(k\)类的均值向量
  • \(\boldsymbol{\Sigma}\)是共同的协方差矩阵
  • \(\pi_k\)是第\(k\)类的先验概率

LDA分类规则:将\(X\)分类到使\(\delta_k(X)\)最大的类别\(k\)

Tip: LDA vs. 逻辑回归

LDA和逻辑回归都是线性分类器,但有以下区别:

特性 LDA 逻辑回归
假设 类条件正态,同协方差 对数几率线性
估计方法 参数化生成模型:估计类均值、共享协方差与先验 参数化判别模型:用 MLE 估计有限维系数
稳定性 正态与共享协方差近似合理时可提高小样本效率 稳定性取决于事件数、分离、共线性和正则化
鲁棒性 对正态性假设敏感 对离群点较敏感
可扩展性 容易扩展到多分类 需要多项逻辑回归

何时考虑 LDA

  • 当类条件分布接近正态时
  • 当每类有效样本相对维数有限,且共享协方差约束可降低估计方差时
  • 当训练内验证显示其概率或分类损失优于透明基线时

何时考虑逻辑回归

  • 当正态性假设不成立时
  • 当 log-odds 线性近似可接受,或可通过预先设定的变换改善时
  • 当需要直接解释条件 odds ratio 或输出经验证的概率时

两者都应在同一训练内验证设计上比较;不存在仅凭“样本较大/较小”即可决定方法的固定阈值。

算法拆解:LDA 与 QDA 在计算上到底做了什么

从“训练流程”角度看,LDA 和 QDA 都属于先建模分布、再做分类的生成式方法。它们和逻辑回归最大的差别在于:逻辑回归直接学习类别概率,而 LDA/QDA 先假定每一类数据各自长什么样,再利用贝叶斯规则反推类别。

它们的计算流程可以拆成下面几步:

  1. 按类别分组样本。先把训练数据按 \(Y=1,2,\ldots,K\) 分开。
  2. 估计每一类的均值向量。这一步回答“第 \(k\) 类样本平均长什么样”。
  3. 估计协方差结构
    • LDA 假设各类共享同一个协方差矩阵,所以把所有类别的信息合并起来估一个共同波动结构;
    • QDA 允许每一类拥有自己的协方差矩阵,因此会分别估计 \(\Sigma_1,\Sigma_2,\ldots,\Sigma_K\)
  4. 带入贝叶斯判别函数打分。对每个新样本,分别计算它在各类下的判别值 \(\delta_k(X)\)
  5. 选择得分最高的类别。在本节默认的 0—1 损失下,谁的后验概率最大,就把样本判给谁;成本不对称时改为选择后验风险最低的动作。

也正因如此:

  • LDA 的参数更少,当共享协方差近似合理时可能降低估计方差;若该假设严重错设,约束反而会增加偏差;
  • QDA 更灵活,因为它允许不同类别有不同的“散布形状”,所以边界可以弯曲;
  • 但 QDA 也更容易过拟合,因为每一类都要单独估计一个协方差矩阵,对样本量的要求更高。

如果把它们类比为金融分析工具:LDA 更像是“先假定各行业公司虽然均值不同,但波动结构差不多”;QDA 则允许“不同类别公司的波动模式本来就完全不同”,因此模型更细腻,但也更依赖足够的数据支撑。

机制实验:LDA 的线性决策边界

下面用两类共享协方差的正态模拟样本展示 LDA 的几何结构。LDA 拟合类均值与共同协方差,二次项在类别比较中抵消,因此原始二维特征空间中的边界是直线。图中的涨跌标签只是类别名称,不能用来评价真实 A 股的可预测性。

# 用受控同协方差样本展示LDA的线性判别边界

import numpy as np  # 生成共享协方差的二维高斯机制样本
import matplotlib.pyplot as plt  # 绘制 LDA 线性边界与模拟观测
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis  # 估计共享协方差下的线性判别边界

# 生成两类共享协方差的二维机制样本
# 使用模拟数据进行可视化,简化为只用 Lag1 和 Lag2 两个特征
# 设置随机种子,确保每次运行结果一致
np.random.seed(42)  # 设置随机种子确保结果可复现
# 总共生成 200 个样本点
total_samples = 200  # 设定模拟样本总数为200个(涨跌各100)

# 生成"下跌"类(Class 0)的数据:100 个样本
# 均值为 [-0.5, -0.2],表示前几天收益率偏负
# 协方差矩阵 [[1.5, 0.2], [0.2, 1.5]] 定义了数据点的分散程度和相关性
# multivariate_normal 生成服从多元正太分布的随机数据
# 生成“下跌”类(Class 0)的30块样本,均值偏负表示前几天收益率偏低
class_down_features = np.random.multivariate_normal([-0.5, -0.2], [[1.5, 0.2], [0.2, 1.5]], 100)

# 生成"上涨"类(Class 1)的数据:100 个样本
# 均值为 [0.5, 0.2],表示前几天收益率偏正
# 注意:两个类使用相同的协方差矩阵——这正是 LDA 的核心假设
# 生成“上涨”类(Class 1)的100个样本,均值偏正且协方差与下跌类相同(LDA核心假设)
class_up_features = np.random.multivariate_normal([0.5, 0.2], [[1.5, 0.2], [0.2, 1.5]], 100)

# 合并两类观测并保留已知类别标签
# np.vstack 将两组数据纵向拼接成一个 200×2 的矩阵
combined_features_matrix = np.vstack([class_down_features, class_up_features])  # 纵向拼接两类数据形成200×2的特征矩阵
# 创建对应的类别标签:前 100 个为 0(下跌),后 100 个为 1(上涨)
target_labels_vector = np.array([0]*100 + [1]*100)  # 创建类别标签:前100个为0(下跌)、后100个为1(上涨)

# 按同协方差假设估计线性判别规则
# 按同协方差假设拟合两类的线性判别规则
lda_classifier_model = LinearDiscriminantAnalysis()  # 以受控样本估计线性边界参数
# 用特征矩阵 X 和标签向量 Y 进行模型训练(拟合)
lda_classifier_model.fit(combined_features_matrix, target_labels_vector)  # 在共享协方差机制样本上估计线性判别边界
LinearDiscriminantAnalysis()
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.

LDA分类器已在Lag1和Lag2构成的二维特征空间上完成训练,学会了如何在”上涨”和”下跌”两类数据之间画出一条最优的线性分界线。下面的代码将通过在整个特征空间上铺设密集的网格点、对每个点进行LDA分类预测,来直观地绘制出这条决策边界及其对应的分类区域。

# 在输入网格上展示估计后的线性分区
# 计算特征空间的范围,上下各留 1 个单位的边距
# 计算特征空间横轴(Lag1)的范围,上下各留出1个单位的边距
x_min, x_max = combined_features_matrix[:, 0].min()-1, combined_features_matrix[:, 0].max()+1
# 计算特征空间纵轴(Lag2)的范围,上下各留出1个单位的边距
y_min, y_max = combined_features_matrix[:, 1].min()-1, combined_features_matrix[:, 1].max()+1

# np.meshgrid 生成一个密集的网格点阵
# 步长 0.1 意味着每隔 0.1 个单位放一个点,覆盖整个特征空间
# 以步长0.1生成密集网格点阵,用于可视化整个特征空间的决策区域
grid_x_axis, grid_y_axis = np.meshgrid(np.arange(x_min, x_max, 0.1), np.arange(y_min, y_max, 0.1))

# 对网格中的每个点进行预测分类
# np.c_ 将 x 和 y 坐标拼成 N×2 的矩阵,ravel() 将二维网格展平为一维
# reshape 将预测结果恢复为与网格相同的二维形状
# 对网格中每个点进行LDA分类预测,并将结果重塑为与网格同形的二维数组
predicted_boundaries = lda_classifier_model.predict(np.c_[grid_x_axis.ravel(), grid_y_axis.ravel()]).reshape(grid_x_axis.shape)

# 展示共享协方差机制下 LDA 的线性决策区域与两类模拟观测
plt.figure(figsize=(10, 6))  # 在同一平面叠加受控观测与LDA分区
# contourf 用颜色填充不同的预测区域,形成决策边界的可视化
# alpha=0.2 设置半透明,cmap='coolwarm' 使用蓝红配色
# 用填充等高线图可视化决策区域,蓝色=下跌、红色=上涨
plt.contourf(grid_x_axis, grid_y_axis, predicted_boundaries, alpha=0.2, cmap='coolwarm')
# 绘制"下跌"类的散点(蓝色)
# 绘制“下跌”类的散点(蓝色圆点)
plt.scatter(class_down_features[:,0], class_down_features[:,1], c='blue', label='Down (0)')
# 绘制"上涨"类的散点(红色)
plt.scatter(class_up_features[:,0], class_up_features[:,1], c='red', label='Up (1)')  # 展示机制中的上涨类观测位置
plt.xlabel('Lag1'); plt.ylabel('Lag2')  # 标明受控机制的两项输入坐标
# 标明边界来自受控模拟而非真实市场方向证据
plt.title('LDA Decision Boundary for Market Direction (Simulated)')  # 防止把机制图误读为实证结果
plt.legend()  # 区分两类受控观测与预测分区
plt.show()  # 输出观测点与LDA线性分区的对应关系
模拟 A 股涨跌两类点在 Lag1—Lag2 平面中的分布,一条 LDA 直线将两个预测区域分开。
图 4.2: LDA Decision Boundary for A-Share Direction (Simulated). Using Lag1 and Lag2 as features.

图 4.2 可以观察到,LDA 在这组受控模拟的 Lag1-Lag2 特征空间中生成直线型决策边界。这是预设的同协方差正态类条件分布所产生的几何演示:判别函数的二次项抵消。图中重叠程度也是模拟均值、协方差和随机种子共同决定的,最多说明这组设定下的可分性;它不能验证真实市场的滞后收益判别能力,也不能印证前文任何经验结论。真实任务必须另用未参与生成或拟合的前向数据评价。

4.6.3 二次判别分析 (Quadratic Discriminant Analysis, QDA)

QDA放松了LDA的”相同协方差矩阵”假设,允许每个类有自己的协方差矩阵\(\boldsymbol{\Sigma}_k\)

判别函数变为:

\[ \delta_k(X) = -\frac{1}{2} \log|\boldsymbol{\Sigma}_k| - \frac{1}{2}(X - \boldsymbol{\mu}_k)^T \boldsymbol{\Sigma}_k^{-1}(X - \boldsymbol{\mu}_k) + \log \pi_k \tag{4.17}\]

这导致二次的决策边界

图 4.3 展示了LDA和QDA的区别。

下面的受控模拟刻意让两类具有不同协方差,用来观察 LDA 的线性边界与 QDA 的二次边界。它只演示模型假设如何改变边界几何,不预先规定哪一种模型在当次样本上更准确,也不代表真实信用客户或市场总体。若要比较性能,必须另设训练/评价样本并读取现场指标。

# 用受控异协方差样本比较LDA与QDA的边界几何

import numpy as np  # 生成类别协方差不同的二维机制样本
import matplotlib.pyplot as plt  # 对照 LDA 与 QDA 的边界几何
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis  # 比较共享与类别特定协方差边界

# 生成类别协方差不同的二维机制样本
# 模拟场景:银行根据客户的"年收入"和"信用卡余额"来预测是否会违约
# 设置随机种子,保证可复现性
np.random.seed(42)  # 设置随机种子确保结果可复现
# 每个类别(违约 / 未违约)各生成 200 个样本
sample_size_per_class = 200  # 每个类别各生成200个样本点

# --- 类别 0(未违约客户):收入集中在 30 万,余额在 2 千左右 ---
# 均值向量:[平均年收入=30万, 平均信用卡余额=2千]
mean0 = [30, 2]  # 未违约客户的均值向量:收入30万、余额2千
# 协方差矩阵:方差较小,说明未违约客户的特征分布比较集中稳定
# [[50, 5], [5, 25]] 表示收入方差=50,余额方差=25,两者适度正相关
cov0 = [[50, 5], [5, 25]]  # 未违约客户的协方差矩阵:分布较集中(方差较小)
# 从多元正态分布中随机生成未违约客户的数据点
# 从多元正态分布中抽取未违约客户的特征数据点
normal_clients_features = np.random.multivariate_normal(mean0, cov0, sample_size_per_class)

# --- 类别 1(违约客户):收入集中在 50 万,余额在 4 千左右 ---
# 均值向量
mean1 = [50, 4]  # 违约客户的均值向量:收入50万、余额4千
# 协方差矩阵:方差显著更大,说明违约客户的特征分布很分散
# 该设定允许QDA表达不同类协方差;是否改善评价指标仍需独立检验
cov1 = [[200, 20], [20, 100]]  # 违约客户的协方差矩阵:分布更分散(方差更大)
# 从多元正态分布中随机生成违约客户的数据点
# 从多元正态分布中抽取违约客户的特征数据点
default_clients_features = np.random.multivariate_normal(mean1, cov1, sample_size_per_class)

# 合并两类观测并编码已知违约状态
# 将两类客户数据纵向拼接为一个完整的特征矩阵
# 将两类客户数据纵向拼接为400×2的完整特征矩阵
combined_client_features = np.vstack([normal_clients_features, default_clients_features])
# 创建标签向量:前 200 个为 0(未违约),后 200 个为 1(违约)
# 创建标签向量:前200个为0(未违约)、后200个为1(违约)
client_status_labels = np.array([0]*sample_size_per_class + [1]*sample_size_per_class)

上面的代码生成了一组模拟的银行信用违约数据。设计要点在于:未违约客户(正常客户)的数据分布较为集中(协方差矩阵元素较小),而违约客户的数据分布非常分散(协方差矩阵元素较大)。这种两类协方差矩阵显著不同的数据结构,正是QDA相比LDA能够体现优势的典型场景。接下来,我们将在同一数据上分别训练LDA和QDA两个模型,并通过密集网格预测来可视化决策边界。

# 在同一样本拟合共享与类别特定协方差模型
# 创建 LDA 模型(假设两个类有相同的协方差矩阵 → 线性边界)
lda_model = LinearDiscriminantAnalysis()  # 创建LDA分类器(假设同协方差 → 线性边界)
# 创建 QDA 模型(允许两个类有不同的协方差矩阵 → 二次曲线边界)
qda_model = QuadraticDiscriminantAnalysis()  # 创建QDA分类器(允许不同协方差 → 二次曲线边界)
# 用相同的训练数据分别拟合两个模型
lda_model.fit(combined_client_features, client_status_labels)  # 强制两类共享协方差以形成线性边界
qda_model.fit(combined_client_features, client_status_labels)  # 分别估计两类协方差以形成二次边界

# 构造共同输入网格以公平比较边界
# 计算特征的最小值和最大值,上下各留一定边距
# 计算横轴(年收入)的范围,左右各留出10个单位的边距
x_min, x_max = combined_client_features[:, 0].min() - 10, combined_client_features[:, 0].max() + 10
# 计算纵轴(信用卡余额)的范围,上下各留出1个单位的边距
y_min, y_max = combined_client_features[:, 1].min() - 1, combined_client_features[:, 1].max() + 1
# 生成密集网格:x 方向步长 0.5,y 方向步长 0.1
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.5),  # 横轴步长0.5生成密集网格
                    np.arange(y_min, y_max, 0.1))  # 纵轴步长0.1生成密集网格

# 将两个判别规则映射到共同网格
# LDA 的预测结果(用于绘制左图的线性边界)
lda_predictions = lda_model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)  # LDA对网格进行分类预测并重塑为二维
# QDA 的预测结果(用于绘制右图的二次边界)
qda_predictions = qda_model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)  # QDA对网格进行分类预测并重塑为二维

LDA和QDA模型均已训练完毕,特征空间上的密集网格预测也已完成。下面的代码将创建一个包含两个子图的并排对比图:左图展示LDA的线性决策边界(一条直线),右图展示QDA的二次决策边界(一条曲线),让我们直观感受两种方法在处理”协方差矩阵不同”这一典型场景时的差异。

# 并排呈现协方差假设与边界形状
# 创建包含 2 个子图的画布(1 行 2 列),总大小 16×7 英寸
fig, axes = plt.subplots(1, 2, figsize=(16, 7))  # 在相同受控样本上并排比较线性与二次边界

# 左图展示共享协方差约束
ax1 = axes[0]  # 获取左侧子图用于展示LDA的线性决策边界
# contourf 用蓝红渐变色填充不同分类区域
ax1.contourf(xx, yy, lda_predictions, alpha=0.3, cmap='RdYlBu')  # 用底色编码LDA预测区域
# contour 画出决策边界的轮廓线(黑色虚线)
ax1.contour(xx, yy, lda_predictions, colors='black', linewidths=2, linestyles='--')  # 用虚线标出LDA类别切换位置
# 绘制未违约客户散点(蓝色圆点)
ax1.scatter(normal_clients_features[:, 0], normal_clients_features[:, 1], c='blue', marker='o',
           s=60, alpha=0.5, edgecolors='darkblue', linewidth=0.5, label='未违约')  # 点大小60、半透明
# 绘制违约客户散点(红色方块)
ax1.scatter(default_clients_features[:, 0], default_clients_features[:, 1], c='red', marker='s',
           s=60, alpha=0.5, edgecolors='darkred', linewidth=0.5, label='违约')  # 点大小60、半透明
# 标明左图输入变量和共享协方差假设
ax1.set_xlabel('年收入(万元)', fontsize=12, fontweight='bold')  # 标明横轴受控收入尺度
ax1.set_ylabel('信用卡余额(千元)', fontsize=12, fontweight='bold')  # 标明纵轴受控余额尺度
ax1.set_title('LDA:线性决策边界\n(假设相同协方差矩阵)', fontsize=13, fontweight='bold')  # 将边界形状与同协方差前提对应
ax1.legend(fontsize=10, loc='upper right', framealpha=0.9)  # 区分两类观测并避免遮挡边界
# 添加浅色网格线,便于读数
ax1.grid(True, alpha=0.3)  # 添加子图网格线

# 右图展示类别特定协方差边界
ax2 = axes[1]  # 获取右侧子图用于展示QDA的二次决策边界
# 用颜色填充 QDA 的分类区域
ax2.contourf(xx, yy, qda_predictions, alpha=0.3, cmap='RdYlBu')  # 用底色编码QDA预测区域
# 画出 QDA 决策边界的轮廓线
ax2.contour(xx, yy, qda_predictions, colors='black', linewidths=2, linestyles='--')  # 用虚线标出QDA类别切换位置
# 绘制未违约客户散点
# 绘制未违约客户散点(蓝色圆点,与左图保持一致)
ax2.scatter(normal_clients_features[:, 0], normal_clients_features[:, 1], c='blue', marker='o',
           s=60, alpha=0.5, edgecolors='darkblue', linewidth=0.5, label='未违约')  # 点大小60、半透明
# 绘制违约客户散点
# 绘制违约客户散点(红色方块,与左图保持一致)
ax2.scatter(default_clients_features[:, 0], default_clients_features[:, 1], c='red', marker='s',
           s=60, alpha=0.5, edgecolors='darkred', linewidth=0.5, label='违约')  # 点大小60、半透明
# 标明右图输入变量和类别特定协方差假设
ax2.set_xlabel('年收入(万元)', fontsize=12, fontweight='bold')  # 保持与左图相同收入尺度
ax2.set_ylabel('信用卡余额(千元)', fontsize=12, fontweight='bold')  # 保持与左图相同余额尺度
ax2.set_title('QDA:二次决策边界\n(允许不同协方差矩阵)', fontsize=13, fontweight='bold')  # 将曲线边界与异协方差前提对应
ax2.legend(fontsize=10, loc='upper right', framealpha=0.9)  # 区分两类观测并保持图例位置一致
ax2.grid(True, alpha=0.3)  # 添加子图网格线

plt.tight_layout()  # 避免两类边界面板的标签相互遮挡
plt.show()  # 输出同一样本上的LDA与QDA分区对照
并排的模拟两类散点:左图 LDA 用直线分区,右图 QDA 用弯曲二次边界分区。
图 4.3: LDA vs. QDA:线性边界 vs. 二次边界。左图:LDA假设协方差矩阵相同,产生线性决策边界。右图:QDA允许不同的协方差矩阵,产生二次(弯曲)决策边界。

图 4.3 可以看出:

  • LDA(左图):决策边界是一条直线,即使实际边界应该是弯曲的
  • QDA(右图):决策边界是二次曲线;它在该设定下更灵活,但是否改善样本外指标须另行评价

LDA vs. QDA的选择

  • 偏向LDA:当样本量较小(参数估计不稳定)或类别间的协方差矩阵相似时
  • 偏向QDA:当样本量较大且类别间的协方差矩阵明显不同时

高维边界:三种生成式模型不能合并概括

设有 \(K\) 个类别、\(p\) 个特征,第 \(k\) 类训练样本数为 \(n_k\)。忽略先验概率的 \(K-1\) 个自由参数后,经典 LDA 约需估计 \(Kp+p(p+1)/2\) 个均值与共享协方差参数;QDA 约需估计 \(Kp+Kp(p+1)/2\) 个均值与类别特定协方差参数;高斯朴素贝叶斯在条件独立约束下只需约 \(2Kp\) 个均值与方差参数。三者在高维小样本下的行为因此不同:

  • LDA 通过共享协方差汇集各类信息,通常比 QDA 节省参数,但共享矩阵仍含 \(O(p^2)\) 个元素。当 \(p\) 接近总有效样本量或特征高度共线时,样本协方差也会病态;此时应考虑收缩 LDA、对角协方差或训练折内降维,而不能无条件称为稳定。
  • QDA 为每一类估计一个完整协方差矩阵,参数负担为 \(O(Kp^2)\)。样本协方差的秩至多为 \(n_k-1\),所以只要某一类满足 \(n_k\leq p\),该类协方差必然奇异,经典 QDA 所需的逆矩阵和行列式便不存在;即使 \(n_k\) 略大于 \(p\),估计也可能极不稳定。
  • 高斯朴素贝叶斯 只估计每个“类别—特征”的一维均值和方差,参数负担为 \(O(Kp)\),因而能在 \(p\) 较大时计算。但条件独立若严重错设,相关特征会被重复计分,类别概率可能过度自信;参数少不等于概率一定准确。

QDA 失效反例与替代。 若每类只有 30 个训练观测而有 60 个财务特征,则每个 \(60\times60\) 类内样本协方差的秩最多为 29,经典 QDA 无法直接求逆。可把协方差收缩为 \(\widehat\Sigma_k(\lambda)=(1-\lambda)\widehat\Sigma_k+\lambda\tau_k I\),采用正则化判别分析或对角 QDA,或在每个训练折内先降维;\(\lambda\)、保留维数和模型类型都必须只用训练期验证选择。若这些替代仍不能改善概率损失,应保留更简单的事件率、逻辑回归、收缩 LDA 或朴素贝叶斯基线,而不是依赖 QDA 的表面灵活性。

4.6.4 朴素贝叶斯 (Naive Bayes)

朴素贝叶斯是一类基于贝叶斯定理、计算较简洁的分类方法。它的“朴素”假设是:给定类别时,预测变量之间相互独立;这一假设是否近似合理须结合数据结构和样本外概率评价判断。

对于\(p\)个预测变量,类条件概率密度为:

\[ f_k(X) = f_{k1}(X_1) \times f_{k2}(X_2) \times \cdots \times f_{kp}(X_p) \tag{4.18}\]

这极大地简化了计算,因为我们可以分别估计每个变量的分布。

Tip: 为什么朴素贝叶斯在实践中表现良好?

朴素贝叶斯的独立性假设在现实中很少成立,但它在实践中却表现出色,原因包括:

  1. 解耦问题:每个特征可以单独建模,降低了维度诅咒
  2. 参数效率:需要估计的参数数量大大减少
  3. 参数节约:不估计完整协方差矩阵,但无关或相关特征仍可能损害排序与校准
  4. 快速训练和预测:计算效率高

算法拆解:朴素贝叶斯为什么训练得这么快

朴素贝叶斯之所以常被当作初学者接触生成式分类器的第一站,是因为它把高维联合分布这个原本很难的问题,拆成了许多个一维小问题。

它的训练过程非常直接:

  1. 统计先验概率:先计算每个类别在训练集中出现的比例 \(\pi_k\)
  2. 逐个特征、逐个类别估计分布参数:例如在高斯朴素贝叶斯中,对每一个特征 \(X_j\) 和每一个类别 \(k\),分别估计均值与方差。
  3. 预测时把证据相乘:新样本到来后,分别计算每个特征在各类别下出现的可能性,再把这些似然与先验相乘。
  4. 比较后验大小:在默认 0—1 损失下判给后验概率最大的类别;若行动成本不对称,则按 式 4.10 比较后验风险。

由于它避免了直接估计高维协方差矩阵,所以:

  • 在特征很多、样本不算多时,它往往比 QDA 更稳定;
  • 在文本分类、公告情感识别、舆情监控这类“变量多但每个变量贡献都不算复杂”的问题上,训练和预测都非常快;
  • 即便独立性假设不完全成立,只要模型对后验概率的排序大体正确,它依然可能给出很好的分类结果。

但也正因为它把联合关系拆开了,所以一旦多个特征高度相关,例如若同时放入多个本质上描述同一盈利能力的财务比率,模型就可能把重复信息“重复计分”,从而让概率输出显得过于自信。这也是为什么朴素贝叶斯常常适合做快速基线,却未必适合作为最终概率定价模型。

适用场景

  • 金融文本分类(公告情感分析、舆情监控)
  • 高维财务数据(多因子选股中的特征筛选)
  • 实时预测系统(信用评分、交易信号生成)

不适用场景

  • 当特征之间有强相关性时(如多个高度相关的财务比率)
  • 当特征的独立性假设严重违反时

4.7 分类方法的比较 (A Comparison of Classification Methods)

4.7.1 理论比较 (An Analytical Comparison)

表 4.2 总结了本章介绍的各种分类方法的特点。

# 按边界、假设与计算特征对照六类分类方法

import pandas as pd  # 汇总分类器假设、概率能力与业务限制

# 创建比较表格数据,使用 Python 字典(dict)来定义每一列的内容
# 每个键(key)是列名,对应的值(value)是一个列表,包含各行的数据
comparison_data = {  # 构建分类方法多维度对比数据字典
    # 方法名称
    '方法': ['逻辑回归', 'LDA', 'QDA', 'KNN (第4章)', '朴素贝叶斯', 'SVM (第9章)'],  # 列出六种分类算法名称
    # 决策边界形状:线性意味着用直线/超平面分隔,非线性则能画出弯曲的分界
    '决策边界': ['线性', '线性', '二次', '通常非线性', '取决于类条件分布', '线性核为线性;非线性核可弯曲'],  # 边界取决于具体建模假设
    # 各方法的核心假设:不同的假设适用于不同的数据场景
    '假设': ['对数几率线性', '类条件正态,同协方差', '类条件正态,异协方差', '局部相似性', '特征独立', '间隔最大化'],  # 各方法的理论假设
    # 参数化模型有固定数量的参数,非参数化模型的复杂度随数据量增长
    '参数/非参数': ['参数化', '参数化', '参数化', '非参数化', '参数化', '非参数化'],  # 模型类型分类
    # 训练速度:模型拟合(学习)的计算开销
    '训练速度': ['快', '快', '快', '慢', '很快', '中等'],  # 模型拟合的计算开销
    # 预测速度:对新样本做出分类判断的速度
    '预测速度': ['很快', '很快', '很快', '慢(需计算距离)', '很快', '中等'],  # 新样本分类预测速度
    # 对高维数据的表现:特征数量很多时(如上百个财务指标)的适应能力
    '对高维数据': ['需正则化', '需收缩共享协方差', '经典QDA在n_k≤p时失效', '距离易退化', '参数少但依赖独立性', '取决于核与正则化'],  # 区分六种方法在高维下的参数负担与失效边界
    # 鲁棒性:对异常值(极端数据点)的抵抗能力
    '鲁棒性(异常值)': ['较敏感', '较敏感', '较敏感', '依赖距离与缩放', '依赖分布估计', '依赖核与正则化'],  # 为六种方法分别说明异常值敏感性
    # 可解释性:能否直观理解模型的决策逻辑
    '可解释性': ['高', '高', '中等', '低', '高', '低']  # 各方法的可解释性等级
}  # 完成分类方法多维度对比数据字典的构建

上面的代码构建了一个全面的分类方法对比数据字典,从决策边界形状、核心假设、参数化特征到训练/预测速度和高维数据适应能力等多个维度,对本章介绍的6种主要分类方法进行了系统性整理。接下来将把这个字典转换为清晰的表格进行展示。

表 4.2: 各种分类方法的比较
# 将字典转换为 pandas DataFrame 表格对象
df_comparison = pd.DataFrame(comparison_data)  # 把六类方法的假设与计算特征对齐成比较维度
# 打印表格,index=False 表示不显示行号
print(df_comparison.to_string(index=False))  # 展示方法假设、边界与适用情境的逐行对照
       方法           决策边界         假设 参数/非参数 训练速度     预测速度          对高维数据 鲁棒性(异常值) 可解释性
     逻辑回归             线性     对数几率线性    参数化    快       很快           需正则化      较敏感    高
      LDA             线性 类条件正态,同协方差    参数化    快       很快       需收缩共享协方差      较敏感    高
      QDA             二次 类条件正态,异协方差    参数化    快       很快 经典QDA在n_k≤p时失效      较敏感   中等
KNN (第4章)          通常非线性      局部相似性   非参数化    慢 慢(需计算距离)          距离易退化  依赖距离与缩放    低
    朴素贝叶斯       取决于类条件分布       特征独立    参数化   很快       很快      参数少但依赖独立性   依赖分布估计    高
SVM (第9章) 线性核为线性;非线性核可弯曲      间隔最大化   非参数化   中等       中等       取决于核与正则化  依赖核与正则化    低

表 4.2 从多个维度比较六种分类方法。逻辑回归与 LDA 在原始特征上线性;QDA 在正态设定下为二次边界;Gaussian NB 的边界取决于各特征的类条件方差,一般可含二次项;KNN 的边界由邻域与样本决定;SVM 则取决于核,线性核仍为线性。这里没有任何方法可无条件拟合“任意”边界。计算、稳定性与可解释性也随维数、样本量、正则化和实现改变,方法选择必须以任务假设和训练内验证证据为准。

分类术语校正:高斯朴素贝叶斯的对数后验比一般含二次项,只在相应方差结构取消时才是线性边界。SVM 的边界取决于核:线性核仍为线性,RBF 等核才可弯曲。逻辑回归是参数概率模型,MLE 是估计方法,不是“非参数”的判据。

补充说明:如何阅读 ROC、AUC 与 PR-AUC

在完成模型训练后,学生最常见的第二个困惑是:为什么有时书里报告准确率,有时报告 AUC,有时又强调 PR-AUC?原因在于,这些指标各自关心的“好模型”标准不同。

  • ROC 曲线:横轴是假正率 \(FPR=\frac{FP}{FP+TN}\),纵轴是真正率 \(TPR=\frac{TP}{TP+FN}\)。当我们不断改变分类阈值时,就会得到一整条曲线。它描述的是:模型为了多抓住一些正类,需要额外付出多少误报代价。
  • AUC:是 ROC 曲线下的面积,可以理解成“模型把一个随机正类排在一个随机负类前面的概率”。AUC 越接近1,说明模型排序能力越强;AUC=0.5 基本等于随机猜测。
  • PR-AUC:对应 precision-recall 曲线下的面积。它更关注“抓出来的正类到底准不准”,因此在 ST 预警、欺诈识别、罕见病诊断这类正类很少的问题中,往往比 AUC 更敏感。

可以把三者的分工理解成:

  • 准确率回答“整体判对了多少”;
  • AUC 回答“模型排序能力强不强”;
  • PR-AUC 回答“当正类稀少时,抓风险这件事到底靠不靠谱”。

这也是为什么一个模型即使 AUC 很高,也未必就适合直接用于业务阈值决策。因为 AUC 只说明排序好,不保证概率值本身已经校准准确。若业务场景需要据此定价、授信或确定是否人工复核,我们仍需进一步检查校准曲线、精确率与召回率之间的平衡。

4.7.2 实证比较 (An Empirical Comparison)

本地 stock_basic_data.h5special_type 是数据快照时的静态状态,没有 ST 生效日或历史变更日。因此下面只能做2023 年财务特征与当前 ST 状态的同期关联分类,不是未来预警,也不能把当前状态回填到历史年份。

未来首次 ST 任务的数据契约:真正的任务必须有事件表 (order_book_id, st_effective_date)。对每个预测日 \(t\),特征只能来自 info_date <= t,标签为 \((t,t+1\text{年}]\) 内是否首次生效;按预测年份滚动验证,同一公司的重复观测不得跨训练/测试边界重叠标签窗口。缺少该事件表时,本章明确拒绝伪造“未来 ST”结果。

下面比较多种分类器对 2023Q4 财务特征与快照时 ST 状态的同期关联。随机分层切分只在这张单期横截面内衡量同分布分类表现;它不能回答公司未来是否首次 ST,也不能被解释为财务变量导致 ST。模型比较同时应对照正类比例基准,并结合 PR-AUC、校准与业务成本,而不能只看准确率。

# 在同一2023Q4横截面比较八类同期ST分类候选

import numpy as np  # 构造财务比率、基线预测与缺失标记
import pandas as pd  # 合并 2023Q4 财务快照与同期 ST 标记
import matplotlib.pyplot as plt  # 绘制同期留出校准与指标对照
import os  # 从 BOOK_DATA_DIR 环境变量取得统一数据根
from sklearn.model_selection import train_test_split, cross_val_score  # 划分同期留出集并估计训练期折间波动
from sklearn.linear_model import LogisticRegression  # 提供概率线性分类基线
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis  # 比较共享与类别特定协方差假设
from sklearn.naive_bayes import GaussianNB  # 提供条件独立高斯生成式候选
from sklearn.neighbors import KNeighborsClassifier  # 提供基于局部邻域的非参数候选
from sklearn.svm import SVC  # 比较线性核与径向基核的概率候选
from sklearn.preprocessing import StandardScaler  # 在训练折内统一距离与惩罚所依赖的特征尺度
from sklearn.pipeline import make_pipeline  # 将缩放与分类器封装在每个验证折内
from sklearn.metrics import accuracy_score, average_precision_score, brier_score_loss, roc_auc_score  # 导入准确率、PR-AUC、Brier 与 ROC-AUC 指标
from sklearn.calibration import calibration_curve  # 将预测概率分箱以核对同期留出集校准

# 固定中文出版字体与负号显示
plt.rcParams['font.family'] = ['Source Han Serif SC']  # 统一使用思源宋体
# 解决负号显示为方块的问题
plt.rcParams['axes.unicode_minus'] = False  # 保证财务比率图中的负值符号可辨认
# 解析同期ST案例的统一数据根
book_data_dir_value = os.environ.get('BOOK_DATA_DIR')  # 安全读取环境变量而不触发KeyError
assert book_data_dir_value, '请先设置 BOOK_DATA_DIR,使其指向包含 stock/ 子目录的数据根'  # 提供可执行修复方向
BOOK_DATA_DIR = Path(book_data_dir_value).expanduser().resolve()  # 解析统一数据根
assert BOOK_DATA_DIR.is_dir(), f'BOOK_DATA_DIR 不存在: {BOOK_DATA_DIR}'  # 在读取前验证目录
# 财务报表数据路径(包含总资产、总负债、净利润等)
FINANCE_PATH = BOOK_DATA_DIR / 'stock/financial_statement.h5'  # 定位构造ROA与杠杆率的年报输入
# 股票基本信息数据路径(包含股票代码、名称等)
BASIC_PATH = BOOK_DATA_DIR / 'stock/stock_basic_data.h5'  # 定位构造ST标签的公司属性输入
missing_classification_paths = [path for path in (FINANCE_PATH, BASIC_PATH) if not path.is_file()]  # 同时核对特征表和标签表的物理输入
assert not missing_classification_paths, f'缺少分类案例文件: {[str(path) for path in missing_classification_paths]};请检查 BOOK_DATA_DIR'  # 在读取前列出全部缺失路径
# 读取构造同期特征与标签所需的两张表
# 选择性读取2023年年报所需的财务字段,避免全量载入全部季度和全部列
financial_data_df = pd.read_hdf(  # 从HDF5文件中按季度与列选择性读取ST判别案例所需财务数据
    FINANCE_PATH,  # 使用已核验的财务报表输入
    where="quarter='2023q4'",  # 仅保留2023年第四季度年报记录
    columns=['quarter', 'order_book_id', 'net_profit', 'total_assets', 'total_liabilities']  # 只读取构造ROA、资产负债率及合并键所需字段
).copy()  # 创建独立副本用于后续特征构造
# 读取股票基本信息中的股票代码与ST标记字段,避免载入无关列
basic_info_df = pd.read_hdf(  # 从HDF5文件中读取股票基本信息子集
    BASIC_PATH,  # 使用已核验的公司属性输入
    columns=['order_book_id', 'special_type']  # 只读取合并键与ST识别所需字段
).copy()  # 创建副本用于后续合并与标签构造

# 将财务数据与基本信息合并,通过股票代码关联
# how='inner' 表示只保留两边都有的记录
# 将财务报表数据与股票基本信息按股票代码内连接合并,获取ST标识字段
merged_financial_data = pd.merge(financial_data_df, basic_info_df[['order_book_id', 'special_type']], on='order_book_id', how='inner')

# 构造规模、盈利与杠杆三项财务特征
# 特征 1:ROA(资产收益率)= 净利润 / 总资产
# ROA 衡量企业利用资产创造利润的能力,ST 公司通常 ROA 很低甚至为负
# 计算资产收益率ROA:净利润与总资产的比值
merged_financial_data['roa'] = merged_financial_data['net_profit'] / merged_financial_data['total_assets']
# 特征 2:资产负债率 = 总负债 / 总资产
# 衡量企业的财务杠杆水平,ST 公司通常负债率偏高
# 计算资产负债率:总负债与总资产的比值
merged_financial_data['debt_to_assets'] = merged_financial_data['total_liabilities'] / merged_financial_data['total_assets']
# 特征 3:资产规模的对数 = log10(总资产 + 1)
# 取对数是为了压缩数据范围(总资产可能从几百万到几千亿差异巨大)
# 计算对数资产规模,加1避免对零取对数
merged_financial_data['log_size'] = np.log10(merged_financial_data['total_assets'] + 1)

# 固定有限值与支持域筛选规则
# 将无穷大值替换为缺失值 NaN,然后删除含有 NaN 的行
# 对三个关键特征列执行缺失值清洗
merged_financial_data = merged_financial_data.replace([np.inf, -np.inf], np.nan).dropna(subset=['roa', 'debt_to_assets', 'log_size'])
# 资产负债率应该在 0~200% 之间(超过 200% 的很可能是数据错误)
# 筛选资产负债率在合理范围内的记录
merged_financial_data = merged_financial_data[(merged_financial_data['debt_to_assets'] >= 0) & (merged_financial_data['debt_to_assets'] <= 2)]
# 总资产至少大于 100 万(过小可能是壳公司或数据异常)
# 排除总资产过小的异常记录
merged_financial_data = merged_financial_data[merged_financial_data['total_assets'] > 1e6]

# 从快照字段构造同期ST状态标签
# 在中国 A 股市场,stock_basic_data 的 special_type 列标识了公司是否为 ST
# special_type 列包含 'ST'/'*ST' 等标识,正常公司为 'Normal' 或 NaN
# 填充缺失值并根据special_type字段构造ST二元标签
merged_financial_data['is_st'] = merged_financial_data['special_type'].fillna('Normal').str.contains('ST').astype(int)

# 提取特征矩阵 X(3 列:ROA、资产负债率、资产规模对数)
features_matrix = merged_financial_data[['roa', 'debt_to_assets', 'log_size']].values  # 锁定同期 ST 分类的三项财务特征顺序
# 提取标签向量 Y(0 = 正常公司,1 = ST 公司)
st_target_labels = merged_financial_data['is_st'].values  # 取快照时点 ST 状态而非未来预警标签

上面的代码从 2023Q4 财务报表提取 ROA、资产负债率和对数资产规模,并由快照字段 special_type 构建同期标签。接下来在这一单期横截面内做分层训练/测试切分;结果只评价同分布的同期分类。

# 分层划分同分布拟合与留出样本
# test_size=0.3 表示 30% 的数据用于测试,70% 用于训练
# random_state=42 保证每次分割结果一致
# stratify=st_target_labels 按标签比例分层抽样,保证训练集和测试集中 ST 公司的比例相同
# 按70/30比例分层抽样切分训练集和测试集,保持ST样本比例一致
features_train, features_test, target_train, target_test = train_test_split(features_matrix, st_target_labels, test_size=0.3, random_state=42, stratify=st_target_labels)

# 标准化不在此处预拟合;下方每个 Pipeline 会在训练折内部学习缩放参数

# 固定八类候选及各自预处理管道
# 使用字典存储模型名称和对应的模型对象
models = {  # 定义包含8种主流分类算法的模型字典
    # 逻辑回归:max_iter=1000 设置最大迭代次数,防止不收敛
    '逻辑回归 (Logistic)': LogisticRegression(max_iter=1000),  # 构建逻辑回归分类器
    # 线性判别分析
    'LDA': LinearDiscriminantAnalysis(),  # 估计共享协方差的线性判别候选
    # 二次判别分析
    'QDA': QuadraticDiscriminantAnalysis(),  # 估计类别特定协方差的二次判别候选
    # 高斯朴素贝叶斯
    '朴素贝叶斯 (NB)': GaussianNB(),  # 构建高斯朴素贝叶斯分类器
    # K 近邻(K=5):根据最近的 5 个邻居投票决定分类
    'KNN (K=5)': KNeighborsClassifier(n_neighbors=5),  # 构建K近邻模型并拟合训练数据
    # K 近邻(K=20):更多邻居,决策更平滑但可能不够灵敏
    'KNN (K=20)': KNeighborsClassifier(n_neighbors=20),  # 构建K近邻模型并拟合训练数据
    # SVM 线性核:在高维空间中找到最大间隔的超平面
    'SVM (线性核)': SVC(kernel='linear', probability=True),  # 构建线性核支持向量机分类器
    # SVM RBF 核:通过径向基函数映射到无限维空间,能处理非线性问题
    'SVM (RBF核)': SVC(kernel='rbf', probability=True)  # 构建径向基函数核SVM分类器
}  # 完成分类模型字典的定义

数据已完成单期横截面的分层 70/30 切分和标准化。下面逐一拟合 8 种分类器,并用训练集内部的分层五折估计同期分布下的分类误差;这不是跨期泛化或未来预警评估。

# 在相同分层切分上估计概率与分类指标
# 创建空字典存放结果
results = {}  # 按模型名累计同期留出与折内验证指标
test_probabilities = {}  # 保存留出集概率以检查校准
for name, model in models.items():  # 遍历模型字典中的每个分类器进行训练和评估
    fold_model = make_pipeline(StandardScaler(), model)  # 保证每个 CV 折只用折内训练数据拟合缩放器
    fold_model.fit(features_train, target_train)  # 在原始训练特征上拟合完整管道
    # 计算训练集准确率(模型在已见过的数据上的表现)
    train_score = fold_model.score(features_train, target_train)  # 计算管道训练评分
    # 计算同一期留出样本准确率
    test_score = fold_model.score(features_test, target_test)  # 使用训练期缩放参数评估测试集
    test_probability = fold_model.predict_proba(features_test)[:, 1]  # 提取同期留出集的 ST 正类概率
    test_probabilities[name] = test_probability  # 按模型名保留概率以供校准诊断

    # 5 折交叉验证:将训练集分成 5 份,轮流用 4 份训练、1 份验证
    # 这能更稳健地评估模型性能,减少"运气"成分
    cv_scores = cross_val_score(fold_model, features_train, target_train, cv=5)  # 缩放和拟合都在折内完成

    # 将结果存入字典
    results[name] = {  # 将当前模型的评估指标存入结果字典
        '训练准确率': train_score,  # 训练集上的分类准确率
        '测试准确率': test_score,  # 同期横截面留出集准确率
        'AUROC': roc_auc_score(target_test, test_probability),  # 衡量跨阈值排序质量
        'PR-AUC': average_precision_score(target_test, test_probability),  # 关注稀少 ST 正类的排序
        'Brier': brier_score_loss(target_test, test_probability),  # 衡量概率预测平方误差
        'CV均值': cv_scores.mean(),      # 5 折的平均准确率
        'CV标准差': cv_scores.std()       # 5 折准确率的标准差(衡量稳定性)
    }  # 完成当前模型结果的存储

模型循环完成后,下一块只组装并打印比较表。

# 汇总训练、留出与交叉验证证据
# 将结果字典转换为 DataFrame,.T 表示转置(行列互换)
df_results = pd.DataFrame(results).T  # 让每个模型占一行、每项指标占一列
print('=' * 80)  # 为同期横截面结果表添加控制台分隔线
print('分类方法性能比较(2023Q4 财务特征与当前 ST 状态)')  # 明确结果仅对应同期横截面任务
print('=' * 80)  # 闭合结果表标题分隔线
# round(4) 保留 4 位小数
print(df_results.round(4))  # 用四位小数保持概率指标可比较
training_event_rate = target_train.mean()  # 只用训练标签估计事件率
training_majority_class = int(training_event_rate >= 0.5)  # 只用训练标签锁定常数类别
majority_predictions = np.repeat(training_majority_class, len(target_test))  # 在测试集保持训练期类别不变
print(f'训练期事件率/PR-AUC朴素基准: {training_event_rate:.4f}')  # 不使用测试比例定义基线
print(f'训练期锁定多数类测试准确率: {accuracy_score(target_test, majority_predictions):.4f}')  # 仅用测试标签评价
================================================================================
分类方法性能比较(2023Q4 财务特征与当前 ST 状态)
================================================================================
                  训练准确率   测试准确率   AUROC  PR-AUC   Brier    CV均值   CV标准差
逻辑回归 (Logistic)  0.9628  0.9635  0.8366  0.2409  0.0323  0.9634  0.0016
LDA              0.9631  0.9604  0.8223  0.2363  0.0344  0.9625  0.0049
QDA              0.9596  0.9554  0.8563  0.2143  0.0382  0.9593  0.0047
朴素贝叶斯 (NB)       0.9601  0.9566  0.8643  0.2165  0.0376  0.9601  0.0046
KNN (K=5)        0.9695  0.9617  0.6947  0.1177  0.0355  0.9628  0.0036
KNN (K=20)       0.9647  0.9629  0.8201  0.1964  0.0311  0.9636  0.0009
SVM (线性核)        0.9639  0.9642  0.8478  0.2371  0.0345  0.9639  0.0005
SVM (RBF核)       0.9658  0.9642  0.5994  0.1647  0.0344  0.9639  0.0005
训练期事件率/PR-AUC朴素基准: 0.0361
训练期锁定多数类测试准确率: 0.9642
calibration_observed, calibration_predicted = calibration_curve(  # 按预测概率分位数组成六个校准箱
    target_test, test_probabilities['逻辑回归 (Logistic)'],  # 对照同期留出标签与逻辑回归概率
    n_bins=6, strategy='quantile',  # 让各箱样本量尽量接近以稳定观测比例
)  # 返回每箱实际 ST 比例与平均预测概率
plt.figure(figsize=(6, 5))  # 为校准曲线建立方形画布
plt.plot([0, 1], [0, 1], '--', color='gray', label='理想校准')  # 绘制预测概率等于事件率的参照线
plt.plot(calibration_predicted, calibration_observed, 'o-', label='逻辑回归')  # 连接各概率箱的经验校准点
plt.xlabel('同期 ST 状态分类概率')  # 横轴表示箱内平均预测概率
plt.ylabel('箱内实际 ST 比例')  # 纵轴表示箱内经验事件率
plt.title('同期 ST 分类的留出集校准')  # 限定图示的同期留出评价范围
plt.legend()  # 标识理想线与模型曲线
plt.grid(alpha=0.3)  # 添加浅色网格以便比较偏离幅度
plt.show()  # 输出同期留出集校准诊断图
同期留出集中逻辑回归的分箱平均预测概率对实际正类率折线,叠加四十五度理想线。
图 4.4: 预先指定逻辑回归在同期留出集上的校准图;对角线表示理想校准。

图 4.4 用于检查概率质量而不是排序能力;它必须与上表的 Brier、PR-AUC 和训练期基线一起解释。

上表展示了 8 种方法在这项同期 ST 分类任务上的训练准确率、测试准确率和五折交叉验证结果。正类占比应由当次数据输出;若类别不平衡,全部预测为多数类也会得到很高准确率。因此需要同时报告多数类基准、PR-AUC 与概率校准。下面的条形图只展示本次横截面切分结果,不是未来泛化证据。

# 并排比较同期留出准确率与训练折估计
# 在同一同期留出任务上并列展示各分类器的动态评价指标
fig, ax = plt.subplots(figsize=(14, 7))  # 将同期留出准确率与训练折均值置于同一坐标系

# 提取各方法的名称和评估指标
methods = list(results.keys())  # 提取所有模型名称列表
test_scores = [results[m]['测试准确率'] for m in methods]  # 提取各模型的测试集准确率
cv_means = [results[m]['CV均值'] for m in methods]  # 提取各模型的交叉验证均值
cv_stds = [results[m]['CV标准差'] for m in methods]  # 提取各模型的交叉验证标准差

# 设置分组条形图的位置
x = np.arange(len(methods))       # 每组的中心位置
width = 0.35                       # 每根柱子的宽度

# 绘制测试集准确率条形图(蓝色,偏左)
ax.bar(x - width/2, test_scores, width, label='测试集准确率',  # 左柱编码同期留出准确率
      color='steelblue', alpha=0.8, edgecolor='black', linewidth=1.5)  # 设置柱状图为钢蓝色,半透明,黑色边框
# 绘制交叉验证准确率条形图(珊瑚色,偏右)
# yerr 添加误差线(±1 个标准差),capsize 设置误差线帽的宽度
ax.bar(x + width/2, cv_means, width, yerr=cv_stds, label='交叉验证准确率(±1SD)',  # 右柱编码训练折均值及波动
      color='coral', alpha=0.8, edgecolor='black', linewidth=1.5, capsize=5)  # 设置柱状图为珊瑚色,添加误差线帽

# 标明横轴模型身份与纵轴准确率口径
ax.set_xlabel('分类方法', fontsize=13, fontweight='bold')  # 标明每组柱对应的分类候选
ax.set_ylabel('准确率', fontsize=13, fontweight='bold')  # 标明柱高采用同一准确率尺度
ax.set_title('分类方法性能比较\n(Classification Methods Performance Comparison)',  # 标明比较的是同一留出集上的方法表现
            fontsize=14, fontweight='bold', pad=15)  # 标明图中比较范围为同期横截面
# 设置 x 轴刻度标签,旋转 45 度以防重叠
ax.set_xticks(x)  # 将每组两根柱定位到同一模型中心
ax.set_xticklabels(methods, rotation=45, ha='right')  # 旋转模型名称以避免类别标签重叠
ax.legend(fontsize=11, loc='lower right')  # 区分同期留出与训练折估计
# 添加浅色水平网格线
ax.grid(True, alpha=0.3, axis='y')  # 添加子图网格线
# 设置 y 轴范围为 70%~100%,聚焦有意义的区间
ax.set_ylim([0.7, 1.0])  # 聚焦候选间可见差异并保留共同尺度

# 在每根柱子顶部添加数值标签,便于精确读数
for i, (test, cv) in enumerate(zip(test_scores, cv_means)):  # 遍历每个模型的准确率数据以添加柱顶标签
    # 测试集准确率标签
    ax.text(i - width/2, test + 0.01, f'{test:.3f}',  # 在测试集柱子顶部添加数值标签
           ha='center', va='bottom', fontsize=9, fontweight='bold')  # 在留出柱上标出现场准确率
    # 交叉验证准确率标签
    ax.text(i + width/2, cv + 0.01, f'{cv:.3f}',  # 在交叉验证柱子顶部添加数值标签
           ha='center', va='bottom', fontsize=9, fontweight='bold')  # 在训练折柱上标出平均准确率

# 自动调整布局
plt.tight_layout()  # 避免旋转模型名与数值标签发生重叠
plt.show()  # 输出同期留出与折内估计的差异供稳定性判断
多种分类器在 2023Q4 同期 ST 留出集上的准确率、AUROC、PR-AUC 和 Brier 指标对比。
图 4.5: 2023Q4 财务特征与当前 ST 状态的同期分类表现;该图不表示未来预警能力。

图 4.5 给出当次横截面切分的结果。应从运行输出比较各方法与多数类基准的差距,并检查误差条、PR-AUC 和校准;不能预先断言哪一种方法稳定或存在过拟合。即使同期分类有效,也只能说明条件关联,未来风险预警仍需带生效日的事件表和前向测试。

4.8 本章小结 (Chapter Summary)

本章介绍了分类问题的基础知识:

  1. 分类 vs. 回归:分类问题的响应变量是定性的;统计模型通常先估计条件类别概率,再按代价与阈值形成标签或行动。

  2. 为什么不能用线性回归

    • 编码方式影响结果
    • 预测值可能超出合理范围
    • 难以处理多分类问题
  3. 逻辑回归

    • 使用逻辑函数将预测值限制在\([0,1]\)区间
    • 通过最大似然估计系数
    • 可以解释为概率
  4. 判别分析

    • LDA 假设类条件正态且协方差相同,产生线性决策边界;共享协方差降低了相对 QDA 的参数负担,但高维时仍可能需要收缩
    • QDA 允许不同协方差,产生二次决策边界;每类都需估计完整协方差,当 \(n_k\leq p\) 时经典估计必然奇异
  5. 朴素贝叶斯

    • 基于特征独立性假设
    • 参数规模随 \(p\) 线性增长,适合作为高维快速基线;强相关特征可能被重复计分并破坏概率校准
  6. 方法选择

    • 逻辑回归:可复核系数关联与预测概率,但不自动支持因果推断
    • LDA:共享协方差可降低估计方差;维数接近样本量时仍需收缩或训练折内降维
    • QDA:允许类别协方差不同;仅在各类协方差可稳定估计且验证表现支持时采用
    • 朴素贝叶斯:高维与实时任务中的低参数基线;独立性错设须由概率损失和校准检验

在接下来的章节中,我们将学习更多高级的分类方法,包括支持向量机、树方法和集成方法等。

4.9 理论来源与前沿

分类方法的经典理论来源主要有两条路径。线性判别分析的历史归因与鸢尾花分类实例见 Fisher 的原始论文 (Fisher 1936年);本章其余分类框架与评价边界以标准统计学习教材为综合入口 (James 等 2023年)

  1. 判别式建模(如逻辑回归):直接建模 \(P(Y\mid X)\),强调参数可解释性与预测概率的校准。
  2. 生成式建模(如 LDA、QDA、朴素贝叶斯):建模 \(P(X\mid Y)\)\(P(Y)\),但不能把三者在小样本高维下合并评价。共享协方差使 LDA 的负担低于 QDA;类别特定完整协方差使 QDA 在 \(n_k\leq p\) 时失效;朴素贝叶斯以条件独立换取线性参数规模。结构假设是否值得接受,必须由训练内验证、概率损失与校准证据判断。

近年来的前沿关注点包括:

  • 类别不平衡与代价敏感分类:在欺诈识别、违约预测等场景中,错误代价不对称,需要用阈值选择、重采样或加权损失来对齐业务目标。
  • 概率校准与决策:从‘分类对错’走向‘概率质量’,让模型输出可直接进入风控定价与策略优化。
  • 可解释与合规:在中国的金融监管语境下,分类模型需要更强的可复核性与稳定性说明。

4.10 综合案例:A 股风险概率与决策阈值

本章综合案例不另造标签,而是严格消费 小节 2.7 定义的公司—预测日样本。为保证从干净内核运行,下面在本章内定义并调用同一 BOOK_DATA_DIR 构造器;特征、真实峰谷最大回撤标签、日期边界和四项返回对象均与第 2 章一致,不读取外部辅助脚本。逻辑回归提供透明概率基线,LDA、QDA 与朴素贝叶斯是否有增量取决于分布近似、样本维数、正则化和开发期验证结果。

先定义前向最大回撤函数。对路径 \(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};请检查 BOOK_DATA_DIR'  # 在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)  # 在当前干净内核构造共享对象
m04_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) == m04_expected_columns  # 在分类前阻止字段、目标或顺序漂移

本章只使用训练段和验证段选择候选与阈值;m02_test_keys 只核对身份,测试标签和测试成绩留到第 9 章。

4.11 练习

4.11.1 概念题

  1. [核心|难度:1|分值:5|任务:概念] 为什么逻辑回归是参数模型?MLE 在这里扮演什么角色?

  2. [核心|难度:2|分值:8|任务:概念] 比较逻辑回归、LDA、QDA 与高斯朴素贝叶斯的假设、参数负担和边界,并说明 QDA 在何种高维条件下必然失效。

  3. [核心|难度:2|分值:6|任务:概念] 解释为什么 AUROC 高不保证概率校准良好。

  4. [核心|难度:2|分值:6|任务:概念] 说明逻辑回归为什么不天然对离群点、高杠杆点或完全分离鲁棒。

4.11.2 应用题

  1. [核心|难度:2|分值:10|任务:决策] 给定验证集概率与误判成本 \(C_{FN}=8\)\(C_{FP}=1\),说明如何选择阈值并在测试集评价。

  2. [核心|难度:3|分值:20|任务:综合案例] 沿用第 2 章的公司—预测日样本,比较训练期事件率、逻辑回归、LDA、QDA 与朴素贝叶斯对“未来 20 个公司交易日内最大回撤是否达到 10%”的概率预测。说明特征时点、训练—验证—测试边界和评价指标。

4.11.3 理论题

  1. [拓展|难度:3|分值:10|任务:推导] 从 Bernoulli 对数似然推导逻辑回归得分方程。

  2. [拓展|难度:3|分值:10|任务:推导] 推导 LDA 在共享协方差下的线性判别函数,并说明 QDA 为何出现二次项。

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

4.12 练习参考解答

4.12.1 概念题参考解答

  1. 逻辑回归用有限维系数 \(\boldsymbol\beta\) 指定 \[P(Y=1\mid X=x)=\{1+\exp(-x^T\boldsymbol\beta)\}^{-1},\] 所以是参数概率模型。MLE 是依据 Bernoulli 似然估计这些系数的方法,不是“参数/非参数”的分类标准。

  2. 逻辑回归假设 log-odds 对特征线性,估计 \(O(Kp)\) 量级的系数,二分类的 \(0.5\) 等概率边界为超平面。LDA 假设各类条件正态且共享一个含 \(p(p+1)/2\) 个自由元素的协方差矩阵;类别比较时共同二次项抵消,边界为线性。QDA 允许每类协方差不同,协方差参数增至 \(Kp(p+1)/2\),二次项不能抵消,边界一般为二次曲面;当任一类 \(n_k\leq p\) 时,样本协方差秩至多为 \(n_k-1\),经典 QDA 必然无法求逆。高斯朴素贝叶斯以条件独立把参数负担降为约 \(2Kp\),但相关特征可能被重复计分并导致过度自信。任一方法是否更好仍取决于结构错设、正则化与同一验证设计下的概率损失。

  3. AUROC 只使用概率排序:任何严格单调变换都保持排序,却可能改变概率绝对水平。校准要求预测为 0.2 的样本中长期约有 20% 成为正类,应使用 Brier 分数、对数损失和校准曲线评价。

  4. sigmoid 只把线性预测子映射到 \((0,1)\)。极端特征会产生高杠杆,错标点会强烈影响似然,完全分离则使无惩罚 MLE 的系数趋向无穷。应检查影响诊断与分离,并考虑数据核查、正则化或适合的偏差修正估计。

4.12.2 应用题参考解答

  1. 在验证集上对预先给定的阈值网格 \(\mathcal T\) 计算 \[L(t)=8\,FN(t)+1\,FP(t),\] 选择使 \(L(t)\) 最小的 \(t^*\);若并列,采用事先规定的更保守规则。锁定模型与 \(t^*\) 后只在测试集使用一次,报告混淆矩阵、每观测平均成本、AUROC、PR-AUC、Brier 与校准图。测试集不得再次选择阈值,否则评价会乐观偏误。

  2. 参考实现与评分量规(20 分):沿用本章在干净内核中构造的项目样本,只使用其中的预测日、公司键、训练期可得特征和二元回撤标签。训练集估计标准化参数与事件率基线,验证集比较候选;测试段继续封存,统一留到第 9 章评价。逻辑回归、LDA、QDA 和朴素贝叶斯都通过 predict_proba() 输出正类概率,再与常数事件率比较 Brier、对数损失、AUROC、PR-AUC 和校准。

    • 观测与样本(4 分):公司—预测日键唯一,覆盖多家公司和足够的正负事件。
    • 标签(4 分):未来 20 个公司交易日最大回撤达到 10% 记为 1;窗口未结束的末端行保持未知。
    • 特征时点(4 分):行情特征只用预测日及以前信息;任何财务特征按实际披露日滞后。
    • 切分与基线(4 分):较早日期训练,中间日期选模型和阈值,最新测试边界预先固定但本章不打开;事件率基线只由训练期计算。
    • 重复评价(4 分):用同一前向折比较候选的概率质量,并报告事件稀少、市场状态变化和单一时期的限制。

下面给出可直接运行的核心结构。第一块只准备开发期矩阵并声明候选,所有尺度变换都封装在训练管道内。

from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis  # 导入生成式判别候选
from sklearn.linear_model import LogisticRegression  # 导入参数概率基线
from sklearn.metrics import average_precision_score, brier_score_loss, log_loss, roc_auc_score  # 导入概率与排序指标
from sklearn.naive_bayes import GaussianNB  # 导入高斯朴素贝叶斯候选
from sklearn.pipeline import make_pipeline  # 把缩放限定在训练拟合中
from sklearn.preprocessing import StandardScaler  # 统一连续特征尺度

m04_train_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'train'].copy()  # 只读取固定训练行
m04_validation_sample = m02_shared_sample.loc[m02_shared_sample['split'] == 'validation'].copy()  # 只读取固定验证行
m04_train_target = m04_train_sample['drawdown_event_20d'].astype(int)  # 提取训练回撤事件
m04_validation_target = m04_validation_sample['drawdown_event_20d'].astype(int)  # 提取验证回撤事件
m04_models = {'logistic': make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)), 'lda': make_pipeline(StandardScaler(), LinearDiscriminantAnalysis()), 'qda': make_pipeline(StandardScaler(), QuadraticDiscriminantAnalysis(reg_param=0.10)), 'naive_bayes': make_pipeline(StandardScaler(), GaussianNB())}  # 声明有限候选集合
for m04_model in m04_models.values():  # 对全部候选执行同一训练流程
    m04_model.fit(m04_train_sample[m02_features], m04_train_target)  # 仅用训练段估计参数

第二块在共同验证键上输出 表 4.3mean_probability 应与 observed_rate 同量级,但完整校准仍需查看分箱曲线。

表 4.3: 第4章分类器在共同验证期的参考输出
m04_observed_rate = float(m04_validation_target.mean())  # 记录验证事件率以解释PR-AUC和平均概率
m04_records = [{'model': 'training_event_rate', 'brier': brier_score_loss(m04_validation_target, np.repeat(m02_training_event_rate, len(m04_validation_target))), 'log_loss': log_loss(m04_validation_target, np.repeat(m02_training_event_rate, len(m04_validation_target))), 'auc': 0.5, 'pr_auc': m04_observed_rate, 'mean_probability': m02_training_event_rate, 'observed_rate': m04_observed_rate}]  # 建立训练事件率基线
for m04_name, m04_model in m04_models.items():  # 在相同验证键上读取各模型概率
    m04_probability = m04_model.predict_proba(m04_validation_sample[m02_features])[:, 1]  # 取得合法正类概率
    m04_records.append({'model': m04_name, 'brier': brier_score_loss(m04_validation_target, m04_probability), 'log_loss': log_loss(m04_validation_target, m04_probability), 'auc': roc_auc_score(m04_validation_target, m04_probability), 'pr_auc': average_precision_score(m04_validation_target, m04_probability), 'mean_probability': float(m04_probability.mean()), 'observed_rate': m04_observed_rate})  # 汇总概率、排序与粗校准证据
m04_reference_table = pd.DataFrame(m04_records)  # 形成可复核结果表
print(m04_reference_table.to_string(index=False))  # 输出所有必需指标
              model    brier  log_loss      auc   pr_auc  mean_probability  observed_rate
training_event_rate 0.229024  0.651461 0.500000 0.344957          0.289620       0.344957
           logistic 0.207203  0.601904 0.691998 0.531940          0.294381       0.344957
                lda 0.207934  0.604193 0.691940 0.531898          0.287658       0.344957
                qda 0.218794  0.650696 0.691955 0.535069          0.276945       0.344957
        naive_bayes 0.222043  0.670745 0.690348 0.530042          0.274847       0.344957

阅读时先找 Brier 与对数损失是否同时低于训练事件率基线,再看 AUROC、PR-AUC 是否提供一致的排序证据。若 QDA 或朴素贝叶斯只提高 AUROC 却恶化概率损失,不能称其风险概率更好;本章也不依据这张验证表打开或反复选择测试结果。

4.12.3 理论题参考解答

  1. \(p_i=\sigma(x_i^T\boldsymbol\beta)\)。Bernoulli 对数似然为 \[\ell(\boldsymbol\beta)=\sum_i[y_i\log p_i+(1-y_i)\log(1-p_i)].\]\(\partial p_i/\partial\boldsymbol\beta=p_i(1-p_i)x_i\)\[\frac{\partial\ell}{\partial\boldsymbol\beta} =\sum_i\left(\frac{y_i}{p_i}-\frac{1-y_i}{1-p_i}\right)p_i(1-p_i)x_i =\sum_i x_i(y_i-p_i)=X^T(y-p).\] MLE 满足得分方程 \(X^T(y-p)=0\);通常需数值迭代,完全分离时有限解可能不存在。

  2. 对类别 \(k\)\[\log p_k(x)=-\frac12(x-\mu_k)^T\Sigma^{-1}(x-\mu_k)+\log\pi_k+C.\] 展开后,\(-\frac12x^T\Sigma^{-1}x\) 对所有类相同,比较时抵消,得到 \[\delta_k(x)=x^T\Sigma^{-1}\mu_k -\frac12\mu_k^T\Sigma^{-1}\mu_k+\log\pi_k,\] 它对 \(x\) 线性。QDA 把 \(\Sigma\) 改为 \(\Sigma_k\),于是 \(-\frac12x^T\Sigma_k^{-1}x\) 随类别改变,不能抵消,边界一般含二次项。

4.13 章末回顾

本章的核心不是把概率强行变成 0/1,而是区分概率模型、排序指标、校准与行动成本。逻辑回归、LDA、QDA 和朴素贝叶斯都是带条件的方法;选择必须依赖任务假设和同一验证设计。受控模拟只说明边界几何,真实 A 股数据才用于经验评价,而且样本外预测仍不等于可交易或因果证据。

无提示检索

  1. 为什么 AUROC 较高并不保证 Brier 分数或概率校准更好?
  2. 为什么行动阈值必须在验证数据上按成本选择,而不能在最终测试集上调整?
  3. QDA 相对 LDA 增加了什么协方差假设代价,应用时需要什么证据?
展开检索反馈与定向返回

若第 1 题不确定,返回“ROC、AUC 与 PR-AUC”并对照校准曲线;若第 2 题不确定,返回练习 5 的成本矩阵;若第 3 题不确定,返回“LDA 与 QDA”的共享协方差假设。

下一章将用前向重采样估计候选模型的样本外误差,使模型和阈值的选择不接触最终测试期。

Fisher, R. A. 1936年. 《The Use of Multiple Measurements in Taxonomic Problems》. Annals of Eugenics 7 (2): 179~88. https://doi.org/10.1111/j.1469-1809.1936.tb02137.x.
James, Gareth, Daniela Witten, Trevor Hastie, Robert Tibshirani, 和 Jonathan Taylor. 2023年. An Introduction to Statistical Learning: With Applications in Python. Springer. https://doi.org/10.1007/978-3-031-38747-0.