3. Flujo completo del sistema
Esta sección sigue una imagen (y luego un batch) a través de todo el sistema, paso a paso, indicando en cada etapa qué entra, qué sale y qué ocurre internamente. Es la vista dinámica que complementa el mapa estático de la sección anterior.
3.1. Panorama de las etapas
3.2. Etapa 1-2 — Ingesta y limpieza (contexto, fuera del pipeline de entrenamiento)
Qué entra: 8 datasets públicos heterogéneos (PlantVillage, campo real de África/India/Sudáfrica, Roboflow, etc.), con formatos, nombres y estructuras dispares.
Qué ocurre: scripts/download_datasets.sh los baja a raw/. Luego ~25 scripts en scripts/cleanup/ (uno por problema puntual) renombran, mueven, deduplican y reorganizan las imágenes hasta consolidarlas en una estructura uniforme.
Qué sale: clean/<clase>/{lab,real}/*.jpg. La distinción lab vs real es clave:
lab= imágenes de laboratorio (fondo controlado, hoja aislada — p.ej. PlantVillage).real= imágenes de campo (fondo ruidoso, iluminación variable, la condición real de despliegue).
Inmutabilidad
raw/ es inmutable (solo fuente original) y clean/ es la única fuente de verdad para entrenamiento. Ninguna etapa posterior modifica estos directorios. clean/ (~25k imágenes) se hospeda en Hugging Face Hub y se descarga con make download-dataset.
3.3. Etapa 3 — Preparación de splits (create_splits.py)
Esta etapa convierte el árbol de imágenes de clean/ en tres archivos CSV (train.csv, val.csv, test.csv) que son manifiestos: cada fila es image_path, label, environment. No copia imágenes; solo indexa rutas.
Qué ocurre internamente (y por qué):
- Escaneo
sorted()de clases → entornos → imágenes. El orden estable es lo que hace que la deduplicación (que conserva "la primera copia vista") sea determinista entre máquinas, no solo dentro de una máquina. - Validación + hash en paralelo (
ThreadPoolExecutor). Cada archivo se lee una sola vez; con esos bytes se hacePIL.verify()(integridad) ysha256()(identidad). Se usan hilos y no procesos porque el trabajo es I/O + C que libera el GIL, y así se oculta la latencia de disco — crítico en volúmenes remotos lentos (Modal). - Deduplicación exacta por SHA-256: dos imágenes con bytes idénticos cuentan como una. Evita fuga de información (la misma imagen en train y test infla las métricas).
- Cap opcional por clase (
_cap_manifest_per_class): limita cada clase a N imágenes con muestreo proporcional por entorno (conserva el balance lab/real). El perfil baseline usa 1500. - Split estratificado 70/15/15 por la súper-etiqueta
label+environment(ver etapa siguiente).
Qué sale: los 3 CSV + un split_audit_report.csv con conteos por clase/entorno/split. Guardados en outputs/splits/seed_42/ (9 clases) o outputs/splits/seed_42_baseline/.
Guardas de calidad
El script falla ruidosamente (no silenciosamente) si: no indexó ninguna imagen, una clase configurada quedó sin imágenes, o un estrato label+environment tiene menos de 7 imágenes (insuficiente para el doble split estratificado). Preferir fallar temprano y claro es una decisión de diseño consciente.
3.4. Etapa 3b — La estratificación jerárquica (splitter.py)
Problema que resuelve: si dividiéramos al azar, una clase minoritaria podría quedar toda en train y nada en test (o peor: todas sus imágenes real en train y solo lab en test, evaluando en un dominio que el modelo nunca verá en producción).
Solución: estratificar por la combinación label + environment (p.ej. common_rust_real, common_rust_lab). Así, la proporción de cada (clase, entorno) se preserva en train, val y test.
Se hace en dos cortes con sklearn.train_test_split:
- Primer corte: 70 % train vs 30 % temporal.
- Segundo corte: el 30 % temporal se parte en 15 % val y 15 % test (con la proporción recalculada,
relative_test_size = 0.15/0.30 = 0.5).
Entra: el manifiesto completo (DataFrame). Sale: tres DataFrames disjuntos y estratificados.
3.5. Etapa 4 — Carga de datos (CornDataset + DataLoader)
Aquí las rutas del CSV se convierten en tensores. Ocurre de forma perezosa (lazy): la imagen se lee solo cuando el DataLoader pide ese índice.
Qué ocurre internamente:
load_and_normalize_image: corrige la orientación EXIF (fotos de smartphone salen rotadas) y fuerza 3 canales RGB. Si la imagen está corrupta,CornDataset.__getitem__reintenta con la siguiente fila (hasta 5 veces) antes de rendirse — una imagen mala no mata el entrenamiento.- Selección de pipeline: si la clase es minoritaria, se aplica augmentation agresivo (
minority); si no, augmentation estándar (train). En val/test se aplica un pipeline determinista (sin aleatoriedad). - Normalización: se resta la media y se divide por la desviación estándar de ImageNet (
[0.485,0.456,0.406]/[0.229,0.224,0.225]), porque los modelos vienen preentrenados con esa normalización.
El balanceo de clases lo hace el WeightedRandomSampler: da a cada imagen una probabilidad de ser muestreada inversamente proporcional a la frecuencia de su clase, de modo que en cada época el modelo ve (en expectativa) las clases balanceadas. Se desactiva automáticamente si el split ya está balanceado (entonces el DataLoader usa shuffle=True normal).
Entra: ruta de imagen. Sale: tensor [3, H, W] normalizado + índice de clase entero.
3.6. Etapa 5 — Entrenamiento (train_baselines.py)
El loop de entrenamiento completo. Para cada modelo solicitado:
Qué ocurre internamente:
- Transfer learning: el modelo carga pesos preentrenados en ImageNet y solo se reemplaza la cabeza (la última capa lineal) para que tenga 9 salidas. Ver fundamentos: transfer learning.
- Optimizador AdamW con
lr=1e-4,weight_decay=1e-4. - Pérdida CrossEntropy sin pesos (el balanceo ya lo hace el sampler; ponderar la loss además sería doble compensación).
- Selección de modelo por val macro-F1: se guarda
best.pthsolo cuando mejora el macro-F1 de validación. Es un early-stopping implícito por checkpoint (siempre se corren las 30 épocas, pero el que se usa para test es el mejor). - Auto-escalado: si un modelo requiere entrada mayor (FastViT 256, EfficientNet-B4 380), se ajusta el
target_sizey se reduce el batch inversamente al área para no reventar la memoria de la GPU.
Entra: directorio de splits. Sale: un run_dir versionado por timestamp con checkpoints, métricas y predictions.csv.
3.7. Etapa 6 — Evaluación
Tras entrenar, se evalúa el mejor checkpoint sobre el conjunto de test (nunca visto durante train/val):
test_classification_report.csv— precision/recall/F1 por clase.test_confusion_matrix.csv— qué clases se confunden con cuáles.predictions.csv— por cada imagen de test:image_path, label, pred_label, pred_prob. Es el puente hacia la explicabilidad:explain_report.pylo cruza para analizar los errores.summary.json— el metadato maestro del run: mapeoclass_to_idx,image_size, hiperparámetros y métricas finales. Es la fuente de verdad que consumen los scripts de explicabilidad e inferencia (garantiza que traduzcan el argmax con exactamente el mismo mapeo con el que se entrenó la cabeza).
3.8. Etapa 7 — Explicabilidad (post-hoc)
Se corre después del entrenamiento, sobre best.pth. No está acoplada al loop (antes corría automáticamente; ahora es un paso separado).
- LIME perturba superpíxeles de la imagen y ajusta un modelo lineal local para estimar qué regiones sostienen la predicción.
- Grad-CAM usa los gradientes de la clase predicha respecto a la última capa convolucional para producir un mapa de calor de "dónde miró" el modelo.
- Se usan dos técnicas complementarias a propósito: si ambas coinciden en la misma región de la hoja, la explicación es más confiable. Ver explicabilidad.
Entra: checkpoint + imágenes de test. Sale: PNGs con paneles superpuestos + resúmenes de fidelidad (confianza y dispersión por clase, acierto/error).
3.9. Etapa 8 — Inferencia (predict.py)
El camino de una sola imagen en "producción" (línea de comandos):
Reutiliza exactamente las mismas piezas que el entrenamiento (loader, transforms, registry), lo que garantiza que no haya train/serve skew (diferencia entre cómo se procesa una imagen al entrenar vs al predecir).
3.10. Etapa 9 — Exportación y despliegue (pendiente)
Todavía no implementado en el repo. El plan: exportar el modelo ganador a TensorFlow Lite Int8 (cuantización a 8 bits para reducir tamaño y latencia) y embeberlo en una app Android offline. Ver ¿Qué pasaría si...? para las implicaciones de la cuantización.
Siguiente paso
Ya tienes el flujo completo. Para bajar al detalle de cada archivo y función, entra en Análisis detallado de código.