GlossBERT¶
GlossBertLinker ports
Huang et al.'s GlossBERT
(Sent-CLS-WS) cross-encoder for WSD: each mention/gloss pair is scored by a
single BertForSequenceClassification forward pass. Four examples cover
it end to end: loading the paper's own checkpoint, training the encoder
from scratch, a full-corpus run, and LLM re-ranking on top of it.
- Reproducing the paper's published results
- Verifying the training path
- Full-corpus training run
- LLM re-ranking over GlossBERT candidates
Reproducing the paper's published results¶
Unlike every other example on this site, this one runs no training at
all -- see Verifying the training path
for that side instead. GlossBertLinker loads
Huang et al.'s own published checkpoint for their best-reported
configuration, GlossBERT(Sent-CLS-WS), directly into GlossBertEncoder --
its BertForSequenceClassification architecture is verbatim what the
checkpoint contains -- and evaluates it via
GlossBertLinker.link, the exact same
production code path a freshly-trained model would use. See
glossbert.py's module docstring for how
closely the candidate generation, gloss formatting and weak-supervision
marking were ported from the reference implementation's own data-prep
code, not just the paper text.
Requires the checkpoint
(Sent_CLS_WS.zip, linked from the
reference repo's README) at
~/Downloads/Sent_CLS_WS.zip, and UFSAC 2.1
extracted to ~/data/ufsac-public-2.1/ -- see the script's own docstring
for both. Candidates are every WordNet sense of each mention's lemma
(ExactMatch(top_k=50) against a WnEntitySource),
matching the paper's own candidate generation exactly (no gold-POS
filtering, no popularity prior).
"""Reproduces GlossBERT(Sent-CLS-WS)'s published results using the paper's
own checkpoint, through this repo's `GlossBertLinker`/`UfsacDataset` pipeline.
No training happens here -- this loads Huang et al.'s own published
checkpoint (https://arxiv.org/pdf/1908.07245.pdf, best-reported
configuration) directly into `GlossBertEncoder` (its `BertForSequenceClassification`
architecture is verbatim what the checkpoint contains) and evaluates it
via `GlossBertLinker.link()`, the exact same production code path a
freshly-trained model would use.
Setup:
1. Download the checkpoint from the link in
https://github.com/HSLCY/GlossBERT#news (`Sent_CLS_WS.zip`) and place
it at `~/Downloads/Sent_CLS_WS.zip` (or point `_CHECKPOINT_ZIP` below
at wherever you put it). It's a plain
`config.json`/`pytorch_model.bin`/`vocab.txt` triple, extracted once
into `~/.cache/linkingtk/glossbert_checkpoint/` on first run.
2. Download UFSAC 2.1 (https://github.com/getalp/UFSAC#content-of-the-repository)
and extract it to `~/data/ufsac-public-2.1/` (or point `_UFSAC_DIR`
below elsewhere) -- this script reads its `raganato_*.xml` files, the
same Raganato et al. (2017) WSD evaluation framework data GlossBERT's
own paper reports numbers against.
Candidates are every WordNet sense of each mention's lemma
(`ExactMatch(top_k=50)` against a `WnEntitySource` -- wide enough that no
real word's sense count gets truncated), matching the paper's own
candidate generation (no gold-POS filtering, no popularity prior).
Published target (the paper's README, "this checkpoint" row -- the
checkpoint this script actually loads, not the original paper run):
SE07=72.1, SE2=77.7, SE3=75.9, SE13=76.8, SE15=79.3, ALL=77.2 (micro F1,
which reduces to precision@1 in this always-answered, one-prediction-
per-instance setting).
Run with: `uv run python examples/glossbert_reproduction.py`
"""
from __future__ import annotations
import zipfile
from pathlib import Path
import torch
from linkingtk.algorithms.wsd.glossbert import GlossBertEncoder, GlossBertLinker
from linkingtk.blocking.exact import ExactMatch
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
_CHECKPOINT_ZIP = Path.home() / "Downloads" / "Sent_CLS_WS.zip"
_CHECKPOINT_DIR = Path.home() / ".cache" / "linkingtk" / "glossbert_checkpoint"
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
# (UFSAC filename stem, README column label, README's "this checkpoint" score)
_EVAL_SETS = [
("raganato_semeval2007", "SE07", 72.1),
("raganato_senseval2", "SE2", 77.7),
("raganato_senseval3", "SE3", 75.9),
("raganato_semeval2013", "SE13", 76.8),
("raganato_semeval2015", "SE15", 79.3),
("raganato_ALL", "ALL", 77.2),
]
def _ensure_checkpoint_extracted() -> Path:
if not (_CHECKPOINT_DIR / "pytorch_model.bin").exists():
_CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(_CHECKPOINT_ZIP) as archive:
archive.extractall(_CHECKPOINT_DIR)
return _CHECKPOINT_DIR
def main() -> None:
checkpoint_dir = _ensure_checkpoint_extracted()
device = "cuda" if torch.cuda.is_available() else "cpu"
encoder = GlossBertEncoder.from_checkpoint(checkpoint_dir, max_length=512)
linker = GlossBertLinker(model=encoder)
linker.model.to(device)
blocking = ExactMatch(top_k=50)
print(f"{'dataset':<8} {'precision@1':>12} {'published':>10}")
for stem, label, published in _EVAL_SETS:
mentions, senses, ground_truth = UfsacDataset(source=str(_UFSAC_DIR / f"{stem}.xml")).load()
results = linker.link(mentions, senses, blocking=blocking)
predictions = [(result.source_id, result.target_id) for result in results]
report = Evaluator.evaluate(predictions=predictions, ground_truth=ground_truth)
score = report.metrics["precision@1"] * 100
print(f"{label:<8} {score:>11.1f}% {published:>9.1f}%")
if __name__ == "__main__":
main()
Run with:
uv run python examples/glossbert_reproduction.py
dataset precision@1 published
SE07 72.5% 72.1%
SE2 77.5% 77.7%
SE3 75.7% 75.9%
SE13 76.7% 76.8%
SE15 78.7% 79.3%
ALL 76.7% 77.2%
Every dataset lands within 0.6 points of its published number. Getting here took four rounds of real diagnosis, each one a concrete, verified bug -- not a single number was accepted at face value.
Bugs found and fixed¶
1 -- mentions were labeled by surface form, not lemma.
UfsacDataset originally set each mention's
labels to its literal surface text (e.g. "caught"). Candidate
generation (ExactMatch querying WnEntitySource by label) needs the
dictionary lemma ("catch") -- WordNet doesn't index inflected forms. The
first real run measured precision@1 = 52.6% on SE07 with recall well
below precision (35.4%), the tell that a third of mentions were
producing no candidates at all. Fixed by using each word's lemma
attribute for labels (both UfsacDataset
and SemCorDataset had this bug), keeping the
literal surface form available from context's span for _gloss_text's
formatting, which needs it instead (GlossBERT's gloss side is "<surface
form> : <gloss>", not the lemma).
2 -- case-sensitive post-filtering dropped wn's own case-insensitive
matches. ExactMatch's EntitySource
branch verifies each of search()'s results actually carries the queried
label, to guard against a loosely-matching search implementation. But
wn.synsets("friday") is itself case-insensitive -- it finds the synset
WordNet lemmatizes as "Friday" -- so the case-sensitive post-filter
silently dropped it, and every other capitalized/proper-noun-like lemma.
Made the post-filter case-insensitive.
3 -- single-answer scoring on multi-answer gold data. Some WSD
gold-standard instances genuinely accept more than one correct sense key.
SemEval-2015's multi-answer rate (18%, 184/1022) is 20x SemEval-2007's
(0.9%) -- and it's exactly the dataset with the disproportionately large
gap after fixes 1-2 (71.3% vs. published 79.3%). Fixed by having
UfsacDataset emit one ground_truth row per valid sense key and
Evaluator.evaluate accept a match against any
of a source's rows (recall's denominator now the distinct-source count,
not the row count -- unchanged for every single-answer caller elsewhere
in this repo).
4 -- WordNet sense-key convention mismatches between UFSAC and
omw-en:1.4. Found by cross-checking against GlossBERT's own official
scorer (see below), which revealed our own Evaluator.evaluate numbers
were precision-correct but the official recall was still short --
i.e. we were dropping real mentions from dataset1 entirely, not just
scoring them wrong. Two distinct lemma-lookup mismatches, both in
sources/wn.py:
- Phrasal verbs: UFSAC's classic-WordNet convention joins multi-word
lemmas with underscores (
"point_out"), butomw-en:1.4/omw-en:2.0index the same lemma space-separated ("point out") -- confirmed directly (wn.synsets("point_out", ...)returns nothing,wn.synsets("point out", ...)returns 3). ~4-5% of SemEval-2007's instances. - Adjective satellites: UFSAC's
wn30_keylayer tags "peculiar" (meaning "specific") as type3(plain adjective) --"peculiar%3:00:00:specific:00"-- butomw-en:1.4stores the identical sense as type5(adjective satellite) instead. ~7.5% of SensEval-2's instances.
Both fixed with a query-side fallback (retry with underscores replaced by
spaces; retry with the adjective/adjective-satellite type digit swapped)
in WnEntitySource.search, sensekey_to_synset_id and
synset_id_to_sensekey, plus the matching normalization in ExactMatch's
post-filter.
Cross-checked against GlossBERT's own official scorer¶
1.7 points still felt worth chasing further after fix 3, so rather than
trust our own Evaluator.evaluate a fourth time, this was cross-verified
against the reference implementation's own evaluation tool: Raganato et
al.'s Scorer.java, bundled in the GlossBERT repo. Compiled it with a
locally-extracted JDK (no root needed --
apt-get download+dpkg-deb -x), converted our predictions back to
sense-key form via the new synset_id_to_sensekey helper, and ran it
against the actual official gold-key files (not just this repo's own
UfsacDataset-loaded ground truth).
Before fix 4, the official scorer showed precision matching our own
numbers almost exactly (e.g. SE07 72.9% both ways) but recall
noticeably lower (SE07 69.7%) -- independent confirmation that our
Evaluator.evaluate math was already correct, and that the gap was
missing coverage (mentions silently dropped from dataset1), not a
scoring bug. After fix 4, every dataset scores P = R = F1 against the
official scorer, matching this repo's own Evaluator.evaluate numbers to
the decimal -- full agreement between two independent implementations,
and 100% of every gold file's instances answered.
Verifying the training path¶
Reproducing the paper's published results
validates GlossBertEncoder's inference path -- architecture, candidate
generation, text formatting -- by loading the paper's own published
checkpoint. It never touches CrossEncoderTrainer at all. This example
is the counterpart: it trains a fresh bert-base-uncased
GlossBertEncoder from scratch via
CrossEncoderTrainer on a real slice of SemCor,
and checks that real learning happens.
Small subset, not the paper's setup -- the paper trains on all
~226K sense-tagged SemCor instances for 6 epochs; this trains on the
first 6 Brown Corpus documents (~3K instances) for 3, so it runs in well
under a minute. The point isn't to approach the published numbers (that's
what the checkpoint reproduction is for) -- it's to verify the training
code itself end to end: CrossEncoderTrainer's BCE loss, hard-negative
mining via blocking, optimizer/warmup/weight-decay setup, all against a
real pretrained backbone and real data, not a toy synthetic model.
"""Trains GlossBertEncoder from scratch (real `bert-base-uncased`, real
gradient descent) on a real slice of SemCor, and evaluates on held-out
documents -- the training-path counterpart to
`examples/glossbert_reproduction.py`, which only exercises inference
against the paper's published checkpoint.
**Small subset, not the paper's setup.** The paper trains on all of
SemCor (~226K sense-tagged instances) for 6 epochs at `batch_size=64`,
`max_seq_length=512` -- multiple GPU-hours even on this sandbox's single
RTX 4090. This script instead trains on the first `_TRAIN_DOCS` Brown
Corpus documents (~3K instances) for 3 epochs at `max_length=128`, runs in
under a minute, and exists to verify the *training path itself* --
`CrossEncoderTrainer`'s loss, hard-negative mining, and optimizer setup
end to end on real data and a real pretrained backbone -- not to
reproduce the paper's published numbers (that's what the checkpoint-based
`glossbert_reproduction.py` is for). Don't expect these numbers to
approach the published ones; a fraction of the data and epochs will not
reach that quality, and that's not the point here.
**Regression context**: this script's own `eval_dataset2=senses` wiring
matters -- `CrossEncoderTrainer._evaluate()` defaults to deriving its
candidate pool from `eval_data`'s own (already-correct) targets alone,
which for WSD silently omits every real decoy sense and overstates
Hits@1. Measured directly during this benchmark's development: 60%
self-reported Hits@1 vs. 33% via the real `GlossBertLinker.link()` path
on the same held-out mentions -- see `CrossEncoderTrainer`'s own
docstring for the fix.
Requires UFSAC 2.1 extracted to `~/data/ufsac-public-2.1/` -- see
`glossbert_reproduction.py`'s docstring.
Run with: `uv run python examples/glossbert_benchmark.py`
"""
from __future__ import annotations
from pathlib import Path
import torch
from linkingtk.algorithms.wsd.glossbert import GlossBertLinker
from linkingtk.blocking.exact import ExactMatch
from linkingtk.core.entity import Entity, label_texts
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
from linkingtk.sources.wn import WnEntitySource
from linkingtk.train.arguments import TrainingArguments
from linkingtk.train.cross_encoder import CrossEncoderTrainer
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
_TRAIN_DOCS = 6
_EVAL_DOCS = 3
def _document_id(mention_id: str) -> str:
# UfsacDataset's positional mention ids (semcor.xml has no native
# per-word id, unlike the raganato_*.xml files) -- "ufsac:{doc}:{sent}:{index}".
return mention_id.split(":")[1]
def _resolve_pairs(
ground_truth: list[tuple[str, str]],
mentions_by_id: dict[str, Entity],
senses: WnEntitySource,
) -> list[tuple[Entity, Entity]]:
cache: dict[str, Entity | None] = {}
pairs = []
for mention_id, synset_id in ground_truth:
if synset_id not in cache:
cache[synset_id] = senses.get(synset_id)
sense = cache[synset_id]
if sense is not None:
pairs.append((mentions_by_id[mention_id], sense))
return pairs
def main() -> None:
mentions, senses, ground_truth = UfsacDataset(source=str(_UFSAC_DIR / "semcor.xml")).load()
mentions_by_id = {m.id: m for m in mentions}
doc_ids = sorted({_document_id(m.id) for m in mentions})
train_docs = set(doc_ids[:_TRAIN_DOCS])
eval_docs = set(doc_ids[_TRAIN_DOCS : _TRAIN_DOCS + _EVAL_DOCS])
train_gt = [(mid, tid) for mid, tid in ground_truth if _document_id(mid) in train_docs]
eval_gt = [(mid, tid) for mid, tid in ground_truth if _document_id(mid) in eval_docs]
train_pairs = _resolve_pairs(train_gt, mentions_by_id, senses)
eval_pairs = _resolve_pairs(eval_gt, mentions_by_id, senses)
eval_mentions = list({m.id: m for m, _s in eval_pairs}.values())
print(
f"{len(train_pairs)} train instances ({_TRAIN_DOCS} docs) / "
f"{len(eval_pairs)} eval instances ({_EVAL_DOCS} docs)"
)
blocking = ExactMatch(top_k=30)
# Most-frequent-sense baseline: WordNet's own sense order (most-common
# first) with no model at all -- the standard WSD sanity-check floor.
mfs_predictions = [
(mention.id, candidates[0].id)
for mention in eval_mentions
for candidates in [senses.search(label_texts(mention)[0], top_k=1)]
if candidates
]
mfs_report = Evaluator.evaluate(predictions=mfs_predictions, ground_truth=eval_gt)
print(f"Most-frequent-sense baseline: {mfs_report.metrics}")
linker = GlossBertLinker(model_name_or_path="bert-base-uncased", max_length=128)
device = "cuda" if torch.cuda.is_available() else "cpu"
linker.model.to(device)
before = Evaluator.evaluate(
predictions=[
(r.source_id, r.target_id)
for r in linker.link(eval_mentions, senses, blocking=blocking)
],
ground_truth=eval_gt,
)
print(f"Untrained precision@1: {before.metrics}")
args = TrainingArguments(
output_dir="./models/glossbert_semcor_subset",
learning_rate=2e-5,
negative_samples_ratio=3,
num_epochs=3,
batch_size=16,
weight_decay=0.01,
warmup_ratio=0.1,
device=device,
)
trainer = CrossEncoderTrainer(
model=linker.model,
args=args,
train_data=train_pairs,
eval_data=eval_pairs,
eval_dataset2=senses,
blocking=blocking,
)
trainer.train()
print("Per-epoch held-out Hits@1 (via CrossEncoderTrainer.eval_history):")
for i, epoch_report in enumerate(trainer.eval_history):
print(f" epoch {i + 1}: {epoch_report.metrics}")
after = Evaluator.evaluate(
predictions=[
(r.source_id, r.target_id)
for r in linker.link(eval_mentions, senses, blocking=blocking)
],
ground_truth=eval_gt,
)
print("Trained precision@1 (via GlossBertLinker.link, the real production path):")
print(f" {after.metrics}")
if __name__ == "__main__":
main()
Run with:
uv run python examples/glossbert_benchmark.py
2964 train instances (6 docs) / 669 eval instances (3 docs)
Most-frequent-sense baseline: {'precision@1': 0.205, 'recall': 0.205, 'f1': 0.205}
Untrained precision@1: {'precision@1': 0.138, 'recall': 0.138, 'f1': 0.138}
Per-epoch held-out Hits@1 (via CrossEncoderTrainer.eval_history):
epoch 1: {'Hits@1': 0.238, 'MRR': 0.423}
epoch 2: {'Hits@1': 0.324, 'MRR': 0.490}
epoch 3: {'Hits@1': 0.333, 'MRR': 0.496}
Trained precision@1 (via GlossBertLinker.link, the real production path):
{'precision@1': 0.333, 'recall': 0.333, 'f1': 0.333}
Real, meaningful learning on a real pretrained backbone: 13.8% (untrained) → 33.3% (trained, 3 epochs on ~3K instances), clearing the most-frequent-sense baseline (20.5%) by a wide margin.
A real bug this caught¶
The first version of this benchmark reported a 60% Hits@1 from
CrossEncoderTrainer.eval_history -- but the same trained model, evaluated
independently via GlossBertLinker.link() on the identical held-out
mentions, measured only 33%. Nearly 2x apart, on numbers that should
agree almost exactly.
The cause: CrossEncoderTrainer._evaluate() (mirroring
Trainer._evaluate()'s convention for EA/EL) derived its candidate pool
from eval_data's own targets -- correct for EA/EL, where the eval
split's own KB subset already stands in for the full target set, but
silently wrong for WSD: the eval split's own gold senses are a far
narrower, far less confusable candidate pool than a mention's real
lemma-wide sense inventory. With only the correct answers ever offered
as candidates, the reported number was measuring something closer to
"can the model recognize its own training targets" than "can it
disambiguate."
Fixed by adding CrossEncoderTrainer(..., eval_dataset2=...): pass the
real target set (here, the same WnEntitySource link() itself queries)
and _evaluate() blocks against that instead. After the fix,
eval_history's last epoch (33.3%) matches the independent link()
check exactly -- see
CrossEncoderTrainer's own docstring, and
tests/train/test_cross_encoder.py::TestEvalDataset2 for the regression
coverage.
Full-corpus training run¶
Verifying the training path verifies the
training path on a small, fast slice of SemCor. This script is the
full-scale counterpart: it trains
GlossBertEncoder on the entire real
SemCor corpus (~230K sense-tagged instances) with near-paper-faithful
hyperparameters, then evaluates against UFSAC's real Raganato et al.
framework test sets -- directly comparable to
Reproducing the paper's published results's
checkpoint-based numbers, but from a model this repo's own
CrossEncoderTrainer trained, not the paper's published weights.
Long-running -- meant to run for hours, unattended
(nohup ... & disown, not a foreground uv run). See the script's own
docstring for measured throughput and expected wall-clock time on a
single GPU.
"""Trains GlossBertEncoder on the *full* real SemCor corpus (~230K
sense-tagged instances) with near-paper-faithful hyperparameters, then
evaluates the result against UFSAC's real Raganato et al. framework test
sets -- the full-scale counterpart to `examples/glossbert_benchmark.py`'s
small, fast verification slice.
**Long-running.** Unlike every other example in this repo, this is meant
to run for hours, unattended (`nohup ... & disown`, not a foreground
`uv run`). Measured directly on this sandbox's single RTX 4090 before
launching: ~545 rows/sec (positives + mined negatives combined) at
`batch_size=64`, `max_length=512`, so ~230K positives (expanding to
~700K-1M rows with negatives) is roughly 20-30 minutes per epoch, low
single-digit GB of peak GPU memory (WSD sentences are short despite the
paper's `max_length=512`) -- comfortably an overnight run at the paper's
own 6 epochs, with margin.
**Hyperparameters, matching the paper (`README.md`/`run_classifier_WSD_sent.py`
in the reference implementation) where GPU-safe**: `learning_rate=2e-5`,
`weight_decay=0.01`, `warmup_ratio=0.1`, `max_length=512`,
`num_epochs=6`. `batch_size=64` matches the paper's own effective batch
(no gradient accumulation needed here -- measured peak memory leaves
comfortable headroom on a 24GB GPU at this scale). Candidates are every
WordNet sense of a lemma across all POS
(`ExactMatch(top_k=50)`/`negative_samples_ratio=49`), matching the
paper's own candidate generation, not an artificially-capped subset.
**Dev/test split matches the paper's own protocol**: train on all of
SemCor, monitor on SemEval-2007 (the paper's own dev set) via
`CrossEncoderTrainer.eval_history` every epoch, and only evaluate on the
real held-out test sets (SensEval-2/3, SemEval-2013/2015, ALL) once,
after training completes -- exactly like `glossbert_reproduction.py`'s
checkpoint-based evaluation, so the two are directly comparable.
Requires UFSAC 2.1 extracted to `~/data/ufsac-public-2.1/` -- see
`glossbert_reproduction.py`'s docstring.
Run with (from the repo root), detached so it survives the shell exiting:
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
import torch
from linkingtk.algorithms.wsd.glossbert import GlossBertLinker
from linkingtk.blocking.exact import ExactMatch
from linkingtk.core.entity import Entity
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
from linkingtk.sources.wn import WnEntitySource
from linkingtk.train.arguments import TrainingArguments
from linkingtk.train.cross_encoder import CrossEncoderTrainer
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
_OUTPUT_DIR = Path("./models/glossbert_semcor_full")
# (UFSAC filename stem, README column label, README's published "this checkpoint" score)
_TEST_SETS = [
("raganato_senseval2", "SE2", 77.7),
("raganato_senseval3", "SE3", 75.9),
("raganato_semeval2013", "SE13", 76.8),
("raganato_semeval2015", "SE15", 79.3),
("raganato_ALL", "ALL", 77.2),
]
def _resolve_pairs(
ground_truth: list[tuple[str, str]],
mentions_by_id: dict[str, Entity],
senses: WnEntitySource,
) -> list[tuple[Entity, Entity]]:
cache: dict[str, Entity | None] = {}
pairs = []
for mention_id, synset_id in ground_truth:
if synset_id not in cache:
cache[synset_id] = senses.get(synset_id)
sense = cache[synset_id]
if sense is not None:
pairs.append((mentions_by_id[mention_id], sense))
return pairs
def main() -> None:
logging.basicConfig(
level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s", force=True
)
logging.getLogger("linkingtk").setLevel(logging.INFO)
t0 = time.time()
train_mentions, senses, train_gt = UfsacDataset(source=str(_UFSAC_DIR / "semcor.xml")).load()
train_mentions_by_id = {m.id: m for m in train_mentions}
train_pairs = _resolve_pairs(train_gt, train_mentions_by_id, senses)
print(f"[{time.time() - t0:.0f}s] loaded {len(train_pairs)} SemCor training instances")
dev_mentions, _senses, dev_gt = UfsacDataset(
source=str(_UFSAC_DIR / "raganato_semeval2007.xml")
).load()
dev_mentions_by_id = {m.id: m for m in dev_mentions}
dev_pairs = _resolve_pairs(dev_gt, dev_mentions_by_id, senses)
print(f"[{time.time() - t0:.0f}s] loaded {len(dev_pairs)} SemEval-2007 dev instances")
device = "cuda" if torch.cuda.is_available() else "cpu"
linker = GlossBertLinker(model_name_or_path="bert-base-uncased", max_length=512)
linker.model.to(device)
blocking = ExactMatch(top_k=50)
args = TrainingArguments(
output_dir=str(_OUTPUT_DIR),
learning_rate=2e-5,
negative_samples_ratio=49,
num_epochs=6,
batch_size=64,
weight_decay=0.01,
warmup_ratio=0.1,
device=device,
)
trainer = CrossEncoderTrainer(
model=linker.model,
args=args,
train_data=train_pairs,
eval_data=dev_pairs,
eval_dataset2=senses,
blocking=blocking,
)
print(f"[{time.time() - t0:.0f}s] starting training ({args.num_epochs} epochs)")
trainer.train()
for i, epoch_report in enumerate(trainer.eval_history):
print(f"[{time.time() - t0:.0f}s] epoch {i + 1} dev (SE07): {epoch_report.metrics}")
print(f"[{time.time() - t0:.0f}s] training complete, checkpoint at {_OUTPUT_DIR}/model.pt")
print(f"[{time.time() - t0:.0f}s] evaluating on real held-out test sets")
print(f"{'dataset':<8} {'precision@1':>12} {'published':>10}")
for stem, label, published in _TEST_SETS:
mentions, _senses, ground_truth = UfsacDataset(
source=str(_UFSAC_DIR / f"{stem}.xml")
).load()
results = linker.link(mentions, senses, blocking=blocking)
predictions = [(r.source_id, r.target_id) for r in results]
report = Evaluator.evaluate(predictions=predictions, ground_truth=ground_truth)
score = (report.metrics["precision@1"] or 0.0) * 100
print(f"{label:<8} {score:>11.1f}% {published:>9.1f}%")
print(f"[{time.time() - t0:.0f}s] done")
if __name__ == "__main__":
main()
Run with (from the repo root), detached so it survives the shell exiting:
nohup uv run python examples/glossbert_full_training.py \
> /tmp/glossbert_full_training.log 2>&1 & disown
LLM re-ranking over GlossBERT candidates¶
LlmRerankerLinker re-ranks
GlossBertLinker's own top-k candidate senses with a real local LLM.
Filed as #23 -- unlike
ChatEA (#22), this isn't a port of a specific paper;
it generalizes the same "cheap retrieval, then let an LLM re-rank only
the top-k" idea to Word Sense Disambiguation, reusing this repo's
already-benchmarked GlossBertLinker as the retrieval stage.
Acceptance bar is relative, not an external paper's number¶
There's nothing published to chase here -- #23 doesn't port a paper. The real question: does re-ranking GlossBERT's own top-k candidate senses with an LLM improve precision@1 over GlossBERT's own already-benchmarked checkpoint-reproduction baseline (ALL=76.7%, see Reproducing the paper's published results/#39)?
Setup is identical to glossbert_reproduction.py -- see
Reproducing the paper's published results
for the checkpoint/UFSAC download steps. Also requires a local Ollama
server with the target model pulled -- ollama pull llama2:13b by
default, pass --model for a different one.
"""Benchmarks LlmRerankerLinker (#23) layered on top of GlossBertLinker's own
cross-encoder scoring, using the paper's published checkpoint -- same setup
as `examples/glossbert_reproduction.py`.
**Acceptance bar is relative, not an external paper's number.** Unlike #22
(ChatEA), #23 doesn't port a specific paper -- there's nothing published to
chase. The question this script answers: does re-ranking GlossBERT's own
top-k candidate senses with an LLM improve precision@1 over GlossBERT's
own already-benchmarked checkpoint-reproduction baseline (ALL=76.7%, per
`glossbert_reproduction.py`/#39)?
First reproduces the full published table exactly like
`glossbert_reproduction.py` (no LLM calls, no cost). Then, on the "ALL"
split, samples a random subset of mentions whose true sense already falls
within GlossBertLinker's own top-k candidates (`--sample`, default 30 --
matching `examples/chatea_ea.py`'s established precedent for capping
real-LLM-call benchmarks) and re-ranks just that window with
`LlmRerankerLinker`, reporting before/after precision@1.
Setup: identical to `glossbert_reproduction.py` -- see that script's
docstring for the checkpoint/UFSAC download steps. Also requires a local
Ollama server with `ollama pull llama2:13b` (or pass `--model`).
Run with: `uv run python examples/glossbert_llm_reranker_benchmark.py`
"""
from __future__ import annotations
import argparse
import random
import zipfile
from pathlib import Path
import torch
from linkingtk.algorithms.llm_reranker import LlmRerankerLinker
from linkingtk.algorithms.wsd.glossbert import GlossBertEncoder, GlossBertLinker
from linkingtk.blocking.exact import ExactMatch
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
from linkingtk.llm.client import create_client
_CHECKPOINT_ZIP = Path.home() / "Downloads" / "Sent_CLS_WS.zip"
_CHECKPOINT_DIR = Path.home() / ".cache" / "linkingtk" / "glossbert_checkpoint"
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
# (UFSAC filename stem, README column label, README's "this checkpoint" score)
_EVAL_SETS = [
("raganato_semeval2007", "SE07", 72.1),
("raganato_senseval2", "SE2", 77.7),
("raganato_senseval3", "SE3", 75.9),
("raganato_semeval2013", "SE13", 76.8),
("raganato_semeval2015", "SE15", 79.3),
("raganato_ALL", "ALL", 77.2),
]
def _ensure_checkpoint_extracted() -> Path:
if not (_CHECKPOINT_DIR / "pytorch_model.bin").exists():
_CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(_CHECKPOINT_ZIP) as archive:
archive.extractall(_CHECKPOINT_DIR)
return _CHECKPOINT_DIR
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", default="ollama/llama2:13b")
parser.add_argument("--sample", type=int, default=30)
parser.add_argument("--top-k", type=int, default=10)
parser.add_argument("--random-seed", type=int, default=20260827)
args = parser.parse_args()
checkpoint_dir = _ensure_checkpoint_extracted()
device = "cuda" if torch.cuda.is_available() else "cpu"
encoder = GlossBertEncoder.from_checkpoint(checkpoint_dir, max_length=512)
linker = GlossBertLinker(model=encoder)
linker.model.to(device)
blocking = ExactMatch(top_k=50)
print(f"{'dataset':<8} {'precision@1':>12} {'published':>10}")
all_mentions = all_senses = all_ground_truth = None
for stem, label, published in _EVAL_SETS:
mentions, senses, ground_truth = UfsacDataset(source=str(_UFSAC_DIR / f"{stem}.xml")).load()
results = linker.link(mentions, senses, blocking=blocking)
predictions = [(result.source_id, result.target_id) for result in results]
report = Evaluator.evaluate(predictions=predictions, ground_truth=ground_truth)
score = report.metrics["precision@1"] * 100
print(f"{label:<8} {score:>11.1f}% {published:>9.1f}%")
if label == "ALL":
all_mentions, all_senses, all_ground_truth = mentions, senses, ground_truth
assert all_mentions is not None and all_senses is not None and all_ground_truth is not None
mentions_by_id = {entity.id: entity for entity in all_mentions}
true_target_by_source = dict(all_ground_truth)
base_results = linker.link(all_mentions, all_senses, blocking=blocking)
base_prediction_by_source = {result.source_id: result.target_id for result in base_results}
base_topk_by_source = {
result.source_id: [result.target_id, *result.alternatives][: args.top_k]
for result in base_results
}
improvable_ids = [
source_id
for source_id, top_k_ids in base_topk_by_source.items()
if source_id in true_target_by_source and true_target_by_source[source_id] in top_k_ids
]
print(
f"Improvable (true sense within top-{args.top_k}): "
f"{len(improvable_ids)}/{len(base_topk_by_source)}"
)
rng = random.Random(args.random_seed)
sampled_ids = set(rng.sample(improvable_ids, min(args.sample, len(improvable_ids))))
print(f"Sampled for real LLM re-ranking: {len(sampled_ids)}")
client = create_client(args.model)
reranker = LlmRerankerLinker(base_linker=linker, client=client, task="wsd", top_k=args.top_k)
sampled_mentions = [mentions_by_id[source_id] for source_id in sampled_ids]
llm_results = reranker.link(sampled_mentions, all_senses, blocking=blocking)
merged_prediction_by_source = dict(base_prediction_by_source)
changed_correctness = 0
for result in llm_results:
source_id = result.source_id
true_target = true_target_by_source[source_id]
was_correct = base_prediction_by_source.get(source_id) == true_target
merged_prediction_by_source[source_id] = result.target_id
now_correct = result.target_id == true_target
if was_correct != now_correct:
changed_correctness += 1
print(f" {source_id}: base_correct={was_correct} -> llm_correct={now_correct}")
print(
f"Sampled entities whose correctness changed after LLM "
f"re-ranking: {changed_correctness}/{len(llm_results)}"
)
final_report = Evaluator.evaluate(
predictions=list(merged_prediction_by_source.items()), ground_truth=all_ground_truth
)
final_score = final_report.metrics["precision@1"] * 100
print(f"After LlmRerankerLinker re-ranking (ALL): {final_score:.1f}%")
if __name__ == "__main__":
main()
Run with:
uv run python examples/glossbert_llm_reranker_benchmark.py
dataset precision@1 published
SE07 72.5% 72.1%
SE2 77.5% 77.7%
SE3 75.7% 75.9%
SE13 76.7% 76.8%
SE15 78.7% 79.3%
ALL 76.7% 77.2%
Improvable (true sense within top-10): 7223/7253
Sampled for real LLM re-ranking: 30
Sampled entities whose correctness changed after LLM re-ranking: 1/30
After LlmRerankerLinker re-ranking (ALL): 76.7%
A real bug was found and fixed along the way, not just a null result
accepted at face value. The first run of this benchmark showed 0/30
changed -- but per-entity diagnosis (not accepting a flat number at face
value, see
the established practice from #22/#39)
revealed why: LlmRerankerLinker's confidence shortcut defaulted to
threshold=0.5, tuned with a bi-encoder's roughly-[-1, 1] cosine
similarity in mind. GlossBertLinker's score is a raw, unbounded
cross-encoder logit margin instead -- routinely 1-15+ in magnitude on
real UFSAC instances -- so a 0.5 gap was almost always exceeded, and
28 of the 30 sampled entities never reached the LLM at all, silently
falling back to GlossBERT's own base ranking every time.
This was a genuine design bug, not a WSD-specific quirk: an absolute
score-gap threshold is only meaningful when the caller knows the base
linker's score scale, which varies by method (bounded similarity for
bi-encoders, unbounded logits for cross-encoders). Fixed by making
threshold default to None (shortcut disabled) rather than a value
tuned for one family of base linkers -- see
LlmRerankerLinker's
own docstring, and the
BLINK reranker benchmark, which opts
back into threshold=0.5 explicitly since BLINK's cosine-similarity
score does make that a sound choice.
With the LLM actually consulted for all 30 sampled entities: 22/30 were already correct beforehand, 8/30 were wrong, and the LLM correctly fixed 1 of those 8 with zero regressions -- a small but real, honestly-measured positive signal from a 30-instance sample (0.4% of the 7,253-instance ALL split), not the "no effect" the threshold bug would have reported.
Fidelity decisions vs. #22 (ChatEA)¶
Documented up front in
llm_reranker's module docstring --
summarized here:
top_ktruncation happens before the LLM call -- the actual point of this class vs.LlmBaseLinker(#21), which sends an LLM every blocked candidate with no first-stage narrowing.- Confidence shortcut (single top1-vs-top2 gap check) is a simplified
analog of
ChatEALinker's same idea, without its iterative window-widening -- #23 isn't tied to a specific paper's algorithm. - LLM-call failure falls back to the base ranking for that source entity rather than dropping it, since a reranker always has a real base score to fall back to.
This closes out #23's WSD half.