神经网络变得简单(第 58 部分):决策转换器(DT)(基础篇)
📘

神经网络变得简单(第 58 部分):决策转换器(DT)(基础篇)

第 1/3 篇

用决策转换器把交易序列当语言建模

决策转换器(Decision Transformer,DT)把强化学习里的状态、动作、回报当成一段序列来建模,思路类似 GPT 处理句子,不再显式拟合价值函数。在 MT5 里这意味着可以把历史 K 线状态、下单动作、盈亏回报直接拼成 token 流喂给模型。 原文示例于 2024-05-21 发布,在 MQL5 社区获得 1071 次浏览,说明这类把序列模型搬进 EA 的做法已有实盘关注者。 落地时建议先取最近 200 根 H1 欧美蜡烛,把每根的开高低收和持仓方向编码为整数 token,回报用该笔平仓后的净值变化归一化,再送进轻量 Transformer 做下一动作预测。外汇与贵金属杠杆高,模型输出仅作概率参考,实盘前必须在 MT5 策略测试器跑至少一年 Tick 数据。

「从马尔可夫决策到序列建模的转折」

传统 RL 智能体的闭环很朴素:观测当前状态、按学到的策略挑一个动作、环境迁移到新状态、回灌奖励。这一套建立在马尔可夫假设上——脱离当前状态只存在一条不依赖历史路径的最优解,过去怎么走的不影响下一步。 我们在系列前几篇里把这类基础算法铺得很开,结论是一致的:它们都绕不开状态—动作—奖励的逐步递推。 谷歌 2021 年 6 月的《Decision Transformer》给了另一条路:把强化学习重写成「按期望奖励做条件、对动作序列做自回归建模」。也就是说,不再一步步求最优动作,而是直接建模「想要多少回报,就对应生成怎样的动作轨迹」。外汇与贵金属市场高风险,这类序列模型在行情里只是概率性拟合,不是确定性解。

◍ 把交易轨迹当句子来建模

决策转换器(Decision Transformer)把强化学习从「选动作」扭成了「写句子」:智能体过去的动作、状态、奖励被拼成一条轨迹,像语言模型依据上下文续写单词一样去生成下一步动作。它不直接喂历史奖励,而是引入「在途回报(Return-To-Go)」——也就是从当前步到剧集结束期望拿到的累积奖励,作为目标条件。 训练时,我们给模型看的是「我想最终拿到多少」而不是「我过去赚了多少」。推理阶段可以指定初始状态加目标奖励(比如 1 代表成功、0 代表失败)来触发生成;每执行一个动作,就按环境反馈把目标奖励往下扣,循环到总奖励达标或世代结束。注意,若达标后硬续,Return-To-Go 可能变负,模型有概率跑出亏损动作。 喂给模型的不是全量历史,而是最近 K 个时间步,总共 3*K 个令牌:回报、状态、动作各占一种模态。每种模态先过一层全连接层投到统一向量维度再做归一化;状态若是图像类复合输入,可换卷积编码器。每个时间步还有独立时间戳向量,加进该步的 3 个令牌里——这和标准 Transformer 位置编码不同,因为一个时间步对应多个令牌。 训练本身是监督式:先随机采样一批轨迹,再离线抽 K 长迷你包,让输入 s_t 的头预测 a_t,离散动作走交叉熵、连续动作走均方误差,各步损失取平均。作者实测发现,让模型顺带预测下一状态或奖励,对效率没可见提升。外汇与贵金属市场高波动、高杠杆,这套机制仅提供建模思路,实盘落地前须在 MT5 用历史 tick 严格回测。

用共享缓冲区把多模态源数据塞进嵌入层

决策转换器里环境状态、账户状态、奖励、动作、时间戳这 5 类源数据来自不同分布,维度也各不相同,卷积层按固定窗口滑行的老办法直接失效。MQL5 框架下若给每种模态单独建矩阵或动态数组,传到 OpenCL 做并行计算会非常笨重,内核数量随模态增加膨胀,还牺牲了并行度。 实际可行的通用做法是只用两个缓冲区:一个按序列一致地铺进所有源数据,另一个存每种模态的窗口大小作为“数据映射”。这样不论模态多少、各自长度多少,内核算法都不用改,能一次性并行嵌入所有模态。 权重侧也碰到类似问题,但所有模态的嵌入维度相等,于是可共用一个权重矩阵:列数=单模态嵌入大小,行数=源数据总量,再为每模态加一行贝叶斯偏置。 自回归特性意味着每步只进 N 个新令牌,前面 (K-1)*N 个旧令牌重复。训练后权重固定时重复嵌入毫无意义,所以只嵌入最新模态、旧嵌入直接顺移,能明显省下训练和推理的资源。 定位编码没走方法作者那种往动作序列插令牌的路,而是把时间戳做成单独模态并行嵌入,代价是数据量变大,具体选哪种要看你程序里资源和精度的权衡。 前向内核跑两个维度:一维嵌入大小,二维模态数。它从 inputs 取原始序列,按 windows 映射找偏移,乘权重后做零均值、单位方差归一化,标准差存进 std 缓冲区供反向用。为省资源,均值和方差在反向时当常数处理,不传它们的梯度。 反向的 EmbeddingHiddenGradient 内核在一维空间按源数据元素数调度,先扫映射定位模态,再据 std 里存的标准差把结果梯度调回前层。权重更新用惯用的 Adam 内核,区别仅在缓冲区偏移计算。 主程序侧新建 CNeuronEmbeddingOCL 类继承 CNeuronBaseOCL。Init 时传 stack_size、window_out、windows[],据映射累加得源数据体量,建权重矩阵用随机值、力矩矩阵用零值;std 缓冲区只在 OpenCL 内存里建。feedForward 里把内核排进二维任务空间,局部组第二维设为 1,第一维等于嵌入大小。开 MT5 把这套类挂上,先拿 5 模态小历史深度跑通前向,再逐步加 K 验证资源占用。外汇与贵金属行情高波动,任何嵌入方案都只是降低建模成本,不预示方向。

