{"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":3175,"databundleVersionId":44352,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":308888038,"isSourceIdPinned":false}],"dockerImageVersionId":31328,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"## Configuration","metadata":{}},{"cell_type":"code","source":"CFG = {\n    'seed': 42,\n    'model_name': 'efficientnet_b0', # Change this to 'efficientnet_b0' later\n    'save_path' : '/kaggle/working/',\n    'model_title': 'efficientnet_b0_v1.pth',\n    'img_size': 224,          # EfficientNet-B0 also uses 224x224\n    'batch_size': 8,\n    'epochs': 8,\n    'lr': 1e-4,\n    'weight_decay': 1e-5,\n    'num_classes': 3,\n    'num_workers': 0\n}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:11:48.702989Z","iopub.execute_input":"2026-04-04T23:11:48.703282Z","iopub.status.idle":"2026-04-04T23:11:48.707928Z","shell.execute_reply.started":"2026-04-04T23:11:48.703256Z","shell.execute_reply":"2026-04-04T23:11:48.707245Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Loading the Stratified Split Metadata","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nimport os\n\n# 1. Get the Labels from your Preprocessing output\n# (Just unzip the SMALL output.zip for the CSVs)\n!unzip -q /kaggle/input/notebooks/tatumsoward/aig210-galaxyzoopreprocessing/_output_.zip -d /kaggle/temp/\n\n# 2. Path to your pre-split CSV\nTRAIN_CSV = '/kaggle/temp/train_split.csv'\n\n# 3. Path to the ORIGINAL images (Mount the dataset for this!)\n# Change this path to the exact location of the dataset ZIP\nIMG_ZIP = '/kaggle/input/competitions/galaxy-zoo-the-galaxy-challenge/images_training_rev1.zip'\n\n# 4. Unzip the images ONLY to the fast temp directory\n!unzip -q {IMG_ZIP} -d /kaggle/temp/images/\n\nBASE_PATH = '/kaggle/temp/'\nIMG_DIR = os.path.join(BASE_PATH, 'images_training_rev1', 'images_training_rev1')\n\nprint(\"CSVs and Images are now linked and ready in /kaggle/temp/\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:11:48.717781Z","iopub.execute_input":"2026-04-04T23:11:48.718151Z","iopub.status.idle":"2026-04-04T23:12:32.999494Z","shell.execute_reply.started":"2026-04-04T23:11:48.718126Z","shell.execute_reply":"2026-04-04T23:12:32.998703Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#Data Loaded Sanity Check\n\nimport os\nimport pandas as pd\nfrom PIL import Image\nimport matplotlib.pyplot as plt\n\n# 1. Check if the CSVs exist\ncsv_path = '/kaggle/temp/train_split.csv'\nif os.path.exists(csv_path):\n    train_df = pd.read_csv(csv_path)\n    print(f\"✅ CSV Found! Training rows: {len(train_df)}\")\nelse:\n    print(\"❌ CSV MISSING! Check your unzip path.\")\n\n# 2. Check the Image folder\n# Note: Unzipping 'images_training_rev1.zip' usually creates a nested folder\nimg_dir = '/kaggle/temp/images/images_training_rev1' \n\nif os.path.exists(img_dir):\n    num_images = len(os.listdir(img_dir))\n    print(f\"✅ Image Folder Found! Total files: {num_images}\")\n    \n    # 3. Pull a random sample for a \"Visual Check\"\n    sample_id = train_df.iloc[0]['GalaxyID']\n    sample_path = os.path.join(img_dir, f\"{sample_id}.jpg\")\n    \n    if os.path.exists(sample_path):\n        img = Image.open(sample_path)\n        plt.imshow(img)\n        plt.title(f\"Sanity Check: Galaxy {sample_id}\")\n        plt.axis('off')\n        plt.show()\n    else:\n        print(f\"❌ Image file {sample_id}.jpg not found inside {img_dir}\")\nelse:\n    print(f\"❌ Image directory {img_dir} is missing!\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.001374Z","iopub.execute_input":"2026-04-04T23:12:33.001722Z","iopub.status.idle":"2026-04-04T23:12:33.253101Z","shell.execute_reply.started":"2026-04-04T23:12:33.001689Z","shell.execute_reply":"2026-04-04T23:12:33.252371Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Preparing the Data for the Model","metadata":{}},{"cell_type":"markdown","source":"### Transformations and Data Augmentation","metadata":{}},{"cell_type":"code","source":"import json\n\n# 1. Load the stats from the same temp directory as your CSVs\nstats_path = '/kaggle/temp/norm_stats.json'\n\nwith open(stats_path, 'r') as f:\n    norm_dict = json.load(f)\n\nGALAXY_MEAN = norm_dict['mean']\nGALAXY_STD = norm_dict['std']\n\nprint(f\"✅ Loaded Normalization Stats:\")\nprint(f\"Mean: {GALAXY_MEAN}\")\nprint(f\"Std:  {GALAXY_STD}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.254027Z","iopub.execute_input":"2026-04-04T23:12:33.254348Z","iopub.status.idle":"2026-04-04T23:12:33.259737Z","shell.execute_reply.started":"2026-04-04T23:12:33.254324Z","shell.execute_reply":"2026-04-04T23:12:33.259193Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from torchvision import transforms\n\ntrain_transforms = transforms.Compose([\n    transforms.RandomResizedCrop(224, scale=(0.8, 1.0), ratio=(0.9, 1.1)), #for EfficiNet\n    #transforms.CenterCrop(CFG['img_size']), # for ResNet\n    transforms.RandomRotation(180), # Galaxies can be at any angle!\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(), #for EfficiNet\n    transforms.ToTensor(),\n    transforms.Normalize(mean=GALAXY_MEAN, std=GALAXY_STD)\n])\n\nval_transforms = transforms.Compose([\n    transforms.CenterCrop(CFG['img_size']),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=GALAXY_MEAN, std=GALAXY_STD)\n])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.261412Z","iopub.execute_input":"2026-04-04T23:12:33.261740Z","iopub.status.idle":"2026-04-04T23:12:33.277843Z","shell.execute_reply.started":"2026-04-04T23:12:33.261713Z","shell.execute_reply":"2026-04-04T23:12:33.277001Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Weighted Sampler for Class Imbalance","metadata":{}},{"cell_type":"code","source":"import torch\nfrom torch.utils.data import WeightedRandomSampler\n\n# Double-check this part of your code:\nclass_counts = train_df['label'].value_counts()\nweights = 1.0 / class_counts\nsample_weights = [weights[label] for label in train_df['label']]\n\nsampler = WeightedRandomSampler(\n    weights=sample_weights,\n    num_samples=len(train_df), # <--- MUST match len(train_dataset)\n    replacement=True\n)\n\nprint(f\"Sampler initialized for {len(class_counts)} classes.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.278724Z","iopub.execute_input":"2026-04-04T23:12:33.278917Z","iopub.status.idle":"2026-04-04T23:12:33.323257Z","shell.execute_reply.started":"2026-04-04T23:12:33.278897Z","shell.execute_reply":"2026-04-04T23:12:33.322653Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Initializing Data Class","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nfrom torch.utils.data import Dataset\nfrom PIL import Image\n\n# Update your paths to point to the TEMP directory\nBASE_PATH = '/kaggle/temp/'\nTRAIN_CSV = os.path.join(BASE_PATH, 'train_split.csv')\n# Note: Unzipping 'images_training_rev1.zip' usually creates a subfolder with the same name\nIMG_DIR = os.path.join(BASE_PATH, 'images_training_rev1', 'images_training_rev1')\n\nclass GalaxyDataset(Dataset):\n    def __init__(self, csv_file, img_dir, transform=None):\n        # Read and force a clean integer index\n        self.df = pd.read_csv(csv_file).reset_index(drop=True)\n        self.img_dir = img_dir\n        self.transform = transform\n        # Ensure keys match exactly what is in your CSV\n        self.label_map = {'Elliptical': 0, 'Spiral': 1, 'Irregular': 2}\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        # FORCE idx to be a Python int. \n        # Sometimes Samplers pass a numpy.int64 which can cause indexing bugs.\n        if hasattr(idx, 'item'):\n            idx = idx.item()\n        idx = int(idx)\n\n        row = self.df.iloc[idx]\n        \n        # 1. Handle Label (The most likely source of 'string index' errors)\n        label_name = str(row['label']) \n        label = self.label_map[label_name]\n        \n        # 2. Handle Image ID\n        img_id = str(int(row['GalaxyID']))\n        img_path = os.path.join(self.img_dir, f\"{img_id}.jpg\")\n        \n        image = Image.open(img_path).convert('RGB')\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, label","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.324199Z","iopub.execute_input":"2026-04-04T23:12:33.324479Z","iopub.status.idle":"2026-04-04T23:12:33.331549Z","shell.execute_reply.started":"2026-04-04T23:12:33.324448Z","shell.execute_reply":"2026-04-04T23:12:33.330726Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Initialize Dataset Objects\n# These use the GalaxyDataset class we defined earlier\ntrain_dataset = GalaxyDataset(\n    csv_file=TRAIN_CSV, \n    img_dir=IMG_DIR, \n    transform=train_transforms\n)\n\nval_dataset = GalaxyDataset(\n    csv_file=os.path.join(BASE_PATH, 'val_split.csv'), \n    img_dir=IMG_DIR, \n    transform=val_transforms\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.332585Z","iopub.execute_input":"2026-04-04T23:12:33.332923Z","iopub.status.idle":"2026-04-04T23:12:33.447855Z","shell.execute_reply.started":"2026-04-04T23:12:33.332890Z","shell.execute_reply":"2026-04-04T23:12:33.447210Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Loading the Data","metadata":{}},{"cell_type":"code","source":"from torch.utils.data import DataLoader\ntrain_df_clean = train_dataset.df.reset_index(drop=True)\ntrain_dataset.df = train_df_clean # Force alignment\n\ncounts = train_df_clean['label'].value_counts()\nweights_map = 1.0 / counts\nsample_weights = train_df_clean['label'].map(weights_map).values\n\nsampler = WeightedRandomSampler(\n    weights=torch.DoubleTensor(sample_weights),\n    num_samples=len(train_df_clean),\n    replacement=True\n)\n\n# 3. Create the DataLoaders\n# The training loader uses the sampler; the validation loader just shuffles normally\ntrain_loader = DataLoader(\n    train_dataset, \n    batch_size=CFG['batch_size'], \n    sampler=sampler,\n    num_workers=CFG['num_workers'],\n    pin_memory=False # Changed to False # Speeds up transfer to GPU\n)\n\nval_loader = DataLoader(\n    val_dataset, \n    batch_size=CFG['batch_size'], \n    shuffle=False, \n    num_workers=CFG['num_workers'],\n    pin_memory=False # Changed to False\n)\n\nprint(f\"✅ DataLoaders Ready!\")\nprint(f\"Training samples: {len(train_dataset)}\")\nprint(f\"Validation samples: {len(val_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.448777Z","iopub.execute_input":"2026-04-04T23:12:33.449129Z","iopub.status.idle":"2026-04-04T23:12:33.461527Z","shell.execute_reply.started":"2026-04-04T23:12:33.449103Z","shell.execute_reply":"2026-04-04T23:12:33.460750Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import matplotlib.pyplot as plt\n\n# Try to grab one actual image from the loader\ntry:\n    images, labels = next(iter(train_loader))\n    # Undo normalization for display\n    img = images[0].permute(1, 2, 0).numpy()\n    img = img * [0.229, 0.224, 0.225] + [0.485, 0.456, 0.406]\n    \n    plt.imshow(img.clip(0, 1))\n    plt.title(f\"Success! Label: {labels[0].item()}\")\n    plt.show()\nexcept Exception as e:\n    print(f\"❌ Path is still broken: {e}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.462586Z","iopub.execute_input":"2026-04-04T23:12:33.462981Z","iopub.status.idle":"2026-04-04T23:12:33.691356Z","shell.execute_reply.started":"2026-04-04T23:12:33.462921Z","shell.execute_reply":"2026-04-04T23:12:33.690577Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Initializing the Model","metadata":{}},{"cell_type":"code","source":"import torch.nn as nn\nfrom torchvision import models\n\n# 1. Determine the device\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 2. Initialize the model based on CFG\nif CFG['model_name'] == 'resnet18':\n    model = models.resnet18(weights='IMAGENET1K_V1')\n    num_features = model.fc.in_features\n    model.fc = nn.Linear(num_features, CFG['num_classes'])\n    \nelif 'efficientnet' in CFG['model_name']:\n    # Placeholder for when you switch\n    model = models.efficientnet_b0(weights='IMAGENET1K_V1')\n    num_features = model.classifier[1].in_features\n    model.classifier[1] = nn.Linear(num_features, CFG['num_classes'])\n\nmodel = model.to(device)\nprint(f\"Model '{CFG['model_name']}' loaded and moved to {device}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.693428Z","iopub.execute_input":"2026-04-04T23:12:33.694001Z","iopub.status.idle":"2026-04-04T23:12:33.905714Z","shell.execute_reply.started":"2026-04-04T23:12:33.693935Z","shell.execute_reply":"2026-04-04T23:12:33.904839Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.optim as optim\n\n# Loss function stays standard for 3 classes\ncriterion = nn.CrossEntropyLoss()\n\n# Optimizer pulls values from CFG\noptimizer = optim.Adam(\n    model.parameters(), \n    lr=CFG['lr'], \n    weight_decay=CFG['weight_decay']\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.906744Z","iopub.execute_input":"2026-04-04T23:12:33.907098Z","iopub.status.idle":"2026-04-04T23:12:33.911714Z","shell.execute_reply.started":"2026-04-04T23:12:33.907073Z","shell.execute_reply":"2026-04-04T23:12:33.910999Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Training the Model","metadata":{}},{"cell_type":"markdown","source":"### Training/Validation Definitions","metadata":{}},{"cell_type":"code","source":"import torch\nfrom tqdm.auto import tqdm # For a nice progress bar\n\ndef train_one_epoch(model, loader, optimizer, criterion, device):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    for images, labels in tqdm(loader, desc=\"Training\", leave=False):\n        images, labels = images.to(device), labels.to(device)\n        \n        # 1. Forward pass\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        \n        # 2. Backward pass\n        loss.backward()\n        optimizer.step()\n        \n        # 3. Statistics\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    return running_loss / total, 100. * correct / total\n\ndef validate_one_epoch(model, loader, criterion, device):\n    model.eval()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    with torch.no_grad(): # No gradients needed for validation\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(device), labels.to(device)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \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    return running_loss / total, 100. * correct / total","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.912839Z","iopub.execute_input":"2026-04-04T23:12:33.913123Z","iopub.status.idle":"2026-04-04T23:12:33.929156Z","shell.execute_reply.started":"2026-04-04T23:12:33.913100Z","shell.execute_reply":"2026-04-04T23:12:33.928337Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Main Execution Loop","metadata":{}},{"cell_type":"code","source":"# Storage for plotting later\nhistory = {\n    'train_loss': [], 'train_acc': [],\n    'val_loss': [], 'val_acc': []\n}\n\nbest_val_acc = 0.0\n\nfor epoch in range(1, CFG['epochs'] + 1):\n    # Run loops\n    train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device)\n    val_loss, val_acc = validate_one_epoch(model, val_loader, criterion, device)\n    \n    # Save history\n    history['train_loss'].append(train_loss)\n    history['train_acc'].append(train_acc)\n    history['val_loss'].append(val_loss)\n    history['val_acc'].append(val_acc)\n    \n    print(f\"Epoch [{epoch}/{CFG['epochs']}]\")\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}%\")\n    \n    # Save Best Model Checkpoint\n    if val_acc > best_val_acc:\n        best_val_acc = val_acc\n        save_path = os.path.join(CFG['save_path'], CFG['model_title'])\n        torch.save(model.state_dict(), save_path)\n        print(f\"--- New Best Model Saved (Acc: {val_acc:.2f}%) ---\")\n    print(\"-\" * 30)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:12:33.930216Z","iopub.execute_input":"2026-04-04T23:12:33.930649Z","iopub.status.idle":"2026-04-04T23:26:59.175209Z","shell.execute_reply.started":"2026-04-04T23:12:33.930626Z","shell.execute_reply":"2026-04-04T23:26:59.174496Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Metrics and Evaluation","metadata":{}},{"cell_type":"markdown","source":"### Initializing Testing Set","metadata":{}},{"cell_type":"code","source":"# 1. Create the Test Dataset Instance\n# Use the same transforms as your validation set (no augmentation)\ntest_dataset = GalaxyDataset(\n    csv_file=os.path.join(BASE_PATH, 'test_split.csv'), \n    img_dir=IMG_DIR, \n    transform=val_transforms\n)\n\n# 2. Create the Test Loader\ntest_loader = DataLoader(\n    test_dataset, \n    batch_size=CFG['batch_size'], \n    shuffle=False, # Crucial: Keep order for the confusion matrix\n    num_workers=CFG['num_workers'],\n    pin_memory=False\n)\n\nprint(f\"✅ Test Loader Ready with {len(test_dataset)} images.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:26:59.176543Z","iopub.execute_input":"2026-04-04T23:26:59.176909Z","iopub.status.idle":"2026-04-04T23:26:59.193597Z","shell.execute_reply.started":"2026-04-04T23:26:59.176874Z","shell.execute_reply":"2026-04-04T23:26:59.192696Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Loading Best Model Weights","metadata":{}},{"cell_type":"code","source":"# 1. Re-initialize the architecture\ntest_model = models.resnet18(weights=None) # No need for ImageNet weights now\ntest_model.fc = nn.Linear(test_model.fc.in_features, CFG['num_classes'])\n\n# 2. Load the saved weights\nmodel_path = os.path.join(CFG['save_path'], CFG['model_title'])\ntest_model.load_state_dict(torch.load(model_path, map_location=device))\n\n# 3. Move to GPU and set to EVAL mode\ntest_model = test_model.to(device)\ntest_model.eval()\n\nprint(f\"✅ Loaded best model weights from: {CFG['model_title']}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:26:59.194496Z","iopub.execute_input":"2026-04-04T23:26:59.194850Z","iopub.status.idle":"2026-04-04T23:26:59.435971Z","shell.execute_reply.started":"2026-04-04T23:26:59.194827Z","shell.execute_reply":"2026-04-04T23:26:59.435317Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Testing Loop","metadata":{}},{"cell_type":"code","source":"import numpy as np\n\ny_true = []\ny_pred = []\n\nwith torch.no_grad():\n    for images, labels in tqdm(test_loader, desc=\"Final Evaluation\"):\n        images = images.to(device)\n        \n        # Get predictions\n        outputs = test_model(images)\n        _, predicted = torch.max(outputs, 1)\n        \n        # Move back to CPU and store\n        y_true.extend(labels.numpy())\n        y_pred.extend(predicted.cpu().numpy())\n\n# Convert to numpy arrays for the dashboard\ny_true = np.array(y_true)\ny_pred = np.array(y_pred)\n\nprint(\"✅ Evaluation Complete!\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:27:29.830028Z","iopub.execute_input":"2026-04-04T23:27:29.830720Z","iopub.status.idle":"2026-04-04T23:27:36.600221Z","shell.execute_reply.started":"2026-04-04T23:27:29.830688Z","shell.execute_reply":"2026-04-04T23:27:36.599439Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Report","metadata":{}},{"cell_type":"code","source":"import matplotlib.pyplot as plt\nimport numpy as np\nimport seaborn as sns\nfrom sklearn.metrics import confusion_matrix, classification_report\n\ndef create_performance_dashboard(history, y_true, y_pred, model_name, save_path=None):\n    \"\"\"\n    Creates a single dashboard image combining Loss, Accuracy, Confusion Matrix, and Classification Report.\n    \"\"\"\n    target_names = ['Elliptical', 'Spiral', 'Irregular']\n    \n    # 1. Setup the figure and grid (2 rows, 2 columns)\n    # We make the figure extra wide/tall to fit everything clearly\n    fig, axes = plt.subplots(2, 2, figsize=(18, 14))\n    plt.suptitle(f'Model Performance Dashboard: {model_name}', fontsize=22, fontweight='bold', y=0.98)\n    \n    # Use a professional style\n    plt.style.use('seaborn-v0_8-whitegrid')\n    \n    # Extract specific axes\n    ax_loss, ax_acc = axes[0, 0], axes[0, 1]\n    ax_cm, ax_report = axes[1, 0], axes[1, 1]\n    \n    # --- A. Plot Loss Curves (Top Left) ---\n    epochs = range(1, len(history['train_loss']) + 1)\n    ax_loss.plot(epochs, history['train_loss'], '-o', label='Train Loss', color='#3498db', linewidth=2, markersize=4)\n    ax_loss.plot(epochs, history['val_loss'], '-o', label='Val Loss', color='#e74c3c', linewidth=2, markersize=4)\n    ax_loss.set_title('Training & Validation Loss', fontsize=16)\n    ax_loss.set_xlabel('Epochs')\n    ax_loss.set_ylabel('Cross Entropy Loss')\n    ax_loss.legend(fontsize=12)\n    ax_loss.grid(True, linestyle='--', alpha=0.7)\n\n    # --- B. Plot Accuracy Curves (Top Right) ---\n    best_epoch = np.argmax(history['val_acc'])\n    best_val = history['val_acc'][best_epoch]\n    \n    ax_acc.plot(epochs, history['train_acc'], '-o', label='Train Acc', color='#3498db', linewidth=2, markersize=4)\n    ax_acc.plot(epochs, history['val_acc'], '-o', label='Val Acc', color='#e74c3c', linewidth=2, markersize=4)\n    \n    # Mark the best validation point\n    ax_acc.scatter(best_epoch + 1, best_val, color='black', s=100, zorder=5)\n    ax_acc.annotate(f'Best Val: {best_val:.1f}%', \n                     xy=(best_epoch + 1, best_val), \n                     xytext=(best_epoch + 1, best_val - 8),\n                     arrowprops=dict(facecolor='black', shrink=0.05, width=1.5, headwidth=8),\n                     horizontalalignment='center', fontsize=12, fontweight='bold')\n    \n    ax_acc.set_title('Training & Validation Accuracy', fontsize=16)\n    ax_acc.set_xlabel('Epochs')\n    ax_acc.set_ylabel('Accuracy (%)')\n    ax_acc.legend(fontsize=12)\n    ax_acc.grid(True, linestyle='--', alpha=0.7)\n\n    # --- C. Confusion Matrix (Bottom Left) ---\n    cm = confusion_matrix(y_true, y_pred)\n    # Calculate percentages for alternative view if needed, but counts are standard\n    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', cbar=False,\n                xticklabels=target_names, yticklabels=target_names,\n                ax=ax_cm, annot_kws={\"size\": 16, \"fontweight\": 'bold'})\n    \n    ax_cm.set_title('Confusion Matrix (Test Data)', fontsize=16)\n    ax_cm.set_ylabel('Actual Class', fontsize=14)\n    ax_cm.set_xlabel('Predicted Class', fontsize=14)\n    ax_cm.tick_params(axis='both', which='major', labelsize=12)\n\n    # --- D. Classification Report (Bottom Right) ---\n    # This is the tricky part: turning text into a plot element\n    report = classification_report(y_true, y_pred, target_names=target_names, digits=3)\n    \n    # Clean up the axes (hide grid, spines, ticks)\n    ax_report.axis('off')\n    ax_report.set_title('Classification Report (Test Data)', fontsize=16)\n    \n    # Add the text with a monospace font so the columns align perfectly\n    ax_report.text(0.05, 0.95, report, \n                   fontsize=13, \n                   family='monospace', # CRUCIAL for alignment\n                   transform=ax_report.transAxes, \n                   verticalalignment='top')\n\n    # --- Final Touches ---\n    plt.tight_layout(rect=[0, 0.03, 1, 0.97]) # Adjust layout to fit main title\n    \n    # Save if a path is provided\n    if save_path:\n        plt.savefig(save_path, dpi=300, bbox_inches='tight')\n        print(f\"✅ Dashboard saved to: {save_path}\")\n    \n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:27:43.592434Z","iopub.execute_input":"2026-04-04T23:27:43.592716Z","iopub.status.idle":"2026-04-04T23:27:45.785516Z","shell.execute_reply.started":"2026-04-04T23:27:43.592690Z","shell.execute_reply":"2026-04-04T23:27:45.784869Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Assuming you just finished evaluate_on_test() from the previous step\n# and have y_true and y_pred as numpy arrays or lists\n\ndashboard_filename = os.path.join(CFG['save_path'], f\"{CFG['model_name']}_dashboard.png\")\n\ncreate_performance_dashboard(\n    history=history, \n    y_true=np.array(y_true), # Ensure they are numpy arrays\n    y_pred=np.array(y_pred), \n    model_name=CFG['model_name'].upper(),\n    save_path=dashboard_filename\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-04-04T23:27:48.281486Z","iopub.execute_input":"2026-04-04T23:27:48.281922Z","iopub.status.idle":"2026-04-04T23:27:50.948334Z","shell.execute_reply.started":"2026-04-04T23:27:48.281896Z","shell.execute_reply":"2026-04-04T23:27:50.947507Z"}},"outputs":[],"execution_count":null}]}