笔记 | PyTorch强化学习实战(18)——基于DQN处理股票交易问题(整理分享)

最近在做优化的时候涉及到了这块内容,觉得值得写下来,方便以后翻阅。

PyTorch强化学习实战(18)——基于DQN处理股票交易麻烦

8. 模型改进策略小结系列链接

0. 前言

在本节中,我们将尝试运用深度Q网络 (Deep Q-Network, DQN)知识来处理更具现实意义的金融交易问题。我们的目标旨在突破 Atari 游戏的局限,展示如何将强化学习 (Reinforcement Learning, RL)应用于另一个实际领域。

1. 股票交易问题与强化学习

每日市场交易涵盖大量金融工具:大宗商品、股票、外汇等。交易活动即通过买卖金融工具实现不同目标:盈利(投资)、规避价格波动风险(对冲),或单纯满足需求(如采购钢材或将美元兑换欧元支付合同款)。自首个金融市场建立以来,人们一直在尝试预测未来的价格走势。这一问题异常复杂,大量金融顾问、投资基金、银行及独立交易者都在试图探索市场预测方式,寻求最佳买卖时机以实现利润最大化。

那么我们能否从强化学习 (Reinforcement Learning, RL)视角审视该问题?假设我们获取了市场观测数据,需要作出决策:买入、卖出或观望。若在价格上涨前买入将获得正收益,反之则得到负回报。我们的目标是最大化收益。市场交易与强化学习之间显然存在联系,下面我们更清晰地定义问题框架。

2. 问题陈述与关键决策

金融领域庞大而复杂,即使每日学习新知识也需耗费数年才能深入。在本节中,我们将利用 RL 工具对问题进行简单探索,并将问题极简表述为:以价格作为观测值,探究智能体能否学会最佳时机买入单一股票并通过平仓实现利润最大化。本节旨在展示 RL 模型的灵活性,以及将 RL 应用于现实场景通常需采取的基础步骤。
构建 RL 问题需要三个要素:环境观测、可行动作集和奖励机制。在 Pong 环境中,这三者皆已给定,且环境的内部机制是隐藏的。而在本节我们面临截然不同的情境,我们需要自己决定智能体将观测什么以及它也能采取的动作集。奖励机制也非严格既定规则,而是由我们的领域直觉与知识所引导,这为我们提供了极大的灵活性。
这种灵活性既带来优势也伴随挑战。优势在于我们也能自主决定向智能体传递哪些被觉得对高效学习至关重要的信息——举个例子不仅提供价格数据,还可加入新闻或重要统计指标。挑战则在于:这种自由度通常意味着需要尝试大量数据表征变体才能找到优质智能体,且往往难以预判哪种方式更有效。在本节中,我们将以最简单的形式实现基础交易智能体:

  • 观测值包含以下信息:
N 根历史 K 线(每根K线包含开盘价、最高价、最低价、收盘价)
  • 持仓状态标识(每次只能买入一只股票)
  • 当前持仓(已购买的股票)的利润或亏损情况
动作集:在每一步,每分钟的 K 线完成后,智能体也能采取以下动作之一:
  • 空操作:跳过当前时段不采取行动
  • 买入股票:若已持有股票,则不再购买;否则支付佣金(通常为当前价格的微小比例)
  • 平仓:若未持有股票则不操作;否则,需要支付交易佣金
奖励机制:智能体获得的奖励可通过不同方式呈现:
  • 方案A:在持股期间将奖励拆分为多步发放,每步奖励等于最新K线的价格波动
  • 方案B:仅在平仓操作后一次性发放全额奖励
表面上看两种奖励方案最终结果应一致,但收敛速度可能不同。实际应用中差异可能相当显著。本节实现方案的环境同时支持两种模式,可以尝试两者之间的差异。 最终需要决定的是如何在环境观测中表征价格。理想情况下,我们希望智能体不受绝对价格值影响,而是关注相对变动——举个例子"该股票上一根 K 线期间涨幅 1% “或"跌幅 5%”。这种做法很有意义,因为不同股票价格可能差异巨大,但往往呈现相似的价格波动模式。金融领域的技术分析学派就专门研究这类模式以预测走势。我们希望系统能自动发现这些模式。为了实现这一目标,我们将把每根 K 线的开盘价、最高价、最低价和收盘价转换为三个数字,表示相对于开盘价的最高价、最低价和收盘价的百分比。 这种表征方式也存在缺陷:我们可能丢失关键价格位信息。举个例子市场已知会从整数关口和历史转折点产生反弹。但如前所述,我们在本节仅进行概念验证实验。采用相对价格变动表征将有助于系统发现价格波动中的重复模式,而不受绝对价格水平干扰。理论上神经网络本可自行学习这种关系(只需从绝对值中减去均价即可),但相对表征能显著简化神经网络的学习任务。

