Dojo / C01 · 1 · proyecto con kit
← Datos y etiquetas C01 · Estación 1 · Proyecto con kit

El proyecto con kit

El kit trae una empresa ficticia funcionando: un marketplace que lleva 60 días con el clasificador en producción. Tú no escribes el mundo; lo corres y construyes encima el pipeline de la estación.

Estás aquí1 · Datos y etiquetas
Camino 3 de 3 · después de las prácticas

Qué es

En producciónCon kit · 4 rondas

Una empresa ficticia que lleva 60 días con el clasificador en producción: tráfico de verdad (24,000 requests), quienes venden, moderación, un proveedor de etiquetado y un golden set. Tú construyes encima el pipeline del mes: juntar señales, filtrar el label débil, elegir qué etiquetar con un presupuesto, mandar al proveedor, medir el acuerdo y cerrar el mes. Y lo practicas en las cuatro rondas: con ayuda, solo y con fallas.

La práctica 2 y este proyecto son el mismo momento: el mes con el modelo ya en producción. En la práctica 2 lo escribes todo tú con 11 publicaciones y sin nada armado; aquí el mundo ya existe, es más grande y además se rompe. Los conceptos y el repaso son de toda la estación: están en su página.

Precálculos

7 cuentas · del proyecto con kit

Antes de escribir código, las cuentas de servilleta de la estación: cuánto entra, cuántos labels te quedan, cuánto cuesta etiquetar y qué tan fino puedes medir. Cada cuenta va en las dos escalas, C01 y el dojo, con lo que decides con ella. Haz la cuenta tú primero y luego ábrela. Las del dojo se comprueban con un comando sobre el mundo recién creado o con la salida de un paso del Guiado.

1

¿Cuánto pesa guardar el log de todas las predicciones?

bytes al día = requests al día × bytes por fila ÷ compresión de Parquet
Ver la cuenta
En C0110 M requests/día × ≈ 300 B ≈ 3 GB/día en crudo; Parquet lo comprime 5–10 veces≈ 0.3–0.6 GB al día
En el dojo24,000 filas en 60 particiones, una por día≈ 2.1 MB: ≈ 90 B por fila ya comprimida

Qué decides con esto. Guardar el 100 % de las predicciones cuesta casi nada, y lo necesitas para unir con la publicación y medir drift. Partes por fecha (fecha=…/) para que cada consulta lea solo sus días. Lo que sí se cuida es qué guardas: request_id y hash, nunca los bytes de la foto ni datos personales.

terminal · compruébalo en el kit
du -sh data/logs && ls data/logs | wc -l
Salida real
2.1M	data/logs
      60
2

¿Cuántas sugerencias aceptadas te deja la regla de maduración?

maduras ≈ publicaciones × (días del periodo − días de maduración) ÷ días del periodo × tasa de aceptación
Ver la cuenta
En C01≈ 2.5 M publicaciones/día; cada día que cumple 30 días se suma a la ventana≈ 75 M publicaciones maduras por mes: sobran
En el dojo24,000 × (60 − 30) ÷ 60 × 0.88≈ 10.6k (el paso 2 da 10,586)

Qué decides con esto. Con 30 días de maduración solo cuenta la primera mitad del periodo: todo octubre queda fuera, incluida la llegada de «bolsas». Los labels débiles sobran; lo escaso son los que no dependen de tu modelo (holdout, golden set y la cola humana). Por eso esos se cuidan y se pagan.

terminal · compruébalo en el kit
python -c "import duckdb; print(duckdb.sql(\"SELECT count(*) AS total, count(*) FILTER (ts_publicacion <= TIMESTAMPTZ '2026-10-01 00:00:00+00') AS maduras, round(avg((eleccion LIKE 'sugerencia_%')::INT), 3) AS acepta FROM 'data/producto/publicaciones.parquet'\"))"

2026-10-01 es el corte (2026-10-31) menos los 30 días de config.yaml, en UTC.

Salida real
┌───────┬─────────┬────────┐
│ total │ maduras │ acepta │
│ int64 │  int64  │ double │
├───────┼─────────┼────────┤
│ 24000 │   11996 │  0.883 │
└───────┴─────────┴────────┘
3

¿Cuántos labels sin sesgo te da el holdout, y con cuánto error mide?

labels ≈ publicaciones × fracción de cuentas · error al 95 % ≈ ± 1.96 × √(p × (1 − p) ÷ n)
Ver la cuenta
En C011 % de ≈ 2.5 M publicaciones/día≈ 25k al día, ≈ 750k al mes
En el dojo20 cuentas de 2,000; esperas 1 % de 24,000 = 240. Con p ≈ 0.81 y n = 253253 labels, exactitud con ± 4.8 pp

Qué decides con esto. El holdout mide sin sesgo, pero cuesta producto: esas cuentas no ven sugerencias. Por eso se queda en 1 %. En C01 sobra volumen; en el dojo, 253 alcanzan para ver un cambio grande, no uno de 2 pp. Salieron 253 y no 240 porque unas cuentas publican más que otras.

terminal · compruébalo en el kit
python -c "import duckdb; print(duckdb.sql(\"SELECT count(DISTINCT cuenta_id) AS cuentas, count(*) AS publicaciones FROM 'data/producto/publicaciones.parquet' WHERE eleccion = 'manual'\"))"
Salida real
┌─────────┬───────────────┐
│ cuentas │ publicaciones │
│  int64  │     int64     │
├─────────┼───────────────┤
│      20 │           253 │
└─────────┴───────────────┘
4

¿Cuánto cuesta la cola humana del mes?

anotaciones = tamaño de la cola × (1 + fracción doble) · costo = anotaciones × precio por etiqueta
Ver la cuenta
En C015k × US$0.05–0.10 ≈ US$250–500, más 500 dobles × el mismo precio ≈ US$25–50≈ US$275–550 al mes
En el dojo800 × (1 + 0.20) = 960 anotaciones960; gratis porque el proveedor es simulado (al precio de C01 serían ≈ US$48–96)

Qué decides con esto. El presupuesto fija el tamaño de la cola, no al revés: primero cuántas puedes pagar, después cómo repartirlas. Cada doble anotación cuesta una etiqueta completa; es el precio de saber si tu guía funciona.

