{"cells":[{"cell_type":"markdown","metadata":{},"source":"# 🏥 CLIP DR Detection v3 — ULTIMATE VERSION\n### Ben Graham Preprocessing + CLIP Fine-Tuning = Maximum Accuracy\n\n**Expected Results:**\n- Accuracy: 75-80%\n- Kappa: 0.75-0.82\n- Training time: ~5-6 hours on Kaggle T4\n\n**Key Improvements:**\n- ✅ Ben Graham preprocessing (biggest single improvement)\n- ✅ Fine-tune last 4 CLIP layers (adapts to retinal images)\n- ✅ Gradual unfreezing strategy\n- ✅ Lower learning rate for stability\n- ✅ 30 epochs with patience=10\n\n---"},{"cell_type":"markdown","metadata":{},"source":"## Step 1: Verify Dataset"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import os\ndataset_path = '/kaggle/input/aptos2019-blindness-detection'\nif os.path.exists(dataset_path):\n    print('✅ Dataset found!')\n    print('Contents:', os.listdir(dataset_path))\nelse:\n    print('❌ Add APTOS 2019 Blindness Detection from Data panel')"},{"cell_type":"markdown","metadata":{},"source":"## Step 2: Install Dependencies"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"!pip install -q transformers safetensors"},{"cell_type":"markdown","metadata":{},"source":"## Step 3: Check GPU"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import torch\nprint(f'PyTorch: {torch.__version__}')\nprint(f'CUDA: {torch.cuda.is_available()}')\nif torch.cuda.is_available():\n    for i in range(torch.cuda.device_count()):\n        print(f'GPU {i}: {torch.cuda.get_device_name(i)} ({torch.cuda.get_device_properties(i).total_memory/1e9:.1f} GB)')"},{"cell_type":"markdown","metadata":{},"source":"## Step 4: Configuration"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"import torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom transformers import AutoModel, AutoProcessor\nfrom PIL import Image\nimport pandas as pd\nimport numpy as np\nimport cv2\nfrom tqdm import tqdm\nfrom sklearn.metrics import accuracy_score, cohen_kappa_score, confusion_matrix\nfrom sklearn.model_selection import train_test_split\nimport matplotlib.pyplot as plt\nimport warnings\nwarnings.filterwarnings('ignore')\n\nclass Config:\n    # Paths\n    DATASET_PATH     = '/kaggle/input/aptos2019-blindness-detection'\n    TRAIN_CSV        = 'train.csv'\n    TRAIN_IMG_FOLDER = 'train_images'\n    OUTPUT_DIR       = '/kaggle/working/checkpoints_v3'\n    \n    IMG_COL   = 'id_code'\n    LABEL_COL = 'diagnosis'\n    IMG_EXT   = '.png'\n    \n    # Model\n    BASE_MODEL = 'openai/clip-vit-large-patch14'\n    \n    # 🔥 ULTIMATE TRAINING SETTINGS\n    BATCH_SIZE = 12          # Reduced for fine-tuning (uses more memory)\n    NUM_EPOCHS = 30          # More epochs for fine-tuning\n    PATIENCE   = 10          # More patience\n    \n    # 🔥 LEARNING RATES (critical for fine-tuning)\n    LR_CLASSIFIER = 1e-4     # Classification head\n    LR_ENCODER    = 1e-5     # CLIP layers (10x smaller!)\n    WEIGHT_DECAY  = 0.01\n    \n    # 🔥 FINE-TUNING STRATEGY\n    FREEZE_EPOCHS      = 5   # Train classifier only for first 5 epochs\n    LAYERS_TO_UNFREEZE = 4   # Unfreeze last 4 CLIP layers after warmup\n    \n    DROPOUT = 0.3\n    \n    # 🔥 BEN GRAHAM PREPROCESSING\n    USE_PREPROCESSING = True\n    PREPROCESS_SIGMA  = 10\n    \n    # Classes\n    NUM_CLASSES      = 5\n    CLASS_NAMES      = ['No DR', 'Mild', 'Moderate', 'Severe', 'Proliferative DR']\n    USE_CLASS_WEIGHTS = True\n    \n    # Splits\n    TRAIN_SPLIT = 0.80\n    VAL_SPLIT   = 0.10\n    TEST_SPLIT  = 0.10\n    \n    DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\n\nconfig = Config()\nos.makedirs(config.OUTPUT_DIR, exist_ok=True)\nprint(f'✅ v3 Config Ready')\nprint(f'   Strategy: Freeze {config.FREEZE_EPOCHS} epochs → Unfreeze last {config.LAYERS_TO_UNFREEZE} layers')\nprint(f'   Total epochs: {config.NUM_EPOCHS}')\nprint(f'   Preprocessing: {config.USE_PREPROCESSING}')"},{"cell_type":"markdown","metadata":{},"source":"## Step 5: Ben Graham Preprocessing"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"def crop_image_from_gray(img, tol=7):\n    if img.ndim == 2:\n        mask = img > tol\n        return img[np.ix_(mask.any(1), mask.any(0))]\n    elif img.ndim == 3:\n        gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)\n        mask = gray > tol\n        check = img[:, :, 0][np.ix_(mask.any(1), mask.any(0))].shape[0]\n        if check == 0:\n            return img\n        img1 = img[:, :, 0][np.ix_(mask.any(1), mask.any(0))]\n        img2 = img[:, :, 1][np.ix_(mask.any(1), mask.any(0))]\n        img3 = img[:, :, 2][np.ix_(mask.any(1), mask.any(0))]\n        return np.stack([img1, img2, img3], axis=-1)\n\ndef ben_graham_preprocess(img, sigmaX=10):\n    img = crop_image_from_gray(img)\n    img = cv2.resize(img, (224, 224))\n    img = cv2.addWeighted(img, 4, cv2.GaussianBlur(img, (0, 0), sigmaX), -4, 128)\n    return img\n\ndef load_and_preprocess(img_path, config):\n    try:\n        img = cv2.imread(img_path)\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if config.USE_PREPROCESSING:\n            img = ben_graham_preprocess(img, sigmaX=config.PREPROCESS_SIGMA)\n        return Image.fromarray(img)\n    except Exception:\n        try:\n            return Image.open(img_path).convert('RGB')\n        except Exception:\n            return Image.new('RGB', (224, 224), 'black')\n\nprint('✅ Preprocessing functions ready')"},{"cell_type":"markdown","metadata":{},"source":"## Step 6: Load & Preview Dataset"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"csv_path  = os.path.join(config.DATASET_PATH, config.TRAIN_CSV)\nimage_dir = os.path.join(config.DATASET_PATH, config.TRAIN_IMG_FOLDER)\ndf = pd.read_csv(csv_path)\n\nprint(f'Dataset: {len(df)} images')\nprint(f'\\nClass distribution:')\nfor cls in range(5):\n    count = len(df[df[config.LABEL_COL] == cls])\n    print(f'  {config.CLASS_NAMES[cls]:20s}: {count:4d} ({count/len(df)*100:.1f}%)')\n\n# Visualize preprocessing effect\nfig, axes = plt.subplots(2, 3, figsize=(15, 10))\nfor i, cls in enumerate([0, 1, 2]):\n    sample = df[df[config.LABEL_COL] == cls].iloc[0]\n    img_path = os.path.join(image_dir, sample[config.IMG_COL] + config.IMG_EXT)\n    \n    orig = cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB)\n    proc = ben_graham_preprocess(orig.copy(), config.PREPROCESS_SIGMA)\n    \n    axes[0, i].imshow(cv2.resize(orig, (224, 224)))\n    axes[0, i].set_title(f'Original - {config.CLASS_NAMES[cls]}', fontsize=10)\n    axes[0, i].axis('off')\n    \n    axes[1, i].imshow(proc)\n    axes[1, i].set_title(f'Preprocessed - {config.CLASS_NAMES[cls]}', fontsize=10, color='green')\n    axes[1, i].axis('off')\n\nplt.suptitle('Ben Graham Preprocessing Effect', fontsize=13, fontweight='bold')\nplt.tight_layout()\nplt.savefig(os.path.join(config.OUTPUT_DIR, 'preprocessing_demo.png'), dpi=150)\nplt.show()"},{"cell_type":"markdown","metadata":{},"source":"## Step 7: Create Splits"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"train_df, temp_df = train_test_split(df, test_size=(1-config.TRAIN_SPLIT), stratify=df[config.LABEL_COL], random_state=42)\nval_df, test_df = train_test_split(temp_df, test_size=0.5, stratify=temp_df[config.LABEL_COL], random_state=42)\ntrain_df, val_df, test_df = train_df.reset_index(drop=True), val_df.reset_index(drop=True), test_df.reset_index(drop=True)\n\nprint(f'Train: {len(train_df)} | Val: {len(val_df)} | Test: {len(test_df)}')"},{"cell_type":"markdown","metadata":{},"source":"## Step 8: Dataset Class"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"class APTOSDataset(Dataset):\n    def __init__(self, dataframe, image_dir, processor, config):\n        self.data = dataframe\n        self.image_dir = image_dir\n        self.processor = processor\n        self.config = config\n    \n    def __len__(self):\n        return len(self.data)\n    \n    def __getitem__(self, idx):\n        row = self.data.iloc[idx]\n        img_path = os.path.join(self.image_dir, row[self.config.IMG_COL] + self.config.IMG_EXT)\n        image = load_and_preprocess(img_path, self.config)\n        pixel_values = self.processor(images=image, return_tensors='pt')['pixel_values'].squeeze(0)\n        return pixel_values, int(row[self.config.LABEL_COL])\n\nprint('✅ Dataset class ready')"},{"cell_type":"markdown","metadata":{},"source":"## Step 9: Model with Fine-Tuning Support"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"class CLIPRetinopathyModel(nn.Module):\n    def __init__(self, num_classes=5, base_model='openai/clip-vit-large-patch14', dropout=0.3):\n        super().__init__()\n        self.vision_model = AutoModel.from_pretrained(base_model).vision_model\n        hidden = self.vision_model.config.hidden_size\n        \n        self.classifier = nn.Sequential(\n            nn.Dropout(dropout),\n            nn.Linear(hidden, hidden // 2),\n            nn.ReLU(),\n            nn.Dropout(dropout),\n            nn.Linear(hidden // 2, num_classes)\n        )\n        \n        # 🔥 Start with everything frozen\n        self.freeze_encoder()\n    \n    def freeze_encoder(self):\n        \"\"\"Freeze entire CLIP encoder\"\"\"\n        for param in self.vision_model.parameters():\n            param.requires_grad = False\n    \n    def unfreeze_last_n_layers(self, n=4):\n        \"\"\"Unfreeze last N transformer layers of CLIP\"\"\"\n        # CLIP ViT-L has 24 layers (encoder.layers[0] to encoder.layers[23])\n        total_layers = len(self.vision_model.encoder.layers)\n        \n        for i in range(total_layers - n, total_layers):\n            for param in self.vision_model.encoder.layers[i].parameters():\n                param.requires_grad = True\n        \n        print(f'✅ Unfroze last {n} CLIP layers (layers {total_layers-n} to {total_layers-1})')\n    \n    def forward(self, pixel_values):\n        pooled = self.vision_model(pixel_values=pixel_values).pooler_output\n        return self.classifier(pooled)\n\nprint('✅ Model class ready')"},{"cell_type":"markdown","metadata":{},"source":"## Step 10: Training Functions"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"def compute_metrics(y_true, y_pred):\n    return {\n        'accuracy': accuracy_score(y_true, y_pred),\n        'kappa': cohen_kappa_score(y_true, y_pred, weights='quadratic')\n    }\n\ndef train_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n    total_loss, preds, labels = 0, [], []\n    for pixels, batch_labels in tqdm(loader, desc='  Training', leave=False):\n        pixels, batch_labels = pixels.to(device), batch_labels.to(device)\n        logits = model(pixels)\n        loss = criterion(logits, batch_labels)\n        optimizer.zero_grad()\n        loss.backward()\n        optimizer.step()\n        preds.extend(logits.argmax(1).cpu().numpy())\n        labels.extend(batch_labels.cpu().numpy())\n        total_loss += loss.item()\n    return total_loss / len(loader), compute_metrics(labels, preds)\n\ndef evaluate(model, loader, criterion, device):\n    model.eval()\n    total_loss, preds, labels = 0, [], []\n    with torch.no_grad():\n        for pixels, batch_labels in tqdm(loader, desc='  Evaluating', leave=False):\n            pixels, batch_labels = pixels.to(device), batch_labels.to(device)\n            logits = model(pixels)\n            loss = criterion(logits, batch_labels)\n            preds.extend(logits.argmax(1).cpu().numpy())\n            labels.extend(batch_labels.cpu().numpy())\n            total_loss += loss.item()\n    return total_loss / len(loader), compute_metrics(labels, preds), confusion_matrix(labels, preds)\n\nprint('✅ Training functions ready')"},{"cell_type":"markdown","metadata":{},"source":"## Step 11: Initialize Everything"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"print('Loading CLIP processor...')\nprocessor = AutoProcessor.from_pretrained(config.BASE_MODEL)\n\ntrain_ds = APTOSDataset(train_df, image_dir, processor, config)\nval_ds = APTOSDataset(val_df, image_dir, processor, config)\ntest_ds = APTOSDataset(test_df, image_dir, processor, config)\n\ntrain_loader = DataLoader(train_ds, batch_size=config.BATCH_SIZE, shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_ds, batch_size=config.BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\ntest_loader = DataLoader(test_ds, batch_size=config.BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n# Class weights\ncounts = train_df[config.LABEL_COL].value_counts().sort_index()\ntotal = len(train_df)\nclass_weights = torch.FloatTensor([total / (config.NUM_CLASSES * counts[i]) for i in range(config.NUM_CLASSES)]).to(config.DEVICE)\nprint(f'Class weights: {class_weights.cpu().numpy().round(3)}')\n\n# Model\nprint('\\nLoading CLIP model...')\nmodel = CLIPRetinopathyModel(config.NUM_CLASSES, config.BASE_MODEL, config.DROPOUT).to(config.DEVICE)\n\ntrainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\ntotal_params = sum(p.numel() for p in model.parameters())\nprint(f'Total params: {total_params:,}')\nprint(f'Trainable (initially): {trainable:,} ({trainable/total_params*100:.1f}%)')\n\n# Loss & optimizer\ncriterion = nn.CrossEntropyLoss(weight=class_weights)\n\nprint('\\n✅ Ready for gradual unfreezing training!')"},{"cell_type":"markdown","metadata":{},"source":"## Step 12: Train with Gradual Unfreezing! 🚀\n\n**Training Strategy:**\n1. **Phase 1 (Epochs 1-5)**: Train classifier only (CLIP frozen)\n2. **Phase 2 (Epochs 6-30)**: Unfreeze last 4 CLIP layers, fine-tune everything\n\nThis prevents catastrophic forgetting and gives best results."},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"print('=' * 70)\nprint('  CLIP DR v3 — Preprocessing + Fine-Tuning')\nprint('=' * 70)\n\nbest_kappa = 0.0\npatience_counter = 0\nhistory = {'train_loss': [], 'val_loss': [], 'train_kappa': [], 'val_kappa': [], 'train_acc': [], 'val_acc': []}\nbest_model_path = os.path.join(config.OUTPUT_DIR, 'best_model_v3.pth')\n\n# Phase tracking\nphase = 1\nunfrozen = False\n\nfor epoch in range(config.NUM_EPOCHS):\n    \n    # 🔥 UNFREEZE CLIP LAYERS AFTER WARMUP\n    if epoch == config.FREEZE_EPOCHS and not unfrozen:\n        print(f'\\n{\"=\"*70}')\n        print(f'🔥 PHASE 2: Unfreezing last {config.LAYERS_TO_UNFREEZE} CLIP layers')\n        print(f'{\"=\"*70}\\n')\n        \n        model.unfreeze_last_n_layers(config.LAYERS_TO_UNFREEZE)\n        \n        # Create new optimizer with different learning rates\n        encoder_params = []\n        for i in range(24 - config.LAYERS_TO_UNFREEZE, 24):\n            encoder_params.extend(list(model.vision_model.encoder.layers[i].parameters()))\n        \n        optimizer = optim.AdamW([\n            {'params': model.classifier.parameters(), 'lr': config.LR_CLASSIFIER},\n            {'params': encoder_params, 'lr': config.LR_ENCODER}  # 10x smaller!\n        ], weight_decay=config.WEIGHT_DECAY)\n        \n        scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=config.NUM_EPOCHS - config.FREEZE_EPOCHS)\n        \n        trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\n        print(f'Trainable params now: {trainable:,} ({trainable/total_params*100:.1f}%)')\n        unfrozen = True\n        phase = 2\n    \n    # PHASE 1: Classifier only\n    if not unfrozen:\n        if epoch == 0:\n            optimizer = optim.AdamW(model.classifier.parameters(), lr=config.LR_CLASSIFIER, weight_decay=config.WEIGHT_DECAY)\n            scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=config.FREEZE_EPOCHS)\n    \n    print(f'\\nEpoch {epoch+1}/{config.NUM_EPOCHS} [Phase {phase}]')\n    print('-' * 70)\n    \n    train_loss, train_m = train_epoch(model, train_loader, optimizer, criterion, config.DEVICE)\n    val_loss, val_m, cm = evaluate(model, val_loader, criterion, config.DEVICE)\n    if unfrozen:\n        scheduler.step()\n    elif epoch < config.FREEZE_EPOCHS:\n        scheduler.step()\n    \n    print(f'  Train → Loss: {train_loss:.4f}  Acc: {train_m[\"accuracy\"]:.4f}  Kappa: {train_m[\"kappa\"]:.4f}')\n    print(f'  Val   → Loss: {val_loss:.4f}  Acc: {val_m[\"accuracy\"]:.4f}  Kappa: {val_m[\"kappa\"]:.4f}')\n    \n    history['train_loss'].append(train_loss)\n    history['val_loss'].append(val_loss)\n    history['train_kappa'].append(train_m['kappa'])\n    history['val_kappa'].append(val_m['kappa'])\n    history['train_acc'].append(train_m['accuracy'])\n    history['val_acc'].append(val_m['accuracy'])\n    \n    if val_m['kappa'] > best_kappa:\n        best_kappa = val_m['kappa']\n        patience_counter = 0\n        torch.save({\n            'epoch': epoch,\n            'model_state_dict': model.state_dict(),\n            'val_kappa': best_kappa,\n            'val_accuracy': val_m['accuracy'],\n            'config': config.__dict__,\n            'phase': phase,\n            'unfrozen': unfrozen\n        }, best_model_path)\n        print(f'  ✅ Best model! Kappa: {best_kappa:.4f} | Acc: {val_m[\"accuracy\"]:.4f} ({val_m[\"accuracy\"]*100:.2f}%)')\n    else:\n        patience_counter += 1\n        print(f'  ⏳ No improvement. Patience: {patience_counter}/{config.PATIENCE}')\n    \n    if patience_counter >= config.PATIENCE:\n        print(f'\\n🛑 Early stopping at epoch {epoch+1}')\n        break\n\nprint(f'\\n{\"=\"*70}')\nprint(f'  Training Complete!')\nprint(f'  Best Val Kappa: {best_kappa:.4f}')\nprint(f'{\"=\"*70}')"},{"cell_type":"markdown","metadata":{},"source":"## Step 13: Visualize Results"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"epochs_ran = range(1, len(history['train_loss']) + 1)\nfig, axes = plt.subplots(1, 3, figsize=(18, 5))\n\n# Mark phase transition\nfreeze_line = config.FREEZE_EPOCHS if len(epochs_ran) > config.FREEZE_EPOCHS else None\n\n# Loss\naxes[0].plot(epochs_ran, history['train_loss'], 'b-o', label='Train', markersize=4)\naxes[0].plot(epochs_ran, history['val_loss'], 'r-s', label='Val', markersize=4)\nif freeze_line:\n    axes[0].axvline(x=freeze_line, color='green', linestyle='--', alpha=0.5, label='Unfreeze CLIP')\naxes[0].set_title('Loss'); axes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')\naxes[0].legend(); axes[0].grid(True, alpha=0.3)\n\n# Accuracy\naxes[1].plot(epochs_ran, history['train_acc'], 'b-o', label='Train', markersize=4)\naxes[1].plot(epochs_ran, history['val_acc'], 'r-s', label='Val', markersize=4)\naxes[1].axhline(y=0.75, color='gold', linestyle='--', alpha=0.5, label='Target 75%')\nif freeze_line:\n    axes[1].axvline(x=freeze_line, color='green', linestyle='--', alpha=0.5)\naxes[1].set_title('Accuracy'); axes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Accuracy')\naxes[1].legend(); axes[1].grid(True, alpha=0.3)\n\n# Kappa\naxes[2].plot(epochs_ran, history['train_kappa'], 'b-o', label='Train', markersize=4)\naxes[2].plot(epochs_ran, history['val_kappa'], 'r-s', label='Val', markersize=4)\naxes[2].axhline(y=0.75, color='gold', linestyle='--', alpha=0.5, label='Target 0.75')\nif freeze_line:\n    axes[2].axvline(x=freeze_line, color='green', linestyle='--', alpha=0.5, label='Unfreeze')\naxes[2].set_title('Kappa'); axes[2].set_xlabel('Epoch'); axes[2].set_ylabel('Kappa')\naxes[2].legend(); axes[2].grid(True, alpha=0.3)\n\nplt.suptitle('CLIP DR v3 — Preprocessing + Fine-Tuning Results', fontsize=14, fontweight='bold')\nplt.tight_layout()\nplt.savefig(os.path.join(config.OUTPUT_DIR, 'training_curves_v3.png'), dpi=150)\nplt.show()"},{"cell_type":"markdown","metadata":{},"source":"## Step 14: Evaluate on Test Set"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"checkpoint = torch.load(best_model_path, weights_only=False)\nmodel.load_state_dict(checkpoint['model_state_dict'])\nprint(f'Loaded best model from epoch {checkpoint[\"epoch\"]+1}  (Val Kappa: {checkpoint[\"val_kappa\"]:.4f})')\n\ntest_loss, test_m, test_cm = evaluate(model, test_loader, criterion, config.DEVICE)\n\nprint(f'\\n{\"=\"*60}')\nprint('  FINAL TEST SET RESULTS')\nprint(f'{\"=\"*60}')\nprint(f'  Accuracy        : {test_m[\"accuracy\"]:.4f}  ({test_m[\"accuracy\"]*100:.2f}%)')\nprint(f'  Quadratic Kappa : {test_m[\"kappa\"]:.4f}')\nprint(f'\\n  Confusion Matrix:')\nprint(test_cm)\nprint(f'\\n  Per-class:')\nfor i, name in enumerate(config.CLASS_NAMES):\n    correct = test_cm[i][i]\n    total = test_cm[i].sum()\n    pct = correct / total * 100 if total > 0 else 0\n    bar = '█' * int(pct / 5)\n    print(f'    {name:20s}: {correct:3d}/{total:3d}  ({pct:5.1f}%)  {bar}')"},{"cell_type":"markdown","metadata":{},"source":"## Step 15: Confusion Matrix Heatmap"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"fig, ax = plt.subplots(figsize=(9, 8))\nim = ax.imshow(test_cm, interpolation='nearest', cmap=plt.cm.Blues)\nplt.colorbar(im, ax=ax)\n\nax.set_xticks(range(5))\nax.set_yticks(range(5))\nax.set_xticklabels(config.CLASS_NAMES, rotation=30, ha='right')\nax.set_yticklabels(config.CLASS_NAMES)\n\nthresh = test_cm.max() / 2\nfor i in range(5):\n    for j in range(5):\n        ax.text(j, i, str(test_cm[i, j]), ha='center', va='center', fontsize=12,\n                color='white' if test_cm[i, j] > thresh else 'black')\n\nax.set_xlabel('Predicted', fontsize=12)\nax.set_ylabel('True', fontsize=12)\nax.set_title(f'v3 Test Confusion Matrix\\nKappa: {test_m[\"kappa\"]:.4f} | Acc: {test_m[\"accuracy\"]*100:.2f}%',\n             fontsize=13, fontweight='bold')\nplt.tight_layout()\nplt.savefig(os.path.join(config.OUTPUT_DIR, 'confusion_matrix_v3.png'), dpi=150)\nplt.show()"},{"cell_type":"markdown","metadata":{},"source":"## Step 16: Summary for Paper 📄"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"print('=' * 70)\nprint('  RESULTS SUMMARY — For Your Paper')\nprint('=' * 70)\nprint(f'  Dataset           : APTOS 2019 ({len(df)} images)')\nprint(f'  Model             : CLIP ViT-L/14 + Fine-tuning last 4 layers')\nprint(f'  Preprocessing     : Ben Graham (sigmaX={config.PREPROCESS_SIGMA})')\nprint(f'  Train/Val/Test    : {len(train_df)}/{len(val_df)}/{len(test_df)}')\nprint(f'  Epochs trained    : {len(history[\"train_loss\"])}')\nprint(f'  Best Val Kappa    : {best_kappa:.4f}')\nprint(f'  Test Kappa        : {test_m[\"kappa\"]:.4f}')\nprint(f'  Test Accuracy     : {test_m[\"accuracy\"]:.4f}  ({test_m[\"accuracy\"]*100:.2f}%)')\nprint(f'  Training Strategy : Freeze {config.FREEZE_EPOCHS} epochs → Unfreeze last {config.LAYERS_TO_UNFREEZE} layers')\nprint(f'  Learning Rates    : Classifier {config.LR_CLASSIFIER}, Encoder {config.LR_ENCODER}')\nprint('=' * 70)\n\nk = test_m['kappa']\nprint(f'\\n  Kappa Interpretation:')\nif k >= 0.8:\n    print(f'  {k:.4f}  →  🌟 Almost Perfect Agreement')\nelif k >= 0.75:\n    print(f'  {k:.4f}  →  ✅ Excellent Agreement (Strong for publication!)')\nelif k >= 0.6:\n    print(f'  {k:.4f}  →  ✅ Substantial Agreement')\nelse:\n    print(f'  {k:.4f}  →  ⚠️  Moderate Agreement')\n\nprint(f'\\n  Files saved:')\nfor f in os.listdir(config.OUTPUT_DIR):\n    print(f'    • {f}')"}],"metadata":{"kernelspec":{"display_name":"Python 3","name":"python3"}},"nbformat":4,"nbformat_minor":4}