简介:一套基于深度强化学习的多星对区域目标观测规划项目,面向航天任务规划研究人员、强化学习爱好者及Python开发者,主要解决多颗卫星协同调度以最大化区域覆盖率的优化问题。包内共492个文件,约198MB,数据与代码分工明确:247个npz、56个fits文件提供卫星轨道与观测数据集,27个py与5个ipynb实现算法与实验流程,8个csv记录观测日志,另有h5/torch/pkl等模型权重文件;整体结构覆盖环境模拟、DQN训练、经验回放、策略/价值网络和超参数调优等完整模块。目前已有193人学习下载。通过研读代码与数据,可以掌握深度强化学习在航天调度中的落地流程,理解如何构建卫星状态与目标区域特征的环境模型,并利用DQN等算法实现观测策略的迭代优化,其环境模拟器、经验回放缓冲区及训练评估代码可直接复用,适合作为课程设计、毕业设计或科研对比的基线项目,对开展多星任务规划或类似组合优化问题的研究具有直接参考价值。
1. 把“多星对区域目标观测的规划”当序列决策来做,这条路到底行不行
如果丢给你一个 500 平方公里的区域观测需求,再给你一支 12 颗卫星的遥感星座,你会立刻撞上一个比“有没有可见窗口”更麻烦的问题:几百个候选条带里,按什么顺序、分配给哪颗星、侧摆多少度,才能把覆盖率拉到最高,同时不把星上存储塞爆。这个组合优化问题,正是基于深度强化学习算法实现多星对区域目标观测的规划要解决的:它把“下一块观测谁”训练成一个策略网络,上线后根据当前轨道窗口和剩余待观测网格做在线决策,替代传统整数规划那种“规模一大就求不动”的离线求解。这篇笔记适合正在做卫星任务规划、遥感应用调度的算法工程师和仿真验证人员,讲清楚建模、训练、落地验证的完整闭环和值得注意的坑。
2. 把区域目标观测规划写成 MDP:从网格化到奖励函数的四个关键设计
2.1 区域目标先转成网格:网格尺度决定覆盖率的“分辨率”和计算量
多星对区域目标观测规划和传统的点目标调度有一个本质差别:点目标可以精确到经纬度,而区域目标是一块连续地表,“覆盖了没有”必须靠离散网格来定义。常见做法是把目标区域按等间隔经纬度切成行列网格,网格中心点作为覆盖判据:当卫星的传感器视场覆盖某个网格中心点,就认为这个网格被观测到。但等经纬度网格在高纬度地区面积会收缩,如果目标区跨纬度大,直接用经纬度网格会让覆盖率虚高或虚低。工程上更稳的做法是先用 UTM 或等面积投影把区域转到平面上再切网格,每个网格的面积才一致,覆盖率统计横跨不同纬度时才具备可比性。
网格尺度直接决定预计算量和规划质量,这里有一个经验上的取舍关系。以 100 公里乘 100 公里的区域为例:0.5 公里的网格会有约 4 万个网格点,每颗卫星经过时都要对这 4 万个点做一次视场包含判定,预计算量会到千万级;1 公里网格是 1 万个点,预计算量下降一个数量级;10 公里网格只有 100 个点,做快速预演很合适,但输出结果的精度也基本没法看。我的习惯是先用 10 公里网格跑通训练闭环,确认奖励曲线在涨后再把网格缩到 1 公里重新训练,不要一上来就上细网格,否则一次环境 step 的耗时会直接把你劝退。
| 网格尺度 | 网格数量(约) | 可见性预计算复杂度 | 覆盖率分辨率 | 推荐用途 |
|---|---|---|---|---|
| 0.5 公里 | 4 万个 | 极高 | 高 | 小范围高精度出图 |
| 1 公里 | 1 万个 | 中等 | 中高 | 常规规划训练 |
| 10 公里 | 100 个 | 低 | 低 | 算法验证与预演 |
除了网格尺寸,还有一个常被忽视的细节:网格与条带的关系。卫星一次过境拍到的是一条带状影像,覆盖的是连续一排网格,不是单个网格。所以规划动作的单位应该是“条带”,一个条带是否有效,要看它覆盖了多少尚未被覆盖的网格。这个“剩余网格集合”就是状态的一部分,奖励计算时也只统计新增网格,否则很快就会出现多颗星重复观测同一片区域、覆盖率数字虚高的假象。
2.2 动作空间为什么要按“条带序列”来设计
把调度写成 MDP 最需要想清楚的是动作怎么表示。给定 12 颗星和一段规划周期,每颗星在每个可见窗口内可以侧摆若干个角度,每个角度对应一条不同的条带。如果动作定义为“在某时刻把某颗星摆到某个角度开始成像”,组合会立即爆炸:窗口数量乘侧摆档位乘条带长度,序列长度又随卫星数量线性增长。这种结构不适合直接用 DQN 那种单步 Q 值输出,更适合用一个序列决策模型逐步生成“下一个条带”。
我一般把动作建模成两层:第一层选卫星和过境窗口,第二层在窗口内选条带起始网格。一个完整规划就是一条决策序列,长度等于实际分配的条带数。这么做的好处是自然贴合指针网络这类模型的输出形式——它本来就是从候选集合里逐步挑元素,挑过的元素用 mask 屏蔽掉。状态则包含三部分:候选窗口矩阵(每条轨道对应的可用时间段)、剩余未覆盖网格列表、每颗星的剩余存储和电量。三者拼接成特征张量输入到策略网络。
这里要注意,MDP 的“状态转移”不是传统的物理状态转移,而是“挑了一个条带后,环境把已覆盖网格从剩余集合中删除,并扣掉对应存储”。环境里维护的是一本覆盖账本和一个资源账本,每步只做账本更新,不需要模拟卫星动力学,因为可见窗口和条带几何已经预计算好了。这也是为什么这个任务能跑深度强化学习算法的主要原因:单步成本极低,可以大规模并行采样。
2.3 奖励函数的三段式设计:覆盖收益、时间窗代价与负载均衡
多星区域观测规划的奖励设计我踩过一个很深的坑:只给最终覆盖率一个稀疏奖励,训练时覆盖率会在前几千步完全不动,因为随机策略几乎没有概率凑出有效覆盖。所以奖励必须拆成三段,让智能体每一步都能收到反馈。核心奖励是新增覆盖网格数,每一步从环境账本里读新覆盖数,作为主要正反馈;其次是时间窗代价,如果策略选择的条带与已选条带存在侧摆切换时间冲突,或者成像时长超出过境可见时长,直接扣分;第三段是负载均衡代价,星上存储超限或电量不足做硬约束扣分。
系数上常用的起步组合是:新增覆盖权重 1.0,时间窗违约权重 0.3,负载超限权重 0.5,侧摆切换代价 0.05。这个组合有一个关键动机:让覆盖收益永远占主导,同时让约束代价大到足以阻止非法动作,又不至于让网络只顾避罚而不敢行动。如果违约权重超过 1.0,策略会趋向于“少做少错”,覆盖率停在很低的位置不再增长,这是同行经验里最容易出现的失败模式。训练到中后期再把违约权重慢慢提上来,让策略在保住覆盖率的同时学会满足约束,这就是“reward shaping 退火”。
2.4 轨道外推与可见性预计算:训练环境与验证环境必须同一套星历来源
规划依赖的底层数据是“哪颗星在什么时候能看到哪片网格”。做这部分预计算时,常见选择有两种:SGP4 配合 TLE 根数做高精度外推,或者用 J2 解析模型快速生成轨道。工程上我强烈建议训练环境和验证环境用同一套轨道外推方式,否则模型很容易学到某套星历下的时间窗拓扑,换一套根数就失效。J2 计算快,适合批量生成训练环境;SGP4 精度高,适合最后评估阶段复现真实运行情况。至少要在数据接口层留一个切换开关,不要在两套外推结果之间混用可见窗口。
可见窗口的判定参数也直接影响规划难度。卫星传感器半视场角决定地面覆盖带宽度;侧摆范围决定单颗星能覆盖的横向跨度,常见的遥感星侧摆范围是 ±40 度或 ±45 度;最小仰角约束控制观测几何质量,太低的角度影像畸变严重。把这些参数列成一张配置表放在环境类顶部,比散落在代码各处好维护得多。预计算完成后,就把可见窗口转成一个三维张量:卫星数乘候选条带数乘时间片数,后续所有调度都在这个张量上做序列决策,不再回到轨道计算层。
| 参数 | 常见取值 | 对规划的影响 |
|---|---|---|
| 传感器半视场角 | 1°~5° | 决定条带覆盖宽度,影响网格覆盖判定 |
| 侧摆范围 | ±40° / ±45° | 扩大单星候选条带数,显著增大动作空间 |
| 最小观测仰角 | 15°~25° | 过滤低质量可见窗口,减少无效动作 |
| 星上存储上限 | 按任务配置 | 限制连续观测时长,影响序列长度 |
3. 用深度强化学习算法训出序列决策:指针网络加 PPO 的最小可跑方案
3.1 为什么我没有选 DQN,而是选了指针网络加 PPO
如果你的第一个念头是“用 DQN 打一套观测规划”,多半会在动作维度上卡住。DQN 要求动作空间固定且有限,但条带序列的长度随卫星数量和网格分布变化,而且候选条带数动不动就是几千,Q 网络输出层根本没法定义。策略梯度类方法天然适合可变长度的序列生成,其中注意力机制加持的指针网络效果最稳定。指针网络的做法是:把状态编码成一组节点特征,解码时每一步用注意力打分从候选节点里选一个,选过的节点用 mask 屏蔽,这正好匹配“逐步挑条带”的决策逻辑。
训练算法我一般配 PPO,而不是原版 REINFORCE。原因有两条:一是 PPO 用重要性采样和 clip 机制限制策略单步更新幅度,训练更稳,网格奖励噪声很大时尤其重要;二是 PPO 自带 actor-critic 结构,critic 学习覆盖率预期值作为 baseline,能有效降低策略梯度方差。REINFORCE 在这类稀疏奖励任务是出了名的方差大,不加 baseline 很难收敛。所以架构上就是:actor 用指针网络生成条带序列,critic 用一个小 MLP 把状态编码回归到预期累计奖励。
3.2 指针网络的核心实现:Transformer 编码加 Masked 解码打分
实际写代码时,编码器我直接用了 TransformerEncoder,解码时不用自回归那套逐词生成,而是用带 mask 的注意力打分。下面是去掉了工程封装后的核心片段,能跑通一次前向:
import torch import torch.nn as nn class PointerNet(nn.Module): def __init__(self, feat_dim, d_model=256, n_heads=8, layers=3): super().__init__() self.embed = nn.Linear(feat_dim, d_model) self.encoder = nn.TransformerEncoder( nn.TransformerEncoderLayer(d_model=d_model, nhead=n_heads, batch_first=True, dropout=0.1), num_layers=layers ) self.Wq = nn.Linear(d_model, d_model, bias=False) self.Wk = nn.Linear(d_model, d_model, bias=False) self.logit_clip = 10.0 def decode_step(self, node_embeds, context, mask): # node_embeds: [B, N, d_model] 所有候选条带节点 # context: [B, d_model] 当前规划上下文 # mask: [B, N] True 表示该候选条带不可选 q = self.Wq(context).unsqueeze(1) # [B, 1, d_model] k = self.Wk(node_embeds) # [B, N, d_model] logits = torch.matmul(q, k.transpose(-2, -1)) / (node_embeds.shape[-1] ** 0.5) logits = logits.squeeze(1).masked_fill(mask, -1e9) logits = self.logit_clip * torch.tanh(logits / self.logit_clip) return logits这个片段里有两个容易被忽略的点。一是 masked_fill 用了 -1e9 而不是负无穷,再经过 tanh 限制输出范围,可以有效避免 logits 极端值在 softmax 阶段产生 NaN;二是 logit_clip 是为了控制策略熵,让网络在训练初期不会因为极端得分分布而陷入确定性策略。实际使用时,node_embeds 由编码器统一生成,context 是当前已选条带序列的汇总向量,mask 则由环境根据已覆盖网格、星上剩余资源和存储实时构造。
3.3 训练循环:环境采样、PPO 截断损失与逐步解码
训练部分的核心是采样一条完整规划轨迹,然后计算 PPO 损失。下面是一个 rollout 的简化流程,突出“环境提供 mask 和奖励,网络提供动作分布”的分工:
def rollout_one_episode(env, model, horizon=64): state = env.reset() log_probs, rewards, dones = [], [], [] for t in range(horizon): feats, mask = env.get_state_features(state) node_embeds = model.encode(feats) logits = model.decode_step(node_embeds, state["context"], mask) dist = torch.distributions.Categorical(logits=logits) action = dist.sample() log_probs.append(dist.log_prob(action)) state, reward, done = env.step(action.item()) rewards.append(reward) dones.append(done) if done: break return torch.stack(log_probs), torch.tensor(rewards, dtype=torch.float32), donerollout 代码里的 horizon 决定单条轨迹最多分配多少条带,一般设为卫星数量的 3 到 5 倍,给策略留出足够决策空间;mask 由环境生成,已经观测过的网格对应的条带会被屏蔽掉,避免重复覆盖。env.step 内部除了更新覆盖账本,还要检查存储和侧摆时间冲突,如果违反约束则返回一个负收益并把该动作计入日志。训练时我一般开多个环境并行采样,每个环境持有不同的目标区域和随机轨道根数,这样采样的轨迹多样性足够支撑 PPO 更新。
PPO 损失计算时,critic 网络对每个状态输出价值估计,用于计算优势函数;actor 损失要带上 clip 项,把当前策略与旧策略的概率比限制在 0.2 以内。这里有一个细节:每次更新用 5 个 epoch,batch size 64,学习率 1e-4,熵系数从 0.01 开始,训练到覆盖率平台期再降到 0.001,避免策略过早熵太小而丧失探索能力。
3.4 超参推荐和一个 200 行的可跑通基线
把上面所有部分合起来,一个最小可跑的深度强化学习观测规划项目在 200 行左右,结构是:环境类负责网格化、覆盖判定和约束检查;网络类负责编码解码;训练脚本负责采样更新。这个规模对新手最友好,可以很快跑通一个 10 颗星、400 个网格的小场景。我常用的超参组合如下表,覆盖“可用性优先”和“精度优先”两档。
| 参数 | 可用性优先 | 精度优先 |
|---|---|---|
| d_model | 128 | 256 |
| Transformer 层数 | 2 | 3 |
| 学习率 | 3e-4 | 1e-4 |
| 并行环境数 | 8 | 32 |
| horizon | 32 | 64 |
| PPO clip | 0.2 | 0.15 |
| 熵系数 | 0.01 | 0.005 |
拿到代码包第一件事不要急着训练,先解压看一下依赖清单和数据目录,把环境里的轨道参数、网格参数和真实任务参数对齐。这个习惯能避掉后面至少一半的排错时间。如果训练三五小时后覆盖率曲线还在 10% 附近波动,不要加网络层数,先回去检查奖励是不是太稀疏、mask 是不是把合法条带屏蔽了,这两件事比模型容量更容易导致不收敛。
4. 深度强化学习规划落地避坑:覆盖不涨、换星历翻车与 mask 溢出
4.1 覆盖率长期停留在个位数:先查奖励地形,不要一上来调网络
这种现象几乎每个做过这类项目的人都会遇到:训练几千步,覆盖率纹丝不动,像被冻住了一样。我在早期做这个题目时也卡在这里,一度怀疑是网络容量不足,加了层数、换了激活函数都没有动静。后来排查到环境里的真实情况是:区域网格切得太细,可见窗口又稀疏,随机策略采样到的条带几乎不与目标区域相交,导致绝大多数步的奖励是零,稀疏到一个正样本都采不到,策略梯度自然导不动网络。
原因清楚了,解决办法就是把奖励从“最终覆盖率”改成“新增覆盖网格数加阶段完成加成”。环境端维护一个已覆盖集合,每步执行后统计新增网格数量,立刻返回给策略,而不是等整条轨迹结束后才给一个覆盖率数字。另外,网格去重必须在环境端做,不要在奖励函数里做裁剪,否则同一网格被多颗星重复覆盖时,奖励会先虚高、后骤降,训练曲线会出现莫名其妙的锯齿。
4.2 训练好的模型换一组轨道根数就翻车:典型域偏移问题
这是多星观测规划里最常见的“部署翻车”场景:训练时用的是圆轨道加 J2 外推模型,部署时切换成真实 TLE 或大偏心率轨道,覆盖率直接掉二十个百分点。根因在于策略网络把轨道根数对应的可见窗口拓扑当作了一种“模式”来记忆,而不是真正学到了“根据当前窗口结构做调度”的能力。训练环境里轨道根数永远不变,模型就会偷懒去背那条固定路径。
解决方法是训练时对轨道参数做随机扰动,这属于域随机化的标准动作。具体做法是把轨道高度在正负百分之十范围内变化、倾角在正负五度范围内变化、升交点赤经随机打散,让每个训练环境都有一组不同的可见窗口矩阵。同时建议训练环境用 J2 解析外推,验证阶段用 SGP4 加真实 TLE 重算窗口,两套结果做一致性对比。如果干扰幅度偏小,验证时照样暴露泛化问题。
4.3 mask 用 -inf 填充导致 NaN:数值稳定性是黑匣子
训练到某个阶段 loss 突然变成 NaN,日志里出现 inf,这种情况在注意力模型里很常见,不用怀疑是学习率太大。问题基本都出在 mask 填充用的负无穷上。当某个候选节点因为已分配而需要被屏蔽时,常见的模板写法是logits = logits.masked_fill(mask, -float('inf')),但一旦遇到整行 mask 全真,或者遮蔽值参与了 softmax 的指数计算,就会产生 NaN。用 -1e9 加 tanh clip 只能缓解,更稳的做法是在解码时单独实现对数值的 masked log softmax,先减去最大值再算 logsumexp,把中间量控制在安全范围。
def masked_log_softmax(logits, mask): logits = logits.masked_fill(mask, -1e9) logits = logits - logits.max(dim=-1, keepdim=True).values log_probs = logits - torch.logsumexp(logits, dim=-1, keepdim=True) return log_probs这段代码把数值稳定性分成两步处理:先减最大值保证指数部分不超过零,再用 logsumexp 做归一化。代码里的 mask 是布尔张量,为 True 的位置被排除在候选集之外。如果训练时发现输出分布过度集中,也可以在这个函数里加大 logits 的缩放系数,等价于降低策略熵,但不建议直接调节温度系数到极端值。
4.4 智能体学会“刷中间奖励”:shaping 权重失衡会让目标被架空
覆盖率涨到百分之四十左右开始停滞,但智能体还在不停做动作,这是 reward shaping 权重失衡的典型症状。奖励函数里中间奖励权重如果远大于真实目标项,策略就会找到漏洞:反复选择一个已经被覆盖过但仍能产出少量正奖励的地块,因为每个 step 都能拿到正的 shaping 收益,累计起来甚至比完成整个区域观测的最终收益更划算。这个问题在加中间奖励时极其容易埋下隐患。
我的处理原则是:中间奖励系数压到主覆盖收益的十分之一以下,并且覆盖产出只在“新增网格”时结算,已经覆盖过的网格再次被选时,奖励恒为零。训练到中后期还要把 shaping 系数按计划衰减,让最终覆盖率逐步成为唯一优化目标。否则你最后看到的漂亮曲线,可能只是网络在迎合环境的一个 bug,不是真正学会了多星对区域目标观测规划。
5. 验证模型做得对不对:用 CP-SAT 当裁判,用星历扰动测泛化
5.1 什么才算真正收敛:覆盖率上限和求解速度要同时看
我习惯在每个训练周期结束时,把同一个场景交给 OR-Tools 的 CP-SAT 求解器做一个同规模整数规划求解,得到覆盖率上界作为基准线。CP-SAT 在几十颗星、几百个网格的规模下能在一个限定的时间内给出一版可行解,虽然不一定是最优,但足够当一个参照系。如果 DRL 策略的覆盖率能达到 CP-SAT 上界的九成以上,且单步决策时间在毫秒级,这个模型才真正有说服力。如果 gap 超过两成,先别急着把锅扣给网络结构,回到奖励和 mask 排查。
这种做法还会暴露一个容易被忽视的问题:DRL 模型训练时的覆盖率是在环境里去重统计的,和 CP-SAT 的目标口径必须一致,否则两个数字根本不具可比性。我一般会在评估脚本里输出三个指标:去重覆盖率、累计时间窗违约次数、平均单条轨迹规划耗时,三张表放一起才能说明模型值不值得用。
5.2 用星历扰动和区域形状变化验证泛化能力
验证泛化的另一个低成本做法是批量生成扰动场景。我会在一百个随机环境里做一次评估,每个环境的轨道高度、倾角和目标区域摆放位置都不同,然后统计覆盖率分布的均值和标准差。标准差如果超过五个百分点,说明策略对星历敏感,鲁棒性不达标。这个测试不需要重新训练,只是把训练好的策略放到新环境里推理,跑一轮很快,但信息量非常大。细心点还能顺便发现某些极端场景里 mask 是否漏掉了合法条带,这种 bug 在单一验证场景里根本看不出来。
| 验证项 | 方法 | 参考阈值 |
|---|---|---|
| 覆盖率上界 gap | 与 CP-SAT 可行解对比 | gap 小于 10% |
| 星历泛化 | 100 个随机轨道根数环境 | 覆盖率标准差小于 5% |
| 约束满足 | 统计违约次数 | 平均每轨迹小于 1 次 |
| 决策耗时 | 单条轨迹推理计时 | 毫秒级 |
5.3 我自己的交付习惯:留一条后路,别让模型无保护上线
最后分享一个我交过学费换来的习惯:训练好的策略网络部署到规划系统时,必须保留一层越控逻辑。模型给出条带序列后,先用一套规则做安全校验,包括可见窗口合法性、存储上限、侧摆切换时间,任何一条过不去就回退到次优候选条带。不要迷信一个纯 DRL 模型可以端到端直接跑任务,观测规划的约束多,策略网络学了大部分正确模式,但偶尔会给出一个明显不合法的组合。这层越控消耗极少,却能让系统在真实任务里稳很多。希望帮到你。
本文还有配套的精品资源,点击获取