Hard-negative sampling for classifier training¶
FeatureClassifierLinker.fit needs negative
(non-matching) examples alongside the ground-truth positives, and by
default samples them uniformly at random from the candidate pool. But a
blocking strategy has already decided which non-matches are plausible
enough to surface as candidates at all — those are more informative
training signal than a uniform random draw, because they're the pairs a
classifier is most likely to get wrong.
sample_hard_negatives mines exactly those:
for each ground-truth source entity, the top-K most confusable
non-matching candidates from
LabelOverlap blocking, in the best-first
order the strategy already returns them in.
As in the feature-classifier example,
LabelOverlap(ngram_size=1, ...) is used here rather than a tighter
max_matches=3, so that blocking surfaces both correct and incorrect
candidates for sample_hard_negatives to mine from — tighter blocking on
a dataset this small finds only the true matches, leaving nothing to mine.
"""Mining hard negatives to train a feature-based classifier.
`sample_hard_negatives` mines, per source entity, the top-K most
confusable non-matching candidates that `LabelOverlap` blocking surfaced --
more informative training signal for `FeatureClassifierLinker.fit` than
negatives sampled uniformly at random from the full candidate pool, since
blocking already considered these plausible enough to return.
`LabelOverlap(ngram_size=1, ...)` is used here (rather than the
`max_matches=3` used in `string_similarity_ea.py`) so that blocking
surfaces both correct and incorrect candidate pairs to mine negatives
from -- on a dataset this small, tighter blocking finds only the true
matches, leaving nothing to mine.
Run with: `uv run python examples/negative_sampling.py`
"""
from linkingtk.algorithms.feature_classifier import FeatureClassifierLinker
from linkingtk.blocking import LabelOverlap, sample_hard_negatives
from linkingtk.datasets import ToyEADataset
from linkingtk.eval import Evaluator
def main() -> None:
kg1, kg2, ground_truth = ToyEADataset().load()
blocking = LabelOverlap(ngram_size=1, max_matches=10)
negatives = sample_hard_negatives(kg1, kg2, ground_truth, blocking, top_k=2)
print("Hard negatives:")
for entity1, entity2 in negatives:
print(f" {entity1.id} -/- {entity2.id}")
linker = FeatureClassifierLinker().fit(
kg1, kg2, ground_truth, blocking=blocking, negatives=negatives, random_state=0
)
results = linker.link(kg1, kg2, blocking=blocking)
predictions = [(r.source_id, r.target_id) for r in results]
report = Evaluator.evaluate(predictions=predictions, ground_truth=ground_truth)
print("Metrics:", report.metrics)
if __name__ == "__main__":
main()
Run with:
uv run python examples/negative_sampling.py
Hard negatives:
kg1:paris -/- kg2:rome
kg1:paris -/- kg2:berlin
kg1:berlin -/- kg2:rome
kg1:berlin -/- kg2:paris
kg1:rome -/- kg2:berlin
kg1:rome -/- kg2:paris
Metrics: {'precision@1': 1.0, 'recall': 1.0, 'f1': 1.0}