Merged in hotfix/provider_name_group_fix (pull request #988)

Hotfix/provider name group fix

* Fixes done in GROUP column

* black format fix


Approved-by: Katon Minhas
This commit is contained in:
Rahul Ailaboina
2026-04-27 21:55:21 +00:00
committed by Katon Minhas
parent e4650064aa
commit f9464fc8c6
+121 -45
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import ast
import json import json
import pandas as pd import pandas as pd
import re import re
@@ -28,38 +29,122 @@ _MODULE_DIR = Path(__file__).resolve().parent
_CONSTANTS_DIR = _MODULE_DIR.parent / "constants" / "mappings" _CONSTANTS_DIR = _MODULE_DIR.parent / "constants" / "mappings"
def _build_provider_group_label(df: pd.DataFrame) -> pd.Series: _NAME_KEY_STOPWORDS = frozenset({"and", "of", "the", "a", "an"})
"""Human-readable grouping label for reviewers.
Uses the cleaned provider name, title-cased. When the same name appears
with more than one TIN in the dataset, disambiguate by appending the TIN def _normalize_provider_name_key(s: str) -> str:
(e.g. "Storr Medical Center - 123456789"). Falls back to grouping_key """Lowercase, strip punctuation, drop short connector words. Lets
for rows with no usable provider name. "CHILDREN'S HOSPITALS AND CLINICS" and "CHILDREN S HOSPITALS CLINICS"
match as the same provider."""
if not s:
return ""
s = re.sub(r"[^a-z0-9]+", " ", s.lower())
return " ".join(t for t in s.split() if t and t not in _NAME_KEY_STOPWORDS)
def _build_provider_group_label(df: pd.DataFrame) -> pd.Series:
"""Title-cased provider name for the Group column.
Clusters rows by shared TIN, then folds no-TIN rows into a TIN cluster
when their normalized name matches uniquely. Picks the longest name
variant per cluster as canonical. Source columns are never written.
""" """
name_col = "PROV_GROUP_NAME_FULL_cleaned" name_col = "PROV_GROUP_NAME_FULL_cleaned"
tin_col = "PROV_GROUP_TIN" tin_col = "PROV_GROUP_TIN"
other_tin_col = "PROV_OTHER_TIN"
if name_col not in df.columns: if name_col not in df.columns:
return df.get("grouping_key", pd.Series([""] * len(df), index=df.index)).astype( return df.get("grouping_key", pd.Series([""] * len(df), index=df.index)).astype(
"string" "string"
) )
name = df[name_col].fillna("").astype(str).str.strip() raw_name = df[name_col].fillna("").astype(str).str.strip()
tin = (
df[tin_col].fillna("").astype(str).str.strip() n = len(df)
if tin_col in df.columns parent_arr = list(range(n))
else pd.Series([""] * len(df), index=df.index)
def _find(i: int) -> int:
while parent_arr[i] != i:
parent_arr[i] = parent_arr[parent_arr[i]]
i = parent_arr[i]
return i
def _union(i: int, j: int) -> None:
ri, rj = _find(i), _find(j)
if ri != rj:
parent_arr[ri] = rj
group_tin_series = (
df[tin_col] if tin_col in df.columns else pd.Series([None] * n, index=df.index)
)
other_tin_series = (
df[other_tin_col]
if other_tin_col in df.columns
else pd.Series([None] * n, index=df.index)
) )
# Count distinct non-empty TINs per name; names with >1 TIN need # Stage 1: union rows sharing any TIN.
# disambiguation. row_tin_sets: List[set] = []
mask = (name != "") & (tin != "") tin_to_anchor_pos: Dict[str, int] = {}
tins_per_name: Dict[str, int] = ( for pos in range(n):
pd.DataFrame({"_n": name[mask], "_t": tin[mask]}) idx = df.index[pos]
.groupby("_n")["_t"] tins = _parse_identifier_set(group_tin_series.loc[idx]) | _parse_identifier_set(
.nunique() other_tin_series.loc[idx]
.to_dict() )
) row_tin_sets.append(tins)
for t in tins:
anchor = tin_to_anchor_pos.get(t)
if anchor is None:
tin_to_anchor_pos[t] = pos
else:
_union(anchor, pos)
# Stage 2: fold no-TIN rows into a TIN cluster by normalized-name match.
# Skip if the name maps to multiple TIN clusters — those are likely
# different providers and shouldn't be merged.
norm_keys: List[str] = [
_normalize_provider_name_key(raw_name.iloc[p]) for p in range(n)
]
key_to_rows: Dict[str, List[int]] = {}
for pos, k in enumerate(norm_keys):
if k:
key_to_rows.setdefault(k, []).append(pos)
for positions in key_to_rows.values():
tin_roots = set()
no_tin_rows: List[int] = []
for pos in positions:
if row_tin_sets[pos]:
tin_roots.add(_find(pos))
else:
no_tin_rows.append(pos)
if len(tin_roots) == 1:
target = next(iter(tin_roots))
for pos in no_tin_rows:
_union(pos, target)
elif not tin_roots and len(no_tin_rows) > 1:
anchor = no_tin_rows[0]
for pos in no_tin_rows[1:]:
_union(anchor, pos)
# Pick canonical name per cluster: longest wins, frequency then alpha tiebreak.
cluster_name_counts: Dict[int, Dict[str, int]] = {}
for pos in range(n):
nm = raw_name.iloc[pos]
if not nm:
continue
root = _find(pos)
bucket = cluster_name_counts.setdefault(root, {})
bucket[nm] = bucket.get(nm, 0) + 1
cluster_canonical: Dict[int, str] = {
root: min(counts.items(), key=lambda kv: (-len(kv[0]), -kv[1], kv[0]))[0]
for root, counts in cluster_name_counts.items()
}
canonical_name = [
cluster_canonical.get(_find(pos), raw_name.iloc[pos]) for pos in range(n)
]
fallback = ( fallback = (
df["grouping_key"].fillna("").astype(str) df["grouping_key"].fillna("").astype(str)
@@ -67,16 +152,11 @@ def _build_provider_group_label(df: pd.DataFrame) -> pd.Series:
else pd.Series([""] * len(df), index=df.index) else pd.Series([""] * len(df), index=df.index)
) )
def _label(n: str, t: str, fb: str) -> str:
if not n:
return fb
display = n.title()
if tins_per_name.get(n, 0) > 1 and t:
return f"{display} - {t}"
return display
return pd.Series( return pd.Series(
[_label(n, t, fb) for n, t, fb in zip(name, tin, fallback)], [
canonical_name[pos].title() if canonical_name[pos] else fallback.iloc[pos]
for pos in range(n)
],
index=df.index, index=df.index,
dtype="string", dtype="string",
) )
@@ -378,17 +458,10 @@ def build_grouping_string(child_row, grouping_cols):
def _parse_identifier_set(value) -> set: def _parse_identifier_set(value) -> set:
"""Normalize a TIN/NPI field into a set of individual ID strings. """Parse a TIN/NPI cell into a set of IDs.
Source data often stores identifiers as JSON-encoded lists Accepts JSON ['["a","b"]'], Python-repr ['['a','b']'], pipe-separated
(e.g. `'["133757370", "840611484"]'`) because a single contract can ('a | b'), plain scalar, or empty/NaN.
span multiple legal entities. Plain scalars (`'133757370'`) are also
supported. Returns an empty set for NaN / empty / "nan" values.
Normalizing to a set lets downstream matchers use intersection
instead of string equality — two rows that share a TIN compare as
compatible even if one encodes it as `["A", "B"]` and the other as
`["B", "C", "D"]`.
""" """
if value is None: if value is None:
return set() return set()
@@ -400,15 +473,18 @@ def _parse_identifier_set(value) -> set:
s = str(value).strip() s = str(value).strip()
if not s or s.lower() == "nan": if not s or s.lower() == "nan":
return set() return set()
# JSON list (the common case from upstream extraction).
if s.startswith("[") and s.endswith("]"): if s.startswith("[") and s.endswith("]"):
try: for loader in (json.loads, ast.literal_eval):
parsed = json.loads(s) try:
parsed = loader(s)
except (json.JSONDecodeError, ValueError, SyntaxError):
continue
if isinstance(parsed, list): if isinstance(parsed, list):
return {str(v).strip() for v in parsed if str(v).strip()} return {str(v).strip() for v in parsed if str(v).strip()}
except (json.JSONDecodeError, ValueError): if "|" in s:
pass parts = {p.strip() for p in s.split("|") if p.strip()}
# Plain scalar — wrap as a singleton set. if parts:
return parts
return {s} return {s}