poi-dedupe 0.1.0__tar.gz

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.
Files changed (40) hide show
  1. poi_dedupe-0.1.0/CHANGELOG.md +35 -0
  2. poi_dedupe-0.1.0/CITATION.cff +20 -0
  3. poi_dedupe-0.1.0/LICENSE +21 -0
  4. poi_dedupe-0.1.0/MANIFEST.in +9 -0
  5. poi_dedupe-0.1.0/PKG-INFO +361 -0
  6. poi_dedupe-0.1.0/README.md +309 -0
  7. poi_dedupe-0.1.0/examples/quickstart.py +23 -0
  8. poi_dedupe-0.1.0/pyproject.toml +128 -0
  9. poi_dedupe-0.1.0/setup.cfg +4 -0
  10. poi_dedupe-0.1.0/src/poi_dedupe/__init__.py +148 -0
  11. poi_dedupe-0.1.0/src/poi_dedupe/__main__.py +7 -0
  12. poi_dedupe-0.1.0/src/poi_dedupe/benchmark.py +118 -0
  13. poi_dedupe-0.1.0/src/poi_dedupe/blocking.py +180 -0
  14. poi_dedupe-0.1.0/src/poi_dedupe/cli.py +319 -0
  15. poi_dedupe-0.1.0/src/poi_dedupe/cluster.py +185 -0
  16. poi_dedupe-0.1.0/src/poi_dedupe/evaluate.py +101 -0
  17. poi_dedupe-0.1.0/src/poi_dedupe/features.py +109 -0
  18. poi_dedupe-0.1.0/src/poi_dedupe/geo.py +184 -0
  19. poi_dedupe-0.1.0/src/poi_dedupe/io.py +492 -0
  20. poi_dedupe-0.1.0/src/poi_dedupe/merge.py +286 -0
  21. poi_dedupe-0.1.0/src/poi_dedupe/models.py +102 -0
  22. poi_dedupe-0.1.0/src/poi_dedupe/normalize.py +456 -0
  23. poi_dedupe-0.1.0/src/poi_dedupe/pipeline.py +188 -0
  24. poi_dedupe-0.1.0/src/poi_dedupe/py.typed +0 -0
  25. poi_dedupe-0.1.0/src/poi_dedupe/scoring.py +293 -0
  26. poi_dedupe-0.1.0/src/poi_dedupe/similarity.py +320 -0
  27. poi_dedupe-0.1.0/src/poi_dedupe/synthetic.py +440 -0
  28. poi_dedupe-0.1.0/src/poi_dedupe/taxonomy.py +284 -0
  29. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/PKG-INFO +361 -0
  30. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/SOURCES.txt +38 -0
  31. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/dependency_links.txt +1 -0
  32. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/entry_points.txt +2 -0
  33. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/requires.txt +27 -0
  34. poi_dedupe-0.1.0/src/poi_dedupe.egg-info/top_level.txt +1 -0
  35. poi_dedupe-0.1.0/tests/test_blocking_geo.py +178 -0
  36. poi_dedupe-0.1.0/tests/test_cluster_merge.py +236 -0
  37. poi_dedupe-0.1.0/tests/test_normalize.py +133 -0
  38. poi_dedupe-0.1.0/tests/test_pipeline_io_cli.py +408 -0
  39. poi_dedupe-0.1.0/tests/test_scoring_features.py +239 -0
  40. poi_dedupe-0.1.0/tests/test_similarity.py +153 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-11-08
