File size: 15,888 Bytes
2408d2e
 
 
 
 
 
 
737ad15
2408d2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6196ce4
 
 
 
2408d2e
 
6196ce4
2408d2e
 
 
6196ce4
2408d2e
 
 
6196ce4
2408d2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737ad15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2408d2e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
from configs.global_cfg import DATASET_PATH
import os
import pickle
import pandas as pd
from torch.optim.lr_scheduler import _LRScheduler
import math
import torch
from safetensors.torch import save_file
from lifelines.utils import concordance_index
from torchmetrics import Metric, MetricCollection
from torchmetrics.utilities import dim_zero_cat


def get_splits_from_file(ds, all_configs, data_root, split=None):
    sample_modality = all_configs.get('modalities', ['EEG'])[0] # Make sure all modalities have the same number of subjects
    if split is None:
        sample_dir = os.path.join(data_root, DATASET_PATH[ds], sample_modality)
    else:
        sample_dir = os.path.join(data_root, DATASET_PATH[ds], split, sample_modality)

    chns = [f.split('.')[0] for f in os.listdir(sample_dir) if f.endswith('mmap')]
    info_path = os.path.join(sample_dir, f'{chns[0]}.pickle')
    with open(info_path, 'rb') as f:
        sig_info = pickle.load(f)

    subjects = sorted([sub for sub in sig_info['sig_info'].keys()])
    return subjects

def get_datasets(datasets, all_configs, data_root, splits=['train']):
    splits_set = dict()

    for split in splits:
        splits_set[split] = []

    for ds in datasets:
        # Use Physionet 2018 dataset        
        if ds == 'shhs1':
            def get_shhs_lists(shhs_split_file):
                ids = pd.read_csv(os.path.join("data","shhs_meta_data", shhs_split_file), header=None, names=['a'])
                ids = ids['a'].values.tolist()    
                list_files = ["shhs1-20"+id[11:15] for id in ids]
                return list_files
            list_files_val = get_shhs_lists("eval_list.txt")
            list_files_test = get_shhs_lists("test_list.txt")
            
            if 'train' in splits:
                train_subjects = get_splits_from_file(ds, all_configs, data_root, split='train')
                missing_cases = ['shhs1-204709','shhs1-204806']

                train_subjects = set(train_subjects) - set(list_files_val) - set(list_files_test) - set(missing_cases)
                train_subjects = sorted(list(train_subjects)) 
                splits_set['train'].extend([('shhs1',f) for f in train_subjects])
            
            if 'val' in splits:
                val_subjects = get_splits_from_file(ds, all_configs, data_root, split='val')
                val_subjects = set(list_files_val) & set(val_subjects)
                val_subjects = sorted(list(val_subjects))
                splits_set['val'].extend([('shhs1',f) for f in val_subjects])
            
            if 'test' in splits:
                test_subjects = get_splits_from_file(ds, all_configs, data_root, split='test')
                test_subjects = set(list_files_test) & set(test_subjects)            
                test_subjects = sorted(list(test_subjects))
                splits_set['test'].extend([('shhs1',f) for f in test_subjects])
            
        elif ds == 'kiss' or 'cfs' in ds or 'isruc' in ds or 'sof' in ds:
            
            ds_main = 'kiss' if ds in 'kiss' else 'cfs' if 'cfs' in ds else 'isruc' if 'isruc' in ds else 'sof'            
            
            
            if 'train' in splits:
                train_subjects = get_splits_from_file(ds, all_configs, data_root, split='train')
                splits_set['train'].extend([(ds_main,f) for f in train_subjects])
            
            if 'val' in splits:
                val_subjects = get_splits_from_file(ds, all_configs, data_root, split='val')
                splits_set['val'].extend([(ds_main,f) for f in val_subjects])
            
            if 'test' in splits:
                test_subjects = get_splits_from_file(ds, all_configs, data_root, split='test')
                splits_set['test'].extend([(ds_main,f) for f in test_subjects])
        elif ds in ['phy-tr', 'phy-te', 'mros1-val', 'mros1-te','mros2-val','mros2-te','mesa-val','mesa-te']:
            # sample_modality = all_configs.get('modalities', ['EEG'])[0]
            # sample_dir = os.path.join(data_root, DATASET_PATH[ds], sample_modality)
            # chns = [f.split('.')[0] for f in os.listdir(sample_dir) if f.endswith('mmap')]
            # info_path = os.path.join(sample_dir, f'{chns[0]}.pickle')
            # with open(info_path, 'rb') as f:
            #     sig_info = pickle.load(f)

            # all_subjects = sorted([sub for sub in sig_info['sig_info'].keys()])
            if 'train' in splits:
                train_subjects = get_splits_from_file(ds, all_configs, data_root, split=None)
                splits_set['train'].extend([(ds, f) for f in train_subjects])

        elif ds in ['shhs2', 'kiss','kvss','mros1','mros2','mesa']:
            if 'train' in splits:
                train_subjects = get_splits_from_file(ds, all_configs, data_root, split='train')
                splits_set['train'].extend([(ds, f) for f in train_subjects])
            

            # splits_set['train'].extend([(ds, f) for f in all_subjects])

    return splits_set



