第 25 课 把一段行情整理成模型能读懂的样本¶
这一课不急着选择模型。我们先跟随一行数据走完它的一生,弄清楚模型在什么时间看见什么,又要等到什么时候才能知道答案。
从 1 月 10 日收盘后的选择说起¶
假设你在研究两只虚构股票,甲公司和乙公司。1 月 10 日收盘以后,你想根据最近一段行情判断哪只股票更值得在下一个交易日买入。此时你能查到两只股票过去二十个交易日的收盘价、成交量和波动,却还不知道接下来会涨还是会跌。
为了让模型学习,你需要把过去许多个交易日都整理成类似的“题目和答案”。题目由当时已经知道的信息组成,答案由交易以后真实发生的结果组成。机器学习中的三个常用词由此出现。特征是作出预测时已经知道的线索,标签是后来发生并用来批改预测的答案,样本则是某个日期、某只股票的一组特征与一个标签。
这三个词看起来简单,时间最容易被写错。1 月 10 日收盘价在当天收盘后才能确认,1 月 11 日开盘价要到第二天才知道,未来五个交易日的结果还要继续等待。只要把后来才知道的信息放进了题目,模型就在练习时偷看了答案。回测会因此显得异常漂亮,真实交易却无法重现。

图中的每个方框都回答一个时间问题。先确定信息何时完整,再确定最早何时成交,最后确定答案何时才可以交给模型学习。
特征要同时说明指标和可得时间¶
“二十日涨幅”经常被叫作一个特征,但这个名称还不够完整。研究者还应说明它使用哪二十个交易日、采用什么价格、遇到停牌怎样处理,以及它在一天中的哪个时刻才能算完。只有这些条件都写清楚,别人才能判断这条信息在预测时是否真的存在。
继续使用 1 月 10 日的例子。若甲公司当日收盘价是 108 元,二十个交易日前的收盘价是 100 元,那么二十日收益率是 (108 ÷ 100) - 1,结果为 8%。这项特征表达的是最近一段时间的价格方向。它不能证明下一周还会上涨,却可以作为模型判断的一条证据。
同一段行情还可以产生波动特征。假如甲公司的价格每天变化很小,乙公司则经常大涨大跌,即使两只股票的二十日收益率相同,它们承受的风险也不同。成交额和换手率又提供了另一类信息,它们帮助研究者判断信号是否可能以合理成本成交。财务报表、行业数据和市场指数也可以成为特征,不过它们各自有不同的公布时间,不能只看表格里写着哪个报告期。
下表把常见来源与它们试图回答的问题放在一起。初学者不必一次收集很多字段,先从两到四个能说清经济含义的特征开始,更容易发现错误。
| 特征来源 | 它试图回答的问题 | 什么时候才能知道 | 最先检查什么 |
|---|---|---|---|
| 过去收益和均线距离 | 价格最近偏强还是偏弱 | 当日收盘后 | 窗口是否只向过去看 |
| 波动率和振幅 | 价格变化是否稳定 | 当日行情结束后 | 极端值是否来自停牌或复牌 |
| 成交额和换手率 | 交易是否活跃 | 当日成交数据完整后 | 单位、缺失和异常成交 |
| 财务与估值 | 公司经营和价格是否匹配 | 公告真正发布以后 | 不能把报告期当成发布日期 |
| 行业与市场环境 | 个股变化是否只是跟随大盘 | 对应市场数据公布后 | 时区、节假日与成分变化 |
先手算,再让程序重复¶
程序中的特征公式只是把刚才的手算重复到许多日期。下面三行代码分别计算五日收益、价格与二十日均线的距离,以及今天开盘相对昨天收盘的跳空。阅读时先看等号左侧,它是新列的名字;再看等号右侧,它说明新列使用了哪些旧数据。
features["return_5"] = close / close.shift(5) - 1
features["distance_ma20"] = close / close.rolling(20).mean() - 1
features["overnight_gap"] = open_price / close.shift(1) - 1
shift(5) 把五个交易日前的收盘价移动到当前行旁边,因此当前收盘价可以和过去价格相除。rolling(20).mean() 只在每一行回看最近二十行并计算平均值。shift(1) 则把昨天收盘价放到今天这一行。三种写法都只应该引用当前时刻以前的数据;如果括号里出现负数,通常意味着程序正在把未来值移到当前行,需要立刻确认它是在制作标签,还是误把未来放进了特征。
标签要从真实成交规则倒推¶
现在才轮到未来五日收益。研究者要根据交易计划定义这个答案,机器学习不会自动规定目标。假设模型在 1 月 10 日收盘后完成计算,市场已经收盘,策略不可能按当天收盘价重新成交。为了让研究更接近现实,我们约定在下一个交易日开盘买入,并在第六个交易日开盘卖出。
先用具体价格手算。若 1 月 11 日的买入价为 100 元,1 月 18 日的卖出价为 105 元,那么这次持有的收益率是 (105 ÷ 100) - 1,结果为 0.05,也就是 5%。这个 5% 才是 1 月 10 日这行样本的答案。1 月 10 日收盘到 1 月 11 日开盘之间的变化没有算进去,因为信号形成以后,策略尚未成交。
把同一件事写成公式之前,我们先给符号逐个取名。字母 i 表示第几只股票,字母 T 表示计算特征的日期,Open 表示某个交易日的开盘价。T+1 是信号之后第一个可成交的开盘时点,T+6 是计划卖出的开盘时点。于是标签可以写成
公式左边的 y 就是标签。右边先用卖出价除以买入价,再减去本金所占的 1。它与刚才 105 元除以 100 元的手算完全相同,只是符号允许这条规则重复用于任何股票和任何日期。若你的策略改成下一个收盘买入,或者持有十个交易日,公式中的时点也必须跟着改变。
代码为什么需要分组和移动¶
理解了交易规则以后,代码才有意义。下面假设 open_price 是一列按日期排列的开盘价,并且数据中同时包含多只股票。groupby("ticker") 要求程序先按股票代码分组,再分别移动每只股票自己的价格;shift(-1) 把下一行开盘价放到当前日期,shift(-6) 则把第六行以后的开盘价放到当前日期。
entry_price = open_price.groupby("ticker").shift(-1)
exit_price = open_price.groupby("ticker").shift(-6)
forward_return = exit_price / entry_price - 1
第一行得到计划买入价,第二行得到计划卖出价,第三行照着公式计算收益。负号在这里没有泄漏到特征中,因为这三行代码明确是在制作训练阶段使用的未来答案。若省略分组,一只股票末尾的行可能接到下一只股票开头,程序不会主动提醒,结果却已经失去意义。
代码算完以后不要急着处理全部数据。先挑一只股票和一个日期,把原始开盘价、程序得到的买入价、卖出价与手算结果并排检查。只要这一个小例子对不上,后面的模型、回测和图表都不值得继续。
一行样本为什么要等六天才能用于训练¶
1 月 10 日的特征在当天收盘后已经完整,但它的标签要到计划卖出的 1 月 18 日开盘以后才完整。在 1 月 15 日重新训练模型时,研究者虽然已经知道部分持有期行情,却仍然不知道完整答案,因此这行样本不能进入训练集。
这种等待常被忽略,是因为历史数据表已经把过去和未来整齐放在同一个文件里。电脑可以瞬间读到 1 月 18 日,现实中的 1 月 10 日却必须一天一天等待。时间安全的研究会为每行样本至少保留三个时点。
| 时间字段 | 在例子中的含义 | 它约束什么 |
|---|---|---|
feature_time |
1 月 10 日收盘后 | 特征最晚只能使用这里以前的信息 |
trade_time |
1 月 11 日开盘 | 回测最早可以在这里成交 |
label_end_time |
1 月 18 日开盘 | 到这里以后,该行才可以进入下一次训练 |
flowchart LR
accTitle: 一行样本的时间边界
accDescr: 1 月 10 日收盘形成特征,1 月 11 日开盘实际买入,1 月 18 日开盘卖出后标签才完整
a[1 月 10 日收盘<br/>特征计算完成] --> b[1 月 11 日开盘<br/>策略实际买入]
b --> c[中间五个交易日<br/>答案仍在形成]
c --> d[1 月 18 日开盘<br/>卖出并得到完整标签]
d --> e[这行样本现在<br/>可以参加下一轮训练]
若每天都制作一个持有五日的标签,相邻样本会共享大部分行情。1 月 10 日和 1 月 11 日的两个标签可能有四天重叠,所以表格里虽然有两行,实际新增的信息远少于两次完全独立的观察。验证时需要在训练区和测试区之间留出足够的等待范围,避免同一段未来行情同时影响两边。
从一只股票的时间序列走到多只股票的面板¶
只有甲公司时,一行通常由一个日期识别,这叫时间序列数据。加入乙公司以后,同一个 1 月 10 日会出现两行,单凭日期已经无法区分它们。此时需要用“日期和股票代码”共同识别一行,这种同时沿时间和资产两个方向展开的表叫面板数据。
下面的表格仍然只是在记录题目和答案。1 月 10 日的甲、乙两行属于同一个比较时点,momentum_20 表示过去二十日收益,volatility_20 表示过去二十日波动,forward_return 则要等各自的持有期结束后才知道。
| 日期 | 股票 | 二十日收益 | 二十日波动 | 未来持有收益 |
|---|---|---|---|---|
| 1 月 10 日 | 甲 | 8% | 22% | 5% |
| 1 月 10 日 | 乙 | -3% | 18% | -1% |
| 1 月 11 日 | 甲 | 6% | 21% | 2% |
研究“甲公司相对自己的过去是否偏强”时,应在每只股票内部沿时间计算。研究“甲公司在 1 月 10 日是否比同日其他股票更强”时,应在同一天的股票之间比较。这两个问题分别叫时间序列比较和横截面比较。它们使用的公式可能相似,含义却不同。
面板每天包含的股票数量也不一定相同。新股上市、公司退市、临时停牌和股票池调整都会改变可选范围。程序不能假定每天都有相同股票,更不能用今天仍然存在的公司名单回填十年前,否则已经退市的失败公司会从历史中消失。
标准化为什么也必须遵守时间¶
二十日收益可能在正负 0.2 之间,成交额却可能以亿元计。许多模型在数值尺度相近时更容易训练,所以研究者常把特征转换成“距离平均水平多少个标准差”,这种处理叫标准化。
若要判断甲公司今天相对自己过去六十天是否异常,应只使用甲公司截至今天的历史计算滚动平均值和标准差。若要比较同一天的多只股票,则只使用当天候选股票计算截面平均值和标准差。前者回答“与自己过去相比”,后者回答“与今天的同伴相比”。
下面的代码把这两个方向写出来。第一段在单只资产内部滚动六十日,第二段在每个日期内部计算同日均值和标准差。代码中的 transform 会把每个分组算出的结果放回原来的每一行,因此可以继续相减和相除。
time_mean = x.rolling(60).mean()
time_std = x.rolling(60).std()
time_z = (x - time_mean) / time_std
cross_mean = panel.groupby("date")["x"].transform("mean")
cross_std = panel.groupby("date")["x"].transform("std")
cross_z = (panel["x"] - cross_mean) / cross_std
最危险的写法是先用全部年份计算一个总平均值,再切分训练和测试。那个总平均值包含测试期以后才出现的行情,相当于让过去提前知道未来市场通常有多剧烈。缺失值填补、极端值缩尾和特征筛选也会从数据中估计规则,因此都只能在训练时间段中学习,再把已经确定的规则用于后面的验证和测试。
三种标签回答三个不同问题¶
连续收益标签保留了涨跌幅度,适合让模型预测一个数值。方向标签把正收益记为 1、负收益或零记为 0,适合回答“会上涨吗”。排名标签则在同一天比较所有股票的未来收益,适合回答“谁可能比同伴更强”。标签形式改变以后,模型学习的问题、评估方法和组合构造都会一起改变。
假设同一天三只股票未来收益分别为 5%、1% 和负 2%。连续标签保留这三个数字,方向标签变成 1、1、0,排名标签则把它们排成高、中、低。方向标签看不出前两只股票相差四个百分点,排名标签也不直接说明第一名是否真的赚钱。选择标签时应从最终决策倒推,不要因为某种代码写起来方便就选择它。
direction_label = (forward_return > 0).astype(int)
rank_label = forward_return.groupby("date").rank(pct=True)
第一行先判断收益是否大于零,判断结果再转换成 1 和 0。第二行在每个日期内部排名,pct=True 把名次转换到 0 至 1 附近,越接近 1 表示在当天截面里越靠前。这两行都建立在前面已经正确得到的 forward_return 之上,所以检查顺序仍然是先核对成交时点,再核对标签变换。
缺失值不是空白,它往往带着原因¶
新上市股票没有二十日历史,因此算不出二十日收益。亏损公司可能没有有意义的市盈率,停牌日可能没有正常成交量,较早年份也可能缺少后来才开始采集的字段。如果把所有缺失统一填成零,模型会误以为这些不同情况表达同一个经济状态。
处理缺失以前,应先记录原因。历史不足的样本可以等窗口完整以后再使用,财务指标无意义时可以增加一个缺失标记,偶发数据错误则应回到来源修正。确实需要用中位数填补时,中位数只能从当时的训练数据计算。验证期和测试期沿用这一个结果,模拟的才是研究者带着过去经验处理新数据的过程。
极端值也要先核对来源。一次真实的价格跳跃可能包含重要信息,一条单位错误的数据则没有研究价值。缩尾处理会根据分位数设置上下界,这些分位数同样只能来自训练窗口或当日可见截面,不能提前浏览全部历史以后再决定。
特征筛选从可解释的小集合开始¶
一百个特征并不等于一百条独立信息。五日、十日和二十日收益彼此可能高度相似,多个估值指标也可能重复描述同一件事。特征越多,模型越有机会碰巧找到只在历史里成立的组合。
初学者可以先为每个候选特征写一张小卡,说明它想表达的市场机制、最早可见时间、缺失原因,以及与已有特征是否重复。随后只在训练时间段中比较它与未来标签的关系。这里会遇到一个常见指标,叫信息系数,英文是 Information Coefficient,通常简称 IC。它衡量同一天的特征排序与后来收益排序是否大致同向。IC 为正表示高特征值往往对应较高的未来收益,接近零表示没有稳定排序关系,负值表示关系方向相反。IC 只是相关性的检查,不证明这个特征导致了收益。
如果研究跨越多年,不能只算一个全时期 IC。更有意义的做法是按日期分别计算,再观察它在不同年份和市场环境中是否稳定。选择特征时也只能看训练段。测试段是最后一次考试,不能因为某个特征在测试段表现好,就把它补回模型后仍把同一段称为样本外结果。
第 17 课已经用三轮日期介绍过滚动前推验证。制作机器学习样本时,同一条时间纪律仍然有效。每轮只在当时的训练窗口中计算填补值、标准化参数和特征筛选规则,再把这些已经确定的处理用于紧随其后的未来区间。测试期与后面的独立检查期都不能回头参与选特征。
安全的数据集按什么顺序制作¶
到这里可以把整课串起来。研究者先保存带有真实可得时间的原始数据,再用只向过去看的窗口计算特征。接着根据明确的成交计划制作向未来看的标签,并保存特征时点、成交时点和标签结束时点。数据按时间切成训练、验证和测试以后,填补、标准化与筛选才在训练区中拟合。
flowchart TB
accTitle: 一张机器学习样本表的制作顺序
accDescr: 原始数据先核对可得时间,特征只回看过去,标签按照真实成交规则向未来计算,切分以后所有预处理只在训练段学习
raw[原始行情和公告<br/>保留真实可得时间] --> check[核对日期、股票代码<br/>单位、缺失和重复]
check --> feature[制作特征<br/>窗口只向过去看]
check --> label[制作标签<br/>从实际成交时点开始]
feature --> align[按日期与股票对齐<br/>保存三个时间字段]
label --> align
align --> split[按时间切成<br/>训练、验证和测试]
split --> fit[只在训练段学习<br/>填补、标准化和筛选规则]
fit --> ready[得到可以交给模型的样本表]
自动检查可以替研究者守住几条底线。增加未来数据以后,过去已经算好的特征不应改变;同一日期和股票不应重复;每个训练样本的标签结束时间必须早于下一次预测;随手抽取的买入价、卖出价和未来收益还应与人工计算一致。
下面的代码检查第一条底线。full 使用全部行情制作特征,prefix 只使用前五百行制作特征。如果特征真的只向过去看,两份结果的前五百行应完全一致。若比较失败,通常说明某个步骤使用了全样本统计量,或者错误地引用了未来。
full = build_features(all_data)
prefix = build_features(all_data.iloc[:500])
pd.testing.assert_frame_equal(
full.iloc[:500],
prefix,
check_names=False,
)
容易弄错的地方¶
| 看起来方便的做法 | 为什么会出错 | 更可靠的处理 |
|---|---|---|
| 用信号当天收盘价开始算标签 | 策略在信号完成时已经来不及成交 | 从下一个真实可成交时点开始 |
所有股票混在一起使用 shift |
一只股票的末尾可能接到另一只开头 | 先按股票代码分组,再在组内移动 |
| 用全部年份的均值做标准化 | 未来市场分布提前进入过去 | 只在训练窗口计算参数 |
| 把缺失全部填成零 | 新股、停牌和无意义指标被混成一种状态 | 先记录原因,再分情况处理 |
| 在全历史挑出最好特征 | 测试答案参加了筛选 | 每个滚动训练窗口重新筛选 |
一分钟自测¶
假设周五收盘后产生信号,下周一开盘买入,再下周一开盘卖出。请先不看答案,指出特征最晚可以使用到什么时候,标签从什么时候开始,以及这行样本最早要到什么时候才能进入下一次训练。
查看答案
特征最晚使用周五收盘时已经完整的信息,标签从下周一实际买入的开盘价开始,到再下周一卖出后才完整。只有卖出价格已经发生以后,这行样本才可以参加下一次训练。本课术语¶
| 词语 | 现在应该怎样理解 |
|---|---|
| 特征 | 作出预测时已经知道,并且希望模型利用的证据 |
| 标签 | 交易以后才发生,用来批改历史预测的答案 |
| 样本 | 某一日期、某一资产的一组特征和一个标签 |
| 面板数据 | 同时包含多个日期与多个资产的二维数据 |
| 时间对齐 | 按真实顺序连接信息形成、预测、成交和标签完成 |
| 标准化 | 把不同尺度的数值转换到更容易比较的范围 |
| 信息系数 | 特征排序与未来收益排序同向程度的相关指标 |
| 向前滚动验证 | 用较早历史训练,在紧随其后的未来检验,并不断向前移动 |
课后练习¶
- 基础理解 二十日均线属于特征还是标签?
提示
先判断它使用的是预测以前还是预测以后的数据。
答案
如果二十日均线只使用预测时点以前的价格,它就是特征。若错误地把预测以后价格放入窗口,它就不再是合法特征。- 时间计算 周五收盘计算特征,周一开盘成交,为什么五日标签不能从周五收盘开始?
提示
比较信号形成时点和策略真正拥有仓位的时点。
答案
周五收盘后信号才完整,策略无法倒回周五收盘成交。从周五起算会多拿一段实际无法获得的收益。- 代码排错 多只股票的数据直接使用
open_price.shift(-1),可能产生什么错误?
提示
观察一只股票最后一行的下一行属于谁。
答案
当前股票最后一个日期可能接到下一只股票的第一个日期。应按股票代码分组,再在每个组内移动。- 动手修改 把本课标签改成下一个开盘买入、第三个开盘卖出,写出手算规则和代码中的两个移动步数。
提示
买入价仍来自 `T+1`,卖出价改到 `T+3`。
答案
手算规则为 `T+3 开盘价 ÷ T+1 开盘价 - 1`。代码分别使用 `shift(-1)` 和 `shift(-3)`。- 开放思考 同一个缺失值为什么不能总是填成零?
评价要点
回答应区分历史不足、停牌、财务指标无意义和数据错误,并说明这些原因代表的经济状态不同。还应提到填补规则只能在训练数据中确定。
下一课将使用这张已经对齐的样本表,从一条可以手算的直线开始理解预测模型。继续阅读第 26 课 线性与逻辑回归。