Skip to content

How to reduce false positives in sanctions screening: testing for false alerts and missed matches

To reduce false positives in sanctions screening, score a labelled set of synthetic name variants, built in the US OFAC (Office of Foreign Assets Control), EU, and UK list formats, and compute precision and recall per threshold. The decisive check: a different person's short name can outscore a transliterated true match, so a date of birth rule separates them.

Why a lower alert count can hide a missed match

The defect is a threshold change approved on alert volume alone. A compliance team raises the match threshold to shrink the review queue, the queue shrinks, and nobody checks which true matches fell below the new line. A missed match surfaces only after a payment to a listed person has left.

OFAC's FAQ 5 (released 30 January 2015, updated 9 September 2026) describes the manual work behind each alert: evaluate the quality of the potential match against the full list entry, then determine next steps for a valid match. It states that "many potential matches identified through screening are false positives" and that OFAC does not confirm potential matches or false positives, so each institution measures both error rates itself. The Wolfsberg Guidance on Sanctions Screening (Wolfsberg Group, 2019), section 3.4, asks for testing that the application "generates expected alerts" and that thresholds suppress undesirable alerts within the firm's risk appetite. That is two measurements: recall on known matches and precision on alerts.

The consequences for screening vendors and their bank clients are on the KYC and AML segment page, and the EU rules on rescreening and ownership are on the AML and KYC requirements page.

Step 1. Build the labelled set from the public list formats

Take the structure from the official files and invent every name. Real list entries are linked only as format sources; a real listed person never appears as an example false positive, and a real customer never appears in a fixture.

ListCustodian on 2026-09-22Machine formatsWhere identifiers sit
SDN listOFAC, US Treasury, through the Sanctions List ServiceSDN.CSV with ADD.CSV, ALT.CSV, and SDN_COMMENTS.CSV; fixed-field; SDN.XML; SDN_ADVANCED.XMLCSV: date of birth, passport, and weak aliases inside the Remarks text; advanced XML: separate elements
EU consolidated financial sanctions listEuropean Commission, Financial Sanctions FilesXML 1.1 and 1.0, CSV 1.1 and 1.0, PDFnameAlias with wholeName, firstName, lastName, strong; birthdate; identification; citizenship
UK Sanctions ListFCDO (Foreign, Commonwealth and Development Office), UK Sanctions ListODT, ODS, XML, HTML, TXT, CSV, PDFName 1 to Name 6, Name type, Alias strength, D.O.B in dd/mm/yyyy, Passport number, National Identifier number

Sources: the OFAC SDN data specification (updated 25 September 2023), the EU XML 1.1 file downloaded on 22 September 2026, and the FCDO format guide (published 22 December 2025). The OFSI Consolidated List of Asset Freeze Targets closed on 28 January 2026, and the FCDO list has been the only source of UK designations since then (GOV.UK notice). A screening configuration that still points at the OFSI file has received no UK update since that date.

For each synthetic entry, write positives in named variant classes and negatives that share part of the name. The list file follows the SDN convention of "SURNAME, Given" and uses a year-only date of birth for one entry, as many real entries do.

Data
list_id,entry_type,name,dob,country
SYN-001,individual,"VELMOZHIN, Grigoriy Pavlovich",1971-03-14,RU
SYN-002,individual,"QASIMZADEH, Abdulrahman",1966,IR
SYN-003,entity,Northgate Maritime Trading LLC,,AE
SYN-004,individual,"DUAN, Jingruo",1983-11-02,CN

Save the block above as synthetic_list.csv and the block below as labelled_cases.csv.

Data
case_id,input_name,dob,country,expected_id,variant
C01,Grigory Velmozhin,1971-03-14,RU,SYN-001,transliteration and dropped patronymic
C02,Velmozhin Grigoriy Pavlovich,1971-03-14,RU,SYN-001,surname first
C03,Grigorij Velmožin,1971-03-14,RU,SYN-001,diacritic transliteration
C04,Gregory Velmozhina,1994-07-30,US,,different person with a close name
C05,Abdul Rahman Qasimzadeh,1966-09-02,IR,SYN-002,split given name
C06,Abdulrahman Kasimzade,,TR,SYN-002,transliteration without date of birth
C07,Abdulla Qasimov,1990-01-15,UZ,,different person with a shared prefix
C08,Northgate Maritime Trading L.L.C.,,AE,SYN-003,legal form punctuation
C09,Northgate Marine Supplies Ltd,,GB,,different entity with a shared first word
C10,Jingruo Duan,1983-11-02,CN,SYN-004,given name first
C11,Duan Jing Ruo,1983-11-02,SG,SYN-004,split syllables
C12,Duan Jing,2001-05-19,CN,,short name of a different person
C13,Grace Velmore,1985-02-11,GB,,different person with a shared surname prefix
C14,Jingruo Duan,1983-02-11,CN,SYN-004,day and month swapped at onboarding

