Información de la Tarea
- Estudiante: Andrés Cruz Chipol
- Curso: Aprendizaje Profundo
- Fecha de entrega: Jueves 20 de Agosto, 2026
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 clasesEl 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 PyTorchpretrained_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 filtrosif '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 recortadopretrained_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:
==========================================================================================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,192Trainable params: 4,436,728 (inception3a + fc + aux)Non-trainable params: 118,464 (conv1, conv2, conv3)Total mult-adds (Units.GIGABYTES): 1.05Input size (MB): 0.43Estimated 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 v2transforma = 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 grisesclass 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, labelEntrenamiento
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 finalfor 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 Momentumloss_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 |
- Pérdida mínima: 9.0 × 10⁻⁶ en Test (época 5).
- Tiempo total: ~7 minutos (5 épocas).
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.