| |
| |
| |
| |
| """ |
| Script to create doctor evaluation dataset with correct model predictions. |
| This script extracts ECG-QA templates with correct model outputs from llama3b_flamingo_predictions.jsonl |
| and creates organized folders with ECG plots, CSV data, and evaluation materials. |
| """ |
|
|
| import json |
| import os |
| import re |
| import pandas as pd |
| import numpy as np |
| import matplotlib.pyplot as plt |
| import wfdb |
| from typing import Dict, List, Tuple |
| from collections import defaultdict |
| from tqdm import tqdm |
|
|
| |
| from opentslm.time_series_datasets.ecg_qa.ECGQACoTQADataset import ECGQACoTQADataset |
| from opentslm.time_series_datasets.ecg_qa.plot_example import get_ptbxl_ecg_path |
|
|
| |
| MODEL_PREDICTIONS_FILE = "/Users/planger/Development/EmbedHealth/evaluation/embedhealth/ecg_qa_cot/llama3b_flamingo_predictions.jsonl" |
| OUTPUT_DIR = "ecg_doctor_eval" |
| SAMPLES_PER_TEMPLATE = 2 |
|
|
| def extract_answer_from_generated(generated_text: str) -> str: |
| """Extract the final answer from generated text after 'Answer: '""" |
| if "Answer: " not in generated_text: |
| return generated_text.strip() |
| |
| answer = generated_text.split("Answer: ")[-1].strip() |
| |
| answer = re.sub(r'<\|.*?\|>|<eos>$', '', answer).strip() |
| answer = re.sub(r'\.$', '', answer).strip() |
| return answer |
|
|
| def is_correct_prediction(generated_text: str, correct_answer: str) -> bool: |
| """Check if the model prediction matches the correct answer""" |
| predicted_answer = extract_answer_from_generated(generated_text) |
| return predicted_answer.lower().strip() == correct_answer.lower().strip() |
|
|
| def load_model_predictions() -> Dict[int, List[Dict]]: |
| """Load model predictions and group by template_id""" |
| print(f"Loading model predictions from {MODEL_PREDICTIONS_FILE}") |
| |
| template_predictions = defaultdict(list) |
| |
| with open(MODEL_PREDICTIONS_FILE, 'r', encoding='utf-8') as f: |
| for line_num, line in enumerate(tqdm(f, desc="Loading predictions"), 1): |
| try: |
| data = json.loads(line.strip()) |
| |
| template_id = data.get('template_id') |
| if template_id is None: |
| continue |
| |
| |
| generated_text = data.get('generated', '') |
| correct_answer = data.get('correct_answer', '') |
| |
| if is_correct_prediction(generated_text, correct_answer): |
| template_predictions[template_id].append({ |
| 'template_id': template_id, |
| 'ecg_id': data.get('ecg_id', [None])[0] if data.get('ecg_id') else None, |
| 'generated': generated_text, |
| 'correct_answer': correct_answer, |
| 'pre_prompt': data.get('pre_prompt', ''), |
| 'line_number': line_num |
| }) |
| |
| except Exception as e: |
| print(f"Error processing line {line_num}: {e}") |
| continue |
| |
| print(f"Found correct predictions for {len(template_predictions)} templates") |
| return template_predictions |
|
|
| def extract_clinical_context(pre_prompt: str) -> str: |
| """Extract clinical context from pre_prompt""" |
| if 'Clinical Context:' in pre_prompt: |
| context_start = pre_prompt.find('Clinical Context:') |
| context_end = pre_prompt.find('\n\n', context_start) |
| if context_end == -1: |
| context_end = pre_prompt.find('\n', context_start) |
| if context_end != -1: |
| return pre_prompt[context_start:context_end].strip() |
| return "Clinical context not available" |
|
|
| def extract_question_from_prompt(pre_prompt: str) -> str: |
| """Extract the question from pre_prompt - this is the authoritative question for each sample""" |
| if 'Question: ' in pre_prompt: |
| question_start = pre_prompt.find('Question: ') |
| question_end = pre_prompt.find('\n\n', question_start) |
| if question_end == -1: |
| question_end = pre_prompt.find('\n', question_start) |
| if question_end != -1: |
| return pre_prompt[question_start:question_end].strip() |
| return "Question not available" |
|
|
| def get_answer_options_for_template(template_id: int) -> List[str]: |
| """Get answer options for a template""" |
| try: |
| return ECGQACoTQADataset.get_possible_answers_for_template(template_id) |
| except Exception as e: |
| print(f"Warning: Could not get answer options for template {template_id}: {e}") |
| return [] |
|
|
| def load_ecg_data(ecg_id: int) -> Tuple[np.ndarray, str]: |
| """Load ECG data for a given ECG ID""" |
| try: |
| ecg_path = get_ptbxl_ecg_path(ecg_id) |
| |
| if not os.path.exists(ecg_path + '.dat'): |
| raise FileNotFoundError(f"ECG file not found: {ecg_path}.dat") |
| |
| |
| ecg_data, meta = wfdb.rdsamp(ecg_path) |
| |
| |
| sampling_freq = meta.get('fs', 500) |
| |
| return ecg_data, sampling_freq |
| |
| except Exception as e: |
| raise RuntimeError(f"Failed to load ECG {ecg_id}: {e}") |
|
|
| def downsample_to_100hz(ecg_data: np.ndarray, original_freq: int) -> np.ndarray: |
| """Downsample ECG data to 100Hz""" |
| if original_freq == 100: |
| return ecg_data |
| |
| |
| downsample_factor = original_freq // 100 |
| |
| |
| downsampled_data = ecg_data[::downsample_factor] |
| |
| return downsampled_data |
|
|
| def save_ecg_as_csv(ecg_data: np.ndarray, output_dir: str, ecg_id: int): |
| """Save ECG data as separate CSV files for each lead""" |
| |
| if ecg_data.shape[0] > 1000: |
| ecg_data = downsample_to_100hz(ecg_data, 500) |
| |
| lead_names = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"] |
| |
| for lead_idx, lead_name in enumerate(lead_names): |
| if lead_idx < ecg_data.shape[1]: |
| lead_data = ecg_data[:, lead_idx] |
| |
| |
| time_points = np.arange(len(lead_data)) / 100.0 |
| df = pd.DataFrame({ |
| 'time_seconds': time_points, |
| 'signal_mV': lead_data |
| }) |
| |
| |
| csv_filename = f"{output_dir}/lead_{lead_name}.csv" |
| df.to_csv(csv_filename, index=False) |
|
|
| def create_ecg_plot(ecg_data: np.ndarray, template_id: int, ecg_id: int, |
| question: str, answer_options: List[str], |
| clinical_context: str, model_output: str, |
| correct_answer: str, output_dir: str): |
| """Create ECG plot with all information""" |
| |
| |
| if ecg_data.shape[0] > 1000: |
| ecg_data = downsample_to_100hz(ecg_data, 500) |
| |
| |
| fig, axes = plt.subplots(12, 1, figsize=(14, 24)) |
| fig.suptitle(f"Template {template_id}: ECG Analysis\nECG ID: {ecg_id}", |
| fontsize=16, fontweight='bold') |
| |
| |
| time_points = np.arange(0, 10, 0.01) |
| |
| |
| lead_names = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"] |
| for i, (ax, lead_name) in enumerate(zip(axes, lead_names)): |
| if i < ecg_data.shape[1]: |
| |
| ax.plot(time_points, ecg_data[:, i], linewidth=2, color="k", alpha=1.0) |
| |
| |
| |
| ax.vlines(np.arange(0, 10, 0.2), -2.5, 2.5, colors="r", alpha=0.3, linewidth=0.5) |
| ax.hlines(np.arange(-2.5, 2.5, 0.5), 0, 10, colors="r", alpha=0.3, linewidth=0.5) |
| |
| |
| ax.vlines(np.arange(0, 10, 0.04), -2.5, 2.5, colors="r", alpha=0.1, linewidth=0.3) |
| ax.hlines(np.arange(-2.5, 2.5, 0.1), 0, 10, colors="r", alpha=0.1, linewidth=0.3) |
| |
| ax.set_xticks(np.arange(0, 11, 1.0)) |
| ax.set_ylabel(f'Lead {lead_name} (mV)', fontweight='bold') |
| ax.margins(0.0) |
| ax.set_ylim(-2.5, 2.5) |
| ax.set_title(f'Lead {lead_name}', fontweight='bold', pad=10) |
| else: |
| ax.set_title(f'Lead {lead_name} (not available)', fontweight='bold', pad=10) |
| ax.text(0.5, 0.5, 'Lead not available', ha='center', va='center', transform=ax.transAxes) |
| |
| |
| info_text = f"""Question: {question} |
| |
| Answer Options: {' | '.join(answer_options[:5])}{'...' if len(answer_options) > 5 else ''} |
| |
| Clinical Context: {clinical_context[:200]}{'...' if len(clinical_context) > 200 else ''} |
| |
| Model Output: {model_output[:300]}{'...' if len(model_output) > 300 else ''} |
| |
| Expected Answer: {correct_answer}""" |
| |
| fig.text(0.02, 0.02, info_text, fontsize=9, transform=fig.transFigure, |
| bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgray", alpha=0.8)) |
| |
| |
| plot_filename = f"{output_dir}/ecg_plot.png" |
| fig.savefig(plot_filename, dpi=300, bbox_inches='tight', facecolor='white') |
| plt.close(fig) |
| |
| return plot_filename |
|
|
| def create_evaluation_text_file(output_dir: str, template_id: int, ecg_id: int, |
| question: str, answer_options: List[str], |
| clinical_context: str, model_output: str, |
| correct_answer: str): |
| """Create a text file with all evaluation information""" |
| |
| txt_filename = f"{output_dir}/evaluation_info.txt" |
| with open(txt_filename, 'w') as f: |
| f.write(f"ECG-QA Doctor Evaluation\n") |
| f.write(f"=" * 50 + "\n\n") |
| |
| f.write(f"Template ID: {template_id}\n") |
| f.write(f"ECG ID: {ecg_id}\n\n") |
| |
| f.write(f"Question:\n{question}\n\n") |
| |
| f.write(f"Answer Options:\n") |
| for i, option in enumerate(answer_options, 1): |
| f.write(f"{i}. {option}\n") |
| f.write(f"\n") |
| |
| f.write(f"Clinical Context:\n{clinical_context}\n\n") |
| |
| f.write(f"Model Output (Llama3B-Flamingo):\n{model_output}\n\n") |
| |
| f.write(f"Expected Answer: {correct_answer}\n") |
|
|
| def create_doctor_evaluation_dataset(): |
| """Main function to create the doctor evaluation dataset""" |
| |
| print("Creating doctor evaluation dataset...") |
| print("Note: Each template_id can have multiple different questions - using the specific question from each sample") |
| |
| |
| os.makedirs(OUTPUT_DIR, exist_ok=True) |
| |
| |
| template_predictions = load_model_predictions() |
| |
| if not template_predictions: |
| print("No correct predictions found!") |
| return |
| |
| |
| processed_templates = 0 |
| |
| for template_id in sorted(template_predictions.keys()): |
| predictions = template_predictions[template_id] |
| |
| if len(predictions) < SAMPLES_PER_TEMPLATE: |
| print(f"Template {template_id}: Only {len(predictions)} correct predictions, skipping") |
| continue |
| |
| print(f"\nProcessing template {template_id} with {len(predictions)} correct predictions") |
| |
| |
| answer_options = get_answer_options_for_template(template_id) |
| if not answer_options: |
| print(f"Template {template_id}: No answer options found, skipping") |
| continue |
| |
| |
| samples_to_process = predictions[:SAMPLES_PER_TEMPLATE] |
| |
| for sample_idx, prediction in enumerate(samples_to_process, 1): |
| try: |
| ecg_id = prediction['ecg_id'] |
| if ecg_id is None: |
| print(f"Template {template_id}, Sample {sample_idx}: No ECG ID, skipping") |
| continue |
| |
| print(f" Processing sample {sample_idx}: ECG {ecg_id}") |
| |
| |
| sample_dir = f"{OUTPUT_DIR}/template_{template_id:02d}/sample{sample_idx}" |
| os.makedirs(sample_dir, exist_ok=True) |
| |
| |
| clinical_context = extract_clinical_context(prediction['pre_prompt']) |
| question = extract_question_from_prompt(prediction['pre_prompt']) |
| model_output = prediction['generated'] |
| correct_answer = prediction['correct_answer'] |
| |
| |
| try: |
| ecg_data, sampling_freq = load_ecg_data(ecg_id) |
| print(f" Loaded ECG data: {ecg_data.shape}, {sampling_freq}Hz") |
| except Exception as e: |
| print(f" Error loading ECG {ecg_id}: {e}") |
| continue |
| |
| |
| try: |
| save_ecg_as_csv(ecg_data, sample_dir, ecg_id) |
| print(f" Saved ECG CSV files") |
| except Exception as e: |
| print(f" Error saving ECG CSV: {e}") |
| |
| |
| try: |
| plot_filename = create_ecg_plot( |
| ecg_data, template_id, ecg_id, question, answer_options, |
| clinical_context, model_output, correct_answer, sample_dir |
| ) |
| print(f" Created ECG plot: {plot_filename}") |
| except Exception as e: |
| print(f" Error creating ECG plot: {e}") |
| |
| |
| try: |
| create_evaluation_text_file( |
| sample_dir, template_id, ecg_id, question, answer_options, |
| clinical_context, model_output, correct_answer |
| ) |
| print(f" Created evaluation text file") |
| except Exception as e: |
| print(f" Error creating text file: {e}") |
| |
| except Exception as e: |
| print(f" Error processing sample {sample_idx}: {e}") |
| continue |
| |
| processed_templates += 1 |
| print(f"Completed template {template_id}") |
| |
| print(f"\nDoctor evaluation dataset creation completed!") |
| print(f"Processed {processed_templates} templates") |
| print(f"Output directory: {OUTPUT_DIR}") |
| |
| |
| create_summary_file(template_predictions) |
|
|
| def create_summary_file(template_predictions: Dict[int, List[Dict]]): |
| """Create a summary file with statistics""" |
| summary_file = f"{OUTPUT_DIR}/dataset_summary.txt" |
| |
| with open(summary_file, 'w') as f: |
| f.write("ECG-QA Doctor Evaluation Dataset Summary\n") |
| f.write("=" * 50 + "\n\n") |
| |
| f.write(f"Total templates with correct predictions: {len(template_predictions)}\n") |
| f.write(f"Samples per template: {SAMPLES_PER_TEMPLATE}\n") |
| f.write(f"Total samples created: {len(template_predictions) * SAMPLES_PER_TEMPLATE}\n\n") |
| |
| f.write("Template Statistics:\n") |
| f.write("-" * 30 + "\n") |
| |
| for template_id in sorted(template_predictions.keys()): |
| predictions = template_predictions[template_id] |
| f.write(f"Template {template_id:2d}: {len(predictions):3d} correct predictions\n") |
| |
| f.write(f"\nDataset Structure:\n") |
| f.write(f"ecg_doctor_eval/\n") |
| f.write(f"βββ template_01/\n") |
| f.write(f"β βββ sample1/\n") |
| f.write(f"β β βββ ecg_plot.png\n") |
| f.write(f"β β βββ evaluation_info.txt\n") |
| f.write(f"β β βββ lead_I.csv\n") |
| f.write(f"β β βββ lead_II.csv\n") |
| f.write(f"β β βββ ... (all 12 leads)\n") |
| f.write(f"β βββ sample2/\n") |
| f.write(f"β βββ ... (same structure)\n") |
| f.write(f"βββ template_02/\n") |
| f.write(f"β βββ ...\n") |
| f.write(f"βββ dataset_summary.txt\n") |
| |
| f.write(f"\nNotes:\n") |
| f.write(f"- All predictions are CORRECT (model answer matches expected answer)\n") |
| f.write(f"- ECG data is downsampled to 100Hz for consistency\n") |
| f.write(f"- Each sample includes clinical context, question, answer options, and model reasoning\n") |
| f.write(f"- CSV files contain time series data for each ECG lead\n") |
| |
| print(f"Summary file created: {summary_file}") |
|
|
| if __name__ == "__main__": |
| try: |
| create_doctor_evaluation_dataset() |
| except Exception as e: |
| print(f"Error: {e}") |
| import traceback |
| traceback.print_exc() |
|
|