Add LLM grounding pipeline: current-source fetch, alias + LLM role-check matching

Ensures text<->gold agreement for the text->triple dataset:
- fetch: newest full prospectus book + later 497 supplements (time-aligned with
  the current N-CEN gold; fixes stale 'largest book' picking 2-5yr-old filings)
- grounding: fast alias matcher (name present, variant-tolerant) AND an LLM
  role-check (llm_extract.py match mode, via local Ollama or remote vLLM server)
  that verifies the entity plays that ROLE -- catches right-name/wrong-role cases
  a lexical matcher over-keeps. Validated with a strong model; ~93% of gold names
  are present once granularity+currency are fixed.
- llm_extract.py: extract (baseline) + match (grounding) modes, sliding-window
  with retrieval pre-filter, claim dedup, retry, ollama/vllm backends
- build_rdf_dataset.py: --grounding alias|llm|name|context|none, whole-trust
  samples now filtered, MIN_PROSE stub guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Herzog 2026-06-09 13:45:32 +02:00
parent 00f51859e0
commit 09798eb27a
8 changed files with 1567 additions and 549 deletions

8
.gitignore vendored
View File

@ -3,9 +3,17 @@
data/rdf_poc/prose/
# Generated training samples and splits (embed raw SEC text, 100s of MB)
data/rdf_poc/samples.jsonl
data/rdf_poc/samples_full.jsonl
data/rdf_poc/train.jsonl
data/rdf_poc/val.jsonl
data/rdf_poc/test.jsonl
# LLM match inputs/outputs (embed raw text / large)
data/rdf_poc/match_input.jsonl
data/rdf_poc/match_all.jsonl
# diagnostic / scratch files
data/rdf_poc/_*.jsonl
# prose backups from iterative fetch-strategy changes
data/rdf_poc/prose_*_bak/
# Raw SEC bulk downloads (re-downloadable from sec.gov)
data/ncen/
data/nport/

View File

