Detection of Chagas Disease from the ECG: PhysioNet Challenge 2025¶

Chagas Disease Overview¶

  • Cause & Transmission: Caused by Trypanosoma cruzi, primarily spread by triatomine insects, but also through blood transfusions, organ transplants, and congenital transmission.

  • Disease Progression: Two phases—acute (mild/nonspecific symptoms) and chronic (asymptomatic in most, but 30-40% develop severe complications, mainly cardiac). Early antiparasitic treatment is effective, but loses efficacy as the infection progresses.

  • Historical Background: Named after Carlos Chagas, who discovered it in 1909. Chronic complications, especially cardiac and digestive issues, typically appear 10 to 30 years post-infection.

  • Diagnosis: Detected via blood tests (parasite detection or serological tests for antibodies).

  • Cardiac Impact & ECG Role: Chagas cardiomyopathy can cause heart damage, arrhythmias, conduction disorders, and dilated cardiomyopathy. Severe cases may lead to sudden cardiac death, though the indeterminate chronic form lacks ECG abnormalities, making early detection challenging.

About the Challenge¶

  • The George B. Moody PhysioNet Challenge 2025 [1] is a prestigious annual competition focused on advancing physiological and clinical problem-solving.

  • This year’s challenge targets Chagas disease, a serious cardiovascular threat prevalent in Central and South America.

  • Participants must develop open-source algorithms to detect Chagas disease using standard 12-lead ECGs.

  • The goal is to improve screening efficiency, identifying potential cases for confirmatory serological testing while optimizing healthcare resources.

  • Inspired by this, our objective is to build a model that can accurately detect Chagas disease using only a short (~10-second) 12-lead ECG recording.

Dataset¶

  • The 2025 PhysioNet Challenge uses the CODE-15% Database [2], which contains 300,000+ 12-lead ECG recordings collected in Brazil (2010–2016).

  • Binary Chagas disease labels are included, based on self-reported diagnoses and potentially supplemented by questionnaire responses.

  • Each ECG record has a WFDB header file with metadata such as sampling frequency, signal parameters, demographics (if available), and Chagas labels.

  • Example: Record 113 contains 12 channels, 400 Hz sampling frequency, and 4096 samples per channel.

In [6]:
wfdb.plot_wfdb(record=wfdb.rdrecord(record, physical=False), figsize=(20, 10), time_units='samples')
No description has been provided for this image

ECG features¶

No description has been provided for this image

Feature Extraction & Data Processing¶

  • ECG Feature Extraction: Relevant features are derived from fiducial points (P, Q, R, S, T waves) and segment durations, enabling numerical representation of the ECG signal for machine learning classification.

  • Chagas Disease Detection: Research indicates that ECG pattern durations are strongly associated with Chagas disease, making them key diagnostic features.

  • Implementation: Feature extraction is handled in preprocessing.py using NeuroKit2 ensuring correct wave order. The extracted features are then merged with the metadata (age, gender, Chagas label) and aggregated in a single csv file.

  • Dataset Challenges & Solutions: The dataset is large and imbalanced, making full processing computationally expensive. To mitigate this:

    • A partitioned subset of the data is used for efficient experimentation.
    • Downsampling is applied to balance seropositive and seronegative cases, ensuring unbiased model training.

Exploratory Data Analysis¶

In [10]:
_, axes = plt.subplots(nrows=8, ncols=6, figsize=(20, 10))
for ax, cname in zip(axes.ravel(), num_cols):
    sn.histplot(data=df, x=cname, bins=50, stat="count", alpha=1, kde=False, multiple='dodge', ax=ax, hue='chagas', legend=False)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [12]:
t_test_df.sort_values(by='p_value')
Out[12]:
column t_stat p_value is_significant
6 PR_segment_mean 20.819895 1.633643e-94 True
7 PR_segment_max 20.634896 4.930207e-93 True
5 PR_interval_max 18.113069 2.226532e-72 True
21 HRV_MaxNN 15.886669 2.847833e-56 True
3 PR_interval_mean 15.732206 3.498921e-55 True
0 P_wave_duration_mean -15.235253 5.901368e-52 True
15 ST_segment_max 14.821371 2.896867e-49 True
17 HRV_SDRMSSD -14.733790 9.797298e-49 True
19 HRV_Prc80NN 13.986132 4.023160e-44 True
1 P_wave_duration_min -13.572657 1.116486e-41 True
13 QT_interval_max 12.460313 2.006407e-35 True
22 HRV_HTI -11.860685 2.772502e-32 True
16 HRV_MedianNN 10.322695 6.949631e-25 True
10 QRS_duration_max 9.578422 1.166052e-21 True
8 QRS_duration_mean 9.532662 1.821155e-21 True
2 P_wave_duration_max -8.089928 6.502718e-16 True
18 HRV_Prc20NN 6.024534 1.742408e-09 True
4 PR_interval_min 5.904194 3.640283e-09 True
14 ST_segment_mean 5.721862 1.079176e-08 True
12 QT_interval_min -4.514126 6.416034e-06 True
11 QT_interval_mean 3.694889 2.209494e-04 True
9 QRS_duration_min 3.558609 3.743054e-04 True
20 HRV_MinNN 2.215779 2.672431e-02 True
In [13]:
_, axes = plt.subplots(nrows=1, ncols=2)
for i, cname in enumerate(cat_cols + ['chagas']):
    df[cname].value_counts().plot(kind='bar', ax=axes[i], figsize=figsize, title=cname, ylabel='Count', xlabel='', sharey=True)

