{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"cell-6354bc","cell_type":"code","source":"# 1D CNN — Naive vs Domain-Informed preprocessing (WORKSHOP VERSION, self-contained)\n#\n# Same lightweight 1D CNN architecture, same training procedure, same 600/120-row\n# workshop subset, for THREE preprocessing variants:\n#\n#   1. naive           : raw 20-channel EEG (no montage) + z-score\n#   2. montage_only     : 8-channel bipolar montage        + z-score  (isolates montage)\n#   3. domain_informed  : 8-channel bipolar montage        + mu-law   (montage + encoding)\n#\n# Running montage_only separately from domain_informed lets us tell apart two different\n# effects that are otherwise bundled together: does bipolar montage alone help or hurt,\n# and does adding mu-law encoding on top help or hurt further?\n#\n# We deliberately use a SMALL, simple CNN (not EEGNet) here: with only 600 training rows,\n# a larger architecture (EEGNet has ~675K params, most of them in one wide projection layer)\n# overfits almost immediately and can drown out any real signal from the preprocessing\n# comparison. A smaller model with global average pooling is less prone to memorizing\n# the training set, which gives the naive-vs-domain comparison a fairer chance to show\n# through the noise.\n#\n# No 2-step training, no augmentation, no sample weighting — the workshop subset\n# is already class-balanced (100 rows/class train, 20 rows/class val), so these\n# imbalance-correction techniques have nothing to correct for here.\n#\n# Fully self-contained — no external .py imports.","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-598387","cell_type":"code","source":"import os, time, random\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.optim import AdamW\nfrom torch.optim.lr_scheduler import CosineAnnealingLR\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.metrics import f1_score\n\nIS_KAGGLE = os.path.exists('/kaggle')\nprint(f\"Environment: {'Kaggle' if IS_KAGGLE else 'Local'} | torch {torch.__version__}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-0009d2","cell_type":"code","source":"# ============ Config ============\nSEED        = 42\nWINDOW_LEN  = 10000     # samples at 200 Hz = 50 seconds per window\nNUM_EPOCHS  = 15        # light architecture — fast even with more epochs than the 2D CNN\nBATCH_SIZE  = 32\nLR          = 1e-3\nWEIGHT_DECAY = 1e-4\nPATIENCE    = 5\n\nif IS_KAGGLE:\n    DATA_ROOT       = '/kaggle/input/competitions/hms-harmful-brain-activity-classification'\n    RAW_TRAIN_PATH  = os.path.join(DATA_ROOT, 'train.csv')\n    EEG_DIR         = os.path.join(DATA_ROOT, 'train_eegs')\n    SAMPLE_IDS_PATH = '/kaggle/input/datasets/xiaosufrankhu/midas-summer-academy-wk3-eeg/workshop_sample_ids.csv'\n    EEG_CACHE_DIR   = '/kaggle/working/eeg_cache'\nelse:\n    DATA_ROOT       = os.path.abspath('../')\n    RAW_TRAIN_PATH  = os.path.abspath('../data_raw/train.csv')\n    EEG_DIR         = os.path.join(DATA_ROOT, 'train_eegs')\n    SAMPLE_IDS_PATH = os.path.abspath('../data_raw/workshop_sample_ids.csv')\n    EEG_CACHE_DIR   = os.path.abspath('../eeg_cache')\n\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\ntorch.backends.cudnn.deterministic = True\ntorch.backends.cudnn.benchmark     = False\n\nDEVICE  = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nUSE_AMP = DEVICE.type == 'cuda'\nprint(f\"Device: {DEVICE} | AMP: {USE_AMP}\")\n\n# NOTE: if running on Kaggle and this path doesn't exist, run !ls /kaggle/input\n# and update DATA_ROOT / EEG_DIR to match how the competition data was attached.\nprint(f\"Competition data path exists: {os.path.exists(DATA_ROOT)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-0d7de3","cell_type":"code","source":"# ============ Data loading (workshop subset) ============\n# workshop_sample_ids.csv only carries ID + split; merge with the raw train.csv\n# to recover eeg_label_offset_seconds and vote columns for the 600/120 subset.\n\nraw_df     = pd.read_csv(RAW_TRAIN_PATH)\nsample_ids = pd.read_csv(SAMPLE_IDS_PATH)\n\nmerged = raw_df.merge(\n    sample_ids[[\"eeg_id\", \"eeg_sub_id\", \"split\"]],\n    on=[\"eeg_id\", \"eeg_sub_id\"],\n    how=\"inner\",\n)\ntrain_df = merged[merged[\"split\"] == \"train\"].reset_index(drop=True)\nval_df   = merged[merged[\"split\"] == \"val\"].reset_index(drop=True)\n\nprint(f'Train: {len(train_df):,} rows')\nprint(f'Val   : {len(val_df):,} rows')\nprint(train_df['expert_consensus'].value_counts())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-ccf2f6","cell_type":"code","source":"# ============ Raw EEG caching ============\n# Each {eeg_id}.parquet holds the full raw recording for that eeg_id; several rows\n# in train_df/val_df may share the same eeg_id but use a different label window\n# (eeg_label_offset_seconds). We cache each eeg_id's raw signal as .npy once so\n# repeated windows from the same recording don't re-read the parquet file.\n\nos.makedirs(EEG_CACHE_DIR, exist_ok=True)\n\ndef cache_one_eeg(eeg_id: int, eeg_dir: str, cache_dir: str) -> None:\n    dst = os.path.join(cache_dir, f\"{eeg_id}.npy\")\n    if os.path.exists(dst):\n        return\n    src = os.path.join(eeg_dir, f\"{eeg_id}.parquet\")\n    eeg = pd.read_parquet(src).to_numpy(dtype=np.float32)  # (T, 20)\n    np.save(dst, eeg)\n\nneeded_eeg_ids = set(train_df['eeg_id']).union(val_df['eeg_id'])\nfor eeg_id in needed_eeg_ids:\n    cache_one_eeg(int(eeg_id), EEG_DIR, EEG_CACHE_DIR)\n\nprint(f'Cache ready: {len(needed_eeg_ids)} raw EEG recordings for workshop subset')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-ea2317","cell_type":"code","source":"# ============ Calibrate mu-law scale from real data ============\n# mu-law assumes its input is roughly within [-1, 1] before compression. Rather than\n# hardcoding a scale, we calibrate it empirically from a sample of the actual\n# bipolar-montage training signal, using the 99th percentile of absolute amplitude.\n\ndef _quick_bipolar_sample(eeg_id: int, cache_dir: str) -> np.ndarray:\n    eeg = np.load(os.path.join(cache_dir, f\"{eeg_id}.npy\")).T  # (20, T)\n    return np.stack([\n        eeg[0]  - eeg[5],  eeg[5]  - eeg[7],\n        eeg[0]  - eeg[2],  eeg[2]  - eeg[7],\n        eeg[11] - eeg[13], eeg[13] - eeg[18],\n        eeg[11] - eeg[16], eeg[16] - eeg[18],\n    ], axis=0)\n\nsample_ids_for_calib = list(train_df['eeg_id'].unique())[:100]  # sample from train only, no leakage\nabs_vals = []\nfor eeg_id in sample_ids_for_calib:\n    bipolar = _quick_bipolar_sample(int(eeg_id), EEG_CACHE_DIR)\n    bipolar = np.nan_to_num(bipolar, nan=0.0, posinf=0.0, neginf=0.0)  # raw HMS EEG has real NaN gaps\n    abs_vals.append(np.abs(bipolar).ravel())\nabs_vals = np.concatenate(abs_vals)\n\nn_nonzero = (abs_vals > 0).sum()\nprint(f\"Calibration sample: {len(abs_vals):,} values, {n_nonzero:,} non-zero after NaN cleanup\")\n\nMULAW_SCALE = float(np.percentile(abs_vals, 99))\nassert np.isfinite(MULAW_SCALE) and MULAW_SCALE > 0, f\"Bad MULAW_SCALE: {MULAW_SCALE}\"\nprint(f\"Calibrated MULAW_SCALE (99th percentile of |bipolar signal|): {MULAW_SCALE:.2f}\")\nprint(f\"(for reference, median={np.median(abs_vals):.2f}, max={abs_vals.max():.2f})\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-bf23b6","cell_type":"code","source":"# ============ Preprocessing: naive vs montage-only vs domain-informed ============\n# Raw parquet column order (fixed, from the HMS competition data):\n#   Fp1=0, F3=1, C3=2, P3=3, F7=4, T3=5, T5=6, O1=7, Fz=8, Cz=9, Pz=10,\n#   Fp2=11, F4=12, C4=13, P4=14, F8=15, T4=16, T6=17, O2=18, EKG=19\n\nVOTE_COLS = [\"seizure_vote\", \"lpd_vote\", \"gpd_vote\",\n             \"lrda_vote\",    \"grda_vote\", \"other_vote\"]\n\n\ndef apply_bipolar_montage(window: np.ndarray) -> np.ndarray:\n    \"\"\"Raw 20-channel window (20, T) -> 8 bipolar chains (8, T).\n    Same electrode pairs as a standard double-banana montage used in clinical EEG reading.\n    Note: only 8 of the 20 raw electrodes are used here (Fp1/Fp2, C3/C4, O1/O2, T3/T4) —\n    Fz/Cz/Pz and the other frontal/temporal electrodes are dropped by this montage design.\"\"\"\n    return np.stack([\n        window[0]  - window[5],   # Fp1-T3\n        window[5]  - window[7],   # T3-O1\n        window[0]  - window[2],   # Fp1-C3\n        window[2]  - window[7],   # C3-O1\n        window[11] - window[13],  # Fp2-C4\n        window[13] - window[18],  # C4-O2\n        window[11] - window[16],  # Fp2-T4\n        window[16] - window[18],  # T4-O2\n    ], axis=0)\n\n\ndef zscore_normalize(window: np.ndarray) -> np.ndarray:\n    window = np.nan_to_num(window, nan=0.0, posinf=0.0, neginf=0.0)\n    mean = window.mean(axis=1, keepdims=True)\n    std  = window.std(axis=1, keepdims=True)\n    std  = np.where(std < 1e-6, 1e-6, std)\n    return (window - mean) / std\n\n\ndef mulaw_encode(window: np.ndarray, scale: float, mu: int = 256) -> np.ndarray:\n    \"\"\"Compresses the dynamic range of the signal — large deflections (e.g. seizure\n    spikes) are compressed proportionally less than small background fluctuations,\n    similar to how mu-law encoding preserves perceptual detail in audio.\n\n    `scale` must be calibrated to the actual signal amplitude (see MULAW_SCALE above) —\n    mu-law assumes its input is roughly within [-1, 1]; a scale that's too small pushes\n    most values into the saturated extremes and destroys the detail-preserving property.\n    \"\"\"\n    window = np.nan_to_num(window, nan=0.0, posinf=0.0, neginf=0.0)\n    x = np.clip(window, -scale * 8, scale * 8) / scale\n    return (np.sign(x) * np.log(1 + mu * np.abs(x))\n            / np.log(mu + 1)).astype(np.float32)\n\n\nclass EEGWindowDataset(Dataset):\n    \"\"\"\n    mode=\"naive\"          : all 20 raw channels, z-score normalized\n    mode=\"montage_only\"   : 8-channel bipolar montage, z-score normalized (no mu-law)\n    mode=\"domain_informed\": 8-channel bipolar montage, mu-law encoded\n    Everything else (windowing, labels) is identical across all three modes.\n    \"\"\"\n\n    def __init__(self, metadata_df: pd.DataFrame, eeg_cache_dir: str,\n                 mode: str, window_len: int = WINDOW_LEN):\n        assert mode in (\"naive\", \"montage_only\", \"domain_informed\")\n        self.meta       = metadata_df.reset_index(drop=True)\n        self.cache_dir  = eeg_cache_dir\n        self.mode       = mode\n        self.window_len = window_len\n\n        votes    = self.meta[VOTE_COLS].to_numpy(dtype=np.float32)\n        row_sums = votes.sum(axis=1, keepdims=True)\n        row_sums = np.where(row_sums == 0, 1.0, row_sums)\n        self.soft_labels = votes / row_sums\n        self.hard_labels = self.soft_labels.argmax(1)\n\n    def __len__(self):\n        return len(self.meta)\n\n    def __getitem__(self, idx):\n        row    = self.meta.iloc[idx]\n        eeg_id = int(row[\"eeg_id\"])\n        offset = int(row[\"eeg_label_offset_seconds\"]) * 200   # 200 Hz sampling rate\n\n        eeg = np.load(os.path.join(self.cache_dir, f\"{eeg_id}.npy\"))  # (T, 20)\n        eeg = eeg.T                                                   # (20, T)\n\n        window = eeg[:, offset:offset + self.window_len]\n        if window.shape[1] < self.window_len:\n            pad    = self.window_len - window.shape[1]\n            window = np.pad(window, ((0, 0), (0, pad)), mode=\"constant\")\n\n        if self.mode == \"naive\":\n            window = zscore_normalize(window)                         # (20, T)\n        elif self.mode == \"montage_only\":\n            window = apply_bipolar_montage(window)                    # (8, T)\n            window = zscore_normalize(window)                         # (8, T)\n        else:  # domain_informed\n            window = apply_bipolar_montage(window)                    # (8, T)\n            window = mulaw_encode(window, scale=MULAW_SCALE)          # (8, T)\n\n        img   = torch.from_numpy(window.astype(np.float32))\n        soft  = torch.from_numpy(self.soft_labels[idx])\n        label = int(self.hard_labels[idx])\n        return {\"x\": img, \"soft_y\": soft, \"y\": label}","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-d614de","cell_type":"code","source":"# ============ Model: small 1D CNN (not EEGNet — see note above) ============\n# Plain conv -> BN -> ReLU -> pool x3, then global average pool + linear head.\n# Far fewer parameters than EEGNet (mostly because of global average pooling instead\n# of a full flatten-then-project), which matters a lot when training on only 600 rows.\n#\n# v2 (slimmed down): the original version had a separate proj: Linear(256, 256) layer\n# after global average pooling — a dense 256->256 layer with no dimensionality change,\n# which alone accounted for ~26% of all parameters (65,792 of 249,030) with no real\n# benefit. This version removes it: conv3 outputs feat_dim channels directly, and\n# channel widths are also narrowed (64/128/256 -> 32/64/128). Total params drop from\n# ~249K to ~46-49K (in_channels dependent) — still oversized relative to 600 training\n# rows, but far less so.\n\nclass TemporalCNN1D(nn.Module):\n    def __init__(self, in_channels, feat_dim=128, dropout=0.2):\n        super().__init__()\n        self.conv1 = nn.Conv1d(in_channels, 32, kernel_size=7, padding=3)\n        self.bn1   = nn.BatchNorm1d(32)\n        self.conv2 = nn.Conv1d(32, 64, kernel_size=5, padding=2)\n        self.bn2   = nn.BatchNorm1d(64)\n        self.conv3 = nn.Conv1d(64, feat_dim, kernel_size=3, padding=1)\n        self.bn3   = nn.BatchNorm1d(feat_dim)\n        self.drop  = nn.Dropout(dropout)\n        # no separate proj layer — conv3 already outputs feat_dim channels directly\n\n    def forward(self, x):\n        # x: (B, C, T)\n        x = F.relu(self.bn1(self.conv1(x)))\n        x = self.drop(x)\n        x = F.max_pool1d(x, kernel_size=2)\n\n        x = F.relu(self.bn2(self.conv2(x)))\n        x = self.drop(x)\n        x = F.max_pool1d(x, kernel_size=2)\n\n        x = F.relu(self.bn3(self.conv3(x)))\n        x = self.drop(x)\n        x = F.adaptive_avg_pool1d(x, 1).squeeze(-1)  # global average pool -> (B, feat_dim)\n        return x\n\n\nclass MLPHead(nn.Module):\n    def __init__(self, in_dim, num_classes, hidden=64, dropout=0.1):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Linear(in_dim, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(dropout),\n            nn.Linear(hidden, num_classes),\n        )\n\n    def forward(self, x):\n        return self.net(x)\n\n\nclass EEGClassifier(nn.Module):\n    def __init__(self, in_channels, num_classes=6, feat_dim=128):\n        super().__init__()\n        self.backbone = TemporalCNN1D(in_channels=in_channels, feat_dim=feat_dim)\n        self.head     = MLPHead(in_dim=feat_dim, num_classes=num_classes)\n\n    def forward(self, x):\n        z = self.backbone(x)\n        return self.head(z)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-46f69d","cell_type":"code","source":"# ============ Train / eval (reusable for all runs) ============\ndef run_training(mode: str, in_channels: int):\n    print(f\"\\n{'='*60}\\nRun: {mode.upper()}  (in_channels={in_channels})\\n{'='*60}\")\n\n    random.seed(SEED); np.random.seed(SEED)\n    torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)\n    \n    train_ds = EEGWindowDataset(train_df, EEG_CACHE_DIR, mode=mode)\n    val_ds   = EEGWindowDataset(val_df,   EEG_CACHE_DIR, mode=mode)\n    train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,  num_workers=2, pin_memory=True)\n    val_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n    model = EEGClassifier(in_channels=in_channels, num_classes=6).to(DEVICE)\n    n_params = sum(p.numel() for p in model.parameters())\n    print(f\"Parameters: {n_params:,}\")\n\n    criterion     = nn.KLDivLoss(reduction='batchmean')\n    val_criterion = nn.CrossEntropyLoss()\n    optimizer     = AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\n    scheduler     = CosineAnnealingLR(optimizer, T_max=NUM_EPOCHS)\n    scaler        = torch.amp.GradScaler('cuda', enabled=USE_AMP)\n\n    def validate():\n        model.eval()\n        val_ce, val_kl = 0.0, 0.0\n        all_preds, all_labels = [], []\n        with torch.no_grad():\n            for batch in val_loader:\n                x, y, soft = batch['x'].to(DEVICE), batch['y'].to(DEVICE), batch['soft_y'].to(DEVICE)\n                with torch.amp.autocast('cuda', enabled=USE_AMP):\n                    logits = model(x)\n                val_ce += val_criterion(logits, y).item()\n                val_kl += criterion(F.log_softmax(logits, dim=1), soft).item()\n                all_preds.extend(logits.argmax(1).cpu().tolist())\n                all_labels.extend(y.cpu().tolist())\n        macro_f1  = f1_score(all_labels, all_preds, average='macro', zero_division=0)\n        per_class = f1_score(all_labels, all_preds, average=None,    zero_division=0)\n        return val_kl / len(val_loader), val_ce / len(val_loader), macro_f1, per_class\n\n    history = []\n    # NOTE: \"best\" is defined by val KL (the competition metric), not macro F1 — this\n    # matches the KL-based early stopping we use in the XGBoost notebook, so the three\n    # models are all selected the same way.\n    best_kl, best_f1, best_epoch, wait, best_state = float('inf'), 0.0, 0, 0, None\n\n    for epoch in range(1, NUM_EPOCHS + 1):\n        model.train()\n        t0 = time.time()\n        train_loss = 0.0\n        for batch in train_loader:\n            x, soft = batch['x'].to(DEVICE), batch['soft_y'].to(DEVICE)\n            optimizer.zero_grad()\n            with torch.amp.autocast('cuda', enabled=USE_AMP):\n                logits = model(x)\n                loss   = criterion(F.log_softmax(logits, dim=1), soft)\n            scaler.scale(loss).backward()\n            scaler.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            scaler.step(optimizer)\n            scaler.update()\n            train_loss += loss.item()\n        scheduler.step()\n\n        val_kl, val_ce, macro_f1, _ = validate()\n        avg_train = train_loss / len(train_loader)\n        elapsed   = time.time() - t0\n\n        history.append({'epoch': epoch, 'train_kl': avg_train, 'val_kl': val_kl,\n                         'val_ce': val_ce, 'macro_f1': macro_f1})\n        print(f'Epoch {epoch:03d} | train_kl {avg_train:.4f} | val_kl {val_kl:.4f} | '\n              f'val_ce {val_ce:.4f} | macro_f1 {macro_f1:.4f} | {elapsed:.0f}s')\n\n        if val_kl < best_kl:\n            best_kl, best_f1, best_epoch, wait = val_kl, macro_f1, epoch, 0\n            best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n            torch.save(model.state_dict(), f'best_1d_{mode}.pt')\n        else:\n            wait += 1\n            if wait >= PATIENCE:\n                print(f'Early stopping at epoch {epoch}')\n                break\n\n    if best_state is not None:\n        model.load_state_dict(best_state)\n\n    _, _, _, best_per_class = validate()  # per-class F1 at the KL-best checkpoint\n\n    print(f\"\\n{mode.upper()} complete \\u2014 best val_kl={best_kl:.4f} (macro_f1={best_f1:.4f}) at epoch {best_epoch}\")\n    return history, best_epoch, best_per_class\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-21f81c","cell_type":"code","source":"# ============ Run 1: naive (20-channel raw, z-score) ============\nhistory_naive, naive_best_epoch, naive_per_class = run_training(mode=\"naive\", in_channels=20)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-f88903","cell_type":"code","source":"# ============ Run 2: montage-only (8-channel bipolar montage, z-score, no mu-law) ============\nhistory_montage, montage_best_epoch, montage_per_class = run_training(mode=\"montage_only\", in_channels=8)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-d8a706","cell_type":"code","source":"# ============ Run 3: domain-informed (8-channel bipolar montage, mu-law) ============\nhistory_domain, domain_best_epoch, domain_per_class = run_training(mode=\"domain_informed\", in_channels=8)\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"5f6b155b","cell_type":"markdown","source":"### Final report: domain-informed run (standard format)\n\nThis is the run used as the starting point for Tweak & Compare below.\n","metadata":{}},{"id":"ade38b95","cell_type":"code","source":"# ============ Final report: domain-informed run (standard format) ============\nCLASS_NAMES = [\"Seizure\", \"LPD\", \"GPD\", \"LRDA\", \"GRDA\", \"Other\"]\n\nepochs_d   = [h[\"epoch\"]    for h in history_domain]\ntrain_kl_d = [h[\"train_kl\"] for h in history_domain]\nval_kl_d   = [h[\"val_kl\"]   for h in history_domain]\nval_ce_d   = [h[\"val_ce\"]   for h in history_domain]\nf1_d       = [h[\"macro_f1\"] for h in history_domain]\n\ndomain_val_kl = next(h[\"val_kl\"]   for h in history_domain if h[\"epoch\"] == domain_best_epoch)\ndomain_val_f1 = next(h[\"macro_f1\"] for h in history_domain if h[\"epoch\"] == domain_best_epoch)\n\nprint(f\"Val macro F1     : {domain_val_f1:.4f}\")\nprint(f\"Val KL divergence: {domain_val_kl:.4f}\")\nprint(f\"(random-guess baseline for 6 balanced classes: macro F1 \\u2248 0.167)\")\nprint(\"\\nPer-class F1 (domain-informed, best epoch):\")\nfor name, f in zip(CLASS_NAMES, domain_per_class):\n    print(f\"  {name:<10} {f:.4f}\")\n\nfig, axes = plt.subplots(2, 2, figsize=(13, 9))\n\naxes[0, 0].plot(epochs_d, train_kl_d, label=\"train KL (loss)\")\naxes[0, 0].plot(epochs_d, val_kl_d,   label=\"val KL (loss)\")\naxes[0, 0].axvline(domain_best_epoch, color=\"red\", linestyle=\"--\", label=f\"best={domain_best_epoch}\")\naxes[0, 0].set_xlabel(\"Epoch\"); axes[0, 0].set_ylabel(\"KL Divergence (loss)\")\naxes[0, 0].set_title(\"Train/Val KL loss\"); axes[0, 0].legend(fontsize=8)\n\naxes[0, 1].plot(epochs_d, val_ce_d, color=\"orange\")\naxes[0, 1].axvline(domain_best_epoch, color=\"red\", linestyle=\"--\", label=f\"best={domain_best_epoch}\")\naxes[0, 1].set_xlabel(\"Epoch\"); axes[0, 1].set_ylabel(\"Cross-Entropy\")\naxes[0, 1].set_title(\"Val CE (monitoring)\"); axes[0, 1].legend(fontsize=8)\n\naxes[1, 0].plot(epochs_d, f1_d, color=\"seagreen\")\naxes[1, 0].axhline(1/6, color=\"gray\", linestyle=\"--\", linewidth=1, label=\"random guess\")\naxes[1, 0].axvline(domain_best_epoch, color=\"red\", linestyle=\"--\", label=f\"best={domain_best_epoch}\")\naxes[1, 0].set_xlabel(\"Epoch\"); axes[1, 0].set_ylabel(\"Val Macro F1\")\naxes[1, 0].set_title(\"Val Macro F1\"); axes[1, 0].legend(fontsize=8)\n\naxes[1, 1].bar(CLASS_NAMES, domain_per_class, color=\"steelblue\")\naxes[1, 1].axhline(domain_val_f1, color=\"red\", linestyle=\"--\", label=f\"macro F1 = {domain_val_f1:.3f}\")\naxes[1, 1].set_ylim(0, 1); axes[1, 1].set_ylabel(\"F1\")\naxes[1, 1].set_title(f\"Per-class F1 (val, epoch {domain_best_epoch})\"); axes[1, 1].legend(fontsize=8)\n\nplt.tight_layout()\nplt.savefig(\"cnn1d_domain_workshop_eval.png\", dpi=150)\nplt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"cell-f6cbde","cell_type":"code","source":"# ============ Comparison across preprocessing variants ============\ndef best_metrics(history):\n    best = min(history, key=lambda h: h[\"val_kl\"])  # KL-based selection, matches XGBoost notebook\n    return best[\"macro_f1\"], best[\"epoch\"], best[\"val_kl\"]\n\nnaive_f1,   naive_epoch,   naive_kl   = best_metrics(history_naive)\nmontage_f1, montage_epoch, montage_kl = best_metrics(history_montage)\ndomain_f1,  domain_epoch,  domain_kl  = best_metrics(history_domain)\n\nprint(f\"{'Metric':<20}{'Naive':>12}{'Montage-only':>16}{'Domain-informed':>18}\")\nprint(f\"{'-'*66}\")\nprint(f\"{'Best val KL':<20}{naive_kl:>12.4f}{montage_kl:>16.4f}{domain_kl:>18.4f}\")\nprint(f\"{'Best macro F1':<20}{naive_f1:>12.4f}{montage_f1:>16.4f}{domain_f1:>18.4f}\")\nprint(f\"\\n(random-guess baseline for 6 balanced classes: macro F1 \\u2248 0.167)\")\n\nfig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 4.5))\n\nruns = [\n    ('Naive (raw 20ch, z-score)',            history_naive,   'tab:orange'),\n    ('Montage-only (8ch bipolar, z-score)',  history_montage, 'tab:green'),\n    ('Domain-informed (8ch bipolar, mu-law)', history_domain,  'tab:blue'),\n]\n\nfor label, history, color in runs:\n    ax1.plot([h['epoch'] for h in history], [h['val_kl'] for h in history], label=label, color=color)\nax1.set_xlabel('Epoch'); ax1.set_ylabel('Val KL Divergence')\nax1.set_title('Val KL across preprocessing variants'); ax1.legend(fontsize=8)\n\nfor label, history, color in runs:\n    ax2.plot([h['epoch'] for h in history], [h['macro_f1'] for h in history], label=label, color=color)\nax2.axhline(1/6, color='gray', linestyle='--', linewidth=1, label='random guess')\nax2.set_xlabel('Epoch'); ax2.set_ylabel('Val Macro F1')\nax2.set_title('Val Macro F1 across preprocessing variants'); ax2.legend(fontsize=8)\n\nplt.tight_layout()\nplt.savefig('naive_vs_domain_1d_workshop.png', dpi=150)\nplt.show()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"70149521","cell_type":"markdown","source":"## Tweak & Compare (~20 min)\n\nPick 1-2 changes below, rerun the cell, and log your result in the shared sheet.\nThis reuses `run_training()` from above, so any of the three preprocessing modes\ncan be selected as one of the \"tweaks\".\n\n| Knob | Default | Try |\n|---|---|---|\n| learning rate | 1e-3 | 3e-4 / 3e-3 |\n| dropout | 0.2 | 0.0 / 0.4 |\n| preprocessing mode | domain_informed | naive / montage_only |\n\n**Log your run:** What you changed | Val F1 | Val KL | one-line observation\n","metadata":{}},{"id":"fe531ed8","cell_type":"code","source":"# ============ Tweak & Compare ============\nTWEAK_LR      = 1e-3               # <- change me (try 3e-4 or 3e-3)\nTWEAK_DROPOUT = 0.2                # <- change me (try 0.0 or 0.4)\nTWEAK_MODE    = \"domain_informed\"  # <- change me (\"naive\" / \"montage_only\" / \"domain_informed\")\nTWEAK_IN_CHANNELS = {\"naive\": 20, \"montage_only\": 8, \"domain_informed\": 8}[TWEAK_MODE]\n\n\ndef run_training_tweak(mode, in_channels, lr, dropout):\n    # re-seed locally so this cell gives the same result every time it's rerun on its own,\n    # regardless of how many earlier cells (and how much of the global RNG stream) ran first\n    random.seed(SEED); np.random.seed(SEED)\n    torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)\n\n    train_ds = EEGWindowDataset(train_df, EEG_CACHE_DIR, mode=mode)\n    val_ds   = EEGWindowDataset(val_df,   EEG_CACHE_DIR, mode=mode)\n    train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,  num_workers=2, pin_memory=True)\n    val_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n    model = EEGClassifier(in_channels=in_channels, num_classes=6)\n    model.backbone = TemporalCNN1D(in_channels=in_channels, dropout=dropout)  # apply tweak dropout\n    model = model.to(DEVICE)\n\n    criterion     = nn.KLDivLoss(reduction='batchmean')\n    val_criterion = nn.CrossEntropyLoss()\n    optimizer     = AdamW(model.parameters(), lr=lr, weight_decay=WEIGHT_DECAY)\n    scheduler     = CosineAnnealingLR(optimizer, T_max=NUM_EPOCHS)\n    scaler        = torch.amp.GradScaler('cuda', enabled=USE_AMP)\n\n    best_kl, best_f1, wait = float('inf'), 0.0, 0\n    for epoch in range(1, NUM_EPOCHS + 1):\n        model.train()\n        for batch in train_loader:\n            x, soft = batch['x'].to(DEVICE), batch['soft_y'].to(DEVICE)\n            optimizer.zero_grad()\n            with torch.amp.autocast('cuda', enabled=USE_AMP):\n                logits = model(x)\n                loss   = criterion(F.log_softmax(logits, dim=1), soft)\n            scaler.scale(loss).backward()\n            scaler.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            scaler.step(optimizer)\n            scaler.update()\n        scheduler.step()\n\n        model.eval()\n        val_kl_sum = 0.0\n        all_preds, all_labels = [], []\n        with torch.no_grad():\n            for batch in val_loader:\n                x, y, soft = batch['x'].to(DEVICE), batch['y'].to(DEVICE), batch['soft_y'].to(DEVICE)\n                with torch.amp.autocast('cuda', enabled=USE_AMP):\n                    logits = model(x)\n                val_kl_sum += criterion(F.log_softmax(logits, dim=1), soft).item()\n                all_preds.extend(logits.argmax(1).cpu().tolist())\n                all_labels.extend(y.cpu().tolist())\n        val_kl   = val_kl_sum / len(val_loader)\n        macro_f1 = f1_score(all_labels, all_preds, average='macro', zero_division=0)\n\n        if val_kl < best_kl:\n            best_kl, best_f1, wait = val_kl, macro_f1, 0\n        else:\n            wait += 1\n            if wait >= PATIENCE:\n                break\n\n    return best_f1, best_kl\n\n\ntweak_f1, tweak_kl = run_training_tweak(TWEAK_MODE, TWEAK_IN_CHANNELS, TWEAK_LR, TWEAK_DROPOUT)\nprint(f\"lr={TWEAK_LR} | dropout={TWEAK_DROPOUT} | mode={TWEAK_MODE}\")\nprint(f\"Val macro F1 : {tweak_f1:.4f}\")\nprint(f\"Val KL       : {tweak_kl:.4f}\")\nprint(\"-> log this row in the shared sheet: what you changed / F1 / KL / one-line observation\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"feb2137c","cell_type":"markdown","source":"### Done early? Extend (~10 min)\n\n**Task:** add one more conv block to `TemporalCNN1D` (192 output channels, between the existing\nconv2 and conv3 blocks) and see if it changes val F1 / KL for the domain-informed mode. Watch for\noverfitting — this is a 600-row dataset, so a deeper network isn't automatically better.\n\n**Copy-paste prompt for Claude / ChatGPT / Any AI tool:**\n\n> Here is my 1D CNN backbone for EEG classification: [paste the `TemporalCNN1D` class from\n> earlier in this notebook]. Add one more conv block (Conv1d -> BatchNorm1d -> ReLU -> Dropout ->\n> MaxPool1d) between the existing conv2 and conv3 blocks, with 192 output channels. Keep the\n> global average pooling at the end and the same `feat_dim` output. Call the new class\n> `TemporalCNN1DExtended`.\n>\n> Then write a function `run_training_extended(mode, in_channels)` that builds an\n> `EEGWindowDataset`/`DataLoader` pair for `train_df`/`val_df` (same pattern as the training code\n> earlier in this notebook), trains a model made of your new `TemporalCNN1DExtended` backbone\n> plus an `MLPHead(in_dim=128, num_classes=6)` head, and returns the best val F1 and val KL. Use:\n> ```\n> BATCH_SIZE = 32\n> LR = 1e-3\n> WEIGHT_DECAY = 1e-4\n> NUM_EPOCHS = 15\n> PATIENCE = 5\n> ```\n> Please set `random.seed(42)`, `np.random.seed(42)`, `torch.manual_seed(42)`, and\n> `torch.cuda.manual_seed_all(42)` at the start of the function, so it gives the same result\n> every time it's rerun on its own.\n\nPaste the AI's code into the cell below, run it, then call your new `run_training_extended`\nfunction and compare F1/KL against the baseline above. \n\n**Note**: AI-generated code from a prompt like this typically gets you 80-90% of the way there — it may not run as-is on the first try (missing an import, a shape mismatch, a variable name that doesn't quite match what's already defined in this notebook). That gap is expected, and closing it is part of the exercise: read the error message, check it against the code already defined above, and fix it yourself. If you get stuck for more than ~10 minutes, the reference solution cell below shows one way to close that gap.\n\n","metadata":{}},{"id":"fc24e8fa","cell_type":"code","source":"# ============ Your extended TemporalCNN1D + run_training_extended go here ============\n# Paste the code Claude/ChatGPT gives you in response to the prompt above, e.g.:\n#\n# class TemporalCNN1DExtended(nn.Module):\n#     ...\n#\n# def run_training_extended(mode, in_channels):\n#     ...\n#\n# then call: ext_f1, ext_kl = run_training_extended(\"domain_informed\", 8)\n# and compare F1/KL against the 3-conv-block baseline above.\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"id":"506b73ae","cell_type":"markdown","source":"### Reference solution (only look if you're stuck)\n","metadata":{}},{"id":"49ebb451","cell_type":"code","source":"# ============ Reference solution ============\nclass TemporalCNN1DExtended(nn.Module):\n    \"\"\"Same as the (slimmed) TemporalCNN1D, plus one extra conv block (96 channels)\n    between the existing conv2 and conv3 blocks.\"\"\"\n\n    def __init__(self, in_channels, feat_dim=128, dropout=0.2):\n        super().__init__()\n        self.conv1  = nn.Conv1d(in_channels, 32, kernel_size=7, padding=3)\n        self.bn1    = nn.BatchNorm1d(32)\n        self.conv2  = nn.Conv1d(32, 64, kernel_size=5, padding=2)\n        self.bn2    = nn.BatchNorm1d(64)\n        self.conv2b = nn.Conv1d(64, 96, kernel_size=3, padding=1)   # NEW block\n        self.bn2b   = nn.BatchNorm1d(96)\n        self.conv3  = nn.Conv1d(96, feat_dim, kernel_size=3, padding=1)\n        self.bn3    = nn.BatchNorm1d(feat_dim)\n        self.drop   = nn.Dropout(dropout)\n        # no separate proj layer, same as the slimmed TemporalCNN1D above\n\n    def forward(self, x):\n        x = F.relu(self.bn1(self.conv1(x)))\n        x = self.drop(x)\n        x = F.max_pool1d(x, kernel_size=2)\n\n        x = F.relu(self.bn2(self.conv2(x)))\n        x = self.drop(x)\n        x = F.max_pool1d(x, kernel_size=2)\n\n        x = F.relu(self.bn2b(self.conv2b(x)))   # NEW block, no extra pooling\n        x = self.drop(x)\n\n        x = F.relu(self.bn3(self.conv3(x)))\n        x = self.drop(x)\n        return F.adaptive_avg_pool1d(x, 1).squeeze(-1)\n\n\ndef run_training_extended(mode, in_channels, lr=LR, dropout=0.2):\n    # re-seed locally so this cell gives the same result every time it's rerun on its own,\n    # regardless of how many earlier cells (and how much of the global RNG stream) ran first\n    random.seed(SEED); np.random.seed(SEED)\n    torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)\n\n    train_ds = EEGWindowDataset(train_df, EEG_CACHE_DIR, mode=mode)\n    val_ds   = EEGWindowDataset(val_df,   EEG_CACHE_DIR, mode=mode)\n    train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True,  num_workers=2, pin_memory=True)\n    val_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n    backbone = TemporalCNN1DExtended(in_channels=in_channels, dropout=dropout)\n    head     = MLPHead(in_dim=128, num_classes=6)\n    model    = nn.Sequential(backbone, head).to(DEVICE)\n\n    criterion = nn.KLDivLoss(reduction='batchmean')\n    optimizer = AdamW(model.parameters(), lr=lr, weight_decay=WEIGHT_DECAY)\n    scheduler = CosineAnnealingLR(optimizer, T_max=NUM_EPOCHS)\n    scaler    = torch.amp.GradScaler('cuda', enabled=USE_AMP)\n\n    best_kl, best_f1, wait = float('inf'), 0.0, 0\n    for epoch in range(1, NUM_EPOCHS + 1):\n        model.train()\n        for batch in train_loader:\n            x, soft = batch['x'].to(DEVICE), batch['soft_y'].to(DEVICE)\n            optimizer.zero_grad()\n            with torch.amp.autocast('cuda', enabled=USE_AMP):\n                logits = model(x)\n                loss   = criterion(F.log_softmax(logits, dim=1), soft)\n            scaler.scale(loss).backward()\n            scaler.unscale_(optimizer)\n            nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n            scaler.step(optimizer)\n            scaler.update()\n        scheduler.step()\n\n        model.eval()\n        val_kl_sum = 0.0\n        all_preds, all_labels = [], []\n        with torch.no_grad():\n            for batch in val_loader:\n                x, y, soft = batch['x'].to(DEVICE), batch['y'].to(DEVICE), batch['soft_y'].to(DEVICE)\n                with torch.amp.autocast('cuda', enabled=USE_AMP):\n                    logits = model(x)\n                val_kl_sum += criterion(F.log_softmax(logits, dim=1), soft).item()\n                all_preds.extend(logits.argmax(1).cpu().tolist())\n                all_labels.extend(y.cpu().tolist())\n        val_kl   = val_kl_sum / len(val_loader)\n        macro_f1 = f1_score(all_labels, all_preds, average='macro', zero_division=0)\n\n        if val_kl < best_kl:\n            best_kl, best_f1, wait = val_kl, macro_f1, 0\n        else:\n            wait += 1\n            if wait >= PATIENCE:\n                break\n\n    return best_f1, best_kl\n\n\next_f1, ext_kl = run_training_extended(\"domain_informed\", 8)\nprint(f\"3-conv-block baseline (from Metrics above)  vs  4-conv-block extended\")\nprint(f\"Extended -> Val F1: {ext_f1:.4f} | Val KL: {ext_kl:.4f}\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}