def export_inference_weights(ckpt_path, model, out_path, metadata=None):
    """
    Strip a Lightning checkpoint down to the weights `model` loads and save them
    as safetensors (no optimizer / scheduler / callback state, no pickle).

    `model` is the inference backbone, e.g. `inference.build_micro_model()` or
    `inference.build_full_night_model()`. Keys are saved without the `model.`
    prefix; `pos_embed` is skipped since it is a fixed sin-cos table rebuilt at
    model construction. Keys absent from the checkpoint (e.g. `head` for a
    pretraining checkpoint) are not saved.

        from inference import build_micro_model
        export_inference_weights("experiments/micro.ckpt", build_micro_model(),
                                 "checkpoints/micro.safetensors", {"task": "micro"})
    """
    ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
    src = ckpt["state_dict"] if "state_dict" in ckpt else ckpt

    weights = {}
    for k in model.state_dict():
        if "pos_embed" in k:
            continue
        if f"model.{k}" in src:
            weights[k] = src[f"model.{k}"].detach().clone().contiguous()
        elif k in src:
            weights[k] = src[k].detach().clone().contiguous()

    meta = {"source_epoch": str(ckpt.get("epoch")), "source_global_step": str(ckpt.get("global_step"))}
    meta.update({k: str(v) for k, v in (metadata or {}).items()})
    save_file(weights, out_path, metadata=meta)
    return weights


def get_layer_decay_param_groups(model, base_lr, weight_decay, layer_decay):
    """
    Create parameter groups with layer-wise learning rate decay.
    Args:
        model: The model whose parameters are grouped.
        base_lr: Base learning rate.
        weight_decay: Weight decay for regularization.
        layer_decay: Layer decay factor (e.g., 0.75).
    Returns:
        A list of parameter groups for the optimizer.
    """
    param_groups = []
    layers = list(model.children())
    num_layers = len(layers)

    # We use named_parameters to filter by name (e.g., A_log, dt, bias)
    # But we need to map them back to their layer index for LR decay.
    for i, layer in enumerate(layers):
        layer_scale = layer_decay ** (num_layers - i - 1)
        curr_lr = base_lr * layer_scale
        
        # We split parameters in each layer into 'decay' and 'no_decay'
        params_to_decay = []
        params_no_decay = []

        for name, param in layer.named_parameters():
            if not param.requires_grad:
                continue
            
            # Check if it's a Mamba-specific parameter or a standard "no-decay" candidate
            # Common Mamba names: A_log, D, dt_proj, x_proj, in_proj, conv1d
            # Standard no-decay: bias, norm weights
            is_ssm_param = any(ssm_name in name for ssm_name in ['A_log', 'D', 'dt', 'B', 'C'])
            is_bias_or_norm = name.endswith("bias") or "norm" in name
            
            if is_ssm_param or is_bias_or_norm:
                params_no_decay.append(param)
            else:
                params_to_decay.append(param)

        # Add groups for this specific layer
        if params_to_decay:
            param_groups.append({
                "params": params_to_decay,
                "lr": curr_lr,
                "weight_decay": weight_decay,
            })
        if params_no_decay:
            param_groups.append({
                "params": params_no_decay,
                "lr": curr_lr,
                "weight_decay": 0.0,
            })

    return param_groups

class WarmupCosineAnnealingLR(_LRScheduler):
    def __init__(self, optimizer, warmup_epochs, max_epochs, eta_min=1e-8, last_epoch=-1):
        """
        Step-based Warmup Cosine Annealing Learning Rate Scheduler.

        Args:
            optimizer: The optimizer for which to schedule the learning rate.
            warmup_epochs: Number of steps for the warmup phase.
            max_epochs: Total number of steps for the scheduler.
            eta_min: Minimum learning rate at the end of the schedule.
            last_epoch: The index of the last step (default: -1).
        """
        self.warmup_epochs = warmup_epochs
        self.max_epochs = max_epochs
        self.eta_min = eta_min
        super(WarmupCosineAnnealingLR, self).__init__(optimizer, last_epoch)

    def get_lr(self):
        if self.last_epoch < self.warmup_epochs:
            # Linear warmup
            return [base_lr * (self.last_epoch + 1) / self.warmup_epochs for base_lr in self.base_lrs]
        else:
            # Cosine annealing
            progress = (self.last_epoch - self.warmup_epochs) / (self.max_epochs - self.warmup_epochs)
            return [self.eta_min + (base_lr - self.eta_min) * (1 + math.cos(math.pi * progress)) / 2 for base_lr in self.base_lrs]



