量化交易中文教材

第 25 章 案例:模式识别与聚类

本章合并原书第 25 章(案例研究 3:模式识别)和第 26 章(案例研究 4:聚类)。第一个案例用多层网络从心电图特征识别心肌梗死,重点是特征提取、类别不平衡、混淆矩阵与 ROC,以及用"多次随机划分"的蒙特卡洛方法评估性能的分布。第二个案例用自组织特征映射(SOFM)对森林覆盖数据聚类,重点是批量 SOM 训练和 U 矩阵、命中直方图、分量平面等可视化工具。量化实战分别对应"稀有事件(大跌)预警"和"市场状态地图"。

学习目标

读完本章,你应当能够:

  1. 说明模式识别任务中特征提取的作用,以及为什么用领域知识设计的特征常常优于原始信号。
  2. 正确处理类别不平衡:在训练集内重复少数类或加权误差,验证集和测试集保持原始分布。
  3. 读懂混淆矩阵的召回率、精确率与 ROC 曲线,并能根据误判代价选择阈值。
  4. 用多次划分加多次初始化的"蒙特卡洛"评估得到测试误差的分布,而不是单个数字;知道如何利用持续误分的样本。
  5. 按原书流程训练批量 SOM:确定神经元数、线性初始化、逐步缩小邻域,并用量化误差、拓扑误差、U 矩阵、命中直方图、分量平面分析结果。
  6. 把这两套方法用到量化:稀有事件预警的评估陷阱,市场状态的 SOM 可视化。

读前导读

这一章在解决什么问题。 两个案例分别对应两类常见任务。第一个是有监督分类,而且是"两类数量很不平衡、漏判代价远高于误判"的分类:由心电图特征判断是否心梗。这在结构上和信用评分(违约是少数类)、大跌预警(大跌是少数类)完全一样。你在信用风险里熟悉的那套东西,混淆矩阵、第一类和第二类错误、ROC 曲线、按代价定阈值,这里都会用到。第二个是无监督聚类:用 SOM 把 10 维的地形数据铺到一张二维地图上,看看相似的样本是否自动聚在一起。量化里对应"市场状态地图"。

本章最值得带走的有三条。第一,处理不平衡(复制少数类或加权)只能在训练集里做,否则测试结果虚高。第二,小样本上单次划分的测试结果不可靠,要看多次划分的分布。第三,准确率在不平衡问题里几乎没有意义,要看召回率、精确率和 AUC,并按业务代价选阈值。

需要先想起来的数学。

  • 条件概率与贝叶斯公式:精确率 = P(真为正 | 判为正),召回率 = P(判为正 | 真为正)。两者通过基础比率(正类占比)联系起来,正类越稀少,同样召回率下精确率越低。见 第 00 册第 07 章 概率中的分析工具。
  • 比例的标准误:测试集有 \(n\) 个样本、真实误差率为 \(p\) 时,观测误差率的标准差约为 \(\sqrt{p(1-p)/n}\)。测试集小,这个数就大。
  • 协方差矩阵的特征向量(主成分):协方差矩阵最大特征值对应的特征向量,是数据方差最大的方向。SOM 的"线性初始化"就是把网格铺在前两个主成分方向上。见 第 00 册第 06 章 线性代数速成。
  • 加权平均是加权平方距离的最小点:使 \(\sum_qh_q\|\mathbf w-\mathbf p_q\|^2\) 最小的 \(\mathbf w\) 就是 \(\mathbf p_q\) 以 \(h_q\) 为权的加权平均。批量 SOM 的式 (26.5) 就是它。见 第 00 册第 05 章 多元微积分与优化。

怎么读这一章。 25.1 必读,尤其 25.1.2 的"类别不平衡"与"划分"两段、25.1.5 的蒙特卡洛评估;25.3.1 的大跌预警代码与四条解读是本章对量化最有用的部分。25.2 SOM 案例第一次可以只读 25.2.3–25.2.5,了解批量 SOM 和几种可视化工具的用途;如果你暂时不做状态识别,25.3.2 可以浏览。本章依赖第 15 章(竞争学习与 SOFM)和第 22 章(训练后分析),忘了的话先回看第 22 章 22.6.2–22.6.3 节。


25.1 案例三:从心电图识别心肌梗死(模式识别)

25.1.1 问题

模式识别要把输入分到目标类别中,典型例子有手写邮编识别、语音单词识别、由症状识别疾病、指纹识别。本案例用多层网络从心电图(EKG)识别心肌梗死(myocardial infarction, MI),即心脏病发作。

心电图记录心脏电活动随时间的变化,通常是同时记录的一组信号,称为导联(lead)。标准判读用 12 导联,本案例用 15 导联。若部分心肌因供血不足受损,电流路径改变,训练有素的医生能从波形变化判断是否受损以及部位。