3. 数据集

在本节中,我们将使用 2015-2016 年期间的俄罗斯股市价格,数据位于 small-quotes.zip 中,下载完成后解压。在压缩包内,包含 M1 级别的 K 线 CSV 文件( M1 即每分钟一条数据),每行记录对应一分钟内的价格波动,包含四个价格字段:
开盘价:该分钟起始时刻的价格
最高价:该时间段内的峰值价格
最低价:该时间段内的谷值价格
收盘价:该分钟完成时刻的最终价格

每一分钟的区间称为一根K线,通过这四个价格可还原该时段内的价格波动情况。以 YNDX_160101_161231.csv 文件(包含 2016Yandex 公司股票数据)为例,该文件包含 13 万行如下格式数据:

前两列数据分别表示该分钟对应的日期与时间;随后四列依次为开盘价、最高价、最低价和收盘价;最终一列数值代表该时间段内完成的买卖订单总量(也称为成交量)。成交量的具体解读因市场而异,但通常能反映该时段的市场活跃度。
这类价格数据的典型可视化方式称为 K 线图,里面每根 K 线以蜡烛形态呈现。下图展示了某交易日 Yandex 股票的部分报价数据:

压缩包内包含 2016 年和 2015 年的两个 M1 数据文件。我们将使用 2016 年数据进行模型训练,2015 年数据用于验证(我们也可以互换两者甚至采用不同时间段数据并观察效果差异)。

4. 交易环境

我们将遵循 GymnasiumEnv 类规范实现交易功能。交易环境实现在 environ.py 模块的 StocksEnv 类中,该类通过多个内部类维护状态并编码观测值。

(1) 首先实现公共 API 类:

import typing as tt
import gymnasium as gym
from gymnasium import spaces
from gymnasium.envs.registration import EnvSpec
import enum
import numpy as np

import data

DEFAULT_BARS_COUNT = 10
DEFAULT_COMMISSION_PERC = 0.1

class Actions(enum.Enum):
    Skip = 0
    Buy = 1
    Close = 2

我们将所有可用动作编码为枚举字段,并只提供三种动作:空操作、买入股票、以及平仓。
在本节的市场模型中,仅允许买入单份股票,既不支持增持现有股票,也不允许卖出并未持有的股票,旨在保持简洁性并避免过度复杂化。

(2) 接下来,实现环境类:

class StocksEnv(gym.Env):
    spec = EnvSpec("StocksEnv-v0")

spec 字段是为了与 gym.Env 兼容并将我们的环境注册到 Gymnasium 的内部注册表中。该类提供两种实例化方式:

@classmethod
    def from_dir(cls, data_dir: str, **kwargs):
        prices = {
            file: data.load_relative(file)
            for file in data.price_files(data_dir)
        }
        return StocksEnv(prices, **kwargs)

如上述代码所示,第一种是通过调用类方式 from_dir 并传入数据目录参数。这种方式会加载目录下所有 CSV 文件的行情数据并构建环境。为处理特定格式的价格数据,我们在 data.py 中提供了若干辅助函数。另一种方式是直接构造类实例。这种方式需要传入 prices 字典参数,该字典需将股票名称映射到 data.py 中声明的 Prices 数据类。该对象包含五个字段,分别以一维 NumPy 数组形式存储开盘价、最高价、最低价、收盘价和成交量时间序列。data.py 模块还提供了价格相对格式转换、遍历目录文件等辅助功能。

(3) 环境类的构造函数,接受多个参数以调整环境行为与观测表示:

def __init__(
            self, prices: tt.Dict[str, data.Prices],
            bars_count: int = DEFAULT_BARS_COUNT,
            commission: float = DEFAULT_COMMISSION_PERC,
            reset_on_close: bool = True, state_1d: bool = False,
            random_ofs_on_reset: bool = True,
            reward_on_close: bool = False, volumes=False
    ):

  • prices:包含一个或多个股票价格的数据字典,里面键是股票的名称,值为存储价格数组的 data.Prices 容器对象
  • bars_count:传入观测值的K线数量,默觉得 10
  • commission:买卖股票时支付给经纪商的股票价格的百分比,默觉得 0.1%
  • reset_on_close:若该参数设置为 True (默认值),每当智能体平仓时(即卖出股票)即终止当前训练回合;否则,回合将持续到时间序列终点,即一年数据的完成
  • conv_1d:布尔参数,用于切换观测值中价格数据的表示方式。若设置为 True,观测值呈二维结构,里面不同行分别存储后续柱线的不同价格分量(第一行为最高价、第二行为最低价、第三行为收盘价)。这种表示方式适用于对时间序列进行一维卷积操作,数据中每行的语义作用类似于 Atari 2D 图像中的不同颜色通道(红/绿/蓝)。设为 False 时,数据将以单一数组形式呈现,每个柱线的所有分量连续存储,这种结构更适合全连接网络架构。两种表示方式如下图所示
  • random_ofs_on_reset:当参数为 True (默认值)时,每次环境重置都会在时间序列中随机选择起始偏移量,否则将从数据起始点开始
  • reward_on_close:此布尔参数用于切换前文讨论的两种奖励机制。设为 True 时,仅当智能体发出"平仓"指令后才发放奖励;否则,我们会在每一根K线后给出奖励,奖励对应的是该K线期间的价格变化
  • volumes:该参数控制是否在观测中启用成交量数据,默认处于关闭状态

(4) 接下来,我们继续分析环境类的构造函数:

self._prices = prices
        if state_1d:
            self._state = State1D(bars_count, commission, reset_on_close,
                                  reward_on_close=reward_on_close, volumes=volumes)
        else:
            self._state = State(bars_count, commission, reset_on_close,
                                reward_on_close=reward_on_close, volumes=volumes)
        self.action_space = spaces.Discrete(n=len(Actions))
        self.observation_space = spaces.Box(
            low=-np.inf, high=np.inf, shape=self._state.shape, dtype=np.float32)
        self.random_ofs_on_reset = random_ofs_on_reset

StocksEnv 类的大部分功能是通过两个内部类实现的:StateState1D。观测值预处理、持仓状态管理及奖励计算,并在观测值中采用不同的数据表示方式。在构造函数中,我们创建了 Gymnasium 框架必需的 state 对象、动作空间及观测空间字段。

(5) 定义环境的 reset() 功能:

def reset(self, *, seed: int | None = None, options: dict[str, tt.Any] | None = None):
        # make selection of the instrument and it's offset. Then reset the state
        super().reset(seed=seed, options=options)
        self._instrument = self.np_random.choice(list(self._prices.keys()))
        prices = self._prices[self._instrument]
        bars = self._state.bars_count
        if self.random_ofs_on_reset:
            offset = self.np_random.choice(prices.high.shape[0]-bars*10) + bars
        else:
            offset = bars
        self._state.reset(prices, offset)
        return self._state.encode(), {}

根据 gym.Env 的语义规范,我们会随机选择要操作的时间序列并确定其起始偏移量。选定的价格数据和偏移量将传递给内部状态实例,该实例随后通过其 encode() 函数获取初始观测值。

(6) step 方法负责处理智能体选择的动作,并返回下一个观测值、奖励值和结束标志:

def step(self, action_idx: int) -> tt.Tuple[np.ndarray, float, bool, bool, dict]:
        action = Actions(action_idx)
        reward, done = self._state.step(action)
        obs = self._state.encode()
        info = {
            "instrument": self._instrument,
            "offset": self._state._offset
        }
        return obs, reward, done, False, info

所有核心功能均在状态类 State 中实现,因此本方法仅是对状态类方法调用的简单封装。
gym.EnvAPI 允许开发者定义 render() 方法处理程序,用于以人类可读或机器可读格式呈现当前状态。通常该方法用于查看环境状态,对调试或追踪智能体行为相当有用。例如,市场环境可以将当前价格渲染成图表以可视化智能体当前所见东西。本环境不支持渲染功能(因此功能为可选实现),故我们完全未定义此函数。

