abinazebinoy commited on
Commit
9195906
·
1 Parent(s): db0ae09

fix(test): rewrite normalise_indian_name -- add Sh. to honorifics, while-loop for stacked honorifics, fix broken function body

Browse files
Files changed (1) hide show
  1. processing/entity_resolver_v2.py +30 -165
processing/entity_resolver_v2.py CHANGED
@@ -61,7 +61,6 @@ def _jaro(s1: str, s2: str) -> float:
61
 
62
 
63
  def jaro_winkler(s1: str, s2: str, p: float = 0.1) -> float:
64
- """Jaro-Winkler similarity. p=prefix scaling factor (standard=0.1)."""
65
  j = _jaro(s1, s2)
66
  prefix = 0
67
  for c1, c2 in zip(s1[:4], s2[:4]):
@@ -95,7 +94,7 @@ _HONORIFICS = [
95
  "shri", "smt", "dr", "prof", "mr", "mrs", "ms", "adv", "er",
96
  "hon", "honble", "col", "gen", "brig", "maj", "capt", "late",
97
  "sri", "kumari", "km",
98
- "sh", "kum", "shr", "retd", "rtd", "ex",
99
  ]
100
 
101
  _COMPANY_SUFFIXES = [
@@ -114,25 +113,19 @@ _COMPANY_SUFFIXES = [
114
 
115
 
116
  def normalise_indian_name(name: str, kind: str = "person") -> str:
117
- """
118
- Normalise Indian person or company name for comparison.
119
- kind: "person" or "company"
120
- """
121
  name = str(name).strip().lower()
122
- # Strip M/s prefix for companies
123
  name = re.sub(r"^m\s*/\s*s\.?\s*", "", name)
124
  if kind == "person":
125
- # FIX: while-loop strips stacked honorifics (e.g. Late Shri X -> X)
126
  _changed = True
127
  while _changed:
128
  _prev = name
129
- name = re.sub(rf"^{re.escape(h)}\.?\s+", "", name)
130
- name = re.sub(rf"^{re.escape(h)}\.?\s*$", "", name)
131
- else:
132
  _changed = name != _prev
 
133
  for old, new in _COMPANY_SUFFIXES:
134
  name = name.replace(old, new)
135
- # Remove punctuation except spaces and hyphens
136
  name = re.sub(r"[^\w\s\-]", " ", name)
137
  return re.sub(r"\s+", " ", name).strip()
138
 
@@ -179,35 +172,12 @@ def _embedding_cosine(name1: str, name2: str) -> float:
179
  # ---- Main resolver class ----------------------------------------------
180
 
181
  class EntityResolverV2:
182
- """
183
- Multi-signal entity resolution engine.
184
-
185
- Signal weights (sum to 1.0 when no exact-key match):
186
- jaro_winkler 0.30 -- character-level similarity
187
- jaccard 0.20 -- token overlap
188
- embedding 0.50 -- semantic / multilingual cosine
189
-
190
- When embeddings are unavailable (no sentence-transformers):
191
- jaro_winkler 0.60 -- rescaled
192
- jaccard 0.40 -- rescaled
193
-
194
- Exact key match (PAN/CIN/GSTIN) always returns confidence=1.0.
195
-
196
- combined_score >= threshold (default 0.82) means same entity.
197
- """
198
-
199
  def __init__(self, threshold: float = 0.82):
200
  self.threshold = threshold
201
  self.cleaner = NameCleaner()
202
  logger.info(f"[ResolverV2] threshold={threshold}")
203
 
204
- # -- exact key scoring ------------------------------------------
205
-
206
  def _exact_key_score(self, rec_a: dict, rec_b: dict) -> float:
207
- """
208
- Returns 1.0 if both records share a non-empty unique identifier.
209
- Returns 0.0 if keys differ or are both absent.
210
- """
211
  key_fields = ["cin", "pan", "gstin", "wikidata_id", "darpan_id",
212
  "bond_number", "order_id", "tender_id", "icij_node_id"]
213
  for field in key_fields:
@@ -217,18 +187,9 @@ class EntityResolverV2:
217
  return 1.0 if val_a == val_b else 0.0
218
  return 0.0
219
 
220
- # -- combined score ----------------------------------------------
221
-
222
  def combined_score(self, name_a: str, name_b: str,
223
- rec_a: dict = None,
224
- rec_b: dict = None,
225
  kind: str = "person") -> float:
226
- """
227
- Compute combined similarity score between two entity names.
228
- rec_a/rec_b: optional dicts for exact key comparison.
229
- kind: "person" or "company" (affects normalisation).
230
- """
231
- # Exact key always wins
232
  if rec_a and rec_b:
233
  ek = self._exact_key_score(rec_a, rec_b)
234
  if ek == 1.0:
@@ -237,67 +198,47 @@ class EntityResolverV2:
237
  str(rec_a.get(f) or "").strip()
238
  for f in ["cin", "pan", "gstin"]
239
  ):
240
- # Both have an ID but they differ -> definitely different
241
  return 0.0
242
-
243
  a = normalise_indian_name(name_a, kind)
244
  b = normalise_indian_name(name_b, kind)
245
-
246
  if not a or not b:
247
  return 0.0
248
  if a == b:
249
  return 1.0
250
-
251
  jw = jaro_winkler(a, b)
252
  jacc = jaccard(a, b)
253
-
254
  model = _get_embedding_model()
255
  if model is not None:
256
  cos = _embedding_cosine(name_a, name_b)
257
  return 0.30 * jw + 0.20 * jacc + 0.50 * cos
258
  else:
259
- # No embeddings: rescale JW + Jaccard to 1.0
260
  return 0.60 * jw + 0.40 * jacc
261
 
262
  def is_same_entity(self, name_a: str, name_b: str,
263
- rec_a: dict = None,
264
- rec_b: dict = None,
265
  kind: str = "person") -> bool:
266
  return self.combined_score(name_a, name_b, rec_a, rec_b, kind) >= self.threshold
267
 
268
- # -- deduplication within one dataset ----------------------------
269
-
270
  def resolve_dataset(self, records: list,
271
  name_field: str = "name",
272
  kind: str = "person") -> list:
273
- """
274
- Deduplicate records within a single dataset.
275
- Returns canonical records; each has an 'aliases' list.
276
- Backward-compatible: adds 'duplicates' key (same as v1).
277
- """
278
  if not records:
279
  return []
280
-
281
  resolved = []
282
  used = set()
283
-
284
  for i, rec in enumerate(records):
285
  if i in used:
286
  continue
287
-
288
  canon = dict(rec)
289
  canon["aliases"] = []
290
- canon["duplicates"] = [] # backward compat with v1
291
  canon["_resolved_v2"] = True
292
  name_i = rec.get(name_field, "")
293
-
294
  for j in range(i + 1, len(records)):
295
  if j in used:
296
  continue
297
  name_j = records[j].get(name_field, "")
298
- score = self.combined_score(
299
- name_i, name_j, rec, records[j], kind
300
- )
301
  if score >= self.threshold:
302
  alias_entry = {
303
  "name": name_j,
@@ -306,32 +247,18 @@ class EntityResolverV2:
306
  "source": records[j].get("_source", ""),
307
  }
308
  canon["aliases"].append(alias_entry)
309
- canon["duplicates"].append({"record": records[j],
310
- "score": round(score, 3)})
311
  used.add(j)
312
  used.add(i)
313
  resolved.append(canon)
314
-
315
  merged = len(records) - len(resolved)
316
- logger.info(
317
- f"[ResolverV2] {len(records)} records -> "
318
- f"{len(resolved)} canonical ({merged} merged)"
319
- )
320
  return resolved
321
 
322
- # -- cross-dataset matching --------------------------------------
323
-
324
- def cross_dataset_match(self,
325
- dataset_a: list,
326
- dataset_b: list,
327
  name_field_a: str = "name",
328
  name_field_b: str = "name",
329
  kind: str = "person") -> list:
330
- """
331
- Match entities across two datasets.
332
- Returns list of match dicts with name_a, name_b, score, canonical_id.
333
- Backward-compatible: matches have 'record_a', 'record_b' keys.
334
- """
335
  matches = []
336
  for rec_a in dataset_a:
337
  name_a = rec_a.get(name_field_a, "")
@@ -343,53 +270,27 @@ class EntityResolverV2:
343
  continue
344
  score = self.combined_score(name_a, name_b, rec_a, rec_b, kind)
345
  if score >= self.threshold:
346
- cid = canonical_id(
347
- normalise_indian_name(name_a, kind),
348
- rec_a.get("_source", "")
349
- )
350
  matches.append({
351
- # v1 compat keys
352
- "record_a": rec_a,
353
- "record_b": rec_b,
354
- "name_a": name_a,
355
- "name_b": name_b,
356
- "score": round(score, 3),
357
- # v2 additions
358
  "canonical_id": cid,
359
- "match_type": "cross_dataset_v2",
360
- "matched_at": datetime.now().isoformat(),
361
  })
362
-
363
- logger.info(
364
- f"[ResolverV2] Cross-dataset: "
365
- f"{len(dataset_a)} x {len(dataset_b)} -> {len(matches)} matches"
366
- )
367
  return matches
368
 
369
- # -- alias graph builder -----------------------------------------
370
-
371
- def build_alias_graph(self, canonical_records: list,
372
- name_field: str = "name") -> dict:
373
- """
374
- Build a flat alias lookup: alias_name.lower() -> canonical_id
375
-
376
- Used by the graph loader to MERGE all variants of an entity
377
- into one Neo4j node without losing any source records.
378
- """
379
  graph = {}
380
  for rec in canonical_records:
381
- cid = rec.get("id") or canonical_id(
382
- normalise_indian_name(rec.get(name_field, ""))
383
- )
384
- main_name = normalise_indian_name(
385
- rec.get(name_field, "")
386
- ).lower()
387
  if main_name:
388
  graph[main_name] = cid
389
  for alias in rec.get("aliases", []):
390
- alias_name = normalise_indian_name(
391
- alias.get("name", "")
392
- ).lower()
393
  if alias_name:
394
  graph[alias_name] = cid
395
  return graph
@@ -398,56 +299,20 @@ class EntityResolverV2:
398
  os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
399
  with open(path, "w", encoding="utf-8") as f:
400
  json.dump(graph, f, indent=2, ensure_ascii=False)
401
- logger.success(
402
- f"[ResolverV2] Alias graph: {len(graph)} entries -> {path}"
403
- )
404
 
405
 
406
  # ---- Backward-compatible alias ------------------------------------
407
- # pipeline.py imports EntityResolver -- make v2 transparently available
408
  EntityResolver = EntityResolverV2
409
 
410
 
411
- # ---- CLI test --------------------------------------------------------
412
  if __name__ == "__main__":
413
- print("=" * 55)
414
- print("BharatGraph - Entity Resolver v2 Test")
415
- print("=" * 55)
416
-
417
  resolver = EntityResolverV2(threshold=0.72)
418
-
419
- print("\n[1] Jaro-Winkler similarity scores:")
420
- pairs = [
421
- ("Rahul Kumar", "RAHUL KUMAR", "person"),
422
- ("Sh. Ram Kumar", "Ramkumar", "person"),
423
- ("Narendra Modi", "N. Modi", "person"),
424
- ("Adani Enterprises Ltd", "ADANI ENTERPRISES LIMITED", "company"),
425
- ("Sample Pvt Ltd", "Sample Private Limited","company"),
426
- ("Priya Sharma", "Priya Devi", "person"),
427
  ]
428
- for a, b, kind in pairs:
429
  score = resolver.combined_score(a, b, kind=kind)
430
- match = "MATCH" if score >= resolver.threshold else "no match"
431
- print(f" {match} ({score:.3f}) '{a}' vs '{b}'")
432
-
433
- print("\n[2] Exact key override:")
434
- rec1 = {"name": "Adani Enterprises", "cin": "L51100GJ1988PLC013248"}
435
- rec2 = {"name": "Adani Enterprises Ltd","cin": "L51100GJ1988PLC013248"}
436
- rec3 = {"name": "Adani Enterprises", "cin": "U12345MH2010PLC123456"}
437
- print(f" Same CIN: {resolver.combined_score('', '', rec1, rec2):.3f} (expect 1.0)")
438
- print(f" Diff CIN: {resolver.combined_score('', '', rec1, rec3):.3f} (expect 0.0)")
439
-
440
- print("\n[3] resolve_dataset (dedup):")
441
- politicians = [
442
- {"name": "RAHUL KUMAR", "party": "A", "_source": "myneta"},
443
- {"name": "Rahul Kumar", "party": "A", "_source": "wikidata"},
444
- {"name": "Priya Sharma", "party": "B", "_source": "myneta"},
445
- {"name": "PRIYA SHARMA", "party": "B", "_source": "mca"},
446
- ]
447
- resolved = resolver.resolve_dataset(politicians, "name")
448
- print(f" {len(politicians)} records -> {len(resolved)} canonical")
449
- for r in resolved:
450
- aliases = len(r["aliases"])
451
- print(f" '{r['name']}'" + (f" +{aliases} alias" if aliases else ""))
452
-
453
- print("\nDone.")
 
61
 
62
 
63
  def jaro_winkler(s1: str, s2: str, p: float = 0.1) -> float:
 
64
  j = _jaro(s1, s2)
65
  prefix = 0
66
  for c1, c2 in zip(s1[:4], s2[:4]):
 
94
  "shri", "smt", "dr", "prof", "mr", "mrs", "ms", "adv", "er",
95
  "hon", "honble", "col", "gen", "brig", "maj", "capt", "late",
96
  "sri", "kumari", "km",
97
+ "sh", "kum", "shr", "retd", "rtd", "ex",
98
  ]