plt.tight_layout()
No description has been provided for this image
In [15]:
df.groupby('is_male')['chagas'].mean().plot.bar(title='Mean of the target variable conditioned on the gender')
plt.tight_layout()
No description has been provided for this image
In [16]:
_, axes = plt.subplots(nrows=8, ncols=6, figsize=(20,10))
for ax, cname in zip(axes.ravel(), num_cols):
    bin_size = (df[cname].max() - df[cname].min()) / 20
    df['chagas'].groupby(df[cname] // bin_size).mean().plot.bar(ax=ax)
plt.tight_layout()
No description has been provided for this image
In [17]:
plt.figure(figsize=(27,12))
sn.heatmap(df.corr(method='pearson'), annot=True, vmin=-1, vmax=1, cmap='RdBu')
plt.tight_layout()
No description has been provided for this image

A more in-depth analysis of the data¶

From the literature we see that:

  • Strong Link Between Chagas Disease & Abnormal ECG [3]90529-x)

    • Seropositive individuals are 4.3x more likely to show abnormal ECG patterns than seronegative ones.
    • Higher prevalence observed in men.
  • Age-Dependent Increase in ECG Abnormalities [3]90529-x)

    • 10-14, ECG abnormalities are similar between the two groups.
    • 25-44, abnormal ECG occurrence is 9.3x higher in seropositive individuals.
    • 44-64, the ratio decreases.
    • 64+, roughly equal proportion between the groups.

How can we recognise an abnormal ecg?

Ventricular Rate¶

Claim:

  • Seropositive persons presents significantly lower ventricular rates [4].

Ventricular Rate: The ventricular rate represents the number of ventricular contractions per minute (beats per minute, BPM) and indicates the heart's electrical pacing.

  • A known cause of low Ventricular Rate is the presence of RBBB, that is know to be a symptom of the chagas disease [4]

HRV_MeanNN: MeanNN is the average NN interval (time between normal heartbeats) and is inversely related to ventricular rate.

  • Higher MeanNN values indicate a lower ventricular rate
In [27]:
df['age_group'] = pd.qcut(df['age'], q=4)
sn.boxplot(x='age_group', y='HRV_MeanNN', hue='chagas', data=df, showfliers=False, showmeans=True)
plt.tight_layout()
plt.show()
No description has been provided for this image

Heart rate variability (HRV_HTI)¶

Claim:

  • Arrhytmias occur 2 times more frequently in seropositive than among seronegative invidivuals [3]90529-x).

HRV_HTI: Measures heart rate regularity, calculated as the total number of RR intervals divided by the height of the RR interval histogram.

  • Higher HRV_HTI values indicate more regular RR intervals, while lower values suggest increased irregularity.
In [28]:
sn.boxplot(x='age_group', y='HRV_HTI', hue='chagas', data=df, showfliers=False, showmeans=True)
plt.tight_layout()
plt.show()
No description has been provided for this image

ST-T wave abnormalities (ST_slope)¶

Claim:

  • Chagas cardiomyopathy presents nonspecific ST- and T-wave abnormalities [7].
  • Common abnormalities are right bundle branch block (RBBB), left anterior fascicular block, and diffuse ST-T changes.

ST_slope: Measures the slope between the S and T wave.

  • In RBBB, this manifests as ST depression and/or T-wave inversion.
In [29]:
sn.boxplot(x='age_group', y='ST_slope_mean', hue='chagas', data=df, showfliers=False, showmeans=True)
plt.tight_layout()
plt.show()
No description has been provided for this image

QRS Duration¶

Claim:

  • Seropositive people had substantially longer QRS intervals than seronegative persons [3]90529-x) [4].
  • 83.5% of individuals with QRS intervals above 0.09s were seropositive, while only 4.4% of seronegative individuals had QRS intervals above this threshold. [3]90529-x)

QRS Duration: is the time between the start of the Q wave and the end of the S wave, representing how long it takes for the ventricles to fully depolarize.

  • Prolonged QRS duration may indicate conduction abnormalities linked to Chagas disease.
In [30]:
sn.boxplot(x='age_group', y='QRS_duration_mean', hue='chagas', data=df, showfliers=False, showmeans=True)
plt.tight_layout()
plt.show()
No description has been provided for this image

Standardization of the data¶

