"""Relation head: prune to top-K spans, score every pair, cross-entropy against gold. Port of `radgraph.dygie.models.relation.RelationExtractor`. Two deliberate simplifications made possible by batch_size=1 (see dataset.py): 1. No span mask / no ignore_index: every one of the K pruned spans is a real span (there's no cross-document padding to mask out), so every (i, j) pair among them is a legitimate loss target -- either a real relation or "null". 2. The scorer outputs `n_labels - 1` channels (real labels only) with a null column prepended, exactly like ner_head.py already does. v1's RelationExtractor scores the *full* `n_labels` (which already includes the null vocab slot at index 0) and *also* prepends a dummy null column, leaving one dead, never-targeted output channel that gets trained toward nothing and just gets suppressed as a side effect of softmax competition. Sizing it like NER removes that dead channel and, as a bonus, means predicted/gold label ids line up with vocabulary indices directly -- no "+1 shift so null is class 0" dance. `transformer_params` (medbit_transformer arm, see notes/v2_plan.md item 1) swaps the pairwise `relation_feedforwards` scorer for a TransformerBlock applied to the pruner's already-pruned top-K spans, *before* pair formation: the K surviving spans attend to each other, then pairs are built from the now-contextualized embeddings and scored with a bare Linear (the transformer's own internal FFN sublayer already did the nonlinear work `relation_feedforwards` used to do). The pruner's own mention scorer stays a plain FeedForward -- it's a preliminary filter picking which K spans survive, not the classifier itself. `use_between_context` (medbit_relations_pooling arm, see notes/v2_plan.md item 2) adds a 4th block to the pair representation: the mean-pooled *raw* word embeddings strictly between the two spans (not the pooled span embeddings -- those don't preserve individual word positions). Computed via a prefix sum over `word_embeddings` (`_between_context`) so all K^2 pairs are done in one vectorized pass instead of a per-pair loop. Overlapping/adjacent/nested spans have no words between them -> zero vector for that pair. Orthogonal to `transformer_params`: the arm forks from the plain medbit baseline, not medbit_transformer, so both flags are never on together in practice, but the code composes correctly either way (the between-block just adds `word_emb_dim` to whichever pair representation -- FeedForward or transformer -- is in use). `relation_feedforward_params` (medbit_relations_pooling arm only, defaults to `feedforward_params` for every other arm) sizes just the relation pair scorer's hidden layers, kept separate from the pruner's `mention_ff` -- the pruner still consumes plain `span_emb_dim` (unaffected by `use_between_context`), so only the layers that actually see the enlarged pair embedding get widened, mirroring the "pruner untouched" principle already applied to `transformer_params`. """ import math from typing import Dict, List, Tuple import torch from torch import nn from .document import PredictedRelation, Sentence from .feedforward import FeedForward from .metrics import RelationMetrics from .pruner import Pruner from .transformer_block import TransformerBlock from .vocab import Vocabulary class RelationHead(nn.Module): def __init__(self, vocab: Vocabulary, span_emb_dim: int, word_emb_dim: int, feedforward_params: dict, spans_per_word: float, transformer_params: dict = None, use_between_context: bool = False, relation_feedforward_params: dict = None): super().__init__() self.vocab = vocab self.spans_per_word = spans_per_word self.namespaces = [ns for ns in vocab.get_namespaces() if ns.endswith("relation_labels")] self.n_labels = {ns: vocab.get_vocab_size(ns) for ns in self.namespaces} self.use_transformer = transformer_params is not None self.use_between_context = use_between_context between_dim = word_emb_dim if use_between_context else 0 relation_feedforward_params = relation_feedforward_params or feedforward_params self.pruners = nn.ModuleDict() self.span_transformers = nn.ModuleDict() self.relation_feedforwards = nn.ModuleDict() self.relation_scorers = nn.ModuleDict() self.metrics: Dict[str, RelationMetrics] = {} for ns in self.namespaces: mention_ff = FeedForward(span_emb_dim, feedforward_params["hidden_dims"], feedforward_params["dropout"]) mention_scorer = nn.Sequential(mention_ff, nn.Linear(mention_ff.output_dim, 1)) self.pruners[ns] = Pruner(mention_scorer) if self.use_transformer: block = TransformerBlock(span_emb_dim, **transformer_params) self.span_transformers[ns] = block self.relation_scorers[ns] = nn.Linear(3 * block.output_dim + between_dim, self.n_labels[ns] - 1) else: relation_ff = FeedForward(3 * span_emb_dim + between_dim, relation_feedforward_params["hidden_dims"], relation_feedforward_params["dropout"]) self.relation_feedforwards[ns] = relation_ff self.relation_scorers[ns] = nn.Linear(relation_ff.output_dim, self.n_labels[ns] - 1) self.metrics[ns] = RelationMetrics() self._loss = nn.CrossEntropyLoss(reduction="sum") @staticmethod def _pair_embeddings(top_embeddings: torch.Tensor) -> torch.Tensor: k = top_embeddings.size(0) e1 = top_embeddings.unsqueeze(1).expand(k, k, -1) e2 = top_embeddings.unsqueeze(0).expand(k, k, -1) return torch.cat([e1, e2, e1 * e2], dim=-1) @staticmethod def _between_context(word_embeddings: torch.Tensor, top_spans: List[Tuple[int, int]]) -> torch.Tensor: """Mean of the raw word embeddings strictly between two spans, one block per (i, j) pair -- vectorized over the whole K x K grid via a prefix sum (no per-pair loop; K^2 pairs can run into the tens of thousands per doc). `low`/`high` don't depend on pair order (i, j) vs (j, i), so the between-region -- and this block -- is symmetric by construction. Overlapping/adjacent/nested spans (count <= 0) get a zero vector.""" device, dtype = word_embeddings.device, word_embeddings.dtype starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long, device=device) ends = torch.tensor([s[1] for s in top_spans], dtype=torch.long, device=device) zero_row = word_embeddings.new_zeros(1, word_embeddings.size(-1)) prefix = torch.cat([zero_row, word_embeddings.cumsum(dim=0)], dim=0) # (num_words + 1, dim) low = torch.minimum(ends.unsqueeze(1), ends.unsqueeze(0)) # end of whichever span is first high = torch.maximum(starts.unsqueeze(1), starts.unsqueeze(0)) # start of whichever span is second count = (high - low - 1).clamp(min=0) # words strictly between seg_sum = prefix[high] - prefix[low + 1] # (K, K, dim) mean = seg_sum / count.clamp(min=1).unsqueeze(-1).to(dtype) return torch.where((count > 0).unsqueeze(-1), mean, seg_sum.new_zeros(())) def forward(self, dataset: str, spans: List[Tuple[int, int]], span_embeddings: torch.Tensor, num_words: int, word_embeddings: torch.Tensor = None, relation_gold: Dict[Tuple[int, int], int] = None, sentence: Sentence = None) -> dict: ns = f"{dataset}__relation_labels" num_to_keep = math.ceil(num_words * self.spans_per_word) top_embeddings, top_indices, top_scores = self.pruners[ns](span_embeddings, num_to_keep) top_indices_list = top_indices.tolist() top_spans = [spans[i] for i in top_indices_list] if self.use_transformer: starts = torch.tensor([s[0] for s in top_spans], dtype=torch.long, device=span_embeddings.device) top_embeddings = self.span_transformers[ns](top_embeddings, starts) pair_embeddings = self._pair_embeddings(top_embeddings) if self.use_between_context: between = self._between_context(word_embeddings, top_spans) pair_embeddings = torch.cat([pair_embeddings, between], dim=-1) k = top_embeddings.size(0) if self.use_transformer: scores = self.relation_scorers[ns](pair_embeddings.view(k * k, -1)).view(k, k, -1) else: projected = self.relation_feedforwards[ns](pair_embeddings.view(k * k, -1)) scores = self.relation_scorers[ns](projected).view(k, k, -1) scores = scores + top_scores.view(k, 1, 1) + top_scores.view(1, k, 1) dummy = scores.new_zeros(k, k, 1) scores = torch.cat([dummy, scores], dim=-1) # (K, K, n_labels), null at index 0 predicted_dict, predictions = self.decode(ns, scores.detach(), top_spans, sentence) output = {"namespace": ns, "scores": scores, "top_spans": top_spans, "predictions": predictions} if relation_gold is not None: pos_of = {orig: pos for pos, orig in enumerate(top_indices_list)} gold_matrix = scores.new_zeros(k, k, dtype=torch.long) for (oi, oj), label_id in relation_gold.items(): pi, pj = pos_of.get(oi), pos_of.get(oj) if pi is not None and pj is not None: gold_matrix[pi, pj] = label_id output["loss"] = self._loss(scores.view(k * k, -1), gold_matrix.view(-1)) self.metrics[ns](predicted_dict, sentence.relation_dict or {}) return output def decode(self, namespace: str, scores: torch.Tensor, top_spans: List[Tuple[int, int]], sentence: Sentence): raw, predicted = scores.max(dim=-1) soft, _ = torch.softmax(scores, dim=-1).max(dim=-1) res_dict, predictions = {}, [] for i, j in (predicted != 0).nonzero(as_tuple=False).tolist(): label = self.vocab.get_token_from_index(int(predicted[i, j]), namespace) span1, span2 = top_spans[i], top_spans[j] res_dict[(span1, span2)] = label entry = [span1[0], span1[1], span2[0], span2[1], label, float(raw[i, j]), float(soft[i, j])] predictions.append(PredictedRelation(entry, sentence, sentence_offsets=True)) return res_dict, predictions def get_metrics(self, reset: bool = False) -> Dict[str, float]: res = {} for ns, metric in self.metrics.items(): prefix = ns.replace("_labels", "") per_class = metric.get_per_class() # {relation label -> F1}, read BEFORE the reset below for label, class_f1 in per_class.items(): res[f"relation_perclass/{label}"] = class_f1 res["relation_macro_f1"] = sum(per_class.values()) / len(per_class) if per_class else 0.0 precision, recall, f1 = metric.get_metric(reset) res[f"{prefix}_precision"] = precision res[f"{prefix}_recall"] = recall res[f"{prefix}_f1"] = f1 for name in ("precision", "recall", "f1"): values = [v for k, v in res.items() if k.endswith(f"_{name}") and "perclass/" not in k and "_macro_" not in k] res[f"MEAN__relation_{name}"] = sum(values) / len(values) if values else 0.0 return res