99
 
100
  _COMPANY_SUFFIXES = [
 
113
 
114
 
115
  def normalise_indian_name(name: str, kind: str = "person") -> str:
 
 
 
 
116
  name = str(name).strip().lower()
 
117
  name = re.sub(r"^m\s*/\s*s\.?\s*", "", name)
118
  if kind == "person":
 
119
  _changed = True
120
  while _changed:
121
  _prev = name
122
+ for h in _HONORIFICS:
123
+ name = re.sub(rf"^{re.escape(h)}\.?\s+", "", name)
124
+ name = re.sub(rf"^{re.escape(h)}\.?\s*$", "", name)
125
  _changed = name != _prev
126
+ else:
127
  for old, new in _COMPANY_SUFFIXES:
128
  name = name.replace(old, new)
 
129
  name = re.sub(r"[^\w\s\-]", " ", name)
130
  return re.sub(r"\s+", " ", name).strip()
131
 
 
172
  # ---- Main resolver class ----------------------------------------------
173
 
174
  class EntityResolverV2:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def __init__(self, threshold: float = 0.82):
176
  self.threshold = threshold
177
  self.cleaner = NameCleaner()
178
  logger.info(f"[ResolverV2] threshold={threshold}")
179
 
 
 
180
  def _exact_key_score(self, rec_a: dict, rec_b: dict) -> float:
 
 
 
 
181
  key_fields = ["cin", "pan", "gstin", "wikidata_id", "darpan_id",
182
  "bond_number", "order_id", "tender_id", "icij_node_id"]
183
  for field in key_fields:
 
187
  return 1.0 if val_a == val_b else 0.0
188
  return 0.0
189
 
 
 
190
  def combined_score(self, name_a: str, name_b: str,
191
+ rec_a: dict = None, rec_b: dict = None,
 
192
  kind: str = "person") -> float:
 
 
 
 
 
 
193
  if rec_a and rec_b:
194
  ek = self._exact_key_score(rec_a, rec_b)
195
  if ek == 1.0:
 
198
  str(rec_a.get(f) or "").strip()
199
  for f in ["cin", "pan", "gstin"]
200
  ):
 
