返回代码索引

HATTV20Network.py

实体编码器、CandidateEdgeDecoder、基础 actor 与 schema 投影

原文件 · 860 行 · 38376 字节

algorithms/algorithm/HATTV20Network.py
SHA-256: 7a3a5ce95102adb886b9d66ac2afd019dd5a78f999f51a605b91846aa37d1597

1"""v2.0 是后续 v2/v3 系列的主要旧基线。它的核心思想是:21. 分别编码 focal agent、其他 agent 和任务;32. 用 A-A attention 更新 focal agent 表示;43. 按当前联盟编号把自己和其他成员聚合到每个任务槽;54. 将任务编码与该任务当前联盟表示融合,再对每条 A-T 候选边打分。67注意:本版本编码的是“当前联盟状态”,没有做 v3.0 之后的候选加入前后反事实推理。8"""910import math1112import torch13import torch.nn as nn1415from algorithms.utils.distributions import FixedCategorical16from algorithms.utils.rnn import RNNLayer17from algorithms.utils.util import check181920class FeatureEncoder(nn.Module):21    """两层 MLP + LayerNorm,用于把 ego/other/task 的原始特征映射到同一隐空间。"""2223    def __init__(self, input_dim, hidden_dim):24        super().__init__()25        self.net = nn.Sequential(26            nn.Linear(input_dim, hidden_dim),27            nn.ReLU(),28            nn.Linear(hidden_dim, hidden_dim),29            nn.LayerNorm(hidden_dim),30        )3132    def forward(self, x):33        return self.net(x)343536def initialize_full_actor_from_compact_reference(37    full_actor, compact_actor, cap_dim38):39    """Make a full-schema actor an exact zero-padded extension of compact6.4041    The compact task order is ``x, y, demand, deadline, req0, type``.  The42    corresponding full-schema columns are copied into the 12-D task encoder;43    all additional input columns start at zero.  Every other actor parameter44    is copied verbatim.  With zero-valued extra fields and restored req0 scale,45    the two actors therefore implement the same function at initialization.46    """4748    compact_state = compact_actor.state_dict()49    full_state = full_actor.state_dict()50    first_weight = "backbone.task_encoder.net.0.weight"51    compact_columns = [0, 1, 2, 3, 4, 5]52    full_columns = [0, 1, 2 + cap_dim, 3 + cap_dim, 2, 7 + cap_dim]5354    if compact_state[first_weight].shape[1] != 6:55        raise ValueError("compact reference task encoder must have six inputs")56    expected_full_dim = 2 + cap_dim + 1 + 1 + 3 + 157    if full_state[first_weight].shape[1] != expected_full_dim:58        raise ValueError(59            "full task encoder has unexpected input width: "60            f"{full_state[first_weight].shape[1]} != {expected_full_dim}"61        )6263    for name, target in full_state.items():64        source = compact_state[name]65        if name == first_weight:66            target.zero_()67            for compact_idx, full_idx in zip(compact_columns, full_columns):68                target[:, full_idx].copy_(source[:, compact_idx])69            continue70        if target.shape != source.shape:71            raise ValueError(72                f"cannot preserve actor function for {name}: "73                f"full={tuple(target.shape)}, compact={tuple(source.shape)}"74            )75        target.copy_(source)76    full_actor.load_state_dict(full_state)777879def encode_repeated_tasks(task_encoder, tasks, shared_task_obs):80    """任务观测在所有 agent 间相同时,只编码一次并广播,减少重复计算。"""8182    if shared_task_obs and tasks.ndim == 4 and tasks.shape[1] > 1:83        encoded = task_encoder(tasks[:, 0])84        return encoded.unsqueeze(1).expand(-1, tasks.shape[1], -1, -1)85    return task_encoder(tasks)868788class RelationAttention(nn.Module):89    """带边特征 bias 的稠密多头注意力,用于 A-A 关系聚合。"""9091    def __init__(self, hidden_dim, edge_dim, num_heads, dropout=0.0):92        super().__init__()93        if hidden_dim % num_heads != 0:94            raise ValueError(95                f"hidden_dim={hidden_dim} must be divisible by num_heads={num_heads}."96            )97        self.num_heads = num_heads98        self.head_dim = hidden_dim // num_heads99        self.scale = self.head_dim ** -0.5100        self.query = nn.Linear(hidden_dim, hidden_dim)101        self.key = nn.Linear(hidden_dim, hidden_dim)102        self.value = nn.Linear(hidden_dim, hidden_dim)103        self.edge_bias = nn.Sequential(104            nn.Linear(edge_dim, hidden_dim),105            nn.ReLU(),106            nn.Linear(hidden_dim, num_heads),107        )108        self.output = nn.Linear(hidden_dim, hidden_dim)109        self.dropout = nn.Dropout(dropout)110111    def forward(self, query, neighbors, edge_features, mask):112        # query: [B, A, H];neighbors: [B, A, N, H]。113        # mask 屏蔽不存在/死亡/不可用的邻居,避免 softmax 分配注意力。114        batch_size, num_agents, num_neighbors, hidden_dim = neighbors.shape115        # 每个 observer agent 作为 query;每个邻居智能体作为 key/value。116        q = self.query(query).view(117            batch_size, num_agents, self.num_heads, self.head_dim118        )119        k = self.key(neighbors).view(120            batch_size, num_agents, num_neighbors, self.num_heads, self.head_dim121        )122        v = self.value(neighbors).view(123            batch_size, num_agents, num_neighbors, self.num_heads, self.head_dim124        )125        scores = torch.einsum("bahd,banhd->banh", q, k) * self.scale126        # 边特征直接加到每个 head 的 logit 上,用于注入相对位置和同联盟关系。127        scores = scores + self.edge_bias(edge_features)128        valid = mask.bool().unsqueeze(-1)129        scores = scores.masked_fill(~valid, torch.finfo(scores.dtype).min)130        weights = torch.softmax(scores, dim=2)131        # 全无效邻居时避免 NaN;再归一化保证有效邻居权重和为 1。132        weights = torch.where(valid, weights, torch.zeros_like(weights))133        weights = weights / weights.sum(dim=2, keepdim=True).clamp_min(1e-8)134        weights = self.dropout(weights)135        context = torch.einsum("banh,banhd->bahd", weights, v)136        context = context.reshape(batch_size, num_agents, hidden_dim)137        return self.output(context), weights138139140class HATTV20Backbone(nn.Module):141    """    对每个观察者 agent,构建 num_tasks 个任务槽的联盟表示。142    这里的联盟表示包含“当前已经分配到该任务的成员”,并包含 focal agent 自己当前所在联盟。143    """144145    def __init__(146        self,147        ego_dim,148        other_dim,149        task_dim,150        hidden_dim,151        num_heads,152        cap_dim,153        num_agents,154        num_tasks,155        dropout=0.0,156        shared_task_obs=True,157        use_aa_attention=True,158        use_coalition_context=True,159        task_input_mode="native",160        task_requirement_scale=5.0,161    ):162        super().__init__()163        self.cap_dim = cap_dim164        self.num_agents = num_agents165        self.num_tasks = num_tasks166        self.shared_task_obs = shared_task_obs167        # Ablations preserve all parameter tensors and shapes.  A disabled168        # branch contributes zeros so parameter count cannot explain a result.169        self.use_aa_attention = bool(use_aa_attention)170        self.use_coalition_context = bool(use_coalition_context)171        self.task_input_mode = str(task_input_mode)172        self.task_requirement_scale = float(task_requirement_scale)173        if self.task_input_mode not in (174            "native",175            "restore_req_scale",176            "compact_projection",177        ):178            raise ValueError(179                "task_input_mode must be native, restore_req_scale, or "180                f"compact_projection; got {self.task_input_mode!r}"181            )182        if self.task_input_mode != "native" and task_dim < 2 + cap_dim + 6:183            raise ValueError(184                f"task_input_mode={self.task_input_mode!r} requires the full "185                f"task schema, got task_dim={task_dim}"186            )187        encoded_task_dim = 6 if self.task_input_mode == "compact_projection" else task_dim188        self.self_encoder = FeatureEncoder(ego_dim, hidden_dim)189        self.other_encoder = FeatureEncoder(other_dim, hidden_dim)190        self.task_encoder = FeatureEncoder(encoded_task_dim, hidden_dim)191        self.aa_attention = RelationAttention(192            hidden_dim, edge_dim=3, num_heads=num_heads, dropout=dropout193        )194        self.agent_fusion = nn.Sequential(195            nn.Linear(hidden_dim * 2, hidden_dim),196            nn.ReLU(),197            nn.Linear(hidden_dim, hidden_dim),198        )199        self.agent_norm = nn.LayerNorm(hidden_dim)200        self.task_fusion = nn.Sequential(201            nn.Linear(hidden_dim * 3 + 1, hidden_dim),202            nn.ReLU(),203            nn.Linear(hidden_dim, hidden_dim),204        )205        self.task_norm = nn.LayerNorm(hidden_dim)206207    def _prepare_task_encoder_input(self, tasks):208        """Apply controlled schema interventions before the v2.0 task MLP.209210        The full DelivCoal schema stores req0 divided by capability_scale.  The211        restore control changes only that scale.  The compact projection maps212        the full schema back to the historical six semantic fields, allowing a213        width-matched actor initialization against the compact baseline.214        """215216        if self.task_input_mode == "native":217            return tasks218219        requirement = tasks[..., 2:3] * self.task_requirement_scale220        if self.task_input_mode == "restore_req_scale":221            prepared = tasks.clone()222            prepared[..., 2:3] = requirement223            return prepared224225        demand_idx = 2 + self.cap_dim226        deadline_idx = 3 + self.cap_dim227        type_idx = 7 + self.cap_dim228        return torch.cat(229            [230                tasks[..., 0:2],231                tasks[..., demand_idx:demand_idx + 1],232                tasks[..., deadline_idx:deadline_idx + 1],233                requirement,234                tasks[..., type_idx:type_idx + 1],235            ],236            dim=-1,237        )238239    def _coalition_indices(self, normalized_ids):240        # 环境中联盟编号以 [0, 1] 归一化存储,这里恢复为离散 task slot。241        return (normalized_ids * max(1, self.num_tasks - 1)).round().long().clamp(242            0, self.num_tasks - 1243        )244245    def _relation_features(self, ego, others, tasks):246        # A-A 边:相对位置 + 是否同联盟。247        # A-T 边:相对任务位置 + focal agent 当前是否分配给该任务。248        ego_position = ego[..., 0:2]249        ego_coalition = ego[..., 2 + self.cap_dim]250        other_position = others[..., 0:2]251        other_coalition = others[..., self.cap_dim + 4]252        aa_relative = other_position - ego_position.unsqueeze(2)253        same_coalition = (254            self._coalition_indices(other_coalition)255            == self._coalition_indices(ego_coalition).unsqueeze(2)256        ).float()257        aa_features = torch.cat(258            [aa_relative, same_coalition.unsqueeze(-1)], dim=-1259        )260        task_position = tasks[..., 0:2]261        at_relative = task_position - ego_position.unsqueeze(2)262        task_ids = torch.arange(self.num_tasks, device=ego.device)263        assigned = (264            self._coalition_indices(ego_coalition).unsqueeze(-1)265            == task_ids.view(1, 1, -1)266        ).float()267        at_features = torch.cat(268            [at_relative, assigned.unsqueeze(-1)], dim=-1269        )270        return aa_features, at_features271272    def _aggregate_coalitions(273        self, self_h, other_h, ego_coalition, other_coalition, other_mask274    ):275        """把 ego/other 成员按当前任务编号 scatter 到任务槽中形成联盟 sum/mean/count。"""276277        batch_size, observers, _, hidden_dim = other_h.shape278        coalition_sum = other_h.new_zeros(279            batch_size, observers, self.num_tasks, hidden_dim280        )281        coalition_count = other_h.new_zeros(282            batch_size, observers, self.num_tasks, 1283        )284        other_indices = self._coalition_indices(other_coalition)285        # scatter_add_ 需要把每个 other 的任务槽编号扩展到 hidden_dim 维。286        message_indices = other_indices.unsqueeze(-1).expand(287            -1, -1, -1, hidden_dim288        )289        # other_mask 会过滤 padding/self/dead agent,避免进入联盟聚合。290        valid_messages = other_h * other_mask.unsqueeze(-1)291        coalition_sum.scatter_add_(2, message_indices, valid_messages)292        coalition_count.scatter_add_(293            2, other_indices.unsqueeze(-1), other_mask.unsqueeze(-1)294        )295        self_indices = self._coalition_indices(ego_coalition).unsqueeze(-1)296        # v2.0 当前联盟状态包含 focal agent 自己,因此把 self_h 加回当前任务槽。297        coalition_sum.scatter_add_(298            2,299            self_indices.unsqueeze(-1).expand(-1, -1, -1, hidden_dim),300            self_h.unsqueeze(2),301        )302        coalition_count.scatter_add_(303            2,304            self_indices.unsqueeze(-1),305            torch.ones_like(self_indices).unsqueeze(-1).float(),306        )307        # mean 给任务融合提供规模无关的联盟表示;count 保留联盟人数信息。308        coalition_mean = coalition_sum / coalition_count.clamp_min(1.0)309        return coalition_sum, coalition_mean, coalition_count310311    def forward(self, obs, available_actions, return_diagnostics=True):312        del available_actions313        ego = obs["ego"]314        others = obs["others"]315        tasks = obs["tasks"]316        ego_coalition = ego[..., 2 + self.cap_dim]317        other_coalition = others[..., self.cap_dim + 4]318        # 联盟编号只用于构造关系和聚合槽,不作为连续语义特征直接送入 encoder。319        ego_input = ego.clone()320        others_input = others.clone()321        ego_input[..., 2 + self.cap_dim] = 0.0322        others_input[..., self.cap_dim + 4] = 0.0323        self_h = self.self_encoder(ego_input)324        other_h = self.other_encoder(others_input)325        task_encoder_input = self._prepare_task_encoder_input(tasks)326        task_h = encode_repeated_tasks(327            self.task_encoder, task_encoder_input, self.shared_task_obs328        )329        # relation_features 不进 encoder,而是作为 attention/decoder 的边条件。330        aa_features, at_features = self._relation_features(ego, others, tasks)331        living_index = self.cap_dim + 6332        # others 已经把对角线自身置零;这里再过滤空行和死亡 agent。333        other_mask = (334            (others.abs().sum(dim=-1) > 0)335            & (others[..., living_index] > 0.5)336        ).float()337        # A-A attention 提供“其他智能体对当前 agent 决策的上下文影响”。338        if self.use_aa_attention:339            aa_context, aa_weights = self.aa_attention(340                self_h, other_h, aa_features, other_mask.bool()341            )342        else:343            aa_context = torch.zeros_like(self_h)344            aa_weights = other_h.new_zeros(345                *other_h.shape[:-1], self.aa_attention.num_heads346            )347        agent_update = self.agent_fusion(348            torch.cat([self_h, aa_context], dim=-1)349        )350        agent_context = self.agent_norm(self_h + agent_update)351        # v2.0 的任务上下文来自当前联盟状态,不考虑“如果我加入某任务会怎样”。352        coalition_sum, coalition_mean, coalition_count = self._aggregate_coalitions(353            self_h,354            other_h,355            ego_coalition,356            other_coalition,357            other_mask,358        )359        if not self.use_coalition_context:360            coalition_sum = torch.zeros_like(coalition_sum)361            coalition_mean = torch.zeros_like(coalition_mean)362            coalition_count = torch.zeros_like(coalition_count)363        normalized_count = coalition_count / float(max(1, self.num_agents))364        task_update = self.task_fusion(365            torch.cat(366                [task_h, coalition_sum, coalition_mean, normalized_count], dim=-1367            )368        )369        task_context = self.task_norm(task_h + task_update)370        diagnostics = {}371        if return_diagnostics:372            diagnostics = {373                # diagnostics 不参与反传,只给 runner/logging 观察注意力和联盟规模。374                "aa_attention": aa_weights,375                "occupied_task_ratio": (coalition_count > 0).float(),376                "max_coalition_size": coalition_count.max(dim=2).values,377                "coalition_context_norm": coalition_sum.norm(dim=-1),378            }379        return agent_context, task_context, at_features, diagnostics380381382class CandidateEdgeDecoder(nn.Module):383    """共享 A-T 边打分器:每个候选任务动作复用同一 MLP 输出一个 logit。"""384385    def __init__(self, agent_dim, task_dim, hidden_dim, edge_dim=3):386        super().__init__()387        self.network = nn.Sequential(388            nn.Linear(agent_dim + task_dim + edge_dim, hidden_dim),389            nn.ReLU(),390            nn.Linear(hidden_dim, hidden_dim),391            nn.ReLU(),392            nn.Linear(hidden_dim, 1),393        )394        nn.init.orthogonal_(self.network[-1].weight, gain=0.01)395        nn.init.constant_(self.network[-1].bias, 0.0)396397    def forward(self, agent_context, task_context, edge_features):398        # agent_context 扩展到所有任务槽,与 task_context/edge_features 拼接后逐任务评分。399        expanded_agent = agent_context.unsqueeze(2).expand(400            -1, -1, task_context.shape[2], -1401        )402        features = torch.cat(403            [expanded_agent, task_context, edge_features], dim=-1404        )405        return self.network(features).squeeze(-1)406407408class HATTV20Actor(nn.Module):409    """MAPPO/RMAPPO actor 外壳:负责 obs 整形、RNN、动作 mask 和 Categorical 分布。"""410411    def __init__(412        self,413        args,414        ego_dim,415        other_dim,416        task_dim,417        num_agents,418        num_tasks,419        device=torch.device("cpu"),420    ):421        super().__init__()422        self.num_agents = num_agents423        self.num_tasks = num_tasks424        self.hidden_size = args.hidden_size425        self.use_policy_active_masks = args.use_policy_active_masks426        self.use_naive_recurrent_policy = args.use_naive_recurrent_policy427        self.use_recurrent_policy = args.use_recurrent_policy428        self.tpdv = dict(dtype=torch.float32, device=device)429        hidden_dim = getattr(args, "hatt_hidden_dim", args.gat_out_dim)430        num_heads = getattr(args, "hatt_num_heads", 4)431        dropout = getattr(args, "hatt_dropout", 0.0)432        self.backbone = HATTV20Backbone(433            ego_dim=ego_dim,434            other_dim=other_dim,435            task_dim=task_dim,436            hidden_dim=hidden_dim,437            num_heads=num_heads,438            cap_dim=args.cap_dim,439            num_agents=num_agents,440            num_tasks=num_tasks,441            dropout=dropout,442            shared_task_obs=getattr(args, "hatt_shared_task_obs", True),443            use_aa_attention=getattr(args, "hatt_use_aa_attention", True),444            use_coalition_context=getattr(445                args, "hatt_use_coalition_context", True446            ),447            task_input_mode=getattr(args, "hatt_v20_task_input_mode", "native"),448            task_requirement_scale=getattr(args, "hatt_capability_scale", 5.0),449        )450        self.agent_projection = nn.Sequential(451            nn.Linear(hidden_dim, self.hidden_size),452            nn.ReLU(),453            nn.LayerNorm(self.hidden_size),454        )455        if self.use_naive_recurrent_policy or self.use_recurrent_policy:456            self.rnn = RNNLayer(457                self.hidden_size,458                self.hidden_size,459                args.recurrent_N,460                args.use_orthogonal,461            )462        self.decoder = CandidateEdgeDecoder(463            agent_dim=self.hidden_size,464            task_dim=hidden_dim,465            hidden_dim=self.hidden_size,466        )467        # learned reconfiguration gate 只在环境把 agent 激活给 actor 时生效。468        # mixture 是最早的可微混合:469        #   pi(a|s) = g(s) * pi_hatt(a|s) + (1-g(s)) * 1[a=current]。470        # switch/hierarchical 是后续定位实验:前者让“离开当前任务”更可辨识,471        # 后者把“是否重新决策”和“重新决策后选哪个任务”拆成显式二阶段动作。472        self.use_reconfiguration_gate = (473            getattr(args, "reconfiguration_mode", "legacy") == "learned"474        )475        self.reconfiguration_gate_floor = float(476            getattr(args, "reconfiguration_gate_floor", 0.02)477        )478        self.reconfiguration_context_features = bool(479            getattr(args, "reconfiguration_context_features", False)480        )481        self.reconfiguration_context_dim = int(482            getattr(args, "reconfiguration_context_dim", 9)483        )484        self.reconfiguration_gate_parameterization = str(485            getattr(args, "reconfiguration_gate_parameterization", "mixture")486        )487        if self.reconfiguration_gate_parameterization not in (488            "mixture", "switch", "hierarchical"489        ):490            raise ValueError(491                "reconfiguration_gate_parameterization must be "492                "'mixture', 'switch', or 'hierarchical'."493            )494        self.reconfiguration_gate_override = str(495            getattr(args, "reconfiguration_gate_override", "none")496        )497        if self.reconfiguration_gate_override not in (498            "none", "open", "closed", "event", "event_or_learned"499        ):500            raise ValueError(501                "reconfiguration_gate_override must be "502                "none/open/closed/event/event_or_learned."503            )504        if (505            self.reconfiguration_gate_override in ("event", "event_or_learned")506            and not self.reconfiguration_context_features507        ):508            raise ValueError(509                "The event gate override requires reconfiguration_context_features."510            )511        if not 0.0 <= self.reconfiguration_gate_floor < 1.0:512            raise ValueError("reconfiguration_gate_floor must be in [0, 1).")513        if self.use_reconfiguration_gate:514            self.reconfiguration_gate = nn.Sequential(515                nn.Linear(516                    self.hidden_size517                    + hidden_dim518                    + 2519                    + (520                        self.reconfiguration_context_dim521                        if self.reconfiguration_context_features522                        else 0523                    ),524                    self.hidden_size,525                ),526                nn.ReLU(),527                nn.Linear(self.hidden_size, 1),528            )529            nn.init.orthogonal_(self.reconfiguration_gate[-1].weight, gain=0.01)530            initial_probability = float(531                getattr(args, "reconfiguration_gate_init", 0.8)532            )533            if not 0.0 < initial_probability < 1.0:534                raise ValueError("reconfiguration_gate_init must be in (0, 1).")535            nn.init.constant_(536                self.reconfiguration_gate[-1].bias,537                math.log(initial_probability / (1.0 - initial_probability)),538            )539        self.last_diagnostics = {}540        self.last_gate_actions = None541        self.last_gate_regularization = None542        self.to(device)543544    def _prepare_obs(self, obs):545        # HATT 路径要求 Dict 观测;flatten 只在非 HATT actor 内部使用。546        if not isinstance(obs, dict):547            raise TypeError("HATTV20Actor requires structured Dict observations.")548        result = {k: check(v).to(**self.tpdv) for k, v in obs.items()}549        if result["ego"].ndim == 2:550            # buffer 采样时常把 [B, A, ...] 压成 [B*A, ...],这里恢复 observer 维。551            result = {552                k: v.reshape(553                    v.shape[0] // self.num_agents,554                    self.num_agents,555                    *v.shape[1:],556                )557                for k, v in result.items()558            }559        return result560561    def _distribution(self, obs, rnn_states, masks, available_actions):562        obs = self._prepare_obs(obs)563        batch_size, num_agents, _ = obs["ego"].shape564        if num_agents != self.num_agents:565            raise ValueError(f"Expected {self.num_agents} agents, got {num_agents}.")566        # available_actions 的最后一维和任务槽一一对应,非法动作会在 logits 上置为极小值。567        available_actions = check(available_actions).to(**self.tpdv)568        available_actions = available_actions.reshape(569            batch_size, num_agents, self.num_tasks570        )571        if not torch.all(available_actions.sum(dim=-1) > 0):572            raise ValueError("Every active HATT policy row must have an available action.")573        agent_context, task_h, edge_features, diagnostics = self.backbone(574            obs, available_actions575        )576        agent_features = self.agent_projection(agent_context).reshape(577            batch_size * num_agents, self.hidden_size578        )579        # RNNLayer 沿 batch*agent 维工作,因此进入 RNN 前先摊平 agent 维。580        rnn_states = check(rnn_states).to(**self.tpdv)581        masks = check(masks).to(**self.tpdv)582        if self.use_naive_recurrent_policy or self.use_recurrent_policy:583            agent_features, rnn_states = self.rnn(584                agent_features, rnn_states, masks585            )586        agent_features = agent_features.reshape(587            batch_size, num_agents, self.hidden_size588        )589        logits = self.decoder(agent_features, task_h, edge_features)590        logits = logits.reshape(batch_size * num_agents, self.num_tasks)591        available_flat = available_actions.reshape(592            batch_size * num_agents, self.num_tasks593        )594        logits = logits.masked_fill(available_flat <= 0, -1e10)595        dist = FixedCategorical(logits=logits)596        if self.use_reconfiguration_gate:597            current_indices = (598                obs["ego"][..., 2 + self.backbone.cap_dim]599                * float(max(1, self.num_tasks - 1))600            ).round().long().clamp(0, self.num_tasks - 1)601            current_task = task_h.gather(602                2,603                current_indices[..., None, None].expand(604                    -1, -1, 1, task_h.shape[-1]605                ),606            ).squeeze(2)607608            base_probs = dist.probs.reshape(609                batch_size, num_agents, self.num_tasks610            )611            current_probability = base_probs.gather(612                2, current_indices.unsqueeze(-1)613            )614            alternative_probs = base_probs.clone()615            alternative_probs.scatter_(616                2, current_indices.unsqueeze(-1), 0.0617            )618            best_alternative_probability = alternative_probs.max(619                dim=2, keepdim=True620            ).values621            # gate 输入包含当前任务上下文、当前动作概率和最优替代动作优势。622            # 若开启 reconfiguration_context_features,再拼接环境显式事件/时间特征;623            # 这样 gate 不需要从稀疏局部观测中反推出“现在是否必须决策”。624            gate_features = torch.cat(625                [626                    agent_features,627                    current_task,628                    current_probability,629                    best_alternative_probability - current_probability,630                    *(631                        [obs["reconfiguration"]]632                        if self.reconfiguration_context_features633                        else []634                    ),635                ],636                dim=-1,637            )638            raw_gate_probability = torch.sigmoid(639                self.reconfiguration_gate(gate_features)640            )641            # floor 防止 gate 在早期训练中完全关死,导致 decoder 没有梯度样本。642            gate_probability = self.reconfiguration_gate_floor + (643                1.0 - self.reconfiguration_gate_floor644            ) * raw_gate_probability645            if self.reconfiguration_gate_override == "open":646                gate_probability = torch.ones_like(gate_probability)647            elif self.reconfiguration_gate_override == "closed":648                gate_probability = torch.zeros_like(gate_probability)649            elif self.reconfiguration_gate_override in ("event", "event_or_learned"):650                context = obs["reconfiguration"]651                # 事件 override 用于干预实验:新任务、任务完成、当前任务完成,652                # 以及 11 维 context 中的 mandatory 决策都会强制开门。653                event_trigger = (654                    (context[..., 0:1] > 0.5)655                    | (context[..., 1:2] > 0.5)656                    | (context[..., 3:4] > 0.5)657                )658                if self.reconfiguration_context_dim >= 11:659                    event_trigger = event_trigger | (context[..., 10:11] > 0.5)660                if self.reconfiguration_gate_override == "event":661                    gate_probability = event_trigger.to(gate_probability.dtype)662                else:663                    gate_probability = torch.where(664                        event_trigger,665                        torch.ones_like(gate_probability),666                        gate_probability,667                    )668            current_available = available_actions.gather(669                2, current_indices.unsqueeze(-1)670            )671            # A completed or newly unavailable current task cannot be retained.672            gate_probability = torch.where(673                current_available > 0.0,674                gate_probability,675                torch.ones_like(gate_probability),676            )677            if self.reconfiguration_gate_parameterization == "hierarchical":678                # hierarchical 不把 gate 混入任务分布,而是暂存 Bernoulli 参数。679                # forward/evaluate_actions 会单独采样/重放 gate_action,并把 joint680                # logprob 写成 log p(z|s) + z * log p(a|s,z=1)。681                self._hierarchical_gate_probability = gate_probability682                self._hierarchical_current_indices = current_indices683                if self.reconfiguration_context_features and self.reconfiguration_context_dim >= 11:684                    # mandatory 位置只表示“必须让 decoder 接管”的状态;685                    # 正则项不会惩罚这些由环境语义强制的重新决策。686                    self._hierarchical_mandatory_mask = (687                        obs["reconfiguration"][..., 10:11] > 0.5688                    ).to(gate_probability.dtype)689                else:690                    self._hierarchical_mandatory_mask = torch.zeros_like(691                        gate_probability692                    )693                mixed_probs = base_probs694            elif self.reconfiguration_gate_parameterization == "switch":695                # Make g identifiable from the executed action:696                # P(current)=1-g and P(alternative)=g*q(alternative), where q697                # has no mass on current.  The historical mixture instead has698                # P(switch)=g*(1-p_current), allowing g and p_current to cancel.699                alternative_probs = base_probs.clone()700                alternative_probs.scatter_(701                    2, current_indices.unsqueeze(-1), 0.0702                )703                alternative_mass = alternative_probs.sum(dim=-1, keepdim=True)704                has_alternative = alternative_mass > 1e-8705                conditional_alternatives = alternative_probs / alternative_mass.clamp_min(1e-8)706                gate_probability = torch.where(707                    has_alternative,708                    gate_probability,709                    torch.zeros_like(gate_probability),710                )711                mixed_probs = gate_probability * conditional_alternatives712                mixed_probs.scatter_add_(713                    2,714                    current_indices.unsqueeze(-1),715                    1.0 - gate_probability,716                )717            else:718                mixed_probs = gate_probability * base_probs719                mixed_probs.scatter_add_(720                    2,721                    current_indices.unsqueeze(-1),722                    1.0 - gate_probability,723                )724            mixed_probs = mixed_probs / mixed_probs.sum(725                dim=-1, keepdim=True726            ).clamp_min(1e-8)727            dist = FixedCategorical(728                probs=mixed_probs.reshape(729                    batch_size * num_agents, self.num_tasks730                )731            )732            diagnostics.update(733                {734                    "reconfiguration_gate_probability": gate_probability,735                    "reconfiguration_switch_probability": (736                        gate_probability737                        if self.reconfiguration_gate_parameterization == "switch"738                        else gate_probability * (1.0 - current_probability)739                    ),740                    "reconfiguration_current_task_probability": current_probability,741                }742            )743            if self.reconfiguration_context_features:744                event_mask = (745                    (obs["reconfiguration"][..., 0:1] > 0.5)746                    | (obs["reconfiguration"][..., 1:2] > 0.5)747                    | (obs["reconfiguration"][..., 3:4] > 0.5)748                )749                if self.reconfiguration_context_dim >= 11:750                    event_mask = event_mask | (751                        obs["reconfiguration"][..., 10:11] > 0.5752                    )753                for suffix, selection in (754                    ("on_event", event_mask),755                    ("off_event", ~event_mask),756                ):757                    selected = gate_probability.masked_select(selection)758                    diagnostics[759                        f"reconfiguration_gate_probability_{suffix}"760                    ] = (761                        selected.mean()762                        if selected.numel()763                        else torch.zeros((), **self.tpdv)764                    )765        # 诊断张量只用于日志,detach 避免 runner 持有计算图。766        self.last_diagnostics = {k: v.detach() for k, v in diagnostics.items()}767        return dist, rnn_states768769    def _hierarchical_action(self, dist, deterministic):770        """采样显式二阶段动作:先决定是否重选,再在开门时采样任务动作。"""771772        gate_probability = self._hierarchical_gate_probability.reshape(-1, 1)773        gate_dist = torch.distributions.Bernoulli(probs=gate_probability)774        gate_action = (775            (gate_probability >= 0.5).to(gate_probability.dtype)776            if deterministic777            else gate_dist.sample()778        )779        task_action = dist.mode() if deterministic else dist.sample()780        current = self._hierarchical_current_indices.reshape(-1, 1)781        action = torch.where(gate_action > 0.5, task_action, current)782        # gate_action=0 时最终动作固定为 current,此时 task_action 只是未执行的783        # 内部样本,不能把它的 logprob 计入 PPO 的重要性权重。784        joint_log_prob = gate_dist.log_prob(gate_action)785        joint_log_prob = joint_log_prob + gate_action * dist.log_probs(task_action)786        self.last_gate_actions = gate_action.detach()787        return action, joint_log_prob788789    def forward(790        self,791        obs,792        rnn_states,793        masks,794        available_actions=None,795        deterministic=False,796    ):797        dist, rnn_states = self._distribution(798            obs, rnn_states, masks, available_actions799        )800        if (801            self.use_reconfiguration_gate802            and self.reconfiguration_gate_parameterization == "hierarchical"803        ):804            actions, action_log_probs = self._hierarchical_action(805                dist, deterministic806            )807            return actions, action_log_probs, rnn_states808        actions = dist.mode() if deterministic else dist.sample()809        self.last_gate_actions = None810        return actions, dist.log_probs(actions), rnn_states811812    def evaluate_actions(813        self,814        obs,815        rnn_states,816        action,817        masks,818        available_actions=None,819        active_masks=None,820        gate_actions=None,821    ):822        dist, _ = self._distribution(obs, rnn_states, masks, available_actions)823        action = check(action).to(device=self.tpdv["device"])824        if (825            self.use_reconfiguration_gate826            and self.reconfiguration_gate_parameterization == "hierarchical"827        ):828            if gate_actions is None:829                raise ValueError("hierarchical gate evaluation requires gate_actions")830            gate_actions = check(gate_actions).to(**self.tpdv).reshape(-1, 1)831            gate_probability = self._hierarchical_gate_probability.reshape(-1, 1)832            gate_dist = torch.distributions.Bernoulli(probs=gate_probability)833            # PPO update 必须重放 rollout 时实际采到的 gate_action;否则834            # old/new logprob 对不上,hierarchical gate 的梯度会变成错配样本。835            action_log_probs = gate_dist.log_prob(gate_actions)836            action_log_probs = action_log_probs + gate_actions * dist.log_probs(action)837            entropy = gate_dist.entropy().squeeze(-1) + (838                gate_probability.squeeze(-1) * dist.entropy()839            )840            learned_mask = 1.0 - self._hierarchical_mandatory_mask.reshape(-1, 1)841            if active_masks is not None:842                learned_mask = learned_mask * check(active_masks).to(**self.tpdv).reshape(-1, 1)843            # decision_coef 惩罚的是“非强制状态下主动开门”的平均概率,844            # 用来寻找更稀疏的 learned reconfiguration,而不是环境奖励项。845            self.last_gate_regularization = (846                gate_probability * learned_mask847            ).sum() / learned_mask.sum().clamp_min(1.0)848        else:849            action_log_probs = dist.log_probs(action)850            entropy = dist.entropy()851            self.last_gate_regularization = None852        if active_masks is not None and self.use_policy_active_masks:853            active_masks = check(active_masks).to(**self.tpdv).squeeze(-1)854            dist_entropy = (855                (entropy * active_masks).sum()856                / active_masks.sum().clamp_min(1.0)857            )858        else:859            dist_entropy = entropy.mean()860        return action_log_probs, dist_entropy