JPX Tokyo Stock Exchange Prediction (2) - Time Series Engineering
Jie Cao 炼丹师

二、时序特征工程

1 多维时序特征分析Multi-Dimensional Temporal Feature Analysis

时序特征分析本身是一个很大的话题,在本次股票数据案例中,我们将从趋势分析特征、市场动态特征两个方面来进行时序特征分析。

股票市场的复杂和动态特性使得时序特征分析成为一个强大的工具。我们可以从历史数据中挖掘出时间序列数据的复杂模式,从而提取有价值的信息,助力构建更精准的预测模型。例如,如果分析揭示了一只股票在每个季度初都会上涨,投资者就可以利用这一信息在适当时期购买股票来获利。

时序特征分析不仅可以用于股票预测,还广泛应用于销售预测、天气预测、能源需求预测等许多领域。借助这一方法,我们能够从不断变化的时间序列数据中捕获核心趋势和模式,为建模和决策提供有力支持。

1.1 在线导入自定义库

1
2
3
4
5
6
7
8
9
10
11
12
13
import pandas as pd
from lightgbm import LGBMRegressor
from sklearn.linear_model import LinearRegression
import matplotlib.pyplot as plt
import jpx_tokyo_market_prediction
import numpy as np
import datetime
import os
from decimal import ROUND_HALF_UP, Decimal
from sklearn.preprocessing import LabelEncoder,MinMaxScaler

# typing 模块提供了一些类型,用于类型提示
from typing import Union,List,Tuple,Iterable
1
2
3
4
# 例如我们想要定义一个函数
# typing模块的类型可以用来为函数参数和返回值添加类型提示,也可以用来为变量添加类型提示
def my_function(a: int, b: Union[int, float]) -> List[int]:
...

kaggle中的notebook如何导入自己编写的特征衍生函数?

在机器学习的项目中,特征衍生是至关重要的一步。有时,我们可能会编写一些自定义的特征衍生函数。在本地环境中,导入这些函数相对简单,只需将.ipy文件和.py文件放在同一目录下即可。但在Kaggle的notebook环境中,事情就复杂了一些。下面将介绍如何在Kaggle上导入自己编写的特征衍生函数。

  • 方法一:使用copyfile
  1. 上传文件:将自己编写的.py文件(例如名为features_creation.py)上传到Kaggle的input文件夹中。

  2. 导入copyfile模块:

1
from shutil import copyfile
  1. 复制文件到工作目录
1
copyfile(src = "/kaggle/input/features-creation-jpx/features_creation.py", dst = "../working/features_creation.py")

其中,src表示源文件的路径,dst表示目标路径,文件将被复制到此位置。

源文件路径需要复制自己数据集中的的.py文件

目标路径统一为…/working/ 也就是说你只需要改最后的文件名

  1. 导入模块:
1
2
# 现在可以像导入其他模块一样导入自己编写的 py 文件了
import features_creation
  • 方法二:直接编写代码

如果觉得上传过程较为繁琐,也可以直接在notebook中编写函数。这种方法适合于使用的函数不多的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def timeseries_feature(timeseris):
""":params timeseris: 必须是时间戳格式才能调用dt方法"""
features_new = pd.DataFrame([])
cols_name = timeseris.name

# 提取年份、月份和日期
features_new[cols_name + '_year'] = timeseris.dt.year
features_new[cols_name + '_month'] = timeseris.dt.month
features_new[cols_name + '_day'] = timeseris.dt.day

# 提取自然周期
features_new[cols_name + '_quarter'] = timeseris.dt.quarter
features_new[cols_name + '_weekofyear'] = timeseris.dt.weekofyear
features_new[cols_name + '_dayofweek'] = timeseris.dt.dayofweek
features_new[cols_name + '_weekend'] = (features_new[cols_name + '_dayofweek'] >= 5).astype(int)

return features_new
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 定义baseline中使用的adjust_price函数
def adjust_price(price):
"""
参数:
price (pd.DataFrame) : 包含股票价格信息的 pd.DataFrame。

返回:
price DataFrame (pd.DataFrame): 含有新生成的 AdjustedClose 的股票价格DataFrame

函数功能:
该函数将输入的原始股价数据进行处理,生成带有调整后收盘价(AdjustedClose)的新DataFrame。
AdjustedClose的计算方式为原收盘价与调整因子(AdjustmentFactor)的累计乘积,结果保留一位小数。
若计算后的AdjustedClose为0,则替换为空值(np.nan),并对此列数据进行向前填充,以保持数据完整性。
"""
# 将 Date 列转换为 datetime 格式
# 因为我们在前面查看数据类型的时候发现Date为object类型
price.loc[: ,"Date"] = pd.to_datetime(price.loc[: ,"Date"], format="%Y-%m-%d")

def generate_adjusted_close(df):
"""
参数:
df (pd.DataFrame) : 单个 SecuritiesCode 的股票价格
返回:
df (pd.DataFrame): 单个 SecuritiesCode 的带有 AdjustedClose 的股票价格
"""
# 排序数据以生成 CumulativeAdjustmentFactor
df = df.sort_values("Date", ascending=False)
# 生成 CumulativeAdjustmentFactor
df.loc[:, "CumulativeAdjustmentFactor"] = df["AdjustmentFactor"].cumprod()#累乘
# 生成 AdjustedClose
df.loc[:, "AdjustedClose"] = (
df["CumulativeAdjustmentFactor"] * df["Close"]
).map(lambda x: float(
Decimal(str(x)).quantize(Decimal('0.1'), rounding=ROUND_HALF_UP)
))
# 反转顺序
df = df.sort_values("Date")
# 填充 AdjustedClose,将 0 替换为 np.nan
df.loc[df["AdjustedClose"] == 0, "AdjustedClose"] = np.nan
# 向前填充 AdjustedClose
df.loc[:, "AdjustedClose"] = df.loc[:, "AdjustedClose"].ffill()
return df

# 生成 AdjustedClose
price = price.sort_values(["SecuritiesCode", "Date"])
price = price.groupby("SecuritiesCode").apply(generate_adjusted_close).reset_index(drop=True)
return price
1
2
stock = pd.read_csv('/kaggle/input/jpx-tokyo-stock-exchange-prediction/train_files/stock_prices.csv')
stock_list = pd.read_csv('/kaggle/input/jpx-tokyo-stock-exchange-prediction/stock_list.csv')

1.2 趋势分析特征

趋势分析是研究时间序列数据中变化趋势的核心方法。通过对数据的长期变化规律进行分析,我们能够揭示其潜在的发展方向,预测未来的走势,以及及时地检测到任何异常的变化点。

在机器学习领域,为了准确地捕捉和描述这些趋势信息,我们经常会采用以下几种技术方法:

  • 历史平移:这种策略是将时间序列数据向后移动一定的时间间隔,与原始数据进行对比,从而得到趋势变化的方向和幅度。尽管这种方法简单而直观,但关键在于选择合适的平移间隔。若间隔选择得太小或太大,都可能导致捕捉到的趋势信息不准确。

  • 增长率特征:此策略计算时间序列数据相邻两点之间的增长率或百分比变化率。通过观察增长率的正负和变化幅度,我们可以推测趋势的走向。但此方法可能会受到数据的大幅波动或异常值的影响,导致增长率估计不稳定。

  • 窗口统计特征:窗口统计是一种常用的统计方法,它可以用来对时间序列数据进行分析。窗口统计的基本思想是将时间序列数据分割成若干个时间窗口,然后对每个时间窗口内的数据进行统计分析。根据窗口的类型,窗口统计可以分为跳动窗口、滑动窗口扩展窗口(累计窗口)三种。这里我们主要介绍滑动窗扣和扩展窗口。

  • 滑动窗口统计:此方法将时间序列数据划分为多个窗口,对每个窗口内的数据执行统计分析(例如计算均值、方差、最大值、最小值等),再根据这些特征值的变化来辨识趋势。使用这种方法可以降低数据中噪声的影响,但需要合适地选择窗口大小步长,以避免信息的模糊或失真。

    image
    上图展示了滑动窗口统计的工作原理,窗口大小为2,步长为1,求窗口内的和作为结果。

  • 扩张窗口统计:该方法从时间序列数据的起始点出发,逐步扩大窗口范围,对每个扩张窗口内的数据进行统计分析。通过观察扩张窗口内的统计量如滚动统计量,我们可以获得对于数据整体变化趋势的深入洞察。尽管此方法能够充分利用历史数据,但选择合适的扩展步长同样关键,以确保计算效率和捕获到的趋势明确性。

    image
    上图解释了扩张窗口统计的原理,窗口范围从最开始逐渐扩展,步长为1,求每个扩展窗口的和。

  • 指数加权移动:指数加权移动平均(Exponentially Weighted Moving Average,EWMA)是一种应用于时间序列数据的权重平均技术。它为最近的数据赋予了更大的权重,而旧的数据权重则随时间指数衰减。因此,与简单移动平均相比,EWMA对于最近的观测值更为敏感,能够更快地反应数据的变化。

1.2.1 历史平移和增长率特征

为什么这两种特征在股票数据中适用?

在金融领域,人们相信"历史会重演"。虽然过去的表现不能完全预测未来,但历史数据提供了对市场趋势、周期和模式的深入了解,这些都是决策制定中不可或缺的。历史平移特征让我们能够直观地了解过去,而增长率特征则为我们提供了一个量化的方法,来测量股票在某段时间内的性能。

此外,投资者和分析师都知道,单靠当前股票的绝对价格是不足以做出投资决策的。他们需要了解股票的相对表现,即其与过去的比较。这就是为什么增长率和历史平移特征对于股票数据分析来说非常有价值的原因。

1
2
# 本地可以运行下面的代码
df_stock = pd.read_csv('train_files/stock_prices.csv')
  • 历史平移特征
1
2
3
# 我们以1301股票的前十条数据为例
data = df_stock[df_stock['SecuritiesCode']==1301][['Open','Close']].head(10)
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Open Close
0 2734.0 2742.0
1865 2743.0 2738.0
3730 2734.0 2740.0
5595 2745.0 2748.0
7460 2748.0 2745.0
9325 2745.0 2731.0
11190 2707.0 2722.0
13055 2725.0 2704.0
14920 2702.0 2686.0
16785 2689.0 2694.0
1
2
3
4
5
# 使用shift函数可以完成平移(和HomeCredit的操作类似)
# 在真实应用中,我们可能会需要调整平移的间隔(即shift的参数)
# 而且这只是对某一支股票进行操作,在本案例中,我们需要对所有股票都进行平移操作
data.loc[:,'history_close'] = data['Close'].shift(1)
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Open Close history_close
0 2734.0 2742.0 NaN
1865 2743.0 2738.0 2742.0
3730 2734.0 2740.0 2738.0
5595 2745.0 2748.0 2740.0
7460 2748.0 2745.0 2748.0
9325 2745.0 2731.0 2745.0
11190 2707.0 2722.0 2731.0
13055 2725.0 2704.0 2722.0
14920 2702.0 2686.0 2704.0
16785 2689.0 2694.0 2686.0
1
2
data.loc[:,'history_close'] = data['Close'].shift(2)
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Open Close history_close
0 2734.0 2742.0 NaN
1865 2743.0 2738.0 NaN
3730 2734.0 2740.0 2742.0
5595 2745.0 2748.0 2738.0
7460 2748.0 2745.0 2740.0
9325 2745.0 2731.0 2748.0
11190 2707.0 2722.0 2745.0
13055 2725.0 2704.0 2731.0
14920 2702.0 2686.0 2722.0
16785 2689.0 2694.0 2704.0

解释:

history_close描述了一个时间窗口内的历史股票收盘价。通过平移数据,模型可以直观地看到某一天之前的股票价格,从而捕捉股票的历史趋势和模式。例如,shift(1)表示当前日期的1天前的收盘价。在金融领域,历史数据被广泛用来预测未来走势,因为股市的价格走势往往与其历史价格存在一定的关联。

  • 增长率特征
