Kaggle 经典比赛 Optiver Realized Volatility Prediction(股票市场波动率预测大赛) 高分方案解析

648 阅读7分钟
本篇文章为比赛第一名方案解析,该比赛涉及到时间序列预测-金融
介绍:参赛者将构建模型来预测不同行业数百只股票的短期波动,可以使用数亿行高度精细的财务数据设计模型,预测 10 分钟内的波动率。
网址:www.kaggle.com/competition…
  • 通过时间-id 顺序的反向工程进行时间序列交叉验证
  • 邻域聚合特征(从0.21提升到0.19)
  • 光明混合:LightGBM、MLP 和 MoA 的 1D-CNN

您也可以在这里看到我的笔记本:
www.kaggle.com/nyanpn/publ…

现在,我想介绍详细的解决方案。

时间-id 顺序逆向工程

竞赛数据中的价格已经归一化,但正如讨论中有人指出的,你可以使用“最小价格变动单位”来恢复归一化前的真实价格。
此外,通过使用 t-SNE 将时间 ID 与股票 ID 的价格矩阵压缩为一维,我能够以足够的精度恢复时间 ID 的顺序。

import glob

import numpy as np
import pandas as pd
from joblib import Parallel, delayed
from sklearn.manifold import TSNE
from sklearn.preprocessing import minmax_scale


def calc_price_from_tick(df):
    tick = sorted(np.diff(sorted(np.unique(df.values.flatten()))))[0]
    return 0.01 / tick


def calc_prices(r):
    df = pd.read_parquet(r.book_path,
                         columns=[
                             'time_id',
                             'ask_price1',
                             'ask_price2',
                             'bid_price1',
                             'bid_price2'
                         ])
    df = df.groupby('time_id') \
        .apply(calc_price_from_tick).to_frame('price').reset_index()
    df['stock_id'] = r.stock_id
    return df


def reconstruct_time_id_order():
    paths = glob.glob('/kaggle/input/optiver-realized-volatility-prediction/book_train.parquet/**/*.parquet')

    df_files = pd.DataFrame(
        {'book_path': paths}) \
        .eval('stock_id = book_path.str.extract("stock_id=(\d+)").astype("int")',
              engine='python')

    # build price matrix using tick-size
    df_prices = pd.concat(
        Parallel(n_jobs=4)(
            delayed(calc_prices)(r) for _, r in df_files.iterrows()
        )
    )
    df_prices = df_prices.pivot('time_id', 'stock_id', 'price')

    # t-SNE to recovering time-id order
    clf = TSNE(
        n_components=1,
        perplexity=400,
        random_state=0,
        n_iter=2000
    )
    compressed = clf.fit_transform(
        pd.DataFrame(minmax_scale(df_prices.fillna(df_prices.mean())))
    )

    order = np.argsort(compressed[:, 0])
    ordered = df_prices.reindex(order).reset_index(drop=True)

    # correct direction of time-id order using known stock (id61 = AMZN)
    if ordered[61].iloc[0] > ordered[61].iloc[-1]:
        ordered = ordered.reindex(ordered.index[::-1])\
            .reset_index(drop=True)

    return ordered[['time_id']]

训练数据的时序 ID 顺序恢复的正确性可以通过与实际市场数据进行比较来轻松验证。通过这种方式,我们知道训练数据是 2020/1/1 至 2021/3/31 期间的数据。


左:t-SNE 恢复的股票价格 右:通过 yfinance 获取的实际股票价格(2020-01-01~2021-03-31)

另一方面,对于测试数据,我没有直接在特征和模型中使用时间-id 信息,因为无法保证 t-SNE 能够正确排序时间-id 的顺序。然而,即使不能直接用于特征,时间-id 的顺序也可以以各种方式使用。

  • 时间序列交叉验证 。现在我们已经知道了时间戳的正确顺序,我们可以像处理正常时间序列数据一样构建验证集。
  • 协变量变化的检测 。使用如对抗验证等方法,我们可以找到随时间变化的特征。
  • 数据排除 。我没有这样做,因为对我来说不起作用,但我们可以从数据中排除特定市场事件期间,例如 2020 年初的股市崩盘。

时间序列交叉验证尤为重要。我使用了4折时间序列交叉验证,其中10%的数据用于测试。
用于每个折的验证数据。这使得我能够在整个比赛中获得足够好的(尽管不是完美的)CV 和 LB 之间的相关性。

数据是如何生成的?

顺便说一下,在 2020 年 1 月 1 日至 2021 年 3 月 31 日期间,股市共开放了 443 天,该期间共有 3833 个时间 ID,这意味着每天大约记录了 8.6 个时间 ID。

如果 Optiver(由于某些原因,可能难以预测)排除了股市开放时间的第一小时和最后一小时,那么 10:30 至 15:00 的时间段可以用于竞赛数据。如果他们把这个时间段分成 30 分钟间隔,每天最多可以记录 9 个时间 ID。

