4  分类 (Classification)

4.1 本章学习契约

先修:掌握第 3 章的线性预测子和非因果边界;能区分训练、验证与测试。

学习目标与达成标准

  1. 在概率、log-odds 和线性预测子间转换;标准是手算给定预测子的概率。
  2. 比较逻辑回归、LDA、QDA 和朴素贝叶斯的假设;标准是正确判断边界何时线性。
  3. 区分排序、概率质量和硬分类成本;标准是同时解释 AUROC/PR-AUC、Brier/校准和阈值损失。
  4. 用训练内验证锁定代价敏感阈值;标准是测试标签不参与选阈值。
  5. 区分同期 ST 关联与未来风险预测;标准是明确列出未来任务所需事件日和 info_date

4.1.1 入口检查与补修

入口题:一个模型 AUROC 高是否保证预测概率已校准?为什么?

作答后展开答案、门槛与补修

答案与门槛:不保证;AUROC 只衡量正负类排序,概率绝对数值要用 Brier 分数和校准图评估。能说出两层区别才通过;否则复习第 2 章损失与本章概率映射。

4.1.2 低风险检索、渐隐链与迁移

检索:闭卷写出 sigmoid 和 LDA 共享协方差假设,再列出一个不平衡任务的朴素基线。

渐隐链:先用给定成本表选阈值;再只给训练内验证概率自行计算;最后在陌生的供应商断供任务中定义事件日、概率指标和业务损失。

4.1.3 目标—评价证据映射

目标 学习活动 评价证据
1—2 入口检查与模型假设检索 练习 1—3、9—10
3—4 阈值渐隐链 练习 4—5、7
5 ST 时点审计与陌生迁移 练习 6、8、小节 4.9

4.2 引言 (Introduction)

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

分类 vs. 回归的对比

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

案例背景:A股市场涨跌预测

在本章中,我们将使用一个来自中国A股市场的真实场景案例:预测股票市场方向。投资者希望根据过去几天的市场表现预测明天的股市是上涨(Up)还是下跌(Down)。这有助于:

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

数据集:我们将使用 Tushare 提供的真实日度行情数据,涵盖以下变量:

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

Note on 分类问题的理论基础

分类问题的统计理论基础可以追溯到费歇尔(Ronald A. Fisher)在1936年提出的线性判别分析(Linear Discriminant Analysis, LDA)。费歇尔当时研究的是鸢尾花分类问题,这个经典数据集至今仍被广泛用于教学。

另一个重要里程碑是逻辑回归(Logistic Regression)的发展,它最初由Verhulst在1845年提出用于人口增长建模,后来在1958年由Berkson引入生物统计学领域。

到了20世纪80-90年代,随着Vapnik等人提出支持向量机(SVM),以及Friedman等人提出广义加法模型(GAM),分类方法得到了快速发展。

4.3 分类问题概述 (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.3.1 两分类 vs. 多分类

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

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

4.3.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]\) 内的概率;“概率映射非线性”不等于决策边界在所有设定下都是非线性的。

# =====================================================================
# 本代码演示:为什么分类问题不能直接使用线性回归?
# 通过对比线性回归和逻辑回归在同一数据上的表现来揭示答案
# =====================================================================

# 导入数值计算库 numpy,用于生成模拟数据和数学运算
import numpy as np  # 导入numpy库
# 导入数据处理库 pandas(本代码块虽未直接使用,但保持导入一致性)
import pandas as pd  # 导入pandas库
# 导入绘图库 matplotlib,用于绑制对比图
import matplotlib.pyplot as plt  # 导入matplotlib库
# 从 scikit-learn 导入线性回归和逻辑回归两个模型
# LinearRegression:普通最小二乘线性回归(用于回归问题)
# LogisticRegression:逻辑回归(专门用于分类问题)
from sklearn.linear_model import LinearRegression, LogisticRegression  # 从sklearn.linear_model导入所需模块

# 设置随机种子为 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)  # 设置子图X轴标签
ax1.set_ylabel('Predicted Direction', fontsize=12)  # 设置子图Y轴标签
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)  # 设置子图X轴标签
ax2.set_ylabel('Probability of Up', fontsize=12)  # 设置子图Y轴标签
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]\)区间
    • 可以解释为概率
    • 对离群点更加鲁棒

4.4 逻辑回归 (Logistic Regression)

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

4.4.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,模型就知道自己低估了风险;如果某个正常客户被打到了0.90,模型就知道自己过度悲观了。

  4. 反复更新参数直到整体误差最小。最大似然估计并不是“一步算出答案”,而是利用梯度不断调整参数,让真实为1的样本整体上得到更高概率,真实为0的样本整体上得到更低概率。换句话说,逻辑回归并不是在直接拟合类别标签,而是在拟合“每个样本属于正类的概率结构”。

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

4.4.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.2}\]

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

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

对数似然函数 (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.4}\]

最大似然估计选择使对数似然函数最大的 \((\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.5}\]

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

为了优化这个对数似然函数,我们需要对其求导。令 \(p_i = p(x_i) = \sigma(\beta_0 + \beta_1 x_i)\)。 首先,回忆 logistics 函数的导数性质:\(\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)\)预测误差。每次迭代更新时,如果误差为正(实际为1但预测概率小),参数就沿着增大预测概率的方向移动。我们在寻找使得总误差加权和为零的参数点。

