Skip to content
Back to skills

Methods Coach

ASecurity

Any project that needs to link records across datasets without a reliable unique identifier, where names (personal or place names) are the primary linking variable. Examples: - Linking patient records across screening years - Deduplicating registries with inconsistent name spelling - Matching village/location names across data sources - Any Western or Congolese name matching context

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 23, 2026
ai-agentsgo

Security analysis

A100/100

Scanned September 23, 2026

npx -y skills add SVerITG/Metis --skill methods-coach --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Methods Coach?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Methods Coach
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/sveritg-methods-coach/badge)](https://www.skillsdirectory.com/skills/sveritg-methods-coach)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# Skill: Fuzzy Name Matching for Record Linkage

## When to use this skill
Any project that needs to link records across datasets without a reliable unique identifier,
where names (personal or place names) are the primary linking variable. Examples:
- Linking patient records across screening years
- Deduplicating registries with inconsistent name spelling
- Matching village/location names across data sources
- Any Western or Congolese name matching context

---

## Core principles

1. **Never match on names alone across an entire dataset** — always block first
2. **Compute distances, then classify into zones** — do not use a single hard threshold
3. **Account for missing variables explicitly** — do not silently drop NA pairs
4. **Always validate before applying at scale** — export candidates, label manually, compute precision/recall
5. **Keep the best match per anchor record** — use `slice_min()`, never `slice(1)`

---

## Step 1 — Variable assessment

Before any matching, assess completeness of candidate linking variables:

```r
dat %>%
  select(nom, prenom, postnom, nom_mere, village) %>%
  summarise(across(everything(), list(
    n_present = \(x) sum(!is.na(x) & trimws(x) != ""),
    pct_miss  = \(x) round(100 * (sum(is.na(x)) + sum(!is.na(x) & trimws(x) == "")) / n(), 1)
  ), .names = "{.col}__{.fn}")) %>%
  pivot_longer(everything(), names_to = c("variable", "stat"), names_sep = "__") %>%
  pivot_wider(names_from = stat, values_from = value) %>%
  arrange(pct_miss)
```

Also check co-missingness — when your primary variable is missing, is the fallback variable present?

```r
dat %>%
  mutate(nom_missing  = is.na(nom) | trimws(nom) == "",
         mere_present = !is.na(nom_mere) & trimws(nom_mere) != "") %>%
  group_by(nom_missing) %>%
  summarise(n = n(), mere_present = sum(mere_present), pct = round(100 * mere_present / n, 1))
```

---

## Step 2 — Blocking

Block on geography or another exact variable BEFORE computing string distances.
This reduces comparisons from millions to thousands and prevents false many-to-many matches.

**For DRC/TRYPELIM data**: block on `healthzone + healtharea + village`
(many villages share the same name across provinces — blocking on village alone is insufficient)

**For Western data**: block on postal code, year of birth, or first letter of surname

```r
candidates <- data_yr1 %>%
  inner_join(data_yr2,
             by = c("healthzone", "healtharea", "village"),
             suffix = c("_yr1", "_yr2"))
```

---

## Step 3 — Distance computation

Use **Jaro-Winkler** (`method = "jw"` in `stringdist`) for name variables.
Rationale: handles transpositions, gives higher weight to prefix matches,
performs better than Levenshtein on short strings and African name variants.

```r
library(stringdist)

candidates <- candidates %>%
  mutate(
    dist_nom     = stringdist(nom_yr1,     nom_yr2,     method = "jw"),
    dist_prenom  = stringdist(prenom_yr1,  prenom_yr2,  method = "jw"),
    dist_postnom = stringdist(postnom_yr1, postnom_yr2, method = "jw"),
    dist_mere    = stringdist(mere_yr1,    mere_yr2,    method = "jw")
  )
```

---

## Step 4 — Sequential gate logic with NA handling

Always require the primary variable (highest completeness) to pass Gate 1.
Then select exactly 2 more variables for Gate 2, using fallbacks when variables are missing.

### Gate 1 — primary variable required
```r
candidates <- candidates %>%
  filter(dist_nom < 0.20)   # lenient at gate; zones do the real work
```

### Gate 2 — select 2 secondary variables, handle NA
```r
candidates <- candidates %>%
  mutate(
    prenom_avail  = !is.na(prenom_yr1)  & trimws(prenom_yr1)  != "" &
                    !is.na(prenom_yr2)  & trimws(prenom_yr2)  != "",
    postnom_avail = !is.na(postnom_yr1) & trimws(postnom_yr1) != "" &
                    !is.na(postnom_yr2) & trimws(postnom_yr2) != "",
    mere_avail    = !is.na(mere_yr1)    & trimws(mere_yr1)    != "" &
                    !is.na(mere_yr2)    & trimws(mere_yr2)    != "",

    vars_used = case_when(
      prenom_avail & postnom_avail  ~ "prenom + postnom",
      prenom_avail & !postnom_avail ~ "prenom + mere",
      !prenom_avail & postnom_avail ~ "postnom + mere",
      TRUE                          ~ "mere only"           # low confidence
    ),

    avg_dist_used = case_when(
      vars_used == "prenom + postnom" ~ rowMeans(cbind(dist_nom, dist_prenom, dist_postnom), na.rm = TRUE),
      vars_used == "prenom + mere"    ~ rowMeans(cbind(dist_nom, dist_prenom, dist_mere),    na.rm = TRUE),
      vars_used == "postnom + mere"   ~ rowMeans(cbind(dist_nom, dist_postnom, dist_mere),   na.rm = TRUE),
      vars_used == "mere only"        ~ rowMeans(cbind(dist_nom, dist_mere),                 na.rm = TRUE)
    ),

    low_confidence = vars_used == "mere only"
  )
```

---

## Step 5 — Three-zone classification

| Zone | avg_dist_used | Action |
|---|---|---|
| auto_match | < 0.10 | Accept automatically |
| review | 0.10 – 0.20 | Export for manual review |
| reject | > 0.20 | Discard |

```r
candidates <- candidates %>%
  mutate(
    zone = case_when(
      avg_dist_used <  0.10 ~ "auto_match",
      avg_dist_used <= 0.20 ~ "review",
      TRUE                  ~ "reject"
    )
  ) %>%
  filter(zone != "reject")
```

---

## Step 6 — Tie-breaking

Keep the best match per anchor record using lowest average distance.
Never use `slice(1)` — that is row-order dependent and not reproducible.

```r
candidates_best <- candidates %>%
  group_by(identifier_yr1) %>%
  slice_min(avg_dist_used, n = 1, with_ties = FALSE) %>%
  ungroup()
```

---

## Step 7 — Export and validation

Export two files:
- **Auto-matches**: no review needed, spot-check a sample for quality control
- **Review zone**: label `correct_match` TRUE/FALSE in Excel, then compute precision/recall

```r
# Review zone export
candidates_best %>%
  filter(zone == "review") %>%
  mutate(correct_match = NA_character_) %>%
  arrange(avg_dist_used) %>%
  write.csv("fuzzy_review.csv", row.names = FALSE, fileEncoding = "UTF-8")
```

After labelling, compute precision by distance band:

```r
val <- read.csv("fuzzy_review.csv") %>%
  mutate(correct_match = as.logical(correct_match))

val %>%
  mutate(dist_band = cut(avg_dist_used,
                         breaks = c(0.10, 0.12, 0.14, 0.16, 0.18, 0.20),
                         include.lowest = TRUE)) %>%
  group_by(dist_band) %>%
  summarise(
    n         = n(),
    precision = round(sum(correct_match, na.rm = TRUE) / n(), 3),
    .groups = "drop"
  )
```

---

## Notes on name types

### Congolese names (DRC context)
- Three-part naming: Nom (family) + Postnom + Prénom — order varies by data entry operator
- Common variation sources: French vs Lingala/Kikongo/Swahili spelling, vowel doubling (Mbaya/Mbaaya), truncation, prefix omission (Nk- vs K-)
- Nom de la mère is a strong fallback — independent of the patient's own name encoding errors
- Postnom completeness is often low (30–40%) — do not rely on it as a required variable
- Village names: many duplicates across provinces — always block on healthzone + healtharea + village

### Western names
- Two-part naming typical: family name + given name (+ optional middle)
- Common variation: hyphenated names, accents stripped (García → Garcia), nickname vs legal name
- Date of birth is a strong additional blocking/matching variable when available
- Soundex can supplement Jaro-Winkler for phonetic matching of anglophone names

---

## Scale considerations

| Dataset size | Recommended approach |
|---|---|
| Single village / small cohort (< 5,000) | Custom sequential JW in tidyverse (transparent, controllable) |
| Regional / national (> 100,000) | `fastLink` package — parallel computing, EM-estimated weights, no manual threshold calibration |

For `fastLink`, the labelled validation CSV from the small-scale approach serves as training data for threshold calibration.

Reference: ASTHO Record Linkage Using R (Parrish, 2024)
- Deterministic exact match: ~68% linkage rate
- Probabilistic with manual review: ~96% linkage rate

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.