estimate¶
Goal: learn the model — m, u and λ — from the data itself, and write it to
model.json.
The concepts behind this command are on Estimation and EM; this page is the interface and how to read what it prints.
Synopsis¶
cpplink estimate --schema <schema.json> [--out <model.json>]
[--u-sample N] [--session-pairs N] [--threads N]
[--iterations N] [--lambda F] [--seed N] [--mode MODE]
[--interactions] [--max-interactions N] [--interaction-bits F]
<file.parquet>...
| Option | Default | Meaning |
|---|---|---|
--schema <file> |
— | required; must declare both comparisons and blocking |
--out <file> |
none | write the model as JSON. Without it the model is printed and discarded |
--u-sample N |
1,000,000 | uniformly random pairs drawn to estimate u for levels the closed form cannot reach |
--session-pairs N |
10,000,000 | per-session cap on pairs actually compared. Above it, the fold becomes a Bernoulli sample |
--threads N |
hardware | threads for the histogram fold |
--iterations N |
500 | EM iteration cap. Reaching it is a symptom, not a setting to raise: a session that will not converge is usually one whose blocking conditions on a column it is trying to estimate |
--lambda F |
derived | override the prior match rate with a count you trust |
--seed N |
20260903 | seed for u sampling and session subsampling |
--fuzzy-u |
off | compute u for the fuzzy levels exactly, by self-joining each column's dictionary, instead of sampling and rescaling |
--ball-budget N |
4e10 | value pairs the self-join may look at for one column |
--no-tie-holdout |
off | stop holding out comparisons tied to the column a session blocks on. Off means the hold-out is on; this is for measuring what it is worth |
--tied-bits F |
0.25 | u-side overlap in bits past which two columns count as tied |
--tie-sample-rows N |
500,000 | rows the tie pass reads. 0 reads every row |
--interactions |
off | fit two-way corrections for the comparison pairs that are not conditionally independent. See below |
--max-interactions N |
2 | how many terms may enter the model. Implies --interactions |
--interaction-bits F |
0.25 | bits a term must move an average matching pair by, in every session that can see it, to be admitted. Implies --interactions |
--min-interaction-sessions N |
2 | sessions that must be able to see a term before it is identified |
--min-pairs-per-parameter F |
0 | refuse a term with fewer matching pairs per free parameter than this. Reported either way |
--interaction-clamp F |
6.0 | the most any single cell of a term may move the weight, in bits |
| (positional) | — | required; the parquet file |
--mode dedup\|link\|link-and-dedup |
link with more than one file, else dedup | which pairs to enumerate; see linking |
--all-pairs |
off | estimate from every pair the mode admits, in one session that holds nothing out. Read the warning in no blocking at all first: without a hold-out, dependence between comparisons goes straight into m |
The two knobs that matter are --u-sample and --session-pairs. Raising --u-sample buys
precision on fuzzy-level u values only — exact and null levels are closed form and unaffected
by it. Raising --session-pairs buys very little: 10⁷ pairs already saturates the pattern
counts, and estimation needs counts, not pairs.
Example¶
u from 999,999 random pairs in 0.5 s, plus 14 levels in closed form from the term frequencies
Session Enumerated Compared Patterns Iters Match rate Seconds
-----------------------------------------------------------------------------------
email 90,600 90,600 242 3 0.999934 0.0
phone 121,195 121,195 274 2 0.870795 0.1
dob 77,931,552 10,002,206 270 4 0.001535 3.5
last_name 39,059,914 7,413,527 553 10 0.003205 3.2
-----------------------------------------------------------------------------------
Session dob
sources dob exact_value
held out dob
sampled 1.28e-01 of 77,931,552 enumerated pairs
em 4 iterations, last change 4.5e-11, converged
implies 119630 matching pairs among the 77,931,552 it reached
Session last_name
sources last_name rare_value, last_name sorted_neighbourhood
held out last_name
sampled 1.90e-01 of 39,059,914 enumerated pairs
em 10 iterations, last change 3.9e-07, converged
implies 125201 matching pairs among the 39,059,914 it reached
warning: lambda is a lower bound: it counts only the matches a session's blocking reached,
and blocking recall is below one. Pass --lambda to set it from a count you trust.
lambda 7.73e-08 (lower bound: session last_name implies 125,201 matches over 1,619,999,100,000 pairs)
prior weight -23.625 bits
Comparison Level m u weight note
--------------------------------------------------------------------------------------------
last_name null 1.00e-12 1.00e-12 0.00 unreachable: no pair can land here
exact 0.609559 2.38e-05 14.64 u exact
jaro_winkler >= 0.92 0.279753 6.00e-06 15.51 u from only 6 random pairs
jaro_winkler >= 0.85 0.078072 0.000209 8.55
else 0.032616 0.999761 -4.94
email null 0.335455 0.055215 2.60 u exact
exact 0.577950 5.96e-07 19.89 u exact
jaro_winkler >= 0.93 0.086592 5.00e-07 17.40 u below the sampling floor
else 3.46e-06 0.944784 -18.06 m at the floor: no matching pair reached this level
--------------------------------------------------------------------------------------------
Weight is log2(m/u): the bits of evidence agreeing at that level carries.
Wrote model.json
Reading the output¶
The u line¶
Two sources of truth, deliberately. 14 levels needed no sampling at all: every exact
level on a single interned column, and every leading null level, is computed exactly as
\(\sum_v c_v(c_v-1) \,/\, N(N-1)\) or as a null count. The sampled levels take what the exact
ones leave, so the two cannot disagree about the total. See
u in closed form, which explains why the numerator is
\(c_v(c_v-1)\) and not \(c_v^2\).
The session table¶
One row per column that an EM-safe blocking source conditions on. Each session holds that
column out and estimates every other comparison's m from it.
| Column | Meaning |
|---|---|
Enumerated |
candidate pairs the session's sources produced |
Compared |
pairs actually evaluated. Below Enumerated means Bernoulli sampling kicked in at --session-pairs |
Patterns |
distinct γ values in the histogram — the entire input to EM |
Iters |
EM iterations to max|Δ| < 1e-6 |
Match rate |
the session's own λ, i.e. the posterior match mass among its pairs |
Seconds |
wall time for enumeration, comparison and EM together |
Three things to notice:
Patternsis tiny. 242 to 553 distinct patterns is the whole reason EM is free here. The cost of EM ispatterns × comparisons × iterationsand has nothing to do with how many pairs were folded.Compared≪Enumeratedis fine and intended. Thedobsession sampled 12.8% of its 78M pairs and still converged in 4 iterations. Sincem,uandλare all ratios of pattern counts, a Bernoulli sample needs no reweighting.Match ratediffers wildly by session and that is expected. Theemailsession is 99.99% matches — agreeing on an exact email is nearly conclusive — whiledobis 0.15%. A session at ~1.0 gets a note saying itsmis close to a direct count over blocked pairs rather than a mixture split.
The per-session detail¶
Session last_name
sources last_name rare_value, last_name sorted_neighbourhood
held out last_name
sampled 1.90e-01 of 39,059,914 enumerated pairs
em 10 iterations, last change 3.9e-07, converged
implies 125201 matching pairs among the 39,059,914 it reached
held out is the identifiability mechanism: every comparison reading that column is excluded
from this session's EM, because blocking selected on it and its m is degenerate. implies
is what feeds λ.
also tied is the same discipline one step wider, and it is not optional:
Session first_name
sources first_name exact_value
held out first_name
also tied first_and_surname (blocking on this column conditions on theirs)
Blocking on a column conditions on everything that column decides, not only on the column
itself.
first_and_surname contains first_name, so among the pairs this session blocks on, two rows
named "john smith" and "john brown" clear jaro_winkler >= 0.80 on the strength of the shared
first token alone.
Their agreement rate on first_and_surname inside the session is nothing like the u the
model holds for it over the whole file, and EM has no way to read the difference as anything
but evidence that the pairs are matches.
Measured on historical_50k: the first_name session's match rate reads 0.9996 where the
truth is 0.0080, a factor of 125, and it reads 0.018 once first_and_surname is held out
too.
End to end that is worth 0.8536 to 0.8676 F1.
Ties are found by one pairwise pass over the rows, the same one profile runs,
which reads no candidate pair and no model; two columns count as tied when one occurs inside
the other on 90% of rows or their u-side overlap is worth --tied-bits, 0.25 by default.
--no-tie-holdout turns it off, which is how the two numbers above were measured against each
other.
A column tied to the blocked column in every session ends up with no estimate at all, and the
run says so with the held out of every session warning below.
That is the cost of the discipline and it is the right price: an m from a session that
conditioned on the answer is worse than no m.
Warnings that can appear here, and what each means:
| Warning | Meaning |
|---|---|
only N comparison(s) are free in this session |
Fewer than three free comparisons does not identify the mixture — a 2×2 table has two exact roots and EM returns whichever it walked to. The session is refused, not merged. Give the schema more comparisons that do not read that column |
every comparison reads this column |
The session can learn nothing and is skipped |
this session's m estimates were not merged into the model |
Follows any of the above, or the label-swap check firing |
comparison "x" was held out of every session |
Its m is a starting value, not an estimate. Add a blocking source on a different column, or break the tie that keeps holding it out |
λ and the prior weight¶
lambda 7.73e-08 (lower bound: session last_name implies 125,201 matches over 1,619,999,100,000 pairs)
prior weight -23.625 bits
λ is put back on the full-data pair count, never a session's own match rate, which
blocking biases upward by exactly the thing blocking is for. It is a lower bound twice
over — blocking recall is below one, and EM's posterior mass is itself conservative. prior
weight is \(\log_2(\lambda/(1-\lambda))\): the −23.6 bit hurdle every pair starts behind.
Pass --lambda if you have a count you trust; it is the single parameter most worth
overriding, since it shifts every score by a constant.
The parameter table¶
The model itself, one block per comparison, one row per level.
| Column | Meaning |
|---|---|
m |
\(P(\text{level} \mid \text{match})\) — how reliable the field is |
u |
\(P(\text{level} \mid \text{non-match})\) — how discriminating it is |
weight |
\(\log_2(m/u)\), the bits of evidence this level carries |
note |
provenance and floors, never hidden |
The note column is the part to read carefully. Four kinds appear:
| Note | Meaning |
|---|---|
u exact |
closed form from term frequencies. Trust it |
u from only N random pairs |
sampled, and thinly. The weight is real but noisy |
u below the sampling floor |
the sample never hit this level; u is half a sampled pair. These are the least trustworthy numbers in the model |
m at the floor: no matching pair reached this level |
no matching pair landed here; m is 0.5 / matching mass — half a pair, the resolution of the evidence |
unreachable: no pair can land here |
closed-form u is exactly zero, e.g. the null level of a column with no missing values. Weight is set to exactly 0 rather than floored |
In the run above, location within 1.00 km at 20.83 bits and address exact at 20.08 bits
both rest on u below the sampling floor. They are the weights to distrust, and raising
--u-sample is the way to firm them up — geo radii and list-set equality are precisely what
the closed form cannot reach.
Output¶
With --out, a model.json carrying per-level m, u, whether u was exact, how many
sampled pairs supported it, the EM support mass behind m, plus lambda, lambda_basis and
records. See Output formats.
Because the model is a small JSON file, it can be hand-edited: overriding a suspect u, or a
weight you have external knowledge about, needs no re-run.
Exact u for the fuzzy levels¶
u is closed form for a leading null level and for a single-column exact level, and sampled
for everything else, then rescaled to fill what the exact levels leave.
A sample of a million random pairs sees a fuzzy level that fires on one pair in 300,000 about
three times, which is not an estimate of anything.
--fuzzy-u computes it instead. u for a level is the chance two random records land on it,
which is a sum over pairs of values:
and the dictionary self-join enumerates exactly that. Measured on the 1M sample:
| Comparison | Level | Sampled u |
Exact u |
|---|---|---|---|
first_name |
levenshtein ≤ 1 | 1.320e-03 | 1.306e-03 |
first_name |
jaro_winkler ≥ 0.88 | 6.140e-04 | 6.072e-04 |
last_name |
jaro_winkler ≥ 0.85 | 1.810e-04 | 1.985e-04 |
last_name |
jaro_winkler ≥ 0.92 | 3.000e-06 | 6.389e-06 |
Where the sample has support it was already within 1%. Where it does not — the last row, which the sample hit about three times — it is wrong by a factor of 2.1, which is 1.1 bits on every pair that lands there.
The cost is quadratic in the number of distinct values: 11.2 billion value pairs for the 1M
sample's 149k surnames took 140 s on eight threads, and 16k first names took 0.6 s.
A near-unique column is refused by --ball-budget rather than approximated — 919k distinct
emails is 423 billion value pairs, and no signature filter makes that affordable.
Relaxing conditional independence¶
Fellegi–Sunter multiplies m and u across comparisons, which is correct only where agreement
is independent given the class.
On real data it is not: where one column contains another, the model counts one signal twice
and every matching pair is scored too high.
--interactions fits a small set of two-way corrections and writes them into the model.
The weight becomes
Both halves are needed. Two columns that agree together among matches are double-counted
only to the extent that they do not also agree together among non-matches, which is what the
main-effect u already prices.
Nothing about scoring changes: \(\delta\) is a function of γ like everything else, so below 22 bits of γ it is folded into the tabulated base weight once at bind time and the hot loop never sees it.
Where each half comes from¶
| Half | Source | Why not somewhere else |
|---|---|---|
| M | the session histograms, weighted by the responsibility EM already computed, averaged over the sessions that left both comparisons free | it is the fit that already exists; nothing new is enumerated |
| U | the uniformly random pairs u is drawn from |
never candidates. A source selecting on a column induces agreement correlation involving that column, so dependence measured on the candidate stream measures the blocking plan |
Both are then fitted to the model's own margins by iterative proportional fitting, which moves
the margins and leaves every odds ratio alone.
That is what makes the term a pure correction: adding it cannot shift m or u, so the main
effects keep carrying the margins and the interaction carries only the association.
Reading the report¶
Two-way interactions, ranked by what they move a match by. 0.0 s.
The u side had 2.71e-04 of its pairs subtracted as this file's own duplicates.
Comparison Comparison effect per match weakest dup u pairs/par G^2 p verdict
------------------------------------------------------------------------------------------------------------------------
first_name first_and_surname 4.26 -2.87 -2.86 31% 6164 264053.2 0.0e+00 fitted
surname first_and_surname 3.58 -3.14 -3.11 43% 6164 63821.2 0.0e+00 fitted
first_name surname 1.80 -1.63 -1.58 43% 6164 16386.6 0.0e+00 past --max-interactions
dob postcode_fake 0.61 -0.19 -0.04 26% 17742 102502.3 0.0e+00 one session moves a match by only -0.04 bits
first_and_surname dob 0.52 0.16 0.16 49% 6508 5722.7 0.0e+00 only 1 session can see it, so nothing can disagree
------------------------------------------------------------------------------------------------------------------------
2 of 28 candidate pairs entered the model; 4 cell(s) hit the clamp.
| Column | Meaning |
|---|---|
effect |
the mean absolute correction an average matching pair gets, in bits. This is what the ranking is on |
per match |
the same thing signed. Negative means the plain model was double-counting that many bits on every match |
weakest |
the least any one session says on its own, zero where two disagree about the sign. A term has to clear --interaction-bits here, not just on the average |
dup u |
how much of the u side was this file's own duplicates before they were subtracted. See below |
pairs/par |
matching pairs per free parameter of the term |
G^2, p |
the deviance against independence, printed as a diagnostic and used for nothing |
The two terms admitted here are exactly the two pairs
profile names from the rows alone — first_and_surname contains both name
columns — reached from the histograms with no notion of containment at all.
G^2 cannot choose the number of terms, for the same reason it could not in levels or simplify
Its power is the size of the run. All 28 candidate pairs here read p = 0 over
historical_50k's 18.4M candidate pairs. The effect size decides: --interaction-bits
sets the bar and
--max-interactions caps the count.
What it is worth¶
Measured end to end at the shipping defaults, against truth the estimator never sees:
| Dataset | Terms | Plain best F1 | Corrected best F1 |
|---|---|---|---|
historical_50k |
2 | 0.8676 at 12 bits | 0.9107 at 6 bits |
febrl3 |
0 | 0.9992 at −4 bits | 0.9992 at −4 bits |
fake_1000 |
2 | 0.9386 at −1 bits | 0.8724 at −3 bits |
On historical_50k precision and recall both rise (0.9259 → 0.9542, 0.8161 → 0.8709) and the
corrected model dominates the plain one at every recall on the frontier.
febrl3 fits nothing and the model is byte-identical, which is the right answer rather than a
null result: its corruption is applied field by field on independent coins, so there is no
dependence to find.
Thresholds move, so comparing at a fixed one compares nothing
Each admitted term takes two to three bits off every matching pair, so the whole weight
scale shifts down and the operating point moves with it — 12 bits becomes 6. Read at a
fixed 12 bits the correction on historical_50k looks like −0.10 F1. Compare
best-against-best, or compare the precision-recall frontiers.
The number of terms is capped at two because that is what measured best:
| Terms | 1 | 2 | 3 | 4 | 8 |
|---|---|---|---|---|---|
historical_50k best F1 |
0.8878 | 0.9135 | 0.9091 | 0.9035 | 0.8847 |
Past the real dependencies there is nothing left to correct and the extra terms fit noise.
The cost, which is the ceiling and not the scoring¶
A correction is not separable across comparisons, so the pair's own cell is unknown until its
levels are, and predict's pair-global ceiling has to add each term's largest
cell to stay admissible. It gets looser:
| Candidates skipped before any metric | predict |
|
|---|---|---|
| plain, 12 bits | 37.61% | 1.8 s |
| corrected, 6 bits | 4.06% | 2.2 s |
At the same threshold the skip rate falls 29.09% to 4.06%, so this is the loosening and not the lower operating point. The tabulated weight itself costs nothing at all.
predict --no-interactions scores the plain model out of the same file, which is how the two
were measured against each other.
Two traps, one of them the standing limit¶
The u side of a joint is the file's own duplicates
A uniformly random pair is a match with probability λ, so a random draw shows
\((1-\lambda)u + \lambda m\). For one column that is a small inflation. For the joint of
two high-cardinality columns it is the entire measurement: on febrl3 two independent
draws land on the date_of_birth × soc_sec_id exact-agreement cell 0.21 times per
million, and the 452 matching pairs in a million random draws land there 335 times — 1,565×
the independent rate, all of it duplicates.
The first working version therefore put a −4 bit correction on every one of that dataset's
28 pairs. λ·m is now subtracted back out, and where the subtraction leaves nothing the pair
is refused rather than guessed. That is the dup u column, and it silences febrl3
entirely.
The correction is only as good as λ, and λ is a lower bound
A session-derived λ counts only the matches blocking reached. On fake_1000 it reads
1.77e-3 against a true 4.07e-3, low by 2.3×, so the subtraction removes less than half the
duplicates it should and the residue pushes every correction negative. Given the true λ on
the command line, that file's two largest terms flip sign (−1.90, −1.93 → +1.26, +1.06)
and best F1 goes 0.8724 → 0.9225.
The report warns whenever λ is a bound. There are two ways out: pass --lambda from a count
you trust, or divide the reported one by the pair completeness
completeness estimates with no truth file.
Letting a correction run to its full size is worse, and the clamp is why
The arithmetic wants about −10 bits on the surname × first_and_surname exact-agreement
cell; --interaction-clamp holds it at −6. Raising the clamp does not help:
| clamp (bits) | 4 | 6 | 8 | 12 | 20 |
|---|---|---|---|---|---|
historical_50k best F1 |
0.9073 | 0.9107 | 0.9061 | 0.9042 | 0.9042 |
That cell is 43% duplicates before the subtraction, so its magnitude is the least reliable part of the fit even though its sign is certain. The clamp is a shrinkage on exactly the cells whose estimate is worst. The curve is shallow across the whole range, so the default is not load-bearing — but removing the clamp is a measurable loss.
Two guards are not optional. A cell the data barely reaches is a ratio of two floors — a column
duplicated under two names can never land on "one agrees and the other does not" — so each
correction is scaled by \(n/(n+5)\), the contingency table's own bar for an expected count; the
empty cell naming that impossible case was reading +3.7 bits before it. And a term must be
visible to two sessions, which is the interaction form of the sessions == 0 refusal
completeness already makes.
Cross-session agreement does not work as a guard, and it was the obvious thing to try
The hypothesis was that a spurious term would read differently in each session and a real
one would not. On fake_1000 the sessions agree to within 0.06 bits on corrections that
have the wrong sign. The bias is systematic, so nothing that looks for noise can find it.