(7) 接下来,我们查看实现环境核心功能的内部类 environ.State

class State:
    def __init__(self, bars_count: int, commission_perc: float, reset_on_close: bool,
                 reward_on_close: bool = True, volumes: bool = True):
        assert bars_count > 0
        assert commission_perc >= 0.0
        self.bars_count = bars_count
        self.commission_perc = commission_perc
        self.reset_on_close = reset_on_close
        self.reward_on_close = reward_on_close
        self.volumes = volumes
        self.have_position = False
        self.open_price = 0.0
        self._prices = None
        self._offset = None

构造函数仅执行参数检查并将参数存储至对象字段中。

(8) 每次环境被要求重置时都会调用 reset() 方法,该方法必须保存传入的价格数据和起始偏移量:

def reset(self, prices: data.Prices, offset: int):
        assert offset >= self.bars_count-1
        self.have_position = False
        self.open_price = 0.0
        self._prices = prices
        self._offset = offset

初始状态下我们未持有任何股票,因此状态参数设置为 have_position=Falseopen_price=0.0

(9) shape 属性返回编码状态 NumPy 数组的维度元组:

@property
    def shape(self) -> tt.Tuple[int, ...]:
        # [h, l, c] * bars + position_flag + rel_profit
        if self.volumes:
            return 4 * self.bars_count + 1 + 1,
        else:
            return 3 * self.bars_count + 1 + 1,

State 类被编码为单一向量(对应上图中的上半部分),包含可选成交量价格数据以及两个数值:持仓状态标志位和持仓盈亏数值。

(10) encode() 方法将当前偏移量对应的价格数据打包成 NumPy 数组,该数组将作为智能体的观测值:

def encode(self) -> np.ndarray:
        """
        Convert current state into numpy array.
        """
        res = np.ndarray(shape=self.shape, dtype=np.float32)
        shift = 0
        for bar_idx in range(-self.bars_count+1, 1):
            ofs = self._offset + bar_idx
            res[shift] = self._prices.high[ofs]
            shift += 1
            res[shift] = self._prices.low[ofs]
            shift += 1
            res[shift] = self._prices.close[ofs]
            shift += 1
            if self.volumes:
                res[shift] = self._prices.volume[ofs]
                shift += 1
        res[shift] = float(self.have_position)
        shift += 1
        if not self.have_position:
            res[shift] = 0.0
        else:
            res[shift] = self._cur_close() / self.open_price - 1.0
        return res

(11) 辅助方法 _cur_close() 用于计算当前 K 线的收盘价:

def _cur_close(self) -> float:
        """
        Calculate real close price for the current bar
        """
        open = self._prices.open[self._offset]
        rel_close = self._prices.close[self._offset]
        return open * (1.0 + rel_close)

传入 State 类的价格数据采用相对于开盘价的相对形式:最高价、最低价和收盘价分量均为相对于开盘价的比例。这种表示形式可能有助于智能体学习与实际价格值无关的价格模式。

(12) step() 方法是 State 类中最复杂的代码部分:

def step(self, action: Actions) -> tt.Tuple[float, bool]:
        reward = 0.0
        done = False
        close = self._cur_close()

该方法负责在环境中执行一步操作,并在返回时必须提供百分比形式的奖励值以及回合结束标志。

(13) 当智能体决定买入股票时,我们将更新状态并支付交易佣金:

if action == Actions.Buy and not self.have_position:
            self.have_position = True
            self.open_price = close
            reward -= self.commission_perc

在我们的状态模型中,我们假设订单会以当前 K 线收盘价即时成交(这是经过简化的处理方式)。实际交易中订单可能以不同价格执行,这种现象称为价格滑点。

(14) 如果我们当前持有股票且智能体要求平仓,我们需要再次支付佣金:在 reset_on_close 模式下会更新结束标识 done,计算整个持仓周期的最终奖励,并更新状态:

elif action == Actions.Close and self.have_position:
            reward -= self.commission_perc
            done |= self.reset_on_close
            if self.reward_on_close:
                reward += 100.0 * (close / self.open_price - 1.0)
            self.have_position = False
            self.open_price = 0.0