The StandardScaler is applied to the numerical columns to have a better distribution of the data by subtracting the mean and dividing by the standard deviation.

We first dropped the age_group column since it was not used inside our models and then applied the StandardScaler to the numerical columns.

The splitting between training and testing sets is done by stratifying the target variable and shuffling the data. For the test set, we used 20% of the data.

In [190]:
X, y = df.drop(columns=['chagas', 'age_group'], axis=1), df['chagas']

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=SEED, stratify=y, shuffle=True)
scaler = StandardScaler()
X_train.iloc[:, :-1] = scaler.fit_transform(X_train.iloc[:, :-1])
X_test.iloc[:, :-1] = scaler.transform(X_test.iloc[:, :-1])
In [191]:
results_dict = {}

def print_results(model, y_test, y_pred, y_pred_proba):
    acc = accuracy_score(y_test, y_pred)
    auc = roc_auc_score(y_test, y_pred_proba)
    f1 = f1_score(y_test, y_pred)
    f_beta = fbeta_score(y_test, y_pred, beta=0.5)
    print(f'Accuracy: {acc:.4f}')
    print(f'ROC-AUC: {auc:.4f}')
    print(f'F1-score: {f1:.4f}')
    print("F-beta score", f_beta)
    cm = confusion_matrix(y_test, y_pred, labels=[0, 1], normalize="all")
    disp = ConfusionMatrixDisplay(confusion_matrix=cm,
                              display_labels=['Seronegative', 'Seropositive'])
    disp.plot()
    plt.show()
    
    return {'acc': acc, 'auc': auc, 'f1': f1, 'f_beta': f_beta}

Logistic Regression¶

Key Features¶

  • L1 Regularization: Promotes sparsity, identifies less useful features.
  • Solver: SAGA chosen for efficiency with L1 and scalability for large datasets.

$$\text{argmin}_{\theta}\ H\biggl(y, f(x,\theta)\biggr) + \frac{1}{C} ||\theta||_1$$

Hyperparameter Tuning¶

  • Parameter C: Controls regularization strength.
  • GridSearchCV: Tuned C with ROC-AUC as performance metric.

Insights on Tuning C¶

  • Bias-Variance Tradeoff:
    • Small C → Strong regularization → Risk of underfitting.
    • Large C → Weak regularization → Risk of overfitting.
  • Data-Driven Results: Optimal C balances generalization and training performance.
  • Benefits of L1: Sparsity, feature selection, improved interpretability.
In [108]:
lr_score_cv, lr_score_test = gscv.best_score_, roc_auc_score(y_test, lr.predict_proba(X_test)[:, 1])
print(f'AUC score for C={lr_params["C"]:.2f}: {lr_score_cv:.2f} (cross-validation), {lr_score_test:.2f} (test)')
AUC score for C=10.00: 0.75 (cross-validation), 0.75 (test)
In [109]:
results_dict['Logistic Regression'] = print_results(lr, y_test, lr.predict(X_test), lr.predict_proba(X_test)[:, 1])
Accuracy: 0.6828
ROC-AUC: 0.7457
F1-score: 0.6722
F-beta score 0.6861796484437994
No description has been provided for this image
In [110]:
pd.DataFrame({'variable': X.columns, 'coeff': np.abs(lr.coef_[0]), 'sign': ['neg' if lr.coef_[0][i] < 0 else 'pos' for i in range(len(X.columns))]}).sort_values(by='coeff', ascending=False).head(20)
Out[110]:
variable coeff sign
32 HRV_CVNN 0.750192 pos
21 ST_segment_std 0.749812 pos
44 HRV_MaxNN 0.628669 neg
1 P_wave_duration_std 0.615769 pos
22 ST_segment_min 0.547871 pos
3 P_wave_duration_max 0.530417 neg
2 P_wave_duration_min 0.487001 pos
39 HRV_Prc20NN 0.469673 pos
12 QRS_duration_mean 0.452798 pos
20 ST_segment_mean 0.391311 pos
33 HRV_CVSD 0.375549 pos
29 HRV_SDNN 0.357011 pos
16 QT_interval_mean 0.349609 neg
42 HRV_pNN20 0.344252 neg
48 is_male 0.335431 neg
11 PR_segment_max 0.311716 neg
24 ST_slope_mean 0.307068 pos
31 HRV_SDSD 0.277912 neg
43 HRV_MinNN 0.269241 pos
9 PR_segment_std 0.264337 pos
In [111]:
lr_coefs = pd.Series(index=X.columns, data=lr.coef_[0])
lr_coefs.plot(kind='bar', figsize=figsize)
plt.show()
No description has been provided for this image

XGBoost¶

Key Features¶

  • Algorithm: XGBoost with hist tree method for computational efficiency on large datasets.
  • Feature Importance:
    • Total Gain Importance: Considers both frequency and magnitude of feature contributions across splits.

Benefits¶

  • Performance: Efficient and effective for large datasets.
  • Explainability: Enhances interpretability for AI through detailed feature contribution analysis.
