Skip to content

4.4. Entrenamiento

Cubre el pipeline funcional (train_baselines.py), las utilidades compartidas (src/training/common.py) y el esqueleto del pipeline principal (train.py).

src/training/common.py — utilidades compartidas

Propósito

Factorizar lo que train.py y train_baselines.py comparten, para no duplicarlo. Si ambos scripts implementaran su propia generación de run_id o su propio versionado, divergirían.

Funciones clave

resolve_model_names(requested, registry)

Traduce ["all"] a la lista completa del registry; valida que los nombres pedidos existan (si no, SystemExit con la lista de disponibles).

worker_init_fn(worker_id) — el detalle sutil de las semillas

python
def worker_init_fn(worker_id):
    worker_seed = torch.initial_seed() % 2**32
    random.seed(worker_seed)
    np.random.seed(worker_seed)

Intención (crítica): en un DataLoader con num_workers>0, cada worker es un proceso separado. PyTorch le da a cada worker una semilla base distinta y variable por época (torch.initial_seed()). Esta función propaga esa semilla a random y numpy, que son los que usan las augmentations de torchvision.

El error que evita: si sembraras los workers con una semilla fija, cada worker generaría la misma secuencia de augmentation en cada época — y como los workers renacen cada época, verías las mismas "variaciones aleatorias" una y otra vez, anulando el propósito del augmentation. Al derivar de torch.initial_seed() (variable pero reproducible globalmente), obtienes variación real y reproducibilidad del run completo. Regla del proyecto: nunca sembrar workers con semilla fija.

select_device()

Elige cuda si hay GPU, si no cpu, y registra el hardware (nombre de GPU, VRAM) o advierte de que CPU será lento.

Versionado de runs: generate_run_id, build_run_dir, update_latest_pointer, resolve_run_dir

python
def generate_run_id() -> str:
    return datetime.now().strftime("%Y%m%d_%H%M%S")   # p.ej. 20260709_040040
  • run_id = timestamp: cada entrenamiento escribe en outputs/<pipeline>/<modelo>/<run_id>/ y nunca sobrescribe corridas previas. Puedes comparar experimentos históricos.
  • latest.json: update_latest_pointer escribe {"run_id": ...} apuntando al run más reciente. Se llama solo tras un run exitoso (con summary.json ya escrito), para no apuntar a runs a medias.
  • resolve_run_dir: sin run_id explícito, lee latest.json; falla con SystemExit claro si no hay runs o el run_id pedido no existe. Es lo que permite make explain-lime sin especificar run: usa el último.

load_run_metadata(run_dir, fallbacks...) — la fuente de verdad del run

Resuelve (splits_dir, class_to_idx, idx_to_class, target_size) para un run. Prioriza el summary.json del run: ahí está persistido el mapeo clase→índice y el image_size con los que se entrenó ese checkpoint concreto.

python
if summary_path.exists():
    summary = json.loads(summary_path.read_text())
    class_to_idx = {str(k): int(v) for k, v in summary["class_to_idx"].items()}
    ...
    return splits_dir, class_to_idx, idx_to_class, target_size
# solo si falta summary.json: fallback al YAML + train.csv

Por qué es tan importante (el bug que previene): reconstruir el mapeo desde baseline.classes es peligroso porque su orden puede diferir del canónico dataset.classes que usó CornDataset al entrenar. Un orden distinto produce rótulos permutados en los reportes. Persistir y releer el mapeo exacto del run elimina esa clase de bug. Esta función es compartida por explain_lime.py y explain_report.py justamente para que ambos traduzcan el argmax idéntico.

scripts/pipeline/train_baselines.py — el pipeline funcional

Es el script más grande y el único entrenamiento funcional hoy. Orquesta todo: genera splits si faltan, construye dataloaders, entrena cada modelo, evalúa y guarda todo.

Flujo de main()

Funciones internas destacadas

_scale_batch_size(base_batch, target_size, base)

python
scaled = round(base_batch * (base_h * base_w) / (height * width))
return max(1, min(base_batch, scaled))

Intención: la memoria de las activaciones crece ~cuadráticamente con el lado de la imagen. Si un modelo requiere 380×380 (área ~2.9× la de 224×224), este cálculo reduce el batch en esa proporción para que quepa en la GPU. El min(base_batch, ...) asegura que nunca suba el batch (solo baja), y max(1, ...) evita batch 0.

_build_dataloaders(...) — construcción coherente de los 3 loaders

  • Crea la CornTransformFactory, luego el CornDataset de train con minority_transform.
  • Extrae class_to_idx del train y lo inyecta en val y test (consistencia de índices).
  • val/test usan pipeline determinista (sin augmentation).
  • sampler = build_weighted_sampler(train_dataset, seed); el train_loader usa shuffle=(sampler is None) — o sampler balanceado, o shuffle, nunca ambos.
  • pin_memory solo en CUDA (acelera la transferencia CPU→GPU).

