pyannotators-entityfishing 1.6.83__tar.gz → 1.6.85__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/PKG-INFO +1 -1
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/src/pyannotators_entityfishing/__init__.py +1 -1
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/src/pyannotators_entityfishing/candidates.py +23 -2
- pyannotators_entityfishing-1.6.85/tools/build_rerank_data.py +130 -0
- pyannotators_entityfishing-1.6.85/tools/check_rerank_endpoint.py +59 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/tools/eval_linking.py +67 -12
- pyannotators_entityfishing-1.6.85/tools/eval_rerank.py +300 -0
- pyannotators_entityfishing-1.6.85/tools/finetune_rerank.py +187 -0
- pyannotators_entityfishing-1.6.85/tools/merge_rerank.py +87 -0
- pyannotators_entityfishing-1.6.85/tools/recalibrate_linking.py +111 -0
- pyannotators_entityfishing-1.6.85/tools/review_linking.html +300 -0
- pyannotators_entityfishing-1.6.85/tools/review_linking.py +237 -0
- pyannotators_entityfishing-1.6.85/tools/sherpa_plan_project.py +118 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/.claude/skills/ef-mapping/SKILL.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/.gitignore +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/.gitmodules +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/.python-version +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/README.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/pyproject.toml +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/src/pyannotators_entityfishing/ef_client.py +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/src/pyannotators_entityfishing/entityfishing.py +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/.claude-plugin/marketplace.json +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/.claude-plugin/plugin.json +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/HOWTO.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/README.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/resources/Stages.yml +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/resources/Taskfile.yml +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/skills/new-plugin/SKILL.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/skills/new-plugin/references/annotator.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/skills/new-plugin/references/importer.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/skills/new-plugin/references/processor.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/skills/new-plugin/references/types.md +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/tools/check-marketplace.py +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/submodules/python-archetype/vars/pythonPipeline.groovy +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/tools/el_jev.py +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/tools/eval_mappings.py +0 -0
- {pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/tools/wdfilter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyannotators-entityfishing
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.85
|
|
4
4
|
Summary: Annotator based on entity-fishing
|
|
5
5
|
Project-URL: Homepage, https://github.com/oterrier/pyannotators_entityfishing/
|
|
6
6
|
Author-email: Olivier Terrier <olivier.terrier@kairntech.com>
|
|
@@ -29,6 +29,9 @@ WIKIDATA = "wikidata"
|
|
|
29
29
|
# Written by pyprocessors_coreference (ADR-0001, §5), when that step ran before this one.
|
|
30
30
|
ENTITY_GROUP = "entity_group"
|
|
31
31
|
CANONICAL_FORM = "canonical_form"
|
|
32
|
+
# On an ambiguous partial name ("Antonelli" ending both "Kimi Antonelli" and another full
|
|
33
|
+
# name): the groups it may belong to. Its candidates are then the union of theirs and its own.
|
|
34
|
+
ENTITY_GROUP_CANDIDATES = "entity_group_candidates"
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class EntityFishingCandidatesParameters(ProcessorParameters):
|
|
@@ -122,15 +125,33 @@ def add_candidates(
|
|
|
122
125
|
senses = {form: [s for s in ss if not 0 < s.get("prob_c", 0.0) < min_prior] for form, ss in senses.items()}
|
|
123
126
|
chosen = {key: next((senses[f] for f in fs if senses.get(f)), [])[:top_k] for key, fs in forms.items()}
|
|
124
127
|
concepts = client.get_kb_concepts_by_page({s["pageid"] for ss in chosen.values() for s in ss}, lang)
|
|
128
|
+
terms_of = {key: candidate_terms(chosen[key], concepts, queries.get(key[0]), wiki_props) for key in groups}
|
|
129
|
+
by_group_id = {key[2]: key for key in groups if key[1] == "group"}
|
|
125
130
|
for key, members in groups.items():
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
terms = terms_of[key]
|
|
132
|
+
possible = [by_group_id[g] for g in (members[0].properties or {}).get(ENTITY_GROUP_CANDIDATES) or [] if g in by_group_id]
|
|
133
|
+
if possible:
|
|
134
|
+
# An ambiguous partial name: the decision step chooses among the candidates of
|
|
135
|
+
# every group it may belong to, the full names first, and its own.
|
|
136
|
+
terms = round_robin([terms_of[k] for k in possible] + [terms], top_k)
|
|
128
137
|
for a in members:
|
|
129
138
|
# Replace the Wikidata candidates, keep the terms of any other lexicon (AFP...).
|
|
130
139
|
kept = [t for t in (a.terms or []) if t.lexicon != WIKIDATA]
|
|
131
140
|
a.terms = kept + [t.model_copy(deep=True) for t in terms]
|
|
132
141
|
|
|
133
142
|
|
|
143
|
+
def round_robin(lists: list[list[Term]], top_k: int) -> list[Term]:
|
|
144
|
+
"""The first of each list, then the second of each... without repeating a QID, up to
|
|
145
|
+
top_k: every possible group keeps its best candidates."""
|
|
146
|
+
out, seen = [], set()
|
|
147
|
+
for rank in range(max((len(ts) for ts in lists), default=0)):
|
|
148
|
+
for ts in lists:
|
|
149
|
+
if rank < len(ts) and ts[rank].identifier not in seen and len(out) < top_k:
|
|
150
|
+
seen.add(ts[rank].identifier)
|
|
151
|
+
out.append(ts[rank])
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
134
155
|
def group_mentions(document: Document, labels: list[str] | None) -> dict[tuple, list[Annotation]]:
|
|
135
156
|
"""The mentions of the same entity: by `entity_group` when the grouping step ran,
|
|
136
157
|
else by label and normalized surface. Never across labels."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the fine-tuning set of the reranker from Jev's decisions on AFP dispatches.
|
|
3
|
+
|
|
4
|
+
ADR-0001 §9 Q7, SHERPA-3054: there are no reference links on AFP, so Jev's confident
|
|
5
|
+
decisions stand in for them. Jev becomes a provisional teacher; the student inherits its
|
|
6
|
+
errors, which is why only confident answers are kept and why the evaluation stays on the
|
|
7
|
+
MSNBC and AQUAINT gold links (tools/eval_rerank.py), never on AFP.
|
|
8
|
+
|
|
9
|
+
Inputs, kept in .cache/afp-runs/ (gitignored; regenerating them costs ~14 M Jev tokens):
|
|
10
|
+
fr_pipeline_ef_jev.jsonl plan pipeline_ef_jev on project afp_ner_fr_2 (5,059 dispatches)
|
|
11
|
+
fr_docs.json the texts of those dispatches
|
|
12
|
+
xx_pipeline_ef_jev_noner.jsonl plan pipeline_ef_jev_noner on project afp_ner_xx (641 dispatches, 6 languages)
|
|
13
|
+
the texts of the latter are read from the multilingual test corpus (--xx-corpus).
|
|
14
|
+
|
|
15
|
+
One example per decided group: the context of its first mention (same window and [ ]
|
|
16
|
+
marks as the evaluation), the descriptions of its candidates ("name. definition", as the
|
|
17
|
+
evaluation without facts), and the target: the index of Jev's choice, or -1 for NONE.
|
|
18
|
+
|
|
19
|
+
Kept: answers whose winning probability is at least --min-prob, all NONE and all groups
|
|
20
|
+
with several candidates, and a --single-share sample of the one-candidate groups Jev
|
|
21
|
+
accepted (the easy ones: 26,600 of 41,500). Documents are split into train and
|
|
22
|
+
validation by a hash of their identifier.
|
|
23
|
+
|
|
24
|
+
.venv/bin/python tools/build_rerank_data.py # writes .cache/rerank-train/{train,val}.jsonl
|
|
25
|
+
.venv/bin/python tools/build_rerank_data.py --runs fr_pipeline_ef_jev.jsonl --out .cache/rerank-train-fr
|
|
26
|
+
# without the 641 REM test dispatches (the xx run), so that the model can be measured on them
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import glob
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import random
|
|
36
|
+
import sys
|
|
37
|
+
from collections import Counter
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
41
|
+
from eval_rerank import mark # noqa: E402
|
|
42
|
+
|
|
43
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
44
|
+
RUNS = ROOT / ".cache" / "afp-runs"
|
|
45
|
+
OUT = ROOT / ".cache" / "rerank-train"
|
|
46
|
+
XX_CORPUS = "/media/olivier/DATA/corpora/AFP/REM/corpus-test-depeches/flair"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def texts(xx_corpus: str) -> dict[str, tuple[str, str]]:
|
|
50
|
+
"""Identifier → (language, text) of every dispatch of the two runs."""
|
|
51
|
+
out = {d["identifier"]: ("fr", d["text"]) for d in json.loads((RUNS / "fr_docs.json").read_text())}
|
|
52
|
+
for f in glob.glob(f"{xx_corpus}/*.json"):
|
|
53
|
+
d = json.loads(Path(f).read_text())
|
|
54
|
+
out[d["identifier"]] = (d["metadata"]["language"], d["text"])
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def examples(run: Path, docs: dict[str, tuple[str, str]], min_prob: float):
|
|
59
|
+
for line in run.open():
|
|
60
|
+
r = json.loads(line)
|
|
61
|
+
j = r["decision"]
|
|
62
|
+
if not j or r["identifier"] not in docs:
|
|
63
|
+
continue
|
|
64
|
+
lang, text = docs[r["identifier"]]
|
|
65
|
+
for k, q in j["questions"].items():
|
|
66
|
+
a = j["answers"][k]
|
|
67
|
+
choice = a["choice"]
|
|
68
|
+
if a["probabilities"].get(choice, 0.0) < min_prob:
|
|
69
|
+
continue
|
|
70
|
+
mention = q["instructions"]["mention"]
|
|
71
|
+
first = next((m for m in r["annotations"] if m["labelName"] == mention["type"] and m["text"] in mention["surfaces"]), None)
|
|
72
|
+
if first is None:
|
|
73
|
+
continue
|
|
74
|
+
qids = [c for c in q["criteria"] if c != "NONE"]
|
|
75
|
+
cands = []
|
|
76
|
+
for qid in qids:
|
|
77
|
+
c = q["criteria"][qid]
|
|
78
|
+
name, definition = (c, "") if isinstance(c, str) else (c["name"], c.get("definition", ""))
|
|
79
|
+
cands.append(f"{name}. {definition}")
|
|
80
|
+
yield {
|
|
81
|
+
"doc": r["identifier"],
|
|
82
|
+
"lang": lang,
|
|
83
|
+
"type": mention["type"],
|
|
84
|
+
"context": mark(text, first["start"], first["end"]),
|
|
85
|
+
"qids": qids,
|
|
86
|
+
"candidates": cands,
|
|
87
|
+
"target": qids.index(choice) if choice != "NONE" else -1,
|
|
88
|
+
"jev_prob": a["probabilities"][choice],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
94
|
+
parser.add_argument("--xx-corpus", default=XX_CORPUS)
|
|
95
|
+
parser.add_argument("--min-prob", type=float, default=0.9)
|
|
96
|
+
parser.add_argument("--single-share", type=float, default=0.2, help="share of the accepted one-candidate groups kept (default 0.2)")
|
|
97
|
+
parser.add_argument("--val-share", type=float, default=0.05)
|
|
98
|
+
parser.add_argument("--seed", type=int, default=3054)
|
|
99
|
+
parser.add_argument("--runs", nargs="+", default=["fr_pipeline_ef_jev.jsonl", "xx_pipeline_ef_jev_noner.jsonl"], help="runs of .cache/afp-runs to draw from, in this order")
|
|
100
|
+
parser.add_argument("--out", type=Path, default=OUT, help="output directory (default .cache/rerank-train)")
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
out = args.out
|
|
103
|
+
|
|
104
|
+
docs = texts(args.xx_corpus)
|
|
105
|
+
rng = random.Random(args.seed)
|
|
106
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
files = {split: (out / f"{split}.jsonl").open("w") for split in ("train", "val")}
|
|
108
|
+
stats = Counter()
|
|
109
|
+
for run in args.runs:
|
|
110
|
+
for ex in examples(RUNS / run, docs, args.min_prob):
|
|
111
|
+
kind = "none" if ex["target"] < 0 else ("single" if len(ex["qids"]) == 1 else "multi")
|
|
112
|
+
if kind == "single" and rng.random() >= args.single_share:
|
|
113
|
+
continue
|
|
114
|
+
split = "val" if int(hashlib.md5(ex["doc"].encode()).hexdigest(), 16) % 1000 < args.val_share * 1000 else "train"
|
|
115
|
+
files[split].write(json.dumps(ex, ensure_ascii=False) + "\n")
|
|
116
|
+
stats[split, kind] += 1
|
|
117
|
+
stats[split, "pairs"] += len(ex["qids"])
|
|
118
|
+
stats[split, ex["lang"]] += 1
|
|
119
|
+
for f in files.values():
|
|
120
|
+
f.close()
|
|
121
|
+
for split in ("train", "val"):
|
|
122
|
+
kinds = {k: stats[split, k] for k in ("multi", "single", "none")}
|
|
123
|
+
langs = {k[1]: v for k, v in stats.items() if k[0] == split and k[1] in ("fr", "en", "de", "es", "pt", "ar")}
|
|
124
|
+
print(f"{split}: {sum(kinds.values())} groups {kinds}, {stats[split, 'pairs']} pairs, by language {langs}")
|
|
125
|
+
print(f"written to {out}")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check a served reranker against the scores of the evaluation: same model, same numbers?
|
|
3
|
+
|
|
4
|
+
The `rerank` processor of pyprocessors_jev reads log-odds back from a `/rerank` endpoint
|
|
5
|
+
(vLLM serving tools/merge_rerank.py's output, or LiteLLM in front of it). This sends the
|
|
6
|
+
same (context, candidate) pairs as tools/eval_rerank.py --adapter, and compares the
|
|
7
|
+
log-odds with those it cached. vLLM serves in bf16 where the evaluation ran in fp32, so
|
|
8
|
+
small gaps are expected; the decisions must not move.
|
|
9
|
+
|
|
10
|
+
.venv/bin/python tools/check_rerank_endpoint.py --base-url http://<host>:<port>/v1 --docs 5
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import math
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
import eval_linking as el # noqa: E402
|
|
24
|
+
import eval_rerank as er # noqa: E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
parser.add_argument("--base-url", required=True)
|
|
30
|
+
parser.add_argument("--model", default="qwen3-reranker-afp-jev")
|
|
31
|
+
parser.add_argument("--label", default="qwen@qwen3-afp-jev", help="the cached scores to compare with")
|
|
32
|
+
parser.add_argument("--docs", type=int, default=5)
|
|
33
|
+
parser.add_argument("--api-key")
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
headers = {"Authorization": f"Bearer {args.api_key}"} if args.api_key else {}
|
|
36
|
+
cache = el.cached("rerank.json")
|
|
37
|
+
|
|
38
|
+
gaps, same, surfaces = [], 0, 0
|
|
39
|
+
for doc in el.load_corpus("msnbc")[: args.docs]:
|
|
40
|
+
cands = el.candidates_of(doc, 8, el.EF_DEFAULT, 0.0)
|
|
41
|
+
cached = cache[f"{args.label}/k8/p0.0/{doc.corpus}/{doc.name}"]
|
|
42
|
+
for s, cs in cands.items():
|
|
43
|
+
if not cs:
|
|
44
|
+
continue
|
|
45
|
+
docs = [er.describe(c, facts=False) for c in cs]
|
|
46
|
+
resp = requests.post(args.base_url.rstrip("/") + "/rerank", json={"model": args.model, "query": er.context(doc, s), "documents": docs}, headers=headers, timeout=60)
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
by_index = {r["index"]: r["relevance_score"] for r in resp.json()["results"]}
|
|
49
|
+
served = [math.log(max(p, 1e-7) / max(1 - p, 1e-7)) for p in (by_index[i] for i in range(len(docs)))]
|
|
50
|
+
gaps += [abs(a - b) for a, b in zip(served, cached[s], strict=True)]
|
|
51
|
+
same += max(range(len(docs)), key=served.__getitem__) == max(range(len(docs)), key=cached[s].__getitem__)
|
|
52
|
+
surfaces += 1
|
|
53
|
+
gaps.sort()
|
|
54
|
+
print(f"{surfaces} surfaces, {len(gaps)} pairs: log-odds gap median {gaps[len(gaps) // 2]:.3f}, max {gaps[-1]:.3f}; same best candidate {same}/{surfaces}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
sys.exit(main())
|
{pyannotators_entityfishing-1.6.83 → pyannotators_entityfishing-1.6.85}/tools/eval_linking.py
RENAMED
|
@@ -23,6 +23,14 @@ jev term lookup (top K) + one Jev Choice per distinct surface, one cal
|
|
|
23
23
|
.venv/bin/python tools/eval_linking.py # both corpora, min_prior 0.01 (Jev calls billed once, then cached)
|
|
24
24
|
.venv/bin/python tools/eval_linking.py --min-prior 0 # the first evaluation, replayed from the cache
|
|
25
25
|
.venv/bin/python tools/eval_linking.py --bench 8 # latency and throughput, real calls
|
|
26
|
+
JEV_API_BASE=http://127.0.0.1:8009 .venv/bin/python tools/eval_linking.py --model cygnet --min-prior 0 # a self-hosted Jev-compatible endpoint, no key
|
|
27
|
+
JEV_API_BASE=http://127.0.0.1:8009 .venv/bin/python tools/eval_linking.py --model cygnet --min-prior 0 --fragments 1 # one call per surface, state = its sentences +-1
|
|
28
|
+
|
|
29
|
+
--fragments W replaces the whole text by fragments: one call per distinct surface, whose
|
|
30
|
+
state is the sentences holding its mentions, W sentences on each side. Cygnet re-reads
|
|
31
|
+
the state for every question, so this is where its tokens go; Jev reads it once per call.
|
|
32
|
+
The answers are cached under the model name "<model>+fragW", which
|
|
33
|
+
tools/recalibrate_linking.py --model cygnet+frag1 replays.
|
|
26
34
|
|
|
27
35
|
Jev answers are cached per (model, document) in .cache/linking/jev.json, so a re-run
|
|
28
36
|
costs nothing; --refresh-jev forces new calls. The key is read from JEV_API_KEY or
|
|
@@ -49,7 +57,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
49
57
|
CACHE = ROOT / ".cache" / "linking"
|
|
50
58
|
RAW = "https://raw.githubusercontent.com/kermitt2/entity-fishing/master/data/corpus/corpus-long"
|
|
51
59
|
EF_DEFAULT = "https://entityfishing.kairntech.com"
|
|
52
|
-
|
|
60
|
+
# Any endpoint that speaks the Jev protocol: the hosted TypeSafe API by default, or a
|
|
61
|
+
# self-hosted one (Cygnet, Open-Jev) through JEV_API_BASE, as in pyprocessors_jev.
|
|
62
|
+
JEV_BASE = os.getenv("JEV_API_BASE", os.getenv("TYPESAFE_BASE_URL", "https://api.typesafe.ai"))
|
|
53
63
|
WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php"
|
|
54
64
|
USER_AGENT = "kairntech-eval-linking/0.1 (olivier.terrier@kairntech.com)"
|
|
55
65
|
NONE = "NONE"
|
|
@@ -255,27 +265,62 @@ def build_questions(cands: dict[str, list[dict]]) -> tuple[list[str], dict]:
|
|
|
255
265
|
return surfaces, questions
|
|
256
266
|
|
|
257
267
|
|
|
268
|
+
def sentence_spans(text: str) -> list[tuple[int, int]]:
|
|
269
|
+
"""Sentences as (start, end) offsets: a line break, or [.!?] then a space before a capital or a quote."""
|
|
270
|
+
spans, start = [], 0
|
|
271
|
+
for m in re.finditer(r"\n+|(?<=[.!?])[\"'”’)]*\s+(?=[A-Z\"“‘(])", text):
|
|
272
|
+
if text[start : m.start()].strip():
|
|
273
|
+
spans.append((start, m.start()))
|
|
274
|
+
start = m.end()
|
|
275
|
+
if text[start:].strip():
|
|
276
|
+
spans.append((start, len(text)))
|
|
277
|
+
return spans
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def fragment(doc: Doc, surface: str, window: int) -> str:
|
|
281
|
+
"""The sentences holding the mentions of `surface`, `window` sentences on each side, gaps marked by [...]."""
|
|
282
|
+
spans = sentence_spans(doc.text)
|
|
283
|
+
hit = {i for m in doc.mentions if m.surface == surface for i, (s, e) in enumerate(spans) if s < m.end and m.start < e}
|
|
284
|
+
keep = sorted({j for i in hit for j in range(i - window, i + window + 1) if 0 <= j < len(spans)})
|
|
285
|
+
parts, prev = [], None
|
|
286
|
+
for j in keep:
|
|
287
|
+
if prev is not None and j != prev + 1:
|
|
288
|
+
parts.append("[...]")
|
|
289
|
+
parts.append(doc.text[spans[j][0] : spans[j][1]].strip())
|
|
290
|
+
prev = j
|
|
291
|
+
return "\n".join(parts)
|
|
292
|
+
|
|
293
|
+
|
|
258
294
|
def call_jev(text: str, questions: dict, model: str, key: str) -> dict:
|
|
259
|
-
|
|
295
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
296
|
+
resp = session.post(JEV_BASE.rstrip("/") + "/v1/systemone", json={"model": model, "state": {"text": text}, "questions": questions}, headers=headers, timeout=300)
|
|
260
297
|
resp.raise_for_status()
|
|
261
298
|
return resp.json()
|
|
262
299
|
|
|
263
300
|
|
|
264
|
-
def jev(doc: Doc, cands: dict[str, list[dict]], model: str, key: str, refresh: bool, k: int, min_prior: float) -> tuple[dict, dict]:
|
|
301
|
+
def jev(doc: Doc, cands: dict[str, list[dict]], model: str, key: str, refresh: bool, k: int, min_prior: float, fragments: int = -1) -> tuple[dict, dict]:
|
|
265
302
|
cache = cached("jev.json")
|
|
266
303
|
# The answers depend on the candidates asked: the key carries K and min_prior. The
|
|
267
304
|
# first evaluation (K=8, no min_prior) keeps its original key, so it replays for free.
|
|
268
|
-
|
|
305
|
+
tag = f"{model}+frag{fragments}" if fragments >= 0 else model
|
|
306
|
+
ck = f"{tag}/{doc.corpus}/{doc.name}" if (k, min_prior) == (8, 0.0) else f"{tag}/k{k}/p{min_prior}/{doc.corpus}/{doc.name}"
|
|
269
307
|
if ck in cache and not refresh:
|
|
270
308
|
return cache[ck]["answers"], cache[ck]["usage"]
|
|
271
309
|
surfaces, questions = build_questions(cands)
|
|
272
310
|
if not questions:
|
|
273
311
|
return {}, {}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
if fragments < 0:
|
|
313
|
+
result = call_jev(doc.text, questions, model, key)
|
|
314
|
+
answers = {surfaces[int(q[1:])]: a for q, a in result["answers"].items()}
|
|
315
|
+
usage = result.get("usage", {})
|
|
316
|
+
else: # one call per surface, on its own fragment
|
|
317
|
+
with ThreadPoolExecutor(8) as pool:
|
|
318
|
+
results = list(pool.map(lambda q: call_jev(fragment(doc, surfaces[int(q[1:])], fragments), {q: questions[q]}, model, key), questions))
|
|
319
|
+
answers = {surfaces[int(q[1:])]: a for r in results for q, a in r["answers"].items()}
|
|
320
|
+
usage = {u: sum(r.get("usage", {}).get(u, 0) for r in results) for u in ("input_tokens", "output_tokens")}
|
|
321
|
+
cache[ck] = {"answers": answers, "usage": usage}
|
|
277
322
|
save("jev.json", cache)
|
|
278
|
-
return answers,
|
|
323
|
+
return answers, usage
|
|
279
324
|
|
|
280
325
|
|
|
281
326
|
# --------------------------------------------------------------------------- scoring
|
|
@@ -390,6 +435,7 @@ def main() -> int:
|
|
|
390
435
|
parser.add_argument("--min-prior", type=float, default=0.01, help="drop senses with 0 < prob_c < this, as the processor (default 0.01; 0 replays the first evaluation)")
|
|
391
436
|
parser.add_argument("--model", default="jev-latest")
|
|
392
437
|
parser.add_argument("--refresh-jev", action="store_true")
|
|
438
|
+
parser.add_argument("--fragments", type=int, default=-1, metavar="W", help="one call per surface, state = the sentences of its mentions, W on each side (default -1: the whole text, one call)")
|
|
393
439
|
parser.add_argument("--no-jev", action="store_true", help="skip the Jev calls (candidates and baselines only; with --bench, time entity-fishing only)")
|
|
394
440
|
parser.add_argument("--bench", type=int, default=0, metavar="N", help="time real calls (no cache) on N documents, half from each corpus")
|
|
395
441
|
parser.add_argument("--concurrency", type=int, nargs="+", default=[1, 8], help="Jev concurrencies measured by --bench")
|
|
@@ -402,7 +448,7 @@ def main() -> int:
|
|
|
402
448
|
k, v = line.split("=", 1)
|
|
403
449
|
os.environ.setdefault(k.strip(), v.strip().strip("'\""))
|
|
404
450
|
key = os.getenv("JEV_API_KEY") or os.getenv("TYPESAFE_API_KEY")
|
|
405
|
-
if not key and not args.no_jev:
|
|
451
|
+
if not key and not args.no_jev and "JEV_API_BASE" not in os.environ:
|
|
406
452
|
print("No JEV_API_KEY: use --no-jev", file=sys.stderr)
|
|
407
453
|
return 1
|
|
408
454
|
|
|
@@ -419,14 +465,19 @@ def main() -> int:
|
|
|
419
465
|
scores: dict[str, Score] = {"disambiguate": Score(), "prior": Score()}
|
|
420
466
|
for t in THRESHOLDS:
|
|
421
467
|
scores[f"jev conf>={t}"] = Score()
|
|
422
|
-
cand_recall, usage = Counter(), Counter()
|
|
468
|
+
cand_recall, usage, state_chars = Counter(), Counter(), Counter()
|
|
423
469
|
|
|
424
470
|
for n, doc in enumerate(docs, 1):
|
|
425
471
|
print(f"# {n}/{len(docs)} {doc.corpus}/{doc.name} ({len(doc.mentions)} mentions)", file=sys.stderr)
|
|
426
472
|
ef_links = disambiguate(doc, args.ef, args.min_selector)
|
|
427
473
|
cands = candidates_of(doc, args.k, args.ef, args.min_prior)
|
|
428
|
-
answers, u = ({}, {}) if args.no_jev else jev(doc, cands, args.model, key, args.refresh_jev, args.k, args.min_prior)
|
|
474
|
+
answers, u = ({}, {}) if args.no_jev else jev(doc, cands, args.model, key, args.refresh_jev, args.k, args.min_prior, args.fragments)
|
|
429
475
|
usage.update(u)
|
|
476
|
+
n_questions = sum(1 for cs in cands.values() if cs)
|
|
477
|
+
state_chars["text"] += len(doc.text)
|
|
478
|
+
state_chars["text x questions"] += len(doc.text) * n_questions
|
|
479
|
+
if args.fragments >= 0:
|
|
480
|
+
state_chars["fragments"] += sum(len(fragment(doc, s, args.fragments)) for s, cs in cands.items() if cs)
|
|
430
481
|
for m in doc.mentions:
|
|
431
482
|
scores["disambiguate"].add(m, ef_links.get((m.start, m.end)))
|
|
432
483
|
cs = cands.get(m.surface, [])
|
|
@@ -437,7 +488,8 @@ def main() -> int:
|
|
|
437
488
|
a = answers.get(m.surface)
|
|
438
489
|
for t in THRESHOLDS:
|
|
439
490
|
pred = None
|
|
440
|
-
|
|
491
|
+
# Cygnet returns no confidence: the probability of the choice stands for it (they coincide for Jev).
|
|
492
|
+
if a and a["choice"] != NONE and a.get("confidence", a["probabilities"].get(a["choice"], 0.0)) >= t:
|
|
441
493
|
pred = a["choice"]
|
|
442
494
|
scores[f"jev conf>={t}"].add(m, pred)
|
|
443
495
|
if a and m.qid: # keep_ambiguity: the gold QID among the 2 most probable non-NONE options
|
|
@@ -446,6 +498,9 @@ def main() -> int:
|
|
|
446
498
|
|
|
447
499
|
mentions = sum(len(d.mentions) for d in docs)
|
|
448
500
|
print(f"\n{len(docs)} documents, {mentions} mentions scored ({unresolved} gold titles with no QID dropped)")
|
|
501
|
+
if args.fragments >= 0:
|
|
502
|
+
reread, frag = state_chars["text x questions"], state_chars["fragments"]
|
|
503
|
+
print(f"state characters: whole text {state_chars['text']}, re-read per question {reread}, fragments (W={args.fragments}) {frag} = {frag / max(reread, 1):.0%} of the re-read text")
|
|
449
504
|
print(f"candidate recall @K={args.k}, min_prior={args.min_prior}: {cand_recall['in_candidates']}/{cand_recall['gold']} = {cand_recall['in_candidates'] / max(cand_recall['gold'], 1):.1%} (ceiling of the term-lookup chain)\n")
|
|
450
505
|
for name, s in scores.items():
|
|
451
506
|
if name.startswith("jev") and args.no_jev:
|