「嵌入栈清零与决策变换器的训练数据喂法」

决策变换器作为自回归模型,对输入数据的一致性极为敏感。我们只在每个时间步喂入新数据,历史深度靠复用 CNeuronEmbeddingOCL 层的输出缓冲区来模拟,这降低了主数据处理开销,但也带来一个硬约束:训练和操作时的初始数据必须一致。训练常用随机样本,为防止原始数据“临时跳转”或切换轨迹时污染嵌入栈,Clear 方法直接用零值填充整个缓冲区再拷进 OpenCL 内存。 源数据层只装系统最后状态(奖励、环境、账户、时间戳、上次动作),后面接批量归一化与嵌入层,嵌入层后放稀疏注意力模块 defNeuronMLMHSparseAttentionOCL,能把可分析历史深度明显拉长,代价是资源占用和推理时间略增。模型尾端用全连接决策模块加 VAE 潜层制造策略随机性,且我们不对序列末令牌解码,而是分析整条序列再做决定。 训练 EA “\DT\Study.mq5” 的 Train 方法里,先从经验回放随机抽一条轨迹和其中一个状态,随后清掉嵌入缓冲区和上次动作向量,再嵌套循环按与环境交互的顺序喂入 3 倍历史深度的数据:价格走势、账户状态、时间戳,以及轨迹末尾的实际累积奖励(在途回报)。目标值直接取智能体实采动作,本质是监督训练——靠“在途回报”令牌把动作和真实奖励绑定,训练好的自回归模型才可能在实盘生成趋近期待结果的动作。外汇与贵金属市场高杠杆、高风险,这种概率性优势不代表稳定盈利。 作者原建议连续动作空间用 MSE,我们补了 CAGrad。下面这段 OpenCL 内核展示了嵌入层怎么把上一时间步数据整体后移、并按窗口累加算输入偏移,是理解栈式喂数的底层视角。

MQL5 / C++
__kernel <span class="keyword">class="type">void</span> Embedding(__global <span class="keyword">class="type">class="kw">float</span> *inputs,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;__global <span class="keyword">class="type">class="kw">float</span> *outputs,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;__global <span class="keyword">class="type">class="kw">float</span> *weights,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;__global <span class="keyword">class="type">int</span>&nbsp;&nbsp; *windows,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;__global <span class="keyword">class="type">class="kw">float</span> *std,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">const</span> <span class="keyword">class="type">int</span> stack_size
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; )
&nbsp;&nbsp;{
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> window_out = get_global_size(<span class="number">class="num">0</span>);
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> pos = get_local_id(<span class="number">class="num">0</span>);
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> emb = get_global_id(<span class="number">class="num">1</span>);
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> emb_total = get_global_size(<span class="number">class="num">1</span>);
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> shift_out = emb * window_out + pos;
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> step = emb_total * window_out;
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">uint</span> ls = min((<span class="keyword">class="type">uint</span>)get_local_size(<span class="number">class="num">0</span>), (<span class="keyword">class="type">uint</span>)LOCAL_ARRAY_SIZE);
&nbsp;&nbsp; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> i=stack_size-<span class="number">class="num">1</span>;i&gt;<span class="number">class="num">0</span>;i--)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;outputs[i*step+shift_out]=outputs[(i-<span class="number">class="num">1</span>)*step+shift_out];
&nbsp;&nbsp; <span class="keyword">class="type">int</span> shift_in = <span class="number">class="num">0</span>;
&nbsp;&nbsp; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> i = <span class="number">class="num">0</span>; i &lt; emb; i++)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;shift_in += windows[i];
&nbsp;&nbsp; <span class="keyword">const</span> <span class="keyword">class="type">int</span> shift_weights = (shift_in + emb) * window_out;

