ecg-digitization-experiments / code /scripts /evaluate_ensemble.py
Ubuntu
Add training scripts and notebooks
b69e447
Raw
History Blame Contribute Delete
44.2 kB
#!/usr/bin/env python3
"""
Evaluate individual models and ensembles on validation set.
Models:
- NET3 (baseline public solution)
- V16 (ConvNeXt-Base per-lead)
- V18 (V16 + Cross-Row Refiner)
- V19_base (ConvNeXt-Base + BiLSTM + DeformableConv)
Metrics:
- MAE (Mean Absolute Error)
- Normalized MSE (MSE / var(gt))
- Scale Invariant SNR (SI-SNR)
- SNR (Signal-to-Noise Ratio)
- Log-Cosh Loss
"""
import os
import sys
import argparse
import numpy as np
import pandas as pd
from pathlib import Path
from tqdm import tqdm
from scipy.signal import savgol_filter
from itertools import combinations
import json
import torch
import torch.nn as nn
import torch.nn.functional as F
import cv2
import timm
# Add baseline path
BASELINE_PATH = '/data/ecg-digitization/hengck23-submit-physionet/hengck23-submit-physionet'
sys.path.insert(0, BASELINE_PATH)
try:
import stage0_common as s0c
import stage1_common as s1c
import stage2_common as s2c
from stage0_model import Net as Stage0Net
from stage1_model import Net as Stage1Net
from stage2_model import MyCoordUnetDecoder, encode_with_resnet
HAS_BASELINE = True
except ImportError:
HAS_BASELINE = False
print("Warning: Baseline not available, Net3 will be skipped")
try:
from torchvision.ops import DeformConv2d
HAS_DEFORM_CONV = True
except ImportError:
HAS_DEFORM_CONV = False
# =============================================================================
# Constants
# =============================================================================
TARGET_HEIGHT, TARGET_WIDTH = 1696, 4352
ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5])
MV_TO_PIXEL = 78.5
T0, T1 = 235, 4161
X0, X1 = 0, 2176
Y0, Y1 = 0, 1696
OUTPUT_WIDTH = T1 - T0
CROP_HALF_HEIGHT = 250
ROW_HEIGHT = 500
ECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0
VAL_SAMPLE_IDS = [
'1006427285', '1006867983', '1012423188', '10140238', '1015663939',
'102150619', '1026034238', '1041099777', '104573050', '1048962695',
'1052007218', '1053922973', '1059602762', '1063816858',
'1067371646', '1067975047', '1068062585', '1072767337',
'1084993373', '108599929'
]
VALID_VARIANTS = ['0001', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012']
LEAD_LAYOUT = [
['I', 'aVR', 'V1', 'V4'],
['II', 'aVL', 'V2', 'V5'],
['III', 'aVF', 'V3', 'V6'],
]
ALL_LEADS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']
# =============================================================================
# Metrics
# =============================================================================
def compute_mae(pred, gt):
"""Mean Absolute Error."""
return np.mean(np.abs(pred - gt))
def compute_normalized_mse(pred, gt):
"""Normalized MSE = MSE / var(gt)."""
mse = np.mean((pred - gt) ** 2)
var_gt = np.var(gt)
if var_gt < 1e-10:
return 0.0
return mse / var_gt
def compute_snr(pred, gt, epsilon=1e-10):
"""Signal-to-Noise Ratio in dB."""
signal_power = np.mean(gt ** 2)
noise_power = np.mean((pred - gt) ** 2)
if noise_power < epsilon:
return 50.0
return 10 * np.log10(signal_power / noise_power)
def compute_si_snr(pred, gt, epsilon=1e-10):
"""Scale-Invariant SNR in dB."""
# SI-SNR: project pred onto gt, then compute SNR
# s_target = <pred, gt> / ||gt||^2 * gt
gt_norm_sq = np.sum(gt ** 2)
if gt_norm_sq < epsilon:
return 0.0
alpha = np.sum(pred * gt) / gt_norm_sq
s_target = alpha * gt
e_noise = pred - s_target
signal_power = np.mean(s_target ** 2)
noise_power = np.mean(e_noise ** 2)
if noise_power < epsilon:
return 50.0
return 10 * np.log10(signal_power / noise_power)
def compute_log_cosh(pred, gt):
"""Log-Cosh loss."""
diff = pred - gt
return np.mean(np.log(np.cosh(diff + 1e-12)))
def compute_all_metrics(pred, gt):
"""Compute all metrics for a single signal pair."""
return {
'mae': compute_mae(pred, gt),
'nmse': compute_normalized_mse(pred, gt),
'si_snr': compute_si_snr(pred, gt),
'snr': compute_snr(pred, gt),
'log_cosh': compute_log_cosh(pred, gt),
}
# =============================================================================
# V16 Model Architecture
# =============================================================================
class CoordConv2d(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, **kwargs):
super().__init__()
self.conv = nn.Conv2d(in_channels + 2, out_channels, kernel_size, **kwargs)
def forward(self, x):
B, C, H, W = x.shape
yy = torch.linspace(-1, 1, H, device=x.device).view(1, 1, H, 1).expand(B, 1, H, W)
xx = torch.linspace(-1, 1, W, device=x.device).view(1, 1, 1, W).expand(B, 1, H, W)
return self.conv(torch.cat([x, yy, xx], dim=1))
class UNetDecoderBlock(nn.Module):
def __init__(self, in_ch, skip_ch, out_ch):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(in_ch + skip_ch, out_ch, 3, padding=1, bias=False),
nn.BatchNorm2d(out_ch), nn.GELU(),
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
nn.BatchNorm2d(out_ch), nn.GELU(),
)
self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
def forward(self, x, skip=None):
x = self.upsample(x)
if skip is not None:
if x.shape[2:] != skip.shape[2:]:
x = F.interpolate(x, size=skip.shape[2:], mode='bilinear', align_corners=True)
x = torch.cat([x, skip], dim=1)
return self.conv(x)
class PerLeadNet(nn.Module):
"""V16 Per-Lead ECG Extraction Network."""
def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True):
super().__init__()
self.encoder = timm.create_model(encoder_name, pretrained=pretrained,
features_only=True, out_indices=(0, 1, 2, 3))
enc_channels = self.encoder.feature_info.channels()
decoder_dims = [256, 128, 64, 32]
self.dec_blocks = nn.ModuleList()
in_ch = enc_channels[-1]
skip_channels = enc_channels[:-1][::-1] + [0]
for skip_ch, out_ch in zip(skip_channels, decoder_dims):
self.dec_blocks.append(UNetDecoderBlock(in_ch, skip_ch, out_ch))
in_ch = out_ch
self.final_up = nn.Sequential(
nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True),
nn.Conv2d(32, 32, 3, padding=1, bias=False), nn.BatchNorm2d(32), nn.GELU())
self.height_attention = nn.Sequential(
CoordConv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.GELU(), nn.Conv2d(64, 1, 1))
self.regression_head = nn.Sequential(
nn.Conv1d(32, 128, 7, padding=3), nn.BatchNorm1d(128), nn.GELU(),
nn.Conv1d(128, 64, 5, padding=2), nn.BatchNorm1d(64), nn.GELU(),
nn.Conv1d(64, 1, 1))
def forward(self, x):
B, C, H, W = x.shape
features = self.encoder(x)
d = features[-1]
skips = features[:-1][::-1] + [None]
for block, skip in zip(self.dec_blocks, skips):
d = block(d, skip)
d = self.final_up(d)
if d.shape[3] != W:
d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True)
attn = F.softmax(self.height_attention(d), dim=2)
d = (d * attn).sum(dim=2)
out = torch.sigmoid(self.regression_head(d))
return out.squeeze(1)
# =============================================================================
# V18 Refiner Model Architecture
# =============================================================================
class CrossRowAttention(nn.Module):
def __init__(self, embed_dim, num_heads=8, dropout=0.1):
super().__init__()
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
self.scale = self.head_dim ** -0.5
self.qkv = nn.Linear(embed_dim, embed_dim * 3)
self.proj = nn.Linear(embed_dim, embed_dim)
self.dropout = nn.Dropout(dropout)
self.norm = nn.LayerNorm(embed_dim)
def forward(self, x):
B, num_rows, C, W = x.shape
x = x.permute(0, 3, 1, 2).reshape(B * W, num_rows, C)
residual = x
x = self.norm(x)
qkv = self.qkv(x).reshape(B * W, num_rows, 3, self.num_heads, self.head_dim)
qkv = qkv.permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2]
attn = (q @ k.transpose(-2, -1)) * self.scale
attn = attn.softmax(dim=-1)
attn = self.dropout(attn)
out = (attn @ v).transpose(1, 2).reshape(B * W, num_rows, C)
out = self.proj(out)
out = self.dropout(out)
out = out + residual
return out.reshape(B, W, num_rows, C).permute(0, 2, 3, 1)
class CrossRowTransformerBlock(nn.Module):
def __init__(self, embed_dim, num_heads=8, mlp_ratio=4.0, dropout=0.1):
super().__init__()
self.attn = CrossRowAttention(embed_dim, num_heads, dropout)
self.norm = nn.LayerNorm(embed_dim)
hidden_dim = int(embed_dim * mlp_ratio)
self.ffn = nn.Sequential(
nn.Linear(embed_dim, hidden_dim), nn.GELU(), nn.Dropout(dropout),
nn.Linear(hidden_dim, embed_dim), nn.Dropout(dropout))
def forward(self, x):
x = self.attn(x)
B, num_rows, C, W = x.shape
residual = x
x = x.permute(0, 3, 1, 2).reshape(B * W, num_rows, C)
x = self.norm(x)
x = self.ffn(x) + residual.permute(0, 3, 1, 2).reshape(B * W, num_rows, C)
return x.reshape(B, W, num_rows, C).permute(0, 2, 3, 1)
class RefinerEncoder(nn.Module):
def __init__(self, encoder_name='efficientnet_b0', pretrained=True):
super().__init__()
self.encoder = timm.create_model(encoder_name, pretrained=pretrained,
features_only=True, out_indices=(1, 2, 3), in_chans=4)
self.channels = self.encoder.feature_info.channels()
def forward(self, x):
return self.encoder(x)
class V18RefinerNet(nn.Module):
def __init__(self, encoder_name='efficientnet_b0', pretrained=True,
cross_row_layers=3, cross_row_dim=128, num_heads=4):
super().__init__()
self.row_encoder = RefinerEncoder(encoder_name, pretrained)
enc_channels = self.row_encoder.channels
self.feature_proj = nn.Sequential(nn.AdaptiveAvgPool2d((1, None)), nn.Flatten(1, 2))
self.channel_proj = nn.Conv1d(enc_channels[-1], cross_row_dim, 1)
self.cross_row_blocks = nn.ModuleList([
CrossRowTransformerBlock(cross_row_dim, num_heads, 2.0, 0.1)
for _ in range(cross_row_layers)])
self.residual_head = nn.Sequential(
nn.Conv1d(cross_row_dim, 64, 5, padding=2), nn.BatchNorm1d(64), nn.GELU(),
nn.Conv1d(64, 32, 3, padding=1), nn.BatchNorm1d(32), nn.GELU(),
nn.Conv1d(32, 1, 1), nn.Tanh())
self.residual_scale = nn.Parameter(torch.tensor(0.1))
def create_guide_channel(self, v16_pred, height, sigma=15.0):
B, W = v16_pred.shape
device = v16_pred.device
y_pred = v16_pred * height
y_grid = torch.arange(height, device=device, dtype=torch.float32).view(1, height, 1)
y_pred = y_pred.unsqueeze(1)
guide = torch.exp(-0.5 * ((y_grid - y_pred) / sigma) ** 2)
return guide.unsqueeze(1)
def forward(self, images, v16_preds):
B, num_rows, C, H, W = images.shape
all_features = []
for row_idx in range(num_rows):
row_image = images[:, row_idx]
row_v16 = v16_preds[:, row_idx]
guide = self.create_guide_channel(row_v16, H)
row_input = torch.cat([row_image, guide], dim=1)
enc_features = self.row_encoder(row_input)
row_feat = enc_features[-1]
row_feat = self.feature_proj(row_feat)
row_feat = F.interpolate(row_feat, size=W, mode='linear', align_corners=True)
row_feat = self.channel_proj(row_feat)
all_features.append(row_feat)
features = torch.stack(all_features, dim=1)
for block in self.cross_row_blocks:
features = block(features)
residuals = []
for row_idx in range(num_rows):
row_feat = features[:, row_idx]
residual = self.residual_head(row_feat)
residuals.append(residual.squeeze(1))
residuals = torch.stack(residuals, dim=1)
scaled_residuals = residuals * self.residual_scale * 0.1
refined = torch.clamp(v16_preds + scaled_residuals, 0, 1)
return refined, scaled_residuals
# =============================================================================
# V19 Model Architecture
# =============================================================================
class DeformableConvBlock(nn.Module):
def __init__(self, in_ch, out_ch, kernel_size=3, stride=1, padding=1):
super().__init__()
if HAS_DEFORM_CONV:
self.offset_conv = nn.Sequential(
nn.Conv2d(in_ch, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True),
nn.Conv2d(64, 2 * kernel_size * kernel_size, 3, padding=1))
nn.init.zeros_(self.offset_conv[-1].weight)
nn.init.zeros_(self.offset_conv[-1].bias)
self.deform_conv = DeformConv2d(in_ch, out_ch, kernel_size, stride=stride, padding=padding)
else:
self.conv = nn.Conv2d(in_ch, out_ch, kernel_size, stride=stride, padding=padding)
self.norm = nn.BatchNorm2d(out_ch)
self.act = nn.GELU()
def forward(self, x):
if HAS_DEFORM_CONV:
offset = self.offset_conv(x)
out = self.deform_conv(x, offset)
else:
out = self.conv(x)
return self.act(self.norm(out))
class BiLSTMHead(nn.Module):
def __init__(self, input_dim, hidden_dim=128, num_layers=2, dropout=0.1):
super().__init__()
self.lstm = nn.LSTM(input_dim, hidden_dim, num_layers, batch_first=True,
bidirectional=True, dropout=dropout if num_layers > 1 else 0)
self.output_proj = nn.Sequential(nn.Linear(hidden_dim * 2, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU())
self.output_dim = hidden_dim
def forward(self, x):
x = x.permute(0, 2, 1)
lstm_out, _ = self.lstm(x)
return self.output_proj(lstm_out)
class AuxiliaryHeads(nn.Module):
def __init__(self, feature_dim):
super().__init__()
self.grid_head = nn.Sequential(
nn.Conv2d(32, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True),
nn.Conv2d(16, 1, 1), nn.Sigmoid())
self.gradient_head = nn.Sequential(nn.Linear(feature_dim, 64), nn.GELU(), nn.Linear(64, 1), nn.Tanh())
self.uncertainty_head = nn.Sequential(nn.Linear(feature_dim, 64), nn.GELU(), nn.Linear(64, 1))
def forward(self, features_2d, features_1d):
return self.grid_head(features_2d), self.gradient_head(features_1d).squeeze(-1), self.uncertainty_head(features_1d).squeeze(-1)
class UNetDecoderBlockV19(nn.Module):
def __init__(self, in_ch, skip_ch, out_ch, use_deform=False):
super().__init__()
if use_deform and HAS_DEFORM_CONV:
self.conv1 = DeformableConvBlock(in_ch + skip_ch, out_ch)
else:
self.conv1 = nn.Sequential(
nn.Conv2d(in_ch + skip_ch, out_ch, 3, padding=1, bias=False),
nn.BatchNorm2d(out_ch), nn.GELU())
self.conv2 = nn.Sequential(
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.GELU())
self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
def forward(self, x, skip=None):
x = self.upsample(x)
if skip is not None:
if x.shape[2:] != skip.shape[2:]:
x = F.interpolate(x, size=skip.shape[2:], mode='bilinear', align_corners=True)
x = torch.cat([x, skip], dim=1)
return self.conv2(self.conv1(x))
class PerLeadNetV19(nn.Module):
"""V19: ConvNeXt-Base + BiLSTM + Deformable Conv."""
def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True):
super().__init__()
self.encoder = timm.create_model(encoder_name, pretrained=pretrained,
features_only=True, out_indices=(0, 1, 2, 3))
enc_channels = self.encoder.feature_info.channels()
decoder_dims = [256, 128, 64, 32]
self.dec_blocks = nn.ModuleList()
in_ch = enc_channels[-1]
skip_channels = enc_channels[:-1][::-1] + [0]
for i, (skip_ch, out_ch) in enumerate(zip(skip_channels, decoder_dims)):
use_deform = (i >= 2)
self.dec_blocks.append(UNetDecoderBlockV19(in_ch, skip_ch, out_ch, use_deform))
in_ch = out_ch
self.final_up = nn.Sequential(
nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True),
nn.Conv2d(32, 32, 3, padding=1, bias=False), nn.BatchNorm2d(32), nn.GELU())
self.height_attention = nn.Sequential(
CoordConv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.GELU(), nn.Conv2d(64, 1, 1))
self.bilstm = BiLSTMHead(32, 128, 2, 0.1)
self.regression_head = nn.Sequential(nn.Linear(128, 64), nn.GELU(), nn.Linear(64, 1), nn.Sigmoid())
self.aux_heads = AuxiliaryHeads(128)
def forward(self, x, return_aux=False):
B, C, H, W = x.shape
features = self.encoder(x)
d = features[-1]
skips = features[:-1][::-1] + [None]
for block, skip in zip(self.dec_blocks, skips):
d = block(d, skip)
features_2d = d
d = self.final_up(d)
if d.shape[3] != W:
d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True)
attn = F.softmax(self.height_attention(d), dim=2)
pooled = (d * attn).sum(dim=2)
temporal_features = self.bilstm(pooled)
y_pred = self.regression_head(temporal_features).squeeze(-1)
if return_aux:
return y_pred, self.aux_heads(features_2d, temporal_features)
return y_pred
# =============================================================================
# Net3 Model Architecture (use baseline's stage2 model)
# =============================================================================
if HAS_BASELINE:
from stage2_model import Net as Stage2Net
# =============================================================================
# Inference Helpers
# =============================================================================
def crop_row(image, row_idx):
"""Crop a single row centered on its baseline."""
baseline_y = int(ZERO_MV[row_idx])
y_start = max(0, baseline_y - CROP_HALF_HEIGHT)
y_end = min(TARGET_HEIGHT, baseline_y + CROP_HALF_HEIGHT)
row_crop = image[y_start:y_end, T0:T1, :].copy()
if row_crop.shape[0] < ROW_HEIGHT:
pad_top = max(0, CROP_HALF_HEIGHT - baseline_y)
pad_bottom = max(0, (baseline_y + CROP_HALF_HEIGHT) - TARGET_HEIGHT)
row_crop = np.pad(row_crop, ((pad_top, pad_bottom), (0, 0), (0, 0)), mode='edge')
return row_crop
def convert_crop_to_mv(pred_normalized, row_idx):
"""Convert normalized prediction to mV."""
pred_y_crop = pred_normalized * ROW_HEIGHT
baseline_y = int(ZERO_MV[row_idx])
y_start = max(0, baseline_y - CROP_HALF_HEIGHT)
pad_top = max(0, CROP_HALF_HEIGHT - baseline_y)
pred_y_full = pred_y_crop - pad_top + y_start
return (ZERO_MV[row_idx] - pred_y_full) / MV_TO_PIXEL
def resample_signal(signal, target_length):
"""Resample signal to target length."""
if len(signal) == target_length:
return signal
x_old = np.linspace(0, 1, len(signal))
x_new = np.linspace(0, 1, target_length)
return np.interp(x_new, x_old, signal)
# =============================================================================
# Model Loaders
# =============================================================================
def load_v16(checkpoint_path, device):
model = PerLeadNet(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False)
ckpt = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
state_dict = ckpt['model'] if 'model' in ckpt else ckpt
if any(k.startswith('module.') for k in state_dict.keys()):
state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()}
model.load_state_dict(state_dict)
model = model.to(device).eval()
print(f"V16 loaded: epoch {ckpt.get('epoch', '?')}, SNR: {ckpt.get('snr', 0):.2f} dB")
return model
def load_v18(checkpoint_path, device):
model = V18RefinerNet(encoder_name='efficientnet_b0', pretrained=False)
ckpt = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
state_dict = ckpt['refiner'] if 'refiner' in ckpt else ckpt.get('model', ckpt)
if any(k.startswith('module.') for k in state_dict.keys()):
state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()}
model.load_state_dict(state_dict)
model = model.to(device).eval()
print(f"V18 loaded: epoch {ckpt.get('epoch', '?')}, SNR: {ckpt.get('snr', 0):.2f} dB")
return model
def load_v19(checkpoint_path, device):
model = PerLeadNetV19(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False)
ckpt = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
state_dict = ckpt['model'] if 'model' in ckpt else ckpt
if any(k.startswith('module.') for k in state_dict.keys()):
state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()}
model.load_state_dict(state_dict)
model = model.to(device).eval()
print(f"V19 loaded: epoch {ckpt.get('epoch', '?')}, SNR: {ckpt.get('snr', 0):.2f} dB")
return model
def load_net3(checkpoint_path, device):
if not HAS_BASELINE:
return None
model = Stage2Net(pretrained=False)
ckpt = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
state_dict = ckpt['state_dict'] if 'state_dict' in ckpt else ckpt
model.load_state_dict(state_dict)
model = model.to(device).eval()
print(f"Net3 (Stage2) loaded")
return model
# =============================================================================
# Inference Functions
# =============================================================================
@torch.no_grad()
def predict_v16(model, row_crops, device):
"""Run V16 on row crops, return 4-row mV signal."""
series_mv = []
for row_idx, crop in enumerate(row_crops):
tensor = torch.from_numpy(crop.astype(np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0).to(device)
with torch.amp.autocast('cuda'):
pred = model(tensor)
pred_np = pred[0].cpu().numpy()
series_mv.append(convert_crop_to_mv(pred_np, row_idx))
return np.array(series_mv)
@torch.no_grad()
def predict_v16v18(v16_model, v18_model, row_crops, device):
"""Run V16 + V18 refiner on row crops, return 4-row mV signal."""
row_tensors = [torch.from_numpy(c.astype(np.float32) / 255.0).permute(2, 0, 1) for c in row_crops]
images = torch.stack(row_tensors, dim=0).unsqueeze(0).to(device) # [1, 4, 3, H, W]
with torch.amp.autocast('cuda'):
v16_preds = []
for row_idx in range(4):
pred = v16_model(images[:, row_idx])
v16_preds.append(pred)
v16_preds = torch.stack(v16_preds, dim=1)
refined, _ = v18_model(images, v16_preds)
refined_np = refined[0].cpu().numpy()
series_mv = np.array([convert_crop_to_mv(refined_np[i], i) for i in range(4)])
return series_mv
@torch.no_grad()
def predict_v19(model, row_crops, device):
"""Run V19 on row crops, return 4-row mV signal."""
series_mv = []
for row_idx, crop in enumerate(row_crops):
tensor = torch.from_numpy(crop.astype(np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0).to(device)
with torch.amp.autocast('cuda'):
pred = model(tensor, return_aux=False)
pred_np = pred[0].cpu().numpy()
series_mv.append(convert_crop_to_mv(pred_np, row_idx))
return np.array(series_mv)
@torch.no_grad()
def predict_net3(model, image_rgb, device):
"""Run Net3 (Stage2) on image, return 4-row mV signal."""
if model is None:
return None
# Crop and resize
x0, x1 = 0, 2176
y0, y1 = 0, 1696
img = image_rgb[y0:y1, x0:x1] / 255.0
batch = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1))).unsqueeze(0).float()
batch = F.interpolate(batch, size=(TARGET_HEIGHT, TARGET_WIDTH), mode='bilinear', align_corners=True)
batch = batch.to(device)
with torch.amp.autocast('cuda'):
output = model(batch)
pixel = torch.sigmoid(output).float().cpu().numpy()[0]
# pixel shape: [4, H, W]
# Use pixel_to_series from baseline
series_in_pixel = s2c.pixel_to_series(pixel[..., T0:T1], ZERO_MV, length=OUTPUT_WIDTH)
series_mv = (ZERO_MV.reshape(4, 1) - series_in_pixel) / MV_TO_PIXEL
return series_mv
def series_to_leads(series_mv):
"""Convert 4-row series to 12-lead dictionary."""
leads = {}
segment_width = series_mv.shape[1] // 4
for row_idx in range(3):
for seg_idx, lead_name in enumerate(LEAD_LAYOUT[row_idx]):
start = seg_idx * segment_width
end = (seg_idx + 1) * segment_width
leads[lead_name] = series_mv[row_idx, start:end]
# Lead II short strip (first segment of row 1)
leads['II'] = series_mv[1, :segment_width] # Use short strip for fair comparison
return leads
def get_gt_leads(df):
"""Get ground truth leads from CSV."""
leads = {}
for lead in ALL_LEADS:
if lead in df.columns:
signal = df[lead].dropna().values
if lead == 'II' and len(signal) > 5000:
# Use first quarter (short strip equivalent)
signal = signal[:len(signal)//4]
leads[lead] = signal
return leads
# =============================================================================
# Ensemble Function
# =============================================================================
def ensemble_signals(signals_dict, weights):
"""
Ensemble multiple model predictions.
signals_dict: {'model_name': leads_dict}
weights: {'model_name': weight}
"""
model_names = list(signals_dict.keys())
all_leads = list(signals_dict[model_names[0]].keys())
ensemble = {}
for lead in all_leads:
signals = []
ws = []
target_len = 0
for model_name in model_names:
if lead in signals_dict[model_name]:
sig = signals_dict[model_name][lead]
target_len = max(target_len, len(sig))
signals.append(sig)
ws.append(weights[model_name])
if not signals:
continue
# Resample to same length
resampled = [resample_signal(s, target_len) for s in signals]
# Weighted average
total_weight = sum(ws)
ensemble[lead] = sum(w * s for w, s in zip(ws, resampled)) / total_weight
return ensemble
# =============================================================================
# Main Evaluation
# =============================================================================
def evaluate_on_validation(models, kaggle_data, device):
"""
Evaluate all models on validation set.
Returns: dict of {model_name: {metric: value}}
"""
kaggle_dir = Path(kaggle_data)
val_sample_set = set(VAL_SAMPLE_IDS)
# Collect all samples
all_samples = []
for sample_dir in kaggle_dir.iterdir():
if not sample_dir.is_dir() or sample_dir.name not in val_sample_set:
continue
csv_files = list(sample_dir.glob('*.csv'))
if len(csv_files) != 1:
continue
csv_path = csv_files[0]
for img_path in sample_dir.glob('*.png'):
variant = img_path.stem.split('-')[-1] if '-' in img_path.stem else '0000'
if variant in VALID_VARIANTS:
all_samples.append((img_path, csv_path))
break # One image per sample
print(f"Found {len(all_samples)} validation samples")
# Initialize metrics storage
model_names = list(models.keys())
metrics_per_model = {name: {'mae': [], 'nmse': [], 'si_snr': [], 'snr': [], 'log_cosh': []}
for name in model_names}
for img_path, csv_path in tqdm(all_samples, desc="Evaluating"):
try:
# Load image
image = cv2.imread(str(img_path), cv2.IMREAD_COLOR)
if image is None:
continue
image = image[Y0:Y1, X0:X1]
image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)
# Load GT
df = pd.read_csv(csv_path)
gt_leads = get_gt_leads(df)
# Crop rows
row_crops = [crop_row(image, i) for i in range(4)]
# Get predictions from each model
preds = {}
if 'v16' in models and models['v16'] is not None:
series = predict_v16(models['v16'], row_crops, device)
preds['v16'] = series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX))
if 'v16v18' in models and models['v16v18'] is not None:
v16_m, v18_m = models['v16v18']
series = predict_v16v18(v16_m, v18_m, row_crops, device)
preds['v16v18'] = series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX))
if 'v19' in models and models['v19'] is not None:
series = predict_v19(models['v19'], row_crops, device)
preds['v19'] = series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX))
if 'net3' in models and models['net3'] is not None:
series = predict_net3(models['net3'], image, device)
if series is not None:
preds['net3'] = series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX))
# Compute metrics for each model
for model_name, pred_leads in preds.items():
for lead in ALL_LEADS:
if lead not in pred_leads or lead not in gt_leads:
continue
pred = pred_leads[lead]
gt = gt_leads[lead]
# Resample to same length
target_len = len(gt)
pred_resampled = resample_signal(pred, target_len)
# Compute metrics
m = compute_all_metrics(pred_resampled, gt)
for metric_name, value in m.items():
metrics_per_model[model_name][metric_name].append(value)
except Exception as e:
print(f"Error: {img_path}: {e}")
continue
# Average metrics
results = {}
for model_name in model_names:
results[model_name] = {}
for metric_name in ['mae', 'nmse', 'si_snr', 'snr', 'log_cosh']:
values = metrics_per_model[model_name][metric_name]
if values:
results[model_name][metric_name] = np.mean(values)
else:
results[model_name][metric_name] = None
return results
def evaluate_ensembles(models, kaggle_data, device, weight_steps=11):
"""
Evaluate all 2-model and 3-model ensembles.
Returns: dict of {ensemble_name: {metric: value, weights: {...}}}
"""
kaggle_dir = Path(kaggle_data)
val_sample_set = set(VAL_SAMPLE_IDS)
# Collect samples
all_samples = []
for sample_dir in kaggle_dir.iterdir():
if not sample_dir.is_dir() or sample_dir.name not in val_sample_set:
continue
csv_files = list(sample_dir.glob('*.csv'))
if len(csv_files) != 1:
continue
csv_path = csv_files[0]
for img_path in sample_dir.glob('*.png'):
variant = img_path.stem.split('-')[-1] if '-' in img_path.stem else '0000'
if variant in VALID_VARIANTS:
all_samples.append((img_path, csv_path))
break
print(f"Found {len(all_samples)} validation samples for ensemble evaluation")
# Get all predictions first
all_preds = {name: [] for name in models.keys()}
all_gts = []
print("Collecting predictions...")
for img_path, csv_path in tqdm(all_samples, desc="Collecting"):
try:
image = cv2.imread(str(img_path), cv2.IMREAD_COLOR)
if image is None:
continue
image = image[Y0:Y1, X0:X1]
image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)
df = pd.read_csv(csv_path)
gt_leads = get_gt_leads(df)
all_gts.append(gt_leads)
row_crops = [crop_row(image, i) for i in range(4)]
if 'v16' in models and models['v16'] is not None:
series = predict_v16(models['v16'], row_crops, device)
all_preds['v16'].append(series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX)))
if 'v16v18' in models and models['v16v18'] is not None:
v16_m, v18_m = models['v16v18']
series = predict_v16v18(v16_m, v18_m, row_crops, device)
all_preds['v16v18'].append(series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX)))
if 'v19' in models and models['v19'] is not None:
series = predict_v19(models['v19'], row_crops, device)
all_preds['v19'].append(series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX)))
if 'net3' in models and models['net3'] is not None:
series = predict_net3(models['net3'], image, device)
if series is not None:
all_preds['net3'].append(series_to_leads(np.clip(series, ECG_MV_MIN, ECG_MV_MAX)))
except Exception as e:
print(f"Error: {e}")
continue
# Available models
available_models = [name for name in models.keys() if len(all_preds[name]) > 0]
print(f"Available models: {available_models}")
# Test 2-model ensembles
print("\nTesting 2-model ensembles...")
best_ensemble_per_metric = {metric: {'score': None, 'models': None, 'weights': None}
for metric in ['mae', 'nmse', 'si_snr', 'snr', 'log_cosh']}
all_ensemble_results = []
for m1, m2 in combinations(available_models, 2):
for w1 in np.linspace(0.1, 0.9, weight_steps):
w2 = 1.0 - w1
weights = {m1: w1, m2: w2}
# Compute ensemble metrics
metrics = {'mae': [], 'nmse': [], 'si_snr': [], 'snr': [], 'log_cosh': []}
for i, gt_leads in enumerate(all_gts):
if i >= len(all_preds[m1]) or i >= len(all_preds[m2]):
continue
signals = {m1: all_preds[m1][i], m2: all_preds[m2][i]}
ensemble = ensemble_signals(signals, weights)
for lead in ALL_LEADS:
if lead not in ensemble or lead not in gt_leads:
continue
pred = ensemble[lead]
gt = gt_leads[lead]
pred_resampled = resample_signal(pred, len(gt))
m = compute_all_metrics(pred_resampled, gt)
for metric_name, value in m.items():
metrics[metric_name].append(value)
# Average
avg_metrics = {k: np.mean(v) if v else None for k, v in metrics.items()}
result = {
'models': f"{m1}+{m2}",
'weights': f"{w1:.1f}/{w2:.1f}",
**avg_metrics
}
all_ensemble_results.append(result)
# Update best
for metric in ['mae', 'nmse', 'log_cosh']: # Lower is better
if avg_metrics[metric] is not None:
if best_ensemble_per_metric[metric]['score'] is None or avg_metrics[metric] < best_ensemble_per_metric[metric]['score']:
best_ensemble_per_metric[metric] = {'score': avg_metrics[metric], 'models': f"{m1}+{m2}", 'weights': weights}
for metric in ['si_snr', 'snr']: # Higher is better
if avg_metrics[metric] is not None:
if best_ensemble_per_metric[metric]['score'] is None or avg_metrics[metric] > best_ensemble_per_metric[metric]['score']:
best_ensemble_per_metric[metric] = {'score': avg_metrics[metric], 'models': f"{m1}+{m2}", 'weights': weights}
# Test 3-model ensembles
print("Testing 3-model ensembles...")
for m1, m2, m3 in combinations(available_models, 3):
for w1 in np.linspace(0.2, 0.6, 5):
for w2 in np.linspace(0.2, 0.6, 5):
w3 = 1.0 - w1 - w2
if w3 < 0.1:
continue
weights = {m1: w1, m2: w2, m3: w3}
metrics = {'mae': [], 'nmse': [], 'si_snr': [], 'snr': [], 'log_cosh': []}
for i, gt_leads in enumerate(all_gts):
if i >= len(all_preds[m1]) or i >= len(all_preds[m2]) or i >= len(all_preds[m3]):
continue
signals = {m1: all_preds[m1][i], m2: all_preds[m2][i], m3: all_preds[m3][i]}
ensemble = ensemble_signals(signals, weights)
for lead in ALL_LEADS:
if lead not in ensemble or lead not in gt_leads:
continue
pred = ensemble[lead]
gt = gt_leads[lead]
pred_resampled = resample_signal(pred, len(gt))
m = compute_all_metrics(pred_resampled, gt)
for metric_name, value in m.items():
metrics[metric_name].append(value)
avg_metrics = {k: np.mean(v) if v else None for k, v in metrics.items()}
result = {
'models': f"{m1}+{m2}+{m3}",
'weights': f"{w1:.1f}/{w2:.1f}/{w3:.1f}",
**avg_metrics
}
all_ensemble_results.append(result)
# Update best
for metric in ['mae', 'nmse', 'log_cosh']:
if avg_metrics[metric] is not None:
if best_ensemble_per_metric[metric]['score'] is None or avg_metrics[metric] < best_ensemble_per_metric[metric]['score']:
best_ensemble_per_metric[metric] = {'score': avg_metrics[metric], 'models': f"{m1}+{m2}+{m3}", 'weights': weights}
for metric in ['si_snr', 'snr']:
if avg_metrics[metric] is not None:
if best_ensemble_per_metric[metric]['score'] is None or avg_metrics[metric] > best_ensemble_per_metric[metric]['score']:
best_ensemble_per_metric[metric] = {'score': avg_metrics[metric], 'models': f"{m1}+{m2}+{m3}", 'weights': weights}
return best_ensemble_per_metric, all_ensemble_results
def main():
parser = argparse.ArgumentParser(description='Evaluate models and ensembles')
parser.add_argument('--kaggle_data', type=str, default='/data/ecg-digitization/stage1_data/train')
parser.add_argument('--v16_ckpt', type=str, default='/data/ecg-digitization/checkpoints/v16_perlead_latest.pth')
parser.add_argument('--v18_ckpt', type=str, default='/data/ecg-digitization/checkpoints/v18_refiner_best.pth')
parser.add_argument('--v19_ckpt', type=str, default='/data/ecg-digitization/checkpoints/v19_enhanced_epoch025.pth')
parser.add_argument('--net3_ckpt', type=str, default='/data/ecg-digitization/hengck23-submit-physionet/hengck23-submit-physionet/weight/stage2-00005810.checkpoint.pth')
parser.add_argument('--output', type=str, default='/home/azureuser/tmp/ensemble_results.json')
args = parser.parse_args()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Device: {device}")
# Load models
print("\n" + "="*70)
print("Loading models...")
print("="*70)
models = {}
# V16
if os.path.exists(args.v16_ckpt):
models['v16'] = load_v16(args.v16_ckpt, device)
else:
print(f"V16 checkpoint not found: {args.v16_ckpt}")
models['v16'] = None
# V18 (requires V16)
if os.path.exists(args.v18_ckpt) and models.get('v16') is not None:
v18 = load_v18(args.v18_ckpt, device)
models['v16v18'] = (models['v16'], v18)
else:
print(f"V18 checkpoint not found or V16 missing")
models['v16v18'] = None
# V19
if os.path.exists(args.v19_ckpt):
models['v19'] = load_v19(args.v19_ckpt, device)
else:
print(f"V19 checkpoint not found: {args.v19_ckpt}")
models['v19'] = None
# Net3
if HAS_BASELINE and os.path.exists(args.net3_ckpt):
models['net3'] = load_net3(args.net3_ckpt, device)
else:
print(f"Net3 not available or checkpoint not found")
models['net3'] = None
# Evaluate individual models
print("\n" + "="*70)
print("Evaluating individual models...")
print("="*70)
individual_results = evaluate_on_validation(models, args.kaggle_data, device)
print("\n" + "="*70)
print("INDIVIDUAL MODEL RESULTS")
print("="*70)
print(f"{'Model':<12} {'MAE':>10} {'NMSE':>10} {'SI-SNR':>10} {'SNR':>10} {'LogCosh':>10}")
print("-"*70)
for model_name, metrics in individual_results.items():
if metrics['mae'] is not None:
print(f"{model_name:<12} {metrics['mae']:>10.4f} {metrics['nmse']:>10.4f} "
f"{metrics['si_snr']:>10.2f} {metrics['snr']:>10.2f} {metrics['log_cosh']:>10.4f}")
# Evaluate ensembles
print("\n" + "="*70)
print("Evaluating ensembles...")
print("="*70)
best_ensembles, all_results = evaluate_ensembles(models, args.kaggle_data, device)
print("\n" + "="*70)
print("BEST ENSEMBLE FOR EACH METRIC")
print("="*70)
for metric, info in best_ensembles.items():
if info['score'] is not None:
weights_str = '/'.join([f"{v:.1f}" for v in info['weights'].values()])
print(f"{metric.upper():<10}: {info['models']:<20} ({weights_str}) = {info['score']:.4f}")
# Save results
output = {
'individual': individual_results,
'best_ensembles': {k: {'score': v['score'], 'models': v['models'],
'weights': {m: float(w) for m, w in v['weights'].items()}}
for k, v in best_ensembles.items() if v['score'] is not None},
}
with open(args.output, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nResults saved to: {args.output}")
if __name__ == '__main__':
main()