Lo confirmas en Paso 5 · Escribir la guía y mandar al proveedor: tareas: 800 · dobles: 160 y 960 anotaciones.

5

¿Cómo se reparte la cola, y qué tan bien mide la parte aleatoria?

cupo = tamaño de la cola × fracción · error de la parte aleatoria ≈ ± 1.96 × √(p × (1 − p) ÷ n)
Ver la cuenta
En C015k × 25 % = 1,250 al azar; con p ≈ 0.85± 2 pp sobre la exactitud en tráfico real
En el dojo800 × 0.25 = 200 al azar, × 0.10 = 80 nuevas, × 0.15 = 120 desacuerdos; el resto, 400, por incertidumbre. Con p ≈ 0.8 y n = 200± 5.5 pp

Qué decides con esto. La parte aleatoria es la única que se parece al tráfico, así que es la que te dice la exactitud real; el resto apunta a donde el modelo duda y sale peor a propósito. Si necesitas medir con ± 2 pp, el tamaño de la parte aleatoria sale de la fórmula, no del gusto.

Lo confirmas en Paso 3 · Elegir qué etiquetar: incertidumbre 400 · aleatoria 200 · desacuerdo 120 · nueva 80.

6

¿Alcanzan las dobles anotaciones para un kappa por clase?

dobles = tamaño de la cola × fracción doble · por clase ≈ dobles ÷ número de clases
Ver la cuenta
En C015k × 10 % = 500 dobles ÷ 500 categorías≈ 1 por categoría al mes
En el dojo800 × 20 % = 160 ÷ 10 clases; con 10 % serían 80 ÷ 10 = 8≈ 16 por clase (en la corrida de referencia, entre 7 y 28)

Qué decides con esto. Un kappa por clase con 1 u 8 ejemplos es ruido. En el dojo se subió la doble anotación a 20 %, y verificar.py solo exige kappa ≥ 0.6 en las clases con al menos 10 positivos. La cola se carga hacia las clases que se confunden, por eso camisas y suéteres juntan más. En C01, con ≈ 1 por categoría al mes, el kappa por clase se acumula varios meses o se mide en las categorías grandes y en los pares que se confunden.

Lo confirmas en Paso 5 (dobles: 160) y paso 6 (kappa_por_clase).

7

¿Qué tan fino mide el golden set?

error al 95 % ≈ ± 1.96 × √(p × (1 − p) ÷ n), global y por clase
Ver la cuenta
En C0150k con p ≈ 0.85; piso de 50 por categoría± 0.3 pp global; ≈ ± 10 pp en una categoría con 50
En el dojo500 con p ≈ 0.73; 50 por clase± 3.9 pp global; ≈ ± 12 pp por clase

Qué decides con esto. El golden set decide si un modelo pasa, así que su tamaño fija la diferencia más chica que puedes ver: en C01, 0.3 pp; en el dojo, solo cambios grandes. Por clase el ruido es enorme, por eso el gate por clase pide caídas grandes y un test pareado. Y como vale tanto, nunca entra a entrenamiento ni a la cola.

terminal · compruébalo en el kit
python -c "import duckdb; print(duckdb.sql(\"SELECT count(*) AS n, count(DISTINCT clase) AS clases FROM 'data/golden/manifest.parquet'\"))"
Salida real
┌───────┬────────┐
│   n   │ clases │
│ int64 │ int64  │
├───────┼────────┤
│   500 │     10 │
└───────┴────────┘

El kit

el mundo

El kit trae una empresa ficticia funcionando: un marketplace que lleva 60 días con el clasificador en producción. Tú no escribes el mundo; lo corres y construyes encima el pipeline de la estación.

Bajar el kit .zip · 54 KB
arranque
unzip c01-datos.zip && cd c01-datos
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
docker compose up -d
python mundo/reiniciar.py --todo

Lo que trae no lo escribes

Simulador del marketplace (mundo/simular.py). 24 000 requests en 60 días con logs del gateway, publicaciones, correcciones de moderación y fotos con EXIF. Trae el feedback loop a propósito y la categoría «bolsas», que negocio agrega el 1 de octubre.

El modelo en producción (mundo/modelo.py). Una regresión logística entrenada con 9 de las 10 categorías de Fashion-MNIST. Acierta ≈ 81 % en el holdout.

S3 local y Label Studio (compose.yaml). RustFS en http://localhost:9100 (consola en :9101) y Label Studio 1.23.2 en http://localhost:8090 con un usuario de prueba.

Proveedor de etiquetado (mundo/anotar.py). Dos equipos simulados que etiquetan según tu guía: se equivocan poco en lo obvio y mucho donde la guía no decide.

El job del mes (mundo/correr.py). Corre los 7 pasos en orden, como el orquestador en producción, y deja el registro en data/pipeline/corrida.log.

verificar.py y romper.py. 12 checks con los criterios de «listo» y 10 fallas que se meten en el mundo, la configuración o la infraestructura, nunca en tu código.

Tu pipeline lo escribes tú

  1. pipeline/1_senales.pyUne logs, publicaciones y la última corrección por request_id.
  2. pipeline/2_debil.pyFiltra los labels débiles y separa el holdout.
  3. pipeline/3_cola.pyElige las 800 fotos del mes: nuevas, desacuerdos, incertidumbre y 25 % al azar.
  4. pipeline/4_copiar.pyCopia solo esas fotos, sin EXIF, a un bucket privado que vence a los 90 días.
  5. pipeline/5_proveedor.py + guia/guia-v2.mdEscribe la guía con los casos límite y manda las tareas al proveedor.
  6. pipeline/6_acuerdo.pyCalcula el kappa por clase y prepara los desacuerdos para Label Studio.
  7. pipeline/7_cierre.pyJunta los labels del mes, protege el golden set y escribe el reporte.

Necesitas

  • Docker Desktop con ≈ 3 GB libres para las dos imágenes
  • Python 3.11 o más nuevo (probado con 3.13)
  • ≈ 300 MB para los datos que genera el simulador
  • Los puertos 9100, 9101 y 8090 libres en tu Mac
