Lista 5 — Classificação de imagens

Redes Neurais Profundas | Prof. Dr. Guilherme Souza Rodrigues

Author
Affiliation

Universidade de Brasília (UnB)

Published

December 6, 2025

Metodologia

Infraestrutura

Primeiramente, escolhi utilizar Python para esta tarefa, com a biblioteca principal Pytorch e seus utilitários, além de outras bibliotecas auxiliares para tarefas intermediárias, como o optuna para estudo e seleção de hiperparâmetros.

Esta é uma tarefa relativamente simples, que pode ser executada em computador pessoal, ainda que sem GPU. Entretanto, pensando em aprimorar a velocidade de treinamento dos modelos, utilizei a infraestrutura gratuita do google colab para execução dos códigos na GPU gratuita fornecida. No código, permiti que selecionasse GPU ou CPU, podendo portanto executar em qualquer máquina disponível, com prioridade para GPU, se existente. Também, trouxe a possibilidade de um modo de fast debug, principalmente pensando na execução em CPU, tal que o código pudesse ser ajustado e aprimorado com poucas iterações nas etapas intermediárias, e ao final desligar este modo para execução completa da atividade.

Pré processamento e aumentamento de dados

Aumentamento de dados

Por se tratar de um problema de classificação de imagens, podemos adotar diversas estratégias para melhorar o nosso dado de entrada, e utilizar destas melhorias também para fazer o aumentamento dos dados. Por se tratarem de poucos dados, e ainda por cima desbalanceados — existem muito mais imagens referentes a impressões digitais masculinas que imagens referentes a impressões digitais femininas —, executei as seguintes tarefas de aumentamento dos dados:

  • Replicar as imagens aleatoriamente com alterações de contraste, saturação e brilho;

  • Aplicar desfoque gaussiano aleatório para gerar réplicas de cada imagem com leves desfoques;

  • Realizar rotações nas imagens aleatoriamente;

  • Reduzir as imagens (“cropar”);

  • Realizar transformações afins nas imagens, mantendo o centro delas invariante.

Com estas estratégias, podemos inserir para nosso modelo uma quantidade aumentada de dados, visto o cenário de poucos exemplos de treinamento, que em geral levam a baixo ajuste da rede.

Pré processamento

Optei por utilizar redes residuais pré treinadas disponíveis no Pytorch, ao invés de declarar minha própria rede. Para isso, observei que estas redes foram treinadas com imagens no tamanho 224x224, enquanto as imagens disponíveis nesta atividade tinham tamanho 96x103. Por isso, resolvi alterar o tamanho das imagens para 224x224, visto que os modelos foram treinados com este tamanho, e a utilização de outro tamanho poderia comprometer o funcionamento dos filtros aprendidos pela rede, por exemplo.

Além disso, fiz a normalização dos tensores também considerando os pesos de normalização padrão das redes da família ResNet do Pytorch, com objetivo análogo ao citado anteriormente.

Também declarei as imagens como se tivessem camadas de cor. Apesar de serem imagens em escala de cinza, estas redes do Pytorch foram treinadas com imagens de três canais de cor (RGB). Para tentar garantir um melhor funcionamento destas redes, repliquei a imagem nestes três canais, como se RGB fossem.

Divisão treino/validação e amostragem

Dividi os dados do conjunto de treino em 80% treino e 20% validação, para estudo de hiperparâmetros. Esta divisão foi estratificada, considerando o desbalanceamento das classes.

Além disso, apliquei pesos para os registros de treinamento, tal que as imagens de impressões digitais femininas tivessem um peso maior que as impressões digitais masculinas, com objetivo de resolver ou ao menos minorar a questão do desbalancemanto de classes no conjunto de dados.

Modelos e hiperparâmetros

Como utilizei de redes pré treinadas, as considerei como hiperparâmetros. Isto é, qual rede usar é um dos hiperparâmetros do estudo. As redes que considerei como possibilidade foram: “resnet18”; “resnet50”; “mobilenet_v2”; “efficientnet_b0”; “densenet121”. Pela documentação do Pytorch, todas estas redes foram treinadas com imagens 224x224, servem para classificação, e possuem pesos similares, o que favorece as escolhas anteriores de pré processamento.

É possível alterar diversos aspectos destas redes no estudo de hiperparâmetros, como a taxa de aprendizado, camadas da rede, otimizador (Adam, SGD, …), etc. Entretanto, fiz alguns testes considerando estudos maiores de hiperparâmetros, e pelas limitações de infraestrutura (quanto mais parâmetros decidirmos ajustar, ou teremos menos combinações a fazer considerando a mesma quantidade de iterações optuna, ou teremos de aumentar exponencialmente a quantidade de iterações, aumentando assim conjuntamente o tempo de seleção e uso de computação), notei que não conseguia ganhos significativos por buscar combinações mais detalhadas de hiperparâmetros, além de perder muito em tempo de execução. Desta forma, optei por ajustar apenas o hiperparâmetro de taxa de aprendizagem (para além de qual rede utilizar), e acredito ter obtido resultados satisfatórios para esta tarefa.

Os valores possíveis para a taxa de aprendizado eram entre 0,001 e 0,00001. Fixei o otimizador Adam convencional, e utilizei como função de perda a “BCEWithLogitsLoss”, que é uma combinação do Pytorch de uma camada Sigmoid com a perda de Entropia Cruzada Binária (BCELoss).

Defini ainda a utilização de poda pelo optuna, bem como algumas questões fixadas como a paciência, a quantidade de épocas e de iterações, que também foram fixas.

Treino final e predições no teste

O estudo optuna reportou a rede resnet18, com taxa de aprendizado \(\approx 0,001\) como o modelo com maior escore F1 (\(\approx 0,5225\)). Fixado estes hiperparâmetros, re-treinei a rede combinando os conjuntos de treino e validação, onde obtive um escore F1 \(\approx 0,51\), o que parece aceitável para esta atividade.

Portanto, utilizou-se deste modelo treinado sobre todo o conjunto de treino para realizar as predições para o conjunto de teste, as quais seguem em anexo no formato solicitado pela atividade, num arquivo csv de duas colunas.

Resumo

Infraestrutura:

  • Python
  • Pytorch
  • Google colab

Data augmentation:

Técnicas utilizadas: Argumentos
ColorJitter brightness = 0.1, contrast = 0.1, prob = 0.2
GaussianBlur kernel_size = 3, sigma = (0.1, 0.5), prob = 0.1
RandomHorizontalFlip prob = 0.5
RandomRotation degrees = (-3, 3)
RandomResizedCrop size = (224, 224), scale = (0.95, 1.0)
RandomAffine degrees=0, translate=(0.03, 0.03)

Pré processamento:

  • Resize -> 96x103 para 224x224 |
  • Normalização, com pesos Resnet|
  • 3 canais de cor, replicando |

Amostragem

  • Divisão 80% treino 20% validação
  • Amostragem estratificada para divisão
  • Pesos aumentados para registros femininos

Hiperparâmetros

Hiperparâmetro Valores possíveis
Rede “resnet18”; “resnet50”; “mobilenet_v2”; “efficientnet_b0”; “densenet121”
Learning rate [0,001;0,00001]

Modelo selecionado

Rede Learnign rate
resnet18 \(\approx 0,0001\)

Performance desta rede:

Durante estudo optuna (80% treino, 20% validação):

  • Escore F1: \(\approx 0,5225\)

Treino final (100% treino):

  • Escore F1: \(\approx 0,51\)

O código

Os resultados da metodologia supracitada e seus respectivos resultados estão listados abaixo na execução do notebook python.

Instalar optuna no Colab (único módulo dos que não usarei que não está pré instalado)

Mostrar códigos
!pip install optuna

Importando módulos

Mostrar códigos
from google.colab import drive
import os
import glob
import random
import numpy as np
from PIL import Image
from tqdm import tqdm

import pandas as pd

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
import torchvision.transforms as T
import torchvision.models as models

from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score, precision_score, recall_score, confusion_matrix

import optuna

Criando conexão com o meu drive, onde subi as imagens

Mostrar códigos
drive.mount('/content/drive')
Mounted at /content/drive

Definindo o caminho (remoto) dos dados

Mostrar códigos
ROOT = '/content/drive/MyDrive/colab_data/rna1/lista5/Treino'

Definindo questões operacionais: Modo fast debug para rodar rapidinho e verificar erros e problemas, desligar para rodar de verdade

