Dojo / C01 · 1 · desde cero
← Datos y etiquetas C01 · Estación 1 · Desde cero 1 de 2

Antes de producción: tu primer dataset

De 10 fotos sueltas a un dataset versionado, con un contrato que lo protege y un primer modelo que sabe con qué datos se entrenó.

Estás aquí1 · Datos y etiquetas

La escena

10 fotos · 2 clases · ≈ 2 horas
Antes de producciónSin kit · carpeta vacía

El marketplace todavía no tiene clasificador. Negocio quiere empezar con dos categorías, playera y tenis. Tú armas el dataset desde una carpeta vacía: recolectas las fotos, escribes la guía, etiquetas en Label Studio, divides, validas, versionas con DVC y entrenas un primer modelo.

Cuándo pasa esto: Es lo que pasa antes del primer modelo: todavía no hay usuarios ni logs, solo fotos y una pregunta de negocio. En C01 es el arranque de la estación 1, cuando se arma el primer set etiquetado y el test que se va a usar en todas las versiones.

Necesitas

  • Docker Desktop con las imágenes de RustFS y Label Studio (si ya bajaste el kit de la estación 1, ya las tienes; si no, ≈ 3 GB).
  • Python 3.11 o más nuevo y Claude Code.
  • Internet la primera vez: recolectar.py baja el set de prueba de Fashion-MNIST (≈ 4.5 MB) y lo guarda en ~/.cache/fashion-mnist.

Terminas con

  • Un repo de git con tus scripts, la guía v1, la ficha del dataset y el tag dataset-v1.
  • Las 10 fotos en un S3 local, versionadas con DVC: las borras y vuelven con un comando.
  • labels.csv y splits.csv legibles en un diff, y un contrato que no deja pasar datos rotos.
  • modelo-v1 con su ficha: qué versión del dataset y qué commit lo produjeron.
Guiado · Cómo se trabajaAbre una sesión de Claude Code en la carpeta de la práctica y pega este prompt. Tú escribes el código; Claude explica, revisa y te hace las preguntas. Esta página es tu referencia: el código es el de la solución probada y cada salida es la que salió de verdad.
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, estación 1 de C01 («Datos y etiquetas»), haciendo la práctica **Antes de producción: tu primer dataset**, desde cero. Trabajo en `~/dojo/c01-desde-cero`, que empieza vacía: no hay kit ni `CLAUDE.md`. La guía de la práctica está en la app del Dojo, en esa estación.

Vamos paso por paso, del 1 al 11: carpeta y servicios, recolectar, guía, etiquetar en Label Studio, `labels.csv`, dividir, contrato, ficha, versionar con DVC, recuperar y entrenar.

Reglas:
- **Yo escribo el código.** Tú me dices qué va en cada archivo y por qué, un bloque a la vez, y revisas lo que escribí. Solo escribes un archivo si te lo pido.
- Antes de empezar, pídeme las tres cuentas de los precálculos (fotos mínimas, qué tan fino mide un test de 2 fotos y por qué las fotos no van en git). Yo las hago primero y tú me corriges.
- En cada paso: el objetivo en una línea y los conceptos que usa, rápido y con su término en inglés.
- Dime qué comando correr y espera a que te pegue la salida. Lo que cambia algo (`docker compose`, `dvc push`, `git commit`, borrar carpetas) no lo corres sin avisarme antes.
- Si algo falla, no lo arregles en silencio: muéstrame el error, dime tu hipótesis y lo arreglamos juntos.
- Al terminar cada paso, agrega una entrada a `BITACORA.md` con la fecha real: qué hice, qué medí (números que salieron de verdad) y qué sigue.
- Las credenciales de `compose.yaml` son de prueba y solo existen en mi Mac.
- Español neutro, de tú, directo y sin relleno.

Empecemos por los precálculos.

Precálculos

3 cuentas

Haz cada cuenta antes de abrirla. Con un dataset tan chico los números son de juguete, pero las preguntas son las mismas que en C01.

1

¿Cuántas fotos necesitas como mínimo?

Ver la cuenta
En tu carpetaCada clase tiene que aparecer en train, val y test, o no la puedes medir. Con 3 de train, 1 de val y 1 de test por clase: 2 clases × (3 + 1 + 1) = 10.10 fotos, 5 por clase
En C01Con las 500 categorías de C01, el mismo piso da 500 × 5 = 2,500 fotos, y eso antes de pensar en qué tan fino quieres medir.

Qué decides con esto. Con 5 fotos en total no alcanza: alguna clase se queda sin val o sin test. El mínimo sale del número de clases y del reparto, no de las ganas.

Lo confirmas en el paso 6, cuando dividir.py imprime train 3 · val 1 · test 1 por clase.

2

¿Qué tan fino mide un test de 2 fotos?

Ver la cuenta
En tu carpetaCada foto de test es 1 de 2: vale 50 puntos de exactitud. Solo hay tres resultados posibles: 0 %, 50 % o 100 %.saltos de 50 puntos
En C01Para medir con ± 2 puntos hacen falta miles de fotos de test; por eso C01 tiene un golden set grande y fijo.

