Fuzzy matching: methods, thresholds, and tuning
Source:vignettes/fuzzy-matching.Rmd
fuzzy-matching.RmdThis vignette shows how taxify() corrects misspelled
names and how to tune that correction. When a name finds no exact match,
taxify() computes a string distance between it and the
backbone candidates in the same genus, and returns the closest candidate
whose distance falls below a threshold. Genus blocking keeps the search
fast on backbones with millions of rows. String distance counts
character edits, so a fuzzy match says two strings are spelled alike and
nothing about whether they name the same organism: it catches typos,
transliteration errors and OCR artefacts, and it cannot resolve a
taxonomic disagreement or turn a common name into a Latin binomial. A
fuzzy match at distance 0.05 almost certainly corrects a minor typo; one
at 0.18 might correct a larger OCR error or might have landed on the
wrong species, and the fuzzy_dist column in the output is
there to tell the two apart. Fuzzy matching runs only on names that
survived cleaning and every exact pass without a hit, so it never
overrides an exact match, even when a closer candidate exists under
another spelling.
Match the names with
taxify(), which falls back to fuzzy matching for names no exact pass resolves.Pick the distance algorithm with
fuzzy_method.Set the tolerance with
fuzzy_threshold, as a fraction of name length or as a raw edit count.Review the questionable matches by their
fuzzy_dist.Skip fuzzy matching for curated lists with
fuzzy = FALSE.
Example
Names that need no fuzzy matching
A curated species list, with or without authorship strings, typically resolves entirely by exact match. Fuzzy matching runs but finds nothing to do.
clean_names <- c(
"Quercus robur",
"Pinus sylvestris",
"Betula pendula",
"Fagus sylvatica",
"Acer pseudoplatanus"
)
result <- taxify(clean_names)
# All rows have match_type == "exact"
table(result$match_type)
# exact
# 5
# fuzzy_dist is NA for all rows
all(is.na(result$fuzzy_dist))
# TRUEThe cleaning pipeline strips authorship before matching, so adding it does not change the picture:
with_authors <- c(
"Quercus robur L.",
"Pinus sylvestris L.",
"Betula pendula Roth",
"Fagus sylvatica L.",
"Acer pseudoplatanus L."
)
result <- taxify(with_authors)
table(result$match_type)
# exact
# 5For curated data like this, fuzzy = FALSE skips the step
entirely and saves a small amount of time on large lists.
Typos and OCR errors
Species lists transcribed from handwritten field notes or extracted from scanned PDFs by OCR arrive with the errors fuzzy matching is designed to rescue:
messy_names <- c(
"Qurecus robur", # transposition: ur -> ru
"Taraxacum officianle", # transposition: al -> la
"Plantago lanceoalata", # transposition: la -> al
"Trifolium repnes", # transposition: en -> ne
"Dactylis gloemrata", # transposition: me -> em
"Lolium perrene", # two edits from perenne
"Achillea millefolum", # deletion: i missing
"Ranunculus acris" # correct (should exact-match)
)
result <- taxify(messy_names)
# Check what matched and how
result[, c("input_name", "accepted_name", "match_type", "fuzzy_dist")]The transposition errors (Qurecus, officianle,
lanceoalata) each cost 1 edit under Damerau-Levenshtein,
producing fuzzy_dist values around 0.05-0.08 for these
13-20 character names. The deletion in millefolum (missing
i) also costs 1 edit, and Ranunculus acris
exact-matches with fuzzy_dist = NA.
For Taraxacum officianle (20 characters) the intended
target, Taraxacum officinale, differs by a transposition of
a and l at positions 18-19: 1 edit, a normalized
distance of 1 / 20 = 0.05, inside the 0.2 threshold and
inside a conservative 0.1 too. Lolium perrene (14 characters)
puts the doubled consonant on the wrong letter compared with Lolium
perenne, which costs 2 edits, 2 / 14 = 0.143. Every
one of these falls within the default threshold of 0.2, so for data with
this error profile the default settings work as they are. The
single-edit corrections cluster at 0.05-0.08; Lolium perrene
sits higher, in the range where a glance is worthwhile.
Choosing a distance method
The fuzzy_method argument selects one of three string
distance algorithms. All three are computed at the C level inside
vectra’s fuzzy_join(), which runs the genus-blocked
comparisons in parallel via OpenMP.
Damerau-Levenshtein (fuzzy_method = "dl", the default)
counts four edit operations, each costing 1:
insertion: add a character (Querus to Quercus)
deletion: remove a character (Quercuss to Quercus)
substitution: replace one character with another (Quarcus to Quercus)
transposition: swap two adjacent characters (Qurecus to Quercus)
Transpositions are among the most common typos in hand-entered data, and counting one as a single edit, where plain Levenshtein counts a deletion plus an insertion, gives tighter distances for real-world errors.
Levenshtein (fuzzy_method = "levenshtein") supports only
insertion, deletion and substitution, so Qurecus to
Quercus costs 2 edits (delete the r, insert r
at the right position). It is stricter than Damerau-Levenshtein for
transposition errors and identical for everything else, so the same
threshold rejects candidates Damerau-Levenshtein would accept. It suits
input from a controlled source (a database export, a curated checklist)
where transpositions are rare. For OCR or hand-typed data,
Damerau-Levenshtein is almost always the better choice.
Jaro-Winkler (fuzzy_method = "jw") computes a similarity
score between 0 (completely different) and 1 (identical), which taxify
converts to a distance as 1 - similarity. The algorithm
gives extra weight to characters that match at the beginning of the
string. For taxonomic names that suits the observation that the genus is
the most informative part and that prefix errors are rarer than epithet
errors. Because the scale is 0 to 1 by definition, only fractional
thresholds are supported: an integer threshold such as
fuzzy_threshold = 2 with fuzzy_method = "jw"
raises an error immediately. Jaro-Winkler can help with very short names
(3-5 characters), where a single edit produces a large normalized
Damerau-Levenshtein distance, and with datasets whose errors concentrate
in the epithet. For general-purpose matching, Damerau-Levenshtein
remains the safer default.
The same input list can produce different results under each method, most visibly when the errors include transpositions:
test_names <- c(
"Qurecus robur", # transposition in genus
"Achillea milefolium", # deletion (l dropped)
"Plantago lanceoalata", # transposition in epithet
"Betula pednula", # transposition in epithet
"Fagus sylvatcia" # transposition in epithet
)
dl_result <- taxify(test_names, fuzzy_method = "dl")
lev_result <- taxify(test_names, fuzzy_method = "levenshtein")
jw_result <- taxify(test_names, fuzzy_method = "jw")
# Compare fuzzy_dist across methods
comparison <- data.frame(
input = test_names,
dl_dist = dl_result$fuzzy_dist,
lev_dist = lev_result$fuzzy_dist,
jw_dist = jw_result$fuzzy_dist,
dl_match = dl_result$match_type,
lev_match = lev_result$match_type,
jw_match = jw_result$match_type
)
comparisonFor a transposition like Qurecus to Quercus, Damerau-Levenshtein reports 1 edit (distance 0.08 on a 13-character name) and Levenshtein 2 edits (0.15). Both fall within the default 0.2 threshold, so both methods match it, but the Levenshtein distance is nearly double. A deletion like milefolium to millefolium involves no transposition, and both methods report the same distance.
Jaro-Winkler distances tend to be smaller overall because the algorithm rewards matching prefixes: a name that shares its entire genus with the candidate starts from a high base similarity. At the same numeric threshold Jaro-Winkler is therefore more permissive; 0.2 is quite loose, and 0.1 is a more comparable starting point. The table below computes the three distances, with vectra’s string distance engine, for one example of each error type:
| Error type | Input | Target | DL dist | Lev dist | JW dist |
|---|---|---|---|---|---|
| Single transposition | Qurecus robur | Quercus robur | 0.08 | 0.15 | 0.02 |
| Single deletion | Achillea millefolum | Achillea millefolium | 0.05 | 0.05 | 0.01 |
| Single substitution | Quarcus robur | Quercus robur | 0.08 | 0.08 | 0.04 |
| Two transpositions | Qeurcus robru | Quercus robur | 0.15 | 0.31 | 0.05 |
The Levenshtein column is always equal to or larger than the Damerau-Levenshtein column, because Levenshtein charges double for transpositions. Jaro-Winkler is consistently the smallest, because the shared genus prefix dominates the similarity. This is why the same threshold value behaves differently across methods and needs recalibrating when you switch.
Setting the threshold
fuzzy_threshold sets how different two strings can be
before the match is rejected, in one of two modes depending on its
value.
A fractional threshold (0 < threshold < 1) caps the normalized
distance. The default of 0.2 means the normalized distance
must not exceed 0.2, where
normalized_distance = raw_edits / max(nchar(input), nchar(candidate))
The cap scales with name length. A 5-character name (Abies)
gets at most 1 edit at threshold 0.2, because 1 / 5 = 0.2;
a 12-character name gets at most 2, because
2 / 12 = 0.167 < 0.2 but
3 / 12 = 0.25 > 0.2; a 20-character name gets up to 4.
The table computes the largest number of edits each threshold allows for
a few representative names:
nm <- c("Poa annua", "Quercus robur", "Taraxacum officinale",
"Achillea millefolium", "Brachypodium sylvaticum")
len <- nchar(nm)
knitr::kable(
data.frame(nm, len, floor(len * 0.2), floor(len * 0.1), floor(len * 0.3)),
col.names = c("Input name", "Length", "Max edits at 0.2",
"Max edits at 0.1", "Max edits at 0.3")
)| Input name | Length | Max edits at 0.2 | Max edits at 0.1 | Max edits at 0.3 |
|---|---|---|---|---|
| Poa annua | 9 | 1 | 0 | 2 |
| Quercus robur | 13 | 2 | 1 | 3 |
| Taraxacum officinale | 20 | 4 | 2 | 6 |
| Achillea millefolium | 20 | 4 | 2 | 6 |
| Brachypodium sylvaticum | 23 | 4 | 2 | 6 |
The “max edits” columns are floor(length * threshold).
The comparison itself uses the floating-point ratio, so a 9-character
name with 2 edits gives 2 / 9 = 0.222, which exceeds 0.2
and is rejected.
An integer threshold (1, 2, 3, …) caps the raw edit count regardless
of name length: fuzzy_threshold = 2L means at most 2 edits,
whether the name is 5 characters or 25. This mode suits data whose error
pattern is known. If the input comes from an OCR pipeline that
occasionally drops or doubles a single character,
fuzzy_threshold = 1L captures those errors without
over-matching on longer names. Integer thresholds are not supported for
Jaro-Winkler, which does not count discrete edits.
# Allow exactly 1 edit, regardless of name length
result <- taxify(
c("Qurecus robur", "Achillea milefolium", "Poa anua"),
fuzzy_threshold = 1L
)
# "Qurecus robur" matches (1 transposition)
# "Achillea milefolium" matches (1 deletion: ll -> l)
# "Poa anua" matches (1 deletion: nn -> n)A loose threshold can match names to the wrong species. This is the main risk of fuzzy matching, and it is highest for short names and names in species-rich genera:
# Poa is a large genus with many similar epithets
poa_names <- c(
"Poa anua", # intended: Poa annua (1 edit)
"Poa pratenss", # intended: Poa pratensis (1 edit)
"Poa trialis" # intended: Poa trivialis (2 edits)
)
# With a loose threshold, some may match the wrong species
loose <- taxify(poa_names, fuzzy_threshold = 0.4)
loose[, c("input_name", "accepted_name", "fuzzy_dist")]At threshold 0.4, Poa trialis (11 characters) is allowed up to 4 edits. That reaches Poa trivialis, the intended target at 2 edits, and potentially other Poa species that happen to be closer in string distance. With 500+ Poa species in the backbone, the risk of a false match is real. Tightening the threshold removes it:
tight <- taxify(poa_names, fuzzy_threshold = 0.15)
tight[, c("input_name", "accepted_name", "match_type", "fuzzy_dist")]
# "Poa anua" still matches (1/9 = 0.11 < 0.15)
# "Poa pratenss" still matches (1/13 = 0.08 < 0.15)
# "Poa trialis" fails (2/13 = 0.154 > 0.15), safer to leave unmatchedA name that fails fuzzy matching gets
match_type = "none". An unmatched name can be reviewed by
hand; a wrong match gives no sign of itself and propagates into
downstream analyses.
The genus Poa has over 500 accepted species in the backbone, many with epithets a few characters apart (pratensis and palustris). The shorter the name, the fewer edits it takes to reach the threshold and the more candidate species fall within range. Carex (2,000+ species), Astragalus (3,000+) and Euphorbia (2,000+) raise the same problem, and for species-rich genera a threshold of 0.1-0.15 is almost always the better setting.
Reviewing matches with fuzzy_dist
Every row of the output has a fuzzy_dist column. It is
NA for exact matches (including case-insensitive and
Latin-normalized ones) and holds the normalized distance, between 0 and
1 with lower meaning closer, for fuzzy matches. A simple filter
separates high-confidence matches from questionable ones:
result <- taxify(my_species_list)
# High-confidence fuzzy matches (likely just typos)
good_fuzzy <- result[result$match_type == "fuzzy" &
result$fuzzy_dist < 0.1, ]
# Questionable fuzzy matches (review manually)
check_fuzzy <- result[result$match_type == "fuzzy" &
result$fuzzy_dist >= 0.1, ]A fuzzy_dist below 0.1 on a name of 10+ characters means
at most 1 edit, and such matches are almost always correct. Between 0.1
and 0.2 means 1-3 edits depending on name length and warrants a glance,
and anything above 0.15 on a short name (under 10 characters) deserves
scrutiny. Sorting by fuzzy_dist in descending order puts
the most suspect matches at the top:
fuzzy_rows <- result[result$match_type == "fuzzy", ]
fuzzy_rows <- fuzzy_rows[order(-fuzzy_rows$fuzzy_dist), ]
head(fuzzy_rows[, c("input_name", "accepted_name", "fuzzy_dist")], 20)Most datasets show a bimodal distribution of fuzzy_dist:
a peak near 0.05-0.08 (single typos on medium-length names) and a sparse
tail above 0.12 (multiple errors, or short names with one error). The
tail is where false matches hide. As a rule of thumb, if more than 5% of
fuzzy matches have fuzzy_dist above 0.15, the threshold is
probably too loose for the dataset; either tighten it, or keep it and
flag every match above 0.12 for manual review. Reviewing a few dozen
names costs little next to carrying a wrong species identity through a
trait analysis or a distribution model.
A two-pass workflow for messy data
For datasets with unknown error rates (historical collections, aggregated multi-source lists), two passes avoid the all-or-nothing choice between a tight and a loose threshold. The first pass runs with a tight threshold to get the high-confidence matches; the second runs the names still unmatched with a looser threshold, and its additional fuzzy matches go to manual review.
# Pass 1: conservative
pass1 <- taxify(my_names, fuzzy_threshold = 0.1)
unmatched <- pass1$input_name[pass1$match_type == "none"]
# Pass 2: permissive, for manual review
pass2 <- taxify(unmatched, fuzzy_threshold = 0.25)
needs_review <- pass2[pass2$match_type == "fuzzy", ]
needs_review[, c("input_name", "accepted_name", "fuzzy_dist")]The bulk of the data is matched at high confidence, and only the residual names get the looser treatment, with a person checking the result.
What runs before fuzzy matching
taxify() matches in a fixed sequence. Name cleaning
comes first. Then come the exact passes: case-sensitive,
case-insensitive, and Latin orthographic normalization. Next, an
abbreviated genus (Q. robur) is resolved from the genus initial
plus the epithet. Fuzzy matching follows, and only after it has failed
does an infraspecific name that no backbone carries fall back to its
species (match_type = "rank_fallback"), so a misspelled
infraspecific epithet reaches the fuzzy stage before its species is
used.
The cleaning pipeline strips qualifiers (cf.,
aff., s.l., s.str.), removes
authorship strings (L., (Aiton) Sm.), drops
brackets and trailing numbers, collapses whitespace, and lowercases
everything except the genus. Backbone names are already clean, so this
step brings user input into the same format, and many names that look as
if they need fuzzy matching resolve by exact match once the noise is
gone:
# All three resolve to the same clean form: "Quercus robur"
result <- taxify(c(
"Quercus robur L.",
"Quercus robur (L.) Sm.",
" Quercus robur "
))
# match_type will be "exact" for all three (no fuzzy needed)Latin orthographic normalization is a separate exact pass.
Alternations like ae/i (hirtaeformis and
hirtiformis), ph/f, rh/r, th/t, and
ii/i at word endings are normalized before comparison, and
these matches appear as exact_ci in the output. Hybrid
markers (the multiplication sign or a standalone “x”) are detected and
stripped during cleaning: Quercus × hispanica is matched as
Quercus hispanica, with the is_hybrid column
set to TRUE. By the time fuzzy matching runs, the remaining
names have genuine character-level errors.
Misspelled genera
Fuzzy matching is genus-blocked: taxify extracts the genus from the input name and compares only against backbone entries with the same genus. This avoids comparing every input against millions of candidates, and it keeps a misspelled epithet from matching a name in a completely different genus.
A misspelled genus therefore finds no candidates in the genus-blocked pass. For WFO, COL, COL XR and GBIF, taxify runs a second, prefix-blocked fuzzy pass on the names still unmatched, blocking on the first two characters of the name instead of the full genus. Most genus typos preserve those characters (Qurecus still starts with Qu, Betual with Be), so the prefix block catches them while still pruning the search space. A typo in the first two characters of the genus is not caught, and the other backbones run the genus-blocked pass only.
A misspelled genus adds edits on top of any epithet error, so such
names carry a higher fuzzy_dist. Qeurcus robru (a
transposition in the genus and another in the epithet) costs 2 edits, a
normalized distance of 2 / 13 = 0.154: inside the default
threshold, but in the range where manual review is advisable.
Practical guidance
When to disable fuzzy matching
For curated checklists, validated databases, or any input already run through a name-resolution service, fuzzy matching adds risk without benefit. Disabling it also skips the fuzzy join step; on a list of 100,000 names the difference can be several seconds.
result <- taxify(curated_list, fuzzy = FALSE)When to tighten the threshold
Tighten below the default 0.2 when the input names are short (many two-word names under 12 characters), when the genera are species-rich (Carex, Poa, Astragalus, Euphorbia), or when false matches would be costly (conservation assessments, regulatory lists). A threshold of 0.1 still catches single-character typos on names of 10+ characters and rejects matches that need 2+ edits on shorter names.
result <- taxify(short_grass_list, fuzzy_threshold = 0.1)When to loosen the threshold
Loosen above 0.2 when the input comes from OCR on degraded documents, when names have been transliterated across character encodings, or when completeness matters more than precision (an initial screening pass where unmatched names are expensive to follow up). A threshold of 0.25-0.3 is reasonable for OCR data; going above 0.3 is rarely justified.
result <- taxify(ocr_names, fuzzy_threshold = 0.25)
# Then filter questionable matches:
suspect <- result[result$fuzzy_dist > 0.15, ]When to switch methods
Damerau-Levenshtein ("dl") is the default for general
use. Levenshtein ("levenshtein") gives the stricter
distance for controlled data where transpositions are unlikely.
Jaro-Winkler ("jw") helps with very short names (3-6
characters, e.g., matching at genus level) where the prefix weighting
matters, with the threshold lowered to 0.1 or below.
Integer thresholds for a uniform error budget
When the error model is known (“our OCR pipeline drops or adds at
most 1 character”), an integer threshold gives direct control:
fuzzy_threshold = 1L means at most 1 edit on a name of any
length. A fractional threshold instead allows a 5-character name 1 edit
and a 25-character name 5.
# Uniform 2-edit budget, regardless of name length
result <- taxify(my_names, fuzzy_threshold = 2L)Output columns related to fuzzy matching
| Column | Values | Meaning |
|---|---|---|
match_type |
"exact", "exact_ci",
"abbrev", "fuzzy",
"hybrid_formula", "rank_fallback",
"basionym", "none",
"out_of_scope"
|
How the name was matched. "exact" is
case-sensitive, "exact_ci" includes case-insensitive and
Latin normalization matches. |
fuzzy_dist |
Numeric (0-1) or NA
|
Normalized string distance for fuzzy matches.
NA for exact matches and unmatched names. |
backbone |
"wfo", "col",
"gbif", etc. |
Which backbone provided the match. Useful in multi-backbone fallback chains. |
Exact matches are definitive, and fuzzy matches with distance below 0.05 are near-certain corrections of minor typos. As the distance climbs toward 0.15 and above, manual review becomes worthwhile, because the matched name may belong to a different species.
Where to go next
-
Constraining
matches to a geographic region for the
regionfilter, which narrows fuzzy candidates to species recorded where the data were collected.