| """
|
| caff/model.py
|
| =============
|
| Full CAFFModel β assembles encoder, CSV, per-hop scorers into a
|
| single trainable module.
|
|
|
| Per paper Β§8.4, learnable capacity must be < 12M params (~3.5%
|
| of the 340M-param frozen backbone). This is asserted at __init__.
|
|
|
| Architecture summary (paper Algorithm 1):
|
|
|
| for β = 1 to L:
|
| C_β β BFS(G, S(Q), β) # Eq. 1
|
| C_β β FreqCap(C_β, K_r) # Eq. 13
|
| g_β β Ο(P_β z_{β-1} + b^g_β) # Eq. 16
|
| Ξ_β β U_β diag(g_β) V_β^T # Eq. 17
|
| W^ctx_β β W_0 + A_β B_β^T + Ξ_β # Eq. 18
|
| for (h, r, t) β C_β:
|
| s β Ο(q^T W^ctx_β e_r + v^T(q β e_r) + Ξ²_β) # Eq. 19
|
| if s β₯ ΞΈ: S_β β S_β βͺ {(h,r,t)}
|
| z_β β CSV(S_β) # Eq. 14
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import logging
|
| from dataclasses import dataclass
|
|
|
| import torch
|
| import torch.nn as nn
|
|
|
| from .config import AblationFlags, CAFFConfig
|
| from .csv import CSV
|
| from .encoders import RelationEmbeddingCache
|
| from .scorer import HopScorer
|
|
|
| logger = logging.getLogger(__name__)
|
|
|
|
|
| @dataclass
|
| class HopOutput:
|
| """Output of scoring a single hop."""
|
| logits: torch.Tensor
|
| scores: torch.Tensor
|
| retained_mask: torch.Tensor
|
| z_next: torch.Tensor
|
|
|
|
|
| class CAFFModel(nn.Module):
|
| """Full CAFF filtering model (paper Β§6).
|
|
|
| Parameters
|
| ----------
|
| config : CAFFConfig
|
| Hyperparameters from paper Β§8.4.
|
| relation_cache : RelationEmbeddingCache
|
| Pre-encoded frozen relation embeddings.
|
| ablation : AblationFlags
|
| Component toggles for ablation study (paper Β§10.1).
|
| """
|
|
|
| def __init__(
|
| self,
|
| config: CAFFConfig,
|
| relation_cache: RelationEmbeddingCache,
|
| ablation: AblationFlags | None = None,
|
| ) -> None:
|
| super().__init__()
|
| self.config = config
|
| self.ablation = ablation or AblationFlags()
|
| self.relation_cache = relation_cache
|
|
|
| d = config.d
|
| rho = config.rho
|
| L = config.L
|
|
|
|
|
| self.W0 = nn.Parameter(torch.empty(d, d))
|
| nn.init.xavier_uniform_(self.W0)
|
| self.v = nn.Parameter(torch.zeros(d))
|
| nn.init.normal_(self.v, mean=0.0, std=0.01)
|
|
|
|
|
| self.hop_scorers = nn.ModuleList([
|
| HopScorer(
|
| d=d,
|
| rho=rho,
|
| gate_activation=self.ablation.gate_activation,
|
| use_dbm=self.ablation.use_dbm,
|
| )
|
| for _ in range(L)
|
| ])
|
|
|
|
|
| self.csv = CSV(relation_cache, pool=self.ablation.csv_pool)
|
|
|
|
|
| n_trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| logger.info(
|
| f"CAFFModel initialized: {n_trainable / 1e6:.2f}M trainable params "
|
| f"(paper budget: <12M)"
|
| )
|
| if n_trainable > 12_000_000:
|
| logger.warning(
|
| f"Trainable param count {n_trainable / 1e6:.2f}M exceeds "
|
| f"paper's <12M budget. Check d, rho, L."
|
| )
|
|
|
| @property
|
| def device(self) -> torch.device:
|
| return next(self.parameters()).device
|
|
|
| def get_hop_W_ctx(
|
| self,
|
| hop_idx: int,
|
| z_prev: torch.Tensor,
|
| ) -> torch.Tensor:
|
| """Compute W^ctx_β for a batch of items at hop β (Eq. 18).
|
|
|
| Parameters
|
| ----------
|
| hop_idx : int in {0, 1, ..., L-1}
|
| z_prev : Tensor of shape (B, d)
|
| CSV from hop β-1 (or zeros at hop 0 / when set is empty).
|
| If ablation.use_csv is False, z_prev is forcibly zeroed.
|
|
|
| Returns
|
| -------
|
| W_ctx : Tensor of shape (B, d, d)
|
| """
|
| if not self.ablation.use_csv:
|
|
|
| z_prev = torch.zeros_like(z_prev)
|
|
|
| return self.hop_scorers[hop_idx].compute_W_ctx(self.W0, z_prev)
|
|
|
| def score_hop_candidates(
|
| self,
|
| hop_idx: int,
|
| W_ctx_single: torch.Tensor,
|
| q: torch.Tensor,
|
| relation_names: list[str],
|
| return_logits: bool = False,
|
| ) -> torch.Tensor:
|
| """Score all candidates of a single (query, hop) β Eq. 19.
|
|
|
| Smart Engineering S3: W_ctx_single is computed ONCE per
|
| (query, hop), then this method scores all N_β candidates
|
| against it in a single matmul.
|
|
|
| Parameters
|
| ----------
|
| hop_idx : int
|
| W_ctx_single : Tensor of shape (d, d)
|
| Pre-computed W^ctx for one item.
|
| q : Tensor of shape (d,)
|
| β2-normalized query embedding.
|
| relation_names : list of str, length N
|
| Relation names of the candidate triples.
|
| return_logits : bool
|
| If True, return pre-sigmoid logits (for BCEWithLogitsLoss).
|
|
|
| Returns
|
| -------
|
| Tensor of shape (N,).
|
| """
|
| E_r = self.relation_cache.get_batch(relation_names)
|
|
|
| scorer = self.hop_scorers[hop_idx]
|
| if return_logits:
|
| return scorer.score_logits(W_ctx_single, self.v, q, E_r)
|
| return scorer.score_candidates(W_ctx_single, self.v, q, E_r)
|
|
|
| @torch.no_grad()
|
| def filter_query(
|
| self,
|
| q: torch.Tensor,
|
| candidate_sets: list[list[tuple[str, str, str]]],
|
| ) -> list[list[tuple[str, str, str]]]:
|
| """Apply CAFF filtering to a single query β Algorithm 1 (paper).
|
|
|
| Parameters
|
| ----------
|
| q : Tensor of shape (d,)
|
| β2-normalized query embedding.
|
| candidate_sets : list of length L
|
| candidate_sets[β] is the BFS-extracted (and FreqCapped)
|
| list of (h, r, t) tuples at hop β+1.
|
|
|
| Returns
|
| -------
|
| retained_sets : list of length L
|
| retained_sets[β] is S_{β+1} = {(h,r,t) : s_β(...) β₯ ΞΈ}.
|
| """
|
| L = self.config.L
|
| theta = self.config.theta
|
| d = self.config.d
|
|
|
|
|
| z_prev = torch.zeros(1, d, device=self.device)
|
| retained_sets: list[list[tuple[str, str, str]]] = []
|
|
|
| for hop_idx in range(L):
|
| candidates = candidate_sets[hop_idx]
|
| if len(candidates) == 0:
|
| retained_sets.append([])
|
|
|
| continue
|
|
|
|
|
| W_ctx = self.get_hop_W_ctx(hop_idx, z_prev).squeeze(0)
|
|
|
|
|
| relation_names = [r for (_, r, _) in candidates]
|
| scores = self.score_hop_candidates(
|
| hop_idx, W_ctx, q.squeeze(0) if q.dim() > 1 else q, relation_names
|
| )
|
|
|
|
|
| retained = [
|
| triple for triple, s in zip(candidates, scores.tolist())
|
| if s >= theta
|
| ]
|
| retained_sets.append(retained)
|
|
|
|
|
| retained_relations = [r for (_, r, _) in retained]
|
| z_prev = self.csv([retained_relations])
|
|
|
| return retained_sets |