A production set holds many rows per variant class and per source script, and a second reviewer confirms each label before it becomes ground truth.

Step 2. Score every threshold and compute precision and recall

The script reads both files, scores each case against every list entry with token-level Jaro-Winkler similarity, and counts outcomes per threshold. To test a vendor engine, replace name_score with a call to its screening API and keep the counting.

Python
"""Score a name screening configuration against labelled synthetic cases.
Reads synthetic_list.csv and labelled_cases.csv from the working directory."""
import csv
import sys
import unicodedata
from datetime import date

LEGAL_FORMS = {"llc", "ltd", "limited", "inc", "co", "sarl", "gmbh"}

def normalise(name: str) -> list[str]:
    if "," in name:  # SDN style "SURNAME, Given" becomes "Given SURNAME"
        surname, _, given = name.partition(",")
        name = f"{given} {surname}"
    text = unicodedata.normalize("NFKD", name)
    text = "".join(c for c in text if not unicodedata.combining(c)).lower().replace(".", "")
    text = "".join(c if c.isalnum() else " " for c in text)
    return [t for t in text.split() if t not in LEGAL_FORMS]

def jaro_winkler(a: str, b: str) -> float:
    if a == b:
        return 1.0
    if not a or not b:
        return 0.0
    window = max(len(a), len(b)) // 2 - 1
    a_hit, b_hit = [False] * len(a), [False] * len(b)
    matches = 0
    for i, ch in enumerate(a):
        for j in range(max(0, i - window), min(len(b), i + window + 1)):
            if not b_hit[j] and b[j] == ch:
                a_hit[i] = b_hit[j] = True
                matches += 1
                break
    if matches == 0:
        return 0.0
    a_seq = [c for c, hit in zip(a, a_hit) if hit]
    b_seq = [c for c, hit in zip(b, b_hit) if hit]
    transpositions = sum(x != y for x, y in zip(a_seq, b_seq)) / 2
    jaro = (matches / len(a) + matches / len(b) + (matches - transpositions) / matches) / 3
    prefix = 0
    for x, y in zip(a[:4], b[:4]):
        if x != y:
            break
        prefix += 1
    return jaro + prefix * 0.1 * (1 - jaro)

def name_score(query: str, entry: str) -> float:
    q, e = normalise(query), normalise(entry)
    if not q or not e:
        return 0.0
    short, long_ = (q, e) if len(q) <= len(e) else (e, q)
    token = sum(max(jaro_winkler(t, u) for u in long_) for t in short) / len(short)
    compact = jaro_winkler("".join(sorted(q)), "".join(sorted(e)))
    return round(max(token, compact), 3)

def dob_conflict(case_dob: str, entry_dob: str, mode: str) -> bool:
    """True when the date of birth rules out the name match. List dates are YYYY or YYYY-MM-DD."""
    a, b = case_dob.strip(), entry_dob.strip()
    if mode == "off" or not a or not b:
        return False
    if mode == "exact":
        return a != b
    try:  # mode "tolerant": compare the year only when either side is year-only
        ay, by = int(a[:4]), int(b[:4])
        if len(a) == 4 or len(b) == 4:
            return ay != by
        da, db = date.fromisoformat(a), date.fromisoformat(b)
    except ValueError:
        return False  # unparseable date: never discount, the analyst decides
    swapped = da.year == db.year and da.month == db.day and da.day == db.month
    return da != db and not swapped

def load(path: str, required: set[str]) -> list[dict]:
    with open(path, newline="", encoding="utf-8") as f:
        rows = [{k: (v or "") for k, v in row.items() if k} for row in csv.DictReader(f)]
    missing = required - set(rows[0].keys() if rows else [])
    if missing:
        raise KeyError(f"{path}: missing columns {sorted(missing)}")
    return rows

def evaluate(cases, entries, threshold: float, dob_mode: str) -> dict:
    tp = fp = fn = alerts = 0
    for case in cases:
        best = max(entries, key=lambda e: name_score(case["input_name"], e["name"]))
        score = name_score(case["input_name"], best["name"])
        alert = score >= threshold
        if alert and dob_conflict(case["dob"], best["dob"], dob_mode):
            alert = False  # secondary identifier discounts the name match
        expected = case["expected_id"].strip()
        correct = alert and best["list_id"] == expected
        alerts += alert
        tp += correct
        fp += alert and not correct
        fn += bool(expected) and not correct
    precision = tp / (tp + fp) if tp + fp else float("nan")
    recall = tp / (tp + fn) if tp + fn else float("nan")
    per_hit = alerts / tp if tp else float("inf")
    return {"alerts": alerts, "tp": tp, "fp": fp, "fn": fn,
            "precision": precision, "recall": recall, "alerts_per_hit": per_hit}