@ -4,14 +4,31 @@ A relationship-rich **finance dataset for text-to-RDF-triple extraction**, built
from mandatory U.S. SEC fund disclosures. Companion data pipeline to the thesis
*Magical RDF Triples and how to synthetize them*.
Each sample pairs a long natural-language **prospectus section** (input) with a
Each sample pairs a long natural-language **prospectus (incl. SAI)** (input) with a
compact graph of **entity-to-entity RDF triples** (target) — a fund *advised by*
a manager, *distributed by* an underwriter, *seriesOf* a trust, and so on. Unlike
Wikidata-derived corpora where text ≈ triples, here the input is ~2050× larger
than the output, and the target is a genuine knowledge graph rather than flat
Wikidata-derived corpora where text ≈ triples, here the input is far larger than
the output, and the target is a genuine knowledge graph rather than flat
attributes. Ground truth comes for free from parallel structured filings (N-CEN),
so no model is needed to label the relational edges.
> **Text↔triple agreement — the central requirement.** For a text→triple dataset
> a gold triple is only useful if the fact is actually stated in the input. Three
> things were needed to achieve high agreement:
> 1. **Granularity** — one sample per *trust* from the full prospectus+SAI book,
> not the tiny per-fund summary segment (where most provider names are absent).
> 2. **Currency** — the N-CEN gold is current, so the source must be too: fetch the
> *newest* full book **plus all later supplements (497)**, not an old or partial
> filing. (A "largest book" can be 25 years stale and disagree with the gold.)
> 3. **Role-correct grounding** — a name appearing in the text is not enough; it
> must appear *in that role*. A lexical/alias matcher over-keeps (e.g. a bank
> named as *securities-lending agent* is not the *custodian*; a parent company
> is not the named sub-adviser). An LLM verifier (see below) checks the role and
> is the accurate filter; the fast alias matcher is a cheap pre-screen.
>
> With (1)+(2), the entity name is present for ~93% of gold triples; the LLM
> role-check then keeps only those actually asserted in that role.
See [`dataset_description.pdf`](dataset_description.pdf) for the full scientific
description (ontology, graph structure, holdings sub-graph, baselines, training
use) and [`data/RDF_DATASET_DESIGN.md`](data/RDF_DATASET_DESIGN.md) for design
@ -25,11 +42,18 @@ The dataset is built by [`build_rdf_dataset.py`](build_rdf_dataset.py) in four s
# 1. gold — parse local N-CEN flat files into per-trust gold graphs
python build_rdf_dataset.py gold --custodian-scope primary
# 2. fetch — download all recent full prospectus books per trust from EDGAR
python build_rdf_dataset.py fetch --limit 435 --max-filings 8
# 2. fetch — newest full prospectus book + all later supplements (497) per trust,
# aligned in time with the current N-CEN gold. (--no-sai to skip SAI docs.)
python build_rdf_dataset.py fetch --limit 435 --max-filings 8 # --ciks ... to target
# 3. samples — segment prose per fund and join with gold into text->triple samples
python build_rdf_dataset.py samples
# 3. samples — one sample per TRUST (whole book -> all the trust's triples),
# filtered by the grounding mode (default alias; use llm for role-correct).
python build_rdf_dataset.py samples --whole-trust --grounding alias
# (optional) LLM role-check grounding: run the match, then rebuild with --grounding llm
python llm_extract.py --mode match --backend vllm \
--in data/rdf_poc/match_input.jsonl --out data/rdf_poc/match_all.jsonl --workers 3
python build_rdf_dataset.py samples --whole-trust --grounding llm
# 4. split — trust-level 80/10/10 train/val/test (no cross-split leakage)
python build_rdf_dataset.py split
@ -38,14 +62,61 @@ python build_rdf_dataset.py split
python build_rdf_dataset.py all --limit 435
```
[`score_baseline.py`](score_baseline.py) computes a no-model string-match baseline
and scores strong-model predictions against the gold:
The four stages above produce the complete dataset (`samples.jsonl` and the
`train`/`val`/`test` splits). Nothing below is required to build or use it.
### Scoring (optional)
[`score_baseline.py`](score_baseline.py) scores any model's predictions against
the N-CEN gold (triple-level P/R/F1, per relation), and also provides a no-model
lexical lower bound:
```bash
python score_baseline.py stringmatch # no-model lower bound
python score_baseline.py stringmatch # no-model lexical lower bound
python score_baseline.py model --pred preds.jsonl
```
## Optional: LLM extractability check
> This is a **diagnostic / quality-assurance tool only** — it is **not** part of
> the dataset build and is **not** needed to train on or use the dataset.
A target triple is only useful if the fact is actually present in the input text.
Whether that holds is a *semantic* question that lexical substring/keyword matching
cannot answer reliably (a brand name like "John Hancock" matches the fund heading,
not the administrator role; "administration services are provided by X" uses no
fixed keyword). [`llm_extract.py`](llm_extract.py) therefore lets a strong
open-source instruct model (local, via Ollama; default `qwen3.6:35b`) read each
segment + ontology and extract the triples it can find. What it extracts is, by
construction, present in the text — so its output doubles as (a) a strong
extraction baseline and (b) a semantic extractability check on the gold.
```bash
# requires a running Ollama server with the model pulled
python llm_extract.py --in data/rdf_poc/test.jsonl --out data/rdf_poc/preds_qwen.jsonl
python score_baseline.py model --pred data/rdf_poc/preds_qwen.jsonl
```
**Long inputs → sliding window.** A single full-book input (0.51 MB) is slow in
one huge-context call and suffers from "lost in the middle". `llm_extract.py`
therefore slides an overlapping window over any text longer than `--window`
(default 40 KB, `--overlap` 8 KB), extracts per window, and unions the
de-duplicated triples — so service-provider facts that live only in the SAI
section near the end are reliably seen by some window. This windowing is an
*inference strategy for the check*, not a transformation of the dataset (the
stored `input_text` always remains the full text).
**Granularity is what determines agreement.** Scored against the small per-fund
*summary* segment, only `advisedBy`/`subAdvisedBy` are reliably present (~0.50.6
recall) and `custodian` collapses to ~0.05 — the motivating Cambria case, where
the foreign sub-custodians filed in N-CEN never appear in the summary. Scored
against the **full single-book trust prospectus+SAI**, the gold names are present
~90% overall (custodian 0.81, administrator/adviser 0.94, seriesOf 0.98): the
information was never missing, the per-fund segment was simply the wrong unit. The
genuinely non-text facts (foreign sub-custodians) stay correctly absent, so they
should not be training targets. This is why the recommended build is one sample
per trust from one full book.
## Sample format
Each line of `samples.jsonl` / `train|val|test.jsonl` is a JSON record:

View File

@ -58,6 +58,12 @@ GOLD_PATH = OUT_DIR / "gold_graphs.jsonl"
PROSE_DIR = OUT_DIR / "prose"
SAMPLES_PATH = OUT_DIR / "samples.jsonl"
# Prose shorter than this is a stub: a 497 supplement fragment, or a closed-end
# fund (which files N-2/486BPOS, not the 485BPOS our fetcher targets) where only
# a tiny amendment was retrieved. Such text cannot ground a fund's triples, so
# the sample is skipped rather than emitted with non-extractable targets.
MIN_PROSE_CHARS = 50000
# RDF marker tokens from the thesis (Section 5.2)
T_START, T_PRED, T_OBJ, T_END = (
"<triple_start>", "<predicate_marker>", "<object_marker>", "<triple_end>")
@ -252,20 +258,29 @@ def build_gold(ncen_dir: Path, custodian_scope: str = "primary"):
# stage: fetch -- prospectus prose for selected registrants
# --------------------------------------------------------------------------- #
# Full statutory prospectuses (cover ALL funds of a book) vs. short supplements.
# We strongly prefer the full prospectuses; 497/497K supplements are tiny
# amendments and only used as a fallback when no full prospectus is available.
FULL_PROSPECTUS_FORMS = ("485BPOS", "485APOS")
SUPPLEMENT_FORMS = ("497", "497K")
# A full prospectus book is large; post-effective AMENDMENTS share the 485BPOS
# form but are small (only the changed sections). Require at least this size to
# count a 485BPOS as a complete book rather than an amendment.
FULL_BOOK_MIN_BYTES = 2_000_000
def _prospectus_filings(cik: str, max_filings: int):
"""Return up to max_filings recent prospectus filings for a CIK.
"""Return the current prospectus document set for a CIK.
Large fund families split their funds across SEVERAL prospectus books, so the
single most recent 485BPOS covers only a subset of the trust's funds. We
therefore collect the most recent FULL prospectuses (485BPOS/485APOS) first,
newest first, and fall back to 497/497K supplements only if no full
prospectus exists. Returns a list of (accession_nodash, primary_doc).
The gold (N-CEN) is CURRENT, so the source text must be too. A prospectus is a
living document: the statutory book (485BPOS) plus running SUPPLEMENTS/addenda
(497) that amend it (e.g. a changed custodian). We therefore return:
1. the NEWEST *full* book -- the newest 485BPOS/485APOS above a size
threshold (so a thin post-effective amendment is not mistaken for the
book; older-but-complete books are skipped in favour of the current one);
2. ALL 497/497K supplements filed AFTER that book's date -- the running
changes a reader would apply on top of it.
Concatenated, this is the up-to-date document a shareholder actually reads,
aligned in time with the N-CEN gold. Falls back to the largest 485BPOS (any
size) or, last, recent supplements, if no full book exists.
Returns a list of (accession_nodash, primary_doc), book first.
"""
url = f"https://data.sec.gov/submissions/CIK{cik}.json"
j = json.loads(_get(url).decode("utf-8", "replace"))
@ -273,25 +288,87 @@ def _prospectus_filings(cik: str, max_filings: int):
forms = recent.get("form", [])
accs = recent.get("accessionNumber", [])
docs = recent.get("primaryDocument", [])
sizes = recent.get("size", [0] * len(forms))
dates = recent.get("filingDate", [""] * len(forms))
def collect(form_set):
seen, out = set(), []
for i, fm in enumerate(forms): # SEC feed is newest-first
if fm in form_set and docs[i]:
acc = accs[i].replace("-", "")
if acc not in seen:
seen.add(acc)
out.append((acc, docs[i]))
return out
books = [] # (date, acc, doc, size) for full-form prospectuses
supps = [] # (date, acc, doc) for supplements
for i, fm in enumerate(forms):
if not docs[i]:
continue
if fm in FULL_PROSPECTUS_FORMS:
books.append((dates[i], accs[i].replace("-", ""), docs[i], sizes[i] or 0))
elif fm in SUPPLEMENT_FORMS:
supps.append((dates[i], accs[i].replace("-", ""), docs[i]))
full = collect(FULL_PROSPECTUS_FORMS)
chosen = full[:max_filings]
if not chosen: # fallback: this trust filed only supplements recently
chosen = collect(SUPPLEMENT_FORMS)[:max_filings]
# newest FULL book (above the size threshold); else largest book of any size
full_books = [b for b in books if b[3] >= FULL_BOOK_MIN_BYTES]
if full_books:
book = max(full_books, key=lambda b: b[0]) # newest complete book
elif books:
book = max(books, key=lambda b: b[3]) # largest available
else:
# no full prospectus at all -> most recent supplements only
supps.sort(reverse=True)
return [(a, d) for _dt, a, d in supps[:max_filings]]
book_date = book[0]
chosen = [(book[1], book[2])]
# all supplements filed on/after the book's date, newest first
later = sorted([s for s in supps if s[0] >= book_date], reverse=True)
for _dt, a, d in later:
if len(chosen) >= max_filings:
break
chosen.append((a, d))
return chosen
def fetch_prose(limit: int, max_filings: int = 8):
# An SAI is identified by the appointment sentences it contains for the very
# service-provider roles that the summary prospectus omits.
_SAI_SIGNAL = re.compile(
r"serves as (?:custodian|administrator|transfer agent|sub-administrator)"
r"|as custodian to|as administrator to|statement of additional information",
re.I)
def _sai_documents(cik: str, acc: str, primary_doc: str):
"""Return extra HTM documents in an accession that look like the SAI.
The SAI is filed as a separate document inside the same 485BPOS accession as
the prospectus. We list the accession's index, fetch each non-primary HTM
document, and keep those whose text contains role-appointment sentences
(\\code{serves as custodian}, etc.). Returns a list of extracted-text strings.
"""
try:
idx = json.loads(_get(
f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{acc}/index.json"
).decode("utf-8", "replace"))
except Exception:
return []
out = []
items = idx.get("directory", {}).get("item", [])
for it in items:
name = it.get("name", "")
if not name.lower().endswith((".htm", ".html")):
continue
if name == primary_doc or name.lower().startswith("r"): # skip primary + XBRL R-files
continue
try:
raw = _get(
f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{acc}/{name}"
).decode("utf-8", "replace")
except Exception:
continue
if not _SAI_SIGNAL.search(raw):
continue
txt = html_to_text(raw)
if len(txt) >= 5000:
out.append(txt)
return out
def fetch_prose(limit: int, max_filings: int = 8, include_sai: bool = True,
only_ciks=None):
"""Fetch ALL recent prospectus filings per trust and concatenate per CIK.
For each selected trust, up to `max_filings` recent prospectus filings are
@ -299,6 +376,13 @@ def fetch_prose(limit: int, max_filings: int = 8):
<cik>.txt, separated by a form-feed marker. This maximises the chance that
every N-CEN fund of the trust has its prospectus section present, raising
per-fund segmentation coverage.
When `include_sai` is set (default), the Statement of Additional Information
(SAI) document(s) in each accession are also appended. The SAI names the
administrator, transfer agent and (primary) custodian in role-appointment
sentences that the summary prospectus omits, which is what makes those
relations prose-grounded; foreign sub-custodians remain absent and so stay
correctly excluded by context-grounding.
"""
if not GOLD_PATH.exists():
log.error("run `gold` first"); return
@ -306,6 +390,9 @@ def fetch_prose(limit: int, max_filings: int = 8):
rows = [json.loads(l) for l in open(GOLD_PATH, encoding="utf-8")]
# prefer trusts with the most edges (richer graphs) for the PoC slice
rows.sort(key=lambda r: -len(r["triples"]))
if only_ciks:
want = set(only_ciks)
rows = [r for r in rows if r["cik"] in want]
done = 0
tot_filings = 0
for r in rows:
@ -329,6 +416,8 @@ def fetch_prose(limit: int, max_filings: int = 8):
txt = html_to_text(raw)
if len(txt) >= 2000:
parts.append(txt)
if include_sai:
parts.extend(_sai_documents(cik, acc, doc))
except Exception as e:
log.debug(" filing %s failed: %s", acc, e)
if not parts:
@ -406,25 +495,220 @@ def _gnorm(s: str) -> str:
return re.sub(r"\s+", " ", s).strip()
def annotate_grounding(triples, entities, text):
"""Mark each triple as prose-grounded if its object name appears in `text`.
# Keywords that signal each relation is being asserted *about* the named entity.
# Context-grounding requires one of these near the object name, so that a name
# mentioned in the wrong role (e.g. an adviser firm that is ALSO the administrator,
# but only ever called "adviser" in the text) is not counted for that relation.
#
# For roles whose bare word is too common in boilerplate ("...including the
# custodian, and the transfer agent..."), we require an APPOINTMENT phrasing
# ("serves as custodian", "acts as administrator") rather than the bare word, so
# that a generic mention near an unrelated name cannot count. This is what makes
# the safeguard genuinely catch the custodian case.
_REL_KEYWORDS = {
"advisedBy": ["advis", "investment manager", "the manager"],
"subAdvisedBy": ["sub-advis", "subadvis", "sub advis", "sub-adviser"],
"transferAgent": ["transfer agent"],
"underwrittenBy": ["distributor", "underwriter"],
"seriesOf": ["series of", "a series", "each a series"],
# appointment-phrased (role word alone is boilerplate):
"administrator": ["as administrator", "as the administrator", "administrator to",
"administrator for", "administrator of the", "administration services"],
"custodian": ["as custodian", "as the custodian", "custodian to",
"custodian for", "custodian of the", "serves as custodian",
"acts as custodian"],
}
_CTX_WINDOW = 200 # chars on each side of the name occurrence
A triple is only useful as a TEXT->triple training target if the fact can be
found in the input. This adds a boolean `grounded` to each triple (the
normalized object label occurs as a substring of the normalized input), so
training/eval can restrict to grounded triples. Returns (annotated_triples,
n_grounded). Mutates copies, not the input dicts.
def _context_grounded(name_norm, predicate, text_low):
"""True iff the object name occurs near a keyword for its relation in text_low.
Uses the most distinctive token of the name (longest >=5 chars) to locate
candidate positions, then checks a +/- window for any relation keyword. This
distinguishes "X serves as administrator" (valid) from a bare mention of X.
"""
kws = _REL_KEYWORDS.get(predicate, [])
if not kws or not name_norm:
return False
toks = [w for w in name_norm.split() if len(w) >= 5] or name_norm.split()
if not toks:
return False
tok = toks[0]
for m in re.finditer(re.escape(tok), text_low):
win = text_low[max(0, m.start() - _CTX_WINDOW): m.start() + _CTX_WINDOW]
if any(k in win for k in kws):
return True
return False
# Stopwords for fuzzy name grounding (generic words shared by many firm names).
_GND_STOP = {"of", "and", "the", "fund", "funds", "services", "service", "asset",
"management", "investment", "investments", "company", "co", "group",
"capital", "advisors", "advisers", "global", "trust", "inc", "llc"}
def _name_tokens(name_norm):
return [w for w in name_norm.split() if w not in _GND_STOP and len(w) > 2]
def _fuzzy_grounded(name_norm, ntext_tokens):
"""True iff a strong majority of the name's distinctive tokens occur in text.
This is the robust middle ground between exact-substring (which false-positives
on shared brand words like "John Hancock") and the strict appointment-keyword
check (which false-negatives on valid paraphrases). A name counts as grounded
when >=2/3 of its non-stopword tokens appear in the document, AND at least one
distinctive (long) token does -- so a fund whose administrator is literally
named is captured, while an unrelated brand mention is not.
"""
toks = _name_tokens(name_norm)
if not toks:
return False
present = sum(1 for w in toks if w in ntext_tokens)
if present / len(toks) < 0.67:
return False
# require at least one *distinctive* token (length >= 5) to be present,
# so two short generic tokens cannot ground a name on their own
return any(len(w) >= 5 and w in ntext_tokens for w in toks)
_ALIAS_STOP = {"the", "inc", "llc", "lp", "llp", "company", "co", "corp",
"corporation", "trust", "fund", "funds", "of", "and", "group",
"na", "national", "association", "incorporated", "limited", "ltd"}
def _alias_tokens(label):
return [w for w in re.sub(r"[^a-z0-9 ]", " ", (label or "").lower()).split()
if w not in _ALIAS_STOP and len(w) >= 2]
def _acronym(label):
return "".join(w[0] for w in _alias_tokens(label))
def _alias_grounded(label, text_norm):
"""True iff the entity NAME occurs in the text, tolerant to surface variants.
This is exactly the dataset requirement: the triple's value (the entity name)
must appear in the document, allowing variation (legal-form suffixes, filler
words, common abbreviations) but not mere scattered token overlap. Two ways to
match, both requiring the name to appear *coherently*:
1. the significant tokens occur consecutively (allowing up to two filler
words between them), e.g. "State Street Bank and Trust Company" matches
"State Street ... Trust";
2. the acronym of the significant tokens (>=3 letters) occurs as a word,
e.g. "BNY"/"JPM".
Validated against a 122B LLM verifier: where they disagree, the name was in
fact present verbatim, i.e. this rule is the faithful test of the requirement.
`text_norm` is the lowercased text with punctuation collapsed to spaces.
"""
tk = _alias_tokens(label)
if not tk:
return False
# Cheap gate first: the most distinctive token must appear at all. Substring
# `in` is far faster than regex over multi-MB texts, so this avoids running the
# flexible pattern on the vast majority of (name, document) pairs.
distinctive = max(tk, key=len)
if (" " + distinctive) not in (" " + text_norm):
ac = _acronym(label)
return len(ac) >= 3 and (" " + ac + " ") in (" " + text_norm + " ")
if len(tk) == 1:
return re.search(r"\b" + re.escape(tk[0]) + r"\b", text_norm) is not None
pat = r"\b" + r"\W+(?:\w+\W+){0,2}?".join(re.escape(w) for w in tk) + r"\b"
if re.search(pat, text_norm):
return True
ac = _acronym(label)
return len(ac) >= 3 and re.search(r"\b" + re.escape(ac) + r"\b", text_norm) is not None
def annotate_grounding(triples, entities, text):
"""Annotate each triple with grounding flags against `text`.
A triple is only a valid TEXT->triple target if its object NAME appears in the
input. Flags recorded per triple:
- `alias_grounded` : the name occurs coherently, tolerant to legal-form
suffixes / filler words / acronyms (the recommended,
requirement-faithful filter; fast, deterministic);
- `grounded` : the normalized name occurs verbatim (strict substring);
- `context_grounded` : the name occurs near an appointment keyword for its
relation (strictest; e.g. "X serves as custodian").
Returns (annotated_triples, n_alias, n_grounded, n_context).
"""
ntext = _gnorm(text)
text_norm = re.sub(r"[^a-z0-9 ]", " ", text.lower())
tlow = text.lower()
out = []
n_grounded = 0
n_alias = n_grounded = n_ctx = 0
for t in triples:
olbl = entities.get(t["o"], {}).get("label", "")
no = _gnorm(olbl)
al = _alias_grounded(olbl, text_norm)
g = bool(no) and no in ntext
cg = g and _context_grounded(no, t["p"], tlow)
n_alias += int(al)
n_grounded += int(g)
out.append({**t, "grounded": g})
return out, n_grounded
n_ctx += int(cg)
out.append({**t, "alias_grounded": al, "grounded": g, "context_grounded": cg})
return out, n_alias, n_grounded, n_ctx
# How strict the build is about triples actually appearing in the input text.
# The build always ANNOTATES all flags; this only controls which (if any) is used
# to FILTER the standard dataset (the full, unfiltered set is always written
# alongside, see _build_samples_per_fund).
# "alias" (default) -> keep triples whose object name occurs coherently in the
# text, tolerant to legal-form/filler/acronym variation.
# Fast, deterministic, and the faithful test of the
# requirement (validated against a 122B LLM verifier).
# "name" -> keep triples whose normalized name occurs verbatim.
# "context" -> strictest: name near an appointment keyword.
# "none" -> keep all triples (annotate only, no filtering).
# "llm" -> use the LLM match verdict (llm_extract.py match mode):
# keeps a triple only if a strong model confirmed the
# entity plays that ROLE in the text. Most accurate (catches
# right-name/wrong-role cases, e.g. a bank named as
# securities-lending agent but not custodian); requires a
# prior match run (see LLM_MATCH_PATH).
GROUNDING_MODE = "alias"
# Per-(cik,predicate,object-IRI) llm_grounded verdicts, loaded on demand from a
# match run. Keyed (cik, predicate, o_iri) -> bool.
LLM_MATCH_PATH = OUT_DIR / "match_all.jsonl"
_LLM_VERDICTS = None
def _load_llm_verdicts():
global _LLM_VERDICTS
if _LLM_VERDICTS is not None:
return _LLM_VERDICTS
_LLM_VERDICTS = {}
if LLM_MATCH_PATH.exists():
for line in open(LLM_MATCH_PATH, encoding="utf-8"):
r = json.loads(line)
cik = r.get("cik")
for t in r.get("triples", []):
iri = t.get("o_iri", t.get("o"))
_LLM_VERDICTS[(cik, t.get("p"), iri)] = bool(t.get("llm_grounded"))
log.info("loaded %d LLM match verdicts from %s", len(_LLM_VERDICTS), LLM_MATCH_PATH)
return _LLM_VERDICTS
def filter_grounded(triples, cik=None):
"""Drop triples that fail the active GROUNDING_MODE. Returns kept triples.
This is what *enforces* rather than merely measures that every target
fact is present in the input, so a non-prose relation (like the foreign
sub-custodians) cannot silently enter the standard dataset.
"""
if GROUNDING_MODE == "llm":
v = _load_llm_verdicts()
return [t for t in triples if v.get((cik, t["p"], t["o"]), False)]
if GROUNDING_MODE == "none":
return triples
key = {"context": "context_grounded", "name": "grounded",
"alias": "alias_grounded"}[GROUNDING_MODE]
return [t for t in triples if t.get(key)]
def ontology_schema(triples, entities) -> dict:
@ -531,8 +815,10 @@ def build_samples(per_fund: bool = True):
return _build_samples_per_fund()
gold = {json.loads(l)["cik"]: json.loads(l)
for l in open(GOLD_PATH, encoding="utf-8")}
n = 0
n = n_skip = 0
n_tot_triples = n_kept_triples = 0
tot_text = tot_json = 0
outfull = open(SAMPLES_FULL_PATH, "w", encoding="utf-8")
with open(SAMPLES_PATH, "w", encoding="utf-8") as out:
for prose_file in sorted(PROSE_DIR.glob("*.txt")):
cik = prose_file.stem
@ -540,36 +826,88 @@ def build_samples(per_fund: bool = True):
if not g:
continue
text = prose_file.read_text(encoding="utf-8")
triples, ents = g["triples"], g["entities"]
target = serialize_triples(triples, ents)
schema = ontology_schema(triples, ents)
sample = {
"cik": cik,
"trust_name": g["trust_name"],
"input_text": text,
"ontology": schema,
"target_triples": triples,
"target_serialized": target,
"stats": {
"input_chars": len(text),
"target_chars": len(target),
"n_triples": len(triples),
"n_entities": len(ents),
"text_to_json_ratio": round(len(text) / max(1, len(target)), 1),
},
}
out.write(json.dumps(sample, ensure_ascii=False) + "\n")
if len(text) < MIN_PROSE_CHARS:
n_skip += 1
continue # stub prose (supplement / closed-end fund) — skip
ents = g["entities"]
cand, n_al, n_gnd, n_ctx = annotate_grounding(g["triples"], ents, text)
n_tot_triples += len(cand)
_write_sample(out, outfull, f"{cik}:ALL", cik, g["trust_name"],
None, "", False, text, cand, ents)
kept = filter_grounded(cand, cik)
if not kept:
continue
n_kept_triples += len(kept)
target = serialize_triples(kept, ents)
n += 1
tot_text += len(text)
tot_json += len(target)
log.info("wrote %d samples -> %s", n, SAMPLES_PATH)
outfull.close()
log.info("wrote %d samples -> %s (full set -> %s)", n, SAMPLES_PATH, SAMPLES_FULL_PATH)
if n:
print(f"\n{n} samples")
agree = 100 * n_kept_triples / max(1, n_tot_triples)
print(f"\n{n} trust samples ({n_skip} stub-prose skipped); grounding={GROUNDING_MODE}")
print(f" text<->gold agreement: {n_kept_triples}/{n_tot_triples} "
f"triples = {agree:.0f}% (kept after grounding filter)")
print(f" mean input : {tot_text//n:>8,} chars")
print(f" mean target : {tot_json//n:>8,} chars")
print(f" mean ratio : {tot_text/max(1,tot_json):>8.1f} : 1 (text : json)")
SAMPLES_FULL_PATH = OUT_DIR / "samples_full.jsonl"
def _write_sample(out, outfull, sid, cik, trust_name, fund, series_id,
segmented, text, cand_triples, ents):
"""Write the filtered record to `out` and the full annotated record to `outfull`.
`cand_triples` are all candidate triples already annotated with grounding
flags. The standard file (`out`) carries only the grounding-filtered triples
(every target is text-present); the full file (`outfull`) carries ALL triples
with their flags, for traceability and alternative thresholds.
"""
def ref_ents(triples):
ref = set()
for t in triples:
ref.add(t["s"]); ref.add(t["o"])
return {k: ents[k] for k in ref if k in ents}
# full record (all triples + flags)
fe = ref_ents(cand_triples)
n_al = sum(t.get("alias_grounded") for t in cand_triples)
n_g = sum(t.get("grounded") for t in cand_triples)
n_cx = sum(t.get("context_grounded") for t in cand_triples)
outfull.write(json.dumps({
"sample_id": sid, "cik": cik, "trust_name": trust_name, "fund": fund,
"series_id": series_id, "segmented": segmented, "input_text": text,
"ontology": ontology_schema(cand_triples, fe),
"target_triples": cand_triples,
"stats": {"input_chars": len(text), "n_triples": len(cand_triples),
"n_alias_grounded": n_al, "n_grounded": n_g,
"n_context_grounded": n_cx},
}, ensure_ascii=False) + "\n")
# standard record (filtered triples only)
kept = filter_grounded(cand_triples, cik)
if not kept:
return
se = ref_ents(kept)
target = serialize_triples(kept, se)
rec = {
"sample_id": sid, "cik": cik, "trust_name": trust_name, "fund": fund,
"series_id": series_id, "segmented": segmented,
"grounding_mode": GROUNDING_MODE,
"input_text": text, "ontology": ontology_schema(kept, se),
"target_triples": kept,
"target_serialized": target,
"target_serialized_plain": serialize_triples_plain(kept, se),
"stats": {"input_chars": len(text), "target_chars": len(target),
"n_triples": len(kept), "n_entities": len(se),
"text_to_json_ratio": round(len(text) / max(1, len(target)), 1)},
}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
def _build_samples_per_fund():
"""One sample per fund: the fund's prospectus section -> the fund's subgraph.
@ -579,13 +917,17 @@ def _build_samples_per_fund():
fact about that fund's trust). Funds whose section cannot be located in the
prose are skipped and counted; trusts where nothing can be located fall back
to a single whole-trust sample so no data is silently dropped.
Two files are written: SAMPLES_PATH (grounding-filtered, training-ready) and
SAMPLES_FULL_PATH (all triples with grounding flags, for traceability).
"""
gold = {json.loads(l)["cik"]: json.loads(l)
for l in open(GOLD_PATH, encoding="utf-8")}
n = 0
n_funds_total = n_funds_located = n_fallback_trusts = 0
n_funds_total = n_funds_located = n_fallback_trusts = n_dropped_empty = 0
tot_text = tot_json = 0
ratios = []
outfull = open(SAMPLES_FULL_PATH, "w", encoding="utf-8")
with open(SAMPLES_PATH, "w", encoding="utf-8") as out:
for prose_file in sorted(PROSE_DIR.glob("*.txt")):
cik = prose_file.stem
@ -593,6 +935,8 @@ def _build_samples_per_fund():
if not g:
continue
text = prose_file.read_text(encoding="utf-8")
if len(text) < MIN_PROSE_CHARS:
continue # stub prose (supplement / closed-end fund) — skip
ents = g["entities"]
# group triples by subject fund IRI; collect trust-level edges
fund_iris = {e_iri for e_iri, e in ents.items() if e["type"] == "Fund"}
@ -611,68 +955,50 @@ def _build_samples_per_fund():
label_to_iri = {v: k for k, v in fund_label.items()}
if not segs: # whole-trust fallback (no section located)
n_fallback_trusts += 1
triples = g["triples"]
triples, n_gnd = annotate_grounding(triples, ents, text)
target = serialize_triples(triples, ents)
target_plain = serialize_triples_plain(triples, ents)
rec = {
"sample_id": f"{cik}:ALL", "cik": cik, "trust_name": g["trust_name"],
"fund": None, "segmented": False,
"input_text": text, "ontology": ontology_schema(triples, ents),
"target_triples": triples,
"target_serialized": target,
"target_serialized_plain": target_plain,
"stats": {"input_chars": len(text), "target_chars": len(target),
"n_triples": len(triples), "n_grounded": n_gnd,
"n_entities": len(ents),
"text_to_json_ratio": round(len(text) / max(1, len(target)), 1)},
}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
n += 1; tot_text += len(text); tot_json += len(target)
ratios.append(rec["stats"]["text_to_json_ratio"])
cand = g["triples"]
cand, n_al, n_gnd, n_ctx = annotate_grounding(cand, ents, text)
_write_sample(out, outfull, f"{cik}:ALL", cik, g["trust_name"],
None, "", False, text, cand, ents)
_fk = filter_grounded(cand, cik)
if _fk:
n_fallback_trusts += 1
tgt = serialize_triples(_fk, ents)
n += 1; tot_text += len(text); tot_json += len(tgt)
ratios.append(round(len(text) / max(1, len(tgt)), 1))
else:
n_dropped_empty += 1
continue
for label, seg in segs.items():
f_iri = label_to_iri.get(label)
if not f_iri:
continue
triples = list(by_fund.get(f_iri, [])) + list(trust_edges)
if not triples:
cand = list(by_fund.get(f_iri, [])) + list(trust_edges)
if not cand:
continue
n_funds_located += 1
# restrict entities to those referenced by this fund's triples
ref = {trust_iri}
for t in triples:
ref.add(t["s"]); ref.add(t["o"])
sub_ents = {k: ents[k] for k in ref if k in ents}
triples, n_gnd = annotate_grounding(triples, sub_ents, seg)
target = serialize_triples(triples, sub_ents)
target_plain = serialize_triples_plain(triples, sub_ents)
rec = {
"sample_id": f"{cik}:{ents[f_iri].get('series_id') or _slug(label)}",
"cik": cik, "trust_name": g["trust_name"],
"fund": label, "series_id": ents[f_iri].get("series_id", ""),
"segmented": True,
"input_text": seg, "ontology": ontology_schema(triples, sub_ents),
"target_triples": triples,
"target_serialized": target,
"target_serialized_plain": target_plain,
"stats": {"input_chars": len(seg), "target_chars": len(target),
"n_triples": len(triples), "n_grounded": n_gnd,
"n_entities": len(sub_ents),
"text_to_json_ratio": round(len(seg) / max(1, len(target)), 1)},
}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
n += 1; tot_text += len(seg); tot_json += len(target)
ratios.append(rec["stats"]["text_to_json_ratio"])
cand, n_al, n_gnd, n_ctx = annotate_grounding(cand, ents, seg)
sid = f"{cik}:{ents[f_iri].get('series_id') or _slug(label)}"
_write_sample(out, outfull, sid, cik, g["trust_name"], label,
ents[f_iri].get("series_id", ""), True, seg, cand, ents)
kept = filter_grounded(cand, cik)
if kept:
tgt = serialize_triples(kept, ents)
n += 1; tot_text += len(seg); tot_json += len(tgt)
ratios.append(round(len(seg) / max(1, len(tgt)), 1))
else:
n_dropped_empty += 1
outfull.close()
import statistics as _st
log.info("wrote %d per-fund samples -> %s", n, SAMPLES_PATH)
log.info("wrote %d per-fund samples -> %s (full set -> %s)",
n, SAMPLES_PATH, SAMPLES_FULL_PATH)
if n:
cov = n_funds_located / max(1, n_funds_total)
print(f"\n{n} samples ({n_funds_located}/{n_funds_total} funds located, "
f"coverage {cov:.0%}; {n_fallback_trusts} trusts fell back to whole-doc)")
print(f"\n{n} samples (grounding={GROUNDING_MODE}; "
f"{n_funds_located}/{n_funds_total} funds located, coverage {cov:.0%}; "
f"{n_fallback_trusts} whole-doc fallbacks; "
f"{n_dropped_empty} units dropped with 0 grounded triples)")
print(f" mean input : {tot_text//n:>8,} chars")
print(f" mean target : {tot_json//n:>8,} chars")
print(f" median ratio: {_st.median(ratios):>8.1f} : 1 (text : json)")
@ -738,9 +1064,15 @@ def main():
f = sub.add_parser("fetch"); f.add_argument("--limit", type=int, default=25)
f.add_argument("--max-filings", type=int, default=8,
help="max prospectus filings to fetch & concatenate per trust")
f.add_argument("--no-sai", action="store_true", help="do not append SAI documents")
f.add_argument("--ciks", nargs="*", help="only fetch these trust CIKs")
s = sub.add_parser("samples")
s.add_argument("--whole-trust", action="store_true",
help="one sample per trust instead of per-fund segmentation")
s.add_argument("--grounding", choices=["alias", "llm", "name", "context", "none"], default="alias",
help="context=keep triples whose name+relation-keyword appear "
"(default, enforces text-extractability); name=name anywhere; "
"none=keep all, only annotate")
sp = sub.add_parser("split")
sp.add_argument("--val-frac", type=float, default=0.10)
sp.add_argument("--test-frac", type=float, default=0.10)
@ -749,19 +1081,24 @@ def main():
a.add_argument("--max-filings", type=int, default=8)
a.add_argument("--custodian-scope", choices=["primary", "all", "none"], default="primary")
a.add_argument("--whole-trust", action="store_true")
a.add_argument("--grounding", choices=["alias", "llm", "name", "context", "none"], default="alias")
a.add_argument("--val-frac", type=float, default=0.10)
a.add_argument("--test-frac", type=float, default=0.10)
args = ap.parse_args()
global GROUNDING_MODE
if args.cmd == "gold":
build_gold(Path(args.ncen), custodian_scope=args.custodian_scope)
elif args.cmd == "fetch":
fetch_prose(args.limit, max_filings=args.max_filings)
fetch_prose(args.limit, max_filings=args.max_filings,
include_sai=not args.no_sai, only_ciks=args.ciks)
elif args.cmd == "samples":
GROUNDING_MODE = args.grounding
build_samples(per_fund=not args.whole_trust)
elif args.cmd == "split":
build_split(val_frac=args.val_frac, test_frac=args.test_frac)
elif args.cmd == "all":
GROUNDING_MODE = args.grounding
build_gold(Path(args.ncen), custodian_scope=args.custodian_scope)
fetch_prose(args.limit, max_filings=args.max_filings)
build_samples(per_fund=not args.whole_trust)

File diff suppressed because one or more lines are too long

494
llm_extract.py Normal file
View File

@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""
LLM extraction baseline for the SEC prospectus -> RDF triple dataset.
A strong open-source instruct model (default qwen3.6:35b via local Ollama) reads
each fund's prospectus segment together with the target ontology and extracts the
service-provider triples. This serves two purposes for the thesis:
1. STRONG BASELINE: the prompted model's triples, scored against the N-CEN gold
with score_baseline.py, measure how well a state-of-the-art model does the
task without finetuning -- the comparison point for the four finetuned models.
2. SEMANTIC GROUNDING: whether a gold triple is actually extractable from the
text is a semantic question that lexical substring/keyword matching cannot
answer (e.g. "John Hancock" matches the fund heading, not the administrator
role; "administration services are provided by X" is a valid mention no fixed
keyword catches). What the strong model extracts from the segment is therefore
a far better grounding signal than the lexical flag.
The model is asked to extract ONLY relations present in the supplied ontology and
to copy entity names verbatim from the text, so that a fair triple-level
comparison against the gold is possible.
Usage:
# all (or a sample of) the dataset
python llm_extract.py --in data/rdf_poc/test.jsonl --out data/rdf_poc/preds_qwen.jsonl
python llm_extract.py --in data/rdf_poc/samples.jsonl --limit 50 --model qwen3.6:35b
# then score:
python score_baseline.py model --pred data/rdf_poc/preds_qwen.jsonl
"""
import argparse
import json
import re
import time
import urllib.request
from pathlib import Path
OLLAMA_URL = "http://localhost:11434/api/chat"
# Backend config (set in main()). Two backends are supported:
# "ollama" -> local Ollama (/api/chat), per-request num_ctx
# "vllm" -> remote vLLM OpenAI-compatible server (/v1/chat/completions),
# fixed served context; much higher throughput / stronger model
# (e.g. silicon.fhgr.ch:7080 serving qwen3.5-122b).
BACKEND = "ollama"
VLLM_URL = "http://silicon.fhgr.ch:7080/v1/chat/completions"
API_KEY = ""
SYSTEM = (
"You are an information-extraction engine. You read a U.S. mutual-fund "
"prospectus/SAI excerpt and extract relationships between named entities as "
"RDF-style triples. You output ONLY valid JSON, no commentary."
)
# The instruction is deliberately strict: only ontology relations, names copied
# verbatim, and an explicit 'not stated' contract so the model does NOT invent
# service providers that the text does not actually name (the whole point of the
# grounding test).
PROMPT_TMPL = """\
Extract relationships for the fund described in the TEXT below.
Allowed relations (subject -> predicate -> object type), from the ontology:
{ontology}
Rules:
- Extract a triple ONLY if the TEXT explicitly states that relationship for this
fund. Do NOT use outside knowledge. If a relation is not stated, omit it.
- Copy each entity name VERBATIM as it appears in the TEXT (full legal name).
- Use only the predicates listed above.
- Output JSON of the form:
{{"triples": [{{"subject": "...", "predicate": "...", "object": "..."}}, ...]}}
TEXT:
\"\"\"
{text}
\"\"\"
"""
MAX_TEXT_CHARS = 1_200_000 # ~300k tokens; effectively the whole single book
# (set lower only to bound latency; too-low truncates
# the SAI section where service providers are named)
def ollama_chat(model, system, prompt, timeout=600, num_ctx=32768):
"""Send a chat request to the active backend; return the message content.
Name kept for backward compatibility with existing call sites.
"""
messages = [{"role": "system", "content": system},
{"role": "user", "content": prompt}]
if BACKEND == "vllm":
body = {
"model": model,
"messages": messages,
"temperature": 0,
# Reasoning model: it spends several thousand tokens "thinking" before
# emitting JSON. Too small a budget -> it never reaches the answer and
# returns empty content. Keep this generous.
"max_tokens": 8000,
"response_format": {"type": "json_object"},
}
headers = {"Content-Type": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
req = urllib.request.Request(
VLLM_URL, data=json.dumps(body).encode("utf-8"), headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8", "replace"))
# reasoning models can return content=null (spent budget "thinking");
# fall back to an empty string so callers' parsers degrade gracefully.
return resp["choices"][0]["message"].get("content") or ""
# default: ollama
body = {
"model": model,
"messages": messages,
"stream": False,
"format": "json", # ask Ollama to constrain to JSON
"options": {"temperature": 0, "num_ctx": num_ctx},
}
req = urllib.request.Request(
OLLAMA_URL, data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8", "replace"))
return resp["message"]["content"]
def parse_triples(raw):
"""Parse the model's JSON; tolerate stray text/code-fences around it."""
s = raw.strip()
s = re.sub(r"^```(?:json)?|```$", "", s, flags=re.MULTILINE).strip()
try:
obj = json.loads(s)
except Exception:
m = re.search(r"\{.*\}", s, re.S)
if not m:
return []
try:
obj = json.loads(m.group(0))
except Exception:
return []
out = []
for t in obj.get("triples", []):
s_, p_, o_ = t.get("subject"), t.get("predicate"), t.get("object")
if s_ and p_ and o_:
out.append({"s": s_, "p": p_, "o": o_})
return out
def ontology_str(ont):
lines = []
for st, preds in ont.items():
for p, ots in preds.items():
lines.append(f" {st} -> {p} -> {', '.join(ots)}")
return "\n".join(lines)
def _dedup(triples):
seen, out = set(), []
for t in triples:
k = (t["p"].lower(), t["o"].strip().lower())
if k not in seen:
seen.add(k)
out.append(t)
return out
def extract_windowed(model, ontology, text, win_chars, overlap, num_ctx, timeout=600):
"""Slide a window over a long document, extract per window, union the results.
A single huge-context call over a 500KB+ book is slow and suffers from
'lost in the middle'; instead we pass overlapping windows (each small enough
for fast inference and a modest context) and take the de-duplicated union of
the triples found in each. Service-provider facts that live only in the SAI
section near the end are thus reliably seen by some window. Overlap keeps a
provider sentence that straddles a window boundary intact in at least one
window.
"""
step = max(1, win_chars - overlap)
windows = [text[i:i + win_chars] for i in range(0, max(1, len(text)), step)]
all_triples, n_win = [], 0
for w in windows:
if len(w.strip()) < 200:
continue
prompt = PROMPT_TMPL.format(ontology=ontology, text=w)
ctx = min(num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
try:
raw = ollama_chat(model, SYSTEM, prompt, timeout=timeout, num_ctx=ctx)
all_triples.extend(parse_triples(raw))
except Exception as e:
print(f" window error: {e}")
n_win += 1
return _dedup(all_triples), n_win
# --------------------------------------------------------------------------- #
# match mode: ask the LLM whether each GOLD triple is supported by the text.
# This sidesteps the surface-form problem entirely: the model decides semantic
# equivalence (e.g. "BNY Mellon" == "The Bank of New York Mellon", "the
# Administrator" anaphora) which no fuzzy string rule can capture.
# --------------------------------------------------------------------------- #
MATCH_SYSTEM = (
"You verify facts against a source text. Given a U.S. mutual-fund "
"prospectus/SAI excerpt and a list of candidate (entity, role, fund) claims, "
"you decide for EACH claim whether the excerpt supports it. Treat name "
"variants, abbreviations and anaphora as the same entity (e.g. 'BNY Mellon' "
"= 'The Bank of New York Mellon'). Output ONLY valid JSON."
)
MATCH_PROMPT = """\
For each numbered CLAIM below, decide whether the TEXT supports it for the fund.
A claim is supported only if the TEXT states that the named entity plays that
role (advisedBy=investment adviser, subAdvisedBy=sub-adviser,
transferAgent=transfer agent, custodian=custodian, administrator=administrator,
underwrittenBy=distributor/underwriter, seriesOf=the fund is a series of that
trust). Use ONLY the TEXT; treat name variants/abbreviations as equal.
Output JSON: {{"results": [{{"id": <n>, "supported": true|false}}, ...]}} with one
entry per claim id.
CLAIMS:
{claims}
TEXT:
\"\"\"
{text}
\"\"\"
"""
def _claim_lines(claims):
return "\n".join(
f' {i}. entity="{c["o"]}" role={c["p"]}' for i, c in enumerate(claims))
def parse_results(raw, n):
"""Parse {"results":[{id,supported}]} -> list[bool] of length n (default False)."""
out = [False] * n
s = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.MULTILINE).strip()
try:
obj = json.loads(s)
except Exception:
m = re.search(r"\{.*\}", s, re.S)
if not m:
return out
try:
obj = json.loads(m.group(0))
except Exception:
return out
for r in obj.get("results", []):
try:
i = int(r["id"])
except (KeyError, ValueError, TypeError):
continue
if 0 <= i < n:
out[i] = bool(r.get("supported"))
return out
# Relation keywords that mark a window as possibly relevant to service-provider
# claims. A window with none of these AND no gold-name token cannot support any
# claim, so it is skipped (retrieval pre-filter) -- this avoids sending the bulk
# of a multi-MB book (holdings tables, risk boilerplate) to the LLM.
_RELEVANCE_KW = ("custodian", "custody", "adviser", "advisor", "sub-advis",
"transfer agent", "administrat", "distributor", "underwriter",
"serves as", "acts as", "series of", "a series")
_STOPTOK = {"the", "inc", "llc", "lp", "company", "trust", "fund", "funds", "of",
"and", "group", "capital", "asset", "management", "services", "co",
"national", "association", "bank"}
def _name_tokens(label):
return [w for w in re.sub(r"[^a-z0-9 ]", " ", (label or "").lower()).split()
if w not in _STOPTOK and len(w) >= 4]
def _window_relevant(w_low, name_tokens):
if any(k in w_low for k in _RELEVANCE_KW):
return True
return any(tok in w_low for tok in name_tokens)
def match_windowed(model, gold_triples, text, win_chars, overlap, num_ctx, timeout=600):
"""For each gold triple, is it supported somewhere in the text?
Retrieval pre-filter: only windows that contain a relation keyword or a
distinctive token of some gold object name are sent to the LLM (the rest of a
multi-MB book -- holdings tables, risk sections -- cannot support a
service-provider claim). All gold triples are checked together per kept window
(one call/window); a triple counts as grounded if ANY window supports it.
Returns (list[bool], n_windows_sent).
"""
claims = _claim_lines(gold_triples)
name_tokens = set()
for t in gold_triples:
name_tokens.update(_name_tokens(t.get("o", "")))
step = max(1, win_chars - overlap)
windows = [text[i:i + win_chars] for i in range(0, max(1, len(text)), step)]
supported = [False] * len(gold_triples)
n_win = n_fail = 0
for w in windows:
if len(w.strip()) < 200:
continue
if not _window_relevant(w.lower(), name_tokens):
continue # retrieval pre-filter: no provider signal in this window
prompt = MATCH_PROMPT.format(claims=claims, text=w)
ctx = min(num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
ok = False
for attempt in range(2): # one retry on transient failure
try:
raw = ollama_chat(model, MATCH_SYSTEM, prompt, timeout=timeout, num_ctx=ctx)
res = parse_results(raw, len(gold_triples))
supported = [a or b for a, b in zip(supported, res)]
ok = True
break
except Exception as e:
if attempt == 0:
time.sleep(3)
else:
print(f" window error: {e}")
if not ok:
n_fail += 1
n_win += 1
# early exit: once every claim is supported, no need to scan further
if all(supported):
break
# If windows failed, the 'False' entries are unreliable (could be missed calls,
# not true absences). Signal this so the caller does not record bogus 0s.
return supported, n_win, n_fail
def _match_one(args, r):
"""Match all gold triples of one sample; return the annotated record."""
text = r["input_text"][:MAX_TEXT_CHARS]
gold = r.get("target_triples", [])
if not gold:
return {"sample_id": r.get("sample_id"), "cik": r["cik"],
"fund": r.get("fund"), "n_windows": 0, "triples": []}
# DEDUP claims: all funds of a trust share the same providers, so e.g. 540
# triples collapse to ~13 distinct (predicate, name) pairs. Matching the
# distinct set keeps each LLM call small (a long claim list overflows the
# answer's max_tokens and yields truncated JSON -> all False) and fast; the
# per-distinct verdict is then mapped back to every triple.
distinct, key_of = [], []
seen = {}
for t in gold:
k = (t["p"], (t.get("o") or "").strip().lower())
if k not in seen:
seen[k] = len(distinct)
distinct.append({"p": t["p"], "o": t.get("o")})
key_of.append(seen[k])
n_fail = 0
try:
if args.window and len(text) > args.window:
d_sup, n_win, n_fail = match_windowed(
args.model, distinct, text, args.window, args.overlap, args.num_ctx)
else:
prompt = MATCH_PROMPT.format(claims=_claim_lines(distinct), text=text)
ctx = min(args.num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
raw = ollama_chat(args.model, MATCH_SYSTEM, prompt, num_ctx=ctx)
d_sup = parse_results(raw, len(distinct)); n_win = 1
except Exception as e:
print(f" [{r.get('sample_id')}] ERROR: {e}")
d_sup = [False] * len(distinct); n_win = 0; n_fail = 1
ann = [{**t, "llm_grounded": bool(d_sup[key_of[i]])} for i, t in enumerate(gold)]
return {"sample_id": r.get("sample_id"), "cik": r["cik"], "fund": r.get("fund"),
"n_windows": n_win, "n_failed_windows": n_fail, "n_distinct": len(distinct),
"ok": n_fail == 0, "triples": ann}
def run_match(args):
"""LLM match mode: annotate each gold triple with llm_grounded (semantic).
Samples are processed concurrently (--workers) to exploit the vLLM server's
parallel request handling (max-num-seqs); each sample's windows still run
sequentially within its worker. Results are written as they complete.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
rows = [json.loads(l) for l in open(args.inp, encoding="utf-8")]
if args.limit:
rows = rows[:args.limit]
done = tot_g = tot_t = 0
t0 = time.time()
workers = max(1, args.workers)
with open(args.out, "w", encoding="utf-8") as out, \
ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(_match_one, args, r): r for r in rows}
for fut in as_completed(futs):
rec = fut.result()
ng = sum(t.get("llm_grounded") for t in rec["triples"])
tot_g += ng; tot_t += len(rec["triples"])
out.write(json.dumps(rec, ensure_ascii=False) + "\n"); out.flush()
done += 1
dt = time.time() - t0
print(f"[{done}/{len(rows)}] {rec.get('fund') or rec['cik']:.38s} "
f"grounded {ng}/{len(rec['triples'])} "
f"({dt/done:.1f}s/sample avg, {workers}w)")
print(f"\nmatched {done} samples -> {args.out} "
f"(LLM-grounded {tot_g}/{tot_t} = {100*tot_g/max(1,tot_t):.0f}%)")
def run_extract(args):
rows = [json.loads(l) for l in open(args.inp, encoding="utf-8")]
if args.limit:
rows = rows[:args.limit]
done = 0
t0 = time.time()
with open(args.out, "w", encoding="utf-8") as out:
for r in rows:
text = r["input_text"][:MAX_TEXT_CHARS]
ont = ontology_str(r["ontology"])
n_win = 1
try:
if args.window and len(text) > args.window:
# long document -> sliding-window extraction + union
triples, n_win = extract_windowed(
args.model, ont, text, args.window, args.overlap, args.num_ctx)
else:
prompt = PROMPT_TMPL.format(ontology=ont, text=text)
ctx = min(args.num_ctx, max(8192, int(len(prompt) / 3.2) + 2048))
triples = _dedup(parse_triples(
ollama_chat(args.model, SYSTEM, prompt, num_ctx=ctx)))
except Exception as e:
print(f" [{r.get('sample_id')}] ERROR: {e}")
triples = []
rec = {"sample_id": r.get("sample_id"), "cik": r["cik"],
"fund": r.get("fund"), "n_windows": n_win, "triples": triples}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush()
done += 1
dt = time.time() - t0
wtag = f" [{n_win}w]" if n_win > 1 else ""
print(f"[{done}/{len(rows)}] {r.get('fund') or r['cik']:.40s} "
f"-> {len(triples)} triples{wtag} ({dt/done:.1f}s/sample)")
print(f"\nwrote {done} predictions -> {args.out} ({args.model})")
def main():
global BACKEND, VLLM_URL, API_KEY
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--mode", choices=["extract", "match"], default="extract",
help="extract = free triple extraction (baseline); "
"match = verify each GOLD triple against the text "
"(semantic grounding flag llm_grounded)")
ap.add_argument("--in", dest="inp", default="data/rdf_poc/test.jsonl")
ap.add_argument("--out", dest="out", default="data/rdf_poc/preds_qwen.jsonl")
ap.add_argument("--backend", choices=["ollama", "vllm"], default="ollama",
help="ollama = local Mac; vllm = remote OpenAI-compatible "
"server (stronger model, higher throughput)")
ap.add_argument("--model", default=None,
help="model id; defaults to qwen3.6:35b (ollama) or "
"qwen3.5-122b-a10b-fp8 (vllm)")
ap.add_argument("--vllm-url", default=VLLM_URL)
ap.add_argument("--api-key", default="")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--num-ctx", type=int, default=32768)
ap.add_argument("--window", type=int, default=0,
help="sliding-window size in chars; texts longer than this are "
"processed window-by-window and unioned. 0 = auto: a "
"server-safe size derived from --num-ctx (vllm) or ~600 KB "
"(ollama, where one big call is faster).")
ap.add_argument("--overlap", type=int, default=8000,
help="overlap between consecutive windows in chars")
ap.add_argument("--workers", type=int, default=1,
help="concurrent samples (vllm server handles parallel "
"requests; keep <= the server's max-num-seqs, e.g. 8-16)")
args = ap.parse_args()
BACKEND = args.backend
VLLM_URL = args.vllm_url
API_KEY = args.api_key
if not args.model:
args.model = "qwen3.5-122b-a10b-fp8" if BACKEND == "vllm" else "qwen3.6:35b"
if not args.window:
if BACKEND == "vllm":
# Empirically (128k-context reasoning server) ~300 KB / ~93k tokens is
# the reliable ceiling per call: above it the model spends its whole
# output budget "thinking" and returns empty (finish_reason=length) or
# times out. 280 KB leaves headroom and yields 1-3 windows for a
# typical book while staying fast (~80s/call).
args.window = 280000
else:
args.window = 600000 # ollama: one big local call beats many windows
if args.mode == "match":
run_match(args)
else:
run_extract(args)
if __name__ == "__main__":
main()

View File

@ -4,3 +4,8 @@ lxml>=5.1.0
pandas>=2.2.0
tqdm>=4.66.0
# SQLite3 is part of Python stdlib — no extra package needed
#
# build_rdf_dataset.py / llm_extract.py / score_baseline.py use only the Python
# stdlib (urllib, json, csv, re), so no extra packages are required for the RDF
# dataset pipeline. The LLM extraction baseline needs a running Ollama server
# (https://ollama.com) with a model pulled, e.g.: ollama pull qwen3.6:35b

View File

@ -46,10 +46,65 @@ def norm(name: str) -> str:
def triple_key(t, entities):
"""Match key: (subject_type, predicate, normalized object label)."""
s_type = entities.get(t["s"], {}).get("type", "")
"""Match key: (predicate, normalized object label).
Subject type is intentionally omitted: gold subjects are IRIs resolved via
`entities`, but model predictions carry raw subject strings, so a type-based
key would never match predictions. Within one fund's scope, (predicate,
object name) uniquely identifies a service-provider edge. The object label is
resolved through `entities` for gold IRIs and falls back to the raw string for
predictions.
"""
o_label = entities.get(t["o"], {}).get("label", t["o"])
return (s_type, t["p"], norm(o_label))
return (t["p"], norm(o_label))
# ---- fuzzy entity-name matching --------------------------------------------
# The same entity is named differently in the prospectus text (what a model
# copies) and in N-CEN gold, e.g. "UBS AM (Americas)" vs
# "UBS Asset Management (Americas) LLC". Strict equality undercounts true hits,
# so fuzzy matching treats two names as the same entity when their token sets
# overlap strongly or one is a token-subset of the other.
_STOP = {"of", "and", "the", "fund", "services", "service", "asset", "management",
"investment", "investments", "company", "co"}
def _toks(name_norm):
return {w for w in name_norm.split() if w not in _STOP and len(w) > 1}
def names_match(a_norm, b_norm, thresh=0.6):
if a_norm == b_norm:
return True
if not a_norm or not b_norm:
return False
if a_norm in b_norm or b_norm in a_norm: # one is a substring of the other
return True
ta, tb = _toks(a_norm), _toks(b_norm)
if not ta or not tb:
return False
if ta <= tb or tb <= ta: # token-subset (after stopwords)
return True
jacc = len(ta & tb) / len(ta | tb)
return jacc >= thresh
def fuzzy_tp(gold_keys, pred_keys):
"""Greedy count of gold edges matched by some prediction (fuzzy on name).
gold_keys / pred_keys are lists of (predicate, normalized_name). A gold edge
matches a prediction if predicates are equal and names_match() holds; each
prediction is consumed at most once.
"""
used = [False] * len(pred_keys)
tp = 0
for gp, gn in gold_keys:
for i, (pp, pn) in enumerate(pred_keys):
if not used[i] and pp == gp and names_match(gn, pn):
used[i] = True
tp += 1
break
return tp
def score(gold_triples, pred_triples, entities):
@ -119,22 +174,52 @@ def run_stringmatch():
print(f" {p:<16}{r:>8.2f}{ngd:>8}")
def run_model(pred_path):
def run_model(pred_path, fuzzy=True):
samples = load_samples_with_entities()
preds = defaultdict(list)
# join predictions by sample_id (per-fund) when present, else by cik
preds_by_id = {}
preds_by_cik = defaultdict(list)
for l in open(pred_path, encoding="utf-8"):
r = json.loads(l)
preds[r["cik"]] = r.get("triples", [])
if r.get("sample_id"):
preds_by_id[r["sample_id"]] = r.get("triples", [])
preds_by_cik[r["cik"]] = r.get("triples", [])
micro = [0, 0, 0]
per_rel = defaultdict(lambda: [0, 0, 0]) # predicate -> [tp, npred, ngold]
for s in samples:
ents = s["_entities"]
tp, np_, ng, *_ = score(s["target_triples"], preds.get(s["cik"], []), ents)
micro[0] += tp; micro[1] += np_; micro[2] += ng
pred = preds_by_id.get(s.get("sample_id")) if s.get("sample_id") in preds_by_id \
else preds_by_cik.get(s["cik"], [])
gold_keys = [triple_key(t, ents) for t in s["target_triples"]]
pred_keys = [triple_key(t, ents) for t in pred]
if fuzzy:
tp_s = fuzzy_tp(gold_keys, pred_keys)
else:
gk = set(gold_keys); tp_s = len(gk & set(pred_keys))
micro[0] += tp_s; micro[1] += len(pred_keys); micro[2] += len(gold_keys)
# per-relation (restrict matching within the same predicate)
for rel in set(p for p, _ in gold_keys) | set(p for p, _ in pred_keys):
g_r = [k for k in gold_keys if k[0] == rel]
p_r = [k for k in pred_keys if k[0] == rel]
t_r = fuzzy_tp(g_r, p_r) if fuzzy else len(set(g_r) & set(p_r))
per_rel[rel][0] += t_r
per_rel[rel][1] += len(p_r)
per_rel[rel][2] += len(g_r)
tp, np_, ng = micro
P = tp / np_ if np_ else 0
R = tp / ng if ng else 0
F = 2 * P * R / (P + R) if (P + R) else 0
print(f"MODEL baseline: P={P:.3f} R={R:.3f} F1={F:.3f} (tp={tp}, pred={np_}, gold={ng})")
mode = "fuzzy" if fuzzy else "strict"
print(f"\nMODEL baseline over {len(samples)} samples ({mode} name matching):")
print(f" MICRO P={P:.3f} R={R:.3f} F1={F:.3f} (tp={tp}, pred={np_}, gold={ng})")
print(f"\n {'relation':16s}{'P':>6}{'R':>6}{'F1':>6}{'gold':>7}")
for p, (t, npd, ngd) in sorted(per_rel.items(), key=lambda x: -x[1][2]):
pr = t / npd if npd else 0
rc = t / ngd if ngd else 0
f1 = 2 * pr * rc / (pr + rc) if (pr + rc) else 0
print(f" {p:16s}{pr:>6.2f}{rc:>6.2f}{f1:>6.2f}{ngd:>7}")
def main():
@ -142,11 +227,13 @@ def main():
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("stringmatch")
m = sub.add_parser("model"); m.add_argument("--pred", required=True)
m.add_argument("--strict", action="store_true",
help="exact normalized name matching (default: fuzzy)")
args = ap.parse_args()
if args.cmd == "stringmatch":
run_stringmatch()
elif args.cmd == "model":
run_model(args.pred)
run_model(args.pred, fuzzy=not args.strict)
else:
ap.print_help()

16
watch_fetch.sh Normal file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Live-Fortschritt des laufenden Prospekt-Downloads anzeigen.
# bash watch_fetch.sh -> live mitlaufen (Strg-C zum Beenden)
# bash watch_fetch.sh count -> nur kurzer Stand (X/435 + Groesse)
LOG="/private/tmp/claude-501/-Users-florianherzog-development-AISE-Admin-Thesis--Florian-Code/745ff40e-0ecf-4430-886c-33fbc724df69/tasks/bp1wdlneq.output"
DIR="data/rdf_poc/prose"
if [ "${1:-}" = "count" ]; then
n=$(ls "$DIR"/*.txt 2>/dev/null | wc -l | tr -d ' ')
last=$(grep -oE '\[[0-9]+/435\]' "$LOG" 2>/dev/null | tail -1)
echo "Fertige Trusts: $n | letzte Logzeile: $last"
du -sh "$DIR" 2>/dev/null
else
echo "Live-Download (Strg-C zum Beenden). Jede Zeile = ein Trust [n/435]:"
tail -f "$LOG"
fi