Qué decides con esto. Este test solo dice si el camino completo funciona, no qué tan bueno es el modelo. Lo vas a ver en el paso 11: 2/2 no es para celebrar.

Lo confirmas en el paso 11, en la ficha de modelo-v1.

3

¿Por qué las fotos no van en git y los labels sí?

Ver la cuenta
En tu carpetaLas 10 fotos pesan 8,341 bytes; labels.csv y splits.csv, unos cientos de bytes cada uno. Git guarda cada versión de cada archivo para siempre y no sabe comparar imágenes.8.3 KB aquí; millones de fotos en C01
En C01En C01 entran miles de fotos nuevas cada mes. En git, el repo crecería sin fin y cada clon bajaría todo el historial.

Qué decides con esto. Fotos a DVC + S3: git solo guarda un puntero de 5 líneas con su hash. Labels, splits, guía y ficha a git: son texto chico y quieres verlos en un diff.

Lo confirmas en el paso 9: data/crudo.dvc dice size: 8341 y nfiles: 10.

Conceptos exprés

6 nuevos

Los que aparecen aquí por primera vez. Los demás chips llevan a los conceptos de la estación.

Versión de datasetdataset versioning (DVC)Un nombre fijo, un tag, que apunta a exactamente qué fotos, qué labels y qué splits había. Git guarda el texto; DVC guarda las fotos en S3 y deja en git un puntero con su hash.Analogía, por qué importa y la trampa

AnalogíaEl ticket del guardarropa: en git queda el ticket chico, el abrigo pesado está en S3, y con el ticket siempre te dan el mismo abrigo.

Por qué importaSin versión no puedes reproducir un modelo, ni comparar dos, ni explicar qué cambió entre un mes y el siguiente.

TrampaVersionar solo las fotos y no los labels, o al revés. La versión es el conjunto: fotos + labels + splits + guía.

Split establestable split / frozen test setCada foto cae en train, val o test una vez y ahí se queda en todas las versiones. Las nuevas se reparten sin mover las viejas.Analogía, por qué importa y la trampa

AnalogíaLas preguntas de un examen de certificación: si cambian cada año, no puedes comparar a una generación con otra.

Por qué importaEs lo que permite comparar modelo-v1 con modelo-v2 en el mismo examen.

TrampaVolver a barajar todo con una semilla en cada versión: fotos de test pasan a train y la comparación ya no vale.

Contrato de datosdata contract / data validationReglas que el dataset tiene que cumplir antes de versionarse o entrenarse. Si una falla, el proceso se detiene con código de salida 1.Analogía, por qué importa y la trampa

AnalogíaLa revisión antes de despegar: no es opcional aunque el avión se vea bien.

Por qué importaAtrapa fotos sin label, clases mal escritas y splits vacíos antes de que lleguen al modelo, donde se ven como «el modelo empeoró».

TrampaQue solo avise y siga. Si no detiene el proceso, nadie lo lee.

Duplicados y fugadeduplication / data leakageLa misma foto dos veces. Si una copia cae en train y otra en test, el modelo ya vio el examen y su métrica se infla.Analogía, por qué importa y la trampa

AnalogíaQue el examen traiga una pregunta que venía resuelta en la tarea.

Por qué importaEn producción la gente vuelve a subir fotos. Sin deduplicar por el hash del contenido, el test se contamina sin que nadie lo note.

TrampaDeduplicar por nombre de archivo: la misma foto con otro nombre pasa. La llave es el sha256 del contenido.

Ficha del datasetdatasheet / data cardUna página que dice qué hay en el dataset, de dónde salió, cómo se etiquetó, cómo se dividió y qué no sirve medir con él.Analogía, por qué importa y la trampa

AnalogíaLa etiqueta nutrimental: no cambia lo que hay adentro, pero te dice si te sirve.

Por qué importaQuien entrena dentro de seis meses (o tú) no estuvo cuando se armó. Los límites conocidos evitan conclusiones falsas.

TrampaEscribirla una vez y no actualizarla. Cada versión cambia la ficha y su sección de cambios.

LinajelineageLa cadena que une un modelo con el dataset y el código que lo produjeron: modelo-v1 → dataset-v1 → commit 7fa27d9.Analogía, por qué importa y la trampa

AnalogíaEl número de lote de un medicamento: si algo sale mal, sabes de qué lote viene y qué más salió de ahí.

Por qué importaCuando un modelo falla en producción, la primera pregunta es con qué datos se entrenó. Con linaje, la respuesta es un archivo.

TrampaEntrenar con lo que haya en la carpeta en ese momento, con cambios sin commit: la ficha dice una versión y los datos eran otros.

Pasos

11 pasos
1Carpeta, entorno y serviciosUna carpeta vacía con git, un entorno de Python y los dos servicios arriba: un S3 local y Label Studio.