def main() -> int:
    try:
        entries = load("synthetic_list.csv", {"list_id", "name", "dob"})
        cases = load("labelled_cases.csv", {"case_id", "input_name", "dob", "expected_id"})
    except (OSError, csv.Error, UnicodeDecodeError, KeyError) as exc:
        print(f"RESULT: cannot run, {exc}")
        return 2
    if not entries or not cases:
        print("RESULT: cannot run, empty input file")
        return 2
    for case in cases:
        best = max(entries, key=lambda e: name_score(case["input_name"], e["name"]))
        print(f"{case['case_id']}  {name_score(case['input_name'], best['name']):.3f}  {best['list_id']}  expected={case['expected_id'] or '-'}")
    print("threshold  dob_rule  alerts  tp  fp  fn  precision  recall  alerts_per_hit")
    for threshold in (0.80, 0.85, 0.90, 0.95):
        for mode in ("off", "exact", "tolerant"):
            r = evaluate(cases, entries, threshold, mode)
            print(f"{threshold:9.2f}  {mode:8}  {r['alerts']:6}  {r['tp']:2}  {r['fp']:2}  {r['fn']:2}"
                  f"  {r['precision']:9.2f}  {r['recall']:6.2f}  {r['alerts_per_hit']:14.2f}")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Output on the two files above, with Python 3.9 or later:

Output
C01  0.988  SYN-001  expected=SYN-001
C02  1.000  SYN-001  expected=SYN-001
C03  0.964  SYN-001  expected=SYN-001
C04  0.938  SYN-001  expected=-
C05  0.945  SYN-002  expected=SYN-002
C06  0.971  SYN-002  expected=SYN-002
C07  0.862  SYN-002  expected=-
C08  1.000  SYN-003  expected=SYN-003
C09  0.802  SYN-003  expected=-
C10  1.000  SYN-004  expected=SYN-004
C11  1.000  SYN-004  expected=SYN-004
C12  0.957  SYN-004  expected=-
C13  0.747  SYN-001  expected=-
C14  1.000  SYN-004  expected=SYN-004
threshold  dob_rule  alerts  tp  fp  fn  precision  recall  alerts_per_hit
     0.80  off           13   9   4   0       0.69    1.00            1.44
     0.80  exact          8   7   1   2       0.88    0.78            1.14
     0.80  tolerant      10   9   1   0       0.90    1.00            1.11
     0.85  off           12   9   3   0       0.75    1.00            1.33
     0.85  exact          7   7   0   2       1.00    0.78            1.00
     0.85  tolerant       9   9   0   0       1.00    1.00            1.00
     0.90  off           11   9   2   0       0.82    1.00            1.22
     0.90  exact          7   7   0   2       1.00    0.78            1.00
     0.90  tolerant       9   9   0   0       1.00    1.00            1.00
     0.95  off            9   8   1   1       0.89    0.89            1.12
     0.95  exact          7   7   0   2       1.00    0.78            1.00
     0.95  tolerant       8   8   0   1       1.00    0.89            1.00

Precision is true alerts divided by all alerts. Recall is true alerts divided by all labelled matches. An alert that points to the wrong list entry counts as both a false positive and a miss.

Step 3. Use secondary identifiers, and test the rule that uses them

A name-only threshold cannot separate C12 from C05. Dates of birth can: FAQ 5 asks "Does the party to your transaction or activity have a different date of birth, identification number, or nationality?" when judging match quality, and section 6.3 of the Wolfsberg guidance says identifying information such as date of birth "does not need to be screened" and helps tell a true match from a false positive.

The discount rule itself needs tests. With exact string comparison, recall drops to 0.78 at every threshold: C05 is suppressed because the list gives only the year 1966, and C14 is suppressed because onboarding swapped day and month. The tolerant rule compares years when either side is year-only and treats a day-month swap as agreement. Year-only values are common in the source data: the SDN.CSV file downloaded on 22 September 2026 holds 1,018 date of birth values that give only a year, in the form "DOB 1966", and 771 alternative dates marked "alt. DOB".

Weak aliases belong in the same step. OFAC FAQ 124 states that OFAC does not expect persons to screen for weak AKAs and that they may help decide whether a hit from other information is accurate. FAQ 123 says where each format marks them: quotation marks in the CSV Remarks field, a weak type in SDN.XML, and LowQuality="true" in the advanced XML. A loader that imports every alias as a strong name screens the names that FAQ 122 describes as likely to "generate a large volume of false hits".

Step 4. Test ownership separately from names