25.1.2 数据与特征提取

  • 数据来自 PhysioNet 数据库的 QT 数据集,共 447 条记录:79 条健康、368 条 MI。诊断由医生给出,但也可能有误。
  • 每条记录是 15 导联、1000 Hz 采样、持续数分钟的信号,数据量巨大,不可能整体输入网络,必须先做特征提取。通用的降维方法有线性的 PCA 和非线性的流形学习(如 Isomap),本案例改用医生常用的特征。
  • 心电图的一个原型周期由 P、Q、R、S、T 波组成,相关的间期有 PR 间期、QRS 时限、ST 段、QT 间期等。在此基础上设计了 47 个输入特征,大致分为:年龄、性别;心率的最大值、最小值、平均间隔、rms 偏差、分布宽度;QT 间期及校正 QT 的均值和 rms 偏差;QRS 间期、PR 间期、ST 段的均值与 rms 偏差;T 波最大幅度;RT 角;漏检 R 波数;未分析或缺失间期的百分比;QRS 波面积、S 到 T 波结束的面积及两者之比;ST 抬高等。
  • 目标:健康为 1,MI 为 \(-1\)。

类别不平衡。 健康记录只有 79 条,MI 有 368 条。若各样本误差等权,网络会偏向判 MI。理想办法是收集更多健康数据;不可能时有两种办法:用加权误差平方和(给健康样本更大权重,使两类总贡献相等),或者更简单地重复健康记录,使训练集中两类数量相等。本案例用后者。

白话解释:为什么误差等权时网络会偏向多数类。最小化均方误差的网络,输出会趋向"给定输入下目标的条件均值"。在两类特征重叠的区域,目标的条件均值由两类的数量比决定:MI 样本是健康的 4.7 倍,重叠区域里的条件均值会被 MI 拉过去,判为 MI。极端情况下,网络全判 MI 也能拿到 \(368/447\approx82\%\) 的准确率。复制健康记录相当于把训练集里的基础比率改成 1:1,重叠区域的输出被拉回中间。注意它改变的是网络"以为的"先验比例,所以输出不再是真实世界的概率;若之后要把输出当概率用,需要按真实比例校正回去。信用评分里对违约样本过采样后要做的"先验校正",是同一件事。

划分。 随机留 15% 验证、15% 测试;只在训练集中重复健康记录,验证集和测试集保持原样。这一点很关键:如果先复制再划分,同一条记录的副本会同时出现在训练集和测试集,测试结果会被高估。

归一化与目标值。 输入用式 (22.1) 缩放到 \([-1,1]\);输出层用 tansig,目标设为 \(\pm0.76\) 而不是 \(\pm1\),避免训练把 sigmoid 推向饱和(第 22 章)。

25.1.3 结构与训练

结构是模式识别的标准网络:47 个输入 → \(S^1\) 个 tansig 隐层神经元 → 1 个 tansig 输出神经元。先取 \(S^1=10\),训练后再检验。

用标度共轭梯度(SCG)训练,提前停止防过拟合。一次典型训练中,验证误差在第 16 次迭代达到最小,保存该处参数。要注意验证误差并不总是单调的,可能先升后降到更低,所以这里确认验证误差在 40 次迭代内不再降低才最终停止。耐心期设得太短,会过早停止。

25.1.4 验证

混淆矩阵(测试集),列为目标类、行为输出类:

目标:健康 目标:MI 按输出类
输出:健康 13 5 13/18 = 72.2%
输出:MI 1 66 66/67 = 98.5%
按目标类 13/14 = 92.9% 66/71 = 93.0% 总正确率 79/85 = 92.9%

分类问题的输出和目标是离散的,散点图用处不大,所以改看混淆矩阵。最大的错误是 5 个 MI 被判为健康,即漏诊;从临床角度,这比把健康判为 MI 代价更高。

ROC 曲线(测试集):理想路径从 \((0,0)\) 到 \((0,1)\) 再到 \((1,1)\),本例曲线接近理想。

金融直觉:ROC 曲线下的面积 AUC 有一个很直观的解释:从正类和负类里各随机抽一个样本,模型给正类样本打分更高的概率。AUC = 0.5 是瞎猜,1 是完美排序。信用评分里常用的 Gini 系数(又叫 accuracy ratio)就是 \(2\times\text{AUC}-1\),两者是同一个量的不同刻度。AUC 只看排序,不看阈值,所以它衡量的是"模型本身的区分能力";而混淆矩阵依赖于你选的阈值。这就是为什么 25.3.1 节重复少数类之后,混淆矩阵变化很大,AUC 却几乎不变:重复少数类等于整体抬高了正类的分数,排序没变,变的是阈值相对于分数的位置。

25.1.5 对划分敏感:蒙特卡洛评估

数据集小(尤其健康样本只有 79 条),结果可能严重依赖于某一次划分。原书把数据随机划分 1000 次,每次用不同初值训练一个网络,统计平均结果:

  • 每个测试集平均约 11.6 个健康者,其中 9.11 个判对(78.4%),2.51 个误判为 MI;
  • 约 53.5 个患者,其中 49.83 个判对(93.1%),3.69 个误判为健康;
  • 判为健康的输出中 71.2% 正确,判为 MI 的输出中 95.2% 正确;
  • 平均测试误差约 9.5%(正确率 90.5%)。

1000 次试验的百分比误差直方图均值为 9.5%,标准差 3.5,散布相当大。

