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

En producción: el reentrenamiento

Un mes de publicaciones reales: qué etiquetar con un presupuesto, una clase nueva, dataset-v2 sin mover el test y modelo-v2 comparado con el mismo examen.

Estás aquí1 · Datos y etiquetas

La escena

11 publicaciones · 8 labels · 1 clase nueva · ≈ 2 horas
En producciónSin kit · la carpeta de la práctica 1

modelo-v1 ya atiende el marketplace. Pasa un mes y llegan 11 publicaciones con la categoría que eligió quien vende. Negocio agregó bolsa, que el modelo no conoce; alguien volvió a subir una foto vieja y alguien eligió mal su categoría. Con un presupuesto de 8 labels armas dataset-v2 sin romper lo que ya tenías y demuestras que modelo-v2 es mejor con el mismo test.

Cuándo pasa esto: Es el ciclo que se repite cada mes con el modelo ya en producción: la flecha de reentrenamiento del mapa. En C01 es la estación 1 completa, la que practicas con el kit, aquí en miniatura y escrita por ti desde cero.

Necesitas

  • Haber terminado «Antes de producción»: sigues en la misma carpeta, con dataset-v1 y modelo-v1.
  • Los servicios arriba: docker compose up -d en esa carpeta.

Terminas con

  • Un log de predicciones de producción y una cola del mes con el motivo de cada foto.
  • La guía v2 con la clase nueva y dataset-v2 con tag, sin que se mueva ninguna foto del test de v1.
  • modelo-v2 comparado con modelo-v1 clase por clase, en el mismo test.
  • La prueba de que reproduces modelo-v1 aunque tu carpeta ya esté en v2.
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 **En producción: el reentrenamiento**. Sigo en `~/dojo/c01-desde-cero`, donde ya terminé «Antes de producción»: tengo `dataset-v1` con tag, `modelo-v1` y los servicios (`docker compose up -d`). La guía de la práctica está en la app del Dojo, en esa estación.

Vamos paso por paso, del 1 al 9: llega el mes, atender y dejar el log, elegir qué etiquetar, la guía v2, etiquetar el lote, juntar y dividir, versionar `dataset-v2`, reentrenar y comparar, y reproducir v1.

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í. La excepción es `mundo/llegan.py` y `mundo/cuantas_bolsas.py`: son el mundo y los copio tal cual de la guía.
- Antes de empezar, pídeme las tres cuentas de los precálculos (si alcanzan 8 labels, cuántas bolsas necesita el modelo y cuántas fotos del test pueden moverse). Yo las hago primero y tú me corriges; la de las bolsas la comprobamos con `mundo/cuantas_bolsas.py` después del paso 1.
- En cada paso: el objetivo en una línea y los conceptos que usa, rápido y con su término en inglés. Cuando algo sea distinto de C01 real por la escala, dímelo en una línea.
- Dime qué comando correr y espera a que te pegue la salida. Lo que cambia algo (`mundo/llegan.py`, `dvc push`, `git commit`, `git checkout`) 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.
- 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

¿Te alcanzan 8 labels?

Ver la cuenta
En tu carpetaLlegan 11. Una ya está en el dataset (mismo sha256): quedan 10. Las 6 bolsas son obligatorias, porque sin ellas la clase no existe; luego 1 desacuerdo con quien vende y 1 al azar con cupo propio: 6 + 1 + 1 = 8. La foto incierta que sobra se queda fuera.8 justos; 1 incierta sin lugar
En C01En la estación 1 del kit es la misma cuenta con 800 fotos al mes y una cuarta parte al azar.

Qué decides con esto. El orden de prioridad decide qué se recorta: primero se va lo incierto, nunca la clase nueva ni el azar.

Lo confirmas en el paso 3, cuando seleccionar.py deja fuera a req_007 por presupuesto.

2

¿Cuántas bolsas necesita el modelo para reconocerlas?