1
2
3
# pct_change(2)等价于(data['Close']-data['Close'].shift(2))/data['Close'].shift(2)
data.loc[:,f'window_close_2_pctchange'] = data['Close'].pct_change(2).values
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Open Close history_close window_close_2_pctchange
0 2734.0 2742.0 NaN NaN
1865 2743.0 2738.0 NaN NaN
3730 2734.0 2740.0 2742.0 -0.000729
5595 2745.0 2748.0 2738.0 0.003652
7460 2748.0 2745.0 2740.0 0.001825
9325 2745.0 2731.0 2748.0 -0.006186
11190 2707.0 2722.0 2745.0 -0.008379
13055 2725.0 2704.0 2731.0 -0.009886
14920 2702.0 2686.0 2722.0 -0.013226
16785 2689.0 2694.0 2704.0 -0.003698

解释:

增长率特征描述了股票价格在一段时间内的变化率。它不仅考虑股票的绝对价格变化,还考虑到了相对于其初始价格的变化幅度,因此提供了更深入的洞察。增长率可以帮助投资者理解股价的动量和方向。正增长率表示价格上涨,而负增长率表示价格下跌。在金融分析中,这一特征非常重要,因为它可以帮助投资者捕捉到短期和中期的市场趋势。

和shift函数一样,我们需要适当的选择pct_change的时间。当pct_change中的参数为1,也就是计算收盘价隔天的增长率的时候,实际上计算的就是Target。(严格来说,根据官方的说明,是使用AdjustedClose计算的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import warnings
warnings.filterwarnings("ignore")

# 选取没有进行股价调整股票代码
seclist = [1301]

for SECURITY in seclist:
# 筛选特定股票代码的数据
df_stock2 = df_stock[df_stock['SecuritiesCode'] == SECURITY].reset_index(drop=True)
df_stock2 = df_stock2.sort_values(by="Date").reset_index(drop=True)

# 计算收盘价的增长率
df_stock2['close_pct_ret'] = df_stock2['Close'].pct_change(1)

# 根据Target的计算公式,需要将Target向后移动2个位置(为了展示Target和就是收盘价增长率)
df_stock2['next_target'] = df_stock2['Target'].shift(2)

# 绘制图形
with plt.style.context('seaborn-whitegrid'):
plt.figure(figsize=(14, 7))
# 绘制下一个时间点的Target
plt.plot(df_stock2.index[:300], df_stock2["next_target"][:300], marker='*', linestyle='-', markersize=1, label="下一个时间点的Target")
# 绘制收盘价的增长率
plt.plot(df_stock2.index[:300], df_stock2["close_pct_ret"][:300], marker='*', linestyle='-', markersize=1, label="收盘价增长率")

# 设置标题
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['font.family'] = 'sans-serif'
plt.title(f"检查{SECURITY}的收盘价增长率与下一个时间点的Target的关系")
# 设置图例
plt.legend()
# 显示图形
plt.show()

![png](JPX Tokyo Stock Exchange Prediction/output_59_0.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 选取没有进行股价调整股票代码
seclist = [1301]

for SECURITY in seclist:
# 筛选特定股票代码的数据
df_stock2 = df_stock[df_stock['SecuritiesCode'] == SECURITY].reset_index(drop=True)
df_stock2 = df_stock2.sort_values(by="Date").reset_index(drop=True)

# 计算收盘价的增长率
df_stock2['close_pct_ret'] = df_stock2['Close'].pct_change(1)

# 根据Target的计算公式,需要将Target向后移动2个位置
df_stock2['next_target'] = df_stock2['Target'].shift(1)

# 绘制图形
with plt.style.context('seaborn-whitegrid'):
plt.figure(figsize=(14, 7))
# 绘制下一个时间点的Target
plt.plot(df_stock2.index[:300], df_stock2["next_target"][:300], marker='*', linestyle='-', markersize=1, label="下一个时间点的Target")
# 绘制收盘价的增长率
plt.plot(df_stock2.index[:300], df_stock2["close_pct_ret"][:300], marker='*', linestyle='-', markersize=1, label="收盘价增长率")

# 设置标题
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['font.family'] = 'sans-serif'
plt.title(f"检查{SECURITY}的收盘价增长率与下一个时间点的Target的关系")
# 设置图例
plt.legend()
# 显示图形
plt.show()

![png](JPX Tokyo Stock Exchange Prediction/output_60_0.png)

只能看到一条线,因为两条线重合了

1.2.2 窗口统计特征

  • 滑动窗口统计

滑动窗口是时间序列分析中的一个核心概念,它提供了一种在连续的时间点上分析数据的方法,同时保持了数据的局部特征。想象一下你正在用一块固定大小的玻璃逐步滑过一幅长图,每次移动都只看到玻璃下的那部分图像——这就是滑动窗口的基本思想。

在机器学习和统计分析中,我们使用滑动窗口来捕捉时间序列数据的短期模式或趋势。这通常涉及以下几个步骤:

  1. 选择窗口大小:这决定了每次观察的数据点数量。窗口的大小会影响到我们捕捉到的数据模式——太小的窗口可能会过度关注噪声,而太大的窗口可能会丧失细节。
  2. 选择步长:步长决定了窗口每次移动的数据点数量。步长较小会产生更多的重叠,可以更精细的分析,但计算量也会增大。
  3. 应用统计函数:对每个窗口内的数据应用统计函数(如均值、中位数、方差等),从而提取出有用的特征或信息。
    滑动窗口的技术不仅可以用来提取特征,还可以用于数据的平滑、异常检测以及预测等多种任务,使其成为时间序列分析的强大工具。

这里我们重点介绍滑动窗口求平均值以及波动比率特征的介绍:

滑动窗口计算平均值特征

1
data['Close']
0        2742.0
1865     2738.0
3730     2740.0
5595     2748.0
7460     2745.0
9325     2731.0
11190    2722.0
13055    2704.0
14920    2686.0
16785    2694.0
Name: Close, dtype: float64
1
2
3
4
5
6
# pd.Series.rolling即可完成滑窗操作
# window参数表示窗口的大小,这里我们设置为2
# rolling不支持自定义步长的大小
# 在rolling之后,需要指定需要计算的统计量,除了.mean()方法外
# 还可以使用 .sum() .max() .min() .std() .var()等方法
data['Close'].rolling(window=2).mean()
0           NaN
1865     2740.0
3730     2739.0
5595     2744.0
7460     2746.5
9325     2738.0
11190    2726.5
13055    2713.0
14920    2695.0
16785    2690.0
Name: Close, dtype: float64
1
pd.__version__
'1.3.5'
1
2
3
# 此版本rolling不支持自定义步长的大小
# pandas版本低于1.5.0就不能自定义步长的大小
data['Close'].rolling(window=2,step=2).mean()
---------------------------------------------------------------------------

TypeError                                 Traceback (most recent call last)

~\AppData\Local\Temp\ipykernel_23048\821644658.py in <module>
      1 # 此版本rolling不支持自定义步长的大小
      2 # pandas版本低于1.5.0就不能自定义步长的大小
----> 3 data['Close'].rolling(window=2,step=2).mean()


TypeError: rolling() got an unexpected keyword argument 'step'
1
2
3
4
# 有时候我们需要在同一个窗口下同时计算多个统计量,则可以借助agg函数来完成
for stat in ['mean', 'std', 'max', 'min', 'median']:
data.loc[:,f'window_close_5_{stat}'] = data['Close'].rolling(window=5).agg(stat).values
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Open Close history_close window_close_2_pctchange window_close_5_mean window_close_5_std window_close_5_max window_close_5_min window_close_5_median
0 2734.0 2742.0 NaN NaN NaN NaN NaN NaN NaN
1865 2743.0 2738.0 NaN NaN NaN NaN NaN NaN NaN
3730 2734.0 2740.0 2742.0 -0.000729 NaN NaN NaN NaN NaN
5595 2745.0 2748.0 2738.0 0.003652 NaN NaN NaN NaN NaN
7460 2748.0 2745.0 2740.0 0.001825 2742.6 3.974921 2748.0 2738.0 2742.0
9325 2745.0 2731.0 2748.0 -0.006186 2740.4 6.580274 2748.0 2731.0 2740.0
11190 2707.0 2722.0 2745.0 -0.008379 2737.2 10.663020 2748.0 2722.0 2740.0
13055 2725.0 2704.0 2731.0 -0.009886 2730.0 17.958285 2748.0 2704.0 2731.0
14920 2702.0 2686.0 2722.0 -0.013226 2717.6 23.093289 2745.0 2686.0 2722.0
16785 2689.0 2694.0 2704.0 -0.003698 2707.4 18.836135 2731.0 2686.0 2704.0

【思考】:为什么滑动窗口统计能产生显著的效果?

滑动窗口统计是时间序列分析中的常见手段。它的主要原理是对数据中的每一段子集(即窗口)进行计算,以提取数据的某些特性。这样做可以帮助我们更好地理解数据在某个特定时间段的特点。例如,通过计算移动平均,我们可以得到数据的局部平均值,从而捕捉到数据的中期趋势,而不受到短期波动的干扰。

考虑股票1301,当我们使用滑动窗口技术绘制移动平均线(MA)时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import seaborn as sns
# 设置绘图风格为白色网格
sns.set_style("whitegrid")
# 设置图形大小
sns.set(rc={'figure.figsize':(28,8)})
# 定义股票代码列表
seclist = [1301]
# 遍历股票代码列表
for SECURITY in seclist:
# 获取指定股票代码的数据,并按日期排序
df_stock2 = df_stock[df_stock['SecuritiesCode'] == SECURITY][:200].reset_index(drop = True)
df_stock2 = df_stock2.sort_values(by = "Date").reset_index()
# 提取日期和收盘价数据
df_s = df_stock2[['Date', 'Close']].reset_index(drop = True)
# 计算20日、50日和100日简单移动平均线
df_s['20D-MA'] = df_s['Close'].rolling(window=5).mean()
df_s['50D-MA'] = df_s['Close'].rolling(window=10).mean()
df_s['100D-MA'] = df_s['Close'].rolling(window=50).mean()
# 设置日期为索引
df_s.set_index('Date')
# 删除日期列
del df_s['Date']
# 设置绘图风格为白色网格
sns.set_style("whitegrid")
# 绘制简单移动平均线图,并设置标题
df_s.plot(title = "MA analysis for Security Code:" + str(SECURITY))
![png](JPX Tokyo Stock Exchange Prediction/output_73_0.png)

观察图像,我们会发现移动平均线比实际股价走势要更加平滑。这种平滑效果在窗口大小增大时会更加明显。因此,移动平均线实际上捕捉到了数据的长期趋势特性,并在一定程度上消除了数据的短期波动。

打开任何股票软件,通常我们都可以看到某只股票的5日、10日、20日移动平均线(MA):

image

在股票市场中,这些移动平均线之间的交叉具有特殊的意义。例如,短期均线下穿长期均线时,这种现象被称为“死叉”,通常被视为卖出的信号;相反,短期均线上穿长期均线时,被称为“金叉”,通常被视为买入的信号。然而,值得注意的是,虽然这些交叉提供了一些参考信息,但它们仅仅是技术分析中的一种工具,并不应被视为绝对的买卖准则。实际的投资决策需要综合多种因素,并考虑个人的投资策略和风险承受能力。

滑动窗口计算极值特征(Max-Min Feature)和波动比率

极值主要指的是某个特定时间段内股票价格的最高和最低值。对于任何一段时间(例如一天、一周或一个月),极值可以理解为这段时间内市场的最高和最低预期。这可以帮助我们了解市场在这段时间内的市场活跃度潜在的阻力和支撑水平(心理关卡)和风险预估

极值特征示例代码:

1
2
3
# 创建示例数据集
data = df_stock[df_stock['SecuritiesCode']==1301][['Date','Close']].head(10)
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close
0 2017-01-04 2742.0
1865 2017-01-05 2738.0
3730 2017-01-06 2740.0
5595 2017-01-10 2748.0
7460 2017-01-11 2745.0
9325 2017-01-12 2731.0
11190 2017-01-13 2722.0
13055 2017-01-16 2704.0
14920 2017-01-17 2686.0
16785 2017-01-18 2694.0
1
2
3
4
5
6
7
8
# 创建一个10天时间窗口的最大值、最小值和差值特征
window_period = 5
data[f'window_close_{window_period}_max'] = data['Close'].rolling(window=window_period).max()
data[f'window_close_{window_period}_min'] = data['Close'].rolling(window=window_period).min()
data[f'window_close_{window_period}_max-min'] = data[f'window_close_{window_period}_max'] - data[f'window_close_{window_period}_min']

# 显示结果
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close window_close_5_max window_close_5_min window_close_5_max-min
0 2017-01-04 2742.0 NaN NaN NaN
1865 2017-01-05 2738.0 NaN NaN NaN
3730 2017-01-06 2740.0 NaN NaN NaN
5595 2017-01-10 2748.0 NaN NaN NaN
7460 2017-01-11 2745.0 2748.0 2738.0 10.0
9325 2017-01-12 2731.0 2748.0 2731.0 17.0
11190 2017-01-13 2722.0 2748.0 2722.0 26.0
13055 2017-01-16 2704.0 2748.0 2704.0 44.0
14920 2017-01-17 2686.0 2745.0 2686.0 59.0
16785 2017-01-18 2694.0 2731.0 2686.0 45.0

波动比率

在时序数据中,特别是股票价格数据,单纯地考虑价格的波动值可能不够充分,因为股票的价格在不同时间可能有很大的差异(比如,有的股票在过去的价位很低,现在价位很高)。在这种情况下,固定的波动值对于低价股票可能非常显著,但对于高价股票则可能不那么显著。因此,考虑波动的比率是有意义的。其计算公式:

波动比率=最大值最小值最大值+最小值2波动比率 = \frac{最大值 - 最小值}{\frac{最大值 + 最小值}{2}}

1
2
3
# 使用平均值为基准计算波动比率
data.loc[:,f'window_close_{window_period}_max-min_ratio_avg'] = 2 * (data[f'window_close_{window_period}_max'] - data[f'window_close_{window_period}_min']) / (data[f'window_close_{window_period}_max'] + data[f'window_close_{window_period}_min'])
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close window_close_5_max window_close_5_min window_close_5_max-min window_close_5_max-min_ratio_avg
0 2017-01-04 2742.0 NaN NaN NaN NaN
1865 2017-01-05 2738.0 NaN NaN NaN NaN
3730 2017-01-06 2740.0 NaN NaN NaN NaN
5595 2017-01-10 2748.0 NaN NaN NaN NaN
7460 2017-01-11 2745.0 2748.0 2738.0 10.0 0.003646
9325 2017-01-12 2731.0 2748.0 2731.0 17.0 0.006206
11190 2017-01-13 2722.0 2748.0 2722.0 26.0 0.009506
13055 2017-01-16 2704.0 2748.0 2704.0 44.0 0.016141
14920 2017-01-17 2686.0 2745.0 2686.0 59.0 0.021727
16785 2017-01-18 2694.0 2731.0 2686.0 45.0 0.016614
  • 扩展窗口统计

在前面的部分,我们详细探讨了滑动窗口统计的原理及其应用。滑动窗口通过定义固定的时间段来计算时间序列数据中的统计量,为我们提供了对数据局部特性的深入了解。现在,我们将转向另一种强大的工具:扩展窗口统计

扩展窗口统计与滑动窗口统计在许多方面都具有相似性,它们都侧重于计算窗口内的统计量来揭示数据的内在特性。然而,扩展窗口的一个显著特点是其动态的窗口大小。与滑动窗口的固定大小不同,扩展窗口的大小会随着新数据的加入而逐渐扩大。换句话说,它始终从序列的起始点开始,但其结束点会随着时间而移动,从而包含更多的数据。

因此,扩展窗口有时也被称为累计窗口。这是因为它累计了从时间序列的开始到当前时间点的所有数据,从而提供了一种全面的视角。

金融领域,扩展窗口的概念尤为重要。例如,证券的持有期收益率称为持有收益率(Holding Period Return)。这是指从投资者开始持有某种证券到某个特定时间点的累计收益。其值不仅仅是单一时间段内的收益,而是从购买之日起至今的总收益。

通过对这种持有期收益率的分析,投资者可以更好地评估其投资策略的长期有效性,而不仅仅是短期的表现。这为金融分析师和投资者提供了一个宝贵的工具,帮助他们在复杂的金融市场中做出明智的决策。

1
2
# 扩展窗口的实现代码也很简单,只需要pd.Series.expanding()即可,后面加上自己需要计算的统计量
data['Close'].expanding().mean()
0        2742.000000
1865     2740.000000
3730     2740.000000
5595     2742.000000
7460     2742.600000
9325     2740.666667
11190    2738.000000
13055    2733.750000
14920    2728.444444
16785    2725.000000
Name: Close, dtype: float64
1
2
3
# 我们把扩展窗口计算的均值赋值给新列
data['expand_mean'] = data['Close'].expanding().mean()
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close window_close_5_max window_close_5_min window_close_5_max-min window_close_5_max-min_ratio_avg expand_mean
0 2017-01-04 2742.0 NaN NaN NaN NaN 2742.000000
1865 2017-01-05 2738.0 NaN NaN NaN NaN 2740.000000
3730 2017-01-06 2740.0 NaN NaN NaN NaN 2740.000000
5595 2017-01-10 2748.0 NaN NaN NaN NaN 2742.000000
7460 2017-01-11 2745.0 2748.0 2738.0 10.0 0.003646 2742.600000
9325 2017-01-12 2731.0 2748.0 2731.0 17.0 0.006206 2740.666667
11190 2017-01-13 2722.0 2748.0 2722.0 26.0 0.009506 2738.000000
13055 2017-01-16 2704.0 2748.0 2704.0 44.0 0.016141 2733.750000
14920 2017-01-17 2686.0 2745.0 2686.0 59.0 0.021727 2728.444444
16785 2017-01-18 2694.0 2731.0 2686.0 45.0 0.016614 2725.000000

1.2.3 指数加权移动平均(EWMA)

指数加权移动平均(EWMA)是时间序列数据平滑方法之一,尤其在金融和经济领域中具有广泛的应用。与简单移动平均不同,EWMA为数据中的每个观测值分配不同的权重。这种权重分配是按照指数递减的方式进行的,这意味着近期的数据点会被赋予较高的权重,而较早的数据点权重则逐渐降低。与简单移动平均线(SMA)相比,EWMA对最近的价格变化更为敏感。这意味着,当市场出现新的信息或变化时,EWMA能够更快地反映这些变化。

其计算公式为:

EWMA(t)=α×x(t)+(1α)×EWMA(t1)EWMA(t)=α×x(t)+(1−α)×EWMA(t−1)

其中,x(t)x(t) 是在时间点t的观测值,EWMA(t1)EWMA(t−1) 是在时间点t-1的指数加权移动平均值,αα 是权重因子,范围在0到1之间。

  • αα接近1时,表示我们更多地依赖最近的观测值。这使得EWMA对近期变化更加敏感,但也可能引入更多的噪声。
  • 而当αα接近0时,模型将更加依赖过去的平均值,从而使得EWMA更加平稳。但这也可能使其对突发的变化反应迟钝。

在实际应用中,选择合适的 αα 值至关重要。这通常需要根据具体的应用场景和数据特性进行选择。例如,金融市场中的股票价格可能会频繁波动,因此可能需要一个较高的 αα 值来捕捉这些短期变化。而在其他更为稳定的时间序列数据中,较低的 αα 值可能更为合适。

总的来说,EWMA提供了一种灵活的方式来平滑时间序列数据,同时考虑到了数据中的时间依赖性。通过合适地选择权重因子,它可以为分析师和决策者提供有关数据趋势和模式的有价值的洞察。

1
2
3
4
5
6
# pd.Series.ewm 可以实现指数加权的计算,在后面加上.mean()就可以计算指数加权平均

# 固定alpha_value的值并且设置adjust=False,表示计算方法为递归计算(上面的计算公式)
alpha_value = 0.3
data['ewma'] = data['Close'].ewm(alpha=alpha_value, adjust=False).mean()
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close window_close_5_max window_close_5_min window_close_5_max-min window_close_5_max-min_ratio_avg expand_mean ewma
0 2017-01-04 2742.0 NaN NaN NaN NaN 2742.000000 2742.000000
1865 2017-01-05 2738.0 NaN NaN NaN NaN 2740.000000 2740.800000
3730 2017-01-06 2740.0 NaN NaN NaN NaN 2740.000000 2740.560000
5595 2017-01-10 2748.0 NaN NaN NaN NaN 2742.000000 2742.792000
7460 2017-01-11 2745.0 2748.0 2738.0 10.0 0.003646 2742.600000 2743.454400
9325 2017-01-12 2731.0 2748.0 2731.0 17.0 0.006206 2740.666667 2739.718080
11190 2017-01-13 2722.0 2748.0 2722.0 26.0 0.009506 2738.000000 2734.402656
13055 2017-01-16 2704.0 2748.0 2704.0 44.0 0.016141 2733.750000 2725.281859
14920 2017-01-17 2686.0 2745.0 2686.0 59.0 0.021727 2728.444444 2713.497301
16785 2017-01-18 2694.0 2731.0 2686.0 45.0 0.016614 2725.000000 2707.648111

adjust=True时,指数加权移动平均值(EWMA)是使用权重wi=(1α)iw_i = (1 - \alpha)^i计算的。例如,序列[x0,x1,...,xt][x_0, x_1, ..., x_t]的EWMA值为:

yt=xt+(1α)xt1+(1α)2xt2+...+(1α)tx01+(1α)+(1α)2+...+(1α)ty_t = \frac{x_t + (1 - \alpha)x_{t-1} + (1 - \alpha)^2x_{t-2} + ... + (1 - \alpha)^tx_0}{1 + (1 - \alpha) + (1 - \alpha)^2 + ... + (1 - \alpha)^t}

其中,α\alphaewm函数的一个参数,用于控制EWMA值对新数据的敏感程度。α\alpha的取值范围为(0,1)(0, 1),和上面一样,α\alpha越大,EWMA值对新数据的敏感程度越高。

当adjust=True时,EWMA值更接近于原始数据;而当adjust=False时,EWMA值更平滑:

1
2
3
4
5
6
7
8
9
10
11
import pandas as pd

# 创建一个简单的数据框
data = pd.DataFrame({'Close': [100, 101, 102, 103, 104]})

# 计算EWMA值,alpha=0.5
data['ewma_adjust_true'] = data['Close'].ewm(alpha=0.5, adjust=True).mean()
data['ewma_adjust_false'] = data['Close'].ewm(alpha=0.5, adjust=False).mean()

# 输出结果
data
.dataframe thead th {
    text-align: right;
}
Close ewma_adjust_true ewma_adjust_false
0 100 100.000000 100.0000
1 101 100.666667 100.5000
2 102 101.428571 101.2500
3 103 102.266667 102.1250
4 104 103.161290 103.0625

上面我们只是计算了指数加权平均,同样的,我们还可以计算其他统计量:

  • .mean():计算指数加权移动平均值。
  • .var():计算指数加权移动方差。
  • .std():计算指数加权移动标准差。这实际上是 .var() 的平方根。
  • .corr():计算两组数的指数加权移动相关系数。
  • .cov():计算两组数的指数加权移动协方差。

上面我们是直接指定了α\alpha的取值,但是它也可以通过别的参数来指定:

EWM (指数加权移动) 参数详解

  1. 衰减因子α\alpha选择:
    选择权重衰减速率时,必须指定以下参数中的一个:
  • alpha:直接指定平滑因子 α,范围为 0 < α ≤ 1。
  • com(center of mass):指定衰减的中心质量(物理学概念)。通过公式 α=11+com\alpha = \frac{1}{1 + \text{com}}计算,其中 com ≥ 0。
  • halflife:指定半衰期(应用于化学和核物理学)。通过公式α=1exp(ln(2)halflife)\alpha = 1 - \exp\left(-\frac{\ln(2)}{\text{halflife}}\right)计算,其中 halflife > 0。
  • span:指定衰减的跨度(数学和统计学概念)。通过公式 α=2span+1\alpha = \frac{2}{\text{span} + 1}计算,其中 span ≥ 1。股票数据使用span参数的情况会比较多一些。
  1. 为什么选择span参数?
    在金融领域,分析师和交易员常参考固定天数的移动平均,如10天、30天、50天。但简单的移动平均可能忽略短期的重要变化。EWMA,特别是通过设置span,能够更精确地反映数据变化。

    • 跨度的意义:例如,“30天的跨度”(span=30)代表权重衰减的基本长度为30天。30天前的数据权重是当前数据的一半。超过30天的数据虽被考虑,但权重迅速衰减。

    • 跨度与其他移动平均的对比:span参数为EWMA提供了直观的定义方式,方便与简单移动平均线(SMA)等进行比较。(所以代码实现也会更直接和简单,可以指定同一个period)

  2. 其他参数:

    • min_periods:窗口中所需的最小观测值数量。默认值为 0。少于此数量则结果为 np.nan。

    • ignore_na:是否在计算权重时忽略缺失值。默认为 False。

    • axis:指定计算的方向。默认为 0(或 ‘index’)。若为 0 或 ‘index’,则沿行计算;若为 1 或 ‘columns’,则沿列计算。

    • times 参数:与观测值对应的时间。必须单调递增且具为 datetime 类型。如果指定了times参数之后halftime则表示观测值衰减到其一半值所需的时间单位(仅计算均值时生效)

1
2
3
4
5
6
# 使用times参数有比较多的限制,所以我们创建一个示例数据来进行说明
data = pd.DataFrame({
'Date': ['2022-01-01', '2022-01-02', '2022-01-03', '2022-01-04', '2022-01-05'],
'Value': [1, 2, 3, 4, 5]
})
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Value
0 2022-01-01 1
1 2022-01-02 2
2 2022-01-03 3
3 2022-01-04 4
4 2022-01-05 5
1
data['Value'].ewm(span=5,adjust=False).mean()
0    1.000000
1    1.333333
2    1.888889
3    2.592593
4    3.395062
Name: Value, dtype: float64
1
2
3
4
# 首先times必须是时间戳数据
times = pd.to_datetime(data['Date'])
# 在使用times参数时,衰减因子只能选择halflife,这里我们设置为5D(天)
data['Value'].ewm(times=times,halflife='5D').mean()
0    1.000000
1    1.534602
2    2.092125
3    2.672350
4    3.274976
Name: Value, dtype: float64
1
2
# 直接指定alpha也不行
data['Value'].ewm(times=times,alpha=0.1).mean()
---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

~\AppData\Local\Temp\ipykernel_23048\2193549492.py in <module>
      1 # 直接指定alpha也不行
----> 2 data['Value'].ewm(times=times,alpha=0.1).mean()


c:\ProgramData\Anaconda3\envs\ai\lib\site-packages\pandas\core\generic.py in ewm(self, com, span, halflife, alpha, min_periods, adjust, ignore_na, axis, times)
  10928             ignore_na=ignore_na,
  10929             axis=axis,
> 10930             times=times,
  10931         )
  10932 


c:\ProgramData\Anaconda3\envs\ai\lib\site-packages\pandas\core\window\ewm.py in __init__(self, obj, com, span, halflife, alpha, min_periods, adjust, ignore_na, axis, times, selection)
    306             if not isinstance(self.halflife, (str, datetime.timedelta)):
    307                 raise ValueError(
--> 308                     "halflife must be a string or datetime.timedelta object"
    309                 )
    310             if isna(self.times).any():


ValueError: halflife must be a string or datetime.timedelta object
1
2
# 而且adjust也只能设置为True,即使用默认值
data['Value'].ewm(times=times,halflife='5D',adjust=False).mean()
---------------------------------------------------------------------------

NotImplementedError                       Traceback (most recent call last)

~\AppData\Local\Temp\ipykernel_23048\2431065888.py in <module>
      1 # 而且adjust也只能设置为True,即使用默认值
----> 2 data['Value'].ewm(times=times,halflife='5D',adjust=False).mean()


c:\ProgramData\Anaconda3\envs\ai\lib\site-packages\pandas\core\generic.py in ewm(self, com, span, halflife, alpha, min_periods, adjust, ignore_na, axis, times)
  10928             ignore_na=ignore_na,
  10929             axis=axis,
> 10930             times=times,
  10931         )
  10932 


