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 +19 -0
- jlink/__main__.py +3 -0
- jlink/audit.py +297 -0
- jlink/block.py +302 -0
- jlink/cli.py +329 -0
- jlink/core.py +244 -0
- jlink/fields.py +67 -0
- jlink/io.py +117 -0
- jlink/judge.py +158 -0
- jlink/linker.py +233 -0
- jlink/resolve.py +351 -0
- jlink-0.1.0.dist-info/METADATA +278 -0
- jlink-0.1.0.dist-info/RECORD +16 -0
- jlink-0.1.0.dist-info/WHEEL +4 -0
- jlink-0.1.0.dist-info/entry_points.txt +3 -0
- jlink-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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())
|