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.
- poi_dedupe-0.1.0/CHANGELOG.md +35 -0
- poi_dedupe-0.1.0/CITATION.cff +20 -0
- poi_dedupe-0.1.0/LICENSE +21 -0
- poi_dedupe-0.1.0/MANIFEST.in +9 -0
- poi_dedupe-0.1.0/PKG-INFO +361 -0
- poi_dedupe-0.1.0/README.md +309 -0
- poi_dedupe-0.1.0/examples/quickstart.py +23 -0
- poi_dedupe-0.1.0/pyproject.toml +128 -0
- poi_dedupe-0.1.0/setup.cfg +4 -0
- poi_dedupe-0.1.0/src/poi_dedupe/__init__.py +148 -0
- poi_dedupe-0.1.0/src/poi_dedupe/__main__.py +7 -0
- poi_dedupe-0.1.0/src/poi_dedupe/benchmark.py +118 -0
- poi_dedupe-0.1.0/src/poi_dedupe/blocking.py +180 -0
- poi_dedupe-0.1.0/src/poi_dedupe/cli.py +319 -0
- poi_dedupe-0.1.0/src/poi_dedupe/cluster.py +185 -0
- poi_dedupe-0.1.0/src/poi_dedupe/evaluate.py +101 -0
- poi_dedupe-0.1.0/src/poi_dedupe/features.py +109 -0
- poi_dedupe-0.1.0/src/poi_dedupe/geo.py +184 -0
- poi_dedupe-0.1.0/src/poi_dedupe/io.py +492 -0
- poi_dedupe-0.1.0/src/poi_dedupe/merge.py +286 -0
- poi_dedupe-0.1.0/src/poi_dedupe/models.py +102 -0
- poi_dedupe-0.1.0/src/poi_dedupe/normalize.py +456 -0
- poi_dedupe-0.1.0/src/poi_dedupe/pipeline.py +188 -0
- poi_dedupe-0.1.0/src/poi_dedupe/py.typed +0 -0
- poi_dedupe-0.1.0/src/poi_dedupe/scoring.py +293 -0
- poi_dedupe-0.1.0/src/poi_dedupe/similarity.py +320 -0
- poi_dedupe-0.1.0/src/poi_dedupe/synthetic.py +440 -0
- poi_dedupe-0.1.0/src/poi_dedupe/taxonomy.py +284 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/PKG-INFO +361 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/SOURCES.txt +38 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/dependency_links.txt +1 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/entry_points.txt +2 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/requires.txt +27 -0
- poi_dedupe-0.1.0/src/poi_dedupe.egg-info/top_level.txt +1 -0
- poi_dedupe-0.1.0/tests/test_blocking_geo.py +178 -0
- poi_dedupe-0.1.0/tests/test_cluster_merge.py +236 -0
- poi_dedupe-0.1.0/tests/test_normalize.py +133 -0
- poi_dedupe-0.1.0/tests/test_pipeline_io_cli.py +408 -0
- poi_dedupe-0.1.0/tests/test_scoring_features.py +239 -0
- 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
|
poi_dedupe-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
[](https://github.com/YusufEminoglu/poi_dedupe_sdk/actions/workflows/ci.yml)
|
|
56
|
+
[](https://pypi.org/project/poi-dedupe/)
|
|
57
|
+
[](https://pypi.org/project/poi-dedupe/)
|
|
58
|
+
[](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
|