Merged in feature/apply-postprocessing-to-test-bed (pull request #526)

Refactor CPT postprocessing, create notebook to postprocess test bed for MCS team

* Move comment

* Implement testbed postprocessing functions

* Refactor normalize CPT postprocessing

* remove old notebook, add new postprocess notebook

* Enhance normalize_cpt_fields to handle comma-separated values in lists

* Update normalize_cpt_fields to return an empty string for None values

* Add testbed postprocessing script, removed notebook


Approved-by: Katon Minhas
This commit is contained in:
Alex Galarce
2025-05-13 16:58:27 +00:00
parent cfcc5488e4
commit 384b3c5d31
6 changed files with 223 additions and 9467 deletions
@@ -195,18 +195,34 @@ def normalize_cpt_fields(value):
Returns:
list: A normalized list of string codes.
"""
if value is None or (isinstance(value, float) and pd.isna(value)): # Handle NaN values
result = []
elif isinstance(value, (list, tuple)): # If already a list or tuple
if not value or (isinstance(value, float) and pd.isna(value)): # Handle NaN values
return "" # Empty string
# First normalize to a list
if isinstance(value, (list, tuple)): # If already a list or tuple
result = [str(v).strip() for v in value]
elif isinstance(value, str): # If it's a string
value = value.strip()
if value.startswith("[") and value.endswith("]"): # String representation of a list
try:
result = [str(v).strip() for v in ast.literal_eval(value)]
# Parse it first
parsed_list = ast.literal_eval(value)
result = []
# Process each item in the parsed list
for item in parsed_list:
item_str = str(item).strip()
# If the item contains commas, split it into multiple items
if "," in item_str:
result.extend([str(v).strip() for v in item_str.split(",")])
else:
result.append(item_str)
except (ValueError, SyntaxError):
result = [value] # If parsing fails, treat it as a single value
elif "-" in value: # If it's a range
elif "," in value: # If it's a comma-separated string
# Split by comma and create a list
result = [str(v).strip() for v in value.split(",")]
elif "-" in value:
# Range format
result = [value]
else: # Single value
result = [value]
@@ -215,7 +231,7 @@ def normalize_cpt_fields(value):
else:
result = [str(value).strip()] # Default case for other types
return result # Return the list directly
return str(result) # Return the list directly
def update_reimb_prov_name(df):
@@ -13,8 +13,8 @@ def postprocess(df):
df = investment_postprocessing_funcs.date_postprocess(df, config.FIELD_JSON_PATH)
# Format rate fields
if "REIMB_FEE_RATE" in df.columns:
# Format rate fields
df['REIMB_FEE_RATE'] = df['REIMB_FEE_RATE'].apply(investment_postprocessing_funcs.format_rate_fields_with_commas)
if "REIMB_PCT_RATE" in df.columns:
df['REIMB_PCT_RATE'] = df['REIMB_PCT_RATE'].apply(investment_postprocessing_funcs.format_rate_fields_with_commas)
@@ -0,0 +1,25 @@
# This script postprocesses the testbed data from an Excel file. It uses a custom
# function `testbed_postprocess` from the `testbed_utils` module (similar to
# postprocess.postprocess() but skipping some steps)
# Run this from the fieldExtraction directory:
# poetry run python -m src.testbed.testbed_postprocess
#### CONFIG ####
testbed_filename = "src/testbed/v6_DoczyAI_all_test_bed_LIVE_5.6.25.xlsx"
testbed_sheetname = 'all_test_bed_labels'
output_filename = "src/testbed/v6_DoczyAI_all_test_bed_LIVE_5.6.25_POSTPROCESSED.xlsx"
#### IMPORTS ####
import pandas as pd
from src.testbed.testbed_utils import testbed_postprocess
#### EXECUTION ####
# Load the testbed
df = pd.read_excel(testbed_filename, sheet_name=testbed_sheetname)
print(f"Loaded testbed from {testbed_filename} with {df.shape[0]} rows and {df.shape[1]} columns")
# Postprocess the testbed
testbed_postprocess(df).to_excel(output_filename, index=False)
print(f"Postprocessed testbed saved to {output_filename}")
File diff suppressed because it is too large Load Diff
+168 -97
View File
@@ -5,6 +5,10 @@ import itertools
from collections import Counter
# Imports for the testbed postprocessing functions
from src.investment import investment_postprocessing_funcs
from src.constants.investment_columns import COLUMN_ORDER
def convert_to_float(value):
try:
@@ -29,7 +33,15 @@ def normalize_tin_npi(value):
return value
def testbed_preprocess(df):
df = df.applymap(convert_to_float)
# Apply numeric conversion to non-CPT/diag/rev fields only
for col in df.columns:
if "_CD" not in col and "CPT" not in col and "DIAG" not in col and "REV" not in col:
df[col] = df[col].apply(convert_to_float)
else:
# For CPT/diag/rev fields, apply string conversion AND convert | delimiters
# to commas
df[col] = df[col].astype(str).str.replace("|", ",", regex=False)
df = df.applymap(lambda x: str(x).strip().upper())
df = df.replace("UNKNOWN", "").fillna("")
df = df.replace("NAN", "")
@@ -50,101 +62,6 @@ def testbed_preprocess(df):
return df
def group_providers_into_json_array(df):
"""Groups provider information from a DataFrame into a JSON array format and appends it as a new column.
This function processes a DataFrame containing provider information, groups the data by the "FILE_NAME" column,
and creates a JSON array for each group. The JSON array includes details about the group provider and other
associated providers. The resulting JSON array is added as a new column, "PROV_INFO_JSON", to the DataFrame.
Args:
df (pandas.DataFrame): The input DataFrame containing the following columns:
- "FILE_NAME": Identifier for grouping rows.
- "PROV_GROUP_TIN": Tax Identification Number (TIN) of the group provider.
- "PROV_GROUP_NPI": National Provider Identifier (NPI) of the group provider.
- "PROV_GROUP_NAME_FULL": Full name of the group provider.
- "PROV_OTHER_TIN": Pipe-separated TINs of other providers.
- "PROV_OTHER_NPI": Pipe-separated NPIs of other providers.
- "PROV_OTHER_NAME_FULL": Pipe-separated full names of other providers.
Returns:
pandas.DataFrame: A modified DataFrame with an additional column "PROV_INFO_JSON",
which contains a JSON array for each "FILE_NAME" group. Each JSON array includes:
- "TIN": TIN of the provider (or "N/A" if not available).
- "NPI": NPI of the provider (or "N/A" if not available).
- "NAME": Full name of the provider (or "N/A" if not available).
- "IS_GROUP": Boolean indicating whether the provider is the group provider (True)
or an associated provider (False).
Notes:
- Missing or NaN values in the input DataFrame are replaced with "N/A" in the JSON output.
- If the lengths of "PROV_OTHER_TIN", "PROV_OTHER_NPI", and "PROV_OTHER_NAME_FULL" are not equal,
missing values are filled with "N/A" using itertools.zip_longest.
- If the OTHER columns are misaligned with one another, the function will NOT
be able to correct them. It will simply fill in the gaps with "N/A".
"""
df = df.copy()
# Create a dictionary to hold provider data in JSON array format for each file
group_providers_dict = {}
for group, grouped_df in df.groupby("FILE_NAME"):
json_dict_list = []
# Get the first row of the grouped DataFrame
first_row = grouped_df.iloc[0]
# Extract TIN/NPI/NAME for group provider.
group_tin = str(first_row["PROV_GROUP_TIN"]).replace("nan", "N/A")
group_npi = str(first_row["PROV_GROUP_NPI"]).replace("nan", "N/A")
group_name_full = str(first_row["PROV_GROUP_NAME_FULL"])
# Append the group provider's TIN/NPI/NAME to the list.
json_dict_list.append({
"TIN": group_tin,
"NPI": group_npi,
"NAME": group_name_full, "IS_GROUP": True})
# Create lists of TIN/NPI/NAME for other providers.
# Split on pipe, then strip whitespace.
other_tins = str(first_row["PROV_OTHER_TIN"]).split("|")
other_npis = str(first_row["PROV_OTHER_NPI"]).split("|")
other_names = str(first_row["PROV_OTHER_NAME_FULL"]).split("|")
other_tins = [tin.strip().replace("nan", "N/A") for tin in other_tins]
other_npis = [npi.strip().replace("nan", "N/A") for npi in other_npis]
other_names = [name.strip().replace("nan", "N/A") for name in other_names]
# Append each other provider's TIN/NPI/NAME to the list.
# Use zip to iterate over the three lists in parallel.
# IF the lengths of the lists are not equal, use itertools.zip_longest to fill in the gaps with N/A.
for tin, npi, name in itertools.zip_longest(other_tins, other_npis, other_names, fillvalue="N/A"):
json_dict_list.append({
"TIN": tin,
"NPI": npi,
"NAME": name,
"IS_GROUP": False
})
group_providers_dict[group] = json_dict_list
# Add JSON data back to original DataFrame
df["PROV_INFO_JSON"] = df["FILE_NAME"].map(group_providers_dict)
## Reorder columns to place PROV_INFO_JSON right after PROV_OTHER_NAME_FULL
cols = list(df.columns)
# Find the position of PROV_OTHER_NAME_FULL
prov_other_name_full_pos = cols.index("PROV_OTHER_NAME_FULL")
# Remove PROV_INFO_JSON from its current position
cols.remove("PROV_INFO_JSON")
# Insert it after PROV_OTHER_NAME_FULL
cols.insert(prov_other_name_full_pos + 1, "PROV_INFO_JSON")
# Reorder the DataFrame
df = df[cols]
return df
def calculate_field_metrics(testbed_df, results_df, field):
"""Calculate precision, recall, and accuracy for a single field.
@@ -557,4 +474,158 @@ def calculate_precision_recall(accuracies):
precision_df = pd.DataFrame(precision_dicts)
recall_df = pd.DataFrame(recall_dicts)
return precision_df, recall_df, overall_metrics_df
return precision_df, recall_df, overall_metrics_df
########## TESTBED POSTPROCESSING CODE ##########
# These functions are to take MCS's test bed that they provided and apply
# (most of) the same postprocessing steps that we apply to the results of the model.
def group_providers_into_json_array(df):
"""Groups provider information from a DataFrame into a JSON array format and appends it as a new column.
This function processes a DataFrame containing provider information, groups the data by the "FILE_NAME" column,
and creates a JSON array for each group. The JSON array includes details about the group provider and other
associated providers. The resulting JSON array is added as a new column, "PROV_INFO_JSON", to the DataFrame.
Args:
df (pandas.DataFrame): The input DataFrame containing the following columns:
- "FILE_NAME": Identifier for grouping rows.
- "PROV_GROUP_TIN": Tax Identification Number (TIN) of the group provider.
- "PROV_GROUP_NPI": National Provider Identifier (NPI) of the group provider.
- "PROV_GROUP_NAME_FULL": Full name of the group provider.
- "PROV_OTHER_TIN": Pipe-separated TINs of other providers.
- "PROV_OTHER_NPI": Pipe-separated NPIs of other providers.
- "PROV_OTHER_NAME_FULL": Pipe-separated full names of other providers.
Returns:
pandas.DataFrame: A modified DataFrame with an additional column "PROV_INFO_JSON",
which contains a JSON array for each "FILE_NAME" group. Each JSON array includes:
- "TIN": TIN of the provider (or "N/A" if not available).
- "NPI": NPI of the provider (or "N/A" if not available).
- "NAME": Full name of the provider (or "N/A" if not available).
- "IS_GROUP": Boolean indicating whether the provider is the group provider (True)
or an associated provider (False).
Notes:
- Missing or NaN values in the input DataFrame are replaced with "N/A" in the JSON output.
- If the lengths of "PROV_OTHER_TIN", "PROV_OTHER_NPI", and "PROV_OTHER_NAME_FULL" are not equal,
missing values are filled with "N/A" using itertools.zip_longest.
- If the OTHER columns are misaligned with one another, the function will NOT
be able to correct them. It will simply fill in the gaps with "N/A".
"""
df = df.copy()
# Create a dictionary to hold provider data in JSON array format for each file
group_providers_dict = {}
for group, grouped_df in df.groupby("FILE_NAME"):
json_dict_list = []
# Get the first row of the grouped DataFrame
first_row = grouped_df.iloc[0]
# Extract TIN/NPI/NAME for group provider.
group_tin = str(first_row["PROV_GROUP_TIN"]).replace("nan", "N/A")
group_npi = str(first_row["PROV_GROUP_NPI"]).replace("nan", "N/A")
group_name_full = str(first_row["PROV_GROUP_NAME_FULL"])
# Append the group provider's TIN/NPI/NAME to the list.
json_dict_list.append({
"TIN": group_tin,
"NPI": group_npi,
"NAME": group_name_full, "IS_GROUP": True})
# Create lists of TIN/NPI/NAME for other providers.
# Split on pipe, then strip whitespace.
other_tins = str(first_row["PROV_OTHER_TIN"]).split("|")
other_npis = str(first_row["PROV_OTHER_NPI"]).split("|")
other_names = str(first_row["PROV_OTHER_NAME_FULL"]).split("|")
other_tins = [tin.strip().replace("nan", "N/A") for tin in other_tins]
other_npis = [npi.strip().replace("nan", "N/A") for npi in other_npis]
other_names = [name.strip().replace("nan", "N/A") for name in other_names]
# Append each other provider's TIN/NPI/NAME to the list.
# Use zip to iterate over the three lists in parallel.
# IF the lengths of the lists are not equal, use itertools.zip_longest to fill in the gaps with N/A.
for tin, npi, name in itertools.zip_longest(other_tins, other_npis, other_names, fillvalue="N/A"):
json_dict_list.append({
"TIN": tin,
"NPI": npi,
"NAME": name,
"IS_GROUP": False
})
group_providers_dict[group] = json_dict_list
# Add JSON data back to original DataFrame
df["PROV_INFO_JSON"] = df["FILE_NAME"].map(group_providers_dict)
## Reorder columns to place PROV_INFO_JSON right after PROV_OTHER_NAME_FULL
cols = list(df.columns)
# Find the position of PROV_OTHER_NAME_FULL
prov_other_name_full_pos = cols.index("PROV_OTHER_NAME_FULL")
# Remove PROV_INFO_JSON from its current position
cols.remove("PROV_INFO_JSON")
# Insert it after PROV_OTHER_NAME_FULL
cols.insert(prov_other_name_full_pos + 1, "PROV_INFO_JSON")
# Reorder the DataFrame
df = df[cols]
return df
def testbed_postprocess(df):
"""Postprocess the testbed DataFrame.
Args:
df (pandas.DataFrame): The input DataFrame containing the testbed data.
Returns:
pandas.DataFrame: The postprocessed DataFrame.
"""
# Apply preprocessing steps
df = testbed_preprocess(df)
# Group providers into JSON array format
df = group_providers_into_json_array(df)
# Add CLIENT_NAME column
df['CLIENT_NAME'] = "AARETE_TESTBED"
# Reformat rate fields
if "REIMB_FEE_RATE" in df.columns:
# Format rate fields
df['REIMB_FEE_RATE'] = df['REIMB_FEE_RATE'].apply(investment_postprocessing_funcs.format_rate_fields_with_commas)
if "REIMB_PCT_RATE" in df.columns:
df['REIMB_PCT_RATE'] = df['REIMB_PCT_RATE'].apply(investment_postprocessing_funcs.format_rate_fields_with_commas)
for col in df.columns:
if "_IND" in col:
df[col] = df[col].apply(investment_postprocessing_funcs.normalize_indicator_field)
# Normalize _IND fields and clean up TIN/NPI fields
if "TIN" in col or "NPI" in col:
df[col] = df[col].apply(investment_postprocessing_funcs.remove_hyphens)
# Apply the flatten_singleton_string_list function
if "_CD" in col and "CPT" not in col:
df[col] = df[col].apply(investment_postprocessing_funcs.flatten_singleton_string_list)
# Convert DATE to YYYY/MM/DD format
if "_DT" in col or "DATE" in col:
df[col] = df[col].apply(investment_postprocessing_funcs.validate_and_reformat_date)
# Normalize CPT fields
if "CPT" in col:
df[col] = df[col].apply(investment_postprocessing_funcs.normalize_cpt_fields)
df = investment_postprocessing_funcs.update_reimb_prov_name(df)
# Add reimb_id
df = investment_postprocessing_funcs.generate_reimb_ids(df)
# Standardize output column order - this should ALWAYS be the final postprocessing step
df = investment_postprocessing_funcs.reorder_columns(df, COLUMN_ORDER)
return df
@@ -100,12 +100,13 @@ class TestPostprocessFunctions(unittest.TestCase):
self.assertEqual(normalize_auto_renewal_term(None), "")
def test_normalize_cpt_fields(self):
self.assertEqual(normalize_cpt_fields("[123, 456]"), ["123", "456"])
self.assertEqual(normalize_cpt_fields("123-456"), ["123-456"])
self.assertEqual(normalize_cpt_fields("123"), ["123"])
self.assertEqual(normalize_cpt_fields(None), [])
self.assertEqual(normalize_cpt_fields(123), ["123"])
self.assertEqual(normalize_cpt_fields("[123, 456]"), "['123', '456']")
self.assertEqual(normalize_cpt_fields("123-456"), "['123-456']")
self.assertEqual(normalize_cpt_fields("['T0000-T9999, S0000-S9999']"), "['T0000-T9999', 'S0000-S9999']")
self.assertEqual(normalize_cpt_fields("123"), "['123']")
self.assertEqual(normalize_cpt_fields(None), "")
self.assertEqual(normalize_cpt_fields(123), "['123']")
def test_process_patient_age_range(self):
"""Tests the process_patient_age_range function with various age range formats.