{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":13836,"databundleVersionId":1718836,"isSourceIdPinned":false},{"sourceType":"modelInstanceVersion","sourceId":787961,"databundleVersionId":16110751,"modelInstanceId":601300,"modelId":613492,"isSourceIdPinned":false}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install timm albumentations --quiet","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:44.795670Z","iopub.execute_input":"2026-03-16T03:46:44.795926Z","iopub.status.idle":"2026-03-16T03:46:48.229809Z","shell.execute_reply.started":"2026-03-16T03:46:44.795891Z","shell.execute_reply":"2026-03-16T03:46:48.229052Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Imports\nimport os\nimport random\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n# PyTorch\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\n\n# Modelo preentrenado\nimport timm\n\n# Data Augmentation\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\n# Métricas\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import (\n    accuracy_score,\n    precision_score,\n    recall_score,\n    f1_score,\n    confusion_matrix,\n    classification_report,\n    roc_curve,\n    auc\n)\n\n# Visualización\nimport matplotlib.pyplot as plt\nimport seaborn as sns\n\n# Verificar GPU\nprint(f\"PyTorch version: {torch.__version__}\")\nprint(f\"CUDA disponible: {torch.cuda.is_available()}\")\nif torch.cuda.is_available():\n    print(f\"GPU: {torch.cuda.get_device_name(0)}\")\n    print(f\"Número de GPUs: {torch.cuda.device_count()}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:48.230989Z","iopub.execute_input":"2026-03-16T03:46:48.231323Z","iopub.status.idle":"2026-03-16T03:46:48.238703Z","shell.execute_reply.started":"2026-03-16T03:46:48.231281Z","shell.execute_reply":"2026-03-16T03:46:48.237924Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- Semilla para reproducibilidad ---\nSEED = 42\n\ndef set_seed(seed):\n    \"\"\"Fijar semilla en todas las librerías para resultados reproducibles.\"\"\"\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n\nset_seed(SEED)\n\n# --- Hiperparámetros ---\nIMG_SIZE       = 380       # Tamaño al que se redimensionan las imágenes\nBATCH_SIZE     = 32        # Imágenes por batch (ajustar si hay OOM)\nNUM_EPOCHS     = 40        # Epochs totales de entrenamiento\nPHASE1_EPOCHS  = 10        # Epochs con backbone congelado (Fase 1)\nLR_PHASE1      = 1e-3      # Learning rate alto para Fase 1 (solo cabeza)\nLR_PHASE2      = 1e-5      # Learning rate bajo para Fase 2 (fine-tuning)\nWEIGHT_DECAY   = 1e-5      # Regularización L2\nFOCAL_GAMMA    = 2.0       # Gamma de Focal Loss\nDROPOUT_1      = 0.4       # Dropout primera capa del clasificador\nDROPOUT_2      = 0.3       # Dropout segunda capa del clasificador\nPATIENCE       = 7         # Epochs sin mejora antes de early stopping\nNUM_CLASSES    = 5          # Clases del dataset\nNUM_WORKERS    = 2          # Workers para DataLoader\n\n# --- Device ---\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Device: {device}\")\n\n# --- Rutas del dataset en Kaggle ---\nBASE_DIR   = '/kaggle/input/competitions/cassava-leaf-disease-classification'\nTRAIN_CSV  = os.path.join(BASE_DIR, 'train.csv')\nTRAIN_IMGS = os.path.join(BASE_DIR, 'train_images')\n\n# --- Nombres de las clases ---\nCLASS_NAMES = [\n    'CBB (Bacterial Blight)',\n    'CBSD (Brown Streak)',\n    'CGM (Green Mottle)',\n    'CMD (Mosaic Disease)',\n    'Healthy'\n]\n\nprint(f\"\\nHiperparámetros configurados:\")\nprint(f\"  Imagen: {IMG_SIZE}x{IMG_SIZE}\")\nprint(f\"  Batch size: {BATCH_SIZE}\")\nprint(f\"  Epochs totales: {NUM_EPOCHS} (Fase 1: {PHASE1_EPOCHS}, Fase 2: {NUM_EPOCHS - PHASE1_EPOCHS})\")\nprint(f\"  Learning rates: Fase 1={LR_PHASE1}, Fase 2={LR_PHASE2}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:48.239731Z","iopub.execute_input":"2026-03-16T03:46:48.240054Z","iopub.status.idle":"2026-03-16T03:46:48.259717Z","shell.execute_reply.started":"2026-03-16T03:46:48.240025Z","shell.execute_reply":"2026-03-16T03:46:48.259144Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"df = pd.read_csv(TRAIN_CSV)\nprint(f\"Total de imágenes: {len(df)}\")\nprint(f\"Columnas: {df.columns.tolist()}\")\nprint(f\"\\nPrimeras filas:\")\nprint(df.head())\n\n# Distribución de clases\nprint(f\"\\n--- Distribución de Clases ---\")\nclass_counts = df['label'].value_counts().sort_index()\nfor idx, count in class_counts.items():\n    pct = count / len(df) * 100\n    print(f\"  Clase {idx} ({CLASS_NAMES[idx]}): {count} imágenes ({pct:.1f}%)\")\n\n# Gráfica de distribución\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# Barplot\ncolors = ['#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db']\nbars = axes[0].bar(range(NUM_CLASSES), class_counts.values, color=colors)\naxes[0].set_xticks(range(NUM_CLASSES))\naxes[0].set_xticklabels([f'Clase {i}' for i in range(NUM_CLASSES)], rotation=0)\naxes[0].set_ylabel('Cantidad de imágenes')\naxes[0].set_title('Distribución de Clases en el Dataset')\nfor bar, count in zip(bars, class_counts.values):\n    axes[0].text(bar.get_x() + bar.get_width()/2., bar.get_height() + 100,\n                 f'{count}', ha='center', va='bottom', fontsize=10)\n\n# Pie chart\naxes[1].pie(class_counts.values, labels=[f'{CLASS_NAMES[i]}\\n({class_counts[i]})' \n            for i in range(NUM_CLASSES)], colors=colors, autopct='%1.1f%%', startangle=90)\naxes[1].set_title('Proporción de cada Clase')\n\nplt.tight_layout()\nplt.savefig('/kaggle/working/eda_distribucion_clases.png', dpi=150, bbox_inches='tight')\nplt.show()\n\n# Mostrar imágenes de ejemplo por clase\nfig, axes = plt.subplots(1, 5, figsize=(20, 4))\nfor i in range(NUM_CLASSES):\n    sample = df[df['label'] == i].iloc[0]\n    img = Image.open(os.path.join(TRAIN_IMGS, sample['image_id']))\n    axes[i].imshow(img)\n    axes[i].set_title(f'Clase {i}: {CLASS_NAMES[i]}', fontsize=9)\n    axes[i].axis('off')\nplt.suptitle('Ejemplo de imagen por cada clase', fontsize=14)\nplt.tight_layout()\nplt.savefig('/kaggle/working/eda_ejemplos_clases.png', dpi=150, bbox_inches='tight')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:48.260591Z","iopub.execute_input":"2026-03-16T03:46:48.260832Z","iopub.status.idle":"2026-03-16T03:46:50.580364Z","shell.execute_reply.started":"2026-03-16T03:46:48.260811Z","shell.execute_reply":"2026-03-16T03:46:50.579522Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df, val_df = train_test_split(\n    df,\n    test_size=0.20,\n    stratify=df['label'],   # Mantener proporción de clases\n    random_state=SEED\n)\n\ntrain_df = train_df.reset_index(drop=True)\nval_df   = val_df.reset_index(drop=True)\n\nprint(f\"Train: {len(train_df)} imágenes ({len(train_df)/len(df)*100:.1f}%)\")\nprint(f\"Val:   {len(val_df)} imágenes ({len(val_df)/len(df)*100:.1f}%)\")\n\n# Verificar que la estratificación se mantuvo\nprint(f\"\\n--- Verificación de estratificación ---\")\nprint(f\"{'Clase':<10} {'Train %':<12} {'Val %':<12} {'Original %':<12}\")\nfor i in range(NUM_CLASSES):\n    train_pct = (train_df['label'] == i).sum() / len(train_df) * 100\n    val_pct   = (val_df['label'] == i).sum() / len(val_df) * 100\n    orig_pct  = (df['label'] == i).sum() / len(df) * 100\n    print(f\"  {i:<8} {train_pct:<12.1f} {val_pct:<12.1f} {orig_pct:<12.1f}\")\n\n# Calcular pesos de clase para Focal Loss (inversamente proporcional a frecuencia)\nclass_counts_train = train_df['label'].value_counts().sort_index().values\nclass_weights = 1.0 / torch.tensor(class_counts_train, dtype=torch.float)\nclass_weights = class_weights / class_weights.sum() * NUM_CLASSES  # Normalizar\nclass_weights = class_weights.to(device)\n\nprint(f\"\\nPesos por clase (Focal Loss):\")\nfor i in range(NUM_CLASSES):\n    print(f\"  Clase {i} ({CLASS_NAMES[i]}): {class_weights[i]:.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:50.581856Z","iopub.execute_input":"2026-03-16T03:46:50.582599Z","iopub.status.idle":"2026-03-16T03:46:50.611466Z","shell.execute_reply.started":"2026-03-16T03:46:50.582554Z","shell.execute_reply":"2026-03-16T03:46:50.610830Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_train_transforms():\n    \"\"\"\n    Transformaciones para entrenamiento.\n    Incluyen augmentations para generar variabilidad artificial\n    y mejorar la generalización del modelo.\n    \"\"\"\n    return A.Compose([\n        A.Resize(IMG_SIZE, IMG_SIZE),\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.3),\n        A.Affine(\n            translate_percent=0.1,\n            scale=(0.9, 1.1),\n            rotate=(-15, 15),\n            p=0.6\n        ),\n        A.RandomBrightnessContrast(\n            brightness_limit=0.2,\n            contrast_limit=0.2,\n            p=0.5\n        ),\n        A.CoarseDropout(\n            num_holes_range=(1, 8),\n            hole_height_range=(IMG_SIZE // 20, IMG_SIZE // 20),\n            hole_width_range=(IMG_SIZE // 20, IMG_SIZE // 20),\n            p=0.3\n        ),\n        A.Normalize(\n            mean=[0.485, 0.456, 0.406],\n            std=[0.229, 0.224, 0.225]\n        ),\n        ToTensorV2()\n    ])\n\n\ndef get_val_transforms():\n    \"\"\"\n    Transformaciones para validación.\n    Solo resize y normalización, sin augmentation.\n    \"\"\"\n    return A.Compose([\n        A.Resize(IMG_SIZE, IMG_SIZE),\n        A.Normalize(\n            mean=[0.485, 0.456, 0.406],\n            std=[0.229, 0.224, 0.225]\n        ),\n        ToTensorV2()\n    ])\n\n\n# Visualizar augmentations en una imagen de ejemplo\nsample_img = np.array(Image.open(os.path.join(TRAIN_IMGS, train_df.iloc[0]['image_id'])))\naug = get_train_transforms()\n\nfig, axes = plt.subplots(2, 4, figsize=(16, 8))\naxes[0, 0].imshow(sample_img)\naxes[0, 0].set_title('Original')\naxes[0, 0].axis('off')\n\nfor i in range(1, 8):\n    augmented = aug(image=sample_img)['image']\n    # Desnormalizar para visualizar\n    img_show = augmented.permute(1, 2, 0).numpy()\n    img_show = img_show * np.array([0.229, 0.224, 0.225]) + np.array([0.485, 0.456, 0.406])\n    img_show = np.clip(img_show, 0, 1)\n    row, col = divmod(i, 4)\n    axes[row, col].imshow(img_show)\n    axes[row, col].set_title(f'Augmentación {i}')\n    axes[row, col].axis('off')\n\nplt.suptitle('Data Augmentation - Variaciones de una misma imagen', fontsize=14)\nplt.tight_layout()\nplt.savefig('/kaggle/working/augmentation_examples.png', dpi=150, bbox_inches='tight')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:50.612354Z","iopub.execute_input":"2026-03-16T03:46:50.612636Z","iopub.status.idle":"2026-03-16T03:46:53.460107Z","shell.execute_reply.started":"2026-03-16T03:46:50.612590Z","shell.execute_reply":"2026-03-16T03:46:53.459352Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class CassavaDataset(Dataset):\n    \"\"\"\n    Dataset personalizado para cargar imágenes de hojas de yuca.\n    Hereda de torch.utils.data.Dataset.\n    \"\"\"\n    \n    def __init__(self, dataframe, img_dir, transform=None):\n        \"\"\"\n        Args:\n            dataframe: DataFrame con columnas 'image_id' y 'label'\n            img_dir: Directorio donde están las imágenes\n            transform: Transformaciones de albumentations a aplicar\n        \"\"\"\n        self.dataframe = dataframe\n        self.img_dir = img_dir\n        self.transform = transform\n    \n    def __len__(self):\n        return len(self.dataframe)\n    \n    def __getitem__(self, idx):\n        row = self.dataframe.iloc[idx]\n        img_path = os.path.join(self.img_dir, row['image_id'])\n        \n        # Cargar imagen con PIL y convertir a numpy array (RGB)\n        image = np.array(Image.open(img_path).convert('RGB'))\n        label = row['label']\n        \n        # Aplicar transformaciones\n        if self.transform:\n            augmented = self.transform(image=image)\n            image = augmented['image']\n        \n        return image, torch.tensor(label, dtype=torch.long)\n\n\n# Crear datasets\ntrain_dataset = CassavaDataset(train_df, TRAIN_IMGS, get_train_transforms())\nval_dataset   = CassavaDataset(val_df, TRAIN_IMGS, get_val_transforms())\n\n# Crear DataLoaders\ntrain_loader = DataLoader(\n    train_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    num_workers=NUM_WORKERS,\n    pin_memory=True,\n    drop_last=True\n)\n\nval_loader = DataLoader(\n    val_dataset,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    num_workers=NUM_WORKERS,\n    pin_memory=True\n)\n\nprint(f\"Train dataset: {len(train_dataset)} imágenes\")\nprint(f\"Val dataset:   {len(val_dataset)} imágenes\")\nprint(f\"Train batches: {len(train_loader)}\")\nprint(f\"Val batches:   {len(val_loader)}\")\n\n# Verificar un batch\nimages, labels = next(iter(train_loader))\nprint(f\"\\nBatch shape: {images.shape}\")\nprint(f\"Labels shape: {labels.shape}\")\nprint(f\"Labels ejemplo: {labels[:8].tolist()}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:53.461133Z","iopub.execute_input":"2026-03-16T03:46:53.461409Z","iopub.status.idle":"2026-03-16T03:46:54.301924Z","shell.execute_reply.started":"2026-03-16T03:46:53.461384Z","shell.execute_reply":"2026-03-16T03:46:54.301097Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class FocalLoss(nn.Module):\n    \"\"\"\n    Focal Loss (Lin et al., 2017).\n    \n    Reduce la contribución de los ejemplos fáciles (bien clasificados)\n    y enfoca el entrenamiento en los ejemplos difíciles (mal clasificados).\n    Especialmente útil cuando hay desbalance de clases.\n    \n    Formula: FL(pt) = -alpha * (1 - pt)^gamma * log(pt)\n    \n    Args:\n        alpha: Pesos por clase (tensor). Clases minoritarias reciben mayor peso.\n        gamma: Factor de enfoque. gamma=0 es CrossEntropy normal.\n               gamma=2.0 es el valor recomendado por el paper original.\n    \"\"\"\n    \n    def __init__(self, alpha=None, gamma=2.0):\n        super(FocalLoss, self).__init__()\n        self.alpha = alpha\n        self.gamma = gamma\n    \n    def forward(self, inputs, targets):\n        # Cross-entropy sin reducción (por cada muestra individual)\n        ce_loss = nn.functional.cross_entropy(inputs, targets, reduction='none')\n        \n        # pt = probabilidad asignada a la clase correcta\n        pt = torch.exp(-ce_loss)\n        \n        # Focal term: (1 - pt)^gamma\n        # Si pt es alto (ejemplo fácil), este término es cercano a 0 → menos peso\n        # Si pt es bajo (ejemplo difícil), este término es cercano a 1 → más peso\n        focal_loss = ((1 - pt) ** self.gamma) * ce_loss\n        \n        # Aplicar pesos por clase si se proporcionan\n        if self.alpha is not None:\n            alpha_t = self.alpha[targets]\n            focal_loss = alpha_t * focal_loss\n        \n        return focal_loss.mean()\n\n\n# Verificar que funciona\ndummy_input  = torch.randn(4, NUM_CLASSES).to(device)\ndummy_target = torch.tensor([0, 1, 3, 4]).to(device)\ncriterion = FocalLoss(alpha=class_weights, gamma=FOCAL_GAMMA)\ndummy_loss = criterion(dummy_input, dummy_target)\nprint(f\"Focal Loss de prueba: {dummy_loss.item():.4f} ✓\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:54.303165Z","iopub.execute_input":"2026-03-16T03:46:54.303453Z","iopub.status.idle":"2026-03-16T03:46:54.312923Z","shell.execute_reply.started":"2026-03-16T03:46:54.303425Z","shell.execute_reply":"2026-03-16T03:46:54.312297Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def create_model(num_classes=NUM_CLASSES, pretrained=True):\n    # Cargar backbone preentrenado (sin la cabeza de clasificación original)\n    model = timm.create_model(\n        'efficientnet_b4',\n        pretrained=pretrained,\n        num_classes=0    # Elimina la capa de clasificación de ImageNet\n    )\n    \n    # Obtener el tamaño de la salida del backbone\n    num_features = model.num_features  # 1792 para EfficientNet-B4\n    \n    # FASE 1: Congelar TODOS los parámetros del backbone\n    for param in model.parameters():\n        param.requires_grad = False\n    \n    # Agregar nuestro clasificador personalizado\n    model.classifier = nn.Sequential(\n        nn.Dropout(p=DROPOUT_1),\n        nn.Linear(num_features, 512),\n        nn.ReLU(),\n        nn.BatchNorm1d(512),\n        nn.Dropout(p=DROPOUT_2),\n        nn.Linear(512, num_classes)\n    )\n    \n    return model\n\n\n# Crear modelo y mover a GPU\nmodel = create_model()\nmodel = model.to(device)\n\n\nmodel = nn.DataParallel(model)\n\n# Resumen de parámetros\ntotal_params     = sum(p.numel() for p in model.parameters())\ntrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)\nfrozen_params    = total_params - trainable_params\n\nprint(f\"Modelo: EfficientNet-B4\")\nprint(f\"  Parámetros totales:      {total_params:>12,}\")\nprint(f\"  Parámetros entrenables:  {trainable_params:>12,} (Fase 1: solo clasificador)\")\nprint(f\"  Parámetros congelados:   {frozen_params:>12,} (backbone)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:54.313662Z","iopub.execute_input":"2026-03-16T03:46:54.313919Z","iopub.status.idle":"2026-03-16T03:46:54.831930Z","shell.execute_reply.started":"2026-03-16T03:46:54.313890Z","shell.execute_reply":"2026-03-16T03:46:54.831283Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_one_epoch(model, loader, criterion, optimizer, epoch):\n    \"\"\"Entrena el modelo por un epoch completo.\"\"\"\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    for batch_idx, (images, labels) in enumerate(loader):\n        images = images.to(device)\n        labels = labels.to(device)\n        \n        # Forward pass\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        \n        # Backward pass\n        optimizer.zero_grad()\n        loss.backward()\n        optimizer.step()\n        \n        # Métricas del batch\n        running_loss += loss.item() * images.size(0)\n        _, predicted = outputs.max(1)\n        total += labels.size(0)\n        correct += predicted.eq(labels).sum().item()\n        \n        # Imprimir progreso cada 100 batches\n        if (batch_idx + 1) % 100 == 0:\n            print(f\"  Epoch {epoch+1} | Batch {batch_idx+1}/{len(loader)} | \"\n                  f\"Loss: {loss.item():.4f} | Acc: {100.*correct/total:.1f}%\")\n    \n    epoch_loss = running_loss / total\n    epoch_acc  = 100. * correct / total\n    return epoch_loss, epoch_acc\n\n\ndef validate(model, loader, criterion):\n    \"\"\"Evalúa el modelo en el conjunto de validación.\"\"\"\n    model.eval()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    all_preds  = []\n    all_labels = []\n    all_probs  = []\n    \n    with torch.no_grad():\n        for images, labels in loader:\n            images = images.to(device)\n            labels = labels.to(device)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            running_loss += loss.item() * images.size(0)\n            \n            # Probabilidades (para ROC curves)\n            probs = torch.softmax(outputs, dim=1)\n            all_probs.append(probs.cpu())\n            \n            _, predicted = outputs.max(1)\n            total += labels.size(0)\n            correct += predicted.eq(labels).sum().item()\n            \n            all_preds.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n    \n    epoch_loss = running_loss / total\n    epoch_acc  = 100. * correct / total\n    all_probs  = torch.cat(all_probs, dim=0).numpy()\n    \n    return epoch_loss, epoch_acc, np.array(all_preds), np.array(all_labels), all_probs","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:54.832758Z","iopub.execute_input":"2026-03-16T03:46:54.833048Z","iopub.status.idle":"2026-03-16T03:46:54.842283Z","shell.execute_reply.started":"2026-03-16T03:46:54.833020Z","shell.execute_reply":"2026-03-16T03:46:54.841766Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\"\"\"\nNOTA:\nComentamos esta celda para tener mejor control de versiones y hacer commit sin necesidad de entrenar en cada commit. El modelo que se encuentra agregado fue entrenado en la versión \"Training\" de este mismo archivo y se puede observar que está en el Output\n\n# --- Criterio de pérdida ---\ncriterion = FocalLoss(alpha=class_weights, gamma=FOCAL_GAMMA)\n\n# --- Optimizer Fase 1 (solo clasificador) ---\noptimizer = optim.Adam(\n    filter(lambda p: p.requires_grad, model.parameters()),\n    lr=LR_PHASE1,\n    weight_decay=WEIGHT_DECAY\n)\n\n# --- Scheduler: reduce LR si val_loss no mejora ---\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(\n    optimizer, mode='min', factor=0.5, patience=3\n)\n\n# --- Variables de tracking ---\nbest_val_acc = 0.0\nbest_val_f1  = 0.0\nepochs_no_improve = 0\nhistory = {\n    'train_loss': [], 'val_loss': [],\n    'train_acc': [],  'val_acc': []\n}\n\nprint(\"=\" * 60)\nprint(\"INICIO DEL ENTRENAMIENTO\")\nprint(\"=\" * 60)\n\nfor epoch in range(NUM_EPOCHS):\n\n    if epoch == PHASE1_EPOCHS:\n        print(\"\\n\" + \"=\" * 60)\n        print(f\"FASE 2: Descongelando backbone para fine-tuning (epoch {epoch+1})\")\n        print(\"=\" * 60)\n        \n        # Descongelar TODOS los parámetros\n        for param in model.parameters():\n            param.requires_grad = True\n        \n        # Nuevo optimizer con learning rate bajo para no destruir features aprendidos\n        optimizer = optim.Adam(model.parameters(), lr=LR_PHASE2, weight_decay=WEIGHT_DECAY)\n        scheduler = optim.lr_scheduler.ReduceLROnPlateau(\n            optimizer, mode='min', factor=0.5, patience=3\n        )\n        \n        trainable_now = sum(p.numel() for p in model.parameters() if p.requires_grad)\n        print(f\"Parámetros entrenables ahora: {trainable_now:,}\")\n        epochs_no_improve = 0  # Resetear early stopping\n    \n    # Indicador de fase actual\n    phase = \"Fase 1 (Feature Extraction)\" if epoch < PHASE1_EPOCHS else \"Fase 2 (Fine-tuning)\"\n    print(f\"\\n--- Epoch {epoch+1}/{NUM_EPOCHS} [{phase}] ---\")\n    \n    # Entrenamiento\n    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, epoch)\n    \n    # Validación\n    val_loss, val_acc, val_preds, val_labels, val_probs = validate(model, val_loader, criterion)\n    \n    # Calcular F1-Score macro\n    val_f1 = f1_score(val_labels, val_preds, average='macro')\n    \n    # Guardar historial\n    history['train_loss'].append(train_loss)\n    history['val_loss'].append(val_loss)\n    history['train_acc'].append(train_acc)\n    history['val_acc'].append(val_acc)\n    \n    # Scheduler step\n    scheduler.step(val_loss)\n    \n    # Imprimir resumen del epoch\n    current_lr = optimizer.param_groups[0]['lr']\n    print(f\"  Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}%\")\n    print(f\"  Val Loss:   {val_loss:.4f} | Val Acc:   {val_acc:.2f}% | Val F1: {val_f1:.4f}\")\n    print(f\"  LR actual:  {current_lr:.2e}\")\n    \n    # Guardar mejor modelo (basado en Val Accuracy)\n    if val_acc > best_val_acc:\n        best_val_acc = val_acc\n        best_val_f1  = val_f1\n        epochs_no_improve = 0\n        torch.save({\n            'epoch': epoch,\n            'model_state_dict': model.state_dict(),\n            'val_acc': val_acc,\n            'val_f1': val_f1,\n        }, '/kaggle/working/best_model.pth')\n        print(f\"  >>> Mejor modelo guardado! Val Acc: {val_acc:.2f}% | F1: {val_f1:.4f}\")\n    else:\n        epochs_no_improve += 1\n        print(f\"  Sin mejora ({epochs_no_improve}/{PATIENCE})\")\n    \n    # Early stopping (solo en Fase 2)\n    if epoch >= PHASE1_EPOCHS and epochs_no_improve >= PATIENCE:\n        print(f\"\\n*** Early stopping activado en epoch {epoch+1} ***\")\n        break\n\nprint(\"\\n\" + \"=\" * 60)\nprint(f\"ENTRENAMIENTO FINALIZADO\")\nprint(f\"Mejor Val Accuracy: {best_val_acc:.2f}%\")\nprint(f\"Mejor Val F1-Score: {best_val_f1:.4f}\")\nprint(\"=\" * 60)\n\"\"\"\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:54.843111Z","iopub.execute_input":"2026-03-16T03:46:54.843426Z","iopub.status.idle":"2026-03-16T03:46:54.863014Z","shell.execute_reply.started":"2026-03-16T03:46:54.843390Z","shell.execute_reply":"2026-03-16T03:46:54.862458Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"MODEL_PATH = '/kaggle/input/models/gorozcor21/cassava-efficientnet-b4/pytorch/default/1/best_model.pth'\n\ncheckpoint = torch.load(MODEL_PATH, map_location=device)\nmodel.load_state_dict(checkpoint['model_state_dict'])\nmodel.eval()\n\nprint(f\"\\nModelo cargado exitosamente!\")\nprint(f\"  Epoch del mejor modelo: {checkpoint['epoch'] + 1}\")\nprint(f\"  Val Accuracy guardada:  {checkpoint['val_acc']:.2f}%\")\nprint(f\"  Val F1-Score guardada:  {checkpoint['val_f1']:.4f}\")\n\n# --- Evaluación final ---\nprint(\"\\nEvaluando modelo en validation set...\")\nval_loss, val_acc, val_preds, val_labels, val_probs = validate(model, val_loader, criterion)\n\n# --- Classification Report ---\nprint(\"\\n\" + \"=\" * 60)\nprint(\"REPORTE DE CLASIFICACIÓN COMPLETO\")\nprint(\"=\" * 60)\nreport = classification_report(\n    val_labels, val_preds,\n    target_names=CLASS_NAMES,\n    digits=4\n)\nprint(report)\n\n# --- Métricas globales ---\naccuracy    = accuracy_score(val_labels, val_preds)\nprecision   = precision_score(val_labels, val_preds, average='macro')\nrecall      = recall_score(val_labels, val_preds, average='macro')\nf1_macro    = f1_score(val_labels, val_preds, average='macro')\nf1_weighted = f1_score(val_labels, val_preds, average='weighted')\n\nprint(f\"\\n--- Métricas Globales ---\")\nprint(f\"  Accuracy:              {accuracy:.4f} ({accuracy*100:.2f}%)\")\nprint(f\"  Precision (macro):     {precision:.4f}\")\nprint(f\"  Recall/Sensib (macro): {recall:.4f}\")\nprint(f\"  F1-Score (macro):      {f1_macro:.4f}\")\nprint(f\"  F1-Score (weighted):   {f1_weighted:.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:46:54.863793Z","iopub.execute_input":"2026-03-16T03:46:54.864128Z","iopub.status.idle":"2026-03-16T03:47:25.003845Z","shell.execute_reply.started":"2026-03-16T03:46:54.864104Z","shell.execute_reply":"2026-03-16T03:47:25.002728Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"cm = confusion_matrix(val_labels, val_preds)\n\n# --- Gráficas ---\nfig, axes = plt.subplots(1, 2, figsize=(18, 7))\n\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues',\n            xticklabels=CLASS_NAMES, yticklabels=CLASS_NAMES, ax=axes[0])\naxes[0].set_ylabel('Etiqueta Real')\naxes[0].set_xlabel('Predicción del Modelo')\naxes[0].set_title('Matriz de Confusión (Conteos)')\naxes[0].tick_params(axis='x', rotation=45)\naxes[0].tick_params(axis='y', rotation=0)\n\ncm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]\nsns.heatmap(cm_normalized, annot=True, fmt='.2%', cmap='Blues',\n            xticklabels=CLASS_NAMES, yticklabels=CLASS_NAMES, ax=axes[1])\naxes[1].set_ylabel('Etiqueta Real')\naxes[1].set_xlabel('Predicción del Modelo')\naxes[1].set_title('Matriz de Confusión (Normalizada)')\naxes[1].tick_params(axis='x', rotation=45)\naxes[1].tick_params(axis='y', rotation=0)\n\nplt.tight_layout()\nplt.savefig('/kaggle/working/confusion_matrix.png', dpi=200, bbox_inches='tight')\nplt.show()\n\n# --- TP, TN, FP, FN por clase ---\nprint(\"\\n\" + \"=\" * 60)\nprint(\"VERDADEROS POSITIVOS, NEGATIVOS, FALSOS POSITIVOS Y NEGATIVOS\")\nprint(\"=\" * 60)\nprint(f\"\\n{'Clase':<30} {'TP':>6} {'TN':>6} {'FP':>6} {'FN':>6} {'Prec':>8} {'Recall':>8} {'F1':>8}\")\nprint(\"-\" * 86)\n\ntp_total, tn_total, fp_total, fn_total = 0, 0, 0, 0\n\nfor i in range(NUM_CLASSES):\n    tp = cm[i, i]\n    fn = cm[i, :].sum() - tp\n    fp = cm[:, i].sum() - tp\n    tn = cm.sum() - tp - fn - fp\n\n    tp_total += tp\n    tn_total += tn\n    fp_total += fp\n    fn_total += fn\n\n    prec_i   = tp / (tp + fp) if (tp + fp) > 0 else 0\n    recall_i = tp / (tp + fn) if (tp + fn) > 0 else 0\n    f1_i     = 2 * prec_i * recall_i / (prec_i + recall_i) if (prec_i + recall_i) > 0 else 0\n\n    print(f\"  {CLASS_NAMES[i]:<28} {tp:>6} {tn:>6} {fp:>6} {fn:>6} {prec_i:>8.4f} {recall_i:>8.4f} {f1_i:>8.4f}\")\n\nprint(\"-\" * 86)\nprint(f\"  {'TOTAL':<28} {tp_total:>6} {tn_total:>6} {fp_total:>6} {fn_total:>6}\")\n\ntotal_samples = cm.sum()\ntotal_correct = np.trace(cm)\nprint(f\"\\n  Total muestras evaluadas:    {total_samples}\")\nprint(f\"  Predicciones correctas:      {total_correct}\")\nprint(f\"  Predicciones incorrectas:    {total_samples - total_correct}\")\nprint(f\"  Accuracy global:             {total_correct/total_samples:.4f} ({total_correct/total_samples*100:.2f}%)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:47:41.279041Z","iopub.execute_input":"2026-03-16T03:47:41.279358Z","iopub.status.idle":"2026-03-16T03:47:42.380659Z","shell.execute_reply.started":"2026-03-16T03:47:41.279325Z","shell.execute_reply":"2026-03-16T03:47:42.379842Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Datos extraídos de los logs de la versión \"Training\" (40 epochs) para realizar la curva de entrenamiento\ntrain_losses = [\n    0.3507, 0.2980, 0.2871, 0.2809, 0.2724, 0.2725, 0.2687, 0.2619, 0.2598, 0.2605,\n    0.2488, 0.2385, 0.2291, 0.2222, 0.2168, 0.2160, 0.2067, 0.2068, 0.2011, 0.1998,\n    0.1954, 0.1912, 0.1902, 0.1888, 0.1833, 0.1792, 0.1788, 0.1738, 0.1730, 0.1739,\n    0.1697, 0.1665, 0.1661, 0.1620, 0.1640, 0.1617, 0.1561, 0.1569, 0.1507, 0.1532\n]\n\nval_losses = [\n    0.2990, 0.2608, 0.2582, 0.2578, 0.2505, 0.2460, 0.2519, 0.2523, 0.2514, 0.2531,\n    0.2434, 0.2389, 0.2329, 0.2280, 0.2242, 0.2212, 0.2193, 0.2155, 0.2169, 0.2185,\n    0.2084, 0.2134, 0.2074, 0.2027, 0.2025, 0.1993, 0.1997, 0.2046, 0.1972, 0.1994,\n    0.1944, 0.1937, 0.1921, 0.1921, 0.1934, 0.1911, 0.1889, 0.1869, 0.1875, 0.1864\n]\n\ntrain_accs = [\n    57.84, 63.80, 64.12, 65.38, 65.31, 65.76, 65.88, 66.14, 66.85, 66.58,\n    68.00, 68.88, 69.74, 69.50, 70.14, 71.54, 71.32, 72.19, 72.92, 72.63,\n    73.34, 73.54, 73.91, 74.26, 74.90, 74.88, 75.85, 75.87, 76.28, 76.42,\n    76.84, 76.86, 77.06, 78.10, 77.70, 77.85, 77.92, 78.04, 79.00, 78.77\n]\n\nval_accs = [\n    62.22, 64.42, 69.02, 69.18, 72.62, 69.95, 67.80, 70.70, 72.71, 70.44,\n    71.40, 70.07, 73.74, 74.09, 75.51, 74.21, 75.07, 72.92, 73.57, 72.31,\n    76.73, 76.57, 76.19, 78.93, 78.36, 79.11, 78.62, 77.34, 78.22, 79.67,\n    79.56, 78.86, 79.98, 78.15, 79.93, 80.23, 80.07, 79.88, 80.54, 79.32\n]\n\nepochs_range = range(1, len(train_losses) + 1)\n\nfig, axes = plt.subplots(1, 2, figsize=(16, 6))\n\n# Curva de Loss\naxes[0].plot(epochs_range, train_losses, 'b-o', label='Train Loss', markersize=3)\naxes[0].plot(epochs_range, val_losses, 'r-o', label='Validation Loss', markersize=3)\naxes[0].axvline(x=10, color='green', linestyle='--', alpha=0.7, label='Inicio Fase 2 (epoch 11)')\naxes[0].set_xlabel('Epoch')\naxes[0].set_ylabel('Loss (Focal Loss)')\naxes[0].set_title('Curvas de Pérdida')\naxes[0].legend()\naxes[0].grid(True, alpha=0.3)\n\n# Curva de Accuracy\naxes[1].plot(epochs_range, train_accs, 'b-o', label='Train Accuracy', markersize=3)\naxes[1].plot(epochs_range, val_accs, 'r-o', label='Validation Accuracy', markersize=3)\naxes[1].axvline(x=10, color='green', linestyle='--', alpha=0.7, label='Inicio Fase 2 (epoch 11)')\naxes[1].set_xlabel('Epoch')\naxes[1].set_ylabel('Accuracy (%)')\naxes[1].set_title('Curvas de Accuracy')\naxes[1].legend()\naxes[1].grid(True, alpha=0.3)\n\nplt.suptitle('Curvas de Entrenamiento - EfficientNet-B4 + Focal Loss (40 Epochs)', fontsize=14)\nplt.tight_layout()\nplt.savefig('/kaggle/working/training_curves.png', dpi=200, bbox_inches='tight')\nplt.show()\n\n# Resumen\nprint(f\"Train Acc final:  {train_accs[-1]:.2f}%\")\nprint(f\"Val Acc final:    {val_accs[-1]:.2f}%\")\nprint(f\"Mejor Val Acc:    {max(val_accs):.2f}% (epoch {val_accs.index(max(val_accs))+1})\")\nprint(f\"Gap (overfitting): {train_accs[-1] - val_accs[-1]:.2f}%\", \"- Aceptable\" if abs(train_accs[-1] - val_accs[-1]) < 5 else \"- Posible overfitting\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:51:58.116578Z","iopub.execute_input":"2026-03-16T03:51:58.117353Z","iopub.status.idle":"2026-03-16T03:51:59.060692Z","shell.execute_reply.started":"2026-03-16T03:51:58.117318Z","shell.execute_reply":"2026-03-16T03:51:59.059882Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"fig, ax = plt.subplots(1, 1, figsize=(10, 8))\ncolors_roc = ['#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#3498db']\n\nauc_scores = {}\n\nfor i in range(NUM_CLASSES):\n    y_true_binary = (val_labels == i).astype(int)\n    y_prob_class  = val_probs[:, i]\n\n    fpr, tpr, _ = roc_curve(y_true_binary, y_prob_class)\n    roc_auc = auc(fpr, tpr)\n    auc_scores[CLASS_NAMES[i]] = roc_auc\n\n    ax.plot(fpr, tpr, color=colors_roc[i], lw=2,\n            label=f'{CLASS_NAMES[i]} (AUC = {roc_auc:.4f})')\n\nax.plot([0, 1], [0, 1], 'k--', lw=1, label='Aleatorio (AUC = 0.5)')\nax.set_xlabel('Tasa de Falsos Positivos (FPR)')\nax.set_ylabel('Tasa de Verdaderos Positivos (TPR)')\nax.set_title('Curvas ROC por Clase')\nax.legend(loc='lower right')\nax.grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig('/kaggle/working/roc_curves.png', dpi=200, bbox_inches='tight')\nplt.show()\n\nprint(\"\\n--- AUC por clase ---\")\nfor name, score in auc_scores.items():\n    print(f\"  {name}: {score:.4f}\")\nmean_auc = np.mean(list(auc_scores.values()))\nprint(f\"\\n  AUC promedio: {mean_auc:.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:52:51.881193Z","iopub.execute_input":"2026-03-16T03:52:51.881788Z","iopub.status.idle":"2026-03-16T03:52:52.484343Z","shell.execute_reply.started":"2026-03-16T03:52:51.881756Z","shell.execute_reply":"2026-03-16T03:52:52.483750Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from collections import Counter\n\nwrong_mask = val_preds != val_labels\nwrong_indices = np.where(wrong_mask)[0]\nprint(f\"Total predicciones incorrectas: {len(wrong_indices)} de {len(val_labels)}\")\nprint(f\"Tasa de error: {len(wrong_indices)/len(val_labels)*100:.2f}%\")\n\nnum_show = min(12, len(wrong_indices))\nnp.random.seed(SEED)\nsample_wrong = np.random.choice(wrong_indices, num_show, replace=False)\n\nfig, axes = plt.subplots(2, 6, figsize=(24, 8))\naxes = axes.flatten()\n\nfor i, idx in enumerate(sample_wrong):\n    row = val_df.iloc[idx]\n    img = Image.open(os.path.join(TRAIN_IMGS, row['image_id']))\n\n    true_label = val_labels[idx]\n    pred_label = val_preds[idx]\n    confidence = val_probs[idx, pred_label] * 100\n\n    axes[i].imshow(img)\n    axes[i].set_title(\n        f\"Real: {true_label} ({CLASS_NAMES[true_label][:4]})\\n\"\n        f\"Pred: {pred_label} ({CLASS_NAMES[pred_label][:4]})\\n\"\n        f\"Conf: {confidence:.1f}%\",\n        fontsize=8,\n        color='red'\n    )\n    axes[i].axis('off')\n\nplt.suptitle('Ejemplos de Predicciones Incorrectas', fontsize=14)\nplt.tight_layout()\nplt.savefig('/kaggle/working/error_analysis.png', dpi=150, bbox_inches='tight')\nplt.show()\n\nprint(\"\\n--- Top 10 Confusiones más frecuentes ---\")\nconfusions = [(val_labels[i], val_preds[i]) for i in wrong_indices]\nconfusion_counts = Counter(confusions)\nfor (true, pred), count in confusion_counts.most_common(10):\n    pct = count / len(wrong_indices) * 100\n    print(f\"  {CLASS_NAMES[true]} -> confundida con {CLASS_NAMES[pred]}: {count} veces ({pct:.1f}%)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:53:03.541910Z","iopub.execute_input":"2026-03-16T03:53:03.542265Z","iopub.status.idle":"2026-03-16T03:53:07.672685Z","shell.execute_reply.started":"2026-03-16T03:53:03.542236Z","shell.execute_reply":"2026-03-16T03:53:07.671791Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(\"=\" * 60)\nprint(\"RESUMEN FINAL DEL PROYECTO\")\nprint(\"=\" * 60)\n\nprint(f\"\"\"\nModelo: EfficientNet-B4 (Transfer Learning desde ImageNet)\nPesos cargados desde: {MODEL_PATH}\nDataset: Cassava Leaf Disease Classification (Kaggle)\n  - Train: {len(train_df)} imagenes | Validation: {len(val_df)} imagenes\n  - Clases: {NUM_CLASSES} ({', '.join(CLASS_NAMES)})\n\nTecnicas aplicadas:\n  - Transfer Learning (2 fases: congelado + fine-tuning)\n  - Focal Loss (gamma={FOCAL_GAMMA}) para desbalance de clases\n  - Data Augmentation (flip, rotacion, brillo, CoarseDropout)\n  - ReduceLROnPlateau + Early Stopping\n\nHiperparametros:\n  - Imagen: {IMG_SIZE}x{IMG_SIZE}\n  - Batch size: {BATCH_SIZE}\n  - LR Fase 1: {LR_PHASE1} | LR Fase 2: {LR_PHASE2}\n\nResultados Finales:\n  - Accuracy:            {accuracy*100:.2f}%\n  - Precision (macro):   {precision*100:.2f}%\n  - Recall (macro):      {recall*100:.2f}%\n  - F1-Score (macro):    {f1_macro:.4f}\n  - F1-Score (weighted): {f1_weighted:.4f}\n  - AUC promedio:        {mean_auc:.4f}\n\"\"\")\n\n# Guardar resumen\nwith open('/kaggle/working/resultados_resumen.txt', 'w') as f:\n    f.write(\"RESUMEN DE RESULTADOS - Cassava Leaf Disease Classification\\n\")\n    f.write(\"=\" * 50 + \"\\n\\n\")\n    f.write(f\"Modelo cargado desde: {MODEL_PATH}\\n\")\n    f.write(f\"Epoch del mejor modelo: {checkpoint['epoch'] + 1}\\n\\n\")\n    f.write(f\"Accuracy: {accuracy:.4f} ({accuracy*100:.2f}%)\\n\")\n    f.write(f\"Precision (macro): {precision:.4f}\\n\")\n    f.write(f\"Recall (macro): {recall:.4f}\\n\")\n    f.write(f\"F1-Score (macro): {f1_macro:.4f}\\n\")\n    f.write(f\"F1-Score (weighted): {f1_weighted:.4f}\\n\")\n    f.write(f\"AUC promedio: {mean_auc:.4f}\\n\")\n    f.write(f\"\\nClassification Report:\\n{report}\\n\")\n    f.write(f\"\\nMatriz de Confusion:\\n{cm}\\n\")\n\n# Listar archivos generados\nprint(\"\\nArchivos guardados en /kaggle/working/:\")\nfor f in sorted(os.listdir('/kaggle/working/')):\n    if not f.startswith('.'):\n        size = os.path.getsize(f'/kaggle/working/{f}')\n        if size > 1024*1024:\n            print(f\"  {f} ({size/(1024*1024):.1f} MB)\")\n        else:\n            print(f\"  {f} ({size/1024:.1f} KB)\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-16T03:53:28.639639Z","iopub.execute_input":"2026-03-16T03:53:28.640417Z","iopub.status.idle":"2026-03-16T03:53:28.649848Z","shell.execute_reply.started":"2026-03-16T03:53:28.640386Z","shell.execute_reply":"2026-03-16T03:53:28.648920Z"}},"outputs":[],"execution_count":null}]}