推导拆解:这个 3.5 个百分点的标准差几乎可以事先算出来。每个测试集约 65 个样本(11.6 + 53.5),若真实误差率是 9.5%,把每个测试样本看成一次"分错/分对"的伯努利试验,观测误差率的标准差约为 \(\sqrt{0.095\times0.905/65}\approx0.036\),即 3.6 个百分点,与观测到的 3.5 非常接近。也就是说,散布的大部分只是"测试集太小"带来的抽样误差,而不是网络之间真有那么大的差别。推论:在 65 个测试样本上,92.9% 和 90.5% 的差距(约 1.5 个人)在统计上无法区分。回测里同理:只有 30 个月的测试期,两个策略的胜率或 IC 差一点点,多半是噪声。

也就是说,只看单次划分的结果会误导:上面那次 92.9% 的正确率只是一次比较幸运的划分。

蒙特卡洛过程还有两个用处:

  1. 找出无论怎么划分都被持续误分的病例,交给医生复核。若原标签错了,就修正数据库;若标签正确,就用这些病例改进网络,例如寻找能刻画其特征的新输入,或收集更多类似的数据。
  2. 把蒙特卡洛中得到的多个网络组合起来投票,通常能得到更准确的分类(即第 22 章的网络委员会)。

25.2 案例四:森林覆盖类型(聚类)

25.2.1 问题

聚类按相似性对数据分组,通常没有目标,采用无监督训练:不是训练网络产生期望响应,而是分析数据集、寻找隐藏的模式。应用包括数据挖掘、城市规划、图像压缩、说话人聚类、客户细分、大型文献库组织。自组织特征映射(SOFM)的独特之处是能把高维数据可视化,本案例重点展示这一点。

森林覆盖类型是林业资源清单的关键属性,但需要实地勘察或遥感,成本很高。数据中有 10 个容易获得的自变量:海拔、坡向、坡度、到最近地表水的水平和垂直距离、到最近道路的水平距离、夏至上午 9 点 / 正午 / 下午 3 点的山体阴影指数、到最近野火起火点的水平距离。覆盖类型共 7 种:0 高山矮曲林、1 云杉/冷杉、2 扭叶松、3 西黄松、4 三角叶杨/柳、5 白杨、6 花旗松。覆盖类型不用于训练,只用来检验 SOM 的聚类能力。

25.2.2 数据与预处理

  • 数据源自 UCI KDD 档案,原有 581,012 条观测;本案例取前 20,000 条、前 10 个自变量。
  • 无监督学习一般不划分训练/验证/测试集:竞争训练通常跑固定迭代次数,不需要验证集提前停止,全部数据用于训练。
  • 用式 (22.1) 缩放到 \([-1,1]\)(也可用式 22.2 标准化)。
  • 训练前先看数据:画散点图矩阵(对角线是各变量的直方图,非对角是两两散点图),检查两件事:数据是否充分分布在取值范围内(几乎不变的变量应剔除);变量之间是否线性相关(若散点恰好落在一条直线上,两个变量只需保留一个)。三个山体阴影指数之间有一定相关,但不是线性相关,都保留。

25.2.3 结构

神经元数要与数据点数匹配,使每个原型向量关联合理数量的数据。经验规则是神经元数随数据点数的平方根增长:\(\sqrt{20000}\approx141\),本案例用 150 个神经元,排成 15×10 的六边形网格(每个内部神经元有 6 个邻居)。竞争层为 \(n_i=-\|{}_i\mathbf w-\mathbf p\|\),\(\mathbf a=\mathrm{compet}(\mathbf n)\)。

与有监督训练不同,SOFM 没有一个确定的"最佳性能"标准,目的往往是获得对数据的洞见,所以选择结构和训练方案有一定艺术性,实践中常要尝试几种。

25.2.4 训练:线性初始化与批量 SOM

线性初始化:计算输入的协方差矩阵,取两个最大特征值对应的特征向量;把权值矩阵各行设为"输入均值 + 两个特征向量的线性组合",使所有初始权值整齐地铺在前两个主成分张成的平面上。这比随机初始化收敛更快。

白话解释:协方差矩阵的特征向量就是主成分方向,特征值就是数据在该方向上的方差。你在利率曲线分析里见过:收益率曲线变动的前三个主成分大致是"平移、斜率、曲率",第一个解释了大部分方差。线性初始化的意思是:先找出数据最"铺得开"的两个方向,再把 SOM 的二维网格像一张网一样平铺在这两个方向张成的平面上,网格的横向对应第一主成分、纵向对应第二主成分。25.3.2 节代码里乘以 \(\sqrt{\text{特征值}}\)(即该方向的标准差),是让网的大小与数据在两个方向上的散布相匹配。这样一开始拓扑顺序就是对的,批量训练只需微调,不容易出现第 15 章说的"扭结"。

序贯 SOM 学习规则(本册第 15 章,原书第 16 章):

\[{}_i\mathbf w(q)={}_i\mathbf w(q-1)+\alpha\big(\mathbf p(q)-{}_i\mathbf w(q-1)\big),\qquad i\in N_{i^*}(d),\qquad(26.1)\]
\(i^*\) 为获胜神经元,邻域 \(N_i(d)=\{j:d_{ij}\le d\}\)。写成邻域函数的形式:
\[{}_i\mathbf w(q)={}_i\mathbf w(q-1)+\alpha h_{ii^*}\big(\mathbf p(q)-{}_i\mathbf w(q-1)\big),\qquad h_{ii^*}=\begin{cases}1,&i\in N_{i^*}(d)\\0,&\text{其他}\end{cases}\qquad(26.3,26.4)\]

