| |
| """ |
| Backend adapter for ECG Classification. |
| Handles heartbeat segmentation, data preparation for sktime models, and model evaluation. |
| """ |
|
|
| import os |
| import sys |
| import numpy as np |
| import pandas as pd |
| from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix |
|
|
| |
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) |
|
|
| |
| class DependencyMissingError(Exception): |
| """Raised when a required third-party library is not installed.""" |
| pass |
|
|
| class EmptyDatasetError(Exception): |
| """Raised when the uploaded dataset is empty or invalid.""" |
| pass |
|
|
| |
| def check_classification_dependencies(): |
| """ |
| Checks if sktime is installed in the current environment. |
| Raises DependencyMissingError if it is not. |
| """ |
| missing = [] |
| try: |
| import sktime |
| except ImportError: |
| missing.append("sktime") |
| |
| if missing: |
| raise DependencyMissingError( |
| f"The following libraries are required for classification but are missing in this environment:\n" |
| f"{', '.join(missing)}\n\n" |
| f"Please run: pip install sktime" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| def bandpass_filter(signal_data, lowcut=5, highcut=15, fs=500, order=2): |
| """Bandpass filter for ECG signal""" |
| from scipy.signal import butter, filtfilt |
| try: |
| nyquist = 0.5 * fs |
| low = lowcut / nyquist |
| high = highcut / nyquist |
| b, a = butter(order, [low, high], btype='band') |
| return filtfilt(b, a, signal_data) |
| except Exception: |
| return signal_data |
|
|
|
|
| def find_r_peaks(ecg_signal, min_heart_rate=40, max_heart_rate=180, fs=500): |
| """Find R-peaks in ECG signal""" |
| from scipy.signal import find_peaks |
| |
| min_distance = int((60 / max_heart_rate) * fs) |
| max_distance = int((60 / min_heart_rate) * fs) |
| |
| |
| peaks, properties = find_peaks(ecg_signal, |
| height=np.percentile(ecg_signal, 80), |
| distance=min_distance, |
| prominence=np.std(ecg_signal) * 0.3) |
| |
| |
| if len(peaks) == 0: |
| return peaks |
| |
| valid_peaks = [peaks[0]] |
| for i in range(1, len(peaks)): |
| distance = peaks[i] - valid_peaks[-1] |
| if min_distance <= distance <= max_distance: |
| valid_peaks.append(peaks[i]) |
| |
| return np.array(valid_peaks) |
|
|
|
|
| def downsample_beat(beat_data, features, downsampling_factor): |
| """ |
| Downsample a single heartbeat by taking every nth sample. |
| Uses vectorized iloc slicing instead of row-by-row iteration. |
| """ |
| if downsampling_factor <= 1: |
| return beat_data |
| try: |
| downsampled = beat_data.iloc[::downsampling_factor] |
| return downsampled if len(downsampled) > 0 else None |
| except Exception as e: |
| print(f"Error in downsampling: {e}") |
| return None |
|
|
|
|
| def segment_beats_around_r_peaks(subject_data, r_peaks, features, fs=500, target_fs=500, pre_r_ms=150, post_r_ms=250): |
| """ |
| Segment heartbeats around R-peaks with downsampling |
| """ |
| heartbeats = [] |
| total_samples = len(subject_data) |
| |
| |
| downsampling_factor = fs // target_fs |
| if downsampling_factor < 1: |
| downsampling_factor = 1 |
| |
| |
| pre_r_original = int((pre_r_ms / 1000) * fs) |
| post_r_original = int((post_r_ms / 1000) * fs) |
| |
| min_beat_len = int((pre_r_original + post_r_original) * 0.7) |
| |
| for r_peak in r_peaks: |
| start_idx = max(0, r_peak - pre_r_original) |
| end_idx = min(total_samples, r_peak + post_r_original) |
| |
| |
| if (end_idx - start_idx) >= min_beat_len: |
| beat_data_original = subject_data.iloc[start_idx:end_idx] |
| |
| |
| beat_data_downsampled = downsample_beat(beat_data_original, features, downsampling_factor) |
| |
| if beat_data_downsampled is not None: |
| heartbeats.append((beat_data_downsampled, r_peak)) |
| |
| return heartbeats |
|
|
|
|
| def local_segment_heartbeats(input_csv, output_csv=None, min_heart_rate=40, max_heart_rate=180, |
| fs=500, target_fs=500): |
| """ |
| Segment ECG data into individual heartbeats for each subject with downsampling. |
| Self-contained implementation. |
| Optimized: uses vectorized pandas concat instead of row-by-row dict assembly. |
| """ |
| print("Loading ECG data for heartbeat segmentation...") |
| df = pd.read_csv(input_csv, index_col=['subject_id', 'timestamp']) |
| features = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6'] |
| |
| |
| existing_features = [f for f in features if f in df.columns] |
| if existing_features: |
| df[existing_features] = df[existing_features].interpolate(method='linear', limit_direction='both').fillna(0.0) |
| |
| |
| downsampling_factor = fs // target_fs |
| print(f"Downsampling from {fs} Hz to {target_fs} Hz (factor: {downsampling_factor})") |
| |
| |
| beat_frames = [] |
| |
| |
| subject_ids = df.index.get_level_values('subject_id').unique() |
| |
| for subject_id in subject_ids: |
| |
| subject_data = df.xs(subject_id, level='subject_id') |
| subject_class = subject_data['class'].iloc[0] |
| |
| |
| ecg_signal = subject_data['II'].values |
| |
| |
| if len(ecg_signal) < fs * 2: |
| continue |
| |
| |
| try: |
| ecg_filtered = bandpass_filter(ecg_signal, lowcut=5, highcut=15, fs=fs, order=2) |
| except Exception: |
| ecg_filtered = ecg_signal |
| |
| |
| r_peaks = find_r_peaks(ecg_filtered, min_heart_rate=min_heart_rate, max_heart_rate=max_heart_rate, fs=fs) |
| |
| if len(r_peaks) < 2: |
| |
| try: |
| r_peaks = find_r_peaks(ecg_signal, min_heart_rate=min_heart_rate, max_heart_rate=max_heart_rate, fs=fs) |
| except Exception: |
| pass |
| |
| if len(r_peaks) < 2: |
| print(f" Warning: Only {len(r_peaks)} R-peaks found for subject {subject_id}") |
| continue |
| |
| |
| heartbeats = segment_beats_around_r_peaks(subject_data, r_peaks, features, fs=fs, target_fs=target_fs) |
| |
| |
| for i, (beat_data, r_peak_pos) in enumerate(heartbeats): |
| beat_id = f"{subject_id}_beat{i+1}" |
| n_rows = len(beat_data) |
| |
| |
| beat_frame = beat_data[existing_features].copy() |
| beat_frame = beat_frame.reset_index(drop=True) |
| beat_frame['subject_id'] = beat_id |
| beat_frame['original_subject'] = subject_id |
| beat_frame['beat_number'] = i + 1 |
| beat_frame['r_peak_position'] = int(r_peak_pos) |
| beat_frame['class'] = subject_class |
| beat_frame['timestamp'] = np.arange(n_rows) |
| |
| beat_frames.append(beat_frame) |
| |
| |
| if not beat_frames: |
| return pd.DataFrame() |
| |
| segmented_df = pd.concat(beat_frames, ignore_index=True) |
| |
| |
| if not segmented_df.empty: |
| segmented_df.set_index(['subject_id', 'timestamp'], inplace=True) |
| |
| |
| if output_csv and not segmented_df.empty: |
| os.makedirs(os.path.dirname(output_csv), exist_ok=True) |
| segmented_df.to_csv(output_csv) |
| print(f"Segmented data saved to: {output_csv}") |
| |
| print(f"\nSegmentation complete:") |
| print(f"Total heartbeats: {segmented_df.index.get_level_values('subject_id').nunique() if not segmented_df.empty else 0}") |
| |
| return segmented_df |
|
|
|
|
| def segment_uploaded_csv(input_csv_path, output_csv_path, target_fs=500): |
| """ |
| Segments a raw digitized dataset CSV into individual heartbeats. |
| Uses the local self-contained heartbeat segmentation implementation. |
| """ |
| |
| df_raw = pd.read_csv(input_csv_path) |
| if df_raw.empty: |
| raise EmptyDatasetError("The uploaded CSV file is empty.") |
| |
| |
| |
| required_cols = ['subject_id', 'timestamp', 'class'] |
| needs_formatting = not all(col in df_raw.columns for col in required_cols) |
| |
| formatted_csv_path = input_csv_path |
| if needs_formatting: |
| print("Formatting raw CSV columns to match expected segmentation schema...") |
| |
| lead_cols = ['I', 'aVR', 'V1', 'V4', 'II', 'aVL', 'V2', 'V5', 'III', 'aVF', 'V3', 'V6'] |
| existing_leads = [col for col in lead_cols if col in df_raw.columns] |
| if not existing_leads: |
| raise ValueError( |
| f"The uploaded CSV does not contain standard ECG lead columns.\n" |
| f"Expected some of: {lead_cols}" |
| ) |
| |
| |
| df_formatted = df_raw.copy() |
| if 'subject_id' not in df_formatted.columns: |
| df_formatted['subject_id'] = 'subject_1' |
| if 'class' not in df_formatted.columns: |
| |
| df_formatted['class'] = 'Pre-Procedural MI' |
| if 'timestamp' not in df_formatted.columns: |
| df_formatted['timestamp'] = range(len(df_formatted)) |
| |
| |
| df_formatted['subject_id'] = df_formatted['subject_id'].astype(str) |
| df_formatted['class'] = df_formatted['class'].astype(str) |
| |
| |
| formatted_csv_path = input_csv_path + "_formatted.csv" |
| df_formatted.to_csv(formatted_csv_path, index=False) |
| |
| |
| try: |
| segmented_df = local_segment_heartbeats( |
| formatted_csv_path, |
| output_csv=output_csv_path, |
| fs=target_fs, |
| target_fs=target_fs |
| ) |
| finally: |
| |
| if needs_formatting and os.path.exists(formatted_csv_path): |
| try: |
| os.remove(formatted_csv_path) |
| except Exception: |
| pass |
| |
| if segmented_df is None or segmented_df.empty: |
| raise ValueError( |
| "Heartbeat segmentation returned no beats. This can happen if the signal is too short or R-peaks were not detected." |
| ) |
| |
| return segmented_df |
|
|
|
|
| |
|
|
|
|
| |
| |
| |
|
|
| def _load_model_and_metadata_impl(model_key): |
| """ |
| Internal implementation: loads a pre-trained model and its metadata from |
| models/classifier_models/{model_key}. |
| """ |
| import json |
| import pickle |
|
|
| model_dir = os.path.join("models", "classifier_models", model_key) |
| metadata_path = os.path.join(model_dir, "model_metadata.json") |
|
|
| if not os.path.exists(metadata_path): |
| raise FileNotFoundError( |
| f"Pre-trained model metadata not found: {metadata_path}\n" |
| f"Please verify models/classifier_models/ directory contents." |
| ) |
|
|
| with open(metadata_path, 'r') as f: |
| metadata = json.load(f) |
|
|
| model_path = os.path.join(model_dir, metadata['model_file']) |
| if not os.path.exists(model_path): |
| raise FileNotFoundError( |
| f"Pre-trained model pickle not found: {model_path}\n" |
| f"Please verify models/classifier_models/ directory contents." |
| ) |
|
|
| print(f"Loading {metadata['model_name']} pre-trained model for {metadata['use_case']}...") |
| with open(model_path, 'rb') as f: |
| model = pickle.load(f) |
|
|
| |
| def _fix_unpickled_classes(obj, visited=None): |
| if obj is None: |
| return |
| if visited is None: |
| visited = set() |
| obj_id = id(obj) |
| if obj_id in visited: |
| return |
| visited.add(obj_id) |
| |
| if hasattr(obj, '_label_binarizer') and hasattr(obj._label_binarizer, 'classes_'): |
| if not hasattr(obj, 'classes_') or obj.classes_ is None: |
| try: |
| obj.classes_ = obj._label_binarizer.classes_ |
| except Exception: |
| pass |
| |
| if isinstance(obj, (list, tuple)): |
| for item in obj: |
| _fix_unpickled_classes(item, visited) |
| elif isinstance(obj, dict): |
| for val in obj.values(): |
| _fix_unpickled_classes(val, visited) |
| else: |
| for attr in ['estimator', 'estimator_', 'classifier', 'classifier_', |
| 'estimators', 'estimators_', 'transformers', 'transformers_', |
| 'steps', 'steps_']: |
| if hasattr(obj, attr): |
| try: |
| val = getattr(obj, attr) |
| _fix_unpickled_classes(val, visited) |
| except Exception: |
| pass |
|
|
| _fix_unpickled_classes(model) |
|
|
| return model, metadata |
|
|
|
|
| |
| try: |
| import streamlit as st |
|
|
| @st.cache_resource(show_spinner="Loading pre-trained model...") |
| def load_pretrained_model_and_metadata(model_key): |
| """Cached wrapper — model pickle is only deserialized once per model_key.""" |
| return _load_model_and_metadata_impl(model_key) |
| except Exception: |
| |
| load_pretrained_model_and_metadata = _load_model_and_metadata_impl |
|
|
|
|
| def preprocess_dataframe_for_inference(df, metadata): |
| """ |
| Preprocesses raw or segmented DataFrame into numpy3D array for the pre-trained model. |
| Reshapes each subject's signal to match the exact target length (n_timesteps), |
| applies max absolute voltage normalization per subject, and returns the 3D numpy array. |
| Optimized: single grouped normalization instead of per-column loop. |
| """ |
| features = metadata['features'] |
| n_timesteps = metadata['n_timesteps'] |
| n_features = metadata['n_features'] |
|
|
| |
| |
| df_reset = df.copy() |
| if 'subject_id' in df_reset.index.names: |
| df_reset = df_reset.reset_index() |
| elif 'subject_id' not in df_reset.columns: |
| df_reset['subject_id'] = 'subject_1' |
|
|
| if 'timestamp' in df_reset.index.names: |
| df_reset = df_reset.reset_index() |
| elif 'timestamp' not in df_reset.columns: |
| df_reset['timestamp'] = df_reset.groupby('subject_id').cumcount() |
|
|
| df_reset = df_reset.set_index(['subject_id', 'timestamp']) |
|
|
| |
| missing = [f for f in features if f not in df_reset.columns] |
| if missing: |
| |
| df_reset.columns = [c.strip() for c in df_reset.columns] |
| missing = [f for f in features if f not in df_reset.columns] |
| if missing: |
| raise ValueError(f"Missing required lead columns in input: {missing}") |
|
|
| |
| df_X = df_reset[features].copy() |
| grouped_max = df_X.abs().groupby(level='subject_id').transform('max') |
| |
| grouped_max = grouped_max.replace(0, 1.0) |
| df_X = df_X / grouped_max |
|
|
| |
| subject_ids = df_X.index.get_level_values('subject_id').unique() |
| X_list = [] |
| valid_subject_ids = [] |
| y_true_str = [] |
|
|
| for subject_id in subject_ids: |
| try: |
| subject_data = df_X.xs(subject_id, level='subject_id') |
| actual_len = len(subject_data) |
|
|
| if actual_len >= n_timesteps: |
| |
| arr = subject_data.iloc[:n_timesteps].values.T |
| else: |
| |
| arr = np.zeros((n_features, n_timesteps)) |
| arr[:, :actual_len] = subject_data.values.T |
|
|
| if arr.shape == (n_features, n_timesteps): |
| X_list.append(arr) |
| valid_subject_ids.append(subject_id) |
| |
| |
| if 'class' in df_reset.columns: |
| lbl = df_reset.loc[subject_id].iloc[0]['class'] |
| y_true_str.append(str(lbl)) |
| except Exception: |
| continue |
|
|
| if not X_list: |
| raise ValueError( |
| f"No valid subjects/heartbeats found after preprocessing. " |
| f"Ensure data contains at least one subject with some timesteps." |
| ) |
|
|
| return np.array(X_list), valid_subject_ids, y_true_str |
|
|
|
|
| def run_pretrained_inference(model, X, metadata, subject_ids): |
| """ |
| Runs pre-trained model predictions and returns a results dictionary. |
| """ |
| from joblib import parallel_backend |
|
|
| with parallel_backend('threading', n_jobs=-1): |
| y_pred = model.predict(X) |
|
|
| has_proba = False |
| y_proba = None |
| try: |
| y_proba = model.predict_proba(X) |
| has_proba = True |
| except (AttributeError, NotImplementedError): |
| pass |
|
|
| results = { |
| 'subject_id': subject_ids, |
| 'predicted_class': y_pred.tolist(), |
| } |
|
|
| if has_proba and y_proba is not None: |
| class_labels = list(model.classes_) |
| results['class_labels'] = class_labels |
| results['probabilities'] = y_proba.tolist() |
|
|
| |
| confidence = [] |
| for j in range(len(y_pred)): |
| try: |
| pred_idx = class_labels.index(y_pred[j]) |
| confidence.append(float(y_proba[j, pred_idx])) |
| except ValueError: |
| confidence.append(1.0) |
| results['confidence'] = confidence |
|
|
| return results |
|
|
|
|
| def calculate_evaluation_metrics(y_true_str, y_pred_str, class_labels, positive_class): |
| """ |
| Calculates accuracy, precision, recall, f1, sensitivity, specificity, and confusion matrix. |
| Maps ground-truth and prediction string labels to binary (0/1). |
| """ |
| |
| pos_str = str(positive_class).strip().lower() |
| |
| |
| y_true_num = [1 if pos_str in str(y).strip().lower() else 0 for y in y_true_str] |
| y_pred_num = [1 if pos_str in str(y).strip().lower() else 0 for y in y_pred_str] |
|
|
| |
| if len(np.unique(y_true_num)) <= 1 and len(np.unique(y_pred_num)) <= 1: |
| |
| accuracy = float(accuracy_score(y_true_num, y_pred_num)) |
| return { |
| "Accuracy": accuracy, |
| "Precision": 1.0 if accuracy == 1.0 else 0.0, |
| "Recall": 1.0 if accuracy == 1.0 else 0.0, |
| "F1": 1.0 if accuracy == 1.0 else 0.0, |
| "Sensitivity": 1.0 if accuracy == 1.0 else 0.0, |
| "Specificity": 1.0 if accuracy == 1.0 else 0.0, |
| "Confusion Matrix": confusion_matrix(y_true_num, y_pred_num, labels=[0, 1]).tolist(), |
| "TN": int(accuracy * len(y_true_num)) if y_true_num[0] == 0 else 0, |
| "FP": 0, |
| "FN": 0, |
| "TP": int(accuracy * len(y_true_num)) if y_true_num[0] == 1 else 0 |
| } |
|
|
| accuracy = float(accuracy_score(y_true_num, y_pred_num)) |
| precision = float(precision_score(y_true_num, y_pred_num, zero_division=0)) |
| recall = float(recall_score(y_true_num, y_pred_num, zero_division=0)) |
| f1 = float(f1_score(y_true_num, y_pred_num, zero_division=0)) |
|
|
| cm = confusion_matrix(y_true_num, y_pred_num, labels=[0, 1]) |
| tn, fp, fn, tp = cm.flatten() |
| |
| sensitivity = float(tp / (tp + fn) if (tp + fn) > 0 else 0) |
| specificity = float(tn / (tn + fp) if (tn + fp) > 0 else 0) |
|
|
| return { |
| "Accuracy": accuracy, |
| "Precision": precision, |
| "Recall": recall, |
| "F1": f1, |
| "Sensitivity": sensitivity, |
| "Specificity": specificity, |
| "Confusion Matrix": cm.tolist(), |
| "TN": int(tn), "FP": int(fp), "FN": int(fn), "TP": int(tp) |
| } |
|
|