c:\ProgramData\Anaconda3\envs\ai\lib\site-packages\pandas\core\window\ewm.py in __init__(self, obj, com, span, halflife, alpha, min_periods, adjust, ignore_na, axis, times, selection)
    295         if self.times is not None:
    296             if not self.adjust:
--> 297                 raise NotImplementedError("times is not supported with adjust=False.")
    298             if isinstance(self.times, str):
    299                 self.times = self._selected_obj[self.times]


NotImplementedError: times is not supported with adjust=False.

1.3 市场动态特征

在分析股票数据时,除了研究价格的长期趋势之外,还需要关注其短期的动态变化。这就是所谓的“市场动态特征”。与“趋势分析特征”主要关注价格的整体走势、形成的长期模式不同,“市场动态特征”更加注重短期内的价格波动、变动范围和特定的市场行为模式。通过对这些特征的分析,我们可以更加细致地了解市场在某一特定时间段内的行为,从而做出更为精准的预测和决策。

接下来,我们将详细探讨这部分的三个核心特征:

  1. 差异和波动性特征则展现了价格的不稳定性,帮助我们感知市场的风险;
  2. 阴阳线特征通过研究开盘和收盘价的关系,为我们揭示了市场的买卖力量分布;
  3. 基础时间特征则将时间因素与价格行为结合,使模型能够理解某些周期性的市场行为,捕捉数据的时间属性和规律。
  • 差异和波动性特征(Diff and Volatility Features)

差异特征 diff 表示在特定的时间窗口内股价的绝对变化,为交易策略提供方向。

1
2
3
4
5
data = df_stock[df_stock['SecuritiesCode']==1301][['Date','Close']].head(10)

# 差异特征,在diff中指定时间窗口周期
data.loc[:, f'close_diff_{window_period}'] = data['Close'].diff(window_period).values
data
.dataframe thead th {
    text-align: right;
}
Date Close close_diff_5
0 2017-01-04 2742.0 NaN
1865 2017-01-05 2738.0 NaN
3730 2017-01-06 2740.0 NaN
5595 2017-01-10 2748.0 NaN
7460 2017-01-11 2745.0 NaN
9325 2017-01-12 2731.0 -11.0
11190 2017-01-13 2722.0 -16.0
13055 2017-01-16 2704.0 -36.0
14920 2017-01-17 2686.0 -62.0
16785 2017-01-18 2694.0 -51.0

上面的操作实际上是叫做一阶差分,相当于data - data.shift(5),那么这种简单的作差操作,为什么就可以帮助模型理解数据并建模呢?

一阶差分和二阶差分是时间序列分析中常用的技术,主要用于使时间序列数据变得更加平稳,即减少或消除数据中的趋势和季节性成分。数据的平稳性是许多时间序列模型,如ARIMA模型,的一个基本假设。大多数的时间序列模型都要求输入的数据是平稳的,或者至少是宽度平稳的。当数据存在趋势或季节性时,模型可能会被这些趋势所迷惑,导致预测不准确。差分技术可以帮助消除这些趋势和季节性,从而使得数据变得平稳。

  1. 一阶差分:ΔYt=YtYt1ΔY_t=Y_t−Y_{t−1}
  • 一阶差分可以帮助移除线性的趋势。
  • 对于时间序列数据YtY_t,一阶差分表示为连续观测值之间的差异。
  • 例如,如果我们有一个上升的线性趋势,一阶差分可能会使得这个趋势消失,从而得到一个平稳的时间序列。
  1. 二阶差分:Δ²Yt=(YtYt1)(Yt1Yt2)Δ²Y_t=(Y_t−Y_{t−1})-(Y_{t-1}-Y_{t-2})
  • 当一阶差分无法消除时间序列中的趋势时,我们可能会尝试二阶差分。
  • 二阶差分有助于消除曲线的趋势。

对于原始数据、一阶差分和二阶差分之间的关系可以对照高中物理的位移、速度、加速度之间的关系。通过差分技术,我们可以获得一个更为平稳、可解释且与模型假设相一致的时间序列数据,增加连续观测值之间的相关性,从而帮助模型更好地理解数据和进行预测。

波动性特征:帮助评估市场的不确定性或风险,波动性增加可能预示大的价格变动,其本质是衡量价格或收益率在某段时间内的变化程度,因此任何可以衡量变化幅度的统计工具理论上都可以用来计算波动性。在方案中,我们计算的是对数收益滚动标准差

这个波动性和上面滑动窗口板块计算的波动比率有什么联系和区别?

  • 它们都是描述股票价格波动性的方法,只是从不同的角度和计算方式来进行描述。
  • 波动比率使用了最大值和最小值来描述股价在一段时间内的波动范围,而对数波动性则使用了价格的对数日回报率的标准差来描述价格的变化程度。
  • 在实际的金融分析中,这两种方法都可以用来估计股票的风险和潜在回报,投资者和分析师可以结合这两种方法来更全面地了解股票的价格动态。

指数收益(对数日回报率)的计算公式:

LogReturn=log(PtPt1)Log Return = \log(\frac{P_{t}}{P_{t-1}})

其中,PtP_{t}为时间 tt 的价格,Pt1P_{t-1}为时间 t1t-1 的价格

1
2
# 用代码来表示就是
np.log(data['Close'] / data['Close'].shift(1))
0             NaN
1865    -0.001460
3730     0.000730
5595     0.002915
7460    -0.001092
9325    -0.005113
11190   -0.003301
13055   -0.006635
14920   -0.006679
16785    0.002974
Name: Close, dtype: float64
1
2
# 考虑到指数的运算规则,也可以使用差分函数diff来实现
np.log(data['Close']).diff()
0             NaN
1865    -0.001460
3730     0.000730
5595     0.002915
7460    -0.001092
9325    -0.005113
11190   -0.003301
13055   -0.006635
14920   -0.006679
16785    0.002974
Name: Close, dtype: float64

基于上述对数日回报率,接下来使用窗口函数计算对数收益率的滚动标准差,代表价格的对数波动性:对数波动性=std(log(今日收盘价)log(昨日收盘价))对数波动性=std(log(今日收盘价)−log(昨日收盘价))

1
2
3
4
# 这里我们设置窗口为5
data['log_diff'] = np.log(data['Close']).diff()
data.loc[:, f'logvolatility_{window_period}day'] = np.log(data['Close']).diff().rolling(window_period).std()
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Close close_diff_5 log_diff logvolatility_5day
0 2017-01-04 2742.0 NaN NaN NaN
1865 2017-01-05 2738.0 NaN -0.001460 NaN
3730 2017-01-06 2740.0 NaN 0.000730 NaN
5595 2017-01-10 2748.0 NaN 0.002915 NaN
7460 2017-01-11 2745.0 NaN -0.001092 NaN
9325 2017-01-12 2731.0 -11.0 -0.005113 0.002969
11190 2017-01-13 2722.0 -16.0 -0.003301 0.003178
13055 2017-01-16 2704.0 -36.0 -0.006635 0.003734
14920 2017-01-17 2686.0 -62.0 -0.006679 0.002383
16785 2017-01-18 2694.0 -51.0 0.002974 0.004005

这个表达式通常用于计算金融时间序列数据中收盘价的波动性,因为标准差是衡量数据分布的统计量,能够反映数据的离散程度。因此,在金融分析中,标准差通常用来反映价格的波动性,波动性越大,也就意味着股票的变化幅度越大,风险越高。

