4. 策略梯度方法:REINFORCE、Actor-Critic 算法,为什么适合连续动作空间
聊到强化学习在投资组合中的应用,很多朋友第一反应就是DQN。嗯,DQN确实经典,但它有个硬伤——只能处理离散动作。你想想看,在真实交易中,我们调整仓位是0.1%、0.5%还是2.3%?这些连续值DQN根本搞不定。
这时候,策略梯度方法就派上用场了。我个人习惯把这类方法称为「端到端的策略学习器」——它直接输出动作的概率分布,而不是先评估每个动作的价值再选。说白了,就是让神经网络自己学会「在什么情况下该下多重的注」。
4.1 为什么连续动作空间需要策略梯度?
先讲个我踩过的坑。几年前我在做期货CTA策略时,想用DQN来控制仓位。结果动作空间设了10档:0%, 10%, 20%... 100%。训练出来的策略永远在极端值之间跳来跳去——要么满仓,要么空仓。为什么?因为DQN的本质是argmax,它只会选Q值最高的那个离散动作。
连续动作空间的核心挑战在于:
- 维度灾难:如果每个资产有100档仓位,10个资产就是100^10种组合,DQN根本存不下
- 动作相关性:仓位从10%调到11%和调到90%,在DQN看来是两个完全独立的动作,学不到「微调」的概念
- 泛化能力:策略梯度直接学习策略函数π(a|s),天然支持连续输出
核心洞察:策略梯度方法不计算Q值,而是直接优化策略参数θ,使得累积回报的期望最大化。这意味着它可以在连续空间中「平滑地」调整动作。
4.2 REINFORCE 算法:最朴素的策略梯度
REINFORCE,也叫蒙特卡洛策略梯度。它的思路非常直白:用完整的episode回报来更新策略。公式长这样:
∇θ J(θ) = E[ Σ ∇θ log π(a_t|s_t) * G_t ]
其中 G_t = Σ γ^(k-t) * r_k (从t时刻开始的折扣累积回报)
我在项目中第一次实现REINFORCE时,犯了个低级错误——忘了对回报做归一化。结果训练曲线抖得像心电图。后来我加了个技巧:用当前batch的回报均值做baseline,方差瞬间降下来了。
REINFORCE的优缺点很明显:
| 优点 | 缺点 |
|---|---|
| 实现简单,逻辑清晰 | 方差大,训练不稳定 |
| 无偏估计 | 只能离线更新(完整episode后) |
| 适合 episodic 任务 | 样本效率低 |
实战技巧:在投资组合场景中,我建议把每个交易周期(比如一个月)当作一个episode。这样G_t就是该周期内的累计收益率,物理意义很清晰。
4.3 Actor-Critic:让策略梯度真正可用
REINFORCE的问题在于,它用完整的episode回报来评估动作好坏。这就像你打完一场篮球赛,才说「刚才那个投篮选择不好」——太晚了!
Actor-Critic的思路是:一边做动作(Actor),一边实时评价(Critic)。Critic学一个价值函数V(s)或Q(s,a),用来替代REINFORCE中的G_t。这样每个时间步都能更新,方差大幅降低。
我画个图帮你理解:
看到这个架构图你就明白了:Actor负责「怎么做」,Critic负责「做得怎么样」。Critic给出的TD误差δ = r + γV(s') - V(s),就是Actor的「老师」——如果δ为正,说明这个动作比预期好,下次多选它;反之则少选。
4.4 为什么Actor-Critic适合连续动作空间?
这个问题我经常被问到。其实答案就藏在Actor网络的输出层设计里。
对于连续动作空间,Actor网络的输出层通常是一个高斯分布的参数——均值和方差。比如:
class Actor(nn.Module):
def __init__(self, state_dim, action_dim):
super().__init__()
self.fc = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU(),
nn.Linear(128, 64),
nn.ReLU()
)
self.mean_head = nn.Linear(64, action_dim)
self.log_std_head = nn.Linear(64, action_dim)
def forward(self, state):
x = self.fc(state)
mean = torch.tanh(self.mean_head(x)) # 仓位在[-1, 1]之间
log_std = self.log_std_head(x)
std = torch.exp(log_std.clamp(-20, 2))
return mean, std
这样设计的好处是:
- 输出是连续的:均值可以是0.372这样的任意值,不是离散档位
- 自带探索:通过标准差控制探索程度,不像DQN需要ε-greedy
- 可微的采样:使用重参数化技巧,让采样过程可导,梯度能回传
我曾经踩过的坑:在训练初期,Actor的标准差会快速坍缩到0,导致策略失去探索能力。解决方案是给log_std加一个熵正则项,强制保留一定的随机性。具体做法是在loss里加上 -β * H(π),其中H是策略的熵。
4.5 投资组合中的实战配置
在实际项目中,我通常这样配置Actor-Critic:
| 组件 | 配置 | 说明 |
|---|---|---|
| 状态空间 | 过去N天的收益率、波动率、相关性矩阵 | 我习惯用20天窗口,太长会滞后 |
| 动作空间 | 各资产的权重向量,和为1 | 用Softmax或归一化保证约束 |
| 奖励函数 | 夏普比率 + 最大回撤惩罚 | 纯收益率会导致过度冒险 |
| Critic网络 | 3层MLP,256-128-64 | 输出标量V(s) |
| 学习率 | Actor: 3e-4, Critic: 1e-3 | Critic学得快一点,给Actor稳定反馈 |
我的个人习惯:在训练初期,我会把Actor的学习率设得比Critic小一个数量级。这样Critic先学会准确评估,Actor再慢慢调整策略。就像你先让裁判熟悉规则,再让运动员上场——顺序很重要。
4.6 代码实战:最小化Actor-Critic
给你一个可以直接跑的最小实现。这个版本去掉了所有花哨技巧,只保留核心逻辑:
import torch
import torch.nn as nn
import torch.optim as optim
class ActorCritic:
def __init__(self, state_dim, action_dim, lr_actor=3e-4, lr_critic=1e-3):
self.actor = Actor(state_dim, action_dim)
self.critic = Critic(state_dim)
self.opt_actor = optim.Adam(self.actor.parameters(), lr=lr_actor)
self.opt_critic = optim.Adam(self.critic.parameters(), lr=lr_critic)
self.gamma = 0.99
def update(self, state, action, reward, next_state, done):
# 计算TD目标
with torch.no_grad():
target = reward + (1 - done) * self.gamma * self.critic(next_state)
# 更新Critic
current = self.critic(state)
critic_loss = nn.MSELoss()(current, target)
self.opt_critic.zero_grad()
critic_loss.backward()
self.opt_critic.step()
# 更新Actor
td_error = target - current # 这就是Critic给Actor的反馈
mean, std = self.actor(state)
dist = torch.distributions.Normal(mean, std)
log_prob = dist.log_prob(action).sum(dim=-1)
actor_loss = -(log_prob * td_error.detach()).mean()
self.opt_actor.zero_grad()
actor_loss.backward()
self.opt_actor.step()
return critic_loss.item(), actor_loss.item()
这段代码虽然短,但包含了Actor-Critic的全部精髓。你把它嵌入到投资组合的回测框架里,就能跑起来了。
4.7 总结一下
策略梯度方法之所以适合连续动作空间,根本原因在于它直接学习策略的分布参数,而不是枚举所有动作的价值。REINFORCE是基础,但方差太大;Actor-Critic通过引入价值函数作为baseline,大幅提升了稳定性。
在实际项目中,我建议你从Actor-Critic入手,先跑通一个最小版本,再逐步加入GAE、PPO等改进。记住:在投资组合中,连续仓位调整带来的边际收益,往往比离散动作高出几个百分点——这就是策略梯度的价值所在。
一句话总结:策略梯度让AI学会「该下多重的注」,而不是「该下注还是不下注」——这对投资组合管理来说,是天壤之别。