Merged in feature/test-1-to-n-fields (pull request #426)

Feature/test 1 to n fields

* Add function to evaluate one-to-n field comparisons and calculate metrics

* Add fuzzy matching for one-to-n field comparisons and enhance evaluation metrics

* Refactor similarity calculation to use token sort ratio for improved field comparison

* Add matched labels to evaluation metrics in one-to-n fuzzy matching


Approved-by: Katon Minhas
This commit is contained in:
Alex Galarce
2025-03-04 01:16:56 +00:00
committed by Katon Minhas
parent 576ea95b6d
commit 08ebf92109
2 changed files with 3661 additions and 0 deletions
@@ -0,0 +1,115 @@
import pandas as pd
from rapidfuzz import fuzz
def are_entries_similar_field_by_field(entry1: tuple, entry2: tuple, thresholds=None):
"""Compare two entries for similarity based on a given threshold using rapidfuzz
Args:
entry1 (tuple): The first entry to compare.
entry2 (tuple): The second entry to compare.
thresholds (int, optional): Dictionary or list of thresholds for each field (defaults to 95 for all)
"""
if thresholds is None:
thresholds = [95] * len(entry1) # Default threshold to 95 for all fields
# Check if threshold is a single value
if isinstance(thresholds, (int, float)):
thresholds = [thresholds] * len(entry1)
# Compare each field with its corresponding threshold
for i, (field1, field2) in enumerate(zip(entry1, entry2)):
field1_str = str(field1)
field2_str = str(field2)
# Calculate similarity for this field
similarity = fuzz.token_sort_ratio(field1_str, field2_str)
# If any field fails the threshold check, entries are not similar
if similarity < thresholds[i]:
return False
# All fields passed their threshold checks
return True
def evaluate_one_to_n_fields_fuzzy_matching_field_by_field(
labels_df: pd.DataFrame,
predictions_df: pd.DataFrame,
key_field: str,
comparison_fields: list,
thresholds=None,
):
"""Evaluate 1:n field predictions against labels using field-by-field fuzzy matching
Args:
labels_df (pd.DataFrame): DataFrame with ground truth labels
predictions_df (pd.DataFrame): DataFrame with predictions
key_field (str): Field that identifies the contract (probably "CONTRACT_FILE_NAME")
comparison_fields (list): A list of fields to compare against the labels
thresholds: Thresholds for each comparison field (same length as comparison_fields); defaults to 95 for all
"""
# Track metrics
total_true_positives = 0
total_false_positives = 0
total_false_negatives = 0
# group by contract
contract_ids = set(labels_df[key_field].unique()).union(
set(predictions_df[key_field].unique())
)
for contract_id in contract_ids:
# Get labeled and predicted values for this contract
contract_labels = labels_df[labels_df[key_field] == contract_id]
contract_predictions = predictions_df[predictions_df[key_field] == contract_id]
# Create lists of entries as tuples for comparison
labeled_entries = [tuple(row) for row in contract_labels[comparison_fields].values]
predicted_entries = [tuple(row) for row in contract_predictions[comparison_fields].values]
# Track matches to avoid double counting
matched_labels = set()
matched_predictions = set()
# Find matches using field-by-field fuzzy comparison
for i, labeled_entry in enumerate(labeled_entries):
for j, predicted_entry in enumerate(predicted_entries):
if j not in matched_predictions and are_entries_similar_field_by_field(labeled_entry, predicted_entry, thresholds):
# If a match is found, increment true positives and mark entries as matched
total_true_positives += 1
matched_labels.add(i)
matched_predictions.add(j)
break
# Calculate unmatched entries
total_false_negatives += len(labeled_entries) - len(matched_labels)
total_false_positives += len(predicted_entries) - len(matched_predictions)
# Calculate overall metrics
precision = (
total_true_positives / (total_true_positives + total_false_positives)
if (total_true_positives + total_false_positives) > 0
else 0
)
recall = (
total_true_positives / (total_true_positives + total_false_negatives)
if (total_true_positives + total_false_negatives) > 0
else 0
)
f1_score = (
2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
)
return {
"precision": precision,
"recall": recall,
"f1_score": f1_score,
"true_positives": total_true_positives,
"false_positives": total_false_positives,
"false_negatives": total_false_negatives,
"matched_labels": {contract_id: [labeled_entries[i] for i in matched_labels] for contract_id in contract_ids},
"unmatched_labels": {contract_id: [labeled_entries[i] for i in range(len(labeled_entries)) if i not in matched_labels] for contract_id in contract_ids},
"unmatched_predictions": {contract_id: [predicted_entries[j] for j in range(len(predicted_entries)) if j not in matched_predictions] for contract_id in contract_ids}
}
File diff suppressed because it is too large Load Diff