Ver la cuenta
En tu carpetaSe mide, no se adivina: entrenar 200 veces con 4 playeras, 4 tenis y N bolsas al azar, y ver qué parte de 50 bolsas que nunca vio reconoce.2 → 57 % · 4 → 78 % · 6 → 86 %
En C01En C01 la pregunta es la misma para cada categoría nueva, y por eso la cola tiene un cupo fijo para ellas.

Qué decides con esto. Con 6 bolsas en la cola, 1 va a val y 1 a test: quedan 4 para train, ≈ 78 %. Si negocio necesita 90 %, el mes que entra la cola necesita más bolsas, no un modelo más grande.

terminal · compruébalo
python mundo/cuantas_bolsas.py

Tarda unos segundos: son 800 entrenamientos chicos. Cópialo del paso 1.

Salida real
2 bolsas en train → reconoce 57% de las bolsas nuevas
3 bolsas en train → reconoce 70% de las bolsas nuevas
4 bolsas en train → reconoce 78% de las bolsas nuevas
6 bolsas en train → reconoce 86% de las bolsas nuevas
3

¿Cuántas fotos del test de v1 pueden moverse en v2?

Ver la cuenta
En tu carpetaNinguna. Si una sola foto de test cambia de split o de label, modelo-v1 y modelo-v2 ya no se miden con el mismo examen.0
En C01En C01 el golden set se protege igual: nunca entra a entrenamiento ni se reetiqueta sin una versión nueva y explícita.

Qué decides con esto. La comparación v1 contra v2 vale en las clases que ya existían; bolsa se mide aparte, con su propia foto de test.

Lo confirmas en el paso 7, cuando git diff entre las dos versiones cuenta 0 líneas cambiadas en splits.csv.

Pasos

9 pasos
1Llega el mes11 publicaciones nuevas en prod/, con lo que eligió quien vende.

mundo/llegan.py es el mundo, no lo que practicas: cópialo tal cual. Escribe las fotos en prod/entrantes/ y en prod/publicaciones.jsonl lo que eligió cada quien. prod/ está en .gitignore: son datos de producción, no del dataset. Al dataset solo entra lo que elijas etiquetar. Copia también mundo/cuantas_bolsas.py, el experimento del precálculo 2.

mundo/llegan.py
"""El mundo (no es lo que practicas): un mes del marketplace con modelo-v1 en producción.

Llegan 11 publicaciones a prod/entrantes/ con lo que eligió quien vende. Negocio agregó la
categoría «bolsa» este mes, así que hay bolsas que el modelo no conoce. Corre una sola vez.
"""
import csv
import gzip
import json
import shutil
from pathlib import Path

import numpy as np
from PIL import Image

CACHE = Path.home() / ".cache" / "fashion-mnist"
imgs = np.frombuffer(gzip.decompress((CACHE / "t10k-images-idx3-ubyte.gz").read_bytes()), np.uint8, offset=16).reshape(-1, 28, 28)
etq = np.frombuffer(gzip.decompress((CACHE / "t10k-labels-idx1-ubyte.gz").read_bytes()), np.uint8, offset=8)

rng = np.random.default_rng(2026)
plan = [(8, "bolsa")] * 6 + [(0, "playera")] * 2 + [(7, "tenis"), (7, "playera")]   # la última: quien vende se equivocó
usadas = set()
SALIDA = Path("prod/entrantes")
SALIDA.mkdir(parents=True, exist_ok=True)
publicaciones = []
for i, (clase, eligio) in enumerate(plan, 1):
    idx = int(rng.choice([j for j in np.where(etq == clase)[0] if j not in usadas and j >= 5000]))
    usadas.add(idx)
    Image.fromarray(255 - imgs[idx]).resize((112, 112), Image.NEAREST).save(SALIDA / f"req_{i:03d}.png")
    publicaciones.append({"request_id": f"req_{i:03d}", "archivo": f"req_{i:03d}.png", "categoria_vendedor": eligio})