Clarification on MLE vs. OLS

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

特性 OLS (线性回归) MLE (逻辑回归)
目标 最小化残差平方和 最大化似然函数
解析解(闭式) 无解析解,需迭代优化
假设 误差项正态分布 伯努利分布(二项分布)
计算效率 高(直接计算) 较低(迭代算法如Newton-Raphson)
适用范围 线性回归 广义线性模型、非线性模型

在误差项服从正态分布的假设下,线性回归的OLS估计等价于MLE。但对于逻辑回归,由于误差项不是正态分布,MLE是自然的选择。

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

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

在下面的量化实战代码中,我们正式利用真实市场的日度历史行情数据来构建一个专业的逻辑回归模型。代码首先读取并定位了浦发银行(600000.XSHG)的历史交易面板数据,借助 pandas 极度强大的时序处理能力(提取 pct_change()shift() 函数属性),快速计算出了当天的绝对收益率,并向后追溯生成了过去三天(Lag1, Lag2, Lag3)的滞后收益率作为预测特征(Features)。同时,我们将当天收益率大于0这一事件提取为布尔值并转化为数字 1,以此作为我们需要预判的二分类响应变量(Direction)。最后,这段代码调用了 statsmodels.api 中的 Logit 模型预估对象,通过后台迭代执行的最大似然估计算法(MLE)彻底完成了参数拟合,并打印产生了一份包含所有自变量回归系数及其 \(P\) 值显著性检验维度的极其详尽的统计摘要表格。

# =====================================================================
# 本代码演示:使用真实 A 股行情数据构建逻辑回归模型
# 目标:用过去 1~3 天的收益率预测今天股票是涨还是跌
# =====================================================================

# 导入数值计算库
import numpy as np  # 导入numpy库
# 导入数据处理库 pandas,用于读取和操作表格数据
import pandas as pd  # 导入pandas库
# 导入 statsmodels 统计建模库
# statsmodels 提供了类似 R 语言的统计分析功能,包括逻辑回归、回归诊断等
import statsmodels.api as sm  # 导入statsmodels库
# 导入操作系统接口,用于判断当前运行平台(Windows 或 Linux)
import os  # 导入os库
from pathlib import Path  # 用路径对象解析数据根目录

# ---------- 第一步:读取真实的 A 股日度行情数据 ----------
# 根据操作系统自动选择正确的本地数据路径
# Windows用C盘路径,Linux用ubuntu服务器挂载路径
BOOK_DATA_DIR = Path(os.environ['BOOK_DATA_DIR']).expanduser().resolve()  # 从必需环境变量解析数据根目录
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'  # 构建股价数据路径
# 按股票代码选择性读取浦发银行的日度行情数据,避免全量载入全市场价格面板
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:                Thu, 13 Aug 2026   Pseudo R-squ.:               0.0005808
Time:                        14:12:47   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.4.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.6}\]

决策规则

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

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

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

Tip: 理解阈值选择的影响

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

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

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

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

4.4.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.7}\]

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

4.4.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.8}\]

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

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

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

4.5.1 贝叶斯分类器 (Bayes Classifier)

最优的贝叶斯分类器将观测分类到后验概率最大的类别:

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

使用贝叶斯定理:

\[ 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.10}\]

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

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

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

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

同理,给定\(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.11 的分子:

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

这就是贝叶斯定理的基本形式。其中\(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.13}\]

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

