from __future__ import annotations from dataclasses import dataclass from typing import List, Tuple import torch from torch import nn @dataclass class BipartiteGraphBatch: """Lightweight container for a batch of bipartite graphs. All tensors live on the same device. Shapes: - object_feats: [B, num_objects, in_dim] - attr_feats: [B, num_attrs, attr_dim] - edge_index: [2, num_edges] with edges from object -> attribute - edge_weight: [num_edges] """ object_feats: torch.Tensor attr_feats: torch.Tensor edge_index: torch.Tensor edge_weight: torch.Tensor class BipartiteMessagePassingLayer(nn.Module): """Single message passing layer for object <-> attribute bipartite graphs. For smoke training we keep a simple formulation: 1) Aggregate attribute messages into each object using weighted mean. 2) Project and combine with previous object features via residual MLP. """ def __init__(self, in_dim: int, out_dim: int, attr_dim: int, dropout: float = 0.0) -> None: super().__init__() self.in_dim = in_dim self.out_dim = out_dim self.attr_to_obj = nn.Linear(attr_dim, in_dim) self.proj = nn.Linear(in_dim, out_dim) self.update = nn.Sequential( nn.Linear(in_dim + out_dim, out_dim), nn.ReLU(), nn.Dropout(dropout), ) def forward( self, object_feats: torch.Tensor, attr_feats: torch.Tensor, edge_index: torch.Tensor, edge_weight: torch.Tensor | None = None, ) -> torch.Tensor: # object_feats: [B, O, Din], attr_feats: [B, A, Din_attr] bsz, num_objects, _ = object_feats.shape device = object_feats.device if edge_weight is None: edge_weight = torch.ones(edge_index.shape[1], device=device) src_obj = edge_index[0] # indices in [0, B*O) src_attr = edge_index[1] # indices in [0, B*A) # Flatten batch/object and batch/attr dimensions for gathering. flat_objects = object_feats.reshape(bsz * num_objects, -1) flat_attrs = attr_feats.reshape(bsz * attr_feats.shape[1], -1) # Messages from attributes to objects. attr_msgs = flat_attrs.index_select(0, src_attr) # [E, Din_attr] attr_msgs = self.attr_to_obj(attr_msgs) # [E, Din] attr_msgs = attr_msgs.to(flat_objects.dtype) w = edge_weight.view(-1, 1).to(flat_objects.dtype) weighted_msgs = attr_msgs * w # Aggregate messages per object index. agg = torch.zeros_like(flat_objects, device=device) agg.index_add_(0, src_obj, weighted_msgs) # Normalize by total incoming weight per object to compute mean. weight_sums = torch.zeros(flat_objects.shape[0], device=device) weight_sums.index_add_(0, src_obj, edge_weight) weight_sums = weight_sums.clamp_min(1e-6).view(-1, 1) agg = agg / weight_sums # Project aggregated messages and combine with original object features. proj_msgs = self.proj(agg) combined = torch.cat([flat_objects, proj_msgs], dim=-1) updated = self.update(combined) return updated.view(bsz, num_objects, self.out_dim) class NativeGNNClassifier(nn.Module): """Simple bipartite GNN classifier for multi-label attribute prediction.""" def __init__( self, in_dim: int, hidden_dims: List[int], num_attributes: int, dropout: float = 0.2, ) -> None: super().__init__() layers: List[nn.Module] = [] dims = [in_dim] + hidden_dims attr_dim = in_dim for dim_in, dim_out in zip(dims[:-1], dims[1:]): layers.append(BipartiteMessagePassingLayer(dim_in, dim_out, attr_dim=attr_dim, dropout=dropout)) self.layers = nn.ModuleList(layers) self.classifier = nn.Linear(dims[-1], num_attributes) def forward( self, graph: BipartiteGraphBatch, ) -> torch.Tensor: x = graph.object_feats for layer in self.layers: x = layer( object_feats=x, attr_feats=graph.attr_feats, edge_index=graph.edge_index, edge_weight=graph.edge_weight, ) # Predict attributes for each object, then average over objects in batch. logits_per_object = self.classifier(x) # [B, O, num_attributes] return logits_per_object.mean(dim=1)