Tarea 7: Clasificador de imágenes de ocho formas con GoogLeNet

Tarea 7: Clasificador de imágenes de ocho formas con GoogLeNet

Información de la Tarea

Descripción de la Tarea

Para esta tarea, implementé un clasificador de imágenes de ocho formas geométricas utilizando Transfer Learning con GoogLeNet recortada (conservando únicamente las capas convolucionales base y el bloque inception3a), adaptando la red para aceptar imágenes en escala de grises (1 canal) y aplicando aumento de datos.

A partir del diseño estándar de GoogLeNet (Inception v1), recorté la arquitectura comentando los bloques posteriores a inception3a. Ajusté la entrada a 1 canal y la salida final a 8 clases:

class GoogLeNet(nn.Module):
def __init__(self, num_classes: int = 8, aux_logits: bool = True, ...):
super().__init__()
# Entrada adaptada para imagenes de 1 canal (escala de grises)
self.conv1 = conv_block(1, 64, kernel_size=7, stride=2, padding=3)
self.maxpool1 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
self.conv2 = conv_block(64, 64, kernel_size=1)
self.conv3 = conv_block(64, 192, kernel_size=3, padding=1)
self.maxpool2 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
# Se conserva unicamente el primer modulo Inception (inception3a)
self.inception3a = inception_block(192, 64, 96, 128, 16, 32, 32)
# Los modulos inception3b hasta inception5b fueron recortados
if aux_logits:
self.aux1 = inception_aux_block(256, num_classes, dropout=dropout_aux)
self.aux2 = inception_aux_block(256, num_classes, dropout=dropout_aux)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.dropout = nn.Dropout(p=dropout)
self.fc = nn.Linear(256, num_classes) # Clasificador lineal a 8 clases

El bloque Inception procesa la entrada en 4 ramas paralelas con diferentes tamaños de kernel (1×1, 3×3, 5×5 y Max Pooling), concatenando sus salidas en profundidad para capturar detalles locales y globales:

class Inception(nn.Module):
def __init__(self, in_channels: int, ch1x1: int, ch3x3red: int, ch3x3: int,
ch5x5red: int, ch5x5: int, pool_proj: int):
super().__init__()
self.branch1 = conv_block(in_channels, ch1x1, kernel_size=1)
self.branch2 = nn.Sequential(
conv_block(in_channels, ch3x3red, kernel_size=1),
conv_block(ch3x3red, ch3x3, kernel_size=3, padding=1)
)
self.branch3 = nn.Sequential(
conv_block(in_channels, ch5x5red, kernel_size=1),
conv_block(ch5x5red, ch5x5, kernel_size=3, padding=1)
)
self.branch4 = nn.Sequential(
nn.MaxPool2d(kernel_size=3, stride=1, padding=1, ceil_mode=True),
conv_block(in_channels, pool_proj, kernel_size=1)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return torch.cat([self.branch1(x), self.branch2(x), self.branch3(x), self.branch4(x)], 1)

Descargué los pesos oficiales de ImageNet (IMAGENET1K_V1). Para adaptar la entrada RGB (3 canales) a escala de grises (1 canal), sumé los pesos de los 3 canales en la capa conv1.conv.weight y cargué únicamente las capas compatibles con la arquitectura recortada:

# Descarga de pesos preentrenados oficiales de PyTorch
pretrained_model = original_googlenet(weights='IMAGENET1K_V1')
pretrained_dict = pretrained_model.state_dict()
model_dict = nuevo_modelo.state_dict()
# Adaptar conv1 de 3 canales a 1 canal sumando sus filtros
if 'conv1.conv.weight' in pretrained_dict:
peso_original = pretrained_dict['conv1.conv.weight'] # [64, 3, 7, 7]
peso_modificado = peso_original.sum(dim=1, keepdim=True) # [64, 1, 7, 7]
pretrained_dict['conv1.conv.weight'] = peso_modificado
# Cargar unicamente las capas presentes en nuestro modelo recortado
pretrained_dict = {k: v for k, v in pretrained_dict.items() if k in model_dict and v.shape == model_dict[k].shape}
model_dict.update(pretrained_dict)
nuevo_modelo.load_state_dict(model_dict)

Verifiqué la estructura de la red con torchinfo, confirmando la reducción de parámetros y el congelamiento selectivo:

Terminal window
==========================================================================================
Layer (type:depth-idx) Param # Trainable
==========================================================================================
GoogLeNet 4,278,800 Partial
├─BasicConv2d: 1-1 -- False
└─Conv2d: 2-1 (3,136) False
└─BatchNorm2d: 2-2 (128) False
├─MaxPool2d: 1-2 -- --
├─BasicConv2d: 1-3 -- False
└─Conv2d: 2-3 (4,096) False
└─BatchNorm2d: 2-4 (128) False
├─BasicConv2d: 1-4 -- False
└─Conv2d: 2-5 (110,592) False
└─BatchNorm2d: 2-6 (384) False
├─MaxPool2d: 1-5 -- --
├─Inception: 1-6 -- True
└─BasicConv2d: 2-7 12,416 True
└─Sequential: 2-8 129,472 True
└─Sequential: 2-9 7,776 True
└─Sequential: 2-10 6,208 True
├─AdaptiveAvgPool2d: 1-7 -- --
├─Dropout: 1-8 -- --
├─Linear (fc): 1-9 2,056 True
==========================================================================================
Total params: 4,555,192
Trainable params: 4,436,728 (inception3a + fc + aux)
Non-trainable params: 118,464 (conv1, conv2, conv3)
Total mult-adds (Units.GIGABYTES): 1.05
Input size (MB): 0.43
Estimated Total Size (MB): 64.02
==========================================================================================

Data Augmentation

Siguiendo el mismo procedimiento de la Tarea 4, dividí inicialmente el conjunto de 480 imágenes (60 por clase) en proporción 80/20 para evitar la Fuga de Datos (Data Leakage): 96 imágenes para prueba (12 por clase) y 384 para entrenamiento (48 por clase).

Con la función AumentaDatos apliqué un factor de aumento de x200 sobre el conjunto de entrenamiento, obteniendo 76,800 muestras:

# Pipeline de Transformaciones con torchvision v2
transforma = v2.Compose([
v2.ToImage(),
v2.RandomRotation(degrees=(0, 360)),
v2.CenterCrop(size=58),
v2.RandomHorizontalFlip(p=0.5),
v2.RandomVerticalFlip(p=0.5),
v2.ToDtype(torch.float32, scale=False)
])
# Custom Dataset para carga y normalizacion en escala de grises
class CustomImageDataset(Dataset):
def __init__(self, annotations_file, img_dir, transform=None):
self.img_labels = pd.read_csv(annotations_file, header=None)
self.img_dir = img_dir
self.transform = transform
def __len__(self):
return len(self.img_labels)
def __getitem__(self, idx):
img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
image = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
image = cv2.normalize(image, None, 0, 1.0, cv2.NORM_MINMAX, dtype=cv2.CV_32F)
label = int(self.img_labels.iloc[idx, 1])
if self.transform:
image = self.transform(image)
return image, label

Entrenamiento

Para el Fine-Tuning Parcial, congelé las capas base (conv1, conv2, conv3) manteniendo sus pesos preentrenados fijos (requires_grad = False), y mantuve descongelados el bloque inception3a y el clasificador lineal fc:

# Congelar capas base, descongelar inception3a y clasificador final
for name, param in nuevo_modelo.named_parameters():
if "fc" not in name and "aux" not in name and "inception3a" not in name:
param.requires_grad = False
else:
param.requires_grad = True
# Optimizador SGD con Nesterov Momentum
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(
filter(lambda p: p.requires_grad, nuevo_modelo.parameters()),
lr=0.1,
momentum=0.9,
nesterov=True
)

Configuración de Hiperparámetros

Hiperparámetro Valor Configurado
Optimizador SGD con Momentum de Nesterov
Tasa de Aprendizaje (lr) 0.1
Momentum 0.9
Función de Pérdida CrossEntropyLoss
Tamaño de Lote (Batch Size) 32
Épocas (Epochs) 5
Semilla Global 42

Resultados

El entrenamiento de las 5 épocas tomó aproximadamente 7 minutos en completarse, convergiendo rápidamente gracias al aprovechamiento de las características preentrenadas y la capacidad de extracción del bloque inception3a.

Métricas por Época

Época Precisión (Train) Loss Promedio (Train) Precisión (Test) Loss Promedio (Test)
1 98.0% 0.056968 100.0% 0.000054
2 100.0% 0.001385 100.0% 0.000024
3 100.0% 0.000900 100.0% 0.000022
4 100.0% 0.000486 100.0% 0.000018
5 100.0% 0.000339 100.0% 0.000009
Azul: 100% de exactitud simultánea (Train y Test) Verde: Pérdida mínima alcanzada (Época óptima)
Curvas de Entrenamiento y Validación GoogLeNet

Conclusión

El recorte de GoogLeNet hasta el bloque inception3a y el uso de Transfer Learning permitieron obtener un modelo compacto y rápido, logrando 100% de precisión en prueba y pérdida mínima sin sobreajuste.