Merged in feature/new-model-testing (pull request #741)

Feature/new model testing

* rename and set up

* Setup

* Get dynamic primary crosswalked answers

* Generate stats for one file

* Update dynamic primary evaluation

* Multithreading

* Set up reimbursement primary evaluation

* UPdate output report generation

* Update TN calculation

* update poetry lock

* reorder reimb primary

* evaluate 3 models

* Configure 4.5

* switch to debug logging

* update type checking

* Fix mypy

* Resolve mypy

* Merged main into feature/new-model-testing


Approved-by: Karan Desai
This commit is contained in:
Katon Minhas
2025-10-22 16:46:28 +00:00
parent 3caf0d8341
commit 23af15306b
11 changed files with 631 additions and 480 deletions
+175 -459
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ word2number = "^1.1"
orjson = "^3.10.16"
pymupdf = "^1.25.5"
pillow = "^11.2.1"
xlsxwriter = "^3.2.9"
[tool.poetry.group.dev.dependencies]
black = "^24.10.0"
+5 -1
View File
@@ -230,11 +230,15 @@ MODEL_ID_CLAUDE3_HAIKU = "anthropic.claude-3-haiku-20240307-v1:0"
MODEL_ID_CLAUDE35_SONNET = "anthropic.claude-3-5-sonnet-20240620-v1:0"
MODEL_ID_CLAUDE2 = "anthropic.claude-instant-v1"
MODEL_ID_CLAUDE35_SONNET_V2 = "anthropic.claude-3-5-sonnet-20241022-v2:0"
MODEL_ID_CLAUDE37_SONNET = "anthropic.claude-3-7-sonnet-20250219-v1:0"
MODEL_ID_CLAUDE37_SONNET = "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
MODEL_ID_CLAUDE4_SONNET = "us.anthropic.claude-sonnet-4-20250514-v1:0"
MODEL_ID_CLAUDE45_SONNET = "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
# Model aliases for easy upgrades
MODEL_ALIASES = {
"sonnet_37" : MODEL_ID_CLAUDE37_SONNET,
"sonnet_4" : MODEL_ID_CLAUDE4_SONNET,
"sonnet_45": MODEL_ID_CLAUDE45_SONNET,
"sonnet_latest": MODEL_ID_CLAUDE4_SONNET,
"haiku_latest": MODEL_ID_CLAUDE3_HAIKU,
"legacy_sonnet": MODEL_ID_CLAUDE35_SONNET,
@@ -39,7 +39,6 @@ def process_file(file_object, constants: Constants, run_timestamp):
# Set default values
dynamic_one_to_one_fields = FieldSet()
process_one_to_n = config.FIELDS in ['all', 'one_to_n']
process_one_to_one = config.FIELDS in ['all', 'one_to_one']
+2 -2
View File
@@ -100,7 +100,7 @@ def main(testing=False, test_params={}):
# Warm prompt caches before parallel processing
# This ensures the cache is registered before multiple threads try to use it
logging.info("Warming prompt caches before parallel processing...")
logging.debug("Warming prompt caches before parallel processing...")
try:
cacheable_instructions = prompt_templates.get_cacheable_instructions(constants)
for cache_key, instruction in cacheable_instructions.items():
@@ -109,7 +109,7 @@ def main(testing=False, test_params={}):
cache_key=cache_key,
model_id="sonnet_latest"
)
logging.info(f"Successfully warmed {len(cacheable_instructions)} prompt caches")
logging.debug(f"Successfully warmed {len(cacheable_instructions)} prompt caches")
except Exception as e:
logging.warning(f"Error warming caches (will proceed anyway): {str(e)}")
@@ -186,7 +186,7 @@ def date_postprocess(df, field_json_path):
)
else:
missing = [col for col in required_cols if col not in df.columns]
logging.info(f"Skipping AARETE_DERIVED_TERMINATION_DT creation. Missing: {missing}")
logging.debug(f"Skipping AARETE_DERIVED_TERMINATION_DT creation. Missing: {missing}")
df["AARETE_DERIVED_TERMINATION_DT"] = pd.NA
return df
+37 -16
View File
@@ -90,7 +90,6 @@ class Field:
- Subsequent calls with same file_path: Uses cached data (no file I/O)
- Thread-safe: Multiple threads can safely call this simultaneously
:param file_path: Path to the JSON file.
:param field_name: The field name to look for in the JSON data.
:return: A Field object if found, otherwise raises a ValueError.
@@ -211,9 +210,14 @@ class FieldSet:
FieldSet(file_path="investment_prompts.json", relationship="one_to_n", field_type="exhibit_level")
FieldSet(file_path="investment_prompts.json", field_type="reimbursement_level")
# Now supports lists for any attribute:
FieldSet(file_path="investment_prompts.json", field_name=["LOB", "PROGRAM"])
FieldSet(file_path="investment_prompts.json", field_type=["exhibit_level", "reimbursement_level"])
:param file_path: Path to a JSON file to load fields from (default: None).
:param fields: List of Field objects to initialize the FieldSet instance with (default: None).
:param filters: Key-value pairs for filtering fields based on their attributes.
Each value can be a single value or a list of values.
"""
self.fields = fields if fields is not None else []
if file_path:
@@ -228,6 +232,11 @@ class FieldSet:
- Each FieldSet can still apply different filters to the same cached data
- Thread-safe: Multiple threads can safely call this simultaneously
FILTERING BEHAVIOR:
- Single values: Field attribute must match exactly
- Lists: Field attribute must match any value in the list
- True: Field attribute must exist (not None)
PERFORMANCE IMPACT:
- Before caching: 1000+ file reads for the same JSON → "too many open files"
- After caching: 1 file read per unique path → fast and efficient
@@ -243,21 +252,33 @@ class FieldSet:
# CACHE HIT: Use cached data
data = _field_data_cache[file_path]
# Apply filters to cached data - this allows different FieldSets
# to get different subsets from the same cached JSON
self.fields = [
Field(field_dict)
for field_dict in data
if all(
(
value is True and field_dict.get(attr) is not None
) # Check for "not None" values
or (
value is not True and field_dict.get(attr) == value
) # Check for exact match
for attr, value in filters.items()
)
]
# Apply filters to cached data with enhanced list support
self.fields = []
for field_dict in data:
# Check if this field_dict matches all filters
matches_all_filters = True
for attr, filter_value in filters.items():
field_value = field_dict.get(attr)
# Handle special case for True filter (check if attribute exists)
if filter_value is True:
if field_value is None:
matches_all_filters = False
break
# Handle list-based filters (match any value in the list)
elif isinstance(filter_value, list):
if field_value not in filter_value:
matches_all_filters = False
break
# Handle single value filters (exact match)
elif field_value != filter_value:
matches_all_filters = False
break
# If all filters matched, add this field
if matches_all_filters:
self.fields.append(Field(field_dict))
def add_field(self, field):
"""Adds a new field manually to the FieldSet instance.
@@ -0,0 +1,205 @@
import pandas as pd
import concurrent.futures
import threading
from src.utils import io_utils, llm_utils
from src.investment import preprocess
from constants.constants import Constants
from src.testbed import model_evaluation_utils
from src import config
from src.prompts.fieldset import FieldSet
prompt = "This is a test prompt. Write 'test', nothing else."
def process_file(filename, contract_text, model_id_dict, testbed_df, dynamic_primary_fields, reimbursement_primary_fields, constants):
"""Process a single file and return its statistics."""
print(f"Processing: {filename}")
testbed_df = testbed_df[testbed_df["FILE_NAME"] == filename]
# Preprocess the contract
contract_text = preprocess.clean_text(contract_text)
text_dict, top_sheet_dict = preprocess.split_text(contract_text)
text_dict, header, footer = preprocess.find_headers_and_footers(text_dict)
text_dict = preprocess.clean_tables(
text_dict, constants.EXHIBIT_HEADER_MARKERS, filename
)
exhibit_dict, all_exhibit_headers = preprocess.one_to_n_exhibit_chunking(
text_dict, constants.EXHIBIT_HEADER_MARKERS, filename
)
# Evaluate Dynamic Primary
dynamic_primary_stats_dicts = model_evaluation_utils.evaluate_dynamic_primary(
exhibit_dict,
model_id_dict,
testbed_df,
dynamic_primary_fields,
constants,
filename
) # list of dicts
# Evaluate Reimbursement Primary
reimbursement_primary_stats_dict = model_evaluation_utils.evaluate_reimbursement_primary(
exhibit_dict,
model_id_dict,
testbed_df,
reimbursement_primary_fields,
constants,
filename
) # dict
return dynamic_primary_stats_dicts, reimbursement_primary_stats_dict
# Initialize variables
dynamic_primary_fields = FieldSet(
config.FIELD_JSON_PATH, field_name=["LOB", "PROGRAM", "NETWORK"]
)
reimbursement_primary_fields = FieldSet(
config.FIELD_JSON_PATH, field_type="reimbursement_level"
)
model_id_dict = {
"SONNET_37" : "sonnet_37",
"SONNET_4" : "sonnet_4",
"SONNET_45" : "sonnet_45"
}
input_dict = io_utils.read_input()
testbed_df = pd.read_excel("Doczy-Testbed.xlsx")
testbed_files = list(testbed_df["FILE_NAME"])
constants = Constants()
# Initialize dynamic primary stats for each model
dynamic_primary = [{"Model": model_name, "TP": 0, "TN": 0, "FP": 0, "FN": 0} for model_name in model_id_dict.keys()]
# Initialize list to collect all reimbursement primary stats
reimbursement_primary_stats_list = []
# Lock for thread-safe updates to stats
stats_lock = threading.Lock()
# Function to update global stats with results from one file
def update_stats(results):
dynamic_primary_stats_dicts, reimbursement_primary_stats_dict = results
with stats_lock:
# Update dynamic primary stats
for file_stats_dict in dynamic_primary_stats_dicts:
model_name = file_stats_dict["Model"]
for model_stats in dynamic_primary:
if model_stats["Model"] == model_name:
for metric in ["TP", "TN", "FP", "FN"]:
model_stats[metric] += file_stats_dict.get(metric, 0)
break
# Add reimbursement primary stats to the list
if reimbursement_primary_stats_dict:
reimbursement_primary_stats_list.append(reimbursement_primary_stats_dict)
# Process files in parallel
filtered_input = {filename: text for filename, text in input_dict.items() if filename in testbed_files}
# Use ThreadPoolExecutor for parallel processing
with concurrent.futures.ThreadPoolExecutor(max_workers=config.MAX_WORKERS) as executor:
# Submit all file processing tasks
future_to_file = {
executor.submit(
process_file,
filename,
contract_text,
model_id_dict,
testbed_df,
dynamic_primary_fields,
reimbursement_primary_fields,
constants
): filename for filename, contract_text in filtered_input.items()
}
# Process results as they complete
for future in concurrent.futures.as_completed(future_to_file):
filename = future_to_file[future]
try:
results = future.result()
update_stats(results)
print(f"Completed processing: {filename}")
except Exception as e:
print(f"Error processing {filename}: {e}")
# Generate Excel report with two sheets
def generate_excel_report(output_path="model_evaluation_results.xlsx"):
# Create Excel writer
with pd.ExcelWriter(output_path, engine='xlsxwriter') as writer:
# Sheet 1: Dynamic Primary Stats
dynamic_primary_df = pd.DataFrame(dynamic_primary)
# Calculate additional metrics for each model
for idx, row in dynamic_primary_df.iterrows():
tp = row["TP"]
tn = 0
fp = row["FP"]
fn = row["FN"]
# Add calculated metrics
dynamic_primary_df.loc[idx, "Accuracy"] = (tp + tn) / (tp + tn + fp + fn) if (tp + tn + fp + fn) > 0 else 0
dynamic_primary_df.loc[idx, "Precision"] = tp / (tp + fp) if (tp + fp) > 0 else 0
dynamic_primary_df.loc[idx, "Recall"] = tp / (tp + fn) if (tp + fn) > 0 else 0
precision = dynamic_primary_df.loc[idx, "Precision"]
recall = dynamic_primary_df.loc[idx, "Recall"]
dynamic_primary_df.loc[idx, "F1"] = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
# Write to Excel
dynamic_primary_df.to_excel(writer, sheet_name='Dynamic Primary Stats', index=False)
# Sheet 2: Reimbursement Primary Stats
if reimbursement_primary_stats_list:
# Combine all reimbursement stats into a DataFrame
reimbursement_df = pd.DataFrame(reimbursement_primary_stats_list)
# Write individual file stats
reimbursement_df.to_excel(writer, sheet_name='Reimbursement Stats', index=False)
# Get a reference to the worksheet to add summary statistics
worksheet = writer.sheets['Reimbursement Stats']
# Add a summary section
summary_row = len(reimbursement_df) + 3 # Leave a gap of 2 rows
# Add headers for the summary section
worksheet.write(summary_row, 0, "Summary Statistics")
worksheet.write(summary_row + 1, 0, "Model")
worksheet.write(summary_row + 1, 1, "Average Row Count Diff")
worksheet.write(summary_row + 1, 2, "Average Row Count Pct Diff")
# Add summary data
row_offset = summary_row + 2
# Add model stats - only proceed if we have testbed data
if 'Testbed' in reimbursement_df.columns:
for model_name in model_id_dict.keys():
if model_name in reimbursement_df.columns:
# Calculate the absolute difference between model and testbed row counts
reimbursement_df['Abs_Diff'] = abs(reimbursement_df[model_name] - reimbursement_df['Testbed'])
# Calculate the percentage difference
reimbursement_df['Pct_Diff'] = reimbursement_df['Abs_Diff'] / reimbursement_df['Testbed'] * 100
reimbursement_df['Pct_Diff'] = reimbursement_df['Pct_Diff'].fillna(0) # Handle division by zero
# Calculate averages
avg_abs_diff = reimbursement_df['Abs_Diff'].mean()
avg_pct_diff = reimbursement_df['Pct_Diff'].mean()
# Write to summary
worksheet.write(row_offset, 0, model_name)
worksheet.write(row_offset, 1, avg_abs_diff)
worksheet.write(row_offset, 2, f"{avg_pct_diff:.2f}%")
row_offset += 1
# Remove the temporary columns from the DataFrame before writing
if 'Abs_Diff' in reimbursement_df.columns:
reimbursement_df = reimbursement_df.drop(['Abs_Diff', 'Pct_Diff'], axis=1)
# We need to write the dataframe again since we modified it
reimbursement_df.to_excel(writer, sheet_name='Reimbursement Stats', index=False)
# Call the report generation function after all processing is complete
generate_excel_report()
@@ -0,0 +1,183 @@
import pandas as pd
from src.prompts.fieldset import FieldSet
from src.utils import llm_utils, string_utils
from src.prompts import prompt_templates
from constants.constants import Constants
from src.investment import aarete_derived, one_to_n_funcs
from src import config
def prompt_dynamic_primary(exhibit_dict: dict[str, str], model_id_dict: dict[str, str], dynamic_primary_fields: FieldSet, constants: Constants, filename: str):
model_answer_dicts = {}
for model_name, model_id in model_id_dict.items():
answer_dicts = []
for exhibit_page, exhibit_text in exhibit_dict.items():
answer_dict = {}
for field in dynamic_primary_fields.fields:
prompt = prompt_templates.DYNAMIC_PRIMARY_TEXT(exhibit_text, field.field_name, field.get_prompt(constants))
llm_answer_raw = llm_utils.invoke_claude(
prompt=prompt,
model_id=model_id,
filename=filename
)
llm_answer_final = string_utils.extract_text_from_delimiters(
llm_answer_raw, string_utils.Delimiter.PIPE
)
answer_dict[field.field_name] = llm_answer_final
answer_dicts.append(answer_dict)
answer_dicts = aarete_derived.get_crosswalk_fields(answer_dicts, constants)
model_answer_dicts[model_name] = answer_dicts
return model_answer_dicts
def consolidate_dynamic_primary(model_answer_dicts: dict):
consolidated_answer_dicts = {}
for model_name, answer_dicts in model_answer_dicts.items():
unique_values = {}
for answer_dict in answer_dicts:
for field_name, value in answer_dict.items():
if "AARETE_DERIVED" not in field_name:
continue
if field_name not in unique_values:
unique_values[field_name] = set()
if not string_utils.is_empty(value):
# Split pipe-separated values and add each individually
if isinstance(value, str) and "|" in value:
for split_value in value.split("|"):
split_value = split_value.strip() # Remove any extra whitespace
if not string_utils.is_empty(split_value):
unique_values[field_name].add(split_value)
else:
unique_values[field_name].add(value)
consolidated_answer_dicts[model_name] = {k: list(v) for k, v in unique_values.items()}
return consolidated_answer_dicts
def generate_dynamic_primary_stats(consolidated_answer_dicts: dict[str, dict], testbed_df: pd.DataFrame, filename: str):
"""
Generate confusion matrix statistics for dynamic primary field extraction.
Args:
consolidated_answer_dicts: Dictionary with model names as keys and dictionaries of field values as values
testbed_df: DataFrame containing the ground truth data
filename: Name of the file being evaluated
Returns:
List of dictionaries with confusion matrix statistics for each model
"""
# Extract all unique ground truth values from testbed_df
ground_truth_values = set()
aarete_cols = ["AARETE_DERIVED_LOB", "AARETE_DERIVED_PROGRAM", "AARETE_DERIVED_NETWORK"]
# Only include columns that actually exist in the DataFrame
aarete_cols = [col for col in aarete_cols if col in testbed_df.columns]
# Collect unique values from each column
for col in aarete_cols:
# Handle potential pipe-separated values
for value in testbed_df[col].dropna():
if isinstance(value, str):
if "|" in value:
for split_value in value.split("|"):
split_value = split_value.strip()
if not string_utils.is_empty(split_value):
ground_truth_values.add(split_value)
else:
if not string_utils.is_empty(value):
ground_truth_values.add(value)
stats_dicts = []
for model_name, field_dict in consolidated_answer_dicts.items():
# Collect all unique values predicted by this model
model_values = set()
for field_name, values_list in field_dict.items():
for value in values_list:
if not string_utils.is_empty(value):
model_values.add(value)
TP = len([v for v in model_values if v in ground_truth_values])
FP = len([v for v in model_values if v not in ground_truth_values])
FN = len([v for v in ground_truth_values if v not in model_values])
stats_dicts.append({"Model" : model_name, "TP" : TP, "FP" : FP, "FN" : FN})
return stats_dicts
def evaluate_dynamic_primary(exhibit_dict: dict[str, str], model_id_dict: dict[str, str], testbed_df: pd.DataFrame, dynamic_primary_fields: FieldSet, constants: Constants, filename: str):
## Prompt for model_answer_dicts
dynamic_answer_dicts = prompt_dynamic_primary(
exhibit_dict,
model_id_dict,
dynamic_primary_fields,
constants,
filename
)
# Consolidate model_answer_dicts
consolidated_answer_dicts = consolidate_dynamic_primary(dynamic_answer_dicts)
# Generate stats
stats_dicts = generate_dynamic_primary_stats(consolidated_answer_dicts, testbed_df, filename)
return stats_dicts
def evaluate_reimbursement_primary(exhibit_dict: dict[str, str], model_id_dict: dict[str, str], testbed_df: pd.DataFrame, reimbursement_primary_fields: FieldSet, constants: Constants, filename: str) -> dict[str, object]:
# Create two separate dictionaries and merge them at the end
metadata: dict[str, str] = {"FILE_NAME": filename}
# Create a numeric counts dictionary with explicit types
counts: dict[str, int] = {}
num_lesser = len(testbed_df[testbed_df['LESSER_OF_IND'] == 'Y'])
counts["Testbed"] = int(num_lesser/2) + (len(testbed_df)-num_lesser)
# Initialize model counts
for model_name in model_id_dict:
counts[model_name] = 0
# Process each model
for model_name, model_id in model_id_dict.items():
seen_pairs = set()
for exhibit_page, exhibit_text in exhibit_dict.items():
field_prompts = reimbursement_primary_fields.print_prompt_dict(constants)
instruction, prompt = prompt_templates.REIMBURSEMENT_PRIMARY(exhibit_text, field_prompts)
llm_answer_raw = llm_utils.invoke_claude(
prompt,
model_id,
filename,
max_tokens=25000,
cache=True,
instruction=instruction,
usage_label="REIMBURSEMENT_PRIMARY",
)
# Check for the special "no results" case
if "NO_REIMBURSEMENT_TERMS_FOUND" in llm_answer_raw:
continue
try:
llm_answer_final = string_utils.universal_json_load(llm_answer_raw)
reimbursement_primary_answers = one_to_n_funcs.clean_reimbursement_primary(
llm_answer_final,
"",
seen_pairs,
exhibit_page,
constants,
filename
)
counts[model_name] += len(reimbursement_primary_answers)
except Exception as e:
pass
# Merge the dictionaries before returning
result: dict[str, object] = {}
result.update(metadata)
result.update(counts)
return result
+22
View File
@@ -194,6 +194,18 @@ def invoke_claude(
)
input_cost_per_1k, output_cost_per_1k = 0.003, 0.015
cache_creation_cost_per_1k, cache_read_cost_per_1k = 0.00375, 0.0003
elif resolved_model_id == config.MODEL_ID_CLAUDE45_SONNET:
response = local_claude_3_and_up(
prompt,
resolved_model_id,
filename,
max_tokens,
cache=cache,
instruction=instruction,
usage_label=usage_label,
)
input_cost_per_1k, output_cost_per_1k = 0.003, 0.015
cache_creation_cost_per_1k, cache_read_cost_per_1k = 0.00375, 0.0003
else:
raise ValueError(f"Unsupported model_id: {model_id}")
elif config.RUN_MODE == "ec2":
@@ -247,6 +259,16 @@ def invoke_claude(
instruction=instruction,
usage_label=usage_label,
)
elif resolved_model_id == config.MODEL_ID_CLAUDE45_SONNET:
response = ec2_claude_3_and_up(
prompt,
resolved_model_id,
filename,
max_tokens,
cache=cache,
instruction=instruction,
usage_label=usage_label,
)
input_cost_per_1k, output_cost_per_1k = 0.003, 0.015
cache_creation_cost_per_1k, cache_read_cost_per_1k = 0.00375, 0.0003
else: