"""EEG Annotation Tool adapter for SenuaLab IEDNet-Lite.""" from __future__ import annotations import torch import torch.nn.functional as F from torch import nn from .preprocessing import INTERNATIONAL_10_20_CHANNELS, preprocess_batch as _preprocess MODEL_CHANNELS = INTERNATIONAL_10_20_CHANNELS MODEL_INPUT_SAMPLES = 1000 MODEL_SAMPLING_RATE_HZ = 250.0 MODEL_WINDOW_SECONDS = 4.0 MODEL_ENTRY_CLASS = "IEDNetLite" MODEL_NUM_CLASSES = 2 MODEL_CLASS_LABELS = ["Non-IED", "IED"] MODEL_NON_IED_CLASS_INDEX = 0 MODEL_IED_CLASS_INDICES = [1] MODEL_DESCRIPTION = "SenuaLab patient-independent IEDNet-Lite binary IED detector" MODEL_BATCH_PREPROCESSOR = "preprocess_batch" MODEL_REQUIRED_REFERENCE = "common average (applied by model adapter)" MODEL_REQUIRED_FILTERS = ["1-45 Hz zero-phase Butterworth (applied by model adapter)"] MODEL_REQUIRED_NORMALIZATION = "global four-second window z-score, clipped to [-8,8]" MODEL_INPUT_UNIT = "scale-invariant after window z-score" MODEL_SOURCE_SIGNAL_POLICY = "raw" MODEL_REQUIRES_FULL_WINDOW = True MODEL_REQUIRES_ALL_CHANNELS = True MODEL_DEFAULT_THRESHOLD = 0.6004377007484436 MODEL_DEFAULT_STEP_MS = 500.0 MODEL_DEFAULT_PAD_POLICY = "skip" MODEL_DEFAULT_BATCH_SIZE = 64 MODEL_DEFAULT_BATCH_MEMORY_MB = 64.0 MODEL_VALIDATION_NOTE = "Threshold selected on the vEpiSet validation subjects; not externally validated." MODEL_PREPROCESSING_NOTE = "The adapter applies the exact released 1-45 Hz, common-average, resampling, and global-window z-score pipeline." def preprocess_batch(batch, source_sfreq=None, channel_names=None): return _preprocess( batch, source_sfreq=source_sfreq, target_sfreq=250, target_samples=1000, channel_names=channel_names, ) class SqueezeExcitation(nn.Module): def __init__(self, channels: int, reduction: int = 8): super().__init__() hidden = max(8, channels // reduction) self.net = nn.Sequential( nn.AdaptiveAvgPool1d(1), nn.Conv1d(channels, hidden, 1), nn.GELU(), nn.Conv1d(hidden, channels, 1), nn.Sigmoid(), ) def forward(self, x): return x * self.net(x) class DSResidual(nn.Module): def __init__(self, in_ch: int, out_ch: int, kernel: int, stride: int = 1, dilation: int = 1): super().__init__() pad = dilation * (kernel - 1) // 2 self.body = nn.Sequential( nn.Conv1d(in_ch, in_ch, kernel, stride=stride, padding=pad, dilation=dilation, groups=in_ch, bias=False), nn.BatchNorm1d(in_ch), nn.GELU(), nn.Conv1d(in_ch, out_ch, 1, bias=False), nn.BatchNorm1d(out_ch), SqueezeExcitation(out_ch), ) self.skip = ( nn.Identity() if in_ch == out_ch and stride == 1 else nn.Sequential(nn.Conv1d(in_ch, out_ch, 1, stride=stride, bias=False), nn.BatchNorm1d(out_ch)) ) def forward(self, x): return F.gelu(self.body(x) + self.skip(x)) class IEDNetLite(nn.Module): def __init__(self, num_channels: int = 19, num_classes: int = 2, input_length: int = 1000, subtypes: int = 5): super().__init__() del input_length branches = [] for kernel in (7, 15, 31): branches.append( nn.Sequential( nn.Conv1d(num_channels, 24, kernel, stride=2, padding=kernel // 2, bias=False), nn.BatchNorm1d(24), nn.GELU(), ) ) self.stem = nn.ModuleList(branches) self.backbone = nn.Sequential( DSResidual(72, 72, 9, stride=2), DSResidual(72, 96, 7, stride=2), DSResidual(96, 96, 7, dilation=2), DSResidual(96, 144, 5, stride=2), DSResidual(144, 144, 5, dilation=2), DSResidual(144, 192, 3, stride=2), DSResidual(192, 192, 3, dilation=2), ) self.dropout = nn.Dropout(0.35) self.binary_head = nn.Linear(384, num_classes) self.subtype_head = nn.Linear(384, subtypes) def forward(self, x): x = torch.cat([branch(x) for branch in self.stem], dim=1) x = self.backbone(x) features = self.dropout(torch.cat([x.mean(-1), x.amax(-1)], dim=1)) return self.binary_head(features), self.subtype_head(features)