Fuzzy Name Matching Against the OFAC SDN List
A wire names its beneficiary as Sr. José Ramírez-González; the OFAC SDN list carries the blocked person as RAMIREZ GONZALEZ, Jose. Exact string equality says no match, and a payment to a sanctioned party clears — a strict-liability violation. The fix is a name-matching routine built specifically for sanctions: one that folds away the honorific, the accents, the punctuation, and the word order before it scores, and that is tuned so a borderline case surfaces for a human rather than slipping through. This guide sits under the OFAC sanctions screening reference, within the broader exception routing and fraud-pattern detection framework, and it specifies exactly one component of that pipeline: the normalize-and-score function that turns a raw party name into a set of candidate SDN hits.
This is the same family of technique as the reconciliation matcher's Levenshtein distance on payment references, but the objective is inverted. Reference matching tunes for precision — a false match manufactures a ledger break. Sanctions matching tunes for recall — a false negative is a violation — so the threshold sits deliberately low and precision is recovered downstream through secondary-identifier review, not by tightening the score.
Concept Spec: Normalization Then Scoring
Name matching against the SDN list is two stages that must never be collapsed. First, normalization projects both the incoming name and every list name onto a canonical form so that cosmetic differences stop counting as edits:
- Uppercase / case-fold — the SDN flat files are upper-case; fold the incoming name to match.
- Strip titles and honorifics —
MR,MRS,DR,SR,SRA,SHEIKH,HAJJIcarry no identifying value and inflate token overlap falsely. - Transliterate — map non-Latin scripts and common variants to a Latin baseline so
Yusuf,Yousef, andYoussefconverge. - Remove diacritics — decompose to NFKD and drop combining marks, so
RAMÍREZbecomesRAMIREZ. - Tokenize — split into a set of word tokens, so word order stops mattering.
Second, scoring compares the two normalized token sets. Character-level edit distance alone fails here because sanctioned names are routinely reordered (SURNAME, Given vs Given SURNAME) — a transposition that Levenshtein punishes heavily. So the scorer combines a token-set comparison (order-independent) with Jaro-Winkler string similarity, which rewards a shared prefix and handles transposed characters within a short window — the profile of real transliteration drift.
Jaro similarity between strings and with matching characters and transpositions is:
Two characters count as matching only if they are within positions of each other. Jaro-Winkler then boosts the score for a shared prefix of length (capped at 4) with scaling factor (conventionally ):
The prefix boost is well-suited to sanctions names, where the surname often leads and shares its opening characters across spellings. Both scores are per comparison, which is why a blocking step must narrow the candidate set before the full scorer runs against 17,000-plus entries and their aliases.
Full Annotated Python Implementation
The function normalizes a party name, then scores it against every alias of every candidate SDN entry, returning the candidates whose best alias score clears a recall-first threshold. It is copy-paste ready and depends only on the standard library.
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
# Honorifics and titles carry no identifying value; drop them as whole tokens.
_TITLES: frozenset[str] = frozenset({
"MR", "MRS", "MS", "MISS", "DR", "PROF", "SIR", "SR", "SRA", "SRTA",
"MME", "MLLE", "HERR", "SHEIKH", "SHAYKH", "HAJJI", "HAJ", "SAYYID",
})
# Minimal transliteration/variant folding. A production table is far larger and
# is maintained per source script; this is the shape, not the whole map.
_TRANSLIT: dict[str, str] = {
"Ø": "O", "Æ": "AE", "ß": "SS", "Ð": "D", "Þ": "TH", "Ł": "L",
}
def normalize_name(raw: str) -> tuple[str, ...]:
"""Project a party name onto a canonical, order-independent token set.
Uppercase -> strip diacritics (NFKD) -> transliterate residual letters ->
keep only alphanumeric tokens -> drop honorifics. Returns a tuple of tokens
so the caller can compare as a set without losing determinism for logging.
"""
# Decompose accented characters and discard the combining marks.
decomposed = unicodedata.normalize("NFKD", raw)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
folded = "".join(_TRANSLIT.get(c, c) for c in stripped).upper()
# Split on any non-alphanumeric run, so commas, hyphens, and dots vanish.
tokens: list[str] = []
current: list[str] = []
for ch in folded:
if ch.isalnum():
current.append(ch)
elif current:
tokens.append("".join(current))
current = []
if current:
tokens.append("".join(current))
return tuple(t for t in tokens if t not in _TITLES)
def _jaro(s1: str, s2: str) -> float:
"""Jaro similarity in [0, 1]; matches only within a bounded window."""
if s1 == s2:
return 1.0
if not s1 or not s2:
return 0.0
window = max(len(s1), len(s2)) // 2 - 1
s1_flags = [False] * len(s1)
s2_flags = [False] * len(s2)
matches = 0
for i, c1 in enumerate(s1):
lo = max(0, i - window)
hi = min(i + window + 1, len(s2))
for j in range(lo, hi):
if not s2_flags[j] and s2[j] == c1:
s1_flags[i] = s2_flags[j] = True
matches += 1
break
if matches == 0:
return 0.0
# Count transpositions among the matched characters.
transpositions = 0
k = 0
for i, flagged in enumerate(s1_flags):
if flagged:
while not s2_flags[k]:
k += 1
if s1[i] != s2[k]:
transpositions += 1
k += 1
t = transpositions / 2
m = matches
return (m / len(s1) + m / len(s2) + (m - t) / m) / 3
def jaro_winkler(s1: str, s2: str, p: float = 0.1) -> float:
"""Jaro-Winkler: Jaro plus a prefix boost (prefix capped at 4 chars)."""
j = _jaro(s1, s2)
prefix = 0
for c1, c2 in zip(s1, s2):
if c1 != c2 or prefix == 4:
break
prefix += 1
return j + prefix * p * (1 - j)
def token_set_score(a: tuple[str, ...], b: tuple[str, ...]) -> float:
"""Order-independent name similarity via best-token Jaro-Winkler.
For each token on the shorter side, take its best Jaro-Winkler match on the
other side, then average. Word order and extra middle names do not sink the
score, which is the behaviour sanctions names demand.
"""
if not a or not b:
return 0.0
short, long = (a, b) if len(a) <= len(b) else (b, a)
total = 0.0
for tok in short:
total += max(jaro_winkler(tok, other) for other in long)
return total / len(short)
@dataclass(frozen=True, slots=True)
class NameHit:
entry_id: str
matched_alias: str
score: float
def score_against_sdn(
party_name: str,
entries: "Iterable[SanctionsListEntry]",
threshold: float = 0.87,
) -> list[NameHit]:
"""Score a party name against SDN entries; return candidates >= threshold.
Recall-first: the threshold is intentionally low. Every returned hit is a
*candidate* for human review with secondary identifiers, never an automatic
determination. Callers must log the score and the alias for the audit trail.
"""
query = normalize_name(party_name)
hits: list[NameHit] = []
for entry in entries:
best = 0.0
best_alias = ""
for alias in (entry.primary_name, *entry.aliases):
s = token_set_score(query, normalize_name(alias))
if s > best:
best, best_alias = s, alias
if best >= threshold:
hits.append(NameHit(entry.entry_id, best_alias, round(best, 4)))
return sorted(hits, key=lambda h: h.score, reverse=True)
The function never decides anything on its own: it returns candidates. The disposition — hold, investigate, block, reject, or release — belongs to the screening pipeline that calls it, and every score it emits is written to the audit trail so an examiner can see why two names were treated as one.
Calibrating the Threshold
The threshold is the single most consequential knob, and the governing principle is recall first: it is better to surface a false positive for a human to clear than to let a true match through. That inverts the tuning from reference matching, where the same code leans toward precision.
- Start low and measure, not guess. A
token_set_scorethreshold around0.85–0.88is a common recall-first starting band. Backtest it against a labeled set — known true aliases plus adversarial common-name collisions — and move it only on evidence, never on queue-volume pressure. - Tune per list, not globally. The SDN list warrants the most aggressive recall because a full block is the harshest outcome; a narrower consolidated list (SSI, FSE) with a specific prohibition can tolerate a slightly higher threshold. One global number over-blocks on the narrow lists or under-catches on the SDN list.
- Recover precision downstream, not by raising the threshold. Knock down false positives with secondary identifiers — date of birth, passport, country, address from the SDN
ADD/ALTrecords — and with a reviewed, audited whitelist of previously-cleared names. Raising the score threshold to cut noise is the one move that can drop a true match, so it is the last lever, not the first. - Read the score relative to name length. A single shared token (
MOHAMMED) against a two-token entry scores high on overlap but means little; require a minimum matched-token count or length before a high score is trusted, so common given names alone do not trip a hit.
Before and After: A Real-Looking Name
Take an incoming wire beneficiary Sr. José Ramírez-González and a (fictional-but-representative) SDN entry RAMIREZ GONZALEZ, Jose Antonio with alias Jose Ramirez.
Before (exact match): "Sr. José Ramírez-González" == "RAMIREZ GONZALEZ, Jose Antonio" is False. The honorific, the accents, the hyphen, the comma, the reordered surname, and the extra middle name all defeat equality, and the payment clears to a blocked person.
After (normalize + token-set score): normalize_name("Sr. José Ramírez-González") decomposes the accents (É→E, Í→I), uppercases, splits on the hyphen and spaces, and drops the honorific SR, yielding ("JOSE", "RAMIREZ", "GONZALEZ"). The primary RAMIREZ GONZALEZ, Jose Antonio normalizes to ("RAMIREZ", "GONZALEZ", "JOSE", "ANTONIO"). Every query token finds an exact Jaro-Winkler match of 1.0 in the entry (ANTONIO is simply extra and never penalizes, because scoring iterates the shorter side), so token_set_score == 1.0, far above a 0.87 threshold. score_against_sdn returns a NameHit with score=1.0, the item holds, and an analyst confirms against the date of birth on the SDN record.
Now the counter-case: incoming Jose Ramos against the same entry normalizes to ("JOSE", "RAMOS"). JOSE matches at 1.0; RAMOS against RAMIREZ scores Jaro-Winkler ≈ 0.87 (shared RAM prefix, then divergence), averaging ≈ 0.93. That clears 0.87 and surfaces as a candidate — a false positive an analyst clears in seconds by checking that the beneficiary's country and date of birth do not match the SDN record. That is the recall-first bargain working as designed: a little analyst time spent so no true match is lost.
Failure Modes & Guardrails
- Over-normalization merging distinct people. Aggressive transliteration and title-stripping can collapse genuinely different names onto the same token set — folding every
AL-,BIN,IBN, orVONparticle away can merge unrelated individuals. Keep normalization conservative and symmetric (apply identically to query and list), and lean on secondary identifiers rather than ever-more-aggressive folding to separate collisions. Log the normalized form so a merge is visible in review. - Threshold set too high, missing a hit. The instinct under a flood of false positives is to raise the threshold, but that is exactly the move that lets a true match through and creates a strict-liability violation. Cut noise with identifiers and whitelists; treat the threshold as recall-protective and move it down, not up, when in doubt.
- Non-ASCII names mis-encoded before they reach the scorer. If an upstream stage double-decodes UTF-8 or drops bytes,
Josécan arrive asJoséorJos?, and normalization then folds garbage. Feed the scorer only text that ingestion has already NFC-normalized and validated;normalize_namecanonicalizes for matching, it is not a repair layer for encoding drift on legacy exports. Mojibake in equals mojibake scored.
The load-bearing rule: the score is a candidate signal, always logged, tuned for recall, and always handed to a human for disposition against secondary identifiers — it never blocks or clears a payment on its own.
Frequently Asked Questions
Why Jaro-Winkler instead of Levenshtein for names?
Because sanctioned names are routinely reordered and re-spelled. Levenshtein punishes whole-token transposition heavily — SURNAME, Given versus Given SURNAME scores as a large edit distance despite being the same person. A token-set comparison makes word order irrelevant, and Jaro-Winkler rewards the shared prefixes that survive transliteration, which is the actual shape of SDN name drift. Levenshtein remains the right tool for character-level reference drift, not for names.
How do I stop scoring from going quadratic against 17,000 entries?
Put a blocking step in front. Bucket candidates by a fast key — a phonetic code (Soundex/Metaphone) on the surname token, or an n-gram index — so the full Jaro-Winkler scorer only runs against a handful of plausible entries per name. The scorer is cheap per call; the cost is entirely in how many calls you make, so narrowing the candidate set is where real-time interdiction latency is won.
Should the threshold be the same for the SDN and consolidated lists?
No. Tune per list. The SDN list drives full blocking, the harshest outcome, so it warrants the most aggressive recall (lowest threshold). Narrower consolidated lists carry specific, lesser prohibitions and can tolerate a slightly higher threshold. A single global threshold either over-blocks on the narrow lists or under-catches on the SDN list.
Does a high name score mean I should block the payment?
Never on its own. A high score means candidate for review. The disposition — hold, investigate, then block, reject, or release — is a human decision made against secondary identifiers (date of birth, passport, country) and, for entities, the OFAC 50% Rule ownership data. The scorer's job is to make sure a true match is never missed; confirming it and choosing the action is the analyst's.
Related
- OFAC Sanctions Screening on Unmatched Payments — the parent reference: when screening runs, the lists, the 50% Rule, hit disposition, and reporting.
- Implementing Levenshtein Distance for Payment References — the reconciliation counterpart of this technique, tuned for precision rather than recall.
- Deterministic vs Fuzzy Matching Logic — the exact-then-fuzzy layering that the same name-matching code serves on the reconciliation side.
- Fraud-Pattern Detection Signals in Payment Reconciliation — the sibling signal layer that scores the same breaks for duplicates, velocity, and structuring.