# Alguien vuelve a subir una foto que ya está en tu dataset (la primera de test, para que duela).
vieja = next(f["sha256"] for f in csv.DictReader(open("data/splits.csv")) if f["split"] == "test")
archivo = next(f["archivo"] for f in csv.DictReader(open("data/labels.csv")) if f["sha256"] == vieja)
shutil.copy(Path("data/crudo") / archivo, SALIDA / "req_011.png")
publicaciones.append({"request_id": "req_011", "archivo": "req_011.png", "categoria_vendedor": "playera"})
rng.shuffle(publicaciones)
Path("prod/publicaciones.jsonl").write_text("".join(json.dumps(p) + "\n" for p in publicaciones))
print(f"{len(publicaciones)} publicaciones en prod/")
mundo/cuantas_bolsas.py
"""Precálculo · ¿Cuántas bolsas necesita el modelo para reconocerlas?

Entrena 200 veces con 4 playeras, 4 tenis y N bolsas al azar, y mide qué parte de 50 bolsas
que nunca vio reconoce. No es parte del pipeline: es la cuenta que decide el cupo del mes.
"""
import gzip
from pathlib import Path

import numpy as np
from sklearn.linear_model import LogisticRegression

C = Path.home() / ".cache" / "fashion-mnist"
X = 1 - np.frombuffer(gzip.decompress((C / "t10k-images-idx3-ubyte.gz").read_bytes()), np.uint8, offset=16).reshape(-1, 784) / 255
y = np.frombuffer(gzip.decompress((C / "t10k-labels-idx1-ubyte.gz").read_bytes()), np.uint8, offset=8)

rng = np.random.default_rng(0)
for n in (2, 3, 4, 6):
    aciertos = []
    for _ in range(200):
        train = np.concatenate([rng.choice(np.where(y == c)[0], k, replace=False) for c, k in ((0, 4), (7, 4), (8, n))])
        modelo = LogisticRegression(max_iter=1000).fit(X[train], y[train])
        prueba = rng.choice(np.where(y == 8)[0], 50, replace=False)
        aciertos.append((modelo.predict(X[prueba]) == 8).mean())
    print(f"{n} bolsas en train → reconoce {np.mean(aciertos):.0%} de las bolsas nuevas")
terminal
python mundo/llegan.py && head -3 prod/publicaciones.jsonl
Salida real
11 publicaciones en prod/
{"request_id": "req_004", "archivo": "req_004.png", "categoria_vendedor": "bolsa"}
{"request_id": "req_001", "archivo": "req_001.png", "categoria_vendedor": "bolsa"}
{"request_id": "req_005", "archivo": "req_005.png", "categoria_vendedor": "bolsa"}
Listo cuando: 11 publicaciones, 11 PNG en prod/entrantes/.
Ojo: Las líneas salen revueltas a propósito: en producción nada llega en orden.
2Atender y dejar el logmodelo-v1 atiende cada publicación y deja una línea de log con el hash, la predicción, la confianza y la versión del modelo.

En C01 esto es el servicio en línea y su log de predicción. Sin ese log no hay de dónde sacar qué etiquetar: es la materia prima del mes siguiente. Fíjate en las bolsas: el modelo no tiene esa clase, así que les pone playera o tenis, a veces con mucha confianza.

scripts/predecir.py
"""Producción · El modelo atiende cada publicación y deja su log de predicción.

En C01 esto lo hace el servicio en línea; aquí es un script. Lo importante es el log:
sin él no hay de dónde sacar qué etiquetar el mes que entra.
Uso: python scripts/predecir.py <modelo>
"""
import hashlib
import json
import sys
from pathlib import Path

import joblib

from comun import vector

nombre = sys.argv[1]
modelo = joblib.load(f"modelos/{nombre}.joblib")
log = []
for linea in Path("prod/publicaciones.jsonl").read_text().splitlines():
    p = json.loads(linea)
    ruta = Path("prod/entrantes") / p["archivo"]
    proba = modelo.predict_proba([vector(ruta.read_bytes())])[0]
    k = int(proba.argmax())
    log.append({"request_id": p["request_id"], "sha256": hashlib.sha256(ruta.read_bytes()).hexdigest(),
                "pred": modelo.classes_[k], "confianza": round(float(proba[k]), 3), "modelo": nombre})
Path("prod/predicciones.jsonl").write_text("".join(json.dumps(r) + "\n" for r in log))
for r in sorted(log, key=lambda r: r["request_id"]):
    print(f"{r['request_id']}  {r['pred']:8} {r['confianza']:.3f}")
terminal
python scripts/predecir.py modelo-v1
Salida real
req_001  tenis    0.764
req_002  playera  0.879
req_003  tenis    0.677
req_004  playera  0.764
req_005  playera  0.800
req_006  playera  0.833
req_007  playera  0.766
req_008  playera  0.984
req_009  tenis    0.968
req_010  tenis    0.805
req_011  tenis    0.981
Listo cuando: 11 líneas en prod/predicciones.jsonl, cada una con "modelo": "modelo-v1".
Ojo: Confianza alta no es acierto: req_002 es una bolsa y sale playera con 0.879. Por eso la clase nueva no se detecta solo con incertidumbre.
3Elegir qué etiquetarLa cola del mes: 8 fotos con su motivo, copiadas a data/crudo/.

Por prioridad: fuera lo que ya está en el dataset (mismo sha256); luego la categoría nueva (quien vende eligió bolsa), los desacuerdos (el modelo dice una cosa y quien vende otra), lo incierto (confianza menor a 0.8) y un cupo al azar que nunca se recorta. Lo elegido se copia a data/crudo/; lo demás se queda en prod/. Agrega a config.json la sección seleccion.

config.json
{"clases": ["playera", "tenis"], "cupo": {"train": 3, "val": 1, "test": 1}, "seleccion": {"presupuesto": 8, "umbral": 0.8, "azar": 1}}
scripts/seleccionar.py
"""Producción · Elegir qué etiquetar con el presupuesto del mes y copiarlo a data/crudo/.

Orden de prioridad: fuera las que ya están en el dataset (mismo sha256), luego las de la
categoría nueva, los desacuerdos con quien vende, las inciertas y una parte al azar.
"""
import csv
import json
import random
import shutil
from pathlib import Path

cfg = json.loads(Path("config.json").read_text())["seleccion"]   # {"presupuesto": 8, "umbral": 0.8, "azar": 1}
pubs = {p["request_id"]: p for p in map(json.loads, Path("prod/publicaciones.jsonl").read_text().splitlines())}
preds = {r["request_id"]: r for r in map(json.loads, Path("prod/predicciones.jsonl").read_text().splitlines())}
en_dataset = {f["sha256"] for f in csv.DictReader(Path("data/labels.csv").open())}
conocidas = set(json.loads(Path("modelos/modelo-v1.json").read_text())["clases"])

cola, resto, vistos = [], [], set()
for rid in sorted(pubs):
    p, r = pubs[rid], preds[rid]
    if r["sha256"] in en_dataset or r["sha256"] in vistos:
        print(f"{rid}  fuera: ya está en el dataset")
        continue
    vistos.add(r["sha256"])
    if p["categoria_vendedor"] not in conocidas:
        motivo = "nueva"
    elif r["pred"] != p["categoria_vendedor"]:
        motivo = "desacuerdo"
    elif r["confianza"] < cfg["umbral"]:
        motivo = "incierta"
    else:
        resto.append(rid)
        continue
    cola.append((rid, motivo))

PRIORIDAD = {"nueva": 0, "desacuerdo": 1, "incierta": 2}
cola.sort(key=lambda x: PRIORIDAD[x[1]])
random.seed(0)
azar = [(rid, "azar") for rid in random.sample(resto, min(cfg["azar"], len(resto)))]
cupo = cfg["presupuesto"] - len(azar)                      # el azar tiene cupo propio: no es lo primero que se recorta
for rid, motivo in cola[cupo:]:
    print(f"{rid}  {motivo}, pero no alcanza el presupuesto")
cola = cola[:cupo] + azar