In [114]:
base_est = xgboost.XGBClassifier(tree_method='hist', importance_type='total_gain', n_jobs=-1)
param_grid={'max_depth': [2, 3, 4], 'n_estimators': list(range(20, 41, 5)), 'reg_lambda': np.linspace(0, 500, 6)}
gscv = GridSearchCV(base_est, param_grid=param_grid, n_jobs=-1)
gscv.fit(X_train, y_train)
xbm, xbm_params = gscv.best_estimator_, gscv.best_params_
In [115]:
xbm_score_test = roc_auc_score(y_test, xbm.predict_proba(X_test)[:, 1])
print(f'AUC score for max_depth={xbm_params["max_depth"]}, n_estimators={xbm_params["n_estimators"]}, reg_lambda={xbm_params["reg_lambda"]:.2f}: {xbm_score_test:.2f} (test)')
AUC score for max_depth=4, n_estimators=40, reg_lambda=100.00: 0.78 (test)
In [116]:
results_dict['XGBoost'] = print_results(xbm, y_test, y_pred=xbm.predict(X_test), y_pred_proba=xbm.predict_proba(X_test)[:, 1])
Accuracy: 0.7050
ROC-AUC: 0.7813
F1-score: 0.7145
F-beta score 0.7011177239076789
No description has been provided for this image
In [117]:
xbm_imp = pd.Series(index=X.columns, data=xbm.feature_importances_)
xbm_imp.plot(kind='bar', figsize=figsize)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [118]:
_, axes = plt.subplots(nrows=2, ncols=3, figsize=(20, 10))
for ax, imp_type in zip(axes.ravel(), ['weight', 'gain', 'cover', 'total_gain', 'total_cover']):
    pd.Series(xbm.get_booster().get_score(importance_type=imp_type)).plot.bar(ax=ax, title=imp_type)
plt.tight_layout()
No description has been provided for this image

Permutation Importance¶

Key Insights¶

  • Data Dependence: Feature importance varies with the dataset.
    • Misconception: Important features are universally best regardless of the dataset.

Methodology¶

  • Definition: Model-agnostic approach.
    • Randomly shuffle feature values.
    • Measure impact on model performance.
  • Purpose: Assess feature importance independently of internal model metrics.

Application¶

  • Datasets: Computed on training and testing sets to check consistency.
  • Model: Used XGBoost due to its superior performance.

Benefits¶

  • Robust Evaluation: Validates feature importance across models and datasets.
  • Model Independence: Avoids reliance on specific model-based metrics.
In [178]:
util.plot_bars(xbm_p_imp, figsize=figsize, std=r_train.importances_std, title='Permutation Feature Importance (train)', rotation=90)
No description has been provided for this image
In [180]:
util.plot_bars(xbm_p_imp, figsize=figsize, std=r_test.importances_std, title='Permutation Feature Importance (test)', rotation=90)
No description has been provided for this image

SHAP Values¶

Key Features¶

  • Purpose: Explain predictions of the XGBoost model with high accuracy.
  • Origin: Based on Shapley values from game theory.
    • Assigns importance to features by analyzing their marginal contributions across all feature combinations.

Methodology¶

  • Explainer Definition: Created using a sample of 100 training points.
  • Computation: SHAP values calculated for the test set.
    • Larger samples improve accuracy but increase computational time (~1 hour for XGBoost).

Comparison¶

  • Versus Permutation Importance:
    • SHAP is more accurate.
    • Provides detailed insights into individual prediction contributions.

Benefits¶

  • Explainability: Highlights the contribution of each feature to model predictions.
  • Robustness: Suitable for high-performing models like XGBoost.
In [61]:
f = lambda x: xbm.predict_proba(x)[:,1]
explainer = shap.KernelExplainer(f, shap.sample(X_train, 100), link='logit', seed=SEED)
In [80]:
shap.summary_plot(shap_values, X_test, plot_type='bar', max_display=20)
No description has been provided for this image
In [81]:
shap.waterfall_plot(shap.Explanation(values=shap_values[0], base_values=explainer.expected_value, data=X_test.iloc[0], feature_names=X.columns.tolist()), max_display=20)
No description has been provided for this image
In [82]:
shap.waterfall_plot(shap.Explanation(values=shap_values[1], base_values=explainer.expected_value, data=X_test.iloc[1], feature_names=X.columns.tolist()), max_display=20)
No description has been provided for this image
In [83]:
shap.plots.beeswarm(shap_values, max_display=20)
No description has been provided for this image

Feature selection¶

In [67]:
shap_mean = np.abs(shap_values.values).mean(axis=0)
shap_mean = pd.Series(index=X.columns, data=shap_mean)
shap_mean.sort_values(ascending=False, inplace=True)
best_20_shap = shap_mean.head(20)
In [68]:
X_train_fs = X_train[best_20_shap.index]
X_test_fs = X_test[best_20_shap.index]