(15) 在函数的剩余部分,我们会调整当前偏移量并根据最近 K 线的价格波动计算奖励:

self._offset += 1
        prev_close = close
        close = self._cur_close()
        done |= self._offset >= self._prices.close.shape[0]-1

        if self.have_position and not self.reward_on_close:
            reward += 100.0 * (close / prev_close - 1.0)

        return reward, done

(16) 接下来,实现 State1D 类,它有相同的行为,该类具有完全相同的功能,仅重写了传递给智能体的状态表示形式:

class State1D(State):
    """
    State with shape suitable for 1D convolution
    """
    @property
    def shape(self) -> tt.Tuple[int, ...]:
        if self.volumes:
            return 6, self.bars_count
        else:
            return 5, self.bars_count

这种表示形式的维度结构有所不同,因为价格数据被编码为适用于一维卷积运算的二维矩阵。

(17) encode 方法根据当前偏移量、是否需要成交量数据以及是否持有股票,将价格数据编码到矩阵中:

def encode(self) -> np.ndarray:
        res = np.zeros(shape=self.shape, dtype=np.float32)
        start = self._offset-(self.bars_count-1)
        stop = self._offset+1
        res[0] = self._prices.high[start:stop]
        res[1] = self._prices.low[start:stop]
        res[2] = self._prices.close[start:stop]
        if self.volumes:
            res[3] = self._prices.volume[start:stop]
            dst = 4
        else:
            dst = 3
        if self.have_position:
            res[dst] = 1.0
            res[dst+1] = self._cur_close() / self.open_price - 1.0
        return res

至此我们已完成交易环境的构建。其与 Gymnasium API 的兼容性使我们能够将其接入之前处理 Atari 游戏时使用的类中。

5. 模型

本节采用两种 DQN 架构:一个包含三层的简单前馈网络,以及以一个带有一维卷积作为特征提取器并接续两个全连接层输出Q值的网络。两者均采用 Dueling DQN 架构,同时应用了 Double DQN两步贝尔曼展开 (two-step Bellman unrolling)技术,其余流程与经典 DQN 保持一致。相关模型在 models.py 中实现。

(1) 首先从前馈模型开始:

class SimpleFFDQN(nn.Module):
    def __init__(self, obs_len: int, actions_n: int):
        super(SimpleFFDQN, self).__init__()

        self.fc_val = nn.Sequential(
            nn.Linear(obs_len, 512),
            nn.ReLU(),
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, 1)
        )

        self.fc_adv = nn.Sequential(
            nn.Linear(obs_len, 512),
            nn.ReLU(),
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, actions_n)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        val = self.fc_val(x)
        adv = self.fc_adv(x)
        return val + (adv - adv.mean(dim=1, keepdim=True))

前馈模型使用独立的网络来预测Q值和优势值。

(2) 卷积模型则采用共享的一维卷积层进行特征提取,后接两个全连接分支头分别输出状态价值 (State Value) 和动作优势值 (Action Advantages):

class DQNConv1D(nn.Module):
    def __init__(self, shape: tt.Tuple[int, ...], actions_n: int):
        super(DQNConv1D, self).__init__()

        self.conv = nn.Sequential(
            nn.Conv1d(shape[0], 128, 5),
            nn.ReLU(),
            nn.Conv1d(128, 128, 5),
            nn.ReLU(),
            nn.Flatten(),
        )
        size = self.conv(torch.zeros(1, *shape)).size()[-1]

        self.fc_val = nn.Sequential(
            nn.Linear(size, 512),
            nn.ReLU(),
            nn.Linear(512, 1)
        )

        self.fc_adv = nn.Sequential(
            nn.Linear(size, 512),
            nn.ReLU(),
            nn.Linear(512, actions_n)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        conv_out = self.conv(x)
        val = self.fc_val(conv_out)
        adv = self.fc_adv(conv_out)
        return val + (adv - adv.mean(dim=1, keepdim=True))

可以看到,该模型与我们在Atari 游戏中使用的 Dueling DQN 架构相当相似。

6. 模型训练

本节包含两个高度相似的训练模块,分别对应前馈模型和一维卷积模型:

  • 采用ε-贪婪策略动作选择方法进行探索:
ε

ε

ε 值在最初 100 万步中从 1.0 线性衰减至 0.1

  • 使用容量为 10 万的经验回放缓冲区,并预先填充 1 万条转移样本
  • 每训练 1000 步:计算固定状态集的均值以监测Q值动态变化
  • 10 万步执行验证:在训练数据和未见过的数据上各运行 100 回合,记录平均利润、持仓周期和持股状态等验证指标至 TensorBoard,用于检测过拟合现象

训练模块位于 train_model.py (前馈模型)和 train_model_conv.py (带有 1D 卷积层)。两个版本接受相同的命令行参数:

  • 通过 --data 指定训练数据(支持单个 CSV 文件或目录),默认使用 2016Yandex 行情数据 (data/YNDX_160101_161231.csv)
  • 通过 --val 指定验证数据(默认使用 2015Yandex 数据)
  • 必须通过 -r 参数指定运行名称,用于 TensorBoard 记录和保存模型的目录

7. 运行结果

完成模型实现后,比较这两个模型的表现,首先从前馈模型开始。

7.1 前馈模型

前馈模型训练过程中,智能体获得的平均奖励呈现缓慢但持续的增长趋势。在完成 30 万次回合后,增长速率逐渐放缓。下图展示了训练过程中的奖励值:

下图展示了基于相同训练数据(但禁用随机行动,即 epsilon=0)的测试奖励结果:

训练与测试奖励图均表明,智能体正在逐步掌握提升收益的策略。随着智能体意识到持有股票可能带来利润,在 10 万次训练回合后,每个训练回合的持续时间也呈现增长趋势。

此外,我们监测了随机状态集的预测值。下图表明在训练过程中,神经网络对这些状态的预测变得越来越乐观:

目前所有图表均显示良好,但需留意的是这些数据均基于训练集得出。虽然智能体在历史数据上展现出盈利能力,但其在未知数据上的表现仍需验证。下图展示了智能体在 2015 年数据上的验证结果:

从图中可以看出,奖励值并未呈现上升趋势。通过平滑处理后甚至可观察到相反现象——训练启动约一小时后(对应训练回合时长显著增长的时间点),奖励值开始缓慢下降。这可能表明智能体在经历 100 万次训练迭代后出现了过拟合。但即使如此,在前 4 小时训练中,奖励值始终高于 -0.2% (该数值代表交易环境中的双向佣金成本:买入和卖出各收取 0.1%),这表明智能体表现仍优于随机交易的基准策略。
训练过程中,代码会持续保存模型以供后续使用:当预留状态集的Q值均值突破历史记录,或验证集奖励刷新最佳成绩时即触发保存机制。run_model.py 用于加载已存储模型对指定行情数据执行交易模拟,并生成收益曲线图,使用方法如下:

$ ./run_model.py -d data/YNDX_160101_161231.csv -m saves/simple-t1/mean_value-0.277.data

该工具支持的命令行参数如下:

  • -d:指定行情数据路径(示例中使用的训练数据与模型训练集相同)
  • -m:模型文件路径(训练模块默认将模型存储于 saves 目录)
  • -b:设定输入模型的时序窗口长度(必须与训练时设置的条形图数量保持一致,默认值为 10,可在训练代码中调整)
  • -n:输出图像文件名的后缀标识
  • --commission:重置佣金费率(默认值为 0.1%)
运行结束后,工具将生成资产总收益动态变化曲线图(以百分比显示)。下图展示的是在 Yandex 2016 行情数据(即训练数据集)上的奖励表现:

训练数据集上的表现令人惊叹:一年内实现 150% 的收益率。但正如 TensorBoard 验证图表所示,该模型在验证集上的表现远逊于此。
为验证零佣金环境下的系统盈利能力,我们可以使用 --commission 0.0 选项在相同的数据上重新运行:
虽然存在部分回撤交易日,但整体表现良好:在零佣金条件下智能体能够持续盈利。需要说明的是,佣金并非唯一影响因素——当前订单模拟系统过于简化,未考虑实际交易中的价差 (spread) 和订单执行滑点 (slippage) 等真实场景。
若采用验证集奖励最高的模型,其收益动态曲线略有改善:虽然绝对收益率降低,但在未知行情数据上的回撤幅度显著收窄(下图已启用默认 0.1% 佣金设置):

但需要强调的是:基于验证集结果选择最佳模型属于违规操作——这种做法实质上破坏了验证机制的根本意义。上述图表仅用于说明某些模型在未知数据上仍可能保持良好性能。

7.2 卷积模型

本例实现的第二个模型采用一维卷积滤波器从价格数据中提取特征。这使得我们能够在不显著增加网络规模的情况下,增加智能体在每个步骤中可感知的上下文窗口中的条数。默认情况下,卷积模型使用 50 条上下文数据。训练代码位于 train_model_conv.py,并接受与前馈模型版本相同的命令行参数。
虽然训练动态曲线高度相似,但该模型在验证集上获得了更高的奖励值,且过拟合现象出现时间明显延后:

8. 模型改进策略

如前所述,金融市场规模庞大且结构复杂。运用强化学习构建完整且可盈利的交易策略是一项系统工程,需要投入大量时间进行专项研究。但我们可以通过以下方向深化理解:

  • 当前数据表征方式存在明显优化空间。我们尚未纳入关键价格水平(支撑位与阻力位)、整数关口价位及其他市场信息。将这些要素整合至观测系统中是一个值得探索的挑战
  • 分跨时间维度分析市场价格。分钟级K线图等高频数据包含大量由零散交易引发的微小波动,如同通过显微镜观察市场,噪声干扰显著。若切换至小时K线或日K线等宏观维度,则能清晰捕捉长期趋势轨迹,这对价格预测具有重要价值。
  • 理论上,智能体可同步观测多尺度价格数据——既能关注近期微观波动,亦可把握宏观趋势( Transformer 架构、留意力机制及长上下文窗口等创新技术可能可以提供重要帮助)
  • 使用更多训练数据。对于一个股票,一年的数据只有 13 万个条形图,这可能不足以捕捉所有的市场情况。单只股票年度数据仅含 13 万条 K 线图,难以覆盖所有市场情境。理想状态下,智能体应基于更大规模数据集训练,例如跨越多年的数百只股票价格历史
  • 尝试不同的网络架构。卷积模型虽比前馈模型展现更快的收敛速度,但仍有大量可优化环节:网络层数、卷积核尺寸、残差结构、留意力机制等
  • 自然语言处理 (Natural Language Processing, NLP) 与金融数据分析存在高度相似性:二者皆处理人类可变长度序列数据。可以将价格 K 线图转化为"金融领域"中的"词元"(例如"上涨 1%"对应词元A,"上涨 2%"对应词元B),进而应用 NLP 技术处理金融数据。例如通过“金融词元”构成的"语句"训练嵌入向量以捕捉市场结构特征,或采用 Transformer 乃至大语言模型进行数据预测与分类

小结

本节通过强化学习实战案例,完整实现了股票交易智能体及自定义 Gymnasium 环境。我们对比测试了两种神经网络架构:接收价格历史数据输入的前馈网络与一维卷积网络。两种架构均采用深度Q网络 (Deep Q-Network, DQN)方法,并融合了若干 DQN 改进技术。

系列链接

PyTorch强化学习实战(1)——强化学习(Reinforcement Learning,RL)详解
PyTorch强化学习实战(2)——强化学习环境库Gymnasium
PyTorch强化学习实战(3)——Gymnasium API扩展功能
PyTorch强化学习实战(4)——PyTorch基础
PyTorch强化学习实战(5)——PyTorch Ignite 事件驱动机制与实践
PyTorch强化学习实战(6)——交叉熵方法详解与实现
PyTorch强化学习实战(7)——表格学习与贝尔曼方程
PyTorch强化学习实战(8)——Q学习详解与实现
PyTorch强化学习实战(9)——深度Q学习
PyTorch强化学习实战(10)——强化学习高级组件
PyTorch强化学习实战(11)——N步DQN(N-step DQN)
PyTorch强化学习实战(12)——Double DQN(DDQN)
PyTorch强化学习实战(13)——噪声网络(NoisyNet-DQN)
PyTorch强化学习实战(14)——优先经验回放机制
PyTorch强化学习实战(15)——Dueling DQN
PyTorch强化学习实战(16)——Categorical DQN
PyTorch强化学习实战(17)——强化学习训练加速


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论