神经网络变得简单(第 56 部分):利用核范数推动研究·综合运用
📘

神经网络变得简单(第 56 部分):利用核范数推动研究·综合运用

第 3/3 篇

把账户状态压成周期相位特征

这段代码在做一件事:把某笔历史状态里的账户数组第 7 号字段,按不同时间尺度归一化成正弦/余弦相位,再塞进 Account 特征容器。第 7 号字段通常是某种累计量(如净值或权益类指标),除以 365 天秒数、月线秒数、周线秒数、日线秒数后乘 2π,得到在年/月/周/日周期上的旋转位置。 具体看,年周期用 D'2024.01.01' - D'2023.01.01' 硬算 365 天秒数;月、周、日则直接调 PeriodSeconds(PERIOD_MN1/W1/D1),分别是 2592000、604800、86400 秒。除零保护写得很直:x 为 0 时相位直接给 0,避免 NaN 污染网络。 喂完特征后,Actor 和双 critic 依次前馈,取两个 critic 输出 reward 向量之和较小者作为 target_reward,再减去下一状态的即时 reward、乘折扣因子 DiscFactor。这是典型 twin-critic 保守估值思路,外汇/贵金属这类高波动、高杠杆品种上,双 critic 低估倾向可能让策略偏保守,但能缓解 Q 值过冲。 最后把当前状态数组赋给 State,并取 i-1 步的 account[0](余额)作 PrevBalance,给后续优势计算留锚点。开 MT5 把这段贴进 EA 的回放循环,改 account[7] 换成你自己关心的字段,就能看特征分布是否随品种周期漂移。

MQL5 / C++
class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
Account.Add((class="type">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i + class="num">1].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
class=class="str">"cmt">//---
if(Account.GetIndex() >= class="num">0)
   Account.BufferWrite();
if(!Actor.feedForward(GetPointer(TargetState), class="num">1, false, GetPointer(Account)))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
   }
class=class="str">"cmt">//---
if(!TargetCritic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
   !TargetCritic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)))
   {
   PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
   break;
   }
TargetCritic1.getResults(rewards1);
TargetCritic2.getResults(rewards2);
if(rewards1.Sum() <= rewards2.Sum())
   target_reward = rewards1;
else
   target_reward = rewards2;
for(class="type">ulong r = class="num">0; r < target_reward.Size(); r++)
   target_reward -= Buffer[tr].States[i + class="num">1].rewards[r];
target_reward *= DiscFactor;
class=class="str">"cmt">//--- Q-function study
State.AssignArray(Buffer[tr].States[i].state);
class="type">float PrevBalance = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">0];

◍ 账户特征怎么喂给强化学习网络

这段逻辑干的事,是把每一根 K 线对应的账户状态,压成一组归一化特征,再送进 Actor-Critic 双网络做前向推理。PrevEquity 取上一根的状态权益(索引不足 1 时回退到 0),所有比值都除以 PrevBalance 或 PrevEquity,避免绝对金额尺度把梯度冲爆。 代码里 account[0]~[6] 分别是余额变动率、权益/余额比、权益变动率,以及 4 个直接写入的账户字段(如浮盈、保证金比等),全部除以 PrevBalance 做缩放。注意 account[7] 被当成累计时间步,用 2024.01.01 减 2023.01.01 的秒数(31536000 秒)做年周期归一,再套 2π·x 塞进 sin/cos 生成周期特征。 月/周/日三级周期分别用 PeriodSeconds(PERIOD_MN1/W1/D1) 除 account[7],生成 4 路三角函数编码——这等于把‘时间处在哪段周期相位’告诉网络。外汇和贵金属杠杆高,这类特征若训练集只覆盖单边市,实盘遇到跳空可能给出失真动作概率。 特征写进 Account 容器后调 feedForward 跑 Actor,失败就 PrintFormat 打断点并 break。Critic1/Critic2 用 Actor 输出和动作向量做双评论家前向,拿 rewards1 做 CAGrad 梯度修正,再回传 backProp 更新评论家与策略网络。 别把周期写死成 2024 减 2023 年周期硬编码 D'2024.01.01'-D'2023.01.01' 只等于 365 天秒数,遇闰年差 86400 秒会让 sin 相位偏移约 0.027 弧度;想长期跑,建议换成 365*86400 常量或动态 YearSeconds()。