批量 SOM:呈现完所有输入后才更新一次权值,

\[{}_i\mathbf w(k)=\frac{\sum_{q=1}^Qh_{ii_q}\mathbf p(q)}{\sum_{q=1}^Qh_{ii_q}},\qquad(26.5)\]
\(k\) 是迭代次数,\(i_q\) 是输入 \(\mathbf p(q)\) 的获胜神经元。对阶跃邻域函数,这就是把每个权值设为"该神经元落在其获胜者邻域内的所有输入"的平均。学习率在分子分母中同时出现,所以不影响批量算法。和序贯算法一样,邻域要逐步缩小:先用大邻域把所有权值带到数据所在区域,再缩小邻域微调。批量算法所需迭代次数少得多(但每次计算量大),本案例只用了两次批量迭代:第一次邻域大小 4,第二次缩小到 1。

推导拆解:式 (26.5) 为什么是"加权平均"。固定所有输入的获胜者 \(i_q\),对神经元 \(i\) 考虑加权平方误差 \(E_i(\mathbf w)=\sum_qh_{ii_q}\|\mathbf w-\mathbf p(q)\|^2\)。对 \(\mathbf w\) 求梯度:\(\nabla E_i=2\sum_qh_{ii_q}(\mathbf w-\mathbf p(q))\)。令其为零,\(\mathbf w\sum_qh_{ii_q}=\sum_qh_{ii_q}\mathbf p(q)\),解出来正是 (26.5)。所以批量 SOM 每一步都在"给定获胜者分配,把每个原型放到使邻域内平方距离和最小的位置",然后重新分配获胜者,再求平均。这和 k-means 的两步交替(分配样本、重算均值)完全一样,邻域缩小到 0 时就退化为 k-means。

学习率为什么消失:序贯规则若把所有输入的更新"累积起来、令其总和为零"(即达到平衡点),条件是 \(\sum_q\alpha h_{ii_q}(\mathbf p(q)-\mathbf w)=\mathbf 0\),\(\alpha\) 是公共因子,可以约去,平衡点与 \(\alpha\) 无关。批量算法直接跳到这个平衡点。

25.2.5 验证与可视化

两个数值指标(第 22 章):

  • 量化误差 0.535:每个数据向量到其获胜神经元的平均距离,衡量分辨率;
  • 拓扑误差 0.037:不到 4% 的输入,其获胜神经元与次近神经元在映射上不相邻,说明训练结束时拓扑已经正确。拓扑保持很重要,下面的可视化工具都依赖它。

U 矩阵(unified distance matrix):显示映射中相邻神经元权值之间的距离。每个神经元一个格,每对相邻神经元之间再插一个格;神经元之间的格按对应权值的距离着色,神经元格按周围值的平均着色,颜色越浅距离越大。原书图中映射左侧有一串浅色格,说明左侧的神经元对应的簇与中部、右侧显著不同。浅色的"山脊"就是簇的边界。

标注映射:本数据其实知道覆盖类型,可以给每个神经元标上离它最近的输入的类型。与 U 矩阵对照:类型 2(扭叶松)在映射左边缘;从左到右依次是类型 0、1,然后类型 5、3、4,类型 6 主要在右上部。SOM 在不知道真实类别的情况下,已经按覆盖类型聚了类。

命中直方图(hit histogram):统计每个神经元在全数据集上获胜的次数,用六边形大小表示;再用灰度表示覆盖类型,可以看到各区域颜色一致。

分量平面(component plane):每个分量平面对应权值矩阵的一列,即输入的一个元素,在映射上用灰度显示该元素在每个神经元权值中的大小。10 个分量平面各不相同,说明没有冗余变量;海拔、到水体的水平/垂直距离、到道路的距离、到起火点的距离这几个变量在映射左边缘出现明显边界,似乎对区分类型 2 与其他类型很重要。

很多问题根本无法给每个输入打标签。本案例的要点是:SOM 在不知道真实类型的情况下,把数据聚成了与真实覆盖类型一致的簇,说明这 10 个输入与覆盖类型足够相关,聚类结果有实际意义。


25.3 量化实战

25.3.1 稀有事件预警:不平衡与评估陷阱

对应关系。 心肌梗死识别与"预测未来几天会不会大跌"在结构上很像:输入是若干经过设计的特征,正类相对稀少,漏报(把危险判为安全)的代价远大于误报。案例三的几条经验都能直接用:

  • 特征工程优先。 案例不把原始高频信号直接喂给网络,而是提取有含义的统计量(均值、rms 偏差、比例、缺失比例),这与从 tick 数据构造波动率、价差、订单不平衡、成交间隔统计等因子的做法一致。"未分析或缺失间期的百分比"本身也是特征,这一点值得借鉴,比如停牌、数据缺失本身常带信息。
  • 只在训练集中处理不平衡。 过采样或加权只能作用于训练集,验证集和测试集保持原始分布,否则评估指标被高估。金融数据还多一层:必须按时间顺序划分。
  • 按代价选阈值。 混淆矩阵中漏报对应"把高风险判为低风险";ROC 曲线把"选阈值"与"模型区分能力"分开,阈值应根据漏报和误报的相对代价、以及策略能承受的误报频率来定。
  • 评估要看分布。 原书 1000 次随机划分的思想在时间序列上的对应物是:多个滚动测试窗口 × 多个随机种子,报告测试指标的均值和标准差。
  • 持续误分样本。 长期被模型错判的股票或时期,可能是数据问题(复权、停牌、退市处理),也可能提示缺失的因子。

