神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)·进阶篇
📘

神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)·进阶篇

第 2/3 篇

用神经网络替掉 Q 表的那几道坎

深度 Q-学习的核心,是用神经网络去近似 Q 函数,而不是像前一篇那样靠聚类把状态压进一张有限表格。表格法假定状态与动作数量有限,聚类虽能限流,却会丢信息;神经网络不限制状态空间,对交易这种连续行情环境算是天然优势。 但直接拿网络换表格并不顺手。代理的训练目标是总奖励最大化,可真实路径里往往只有终点给一次性奖励,中间全为零——就像示意图里橙线和蓝线长度不同、累计奖励却相等。交易中也一样:立刻落袋的收入,比把资金占着、遥遥无期才回款更划算,因为资金有时间成本。 为此引入折扣因子 ɣ∈[0,1]:取 1 不折扣,取 0 无视未来,实战通常设得接近 1。问题在行情里我们既不知道下一状态也不知奖励,只能走一步看一步,没法回溯重选。贝尔曼优化于是派上用场——每步选局部最优动作,累积起来逼近全局最优,更新式本质是「旧值 + 学习率 ×(奖励 + 折扣后下一步最大 Q − 旧值)」,和随机梯度下降权重更新一个味儿。 「别让相关性拖垮网络」 沿环境移动时,相邻状态高度自相关(你走一步周围不会剧变)。若每步都用紧邻状态训网络,小学习率也挡不住模型只顾眼前、忘了旧经验。监督学习靠洗牌保独立,强化学习得靠记忆缓冲区:把「状态→动作→奖励→新状态」存进定长缓冲,满则覆盖最旧,训练时随机抽样,把时间轴上的样本打散,相关性自然降下来。缓冲大小是架构超参数,得自己调。 「冻一个网络治高估」 Q 更新里 maxQ(s_t+1) 来自「未来」,而每次训模型都在动权重,下一步预测值也跟着漂;递归最大化会把函数值越估越虚。解法是开第二个目标网络:它是主模型的副本,固定权重专门预测未来 Q,按周期从主模型拷权重,不并行训练。这样只训一个模型,却同时有「最新」和「冻结」两版 Q,根除递归高估。 汇总落点:MT5 里跑 DQN,至少得备神经网络预测 Q、体验回放缓冲、周期同步的目标网络这三件。外汇/贵金属杠杆高、滑点跳空多,这类近似在实盘只代表概率倾向,别当确定性信号。

◍ 用双网络搭起深度Q学习的交易骨架

在 MT5 里落地深度 Q-学习,核心是建两个神经网络实例:StudyNet 负责训练,TargetNet 只做未来 Q 值预测。EA 初始化时两个模型从同一个 .nnw 文件加载,保证起点权重一致,避免目标网络早期乱带节奏。 奖励系统直接挂钩蜡烛实体:盈利持仓拿到的奖励等于该根烛条主体大小;场外观望按实体大小记负惩罚;亏损持仓则罚双倍实体(亏掉的部分加上本可赚的利润)。为了不让模型躺着不动,空仓惩罚必须低于持仓亏损惩罚,否则它学会一直观望——无利润也无风险,但你要的是波动里的机会。外汇和贵金属杠杆高,这种试错模型实盘前务必小资金验证。 四个超参数决定训练形态:Batch=100 是权重更新批量,UpdateTarget=20 指 StudyNet 权重更新 20 次后才把参数拷给 TargetNet,Iterations=1000 是总迭代次数,DiscountFactor=0.9 贴未来奖励的折扣。代码里 DiscountFactor 写成 0.9,意味着模型更看重眼前这根蜡烛的盈亏,而非远端累计。 训练函数 Train 里有个细节:TargetNet 首次更新前用 use_target 标志关掉未来奖励预测,因为随机初始化权重给出的 Q 值是噪声,拿噪声当目标会污染整轮学习。另外作者没照搬贝尔曼方程取未来最大值,只对“场外观望”动作取最大,其余用单向值,目的是压住图表噪音诱发的频繁开平仓。 模型保存和 TargetNet 同步靠现成的存读机制:StudyNet 训完存盘,再让 TargetNet 从同一文件加载,等于精确复制全套参数,不用另写权重交换方法。