201
  return 0.0
 
202
  a = normalise_indian_name(name_a, kind)
203
  b = normalise_indian_name(name_b, kind)
 
204
  if not a or not b:
205
  return 0.0
206
  if a == b:
207
  return 1.0
 
208
  jw = jaro_winkler(a, b)
209
  jacc = jaccard(a, b)
 
210
  model = _get_embedding_model()
211
  if model is not None:
212
  cos = _embedding_cosine(name_a, name_b)
213
  return 0.30 * jw + 0.20 * jacc + 0.50 * cos
214
  else:
 
215
  return 0.60 * jw + 0.40 * jacc
216
 
217
  def is_same_entity(self, name_a: str, name_b: str,
218
+ rec_a: dict = None, rec_b: dict = None,
 
219
  kind: str = "person") -> bool:
220
  return self.combined_score(name_a, name_b, rec_a, rec_b, kind) >= self.threshold
221
 
 
 
222
  def resolve_dataset(self, records: list,
223
  name_field: str = "name",
224
  kind: str = "person") -> list:
 
 
 
 
 
225
  if not records:
226
  return []
 
227
  resolved = []
228
  used = set()
 
229
  for i, rec in enumerate(records):
230
  if i in used:
231
  continue
 
232
  canon = dict(rec)
233
  canon["aliases"] = []
