jlink 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jlink/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """jlink: record linkage with match rules in plain English, judged by TypeSafe's Jev model.
2
+
3
+ import jlink
4
+ result = jlink.link(left, right, entity="firm", on=["name", "state"],
5
+ definition="A parent company and its subsidiary are different firms.")
6
+ result.links # the chosen pairs, with probabilities
7
+ print(result.methods()) # a paragraph for the data appendix
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from . import block # noqa: E402
13
+ from .audit import Evaluation, audit_sample, evaluate, score_against_truth # noqa: E402
14
+ from .judge import judge # noqa: E402
15
+ from .linker import Linker, Result, link, load # noqa: E402
16
+ from .resolve import resolve # noqa: E402
17
+
18
+ __all__ = ["Linker", "Result", "link", "load", "block", "judge", "resolve", "audit_sample", "evaluate",
19
+ "score_against_truth", "Evaluation", "__version__"]
jlink/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
jlink/audit.py ADDED
@@ -0,0 +1,297 @@
1
+ """Samples for hand labeling and weighted evidence about linkage quality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from .fields import check_columns, ids, parse_on
11
+ from .resolve import _columns, _number, _numbers, _pairs, _probabilities
12
+
13
+
14
+ def _integer(value: int, name: str, minimum: int) -> int:
15
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < minimum:
16
+ raise ValueError(f"{name} must be a whole number at least {minimum}")
17
+ return int(value)
18
+
19
+
20
+ def _allocation(sizes: np.ndarray, n: int) -> np.ndarray:
21
+ """Equal allocation, repeatedly redistributing places left by exhausted bins."""
22
+ counts = np.zeros(len(sizes), dtype=np.int64)
23
+ remaining = min(n, int(sizes.sum()))
24
+ while remaining:
25
+ available = np.flatnonzero(counts < sizes)
26
+ share, remainder = divmod(remaining, len(available))
27
+ extra = np.full(len(available), share, dtype=np.int64)
28
+ extra[:remainder] += 1
29
+ extra = np.minimum(extra, sizes[available] - counts[available])
30
+ counts[available] += extra
31
+ remaining -= int(extra.sum())
32
+ return counts
33
+
34
+
35
+ def _fields(sample: pd.DataFrame, left: pd.DataFrame, right: pd.DataFrame, on,
36
+ left_id: str | None, right_id: str | None) -> list[str]:
37
+ fields = parse_on(on)
38
+ labels = [label for label, _, _ in fields]
39
+ if len(set(labels)) != len(labels):
40
+ raise ValueError("on must give each field a different left column name")
41
+ positions = []
42
+ for frame, column, side in ((left, left_id, "left"), (right, right_id, "right")):
43
+ _columns(frame, [], f"the {side} data")
44
+ values = ids(frame, column, side)
45
+ if values.isna().any():
46
+ raise ValueError(f"the {side} data has missing IDs; supply an ID for every record")
47
+ indexer = values.get_indexer(sample[f"{side}_id"])
48
+ if (indexer < 0).any():
49
+ raise ValueError(f"sample column '{side}_id' contains IDs absent from the {side} data")
50
+ positions.append(indexer)
51
+ columns = []
52
+ for label, a, b in fields:
53
+ check_columns(left, [a], "left")
54
+ check_columns(right, [b], "right")
55
+ sample[f"a_{label}"] = left[a].iloc[positions[0]].reset_index(drop=True)
56
+ sample[f"b_{label}"] = right[b].iloc[positions[1]].reset_index(drop=True)
57
+ columns.extend([f"a_{label}", f"b_{label}"])
58
+ return columns
59
+
60
+
61
+ def audit_sample(scores: pd.DataFrame, *, n: int = 200,
62
+ bins: tuple[float, ...] = (0, 0.05, 0.2, 0.5, 0.8, 0.95, 1.0), seed: int = 0,
63
+ left: pd.DataFrame | None = None, right: pd.DataFrame | None = None, on=None,
64
+ left_id: str | None = None, right_id: str | None = None) -> pd.DataFrame:
65
+ """Draw an equally allocated stratified sample, with inverse inclusion weights.
66
+
67
+ Nonempty bin categories are retained even when n is too small to sample every bin.
68
+ Saving to CSV loses unused categories; label every sampled bin before evaluation.
69
+ """
70
+ n = _integer(n, "n", 0)
71
+ seed = _integer(seed, "seed", 0)
72
+ _pairs(scores, "scores")
73
+ p = _probabilities(scores)
74
+ try:
75
+ edges = np.asarray(bins, dtype=float)
76
+ except (TypeError, ValueError) as exc:
77
+ raise ValueError("bins must be increasing numeric boundaries from 0 to 1") from exc
78
+ if (edges.ndim != 1 or len(edges) < 2 or not np.isfinite(edges).all()
79
+ or edges[0] != 0 or edges[-1] != 1 or (np.diff(edges) <= 0).any()):
80
+ raise ValueError("bins must be strictly increasing boundaries starting at 0 and ending at 1")
81
+ supplied = (left is not None, right is not None, on is not None)
82
+ if any(supplied) and not all(supplied):
83
+ raise ValueError("provide left, right and on together to include fields for labeling")
84
+ frame = scores.loc[p.notna(), ["left_id", "right_id"]].copy().reset_index(drop=True)
85
+ frame["p"] = p.loc[p.notna()].to_numpy()
86
+ labels = [f"{'[' if i == 0 else '('}{low:g}, {high:g}]"
87
+ for i, (low, high) in enumerate(zip(edges[:-1], edges[1:]))]
88
+ if len(set(labels)) != len(labels):
89
+ # Preserve distinct labels even for boundaries that differ past six significant digits.
90
+ labels = [f"{'[' if i == 0 else '('}{low!r}, {high!r}]"
91
+ for i, (low, high) in enumerate(zip(edges[:-1].tolist(), edges[1:].tolist()))]
92
+ frame["bin"] = pd.cut(frame["p"], edges, labels=labels, right=True, include_lowest=True)
93
+ frame["bin"] = frame["bin"].cat.remove_unused_categories()
94
+ groups = frame.groupby("bin", observed=True, sort=True).indices
95
+ counts = _allocation(np.asarray([len(group) for group in groups.values()]), n)
96
+ rng = np.random.default_rng(seed)
97
+ samples = []
98
+ for group, count in zip(groups.values(), counts):
99
+ if count:
100
+ part = frame.iloc[rng.choice(group, size=count, replace=False)].copy()
101
+ part["weight"] = len(group) / int(count)
102
+ samples.append(part)
103
+ if samples:
104
+ sample = pd.concat(samples, ignore_index=True)
105
+ sample = sample.iloc[rng.permutation(len(sample))].reset_index(drop=True)
106
+ else:
107
+ sample = frame.iloc[:0].copy()
108
+ sample["weight"] = pd.Series(dtype=float)
109
+ sample["is_match"] = pd.Series(pd.NA, index=sample.index, dtype="object")
110
+ fields = _fields(sample, left, right, on, left_id, right_id) if all(supplied) else []
111
+ return sample[["is_match", *fields, "p", "bin", "weight", "left_id", "right_id"]]
112
+
113
+
114
+ def _labels(values: pd.Series) -> pd.Series:
115
+ text = values.astype("string").str.strip().str.casefold()
116
+ mapping = {"1": 1.0, "1.0": 1.0, "true": 1.0, "y": 1.0, "yes": 1.0,
117
+ "0": 0.0, "0.0": 0.0, "false": 0.0, "n": 0.0, "no": 0.0}
118
+ blank = text.isna() | text.eq("").fillna(False)
119
+ invalid = ~blank & ~text.isin(mapping)
120
+ if invalid.any():
121
+ bad = values.loc[invalid].iloc[0]
122
+ raise ValueError(f"column 'is_match' has {bad!r}; use 1/0, True/False, y/n, yes/no, or blank")
123
+ return text.map(mapping).astype(float)
124
+
125
+
126
+ def _metrics(totals: np.ndarray) -> np.ndarray:
127
+ tp, linked, true = np.moveaxis(totals, -1, 0)
128
+ numerator = np.stack([tp, tp, 2 * tp], axis=-1)
129
+ denominator = np.stack([linked, true, linked + true], axis=-1)
130
+ return np.divide(numerator, denominator, out=np.full_like(numerator, np.nan), where=denominator > 0)
131
+
132
+
133
+ def _bootstrap(contributions: np.ndarray, groups: dict, n_boot: int, seed: int) -> np.ndarray:
134
+ rng = np.random.default_rng(seed)
135
+ totals = np.zeros((n_boot, 3))
136
+ for group in groups.values():
137
+ size = len(group)
138
+ if not size:
139
+ continue
140
+ # Bound the temporary draws for large audits, while vectorizing over replicates.
141
+ batch = max(1, 250_000 // size)
142
+ values = contributions[group]
143
+ for start in range(0, n_boot, batch):
144
+ stop = min(n_boot, start + batch)
145
+ draws = rng.integers(size, size=(stop - start, size))
146
+ totals[start:stop] += values[draws].sum(axis=1)
147
+ return _metrics(totals)
148
+
149
+
150
+ def _format(value: float) -> str:
151
+ return "NaN" if np.isnan(value) else f"{value:.4f}"
152
+
153
+
154
+ @dataclass
155
+ class Evaluation:
156
+ """Weighted audit estimates and percentile intervals from a stratified bootstrap."""
157
+
158
+ precision: tuple[float, float, float]
159
+ recall: tuple[float, float, float]
160
+ f1: tuple[float, float, float]
161
+ brier: float
162
+ calibration: pd.DataFrame
163
+ n_labeled: int
164
+ n_unlabeled: int
165
+ threshold: float
166
+ _notes: tuple[str, ...] = field(default=(), repr=False)
167
+
168
+ def summary(self) -> str:
169
+ """Describe the estimand and explain unavailable estimates."""
170
+ parts = [f"{self.n_labeled:,} labeled pairs; {self.n_unlabeled:,} blank labels dropped; "
171
+ f"threshold {self.threshold:g}."]
172
+ for name in ("precision", "recall", "f1"):
173
+ estimate, low, high = getattr(self, name)
174
+ label = "F1" if name == "f1" else name.capitalize()
175
+ parts.append(f"{label} {_format(estimate)} (95% CI {_format(low)} to {_format(high)}).")
176
+ parts.append(f"Weighted Brier score {_format(self.brier)}.")
177
+ parts.append("Estimates use sampling weights; 95% intervals use a bootstrap within bins.")
178
+ parts.append("Recall is among candidate pairs and cannot see true matches lost in blocking.")
179
+ parts.extend(self._notes)
180
+ return " ".join(parts)
181
+
182
+ def to_markdown(self) -> str:
183
+ """Return appendix tables without requiring pandas' optional tabulate dependency."""
184
+ lines = ["| Metric | Estimate | 95% interval |", "|:--|--:|:--|"]
185
+ for name, label in (("precision", "Precision"), ("recall", "Candidate-pair recall"), ("f1", "F1")):
186
+ estimate, low, high = getattr(self, name)
187
+ lines.append(f"| {label} | {_format(estimate)} | {_format(low)} to {_format(high)} |")
188
+ lines.append(f"| Weighted Brier score | {_format(self.brier)} | — |")
189
+ lines.extend(["", "| Probability bin | Labeled pairs | Weighted mean p | Weighted match rate |",
190
+ "|:--|--:|--:|--:|"])
191
+ for row in self.calibration.itertuples(index=False):
192
+ label = str(row.bin).replace("|", "\\|").replace("\n", " ").replace("\r", " ")
193
+ lines.append(f"| {label} | {row.n:,} | {_format(row.mean_p)} | {_format(row.match_rate)} |")
194
+ lines.extend(["", self.summary()])
195
+ return "\n".join(lines)
196
+
197
+
198
+ def evaluate(labeled: pd.DataFrame, *, threshold: float = 0.5,
199
+ n_boot: int = 2000, seed: int = 0) -> Evaluation:
200
+ """Estimate linkage accuracy using inverse inclusion weights and within-bin resampling.
201
+
202
+ Entirely unlabeled strata make population-wide estimates unavailable. Partial labeling
203
+ uses the supplied weights for the available judgments; it assumes missing labels do not
204
+ introduce selection bias. F1 is 2 TP / (predicted links + true matches).
205
+ """
206
+ threshold = _number(threshold, "threshold", probability=True)
207
+ n_boot = _integer(n_boot, "n_boot", 1)
208
+ seed = _integer(seed, "seed", 0)
209
+ _columns(labeled, ["p", "bin", "weight", "is_match"], "labeled data")
210
+ frame = labeled.reset_index(drop=True).copy()
211
+ y = _labels(frame["is_match"])
212
+ keep = y.notna()
213
+ n_labeled, n_unlabeled = int(keep.sum()), int((~keep).sum())
214
+ if frame["bin"].isna().any():
215
+ raise ValueError("column 'bin' must identify a sampling stratum for every pair")
216
+ strata = (list(frame["bin"].cat.categories) if isinstance(frame["bin"].dtype, pd.CategoricalDtype)
217
+ else list(pd.unique(frame["bin"])))
218
+ judged = frame.loc[keep].reset_index(drop=True)
219
+ p = _probabilities(judged, missing=False).to_numpy()
220
+ weights = _numbers(judged, "weight").to_numpy()
221
+ if (weights <= 0).any():
222
+ raise ValueError("column 'weight' must contain positive sampling weights")
223
+ if n_labeled:
224
+ # Ratios are invariant to a common scale; avoid overflow with large population weights.
225
+ weights = weights / weights.max()
226
+ truth = y.loc[keep].to_numpy()
227
+ predicted = p >= threshold
228
+ contributions = weights[:, None] * np.column_stack([predicted * truth, predicted, truth])
229
+ totals = contributions.sum(axis=0)
230
+ groups = judged.groupby("bin", observed=True, sort=False).indices
231
+ calibration = []
232
+ empty = []
233
+ for stratum in strata:
234
+ group = groups.get(stratum, np.array([], dtype=int))
235
+ if len(group):
236
+ mean_p = float(np.average(p[group], weights=weights[group]))
237
+ rate = float(np.average(truth[group], weights=weights[group]))
238
+ else:
239
+ mean_p = rate = float("nan")
240
+ empty.append(stratum)
241
+ calibration.append((stratum, len(group), mean_p, rate))
242
+ table = pd.DataFrame(calibration, columns=["bin", "n", "mean_p", "match_rate"])
243
+ notes = []
244
+ estimates = _metrics(totals)
245
+ intervals = np.full((3, 2), np.nan)
246
+ brier = float(np.average((p - truth) ** 2, weights=weights)) if n_labeled else float("nan")
247
+ if not n_labeled:
248
+ notes.append("No labeled pairs: all estimates and intervals are NaN.")
249
+ if empty:
250
+ names = ", ".join(str(value) for value in empty)
251
+ notes.append(f"No labels in bin(s) {names}: population-wide estimates and intervals are NaN.")
252
+ if not n_labeled or empty:
253
+ estimates[:] = np.nan
254
+ brier = float("nan")
255
+ else:
256
+ if totals[1] == 0:
257
+ notes.append("No predicted links at this threshold: precision and its interval are NaN.")
258
+ if totals[2] == 0:
259
+ notes.append("No true matches among labeled pairs: recall and its interval are NaN.")
260
+ if totals[1] + totals[2] == 0:
261
+ notes.append("No predicted links or true matches: F1 and its interval are NaN.")
262
+ boot = _bootstrap(contributions, groups, n_boot, seed)
263
+ for index, name in enumerate(("precision", "recall", "F1")):
264
+ finite = np.isfinite(boot[:, index])
265
+ if np.isfinite(estimates[index]) and finite.any():
266
+ intervals[index] = np.quantile(boot[finite, index], [0.025, 0.975])
267
+ if not finite.all():
268
+ notes.append(f"{n_boot - int(finite.sum()):,} bootstrap replicates with undefined {name} "
269
+ "were excluded from that interval.")
270
+ if any(len(group) == 1 for group in groups.values()):
271
+ notes.append("A bin has only one label; its within-bin uncertainty cannot be estimated "
272
+ "by resampling. Label more pairs for reliable intervals.")
273
+ if n_unlabeled and n_labeled:
274
+ notes.append("Available labels retain their sampling weights; "
275
+ "selective missing labels can bias estimates.")
276
+ metrics = [tuple(float(value) for value in (estimate, *interval))
277
+ for estimate, interval in zip(estimates, intervals)]
278
+ return Evaluation(*metrics, brier, table, n_labeled, n_unlabeled, threshold, tuple(notes))
279
+
280
+
281
+ def score_against_truth(links: pd.DataFrame, truth: pd.DataFrame,
282
+ candidates: pd.DataFrame | None = None) -> dict:
283
+ """Compare unique linked pairs with known truth; optionally measure blocking recall."""
284
+ _pairs(links, "links")
285
+ _pairs(truth, "truth")
286
+ index = pd.MultiIndex.from_frame(truth[["left_id", "right_id"]])
287
+ linked = pd.MultiIndex.from_frame(links[["left_id", "right_id"]])
288
+ tp = int(linked.isin(index).sum())
289
+ fp, fn = len(links) - tp, len(truth) - tp
290
+ precision, recall, f1 = _metrics(np.array([tp, len(links), len(truth)], dtype=float))
291
+ result = {"precision": float(precision), "recall": float(recall), "f1": float(f1),
292
+ "tp": tp, "fp": fp, "fn": fn}
293
+ if candidates is not None:
294
+ _pairs(candidates, "candidates")
295
+ proposed = pd.MultiIndex.from_frame(candidates[["left_id", "right_id"]])
296
+ result["pairs_completeness"] = float(index.isin(proposed).mean()) if len(index) else float("nan")
297
+ return result
jlink/block.py ADDED
@@ -0,0 +1,302 @@
1
+ """Cheap, deterministic candidate passes before pairs are sent to the judge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from numbers import Integral, Real
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from scipy.sparse import csr_matrix
11
+ from sklearn.feature_extraction.text import TfidfVectorizer
12
+
13
+ from .fields import check_columns, ids, normalize, parse_on, record_text
14
+
15
+ # Bound even a fully populated sparse product to about 64 MiB (float64 + int32).
16
+ _CHUNK_ROWS = 256
17
+ _PRODUCT_BYTES = 64 * 1024 * 1024
18
+ _PAIR_CHUNK_ROWS = 8192
19
+ _SIM_NGRAMS = (2, 4)
20
+ _IGNORED = frozenset({
21
+ "and", "of", "the", "for", "inc", "corp", "co", "ltd", "llc", "plc",
22
+ "company", "corporation", "incorporated", "limited",
23
+ })
24
+
25
+
26
+ class Blocker:
27
+ """A named pass returning pairs of row positions, independent of source IDs."""
28
+
29
+ name: str
30
+
31
+ def pairs(self, left: pd.DataFrame, right: pd.DataFrame) -> np.ndarray:
32
+ """Return an int64 array of shape (number of pairs, 2)."""
33
+ raise NotImplementedError("implement pairs(left, right) for this blocking pass")
34
+
35
+
36
+ def _positive_int(value: object, label: str) -> None:
37
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral) or value < 1:
38
+ raise ValueError(f"`{label}` must be a positive integer")
39
+
40
+
41
+ def _pass_fields(columns: tuple, name: str | None, kind: str) -> tuple[list, str]:
42
+ fields = parse_on(columns)
43
+ if name is None:
44
+ names = [a if a == b else f"{a}={b}" for _, a, b in fields]
45
+ name = f"{kind}:{','.join(names)}"
46
+ if not isinstance(name, str) or not name.strip():
47
+ raise ValueError("a blocker's `name` must be a nonempty string")
48
+ return fields, name
49
+
50
+
51
+ def _columns(left: pd.DataFrame, right: pd.DataFrame, fields: list) -> tuple[list[str], list[str]]:
52
+ for side, frame in (("left", left), ("right", right)):
53
+ if not isinstance(frame, pd.DataFrame):
54
+ raise ValueError(f"the {side} data must be a pandas DataFrame")
55
+ a, b = [f[1] for f in fields], [f[2] for f in fields]
56
+ check_columns(left, a, "left")
57
+ check_columns(right, b, "right")
58
+ return a, b
59
+
60
+
61
+ def _empty_pairs() -> np.ndarray:
62
+ return np.empty((0, 2), dtype=np.int64)
63
+
64
+
65
+ def exact(*columns: str | tuple[str, str], name: str | None = None) -> Blocker:
66
+ """Pair records equal in every listed field; incomplete keys never pair."""
67
+ fields, name = _pass_fields(columns, name, "exact")
68
+ return _Exact(fields, name)
69
+
70
+
71
+ @dataclass
72
+ class _Exact(Blocker):
73
+ fields: list
74
+ name: str
75
+
76
+ def pairs(self, left: pd.DataFrame, right: pd.DataFrame) -> np.ndarray:
77
+ a, b = _columns(left, right, self.fields)
78
+ groups = {}
79
+ for j, key in enumerate(zip(*(right[c].map(normalize) for c in b))):
80
+ if all(key):
81
+ groups.setdefault(key, []).append(j)
82
+ pairs = []
83
+ for i, key in enumerate(zip(*(left[c].map(normalize) for c in a))):
84
+ if all(key):
85
+ pairs.extend((i, j) for j in groups.get(key, ()))
86
+ return np.asarray(pairs, dtype=np.int64).reshape(-1, 2)
87
+
88
+
89
+ def ngrams(*columns: str | tuple[str, str], k: int = 10, n: tuple[int, int] = (2, 4),
90
+ min_sim: float = 0.1, name: str | None = None) -> Blocker:
91
+ """Keep up to k cosine neighbors per left row; ties use right row position."""
92
+ fields, name = _pass_fields(columns, name, "ngrams")
93
+ _positive_int(k, "k")
94
+ if not isinstance(n, tuple) or len(n) != 2:
95
+ raise ValueError("`n` must be a (minimum, maximum) tuple of positive n-gram lengths")
96
+ for value in n:
97
+ _positive_int(value, "n")
98
+ if n[0] > n[1]:
99
+ raise ValueError("`n` must have its minimum n-gram length before its maximum")
100
+ if (isinstance(min_sim, (bool, np.bool_)) or not isinstance(min_sim, Real)
101
+ or not np.isfinite(min_sim) or not 0 <= min_sim <= 1):
102
+ raise ValueError("`min_sim` must be a finite number between 0 and 1")
103
+ return _Ngrams(fields, name, int(k), n, float(min_sim))
104
+
105
+
106
+ def _vectors(left: pd.DataFrame, right: pd.DataFrame, a: list[str], b: list[str],
107
+ n: tuple[int, int]) -> tuple[csr_matrix, csr_matrix]:
108
+ text = pd.concat([record_text(left, a), record_text(right, b)], ignore_index=True)
109
+ vectorizer = TfidfVectorizer(analyzer="char_wb", ngram_range=n, norm="l2", dtype=np.float64)
110
+ try:
111
+ matrix = vectorizer.fit_transform(text).tocsr()
112
+ except ValueError as error:
113
+ if "empty vocabulary" not in str(error):
114
+ raise
115
+ matrix = csr_matrix((len(text), 0), dtype=np.float64)
116
+ # A fixed feature traversal keeps dot-product rounding independent of row chunks.
117
+ matrix.sort_indices()
118
+ return matrix[:len(left)], matrix[len(left):]
119
+
120
+
121
+ def _top_k(positions: np.ndarray, scores: np.ndarray, k: int) -> np.ndarray:
122
+ """Partition by score, resolving the entire boundary tie by position."""
123
+ if len(scores) > k:
124
+ cutoff = np.partition(scores, len(scores) - k)[len(scores) - k]
125
+ better = np.flatnonzero(scores > cutoff)
126
+ tied = np.flatnonzero(scores == cutoff)
127
+ remaining = k - len(better)
128
+ if len(tied) > remaining:
129
+ tied = tied[np.argpartition(positions[tied], remaining - 1)[:remaining]]
130
+ keep = np.concatenate((better, tied))
131
+ positions, scores = positions[keep], scores[keep]
132
+ return positions[np.lexsort((positions, -scores))]
133
+
134
+
135
+ def _clip_cosines(scores: np.ndarray) -> np.ndarray:
136
+ np.clip(scores, 0, 1, out=scores)
137
+ # Normalized identical vectors can sum to just below one in floating point.
138
+ scores[np.abs(scores - 1) <= 1e-14] = 1
139
+ return scores
140
+
141
+
142
+ @dataclass
143
+ class _Ngrams(Blocker):
144
+ fields: list
145
+ name: str
146
+ k: int
147
+ n: tuple[int, int]
148
+ min_sim: float
149
+
150
+ def pairs(self, left: pd.DataFrame, right: pd.DataFrame) -> np.ndarray:
151
+ a, b = _columns(left, right, self.fields)
152
+ if left.empty or right.empty:
153
+ return _empty_pairs()
154
+ x, y = _vectors(left, right, a, b, self.n)
155
+ k = min(self.k, len(right))
156
+ if not x.nnz or not y.nnz:
157
+ if self.min_sim > 0:
158
+ return _empty_pairs()
159
+ return np.column_stack((np.repeat(np.arange(len(left), dtype=np.int64), k),
160
+ np.tile(np.arange(k, dtype=np.int64), len(left))))
161
+ transpose = y.T.tocsr()
162
+ rows = max(1, min(_CHUNK_ROWS, _PRODUCT_BYTES // (12 * len(right))))
163
+ output = np.empty((len(left) * k, 2), dtype=np.int64)
164
+ count = 0
165
+ for start in range(0, len(left), rows):
166
+ product = (x[start:start + rows] @ transpose).tocsr()
167
+ _clip_cosines(product.data)
168
+ for local in range(product.shape[0]):
169
+ lo, hi = product.indptr[local:local + 2]
170
+ positions, scores = product.indices[lo:hi], product.data[lo:hi]
171
+ keep = (scores >= self.min_sim) & (scores > 0)
172
+ positions, scores = positions[keep], scores[keep]
173
+ chosen = _top_k(positions, scores, k)
174
+ if self.min_sim == 0 and len(chosen) < k:
175
+ # Implicit sparse zeros qualify too, including for missing text.
176
+ first = np.arange(k, dtype=np.int64)
177
+ zeros = first[~np.isin(first, chosen)][:k - len(chosen)]
178
+ chosen = np.concatenate((chosen, zeros))
179
+ stop = count + len(chosen)
180
+ output[count:stop, 0] = start + local
181
+ output[count:stop, 1] = chosen
182
+ count = stop
183
+ return output[:count].copy()
184
+
185
+
186
+ def initials(column: str | tuple[str, str], min_len: int = 2, name: str | None = None) -> Blocker:
187
+ """Pair an acronym with a token expansion in either direction, omitting filler words."""
188
+ fields, name = _pass_fields((column,), name, "initials")
189
+ _positive_int(min_len, "min_len")
190
+ return _Initials(fields, name, int(min_len))
191
+
192
+
193
+ def _acronym_keys(text: str, min_len: int) -> tuple[str, str]:
194
+ acronym = text if text.isalpha() and len(text) >= min_len else ""
195
+ expanded = "".join(token[0] for token in text.split() if token not in _IGNORED)
196
+ if len(expanded) < min_len:
197
+ expanded = ""
198
+ return acronym, expanded
199
+
200
+
201
+ @dataclass
202
+ class _Initials(Blocker):
203
+ fields: list
204
+ name: str
205
+ min_len: int
206
+
207
+ def pairs(self, left: pd.DataFrame, right: pd.DataFrame) -> np.ndarray:
208
+ a, b = _columns(left, right, self.fields)
209
+ acronyms, expansions = {}, {}
210
+ for j, text in enumerate(right[b[0]].map(normalize)):
211
+ acronym, expanded = _acronym_keys(text, self.min_len)
212
+ if acronym:
213
+ acronyms.setdefault(acronym, []).append(j)
214
+ if expanded:
215
+ expansions.setdefault(expanded, []).append(j)
216
+ pairs = []
217
+ for i, text in enumerate(left[a[0]].map(normalize)):
218
+ acronym, expanded = _acronym_keys(text, self.min_len)
219
+ matches = set(expansions.get(acronym, ())) | set(acronyms.get(expanded, ()))
220
+ pairs.extend((i, j) for j in sorted(matches))
221
+ return np.asarray(pairs, dtype=np.int64).reshape(-1, 2)
222
+
223
+
224
+ def _checked_pairs(blocker: Blocker, left: pd.DataFrame, right: pd.DataFrame) -> np.ndarray:
225
+ pairs = blocker.pairs(left, right)
226
+ if (not isinstance(pairs, np.ndarray) or pairs.ndim != 2 or pairs.shape[1] != 2
227
+ or not np.issubdtype(pairs.dtype, np.integer)):
228
+ raise ValueError(f"blocker {blocker.name!r} must return an integer array "
229
+ "with shape (number of pairs, 2)")
230
+ if pairs.size and (np.any(pairs < 0) or np.any(pairs[:, 0] >= len(left))
231
+ or np.any(pairs[:, 1] >= len(right))):
232
+ raise ValueError(f"blocker {blocker.name!r} returned a row position outside the left or right data")
233
+ return pairs
234
+
235
+
236
+ def _pair_similarities(left: pd.DataFrame, right: pd.DataFrame, a: list[str], b: list[str],
237
+ pairs: np.ndarray) -> np.ndarray:
238
+ x, y = _vectors(left, right, a, b, _SIM_NGRAMS)
239
+ similarities = np.empty(len(pairs), dtype=np.float64)
240
+ for start in range(0, len(pairs), _PAIR_CHUNK_ROWS):
241
+ chunk = pairs[start:start + _PAIR_CHUNK_ROWS]
242
+ similarities[start:start + len(chunk)] = np.asarray(
243
+ x[chunk[:, 0]].multiply(y[chunk[:, 1]]).sum(axis=1)
244
+ ).ravel()
245
+ return _clip_cosines(similarities)
246
+
247
+
248
+ def candidates(left: pd.DataFrame, right: pd.DataFrame, *, on: str | list[str | tuple[str, str]],
249
+ blockers: list[Blocker] | None = None, left_id: str | None = None,
250
+ right_id: str | None = None, max_pairs: int | None = 5_000_000) -> pd.DataFrame:
251
+ """Union named passes and score every pair on all `on` fields using 2–4 character grams."""
252
+ try:
253
+ fields = parse_on(on)
254
+ except TypeError as error:
255
+ raise ValueError("`on` must list column names or (left, right) pairs of column names") from error
256
+ a, b = _columns(left, right, fields)
257
+ left_ids, right_ids = ids(left, left_id, "left"), ids(right, right_id, "right")
258
+ if max_pairs is not None and (isinstance(max_pairs, (bool, np.bool_))
259
+ or not isinstance(max_pairs, Integral) or max_pairs < 0):
260
+ raise ValueError("`max_pairs` must be a nonnegative integer, or None for no limit")
261
+ if blockers is None:
262
+ blockers = [ngrams(*[(a, b) for _, a, b in fields], k=10)]
263
+ if not isinstance(blockers, list) or any(not isinstance(p, Blocker) for p in blockers):
264
+ raise ValueError("`blockers` must be a list of Blocker passes, or None for the default n-gram pass")
265
+ union = {}
266
+ for pass_number, blocker in enumerate(blockers):
267
+ if not isinstance(getattr(blocker, "name", None), str) or not blocker.name.strip():
268
+ raise ValueError("each blocker must have a nonempty string `name`")
269
+ bit = 1 << pass_number
270
+ for i, j in _checked_pairs(blocker, left, right):
271
+ key = (int(i), int(j))
272
+ union[key] = union.get(key, 0) | bit
273
+ if max_pairs is not None and len(union) > max_pairs:
274
+ raise ValueError(f"blocking produced {len(union):,} pairs, exceeding max_pairs={max_pairs:,}; "
275
+ "use a smaller `k` or a more selective `exact` pass")
276
+ pairs = np.asarray(list(union), dtype=np.int64).reshape(-1, 2)
277
+ labels = {mask: "+".join(p.name for i, p in enumerate(blockers) if mask & (1 << i))
278
+ for mask in set(union.values())}
279
+ blocks = [labels[mask] for mask in union.values()]
280
+ del union
281
+ sim = _pair_similarities(left, right, a, b, pairs) if len(pairs) else np.empty(0, dtype=float)
282
+ order = np.lexsort((pairs[:, 1], -sim, pairs[:, 0]))
283
+ return pd.DataFrame({
284
+ "left_id": left_ids.take(pairs[:, 0]),
285
+ "right_id": right_ids.take(pairs[:, 1]),
286
+ "block": pd.Series(blocks, dtype=object),
287
+ "sim": sim,
288
+ }).iloc[order].reset_index(drop=True)
289
+
290
+
291
+ def pairs_completeness(candidates: pd.DataFrame, truth: pd.DataFrame) -> float:
292
+ """Share of distinct truth pairs proposed; NaN when there are no known truth pairs."""
293
+ columns = ["left_id", "right_id"]
294
+ for label, frame in (("candidates", candidates), ("truth", truth)):
295
+ if not isinstance(frame, pd.DataFrame):
296
+ raise ValueError(f"the {label} data must be a pandas DataFrame")
297
+ check_columns(frame, columns, label)
298
+ known = pd.MultiIndex.from_frame(truth[columns].drop_duplicates())
299
+ if not len(known):
300
+ return float("nan")
301
+ proposed = pd.MultiIndex.from_frame(candidates[columns].drop_duplicates())
302
+ return float(known.isin(proposed).mean())