Probabilistic Catalog Matching with Fuzzy Scoring
Exact-key joins on ISRC or UPC resolve the majority of catalog records, but every royalty pipeline eventually hits a residual population where identifiers are missing, mistyped by an aggregator, or simply absent from a DSP’s delivery feed. That residual population is where probabilistic matching takes over from the deterministic pass described in Cross-Platform Catalog Matching, and it is also where a badly tuned scoring function does the most financial damage — a false-positive merge silently reassigns payout to the wrong rights holder, while a false negative leaves earned revenue stuck in exception queues. Getting the scoring function, blocking strategy, and threshold calibration right is a precision engineering problem, not a one-line fuzzywuzzy call.
Fuzzy Matching Method Trade-offs
Each scoring method trades recall for precision differently, and the choice of blocking key determines how many candidate pairs the scorer even sees. The table below assumes a catalog of 5–50 million tracks and a nightly reconciliation window.
| Method | Strength | Weakness | Compute Cost | Royalty Impact |
|---|---|---|---|---|
| Levenshtein edit distance | Catches single-character typos in titles | Penalizes word-order swaps and abbreviations heavily | Low (O(n·m) per pair) | Under-merges reordered titles, leaving valid streams unmatched and revenue parked in exception queues |
| Token sort ratio (RapidFuzz) | Order-independent, tolerant of “feat.” / bracket noise | Can over-merge distinct remixes with similar tokens | Low–medium | Reduces false negatives but requires a duration or ISRC-prefix guard to avoid merging distinct masters |
| Jaro-Winkler | Weights prefix similarity; strong on artist name variants | Weak on transposed multi-word titles | Low | Best for payee/artist name reconciliation; used alone on titles it overpays cover versions as originals |
| Blocking on normalized artist + duration bucket | Cuts candidate pairs by 2–3 orders of magnitude before scoring | Misses matches where duration metadata itself is wrong | Very low (indexing only) | Essential for keeping nightly runs tractable; a bad blocking key silently excludes true matches from ever being scored |
| Weighted composite score (title + artist + duration + territory) | Balances signal from multiple fields, tunable per catalog | Requires ongoing weight calibration against labeled data | Medium | Most accurate for production payout decisions, but an unvalidated weight vector can systematically favor one field’s noise |
| Single fixed threshold (e.g., 0.85) | Simple, auditable | Ignores that precision/recall trade-off differs by field combination | None | A threshold set too low auto-merges ambiguous pairs into the wrong payee; set too high, it floods manual review and delays payout |
Prerequisites & Assumptions
The implementation below targets Python 3.11+, rapidfuzz>=3.9 for vectorized string scoring, polars>=0.20 for candidate-pair generation and blocking, and pydantic>=2.6 for the match-result contract. It assumes incoming records have already passed schema validation and that isrc, artist_name, track_title, duration_seconds, and territory_code fields are populated (possibly with noise) but that a deterministic ISRC match has already failed or is absent — this stage only runs on the exact-match miss population.
Step 1: Build blocking keys to bound the candidate space
Scoring every incoming record against the full catalog is quadratic and will not finish inside a nightly window. Blocking keys narrow candidates to a plausible subset before any expensive string comparison runs.
import polars as pl
import unicodedata
import re
def normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
value = re.sub(r"[^a-z0-9 ]", "", value.lower())
return re.sub(r"\s+", " ", value).strip()
def build_blocking_key(df: pl.DataFrame) -> pl.DataFrame:
return df.with_columns([
pl.col("artist_name").map_elements(normalize_text, return_dtype=pl.Utf8).alias("artist_norm"),
pl.col("track_title").map_elements(normalize_text, return_dtype=pl.Utf8).alias("title_norm"),
(pl.col("duration_seconds") // 5 * 5).alias("duration_bucket"),
]).with_columns(
(pl.col("artist_norm").str.slice(0, 4) + "_" + pl.col("duration_bucket").cast(pl.Utf8)).alias("block_key")
)
def generate_candidate_pairs(incoming: pl.DataFrame, catalog: pl.DataFrame) -> pl.DataFrame:
incoming_b = build_blocking_key(incoming)
catalog_b = build_blocking_key(catalog)
return incoming_b.join(catalog_b, on="block_key", how="inner", suffix="_catalog")
Step 2: Score candidate pairs with a weighted composite
Within each candidate block, apply per-field scorers and combine them into a single confidence value rather than trusting any one metric in isolation.
from rapidfuzz import fuzz
from pydantic import BaseModel, Field
class MatchScore(BaseModel):
isrc: str | None = None
catalog_isrc: str = Field(...)
title_score: float = Field(ge=0, le=100)
artist_score: float = Field(ge=0, le=100)
duration_delta: int
composite_score: float = Field(ge=0, le=100)
confidence_tier: str
FIELD_WEIGHTS = {"title": 0.45, "artist": 0.40, "duration_penalty": 0.15}
def score_pair(row: dict) -> MatchScore:
title_score = fuzz.token_sort_ratio(row["title_norm"], row["title_norm_catalog"])
artist_score = fuzz.WRatio(row["artist_norm"], row["artist_norm_catalog"])
duration_delta = abs(row["duration_seconds"] - row["duration_seconds_catalog"])
duration_penalty = max(0.0, 100 - duration_delta * 8)
composite = (
title_score * FIELD_WEIGHTS["title"]
+ artist_score * FIELD_WEIGHTS["artist"]
+ duration_penalty * FIELD_WEIGHTS["duration_penalty"]
)
if composite >= 92:
tier = "auto_merge"
elif composite >= 78:
tier = "manual_review"
else:
tier = "reject"
return MatchScore(
catalog_isrc=row["isrc_catalog"],
title_score=title_score,
artist_score=artist_score,
duration_delta=duration_delta,
composite_score=round(composite, 2),
confidence_tier=tier,
)
Step 3: Route by confidence tier, never by a single global cutoff
A single threshold treats “no ISRC at all” the same as “ISRC present but malformed,” which have very different risk profiles. Split routing so that low-risk auto-merges and high-risk manual reviews follow separate paths, and persist every score — not just the winner — for audit purposes.
def route_matches(scores: list[MatchScore], audit_sink) -> dict[str, list[MatchScore]]:
routed: dict[str, list[MatchScore]] = {"auto_merge": [], "manual_review": [], "reject": []}
for score in scores:
routed[score.confidence_tier].append(score)
audit_sink.write(score.model_dump())
return routed
Verification & Validation
Before promoting a scoring change to production, replay it against a labeled holdout set of previously resolved matches and track the ISRC resolution rate (percentage of exact-match misses successfully closed by the fuzzy layer) alongside a precision figure computed from manually confirmed pairs. Assert that auto_merge precision stays above 99.5% — because auto-merges bypass human review, any drift below that threshold should block deployment. Log the full MatchScore payload, including rejected candidates, to an append-only table keyed by batch_id so that a royalty manager disputing a payout can reconstruct exactly why a track was or was not merged, and reconcile the count of auto_merge rows against the delta in the payout ledger for that reporting period.
Edge Cases & Gotchas
Cover versions and live re-recordings routinely score above 90 on title and artist alone because the composite weights in this example do not penalize duration mismatches heavily enough for short-duration catalogs — tune the duration penalty steeper for classical or spoken-word content where a 30-second difference is a different work entirely. Territory-code noise (US vs USA vs a missing code) will silently exclude legitimate blocking-key matches if normalization runs after the block key is built rather than before. Unicode artist names transliterated differently across DSPs (e.g., a Cyrillic or Hangul name romanized two different ways) will pass Jaro-Winkler poorly even when the underlying work is identical — maintain a transliteration lookup rather than relying on scoring alone. Finally, NULL duration fields break the bucket-based blocking key outright; coerce them to a sentinel bucket rather than letting the join silently drop the row.
Related
Records that clear the manual_review tier but remain unresolved after human inspection should fall through to the Fallback Routing Logic Design layer rather than being force-merged, and persistent identifier collisions surfaced by low composite scores are often better resolved with the dedicated ISRC Collision Resolution Strategies covered separately.