深度强化学习在多星区域目标观测调度中的PPO实战解析
简介面向航天任务规划与深度强化学习研究者这份资源以多星对区域目标连续观测为典型场景完整展示了深度强化学习求解调度问题的工程实现。包内共492个文件核心代码以27个Python脚本和5个Jupyter Notebook为主辅以PyTorch模型权重pth/torch、经验回放数据npz/npy/h5与训练日志CSV另有FITS天文数据及GZ压缩文件支撑环境模拟资源包整体198.33MB。项目包含环境模拟器、深度Q网络DQN、策略网络与价值网络、经验回放缓冲区等核心模块并内置Fermi、Swift、Integral等多星观测实例数据覆盖训练、评估与超参数调整流程可直接复现实验。目前已有193人学习下载适合希望掌握深度强化学习在航天多目标优化中落地方法的开发者与研究者参考。1. 多星对区域目标的观测规划深度强化学习算法到底解决了哪一环多星对区域目标的观测调度在卫星运控里一直是“看起来是个排班做起来是个组合优化”的事。区域目标不是点目标而是一块不规则的区域目标比如风暴过境后的受灾带或大面积森林火光单颗星的条带视场覆盖不满必须拆成多条带交给多颗星接力观测。过去常见做法是整数规划或遗传算法离线解一次规划要几分钟甚至更久一旦云遮、侧摆角超限或目标新增又得重算一轮。深度强化学习算法把“选哪颗星、切哪条带、什么时刻看”变成一个端到端序列策略离线训练、在线秒级推理同时把覆盖收益、机动代价、存储约束都折算进奖励函数。适合愿意花几天训练、但重规划响应要按秒算的地面运控、算法或载荷管理团队。2. 问题结构从区域离散化到带时间窗的序列决策模型2.1 区域目标为什么必须拆条带条带为什么是调度原子先讲几何约束。卫星星载相机的视场在地表投影是一条窄带单次推扫能覆盖的地面区域就是这一条条带。区域目标如果是几十公里宽的多边形单颗星一次过境根本盖不满常见的做法是把目标区域按“星下点轨迹方向和侧摆角档位”切成多个条带每个条带绑定一次成像机会。于是一个区域目标就被拆成若干个候选条带规划时真正要决策的是“哪颗星、哪个条带、在哪个时间窗去执行”。条带是整个调度的原子单位而不是目标本身。很多人第一次做这类任务习惯把区域目标当成点目标处理每个目标直接安排一次过境成像结果在跨度大的区域上会留下大量漏扫。正确的建模方式是把区域网格化每个条带对应一张覆盖掩码mask掩码标记这个条带推扫过后能覆盖哪些网格。调度问题的目标就是选出一组条带使最终被覆盖的加权网格面积最大同时满足时间窗、姿态机动、能源存储等约束。这里还有一个容易被忽略的性质区域覆盖收益是次模的。简单说一条带覆盖的新面积取决于之前哪些条带已经执行过重复覆盖的边际收益是递减的。常规贪心算法在约束少的时候表现还行一旦加上侧摆机动时间、云层遮挡、多星接力限制贪心很容易陷入局部最优。深度强化学习在这里的价值是拿历史轨迹学习一条“在约束里找长期收益”的策略而不是在推理时临时拼规则。2.2 把覆盖调度写成马尔可夫决策过程把调度问题翻译成强化学习语言核心是定义状态、动作、转移和奖励。常见做法是按时间步推进每个时间步对应一个决策时隙智能体观察当前覆盖情况并决定是否执行某一条带。状态 (s_t) 通常包含三块当前已覆盖的网格图 (H_t)、各颗卫星当前的侧摆角 (\theta_{1,t}, \theta_{2,t}, \dots, \theta_{S,t})、当前时间进度 (\tau_t)。网格图直接展平成一维向量优先级用网格权重 (w_{ij}) 表示。动作 (a_t) 是离散的范围是所有候选条带加一个“跳过”动作。转移函数就是选中条带后更新覆盖图、更新对应卫星的侧摆角并累加时间。奖励 (r_t) 我一般写成[ r_t \lambda_g \cdot \Delta A_t - \lambda_c \cdot \text{cost}_t - \lambda_r \cdot \text{overlap}_t ]其中 (\Delta A_t) 是本次条带新增覆盖网格的加权面积cost 是姿态机动和载荷存储代价overlap 是重复覆盖惩罚。这样设计的目的很直接智能体要为新增覆盖拿正奖励同时为机动成本和重复扫负奖励买单模型才会在“多覆盖一块”和“少耗一次机动”之间主动权衡。需要提醒的是(\Delta A_t) 必须用目标优先级加权而不是简单数像素。很多初次实现用均匀网格奖励函数对所有区域一视同仁结果模型倾向去覆盖面积大但低价值的边角区域。把区域目标拆成带权重的网格是后续一切能收敛的前提。2.3 用时间窗矩阵化解多星并发冲突多星调度比单星难难在并发冲突。两颗星可能在同一时隙经过同一个区域如果都安排观测重叠覆盖浪费存储同一颗星相邻两个条带如果侧摆角跳变过大实际姿态机动时间不够调度结果在工程上就是废单。我习惯在环境内部维护一张时间窗矩阵 (W_{s,t})它表示卫星 (s) 在时间步 (t) 是否处于某个候选条带的可执行窗口内。每次动作选择前环境先根据这个矩阵过滤掉当前时隙不可执行的条带。这类过滤不应该放到奖励函数里用“罚分”去引导因为你很难通过随机采样让小概率的不可行动作被充分惩罚。最优做法是直接在状态转移阶段禁止无效动作让策略只在一个合法动作集合上采样。多星并发冲突的另一种常见情况是“同星时间重叠”上一条带还没执行完下一条带时间窗已经开始。处理办法是在状态转移里记录每颗卫星最近一次观测的时间索引凡是时间间隔小于姿态机动所需时隙的候选条带直接踢出动作掩码。这样一来调度结果天然满足工程可行性而不是靠训练后期模型自己“悟”出来。3. 用 PPO 搭一个最小可跑的观测调度环境状态、动作、奖励的拆解3.1 环境骨架网格优先级、候选条带和观测向量下面给一个能直接改着用的环境骨架。它把区域目标离成一个 10×10 优先级网格随机生成 12 个候选条带动作空间是“12 条带 1 个跳过”。真实工程里候选条带应该由几何推扫程序计算这里用随机生成是为了先把训练闭环跑通。# env_region.py —— 多星对区域目标观测规划的最小环境 # 依赖gymnasium 0.29, numpy 1.24 import gymnasium as gym import numpy as np from gymnasium import spaces class MultiSatRegionObs(gym.Env): def __init__(self, num_sats3, grid_size(10, 10), max_tracks12): super().__init__() self.num_sats num_sats self.max_tracks max_tracks self.grid_size grid_size # 目标区域优先级网格1.0 主目标0.8 次要目标 self.grid np.zeros(grid_size, dtypenp.float32) self.grid[2:6, 1:4] 1.0 self.grid[7:9, 5:9] 0.8 # 候选条带真实项目里由星历、视场角、侧摆角计算得到 self.tracks self._gen_tracks() # 观测 已覆盖网格 各星当前侧摆角 时间进度 self.obs_dim grid_size[0] * grid_size[1] num_sats 1 self.observation_space spaces.Box( low0, high1, shape(self.obs_dim,), dtypenp.float32 ) # 动作最后一个是“跳过” self.action_space spaces.Discrete(len(self.tracks) 1) self.reset() def _gen_tracks(self): rng np.random.default_rng(0) tracks [] for i in range(self.max_tracks): sat i % self.num_sats h, w rng.integers(2, 4, 2) y0, x0 rng.integers(0, self.grid_size[0] - h 1, 2) mask np.zeros(self.grid_size, dtypenp.float32) mask[x0:x0 int(w), y0:y0 int(h)] 1.0 tracks.append({ sat: sat, # 由哪颗星执行 mask: mask, # 本次推扫覆盖的网格 twin: int(rng.integers(0, 5)), # 最早可执行时隙 angle: float(rng.uniform(-0.3, 0.3)), # 目标侧摆角 cost: float(rng.uniform(0.2, 0.8)), # 机动存储代价 }) return tracks def reset(self, seedNone): super().reset(seedseed) self.covered np.zeros(self.grid_size, dtypenp.float32) self.side_angles np.zeros(self.num_sats, dtypenp.float32) self.step_idx 0 return self._obs(), {} def _obs(self): progress np.array([self.step_idx / self.max_tracks], dtypenp.float32) return np.concatenate([ self.covered.reshape(-1), self.side_angles, progress ]).astype(np.float32) def step(self, a): self.step_idx 1 done self.step_idx self.max_tracks # “跳过”动作无收益也无额外代价 if a len(self.tracks): return self._obs(), 0.0, done, False, {} t self.tracks[a] # 只有之前未被覆盖的网格才计新增收益并按优先级加权 new_cover (t[mask] 0.5) (self.covered 0.5) gain float(self.grid[new_cover].sum()) # 已经被覆盖的区域视作重复覆盖扣分 overlap float(((t[mask] 0.5) (self.covered 0.5)).sum()) reward gain - 0.05 * t[cost] * self.num_sats - 0.08 * overlap self.covered t[mask] self.side_angles[t[sat]] t[angle] return self._obs(), reward, done, False, {}这段代码里最关键的是new_cover的计算逻辑它拿条带掩码和当前已覆盖网格做比较只对“新增覆盖”给正奖励。这个设计让奖励天然稀疏又带信息模型每执行一个条带都能立刻看到收益变化不需要等到回合结束才反馈。gain是优先级网格的加权和所以主目标区域的网格权重高覆盖到的收益就大重复覆盖的overlap会被扣分模型慢慢会学会不重复扫同一块区域。观测向量里有一个容易被忽略的维度时间进度。它把“当前训练到回合的哪个阶段”告诉策略。没有这个维度模型很难学会“前期多覆盖、后期补漏”这种时序策略因为它在每个时隙看到的状态高度相似无法区分是上午还是下午。3.2 动作掩码让智能体只碰“能执行”的条带动作掩码是这类调度环境里最重要的一层。松耦合环境里无效动作通常会返回一个负奖励策略靠试错学会规避但在卫星调度里无效动作的代价太高而且在动作空间很大的情况下随机采到无效动作的概率也不低训练效率会非常差。# 在 step 之前先算动作掩码过滤掉不可执行的条带 def _action_mask(self): mask np.ones(len(self.tracks) 1, dtypebool) mask[-1] True # “跳过”始终合法 for i, t in enumerate(self.tracks): # 该条带已完全覆盖过不再重复选 if (self.covered[t[mask] 0.5]).all(): mask[i] False continue # 时间窗还没到不能执行 if self.step_idx t[twin]: mask[i] False continue # 同一颗卫星侧摆角变化超过机动速率上限来不及转到位 angle_delta abs(self.side_angles[t[sat]] - t[angle]) if angle_delta 0.4: mask[i] False return mask这里面的三条过滤逻辑分别对应重复覆盖、时间窗、姿态机动约束。注意“跳过”动作永远合法避免在回合末段所有条带都被过滤后策略陷入无动作可选的死局。策略网络输出的是所有动作的 logits掩码要在 logits 上生效后再算 softmax 或 Categorical 分布logits, value policy(obs_tensor) mask_tensor torch.from_numpy(env._action_mask()) logits logits.masked_fill(~mask_tensor, -1e9) dist Categorical(logitslogits) # 只在一个合法子集上采样把无效动作的 logits 填充成负无穷等价于让这些动作概率为 0策略梯度不会沿着无效方向更新。很多实现把 mask 漏在 Categorical 外面只在环境里拦截结果策略在无效动作上反复试探奖励曲线迟迟不涨。3.3 奖励函数覆盖率增量、机动代价、重复观测代价奖励系数怎么设是这类项目里最像玄学的部分。我的经验是覆盖收益的绝对量级要明显大于机动代价和重复覆盖惩罚否则模型会消极选择“跳过”。一个可用起点是主目标网格满覆盖时gain能到 1015单条机动代价在 0.20.8重复覆盖惩罚设为 0.08 每个格子。这样模型发现执行条带gain普遍远大于cost会倾向多尝试覆盖只有明显重叠的条带才会被扣分拦住。如果项目里“跳过”比率偏高就把 cost 系数再调小如果出现反复扫同一块区域就把 overlap 系数调大。另外建议在训练过程中对 reward 做滑动标准化。因为区域目标大小变化gain的量级可能随任务规模漂移。可以在 batch 里统计一段时间内的均值方差将 reward 缩放到接近单位量级PPO 的优势函数会更稳定。关于 PPO 的水有多深后面第 5 章再展开说。4. 训练闭环与推理输出PPO 在区域目标调度上的实验参数4.1 网络结构与 PPO 主循环策略网络用两层 256 的 MLP 就够了。输入维度是环境观察维度这里约 104 维输出是两个头动作 logits 和状态价值。网络不深因为调度任务的特征主要是覆盖状态和姿态状态MLP 能表达没必要上 Transformer。# train_ppo.py —— PPO 最小训练闭环 import torch import torch.nn as nn from torch.distributions import Categorical class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.fc nn.Sequential( nn.Linear(obs_dim, 256), nn.Tanh(), nn.Linear(256, 256), nn.Tanh(), ) self.pi nn.Linear(256, act_dim) # actor 输出 logits self.v nn.Linear(256, 1) # critic 输出状态价值 def forward(self, obs): x self.fc(obs) return self.pi(x), self.v(x).squeeze(-1)训练主循环按 rollout 收集一批数据算 GAE 优势再做 4 轮 minibatch 更新。核心张量操作如下# 以一批完整 mini-batch 为例展示 PPO 更新 for obs, act, old_logp, advantage, ret, mask_batch in batch: logits, value policy(obs) # 掩码必须同时作用在更新时 logits logits.masked_fill(~mask_batch, -1e9) dist Categorical(logitslogits) logp dist.log_prob(act) ratio (logp - old_logp).exp() surr1 ratio * advantage surr2 ratio.clamp(1 - 0.2, 1 0.2) * advantage policy_loss -torch.min(surr1, surr2).mean() value_loss 0.5 * (value - ret).pow(2).mean() entropy_loss -dist.entropy().mean() total_loss policy_loss value_loss - 0.01 * entropy_loss optimizer.zero_grad() total_loss.backward() optimizer.step()更新时再算一次掩码是为了防止采样阶段和更新阶段不一致。如果更新时丢掉了掩码模型会看到无效动作的 logits 也被强行归一化策略分布被污染训练节奏会被拖垮。我常用的参数如下表。这套参数在多数“卫星数 38、候选条带 1060”的规模上表现稳定参数推荐值说明学习率3e-4Adam 默认档调度任务不需要大学习率GAE λ0.95优势估计平滑回合变长时可调 0.98折扣 γ0.99多个决策时隙后仍能保留远期收益rollout steps512约等于 24 个完整回合长度batch size128更新批次过大容易过拟合clip ε0.2PPO 截断范围entropy 系数0.01后期可降到 0.003 让策略确定性增强每批更新轮数4调度任务不需要多次更新要注意的是entropy系数不能设成 0。调度动作空间里“跳过”动作很容易成为局部最优保留一点熵能让模型继续试探其他条带否则训练中期就容易锁死在只做几次观测就摆烂的状态。4.2 训练完之后的调度输出其实还要过“决策时序”训练完成后评估阶段要用argmax而不是采样。这里也是最容易出血的地方直接把一条轨迹拼成调度方案往往不可行因为每个时步的观测是序列相关的前一步选了哪个条带会直接影响后一步的覆盖图。policy.eval() schedule [] obs, _ env.reset() for step in range(env.max_tracks): mask torch.from_numpy(env._action_mask()) logits, _ policy(torch.from_numpy(obs).unsqueeze(0)) logits logits.masked_fill(~mask, -1e9) action int(torch.argmax(logits, dim-1)) if action len(env.tracks): break # 模型选择跳过提前结束观测序列 schedule.append(env.tracks[action]) obs, _, done, _, _ env.step(action) if done: break这段代码看起来简单但实际推理时很多人会忘记重新调用_action_mask()。训练时环境会自动过滤无效动作使采样分布合法推理时如果直接拿全量 logits 做 argmax选出已经被覆盖过的条带调度方案就会含大量废动作。mask 每次都要根据当前 step 和当前covered状态重新计算。推理输出的schedule还只是条带列表要变成工程可执行的载荷指令还需要按时间窗排序、插入姿态机动时长、检查同一颗星的指令冲突。这一步建议放成一个独立模块而不是在训练环境里顺手做因为训练环境允许一定简化真实指令生成则必须严格。4.3 训练监控看哪几条曲线只盯着总奖励曲线不够我一般同时看三条曲线。第一是回合总奖励的滑动平均它反映策略在整体优化目标上的表现第二是每回合结束时“覆盖率”用新增覆盖网格加权和除以总权重第三是有效动作占比也就是模型没有频繁选“跳过”或无效动作的比例。有效动作占比尤其关键。我见到太多情况是总奖励在涨但覆盖率几乎没有变化说明模型发现了“少做少错”的漏洞执行条带会带来机动代价但跳过没有代价于是策略退化成全程跳过。这时候要把 reward 里的cost系数大幅调低甚至先置 0 训练到覆盖收敛再逐步加回代价项。5. 避坑训练不收敛和调度结果翻车的踩坑记录5.1 奖励尺度太小PPO 训练变成玄学现象训练 500 个回合总奖励曲线一直在 0 附近波动模型动作几乎全是“跳过”偶尔执行一两个条带覆盖率始终不上来。原因奖励的量级不对。gain用网格权重求和后可能只有 0.31.5而机动代价扣 0.5 左右cost和gain在同一数量级甚至更高模型发现执行条带并不比跳过更划算。PPO 对优势估计的量级很敏感当奖励信号过小优势被归一化后噪声占主导策略根本学不到有效的梯度方向。解决先做奖励量级审计统计一条随机策略下gain、cost、overlap的均值让gain的均值至少是cost的 35 倍。再不行就在训练 batch 里对 reward 做标准化r (r - r_mean) / (r_std 1e-8)把量级压到 1 附近。这个动作解决了我几个项目里 PPO 奖励曲线不动的问题。5.2 掩码没在采样时生效策略一直在无效动作里打转现象训练曲线能涨但每回合还有 20% 以上的动作被环境判为“无效”并被强制转为跳过有效动作占比很低。原因很多实现只把掩码用在环境step层也就是动作非法时返回一个负奖励或者 no-op却没有在策略网络的Categorical分布层做masked_fill。模型在采样时仍然看到全量动作空间它会反复探索那些无效条带即使被惩罚也浪费了大量样本。解决在采样和更新两处都执行logits.masked_fill(~mask, -1e9)让概率分布只在不被过滤的动作集合上分配质量。同时把环境里的强制 no-op 行为去掉改成返回一个较大的负奖励并结束回合这样才能让模型意识到“动作是合法的只是当前状态不该选它”。5.3 时间窗重叠没进状态转移训练完的方案在仿真里翻车现象训练收敛后把调度结果塞进 STK 或开源轨道仿真里检查发现同一颗卫星前后两条带的时间窗重叠姿态根本转不过来或者两颗星同一时刻指向同一块区域数据大量重复。原因训练环境把“时间窗”简化成了离散时隙没有建模真实的时间长度。候选条带的执行时间是连续的一颗星执行完上一条带后需要侧摆机动若干秒才能执行下一条带。这个约束如果只在奖励里扣分而不在状态转移里禁止模型很容易钻空子。解决在环境的状态转移里增加“最近执行时间”记录任何候选条带如果和同一颗星上一次观测的时间间隔小于机动时间直接置为不可执行。这个约束不是可学的规则而是硬过滤条件。调度结果过一遍 STK 校验是必须做的不能只信训练环境内部的覆盖率。5.4 训练规模与评估规模不一致现象3 颗星训练出来的策略换到 5 颗星或 10 颗星的场景评估覆盖率明显下降甚至不如简单贪心算法。原因策略网络虽然输入固定维度但它学到的模式暗含了“卫星数只有 3 个”的统计规律比如同一时隙可执行的条带密度、候选条带在卫星间的平均分布。候选条带数量一变动作空间长度直接变网络输出维度对不上只能做 padding效果自然崩。解决把架构设计成“动作数量可变但输出维度固定上限 掩码”也就是网络输出一个最大容量动作空间超出部分全部 mask 掉。训练时故意打乱每回合的卫星数量和条带生成参数做“课程式”增强让策略不依赖具体的星群规模。这样从 3 星迁移到 10 星时网络至少能理解“还有更多条带可选”这一信息。5.5 覆盖率指标好看优先级却丢了现象模型训练得很好覆盖率 92%但查看区域分布发现主目标区域只覆盖了 60%次目标区域覆盖得满满当当。原因是奖励函数里网格权重没有生效或者用了均匀网格统计。原因有的实现为了省事把gain写成mask.sum()而不是self.grid[mask].sum()这等于把所有区域一视同仁。模型自然倾向先覆盖面积大、容易覆盖的边角而不是高价值目标。解决严格用加权网格计算增益同时增加一个“主目标区域覆盖比例”作为验证指标不只看整体覆盖率。陷阱往往藏在指标和奖励函数不一致上训练奖励用的口径和最终验收口径必须完全一致否则就是奖励黑客。6. 收敛之后怎么验证规则校验、指标口径与扩展方向6.1 调度结果校验器比奖励曲线更可信的规则训练奖励再好看也不如一个硬规则校验器靠谱。我每次训练完都会跑一段调度校验把输出方案按工程规则逐条过合格率不达标就直接打回重训。def validate_schedule(schedule, env): # 规则1同一颗星相邻两条带的侧摆角变化不能超过机动速率 for s in range(env.num_sats): sat_strips [t for t in schedule if t[sat] s] for prev, cur in zip(sat_strips, sat_strips[1:]): if abs(cur[angle] - prev[angle]) 0.4: return False # 规则2重复覆盖不能太严重 overlay np.sum([t[mask] for t in schedule], axis0) if float((overlay 3).mean()) 0.05: return False # 规则3主目标区域覆盖率要单独算不混到整体里 main_cover ((np.sum([t[mask] for t in schedule], axis0) 0) (env.grid 0.9)).mean() return main_cover 0.8这套校验器的价值在于它把隐蔽的错误暴露在训练阶段之后、仿真验证之前。覆盖率看着高但优先级丢了、重复观测超限、侧摆角串不过去这些问题通过硬规则都能快速筛掉。6.2 什么时候上 MADDPG 或 MAPPO如果你的星群规模一直不大比如 38 颗星单智能体集中策略加动作掩码就够用不要盲目上多智能体算法。多智能体带来的收益主要在卫星之间有明显协同约束时才会体现比如两颗星需要接力覆盖、共享数传资源、互相避让。超过 10 颗星且每颗星的载荷、存储、机动能力差异很大时可以考虑 MAPPO 或 MADDPG。它们的共同思路是中心化训练、去中心化执行用共享 critic 评估联合价值每个 actor 只决策自己要执行的条带。但多智能体版本的调试成本高不少。一个最常见的坑是训练时各智能体奖励分配不均卫星不会“互让”优先级结果全体抢同一条带。如果想低成本试探先保持单智能体决策对外观再加一套启发式分配器分离出每颗星的指令往往比直接上多智能体更快出效果。6.3 行为克隆当后悔药奖励函数如果实在调不好行为克隆是很好的后悔药。先用整数规划或遗传算法跑出一批合格调度方案把方案转成“观测向量 动作标签”的离线数据集让策略网络先做一轮监督学习。之后再接 PPO 微调模型会从历史方案学到一个合理基线奖励函数只要负责“小幅修正”而不是从零摸索。这个做法在奖励稀疏、约束复杂、动作空间大的场景下能省掉大量调参时间。我现在拿到一个新的区域目标任务不会直接扔进训练脚本而是先写一个规则校验和仿真脚本把调度结果过一遍确保条带时间线能真实落地再回头迭代模型。这种“先验证后训练”的习惯帮我少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →