gabfon's picture
Upload folder using huggingface_hub
3e363e2 verified
Raw History Blame Contribute Delete
25.7 kB
"""Wikidata (CC0) and CoDEx (MIT) adapters for the Wikidata tier (PLAN section 73).
Wikidata is acquired as bounded, randomly sampled per-property SPARQL extracts
(see ``scripts/download_wikidata_data.py``). The raw snapshot is a set of
gzip TSV files:
``properties/<PID>.tsv.gz`` and ``expansion/batch-*.tsv.gz``
``subject<TAB>property<TAB>object`` with bare Q/P identifiers.
``labels/entities-*.tsv.gz``
``qid<TAB>english label<TAB>description<TAB>instance-of QIDs (|-joined)``.
``property_labels.tsv``
``pid<TAB>english label<TAB>description``.
Relation phrases come from the real English property labels (for example
``the "located in the administrative territorial entity" relation``), so the
tier adds hundreds of human-named relation types instead of the synthetic
ontology's 18. Queries never contain the raw property identifiers.
Held-out design (all deterministic):
* a fixed list of properties is removed from every train graph and only emitted
into a relation-held-out evaluation shard;
* entities are split by ``sha256(QID) % 100``; held-out-bucket entities are never
the subject of a train edge, and evaluation queries are anchored only on them;
* CoDEx is an evaluation-only, source-held-out shard set.
"""
from __future__ import annotations
import csv
import gzip
import hashlib
import json
import re
from collections import Counter, defaultdict
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path
from typing import IO, Any
from graph.graph import EdgeRecord, GraphStore
from graph.real.corpus import RealGraph, SourceMetadata, generate_real_examples
from graph.synthetic.generator import ReasoningExample
WIKIDATA_ENTITY_PREFIX = "http://www.wikidata.org/entity/"
_QID = re.compile(r"^Q[1-9][0-9]*$")
_PID = re.compile(r"^P[1-9][0-9]*$")
# --------------------------------------------------------------------------- #
# Configuration and query text
# --------------------------------------------------------------------------- #
def load_wikidata_config(path: str | Path) -> dict[str, Any]:
"""Load ``configs/wikidata_sources.yaml`` and validate the allow-list."""
import yaml
payload = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict) or "wikidata" not in payload:
raise ValueError(f"{path}: missing 'wikidata' section")
wikidata = payload["wikidata"]
properties = wikidata.get("properties") or {}
if not isinstance(properties, dict) or not properties:
raise ValueError(f"{path}: wikidata.properties must be a non-empty mapping")
for pid in properties:
if not _PID.match(str(pid)):
raise ValueError(f"{path}: invalid property id {pid!r}")
heldout = [str(pid) for pid in wikidata.get("heldout_properties") or ()]
missing = [pid for pid in heldout if pid not in properties]
if missing:
raise ValueError(f"{path}: held-out properties not allow-listed: {missing}")
return payload
def property_query(pid: str, limit: int) -> str:
"""Bounded random-sample SPARQL for one property (entity objects only)."""
if not _PID.match(pid):
raise ValueError(f"invalid property id {pid!r}")
if limit < 1:
raise ValueError("limit must be positive")
return (
"SELECT ?s ?o WHERE {\n"
" SERVICE bd:sample {\n"
f" ?s wdt:{pid} ?o .\n"
f" bd:serviceParam bd:sample.limit {int(limit)} .\n"
' bd:serviceParam bd:sample.sampleType "RANDOM" .\n'
" }\n"
f' FILTER(STRSTARTS(STR(?s), "{WIKIDATA_ENTITY_PREFIX}Q") &&\n'
f' STRSTARTS(STR(?o), "{WIKIDATA_ENTITY_PREFIX}Q"))\n'
"}"
)
def expansion_query(subjects: Sequence[str], pids: Sequence[str], limit: int) -> str:
"""Outgoing allow-listed edges for a batch of (frequently referenced) items."""
for qid in subjects:
if not _QID.match(qid):
raise ValueError(f"invalid entity id {qid!r}")
for pid in pids:
if not _PID.match(pid):
raise ValueError(f"invalid property id {pid!r}")
values_s = " ".join(f"wd:{qid}" for qid in subjects)
values_p = " ".join(f"wdt:{pid}" for pid in pids)
return (
"SELECT ?s ?p ?o WHERE {\n"
f" VALUES ?s {{ {values_s} }}\n"
f" VALUES ?p {{ {values_p} }}\n"
" ?s ?p ?o .\n"
f' FILTER(STRSTARTS(STR(?o), "{WIKIDATA_ENTITY_PREFIX}Q"))\n'
f"}} LIMIT {int(limit)}"
)
def label_query(entities: Sequence[str]) -> str:
"""English label, description and instance-of classes for a batch of items."""
for qid in entities:
if not _QID.match(qid):
raise ValueError(f"invalid entity id {qid!r}")
values = " ".join(f"wd:{qid}" for qid in entities)
return (
"SELECT ?e ?l ?d ?t WHERE {\n"
f" VALUES ?e {{ {values} }}\n"
' OPTIONAL { ?e rdfs:label ?l . FILTER(LANG(?l) = "en") }\n'
' OPTIONAL { ?e schema:description ?d . FILTER(LANG(?d) = "en") }\n'
" OPTIONAL { ?e wdt:P31 ?t }\n"
"}"
)
def property_label_query(pids: Sequence[str]) -> str:
for pid in pids:
if not _PID.match(pid):
raise ValueError(f"invalid property id {pid!r}")
values = " ".join(f"wd:{pid}" for pid in pids)
return (
"SELECT ?p ?l ?d WHERE {\n"
f" VALUES ?p {{ {values} }}\n"
' OPTIONAL { ?p rdfs:label ?l . FILTER(LANG(?l) = "en") }\n'
' OPTIONAL { ?p schema:description ?d . FILTER(LANG(?d) = "en") }\n'
"}"
)
# --------------------------------------------------------------------------- #
# SPARQL result parsing (TSV or JSON)
# --------------------------------------------------------------------------- #
def _term(value: str) -> str:
"""Normalise one SPARQL TSV/JSON term to a bare ID or plain string."""
value = value.strip()
if value.startswith("<") and value.endswith(">"):
value = value[1:-1]
for prefix in (WIKIDATA_ENTITY_PREFIX, "http://www.wikidata.org/prop/direct/"):
if value.startswith(prefix):
return value[len(prefix) :]
if value.startswith('"'):
match = re.match(r'^"(.*)"(?:@[A-Za-z-]+|\^\^<[^>]*>)?$', value, re.DOTALL)
if match:
text = match.group(1)
return (
text.replace('\\"', '"')
.replace("\\t", " ")
.replace("\\n", " ")
.replace("\\\\", "\\")
)
return value
def parse_sparql_tsv(text: str) -> list[dict[str, str]]:
"""Parse a WDQS ``text/tab-separated-values`` response."""
lines = text.splitlines()
if not lines:
return []
header = [name.lstrip("?") for name in lines[0].split("\t")]
rows: list[dict[str, str]] = []
for line in lines[1:]:
if not line.strip():
continue
fields = line.split("\t")
fields += [""] * (len(header) - len(fields))
rows.append(
{name: _term(field) for name, field in zip(header, fields, strict=False)}
)
return rows
def parse_sparql_json(payload: Mapping[str, Any] | str) -> list[dict[str, str]]:
"""Parse a SPARQL 1.1 JSON results document."""
data = json.loads(payload) if isinstance(payload, str) else payload
variables = list(data.get("head", {}).get("vars", ()))
rows: list[dict[str, str]] = []
for binding in data.get("results", {}).get("bindings", ()):
row = {}
for name in variables:
cell = binding.get(name)
if not cell:
row[name] = ""
elif cell.get("type") == "uri":
row[name] = _term("<" + str(cell.get("value", "")) + ">")
else:
row[name] = " ".join(str(cell.get("value", "")).split())
rows.append(row)
return rows
# --------------------------------------------------------------------------- #
# Raw snapshot readers
# --------------------------------------------------------------------------- #
def _open_text(path: str | Path) -> IO[str]:
file_path = Path(path)
if file_path.name.endswith(".gz"):
return gzip.open(file_path, "rt", encoding="utf-8", newline="")
return file_path.open(encoding="utf-8", newline="")
def _rows(path: str | Path) -> Iterator[list[str]]:
with _open_text(path) as handle:
reader = csv.reader(handle, delimiter="\t", quoting=csv.QUOTE_NONE)
header = next(reader, None)
if header is None:
return
yield from reader
def iter_wikidata_triples(
paths: Iterable[str | Path],
) -> Iterator[tuple[str, str, str]]:
"""Yield ``(subject, property, object)`` from raw extract files, validated."""
for path in paths:
for fields in _rows(path):
if len(fields) < 3:
continue
subject, pid, obj = (field.strip() for field in fields[:3])
if (
_QID.match(subject)
and _PID.match(pid)
and _QID.match(obj)
and subject != obj
):
yield subject, pid, obj
@dataclass(frozen=True)
class PropertyInfo:
pid: str
label: str
description: str = ""
def load_property_labels(path: str | Path) -> dict[str, PropertyInfo]:
result: dict[str, PropertyInfo] = {}
for fields in _rows(path):
if not fields or not _PID.match(fields[0]):
continue
label = fields[1].strip() if len(fields) > 1 else ""
description = fields[2].strip() if len(fields) > 2 else ""
if label:
result[fields[0]] = PropertyInfo(fields[0], label, description)
return result
@dataclass(frozen=True)
class EntityTable:
labels: Mapping[str, str]
descriptions: Mapping[str, str]
classes: Mapping[str, tuple[str, ...]]
def load_entity_labels(paths: Iterable[str | Path]) -> EntityTable:
labels: dict[str, str] = {}
descriptions: dict[str, str] = {}
classes: dict[str, tuple[str, ...]] = {}
for path in paths:
for fields in _rows(path):
if not fields or not _QID.match(fields[0]):
continue
qid = fields[0]
label = fields[1].strip() if len(fields) > 1 else ""
if label:
labels[qid] = label
if len(fields) > 2 and fields[2].strip():
descriptions[qid] = fields[2].strip()
if len(fields) > 3 and fields[3].strip():
classes[qid] = tuple(
sorted(
{value for value in fields[3].split("|") if _QID.match(value)}
)
)
return EntityTable(labels, descriptions, classes)
# --------------------------------------------------------------------------- #
# Graph construction
# --------------------------------------------------------------------------- #
def relation_phrase_from_label(label: str) -> str:
"""Natural-language phrase built from a real property label."""
clean = " ".join(label.replace('"', "'").split())
return f'the "{clean}" relation'
def entity_bucket(qid: str) -> int:
"""Stable 0-99 bucket from the QID (not Python's salted hash)."""
return int.from_bytes(hashlib.sha256(qid.encode("utf-8")).digest()[:8], "big") % 100
def is_heldout_entity(qid: str, percent: int) -> bool:
return entity_bucket(qid) < percent
def _node_type(
qid: str,
classes: Mapping[str, tuple[str, ...]],
labels: Mapping[str, str],
class_frequency: Mapping[str, int],
) -> str:
candidates = [cls for cls in classes.get(qid, ()) if cls in labels]
if not candidates:
return "entity"
best = min(candidates, key=lambda cls: (-class_frequency.get(cls, 0), cls))
return " ".join(labels[best].split())[:80] or "entity"
def build_labelled_graph(
triples: Iterable[tuple[str, str, str]],
source: SourceMetadata,
*,
relation_labels: Mapping[str, PropertyInfo],
entities: EntityTable,
node_prefix: str = "wd:",
max_edges_per_subject_property: int | None = 24,
evidence_kind: str = "wikidata_truthy_statement",
) -> RealGraph:
"""Build a ``RealGraph`` from ID triples, dropping unlabelled endpoints.
Triples are de-duplicated and sorted, so the result is independent of the
order of the input files. Relation phrases come from ``relation_labels``.
"""
unique = sorted(
{
triple
for triple in triples
if triple[1] in relation_labels
and triple[0] in entities.labels
and triple[2] in entities.labels
}
)
per_key: Counter[tuple[str, str]] = Counter()
kept: list[tuple[str, str, str]] = []
for subject, pid, obj in unique:
key = (subject, pid)
if (
max_edges_per_subject_property is not None
and per_key[key] >= max_edges_per_subject_property
):
continue
per_key[key] += 1
kept.append((subject, pid, obj))
endpoints = sorted({qid for s, _, o in kept for qid in (s, o)})
class_frequency: Counter[str] = Counter(
cls for qid in endpoints for cls in entities.classes.get(qid, ())
)
nodes: dict[str, str] = {}
node_attributes: dict[str, dict[str, str]] = {}
labels: dict[str, str] = {}
for qid in endpoints:
node_id = node_prefix + qid
node_type = _node_type(qid, entities.classes, entities.labels, class_frequency)
nodes[node_id] = node_type
labels[node_id] = entities.labels[qid]
attributes = {"label": entities.labels[qid], "wikidata_id": qid}
description = entities.descriptions.get(qid, "")
if description:
attributes["description"] = description[:160]
node_attributes[node_id] = attributes
relations = sorted({pid for _, pid, _ in kept})
phrases = {
pid: relation_phrase_from_label(relation_labels[pid].label) for pid in relations
}
edges = [
EdgeRecord(
node_prefix + subject,
pid,
node_prefix + obj,
nodes[node_prefix + subject],
nodes[node_prefix + obj],
relation_labels[pid].label,
{"evidence_kind": evidence_kind},
)
for subject, pid, obj in kept
]
relation_attributes = {
pid: {
"functional": "false",
"label": relation_labels[pid].label,
"description": relation_labels[pid].description[:240] or phrases[pid],
"wikidata_property": pid,
}
for pid in relations
}
if not nodes:
raise ValueError(f"{source.source_id}: no labelled triples to build a graph")
return RealGraph(
graph=_fast_graph_store(
nodes,
edges,
graph_version=f"{source.source_id}@{source.version}",
node_attributes=node_attributes,
relation_attributes=relation_attributes,
),
node_labels=labels,
relation_phrases=phrases,
source=source,
)
def _fast_graph_store(
nodes: Mapping[str, str],
edges: Sequence[EdgeRecord],
*,
graph_version: str,
node_attributes: Mapping[str, Mapping[str, str]],
relation_attributes: Mapping[str, Mapping[str, str]],
) -> GraphStore:
"""Construct a ``GraphStore`` with an O(E) adjacency build.
``GraphStore.__init__`` appends to per-node tuples, which is quadratic in
out-degree; at Wikidata scale that dominates build time. The resulting
object is identical (same edges, same per-node edge order).
"""
store = GraphStore.__new__(GraphStore)
store.nodes = dict(nodes)
if not store.nodes:
raise ValueError("graph must contain at least one node")
store.edges = tuple(edges)
store.graph_version = graph_version
store.node_attributes = {key: dict(value) for key, value in node_attributes.items()}
store.relation_attributes = {
key: dict(value) for key, value in relation_attributes.items()
}
outgoing: dict[str, list[EdgeRecord]] = defaultdict(list)
for edge in store.edges:
if edge.source_id not in store.nodes or edge.target_id not in store.nodes:
raise ValueError(f"edge references unknown node: {edge}")
outgoing[edge.source_id].append(edge)
store._out = {key: tuple(value) for key, value in outgoing.items()}
return store
# --------------------------------------------------------------------------- #
# Held-out splits
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class WikidataSplit:
"""ID-level triples for the train graph and the held-out evaluation graph."""
train: tuple[tuple[str, str, str], ...]
evaluation: tuple[tuple[str, str, str], ...]
heldout_properties: frozenset[str]
heldout_entity_percent: int
#: held-out-bucket subjects with at least one held-out-property edge
heldout_relation_starts: tuple[str, ...]
#: held-out-bucket subjects with at least one train-property edge
heldout_entity_starts: tuple[str, ...]
def split_wikidata_triples(
triples: Iterable[tuple[str, str, str]],
*,
heldout_properties: Iterable[str],
heldout_entity_percent: int,
continuation_layers: int = 2,
) -> WikidataSplit:
"""Split triples into a train set and a held-out evaluation set.
Train: allow-listed, non-held-out properties whose subject is outside the
held-out entity bucket. Evaluation: every edge whose subject is in the
held-out bucket (both held-out and seen properties, so seen relations act as
distractors), plus ``continuation_layers`` of seen-property edges reachable
from those objects so multi-hop chains can continue. Held-out properties
therefore never occur in ``train`` and held-out-bucket entities are never a
train subject.
"""
if not 0 <= heldout_entity_percent < 100:
raise ValueError("heldout_entity_percent must be in [0, 100)")
heldout = frozenset(heldout_properties)
unique = sorted(set(triples))
by_subject: dict[str, list[tuple[str, str, str]]] = defaultdict(list)
for triple in unique:
by_subject[triple[0]].append(triple)
train: list[tuple[str, str, str]] = []
evaluation: set[tuple[str, str, str]] = set()
relation_starts: set[str] = set()
entity_starts: set[str] = set()
frontier: set[str] = set()
for subject, pid, obj in unique:
if is_heldout_entity(subject, heldout_entity_percent):
evaluation.add((subject, pid, obj))
frontier.add(obj)
if pid in heldout:
relation_starts.add(subject)
else:
entity_starts.add(subject)
elif pid not in heldout:
train.append((subject, pid, obj))
seen = set(frontier)
for _ in range(continuation_layers):
next_frontier: set[str] = set()
for node in sorted(frontier):
for triple in by_subject.get(node, ()):
if triple[1] in heldout and not is_heldout_entity(
node, heldout_entity_percent
):
# held-out relations only ever appear anchored in the bucket
continue
evaluation.add(triple)
if triple[2] not in seen:
seen.add(triple[2])
next_frontier.add(triple[2])
frontier = next_frontier
return WikidataSplit(
train=tuple(train),
evaluation=tuple(sorted(evaluation)),
heldout_properties=heldout,
heldout_entity_percent=heldout_entity_percent,
heldout_relation_starts=tuple(sorted(relation_starts)),
heldout_entity_starts=tuple(sorted(entity_starts)),
)
def generate_filtered_examples(
real_graph: RealGraph,
*,
count: int,
seed: int,
accept: Callable[[ReasoningExample], bool],
start_nodes: Sequence[str] | None = None,
id_prefix: str | None = None,
max_attempt_factor: int = 20,
**kwargs: Any,
) -> Iterator[ReasoningExample]:
"""Stream ``count`` examples from ``generate_real_examples`` that pass ``accept``.
Accepted examples are renumbered so example IDs stay contiguous. Raises
``RuntimeError`` if the acceptance rate is too low to reach ``count``.
"""
if count < 0:
raise ValueError("count must be non-negative")
prefix = id_prefix or real_graph.source.source_id
produced = 0
stream = generate_real_examples(
real_graph,
count=max(1, count * max_attempt_factor),
seed=seed,
start_nodes=start_nodes,
**kwargs,
)
try:
for example in stream:
if produced >= count:
break
if not accept(example):
continue
yield replace(
example,
example_id=f"{prefix}-{seed}-{produced}",
seed=seed + produced,
)
produced += 1
if produced >= count:
break
except RuntimeError:
if produced < count:
raise
finally:
close = getattr(stream, "close", None)
if close is not None:
close()
if produced < count:
raise RuntimeError(
f"accepted only {produced}/{count} examples from {prefix}; "
"the acceptance filter is too strict for this graph"
)
def contains_relation(relations: Iterable[str]) -> Callable[[ReasoningExample], bool]:
wanted = frozenset(relations)
return lambda example: any(pid in wanted for pid in example.composition)
def excludes_relations(relations: Iterable[str]) -> Callable[[ReasoningExample], bool]:
banned = frozenset(relations)
return lambda example: not any(pid in banned for pid in example.composition)
# --------------------------------------------------------------------------- #
# CoDEx
# --------------------------------------------------------------------------- #
def _read_json(path: str | Path) -> Any:
with _open_text(path) as handle:
return json.load(handle)
def load_codex(
triple_paths: Sequence[str | Path],
source: SourceMetadata,
*,
entities_json: str | Path,
relations_json: str | Path,
entity_types_json: str | Path | None = None,
types_json: str | Path | None = None,
max_triples: int | None = None,
max_edges_per_subject_property: int | None = 24,
) -> RealGraph:
"""Load CoDEx triples with English entity/relation labels and entity types."""
entities_payload = _read_json(entities_json)
relations_payload = _read_json(relations_json)
labels: dict[str, str] = {}
descriptions: dict[str, str] = {}
for qid, record in entities_payload.items():
if isinstance(record, dict) and str(record.get("label", "")).strip():
labels[str(qid)] = " ".join(str(record["label"]).split())
if str(record.get("description", "")).strip():
descriptions[str(qid)] = " ".join(str(record["description"]).split())
relation_labels = {
str(pid): PropertyInfo(
str(pid),
" ".join(str(record.get("label", "")).split()),
" ".join(str(record.get("description", "")).split()),
)
for pid, record in relations_payload.items()
if isinstance(record, dict) and str(record.get("label", "")).strip()
}
classes: dict[str, tuple[str, ...]] = {}
if entity_types_json is not None:
for qid, values in _read_json(entity_types_json).items():
if isinstance(values, list):
classes[str(qid)] = tuple(sorted(str(value) for value in values))
if types_json is not None:
for qid, record in _read_json(types_json).items():
if isinstance(record, dict) and str(record.get("label", "")).strip():
labels.setdefault(str(qid), " ".join(str(record["label"]).split()))
def triples() -> Iterator[tuple[str, str, str]]:
emitted = 0
for path in triple_paths:
with _open_text(path) as handle:
for line in handle:
fields = line.rstrip("\n").split("\t")
if len(fields) < 3:
continue
head, relation, tail = (field.strip() for field in fields[:3])
if not head or not relation or not tail or head == tail:
continue
yield head, relation, tail
emitted += 1
if max_triples is not None and emitted >= max_triples:
return
return build_labelled_graph(
triples(),
source,
relation_labels=relation_labels,
entities=EntityTable(labels, descriptions, classes),
node_prefix="codex:",
max_edges_per_subject_property=max_edges_per_subject_property,
evidence_kind="codex_benchmark_triple",
)
def load_wikidata_snapshot(
root: str | Path,
) -> tuple[list[tuple[str, str, str]], dict[str, PropertyInfo], EntityTable]:
"""Read every raw extract under ``root`` (a ``data/wikidata/wikidata`` dir)."""
base = Path(root)
triple_paths = sorted(base.glob("properties/*.tsv.gz")) + sorted(
base.glob("expansion/*.tsv.gz")
)
triples = list(iter_wikidata_triples(triple_paths))
relation_labels = load_property_labels(base / "property_labels.tsv")
entities = load_entity_labels(sorted(base.glob("labels/*.tsv.gz")))
return triples, relation_labels, entities