术语 符号 含义
先验概率 (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%!这是因为违约群体的基础比率(base rate)很低(仅5%),大量正常客户中即使只有10%被误标记,其绝对数量也远超过真正的违约客户。这个例子生动地说明了先验概率在贝叶斯推断中的关键作用,也是精确率(precision)与召回率(recall)在实际应用中经常存在矛盾的根本原因。

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

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

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

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

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

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

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

理解了贝叶斯定理后,我们来看它在分类问题中的具体应用。贝叶斯分类器通过比较后验概率 \(P(Y=k|X)\) 来做决策——将观测分到后验概率最大的类别中。

将贝叶斯定理 式 4.13 应用到分类问题中,令\(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.5.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.10,并取对数: \[ \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.14}\]

其中:

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

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

Tip: LDA vs. 逻辑回归

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

特性 LDA 逻辑回归
假设 类条件正态,同协方差 对数几率线性
估计方法 参数化(估计均值、协方差) 非参数化(MLE)
稳定性 对小样本更稳定 需要较大样本
鲁棒性 对正态性假设敏感 对离群点较敏感
可扩展性 容易扩展到多分类 需要多项逻辑回归

何时使用LDA

  • 当类条件分布接近正态时
  • 当样本量较小(此时参数估计更稳定)
  • 当类别分离度很高时

何时使用逻辑回归

  • 当正态性假设不成立时
  • 当样本量较大时
  • 当需要更稳健的推断时

算法拆解: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. 选择得分最高的类别。谁的后验概率最大,就把样本判给谁。

也正因如此:

  • LDA 更稳,因为它需要估计的参数更少,特别适合样本不多、但希望得到稳定边界的场景;
  • QDA 更灵活,因为它允许不同类别有不同的“散布形状”,所以边界可以弯曲;
  • 但 QDA 也更容易过拟合,因为每一类都要单独估计一个协方差矩阵,对样本量的要求更高。

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

案例应用:使用LDA预测信贷违约

为了在二维平面上直观展示 LDA 的工作机制,接下来的这段 Python 代码借助了 sklearn.discriminant_analysis 架构下的 LinearDiscriminantAnalysis 模块。我们首先利用多元正态分布函数(multivariate_normal)仿真了两簇带有一定重叠概率特征的数据集,分别代表股市中的“下跌”(蓝点)和“上涨”(红点)。在用这些二维坐标点喂给 LDA 模型并完成核心参数矩阵拟合后,代码使用等高线填充函数 contourf 大面积绘制出了背景判定色块,极其清晰地刻画出那条刚性、笔直的线性决策边界(Linear Decision Boundary)。正是因为模型内核坚守了“不同类别的数据必然享有绝对相同的波动形状和倾斜度(必须具备同一协方差矩阵)”这个严苛的数学前提约束,才直接赋予了这条两军分界线不可弯曲的绝对线性形态。这也构成了 LDA 非常稳健但可能有时缺乏非线性灵活度的最典型表象特征。

# =====================================================================
# 本代码演示:LDA(线性判别分析)的决策边界可视化
# 目标:直观展示 LDA 如何在二维特征空间中画出一条"分界线"
# =====================================================================

# 导入数值计算库
import numpy as np  # 导入numpy库
# 导入绘图库
import matplotlib.pyplot as plt  # 导入matplotlib库
# 从 scikit-learn 导入线性判别分析分类器
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis  # 从sklearn.discriminant_analysis导入所需模块

# ---------- 第一步:生成模拟的股市涨跌数据 ----------
# 使用模拟数据进行可视化,简化为只用 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 模型 ----------
# 创建 LDA 分类器对象
lda_classifier_model = LinearDiscriminantAnalysis()  # 创建LDA分类器对象
# 用特征矩阵 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)

# 创建画布,设置图形大小为 10×6 英寸
plt.figure(figsize=(10, 6))  # 创建新图形
# 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')  # 设置X轴标签
# 设置图标题
plt.title('LDA Decision Boundary for Market Direction (Simulated)')  # 设置图表标题
# 显示图例
plt.legend()  # 添加图例
# 展示图形
plt.show()  # 显示图形
模拟 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.5.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.15}\]

这导致二次的决策边界

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

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

# =====================================================================
# 本代码演示:LDA 与 QDA 的决策边界对比
# 观察目标:不同协方差设定下比较线性与二次边界;不预写性能赢家
# =====================================================================

# 导入数值计算库
import numpy as np  # 导入numpy库
# 导入绘图库
import matplotlib.pyplot as plt  # 导入matplotlib库
# 从 scikit-learn 同时导入 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 和 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))  # 创建子图布局

# ===== 左图:LDA 的线性决策边界 =====
ax1 = axes[0]  # 获取左侧子图用于展示LDA的线性决策边界
# contourf 用蓝红渐变色填充不同分类区域
ax1.contourf(xx, yy, lda_predictions, alpha=0.3, cmap='RdYlBu')  # 在子图中绑制等高线
# contour 画出决策边界的轮廓线(黑色虚线)
ax1.contour(xx, yy, lda_predictions, colors='black', linewidths=2, linestyles='--')  # 在子图中绑制等高线
# 绘制未违约客户散点(蓝色圆点)
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')  # 设置子图X轴标签
ax1.set_ylabel('信用卡余额(千元)', fontsize=12, fontweight='bold')  # 设置子图Y轴标签
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)  # 添加子图网格线

# ===== 右图:QDA 的二次决策边界 =====
ax2 = axes[1]  # 获取右侧子图用于展示QDA的二次决策边界
# 用颜色填充 QDA 的分类区域
ax2.contourf(xx, yy, qda_predictions, alpha=0.3, cmap='RdYlBu')  # 在子图中绑制等高线
# 画出 QDA 决策边界的轮廓线
ax2.contour(xx, yy, qda_predictions, colors='black', linewidths=2, linestyles='--')  # 在子图中绑制等高线
# 绘制未违约客户散点
# 绘制未违约客户散点(蓝色圆点,与左图保持一致)
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')  # 设置子图X轴标签
ax2.set_ylabel('信用卡余额(千元)', fontsize=12, fontweight='bold')  # 设置子图Y轴标签
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 用弯曲二次边界分区。
图 4.3: LDA vs. QDA:线性边界 vs. 二次边界。左图:LDA假设协方差矩阵相同,产生线性决策边界。右图:QDA允许不同的协方差矩阵,产生二次(弯曲)决策边界。

图 4.3 可以看出:

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

LDA vs. QDA的选择

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

4.5.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.16}\]

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

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

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

  1. 解耦问题:每个特征可以单独建模,降低了维度诅咒
  2. 参数效率:需要估计的参数数量大大减少
  3. 鲁棒性:对无关特征不敏感
  4. 快速训练和预测:计算效率高

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

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