base_est = xgboost.XGBClassifier(tree_method='hist', importance_type='total_gain')
param_grid={'max_depth': [2, 3, 4], 'n_estimators': list(range(20, 41, 5)), 'reg_lambda': np.linspace(0, 500, 6)}
gscv_fs = GridSearchCV(base_est, param_grid=param_grid, n_jobs=-1)
gscv_fs.fit(X_train_fs, y_train)
xbm_fs, xbm_params_fs = gscv_fs.best_estimator_, gscv_fs.best_params_
y_pred = xbm_fs.predict_proba(X_test_fs)[:, 1]
xbm_score_test_fs = roc_auc_score(y_test, y_pred)

print(f'AUC score for max_depth={xbm_params_fs["max_depth"]}, n_estimators={xbm_params_fs["n_estimators"]}, reg_lambda={xbm_params_fs["reg_lambda"]:.2f}: {xbm_score_test_fs:.4f} (test)')
AUC score for max_depth=4, n_estimators=40, reg_lambda=100.00: 0.7804 (test)
In [69]:
results_dict['XGBoost-SHAP'] = print_results(xbm_fs, y_test, xbm_fs.predict(X_test_fs), xbm_fs.predict_proba(X_test_fs)[:, 1])
Accuracy: 0.7023
ROC-AUC: 0.7804
F1-score: 0.7103
F-beta score 0.6992238980817104
No description has been provided for this image

BorutaPy¶

  • Generate and Evaluate
    1. extend the dataset by adding and shuffling copies of all variables
    2. run a random forest to compute Z-scores for all features
  • Compare Scores
    1. finds maximum Z-score among shadow attributes (MZSA)
    2. assign a “hit” to attributes that exceeds this value
  • Statistical Testing & Iteration
    1. For undetermined attributes, perform a two-sided test against the MZSA to label them as important or unimportant
    2. remove shadow attributes
    3. repeat until all attributes are classified or limit is reached
In [70]:
bfs = BorutaPy(xbm, n_estimators='auto', max_iter=100, verbose=0, random_state=SEED, early_stopping=True)
bfs.fit(X=X, y=y)
Out[70]:
BorutaPy(early_stopping=True,
         estimator=XGBClassifier(base_score=None, booster=None, callbacks=None,
                                 colsample_bylevel=None, colsample_bynode=None,
                                 colsample_bytree=None, device=None,
                                 early_stopping_rounds=None,
                                 enable_categorical=False, eval_metric=None,
                                 feature_types=None, gamma=None,
                                 grow_policy=None, importance_type='total_gain',
                                 interaction_constraints=None,
                                 learning_rate=None, max_bin=None,
                                 max_cat_threshold=None, max_cat_to_onehot=None,
                                 max_delta_step=None, max_depth=4,
                                 max_leaves=None, min_child_weight=None,
                                 missing=nan, monotone_constraints=None,
                                 multi_strategy=None, n_estimators=173,
                                 n_jobs=-1, num_parallel_tree=None,
                                 random_state=381215112, ...),
         n_estimators='auto',
         random_state=RandomState(MT19937) at 0x7682C5A9F340)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
BorutaPy(early_stopping=True,
         estimator=XGBClassifier(base_score=None, booster=None, callbacks=None,
                                 colsample_bylevel=None, colsample_bynode=None,
                                 colsample_bytree=None, device=None,
                                 early_stopping_rounds=None,
                                 enable_categorical=False, eval_metric=None,
                                 feature_types=None, gamma=None,
                                 grow_policy=None, importance_type='total_gain',
                                 interaction_constraints=None,
                                 learning_rate=None, max_bin=None,
                                 max_cat_threshold=None, max_cat_to_onehot=None,
                                 max_delta_step=None, max_depth=4,
                                 max_leaves=None, min_child_weight=None,
                                 missing=nan, monotone_constraints=None,
                                 multi_strategy=None, n_estimators=173,
                                 n_jobs=-1, num_parallel_tree=None,
                                 random_state=381215112, ...),
         n_estimators='auto',
         random_state=RandomState(MT19937) at 0x7682C5A9F340)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric=None, feature_types=None,
              gamma=None, grow_policy=None, importance_type='total_gain',
              interaction_constraints=None, learning_rate=None, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=4, max_leaves=None,
              min_child_weight=None, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=173, n_jobs=-1,
              num_parallel_tree=None, random_state=583222411, ...)
XGBClassifier(base_score=None, booster=None, callbacks=None,
              colsample_bylevel=None, colsample_bynode=None,
              colsample_bytree=None, device=None, early_stopping_rounds=None,
              enable_categorical=False, eval_metric=None, feature_types=None,
              gamma=None, grow_policy=None, importance_type='total_gain',
              interaction_constraints=None, learning_rate=None, max_bin=None,
              max_cat_threshold=None, max_cat_to_onehot=None,
              max_delta_step=None, max_depth=4, max_leaves=None,
              min_child_weight=None, missing=nan, monotone_constraints=None,
              multi_strategy=None, n_estimators=173, n_jobs=-1,
              num_parallel_tree=None, random_state=1584956563, ...)
In [72]:
pd.DataFrame(data=bfs.ranking_, index=X.columns).rename(columns={0: 'Ranking'}).sort_values(by='Ranking', ascending=True)
Out[72]:
Ranking
P_wave_duration_mean 1
ST_segment_mean 1
ST_segment_std 1
ST_segment_min 1
ST_segment_max 1
age 1
ST_slope_min 1
HRV_MeanNN 1
HRV_SDSD 1
HRV_CVNN 1
HRV_CVSD 1
HRV_MCVNN 1
HRV_SDRMSSD 1
HRV_Prc20NN 1
HRV_HTI 1
HRV_TINN 1
QT_interval_max 1
QT_interval_min 1
ST_slope_mean 1
QRS_duration_min 1
QRS_duration_mean 1
PR_segment_mean 1
QT_interval_mean 2
ST_slope_max 2
P_wave_duration_max 3
P_wave_duration_std 4
HRV_Prc80NN 6
HRV_MedianNN 6
is_male 6
PR_segment_std 8
ST_slope_std 9
HRV_pNN20 9
PR_segment_min 11
QT_interval_std 12
HRV_MaxNN 13
PR_interval_min 14
HRV_MinNN 15
QRS_duration_std 16
HRV_IQRNN 16
PR_interval_mean 18
PR_segment_max 18
P_wave_duration_min 20
QRS_duration_max 21
PR_interval_max 22
PR_interval_std 23
HRV_SDNN 24
HRV_pNN50 25
HRV_RMSSD 26
HRV_MadNN 27
In [73]:
X_train_boruta = X_train[X.columns[bfs.ranking_ == 1].values]
X_test_boruta = X_test[X.columns[bfs.ranking_ == 1].values]

base_est = xgboost.XGBClassifier(tree_method='hist', importance_type='total_gain')
param_grid={'max_depth': [2, 3, 4], 'n_estimators': list(range(20, 41, 5)), 'reg_lambda': np.linspace(0, 500, 6)}
gscv_boruta = GridSearchCV(base_est, param_grid=param_grid, n_jobs=-1)
gscv_boruta.fit(X_train_boruta, y_train)
xbm_boruta, xbm_params_boruta = gscv_boruta.best_estimator_, gscv_boruta.best_params_
y_pred = xbm_boruta.predict_proba(X_test_boruta)[:, 1]
xbm_score_test_boruta = roc_auc_score(y_test, y_pred)

print(f'AUC score for max_depth={xbm_params_boruta["max_depth"]}, n_estimators={xbm_params_boruta["n_estimators"]}, reg_lambda={xbm_params_boruta["reg_lambda"]:.2f}: {xbm_score_test_boruta:.4f} (test)')
AUC score for max_depth=4, n_estimators=35, reg_lambda=100.00: 0.7800 (test)
In [74]:
results_dict['XGBoost-Boruta'] = print_results(xbm_boruta, y_test, xbm_boruta.predict(X_test_boruta), xbm_boruta.predict_proba(X_test_boruta)[:, 1])
Accuracy: 0.7081
ROC-AUC: 0.7800
F1-score: 0.7160
F-beta score 0.70466852041563
No description has been provided for this image
In [75]:
shap_20_feature_set = set(best_20_shap.index)
boruta_feature_set = set(X.columns[bfs.ranking_ == 1].values)
intersection = shap_20_feature_set.intersection(boruta_feature_set)

X_train_intersection = X_train[list(intersection)]
X_test_intersection = X_test[list(intersection)]

base_est = xgboost.XGBClassifier(tree_method='hist', importance_type='total_gain')
param_grid={'max_depth': [2, 3, 4], 'n_estimators': list(range(20, 41, 5)), 'reg_lambda': np.linspace(0, 500, 6)}
gscv_intersection = GridSearchCV(base_est, param_grid=param_grid, n_jobs=-1)
gscv_intersection.fit(X_train_intersection, y_train)
xbm_intersection, xbm_params_intersection = gscv_intersection.best_estimator_, gscv_intersection.best_params_
y_pred = xbm_intersection.predict_proba(X_test_intersection)[:, 1]
xbm_score_test_intersection = roc_auc_score(y_test, y_pred)