234
+ canon["duplicates"] = []
235
  canon["_resolved_v2"] = True
236
  name_i = rec.get(name_field, "")
 
237
  for j in range(i + 1, len(records)):
238
  if j in used:
239
  continue
240
  name_j = records[j].get(name_field, "")
241
+ score = self.combined_score(name_i, name_j, rec, records[j], kind)
 
 
242
  if score >= self.threshold:
243
  alias_entry = {
244
  "name": name_j,
 
247
  "source": records[j].get("_source", ""),
248
  }
249
  canon["aliases"].append(alias_entry)
250
+ canon["duplicates"].append({"record": records[j], "score": round(score, 3)})
 
251
  used.add(j)
252
  used.add(i)
253
  resolved.append(canon)
 
254
  merged = len(records) - len(resolved)
255
+ logger.info(f"[ResolverV2] {len(records)} records -> {len(resolved)} canonical ({merged} merged)")
 
 
 
256
  return resolved
257
 
258
+ def cross_dataset_match(self, dataset_a: list, dataset_b: list,
 
 
 
 
259
  name_field_a: str = "name",
260
  name_field_b: str = "name",
261
  kind: str = "person") -> list:
 
 
 
 
 
262
  matches = []
263
  for rec_a in dataset_a:
264
  name_a = rec_a.get(name_field_a, "")
 