En la nubeEn el dojoQué cambia
Amazon S3RustFS 1.0.4Misma API de S3 (boto3 y aws-cli con --endpoint-url). En AWS los permisos son de IAM, los buckets nuevos ya traen el bloqueo de acceso público activado y cifrado por defecto, y el vencimiento del lifecycle se aplica de forma asíncrona; aquí hay un solo usuario.
Logs en s3://…/logs/data/logs/ en tu discoMismo Parquet particionado por fecha; DuckDB leería igual desde S3 con su extensión httpfs.
Etiquetadores o proveedor gestionadomundo/anotar.pyUn proveedor real tarda días y cobra; el simulado responde al instante y se equivoca según tu guía.
Orquestador (Argo Workflows, Airflow)mundo/correr.pyCorre los pasos en orden y se detiene en el primer error, sin reintentos ni calendario.
El CLAUDE.md del kitLas reglas de fondo para Claude Code en la carpeta del kit. Ya viene en el .zip.
Ver el CLAUDE.md
# Dojo de entrenamiento ML engineer's · C01 · Estación 1: Datos y etiquetas

Este es un mini-proyecto de práctica. Reproduce en pequeño, en una Mac sin GPU ni nube, la estación «Datos y etiquetas» del caso C01: un clasificador de categorías para las fotos de un marketplace. La persona con la que trabajas está aprendiendo; tu trabajo es que **entienda**, no que termine rápido.

## Qué es cada cosa

| Carpeta o archivo | Qué es | ¿Se toca? |
|---|---|---|
| `mundo/` | La empresa ficticia: el simulador del marketplace, el modelo en producción, el proveedor de etiquetado y el reinicio. | **No.** Es el mundo; solo se corre. |
| `pipeline/` | El pipeline de la estación, en 7 archivos con su contrato (entradas, salidas) arriba. | **Sí.** Es lo que la persona construye. |
| `config.yaml` | Los parámetros del pipeline (ventana, umbrales, tamaño de la cola, buckets). | Sí, cuando un paso o un arreglo lo pide. |
| `guia/` | La guía de etiquetado, versionada (`guia-v1.md`, `guia-v2.md`…). | Sí: escribirla es parte del paso 5. |
| `verificar.py` | Revisa los criterios de «listo» de la estación, uno por check. | No. |
| `romper.py` | Mete fallas en el mundo, la configuración o la infraestructura. Nunca en `pipeline/`. | No. Y no leas su código para adivinar una falla escondida. |
| `GUION.md` / `OBJETIVOS.md` | El paso a paso (ronda Guiado) y solo los objetivos (ronda Solo). | No. |
| `data/` | Lo que generan el mundo y el pipeline. Se puede borrar: `python mundo/reiniciar.py`. | No a mano. |
| `taller/progreso.json`, `BITACORA.md` | El registro de las rondas. | Sí, tú los mantienes (ver abajo). |

## Cómo se trabaja

- Responde en español neutro, de tú, directo y sin relleno. Términos técnicos en inglés la primera vez, con su explicación.
- Explica siempre el **por qué**. Cuando algo en el dojo es distinto de C01 real (escala, servicio local en vez de AWS), dilo en una línea.
- Nunca inventes salidas: si no corriste algo, di que no lo corriste.
- Antes de correr algo que cambia el mundo (`mundo/*.py`, `docker compose`, `romper.py`), dilo. Lo que solo lee (`verificar.py`, consultas a `data/`) se puede correr directo.
- Sigue el prompt de la ronda que te pegaron: en Solo y en Romper solo **no das código ni pistas** salvo que la persona se rinda.

## Bitácora

Al terminar cada paso, objetivo o falla, agrega una entrada en los dos archivos:

`BITACORA.md`, legible en el celular:

```markdown
## 2026-11-03 · Guiado · paso 2 (filtrar el label débil)
- **Hice:** …
- **Medí:** … (números reales que salieron)
- **Sigue:** …
```

`taller/progreso.json`, el que se importa en el tablero de la app. Agrega a `bitacora` y actualiza `rondas`:

```json
{
  "estacion": "c01-datos",
  "rondas": {
    "guiado":       { "estado": "en_curso", "inicio": "2026-11-03", "fin": null },
    "solo":         { "estado": "pendiente", "inicio": null, "fin": null, "pistas": 0 },
    "romper_ayuda": { "estado": "pendiente", "fallas": [] },
    "romper_solo":  { "estado": "pendiente", "fallas": [] }
  },
  "bitacora": [
    { "fecha": "2026-11-03", "ronda": "guiado", "paso": "debil", "hice": "…", "medi": "…", "sigue": "…" }
  ]
}
```

- `estado` es `pendiente`, `en_curso` o `hecha`.
- Cada falla se anota como `{ "id": "…", "minutos": 0, "pistas": 0, "rendido": false, "aprendi": "…" }`.
- Usa la fecha real de hoy. Mantén el JSON válido: después de editarlo, corre `python -m json.tool taller/progreso.json > /dev/null`.

Las cuatro rondas

los prompts

Cada ronda se trabaja en una sesión de Claude Code abierta en la carpeta del kit. Copia el prompt de la ronda y pégalo; el CLAUDE.md del kit pone las reglas de fondo.

ConstruirRomper Con ayuda
1 · GuiadoConstruyen juntos los pasos 0 a 7.
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 1 de C01, «Datos y etiquetas», ronda **Guiado**.

Lee primero `CLAUDE.md` y `GUION.md` de esta carpeta. Vamos a construir juntos `pipeline/` siguiendo los pasos 0 a 7 de `GUION.md`, uno por uno.

Entre el paso 0 y el 1, `GUION.md` trae los «Precálculos»: las cuentas de servilleta de la estación. Pídeme que haga cada cuenta yo primero, corrígeme y después corre el comando que la comprueba (o dime en qué paso se va a confirmar).

En cada paso:
1. Dime el objetivo en una línea y explícame los conceptos que usa, rápido y con el término en inglés.
2. Escribe conmigo el código del paso. Explica cada bloque, sin hacer todo de golpe.
3. Dime qué comando correr y espera a que te pegue la salida. No corras tú los comandos que cambian el mundo (`mundo/*`, `docker compose`) sin decírmelo antes.
4. Corre `python verificar.py <check del paso>` y explícame el resultado.
5. Anota el paso en la bitácora (sección «Bitácora» de `CLAUDE.md`) con lo que hice, lo que medí y lo que sigue.