下面的代码模拟 8 个持续的日度风险特征,正类"大跌"约占 8%,由其中几个特征的非线性组合决定。比较三件事:不处理不平衡 vs. 只在训练集内重复少数类;错误地"先过采样再随机划分";以及多窗口多种子的评估分布。

import numpy as np, warnings
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import confusion_matrix, roc_auc_score
warnings.filterwarnings("ignore")
rng = np.random.default_rng(11)

# ---------- 模拟: 8 个日度风险特征, 预测"未来 5 日大跌"这一稀有事件 ----------
T = 6000
F = np.zeros((T, 8))
for t in range(1, T):
    F[t] = 0.9 * F[t - 1] + rng.normal(0, np.sqrt(1 - 0.81), 8)   # 持续的风险特征
score = 1.4 * F[:, 0] + 1.0 * F[:, 1] * (F[:, 2] > 0) - 0.8 * F[:, 3] + rng.normal(0, 1.2, T)
y = (score > np.quantile(score, 0.92)).astype(int)                  # 约 8% 为"大跌"(正类)
print(f"正类比例 = {y.mean():.3f}")

def fit_eval(tr, te, balance, seed):
    Xtr, ytr = F[tr], y[tr]
    if balance:                                    # 只在训练集内重复少数类(原书第 25 章做法)
        pos = np.where(ytr == 1)[0]
        extra = rng.choice(pos, (ytr == 0).sum() - len(pos))
        Xtr, ytr = np.vstack([Xtr, Xtr[extra]]), np.concatenate([ytr, ytr[extra]])
    mu, sd = Xtr.mean(0), Xtr.std(0)
    net = MLPClassifier(hidden_layer_sizes=(10,), activation="tanh", alpha=1e-2,
                        max_iter=500, random_state=seed).fit((Xtr - mu) / sd, ytr)
    p = net.predict_proba((F[te] - mu) / sd)[:, 1]
    return confusion_matrix(y[te], (p > 0.5).astype(int), labels=[1, 0]).T, roc_auc_score(y[te], p)

def show(C, auc, title):            # 原书格式: 列=目标类, 行=输出类, 类 1 = 大跌
    rec, prec = np.diag(C) / C.sum(0), np.diag(C) / C.sum(1).clip(1)
    print(f"{title}\n  混淆矩阵 [[TP FP],[FN TN]] = {C.tolist()}  召回(大跌/正常) = {np.round(rec, 3)}"
          f"  精确(大跌/正常) = {np.round(prec, 3)}  AUC = {auc:.3f}")

tr, te = np.arange(0, 4800), np.arange(4800 + 5, T)     # 时间顺序, 中间留 5 天(标签长度)
show(*fit_eval(tr, te, False, 0), "不平衡处理: 无")
show(*fit_eval(tr, te, True, 0),  "不平衡处理: 训练集内重复少数类")

# ---------- 错误示范: 先过采样再随机划分 -> 复制品同时出现在训练集和测试集 ----------
pos = np.where(y == 1)[0]; extra = rng.choice(pos, (y == 0).sum() - len(pos))
Xb, yb = np.vstack([F, F[extra]]), np.concatenate([y, y[extra]])
perm = rng.permutation(len(yb)); a, b = perm[:int(.8 * len(yb))], perm[int(.8 * len(yb)):]
mu, sd = Xb[a].mean(0), Xb[a].std(0)
net = MLPClassifier(hidden_layer_sizes=(10,), activation="tanh", alpha=1e-2, max_iter=500,
                    random_state=0).fit((Xb[a] - mu) / sd, yb[a])
print(f"错误示范(先过采样后随机划分)的测试 AUC = {roc_auc_score(yb[b], net.predict_proba((Xb[b] - mu) / sd)[:, 1]):.3f}")

# ---------- "蒙特卡洛"评估的时间序列版本: 多个滚动测试窗 x 多个随机种子 ----------
errs, aucs = [], []
for start in range(2000, 5500, 500):                    # 7 个滚动窗口
    tr, te = np.arange(0, start), np.arange(start + 5, start + 505)
    for seed in range(5):
        C, auc = fit_eval(tr, te, True, seed)
        errs.append(1 - np.trace(C) / C.sum()); aucs.append(auc)
print(f"35 次评估: 测试误差率 均值 {np.mean(errs):.3f} 标准差 {np.std(errs):.3f}"
      f" [最小 {np.min(errs):.3f}, 最大 {np.max(errs):.3f}];  AUC 均值 {np.mean(aucs):.3f} 标准差 {np.std(aucs):.3f}")

关键输出:

