| """Sparse typed relation operators for predicate-hypergraph traversal.""" |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import Mapping, Sequence |
|
|
| import torch |
| from torch import nn |
|
|
|
|
| class SparseRelationOperators(nn.Module): |
| """A fixed set of sparse source-to-target adjacency operators. |
| |
| Every edge is ``(relation, source, target, weight)``. A traversal applies |
| ``A_relation.T @ state``. Relation names choose distinct operators rather |
| than modulating a shared learned message function. |
| """ |
|
|
| def __init__( |
| self, |
| node_count: int, |
| relation_names: Sequence[str], |
| edges: Mapping[str, Sequence[tuple[int, int] | tuple[int, int, float]]], |
| ) -> None: |
| super().__init__() |
| if node_count <= 0: |
| raise ValueError("node_count must be positive") |
| names = tuple(str(name) for name in relation_names) |
| if not names or len(set(names)) != len(names): |
| raise ValueError("relation_names must be non-empty and unique") |
| relation_ids: list[int] = [] |
| sources: list[int] = [] |
| targets: list[int] = [] |
| weights: list[float] = [] |
| for relation_id, name in enumerate(names): |
| for edge in edges.get(name, ()): |
| if len(edge) == 2: |
| source, target = edge |
| weight = 1.0 |
| elif len(edge) == 3: |
| source, target, weight = edge |
| else: |
| raise ValueError("edges must be (source, target) or (source, target, weight)") |
| source = int(source) |
| target = int(target) |
| if not 0 <= source < node_count or not 0 <= target < node_count: |
| raise IndexError(f"edge ({source}, {target}) is outside {node_count} nodes") |
| if float(weight) < 0: |
| raise ValueError("relation weights must be non-negative") |
| relation_ids.append(relation_id) |
| sources.append(source) |
| targets.append(target) |
| weights.append(float(weight)) |
| self.node_count = int(node_count) |
| self.relation_names = names |
| self.register_buffer("edge_relations", torch.tensor(relation_ids, dtype=torch.long)) |
| self.register_buffer("edge_sources", torch.tensor(sources, dtype=torch.long)) |
| self.register_buffer("edge_targets", torch.tensor(targets, dtype=torch.long)) |
| self.register_buffer("edge_weights", torch.tensor(weights, dtype=torch.float32)) |
|
|
| @property |
| def relation_count(self) -> int: |
| return len(self.relation_names) |
|
|
| def relation_id(self, relation: str | int) -> int: |
| if isinstance(relation, int): |
| if not 0 <= relation < self.relation_count: |
| raise IndexError("relation index out of range") |
| return relation |
| try: |
| return self.relation_names.index(relation) |
| except ValueError as exc: |
| raise KeyError(f"unknown relation {relation!r}") from exc |
|
|
| def sparse_operator(self, relation: str | int, *, transpose: bool = False) -> torch.Tensor: |
| relation_id = self.relation_id(relation) |
| selected = self.edge_relations == relation_id |
| source = self.edge_sources[selected] |
| target = self.edge_targets[selected] |
| if transpose: |
| source, target = target, source |
| indices = torch.stack([source, target]) if source.numel() else torch.empty( |
| 2, 0, dtype=torch.long, device=self.edge_sources.device |
| ) |
| values = self.edge_weights[selected] |
| return torch.sparse_coo_tensor( |
| indices, |
| values, |
| (self.node_count, self.node_count), |
| device=values.device, |
| ).coalesce() |
|
|
| def step( |
| self, |
| state: torch.Tensor, |
| relation: str | int, |
| *, |
| semiring: str = "sum_product", |
| ) -> torch.Tensor: |
| if state.shape[-1] != self.node_count: |
| raise ValueError(f"state must end with {self.node_count} nodes") |
| relation_id = self.relation_id(relation) |
| selected = self.edge_relations == relation_id |
| source = self.edge_sources[selected] |
| target = self.edge_targets[selected] |
| weight = self.edge_weights[selected].to(dtype=state.dtype) |
| flat = state.reshape(-1, self.node_count) |
| if semiring == "sum_product": |
| out = flat.new_zeros(flat.shape) |
| if source.numel(): |
| contribution = flat[:, source] * weight.unsqueeze(0) |
| out.scatter_add_(1, target.unsqueeze(0).expand(flat.shape[0], -1), contribution) |
| elif semiring == "max_product": |
| out = flat.new_zeros(flat.shape) |
| if source.numel(): |
| contribution = flat[:, source] * weight.unsqueeze(0) |
| out.scatter_reduce_( |
| 1, |
| target.unsqueeze(0).expand(flat.shape[0], -1), |
| contribution, |
| reduce="amax", |
| include_self=True, |
| ) |
| elif semiring == "logsumexp": |
| out = flat.new_full(flat.shape, -torch.inf) |
| if source.numel(): |
| log_weight = weight.clamp_min(torch.finfo(weight.dtype).tiny).log() |
| contribution = flat[:, source] + log_weight.unsqueeze(0) |
| index = target.unsqueeze(0).expand(flat.shape[0], -1) |
| maximum = flat.new_full(flat.shape, -torch.inf) |
| maximum.scatter_reduce_(1, index, contribution, reduce="amax", include_self=True) |
| gathered_max = maximum.gather(1, index) |
| shifted = torch.where( |
| torch.isfinite(gathered_max), |
| (contribution - gathered_max).exp(), |
| torch.zeros_like(contribution), |
| ) |
| total = flat.new_zeros(flat.shape) |
| total.scatter_add_(1, index, shifted) |
| finite = torch.isfinite(maximum) & (total > 0) |
| out = torch.where(finite, maximum + total.clamp_min(torch.finfo(total.dtype).tiny).log(), out) |
| else: |
| raise ValueError("semiring must be sum_product, max_product, or logsumexp") |
| return out.reshape(state.shape) |
|
|
|
|
| __all__ = ["SparseRelationOperators"] |
|
|