| """Exact tensor-product role binding with a fixed orthonormal role basis.""" |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import Sequence |
|
|
| import torch |
| from torch import nn |
|
|
|
|
| DEFAULT_ROLE_NAMES = ( |
| "ARG0", |
| "ARG1", |
| "ARG2", |
| "TIME", |
| "LOCATION", |
| "OTHER", |
| "NULL", |
| ) |
|
|
|
|
| class OrthonormalRoleBinder(nn.Module): |
| """Bind filler vectors to canonical role vectors and unbind them exactly. |
| |
| The memory matrix is ``sum_r filler[r] outer role[r]``. Canonical one-hot |
| role vectors avoid numerical drift and make the Phase 0 contract exact. |
| A supplied orthonormal basis can be used for controlled permutation tests. |
| """ |
|
|
| def __init__( |
| self, |
| filler_dim: int, |
| role_names: Sequence[str] = DEFAULT_ROLE_NAMES, |
| *, |
| role_dim: int | None = None, |
| basis: torch.Tensor | None = None, |
| ) -> None: |
| super().__init__() |
| if filler_dim <= 0: |
| raise ValueError("filler_dim must be positive") |
| names = tuple(str(name) for name in role_names) |
| if not names or len(set(names)) != len(names): |
| raise ValueError("role_names must be non-empty and unique") |
| role_dim = len(names) if role_dim is None else int(role_dim) |
| if role_dim < len(names): |
| raise ValueError("role_dim must be at least the number of roles") |
| if basis is None: |
| basis = torch.eye(role_dim, dtype=torch.float32)[: len(names)] |
| else: |
| basis = basis.detach().to(dtype=torch.float32) |
| if basis.shape != (len(names), role_dim): |
| raise ValueError( |
| f"basis must have shape {(len(names), role_dim)}, got {tuple(basis.shape)}" |
| ) |
| gram = basis @ basis.transpose(0, 1) |
| identity = torch.eye(len(names), dtype=gram.dtype, device=gram.device) |
| if not torch.allclose(gram, identity, atol=1e-6, rtol=1e-6): |
| raise ValueError("role basis must be orthonormal") |
| self.filler_dim = int(filler_dim) |
| self.role_names = names |
| self.role_dim = role_dim |
| self.register_buffer("basis", basis.contiguous(), persistent=True) |
|
|
| @property |
| def role_count(self) -> int: |
| return len(self.role_names) |
|
|
| def role_id(self, name: str) -> int: |
| try: |
| return self.role_names.index(name) |
| except ValueError as exc: |
| raise KeyError(f"unknown role {name!r}") from exc |
|
|
| def role_vector(self, roles: int | str | torch.Tensor) -> torch.Tensor: |
| if isinstance(roles, str): |
| roles = self.role_id(roles) |
| if isinstance(roles, int): |
| if not 0 <= roles < self.role_count: |
| raise IndexError("role index out of range") |
| return self.basis[roles] |
| role_ids = roles.to(device=self.basis.device, dtype=torch.long) |
| if bool(((role_ids < 0) | (role_ids >= self.role_count)).any()): |
| raise IndexError("role index out of range") |
| return self.basis[role_ids] |
|
|
| def bind(self, fillers: torch.Tensor, *, role_mask: torch.Tensor | None = None) -> torch.Tensor: |
| """Return a TPR matrix from ``[..., roles, filler_dim]`` fillers.""" |
| if fillers.shape[-2:] != (self.role_count, self.filler_dim): |
| raise ValueError( |
| "fillers must end with " |
| f"[{self.role_count}, {self.filler_dim}], got {tuple(fillers.shape)}" |
| ) |
| values = fillers |
| if role_mask is not None: |
| expected = fillers.shape[:-1] |
| if role_mask.shape != expected: |
| raise ValueError(f"role_mask must have shape {expected}, got {tuple(role_mask.shape)}") |
| values = values * role_mask.to(device=values.device, dtype=values.dtype).unsqueeze(-1) |
| basis = self.basis.to(device=values.device, dtype=values.dtype) |
| return torch.einsum("...rf,rd->...fd", values, basis) |
|
|
| def unbind(self, memory: torch.Tensor, roles: int | str | torch.Tensor) -> torch.Tensor: |
| """Apply the requested role as an algebraic unbinding operator.""" |
| if memory.shape[-2:] != (self.filler_dim, self.role_dim): |
| raise ValueError( |
| "memory must end with " |
| f"[{self.filler_dim}, {self.role_dim}], got {tuple(memory.shape)}" |
| ) |
| role_vector = self.role_vector(roles).to(device=memory.device, dtype=memory.dtype) |
| if role_vector.ndim == 1: |
| return torch.einsum("...fd,d->...f", memory, role_vector) |
| if role_vector.shape[:-1] != memory.shape[:-2]: |
| raise ValueError( |
| "tensor role IDs must match memory batch dimensions: " |
| f"{tuple(role_vector.shape[:-1])} != {tuple(memory.shape[:-2])}" |
| ) |
| return torch.einsum("...fd,...d->...f", memory, role_vector) |
|
|
| def unbind_all(self, memory: torch.Tensor) -> torch.Tensor: |
| if memory.shape[-2:] != (self.filler_dim, self.role_dim): |
| raise ValueError("memory has incompatible trailing dimensions") |
| basis = self.basis.to(device=memory.device, dtype=memory.dtype) |
| return torch.einsum("...fd,rd->...rf", memory, basis) |
|
|
| def permuted(self, permutation: torch.Tensor) -> "OrthonormalRoleBinder": |
| """Return a binder with globally relabeled role basis vectors.""" |
| permutation = permutation.to(dtype=torch.long, device=self.basis.device) |
| if permutation.shape != (self.role_count,): |
| raise ValueError(f"permutation must have shape ({self.role_count},)") |
| if sorted(permutation.tolist()) != list(range(self.role_count)): |
| raise ValueError("permutation must contain each role exactly once") |
| return OrthonormalRoleBinder( |
| self.filler_dim, |
| self.role_names, |
| role_dim=self.role_dim, |
| basis=self.basis[permutation].cpu(), |
| ) |
|
|
|
|
| __all__ = ["DEFAULT_ROLE_NAMES", "OrthonormalRoleBinder"] |
|
|