Delete archive
Browse files- archive/classification/aggregate_subject_metrics.py +0 -96
- archive/classification/dataset_curate.py +0 -66
- archive/classification/feature_analysis.py +0 -93
- archive/classification/prepare_multiclass_data.py +0 -71
- archive/classification/re_plotter.py +0 -494
- archive/classification/run_benchmarking.py +0 -64
- archive/classification/run_classification.py +0 -34
- archive/classification/run_inference.py +0 -297
- archive/classification/run_lead_importance_test.py +0 -128
- archive/classification/train_and_save_models.py +0 -427
- archive/digitization/README.md +0 -21
- archive/digitization/run_ecg.py +0 -302
archive/classification/aggregate_subject_metrics.py
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import numpy as np
|
| 3 |
-
import os
|
| 4 |
-
import re
|
| 5 |
-
from sklearn.metrics import accuracy_score, precision_score, f1_score
|
| 6 |
-
|
| 7 |
-
def get_base_subject_id(subject_id_with_beat):
|
| 8 |
-
"""
|
| 9 |
-
Extracts the base subject ID by removing suffixes like _beatN, beatN, -index_beatN, etc.
|
| 10 |
-
"""
|
| 11 |
-
# Regex to catch variations like _beat1, beat1, -index_beat1, INDEX_beat1, etc.
|
| 12 |
-
# It looks for 'beat' preceded by common delimiters and captures everything before it.
|
| 13 |
-
match = re.search(r'^(.*?)(?:[_\s-](?:index|INDEX|post|POST|checkpoint|POST PCI|POST PCI \d|POST \d)*)?(?:[_\s-]beat\d+)$', str(subject_id_with_beat))
|
| 14 |
-
if match:
|
| 15 |
-
return match.group(1).strip()
|
| 16 |
-
|
| 17 |
-
# Fallback: just split by 'beat' if regex is too specific
|
| 18 |
-
if 'beat' in str(subject_id_with_beat):
|
| 19 |
-
return str(subject_id_with_beat).split('beat')[0].rstrip('_ -').strip()
|
| 20 |
-
|
| 21 |
-
return str(subject_id_with_beat).strip()
|
| 22 |
-
|
| 23 |
-
def calculate_metrics(df_subject):
|
| 24 |
-
"""
|
| 25 |
-
Calculates metrics for subject-level predictions.
|
| 26 |
-
"""
|
| 27 |
-
# Ground truth: since all beats for a subject have the same label, we just take the first
|
| 28 |
-
y_true = df_subject['true_label_numeric'].values
|
| 29 |
-
|
| 30 |
-
# Prediction based on averaged probability for Pre-Procedural MI
|
| 31 |
-
# Assuming probability_Pre-Procedural MI >= 0.5 is Pre-Procedural MI (1)
|
| 32 |
-
y_pred = (df_subject['avg_prob_pre_procedural'] >= 0.5).astype(int)
|
| 33 |
-
|
| 34 |
-
acc = accuracy_score(y_true, y_pred)
|
| 35 |
-
prec = precision_score(y_true, y_pred, zero_division=0)
|
| 36 |
-
f1 = f1_score(y_true, y_pred, zero_division=0)
|
| 37 |
-
|
| 38 |
-
return acc, prec, f1
|
| 39 |
-
|
| 40 |
-
def main():
|
| 41 |
-
prediction_dir = r'output/benchmark_results/ecg_matrix_dataset_segmented/model_predictions'
|
| 42 |
-
|
| 43 |
-
if not os.path.exists(prediction_dir):
|
| 44 |
-
print(f"Directory not found: {prediction_dir}")
|
| 45 |
-
return
|
| 46 |
-
|
| 47 |
-
# Filter out the combined file if it exists
|
| 48 |
-
files = [f for f in os.listdir(prediction_dir) if f.endswith('.csv') and f != 'all_models_predictions_combined.csv']
|
| 49 |
-
|
| 50 |
-
results = []
|
| 51 |
-
|
| 52 |
-
print(f"{'Model':<20} | {'Accuracy':<10} | {'Precision':<10} | {'F1 Score':<10}")
|
| 53 |
-
print("-" * 60)
|
| 54 |
-
|
| 55 |
-
for file in files:
|
| 56 |
-
model_name = file.replace('_predictions.csv', '')
|
| 57 |
-
file_path = os.path.join(prediction_dir, file)
|
| 58 |
-
|
| 59 |
-
try:
|
| 60 |
-
df = pd.read_csv(file_path)
|
| 61 |
-
|
| 62 |
-
# 1. Extract base subject ID
|
| 63 |
-
df['base_subject_id'] = df['subject_id'].apply(get_base_subject_id)
|
| 64 |
-
|
| 65 |
-
# 2. Group by base subject ID
|
| 66 |
-
# We average the probability and take the first true label (since it should be invariant for a subject)
|
| 67 |
-
subject_group = df.groupby('base_subject_id').agg({
|
| 68 |
-
'probability_Pre-Procedural MI': 'mean',
|
| 69 |
-
'true_label_numeric': 'first'
|
| 70 |
-
}).reset_index()
|
| 71 |
-
|
| 72 |
-
subject_group.rename(columns={'probability_Pre-Procedural MI': 'avg_prob_pre_procedural'}, inplace=True)
|
| 73 |
-
|
| 74 |
-
# 3. Calculate Metrics
|
| 75 |
-
acc, prec, f1 = calculate_metrics(subject_group)
|
| 76 |
-
|
| 77 |
-
results.append({
|
| 78 |
-
'Model': model_name,
|
| 79 |
-
'Accuracy': acc,
|
| 80 |
-
'Precision': prec,
|
| 81 |
-
'F1 Score': f1
|
| 82 |
-
})
|
| 83 |
-
|
| 84 |
-
print(f"{model_name:<20} | {acc:<10.4f} | {prec:<10.4f} | {f1:<10.4f}")
|
| 85 |
-
|
| 86 |
-
except Exception as e:
|
| 87 |
-
print(f"Error processing {file}: {e}")
|
| 88 |
-
|
| 89 |
-
# Save summary to CSV
|
| 90 |
-
summary_df = pd.DataFrame(results)
|
| 91 |
-
summary_path = os.path.join(prediction_dir, 'subject_level_metrics_summary.csv')
|
| 92 |
-
summary_df.to_csv(summary_path, index=False)
|
| 93 |
-
print(f"\nSummary saved to: {summary_path}")
|
| 94 |
-
|
| 95 |
-
if __name__ == "__main__":
|
| 96 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/dataset_curate.py
DELETED
|
@@ -1,66 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import pandas as pd
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
-
def process_ecg_data_wide_format(root_folder):
|
| 6 |
-
"""
|
| 7 |
-
Process ECG data in wide format (one row per timestamp with all leads as columns)
|
| 8 |
-
"""
|
| 9 |
-
lead_names = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
|
| 10 |
-
|
| 11 |
-
all_data = []
|
| 12 |
-
base_path = Path(root_folder)
|
| 13 |
-
|
| 14 |
-
# Process both classes
|
| 15 |
-
for class_name, class_folder in [("Pre-Procedural MI", "Pre"),
|
| 16 |
-
("Post-Procedural MI", "Post")]:
|
| 17 |
-
class_path = base_path / class_folder
|
| 18 |
-
|
| 19 |
-
if class_path.exists():
|
| 20 |
-
for csv_file in class_path.glob("*.csv"):
|
| 21 |
-
try:
|
| 22 |
-
df = pd.read_csv(csv_file)
|
| 23 |
-
subject_id = csv_file.stem.replace('_hr', '')
|
| 24 |
-
|
| 25 |
-
# Add metadata columns
|
| 26 |
-
df['subject_id'] = subject_id
|
| 27 |
-
df['class'] = class_name
|
| 28 |
-
df['filename'] = csv_file.name
|
| 29 |
-
df['timestamp'] = range(len(df))
|
| 30 |
-
|
| 31 |
-
all_data.append(df)
|
| 32 |
-
print(f"Processed {csv_file.name}")
|
| 33 |
-
print(class_name)
|
| 34 |
-
|
| 35 |
-
except Exception as e:
|
| 36 |
-
print(f"Error processing {csv_file.name}: {str(e)}")
|
| 37 |
-
|
| 38 |
-
if all_data:
|
| 39 |
-
# Combine all DataFrames
|
| 40 |
-
combined_df = pd.concat(all_data, ignore_index=True)
|
| 41 |
-
|
| 42 |
-
# Reorder columns to have metadata first
|
| 43 |
-
metadata_cols = ['subject_id', 'timestamp', 'class', 'filename']
|
| 44 |
-
signal_cols = [col for col in combined_df.columns if col not in metadata_cols]
|
| 45 |
-
combined_df = combined_df[metadata_cols + signal_cols]
|
| 46 |
-
|
| 47 |
-
return combined_df
|
| 48 |
-
return None
|
| 49 |
-
|
| 50 |
-
# Usage for wide format:
|
| 51 |
-
if __name__ == "__main__":
|
| 52 |
-
folder_path = "."
|
| 53 |
-
|
| 54 |
-
if os.path.exists(folder_path):
|
| 55 |
-
print("Processing ECG data in wide format...")
|
| 56 |
-
wide_df = process_ecg_data_wide_format(folder_path)
|
| 57 |
-
|
| 58 |
-
if wide_df is not None:
|
| 59 |
-
wide_df.to_csv("combined_ecg_data_wide.csv", index=False)
|
| 60 |
-
print("Wide format data saved to combined_ecg_data_wide.csv")
|
| 61 |
-
print(f"Shape: {wide_df.shape}")
|
| 62 |
-
print(f"Columns: {list(wide_df.columns)}")
|
| 63 |
-
print("\nSample data:")
|
| 64 |
-
print(wide_df.head())
|
| 65 |
-
else:
|
| 66 |
-
print(f"Folder '{folder_path}' not found.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/feature_analysis.py
DELETED
|
@@ -1,93 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import pandas as pd
|
| 3 |
-
import numpy as np
|
| 4 |
-
from utils.mlp_feature_downsample import (
|
| 5 |
-
perform_comprehensive_heartbeat_analysis,
|
| 6 |
-
perform_full_signal_analysis,
|
| 7 |
-
DEFAULT_HEARTBEAT_OUTPUT_DIR,
|
| 8 |
-
DEFAULT_TARGET_FS,
|
| 9 |
-
)
|
| 10 |
-
|
| 11 |
-
if __name__ == "__main__":
|
| 12 |
-
|
| 13 |
-
# Create output directory
|
| 14 |
-
os.makedirs('output', exist_ok=True)
|
| 15 |
-
|
| 16 |
-
# Use the processed CSV from the previous step
|
| 17 |
-
combined_input_csv = 'data/ecg_dataset_processed.csv'
|
| 18 |
-
|
| 19 |
-
# Load and verify the data
|
| 20 |
-
df_combined = pd.read_csv(combined_input_csv)
|
| 21 |
-
print(f"Loaded combined dataset shape: {df_combined.shape}")
|
| 22 |
-
print(f"Unique subjects: {df_combined['subject_id'].nunique()}")
|
| 23 |
-
|
| 24 |
-
print("\n" + "="*50)
|
| 25 |
-
print("RUNNING SEGMENTED HEARTBEAT ANALYSIS (500 Hz)")
|
| 26 |
-
print("="*50)
|
| 27 |
-
|
| 28 |
-
heartbeat_results = perform_comprehensive_heartbeat_analysis(
|
| 29 |
-
input_csv=combined_input_csv,
|
| 30 |
-
output_dir=DEFAULT_HEARTBEAT_OUTPUT_DIR,
|
| 31 |
-
pca_components=50,
|
| 32 |
-
shap_samples=100,
|
| 33 |
-
shap_background_size=50,
|
| 34 |
-
hidden_layer_sizes=(100, 50),
|
| 35 |
-
max_iter=1000,
|
| 36 |
-
target_fs=500,
|
| 37 |
-
)
|
| 38 |
-
|
| 39 |
-
print("\n" + "="*50)
|
| 40 |
-
print("RUNNING FULL SIGNAL ANALYSIS (500 Hz)")
|
| 41 |
-
print("="*50)
|
| 42 |
-
|
| 43 |
-
full_signal_results = perform_full_signal_analysis(
|
| 44 |
-
input_csv=combined_input_csv,
|
| 45 |
-
output_dir='output/full_signal_analysis',
|
| 46 |
-
pca_components=50,
|
| 47 |
-
shap_samples=100,
|
| 48 |
-
shap_background_size=50,
|
| 49 |
-
hidden_layer_sizes=(100, 50),
|
| 50 |
-
max_iter=1000,
|
| 51 |
-
target_fs=500,
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
# Print Summary Results
|
| 55 |
-
print("\n" + "#" * 60)
|
| 56 |
-
print("FINAL SUMMARY RESULTS")
|
| 57 |
-
print("#" * 60)
|
| 58 |
-
|
| 59 |
-
if heartbeat_results is not None and 'metrics' in heartbeat_results:
|
| 60 |
-
m = heartbeat_results['metrics']
|
| 61 |
-
print("\n--- Segmented Heartbeat Analysis ---")
|
| 62 |
-
print(f" Accuracy: {m['accuracy']:.4f}")
|
| 63 |
-
print(f" Precision: {m['precision']:.4f}")
|
| 64 |
-
print(f" Recall: {m['recall']:.4f}")
|
| 65 |
-
print(f" F1-score: {m['f1']:.4f}")
|
| 66 |
-
print(f" Sensitivity: {m['sensitivity']:.4f}")
|
| 67 |
-
print(f" Specificity: {m['specificity']:.4f}")
|
| 68 |
-
print("=" * 50)
|
| 69 |
-
'''
|
| 70 |
-
############################################## Supervised Classifier Benchmarking ##################################
|
| 71 |
-
#print("\nPerforming classifier benchmarking...")
|
| 72 |
-
benchmark_results, models = perform_benchmark(combined_input_csv)
|
| 73 |
-
|
| 74 |
-
print("\nBenchmarking Results:")
|
| 75 |
-
print(benchmark_results)
|
| 76 |
-
|
| 77 |
-
# Save all results to a summary file
|
| 78 |
-
results_summary = {
|
| 79 |
-
#'kmeans_results': kmeans_results,
|
| 80 |
-
'mlp_results': mlp_results,
|
| 81 |
-
'benchmark_results': benchmark_results,
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
# Save summary to file
|
| 85 |
-
summary_df = pd.DataFrame([
|
| 86 |
-
#{'model': 'K-Means', 'details': str(kmeans_results)},
|
| 87 |
-
#{'model': 'MLP', 'details': str(mlp_results)},
|
| 88 |
-
{'model': 'Benchmark', 'details': str(benchmark_results)},
|
| 89 |
-
])
|
| 90 |
-
|
| 91 |
-
summary_df.to_csv('output/analysis_summary.csv', index=False)
|
| 92 |
-
print("\nAnalysis summary saved to 'output/analysis_summary.csv'")
|
| 93 |
-
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/prepare_multiclass_data.py
DELETED
|
@@ -1,71 +0,0 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import os
|
| 3 |
-
|
| 4 |
-
if __name__ == "__main__":
|
| 5 |
-
# ── CONFIGURATION ──
|
| 6 |
-
# Input CSV file path
|
| 7 |
-
input_csv = 'data/combined_ecg_matrix_data_segmented.csv'
|
| 8 |
-
|
| 9 |
-
# Output CSV file path
|
| 10 |
-
output_csv = 'data/combined_ecg_matrix_data_filtered.csv'
|
| 11 |
-
|
| 12 |
-
# Option 1: Only include specific classes (set to None to use all)
|
| 13 |
-
# e.g. ['Pre', 'Post', 'Index']
|
| 14 |
-
include = None
|
| 15 |
-
|
| 16 |
-
# Option 2: Exclude specific classes (set to None to skip)
|
| 17 |
-
# e.g. ['Dimi']
|
| 18 |
-
exclude = None
|
| 19 |
-
|
| 20 |
-
# Option 3: Combine/merge classes under a new label (set to None to skip)
|
| 21 |
-
# e.g. {'Surgery': ['Pre', 'Post'], 'Other': ['Dimi', 'Discharge']}
|
| 22 |
-
combine = {'Post-Procedural MI': ['Discharge', 'Dimi', 'Post'], 'Pre-Procedural MI': ['Index', 'Pre']}
|
| 23 |
-
|
| 24 |
-
print(f"Loading data from '{input_csv}'...")
|
| 25 |
-
if not os.path.exists(input_csv):
|
| 26 |
-
print(f"Error: Input file '{input_csv}' not found.")
|
| 27 |
-
exit(1)
|
| 28 |
-
|
| 29 |
-
df = pd.read_csv(input_csv)
|
| 30 |
-
initial_rows = len(df)
|
| 31 |
-
print(f"Loaded {initial_rows} rows.")
|
| 32 |
-
|
| 33 |
-
if 'class' not in df.columns:
|
| 34 |
-
print("Error: 'class' column not found in input CSV.")
|
| 35 |
-
exit(1)
|
| 36 |
-
|
| 37 |
-
print(f"\nOriginal classes: {sorted(df['class'].dropna().unique())}")
|
| 38 |
-
for cls in sorted(df['class'].dropna().unique()):
|
| 39 |
-
print(f" {cls}: {len(df[df['class'] == cls])} rows")
|
| 40 |
-
|
| 41 |
-
# ── 1. Combine/Merge Classes ──
|
| 42 |
-
if combine:
|
| 43 |
-
print("\nApplying class combinations...")
|
| 44 |
-
for new_name, old_names in combine.items():
|
| 45 |
-
df.loc[df['class'].isin(old_names), 'class'] = new_name
|
| 46 |
-
print(f" Combined {old_names} -> '{new_name}'")
|
| 47 |
-
|
| 48 |
-
# ── 2. Include Classes ──
|
| 49 |
-
if include:
|
| 50 |
-
print(f"\nIncluding only: {include}")
|
| 51 |
-
df = df[df['class'].isin(include)]
|
| 52 |
-
|
| 53 |
-
# ── 3. Exclude Classes ──
|
| 54 |
-
if exclude:
|
| 55 |
-
print(f"\nExcluding: {exclude}")
|
| 56 |
-
df = df[~df['class'].isin(exclude)]
|
| 57 |
-
|
| 58 |
-
if len(df) == 0:
|
| 59 |
-
print("\nERROR: No data remaining after class filtering!")
|
| 60 |
-
exit(1)
|
| 61 |
-
|
| 62 |
-
print(f"\nData processing complete. Remaining rows: {len(df)} ({(len(df)/initial_rows)*100:.1f}%)")
|
| 63 |
-
|
| 64 |
-
final_classes = sorted(df['class'].dropna().unique())
|
| 65 |
-
print(f"\nFinal classes hierarchy: {final_classes}")
|
| 66 |
-
for cls in final_classes:
|
| 67 |
-
print(f" {cls}: {len(df[df['class'] == cls])} rows")
|
| 68 |
-
|
| 69 |
-
os.makedirs(os.path.dirname(output_csv), exist_ok=True)
|
| 70 |
-
df.to_csv(output_csv, index=False)
|
| 71 |
-
print(f"\nSaved filtered data to '{output_csv}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/re_plotter.py
DELETED
|
@@ -1,494 +0,0 @@
|
|
| 1 |
-
import numpy as np
|
| 2 |
-
import pandas as pd
|
| 3 |
-
import matplotlib.pyplot as plt
|
| 4 |
-
import os
|
| 5 |
-
from matplotlib.gridspec import GridSpec
|
| 6 |
-
|
| 7 |
-
def replot_idealized_shap_all_leads(shap_values_csv, top_regions_csv, output_dir, n_timesteps=40, fs=500):
|
| 8 |
-
"""
|
| 9 |
-
Replot idealized heartbeat with SHAP values for all leads below it in order of importance
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
# Create output directory
|
| 13 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 14 |
-
|
| 15 |
-
# Load SHAP values and top regions
|
| 16 |
-
print("Loading SHAP values and top regions...")
|
| 17 |
-
shap_df = pd.read_csv(shap_values_csv)
|
| 18 |
-
top_regions_df = pd.read_csv(top_regions_csv)
|
| 19 |
-
|
| 20 |
-
print(f"SHAP DataFrame shape: {shap_df.shape}")
|
| 21 |
-
print(f"Number of timesteps inferred from columns: {len([col for col in shap_df.columns if '_t' in col]) // 12}") # 12 leads
|
| 22 |
-
|
| 23 |
-
# Calculate mean absolute SHAP values for each feature
|
| 24 |
-
mean_shap_abs = np.abs(shap_df).mean(axis=0)
|
| 25 |
-
|
| 26 |
-
# Extract unique leads and their overall importance
|
| 27 |
-
leads = []
|
| 28 |
-
lead_importance = {}
|
| 29 |
-
|
| 30 |
-
for feature_name in mean_shap_abs.index:
|
| 31 |
-
if '_t' in feature_name:
|
| 32 |
-
lead = feature_name.split('_t')[0]
|
| 33 |
-
if lead not in lead_importance:
|
| 34 |
-
lead_importance[lead] = 0
|
| 35 |
-
lead_importance[lead] += mean_shap_abs[feature_name]
|
| 36 |
-
|
| 37 |
-
# Sort leads by overall importance
|
| 38 |
-
sorted_leads = sorted(lead_importance.items(), key=lambda x: x[1], reverse=True)
|
| 39 |
-
lead_names = [lead for lead, importance in sorted_leads]
|
| 40 |
-
lead_importances = [importance for lead, importance in sorted_leads]
|
| 41 |
-
|
| 42 |
-
print("Lead importance ranking:")
|
| 43 |
-
for i, (lead, importance) in enumerate(sorted_leads):
|
| 44 |
-
print(f"{i+1}. {lead}: {importance:.4f}")
|
| 45 |
-
|
| 46 |
-
# Dynamically determine n_timesteps from the data
|
| 47 |
-
actual_timesteps = infer_timesteps_from_data(shap_df, lead_names[0])
|
| 48 |
-
print(f"Using {actual_timesteps} timesteps based on data")
|
| 49 |
-
|
| 50 |
-
# Create time points in percentage (0-100%)
|
| 51 |
-
time_points_pct = np.linspace(0, 100, actual_timesteps)
|
| 52 |
-
|
| 53 |
-
# Create main plot with idealized ECG at top and all leads below
|
| 54 |
-
create_main_comprehensive_plot(lead_names, shap_df, time_points_pct, output_dir, lead_importances, actual_timesteps)
|
| 55 |
-
|
| 56 |
-
return lead_names, lead_importances
|
| 57 |
-
|
| 58 |
-
def infer_timesteps_from_data(shap_df, sample_lead):
|
| 59 |
-
"""Infer number of timesteps from the data"""
|
| 60 |
-
lead_columns = [col for col in shap_df.columns if col.startswith(sample_lead + '_t')]
|
| 61 |
-
return len(lead_columns)
|
| 62 |
-
|
| 63 |
-
def create_main_comprehensive_plot(lead_names, shap_df, time_points_pct, output_dir, lead_importances, n_timesteps):
|
| 64 |
-
"""
|
| 65 |
-
Create main comprehensive plot with idealized ECG at top and all leads' SHAP values below
|
| 66 |
-
"""
|
| 67 |
-
n_leads = len(lead_names)
|
| 68 |
-
|
| 69 |
-
# Create larger figure with better spacing
|
| 70 |
-
fig = plt.figure(figsize=(18, 4 + n_leads * 1.5)) # Increased size
|
| 71 |
-
|
| 72 |
-
# Create grid with more space for idealized ECG and better spacing for leads
|
| 73 |
-
gs = GridSpec(n_leads + 1, 1, figure=fig, height_ratios=[3] + [1.2] * n_leads, hspace=0.4)
|
| 74 |
-
|
| 75 |
-
# Plot 1: Idealized ECG at the top
|
| 76 |
-
ax_ideal = fig.add_subplot(gs[0])
|
| 77 |
-
plot_idealized_heartbeat(ax_ideal, time_points_pct)
|
| 78 |
-
|
| 79 |
-
# Plot 2: SHAP values for each lead below (in order of importance)
|
| 80 |
-
for i, lead in enumerate(lead_names):
|
| 81 |
-
ax_shap = fig.add_subplot(gs[i + 1])
|
| 82 |
-
|
| 83 |
-
# Get SHAP values for this lead
|
| 84 |
-
lead_shap_values = extract_lead_shap_values(shap_df, lead, n_timesteps)
|
| 85 |
-
|
| 86 |
-
# Plot SHAP bars for this lead with cardiac cycle regions
|
| 87 |
-
plot_lead_shap_bars(ax_shap, time_points_pct, lead_shap_values, lead, lead_importances[i], i + 1, n_leads)
|
| 88 |
-
|
| 89 |
-
plt.tight_layout()
|
| 90 |
-
plt.savefig(f'{output_dir}/idealized_with_all_leads_shap.png',
|
| 91 |
-
dpi=300, bbox_inches='tight')
|
| 92 |
-
plt.savefig(f'{output_dir}/idealized_with_all_leads_shap.pdf',
|
| 93 |
-
bbox_inches='tight')
|
| 94 |
-
plt.close()
|
| 95 |
-
|
| 96 |
-
print(f"Main comprehensive plot saved: {output_dir}/idealized_with_all_leads_shap.png")
|
| 97 |
-
|
| 98 |
-
def extract_lead_shap_values(shap_df, lead, n_timesteps):
|
| 99 |
-
"""
|
| 100 |
-
Extract SHAP values for a specific lead across all timesteps
|
| 101 |
-
"""
|
| 102 |
-
lead_shap_values = np.zeros(n_timesteps)
|
| 103 |
-
found_features = 0
|
| 104 |
-
|
| 105 |
-
for t in range(n_timesteps):
|
| 106 |
-
feature_name = f"{lead}_t{t}"
|
| 107 |
-
if feature_name in shap_df.columns:
|
| 108 |
-
# Take mean absolute SHAP value for this time point
|
| 109 |
-
lead_shap_values[t] = np.abs(shap_df[feature_name]).mean()
|
| 110 |
-
found_features += 1
|
| 111 |
-
else:
|
| 112 |
-
print(f"Warning: Feature {feature_name} not found in SHAP DataFrame")
|
| 113 |
-
lead_shap_values[t] = 0
|
| 114 |
-
|
| 115 |
-
print(f"Lead {lead} - Found {found_features}/{n_timesteps} features, SHAP range: [{lead_shap_values.min():.6f}, {lead_shap_values.max():.6f}]")
|
| 116 |
-
return lead_shap_values
|
| 117 |
-
|
| 118 |
-
def plot_idealized_heartbeat(ax, time_points_pct):
|
| 119 |
-
"""
|
| 120 |
-
Plot accurate idealized ECG heartbeat on given axis with proper waveform characteristics
|
| 121 |
-
"""
|
| 122 |
-
# Create time array
|
| 123 |
-
t = time_points_pct
|
| 124 |
-
|
| 125 |
-
# Initialize ECG signal
|
| 126 |
-
ideal_ecg = np.zeros_like(t, dtype=float)
|
| 127 |
-
|
| 128 |
-
# Define standard ECG regions with PR segment removed and P-wave extended
|
| 129 |
-
ecg_regions = [
|
| 130 |
-
{'name': 'P Wave', 'description': 'Atrial Depolarization', 'start': 0, 'end': 25, 'center': 12.5, 'color': 'lightblue', 'alpha': 0.5},
|
| 131 |
-
{'name': 'QRS Complex', 'description': 'Ventricular Depolarization', 'start': 25, 'end': 42, 'center': 33.5, 'color': 'lightgreen', 'alpha': 0.5},
|
| 132 |
-
{'name': 'ST Segment', 'description': 'Ventricular Plateau', 'start': 42, 'end': 58, 'center': 50, 'color': 'orange', 'alpha': 0.5},
|
| 133 |
-
{'name': 'T Wave', 'description': 'Ventricular Repolarization', 'start': 58, 'end': 83, 'center': 70.5, 'color': 'violet', 'alpha': 0.5},
|
| 134 |
-
{'name': 'TP Segment', 'description': 'Ventricular Diastole', 'start': 83, 'end': 100, 'center': 91.5, 'color': 'saddlebrown', 'alpha': 0.5}
|
| 135 |
-
]
|
| 136 |
-
|
| 137 |
-
# Define precise landmark positions and values FIRST to ensure consistency
|
| 138 |
-
# We align the signal generation to these landmarks
|
| 139 |
-
landmark_data = [
|
| 140 |
-
# P wave peak - Center of P region
|
| 141 |
-
{'time': 12.5, 'label': 'P', 'value': 0.25, 'description': 'Atrial Depolarization'},
|
| 142 |
-
# Q wave nadir - Early QRS
|
| 143 |
-
{'time': 27.0, 'label': 'Q', 'value': -0.15, 'description': 'Start of Ventricular Depolarization'},
|
| 144 |
-
# R wave peak - Center of QRS
|
| 145 |
-
{'time': 33.5, 'label': 'R', 'value': 1.0, 'description': 'Ventricular Depolarization Peak'},
|
| 146 |
-
# S wave nadir - Late QRS
|
| 147 |
-
{'time': 39.0, 'label': 'S', 'value': -0.25, 'description': 'End of Ventricular Depolarization'},
|
| 148 |
-
# T wave peak - Center of T region
|
| 149 |
-
{'time': 70.5, 'label': 'T', 'value': 0.35, 'description': 'Ventricular Repolarization'}
|
| 150 |
-
]
|
| 151 |
-
|
| 152 |
-
# Helper to create Gaussian waves
|
| 153 |
-
def gaussian(x, mu, sig, amp):
|
| 154 |
-
return amp * np.exp(-np.power(x - mu, 2.) / (2 * np.power(sig, 2.)))
|
| 155 |
-
|
| 156 |
-
# Create accurate ECG waveform components using Gaussians for natural shape
|
| 157 |
-
for i, time_val in enumerate(t):
|
| 158 |
-
val = 0.0
|
| 159 |
-
|
| 160 |
-
# P Wave: centered at 12.5, wide
|
| 161 |
-
val += gaussian(time_val, 12.5, 4.0, 0.25)
|
| 162 |
-
|
| 163 |
-
# Q Wave: centered at 27.0, narrow
|
| 164 |
-
val += gaussian(time_val, 27.0, 1.2, -0.15)
|
| 165 |
-
|
| 166 |
-
# R Wave: centered at 33.5, narrow and tall
|
| 167 |
-
val += gaussian(time_val, 33.5, 1.5, 1.0)
|
| 168 |
-
|
| 169 |
-
# S Wave: centered at 39.0, narrow
|
| 170 |
-
val += gaussian(time_val, 39.0, 1.2, -0.25)
|
| 171 |
-
|
| 172 |
-
# T Wave: centered at 70.5, wide
|
| 173 |
-
val += gaussian(time_val, 70.5, 6.0, 0.35)
|
| 174 |
-
|
| 175 |
-
# Assign to array
|
| 176 |
-
ideal_ecg[i] = val
|
| 177 |
-
|
| 178 |
-
# Plot cardiac cycle regions with increased alpha
|
| 179 |
-
for region in ecg_regions:
|
| 180 |
-
ax.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
|
| 181 |
-
|
| 182 |
-
# Add region labels - name above and description below
|
| 183 |
-
ax.text(region['center'], -0.45, region['name'],
|
| 184 |
-
ha='center', va='center', fontsize=9, fontweight='bold',
|
| 185 |
-
bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.9, edgecolor=region['color']),
|
| 186 |
-
zorder=10)
|
| 187 |
-
ax.text(region['center'], -0.65, region['description'],
|
| 188 |
-
ha='center', va='center', fontsize=8,
|
| 189 |
-
bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.8, edgecolor=region['color']),
|
| 190 |
-
zorder=10)
|
| 191 |
-
|
| 192 |
-
# Plot idealized ECG
|
| 193 |
-
ax.plot(t, ideal_ecg, 'k-', linewidth=2, label='Idealized ECG', zorder=5)
|
| 194 |
-
ax.set_title('Idealized ECG with Standard Regions', fontsize=16, fontweight='bold', pad=20)
|
| 195 |
-
ax.set_ylabel('Normalized Amplitude', fontsize=12)
|
| 196 |
-
ax.grid(True, alpha=0.3, zorder=1)
|
| 197 |
-
|
| 198 |
-
# Plot ECG landmarks with annotations
|
| 199 |
-
for landmark in landmark_data:
|
| 200 |
-
# Calculate actual value on the curve at this time
|
| 201 |
-
# This ensures the point is exactly ON the line, even if Gaussian summation shifts it slightly
|
| 202 |
-
curve_val = 0.0
|
| 203 |
-
t_lm = landmark['time']
|
| 204 |
-
curve_val += gaussian(t_lm, 12.5, 4.0, 0.25)
|
| 205 |
-
curve_val += gaussian(t_lm, 27.0, 1.2, -0.15)
|
| 206 |
-
curve_val += gaussian(t_lm, 33.5, 1.5, 1.0)
|
| 207 |
-
curve_val += gaussian(t_lm, 39.0, 1.2, -0.25)
|
| 208 |
-
curve_val += gaussian(t_lm, 70.5, 6.0, 0.35)
|
| 209 |
-
|
| 210 |
-
ax.plot(t_lm, curve_val, 'ro', markersize=8, zorder=10)
|
| 211 |
-
|
| 212 |
-
# Position text based on whether it's a peak or trough
|
| 213 |
-
if landmark['value'] > 0:
|
| 214 |
-
text_y_offset = 0.15
|
| 215 |
-
va = 'bottom'
|
| 216 |
-
else:
|
| 217 |
-
text_y_offset = -0.15
|
| 218 |
-
va = 'top'
|
| 219 |
-
|
| 220 |
-
ax.annotate(landmark['label'],
|
| 221 |
-
(t_lm, curve_val),
|
| 222 |
-
xytext=(0, text_y_offset * 30),
|
| 223 |
-
textcoords='offset points',
|
| 224 |
-
ha='center', va=va,
|
| 225 |
-
fontweight='bold',
|
| 226 |
-
fontsize=12,
|
| 227 |
-
bbox=dict(boxstyle="round,pad=0.3", facecolor='white', alpha=0.9, edgecolor='red'),
|
| 228 |
-
zorder=15)
|
| 229 |
-
|
| 230 |
-
ax.set_ylim(-0.8, 1.5) # Increased range to accommodate annotations
|
| 231 |
-
ax.set_xlim(0, 100)
|
| 232 |
-
|
| 233 |
-
# Remove x-axis labels and ticks for the top plot
|
| 234 |
-
ax.set_xticklabels([])
|
| 235 |
-
ax.set_xticks([])
|
| 236 |
-
ax.set_xlabel('')
|
| 237 |
-
|
| 238 |
-
ax.tick_params(axis='y', which='major', labelsize=10)
|
| 239 |
-
|
| 240 |
-
# Add a legend for the waveform
|
| 241 |
-
ax.legend(loc='upper right', framealpha=0.9)
|
| 242 |
-
|
| 243 |
-
def plot_lead_shap_bars(ax, time_points_pct, shap_importance, lead, lead_importance, rank, total_leads):
|
| 244 |
-
"""
|
| 245 |
-
Plot SHAP importance bars for a single lead with cardiac cycle regions
|
| 246 |
-
"""
|
| 247 |
-
if len(shap_importance) == len(time_points_pct):
|
| 248 |
-
# Define cardiac cycle regions with PR segment removed and P-wave extended
|
| 249 |
-
ecg_regions = [
|
| 250 |
-
{'name': 'P Wave', 'start': 0, 'end': 25, 'color': 'lightblue', 'alpha': 0.5},
|
| 251 |
-
{'name': 'QRS Complex', 'start': 25, 'end': 42, 'color': 'lightgreen', 'alpha': 0.5},
|
| 252 |
-
{'name': 'ST Segment', 'start': 42, 'end': 58, 'color': 'orange', 'alpha': 0.5},
|
| 253 |
-
{'name': 'T Wave', 'start': 58, 'end': 83, 'color': 'violet', 'alpha': 0.5},
|
| 254 |
-
{'name': 'TP Segment', 'start': 83, 'end': 100, 'color': 'saddlebrown', 'alpha': 0.5}
|
| 255 |
-
]
|
| 256 |
-
|
| 257 |
-
# Plot cardiac cycle regions as background
|
| 258 |
-
for region in ecg_regions:
|
| 259 |
-
ax.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
|
| 260 |
-
|
| 261 |
-
bar_width = 100 / len(time_points_pct) * 0.8
|
| 262 |
-
|
| 263 |
-
# Debug info
|
| 264 |
-
max_shap = np.max(shap_importance) if len(shap_importance) > 0 else 0
|
| 265 |
-
print(f" {rank}. {lead} - Max SHAP: {max_shap:.6f}")
|
| 266 |
-
|
| 267 |
-
# Create bars with dark grey color
|
| 268 |
-
if max_shap > 0:
|
| 269 |
-
bars = ax.bar(time_points_pct, shap_importance, alpha=0.8, color='darkslategray',
|
| 270 |
-
width=bar_width, edgecolor='black', linewidth=0.5, zorder=5)
|
| 271 |
-
|
| 272 |
-
# Highlight top region
|
| 273 |
-
top_idx = np.argmax(shap_importance)
|
| 274 |
-
top_value = shap_importance[top_idx]
|
| 275 |
-
top_time = time_points_pct[top_idx]
|
| 276 |
-
|
| 277 |
-
# Highlight the top bar with red
|
| 278 |
-
bars[top_idx].set_color('red')
|
| 279 |
-
bars[top_idx].set_alpha(1.0)
|
| 280 |
-
bars[top_idx].set_edgecolor('darkred')
|
| 281 |
-
bars[top_idx].set_linewidth(1)
|
| 282 |
-
|
| 283 |
-
# Add annotation for top region
|
| 284 |
-
if top_value > max_shap * 0.1: # Only annotate if significant
|
| 285 |
-
ax.text(top_time, top_value * 0.9,
|
| 286 |
-
f'{top_time:.1f}%',
|
| 287 |
-
ha='center', va='top', fontsize=8, fontweight='bold', zorder=10,
|
| 288 |
-
bbox=dict(boxstyle="round,pad=0.2", facecolor='white', alpha=0.9))
|
| 289 |
-
else:
|
| 290 |
-
# If no SHAP values, plot zeros with different color
|
| 291 |
-
bars = ax.bar(time_points_pct, shap_importance, alpha=0.3, color='gray',
|
| 292 |
-
width=bar_width, edgecolor='darkgray', linewidth=0.5, zorder=5)
|
| 293 |
-
|
| 294 |
-
# Set labels with better formatting
|
| 295 |
-
ax.set_ylabel(f'{lead}\n({lead_importance:.3f})\nSHAP Value', fontsize=10, rotation=0,
|
| 296 |
-
ha='right', va='center', labelpad=10)
|
| 297 |
-
ax.grid(True, alpha=0.3, zorder=1)
|
| 298 |
-
ax.set_xlim(0, 100)
|
| 299 |
-
|
| 300 |
-
# Remove x-axis labels and ticks for all but bottom plot
|
| 301 |
-
if rank < total_leads:
|
| 302 |
-
ax.set_xticklabels([])
|
| 303 |
-
ax.set_xticks([])
|
| 304 |
-
else:
|
| 305 |
-
ax.set_xlabel('Cardiac Cycle (%)', fontsize=11)
|
| 306 |
-
ax.tick_params(axis='x', labelsize=9)
|
| 307 |
-
|
| 308 |
-
# Set y-axis limit based on max SHAP value with some margin
|
| 309 |
-
max_shap_val = max_shap if max_shap > 0 else 0.001
|
| 310 |
-
ax.set_ylim(0, max_shap_val * 1.3)
|
| 311 |
-
ax.tick_params(axis='y', labelsize=8)
|
| 312 |
-
|
| 313 |
-
# Remove y-axis ticks and labels (the numbers 1-12)
|
| 314 |
-
ax.set_yticklabels([])
|
| 315 |
-
ax.set_yticks([])
|
| 316 |
-
|
| 317 |
-
def create_lead_importance_table(lead_names, lead_importances, output_dir):
|
| 318 |
-
"""
|
| 319 |
-
Create a simple table showing lead importance ranking
|
| 320 |
-
"""
|
| 321 |
-
# Create importance DataFrame
|
| 322 |
-
importance_df = pd.DataFrame({
|
| 323 |
-
'Rank': range(1, len(lead_names) + 1),
|
| 324 |
-
'Lead': lead_names,
|
| 325 |
-
'Total_SHAP_Importance': lead_importances
|
| 326 |
-
})
|
| 327 |
-
|
| 328 |
-
# Save to CSV
|
| 329 |
-
importance_df.to_csv(f'{output_dir}/lead_importance_ranking.csv', index=False)
|
| 330 |
-
|
| 331 |
-
# Create simple text summary
|
| 332 |
-
with open(f'{output_dir}/lead_importance_summary.txt', 'w') as f:
|
| 333 |
-
f.write("Lead Importance Ranking (by Total SHAP Value)\n")
|
| 334 |
-
f.write("=" * 50 + "\n")
|
| 335 |
-
for i, (lead, importance) in enumerate(zip(lead_names, lead_importances)):
|
| 336 |
-
f.write(f"{i+1:2d}. {lead:4s}: {importance:.4f}\n")
|
| 337 |
-
|
| 338 |
-
print(f"Lead importance table saved: {output_dir}/lead_importance_ranking.csv")
|
| 339 |
-
return importance_df
|
| 340 |
-
|
| 341 |
-
# Function to fix other plots (avg_with_shap plots)
|
| 342 |
-
def fix_other_plots(analysis_output_dir, n_timesteps=40):
|
| 343 |
-
"""
|
| 344 |
-
Fix the other plots that are not showing SHAP values
|
| 345 |
-
"""
|
| 346 |
-
print("\nFixing other plots...")
|
| 347 |
-
|
| 348 |
-
# Load the necessary data
|
| 349 |
-
shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
|
| 350 |
-
top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
|
| 351 |
-
|
| 352 |
-
if not os.path.exists(shap_values_csv) or not os.path.exists(top_regions_csv):
|
| 353 |
-
print("Required files not found. Skipping other plots.")
|
| 354 |
-
return
|
| 355 |
-
|
| 356 |
-
shap_df = pd.read_csv(shap_values_csv)
|
| 357 |
-
top_regions_df = pd.read_csv(top_regions_csv)
|
| 358 |
-
|
| 359 |
-
# Get lead names from top regions
|
| 360 |
-
lead_names = top_regions_df['feature'].unique()
|
| 361 |
-
|
| 362 |
-
# Create time points
|
| 363 |
-
time_points_pct = np.linspace(0, 100, n_timesteps)
|
| 364 |
-
|
| 365 |
-
# Recreate avg_with_shap plots for each lead
|
| 366 |
-
for lead in lead_names:
|
| 367 |
-
create_fixed_avg_shap_plot(lead, shap_df, time_points_pct, analysis_output_dir, n_timesteps)
|
| 368 |
-
|
| 369 |
-
def create_fixed_avg_shap_plot(lead, shap_df, time_points_pct, output_dir, n_timesteps):
|
| 370 |
-
"""
|
| 371 |
-
Create fixed avg_with_shap plot for a single lead
|
| 372 |
-
"""
|
| 373 |
-
# Extract SHAP values
|
| 374 |
-
lead_shap_values = extract_lead_shap_values(shap_df, lead, n_timesteps)
|
| 375 |
-
|
| 376 |
-
# Create figure
|
| 377 |
-
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(15, 8))
|
| 378 |
-
|
| 379 |
-
# Plot 1: Placeholder for average heartbeat (you would need the original data for this)
|
| 380 |
-
ax1.text(0.5, 0.5, f'Average Heartbeats - {lead}\n(Original data required)',
|
| 381 |
-
ha='center', va='center', transform=ax1.transAxes, fontsize=14)
|
| 382 |
-
ax1.set_title(f'Average Heartbeats - {lead}', fontsize=16)
|
| 383 |
-
ax1.set_ylabel('Normalized Amplitude')
|
| 384 |
-
ax1.grid(True, alpha=0.3)
|
| 385 |
-
|
| 386 |
-
# Plot 2: SHAP importance with updated regions
|
| 387 |
-
# Define cardiac cycle regions with PR segment removed and P-wave extended
|
| 388 |
-
ecg_regions = [
|
| 389 |
-
{'name': 'P Wave', 'start': 0, 'end': 25, 'color': 'lightblue', 'alpha': 0.3},
|
| 390 |
-
{'name': 'QRS Complex', 'start': 25, 'end': 42, 'color': 'lightgreen', 'alpha': 0.3},
|
| 391 |
-
{'name': 'ST Segment', 'start': 42, 'end': 58, 'color': 'orange', 'alpha': 0.3},
|
| 392 |
-
{'name': 'T Wave', 'start': 58, 'end': 83, 'color': 'violet', 'alpha': 0.3},
|
| 393 |
-
{'name': 'TP Segment', 'start': 83, 'end': 100, 'color': 'saddlebrown', 'alpha': 0.3}
|
| 394 |
-
]
|
| 395 |
-
|
| 396 |
-
# Plot cardiac cycle regions as background
|
| 397 |
-
for region in ecg_regions:
|
| 398 |
-
ax2.axvspan(region['start'], region['end'], alpha=region['alpha'], color=region['color'], zorder=0)
|
| 399 |
-
|
| 400 |
-
bar_width = 100 / len(time_points_pct) * 0.8
|
| 401 |
-
ax2.bar(time_points_pct, lead_shap_values, alpha=0.7, color='darkslategray',
|
| 402 |
-
width=bar_width, label='SHAP Importance', zorder=5)
|
| 403 |
-
ax2.set_title(f'SHAP Importance - {lead}', fontsize=16)
|
| 404 |
-
ax2.set_xlabel('Cardiac Cycle (%)')
|
| 405 |
-
ax2.set_ylabel('SHAP Value')
|
| 406 |
-
ax2.grid(True, alpha=0.3)
|
| 407 |
-
ax2.legend()
|
| 408 |
-
|
| 409 |
-
plt.tight_layout()
|
| 410 |
-
plt.savefig(f'{output_dir}/heartbeat_plots/avg_with_shap_{lead}_FIXED.png',
|
| 411 |
-
dpi=300, bbox_inches='tight')
|
| 412 |
-
plt.close()
|
| 413 |
-
|
| 414 |
-
print(f"Fixed plot saved: {output_dir}/heartbeat_plots/avg_with_shap_{lead}_FIXED.png")
|
| 415 |
-
|
| 416 |
-
# Additional function to create standalone idealized ECG plot
|
| 417 |
-
def create_standalone_idealized_ecg_plot(output_dir, n_timesteps=40):
|
| 418 |
-
"""
|
| 419 |
-
Create a standalone idealized ECG plot with the updated regions
|
| 420 |
-
"""
|
| 421 |
-
time_points_pct = np.linspace(0, 100, n_timesteps)
|
| 422 |
-
|
| 423 |
-
fig, ax = plt.subplots(figsize=(15, 6))
|
| 424 |
-
plot_idealized_heartbeat(ax, time_points_pct)
|
| 425 |
-
|
| 426 |
-
plt.tight_layout()
|
| 427 |
-
plt.savefig(f'{output_dir}/standalone_idealized_ecg.png',
|
| 428 |
-
dpi=300, bbox_inches='tight')
|
| 429 |
-
plt.savefig(f'{output_dir}/standalone_idealized_ecg.pdf',
|
| 430 |
-
bbox_inches='tight')
|
| 431 |
-
plt.close()
|
| 432 |
-
|
| 433 |
-
print(f"Standalone idealized ECG plot saved: {output_dir}/standalone_idealized_ecg.png")
|
| 434 |
-
|
| 435 |
-
# Main execution function
|
| 436 |
-
def main():
|
| 437 |
-
"""
|
| 438 |
-
Main function to run the SHAP replotting
|
| 439 |
-
"""
|
| 440 |
-
# Define paths
|
| 441 |
-
analysis_output_dir = 'results/heartbeat_analysis_500Hz_good'
|
| 442 |
-
shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
|
| 443 |
-
top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
|
| 444 |
-
output_dir = f'{analysis_output_dir}/shap_replots'
|
| 445 |
-
|
| 446 |
-
# Check if files exist
|
| 447 |
-
if not os.path.exists(shap_values_csv):
|
| 448 |
-
print(f"Error: SHAP values file not found at {shap_values_csv}")
|
| 449 |
-
return
|
| 450 |
-
if not os.path.exists(top_regions_csv):
|
| 451 |
-
print(f"Error: Top regions file not found at {top_regions_csv}")
|
| 452 |
-
return
|
| 453 |
-
|
| 454 |
-
print("Starting SHAP replotting...")
|
| 455 |
-
print(f"SHAP values file: {shap_values_csv}")
|
| 456 |
-
print(f"Top regions file: {top_regions_csv}")
|
| 457 |
-
print(f"Output directory: {output_dir}")
|
| 458 |
-
|
| 459 |
-
# Replot all leads
|
| 460 |
-
lead_names, lead_importances = replot_idealized_shap_all_leads(
|
| 461 |
-
shap_values_csv, top_regions_csv, output_dir
|
| 462 |
-
)
|
| 463 |
-
|
| 464 |
-
# Create lead importance table
|
| 465 |
-
importance_df = create_lead_importance_table(lead_names, lead_importances, output_dir)
|
| 466 |
-
|
| 467 |
-
# Create standalone idealized ECG plot
|
| 468 |
-
create_standalone_idealized_ecg_plot(output_dir)
|
| 469 |
-
|
| 470 |
-
# Fix other plots
|
| 471 |
-
fix_other_plots(analysis_output_dir)
|
| 472 |
-
|
| 473 |
-
print("\n" + "="*60)
|
| 474 |
-
print("SHAP REPLOTTING COMPLETE!")
|
| 475 |
-
print("="*60)
|
| 476 |
-
print(f"Results saved to: {output_dir}")
|
| 477 |
-
print(f"Lead ranking (by importance):")
|
| 478 |
-
for i, (lead, importance) in enumerate(zip(lead_names, lead_importances)):
|
| 479 |
-
print(f" {i+1:2d}. {lead}: {importance:.4f}")
|
| 480 |
-
|
| 481 |
-
# Alternative: Function to use in your existing code
|
| 482 |
-
def replot_from_existing_analysis(analysis_output_dir):
|
| 483 |
-
"""
|
| 484 |
-
Replot from existing analysis output directory
|
| 485 |
-
"""
|
| 486 |
-
shap_values_csv = f'{analysis_output_dir}/shap/shap_values_detailed.csv'
|
| 487 |
-
top_regions_csv = f'{analysis_output_dir}/shap/top_shap_regions.csv'
|
| 488 |
-
output_dir = f'{analysis_output_dir}/shap_replots'
|
| 489 |
-
|
| 490 |
-
return replot_idealized_shap_all_leads(shap_values_csv, top_regions_csv, output_dir)
|
| 491 |
-
|
| 492 |
-
# Example usage:
|
| 493 |
-
if __name__ == "__main__":
|
| 494 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/run_benchmarking.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
| 1 |
-
import argparse
|
| 2 |
-
import os
|
| 3 |
-
from utils.classifier_benchmarking import perform_benchmark
|
| 4 |
-
|
| 5 |
-
def run_benchmarking(input_file, output_folder=None):
|
| 6 |
-
"""
|
| 7 |
-
Run benchmarking on the specified input file.
|
| 8 |
-
|
| 9 |
-
Args:
|
| 10 |
-
input_file: Path to input CSV
|
| 11 |
-
output_folder: Directory to save results. If None, derives from input filename.
|
| 12 |
-
"""
|
| 13 |
-
if not os.path.exists(input_file):
|
| 14 |
-
print(f"Error: Input file '{input_file}' not found.")
|
| 15 |
-
return
|
| 16 |
-
|
| 17 |
-
# Determine output folder if not specified
|
| 18 |
-
if output_folder is None:
|
| 19 |
-
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
| 20 |
-
output_folder = f'output/benchmark_results/{base_name}'
|
| 21 |
-
|
| 22 |
-
print(f"\n{'='*60}")
|
| 23 |
-
print(f"STARTING BENCHMARKING")
|
| 24 |
-
print(f"Input: {input_file}")
|
| 25 |
-
print(f"Output: {output_folder}")
|
| 26 |
-
print(f"{'='*60}")
|
| 27 |
-
|
| 28 |
-
try:
|
| 29 |
-
perform_benchmark(input_file, output_dir=output_folder)
|
| 30 |
-
print(f"\nBenchmarking complete for {input_file}")
|
| 31 |
-
print(f"Results saved to: {output_folder}")
|
| 32 |
-
except Exception as e:
|
| 33 |
-
print(f"\nError running benchmark: {e}")
|
| 34 |
-
import traceback
|
| 35 |
-
traceback.print_exc()
|
| 36 |
-
|
| 37 |
-
if __name__ == "__main__":
|
| 38 |
-
parser = argparse.ArgumentParser(description="Run ECG Classifier Benchmarking")
|
| 39 |
-
parser.add_argument('--input', type=str, help='Path to input CSV file (optional)')
|
| 40 |
-
parser.add_argument('--all', action='store_true', help='Run on both standard datasets (segmented and full)')
|
| 41 |
-
|
| 42 |
-
args = parser.parse_args()
|
| 43 |
-
|
| 44 |
-
# Default files
|
| 45 |
-
segmented_file = 'data/segmented_heartbeats.csv'
|
| 46 |
-
processed_file = 'data/ecg_dataset_processed.csv'
|
| 47 |
-
|
| 48 |
-
# Define explicit output paths for standard runs
|
| 49 |
-
output_segmented = 'output/benchmark_results/segmented_heartbeats'
|
| 50 |
-
output_processed = 'output/benchmark_results/ecg_dataset_processed'
|
| 51 |
-
|
| 52 |
-
if args.input:
|
| 53 |
-
# Run on user specified file (folder derived from filename)
|
| 54 |
-
run_benchmarking(args.input)
|
| 55 |
-
elif args.all:
|
| 56 |
-
# Run on both with specific folders
|
| 57 |
-
print("Running comprehensive benchmarking on ALL datasets...")
|
| 58 |
-
run_benchmarking(segmented_file, output_folder=output_segmented)
|
| 59 |
-
run_benchmarking(processed_file, output_folder=output_processed)
|
| 60 |
-
else:
|
| 61 |
-
# Default run
|
| 62 |
-
print("No input specified. Defaulting to processed ECG data.")
|
| 63 |
-
print(f"Usage: python run_benchmarking.py --input <path> OR --all")
|
| 64 |
-
run_benchmarking(processed_file, output_folder=output_processed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/run_classification.py
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
from utils.ecg_classification import perform_classification_analysis
|
| 2 |
-
import os
|
| 3 |
-
# Import the universally defined target frequency
|
| 4 |
-
from run_segmentation import TARGET_FS
|
| 5 |
-
|
| 6 |
-
if __name__ == "__main__":
|
| 7 |
-
# Define paths
|
| 8 |
-
input_csv = 'data/ecg_matrix_dataset_segmented.csv'
|
| 9 |
-
output_dir = 'output/heartbeat_analysis_matrix'
|
| 10 |
-
|
| 11 |
-
# Check if input exists
|
| 12 |
-
if not os.path.exists(input_csv):
|
| 13 |
-
print(f"Error: Input file {input_csv} not found.")
|
| 14 |
-
print("Please run run_segmentation.py first.")
|
| 15 |
-
exit(1)
|
| 16 |
-
|
| 17 |
-
print(f"Starting classification analysis...")
|
| 18 |
-
print(f"Input: {input_csv}")
|
| 19 |
-
print(f"Output Directory: {output_dir}")
|
| 20 |
-
print(f"Target Frequency: {TARGET_FS} Hz (imported from run_segmentation.py)")
|
| 21 |
-
|
| 22 |
-
# Run analysis
|
| 23 |
-
# Normalization happens inside this function
|
| 24 |
-
results = perform_classification_analysis(
|
| 25 |
-
segmented_csv=input_csv,
|
| 26 |
-
output_dir=output_dir,
|
| 27 |
-
target_fs=TARGET_FS
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
if results:
|
| 31 |
-
print("\nAnalysis successful!")
|
| 32 |
-
print(f"Results available in {output_dir}")
|
| 33 |
-
else:
|
| 34 |
-
print("\nAnalysis failed.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/run_inference.py
DELETED
|
@@ -1,297 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Run inference on new ECG data using a previously saved model.
|
| 3 |
-
|
| 4 |
-
Loads the serialised model and its metadata, applies identical preprocessing
|
| 5 |
-
(per-subject normalisation, reshape to numpy3D), and outputs per-subject
|
| 6 |
-
predictions with confidence scores.
|
| 7 |
-
|
| 8 |
-
Usage:
|
| 9 |
-
# MI vs Normal
|
| 10 |
-
python run_inference.py --model mi_vs_normal --input data/new_ecg.csv
|
| 11 |
-
|
| 12 |
-
# OMI vs non-OMI
|
| 13 |
-
python run_inference.py --model omi_vs_nonomi --input data/new_ecg.csv
|
| 14 |
-
|
| 15 |
-
# Custom output path
|
| 16 |
-
python run_inference.py --model mi_vs_normal --input data/new_ecg.csv --output results/preds.csv
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import os
|
| 20 |
-
import argparse
|
| 21 |
-
import pickle
|
| 22 |
-
import json
|
| 23 |
-
import numpy as np
|
| 24 |
-
import pandas as pd
|
| 25 |
-
from tqdm.auto import tqdm
|
| 26 |
-
|
| 27 |
-
# ─── Configuration ───────────────────────────────────────────────────────────
|
| 28 |
-
|
| 29 |
-
MODELS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../models/classifier_models'))
|
| 30 |
-
|
| 31 |
-
# ─── Model Loading ───────────────────────────────────────────────────────────
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def load_model(use_case):
|
| 35 |
-
"""
|
| 36 |
-
Load a saved model and its metadata from the models directory.
|
| 37 |
-
|
| 38 |
-
Args:
|
| 39 |
-
use_case: One of 'mi_vs_normal' or 'omi_vs_nonomi'.
|
| 40 |
-
|
| 41 |
-
Returns:
|
| 42 |
-
(model, metadata) tuple.
|
| 43 |
-
"""
|
| 44 |
-
model_dir = os.path.join(MODELS_DIR, use_case)
|
| 45 |
-
|
| 46 |
-
# Metadata
|
| 47 |
-
metadata_path = os.path.join(model_dir, 'model_metadata.json')
|
| 48 |
-
if not os.path.exists(metadata_path):
|
| 49 |
-
raise FileNotFoundError(
|
| 50 |
-
f"Model metadata not found: {metadata_path}\n"
|
| 51 |
-
f"Have you run train_and_save_models.py first?")
|
| 52 |
-
|
| 53 |
-
with open(metadata_path, 'r') as f:
|
| 54 |
-
metadata = json.load(f)
|
| 55 |
-
|
| 56 |
-
# Model pickle
|
| 57 |
-
model_path = os.path.join(model_dir, metadata['model_file'])
|
| 58 |
-
if not os.path.exists(model_path):
|
| 59 |
-
raise FileNotFoundError(
|
| 60 |
-
f"Model file not found: {model_path}\n"
|
| 61 |
-
f"Have you run train_and_save_models.py first?")
|
| 62 |
-
|
| 63 |
-
print(f"Loading {metadata['model_name']} model for {metadata['use_case']} ...")
|
| 64 |
-
with open(model_path, 'rb') as f:
|
| 65 |
-
model = pickle.load(f)
|
| 66 |
-
|
| 67 |
-
print(f" Model loaded successfully.")
|
| 68 |
-
print(f" Trained on: {metadata['training_data']}")
|
| 69 |
-
print(f" Training date: {metadata['training_date']}")
|
| 70 |
-
print(f" Expected input: {metadata['n_features']} leads x "
|
| 71 |
-
f"{metadata['n_timesteps']} timesteps")
|
| 72 |
-
print(f" Performance metrics on held-out test set:")
|
| 73 |
-
metrics = metadata.get('test_metrics', {})
|
| 74 |
-
print(f" - Accuracy: {metrics.get('accuracy', 0.0):.4f}")
|
| 75 |
-
print(f" - Precision: {metrics.get('precision', 0.0):.4f}")
|
| 76 |
-
print(f" - Recall: {metrics.get('recall', 0.0):.4f}")
|
| 77 |
-
print(f" - F1 Score: {metrics.get('f1', 0.0):.4f}")
|
| 78 |
-
print(f" - Sensitivity: {metrics.get('sensitivity', 0.0):.4f}")
|
| 79 |
-
print(f" - Specificity: {metrics.get('specificity', 0.0):.4f}")
|
| 80 |
-
|
| 81 |
-
return model, metadata
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
# ─── Preprocessing ───────────────────────────────────────────────────────────
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def preprocess_data(input_csv, metadata):
|
| 88 |
-
"""
|
| 89 |
-
Preprocess new ECG data for inference using the same pipeline as training.
|
| 90 |
-
|
| 91 |
-
Steps (mirrors prepare_data() in classifier_benchmarking.py):
|
| 92 |
-
1. Per-subject normalisation (divide each lead by its max absolute value)
|
| 93 |
-
2. Reshape to numpy3D (n_subjects, n_features, n_timesteps)
|
| 94 |
-
3. Truncate or zero-pad each subject to the training n_timesteps
|
| 95 |
-
|
| 96 |
-
Args:
|
| 97 |
-
input_csv: Path to the input CSV. Must contain columns
|
| 98 |
-
'subject_id', 'timestamp', and the 12 ECG lead columns.
|
| 99 |
-
metadata: The model_metadata dict loaded from JSON.
|
| 100 |
-
|
| 101 |
-
Returns:
|
| 102 |
-
(X, valid_subject_ids) where X has shape
|
| 103 |
-
(n_subjects, n_features, n_timesteps).
|
| 104 |
-
"""
|
| 105 |
-
features = metadata['features']
|
| 106 |
-
n_timesteps = metadata['n_timesteps']
|
| 107 |
-
n_features = metadata['n_features']
|
| 108 |
-
|
| 109 |
-
# ── Load ──────────────────────────────────────────────────────────────
|
| 110 |
-
print(f"\nLoading input data from {input_csv} ...")
|
| 111 |
-
df = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
|
| 112 |
-
|
| 113 |
-
missing = [f for f in features if f not in df.columns]
|
| 114 |
-
if missing:
|
| 115 |
-
raise ValueError(f"Missing required lead columns in input: {missing}")
|
| 116 |
-
|
| 117 |
-
# ── Normalise ─────────────────────────────────────────────────────────
|
| 118 |
-
def safe_normalize(x):
|
| 119 |
-
max_val = x.abs().max()
|
| 120 |
-
return x / max_val if max_val > 0 else x
|
| 121 |
-
|
| 122 |
-
print("Normalising features per subject ...")
|
| 123 |
-
df_X = df[features].copy()
|
| 124 |
-
for col in tqdm(df_X.columns, desc="Normalising"):
|
| 125 |
-
df_X[col] = df_X.groupby(level='subject_id')[col].transform(safe_normalize)
|
| 126 |
-
|
| 127 |
-
# ── Reshape ───────────────────────────────────────────────────────────
|
| 128 |
-
subject_ids = df_X.index.get_level_values('subject_id').unique()
|
| 129 |
-
timesteps_per_subject = df_X.groupby(level='subject_id').size()
|
| 130 |
-
|
| 131 |
-
print(f" Subjects found: {len(subject_ids)}")
|
| 132 |
-
print(f" Timesteps per subject — "
|
| 133 |
-
f"min: {timesteps_per_subject.min()}, "
|
| 134 |
-
f"max: {timesteps_per_subject.max()}")
|
| 135 |
-
print(f" Target timesteps (train): {n_timesteps}")
|
| 136 |
-
|
| 137 |
-
X_list = []
|
| 138 |
-
valid_subject_ids = []
|
| 139 |
-
skipped = 0
|
| 140 |
-
|
| 141 |
-
for subject_id in tqdm(subject_ids, desc="Reshaping subjects"):
|
| 142 |
-
try:
|
| 143 |
-
subject_data = df_X.xs(subject_id, level='subject_id')
|
| 144 |
-
actual_len = len(subject_data)
|
| 145 |
-
|
| 146 |
-
if actual_len >= n_timesteps:
|
| 147 |
-
# Truncate to training length
|
| 148 |
-
arr = subject_data.iloc[:n_timesteps].values.T
|
| 149 |
-
else:
|
| 150 |
-
# Zero-pad short subjects
|
| 151 |
-
arr = np.zeros((n_features, n_timesteps))
|
| 152 |
-
arr[:, :actual_len] = subject_data.values.T
|
| 153 |
-
print(f" [warn] Subject {subject_id}: {actual_len} timesteps "
|
| 154 |
-
f"→ padded to {n_timesteps}")
|
| 155 |
-
|
| 156 |
-
if arr.shape == (n_features, n_timesteps):
|
| 157 |
-
X_list.append(arr)
|
| 158 |
-
valid_subject_ids.append(subject_id)
|
| 159 |
-
else:
|
| 160 |
-
skipped += 1
|
| 161 |
-
except Exception as e:
|
| 162 |
-
print(f" [warn] Skipped subject {subject_id}: {e}")
|
| 163 |
-
skipped += 1
|
| 164 |
-
|
| 165 |
-
if not X_list:
|
| 166 |
-
raise ValueError("No valid subjects found in the input data!")
|
| 167 |
-
|
| 168 |
-
X = np.array(X_list)
|
| 169 |
-
print(f"\n Preprocessed shape: {X.shape} "
|
| 170 |
-
f"({len(valid_subject_ids)} subjects, {skipped} skipped)")
|
| 171 |
-
|
| 172 |
-
return X, valid_subject_ids
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
# ─── Inference ───────────────────────────────────────────────────────────────
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def run_predictions(model, X, metadata, subject_ids):
|
| 179 |
-
"""
|
| 180 |
-
Run model predictions and package them into a DataFrame.
|
| 181 |
-
|
| 182 |
-
Returns:
|
| 183 |
-
pd.DataFrame with columns:
|
| 184 |
-
subject_id, predicted_class, confidence,
|
| 185 |
-
probability_<class0>, probability_<class1>
|
| 186 |
-
"""
|
| 187 |
-
print(f"\nRunning inference on {len(subject_ids)} subjects ...")
|
| 188 |
-
|
| 189 |
-
# Predict labels and probabilities in threading backend to bypass Numba read-only array issue
|
| 190 |
-
from joblib import parallel_backend
|
| 191 |
-
with parallel_backend('threading', n_jobs=-1):
|
| 192 |
-
y_pred = model.predict(X)
|
| 193 |
-
|
| 194 |
-
# Predict probabilities (Arsenal & Rocket both support this)
|
| 195 |
-
has_proba = False
|
| 196 |
-
y_proba = None
|
| 197 |
-
try:
|
| 198 |
-
y_proba = model.predict_proba(X)
|
| 199 |
-
has_proba = True
|
| 200 |
-
except (AttributeError, NotImplementedError):
|
| 201 |
-
pass
|
| 202 |
-
|
| 203 |
-
# ── Build output DataFrame ────────────────────────────────────────────
|
| 204 |
-
results = {
|
| 205 |
-
'subject_id': subject_ids,
|
| 206 |
-
'predicted_class': y_pred,
|
| 207 |
-
}
|
| 208 |
-
|
| 209 |
-
if has_proba:
|
| 210 |
-
class_labels = list(model.classes_)
|
| 211 |
-
for i, cls in enumerate(class_labels):
|
| 212 |
-
results[f'probability_{cls}'] = y_proba[:, i]
|
| 213 |
-
|
| 214 |
-
# Confidence = probability of the predicted class
|
| 215 |
-
confidence = []
|
| 216 |
-
for j in range(len(y_pred)):
|
| 217 |
-
pred_idx = class_labels.index(y_pred[j])
|
| 218 |
-
confidence.append(float(y_proba[j, pred_idx]))
|
| 219 |
-
results['confidence'] = confidence
|
| 220 |
-
|
| 221 |
-
return pd.DataFrame(results)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
def main():
|
| 228 |
-
parser = argparse.ArgumentParser(
|
| 229 |
-
description='Run ECG classification inference with a saved model',
|
| 230 |
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 231 |
-
epilog="""
|
| 232 |
-
Examples:
|
| 233 |
-
python run_inference.py --model mi_vs_normal_processed --input data/ptb_xl/ecg_dataset_processed.csv
|
| 234 |
-
python run_inference.py --model mi_vs_normal_segmented --input data/ptb_xl/segmented_heartbeats.csv
|
| 235 |
-
python run_inference.py --model omi_vs_nonomi --input data/ecg_matrix_omi_segmented_50_150_90.csv
|
| 236 |
-
python run_inference.py --model ecg_surgery --input data/ecg_surgery_segmented_50_150_70.csv
|
| 237 |
-
""")
|
| 238 |
-
|
| 239 |
-
parser.add_argument('--model', type=str, required=True,
|
| 240 |
-
choices=['mi_vs_normal_processed', 'mi_vs_normal_segmented', 'omi_vs_nonomi', 'ecg_surgery'],
|
| 241 |
-
help='Which saved model to use')
|
| 242 |
-
parser.add_argument('--input', type=str, required=True,
|
| 243 |
-
help='Path to input CSV (subject_id, timestamp, 12 leads)')
|
| 244 |
-
parser.add_argument('--output', type=str, default=None,
|
| 245 |
-
help='Path to save predictions CSV '
|
| 246 |
-
'(default: results/<model>_predictions.csv)')
|
| 247 |
-
|
| 248 |
-
args = parser.parse_args()
|
| 249 |
-
|
| 250 |
-
# Default output
|
| 251 |
-
if args.output is None:
|
| 252 |
-
os.makedirs('results', exist_ok=True)
|
| 253 |
-
args.output = f'results/{args.model}_predictions.csv'
|
| 254 |
-
|
| 255 |
-
print('=' * 60)
|
| 256 |
-
print(' ECG CLASSIFICATION - INFERENCE')
|
| 257 |
-
print('=' * 60)
|
| 258 |
-
|
| 259 |
-
# ── Load model ────────────────────────────────────────────────────────
|
| 260 |
-
model, metadata = load_model(args.model)
|
| 261 |
-
|
| 262 |
-
# ── Preprocess ────────────────────────────────────────────────────────
|
| 263 |
-
X, subject_ids = preprocess_data(args.input, metadata)
|
| 264 |
-
|
| 265 |
-
# ── Predict ───────────────────────────────────────────────────────────
|
| 266 |
-
results_df = run_predictions(model, X, metadata, subject_ids)
|
| 267 |
-
|
| 268 |
-
# ── Save ──────────────────────────────────────────────────────────────
|
| 269 |
-
out_dir = os.path.dirname(args.output)
|
| 270 |
-
if out_dir:
|
| 271 |
-
os.makedirs(out_dir, exist_ok=True)
|
| 272 |
-
results_df.to_csv(args.output, index=False)
|
| 273 |
-
|
| 274 |
-
# ── Summary ───────────────────────────────────────────────────────────
|
| 275 |
-
print(f"\n{'=' * 60}")
|
| 276 |
-
print(f" INFERENCE RESULTS")
|
| 277 |
-
print(f"{'=' * 60}")
|
| 278 |
-
print(f" Model: {metadata['model_name']} ({metadata['use_case']})")
|
| 279 |
-
print(f" Total subjects: {len(results_df)}")
|
| 280 |
-
print(f"\n Predicted class distribution:")
|
| 281 |
-
for cls, count in results_df['predicted_class'].value_counts().items():
|
| 282 |
-
print(f" {cls}: {count}")
|
| 283 |
-
|
| 284 |
-
if 'confidence' in results_df.columns:
|
| 285 |
-
conf = results_df['confidence']
|
| 286 |
-
print(f"\n Confidence statistics:")
|
| 287 |
-
print(f" Mean: {conf.mean():.4f}")
|
| 288 |
-
print(f" Median: {conf.median():.4f}")
|
| 289 |
-
print(f" Min: {conf.min():.4f}")
|
| 290 |
-
print(f" Max: {conf.max():.4f}")
|
| 291 |
-
|
| 292 |
-
print(f"\n Results saved to: {args.output}")
|
| 293 |
-
print(f"{'=' * 60}")
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
if __name__ == '__main__':
|
| 297 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/run_lead_importance_test.py
DELETED
|
@@ -1,128 +0,0 @@
|
|
| 1 |
-
import numpy as np
|
| 2 |
-
import pandas as pd
|
| 3 |
-
import matplotlib.pyplot as plt
|
| 4 |
-
import os
|
| 5 |
-
from sklearn.model_selection import train_test_split
|
| 6 |
-
from sklearn.neural_network import MLPClassifier
|
| 7 |
-
from sklearn.preprocessing import StandardScaler
|
| 8 |
-
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
|
| 9 |
-
from tqdm.auto import tqdm
|
| 10 |
-
|
| 11 |
-
# Import common configuration
|
| 12 |
-
try:
|
| 13 |
-
from run_segmentation import TARGET_FS
|
| 14 |
-
except ImportError:
|
| 15 |
-
TARGET_FS = 500 # Default fallback
|
| 16 |
-
|
| 17 |
-
def run_lead_importance_test(input_csv, output_dir):
|
| 18 |
-
"""
|
| 19 |
-
Test the accuracy of identifying MI using each ECG lead individually.
|
| 20 |
-
"""
|
| 21 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 22 |
-
|
| 23 |
-
# Load segmented data
|
| 24 |
-
print(f"Loading data from {input_csv}...")
|
| 25 |
-
df_segmented = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
|
| 26 |
-
|
| 27 |
-
if df_segmented.empty:
|
| 28 |
-
print("Error: Input data is empty.")
|
| 29 |
-
return
|
| 30 |
-
|
| 31 |
-
# Define features/leads
|
| 32 |
-
leads = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
|
| 33 |
-
label_map = {'Post-Procedural MI': 0, 'Pre-Procedural MI': 1}
|
| 34 |
-
|
| 35 |
-
results = []
|
| 36 |
-
|
| 37 |
-
print(f"\nEvaluating {len(leads)} leads individually at {TARGET_FS} Hz...")
|
| 38 |
-
|
| 39 |
-
for lead in leads:
|
| 40 |
-
print(f"\n>>> Testing Lead {lead}...")
|
| 41 |
-
|
| 42 |
-
# 1. Prepare data for this lead
|
| 43 |
-
df_lead = df_segmented[[lead, 'class']].copy()
|
| 44 |
-
|
| 45 |
-
# Normalize per heartbeat
|
| 46 |
-
df_lead[lead] = df_lead.groupby(level='subject_id')[lead].transform(
|
| 47 |
-
lambda x: x / x.abs().max() if x.abs().max() > 0 else x
|
| 48 |
-
)
|
| 49 |
-
|
| 50 |
-
# Reshape to (heartbeats, timesteps)
|
| 51 |
-
beat_ids = df_lead.index.get_level_values('subject_id').unique()
|
| 52 |
-
timesteps_per_beat = df_lead.groupby(level='subject_id').size().min()
|
| 53 |
-
|
| 54 |
-
X_list = []
|
| 55 |
-
y_list = []
|
| 56 |
-
|
| 57 |
-
for beat_id in beat_ids:
|
| 58 |
-
beat_data = df_lead.xs(beat_id, level='subject_id')
|
| 59 |
-
if len(beat_data) >= timesteps_per_beat:
|
| 60 |
-
X_list.append(beat_data[lead].values[:timesteps_per_beat])
|
| 61 |
-
y_list.append(label_map[beat_data['class'].iloc[0]])
|
| 62 |
-
|
| 63 |
-
X = np.array(X_list)
|
| 64 |
-
y = np.array(y_list)
|
| 65 |
-
|
| 66 |
-
# 2. Train and Evaluate
|
| 67 |
-
X_train, X_test, y_train, y_test = train_test_split(
|
| 68 |
-
X, y, test_size=0.25, random_state=42, stratify=y
|
| 69 |
-
)
|
| 70 |
-
|
| 71 |
-
scaler = StandardScaler()
|
| 72 |
-
X_train_scaled = scaler.fit_transform(X_train)
|
| 73 |
-
X_test_scaled = scaler.transform(X_test)
|
| 74 |
-
|
| 75 |
-
clf = MLPClassifier(
|
| 76 |
-
hidden_layer_sizes=(100,),
|
| 77 |
-
max_iter=1000,
|
| 78 |
-
random_state=42
|
| 79 |
-
)
|
| 80 |
-
clf.fit(X_train_scaled, y_train)
|
| 81 |
-
|
| 82 |
-
y_pred = clf.predict(X_test_scaled)
|
| 83 |
-
|
| 84 |
-
# Collect metrics
|
| 85 |
-
metrics = {
|
| 86 |
-
'Lead': lead,
|
| 87 |
-
'Accuracy': accuracy_score(y_test, y_pred),
|
| 88 |
-
'Precision': precision_score(y_test, y_pred, zero_division=0),
|
| 89 |
-
'Recall': recall_score(y_test, y_pred, zero_division=0),
|
| 90 |
-
'F1_Score': f1_score(y_test, y_pred, zero_division=0)
|
| 91 |
-
}
|
| 92 |
-
results.append(metrics)
|
| 93 |
-
print(f" Accuracy: {metrics['Accuracy']:.4f}")
|
| 94 |
-
|
| 95 |
-
# 3. Save and Visualize Results
|
| 96 |
-
results_df = pd.DataFrame(results).sort_values(by='Accuracy', ascending=False)
|
| 97 |
-
results_df.to_csv(f'{output_dir}/lead_accuracies.csv', index=False)
|
| 98 |
-
|
| 99 |
-
print("\n" + "="*40)
|
| 100 |
-
print("LEAD IMPORTANCE TEST RESULTS")
|
| 101 |
-
print("="*40)
|
| 102 |
-
print(results_df.to_string(index=False))
|
| 103 |
-
|
| 104 |
-
# Plot results
|
| 105 |
-
plt.figure(figsize=(12, 6))
|
| 106 |
-
plt.bar(results_df['Lead'], results_df['Accuracy'], color='skyblue', edgecolor='navy')
|
| 107 |
-
plt.axhline(y=0.5, color='red', linestyle='--', label='Baseline (Random)')
|
| 108 |
-
plt.title(f'MLP Accuracy per Single ECG Lead ({TARGET_FS} Hz)', fontsize=15, fontweight='bold')
|
| 109 |
-
plt.xlabel('ECG Lead', fontsize=12)
|
| 110 |
-
plt.ylabel('Accuracy', fontsize=12)
|
| 111 |
-
plt.ylim(0, 1.0)
|
| 112 |
-
plt.grid(axis='y', alpha=0.3)
|
| 113 |
-
plt.legend()
|
| 114 |
-
plt.tight_layout()
|
| 115 |
-
plt.savefig(f'{output_dir}/lead_accuracies_plot.png', dpi=300)
|
| 116 |
-
plt.close()
|
| 117 |
-
|
| 118 |
-
print(f"\nResults saved to {output_dir}")
|
| 119 |
-
|
| 120 |
-
if __name__ == "__main__":
|
| 121 |
-
#input_csv = 'data/segmented_heartbeats.csv'
|
| 122 |
-
input_csv = 'data/ecg_matrix_dataset_segmented.csv'
|
| 123 |
-
output_dir = 'output/lead_importance_test'
|
| 124 |
-
|
| 125 |
-
if not os.path.exists(input_csv):
|
| 126 |
-
print(f"Error: {input_csv} not found. Run segmentation first.")
|
| 127 |
-
else:
|
| 128 |
-
run_lead_importance_test(input_csv, output_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/classification/train_and_save_models.py
DELETED
|
@@ -1,427 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import argparse
|
| 3 |
-
import pickle
|
| 4 |
-
import json
|
| 5 |
-
import time
|
| 6 |
-
import numpy as np
|
| 7 |
-
import pandas as pd
|
| 8 |
-
from sklearn.model_selection import train_test_split
|
| 9 |
-
from imblearn.combine import SMOTETomek
|
| 10 |
-
from sklearn.metrics import (accuracy_score, precision_score,
|
| 11 |
-
recall_score, f1_score, confusion_matrix)
|
| 12 |
-
from sktime.classification.kernel_based import Arsenal, RocketClassifier
|
| 13 |
-
from sktime.classification.deep_learning.inceptiontime import InceptionTimeClassifier
|
| 14 |
-
from tqdm.auto import tqdm
|
| 15 |
-
import warnings
|
| 16 |
-
warnings.filterwarnings('ignore')
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# ─── Configuration ───────────────────────────────────────────────────────────
|
| 20 |
-
|
| 21 |
-
MODELS_DIR = 'models'
|
| 22 |
-
|
| 23 |
-
FEATURES = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5',
|
| 24 |
-
'III', 'aVF', 'V3', 'V6']
|
| 25 |
-
|
| 26 |
-
USE_CASES = {
|
| 27 |
-
'mi_vs_normal_processed': {
|
| 28 |
-
'name': 'MI vs Normal (Processed)',
|
| 29 |
-
'input_csv': 'data/ptb_xl/ecg_dataset_processed.csv',
|
| 30 |
-
'model_class': Arsenal,
|
| 31 |
-
'model_name': 'Arsenal',
|
| 32 |
-
'model_file': 'arsenal_processed_model.pkl',
|
| 33 |
-
'positive_class': 'MYOCARDIAL_INFARCTION',
|
| 34 |
-
'negative_class': 'NORMAL',
|
| 35 |
-
'hyperparameters': {
|
| 36 |
-
'num_kernels': 10000,
|
| 37 |
-
'random_state': 42,
|
| 38 |
-
'n_jobs': 1,
|
| 39 |
-
},
|
| 40 |
-
},
|
| 41 |
-
'mi_vs_normal_segmented': {
|
| 42 |
-
'name': 'MI vs Normal (Segmented)',
|
| 43 |
-
'input_csv': 'data/ptb_xl/segmented_heartbeats.csv',
|
| 44 |
-
'model_class': Arsenal,
|
| 45 |
-
'model_name': 'Arsenal',
|
| 46 |
-
'model_file': 'arsenal_segmented_model.pkl',
|
| 47 |
-
'positive_class': 'MYOCARDIAL_INFARCTION',
|
| 48 |
-
'negative_class': 'NORMAL',
|
| 49 |
-
'hyperparameters': {
|
| 50 |
-
'num_kernels': 20000,
|
| 51 |
-
'random_state': 42,
|
| 52 |
-
'n_jobs': 1,
|
| 53 |
-
},
|
| 54 |
-
},
|
| 55 |
-
'omi_vs_nonomi': {
|
| 56 |
-
'name': 'OMI vs non-OMI',
|
| 57 |
-
'input_csv': 'data/ecg_matrix_omi_segmented_50_150_90.csv',
|
| 58 |
-
'model_class': RocketClassifier,
|
| 59 |
-
'model_name': 'Rocket',
|
| 60 |
-
'model_file': 'rocket_model.pkl',
|
| 61 |
-
'positive_class': 'OMI',
|
| 62 |
-
'negative_class': 'non-OMI',
|
| 63 |
-
'hyperparameters': {
|
| 64 |
-
'num_kernels': 50000,
|
| 65 |
-
'random_state': 42,
|
| 66 |
-
'n_jobs': -1,
|
| 67 |
-
},
|
| 68 |
-
},
|
| 69 |
-
'ecg_surgery': {
|
| 70 |
-
'name': 'Pre vs Post Procedural MI (Surgery)',
|
| 71 |
-
'input_csv': 'data/ecg_surgery_segmented_50_150_70.csv',
|
| 72 |
-
'model_class': InceptionTimeClassifier,
|
| 73 |
-
'model_name': 'InceptionTime',
|
| 74 |
-
'model_file': 'inceptiontime_model.pkl',
|
| 75 |
-
'positive_class': 'pre-procedural MI',
|
| 76 |
-
'negative_class': 'post-procedural MI',
|
| 77 |
-
'hyperparameters': {
|
| 78 |
-
'n_epochs': 200,
|
| 79 |
-
'batch_size': 64,
|
| 80 |
-
'random_state': 42,
|
| 81 |
-
'verbose': True,
|
| 82 |
-
},
|
| 83 |
-
},
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
# ─── Data Preprocessing ──────────────────────────────────────────────────────
|
| 87 |
-
|
| 88 |
-
def prepare_data(df):
|
| 89 |
-
"""Prepare data for benchmarking in sktime compatible format (numpy3D)"""
|
| 90 |
-
features = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6']
|
| 91 |
-
|
| 92 |
-
def safe_normalize(x):
|
| 93 |
-
max_val = x.abs().max()
|
| 94 |
-
# Prevent division by zero which yields NaNs
|
| 95 |
-
return x / max_val if max_val > 0 else x
|
| 96 |
-
|
| 97 |
-
print(" Normalizing features...")
|
| 98 |
-
# Normalize features within each subject
|
| 99 |
-
df_X = df[features].copy()
|
| 100 |
-
for col in tqdm(df_X.columns, desc="Normalizing features"):
|
| 101 |
-
df_X[col] = df_X.groupby(level='subject_id')[col].transform(safe_normalize)
|
| 102 |
-
|
| 103 |
-
# Get unique subjects
|
| 104 |
-
subject_ids = df_X.index.get_level_values('subject_id').unique()
|
| 105 |
-
|
| 106 |
-
# Check timesteps per subject
|
| 107 |
-
timesteps_per_subject = df_X.groupby(level='subject_id').size()
|
| 108 |
-
print(f" Timesteps per subject stats:")
|
| 109 |
-
print(f" Min: {timesteps_per_subject.min()}, Max: {timesteps_per_subject.max()}, Mean: {timesteps_per_subject.mean():.1f}")
|
| 110 |
-
|
| 111 |
-
# Use minimum timesteps to ensure consistent shape
|
| 112 |
-
n_timesteps = timesteps_per_subject.min()
|
| 113 |
-
n_features = len(features)
|
| 114 |
-
|
| 115 |
-
print(f" Using {n_timesteps} timesteps per subject (minimum)")
|
| 116 |
-
|
| 117 |
-
# Manually reshape to numpy3D format: (n_instances, n_features, n_timesteps)
|
| 118 |
-
print(" Reshaping data to numpy3D format...")
|
| 119 |
-
X_list = []
|
| 120 |
-
y_list = []
|
| 121 |
-
valid_subject_ids = []
|
| 122 |
-
|
| 123 |
-
for subject_id in tqdm(subject_ids, desc="Processing subjects"):
|
| 124 |
-
try:
|
| 125 |
-
subject_data = df_X.xs(subject_id, level='subject_id')
|
| 126 |
-
|
| 127 |
-
# Ensure we have enough timesteps
|
| 128 |
-
if len(subject_data) >= n_timesteps:
|
| 129 |
-
# Take first n_timesteps and transpose to (n_features, n_timesteps)
|
| 130 |
-
subject_array = subject_data.iloc[:n_timesteps].values.T
|
| 131 |
-
|
| 132 |
-
# Check shape
|
| 133 |
-
if subject_array.shape == (n_features, n_timesteps):
|
| 134 |
-
X_list.append(subject_array)
|
| 135 |
-
|
| 136 |
-
# Get label for this subject
|
| 137 |
-
subject_label = df.loc[subject_id].iloc[0]['class']
|
| 138 |
-
y_list.append(subject_label)
|
| 139 |
-
valid_subject_ids.append(subject_id)
|
| 140 |
-
else:
|
| 141 |
-
continue
|
| 142 |
-
|
| 143 |
-
except Exception as e:
|
| 144 |
-
continue
|
| 145 |
-
|
| 146 |
-
# Convert to numpy arrays
|
| 147 |
-
X = np.array(X_list) # Shape: (n_instances, n_features, n_timesteps)
|
| 148 |
-
y = pd.Series(y_list, index=valid_subject_ids)
|
| 149 |
-
|
| 150 |
-
print(f" Final data shape: {X.shape}")
|
| 151 |
-
print(f" Class distribution:")
|
| 152 |
-
print(y.value_counts())
|
| 153 |
-
|
| 154 |
-
# Split into train/test
|
| 155 |
-
print(" Splitting data into train/test sets...")
|
| 156 |
-
X_train, X_test, y_train, y_test = train_test_split(
|
| 157 |
-
X, y, test_size=0.25, random_state=42, stratify=y
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
# --- SMOTE-TOMEK INJECTION ---
|
| 161 |
-
print(" Executing SMOTE-Tomek to isolate and synthetically balance training geometries...")
|
| 162 |
-
try:
|
| 163 |
-
# Flatten 3D arrays to 2D for SMOTETomek: (N, features, timesteps) -> (N, features * timesteps)
|
| 164 |
-
n_train, n_feat, n_time = X_train.shape
|
| 165 |
-
X_train_flat = X_train.reshape(n_train, n_feat * n_time)
|
| 166 |
-
|
| 167 |
-
# Balance classes
|
| 168 |
-
smote_tomek = SMOTETomek(random_state=42)
|
| 169 |
-
X_resampled_flat, y_resampled = smote_tomek.fit_resample(X_train_flat, y_train)
|
| 170 |
-
|
| 171 |
-
# Deflate back out to precise 3D format for Sktime models
|
| 172 |
-
n_new = X_resampled_flat.shape[0]
|
| 173 |
-
X_train = X_resampled_flat.reshape(n_new, n_feat, n_time)
|
| 174 |
-
y_train = y_resampled
|
| 175 |
-
|
| 176 |
-
print(f" -> SMOTE Success! Training geometries augmented from {n_train} to {n_new} samples.")
|
| 177 |
-
except Exception as e:
|
| 178 |
-
print(f" [!] SMOTETomek failed (are you missing imbalanced-learn?): {e}")
|
| 179 |
-
# -----------------------------
|
| 180 |
-
|
| 181 |
-
print(f" Final Training set: {X_train.shape}")
|
| 182 |
-
print(f" Final Test set: {X_test.shape}")
|
| 183 |
-
print(f" y_train balanced distribution: {y_train.value_counts().to_dict()}")
|
| 184 |
-
print(f" y_test strictly isolated distribution: {y_test.value_counts().to_dict()}")
|
| 185 |
-
|
| 186 |
-
return X_train, X_test, y_train, y_test
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
# ─── Training Logic ──────────────────────────────────────────────────────────
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def train_and_save(use_case_key, config):
|
| 193 |
-
"""
|
| 194 |
-
Train a single model for the given use case, evaluate it, and persist
|
| 195 |
-
both the serialised model and a metadata JSON to disk.
|
| 196 |
-
|
| 197 |
-
Returns True on success, False on failure.
|
| 198 |
-
"""
|
| 199 |
-
|
| 200 |
-
print(f"\n{'=' * 60}")
|
| 201 |
-
print(f" TRAINING: {config['name']}")
|
| 202 |
-
print(f" Model: {config['model_name']}")
|
| 203 |
-
print(f"{'=' * 60}")
|
| 204 |
-
|
| 205 |
-
input_csv = config['input_csv']
|
| 206 |
-
if not os.path.exists(input_csv):
|
| 207 |
-
print(f"[ERROR] Input file not found: {input_csv}")
|
| 208 |
-
return False
|
| 209 |
-
|
| 210 |
-
# ── Load data ──────────────────────────────────────────────────────────
|
| 211 |
-
print(f"\nLoading data from {input_csv} ...")
|
| 212 |
-
df = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp'])
|
| 213 |
-
|
| 214 |
-
# Map labels to Pre/Post Procedural MI if it's the surgery dataset
|
| 215 |
-
if use_case_key == 'ecg_surgery':
|
| 216 |
-
print(" Mapping class labels to 'pre-procedural MI' and 'post-procedural MI' ...")
|
| 217 |
-
df['class'] = df['class'].replace({
|
| 218 |
-
'Pre-Surgery MI': 'pre-procedural MI',
|
| 219 |
-
'Post-Surgery MI': 'post-procedural MI'
|
| 220 |
-
})
|
| 221 |
-
|
| 222 |
-
print(f" DataFrame shape: {df.shape}")
|
| 223 |
-
|
| 224 |
-
subject_class = df.groupby(level='subject_id').first()['class']
|
| 225 |
-
print(f" Class distribution (subjects):\n{subject_class.value_counts().to_string()}")
|
| 226 |
-
|
| 227 |
-
# ── Prepare data (normalize → reshape → split → SMOTE-Tomek) ──────────
|
| 228 |
-
print("\nPreparing data (normalize, reshape, split, SMOTE-Tomek) ...")
|
| 229 |
-
X_train, X_test, y_train, y_test = prepare_data(df)
|
| 230 |
-
|
| 231 |
-
n_timesteps = X_train.shape[2]
|
| 232 |
-
n_features = X_train.shape[1]
|
| 233 |
-
print(f" n_timesteps = {n_timesteps}")
|
| 234 |
-
print(f" n_features = {n_features}")
|
| 235 |
-
print(f" X_train shape: {X_train.shape} | X_test shape: {X_test.shape}")
|
| 236 |
-
|
| 237 |
-
# ── Initialise & train model ──────────────────────────────────────────
|
| 238 |
-
model = config['model_class'](**config['hyperparameters'])
|
| 239 |
-
|
| 240 |
-
params_str = ""
|
| 241 |
-
if 'num_kernels' in config['hyperparameters']:
|
| 242 |
-
params_str = f" (num_kernels={config['hyperparameters']['num_kernels']})"
|
| 243 |
-
elif 'n_epochs' in config['hyperparameters']:
|
| 244 |
-
params_str = f" (n_epochs={config['hyperparameters']['n_epochs']})"
|
| 245 |
-
if 'batch_size' in config['hyperparameters']:
|
| 246 |
-
params_str += f" (batch_size={config['hyperparameters']['batch_size']})"
|
| 247 |
-
|
| 248 |
-
print(f"\nTraining {config['model_name']}{params_str} ...")
|
| 249 |
-
train_start = time.time()
|
| 250 |
-
|
| 251 |
-
from joblib import parallel_backend
|
| 252 |
-
with parallel_backend('threading'):
|
| 253 |
-
model.fit(X_train, y_train)
|
| 254 |
-
train_time = time.time() - train_start
|
| 255 |
-
print(f" Training completed in {train_time:.1f}s")
|
| 256 |
-
|
| 257 |
-
# ── Evaluate on test set ──────────────────────────────────────────────
|
| 258 |
-
print("\nEvaluating on held-out test set ...")
|
| 259 |
-
y_pred = model.predict(X_test)
|
| 260 |
-
|
| 261 |
-
positive = config['positive_class']
|
| 262 |
-
y_test_num = (y_test == positive).astype(int)
|
| 263 |
-
y_pred_num = (y_pred == positive).astype(int)
|
| 264 |
-
|
| 265 |
-
accuracy = accuracy_score(y_test_num, y_pred_num)
|
| 266 |
-
precision = precision_score(y_test_num, y_pred_num, zero_division=0)
|
| 267 |
-
recall = recall_score(y_test_num, y_pred_num, zero_division=0)
|
| 268 |
-
f1 = f1_score(y_test_num, y_pred_num, zero_division=0)
|
| 269 |
-
|
| 270 |
-
cm = confusion_matrix(y_test_num, y_pred_num)
|
| 271 |
-
if cm.shape == (2, 2):
|
| 272 |
-
tn, fp, fn, tp = cm.flatten()
|
| 273 |
-
sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0
|
| 274 |
-
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
|
| 275 |
-
else:
|
| 276 |
-
sensitivity = specificity = 0.0
|
| 277 |
-
|
| 278 |
-
test_metrics = {
|
| 279 |
-
'accuracy': float(accuracy),
|
| 280 |
-
'precision': float(precision),
|
| 281 |
-
'recall': float(recall),
|
| 282 |
-
'f1': float(f1),
|
| 283 |
-
'sensitivity': float(sensitivity),
|
| 284 |
-
'specificity': float(specificity),
|
| 285 |
-
'confusion_matrix': cm.tolist(),
|
| 286 |
-
}
|
| 287 |
-
|
| 288 |
-
print(f"\n{'-' * 40}")
|
| 289 |
-
print(f" TEST-SET RESULTS")
|
| 290 |
-
print(f"{'-' * 40}")
|
| 291 |
-
print(f" Accuracy: {accuracy:.4f}")
|
| 292 |
-
print(f" Precision: {precision:.4f}")
|
| 293 |
-
print(f" Recall: {recall:.4f}")
|
| 294 |
-
print(f" F1 Score: {f1:.4f}")
|
| 295 |
-
print(f" Sensitivity: {sensitivity:.4f}")
|
| 296 |
-
print(f" Specificity: {specificity:.4f}")
|
| 297 |
-
print(f" Confusion Matrix:\n {cm}")
|
| 298 |
-
|
| 299 |
-
# ── Save model (pickle) ──────────────────────────────────────────────
|
| 300 |
-
output_dir = os.path.join(MODELS_DIR, use_case_key)
|
| 301 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 302 |
-
|
| 303 |
-
model_path = os.path.join(output_dir, config['model_file'])
|
| 304 |
-
print(f"\nSaving model to {model_path} ...")
|
| 305 |
-
with open(model_path, 'wb') as f:
|
| 306 |
-
pickle.dump(model, f)
|
| 307 |
-
|
| 308 |
-
model_size_mb = os.path.getsize(model_path) / (1024 * 1024)
|
| 309 |
-
print(f" Model saved ({model_size_mb:.1f} MB)")
|
| 310 |
-
|
| 311 |
-
# ── Save metadata (JSON) ─────────────────────────────────────────────
|
| 312 |
-
metadata = {
|
| 313 |
-
'model_name': config['model_name'],
|
| 314 |
-
'model_file': config['model_file'],
|
| 315 |
-
'use_case': config['name'],
|
| 316 |
-
'positive_class': config['positive_class'],
|
| 317 |
-
'negative_class': config['negative_class'],
|
| 318 |
-
'class_labels': [config['negative_class'], config['positive_class']],
|
| 319 |
-
'features': FEATURES,
|
| 320 |
-
'n_features': int(n_features),
|
| 321 |
-
'n_timesteps': int(n_timesteps),
|
| 322 |
-
'hyperparameters': config['hyperparameters'],
|
| 323 |
-
'training_data': input_csv,
|
| 324 |
-
'training_date': time.strftime('%Y-%m-%d %H:%M:%S'),
|
| 325 |
-
'training_time_seconds': round(train_time, 2),
|
| 326 |
-
'preprocessing': {
|
| 327 |
-
'normalization': 'per_subject_max_abs',
|
| 328 |
-
'smote_tomek': True,
|
| 329 |
-
'test_size': 0.25,
|
| 330 |
-
'random_state': 42,
|
| 331 |
-
},
|
| 332 |
-
'training_samples': int(X_train.shape[0]),
|
| 333 |
-
'test_samples': int(X_test.shape[0]),
|
| 334 |
-
'test_metrics': test_metrics,
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
-
metadata_path = os.path.join(output_dir, 'model_metadata.json')
|
| 338 |
-
with open(metadata_path, 'w') as f:
|
| 339 |
-
json.dump(metadata, f, indent=2)
|
| 340 |
-
print(f" Metadata saved to {metadata_path}")
|
| 341 |
-
|
| 342 |
-
print(f"\n[OK] {config['name']} - done.")
|
| 343 |
-
return True
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
| 347 |
-
|
| 348 |
-
def show_saved_metrics():
|
| 349 |
-
"""
|
| 350 |
-
Search the MODELS_DIR directory for any saved model metadata JSON files
|
| 351 |
-
and print a beautiful summary of their performance metrics.
|
| 352 |
-
"""
|
| 353 |
-
print('=' * 60)
|
| 354 |
-
print(' TRAINED ECG MODEL METRICS SUMMARY')
|
| 355 |
-
print('=' * 60)
|
| 356 |
-
|
| 357 |
-
found = False
|
| 358 |
-
if os.path.exists(MODELS_DIR):
|
| 359 |
-
for item in sorted(os.listdir(MODELS_DIR)):
|
| 360 |
-
meta_path = os.path.join(MODELS_DIR, item, 'model_metadata.json')
|
| 361 |
-
if os.path.exists(meta_path):
|
| 362 |
-
try:
|
| 363 |
-
with open(meta_path, 'r') as f:
|
| 364 |
-
meta = json.load(f)
|
| 365 |
-
|
| 366 |
-
found = True
|
| 367 |
-
print(f"\nUse Case: {meta.get('use_case', 'Unknown')}")
|
| 368 |
-
print(f"Model Class: {meta.get('model_name', 'Unknown')}")
|
| 369 |
-
print(f"Model File: {os.path.join(MODELS_DIR, item, meta.get('model_file', ''))}")
|
| 370 |
-
print(f"Trained On: {meta.get('training_data', 'Unknown')}")
|
| 371 |
-
print(f"Training Date: {meta.get('training_date', 'Unknown')}")
|
| 372 |
-
if 'training_time_seconds' in meta:
|
| 373 |
-
print(f"Train Time: {meta['training_time_seconds']:.1f}s")
|
| 374 |
-
|
| 375 |
-
metrics = meta.get('test_metrics', {})
|
| 376 |
-
print("Test Metrics:")
|
| 377 |
-
print(f" - Accuracy: {metrics.get('accuracy', 0.0):.4f}")
|
| 378 |
-
print(f" - Precision: {metrics.get('precision', 0.0):.4f}")
|
| 379 |
-
print(f" - Recall: {metrics.get('recall', 0.0):.4f}")
|
| 380 |
-
print(f" - F1 Score: {metrics.get('f1', 0.0):.4f}")
|
| 381 |
-
print(f" - Sensitivity: {metrics.get('sensitivity', 0.0):.4f}")
|
| 382 |
-
print(f" - Specificity: {metrics.get('specificity', 0.0):.4f}")
|
| 383 |
-
|
| 384 |
-
cm = metrics.get('confusion_matrix', None)
|
| 385 |
-
if cm:
|
| 386 |
-
print(f" - Confusion Matrix:\n {np.array(cm)}")
|
| 387 |
-
print("-" * 60)
|
| 388 |
-
except Exception as e:
|
| 389 |
-
print(f"[Error loading metadata from {meta_path}]: {e}")
|
| 390 |
-
|
| 391 |
-
if not found:
|
| 392 |
-
print(f"\nNo saved model metadata files found under '{MODELS_DIR}/'.")
|
| 393 |
-
print("Please train models first to generate performance metrics.")
|
| 394 |
-
print('=' * 60)
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
if __name__ == '__main__':
|
| 398 |
-
parser = argparse.ArgumentParser(
|
| 399 |
-
description='Train and save the best ECG classification models')
|
| 400 |
-
parser.add_argument(
|
| 401 |
-
'--use-case', type=str,
|
| 402 |
-
choices=['mi_vs_normal_processed', 'mi_vs_normal_segmented', 'omi_vs_nonomi', 'ecg_surgery', 'all'],
|
| 403 |
-
default='all',
|
| 404 |
-
help='Which use case to train (default: all)')
|
| 405 |
-
parser.add_argument(
|
| 406 |
-
'--show-metrics', action='store_true',
|
| 407 |
-
help='Display performance metrics of already trained/saved models and exit')
|
| 408 |
-
args = parser.parse_args()
|
| 409 |
-
|
| 410 |
-
if args.show_metrics:
|
| 411 |
-
show_saved_metrics()
|
| 412 |
-
else:
|
| 413 |
-
cases = list(USE_CASES.keys()) if args.use_case == 'all' else [args.use_case]
|
| 414 |
-
|
| 415 |
-
print('=' * 60)
|
| 416 |
-
print(' ECG MODEL TRAINING & SERIALISATION')
|
| 417 |
-
print('=' * 60)
|
| 418 |
-
|
| 419 |
-
for key in cases:
|
| 420 |
-
success = train_and_save(key, USE_CASES[key])
|
| 421 |
-
if not success:
|
| 422 |
-
print(f"\n[FAIL] Could not train {USE_CASES[key]['name']}")
|
| 423 |
-
|
| 424 |
-
print(f"\n{'=' * 60}")
|
| 425 |
-
print(f" ALL TRAINING COMPLETE")
|
| 426 |
-
print(f" Models saved to: {MODELS_DIR}/")
|
| 427 |
-
print(f"{'=' * 60}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/digitization/README.md
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 1 |
-
# archive/digitization
|
| 2 |
-
|
| 3 |
-
Legacy ECG image digitization scripts that have been **superseded** by the
|
| 4 |
-
Streamlit dashboard in the workspace root.
|
| 5 |
-
|
| 6 |
-
## Contents
|
| 7 |
-
|
| 8 |
-
| File | Status | Reason for archival |
|
| 9 |
-
| --- | --- | --- |
|
| 10 |
-
| `run_ecg.py` | Legacy batch digitizer | Processes a directory tree of ECG paper scans (one CSV per image). Uses Linux-only `SIGALRM` timeouts and hard-coded model paths under `Submission code/...`. Replaced by the interactive pipeline in `backend/digitization_runner.py` invoked from `app.py`. |
|
| 11 |
-
|
| 12 |
-
## Current digitization entry point
|
| 13 |
-
|
| 14 |
-
Use the Streamlit dashboard:
|
| 15 |
-
|
| 16 |
-
```bash
|
| 17 |
-
streamlit run app.py
|
| 18 |
-
```
|
| 19 |
-
|
| 20 |
-
Then open the **"📷 Image Digitizer"** (now **"ECG Image Digitizer"**) page and
|
| 21 |
-
either upload an ECG scan or check the sample image option.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
archive/digitization/run_ecg.py
DELETED
|
@@ -1,302 +0,0 @@
|
|
| 1 |
-
# Force all BLAS / OpenMP thread pools to a single thread before any other
|
| 2 |
-
# import. This must happen before numpy, torch, or cv2 are imported, otherwise
|
| 3 |
-
# the libraries have already spawned their thread pools and the env vars have
|
| 4 |
-
# no effect. Without this, BLAS and PyTorch worker threads compete for CPU
|
| 5 |
-
# cores and can deadlock on multi-core servers.
|
| 6 |
-
import os
|
| 7 |
-
os.environ["OPENBLAS_NUM_THREADS"] = "1"
|
| 8 |
-
os.environ["OMP_NUM_THREADS"] = "1"
|
| 9 |
-
os.environ["MKL_NUM_THREADS"] = "1"
|
| 10 |
-
|
| 11 |
-
import glob
|
| 12 |
-
import signal
|
| 13 |
-
import torch
|
| 14 |
-
import pandas as pd
|
| 15 |
-
from ultralytics import YOLO
|
| 16 |
-
from digitization import ECGImage
|
| 17 |
-
|
| 18 |
-
# -------------------------
|
| 19 |
-
# Configuration
|
| 20 |
-
# -------------------------
|
| 21 |
-
|
| 22 |
-
# Root of the organised input dataset. Each immediate subdirectory is treated
|
| 23 |
-
# as one hospital and must itself contain subfolders named after CATEGORIES.
|
| 24 |
-
ORGANIZED_DIR = "../ecg_files/ECG_organized_all"
|
| 25 |
-
|
| 26 |
-
# Root where output CSVs are written, mirroring the hospital/category structure
|
| 27 |
-
# of ORGANIZED_DIR. Created automatically if it does not exist.
|
| 28 |
-
OUTPUT_DIR = "../ecg_files/ECG_digitized"
|
| 29 |
-
|
| 30 |
-
# Subfolders to process within each hospital directory.
|
| 31 |
-
# Any subfolder not listed here is silently skipped.
|
| 32 |
-
# Add or remove entries to match your dataset's naming convention.
|
| 33 |
-
CATEGORIES = ["pre", "index", "post", "discharge", "dimi"]
|
| 34 |
-
|
| 35 |
-
# Per-image wall-clock time limit in seconds, enforced via SIGALRM.
|
| 36 |
-
# Images that exceed this budget are logged as timeouts and skipped.
|
| 37 |
-
# Increase for very large images or slow CPU-only machines.
|
| 38 |
-
# Note: SIGALRM is only available on Linux.
|
| 39 |
-
TIMEOUT = 600
|
| 40 |
-
|
| 41 |
-
# -------------------------
|
| 42 |
-
# Torch / GPU settings
|
| 43 |
-
# -------------------------
|
| 44 |
-
|
| 45 |
-
if torch.cuda.is_available():
|
| 46 |
-
# benchmark=True lets cuDNN auto-tune convolution algorithms at the first
|
| 47 |
-
# forward pass. Speeds up subsequent passes when input sizes are fixed,
|
| 48 |
-
# which is the case here (images are resized to a fixed height).
|
| 49 |
-
torch.backends.cudnn.benchmark = True
|
| 50 |
-
|
| 51 |
-
# deterministic=False allows cuDNN to use non-deterministic (but faster)
|
| 52 |
-
# algorithms. Set to True if exact reproducibility is required.
|
| 53 |
-
torch.backends.cudnn.deterministic = False
|
| 54 |
-
|
| 55 |
-
print(f"GPU: {torch.cuda.get_device_name(0)}", flush=True)
|
| 56 |
-
print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB\n",
|
| 57 |
-
flush=True)
|
| 58 |
-
else:
|
| 59 |
-
print("No GPU found — running on CPU.\n", flush=True)
|
| 60 |
-
|
| 61 |
-
# -------------------------
|
| 62 |
-
# Timeout (Linux only)
|
| 63 |
-
# -------------------------
|
| 64 |
-
|
| 65 |
-
class TimeoutException(Exception):
|
| 66 |
-
"""Raised by timeout_handler when SIGALRM fires."""
|
| 67 |
-
pass
|
| 68 |
-
|
| 69 |
-
def timeout_handler(signum, frame):
|
| 70 |
-
"""Signal handler that converts SIGALRM into a TimeoutException.
|
| 71 |
-
|
| 72 |
-
Registered below with signal.signal() so that any blocking call
|
| 73 |
-
inside process_folder() can be interrupted after TIMEOUT seconds.
|
| 74 |
-
"""
|
| 75 |
-
raise TimeoutException("Processing timed out")
|
| 76 |
-
|
| 77 |
-
# Register the handler for SIGALRM. signal.alarm(n) starts a countdown;
|
| 78 |
-
# if the process has not called signal.alarm(0) within n seconds, this
|
| 79 |
-
# handler fires and raises TimeoutException.
|
| 80 |
-
signal.signal(signal.SIGALRM, timeout_handler)
|
| 81 |
-
|
| 82 |
-
# -------------------------
|
| 83 |
-
# Load models once (on GPU if available)
|
| 84 |
-
# -------------------------
|
| 85 |
-
|
| 86 |
-
# All four models are loaded once before the processing loop so that the
|
| 87 |
-
# weights are only read from disk once, regardless of how many images are
|
| 88 |
-
# processed. Loading inside the loop would add several seconds of overhead
|
| 89 |
-
# per image.
|
| 90 |
-
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 91 |
-
|
| 92 |
-
print("Loading models...", flush=True)
|
| 93 |
-
|
| 94 |
-
# Detection model for full lead bounding boxes (one box per lead region).
|
| 95 |
-
box_model = YOLO("Submission code/3. Model training/runs/yolo11_full/weights/best.pt")
|
| 96 |
-
|
| 97 |
-
# Classification model for lead name labels (I, II, III, aVR, aVL, aVF, V1–V6).
|
| 98 |
-
lead_name_model = YOLO("Submission code/3. Model training/runs/yolo11_lead/weights/best.pt")
|
| 99 |
-
|
| 100 |
-
# Detection model for calibration (reference) pulse boxes.
|
| 101 |
-
pulse_model = YOLO("Submission code/3. Model training/runs/yolo11_pulse/weights/best.pt")
|
| 102 |
-
|
| 103 |
-
box_model.to(DEVICE)
|
| 104 |
-
lead_name_model.to(DEVICE)
|
| 105 |
-
pulse_model.to(DEVICE)
|
| 106 |
-
|
| 107 |
-
# The segmentation model is kept as a file path rather than a loaded model
|
| 108 |
-
# object because it is consumed by patched_yolo_infer.MakeCropsDetectThem,
|
| 109 |
-
# which loads it internally on each call. This avoids a second copy of the
|
| 110 |
-
# weights living in VRAM alongside the three detection models above.
|
| 111 |
-
segmentation_model = "Submission code/3. Model training/runs/yolo11_patch/weights/best.pt"
|
| 112 |
-
|
| 113 |
-
print(f"Models loaded on {DEVICE.upper()}.\n", flush=True)
|
| 114 |
-
|
| 115 |
-
# -------------------------
|
| 116 |
-
# Process a single folder
|
| 117 |
-
# -------------------------
|
| 118 |
-
|
| 119 |
-
def process_folder(input_dir, output_dir):
|
| 120 |
-
"""Digitize all ECG images in input_dir and save CSVs to output_dir.
|
| 121 |
-
|
| 122 |
-
Iterates over all .png / .jpg / .jpeg files in input_dir (sorted
|
| 123 |
-
alphabetically). Each image is processed by ECGImage.run_full_pipeline()
|
| 124 |
-
and the resulting signals are exported as a CSV file with the same base
|
| 125 |
-
name as the image.
|
| 126 |
-
|
| 127 |
-
Parameters
|
| 128 |
-
----------
|
| 129 |
-
input_dir : str
|
| 130 |
-
Directory containing the input ECG images.
|
| 131 |
-
output_dir : str
|
| 132 |
-
Directory where output CSV files are written (created if absent).
|
| 133 |
-
|
| 134 |
-
Returns
|
| 135 |
-
-------
|
| 136 |
-
success : int
|
| 137 |
-
Number of images successfully digitized.
|
| 138 |
-
total : int
|
| 139 |
-
Total number of images found in input_dir.
|
| 140 |
-
"""
|
| 141 |
-
# Collect all supported image formats; sort for reproducible ordering.
|
| 142 |
-
files = []
|
| 143 |
-
for ext in ("*.png", "*.jpg", "*.jpeg"):
|
| 144 |
-
files.extend(glob.glob(os.path.join(input_dir, ext)))
|
| 145 |
-
files.sort()
|
| 146 |
-
|
| 147 |
-
# Return early without creating the output directory if the folder is empty.
|
| 148 |
-
if not files:
|
| 149 |
-
return 0, 0
|
| 150 |
-
|
| 151 |
-
os.makedirs(output_dir, exist_ok=True)
|
| 152 |
-
success = errors = timeouts = 0
|
| 153 |
-
|
| 154 |
-
for i, file_path in enumerate(files):
|
| 155 |
-
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
| 156 |
-
print(f" [{i+1}/{len(files)}] {base_name}", flush=True)
|
| 157 |
-
try:
|
| 158 |
-
# Start the per-image countdown. If the block below takes longer
|
| 159 |
-
# than TIMEOUT seconds, SIGALRM fires and TimeoutException is raised.
|
| 160 |
-
signal.alarm(TIMEOUT)
|
| 161 |
-
|
| 162 |
-
# Instantiate the digitizer for this image. Models are passed in
|
| 163 |
-
# so they are not reloaded on every iteration.
|
| 164 |
-
ecg = ECGImage(
|
| 165 |
-
box_model=box_model,
|
| 166 |
-
segmentation_model=segmentation_model,
|
| 167 |
-
lead_name_model=lead_name_model,
|
| 168 |
-
pulse_model=pulse_model,
|
| 169 |
-
image_path=file_path,
|
| 170 |
-
)
|
| 171 |
-
|
| 172 |
-
# Run all pipeline stages: preprocessing → segmentation → YOLO
|
| 173 |
-
# detection (×3) → scale calibration → grid construction →
|
| 174 |
-
# signal extraction. Internally retries at different image sizes
|
| 175 |
-
# if any detection returns empty.
|
| 176 |
-
ecg.run_full_pipeline()
|
| 177 |
-
|
| 178 |
-
# Write one CSV per image: columns = lead names, rows = time samples.
|
| 179 |
-
ecg.save_signals_as_csv(base_name, directory=output_dir)
|
| 180 |
-
success += 1
|
| 181 |
-
|
| 182 |
-
except TimeoutException:
|
| 183 |
-
# The image took longer than TIMEOUT seconds. Counted separately
|
| 184 |
-
# from errors so they can be investigated independently.
|
| 185 |
-
timeouts += 1
|
| 186 |
-
print(f" ⏰ TIMEOUT: {base_name}", flush=True)
|
| 187 |
-
|
| 188 |
-
except Exception as e:
|
| 189 |
-
# Any other failure (bad image, detection failure, calibration
|
| 190 |
-
# error, etc.) is caught here so the loop continues with the
|
| 191 |
-
# remaining images.
|
| 192 |
-
errors += 1
|
| 193 |
-
print(f" ❌ ERROR: {base_name} → {e}", flush=True)
|
| 194 |
-
|
| 195 |
-
finally:
|
| 196 |
-
# Always cancel the alarm, even if an exception was raised, to
|
| 197 |
-
# prevent a stale countdown from interrupting the next image.
|
| 198 |
-
signal.alarm(0)
|
| 199 |
-
|
| 200 |
-
# Release VRAM after each image to prevent fragmentation across
|
| 201 |
-
# a long batch run. synchronize() ensures all GPU ops are complete
|
| 202 |
-
# before empty_cache() frees the memory.
|
| 203 |
-
if torch.cuda.is_available():
|
| 204 |
-
try:
|
| 205 |
-
torch.cuda.synchronize()
|
| 206 |
-
torch.cuda.empty_cache()
|
| 207 |
-
except Exception:
|
| 208 |
-
pass
|
| 209 |
-
|
| 210 |
-
total = len(files)
|
| 211 |
-
print(f" → {success}/{total} ok | {errors} errors | {timeouts} timeouts\n",
|
| 212 |
-
flush=True)
|
| 213 |
-
return success, total
|
| 214 |
-
|
| 215 |
-
# -------------------------
|
| 216 |
-
# Main loop
|
| 217 |
-
# -------------------------
|
| 218 |
-
|
| 219 |
-
# Discover hospital directories. Each immediate child directory of
|
| 220 |
-
# ORGANIZED_DIR is treated as one hospital; files at the root level are ignored.
|
| 221 |
-
hospitals = sorted([
|
| 222 |
-
name for name in os.listdir(ORGANIZED_DIR)
|
| 223 |
-
if os.path.isdir(os.path.join(ORGANIZED_DIR, name))
|
| 224 |
-
])
|
| 225 |
-
|
| 226 |
-
# Accumulate per-hospital/category counts for the summary report.
|
| 227 |
-
log_rows = []
|
| 228 |
-
|
| 229 |
-
print(f"Found {len(hospitals)} hospital(s). Starting digitization...\n", flush=True)
|
| 230 |
-
|
| 231 |
-
for hospital in hospitals:
|
| 232 |
-
print(f"{'='*60}", flush=True)
|
| 233 |
-
print(f"Hospital: {hospital}", flush=True)
|
| 234 |
-
print(f"{'='*60}", flush=True)
|
| 235 |
-
|
| 236 |
-
for cat in CATEGORIES:
|
| 237 |
-
input_dir = os.path.join(ORGANIZED_DIR, hospital, cat)
|
| 238 |
-
output_dir = os.path.join(OUTPUT_DIR, hospital, cat)
|
| 239 |
-
|
| 240 |
-
# Skip silently if this category does not exist for this hospital.
|
| 241 |
-
if not os.path.isdir(input_dir):
|
| 242 |
-
continue
|
| 243 |
-
|
| 244 |
-
print(f" [{cat.upper()}]", flush=True)
|
| 245 |
-
success, total = process_folder(input_dir, output_dir)
|
| 246 |
-
|
| 247 |
-
# Only log categories that had at least one image, to keep the
|
| 248 |
-
# summary table free of zero-row entries.
|
| 249 |
-
if total > 0:
|
| 250 |
-
log_rows.append({
|
| 251 |
-
"hospital": hospital,
|
| 252 |
-
"category": cat,
|
| 253 |
-
"total" : total,
|
| 254 |
-
"success" : success,
|
| 255 |
-
"failed" : total - success,
|
| 256 |
-
"rate" : f"{100 * success / total:.1f}%",
|
| 257 |
-
})
|
| 258 |
-
|
| 259 |
-
# -------------------------
|
| 260 |
-
# Summary report
|
| 261 |
-
# -------------------------
|
| 262 |
-
|
| 263 |
-
print("\n" + "="*60, flush=True)
|
| 264 |
-
print("DIGITIZATION SUMMARY", flush=True)
|
| 265 |
-
print("="*60, flush=True)
|
| 266 |
-
|
| 267 |
-
df = pd.DataFrame(log_rows)
|
| 268 |
-
|
| 269 |
-
if df.empty:
|
| 270 |
-
print("No images were processed.", flush=True)
|
| 271 |
-
else:
|
| 272 |
-
# Flat per-hospital/category breakdown.
|
| 273 |
-
print("\nPer hospital / category:\n")
|
| 274 |
-
print(df.to_string(index=False))
|
| 275 |
-
|
| 276 |
-
# Pivot to a hospital × category matrix of successful digitizations,
|
| 277 |
-
# with a TOTAL row and column for quick inspection.
|
| 278 |
-
print("\n\nSuccessful digitizations — distribution:\n")
|
| 279 |
-
success_pivot = (
|
| 280 |
-
df.pivot_table(index="hospital", columns="category",
|
| 281 |
-
values="success", aggfunc="sum", fill_value=0)
|
| 282 |
-
.reindex(columns=CATEGORIES, fill_value=0) # preserve CATEGORIES column order
|
| 283 |
-
)
|
| 284 |
-
success_pivot["TOTAL"] = success_pivot.sum(axis=1) # row totals
|
| 285 |
-
success_pivot.loc["TOTAL"] = success_pivot.sum(axis=0) # grand total row
|
| 286 |
-
print(success_pivot.to_string())
|
| 287 |
-
|
| 288 |
-
# Overall counts across the entire dataset.
|
| 289 |
-
total_imgs = df["total"].sum()
|
| 290 |
-
total_ok = df["success"].sum()
|
| 291 |
-
total_failed = df["failed"].sum()
|
| 292 |
-
print(f"\n✅ Overall: {total_ok}/{total_imgs} successful "
|
| 293 |
-
f"({100*total_ok/total_imgs:.1f}%) | "
|
| 294 |
-
f"{total_failed} failed", flush=True)
|
| 295 |
-
|
| 296 |
-
# Persist the pivot table as a CSV for later analysis.
|
| 297 |
-
summary_path = os.path.join(OUTPUT_DIR, "digitization_summary.csv")
|
| 298 |
-
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 299 |
-
success_pivot.to_csv(summary_path)
|
| 300 |
-
print(f"📄 Summary saved to {summary_path}", flush=True)
|
| 301 |
-
|
| 302 |
-
print("\nAll processing complete.", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|