| Title: | Heuristic Index-Based Record Linkage |
|---|---|
| Description: | Links records that refer to the same entity across sources that share no common key, such as people, firms, or addresses with spelling variation, abbreviations, or reordered words. Linkage is described declaratively as a strategy that normalises, tokenises, phonetically encodes, weights, and blocks each field; candidate pairs are then scored by the rarity-weighted overlap of their tokens and every score is attributed back to individual tokens for explainability. Strategies compose into staged pipelines of exact, fuzzy, and optional embedding-based matching that carry unmatched records forward and resolve entities as connected components. The same strategy runs on an in-memory 'data.table' backend or an out-of-core 'DuckDB' backend, and diagnostic and calibration tools help tune a strategy and filter false positives. The token-retrieval heuristic follows Doherr (2023) <doi:10.2139/ssrn.4326848>. |
| Authors: | Eduard Brüll [aut, cre] |
| Maintainer: | Eduard Brüll <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 1.0.1 |
| Built: | 2026-07-07 22:50:46 UTC |
| Source: | https://github.com/cran/joinery |
Bar chart of candidates-per-record distribution (candidates only)
ambiguity_plot(x, ...)ambiguity_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (ambiguity_dist).
Score a Match_Features table with a fitted Filter_Model and
return a Calibrated_Matches object. When matches is supplied,
the original match table is enriched with tp_prob and
predicted_tp columns and stored in the result's @matches slot;
when matches is NULL, the features table itself is enriched and
stored.
apply_filter( features, filter_model, threshold = NULL, threshold_rule = c("youden", "target_recall", "cost_weighted"), target_recall = 0.95, cost_ratio = 1, matches = NULL, ... )apply_filter( features, filter_model, threshold = NULL, threshold_rule = c("youden", "target_recall", "cost_weighted"), target_recall = 0.95, cost_ratio = 1, matches = NULL, ... )
features |
A |
filter_model |
A |
threshold |
Numeric scalar in (0, 1) or |
threshold_rule |
The operating-point rule used when |
target_recall |
Target recall in (0, 1] for
|
cost_ratio |
|
matches |
Optional raw matches table to enrich. When supplied,
|
... |
Reserved for future expansion. |
A Calibrated_Matches object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") model <- fit_filter(feats, match_labels_example) # Broadcast the true-positive probability back onto the match rows. apply_filter(feats, model, matches = matches)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") model <- fit_filter(feats, match_labels_example) # Broadcast the true-positive probability back onto the match rows. apply_filter(feats, model, matches = matches)
approximate_date() rounds dates to the start of broader time periods
(month, quarter, half-year, year, or decade). This is useful for fuzzy
temporal matching when exact dates may differ slightly but represent the
same general time period.
approximate_date( x, unit = c("month", "quarter", "half", "year", "decade"), format = NULL, orders = c("ymd", "dmy", "mdy") )approximate_date( x, unit = c("month", "quarter", "half", "year", "decade"), format = NULL, orders = c("ymd", "dmy", "mdy") )
x |
A character or Date vector containing dates to approximate. |
unit |
Character string specifying the rounding unit. One of:
|
format |
Optional format string for parsing (passed to |
orders |
Optional character vector of lubridate order specifications.
Used when |
Rounding always goes to the start of the period:
"month": 2023-03-15 -> 2023-03-01
"quarter": 2023-03-15 -> 2023-01-01 (Q1), 2023-05-20 -> 2023-04-01 (Q2)
"half": 2023-03-15 -> 2023-01-01 (H1), 2023-08-20 -> 2023-07-01 (H2)
"year": 2023-03-15 -> 2023-01-01
"decade": 2023-03-15 -> 2020-01-01
A character vector of dates in ISO 8601 format (YYYY-MM-DD),
rounded to the start of the specified time unit. Unparseable dates
return NA_character_ with a warning.
normalize_date() for exact dates, date_tokens() to split a date
into part tokens.
Other date preparers:
date_tokens(),
normalize_date()
approximate_date("2023-03-15", unit = "month") # "2023-03-01" approximate_date("2023-03-15", unit = "quarter") # "2023-01-01" approximate_date("2023-08-20", unit = "half") # "2023-07-01" approximate_date("2023-03-15", unit = "year") # "2023-01-01" approximate_date("2023-03-15", unit = "decade") # "2020-01-01" approximate_date(c("2023-01-15", "2023-04-20", "2023-09-10"), unit = "quarter") # c("2023-01-01", "2023-04-01", "2023-07-01")approximate_date("2023-03-15", unit = "month") # "2023-03-01" approximate_date("2023-03-15", unit = "quarter") # "2023-01-01" approximate_date("2023-08-20", unit = "half") # "2023-07-01" approximate_date("2023-03-15", unit = "year") # "2023-01-01" approximate_date("2023-03-15", unit = "decade") # "2020-01-01" approximate_date(c("2023-01-15", "2023-04-20", "2023-09-10"), unit = "quarter") # c("2023-01-01", "2023-04-01", "2023-07-01")
The Cologne phonetic procedure (Koelner Phonetik) is the German-language
counterpart to Soundex. It maps text to a digit string by German
pronunciation rules, so variants like "Meier", "Maier", and "Mayer"
share one key. Reach for this over as_soundex() or as_metaphone() when the
data is German.
as_cologne(text)as_cologne(text)
text |
A character string or vector to encode, or a token list-column (one character vector of tokens per row) when the encoder is placed after a token generator – each token is then encoded in place. |
Returns text, so it slots ahead of a token generator, or use it directly on a one-word column. Like any phonetic key it favours recall over precision; pair it with a sharper field rather than matching on the key alone.
A character vector of Cologne phonetic keys (digit strings), one per input element.
Other phonetic encoders:
as_metaphone(),
as_soundex()
as_cologne(c("Meier", "Maier", "Mayer")) # same keyas_cologne(c("Meier", "Maier", "Mayer")) # same key
Names that sound alike are often spelled differently: "Smith" and
"Smyth", "Meyer" and "Maier". Metaphone encodes text by how it sounds,
so those variants share one key and match even though the letters differ.
Best on single-word fields such as surnames or company names; it is tuned for
English pronunciation (for German, see as_cologne()).
as_metaphone(text)as_metaphone(text)
text |
A character string or vector to encode, or a token list-column (one character vector of tokens per row) when the encoder is placed after a token generator – each token is then encoded in place. |
Runs on either side of a token generator: ahead of one (on a text column), or after one (on a token column, encoding each token in place). Phonetic keys are deliberately coarse, so they trade precision for recall: pair them with a sharper field rather than matching on a phonetic key alone.
A character vector of Metaphone keys, one per input element.
Other phonetic encoders:
as_cologne(),
as_soundex()
as_metaphone("Smith") as_metaphone(c("Meyer", "Maier")) # same keyas_metaphone("Smith") as_metaphone(c("Meyer", "Maier")) # same key
Soundex is the classic phonetic code: it keeps the first letter and reduces
the rest to a short digit string (for example "Robert" and "Rupert" both
become "R163"), so spellings that sound alike share one key. It is coarser
and older than Metaphone but widely understood and a good default for English
surnames.
as_soundex(text)as_soundex(text)
text |
A character string or vector to encode, or a token list-column (one character vector of tokens per row) when the encoder is placed after a token generator – each token is then encoded in place. |
Runs on either side of a token generator: ahead of one (on a text column), or after one (on a token column, encoding each token in place). As with any phonetic key it favours recall over precision; pair it with a sharper field rather than matching on the key alone.
A character vector of Soundex keys (letter followed by digits), one per input element.
Other phonetic encoders:
as_cologne(),
as_metaphone()
as_soundex("Robert") as_soundex(c("Robert", "Rupert")) # same keyas_soundex("Robert") as_soundex(c("Robert", "Rupert")) # same key
Pre-match diagnostic (Q1). Runs preparation and rarity
computation, reports per-column token / rarity statistics and
(when block_by is set) block-size distribution and estimated
comparison count. Surfaces recommendations linking pre-match
symptoms to strategy levers.
audit_strategy(data, id, strategy, ...)audit_strategy(data, id, strategy, ...)
data |
A data.frame / tibble / data.table (or backend-specific table). |
id |
Character scalar naming the ID column in |
strategy |
A |
... |
Additional backend-specific arguments. Notably:
|
A Strategy_Audit object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) audit_strategy(workshop_register, "reg_no", strat)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) audit_strategy(workshop_register, "reg_no", strat)
A dataset containing 3,300 person records with German, Turkish, and Polish names, including addresses across various German cities. Approximately 5% of records are intentional duplicates with small variations to simulate real-world data quality issues.
base_examplebase_example
A tibble with 3,300 rows and 7 variables:
Character identifier for base records (B0001-B3150)
First name, weighted by ethnic group prevalence
Last name, weighted by ethnic group prevalence
Street name, including German street types
House number, some with letter suffixes
City or town name
Administrative district (Kreis)
Synthetically generated using weighted sampling from common German, Turkish, and Polish names (93%, 4%, and 3% respectively) and realistic German geography.
Streams a DuckDB table through a batch plan and applies a user-defined function to each batch. The function must accept a data.frame and return a data.frame. Results can be collected in memory or written back to DuckDB incrementally.
batch_map(plan, con, input_table, fn, persist = TRUE, output_table = NULL)batch_map(plan, con, input_table, fn, persist = TRUE, output_table = NULL)
plan |
A batch plan produced by |
con |
A DuckDB connection. |
input_table |
Character. Name of the source table in DuckDB. |
fn |
A function applied to each batch. Receives a data.frame and must return a data.frame. |
persist |
Logical. If |
output_table |
Optional DuckDB table name where results are stored
when |
Database work is performed batch-by-batch, allowing preprocessing of tables that exceed available RAM. For each batch, a SQL slice or block filter is executed, the function is applied, and (optionally) results are appended to a DuckDB table.
If persist = TRUE: A tbl_duckdb_connection pointing to the output table.
If persist = FALSE: A list of data.frames, one per batch.
Build a token-blocking key for use inside a strategy's block_by. Where a
plain column name blocks two records only when they share a literal value,
block_on_tokens() blocks them when they share any of a designated
column's (rare) tokens. This is region-free: a record that drifts across
a region boundary - a firm that moves to a new postcode, say - still
co-blocks with its earlier self through a distinctive name token, and so
becomes a candidate where a literal block would never compare them.
Hand it to block_by in place of (or mixed with) a column name:
# fully region-free - share a rare name token, regardless of place
search_strategy(name ~ normalize_text + word_tokens(min_nchar = 3),
block_by = block_on_tokens("name", max_df = 50))
# region-bounded - share a rare name token AND sit in the same plz2
search_strategy(name ~ normalize_text + word_tokens(min_nchar = 3),
block_by = list(block_on_tokens("name", max_df = 50), "plz2"))
max_df and min_rarity select which tokens are eligible block keys, using
the global (corpus-wide) document frequency: a token appearing in more
than max_df records, or whose global rarity falls below min_rarity, is
dropped as a key. This is where "block on the distinctive words, not the
common ones" lives - a franchise name ("ALDI") is globally common, fails the
cap, and never becomes a block key, while a distinctive brand survives. A
record with no surviving block key is unreachable via token-blocking in
this stage (it contributes no token-block rows).
Token-blocking is the densest operation in the package: every pair sharing a
surviving key is materialised. It is safe only behind a real max_df (or
min_rarity) plus the always-on fan-out guard. Passing neither cap is a
loud warning, not an error, but you almost always want one.
block_on_tokens( column, max_df = Inf, min_rarity = 0, preparer = NULL, min_nchar = 3L )block_on_tokens( column, max_df = Inf, min_rarity = 0, preparer = NULL, min_nchar = 3L )
column |
The column whose tokens become block keys (for example
|
max_df |
Numeric scalar. Global document-frequency cap: tokens appearing
in more than |
min_rarity |
Numeric scalar. Global rarity floor: tokens whose
corpus-wide rarity falls below this are dropped as block keys. Default |
preparer |
Optional preprocessing pipeline for the blocking column,
given as a one-sided or two-sided formula like the |
min_nchar |
Integer scalar. Minimum token length for the default
preparer. Default |
A Block_On_Tokens spec, to be placed in block_by.
# Block on a rare word from the workshop name instead of a region, so a # workshop still co-blocks with its relocated self. The max_df cap keeps # common words ("joinery") from becoming block keys. strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = list(block_on_tokens("workshop", max_df = 50, min_nchar = 4), "trade"), rarity_scope = "global", threshold = 0.6 ) strat# Block on a rare word from the workshop name instead of a region, so a # workshop still co-blocks with its relocated self. The max_df cap keeps # common words ("joinery") from becoming block keys. strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = list(block_on_tokens("workshop", max_df = 50, min_nchar = 4), "trade"), rarity_scope = "global", threshold = 0.6 ) strat
Bar chart of block sizes (requires block_by on strategy)
block_size_plot(x, ...)block_size_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (block_summary$distribution).
Compute calibration diagnostics for a fitted false-positive filter on
a labelled evaluation set. Returns a Filter_Calibration carrying
the reliability table, Brier score, log-loss, per-class confusion
matrix, and a threshold sweep curve.
Two call shapes:
calibrate(calibrated_matches, labels) - evaluate on labels held
out from the training fit.
calibrate(calibrated_matches) - evaluate on the training labels
stored on the Filter_Model (sanity-check view; do not use
for model selection).
calibrate(x, labels = NULL, bins = 10L, ...)calibrate(x, labels = NULL, bins = 10L, ...)
x |
A |
labels |
Optional labels |
bins |
Integer. Number of equal-width probability bins for the
reliability table. Default |
... |
Reserved for future expansion. |
A Filter_Calibration object.
High-level Q5 verb. Builds features via match_features(), fits a
Filter_Model via fit_filter(), and applies it via
apply_filter() to return a Calibrated_Matches object enriched
with tp_prob / predicted_tp. Dispatches on the strategy class.
calibrate_matches(matches, strategy, ...)calibrate_matches(matches, strategy, ...)
matches |
Match output table (data.table / tibble / data.frame
/ DuckDB lazy |
strategy |
The |
... |
Method-specific arguments. Required: |
A Calibrated_Matches object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # One call: build features, fit the filter, apply it. Uses the shipped # labelled pairs, which line up with this exact search. calibrate_matches(matches, strat, labels = match_labels_example, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no")strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # One call: build features, fit the filter, apply it. Uses the shipped # labelled pairs, which line up with this exact search. calibrate_matches(matches, strat, labels = match_labels_example, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no")
Empties joinery's in-session embedding cache, and optionally the on-disk cache. The cache stores raw embedding vectors so that the data.table and tibble backends reuse them instead of re-embedding on every call. You rarely need to call this by hand; it is mainly useful to force a clean re-embed or to reclaim memory in a long-running session.
clear_embedding_cache(disk = FALSE)clear_embedding_cache(disk = FALSE)
disk |
Logical. If |
Invisibly NULL.
compute_embeddings() for how the cache is filled, and
joinery (package options) for joinery.embedding_reuse and
joinery.embedding_cache_dir.
Bar chart of cluster-size distribution (duplicates only)
cluster_size_plot(x, ...)cluster_size_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (cluster_dist).
Multi-stage diagnostic. Produces per-stage
Match_Overview objects, marginal coverage per stage, and overlaid
per-stage score distributions. Note that summarise_matches() does
not auto-detect a stage column - users explicitly call this
verb when they want per-stage analysis (see
notes/diagnostics_design.md).
compare_stages(matches, ...)compare_stages(matches, ...)
matches |
Multi-stage match table with a |
... |
Method-specific arguments. The data.table method will
accept |
A Stage_Comparison object.
exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.55 ) g <- multi_stage_search( workshop_panel, workshop_panel, base_id = "record_id", target_id = "record_id", list(exact = exact, fuzzy = fuzzy), self = TRUE, source_by = "year", collapse = "rep" ) # See how much each pass added that earlier passes had not reached. compare_stages(g, base = workshop_panel, target = workshop_panel)exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.55 ) g <- multi_stage_search( workshop_panel, workshop_panel, base_id = "record_id", target_id = "record_id", list(exact = exact, fuzzy = fuzzy), self = TRUE, source_by = "year", collapse = "rep" ) # See how much each pass added that earlier passes had not reached. compare_stages(g, base = workshop_panel, target = workshop_panel)
Compute embedding vectors for records using an Embedding_Strategy.
This is a backend-specific generic that handles data retrieval,
text assembly, and embedding computation via tidyllm.
Embedding is the expensive part of a vector match, so each record is embedded
once and the vector is reused on later calls. The data.table and tibble
backends keep a per-session cache keyed by model and record content; the
DuckDB backend reuses through its persisted embeddings column. Reuse is
controlled by the joinery.embedding_reuse and joinery.embedding_cache_dir
options (see joinery package options) and can be cleared with
clear_embedding_cache().
compute_embeddings(data, id, strategy, ...)compute_embeddings(data, id, strategy, ...)
data |
A data.frame / tibble / data.table (or db table in other backends). |
id |
Character scalar naming the ID column in |
strategy |
An |
... |
Additional arguments passed to backend-specific methods. |
A backend-specific table with columns: id and embedding
(where embedding contains numeric vectors).
compute_rarity() assigns a rarity score to each token produced by
prepare_search_data(), using the rarity method defined in a
Search_Strategy.
compute_rarity(tokens, strategy, ...)compute_rarity(tokens, strategy, ...)
tokens |
A token table created by |
strategy |
A |
... |
Additional arguments passed to backend-specific methods. |
Rarity quantifies how informative a token is when comparing records. In joinery, rarity is always computed:
using one global rarity metric specified in the strategy,
per column, because each field has its own token distribution,
within each block (if the strategy specifies block_by).
The input tokens must be the long-format token table returned by
prepare_search_data(), containing at minimum:
an ID column,
a column field indicating the source variable,
a token field,
a row_id identifying the originating record,
and any block_by variables required by the strategy.
Backends (e.g., data.frame, data.table, DuckDB relations) may implement
their own methods for this generic, but all must return the same logical
structure: the original token table with an added numeric rarity column.
The same token table with an added rarity column.
Horizontal bar chart of per-column score contributions
contribution_plot(x, ...)contribution_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (per_column_contrib).
Bar chart of match coverage (base and/or target)
coverage_plot(x, ...)coverage_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table.
date_tokens() parses dates and extracts specified components (year, month, day)
as separate tokens. This is useful for flexible date matching where you want to
match on specific date parts rather than full dates.
date_tokens( x, components = c("year", "month", "day"), format = NULL, orders = c("ymd", "dmy", "mdy") )date_tokens( x, components = c("year", "month", "day"), format = NULL, orders = c("ymd", "dmy", "mdy") )
x |
A character or Date vector containing dates to tokenize. |
components |
Character vector specifying which date components to extract.
Can include |
format |
Optional format string for parsing (passed to |
orders |
Optional character vector of lubridate order specifications
(e.g., |
Components are returned as zero-padded strings:
"year" – 4-digit year (e.g., "2023")
"month" – 2-digit month (e.g., "01", "12")
"day" – 2-digit day (e.g., "05", "31")
The order of tokens in the output follows the order of components.
A list of character vectors, one per input element. Each vector contains the requested date components as strings. Unparseable dates return an empty character vector with a warning.
normalize_date() to match whole dates, approximate_date() to
match on coarser periods.
Other date preparers:
approximate_date(),
normalize_date()
date_tokens("2023-12-31") # list(c("2023", "12", "31")) date_tokens("31.12.2023", components = c("year", "month")) # list(c("2023", "12")) date_tokens("12/31/2023", components = "year") # list("2023") date_tokens(c("2023-01-15", "15.06.2023")) # list(c("2023", "01", "15"), c("2023", "06", "15"))date_tokens("2023-12-31") # list(c("2023", "12", "31")) date_tokens("31.12.2023", components = c("year", "month")) # list(c("2023", "12")) date_tokens("12/31/2023", components = "year") # list("2023") date_tokens(c("2023-01-15", "15.06.2023")) # list(c("2023", "01", "15"), c("2023", "06", "15"))
Generic function that removes or merges duplicate records from a table
based on duplicate pairs identified by detect_duplicates().
deduplicate_table(base_table, duplicates, id, ...)deduplicate_table(base_table, duplicates, id, ...)
base_table |
A data.frame / tibble / data.table (or db table in other backends). |
duplicates |
A table of duplicate pairs generated by detect_duplicates |
id |
Character scalar naming the ID column in |
... |
Additional arguments passed to backend-specific methods. |
A deduplicated version of base_table.
ex <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) dups <- detect_duplicates(workshop_register, id = "reg_no", strategy = ex) deduped <- deduplicate_table(workshop_register, dups, id = "reg_no") c(before = nrow(workshop_register), after = nrow(deduped))ex <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) dups <- detect_duplicates(workshop_register, id = "reg_no", strategy = ex) deduped <- deduplicate_table(workshop_register, dups, id = "reg_no") c(before = nrow(workshop_register), after = nrow(deduped))
Find likely duplicate records inside a single table and group them. Records are compared by how much of their rare, informative token content they share (not by character-level edit distance), every pair is scored, and any pair scoring at or above the threshold is linked. Records that link directly or transitively form one duplicate group.
Pass a search_strategy() for fuzzy, scored matching, or an
exact_strategy() to group only records whose token sets are identical.
detect_duplicates(base_table, id, strategy, ...)detect_duplicates(base_table, id, strategy, ...)
base_table |
A data.frame, tibble, data.table, or backend table to deduplicate. |
id |
Character scalar naming the ID column in |
strategy |
A |
... |
Additional arguments passed to backend-specific methods. The
most useful are |
A table with one row per record that belongs to a duplicate group:
Group label shared by all records that are duplicates of one another.
The record ID.
The record's match score within its group.
Rank within the group; rank 1 is the representative kept by
deduplicate_table().
<original columns>Every other column from base_table.
deduplicate_table() to collapse the groups, search_candidates()
for the cross-table version, multi_stage_dedup() for staged passes.
data(base_example) strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), Ort ~ normalize_text(), block_by = "Kreis", threshold = 0.8 ) dups <- detect_duplicates(base_example, id = "id_base", strategy = strat) head(dups)data(base_example) strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), Ort ~ normalize_text(), block_by = "Kreis", threshold = 0.8 ) dups <- detect_duplicates(base_example, id = "id_base", strategy = strat) head(dups)
The DuckDB backend writes ephemeral tables during batch preprocessing (for
example the token tables built by prepare_search_data()). A clean run drops
them when it finishes, but a run that is killed partway, or a machine that
loses power mid-job, can leave them behind on disk. This sweeps them up.
drop_joinery_temp_tables( con, prefixes = c("_joinery_tokens_", "_joinery_tmp_", "_joinery_emb_") )drop_joinery_temp_tables( con, prefixes = c("_joinery_tokens_", "_joinery_tmp_", "_joinery_emb_") )
con |
A DuckDB connection. |
prefixes |
Character vector of table name prefixes that identify joinery temporary tables. Defaults cover all current ephemeral table types. |
Each temporary table carries a reserved name prefix such as
"_joinery_tokens_" or "_joinery_tmp_". Only tables whose names begin with
one of those prefixes are removed, so your own tables are never touched. Pass
extra prefixes to cover temporary table types added in future.
A character vector of removed table names, invisibly.
if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") # A stray joinery temp table left behind by an interrupted run: DBI::dbWriteTable(con, "_joinery_tmp_demo", data.frame(x = 1)) drop_joinery_temp_tables(con) # removes it, returns its name invisibly DBI::dbDisconnect(con, shutdown = TRUE) }if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") # A stray joinery temp table left behind by an interrupted run: DBI::dbWriteTable(con, "_joinery_tmp_demo", data.frame(x = 1)) drop_joinery_temp_tables(con) # removes it, returns its name invisibly DBI::dbDisconnect(con, shutdown = TRUE) }
Symmetric inverse of numeric_tokens(): removes pure-digit tokens
(typically house numbers) from a token column. Operates on the
list-of-character token vectors produced by earlier steps such as
word_tokens(), mirroring filter_stopwords().
Useful in address pipelines where the street name carries the matching
signal but the house number is noise (and fans out blocks): tokenize the
street, then drop_numeric_tokens() to keep only the name tokens.
drop_numeric_tokens(tokens, keep_letters = TRUE)drop_numeric_tokens(tokens, keep_letters = TRUE)
tokens |
A list of character vectors. |
keep_letters |
Logical. If TRUE (default), number-letter tokens such as "12A" are retained; only pure-digit tokens like "12" are dropped. If FALSE, any token containing a digit is dropped. |
A list of character vectors with numeric tokens removed.
numeric_tokens(), its inverse; filter_stopwords() for the same
idea with a named word list.
Other token transformers:
drop_short_tokens(),
extract_initials(),
filter_stopwords(),
fuzzy_tokens(),
token_shapes(),
use_dictionary()
drop_numeric_tokens(list(c("MAIN", "12", "ST"))) # list(c("MAIN", "ST")) drop_numeric_tokens(list(c("MAIN", "12A")), keep_letters = FALSE) # list("MAIN")drop_numeric_tokens(list(c("MAIN", "12", "ST"))) # list(c("MAIN", "ST")) drop_numeric_tokens(list(c("MAIN", "12A")), keep_letters = FALSE) # list("MAIN")
Removes tokens shorter than min_nchar characters from a token column. Where
word_tokens()'s own min_nchar filters length at tokenisation, this filters
length after a token transform - which is where it matters for the phonetic
encoders (as_cologne(), as_soundex(), as_metaphone()) and generate_ngrams():
those produce short codes, and a 1-2 character code maps to a very large
equivalence class (low distinctiveness), so it behaves as a false-match magnet.
Chain drop_short_tokens() after the encoder to keep only the discriminative codes.
Operates on the list-of-character token vectors produced by earlier steps,
mirroring filter_stopwords() / drop_numeric_tokens().
drop_short_tokens(tokens, min_nchar = 2)drop_short_tokens(tokens, min_nchar = 2)
tokens |
A list of character vectors. |
min_nchar |
Whole number; tokens with fewer than this many characters are
dropped. Default |
A list of character vectors with short tokens removed.
filter_stopwords() and drop_numeric_tokens() for the same
list-column idea with other drop rules; word_tokens() for the same length
cut applied at tokenisation instead.
Other token transformers:
drop_numeric_tokens(),
extract_initials(),
filter_stopwords(),
fuzzy_tokens(),
token_shapes(),
use_dictionary()
drop_short_tokens(list(c("BAU", "AG", "X"))) # list(c("BAU", "AG")) # the 1-char token is dropped at the default min_nchar = 2 # keep only Cologne codes of 4+ digits (drops the collision-prone short class) drop_short_tokens(as_cologne(list(c("Bülau", "Mertens"))), min_nchar = 4) # list("67268")drop_short_tokens(list(c("BAU", "AG", "X"))) # list(c("BAU", "AG")) # the 1-char token is dropped at the default min_nchar = 2 # keep only Cologne codes of 4+ digits (drops the collision-prone short class) drop_short_tokens(as_cologne(list(c("Bülau", "Mertens"))), min_nchar = 4) # list("67268")
Analyses a DuckDB table and generates a batch plan (data.table) that defines how to split the table into atomic processing units. Each row of the plan represents one batch with row counts, optional row-number windows, and block identifiers (if blocking is used).
duckdb_batch_plan( db_tbl, id, target_batch_size = NULL, min_batch_size = NULL, chunk_strategy = "block_consolidated", block_by = NULL, atomic_blocks = FALSE )duckdb_batch_plan( db_tbl, id, target_batch_size = NULL, min_batch_size = NULL, chunk_strategy = "block_consolidated", block_by = NULL, atomic_blocks = FALSE )
db_tbl |
A DuckDB table reference (result of |
id |
Character. Column name(s) to use as record identifier(s). Not used for batching but validated to exist in the table. |
target_batch_size |
Positive integer. Target number of rows per batch. Default: 1e6 (1 million rows). |
min_batch_size |
Positive integer. Minimum table size to trigger batching. If total rows < min_batch_size, returns single batch. Default: 1e5 (100k rows). |
chunk_strategy |
Character. One of |
block_by |
Optional character vector. Column name(s) to use for semantic blocking. If specified, batches respect block boundaries. Supports multiple columns (e.g., c("region", "year")). |
atomic_blocks |
Logical. When |
The function supports three chunking strategies:
"even": Simple row-number chunking, ignores blocks
"block_first": Each batch = one block (or sub-chunks if block > target_batch_size)
"block_consolidated": Consolidates small blocks to minimize batch count (default)
Small tables: If total rows < min_batch_size, returns a single batch regardless
of strategy. With blocking, still respects blocks.
Row-number windows: For unblocked or large-block sub-chunking, row_start and
row_end define window boundaries (1-based, inclusive). For block-based batches
(small blocks), these are NA.
Consolidation: "block_consolidated" (default) combines multiple small blocks
into single batches up to target_batch_size to reduce overhead. Each batch may
contain zero, one, or multiple blocks (depending on sizes and consolidation).
Row ordering: To ensure row_start and row_end windows are consecutive and
can be reliably sliced from the DB, the function sorts by the id column before
computing row numbers. This ensures reproducible, deterministic batch boundaries.
A data.table with columns:
batch_id: integer, sequential batch identifier (1, 2, 3, ...)
row_count: integer, number of rows in this batch
row_start: integer (or NA), window start for row-number-based batches; NA for block-based
row_end: integer (or NA), window end for row-number-based batches; NA for block-based
Additional columns (if block_by specified): one per blocking variable, containing block values
if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE) && requireNamespace("dplyr", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") DBI::dbWriteTable( con, "data", data.frame(id = 1:1000, region = rep(LETTERS[1:5], length.out = 1000)) ) tbl_ref <- dplyr::tbl(con, "data") # Unblocked, even row-number chunking plan1 <- duckdb_batch_plan( tbl_ref, id = "id", target_batch_size = 200, chunk_strategy = "even" ) # Blocked, consolidated strategy (default, respects regions) plan2 <- duckdb_batch_plan( tbl_ref, id = "id", target_batch_size = 200, block_by = "region" ) DBI::dbDisconnect(con, shutdown = TRUE) }if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE) && requireNamespace("dplyr", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") DBI::dbWriteTable( con, "data", data.frame(id = 1:1000, region = rep(LETTERS[1:5], length.out = 1000)) ) tbl_ref <- dplyr::tbl(con, "data") # Unblocked, even row-number chunking plan1 <- duckdb_batch_plan( tbl_ref, id = "id", target_batch_size = 200, chunk_strategy = "even" ) # Blocked, consolidated strategy (default, respects regions) plan2 <- duckdb_batch_plan( tbl_ref, id = "id", target_batch_size = 200, block_by = "region" ) DBI::dbDisconnect(con, shutdown = TRUE) }
Build a Duckdb_Control bundling the DuckDB backend's execution knobs, and
pass it as control = to prepare_search_data(), detect_duplicates(), or
search_candidates() on DuckDB tables. It controls how a match runs
(memory, batching, chunking, failure isolation), never what matches -
matching semantics stay on the search_strategy().
Two execution stages, two atomicity rules:
Preprocess batching (tokenization) is per-row, governed by
target_batch_size / min_batch_size / chunk_strategy. Any row split is
safe.
Scoring chunking (the overlap join) is block-atomic - a pair only
forms within a block, so a block can never be split. chunk_by packs
whole blocks under target_batch_size; on_error isolates a
pathological block from the rest of the run.
Chunking is a DuckDB (out-of-core) concern; the in-memory data.table backend ignores it.
duckdb_control( target_batch_size = NULL, min_batch_size = NULL, chunk_strategy = c("block_consolidated", "block_first", "even"), chunk_by = NULL, on_error = c("skip", "retry", "stop"), progress = NULL )duckdb_control( target_batch_size = NULL, min_batch_size = NULL, chunk_strategy = c("block_consolidated", "block_first", "even"), chunk_by = NULL, on_error = c("skip", "retry", "stop"), progress = NULL )
target_batch_size |
|
min_batch_size |
|
chunk_strategy |
Preprocess chunking strategy: |
chunk_by |
Scoring chunk key. |
on_error |
Per-scoring-chunk failure policy: |
progress |
|
A Duckdb_Control object.
prepare_search_data(), detect_duplicates(), search_candidates().
# The control object just bundles execution knobs; it carries no data. ctrl <- duckdb_control(target_batch_size = 5e5, on_error = "skip") ctrl # Pass it to a verb running on a DuckDB table. if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE) && requireNamespace("dplyr", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") DBI::dbWriteTable(con, "reg", as.data.frame(workshop_register)) strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) detect_duplicates(dplyr::tbl(con, "reg"), "reg_no", strat, control = ctrl) DBI::dbDisconnect(con, shutdown = TRUE) }# The control object just bundles execution knobs; it carries no data. ctrl <- duckdb_control(target_batch_size = 5e5, on_error = "skip") ctrl # Pass it to a verb running on a DuckDB table. if (requireNamespace("duckdb", quietly = TRUE) && requireNamespace("DBI", quietly = TRUE) && requireNamespace("dplyr", quietly = TRUE)) { con <- DBI::dbConnect(duckdb::duckdb(), ":memory:") DBI::dbWriteTable(con, "reg", as.data.frame(workshop_register)) strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) detect_duplicates(dplyr::tbl(con, "reg"), "reg_no", strat, control = ctrl) DBI::dbDisconnect(con, shutdown = TRUE) }
Construct an Embedding_Strategy object for semantic matching using
embeddings. This is a distinct strategy type from token-based strategies
created with search_strategy().
Embedding strategies:
Represent entire records as embedding vectors
Use cosine similarity for scoring
Support blocking variables to restrict comparisons
Require the tidyllm package for embedding computation
embedding_strategy( columns = NULL, embedding_model, threshold, collapse_sep = " ", normalize = TRUE, batch_size = 1000, block_by = NULL )embedding_strategy( columns = NULL, embedding_model, threshold, collapse_sep = " ", normalize = TRUE, batch_size = 1000, block_by = NULL )
columns |
Character vector of column names to embed, or NULL (default) to use all non-id character-like columns. |
embedding_model |
A tidyllm provider object (e.g.,
|
threshold |
Numeric scalar in (0, 1). Cosine similarity threshold for filtering matches. |
collapse_sep |
Character scalar. Separator used when joining multiple columns into a single text string. Default is " ". |
normalize |
Logical scalar. If TRUE (default), apply L2 normalization to embeddings before computing cosine similarity. |
batch_size |
Numeric scalar. Number of records to process per batch when computing embeddings. Default is 1000. |
block_by |
Character vector of blocking variable names, or NULL (default). When specified, comparisons are only made within matching blocks. |
An Embedding_Strategy S7 object.
## Not run: library(tidyllm) # Create an embedding strategy using Ollama emb_strat <- embedding_strategy( columns = c("name", "address"), embedding_model = ollama(.model = "mxbai-embed-large"), threshold = 0.85 ) # Use in multi-stage workflow results <- multi_stage_search( base_table = customers_a, target_table = customers_b, base_id = "id_a", target_id = "id_b", strategies = list( token_stage = search_strategy(name ~ normalize_text() + word_tokens()), semantic_stage = emb_strat ) ) ## End(Not run)## Not run: library(tidyllm) # Create an embedding strategy using Ollama emb_strat <- embedding_strategy( columns = c("name", "address"), embedding_model = ollama(.model = "mxbai-embed-large"), threshold = 0.85 ) # Use in multi-stage workflow results <- multi_stage_search( base_table = customers_a, target_table = customers_b, base_id = "id_a", target_id = "id_b", strategies = list( token_stage = search_strategy(name ~ normalize_text() + word_tokens()), semantic_stage = emb_strat ) ) ## End(Not run)
Creates an Exact_Strategy for exact, score-1.0 token-set matching. Hand it
to the same verbs you would hand a search_strategy(): detect_duplicates()
to group identical records within a table, search_candidates() to match
them across tables. Both return the usual result with score == 1.0.
Two records link only when every column's token set is equal within the same block. This is the same as a fuzzy score of exactly 1.0, reached without any scoring or threshold, and it is robust to empty columns: two records with identical names and both streets blank will link, where a weighted threshold would silently reject them. (A blank column drags a weighted score below its threshold, since its weight stays in the denominator; exact matching has no such ceiling.)
Use it as the cheap first stage of a staged workflow (exact first, then fuzzy
on whatever is left): the leftover records come from extract_unmatched(),
and multi_stage_dedup() / multi_stage_search() thread them through for
you when you pass list(exact_strategy(...), search_strategy(...)).
exact_strategy( ..., block_by = NULL, rarity = "inverse_freq", containment = c("off", "forward", "bidirectional"), min_base_rarity = 0, min_containment_tokens = 1 )exact_strategy( ..., block_by = NULL, rarity = "inverse_freq", containment = c("off", "forward", "bidirectional"), min_base_rarity = 0, min_containment_tokens = 1 )
... |
Two-sided formulas |
block_by |
Optional character vector of blocking columns. |
rarity |
Character scalar rarity metric, used only by the
|
containment |
One of |
min_base_rarity |
Numeric containment guard: drop links whose base
record carries summed rarity mass below this floor. Default |
min_containment_tokens |
Numeric containment guard, default |
An Exact_Strategy object.
search_strategy(), detect_duplicates(), search_candidates(),
extract_unmatched().
# Link only workshops whose name tokens are identical within the same area # and trade. No threshold to tune, and blank columns do not sink a match. ex <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) dups <- detect_duplicates(workshop_register, id = "reg_no", strategy = ex) head(dups)# Link only workshops whose name tokens are identical within the same area # and trade. No threshold to tune, and blank columns do not sink a match. ex <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) dups <- detect_duplicates(workshop_register, id = "reg_no", strategy = ex) head(dups)
Attribution diagnostic (Q3). Reconstructs per-column and
per-token contributions to a single match score. Dispatches on the
second positional argument: a Search_Strategy triggers
reconstruction from raw inputs; a tokens-shaped table is used
directly.
explain_match(matches, x, ...)explain_match(matches, x, ...)
matches |
Match output table. |
x |
Either a |
... |
Backend-specific arguments. For the ergonomic form:
|
A Match_Explanation object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # Break one pair's score down into its per-token contributions. first_id <- matches$match_id[matches$source == "target"][1] explain_match(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no", match_id = first_id)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # Break one pair's score down into its per-token contributions. first_id <- matches$match_id[matches$source == "target"][1] explain_match(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no", match_id = first_id)
Write a sampled set of matches to a CSV pre-filled with an equal
column on block-header rows. Users edit the CSV in any spreadsheet,
marking only exceptions (e.g. false positives) and leaving the rest
as defaults.
Block definition follows the matches schema: for candidate matches
(from search_candidates()), the header is the base-side row and
candidate rows inherit its default. For duplicate matches (from
detect_duplicates()), the header is the rank-1 row and the
remaining records in the duplicate group inherit its default.
export_for_labelling(sample, file, default_label = 1L)export_for_labelling(sample, file, default_label = 1L)
sample |
A |
file |
Path to the CSV file to write. |
default_label |
Integer scalar (default |
Invisibly returns file.
Keeps only the first character of each token ("ANNA" becomes "A"). Use it
to match on initials when full first names are recorded inconsistently, for
example when one source has "Anna Berta Schmidt" and another "A. B. Schmidt".
extract_initials(tokens)extract_initials(tokens)
tokens |
A list of character vectors. |
It transforms a token column, so it runs after a token generator such as
word_tokens().
A list of character vectors of single-character initials.
Other token transformers:
drop_numeric_tokens(),
drop_short_tokens(),
filter_stopwords(),
fuzzy_tokens(),
token_shapes(),
use_dictionary()
extract_initials(list(c("Anna", "Berta"))) # list(c("A", "B"))extract_initials(list(c("Anna", "Berta"))) # list(c("A", "B"))
Identify and extract records from a table that were not matched in a record linkage operation.
extract_unmatched(data, id, matches, ...)extract_unmatched(data, id, matches, ...)
data |
A data.frame / tibble / data.table (or db table in other backends) containing the original records. |
id |
Character scalar naming the ID column in |
matches |
A table of matched record pairs, containing the ID column. |
... |
Additional arguments passed to backend-specific methods. |
A subset of data containing only records whose IDs do not appear
in matches.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # The listings that found no register match, ready for a looser next pass. leftover <- extract_unmatched(workshop_listings, "listing_id", matches) nrow(leftover)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # The listings that found no register match, ready for a looser next pass. leftover <- extract_unmatched(workshop_listings, "listing_id", matches) nrow(leftover)
Some tokens carry no matching signal but appear everywhere: legal forms like
GMBH or LTD, articles, generic words. Because they are common they create
many spurious matches and fan out blocks. filter_stopwords() removes named
tokens so matching rests on the distinctive ones. The comparison is
case-insensitive.
filter_stopwords(tokens, stopwords)filter_stopwords(tokens, stopwords)
tokens |
A list of character vectors, as produced by |
stopwords |
A character vector of tokens to remove (case-insensitive). |
It transforms a token column, so it runs after a token generator such as
word_tokens().
A list of character vectors with the stopwords removed.
drop_numeric_tokens() to remove house numbers the same way.
Other token transformers:
drop_numeric_tokens(),
drop_short_tokens(),
extract_initials(),
fuzzy_tokens(),
token_shapes(),
use_dictionary()
filter_stopwords(list(c("MUELLER", "GMBH")), stopwords = c("gmbh")) # list("MUELLER")filter_stopwords(list(c("MUELLER", "GMBH")), stopwords = c("gmbh")) # list("MUELLER")
Scores every (src_column, token) by its document frequency - the share of
records in that column whose value contains the token - and returns the
tokens common enough to be poor discriminators. These are stopword
candidates: feed them to filter_stopwords() in the preparer chain and
re-run prepare_search_data().
find_stopwords( tokens, max_prop = 0.3, top_n = NULL, by_block = FALSE, block_by = NULL )find_stopwords( tokens, max_prop = 0.3, top_n = NULL, by_block = FALSE, block_by = NULL )
tokens |
A token table produced by |
max_prop |
Numeric in |
top_n |
Optional integer. If supplied, instead of (or in addition to)
the |
by_block |
Logical. Compute the share within each block rather than
corpus-wide. Requires |
block_by |
Character vector of block columns. Required when
|
Document frequency is computed corpus-wide by default (by_block = FALSE),
i.e. across all blocks. This matches the intuition of a stopword as a
globally common term. With by_block = TRUE the share is computed within
each block and a token is returned if it crosses max_prop in any block,
reported at its maximum block-level share - useful when a token is rare
overall but saturates a single dense block.
A data.table with one row per flagged (src_column, token):
src_column, token, df (distinct records containing the token),
n_records (records in the column / block), and prop = df / n_records.
Sorted by src_column then descending prop. Empty (zero-row) when
nothing crosses the threshold.
filter_stopwords() to apply the result in a preparer chain.
Fit a baseline classifier to predict whether each scored pair is a
true match (equal == 1L) or a false positive (equal == 0L).
The baseline path uses stats::glm with the logit link and no
external dependencies. The features object is the input from
match_features(); labels carry the equal column produced by
import_labels().
fit_filter( features, labels, model = "logistic", class_weighted = FALSE, na_fill = 0, ... )fit_filter( features, labels, model = "logistic", class_weighted = FALSE, na_fill = 0, ... )
features |
A |
labels |
A |
model |
Character scalar (default |
class_weighted |
Logical scalar. When |
na_fill |
Numeric scalar used to impute predictor NAs. Default
|
... |
Reserved for future expansion. |
A Filter_Model object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") # match_labels_example carries the same pairs with a hand-checked `equal` flag. model <- fit_filter(feats, match_labels_example) modelstrat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") # match_labels_example carries the same pairs with a hand-checked `equal` flag. model <- fit_filter(feats, match_labels_example) model
Plots each candidate block key at (candidate, exact_twin_survival) - the recall axis - with the brute-pair cost in the point labels. The knee is the cheapest candidate whose twin survival stays high.
frontier_plot(x, ...)frontier_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (frontier).
Typos and minor spelling differences split one real token into many
("Neumann", "Neumann" with a slip, "Neuman"). fuzzy_tokens() finds
tokens within a string distance of each other, groups them, and rewrites
every member of a group to one canonical spelling, so the variants match.
Unlike use_dictionary(), which needs a known synonym list, this discovers
the groups from the data.
fuzzy_tokens(x, max_dist = 2, method = "osa", min_nchar = 1)fuzzy_tokens(x, max_dist = 2, method = "osa", min_nchar = 1)
x |
A character vector to tokenize and canonicalize. |
max_dist |
Maximum string distance for two tokens to be treated as the
same. For |
method |
A |
min_nchar |
Minimum token length to consider; shorter tokens are dropped before grouping. |
Use it when a field has organic spelling noise and you do not have a dictionary. The canonical form per group is the longest token, breaking ties by the most central token, then alphabetically.
When not to use it:
High-cardinality columns. It compares every distinct token against
every other in one dense distance matrix, so cost and memory grow with the
square of the number of distinct tokens. On a large vocabulary (tens of
thousands of distinct tokens and up) it is slow and memory-hungry.
Normalize aggressively first, and prefer use_dictionary() when the groups
are already known.
When over-merging is costly. Grouping is by connected components, so
matches chain transitively: if A is close to B and B to C, all
three collapse even when A and C are far apart. A loose max_dist or
short tokens can fuse genuinely distinct values. Keep max_dist tight,
raise min_nchar to drop noise-prone short tokens, and check the groups on
a sample before trusting them.
A list of character vectors, one per input element, with each token replaced by its group's canonical form.
use_dictionary() when the groups are known in advance.
Other token transformers:
drop_numeric_tokens(),
drop_short_tokens(),
extract_initials(),
filter_stopwords(),
token_shapes(),
use_dictionary()
fuzzy_tokens(c("Neumann", "Neumaxn", "Neuman"), max_dist = 2) # every row's token becomes "NEUMANN"fuzzy_tokens(c("Neumann", "Neumaxn", "Neuman"), max_dist = 2) # every row's token becomes "NEUMANN"
An n-gram is a sliding window of n consecutive characters. Matching on
character n-grams instead of whole words tolerates typos, truncations, and
joined-up spellings, because two strings that differ by a letter still share
most of their windows ("meier" and "maier" share "ei", "er", and so
on). Reach for it on short, noisy fields where word tokens are too brittle.
generate_ngrams(text, n)generate_ngrams(text, n)
text |
A character vector to break into n-grams. |
n |
The window length (number of characters per n-gram). |
It tokenizes text directly, so it replaces word_tokens() rather than
following it. The trade-off is fan-out: every string yields many overlapping
tokens, so n-grams cost more to match than words. Larger n is sharper and
cheaper, smaller n is fuzzier and denser.
A list of character vectors, one per input element. Strings shorter
than n yield an empty vector.
word_tokens() for whole-word tokens.
Other token generators:
numeric_tokens(),
word_tokens()
generate_ngrams("hello", 2) generate_ngrams("an example", 3)generate_ngrams("hello", 2) generate_ngrams("an example", 3)
Read a CSV written by export_for_labelling() (optionally edited by a
user), propagate the block-default equal value from each header row
onto unmarked rows in that block, validate the schema, and return a
data.table ready for fit_filter() / calibrate_matches().
import_labels(file)import_labels(file)
file |
Path to the CSV file to read. |
A data.table with the same rows as the original sample plus
a fully populated equal column (0L / 1L).
Extract and examine the tokens generated for a specific column
after applying the preprocessing steps defined in a Search_Strategy.
Useful for debugging and understanding how text is tokenized.
inspect_tokens(data, id, strategy, column)inspect_tokens(data, id, strategy, column)
data |
A data.frame / tibble / data.table (or db table in other backends). |
id |
Character scalar naming the ID column in |
strategy |
A |
column |
< |
A backend-specific table showing the tokens generated for the specified column.
Construct a pre-configured recipes::recipe() suitable for fitting a
false-positive filter on the output of match_features(). Tags ID
columns (searched, found, match_id) with role "id", sets
equal as the outcome, and keeps every other numeric column as a
predictor. Requires the suggested recipes package.
joinery_recipe(features, labels, ...)joinery_recipe(features, labels, ...)
features |
A |
labels |
A labels |
... |
Reserved for future expansion. |
A recipes::recipe() object.
Computes a wide, one-row-per-pair feature data.table from a joinery
match result, suitable for downstream calibration / false-positive
filtering. The schema is documented in
notes/calibration_design.md and treated as the public API.
Additions are allowed; reorders or renames are not.
Dispatches on (matches, strategy). A Search_Strategy returns
the full token schema (core + token-side columns + string similarity).
An Embedding_Strategy returns the reduced "embedding" schema
(core columns + string similarity + cosine_sim + embedding norms).
match_features(matches, strategy, ...)match_features(matches, strategy, ...)
matches |
A match result table (data.table / tibble / data.frame
/ DuckDB lazy |
strategy |
The |
... |
Method-specific arguments. Both strategy methods accept:
|
A Match_Features object wrapping a wide feature
data.table.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # One row per pair, with the features a filter can learn from. feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") featsstrat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), proprietor ~ normalize_text() + word_tokens(min_nchar = 2), block_by = c("postcode_area", "trade"), threshold = 0.30 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # One row per pair, with the features a filter can learn from. feats <- match_features(matches, strat, base = workshop_listings, id = "listing_id", target = workshop_register, target_id = "reg_no") feats
A frozen set of candidate match pairs with a ground-truth equal label,
for the calibration workflow (fit_filter,
apply_filter, calibrate). It is a saved
search_candidates() run linking workshop_listings to
workshop_register, with equal filled from each listing's true
actual_link. The false positives are concentrated in the deliberately
hard tiers, common-surname homonyms and planted register duplicates, so a
learned filter has real mistakes to catch. The schema matches what
import_labels returns, so it feeds the calibration verbs with no
manual labelling step.
match_labels_examplematch_labels_example
A data frame with 1,862 rows (two rows per pair) and 12 variables:
Integer pair id. Each id groups one base row and one
target row.
The candidate score from the frozen search
"base" (the searched listing) or "target" (the
register candidate). The candidate row carries the label.
The record id: a listing_id on base rows, a reg_no
on target rows
Business name of the row
Proprietor name of the row
Trade
UK outward-code area
The generation tier of the row, useful for slicing the hard cases
The searched listing's true reg_no (present on
base rows)
Candidate rank within the pair
Evaluation label. 1 when the target candidate is the
listing's true match, 0 otherwise. On base header rows it is the
block default.
Synthetically generated by data-raw/generate_match_labels.R
from the shipped workshop_listings / workshop_register pair.
fit_filter, import_labels,
workshop_listings
Rehydrate a set of record IDs back into their full records. The
positive (semi-join) complement of extract_unmatched(): where
extract_unmatched() produces a residual set of IDs, materialize_records()
pulls those IDs back into complete, scorable rows for the next stage.
materialize_records(data, id, ids, ...)materialize_records(data, id, ids, ...)
data |
A data.frame / tibble / data.table (or db table in other backends) - the corpus to pull records from. |
id |
Character scalar naming the ID column in |
ids |
Either an atomic vector of ID values, or a table carrying them
(read from an |
... |
Additional arguments passed to backend-specific methods. |
ids is polymorphic. It may be either
an atomic vector of ID values, or
a table (data.frame / data.table / backend tbl) carrying the IDs. The
lookup order for the ID column is: a column literally named id first
(the extract_unmatched() / resolve_entities() output convention),
otherwise a column named the same as id.
The return is a semi-join: IDs absent from data are silently dropped
(there is nothing to rehydrate), never NULL-filled. IDs are coerced to a
common type on both sides, so a BIGINT-corpus / character-id request still
matches. Row order is not guaranteed; the caller sorts if needed.
On the DuckDB backend the IDs are always registered as a temp table and
joined - never inlined as an id IN (<literal list>), which binds in
roughly O(n^2) and pins cores for minutes on large residual sets.
The rows of data whose ID is in ids, all columns intact, one
row per matching record, in no guaranteed order.
extract_unmatched(), the negative complement that produces the
residual IDs this verb rehydrates.
Deduplicate a single table in increasingly tolerant passes. A typical run
starts with a cheap exact_strategy() pass that catches the clean
duplicates, then applies looser search_strategy() passes (often with
wider blocking) to the records still unmatched. All the links found across
the passes are grouped into duplicate groups at the end, so a record linked
to B in an early pass and B linked to C in a later one all land in the
same group.
For linking across two tables or several sources, use multi_stage_search().
multi_stage_dedup(table, id, strategies, ...)multi_stage_dedup(table, id, strategies, ...)
table |
A data.frame, tibble, data.table, or backend table to deduplicate. |
id |
Character scalar naming the ID column in |
strategies |
Named, ordered list of strategies to apply in turn. Each
element is an |
... |
Further arguments to the staged run:
Backend methods may accept additional arguments. |
The standard dedup result: duplicate_group | id | score | rank
plus the original columns of table, and a stage column recording which
pass first linked each record.
multi_stage_search() for the cross-table version,
detect_duplicates() for a single pass, resolve_entities() for the
grouping step.
# Two passes over one table: exact token-set first, then a looser fuzzy pass # on whatever the exact pass left unmatched. exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.6 ) dups <- multi_stage_dedup(workshop_register, "reg_no", list(exact = exact, fuzzy = fuzzy)) head(dups)# Two passes over one table: exact token-set first, then a looser fuzzy pass # on whatever the exact pass left unmatched. exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.6 ) dups <- multi_stage_dedup(workshop_register, "reg_no", list(exact = exact, fuzzy = fuzzy)) head(dups)
Link the same real-world entity across two tables, or across several
datasets or vintages of one dataset, by running an ordered list of
strategies as successive search passes. Each pass adds the links it finds to
a running record of every match (the ledger), and at the end all the links
are grouped into entities, one row per record showing which entity it
belongs to.
A typical run starts with a cheap exact_strategy() pass to catch the clean
matches, then applies one or more looser search_strategy() passes to the
records still unmatched. Use this when the two sides are not interchangeable:
for example one record may carry only part of another's information, so it
matters which side is searched against which. For finding duplicates within
a single table, use multi_stage_dedup() instead.
multi_stage_search( base_table, target_table, base_id, target_id, strategies, ... )multi_stage_search( base_table, target_table, base_id, target_id, strategies, ... )
base_table |
The left table in the linkage. |
target_table |
The right table. Pass |
base_id |
Character scalar naming the ID column in |
target_id |
Character scalar naming the ID column in |
strategies |
Named, ordered list of strategies to apply in turn. Each
element is an |
... |
Further arguments controlling the staged run:
Backend methods may accept additional arguments. |
One row per pooled record describing its entity:
entity | id | rep | rank | score | source | covered_sources | n_in_entity | stage. The full list of links found, with the stage and
direction of each, is attached as the ledger attribute and read with
attr(result, "ledger").
multi_stage_dedup() for the within-one-table version,
resolve_entities() for the grouping step, exact_strategy() for the
usual front stage.
# Follow each workshop across years: pool the panel, search it against itself, # exact first then fuzzy, collapsing each group found so later passes see less. exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.55 ) g <- multi_stage_search( workshop_panel, workshop_panel, base_id = "record_id", target_id = "record_id", list(exact = exact, fuzzy = fuzzy), self = TRUE, source_by = "year", collapse = "rep" ) head(g)# Follow each workshop across years: pool the panel, search it against itself, # exact first then fuzzy, collapsing each group found so later passes see less. exact <- exact_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) fuzzy <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.55 ) g <- multi_stage_search( workshop_panel, workshop_panel, base_id = "record_id", target_id = "record_id", list(exact = exact, fuzzy = fuzzy), self = TRUE, source_by = "year", collapse = "rep" ) head(g)
Plots p05/p25/p50/p75/p95 of the embedding vector norms. A norm of 1 is annotated; for an L2-normalised strategy all bars should sit on it.
norm_plot(x, ...)norm_plot(x, ...)
x |
An |
... |
Passed to |
Invisibly, the plotted data.table (quantile, norm).
The same day is written "31.12.2023", "12/31/2023", or "2023-12-31"
depending on who typed it. normalize_date() parses these mixed formats and
rewrites them to one ISO 8601 string (YYYY-MM-DD), so a date column matches
on the day it names rather than on how it was formatted. It recognizes
European (DD.MM.YYYY), American (MM/DD/YYYY), and ISO-style inputs.
normalize_date(x, format = NULL, orders = c("ymd", "dmy", "mdy"))normalize_date(x, format = NULL, orders = c("ymd", "dmy", "mdy"))
x |
A character or Date vector containing dates to normalize. |
format |
Optional format string for parsing (passed to |
orders |
Optional character vector of lubridate order specifications
(e.g., |
Returns text. For matching on individual date parts (year only, year and
month) use date_tokens(); to deliberately blur near-dates together use
approximate_date().
When format is provided, uses as.Date(x, format) directly.
When format = NULL, tries lubridate::parse_date_time() with the
specified orders to handle mixed formats flexibly.
A character vector of dates in ISO 8601 format (YYYY-MM-DD).
Unparseable dates return NA_character_ with a warning.
Other date preparers:
approximate_date(),
date_tokens()
normalize_date("31.12.2023") # "2023-12-31" normalize_date("12/31/2023") # "2023-12-31" normalize_date(c("2023-01-15", "15.01.2023", "01/15/2023")) # c("2023-01-15", "2023-01-15", "2023-01-15") normalize_date("31-12-2023", format = "%d-%m-%Y") # "2023-12-31"normalize_date("31.12.2023") # "2023-12-31" normalize_date("12/31/2023") # "2023-12-31" normalize_date(c("2023-01-15", "15.01.2023", "01/15/2023")) # c("2023-01-15", "2023-01-15", "2023-01-15") normalize_date("31-12-2023", format = "%d-%m-%Y") # "2023-12-31"
Street names are written many ways for the same place: "Hauptstr.",
"Hauptstrasse", "Haupt Strasse". normalize_street() collapses those
variants to one canonical spelling so an address column matches on the street
name rather than on its abbreviation. It normalizes Unicode, folds to ASCII,
upper-cases, and cleans whitespace, then rewrites known street-type tokens
from a multilingual dictionary.
normalize_street( x, lang = NULL, drop_house_numbers = FALSE, drop_stopwords = FALSE, dict = joinery::street_types, stopwords = joinery::street_stopwords )normalize_street( x, lang = NULL, drop_house_numbers = FALSE, drop_stopwords = FALSE, dict = joinery::street_types, stopwords = joinery::street_stopwords )
x |
A character vector containing street names or address fragments. |
lang |
Optional language code (e.g., |
drop_house_numbers |
Logical (default |
drop_stopwords |
Logical (default |
dict |
A dictionary of street-type definitions, typically street_types, containing the columns:
|
stopwords |
A street-stopword table, typically
street_stopwords, with columns |
Returns text, so it sits where normalize_text() would in a pipeline, ahead
of a token generator: street ~ normalize_street(lang = "de") + word_tokens().
Exact matches (e.g., "st", "rd.", "via") are always replaced.
Suffix matches (e.g., German "strasse" endings or Dutch "straat")
are applied only when lang is explicitly specified, which prevents
unsafe substitutions such as rewriting the ending of "LINCOLN LANE".
Normalization steps include:
Unicode -> Latin transliteration and ASCII folding (stri_trans_general)
Conversion to uppercase
Removal of non-alphanumeric characters
Tokenization on spaces and per-token replacement
Exact variants are replaced verbatim with their canonical form. Suffix variants are replaced only when:
lang is specified, and
the token ends with a known variant suffix for that language.
A character vector of normalized street names. NA inputs are
preserved as NA. Rows reduced to nothing (e.g. a bare house number with
drop_house_numbers = TRUE) become "".
Other text normalizers:
normalize_text(),
strip_vowels()
normalize_street("Muellerstrasse", lang = "de") # "MUELLERSTRASSE" normalize_street("123 Main St.") # "123 MAIN STREET" normalize_street("Calle Mayor 3", lang = "es") # "CALLE MAYOR 3" normalize_street("Hauptstr. 123A", lang = "de", drop_house_numbers = TRUE) # "HAUPTSTRASSE" normalize_street("An der Alster 5", lang = "de", drop_house_numbers = TRUE, drop_stopwords = TRUE) # "ALSTER"normalize_street("Muellerstrasse", lang = "de") # "MUELLERSTRASSE" normalize_street("123 Main St.") # "123 MAIN STREET" normalize_street("Calle Mayor 3", lang = "es") # "CALLE MAYOR 3" normalize_street("Hauptstr. 123A", lang = "de", drop_house_numbers = TRUE) # "HAUPTSTRASSE" normalize_street("An der Alster 5", lang = "de", drop_house_numbers = TRUE, drop_stopwords = TRUE) # "ALSTER"
The usual first step in a preparer pipeline. Folds text to upper case,
transliterates accented and non-Latin characters to ASCII, drops anything
that is not a letter, digit, or space, and collapses runs of whitespace. The
point is to make superficial differences in case, accents, and punctuation
disappear so that "Cafe-Conac" and "cafe conac" reduce to the same text
before it is split into tokens.
normalize_text(text, transliteration = "De-ASCII")normalize_text(text, transliteration = "De-ASCII")
text |
A character string or vector to normalize. |
transliteration |
A transliteration scheme passed to
|
Returns text, so it goes ahead of a token generator such as word_tokens()
in a strategy: name ~ normalize_text() + word_tokens().
A character vector the same length as text: upper-cased, ASCII,
alphanumeric-and-space only, with surrounding and repeated spaces removed.
word_tokens(), the token generator that usually follows.
Other text normalizers:
normalize_street(),
strip_vowels()
normalize_text("Cafe Conac") normalize_text("Strasse", transliteration = "Latin-ASCII")normalize_text("Cafe Conac") normalize_text("Strasse", transliteration = "Latin-ASCII")
Turns numeric/house-number-like text into a list of tokens. Expands ranges such as "12-14" or "7-9" into c("12","13","14"). Uses original spacing/separators to detect ranges, while normalization cleans text for tokenization.
numeric_tokens(text, keep_letters = TRUE, destructive = FALSE)numeric_tokens(text, keep_letters = TRUE, destructive = FALSE)
text |
Character vector of numeric or address fields. |
keep_letters |
Logical. If TRUE, retains letter suffixes like "12A".
Only applies when |
destructive |
Logical. If TRUE, removes all non-digit characters except whitespace. If FALSE (default), preserves letters alongside digits. |
A list of character vectors, one per input element. Each vector contains numeric tokens, with ranges expanded into sequences.
drop_numeric_tokens(), its inverse, to discard numbers from a
token column instead.
Other token generators:
generate_ngrams(),
word_tokens()
numeric_tokens("12-14") # list(c("12", "13", "14")) numeric_tokens("7A 9B", keep_letters = TRUE) # list(c("7A", "9B")) numeric_tokens("House 5", destructive = TRUE) # list("5")numeric_tokens("12-14") # list(c("12", "13", "14")) numeric_tokens("7A 9B", keep_letters = TRUE) # list(c("7A", "9B")) numeric_tokens("House 5", destructive = TRUE) # list("5")
Helps you choose a blocking before you run anything. Where
audit_strategy() grades a strategy you have already settled on, and
rarity_distribution() reads one column's token distribution,
plan_strategy() compares several candidate blockings side by side and
shows the trade-off between how many comparisons each one costs and how many
true matches it would keep together.
It never builds the pair set, so it is safe to run on a full corpus. For
each candidate blocking it reports: how many blocks it makes and how big
they are, an estimate of how many record comparisons it implies, and the
share of identical-token records that stay in the same block (the recall it
would cost you). It also reports how much an exact_strategy() front stage
would absorb, the shape of the leftover records, and how discriminative each
column is, including a warning when a column that is often empty puts a
ceiling on achievable scores.
The strategy you pass supplies only the column preparation steps; its own
block_by is ignored, since the blocking is exactly what you are choosing
here.
plan_strategy( base, strategy, target = NULL, block_candidates = list(), base_id = NULL, target_id = NULL, n_offenders = 20L, min_rarity_grid = NULL, containment = FALSE, ... )plan_strategy( base, strategy, target = NULL, block_candidates = list(), base_id = NULL, target_id = NULL, n_offenders = 20L, min_rarity_grid = NULL, containment = FALSE, ... )
base |
A data.frame / tibble / data.table (or backend table). |
strategy |
A |
target |
Optional second table. |
block_candidates |
Named list of candidate |
base_id |
Character scalar naming the id column in |
target_id |
Character scalar naming the id column in |
n_offenders |
Number of top- |
min_rarity_grid |
Optional numeric vector of |
containment |
Logical. When |
... |
Backend-specific arguments, such as |
A Strategy_Plan object.
audit_strategy() to grade a chosen strategy,
rarity_distribution() for one column's distribution,
exact_strategy() for the front stage it sizes.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3) ) # Compare two candidate blockings side by side before committing to one. plan_strategy( workshop_register, strat, block_candidates = list(area = "postcode_area", area_trade = c("postcode_area", "trade")), base_id = "reg_no" )strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3) ) # Compare two candidate blockings side by side before committing to one. plan_strategy( workshop_register, strat, block_candidates = list(area = "postcode_area", area_trade = c("postcode_area", "trade")), base_id = "reg_no" )
Turn a table into the long-format token table the matching verbs work on:
it applies each column's preparation steps, splits the text into tokens, and
attaches the id and any blocking columns. The other verbs
(detect_duplicates(), search_candidates()) call this for you, so you
rarely need it directly; reach for it when you want to see or post-process
the tokens yourself.
prepare_search_data(data, id, strategy, ...)prepare_search_data(data, id, strategy, ...)
data |
A data.frame / tibble / data.table (or db table in other backends). |
id |
Character scalar naming the ID column in |
strategy |
A |
... |
Additional arguments passed to backend-specific methods. |
A long-format token table with one row per token, carrying the id,
the source column, the token, a row_id, and any blocking columns.
inspect_tokens() for a quick per-column look at the tokens.
A pre-match read of how token rarity is distributed in your data. For each
column (and block, when the strategy blocks) it reports the spread of token
document frequency and rarity, plus an offender list: the most common tokens,
the ones that drive a match to balloon. Use it to set min_rarity and
max_token_df from what is actually in the data instead of guessing.
It never builds the pair set: it only tokenizes and measures rarity, so it is cheap enough to run on a full corpus before committing to a strategy.
rarity_distribution(data, id, strategy, ...)rarity_distribution(data, id, strategy, ...)
data |
A data.frame / tibble / data.table (or backend-specific table). |
id |
Character scalar naming the ID column in |
strategy |
A |
... |
Additional backend-specific arguments. Notably |
A Rarity_Distribution object.
search_strategy() for the min_rarity / max_token_df levers
this verb informs; audit_strategy() for the broader pre-match audit.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) # Read the token distribution and the most common tokens before matching. rarity_distribution(workshop_register, "reg_no", strat)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade") ) # Read the token distribution and the most common tokens before matching. rarity_distribution(workshop_register, "reg_no", strat)
Bar chart of median token rarity per column
rarity_histogram(x, ...)rarity_histogram(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (column_rarity_stats).
Accessor returning the recommendations strings stored on a
diagnostic result object. Returns character(0) when no
recommendations fired. The same strings are surfaced inline by the
object's print() method.
Methods for individual classes live alongside those classes -
diagnostic classes (Match_Overview, Strategy_Audit) in
diagnostic_classes.R; calibration classes (Calibrated_Matches,
Filter_Calibration) in calibration_classes.R.
recommendations(x, ...)recommendations(x, ...)
x |
A diagnostic result object ( |
... |
Reserved for future methods. |
A character vector.
Take a list of matched record pairs (an edge list) and turn it into
entities: records that link directly or through a chain of links are
grouped together, each group gets an entity number, and one record in
each group is marked as its representative.
This is the grouping step detect_duplicates() performs internally, exposed
on its own so you can resolve any pair list into entities, for example the
output of search_candidates() or a set of links you assembled yourself.
resolve_entities( edges, id_a, id_b, score = NULL, vertices = NULL, rep_by = NULL, block_by = NULL, ... )resolve_entities( edges, id_a, id_b, score = NULL, vertices = NULL, rep_by = NULL, block_by = NULL, ... )
edges |
A backend table of record-pair edges (one row per edge). |
id_a, id_b
|
Character scalars naming the two endpoint columns in
|
score |
Optional character scalar naming a per-edge score column in
|
vertices |
Optional. All vertex ids to include, so that ids absent
from every edge come back as their own singleton entity (rank 1,
|
rep_by |
Optional character scalar naming a priority column (on the
|
block_by |
Optional character vector of columns in |
... |
Additional arguments passed to backend-specific methods. |
The result does not depend on the order of rows in edges: the same pairs
always produce the same entity, rep, and rank. Entity numbers are
assigned by the smallest member id in each group. The representative (the
rank-1 member) is chosen by highest best score when a score column is
given, then by smallest rep_by when given, then by smallest id.
One row per resolved vertex:
The vertex id.
Integer entity (connected-component) label.
The canonical representative id of the entity.
Rank within the entity; rank 1 is the representative.
Best incident-edge score per vertex (only when score is
supplied).
# r1-r2 and r2-r3 chain into one entity; r4-r5 form another edges <- data.table::data.table( a = c("r1", "r2", "r4"), b = c("r2", "r3", "r5") ) resolve_entities(edges, id_a = "a", id_b = "b")# r1-r2 and r2-r3 chain into one entity; r4-r5 form another edges <- data.table::data.table( a = c("r1", "r2", "r4"), b = c("r2", "r3", "r5") ) resolve_entities(edges, id_a = "a", id_b = "b")
Sampling diagnostic (Q4). Modes: "high", "low",
"borderline", "ambiguous", "top_gap", "random".
sample_matches(matches, ...)sample_matches(matches, ...)
matches |
Match output table. |
... |
Method-specific arguments. Standard arguments: |
A Match_Sample object.
strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # Pull the borderline pairs near the threshold, the ones worth eyeballing. sample_matches(matches, mode = "borderline", n = 5, threshold = 0.7)strat <- search_strategy( workshop ~ normalize_text() + word_tokens(min_nchar = 3), block_by = c("postcode_area", "trade"), threshold = 0.7 ) matches <- search_candidates( workshop_listings, workshop_register, base_id = "listing_id", target_id = "reg_no", strategy = strat ) # Pull the borderline pairs near the threshold, the ones worth eyeballing. sample_matches(matches, mode = "borderline", n = 5, threshold = 0.7)
Expands the pre-binned histogram to approximate raw scores before passing to the density estimator.
score_density(x, threshold = x@score_dist$threshold %||% NA_real_, ...)score_density(x, threshold = x@score_dist$threshold %||% NA_real_, ...)
x |
A |
threshold |
Numeric. Draws a dashed vertical line. Defaults to the
threshold stored in |
... |
Passed to |
Invisibly, the data.table of expanded scores.
Compute cosine similarity scores between base and target embeddings. This is a pure scoring function that operates on pre-computed embeddings.
score_embeddings(base_embeddings, target_embeddings, strategy, ...)score_embeddings(base_embeddings, target_embeddings, strategy, ...)
base_embeddings |
A table with columns: |
target_embeddings |
A table with columns: |
strategy |
An |
... |
Additional arguments passed to backend-specific methods. |
A backend-specific table with columns: base_id, target_id, score.
Bar chart of the pre-binned score distribution
score_histogram(x, threshold = x@score_dist$threshold %||% NA_real_, ...)score_histogram(x, threshold = x@score_dist$threshold %||% NA_real_, ...)
x |
A |
threshold |
Numeric. Draws a dashed vertical line. Defaults to the
threshold stored in |
... |
Passed to |
Invisibly, the plotted data.table (histogram with bin_mid column).
Find candidate matches between two tables: for each record on one side, the
records on the other side that share enough rare, informative token content
to score at or above the threshold. This is the cross-table counterpart of
detect_duplicates().
Pass a search_strategy() for fuzzy, scored matching, or an
exact_strategy() to keep only pairs whose token sets are identical.
search_candidates(base_table, target_table, base_id, target_id, strategy, ...)search_candidates(base_table, target_table, base_id, target_id, strategy, ...)
base_table |
A data.frame, tibble, data.table, or backend table. |
target_table |
The table to search against. |
base_id |
Character scalar naming the ID column in |
target_id |
Character scalar naming the ID column in |
strategy |
A |
... |
Additional arguments passed to backend-specific methods, such as
|
A table with two rows per matched pair (one for the base record, one
for the target record), sharing a match_id:
Identifier shared by the two rows of a matched pair.
The pair's match score.
"base" or "target".
The record ID.
<original columns>Every other column from the source table.
Rank of this candidate among a record's matches.
detect_duplicates() for the within-table version,
extract_unmatched() for the residual, multi_stage_search() for staged
passes.
data(base_example) data(target_example) strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), Ort ~ normalize_text(), block_by = "Kreis", threshold = 0.8 ) matches <- search_candidates( base_example, target_example, base_id = "id_base", target_id = "id_target", strategy = strat ) head(matches)data(base_example) data(target_example) strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), Ort ~ normalize_text(), block_by = "Kreis", threshold = 0.8 ) matches <- search_candidates( base_example, target_example, base_id = "id_base", target_id = "id_target", strategy = strat ) head(matches)
Creates a Search_Strategy object that specifies how columns should be preprocessed for token index based record linkage, along with optional weights, blocking variables, rarity computation method, rIP smoothing, and similarity threshold.
search_strategy( ..., block_by = NULL, weights = numeric(), rarity = "inverse_freq", rarity_scope = c("block", "global"), min_rarity = 0, max_token_df = Inf, threshold = 0.9, smoothing = smooth_rip_identity(), max_candidates = Inf, max_fanout = 5e+07, on_fanout = c("cap", "abort", "off"), feedback_strength = 0, on_missing = c("penalise", "renormalise") )search_strategy( ..., block_by = NULL, weights = numeric(), rarity = "inverse_freq", rarity_scope = c("block", "global"), min_rarity = 0, max_token_df = Inf, threshold = 0.9, smoothing = smooth_rip_identity(), max_candidates = Inf, max_fanout = 5e+07, on_fanout = c("cap", "abort", "off"), feedback_strength = 0, on_missing = c("penalise", "renormalise") )
... |
Two sided formulas of the form |
block_by |
Optional character vector of column names to use for blocking.
Candidate searches will be restricted to records sharing the same blocking
key values. Default is |
weights |
Optional named numeric vector of weights for similarity scoring.
Names should correspond to columns. Default is |
rarity |
Character scalar choosing how a token's rarity (its
informativeness, the weight it carries in scoring) is computed from token
counts. A shared rare token is strong evidence two records match; a shared
common one is weak. The four methods differ in how hard they push common
tokens down. Let
Default is |
rarity_scope |
Character scalar, |
min_rarity |
Numeric scalar specifying the minimum rarity value required
for a token to be included in similarity scoring. Tokens with rarity below
this threshold are filtered out. Default is |
max_token_df |
Numeric scalar specifying the maximum raw document
frequency a token may have within its |
threshold |
Numeric scalar specifying the minimum relative identification
potential required for two records to be considered matches. Default is |
smoothing |
A |
max_candidates |
Numeric scalar specifying the maximum number of candidate
matches to retain per record. Default is |
max_fanout |
Numeric scalar. The always-on guard against a single hot or
boilerplate token (think a directory publisher's name, or a stopword that
slipped through) fanning one block into a huge number of pairwise
comparisons. This is the same failure |
on_fanout |
What to do when the estimated fan-out exceeds |
feedback_strength |
Numeric scalar controlling feedback weighted scoring.
Default is |
on_missing |
How to score a pair when a weighted column is empty on
both records. With |
A Search_Strategy object.
# Tokenize two name columns, block on region, keep pairs scoring at least 0.8. strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), block_by = "Kreis", threshold = 0.8 ) strat# Tokenize two name columns, block on region, keep pairs scoring at least 0.8. strat <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), Vorname ~ normalize_text() + word_tokens(min_nchar = 3), block_by = "Kreis", threshold = 0.8 ) strat
Histogram of sampled pairwise cosine similarities
similarity_histogram(x, threshold = attr(x, "threshold"), bins = 30L, ...)similarity_histogram(x, threshold = attr(x, "threshold"), bins = 30L, ...)
x |
An |
threshold |
Numeric. Draws a dashed vertical line at the strategy
threshold (default: |
bins |
Integer. Number of histogram bins. |
... |
Passed to |
Invisibly, the histogram data.table with columns
bin_lower, bin_upper, bin_mid, count.
Helper functions that construct S7 Smoothing objects used by
search_strategy() to control how relative identification potential (rIP)
is smoothed before scoring.
All helpers are pure configuration; they do not perform any computation
by themselves. Backend methods for detect_duplicates() and
search_candidates() interpret the resulting Smoothing object.
smooth_rip_identity() smooth_rip_log() smooth_rip_offset(alpha = 0.5) smooth_rip_softmax(temperature = 1)smooth_rip_identity() smooth_rip_log() smooth_rip_offset(alpha = 0.5) smooth_rip_softmax(temperature = 1)
alpha |
Numeric scalar; offset that is added to rIP values prior to normalization. Must be non negative. |
temperature |
Numeric scalar; softmax temperature parameter. Must be strictly positive. |
rIP Smoothing Helpers
An object inheriting from Smoothing that can be passed to
the smoothing argument of search_strategy().
smooth_rip_identity(): Identity rIP smoothing (no transformation beyond
standard per record normalization). This is the default.
smooth_rip_log(): Logarithmic rIP smoothing.
Backends typically apply log1p(rIP) and then renormalize within
each record and column.
smooth_rip_offset(): Offset based rIP smoothing with a constant offset
alpha that is added to all rIP values before renormalization.
smooth_rip_softmax(): Softmax style rIP smoothing with a temperature
parameter that controls how sharp or flat the transformed distribution is.
Uses percentage coverage when base was supplied to compare_stages(),
raw record counts otherwise.
stage_coverage_plot(x, ...)stage_coverage_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (marginal_coverage with stage_idx).
Grouped bar chart of score distributions by stage
stage_score_plot(x, ...)stage_score_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (score_dist_by_stage with bin_mid).
Locative particles and articles that recur inside multi-word street names
but carry no discriminative signal for matching - German AN DER, French
DE LA, Italian DELLA, and so on. Used by normalize_street() when
drop_stopwords = TRUE to collapse e.g. "An der Alster" to "ALSTER".
street_stopwordsstreet_stopwords
An object of class tbl_df (inherits from tbl, data.frame) with 58 rows and 2 columns.
The list is deliberately tight: only true prepositions and articles, never
adjectives ("NEUE", "GROSSE") or directionals that can themselves be the
distinguishing part of a name. Entries are uppercase ASCII so they join
directly against normalize_street()'s already-uppercased, transliterated
tokens. When a lang is supplied to normalize_street(), only that
language's particles are removed.
A tibble with two columns:
Character string. The particle in uppercase ASCII
(e.g. "AN", "DER", "DE", "DELLA").
ISO 639-1 language code ("de", "en", "fr", "es",
"it", "pt", "nl").
Manually curated from common multi-word street-name patterns across languages. Expandable as new particles are encountered.
normalize_street(), street_types
A curated cross-linguistic dictionary of street-type forms used for robust address standardization and record linkage. Each entry maps a variant - including abbreviations, orthographic alternatives, morphological forms, and transliterated spellings - to a canonical street-type label.
street_typesstreet_types
An object of class tbl_df (inherits from tbl, data.frame) with 143 rows and 4 columns.
Unlike simple suffix lists, this dictionary encodes language-specific normalization rules. Each variant is marked as either:
"exact" - the variant should only match a token when it appears
exactly, e.g. "st." -> "STREET" (English), "pl" -> "PLAZA" (Spanish)
"suffix" - the variant may safely match a token ending with that
sequence, e.g. "gatan" -> "GATA" (Swedish), "strasse" -> "STRASSE"
(German)
By separating exact vs. suffix behaviour and tagging each entry with an ISO
language code, joinery can normalize addresses without incorrect
transformations (e.g. preventing "LINCOLN" -> "LANE", or "VICTOR" ->
"RUE"). This structure enables high-precision multilingual address cleaning.
The dictionary currently includes major street-type systems from:
German - Straße, Gasse, Weg, Platz, Allee, etc.
English - Street, Road, Avenue, Boulevard, Lane, etc.
French - Rue, Avenue, Boulevard, Impasse, Quai, Chemin, etc.
Spanish - Calle, Avenida, Paseo, Plaza, Camino, etc.
Italian - Via, Piazza, Corso, Viale, etc.
Portuguese - Rua, Avenida, Praça, Alameda, Travessa, etc.
Polish - Ulica, Aleja, Plac, Osiedle, etc.
Dutch - Straat, Laan, Weg, Plein, etc.
Turkish - Sokak, Cadde, Bulvar, Meydan, etc.
Swedish - Gata, Gatan, Vägen, Torg, etc.
Danish/Norwegian - Gade, Vej, Plads, etc.
Greek (transliterated) - Odos, Leoforos, Plateia
Russian (transliterated) - Ulitsa, Prospekt, Pereulok, etc.
Additional languages and street-type systems can be incorporated as needed.
normalize_street()
street_types is used by normalize_street() to:
standardize street-type tokens to a canonical form,
optionally apply language-specific suffix rules (lang = "de", "sv", etc.),
avoid over-normalization by matching only valid variants for the specified language,
support multilingual cleaning workflows in data preprocessing and record linkage.
A tibble with four columns:
Character string. The standardized street-type label in
uppercase ASCII (e.g. "STRASSE", "AVENUE", "PLAC").
Character string. A lowercase spelling, abbreviation,
transliteration, or inflected form seen in raw address data (e.g.
"str.", "straße", "avda", "gatan").
Either "exact" or "suffix", indicating whether the variant
should match only whole tokens or may safely match as a word-final
suffix.
ISO 639-1 language code (e.g. "de", "en", "fr", "sv"),
used to restrict normalization to the appropriate street-type system.
Manually curated based on postal conventions, open datasets, and commonly observed street-name variations across languages. The dictionary is periodically expanded as new variants are encountered in real-world data.
Reduces text to its consonant skeleton by removing vowels (A, E, I, O, U,
including accented variants). Two spellings that differ only in their vowels,
such as "MEYER" and "MAYER" or "MUELLER" and "MULLER", collapse to the
same skeleton, so they match despite the difference. It is a lighter-weight
alternative to the phonetic encoders (as_soundex(), as_metaphone()) when
you only want to ignore vowel variation.
strip_vowels(text)strip_vowels(text)
text |
A character vector. |
Returns text, so it goes ahead of a token generator in a pipeline.
A character vector with vowels removed, upper-cased and ASCII-folded.
as_soundex() and as_metaphone() for full phonetic encoding.
Other text normalizers:
normalize_street(),
normalize_text()
strip_vowels("Mueller") # "MLLR" strip_vowels("Cafe Noir") # "CF NR" strip_vowels(c("Anna", "Peter"))strip_vowels("Mueller") # "MLLR" strip_vowels("Cafe Noir") # "CF NR" strip_vowels(c("Anna", "Peter"))
Post-match overview (Q2). Auto-detects whether the input
is a duplicate table (presence of duplicate_group column) or a
candidate table (presence of match_id and source columns), and
reports score distribution, coverage (when base / target are
supplied), cluster-size or candidates-per-record distribution, and
top-1-vs-top-2 score-gap distribution for candidates. Recommendations
link symptoms to strategy levers.
summarise_matches(matches, ...)summarise_matches(matches, ...)
matches |
Match output table from |
... |
Method-specific arguments. The data.table method accepts:
|
A Match_Overview object.
s <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), block_by = "Kreis", threshold = 0.8 ) dups <- detect_duplicates(base_example, "id_base", s) summarise_matches(dups, base = base_example)s <- search_strategy( Nachname ~ normalize_text() + word_tokens(min_nchar = 3), block_by = "Kreis", threshold = 0.8 ) dups <- detect_duplicates(base_example, "id_base", s) summarise_matches(dups, base = base_example)
A dataset containing 3,000 person records designed to match with base_example.
Approximately 80% of records correspond to records in the base dataset but
with realistic errors and variations (typos, abbreviated names, title additions,
street name variations). The remaining 20% are new records with no match.
target_exampletarget_example
A tibble with 3,000 rows and 7 variables:
The actual base_id in the simulation process
Character identifier for target records (T0001-T3600)
First name, may include titles, initials, or middle names
Last name, may contain typos or token swaps
Street name with possible abbreviations or typos
House number with possible letter suffixes
City or town name, may contain typos
Administrative district (Kreis)
Synthetically generated by distorting 80% of base_example records
and adding 20% new unmatched records.
Horizontal bar chart of per-token score contributions, coloured by column
token_contribution_plot(x, ...)token_contribution_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (shared_tokens with token_label).
Bar chart of average tokens per record per column
token_frequency_plot(x, ...)token_frequency_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (column_token_stats).
Reduces each token to its letter/digit pattern: every letter becomes "A",
every digit "N", anything else "X". The signature ignores the actual
characters and keeps only the layout, which is useful for matching on the
format of a code or identifier (postal codes, licence plates, product codes)
rather than its exact value, or as a coarse blocking key.
token_shapes(tokens)token_shapes(tokens)
tokens |
A list of character vectors. |
It transforms a token column, so it runs after a token generator such as
word_tokens().
A list of character vectors of shape signatures, one signature per input token.
Other token transformers:
drop_numeric_tokens(),
drop_short_tokens(),
extract_initials(),
filter_stopwords(),
fuzzy_tokens(),
use_dictionary()
token_shapes(list(c("MUELLER", "A12B"))) # list(c("AAAAAAA", "ANNA"))token_shapes(list(c("MUELLER", "A12B"))) # list(c("AAAAAAA", "ANNA"))
Bar chart of top-1 vs top-2 score gap distribution (candidates only)
top_gap_density(x, ...)top_gap_density(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table (top_gap_dist with bin_mid).
When you already know which tokens mean the same thing (a curated synonym
list, brand-name variants, a code-to-label table), use_dictionary() rewrites
each token to its group label so the variants collapse to one token and match.
Use it when the mapping is known in advance; when you instead want joinery to
discover near-duplicates from the data, use fuzzy_tokens().
use_dictionary(text, dict)use_dictionary(text, dict)
text |
A character vector of tokens to look up. |
dict |
A data.table::data.table with a |
Tokens absent from the dictionary return no group, so chain this after a token generator and keep a sharper field alongside it.
A list of character vectors, one per input element, holding the
matched group labels (empty when the token is not in dict).
fuzzy_tokens() to discover groups from the data instead.
Other token transformers:
drop_numeric_tokens(),
drop_short_tokens(),
extract_initials(),
filter_stopwords(),
fuzzy_tokens(),
token_shapes()
dict <- data.table::data.table( tokens = c("example", "sample"), token_group = c("example/sample", "example/sample") ) use_dictionary("example", dict) use_dictionary("nonexistent", dict)dict <- data.table::data.table( tokens = c("example", "sample"), token_group = c("example/sample", "example/sample") ) use_dictionary("example", dict) use_dictionary("nonexistent", dict)
Bar chart of vocabulary overlap between base and target per column
vocab_overlap_plot(x, ...)vocab_overlap_plot(x, ...)
x |
A |
... |
Passed to |
Invisibly, the plotted data.table.
The workhorse tokenizer. It splits each string on whitespace into a vector of
words, the tokens joinery matches on. It almost always follows
normalize_text(), which strips punctuation and case first so the split is
clean: name ~ normalize_text() + word_tokens().
word_tokens(text, min_nchar = 0)word_tokens(text, min_nchar = 0)
text |
A character vector to split into words. |
min_nchar |
Minimum token length to keep. Tokens shorter than this are
dropped. Defaults to |
Set min_nchar to drop very short tokens (single initials, stray letters)
that match too easily and add noise.
A list of character vectors, one per input element, each holding that element's word tokens.
normalize_text(), the usual preceding step;
filter_stopwords() to drop common words by name.
Other token generators:
generate_ngrams(),
numeric_tokens()
word_tokens("this is an example") word_tokens("this is an example", min_nchar = 3) # drops "is", "an"word_tokens("this is an example") word_tokens("this is an example", min_nchar = 3) # drops "is", "an"
A messier external directory of the same UK workshops as
workshop_register, the target table for cross-source linkage. Listings
carry the realistic distortions a scrappy directory introduces, planted as
labelled tiers (via gen_tier) so each exercises a specific joinery
feature: slogan-stuffed supersets (exact containment), movers that changed
postcode area (token blocking with global rarity), phonetic name variants
(Cologne/Soundex encoders), shared-venue and bare-category rows that bait
over-linking (the containment guards), and common-surname homonyms (ambiguity
and calibration). The matchable columns share names with
workshop_register so one formula serves both tables.
workshop_listingsworkshop_listings
A tibble with 894 rows and 8 variables:
Character identifier for listings (e.g. "L00042")
Directory rendering of the business name, often messy
Directory rendering of the proprietor name; may be missing
Trade; half the blocking key
UK outward-code area; half the blocking key
Town for the postcode area
Evaluation only. The reg_no this listing refers
to, or NA for a workshop absent from the register.
Evaluation only. The feature-exercise tier (clean, slogan, variant, mover, phonetic, hub_member, hub_trap, category_trap, homonym_area, homonym_block, homonym_total, new).
Synthetically generated by data-raw/generate_workshop_example.R
from workshop_register and a frozen LLM seed of messy renderings.
A small pooled-long panel of the same UK woodworking workshops as
workshop_register, observed across five years (2019 to 2023). It is the
runnable example for cross-year entity resolution: each row is one workshop in
one year, names drift over time, a minority of workshops relocate to a
different postcode area, and workshops enter and exit so trajectories have
gaps. The task is to recover the stable identity (true_entity) behind
the year-by-year rows. A reappearance window of five years matches the design
of the real Yellow Pages panel the package was built against.
workshop_panelworkshop_panel
A tibble with 847 rows and 10 variables:
Character row id, unique per workshop-year (e.g. "YR-00042")
Observation year, 2019 to 2023
Business name as recorded that year, with light per-year noise
Proprietor name, in a directory rendering that varies by year
One of eight woodworking trades
UK outward-code area; changes mid-trajectory for movers
Town for the postcode area
Year the workshop was established (stable within an entity)
Evaluation only. The stable entity key (the
workshop_register reg_no); every year-row of one workshop
shares it.
Evaluation only. The cross-year challenge the entity
carries: stable (per-year noise only), name_drift (a
structural name change part-way through), mover (a postcode-area
relocation), or phonetic (a code-preserving stem twin).
Synthetically generated by data-raw/generate_workshop_panel.R,
which draws core workshops from workshop_register and gives each a
year span with drift. Seeded and offline; ships no real business.
workshop_register, workshop_listings
A synthetic register of UK joinery and carpentry workshops, styled like an
excerpt from a Guild of Master Craftsmen trade roll. It is the clean base
table for the linkage examples: distinctive workshop names paired with
boilerplate trade and legal-form terms, so the rarity-versus-boilerplate
behaviour of the matcher is visible. Pairs with workshop_listings, the
messier external directory. Block on (postcode_area, trade).
workshop_registerworkshop_register
A tibble with 1,052 rows and 15 variables:
Character registration number, the base id. Most are
"GMC-#####"; planted duplicates, homonyms, and shared-venue rows
carry "GMC-D####", "GMC-H####", and "GMC-V####"
prefixes respectively.
Canonical business name (distinctive stem plus trade and legal-form boilerplate)
Proprietor name
One of eight woodworking trades; half the blocking key
Ltd, LLP, Partnership, or Sole Trader
UK outward-code area (e.g. "LS"); half the blocking key
Town for the postcode area
Street address
Year the workshop was established
Headcount, varying with legal form
Number of apprentices
Logical, whether a current guild member
UK SIC 2007 industry code for the trade
Evaluation only. Same-entity key: planted duplicate rows share it, homonym workshops get distinct keys.
Evaluation only. Which generation tier the row belongs to.
Three rows are hub_trap: short-named shared venues ("Trinity
Workshops", "The Forge", "Riverside Works") that are themselves guild
registered. Their two-token names are a forward-containment subset of every
<workshop>, <venue> listing, so they bait an exact containment strategy
into merging unrelated workshops; the min_containment_tokens guard
blocks them.
Synthetically generated. Distinct workshop identities come from a
frozen LLM seed (data-raw/llm_workshop_seed.R); all geography,
colour columns, planted duplicates, and homonyms are added by the seeded,
offline data-raw/generate_workshop_example.R. Ships no real business.