【扩展】: 非金融专业的角度该如何理解“波动性”?上面我们计算的是指数收益的波动性,简单收益率不行吗?为什么要使用对数收益率?

  1. 波动性简介:波动性其实就是描述收益率的变化幅度有多大。试想一下,如果一个股票的价格每天都上涨1%,那它的变化就很稳定,波动性低。但如果价格时而大涨,时而大跌,那它的波动性就很高了。而这种变化幅度在数学上,我们用“标准差”来量化这种波动性。(回想我们在夏普比率中用到的分母,那其实也是一个波动性的指标)。

  2. 简单收益率与对数收益率的选择:


    简单收益率:PtPt1Pt1\frac{P_{t} - P_{t-1}}{P_{t-1}}
    从直观上理解,它描述了价格从一个时间点到另一个时间点的变化情况。从公式的理解上来说,简单收益率确实有可取之处。因此,尽管在解释实际的经济意义的时候我们会使用简单收益率,但是在对金融数据进行衍生的时候我们还是会优先考虑对数收益率,因为其优秀的数学理质:
    • 加性属性:多个连续时期的对数收益率可以简单地累加,对于模型来说,比较容易捕捉到这种信息
    • 数学和统计特性:对数收益率在许多统计和金融模型中都显示出了更好的数学特性,因为对数收益率的分布往往更接近正态分布(许多金融理论的基本假设前提都是服从随机的正态分布)。
    • 数值稳定性:对于较小的收益值,对数收益率与简单收益率之间的差异其实并不大。这使得对数收益率更为稳定,特别适用于金融数据的时间序列分析。所以,尽管在解释实际投资回报时我们可能会用简单收益率,但在构建金融模型时,对数收益率往往是首选。

波动性特征并不仅仅适用于股票数据。事实上,波动性是一个广泛的概念,可以用来描述许多不同类型数据的不稳定性或变化幅度。任何可能随时间变化、并且我们希望衡量其变化程度的数据都可以考虑计算其波动性。例如外汇市场、商品市场(油价金价农产品价格)、利率、房地产市场、流量和使用率、气候和天气数据、电力和能源消耗、销售和供应链数据等等。只要存在变化和不确定性的地方,波动性度量就可能是一个有用的有效的衍生方向。

  • 阴阳线特征(Candlestick Features)

股票行情的“K线图”

以篮球比赛为例,比赛的每一分、每一秒都充满了紧张和刺激,但我们通常只关心得分、助攻、篮板等关键数据。同理,股票的价格每天都在变动,但我们可以通过K线图来捕捉每天的四个关键价格,其由若干个阴线和阳线构成,红色的是阳线,绿色的阴线:
image

以单日K线图为例,每根K线图都包含了四个价格信息:开盘价、收盘价、最高价和最低价,中间的柱子叫做实体部分,实体部分可以是空白的,也可以是红色或绿色填充的。实体上面叫做上影线,在阳线中表示当日最高价和收盘价之间的差值(如果是阴线的话就是开盘价),下影线同理。
image

在这部分,上影线和下影线可以作为一个技术指标

在K线图中,长上影线和长下影线确实常常被解读为市场的不确定性和多空双方的斗争(多指的是做多的一方,空指的是做空的一方)。总之,影线的长短可以反映买卖双方的交易激烈程度(市场情绪)。当影线很长时,说明当日股票价格波动较大,买卖双方交易激烈。

我们可以使用下面的代码计算K线图中的上影线和下影线长度:

1
2
3
4
5
6
data = df_stock[df_stock['SecuritiesCode']==1301][['Date','Open','Close','High','Low']].head(10)
# 上影线长度其实就是最高价和max(Close,Open)的差值
data['Upper_Shadow'] = data['High'] - data[['Close', 'Open']].max(axis=1)
# 下影线长度其实就是最低价和min(Close,Open)的差值
data['Lower_Shadow'] = data[['Close', 'Open']].min(axis=1) - data['Low']
data
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date Open Close High Low Upper_Shadow Lower_Shadow
0 2017-01-04 2734.0 2742.0 2755.0 2730.0 13.0 4.0
1865 2017-01-05 2743.0 2738.0 2747.0 2735.0 4.0 3.0
3730 2017-01-06 2734.0 2740.0 2744.0 2720.0 4.0 14.0
5595 2017-01-10 2745.0 2748.0 2754.0 2735.0 6.0 10.0
7460 2017-01-11 2748.0 2745.0 2752.0 2737.0 4.0 8.0
9325 2017-01-12 2745.0 2731.0 2747.0 2703.0 2.0 28.0
11190 2017-01-13 2707.0 2722.0 2730.0 2707.0 8.0 0.0
13055 2017-01-16 2725.0 2704.0 2725.0 2696.0 0.0 8.0
14920 2017-01-17 2702.0 2686.0 2704.0 2682.0 2.0 4.0
16785 2017-01-18 2689.0 2694.0 2695.0 2681.0 1.0 8.0

K线图也被称为是蜡烛图,除了股票数据,蜡烛图也常用于分析其他金融产品,如外汇、债券、商品等。这种计算影线的衍生方法适用于任何具有与时间相关的最高值、最低值、开始值和结束值的数据。例如,一家公司在一个月内的日销售额,其中开盘价和收盘价可以分别被视为当天的起始销售额和结束销售额。虽然影线的计算常常与股票和其他金融产品关联,但其背后的逻辑和分析方法可以应用于任何具有开、高、低、闭四个关键数据点的情境中。

  • 基础时间特征(Time Features)

基础时间特征是从时间序列中数据点的时间戳派生的简单特征。可以使用下面的 timeseries_feature 函数来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def timeseries_feature(timeseris):
""":params timeseris: 必须是时间戳格式才能调用dt方法"""
features_new = pd.DataFrame([])
cols_name = timeseris.name

# 提取年份、月份和日期
features_new[cols_name + '_year'] = timeseris.dt.year
features_new[cols_name + '_month'] = timeseris.dt.month
features_new[cols_name + '_day'] = timeseris.dt.day

# 提取自然周期
features_new[cols_name + '_quarter'] = timeseris.dt.quarter
features_new[cols_name + '_weekofyear'] = timeseris.dt.weekofyear
features_new[cols_name + '_dayofweek'] = timeseris.dt.dayofweek
# features_new[cols_name + '_weekend'] = (features_new[cols_name + '_dayofweek'] >= 5).astype(int)

return features_new

通过上面的timeseries_feature函数可以为数据衍生出如周、月、季度、年等时间单位特征,这对于分析季节性和其他基于时间的模式非常有帮助,具体体现在:

  1. 年、月、日:这三个特征可以帮助模型理解数据中的长期趋势、季节性变化以及一个月中某些日子可能存在的模式。例如,零售业销售数据可能会在特定的月份、季度或某些日期(比如黑色星期五)上升。

  2. 季度:许多业务活动都是按季度进行的,例如公司的季度报告。这个特征可以帮助模型捕捉这些季度效应。

  3. 一年中的第几周:这可以捕捉到一年中某些周的特定模式,例如假期购物季。

  4. 一周中的第几天:某些事件或模式可能在一周的某些天出现,比如周末或周中。例如,周五的电影票销售可能会比周三的多。

  5. 是否为周末:与“一周中的第几天”类似,但更加明确地标注了是否为周末。对于一些业务,周末可能与工作日有非常大的不同,例如餐馆的就餐量或旅游景点的参观量。(股票在周末和节假日不会交易,所以注释掉了)

看起来如此基础的时间戳衍生,为什么能够有效果?在哪些场景下可能会有何种效果?

  1. 季节性:许多现象都会受到季节性的影响,例如零售销售通常在节假日期间增加,而能源需求可能在冬季或夏季达到高峰。通过提取月份或季度信息,我们可以帮助模型捕捉到这些季节性趋势。

  2. 趋势分析:提取年份可以帮助模型识别长期趋势。例如,一个产业的产量可能随着时间的推移而逐渐增长或减少。

  3. 周内效应:一些现象在一周中的不同天有不同的表现。例如,餐馆在周末可能比工作日更加忙碌,股票市场在周一可能比其他工作日有不同的行为。通过提取“dayofweek”特征,我们可以捕捉到这种周期性变化。

  4. 特定事件:有时某些特定的日期或月份可能与特定的事件或活动相关联。例如,学校的开学月、大型促销或节假日。这些日期可能会对目标变量产生影响,因此提取这些基本特征可能很有用。

  5. 模型要求提供周期性因子:一些模型,如Facebook的Prophet,要求明确地提供日、周和年的周期性信息来帮助预测。

  6. 为交叉衍生作准备:时间特征也可以与其他特征组合来创建交叉特征。例如,将月份和商品类别组合,可以帮助模型更好地理解在特定月份哪些商品的销售可能会增加。

基础时间特征的衍生在许多项目中都是标准的第一步,因为它们通常能为模型提供大量有关数据的上下文信息。

1.4 应用于当前数据集

首先将上述时序特征工程手段封装在一个函数里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def create_feature(df_temp:pd.DataFrame,periods:Union[List,Tuple,Iterable])->pd.DataFrame:
'''
:param df_temp: pd.DataFrame, 输入的数据框,其中需要包含金融时间序列数据。
数据框需要至少包含日期、股票代码、收盘价、调整收盘价、最高价、最低价等列。
其中日期会被用于排序,股票代码用于分组操作,而其他列用于特征工程。

:param periods: Union[List, Tuple, Iterable], 一个可迭代的对象(如列表、元组等),包含了想要用于计算历史数据平移和窗口统计的时间段。
例如,如果想要考虑过去5天、10天和20天的数据,那么periods就应该是[5, 10, 20]。

:return: pd.DataFrame, 增加了新特征的dataframe。
'''
df = df_temp.copy()
df.sort_values(by='Date',inplace=True)
key_col = 'SecuritiesCode'

# 历史平移
for period in periods:
df.loc[:,f'history_close_{period}']=df.groupby(key_col)['Close'].shift(period)

#窗口统计
for stat in ['mean', 'std', 'max', 'min', 'median']:
df.loc[:,f'window_close_{period}_{stat}'] = df.groupby(key_col)['AdjustedClose'].rolling(window=period).agg(stat).values

# max-min feature
df.loc[:,f'window_close_{period}_max-min'] = df[f'window_close_{period}_max']-df[f'window_close_{period}_min'].values
# 波动率
avg_value = (df[f'window_close_{period}_max'] + df[f'window_close_{period}_min']) / 2
df.loc[:,f'window_close_{period}_fluctuation_rate'] = df[f'window_close_{period}_max-min'] / avg_value

#增长率特征
df.loc[:,f'window_close{period}_pctchange'] = df.groupby(key_col)['AdjustedClose'].pct_change(period).values

# diff 特征
df.loc[:,f'window_close{period}_diff'] = df.groupby(key_col)['AdjustedClose'].diff(period).values
df.loc[:,f"Volatility_{period}Day"] = np.log(df['AdjustedClose']).groupby(df[key_col]).diff().rolling(period).std()

# 扩张窗口特征
df.loc[:,f"ExpMovingAvg_{period}Day"] = df.groupby(key_col)['AdjustedClose'].ewm(span=period,adjust=False).mean().values

# 尝试使用times参数,衰减因子只能选择halflife,这里我们设置为5D(天)【5天为一周】
# 发现效果并不好
# times = pd.to_datetime(df['Date'])
# df.loc[:,"ExpMovingAvg_{}Day".format(period)] = df.groupby(key_col)['AdjustedClose'].ewm(times=times,halflife='5D').mean().values

df.loc[:,f'expand_mean'] = df.groupby(key_col)['Close'].expanding().mean().values

df['Upper_Shadow'] = df['High'] - df[['Close', 'Open']].max(axis=1)
df['Lower_Shadow'] = df[['Close', 'Open']].min(axis=1) - df['Low']