正类比例 = 0.080
不平衡处理: 无
  混淆矩阵 [[TP FP],[FN TN]] = [[44, 17], [46, 1088]]  召回(大跌/正常) = [0.489 0.985]  精确(大跌/正常) = [0.721 0.959]  AUC = 0.932
不平衡处理: 训练集内重复少数类
  混淆矩阵 [[TP FP],[FN TN]] = [[76, 122], [14, 983]]  召回(大跌/正常) = [0.844 0.89 ]  精确(大跌/正常) = [0.384 0.986]  AUC = 0.920
错误示范(先过采样后随机划分)的测试 AUC = 0.950
35 次评估: 测试误差率 均值 0.123 标准差 0.027 [最小 0.084, 最大 0.196];  AUC 均值 0.908 标准差 0.027

怎么读这组结果:

  • 不处理不平衡时,总正确率很高(约 95%),但大跌的召回率只有 48.9%,一半的大跌被漏掉。这和原书"网络会偏向多数类"的说法一致,单看准确率会被误导。
  • 重复少数类后,大跌召回率升到 84.4%,代价是精确率降到 38.4%:每发出约 2.6 次预警才有 1 次真的大跌。两者的 AUC 几乎相同(0.932 vs 0.920),说明重复少数类主要是移动了决策阈值,并没有提升模型的区分能力。实际中也可以不重采样,直接在 ROC 曲线上按代价选阈值。

推导拆解:精确率为什么只有 38.4%,可以用贝叶斯公式从召回率和误报率推出来。正类比例 \(\pi=8\%\),召回率(真阳性率)\(\text{TPR}=0.844\),正常日被误报的比例 \(\text{FPR}=1-0.89=0.11\)。

\[\text{精确率}=\frac{\pi\cdot\text{TPR}}{\pi\cdot\text{TPR}+(1-\pi)\cdot\text{FPR}}=\frac{0.08\times0.844}{0.08\times0.844+0.92\times0.11}\approx\frac{0.0675}{0.0675+0.1012}\approx0.40.\]
与输出的 0.384 一致(测试段的实际正类比例略低于 8%)。要点是:误报率 11% 看上去不高,但它乘的是 92% 的正常日,而召回率乘的只是 8% 的大跌日,所以预警中大部分是误报。这和医学筛查里"罕见病即使检测很准,阳性结果多半也是假阳性"是同一个道理。正类越稀少,想要高精确率就越需要极低的误报率。

  • 先过采样再随机划分,AUC 被抬高到 0.950,高于任何诚实的评估。原因是同一条正样本的复制品同时进了训练集和测试集,网络等于在"考原题"。模型越灵活,这种虚高越严重。
  • 多窗口 × 多种子的 35 次评估中,误差率从 8.4% 到 19.6% 不等,标准差 2.7 个百分点,与原书误差均值 9.5%、标准差 3.5 的情形类似:单次评估可能落在这个区间的任何位置。

25.3.2 市场状态地图:SOM

SOM 的价值在于把多维的市场指标压到一张二维地图上:U 矩阵显示状态之间的边界,命中直方图显示各状态出现的频率,分量平面显示是哪些变量把状态区分开;把每天的获胜神经元按时间连起来,就得到"市场在状态地图上的轨迹",可以用于情景分析和状态识别。同样的方法也可以按收益相关性或基本面特征给股票聚类,得到数据驱动的"行业"分组,用于中性化或配对交易候选的筛选。

下面用 4 种模拟市场状态(平稳上涨、高波动下跌、震荡、危机,危机只占 5%)下的 6 维特征(动量、波动、相关性、期限利差、信用利差、成交量),完全按案例四的流程实现批量 SOM:神经元数取 \(\sqrt{2000}\approx45\),排成 9×5 六边形网格;线性初始化;按式 (26.5) 做批量更新,邻域半径从 4 逐步缩小到 1;最后计算量化误差、拓扑误差,打印标注映射、U 值和一个分量平面。

import numpy as np
rng = np.random.default_rng(5)

# ---------- 模拟 4 种市场状态下的 6 维日度特征 ----------
names = ["平稳上涨", "高波动下跌", "震荡", "危机"]
mu = np.array([[ 0.8, -0.8, -0.5,  0.6, -0.5,  0.0],   # [动量, 波动, 相关性, 期限利差, 信用利差, 成交量]
               [-0.6,  0.6,  0.5, -0.2,  0.4,  0.6],
               [ 0.0, -0.1,  0.0,  0.0,  0.0, -0.6],
               [-1.5,  1.8,  1.5, -1.0,  1.8,  1.5]])
lab = rng.choice(4, 2000, p=[.4, .25, .3, .05])
X = mu[lab] + rng.normal(0, 0.35, (2000, 6))
X = (X - X.mean(0)) / X.std(0)                        # 式(22.2)

# ---------- 六边形 9x5 网格, 约 sqrt(2000)=45 个神经元 ----------
cols, rows = 9, 5
pos = np.array([[c + 0.5 * (r % 2), r * np.sqrt(3) / 2] for r in range(rows) for c in range(cols)])
D = np.linalg.norm(pos[:, None] - pos[None], axis=2)  # 神经元在映射上的距离
adjacent = (D > 0) & (D < 1.01)                        # 六边形: 内部神经元有 6 个邻居

