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:
parent
216fd9876c
commit
50f67bcbe0
@ -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")
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user