它的训练过程非常直接:

  1. 统计先验概率:先计算每个类别在训练集中出现的比例 \(\pi_k\)
  2. 逐个特征、逐个类别估计分布参数:例如在高斯朴素贝叶斯中,对每一个特征 \(X_j\) 和每一个类别 \(k\),分别估计均值与方差。
  3. 预测时把证据相乘:新样本到来后,分别计算每个特征在各类别下出现的可能性,再把这些似然与先验相乘。
  4. 比较后验大小:哪个类别的后验概率更大,就判给哪个类别。

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

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

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

适用场景

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

不适用场景

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

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

4.6.1 理论比较 (An Analytical Comparison)

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

# =====================================================================
# 本代码:创建一张汇总表,对比本章介绍的 6 种分类方法
# 帮助学生一目了然地掌握各方法的核心特点和适用条件
# =====================================================================

# 导入 pandas 数据处理库
import pandas as pd  # 导入pandas库

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

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

表 4.2: 各种分类方法的比较
# 将字典转换为 pandas DataFrame 表格对象
df_comparison = pd.DataFrame(comparison_data)  # 构建DataFrame数据表
# 打印表格,index=False 表示不显示行号
print(df_comparison.to_string(index=False))  # 输出结果到控制台
       方法           决策边界         假设 参数/非参数 训练速度     预测速度   对高维数据 鲁棒性(异常值) 可解释性
     逻辑回归             线性     对数几率线性    参数化    快       很快      良好       中等    高
      LDA             线性 类条件正态,同协方差    参数化    快       很快      良好       中等    高
      QDA             二次 类条件正态,异协方差    参数化    快       很快 中等(参数多)       中等   中等
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.6.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、校准与业务成本,而不能只看准确率。

# =====================================================================
# 本代码演示:在真实 A 股上市公司数据上全面比较 8 种分类算法
# 任务:分类快照时是否为 ST(同期关联,不是未来预警)
# 这是一个完整的机器学习实验流程,从数据准备到模型评估
# =====================================================================

# 导入数值计算库
import numpy as np  # 导入numpy库
# 导入数据处理库
import pandas as pd  # 导入pandas库
# 导入绘图库
import matplotlib.pyplot as plt  # 导入matplotlib库
# 导入操作系统接口,用于判断当前运行平台(Windows 或 Linux)
import os  # 导入os库
# 从 scikit-learn 导入数据分割工具和交叉验证工具
from sklearn.model_selection import train_test_split, cross_val_score  # 从sklearn.model_selection导入所需模块
# 导入逻辑回归分类器
from sklearn.linear_model import LogisticRegression  # 从sklearn.linear_model导入所需模块
# 导入 LDA(线性判别分析)和 QDA(二次判别分析)
# 导入判别分析模型用于多类别分类任务
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis
# 导入高斯朴素贝叶斯分类器
from sklearn.naive_bayes import GaussianNB  # 从sklearn.naive_bayes导入所需模块
# 导入 K 近邻分类器
from sklearn.neighbors import KNeighborsClassifier  # 从sklearn.neighbors导入所需模块
# 导入支持向量机分类器
from sklearn.svm import SVC  # 从sklearn.svm导入所需模块
# 导入标准化工具:将特征缩放到均值为 0、标准差为 1
from sklearn.preprocessing import StandardScaler  # 从sklearn.preprocessing导入所需模块
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  # 设置matplotlib负号正常显示
# ---------- 第一步:设置数据路径(自动适配 Windows / Linux) ----------
# os.name == 'nt' 表示当前是 Windows 系统
# 根据操作系统自动选择本地数据存储的根目录路径
BOOK_DATA_DIR = Path(os.environ['BOOK_DATA_DIR']).expanduser().resolve()  # 复用必需环境变量契约
# 财务报表数据路径(包含总资产、总负债、净利润等)
FINANCE_PATH = BOOK_DATA_DIR / 'stock/financial_statement.h5'  # 构建财务报表路径
# 股票基本信息数据路径(包含股票代码、名称等)
BASIC_PATH = BOOK_DATA_DIR / 'stock/stock_basic_data.h5'  # 构建公司基础信息路径

# ---------- 第二步:读取本地财务数据 ----------
# 选择性读取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()  # 创建副本用于后续合并与标签构造

# 设定用于合并两张表的公共键(股票代码字段)
join_key = 'order_book_id'  # 定义合并两张数据表所用的公共键名(股票代码字段)

# 将财务数据与基本信息合并,通过股票代码关联
# how='inner' 表示只保留两边都有的记录
# 将财务报表数据与股票基本信息按股票代码内连接合并,获取ST标识字段
merged_financial_data = pd.merge(financial_data_df, basic_info_df[[join_key, 'special_type']], on=join_key, 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  # 提取为NumPy数组
# 提取标签向量 Y(0 = 正常公司,1 = ST 公司)
st_target_labels = merged_financial_data['is_st'].values  # 提取为NumPy数组

上面的代码从 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 会在训练折内部学习缩放参数

# ---------- 第八步:定义 8 种分类模型 ----------
# 使用字典存储模型名称和对应的模型对象
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]
    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),
        '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',
)
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 与概率校准。下面的条形图只展示本次横截面切分结果,不是未来泛化证据。

