| |
| """ |
| V19 Fine-tuning with Soft-DTW Loss |
| |
| Soft-DTW allows temporal alignment tolerance during training, |
| which can improve performance on signals where exact pixel alignment varies. |
| |
| Strategy: |
| - Start from best V19 checkpoint (epoch 19) |
| - Fine-tune with Soft-DTW loss + MSE loss |
| - Use very low learning rate to preserve existing features |
| - Train for few epochs only (2-5) |
| """ |
|
|
| import os |
| import sys |
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import timm |
| from torch.utils.data import DataLoader, Dataset |
| from pathlib import Path |
| import cv2 |
| from tqdm import tqdm |
| import random |
| from datetime import datetime |
|
|
| |
| try: |
| from tslearn.metrics import soft_dtw |
| from tslearn.backend import Backend |
| HAS_TSLEARN = True |
| except ImportError: |
| HAS_TSLEARN = False |
| print("Warning: tslearn not found, using pure PyTorch Soft-DTW") |
|
|
| try: |
| from torchvision.ops import DeformConv2d |
| HAS_DEFORM_CONV = True |
| except ImportError: |
| HAS_DEFORM_CONV = False |
|
|
| |
| BASELINE_PATH = '/home/azureuser/tmp/hengck23/hengck23-submit-physionet' |
| V19_CHECKPOINT = '/data/ecg-digitization/checkpoints/v19_enhanced_epoch019.pth' |
| TRAIN_DIR = Path('/data/ecg-digitization/data/physionet.org/files/ecg-image-database/1.0.0/synthetic_images') |
| GT_DIR = Path('/data/ecg-digitization/data/physionet.org/files/ecg-image-database/1.0.0/ground_truth') |
| OUTPUT_DIR = Path('/data/ecg-digitization/checkpoints') |
|
|
| |
| DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| LEARNING_RATE = 1e-5 |
| EPOCHS = 3 |
| BATCH_SIZE = 4 |
| DTW_GAMMA = 1.0 |
| LAMBDA_DTW = 0.3 |
|
|
| |
| 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 |
| CROP_HALF_HEIGHT = 250 |
| ROW_HEIGHT = 500 |
| OUTPUT_WIDTH = T1 - T0 |
| ECG_MV_MIN, ECG_MV_MAX = -7.0, 7.0 |
|
|
| |
| class SoftDTWLoss(nn.Module): |
| """ |
| Differentiable Soft-DTW loss for time series alignment. |
| Based on Cuturi & Blondel 2017. |
| """ |
| def __init__(self, gamma=1.0): |
| super().__init__() |
| self.gamma = gamma |
| |
| def pairwise_distances(self, x, y): |
| """Compute pairwise squared Euclidean distances.""" |
| |
| x = x.unsqueeze(2) |
| y = y.unsqueeze(1) |
| return (x - y) ** 2 |
| |
| def forward(self, pred, target): |
| """ |
| Compute Soft-DTW loss. |
| pred: [B, T] predicted signal |
| target: [B, T] ground truth signal |
| """ |
| B, T = pred.shape |
| D = self.pairwise_distances(pred, target) |
| |
| |
| R = torch.full((B, T + 1, T + 1), float('inf'), device=pred.device) |
| R[:, 0, 0] = 0 |
| |
| |
| for i in range(1, T + 1): |
| for j in range(1, T + 1): |
| r_options = torch.stack([ |
| R[:, i-1, j-1], |
| R[:, i-1, j], |
| R[:, i, j-1] |
| ], dim=1) |
| |
| |
| R[:, i, j] = -self.gamma * torch.logsumexp(-r_options / self.gamma, dim=1) + D[:, i-1, j-1] |
| |
| return R[:, T, T].mean() |
|
|
|
|
| class CombinedLoss(nn.Module): |
| """Combined MSE + Soft-DTW loss.""" |
| def __init__(self, lambda_dtw=0.3, gamma=1.0): |
| super().__init__() |
| self.lambda_dtw = lambda_dtw |
| self.mse = nn.MSELoss() |
| self.soft_dtw = SoftDTWLoss(gamma=gamma) |
| |
| def forward(self, pred, target): |
| mse_loss = self.mse(pred, target) |
| |
| |
| stride = 8 |
| pred_ds = pred[:, ::stride] |
| target_ds = target[:, ::stride] |
| dtw_loss = self.soft_dtw(pred_ds, target_ds) |
| |
| total = (1 - self.lambda_dtw) * mse_loss + self.lambda_dtw * dtw_loss |
| return total, mse_loss, dtw_loss |
|
|
|
|
| |
| 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)) |
| 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: |
| out = self.deform_conv(x, self.offset_conv(x)) |
| 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 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 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): |
| 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] |
| for i, (skip_ch, out_ch) in enumerate(zip(enc_channels[:-1][::-1] + [0], decoder_dims)): |
| self.dec_blocks.append(UNetDecoderBlockV19(in_ch, skip_ch, out_ch, use_deform=(i >= 2))) |
| 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, hidden_dim=128, num_layers=2, dropout=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] |
| for block, skip in zip(self.dec_blocks, features[:-1][::-1] + [None]): |
| 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 |
|
|
|
|
| |
| class ECGDataset(Dataset): |
| def __init__(self, train_dir, gt_dir, max_samples=None): |
| self.samples = [] |
| |
| |
| for variant_dir in sorted(train_dir.iterdir()): |
| if not variant_dir.is_dir(): |
| continue |
| for img_path in sorted(variant_dir.glob('*.png')): |
| sample_id = img_path.stem.replace('-0', '-').replace('-', '/') |
| gt_path = gt_dir / f"{img_path.stem.split('-')[0]}.csv" |
| if gt_path.exists(): |
| self.samples.append({ |
| 'image_path': img_path, |
| 'gt_path': gt_path, |
| 'row_idx': int(img_path.stem.split('-')[1]) |
| }) |
| |
| if max_samples: |
| self.samples = self.samples[:max_samples] |
| |
| print(f"Found {len(self.samples)} training samples") |
| |
| def __len__(self): |
| return len(self.samples) |
| |
| def __getitem__(self, idx): |
| sample = self.samples[idx] |
| |
| |
| img = cv2.imread(str(sample['image_path'])) |
| img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) |
| |
| |
| gt_df = np.loadtxt(sample['gt_path'], delimiter=',', skiprows=1) |
| row_idx = sample['row_idx'] |
| |
| |
| y_values = gt_df[:, row_idx] |
| y_normalized = (y_values - y_values.min()) / (y_values.max() - y_values.min() + 1e-6) |
| |
| |
| y_normalized = np.interp( |
| np.linspace(0, 1, OUTPUT_WIDTH), |
| np.linspace(0, 1, len(y_normalized)), |
| y_normalized |
| ) |
| |
| |
| img_tensor = torch.from_numpy(img.astype(np.float32) / 255.0).permute(2, 0, 1) |
| y_tensor = torch.from_numpy(y_normalized.astype(np.float32)) |
| |
| return img_tensor, y_tensor |
|
|
|
|
| class SimpleRowDataset(Dataset): |
| """Simple dataset loading pre-cropped rows.""" |
| def __init__(self, data_dir, max_samples=None): |
| self.samples = [] |
| data_dir = Path(data_dir) |
| |
| for row_dir in sorted(data_dir.glob('row_*')): |
| for img_path in sorted(row_dir.glob('*.npz')): |
| self.samples.append(str(img_path)) |
| |
| if max_samples: |
| random.shuffle(self.samples) |
| self.samples = self.samples[:max_samples] |
| |
| print(f"Found {len(self.samples)} pre-cropped samples") |
| |
| def __len__(self): |
| return len(self.samples) |
| |
| def __getitem__(self, idx): |
| data = np.load(self.samples[idx]) |
| img = torch.from_numpy(data['image'].astype(np.float32) / 255.0) |
| if img.dim() == 3 and img.shape[2] == 3: |
| img = img.permute(2, 0, 1) |
| y = torch.from_numpy(data['y_normalized'].astype(np.float32)) |
| return img, y |
|
|
|
|
| |
| def train_epoch(model, dataloader, optimizer, criterion, device, epoch): |
| model.train() |
| total_loss = 0 |
| total_mse = 0 |
| total_dtw = 0 |
| |
| pbar = tqdm(dataloader, desc=f'Epoch {epoch}') |
| for batch_idx, (images, targets) in enumerate(pbar): |
| images = images.to(device) |
| targets = targets.to(device) |
| |
| optimizer.zero_grad() |
| |
| with torch.amp.autocast('cuda', dtype=torch.float16): |
| pred = model(images, return_aux=False) |
| loss, mse_loss, dtw_loss = criterion(pred, targets) |
| |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| optimizer.step() |
| |
| total_loss += loss.item() |
| total_mse += mse_loss.item() |
| total_dtw += dtw_loss.item() |
| |
| pbar.set_postfix({ |
| 'loss': f'{loss.item():.4f}', |
| 'mse': f'{mse_loss.item():.4f}', |
| 'dtw': f'{dtw_loss.item():.4f}' |
| }) |
| |
| n = len(dataloader) |
| return total_loss / n, total_mse / n, total_dtw / n |
|
|
|
|
| def validate(model, dataloader, device): |
| model.eval() |
| total_snr = 0 |
| n_samples = 0 |
| |
| with torch.no_grad(): |
| for images, targets in tqdm(dataloader, desc='Validation'): |
| images = images.to(device) |
| targets = targets.to(device) |
| |
| with torch.amp.autocast('cuda', dtype=torch.float16): |
| pred = model(images, return_aux=False) |
| |
| |
| mse = ((pred - targets) ** 2).mean(dim=1) |
| signal_power = (targets ** 2).mean(dim=1) |
| snr = 10 * torch.log10(signal_power / (mse + 1e-10)) |
| |
| total_snr += snr.sum().item() |
| n_samples += images.shape[0] |
| |
| return total_snr / n_samples if n_samples > 0 else 0 |
|
|
|
|
| def main(): |
| print(f"Device: {DEVICE}") |
| print(f"Deformable Conv: {HAS_DEFORM_CONV}") |
| |
| |
| model = PerLeadNetV19(pretrained=False) |
| checkpoint = torch.load(V19_CHECKPOINT, map_location='cpu', weights_only=False) |
| model.load_state_dict(checkpoint['model'], strict=True) |
| model.to(DEVICE) |
| print(f"Loaded V19 epoch {checkpoint['epoch']}, SNR={checkpoint['snr']:.2f} dB") |
| |
| |
| criterion = CombinedLoss(lambda_dtw=LAMBDA_DTW, gamma=DTW_GAMMA) |
| |
| |
| optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE, weight_decay=0.01) |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS, eta_min=LEARNING_RATE / 10) |
| |
| |
| precropped_dir = Path('/data/ecg-digitization/precropped_rows') |
| if precropped_dir.exists(): |
| dataset = SimpleRowDataset(precropped_dir, max_samples=1000) |
| else: |
| print("Warning: Pre-cropped data not found. Creating minimal dataset...") |
| |
| dataset = SimpleRowDataset('/data/ecg-digitization/validation_rows', max_samples=500) if Path('/data/ecg-digitization/validation_rows').exists() else None |
| |
| if dataset is None or len(dataset) == 0: |
| print("Error: No training data found!") |
| return |
| |
| |
| n_val = min(100, len(dataset) // 5) |
| n_train = len(dataset) - n_val |
| train_dataset, val_dataset = torch.utils.data.random_split(dataset, [n_train, n_val]) |
| |
| train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True) |
| val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=4, pin_memory=True) |
| |
| print(f"Training samples: {len(train_dataset)}, Validation samples: {len(val_dataset)}") |
| |
| best_snr = checkpoint['snr'] |
| |
| for epoch in range(1, EPOCHS + 1): |
| loss, mse, dtw = train_epoch(model, train_loader, optimizer, criterion, DEVICE, epoch) |
| val_snr = validate(model, val_loader, DEVICE) |
| scheduler.step() |
| |
| print(f"Epoch {epoch}: Loss={loss:.4f}, MSE={mse:.4f}, DTW={dtw:.4f}, Val SNR={val_snr:.2f} dB") |
| |
| |
| ckpt_path = OUTPUT_DIR / f'v19_softdtw_epoch{epoch:03d}.pth' |
| torch.save({ |
| 'epoch': checkpoint['epoch'] + epoch, |
| 'model': model.state_dict(), |
| 'optimizer': optimizer.state_dict(), |
| 'snr': val_snr, |
| 'loss': loss |
| }, ckpt_path) |
| print(f"Saved: {ckpt_path}") |
| |
| if val_snr > best_snr: |
| best_snr = val_snr |
| best_path = OUTPUT_DIR / 'v19_softdtw_best.pth' |
| torch.save({ |
| 'epoch': checkpoint['epoch'] + epoch, |
| 'model': model.state_dict(), |
| 'snr': val_snr |
| }, best_path) |
| print(f"New best! SNR={val_snr:.2f} dB") |
| |
| print(f"\nFine-tuning complete. Best SNR: {best_snr:.2f} dB") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|