Si algo falla, no lo arregles en silencio: muéstrame el error, dime tu hipótesis y arreglémoslo juntos. Al final, corre `python verificar.py` completo y cierra la ronda en `taller/progreso.json`.
3 · Romper con ayudaUna falla real; Claude te guía con preguntas.
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 1 de C01, «Datos y etiquetas», ronda **Romper con ayuda**.

Lee `CLAUDE.md`. Mi pipeline ya pasa `python verificar.py` completo. Ahora:

1. Corre `python romper.py --mostrar` (o `--mostrar --falla <id>` si te digo cuál). El script mete la falla en el mundo, la configuración o la infraestructura, nunca en mi código, y te dice cuál fue. **A mí no me la digas.** Solo cuéntame la «historia»: qué notó alguien en la empresa.
2. Corre de nuevo mi pipeline y `python verificar.py`, y pregúntame qué veo.
3. Guíame con preguntas en este orden: síntoma → hipótesis (pídeme al menos dos) → el comando que confirma o descarta cada una → el arreglo. Si me atoro más de dos veces en el mismo punto, dame una pista concreta y anótala.
4. Cuando lo arregle, corre `python romper.py --verificar`. Ahí sí explícame qué era, el concepto detrás y cómo se vería en C01 de verdad.
5. Anota en la bitácora la falla, cuánto tardé, cuántas pistas usé y qué aprendí.

Repetimos con otra falla hasta que te diga que paremos. Para volver al estado sano entre fallas: `python romper.py --restaurar`.
Solo
2 · SoloRehaces todo desde el esqueleto, solo con los objetivos.
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 1 de C01, «Datos y etiquetas», ronda **Solo**.

Lee `CLAUDE.md`. **No leas `GUION.md`** y no me des código: esta ronda la construyo yo desde cero, con `OBJETIVOS.md` como única guía.

Tu papel:
- Primero corre `python mundo/reiniciar.py --pipeline` para dejar `pipeline/` como esqueleto vacío (el mundo se queda igual). Confírmame qué se borró.
- Cuando te lo pida, corre `python verificar.py` (o un check suelto) y dime qué objetivo pasa y cuál no, con el mensaje exacto.
- Si te pregunto algo, contéstame con una pregunta que me acerque o con el concepto, nunca con el código ni con el arreglo. Si te digo «me rindo en este objetivo», ahí sí explícamelo, y anótalo en la bitácora como pista usada.
- Anota en la bitácora cada objetivo cuando pase: cuánto tardé y qué me costó.

Al final, con `python verificar.py` en verde, cierra la ronda en `taller/progreso.json`.
4 · Romper soloUna falla escondida; nadie te da pistas.
Ver el prompt
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 1 de C01, «Datos y etiquetas», ronda **Romper solo**.

Lee `CLAUDE.md`. Mi pipeline ya pasa `python verificar.py` completo.

1. Corre `python romper.py` **sin** `--mostrar`. El script elige una falla al azar y la esconde; tú tampoco sabes cuál es, así que no intentes averiguarla leyendo `romper.py`, `.taller/` ni los datos que cambió.
2. Dime solo la «historia» que imprime y la hora de inicio. Desde ahí trabajo yo solo: no me des pistas ni hipótesis. Si te pido correr un comando, córrelo y pégame la salida tal cual, sin interpretarla.
3. Cuando te diga «listo», corre `python romper.py --verificar`. Dime si quedó arreglado, cuál era la falla y cuánto tardé. Si no quedó, dime solo que no y sigo.
4. Si te digo «me rindo», corre `python romper.py --revelar` y explícame la falla completa.
5. Anota en la bitácora la falla, el tiempo, si me rendí y qué haría distinto.

Guiado

paso a paso

El código que ves es el de la solución de referencia, y cada salida es la que salió al correr el kit en una carpeta limpia.

0Levantar el mundoTener corriendo el S3 local, Label Studio y los 60 días de datos del marketplace.

Antes de escribir una línea del pipeline necesitas lo que en C01 ya existe: el gateway lleva semanas escribiendo logs y las fotos viven en S3. compose.yaml levanta dos contenedores (el S3 local y Label Studio) y mundo/reiniciar.py --todo crea el esqueleto de tu pipeline, tu config.yaml y genera el mundo: descarga Fashion-MNIST, entrena el modelo en producción y sube 24 500 fotos.

terminal
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

Un entorno aislado con las versiones fijadas del dojo.

La salida depende de tu corrida.

terminal
docker compose up -d

Si un puerto está ocupado, cambia el lado izquierdo en compose.yaml («9100:9000» o «8090:8080») y la dirección que le corresponde en config.yaml (s3.endpoint o labelstudio.url).

La salida depende de tu corrida.

terminal
python mundo/reiniciar.py --todo

≈ 1–2 min. Crea pipeline/, config.yaml y el mundo. La descarga de Fashion-MNIST solo pasa la primera vez.

Salida real
  pipeline/: esqueleto de los 7 pasos
  config.yaml: valores originales
  data/pipeline y data/etiquetado: borrados
  descargando train-images-idx3-ubyte…
  descargando train-labels-idx1-ubyte…
  descargando t10k-images-idx3-ubyte…
  descargando t10k-labels-idx1-ubyte…
  entrenando el modelo en producción (9 categorías, sin Bolsas)…
  subiendo 24,500 fotos a S3 local…
✓ mundo listo: 24,000 requests en 60 días, 407 correcciones, golden set de 500
✓ listo
terminal
python verificar.py mundo

El primer check: los servicios y los datos están arriba.

Salida real
✓ mundo          S3, Label Studio y los datos del mundo están arriba

1/1 en verde
terminal
python -c "import duckdb; print(duckdb.sql(\"DESCRIBE SELECT * FROM read_parquet('data/logs/*/*.parquet', hive_partitioning = true)\"))"

Mira el esquema del log antes de usarlo: top_k es una lista de structs {clase, score} y fecha sale de la carpeta.

