Files
doczyai-pipelines/fieldExtraction/src/investment/code_funcs.py
T
Alex Galarce 6d6839dcd4 Merged in hotfix/code-description-handling (pull request #416)
Hotfix/code description handling

* Update date formatting in prompts to YYYY/MM/DD

* fix tests for new date formatting

* Enhance code parsing to support comma-separated values in get_code_description function

* Merged main into hotfix/code-description-handling


Approved-by: Katon Minhas
2025-02-25 20:16:21 +00:00

362 lines
17 KiB
Python

from src.prompts.investment_prompts import Field, FieldSet
import src.config as config
import src.utils.string_utils as string_utils
import src.utils.llm_utils as llm_utils
import src.prompts.investment_prompts as investment_prompts
import src.utils.embedding_utils as embedding_utils
import pandas as pd
import os
import re
import string
import ast
import src.constants.investment_values as investment_values
from crosswalk.crosswalk_utils import CrosswalkBuilder
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
# Load model
model = SentenceTransformer("all-roberta-large-v1")
def create_faiss_index(choices):
"""
Creates a FAISS index for a list of choices using cosine similarity (inner product).
Args:
choices (list): A list of text items to index.
Returns:
faiss.IndexFlatIP: A FAISS index for the given embeddings.
"""
embeddings = model.encode(choices, normalize_embeddings=True).astype("float32")
index = faiss.IndexFlatIP(embeddings.shape[1]) # Inner product for cosine similarity
index.add(embeddings)
return index
def get_matches_above_threshold(target, choices, index, threshold=0.7, top_k=5):
"""
Retrieves the top-k matches from a FAISS index with similarity scores above a given threshold.
Args:
target (str): The target text to compare against the choices.
choices (list): A list of choices to match the target with.
index (faiss.IndexFlatIP): The FAISS index for fast nearest-neighbor search.
threshold (float, optional): The minimum similarity score to consider a match. Defaults to 0.7.
top_k (int, optional): The number of top matches to retrieve. Defaults to 5.
Returns:
list: A list of tuples containing matched choice and score for each match above the threshold.
"""
target_vec = model.encode([target], normalize_embeddings=True).astype("float32")
similarity_scores, match_indices = index.search(target_vec, top_k)
return [
(choices[idx], score)
for idx, score in zip(match_indices[0], similarity_scores[0])
if score >= threshold
]
def crosswalk_levels():
"""
Loads and processes crosswalk mappings for different levels (e.g., CPT, HCPCS) from CSV files,
and returns a dictionary with clean-to-description and description-to-code mappings for each level.
Returns:
dict: A dictionary where each key is a level, and the value is a tuple containing:
- clean-to-description mapping (str -> str)
- description-to-code mapping (str -> str)
"""
# Get necessary mappings
crosswalk_dict = {}
for level in investment_values.levels:
mapping_df = pd.read_csv(f"crosswalk/mapping_csvs/proc_cd/{level}.csv", dtype=str)
level_crosswalk = CrosswalkBuilder().from_df(mapping_df, from_col="Code", to_col="Description")
clean_to_description = {get_clean_value(x) : x for x in level_crosswalk.mapping.values()}
description_to_code = level_crosswalk.create_reverse_mapping()
crosswalk_dict[level] = (clean_to_description, description_to_code)
return crosswalk_dict
def code_primary(answer_dicts, filename):
"""
Breaks down the direct code information from CONTRACT_SERVICE_CD_OR_DESC and CONTRACT_REIMBURSEMENT_METHOD fields
using an LLM prompt and adds the results to the provided answer dictionaries.
Args:
answer_dicts (list of dict): A list of dictionaries containing service and reimbursement data.
filename (str): The name of the file being processed, used for LLM processing.
Returns:
list of dict: The input answer dictionaries with additional primary code breakout information. These are only the codes that are explicitly written in the Service/Methodology
"""
code_primary_questions = FieldSet(file_path=config.FIELD_JSON_PATH, field_type="code_primary_breakout").get_prompt_dict()
code_breakout_answers = []
for answer_dict in answer_dicts:
service, methodology = answer_dict.get("CONTRACT_SERVICE_CD_OR_DESC"), answer_dict.get("CONTRACT_REIMBURSEMENT_METHOD")
if service:
code_answer_dict = string_utils.universal_json_load(
llm_utils.invoke_claude(
investment_prompts.CODE_PRIMARY_BREAKOUT(service, methodology, code_primary_questions),
config.MODEL_ID_CLAUDE35_SONNET,
filename
)
)
else:
code_answer_dict = {}
code_breakout_answers.append({**answer_dict, **code_answer_dict})
return code_breakout_answers
def get_proc_crosswalk():
"""
Loads and concatenates all CSV files in the 'proc_cd' directory, and creates a crosswalk mapping from the 'Code' to 'Description' columns.
Returns:
CrosswalkBuilder: A CrosswalkBuilder object containing the mapping from procedure codes to descriptions.
"""
all_mappings = []
mapping_dir = "crosswalk/mapping_csvs/proc_cd"
for filename in os.listdir(mapping_dir):
if filename.endswith(".csv"):
mapping_df = pd.read_csv(os.path.join(mapping_dir, filename))
all_mappings.append(mapping_df)
proc_df = pd.concat(all_mappings, ignore_index=True)
return CrosswalkBuilder().from_df(proc_df, from_col="Code", to_col="Description")
def get_clean_value(service_description):
"""
Cleans a service description by removing stop words, replacing certain terms, and standardizing acronyms.
Args:
service_description (str): The service description string to clean.
Returns:
str: The cleaned service description with stop words removed, specific terms replaced, and acronyms expanded.
"""
stop_words = {"the", "is", "in", "and", "to", "of", "a", "an", "that", "this", "it", "for", "on", "with", "as", "was", "were", "at", "by", "but", "be"} # Experiment with not removing 'and'
def remove_stop_words(text):
words = text.split()
return " ".join(word for word in words if word.lower() not in stop_words)
service_clean = service_description.lower()
# Replace "Services" and "Procedures"
service_clean = service_clean.replace("services", "").replace("procedures", "")
service_clean = service_clean.replace("DME", "Durable Medical Equipment") # Explore adding other acronyms
service_clean = remove_stop_words(service_clean)
# Explore lemmatization here
return service_clean
def get_best_match(crosswalk_dict, target, model, thresholds={
"cpt": 0.85, "cpt_level3": 0.8, "cpt_level2": 0.75, "cpt_level1": 0.7,
"hcpcs": 0.85, "hcpcs_level3": 0.8, "hcpcs_level2": 0.75, "hcpcs_level1": 0.7
}):
"""
Finds the best matching code from a crosswalk dictionary by comparing a target string to precomputed embeddings
using a model and similarity threshold values for different levels.
Args:
crosswalk_dict (dict): Dictionary containing the crosswalk data for different levels.
target (str): The target string to match.
model: The model used to encode the target string into an embedding.
thresholds (dict, optional): A dictionary of similarity thresholds for each level (default values provided).
Returns:
tuple: A tuple containing the best matching code, its description, and the similarity score.
If no match is found above the threshold, returns (None, None, 0).
"""
target_vec = model.encode([target], normalize_embeddings=True).astype("float32")
for level in investment_values.levels:
index, _, choices = embedding_utils.load_faiss_index(index_path=os.path.join("embeddings", level, "faiss_index.bin"),
embedding_path=os.path.join("embeddings", level, "embeddings.npy"),
choices_path=os.path.join("embeddings", level, "choices.pkl"),)
similarity_scores, match_indices = index.search(target_vec, 1)
best_match, best_score = choices[match_indices[0][0]], similarity_scores[0][0]
if best_score >= thresholds[level]:
best_description = crosswalk_dict[level][0][best_match]
best_code = crosswalk_dict[level][1][best_description]
return best_code, best_description, best_score
return None, None, 0 # No valid match found
def code_indirect(answer_dicts, crosswalk):
"""
Maps free-form service descriptions to the best-matching code and description.
Caches results for efficiency and skips entries with existing codes or irrelevant text.
ONLY FOR PROC CODES RN
Args:
answer_dicts (list): List of dictionaries containing 1:N answers
filename (str): Unused parameter (can be removed if unnecessary).
Returns:
list: Updated list of dictionaries with mapped CPT4 procedure codes and descriptions.
"""
results = []
already_seen = {}
for answer_dict in answer_dicts:
service = answer_dict.get("CONTRACT_SERVICE_CD_OR_DESC", "")
proc_code = answer_dict.get("CPT4_PROC_CD")
if (not service # Service DNE
or "covered service" in service.lower() # Is generic
or isinstance(proc_code, list) # Is a list
or not string_utils.is_empty(proc_code) # Is not empty
):
pass # pass for now - we may need to add functionality here later
else:
if service in already_seen:
code = already_seen[service]['code']
description = already_seen[service]['description']
else:
code, description, score = get_best_match(crosswalk, service, model)
# print(service, " | ", description, " | ", code, " | ", score)
answer_dict["CPT4_PROC_CD"] = code
answer_dict["CPT4_PROC_DESC"] = description
results.append(answer_dict)
return results
def get_code_description(code, code_mapping):
"""
Retrieves descriptions for a given code or list of codes using the provided code mapping.
If no description is found, attempts to resolve by modifying the code (e.g., prepending "0" or handling code patterns).
Args:
code (str, list): A single code or a list of codes to look up.
code_mapping (Crosswalk): The mapping object containing code-to-description mappings.
Returns:
str: A comma-separated string of descriptions for the provided codes.
"""
if isinstance(code, str):
if string_utils.is_empty(code):
return "N/A"
elif "[" in code and "]" in code:
code_list = ast.literal_eval(code)
elif "," in code:
code_list = code.split(",")
else:
code_list = [code]
elif isinstance(code, list):
code_list = code
else:
code_list = [code]
code_description = []
for code in code_list:
code_str = str(code).strip()
description = code_mapping.mapping.get(code_str, "mismap")
if string_utils.is_empty(description) or description == "mismap":
# If mismap, prepend "0" and try again (useful for rev codes)
code_str = "0" + code_str
description = code_mapping.mapping.get(code_str, "mismap")
# We need similar logic for Diag codes with X (e.g. 123X)
# Could either: a) convert to list ([1230, 1231, ...]), b) convert to range (1230-1239), c) leave as is (123X) but have the descriptions be a list ["desc1","desc2", etc]
# for i in range(10): code is '123' + i' map that code
if not string_utils.is_empty(description):
code_description.append(description)
return ",".join(code_description)
def get_mappings():
"""
Loads and processes multiple CSV and Excel files containing code mappings for various categories
(e.g., PROC, DIAG, Grouper, Rev, Admit Type), and returns a dictionary of crosswalks for each code type.
Returns:
dict: A dictionary containing mappings for PROC, DIAG, Grouper, Rev, and Admit Type codes.
"""
all_mappings = {}
# PROC codes
mapping_path = "crosswalk/mapping_csvs/"
all_proc_mappings = pd.concat([pd.read_csv(os.path.join(mapping_path, "proc_cd", file), dtype=str) for file in os.listdir(os.path.join(mapping_path, "proc_cd")) if file.endswith(".csv")])
proc_mapping = CrosswalkBuilder().from_df(all_proc_mappings, from_col="Code", to_col="Description")
all_mappings['proc_mapping'] = proc_mapping
# Diag codes
all_diag_mappings = pd.concat([pd.read_csv(os.path.join(mapping_path, "diag_cd", file), dtype=str) for file in os.listdir(os.path.join(mapping_path, "diag_cd")) if file.endswith(".csv")])
diag_mapping = CrosswalkBuilder().from_df(all_diag_mappings, from_col="Code", to_col="Description")
all_mappings['diag_mapping'] = diag_mapping
# Grouper codes
all_grouper_mappings = pd.concat([pd.read_csv(os.path.join(mapping_path, "grouper_cd", file), dtype=str) for file in os.listdir(os.path.join(mapping_path, "grouper_cd")) if file.endswith(".csv")])
grouper_mapping = CrosswalkBuilder().from_df(all_grouper_mappings, from_col="Code", to_col="Description")
grouper_version_mapping = CrosswalkBuilder().from_df(all_grouper_mappings, from_col="Code", to_col="Version")
all_mappings['grouper_mapping'] = grouper_mapping
all_mappings['grouper_version_mapping'] = grouper_version_mapping
# Rev codes
rev_mapping = CrosswalkBuilder().from_excel(path=os.path.join(mapping_path, "rev_cd", "rev_mapping.csv"), from_col="Code", to_col="Description")
all_mappings['rev_mapping'] = rev_mapping
# Admit Type codes
admit_mapping = CrosswalkBuilder().from_excel(path=os.path.join(mapping_path, "admit_type_cd", "admit_mapping.csv"), from_col="Code", to_col="Description")
all_mappings['admit_mapping'] = admit_mapping
return all_mappings
def code_description_mapping(answer_dicts, all_mappings):
"""
Maps code descriptions to relevant fields in the answer dictionaries using provided mappings.
Args:
answer_dicts (list): List of dictionaries containing codes to be mapped.
all_mappings (dict): Dictionary containing the mappings for different code types.
Returns:
list: Updated list of dictionaries with code descriptions added.
"""
for answer_dict in answer_dicts:
proc_code = answer_dict.get("CPT4_PROC_CD")
proc_mod = answer_dict.get("CPT4_PROC_MOD")
diag_code = answer_dict.get("DIAG_CD")
rev_code = answer_dict.get("REVENUE_CD")
grouper_code = answer_dict.get("FACILITY_GROUPER_CD")
admit_type_code = answer_dict.get("CLAIM_ADMIT_TYPE_CD")
answer_dict["CPT4_PROC_DESC"] = get_code_description(proc_code, all_mappings['proc_mapping'])
answer_dict["CPT4_PROC_MOD_DESC"] = get_code_description(proc_mod, all_mappings['proc_mapping'])
answer_dict["DIAG_CD_DESC"] = get_code_description(diag_code, all_mappings['diag_mapping'])
answer_dict["REVENUE_CD_DESC"] = get_code_description(rev_code, all_mappings['rev_mapping'])
answer_dict["FACILITY_GROUPER_DESC"] = get_code_description(grouper_code, all_mappings['grouper_mapping'])
answer_dict["GROUPER_TYPE"] = get_code_description(grouper_code, all_mappings['grouper_version_mapping'])
answer_dict["AUTH_ADMIT_TYPE_DESC"] = get_code_description(admit_type_code, all_mappings['admit_mapping'])
return answer_dicts
def get_code_breakout(answer_dicts: list[dict], filename: str, all_dataset: dict):
"""
Extracts and maps codes from contract data, including primary, indirect, and description mappings.
Args:
answer_dicts (list[dict]): List of dictionaries containing contract-related answers.
filename (str): The name of the file being processed.
all_dataset (dict): Dictionary containing crosswalk and mappings for code translation.
Returns:
dict: A dictionary with primary, indirect/derived, and mapped code descriptions.
"""
# Code Primary (Pull codes directly from CONTRACT_SERVICE_CD_OR_DESC and CONTRACT_REIMBURSEMENT_METHOD)
code_primary_answers = code_primary(answer_dicts, filename)
# Only used for Implicit. Code Indirect (Derive codes indirectly from CONTRACT_SERVICE_CD_OR_DESC and CONTRACT_REIMBURSEMENT_METHOD)
code_indirect_answers = code_indirect(code_primary_answers, all_dataset['codes_crosswalk'])
# Code Descriptions (Pull standardized descriptions from codes)
code_description_answers = code_description_mapping(code_indirect_answers, all_dataset['codes_mappings'])
return code_description_answers