trainset: GUARANTEE every target triple's entity name is present in input_text

Critical training-data fix. Verification showed only 64% of target-triple names
actually appeared in the excerpt the model sees. Two causes, both fixed:

1. Excerpt picker (_evidence_excerpt) matched only a single distinctive token
   near a role word, so it often cut a window that missed the name even though it
   was in the prose. Now uses _name_positions (same coherent-full-name/acronym
   match as the verification) and always falls back to the first real occurrence
   -> the name is guaranteed inside the excerpt.
2. ~1.6% of triples were LLM-grounded but the entity name is absent from the
   prose entirely (named only in an SAI section, or a mismatch). These 170 are
   now DROPPED rather than emitted as targets without textual support.

Result: 10519/10519 = 100% of target triples present in input_text (was 64%).
332 samples, train/val/test re-split, no trust leakage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Herzog 2026-06-10 16:17:14 +02:00
parent 216fd9876c
commit 50f67bcbe0

View File

@ -1177,40 +1177,50 @@ _ROLE_HINT = ("custodian", "custody", "advis", "transfer agent", "administrat",
# extraction signal; the residual time-mismatch is documented rather than flagged.
def _evidence_excerpt(text_norm_full, raw_text, label, predicate=None):
"""Cut a coherent excerpt of raw_text around the BEST mention of the entity.
def _name_positions(label, low):
"""All start positions where the entity name actually occurs in `low`.
A common firm token (e.g. "alliancebernstein") appears hundreds of times, so
the first occurrence is usually boilerplate. We therefore score every
occurrence of the name's most distinctive token by whether a role keyword
(custodian/adviser/transfer agent/...) appears within +/- a window, and pick
the occurrence with the nearest role hint -- that is the actual service-provider
statement, the evidence we want. Falls back to the consecutive-tokens match,
then the first token occurrence. Returns (excerpt, start) or (None, -1).
Uses the SAME matching the trainset verification uses, so an excerpt is only
produced where the name truly appears: (1) coherent full name -- significant
tokens consecutive with <=2 fillers; (2) acronym (>=3 chars). This is what
guarantees the chosen excerpt contains the name, not just one common token.
"""
tk = _alias_tokens(label)
if not tk:
return None, -1
low = raw_text.lower()
distinctive = max(tk, key=len)
occurrences = [m.start() for m in re.finditer(re.escape(distinctive), low)]
pos = -1
if occurrences:
# prefer an occurrence with a role keyword nearby
best = None
for p in occurrences:
win = low[max(0, p - 250): p + 250]
if any(k in win for k in _ROLE_HINT):
best = p
break
pos = best if best is not None else occurrences[0]
if pos == -1:
return []
pos = []
if len(tk) == 1:
pos = [m.start() for m in re.finditer(re.escape(tk[0]), low)]
else:
pat = r"\W+(?:\w+\W+){0,2}?".join(re.escape(w) for w in tk)
pos = [m.start() for m in re.finditer(pat, low)]
if not pos:
ac = _acronym(label)
if len(ac) >= 3:
m = re.search(r"\b" + re.escape(ac) + r"\b", low)
pos = m.start() if m else -1
if pos == -1:
pos = [m.start() for m in re.finditer(r"\b" + re.escape(ac) + r"\b", low)]
return pos
def _evidence_excerpt(text_norm_full, raw_text, label, predicate=None):
"""Cut an excerpt of raw_text around a REAL occurrence of the entity name.
Among the positions where the name genuinely appears (see _name_positions),
prefer one with a role keyword (custodian/adviser/transfer agent/...) nearby
-- the actual service-provider statement -- but ALWAYS fall back to the first
real occurrence so the name is guaranteed to be inside the excerpt (the
training input must contain every target). Returns (excerpt, start) or
(None, -1) only when the name does not occur at all (a real grounding error).
"""
low = raw_text.lower()
occ = _name_positions(label, low)
if not occ:
return None, -1
pos = occ[0]
for p in occ:
win = low[max(0, p - 250): p + 250]
if any(k in win for k in _ROLE_HINT):
pos = p
break
a = max(0, pos - EXCERPT_RADIUS)
b = min(len(raw_text), pos + EXCERPT_RADIUS)
return raw_text[a:b].strip(), a
@ -1233,24 +1243,35 @@ def build_trainset():
log.error("run `label` first (need %s)", SAMPLES_LABELLED_PATH); return
gold = {json.loads(l)["cik"]: json.loads(l)
for l in open(GOLD_PATH, encoding="utf-8")}
n = n_skip = 0
n = n_skip = n_drop = 0
tot_in = tot_tr = 0
with open(TRAINSET_PATH, "w", encoding="utf-8") as out:
for line in open(SAMPLES_LABELLED_PATH, encoding="utf-8"):
r = json.loads(line)
ents = gold.get(r["cik"], {}).get("entities", {})
full = r["input_text"]
kept = [t for t in r["target_triples"] if t.get("extractable")]
cand = [t for t in r["target_triples"] if t.get("extractable")]
if not cand:
n_skip += 1
continue
# Build the excerpt around each candidate's object name, then KEEP ONLY
# triples whose object name truly occurs in the text (drop the ~1.6% the
# LLM marked grounded but whose name is absent from the prose -- e.g.
# provider named only in an SAI section not in this excerpt source).
# This guarantees every TARGET triple is supported by the INPUT text.
spans = []
kept = []
for t in cand:
lbl = ents.get(t["o"], {}).get("label", t["o"])
exc, a = _evidence_excerpt(None, full, lbl)
if exc is None:
n_drop += 1 # name not in text -> not a valid sample
continue
kept.append(t)
spans.append((a, a + len(exc)))
if not kept:
n_skip += 1
continue
# collect excerpts around each grounded object name; merge overlaps.
spans = []
for t in kept:
lbl = ents.get(t["o"], {}).get("label", t["o"])
exc, a = _evidence_excerpt(None, full, lbl)
if exc:
spans.append((a, a + len(exc)))
spans.sort()
merged = []
for a, b in spans:
@ -1281,6 +1302,8 @@ def build_trainset():
print(f"\nTrainset: {n} samples -> {TRAINSET_PATH} ({n_skip} NONE-samples skipped)")
if n:
print(f" grounded triples (targets): {tot_tr:,}")
print(f" dropped (LLM-grounded but name absent from prose): {n_drop} "
f"-> 100% of targets now present in input_text")
print(f" mean input excerpt: {tot_in//n:,} chars (was multi-MB full book)")
print(f" -> split with: python build_rdf_dataset.py split --src trainset.jsonl")