MQL5 / C++
CNet                StudyNet;
CNet                TargetNet;
class="kw">input class="type">int           Batch =  class="num">100;
class="kw">input class="type">int           UpdateTarget = class="num">20;
class="kw">input class="type">int           Iterations = class="num">1000;
class="kw">input class="type">class="kw">double        DiscountFactor =  class="num">0.9;
class=class="str">"cmt">//---
   class="type">class="kw">float temp1, temp2;
   if(!StudyNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false) ||
      !TargetNet.Load(FileName + ".nnw", dError, temp1, temp2, dtStudied, class="kw">false))
      class="kw">return INIT_FAILED;
   if(!StudyNet.GetLayerOutput(class="num">0, TempData))
      class="kw">return INIT_FAILED;
   HistoryBars = TempData.Total() / class="num">12;
   StudyNet.getResults(TempData);
   if(TempData.Total() != Actions)
      class="kw">return INIT_PARAMETERS_INCORRECT;
class="type">void Train(class="type">void)
  {
class=class="str">"cmt">//---
   class="type">MqlDateTime start_time;
   TimeCurrent(start_time);
   start_time.year -= StudyPeriod;
   if(start_time.year <= class="num">0)

「用随机采样把多指标状态喂给模型」

这段逻辑干的事很直接:从 1900 年起拉全量汇率,把 RSI、CCI、ATR、MACD 四个指标缓冲区按 bars 数量重设,任一失败就 ExpertRemove 退出。注意 CopyRates 的起始时间用 StructToTime 把 year 写死成 1900,相当于不限制历史深度,MT5 实盘里这可能一次性吃进十几万根 bar,内存和加载时间要先心里有数。 真正的核心在双重循环:外层按 Iterations 跑,内层每批 Batch*UpdateTarget 次随机抽样本。随机数用 MathRand()*MathRand()/32767² 把均匀性压到平方级,再乘 total 得到起点 i,total = bars - HistoryBars - 240,留了 240 根作边界缓冲。 每个样本取 HistoryBars 根回看,把 open/close 差、high-low 差、tick_volume/1000、小时/星期/月份,以及四个指标值共 12 维塞进 State1 向量。任一指标为 EMPTY_VALUE 就跳过,避免脏数据进模型。外汇和贵金属杠杆高,这种历史态采样只反映概率分布,实盘信号仍可能失效。 让小布替你跑这套 把 HistoryBars 从默认改到 200 试一次,看 MT5 专家日志里 State1 大小是否稳定在 200*12,若频繁 break 说明你的品种在远端历史有指标空洞。

MQL5 / C++
    start_time.year = class="num">1900;
    class="type">class="kw">datetime st_time = StructToTime(start_time);
class=class="str">"cmt">//---
    class="type">int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates);
    if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
       ExpertRemove();
       class="kw">return;
     }
    if(!ArraySetAsSeries(Rates, true))
     {
       ExpertRemove();
       class="kw">return;
     }
