{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":18613,"sourceType":"datasetVersion","datasetId":5839}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom glob import glob\nimport os\nimport matplotlib.gridspec as gridspec\nimport matplotlib.ticker as ticker\nsns.set_style('whitegrid')\n%matplotlib inline\nimport warnings\nwarnings.filterwarnings('ignore')\n\n# --- Replaced Keras with PyTorch equivalents ---\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\n\n# Setup for Kaggle T4 GPUs (Parallel Processing)\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\n\n# If using Kaggle's 2x T4 GPUs, this will help later in the model definition:\nif torch.cuda.device_count() > 1:\n    print(f\"Found {torch.cuda.device_count()} GPUs. Ready for DataParallel.\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.model_selection import train_test_split","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"data = pd.read_csv('../input/data/Data_Entry_2017.csv')\ndata = data[data['Patient Age']<100] #removing datapoints which having age greater than 100\n\ndata_image_paths = {os.path.basename(x): x for x in \n                   glob(os.path.join('..', 'input', 'data','images*', '*', '*.png'))}\n\nprint('Scans found:', len(data_image_paths), ', Total Headers', data.shape[0])\n\ndata['path'] = data['Image Index'].map(data_image_paths.get)\ndata['Patient Age'] = data['Patient Age'].map(lambda x: int(x))\ndata.sample(3)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Standardize Finding Labels (already done in your code)\ndata['Finding Labels'] = data['Finding Labels'].map(lambda x: x.replace('No Finding', ''))\n\n# 2. Extract EVERY unique label present in the dataset\nfrom itertools import chain\nall_labels = np.unique(list(chain(*data['Finding Labels'].map(lambda x: x.split('|')).tolist())))\nall_labels = [x for x in all_labels if len(x)>0]\n\nprint(f'All Labels ({len(all_labels)}): {all_labels}')\n\n# 3. Create Multi-Hot columns for EVERY label\nfor c_label in all_labels:\n    # We remove the MIN_CASES check here to keep everything\n    data[c_label] = data['Finding Labels'].map(lambda finding: 1.0 if c_label in finding else 0.0)\n\n# 4. Create the target vector column for the Dataset class\ndata['disease_vec'] = data.apply(lambda x: [x[all_labels].values], axis=1).map(lambda x: x[0])\n\nprint(f\"Final Class Count: {len(all_labels)}\")\nprint(\"Cases per class:\")\nprint(data[all_labels].sum())","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Calculate weights: Images with more findings get higher priority\n# If an image has 0 findings, it gets a weight of 0.04\n# If an image has 3 rare findings, it gets a weight of 3.04\nraw_weights = data['Finding Labels'].map(lambda x: len(x.split('|')) if x != '' else 0).values + 0.04\n\nfrom torch.utils.data import WeightedRandomSampler\n\nsampler_weights = torch.DoubleTensor(raw_weights)\n\n# This sampler will now cover every single image in the full dataset\nsampler = WeightedRandomSampler(\n    weights=sampler_weights, \n    num_samples=len(data), \n    replacement=True\n)\n\nprint(f\"Sampler initialized for {len(data)} images.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Use the full dataset instead of sampling 40000\n# data = data  <-- Just keep the original 'data' dataframe\n\n# 2. Calculate weights for the entire dataset\n# This logic gives more weight to images with multiple findings\nraw_weights = data['Finding Labels'].map(lambda x: len(x.split('|')) if x != 'No Finding' else 0).values + 0.04\n\n# 3. Create the Sampler\n# This tells PyTorch: \"When training, pick these indices based on these weights\"\nfrom torch.utils.data import WeightedRandomSampler\n\n# Convert weights to a tensor\nsampler_weights = torch.DoubleTensor(raw_weights)\nsampler = WeightedRandomSampler(sampler_weights, num_samples=len(sampler_weights), replacement=True)\n\nprint(f\"Total images being used for training/val: {len(data)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Visualize the top 15 categories in the full dataset\nlabel_counts = data['Finding Labels'].value_counts()[:15]\nfig, ax1 = plt.subplots(1, 1, figsize=(12, 8))\nax1.bar(np.arange(len(label_counts)) + 0.5, label_counts)\nax1.set_xticks(np.arange(len(label_counts)) + 0.5)\nax1.set_xticklabels(label_counts.index, rotation=90)\nax1.set_title(\"Distribution of Full Dataset (All Images)\")\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Re-run the split on the full data\ntrain_df, test_df = train_test_split(data, \n                                   test_size = 0.20, \n                                   random_state = 2018,\n                                   stratify = data['Finding Labels'].map(lambda x: x[:4]))\n\n# Further split test into validation and test (standard practice)\n# This gives you a clean validation set for your 'best model' logic\nvalid_df, test_df = train_test_split(test_df, \n                                   test_size = 0.50, \n                                   random_state = 2018)\n\nprint(f'Train set: {train_df.shape[0]}')\nprint(f'Val set: {valid_df.shape[0]}')\nprint(f'Test set: {test_df.shape[0]}')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Calculate weights specifically for the TRAIN set rows\ntrain_raw_weights = train_df['Finding Labels'].map(lambda x: len(x.split('|')) if x != '' else 0).values + 0.04\n\n# 2. Create the Sampler for Training\nfrom torch.utils.data import WeightedRandomSampler\ntrain_sampler_weights = torch.DoubleTensor(train_raw_weights)\ntrain_sampler = WeightedRandomSampler(\n    weights=train_sampler_weights, \n    num_samples=len(train_df), \n    replacement=True\n)\n\n# 3. Validation and Test do NOT use a sampler (they use standard shuffling)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df, valid_df = train_test_split(train_df, \n                                   test_size = 0.10, \n                                   random_state = 2018,\n                                   stratify = train_df['Finding Labels'].map(lambda x: x[:4]))\n\nprint('train', train_df.shape[0], 'valid', valid_df.shape[0])","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from torch.utils.data import Dataset, DataLoader\nfrom PIL import Image\nimport torch\nimport numpy as np\n\nclass ChestXrayDataset(Dataset):\n    def __init__(self, dataframe, path_col, y_col, transform=None):\n        self.dataframe = dataframe\n        self.path_col = path_col\n        self.y_col = y_col\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.dataframe)\n\n    def __getitem__(self, idx):\n        # Get image path\n        img_path = self.dataframe.iloc[idx][self.path_col]\n        \n        # FIX: Explicitly convert the label to a float numpy array first, \n        # then to a torch tensor. This resolves the 'numpy.object_' error.\n        label_array = np.array(self.dataframe.iloc[idx][self.y_col], dtype=np.float32)\n        labels = torch.from_numpy(label_array)\n        \n        # Load image\n        image = Image.open(img_path).convert('RGB')\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, labels\n\nprint(\"Fixed PyTorch Dataset class defined.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nfrom torchvision import transforms\n\n# Set image size based on your requirement\nIMG_SIZE = (224, 224)\n\n# Defining the preprocessing pipeline for DenseNet121\n# This replaces keras.applications.densenet.preprocess_input and ImageDataGenerator\ncore_idg_dense = transforms.Compose([\n    transforms.Resize(IMG_SIZE),\n    transforms.ToTensor(),\n    # DenseNet standard ImageNet normalization\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], \n                         std=[0.229, 0.224, 0.225])\n])\n\nprint(f\"Pre-processing pipeline defined for image size: {IMG_SIZE}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Create the Dataset instances\ntrain_ds = ChestXrayDataset(train_df, \n                            path_col='path', \n                            y_col='disease_vec', \n                            transform=core_idg_dense)\n\nvalid_ds = ChestXrayDataset(valid_df, \n                            path_col='path', \n                            y_col='disease_vec', \n                            transform=core_idg_dense) # Use validation transform if different\n\ntest_ds = ChestXrayDataset(test_df, \n                           path_col='path', \n                           y_col='disease_vec', \n                           transform=core_idg_dense)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Training: Uses the sampler for imbalance\ntrain_gen_full = DataLoader(train_ds, \n    \n                            batch_size=32, \n                            sampler=train_sampler, \n                            num_workers=4, \n                            pin_memory=True)\n\n# Validation & Test: Standard loading (No sampler needed)\nvalid_gen = DataLoader(valid_ds, \n                       batch_size=32, \n                       shuffle=False, \n                       num_workers=4, \n                       pin_memory=True)\n\ntest_gen = DataLoader(test_ds, \n                      batch_size=32, \n                      shuffle=False, \n                      num_workers=4)\n\nprint(f\"DataLoaders ready. Training batches: {len(train_gen_full)}\")\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# For test_X, test_Y (simulating the \"one big batch\" logic)\n# We use a smaller batch size to avoid OOM, but keep your logic of extracting them as tensors\ntest_loader_big = DataLoader(test_ds, batch_size=800, shuffle=False) # Reduced from 8000 to fit GPU memory\ntest_X, test_Y = next(iter(test_loader_big))\n\nprint(f'Train batches: {len(train_gen_full)}, Valid batches: {len(valid_gen)}')\nprint(f'Test Batch X shape: {test_X.shape}, Y shape: {test_Y.shape}')","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Re-define all_labels (usually from your multi-hot encoding step)\n# Example: \nall_labels = ['Atelectasis', 'Cardiomegaly', 'Consolidation', 'Edema', 'Effusion', \n              'Emphysema', 'Fibrosis', 'Hernia', 'Infiltration', 'Mass', \n              'Nodule', 'Pleural_Thickening', 'Pneumonia', 'Pneumothorax']\n\n# Or if you are pulling it from your dataframe:\n# all_labels = sorted(train_df['Finding Labels'].unique()) \n# (Adjust based on how you originally created the list)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport torch\nimport torch.nn as nn\nfrom torchvision import models\n\n# 3. Load only if the file exists\nmodel_path = \"best_chest_xray_model.pth\"\n\nif os.path.exists(model_path):\n    checkpoint = torch.load(model_path, map_location=device)\n    loaded_mod.load_state_dict(checkpoint)\n    print(f\"Successfully loaded weights from {model_path}\")\nelse:\n    print(f\"No checkpoint found. Initializing with ImageNet weights and custom head.\")\n    # Re-initialize the base model\n    loaded_mod = models.densenet121(weights='IMAGENET1K_V1')\n    \n    # RE-APPLY the custom classifier (14 classes)\n    num_ftrs = loaded_mod.classifier.in_features\n    loaded_mod.classifier = nn.Sequential(\n        nn.Dropout(p=0.5),\n        nn.Linear(num_ftrs, len(all_labels)),\n        nn.Sigmoid()\n    )\n    \n    # RE-MOVE to GPU and wrap in DataParallel\n    loaded_mod = loaded_mod.to(device)\n    if torch.cuda.device_count() > 1:\n        loaded_mod = nn.DataParallel(loaded_mod)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Use the full dataset (ensure all_labels are already processed)\n# data = original_dataframe_with_120k_rows \n\n# 2. Calculate weights for the Sampler to handle imbalance\n# Images with rare diseases get higher weights\ndef calculate_weights(df):\n    # Count how many diseases are in each row\n    num_diseases = df['Finding Labels'].map(lambda x: len(x.split('|')) if x != 'No Finding' else 0)\n    # Give higher weight to images with findings (0.04 is a small baseline for 'No Finding')\n    weights = num_diseases + 0.05 \n    return torch.DoubleTensor(weights.values)\n\ntrain_weights = calculate_weights(train_df)\n# WeightedRandomSampler picks samples based on the weights provided\nsampler = torch.utils.data.WeightedRandomSampler(train_weights, len(train_weights))\n\n# 3. Update DataLoader to use the Sampler\n# Note: shuffle=True cannot be used with a Sampler\ntrain_gen_full = DataLoader(train_ds, batch_size=64, sampler=sampler, num_workers=8)","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import gc\nimport torch\n\n# Clear variables\nif 'model' in locals():\n    del model\nif 'optimizer' in locals():\n    del optimizer\n\ngc.collect()\ntorch.cuda.empty_cache() # This releases the 'Reserved' memory back to the GPU","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.optim as optim\n\n# 1. Define Loss Function (Binary Cross Entropy)\n# We use BCELoss because you have the Sigmoid activation at the end of your model\ncriterion = nn.BCELoss()\n\n# 2. Define Optimizer\n# Note: lr=0.001 matches your Keras Adam setup\noptimizer = optim.Adam(loaded_mod.parameters(), lr=0.001)\n\n# 3. Define Learning Rate Scheduler\n# This will reduce the learning rate when the validation loss stops improving\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, \n                                                 mode='min', \n                                                 factor=0.1, \n                                                 patience=1, \n                                                 verbose=True)\n\nprint(\"Optimizer, Loss, and Scheduler initialized.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\nimport gc\n\n# 1. Force unwrap: Loop until we hit the actual DenseNet\nbase_model = loaded_mod\nwhile isinstance(base_model, nn.DataParallel):\n    base_model = base_model.module\n\n# 2. Verify we have the right object\nprint(f\"Targeting base model type: {type(base_model).__name__}\")\n\n# 3. Now modify the classifier safely\n# DenseNet stores the classifier in .classifier\nif hasattr(base_model, 'classifier'):\n    if isinstance(base_model.classifier, nn.Sequential):\n        # If it's already a Sequential (from a previous run), \n        # get the input features from the Linear layer inside it\n        num_ftrs = base_model.classifier[1].in_features\n    else:\n        num_ftrs = base_model.classifier.in_features\n        \n    base_model.classifier = nn.Sequential(\n        nn.Dropout(p=0.5),\n        nn.Linear(num_ftrs, len(all_labels)),\n        nn.Sigmoid()\n    )\nelse:\n    raise AttributeError(\"This model doesn't have a '.classifier' attribute. Check if it's a DenseNet.\")\n\n# 4. Move to GPU and Wrap for Dual T4s\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nbase_model = base_model.to(device)\n\n# Clear cache before wrapping\ngc.collect()\ntorch.cuda.empty_cache()\n\nloaded_mod = nn.DataParallel(base_model)\n\n# 5. Re-initialize Optimizer (Mandatory)\noptimizer = torch.optim.Adam(loaded_mod.parameters(), lr=1e-4)\n\nprint(f\"Success! Model is now {type(loaded_mod).__name__} with {len(all_labels)} output classes.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\nimport torch.optim as optim\n\n# 1. Define the device\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 2. Ensure model is wrapped EXACTLY ONCE for Dual T4s\n# First, unwrap everything to get the base DenseNet\nbase_model = loaded_mod\nwhile isinstance(base_model, nn.DataParallel):\n    base_model = base_model.module\n\n# 3. Move base model to GPU\nbase_model = base_model.to(device)\n\n# 4. Wrap it once\nif torch.cuda.device_count() > 1:\n    print(f\"Using {torch.cuda.device_count()} GPUs\")\n    loaded_mod = nn.DataParallel(base_model)\nelse:\n    loaded_mod = base_model\n\n# 5. Initialize Optimizer on the current loaded_mod\noptimizer = optim.Adam(loaded_mod.parameters(), lr=1e-4)\n\n# 6. Initialize Scheduler\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=1)\n\nprint(f\"Setup Complete. Model type: {type(loaded_mod).__name__}\")\nprint(f\"Final Class Count: {loaded_mod.module.classifier[1].out_features if isinstance(loaded_mod, nn.DataParallel) else loaded_mod.classifier[1].out_features}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Use this criterion for Sigmoid output\ncriterion = nn.BCELoss()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.optim as optim\nfrom tqdm.auto import tqdm\nimport gc\n\n# 1. Final Criterion & Optimizer Setup\ncriterion = nn.BCELoss() # Ideal for multi-label Sigmoid outputs\noptimizer = optim.Adam(loaded_mod.parameters(), lr=1e-4)\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=1)\n\n# 2. Training Settings\nepochs = 4\nbest_val_loss = float('inf')\n\nprint(f\"Starting Training for {epochs} epochs...\")\n\nfor epoch in range(epochs):\n    # --- TRAINING PHASE ---\n    loaded_mod.train()\n    running_loss = 0.0\n    \n    # Progress bar for the full dataset\n    pbar = tqdm(train_gen_full, desc=f\"Epoch {epoch+1}/{epochs} [Train]\")\n    \n    for inputs, labels in pbar:\n        inputs, labels = inputs.to(device), labels.to(device).float()\n        \n        optimizer.zero_grad()\n        \n        # Forward Pass\n        outputs = loaded_mod(inputs)\n        loss = criterion(outputs, labels)\n        \n        # Backward Pass\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        pbar.set_postfix({'batch_loss': f\"{loss.item():.4f}\"})\n\n    # --- VALIDATION PHASE ---\n    loaded_mod.eval()\n    val_loss = 0.0\n    with torch.no_grad():\n        for inputs, labels in tqdm(valid_gen, desc=f\"Epoch {epoch+1} [Valid]\"):\n            inputs, labels = inputs.to(device), labels.to(device).float()\n            outputs = loaded_mod(inputs)\n            val_loss += criterion(outputs, labels).item()\n            \n    avg_train_loss = running_loss / len(train_gen_full)\n    avg_val_loss = val_loss / len(valid_gen)\n    \n    # Step the scheduler based on validation performance\n    scheduler.step(avg_val_loss)\n    \n    print(f\"\\nEpoch {epoch+1} Summary:\")\n    print(f\"Train Loss: {avg_train_loss:.4f} | Val Loss: {avg_val_loss:.4f}\")\n    \n    # --- SAVE BEST MODEL ---\n    if avg_val_loss < best_val_loss:\n        best_val_loss = avg_val_loss\n        # Save the underlying state_dict (stripping DataParallel wrapper if present)\n        model_to_save = loaded_mod.module if isinstance(loaded_mod, nn.DataParallel) else loaded_mod\n        torch.save(model_to_save.state_dict(), \"best_final_model.pth\")\n        print(f\"⭐ New Best Model Saved (Val Loss: {best_val_loss:.4f})\")\n    \n    # Clean memory between epochs\n    gc.collect()\n    torch.cuda.empty_cache()\n    print(\"-\" * 30)\n\nprint(\"Training Complete!\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom tqdm.auto import tqdm\nimport gc\n\n# 1. Access the Base Model (Unwrap if needed)\nbase_model = loaded_mod.module if isinstance(loaded_mod, nn.DataParallel) else loaded_mod\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 2. Load the Saved Weights\nmodel_path = \"best_final_model.pth\"\nif os.path.exists(model_path):\n    print(f\"--- Resuming from {model_path} ---\")\n    state_dict = torch.load(model_path, map_location=device)\n    base_model.load_state_dict(state_dict)\n    # We set a slightly better starting point for the comparison\n    # If you know your previous best val loss, you can set it here:\n    # best_val_loss = 0.1234 \nelse:\n    print(\"--- No saved model found. Starting with current weights ---\")\n\n# 3. Re-Sync Multi-GPU and Optimizer\nif torch.cuda.device_count() > 1 and not isinstance(loaded_mod, nn.DataParallel):\n    loaded_mod = nn.DataParallel(base_model)\n\n# Lower LR for fine-tuning continued training\noptimizer = optim.Adam(loaded_mod.parameters(), lr=5e-5) \nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=1)\ncriterion = nn.BCELoss()\n\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 4. Training Settings\nepochs = 15\nbest_val_loss = float('inf') \n\nprint(f\"Starting Training for {epochs} more epochs on {device}...\")\n\nfor epoch in range(epochs):\n    # --- TRAINING PHASE ---\n    loaded_mod.train()\n    running_loss = 0.0\n    pbar = tqdm(train_gen_full, desc=f\"Epoch {epoch+1}/{epochs} [Train]\")\n    \n    for inputs, labels in pbar:\n        inputs, labels = inputs.to(device), labels.to(device).float()\n        \n        optimizer.zero_grad()\n        outputs = loaded_mod(inputs)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        pbar.set_postfix({'loss': f\"{loss.item():.4f}\"})\n\n    # --- VALIDATION PHASE ---\n    loaded_mod.eval()\n    val_loss = 0.0\n    with torch.no_grad():\n        for inputs, labels in tqdm(valid_gen, desc=f\"Epoch {epoch+1} [Valid]\"):\n            inputs, labels = inputs.to(device), labels.to(device).float()\n            outputs = loaded_mod(inputs)\n            val_loss += criterion(outputs, labels).item()\n            \n    avg_train_loss = running_loss / len(train_gen_full)\n    avg_val_loss = val_loss / len(valid_gen)\n    \n    scheduler.step(avg_val_loss)\n    \n    print(f\"\\nEpoch {epoch+1} Summary:\")\n    print(f\"Train Loss: {avg_train_loss:.4f} | Val Loss: {avg_val_loss:.4f}\")\n    \n    # --- SAVE BEST MODEL ---\n    if avg_val_loss < best_val_loss:\n        best_val_loss = avg_val_loss\n        save_obj = loaded_mod.module if isinstance(loaded_mod, nn.DataParallel) else loaded_mod\n        torch.save(save_obj.state_dict(), \"best_final_model_v2.pth\")\n        print(f\"⭐ Progress Saved! New best val loss: {best_val_loss:.4f}\")\n    \n    # Memory Cleanup\n    gc.collect()\n    torch.cuda.empty_cache()\n    print(\"-\" * 30)\n\nprint(\"Continued Training Complete!\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}