◍ 在 GPU 上做嵌入层归一化与方差回传

这段 OpenCL 内核把嵌入层的前向归一化和梯度统计塞进了本地内存,避免反复读写全局显存。核心思路是用 local_pos 与 local_order 把一维线程映射回窗口内的局部坐标,local_orders 决定每个线程要累加几次。 前向部分先用 weights[shift_weights + window_in] 取偏置,再对 window_in 长度的输入做点积;随后用 do-while 的折半归约(count 从 ls 起每次 (count+1)/2)把 temp 数组加到 temp[0],得到窗口均值与方差。 归一化时 value 减去均值并除以 std 的平方根,方差项则通过 pow(value,2.0f)/window_out 再次归约得到。若 temp[0] > 0 才做除法,pos==0 时把 sqrt(temp[0]) 写回 std[emb] 供反向使用。 反向内核 EmbeddingHiddenGradient 接收 outputs_gradient 与 std,参数列表里 window_out 直接以 const int 传入,说明输出窗口大小在编译期或调用时固定。开 MT5 的 OpenCL 采样器跑这段,重点看 ls 取值——本地数组尺寸不够会直接编译失败,调大 LOCAL_ARRAY_SIZE 可能让 EURUSD 的 M15 嵌入推理延迟降一截,但外汇高杠杆下模型信号仅作概率参考。

MQL5 / C++
  const class="type">int window_in = windows[emb];
  const class="type">int local_pos = (pos >= ls ? pos % (ls - class="num">1) : pos);
  const class="type">int local_orders = (window_out + ls - class="num">1) / ls;
  const class="type">int local_order = pos / ls;
  __local class="type">class="kw">float temp[LOCAL_ARRAY_SIZE];
  if(local_order == class="num">0)
      temp[local_pos] = class="num">0;
  barrier(CLK_LOCAL_MEM_FENCE);
  class="type">class="kw">float value = weights[shift_weights + window_in];
  for(class="type">int i = class="num">0; i < window_in; i++)
      value += inputs[shift_in + i] * weights[shift_weights + i];
  for(class="type">int i = class="num">0; i < local_orders; i++)
    {
      if(i == local_order)
        temp[local_pos] += value;
      barrier(CLK_LOCAL_MEM_FENCE);
    }
class=class="str">"cmt">//---
  class="type">int count = ls;
  do
    {
      count = (count + class="num">1) / class="num">2;
      if(pos < count)
        temp[pos] += temp[pos + count];
      barrier(CLK_LOCAL_MEM_FENCE);
    }
  while(count > class="num">1);
class=class="str">"cmt">//---
  value -= temp[class="num">0] / (class="type">class="kw">float)window_out;
  barrier(CLK_LOCAL_MEM_FENCE);
  if(local_order == class="num">0)
      temp[local_pos] = class="num">0;
  barrier(CLK_LOCAL_MEM_FENCE);
class=class="str">"cmt">//---
  for(class="type">int i = class="num">0; i < local_orders; i++)
    {
      if(i == local_order)
        temp[local_pos] += pow(value,class="num">2.0f) / (class="type">class="kw">float)window_out;
      barrier(CLK_LOCAL_MEM_FENCE);
    }
class=class="str">"cmt">//---
  count = ls;
  do
    {
      count = (count + class="num">1) / class="num">2;
      if(pos < count)
        temp[pos] += temp[pos + count];
      barrier(CLK_LOCAL_MEM_FENCE);
    }
  while(count > class="num">1);
class=class="str">"cmt">//---
  if(temp[class="num">0] > class="num">0)
      value /= sqrt(temp[class="num">0]);
  outputs[shift_out] = value;
  if(pos == class="num">0)
      std[emb] = sqrt(temp[class="num">0]);
  }
__kernel class="type">void EmbeddingHiddenGradient(__global class="type">class="kw">float *inputs_gradient,
                                     __global class="type">class="kw">float *outputs_gradient,
                                     __global class="type">class="kw">float *weights,
                                     __global class="type">int   *windows,
                                     __global class="type">class="kw">float *std,
                                     const class="type">int window_out

常见问题

将每笔交易的 state、action、return 按时间顺序拼成一段序列,类似把句子拆成词,再统一送进模型训练。
用共享缓冲区把不同模态的源数据先归一化再堆叠,作为统一输入送嵌入层,避免各自独立编码导致维度错乱。
小布可自动整理多模态交易序列并生成嵌入栈清零与喂数据脚本,你只需导入品种和周期即可在本地 GPU 上验证。
归一化后需手动保持方差梯度连通,否则回传时尺度漂移会让训练不稳定,建议用支持自动微分的算子。
模型会参考整段历史轨迹而非单步状态,可能提升对连涨连跌节奏的捕捉,但外汇贵金属高风险,需先回测确认。