数据科学和机器学习(第 26 部分):时间序列预测的终极之战  LSTM 对比 GRU 神经网络·进阶篇
🧠

数据科学和机器学习(第 26 部分):时间序列预测的终极之战 LSTM 对比 GRU 神经网络·进阶篇

(2/3)· 承接上篇 RNN 短板,本篇用 MQL5 把两种门控递归网络落成可训练子类

偏理论 第 2/3 篇
接上篇,我们继续深挖时间序列预测里两种主流门控网络在 MQL5 中的落地。很多人读完 RNN 局限就直接调库跑模型,却说不清 LSTM 和 GRU 的父类为什么要先抽象。先把结构理顺,后面训练才不会在维度对齐上反复踩坑。

◍ 给循环网络分类器先写个基类

LSTM 和 GRU 在参数结构、训练流程上高度同构,如果每次都重写构建、编译、优化、特征重要性和导出逻辑,后续子类会堆满重复代码。先抽一个 RNNClassifier 父类,把共用骨架定死,LSTM 和 GRU 只去实现各自的 build_compile_and_train 就行。 神经网络对外汇与贵金属行情预测这类高噪声序列极其敏感,超参数没调好,模型大概率直接失效。基类里用 Optuna 做意向函数 optimize_objective,默认跑 50 次 trial 搜最优组合,覆盖神经元数 10–100、隐藏层 1–5、dropout 0.1–0.5、学习率 1e-5 到 1e-2(对数空间)等。 优化之外,基类还留了 SHAP 接口 check_feature_importance,用来量每个特征对预测方向的影响强度,帮你判断哪根均线或波动率分量真正在驱动信号。最后 save_onnx_model 把模型导成 ONNX,这是接进 MT5 本地推理的前置条件,少一步都加载不了。 下面这段是基类骨架与优化函数的核心代码,注意 build_compile_and_train 在父类只抛 NotImplementedError,强制子类自己填:

MQL5 / C++
class="kw">import tensorflow as tf
from tensorflow.keras.models class="kw">import Sequential
from tensorflow.keras.layers class="kw">import LSTM, GRU, Dense, Input, Dropout
from keras.callbacks class="kw">import EarlyStopping
from keras.optimizers class="kw">import Adam
class="kw">import tf2onnx
class="kw">import optuna
class="kw">import shap
from sklearn.metrics class="kw">import accuracy_score
class RNNClassifier():
    def __init__(self, time_step, x_train, y_train, x_test, y_test):
        self.model = None
        self.time_step = time_step
        self.x_train = x_train
        self.y_train = y_train
        self.x_test = x_test
        self.y_test = y_test
    
    # a crucial function that all the subclasses must implement
    
    def build_compile_and_train(self, params, verbose=class="num">0):
        raise NotImplementedError("Subclasses should implement this method")
    
    # a function for saving the RNN model to onnx & the Standard scaler parameters
    
    def save_onnx_model(self, onnx_file_name):
    
    # optuna objective function to oprtimize
    def optimize_objective(self, trial):
    
    # optimize for class="num">50 trials by class="kw">default
    def optimize(self, n_trials=class="num">50):
    
    def _rnn_predict(self, data):
    
    def check_feature_importance(self, feature_names):
