Merged in feature/testbed-updates (pull request #589)

Feature/testbed updates

* Begin refactor

* Update print

* finalize testbed script


Approved-by: Alex Galarce
This commit is contained in:
Katon Minhas
2025-06-25 15:57:24 +00:00
parent c6313460fc
commit 6d27b17819
2 changed files with 416 additions and 357 deletions
+19 -350
View File
@@ -19,376 +19,45 @@ results = pd.read_csv("Doczy-Results.csv") # This is the doczy output
testbed = testbed_utils.testbed_preprocess(testbed)
results = testbed_utils.testbed_preprocess(results)
# Ensure FILE_NAME values match between testbed and results
common_file_names = set(testbed['FILE_NAME']).intersection(set(results['FILE_NAME']))
print(f"common_file_names: {len(common_file_names)} items")
if "86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661".upper() in common_file_names:
print("found 86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661 in both testbed and results; removing it from both datasets")
common_file_names.remove("86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661".upper())
print(f"common_file_names now has {len(common_file_names)} items")
testbed = testbed[testbed['FILE_NAME'].isin(common_file_names)]
results = results[results['FILE_NAME'].isin(common_file_names)]
# Filter out filenames we don't want to include
testbed, results = testbed_utils.filter_file_names(testbed, results)
########################## Analyze Testbed ##########################
print("*************** Test Bed Stats ****************")
print(f"{len(testbed['FILE_NAME'].unique())} contracts")
print(f"{len(testbed.columns)} fields")
print(f"{(testbed == '').all().sum()} fields not populated:") # Count of completely empty fields
print(f"{testbed.columns[(testbed == '').all()].tolist()}") # List of completely empty fields
testbed_utils.get_df_stats(testbed)
########################## Row Comparison ##########################
row_comparison = pd.DataFrame(index=testbed['FILE_NAME'].unique())
row_comparison['testbed'] = testbed.groupby('FILE_NAME').size()
row_comparison['results'] = results.groupby('FILE_NAME').size()
print("\n*************** Row Count Comparison ****************")
print(f"Files with different row counts: {(row_comparison['testbed'] != row_comparison['results']).sum()}")
row_comparison = testbed_utils.get_row_comparison(testbed, results)
########################## Page-Level Row Count Comparison ##########################
print("\n*************** Page-Level Row Count Comparison ****************")
def get_consecutive_page_groups(pages) -> list:
"""Group consecutive page numbers together.
e.g., [4, 5, 6, 8, 9] -> [[4, 5, 6], [8, 9]]
"""
if not pages:
return []
# Convert to sorted list of integers
page_nums = sorted([int(p) for p in pages if str(p).isdigit()])
if not page_nums:
return []
groups = []
current_group = [page_nums[0]]
for i in range(1, len(page_nums)):
if page_nums[i] == page_nums[i - 1] + 1: # consecutive pages
current_group.append(page_nums[i])
else:
groups.append(current_group)
current_group = [page_nums[i]]
groups.append(current_group) # Append the last group
return groups
def group_name(group):
"""Convert a group of consecutive pages to a readable name.
E.g., [4, 5, 6] -> "4-6", [8, 9] -> "8-9", [13] -> "13"
"""
if len(group) == 1:
return str(group[0])
else:
return f"{group[0]}-{group[-1]}"
def normalize_page(page_str):
"""Normalize page numbers to integer strings for comparison (e.g., "4.0" -> "4", "4" -> "4", "13.0.0" -> "13")"""
if pd.isna(page_str) or page_str == "":
return None
try:
# Split on first period
return str(page_str).strip().split(".")[0]
except (ValueError, TypeError):
return str(page_str).strip()
if "EXHIBIT_PAGE" in testbed.columns and "EXHIBIT_PAGE" in results.columns:
page_mismatches = []
for file in testbed['FILE_NAME'].unique():
testbed_file = testbed[testbed['FILE_NAME'] == file].copy()
results_file = results[results['FILE_NAME'] == file].copy()
# Normalize page numbers
testbed_file['EXHIBIT_PAGE_NORM'] = testbed_file['EXHIBIT_PAGE'].apply(normalize_page)
results_file['EXHIBIT_PAGE_NORM'] = results_file['EXHIBIT_PAGE'].apply(normalize_page)
# Get unique pages from each dataset (excluding None/empty)
testbed_pages = set(testbed_file['EXHIBIT_PAGE_NORM'].dropna())
results_pages = set(results_file['EXHIBIT_PAGE_NORM'].dropna())
testbed_pages.discard("") # Remove empty strings
results_pages.discard("") # Remove empty strings
# Group consecutive pages
testbed_groups = get_consecutive_page_groups(testbed_pages)
results_groups = get_consecutive_page_groups(results_pages)
# Count rows for each group
testbed_group_counts = {}
for group in testbed_groups:
group_key = group_name(group)
count = sum(testbed_file['EXHIBIT_PAGE_NORM'].isin([str(p) for p in group]))
testbed_group_counts[group_key] = count
results_group_counts = {}
for group in results_groups:
group_key = group_name(group)
count = sum(results_file['EXHIBIT_PAGE_NORM'].isin([str(p) for p in group]))
results_group_counts[group_key] = count
# Compare group counts
all_groups = set(testbed_group_counts.keys()) | set(results_group_counts.keys())
group_comparison = {}
has_mismatch = False
for group_key in all_groups:
testbed_count = testbed_group_counts.get(group_key, 0)
results_count = results_group_counts.get(group_key, 0)
group_comparison[group_key] = {
'testbed_count': testbed_count,
'results_count': results_count,
'difference': results_count - testbed_count
}
if testbed_count != results_count:
has_mismatch = True
if has_mismatch:
page_mismatches.append({
'FILE_NAME': file,
'group_comparison': group_comparison,
'total_testbed_rows': len(testbed_file),
'total_results_rows': len(results_file)
})
if page_mismatches:
# Create detailed comparison DataFrame
detailed_page_comparison = []
for file_data in page_mismatches:
file_name = file_data['FILE_NAME']
for group_key, counts in file_data['group_comparison'].items():
detailed_page_comparison.append({
'FILE_NAME': file_name,
'PAGE_GROUP': group_key,
'TESTBED_COUNT': counts['testbed_count'],
'RESULTS_COUNT': counts['results_count'],
'DIFFERENCE': counts['difference']
})
page_comparison_df = pd.DataFrame(detailed_page_comparison)
# Sort by file name and first page number in group
def sort_key(group_str):
"""Extract the first page number from a group string for sorting."""
parts = group_str.split('-')
return int(parts[0]) if parts else float('inf')
page_comparison_df['SORT_KEY'] = page_comparison_df['PAGE_GROUP'].apply(sort_key)
page_comparison_df = page_comparison_df.sort_values(by=['FILE_NAME', 'SORT_KEY']).drop(columns=['SORT_KEY'])
# Summary statistics
total_missing_rows = page_comparison_df[page_comparison_df['DIFFERENCE'] < 0]['DIFFERENCE'].sum()
total_extra_rows = page_comparison_df[page_comparison_df['DIFFERENCE'] > 0]['DIFFERENCE'].sum()
pages_with_missing = (page_comparison_df['DIFFERENCE'] < 0).sum()
pages_with_extra = (page_comparison_df['DIFFERENCE'] > 0).sum()
print(f"Pages with missing rows in results: {pages_with_missing}")
print(f"Pages with extra rows in results: {pages_with_extra}")
print(f"Total missing rows: {abs(total_missing_rows)}")
print(f"Total extra rows: {total_extra_rows}")
else:
print("All files have matching row counts for consecutive page groups!")
page_comparison_df = pd.DataFrame()
else:
print("EXHIBIT_PAGE column not found in either testbed or results. Skipping page-level row count comparison.")
page_comparison_df = pd.DataFrame()
page_comparison_df = testbed_utils.get_page_comparison_df(testbed, results)
########################## One-to-One Analysis ##########################
print("\n*************** One-to-One Stats ****************")
one_to_one_fields = FieldSet(file_path=config.FIELD_JSON_PATH).filter(relationship="one_to_one").list_fields()
one_to_one_fields.extend(['PROV_GROUP_TIN',
'PROV_GROUP_NPI',
'PROV_GROUP_NAME_FULL'])
one_to_one_fields = [f for f in one_to_one_fields if f not in {'TIN', 'NPI', 'NAME', "CLIENT_NAME"}]
# Create filtered dataframes with only one-to-one fields
one_to_one_columns = ['FILE_NAME'] + [f for f in one_to_one_fields if f in testbed.columns and f in results.columns]
testbed_one_to_one = testbed[one_to_one_columns].drop_duplicates()
results_one_to_one = results[one_to_one_columns].drop_duplicates()
metrics_df = pd.DataFrame(columns=['Field', 'Precision', 'Recall', 'Accuracy', 'FN_list'])
comparison_df = pd.DataFrame(index=testbed_one_to_one['FILE_NAME'].unique())
for field in one_to_one_fields:
if field in testbed.columns and field in results.columns and field != 'FILE_NAME':
# Merge and compare values (no need to drop_duplicates here since we already did)
merged = pd.merge(testbed_one_to_one[['FILE_NAME', field]],
results_one_to_one[['FILE_NAME', field]],
on='FILE_NAME',
suffixes=('_truth', '_pred'))
# Compare values
merged[field] = (merged[f"{field}_truth"] == merged[f"{field}_pred"]) & (merged[f"{field}_truth"] != '')
# Drop duplicate rows
merged = merged.drop_duplicates(subset=['FILE_NAME', field])
# Add to comparison DataFrame
try:
comparison_df[field] = merged.set_index('FILE_NAME')[field]
except:
print(merged['FILE_NAME'].value_counts())
raise
# Calculate metrics
precision, recall, accuracy, FN_list = testbed_utils.calculate_field_metrics(merged, field)
metrics_df.loc[len(metrics_df)] = [field, precision, recall, accuracy, FN_list]
else:
metrics_df.loc[len(metrics_df)] = [field, None, None, None, None]
comparison_df[field] = None
# Display results with clean formatting
print(metrics_df.to_string(
index=False,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'Field': 40, 'Precision': 15, 'Recall': 15, 'Accuracy': 15}
))
metrics_df, comparison_df = testbed_utils.get_one_to_one_analysis(testbed, results)
########################## One-to-N Analysis ##########################
print("\n*************** One-to-N Stats ****************")
one_to_n_fields = {"methodology_breakout" : [field for field in FieldSet(file_path=config.FIELD_JSON_PATH).filter(field_type="methodology_breakout").list_fields() if field != "GREATER_OF_IND"],
"fee_schedule_breakout" : ["AARETE_DERIVED_FEE_SCHEDULE", "AARETE_DERIVED_FEE_SCHEDULE_VERSION"],
"dynamic_primary" : ["AARETE_DERIVED_LOB", "AARETE_DERIVED_PROGRAM", "AARETE_DERIVED_NETWORK", "AARETE_DERIVED_PRODUCT"],
"reimb_prov_info" : ["REIMB_PROV_TIN", "REIMB_PROV_NPI", "REIMB_PROV_NAME"]
}
one_to_n_metrics = {}
for field_type, fields in one_to_n_fields.items():
N = len(fields)
accuracies = []
for file in testbed['FILE_NAME'].unique():
testbed_file = testbed[testbed['FILE_NAME'] == file]
results_file = results[results['FILE_NAME'] == file]
# Ensure there are rows in both dataframes
if testbed_file.shape[0] == 0 or results_file.shape[0] == 0:
print(f"No rows in either labels or predictions for file: {file}")
continue
confusion_matrix = testbed_utils.match_rows(fields, testbed_file, results_file, N)
confusion_matrix["Filename"] = file
accuracies.append(confusion_matrix)
precision_df, recall_df, overall = testbed_utils.calculate_precision_recall(accuracies)
one_to_n_metrics[field_type] = {'precision': precision_df, 'recall': recall_df, 'overall': overall}
print(f"\nOverall Metrics: {field_type}")
print(overall.to_string(
index=True,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'tp': 15, 'fp': 15, 'fn': 15, 'prec': 15, 'rec': 15, 'f1': 15}
))
one_to_n_metrics = testbed_utils.get_one_to_n_analysis(testbed, results)
########################## Reimbursement Primary ##########################
# Special handling for reimbursement primary
common_files = set(testbed['FILE_NAME']).intersection(set(results['FILE_NAME']))
reimb_primary_metric_rows = []
for file in common_files:
result = testbed_utils.compare_term_extraction_results(file, predictions_df=results, gt_df=testbed)
reimb_primary_metric_rows.append(result.copy())
reimb_primary_df = pd.DataFrame(reimb_primary_metric_rows)
reimb_primary_df = testbed_utils.get_reimb_primary(testbed, results)
########################## Order-Independent Provider Analysis ##########################
print("\n*************** Order-Independent Provider Analysis ****************")
provider_results = testbed_utils.evaluate_provider_fields_separately(
testbed,
results, verbose=True)
# Display the results
provider_metrics_df = pd.DataFrame([
{
'Field': f"PROV_OTHER_{field_type}",
'Precision': metrics['precision'],
'Recall': metrics['recall'],
'F1': metrics['f1'],
'TP': metrics['tp'],
'FP': metrics['fp'],
'FN': metrics['fn'],
'average_accuracy': metrics['average_accuracy'],
}
for field_type, metrics in provider_results.items()
])
print(provider_metrics_df.to_string(
index=False,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'Field': 15, 'Precision': 12, 'Recall': 12, 'F1': 12, 'TP': 8, 'FP': 8, 'FN': 8}
))
provider_results, provider_metrics_df = testbed_utils.evaluate_provider_fields_separately(testbed, results, verbose=True)
########################## Export to Excel ##########################
print("\nExporting results to Excel...")
# Create Excel writer object
output_file = f"{config.TODAY}-testbed-metrics.xlsx"
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
# Test Bed Stats
stats_df = pd.DataFrame({
'Metric': ['Total Contracts', 'Total Fields', 'Empty Fields'],
'Value': [
len(testbed['FILE_NAME'].unique()),
len(testbed.columns),
(testbed == '').all().sum()
]
})
empty_fields_df = pd.DataFrame({'Empty Fields': testbed.columns[(testbed == '').all()].tolist()})
stats_df.to_excel(writer, sheet_name='Testbed Stats', index=False)
row_comparison.to_excel(writer, sheet_name='Row Counts')
empty_fields_df.to_excel(writer, sheet_name='Empty Fields', index=False)
# Add page-level row count comparison if it exists
if not page_comparison_df.empty:
page_comparison_df.to_excel(writer, sheet_name='Page-Level Row Counts', index=False)
else:
print("No page-level row count mismatches found; skipping export.")
# One-to-One Results
metrics_df.to_excel(writer, sheet_name='One-to-One Metrics', index=False)
comparison_df.to_excel(writer, sheet_name='One-to-One Details') # Add new sheet
# One-to-N Results
for field_type, metrics in one_to_n_metrics.items():
precision_df = metrics['precision']
recall_df = metrics['recall']
overall = metrics['overall']
# Add the field type to the sheet names
precision_df.to_excel(writer, sheet_name=f'{field_type} Precision', index=False)
recall_df.to_excel(writer, sheet_name=f'{field_type} Recall', index=False)
overall.to_excel(writer, sheet_name=f'{field_type} Overall')
# Reimbursement Primary Results
reimb_primary_df.to_excel(writer, sheet_name='Reimbursement Primary', index=False)
# Order-independent provider analysis
provider_metrics_df.to_excel(writer, sheet_name='Provider Fields', index=False)
# Auto-adjust column widths
for sheet_name in writer.sheets:
worksheet = writer.sheets[sheet_name]
for column in worksheet.columns:
max_length = 0
column = [cell for cell in column]
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = (max_length + 2)
worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
print(f"Results exported to {output_file}")
testbed_utils.export_to_excel(output_file,
testbed,
row_comparison,
page_comparison_df,
metrics_df,
comparison_df,
one_to_n_metrics,
reimb_primary_df,
provider_metrics_df)
+397 -7
View File
@@ -10,7 +10,20 @@ from rapidfuzz import fuzz
# Imports for the testbed postprocessing functions
from src.investment import investment_postprocessing_funcs
from src.constants.investment_columns import COLUMN_ORDER
from src.prompts.investment_prompts import FieldSet
import src.config as config
def filter_file_names(testbed, results):
# Ensure FILE_NAME values match between testbed and results
common_file_names = set(testbed['FILE_NAME']).intersection(set(results['FILE_NAME']))
print(f"common_file_names: {len(common_file_names)} items")
if "86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661".upper() in common_file_names:
print("found 86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661 in both testbed and results; removing it from both datasets")
common_file_names.remove("86-0898663-Founder Health, LLC dba Preferred Homecare-ICMProviderAgreement_120661".upper())
print(f"common_file_names now has {len(common_file_names)} items")
testbed = testbed[testbed['FILE_NAME'].isin(common_file_names)]
results = results[results['FILE_NAME'].isin(common_file_names)]
return testbed, results
def convert_to_float(value):
try:
@@ -196,6 +209,7 @@ def calculate_field_metrics(merged, field):
def evaluate_provider_fields_separately(testbed_df, results_df, verbose=False):
"""Evaluates TIN, NPI, and NAME fields separately for each provider."""
print("\n*************** Order-Independent Provider Analysis ****************")
# Initialize metrics for each field type
metrics = {
"TIN": {'tp' : 0, 'fp': 0, 'fn': 0, 'accuracy_file': []},
@@ -380,8 +394,6 @@ def evaluate_provider_fields_separately(testbed_df, results_df, verbose=False):
# Save the DataFrame to a CSV file
csv_output_df.to_csv("provider_metrics.csv", index=False)
# Calculate accuracy, precision, recall, and F1 for each field type
results = {}
for field_type, field_metrics in metrics.items():
@@ -389,9 +401,6 @@ def evaluate_provider_fields_separately(testbed_df, results_df, verbose=False):
recall = field_metrics['tp'] / (field_metrics['tp'] + field_metrics['fn']) if (field_metrics['tp'] + field_metrics['fn']) > 0 else 0
f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
results[field_type] = {
"precision": precision,
"recall": recall,
@@ -404,7 +413,30 @@ def evaluate_provider_fields_separately(testbed_df, results_df, verbose=False):
if verbose:
print("Verbose output written to provider_metrics.txt")
return results
# Display the results
provider_metrics_df = pd.DataFrame([
{
'Field': f"PROV_OTHER_{field_type}",
'Precision': metrics['precision'],
'Recall': metrics['recall'],
'F1': metrics['f1'],
'TP': metrics['tp'],
'FP': metrics['fp'],
'FN': metrics['fn'],
'average_accuracy': metrics['average_accuracy'],
}
for field_type, metrics in results.items()
])
print(provider_metrics_df.to_string(
index=False,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'Field': 15, 'Precision': 12, 'Recall': 12, 'F1': 12, 'TP': 8, 'FP': 8, 'FN': 8}
))
return results, provider_metrics_df
def match_rows(fields, labels_df, predictions_df, N):
@@ -911,4 +943,362 @@ def testbed_postprocess(df):
# Standardize output column order - this should ALWAYS be the final postprocessing step
df = investment_postprocessing_funcs.reorder_columns(df, COLUMN_ORDER)
return df
return df
def get_df_stats(df):
print("*************** Test Bed Stats ****************")
print(f"{len(df['FILE_NAME'].unique())} contracts")
print(f"{len(df.columns)} fields")
print(f"{(df == '').all().sum()} fields not populated:") # Count of completely empty fields
print(f"{df.columns[(df == '').all()].tolist()}") # List of completely empty fields
def get_row_comparison(testbed, results):
row_comparison = pd.DataFrame(index=testbed['FILE_NAME'].unique())
row_comparison['testbed'] = testbed.groupby('FILE_NAME').size()
row_comparison['results'] = results.groupby('FILE_NAME').size()
print("\n*************** Row Count Comparison ****************")
print(f"Files with different row counts: {(row_comparison['testbed'] != row_comparison['results']).sum()}")
return row_comparison
def get_consecutive_page_groups(pages) -> list:
"""Group consecutive page numbers together.
e.g., [4, 5, 6, 8, 9] -> [[4, 5, 6], [8, 9]]
"""
if not pages:
return []
# Convert to sorted list of integers
page_nums = sorted([int(p) for p in pages if str(p).isdigit()])
if not page_nums:
return []
groups = []
current_group = [page_nums[0]]
for i in range(1, len(page_nums)):
if page_nums[i] == page_nums[i - 1] + 1: # consecutive pages
current_group.append(page_nums[i])
else:
groups.append(current_group)
current_group = [page_nums[i]]
groups.append(current_group) # Append the last group
return groups
def group_name(group):
"""Convert a group of consecutive pages to a readable name.
E.g., [4, 5, 6] -> "4-6", [8, 9] -> "8-9", [13] -> "13"
"""
if len(group) == 1:
return str(group[0])
else:
return f"{group[0]}-{group[-1]}"
def normalize_page(page_str):
"""Normalize page numbers to integer strings for comparison (e.g., "4.0" -> "4", "4" -> "4", "13.0.0" -> "13")"""
if pd.isna(page_str) or page_str == "":
return None
try:
# Split on first period
return str(page_str).strip().split(".")[0]
except (ValueError, TypeError):
return str(page_str).strip()
def get_page_comparison_df(testbed, results):
print("\n*************** Page-Level Row Count Comparison ****************")
if "EXHIBIT_PAGE" in testbed.columns and "EXHIBIT_PAGE" in results.columns:
page_mismatches = []
for file in testbed['FILE_NAME'].unique():
testbed_file = testbed[testbed['FILE_NAME'] == file].copy()
results_file = results[results['FILE_NAME'] == file].copy()
# Normalize page numbers
testbed_file['EXHIBIT_PAGE_NORM'] = testbed_file['EXHIBIT_PAGE'].apply(normalize_page)
results_file['EXHIBIT_PAGE_NORM'] = results_file['EXHIBIT_PAGE'].apply(normalize_page)
# Get unique pages from each dataset (excluding None/empty)
testbed_pages = set(testbed_file['EXHIBIT_PAGE_NORM'].dropna())
results_pages = set(results_file['EXHIBIT_PAGE_NORM'].dropna())
testbed_pages.discard("") # Remove empty strings
results_pages.discard("") # Remove empty strings
# Group consecutive pages
testbed_groups = get_consecutive_page_groups(testbed_pages)
results_groups = get_consecutive_page_groups(results_pages)
# Count rows for each group
testbed_group_counts = {}
for group in testbed_groups:
group_key = group_name(group)
count = sum(testbed_file['EXHIBIT_PAGE_NORM'].isin([str(p) for p in group]))
testbed_group_counts[group_key] = count
results_group_counts = {}
for group in results_groups:
group_key = group_name(group)
count = sum(results_file['EXHIBIT_PAGE_NORM'].isin([str(p) for p in group]))
results_group_counts[group_key] = count
# Compare group counts
all_groups = set(testbed_group_counts.keys()) | set(results_group_counts.keys())
group_comparison = {}
has_mismatch = False
for group_key in all_groups:
testbed_count = testbed_group_counts.get(group_key, 0)
results_count = results_group_counts.get(group_key, 0)
group_comparison[group_key] = {
'testbed_count': testbed_count,
'results_count': results_count,
'difference': results_count - testbed_count
}
if testbed_count != results_count:
has_mismatch = True
if has_mismatch:
page_mismatches.append({
'FILE_NAME': file,
'group_comparison': group_comparison,
'total_testbed_rows': len(testbed_file),
'total_results_rows': len(results_file)
})
if page_mismatches:
# Create detailed comparison DataFrame
detailed_page_comparison = []
for file_data in page_mismatches:
file_name = file_data['FILE_NAME']
for group_key, counts in file_data['group_comparison'].items():
detailed_page_comparison.append({
'FILE_NAME': file_name,
'PAGE_GROUP': group_key,
'TESTBED_COUNT': counts['testbed_count'],
'RESULTS_COUNT': counts['results_count'],
'DIFFERENCE': counts['difference']
})
page_comparison_df = pd.DataFrame(detailed_page_comparison)
# Sort by file name and first page number in group
def sort_key(group_str):
"""Extract the first page number from a group string for sorting."""
parts = group_str.split('-')
return int(parts[0]) if parts else float('inf')
page_comparison_df['SORT_KEY'] = page_comparison_df['PAGE_GROUP'].apply(sort_key)
page_comparison_df = page_comparison_df.sort_values(by=['FILE_NAME', 'SORT_KEY']).drop(columns=['SORT_KEY'])
# Summary statistics
total_missing_rows = page_comparison_df[page_comparison_df['DIFFERENCE'] < 0]['DIFFERENCE'].sum()
total_extra_rows = page_comparison_df[page_comparison_df['DIFFERENCE'] > 0]['DIFFERENCE'].sum()
pages_with_missing = (page_comparison_df['DIFFERENCE'] < 0).sum()
pages_with_extra = (page_comparison_df['DIFFERENCE'] > 0).sum()
print(f"Pages with missing rows in results: {pages_with_missing}")
print(f"Pages with extra rows in results: {pages_with_extra}")
print(f"Total missing rows: {abs(total_missing_rows)}")
print(f"Total extra rows: {total_extra_rows}")
else:
print("All files have matching row counts for consecutive page groups!")
page_comparison_df = pd.DataFrame()
else:
print("EXHIBIT_PAGE column not found in either testbed or results. Skipping page-level row count comparison.")
page_comparison_df = pd.DataFrame()
return page_comparison_df
def get_one_to_one_analysis(testbed, results):
print("\n*************** One-to-One Stats ****************")
one_to_one_fields = FieldSet(file_path=config.FIELD_JSON_PATH).filter(relationship="one_to_one").list_fields()
one_to_one_fields.extend(['PROV_GROUP_TIN',
'PROV_GROUP_NPI',
'PROV_GROUP_NAME_FULL'])
one_to_one_fields = [f for f in one_to_one_fields if f not in {'TIN', 'NPI', 'NAME', "CLIENT_NAME"}]
# Create filtered dataframes with only one-to-one fields
one_to_one_columns = ['FILE_NAME'] + [f for f in one_to_one_fields if f in testbed.columns and f in results.columns]
testbed_one_to_one = testbed[one_to_one_columns].drop_duplicates()
results_one_to_one = results[one_to_one_columns].drop_duplicates()
metrics_df = pd.DataFrame(columns=['Field', 'Precision', 'Recall', 'Accuracy', 'FN_list'])
comparison_df = pd.DataFrame(index=testbed_one_to_one['FILE_NAME'].unique())
for field in one_to_one_fields:
if field in testbed.columns and field in results.columns and field != 'FILE_NAME':
# Merge and compare values (no need to drop_duplicates here since we already did)
merged = pd.merge(testbed_one_to_one[['FILE_NAME', field]],
results_one_to_one[['FILE_NAME', field]],
on='FILE_NAME',
suffixes=('_truth', '_pred'))
# Compare values
merged[field] = (merged[f"{field}_truth"] == merged[f"{field}_pred"]) & (merged[f"{field}_truth"] != '')
# Drop duplicate rows
merged = merged.drop_duplicates(subset=['FILE_NAME', field])
# Add to comparison DataFrame
try:
comparison_df[field] = merged.set_index('FILE_NAME')[field]
except:
print(merged['FILE_NAME'].value_counts())
raise
# Calculate metrics
precision, recall, accuracy, FN_list = calculate_field_metrics(merged, field)
metrics_df.loc[len(metrics_df)] = [field, precision, recall, accuracy, FN_list]
else:
metrics_df.loc[len(metrics_df)] = [field, None, None, None, None]
comparison_df[field] = None
# Display results with clean formatting
print(metrics_df[["Field", "Precision", "Recall", "Accuracy"]].to_string(
index=False,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'Field': 40, 'Precision': 15, 'Recall': 15, 'Accuracy': 15}
))
return metrics_df, comparison_df
def get_one_to_n_analysis(testbed, results):
print("\n*************** One-to-N Stats ****************")
one_to_n_fields = {"methodology_breakout" : [field for field in FieldSet(file_path=config.FIELD_JSON_PATH).filter(field_type="methodology_breakout").list_fields() if field != "GREATER_OF_IND"],
"fee_schedule_breakout" : ["AARETE_DERIVED_FEE_SCHEDULE", "AARETE_DERIVED_FEE_SCHEDULE_VERSION"],
"dynamic_primary" : ["AARETE_DERIVED_LOB", "AARETE_DERIVED_PROGRAM", "AARETE_DERIVED_NETWORK", "AARETE_DERIVED_PRODUCT"],
"reimb_prov_info" : ["REIMB_PROV_TIN", "REIMB_PROV_NPI", "REIMB_PROV_NAME"]
}
one_to_n_metrics = {}
for field_type, fields in one_to_n_fields.items():
N = len(fields)
accuracies = []
for file in testbed['FILE_NAME'].unique():
testbed_file = testbed[testbed['FILE_NAME'] == file]
results_file = results[results['FILE_NAME'] == file]
# Ensure there are rows in both dataframes
if testbed_file.shape[0] == 0 or results_file.shape[0] == 0:
print(f"No rows in either labels or predictions for file: {file}")
continue
confusion_matrix = match_rows(fields, testbed_file, results_file, N)
confusion_matrix["Filename"] = file
accuracies.append(confusion_matrix)
precision_df, recall_df, overall = calculate_precision_recall(accuracies)
one_to_n_metrics[field_type] = {'precision': precision_df, 'recall': recall_df, 'overall': overall}
print(f"\nOverall Metrics: {field_type}")
print(overall.to_string(
index=True,
float_format=lambda x: '{:.2f}'.format(x) if pd.notnull(x) else 'Not found',
justify='left',
col_space={'tp': 15, 'fp': 15, 'fn': 15, 'prec': 15, 'rec': 15, 'f1': 15}
))
return one_to_n_metrics
def get_reimb_primary(testbed, results):
common_files = set(testbed['FILE_NAME']).intersection(set(results['FILE_NAME']))
reimb_primary_metric_rows = []
for file in common_files:
result = compare_term_extraction_results(file, predictions_df=results, gt_df=testbed)
reimb_primary_metric_rows.append(result.copy())
reimb_primary_df = pd.DataFrame(reimb_primary_metric_rows)
return reimb_primary_df
def export_to_excel(output_file,
testbed,
row_comparison,
page_comparison_df,
metrics_df,
comparison_df,
one_to_n_metrics,
reimb_primary_df,
provider_metrics_df):
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
# Test Bed Stats
stats_df = pd.DataFrame({
'Metric': ['Total Contracts', 'Total Fields', 'Empty Fields'],
'Value': [
len(testbed['FILE_NAME'].unique()),
len(testbed.columns),
(testbed == '').all().sum()
]
})
empty_fields_df = pd.DataFrame({'Empty Fields': testbed.columns[(testbed == '').all()].tolist()})
stats_df.to_excel(writer, sheet_name='Testbed Stats', index=False)
row_comparison.to_excel(writer, sheet_name='Row Counts')
empty_fields_df.to_excel(writer, sheet_name='Empty Fields', index=False)
# Add page-level row count comparison if it exists
if not page_comparison_df.empty:
page_comparison_df.to_excel(writer, sheet_name='Page-Level Row Counts', index=False)
else:
print("No page-level row count mismatches found; skipping export.")
# One-to-One Results
metrics_df.to_excel(writer, sheet_name='One-to-One Metrics', index=False)
comparison_df.to_excel(writer, sheet_name='One-to-One Details') # Add new sheet
# One-to-N Results
for field_type, metrics in one_to_n_metrics.items():
precision_df = metrics['precision']
recall_df = metrics['recall']
overall = metrics['overall']
# Add the field type to the sheet names
precision_df.to_excel(writer, sheet_name=f'{field_type} Precision', index=False)
recall_df.to_excel(writer, sheet_name=f'{field_type} Recall', index=False)
overall.to_excel(writer, sheet_name=f'{field_type} Overall')
# Reimbursement Primary Results
reimb_primary_df.to_excel(writer, sheet_name='Reimbursement Primary', index=False)
# Order-independent provider analysis
provider_metrics_df.to_excel(writer, sheet_name='Provider Fields', index=False)
# Auto-adjust column widths
for sheet_name in writer.sheets:
worksheet = writer.sheets[sheet_name]
for column in worksheet.columns:
max_length = 0
column = [cell for cell in column]
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = (max_length + 2)
worksheet.column_dimensions[column[0].column_letter].width = adjusted_width
print(f"Results exported to {output_file}")