CAFF / caff /data.py
MrDhifallah's picture
Upload folder using huggingface_hub
634ebe8 verified
Raw
History Blame Contribute Delete
25.5 kB
"""
caff/data.py
============
Data pipeline for CAFF: KG loading, BFS extraction with frequency
capping, gold-relevance annotation, and PyTorch Datasets.
Implements:
• Eq. 1 — BFS candidate sets C_ℓ
• Eq. 13 — Frequency cap K_r=20 per (head, relation)
• Paper §8.1 — Gold annotation via shortest-path reachability
• Paper §8.4 — Train/dev/test splits
Smart Engineering:
• S2 — Pre-computed BFS subgraphs cached to disk (deterministic
given G and S(Q), so we never recompute across seeds).
Data formats expected
---------------------
merged_kg.tsv :
Tab-separated, columns: head, relation, tail, [head_type], [tail_type]
Header row required.
train.json / dev.json / test.json :
JSON list of objects:
{
"query_id": str,
"question": str, # natural-language query
"seeds": [str, ...], # entities linked from the question
"gold_answer": str | null, # answer entity (for shortest-path
# annotation) — used at training only
"answer_label": str | null # for end-to-end QA (yes/no/maybe)
}
"""
from __future__ import annotations
import json
import logging
import pickle
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
import networkx as nx
import torch
from torch.utils.data import Dataset
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────
# Knowledge Graph container
# ─────────────────────────────────────────────────────────────────
@dataclass
class Triple:
"""A directed typed triple (h, r, t) per Assumption 1."""
h: str
r: str
t: str
def as_tuple(self) -> tuple[str, str, str]:
return (self.h, self.r, self.t)
class KnowledgeGraph:
"""Biomedical KG (paper §8.1).
After construction:
|V| ≈ 148,423 (entities)
|E| ≈ 2,318,941 (triples)
|R| = 42 (after singleton removal: relations with <50 triples dropped)
Stores both:
• A list of Triple objects (canonical)
• An adjacency dict adj[h] -> list[(r, t)] for fast BFS
• A reverse adjacency rev[t] -> list[(r, h)] for tail-degree lookups
• A NetworkX MultiDiGraph for shortest-path queries
"""
def __init__(
self,
triples: list[Triple],
min_relation_freq: int = 50,
) -> None:
# Singleton-relation removal (paper §8.1: "Singleton relations
# (<50 triples) are removed, retaining |R|=42").
if min_relation_freq > 0:
rel_counts: dict[str, int] = defaultdict(int)
for t in triples:
rel_counts[t.r] += 1
kept_relations = {r for r, c in rel_counts.items() if c >= min_relation_freq}
n_before = len(triples)
triples = [t for t in triples if t.r in kept_relations]
logger.info(
f"Filtered {n_before - len(triples)} triples from "
f"{len(rel_counts) - len(kept_relations)} singleton relations "
f"(min_freq={min_relation_freq})"
)
self.triples: list[Triple] = triples
self.entities: list[str] = sorted({t.h for t in triples} | {t.t for t in triples})
self.relations: list[str] = sorted({t.r for t in triples})
self.entity_to_idx = {e: i for i, e in enumerate(self.entities)}
self.relation_to_idx = {r: i for i, r in enumerate(self.relations)}
# Forward adjacency: h -> [(r, t), ...]
self.adj: dict[str, list[tuple[str, str]]] = defaultdict(list)
# Reverse adjacency: t -> [(r, h), ...] (for tail-degree lookups)
self.rev: dict[str, list[tuple[str, str]]] = defaultdict(list)
# Tail-degree cache: t -> deg_in(t)
self._tail_degree: dict[str, int] = defaultdict(int)
for tr in triples:
self.adj[tr.h].append((tr.r, tr.t))
self.rev[tr.t].append((tr.r, tr.h))
self._tail_degree[tr.t] += 1
# Fail-fast: an empty KG after filtering is ALWAYS a configuration bug.
if len(triples) == 0:
raise ValueError(
f"KnowledgeGraph is empty after filtering "
f"(min_relation_freq={min_relation_freq}). "
f"Either the source TSV is empty, or every relation has "
f"fewer than min_relation_freq triples. "
f"Inspect the KG file before retrying."
)
logger.info(
f"KG loaded: |V|={len(self.entities):,} "
f"|E|={len(triples):,} |R|={len(self.relations)}"
)
def tail_degree(self, entity: str) -> int:
"""In-degree of `entity` = number of triples with t=entity.
Used by FreqCap (Eq. 13) to rank candidates by tail degree.
"""
return self._tail_degree.get(entity, 0)
def neighbors(self, head: str) -> list[tuple[str, str]]:
"""Return [(r, t), ...] outgoing from `head`."""
return self.adj.get(head, [])
def to_networkx(self) -> nx.MultiDiGraph:
"""Build a NetworkX graph for shortest-path annotation.
Built lazily and cached — the graph is large (~2.3M edges)
and only needed for gold annotation, not for training.
"""
if not hasattr(self, "_nx_graph"):
G = nx.MultiDiGraph()
G.add_nodes_from(self.entities)
for tr in self.triples:
G.add_edge(tr.h, tr.t, key=tr.r, relation=tr.r)
self._nx_graph = G
logger.info(f"NetworkX graph built: {G.number_of_edges():,} edges")
return self._nx_graph
@classmethod
def from_tsv(
cls,
path: str | Path,
min_relation_freq: int = 50,
) -> "KnowledgeGraph":
"""Load merged KG from a tab-separated file."""
path = Path(path)
triples: list[Triple] = []
with path.open("r", encoding="utf-8") as f:
header = f.readline().strip().split("\t")
assert "head" in header and "relation" in header and "tail" in header, (
f"merged_kg.tsv must have columns 'head', 'relation', 'tail'; "
f"got {header}"
)
h_idx = header.index("head")
r_idx = header.index("relation")
t_idx = header.index("tail")
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) <= max(h_idx, r_idx, t_idx):
continue
triples.append(Triple(parts[h_idx], parts[r_idx], parts[t_idx]))
return cls(triples, min_relation_freq=min_relation_freq)
# ─────────────────────────────────────────────────────────────────
# BFS candidate extraction (Eq. 1) and Frequency Cap (Eq. 13)
# ─────────────────────────────────────────────────────────────────
def bfs_candidate_sets(
kg: KnowledgeGraph,
seeds: list[str],
L: int,
) -> list[list[Triple]]:
"""Eq. 1 — extract BFS candidate sets up to depth L.
C_ℓ = { (h, r, t) ∈ E : d_G(S(Q), h) = ℓ - 1 }
where d_G is the (undirected) shortest-path distance from any
seed in S(Q). We expand BFS forward only (h → t) since the
paper's reasoning chains follow directed semantics.
Parameters
----------
kg : KnowledgeGraph
seeds : list of entity names — S(Q)
L : max hop depth (paper default: 3)
Returns
-------
candidate_sets : list of length L
candidate_sets[â„“] = C_{â„“+1}, the candidates at hop â„“+1
(Python is 0-indexed; paper uses 1-indexed â„“).
"""
visited: set[str] = set(seeds)
frontier: set[str] = {s for s in seeds if s in kg.adj}
candidate_sets: list[list[Triple]] = []
for hop in range(L):
next_frontier: set[str] = set()
hop_candidates: list[Triple] = []
for h in frontier:
for r, t in kg.neighbors(h):
hop_candidates.append(Triple(h, r, t))
if t not in visited:
next_frontier.add(t)
candidate_sets.append(hop_candidates)
visited |= next_frontier
frontier = next_frontier
return candidate_sets
def apply_frequency_cap(
candidates: list[Triple],
kg: KnowledgeGraph,
K_r: int = 20,
) -> list[Triple]:
"""Eq. 13 — Frequency cap: per (head, relation) keep top-K_r tails by deg(t).
C^{(h,r)}_ℓ = top-K_r({(h, r, t) ∈ E}, by deg(t))
This prevents hub-entity relation embeddings from saturating
the CSV (paper §6.1).
Parameters
----------
candidates : list of Triple
BFS-extracted candidates from one hop.
kg : KnowledgeGraph
For tail-degree lookups.
K_r : int
Cap (paper default: 20).
Returns
-------
Filtered list of Triple, with at most K_r per (h, r) pair.
Failure mode F-data-1 (silent over-cap):
We assert in the caller that no (h, r) pair exceeds K_r.
"""
# Group by (h, r)
grouped: dict[tuple[str, str], list[Triple]] = defaultdict(list)
for tr in candidates:
grouped[(tr.h, tr.r)].append(tr)
capped: list[Triple] = []
for (h, r), group in grouped.items():
if len(group) <= K_r:
capped.extend(group)
else:
# Sort by tail degree (descending), keep top-K_r
sorted_group = sorted(
group, key=lambda tr: kg.tail_degree(tr.t), reverse=True
)
capped.extend(sorted_group[:K_r])
return capped
# ─────────────────────────────────────────────────────────────────
# Gold-relevance annotation (paper §8.1)
# ─────────────────────────────────────────────────────────────────
def annotate_gold_relevance(
kg: KnowledgeGraph,
seeds: list[str],
gold_answer: str,
L: int,
) -> set[tuple[str, str, str]]:
"""Paper §8.1: triples on any shortest path from seed to gold
answer entity receive y=1; all others y=0.
Returns
-------
Set of (h, r, t) tuples with y=1.
Implementation
--------------
For each seed s, find ALL shortest paths from s to gold_answer
in the underlying graph (ignoring relation labels for path
finding — paper uses unlabeled shortest path). Every edge on
any shortest path becomes a positive triple.
"""
G = kg.to_networkx()
if gold_answer not in G:
return set()
positive_edges: set[tuple[str, str, str]] = set()
for s in seeds:
if s not in G:
continue
try:
length = nx.shortest_path_length(G, source=s, target=gold_answer)
if length > L:
continue
for path in nx.all_shortest_paths(G, source=s, target=gold_answer):
for u, v in zip(path[:-1], path[1:]):
# All parallel edges (relations) between u and v
# are considered on the shortest path.
for key in G[u][v]:
positive_edges.add((u, key, v))
except nx.NetworkXNoPath:
continue
except nx.NodeNotFound:
continue
return positive_edges
# ─────────────────────────────────────────────────────────────────
# Cached BFS extractor (Smart Engineering S2)
# ─────────────────────────────────────────────────────────────────
class CachedBFSExtractor:
"""Pre-computes and caches BFS subgraphs per query_id.
BFS is deterministic given (KG, seeds, L), so caching across
seeds and epochs is safe and saves substantial wall-clock.
Cache layout:
cache_dir/
bfs_<query_id>.pkl → dict {
"candidate_sets": list[list[Triple]],
"candidate_sets_cap": list[list[Triple]], # post-FreqCap
"gold_positives": set[tuple[str,str,str]] | None,
}
"""
def __init__(
self,
kg: KnowledgeGraph,
L: int = 3,
K_r: int = 20,
cache_dir: str | Path | None = None,
) -> None:
self.kg = kg
self.L = L
self.K_r = K_r
self.cache_dir = Path(cache_dir) if cache_dir else None
if self.cache_dir is not None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
def _cache_path(self, query_id: str) -> Path | None:
if self.cache_dir is None:
return None
# Sanitize query_id for filesystem
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in query_id)
return self.cache_dir / f"bfs_L{self.L}_K{self.K_r}_{safe}.pkl"
def extract(
self,
query_id: str,
seeds: list[str],
gold_answer: str | None = None,
) -> dict:
"""Extract (or load from cache) BFS data for a single query."""
cache_path = self._cache_path(query_id)
if cache_path is not None and cache_path.exists():
with cache_path.open("rb") as f:
return pickle.load(f)
candidate_sets = bfs_candidate_sets(self.kg, seeds, self.L)
candidate_sets_cap = [
apply_frequency_cap(C, self.kg, self.K_r) for C in candidate_sets
]
# Sanity check (failure mode F-data-1)
for hop_idx, C in enumerate(candidate_sets_cap):
counts: dict[tuple[str, str], int] = defaultdict(int)
for tr in C:
counts[(tr.h, tr.r)] += 1
for (h, r), c in counts.items():
assert c <= self.K_r, (
f"FreqCap violation at hop {hop_idx}: "
f"({h}, {r}) has {c} candidates > K_r={self.K_r}"
)
gold_positives: set[tuple[str, str, str]] | None = None
if gold_answer is not None:
gold_positives = annotate_gold_relevance(
self.kg, seeds, gold_answer, self.L
)
data = {
"candidate_sets": candidate_sets,
"candidate_sets_cap": candidate_sets_cap,
"gold_positives": gold_positives,
}
if cache_path is not None:
with cache_path.open("wb") as f:
pickle.dump(data, f)
return data
# ─────────────────────────────────────────────────────────────────
# QA query records
# ─────────────────────────────────────────────────────────────────
@dataclass
class QARecord:
"""A single QA instance from train/dev/test split."""
query_id: str
question: str
seeds: list[str]
gold_answer: str | None
answer_label: str | None # for end-to-end QA evaluation
def load_qa_split(path: str | Path) -> list[QARecord]:
"""Load a train/dev/test JSON file into QARecord objects."""
path = Path(path)
with path.open("r", encoding="utf-8") as f:
raw = json.load(f)
records = []
for r in raw:
records.append(
QARecord(
query_id=r["query_id"],
question=r["question"],
seeds=r.get("seeds", []),
gold_answer=r.get("gold_answer"),
answer_label=r.get("answer_label"),
)
)
logger.info(f"Loaded {len(records)} QA records from {path}")
return records
# ─────────────────────────────────────────────────────────────────
# Triple-level dataset (per training instance = one BFS triple)
# ─────────────────────────────────────────────────────────────────
@dataclass
class TripleInstance:
"""One training example: a single (Q, h, r, t) at a specific hop â„“.
The label y comes from gold-relevance annotation. The upstream
context z_{â„“-1} is computed lazily during training (we cannot
cache it because it depends on the model's current decisions
at hops 1..â„“-1).
"""
query_id: str
question: str
head: str
relation: str
tail: str
hop: int # ℓ ∈ {1, ..., L}
label: int # y ∈ {0, 1}
class CAFFTripleDataset(Dataset):
"""PyTorch Dataset of triple-level training instances.
Each item is one TripleInstance. The trainer iterates over
these in mini-batches, but actual scoring happens at the
query+hop level (because W^ctx is per-query-per-hop, not
per-triple). The DataLoader thus uses a custom collate_fn
that groups by (query_id, hop) — provided in caff/trainer.py.
"""
def __init__(
self,
qa_records: list[QARecord],
bfs_extractor: CachedBFSExtractor,
require_gold: bool = True,
) -> None:
self.qa_records = qa_records
self.bfs_extractor = bfs_extractor
self.instances: list[TripleInstance] = []
self._build(require_gold=require_gold)
def _build(self, require_gold: bool) -> None:
"""Materialize per-triple training instances from QA records."""
skipped_no_gold = 0
for rec in self.qa_records:
if require_gold and rec.gold_answer is None:
skipped_no_gold += 1
continue
bfs = self.bfs_extractor.extract(
rec.query_id, rec.seeds, rec.gold_answer
)
gold_set = bfs["gold_positives"] or set()
for hop_idx, C in enumerate(bfs["candidate_sets_cap"]):
hop = hop_idx + 1
for tr in C:
label = 1 if tr.as_tuple() in gold_set else 0
self.instances.append(
TripleInstance(
query_id=rec.query_id,
question=rec.question,
head=tr.h,
relation=tr.r,
tail=tr.t,
hop=hop,
label=label,
)
)
logger.info(
f"Built {len(self.instances):,} triple instances "
f"(skipped {skipped_no_gold} records lacking gold answers)"
)
if len(self.instances) > 0:
n_pos = sum(1 for x in self.instances if x.label == 1)
logger.info(
f" Class balance: {n_pos / len(self.instances) * 100:.2f}% positive"
)
def __len__(self) -> int:
return len(self.instances)
def __getitem__(self, idx: int) -> TripleInstance:
return self.instances[idx]
def iter_by_query_hop(
self,
) -> Iterator[tuple[str, int, str, list[TripleInstance]]]:
"""Yield (query_id, hop, question, [instances]) groups.
Used by the trainer to batch all candidates of one
(query, hop) together, since they share W^ctx (S3).
"""
groups: dict[tuple[str, int], list[TripleInstance]] = defaultdict(list)
for inst in self.instances:
groups[(inst.query_id, inst.hop)].append(inst)
for (qid, hop), items in groups.items():
yield qid, hop, items[0].question, items
def class_imbalance_pos_weight(self) -> float:
"""Compute pos_weight = N_neg / N_pos for BCEWithLogitsLoss."""
n_pos = sum(1 for x in self.instances if x.label == 1)
n_neg = len(self.instances) - n_pos
if n_pos == 0:
return 1.0
return n_neg / n_pos