This vignette demonstrates the complete starling
probabilistic record linkage workflow: from pre-linkage data quality
assessment through blocking variable construction, threshold sensitivity
analysis, linkage, and post-linkage validation. The scenario mirrors a
routine SCPHU task: linking a notifiable disease linelist (EDIS
extracts) to the Australian Immunisation Register (AIR) to determine
vaccination status at the time of disease onset.
The datasets used (cases_notifiable and
vax_air) are synthetic — no real person data. They include
deliberate data quality issues (name typos, corrupted Medicare numbers)
to demonstrate how the starling toolkit handles real-world
messiness.
library(starling)
data(cases_notifiable)
data(vax_air)
cat("Cases linelist: ", nrow(cases_notifiable), "records\n")
cat("Vaccination register:", nrow(vax_air), "records\n")
cat("True matches (known):", sum(!is.na(cases_notifiable$true_link_id)), "\n")
preflight()Before generating a single candidate pair, preflight()
runs a structured battery of checks across both datasets: completeness
of linkage variables, duplicate identifiers, date plausibility, Medicare
validity, name field quality, and factor-level consistency.
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"
)
The audit flags include: - Any linkage variables with missingness above 10% - Duplicate ID values in either dataset - Medicare numbers that fail the Modulus 10 checksum - Date values before 1900 or after today
check_medicare()The preflight() report includes Medicare validity, but
check_medicare() can also be called standalone for a more
detailed summary and to add the validation flag column for downstream
use.
# Validate cases
cases_checked <- check_medicare(cases_notifiable,
medicare_col = "medicare10",
output_col = "medicare_valid",
verbose = TRUE)
# Confirm AIR Medicare numbers are all valid
vax_checked <- check_medicare(vax_air,
medicare_col = "medicare10",
output_col = "medicare_valid",
verbose = TRUE)
# Replace corrupted Medicare numbers with NA before linkage
# so they don't negatively score a true match
cases_checked$medicare10 <- ifelse(
cases_checked$medicare_valid == 1L,
cases_checked$medicare10,
NA_character_
)
The cases dataset has ~10% corrupted Medicare numbers by design.
Setting those to NA before linkage is better than passing
an invalid number, because the EM algorithm treats NA as
“not observed” (no contribution to the score, positive or negative),
whereas an invalid number that happens to match a wrong AIR record would
add spurious positive weight.
flock()flock() creates blocking keys that partition both
datasets into candidate comparison groups. murmuration()
only compares pairs within the same block, making the search tractable
for large datasets.
# Extract birth year for composite blocking
cases_blocked <- flock(cases_checked,
block1_vars = "gender",
block2_vars = "gender",
block3_vars = "postcode",
birth_year_col = "dob")
vax_blocked <- flock(vax_checked,
block1_vars = "gender",
block2_vars = "gender",
block3_vars = "postcode",
birth_year_col = "dob")
# Summary of blocking key distributions
cat("block1 (gender) — unique values in cases:",
dplyr::n_distinct(cases_blocked$block1), "\n")
cat("block3 (postcode) — unique values in cases:",
dplyr::n_distinct(cases_blocked$block3), "\n")
For this small synthetic dataset we use block1 (gender
only). For large production datasets (> 100 000 records), use
multi-pass blocking: run murmuration() separately with
block1 and block3, then union the results.
perch()Before committing to a threshold, we can use perch()
standalone to understand the score landscape. Alternatively,
murmuration(perch_before_linking = TRUE) calls
perch() automatically mid-linkage after the EM model
fits.
# This would require running the EM model first —
# see the murmuration() call below which does this in one step.
# For standalone use on a pre-scored pairs object:
# pairs <- reclin2::pair_blocking(cases_blocked, vax_blocked, "block1")
# reclin2::compare_pairs(pairs,
# on = c("lettername1", "lettername2", "dob", "medicare10"),
# default_comparator = reclin2::jaro_winkler(0.9), inplace = TRUE)
# m <- reclin2::problink_em(
# ~ lettername1 + lettername2 + dob + medicare10, data = pairs)
# pairs_pred <- predict(m, pairs = pairs, add = TRUE)
#
# perch(pairs_pred, n_records_df1 = nrow(cases_blocked),
# thresholds = seq(8, 25, by = 1))
The threshold guidance from Australian linkage authorities:
| Range | Source | Meaning |
|---|---|---|
| 10–20 | AIHW / WA Data Linkage Unit | Clerical review zone |
| 15–20 | PHRN | Operational target for <0.5% false-match rate |
| 17 | starling default | Balanced for routine surveillance |
murmuration()murmuration() runs the complete Fellegi-Sunter EM
linkage pipeline in one call. We use
perch_before_linking = TRUE to inspect the score
distribution before the threshold is applied.
linked <- murmuration(
df1 = cases_blocked,
df2 = vax_blocked,
linkage_type = "v2c",
event_date = "onset_date",
id_var = "id_var",
blocking_var = "block1",
compare_vars = c("lettername1", "lettername2", "dob", "medicare10"),
threshold_value = 17,
perch_before_linking = FALSE, # set TRUE in interactive sessions to inspect
days_allowed_before_event = 14,
clean_eggs = TRUE
)
cat("Linked records: ", nrow(linked), "\n")
cat("With vaccination: ", sum(!is.na(linked$vax_date_1)), "\n")
cat("Without vaccination: ", sum( is.na(linked$vax_date_1)), "\n")
murmuration_plot()Even if perch_before_linking = FALSE during the linkage
call, we can still inspect the weight distribution afterwards by
accessing the weights column on the linked output.
# The linked output retains the weights column when clean_eggs = TRUE
# For the visualisation, we use the weights from the linked data frame
if ("weights" %in% names(linked)) {
murmuration_plot(linked, threshold = 17, show_density = FALSE,
palette = "sch")
}
Because cases_notifiable contains
true_link_id (the ground-truth match identifier), we can
compute recall and precision on the synthetic data. This step is only
possible with synthetic data — in production, post-linkage validation
requires a clerical review sample.
# Recall: proportion of true matches recovered
true_positives <- sum(
!is.na(linked$true_link_id) &
!is.na(linked$id_var_df2) &
linked$true_link_id == linked$id_var_df2,
na.rm = TRUE
)
total_true_matches <- sum(!is.na(cases_notifiable$true_link_id))
recall <- true_positives / total_true_matches
# Precision: proportion of accepted links that are true matches
total_links <- sum(!is.na(linked$id_var_df2))
precision <- true_positives / total_links
cat(sprintf("Recall: %.1f%% (%d / %d true matches recovered)\n",
recall * 100, true_positives, total_true_matches))
cat(sprintf("Precision: %.1f%% (%d / %d links are true matches)\n",
precision * 100, true_positives, total_links))
cat(sprintf("F1 score: %.3f\n",
2 * precision * recall / (precision + recall)))
library(starling)
data(cases_notifiable); data(vax_air)
# 1. Pre-linkage audit
preflight(cases_notifiable, vax_air,
linkage_vars = c("lettername1", "lettername2", "dob", "medicare10"),
medicare_col = "medicare10")
# 2. Medicare validation — replace invalid numbers with NA
cases <- check_medicare(cases_notifiable)
cases$medicare10 <- ifelse(cases$medicare_valid == 1L, cases$medicare10, NA_character_)
# 3. Blocking variables
cases <- flock(cases, block1_vars = "gender", birth_year_col = "dob")
vax <- flock(vax_air, block1_vars = "gender", birth_year_col = "dob")
# 4. Link (perch_before_linking = TRUE in interactive sessions)
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)
# 5. Pass to mudnester or bowerbird for downstream analysis
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
#>
#> loaded via a namespace (and not attached):
#> [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.56
#> [5] cachem_1.1.0 knitr_1.51 htmltools_0.5.9 rmarkdown_2.31
#> [9] lifecycle_1.0.5 cli_3.6.5 sass_0.4.10 jquerylib_0.1.4
#> [13] compiler_4.5.2 rstudioapi_0.18.0 tools_4.5.2 evaluate_1.0.5
#> [17] bslib_0.10.0 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
#> [21] rlang_1.2.0