ESC¶
EscLinker ports
Barba, Pasini & Navigli's
ESC, which reframes WSD as extractive span comprehension over BART-large.
Two examples cover it: loading the paper's own checkpoint, and training
the encoder from scratch.
Reproducing the paper's published results¶
Like EWISER's reproduction,
this runs no training at all -- see
Verifying the training path for that side
instead. EscEncoder.from_checkpoint loads
Barba, Pasini & Navigli's
own published SemCor checkpoint directly, and evaluates via
EscLinker.link, the exact same production
code path a freshly trained model would use.
ESC reframes WSD as extractive span comprehension: a mention's context
sentence (with the target word bracketed by literal <classify>/
</classify> markers) is paired with every candidate sense's gloss,
concatenated into one sequence, and a standard extractive-QA head
(AutoModelForQuestionAnswering, BART-large) predicts which candidate's
gloss span answers the "question" -- one forward pass per mention,
jointly observing every one of that mention's candidates at once. See
esc.py's module docstring for the real
architectural details confirmed directly from the checkpoint (the plain
AutoModelForQuestionAnswering path, not the reference's custom XLNet/
SQuAD-head/special-token variants; the full BART encoder-decoder stack,
not encoder-only).
Requires the paper's own SemCor checkpoint (linked from the reference
repo's README) at
~/Downloads/escher_semcor_best.ckpt, and
UFSAC 2.1 extracted to
~/data/ufsac-public-2.1/ -- see the script's own docstring. Candidates
are restricted to each mention's own tagged part of speech, matching the
reference's own WordNetDataset.init_dataset (confirmed directly against
esc/esc_dataset.py's wn_offsets_from_lemmapos) -- the same convention
EWISER's reproduction
already uses for EWISER's own candidate generation.
"""Reproduces ESC's published results using the paper's own checkpoint,
through this repo's `EscLinker`/`UfsacDataset` pipeline.
No training happens here -- this loads Barba, Pasini & Navigli's own
released SemCor checkpoint (https://github.com/SapienzaNLP/esc, NAACL
2021) directly into `EscEncoder.from_checkpoint` and evaluates it via
`EscLinker.link()`, the exact same production code path a freshly trained
model would use. **This is a hard acceptance gate for issue #41, not a
best-effort verification pass**: measured numbers are expected to match
the published ones (within a stated, verified methodology difference, not
an assumed one).
Setup:
1. The paper's own SemCor checkpoint (the Google Drive link in the ESC
README's "Checkpoints" section) at
``~/Downloads/escher_semcor_best.ckpt`` (or point `_CHECKPOINT_PATH`
below elsewhere).
2. UFSAC 2.1 (https://github.com/getalp/UFSAC#content-of-the-repository)
extracted to `_UFSAC_DIR` below -- same Raganato et al. (2017) WSD
evaluation framework data ESC's own paper reports numbers against (same
convention as ``ewiser_reproduction.py``).
Only SE07 and ALL are published in the reference's own README (``SE07:
76.3 | ALL: 80.7`` for the SemCor-only checkpoint) -- the other Raganato
splits are reported below for reference, without a stated target.
Candidates are restricted to each mention's own tagged part of speech,
matching the reference's own `WordNetDataset.init_dataset`
(``wn_offsets_from_lemmapos``, confirmed directly against
``esc/esc_dataset.py``) -- the same convention `ewiser_reproduction.py`
already uses for EWISER's own (separately confirmed) POS-restricted
candidate generation.
Run with: `uv run python examples/esc_reproduction.py`
"""
from __future__ import annotations
from pathlib import Path
import torch
from linkingtk.algorithms.wsd.esc import EscEncoder, EscLinker
from linkingtk.blocking.base import BlockingStrategy
from linkingtk.blocking.exact import ExactMatch
from linkingtk.core.entity import Entity
from linkingtk.core.source import EntitySource
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
_CHECKPOINT_PATH = Path.home() / "Downloads" / "escher_semcor_best.ckpt"
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
# (UFSAC filename stem, README column label, published F1 or None)
_EVAL_SETS = [
("raganato_semeval2007", "SE07", 76.3),
("raganato_senseval2", "SE2", None),
("raganato_senseval3", "SE3", None),
("raganato_semeval2013", "SE13", None),
("raganato_semeval2015", "SE15", None),
("raganato_ALL", "ALL", 80.7),
]
_PENN_TO_WORDNET_POS = {"N": "n", "V": "v", "J": "a", "R": "r"}
def _wordnet_pos_of_synset_id(synset_id: str) -> str | None:
pos = synset_id.rsplit("-", 1)[-1]
if pos == "s": # adjective satellite counts as a plain adjective for POS filtering
return "a"
return pos if pos in "nvar" else None
def _mention_wordnet_pos(mention: Entity) -> str | None:
tag = mention.properties.get("pos")
return _PENN_TO_WORDNET_POS.get(tag[0]) if tag else None
class _PosRestrictedMatch(BlockingStrategy):
"""Wraps `ExactMatch`, keeping only candidates whose WordNet POS matches
the mention's own tagged POS (see module docstring)."""
def __init__(self, top_k: int = 50) -> None:
self._inner = ExactMatch(top_k=top_k)
def candidate_pairs(
self, dataset1: list[Entity], dataset2: list[Entity] | EntitySource
) -> list[tuple[Entity, Entity]]:
pairs = self._inner.candidate_pairs(dataset1, dataset2)
filtered = []
for entity1, entity2 in pairs:
mention_pos = _mention_wordnet_pos(entity1)
if mention_pos is None or _wordnet_pos_of_synset_id(entity2.id) == mention_pos:
filtered.append((entity1, entity2))
return filtered
def main() -> None:
device = "cuda" if torch.cuda.is_available() else "cpu"
blocking = _PosRestrictedMatch(top_k=50)
encoder = EscEncoder.from_checkpoint(_CHECKPOINT_PATH, forward_batch_size=8)
encoder.to(device)
linker = EscLinker(encoder)
print(f"{'dataset':<8} {'precision@1':>12} {'published':>10}")
for stem, eval_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
published_str = f"{published:.1f}%" if published is not None else "n/a"
print(f"{eval_label:<8} {score:>11.1f}% {published_str:>10}")
if __name__ == "__main__":
main()
Run with:
uv run python examples/esc_reproduction.py
dataset precision@1 published
SE07 76.3% 76.3%
SE2 81.5% n/a
SE3 77.8% n/a
SE13 82.2% n/a
SE15 83.0% n/a
ALL 80.6% 80.7%
SE07 matches the published number exactly; ALL -- the paper's own
headline metric -- lands 0.1 points off. Only SE07 and ALL are
published in the reference's own README; the other splits are reported
for reference without a stated target.
Unlike EWISER (#40) and GlossBERT (#39), this reproduction needed no
bug hunting at all: reading the checkpoint's own hyper_parameters and
state_dict directly (not just the paper text) identified the exact
architecture up front -- facebook/bart-large, squad_head=False,
use_special_tokens=False, the plain HF AutoModelForQuestionAnswering
path -- and porting the joint-sequence tokenization
(build_joint_sequence) against that
confirmed architecture landed within the checkpoint's own real numbers on
the first real run.
Verifying the training path¶
Reproducing the paper's published results
validates EscEncoder's inference path by loading the paper's own
published checkpoint. It never touches EscTrainer
at all. This example is the counterpart: it trains a fresh
facebook/bart-base EscEncoder from
scratch via EscTrainer on a real slice of SemCor, and checks that real
learning happens.
Small subset and a smaller backbone, not expected to approach published
numbers -- unlike Reproducing the paper's published results
(a hard acceptance gate for the checkpoint path), this script has no such
bar. The reference's own full recipe trains facebook/bart-large for far
longer than a handful of epochs on a 9-document slice, and also injects
Poisson-sampled distractor-gloss noise during training
(add_glosses_noise) -- this uses facebook/bart-base instead (same
"smaller/faster backbone for the training-path check" convention
EWISER's training verification
already uses for EWISER's bert-base-cased vs. the paper's
bert-large-cased) and no noise augmentation. The point is to verify the
training code itself -- EscTrainer's extractive-QA span-position
loss, per-mention candidate resolution/shuffling, and batching -- end to
end against a real pretrained encoder and real data, not to reproduce the
paper's numbers.
"""Trains `EscEncoder` from scratch (real BART, real gradient descent) on a
real slice of SemCor, and evaluates on held-out documents -- the
training-path counterpart to `examples/esc_reproduction.py`, which only
exercises inference against the paper's own published checkpoint.
**Small subset and a smaller backbone, not the paper's setup, and not
expected to approach published numbers.** Unlike `esc_reproduction.py` (a
hard acceptance gate for issue #41), this script has no such bar: this
exists to verify the *training path itself* -- `EscTrainer`'s extractive
-QA span-position loss, per-mention candidate resolution/shuffling, and
batching all work correctly on real data and a real pretrained encoder --
not to reproduce the paper's numbers. The reference's own full recipe also
does Poisson-sampled distractor-gloss noise during training
(`add_glosses_noise`) and trains ``facebook/bart-large`` for far longer
than a handful of epochs on a 9-document slice; this uses
``facebook/bart-base`` instead (same "smaller/faster backbone for the
training-path check" convention `ewiser_benchmark.py` already uses for
EWISER's `bert-base-cased` vs. the paper's `bert-large-cased`).
Requires UFSAC 2.1 extracted to `~/data/ufsac-public-2.1/` -- see
`esc_reproduction.py`'s docstring.
Run with: `uv run python examples/esc_benchmark.py`
"""
from __future__ import annotations
from pathlib import Path
import torch
from linkingtk.algorithms.wsd.esc import EscEncoder, EscLinker
from linkingtk.blocking.exact import ExactMatch
from linkingtk.core.entity import label_texts
from linkingtk.datasets.ufsac import UfsacDataset
from linkingtk.eval import Evaluator
from linkingtk.train.arguments import TrainingArguments
from linkingtk.train.esc_trainer import EscTrainer
_UFSAC_DIR = Path.home() / "data" / "ufsac-public-2.1"
_TRAIN_DOCS = 6
_EVAL_DOCS = 3
_MODEL_NAME = "facebook/bart-base" # smaller/faster than the paper's facebook/bart-large
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 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_mentions = [mentions_by_id[mid] for mid, _tid in train_gt]
eval_mentions = [mentions_by_id[mid] for mid, _tid in eval_gt]
print(
f"{len(train_gt)} train instances ({_TRAIN_DOCS} docs) / "
f"{len(eval_gt)} 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}")
device = "cuda" if torch.cuda.is_available() else "cpu"
encoder = EscEncoder(model_name_or_path=_MODEL_NAME, max_length=512, forward_batch_size=4)
encoder.to(device)
linker = EscLinker(encoder)
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/esc_semcor_subset",
learning_rate=3e-5,
num_epochs=5,
batch_size=4,
device=device,
)
trainer = EscTrainer(
model=encoder,
args=args,
train_data=(train_mentions, train_gt),
senses=senses,
eval_data=(eval_mentions, eval_gt),
blocking=blocking,
)
trainer.train()
print("Per-epoch training loss (via EscTrainer.loss_history):")
for i, loss in enumerate(trainer.loss_history):
print(f" epoch {i + 1}: {loss:.4f}")
print("Per-epoch held-out Hits@1 (via EscTrainer.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 EscLinker.link, the real production path):")
print(f" {after.metrics}")
if __name__ == "__main__":
main()
Run with:
uv run python examples/esc_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.172, 'recall': 0.172, 'f1': 0.172}
Per-epoch training loss (via EscTrainer.loss_history):
epoch 1: 1.6357
epoch 2: 1.1363
epoch 3: 0.8366
epoch 4: 0.6036
epoch 5: 0.4051
Per-epoch held-out Hits@1 (via EscTrainer.eval_history):
epoch 1: {'Hits@1': 0.426, 'MRR': 0.605}
epoch 2: {'Hits@1': 0.495, 'MRR': 0.652}
epoch 3: {'Hits@1': 0.505, 'MRR': 0.660}
epoch 4: {'Hits@1': 0.505, 'MRR': 0.664}
epoch 5: {'Hits@1': 0.489, 'MRR': 0.657}
Trained precision@1 (via EscLinker.link, the real production path):
{'precision@1': 0.489, 'recall': 0.489, 'f1': 0.489}
Real, meaningful learning on a real pretrained backbone: the QA
span-position loss drops monotonically every epoch (1.64 → 0.41), and
held-out precision@1 climbs from 17.2% (untrained) to 48.9% (trained, 5
epochs on ~3K instances) -- clearing the most-frequent-sense baseline
(20.5%) by a wide margin. eval_history's final epoch matches the
independent EscLinker.link() check exactly (48.9% both ways), confirming
the training path and the production inference path agree -- the same
regression this repo's own CrossEncoderTrainer
history caught a real bug on for GlossBERT (see
GlossBERT's training verification).
Hits@1 dips slightly after the peak at epochs 3-4 (50.5% → 48.9%) --
expected noise from continuing to fine-tune a small backbone on a slice
this small (~3K instances, 5 epochs, no early stopping), not a sign of a
training-path bug; loss_history keeps decreasing every epoch throughout,
confirming the optimizer itself is behaving correctly even as held-out
generalization plateaus.