[Python] ML Recognition of retinopathy from fundus images

Discussão em 'Python' iniciado por Stack, Junho 14, 2025.

  1. Stack

    Stack Membro Participativo

    I am a novice ml programmer. I recently made a code project that identifies a disease based on an image of the fundus. The neural network was trained on 20 thousand photos. Right now I can't further improve the metrics of the model (especially precision), please help experienced people

    import torch
    import os
    import torch.nn as nn
    import torch.optim as optim
    from torch.utils.data import Dataset, DataLoader
    from torchvision import transforms, models
    from PIL import Image
    import pandas as pd
    from sklearn.model_selection import train_test_split
    from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score, roc_curve
    import matplotlib.pyplot as plt
    from sklearn.metrics import recall_score
    import numpy as np

    # Параметры
    DATA_DIR = '/Users/sergej/Downloads/archive/images_stack_without_captions/images_stack_without_captions'
    BATCH_SIZE = 32
    IMG_SIZE = 224
    NUM_EPOCHS = 30
    LR = 0.001
    NUM_CLASSES = 2



    #! Преобразует данные в начальный Dataframe с метаданными
    def create_df(data_dir):
    image_files = [f for f in os.listdir(data_dir) if f.endswith('.jpg')]
    data = []

    for img_file in image_files:
    parts = img_file.split('_')
    patient_id = parts[0]
    sex = parts[1]
    ga = int(parts[2][2:])
    bw = int(parts[3][2:])
    pa = int(parts[4][2:])
    dg = int(parts[5][2:])
    pf = int(parts[6][2:])
    device = parts[7][1:]
    series = parts[8][1:]
    img_num = parts[9].split('.')[0]
    # Бинарная метка: 0 - здоровый, 1 - больной
    label = 0 if dg == 0 else 1
    data.append({
    'filename': img_file,
    'patient_id': patient_id,
    'sex': sex,
    'GESTATIONAL_AGE': ga,
    'BIRTH_WEIGHT': bw,
    'POSTCONCEPTUAL_AGE': pa,
    'DIAGNOSIS_CODE': dg,
    'PLUS_FORM': pf,
    'DEVICE': device,
    'SERIES_NUMBER': series,
    'img_num': img_num,
    'label': label
    })

    return pd.DataFrame(data)


    # Создаем DataFrame с метаданными
    metadata_df = create_df(DATA_DIR)

    # Разделение на train/val/test с учетом patient_id (чтобы изображения одного пациента не попали в разные наборы)
    patient_ids = metadata_df['patient_id'].unique()
    train_ids, test_ids = train_test_split(
    patient_ids, test_size=0.2, random_state=42)
    train_ids, val_ids = train_test_split(
    train_ids, test_size=0.25, random_state=42) # 60/20/20 split

    train_df = metadata_df[metadata_df['patient_id'].isin(train_ids)]
    val_df = metadata_df[metadata_df['patient_id'].isin(val_ids)]
    test_df = metadata_df[metadata_df['patient_id'].isin(test_ids)]

    #! Создание трансформаций изоьражений и их нормализация

    train_transform = transforms.Compose([
    transforms.Resize(280), # Изменение размера до 280x280

    # Случайный поворот (-15°,+15°), сдвиг до 10%, масштаб (90%-110%)
    transforms.RandomAffine(degrees=15, translate=(0.1, 0.1), scale=(0.9, 1.1)),

    # Вырезает случайную область (70%-100% изображения) и масштабирует до 224x224
    transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
    transforms.RandomHorizontalFlip(), # Зеркальное отражение по горизонтали (50% шанс)
    transforms.RandomVerticalFlip(), # Зеркальное отражение по вертикали (50% шанс)

    # Случайные изменения: яркость, контраст, насыщенность (±20%), оттенок (±0.02)
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.02),

    # Размытие Гаусса (ядро 3x3, сила размытия 0.1-2.0)
    transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 2.0)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    # Случайное затирание областей (50% шанс, размер 2%-15%, заполнение случайным цветом)
    transforms.RandomErasing(p=0.5, scale=(0.02, 0.15), value='random')
    ])



    val_test_transform = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)), # Фиксированный размер 224x224
    transforms.ToTensor(), # Конвертация в тензор
    transforms.Normalize( # Нормализация (как для train)
    mean=[0.485, 0.456, 0.406],
    std=[0.229, 0.224, 0.225]
    )
    ])



    #! Функция останавливает обучение, когда модель начинает "запоминать" тренировочные данные вместо обучения общим закономерностям
    #! Избегает бесполезных вычислений после достижения оптимального результата.
    class EarlyStopping:
    def __init__(self, patience=7, delta=0.001):
    self.patience = patience
    self.delta = delta
    self.counter = 0
    self.best_loss = float('inf')

    def __call__(self, val_loss):
    if val_loss < self.best_loss - self.delta:
    self.best_loss = val_loss
    self.counter = 0
    else:
    self.counter += 1
    if self.counter >= self.patience:
    return True
    return False



    #! Обрабатывает изображени
    class RetinalDataset(Dataset):
    def __init__(self, df, data_dir, transform=None):
    self.df = df
    self.data_dir = data_dir
    self.transform = transform
    # Нормализуем числовые признаки
    self.numeric_cols = ['GESTATIONAL_AGE', 'BIRTH_WEIGHT', 'POSTCONCEPTUAL_AGE']
    self.means = self.df[self.numeric_cols].mean()
    self.stds = self.df[self.numeric_cols].std()

    def __len__(self):
    return len(self.df)

    def __getitem__(self, idx):
    img_path = os.path.join(self.data_dir, self.df.iloc[idx]['filename'])
    image = Image.open(img_path).convert('RGB')
    label = self.df.iloc[idx]['label']

    # Получаем дополнительные признаки
    numeric_features = self.df.iloc[idx][self.numeric_cols].values.astype(np.float32)
    # Cтандартизируем, ну практически тоже самое что и Нормализация
    numeric_features = (numeric_features - self.means.values) / self.stds.values

    # Сделали бинарным пол
    #? Может сделать по one-hot-encoding?
    sex = 1 if self.df.iloc[idx]['sex'] == 'M' else 0

    if self.transform:
    image = self.transform(image)

    # Возвращаем изображение, числовые признаки, категориальный признак и метку
    return image, torch.FloatTensor(numeric_features), torch.tensor(sex, dtype=torch.float32), label


    #! Модель
    class RetinalModel(nn.Module):
    def __init__(self, num_classes=NUM_CLASSES):
    super(RetinalModel, self).__init__()
    # Замораживаем часть слоев ResNet
    self.cnn = models.resnet50(pretrained=True)
    for param in list(self.cnn.parameters())[:-50]: # Замораживаем все кроме последних параметров
    param.requires_grad = False
    self.cnn.fc = nn.Identity()

    # Упрощенный обработчик признаков с L2-регуляризацией
    self.feature_processor = nn.Sequential(
    nn.Linear(4, 32),
    nn.Dropout(0.4),
    nn.Linear(32, 64))

    # Более регуляризованный классификатор
    self.classifier = nn.Sequential(
    nn.Linear(2048 + 64, 256),
    nn.Dropout(0.6),
    nn.Linear(256, num_classes))

    def forward(self, img, numeric_features, sex):
    img_features = self.cnn(img)
    additional_features = torch.cat([numeric_features, sex.unsqueeze(1)], dim=1)
    processed_features = self.feature_processor(additional_features)
    combined = torch.cat([img_features, processed_features], dim=1)
    return self.classifier(combined)


    #! Функция для тренировки модели
    def train_model(model, train_loader, val_loader, criterion, optimizer, scheduler, early_stopping, num_epochs=NUM_EPOCHS):
    train_losses = []
    val_losses = []
    train_recalls = []
    val_recalls = []

    for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0
    all_train_labels = []
    all_train_preds = []

    for images, numeric, sex, labels in train_loader:
    images = images.to(device)
    numeric = numeric.to(device)
    sex = sex.to(device)
    labels = labels.to(device)

    optimizer.zero_grad()
    outputs = model(images, numeric, sex)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

    running_loss += loss.item()
    _, preds = torch.max(outputs, 1)
    all_train_labels.extend(labels.cpu().numpy())
    all_train_preds.extend(preds.cpu().numpy())

    train_loss = running_loss / len(train_loader)
    train_recall = recall_score(all_train_labels, all_train_preds)

    # Валидация
    model.eval()
    val_loss = 0.0
    all_val_labels = []
    all_val_preds = []

    with torch.no_grad():
    for images, numeric, sex, labels in val_loader:
    images = images.to(device)
    numeric = numeric.to(device)
    sex = sex.to(device)
    labels = labels.to(device)

    outputs = model(images, numeric, sex)
    loss = criterion(outputs, labels)
    val_loss += loss.item()
    _, preds = torch.max(outputs, 1)
    all_val_labels.extend(labels.cpu().numpy())
    all_val_preds.extend(preds.cpu().numpy())

    val_loss /= len(val_loader)
    val_recall = recall_score(all_val_labels, all_val_preds)

    scheduler.step(val_loss)
    if early_stopping(val_loss):
    print(f'Остановлен после {epoch} эпохи')
    break

    train_losses.append(train_loss)
    val_losses.append(val_loss)
    train_recalls.append(train_recall)
    val_recalls.append(val_recall)

    print(f'Эпоха {epoch+1}/{num_epochs}')
    print(f'Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f}')
    print(f'Train Recall: {train_recall:.4f} | Val Recall: {val_recall:.4f}')
    print('-' * 60)

    # Визуализация
    plt.figure(figsize=(15, 5))

    # Функция потерь
    plt.subplot(1, 2, 1)
    plt.plot(train_losses, label='Train Loss')
    plt.plot(val_losses, label='Val Loss')
    plt.legend()
    plt.title('Функция потерь')

    # Recall
    plt.subplot(1, 2, 2)
    plt.plot(train_recalls, label='Train Recall')
    plt.plot(val_recalls, label='Val Recall')
    plt.legend()
    plt.title('Кривая Recall')
    plt.show()

    return model


    #! Функция для оценки модели
    def evaluate_model(model, test_loader, threshold=0.3): # Добавляем параметр threshold
    model.eval()
    all_labels = []
    all_preds = []
    all_probs = []

    with torch.no_grad():
    for images, numeric, sex, labels in test_loader:
    images = images.to(device)
    numeric = numeric.to(device)
    sex = sex.to(device)
    labels = labels.to(device)

    outputs = model(images, numeric, sex)
    probs = torch.softmax(outputs, dim=1)

    # Изменяем способ получения предсказаний с учетом порога
    preds = (probs[:, 1] >= threshold).long() # Используем заданный порог

    all_labels.extend(labels.cpu().numpy())
    all_preds.extend(preds.cpu().numpy())
    all_probs.extend(probs[:, 1].cpu().numpy())

    accuracy = accuracy_score(all_labels, all_preds)
    precision = precision_score(all_labels, all_preds)
    recall = recall_score(all_labels, all_preds)
    f1 = f1_score(all_labels, all_preds)
    auc = roc_auc_score(all_labels, all_probs)

    return accuracy, precision, recall, f1, auc


    if __name__ == '__main__':

    device = torch.device('mps' if torch.backends.mps.is_available() else 'cpu')

    metadata_df = create_df(DATA_DIR)

    train_dataset = RetinalDataset(train_df, DATA_DIR, train_transform)
    val_dataset = RetinalDataset(val_df, DATA_DIR, val_test_transform)
    test_dataset = RetinalDataset(test_df, DATA_DIR, val_test_transform)

    train_loader = DataLoader(
    train_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=4, pin_memory=True)
    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE,
    shuffle=False, num_workers=4, pin_memory=True)
    test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE,
    shuffle=False, num_workers=4, pin_memory=True)

    # Инициализация модели
    model = RetinalModel().to(device)

    # Функция потерь с более сбалансированными весами
    class_weights = torch.tensor([1.0, 3.0]).to(device) # Было [1.0, 2.0]
    criterion = nn.CrossEntropyLoss(weight=class_weights)

    # Оптимизатор с измененными параметрами
    optimizer = optim.AdamW([
    {'params': [p for p in model.cnn.parameters() if p.requires_grad], 'lr': 1e-5},
    {'params': model.feature_processor.parameters(), 'lr': 1e-4},
    {'params': model.classifier.parameters(), 'lr': 1e-4}
    ], weight_decay=1e-4) # Увеличена регуляризация

    # Измененный scheduler
    # scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    # optimizer,
    # T_max=10, # Период сброса lr в эпохах
    # eta_min=1e-6 # Минимальный lr
    # )
    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode='min', # Следить за уменьшением val_loss
    factor=0.5, # Умножать LR на 0.5 при срабатывании
    patience=3, # Ждать 3 эпохи без улучшения
    verbose=True # Выводить сообщения
    )


    early_stopping = EarlyStopping(patience=5, delta=0.001)


    # Вывод модели и распределения
    print(model)
    # Проверим баланс классов
    print("Распределение в обучающей выборке")
    print(train_df['label'].value_counts())
    print("\nРаспределение в валидационной выборке")
    print(val_df['label'].value_counts())
    print("\nРаспределение в тестовой выборке")
    print(test_df['label'].value_counts())

    # Обучение
    model = train_model(model, train_loader, val_loader,
    criterion, optimizer, scheduler, early_stopping)

    # Оценка
    # Оценка с порогом 0.3
    print("\nФинальная оценка по тестовой выборке (порог 30%)")
    test_accuracy, test_precision, test_recall, test_f1, test_auc = evaluate_model(
    model, test_loader, threshold=0.3) # Указываем порог 0.3

    # Детальный отчет
    print("\nClassification Report (порог 30%):")
    print(f"Accuracy: {test_accuracy:.4f}")
    print(f"Precision: {test_precision:.4f}")
    print(f"Recall: {test_recall:.4f}")
    print(f"F1-score: {test_f1:.4f}")
    print(f"AUC-ROC: {test_auc:.4f}")

    # Оценка с порогом 0.5 для сравнения
    print("\nДля сравнения - оценка с порогом 50%:")
    test_accuracy_50, test_precision_50, test_recall_50, test_f1_50, _ = evaluate_model(
    model, test_loader, threshold=0.5)

    print("\nClassification Report (порог 50%):")
    print(f"Accuracy: {test_accuracy_50:.4f}")
    print(f"Precision: {test_precision_50:.4f}")
    print(f"Recall: {test_recall_50:.4f}")
    print(f"F1-score: {test_f1_50:.4f}")
    model.eval()
    all_labels = []
    all_probs = []

    with torch.no_grad():
    for images, numeric, sex, labels in test_loader:
    images = images.to(device)
    numeric = numeric.to(device)
    sex = sex.to(device)
    outputs = model(images, numeric, sex) # Передаем все необходимые аргументы
    probs = torch.softmax(outputs, dim=1)
    all_labels.extend(labels.cpu().numpy())
    all_probs.extend(probs[:, 1].cpu().numpy())

    fpr, tpr, thresholds = roc_curve(all_labels, all_probs)
    plt.figure()
    plt.plot(fpr, tpr, color='orange', lw=2, label=f'ROC curve (area = {test_auc:.2f})')
    plt.plot([0, 1], [0, 1], color='blue', lw=2, linestyle='--')
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate')
    plt.ylabel('True Positive Rate')
    plt.legend(loc="lower right")
    plt.show()


    I tried changing the weights of classes to increase recall, but other metrics suffer because of this. I don't understand how to achieve balance at high results

    Continue reading...

Compartilhe esta Página