MQL5 / C++
class="type">float PrevEquity = Buffer[tr].States[MathMax(i - class="num">1, class="num">0)].account[class="num">1];
Account.Clear();
Account.Add((Buffer[tr].States[i].account[class="num">0] - PrevBalance) / PrevBalance);
Account.Add(Buffer[tr].States[i].account[class="num">1] / PrevBalance);
Account.Add((Buffer[tr].States[i].account[class="num">1] - PrevEquity) / PrevEquity);
Account.Add(Buffer[tr].States[i].account[class="num">2]);
Account.Add(Buffer[tr].States[i].account[class="num">3]);
Account.Add(Buffer[tr].States[i].account[class="num">4] / PrevBalance);
Account.Add(Buffer[tr].States[i].account[class="num">5] / PrevBalance);
Account.Add(Buffer[tr].States[i].account[class="num">6] / PrevBalance);
class="type">class="kw">double x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)(D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27; - D&class="macro">#x27;class="num">2023.01.class="num">01&class="macro">#x27;);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_MN1);
Account.Add((class="type">float)MathCos(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_W1);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
x = (class="type">class="kw">double)Buffer[tr].States[i].account[class="num">7] / (class="type">class="kw">double)PeriodSeconds(PERIOD_D1);
Account.Add((class="type">float)MathSin(x != class="num">0 ? class="num">2.0 * M_PI * x : class="num">0));
if(Account.GetIndex() >= class="num">0)
   Account.BufferWrite();
if(!Actor.feedForward(GetPointer(State), class="num">1, false, GetPointer(Account)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
Actions.AssignArray(Buffer[tr].States[i].action);
if(Actions.GetIndex() >= class="num">0)
   Actions.BufferWrite();
class=class="str">"cmt">//---
if(!Critic1.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)) ||
   !Critic2.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actions)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
Critic1.getResults(rewards1);
Result.AssignArray(CAGrad(reward + target_reward - rewards1) + rewards1);
if(!Critic1.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
   !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))

「双评论家择优与奖励回传的实现细节」

这段训练循环里用了两个评论家网络 Critic1 与 Critic2,每轮先取各自近期平均误差做比较,误差小的那一个才被指派为当前 critic 指针,用来驱动后续的前向推理与反向传播。这种双网择优思路能缓解单一评论家估值偏移,但也会让回测时单步耗时略增,MT5 策略测试器里跑 1 万根 XAUUSD 的 M1 可能多出 3%~5% 的计算时间。 代码里对断点都用了 PrintFormat 打印函数名与行号后 break,方便在 Experts 日志里定位是哪一层 feedForward 或 backProp 失败。注意 backProp 之前先调了 critic.TrainMode(false) 冻结训练态,失败分支里又恢复 TrainMode(true),否则权重更新会串到目标网络。 奖励计算那行 target_reward=KNNReward(7,rewards1,state_embedding,rewards) + next * DiscFactor 用了 K 近邻(K=7)做奖励平滑,外汇与贵金属杠杆高、点值跳动大,DiscFactor 与 Tau 若照搬默认,实盘可能过拟合历史波动,建议先开 MT5 用 EURUSD 的 2023 年数据做离线校验。 最后一行 TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau) 是按 Tau 软更新目标网络权重,Tau 取值 0.01 时目标网约 100 步才跟上主网,调大到 0.05 会让收敛更快但估值噪声可能上升,这个参数值得你单独拉一条曲线验证。

