跳转至

第 25 课 把一段行情整理成模型能读懂的样本

这一课不急着选择模型。我们先跟随一行数据走完它的一生,弄清楚模型在什么时间看见什么,又要等到什么时候才能知道答案。

适合谁准备制作第一个机器学习数据集的读者
前置知识第 24 课的特征、标签和时间切分
建议用时150 分钟,可以分两次完成
本课成果一张经得起时间检查的样本表

从 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_{i,T}=\frac{Open_{i,T+6}}{Open_{i,T+1}}-1 \]

公式左边的 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 一只股票的末尾可能接到另一只开头 先按股票代码分组,再在组内移动
用全部年份的均值做标准化 未来市场分布提前进入过去 只在训练窗口计算参数
把缺失全部填成零 新股、停牌和无意义指标被混成一种状态 先记录原因,再分情况处理
在全历史挑出最好特征 测试答案参加了筛选 每个滚动训练窗口重新筛选

一分钟自测

假设周五收盘后产生信号,下周一开盘买入,再下周一开盘卖出。请先不看答案,指出特征最晚可以使用到什么时候,标签从什么时候开始,以及这行样本最早要到什么时候才能进入下一次训练。

查看答案 特征最晚使用周五收盘时已经完整的信息,标签从下周一实际买入的开盘价开始,到再下周一卖出后才完整。只有卖出价格已经发生以后,这行样本才可以参加下一次训练。

本课术语

词语 现在应该怎样理解
特征 作出预测时已经知道,并且希望模型利用的证据
标签 交易以后才发生,用来批改历史预测的答案
样本 某一日期、某一资产的一组特征和一个标签
面板数据 同时包含多个日期与多个资产的二维数据
时间对齐 按真实顺序连接信息形成、预测、成交和标签完成
标准化 把不同尺度的数值转换到更容易比较的范围
信息系数 特征排序与未来收益排序同向程度的相关指标
向前滚动验证 用较早历史训练,在紧随其后的未来检验,并不断向前移动

课后练习

  1. 基础理解 二十日均线属于特征还是标签?
    提示先判断它使用的是预测以前还是预测以后的数据。
答案如果二十日均线只使用预测时点以前的价格,它就是特征。若错误地把预测以后价格放入窗口,它就不再是合法特征。
  1. 时间计算 周五收盘计算特征,周一开盘成交,为什么五日标签不能从周五收盘开始?
    提示比较信号形成时点和策略真正拥有仓位的时点。
答案周五收盘后信号才完整,策略无法倒回周五收盘成交。从周五起算会多拿一段实际无法获得的收益。
  1. 代码排错 多只股票的数据直接使用 open_price.shift(-1),可能产生什么错误?
    提示观察一只股票最后一行的下一行属于谁。
答案当前股票最后一个日期可能接到下一只股票的第一个日期。应按股票代码分组,再在每个组内移动。
  1. 动手修改 把本课标签改成下一个开盘买入、第三个开盘卖出,写出手算规则和代码中的两个移动步数。
    提示买入价仍来自 `T+1`,卖出价改到 `T+3`。
答案手算规则为 `T+3 开盘价 ÷ T+1 开盘价 - 1`。代码分别使用 `shift(-1)` 和 `shift(-3)`。
  1. 开放思考 同一个缺失值为什么不能总是填成零?
    评价要点回答应区分历史不足、停牌、财务指标无意义和数据错误,并说明这些原因代表的经济状态不同。还应提到填补规则只能在训练数据中确定。

下一课将使用这张已经对齐的样本表,从一条可以手算的直线开始理解预测模型。继续阅读第 26 课 线性与逻辑回归