8
+ ### Added
9
+ - **Blocking**: grid and geohash spatial blocking with neighbour cells and an exact haversine
10
+ radius. Bucket sizes come from great-circle bounds, so no pair within the radius is ever
11
+ missed, including at high latitudes, across the antimeridian and over the poles. Optional
12
+ category blocking and cross-source-only filtering.
13
+ - **Normalisation**: case folding with Turkish dotted/dotless I and German ß/umlaut handling,
14
+ accent folding, dotted acronyms (`A.Ş.`, `e.V.`), legal suffixes (Ltd, GmbH, Şti, ...),
15
+ stop-words for seven languages, abbreviation expansion with position-based handling of the
16
+ ambiguous `st` (saint/street) and `dr` (doctor/drive); address parsing (house number and
17
+ street tokens); best-effort E.164 phone normalisation; registrable-domain websites with
18
+ special handling of shared hosts (Facebook, Instagram, ...).
19
+ - **Similarity** (pure Python): Jaro, Jaro-Winkler, InDel ratio, token-sort and token-set
20
+ ratios, character n-gram TF-IDF cosine, address, phone, website, category compatibility and
21
+ category-dependent distance decay.
22
+ - **Taxonomy**: ~35 canonical categories with multilingual aliases, groups, distance scales,
23
+ compatibility overrides and per-source mappings; JSON load/save.
24
+ - **Scoring**: transparent weighted model with missing-feature renormalisation and per-term
25
+ explanations; logistic regression trainer (NumPy Newton/IRLS or scikit-learn); JSON model
26
+ files; precision/recall curve and threshold selection.
27
+ - **Clustering**: greedy union-find with conflict checks (max diameter, one record per source,
28
+ cannot-link) and connected components split by max diameter.
29
+ - **Golden records**: field-level survivorship (source priority, most complete, most recent,
30
+ majority vote), coordinate rules (mean, medoid, source priority), per-field provenance and
31
+ stable entity ids.
32
+ - **I/O**: CSV, GeoJSON, GeoParquet (pyarrow extra) and pandas DataFrames (pandas extra).
33
+ - **Evaluation**: labelled-pair and ground-truth precision/recall/F1, cluster metrics,
34
+ blocking pair completeness and reduction ratio; seeded synthetic multi-source benchmark.
35
+ - **CLI** `poi-dedupe` with `run`, `pairs`, `evaluate`, `train`, `synth` and `benchmark`.
@@ -0,0 +1,20 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ authors:
4
+ - family-names: "Eminoğlu"
5
+ given-names: "Yusuf"
6
+ email: "yusufeminoglu@gmail.com"
7
+ orcid: "https://orcid.org/0009-0005-6000-2934"
8
+ title: "poi-dedupe"
9
+ version: "0.1.0"
10
+ date-released: "2026-11-08"
11
+ license: "MIT"
12
+ repository-code: "https://github.com/YusufEminoglu/poi_dedupe_sdk"
13
+ url: "https://pypi.org/project/poi-dedupe/"
14
+ abstract: "Deduplication and conflation of points of interest from multiple sources: complete spatial blocking, explainable name/address/phone/website/category/distance similarity, weighted or logistic scoring, conflict-aware clustering and golden records with field-level provenance."
15
+ keywords:
16
+ - points of interest
17
+ - deduplication
18
+ - conflation
19
+ - entity resolution
20
+ - geospatial
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/poi_dedupe/py.typed
7
+ recursive-include src/poi_dedupe *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.py *.csv
@@ -0,0 +1,361 @@
1
+ Metadata-Version: 2.4
2
+ Name: poi-dedupe
3
+ Version: 0.1.0
4
+ Summary: Deduplicate and conflate points of interest from OSM, Overture, Google exports, registries and scraped lists: spatial blocking, explainable similarity scoring, clustering and golden records with provenance.
5
+ Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/poi_dedupe_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/poi_dedupe_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/poi_dedupe_sdk/issues
10
+ Project-URL: Changelog, https://github.com/YusufEminoglu/poi_dedupe_sdk/blob/main/CHANGELOG.md
11
+ Keywords: poi,points-of-interest,deduplication,conflation,entity-resolution,record-linkage,geospatial,gis,openstreetmap,overture-maps,fuzzy-matching,jaro-winkler,golden-record,data-quality
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: Text Processing :: Linguistic
24
+ Classifier: Intended Audience :: Developers
25
+ Classifier: Intended Audience :: Science/Research
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Provides-Extra: numpy
31
+ Requires-Dist: numpy>=1.20.0; extra == "numpy"
32
+ Provides-Extra: pandas
33
+ Requires-Dist: pandas>=1.3.0; extra == "pandas"
34
+ Provides-Extra: parquet
35
+ Requires-Dist: pyarrow>=8.0.0; extra == "parquet"
36
+ Provides-Extra: sklearn
37
+ Requires-Dist: scikit-learn>=1.0.0; extra == "sklearn"
38
+ Requires-Dist: numpy>=1.20.0; extra == "sklearn"
39
+ Provides-Extra: all
40
+ Requires-Dist: numpy>=1.20.0; extra == "all"
41
+ Requires-Dist: pandas>=1.3.0; extra == "all"
42
+ Requires-Dist: pyarrow>=8.0.0; extra == "all"
43
+ Provides-Extra: dev
44
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
45
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
46
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
47
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
48
+ Requires-Dist: numpy>=1.20.0; extra == "dev"
49
+ Requires-Dist: pandas>=1.3.0; extra == "dev"
50
+ Requires-Dist: pyarrow>=8.0.0; extra == "dev"
51
+ Dynamic: license-file
52
+
53
+ # poi-dedupe
54
+
55
+ [![CI](https://github.com/YusufEminoglu/poi_dedupe_sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/YusufEminoglu/poi_dedupe_sdk/actions/workflows/ci.yml)
56
+ [![PyPI](https://img.shields.io/pypi/v/poi-dedupe.svg)](https://pypi.org/project/poi-dedupe/)
57
+ [![Python](https://img.shields.io/pypi/pyversions/poi-dedupe.svg)](https://pypi.org/project/poi-dedupe/)
58
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
59
+
60
+ **Deduplicate and conflate points of interest from several sources** (OpenStreetMap, Overture
61
+ Maps, Google exports, government registries, scraped lists) into one set of golden records,
62
+ with every field traceable to the record it came from.
63
+
64
+ ```
65
+ sources ──► spatial blocking ──► similarity features ──► match score ──► clustering ──► golden records
66
+ (grid / geohash, (name, address, phone, (weighted or (union-find (survivorship
67
+ exact radius) website, category, logistic) with conflict rules +
68
+ distance decay) checks) provenance)
69
+ ```
70
+
71
+ - **Zero required dependencies.** Matching, scoring, clustering, merging, CSV and GeoJSON are
72
+ pure Python. NumPy is only needed to *train* a logistic model; pandas and pyarrow are
73
+ optional extras for DataFrames and GeoParquet.
74
+ - **Complete blocking.** No pair within the radius is ever missed, at any latitude, across the
75
+ antimeridian or over a pole; this is property-tested against brute force.
76
+ - **Explainable.** The default model is a weighted mean whose terms you can print per pair.
77
+ - **Multilingual normalisation**: Turkish dotted/dotless I, German ß and umlauts, accents, legal
78
+ forms (`Ltd. Şti.`, `GmbH`, `A.Ş.`, `LLC`), stop-words in seven languages and cautious
79
+ abbreviation expansion.
80
+
81
+ ## Install
82
+
83
+ ```bash
84
+ pip install poi-dedupe # core: pure Python
85
+ pip install "poi-dedupe[numpy]" # + logistic-regression training
86
+ pip install "poi-dedupe[pandas]" # + DataFrame in/out
87
+ pip install "poi-dedupe[parquet]" # + GeoParquet in/out (pyarrow)
88
+ pip install "poi-dedupe[all]" # all of the above
89
+ ```
90
+
91
+ Python 3.9 to 3.13.
92
+
93
+ ## Quick start (CLI)
94
+
95
+ ```bash
96
+ poi-dedupe run osm.csv overture.csv registry.csv \
97
+ --name name --lat lat --lon lon --id id \
98
+ --category category --address address --phone phone --website website --updated updated \
99
+ --country-code 90 --priority registry,osm,overture \
100
+ -o merged.geojson --clusters-out clusters.csv
101
+ ```
102
+
103
+ Each input file is one source (the source name defaults to the file stem; override it with
104
+ `--sources a,b,c`). Record ids become `"<source>:<id>"`. Inputs can be `.csv` (delimiter is
105
+ detected), `.geojson` (Point features) or `.parquet` (lat/lon columns or a WKB `geometry`
106
+ column); output can be `.geojson`, `.csv` or `.parquet` (GeoParquet 1.0).
107
+
108
+ | command | what it does |
109
+ |---|---|
110
+ | `poi-dedupe run ...` | block, score, cluster, merge; writes golden records (`--pairs-out`, `--clusters-out` optional) |
111
+ | `poi-dedupe pairs ... -o pairs.csv` | writes every candidate pair with all features and the score |
112
+ | `poi-dedupe evaluate pairs.csv --labels labels.csv` | precision / recall / F1, best-F1 threshold, optional `--curve` CSV |
113
+ | `poi-dedupe evaluate pairs.csv --truth truth.csv` | same, against complete ground truth (`id,entity_id`) |
114
+ | `poi-dedupe train pairs.csv --truth truth.csv -o model.json` | fits a logistic model, tunes its threshold; use with `run --model model.json` |
115
+ | `poi-dedupe synth -o bench/` | writes a synthetic three-source city plus `truth.csv` |
116
+ | `poi-dedupe benchmark` | runs the benchmark below |
117
+
118
+ Useful `run` options: `--radius` (metres, default 250), `--threshold`, `--blocking geohash`,
119
+ `--category-blocking`, `--cross-source-only`, `--clustering components`, `--max-diameter`,
120
+ `--one-per-source`, `--coordinates mean|medoid|source_priority`, `--rule phone=most_recent`,
121
+ `--taxonomy my_taxonomy.json`, `--lang de`.
122
+
123
+ ## Quick start (Python)
124
+
125
+ ```python
126
+ from poi_dedupe import POI, Deduper, Survivorship
127
+
128
+ records = [
129
+ POI("osm:1", "Kahve Dünyası", 41.04380, 29.00940, source="osm", category="amenity=cafe",
130
+ phone="+90 212 555 1234", updated="2024-01-01"),
131
+ POI("overture:1", "KAHVE DUNYASI Besiktas", 41.04383, 29.00945, source="overture",
132
+ category="coffee_shop", phone="0212 555 12 34", website="kahvedunyasi.com"),
133
+ POI("registry:1", "Kahve Dunyasi Ltd. Şti.", 41.04378, 29.00938, source="registry",
134
+ category="Coffee Shop", address="Barbaros Blv. No: 12"),
135
+ POI("osm:2", "Garanti BBVA ATM", 41.04385, 29.00950, source="osm", category="amenity=atm"),
136
+ ]
137
+
138
+ result = Deduper(
139
+ radius_m=150,
140
+ survivorship=Survivorship(source_priority=["registry", "osm", "overture"]),
141
+ ).run(records)
142
+
143
+ len(result.golden) # 2: the cafe (3 records) and the ATM
144
+ g = result.golden[0]
145
+ g.fields["phone"], g.provenance["phone"]
146
+ # ('+90 212 555 1234', {'value': ..., 'record_id': 'osm:1', 'source': 'osm',
147
+ # 'rule': 'majority', 'support': 2, 'candidates': 2})
148
+ ```
149
+
150
+ Reading files instead:
151
+
152
+ ```python
153
+ from poi_dedupe import ColumnMap, read_records, write_golden
154
+
155
+ cols = ColumnMap(name="name", lat="lat", lon="lon", id="id", category="category", phone="phone")
156
+ records = read_records("osm.csv", cols) + read_records("overture.geojson", cols)
157
+ write_golden(Deduper().run(records).golden, "merged.parquet")
158
+ ```
159
+
160
+ DataFrames: `records_from_dataframe(df, cols, source="osm")`, `golden_to_dataframe(result.golden)`,
161
+ `pairs_to_dataframe(result.pairs)`.
162
+
163
+ ## How it works
164
+
165
+ ### 1. Candidate generation (blocking)
166
+
167
+ `grid` (default) or `geohash` buckets with their neighbour cells, then an exact haversine check
168
+ against `radius_m`. Bucket sizes come from great-circle bounds: two points `d` metres apart
169
+ differ by at most `d / R` in latitude and by at most `2 asin(sin(d / 2R) / cos φmax)` in
170
+ longitude, where `φmax` is the highest latitude present. Neighbouring cells are therefore
171
+ always enough, and the result equals brute force exactly (tested at the equator, 41° N,
172
+ 60° N, 78° N, 89.995° N and on the antimeridian). Near the poles the bucketing falls back to
173
+ one longitude column, which is still complete but slower.
174
+
175
+ Options: `category_blocking=True` drops pairs whose categories are both known and
176
+ incompatible; `cross_source_only=True` never compares two records of the same source.
177
+
178
+ ### 2. Similarity features
179
+
180
+ All in `[0, 1]`; `None` means "no evidence" (a field missing on either side).
181
+
182
+ | feature | how |
183
+ |---|---|
184
+ | `name_jw` | Jaro-Winkler on normalised names |
185
+ | `name_token_set` | token-set ratio (FuzzyWuzzy/RapidFuzz definition, InDel based): word order and extra words such as a district name do not hurt |
186
+ | `name_tfidf` | cosine of character-trigram TF-IDF vectors fitted on all names of the run: rare grams count more than common ones like "cafe" |
187
+ | `address` | street-token overlap (typo tolerant) with a house-number term: `0.75 * street + 0.25 * [numbers equal]` when both sides have a number |
188
+ | `phone` | E.164 normalisation; 1.0 equal, 0.9 same last nine digits (one side lacks the country code), else 0.0 |
189
+ | `website` | same registrable domain (`shop.example.co.uk` = `example.co.uk`); for shared hosts (Facebook, Instagram, ...) the page has to match too, otherwise no evidence |
190
+ | `category` | compatibility from the taxonomy: 1 same, 0.5 same group (cafe vs restaurant), overrides (bar~pub 0.9, bank~atm 0.5), 0 unrelated |
191
+ | `distance` | half-life decay `0.5 ** (d / scale)`, where the scale depends on category: 15 m for an ATM, 40 m for a cafe, 300 m for a mall, 2 km for an airport |
192
+
193
+ Name normalisation, step by step: case folding (`İ ı I i` all become `i`; `ß` becomes `ss`;
194
+ `lang="de"` turns `ü` into `ue`), accent folding (`ş→s`, `é→e`, `ø→o`, `ł→l`), dotted acronyms
195
+ joined (`A.Ş.→as`), apostrophes dropped (`McDonald's→mcdonalds`), `&→and`, other punctuation
196
+ removed, abbreviations expanded, trailing legal forms stripped, stop-words removed (never
197
+ down to an empty name).
198
+
199
+ Abbreviations that are ambiguous are expanded **only by position**: `St. Mary` → `saint mary`
200
+ (leading), `Main St` → `main street` (trailing), and a `st` in the middle stays `st`. The same
201
+ goes for `Dr.` (doctor/drive). Unambiguous ones (`ave`, `blvd`, `rd`, `cd` → `caddesi`, `sk` →
202
+ `sokak`, `hauptstr` → `hauptstrasse`, ...) are always expanded.
203
+
204
+ ### 3. Scoring
205
+
206
+ **`WeightedScorer`** (default): a weighted mean over the features that are present, so a missing
207
+ phone neither helps nor hurts. Default weights: names 0.15 + 0.15 + 0.15, address 0.12, phone
208
+ 0.12, website 0.06, category 0.10, distance 0.15; default threshold 0.70.
209
+ `scorer.explain(features)` returns each term's contribution.
210
+
211
+ **`LogisticScorer`**: trained from labelled pairs with `train_logistic` (NumPy Newton/IRLS with
212
+ an L2 penalty, or `backend="sklearn"`). Each optional feature gets a presence flag, so the
213
+ model can learn that a *different* phone number is evidence against a match. Inference is
214
+ pure Python; models are saved as small JSON files.
215
+
216
+ Threshold tuning: `precision_recall_curve(scores, labels)` and
217
+ `best_threshold(scores, labels, min_precision=0.98)` (or max F1). Pass `n_positive` when some
218
+ true pairs were never scored, so recall stays honest.
219
+
220
+ ### 4. Clustering
221
+
222
+ - `union_find` (default): matches are applied strongest first, and two clusters merge only if
223
+ the merged cluster stays within `max_diameter_m` (default 2 × radius), keeps at most one
224
+ record per source (`one_per_source=True`) and violates no cannot-link pair. A weak bridging
225
+ match cannot chain two different places together.
226
+ - `components`: connected components, and any component wider than `max_diameter_m` is split
227
+ by removing its weakest edges.
228
+
229
+ Both return a partition of all records (singletons included). Invariants are tested on random
230
+ graphs.
231
+
232
+ ### 5. Golden records
233
+
234
+ `Survivorship` sets one rule per field:
235
+
236
+ | rule | picks |
237
+ |---|---|
238
+ | `source_priority` | the value from the most trusted source that has one |
239
+ | `most_complete` | the longest value |
240
+ | `most_recent` | the value from the record with the latest `updated` timestamp |
241
+ | `majority` | the most frequent value after normalisation (names without legal suffixes, phones as E.164, websites as hosts) |
242
+
243
+ Defaults: name, category and phone use `majority`; address and website use `most_complete`;
244
+ every other column (`attrs`) uses `default_rule`. Ties go to source priority, then recency,
245
+ then record id, so results are deterministic. Coordinates use `mean` (spherical centroid),
246
+ `medoid` or `source_priority`. Every field records its provenance (`value`, `record_id`,
247
+ `source`, `rule`, `support`, `candidates`), and entity ids are a stable hash of the member ids.
248
+
249
+ ### Taxonomy
250
+
251
+ `Taxonomy.default()` knows about 35 canonical categories with English, Turkish and German
252
+ aliases and understands OSM tags (`amenity=cafe`), Overture-style ids (`coffee_shop`) and
253
+ hierarchical labels (`eat_and_drink > cafe`, `food.cafe`). To extend it, use a JSON file
254
+ (`--taxonomy`):
255
+
256
+ ```json
257
+ {
258
+ "categories": {"vending": {"group": "retail", "distance_scale_m": 5, "aliases": ["vending machine"]}},
259
+ "compatibility": [["vending", "convenience", 0.3]],
260
+ "source_mappings": {"google": {"Espresso bar": "cafe"}}
261
+ }
262
+ ```
263
+
264
+ ## Benchmark
265
+
266
+ `poi-dedupe benchmark` generates two independent synthetic cities from different seeds. The
267
+ threshold is tuned and the logistic model trained on the first; all numbers below are measured
268
+ on the second, unseen one. Three sources (`osm`, `overture`, `registry`) contain each place with
269
+ probability 0.85 / 0.75 / 0.60, plus 2 % same-source duplicates. The noise includes coordinate
270
+ jitter scaled by category (5 % of records get three times the jitter), typos, case and accent
271
+ changes, legal suffixes, district suffixes, dropped words, `St.`/`Cd.` abbreviations, missing
272
+ fields, five phone formats, URL variants and a separate category vocabulary per source. Chain
273
+ brands (Starbucks, Migros, Shell, bank ATMs, ...) give hard negatives: same name and website,
274
+ different branch. All names and numbers are invented.
275
+
276
+ Metrics are pairwise against complete ground truth. "Pair" scores the matched pairs, "cluster"
277
+ scores the pairs implied by the final entities.
278
+
279
+ **Standard city**: 1000 entities in 5 × 5 km, 2221 records, radius 250 m. Blocking: 20 273
280
+ candidate pairs, pair completeness 0.977, reduction ratio 0.992.
281
+
282
+ | model | threshold | pair P | pair R | pair F1 | cluster P | cluster R | cluster F1 | entities (true 1000) |
283
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
284
+ | weighted (default) | 0.700 | 0.990 | 0.966 | 0.978 | 0.973 | 0.975 | 0.974 | 1018 |
285
+ | weighted (tuned) | 0.692 | 0.988 | 0.966 | 0.977 | 0.960 | 0.975 | 0.967 | 1016 |
286
+ | logistic | 0.463 | 0.989 | 0.971 | 0.980 | 0.959 | 0.979 | 0.969 | 1012 |
287
+
288
+ **Dense city** (`--extent 1500 --radius 150`): the same 1000 entities in 1.5 × 1.5 km, so
289
+ far more look-alike neighbours. Blocking: 71 653 candidate pairs, pair completeness 0.947,
290
+ reduction ratio 0.971.
291
+
292
+ | model | threshold | pair P | pair R | pair F1 | cluster P | cluster R | cluster F1 | entities (true 1000) |
293
+ |---|---:|---:|---:|---:|---:|---:|---:|---:|
294
+ | weighted (default) | 0.700 | 0.926 | 0.936 | 0.931 | 0.828 | 0.950 | 0.885 | 1006 |
295
+ | weighted (tuned) | 0.731 | 0.952 | 0.929 | 0.940 | 0.904 | 0.944 | 0.923 | 1035 |
296
+ | logistic | 0.575 | 0.982 | 0.923 | 0.952 | 0.944 | 0.940 | 0.942 | 1053 |
297
+
298
+ How to read these numbers:
299
+
300
+ - The **recall ceiling is set by blocking**. 2.3 % (standard) and 5.3 % (dense, smaller radius)
301
+ of the true pairs lie beyond the radius because of the heavy-jitter records, and no model can
302
+ recover them. Pick the radius from how far apart your sources really put the same place.
303
+ - In the dense city the **default threshold over-merges**: cluster precision (0.83) is well
304
+ below pair precision (0.93) because transitive chains join look-alike neighbours. Tuning the
305
+ threshold or training the logistic model on a few hundred labelled pairs from *your* data
306
+ closes most of that gap.
307
+ - **Synthetic data is not your data.** The generator is a controlled stress test, not a claim
308
+ about accuracy on real OSM / Overture / registry data, where coordinate offsets, naming
309
+ conventions and missing fields follow other distributions. Label a sample of
310
+ `poi-dedupe pairs` output and use `evaluate` / `train` before you trust a threshold.
311
+ - Runtime (pure Python, one core): about 2 s per configuration in the standard city and about
312
+ 6 s in the dense one (≈ 72 k pairs). Cost grows with the number of candidate pairs, that is
313
+ with density × radius².
314
+
315
+ Reproduce with `poi-dedupe benchmark` and `poi-dedupe benchmark --extent 1500 --radius 150`
316
+ (add `--json` for the full report).
317
+
318
+ ## API overview
319
+
320
+ | area | objects |
321
+ |---|---|
322
+ | data | `POI`, `CandidatePair`, `ScoredPair`, `GoldenRecord`, `pair_key` |
323
+ | pipeline | `Deduper`, `DedupeResult`, `dedupe` |
324
+ | blocking | `generate_candidates`, `grid_blocks`, `geohash_blocks`, `reduction_ratio` |
325
+ | normalisation | `normalize_text`, `normalize_name`, `parse_address`, `normalize_phone` |
326
+ | similarity | `jaro`, `jaro_winkler`, `ratio`, `token_sort_ratio`, `token_set_ratio`, `TfidfVectorizer`, `tfidf_cosine`, `address_similarity`, `phone_similarity`, `website_similarity`, `distance_decay` |
327
+ | features | `FeatureExtractor`, `FEATURE_NAMES` |
328
+ | taxonomy | `Taxonomy`, `Category` |
329
+ | scoring | `WeightedScorer`, `LogisticScorer`, `train_logistic`, `precision_recall_curve`, `best_threshold`, `save_model`, `load_model` |
330
+ | clustering | `cluster_union_find`, `cluster_components`, `UnionFind` |
331
+ | merge | `Survivorship`, `merge_cluster`, `merge_clusters` |
332
+ | evaluation | `Metrics`, `evaluate_labelled_pairs`, `evaluate_against_truth`, `evaluate_clusters`, `blocking_recall`, `truth_pairs` |
333
+ | synthetic | `make_synthetic`, `SyntheticDataset` |
334
+ | I/O | `ColumnMap`, `read_records`, `read_csv`, `read_geojson`, `read_parquet`, `write_golden`, `write_pairs`, `read_pairs`, `read_labels`, `read_truth`, `records_from_dataframe`, `golden_to_dataframe`, `pairs_to_dataframe` |
335
+
336
+ ## Limitations
337
+
338
+ - Phone handling is deliberately simple (no numbering-plan validation); pass
339
+ `default_country_code` when a source uses national formats.
340
+ - Registrable domains use a short built-in list of multi-part suffixes (`co.uk`, `com.tr`, ...),
341
+ not the full Public Suffix List.
342
+ - Pure Python favours portability over speed: plan on roughly 10 k pair comparisons per
343
+ second. Very dense inputs benefit from a smaller radius or `category_blocking=True`.
344
+ - Input geometries are points; polygons (malls, campuses) should be reduced to a representative
345
+ point first.
346
+
347
+ ## Development
348
+
349
+ ```bash
350
+ pip install -e ".[dev]"
351
+ pytest --cov=poi_dedupe
352
+ ruff check . && ruff format --check src tests && mypy src
353
+ ```
354
+
355
+ ## Citation
356
+
357
+ See [CITATION.cff](CITATION.cff).
358
+
359
+ ## License
360
+
361
+ MIT © Yusuf Eminoğlu