fixando o tamanho da imagem para upscale - como estou usando modelos pré treinados para 224x224, a recomendação da bibliografia é (neste caso) aumentar o tamanho das imagens de 96x103 para 224x224, para garantir o correto funcionamento dos filtros kernel de imagem da forma que foram concebidos nos modelos originais.

Mostrar códigos
FAST_DEBUG = False
if FAST_DEBUG:
    N_EPOCHS = 2
    N_TRIALS = 2
    BATCH_SIZE = 16
else:
    N_EPOCHS = 20
    N_TRIALS = 30
    BATCH_SIZE = 32

IMAGE_HEIGHT = 224
IMAGE_WIDTH  = 224

Garantindo possibilidades: rodar na GPU se possível, no processador, caso não tenha.

Mostrar códigos
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("DEVICE:", device)
print("FAST_DEBUG:", FAST_DEBUG)
DEVICE: cuda
FAST_DEBUG: False

Trazer os dados para o python

Mostrar códigos
def collect_image_files(root):
    exts = ("*.bmp", "*.BMP")
    files = []
    for e in exts:
        files.extend(glob.glob(os.path.join(root, e)))
    files = sorted(files)
    return files

all_files = collect_image_files(ROOT)
if len(all_files) == 0:
    raise RuntimeError(f"Nenhuma imagem encontrada")

Preparando os dados pré-separação em treino e validação

Mostrar códigos
class FingerprintDataset(Dataset):
    def __init__(self, files_list, transform=None):
        self.files = list(files_list)
        self.transform = transform
        self.labels = []
        for f in self.files:
            name = os.path.basename(f)
            first = name[0].upper() if len(name) > 0 else ""
            if first == "F":
                self.labels.append(1)
            elif first == "M":
                self.labels.append(0)
            else:
                base = name.split("_")[0].upper() if "_" in name else first
                if base == "F":
                    self.labels.append(1)
                elif base == "M":
                    self.labels.append(0)
                else:
                    raise ValueError(f"Nome de arquivo não tem F/M na frente: {name}")

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

    def __getitem__(self, idx):
        path = self.files[idx]
        img = Image.open(path)
        if self.transform:
            img = self.transform(img)
        label = self.labels[idx]
        return img, label

Criando os tensores - data augmentation sendo feito nesta etapa

obs: a normalização está sendo feita com os pesos dos modelos pré treinados, em detrimento das estatísticas dos meus dados.

Mostrar códigos
train_tf = T.Compose([

    T.Resize((IMAGE_HEIGHT, IMAGE_WIDTH)),
    T.Grayscale(num_output_channels=3),

    T.RandomApply([T.ColorJitter(brightness=0.1, contrast=0.1)], p=0.2),
    T.RandomApply([T.GaussianBlur(kernel_size=3, sigma=(0.1, 0.5))], p=0.1),
    T.RandomHorizontalFlip(p=0.5),
    T.RandomRotation(degrees=(-3, 3)),
    T.RandomResizedCrop(size=(IMAGE_HEIGHT, IMAGE_WIDTH), scale=(0.95, 1.0)),
    T.RandomAffine(degrees=0, translate=(0.03, 0.03)),

    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),

    ])

val_tf = T.Compose([

    T.Resize((IMAGE_HEIGHT, IMAGE_WIDTH)),
    T.Grayscale(num_output_channels=3),

    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),

])

Split treino/validação (80/20)

Mostrar códigos
labels_all = []
for f in all_files:
    name = os.path.basename(f)
    first = name[0].upper() if len(name) > 0 else ""
    if first == "F":
        labels_all.append(1)
    elif first == "M":
        labels_all.append(0)
    else:
        base = name.split("_")[0].upper() if "_" in name else first
        labels_all.append(1 if base == "F" else 0)

labels_all = np.array(labels_all)

train_idx, val_idx = train_test_split(
    np.arange(len(all_files)),
    test_size=0.2,
    stratify=labels_all,
    random_state=42
)
train_files = [all_files[i] for i in train_idx]
val_files   = [all_files[i] for i in val_idx]

train_dataset = FingerprintDataset(train_files, transform=train_tf)
val_dataset   = FingerprintDataset(val_files, transform=val_tf)

Aplicando pesos para a classe com menor frequência (impressões digitais de mulheres)

