4.2. Capa de datos (src/data/)
La capa más crítica del proyecto. Convierte rutas de disco en tensores listos para el modelo, de forma reproducible, robusta y balanceada. Cuatro archivos: loader.py, dataset.py, splitter.py, transforms.py.
loader.py — el único punto de entrada a imagen
Propósito y por qué existe
Garantizar que cada imagen que entra al sistema —al entrenar, al evaluar, al explicar o al predecir— se cargue exactamente igual. Si distintas partes del código abrieran imágenes con PIL a su manera, aparecerían inconsistencias sutiles (una parte corrige EXIF y otra no; una convierte a RGB y otra deja RGBA), causando train/serve skew.
Función load_and_normalize_image(image_path) -> Image.Image
- Entrada: ruta a un archivo de imagen. Salida:
PIL.Imageen modo RGB, orientación corregida. - Garantiza dos cosas: (1) corrección de orientación física vía EXIF; (2) conversión estricta a 3 canales RGB.
with Image.open(image_path) as raw:
img = ImageOps.exif_transpose(raw) # corrección EXIF (smartphones)
if img is raw: # Pillow antiguos pueden devolver el mismo objeto lazy
img = raw.copy()
if img.mode != "RGB":
img = img.convert("RGB")Bloques críticos explicados:
ImageOps.exif_transpose: las cámaras de smartphone guardan la orientación en metadatos EXIF en lugar de rotar los píxeles. Sin esta corrección, una hoja fotografiada "de lado" entraría rotada 90°, degradando la predicción. Como el modelo se entrena y se despliega sobre fotos de teléfono, esto no es opcional.if img is raw: img = raw.copy(): PIL carga de forma perezosa. La imagen debe quedar materializada en memoria antes de que elwithcierre el archivo; si escapa lazy, se filtran file descriptors o se accede a un buffer ya destruido. Este guard cubre versiones antiguas de Pillow que devuelven el mismo objeto.convert("RGB"): normaliza el espacio de color. Elimina el canal Alfa de PNGs RGBA y expande imágenes en escala de grises a 3 canales. Los modelos preentrenados esperan exactamente 3 canales.
Casos borde:
- Archivo inexistente →
FileNotFoundError. - Archivo corrupto o ilegible → se captura y se re-lanza como
RuntimeError("Archivo corrupto o ilegible").CornDatasetsabe atrapar ambos.
Complejidad y riesgos
O(tamaño de la imagen) por E/S. Riesgo: es un cuello de botella de I/O en datasets grandes en disco lento, pero se mitiga con la carga perezosa + múltiples workers del DataLoader.
dataset.py — CornDataset, minoritarias y sampler
Propósito
Implementar un torch.utils.data.Dataset que entregue (tensor, label) bajo demanda, aplicando la transform correcta según si la clase es minoritaria, y ofrecer las utilidades de balanceo.
compute_minority_classes(data_frame, threshold) -> set[str]
- Objetivo: derivar dinámicamente qué clases son minoritarias, a partir de la distribución real del split.
- Regla: una clase es minoritaria si
max_count / count_de_la_clase > threshold(default 4.0).
counts = data_frame["label"].value_counts()
max_count = counts.max()
return {str(label) for label, n in counts.items() if max_count / n > threshold}Por qué se deriva y no se hardcodea (decisión clave): el conjunto de minoritarias depende del split. Sobre el dataset completo de 9 clases, califican gray_leaf_spot, nitrogen_deficiency, phosphorus_deficiency y potassium_deficiency. Pero sobre un split ya balanceado (p.ej. si alguien recorta todas las clases a la misma cantidad), el conjunto queda vacío y entonces ni el augmentation agresivo ni el sampler se activan — automáticamente. No hay un frozenset estático que se desincronice de la realidad.
Nota sobre el perfil baseline
El perfil baseline usa cap de 1500/clase, pero no balancea: las clases con menos de 1500 imágenes (las deficiencias) quedan intactas y por debajo del umbral, así que siguen calificando como minoritarias. El cap solo recorta las mayoritarias.
Clase CornDataset(Dataset)
Responsabilidad: mapeo indexado de imágenes de hojas → tensores + etiquetas.
Parámetros del constructor (los importantes):
| Parámetro | Rol |
|---|---|
csv_path | Manifiesto del split (train.csv/val.csv/test.csv) |
transform | Pipeline estándar (torchvision) |
minority_transform | Pipeline agresivo para clases minoritarias; si None, todas usan transform |
class_to_idx | Mapeo canónico inyectado desde el split de train, para que val/test usen índices idénticos |
minority_classes | Si None, se derivan del split; permite inyectarlas |
El punto sutil de class_to_idx: el dataset de train construye el mapeo clase→índice; los datasets de val y test lo reciben inyectado. Si cada uno construyera el suyo, un split de val al que le falte una clase produciría índices desplazados y las etiquetas no coincidirían. Inyectar el mapeo del train es lo que garantiza consistencia entre los tres splits.
__getitem__(idx) -> (tensor, int) — carga perezosa con reintentos
for attempt in range(_MAX_FALLBACK_ATTEMPTS): # 5 intentos
row = self.data_frame.iloc[(idx + attempt) % len(self)]
img_path = self.dataset_root / row["image_path"]
try:
image = load_and_normalize_image(img_path)
class_name = row["label"]
break
except (FileNotFoundError, RuntimeError) as e:
last_error = e
logger.warning(...) # prueba la siguiente fila
else:
raise RuntimeError("5 imágenes consecutivas ilegibles...")Intención del reintento acotado: una imagen corrupta aislada no debe matar el worker ni abortar una época de entrenamiento; el dataset simplemente avanza a la siguiente fila. Pero una racha de 5 fallos consecutivos sí se propaga como error: eso ya no es una imagen mala sino un síntoma de que DATASET_ROOT se volvió inaccesible o los splits están desactualizados. El for/else de Python es idiomático aquí: el else se ejecuta solo si el for no hizo break (es decir, si agotó los 5 intentos).
Selección de pipeline (líneas finales):
pipeline = (
self.minority_transform
if self.minority_transform is not None and class_name in self.minority_classes
else self.transform
)Cada muestra decide su augmentation según si su clase es minoritaria. Esto es lo que aplica augmentation asimétrico solo donde hace falta.
Complejidad: __getitem__ es O(coste de leer+transformar 1 imagen). El __init__ es O(N) por la lectura del CSV y el conteo.
resolve_class_mapping(train_csv_path, classes)
Reconstruye class_to_idx/idx_to_class filtrando la lista classes (orden del YAML) contra las etiquetas presentes en train.csv. Advertencia documentada: debe usarse el mismo train_csv_path con el que se entrenó el checkpoint, porque los índices no viven en el state_dict. Es el fallback cuando no hay summary.json.
build_weighted_sampler(dataset, seed) -> WeightedRandomSampler | None
if not dataset.minority_classes:
return None # split balanceado → no hay nada que balancear
sample_weights = torch.tensor([1.0 / class_counts[label] for label in labels])
return WeightedRandomSampler(weights=..., num_samples=len(...), replacement=True, generator=...)- Qué hace: asigna a cada muestra un peso inversamente proporcional a la frecuencia de su clase. Al muestrear con
replacement=True, en cada época el modelo ve (en expectativa) las clases balanceadas, aunque las minoritarias se repitan. - Devuelve
Nonesi no hay minoritarias: decisión clave. Con un split balanceado, activar el sampler conreplacement=Truereduciría la cobertura por época (muestrearía con repetición sin necesidad). DevolverNonedeja que elDataLoaderuseshuffle=True, que recorre cada imagen una vez por época. - Semilla del generador: hace reproducible la secuencia de muestreo.
No se pondera además la loss
El balanceo lo hace solo el sampler. La CrossEntropyLoss se usa sin pesos por clase. Ponderar la loss además del sampler sería doble compensación y sobre-corregiría hacia las minoritarias. Ver decisiones técnicas.
splitter.py — partición estratificada jerárquica
Clase HierarchicalStratifiedSplitter
- Responsabilidad: dividir el manifiesto en train/val/test preservando la proporción de cada combinación
clase + entorno.
stratify_col = data_manifest["label"] + "_" + data_manifest["environment"] # p.ej. common_rust_real
train_df, temp_df = train_test_split(data_manifest, train_size=0.70, stratify=stratify_col, ...)
relative_test_size = test_size / (val_size + test_size) # 0.15/0.30 = 0.5
val_df, test_df = train_test_split(temp_df, test_size=relative_test_size, stratify=temp_stratify, ...)Por qué la súper-etiqueta label+environment: estratificar solo por clase no bastaría. Una clase puede tener 90 % de imágenes lab y 10 % real; sin estratificar por entorno, el test podría quedar dominado por lab, evaluando en un dominio (fondo limpio) distinto del de despliegue (campo). Combinar ambos preserva la mezcla lab/real en los tres splits.
Por qué dos cortes: sklearn solo divide en dos a la vez. El primer corte separa train del resto; el segundo parte el resto en val y test. El recálculo de relative_test_size es la aritmética necesaria para que el 30 % restante se reparta 15/15 sobre el total original.
Caso borde: valida que las proporciones sumen 1.0 (tolerancia 1e-9). Y create_splits.py garantiza aguas arriba que cada estrato tenga ≥7 imágenes (mínimo para que sklearn pueda poner ≥2 por lado en cada corte).
transforms.py — pipelines de augmentation (patrón Factory)
Diseño: TransformPipelineFactory (ABC) + fábricas concretas + CornTransformFactory
Se usa el patrón Abstract Factory (ABC con create_transforms) para tener una interfaz común y poder pedir el pipeline por etapa (train/minority/val/test/inference) sin que el llamador conozca los detalles.
Los tres pipelines y su filosofía de dominio
| Pipeline | Augmentations | Filosofía |
|---|---|---|
CornTrainingTransforms (estándar) | Resize, flips H/V, rotación ±15°, ColorJitter sutil (brillo/contraste 0.1, saturación/hue 0) | Distorsiones geométricas seguras; no toca color agresivamente |
CornMinorityTransforms (agresivo) | RandomResizedCrop(0.7-1.0), flips, rotación ±30°, ColorJitter medio (0.3/0.3/0.2, hue 0.05), GaussianBlur | Más variación para clases con pocas imágenes, pero preserva el hue diagnóstico |
CornValidationTransforms (determinista) | Solo Resize + ToTensor + Normalize | Sin aleatoriedad: evaluación justa y reproducible |
La decisión de dominio más importante aquí: el ColorJitter mantiene saturation=0.0, hue=0.0 en el pipeline estándar y solo 0.2/0.05 en el agresivo. Por qué: el diagnóstico de deficiencias nutricionales depende del color (clorosis/amarillamiento por nitrógeno, bordes morados por fósforo). Alterar agresivamente saturación o tono destruiría la señal diagnóstica y el modelo aprendería a ignorar justo lo que debe mirar. Es un caso claro de augmentation informada por el conocimiento del dominio, no una receta genérica de visión por computador.
Los flips vertical y horizontal (p=0.5) son seguros aquí porque una hoja de maíz no tiene orientación canónica: girarla no cambia el diagnóstico. (En otros dominios —dígitos, texto— un flip sí cambiaría la clase.)
CornTransformFactory.get_pipeline(stage)
if stage == "train": return CornTrainingTransforms(...).create_transforms()
elif stage == "minority": return CornMinorityTransforms(...).create_transforms()
elif stage in ["val","test","inference"]: return CornValidationTransforms(...).create_transforms()target_sizese lee del YAML como(alto, ancho), o se sobrescribe por modelo (ver modelos).- El
Resizedirecto distorsiona la relación de aspecto a propósito: es consistente entre train/eval/inferencia. Cambiarlo a Resize+CenterCrop introduciría un recorte que perdería bordes de la hoja (donde aparecen síntomas de potasio, por ejemplo).
Siguiente
Ya sabes cómo se preparan y cargan los datos. Sigue con Modelos y registry para ver cómo se construyen las arquitecturas.