# 线性初始化: 权值铺在前两个主成分张成的平面上
ev, evec = np.linalg.eigh(np.cov(X.T)); pc = evec[:, ::-1][:, :2] * np.sqrt(ev[::-1][:2])
g = (pos - pos.mean(0)) / pos.std(0)
W = X.mean(0) + g @ pc.T

def bmu(W, X):                                         # 获胜神经元与次近神经元
    d = np.linalg.norm(X[:, None] - W[None], axis=2); o = np.argsort(d, 1)
    return o[:, 0], o[:, 1], d[np.arange(len(X)), o[:, 0]]

# 批量 SOM, 式(26.5): w_i = sum_q h_{i,i_q} p_q / sum_q h_{i,i_q}, 邻域半径逐步缩小
for radius in (4, 3, 2, 1, 1, 1):
    win, _, _ = bmu(W, X)
    h = (D[:, win] <= radius + 1e-9).astype(float)     # 阶跃邻域函数(26.4), S x Q
    W = (h @ X) / h.sum(1, keepdims=True)

win, second, dist = bmu(W, X)
print(f"量化误差 = {dist.mean():.3f}   拓扑误差 = {np.mean(~adjacent[win, second]):.3f}")

# 标注映射: 每个神经元标上命中样本中最多的状态; 命中直方图
hits = np.bincount(win, minlength=len(W))
tag = np.full(len(W), "·")
for i in range(len(W)):
    if hits[i]: tag[i] = "ABCD"[np.bincount(lab[win == i], minlength=4).argmax()]
print("标注映射 (A=平稳上涨 B=高波动下跌 C=震荡 D=危机, ·=无命中):")
for r in range(rows - 1, -1, -1):
    print("   " + (" " if r % 2 else "") + " ".join(tag[r * cols:(r + 1) * cols]))
purity = sum(np.bincount(lab[win == i]).max() for i in range(len(W)) if hits[i]) / len(X)
print(f"按神经元多数标签的纯度 = {purity:.3f}")

# U 矩阵的简化版: 每个神经元到其邻居权值的平均距离
U = np.array([np.linalg.norm(W[adjacent[i]] - W[i], axis=1).mean() for i in range(len(W))])
for k in range(4):
    print(f"  {names[k]:<5}: 所在神经元平均 U 值 = {U[win[lab == k]].mean():.3f}")

# 分量平面: 某个特征在映射上的分布(这里看"信用利差"列)
print("分量平面(信用利差, 权值 x10 取整):")
for r in range(rows - 1, -1, -1):
    print("   " + (" " if r % 2 else "") + " ".join(f"{int(round(10 * W[r * cols + c, 4])):>3d}" for c in range(cols)))

关键输出:

量化误差 = 1.007   拓扑误差 = 0.052
标注映射 (A=平稳上涨 B=高波动下跌 C=震荡 D=危机, ·=无命中):
   A A C C C C C B B
    A A C C C C B B B
   A A A C C C B B B
    A A A A B B B B D
   A A A A A B B B D
按神经元多数标签的纯度 = 0.973
  平稳上涨 : 所在神经元平均 U 值 = 0.500
  高波动下跌: 所在神经元平均 U 值 = 0.824
  震荡   : 所在神经元平均 U 值 = 0.631
  危机   : 所在神经元平均 U 值 = 1.343
分量平面(信用利差, 权值 x10 取整):
   -11  -9  -2   1   1   0   1   2   5
     -9  -5   0   0   1   1   5   7   6
    -9  -6  -3  -1   0   2   6   8  17
     -8  -5  -5  -4   2   5   7  17  26
    -9  -7  -7  -6  -4   4   6   8  23

结果与案例四的各项分析一一对应。拓扑误差 5.2%,与原书的 3.7% 同一量级,说明映射保持了拓扑,可视化可信。标注映射上四种状态各占一块连续区域:平稳上涨在左,震荡在上中,高波动下跌在右,危机挤在右下角。纯度 97.3% 说明 SOM 在不知道状态标签的情况下找回了状态结构。危机样本所在神经元的 U 值最大(1.343),即它们与邻居差别很大,这就是 U 矩阵上"浅色山脊"的含义:危机是一个与其他状态隔开的区域,并且只占两个神经元,因为它只有约 5% 的样本。信用利差的分量平面从左到右单调升高、在右下角达到最大,说明它是区分危机和平稳状态的关键变量,相当于原书用分量平面找出区分类型 2 的变量。

使用时要注意:SOM 没有明确的最优准则,结果依赖于网格大小、初始化和邻域衰减方案。它更适合作为探索性工具,而不是直接当交易信号;在金融数据上还要检查簇在时间上是否稳定,例如用前一半数据训练、看后一半数据落到地图上的哪些位置。


本章小结

