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:
committed by
Katon Minhas
parent
e4650064aa
commit
f9464fc8c6
+121
-45
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user