library(data.table); library(rpart)
pairs <- as.data.table(read.csv(
np_fetch(np_source_urls()$url[np_source_urls()$key == "training_pairs_full"])))
# match_yn = 1 if this ein is the ground-truth match for the ueiHow to turn the labeled benchmark into a Stage-1 classifier that decides “is this candidate the right EIN?” — and, more usefully, how extending the training data with extra BMF/SAM fields and a few feature-engineering steps improves on the hand-tuned scoring weights. The lessons matter more than the model: gate degeneracy, model gradation, and let a small tree tell you which is which. All results below are from TRAINING-PAIRS-FULL (304,274 scored candidate pairs; 1,168 true matches).
1. The task, and the trap
The classifier scores candidate pairs, but the real job is per query: for each source org (uei), pick the one correct ein — or none. Two facts shape everything:
- The pairs are already blocked candidates. So evaluate query-level (did we pick the right EIN?), not per-pair.
- The label is 0.38% positive (1,168 / 304,274). At that base rate a model that always predicts “no match” is 99.6% accurate. Accuracy is a trap — lead with precision/recall and the query-level selection rate, and either down-sample negatives or class-weight when fitting.
2. Feature engineering
The raw similarities are the backbone; a handful of engineered features attack the specific failure modes of pure string similarity.
pairs[, name_exact := as.integer(name_sim >= 0.999)] # the jump at 1.0 is qualitative
pairs[, log_name_freq := log1p(name_freq)] # distinctiveness (rare name = trustworthy)
pairs[, subset_flag := as.integer(name_match_type == "token_overlap")] # one name inside the other
pairs[, group_member := as.integer(BMF_group_exemption_is_member %in% c("TRUE","1"))]
# officer / contact-name agreement (continuous, not 0/1)
pairs[, officer_sim := ifelse(nzchar(BMF_in_care_of) & nzchar(SAM_poc_last_name),
stringdist::stringsim(toupper(BMF_in_care_of), toupper(SAM_poc_last_name), "jw", p = .1), NA)]
# name-token conflicts (a "Fund II" vs "Fund III" is a strong negative)
conflict <- function(a, b) as.integer(nzchar(a) & nzchar(b) & a != b)
pairs[, `:=`(num_conflict = conflict(name_nums_x, name_nums_y),
ord_conflict = conflict(name_ord_x, name_ord_y),
dir_conflict = conflict(name_dir_x, name_dir_y))]Why these, and not others:
| Feature | Rationale |
|---|---|
name_sim and name_exact
|
the relationship is non-linear — an exact match is a different regime, and trees split on it cleanly |
log_name_freq |
distinctiveness: an exact match on a unique name is trustworthy; on “First Baptist Church” it is nearly worthless. Interact it with name_sim
|
geo_stnum, geo_zip5, street_sim
|
address agreement; geo_stnum (same street number) is the strongest single address signal |
subset_flag |
catches truncation/expansion that Jaro-Winkler misses (“Lake Stewards” ⊂ “Lake Stewards of Maine”) |
group_member |
federated chapters (Habitat, Rotary, VFW) share names → inherently ambiguous → the model should down-weight confidence |
officer_sim |
a matching director / point-of-contact name disambiguates small orgs with generic names |
We deliberately exclude score (the current model’s own output — including it just teaches “trust the old scorer”), pass, and is_top_candidate (the algo’s pick — direct leakage). They are baselines to beat, not inputs.
3. Vetoes vs. features — gate degeneracy, model gradation
Before modeling, ask of each categorical region: what does P(match) look like here? If it is a near-deterministic zero with a mechanism, it is a rule (filter it out — this de-noises training and cuts the imbalance). If it merely shifts the probability, it is a feature. The test is the upper confidence bound on the region’s match rate — not the point estimate — plus a mechanism and enough support:
Run over the candidate cells, this is what the benchmark shows:
| Cell | n | matches | rate | 95% upper | verdict |
|---|---|---|---|---|---|
SAM foreign_owned
|
3,643 | 0 | 0.00% | 0.11% | → veto (large, zero, mechanism) |
SAM for_profit
|
3 | 0 | 0.00% | 56% | ✗ not a veto — n too small to conclude |
| BMF group member | 38,577 | 63 | 0.16% | 0.21% | feature (down-weight) |
SAM struct 2L (not tax-exempt) |
17,747 | 31 | 0.18% | 0.25% | feature |
SAM nonprofit
|
264,032 | 1,135 | 0.43% | — | base rate |
name exact (sim=1) |
1,465 | 996 | 68% | 70% | dominant signal |
The lesson in one table. foreign_owned and for_profit have the same point estimate (0%), but only foreign_owned is a veto: n = 3,643 gives an upper bound of 0.1%, and an international org cannot have a domestic BMF record (the mechanism guarantees it generalizes). for_profit has n = 3 and an upper bound of 56% — you cannot conclude anything, and promoting it to a rule would be overfitting. This is why small pure cells must never become rules on the point estimate alone, and why the mechanism check also guards against filtering on a blocking artifact (these are already candidate pairs).
Promote only foreign_owned (and the existing hard vetoes) to the gate; leave group_member, structure, and business-type as features the model can grade.
4. A small, interpretable tree
Fit a shallow, pruned CART on a grouped, down-sampled training split (all positives + ~15× negatives; queries never split across folds):
ueis <- unique(pairs$uei); tr_uei <- sample(ueis, floor(.7 * length(ueis)))
tr <- pairs[uei %in% tr_uei]; te <- pairs[!uei %in% tr_uei]
pos <- tr[match_yn == 1]; neg <- tr[match_yn == 0][sample(.N, 15 * nrow(pos))]
fit <- rpart(match_yn ~ name_sim + name_exact + log_name_freq + geo_stnum + geo_zip5 +
geo_state + street_sim + city_sim + name_match_type + group_member +
subset_flag + officer_sim + num_conflict,
data = rbind(pos, neg), method = "class",
control = rpart.control(maxdepth = 4, cp = .004, minbucket = 40))
fit <- prune(fit, cp = fit$cptable[which.min(fit$cptable[, "xerror"]), "CP"])
print(fit)The pruned tree is three leaves:
name_sim < 0.94 ?
├─ yes → street_sim < 0.88 ?
│ ├─ yes → NO (0.2% match) the clean-negative leaf
│ └─ no → YES (77% match) fuzzy name rescued by a matching street
└─ no → YES (95% match)
A two-variable story with an interaction: a strong name is enough on its own; a weak name is rescued only when the street agrees (the address-anchored recovery); everything else is a non-match. Variable importance echoes it — name_sim (.29), name_exact (.26), street_sim (.17), geo_stnum (.16), geo_zip5 (.12) — roughly five features carry all the signal. This is the tree-as-search idea in action: it discovers where the effect of name_sim varies (its threshold differs by branch), i.e. it gives you varying slopes without saturating a factor table.
5. Does it work? Two lessons
Pick the highest-scoring candidate per query and ask whether it is the true match, against the hand-tuned hier score as the baseline (queries split by uei, foreign_owned gated out):
| Selector | ALL | RANDOM | HARD |
|---|---|---|---|
| Hand-tuned hier score | 0.934 | 0.961 | 0.877 |
| Small 3-leaf tree | 0.915 | 0.927 | 0.891 |
| Logistic regression (core features) | 0.945 | 0.961 | 0.912 |
Lesson 1 — trees explain; smooth models select. The three-leaf tree is a superb summary (two variables, one interaction, a clean veto) and lands close, but it ranks coarsely: a shallow tree assigns each candidate one of a few leaf probabilities, so many candidates in a query tie and the within-query argmax is arbitrary. That is why the tree sits below the continuous score. Use the tree for interpretation and veto discovery; use a smooth model for the actual selection.
Lesson 2 — a learned model beats the hand-tuned weights, most where it matters. A plain logistic regression on the same core features tops the hand-tuned score overall (0.945 vs 0.934) and by +3.5 points on the hard cases (0.912 vs 0.877). Learning the weights from the labeled data — rather than setting 0.6·name + 0.4·geo by hand — is the improvement, and it concentrates exactly on the difficult residual.
And the coefficients are legible — they are the story the tree told, quantified:
name_sim +21.0 geo_stnum +4.1 name_exact +3.5 geo_state +2.4 geo_zip5 +1.1
(city/street positive; large negative intercept)
The honest twist on feature engineering. Adding the engineered features (log_name_freq interaction, group_member, subset_flag, officer_sim, the conflict flags) to the logistic model did not move top-1 selection on this benchmark — the core name+geo signal already carries it. Their value shows up elsewhere, and it is real: their coefficients confirm every mechanism — num_conflict ≈ −13 (a “Fund II” vs “Fund III” all but rules the pair out), group_member ≈ −2 (federated chapters are genuinely weaker, your #10), name_sim:log_name_freq ≈ −2 (an exact match on a common name is worth less — the distinctiveness interaction) — and they sharpen precision on the ambiguous tail, not the headline metric. The lesson is the discipline you asked for: don’t pile on features reflexively. Learn the weights (that is the win), let a tree show you the few variables and where the slopes vary, gate the one mechanistic dead-zone (foreign_owned), and add engineered signals where a mechanism — not just a hoped-for lift — says they belong.
6. From classifier to pipeline
The gate-then-model split you just built is exactly the package’s architecture: the veto layer (np_veto) removes mechanistically-impossible pairs; the scorer (np_score) grades the rest; np_select / np_tier turn per-pair scores into a per-query YES / MAYBE / NO. A learned Stage-1 model drops straight into the scorer slot, and the MAYBE tier still routes the uncertain middle to the LLM review loop (np_as_prompts). Retrain it as the benchmark grows — the training pairs are a projection of the evaluation frame, so every improvement is reproducible.
Appendix — model menu
The main text uses one model on purpose. For accuracy or a second opinion:
| Model | R package | Note |
|---|---|---|
| Pruned CART |
rpart (+ rpart.plot) |
the interpretable centerpiece used above |
| C5.0 / cond-inference tree |
C50, partykit::ctree
|
ctree’s significance-tested splits won’t manufacture a spurious pure cell — good for veto mining |
| Rule learner (RIPPER) | RWeka::JRip |
targets “high-precision rule over a region” directly — the veto-mining objective as an algorithm |
| Random forest | ranger |
fast accuracy benchmark + variable importance |
| Gradient boosting | xgboost |
max accuracy; use scale_pos_weight for the imbalance |
| Logistic regression | base glm, npmatch::np_train
|
a parametric baseline; add varying slopes only on the few factor×continuous cells the tree flags |
| Optimal classification tree |
iai (commercial) / evtree (open) |
marginal gain over pruned CART here — not required |
Wrap any of these in tidymodels (recipes for the engineering, rsample for grouped CV by uei, yardstick for PR-AUC) to keep the comparison honest.