with Path("prod/cola.csv").open("w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["request_id", "archivo", "sha256", "motivo"])
    for rid, motivo in cola:
        w.writerow([rid, pubs[rid]["archivo"], preds[rid]["sha256"], motivo])
        shutil.copy(Path("prod/entrantes") / pubs[rid]["archivo"], Path("data/crudo") / pubs[rid]["archivo"])
for rid, motivo in cola:
    print(f"{rid}  {motivo}")
print(f"\n{len(cola)} a etiquetar (presupuesto {cfg['presupuesto']}) · copiadas a data/crudo/")
terminal
python scripts/seleccionar.py
Salida real
req_011  fuera: ya está en el dataset
req_007  incierta, pero no alcanza el presupuesto
req_001  nueva
req_002  nueva
req_003  nueva
req_004  nueva
req_005  nueva
req_006  nueva
req_010  desacuerdo
req_009  azar

8 a etiquetar (presupuesto 8) · copiadas a data/crudo/
terminal
python scripts/validar.py

El contrato ya no pasa: hay 8 fotos sin label. Es lo correcto: no se versiona nada hasta etiquetar.

Salida real
✓ una fila por foto         10 filas, 10 fotos distintas
✓ cada label tiene foto     todas en data/crudo/
✗ cada foto tiene label     sin label: ['req_001.png', 'req_002.png', 'req_003.png', 'req_004.png', 'req_005.png', 'req_006.png', 'req_009.png', 'req_010.png']
✓ 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: 8 a etiquetar: 6 nuevas, 1 desacuerdo y 1 al azar; req_011 fuera por repetida y req_007 fuera por presupuesto.
Ojo: req_011 es una foto de tu test que alguien volvió a subir con otro nombre. Si la etiquetas y cae en train, el modelo ve el examen. El hash la atrapa.
4La guía v2La clase nueva en la guía, con sus casos límite, y en la interfaz.

La clase nueva entra primero a la guía y luego a la interfaz. guia-v1.md se queda como está: los labels del lote inicial siguen diciendo v1. La sección «Cambios» explica qué cambió y por qué.

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

**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. |
| `bolsa` | Bolsa de mano, de hombro, mochila chica o cartera con asa. | Maletas de viaje, estuches. |

## 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`.
- **Bolsa con forma de playera o de tenis (estampado, llavero):** manda la función: es `bolsa`.
- **Lo que eligió quien vende no cuenta:** etiqueta lo que ves en la foto.
- **No se ve bien o no es ninguna:** no adivines; usa *Skip* y anótalo en la bitácora.

## Cambios

- v2 (2026-11): negocio agrega `bolsa`; regla de función sobre forma; recordatorio de ignorar la categoría de quien vende.
- v1 (inicial): dos categorías.
guia/interfaz-v2.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"/>
    <Choice value="bolsa"/>
  </Choices>
</View>
terminal
diff guia/interfaz-v1.xml guia/interfaz-v2.xml

diff sale con código 1 cuando encuentra diferencias: aquí es lo esperado.

Salida real
5a6
>     <Choice value="bolsa"/>
Listo cuando: La guía v2 dice qué es bolsa y qué no, y su sección de cambios lo explica.
Ojo: «Etiqueta lo que ves, no lo que eligió quien vende» parece obvio. Sin esa regla, quien etiqueta copia el error de req_010.
5Etiquetar el lote del mesLas 8 fotos de la cola etiquetadas y exportadas a data/etiquetado/export-v2.json.

Un proyecto nuevo en Label Studio, c01-v2, igual que en v1 pero con interfaz-v2.xml. En Data Import sube solo las 8 fotos de la cola: están en prod/cola.csv y ya copiadas en data/crudo/. Tecla 3 para bolsa. Exportas igual que en v1, ahora del proyecto 2.

terminal
curl -s "http://localhost:8091/api/projects/2/export?exportType=JSON_MIN" \
  -H "Authorization: Token practica-token-local-0001" -o data/etiquetado/export-v2.json
python -c 'import json; [print(t["image"], t["clase"]) for t in json.load(open("data/etiquetado/export-v2.json"))]'
Salida real
/data/upload/2/5b40a97a-req_001.png bolsa
/data/upload/2/f02a54b9-req_002.png bolsa
/data/upload/2/19d04c8a-req_003.png bolsa
/data/upload/2/7cd19e17-req_004.png bolsa
/data/upload/2/31c83039-req_005.png bolsa
/data/upload/2/9fe362d9-req_006.png bolsa
/data/upload/2/27b0bb0f-req_010.png tenis
/data/upload/2/ba7555db-req_009.png tenis
Listo cuando: 8 filas: 6 bolsas, y req_009 y req_010 como tenis.
Ojo: req_010: quien vende eligió playera y el modelo dijo tenis. Ganó el modelo, y ahora un label humano lo confirma.
6Juntar, dividir y validarEl lote del mes dentro de labels.csv y splits.csv, con el contrato en verde.

Agrega "bolsa" a clases en config.json y corre los mismos tres scripts de la práctica anterior, sin cambiarles nada. etiquetas.py suma el lote 2026-11 con la guía v2, dividir.py respeta los splits viejos y reparte las nuevas, y el contrato vuelve a verde.

terminal
python scripts/etiquetas.py data/etiquetado/export-v2.json v2 2026-11
Salida real
8 labels nuevos · 18 en total
terminal
python scripts/dividir.py
Salida real
bolsa    train 4 · val 1 · test 1
playera  train 3 · val 1 · test 1
tenis    train 5 · val 1 · test 1
terminal
python scripts/validar.py
Salida real
✓ una fila por foto         18 filas, 18 fotos distintas
✓ cada label tiene foto     todas en data/crudo/
✓ cada foto tiene label     ninguna sin etiquetar
✓ clases conocidas          playera, tenis, bolsa
✓ cada foto en un split     train, val o test
✓ cada clase en cada split  sí

train 12 · val 3 · test 3
Listo cuando: 18 fotos, seis ✓, y bolsa con train 4 · val 1 · test 1.
Ojo: Mira dividir: playera y tenis tienen las mismas val y test que en v1, y todas sus fotos nuevas fueron a train. Bolsa estrena su foto de val y su foto de test.
7Versionar dataset-v2dataset-v2 con tag y en el remoto, y la diferencia exacta con v1.

Igual que v1: actualiza la ficha, dvc add, commit, tag y push. Luego mira qué cambió entre versiones: dvc diff para las fotos y git diff para todo lo demás.

DATASET.md
# Ficha del dataset · c01-categorias

| | |
|---|---|
| **Versión** | `dataset-v2` |
| **Para qué** | Entrenar y evaluar el clasificador de categorías de las fotos del marketplace. |
| **Fotos** | 18 PNG de 112 × 112 px, en `data/crudo/` (DVC, remoto `s3://datasets/c01`). |
| **Clases** | `playera` 5 · `tenis` 7 · `bolsa` 6 |
| **Splits** | train 12 · val 3 · test 3. El test de v1 sigue igual; `bolsa` estrena 1 de val y 1 de test. |
| **Labels** | `data/labels.csv`: lote `inicial` con la guía v1 y lote `2026-11` con la guía v2. |
| **Fuente** | `inicial`: Fashion-MNIST. `2026-11`: publicaciones del marketplace elegidas por `scripts/seleccionar.py` (nueva, desacuerdo, azar). |
| **Datos personales** | Ninguno: solo entran al dataset las fotos elegidas para etiquetar; el resto se queda en `prod/`. |

## Límites conocidos

- `bolsa` tiene 4 fotos de train: según el precálculo, el modelo reconocería ≈ 3 de cada 4 bolsas nuevas.
- La cola del mes viene de lo que el modelo v1 vio en producción: no es una muestra uniforme. El cupo al azar existe para medir eso.

## Cambios

- `dataset-v2` (2026-11): +8 fotos de producción, clase nueva `bolsa`, guía v2.
- `dataset-v1`: versión inicial.
terminal
dvc add data/crudo -q && git add -A && git status --short
Salida real
M  DATASET.md
M  config.json
M  data/crudo.dvc
A  data/etiquetado/export-v2.json
M  data/labels.csv
M  data/splits.csv
A  guia/guia-v2.md
A  guia/interfaz-v2.xml
A  mundo/cuantas_bolsas.py
A  mundo/llegan.py
A  scripts/predecir.py
A  scripts/seleccionar.py
terminal
git commit -m "dataset v2: +8 fotos de noviembre, clase bolsa, guía v2"
git tag -a dataset-v2 -m "18 fotos · 3 clases"

La salida depende de tu corrida.

terminal
dvc push
Salida real
9 files pushed
terminal
dvc diff dataset-v1 dataset-v2
Salida real
Added:
    data/crudo/req_001.png
    data/crudo/req_002.png
    data/crudo/req_003.png
    data/crudo/req_004.png
    data/crudo/req_005.png
    data/crudo/req_006.png
    data/crudo/req_009.png
    data/crudo/req_010.png

Modified:
    data/crudo/

files summary: 8 added
terminal
git diff --stat dataset-v1 dataset-v2 -- data guia config.json DATASET.md
Salida real
 DATASET.md                     | 19 +++++-----
 config.json                    |  2 +-
 data/crudo.dvc                 |  6 ++--
 data/etiquetado/export-v2.json | 82 ++++++++++++++++++++++++++++++++++++++++++
 data/labels.csv                |  8 +++++
 data/splits.csv                |  8 +++++
 guia/guia-v2.md                | 24 +++++++++++++
 guia/interfaz-v2.xml           |  8 +++++
 8 files changed, 144 insertions(+), 13 deletions(-)
terminal
git diff dataset-v1 dataset-v2 -- data/splits.csv | grep -c "^-[0-9a-f]"

Cuenta las líneas de splits.csv que se borraron o cambiaron entre las dos versiones.

Salida real
0
Listo cuando: dvc diff muestra 8 fotos agregadas y 0 líneas cambiadas en los splits.
Ojo: dvc push subió 9 archivos: las 8 fotos nuevas y el índice nuevo de la carpeta. Las 10 de v1 ya estaban en el remoto y no se vuelven a subir.
8Reentrenar y compararmodelo-v2 con dataset-v2, y los dos modelos frente al mismo test.

entrenar.py sin cambios, ahora con dataset-v2. Luego evaluar.py pone a los dos modelos frente al test de una versión, clase por clase. Versiona evaluar.py antes de usarlo y la ficha de modelo-v2 al final.

scripts/evaluar.py
"""Comparar modelos con el MISMO test de una versión, clase por clase.

Uso: python scripts/evaluar.py <tag> <modelo> [<modelo> ...]   p. ej. dataset-v2 modelo-v1 modelo-v2
"""
import sys

import joblib

from comun import cargar

tag, modelos = sys.argv[1], sys.argv[2:]
X, y = cargar(tag, "test")
clases = sorted(set(y))
ancho = max(len(m) for m in modelos + [f"test de {tag}"]) + 2
print(f"test de {tag}".ljust(ancho) + "".join(f"{c:>9}" for c in clases))
for nombre in modelos:
    pred = joblib.load(f"modelos/{nombre}.joblib").predict(X)
    print(nombre.ljust(ancho) + "".join(f"{f'{(pred[y == c] == c).sum()}/{(y == c).sum()}':>9}" for c in clases))
terminal
git add scripts/evaluar.py && git commit -m "scripts para evaluar"

La salida depende de tu corrida.

terminal
python scripts/entrenar.py modelo-v2 dataset-v2
Salida real
{
  "modelo": "modelo-v2",
  "dataset": "dataset-v2",
  "commit": "6c4356f",
  "clases": [
    "bolsa",
    "playera",
    "tenis"
  ],
  "n": {
    "train": 12,
    "val": 3,
    "test": 3
  },
  "aciertos": {
    "val": "3/3",
    "test": "3/3"
  }
}
terminal
python scripts/evaluar.py dataset-v2 modelo-v1 modelo-v2
Salida real
test de dataset-v2      bolsa  playera    tenis
modelo-v1                 0/1      1/1      1/1
modelo-v2                 1/1      1/1      1/1
terminal
git add -A && git commit -m "modelo-v2 entrenado con dataset-v2"

La salida depende de tu corrida.

Listo cuando: modelo-v2 acierta la bolsa de test y no pierde nada en playera ni en tenis.
Ojo: 1/1 en bolsa es una foto: puede ser suerte, y el precálculo dice ≈ 78 %. Lo sólido es que en playera y tenis el examen fue el mismo.
9Reproducir v1Volver a entrenar modelo-v1 con tu carpeta en v2, y ver la carpeta como estaba en v1.

La prueba final del versionado. Tu carpeta ya está en v2 y aun así reproduces modelo-v1, porque entrenar.py lee dataset-v1 por su tag. Y si quieres ver la carpeta como estaba, git checkout + dvc checkout la regresan y la traen de vuelta.

terminal
python scripts/entrenar.py modelo-v1-repro dataset-v1 | tail -9
Salida real
    "train": 6,
    "val": 2,
    "test": 2
  },
  "aciertos": {
    "val": "2/2",
    "test": "2/2"
  }
}
terminal
python scripts/evaluar.py dataset-v1 modelo-v1 modelo-v1-repro modelo-v2
Salida real
test de dataset-v1    playera    tenis
modelo-v1                 1/1      1/1
modelo-v1-repro           1/1      1/1
modelo-v2                 1/1      1/1
terminal
git checkout -q dataset-v1 && dvc checkout && ls data/crudo | wc -l

