| """Cross-attention pooling + supervised contrastive loss. |
| |
| STATUS (as of v9, 2026-05-27): NOT integrated into the current model. |
| Kept as a v10+ experiment toolkit: |
| |
| - DocumentPooling: cross-attention pooling using the kind node as query. |
| A pre-tree-aggregator design from v1/v2. Useful as an ablation baseline |
| if we ever want to test "is tree aggregation actually better than |
| plain cross-attention pooling?" |
| |
| - supervised_contrastive_loss: SupCon implementation. Useful for the |
| v10+ contrastive-learning experiments discussed in v9 results doc |
| (e.g., positive pairs = sibling subtrees of same parent). |
| |
| Has tests in tests/test_pooling.py. |
| """ |
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| class DocumentPooling(nn.Module): |
| """Pooling by Multi-head Attention. |
| |
| The kind node queries all other nodes via cross-attention |
| to produce a single document embedding. |
| """ |
|
|
| def __init__(self, d_model: int, num_heads: int = 4) -> None: |
| super().__init__() |
| self.query_proj: nn.Linear = nn.Linear(d_model, d_model) |
| self.cross_attn: nn.MultiheadAttention = nn.MultiheadAttention( |
| d_model, num_heads, batch_first=True, |
| ) |
| self.layer_norm: nn.LayerNorm = nn.LayerNorm(d_model) |
|
|
| def forward( |
| self, |
| kind_hidden: torch.Tensor, |
| all_hidden: torch.Tensor, |
| ) -> torch.Tensor: |
| query: torch.Tensor = self.query_proj(kind_hidden) |
| doc_emb, _ = self.cross_attn(query, all_hidden, all_hidden) |
| doc_emb = self.layer_norm(doc_emb) |
| return doc_emb.squeeze(1) |
|
|
|
|
| def supervised_contrastive_loss( |
| embeddings: torch.Tensor, |
| labels: torch.Tensor, |
| temperature: float = 0.1, |
| ) -> torch.Tensor: |
| embeddings = F.normalize(embeddings, dim=1) |
| batch_size: int = embeddings.shape[0] |
|
|
| sim: torch.Tensor = embeddings @ embeddings.T / temperature |
|
|
| label_mask: torch.Tensor = labels.unsqueeze(0) == labels.unsqueeze(1) |
| self_mask: torch.Tensor = torch.eye(batch_size, dtype=torch.bool, device=embeddings.device) |
| label_mask = label_mask & ~self_mask |
|
|
| sim_max, _ = sim.max(dim=1, keepdim=True) |
| sim = sim - sim_max.detach() |
|
|
| exp_sim: torch.Tensor = torch.exp(sim) * (~self_mask).float() |
| log_prob: torch.Tensor = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-8) |
|
|
| pos_count: torch.Tensor = label_mask.float().sum(dim=1).clamp(min=1) |
| loss: torch.Tensor = -(label_mask.float() * log_prob).sum(dim=1) / pos_count |
|
|
| return loss.mean() |
|
|