# 基础时间特征
time_feature = timeseries_feature(df['Date'])
return pd.concat((df,time_feature),axis=1)
1
2
3
stock = pd.read_csv('/kaggle/input/jpx-tokyo-stock-exchange-prediction/train_files/stock_prices.csv')
stock_list = pd.read_csv('/kaggle/input/jpx-tokyo-stock-exchange-prediction/stock_list.csv')
stock_list.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
SecuritiesCode EffectiveDate Name Section/Products NewMarketSegment 33SectorCode 33SectorName 17SectorCode 17SectorName NewIndexSeriesSizeCode NewIndexSeriesSize TradeDate Close IssuedShares MarketCapitalization Universe0
0 1301 20211230 KYOKUYO CO.,LTD. First Section (Domestic) Prime Market 50 Fishery, Agriculture and Forestry 1 FOODS 7 TOPIX Small 2 20211230.0 3080.0 1.092828e+07 3.365911e+10 True
1 1305 20211230 Daiwa ETF-TOPIX ETFs/ ETNs NaN - - - - - - 20211230.0 2097.0 3.634636e+09 7.621831e+12 False
2 1306 20211230 NEXT FUNDS TOPIX Exchange Traded Fund ETFs/ ETNs NaN - - - - - - 20211230.0 2073.5 7.917718e+09 1.641739e+13 False
3 1308 20211230 Nikko Exchange Traded Index Fund TOPIX ETFs/ ETNs NaN - - - - - - 20211230.0 2053.0 3.736943e+09 7.671945e+12 False
4 1309 20211230 NEXT FUNDS ChinaAMC SSE50 Index Exchange Trade... ETFs/ ETNs NaN - - - - - - 20211230.0 44280.0 7.263200e+04 3.216145e+09 False
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 定义为数据进行预处理的函数
def preprocess(data):
'''
对训练集数据进行处理:
将日期转换为datetime格式;
使用0来填充ExpectedDividend和Target中的缺失值;
将SupervisionFlag从布尔型转换为整数型;
填充Open、High、Low和Close列中的缺失值;
'''
# 将日期从字符串格式转换为datetime格式
# data['Date'] = pd.to_datetime(data['Date'])
# 使用0来填充ExpectedDividend和Target中的缺失值
# 因为我们这个函数可能会同时在不同的特征工程下被调用
# 比如测试集就没有Target,所以使用try-except 语句避免报错
try:
data['ExpectedDividend'] = data['ExpectedDividend'].fillna(0)
except:
pass
try:
data['Target'] = data['Target'].fillna(0)
except:
pass

# 将SupervisionFlag从布尔型转换为整数型(True转为1,False转为0)
data["SupervisionFlag"] = data["SupervisionFlag"].astype(int)

# 填充Open、High、Low和Close列中的缺失值
cols = ['Open', 'High', 'Low', 'Close']
data.loc[:,cols] = data.loc[:,cols].ffill().bfill()

# 删除不需要的列RowId
data = data.drop(['RowId'], axis=1)
return data
1
2
# 17行业的取值情况
stock_list['17SectorName'].value_counts()
IT & SERVICES, OTHERS                           1155
-                                                500
RETAIL TRADE                                     339
COMMERCIAL & WHOLESALE TRADE                     311
CONSTRUCTION & MATERIALS                         303
ELECTRIC APPLIANCES & PRECISION INSTRUMENTS      293
RAW MATERIALS & CHEMICALS                        288
MACHINERY                                        228
REAL ESTATE                                      137
FOODS                                            136
TRANSPORTATION & LOGISTICS                       117
AUTOMOBILES & TRANSPORTATION EQUIPMENT           108
FINANCIALS (EX BANKS)                             88
BANKS                                             81
STEEL & NONFERROUS METALS                         77
PHARMACEUTICAL                                    73
IT & SERVICES, OTHERS                             33
ELECTRIC POWER & GAS                              25
RETAIL TRADE                                      20
ENERGY RESOURCES                                  17
CONSTRUCTION & MATERIALS                          13
ELECTRIC APPLIANCES & PRECISION INSTRUMENTS       12
BANKS                                             12
COMMERCIAL & WHOLESALE TRADE                       9
FINANCIALS (EX BANKS)                              9
REAL ESTATE                                        6
RAW MATERIALS & CHEMICALS                          6
AUTOMOBILES & TRANSPORTATION EQUIPMENT             6
PHARMACEUTICAL                                     4
ENERGY RESOURCES                                   3
STEEL & NONFERROUS METALS                          3
MACHINERY                                          2
TRANSPORTATION & LOGISTICS                         2
FOODS                                              1
Name: 17SectorName, dtype: int64
1
2
3
4
5
6
7
8
9
# 预处理股票数据集
stock = preprocess(stock)
# 行业可能会对股票有影响,所以将行业也加入特征进行建模
# 将17SectorName进行labelencoder
stock_list['17SectorName'] = LabelEncoder().fit_transform(stock_list['17SectorName'])
# 并入主表
price = stock.merge(stock_list[['SecuritiesCode','17SectorName']],on='SecuritiesCode')
# 尝试删除股息建模
price.drop(columns='ExpectedDividend',inplace=True)
1
2
# 调整股价
price = adjust_price(price) # 包含了对 'Data' 字段的类型转换
1
2
# 使用工作日作为窗口
new_feature = create_feature(price,periods=[5,10,20,30,40,50,60,70])
/opt/conda/lib/python3.7/site-packages/ipykernel_launcher.py:13: FutureWarning: Series.dt.weekofyear and Series.dt.week have been deprecated.  Please use Series.dt.isocalendar().week instead.
  del sys.path[0]

关于窗口大小的选择

什么时候该使用哪种窗口大小,这取决于以下因素:

  1. 目标的时间尺度:如果你关心的是短期的价格动态,那么短的窗口大小可能更有用。反之,如果关心的是长期趋势,那么长的窗口大小可能更合适。
  2. 数据的频率:对于高频数据(如每分钟或每小时的数据),以天为单位的窗口就会失去意义。例如,对于分钟级的金融数据,可能会选择5分钟、10分钟、30分钟、1小时等作为窗口大小,以捕捉到短期内的价格动态。而对于小时级数据,可能会选择2小时、4小时、12小时等作为窗口大小。
  3. 模型的复杂性:使用过多的窗口大小可能会导致特征的维度过高,这可能会使模型过拟合。需要根据模型的复杂性和可用数据的量来选择合适的窗口大小。
  4. 实验与验证:最佳的窗口大小可能需要通过交叉验证或其他模型验证技术来确定。在公司实际的业务中,技术组都会积累自己的技术经验,而作为竞赛,我们需要根据我们对数据业务层面的理解选择窗口,例如,在金融领域,5、10、20、30、50、200天是经常被使用的滚动窗口大小,它们具有很强的传统和经验依据。例如,20天大致对应一个月的交易日(去掉周末和节加日),50天和200天是许多技术分析师用于短期和长期移动平均线的标准。
1
new_feature.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date SecuritiesCode Open High Low Close Volume AdjustmentFactor SupervisionFlag Target ... ExpMovingAvg_70Day expand_mean Upper_Shadow Lower_Shadow Date_year Date_month Date_day Date_quarter Date_weekofyear Date_dayofweek
0 2017-01-04 1301 2734.0 2755.0 2730.0 2742.0 31400 1.0 0 0.000730 ... 2742.000000 2742.0 13.0 4.0 2017 1 4 1 1 2
1861349 2017-01-04 8185 2800.0 2847.0 2785.0 2846.0 57500 1.0 0 0.015827 ... 2741.887324 2740.0 1.0 15.0 2017 1 4 1 1 2
285188 2017-01-04 2698 1770.0 1783.0 1764.0 1778.0 45700 1.0 0 0.005062 ... 2741.834160 2740.0 5.0 6.0 2017 1 4 1 1 2
1860147 2017-01-04 8182 1482.0 1512.0 1482.0 1510.0 50700 1.0 0 0.000649 ... 2742.007846 2742.0 2.0 0.0 2017 1 4 1 1 2
286390 2017-01-04 2702 3090.0 3090.0 3050.0 3060.0 384900 1.0 0 0.000000 ... 2742.092132 2742.6 0.0 10.0 2017 1 4 1 1 2

5 rows × 118 columns

1
2
3
4
5
6
7
8
9
10
# 增加弱评估器的个数到700,减小学习率为0.01
# 没有精调参,这里和下面的特征工程都使用同一套参数
params = {'n_estimators': 700,
"learning_rate": 0.01,
'random_state': 1412
}
X = new_feature.drop(columns=['Date','SecuritiesCode','Target'],axis=1)
y = new_feature.Target

gbm = LGBMRegressor(**params).fit(X.drop(['Close'],axis=1),y)

2 行业分段建模 Industry Segmented Modeling

在金融市场中,不同的行业具有独特的特点和动态变化模式(请看下面每个行业每天的平均收盘价的图)。对于投资者和分析师来说,理解并应对这些差异至关重要。行业分段建模是一个专门设计来解决这个问题的方法。

此方法采用一个简单但强大的思路:为每一个行业构建独立的模型,确保每个模型可以专注于学习并预测该行业的特定特点和趋势。这种方法提供了三个主要的优势:

  • 专业性: 每个行业的模型都是为了捕捉该行业的独特特点和规律而训练的。这确保了模型不会受到其他行业动态的干扰,提高了预测的准确性。虽然所有行业都受到宏观经济的影响,但影响的程度和方式可能会有所不同。例如,利率上升可能对房地产和金融行业的股票产生不同的影响。单独为每个行业建模可以更好地捕捉行业内部的这些特定动态。

  • 灵活性: 由于每个行业都有其独立的模型,当某个行业发生重大变化或面临新的挑战时,可以单独调整或重新训练该行业的模型,而不会影响到其他行业。

  • 模型复杂性:当我们为每个行业单独建模时,模型可能不需要太复杂,因为它只需考虑特定行业的特点,而不是整个市场的复杂性。

行业分段建模不仅仅是一个技术方法,更是一种思维方式。它鼓励我们从宏观和微观的角度同时考虑问题,确保我们的策略和决策既有广度又有深度。

值得一提的是,这种策略并不是只适用于股票数据集,这种思维还可以应用到别的行业当中:

  1. 气候建模:气候模型可能会独立地为不同的地理区域或气候带进行建模,因为每个区域的气候驱动因素可能会有所不同。

  2. 零售与销售预测:大型零售商可能会为不同的产品类别、地区或季节分别进行销售预测。

  3. 网站流量预测:对于大型网站,流量预测可能会基于网站的不同板块(新闻、论坛、博客、产品页)或不同用户群体进行。

  4. 医疗研究:在研究某种疾病或治疗方法时,可能会单独考虑不同的人群、年龄组或性别。例如,某些疾病仅在男性或女性中更为常见;同样,某些药物或治疗对于男性和女性的效果可能不同。

1
2
3
4
# 本地运行代码
train_df = pd.read_csv('train_files/stock_prices.csv')
test_df = pd.read_csv('supplemental_files/stock_prices.csv')
stock_list_df = pd.read_csv('stock_list.csv')
1
2
train_df = train_df.merge(stock_list_df[['SecuritiesCode','17SectorName']],on='SecuritiesCode')
train_df.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
RowId Date SecuritiesCode Open High Low Close Volume AdjustmentFactor ExpectedDividend SupervisionFlag Target 17SectorName
0 20170104_1301 2017-01-04 1301 2734.0 2755.0 2730.0 2742.0 31400 1.0 NaN False 0.000730 FOODS
1 20170105_1301 2017-01-05 1301 2743.0 2747.0 2735.0 2738.0 17900 1.0 NaN False 0.002920 FOODS
2 20170106_1301 2017-01-06 1301 2734.0 2744.0 2720.0 2740.0 19900 1.0 NaN False -0.001092 FOODS
3 20170110_1301 2017-01-10 1301 2745.0 2754.0 2735.0 2748.0 24200 1.0 NaN False -0.005100 FOODS
4 20170111_1301 2017-01-11 1301 2748.0 2752.0 2737.0 2745.0 9300 1.0 NaN False -0.003295 FOODS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 按照行业分组并计算每个行业每天的平均收盘价
grouped = adjust_price(train_df).groupby(['Date', '17SectorName'])['AdjustedClose'].mean().reset_index()

# 创建一个画布和子图
fig, ax = plt.subplots(figsize=(30, 12))