def cox_ph_loss(hazards, event_times, is_event):
    # Sort event times and get corresponding indices for sorting other tensors
    event_times, sorted_idx = event_times.sort(dim=0, descending=True)
   

    # Make gathering robust: if sorted_idx is 1D and hazards/is_event are 2D,
    # use advanced indexing; otherwise fall back to gather which requires
    # the index tensor to have the same number of dimensions as input.
    if sorted_idx.dim() == 1 and hazards.dim() == 2:
        hazards = hazards[sorted_idx]
        is_event = is_event[sorted_idx]
    else:
        hazards = hazards.gather(0, sorted_idx)
        is_event = is_event.gather(0, sorted_idx)

    log_cumulative_hazard = torch.logcumsumexp(hazards.float(), dim=0)

    # Calculate losses for all labels simultaneously
    losses = (hazards - log_cumulative_hazard) * is_event
    losses = -losses  # Negative for maximization

    # Average loss per label
    label_loss = losses.sum(dim=0) / (is_event.sum(dim=0) + 1e-9)  # Avoid division by zero

    # Average across labels
    total_loss = label_loss.mean()

    return total_loss


def c_index_torch(scores, event_times, is_event):    
    return float(concordance_index(event_times, -scores, is_event))



class CIndex(Metric):
    def __init__(self, num_classes=6, **kwargs):
        super().__init__(**kwargs)
        self.add_state("scores", default=[], dist_reduce_fx="cat")
        self.add_state("event_time", default=[], dist_reduce_fx="cat")
        self.add_state("is_event", default=[], dist_reduce_fx="cat")
        self.num_classes = num_classes

    def update(self, score, t, e) -> None:
        self.scores.append(score)
        self.event_time.append(t)
        self.is_event.append(e)

    def compute(self):

        # parse inputs
        # Convert to float32 before numpy() since numpy doesn't support BFloat16
        scores = dim_zero_cat(self.scores).detach().cpu().float().numpy()
        event_time = dim_zero_cat(self.event_time).detach().cpu().float().numpy()
        is_event = dim_zero_cat(self.is_event).detach().cpu().float().numpy()
        
        from lifelines.utils import concordance_index
        cindex = []
        for i in range(self.num_classes):
            try:
                c = concordance_index(event_time[:,i], -scores[:,i], is_event[:,i])
                cindex.append(c)
            except (ZeroDivisionError, ValueError):
                # Not enough admissible pairs (common during sanity check or with small batches)
                # Return 0.5 (random predictor) as default
                cindex.append(0.5)
        return torch.tensor(cindex)
    
class AUROC(Metric):
    def __init__(self, num_classes=6, **kwargs):
        super().__init__(**kwargs)
        self.add_state("scores", default=[], dist_reduce_fx="cat")
        self.add_state("event_time", default=[], dist_reduce_fx="cat")
        self.add_state("is_event", default=[], dist_reduce_fx="cat")
        self.num_classes = num_classes
        self.measure_date = 365 * 6 # at 6 years

    def update(self, score, t, e) -> None:
        self.scores.append(score)
        self.event_time.append(t)
        self.is_event.append(e)

    def compute(self):

        # parse inputs
        # Convert to float32 before numpy() since numpy doesn't support BFloat16
        scores = dim_zero_cat(self.scores).detach().cpu().float().numpy()
        event_time = dim_zero_cat(self.event_time).detach().cpu().float().numpy()
        is_event = dim_zero_cat(self.is_event).detach().cpu().float().numpy()
        
        from sklearn.metrics import roc_auc_score, roc_curve
        # auroc = []
        # # For each disease, calculate AUROC
        # for i in range(self.num_classes):
        #     y_true = (event_time[:,i] <= self.measure_date).astype(int)
        #     y_scores = scores[:,i]
        #     try:
        #         c = roc_auc_score(y_true, y_scores)
        #         auroc.append(c)
        #     except (ZeroDivisionError, ValueError):
        #         # Not enough admissible pairs (common during sanity check or with small batches)
        #         # Return 0.5 (random predictor) as default
        #         auroc.append(0.5)
        # return torch.tensor(auroc)
        auroc = []
        # For each disease, calculate AUROC at 6-year follow-up (time-dependent)
        for i in range(self.num_classes):
            # Filter out zero times (invalid entries)
            valid_mask = event_time[:, i] > 0
            if valid_mask.sum() < 2:
                auroc.append(0.5)
                continue
            
            valid_scores = scores[valid_mask, i]
            valid_times = event_time[valid_mask, i]
            valid_events = is_event[valid_mask, i]
            
            # Time-dependent AUROC at measure_date:
            # - Samples with event before measure_date: y=1
            # - Samples with no event and observed until after measure_date: y=0
            # - Samples with censoring before measure_date: excluded (potentially informative censoring)
            
            # Consider only samples observed until measure_date
            observed_mask = valid_times >= self.measure_date
            # Or event occurred before measure_date
            event_before_mask = valid_events == 1
            
            # Use all samples with valid time info for AUROC
            # y_true: 1 if event before measure_date, 0 otherwise
            y_true = (valid_events == 1) & (valid_times <= self.measure_date)
            y_true = y_true.astype(int)
            y_scores = valid_scores
            
            try:
                # Check if there's variation in labels
                if len(np.unique(y_true)) < 2:
                    auroc.append(0.5)
                else:
                    c = roc_auc_score(y_true, y_scores)
                    auroc.append(c)
            except (ZeroDivisionError, ValueError) as e:
                # Not enough admissible pairs
                auroc.append(0.5)
        return torch.tensor(auroc)