whitebox-dedup 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.
- whitebox_dedup/__init__.py +27 -0
- whitebox_dedup/__main__.py +5 -0
- whitebox_dedup/blocking.py +115 -0
- whitebox_dedup/cli.py +199 -0
- whitebox_dedup/conformal.py +96 -0
- whitebox_dedup/features.py +166 -0
- whitebox_dedup/mars_scorer.py +180 -0
- whitebox_dedup/pipeline.py +164 -0
- whitebox_dedup/router.py +145 -0
- whitebox_dedup/schema.py +168 -0
- whitebox_dedup/trainer.py +194 -0
- whitebox_dedup-0.1.0.dist-info/METADATA +294 -0
- whitebox_dedup-0.1.0.dist-info/RECORD +15 -0
- whitebox_dedup-0.1.0.dist-info/WHEEL +4 -0
- whitebox_dedup-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
whitebox-dedup: Interpretable entity deduplication with auditable MARS equations
|
|
3
|
+
and Inductive Conformal Prediction.
|
|
4
|
+
|
|
5
|
+
Developed by FutuX Agri-consult Limited.
|
|
6
|
+
|
|
7
|
+
Key exports:
|
|
8
|
+
- DeduplicationPipeline: end-to-end deduplication runner
|
|
9
|
+
- EntitySchema: declares the semantic role of each column
|
|
10
|
+
- MARSScorer: zero-dependency MARS equation evaluator
|
|
11
|
+
- DeduplicationResult: structured output from pipeline.run()
|
|
12
|
+
"""
|
|
13
|
+
from .pipeline import DeduplicationPipeline, DeduplicationResult
|
|
14
|
+
from .schema import EntitySchema
|
|
15
|
+
from .mars_scorer import MARSScorer
|
|
16
|
+
from .trainer import MARSTrainer
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DeduplicationPipeline",
|
|
20
|
+
"EntitySchema",
|
|
21
|
+
"MARSScorer",
|
|
22
|
+
"MARSTrainer",
|
|
23
|
+
"DeduplicationResult",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
__author__ = "Daniel Olagunju, Temitope Olarewaju"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blocking strategies to generate candidate duplicate pairs efficiently.
|
|
3
|
+
|
|
4
|
+
Without blocking, comparing every pair in N records requires N*(N-1)/2 comparisons.
|
|
5
|
+
Blocking groups records into buckets where duplicates are likely to be found,
|
|
6
|
+
then only compares records within the same bucket.
|
|
7
|
+
"""
|
|
8
|
+
import math
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from .schema import EntitySchema, FieldRole
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_candidate_pairs(
|
|
15
|
+
df: pd.DataFrame,
|
|
16
|
+
schema: EntitySchema,
|
|
17
|
+
geo_radius_m: float = 50.0,
|
|
18
|
+
) -> list[tuple[int, int]]:
|
|
19
|
+
"""
|
|
20
|
+
Generate candidate duplicate pairs using available blocking strategies.
|
|
21
|
+
Returns list of (row_index_a, row_index_b) tuples.
|
|
22
|
+
Deduplicates pairs (always returns (i, j) where i < j).
|
|
23
|
+
"""
|
|
24
|
+
pairs: set[tuple[int, int]] = set()
|
|
25
|
+
|
|
26
|
+
# Strategy 1: First-letter blocking on name fields
|
|
27
|
+
name_cols = schema.fields_by_role(FieldRole.NAME)
|
|
28
|
+
if name_cols:
|
|
29
|
+
pairs.update(_name_block(df, name_cols[0]))
|
|
30
|
+
|
|
31
|
+
# Strategy 2: Exact phone blocking
|
|
32
|
+
phone_cols = schema.fields_by_role(FieldRole.PHONE)
|
|
33
|
+
if phone_cols:
|
|
34
|
+
pairs.update(_exact_block(df, phone_cols[0]))
|
|
35
|
+
|
|
36
|
+
# Strategy 3: Exact ID blocking (to detect hard duplicates)
|
|
37
|
+
id_cols = schema.fields_by_role(FieldRole.ID)
|
|
38
|
+
if id_cols:
|
|
39
|
+
pairs.update(_exact_block(df, id_cols[0]))
|
|
40
|
+
|
|
41
|
+
# Strategy 4: Spatial blocking (haversine <= geo_radius_m)
|
|
42
|
+
lat_cols = schema.fields_by_role(FieldRole.LATITUDE)
|
|
43
|
+
lon_cols = schema.fields_by_role(FieldRole.LONGITUDE)
|
|
44
|
+
if lat_cols and lon_cols:
|
|
45
|
+
pairs.update(_geo_block(df, lat_cols[0], lon_cols[0], geo_radius_m))
|
|
46
|
+
|
|
47
|
+
return sorted(pairs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _name_block(df: pd.DataFrame, col: str) -> set[tuple[int, int]]:
|
|
51
|
+
"""Block by first 2 characters of name field."""
|
|
52
|
+
if col not in df.columns:
|
|
53
|
+
return set()
|
|
54
|
+
buckets: dict[str, list[int]] = {}
|
|
55
|
+
for idx, val in df[col].items():
|
|
56
|
+
key = str(val or "")[:2].lower()
|
|
57
|
+
if key:
|
|
58
|
+
buckets.setdefault(key, []).append(idx)
|
|
59
|
+
return _pairs_from_buckets(buckets)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _exact_block(df: pd.DataFrame, col: str) -> set[tuple[int, int]]:
|
|
63
|
+
"""Block by exact value match (detects hard duplicates)."""
|
|
64
|
+
if col not in df.columns:
|
|
65
|
+
return set()
|
|
66
|
+
buckets: dict[str, list[int]] = {}
|
|
67
|
+
for idx, val in df[col].items():
|
|
68
|
+
key = str(val or "").strip().lower()
|
|
69
|
+
if key:
|
|
70
|
+
buckets.setdefault(key, []).append(idx)
|
|
71
|
+
# Only keep buckets with > 1 record (actual duplicates possible)
|
|
72
|
+
return _pairs_from_buckets({k: v for k, v in buckets.items() if len(v) > 1})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _geo_block(df: pd.DataFrame, lat_col: str, lon_col: str, radius_m: float) -> set[tuple[int, int]]:
|
|
76
|
+
"""
|
|
77
|
+
Spatial blocking: pair records within radius_m metres of each other.
|
|
78
|
+
Uses a simple grid cell approach: approximately 0.001 degree = 111m resolution.
|
|
79
|
+
"""
|
|
80
|
+
if lat_col not in df.columns or lon_col not in df.columns:
|
|
81
|
+
return set()
|
|
82
|
+
|
|
83
|
+
cell_deg = radius_m / 111_000.0
|
|
84
|
+
buckets: dict[tuple[int, int], list[int]] = {}
|
|
85
|
+
for idx, row in df[[lat_col, lon_col]].iterrows():
|
|
86
|
+
try:
|
|
87
|
+
lat, lon = float(row[lat_col]), float(row[lon_col])
|
|
88
|
+
cell = (int(lat / cell_deg), int(lon / cell_deg))
|
|
89
|
+
buckets.setdefault(cell, []).append(idx)
|
|
90
|
+
except (ValueError, TypeError):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# Also check adjacent cells
|
|
94
|
+
pairs: set[tuple[int, int]] = set()
|
|
95
|
+
cells = list(buckets.keys())
|
|
96
|
+
for i, cell in enumerate(cells):
|
|
97
|
+
members = buckets[cell]
|
|
98
|
+
for di in [-1, 0, 1]:
|
|
99
|
+
for dj in [-1, 0, 1]:
|
|
100
|
+
neighbor = (cell[0] + di, cell[1] + dj)
|
|
101
|
+
if neighbor in buckets:
|
|
102
|
+
for a in members:
|
|
103
|
+
for b in buckets[neighbor]:
|
|
104
|
+
if a < b:
|
|
105
|
+
pairs.add((a, b))
|
|
106
|
+
return pairs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pairs_from_buckets(buckets: dict) -> set[tuple[int, int]]:
|
|
110
|
+
pairs: set[tuple[int, int]] = set()
|
|
111
|
+
for members in buckets.values():
|
|
112
|
+
for i in range(len(members)):
|
|
113
|
+
for j in range(i + 1, len(members)):
|
|
114
|
+
pairs.add((min(members[i], members[j]), max(members[i], members[j])))
|
|
115
|
+
return pairs
|
whitebox_dedup/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for whitebox-dedup.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
wdedup deduplicate --input FILE [--schema YAML] [--output JSON]
|
|
6
|
+
wdedup score-pair [--schema YAML]
|
|
7
|
+
wdedup show-equation [--model MODEL_NAME]
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print_err(msg: str) -> None:
|
|
16
|
+
print(f"[error] {msg}", file=sys.stderr)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _deduplicate_cmd(args) -> int:
|
|
20
|
+
"""Run full deduplication on a CSV file."""
|
|
21
|
+
from .pipeline import DeduplicationPipeline
|
|
22
|
+
from .schema import EntitySchema
|
|
23
|
+
|
|
24
|
+
input_path = Path(args.input)
|
|
25
|
+
if not input_path.exists():
|
|
26
|
+
_print_err(f"Input file not found: {input_path}")
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
schema = None
|
|
30
|
+
if args.schema:
|
|
31
|
+
schema_path = Path(args.schema)
|
|
32
|
+
if not schema_path.exists():
|
|
33
|
+
_print_err(f"Schema file not found: {schema_path}")
|
|
34
|
+
return 1
|
|
35
|
+
schema = EntitySchema.from_yaml(schema_path)
|
|
36
|
+
|
|
37
|
+
print(f"Loading data from: {input_path}")
|
|
38
|
+
pipeline = DeduplicationPipeline.from_csv(input_path, schema=schema)
|
|
39
|
+
print(f"Schema active features: {sorted(pipeline.schema.active_features())}")
|
|
40
|
+
print(f"Model: {pipeline.scorer.model_name}")
|
|
41
|
+
print("Running deduplication...")
|
|
42
|
+
|
|
43
|
+
result = pipeline.run()
|
|
44
|
+
summary = result.summary()
|
|
45
|
+
|
|
46
|
+
print("\nResults:")
|
|
47
|
+
print(f" Records processed: {summary['total_records']}")
|
|
48
|
+
print(f" Candidate pairs: {summary['candidate_pairs_evaluated']}")
|
|
49
|
+
print(f" Tier 1 (auto-merge): {summary['tier1_auto_merge']}")
|
|
50
|
+
print(f" Tier 2 (human review): {summary['tier2_human_review']}")
|
|
51
|
+
print(f" Tier 3 (auto-distinct): {summary['tier3_auto_distinct']}")
|
|
52
|
+
|
|
53
|
+
if args.output:
|
|
54
|
+
output_path = Path(args.output)
|
|
55
|
+
output_data = {
|
|
56
|
+
"summary": summary,
|
|
57
|
+
"tier1_auto_merge": result.tier1_auto_merge,
|
|
58
|
+
"tier2_human_review": result.tier2_human_review,
|
|
59
|
+
"tier3_auto_distinct": result.tier3_auto_distinct,
|
|
60
|
+
}
|
|
61
|
+
with open(output_path, "w") as f:
|
|
62
|
+
json.dump(output_data, f, indent=2, default=str)
|
|
63
|
+
print(f"\nFull results written to: {output_path}")
|
|
64
|
+
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _score_pair_cmd(args) -> int:
|
|
69
|
+
"""Interactively score a single pair of records."""
|
|
70
|
+
from .pipeline import DeduplicationPipeline
|
|
71
|
+
from .schema import EntitySchema
|
|
72
|
+
|
|
73
|
+
schema = None
|
|
74
|
+
if args.schema:
|
|
75
|
+
schema_path = Path(args.schema)
|
|
76
|
+
if not schema_path.exists():
|
|
77
|
+
_print_err(f"Schema file not found: {schema_path}")
|
|
78
|
+
return 1
|
|
79
|
+
schema = EntitySchema.from_yaml(schema_path)
|
|
80
|
+
else:
|
|
81
|
+
print("No schema provided. Using default agricultural schema.")
|
|
82
|
+
from .schema import EntityField, FieldRole
|
|
83
|
+
schema = EntitySchema(fields={
|
|
84
|
+
"full_name": EntityField("full_name", FieldRole.NAME),
|
|
85
|
+
"phone": EntityField("phone", FieldRole.PHONE),
|
|
86
|
+
"state": EntityField("state", FieldRole.ADMIN_1),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
print("Enter record A as JSON (e.g. {\"full_name\": \"Aminu Bello\", \"phone\": \"08012345678\"}):")
|
|
90
|
+
try:
|
|
91
|
+
line_a = input("> ").strip()
|
|
92
|
+
row_a = json.loads(line_a)
|
|
93
|
+
except (json.JSONDecodeError, EOFError) as exc:
|
|
94
|
+
_print_err(f"Invalid JSON for record A: {exc}")
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
print("Enter record B as JSON:")
|
|
98
|
+
try:
|
|
99
|
+
line_b = input("> ").strip()
|
|
100
|
+
row_b = json.loads(line_b)
|
|
101
|
+
except (json.JSONDecodeError, EOFError) as exc:
|
|
102
|
+
_print_err(f"Invalid JSON for record B: {exc}")
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
from .mars_scorer import MARSScorer
|
|
106
|
+
from .router import DecisionRouter
|
|
107
|
+
|
|
108
|
+
scorer = DeduplicationPipeline._load_best_model(schema)
|
|
109
|
+
try:
|
|
110
|
+
from .conformal import ICPPredictor
|
|
111
|
+
icp = ICPPredictor.load_pretrained(scorer.model_name)
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
icp = None
|
|
114
|
+
|
|
115
|
+
router = DecisionRouter(scorer, icp)
|
|
116
|
+
from .features import extract_all
|
|
117
|
+
feats = extract_all(row_a, row_b, schema)
|
|
118
|
+
routing = router.route(feats, schema)
|
|
119
|
+
|
|
120
|
+
print(f"\nTier: {routing.tier.value}")
|
|
121
|
+
print(f"Probability: {routing.probability:.4f}")
|
|
122
|
+
print(f"Reason: {routing.reason}")
|
|
123
|
+
print(f"Features: {json.dumps({k: round(v, 4) if v is not None else None for k, v in feats.items()}, indent=2)}")
|
|
124
|
+
|
|
125
|
+
if routing.mars_trace:
|
|
126
|
+
print("\nMARS trace:")
|
|
127
|
+
for contrib in routing.mars_trace["hinge_contributions"]:
|
|
128
|
+
print(f" {contrib['feature']} hinge at {contrib['knot']}: {contrib['hinge_output']:.4f}")
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _show_equation_cmd(args) -> int:
|
|
134
|
+
"""Print the MARS equation for a given model."""
|
|
135
|
+
from .mars_scorer import MARSScorer
|
|
136
|
+
|
|
137
|
+
model_name = args.model or "text_only_v1"
|
|
138
|
+
try:
|
|
139
|
+
scorer = MARSScorer.load_pretrained(model_name)
|
|
140
|
+
except FileNotFoundError as exc:
|
|
141
|
+
_print_err(str(exc))
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
print(f"Model: {scorer.model_name}")
|
|
145
|
+
if scorer.description:
|
|
146
|
+
print(f"Description: {scorer.description}")
|
|
147
|
+
print()
|
|
148
|
+
print(scorer.equation_text())
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> None:
|
|
153
|
+
"""Entry point for the wdedup CLI."""
|
|
154
|
+
import argparse
|
|
155
|
+
|
|
156
|
+
parser = argparse.ArgumentParser(
|
|
157
|
+
prog="wdedup",
|
|
158
|
+
description="whitebox-dedup: interpretable entity deduplication",
|
|
159
|
+
)
|
|
160
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
161
|
+
|
|
162
|
+
# deduplicate command
|
|
163
|
+
dedup_parser = subparsers.add_parser(
|
|
164
|
+
"deduplicate",
|
|
165
|
+
help="Deduplicate a CSV file",
|
|
166
|
+
)
|
|
167
|
+
dedup_parser.add_argument("--input", required=True, help="Path to input CSV file")
|
|
168
|
+
dedup_parser.add_argument("--schema", default=None, help="Path to schema YAML file (optional)")
|
|
169
|
+
dedup_parser.add_argument("--output", default=None, help="Path to output JSON file (optional)")
|
|
170
|
+
|
|
171
|
+
# score-pair command
|
|
172
|
+
score_parser = subparsers.add_parser(
|
|
173
|
+
"score-pair",
|
|
174
|
+
help="Interactively score a single pair of records",
|
|
175
|
+
)
|
|
176
|
+
score_parser.add_argument("--schema", default=None, help="Path to schema YAML file (optional)")
|
|
177
|
+
|
|
178
|
+
# show-equation command
|
|
179
|
+
eq_parser = subparsers.add_parser(
|
|
180
|
+
"show-equation",
|
|
181
|
+
help="Print the MARS equation for a model",
|
|
182
|
+
)
|
|
183
|
+
eq_parser.add_argument("--model", default="text_only_v1", help="Model name (default: text_only_v1)")
|
|
184
|
+
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
if args.command == "deduplicate":
|
|
188
|
+
sys.exit(_deduplicate_cmd(args))
|
|
189
|
+
elif args.command == "score-pair":
|
|
190
|
+
sys.exit(_score_pair_cmd(args))
|
|
191
|
+
elif args.command == "show-equation":
|
|
192
|
+
sys.exit(_show_equation_cmd(args))
|
|
193
|
+
else:
|
|
194
|
+
parser.print_help()
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inductive Conformal Prediction (ICP) for deduplication.
|
|
3
|
+
|
|
4
|
+
ICP wraps the MARS scorer to produce a prediction SET {0}, {1}, or {0,1}
|
|
5
|
+
at a user-specified coverage level (e.g., 95%).
|
|
6
|
+
|
|
7
|
+
When the prediction set is {0,1} (ambiguous), the circuit breaker fires:
|
|
8
|
+
the system cannot make an automated decision and diverts to human review.
|
|
9
|
+
|
|
10
|
+
Coverage stratified by Jaro-Winkler similarity shows that coverage collapses
|
|
11
|
+
in the ambiguous zone (JW >= 0.491), establishing an automated statutory trigger.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ICPCalibration:
|
|
22
|
+
"""Pre-computed nonconformity score thresholds."""
|
|
23
|
+
alpha: float # significance level (e.g., 0.05 for 95% coverage)
|
|
24
|
+
threshold_duplicate: float # nonconformity threshold for class 1 (duplicate)
|
|
25
|
+
threshold_distinct: float # nonconformity threshold for class 0 (distinct)
|
|
26
|
+
model_name: str = ""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def load(cls, path: str | Path) -> "ICPCalibration":
|
|
30
|
+
with open(path) as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
return cls(
|
|
33
|
+
alpha=data["alpha"],
|
|
34
|
+
threshold_duplicate=data["threshold_duplicate"],
|
|
35
|
+
threshold_distinct=data["threshold_distinct"],
|
|
36
|
+
model_name=data.get("model_name", ""),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ICPPredictor:
|
|
41
|
+
"""
|
|
42
|
+
Produces conformal prediction sets given a MARS probability score.
|
|
43
|
+
|
|
44
|
+
A prediction set of {1} means "definitely duplicate at the stated coverage level".
|
|
45
|
+
A prediction set of {0} means "definitely distinct".
|
|
46
|
+
A prediction set of {0, 1} means "ambiguous: circuit breaker fires".
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, calibration: ICPCalibration):
|
|
50
|
+
self.cal = calibration
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def load_pretrained(cls, model_name: str) -> "ICPPredictor":
|
|
54
|
+
cal_dir = Path(__file__).parent.parent / "models" / "calibration"
|
|
55
|
+
path = cal_dir / f"{model_name}_icp.json"
|
|
56
|
+
if not path.exists():
|
|
57
|
+
raise FileNotFoundError(f"ICP calibration not found: {path}")
|
|
58
|
+
return cls(ICPCalibration.load(path))
|
|
59
|
+
|
|
60
|
+
def predict_set(self, probability: float) -> frozenset[int]:
|
|
61
|
+
"""
|
|
62
|
+
Return the conformal prediction set for a given duplicate probability.
|
|
63
|
+
|
|
64
|
+
The nonconformity score for class 1 is (1 - p), for class 0 is p.
|
|
65
|
+
A label y is included in the prediction set if its nonconformity
|
|
66
|
+
score <= the calibration threshold.
|
|
67
|
+
"""
|
|
68
|
+
nc_1 = 1.0 - probability # nonconformity for "duplicate"
|
|
69
|
+
nc_0 = probability # nonconformity for "distinct"
|
|
70
|
+
|
|
71
|
+
include_1 = nc_1 <= self.cal.threshold_duplicate
|
|
72
|
+
include_0 = nc_0 <= self.cal.threshold_distinct
|
|
73
|
+
|
|
74
|
+
result = set()
|
|
75
|
+
if include_1:
|
|
76
|
+
result.add(1)
|
|
77
|
+
if include_0:
|
|
78
|
+
result.add(0)
|
|
79
|
+
if not result:
|
|
80
|
+
result = {0, 1} # always return non-empty set
|
|
81
|
+
return frozenset(result)
|
|
82
|
+
|
|
83
|
+
def is_ambiguous(self, probability: float) -> bool:
|
|
84
|
+
return self.predict_set(probability) == frozenset({0, 1})
|
|
85
|
+
|
|
86
|
+
def coverage_zone(self, jw_score: float) -> str:
|
|
87
|
+
"""
|
|
88
|
+
Classify coverage zone from JW similarity.
|
|
89
|
+
Based on empirical finding: coverage collapses to 77.93% when JW >= 0.491.
|
|
90
|
+
"""
|
|
91
|
+
if jw_score < 0.491:
|
|
92
|
+
return "high_coverage" # >= 95% empirical coverage
|
|
93
|
+
elif jw_score >= 0.898:
|
|
94
|
+
return "high_coverage" # very high similarity -> clear duplicate
|
|
95
|
+
else:
|
|
96
|
+
return "ambiguous_zone" # coverage 77.93% -> circuit breaker
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pairwise feature extraction for entity deduplication.
|
|
3
|
+
|
|
4
|
+
All features take two record dicts (row_a, row_b) and an EntitySchema,
|
|
5
|
+
and return a float value. Missing columns return None (feature skipped).
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from typing import Optional
|
|
10
|
+
import jellyfish
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def jw_name_full(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
|
|
14
|
+
"""Jaro-Winkler similarity on concatenated name tokens."""
|
|
15
|
+
if not name_cols:
|
|
16
|
+
return None
|
|
17
|
+
a = " ".join(str(row_a.get(c, "") or "").strip() for c in name_cols).lower()
|
|
18
|
+
b = " ".join(str(row_b.get(c, "") or "").strip() for c in name_cols).lower()
|
|
19
|
+
if not a or not b:
|
|
20
|
+
return None
|
|
21
|
+
return jellyfish.jaro_winkler_similarity(a, b)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def jw_name_first(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
|
|
25
|
+
"""Jaro-Winkler on first token of primary name field."""
|
|
26
|
+
if not name_cols:
|
|
27
|
+
return None
|
|
28
|
+
a_tokens = str(row_a.get(name_cols[0], "") or "").split()
|
|
29
|
+
b_tokens = str(row_b.get(name_cols[0], "") or "").split()
|
|
30
|
+
if not a_tokens or not b_tokens:
|
|
31
|
+
return None
|
|
32
|
+
return jellyfish.jaro_winkler_similarity(a_tokens[0].lower(), b_tokens[0].lower())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def jw_name_last(row_a: dict, row_b: dict, name_cols: list[str]) -> Optional[float]:
|
|
36
|
+
"""Jaro-Winkler on last token of primary name field."""
|
|
37
|
+
if not name_cols:
|
|
38
|
+
return None
|
|
39
|
+
a_tokens = str(row_a.get(name_cols[0], "") or "").split()
|
|
40
|
+
b_tokens = str(row_b.get(name_cols[0], "") or "").split()
|
|
41
|
+
if not a_tokens or not b_tokens:
|
|
42
|
+
return None
|
|
43
|
+
return jellyfish.jaro_winkler_similarity(a_tokens[-1].lower(), b_tokens[-1].lower())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def haversine_m(row_a: dict, row_b: dict, lat_col: str, lon_col: str) -> Optional[float]:
|
|
47
|
+
"""Great-circle distance in metres between two GPS coordinates."""
|
|
48
|
+
try:
|
|
49
|
+
lat1, lon1 = float(row_a[lat_col]), float(row_a[lon_col])
|
|
50
|
+
lat2, lon2 = float(row_b[lat_col]), float(row_b[lon_col])
|
|
51
|
+
except (KeyError, TypeError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
R = 6_371_000.0
|
|
54
|
+
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
|
55
|
+
dphi = math.radians(lat2 - lat1)
|
|
56
|
+
dlambda = math.radians(lon2 - lon1)
|
|
57
|
+
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
|
58
|
+
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_phone(phone: str) -> str:
|
|
62
|
+
return re.sub(r"[\s\-().+]", "", str(phone or ""))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def exact_phone(row_a: dict, row_b: dict, phone_col: str) -> Optional[float]:
|
|
66
|
+
"""1.0 if phone numbers match after normalisation, 0.0 otherwise."""
|
|
67
|
+
a = _normalize_phone(row_a.get(phone_col, ""))
|
|
68
|
+
b = _normalize_phone(row_b.get(phone_col, ""))
|
|
69
|
+
if not a or not b:
|
|
70
|
+
return None
|
|
71
|
+
return 1.0 if a == b else 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def exact_id(row_a: dict, row_b: dict, id_col: str) -> Optional[float]:
|
|
75
|
+
"""1.0 if unique ID values match, 0.0 if both non-null and different."""
|
|
76
|
+
a = str(row_a.get(id_col, "") or "").strip()
|
|
77
|
+
b = str(row_b.get(id_col, "") or "").strip()
|
|
78
|
+
if not a or not b:
|
|
79
|
+
return None
|
|
80
|
+
return 1.0 if a.lower() == b.lower() else 0.0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def same_admin(row_a: dict, row_b: dict, col: str) -> Optional[float]:
|
|
84
|
+
"""1.0 if same administrative unit, 0.0 otherwise."""
|
|
85
|
+
a = str(row_a.get(col, "") or "").strip().lower()
|
|
86
|
+
b = str(row_b.get(col, "") or "").strip().lower()
|
|
87
|
+
if not a or not b:
|
|
88
|
+
return None
|
|
89
|
+
return 1.0 if a == b else 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dob_match(row_a: dict, row_b: dict, dob_col: str) -> Optional[float]:
|
|
93
|
+
"""1.0 if date of birth strings match exactly (after stripping)."""
|
|
94
|
+
a = str(row_a.get(dob_col, "") or "").strip()
|
|
95
|
+
b = str(row_b.get(dob_col, "") or "").strip()
|
|
96
|
+
if not a or not b:
|
|
97
|
+
return None
|
|
98
|
+
return 1.0 if a == b else 0.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def same_gender(row_a: dict, row_b: dict, gender_col: str) -> Optional[float]:
|
|
102
|
+
"""1.0 if gender values match (normalised to first letter)."""
|
|
103
|
+
a = str(row_a.get(gender_col, "") or "")[:1].upper()
|
|
104
|
+
b = str(row_b.get(gender_col, "") or "")[:1].upper()
|
|
105
|
+
if not a or not b:
|
|
106
|
+
return None
|
|
107
|
+
return 1.0 if a == b else 0.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def same_enumerator(row_a: dict, row_b: dict, enum_col: str) -> Optional[float]:
|
|
111
|
+
"""1.0 if records were collected by the same field agent."""
|
|
112
|
+
a = str(row_a.get(enum_col, "") or "").strip()
|
|
113
|
+
b = str(row_b.get(enum_col, "") or "").strip()
|
|
114
|
+
if not a or not b:
|
|
115
|
+
return None
|
|
116
|
+
return 1.0 if a == b else 0.0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_all(row_a: dict, row_b: dict, schema) -> dict[str, Optional[float]]:
|
|
120
|
+
"""
|
|
121
|
+
Extract all available features for a pair given the EntitySchema.
|
|
122
|
+
Returns a dict of feature_name -> value (None if feature unavailable).
|
|
123
|
+
"""
|
|
124
|
+
from .schema import FieldRole
|
|
125
|
+
result: dict[str, Optional[float]] = {}
|
|
126
|
+
active = schema.active_features()
|
|
127
|
+
|
|
128
|
+
name_cols = schema.fields_by_role(FieldRole.NAME)
|
|
129
|
+
if "jw_name_full" in active: result["jw_name_full"] = jw_name_full(row_a, row_b, name_cols)
|
|
130
|
+
if "jw_name_first" in active: result["jw_name_first"] = jw_name_first(row_a, row_b, name_cols)
|
|
131
|
+
if "jw_name_last" in active: result["jw_name_last"] = jw_name_last(row_a, row_b, name_cols)
|
|
132
|
+
|
|
133
|
+
id_cols = schema.fields_by_role(FieldRole.ID)
|
|
134
|
+
if "exact_id" in active and id_cols:
|
|
135
|
+
result["exact_id"] = exact_id(row_a, row_b, id_cols[0])
|
|
136
|
+
|
|
137
|
+
phone_cols = schema.fields_by_role(FieldRole.PHONE)
|
|
138
|
+
if "exact_phone" in active and phone_cols:
|
|
139
|
+
result["exact_phone"] = exact_phone(row_a, row_b, phone_cols[0])
|
|
140
|
+
|
|
141
|
+
lat_cols = schema.fields_by_role(FieldRole.LATITUDE)
|
|
142
|
+
lon_cols = schema.fields_by_role(FieldRole.LONGITUDE)
|
|
143
|
+
if "haversine_m" in active and lat_cols and lon_cols:
|
|
144
|
+
result["haversine_m"] = haversine_m(row_a, row_b, lat_cols[0], lon_cols[0])
|
|
145
|
+
|
|
146
|
+
a1_cols = schema.fields_by_role(FieldRole.ADMIN_1)
|
|
147
|
+
if "same_admin_1" in active and a1_cols:
|
|
148
|
+
result["same_admin_1"] = same_admin(row_a, row_b, a1_cols[0])
|
|
149
|
+
|
|
150
|
+
a2_cols = schema.fields_by_role(FieldRole.ADMIN_2)
|
|
151
|
+
if "same_admin_2" in active and a2_cols:
|
|
152
|
+
result["same_admin_2"] = same_admin(row_a, row_b, a2_cols[0])
|
|
153
|
+
|
|
154
|
+
dob_cols = schema.fields_by_role(FieldRole.DATE_OF_BIRTH)
|
|
155
|
+
if "dob_match" in active and dob_cols:
|
|
156
|
+
result["dob_match"] = dob_match(row_a, row_b, dob_cols[0])
|
|
157
|
+
|
|
158
|
+
gender_cols = schema.fields_by_role(FieldRole.GENDER)
|
|
159
|
+
if "same_gender" in active and gender_cols:
|
|
160
|
+
result["same_gender"] = same_gender(row_a, row_b, gender_cols[0])
|
|
161
|
+
|
|
162
|
+
enum_cols = schema.fields_by_role(FieldRole.ENUMERATOR)
|
|
163
|
+
if "same_enumerator" in active and enum_cols:
|
|
164
|
+
result["same_enumerator"] = same_enumerator(row_a, row_b, enum_cols[0])
|
|
165
|
+
|
|
166
|
+
return result
|