--- title: "Pre-Linkage Data Quality: preflight(), check_medicare(), and flock()" subtitle: "Preparing your data before murmuration()" author: "Dr Nicolas Smoll, SCPHU, Sunshine Coast Hospital and Health Service" date: "`r Sys.Date()`" output: html_document: toc: true toc_depth: 3 toc_float: true theme: flatly pdf_document: toc: true toc_depth: 3 vignette: > %\VignetteIndexEntry{Pre-Linkage Data Quality} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", message = FALSE, warning = FALSE ) ``` ## Why pre-linkage quality matters 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: - **High missingness** in a comparison variable reduces its discriminating power, flattening the valley between match and non-match clusters. - **Invalid Medicare numbers** that happen to match a wrong AIR record add spurious positive weight to false pairs. - **Name typos** are expected (the algorithm handles them via fuzzy string comparison) but systematic truncation or encoding differences are not. - **Duplicate IDs** in the primary dataset produce multiple output rows per original record, inflating counts and biasing VE estimates. `starling` provides three functions specifically for catching these issues before `murmuration()` is called. --- ## `preflight()` — structured pre-linkage audit `preflight()` runs a battery of checks across both datasets and returns a structured report. It is the recommended first step before any linkage call. ```{r preflight} 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 ) ``` The `audit` list contains structured results for programmatic access: ```{r audit-structure} # Completeness table head(audit$completeness) # Duplicate ID counts audit$duplicates # Flags raised (empty = all clear) audit$flags ``` --- ## `check_medicare()` — Modulus 10 checksum validation Australian 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 | ```{r medicare} cases_checked <- check_medicare( cases_notifiable, medicare_col = "medicare10", output_col = "medicare_valid", verbose = TRUE ) # Distribution of flags table(cases_checked$medicare_valid, useNA = "always") ``` ### What to do with invalid Medicare numbers 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: ```{r fix-medicare} cases_checked$medicare10 <- ifelse( cases_checked$medicare_valid == 1L, cases_checked$medicare10, NA_character_ ) ``` --- ## `flock()` — blocking variable construction `flock()` 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. ```{r flock} # 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) head(sort(table(cases_blocked$block3), decreasing = TRUE)) ``` ### Choosing blocking variables for Australian datasets | 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: ```{r multi-pass, eval = FALSE} 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() ``` --- ## Putting it together ```{r summary, eval = FALSE} 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) ``` --- ## Session information ```{r session} sessionInfo() ```