Mostrar códigos
train_labels = np.array(train_dataset.labels)
class_counts = np.bincount(train_labels)

class_weights = 1.0 / (class_counts)
sample_weights = class_weights[train_labels]

sampler = WeightedRandomSampler(
    weights=sample_weights.tolist(),
    num_samples=len(sample_weights),
    replacement=True
)

train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=sampler, num_workers=0, pin_memory=False)
val_loader   = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0, pin_memory=False)

Definindo modelos: Ao invés de montar uma arquitetura própria, trouxe algumas sugestões de arquiteturas pré-treinadas para classificação de imagens

Mostrar códigos
def create_model(trial):
    model_name = trial.suggest_categorical(
        "model_type",
        ["resnet18", "resnet50", "mobilenet_v2", "efficientnet_b0", "densenet121"]
    )

    if model_name == "resnet18":
        model = models.resnet18(weights="IMAGENET1K_V1")
        model.fc = nn.Linear(model.fc.in_features, 1)

    elif model_name == "resnet50":
        model = models.resnet50(weights="IMAGENET1K_V1")
        model.fc = nn.Linear(model.fc.in_features, 1)

    elif model_name == "mobilenet_v2":
        model = models.mobilenet_v2(weights="IMAGENET1K_V1")
        model.classifier[1] = nn.Linear(model.classifier[1].in_features, 1)

    elif model_name == "efficientnet_b0":
        model = models.efficientnet_b0(weights="IMAGENET1K_V1")
        model.classifier[1] = nn.Linear(model.classifier[1].in_features, 1)

    elif model_name == "densenet121":
        model = models.densenet121(weights="IMAGENET1K_V1")
        model.classifier = nn.Linear(model.classifier.in_features, 1)

    return model.to(device)

Treino e avaliação dos modelos

Mostrar códigos
def train_and_evaluate(model, trial=None, save_path=None):
    lr = trial.suggest_float("lr", 1e-5, 1e-3, log=True) if trial is not None else 1e-4
    optimizer = optim.Adam(model.parameters(), lr=lr)
    criterion = nn.BCEWithLogitsLoss()

    best_f1 = 0.0
    best_state = None
    patience = 3
    no_improve = 0

    for epoch in range(N_EPOCHS):
        model.train()
        running_loss = 0.0
        for imgs, labels in train_loader:
            imgs = imgs.to(device)
            labels = labels.float().to(device)

            optimizer.zero_grad()
            logits = model(imgs).squeeze(1)
            loss = criterion(logits, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item() * imgs.size(0)

        model.eval()
        preds = []
        trues = []
        with torch.no_grad():
            for imgs, labels in val_loader:
                imgs = imgs.to(device)
                labels = labels.to(device)
                logits = model(imgs).squeeze(1)
                probs = torch.sigmoid(logits)
                pred_bin = (probs > 0.5).long().cpu().numpy()
                preds.extend(pred_bin.tolist())
                trues.extend(labels.cpu().numpy().tolist())

        f1 = f1_score(trues, preds, zero_division=0)
        prec = precision_score(trues, preds, zero_division=0)
        rec  = recall_score(trues, preds, zero_division=0)

        print(f"Epoch {epoch+1}/{N_EPOCHS} - loss: {running_loss/len(train_dataset):.4f} - F1: {f1:.4f} - P: {prec:.4f} - R: {rec:.4f}")

        if f1 > best_f1:
            best_f1 = f1
            no_improve = 0
            best_state = model.state_dict()
            if save_path is not None:
                torch.save(best_state, save_path)
        else:
            no_improve += 1
            if no_improve >= patience:
                print("Early stopping.")
                break

        if trial is not None:
            trial.report(best_f1, epoch)
            if trial.should_prune():
                raise optuna.exceptions.TrialPruned()

    return best_f1, best_state

Função objetivo do optuna

Mostrar códigos
def objective(trial):
    model = create_model(trial)
    f1, _ = train_and_evaluate(model, trial=trial, save_path=None)
    return f1

Rodar optuna

Mostrar códigos
study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=N_TRIALS, n_jobs=-1, show_progress_bar=True)

print("Melhor trial:", study.best_trial.params)
print("Melhor F1 obtido (val):", study.best_value)

Coletando melhor modelo do estudo de hiperparâmetros