模式识别案例的流程是:用领域知识把海量原始信号压成少量有含义的特征;只在训练集中处理类别不平衡(重复少数类或加权误差),验证和测试集保持原始分布;输入归一化,tansig 输出目标设为 \(\pm0.76\);用 SCG 加提前停止训练,耐心期要足够长;用混淆矩阵和 ROC 验证,并按误判代价选阈值;用多次划分、多次初始化的蒙特卡洛评估得到误差的分布,识别持续误分的样本,并可让多个网络投票。聚类案例的流程是:先用散点图矩阵检查变量;归一化;神经元数约按 \(\sqrt N\) 设定;用前两个主成分做线性初始化;批量 SOM 把权值设为邻域内输入的平均,迭代次数少、与学习率无关,邻域逐步缩小;用量化误差、拓扑误差评估,用 U 矩阵看簇边界、命中直方图看频率、分量平面看变量作用。在量化中,前者对应稀有事件预警(注意过采样的位置、阈值的选择、评估的分布),后者对应市场状态地图。

概念 公式 / 要点
召回率(按目标类) 对角元 / 列和
精确率(按输出类) 对角元 / 行和
类别不平衡 训练集内重复少数类或加权误差;验证、测试集不动
蒙特卡洛评估 多次划分 × 多次初始化,报告误差均值与标准差
SOM 神经元数 约 \(\sqrt N\)
线性初始化 权值铺在前两个主成分平面上
序贯 SOM \({}_i\mathbf w\leftarrow{}_i\mathbf w+\alpha h_{ii^*}(\mathbf p-{}_i\mathbf w)\)
批量 SOM \({}_i\mathbf w=\sum_qh_{ii_q}\mathbf p_q/\sum_qh_{ii_q}\)
量化误差 / 拓扑误差 到获胜神经元的平均距离 / 获胜与次近不相邻的比例
U 矩阵、命中直方图、分量平面 簇边界 / 样本频率 / 单个变量在映射上的分布

练习

基础

  1. 原书 1000 次蒙特卡洛的平均结果中,每个测试集约 11.6 个健康者、53.5 个患者,验证"平均测试误差约 9.5%"。 答案:误分数约 \(2.51+3.69=6.2\),总数 \(65.1\),\(6.2/65.1\approx9.5\%\)。
  2. 为什么重复健康记录只能在训练集中做?如果在划分前做,会发生什么? 提示:副本会同时出现在训练集和测试集,测试变成"考原题",结果被高估(本章代码中 AUC 从约 0.92 被抬高到 0.95)。
  3. 有 20,000 个数据点时,按经验规则 SOM 大约用多少个神经元?若数据增加到 80,000 呢? 答案:约 141;约 283(只增加一倍,而不是四倍)。
  4. 批量 SOM 为什么与学习率无关? 提示:式 (26.5) 中学习率在分子分母同时出现,相消。
  5. 量化误差可以通过增加神经元无限减小,那它还有什么用?什么情况下它没有意义? 提示:在神经元数固定时比较训练方案;神经元数不显著少于数据点数时没有意义。

进阶

  1. 在本章大跌预警代码中,不做重采样,而是在 ROC 曲线上找使"漏报代价 × FN + 误报代价 × FP"最小的阈值(设漏报代价是误报的 5 倍),与重复少数类的结果比较。
  2. 把大跌预警代码中的"重复少数类"换成加权误差(MLPClassifier 不支持样本权重时,可以用 sklearn.linear_model.LogisticRegression(class_weight="balanced") 作对照),比较召回率、精确率和 AUC。
  3. 修改 SOM 代码:用随机初始化代替线性初始化,并只做原书那样的两次批量迭代(邻域 4、1),比较量化误差和拓扑误差。
  4. 在 SOM 代码中生成带时间顺序的状态序列(如马尔可夫链切换状态),把每天的获胜神经元连成轨迹,统计相邻两天获胜神经元在映射上的平均距离,在状态切换日和非切换日分别是多少?
  5. 用前一半数据训练 SOM,把后一半数据投到地图上,计算后一半数据的量化误差。如果后一半出现了训练期从未有过的状态,量化误差会怎样变化?这与第 22 章的新颖性检测有什么关系?

原书推荐习题:第 25、26 章为案例章,无习题。建议用原书数据 ekg_p.txt/ekg_t.txt 复现 1000 次蒙特卡洛划分,比较"重复少数类"与"加权误差",以及多网络投票的提升;用 cover_p.txt/cover_t.txt 比较线性初始化与随机初始化,以及不同网格尺寸下量化误差与拓扑误差的权衡。

原书对照

本章小节 原书章节 PDF 页码
25.1.1 问题 25 Objectives;Description of Myocardial Infarction Recognition p.941–942
25.1.2 数据与特征 Data Collection and Preprocessing(47 个特征,类别不平衡) p.943–946
25.1.3 结构与训练 Selecting the Architecture;Training the Network(图 25.3–25.4) p.946–947
25.1.4–25.1.5 验证 Validation(图 25.5–25.8,蒙特卡洛) p.947–950
— 第 25 章 Epilogue、Further Reading p.951–952
25.2.1 问题 26 Objectives;Description of the Forest Cover Problem(表 26.1–26.2) p.953–955
25.2.2 数据 Data Collection and Preprocessing(图 26.1) p.956–957
25.2.3 结构 Selecting the Architecture(图 26.2) p.957–958
25.2.4 训练 Training the Network(式 26.1–26.5) p.958–959
25.2.5 验证 Validation(图 26.3–26.6) p.959–962
— 第 26 章 Epilogue、Further Reading p.963–964