返回代码索引

HATTV33AANetwork.py

TypedRelationEdge 与 v3.3AA 基础;learned/uniform/zero 语义

原文件 · 436 行 · 18592 字节

algorithms/algorithm/HATTV33AANetwork.py
SHA-256: 6b0ee905eff4b1d5fcd203023c5aa0d00a256b39cd0aef791fa0fc3b6f13dbdb

1"""HATT v3.3AA 用同一套计算步骤处理智能体边和任务边。23智能体边和任务边各自保存参数。现有成员的智能体边输出会按联盟编号放入对应的4候选任务,再和当前智能体到该任务的边输出一起生成动作表示。56``hatt_candidate_aa_mode`` 只控制智能体边的 gate:``learned`` 使用网络输出,7``uniform`` 固定为 0.5,``zero`` 将这条分支清零。8"""910import torch11import torch.nn as nn1213from algorithms.algorithm.HATTV20Network import (14    HATTV20Actor,15    FeatureEncoder,16    encode_repeated_tasks,17)181920class TypedRelationEdge(nn.Module):21    """输入一组有方向的边,为每条有效边生成 gate 和消息。"""2223    def __init__(self, hidden_dim, edge_dim, num_heads, dropout=0.0):24        super().__init__()25        # 每个 head 使用相同宽度,因此 hidden_dim 必须能被 head 数整除。26        if hidden_dim % num_heads != 0:27            raise ValueError(28                f"hidden_dim={hidden_dim} must be divisible by num_heads={num_heads}."29            )30        self.num_heads = num_heads31        self.head_dim = hidden_dim // num_heads32        # 点积除以维度平方根,避免 hidden_dim 增大后数值过大。33        self.scale = self.head_dim ** -0.534        # source 生成 query,target 分别生成 key 和 value。35        self.query = nn.Linear(hidden_dim, hidden_dim)36        self.key = nn.Linear(hidden_dim, hidden_dim)37        self.value = nn.Linear(hidden_dim, hidden_dim)38        # 相对位置和联盟关系会转换成每个 head 的额外分数。39        self.edge_bias = nn.Sequential(40            nn.Linear(edge_dim, hidden_dim),41            nn.ReLU(),42            nn.Linear(hidden_dim, num_heads),43        )44        self.output = nn.Linear(hidden_dim, hidden_dim)45        nn.init.constant_(self.output.bias, 0.0)46        self.dropout = nn.Dropout(dropout)4748    def forward(self, source, target, edge_features, valid_mask, gate_mode="learned"):49        # source 是边的起点,target 可以包含多个终点。每个 head 分别计算 gate,50        # 随后用 gate 缩放终点的 value,并合并成一条消息。51        if gate_mode not in ("learned", "uniform", "zero"):52            raise ValueError(53                "gate_mode must be learned, uniform, or zero; "54                f"got {gate_mode!r}."55            )56        prefix = target.shape[:-2]57        num_targets = target.shape[-2]58        hidden_dim = target.shape[-1]59        # 把最后一维拆成多个 head。prefix 中保留 batch、观察者等前置维度。60        query = self.query(source).view(*prefix, self.num_heads, self.head_dim)61        key = self.key(target).view(62            *prefix, num_targets, self.num_heads, self.head_dim63        )64        value = self.value(target).view(65            *prefix, num_targets, self.num_heads, self.head_dim66        )67        # 每条边的分数由 query-key 点积和边特征共同决定。68        logits = (69            torch.einsum("...he,...nhe->...nh", query, key) * self.scale70            + self.edge_bias(edge_features)71        )72        learned_gates = torch.sigmoid(logits)73        valid = valid_mask.bool().unsqueeze(-1)74        # padding、死亡智能体和不可选任务对应的 gate 固定为零。75        learned_gates = torch.where(76            valid, learned_gates, torch.zeros_like(learned_gates)77        )78        if gate_mode == "learned":79            gates = learned_gates80        elif gate_mode == "uniform":81            # uniform 用固定权重替代网络输出,供对照实验使用。82            gates = valid.expand_as(learned_gates).to(learned_gates.dtype) * 0.583        else:84            gates = torch.zeros_like(learned_gates)85        # 每个 head 用自己的 gate 缩放 value,再把所有 head 拼回 hidden_dim。86        gates = self.dropout(gates)87        message = (gates.unsqueeze(-1) * value).reshape(88            *prefix, num_targets, hidden_dim89        )90        message = self.output(message)91        if gate_mode == "zero":92            # output 层可能含有 bias,这里再次清零,保证整条分支没有输出。93            message = torch.zeros_like(message)94        # learned_gates 始终保留网络原始结果,uniform/zero 模式下也可用于诊断。95        return message, gates, learned_gates969798class HATTV33AABackbone(nn.Module):99    """汇总智能体边和任务边的消息,为每个候选任务生成表示。"""100101    AA_MODES = ("learned", "uniform", "zero")102103    def __init__(104        self,105        ego_dim,106        other_dim,107        task_dim,108        hidden_dim,109        num_heads,110        cap_dim,111        num_agents,112        num_tasks,113        dropout=0.0,114        shared_task_obs=True,115        candidate_aa_mode="learned",116    ):117        super().__init__()118        # 配置错误在初始化时直接报出,避免训练过程中静默切换计算方式。119        if candidate_aa_mode not in self.AA_MODES:120            raise ValueError(121                f"hatt_candidate_aa_mode must be one of {self.AA_MODES}, "122                f"got {candidate_aa_mode!r}."123            )124        self.cap_dim = cap_dim125        self.num_agents = num_agents126        self.num_tasks = num_tasks127        self.shared_task_obs = shared_task_obs128        self.candidate_aa_mode = candidate_aa_mode129130        # 三类实体先各自编码到相同的 hidden_dim,后续关系层才能直接组合。131        self.self_encoder = FeatureEncoder(ego_dim, hidden_dim)132        self.other_encoder = FeatureEncoder(other_dim, hidden_dim)133        self.task_encoder = FeatureEncoder(task_dim, hidden_dim)134        # 两类边使用相同的计算步骤,参数分别保存在 aa_relation 和 at_relation 中。135        self.aa_relation = TypedRelationEdge(136            hidden_dim, edge_dim=3, num_heads=num_heads, dropout=dropout137        )138        self.at_relation = TypedRelationEdge(139            hidden_dim, edge_dim=3, num_heads=num_heads, dropout=dropout140        )141        # 当前智能体表示会和全局 A-A、A-T 消息拼接,生成 agent_context。142        self.agent_fusion = nn.Sequential(143            nn.Linear(hidden_dim * 3, hidden_dim),144            nn.ReLU(),145            nn.Linear(hidden_dim, hidden_dim),146        )147        self.agent_norm = nn.LayerNorm(hidden_dim)148        # 每个候选任务合并任务本身、当前智能体到任务的消息、联盟成员消息和人数。149        self.candidate_fusion = nn.Sequential(150            nn.Linear(hidden_dim * 4 + 1, hidden_dim),151            nn.ReLU(),152            nn.Linear(hidden_dim, hidden_dim),153        )154        self.candidate_norm = nn.LayerNorm(hidden_dim)155156    def _coalition_indices(self, normalized_ids):157        """把观测中的归一化联盟编号恢复成任务槽下标。"""158        return (normalized_ids * max(1, self.num_tasks - 1)).round().long().clamp(159            0, self.num_tasks - 1160        )161162    @staticmethod163    def _delivery_mask(tasks):164        """标出任务列表中的 Deliver 槽。"""165        # 任务类型除以 3 存入最后一项,因此 Deliver 对应 1/3。166        return torch.isclose(167            tasks[..., -1],168            tasks.new_tensor(1.0 / 3.0),169            atol=1e-5,170            rtol=0.0,171        )172173    @staticmethod174    def _masked_mean(value, mask):175        """只计算 mask 选中元素的均值;没有元素时返回零。"""176        selected = value.masked_select(mask)177        return selected.mean() if selected.numel() else value.new_zeros(())178179    def _relation_features(self, ego, others, tasks):180        """生成相对位置、同配送联盟和当前任务归属特征。"""181        ego_position = ego[..., 0:2]182        ego_coalition = ego[..., 2 + self.cap_dim]183        other_position = others[..., 0:2]184        other_coalition = others[..., self.cap_dim + 4]185        ego_indices = self._coalition_indices(ego_coalition)186        other_indices = self._coalition_indices(other_coalition)187188        # 先查出当前智能体所处任务的类型,控制动作不产生“同配送联盟”关系。189        current_task_type = tasks[..., -1].gather(2, ego_indices.unsqueeze(-1))190        current_is_delivery = torch.isclose(191            current_task_type,192            tasks.new_tensor(1.0 / 3.0),193            atol=1e-5,194            rtol=0.0,195        )196        same_delivery_coalition = (197            (other_indices == ego_indices.unsqueeze(2)) & current_is_delivery198        ).to(ego.dtype)199        # A-A 边包含对方相对位置,以及双方是否属于同一个配送联盟。200        aa_features = torch.cat(201            [202                other_position - ego_position.unsqueeze(2),203                same_delivery_coalition.unsqueeze(-1),204            ],205            dim=-1,206        )207208        task_position = tasks[..., 0:2]209        task_ids = torch.arange(self.num_tasks, device=tasks.device)210        # assigned 表示候选任务是否就是当前智能体正在执行的任务。211        assigned = (ego_indices.unsqueeze(-1) == task_ids.view(1, 1, -1)).to(212            ego.dtype213        )214        at_features = torch.cat(215            [216                task_position - ego_position.unsqueeze(2),217                assigned.unsqueeze(-1),218            ],219            dim=-1,220        )221        return aa_features, at_features, other_indices222223    @staticmethod224    def _pool_messages(messages, valid_mask):225        """平均有效边的消息,用于生成当前智能体的整体关系信息。"""226        valid = valid_mask.to(messages.dtype).unsqueeze(-1)227        count = valid.sum(dim=-2).clamp_min(1.0)228        return (messages * valid).sum(dim=-2) / count229230    def forward(self, obs, available_actions, return_diagnostics=True):231        """生成当前智能体表示、各候选任务表示、A-T 边特征和诊断数据。"""232        ego = obs["ego"]233        others = obs["others"]234        tasks = obs["tasks"]235        # 联盟编号用于后面的任务槽查找,不作为普通连续值送入实体 encoder。236        ego_input = ego.clone()237        others_input = others.clone()238        ego_input[..., 2 + self.cap_dim] = 0.0239        others_input[..., self.cap_dim + 4] = 0.0240        # self_h、other_h、task_h 的最后一维都等于 hidden_dim。241        self_h = self.self_encoder(ego_input)242        other_h = self.other_encoder(others_input)243        task_h = encode_repeated_tasks(244            self.task_encoder, tasks, self.shared_task_obs245        )246        aa_features, at_features, other_indices = self._relation_features(247            ego, others, tasks248        )249250        living_index = self.cap_dim + 6251        # 全零行是 padding;living 为 0 的智能体也不能发送消息。252        other_mask = (253            (others.abs().sum(dim=-1) > 0)254            & (others[..., living_index] > 0.5)255        )256        available_mask = (257            available_actions > 0258            if available_actions is not None259            else torch.ones(260                *tasks.shape[:-1], dtype=torch.bool, device=tasks.device261            )262        )263        # 当前智能体分别向所有其他智能体和所有可选任务建立有方向的边。264        aa_messages, aa_gates, learned_aa_gates = self.aa_relation(265            self_h,266            other_h,267            aa_features,268            other_mask,269            gate_mode=self.candidate_aa_mode,270        )271        at_messages, at_gates, _ = self.at_relation(272            self_h,273            task_h,274            at_features,275            available_mask,276            gate_mode="learned",277        )278279        # 为每个其他智能体取出它当前加入的任务,只计算这一条智能体到任务的边。280        # 这里复用 at_relation,无需展开“所有成员 × 所有任务”的完整组合。281        member_task_h = task_h.unsqueeze(2).expand(282            -1, -1, self.num_agents, -1, -1283        ).gather(284            3,285            other_indices[..., None, None].expand(286                -1, -1, -1, 1, task_h.shape[-1]287            ),288        )289        # 同样取出该任务的位置,用于计算成员到其当前任务的相对位置。290        member_task_position = tasks[..., 0:2].unsqueeze(2).expand(291            -1, -1, self.num_agents, -1, -1292        ).gather(293            3,294            other_indices[..., None, None].expand(-1, -1, -1, 1, 2),295        ).squeeze(3)296        # 最后一项固定为 1,表示这条边连接的是成员当前已经加入的任务。297        member_at_features = torch.cat(298            [299                member_task_position - others[..., 0:2],300                torch.ones_like(other_indices, dtype=tasks.dtype).unsqueeze(-1),301            ],302            dim=-1,303        ).unsqueeze(3)304        member_task_is_delivery = self._delivery_mask(tasks).gather(305            2, other_indices306        )307        # 只让存活成员向自己当前加入的 Deliver 任务发送消息。308        member_at_valid = (other_mask & member_task_is_delivery).unsqueeze(-1)309        member_at_messages, member_at_gates, _ = self.at_relation(310            other_h,311            member_task_h,312            member_at_features,313            member_at_valid,314            gate_mode="learned",315        )316        member_at_messages = member_at_messages.squeeze(3)317318        # 分别平均所有有效的智能体边消息和任务边消息,生成当前智能体的整体表示。319        global_aa = self._pool_messages(aa_messages, other_mask)320        global_at = self._pool_messages(at_messages, available_mask)321        # 残差保留 self_h,再用 LayerNorm 稳定合并后的数值范围。322        agent_update = self.agent_fusion(323            torch.cat([self_h, global_aa, global_at], dim=-1)324        )325        agent_context = self.agent_norm(self_h + agent_update)326327        task_ids = torch.arange(self.num_tasks, device=tasks.device).view(1, 1, -1)328        delivery_mask = self._delivery_mask(tasks)329        # member_mask 的维度是“观察者 × 候选任务 × 其他智能体”。330        member_mask = (331            (other_indices.unsqueeze(2) == task_ids.unsqueeze(-1))332            & other_mask.unsqueeze(2)333            & delivery_mask.unsqueeze(-1)334        )335        # 根据联盟编号,把已有的智能体边消息放到对应的候选任务中。336        routed_aa = aa_messages.unsqueeze(2) * member_mask.unsqueeze(-1).to(337            aa_messages.dtype338        )339        routed_member_at = member_at_messages.unsqueeze(2) * member_mask.unsqueeze(340            -1341        ).to(member_at_messages.dtype)342        # 每个候选任务分别统计当前联盟人数。343        member_count = member_mask.sum(dim=3, keepdim=True).to(task_h.dtype)344        # 除以人数平方根,减小大联盟简单累加带来的数值增长。345        coalition_aa = routed_aa.sum(dim=3) / member_count.clamp_min(1.0).sqrt()346        coalition_member_at = (347            routed_member_at.sum(dim=3) / member_count.clamp_min(1.0).sqrt()348        )349        candidate_update = self.candidate_fusion(350            torch.cat(351                [352                    task_h,353                    at_messages,354                    coalition_member_at,355                    coalition_aa,356                    member_count / float(max(1, self.num_agents)),357                ],358                dim=-1,359            )360        )361        # 保留原始 task_h,让新增关系消息以残差形式修改候选任务表示。362        candidate_context = self.candidate_norm(task_h + candidate_update)363364        diagnostics = {}365        if return_diagnostics:366            # eligible、nonempty、multi_member 分别记录可选配送任务、已有成员的367            # 配送任务,以及至少有两名成员的配送任务。368            eligible = delivery_mask & available_mask369            nonempty = eligible & (member_count[..., 0] > 0)370            multi_member = eligible & (member_count[..., 0] > 1)371            routed_gates = aa_gates.unsqueeze(2).expand(372                -1, -1, self.num_tasks, -1, -1373            )374            routed_learned = learned_aa_gates.unsqueeze(2).expand_as(routed_gates)375            # route_mask 只选出真正被放入某个候选联盟的 A-A gate。376            route_mask = member_mask.unsqueeze(-1).expand_as(routed_gates)377            diagnostics = {378                "aa_relation_gate": aa_gates,379                "at_relation_gate": at_gates,380                "candidate_aa_context_norm": coalition_aa.norm(dim=-1),381                "candidate_member_at_context_norm": coalition_member_at.norm(dim=-1),382                "candidate_aa_member_count": member_count,383                "candidate_aa_eligible_ratio": eligible.to(task_h.dtype),384                "candidate_aa_nonempty_ratio": nonempty.to(task_h.dtype),385                "candidate_aa_multi_member_ratio": multi_member.to(task_h.dtype),386                "candidate_aa_routed_gate": self._masked_mean(387                    routed_gates, route_mask388                ),389                "candidate_aa_learned_uniform_gate_delta": self._masked_mean(390                    (routed_learned - 0.5).abs(), route_mask391                ),392                "global_aa_context_norm": global_aa.norm(dim=-1),393                "global_at_context_norm": global_at.norm(dim=-1),394                "member_at_relation_gate": member_at_gates,395            }396        return agent_context, candidate_context, at_features, diagnostics397398399class HATTV33AAActor(HATTV20Actor):400    """使用 HATTV33AABackbone 的 MAPPO actor。"""401402    def __init__(403        self,404        args,405        ego_dim,406        other_dim,407        task_dim,408        num_agents,409        num_tasks,410        device=torch.device("cpu"),411    ):412        super().__init__(413            args,414            ego_dim,415            other_dim,416            task_dim,417            num_agents,418            num_tasks,419            device,420        )421        hidden_dim = getattr(args, "hatt_hidden_dim", args.gat_out_dim)422        # HATTV20Actor 提供动作分布和 RNN 外壳,这里只替换关系计算 backbone。423        self.backbone = HATTV33AABackbone(424            ego_dim=ego_dim,425            other_dim=other_dim,426            task_dim=task_dim,427            hidden_dim=hidden_dim,428            num_heads=getattr(args, "hatt_num_heads", 4),429            cap_dim=args.cap_dim,430            num_agents=num_agents,431            num_tasks=num_tasks,432            dropout=getattr(args, "hatt_dropout", 0.0),433            shared_task_obs=getattr(args, "hatt_shared_task_obs", True),434            candidate_aa_mode=getattr(args, "hatt_candidate_aa_mode", "learned"),435        )436        self.to(device)