Empiezas sin kit. compose.yaml levanta RustFS (un S3 que corre en tu Mac, donde DVC va a guardar las fotos) y Label Studio (donde las etiquetas). Usan los puertos 9200, 9201 y 8091 para no chocar con el kit de la estación. Las credenciales son de prueba y solo existen en tu Mac. En .gitignore van desde ya prod/ y los modelos: lo que llegue de producción y los binarios no son parte del repo.

compose.yaml
# Dos servicios para la práctica: un S3 local (RustFS) para guardar las versiones del dataset
# y Label Studio para etiquetar. En C01 real: Amazon S3 y Label Studio o un proveedor.
name: c01-desde-cero
services:
  s3:
    image: rustfs/rustfs:1.0.4
    ports: ["9200:9000", "9201:9001"]   # 9200 = API S3 · 9201 = consola web
    environment:
      RUSTFS_ACCESS_KEY: practica
      RUSTFS_SECRET_KEY: practica-secreto
      RUSTFS_ADDRESS: ":9000"
      RUSTFS_CONSOLE_ADDRESS: ":9001"
      RUSTFS_CONSOLE_ENABLE: "true"
    volumes: ["s3:/data"]
    command: ["/data"]
  labelstudio:
    image: heartexlabs/label-studio:1.23.2
    ports: ["8091:8080"]                 # http://localhost:8091
    environment:
      LABEL_STUDIO_USERNAME: practica@ejemplo.com
      LABEL_STUDIO_PASSWORD: practica-etiquetas
      LABEL_STUDIO_USER_TOKEN: practica-token-local-0001
      LABEL_STUDIO_ENABLE_LEGACY_API_TOKEN: "true"
    volumes: ["ls:/label-studio/data"]
volumes:
  s3: {}
  ls: {}