Quedas en «detached HEAD»: estás mirando un tag, no una rama. Es normal.

Salida real
M       data/crudo/
      10
terminal
git checkout -q main && dvc checkout && ls data/crudo | wc -l
Salida real
M       data/crudo/
      18
Listo cuando: modelo-v1-repro saca lo mismo que modelo-v1, y la carpeta pasa de 18 a 10 fotos y de regreso.
Ojo: Es lo que te van a preguntar cuando un modelo falle en producción: «¿con qué datos se entrenó y puedes reproducirlo?». Con linaje, la respuesta es un comando.

Hazlo solo

6 objetivos

Desde tu carpeta de «Antes de producción» con dataset-v1 y modelo-v1, sin abrir esta página.

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

01Un log de predicción con hash, clase, confianza y versión del modelo.
02Una cola con presupuesto 8 que saca la foto repetida, prioriza la clase nueva y guarda un cupo al azar.
03La guía v2 con la clase nueva y su sección de cambios.
04dataset-v2 con tag, sin mover ninguna foto del test de v1 (compruébalo con git diff).
05modelo-v2 comparado con modelo-v1 clase por clase en el mismo test.
06modelo-v1 reproducido desde su tag con tu carpeta en v2.
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 **En producción: el reentrenamiento**. Ya la hice guiada; ahora la rehago sin ver la guía, en la carpeta de mi «Hazlo solo» anterior (`~/dojo/c01-desde-cero-2`, con `dataset-v1` y `modelo-v1`). Puedo copiar `mundo/llegan.py` y `mundo/cuantas_bolsas.py` de la guía: son el mundo.

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. Un log de predicción de `modelo-v1` sobre las publicaciones del mes, con hash, clase, confianza y versión del modelo.
2. Una cola con presupuesto 8 que saca la foto repetida, prioriza la clase nueva y guarda un cupo al azar.
3. La guía v2 con la clase nueva y su sección de cambios.
4. `dataset-v2` con tag, sin mover ninguna foto del test de v1 (demostrado con `git diff`).
5. `modelo-v2` comparado con `modelo-v1` clase por clase en el mismo test.
6. `modelo-v1` reproducido desde su tag con mi carpeta en v2.

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