{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","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"},"kaggle":{"accelerator":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ===== STAGE 16 SESSION: FOLD 3, CUSTOM CNN + EFFICIENTNETB0 + MOBILENETV2 (loads locked fold file, never regenerates) =====\n# First touch on fold 3. This session ESTABLISHES fold 3's split sizes and class weight span.\n# A later ResNet50-only session for fold 3 will need to assert reproduction against whatever prints here.\n\n!pip install -q tensorflow==2.19.0\n\nimport os\nos.environ['TF_USE_LEGACY_KERAS'] = '1'\n\nimport random\nimport numpy as np\nimport pandas as pd\nimport tensorflow as tf\n\nSEED = 42\nos.environ['PYTHONHASHSEED'] = str(SEED)\nos.environ['TF_DETERMINISTIC_OPS'] = '1'\nrandom.seed(SEED); np.random.seed(SEED); tf.random.set_seed(SEED)\nprint(f\"Seed {SEED} set, TF {tf.__version__}, tf.keras module: {tf.keras.__name__}\")\nassert 'tf_keras' in tf.keras.__name__, \"STOP: Keras 3 active, not legacy. Restart before continuing.\"\n\nfrom tensorflow.keras.preprocessing.image import ImageDataGenerator\nfrom tensorflow.keras.models import Sequential, load_model\nfrom tensorflow.keras.layers import (Input, Conv2D, BatchNormalization, MaxPooling2D,\n                                      Dropout, GlobalAveragePooling2D, Dense)\nfrom tensorflow.keras.applications import EfficientNetB0, MobileNetV2\nfrom tensorflow.keras.applications.efficientnet import preprocess_input as eff_pre\nfrom tensorflow.keras.applications.mobilenet_v2 import preprocess_input as mob_pre\nfrom tensorflow.keras.optimizers import Adam\nfrom tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, CSVLogger\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (cohen_kappa_score, roc_auc_score, accuracy_score,\n                              f1_score, recall_score, confusion_matrix)\n\n# ---------- LOAD LOCKED FOLD ASSIGNMENTS, DO NOT REGENERATE ----------\nFOLD_CSV_PATH = '/kaggle/input/datasets/asivakumarnair/drcvfoldassignments/dr_cv_fold_assignments.csv'\n\nGRADES, NUM_CLASSES = ['0','1','2','3','4'], 5\nIMG_SIZE, BATCH_SIZE = 224, 32\nPHASE1_EPOCHS, PHASE1_LR, PHASE2_LR, CUSTOM_LR, EARLYSTOP_PAT = 10, 1e-3, 1e-5, 1e-3, 7\nAUG = dict(rotation_range=20, width_shift_range=0.1, height_shift_range=0.1,\n           horizontal_flip=True, zoom_range=0.1)\nCURRENT_FOLD = 3\n\npooled = pd.read_csv(FOLD_CSV_PATH)\npooled['grade'] = pooled['grade'].astype(str)\nprint(f\"Loaded {len(pooled):,} rows (expect 9,068)\")\nassert len(pooled) == 9068, \"Row count mismatch, wrong file or corrupted upload\"\nassert pooled['fold'].nunique() == 5, \"Fold file does not have 5 folds\"\n\ntest_df   = pooled[pooled.fold == CURRENT_FOLD].reset_index(drop=True)\nremainder = pooled[pooled.fold != CURRENT_FOLD].reset_index(drop=True)\n\ndef safe_split(df, label_col, test_size, rs, tag=\"\"):\n    try:\n        return train_test_split(df, test_size=test_size, stratify=df[label_col], random_state=rs)\n    except ValueError as e:\n        print(f\"WARNING [{tag}]: stratified split failed, unstratified fallback. {e}\")\n        return train_test_split(df, test_size=test_size, random_state=rs)\n\ndef split_group_level_inner(df, label_col='grade', val_frac=0.15, rs=SEED, tag=\"\"):\n    grouped = df.groupby('group_id')[label_col].agg(lambda s: s.value_counts().index[0]).reset_index()\n    g_tr, g_va = safe_split(grouped, label_col, val_frac, rs, tag=tag)\n    pick = lambda ids: df[df['group_id'].isin(ids['group_id'])]\n    return pick(g_tr), pick(g_va)\n\ntrain_parts, val_parts = [], []\nfor src in ['aptos', 'eyepacs', 'messidor']:\n    sub = remainder[remainder.source == src]\n    tr_s, va_s = split_group_level_inner(sub, tag=f\"fold{CURRENT_FOLD}-{src}-inner\")\n    train_parts.append(tr_s); val_parts.append(va_s)\ntrain_df = pd.concat(train_parts, ignore_index=True)\nval_df   = pd.concat(val_parts, ignore_index=True)\n\nprint(f\"\\nFold {CURRENT_FOLD}: Train {len(train_df):,} ({len(train_df)/len(pooled)*100:.1f}%) | \"\n      f\"Val {len(val_df):,} ({len(val_df)/len(pooled)*100:.1f}%) | \"\n      f\"Test {len(test_df):,} ({len(test_df)/len(pooled)*100:.1f}%)\")\nprint(\"^^ RECORD THESE NUMBERS. The fold 3 ResNet50 session will assert reproduction against them.\")\n\nfor src in ['aptos', 'eyepacs', 'messidor']:\n    tr_g = set(train_df[train_df.source==src]['group_id'])\n    va_g = set(val_df[val_df.source==src]['group_id'])\n    te_g = set(test_df[test_df.source==src]['group_id'])\n    ok = tr_g.isdisjoint(va_g) and tr_g.isdisjoint(te_g) and va_g.isdisjoint(te_g)\n    print(f\"  {src}: train/val/test group-disjoint = {ok}\")\n    assert ok, f\"LEAKAGE in fold {CURRENT_FOLD}, source {src}\"\nprint(f\"Fold {CURRENT_FOLD} leakage check: PASS\")\n\ncls = np.array(GRADES)\ncw = compute_class_weight('balanced', classes=cls, y=train_df['grade'])\nCLASS_WEIGHT = {i: w for i, w in enumerate(cw)}\nprint(f\"Fold {CURRENT_FOLD} class_weight (fresh from this fold's train set):\",\n      {c: round(w,3) for c,w in zip(cls,cw)}, f\"| span {cw.max()/cw.min():.1f}x\")\nprint(\"^^ RECORD THIS SPAN too, same reproduction requirement.\")\n\ndef make_gens(preprocess_fn):\n    if preprocess_fn is None:\n        train_idg = ImageDataGenerator(rescale=1./255, **AUG)\n        eval_idg  = ImageDataGenerator(rescale=1./255)\n    else:\n        train_idg = ImageDataGenerator(preprocessing_function=preprocess_fn, **AUG)\n        eval_idg  = ImageDataGenerator(preprocessing_function=preprocess_fn)\n    common = dict(x_col='image_path', y_col='grade', target_size=(IMG_SIZE,IMG_SIZE), batch_size=BATCH_SIZE,\n                  class_mode='categorical', classes=GRADES, color_mode='rgb')\n    return (train_idg.flow_from_dataframe(train_df, shuffle=True, seed=SEED, **common),\n            eval_idg.flow_from_dataframe(val_df, shuffle=False, **common),\n            eval_idg.flow_from_dataframe(test_df, shuffle=False, **common))\n\ndef build_custom_cnn(num_classes=5, shape=(224,224,3)):\n    return Sequential([\n        Input(shape=shape),\n        Conv2D(32,3,padding='same',activation='relu'), BatchNormalization(),\n        Conv2D(32,3,padding='same',activation='relu'), BatchNormalization(),\n        MaxPooling2D(), Dropout(0.25),\n        Conv2D(64,3,padding='same',activation='relu'), BatchNormalization(),\n        Conv2D(64,3,padding='same',activation='relu'), BatchNormalization(),\n        MaxPooling2D(), Dropout(0.25),\n        Conv2D(128,3,padding='same',activation='relu'), BatchNormalization(),\n        Conv2D(128,3,padding='same',activation='relu'), BatchNormalization(),\n        MaxPooling2D(), Dropout(0.25),\n        GlobalAveragePooling2D(),\n        Dense(256,activation='relu'), Dropout(0.5),\n        Dense(num_classes,activation='softmax')\n    ])\n\ndef build_pretrained(base_class, num_classes=5, shape=(224,224,3)):\n    base = base_class(include_top=False, weights='imagenet', input_shape=shape)\n    model = Sequential([base, GlobalAveragePooling2D(), Dense(256,activation='relu'),\n                        Dropout(0.3), Dense(num_classes,activation='softmax')])\n    return model, base\n\ndef macro_specificity(y_true, y_pred, n_classes=NUM_CLASSES):\n    cm = confusion_matrix(y_true, y_pred, labels=range(n_classes))\n    total = cm.sum(); specs = []\n    for i in range(n_classes):\n        tp = cm[i,i]; fn = cm[i,:].sum()-tp; fp = cm[:,i].sum()-tp\n        tn = total-tp-fn-fp\n        specs.append(tn/(tn+fp) if (tn+fp) > 0 else np.nan)\n    return np.nanmean(specs)\n\ndef full_test_metrics(model, te_gen):\n    y_prob = model.predict(te_gen, verbose=0)\n    y_true = np.asarray(te_gen.classes)\n    y_pred = y_prob.argmax(axis=1)\n    try:\n        auc = roc_auc_score(np.eye(NUM_CLASSES)[y_true], y_prob, average='macro', multi_class='ovr')\n    except ValueError:\n        auc = np.nan\n    return dict(qwk=cohen_kappa_score(y_true, y_pred, weights='quadratic'), macro_auc=auc,\n                accuracy=accuracy_score(y_true, y_pred),\n                macro_f1=f1_score(y_true, y_pred, average='macro'),\n                macro_sensitivity=recall_score(y_true, y_pred, average='macro'),\n                macro_specificity=macro_specificity(y_true, y_pred), n_test=len(y_true)), y_true, y_pred, y_prob\n\ndef train_and_evaluate(arch_code, build_fn, preprocess_fn, is_pretrained):\n    tr, va, te = make_gens(preprocess_fn)\n    auc_path = f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_aucbest.keras'\n    acc_path = f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_accbest.keras'\n\n    if is_pretrained:\n        model, base = build_fn()\n        base.trainable = False\n        model.compile(Adam(PHASE1_LR), 'categorical_crossentropy',\n                      metrics=['accuracy', tf.keras.metrics.AUC(name='auc', multi_label=False)])\n        print(f\"\\n===== Fold {CURRENT_FOLD}, {arch_code}: PHASE 1 (head only, {PHASE1_EPOCHS} epochs) =====\")\n        model.fit(tr, validation_data=va, epochs=PHASE1_EPOCHS, class_weight=CLASS_WEIGHT, verbose=1,\n                  callbacks=[CSVLogger(f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_log.csv', append=False)])\n        base.trainable = True\n        model.compile(Adam(PHASE2_LR), 'categorical_crossentropy',\n                      metrics=['accuracy', tf.keras.metrics.AUC(name='auc', multi_label=False)])\n        print(f\"\\n===== Fold {CURRENT_FOLD}, {arch_code}: PHASE 2 (full fine-tune, dual checkpoint) =====\")\n        hist = model.fit(tr, validation_data=va, epochs=60, class_weight=CLASS_WEIGHT, verbose=1,\n                  callbacks=[EarlyStopping(monitor='val_auc', mode='max', patience=EARLYSTOP_PAT, restore_best_weights=True),\n                             ModelCheckpoint(auc_path, monitor='val_auc', mode='max', save_best_only=True),\n                             ModelCheckpoint(acc_path, monitor='val_accuracy', mode='max', save_best_only=True),\n                             CSVLogger(f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_log.csv', append=True)])\n    else:\n        model = build_fn()\n        model.compile(Adam(CUSTOM_LR), 'categorical_crossentropy',\n                      metrics=['accuracy', tf.keras.metrics.AUC(name='auc', multi_label=False)])\n        print(f\"\\n===== Fold {CURRENT_FOLD}, {arch_code}: single phase, dual checkpoint =====\")\n        hist = model.fit(tr, validation_data=va, epochs=60, class_weight=CLASS_WEIGHT, verbose=1,\n                  callbacks=[EarlyStopping(monitor='val_auc', mode='max', patience=EARLYSTOP_PAT, restore_best_weights=True),\n                             ModelCheckpoint(auc_path, monitor='val_auc', mode='max', save_best_only=True),\n                             ModelCheckpoint(acc_path, monitor='val_accuracy', mode='max', save_best_only=True),\n                             CSVLogger(f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_log.csv', append=False)])\n\n    live_metrics, y_true, y_pred, y_prob = full_test_metrics(model, te)\n    print(f\"\\nLive (in-memory) test metrics, auc-selected: {live_metrics}\")\n    np.savez(f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_preds.npz',\n             y_true=y_true, y_pred=y_pred, y_prob=y_prob,\n             source=test_df['source'].values, group_id=test_df['group_id'].values)\n\n    reloaded_auc_model = load_model(auc_path)\n    reloaded_auc_metrics, _, _, _ = full_test_metrics(reloaded_auc_model, te)\n    match = abs(live_metrics['macro_auc'] - reloaded_auc_metrics['macro_auc']) < 1e-3\n    print(f\"Reloaded auc-checkpoint macro_auc={reloaded_auc_metrics['macro_auc']:.4f} vs live={live_metrics['macro_auc']:.4f}, match={match}\")\n    assert match, \"AUC-CHECKPOINT MISMATCH, invalid provenance, discard this result\"\n    del reloaded_auc_model\n\n    best_val_acc_seen = max(hist.history['val_accuracy'])\n    reloaded_acc_model = load_model(acc_path)\n    reloaded_val_acc = reloaded_acc_model.evaluate(va, verbose=0)[1]\n    match_acc = abs(best_val_acc_seen - reloaded_val_acc) < 1e-3\n    print(f\"Accuracy-checkpoint provenance: best val_accuracy seen during training={best_val_acc_seen:.4f} \"\n          f\"vs reloaded val_accuracy={reloaded_val_acc:.4f}, match={match_acc}\")\n    assert match_acc, \"ACCURACY-CHECKPOINT MISMATCH, invalid provenance, discard this result\"\n    reloaded_acc_metrics, _, _, _ = full_test_metrics(reloaded_acc_model, te)\n    del reloaded_acc_model\n    del model; import gc; gc.collect(); tf.keras.backend.clear_session()\n\n    same_checkpoint = abs(reloaded_auc_metrics['macro_auc'] - reloaded_acc_metrics['macro_auc']) < 1e-6\n    print(f\"Monitors agreed on the same epoch: {same_checkpoint}\")\n\n    rows = [\n        {'fold': CURRENT_FOLD, 'arch': arch_code, 'selected_by': 'val_auc', **reloaded_auc_metrics},\n        {'fold': CURRENT_FOLD, 'arch': arch_code, 'selected_by': 'val_accuracy', **reloaded_acc_metrics},\n    ]\n    out_df = pd.DataFrame(rows)\n    out_path = f'/kaggle/working/cv_f{CURRENT_FOLD}_{arch_code}_results.csv'\n    out_df.to_csv(out_path, index=False)\n    print(f\"\\nSaved {out_path}\")\n    print(out_df.round(4).to_string(index=False))\n    return out_df\n\n# ---- CUSTOM CNN ----\ncustom_results = train_and_evaluate('custom', build_custom_cnn, None, is_pretrained=False)\n\n# ---- EFFICIENTNETB0 ----\neff_results = train_and_evaluate('eff', lambda: build_pretrained(EfficientNetB0), eff_pre, is_pretrained=True)\n\n# ---- MOBILENETV2 ----\nmob_results = train_and_evaluate('mob', lambda: build_pretrained(MobileNetV2), mob_pre, is_pretrained=True)\n\nprint(f\"\\n\\n{'='*20} FOLD {CURRENT_FOLD}: CUSTOM + EFF + MOB DONE (3 of 4) {'='*20}\")\nprint(pd.concat([custom_results, eff_results, mob_results], ignore_index=True).round(4).to_string(index=False))\nprint(f\"\\nDownload individually: cv_f{CURRENT_FOLD}_custom_results.csv, cv_f{CURRENT_FOLD}_eff_results.csv, cv_f{CURRENT_FOLD}_mob_results.csv\")\nprint(f\"Still needed for fold {CURRENT_FOLD}: res. Do not start fold 4 until all 4 exist.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-23T09:25:59.179216Z","iopub.execute_input":"2026-08-23T09:25:59.179878Z","iopub.status.idle":"2026-08-23T09:45:05.804776Z","shell.execute_reply.started":"2026-08-23T09:25:59.179843Z","shell.execute_reply":"2026-08-23T09:45:05.803824Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}