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
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
def generate_run_id() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S") # p.ej. 20260709_040040run_id= timestamp: cada entrenamiento escribe enoutputs/<pipeline>/<modelo>/<run_id>/y nunca sobrescribe corridas previas. Puedes comparar experimentos históricos.latest.json:update_latest_pointerescribe{"run_id": ...}apuntando al run más reciente. Se llama solo tras un run exitoso (consummary.jsonya escrito), para no apuntar a runs a medias.resolve_run_dir: sinrun_idexplícito, leelatest.json; falla conSystemExitclaro si no hay runs o elrun_idpedido no existe. Es lo que permitemake explain-limesin 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.
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.csvPor 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)
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 elCornDatasetde train conminority_transform. - Extrae
class_to_idxdel 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); eltrain_loaderusashuffle=(sampler is None)— o sampler balanceado, o shuffle, nunca ambos.pin_memorysolo 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)
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()) # confianzaUn solo método sirve para las 3 fases, distinguidas por si se pasa optimizer:
- Train (
optimizerpresente): 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
- Crea
run_dirversionado y guarda evidencia de augmentation (save_augmentation_evidence). - Construye el modelo (transfer learning) → device.
criterion = CrossEntropyLoss()(sin pesos),optimizer = AdamW(lr, weight_decay).- Bucle de épocas: train → val → si mejora
val_macro_f1, guardabest.pth; siempre guardalast.pthy actualizatrain_history.csv. - Al terminar: carga
best.pth(nolast.pth) y evalúa en test. - Escribe
test_confusion_matrix.csv,test_classification_report.csv,predictions.csv,summary.json, y actualizalatest.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
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())
| Flag | Default | Rol |
|---|---|---|
--epochs | 30 | épocas |
--batch-size | 32 | se auto-escala a la baja para entradas grandes |
--learning-rate | 1e-4 | tasa de aprendizaje de AdamW |
--weight-decay | 1e-4 | regularización L2 desacoplada |
--num-workers | 4 | procesos de carga de datos |
--models | efficientnet_b0, shufflenet_v2_x1_0, efficientnet_lite0 | los 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:
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.