def optimize_objective(self, trial):
    params = {
        "neurons": trial.suggest_int(&class="macro">#x27;neurons&class="macro">#x27;, class="num">10, class="num">100),
        "n_hidden_layers": trial.suggest_int(&class="macro">#x27;n_hidden_layers&class="macro">#x27;, class="num">1, class="num">5),
        "dropout_rate": trial.suggest_float(&class="macro">#x27;dropout_rate&class="macro">#x27;, class="num">0.1, class="num">0.5),
        "learning_rate": trial.suggest_float(&class="macro">#x27;learning_rate&class="macro">#x27;, class="num">1e-5, class="num">1e-2, log=True),
        "hidden_activation_function": trial.suggest_categorical(&class="macro">#x27;hidden_activation_function&class="macro">#x27;, [&class="macro">#x27;relu&class="macro">#x27;, &class="macro">#x27;tanh&class="macro">#x27;, &class="macro">#x27;sigmoid&class="macro">#x27;]),
        "loss_function": trial.suggest_categorical(&class="macro">#x27;loss_function&class="macro">#x27;, [&class="macro">#x27;categorical_crossentropy&class="macro">#x27;, &class="macro">#x27;binary_crossentropy&class="macro">#x27;, &class="macro">#x27;mean_squared_error&class="macro">#x27;, &class="macro">#x27;mean_absolute_error&class="macro">#x27;])
    }
    val_accuracy = self.build_compile_and_train(params, verbose=class="num">0) # we build a model with different parameters and train it, just to class="kw">return a validation accuracy value
    class="kw">return val_accuracy
# optimize for class="num">50 trials by class="kw">default
def optimize(self, n_trials=class="num">50):
    study = optuna.create_study(direction=&class="macro">#x27;maximize&class="macro">#x27;) # we want to find the model with the highest validation accuracy value
    study.optimize(self.optimize_objective, n_trials=n_trials)
    class="kw">return study.best_params # returns the parameters that produced the best performing model
def check_feature_importance(self, feature_names):
    # Sample a subset of training data for SHAP explainer

「用 SHAP 给 RNN 特征打分并导出 ONNX」

把训练集里随机抽 100 条不重复样本喂给 KernelExplainer,是对 RNN 预测函数做黑盒解释的常见做法;测试集同样取前 100 条,nsamples=100 控制每个样本的背景采样量,计算开销和解释稳定性之间能取得一个可接受的平衡。 特征名按 time_step 展开成『特征_t0、特征_t1…』的时序格式,summary_plot 用 max_display=len(feature_names) 把全部维度都画出来,画布被硬性拉到 7.5×14 英寸、左边距 0.12,否则长特征列表会被挤成不可读的窄条。 存图时文件名直接取类名小写加 _feature_importance.png,跑完一批不同模型类就能靠类名自动区分,不用手动改路径。 save_onnx_model 里 input_signature 指定为 (None, time_step, 特征数) 的 float16 张量,opset=13 是 tf2onnx 在 MT5 外接推理里兼容性较稳的一档;顺手把 StandardScaler 的 mean_ 和 scale_ 写成 .bin,后续在 C++/Python 侧做同样的归一化才不会偏移。 外汇与贵金属行情具有高杠杆与跳空风险,任何模型解释或导出仅用于辅助复盘,实盘信号仍须结合仓位与止损规则。

MQL5 / C++
    sampled_idx = np.random.choice(len(self.x_train), size=class="num">100, replace=False)
    explainer = shap.KernelExplainer(self._rnn_predict, self.x_train[sampled_idx].reshape(class="num">100, -class="num">1))
    # Get SHAP values for the test set
    shap_values = explainer.shap_values(self.x_test[:class="num">100].reshape(class="num">100, -class="num">1), nsamples=class="num">100)
    # Update feature names for SHAP
    feature_names = [f&class="macro">#x27;{feature}_t{t}&class="macro">#x27; for t in range(self.time_step) for feature in feature_names]
    # Plot the SHAP values
    shap.summary_plot(shap_values, self.x_test[:class="num">100].reshape(class="num">100, -class="num">1), feature_names=feature_names, max_display=len(feature_names), show=False)
    # Adjust layout and set figure size
    plt.subplots_adjust(left=class="num">0.12, bottom=class="num">0.1, right=class="num">0.9, top=class="num">0.9)    
    plt.gcf().set_size_inches(class="num">7.5, class="num">14)
    plt.tight_layout()
    # Get the class name of the current instance
    class_name = self.__class__.__name__
    # Create the file name using the class name
    file_name = f"{class_name.lower()}_feature_importance.png"
    plt.savefig(file_name)
    plt.show()
def save_onnx_model(self, onnx_file_name):
    # Convert the Keras model to ONNX
    spec = (tf.TensorSpec((None, self.time_step, self.x_train.shape[class="num">2]), tf.float16, name="class="kw">input"),)
    self.model.output_names = [&class="macro">#x27;outputs&class="macro">#x27;]
    onnx_model, _ = tf2onnx.convert.from_keras(self.model, input_signature=spec, opset=class="num">13)
    # Save the ONNX model to a file
    with open(onnx_file_name, "wb") as f:
        f.write(onnx_model.SerializeToString())
    # Save the mean and scale parameters to binary files
    scaler.mean_.tofile(f"{onnx_file_name.replace(&class="macro">#x27;.onnx&class="macro">#x27;,&class="macro">#x27;&class="macro">#x27;)}.standard_scaler_mean.bin")
    scaler.scale_.tofile(f"{onnx_file_name.replace(&class="macro">#x27;.onnx&class="macro">#x27;,&class="macro">#x27;&class="macro">#x27;)}.standard_scaler_scale.bin")

LSTM 与 GRU 子类只差一层定义

做递归网络分类器时,LSTM 和 GRU 的搭建流程几乎完全一致:输入形状、隐藏层循环、Dropout、输出层 softmax、Adam 优化器都共用同一套逻辑,真正不同的只有第一层用的是 LSTM 还是 GRU 单元。 下面两段 Python 子类代码能直接看出差异点——除了黑体标出的层类型,其余行逐字相同。训练时固定跑 100 个 epoch、batch_size=32,并用 patience=10 的 EarlyStopping 回滚最优权重,验证集精度作为返回指标。 别把两种单元当成两套体系 实际调参时,把 neurons、dropout_rate、learning_rate 这些键抽出来共用即可;换模型类型只是把 LSTM(...) 改成 GRU(...),不用重写训练循环。外汇与贵金属行情序列用这类模型做方向分类属高风险尝试,回测精度高不代表实盘概率占优。

MQL5 / C++
class LSTMClassifier(RNNClassifier):
    def build_compile_and_train(self, params, verbose=class="num">0):
        self.model = Sequential()
        self.model.add(Input(shape=(self.time_step, self.x_train.shape[class="num">2])))
        self.model.add(LSTM(units=params["neurons"], activation=&class="macro">#x27;relu&class="macro">#x27;, kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;)) # class="kw">input layer
        for layer in range(params["n_hidden_layers"]): # dynamically adjusting the number of hidden layers
            self.model.add(Dense(units=params["neurons"], activation=params["hidden_activation_function"], kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;))
            self.model.add(Dropout(params["dropout_rate"]))
        self.model.add(Dense(units=len(classes_in_y), activation=&class="macro">#x27;softmax&class="macro">#x27;, name=&class="macro">#x27;output_layer&class="macro">#x27;, kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;)) # the output layer
        # Compile the model
        adam_optimizer = Adam(learning_rate=params["learning_rate"])
        self.model.compile(optimizer=adam_optimizer, loss=params["loss_function"], metrics=[&class="macro">#x27;accuracy&class="macro">#x27;])
        
        if verbose != class="num">0:
            self.model.summary()
        early_stopping = EarlyStopping(monitor=&class="macro">#x27;val_loss&class="macro">#x27;, patience=class="num">10, restore_best_weights=True)
        history = self.model.fit(self.x_train, self.y_train, epochs=class="num">100, batch_size=class="num">32,
                                  validation_data=(self.x_test, self.y_test),
                                  callbacks=[early_stopping], verbose=verbose)
        val_loss, val_accuracy = self.model.evaluate(self.x_test, self.y_test, verbose=verbose)
        class="kw">return val_accuracy
class GRUClassifier(RNNClassifier):
    def build_compile_and_train(self, params, verbose=class="num">0):
        self.model = Sequential()
        self.model.add(Input(shape=(self.time_step, self.x_train.shape[class="num">2])))
        self.model.add(GRU(units=params["neurons"], activation=&class="macro">#x27;relu&class="macro">#x27;, kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;)) # class="kw">input layer
        for layer in range(params["n_hidden_layers"]): # dynamically adjusting the number of hidden layers
            self.model.add(Dense(units=params["neurons"], activation=params["hidden_activation_function"], kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;))
            self.model.add(Dropout(params["dropout_rate"]))
        self.model.add(Dense(units=len(classes_in_y), activation=&class="macro">#x27;softmax&class="macro">#x27;, name=&class="macro">#x27;output_layer&class="macro">#x27;, kernel_initializer=&class="macro">#x27;he_uniform&class="macro">#x27;)) # the output layer
        # Compile the model
        adam_optimizer = Adam(learning_rate=params["learning_rate"])

◍ 模型编译与早停训练的落地参数

这段 Keras 风格的建模代码把优化器、损失函数和准确率指标绑在一起编译,随后用验证集损失做早停监控。patience=10 意味着连续 10 个 epoch 验证损失不降就回滚到最优权重,避免过拟合。 训练循环固定跑 100 个 epoch、batch_size=32,但早停可能让它提前终止;最终返回的是测试集 val_accuracy,这个数值才是你拿去对比不同损失函数时的硬指标。 外汇与贵金属行情具有高风险,这类模型输出仅作概率参考,实盘前务必在 MT5 历史数据上重跑验证。

MQL5 / C++
self.model.compile(optimizer=adam_optimizer, loss=params["loss_function"], metrics=[&class="macro">#x27;accuracy&class="macro">#x27;])

if verbose != class="num">0:
    self.model.summary()
early_stopping = EarlyStopping(monitor=&class="macro">#x27;val_loss&class="macro">#x27;, patience=class="num">10, restore_best_weights=True)
history = self.model.fit(self.x_train, self.y_train, epochs=class="num">100, batch_size=class="num">32,
                          validation_data=(self.x_test, self.y_test),
                          callbacks=[early_stopping], verbose=verbose)
val_loss, val_accuracy = self.model.evaluate(self.x_test, self.y_test, verbose=verbose)
class="kw">return val_accuracy

「双模型初始化与择优结果」

实盘前先把 LSTM 与 GRU 两个分类器都实例化,共用同一套序列化的训练集与测试集,time_step 保持一致才能横向比精度。 两个模型各自跑 20 次超参择优。LSTM 在第 19 次 trial 拿到最佳验证值 0.5597,对应神经元 79、隐藏层 4、dropout 0.336、学习率 3.07e-5、relu 激活、分类交叉熵损失。 GRU 反而更早收敛:第 3 次 trial 验证准确率约 55.97%,是全模型里最高的;该次配置为神经元 55、隐藏层 5、dropout 0.273、学习率 0.0096、sigmoid 激活、均方误差损失,并在验证集上给出约 53.24% 的准确率。 外汇与贵金属行情受宏观事件驱动,这类序列模型仅刻画历史概率结构,实盘信号失效风险高,须以小资金验证。 让小布替你跑这套 把下面代码贴进 MT5 的 Python 环境或直接用本地 notebook 复现,先确认自己数据的 x_train_seq / y_train_encoded 维度对齐,否则 optimize 会直接报维度错。

MQL5 / C++
lstm_clf = LSTMClassifier(time_step=time_step,
                          x_train= x_train_seq, 
                          y_train= y_train_encoded, 
                          x_test= x_test_seq, 
                          y_test= y_test_encoded
                         )
gru_clf = GRUClassifier(time_step=time_step,
                          x_train= x_train_seq, 
                          y_train= y_train_encoded, 
                          x_test= x_test_seq, 
                          y_test= y_test_encoded
                         )
best_params = lstm_clf.optimize(n_trials=class="num">20)
best_params = gru_clf.optimize(n_trials=class="num">20)
[I class="num">2024-class="num">07-class="num">01 class="num">11:class="num">14:class="num">40,class="num">588] Trial class="num">19 finished with value: class="num">0.5597269535064697 and parameters: {&class="macro">#x27;neurons&class="macro">#x27;: class="num">79, &class="macro">#x27;n_hidden_layers&class="macro">#x27;: class="num">4, &class="macro">#x27;dropout_rate&class="macro">#x27;: class="num">0.335909076638275, &class="macro">#x27;learning_rate&class="macro">#x27;: class="num">3.0704319088493336e-05, &class="macro">#x27;hidden_activation_function&class="macro">#x27;: &class="macro">#x27;relu&class="macro">#x27;, &class="macro">#x27;loss_function&class="macro">#x27;: &class="macro">#x27;categorical_crossentropy&class="macro">#x27;}. 
Best is trial class="num">19 with value: class="num">0.5597269535064697.
[I class="num">2024-class="num">07-class="num">01 class="num">11:class="num">18:class="num">52,class="num">190] Trial class="num">3 finished with value: class="num">0.532423198223114 and parameters: {&class="macro">#x27;neurons&class="macro">#x27;: class="num">55, &class="macro">#x27;n_hidden_layers&class="macro">#x27;: class="num">5, &class="macro">#x27;dropout_rate&class="macro">#x27;: class="num">0.2729838602302831, &class="macro">#x27;learning_rate&class="macro">#x27;: class="num">0.009626688728041802, &class="macro">#x27;hidden_activation_function&class="macro">#x27;: &class="macro">#x27;sigmoid&class="macro">#x27;, &class="macro">#x27;loss_function&class="macro">#x27;: &class="macro">#x27;mean_squared_error&class="macro">#x27;}. 
Best is trial class="num">3 with value: class="num">0.532423198223114.
把重复劳动交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到 LSTM 与 GRU 的回测差异概览,你专注决策而非手搓封装。

常见问题

两者共享时序前向传播与权重更新骨架,抽象父类能避免重复代码并保持门控逻辑一致,子类只重写遗忘门或更新门的计算。
小布盯盘内置了两类网络的回测概览页,可查看历史品种上各自的概率分布,但外汇贵金属高风险,输出仅为概率倾向而非确定方向。
差异在门数量与状态合并方式:LSTM 有遗忘、输入、输出三门与独立 cell 状态,GRU 用重置、更新两门并合并隐藏态,子类重写对应步进函数即可。
建议切分时间序列窗口并固定随机种子,监控验证集误差;梯度可能迷失或膨胀,需对长序列做截断反向传播。