实际上,考虑到熔断器和提前收盘日的情况,他们每天可用于数据的 time-id 数量应该略少于每天 9 个。这只是个猜测,但我认为数据就是这样生成的。

特征工程

在这场预测前 10 分钟数据中下一个 10 分钟实现波动率(RV)的比赛中,显然最重要的特征将是前 10 分钟的 RV。

然而,如果我们考虑数据的生成方式,在目标计算周期结束后仅 10 分钟,下一个时间戳 ID 的训练数据就开始了。这意味着如果能够完全恢复时间戳 ID 的顺序,我们可以期待下一个时间戳 ID 的 RV 成为一个非常强大的特征。

如果进一步推广,我们可以通过使用不仅下一个时间-id,还包括某些距离度量中“相似”的时间-id 的信息来提高预测精度。例如,当市场具有相似的价格、波动性和交易量时,同一股票的 RV 对于预测某个时间-id 的 RV 是有用的。

因此,我使用了具有各种距离度量的 NearestNeighbor 来找到相似的 N 个时间-id,并计算了特征的平均值,如 RV 和股票规模(N=2,3,5,10,20,40)。

除了时间-id 之外,我还计算了相似股票-id 之间的聚合。此外,通过结合这些想法,我计算了诸如“在 5 个最近时间-id 中具有相似波动性的 20 个相似股票的平均 tau”等特征。

target_feature = 'book.log_return1.realized_volatility'
n_max = 40

# make neighbors
pivot = df.pivot('time_id', 'stock_id', 'price')
pivot = pivot.fillna(pivot.mean())
pivot = pd.DataFrame(minmax_scale(pivot))

nn = NearestNeighbors(n_neighbors=n_max, p=1)
nn.fit(pivot)
neighbors = nn.kneighbors(pivot)

# aggregate

def make_nn_feature(df, neighbors, f_col, n=5, agg=np.mean, postfix=''):
    pivot_aggs = pd.DataFrame(agg(neighbors[1:n,:,:], axis=0), 
                              columns=feature_pivot.columns, 
                              index=feature_pivot.index)
    dst = pivot_aggs.unstack().reset_index()
    dst.columns = ['stock_id', 'time_id', f'{f_col}_cluster{n}{postfix}_{agg.__name__}']
    return dst

feature_pivot = df.pivot('time_id', 'stock_id', target_feature)
feature_pivot = feature_pivot.fillna(feature_pivot.mean())

neighbor_features = np.zeros((n_max, *feature_pivot.shape))

for i in range(n):
    neighbor_features[i, :, :] += feature_pivot.values[neighbors[:, i], :]

for n in [2, 3, 5, 10, 20, 40]:
    dst = make_nn_feature(df, neighbors, feature_pivot, n)
    df = pd.merge(df, dst, on=['stock_id', 'time_id'], how='left')

我总共创建了大约 600 个特征,其中 360 个是最近邻特征,我的分数提升主要基于这些 NN 特征。

我认为在真实的 Optiver 场景中我们不能使用未来信息,但使用最近邻来聚合附近特征的想法可能可以在实际模型中使用。

特征处理

现在我们知道了时间-id 的顺序,我们可以检测随时间变化的特征。通过进行对抗验证,我发现从 trade.order_count 和 book.total_volume 聚集的特征随时间变化非常显著。因此,我不再使用它们的原始特征,而是将这些特征转换为同一时间-id 内的排名。

我也对具有较大偏斜的特征应用了 np.log1p,因为它们可能在第二阶段出现大异常值时降低预测效果。

这些对 LB 分数的提升很小,但我相信它们有助于降低在私人 LB 中发生震荡的风险。

建模

我使用了三个简单的混合模型进行预测:LightGBM、1D-CNN 和 MLP。1D-CNN 是 MoA 第二名解决方案中使用的架构的简化版本。(这个 CNN 在最近的 MLB 和 Optiver 比赛中都表现得非常出色。)


1D-CNN 架构

我没有使用预训练模型,因为我想使用测试数据进行特征计算,因此所有模型训练都在单个笔记本内完成。我的神经网络模型在训练中有点不稳定,所以我训练了 10 个不同种子的模型,并从具有最佳验证分数的前 5 个模型中挑选出最好的用于预测(哎呀,我忘记使用 seed=3047 了 :))。

什么不起作用

  • 许多领域特定特征,如贝塔系数
  • TabNet(不错,但训练时间太长)
  • 在残差上训练神经网络(目标 - book.log_return.realized_volatility)
  • 维度降维特征

我所认为可能可行,但没时间去做

  • 300 秒模型(将训练数据分为前半部分和后半部分,创建一个从第一半预测第二半 RV 的模型,并将其用作元特征或数据增强)
  • 集成 LSTMs 和 RNNs 而不创建特征
  • 自动编码器