# 对于每个行业,绘制平均收盘价随时间变化的折线图
for sector in grouped['17SectorName'].unique()[:10]:
data = grouped[grouped['17SectorName'] == sector]
ax.plot(data['Date'][-300:], data['AdjustedClose'][-300:], label=sector)

# 添加图例和标签
ax.legend()
ax.set_xlabel('Date')
ax.set_ylabel('Average Close Price')

# 显示图表
plt.show()
![png](JPX Tokyo Stock Exchange Prediction/output_149_0.png)
1
2
3
4
5
# 从文件中读取数据
train_df = pd.read_csv('../input/jpx-tokyo-stock-exchange-prediction/train_files/stock_prices.csv')
# 这里我们把补充文件命名为test_df,如果想要调参的话建议使用test_df作为验证集
test_df = pd.read_csv('../input/jpx-tokyo-stock-exchange-prediction/supplemental_files/stock_prices.csv')
stock_list_df = pd.read_csv('../input/jpx-tokyo-stock-exchange-prediction/stock_list.csv')
1
2
# 查看股票列表数据集
stock_list_df.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
SecuritiesCode EffectiveDate Name Section/Products NewMarketSegment 33SectorCode 33SectorName 17SectorCode 17SectorName NewIndexSeriesSizeCode NewIndexSeriesSize TradeDate Close IssuedShares MarketCapitalization Universe0
0 1301 20211230 KYOKUYO CO.,LTD. First Section (Domestic) Prime Market 50 Fishery, Agriculture and Forestry 1 FOODS 7 TOPIX Small 2 20211230.0 3080.0 1.092828e+07 3.365911e+10 True
1 1305 20211230 Daiwa ETF-TOPIX ETFs/ ETNs NaN - - - - - - 20211230.0 2097.0 3.634636e+09 7.621831e+12 False
2 1306 20211230 NEXT FUNDS TOPIX Exchange Traded Fund ETFs/ ETNs NaN - - - - - - 20211230.0 2073.5 7.917718e+09 1.641739e+13 False
3 1308 20211230 Nikko Exchange Traded Index Fund TOPIX ETFs/ ETNs NaN - - - - - - 20211230.0 2053.0 3.736943e+09 7.671945e+12 False
4 1309 20211230 NEXT FUNDS ChinaAMC SSE50 Index Exchange Trade... ETFs/ ETNs NaN - - - - - - 20211230.0 44280.0 7.263200e+04 3.216145e+09 False
1
2
3
# 选择股票列表中属于"Universe0"(股票池)的股票
selected_stocks = stock_list_df[stock_list_df["Universe0"]]
sec_info = selected_stocks[["SecuritiesCode", "33SectorName", "17SectorName"]]
1
sec_info.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
SecuritiesCode 33SectorName 17SectorName
0 1301 Fishery, Agriculture and Forestry FOODS
19 1332 Fishery, Agriculture and Forestry FOODS
20 1333 Fishery, Agriculture and Forestry FOODS
37 1375 Fishery, Agriculture and Forestry FOODS
38 1376 Fishery, Agriculture and Forestry FOODS
定义给第二套特征工程用的预处理函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 和时序特征工程类似,但是需要将股票信息的数据并入股价数据集
def preprocess_data(data):
"""预处理函数"""
data = preprocess(data)
# 将数据与股票信息合并
data = pd.merge(data, sec_info, on="SecuritiesCode")
# 将股票行业转化为分类数据,因为要加入lightgbm建模
# lightgbm接收类别型category变量(尝试不同的特征工程思路)
data["33SectorName"] = data["33SectorName"].astype("category")
data["17SectorName"] = data["17SectorName"].astype("category")
# 前向填充Target列的空值
#(preprocess已经处理过,这里是想给小伙伴们展示update的用法)
try:
# 使用update更新dataframe,可以看下面的代码介绍
data.update(data.groupby("SecuritiesCode")["Target"].fillna(0))
# 等价于下面的代码
# data['Target']=(data.groupby("SecuritiesCode")["Target"].fillna(0))
except:
pass

return data

update介绍

update 函数用于根据另一个 DataFrame 来更新原始 DataFrame 中的数据。它会根据提供的 DataFrame 中的索引和列名来更新原始 DataFrame 中对应的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建原始 DataFrame
df1 = pd.DataFrame({'A': [1, 2, 3],
'B': [4, 5, 6],
'C': [7, 8, 9]})

# 创建用于更新的 DataFrame
df2 = pd.DataFrame({'B': [40, 50, 60],
'C': [70, 80, 90]})

# 使用 update 函数更新 df1
df1.update(df2)

# 显示更新后的 df1
print(df1)
   A   B   C
0  1  40  70
1  2  50  80
2  3  60  90
1
2
# 对数据进行预处理
train_df = preprocess_data(train_df)

模型融合的目标是通过结合多个模型来捕捉它们的优势和补偿它们的不足,从而提高整体的预测性能。融合多个相似的模型可能不会带来太大的性能提升,因为它们可能会重复相同的错误。为了最大化模型融合的潜力,模型之间应该存在一定的差异性。有几种策略可以增加模型的差异性:

  1. 模型结构的差异性:选择原理或结构差异大的模型进行融合,可以帮助捕捉数据中的不同信息。例如,LightGBM 是基于树的模型,它能够捕捉非线性关系。而线性回归则关注数据的线性关系。结合这两者,可以提高模型的泛化性能。

  2. 特征工程的差异性:即使我们使用相同的模型,如多个 LightGBM 实例,通过为它们提供不同的特征集也可以引入差异性。这种方法的思想是,不同的特征集可能会揭示数据的不同方面,从而使模型对数据有不同的理解。

  3. 超参数的差异性:对于同一模型,使用不同的超参数设置也会产生不同的模型。例如,不同的树深度、学习率或正则化项都可以为模型引入差异。

基于上面的思路,我们可以在第二套特征工程上尝试对数据进一步进行探索和尝试:

对于时序数据,并不总是需要使用尽可能长时间的数据。如果目标是捕捉长期趋势,那么使用更长的时间段会更有意义。如果我们更关心的是短期的变化或波动,那么最近的数据可能更为相关(当前目标也就是预测未来三个月某一天的收益率)。因此,我们可以尝试选取不同时长的时序数据,以时间作为一个切入点。

1
2
# 并且我们在探索的过程中发现核心股价数据集的日期有一些特点
train_df['Date'].value_counts().sort_index()
2017-01-04    1865
2017-01-05    1865
2017-01-06    1865
2017-01-10    1865
2017-01-11    1865
              ... 
2021-11-29    2000
2021-11-30    2000
2021-12-01    2000
2021-12-02    2000
2021-12-03    2000
Name: Date, Length: 1202, dtype: int64

思考: 日期的取值情况是什么意思?为什么17年的时候只有1865?这1865是什么含义?

对数字敏感的小伙伴可能会立马注意到,2000就是股票池里的股票的个数。按道理来说,每天都应该有2000支股票的数据才对,为什么会出现小于2000的情况? —— 回顾jpx构建股票池的规则:市值排名前2000的股票,而且这些股票必须在交易所上市至少一年。

可是这个“一年”到底是什么时候开始计算的呢?可以看到核心股价数据集的最新日期为2021-12-03,如果往前推一年的话应该是2020-12-03。也就是说,2020-12-03开始,股票池中2000支股票的数据就应该都被记录在核心股价数据集中。下面我们来验证我们的想法:

1
2
# 选取2020-12-03之后的某一天,我们发现虽然已经很接近2000了,但是还是差一些
train_df[train_df.Date=='2020-12-18'].SecuritiesCode.nunique()
1996

我们换一种思路:

1
2
3
4
5
6
7
# 计算Date字段取值出现次数
value_counts = train_df['Date'].value_counts()

# 筛选出取值出现次数小于2000的数据,存在result变量中
result = train_df[train_df['Date'].isin(value_counts[value_counts < 2000].index)]
# 查看前五行
result.head()
.dataframe thead th {
    text-align: right;
}
RowId Date SecuritiesCode Open High Low Close Volume AdjustmentFactor ExpectedDividend SupervisionFlag Target
0 20170104_1301 2017-01-04 1301 2734.0 2755.0 2730.0 2742.0 31400 1.0 NaN False 0.000730
1 20170104_1332 2017-01-04 1332 568.0 576.0 563.0 571.0 2798500 1.0 NaN False 0.012324
2 20170104_1333 2017-01-04 1333 3150.0 3210.0 3140.0 3210.0 270800 1.0 NaN False 0.006154
3 20170104_1376 2017-01-04 1376 1510.0 1550.0 1510.0 1550.0 11300 1.0 NaN False 0.011053
4 20170104_1377 2017-01-04 1377 3270.0 3350.0 3270.0 3330.0 150800 1.0 NaN False 0.003026
1
2
# 查看后五行
result.tail()
.dataframe thead th {
    text-align: right;
}
RowId Date SecuritiesCode Open High Low Close Volume AdjustmentFactor ExpectedDividend SupervisionFlag Target
1868526 20201222_9990 2020-12-22 9990 533.0 533.0 524.0 525.0 97900 1.0 NaN False 0.009634
1868527 20201222_9991 2020-12-22 9991 976.0 976.0 951.0 960.0 19100 1.0 NaN False 0.010460
1868528 20201222_9993 2020-12-22 9993 1778.0 1782.0 1763.0 1777.0 2000 1.0 NaN False -0.001674
1868529 20201222_9994 2020-12-22 9994 2167.0 2170.0 2153.0 2165.0 5300 1.0 NaN False 0.001856
1868530 20201222_9997 2020-12-22 9997 1013.0 1017.0 981.0 981.0 266400 1.0 NaN False 0.045918
1
2
# 查看不到2000支股票的最后一个交易日
result.Date.max()
'2020-12-22'
1
2
# 查看不到2000支股票的最早的交易日
result.Date.min()
'2017-01-04'

也就是说,2020-12-23以后的数据都会包含股票池里完整的2000支股票(猜对了年月,没有猜对具体日子),在此之前有些股票的数据都是缺失的,所以我们尝试选取包含完整股票池所有股票信息的数据进行建模。

1
train_df[train_df.Date=='2020-12-23'].SecuritiesCode.nunique()
2000
1
2
3
4
5
6
7
8
# 所以我们筛选有2000支股票数据
train_df = train_df[train_df.Date>='2020-12-23']
# 预处理补充股价数据集
test_df = preprocess_data(test_df)

# 将训练数据和测试数据合并
train_df = pd.concat([train_df, test_df])
train_df.head()
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
Date SecuritiesCode Open High Low Close Volume AdjustmentFactor ExpectedDividend SupervisionFlag Target 33SectorName 17SectorName
970 2020-12-23 1301 2913.0 2920.0 2906.0 2913.0 6300 1.0 0.0 0 -0.000343 Fishery, Agriculture and Forestry FOODS
971 2020-12-24 1301 2913.0 2937.0 2909.0 2917.0 13900 1.0 0.0 0 0.011660 Fishery, Agriculture and Forestry FOODS
972 2020-12-25 1301 2903.0 2930.0 2903.0 2916.0 8300 1.0 0.0 0 0.003729 Fishery, Agriculture and Forestry FOODS
973 2020-12-28 1301 2929.0 2950.0 2910.0 2950.0 22900 1.0 0.0 0 -0.003377 Fishery, Agriculture and Forestry FOODS
974 2020-12-29 1301 2948.0 2963.0 2945.0 2961.0 15100 1.0 0.0 0 -0.000339 Fishery, Agriculture and Forestry FOODS
经过前面的数据预处理,我们需要准备开始建模。回顾之前的建模策略:**行业分段建模**。这种策略的核心思想是,为每一个行业独立地训练一个模型,这样可以更精确地捕获每个行业特有的数据模式。

为了实现这个策略,我们选择了LightGBM作为基础模型,因为它在很多实际应用中都表现出色。但是,标准的LightGBM库并不直接支持行业分段建模。因此,我们决定设计一个自定义类,使得行业分段建模变得简单和直观。

