ecg-digitization-experiments / code /scripts /eval_ensemble_preprocessed.py
Ubuntu
Add training scripts and notebooks
b69e447
Raw
History Blame Contribute Delete
47.7 kB
#!/usr/bin/env python3
"""
Evaluate individual models and ensembles on validation set using preprocessed images.
Uses preprocessed stage1 images directly (no stage0/1 processing).
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 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 for Net3
BASELINE_PATH = '/data/ecg-digitization/hengck23-submit-physionet/hengck23-submit-physionet'
sys.path.insert(0, BASELINE_PATH)
try:
import stage2_common as s2c
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 # For V16/V18/V19
MV_TO_PIXEL_NET3 = 78.8 # Net3 uses slightly different value
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
# Validation sample IDs (holdout set)
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."""
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 - Matches Kaggle checkpoint
# =============================================================================
if HAS_BASELINE:
class Net3(nn.Module):
def __init__(self, pretrained=True):
super().__init__()
encoder_dim = [64, 128, 256, 512]
decoder_dim = [128, 64, 32, 16]
self.encoder = timm.create_model(
model_name='resnet34.a3_in1k',
pretrained=pretrained,
in_chans=3,
num_classes=0,
global_pool=''
)
self.decoder = MyCoordUnetDecoder(
in_channel=encoder_dim[-1],
skip_channel=encoder_dim[:-1][::-1] + [0],
out_channel=decoder_dim,
scale=[2, 2, 2, 2]
)
self.pixel = nn.Conv2d(decoder_dim[-1], 4, 1)
def forward(self, image):
encode = encode_with_resnet(self.encoder, image)
last, _ = self.decoder(feature=encode[-1], skip=encode[:-1][::-1] + [None])
return self.pixel(last)
# =============================================================================
# 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)
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]
return leads
def get_gt_leads(df):
"""
Get ground truth leads from CSV.
IMPORTANT: The GT CSV structure is:
- Most leads (I, III, aVR, aVL, aVF, V1-V6): Already 2.5s segments (~2562 samples)
- Lead II: Full 10 seconds (~10250 samples)
For the SHORT Lead II strip (row 1, segment 0), use first 2.5s of GT.
For rhythm strip comparison, we would need full II, but we're only comparing short leads.
"""
leads = {}
# Get reference length for short leads
ref_lead = 'I' if 'I' in df.columns else 'III'
ref_len = len(df[ref_lead].dropna().values) if ref_lead in df.columns else 2500
for lead in ALL_LEADS:
if lead in df.columns:
signal = df[lead].dropna().values.astype(np.float32)
# Special handling for Lead II (10s data in GT)
# The SHORT strip shows first 2.5s (0-2.5s)
if lead == 'II' and len(signal) > ref_len * 2:
# Lead II is at least 2x longer = it's 10s data
# Use first quarter for SHORT strip comparison
quarter_len = len(signal) // 4
leads[lead] = signal[:quarter_len]
else:
leads[lead] = signal
return leads
# =============================================================================
# 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 = Net3(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
# Filter out unexpected keys like D, mean, std
model_keys = set(model.state_dict().keys())
state_dict = {k: v for k, v in state_dict.items() if k in model_keys}
model.load_state_dict(state_dict, strict=False)
model = model.to(device).eval()
print(f"Net3 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 ALREADY preprocessed image, return 4-row mV signal.
IMPORTANT: image_rgb should already be:
- Cropped to [0:1696, 0:2176] from stage1 output
- Resized to (1696, 4352)
So we just normalize and run through the model.
"""
if model is None:
return None
# Image is already preprocessed to (1696, 4352, 3)
# Just normalize to [0, 1] and run
img = image_rgb.astype(np.float32) / 255.0
batch = torch.from_numpy(np.ascontiguousarray(img.transpose(2, 0, 1))).unsqueeze(0).float()
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] = [4, 1696, 4352]
# Use pixel_to_series from baseline to convert heatmap to pixel coords
# Slice to signal region T0:T1 (same as reference)
series_in_pixel = s2c.pixel_to_series(pixel[..., T0:T1], ZERO_MV, length=OUTPUT_WIDTH)
# Convert to mV using Net3's MV_TO_PIXEL value (78.8, not 78.5!)
series_mv = (ZERO_MV.reshape(4, 1) - series_in_pixel) / MV_TO_PIXEL_NET3
return series_mv
# =============================================================================
# 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())
if not model_names:
return {}
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 collect_samples(stage1_dir):
"""
Collect all validation samples.
Uses preprocessed images and GT from stage1_dir.
Structure: stage1_dir/{sample_id}/{sample_id}-{variant}.png and {sample_id}.csv
"""
stage1_path = Path(stage1_dir)
val_sample_set = set(VAL_SAMPLE_IDS)
samples = []
# Iterate through sample folders
for sample_folder in stage1_path.iterdir():
if not sample_folder.is_dir():
continue
sample_id = sample_folder.name
if sample_id not in val_sample_set:
continue
# Find GT CSV in the same folder
csv_path = sample_folder / f"{sample_id}.csv"
if not csv_path.exists():
continue
# Find all variant images
for img_path in sample_folder.glob(f"{sample_id}-*.png"):
# Format: 1006427285-0001.png
name = img_path.stem
parts = name.split('-')
if len(parts) != 2:
continue
variant = parts[1]
if variant not in VALID_VARIANTS:
continue
samples.append({
'image_path': img_path,
'csv_path': csv_path,
'sample_id': sample_id,
'variant': variant,
})
return samples
def evaluate_models(models, samples, device):
"""
Evaluate all models on samples.
Returns predictions and ground truth for each sample.
"""
all_preds = {name: [] for name in models.keys()}
all_gts = []
sample_ids = []
for sample in tqdm(samples, desc="Evaluating"):
try:
# Load preprocessed image (already stage0/1 processed)
image = cv2.imread(str(sample['image_path']), cv2.IMREAD_COLOR)
if image is None:
continue
# Preprocess: crop then resize (same as infer_v16)
# stage1_data images are ~(1700, 2200), need to crop to (Y1, X1) then resize
h, w = image.shape[:2]
crop_h = min(h, Y1) # min(1700, 1696) = 1696
crop_w = min(w, X1) # min(2200, 2176) = 2176
image = image[:crop_h, :crop_w]
image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)
# Load GT
df = pd.read_csv(sample['csv_path'])
gt_leads = get_gt_leads(df)
if not gt_leads:
continue
all_gts.append(gt_leads)
sample_ids.append(f"{sample['sample_id']}-{sample['variant']}")
# Crop rows for V16/V18/V19
row_crops = [crop_row(image, i) for i in range(4)]
# Get predictions from each model
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 'v18' in models and models['v18'] is not None:
v16_m, v18_m = models['v18']
series = predict_v16v18(v16_m, v18_m, row_crops, device)
all_preds['v18'].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:
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
series = predict_net3(models['net3'], image_rgb, 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: {sample['image_path']}: {e}")
continue
return all_preds, all_gts, sample_ids
def compute_metrics_for_predictions(all_preds, all_gts, model_name):
"""Compute metrics for a single model's predictions."""
metrics = {'mae': [], 'nmse': [], 'si_snr': [], 'snr': [], 'log_cosh': []}
preds_list = all_preds.get(model_name, [])
if not preds_list:
return None
for i, gt_leads in enumerate(all_gts):
if i >= len(preds_list):
break
pred_leads = preds_list[i]
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
pred_resampled = resample_signal(pred, len(gt))
# Compute metrics
m = compute_all_metrics(pred_resampled, gt)
for metric_name, value in m.items():
metrics[metric_name].append(value)
# Average
return {k: np.mean(v) if v else None for k, v in metrics.items()}
def compute_metrics_for_ensemble(all_preds, all_gts, model_names, weights):
"""Compute metrics for an ensemble."""
metrics = {'mae': [], 'nmse': [], 'si_snr': [], 'snr': [], 'log_cosh': []}
for i, gt_leads in enumerate(all_gts):
# Check all models have predictions for this sample
valid = True
for name in model_names:
if i >= len(all_preds.get(name, [])):
valid = False
break
if not valid:
continue
# Build signals dict for this sample
signals = {name: all_preds[name][i] for name in model_names}
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)
return {k: np.mean(v) if v else None for k, v in metrics.items()}
def main():
parser = argparse.ArgumentParser(description='Evaluate models and ensembles on preprocessed images')
parser.add_argument('--stage1_dir', 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_augraphy_latest.pth')
parser.add_argument('--net3_ckpt', type=str, default='/data/ecg-digitization/checkpoints/net3_kaggle/iter_0004200.pt')
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}")
# Collect samples
print("\n" + "="*70)
print("Collecting validation samples...")
print("="*70)
samples = collect_samples(args.stage1_dir)
print(f"Found {len(samples)} validation samples")
if len(samples) == 0:
print("ERROR: No samples found!")
return
# Load models
print("\n" + "="*70)
print("Loading models...")
print("="*70)
models = {}
# V16
if os.path.exists(args.v16_ckpt):
v16_model = load_v16(args.v16_ckpt, device)
models['v16'] = v16_model
else:
print(f"V16 checkpoint not found: {args.v16_ckpt}")
# V18 (requires V16)
if os.path.exists(args.v18_ckpt) and 'v16' in models:
v18_model = load_v18(args.v18_ckpt, device)
models['v18'] = (models['v16'], v18_model)
else:
print(f"V18 checkpoint not found or V16 missing")
# 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}")
# # 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")
print(f"\nLoaded models: {list(models.keys())}")
# Evaluate
print("\n" + "="*70)
print("Running inference...")
print("="*70)
all_preds, all_gts, sample_ids = evaluate_models(models, samples, device)
print(f"\nProcessed {len(all_gts)} samples")
# Compute individual metrics
print("\n" + "="*70)
print("INDIVIDUAL MODEL RESULTS")
print("="*70)
individual_results = {}
print(f"\n{'Model':<12} {'MAE':>10} {'NMSE':>10} {'SI-SNR':>10} {'SNR':>10} {'LogCosh':>10}")
print("-"*70)
for model_name in ['v16', 'v18', 'v19', 'net3']:
if model_name in models:
metrics = compute_metrics_for_predictions(all_preds, all_gts, model_name)
if metrics and metrics['mae'] is not None:
individual_results[model_name] = metrics
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}")
# Find best ensemble for each metric
print("\n" + "="*70)
print("SEARCHING FOR BEST ENSEMBLES...")
print("="*70)
available_models = [name for name in models.keys() if len(all_preds.get(name, [])) > 0]
print(f"Available models for ensemble: {available_models}")
best_ensembles = {
'mae': {'score': None, 'models': None, 'weights': None},
'nmse': {'score': None, 'models': None, 'weights': None},
'si_snr': {'score': None, 'models': None, 'weights': None},
'snr': {'score': None, 'models': None, 'weights': None},
'log_cosh': {'score': None, 'models': None, 'weights': None},
}
all_ensemble_results = []
# Test 2-model ensembles
print("\nTesting 2-model ensembles...")
for m1, m2 in combinations(available_models, 2):
for w1 in np.linspace(0.1, 0.9, 9):
w2 = 1.0 - w1
weights = {m1: w1, m2: w2}
metrics = compute_metrics_for_ensemble(all_preds, all_gts, [m1, m2], weights)
if metrics['mae'] is None:
continue
result = {
'models': f"{m1}+{m2}",
'weights': {m1: float(w1), m2: float(w2)},
**metrics
}
all_ensemble_results.append(result)
# Update best (lower is better for mae, nmse, log_cosh)
for metric in ['mae', 'nmse', 'log_cosh']:
if best_ensembles[metric]['score'] is None or metrics[metric] < best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': f"{m1}+{m2}", 'weights': weights.copy()}
# Higher is better for si_snr, snr
for metric in ['si_snr', 'snr']:
if best_ensembles[metric]['score'] is None or metrics[metric] > best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': f"{m1}+{m2}", 'weights': weights.copy()}
# Test 3-model ensembles
if len(available_models) >= 3:
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 = compute_metrics_for_ensemble(all_preds, all_gts, [m1, m2, m3], weights)
if metrics['mae'] is None:
continue
result = {
'models': f"{m1}+{m2}+{m3}",
'weights': {m1: float(w1), m2: float(w2), m3: float(w3)},
**metrics
}
all_ensemble_results.append(result)
for metric in ['mae', 'nmse', 'log_cosh']:
if best_ensembles[metric]['score'] is None or metrics[metric] < best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': f"{m1}+{m2}+{m3}", 'weights': weights.copy()}
for metric in ['si_snr', 'snr']:
if best_ensembles[metric]['score'] is None or metrics[metric] > best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': f"{m1}+{m2}+{m3}", 'weights': weights.copy()}
# Test 4-model ensembles
if len(available_models) >= 4:
print("Testing 4-model ensembles...")
for w1 in np.linspace(0.2, 0.4, 3):
for w2 in np.linspace(0.2, 0.4, 3):
for w3 in np.linspace(0.1, 0.3, 3):
w4 = 1.0 - w1 - w2 - w3
if w4 < 0.1:
continue
weights = {available_models[0]: w1, available_models[1]: w2,
available_models[2]: w3, available_models[3]: w4}
metrics = compute_metrics_for_ensemble(all_preds, all_gts, available_models, weights)
if metrics['mae'] is None:
continue
result = {
'models': '+'.join(available_models),
'weights': {k: float(v) for k, v in weights.items()},
**metrics
}
all_ensemble_results.append(result)
for metric in ['mae', 'nmse', 'log_cosh']:
if best_ensembles[metric]['score'] is None or metrics[metric] < best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': '+'.join(available_models), 'weights': weights.copy()}
for metric in ['si_snr', 'snr']:
if best_ensembles[metric]['score'] is None or metrics[metric] > best_ensembles[metric]['score']:
best_ensembles[metric] = {'score': metrics[metric], 'models': '+'.join(available_models), 'weights': weights.copy()}
# Print results
print("\n" + "="*70)
print("BEST ENSEMBLE FOR EACH METRIC")
print("="*70)
for metric in ['mae', 'nmse', 'si_snr', 'snr', 'log_cosh']:
info = best_ensembles[metric]
if info['score'] is not None:
weights_str = ', '.join([f"{k}:{v:.2f}" for k, v in info['weights'].items()])
print(f"{metric.upper():<10}: {info['models']:<25} ({weights_str}) = {info['score']:.4f}")
# Print ALL ensemble results sorted by SNR (most useful metric)
print("\n" + "="*70)
print("ALL ENSEMBLE RESULTS (sorted by SNR, descending)")
print("="*70)
# Sort by SNR descending
sorted_by_snr = sorted(all_ensemble_results, key=lambda x: x['snr'] if x['snr'] is not None else -999, reverse=True)
print(f"{'Models':<30} {'Weights':<40} {'MAE':>8} {'NMSE':>8} {'SNR':>8} {'SI-SNR':>8} {'LogCosh':>8}")
print("-" * 110)
for result in sorted_by_snr[:50]: # Top 50 by SNR
weights_str = '/'.join([f"{result['weights'].get(m, 0):.2f}" for m in sorted(result['weights'].keys())])
models_short = result['models'].replace('+', '/')
print(f"{models_short:<30} {weights_str:<40} {result['mae']:>8.4f} {result['nmse']:>8.2f} {result['snr']:>8.2f} {result['si_snr']:>8.2f} {result['log_cosh']:>8.4f}")
# Also show top 10 by MAE
print("\n" + "="*70)
print("TOP 10 BY MAE (ascending)")
print("="*70)
sorted_by_mae = sorted(all_ensemble_results, key=lambda x: x['mae'] if x['mae'] is not None else 999)
print(f"{'Models':<30} {'Weights':<40} {'MAE':>8} {'SNR':>8}")
print("-" * 90)
for result in sorted_by_mae[:10]:
weights_str = '/'.join([f"{result['weights'].get(m, 0):.2f}" for m in sorted(result['weights'].keys())])
models_short = result['models'].replace('+', '/')
print(f"{models_short:<30} {weights_str:<40} {result['mae']:>8.4f} {result['snr']:>8.2f}")
# 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},
'all_ensemble_results': all_ensemble_results,
}
os.makedirs(os.path.dirname(args.output), exist_ok=True)
with open(args.output, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nResults saved to: {args.output}")
if __name__ == '__main__':
main()