两万条数据、四轮返工、前前后后折腾了将近三个月,这是我在做一个机器学习量化策略项目时,花在数据标注上的全部成本。很多同行听到这个数字的第一反应是“值不值”,我当时的回答是:先别问值不值,先问你能不能标得动。因为真正动手之后你会发现,量化领域最难的不是模型结构,也不是特征工程,而是你根本找不到一份“标准答案”。行情数据本身是确定的,收盘价就是收盘价,可模型要学的从来不是收盘价,而是“在这样的信息条件下,下一步该怎么判断”。这个判断,机器给不出来,只能靠人一条一条过。这篇文章我想把整件事从头到尾拆开讲:为什么我要自己下场标注、标签体系怎么设计、流水线怎么搭、训练和回测里踩了哪些坑。适合已经有一点因子或策略基础、正准备往机器学习方向走的朋友,也适合纯粹好奇“量化里的标注到底在标什么”的读者。
1. 为什么我决定自己动手标注两万条数据
1.1 规则策略撞墙的那半年
最开始我是写规则策略的,逻辑很朴素:均线多头排列加成交量放大,或者某个震荡指标超卖之后反转。这套东西在样本内跑得漂漂亮亮,一上样本外就开始拉稀。我记得最清楚的一次,一个从三年数据里调出来的参数组合,回测年化看着很舒服,实盘跑了两个月,信号触发的频率只有回测的三分之一,原因是市场结构变了,原来的阈值区间几乎不再被触及。
撞墙之后我的判断是:问题不在于规则写得不够多,而在于规则是人工穷举的,维度上不去。三五个条件组合出来的决策面,本质上是把一个高维空间切成几块方块,而真实市场的关系是连续、非线性、还带大量交互项的。这时候往机器学习方向走是很自然的选择,但一走进去就撞上第二堵墙:监督学习需要标签,而市场不会给你标签。
很多人以为量化里的标签就是“明天涨还是跌”,理论上没错,但真按这个标,你会发现两万条数据里大概有一万条是噪声。涨幅0.3%算涨还是算跌?在低波动品种上0.3%是大事,在高波动品种上连水花都算不上。所以标签体系的第一个问题不是“标什么”,而是“用什么口径去定义一件事情的轻重”。
1.2 标注在量化里到底标的是什么
这里我想把一件事说透,因为它决定了你后面所有的工程决策。量化的标注分成两层。第一层是机械层,完全可以用代码生成:给定未来N个交易日的收益,按某个规则映射成类别。这一层不需要人,一行代码的事。第二层是语境层,是代码生成不了的部分:这个跳空是正常波动还是因为某条消息、这个形态在真实盘口下能不能成交、这段行情是不是停牌复牌造成的假信号。
我最初天真地以为第一层就够了,写了个脚本把未来五日收益率按正负二分,拿着八万条样本去训练,结果模型学出来的东西基本等价于“买波动大的”,因为高波动标的天然有更大的正负收益幅度,分类边界被波动率主导了。这就是典型的“标签口径被无关变量污染”。
所以后来我做的事情是:机械层负责生成初始标签,人工层负责三件事——修正被异常事件污染的样本、给边界样本做仲裁、标记那些“数据上看不出来但实盘会出问题”的情况。两万条里,人工真正逐条过手的大概是七千条左右,剩下的是高置信度样本直接放行。这个比例很重要,如果你的流水线需要人肉看百分之百的数据,那这事基本做不下去。
提醒:不要一开始就想着标两万条。先标五百条,把标签规范、边界情况、争议点全部暴露出来,再谈规模化。我第一版规范写了不到一页纸,标到第三百条的时候就发现有一半的情况它覆盖不了。
2. 标签体系怎么定:先想清楚模型要吃什么样的答案
2.1 四种常见标签方案的横向对比
在动手之前我把能想到的方案列了一张表,逐个评估。这个对比过程我觉得比结论本身更有价值,因为很多坑在纸面上就能看出来。
| 方案 | 具体做法 | 信息量 | 主要问题 | 适用场景 |
|---|---|---|---|---|
| 回归未来收益 | 直接预测未来N日收益率数值 | 高 | 噪声极大,损失函数被极端值主导 | 排序类任务、配合稳健损失 |
| 二分类方向 | 未来收益>0记1,否则记0 | 低 | 边界样本全是噪声,类别易失衡 | 快速验证特征有效性 |
| 分位数分箱 | 按收益分位切三档或五档 | 中 | 分位点随样本窗口漂移 | 分层选股、组合构建 |
| 事件型标签 | 是否触发某类形态或状态 | 中高 | 定义成本高,正样本稀疏 | 择时、特殊行情识别 |
回归方案我试过一版,用MSE训练出来的模型预测值几乎全部挤在零附近,因为极端收益样本把梯度带偏了。后来改用Huber损失好一些,但解释性依然很差。二分类的问题更直接:我在两万条样本上统计过,未来五日收益绝对值小于0.5%的样本占了四成多,这些样本的方向标签基本等于抛硬币,模型学到的是噪声。分位数分箱是我最后走的路,事件型标签则作为辅助任务并行做。
2.2 我最终选的三分类加波动率归一
具体口径是这样:取未来5个交易日的累计收益率,除以过去20个交易日的已实现波动率做归一化,得到一个风险调整后的收益z值,再按z值的30%和70%分位切三档,分别对应“弱于常态”“常态”“强于常态”。
这里的每一步都有理由。用5日而不是1日,是因为1日收益的信噪比太低,而且高频调仓的成本会吃掉所有alpha;用5日而不是20日,是因为跨度越长,标签和特征的时序重叠越严重,训练集和验证集之间的信息泄漏越难控制。除以过去20日波动率,是为了把“涨了2%”这件事放到该标的自身的波动尺度上去衡量——一个日波动0.5%的品种涨2%是异动,一个日波动3%的品种涨2%什么都不是。这个归一化动作看起来简单,但它直接把标签和波动率这个混淆变量解耦了,我做完这一步之后模型的混淆矩阵才终于像点样子。
分位点选30/70而不是33/33,是因为中间那一档我希望能稍微宽一点,让“常态”类占比接近四成,避免三分类变成2:1:1之后模型倾向于预测多数类。实际统计下来三类的分布大概是28%、41%、31%,还算均衡。
import numpy as np import pandas as pd def build_label(df, horizon=5, vol_window=20, low_q=0.3, high_q=0.7): # df 需包含 close 列,索引为交易日 fwd_ret = df['close'].shift(-horizon) / df['close'] - 1.0 log_ret = np.log(df['close'] / df['close'].shift(1)) realized_vol = log_ret.rolling(vol_window).std() * np.sqrt(252) z = fwd_ret / realized_vol.replace(0, np.nan) lo, hi = z.quantile(low_q), z.quantile(high_q) label = pd.Series(1, index=df.index) # 1 = 常态 label[z <= lo] = 0 # 0 = 弱于常态 label[z >= hi] = 2 # 2 = 强于常态 label[fwd_ret.isna()] = -1 # -1 = 无效样本,不参与训练 return label, z代码不复杂,但有一个细节必须强调:shift(-horizon)之后最后5行一定是NaN,这些样本必须显式标成无效,而不是让它们默默变成某个默认类别。我在第一版里就犯了这个错,结果模型莫名其妙地学会了“样本越靠后越容易涨”,因为它把NaN填充成了0再转成类别1。
2.3 标签的时间边界与未来函数陷阱
这一节我想单独拿出来讲,因为它是整个项目里最容易被忽视、后果最严重的地方。标签用到了未来5日的信息,这意味着任何一个训练样本的“答案”都覆盖了它之后5个交易日。如果你把样本按时间顺序简单切成前80%训练、后20%验证,那么训练集最后5天的标签其实用到了验证集前几天的价格——这就是信息泄漏。
我的处理方式是三重隔离。第一,训练集和验证集之间留出至少等于标签跨度的空档期,也就是5个交易日,我实际留了10个。第二,特征的最长回看窗口也要计入,比如某个特征用了过去60日均值,那空档期至少是60加5,因为你不能保证验证集第一天的特征计算窗口没有伸进训练集尾巴。第三,所有涉及横截面排序的特征必须在每个时间截面上独立计算,不能跨期做全局排名。
注意:特征里最阴险的泄漏不是价格类,而是财务和公告类。财报的“报告期”和“实际披露日”是两个完全不同的时间点,用报告期对齐等于提前知道了未来。这件事我在第三周才发现,返工了大概四千条样本的特征。
3. 标注流水线的工程化:从两万条堆成两万条可用
3.1 半自动预标注:让机器先干七成的活
两万条纯手工标,按每条30秒算也是160多个小时,这个投入不现实。我的做法是三层漏斗。第一层是规则引擎,把明显无效的样本直接剔掉,比如ST状态、上市不满60个交易日、当日成交额低于某阈值、停牌前后三个交易日,这一层大约砍掉18%。第二层是模型预标注,用一个早期训练的粗模型输出三类概率,概率最大值超过0.85的直接采纳为预标结果,不做人工干预,这一层覆盖了剩下样本里的六成左右。第三层才是人工,只处理模型不确定的边界样本,也就是最高概率落在0.4到0.6区间的那些。
这个漏斗设计的关键在于:人工不是去“生产”标签,而是去“裁决”分歧。这两件事的心理负担完全不一样。生产标签需要你从零思考,容易疲劳、容易标准漂移;裁决分歧只需要你在两个已有选项中做判断,速度快得多,一致性也高得多。我统计过,裁决模式下我平均每条花12秒,生产效率模式下平均每条花40秒,而且后者在连续工作一小时后准确率明显下降。
3.2 标注界面与操作手感
界面这件事听起来像是次要问题,但它直接决定你能不能在两周内标完七千条。我自己搭了一个极简的本地页面,核心就几块:上面是该标的最近120根K线的走势图,中间是当期特征值的表格,下面是三个大类按钮和几个常见异常的标记框,右侧是备注输入框。
手感上有几个细节值得说。快捷键必须能覆盖九成的操作,我当时设的是数字键1、2、3分别对应三类,空格跳到下一条,退格回上一条,Q标记“数据异常”,W标记“疑似事件驱动”。自动保存必须做到每一条都落盘,因为我确实遇到过一次连续标了两百多条之后页面崩了,那种感觉非常糟糕。还有一点,列表要支持按“预标注置信度升序”排序,让你先处理最难的,因为人的注意力是递减的,难的样本放在后面处理质量会很差。
3.3 双标抽检与一致性校验
一个人标完就算完,风险太大。我抽了10%的样本做双标,也就是同一条数据让两个不同的判断者独立标一次,然后算Cohen's Kappa系数。第一轮的结果是0.61,这个数值属于“中等一致”,放在学术场景勉强能看,放在生产场景就是不能用。
排查下来分歧集中在两个地方:一是“弱于常态”和“常态”的边界,很多样本的z值刚好卡在0.3分位附近,两个人对“这算不算弱”的判断完全不同;二是事件型标记,一个人觉得某天的跳空值得标,另一个人觉得幅度不够。针对第一个问题,我引入了“边界缓冲带”,z值落在分位点正负0.05区间内的样本不参与评估,避免用噪声衡量一致性;针对第二个问题,我把事件的判定标准从“感觉明显”改成了可以量化的口径,比如跳空幅度超过过去20日平均振幅的1.5倍,且当日成交量超过20日均量的1.8倍。
改完之后第二轮Kappa到了0.78,第三轮0.81。到这里我才认为标签质量可以支撑训练。
提示:Kappa不是越高越好,0.8以上说明规范清晰,但如果超过0.95,反而要怀疑是不是标注者之间存在“抄答案”式的趋同,或者任务本身太简单没有信息量。
4. 特征工程与训练细节:标完数据才是开始
4.1 特征清单与计算口径
标签定好之后,特征这边我按五个大类铺开,每一类都严格控制计算窗口,确保只用当期及之前的信息。
| 类别 | 代表特征 | 计算口径要点 |
|---|---|---|
| 动量与趋势 | 5/20/60日收益率、均线偏离度 | 全部用复权价,避免除权跳空污染 |
| 波动结构 | 已实现波动率、振幅、ATR | 波动率用对数收益标准差,年化处理 |
| 量能 | 量比、换手率、量价相关性 | 成交量需做流通股本归一 |
| 横截面 | 行业内排名、全市场分位 | 每个时间截面独立计算,禁止跨期 |
| 微观结构 | 大单占比代理、日内价格位置 | 用日频数据近似,口径需一致 |
这里我想强调“横截面”那一栏。很多人做量化特征喜欢用绝对数值,比如换手率是3%,但3%在不同市值、不同行业里含义完全不同。我做法是每个交易日把全市场样本按行业分组,组内做百分位排名,再把排名映射到0到1之间。这一步做完,特征的分布稳定性提升非常明显,训练集和验证集的特征均值差异从原来的显著变成了基本重合。代价是每个截面都要重算,数据量大的时候计算耗时上去了,我用的是向量化操作加分组apply,两万条样本乘上几百个特征,单机跑一次大概十几分钟,可以接受。
4.2 时序交叉验证的正确打开方式
普通的K折交叉验证在时序数据上基本等于自欺欺人,因为随机打乱之后,训练集里必然混入了验证集未来时点的样本。我用的是带净化间隔的时序分折,核心思想是:折与折之间按时间顺序推进,每折训练集和验证集之间挖掉一段空档。
def purged_time_split(n_samples, n_splits=5, embargo=10, label_span=5): fold_size = n_samples // (n_splits + 1) for k in range(n_splits): train_end = fold_size * (k + 1) val_start = train_end + embargo val_end = min(val_start + fold_size, n_samples) if val_start >= n_samples: break train_idx = list(range(0, train_end - label_span)) val_idx = list(range(val_start, val_end)) yield train_idx, val_idxembargo我设的是10,label_span是5,加起来就是15个交易日的隔离带。为什么训练集尾部还要再减掉label_span?因为训练集最后几天的标签本身就用到了验证集开头的价格,不剪掉就是泄漏。这个细节很多人会漏。
另外一个经验是:不要只用一次划分下结论。我跑的是滚动前进的验证,每折往前推一段,把五折的指标都记下来看波动。如果某一折的表现明显优于其他折,那大概率是那一折的行情恰好契合了模型偏好的风格,而不是模型真的稳。
4.3 样本不平衡的处理取舍
三分类做到28/41/31的分布其实已经不算严重失衡,但在某些时间窗口里,“强于常态”类会掉到20%以下,尤其是震荡市里几乎没有强势样本。我试过三种处理方式:类别权重、分层采样、focal loss。
类别权重是最省事的,直接把权重设成类别频率的倒数,效果是把少数类的召回率从0.42拉到了0.55,代价是多数类的精确率掉了几个点。分层采样在时序数据上要小心,只能对训练集做,验证集绝对不能动,否则评估结果失真。focal loss在这个任务上收益不明显,因为它的设计初衷是处理极难样本,而我的难点主要在于标签本身有噪声,不是样本难度分布不均。
最后我用的是类别权重加一个小的标签平滑,平滑系数设0.05,目的是防止模型对某一个类别过度自信,输出概率的分布会更平缓,后面做仓位映射的时候更好用。
4.4 评价指标别只盯着准确率
这是我踩得最深的一个坑。第一版模型验证集准确率58%,三类任务的随机基线大概33%,看起来提升不小,我挺高兴。但把它接到分层回测里,五档组合的收益完全不单调,最高档甚至跑不过中间档。
问题在于准确率衡量的是“分类对不对”,而我们真正关心的是“预测概率的排序对不对”。后来我换了一组指标:一是IC,就是预测分数和未来实际收益的秩相关系数;二是分层单调性,把预测分数分五档看每档的平均未来收益;三是多空组合的净值曲线和换手率。这三个指标一起看,才能真正判断模型有没有实用价值。
实测下来,我这个模型的三类准确率大约57%,但预测分数的RankIC均值在0.04上下,分层单调性在四折里成立、一折不成立。0.04的IC在日频或周五频上属于可用但不惊艳的水平,配合上成本控制,勉强能撑起一个中低频的选股逻辑。
5. 从回测到实盘:对齐环节的硬骨头
5.1 成本模型算清楚,很多“策略”当场就死了
我见过太多回测曲线漂亮得像艺术品的方案,一问成本假设是万分之一,这个数在真实交易里根本不可能。我的成本模型包含四块:佣金双边、卖出印花、滑点、冲击成本。佣金按行业常见的万分之二点五算双边,印花按卖出千分之一,滑点按成交价的千分之一,冲击成本跟我下单金额占当日成交额的比例挂钩,简单线性近似。
算一笔账就明白了。假设模型每次调仓换手30%,一年调仓50次,那么年换手率是15倍。按单边成本万分之三点五加滑点千分之一算,单边约万分之十三点五,双边万分之二十七,乘上15倍换手,年化成本大约4%。这意味着如果你的策略毛超额收益不到4%,扣完成本就是负的。我第一版模型扣成本之前的年化超额大概是6.2%,扣完只剩2%出头,波动还比基准大,风险调整后完全没有吸引力。
这个计算结果直接促使我做了两件事:一是降低调仓频率,把信号从每日更新改成每周更新,并且加入换手惩罚项,让模型在预测分数接近的时候倾向于维持原持仓;二是把预测分数做阈值化处理,只在分数突破某个区间时才动仓,避免频繁的小额调仓被成本吃光。
5.2 分层单调性检验:模型有没有学到东西的照妖镜
这个检验方法我强烈推荐给所有做类似任务的人。做法很简单:每个调仓日,把所有标的按模型输出的预测分数排序,分成五组,等权持有到下一个调仓日,统计每组的平均未来收益。
如果模型真的学到了东西,这五组的收益应该呈现单调递增或者递减的趋势。如果中间某组突然冒尖,或者首尾差不多,那说明模型只是在拟合噪声。我第一次跑出来的结果是五组收益分别是0.31%、0.28%、0.42%、0.29%、0.26%,第一组和第五组几乎没差别,中间那组最高——典型的没有信号。
改进之后第二次跑是0.18%、0.26%、0.34%、0.41%、0.52%,单调性出来了,首尾差0.34%每期。虽然幅度不大,但至少方向一致。这个检验比准确率直观得多,而且不需要跑完整的组合回测,实现成本很低。
5.3 回测和实盘的四个常见偏离点
第一个是幸存者偏差。我早期的样本池是拿当前还在交易的标的往回推,那些退市、被合并的标的根本没进样本。这会导致回测收益系统性偏高,尤其是在小市值段。修正方法是在每个历史截面上用当时的全样本,包括后来消失的那些。
第二个是涨跌停和停牌造成的不可成交。模型给出信号不代表你能按收盘价买到,尤其是信号强烈的标的往往当天已经封板。我在回测里加了成交限制:当日涨停的标的次日买入价按次日开盘价上浮一定比例模拟,连续涨停的直接放弃。
第三个是复权处理。前复权价格在每次除权后会整体变化,如果你用前复权数据算特征又用不复权价格算成本,两边对不上。我的做法是特征计算全部用后复权,成本核算单独用一个不复权的价格序列,两边通过复权因子对齐。
第四个是财报数据的时点对齐。前面提过一次,这里再强调:必须用实际披露日,不是报告期末。有些数据源给的字段是“报告期”,直接用会引入长达一两个月的未来信息,这种泄漏在回测里表现得特别美好,实盘会瞬间打回原形。
6. 常见问题速查与排查思路
下面这张表是我这三个月踩过的坑整理出来的,按现象、可能原因、排查动作三列组织。遇到问题的时候按表扫一遍,能省不少时间。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 验证集准确率异常高(>75%) | 特征或标签泄漏 | 逐特征检查计算窗口,确认是否用了未来数据;检查训练验证切分是否有重叠 |
| 模型几乎全预测同一类 | 类别严重不平衡或特征无区分度 | 打印各类样本量;做单特征与标签的交叉表;尝试加类别权重 |
| 双标Kappa低于0.7 | 标签定义模糊、边界样本过多 | 补充规范文档;设置边界缓冲带;对争议样本单独归档分析 |
| 分层回测不单调 | 模型学的是噪声,或指标与任务不匹配 | 换用IC和RankIC评估;检查预测分数分布是否集中在窄区间 |
| 回测好实盘差 | 成本低估、幸存者偏差、成交假设过于乐观 | 重算成本模型;修正样本池;加入涨跌停成交限制 |
| 不同时间窗结果差异巨大 | 模型对市场风格敏感、过拟合 | 做滚动验证,看各折指标方差;检查特征是否在某类行情下失效 |
| 训练损失下降但验证指标不涨 | 过拟合或标签噪声比例高 | 加正则、早停;抽样人工复核标签质量 |
除了表里的,我再补两个不那么常见但很折磨人的。
一个是标注疲劳导致的标签漂移。连续标两小时以上,人对“边界”的判断标准会无意识松动,前一百条标得严,后一百条标得松。我的对策是把标注任务拆成25分钟一段,每段结束强制休息,并且在每段开始时随机插入十条已经标过的“锚点样本”,如果发现锚点判断和之前不一致,说明状态不对,当天的活就停掉。这个办法听起来有点小题大做,但它把标签一致性从0.72拉到了0.85。
另一个是样本的时间分布不均。某些时间段行情剧烈,样本的特征分布和平时差异很大,如果训练集里这类样本占比过高,模型会偏向在那种行情下表现。我的做法是按季度统计样本量,对样本过密的时段做降采样,对样本稀疏的时段保留全部,让时间分布尽量平。
提醒:所有排查动作都要留日志。标注了什么、什么时候改的口径、哪一批样本被返工,这些记录在你三个月后回头看的时候价值极高。
7. 几条我认为最值钱的个人体会
最后不写什么总结了,就说几个我实打实体会到的东西。
第一,小样本试点这一步千万不能省。我如果一开始就闷头标两万条,等标完才发现标签口径有问题,那三个月的活就全废了。正确的顺序是标三百条、训一个小模型、看指标、发现不对劲、改口径、重新标。这个循环我走了四轮,每一轮都把规范补厚一页。
第二,标签规范要写成文档,而且要写得像给新人看的操作手册,不是给自己看的备忘录。我当时写规范的时候觉得“这个情况我肯定记得”,结果两周后再看自己标的样本,有一半的边界决策我都想不起来当时的理由。文档里至少要有:类别定义、每个类别的正例和反例、常见异常的判定阈值、争议处理流程。
第三,别迷信模型复杂度。我试过加层、试过换更花哨的结构,最终效果最好的还是一个中等复杂度的梯度提升模型配上干净的特征。数据质量对结果的边际贡献,远大于模型结构。两万条精心标注的数据,比二十万条粗标的数据有用得多。
第四,一定要给自己留一个“离线检查集”。我在整个项目开始时随机锁了一批样本,完全没参与任何训练和调参,只在最后用来做一次性的效果验证。这批样本的结论比任何交叉验证都可信,因为它从头到尾没见过模型。
第五,接受一个现实:这套东西做出来大概不会是一个能打的策略。我做这件事的定位从一开始就是技术验证和方法论沉淀,两万条数据教会我的东西主要在数据工程和评估体系上,而不是在收益曲线上。如果哪天你也要做类似的事情,我建议先想清楚你是想要一个能上线的策略,还是想要一套可复用的标注和验证流程。这两个目标的投入方式完全不同,前者要往精细化和成本控制上使劲,后者要往规范化和工具化上使劲。