# ---------- 第十一步:绘制性能比较条形图 ----------
# 创建画布,大小 14×7 英寸
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')  # 设置子图X轴标签
ax.set_ylabel('准确率', fontsize=13, fontweight='bold')  # 设置子图Y轴标签
ax.set_title('分类方法性能比较\n(Classification Methods Performance Comparison)',  # 设置子图标题
            fontsize=14, fontweight='bold', pad=15)  # 设置标题字号、加粗及与图表的间距
# 设置 x 轴刻度标签,旋转 45 度以防重叠
ax.set_xticks(x)  # 设置X轴刻度位置
ax.set_xticklabels(methods, rotation=45, ha='right')  # 设置X轴刻度标签
# 添加图例
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])  # 设置子图Y轴范围

# 在每根柱子顶部添加数值标签,便于精确读数
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.7 本章小结 (Chapter Summary)

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

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

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

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

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

    • LDA假设类条件正态且协方差相同,产生线性决策边界
    • QDA允许不同协方差,产生二次决策边界
  5. 朴素贝叶斯

    • 基于特征独立性假设
    • 计算效率高,适合高维数据
  6. 方法选择

    • 逻辑回归:可审计系数关联与预测概率,但不自动支持因果推断
    • LDA:小样本下稳定
    • QDA:允许类别协方差不同;仅在协方差估计稳定且验证表现支持时采用
    • 朴素贝叶斯:高维数据和实时应用

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

4.8 理论来源与前沿

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

  1. 判别式建模(如逻辑回归):直接建模 \(P(Y\mid X)\),强调参数可解释性与预测概率的校准。
  2. 生成式建模(如 LDA/QDA、朴素贝叶斯):建模 \(P(X\mid Y)\)\(P(Y)\),当结构假设近似成立或样本较小(而维度较高)时往往更稳健。

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

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

4.9 贯穿项目里程碑 M04

本里程碑与 小节 6.3 的 M04 一致,预计 60 分钟;输入是 M02 的 a-share-drawdown-20d-v1 与 M03 的同成员基线。本章 ST 案例保留为同期分类支线,不属于贯穿项目,也不得替换 20 日回撤标签。

契约项 可审计要求
输入 M02 的同一键、20 日回撤标签、label_date、特征与训练/验证成员;最终测试继续封存
输出 训练事件率/多数类基线;四模型的验证 AUROC、PR-AUC、Brier、校准证据,以及按 \(C_{FN}=5\)\(C_{FP}=1\) 与阈值 \(1/6\) 预注册的每公司日业务损失
时点与冻结 每个训练成员满足 label_date 早于下一验证原点;所有缩放/估计只拟合训练;成本和阈值在查看验证结果前锁定
失败条件 改成 ST 或其他标签;训练/验证多数类相反时改用验证类别;单类集合仍计算 AUC;全样本缩放或访问最终测试
量规 D 时点 30%,M 概率验证 40%,E 图表可复算 30%

4.10 练习

4.10.1 概念题

  1. [核心|难度:1|时间:10分钟|分值:10|项目:无] 为什么逻辑回归要用对数几率(log-odds)作为线性预测子?解释它与概率的关系。

  2. [核心|难度:1|时间:10分钟|分值:10|项目:无] 解释 LDA 与 QDA 的核心假设分别是什么。为什么 QDA 更灵活但更容易过拟合?

  3. [拓展|难度:1|时间:10分钟|分值:10|项目:无] 朴素贝叶斯的条件独立假设在现实中往往不成立,为什么它仍然可能工作得很好?

  4. [核心|难度:2|时间:15分钟|分值:15|项目:无] 在类别极度不平衡时,为什么准确率可能误导?列举至少两个更合适的指标,并说明各自适用场景。

  5. [核心|难度:2|时间:15分钟|分值:15|项目:无] 解释概率校准的含义。为什么一个 AUC 很高的模型,其概率输出仍可能不适合直接用于定价或阈值决策?

4.10.2 应用题

  1. [核心|难度:3|时间:60分钟|分值:40|项目:M04] 同任务概率分类:复用 M02 的 a-share-drawdown-20d-v1 键、标签和训练/验证成员,比较逻辑回归、LDA、QDA 与 Gaussian NB。只用训练标签锁定事件率与多数类;在看验证结果前登记漏报成本 \(C_{FN}=5\)、误报成本 \(C_{FP}=1\),据此锁定阈值 \(1/6\)。在验证集报告 AUROC、PR-AUC、Brier、校准证据及每公司日业务损失,单类时结构化停止。根据现场输出作条件式比较,不得访问最终测试或把预测关联写成因果。

  2. [核心|难度:2|时间:25分钟|分值:20|项目:无] 在上题基础上,加入代价敏感决策:

    • 假阳性成本:错杀优质公司导致机会成本
    • 假阴性成本:漏判高风险公司导致损失

    给出一个基于预测概率的阈值选择方案,并说明如何用历史数据近似评估该阈值的期望收益。

  3. [拓展|难度:3|时间:30分钟|分值:25|项目:无] 选择一只长三角上市公司与一个对照公司(非长三角),构造一个小样本分类实验,讨论:

    • 小样本下 LDA 与逻辑回归谁更稳?
    • 你会如何通过正则化或先验来提升稳定性?