OFAC's Revised Guidance on Entities Owned by Persons Whose Property and Interests in Property are Blocked (13 August 2014) states that "any entity owned in the aggregate, directly or indirectly, 50 percent or more by one or more blocked persons is itself considered to be a blocked person", regardless of whether the entity itself is listed. The rule speaks to ownership and not to control (FAQ 398), and FAQ 401 counts ownership through intermediate entities that are themselves 50 percent or more owned by blocked persons. Name screening never alerts on such an entity, so the test feeds ownership data:

JSON
{
  "blocked": ["SYN-001"],
  "ownership": [
    { "owner": "SYN-001", "entity": "Harbourline Holdings", "percent": 50 },
    { "owner": "Harbourline Holdings", "entity": "Estuary Freight Ltd", "percent": 50 },
    { "owner": "SYN-001", "entity": "Bracken Logistics", "percent": 40, "control": "sole director" }
  ],
  "expected": {
    "Harbourline Holdings": "blocked, direct ownership of 50 percent",
    "Estuary Freight Ltd": "blocked, indirect ownership through a blocked entity",
    "Bracken Logistics": "not blocked by the 50 Percent Rule; our test expects a caution flag for review"
  }
}

The caution flag is our design, based on the guidance advising caution with a non-blocked entity in which blocked persons hold a significant ownership interest below 50 percent or which they may control by other means. The EU ownership test differs, and the AML and KYC page lists it.

Step 5. Run regression on every list update

Pin each run to a list version: the publication in the OFAC Delta File Archive, the generationDate attribute of the EU XML export, and the "Last Updated" column of the UK file. Then, on each update:

  1. Classify the changes as added entries, removed entries, and amended entries such as a new alias or a new date of birth.
  2. Rescreen the full customer base against added and amended entries. A job that rescreens only against new entries misses a customer who matches an alias added to an existing entry.
  3. Rerun the labelled set and diff it against the previous version. A lost true alert fails the release; a new false alert goes to the precision report.
  4. Record alert volume per list. The Wolfsberg glossary names an "alert spike" caused by watchlist updates, so compare the volume change with the size of the update.

Alert-to-hit ratio as a production metric

We define the alert-to-hit ratio as alerts raised divided by alerts confirmed as true matches, per list, per business line, and per list version. Section 3.3 of the Wolfsberg guidance lists alerts generated by list, by jurisdiction, and by business as example metrics; the ratio adds the confirmed-hit denominator. On the labelled set the ratio is 1.44 at threshold 0.80 with no date rule, because true matches were planted in 9 of 14 cases. In production, true matches are rare, so the ratio runs far higher and only trends matter.

Report the ratio next to recall on the planted set. A falling ratio with falling recall means suppression, and the review queue shrank at the cost of misses.

The pitfall: short names outscore true matches

In the output, C12 "Duan Jing", a different person, scores 0.957, and C05, a true transliterated match, scores 0.945. At threshold 0.95 without a date rule, the engine keeps the false alert and drops the true match. Jaro-Winkler adds a bonus for up to four shared leading characters, and a two-token name has fewer tokens to disagree, so no global threshold separates the two. OFAC's own weak alias review, quoted in the data specification, used character length as a flagging criterion because "shorter strings were assumed to be less effective" in screening. Test short names as their own variant class and fix them with a secondary identifier rule.

Two file-level pitfalls sit next to it. The SDN.CSV file downloaded on 22 September 2026 has no header row. The data specification truncates Remarks at 1,000 characters and keeps the full text in the SDN_COMMENTS.CSV spillover file, so a loader that skips that file loses every identifier past the cut. The UK CSV downloaded on 22 September 2026 starts with a "Report Date" line above the header row, so a parser that takes line one as the header maps every column wrong.

Test matrix

Variant classExample caseExpected resultFailure it catches
TransliterationC01, C03Alert to SYN-001Latin-only or diacritic-sensitive matching
Name orderC02, C10Alert to the listed entryPositional first and last name comparison
Split or joined tokensC05, C11Alert to the listed entryToken count penalty
Short name of a different personC12No alert after the date ruleThreshold raised instead of adding a secondary identifier
Year-only list date of birthC05Alert keptExact date comparison
Swapped day and monthC14Alert keptExact date comparison
Legal form punctuationC08Alert to SYN-003"L.L.C." not normalised
Indirect ownershipEstuary Freight LtdBlockedName screening used as the ownership control
Amended list entryNew alias on an existing entryNew alert on rescreenRescreen only against added entries

We run this matrix in KYC, AML and fraud testing, including payout screening for remittance products, withdrawal screening on crypto exchanges, and onboarding screening at neobanks, and the labelled set follows the data rules on our synthetic test data comparison.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing