参考资料:Sezer, Omer & Ozbayoglu, Murat.(2018). 基于深度卷积神经网络的算法金融交易:时间序列到图像转换方法.应用软计算。70. 10.1016/j.asoc.2018.04.024.
这篇论文讨论了将计算每天具有不同窗口大小的技术指标,然后将每天的指标转换为图像,并将其提供给卷积神经网络进行训练。实现对表格形式的股票趋势进行图像分类,这确实是一个有趣的思路,故而此文尝试实现,并且罗列一些遇到的问题。
实现思路
讲解我照搬了nayash 的一篇博文,末尾附上他的链接。
特征工程:这里用简单移动平均线 (SMA) 来解释技术指标和时间段的概念,因为它更简单。这应该足以让您理解这个想法。
数字列表的移动平均值类似于算术平均值,但我们不是计算所有数字的平均值,而是计算前“n”个数字的平均值(n 称为窗口大小或时间段),然后移动(或滑动)窗口 1 个索引,从而排除第一个元素并包括 n+1 元素并计算它们的平均值。这个过程仍在继续。下面是一个例子来说明这一点:
Excel 工作表上的 SMA 示例
这是窗口大小为 6 时的 SMA 示例。前 6 个元素的 SMA 以橙色显示。现在将上面的第一列视为您选择的股票的收盘价。现在计算 SMA 的收盘价,其他 14 个窗口大小(7 到 20)连接在sma_6右侧。现在,数据集的每一行都有 15 个新要素。对其他 14 个技术指标重复此过程,并删除空行。
现在您有 225 个新指标。如果你把这些数字重塑成一个 15x15 的数组,你就得到了一个图像!(尽管在这一点上,它是一个单一的特征。稍后会详细介绍)。不过,有一件事要记住。在构建这些图像时,我们应该保持相关的技术指标在空间上接近。在训练人脸识别时,如果一张图片的鼻子下方有一只眼睛,你就不会将它标记为人脸。相关像素应该在附近。您可以在 utils.py 文件中找到指标计算代码。(使用ta和talib)
标签:现在剩下的就是标记这个数据集。为此,作者使用了以下算法:
用于将数据集标记为买入/卖出/持有的算法
乍一看,这似乎令人生畏,但它所说的只是:使用收盘价的 11 天窗口。如果中间数在窗口内最大,则将中间数字标记为“卖出”,或者,如果中间数字最小,则将中间数字标记为“买入”,否则标记为“持有”。如前所述滑动窗口并重复。这个想法是在任何 11 天窗口的低谷买入并在波峰卖出。这个算法的能力是另一回事,我将在最后讨论这个问题。
训练: 作者使用了卷帘窗训练,这类似于我们上面看到的滑动窗概念。如果您有 2000 年至 2019 年的股票历史数据,并且您决定使用 5 年数据进行训练并对 1 年数据进行测试,那么请从数据集中切片 2000-2004 年的数据进行训练,并从 2005 年的数据中切片进行测试。根据此数据训练和测试模型。接下来,选择 2001–2005 作为训练数据,选择 2006 作为测试数据。使用相同的模型对此数据进行重新训练。重复直到到达终点。
计算性能评估:作者在论文中提供了两种类型的模型评估,即计算评估和财务评估。计算评估包括混淆矩阵、F1 分数、类精度等。财务评估是通过将模型预测应用于现实世界的交易并衡量所获得的利润来完成的。(本文只讨论计算评估)
数据处理
数据处理相关代码可在 data_generator.py 中找到
数据来源:我这里使用的是自己的本地数据
原文数据如下所示:
如果使用本地数据可在outputs文件夹内添加本地文件,程序读取方式在data_generator.py中。
df = pd.read_csv(os.path.join(self.output_path, "df_" + self.company_code+".csv"))
数据处理后,csv文件如下所示。
归一化:使用Sklearn的MinMaxScaler将数据归一化在[0,1]范围内,尽管论文中使用的是[- 1,1]范围(第二个偏差)。看大家喜好吧。
from sklearn.preprocessing import MinMaxScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
# from imblearn.over_sampling import SMOTE, ADASYN, RandomOverSampler
from collections import Counter
list_features = list(df.loc[:, 'open':'eom_26'].columns)
print('Total number of features', len(list_features))
x_train, x_test, y_train, y_test = train_test_split(df.loc[:, 'open':'eom_26'].values, df['labels'].values, train_size=0.8,
test_size=0.2, random_state=2, shuffle=True, stratify=df['labels'].values)
# smote = RandomOverSampler(random_state=42, sampling_strategy='not majority')
# x_train, y_train = smote.fit_resample(x_train, y_train)
# print('Resampled dataset shape %s' % Counter(y_train))
if 0.7*x_train.shape[0] < 2500:
train_split = 0.8
else:
train_split = 0.7
# train_split = 0.7
print('train_split =',train_split)
x_train, x_cv, y_train, y_cv = train_test_split(x_train, y_train, train_size=train_split, test_size=1-train_split,
random_state=2, shuffle=True, stratify=y_train)
mm_scaler = MinMaxScaler(feature_range=(0, 1)) # or StandardScaler?
x_train = mm_scaler.fit_transform(x_train)
x_cv = mm_scaler.transform(x_cv)
x_test = mm_scaler.transform(x_test)
x_main = x_train.copy()
print("Shape of x, y train/cv/test {} {} {} {} {} {}".format(x_train.shape, y_train.shape, x_cv.shape, y_cv.shape, x_test.shape, y_test.shape))
图像生成:再将数据重塑为图像。
处理类不平衡:这类问题难以解决的另一个原因是数据严重不平衡。“持有”操作的实例数将始终远大于买入/卖出。事实上,本文中介绍的标记算法产生了大量买入/卖出实例,但是实际上他的策略产生的实例很少。下图为很差的处理结果。
-
加权 F1 分数(Weighted F1 score):0.7796
- 这个指标考虑了每个类别的 F1 分数,并进行了加权平均。一个值为0.7796的加权 F1 分数表明模型在多类别分类任务中取得了相对较好的平衡,但仍有改进的空间。
-
宏平均 F1 分数(Macro F1 score):0.3051
- 宏平均 F1 分数是各个类别 F1 分数的简单平均。0.3051的值相对较低,可能表示某些类别的分类性能较差。需要进一步探索哪些类别影响了宏平均分数。
-
微平均 F1 分数(Micro F1 score):0.8436
- 微平均 F1 分数综合考虑了所有类别的真正例、假正例和假负例。0.8436的微平均 F1 分数相对较高,表明整体上模型在样本级别上有较好的分类性能。
-
各类别精确度(Precision):0.0, 0.0, 0.99
- 对于类别0和1的精确度为0.0,可能表示模型在这两个类别上存在问题,而类别2的精确度为0.99,表示模型在这个类别上有很高的精确性。
更复杂的是,“保持”事件的分类并不简单(这方面我并未有什么好思路,我的实践中,套用作者的“样本权重”还是有持有占比过高的情况出现)。
训练窗口:此处各位根据原文比例进行细分即可。此处由于采用分钟线,我也将窗口进行了等比例缩小。注意数据读入部分的“timestamp”需要适配数据格式。
模型训练
训练:所有与训练相关的代码都可以在“stock_keras.ipynb”中找到。文中提到的模型架构存在一些缺失。例如,它没有提到他们使用的步长。但是当我们尝试使用stride=1和padding=same的时候,我们意识到这个模型太大了,特别是对于5年数据的训练来说。不管我们使用的网络有多小,在滑动窗口训练方面都不好。因此,我们决定在完整训上使用交叉验证(第五个偏差)的方式对据进行训练。这部分代码包含了滚动窗口训练,都在data_generator.py文件中。
CNN配置如下
from functools import *
from sklearn.metrics import f1_score
from tensorflow.keras.metrics import AUC
def f1_custom(y_true, y_pred):
y_t = np.argmax(y_true, axis=1)
y_p = np.argmax(y_pred, axis=1)
f1_score(y_t, y_p, labels=None, average='weighted', sample_weight=None, zero_division='warn')
def create_model_cnn(params):
model = Sequential()
print("Training with params {}".format(params))
conv2d_layer1 = Conv2D(params["conv2d_layers"]["conv2d_filters_1"],
params["conv2d_layers"]["conv2d_kernel_size_1"],
strides=params["conv2d_layers"]["conv2d_strides_1"],
kernel_regularizer=regularizers.l2(params["conv2d_layers"]["kernel_regularizer_1"]),
padding='same',activation="relu", use_bias=True,
kernel_initializer='glorot_uniform',
input_shape=(x_train[0].shape[0],
x_train[0].shape[1], x_train[0].shape[2]))
model.add(conv2d_layer1)
if params["conv2d_layers"]['conv2d_mp_1'] > 1:
model.add(MaxPool2D(pool_size=params["conv2d_layers"]['conv2d_mp_1']))
model.add(Dropout(params['conv2d_layers']['conv2d_do_1']))
if params["conv2d_layers"]['layers'] == 'two':
conv2d_layer2 = Conv2D(params["conv2d_layers"]["conv2d_filters_2"],
params["conv2d_layers"]["conv2d_kernel_size_2"],
strides=params["conv2d_layers"]["conv2d_strides_2"],
kernel_regularizer=regularizers.l2(params["conv2d_layers"]["kernel_regularizer_2"]),
padding='same',activation="relu", use_bias=True,
kernel_initializer='glorot_uniform')
model.add(conv2d_layer2)
if params["conv2d_layers"]['conv2d_mp_2'] > 1:
model.add(MaxPool2D(pool_size=params["conv2d_layers"]['conv2d_mp_2']))
model.add(Dropout(params['conv2d_layers']['conv2d_do_2']))
model.add(Flatten())
model.add(Dense(params['dense_layers']["dense_nodes_1"], activation='relu'))
model.add(Dropout(params['dense_layers']['dense_do_1']))
if params['dense_layers']["layers"] == 'two':
model.add(Dense(params['dense_layers']["dense_nodes_2"], activation='relu',
kernel_regularizer=params['dense_layers']["kernel_regularizer_1"]))
model.add(Dropout(params['dense_layers']['dense_do_2']))
model.add(Dense(3, activation='softmax'))
if params["optimizer"] == 'rmsprop':
optimizer = optimizers.RMSprop(lr=params["lr"])
elif params["optimizer"] == 'sgd':
optimizer = optimizers.SGD(lr=params["lr"], decay=1e-6, momentum=0.9, nesterov=True)
elif params["optimizer"] == 'adam':
optimizer = optimizers.Adam(learning_rate=params["lr"], beta_1=0.9, beta_2=0.999, amsgrad=False)
model.compile(loss='categorical_crossentropy', optimizer=optimizer, metrics=['accuracy', f1_metric])
return model
def check_baseline(pred, y_test):
print("size of test set", len(y_test))
e = np.equal(pred, y_test)
print("TP class counts", np.unique(y_test[e], return_counts=True))
print("True class counts", np.unique(y_test, return_counts=True))
print("Pred class counts", np.unique(pred, return_counts=True))
holds = np.unique(y_test, return_counts=True)[1][2] # number 'hold' predictions
print("baseline acc:", (holds/len(y_test)*100))
(上图的(?,3136)中的?会引起很频繁的警告,不影响结果,可以定向关闭同类型警告)
from utils import download_save, seconds_to_minutes
import warnings
warnings.filterwarnings('ignore', category=DeprecationWarning)
warnings.filterwarnings('ignore', category=FutureWarning)
出现“提前回调”的提醒不用担心,这是keras模型训练的一部分。
另外每次运行它时,这个结果都会有所不同,这可能是由于 Keras 权重初始化造成的。这实际上是一种已知的行为,下面的链接很长的讨论线程。简而言之,您必须为 numpy 和 tensorflow 设置随机种子。这里有https://github.com/keras-team/keras/issues/2743
但是每个类的精度值保持在[80,90]的范围内,kappa值保持在[58,65]的范围内。具体的讨论大家可以看这里:
结果评价
结果:所有相关结果都可以在outputs中找到(每个模型的结果都可以在日志中找到)。这里展示的模型是根据期货AU2312的主力合约期训练的结果,可以说是差强人意。尽管在F1得分尚可,但分类模糊,难以投入使用。这是在窗口期设定为180分钟的情况下,一直没能获得的较好的结果(卖的准确率一直低下)。但在后续查阅日志过程中,发现在窗口为180天的股票的日行情处理结果较好,可能是数据本身的问题。
期货
股票
评价:这里附加上论文原作者的结果图,在标记大量hold的情况下,取得的准确率还是一般。但是在其他前辈复现这篇论文的文章中,也看到了一些很有希望的结果,也有人在模拟市场中成功盈利(羡慕)。鉴于其他相似思路的CNN模型的精度十分惊人,这篇论文所使用的模型参数想必还有很多提升空间,所以也如果您感兴趣也可以尝试一下。
作者原文这样评价他的模型:
“However, a lot of false entry and exit points are also generated. This is mainly due to the fact that “Buy” and “Sell” points appear much less frequent than “Hold” points, it is not easy for the neural network to catch the “seldom” entry and exit points without jeopardizing the general distribution of the dominant “Hold” values. In other words, in order to be able to catch most of the “Buy” and “Sell” points (recall), the model has a trade-off by generating false alarms for non-existent entry and exit points (precision). Besides, Hold points are not as clear as “Buy” and “Sell” (hills and valleys). It is quite possible for the neural network to confuse some of the “Hold” points with “Buy” and “Sell” points, especially if they are close to the top of the hill or bottom of the valley on sliding windows.”
“然而,也会产生许多虚假的进入和退出点。这主要是由于“买入”和“卖出”点的出现频率远低于“持有”点,神经网络不容易在不危及占主导地位的“持有”值的一般分布的情况下捕捉到“很少”的进入和离开点。换言之,为了能够捕捉到大多数“买入”和“卖出”点(召回),该模型通过为不存在的进入点和退出点(精度)生成假警报来进行权衡。此外,持有点不像“买入”和“卖出”(山丘和山谷)那么清晰。神经网络很可能将一些“持有”点与“买入”和“卖出”点混淆,尤其是当它们靠近滑动窗上的山顶或谷底时。
结尾附上一个已经优化过模型参数的项目链接:
Differing results in article and ipynb · Issue #6 · nayash/stock_cnn_blog_pub · GitHubhttps://github.com/nayash/stock_cnn_blog_pub/issues/6我的理解尚且浅薄,也没对模型进行优化修改,如果您想更深入的了解,可以去看看原作者的论文,里面系统得阐述了思路和实现。
https://www.researchgate.net/publication/324802031