reckoner-er 0.3.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.
- reckoner/__init__.py +77 -0
- reckoner/__main__.py +7 -0
- reckoner/api.py +221 -0
- reckoner/cli.py +474 -0
- reckoner/evaluate.py +89 -0
- reckoner/py.typed +0 -0
- reckoner/resolver.py +341 -0
- reckoner_er-0.3.0.dist-info/METADATA +100 -0
- reckoner_er-0.3.0.dist-info/RECORD +13 -0
- reckoner_er-0.3.0.dist-info/WHEEL +5 -0
- reckoner_er-0.3.0.dist-info/entry_points.txt +2 -0
- reckoner_er-0.3.0.dist-info/licenses/LICENSE +21 -0
- reckoner_er-0.3.0.dist-info/top_level.txt +1 -0
reckoner/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""reckoner - deterministic, explainable entity resolution for organization names.
|
|
2
|
+
|
|
3
|
+
No fuzzy matching. Identifiers merge outright; exact-normalized names link at low
|
|
4
|
+
confidence; conflicting strong identifiers block a merge. Every link and every
|
|
5
|
+
refusal is an auditable receipt.
|
|
6
|
+
|
|
7
|
+
Install with ``pip install reckoner-er`` (the import name is ``reckoner``).
|
|
8
|
+
|
|
9
|
+
The short API:
|
|
10
|
+
|
|
11
|
+
from reckoner import resolve, match
|
|
12
|
+
|
|
13
|
+
for cluster in resolve(["GENERAL ELECTRIC CO", "General Electric Company", "Boeing"]):
|
|
14
|
+
print(cluster.canonical_name, cluster.names, cluster.explanations)
|
|
15
|
+
|
|
16
|
+
m = match("THE SHERWIN-WILLIAMS COMPANY", "Sherwin Williams Co")
|
|
17
|
+
print(m.matched, m.score, m.reasons)
|
|
18
|
+
|
|
19
|
+
The full result, with every receipt as a dict:
|
|
20
|
+
|
|
21
|
+
from reckoner import EntityResolver
|
|
22
|
+
|
|
23
|
+
records = [
|
|
24
|
+
{"name": "GENERAL ELECTRIC CO", "cik": "40545", "source": "sec"},
|
|
25
|
+
{"name": "General Electric Company", "source": "usaspending"},
|
|
26
|
+
{"name": "Meta Platforms, Inc.", "cik": "1326801", "source": "sec"},
|
|
27
|
+
{"name": "Meta Financial Group", "cik": "907471", "source": "sec"},
|
|
28
|
+
]
|
|
29
|
+
result = EntityResolver().resolve(records)
|
|
30
|
+
for e in result["entities"]:
|
|
31
|
+
print(e["canonical_name"], e["member_indices"])
|
|
32
|
+
"""
|
|
33
|
+
from .api import Cluster, MatchResult, Record, match, resolve
|
|
34
|
+
from .evaluate import PairwiseScores, pairwise_scores
|
|
35
|
+
from .resolver import (
|
|
36
|
+
EntityResolver,
|
|
37
|
+
MatchRecord,
|
|
38
|
+
normalize_org,
|
|
39
|
+
normalize_agency,
|
|
40
|
+
norm_cik,
|
|
41
|
+
norm_uei,
|
|
42
|
+
norm_ein,
|
|
43
|
+
norm_cage,
|
|
44
|
+
norm_ticker,
|
|
45
|
+
norm_domain,
|
|
46
|
+
IDENTIFIERS,
|
|
47
|
+
STRONG_IDS,
|
|
48
|
+
LEGAL_SUFFIXES,
|
|
49
|
+
AGENCY_ALIASES,
|
|
50
|
+
NAME_EXACT_CONFIDENCE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "0.3.0"
|
|
54
|
+
__all__ = [
|
|
55
|
+
"resolve",
|
|
56
|
+
"match",
|
|
57
|
+
"Cluster",
|
|
58
|
+
"MatchResult",
|
|
59
|
+
"Record",
|
|
60
|
+
"pairwise_scores",
|
|
61
|
+
"PairwiseScores",
|
|
62
|
+
"EntityResolver",
|
|
63
|
+
"MatchRecord",
|
|
64
|
+
"normalize_org",
|
|
65
|
+
"normalize_agency",
|
|
66
|
+
"norm_cik",
|
|
67
|
+
"norm_uei",
|
|
68
|
+
"norm_ein",
|
|
69
|
+
"norm_cage",
|
|
70
|
+
"norm_ticker",
|
|
71
|
+
"norm_domain",
|
|
72
|
+
"IDENTIFIERS",
|
|
73
|
+
"STRONG_IDS",
|
|
74
|
+
"LEGAL_SUFFIXES",
|
|
75
|
+
"AGENCY_ALIASES",
|
|
76
|
+
"NAME_EXACT_CONFIDENCE",
|
|
77
|
+
]
|
reckoner/__main__.py
ADDED
reckoner/api.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The two-function public API: ``resolve`` and ``match``.
|
|
2
|
+
|
|
3
|
+
Both are thin, typed wrappers over :class:`reckoner.EntityResolver`. They make the
|
|
4
|
+
same decisions (identifiers first, exact normalized names second, conflicting
|
|
5
|
+
strong identifiers block a merge, nothing fuzzy) and turn the resolver's receipts
|
|
6
|
+
into plain sentences you can show a person.
|
|
7
|
+
|
|
8
|
+
>>> from reckoner import resolve, match
|
|
9
|
+
>>> [c.names for c in resolve(["GENERAL ELECTRIC CO", "General Electric Company", "Boeing"])]
|
|
10
|
+
[('GENERAL ELECTRIC CO', 'General Electric Company'), ('Boeing',)]
|
|
11
|
+
>>> m = match("THE SHERWIN-WILLIAMS COMPANY", "Sherwin Williams Co")
|
|
12
|
+
>>> m.matched, m.score
|
|
13
|
+
(True, 0.6)
|
|
14
|
+
>>> match("Meta Platforms, Inc.", "Meta Financial Group, Inc.").score
|
|
15
|
+
0.0
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Dict, Iterable, List, Mapping, Tuple, Union
|
|
21
|
+
|
|
22
|
+
from .resolver import STRONG_IDS, EntityResolver
|
|
23
|
+
|
|
24
|
+
#: A record is either a bare organization name or a mapping with any of the keys
|
|
25
|
+
#: ``name``, ``agency``, ``cik``, ``uei``, ``ein``, ``cage``, ``ticker``, ``domain``,
|
|
26
|
+
#: ``source`` and ``state``.
|
|
27
|
+
Record = Union[str, Mapping[str, Any]]
|
|
28
|
+
|
|
29
|
+
__all__ = ["Record", "Cluster", "MatchResult", "resolve", "match"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Cluster:
|
|
34
|
+
"""One resolved organization and why its members are together.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
entity_id: Stable within one call, e.g. ``"ent-0"``.
|
|
38
|
+
canonical_name: The longest member name (the most specific spelling).
|
|
39
|
+
normalized_name: ``canonical_name`` after normalization.
|
|
40
|
+
members: Indices into the input, in input order.
|
|
41
|
+
names: The member names, aligned with ``members``.
|
|
42
|
+
identifiers: Identifiers carried by any member, e.g. ``{"cik": "40545"}``.
|
|
43
|
+
link_confidence: The strongest evidence that merged the members: an
|
|
44
|
+
identifier's confidence (CIK 0.99 ... domain 0.85), 0.60 for an exact
|
|
45
|
+
normalized name, 1.0 for a single record.
|
|
46
|
+
explanations: One sentence per merge into this cluster and per refused
|
|
47
|
+
merge involving one of its members.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
entity_id: str
|
|
51
|
+
canonical_name: str
|
|
52
|
+
normalized_name: str
|
|
53
|
+
members: Tuple[int, ...]
|
|
54
|
+
names: Tuple[str, ...]
|
|
55
|
+
identifiers: Dict[str, str]
|
|
56
|
+
link_confidence: float
|
|
57
|
+
explanations: Tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
def __len__(self) -> int:
|
|
60
|
+
return len(self.members)
|
|
61
|
+
|
|
62
|
+
def __hash__(self) -> int:
|
|
63
|
+
# ``identifiers`` is a dict, which cannot be hashed, so it is hashed as a
|
|
64
|
+
# sorted tuple of pairs. Equal clusters still hash equal, so clusters can
|
|
65
|
+
# go in a set or be dict keys. Do not change ``identifiers`` in place.
|
|
66
|
+
return hash((self.entity_id, self.canonical_name, self.normalized_name,
|
|
67
|
+
self.members, self.names, tuple(sorted(self.identifiers.items())),
|
|
68
|
+
self.link_confidence, self.explanations))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class MatchResult:
|
|
73
|
+
"""Whether two records name the same organization, with the reasons.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
matched: True when reckoner would merge the two records.
|
|
77
|
+
score: The confidence of the merge (0.85 to 0.99 for a shared identifier,
|
|
78
|
+
0.60 for an exact normalized name), or 0.0 when they do not match.
|
|
79
|
+
It is not a similarity score: there are no partial matches.
|
|
80
|
+
method: ``"id:<name>"`` (for example ``"id:cik"``), ``"name_exact_normalized"``,
|
|
81
|
+
``"refused"`` (same normalized name, conflicting strong identifier)
|
|
82
|
+
or ``"none"``.
|
|
83
|
+
reasons: Plain sentences explaining the decision.
|
|
84
|
+
normalized: The two normalized names that were compared.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
matched: bool
|
|
88
|
+
score: float
|
|
89
|
+
method: str
|
|
90
|
+
reasons: Tuple[str, ...]
|
|
91
|
+
normalized: Tuple[str, str]
|
|
92
|
+
|
|
93
|
+
def __bool__(self) -> bool:
|
|
94
|
+
return self.matched
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _as_record(item: Record) -> Dict[str, Any]:
|
|
98
|
+
if isinstance(item, str):
|
|
99
|
+
return {"name": item}
|
|
100
|
+
if isinstance(item, Mapping):
|
|
101
|
+
return {str(k).strip().lower(): v for k, v in item.items()}
|
|
102
|
+
raise TypeError(f"expected a name (str) or a mapping, got {type(item).__name__}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _describe(m: Dict[str, Any]) -> str:
|
|
106
|
+
left = f"#{m['left_index']} {m['left_original']!r}"
|
|
107
|
+
right = f"#{m['right_index']} {m['right_original']!r}"
|
|
108
|
+
if not m["merged"]:
|
|
109
|
+
return f"kept {right} apart from {left}: {m['refusal_reason']}"
|
|
110
|
+
if m["method"].startswith("id:"):
|
|
111
|
+
why = f"shared {m['method'][3:].upper()} {m['normalized']}"
|
|
112
|
+
else:
|
|
113
|
+
why = f"same name after normalization ({m['normalized']!r})"
|
|
114
|
+
extra = [e for e in m["evidence"] if not e.startswith("exact normalized name")]
|
|
115
|
+
if extra:
|
|
116
|
+
why += ", " + ", ".join(extra)
|
|
117
|
+
return f"{right} joined {left}: {why} (confidence {m['confidence']:.2f})"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve(names: Iterable[Record], *, agency: bool = False) -> List[Cluster]:
|
|
121
|
+
"""Group names (or records) that refer to the same organization.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
names: Organization names, or mappings that may also carry identifiers
|
|
125
|
+
(``{"name": "GENERAL ELECTRIC CO", "cik": "40545"}``). Strings and
|
|
126
|
+
mappings can be mixed.
|
|
127
|
+
agency: Normalize as government agency names (strip "U.S.", expand DoD,
|
|
128
|
+
EPA and similar aliases).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
One :class:`Cluster` per organization, in order of first appearance. Every
|
|
132
|
+
input index appears in exactly one cluster.
|
|
133
|
+
"""
|
|
134
|
+
records = [_as_record(n) for n in names]
|
|
135
|
+
result = EntityResolver(agency_mode=agency).resolve(records)
|
|
136
|
+
matches = result["matches"]
|
|
137
|
+
original = [str(r.get("name") or r.get("agency") or "") for r in records]
|
|
138
|
+
clusters: List[Cluster] = []
|
|
139
|
+
for e in sorted(result["entities"], key=lambda e: e["member_indices"][0]):
|
|
140
|
+
members = tuple(e["member_indices"])
|
|
141
|
+
member_set = set(members)
|
|
142
|
+
joins = sorted((m for m in matches if m["merged"] and m["right_index"] in member_set),
|
|
143
|
+
key=lambda m: (m["right_index"], -m["confidence"]))
|
|
144
|
+
refusals = [m for m in matches if not m["merged"]
|
|
145
|
+
and (m["left_index"] in member_set or m["right_index"] in member_set)]
|
|
146
|
+
explanations = [_describe(m) for m in joins + refusals]
|
|
147
|
+
if len(members) == 1 and not explanations:
|
|
148
|
+
norm = e["normalized_name"]
|
|
149
|
+
explanations.append(
|
|
150
|
+
f"no other record shares an identifier or the normalized name {norm!r}"
|
|
151
|
+
if norm else "no name and no identifier shared with another record")
|
|
152
|
+
clusters.append(Cluster(
|
|
153
|
+
entity_id=e["entity_id"],
|
|
154
|
+
canonical_name=e["canonical_name"],
|
|
155
|
+
normalized_name=e["normalized_name"],
|
|
156
|
+
members=members,
|
|
157
|
+
names=tuple(original[i] for i in members),
|
|
158
|
+
identifiers=dict(e["identifiers"]),
|
|
159
|
+
link_confidence=float(e["link_confidence"]),
|
|
160
|
+
explanations=tuple(explanations),
|
|
161
|
+
))
|
|
162
|
+
return clusters
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def match(a: Record, b: Record, *, agency: bool = False) -> MatchResult:
|
|
166
|
+
"""Decide whether two names (or records) are the same organization.
|
|
167
|
+
|
|
168
|
+
The decision is exactly the one :func:`resolve` makes for a pair: a shared
|
|
169
|
+
identifier matches outright; otherwise the names must be identical after
|
|
170
|
+
normalization and must not carry conflicting CIK, UEI, EIN or CAGE values.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
a: A name or a record mapping.
|
|
174
|
+
b: A name or a record mapping.
|
|
175
|
+
agency: Normalize as government agency names.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A :class:`MatchResult`. It is truthy when the two match.
|
|
179
|
+
"""
|
|
180
|
+
ra, rb = _as_record(a), _as_record(b)
|
|
181
|
+
resolver = EntityResolver(agency_mode=agency)
|
|
182
|
+
result = resolver.resolve([ra, rb])
|
|
183
|
+
fa, fb = resolver._features(ra), resolver._features(rb)
|
|
184
|
+
normalized = (fa["norm_name"], fb["norm_name"])
|
|
185
|
+
merged = [m for m in result["matches"] if m["merged"]]
|
|
186
|
+
refused = [m for m in result["matches"] if not m["merged"]]
|
|
187
|
+
|
|
188
|
+
reasons: List[str] = []
|
|
189
|
+
if merged:
|
|
190
|
+
best = max(merged, key=lambda m: m["confidence"])
|
|
191
|
+
for m in sorted(merged, key=lambda m: -m["confidence"]):
|
|
192
|
+
if m["method"].startswith("id:"):
|
|
193
|
+
reasons.append(f"both carry {m['method'][3:].upper()} {m['normalized']} "
|
|
194
|
+
f"(confidence {m['confidence']:.2f})")
|
|
195
|
+
else:
|
|
196
|
+
reasons.append(f"names are identical after normalization: {m['normalized']!r} "
|
|
197
|
+
f"(confidence {m['confidence']:.2f})")
|
|
198
|
+
reasons.extend(e for e in m["evidence"]
|
|
199
|
+
if not e.startswith("exact normalized name"))
|
|
200
|
+
if best["method"].startswith("id:"):
|
|
201
|
+
if normalized[0] and normalized[0] == normalized[1]:
|
|
202
|
+
reasons.append(f"names also agree after normalization ({normalized[0]!r})")
|
|
203
|
+
elif normalized[0] and normalized[1]:
|
|
204
|
+
reasons.append(f"names differ ({normalized[0]!r} vs {normalized[1]!r}); "
|
|
205
|
+
"the shared identifier decides")
|
|
206
|
+
return MatchResult(True, round(float(best["confidence"]), 2), best["method"],
|
|
207
|
+
tuple(reasons), normalized)
|
|
208
|
+
|
|
209
|
+
if refused:
|
|
210
|
+
return MatchResult(False, 0.0, "refused", (refused[0]["refusal_reason"],), normalized)
|
|
211
|
+
|
|
212
|
+
if not normalized[0] or not normalized[1]:
|
|
213
|
+
reasons.append("at least one record has no name to compare and no identifier is shared")
|
|
214
|
+
else:
|
|
215
|
+
reasons.append(f"normalized names differ: {normalized[0]!r} vs {normalized[1]!r} "
|
|
216
|
+
"(reckoner never matches on similarity)")
|
|
217
|
+
for key in STRONG_IDS:
|
|
218
|
+
va, vb = fa["ids"].get(key), fb["ids"].get(key)
|
|
219
|
+
if va and vb and va != vb:
|
|
220
|
+
reasons.append(f"different {key.upper()}: {va} vs {vb}")
|
|
221
|
+
return MatchResult(False, 0.0, "none", tuple(reasons), normalized)
|