print(f'AUC score for max_depth={xbm_params_intersection["max_depth"]}, n_estimators={xbm_params_intersection["n_estimators"]}, reg_lambda={xbm_params_intersection["reg_lambda"]:.2f}: {xbm_score_test_intersection:.4f} (test)')
AUC score for max_depth=3, n_estimators=40, reg_lambda=100.00: 0.7741 (test)
In [76]:
results_dict['XGBoost-Intersection'] = print_results(xbm_intersection, y_test, xbm_intersection.predict(X_test_intersection), xbm_intersection.predict_proba(X_test_intersection)[:, 1])
Accuracy: 0.6943
ROC-AUC: 0.7741
F1-score: 0.7024
F-beta score 0.6915750915750916
No description has been provided for this image
In [84]:
pd.DataFrame(results_dict).T.sort_values(by='auc', ascending=False)
Out[84]:
acc auc f1 f_beta
XGBoost 0.705006 0.781293 0.714497 0.701118
XGBoost-SHAP 0.702331 0.780380 0.710301 0.699224
XGBoost-Boruta 0.708063 0.780037 0.715985 0.704669
XGBoost-Intersection 0.694306 0.774087 0.702381 0.691575
KNN 0.690103 0.753267 0.680331 0.693618
Logistic Regression 0.682843 0.745708 0.672196 0.686180

Deep Learning¶

Why¶

  • Goal: Improve ROC AUC by capturing complex ECG patterns.

  • Model Choices: CNNs & RNNs handle spatial & temporal dependencies better than FNNs.

  • Hybrid Approach: 1D CNNs extract features, RNNs model time dependencies (SOTA for ECG tasks).

  • Better Performance: Despite complexity, deep learning can outperform traditional ML.

Explainability¶

  • Deep Learning = Black Box? Yes, but SHAP, and Grad-CAM help interpret predictions.
  • SHAP Values: Show feature importance in model decisions.
  • Grad-CAM: Highlights important ECG leads, testing if lead II is most relevant.
  • Approach: Use Grad-CAM for deeper insights.
In [140]:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=SEED, stratify=y)

X_train, X_val, y_train, y_val = train_test_split(X_train, y_train, test_size=0.10, random_state=SEED, stratify=y_train)

print("Train", len(X_train))
print("Val", len(X_val))
print("Test", len(X_test))
Train 9425
Val 1048
Test 2619
In [142]:
BATCH_SIZE = 32

train_dataset = ECGDataset(X_train, y_train)
val_dataset = ECGDataset(X_val, y_val)
test_dataset = ECGDataset(X_test, y_test)
train_dloader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, pin_memory=True)
val_dloader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, pin_memory=True)
test_dloader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)

Model¶

  • 1D Conv SE-ResNet18: A ResNet18-based CNN with squeeze-and-excitation blocks to capture spatial dependencies across ECG leads.

  • Demographic Integration: A Fully Connected Network (FCN) processes demographic data, concatenated with CNN outputs before the final prediction.

  • Advanced Methods (for future work): Attention mechanisms or FiLM layers could modulate CNN features based on demographics for better adaptation.

  • Why Concatenation? Simpler and sufficient for our current dataset, but future scaling may benefit from more complex approaches.

In [146]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Initialize model
resnet = ResNet(BasicBlock, **resnet_params)
resnet.to(device)

# Positive class weight
pos_weight = (y==0.).sum()/y.sum()

# Define loss function, optimizer, and scheduler
criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor(pos_weight))
optimizer = torch.optim.AdamW(resnet.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, eta_min=1e-5, T_max=74*15)

# Define epochs and patience
epochs = 30
patience = 10
In [153]:
results_dict['SE-ResNet18'] = print_results(resnet, y_test, y_pred_resnet, y_pred_proba_resnet)
Accuracy: 0.7511
ROC-AUC: 0.8439
F1-score: 0.7574
F-beta score 0.7461155086484902
No description has been provided for this image

Grad-CAM¶