4.10.3 理论题

  1. [拓展|难度:3|时间:20分钟|分值:20|项目:无] 推导逻辑回归的对数似然函数,并给出梯度的表达式。

  2. [拓展|难度:3|时间:20分钟|分值:20|项目:无] 证明在两类正态、共享协方差矩阵的假设下,LDA 的决策边界是线性的。

4.11 练习参考解答

展开第 4 章完整解答与评分键

统一评分与验收说明:按题面元数据分值计分;模型假设、标签时点、排序/概率/决策指标与解释边界分别计分。概率和 Brier 相对容差 \(10^{-6}\),AUROC/PR-AUC 允许 \(10^{-4}\) 的版本显示差异;校准图按分箱数、对角线、轴标签和留出集口径验收。常见失败是用当前 ST 回填历史、在全样本上缩放、用测试标签选阈值、只报准确率,或把同期关联写成未来预警。

4.11.1 概念题参考解答

  1. 为什么用 log-odds

    逻辑回归设 \[ \log\frac{p(x)}{1-p(x)} = \beta_0+\beta^\top x, \] 这样左边是实数,右边是线性函数;再通过 sigmoid 把线性预测映射回 \((0,1)\) 的概率。

  2. LDA vs QDA

    LDA 假设各类条件分布正态且协方差相同;QDA 允许不同协方差,因此边界可为二次曲线。QDA 参数更多,小样本下方差更大,易过拟合。

  3. 朴素贝叶斯为何仍有效

    决策只依赖于后验比值的排序。即便独立性不成立,只要模型在排序上近似正确,分类仍可能表现很好。

  4. 不平衡指标

    • PR-AUC:在正类稀少时更敏感。
    • F1:平衡精确率与召回率。
    • 召回率/漏报率:当漏判代价高时优先。
  5. 校准的重要性

    AUC 衡量排序能力,不保证概率数值正确。定价与阈值决策依赖概率的绝对水平,因此需要校准(例如 Platt scaling、等距回归)并用校准曲线检查。

4.11.2 应用题参考解答

  1. a-share-drawdown-20d-v1 概率分类

本答案可在 fresh kernel 顺序运行,不依赖 M03 的内存对象。必须显式提供 BOOK_PROJECT_ARTIFACT_DIRBOOK_PROJECT_DEVELOPMENT_FEATURESBOOK_PROJECT_DEVELOPMENT_FEATURES_SHA256;开发标签模式固定为键、label_datesplity,特征模式只能是相同键加数值特征。先核验 manifest 原字节、键唯一、末端未知标签已排除,以及 max(train.label_date) < min(validation.prediction_date)