requirements.txt
dvc[s3]==3.67.1
boto3==1.43.117
pillow==13.0.0
numpy==2.5.4
scikit-learn==1.9.1
pyyaml==6.0.3
.gitignore
.venv
__pycache__/
prod/
modelos/*.joblib
terminal
mkdir -p ~/dojo/c01-desde-cero && cd ~/dojo/c01-desde-cero
git init -b main
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

Escribe compose.yaml, requirements.txt y .gitignore antes del pip install.

La salida depende de tu corrida.

terminal
docker compose up -d
docker compose ps --format '{{.Service}}  {{.Status}}  {{.Ports}}'
Salida real
labelstudio  Up 18 seconds  0.0.0.0:8091->8080/tcp, [::]:8091->8080/tcp
s3  Up 18 seconds  0.0.0.0:9200->9000/tcp, [::]:9200->9000/tcp, 0.0.0.0:9201->9001/tcp, [::]:9201->9001/tcp
Listo cuando: Los dos servicios dicen Up y http://localhost:8091 abre el login de Label Studio.
Ojo: Si tienes arriba el kit de la estación 1, no pasa nada: esta práctica usa otros puertos y otros volúmenes.
2Recolectar las fotos10 fotos sin etiqueta en data/crudo/, con nombres que no delatan la clase.

En un proyecto real las fotos llegan de un catálogo, de un proveedor o de tu teléfono. Aquí salen de Fashion-MNIST: 5 playeras y 5 tenis del set de prueba, agrandadas a 112 px para verlas bien. El script las revuelve y las llama foto_01…foto_10 a propósito: quien etiqueta no debe adivinar por el nombre.

scripts/recolectar.py
"""Paso 2 · Recolectar: 10 fotos sin etiqueta en data/crudo/.

Toma 5 playeras y 5 tenis del set de prueba de Fashion-MNIST, las agranda a 112 px
para verlas bien en Label Studio y les pone nombres que no delatan la clase.
"""
import gzip
import urllib.request
from pathlib import Path

import numpy as np
from PIL import Image

URL = "https://github.com/zalandoresearch/fashion-mnist/raw/master/data/fashion/{}.gz"
CACHE = Path.home() / ".cache" / "fashion-mnist"
SALIDA = Path("data/crudo")


def bajar(nombre: str) -> bytes:
    CACHE.mkdir(parents=True, exist_ok=True)
    p = CACHE / f"{nombre}.gz"
    if not p.exists():
        urllib.request.urlretrieve(URL.format(nombre), p)
    return gzip.decompress(p.read_bytes())


imgs = np.frombuffer(bajar("t10k-images-idx3-ubyte"), np.uint8, offset=16).reshape(-1, 28, 28)
etq = np.frombuffer(bajar("t10k-labels-idx1-ubyte"), np.uint8, offset=8)

rng = np.random.default_rng(1)
elegidas = np.concatenate([rng.choice(np.where(etq == c)[0], 5, replace=False) for c in (0, 7)])  # 0 = playera, 7 = tenis
rng.shuffle(elegidas)

SALIDA.mkdir(parents=True, exist_ok=True)
for i, idx in enumerate(elegidas, 1):
    Image.fromarray(255 - imgs[idx]).resize((112, 112), Image.NEAREST).save(SALIDA / f"foto_{i:02d}.png")
print(f"{len(elegidas)} fotos en {SALIDA}/")
terminal
python scripts/recolectar.py
Salida real
10 fotos en data/crudo/
terminal
shasum -a 256 data/crudo/foto_01.png data/crudo/foto_02.png

El sha256 es la huella del contenido: si cambia un pixel, cambia todo. Va a ser la llave de cada foto, no su nombre.

Salida real
77cf97fb3f30d472404d96a20b93db33d544a739f5448798a2be9730d7728c1c  data/crudo/foto_01.png
8a4fd378d8fb33c8ecadc3527530a09f3473c2ad892e700004c78162da90eb1f  data/crudo/foto_02.png
Listo cuando: Hay 10 PNG y tus dos primeros sha256 empiezan con 77cf97fb y 8a4fd378: el script usa una semilla fija.
Ojo: ¿Quieres usar fotos tuyas? Sirve igual: 5 y 5 en data/crudo/ como PNG. Tus hashes y algunas salidas van a ser otras.
3Escribir la guía y la interfazLa guía v1, con qué es cada clase y sus casos límite, y la interfaz de Label Studio que la refleja.

Mira las 10 fotos antes de escribir. Vas a encontrar dos dudas reales: una playera de manga larga (foto_01) y un tenis que cubre el tobillo (foto_08). La guía decide esos casos antes de etiquetar, no después. La interfaz es un XML de Label Studio: una imagen y una sola elección entre las clases.

guia/guia-v1.md
# Guía de etiquetado · v1

**Para qué:** decidir la categoría de una foto de producto del marketplace. Una foto, una categoría.

## Categorías

| Categoría | Es | No es |
|---|---|---|
| `playera` | Prenda para el torso, de manga corta, larga o sin manga, con o sin estampado. | Vestidos, abrigos. |
| `tenis` | Calzado deportivo con agujetas o cierre, bajo o de bota. | Sandalias, zapatos de vestir. |

## Casos límite

- **Manga larga:** sigue siendo `playera` si es de tela delgada y sin botones al frente.
- **Tenis de bota (cubre el tobillo):** es `tenis`.
- **No se ve bien o no es ninguna:** no adivines; usa *Skip* y anótalo en la bitácora.

## Cambios

- v1 (inicial): dos categorías.
guia/interfaz-v1.xml
<View>
  <Image name="foto" value="$image" zoom="true"/>
  <Choices name="clase" toName="foto" choice="single" showInline="true" required="true">
    <Choice value="playera"/>
    <Choice value="tenis"/>
  </Choices>
</View>
terminal
open data/crudo

Abre la carpeta en Finder para ver las fotos.

No imprime nada.

Listo cuando: Cada caso límite que viste en las fotos tiene una regla escrita.
Ojo: Versiona la guía como el código (guia-v1.md, luego guia-v2.md): cada label va a decir con qué guía se hizo.
4Etiquetar en Label StudioLas 10 fotos etiquetadas a mano y exportadas a data/etiquetado/export-v1.json.

Entra a http://localhost:8091 con practica@ejemplo.com / practica-etiquetas. Create Project y, en esa misma ventana: en Project Name escribe c01-v1; en Data Import arrastra las 10 fotos de data/crudo/; en Labeling Setup elige Custom template y pega guia/interfaz-v1.xml en el editor de código. Save. Luego Label All Tasks y vas una por una: tecla 1 para playera, 2 para tenis, y el botón Submit. La exportación la haces por la API, como lo haría un job.

terminal
mkdir -p data/etiquetado
curl -s "http://localhost:8091/api/projects/1/export?exportType=JSON_MIN" \
  -H "Authorization: Token practica-token-local-0001" -o data/etiquetado/export-v1.json
python -m json.tool data/etiquetado/export-v1.json | head -11

JSON_MIN trae una fila por foto con su clase. Label Studio le pone un prefijo al nombre (14852f68-foto_01.png); el tuyo va a ser otro.

Salida real
[
    {
        "image": "/data/upload/1/345ef53e-foto_01.png",
        "id": 1,
        "clase": "playera",
        "annotator": 1,
        "annotation_id": 1,
        "created_at": "2026-11-04T05:20:30.886982Z",
        "updated_at": "2026-11-04T05:20:30.886991Z",
        "lead_time": null
    },
Listo cuando: export-v1.json tiene 10 filas y cada una trae clase.
Ojo: El usuario y el token salen de compose.yaml: son de prueba, solo para tu Mac. En una empresa el token vive en un gestor de secretos, nunca en el repo.
5De la exportación a labels.csvUna tabla de labels con la foto por su hash, la clase, quién la etiquetó, con qué guía y en qué lote.

La exportación es un formato de Label Studio; tu dataset no debe depender de él. etiquetas.py le quita el prefijo al nombre, calcula el sha256 de la foto y escribe data/labels.csv. Si una foto ya tenía label no la pisa: cambiar un label tiene que ser a propósito y quedar en el diff.

scripts/etiquetas.py
"""Paso 5 · De la exportación de Label Studio a data/labels.csv.

Una fila por foto: el archivo, su sha256 (la llave que no cambia aunque renombres el archivo),
la clase, quién la etiquetó, con qué guía y en qué lote llegó.
Uso: python scripts/etiquetas.py <export.json> <guia> <lote>
"""
import csv
import hashlib
import json
import sys
from pathlib import Path

export, guia, lote = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
CRUDO = Path("data/crudo")
LABELS = Path("data/labels.csv")
CAMPOS = ["sha256", "archivo", "clase", "anotador", "guia", "lote"]

previas = list(csv.DictReader(LABELS.open())) if LABELS.exists() else []
vistas = {f["sha256"] for f in previas}

nuevas = []
for t in json.loads(export.read_text()):
    archivo = t["image"].rsplit("/", 1)[1].split("-", 1)[1]   # /data/upload/1/d942189a-foto_01.png -> foto_01.png
    sha = hashlib.sha256((CRUDO / archivo).read_bytes()).hexdigest()
    if sha in vistas:
        continue                                               # ya tenía label: no se pisa en silencio
    nuevas.append({"sha256": sha, "archivo": archivo, "clase": t["clase"],
                   "anotador": t["annotator"], "guia": guia, "lote": lote})

with LABELS.open("w", newline="") as f:
    w = csv.DictWriter(f, CAMPOS)
    w.writeheader()
    w.writerows(sorted(previas + nuevas, key=lambda f: f["archivo"]))
print(f"{len(nuevas)} labels nuevos · {len(previas) + len(nuevas)} en total")
terminal
python scripts/etiquetas.py data/etiquetado/export-v1.json v1 inicial
Salida real
10 labels nuevos · 10 en total
terminal
cut -d, -f2- data/labels.csv | head -4

Sin la columna sha256, para que quepa en pantalla.

Salida real
archivo,clase,anotador,guia,lote
foto_01.png,playera,1,v1,inicial
foto_02.png,playera,1,v1,inicial
foto_03.png,tenis,1,v1,inicial
Listo cuando: 10 labels nuevos · 10 en total, y cada fila dice guía v1 y lote inicial.
Ojo: Guarda también la exportación cruda en el repo: si mañana dudas de un label, ahí está quién y cuándo.
6Dividir en train, val y testCada foto en un split: 3, 1 y 1 por clase.

El cupo vive en config.json. dividir.py reparte por clase y en orden de hash, no de nombre: el resultado no depende de cómo se llamen ni de en qué orden lleguen. Y la regla que más importa: una foto que ya tiene split lo conserva. Hoy no se nota; en la práctica de producción es lo que mantiene quieto el test.

config.json
{"clases": ["playera", "tenis"], "cupo": {"train": 3, "val": 1, "test": 1}}
scripts/dividir.py
"""Paso 6 · Dividir en train / val / test, por clase y sin depender del orden de los archivos.

Una foto que ya tiene split lo conserva: el test de una versión no se mueve en la siguiente.
Las nuevas se reparten por clase con el cupo de config (por hash, así sale igual cada vez).
"""
import csv
import json
from collections import defaultdict
from pathlib import Path

CUPO = json.loads(Path("config.json").read_text())["cupo"]   # p. ej. {"train": 3, "val": 1, "test": 1}
LABELS, SPLITS = Path("data/labels.csv"), Path("data/splits.csv")

labels = list(csv.DictReader(LABELS.open()))
splits = {f["sha256"]: f["split"] for f in csv.DictReader(SPLITS.open())} if SPLITS.exists() else {}

por_clase = defaultdict(list)
for f in labels:
    por_clase[f["clase"]].append(f["sha256"])

for clase, shas in sorted(por_clase.items()):
    ya = defaultdict(int)
    for s in shas:
        if s in splits:
            ya[splits[s]] += 1
    for s in sorted(x for x in shas if x not in splits):          # orden por hash: estable y sin sesgo de nombre
        falta = [n for n in ("test", "val") if ya[n] < CUPO[n]]
        splits[s] = falta[0] if falta else "train"
        ya[splits[s]] += 1

with SPLITS.open("w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["sha256", "split"])
    w.writerows(sorted(splits.items()))

cuenta = defaultdict(lambda: defaultdict(int))
clase_de = {f["sha256"]: f["clase"] for f in labels}
for s, sp in splits.items():
    cuenta[clase_de[s]][sp] += 1
for c in sorted(cuenta):
    print(f"{c:8} train {cuenta[c]['train']} · val {cuenta[c]['val']} · test {cuenta[c]['test']}")
terminal
python scripts/dividir.py
Salida real
playera  train 3 · val 1 · test 1
tenis    train 3 · val 1 · test 1
Listo cuando: playera y tenis con train 3 · val 1 · test 1.
Ojo: ¿Por qué no un split al azar con semilla? Porque en la siguiente versión, con más fotos, la misma semilla baraja distinto y fotos de test terminan en train.
7El contrato del datasetUn script que revisa el dataset y detiene todo si algo está mal.

Seis reglas: una fila por foto, cada label con su foto, cada foto con su label, solo clases conocidas, cada foto en un split y cada clase en cada split. Sale con código 1 si alguna falla, así un CI o un orquestador se detiene solo.

scripts/validar.py
"""Paso 7 · El contrato del dataset. Si algo falla, no se versiona ni se entrena.

Sale con código 1 si falla una regla: así lo puede usar un CI o el orquestador.
"""
import csv
import hashlib
import json
import sys
from collections import Counter
from pathlib import Path

cfg = json.loads(Path("config.json").read_text())
labels = list(csv.DictReader(Path("data/labels.csv").open()))
splits = {f["sha256"]: f["split"] for f in csv.DictReader(Path("data/splits.csv").open())}
en_disco = {hashlib.sha256(p.read_bytes()).hexdigest(): p.name for p in Path("data/crudo").glob("*.png")}

reglas = []
def regla(nombre, ok, detalle):
    reglas.append(ok)
    print(f"{'✓' if ok else '✗'} {nombre:25} {detalle}")

shas = [f["sha256"] for f in labels]
regla("una fila por foto", len(shas) == len(set(shas)), f"{len(shas)} filas, {len(set(shas))} fotos distintas")
faltan = [f["archivo"] for f in labels if f["sha256"] not in en_disco]
regla("cada label tiene foto", not faltan, "todas en data/crudo/" if not faltan else f"faltan: {faltan}")
sin_label = sorted(n for s, n in en_disco.items() if s not in set(shas))
regla("cada foto tiene label", not sin_label, "ninguna sin etiquetar" if not sin_label else f"sin label: {sin_label}")
raras = sorted({f["clase"] for f in labels} - set(cfg["clases"]))
regla("clases conocidas", not raras, ", ".join(cfg["clases"]) if not raras else f"desconocidas: {raras}")
sin_split = [s for s in shas if s not in splits]
regla("cada foto en un split", not sin_split, "train, val o test" if not sin_split else f"{len(sin_split)} sin split")
clase_de = {f["sha256"]: f["clase"] for f in labels}
huecos = [(sp, c) for sp in ("train", "val", "test") for c in cfg["clases"]
          if not any(splits.get(s) == sp and clase_de[s] == c for s in shas)]
regla("cada clase en cada split", not huecos, "sí" if not huecos else f"faltan: {huecos}")
n = Counter(splits[s] for s in shas if s in splits)
print(f"\ntrain {n['train']} · val {n['val']} · test {n['test']}")
sys.exit(0 if all(reglas) else 1)
terminal
python scripts/validar.py
Salida real
✓ una fila por foto         10 filas, 10 fotos distintas
✓ cada label tiene foto     todas en data/crudo/
✓ cada foto tiene label     ninguna sin etiquetar
✓ clases conocidas          playera, tenis
✓ cada foto en un split     train, val o test
✓ cada clase en cada split  sí

train 6 · val 2 · test 2
Listo cuando: Seis ✓ y train 6 · val 2 · test 2.
Ojo: Rómpelo a propósito: borra una línea de labels.csv y córrelo. Tiene que salir ✗ y echo $? tiene que dar 1. Luego regresa la línea con git checkout data/labels.csv (o rehaz el paso 5 si todavía no hay commit).
8La ficha del datasetDATASET.md: qué hay, de dónde salió, cómo se etiquetó y qué no sirve medir con él.

Se escribe antes de versionar porque es parte de la versión: el tag dataset-v1 va a incluir la ficha que lo describe. Los números salen de los pasos anteriores; los límites, de los precálculos.

DATASET.md
# Ficha del dataset · c01-categorias

| | |
|---|---|
| **Versión** | `dataset-v1` |
| **Para qué** | Entrenar y evaluar el clasificador de categorías de las fotos del marketplace. |
| **Fotos** | 10 PNG de 112 × 112 px, en `data/crudo/` (DVC, remoto `s3://datasets/c01`). |
| **Clases** | `playera` 5 · `tenis` 5 |
| **Splits** | train 6 · val 2 · test 2, por clase y por hash (`data/splits.csv`). El test no se mueve en versiones futuras. |
| **Labels** | `data/labels.csv`: una persona, con la guía v1 (`guia/guia-v1.md`), en Label Studio. |
| **Fuente** | Fashion-MNIST (set de prueba), © 2017 Zalando SE, licencia MIT. |
| **Datos personales** | Ninguno: fotos de producto sin personas ni metadatos. |

## Límites conocidos

- Con 1 foto de test por clase, cada foto vale 50 puntos de exactitud: el test solo dice si el pipeline funciona, no qué tan bueno es el modelo.
- Un solo anotador: no hay medida de acuerdo.

## Cambios

- `dataset-v1`: versión inicial.
Listo cuando: Alguien que no estuvo aquí entiende el dataset sin preguntarte.
Ojo: Los límites conocidos son la parte más útil: dicen qué no hacer con estos datos.
9Versionar con DVCLa versión dataset-v1: fotos en S3 con DVC, todo lo demás en git, amarrado con un tag.

Primero el bucket. Luego DVC: guarda las fotos en el S3 local y deja en git data/crudo.dvc, un archivo chico con el hash de la carpeta. Git guarda labels, splits, guía, ficha y código. El tag dataset-v1 amarra todo: ese nombre siempre va a significar exactamente esto. Las llaves del S3 van con --local a .dvc/config.local, que no se sube a git.

terminal
python -c "
import boto3
s3 = boto3.client('s3', endpoint_url='http://localhost:9200', aws_access_key_id='practica', aws_secret_access_key='practica-secreto', region_name='us-east-1')
s3.create_bucket(Bucket='datasets')
print([b['Name'] for b in s3.list_buckets()['Buckets']])"
Salida real
['datasets']
terminal
dvc init -q
dvc remote add -d s3 s3://datasets/c01
dvc remote modify s3 endpointurl http://localhost:9200
dvc remote modify --local s3 access_key_id practica
dvc remote modify --local s3 secret_access_key practica-secreto
cat .dvc/config

Sin llaves: esas quedaron en .dvc/config.local.

Salida real
Setting 's3' as a default remote.
[core]
    remote = s3
['remote "s3"']
    url = s3://datasets/c01
    endpointurl = http://localhost:9200
terminal
dvc add data/crudo
cat data/crudo.dvc

DVC también agrega /crudo a data/.gitignore: git ya no ve las fotos.

Salida real

To track the changes with git, run:

	git add data/crudo.dvc data/.gitignore

To enable auto staging, run:

	dvc config core.autostage true
outs:
- md5: 7cffdd0acb7ed96ad9325bf9618e449f.dir
  size: 8341
  nfiles: 10
  hash: md5
  path: crudo
terminal
git add -A && git status --short
Salida real
A  .dvc/.gitignore
A  .dvc/config
A  .dvcignore
A  .gitignore
A  DATASET.md
A  compose.yaml
A  config.json
A  data/.gitignore
A  data/crudo.dvc
A  data/etiquetado/export-v1.json
A  data/labels.csv
A  data/splits.csv
A  guia/guia-v1.md
A  guia/interfaz-v1.xml
A  requirements.txt
A  scripts/dividir.py
A  scripts/etiquetas.py
A  scripts/recolectar.py
A  scripts/validar.py
terminal
git commit -m "dataset v1: 10 fotos, playera/tenis, guía v1"
git tag -a dataset-v1 -m "10 fotos · 2 clases · 6/2/2"

El hash del commit va a ser otro en tu corrida.

La salida depende de tu corrida.

terminal
dvc push
Salida real
11 files pushed
terminal
git log --oneline --decorate
Salida real
7fa27d9 (HEAD -> main, tag: dataset-v1) dataset v1: 10 fotos, playera/tenis, guía v1
Listo cuando: dvc push sube 11 archivos (las 10 fotos y el índice de la carpeta) y git log muestra el tag dataset-v1.
Ojo: Nunca pongas las llaves sin --local: terminarían en .dvc/config, que sí va a git.
10Comprobar que se recuperaBorrar las fotos y el caché de DVC, y que vuelvan del remoto.

Una versión que solo existe en tu disco no es una versión. Así llega el dataset a una máquina de entrenamiento: git clone + dvc pull. Ninguna foto viaja por git.

terminal
rm -rf data/crudo .dvc/cache && ls data
Salida real
crudo.dvc
etiquetado
labels.csv
splits.csv
terminal
dvc pull
Salida real
A       data/crudo/
11 files fetched and 10 files added
terminal
dvc status && python scripts/validar.py | tail -1
Salida real
Data and pipelines are up to date.
train 6 · val 2 · test 2
Listo cuando: dvc pull trae las 10 fotos, dvc status dice que todo está al día y el contrato sigue en verde.
Ojo: Si dvc pull falla con un error de acceso, revisa .dvc/config.local: es lo único que no viene en el repo.
11Entrenar con linajemodelo-v1 entrenado con dataset-v1 y una ficha que lo dice.

El modelo es una regresión logística sobre los 784 pixeles de cada foto; lo importante no es el modelo sino de dónde lee. entrenar.py no lee tu carpeta: lee la versión por su tag con dvc.api, y la ficha anota el tag y el commit al que apunta. Así cualquiera puede reproducirlo aunque tu carpeta ya haya cambiado. comun.py tiene las dos piezas que también va a usar producción: leer una versión y convertir una foto en números.

scripts/comun.py
"""Lo que comparten entrenar.py y evaluar.py: leer una versión del dataset por su tag.

dvc.api lee de git (labels.csv, splits.csv) y del caché o del remoto de DVC (las fotos)
tal como estaban en ese tag, sin tocar tu carpeta de trabajo.
"""
import csv
import io

import dvc.api
import numpy as np
from PIL import Image


def cargar(tag: str, split: str):
    labels = list(csv.DictReader(io.StringIO(dvc.api.read("data/labels.csv", rev=tag))))
    splits = {f["sha256"]: f["split"] for f in csv.DictReader(io.StringIO(dvc.api.read("data/splits.csv", rev=tag)))}
    filas = [f for f in labels if splits[f["sha256"]] == split]
    X = np.array([vector(dvc.api.read(f"data/crudo/{f['archivo']}", rev=tag, mode="rb")) for f in filas])
    return X, np.array([f["clase"] for f in filas])


def vector(png: bytes) -> np.ndarray:
    """La foto como 784 números entre 0 y 1 (28 × 28 px), igual en entrenamiento y en producción."""
    return np.asarray(Image.open(io.BytesIO(png)).resize((28, 28)), float).ravel() / 255
scripts/entrenar.py
"""Entrenar con una versión del dataset (por su tag) y dejar anotado cuál fue.

Uso: python scripts/entrenar.py <modelo> <tag>     p. ej. modelo-v1 dataset-v1
Lee la versión con dvc.api: si el tag no existe o sus fotos no están en el remoto, falla.
"""
import json
import subprocess
import sys
from pathlib import Path

import joblib
from sklearn.linear_model import LogisticRegression

from comun import cargar

nombre, tag = sys.argv[1], sys.argv[2]
Xtr, ytr = cargar(tag, "train")
modelo = LogisticRegression(max_iter=1000).fit(Xtr, ytr)

metricas, n = {}, {"train": len(ytr)}
for split in ("val", "test"):
    X, y = cargar(tag, split)
    n[split] = len(y)
    metricas[split] = f"{int((modelo.predict(X) == y).sum())}/{len(y)}"

ficha = {
    "modelo": nombre,
    "dataset": tag,
    "commit": subprocess.run(["git", "rev-list", "-n1", "--abbrev-commit", tag], capture_output=True, text=True, check=True).stdout.strip(),
    "clases": [str(c) for c in modelo.classes_],
    "n": n,
    "aciertos": metricas,
}
Path("modelos").mkdir(exist_ok=True)
joblib.dump(modelo, f"modelos/{nombre}.joblib")
Path(f"modelos/{nombre}.json").write_text(json.dumps(ficha, ensure_ascii=False, indent=2) + "\n")
print(json.dumps(ficha, ensure_ascii=False, indent=2))
terminal
python scripts/entrenar.py modelo-v1 dataset-v1

El commit va a ser el de tu tag.

Salida real
{
  "modelo": "modelo-v1",
  "dataset": "dataset-v1",
  "commit": "7fa27d9",
  "clases": [
    "playera",
    "tenis"
  ],
  "n": {
    "train": 6,
    "val": 2,
    "test": 2
  },
  "aciertos": {
    "val": "2/2",
    "test": "2/2"
  }
}
terminal
git add -A && git commit -m "modelo-v1 entrenado con dataset-v1"

Se versiona la ficha (modelos/modelo-v1.json); el binario se queda fuera por .gitignore.

La salida depende de tu corrida.

Listo cuando: La ficha dice dataset-v1, el commit del tag y 2/2 en val y en test.
Ojo: 2/2 no es un buen modelo: es un test de 2 fotos (precálculo 2). Lo que sí demuestra es que el camino completo funciona: fotos → labels → splits → contrato → versión → modelo con linaje.

Hazlo solo

7 objetivos

Una carpeta nueva (~/dojo/c01-desde-cero-2), sin abrir esta página: solo con el prompt de «Hazlo solo».

Claude no te da código: te contesta con preguntas, y solo da una pista si dices «me rindo».

0110 fotos en data/crudo/ con nombres que no delatan la clase.
02La guía v1 con una regla para cada caso límite que veas en las fotos.
03labels.csv con el sha256 como llave, la clase, la guía y el lote.
04Splits 3/1/1 por clase que no dependen del nombre ni del orden.
05Un contrato que sale con código 1 si quitas un label.
06dataset-v1 con tag y en el remoto: dvc pull la recupera después de borrarla.
07modelo-v1 con una ficha que nombra su dataset y su commit.
Solo · El prompt
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, estación 1 de C01, ronda **Hazlo solo** de la práctica **Antes de producción**. Ya la hice guiada; ahora la rehago desde cero en una carpeta nueva, `~/dojo/c01-desde-cero-2`, sin ver la guía.

Reglas:
- **No me des código ni me digas qué escribir.** Si te pregunto, contéstame con una pregunta que me haga pensar o con el nombre del concepto.
- Si digo «me rindo» en un objetivo, entonces sí: una pista concreta, y la anotas.
- Cuando diga que terminé un objetivo, compruébalo de verdad (corre o pídeme el comando que lo demuestra) y dime si cumple.
- Al terminar cada objetivo, anota en `BITACORA.md` cuánto tardé, dónde me atoré y cuántas pistas usé.

Los objetivos:
1. 10 fotos en `data/crudo/` (5 playeras y 5 tenis) con nombres que no delatan la clase.
2. La guía v1 con una regla para cada caso límite que vea en las fotos.
3. `labels.csv` con el sha256 como llave, la clase, la guía y el lote, desde una exportación de Label Studio.
4. Splits 3/1/1 por clase que no dependen del nombre ni del orden.
5. Un contrato que sale con código 1 si quito un label.
6. `dataset-v1` con tag y en el S3 local: `dvc pull` la recupera después de borrarla.
7. `modelo-v1` con una ficha que nombra su dataset y su commit.

Empieza por decirme el objetivo 1 y nada más.