class=class="str">"cmt">//---
    RSI.Refresh();
    CCI.Refresh();
    ATR.Refresh();
    MACD.Refresh();
    class="type">int total = bars - (class="type">int)HistoryBars - class="num">240;
    class="type">bool use_target = class="kw">false;
    for(class="type">int iter = class="num">0; (iter < Iterations && !IsStopped()); iter += UpdateTarget)
     {
       class="type">int i = class="num">0;
       for(class="type">int batch = class="num">0; batch < Batch * UpdateTarget; batch++)
        {
         i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (total));
         State1.Clear();
         State2.Clear();
         class="type">int r = i + (class="type">int)HistoryBars;
         if(r > bars)
           class="kw">continue;
         for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++)
           {
            class="type">int bar_t = r - b;
            class="type">class="kw">float open = (class="type">class="kw">float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            class="type">class="kw">float rsi = (class="type">class="kw">float)RSI.Main(bar_t);
            class="type">class="kw">float cci = (class="type">class="kw">float)CCI.Main(bar_t);
            class="type">class="kw">float atr = (class="type">class="kw">float)ATR.Main(bar_t);
            class="type">class="kw">float macd = (class="type">class="kw">float)MACD.Main(bar_t);
            class="type">class="kw">float sign = (class="type">class="kw">float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
              class="kw">continue;
class=class="str">"cmt">//---
            if(!State1.Add((class="type">class="kw">float)Rates[bar_t].close - open) || !State1.Add((class="type">class="kw">float)Rates[bar_t].high - open) ||
!State1.Add((class="type">class="kw">float)Rates[bar_t].low - open) || !State1.Add((class="type">class="kw">float)Rates[bar_t].tick_volume / class="num">1000.0f) ||
              !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
              !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
              class="kw">break;
            if(!use_target)

把历史K线喂给双网络做强化学习样本

这段逻辑处在训练循环尾部,负责把单根 bar 的衍生特征塞进两个神经网络的输入容器,并在数据不完整时跳过。State2 一次追加 12 个 float:收盘减开盘、最高减开盘、最低减开盘、Tick 量除以 1000、小时、星期几、月份,以及 RSI、CCI、ATR、MACD 主线与信号线,任何一项为 EMPTY_VALUE 就 continue 弃用该 bar。 外层判断若 State1 总样本数不足 HistoryBars*12,或启用目标网络时 State2 也不足该数,则继续攒数据不训练。之后分别对 StudyNet 与(可选)TargetNet 做 feedForward,输入指针偏移 12 维,第三个参数 true 表示训练模式而非推理。 奖励计算取未来第 240 根 bar(约 240 分钟,对 M1 图表即次日后段)的 close-open 作为 reward 基线。若 reward>=0,向 Rewards 容器写入正收益加折扣项,以及负向惩罚项;外汇与贵金属杠杆高,这类基于历史回看窗的强化样本在实际Tick跳空时可能严重偏离训练分布,上 MT5 跑前先调小 HistoryBars 看样本堆积速度。

MQL5 / C++
            class="kw">continue;
            class=class="str">"cmt">//---
            bar_t --;
            open = (class="type">class="kw">float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            rsi = (class="type">class="kw">float)RSI.Main(bar_t);
            cci = (class="type">class="kw">float)CCI.Main(bar_t);
            atr = (class="type">class="kw">float)ATR.Main(bar_t);
            macd = (class="type">class="kw">float)MACD.Main(bar_t);
            sign = (class="type">class="kw">float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
               class="kw">continue;
            class=class="str">"cmt">//---
            if(!State2.Add((class="type">class="kw">float)Rates[bar_t].close - open) || !State2.Add((class="type">class="kw">float)Rates[bar_t].high - open) ||
!State2.Add((class="type">class="kw">float)Rates[bar_t].low - open) || !State2.Add((class="type">class="kw">float)Rates[bar_t].tick_volume / class="num">1000.0f) ||
               !State2.Add(sTime.hour) || !State2.Add(sTime.day_of_week) || !State2.Add(sTime.mon) ||
               !State2.Add(rsi) || !State2.Add(cci) || !State2.Add(atr) || !State2.Add(macd) || !State2.Add(sign))
               class="kw">break;
            }
         if(IsStopped())
           {
            ExpertRemove();
            class="kw">return;
           }
         if(State1.Total() < (class="type">int)HistoryBars * class="num">12 ||
            (use_target && State2.Total() < (class="type">int)HistoryBars * class="num">12))
            class="kw">continue;
         if(!StudyNet.feedForward(GetPointer(State1), class="num">12, true))
            class="kw">return;
         if(use_target)
           {
            if(!TargetNet.feedForward(GetPointer(State2), class="num">12, true))
               class="kw">return;
            TargetNet.getResults(TempData);
           }
         Rewards.Clear();
         class="type">class="kw">double reward = Rates[i - class="num">1 + class="num">240].close - Rates[i - class="num">1 + class="num">240].open;
         if(reward >= class="num">0)
           {
            if(!Rewards.Add((class="type">class="kw">float)(reward + (use_target ? DiscountFactor * TempData.At(class="num">0) : class="num">0))) ||
               !Rewards.Add((class="type">class="kw">float)(-class="num">2 * (use_target ? reward + DiscountFactor * TempData.At(class="num">1) : class="num">0)))
               ||

常见问题

主要坎在状态空间连续化后维度爆炸、网络输出动作价值不稳定、以及样本相关性导致训练发散。先用小规模指标状态试跑,再逐步加特征。
在线网络每步都更新,目标网络每隔固定步数(如 1000 步)软更新或硬拷贝,避免目标值抖动。回测中硬拷贝更稳。
可以。小布能按你设定的指标窗口抽取状态、做随机采样并生成训练样本包,你直接拿去喂双网络,省掉手写数据管道的活。
经验上每轮从缓冲池抽 32~128 条、池子容量大于 1 万条时过拟合概率较低。具体看品种波动,贵金属类建议池子更大。
日线级策略 500~2000 根常见,分钟线需上万根。外汇和贵金属高风险,样本不足时模型易在实盘漂移,先验证再上。