{"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":"gpu","dataSources":[{"sourceId":14774,"databundleVersionId":875431,"sourceType":"competition"},{"sourceId":13938328,"sourceType":"datasetVersion","datasetId":8882832},{"sourceId":532013,"sourceType":"datasetVersion","datasetId":253160}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"\nimport os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.utils.class_weight import compute_class_weight\nfrom sklearn.metrics import classification_report, confusion_matrix\nimport seaborn as sns\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\n\n# ==========================================\n# 配置与路径\n# ==========================================\nCONFIG = {\n    \"seed\": 42,\n    \"img_size\": 224,  # 我们在预处理阶段就 Resize 到这个尺寸\n    \"batch_size\": 32,\n    \"epochs\": 40,\n    \"lr\": 1e-5,\n    \"num_classes\": 5,\n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    # 原始路径\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    # 【新】处理后的图片保存路径 (Kaggle 输出目录)\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_224\"\n}\n\n# 设置随机种子\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']}\")\n\nfrom concurrent.futures import ProcessPoolExecutor\nimport functools\n\n# ==========================================\n# 第一部分：离线预处理 (Offline Preprocessing) - 多进程加速版\n# ==========================================\n\ndef process_one_image(img_name):\n    \"\"\"\n    单个图片的处理函数，用于多进程调用\n    \"\"\"\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    # 1. 读取原始图片\n    image = cv2.imread(load_path)\n    if image is None:\n        return # 读取失败直接跳过\n        \n    # 2. 提前 Resize\n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    \n    # 3. 执行 CLAHE\n    # 注意：在函数内部创建 CLAHE 对象，确保线程安全\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    \n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l) # 只增强亮度通道\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    # 4. 保存 (转回 BGR)\n    save_image_bgr = cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR)\n    cv2.imwrite(save_path, save_image_bgr)\n\ndef preprocess_and_save_images_parallel():\n    # 检查是否已存在\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(f\"检测到 {CONFIG['processed_img_dir']} 已有数据，跳过预处理步骤...\")\n        return\n\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    \n    print(f\"🚀 开始多进程加速预处理 (总数: {len(image_ids)})...\")\n    \n    # 使用 ProcessPoolExecutor 自动利用所有 CPU 核心\n    # max_workers=None 会自动设置为 CPU 核心数 (Kaggle 上通常是 4)\n    with ProcessPoolExecutor() as executor:\n        # 使用 tqdm 显示进度条\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids), desc=\"Parallel Processing\"))\n        \n    print(\"✅ 预处理完成！所有增强后的图片已保存。\")\n\n# --- 执行加速版预处理 ---\nif __name__ == '__main__':\n    preprocess_and_save_images_parallel()\n\n\n# ==========================================\n# 第二部分：数据加载与训练 (Training Pipeline)\n# ==========================================\n\n# 1. 定义 Dataset (现在它非常轻量级，只负责读图)\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        # 注意：这里我们读取的是【新目录】下的图片\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n\n        # 读取图片 (已经是 224x224 且做过 CLAHE 的了)\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 转 RGB\n        \n        # PyTorch Transforms (主要是 Augmentation 和 Normalization)\n        if self.transform:\n            image = self.transform(image)\n        \n        return image, torch.tensor(label, dtype=torch.long)\n\n# 2. 数据准备\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\n# 计算类别权重 (保持这一步，这对不平衡数据至关重要)\nclass_weights = compute_class_weight(\n    class_weight='balanced', \n    classes=np.unique(train_df['diagnosis']), \n    y=train_df['diagnosis']\n)\nclass_weights = torch.tensor(class_weights, dtype=torch.float).to(CONFIG['device'])\n\n# 定义 Transforms\n# 注意：不需要再 Resize 了，因为预处理时已经 Resize 过了\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomRotation(15), \n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\n# 实例化 DataLoader\ntrain_dataset = ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms)\nval_dataset = ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms)\n\n# num_workers 可以设为 2 或 4，现在 CPU 压力很小，主要负责从硬盘搬运数据\ntrain_loader = DataLoader(train_dataset, batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_dataset, batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# 3. 模型构建 (ResNet50)\ndef build_model(num_classes):\n    print(\"正在加载 DenseNet121 (比 ResNet50 更适合医学影像)...\")\n    # 加载预训练的 DenseNet121\n    model = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)\n    \n    # 获取分类层的输入特征数\n    num_ftrs = model.classifier.in_features\n    \n    # 替换最后的全连接层\n    model.classifier = nn.Sequential(\n        nn.Dropout(0.5),  # 防止过拟合\n        nn.Linear(num_ftrs, num_classes)\n    )\n    return model\n\nmodel = build_model(CONFIG['num_classes'])\nmodel = model.to(CONFIG['device'])\n\n# 4. 优化器与 Loss\ncriterion = nn.CrossEntropyLoss(weight=class_weights) # 使用类别权重\noptimizer = optim.Adam(model.parameters(), lr=CONFIG['lr'])\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=3, verbose=True)\n\n# 5. 训练循环\nbest_acc = 0.0\npatience = 7\ncounter = 0\n\ntrain_history = {'loss': [], 'acc': []}\nval_history = {'loss': [], 'acc': []}\n\nfor epoch in range(CONFIG['epochs']):\n    print(f\"\\nEpoch {epoch+1}/{CONFIG['epochs']}\")\n    \n    # === Training ===\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    # 这里的 tqdm 会跑得快得多\n    train_bar = tqdm(train_loader, desc=\"Training\")\n    for images, labels in train_bar:\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        \n        train_bar.set_postfix(loss=loss.item(), acc=correct/total)\n    \n    epoch_train_loss = running_loss / len(train_loader)\n    epoch_train_acc = correct / total\n    train_history['loss'].append(epoch_train_loss)\n    train_history['acc'].append(epoch_train_acc)\n    \n    # === Validation ===\n    model.eval()\n    val_running_loss = 0.0\n    val_correct = 0\n    val_total = 0\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            val_running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n            val_total += labels.size(0)\n            val_correct += (predicted == labels).sum().item()\n            \n    epoch_val_loss = val_running_loss / len(val_loader)\n    epoch_val_acc = val_correct / val_total\n    val_history['loss'].append(epoch_val_loss)\n    val_history['acc'].append(epoch_val_acc)\n    \n    print(f\"Val Loss: {epoch_val_loss:.4f} | Val Acc: {epoch_val_acc:.4f}\")\n    \n    scheduler.step(epoch_val_loss)\n    \n    if epoch_val_acc > best_acc:\n        best_acc = epoch_val_acc\n        torch.save(model.state_dict(), \"best_resnet50_offline.pth\")\n        print(f\"🔥 New Best Model Saved! Accuracy: {best_acc:.4f}\")\n        counter = 0\n    else:\n        counter += 1\n        print(f\"EarlyStopping counter: {counter} out of {patience}\")\n        if counter >= patience:\n            print(\"🛑 Early stopping triggered.\")\n            break\n\n# ==========================================\n# 结果展示\n# ==========================================\nmodel.load_state_dict(torch.load(\"best_resnet50_offline.pth\"))\nmodel.eval()\n\nall_preds = []\nall_labels = []\n\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device'])\n        outputs = model(images)\n        _, preds = torch.max(outputs, 1)\n        all_preds.extend(preds.cpu().numpy())\n        all_labels.extend(labels.numpy())\n\nprint(\"\\n=== Classification Report ===\")\nprint(classification_report(all_labels, all_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\n\ncm = confusion_matrix(all_labels, all_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues')\nplt.title('Confusion Matrix (Offline Preprocessed)')\nplt.show()","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import classification_report, confusion_matrix\nimport seaborn as sns\nfrom concurrent.futures import ProcessPoolExecutor\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\n\n# ==========================================\n# 配置与路径\n# ==========================================\nCONFIG = {\n    \"seed\": 42,\n    \"img_size\": 224, \n    \"batch_size\": 32,\n    \"epochs\": 40,\n    \"lr\": 2e-4,  # 回归任务通常可以接受稍微大一点的初始学习率\n    \"num_classes\": 1,  # 【修改】回归模式：输出1个数值\n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_224\"\n}\n\n# 设置随机种子\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']}\")\n\n# ==========================================\n# 第一部分：离线预处理 (多进程加速版)\n# ==========================================\n\ndef process_one_image(img_name):\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    image = cv2.imread(load_path)\n    if image is None: return \n        \n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    \n    # CLAHE 增强\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l)\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    save_image_bgr = cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR)\n    cv2.imwrite(save_path, save_image_bgr)\n\ndef preprocess_and_save_images_parallel():\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(f\"检测到已预处理数据，跳过...\")\n        return\n\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    \n    print(f\"🚀 开始多进程预处理...\")\n    with ProcessPoolExecutor() as executor:\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids), desc=\"Parallel Preprocessing\"))\n    print(\"✅ 预处理完成\")\n\nif __name__ == '__main__':\n    preprocess_and_save_images_parallel()\n\n\n# ==========================================\n# 第二部分：数据加载与模型 (Regression Mode)\n# ==========================================\n\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        if self.transform:\n            image = self.transform(image)\n        \n        # 【修改】这里返回 float 类型，用于回归计算距离\n        return image, torch.tensor(label, dtype=torch.float)\n\n# 数据准备\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(), # 增加垂直翻转\n    transforms.RandomRotation(20),   \n    transforms.ColorJitter(brightness=0.1, contrast=0.1), # 轻微颜色抖动\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_dataset = ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms)\nval_dataset = ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms)\n\ntrain_loader = DataLoader(train_dataset, batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_dataset, batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# 【修改】构建回归模型\ndef build_model():\n    print(\"正在加载 DenseNet121 (Regression Mode)...\")\n    model = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1)\n    num_ftrs = model.classifier.in_features\n    \n    # 输出层改为 1 个节点，直接预测 0.0 ~ 4.0 的数值\n    model.classifier = nn.Sequential(\n        nn.Dropout(0.5),\n        nn.Linear(num_ftrs, 1) \n    )\n    return model\n\nmodel = build_model()\nmodel = model.to(CONFIG['device'])\n\n# 【修改】Loss 改为 MSELoss\ncriterion = nn.MSELoss()\noptimizer = optim.Adam(model.parameters(), lr=CONFIG['lr'])\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3, verbose=True)\n\n# ==========================================\n# 第三部分：训练循环 (Regression Logic)\n# ==========================================\n\nbest_acc = 0.0\npatience = 8\ncounter = 0\n\nfor epoch in range(CONFIG['epochs']):\n    print(f\"\\nEpoch {epoch+1}/{CONFIG['epochs']}\")\n    \n    # === Training ===\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    train_bar = tqdm(train_loader, desc=\"Training\")\n    for images, labels in train_bar:\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        \n        # 【修改】Reshape 标签以匹配输出 (batch_size, 1)\n        labels = labels.view(-1, 1)\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        \n        # 【修改】计算准确率：截断 -> 四舍五入 -> 转整数\n        with torch.no_grad():\n            preds_clipped = torch.clamp(outputs, 0, 4)\n            predicted = torch.round(preds_clipped)\n            total += labels.size(0)\n            correct += (predicted == labels).sum().item()\n        \n        train_bar.set_postfix(mse_loss=loss.item(), acc=correct/total)\n    \n    epoch_train_loss = running_loss / len(train_loader)\n    \n    # === Validation ===\n    model.eval()\n    val_running_loss = 0.0\n    val_correct = 0\n    val_total = 0\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            labels = labels.view(-1, 1)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            val_running_loss += loss.item()\n            \n            # 同样的转换逻辑\n            preds_clipped = torch.clamp(outputs, 0, 4)\n            predicted = torch.round(preds_clipped)\n            \n            val_total += labels.size(0)\n            val_correct += (predicted == labels).sum().item()\n            \n    epoch_val_loss = val_running_loss / len(val_loader)\n    epoch_val_acc = val_correct / val_total\n    \n    print(f\"Val MSE Loss: {epoch_val_loss:.4f} | Val Accuracy: {epoch_val_acc:.4f}\")\n    \n    scheduler.step(epoch_val_loss)\n    \n    if epoch_val_acc > best_acc:\n        best_acc = epoch_val_acc\n        torch.save(model.state_dict(), \"best_densenet_regression.pth\")\n        print(f\"🔥 New Best Model Saved! Accuracy: {best_acc:.4f}\")\n        counter = 0\n    else:\n        counter += 1\n        print(f\"EarlyStopping counter: {counter} out of {patience}\")\n        if counter >= patience:\n            print(\"🛑 Early stopping triggered.\")\n            break\n\n# ==========================================\n# 第四部分：结果展示 (转换回类别)\n# ==========================================\nprint(\"\\nLoading Best Model for Evaluation...\")\nmodel.load_state_dict(torch.load(\"best_densenet_regression.pth\"))\nmodel.eval()\n\nall_preds = []\nall_labels = []\n\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device'])\n        outputs = model(images)\n        \n        # 将连续预测值转回离散类别\n        preds_clipped = torch.clamp(outputs, 0, 4)\n        preds_int = torch.round(preds_clipped).long()\n        \n        all_preds.extend(preds_int.cpu().numpy().flatten()) # flatten 展平数组\n        all_labels.extend(labels.long().numpy())\n\nprint(\"\\n=== Classification Report (Regression Based) ===\")\nprint(classification_report(all_labels, all_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\n\ncm = confusion_matrix(all_labels, all_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues')\nplt.title('Confusion Matrix (Regression Model)')\nplt.ylabel('True Label')\nplt.xlabel('Predicted Label (Rounded)')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nimport seaborn as sns\nfrom concurrent.futures import ProcessPoolExecutor\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\n\n# ==========================================\n# 核心配置 (关键改动区)\n# ==========================================\nCONFIG = {\n    \"seed\": 2025,\n    # 【改动1】分辨率大幅提升，这是识别微小病灶的关键\n    \"img_size\": 300,  \n    # 如果显存不够 (OOM)，请把 batch_size 调小到 16 或 8\n    \"batch_size\": 16, \n    \"epochs\": 35,\n    \"lr\": 1e-4,\n    \"num_classes\": 1,  # 回归模式\n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    # 【改动2】保存路径换个名字，避免和之前的混淆\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_300\"\n}\n\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']} | Image Size: {CONFIG['img_size']}\")\n\n# ==========================================\n# 1. 高清预处理 (保留 CLAHE，但尺寸变大)\n# ==========================================\ndef process_one_image(img_name):\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    image = cv2.imread(load_path)\n    if image is None: return \n        \n    # Resize 到 300x300\n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    \n    # CLAHE 增强 (对于识别微血管瘤至关重要)\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l)\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    # 转 BGR 保存\n    save_image_bgr = cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR)\n    cv2.imwrite(save_path, save_image_bgr)\n\ndef preprocess_parallel():\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(f\"检测到已存在的 300px 数据，跳过预处理...\")\n        return\n\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    \n    print(f\"🚀 开始生成 {CONFIG['img_size']}x{CONFIG['img_size']} 高清增强数据...\")\n    with ProcessPoolExecutor() as executor:\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids)))\n    print(\"✅ 预处理完成\")\n\nif __name__ == '__main__':\n    preprocess_parallel()\n\n# ==========================================\n# 2. 数据加载与增强 (使用 Cutout/CoarseDropout 思想)\n# ==========================================\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        if self.transform:\n            image = self.transform(image)\n        \n        return image, torch.tensor(label, dtype=torch.float)\n\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\n# 更丰富的数据增强，防止大模型过拟合\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(), \n    transforms.RandomRotation(360), # 眼底图是圆的，360度旋转都没问题\n    transforms.ColorJitter(brightness=0.2, contrast=0.2), # 模拟不同设备的曝光\n    transforms.ToTensor(),\n    # EfficientNet 官方推荐的归一化参数\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_dataset = ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms)\nval_dataset = ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms)\n\ntrain_loader = DataLoader(train_dataset, batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(val_dataset, batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# ==========================================\n# 3. 核心模型: EfficientNet-B3\n# ==========================================\ndef build_model():\n    print(\"正在加载 EfficientNet-B3 (性能怪兽)...\")\n    # EfficientNet-B3 适合 300x300 左右的分辨率\n    model = models.efficientnet_b3(weights=models.EfficientNet_B3_Weights.IMAGENET1K_V1)\n    \n    # 修改 classifier\n    num_ftrs = model.classifier[1].in_features\n    model.classifier[1] = nn.Linear(num_ftrs, 1) # 回归输出\n    \n    return model\n\nmodel = build_model()\nmodel = model.to(CONFIG['device'])\n\ncriterion = nn.MSELoss()\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'], weight_decay=1e-5) # AdamW 防止过拟合\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=2, verbose=True)\n\n# ==========================================\n# 4. 训练循环\n# ==========================================\nbest_kappa = -1.0 # 使用 Kappa 作为最佳模型保存标准，这比 Accuracy 更靠谱\npatience = 10\ncounter = 0\n\nfor epoch in range(CONFIG['epochs']):\n    print(f\"\\nEpoch {epoch+1}/{CONFIG['epochs']}\")\n    \n    # --- Training ---\n    model.train()\n    running_loss = 0.0\n    \n    train_bar = tqdm(train_loader, desc=\"Training\")\n    for images, labels in train_bar:\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        labels = labels.view(-1, 1)\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        train_bar.set_postfix(mse=loss.item())\n    \n    # --- Validation ---\n    model.eval()\n    val_loss = 0.0\n    all_preds = []\n    all_labels = []\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            labels = labels.view(-1, 1)\n            \n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            val_loss += loss.item()\n            \n            # 回归 -> 整数类别\n            preds_clipped = torch.clamp(outputs, 0, 4)\n            preds_int = torch.round(preds_clipped).long()\n            \n            all_preds.extend(preds_int.cpu().numpy().flatten())\n            all_labels.extend(labels.cpu().numpy().flatten())\n            \n    avg_val_loss = val_loss / len(val_loader)\n    \n    # 计算 Cohen's Kappa (这是最严格的医学指标)\n    kappa = cohen_kappa_score(all_labels, all_preds, weights='quadratic')\n    # 计算 Accuracy\n    acc = np.mean(np.array(all_preds) == np.array(all_labels))\n    \n    print(f\"Val MSE: {avg_val_loss:.4f} | Accuracy: {acc:.4f} | Kappa Score: {kappa:.4f}\")\n    \n    scheduler.step(avg_val_loss)\n    \n    # 保存逻辑：我们优先看 Accuracy 是否突破\n    if acc > 0.88 or kappa > best_kappa: \n        if kappa > best_kappa: best_kappa = kappa\n        torch.save(model.state_dict(), \"best_efficientnet_b3.pth\")\n        print(f\"🔥 Model Saved! (Acc: {acc:.4f}, Kappa: {kappa:.4f})\")\n        counter = 0\n    else:\n        counter += 1\n        if counter >= patience:\n            print(\"🛑 Early stopping\")\n            break\n\n# ==========================================\n# 5. 最终验证与混淆矩阵\n# ==========================================\nmodel.load_state_dict(torch.load(\"best_efficientnet_b3.pth\"))\nmodel.eval()\n\n# 这里可以加入 TTA (Test Time Augmentation) 逻辑进一步刷分\n# 暂时先用标准预测\nfinal_preds = []\nfinal_labels = []\n\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device'])\n        outputs = model(images)\n        preds = torch.round(torch.clamp(outputs, 0, 4)).long()\n        final_preds.extend(preds.cpu().numpy().flatten())\n        final_labels.extend(labels.numpy())\n\nprint(classification_report(final_labels, final_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\n\ncm = confusion_matrix(final_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'EfficientNet-B3 Confusion Matrix\\nAccuracy: {np.mean(np.array(final_preds)==np.array(final_labels)):.4f}')\nplt.xlabel('Predicted')\nplt.ylabel('True')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport scipy as sp\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom functools import partial\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom concurrent.futures import ProcessPoolExecutor\n\n# ==========================================\n# 1. 全局配置\n# ==========================================\nCONFIG = {\n    \"seed\": 2025,\n    \"img_size\": 300,  # 高清尺寸\n    \"batch_size\": 16, # 显存不够请改小\n    \"epochs\": 25,     # 25轮足够了\n    \"lr\": 3e-4,       # 稍微加大一点初始学习率\n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_300\"\n}\n\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']}\")\n\n# ==========================================\n# 2. 预处理 (如果没有文件则自动运行)\n# ==========================================\ndef process_one_image(img_name):\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    image = cv2.imread(load_path)\n    if image is None: return \n    \n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l)\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    cv2.imwrite(save_path, cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR))\n\ndef check_and_preprocess():\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(\"✅ 检测到已预处理数据，跳过生成步骤。\")\n        return\n    \n    print(f\"🚀 正在生成 {CONFIG['img_size']}px 高清数据 (多进程)...\")\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    with ProcessPoolExecutor() as executor:\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids)))\n    print(\"✅ 预处理完成\")\n\ncheck_and_preprocess()\n\n# ==========================================\n# 3. Dataset & Model\n# ==========================================\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        if self.transform: image = self.transform(image)\n        return image, torch.tensor(label, dtype=torch.float)\n\n# 加载数据\nfrom sklearn.model_selection import train_test_split\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(360),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_loader = DataLoader(ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms), \n                          batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms), \n                        batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\ndef build_model():\n    print(\"正在加载 EfficientNet-B3...\")\n    model = models.efficientnet_b3(weights=models.EfficientNet_B3_Weights.IMAGENET1K_V1)\n    model.classifier[1] = nn.Linear(model.classifier[1].in_features, 1)\n    return model\n\nmodel = build_model().to(CONFIG['device'])\ncriterion = nn.MSELoss()\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'], weight_decay=1e-5)\n# 使用 CosineAnnealingLR，这对微调非常有效\nscheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=CONFIG['epochs'], eta_min=1e-6)\n\n# ==========================================\n# 4. 训练循环 (Training Loop)\n# ==========================================\nbest_kappa = -1.0\nprint(\"\\n🔥 开始训练 (Training Started)...\")\n\nfor epoch in range(CONFIG['epochs']):\n    model.train()\n    train_loss = 0.0\n    for images, labels in tqdm(train_loader, desc=f\"Epoch {epoch+1}/{CONFIG['epochs']}\"):\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device']).view(-1, 1)\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        train_loss += loss.item()\n    \n    # Validation\n    model.eval()\n    val_preds = []\n    val_labels = []\n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device']).view(-1, 1)\n            outputs = model(images)\n            val_preds.extend(outputs.cpu().numpy().flatten())\n            val_labels.extend(labels.cpu().numpy().flatten())\n    \n    # 简单舍入计算当前 Kappa\n    cur_preds = np.round(np.clip(val_preds, 0, 4)).astype(int)\n    cur_kappa = cohen_kappa_score(val_labels, cur_preds, weights='quadratic')\n    \n    print(f\"Epoch {epoch+1} | Val Kappa: {cur_kappa:.4f} | LR: {optimizer.param_groups[0]['lr']:.6f}\")\n    \n    if cur_kappa > best_kappa:\n        best_kappa = cur_kappa\n        torch.save(model.state_dict(), \"best_efficientnet_b3.pth\")\n        print(f\"✅ 模型已保存! Best Kappa: {best_kappa:.4f}\")\n    \n    scheduler.step()\n\n# ==========================================\n# 5. 阈值优化 (Threshold Optimization)\n# ==========================================\nprint(\"\\n🏆 训练结束，开始寻找最佳切割阈值...\")\n\n# 加载最佳权重\nmodel.load_state_dict(torch.load(\"best_efficientnet_b3.pth\"))\nmodel.eval()\n\n# 获取所有验证集预测结果\nvalid_preds = []\nvalid_labels = []\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device'])\n        outputs = model(images)\n        valid_preds.extend(outputs.cpu().numpy().flatten())\n        valid_labels.extend(labels.numpy())\n\n# 定义优化器\nclass OptimizedRounder(object):\n    def __init__(self): self.coef_ = 0\n    def _kappa_loss(self, coef, X, y):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]: X_p[i] = 0\n            elif pred >= coef[0] and pred < coef[1]: X_p[i] = 1\n            elif pred >= coef[1] and pred < coef[2]: X_p[i] = 2\n            elif pred >= coef[2] and pred < coef[3]: X_p[i] = 3\n            else: X_p[i] = 4\n        return -cohen_kappa_score(y, X_p, weights='quadratic')\n    def fit(self, X, y):\n        loss_partial = partial(self._kappa_loss, X=X, y=y)\n        self.coef_ = sp.optimize.minimize(loss_partial, [0.5, 1.5, 2.5, 3.5], method='nelder-mead')\n    def predict(self, X, coef):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]: X_p[i] = 0\n            elif pred >= coef[0] and pred < coef[1]: X_p[i] = 1\n            elif pred >= coef[1] and pred < coef[2]: X_p[i] = 2\n            elif pred >= coef[2] and pred < coef[3]: X_p[i] = 3\n            else: X_p[i] = 4\n        return X_p\n\n# 运行优化\noptR = OptimizedRounder()\noptR.fit(valid_preds, valid_labels)\ncoefficients = optR.coef_['x']\nprint(f\"优化后的阈值: {coefficients}\")\n\n# 最终预测\nfinal_preds = optR.predict(valid_preds, coefficients)\nfinal_acc = np.mean(final_preds == valid_labels)\nfinal_kappa = cohen_kappa_score(valid_labels, final_preds, weights='quadratic')\n\nprint(f\"\\n🎉 最终 Accuracy: {final_acc*100:.2f}%\")\nprint(f\"🎉 最终 Kappa Score: {final_kappa:.4f}\")\n\n# 绘图\ncm = confusion_matrix(valid_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'Final Confusion Matrix (Acc: {final_acc:.4f})')\nplt.xlabel('Predicted')\nplt.ylabel('True')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport scipy as sp\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom functools import partial\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom concurrent.futures import ProcessPoolExecutor\n\n# ==========================================\n# 1. 全局配置\n# ==========================================\nCONFIG = {\n    \"seed\": 2025,\n    \"img_size\": 300,  # 高清尺寸\n    \"batch_size\": 16, # 显存不够请改小\n    \"epochs\": 25,     # 25轮足够了\n    \"lr\": 3e-4,       # 稍微加大一点初始学习率\n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_300\"\n}\n\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']}\")\n\n# ==========================================\n# 2. 预处理 (如果没有文件则自动运行)\n# ==========================================\ndef process_one_image(img_name):\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    image = cv2.imread(load_path)\n    if image is None: return \n    \n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l)\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    cv2.imwrite(save_path, cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR))\n\ndef check_and_preprocess():\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(\"✅ 检测到已预处理数据，跳过生成步骤。\")\n        return\n    \n    print(f\"🚀 正在生成 {CONFIG['img_size']}px 高清数据 (多进程)...\")\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    with ProcessPoolExecutor() as executor:\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids)))\n    print(\"✅ 预处理完成\")\n\ncheck_and_preprocess()\n\n# ==========================================\n# 3. Dataset & Model (含均衡采样修复)\n# ==========================================\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        if self.transform: image = self.transform(image)\n        return image, torch.tensor(label, dtype=torch.float)\n\n# 加载数据\nfrom sklearn.model_selection import train_test_split\nfrom torch.utils.data import WeightedRandomSampler # <--- 引入这个神器\n\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\n# === 关键修改：计算采样权重 ===\n# 目的：让每个类别在训练时出现的概率相等\nclass_counts = train_df['diagnosis'].value_counts().sort_index().values\nsample_weights = 1.0 / class_counts\nsamples_weights = np.array([sample_weights[t] for t in train_df['diagnosis']])\nsamples_weights = torch.from_numpy(samples_weights).double()\n\n# 创建采样器\nsampler = WeightedRandomSampler(samples_weights, len(samples_weights))\n# ============================\n\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(360),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\n# === 关键修改：在 DataLoader 中启用 sampler ===\n# 注意：使用了 sampler 后，shuffle 必须设为 False (因为 sampler 已经在随机抽样了)\ntrain_loader = DataLoader(\n    ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms), \n    batch_size=CONFIG['batch_size'], \n    sampler=sampler,      # <--- 注入采样器\n    shuffle=False,        # <--- 必须为 False\n    num_workers=2, \n    pin_memory=True\n)\n\nval_loader = DataLoader(\n    ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms), \n    batch_size=CONFIG['batch_size'], \n    shuffle=False, \n    num_workers=2, \n    pin_memory=True\n)\n\ndef build_model():\n    print(\"正在加载 EfficientNet-B3 (均衡采样版)...\")\n    model = models.efficientnet_b3(weights=models.EfficientNet_B3_Weights.IMAGENET1K_V1)\n    model.classifier[1] = nn.Linear(model.classifier[1].in_features, 1)\n    return model\n\nmodel = build_model().to(CONFIG['device'])\ncriterion = nn.MSELoss()\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'], weight_decay=1e-5)\nscheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=CONFIG['epochs'], eta_min=1e-6)\n\n# ==========================================\n# 4. 训练循环 (Training Loop)\n# ==========================================\nbest_kappa = -1.0\nprint(\"\\n🔥 开始训练 (Training Started)...\")\n\nfor epoch in range(CONFIG['epochs']):\n    model.train()\n    train_loss = 0.0\n    for images, labels in tqdm(train_loader, desc=f\"Epoch {epoch+1}/{CONFIG['epochs']}\"):\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device']).view(-1, 1)\n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        train_loss += loss.item()\n    \n    # Validation\n    model.eval()\n    val_preds = []\n    val_labels = []\n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device']).view(-1, 1)\n            outputs = model(images)\n            val_preds.extend(outputs.cpu().numpy().flatten())\n            val_labels.extend(labels.cpu().numpy().flatten())\n    \n    # 简单舍入计算当前 Kappa\n    cur_preds = np.round(np.clip(val_preds, 0, 4)).astype(int)\n    cur_kappa = cohen_kappa_score(val_labels, cur_preds, weights='quadratic')\n    \n    print(f\"Epoch {epoch+1} | Val Kappa: {cur_kappa:.4f} | LR: {optimizer.param_groups[0]['lr']:.6f}\")\n    \n    if cur_kappa > best_kappa:\n        best_kappa = cur_kappa\n        torch.save(model.state_dict(), \"best_efficientnet_b3.pth\")\n        print(f\"✅ 模型已保存! Best Kappa: {best_kappa:.4f}\")\n    \n    scheduler.step()\n\n# ==========================================\n# 5. 阈值优化 (Threshold Optimization)\n# ==========================================\nprint(\"\\n🏆 训练结束，开始寻找最佳切割阈值...\")\n\n# 加载最佳权重\nmodel.load_state_dict(torch.load(\"best_efficientnet_b3.pth\"))\nmodel.eval()\n\n# 获取所有验证集预测结果\nvalid_preds = []\nvalid_labels = []\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device'])\n        outputs = model(images)\n        valid_preds.extend(outputs.cpu().numpy().flatten())\n        valid_labels.extend(labels.numpy())\n\n# 定义优化器\nclass OptimizedRounder(object):\n    def __init__(self): self.coef_ = 0\n    def _kappa_loss(self, coef, X, y):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]: X_p[i] = 0\n            elif pred >= coef[0] and pred < coef[1]: X_p[i] = 1\n            elif pred >= coef[1] and pred < coef[2]: X_p[i] = 2\n            elif pred >= coef[2] and pred < coef[3]: X_p[i] = 3\n            else: X_p[i] = 4\n        return -cohen_kappa_score(y, X_p, weights='quadratic')\n    def fit(self, X, y):\n        loss_partial = partial(self._kappa_loss, X=X, y=y)\n        self.coef_ = sp.optimize.minimize(loss_partial, [0.5, 1.5, 2.5, 3.5], method='nelder-mead')\n    def predict(self, X, coef):\n        X_p = np.copy(X)\n        for i, pred in enumerate(X_p):\n            if pred < coef[0]: X_p[i] = 0\n            elif pred >= coef[0] and pred < coef[1]: X_p[i] = 1\n            elif pred >= coef[1] and pred < coef[2]: X_p[i] = 2\n            elif pred >= coef[2] and pred < coef[3]: X_p[i] = 3\n            else: X_p[i] = 4\n        return X_p\n\n# 运行优化\noptR = OptimizedRounder()\noptR.fit(valid_preds, valid_labels)\ncoefficients = optR.coef_['x']\nprint(f\"优化后的阈值: {coefficients}\")\n\n# 最终预测\nfinal_preds = optR.predict(valid_preds, coefficients)\nfinal_acc = np.mean(final_preds == valid_labels)\nfinal_kappa = cohen_kappa_score(valid_labels, final_preds, weights='quadratic')\n\nprint(f\"\\n🎉 最终 Accuracy: {final_acc*100:.2f}%\")\nprint(f\"🎉 最终 Kappa Score: {final_kappa:.4f}\")\n\n# 绘图\ncm = confusion_matrix(valid_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'Final Confusion Matrix (Acc: {final_acc:.4f})')\nplt.xlabel('Predicted')\nplt.ylabel('True')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom sklearn.model_selection import train_test_split\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom concurrent.futures import ProcessPoolExecutor\nimport torch.nn.functional as F\n\n# ==========================================\n# 1. 终极配置 (Dual Backbone Mode)\n# ==========================================\nCONFIG = {\n    \"seed\": 2025,\n    \"img_size\": 256,  # 256 是双模型下显存和精度的最佳平衡点\n    \"batch_size\": 12, # 双模型显存占用大，调小 batch_size 防止炸显存\n    \"epochs\": 30,     \n    \"lr\": 1e-4,       \n    \"num_classes\": 5, \n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    \"csv_path\": \"/kaggle/input/aptos2019-blindness-detection/train.csv\",\n    \"raw_img_dir\": \"/kaggle/input/aptos2019-blindness-detection/train_images\",\n    \"processed_img_dir\": \"/kaggle/working/processed_images_clahe_256\"\n}\n\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\nprint(f\"Using device: {CONFIG['device']}\")\n\n# ==========================================\n# 2. 预处理 (CLAHE + Resize)\n# ==========================================\ndef process_one_image(img_name):\n    load_path = os.path.join(CONFIG['raw_img_dir'], img_name + \".png\")\n    save_path = os.path.join(CONFIG['processed_img_dir'], img_name + \".png\")\n    \n    image = cv2.imread(load_path)\n    if image is None: return \n    \n    image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n    \n    # CLAHE 增强\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n    lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)\n    l, a, b = cv2.split(lab)\n    cl = clahe.apply(l)\n    merged = cv2.merge((cl, a, b))\n    final_image = cv2.cvtColor(merged, cv2.COLOR_LAB2RGB)\n    \n    cv2.imwrite(save_path, cv2.cvtColor(final_image, cv2.COLOR_RGB2BGR))\n\ndef check_and_preprocess():\n    if os.path.exists(CONFIG['processed_img_dir']) and len(os.listdir(CONFIG['processed_img_dir'])) > 100:\n        print(\"✅ 检测到已预处理数据，直接使用。\")\n        return\n    \n    print(f\"🚀 正在生成 {CONFIG['img_size']}px 数据 (多进程)...\")\n    os.makedirs(CONFIG['processed_img_dir'], exist_ok=True)\n    df = pd.read_csv(CONFIG['csv_path'])\n    image_ids = df['id_code'].tolist()\n    with ProcessPoolExecutor() as executor:\n        list(tqdm(executor.map(process_one_image, image_ids), total=len(image_ids)))\n    print(\"✅ 预处理完成\")\n\ncheck_and_preprocess()\n\n# ==========================================\n# 3. Dataset (分类模式)\n# ==========================================\nclass ProcessedDRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df\n        self.img_dir = img_dir\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_name = self.df.iloc[idx]['id_code']\n        img_path = os.path.join(self.img_dir, img_name + \".png\")\n        label = self.df.iloc[idx]['diagnosis']\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        if self.transform: image = self.transform(image)\n        return image, torch.tensor(label, dtype=torch.long)\n\ndf = pd.read_csv(CONFIG['csv_path'])\ntrain_df, val_df = train_test_split(df, test_size=0.2, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(360), # 360度旋转\n    transforms.ColorJitter(brightness=0.1, contrast=0.1),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_loader = DataLoader(ProcessedDRDataset(train_df, CONFIG['processed_img_dir'], transform=train_transforms), \n                          batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(ProcessedDRDataset(val_df, CONFIG['processed_img_dir'], transform=val_transforms), \n                        batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# ==========================================\n# 4. 🏆 核心模型: Dual Backbone (DenseNet + ResNet)\n# ==========================================\nclass DualBackboneModel(nn.Module):\n    def __init__(self, num_classes=5):\n        super(DualBackboneModel, self).__init__()\n        \n        print(\"💡 正在构建双流模型: DenseNet201 + ResNet50 (Strong Baseline)...\")\n        \n        # Branch 1: DenseNet201 (擅长细节纹理)\n        self.densenet = models.densenet201(weights=models.DenseNet201_Weights.IMAGENET1K_V1)\n        dens_out = self.densenet.classifier.in_features # 1920\n        self.densenet.classifier = nn.Identity() # 移除分类头，只取特征\n        \n        # Branch 2: ResNet50 (擅长整体结构)\n        self.resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)\n        res_out = self.resnet.fc.in_features # 2048\n        self.resnet.fc = nn.Identity() # 移除分类头\n        \n        # 融合后的全连接层\n        self.fusion_fc = nn.Sequential(\n            nn.Linear(dens_out + res_out, 256), # 1920 + 2048 -> 256\n            nn.ReLU(),\n            nn.Dropout(0.5),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x):\n        # 左手画圆\n        feat1 = self.densenet(x)\n        # 右手画方\n        feat2 = self.resnet(x)\n        \n        # 合体！\n        concat_feat = torch.cat((feat1, feat2), dim=1)\n        output = self.fusion_fc(concat_feat)\n        return output\n\nmodel = DualBackboneModel(num_classes=5).to(CONFIG['device'])\n\n# ==========================================\n# 5. 训练配置 (Class Weights + Label Smoothing)\n# ==========================================\nfrom sklearn.utils.class_weight import compute_class_weight\nclass_weights = compute_class_weight('balanced', classes=np.unique(train_df['diagnosis']), y=train_df['diagnosis'])\nclass_weights = torch.tensor(class_weights, dtype=torch.float).to(CONFIG['device'])\n\n# 这里的 label_smoothing=0.1 是提分神器，防止模型对 Class 2 过度自信\ncriterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'], weight_decay=1e-4)\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=4, verbose=True)\n\n# ==========================================\n# 6. 训练循环\n# ==========================================\nbest_acc = 0.0\nprint(\"\\n🔥 开始双流模型训练 (Training Started)...\")\n\nfor epoch in range(CONFIG['epochs']):\n    model.train()\n    train_loss = 0.0\n    correct = 0\n    total = 0\n    \n    # Training\n    for images, labels in tqdm(train_loader, desc=f\"Epoch {epoch+1}/{CONFIG['epochs']}\"):\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        train_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n    \n    train_acc = correct / total\n\n    # Validation\n    model.eval()\n    val_correct = 0\n    val_total = 0\n    val_preds = []\n    val_labels = []\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            outputs = model(images)\n            _, predicted = torch.max(outputs.data, 1)\n            \n            val_total += labels.size(0)\n            val_correct += (predicted == labels).sum().item()\n            \n            val_preds.extend(predicted.cpu().numpy())\n            val_labels.extend(labels.cpu().numpy())\n            \n    val_acc = val_correct / val_total\n    kappa = cohen_kappa_score(val_labels, val_preds, weights='quadratic')\n    \n    print(f\"Epoch {epoch+1} | Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f} | Kappa: {kappa:.4f}\")\n    \n    scheduler.step(val_acc)\n    \n    if val_acc > best_acc:\n        best_acc = val_acc\n        torch.save(model.state_dict(), \"best_dual_model.pth\")\n        print(f\"✅ 新纪录! Best Accuracy: {best_acc*100:.2f}%\")\n\n# ==========================================\n# 7. 最终结果展示\n# ==========================================\nprint(\"\\n🏆 加载最佳双流模型进行评估...\")\nmodel.load_state_dict(torch.load(\"best_dual_model.pth\"))\nmodel.eval()\n\nfinal_preds = []\nfinal_labels = []\n\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        outputs = model(images)\n        _, predicted = torch.max(outputs.data, 1)\n        final_preds.extend(predicted.cpu().numpy())\n        final_labels.extend(labels.cpu().numpy())\n\nprint(\"\\n=== Classification Report ===\")\nprint(classification_report(final_labels, final_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\n\nfinal_acc = np.mean(np.array(final_preds) == np.array(final_labels))\nprint(f\"🎉 最终 Accuracy: {final_acc*100:.2f}%\")\n\ncm = confusion_matrix(final_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'Dual Backbone Confusion Matrix (Acc: {final_acc:.4f})')\nplt.ylabel('True')\nplt.xlabel('Predicted')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\nimport os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom sklearn.model_selection import train_test_split\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\nfrom concurrent.futures import ProcessPoolExecutor\n\n# 1. 精准路径配置 (基于你的截图)\n# ==========================================\n# 根目录 (根据你的截图推断)\nBASE_DIR = \"/kaggle/input/resized-2015-2019-blindness-detection-images\"\n\nCONFIG = {\n    \"seed\": 2025,\n    \"img_size\": 224,      \n    \"batch_size\": 32,     \n    \"epochs\": 15,        \n    \"lr\": 1e-4,       \n    \"num_classes\": 5, \n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n}\n\n# ==========================================\n# 2. 混合数据加载器 (2015 + 2019)\n# ==========================================\ndef load_mixed_data_v2():\n    print(\"🚀 正在分别读取 2015 和 2019 的数据表...\")\n    \n    # --- 1. 处理 2015 数据 ---\n    # 你的截图显示 CSV 在 labels 文件夹下\n    path_15 = os.path.join(BASE_DIR, \"labels/trainLabels15.csv\")\n    df_15 = pd.read_csv(path_15)\n    df_15.rename(columns={'image': 'id_code', 'level': 'diagnosis'}, inplace=True)\n    \n    # 2015 的图片通常在 'resized train 15' 文件夹里 (截图里有 resized test 15，推测 train 也在同级)\n    # 我们先自动搜索一下 'resized train 15' 在哪里\n    dir_15 = None\n    for root, dirs, files in os.walk(BASE_DIR):\n        if \"resized train 15\" in dirs:\n            dir_15 = os.path.join(root, \"resized train 15\")\n            break\n            \n    if dir_15:\n        # 截图显示后缀是 .jpg\n        df_15['id_code'] = df_15['id_code'].apply(lambda x: os.path.join(dir_15, f\"{x}.jpg\"))\n        print(f\"✅ 2015 数据加载成功: {len(df_15)} 张 (路径: {dir_15})\")\n    else:\n        print(\"⚠️ 警告: 没找到 'resized train 15' 文件夹，可能数据集解压结构不同，跳过 2015 数据。\")\n        df_15 = pd.DataFrame() # 空表\n\n    # --- 2. 处理 2019 数据 ---\n    path_19 = os.path.join(BASE_DIR, \"labels/trainLabels19.csv\")\n    df_19 = pd.read_csv(path_19)\n    # 2019 的列名通常是 id_code, diagnosis，但也可能不一样\n    if 'image' in df_19.columns: df_19.rename(columns={'image': 'id_code'}, inplace=True)\n    if 'level' in df_19.columns: df_19.rename(columns={'level': 'diagnosis'}, inplace=True)\n    \n    # 寻找 2019 图片目录\n    dir_19 = None\n    for root, dirs, files in os.walk(BASE_DIR):\n        if \"resized train 19\" in dirs:\n            dir_19 = os.path.join(root, \"resized train 19\")\n            break\n            \n    if dir_19:\n        df_19['id_code'] = df_19['id_code'].apply(lambda x: os.path.join(dir_19, f\"{x}.jpg\"))\n        print(f\"✅ 2019 数据加载成功: {len(df_19)} 张 (路径: {dir_19})\")\n    else:\n        # 如果找不到 resized train 19，就尝试用官方原始数据集路径\n        print(\"⚠️ 没找到 resized 2019 目录，尝试使用原始路径...\")\n        raw_19_dir = \"/kaggle/input/aptos2019-blindness-detection/train_images\"\n        if os.path.exists(raw_19_dir):\n            df_19['id_code'] = df_19['id_code'].apply(lambda x: os.path.join(raw_19_dir, f\"{x}.png\"))\n            print(f\"✅ 已回退到 APTOS 2019 原始路径: {len(df_19)} 张\")\n\n    # --- 3. 合并与清洗 ---\n    df_final = pd.concat([df_15, df_19], axis=0).reset_index(drop=True)\n    \n    # 再次检查文件是否存在 (非常重要，防止报错)\n    print(\"正在最终校验文件路径 (这可能需要几秒钟)...\")\n    # 只检查前 10 个和后 10 个作为快速验证\n    valid_count = 0\n    # 这里的 lambda 函数会检查文件是否真的在硬盘上\n    df_final['exists'] = df_final['id_code'].apply(os.path.exists)\n    df_final = df_final[df_final['exists']].reset_index(drop=True)\n    \n    print(f\"✅ 最终有效图片数量: {len(df_final)}\")\n    \n    # --- 4. 智能采样 (只留精华) ---\n    df_0 = df_final[df_final['diagnosis'] == 0]\n    df_others = df_final[df_final['diagnosis'] != 0]\n    \n    # 健康样本太多了，取 5000 张平衡一下\n    if len(df_0) > 5000:\n        df_0 = df_0.sample(n=5000, random_state=2025)\n        \n    df_train = pd.concat([df_0, df_others], axis=0).sample(frac=1, random_state=2025).reset_index(drop=True)\n    print(f\"📉 训练集精简后: {len(df_train)} 张 (包含所有患病样本 + 5000健康样本)\")\n    \n    return df_train\n\n# 执行加载\ndf = load_mixed_data_v2()\n\n# 划分\ntrain_df, val_df = train_test_split(df, test_size=0.1, random_state=CONFIG['seed'], stratify=df['diagnosis'])\n# 4. Dataset & DataLoader\n# ==========================================\nclass RetinopathyDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_path = self.df.iloc[idx]['id_code']\n        label = self.df.iloc[idx]['diagnosis']\n        \n        image = cv2.imread(img_path)\n        # 容错：万一读不到图\n        if image is None:\n            # print(f\"Warning: Could not read {img_path}\")\n            image = np.zeros((CONFIG['img_size'], CONFIG['img_size'], 3), dtype=np.uint8)\n        else:\n            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n            image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size'])) # 确保尺寸统一\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, torch.tensor(label, dtype=torch.long)\n\n# 增强策略\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(180),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_loader = DataLoader(RetinopathyDataset(train_df, transform=train_transforms), \n                          batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\nval_loader = DataLoader(RetinopathyDataset(val_df, transform=val_transforms), \n                        batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# ==========================================\n# 5. 双流模型 (Dual Backbone)\n# ==========================================\nclass DualBackboneModel(nn.Module):\n    def __init__(self, num_classes=5):\n        super(DualBackboneModel, self).__init__()\n        print(\"💡 构建双流模型 (DenseNet201 + ResNet50)...\")\n        \n        # Branch 1: DenseNet\n        self.densenet = models.densenet201(weights=models.DenseNet201_Weights.IMAGENET1K_V1)\n        dens_out = self.densenet.classifier.in_features\n        self.densenet.classifier = nn.Identity()\n        \n        # Branch 2: ResNet\n        self.resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)\n        res_out = self.resnet.fc.in_features\n        self.resnet.fc = nn.Identity()\n        \n        # Fusion\n        self.fusion_fc = nn.Sequential(\n            nn.Linear(dens_out + res_out, 256),\n            nn.ReLU(),\n            nn.Dropout(0.5),\n            nn.Linear(256, num_classes)\n        )\n\n    def forward(self, x):\n        feat1 = self.densenet(x)\n        feat2 = self.resnet(x)\n        concat_feat = torch.cat((feat1, feat2), dim=1)\n        output = self.fusion_fc(concat_feat)\n        return output\n\nmodel = DualBackboneModel(num_classes=5).to(CONFIG['device'])\n\n# ==========================================\n# 6. 训练循环\n# ==========================================\nfrom sklearn.utils.class_weight import compute_class_weight\n# 计算 Class Weights 防止不平衡\nclass_weights = compute_class_weight('balanced', classes=np.unique(train_df['diagnosis']), y=train_df['diagnosis'])\nclass_weights = torch.tensor(class_weights, dtype=torch.float).to(CONFIG['device'])\n\ncriterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'])\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=2, verbose=True)\n\nbest_acc = 0.0\nprint(f\"\\n🔥 开始训练！总数据量: {len(df)} (已过滤精华)\")\n\nfor epoch in range(CONFIG['epochs']):\n    model.train()\n    train_loss = 0.0\n    correct = 0\n    total = 0\n    \n    pbar = tqdm(train_loader, desc=f\"Epoch {epoch+1}/{CONFIG['epochs']}\")\n    for images, labels in pbar:\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        train_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        pbar.set_postfix({'loss': loss.item(), 'acc': correct/total})\n    \n    train_acc = correct / total\n\n    # Validation\n    model.eval()\n    val_correct = 0\n    val_total = 0\n    val_preds = []\n    val_labels = []\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            outputs = model(images)\n            _, predicted = torch.max(outputs.data, 1)\n            \n            val_total += labels.size(0)\n            val_correct += (predicted == labels).sum().item()\n            val_preds.extend(predicted.cpu().numpy())\n            val_labels.extend(labels.cpu().numpy())\n            \n    val_acc = val_correct / val_total\n    kappa = cohen_kappa_score(val_labels, val_preds, weights='quadratic')\n    \n    print(f\"Epoch {epoch+1} | Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f} | Kappa: {kappa:.4f}\")\n    \n    scheduler.step(val_acc)\n    \n    if val_acc > best_acc:\n        best_acc = val_acc\n        torch.save(model.state_dict(), \"best_mega_model.pth\")\n        print(f\"✅ 新纪录! Best Accuracy: {best_acc*100:.2f}%\")\n\n# ==========================================\n# 7. 结果展示\n# ==========================================\nprint(\"\\n🏆 最终评估...\")\nmodel.load_state_dict(torch.load(\"best_mega_model.pth\"))\nmodel.eval()\n\nfinal_preds = []\nfinal_labels = []\n\nwith torch.no_grad():\n    for images, labels in val_loader:\n        images = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        outputs = model(images)\n        _, predicted = torch.max(outputs.data, 1)\n        final_preds.extend(predicted.cpu().numpy())\n        final_labels.extend(labels.cpu().numpy())\n\nprint(classification_report(final_labels, final_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\nacc = np.mean(np.array(final_preds) == np.array(final_labels))\nprint(f\"🎉 最终 Accuracy: {acc*100:.2f}%\")\n\ncm = confusion_matrix(final_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'Mega Dataset Confusion Matrix (Acc: {acc:.4f})')\nplt.ylabel('True')\nplt.xlabel('Predicted')\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import models, transforms\nfrom sklearn.metrics import classification_report, confusion_matrix, cohen_kappa_score\nfrom sklearn.model_selection import train_test_split\nfrom tqdm import tqdm\n\n# ==========================================\n# 1. 核心配置\n# ==========================================\nCONFIG = {\n    \"seed\": 2025,\n    \"img_size\": 256,      # 256 平衡点\n    \"batch_size\": 16,     # 稍微大一点\n    \"epochs\": 15,         # 15轮足够\n    \"lr\": 1e-4,       \n    \"num_classes\": 5, \n    \"device\": torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\"),\n    # 两个数据集的路径\n    \"path_2015_images\": \"/kaggle/input/resized-2015-2019-blindness-detection-images/resized_train_images\",\n    \"path_2015_csv\": \"/kaggle/input/resized-2015-2019-blindness-detection-images/labels/trainLabels15.csv\",\n    \"path_2019_base\": \"/kaggle/input/aptos2019-blindness-detection\"\n}\n\ndef seed_everything(seed):\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nseed_everything(CONFIG['seed'])\n\n# ==========================================\n# 2. Ben's Preprocessing (提分神器：圆形裁剪)\n# ==========================================\ndef crop_image_from_gray(img, tol=7):\n    \"\"\"\n    自动切掉眼底图周围的黑色区域，只保留眼球\n    \"\"\"\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_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)\n        mask = gray_img > tol\n        \n        check_shape = img[:,:,0][np.ix_(mask.any(1),mask.any(0))].shape[0]\n        if (check_shape == 0): # image is too dark so that we crop out everything,\n            return img # return original image\n        else:\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            img = np.stack([img1,img2,img3],axis=-1)\n        return img\n\ndef circle_crop(img, sigmaX=10):\n    \"\"\"\n    进一步处理：把图像resize并应用高斯模糊，突出血管\n    \"\"\"\n    img = crop_image_from_gray(img)\n    img = cv2.resize(img, (CONFIG['img_size'], CONFIG['img_size']))\n    img = cv2.addWeighted(img, 4, cv2.GaussianBlur(img, (0, 0), sigmaX), -4, 128)\n    return img\n\n# ==========================================\n# 3. 数据准备：分开准备 Train 和 Val\n# ==========================================\ndef prepare_data():\n    print(\"🚀 正在构建数据集：训练集用混合数据，验证集只用2019数据...\")\n    \n    # --- A. 准备 2019 数据 (高质量) ---\n    df_19 = pd.read_csv(os.path.join(CONFIG['path_2019_base'], \"train.csv\"))\n    df_19['id_code'] = df_19['id_code'].apply(lambda x: os.path.join(CONFIG['path_2019_base'], \"train_images\", x + \".png\"))\n    # 给 2019 数据打标，方便识别\n    df_19['source'] = '2019'\n    \n    # --- B. 准备 2015 数据 (作为补充粮草) ---\n    # 注意：需要你的 resized 数据集路径正确\n    df_15 = pd.read_csv(CONFIG['path_2015_csv'])\n    df_15.rename(columns={'image': 'id_code', 'level': 'diagnosis'}, inplace=True)\n    \n    # 自动寻找 2015 图片目录\n    real_2015_dir = None\n    base_search = \"/kaggle/input/resized-2015-2019-blindness-detection-images\"\n    for root, dirs, files in os.walk(base_search):\n        if \"resized train 15\" in dirs:\n            real_2015_dir = os.path.join(root, \"resized train 15\")\n            break\n    \n    if real_2015_dir:\n        df_15['id_code'] = df_15['id_code'].apply(lambda x: os.path.join(real_2015_dir, x + \".jpg\"))\n        df_15['source'] = '2015'\n        print(f\"✅ 2015 数据就绪: {len(df_15)} 张\")\n    else:\n        print(\"⚠️ 没找到 2015 图片目录，仅使用 2019 数据\")\n        df_15 = pd.DataFrame()\n\n    # --- C. 关键划分 ---\n    # 验证集 (Validation)：必须全部来自 2019！这样测出来的分才是真实的！\n    # 我们从 2019 里切 20% 出来做验证\n    train_19, val_19 = train_test_split(df_19, test_size=0.2, random_state=CONFIG['seed'], stratify=df_19['diagnosis'])\n    \n    # 训练集 (Training)：剩下的 2019 + 所有的 2015\n    # 我们可以对 2015 进行采样，不要全用，否则跑太慢\n    if len(df_15) > 10000:\n        # 只取 1.5万张 2015 的数据，加上 3000 张 2019 的数据\n        df_15_sample = df_15.sample(n=15000, random_state=2025)\n    else:\n        df_15_sample = df_15\n        \n    df_train_final = pd.concat([train_19, df_15_sample], axis=0).sample(frac=1).reset_index(drop=True)\n    df_val_final = val_19.reset_index(drop=True)\n    \n    print(\"-\" * 30)\n    print(f\"📚 训练集 (混合): {len(df_train_final)} 张 (2015辅助 + 2019核心)\")\n    print(f\"📝 验证集 (纯2019): {len(df_val_final)} 张 (这就是你的真实成绩单)\")\n    print(\"-\" * 30)\n    \n    return df_train_final, df_val_final\n\ntrain_df, val_df = prepare_data()\n\n# ==========================================\n# 4. Dataset\n# ==========================================\nclass RetinopathyDataset(Dataset):\n    def __init__(self, df, transform=None, mode='train'):\n        self.df = df\n        self.transform = transform\n        self.mode = mode\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        img_path = self.df.iloc[idx]['id_code']\n        label = self.df.iloc[idx]['diagnosis']\n        \n        image = cv2.imread(img_path)\n        if image is None: # 容错\n            image = np.zeros((CONFIG['img_size'], CONFIG['img_size'], 3), dtype=np.uint8)\n        else:\n            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n            # 应用 Ben's Preprocessing (圆形裁剪+高斯模糊)\n            # 这步可能会稍微慢一点点，但对提分非常关键\n            try:\n                image = circle_crop(image)\n            except:\n                image = cv2.resize(image, (CONFIG['img_size'], CONFIG['img_size']))\n        \n        if self.transform:\n            image = self.transform(image)\n            \n        return image, torch.tensor(label, dtype=torch.long)\n\ntrain_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(),\n    transforms.RandomRotation(360),\n    transforms.ColorJitter(brightness=0.2, contrast=0.2),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\nval_transforms = transforms.Compose([\n    transforms.ToPILImage(),\n    transforms.ToTensor(),\n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])\n])\n\ntrain_loader = DataLoader(RetinopathyDataset(train_df, transform=train_transforms), \n                          batch_size=CONFIG['batch_size'], shuffle=True, num_workers=2, pin_memory=True)\n# 验证集不动，也不Shuffle\nval_loader = DataLoader(RetinopathyDataset(val_df, transform=val_transforms), \n                        batch_size=CONFIG['batch_size'], shuffle=False, num_workers=2, pin_memory=True)\n\n# ==========================================\n# 5. 模型: EfficientNet-B5 (单体最强)\n# ==========================================\ndef build_model():\n    print(\"💡 加载 EfficientNet-B5 (Pretrained)...\")\n    model = models.efficientnet_b5(weights=models.EfficientNet_B5_Weights.IMAGENET1K_V1)\n    num_ftrs = model.classifier[1].in_features\n    model.classifier[1] = nn.Linear(num_ftrs, CONFIG['num_classes'])\n    return model\n\nmodel = build_model().to(CONFIG['device'])\n\n# ==========================================\n# 6. 训练循环\n# ==========================================\n# 使用 Class Weights 解决不平衡\nfrom sklearn.utils.class_weight import compute_class_weight\nclass_weights = compute_class_weight('balanced', classes=np.unique(train_df['diagnosis']), y=train_df['diagnosis'])\nclass_weights = torch.tensor(class_weights, dtype=torch.float).to(CONFIG['device'])\n\ncriterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)\noptimizer = optim.AdamW(model.parameters(), lr=CONFIG['lr'])\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=2, verbose=True)\n\nbest_acc = 0.0\nprint(\"\\n🔥 开始最终冲刺 (Train on Mixed, Val on 2019)...\")\n\nfor epoch in range(CONFIG['epochs']):\n    model.train()\n    train_loss = 0.0\n    correct = 0\n    total = 0\n    \n    pbar = tqdm(train_loader, desc=f\"Epoch {epoch+1}\")\n    for images, labels in pbar:\n        images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        train_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        pbar.set_postfix({'loss': loss.item(), 'acc': correct/total})\n    \n    # Validation (On 2019 Only)\n    model.eval()\n    val_correct = 0\n    val_total = 0\n    val_preds = []\n    val_labels = []\n    \n    with torch.no_grad():\n        for images, labels in val_loader:\n            images, labels = images.to(CONFIG['device']), labels.to(CONFIG['device'])\n            outputs = model(images)\n            _, predicted = torch.max(outputs.data, 1)\n            \n            val_total += labels.size(0)\n            val_correct += (predicted == labels).sum().item()\n            val_preds.extend(predicted.cpu().numpy())\n            val_labels.extend(labels.cpu().numpy())\n            \n    val_acc = val_correct / val_total\n    kappa = cohen_kappa_score(val_labels, val_preds, weights='quadratic')\n    \n    print(f\"Epoch {epoch+1} | Val Acc (2019): {val_acc:.4f} | Kappa: {kappa:.4f}\")\n    \n    scheduler.step(val_acc)\n    \n    if val_acc > best_acc:\n        best_acc = val_acc\n        torch.save(model.state_dict(), \"best_model_final.pth\")\n        print(f\"✅ 最佳模型保存! Best Acc: {best_acc:.4f}\")\n\n# ==========================================\n# 7. 最终评估\n# ==========================================\nprint(\"\\n🏆 加载最佳模型进行最终 TTA 评估...\")\nmodel.load_state_dict(torch.load(\"best_model_final.pth\"))\nmodel.eval()\n\nfinal_preds = []\nfinal_labels = []\n\n# 简单的 TTA: 原图 + 水平翻转\nwith torch.no_grad():\n    for images, labels in tqdm(val_loader, desc=\"TTA Testing\"):\n        images = images.to(CONFIG['device'])\n        \n        p1 = model(images)\n        p2 = model(torch.flip(images, dims=[3])) # Flip horizontal\n        \n        avg = (p1 + p2) / 2.0\n        _, predicted = torch.max(avg.data, 1)\n        \n        final_preds.extend(predicted.cpu().numpy())\n        final_labels.extend(labels.cpu().numpy())\n\nprint(classification_report(final_labels, final_preds, target_names=['No DR', 'Mild', 'Mod', 'Severe', 'Prolif']))\nacc = np.mean(np.array(final_preds) == np.array(final_labels))\nprint(f\"🎉 最终 Accuracy: {acc*100:.2f}%\")\n\ncm = confusion_matrix(final_labels, final_preds)\nplt.figure(figsize=(10, 8))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Greens')\nplt.title(f'Final Validation (2019 Only)\\nAcc: {acc:.4f}')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-01T08:49:11.980540Z","iopub.execute_input":"2025-12-01T08:49:11.980756Z","execution_failed":"2025-12-01T11:59:27.425Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport torch\nimport os\nimport cv2\nfrom PIL import Image\nfrom torch.utils.data import Dataset, DataLoader, WeightedRandomSampler\nfrom sklearn.model_selection import train_test_split\nfrom torchvision import transforms\nfrom sklearn.utils.class_weight import compute_class_weight \n\n# ======================================================\n# 1. 路径配置 (请根据你的实际情况修改这里！！！)\n# ======================================================\n\n# 【情况 A】如果你用的是上传好的 Dataset (推荐):\n# SAVE_DIR = \"/kaggle/input/你的数据集名字/processed_images\" \nSAVE_DIR = \"/kaggle/input/aptos-2019-preprocessed-224/processed_images\"\n\n\n# 检查一下路径对不对，不对直接报错，免得后面训练白费劲\nif not os.path.exists(SAVE_DIR):\n    raise FileNotFoundError(f\"❌ 找不到文件夹: {SAVE_DIR}。请检查路径设置！\")\nelse:\n    print(f\"✅ 图片文件夹定位成功: {SAVE_DIR}\")\n    print(f\"文件夹内图片数量: {len(os.listdir(SAVE_DIR))}\")\n\nCSV_PATH = \"/kaggle/input/aptos2019-blindness-detection/train.csv\"\nBATCH_SIZE = 32\n\n# ======================================================\n# 2. 定义 Dataset 类 (加了一个 print 警告)\n# ======================================================\nclass RetinopathyDataset(Dataset):\n    def __init__(self, dataframe, img_dir, transform=None):\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        if torch.is_tensor(idx):\n            idx = idx.tolist()\n            \n        row = self.dataframe.iloc[idx]\n        filename = row[\"filename\"]\n        label = row[\"diagnosis\"]\n        img_path = os.path.join(self.img_dir, filename)\n        \n        image = cv2.imread(img_path)\n        \n        # --- 这里的修改：如果是 None，报错而不是给黑图 ---\n        # 否则你可能训练了半天发现准确率不涨，其实全是黑图\n        if image is None:\n            print(f\"❌ 警告: 无法读取图片 {img_path}\")\n            # 只有当你确定个别图片损坏时才用全黑图，否则建议直接在这里报错检查\n            image = np.zeros((224, 224, 3), dtype=np.uint8)\n            \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(image)\n        \n        if self.transform:\n            img_tensor = self.transform(img_pil)\n        else:\n            img_tensor = transforms.ToTensor()(img_pil)\n            \n        return img_tensor, torch.tensor(label, dtype=torch.long)\n\n# ======================================================\n# 3. 定义数据增强\n# ======================================================\ndata_transforms = {\n    'train': transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.RandomHorizontalFlip(),\n        transforms.RandomVerticalFlip(),\n        transforms.RandomRotation(30),\n        transforms.ColorJitter(brightness=0.1, contrast=0.1),\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ]),\n    'val': transforms.Compose([\n        transforms.Resize((224, 224)),\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ]),\n}\n\n# ======================================================\n# 4. 读取数据并划分\n# ======================================================\ndf = pd.read_csv(CSV_PATH)\ndf[\"filename\"] = df[\"id_code\"].apply(lambda x: x + \".png\")\n\n# 重新划分\ntrain_df, val_df = train_test_split(df, test_size=0.2, stratify=df[\"diagnosis\"], random_state=42)\ntrain_df = train_df.reset_index(drop=True)\nval_df = val_df.reset_index(drop=True)\n\nprint(f\"训练集大小: {len(train_df)} | 验证集大小: {len(val_df)}\")\n\n# ======================================================\n# 5. 制作过采样 Sampler (逻辑无误)\n# ======================================================\nprint(\"正在计算采样权重...\")\nclass_weights_np = compute_class_weight('balanced', classes=np.unique(train_df['diagnosis']), y=train_df['diagnosis'])\n\n# 映射权重到每个样本\ntrain_targets = train_df['diagnosis'].to_numpy()\n# 列表推导式可能慢，numpy映射更快，但你的写法也没问题\nsamples_weight = torch.DoubleTensor([class_weights_np[t] for t in train_targets])\n\nsampler = WeightedRandomSampler(weights=samples_weight, num_samples=len(samples_weight), replacement=True)\n\n# ======================================================\n# 6. 生成 DataLoader\n# ======================================================\n# 这里的 transform 记得传进去\ntrain_ds = RetinopathyDataset(train_df, SAVE_DIR, transform=data_transforms['train'])\nval_ds = RetinopathyDataset(val_df, SAVE_DIR, transform=data_transforms['val'])\n\ntrain_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, sampler=sampler, shuffle=False, num_workers=2)\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2)\n\nprint(\"\\n✅ DataLoader 准备完毕！可以开始训练了。\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from PIL import Image\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as transforms\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.utils.class_weight import compute_class_weight\nimport torch\nimport numpy as np\n\nclass RetinopathyDataset(Dataset):\n    def __init__(self, dataframe, img_dir, transform=None):\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        filename = row[\"filename\"] # 使用我们可以确定的文件名\n        label = row[\"diagnosis\"]\n        \n        # 1. 从磁盘构建路径\n        img_path = os.path.join(self.img_dir, filename)\n        \n        # 2. 读取图片 (OpenCV 读取的是 BGR)\n        image = cv2.imread(img_path)\n        \n        if image is None:\n            # 容错处理：如果读取失败，创建一个黑图\n            image = np.zeros((224, 224, 3), dtype=np.uint8)\n        \n        # 3. BGR 转 RGB (非常重要！因为 PyTorch/PIL 期望 RGB)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n        # 4. 转为 PIL Image 以便应用 transforms\n        img_pil = Image.fromarray(image)\n        \n        # 5. 应用增强\n        if self.transform:\n            img_tensor = self.transform(img_pil)\n        else:\n            img_tensor = transforms.ToTensor()(img_pil)\n\n        return img_tensor, torch.tensor(label, dtype=torch.long)\n\n# --- 数据增强配置 (保持不变) ---\ndata_transforms = {\n    'train': transforms.Compose([\n        transforms.RandomHorizontalFlip(p=0.5),\n        transforms.RandomVerticalFlip(p=0.5),\n        transforms.RandomRotation(degrees=45),\n        transforms.RandomAffine(degrees=0, translate=(0.1, 0.1), scale=(0.9, 1.1)),\n        transforms.ColorJitter(brightness=0.1, contrast=0.1),\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ]),\n    'val': transforms.Compose([\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ]),\n}","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from torch.utils.data import WeightedRandomSampler\n\n# 1. 划分数据集 (保持不变)\ntrain_df, test_val_df = train_test_split(df, test_size=0.3, stratify=df[\"diagnosis\"], random_state=42)\nval_df, test_df = train_test_split(test_val_df, test_size=0.5, stratify=test_val_df[\"diagnosis\"], random_state=42)\n\nprint(f\"Train: {len(train_df)}, Val: {len(val_df)}, Test: {len(test_df)}\")\n\n# 2. 计算 Class Weights (保持不变，用于 Loss 或 采样)\n# 注意：这里我们保留 numpy 版本用于生成采样器权重\nclass_weights_np = compute_class_weight('balanced', classes=np.unique(train_df['diagnosis']), y=train_df['diagnosis'])\n# 转为 Tensor 备用 (如果你还想在 Loss 里双重加权的话，虽然通常用了采样就不需要 Loss 加权了)\nclass_weights = torch.tensor(class_weights_np, dtype=torch.float).to(torch.device(\"cuda\"))\nprint(f\"Class Weights: {class_weights}\")\n\n# === 【新增步骤】为过采样准备样本权重 ===\n# 2.1 获取训练集所有 Label\ntrain_targets = train_df['diagnosis'].to_numpy()\n\n# 2.2 给每个样本分配权重 (样本权重 = 它所属类别的权重)\n# 列表推导式：遍历每个样本的 label，查表找到对应的 weight\nsamples_weight = [class_weights_np[t] for t in train_targets]\nsamples_weight = torch.DoubleTensor(samples_weight) # 采样器需要 DoubleTensor\n\n# 2.3 创建采样器\n# replacement=True 表示允许重复抽样 (这就是过采样的原理)\nsampler = WeightedRandomSampler(weights=samples_weight, num_samples=len(samples_weight), replacement=True)\n\n# 3. 实例化 Dataset (保持不变)\ntrain_ds = RetinopathyDataset(train_df, SAVE_DIR, transform=data_transforms['train'])\nval_ds = RetinopathyDataset(val_df, SAVE_DIR, transform=data_transforms['val'])\ntest_ds = RetinopathyDataset(test_df, SAVE_DIR, transform=data_transforms['val'])\n\n# 4. DataLoader (关键修改！)\nBATCH_SIZE = 32\n\n# === 修改点：train_loader 加入 sampler，并关闭 shuffle ===\ntrain_loader = DataLoader(\n    train_ds, \n    batch_size=BATCH_SIZE, \n    sampler=sampler,   # <--- 加入采样器\n    shuffle=False,     # <--- 必须改为 False！！Sampler 和 Shuffle 互斥\n    num_workers=2, \n    pin_memory=True\n)\n\n# 验证集和测试集不需要过采样，保持 shuffle=False 即可\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\ntest_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\nprint(\"DataLoaders with Oversampling are ready!\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nfrom torchvision import models\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nclass MultiBranchModel(nn.Module):\n    def __init__(self, num_classes=5):\n        super(MultiBranchModel, self).__init__()\n        \n        # --- Branch 1: DenseNet201 ---\n        self.densenet = models.densenet201(weights=models.DenseNet201_Weights.IMAGENET1K_V1)\n        self.densenet_features = self.densenet.features\n        # DenseNet 输出通道: 1920\n        \n        # --- Branch 2: ResNet50 ---\n        self.resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)\n        # 去掉最后两层 (AvgPool, FC)\n        self.resnet_features = nn.Sequential(*list(self.resnet.children())[:-2]) \n        # ResNet 输出通道: 2048\n        \n        # 全局池化\n        self.global_pool = nn.AdaptiveAvgPool2d((1, 1))\n        \n        # 分支特定的 Dropout (防止某一个分支主导)\n        self.branch_dropout = nn.Dropout(0.5)\n        \n        # 合并后的分类头\n        # 输入维度: 1920 + 2048 = 3968\n        self.fc = nn.Sequential(\n            nn.Linear(3968, 512),\n            nn.BatchNorm1d(512), # 加 BN 层加速收敛\n            nn.ReLU(),\n            nn.Dropout(0.5),     # 强力 Dropout\n            nn.Linear(512, num_classes)\n        )\n        \n    def forward(self, x):\n        # Branch 1\n        x1 = self.densenet_features(x)\n        x1 = nn.functional.relu(x1, inplace=True)\n        x1 = self.global_pool(x1)\n        x1 = torch.flatten(x1, 1)\n        \n        # Branch 2\n        x2 = self.resnet_features(x)\n        x2 = self.global_pool(x2)\n        x2 = torch.flatten(x2, 1)\n        \n        # Concatenate\n        x_cat = torch.cat((x1, x2), dim=1)\n        x_cat = self.branch_dropout(x_cat) # 应用 Dropout\n        \n        # Classification\n        out = self.fc(x_cat)\n        return out\n\nmodel = MultiBranchModel(num_classes=5).to(device)\nprint(\"Model initialized.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_one_epoch(model, loader, criterion, optimizer):\n    model.train()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    pbar = tqdm(loader, desc=\"Training\", leave=False)\n    for images, labels in pbar:\n        images, labels = images.to(device), labels.to(device)\n        \n        optimizer.zero_grad()\n        outputs = model(images)\n        loss = criterion(outputs, labels)\n        loss.backward()\n        optimizer.step()\n        \n        running_loss += loss.item()\n        _, predicted = torch.max(outputs.data, 1)\n        total += labels.size(0)\n        correct += (predicted == labels).sum().item()\n        \n        pbar.set_postfix({'loss': loss.item()})\n        \n    return running_loss / len(loader), correct / total\n\ndef validate(model, loader, criterion):\n    model.eval()\n    running_loss = 0.0\n    correct = 0\n    total = 0\n    \n    with torch.no_grad():\n        for images, labels in tqdm(loader, desc=\"Validating\", leave=False):\n            images, labels = images.to(device), labels.to(device)\n            outputs = model(images)\n            loss = criterion(outputs, labels)\n            \n            running_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n            total += labels.size(0)\n            correct += (predicted == labels).sum().item()\n            \n    return running_loss / len(loader), correct / total","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nfrom tqdm import tqdm\nimport torch.optim as optim\n\n# 1. 确保定义了设备\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 2. 将之前计算出的 list/numpy 权重转换为 Tensor，并移至 device\n# 假设 class_weights = [0.40567866, 1.9790541, 0.73316646, 3.8038962, 2.4822035]\nclass_weights_tensor = torch.tensor(class_weights, dtype=torch.float32).to(device)\n\n# 3. 传入转换后的 Tensor\ncriterion = nn.CrossEntropyLoss(weight=class_weights_tensor)\n# ---------------------------------------------------------\n# STAGE 1: Warmup (冻结骨干，只训练分类头)\n# ---------------------------------------------------------\nprint(\"=== Stage 1: Training Head Only ===\")\n\n# 1. 冻结所有层\nfor param in model.parameters():\n    param.requires_grad = False\n# 2. 解冻分类头 (fc)\nfor param in model.fc.parameters():\n    param.requires_grad = True\n\n# Stage 1 优化器 (学习率稍大)\noptimizer_s1 = optim.Adam(model.fc.parameters(), lr=1e-3)\nbest_val_acc = 0.0\n\nfor epoch in range(5): # 训练 5 个 epoch 预热\n    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer_s1)\n    val_loss, val_acc = validate(model, val_loader, criterion)\n    print(f\"Epoch {epoch+1}/5 - Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}\")\n    \n    if val_acc > best_val_acc:\n        best_val_acc = val_acc\n        torch.save(model.state_dict(), \"model_stage1.pth\")\n\nprint(\"Stage 1 Complete. Loading best weights...\")\nmodel.load_state_dict(torch.load(\"model_stage1.pth\"))\n\n\n# ---------------------------------------------------------\n# STAGE 2: Fine-Tuning (解冻骨干，微调全网)\n# ---------------------------------------------------------\nprint(\"\\n=== Stage 2: Global Fine-Tuning ===\")\n\n# 1. 解冻所有层\nfor param in model.parameters():\n    param.requires_grad = True\n\n# Stage 2 优化器 (学习率极小，配合权重衰减)\noptimizer_s2 = optim.Adam(model.parameters(), lr=1e-5, weight_decay=1e-4)\n\n# 学习率调度器\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer_s2, mode='max', factor=0.1, patience=3, verbose=True)\n\nbest_val_acc = 0.0\npatience_counter = 0\nearly_stopping_limit = 7 # 容忍次数\n\nhistory = {'train_loss': [], 'val_loss': [], 'val_acc': []}\n\nfor epoch in range(20): # 最多 20 epochs\n    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer_s2)\n    val_loss, val_acc = validate(model, val_loader, criterion)\n    \n    # 记录历史\n    history['train_loss'].append(train_loss)\n    history['val_loss'].append(val_loss)\n    history['val_acc'].append(val_acc)\n    \n    # 更新 LR\n    scheduler.step(val_acc)\n    \n    print(f\"Epoch {epoch+1}/20 - Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}\")\n    \n    # 保存最佳模型 & 早停\n    if val_acc > best_val_acc:\n        best_val_acc = val_acc\n        torch.save(model.state_dict(), \"best_multibranch_model.pth\")\n        patience_counter = 0 # 重置计数器\n        print(\">>> New Best Model Saved!\")\n    else:\n        patience_counter += 1\n        print(f\">>> EarlyStopping counter: {patience_counter}/{early_stopping_limit}\")\n        \n    if patience_counter >= early_stopping_limit:\n        print(\"Early stopping triggered.\")\n        break","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport pandas as pd\nimport torch\nimport os\nimport cv2\nfrom PIL import Image\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as transforms\nfrom sklearn.model_selection import train_test_split\n\n# === 1. 配置路径 (确保这里和训练时一样) ===\nSAVE_DIR = \"/kaggle/input/aptos-2019-preprocessed-224/processed_images\" \nCSV_PATH = \"/kaggle/input/aptos2019-blindness-detection/train.csv\"\nBATCH_SIZE = 32\n\n# === 2. 重新定义 Dataset 类 ===\nclass RetinopathyDataset(Dataset):\n    def __init__(self, dataframe, img_dir, transform=None):\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        filename = row[\"filename\"]\n        label = row[\"diagnosis\"]\n        img_path = os.path.join(self.img_dir, filename)\n        \n        image = cv2.imread(img_path)\n        if image is None:\n            image = np.zeros((224, 224, 3), dtype=np.uint8)\n        \n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        img_pil = Image.fromarray(image)\n        \n        if self.transform:\n            img_tensor = self.transform(img_pil)\n        else:\n            img_tensor = transforms.ToTensor()(img_pil)\n\n        return img_tensor, torch.tensor(label, dtype=torch.long)\n\n# === 3. 重新定义数据增强 (Val部分) ===\ndata_transforms = {\n    'val': transforms.Compose([\n        transforms.ToTensor(),\n        transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])\n    ]),\n}\n\n# === 4. 重新划分数据 (必须和训练时随机种子一致！) ===\ndf = pd.read_csv(CSV_PATH)\ndf[\"filename\"] = df[\"id_code\"].apply(lambda x: x + \".png\")\n\n# 这里的 random_state=42 保证了你现在的 val_df 和训练时完全一样\ntrain_df, test_val_df = train_test_split(df, test_size=0.3, stratify=df[\"diagnosis\"], random_state=42)\nval_df, test_df = train_test_split(test_val_df, test_size=0.5, stratify=test_val_df[\"diagnosis\"], random_state=42)\n\nprint(f\"验证集恢复成功，数量: {len(val_df)}\")\n\n# === 5. 只生成 val_loader 即可 (不需要 train_loader) ===\nval_ds = RetinopathyDataset(val_df, SAVE_DIR, transform=data_transforms['val'])\nval_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\nprint(\"✅ val_loader 准备就绪！\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\nfrom torchvision import models\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# === 把你定义的模型类原封不动复制过来 ===\nclass MultiBranchModel(nn.Module):\n    def __init__(self, num_classes=5):\n        super(MultiBranchModel, self).__init__()\n        \n        # Branch 1: DenseNet201\n        self.densenet = models.densenet201(weights=None) # 推理时不需要下载 ImageNet 权重，反正会被覆盖\n        self.densenet_features = self.densenet.features\n        \n        # Branch 2: ResNet50\n        self.resnet = models.resnet50(weights=None)\n        self.resnet_features = nn.Sequential(*list(self.resnet.children())[:-2]) \n        \n        self.global_pool = nn.AdaptiveAvgPool2d((1, 1))\n        self.branch_dropout = nn.Dropout(0.5)\n        \n        self.fc = nn.Sequential(\n            nn.Linear(3968, 512),\n            nn.BatchNorm1d(512),\n            nn.ReLU(),\n            nn.Dropout(0.5),\n            nn.Linear(512, num_classes)\n        )\n        \n    def forward(self, x):\n        x1 = self.densenet_features(x)\n        x1 = nn.functional.relu(x1, inplace=True)\n        x1 = self.global_pool(x1)\n        x1 = torch.flatten(x1, 1)\n        \n        x2 = self.resnet_features(x)\n        x2 = self.global_pool(x2)\n        x2 = torch.flatten(x2, 1)\n        \n        x_cat = torch.cat((x1, x2), dim=1)\n        x_cat = self.branch_dropout(x_cat)\n        \n        out = self.fc(x_cat)\n        return out\n\n# === 初始化模型 ===\nmodel = MultiBranchModel(num_classes=5).to(device)\nprint(\"✅ 模型结构已重建（当前是随机初始化的）\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import confusion_matrix, classification_report, f1_score\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nfrom tqdm.notebook import tqdm\n\n# === 1. 加载权重 ===\nweight_path = \"best_multibranch_model.pth\"\n\nif os.path.exists(weight_path):\n    print(f\"正在加载权重: {weight_path} ...\")\n    # 加载权重到模型\n    model.load_state_dict(torch.load(weight_path, map_location=device))\n    model.eval() # 极其重要：切换到评估模式 (关闭 Dropout/BN)\n    print(\"✅ 权重加载成功！不需要重训！\")\nelse:\n    print(f\"❌ 找不到文件: {weight_path}\")\n    print(\"如果文件丢失，说明 Session 重置导致临时文件被清空，这种情况下只能重训了...\")\n    # 如果这里报错，停止往下运行\n\n# === 2. 只有权重加载成功才运行下面的预测 ===\nif os.path.exists(weight_path):\n    all_preds = []\n    all_labels = []\n\n    print(\"开始验证集推理...\")\n    with torch.no_grad():\n        for inputs, labels in tqdm(val_loader):\n            inputs = inputs.to(device)\n            labels = labels.to(device)\n            \n            outputs = model(inputs)\n            _, preds = torch.max(outputs, 1)\n            \n            all_preds.extend(preds.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n\n    y_val = np.array(all_labels)\n    y_pred = np.array(all_preds)\n\n    # === 3. 打印报告 ===\n    target_names = ['0: No DR', '1: Mild', '2: Mod', '3: Severe', '4: Prolif']\n    print(\"\\n=== Classification Report ===\")\n    print(classification_report(y_val, y_pred, target_names=target_names))\n\n    # === 4. 画混淆矩阵 ===\n    cm = confusion_matrix(y_val, y_pred)\n    cm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]\n\n    plt.figure(figsize=(10, 8))\n    sns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues',\n                xticklabels=target_names, yticklabels=target_names)\n    plt.xlabel('Predicted')\n    plt.ylabel('True')\n    plt.title('Confusion Matrix (Loaded Model)')\n    plt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 绘制 Loss 和 Accuracy 曲线\nplt.figure(figsize=(12, 5))\n\n# 1. Loss 曲线\nplt.subplot(1, 2, 1)\nplt.plot(history['train_loss'], label='Train Loss', marker='.')\nplt.plot(history['val_loss'], label='Val Loss', marker='.')\nplt.title('Training & Validation Loss')\nplt.xlabel('Epochs')\nplt.ylabel('Loss')\nplt.legend()\nplt.grid(True)\n\n# 2. Accuracy 曲线\nplt.subplot(1, 2, 2)\n# 注意：你的history里好像只记录了 val_acc，如果 train_acc 没存进去会报错\n# 假设你的 history 字典结构是 {'train_loss':[], 'val_loss':[], 'val_acc':[]}\nif 'train_acc' in history:\n    plt.plot(history['train_acc'], label='Train Acc', marker='.')\nplt.plot(history['val_acc'], label='Val Acc', marker='.')\nplt.title('Training & Validation Accuracy')\nplt.xlabel('Epochs')\nplt.ylabel('Accuracy')\nplt.legend()\nplt.grid(True)\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import confusion_matrix, classification_report, f1_score\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nimport numpy as np\n\n# ==========================================\n# 前置检查：确保你的 y_pred 是类别标签 (0,1,2...) 而不是概率\n# 如果你的 y_pred 是概率 (例如 shape是 (n, 5))，请取消下面这行的注释：\n# y_pred = np.argmax(y_pred, axis=1)\n# ==========================================\n\n# 1. 打印详细的分类报告 (包含 Precision, Recall, F1-Score)\nprint(\"=== Classification Report ===\")\n# target_names 设置为你的5个类别，方便查看\nreport = classification_report(y_val, y_pred, target_names=['0: No DR', '1: Mild', '2: Mod', '3: Severe', '4: Prolif'])\nprint(report)\n\n# 2. 计算并绘制混淆矩阵\ncm = confusion_matrix(y_val, y_pred)\n\nplt.figure(figsize=(10, 8))\n\n# 归一化混淆矩阵 (按真实类别归一化 -> 查看召回率 Recall)\n# 每一行的和为1，表示该真实类别被预测成各类的比例\ncm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]\n\n# 绘制热力图\nsns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues',\n            xticklabels=['0', '1', '2', '3', '4'],\n            yticklabels=['0', '1', '2', '3', '4'])\n\nplt.xlabel('Predicted Label (模型预测)')\nplt.ylabel('True Label (真实情况)')\nplt.title('Normalized Confusion Matrix (Recall View)')\nplt.show()\n\n# 3. 单独打印 Macro F1 Score\nmacro_f1 = f1_score(y_val, y_pred, average='macro')\nprint(f\"=== Macro F1-Score: {macro_f1:.4f} ===\")\nprint(\"(这是衡量不平衡数据集中模型综合性能的关键指标)\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.metrics import confusion_matrix, classification_report, f1_score\nimport seaborn as sns\nimport matplotlib.pyplot as plt\nimport numpy as np\n\n# ==========================================\n# 前置检查：确保你的 y_pred 是类别标签 (0,1,2...) 而不是概率\n# 如果你的 y_pred 是概率 (例如 shape是 (n, 5))，请取消下面这行的注释：\n# y_pred = np.argmax(y_pred, axis=1)\n# ==========================================\n\n# 1. 打印详细的分类报告 (包含 Precision, Recall, F1-Score)\nprint(\"=== Classification Report ===\")\n# target_names 设置为你的5个类别，方便查看\nreport = classification_report(y_val, y_pred, target_names=['0: No DR', '1: Mild', '2: Mod', '3: Severe', '4: Prolif'])\nprint(report)\n\n# 2. 计算并绘制混淆矩阵\ncm = confusion_matrix(y_val, y_pred)\n\nplt.figure(figsize=(10, 8))\n\n# 归一化混淆矩阵 (按真实类别归一化 -> 查看召回率 Recall)\n# 每一行的和为1，表示该真实类别被预测成各类的比例\ncm_normalized = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]\n\n# 绘制热力图\nsns.heatmap(cm_normalized, annot=True, fmt='.2f', cmap='Blues',\n            xticklabels=['0', '1', '2', '3', '4'],\n            yticklabels=['0', '1', '2', '3', '4'])\n\nplt.xlabel('Predicted Label (模型预测)')\nplt.ylabel('True Label (真实情况)')\nplt.title('Normalized Confusion Matrix (Recall View)')\nplt.show()\n\n# 3. 单独打印 Macro F1 Score\nmacro_f1 = f1_score(y_val, y_pred, average='macro')\nprint(f\"=== Macro F1-Score: {macro_f1:.4f} ===\")\nprint(\"(这是衡量不平衡数据集中模型综合性能的关键指标)\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 加载最佳模型\nmodel.load_state_dict(torch.load(\"best_multibranch_model.pth\"))\nmodel.eval()\n\ny_true = []\ny_pred = []\ny_probs = []\n\nwith torch.no_grad():\n    for images, labels in tqdm(test_loader, desc=\"Testing\"):\n        images, labels = images.to(device), labels.to(device)\n        outputs = model(images)\n        probs = torch.softmax(outputs, dim=1)\n        \n        _, predicted = torch.max(outputs, 1)\n        \n        y_true.extend(labels.cpu().numpy())\n        y_pred.extend(predicted.cpu().numpy())\n        y_probs.extend(probs.cpu().numpy())\n\n# 1. 准确率\nacc = accuracy_score(y_true, y_pred)\nprint(f\"\\nFinal Test Accuracy: {acc*100:.2f}%\")\n\n# 2. 混淆矩阵\ncm = confusion_matrix(y_true, y_pred)\nplt.figure(figsize=(8, 6))\nsns.heatmap(cm, annot=True, fmt='d', cmap='Blues')\nplt.title(\"Confusion Matrix (Multi-Branch)\")\nplt.xlabel(\"Predicted\")\nplt.ylabel(\"True\")\nplt.show()\n\n# 3. 分类报告\nprint(\"\\nClassification Report:\")\nprint(classification_report(y_true, y_pred))\n\n# 4. 训练曲线\nplt.figure(figsize=(12, 5))\nplt.subplot(1, 2, 1)\nplt.plot(history['train_loss'], label='Train Loss')\nplt.plot(history['val_loss'], label='Val Loss')\nplt.title(\"Loss Curve\")\nplt.legend()\n\nplt.subplot(1, 2, 2)\nplt.plot(history['val_acc'], label='Val Accuracy', color='green')\nplt.title(\"Validation Accuracy\")\nplt.legend()\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def get_saliency_map(model, image_tensor):\n    model.eval()\n    image_tensor = image_tensor.to(device)\n    image_tensor.requires_grad_() # 开启梯度追踪\n    \n    output = model(image_tensor.unsqueeze(0)) # Add batch dim\n    output_idx = output.argmax()\n    output_max = output[0, output_idx]\n    \n    # 反向传播\n    model.zero_grad()\n    output_max.backward()\n    \n    # 获取输入图像的梯度 (取绝对值 max)\n    saliency, _ = torch.max(image_tensor.grad.data.abs(), dim=0)\n    return saliency.cpu().numpy()\n\n# 从测试集中取一张图片\nidx = 10 \nimg_tensor, label = test_ds[idx]\n\n# 计算 Saliency\nsaliency_map = get_saliency_map(model, img_tensor)\n\n# 绘图\nplt.figure(figsize=(10, 5))\nplt.subplot(1, 2, 1)\n# 反归一化以便显示\ninv_normalize = transforms.Normalize(\n   mean=[-0.485/0.229, -0.456/0.224, -0.406/0.225],\n   std=[1/0.229, 1/0.224, 1/0.225]\n)\nimg_display = inv_normalize(img_tensor).permute(1, 2, 0).numpy()\nimg_display = np.clip(img_display, 0, 1) # 修正数值范围\n\nplt.imshow(img_display)\nplt.title(f\"Original (Label: {label.item()})\")\nplt.axis('off')\n\nplt.subplot(1, 2, 2)\nplt.imshow(saliency_map, cmap='hot')\nplt.title(\"Saliency Map\")\nplt.axis('off')\n\nplt.show()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nfrom collections import Counter\n\n# 假设你的训练集变量名是 train_dataset\n# 如果你的变量名不一样（比如 train_data），请修改下面这一行\ndataset_to_count = train_ds\n\nprint(\"正在统计类别数量，请稍候...\")\n\ntry:\n    # === 方法 A: 极速版 (针对 ImageFolder 或常用数据集) ===\n    # ImageFolder 通常会把标签存在 .targets 里\n    if hasattr(dataset_to_count, 'targets'):\n        labels = dataset_to_count.targets\n    # 有些自定义 Dataset 可能会叫 .labels\n    elif hasattr(dataset_to_count, 'labels'):\n        labels = dataset_to_count.labels\n    else:\n        raise AttributeError(\"没找到 targets 属性\")\n    \n    # 确保 labels 是列表，不是 Tensor\n    if isinstance(labels, torch.Tensor):\n        labels = labels.tolist()\n        \n    print(\"成功通过属性直接读取标签！\")\n\nexcept AttributeError:\n    # === 方法 B: 暴力版 (万能，但如果数据集很大可能会慢) ===\n    print(\"未找到直接标签属性，正在遍历数据集统计 (可能需要一点时间)...\")\n    labels = []\n    for i in range(len(dataset_to_count)):\n        # 只取 label，不加载图片数据以加快速度\n        # 注意：这里假设你的 dataset[i] 返回的是 (image, label)\n        _, label = dataset_to_count[i]\n        \n        # 如果 label 是 Tensor，转成数字\n        if isinstance(label, torch.Tensor):\n            label = label.item()\n        labels.append(label)\n\n# === 统计并打印 ===\ncounts = Counter(labels)\nsorted_counts = dict(sorted(counts.items())) # 按类别 ID 排序\n\nprint(\"\\n=== 训练集类别分布 ===\")\ntotal_count = sum(counts.values())\nfor class_idx, count in sorted_counts.items():\n    percent = (count / total_count) * 100\n    print(f\"Class {class_idx}: {count} 张 ({percent:.2f}%)\")\n\nprint(f\"\\n总样本数: {total_count}\")\n\n# 简单的判断\nmax_class = max(counts.values())\nmin_class = min(counts.values())\nif max_class / min_class > 2:\n    print(f\"\\n⚠️ 警告: 类别极其不平衡！最多类是最小类的 {max_class/min_class:.1f} 倍。\")\n    print(\"强烈建议使用 '加权 Loss' 或 'WeightedRandomSampler'。\")\nelse:\n    print(\"\\n✅ 类别相对平衡，可能不是不平衡导致的问题。\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nimport os\nfrom sklearn.model_selection import train_test_split\nimport torch\n\n# === 1. 重新读取 CSV (找回 df) ===\nCSV_PATH = \"/kaggle/input/aptos2019-blindness-detection/train.csv\"\n# 定义保存路径 (后续 Dataset 需要用到)\nSAVE_DIR = \"/kaggle/working/processed_images\" \n\nif not os.path.exists(CSV_PATH):\n    print(\"错误：找不到 CSV 文件，请检查路径！\")\nelse:\n    df = pd.read_csv(CSV_PATH)\n    # 别忘了这一步！加上后缀\n    df[\"filename\"] = df[\"id_code\"].apply(lambda x: x + \".png\")\n    print(f\"成功读取 Dataframe: {len(df)} 行\")\n\n# === 2. 划分训练集和验证集 ===\n# stratify=df['diagnosis'] 保证类别比例一致\ntrain_df, val_df = train_test_split(\n    df, \n    test_size=0.2, \n    random_state=42, \n    stratify=df['diagnosis']\n)\n\nprint(f\"训练集数量: {len(train_df)}\")\nprint(f\"验证集数量: {len(val_df)}\")\n\n# === 3. 统计类别并计算权重 ===\nclass_counts = train_df['diagnosis'].value_counts().sort_index()\nprint(\"\\n=== 训练集类别分布 ===\")\nprint(class_counts)\n\n# 计算权重\ntotal_samples = len(train_df)\nnum_classes = len(class_counts)\nclass_weights = [total_samples / (num_classes * count) for count in class_counts]\n\n# 转成 Tensor\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nclass_weights_tensor = torch.FloatTensor(class_weights).to(device)\n\nprint(f\"\\n计算出的类别权重 (Class Weights): \\n{class_weights_tensor.cpu().numpy()}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torchvision.models as models\nimport torch.nn as nn\nimport torch\nimport torch.optim as optim\n\n# === 1. 定义模型 (如果你还没定义的话) ===\nprint(\"正在加载 ResNet50 预训练模型...\")\n# 使用 ResNet50，它的性能比 ResNet18 强很多，适合医学图像\n# weights='DEFAULT' 表示加载最新的 ImageNet 预训练权重\nmodel = models.resnet50(weights='DEFAULT')\n\n# === 2. 修改全连接层 (适配 5 分类) ===\n# 获取 ResNet 最后全连接层的输入特征数\nnum_ftrs = model.fc.in_features\n# 替换为新的全连接层，输出类别数为 5 (APTOS 数据集)\nmodel.fc = nn.Linear(num_ftrs, 5)\n\n# === 3. 搬运到 GPU ===\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nmodel = model.to(device)\nprint(f\"模型已加载并移动到: {device}\")\n\n# ==========================================\n#       下面是 Stage 1 训练代码\n# ==========================================\n\nprint(\"\\n=== Stage 1: Training Head Only (with Class Weights) ===\")\n\n# 4. 冻结所有层 (只训练最后一层)\nfor param in model.parameters():\n    param.requires_grad = False\n\n# 5. 解冻分类头 (fc 层)\nfor param in model.fc.parameters():\n    param.requires_grad = True\n\n# 6. 定义带权重的 Loss\n# 确保 class_weights_tensor 存在 (如果报错，说明你需要重新运行计算权重的代码)\nif 'class_weights_tensor' not in locals():\n    print(\"错误: class_weights_tensor 未定义！请先运行上面计算权重的代码。\")\nelse:\n    class_weights_tensor = class_weights_tensor.to(device)\n    criterion = nn.CrossEntropyLoss(weight=class_weights_tensor)\n    \n    # 7. 优化器 (只优化 fc)\n    optimizer_s1 = optim.Adam(model.fc.parameters(), lr=1e-3)\n\n    # 8. 训练循环 (5 Epochs)\n    best_val_loss = float('inf')\n\n    for epoch in range(5): \n        model.train()\n        running_loss = 0.0\n        running_corrects = 0\n        \n        for inputs, labels in train_loader:\n            inputs, labels = inputs.to(device), labels.to(device)\n            \n            optimizer_s1.zero_grad()\n            outputs = model(inputs)\n            loss = criterion(outputs, labels) # 加权 Loss\n            loss.backward()\n            optimizer_s1.step()\n            \n            _, preds = torch.max(outputs, 1)\n            running_loss += loss.item() * inputs.size(0)\n            running_corrects += torch.sum(preds == labels.data)\n\n        epoch_loss = running_loss / len(train_ds)\n        epoch_acc = running_corrects.double() / len(train_ds)\n        \n        # 验证逻辑\n        model.eval()\n        val_running_loss = 0.0\n        val_corrects = 0\n        with torch.no_grad():\n            for inputs, labels in val_loader:\n                inputs, labels = inputs.to(device), labels.to(device)\n                outputs = model(inputs)\n                loss = criterion(outputs, labels)\n                \n                _, preds = torch.max(outputs, 1)\n                val_running_loss += loss.item() * inputs.size(0)\n                val_corrects += torch.sum(preds == labels.data)\n        \n        val_loss = val_running_loss / len(val_ds)\n        val_acc = val_corrects.double() / len(val_ds)\n\n        print(f\"Epoch {epoch+1}/5 - Train Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f} | Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}\")\n\n        if val_loss < best_val_loss:\n            best_val_loss = val_loss\n            torch.save(model.state_dict(), \"model_stage1_weighted.pth\")\n\n    print(\"Stage 1 完成！准备进入 Stage 2。\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.optim as optim\nimport torch.nn as nn\nfrom torch.utils.data import DataLoader\n\n\n\n# 1. 重新生成 Dataset 和 DataLoader (确保使用刚才切分好的带权重的 train_df)\n# 假设 SAVE_DIR 是你刚才预处理图片的路径 \"/kaggle/working/processed_images\"\ntrain_ds = RetinopathyDataset(train_df, SAVE_DIR, transform=data_transforms['train'])\nval_ds = RetinopathyDataset(val_df, SAVE_DIR, transform=data_transforms['val'])\n\ntrain_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2)\nval_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2)\n\n# 2. 定义带权重的 Loss (关键步骤！)\n# 确保权重在 GPU 上\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nclass_weights_tensor = class_weights_tensor.to(device) \ncriterion = nn.CrossEntropyLoss(weight=class_weights_tensor)\n\n# 3. 准备 Stage 2 微调 (解冻所有层 + 小学习率)\n# 如果你需要重新加载之前的最佳权重，请取消下面这行的注释\n# model.load_state_dict(torch.load(\"model_stage1.pth\")) \n\nfor param in model.parameters():\n    param.requires_grad = True\n\n# 使用较小的学习率进行微调\noptimizer = optim.Adam(model.parameters(), lr=1e-4)\nscheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.1, patience=3, verbose=True)\n\n# 4. 开始训练\nprint(f\"=== Starting Stage 2 Training with Weighted Loss (Weights: {class_weights_tensor.cpu().numpy().round(2)}) ===\")\nbest_val_loss = float('inf')\n\nfor epoch in range(15): # 建议跑 10-15 个 Epoch\n    model.train()\n    running_loss = 0.0\n    running_corrects = 0\n    \n    for inputs, labels in train_loader:\n        inputs, labels = inputs.to(device), labels.to(device)\n        \n        optimizer.zero_grad()\n        outputs = model(inputs)\n        loss = criterion(outputs, labels) # 这里会自动应用 class_weights\n        loss.backward()\n        optimizer.step()\n        \n        _, preds = torch.max(outputs, 1)\n        running_loss += loss.item() * inputs.size(0)\n        running_corrects += torch.sum(preds == labels.data)\n\n    epoch_loss = running_loss / len(train_ds)\n    epoch_acc = running_corrects.double() / len(train_ds)\n    \n    # 验证阶段\n    model.eval()\n    val_running_loss = 0.0\n    val_corrects = 0\n    \n    with torch.no_grad():\n        for inputs, labels in val_loader:\n            inputs, labels = inputs.to(device), labels.to(device)\n            outputs = model(inputs)\n            loss = criterion(outputs, labels)\n            \n            _, preds = torch.max(outputs, 1)\n            val_running_loss += loss.item() * inputs.size(0)\n            val_corrects += torch.sum(preds == labels.data)\n            \n    val_loss = val_running_loss / len(val_ds)\n    val_acc = val_corrects.double() / len(val_ds)\n    \n    print(f\"Epoch {epoch+1}/15 - Train Loss: {epoch_loss:.4f} Acc: {epoch_acc:.4f} | Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}\")\n    \n    # 保存最佳模型 (根据 Val Loss 而不是 Acc，因为数据不平衡时 Loss 更可靠)\n    if val_loss < best_val_loss:\n        best_val_loss = val_loss\n        torch.save(model.state_dict(), \"best_model_weighted.pth\")\n        print(\">>> New Best Model Saved (Improved Val Loss)!\")\n        \n    scheduler.step(val_loss)","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}