The Fellegi-Sunter EM algorithm scores pairs by comparing field values. Poor data quality in any of those fields degrades the score distribution in ways that no threshold choice can fully recover:
starling provides three functions specifically for
catching these issues before murmuration() is called.
preflight() — structured pre-linkage auditpreflight() runs a battery of checks across both
datasets and returns a structured report. It is the recommended first
step before any linkage call.
library(starling)
data(cases_notifiable)
data(vax_air)
audit <- preflight(
data1 = cases_notifiable,
data2 = vax_air,
linkage_vars = c("lettername1", "lettername2", "dob", "medicare10"),
id_col1 = "id_var",
id_col2 = "id_var",
date_cols = c("dob", "onset_date"),
medicare_col = "medicare10",
verbose = TRUE
)
#>
#> ================================================================
#> starling::preflight() - Pre-Linkage Data Quality Report
#> ================================================================
#> Dataset 1: 300 records x 10 columns
#> Dataset 2: 400 records x 15 columns
#>
#> == 1. Completeness of linkage variables ==
#> Variable Dataset Present Missing % Missing
#> ------------------------------------------------------------
#> lettername1 data1 300 0 0%
#> lettername2 data1 300 0 0%
#> dob data1 NA NA NA%
#> medicare10 data1 300 0 0%
#> lettername1 data2 400 0 0%
#> lettername2 data2 400 0 0%
#> dob data2 NA NA NA%
#> medicare10 data2 400 0 0%
#>
#> == 2. Duplicate records (by ID column) ==
#> Dataset Records Unique IDs Duplicates % Duplicate
#> ------------------------------------------------------------
#> data1 300 300 0 0%
#> data2 400 400 0 0%
#>
#> == 4. Date plausibility ==
#> Column: dob
#> data1 - Missing: 0 | Future: 0 | Pre-1900: 0 | Range: 1905-04-01 to 1989-03-10
#> data2 - Missing: 0 | Future: 0 | Pre-1900: 0 | Range: 1905-02-09 to 1990-01-01
#> Column: onset_date
#> data1 - Missing: 0 | Future: 0 | Pre-1900: 0 | Range: 2024-01-05 to 2024-12-30
#> data2: column not found
#>
#> == 5. Medicare number validation ==
#> data1 - Valid: 0 (0%) | Invalid checksum: 0 (0%) | Missing: 300
#> data2 - Valid: 0 (0%) | Invalid checksum: 0 (0%) | Missing: 400
#>
#> == 6. Name field quality ==
#> Column Dataset Too short Numeric Unusual chars
#> -----------------------------------------------------------------
#> lettername1 data1 0 0 0
#> lettername1 data2 0 0 0
#> lettername2 data1 0 0 0
#> lettername2 data2 0 0 0
#>
#> == 7. Categorical variable consistency (shared columns) ==
#> 'gender': data1 levels = {Female, Male} | data2 levels = {Female, Male}
#>
#> == Summary of flags ==
#> 2 issue(s) flagged:
#> [!] [HIGH MISSINGNESS] 'NA' in NA: NA missing
#> [!] [HIGH MISSINGNESS] 'NA' in NA: NA missing
#> ================================================================
The audit list contains structured results for
programmatic access:
# Completeness table
head(audit$completeness)
#> variable dataset n_records n_present n_missing pct_missing
#> 1 lettername1 data1 300 300 0 0%
#> 2 lettername2 data1 300 300 0 0%
#> 3 dob data1 300 NA NA NA%
#> 4 medicare10 data1 300 300 0 0%
#> 5 lettername1 data2 400 400 0 0%
#> 6 lettername2 data2 400 400 0 0%
# Duplicate ID counts
audit$duplicates
#> dataset n_records n_unique_ids n_duplicate_ids pct_duplicate
#> 1 data1 300 300 0 0%
#> 2 data2 400 400 0 0%
# Flags raised (empty = all clear)
audit$flags
#> [1] "[HIGH MISSINGNESS] 'NA' in NA: NA missing"
#> [2] "[HIGH MISSINGNESS] 'NA' in NA: NA missing"
check_medicare() — Modulus 10 checksum validationAustralian Medicare numbers contain a check digit at position 9
calculated from positions 1–8 using the weights 1, 3, 7, 9, 1, 3, 7, 9.
check_medicare() verifies this and returns a three-state
flag:
| Value | Meaning |
|---|---|
1L |
Checksum passes — number is internally consistent |
0L |
Checksum fails — at least one digit is wrong |
NA |
Missing, blank, or non-numeric — not verifiable |
cases_checked <- check_medicare(
cases_notifiable,
medicare_col = "medicare10",
output_col = "medicare_valid",
verbose = TRUE
)
#>
#> -- starling::check_medicare() ----------------------------------
#> Total records : 300
#> Missing / blank : 300
#> Present (n) : 0
#> Valid checksum : 0 (N/A%)
#> Invalid checksum : 0 (N/A%)
#> Output column : 'medicare_valid' (1=valid, 0=invalid, NA=missing)
#> ----------------------------------------------------------------
# Distribution of flags
table(cases_checked$medicare_valid, useNA = "always")
#>
#> <NA>
#> 300
Do not discard the record — it may still link correctly on name and
DOB. Instead, replace the invalid number with NA before
linkage so it does not contribute negatively to the score:
cases_checked$medicare10 <- ifelse(
cases_checked$medicare_valid == 1L,
cases_checked$medicare10,
NA_character_
)
flock() — blocking variable constructionflock() creates one to three blocking keys from
demographic fields. Blocking restricts murmuration() to
comparing only pairs that share a blocking key, making the search
tractable for large datasets without sacrificing recall on the key
variables.
# Single-field block (gender only — broadest, lowest specificity)
cases_blocked <- flock(
cases_checked,
block1_vars = "gender", # block1: gender only
block2_vars = "gender", # block2: same here; use composite in production
block3_vars = "postcode", # block3: postcode (finer)
birth_year_col = "dob" # derives birth_year column for composite use
)
vax_blocked <- flock(
vax_air,
block1_vars = "gender",
block3_vars = "postcode",
birth_year_col = "dob"
)
# Inspect blocking key distributions
table(cases_blocked$block1)
#>
#> Female Male
#> 141 159
head(sort(table(cases_blocked$block3), decreasing = TRUE))
#>
#> 4562 4555 4552 4556 4557 4560
#> 30 29 28 28 28 25
| Dataset size | Recommended block | Rationale |
|---|---|---|
| < 10 000 records | gender |
Broadest; halves candidate space immediately |
| 10 000–500 000 | gender + birth_year composite |
Balances sensitivity and specificity |
| > 500 000 | postcode + birth_year |
Fine-grained; requires high postcode completeness |
For maximum recall, run murmuration() twice — once with
a broad block, once with a fine block — and union the results:
linked_broad <- murmuration(cases_blocked, vax_blocked,
blocking_var = "block1", ...)
linked_fine <- murmuration(cases_blocked, vax_blocked,
blocking_var = "block3", ...)
# Union, keeping the highest-scoring link per case
linked_all <- dplyr::bind_rows(linked_broad, linked_fine) |>
dplyr::group_by(id_var.x) |>
dplyr::slice_max(weights, n = 1, with_ties = FALSE) |>
dplyr::ungroup()
library(starling)
data(cases_notifiable); data(vax_air)
# 1. Audit
preflight(cases_notifiable, vax_air,
linkage_vars = c("lettername1", "lettername2", "dob", "medicare10"),
medicare_col = "medicare10")
# 2. Fix Medicare
cases <- check_medicare(cases_notifiable)
cases$medicare10 <- ifelse(cases$medicare_valid == 1L,
cases$medicare10, NA_character_)
# 3. Block
cases <- flock(cases, block1_vars = "gender", birth_year_col = "dob")
vax <- flock(vax_air, block1_vars = "gender", birth_year_col = "dob")
# 4. Link
linked <- murmuration(cases, vax,
linkage_type = "v2c",
event_date = "onset_date",
id_var = "id_var",
blocking_var = "block1",
compare_vars = c("lettername1", "lettername2", "dob", "medicare10"),
threshold_value = 17)
sessionInfo()
#> R version 4.5.2 (2025-10-31 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26100)
#>
#> Matrix products: default
#> LAPACK version 3.12.1
#>
#> locale:
#> [1] LC_COLLATE=C LC_CTYPE=English_Australia.utf8
#> [3] LC_MONETARY=English_Australia.utf8 LC_NUMERIC=C
#> [5] LC_TIME=English_Australia.utf8
#>
#> time zone: Australia/Brisbane
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] starling_1.1.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 jsonlite_2.0.0 dplyr_1.2.0
#> [4] compiler_4.5.2 Rcpp_1.1.1 tidyselect_1.2.1
#> [7] stringr_1.6.0 parallel_4.5.2 jquerylib_0.1.4
#> [10] scales_1.4.0 yaml_2.3.12 fastmap_1.2.0
#> [13] ggplot2_4.0.3 R6_2.6.1 generics_0.1.4
#> [16] knitr_1.51 tibble_3.3.1 lubridate_1.9.5
#> [19] bslib_0.10.0 pillar_1.11.1 RColorBrewer_1.1-3
#> [22] rlang_1.2.0 stringi_1.8.7 cachem_1.1.0
#> [25] reclin2_0.6.0 xfun_0.56 sass_0.4.10
#> [28] S7_0.2.1 otel_0.2.0 timechange_0.4.0
#> [31] cli_3.6.5 magrittr_2.0.4 stringdist_0.9.17
#> [34] digest_0.6.39 grid_4.5.2 rstudioapi_0.18.0
#> [37] lifecycle_1.0.5 vctrs_0.7.1 lpSolve_5.6.23
#> [40] data.table_1.18.2.1 evaluate_1.0.5 glue_1.8.0
#> [43] farver_2.1.2 rmarkdown_2.31 tools_4.5.2
#> [46] pkgconfig_2.0.3 htmltools_0.5.9