Download code/graph/typed_readout.py from frontal-labs/graph-reasoner-typed-compositional: direct link, hf CLI and curl.
- Browser
- Download file 27.7 kB
-
https://huggingface.co/frontal-labs/graph-reasoner-typed-compositional/resolve/main/code/graph/typed_readout.py
- Command line
-
hf download hf://frontal-labs/graph-reasoner-typed-compositional/code/graph/typed_readout.py
-
curl -L -o typed_readout.py https://huggingface.co/frontal-labs/graph-reasoner-typed-compositional/resolve/main/code/graph/typed_readout.py
27.7 kB
| """Typed path readout: answer from node types and structure, never relation names. | |
| The unseen-ontology holdout renames every relation to an opaque label, so a readout | |
| that matches the question to relation names has nothing to match. Node types keep | |
| their names, and an oracle that follows only node types reaches 0.99 exact match on | |
| that split, so the information is in the input. This readout enumerates the paths | |
| from the start entities and scores each path by its node types alone. | |
| Two complementary scorers, blended with a weight chosen on internal holdouts: | |
| * :class:`TypeSequenceScorer` reads the whole question and predicts the hop count and | |
| the type at each hop. Robust to new wording of known question types, but it learns | |
| whole type sequences, so it fails on new *combinations* (0.01 on held-out intents). | |
| * :class:`TypeBagScorer` reads the question token by token: each token names a type | |
| or nothing, and a path is scored by how likely its multiset of types is under those | |
| token probabilities, plus the answer type. Order is left to the graph. Because a | |
| word such as "supplier" names the same type in every intent, this composes (0.65 on | |
| held-out intents), at some cost on new wording. | |
| Types are scored by name embedding, so type names never seen in training work as | |
| long as the name carries meaning. A small classifier over the readout's own scores | |
| decides whether to answer or abstain, and confidence is isotonic-calibrated per | |
| predicted hop count. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| from collections import Counter | |
| from collections.abc import Callable, Sequence | |
| from dataclasses import dataclass, replace | |
| from pathlib import Path | |
| from typing import Any | |
| import torch | |
| from torch import Tensor, nn | |
| from torch.nn import functional | |
| from graph.graph import GraphStore | |
| from graph.training.calibration import IsotonicCalibrator | |
| from graph.types import ( | |
| EntityAnswer, | |
| GraphPath, | |
| GraphQuery, | |
| ReasoningResult, | |
| ReasoningStatus, | |
| ) | |
| MAX_HOPS = 4 | |
| #: Largest per-type count the bag scorer models along one path. | |
| MAX_COUNT = 4 | |
| DEFAULT_ENCODER = "sentence-transformers/all-mpnet-base-v2" | |
| #: Features read by the abstain head: v1 has 5, v2 adds novelty and agreement, v3 adds | |
| #: the gate's P(sequence scorer is right). | |
| FEATURES_V1 = 5 | |
| FEATURES_V2 = 7 | |
| FEATURES_V3 = 8 | |
| NUM_FEATURES = FEATURES_V1 | |
| #: Index of the template-novelty feature. It predicts errors on held-out *intents* but | |
| #: not on new wording of known intents, so the abstain head must not read it: with it, | |
| #: the v3 head abstained on 71% of answerable unseen-ontology questions. | |
| NOVELTY_FEATURE = 5 | |
| #: Inputs to the gate; see :func:`gate_features`. Gates trained with the first 5 only | |
| #: still load: :meth:`TypedReadout.gate_probability` slices to the gate's input size. | |
| GATE_FEATURES = 7 | |
| #: Hard cap on enumerated paths per query, so a dense graph cannot stall inference. | |
| MAX_PATHS = 200_000 | |
| _ENTITY = re.compile(r"\be\d+_\d+\b") | |
| Encoder = Callable[[Sequence[str]], Tensor] | |
| TokenEncoder = Callable[[Sequence[str]], list[Tensor]] | |
| class TypeSequenceScorer(nn.Module): | |
| """Question embedding -> P(hop count) and P(node type at hop i | hop count).""" | |
| def __init__(self, dim: int, max_hops: int = MAX_HOPS) -> None: | |
| super().__init__() | |
| self.max_hops = max_hops | |
| self.hop = nn.Sequential(nn.Linear(dim, 256), nn.GELU(), nn.Linear(256, max_hops)) | |
| self.slot = nn.Parameter(torch.randn(max_hops, max_hops, 64) * 0.1) | |
| self.mix = nn.Sequential(nn.Linear(dim + 64, 512), nn.GELU(), nn.Linear(512, dim)) | |
| self.scale = nn.Parameter(torch.tensor(10.0)) | |
| def hop_logp(self, question: Tensor) -> Tensor: | |
| return functional.log_softmax(self.hop(question), -1) | |
| def type_logits( | |
| self, question: Tensor, hops: int, position: int, type_embeddings: Tensor | |
| ) -> Tensor: | |
| """Logits over ``type_embeddings`` rows for the node at ``position``.""" | |
| slot = self.slot[hops - 1, position].expand(question.shape[0], -1) | |
| query = functional.normalize(self.mix(torch.cat([question, slot], -1)), dim=-1) | |
| return self.scale * (query @ type_embeddings.T) | |
| class TypeBagScorer(nn.Module): | |
| """Token-level type mentions -> likelihood of a path's type multiset.""" | |
| def __init__(self, dim: int, max_count: int = MAX_COUNT) -> None: | |
| super().__init__() | |
| self.max_count = max_count | |
| self.tok = nn.Sequential(nn.Linear(dim, dim), nn.GELU(), nn.Linear(dim, dim)) | |
| self.none = nn.Parameter(torch.tensor(2.0)) | |
| self.scale = nn.Parameter(torch.tensor(10.0)) | |
| self.ans = nn.Sequential(nn.Linear(dim, 512), nn.GELU(), nn.Linear(512, dim)) | |
| self.ascale = nn.Parameter(torch.tensor(10.0)) | |
| def token_probs(self, tokens: Tensor, type_embeddings: Tensor) -> Tensor: | |
| """Per token, P(token names type k); the remainder is P(names no type).""" | |
| z = functional.normalize(self.tok(tokens), dim=-1) | |
| logits = self.scale * (z @ type_embeddings.T) | |
| logits = torch.cat([logits, self.none.expand(logits.shape[0], 1)], -1) | |
| return torch.softmax(logits, -1)[:, :-1] | |
| def count_logp(self, tokens: Tensor, type_embeddings: Tensor) -> Tensor: | |
| """[types, max_count + 1]: log P(type k is named c times), Poisson-binomial.""" | |
| p = self.token_probs(tokens, type_embeddings).clamp(1e-6, 1 - 1e-6) | |
| k = p.shape[1] | |
| dist = torch.zeros(k, self.max_count + 1, device=p.device) | |
| dist[:, 0] = 1.0 | |
| for row in p: | |
| prob = row[:, None] | |
| shifted = torch.cat([torch.zeros(k, 1, device=p.device), dist[:, :-1]], 1) | |
| dist = dist * (1 - prob) + shifted * prob | |
| return torch.log(dist.clamp_min(1e-12)) | |
| def answer_logp(self, question: Tensor, type_embeddings: Tensor) -> Tensor: | |
| z = functional.normalize(self.ans(question), dim=-1) | |
| return functional.log_softmax(self.ascale * (z @ type_embeddings.T), -1) | |
| class GateHead(nn.Module): | |
| """P(the sequence scorer's answer is right rather than the bag scorer's).""" | |
| def __init__(self, features: int = GATE_FEATURES) -> None: | |
| super().__init__() | |
| self.net = nn.Sequential(nn.Linear(features, 16), nn.GELU(), nn.Linear(16, 1)) | |
| def forward(self, features: Tensor) -> Tensor: | |
| return self.net(features).squeeze(-1) | |
| class AbstainHead(nn.Module): | |
| """P(answering is correct) from the readout's own scores.""" | |
| def __init__(self, features: int = NUM_FEATURES) -> None: | |
| super().__init__() | |
| self.net = nn.Sequential(nn.Linear(features, 32), nn.GELU(), nn.Linear(32, 1)) | |
| def forward(self, features: Tensor) -> Tensor: | |
| return self.net(features).squeeze(-1) | |
| class ReadoutScore: | |
| """Everything the readout computes for one query before deciding.""" | |
| answers: frozenset[str] | |
| features: tuple[float, ...] | |
| best_paths: dict[str, GraphPath] | |
| endpoint_scores: dict[str, float] | |
| #: argmax of the hop-count head; known at inference, used to pick a calibrator. | |
| predicted_hops: int = 0 | |
| class TypedReadout: | |
| """Neuro-symbolic readout over a :class:`GraphStore`; see the module docstring.""" | |
| def __init__( | |
| self, | |
| scorer: TypeSequenceScorer, | |
| abstain: AbstainHead, | |
| encode: Encoder, | |
| *, | |
| feature_mean: Tensor, | |
| feature_std: Tensor, | |
| threshold: float, | |
| calibration: tuple[tuple[float, ...], tuple[float, ...]] | None = None, | |
| hop_calibration: dict[int, tuple[tuple[float, ...], tuple[float, ...]]] | None = None, | |
| bag: TypeBagScorer | None = None, | |
| encode_tokens: TokenEncoder | None = None, | |
| blend_weight: float = 1.0, | |
| template_bank: Tensor | None = None, | |
| gate: GateHead | None = None, | |
| gate_mean: Tensor | None = None, | |
| gate_std: Tensor | None = None, | |
| feature_mask: Sequence[int] | None = None, | |
| decision_source: str = "chosen", | |
| model_version: str = "typed-readout-v1", | |
| device: str | torch.device = "cpu", | |
| ) -> None: | |
| if bag is not None and encode_tokens is None: | |
| raise ValueError("a bag scorer needs a token encoder") | |
| self.device = torch.device(device) | |
| self.scorer = scorer.to(self.device).eval() | |
| self.bag = bag.to(self.device).eval() if bag is not None else None | |
| self.abstain = abstain.to(self.device).eval() | |
| self.encode = encode | |
| self.encode_tokens = encode_tokens | |
| self.blend_weight = float(blend_weight) | |
| self.template_bank = ( | |
| functional.normalize(template_bank.to(self.device).float(), dim=-1) | |
| if template_bank is not None | |
| else None | |
| ) | |
| self.gate = gate.to(self.device).eval() if gate is not None else None | |
| self.gate_mean = (gate_mean if gate_mean is not None else torch.zeros(GATE_FEATURES)).to(self.device) | |
| self.gate_std = ( | |
| (gate_std if gate_std is not None else torch.ones(GATE_FEATURES)).to(self.device).clamp_min(1e-6) | |
| ) | |
| #: Indices of score features the abstain head reads (None: all of them). | |
| self.feature_mask = tuple(feature_mask) if feature_mask is not None else None | |
| #: Which scores the answer/abstain decision reads: "chosen" (the scorer that | |
| #: produced the answer) or "sequence" (always the sequence scorer, whose scores | |
| #: the decision was calibrated on and which transfer under wording shift). | |
| if decision_source not in ("chosen", "sequence"): | |
| raise ValueError(f"unknown decision_source {decision_source!r}") | |
| self.decision_source = decision_source | |
| self.feature_mean = feature_mean.to(self.device) | |
| self.feature_std = feature_std.to(self.device).clamp_min(1e-6) | |
| self.threshold = float(threshold) | |
| self.calibration = calibration | |
| self.hop_calibration = dict(hop_calibration or {}) | |
| self.model_version = model_version | |
| self._type_cache: dict[str, Tensor] = {} | |
| def feature_count(self) -> int: | |
| if self.gate is not None: | |
| return FEATURES_V3 | |
| return FEATURES_V2 if self.template_bank is not None else FEATURES_V1 | |
| # -- construction ------------------------------------------------------- | |
| def from_checkpoint( | |
| cls, | |
| path: str | Path, | |
| *, | |
| device: str | torch.device = "cpu", | |
| encode: Encoder | None = None, | |
| encode_tokens: TokenEncoder | None = None, | |
| ) -> TypedReadout: | |
| bundle = torch.load(path, map_location="cpu", weights_only=True) | |
| encoder_name = bundle.get("encoder", DEFAULT_ENCODER) | |
| if encode is None: | |
| encode, default_tokens = sentence_encoders(encoder_name, device) | |
| encode_tokens = encode_tokens or default_tokens | |
| scorer = TypeSequenceScorer(bundle["dim"], bundle.get("max_hops", MAX_HOPS)) | |
| scorer.load_state_dict(bundle["scorer"]) | |
| bag = None | |
| if bundle.get("bag") is not None: | |
| bag = TypeBagScorer(bundle["dim"], bundle.get("max_count", MAX_COUNT)) | |
| bag.load_state_dict(bundle["bag"]) | |
| gate = None | |
| if bundle.get("gate") is not None: | |
| gate = GateHead(int(bundle["gate"]["net.0.weight"].shape[1])) | |
| gate.load_state_dict(bundle["gate"]) | |
| # The head's input size is read from its weights, not ``num_features``: an | |
| # internal readout saved before its gate existed recorded the wrong count. | |
| abstain = AbstainHead(int(bundle["abstain"]["net.0.weight"].shape[1])) | |
| abstain.load_state_dict(bundle["abstain"]) | |
| calibration = bundle.get("calibration") | |
| hop_calibration = { | |
| int(hops): (tuple(pair[0]), tuple(pair[1])) | |
| for hops, pair in (bundle.get("hop_calibration") or {}).items() | |
| } | |
| return cls( | |
| scorer, | |
| abstain, | |
| encode, | |
| feature_mean=bundle["feature_mean"], | |
| feature_std=bundle["feature_std"], | |
| threshold=bundle["threshold"], | |
| calibration=tuple(map(tuple, calibration)) if calibration else None, | |
| hop_calibration=hop_calibration, | |
| bag=bag, | |
| encode_tokens=encode_tokens, | |
| blend_weight=bundle.get("blend_weight", 1.0), | |
| template_bank=bundle.get("template_bank"), | |
| gate=gate, | |
| gate_mean=bundle.get("gate_mean"), | |
| gate_std=bundle.get("gate_std"), | |
| feature_mask=bundle.get("feature_mask"), | |
| decision_source=bundle.get("decision_source", "chosen"), | |
| model_version=bundle.get("model_version", "typed-readout-v1"), | |
| device=device, | |
| ) | |
| def state(self, *, dim: int, encoder: str) -> dict[str, Any]: | |
| """Serialisable bundle for :meth:`from_checkpoint`.""" | |
| return { | |
| "scorer": self.scorer.state_dict(), | |
| "bag": self.bag.state_dict() if self.bag is not None else None, | |
| "abstain": self.abstain.state_dict(), | |
| "feature_mean": self.feature_mean.cpu(), | |
| "feature_std": self.feature_std.cpu(), | |
| "threshold": self.threshold, | |
| "calibration": self.calibration, | |
| "hop_calibration": self.hop_calibration, | |
| "blend_weight": self.blend_weight, | |
| "template_bank": self.template_bank.half().cpu() | |
| if self.template_bank is not None | |
| else None, | |
| "gate": self.gate.state_dict() if self.gate is not None else None, | |
| "gate_mean": self.gate_mean.cpu() if self.gate is not None else None, | |
| "gate_std": self.gate_std.cpu() if self.gate is not None else None, | |
| "feature_mask": list(self.feature_mask) if self.feature_mask is not None else None, | |
| "decision_source": self.decision_source, | |
| "dim": dim, | |
| "max_hops": self.scorer.max_hops, | |
| "max_count": self.bag.max_count if self.bag is not None else MAX_COUNT, | |
| "num_features": int(self.abstain.net[0].in_features), | |
| "encoder": encoder, | |
| "model_version": self.model_version, | |
| } | |
| # -- scoring -------------------------------------------------------------- | |
| def _type_embeddings(self, names: Sequence[str]) -> Tensor: | |
| missing = [name for name in names if name not in self._type_cache] | |
| if missing: | |
| vectors = self.encode([name.replace("_", " ") for name in missing]) | |
| for name, vector in zip(missing, vectors, strict=True): | |
| self._type_cache[name] = vector.to(self.device).float() | |
| return torch.stack([self._type_cache[name] for name in names]) | |
| def encode_query(self, query: GraphQuery) -> tuple[Tensor, Tensor | None, Tensor | None]: | |
| """Sentence vector, token vectors (bag) and masked-template vector (novelty).""" | |
| text = question_text(query) | |
| texts = [text, template_text(text)] if self.template_bank is not None else [text] | |
| vectors = self.encode(texts) | |
| tokens = self.encode_tokens([text])[0] if self.bag is not None else None | |
| masked = vectors[1] if self.template_bank is not None else None | |
| return vectors[0], tokens, masked | |
| def dual( | |
| self, | |
| graph: GraphStore, | |
| query: GraphQuery, | |
| question: Tensor | None = None, | |
| tokens: Tensor | None = None, | |
| masked: Tensor | None = None, | |
| ) -> dict[str, Any]: | |
| """Per-endpoint best scores under each scorer, and the shared signals.""" | |
| if question is None: | |
| question, tokens, masked = self.encode_query(query) | |
| question = question.to(self.device).float().reshape(1, -1) | |
| type_names = sorted({name for name in graph.nodes.values() if name}) | |
| type_index = {name: i for i, name in enumerate(type_names)} | |
| type_embeddings = self._type_embeddings(type_names) | |
| hop_logp = self.scorer.hop_logp(question)[0] | |
| hop_table = hop_logp.tolist() | |
| use_bag = self.bag is not None and tokens is not None | |
| if use_bag: | |
| tokens = functional.normalize(tokens.to(self.device).float(), dim=-1) | |
| count_logp = self.bag.count_logp(tokens, type_embeddings).cpu() | |
| answer_logp = self.bag.answer_logp(question, type_embeddings)[0].cpu() | |
| # Every type contributes log P(count = 0) unless the path contains it, so | |
| # sum that once and adjust only the types on the path: O(hops) per path. | |
| zero_total = float(count_logp[:, 0].sum()) | |
| count_table = count_logp.tolist() | |
| starts = set(query.start_entity_ids) or set(query.start_entities) | |
| per_hop: dict[int, list[Tensor]] = {} | |
| tables: dict[str, tuple[dict[str, float], dict[str, GraphPath]]] = { | |
| "sequence": ({}, {}), "bag": ({}, {}), "blend": ({}, {}), | |
| } | |
| def keep(name: str, end: str, value: float, path: GraphPath) -> None: | |
| best, paths = tables[name] | |
| if value > best.get(end, -math.inf): | |
| best[end] = value | |
| paths[end] = path | |
| max_hops = min(query.max_hops, self.scorer.max_hops) | |
| for count, path in enumerate(graph.iter_paths(sorted(starts), max_hops)): | |
| if count >= MAX_PATHS: | |
| break | |
| hops = len(path.steps) | |
| if not hops or hops < query.min_hops: | |
| continue | |
| end = path.steps[-1].target_id | |
| if end in starts: | |
| continue | |
| types = [graph.nodes[step.target_id] for step in path.steps] | |
| if hops not in per_hop: | |
| per_hop[hops] = [ | |
| functional.log_softmax( | |
| self.scorer.type_logits(question, hops, i, type_embeddings)[0], -1 | |
| ).tolist() | |
| for i in range(hops) | |
| ] | |
| sequence = hop_table[hops - 1] + sum( | |
| per_hop[hops][i][type_index[name]] for i, name in enumerate(types) | |
| ) | |
| keep("sequence", end, sequence, path) | |
| if use_bag: | |
| bag_value = float(answer_logp[type_index[types[-1]]]) + zero_total | |
| for name, count in Counter(types).items(): | |
| row = count_table[type_index[name]] | |
| bag_value += row[min(count, self.bag.max_count)] - row[0] | |
| keep("bag", end, bag_value, path) | |
| keep("blend", end, self.blend_weight * sequence + bag_value, path) | |
| novelty = 0.0 | |
| if self.template_bank is not None and masked is not None: | |
| vector = functional.normalize(masked.to(self.device).float(), dim=-1) | |
| novelty = float((self.template_bank @ vector).max()) | |
| return {"tables": tables, "hop_logp": hop_logp, "novelty": novelty, "use_bag": use_bag} | |
| def gate_probability(self, dual: dict[str, Any]) -> float | None: | |
| """P(sequence scorer is right), or None without a gate or bag scorer.""" | |
| if self.gate is None or not dual["use_bag"]: | |
| return None | |
| values = gate_features( | |
| dual["tables"]["sequence"][0], dual["tables"]["bag"][0], dual["hop_logp"], dual["novelty"] | |
| ) | |
| if values is None: | |
| return None | |
| width = int(self.gate.net[0].in_features) | |
| features = torch.tensor(values[:width], device=self.device).reshape(1, -1) | |
| mean, std = self.gate_mean[:width], self.gate_std[:width] | |
| return float(torch.sigmoid(self.gate((features - mean) / std))[0]) | |
| def score( | |
| self, | |
| graph: GraphStore, | |
| query: GraphQuery, | |
| question: Tensor | None = None, | |
| tokens: Tensor | None = None, | |
| masked: Tensor | None = None, | |
| ) -> ReadoutScore: | |
| """Score every endpoint reachable from the query's start entities.""" | |
| dual = self.dual(graph, query, question, tokens, masked) | |
| tables = dual["tables"] | |
| gate_p = self.gate_probability(dual) | |
| if gate_p is not None: | |
| chosen = "sequence" if gate_p >= 0.5 else "bag" | |
| elif dual["use_bag"]: | |
| chosen = "blend" | |
| else: | |
| chosen = "sequence" | |
| best, best_paths = tables[chosen] | |
| extra: tuple[float, ...] = () | |
| if self.template_bank is not None: | |
| agree = ( | |
| float(_top(tables["sequence"][0]) == _top(tables["bag"][0])) | |
| if dual["use_bag"] | |
| else 1.0 | |
| ) | |
| extra = (dual["novelty"], agree) | |
| if self.gate is not None: | |
| extra = (*extra, gate_p if gate_p is not None else 1.0) | |
| result = _finalise(best, best_paths, dual["hop_logp"], extra) | |
| if self.decision_source == "sequence" and chosen != "sequence": | |
| sequence_view = _finalise(*tables["sequence"], dual["hop_logp"], extra) | |
| result = replace(result, features=sequence_view.features) | |
| return result | |
| def probability(self, score: ReadoutScore) -> float: | |
| """P(answering is correct), from the abstain head.""" | |
| values = score.features | |
| if self.feature_mask is not None: | |
| values = tuple(values[i] for i in self.feature_mask) | |
| features = torch.tensor(values, device=self.device).reshape(1, -1) | |
| logit = self.abstain((features - self.feature_mean) / self.feature_std) | |
| return float(torch.sigmoid(logit)[0]) | |
| def calibrate(self, confidence: float, hops: int | None = None) -> float: | |
| """Isotonic calibration, per predicted hop count when fitted for it.""" | |
| table = self.hop_calibration.get(hops) if hops is not None else None | |
| table = table or self.calibration | |
| if not table: | |
| return confidence | |
| calibrator = IsotonicCalibrator(*table) | |
| return float(calibrator.transform(torch.tensor([confidence]))[0]) | |
| # -- decision ------------------------------------------------------------- | |
| def reason(self, graph: GraphStore, query: GraphQuery) -> ReasoningResult: | |
| score = self.score(graph, query) | |
| probability = self.probability(score) | |
| answer = bool(score.answers) and probability >= self.threshold | |
| # Confidence describes the decision made: for an abstention, the chance | |
| # that abstaining was right is approximated by 1 - P(answer correct). | |
| raw = probability if answer else 1.0 - probability | |
| confidence = self.calibrate(raw, score.predicted_hops) | |
| answers = ( | |
| tuple( | |
| EntityAnswer(entity, confidence) | |
| for entity in sorted(score.answers, key=lambda e: -score.endpoint_scores[e]) | |
| ) | |
| if answer | |
| else () | |
| ) | |
| status = ReasoningStatus.SUPPORTED if answer else ReasoningStatus.INSUFFICIENT_EVIDENCE | |
| boolean_answer = None | |
| aggregate_value = None | |
| if query.answer_mode == "count": | |
| aggregate_value = len(answers) | |
| elif query.answer_mode == "boolean": | |
| targets = set(query.target_entity_ids) | |
| boolean_answer = bool(targets & {a.entity_id for a in answers}) if targets else answer | |
| return ReasoningResult( | |
| status=status, | |
| answers=answers, | |
| supporting_paths=tuple(score.best_paths[a.entity_id] for a in answers) | |
| if query.return_paths | |
| else (), | |
| conflicting_paths=(), | |
| confidence=confidence, | |
| calibrated=bool(self.calibration or self.hop_calibration), | |
| model_version=self.model_version, | |
| graph_version=graph.graph_version, | |
| reasoning_steps=max((len(p.steps) for p in score.best_paths.values()), default=0), | |
| metadata={"readout": "typed", "answer_probability": probability}, | |
| boolean_answer=boolean_answer, | |
| aggregate_value=aggregate_value, | |
| ) | |
| __call__ = reason | |
| def gate_features( | |
| sequence: dict[str, float], bag: dict[str, float], hop_logp: Tensor, novelty: float | |
| ) -> tuple[float, ...] | None: | |
| """Gate inputs: how much each scorer dislikes the other's answer, hop certainty, | |
| template novelty, whether the two agree, and each scorer's best score. | |
| The best scores separate the two shifts: on new wording of a known intent the | |
| sequence scorer still finds a path that fits its predicted types (high best score); | |
| on a new combination its memorised type sequence rarely exists from the start | |
| entity (low best score), which is when the bag scorer should be trusted.""" | |
| if not sequence or not bag: | |
| return None | |
| seq_top, bag_top = _top(sequence), _top(bag) | |
| seq_max, bag_max = max(sequence.values()), max(bag.values()) | |
| bag_on_seq = max(bag[e] for e in seq_top) - bag_max | |
| seq_on_bag = max(sequence[e] for e in bag_top) - seq_max | |
| return ( | |
| max(bag_on_seq, -50.0), | |
| max(seq_on_bag, -50.0), | |
| float(hop_logp.max().exp()), | |
| novelty, | |
| float(seq_top == bag_top), | |
| max(seq_max, -100.0), | |
| max(bag_max, -100.0), | |
| ) | |
| def _top(scores: dict[str, float]) -> frozenset[str]: | |
| if not scores: | |
| return frozenset() | |
| top = max(scores.values()) | |
| return frozenset(e for e, s in scores.items() if s >= top - 1e-6) | |
| def _finalise( | |
| best: dict[str, float], | |
| best_paths: dict[str, GraphPath], | |
| hop_logp: Tensor, | |
| extra: tuple[float, ...] = (), | |
| ) -> ReadoutScore: | |
| hops = int(hop_logp.argmax()) + 1 | |
| if not best: | |
| return ReadoutScore( | |
| answers=frozenset(), | |
| features=(-30.0, 0.0, 0.0, float(hop_logp.max()), 0.0, *extra), | |
| best_paths={}, | |
| endpoint_scores={}, | |
| predicted_hops=hops, | |
| ) | |
| ranked = sorted(best.values(), reverse=True) | |
| top = ranked[0] | |
| answers = frozenset(e for e, s in best.items() if s >= top - 1e-6) | |
| runner_up = next((s for s in ranked if s < top - 1e-6), top - 30.0) | |
| features = ( | |
| top, | |
| top - runner_up, | |
| float(len(answers)), | |
| float(hop_logp.max()), | |
| math.log1p(len(best)), | |
| *extra, | |
| ) | |
| return ReadoutScore(answers, features, best_paths, dict(best), hops) | |
| def question_text(query: GraphQuery) -> str: | |
| return query.text or query.canonical_text() or "" | |
| def template_text(text: str) -> str: | |
| """Question with entity identifiers masked, for template-novelty lookups.""" | |
| return _ENTITY.sub("X", text).lower().strip() | |
| def sentence_encoders( | |
| name: str, device: str | torch.device = "cpu" | |
| ) -> tuple[Encoder, TokenEncoder]: | |
| """Frozen sentence-transformer: pooled unit vectors and per-token vectors.""" | |
| from sentence_transformers import SentenceTransformer | |
| model = SentenceTransformer(name, device=str(device)) | |
| def encode(texts: Sequence[str]) -> Tensor: | |
| with torch.no_grad(): | |
| return ( | |
| model.encode( | |
| list(texts), | |
| normalize_embeddings=True, | |
| convert_to_tensor=True, | |
| batch_size=256, | |
| ) | |
| .float() | |
| .clone() | |
| ) | |
| def encode_tokens(texts: Sequence[str]) -> list[Tensor]: | |
| with torch.no_grad(): | |
| rows = model.encode(list(texts), output_value="token_embeddings", batch_size=256) | |
| return [functional.normalize(row.float().clone(), dim=-1) for row in rows] | |
| return encode, encode_tokens | |
| def sentence_encoder(name: str, device: str | torch.device = "cpu") -> Encoder: | |
| """Pooled encoder only (the v1 interface).""" | |
| return sentence_encoders(name, device)[0] | |