Salida real
┌────────────────────┬──────────────────────────────────────┬─────────┬─────────┬─────────┬─────────┐
│    column_name     │             column_type              │  null   │   key   │ default │  extra  │
│      varchar       │               varchar                │ varchar │ varchar │ varchar │ varchar │
├────────────────────┼──────────────────────────────────────┼─────────┼─────────┼─────────┼─────────┤
│ request_id         │ VARCHAR                              │ YES     │ NULL    │ NULL    │ NULL    │
│ ts                 │ TIMESTAMP WITH TIME ZONE             │ YES     │ NULL    │ NULL    │ NULL    │
│ model_version      │ VARCHAR                              │ YES     │ NULL    │ NULL    │ NULL    │
│ cuenta_id          │ INTEGER                              │ YES     │ NULL    │ NULL    │ NULL    │
│ top_k              │ STRUCT(clase TINYINT, score FLOAT)[] │ YES     │ NULL    │ NULL    │ NULL    │
│ img_sha256_prefijo │ VARCHAR                              │ YES     │ NULL    │ NULL    │ NULL    │
│ foto_key           │ VARCHAR                              │ YES     │ NULL    │ NULL    │ NULL    │
│ latencia_ms        │ FLOAT                                │ YES     │ NULL    │ NULL    │ NULL    │
│ fecha              │ DATE                                 │ YES     │ NULL    │ NULL    │ NULL    │
└────────────────────┴──────────────────────────────────────┴─────────┴─────────┴─────────┴─────────┘
Listo cuando: python verificar.py mundo en verde.
Ojo: En la consola de RustFS (http://localhost:9101, usuario taller / taller-secreto) puedes ver los buckets producto-fotos y golden-set. No toques nada de mundo/: es la empresa, no tu código.
1Juntar señalesUna tabla con una fila por request: lo que predijo el modelo, lo que eligió quien vende y la última corrección de moderación.

Tres fuentes, una llave: request_id. Con DuckDB lees los Parquet directo, sin cargarlos antes. Del log sacas el top-1, su score y el margen (top-1 menos top-2). Las correcciones pueden ser varias por anuncio: con QUALIFY row_number() … = 1 te quedas con la más reciente antes de unir, para no multiplicar filas. Logs con publicaciones van con JOIN (todo request publicado tiene log); las correcciones van con LEFT JOIN (la mayoría no tiene). Al final cuentas filas: un pipeline de datos que no cuenta, pierde datos sin enterarse.

pipeline/1_senales.py
WITH logs AS (
  SELECT request_id, ts, cuenta_id, img_sha256_prefijo, foto_key,
         top_k[1].clase AS top1,
         top_k[1].score AS top1_score,
         top_k[2].clase AS top2,
         top_k[1].score - top_k[2].score AS margen
  FROM read_parquet('data/logs/*/*.parquet', hive_partitioning = true)
),
ultima_correccion AS (   -- puede haber varias por request: cuenta solo la más reciente
  SELECT request_id, categoria_corregida, ts_correccion
  FROM read_parquet('data/producto/correcciones.parquet')
  QUALIFY row_number() OVER (PARTITION BY request_id ORDER BY ts_correccion DESC) = 1
)
SELECT l.*, p.ts_publicacion, p.categoria_final, p.eleccion, p.titulo,
       c.categoria_corregida, c.ts_correccion
FROM logs l
JOIN read_parquet('data/producto/publicaciones.parquet') p USING (request_id)
LEFT JOIN ultima_correccion c USING (request_id)

Va dentro de un COPY (…) TO 'data/pipeline/senales.parquet' (FORMAT parquet). Las listas de DuckDB empiezan en 1.

pipeline/1_senales.py
n_logs = con.execute("SELECT count(*) FROM read_parquet('data/logs/*/*.parquet')").fetchone()[0]
n_pub = con.execute("SELECT count(*) FROM 'data/producto/publicaciones.parquet'").fetchone()[0]
n, unicos = con.execute("SELECT count(*), count(DISTINCT request_id) FROM 'data/pipeline/senales.parquet'").fetchone()
print(f"logs: {n_logs:,} · publicaciones: {n_pub:,} · señales: {n:,} ({unicos:,} request_id únicos)")
print(f"se unió el {n / n_pub:.1%} de las publicaciones")

En DuckDB 2.0 las relaciones ya no tienen fetchone(): usa con.execute(…).fetchone().

terminal
python pipeline/1_senales.py
Salida real
logs: 24,000 · publicaciones: 24,000 · señales: 24,000 (24,000 request_id únicos)
se unió el 100.0% de las publicaciones
terminal
python verificar.py senales
Salida real
✓ senales        24,000 requests, una fila cada uno, con la última corrección

1/1 en verde
Listo cuando: 24 000 filas, request_id único y la corrección más reciente en cada una.
Ojo: _comun.duck() fija la sesión en UTC. No lo quites: así el resultado no depende de la zona de tu laptop.
2Filtrar el label débilQuedarte solo con los labels gratis que tienen una señal de confianza, y apartar el holdout.

No todo lo que eligió quien vende sirve. Entra con su fuente: correccion si moderación la corrigió (la última manda); busqueda si quien vende buscó la categoría en vez de aceptar la sugerencia (señal fuerte: el modelo no la convenció); sugerencia_madura si aceptó una sugerencia y pasaron 30 días sin corrección. Las sugerencias aceptadas de los últimos 30 días todavía no cuentan: aún pueden corregirse. Las cuentas del holdout (eleccion = 'manual') se van a otro archivo: nunca se mezclan con el entrenamiento.

pipeline/2_debil.py
SELECT request_id, img_sha256_prefijo,
       coalesce(categoria_corregida, categoria_final) AS label,
       CASE WHEN categoria_corregida IS NOT NULL THEN 'correccion'
            WHEN eleccion = 'busqueda' THEN 'busqueda'
            ELSE 'sugerencia_madura' END AS fuente
FROM 'data/pipeline/senales.parquet'
WHERE eleccion <> 'manual'
  AND (categoria_corregida IS NOT NULL
       OR eleccion = 'busqueda'
       OR (eleccion LIKE 'sugerencia_%'
           AND ts_publicacion <= TIMESTAMPTZ '2026-10-31T00:00:00Z' - INTERVAL 30 DAY))

El corte y los 30 días salen de config.yaml (corte, debil.dias_maduracion); aquí están escritos para leerlo fácil.

terminal
python pipeline/2_debil.py
Salida real
┌───────────────────┬───────┐
│      fuente       │   n   │
│      varchar      │ int64 │
├───────────────────┼───────┤
│ sugerencia_madura │ 10586 │
│ busqueda          │  2537 │
│ correccion        │   383 │
└───────────────────┴───────┘

holdout: 253 labels
terminal
python verificar.py debil

La auditoría usa la verdad del simulador: en una empresa sería una muestra revisada a mano.

Salida real
✓ debil          13,506 labels débiles, 2.0% mal según la auditoría

1/1 en verde
Listo cuando: Los labels coinciden con las reglas de la estación y la auditoría encuentra ≤ 3 % mal.
Ojo: Fíjate en el 2.0 %: es bajo porque el filtro funciona. Sin la regla de los 30 días entrarían sugerencias equivocadas que moderación todavía no alcanza a corregir.
3Elegir qué etiquetarLas 800 fotos del mes, cada una con su motivo: nueva, desacuerdo, incertidumbre o al azar.

El presupuesto es fijo, así que cada foto tiene que valer. Primero se sortea un 25 % al azar de todo el tráfico: si se sorteara al final, ya sin los casos difíciles, saldría optimista. Luego se llenan los cupos en orden: nuevas (categorías que negocio acaba de agregar: 10 %), luego desacuerdos (una persona eligió otra cosa que el top-1: 15 %), luego incertidumbre (top-1 < 0.5 o margen < 0.15) Lo que no alcance en un cupo se llena con incertidumbre. El holdout no se toca.

pipeline/3_cola.py
s = s.sort_values("request_id")                  # DuckDB no garantiza el orden: así la muestra es reproducible
s = s[s.eleccion != "manual"]                    # el holdout no se toca
humano = s.categoria_corregida.fillna(s.categoria_final.where(s.eleccion == "busqueda"))

grupos = {
    "nueva": s.categoria_final.isin(c["nuevas"]["clases"]) | s.categoria_corregida.isin(c["nuevas"]["clases"]),
    "desacuerdo": humano.notna() & (humano != s.top1),
    "incertidumbre": (s.top1_score < k["umbral_top1"]) | (s.margen < k["umbral_margen"]),
}

humano = lo que dijo una persona sin influencia de la sugerencia: la corrección o, si no hay, la búsqueda.

pipeline/3_cola.py
# La aleatoria primero, de todo el tráfico: si se sorteara al final, ya sin los casos difíciles, saldría optimista.
elegidos = dict.fromkeys(rng.choice(s.request_id.to_numpy(), cupo["aleatoria"], replace=False), "aleatoria")
for motivo in ["nueva", "desacuerdo", "incertidumbre"]:
    candidatos = s.request_id[grupos[motivo] & ~s.request_id.isin(list(elegidos))]
    n = min(cupo[motivo], len(candidatos))
    if motivo == "incertidumbre":                # lo que no alcanzó en los otros cupos se va a incertidumbre
        n = min(len(candidatos), k["tamano"] - len(elegidos))
    elegidos |= dict.fromkeys(rng.choice(candidatos.to_numpy(), n, replace=False), motivo)

Los cupos salen de config.yaml (cola.*). Una foto entra con un solo motivo: el primero que la elige.

terminal
python pipeline/3_cola.py
Salida real
motivo
incertidumbre    400
aleatoria        200
desacuerdo       120
nueva             80
total: 800
terminal
python verificar.py cola-mezcla cola-nuevas
Salida real
✓ cola-mezcla    incertidumbre 400 · aleatoria 200 · desacuerdo 120 · nueva 80
✓ cola-nuevas    79 ejemplos de bolsas (cupo 80)

2/2 en verde
Listo cuando: 800 fotos, 20–30 % al azar, sin holdout, y al menos el 90 % del cupo de categorías nuevas son de verdad bolsas.
Ojo: Los umbrales de C01 (top-1 < 0.3) aquí casi no seleccionarían nada: este modelo tiene 9 clases y es más seguro de sí. Antes de fijar un umbral, mira los cuantiles de top1_score y margen. Y ordena antes de muestrear: DuckDB no garantiza el orden de las filas, y sin orden la misma semilla da otra cola.
4Copiar solo esas fotosLas 800 fotos de la cola, sin metadatos, en un bucket privado que se borra solo a los 90 días.

Primero el bucket: se crea, se le pone el bloqueo de acceso público completo (los cuatro interruptores) y una regla de ciclo de vida que vence lo que esté bajo cola/ a los 90 días. Luego cada foto: se baja del bucket del producto, se le quita todo metadato y se sube con el request_id como nombre. Para quitar el EXIF: ImageOps.exif_transpose aplica la rotación antes de perder el tag de orientación, y Image.frombytes crea una imagen nueva solo con los píxeles. Al final se borra lo que sobre de otras corridas: el bucket solo tiene la cola de este mes.

pipeline/4_copiar.py
cli.put_public_access_block(Bucket=B, PublicAccessBlockConfiguration={
    "BlockPublicAcls": True, "IgnorePublicAcls": True, "BlockPublicPolicy": True, "RestrictPublicBuckets": True})
cli.put_bucket_lifecycle_configuration(Bucket=B, LifecycleConfiguration={"Rules": [{
    "ID": "cola-vence", "Status": "Enabled", "Filter": {"Prefix": "cola/"},
    "Expiration": {"Days": 90}}]})

Los 90 días salen de config.yaml (s3.dias_vencimiento).

pipeline/4_copiar.py
def limpiar(jpg: bytes) -> bytes:
    im = ImageOps.exif_transpose(Image.open(io.BytesIO(jpg)))   # aplica la rotación antes de perder el tag
    limpio = Image.frombytes(im.mode, im.size, im.tobytes())   # una imagen nueva: solo píxeles, cero metadatos
    out = io.BytesIO()
    limpio.save(out, "JPEG", quality=92)
    return out.getvalue()

save() de un JPEG no copia el EXIF si no se lo pasas, pero sí el comentario del archivo (y otros formatos copian más): reconstruir desde los píxeles no deja nada.

terminal
python pipeline/4_copiar.py
Salida real
copiadas: 800 · borradas de corridas anteriores: 0
terminal
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9100/etiquetado-restringido/cola/$(python -c "import pandas as pd; print(pd.read_parquet('data/pipeline/cola.parquet').request_id.iloc[0])").jpg

Pedir una foto sin credenciales: tiene que dar 403.

Salida real
403
terminal
python verificar.py copia-exif bucket-seguro
Salida real
✓ copia-exif     800 fotos, todas sin metadatos
✓ bucket-seguro  privado, sin acceso público y con vencimiento de 90 días

2/2 en verde
Listo cuando: El bucket tiene exactamente la cola, ninguna foto trae EXIF, el GET anónimo da 403 y la regla de 90 días está activa.
Ojo: Los objetos guardan el request_id y el prefijo del hash como metadatos de S3 (x-amz-meta-*). Eso no es EXIF: es tu forma de rastrear cada copia.
5Escribir la guía y mandar al proveedorUna guía v2 con la categoría nueva y los casos límite, y las 800 tareas en manos del proveedor (20 % dobles).

La guía v1 se escribió antes de que existiera «bolsas» y no dice nada de los casos límite. Escribe guia/guia-v2.md: copia la v1, agrega ## bolsas y una sección de casos límite con una regla para cada par que se confunde, con este formato: - regla: camisas-vs-playeras — … (también camisas-vs-sueteres y sueteres-vs-abrigos). Cambia etiquetado.guia_version en config.yaml. Después, el paso 5 escribe una tarea por foto con la versión de la guía y marca al azar el 20 % como doble. Para ver el efecto de la guía, corre primero con la v1 y luego con la v2.

guia/guia-v2.md
## bolsas
Bolsas de mano, bolsos, mochilas pequeñas y bolsas tote. No es ropa: si la foto muestra una prenda y una bolsa, manda la prenda.

## Casos límite

- regla: camisas-vs-playeras — Si se ven cuello y botones al frente, es camisa. Manga corta sin botones es playera, aunque tenga cuello.
- regla: camisas-vs-sueteres — Botones al frente = camisa. Tejido grueso sin abertura al frente = suéter.
- regla: sueteres-vs-abrigos — Si se abre al frente de arriba abajo, es abrigo. Si se pone por la cabeza, es suéter.

Lo que la v2 agrega a la v1. El proveedor simulado busca exactamente «## bolsas» y «regla: ».

pipeline/5_proveedor.py
t = pd.read_parquet(SAL / "copiadas.parquet")
t["guia_version"] = c["guia_version"]      # cada label recuerda con qué versión de la guía se hizo
t["doble"] = False
t.loc[rng.choice(t.index, round(len(t) * c["frac_doble"]), replace=False), "doble"] = True
t.to_parquet(ETQ / "tareas-proveedor.parquet", index=False)
terminal
python pipeline/5_proveedor.py && python mundo/anotar.py

Con la guía v2 en config.yaml.

Salida real
tareas: 800 · dobles: 160 · guía: guia-v2
✓ el proveedor devolvió 960 anotaciones (160 dobles) con guia-v2
terminal
python verificar.py guia
Salida real
✓ guia           guia-v2: las 10 categorías y los 3 casos límite

1/1 en verde
Listo cuando: La guía vigente define las 10 categorías y los 3 casos límite, y cada tarea lleva su guia_version.
Ojo: En C01 la doble anotación es el 10 %. Aquí es el 20 % porque la cola es chica: con 80 dobles, cada categoría tendría 5–15 casos y el kappa por clase saltaría de un mes a otro por puro azar.
6Medir el acuerdo y adjudicarEl kappa por clase de la doble anotación y cada desacuerdo decidido por ti en Label Studio.

Sobre las 160 dobles calculas el acuerdo bruto y el kappa, global y por clase. scikit-learn no trae kappa por clase: se calcula «uno contra el resto», preguntando para cada categoría si A y B coinciden en «es esta / no es esta». Los desacuerdos van a Label Studio como tareas: la imagen viaja como URL prefirmada (presigned URL) del bucket restringido: vence en 7 días, el máximo con la firma SigV4, y el bucket sigue privado. En AWS, si firmas con credenciales temporales de un rol, la URL muere cuando mueren esas credenciales. En Label Studio (http://localhost:8090, usuario taller@ejemplo.com / taller-etiquetas): crea un proyecto, pega guia/labelstudio-config.xml en Settings → Labeling Interface → Code, importa data/etiquetado/adjudicar-tareas.json, decide cada caso y exporta en JSON_MIN a data/etiquetado/adjudicacion-export.json. Con la guía v1 el kappa se cae justo donde la guía no decide: en la prueba del dojo, camisas 0.09, suéteres 0.09, playeras 0.29 y bolsas 0.54; con la v2, la más baja es camisas con 0.82.

pipeline/6_acuerdo.py
dobles = a.pivot(index="request_id", columns="anotador", values="clase").dropna().astype(int)
por_clase = {}
for i, nombre in enumerate(CLASES):
    k = cohen_kappa_score(dobles.A == i, dobles.B == i)   # uno contra el resto
    por_clase[nombre] = None if np.isnan(k) else round(float(k), 3)
pipeline/6_acuerdo.py
tareas = [{"data": {
    "image": cli.generate_presigned_url("get_object", Params={"Bucket": B, "Key": f"cola/{rid}.jpg"}, ExpiresIn=7 * 24 * 3600),
    "request_id": rid, "anotador_a": CLASES[f.A], "anotador_b": CLASES[f.B], "guia_version": version,
}} for rid, f in desacuerdos.iterrows()]

Todo lo que va en data se ve en la interfaz ($anotador_a, $anotador_b) y regresa tal cual en el export.

terminal
python pipeline/6_acuerdo.py

Corre primero con la guía v1 y luego con la v2: compara camisas, suéteres y bolsas.

Salida real
{
  "guia_version": "guia-v2",
  "n_dobles": 160,
  "acuerdo_bruto": 0.931,
  "kappa": 0.922,
  "kappa_por_clase": {
    "playeras": 0.937,
    "pantalones": 0.944,
    "sueteres": 0.853,
    "vestidos": 0.953,
    "abrigos": 0.972,
    "sandalias": 0.902,
    "camisas": 0.819,
    "tenis": 1.0,
    "bolsas": 0.955,
    "botines": 1.0
  }
}
desacuerdos para adjudicar: 11
terminal
python verificar.py acuerdo
Salida real
✓ acuerdo        kappa global 0.922 con 160 dobles · guía guia-v2

1/1 en verde
Listo cuando: kappa.json coincide con las anotaciones y toda categoría con ≥ 10 casos dobles tiene kappa ≥ 0.6.
Ojo: Export en JSON_MIN: cada tarea queda plana, con los campos de data y tu elección en «categoria». En JSON (completo) la elección está en annotations[0].result[0].value.choices[0]. El paso 7 espera JSON_MIN.
7Cerrar el mesLos labels del mes, el golden set intacto y un reporte que muestra el feedback loop con números.

Los labels del mes: lo que dijo A en las sencillas y, en las dobles, el acuerdo o tu adjudicación. Antes de guardarlos, se cruzan los hashes de contenido contra el manifest del golden set: si alguna foto del golden aparece, el paso se detiene, porque entrenar con ella haría que el golden deje de medir. Por último, el reporte: la exactitud del modelo en producción medida contra cada fuente. Ahí ves el feedback loop: contra las sugerencias aceptadas sale ≈ 0.89; contra el holdout, ≈ 0.81.

pipeline/7_cierre.py
golden = pd.read_parquet(DATA / "golden" / "manifest.parquet")
fuga = set(labels.img_sha256_prefijo) & set(golden.img_sha256.str[:16])
if fuga:
    sys.exit(f"✗ {len(fuga)} imágenes del golden set están en los labels del mes: el golden dejaría de medir")

El hash se calcula una vez, en el gateway, sobre los píxeles originales, y viaja como columna: quitar el EXIF o recomprimir no cambia la identidad. Si recalculas el hash de la copia, no coincide (recomprimir cambia algunos píxeles).

pipeline/7_cierre.py
reporte = {"exactitud": {
    "vs_labels_debiles": {f: exactitud(g) for f, g in debil.groupby("fuente")},
    "vs_holdout": exactitud(pd.read_parquet(SAL / "holdout.parquet").merge(s)),
    "vs_labels_mes": exactitud(labels.merge(s)),
    "vs_golden": exactitud(golden),
}}

Para el golden se baja cada foto de s3://golden-set y se predice con mundo/modelo.py, como bajarías el modelo del registry.

terminal
python pipeline/7_cierre.py

Tus números de labels del mes y de adjudicados pueden variar un poco según cómo adjudiques. Esta salida es con el experto simulado.

Salida real
{
  "exactitud": {
    "vs_labels_debiles": {
      "busqueda": 0.575,
      "correccion": 0.055,
      "sugerencia_madura": 0.89
    },
    "vs_holdout": 0.81,
    "vs_labels_mes": 0.411,
    "vs_golden": 0.732
  },
  "n": {
    "labels_mes": 800,
    "adjudicados": 11,
    "golden": 500
  },
  "guia_version": "guia-v2"
}
terminal
python verificar.py

Los 12 checks.

Salida real
✓ mundo          S3, Label Studio y los datos del mundo están arriba
✓ senales        24,000 requests, una fila cada uno, con la última corrección
✓ debil          13,506 labels débiles, 2.0% mal según la auditoría
✓ cola-mezcla    incertidumbre 400 · aleatoria 200 · desacuerdo 120 · nueva 80
✓ cola-nuevas    79 ejemplos de bolsas (cupo 80)
✓ copia-exif     800 fotos, todas sin metadatos
✓ bucket-seguro  privado, sin acceso público y con vencimiento de 90 días
✓ guia           guia-v2: las 10 categorías y los 3 casos límite
✓ acuerdo        kappa global 0.922 con 160 dobles · guía guia-v2
✓ labels-mes     800 labels (11 adjudicados) con guia-v2
✓ golden         500 imágenes congeladas, ninguna en la cola ni en los labels del mes
✓ reporte        holdout 0.81 · golden 0.732 · labels del mes 0.411

12/12 en verde
Listo cuando: 12/12 en verde.
Ojo: El golden sale más bajo que el holdout y no es que esté «peor»: pesa igual cada categoría e incluye 50 bolsas que el modelo nunca vio. Y los labels del mes salen bajísimos a propósito: la cola es difícil por diseño.

Solo

12 objetivos

Antes de empezar, python mundo/reiniciar.py --pipeline: respalda tu versión anterior en .respaldo/ y deja el esqueleto vacío. El mundo no cambia.

Solo tienes OBJETIVOS.md, los contratos al inicio de cada archivo de pipeline/ y python verificar.py. Nada de GUION.md.

Claude no te da código: te dice qué check falla y te contesta con preguntas.

Mide tu tiempo por objetivo y anota en la bitácora dónde te atoraste.

01Levanta el mundo y confirma que los servicios y los datos están arriba.python verificar.py mundo
02Una fila por request con el top-1, su score, el margen, lo que eligió quien vende y la corrección más reciente.python verificar.py senalesPista: Cuenta filas antes y después de cada join.
03Labels débiles con su fuente (corrección, búsqueda o sugerencia sin corregir en 30 días) y el holdout aparte.python verificar.py debil
04Una cola de 800 con 20–30 % al azar y sin tocar el holdout.python verificar.py cola-mezcla
05Que la categoría nueva llegue a la cola con su cupo.python verificar.py cola-nuevas
06Solo las fotos de la cola en el bucket restringido, sin EXIF.python verificar.py copia-exif
07El bucket restringido privado, sin acceso público y con vencimiento a 90 días.python verificar.py bucket-seguro
08Una guía que define las 10 categorías y decide los 3 casos límite.python verificar.py guia
09Kappa por clase de la doble anotación, ≥ 0.6 donde hay suficientes casos.python verificar.py acuerdo
10Labels del mes completos, con los desacuerdos adjudicados y la versión de la guía.python verificar.py labels-mes
11El golden set congelado y fuera de la cola y de los labels.python verificar.py golden
12Un reporte con la exactitud del modelo contra cada fuente de labels.python verificar.py reporte

Romper

10 fallas

Con todo en verde, romper.py mete una falla en el mundo, la configuración o la infraestructura (nunca en lo que escribiste) y, si hace falta, deja corriendo lo que «pasó anoche». Tú empiezas por verificar.py.

romper.py
python romper.py --mostrar   # con ayuda: Claude sabe cuál es
python romper.py             # solo: escondida, ni Claude la sabe
python romper.py --verificar # ¿ya quedó?
python romper.py --revelar   # me rindo
python romper.py --restaurar # deshacer