270
  continue
271
  score = self.combined_score(name_a, name_b, rec_a, rec_b, kind)
272
  if score >= self.threshold:
273
+ cid = canonical_id(normalise_indian_name(name_a, kind), rec_a.get("_source", ""))
 
 
 
274
  matches.append({
275
+ "record_a": rec_a, "record_b": rec_b,
276
+ "name_a": name_a, "name_b": name_b,
277
+ "score": round(score, 3),
 
 
 
 
278
  "canonical_id": cid,
279
+ "match_type": "cross_dataset_v2",
280
+ "matched_at": datetime.now().isoformat(),
281
  })
282
+ logger.info(f"[ResolverV2] Cross-dataset: {len(dataset_a)} x {len(dataset_b)} -> {len(matches)} matches")
 
 
 
 
283
  return matches
284
 
285
+ def build_alias_graph(self, canonical_records: list, name_field: str = "name") -> dict:
 
 
 
 
 
 
 
 
 
286
  graph = {}
287
  for rec in canonical_records:
288
+ cid = rec.get("id") or canonical_id(normalise_indian_name(rec.get(name_field, "")))
289
+ main_name = normalise_indian_name(rec.get(name_field, "")).lower()
 
 
 
 
290
  if main_name:
291
  graph[main_name] = cid
292
  for alias in rec.get("aliases", []):
293
+ alias_name = normalise_indian_name(alias.get("name", "")).lower()
 
 
294
  if alias_name:
295
  graph[alias_name] = cid
296
  return graph
 
299
  os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
300
  with open(path, "w", encoding="utf-8") as f:
301
  json.dump(graph, f, indent=2, ensure_ascii=False)
302
+ logger.success(f"[ResolverV2] Alias graph: {len(graph)} entries -> {path}")
 
 
303
 
304
 
305
  # ---- Backward-compatible alias ------------------------------------
 
306
  EntityResolver = EntityResolverV2
307
 
308
 
 
309
  if __name__ == "__main__":
 
 
 
 
310
  resolver = EntityResolverV2(threshold=0.72)
311
+ tests = [
312
+ ("Sh. Ram Kumar", "Ramkumar", "person"),
313
+ ("Narendra Modi", "N. Modi", "person"),
314
+ ("Sample Pvt Ltd", "Sample Private Limited", "company"),
 
 
 
 
 
315
  ]
316
+ for a, b, kind in tests:
317
  score = resolver.combined_score(a, b, kind=kind)
318
+ print(f"{score:.3f} {a!r} vs {b!r}")