import hashlib  # 核验全部输入文件的真实字节
import json  # 读取M02机器合同
import os  # 读取fresh-kernel显式入口
from pathlib import Path  # 使用跨平台路径
import numpy as np  # 构造基线概率
import pandas as pd  # 读取开发制品
from sklearn.pipeline import Pipeline  # 将预处理限制在训练拟合
from sklearn.impute import SimpleImputer  # 训练期缺失填补
from sklearn.preprocessing import StandardScaler  # 训练期标准化
from sklearn.linear_model import LogisticRegression  # 判别式概率模型
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis, QuadraticDiscriminantAnalysis  # 生成式模型
from sklearn.naive_bayes import GaussianNB  # 高斯朴素贝叶斯
from sklearn.metrics import roc_auc_score, average_precision_score, brier_score_loss  # 概率指标
artifact_dir = Path(os.environ['BOOK_PROJECT_ARTIFACT_DIR']).resolve()  # M02制品目录
manifest_path = artifact_dir / 'project-manifest-v1.csv'  # 唯一manifest
development_path = artifact_dir / 'project-development-v1.csv'  # M02仅开发标签
contract_path = artifact_dir / 'project-contract-v1.json'  # M02总体与哈希合同
feature_path = Path(os.environ['BOOK_PROJECT_DEVELOPMENT_FEATURES']).resolve()  # 课程运行器仅开发特征
assert all(path.is_file() for path in [manifest_path, development_path, contract_path, feature_path])  # 输入缺失立即失败
contract = json.loads(contract_path.read_text(encoding='utf-8'))  # 读取冻结合同
expected_hashes = contract['artifact_hashes']  # 取得M02权威哈希
assert hashlib.sha256(manifest_path.read_bytes()).hexdigest() == expected_hashes['manifest_sha256']  # 拒绝成员变化
assert hashlib.sha256(development_path.read_bytes()).hexdigest() == expected_hashes['development_sha256']  # 拒绝开发标签变化
assert hashlib.sha256(feature_path.read_bytes()).hexdigest() == os.environ['BOOK_PROJECT_DEVELOPMENT_FEATURES_SHA256'].lower()  # 绑定特征真实字节
manifest = pd.read_csv(manifest_path, parse_dates=['prediction_date', 'label_date'])  # 读取权威全成员表
labels = pd.read_csv(development_path, parse_dates=['prediction_date', 'label_date'])  # 读取开发标签
features = pd.read_csv(feature_path, parse_dates=['prediction_date'])  # 读取仅开发特征
keys = ['order_book_id', 'prediction_date']  # 项目键
reserved_columns = {'label_date', 'y', 'split', 'event', 'future_min_close', 'target', 'outcome'}  # 禁止特征源携带结果或成员字段
assert reserved_columns.isdisjoint(set(features.columns)) and not features.duplicated(keys).any()  # 冲突或重复在合并前失败
development_manifest = manifest[manifest['split'].isin(['train', 'validation'])].copy()  # 以manifest为权威开发左表
assert set(labels.columns) == set(keys + ['label_date', 'split', 'y'])  # 拒绝标签模式漂移
authoritative_panel = development_manifest.merge(labels[keys + ['y']], on=keys, how='left', validate='one_to_one', indicator='label_merge')  # 权威左连接标签
assert authoritative_panel['label_merge'].eq('both').all() and len(authoritative_panel) == len(development_manifest)  # 禁止标签缺键缩样
assert set(map(tuple, labels[keys].to_numpy())) == set(map(tuple, development_manifest[keys].to_numpy()))  # 禁止额外标签键
authoritative_panel = authoritative_panel.drop(columns='label_merge')  # 删除标签审计标记
project_panel = authoritative_panel.merge(features, on=keys, how='left', validate='one_to_one', indicator='feature_merge')  # 权威左连接特征
assert project_panel['feature_merge'].eq('both').all() and len(project_panel) == len(development_manifest)  # 禁止特征缺键缩样
assert set(map(tuple, features[keys].to_numpy())) == set(map(tuple, development_manifest[keys].to_numpy()))  # 禁止额外特征键
project_panel = project_panel.drop(columns='feature_merge')  # 删除特征审计标记
feature_columns = [column for column in features.columns if column not in keys]  # 冻结无保留冲突的特征模式
assert feature_columns and np.isfinite(project_panel[feature_columns].to_numpy(dtype=float)).all()  # 拒绝空特征或非有限值
actual_key_bytes = project_panel[keys].to_csv(index=False, date_format='%Y-%m-%d', lineterminator='\n').encode('utf-8')  # 规范化实际键字节
expected_key_bytes = development_manifest[keys].to_csv(index=False, date_format='%Y-%m-%d', lineterminator='\n').encode('utf-8')  # 规范化权威键字节
assert actual_key_bytes == expected_key_bytes  # 精确核对键顺序、行数与内容
print({'development_rows': len(project_panel), 'development_key_sha256': hashlib.sha256(actual_key_bytes).hexdigest(), 'features_sha256': hashlib.sha256(feature_path.read_bytes()).hexdigest()})  # 输出合并血缘证据
train_panel = project_panel[project_panel['split'].eq('train')].copy()  # 训练成员
validation_panel = project_panel[project_panel['split'].eq('validation')].copy()  # 验证成员
assert train_panel['label_date'].max() < validation_panel['prediction_date'].min()  # 严格purge
if train_panel['y'].nunique() < 2 or validation_panel['y'].nunique() < 2:  # 排序指标前置条件
    raise RuntimeError('status=not_evaluable_single_class')  # 结构化停止而非删除集合
training_event_rate = train_panel['y'].mean()  # 只用训练标签
training_majority = int(training_event_rate >= 0.5)  # 只用训练标签锁定类别
false_negative_cost = 5.0  # 事前登记漏报一次未来大回撤的业务损失
false_positive_cost = 1.0  # 事前登记误报一次风险事件的业务损失
decision_threshold = false_positive_cost / (false_positive_cost + false_negative_cost)  # 按固定成本比锁定概率阈值
models = {  # 题面规定的四个候选
    'logistic': LogisticRegression(max_iter=2000),  # 以正则逻辑回归提供判别式基线
    'lda': LinearDiscriminantAnalysis(),  # 以共享协方差检验线性生成边界
    'qda': QuadraticDiscriminantAnalysis(reg_param=1e-6),  # 以轻微正则稳定类别协方差
    'gaussian_nb': GaussianNB(),  # 以条件独立生成模型形成低方差对照
}
validation_probabilities = {'training_event_rate': np.repeat(training_event_rate, len(validation_panel))}  # 先登记训练事件率概率基线
for model_name, estimator in models.items():  # 对每个预注册候选执行相同开发协议
    pipeline = Pipeline([('imputer', SimpleImputer()), ('scale', StandardScaler()), ('model', estimator)])  # 将填补与缩放限制在训练拟合
    pipeline.fit(train_panel[feature_columns], train_panel['y'])  # 仅用训练成员估计全部参数
    validation_probabilities[model_name] = pipeline.predict_proba(validation_panel[feature_columns])[:, 1]  # 生成同成员验证概率