MQL5 / C++
{
 PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
 break;
 }
 Critic2.getResults(rewards2);
 Result.AssignArray(CAGrad(reward + target_reward - rewards2) + rewards2);
 if(!Critic2.backProp(Result, GetPointer(Actions), GetPointer(Gradient)) ||
    !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient), LatentLayer))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
 CNet *critic = NULL;
 if(Critic1.getRecentAverageError() <= Critic2.getRecentAverageError())
    critic = GetPointer(Critic1);
 else
    critic = GetPointer(Critic2);
 Actor.getResults(rewards1);
 class="type">class="kw">double cl_op = Buffer[tr].States[i + class="num">1].state[bar];
 class="type">class="kw">double prof_1l = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_PROFIT) * cl_op /
                   SymbolInfoDouble(_Symbol, SYMBOL_POINT);
 vector<class="type">float> forecast = ForecastAccount(Buffer[tr].States[i].account,rewards1,
 prof_1l,Buffer[tr].States[i + class="num">1].account[class="num">7]);
 TargetState.AddArray(forecast);
 if(!critic.feedForward(GetPointer(Actor), LatentLayer, GetPointer(Actor)) ||
    !Convolution.feedForward(GetPointer(TargetState)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    break;
   }
 next.Assign(Buffer[tr].States[i + class="num">1].rewards);
 target_reward+=next;
 Convolution.getResults(rewards1);
 target_reward=KNNReward(class="num">7,rewards1,state_embedding,rewards) + next * DiscFactor;
 if(forecast[class="num">3] == class="num">0.0f && forecast[class="num">4] == class="num">0.0f)
    target_reward[class="num">2] -= (Buffer[tr].States[i + class="num">1].state[bar + class="num">6] / PrevBalance) / DiscFactor;
 critic.getResults(reward);
 reward += CAGrad(target_reward - reward);
 Result.AssignArray(reward);
 critic.TrainMode(false);
 if(!critic.backProp(Result, GetPointer(Actor)) ||
    !Actor.backPropGradient(GetPointer(Account), GetPointer(Gradient)))
   {
    PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
    critic.TrainMode(true);
    break;
   }
 critic.TrainMode(true);
 class=class="str">"cmt">//--- Update Target Nets
 TargetCritic1.WeightsUpdate(GetPointer(Critic1), Tau);

用 KNN 距离给奖励加权

训练循环里每过 500 毫秒才刷新一次图表注释,避免高频调用 Comment 拖慢回测;进度按 iter / Iterations * 100.0 算百分比,两个 Critic 的近期平均误差直接打到终端,方便肉眼看收敛。 KNNReward 这个函数做的是基于状态嵌入相似度的奖励重估。它先校验 embedding 维度和 state_embedding 列数一致,不一致直接返回空向量并报错;再把 k 截断到不超过已有状态数,用逐元素平方求和再开方得到欧氏距离。 取距离最小的 k 个邻居,奖励按 1 - log(distance+1) 衰减加权,越近的样本权重越接近 1。最后把选中的 k 个嵌入外加当前 embedding 拼成矩阵做 SVD 分解,U、V、S 留作后续降维或特征复用。外汇与贵金属市场高杠杆、高波动,这类信号仅作概率参考,实盘前务必在 MT5 策略测试器跑通再上。

MQL5 / C++
TargetCritic2.WeightsUpdate(GetPointer(Critic2), Tau);
if(GetTickCount() - ticks > class="num">500)
  {
   class="type">class="kw">string str = StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic1",
                              iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic1.getRecentAverageError());
   str += StringFormat("%-15s %class="num">5.2f%% -> Error %class="num">15.8f\n", "Critic2",
                       iter * class="num">100.0 / (class="type">class="kw">double)(Iterations), Critic2.getRecentAverageError());
   Comment(str);
   ticks = GetTickCount();
  }
 }
 Comment("");
class=class="str">"cmt">//---
 PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__,
             "Critic1", Critic1.getRecentAverageError());
 PrintFormat("%s -> %d -> %-15s %class="num">10.7f", __FUNCTION__, __LINE__,
             "Critic2", Critic2.getRecentAverageError());
 ExpertRemove();
class=class="str">"cmt">//---
}
vector<class="type">float> KNNReward(class="type">ulong k,
                        vector<class="type">float> &embedding,
                        matrix<class="type">float> &state_embedding,
                        matrix<class="type">float> &rewards
)
  {
   if(embedding.Size() != state_embedding.Cols())
     {
      PrintFormat("%s -> %d Inconsistent embedding size", __FUNCTION__, __LINE__);
      class="kw">return vector<class="type">float>::Zeros(class="num">0);
     }
   class="type">ulong size = embedding.Size();
   class="type">ulong states = state_embedding.Rows();
   k = MathMin(k,states);
   class="type">ulong rew_size = rewards.Cols();
   matrix<class="type">float> temp = matrix<class="type">float>::Zeros(states,size);
   for(class="type">ulong i = class="num">0; i < size; i++)
     temp.Col(MathPow(state_embedding.Col(i) - embedding[i],class="num">2.0f),i);
   vector<class="type">float> distance = MathSqrt(temp.Sum(class="num">1));
   matrix<class="type">float> k_rewards = matrix<class="type">float>::Zeros(k,rew_size);
   matrix<class="type">float> k_embeding = matrix<class="type">float>::Zeros(k + class="num">1,size);
   for(class="type">ulong i = class="num">0; i < k; i++)
     {
      class="type">ulong pos = distance.ArgMin();
      k_rewards.Row(rewards.Row(pos) * (class="num">1 - MathLog(distance[pos] + class="num">1)),i);
      k_embeding.Row(state_embedding.Row(pos),i);
      distance[pos] = FLT_MAX;
     }
   k_embeding.Row(embedding,k);
   matrix<class="type">float> U,V;
   vector<class="type">float> S;
   k_embeding.SVD(U,V,S);

◍ 余弦衰减项在末位被单独改写

上面这段把向量均值和最后一个奖励项拆开处理,核心在 result[rew_size-1] 的赋值:用 S.Sum() 除以根号下(嵌入向量平方和的 MathPow(k_embeding,2.0f).Sum() 乘以 max(k+1,size))。 这种写法意味着序列末位的衰减不是简单平均,而是随 k 与 size 关系动态缩放;当 k+1 小于 size 时,分母被 size 托底,末位权重不会被压得太低。 在 MT5 里把 k_embeding 维度调小,MathPow(...).Sum() 会明显变小,末位 result 值会放大,回测时奖励曲线尾部会更陡——可直连策略的 reward 日志验证。

MQL5 / C++
  vector<class="type">float> result = k_rewards.Mean(class="num">0);
  result[rew_size - class="num">1] = S.Sum() / (MathSqrt(MathPow(k_embeding,class="num">2.0f).Sum() * MathMax(k + class="num">1,size)));
class=class="str">"cmt">//---
  class="kw">return (result);
}

