Skip to content

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}