metric_rows, calibration_rows = [], []  # 收集可评分输出
for model_name, probability in validation_probabilities.items():  # 在同一验证成员上评价基线与候选
    predicted_event = probability >= decision_threshold  # 使用预注册业务阈值形成决策
    false_negative_count = int(((validation_panel['y'].to_numpy() == 1) & ~predicted_event).sum())  # 统计高成本漏报
    false_positive_count = int(((validation_panel['y'].to_numpy() == 0) & predicted_event).sum())  # 统计低成本误报
    business_loss = (false_negative_cost * false_negative_count + false_positive_cost * false_positive_count) / len(validation_panel)  # 计算每公司日业务损失
    metric_rows.append({'model': model_name, 'AUROC': roc_auc_score(validation_panel['y'], probability), 'PR_AUC': average_precision_score(validation_panel['y'], probability), 'Brier': brier_score_loss(validation_panel['y'], probability), 'business_loss': business_loss, 'false_negative_count': false_negative_count, 'false_positive_count': false_positive_count})  # 汇总统计与业务指标
    calibration_frame = pd.DataFrame({'y': validation_panel['y'].to_numpy(), 'p': probability})  # 配对真实结果与预测概率
    calibration_frame['bin'] = pd.qcut(calibration_frame['p'].rank(method='first'), q=min(10, len(calibration_frame)), duplicates='drop')  # 形成等频校准分箱
    grouped = calibration_frame.groupby('bin', observed=True).agg(mean_probability=('p', 'mean'), event_rate=('y', 'mean'), n=('y', 'size')).reset_index(drop=True)  # 计算每箱概率与事件率
    grouped['model'] = model_name  # 保留候选身份以支持纵向审计
    calibration_rows.append(grouped)  # 汇总本候选校准证据
metric_table = pd.DataFrame(metric_rows).sort_values('Brier')  # 现场排序,不预写赢家
calibration_table = pd.concat(calibration_rows, ignore_index=True)  # 校准证据
print({'training_event_rate': training_event_rate, 'training_majority': training_majority})  # 输出只由训练标签定义的基线
print({'false_negative_cost': false_negative_cost, 'false_positive_cost': false_positive_cost, 'decision_threshold': decision_threshold})  # 输出预注册业务损失合同
print(metric_table)  # 输出四模型与基线的统计及业务评价表
print(calibration_table)  # 输出逐模型校准分箱表

训练事件率与常数类别只由 train.y 决定,即使验证多数类相反也不得改变。所有填补、缩放和协方差估计都位于训练管道中。

结果解释必须读取现场指标:按 Brier 讨论概率质量,按 AUROC/PR-AUC 讨论排序;若所有模型未改善训练事件率概率基线,则报告“本验证期无增量证据”。模型概率只能支持条件预测关联,不能解释为某特征导致回撤。前文 ST 示例仍是独立同期分类案例,不进入此项目,也不允许替换项目标签。

  1. 阈值选择(代价敏感)

    设阈值为 \(t\),当 \(\hat p\ge t\) 判为高风险。在训练期的内层前向验证预测上计算 \[ \widehat L(t)=\frac{1}{n}\sum_i\{C_{FN}I(FN_i;t)+C_{FP}I(FP_i;t)\}. \] 先由业务方给定 \(C_{FN}\)\(C_{FP}\) 与任何召回率约束,再在候选阈值中选取验证成本最低的 \(t\)。锁定模型与阈值后才评估外层测试集;不得用测试标签回调阈值。

  2. 小样本稳定性讨论

    固定同一任务、特征和前向外层切分,比较正则化逻辑回归、LDA 与收缩 LDA。缺失填补、标准化、收缩强度和正则化参数必须在每个训练折内拟合;报告每个外层测试年的正例数、PR-AUC、Brier 与校准,并以跨年离散程度评价稳定性。样本过少或某年只有单一类别时,应明确记为不可估计而不是删除该年份。

4.11.3 理论题参考解答

  1. 逻辑回归对数似然与梯度

    \(p_i=\sigma(\beta_0+\beta^\top x_i)\),则 \[ \ell(\beta)=\sum_i \big[y_i\log p_i+(1-y_i)\log(1-p_i)\big]. \] 梯度为 \[ \nabla\ell(\beta)=\sum_i (y_i-p_i)x_i. \]

  2. LDA 线性边界

    在共享协方差 \(\Sigma\) 下,判别函数的对数后验比可写成 \[ \delta_1(x)-\delta_0(x)=x^\top\Sigma^{-1}(\mu_1-\mu_0)+\text{const}, \]

    因此决策边界满足线性方程,故为线性。

4.12 章末学习闭环

逐项自检:能否区分逻辑回归、LDA、QDA 与 Gaussian NB 的假设;能否同时报告 AUROC、PR-AUC、Brier 与校准;能否只用训练标签锁定基线;能否按 M02 manifest 完成 M04。禁止用测试标签选阈值,也禁止把模拟边界当市场证据。常见误区是把 AUC 当校准、把灵活模型预设为赢家。

不看正文回答:LDA 与 QDA 的协方差假设有何不同?类别不平衡时为何看 PR-AUC?Brier 衡量什么?迁移任务:为高漏判成本的反欺诈任务写阈值合同。下一章复用概率指标和标签终点,学习时间感知重采样。

展开检索答案 LDA 共享类协方差而 QDA 分别估计;PR-AUC 直接反映稀有正类的查准—查全权衡;Brier 是概率与二元结果平方差的均值。

出口决策:训练期基线、概率校准与阈值成本、测试不参与选择三项为 must-pass;三项全过、其余目标至少一项有证据且检索至少两题正确才进入第 5 章。模型假设错回到 小节 4.8 前比较新设的同/异协方差类别;校准错用“供应商断供”画可靠性表;成本错用新的 \(C_{FN}:C_{FP}=3:2\) 推导阈值。每项复测 2 分且须说明排序、概率、决策的区别,之后才重入 小节 4.12

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.