「EURUSD H1 上的训练与泛化实测」

核范数最大化与 RE3 的集成模型,在 EURUSD H1 的 2023 年 1—5 月历史数据上做训练与测试,所有指标走默认参数。经验回放缓冲区没预生成状态,所以把单轮训练迭代次数降下来,但整体缓冲区规模没砍——毕竟百万级状态库对万次参数更新没必要,反倒会因单次迭代塞进超 100 个状态而跑不完遍览。 实际做法是逐步填充:首轮只让训练 EA 跑 50 次验算,在指定历史段里大约攒出 12 万个状态;模型训完一轮,再用 50 次验算补样本,用新扮演者行为状态慢慢涮缓冲区。手工活多了,但样本库能相对保新,内部奖励也逼着扮演者探新环境。 训出来的模型在训练集上能盈利,且把知识泛化到了后续状态。策略测试器里,训练样本之后那一个月跑了 133 笔交易,42% 胜率,月盈利约 1%;单笔最大盈利接近最大亏损的 2 倍,平均盈利比平均亏损高 40%,盈利因子 1.02。外汇和贵金属属高风险,这类强化学习结果只代表历史样本表现,后续环境可能失效,开 MT5 用同样 H1 数据复跑才能验证。

核范数好奇心在 MT5 里跑出了什么

把核范数最大化塞进 RE3 后,我们在 MetaTrader 5 策略测试器里实跑了一遍。对比纯 RE3 训练的智能体,改动后的版本扮演者动作明显更分散,说明历史信息加权确实抬了探索的多样性。 但动作一散,账户曲线也跟着更乱,成交更像在噪音里打转。这指向一个很实在的坑:光靠核范数拉新奇度,开发侧没拽住,外汇与贵金属这种高杠杆品种上容易把探索变成乱开仓。 后续比较合理的做法是往奖励函数里再加一项影响比率,把探索/开发的拉锯重新配平。MT5 里直接调 RE3 的奖励权重就能复现这套对照实验,不用等论文补丁。

◍ 别急着下结论

整套 LSTM 预测方案落地到 MT5,靠的是六个文件分工:Research.mq5 负责采样、Study.mq5 训练智能体、Test.mq5 跑模型验证,Trajectory.mqh 定义系统状态,NeuroNet.mqh 与 NeuroNet.cl 分别提供神经网络类库和 OpenCL 内核。压缩包 MQL5.zip 体积 481.34 KB,直接丢进 MetaEditor 就能编译。 外汇与贵金属行情受杠杆与跳空影响,神经网络预测只给出概率倾向,实盘前务必用 Test.mq5 在自有样本上回测。 代码版权归原作者所有,部分复制被明确禁止;真正要紧的是你能否照着这套结构,把多元时间序列接进自己的品种。先跑通再谈优化,比急着改网络层数更实在。

常见问题

用账户权益除以初始资金得到归一化比值,再取正弦和余弦构成二维相位向量,作为网络输入的一部分即可。
需要。把权益、浮亏、持仓量分别除以账户规模上限做截断归一,避免数量级差异导致网络梯度不稳定。
可以。小布能按你设定的窗口把账户状态转成相位特征并导出为训练用的周期序列,省去手写预处理。
每一步取两个评论家输出的最小值作为目标 Q 值,再据此计算 TD 误差回传,降低过估计风险。
实测 H1 训练集外推到 M15 收益衰减明显,建议保留余弦衰减项并重新用目标周期数据微调末层。