In [137]:
example = next(iter(gradcam_loader))
signal = example[0].squeeze(0).transpose(0, 1).cpu().numpy()
leads = ['I', 'II', 'III', 'AVL', 'AVF', 'AVR', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']
layers = ['layer1.0.conv1', 'conv1']
for layer in layers:
    heatmap = get_grad_cam(net=model, test_example=example, target_layer=layer)
    visualize_signal(signal, heatmap, leads, sampling_freq=400, title=f"Grad-CAM for layer {layer}")
No description has been provided for this image
No description has been provided for this image

Transformers¶

Model Architecture¶

Why using a Transformer¶

  • Captures Complex ECG Relationships: Utilizes self-attention to model both local and long-range dependencies in ECG data.
  • Proven Success in Various Domains: A robust and scalable framework for developing accurate diagnostic models.

Starting Point: Annamalai Natarajan et al. [6]:

  • used a transformer model to detect cardiac abnormalities from 12-lead ECG recordings.
  • demonstrated success winning the first place in the 2020 PhysioNet challenge

Feature extraction¶

  • Inspired by Annamalai Natarajan et al [6] ,
  • Sequential Encoder Architecture:
    • Composed of multiple 1D convolutional layers for feature extraction.
    • Designed to capture local temporal patterns using filters with varied kernel sizes and strides.
    • Batch normalization & ReLU activations ensure stable training and non-linearity [6].
    • Downsamples ECG signals by a factor of 20, reducing computational complexity.

Positional Encoding¶

Problem:

  • The ECG capture the temporal dynamics of the heart's electrical activity
  • The transformer does not inherently consider sequence order.

Solution: Positional Encoding

  • Adds order and relative position information to the model.
  • Helps capture temporal dynamics in ECG signals.
  • Enables the model to recognize sequential patterns in heart activity.
In [52]:
#Parameters
model_parameters = {
    "d_model": 256,
    "nhead" :8,
    "d_ff" : 2048,
    "num_layers" : 8,
    "dropout_rate" : 0.2,
    "deepfeat_sz" : 64
}

learning_rate = 1e-4
weight_decay = 1e-3
In [53]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

ctn = CTN(**model_parameters)
ctn.to(device)

# Positive class weight
pos_weight = (y==0.).sum()/y.sum()

# Define loss function, optimizer, and scheduler
criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor(pos_weight))
optimizer = torch.optim.AdamW(ctn.parameters(), lr=learning_rate, weight_decay=weight_decay)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, eta_min=1e-6, T_max=295*15)
In [57]:
y_pred_ctn, y_pred_proba_ctn = evaluate_model(ctn, test_dloader, device, with_demographic=False, with_handcrafted=False)
results_dict['CTN'] = print_results(ctn, y_test, y_pred_ctn, y_pred_proba_ctn)
Accuracy: 0.7331
ROC-AUC: 0.8234
F1-score: 0.7127
F-beta score 0.7471561530506722
No description has been provided for this image

Transformer model with age and sex¶

Problem:

  • The ECG dosn't give information about Age and Sex of a patient
    • Age: Cardiac abnormalities develop over time
    • Sex: Differences in heart rate variability and ECG patterns enhance classification.

Solution:

  • Augment the transformer's output with demographic (age & sex) features
  • Demographic vector is concatenated with the feature representation

We define the training procedure as before

In [63]:
y_pred_ctn_demographic, y_pred_proba_ctn_demographic = evaluate_model(ctn_demographic, test_dloader, device, with_demographic=True, with_handcrafted=False)
results_dict['Demog-Transformer'] = print_results(ctn_demographic, y_test, y_pred_ctn_demographic, y_pred_proba_ctn_demographic)
Accuracy: 0.7316
ROC-AUC: 0.8277
F1-score: 0.7590
F-beta score 0.7153011113982941
No description has been provided for this image

Transformer with top 20 handfeatures (SHAP)¶

Idea:

  • Enriches the model’s representation by integrating domain-specific insights that capture subtle signal characteristics.
  • Followed the approach from [6]

Feature Augmentation

  • Integrate ECG features extracted during preprocessing.
  • Combined ECG-derived features with transformer-encoded representations.
  • Used SHAP (SHapley Additive exPlanations) instead of Random Forest feature importance.
In [71]:
y_pred_handcrafted, y_pred_proba_handcrafted = evaluate_model(ctn_handcrafted, test_dloader, device, with_demographic=True, with_handcrafted=True)
results_dict['Handcrafted-Transformer'] = print_results(ctn_handcrafted, y_test, y_pred_handcrafted, y_pred_proba_handcrafted)
Accuracy: 0.7461
ROC-AUC: 0.8379
F1-score: 0.7536
F-beta score 0.7406058840664143
No description has been provided for this image

ROC Curves¶

In [193]:
fig
Out[193]:
No description has been provided for this image
In [162]:
fig
Out[162]:
No description has been provided for this image

Model Comparison¶

In [241]:
pd.DataFrame(results_dict).T.sort_values(by='auc', ascending=False)
Out[241]:
acc auc f1 f_beta
SE-ResNet18 0.751050 0.843945 0.757440 0.746116
Handcrafted-Transformer 0.746086 0.837884 0.753612 0.740606
Demog-Transformer 0.731577 0.827712 0.758999 0.715301
CTN 0.733104 0.823354 0.712700 0.747156
XGBoost 0.705006 0.781293 0.714497 0.701118
KNN 0.690103 0.753267 0.680331 0.693618
Logistic Regression 0.682843 0.745708 0.672196 0.686180

References¶

  • [1] "Detection of Chagas Disease from the ECG: The George B. Moody PhysioNet Challenge 2025 "
  • [2] "CODE-15%: a large scale annotated dataset of 12-lead ECGs "
  • [3], "Relationship of electrocardiographic abnormalities and seropositivity to Trypanosoma cruzi within a rural community in northeast Brazil", J H Maguire et al.90529-x)
  • [4] "Electrocardiographic Characteristics in a Population with High Rates of Seropositivity for Trypanosoma cruzi Infection", 09/2007 , S. Williams-Blangero et al.
  • [5] "The PR Interval & PR segment"
  • [6] "A Wide and Deep Transformer Neural Network for 12-Lead ECG Classification", Annamalai Natarajan et al.
  • [7] "Clinical features of Chagas disease progression and severity"