Cache de loaders (loader_cache): si varios modelos comparten (target_size, batch_size), se reutilizan los mismos dataloaders en vez de reconstruirlos. Los 3 baselines por defecto (todos 224/32) comparten un único set de loaders.

_run_epoch(...) — el motor de una época (train, val o test)

python
is_train = optimizer is not None
model.train(is_train)                                   # modo train/eval según el caso
context = torch.enable_grad() if is_train else torch.no_grad()
with context:
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        if is_train: optimizer.zero_grad(set_to_none=True)
        logits = model(images)
        loss = criterion(logits, labels)
        if is_train:
            loss.backward()      # backpropagation
            optimizer.step()     # actualiza pesos
        ...
        probs = logits.detach().softmax(dim=1)
        preds_all.extend(probs.argmax(dim=1).cpu().tolist())
        probs_all.extend(probs.max(dim=1).values.cpu().tolist())  # confianza

Un solo método sirve para las 3 fases, distinguidas por si se pasa optimizer:

  • Train (optimizer presente): gradientes activos, backward() + step(), model.train() (activa Dropout y BatchNorm en modo entrenamiento).
  • Val/Test (optimizer=None): torch.no_grad() (sin gradientes, más rápido y menos memoria), model.eval() (BatchNorm usa estadísticas acumuladas, Dropout se apaga).

Esta unificación es elegante y evita duplicar el bucle tres veces. Devuelve métricas + listas de labels/predicciones/confianzas para los reportes.

_train_model(...) — el ciclo completo de un modelo

  1. Crea run_dir versionado y guarda evidencia de augmentation (save_augmentation_evidence).
  2. Construye el modelo (transfer learning) → device.
  3. criterion = CrossEntropyLoss() (sin pesos), optimizer = AdamW(lr, weight_decay).
  4. Bucle de épocas: train → val → si mejora val_macro_f1, guarda best.pth; siempre guarda last.pth y actualiza train_history.csv.
  5. Al terminar: carga best.pth (no last.pth) y evalúa en test.
  6. Escribe test_confusion_matrix.csv, test_classification_report.csv, predictions.csv, summary.json, y actualiza latest.json.

Selección por best.pth: el checkpoint que se evalúa y se despliega es el de mejor macro-F1 de validación, no el de la última época. Esto es early stopping por checkpoint: aunque se corran las 30 épocas, un sobreajuste tardío no perjudica el modelo final. La métrica de selección es macro-F1 (no accuracy ni loss) por el desbalance de clases.

predictions.csv — el puente a la explicabilidad

python
predictions_df = pd.DataFrame({
    "image_path": ..., "label": ..., "pred_label": ..., "pred_prob": ...
})

Por cada imagen de test guarda etiqueta real, predicha y confianza. explain_report.py --errors-only filtra label != pred_label para explicar exactamente los errores. Sin este CSV, la explicabilidad no puede cruzar con los aciertos/errores.

Hiperparámetros por defecto (de main())

FlagDefaultRol
--epochs30épocas
--batch-size32se auto-escala a la baja para entradas grandes
--learning-rate1e-4tasa de aprendizaje de AdamW
--weight-decay1e-4regularización L2 desacoplada
--num-workers4procesos de carga de datos
--modelsefficientnet_b0, shufflenet_v2_x1_0, efficientnet_lite0los 3 baselines

Detalle exhaustivo en Hiperparámetros.

scripts/pipeline/train.py — el pipeline principal (esqueleto)

Estado actual: construye toda la infraestructura (dataloaders, sampler, modelo, criterion) idéntica a baselines, pero el loop de entrenamiento no está implementado:

python
for model_name in model_names:
    model = MODEL_REGISTRY.build(model_name, num_classes=num_classes).to(device)
    run_id = generate_run_id()
    run_dir = build_run_dir(output_dir, model_name, run_id)
    # TODO: loop de entrenamiento - al implementarlo, guardar checkpoints/metrics en
    # run_dir y llamar a update_latest_pointer(...) al final.

Por qué existe así: demuestra que la infraestructura de datos/modelos es compartida y reutilizable entre ambos pipelines. La idea es que el pipeline principal difiera de baselines en el loop (posiblemente con schedulers, más épocas, fine-tuning por fases, etc.), no en la carga de datos. Los # noqa: F841 marcan variables construidas pero aún no usadas por el loop pendiente. Ver ¿Qué pasaría si...? para ideas de cómo completarlo.

Siguiente

El modelo ya entrena y evalúa. Ahora, cómo se explica: Explicabilidad.