这个自定义类的目标是:

  1. 提供与LightGBM类似的接口,以便于使用。例如,我们可以使用model.train进行训练,使用model.predict进行预测。
  2. 在调用这些方法时,自动实现行业分段建模。也就是说,当我们调用model.train时,该类会自动为每个行业训练一个独立的模型;当我们调用model.predict时,它会使用相应的模型为每个行业进行预测。
    下面是实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 使用LightGBM模型进行训练和预测
class LGBMHierarchModel():
# 创建构造函数,初始化实例的属性
def __init__(self, seed=1412):
# 设置随机数种子
self.seed = seed
# 设置训练模型的参数
self._best_found_params = {
"learning_rate": 0.01,
"n_estimators": 700,
}
# 创建一个空字典,用来存储训练好的模型
self.models = {}

# 分层训练部分代码
def train(self, train: pd.DataFrame):
"""训练函数"""
# 循环遍历每一个行业
for name, group in train.groupby("33SectorName"):
y = group["Target"]

# 删除不需要的列
X = group.drop(["Target", "Date", "SecuritiesCode"], axis=1)
# 实例化lightgbm
model = LGBMRegressor(**self._best_found_params)
# 使用fit接口进行训练
model.fit(X, y, verbose=False)
# 保存模型
self.models[name] = model

# 分层预测代码,不理解的话请看下面的示例代码
def predict(self, test: pd.DataFrame):
"""预测函数"""
y_preds = []
for name, group in test.groupby("33SectorName"):
sec_codes = group["SecuritiesCode"]
X_test = group.drop(["Date", "SecuritiesCode"], axis=1)
y_pred = self.models[name].predict(X_test)
y_preds.extend(list(zip(sec_codes, y_pred)))
df = pd.DataFrame(y_preds, columns=["codes", "pred"])
return df.sort_values("codes", ascending=True)["pred"].values

predict部分详解

1
2
3
4
5
6
7
# 创建一个示例数据集
df = pd.DataFrame({
'33SectorName': ['Services', 'Services', 'Foods', 'Foods'],
'SecuritiesCode': [1301, 1302, 1303, 1304],
'feature': [5, 6, 7, 8]
})
df
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
33SectorName SecuritiesCode feature
0 Services 1301 5
1 Services 1302 6
2 Foods 1303 7
3 Foods 1304 8
1
2
3
# 如果我们直接对训练集按照行业分组的话,返回的对象是一个DataFrameGroupBy 对象
# 它是一个可迭代的对象,包含了分组后的数据
df.groupby("33SectorName")
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x000001E731047188>
1
2
3
4
5
6
# 既然是可迭代对象,我们就可以使用for循环取出
# 第一个元素是组的名称(33SectorName的唯一值)
# 而group变量表示该组对应的dataframe
for name, group in df.groupby("33SectorName"):
print(f"Group: {name}")
print(group)
Group: Foods
  33SectorName  SecuritiesCode  feature
2        Foods            1303        7
3        Foods            1304        8
Group: Services
  33SectorName  SecuritiesCode  feature
0     Services            1301        5
1     Services            1302        6
1
y_pred = np.array([ 0.00113337, -0.01450866,  0.01365594,  0.00297457])
1
2
sec_codes = group['SecuritiesCode']
sec_codes
0    1301
1    1302
Name: SecuritiesCode, dtype: int64
1
2
3
# 接受两个或多个可迭代对象作为参数,并返回一个迭代器
# 该迭代器生成一系列元组,其中每个元组包含来自每个可迭代对象的元素
zip(sec_codes, y_pred)
<zip at 0x1e731121948>
1
list(zip(sec_codes, y_pred))
[(1301, 0.00113337), (1302, -0.01450866)]
1
2
3
4
y_preds = []
# 使用extend在列表的末尾追加元素
y_preds.extend(list(zip(sec_codes, y_pred)))
y_preds
[(1301, 0.00113337), (1302, -0.01450866)]
1
pd.DataFrame(y_preds, columns=["codes", "pred"])
.dataframe tbody tr th {
    vertical-align: top;
}

.dataframe thead th {
    text-align: right;
}
codes pred
0 1301 0.001133
1 1302 -0.014509
1
2
3
4
# 实例化模型并训练
model = LGBMHierarchModel()
# 使用调整之后的股价数据进行建模,并且丢弃掉原来的收盘价
model.train(adjust_price(train_df).drop('Close',axis=1))
/opt/conda/lib/python3.7/site-packages/lightgbm/sklearn.py:736: UserWarning: 'verbose' argument is deprecated and will be removed in a future release of LightGBM. Pass 'log_evaluation()' callback via 'callbacks' argument instead.
  _log_warning("'verbose' argument is deprecated and will be removed in a future release of LightGBM. "

如果我不想使用lightgbm模型进行训练怎么办?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 使用LinearRegression模型进行训练和预测
class RegHierarchModel():
def __init__(self, seed=1412):
self.seed = seed
self.models = {}


def train(self, train: pd.DataFrame):
"""训练函数""" # 下面可以换成自己喜欢的类别型变量
for name, group in train.groupby("33SectorName"):
y = group["Target"]
X = group.drop(["Target", "Date", "SecuritiesCode"], axis=1)
model = LinearRegression() # 在这里可以换成自己喜欢的模型
model.fit(X, y)
self.models[name] = model


def predict(self, test: pd.DataFrame):
"""预测函数"""
y_preds = [] # 上面训练如果换了字段,这里记得保持一致
for name, group in test.groupby("33SectorName"):
sec_codes = group["SecuritiesCode"]
X_test = group.drop(["Date", "SecuritiesCode"], axis=1)
y_pred = self.models[name].predict(X_test)
y_preds.extend(list(zip(sec_codes, y_pred)))
df = pd.DataFrame(y_preds, columns=["codes", "pred"])
return df.sort_values("codes", ascending=True)["pred"].values
1
# train_df
1
2
3
4
5
6
7
8
# 实例化模型并训练
model_Reg = RegHierarchModel()
# 因为线性回归不支持类别型变量,这里需要对类别型变量进行编码
temp_df = adjust_price(train_df).drop('Close',axis=1)
temp_df['33SectorName'], _ = pd.factorize(temp_df['33SectorName'])
temp_df['17SectorName'], _ = pd.factorize(temp_df['17SectorName'])

model_Reg.train(temp_df)

三、模型融合与提交

加入线性模型抗过拟合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 定义为训练数据进行特征工程的函数
def featuring(data):
'''
对训练集数据进行处理:
将日期转换为datetime格式;
使用0来填充ExpectedDividend和Target中的缺失值;
将SupervisionFlag从布尔型转换为整数型;
填充Open、High、Low和Close列中的缺失值;
删除了不需要的RowId列。
'''
# 将日期从字符串格式转换为datetime格式
data['Date'] = pd.to_datetime(data['Date'])
# 使用0来填充ExpectedDividend和Target中的缺失值
data['ExpectedDividend'] = data['ExpectedDividend'].fillna(0)
try:
data['Target'] = data['Target'].fillna(0)
except:
pass
# 将SupervisionFlag从布尔型转换为整数型(True转为1,False转为0)
data["SupervisionFlag"] = data["SupervisionFlag"].astype(int)

# 填充Open、High、Low和Close列中的缺失值
cols = ['Open', 'High', 'Low', 'Close']
data.loc[:,cols] = data.loc[:,cols].ffill().bfill()

# 删除不需要的列RowId
# data = data.drop(['RowId','SecuritiesCode'], axis=1) # 0.407
data = data.drop(['RowId'], axis=1) # 0.472

# 上面二选一即可,一般来说,唯一的ID列(标识符)没有实际意义,是不会作为特征加入训练的
# 因为股票代码本身不包含与股票价格或收益率相关的任何实际信息。
# 然而,在当前的数据集上保留SecuritiesCode成绩会更高,或许这是因为刚好过拟合到了预测数据上去了(在最后的总结那里会再提到)
# 但不管选哪个,本套特征工程都是超越冠军方案的
return data
1
2
3
4
5
6
7
8
9
10
11
12
13
# 加载数据集

# 读取主要的股价数据
stock_prices = pd.read_csv("../input/jpx-tokyo-stock-exchange-prediction/train_files/stock_prices.csv")
# 读取补充的主要股价数据
supplemental_prices = pd.read_csv("../input/jpx-tokyo-stock-exchange-prediction/supplemental_files/stock_prices.csv")

secondary_stock_prices = pd.read_csv("../input/jpx-tokyo-stock-exchange-prediction/train_files/secondary_stock_prices.csv")
supplemental_secondary_stock_prices = pd.read_csv("../input/jpx-tokyo-stock-exchange-prediction/supplemental_files/secondary_stock_prices.csv")
# 合并所有的股价数据
stock_prices = stock_prices.append(secondary_stock_prices)
stock_prices = stock_prices.append(supplemental_prices)
stock_prices = stock_prices.append(supplemental_secondary_stock_prices)
1
2
3
4
5
6
7
# 对数据进行特征工程
data = featuring(stock_prices)
# 删除不需要的Date列,得到训练数据集
data_train = data.drop(['Date'], axis=1)
# 从数据中分离学习特征和目标变量
X_train = data_train.drop(['Target'], axis=1)
y_train = data_train['Target']
1
2
3
4
5
6
7
8
9
# Instantiation and Learning
reg_model = LinearRegression()
from sklearn.preprocessing import MinMaxScaler

# 将每个特征缩放到0-1的范围,如果不设置的话将会缩放到(-1,1)之间
# 思考:为什么这个封装在函数里面
scaler = MinMaxScaler(feature_range=(0, 1))

reg_model.fit(scaler.fit_transform(X_train), y_train)
LinearRegression()

导入测试模块

1
2
env = jpx_tokyo_market_prediction.make_env()
iter_test = env.iter_test()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
cols=['Date','SecuritiesCode','Open','High','Low','Close','Volume','AdjustmentFactor','SupervisionFlag','17SectorName']
train=new_feature[cols]

# 为了节省内存,不需要使用的内存都可以使用“_”接收
for (prices, _, _, _, _, sample_prediction) in iter_test:
# 时序特征工程
prices1 = preprocess(prices)
current_date = prices1["Date"].iloc[0]

prices1 = pd.merge(left=prices1,right=stock_list[['SecuritiesCode','17SectorName']])
df_price_raw = pd.concat([train, prices1[cols]]).reset_index(drop=True)
df_price_raw = adjust_price(df_price_raw)
features = create_feature(df_temp=df_price_raw,periods=[5,10,20,30,40,50,60,70])
features = features[features.Date == current_date]
features.drop(columns=['SecuritiesCode','Date'],axis=1,inplace=True)
features["pred"] = gbm.predict(features.drop(['Close'],axis=1))

print('Done1')

# 行业分段建模
prices2 = preprocess_data(prices)

pred_2 = model.predict(adjust_price(prices2).drop('Close',axis=1))
print('Done2')

# log_reg baseline
x_test = featuring(prices)
x_test = x_test.drop(['Date'], axis=1)
y_pred_reg = reg_model.predict(scaler.transform(x_test))

print('Done3')


# 模型融合(0.472)
features['pred'] = 0.05*(0.1* features['pred'].values + 0.9*pred_2) + 0.95*y_pred_reg

# 使用rank函数,按照pred的大小进行排名
# method="first"意味着当有多个相同的值时,它们的排名将根据它们在数据中出现的顺序来确定
# ascending参数设置为False,值越大排名越靠前
# 代码将排名结果减去1,因为主办方要求我们提交的排名是0-199
features["Rank"] = (features["pred"].rank(method="first", ascending=False)-1).astype(int)
sample_prediction["Rank"] = features["Rank"].values
# 打印前五行
display(sample_prediction.head())

# 检查可能的错误
assert sample_prediction["Rank"].notna().all() # Rank是否有任何缺失值
assert sample_prediction["Rank"].min() == 0 # 最小值是否为0
assert sample_prediction["Rank"].max() == len(sample_prediction["Rank"]) - 1 # 最大值是否等于 sample_prediction["Rank"] 的长度减1(199)
env.predict(sample_prediction)
Powered by Hexo & Theme Keep
This site is deployed on