Mostrar códigos
best_params = study.best_trial.params
class Dummy:
    def __init__(self, params):
        self.params = params
    def suggest_float(self, *args, **kwargs):
        return self.params.get("lr", 1e-4)
    def suggest_categorical(self, name, choices):
        return self.params.get("model_type", choices[0])

dummy_trial = Dummy(best_params)
final_model = create_model(dummy_trial)
best_model_path = "best_model_final.pth"
best_f1, best_state = train_and_evaluate(final_model, trial=None, save_path=best_model_path)
print("Final F1 (val):", best_f1)
print("Best model saved to:", best_model_path)
Epoch 1/20 - loss: 0.5652 - F1: 0.4317 - P: 0.3053 - R: 0.7365
Epoch 2/20 - loss: 0.4471 - F1: 0.5226 - P: 0.4029 - R: 0.7432
Epoch 3/20 - loss: 0.3927 - F1: 0.4959 - P: 0.4155 - R: 0.6149
Epoch 4/20 - loss: 0.3358 - F1: 0.4978 - P: 0.3709 - R: 0.7568
Epoch 5/20 - loss: 0.2652 - F1: 0.5101 - P: 0.5067 - R: 0.5135
Early stopping.
Final F1 (val): 0.5225653206650831
Best model saved to: best_model_final.pth

Avaliação “final”

Mostrar códigos
if best_state is not None:
    final_model.load_state_dict(best_state)

final_model.eval()
preds = []
trues = []
with torch.no_grad():
    for imgs, labels in val_loader:
        imgs = imgs.to(device)
        labels = labels.to(device)
        logits = final_model(imgs).squeeze(1)
        probs = torch.sigmoid(logits)
        pred_bin = (probs > 0.5).long().cpu().numpy()
        preds.extend(pred_bin.tolist())
        trues.extend(labels.cpu().numpy().tolist())

print("Precision:", precision_score(trues, preds, zero_division=0))
print("Recall:", recall_score(trues, preds, zero_division=0))
print("F1 Score:", f1_score(trues, preds, zero_division=0))
print("Confusion Matrix:\n", confusion_matrix(trues, preds))
Precision: 0.5066666666666667
Recall: 0.5135135135135135
F1 Score: 0.5100671140939598
Confusion Matrix:
 [[578  74]
 [ 72  76]]

Utilizando agora o modelo selecionado para classificar as imagens no conjunto de teste

Mostrar códigos
TEST_ROOT = '/content/drive/MyDrive/colab_data/rna1/lista5/Teste'

def collect_image_files(root):
    exts = ("*.bmp", "*.BMP")
    files = []
    for e in exts:
        files.extend(glob.glob(os.path.join(root, e)))
    files = sorted(files)
    return files

test_files = collect_image_files(TEST_ROOT)

if best_state is None and os.path.exists("best_model_final.pth"):
    final_model.load_state_dict(torch.load("best_model_final.pth", map_location=device))
elif best_state is not None:
    final_model.load_state_dict(best_state)

class TestDataset(Dataset):
    def __init__(self, files_list, transform=None):
        self.files = files_list
        self.transform = transform

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

    def __getitem__(self, idx):
        path = self.files[idx]
        img = Image.open(path)
        file_id = os.path.basename(path).split('.')[0]
        if self.transform:
            img = self.transform(img)
        return img, file_id

test_dataset = TestDataset(test_files, transform=val_tf)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)

final_model.eval()
final_model.to(device)

predictions = []

with torch.no_grad():
    for imgs, file_ids in tqdm(test_loader, desc="Classificando imagens de teste"):
        imgs = imgs.to(device)
        logits = final_model(imgs).squeeze(1)
        probs = torch.sigmoid(logits)
        pred_bin = (probs > 0.5).long().cpu().numpy()

        for file_id, pred in zip(file_ids, pred_bin):
            label_str = "M" if pred == 0 else "F"
            predictions.append([file_id, label_str])

df_predictions = pd.DataFrame(predictions, columns=["ID", "PREDIT"])

Salvando os resultados em CSV

Mostrar códigos
output_csv_path = "/content/drive/MyDrive/colab_data/rna1/lista5/test_predictions.csv"
df_predictions.to_csv(output_csv_path, index=False)
Source: lista5.ipynb