{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.11"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":14774,"databundleVersionId":875431,"isSourceIdPinned":false}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"efd0f625","cell_type":"markdown","source":"# Step 1 (FINAL) — Stage 1 Complete Pipeline\n\n**Objective**: Produce the final Stage 1 binary DR classifier, ready to feed into the cascade.\n\n**Pipeline** (all-in-one notebook):\n1. **Phase 0** — Fundus preprocessing (crop_retina + circular_mask + Ben Graham)\n2. **Phase A** — MobileNetV3Small baseline training (head + fine-tune, IMG=300)\n3. **Phase B** — AMCA backward layer search (probe AUC + FLOPs per candidate cut)\n4. **Phase C** — Multi-δ Pareto analysis (δ ∈ {0.5%, 1%, 2%, 5%})\n5. **Phase D** — Long fine-tune (15 ep cosine) on best pruned candidate\n6. **Output** — `stage1_final.weights.h5` ready for Step 3 Cascade\n\n**Hardware**: Kaggle T4×2, FP16 mixed precision, XLA disabled\n**Estimated time**: ~90 min\n\n**Backbone justification**: MobileNetV3Small chosen from Step 1 benchmark + multi-seed validation (ΔAUC vs DenseNet121 = 0.004, negligible, but 1.8× faster → aligns with thesis focus on inference speed)\n","metadata":{}},{"id":"b32bcfcb","cell_type":"code","source":"# ===== CELL 1 : Imports + hardware =====\nimport os, gc, time, json, math, random, tempfile\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\nfrom tensorflow.keras import layers, Model, Input\nfrom tensorflow.keras.callbacks import EarlyStopping, LearningRateScheduler\nfrom tensorflow.keras import mixed_precision\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import roc_auc_score\nfrom PIL import Image\nfrom concurrent.futures import ThreadPoolExecutor\nimport matplotlib.pyplot as plt\nimport cv2\n\nos.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'\ntf.config.optimizer.set_jit(False)  # XLA OFF (FP16+MirroredStrategy bug)\n\ngpus = tf.config.list_physical_devices('GPU')\nprint(f'Physical GPUs: {len(gpus)}')\nfor g in gpus: print(' ', g)\nif len(gpus) == 0:\n    raise RuntimeError('No GPU detected — aborting (avoid silent CPU fallback).')\nfor g in gpus: tf.config.experimental.set_memory_growth(g, True)\n\nstrategy = tf.distribute.MirroredStrategy() if len(gpus) >= 2 else tf.distribute.get_strategy()\nN_REPLICAS = strategy.num_replicas_in_sync\nmixed_precision.set_global_policy('mixed_float16')\nprint(f'Replicas: {N_REPLICAS}   TF {tf.__version__}')\n\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); tf.random.set_seed(SEED)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"91567749","cell_type":"code","source":"# ===== CELL 2 : Fundus preprocessing (ARCHITECTURE.md Phase 0) =====\ndef crop_retina(img, tol=7):\n    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) if img.ndim == 3 else img\n    mask = gray > tol\n    if not mask.any(): return img\n    y_idx = np.where(mask.any(axis=1))[0]; x_idx = np.where(mask.any(axis=0))[0]\n    if len(y_idx) < 20 or len(x_idx) < 20: return img\n    return img[y_idx[0]:y_idx[-1]+1, x_idx[0]:x_idx[-1]+1]\n\ndef circular_mask(img, radius_ratio=1.0):\n    h, w = img.shape[:2]\n    cy, cx = h//2, w//2\n    r = int(min(h, w) * radius_ratio / 2)\n    Y, X = np.ogrid[:h, :w]\n    mask = (X - cx)**2 + (Y - cy)**2 <= r*r\n    out = img.copy(); out[~mask] = 0\n    return out\n\ndef ben_graham(img, sigma=None, scale=4, bias=128):\n    if sigma is None: sigma = max(img.shape[:2]) / 30.0\n    blurred = cv2.GaussianBlur(img, (0, 0), sigma)\n    out = cv2.addWeighted(img, scale, blurred, -scale, bias)\n    return np.clip(out, 0, 255).astype(np.uint8)\n\ndef preprocess_fundus(img_rgb, target_size):\n    img = crop_retina(img_rgb, tol=7)\n    img = cv2.resize(img, (target_size, target_size), interpolation=cv2.INTER_AREA)\n    img = circular_mask(img, 1.0)\n    img = ben_graham(img, sigma=target_size/30.0, scale=4, bias=128)\n    return img\n\nprint('Fundus preprocessing functions ready.')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"8b8c66ab","cell_type":"code","source":"# ===== CELL 3 : Visualize preprocessing (sanity check) =====\nDATA_DIR = '/kaggle/input/aptos2019-blindness-detection'\nif not os.path.exists(f'{DATA_DIR}/train.csv'):\n    DATA_DIR = '/kaggle/input/competitions/aptos2019-blindness-detection'\n\ndf_all = pd.read_csv(f'{DATA_DIR}/train.csv')\ndf_samp = df_all.sample(4, random_state=42).reset_index(drop=True)\n\nfig, axes = plt.subplots(4, 4, figsize=(15, 15))\nfor i, row in df_samp.iterrows():\n    raw = np.asarray(Image.open(f'{DATA_DIR}/train_images/{row.id_code}.png').convert('RGB'))\n    c = crop_retina(raw, tol=7)\n    r = cv2.resize(c, (300, 300), interpolation=cv2.INTER_AREA)\n    m = circular_mask(r, 1.0)\n    b = ben_graham(m, sigma=10, scale=4, bias=128)\n    for j, (img, title) in enumerate([(raw,f'RAW g{row.diagnosis}'),\n                                       (c,f'cropped {c.shape[:2]}'),\n                                       (m,'resized+mask'),\n                                       (b,'Ben Graham')]):\n        axes[i,j].imshow(img); axes[i,j].set_title(title, fontsize=10); axes[i,j].axis('off')\nplt.tight_layout()\nplt.savefig('/kaggle/working/step1_preprocessing_demo.png', dpi=90)\nplt.show()\nprint('Saved: step1_preprocessing_demo.png')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"f5b440a6","cell_type":"code","source":"# ===== CELL 4 : Load APTOS + preload preprocessed images @ 300 =====\nIMG_SIZE = 300\n\ndf_all['label'] = (df_all['diagnosis'] > 0).astype(np.int32)\ndf_all['path']  = df_all['id_code'].apply(lambda x: f'{DATA_DIR}/train_images/{x}.png')\nprint(f'Total APTOS: {len(df_all)}   no_DR={(df_all.label==0).sum()}   DR={(df_all.label==1).sum()}')\n\ndef _load_pp(p):\n    return preprocess_fundus(np.asarray(Image.open(p).convert('RGB')), IMG_SIZE)\n\nt0 = time.time()\npaths = df_all['path'].tolist()\nX_all = np.empty((len(paths), IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)\nwith ThreadPoolExecutor(max_workers=8) as ex:\n    for i, a in enumerate(ex.map(_load_pp, paths)): X_all[i] = a\ny_all = df_all['label'].values.astype(np.int32)\nprint(f'Preloaded {len(X_all)} preprocessed images in {time.time()-t0:.1f}s  ({X_all.nbytes/1e6:.1f} MB)')\n\ntr_idx, va_idx = train_test_split(np.arange(len(X_all)), test_size=0.20,\n                                   stratify=y_all, random_state=SEED)\nX_train, X_val = X_all[tr_idx], X_all[va_idx]\ny_train, y_val = y_all[tr_idx], y_all[va_idx]\nprint(f'Train {len(X_train)}  Val {len(X_val)}')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"ac49ea70","cell_type":"code","source":"# ===== CELL 5 : Dataset pipeline =====\nBATCH = 32\nGLOBAL_BATCH = BATCH * N_REPLICAS\n\naug = tf.keras.Sequential([\n    layers.RandomFlip('horizontal_and_vertical'),\n    layers.RandomRotation(0.10),\n    layers.RandomZoom(0.10),\n    layers.RandomContrast(0.05),\n], name='aug')\n\ndef make_ds(X, y, shuffle=False, augment=False):\n    ds = tf.data.Dataset.from_tensor_slices((X, y))\n    if shuffle: ds = ds.shuffle(len(X), seed=SEED, reshuffle_each_iteration=True)\n    if augment: ds = ds.map(lambda x,y:(aug(x,training=True),y), num_parallel_calls=tf.data.AUTOTUNE)\n    return ds.batch(GLOBAL_BATCH).prefetch(tf.data.AUTOTUNE)\n\ntrain_ds = make_ds(X_train, y_train, shuffle=True, augment=True)\nval_ds   = make_ds(X_val,   y_val)\nprint('Datasets ready.')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"5de2f73b","cell_type":"code","source":"# ===== CELL 6 : Build MobileNetV3Small baseline (Phase A) =====\nfrom tensorflow.keras.applications import MobileNetV3Small\nfrom tensorflow.keras.applications.mobilenet_v3 import preprocess_input as mnv3_prep\n\ndef build_full_model(backbone_fn, include_top_dense=True):\n    base = backbone_fn(input_shape=(IMG_SIZE, IMG_SIZE, 3), include_top=False, weights='imagenet')\n    inp = Input(shape=(IMG_SIZE, IMG_SIZE, 3), dtype=tf.uint8)\n    x = layers.Lambda(lambda t: tf.cast(t, tf.float32))(inp)\n    x = layers.Lambda(mnv3_prep)(x)\n    x = base(x, training=False)\n    x = layers.GlobalAveragePooling2D()(x)\n    x = layers.Dropout(0.3)(x)\n    out = layers.Dense(1, activation='sigmoid', dtype='float32')(x)\n    return Model(inp, out, name='MNV3S_stage1'), base\n\nEPOCHS_HEAD, EPOCHS_FT = 4, 6\nLR_HEAD = 1e-3 * N_REPLICAS\nLR_FT   = 1e-5 * N_REPLICAS\n\nwith strategy.scope():\n    full_model, base = build_full_model(MobileNetV3Small)\n    base.trainable = False\n    full_model.compile(optimizer=tf.keras.optimizers.Adam(LR_HEAD),\n                       loss='binary_crossentropy',\n                       metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])\nfull_model.summary(line_length=100)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"93815d80","cell_type":"code","source":"# ===== CELL 7 : Phase A — Train baseline (head + fine-tune) =====\nprint('\\n=== Phase 1 : head-only ===')\nh1 = full_model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS_HEAD, verbose=1)\n\nprint('\\n=== Phase 2 : fine-tune (BN frozen) ===')\nwith strategy.scope():\n    base.trainable = True\n    for l in base.layers:\n        if isinstance(l, layers.BatchNormalization):\n            l.trainable = False\n    full_model.compile(optimizer=tf.keras.optimizers.Adam(LR_FT),\n                       loss='binary_crossentropy',\n                       metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])\nes = EarlyStopping(monitor='val_auc', mode='max', patience=3, restore_best_weights=True)\nh2 = full_model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS_FT,\n                    callbacks=[es], verbose=1)\n\n# Final baseline metrics\nprobs = full_model.predict(X_val, batch_size=GLOBAL_BATCH, verbose=0).ravel()\nBASELINE_AUC = float(roc_auc_score(y_val, probs))\nBASELINE_ACC = float(((probs>=0.5).astype(int) == y_val).mean())\nprint(f'\\n>>> BASELINE: val_acc={BASELINE_ACC:.4f}  val_auc={BASELINE_AUC:.4f}')\n\n# Save baseline weights\nfull_model.save_weights('/kaggle/working/stage1_baseline.weights.h5')\nprint('Saved: stage1_baseline.weights.h5')\n\n# Baseline history for curves\nH_baseline = {}\nfor k in ('accuracy','val_accuracy','loss','val_loss','auc','val_auc'):\n    H_baseline[k] = list(h1.history.get(k,[])) + list(h2.history.get(k,[]))\nH_baseline['phase1_len'] = EPOCHS_HEAD\n","metadata":{},"outputs":[],"execution_count":null},{"id":"e37b26fd","cell_type":"code","source":"# ===== CELL 8 : Compute baseline FLOPs and size =====\ndef compute_flops(model):\n    '''Approximate FLOPs using TF profiler.'''\n    from tensorflow.python.framework.convert_to_constants import convert_variables_to_constants_v2\n    concrete = tf.function(lambda x: model(x)).get_concrete_function(\n        tf.TensorSpec([1] + list(model.input.shape[1:]), model.input.dtype))\n    frozen_func = convert_variables_to_constants_v2(concrete)\n    run_meta = tf.compat.v1.RunMetadata()\n    opts = tf.compat.v1.profiler.ProfileOptionBuilder.float_operation()\n    opts['output'] = 'none'\n    flops = tf.compat.v1.profiler.profile(\n        graph=frozen_func.graph, run_meta=run_meta,\n        cmd='op', options=opts)\n    return flops.total_float_ops if flops is not None else 0\n\ndef model_size_mb(model):\n    path = tempfile.mktemp(suffix='.weights.h5')\n    model.save_weights(path)\n    size = os.path.getsize(path) / 1e6\n    os.remove(path)\n    return size\n\ntry:\n    BASELINE_FLOPS = compute_flops(full_model)\nexcept Exception as e:\n    print('FLOPs compute fallback:', e)\n    BASELINE_FLOPS = 0\n\nBASELINE_SIZE  = model_size_mb(full_model)\nBASELINE_PARAMS = full_model.count_params()\nprint(f'Baseline: {BASELINE_PARAMS:,} params  {BASELINE_SIZE:.2f} MB  {BASELINE_FLOPS/1e6:.1f} MFLOPs')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"67444cde","cell_type":"code","source":"# ===== CELL 9 : Phase B — Detect AMCA anchor layers (robust) =====\n# Anchors = Add layers (residual connections) — robust to TF layer-name changes\n\nanchors = []\nfor i, l in enumerate(base.layers):\n    if isinstance(l, layers.Add):\n        anchors.append((i, l.name))\n\nprint(f'Found {len(anchors)} Add anchors (candidate cut points):')\nfor idx, name in anchors: print(f'  [{idx}] {name}')\n\n# Order from DEEP to SHALLOW (backward search)\nanchors_backward = list(reversed(anchors))\nprint(f'\\nBackward search order (deep → shallow): {len(anchors_backward)} candidates')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"c1a7a31c","cell_type":"code","source":"# ===== CELL 10 : Phase B — AMCA backward search =====\ndef build_cut_model(cut_idx):\n    '''Build model truncated at cut_idx layer of base, with head.'''\n    with strategy.scope():\n        base2 = MobileNetV3Small(input_shape=(IMG_SIZE,IMG_SIZE,3), include_top=False, weights='imagenet')\n        cut_output = base2.layers[cut_idx].output\n        base2_cut = Model(base2.input, cut_output, name=f'base_cut{cut_idx}')\n        inp = Input(shape=(IMG_SIZE,IMG_SIZE,3), dtype=tf.uint8)\n        x = layers.Lambda(lambda t: tf.cast(t, tf.float32))(inp)\n        x = layers.Lambda(mnv3_prep)(x)\n        x = base2_cut(x, training=False)\n        x = layers.GlobalAveragePooling2D()(x)\n        x = layers.Dropout(0.3)(x)\n        out = layers.Dense(1, activation='sigmoid', dtype='float32')(x)\n        m = Model(inp, out, name=f'MNV3S_cut{cut_idx}')\n    return m, base2_cut\n\ndef quick_probe(cut_idx, probe_epochs=4):\n    '''Quick head-only training to probe transferability at this cut.'''\n    tf.keras.backend.clear_session(); gc.collect()\n    m, bcut = build_cut_model(cut_idx)\n    with strategy.scope():\n        bcut.trainable = False\n        m.compile(optimizer=tf.keras.optimizers.Adam(LR_HEAD),\n                  loss='binary_crossentropy', metrics=[tf.keras.metrics.AUC(name='auc')])\n    m.fit(train_ds, validation_data=val_ds, epochs=probe_epochs, verbose=0)\n    probs = m.predict(X_val, batch_size=GLOBAL_BATCH, verbose=0).ravel()\n    auc = float(roc_auc_score(y_val, probs))\n    try:\n        flops = compute_flops(m)\n    except:\n        flops = 0\n    size = model_size_mb(m); params = m.count_params()\n    return {'cut_idx':cut_idx, 'probe_auc':auc, 'flops':flops,\n            'size_mb':size, 'params':params}\n\nprint('Starting AMCA backward search...')\namca_results = []\nfor i, (cut_idx, name) in enumerate(anchors_backward):\n    t0 = time.time()\n    try:\n        r = quick_probe(cut_idx)\n        r['anchor_name'] = name\n        r['elapsed'] = time.time() - t0\n        amca_results.append(r)\n        print(f'[{i+1}/{len(anchors_backward)}] cut={cut_idx:3d} ({name})  '\n              f'AUC={r[\"probe_auc\"]:.4f}  FLOPs={r[\"flops\"]/1e6:.1f}M  '\n              f'size={r[\"size_mb\"]:.1f}MB  ({r[\"elapsed\"]:.0f}s)')\n    except Exception as e:\n        import traceback; traceback.print_exc()\n        print(f'FAILED at cut={cut_idx}: {e}')\n\ndf_amca = pd.DataFrame(amca_results)\ndf_amca.to_csv('/kaggle/working/step1_amca_search.csv', index=False)\nprint(f'\\nSaved AMCA search results to step1_amca_search.csv ({len(df_amca)} rows)')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"f123d2e4","cell_type":"code","source":"# ===== CELL 11 : Phase C — Multi-δ Pareto analysis (FIXED schema) =====\nDELTAS = [0.005, 0.01, 0.02, 0.05]\npareto_rows = []\nfor delta in DELTAS:\n    thr_auc = BASELINE_AUC - delta\n    valid = df_amca[df_amca['probe_auc'] >= thr_auc]\n    base_row = {\n        'delta': float(delta), 'cut_idx': np.nan, 'anchor_name': None,\n        'probe_auc': np.nan, 'flops_m': np.nan, 'size_mb': np.nan,\n        'params': np.nan, 'reduction_pct': np.nan,\n    }\n    if len(valid) == 0:\n        pareto_rows.append(base_row); continue\n    best = valid.nsmallest(1, 'flops').iloc[0]\n    red = (1 - best['flops'] / BASELINE_FLOPS) * 100 if BASELINE_FLOPS > 0 else 0\n    base_row.update({\n        'cut_idx': int(best['cut_idx']), 'anchor_name': best['anchor_name'],\n        'probe_auc': float(best['probe_auc']),\n        'flops_m': float(best['flops'] / 1e6), 'size_mb': float(best['size_mb']),\n        'params': int(best['params']), 'reduction_pct': float(red),\n    })\n    pareto_rows.append(base_row)\n\ndf_pareto = pd.DataFrame(pareto_rows)\nprint('=== Multi-δ Pareto ===')\nprint(df_pareto.to_string(index=False, float_format='%.4f'))\ndf_pareto.to_csv('/kaggle/working/step1_pareto.csv', index=False)\n\nfig, ax = plt.subplots(figsize=(10, 6))\nax.scatter(df_amca['flops']/1e6, df_amca['probe_auc'], s=50, alpha=0.5, label='AMCA candidates')\nfor _, row in df_pareto.iterrows():\n    if pd.notna(row['flops_m']):\n        ax.scatter(row['flops_m'], row['probe_auc'], s=200, marker='*',\n                   label=f'δ={row[\"delta\"]:.3f}  cut={int(row[\"cut_idx\"])}')\nax.axhline(BASELINE_AUC, color='red', linestyle='--', label=f'Baseline AUC={BASELINE_AUC:.4f}')\nax.axvline(BASELINE_FLOPS/1e6, color='red', linestyle=':', alpha=0.5, label=f'Baseline FLOPs={BASELINE_FLOPS/1e6:.0f}M')\nax.set_xlabel('FLOPs (M)'); ax.set_ylabel('Probe AUC')\nax.set_title('AMCA Backward Pruning — Pareto Front')\nax.legend(loc='lower right', fontsize=9); ax.grid(alpha=0.3)\nplt.tight_layout()\nplt.savefig('/kaggle/working/step1_pareto.png', dpi=100, bbox_inches='tight')\nplt.show()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"2de199c8","cell_type":"code","source":"# ===== CELL 12 : Phase D — Long fine-tune on chosen pruned model (dual Pareto) =====\n# NaN-safe + dual run (conservative + aggressive) for a defensible Pareto in the thesis.\nimport pandas as pd, shutil\n\n# --- Préférence : δ petit (haute accuracy), cascade de fallback si NaN ---\nDELTA_PREFS = [0.01, 0.02, 0.05, 0.005]\nchosen_row = None\nfor d in DELTA_PREFS:\n    c = df_pareto[df_pareto['delta'] == d]\n    if len(c) == 0:\n        continue\n    r = c.iloc[0]\n    if pd.notna(r['cut_idx']):\n        chosen_row = r\n        CHOSEN_DELTA = d\n        break\n\nif chosen_row is None:\n    raise RuntimeError('Aucun cut valide dans df_pareto — vérifier AMCA search.')\n\nCHOSEN_CUT = int(chosen_row['cut_idx'])\nprint(f'✓ Conservative pick → δ={CHOSEN_DELTA}  cut_idx={CHOSEN_CUT}  '\n      f'anchor={chosen_row[\"anchor_name\"]}  '\n      f'probe_AUC={chosen_row[\"probe_auc\"]:.4f}  '\n      f'FLOPs_reduction={chosen_row[\"reduction_pct\"]:.1f}%')\n\n\n# ---------- Fonction réutilisable : long fine-tune d'un cut ----------\ndef long_finetune(cut_idx, tag):\n    \"\"\"Fine-tune long (15 ep cosine) sur le modèle coupé à cut_idx.\"\"\"\n    tf.keras.backend.clear_session(); gc.collect()\n    model, base = build_cut_model(cut_idx)\n\n    EPOCHS_LONG = 15\n    LR_MAX = 1e-4 * N_REPLICAS\n    LR_MIN = 1e-6 * N_REPLICAS\n\n    def cosine_lr(epoch):\n        return LR_MIN + 0.5 * (LR_MAX - LR_MIN) * (1 + math.cos(math.pi * epoch / EPOCHS_LONG))\n\n    with strategy.scope():\n        base.trainable = True\n        for l in base.layers:\n            if isinstance(l, layers.BatchNormalization):\n                l.trainable = False\n        model.compile(optimizer=tf.keras.optimizers.Adam(LR_MAX),\n                      loss='binary_crossentropy',\n                      metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])\n\n    es = EarlyStopping(monitor='val_auc', mode='max', patience=5, restore_best_weights=True)\n    lr_sched = LearningRateScheduler(cosine_lr, verbose=0)\n\n    print(f'\\n=== Long fine-tune [{tag}]  cut={cut_idx}  (15 ep cosine) ===')\n    hist = model.fit(train_ds, validation_data=val_ds, epochs=EPOCHS_LONG,\n                     callbacks=[es, lr_sched], verbose=1)\n\n    probs = model.predict(X_val, batch_size=GLOBAL_BATCH, verbose=0).ravel()\n    auc = float(roc_auc_score(y_val, probs))\n    acc = float(((probs >= 0.5).astype(int) == y_val).mean())\n    try:\n        flops = compute_flops(model)\n    except Exception:\n        flops = 0\n    size = model_size_mb(model)\n    params = model.count_params()\n\n    print(f'>>> [{tag}] val_acc={acc:.4f}  val_auc={auc:.4f}  '\n          f'params={params:,}  size={size:.2f} MB  FLOPs={flops/1e6:.1f} M')\n    print(f'    ΔAUC vs baseline = {auc-BASELINE_AUC:+.4f}   '\n          f'FLOPs reduction = {(1-flops/BASELINE_FLOPS)*100:.1f}%')\n\n    wpath = f'/kaggle/working/stage1_{tag}.weights.h5'\n    model.save_weights(wpath)\n    print(f'    Saved → {wpath}')\n    return {\n        'tag': tag, 'cut_idx': cut_idx, 'val_acc': acc, 'val_auc': auc,\n        'params': int(params), 'size_MB': float(size), 'flops': int(flops),\n        'history': {k: [float(x) for x in v] for k, v in hist.history.items()},\n        'weights_path': wpath,\n    }\n\n\n# ---------- Run 1 : cut conservateur (accuracy-priority) ----------\nres_cons = long_finetune(CHOSEN_CUT, tag='conservative')\n\n\n# ---------- Run 2 : cut agressif (compression-priority) ----------\naggressive_row = None\nfor d in [0.05, 0.02, 0.01, 0.005]:\n    c = df_pareto[df_pareto['delta'] == d]\n    if len(c) and pd.notna(c.iloc[0]['cut_idx']):\n        r = c.iloc[0]\n        if int(r['cut_idx']) != CHOSEN_CUT:\n            aggressive_row = r\n            break\n\nif aggressive_row is not None:\n    AGGRESSIVE_CUT = int(aggressive_row['cut_idx'])\n    print(f'\\n→ Second run sur cut agressif {AGGRESSIVE_CUT} '\n          f'(probe_AUC={aggressive_row[\"probe_auc\"]:.4f}, '\n          f'{aggressive_row[\"reduction_pct\"]:.1f}% FLOPs reduction)')\n    res_aggr = long_finetune(AGGRESSIVE_CUT, tag='aggressive')\nelse:\n    print('\\n(Pas de second cut distinct — skip aggressive run)')\n    res_aggr = None\n    AGGRESSIVE_CUT = None\n\n\n# ---------- Choix final pour le cascade ----------\n# Règle thèse : l'agressif gagne s'il garde AUC ≥ baseline - 1.5% ET FLOPs ≥ 40%\ndef pick_winner(a, b):\n    if b is None:\n        return a, 'conservative'\n    red_b = (1 - b['flops']/BASELINE_FLOPS)*100\n    if b['val_auc'] >= BASELINE_AUC - 0.015 and red_b >= 40:\n        return b, 'aggressive'\n    return a, 'conservative'\n\nwinner, winner_tag = pick_winner(res_cons, res_aggr)\nFINAL_AUC    = winner['val_auc']\nFINAL_ACC    = winner['val_acc']\nFINAL_FLOPS  = winner['flops']\nFINAL_SIZE   = winner['size_MB']\nFINAL_PARAMS = winner['params']\nFINAL_CUT    = winner['cut_idx']\n\n# Copie les poids du winner vers le nom canonique utilisé par Step 3\nshutil.copy(winner['weights_path'], '/kaggle/working/stage1_final.weights.h5')\nprint(f'\\n>>> WINNER = {winner_tag} (cut {FINAL_CUT})')\nprint(f'    val_acc={FINAL_ACC:.4f}  val_auc={FINAL_AUC:.4f}  '\n      f'FLOPs={FINAL_FLOPS/1e6:.1f} M  size={FINAL_SIZE:.2f} MB')\nprint(f'    Canonical weights → /kaggle/working/stage1_final.weights.h5')\n\n# Shim pour compat historique avec cellules qui attendaient `h_long.history`\nclass _HistShim:\n    def __init__(self, hd): self.history = hd\nh_long = _HistShim(winner['history'])\n\n# Stocker les 2 runs pour Cell 13 et Cell 14\nLONG_FT_RUNS = {'conservative': res_cons, 'aggressive': res_aggr, 'winner_tag': winner_tag}\n","metadata":{},"outputs":[],"execution_count":null},{"id":"2d98b27b","cell_type":"code","source":"# ===== CELL 13 : Training curves — Baseline + Conservative + Aggressive =====\n# Affiche Accuracy / Loss / AUC pour les 3 phases d'entraînement (grille 3×3).\n\nH_cons = LONG_FT_RUNS['conservative']['history']\nH_aggr = LONG_FT_RUNS['aggressive']['history'] if LONG_FT_RUNS['aggressive'] is not None else None\n\nrows = [('Baseline (Phase A)', H_baseline, 'tab:blue', 'tab:orange',\n        H_baseline['phase1_len'])]\nrows.append((f\"Conservative long-FT — cut {LONG_FT_RUNS['conservative']['cut_idx']}\",\n             H_cons, 'tab:green', 'tab:red', None))\nif H_aggr is not None:\n    rows.append((f\"Aggressive long-FT — cut {LONG_FT_RUNS['aggressive']['cut_idx']}\",\n                 H_aggr, 'tab:purple', 'tab:olive', None))\n\nmetrics = [('accuracy', 'Accuracy', (0.5, 1.0)),\n           ('loss',     'Loss',     None),\n           ('auc',      'AUC',      (0.5, 1.0))]\n\nn_rows = len(rows)\nfig, axes = plt.subplots(n_rows, 3, figsize=(16, 4*n_rows))\nif n_rows == 1:\n    axes = axes.reshape(1, -1)\n\nfor r, (title, H, ctr, cval, vline) in enumerate(rows):\n    for j, (mkey, ylabel, ylim) in enumerate(metrics):\n        ax = axes[r, j]\n        if mkey not in H or len(H[mkey]) == 0:\n            ax.set_visible(False); continue\n        ep = np.arange(1, len(H[mkey])+1)\n        ax.plot(ep, H[mkey], label='train', color=ctr, linewidth=2)\n        ax.plot(ep, H['val_'+mkey], label='val', color=cval,\n                linewidth=2, linestyle='--')\n        if vline is not None:\n            ax.axvline(vline+0.5, color='gray', linestyle=':', alpha=0.5,\n                       label='phase boundary')\n        ax.set_title(f'{title} — {ylabel}', fontsize=10)\n        ax.set_xlabel('Epoch'); ax.set_ylabel(ylabel)\n        if ylim is not None: ax.set_ylim(*ylim)\n        ax.legend(fontsize=8); ax.grid(alpha=0.3)\n\nplt.suptitle('Stage 1 — Training curves across phases', fontsize=13, y=1.00)\nplt.tight_layout()\nplt.savefig('/kaggle/working/step1_training_curves.png', dpi=110, bbox_inches='tight')\nplt.show()\nprint('Saved: step1_training_curves.png')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"80c3b5a0","cell_type":"code","source":"# ===== CELL 13.5 : Model comparison — bar charts + final Pareto =====\n# Visualise: AUC, Accuracy, FLOPs, Size, Params — pour baseline + 2 runs pruned\nmodels = [\n    ('Baseline\\nMNV3S (FULL)',\n     BASELINE_AUC, BASELINE_ACC, BASELINE_FLOPS/1e6, BASELINE_SIZE, BASELINE_PARAMS,\n     'tab:blue'),\n]\nr_c = LONG_FT_RUNS['conservative']\nmodels.append((f'Conservative\\ncut {r_c[\"cut_idx\"]}',\n               r_c['val_auc'], r_c['val_acc'], r_c['flops']/1e6, r_c['size_MB'], r_c['params'],\n               'tab:green'))\nif LONG_FT_RUNS['aggressive'] is not None:\n    r_a = LONG_FT_RUNS['aggressive']\n    models.append((f'Aggressive\\ncut {r_a[\"cut_idx\"]}',\n                   r_a['val_auc'], r_a['val_acc'], r_a['flops']/1e6, r_a['size_MB'], r_a['params'],\n                   'tab:purple'))\n\nnames  = [m[0] for m in models]\naucs   = [m[1] for m in models]\naccs   = [m[2] for m in models]\nflopsm = [m[3] for m in models]\nsizes  = [m[4] for m in models]\nparams = [m[5]/1e6 for m in models]  # M params\ncolors = [m[6] for m in models]\n\n# Highlight le winner\nwinner_tag = LONG_FT_RUNS['winner_tag']\nwinner_idx = 1 if winner_tag == 'conservative' else 2 if winner_tag == 'aggressive' else None\n\nfig = plt.figure(figsize=(16, 10))\ngs = fig.add_gridspec(2, 3, hspace=0.45, wspace=0.35)\n\ndef _bar(ax, values, title, ylabel, fmt='{:.3f}', annotate_pct=False, base_val=None):\n    bars = ax.bar(names, values, color=colors, edgecolor='black', linewidth=0.8)\n    if winner_idx is not None:\n        bars[winner_idx].set_edgecolor('gold')\n        bars[winner_idx].set_linewidth(3)\n    for b, v in zip(bars, values):\n        label = fmt.format(v)\n        if annotate_pct and base_val not in (None, 0):\n            label += f'\\n({(v-base_val)/base_val*100:+.1f}%)'\n        ax.text(b.get_x()+b.get_width()/2, b.get_height(),\n                label, ha='center', va='bottom', fontsize=9)\n    ax.set_title(title, fontsize=11, fontweight='bold')\n    ax.set_ylabel(ylabel); ax.grid(axis='y', alpha=0.3)\n\n# Row 0 : accuracy metrics\n_bar(fig.add_subplot(gs[0,0]), aucs, 'Validation AUC', 'AUC',\n     fmt='{:.4f}', annotate_pct=False)\n_bar(fig.add_subplot(gs[0,1]), accs, 'Validation Accuracy', 'Accuracy',\n     fmt='{:.4f}', annotate_pct=False)\n\n# Final Pareto (AUC vs FLOPs)\nax3 = fig.add_subplot(gs[0,2])\nfor n, a, f, c in zip(names, aucs, flopsm, colors):\n    ax3.scatter(f, a, s=280, color=c, edgecolor='black', linewidth=1.2,\n                label=n.replace('\\n',' '))\n    ax3.annotate(n.split('\\n')[0], (f, a),\n                 textcoords='offset points', xytext=(8, -4), fontsize=9)\nif winner_idx is not None:\n    ax3.scatter(flopsm[winner_idx], aucs[winner_idx],\n                s=520, facecolor='none', edgecolor='gold',\n                linewidth=3, label='winner')\nax3.axhline(BASELINE_AUC - 0.015, color='red', linestyle=':',\n            label='AUC floor (baseline − 1.5%)')\nax3.axvline(BASELINE_FLOPS*0.6/1e6, color='orange', linestyle=':',\n            label='FLOPs target (−40%)')\nax3.set_xlabel('FLOPs (M)'); ax3.set_ylabel('val AUC')\nax3.set_title('Final Pareto — AUC vs FLOPs', fontsize=11, fontweight='bold')\nax3.grid(alpha=0.3); ax3.legend(loc='lower right', fontsize=8)\n\n# Row 1 : compression metrics (baseline ratio)\n_bar(fig.add_subplot(gs[1,0]), flopsm, 'FLOPs', 'MFLOPs',\n     fmt='{:.1f}', annotate_pct=True, base_val=BASELINE_FLOPS/1e6)\n_bar(fig.add_subplot(gs[1,1]), sizes, 'Model size', 'MB',\n     fmt='{:.2f}', annotate_pct=True, base_val=BASELINE_SIZE)\n_bar(fig.add_subplot(gs[1,2]), params, 'Parameters', 'Millions',\n     fmt='{:.3f}', annotate_pct=True, base_val=BASELINE_PARAMS/1e6)\n\nplt.suptitle(f'Stage 1 — Model comparison (winner: {winner_tag})',\n             fontsize=13, fontweight='bold', y=1.00)\nplt.savefig('/kaggle/working/step1_model_comparison.png', dpi=110, bbox_inches='tight')\nplt.show()\nprint('Saved: step1_model_comparison.png')\n","metadata":{},"outputs":[],"execution_count":null},{"id":"7d10028e","cell_type":"code","source":"# ===== CELL 14 : Final summary + save everything =====\ndef _run_dict(r):\n    \"\"\"Flatten a LONG_FT_RUNS entry into a JSON-safe summary block.\"\"\"\n    if r is None: return None\n    return {\n        'cut_idx': r['cut_idx'],\n        'val_acc': r['val_acc'],\n        'val_auc': r['val_auc'],\n        'params': int(r['params']),\n        'size_mb': r['size_MB'],\n        'flops_m': r['flops'] / 1e6,\n        'flops_reduction_pct': (1 - r['flops'] / BASELINE_FLOPS) * 100 if BASELINE_FLOPS > 0 else 0,\n        'auc_delta_vs_baseline': r['val_auc'] - BASELINE_AUC,\n        'weights_path': r['weights_path'],\n    }\n\nsummary = {\n    'stage': 1,\n    'backbone': 'MobileNetV3Small',\n    'img_size': IMG_SIZE,\n    'preprocessing': 'crop_retina + circular_mask + ben_graham',\n\n    'baseline': {\n        'val_acc': BASELINE_ACC, 'val_auc': BASELINE_AUC,\n        'params': int(BASELINE_PARAMS), 'size_mb': BASELINE_SIZE,\n        'flops_m': BASELINE_FLOPS / 1e6,\n    },\n\n    # Les deux runs long fine-tune (cut conservative + cut aggressive)\n    'long_ft_runs': {\n        'conservative': _run_dict(LONG_FT_RUNS['conservative']),\n        'aggressive':   _run_dict(LONG_FT_RUNS['aggressive']),\n        'winner_tag':   LONG_FT_RUNS['winner_tag'],\n    },\n\n    # Le winner promoted en final_pruned (consommé par Step 3 Cascade)\n    'final_pruned': {\n        'tag': LONG_FT_RUNS['winner_tag'],\n        'val_acc': FINAL_ACC, 'val_auc': FINAL_AUC,\n        'params': int(FINAL_PARAMS), 'size_mb': FINAL_SIZE,\n        'flops_m': FINAL_FLOPS / 1e6,\n        'cut_idx': FINAL_CUT,\n        'flops_reduction_pct': (1 - FINAL_FLOPS / BASELINE_FLOPS) * 100 if BASELINE_FLOPS > 0 else 0,\n        'auc_delta_vs_baseline': FINAL_AUC - BASELINE_AUC,\n    },\n    'weights_file': '/kaggle/working/stage1_final.weights.h5',\n}\n\nwith open('/kaggle/working/stage1_summary.json', 'w') as f:\n    json.dump(summary, f, indent=2)\n\nprint('='*70)\nprint('STEP 1 FINAL SUMMARY')\nprint('='*70)\nprint(json.dumps(summary, indent=2))\nprint('\\nFiles produced:')\nfor fname in ['stage1_baseline.weights.h5',\n              'stage1_conservative.weights.h5', 'stage1_aggressive.weights.h5',\n              'stage1_final.weights.h5',\n              'stage1_summary.json',\n              'step1_amca_search.csv', 'step1_pareto.csv',\n              'step1_pareto.png', 'step1_training_curves.png',\n              'step1_model_comparison.png',\n              'step1_preprocessing_demo.png']:\n    path = f'/kaggle/working/{fname}'\n    if os.path.exists(path):\n        print(f'  {fname}  ({os.path.getsize(path)/1e6:.2f} MB)')\n","metadata":{},"outputs":[],"execution_count":null}]}