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.
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
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
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.
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'\"))"
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)
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
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
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'\"))"
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.
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ú
pipeline/1_senales.pyUne logs, publicaciones y la última corrección por request_id.
pipeline/2_debil.pyFiltra los labels débiles y separa el holdout.
pipeline/3_cola.pyElige las 800 fotos del mes: nuevas, desacuerdos, incertidumbre y 25 % al azar.
pipeline/4_copiar.pyCopia solo esas fotos, sin EXIF, a un bucket privado que vence a los 90 días.
pipeline/5_proveedor.py + guia/guia-v2.mdEscribe la guía con los casos límite y manda las tareas al proveedor.
pipeline/6_acuerdo.pyCalcula el kappa por clase y prepara los desacuerdos para Label Studio.
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 nube
En el dojo
Qué cambia
Amazon S3
RustFS 1.0.4
Misma 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 disco
Mismo Parquet particionado por fecha; DuckDB leería igual desde S3 con su extensión httpfs.
Etiquetadores o proveedor gestionado
mundo/anotar.py
Un 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.py
Corre 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.
ConstruirRomperCon 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.
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
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.
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.
✓ 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.
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
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)
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.
✓ 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.
✓ 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
Catálogo de fallas (spoilers)El catálogo de abajo trae las respuestas. Ábrelo después de romper, para repasar.Abrir el catálogo
mundo
La sugerencia viene preseleccionada
Producto lanzó a mediados de septiembre un rediseño del formulario de publicación. Este mes la exactitud offline del modelo subió y todos celebran, pero la tasa de aceptación en producción no se movió.
Síntoma. verificar.py: la auditoría encuentra ≈ 4.7 % de labels débiles mal (máximo 3 %), casi todos de sugerencia_madura. En el reporte, la exactitud contra las sugerencias sube y contra el holdout no.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
Quien vende acepta más la sugerencia, aunque esté mal (cambió el producto).
Moderación dejó de corregir.
El modelo mejoró de verdad.
cómo confirmar
python -c "import duckdb; duckdb.sql(\"SET TimeZone = 'UTC'\"); print(duckdb.sql(\"SELECT date_trunc('week', ts_publicacion) AS semana, round(avg((eleccion = 'sugerencia_1')::INT), 3) AS acepta_top1 FROM 'data/producto/publicaciones.parquet' GROUP BY ALL ORDER BY semana\"))"
La tasa de aceptación de la primera sugerencia, por semana: si salta de golpe en una fecha, cambió el producto, no el modelo.
La salida depende de tu corrida.
El arreglo. Pon en config.yaml debil.excluir_sugerencias_desde con la fecha del cambio (2026-09-16T00:00:00Z) para que esas sugerencias no cuenten como label, corre desde el paso 2 y avisa a producto. El holdout y el golden no se movieron: el modelo no mejoró.
El equipo del gateway desplegó una versión nueva a finales de octubre. Nadie avisó de nada raro, pero este mes hay menos labels nuevos que de costumbre.
Síntoma. verificar.py: se unieron 20 000 de 24 000 publicaciones (83.3 %) y, en cadena, faltan ≈ 500 labels débiles. El job terminó sin errores.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
Faltan días de logs.
La llave cambió de formato en una de las fuentes.
Hay publicaciones sin request en el gateway.
cómo confirmar
python -c "import duckdb; print(duckdb.sql(\"SELECT fecha, count(*) FILTER (WHERE request_id <> lower(request_id)) AS raros, count(*) AS total FROM read_parquet('data/logs/*/*.parquet', hive_partitioning = true) GROUP BY ALL ORDER BY fecha DESC LIMIT 12\"))"
Cuenta por día los request_id que no están en minúsculas.
La salida depende de tu corrida.
El arreglo. Normaliza la llave antes del join (lower(request_id)) y deja en el paso 1 una alarma: si se une menos del 99 %, que falle en vez de seguir.
El equipo de producto migró el export de publicaciones a un sistema nuevo. El job del mes corrió, pero los números de labels débiles no cuadran con la auditoría.
Síntoma. verificar.py: ≈ 76 labels de más contra las reglas de la estación. Todo lo demás pasa.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
Cambió el tipo o la zona de alguna columna de fecha.
Cambió la regla de 30 días en config.yaml.
Hay correcciones duplicadas nuevas.
cómo confirmar
python -c "import duckdb; print(duckdb.sql(\"DESCRIBE SELECT * FROM 'data/producto/publicaciones.parquet'\"))"
Compara el tipo de ts_publicacion: TIMESTAMP WITH TIME ZONE es un instante; TIMESTAMP a secas es una hora de reloj sin lugar.
La salida depende de tu corrida.
El arreglo. En el paso 1, si ts_publicacion llega como TIMESTAMP, interprétala como hora de America/Mexico_City y pásala a UTC (en DuckDB: timezone('America/Mexico_City', ts_publicacion)). Así funciona con el export viejo y con el nuevo.
Negocio agregó «bolsas» al árbol de categorías en octubre y pregunta por qué el modelo todavía no las sugiere.
Síntoma. verificar.py: la cola trae 0 ejemplos reales de bolsas (cupo 80).
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
No hay bolsas en el tráfico.
Hay bolsas, pero nadie las puede etiquetar como «bolsas».
El cupo de nuevas está en 0.
cómo confirmar
python -c "import duckdb; print(duckdb.sql(\"SELECT count(*) FILTER (WHERE categoria_final = 8) AS con_label_8, count(*) FILTER (WHERE lower(titulo) SIMILAR TO '.*(bolsa|bolso|mochila).*') AS titulo_de_bolsa FROM 'data/pipeline/senales.parquet'\"))"
Casi ningún label 8 (unos pocos del holdout o de búsquedas equivocadas), pero cientos de títulos que dicen bolsa: la categoría existe en el tráfico, no en los datos.
La salida depende de tu corrida.
El arreglo. Siembra la categoría por texto: agrega nuevas.palabras (bolsa, bolso, mochila) en config.yaml y en el paso 3 marca como «nueva» lo que tenga esas palabras en el título.
Para ahorrar presupuesto de etiquetado, alguien del equipo «optimizó» la cola de este mes. Ahora la exactitud medida en los labels nuevos se ve muy baja y preocupa a todos.
Síntoma. verificar.py: la parte aleatoria de la cola es 0 %. En el reporte, vs_labels_mes cae todavía más.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
El modelo empeoró.
Cambió la mezcla de la cola.
El proveedor etiquetó mal.
cómo confirmar
grep -n frac_ config.yaml
¿La parte aleatoria sigue en 0.25? Si tu carpeta es un repositorio de git, git diff config.yaml también te dice qué cambió y cuándo.
La salida depende de tu corrida.
El arreglo. Regresa cola.frac_aleatoria a 0.25 y corre desde el paso 3. Explícale al equipo por qué no se recorta: es lo único de la cola que mide sin sesgo.
Alguien re-exportó la adjudicación desde Label Studio para revisarla y la dejó en su lugar. El cierre del mes ya no termina.
Síntoma. El paso 7 truena con KeyError: 'request_id' (en el export completo, request_id y la elección ya no están arriba, sino dentro de data y de annotations). verificar.py: labels_mes es más viejo que el export.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
Falta adjudicar algún caso.
El archivo de export tiene otra forma.
Cambiaron los nombres de las categorías.
cómo confirmar
head -c 600 data/etiquetado/adjudicacion-export.json
¿Las tareas son planas con «categoria», o traen annotations → result → value → choices?
La salida depende de tu corrida.
El arreglo. Vuelve a exportar en JSON_MIN (en las rondas de romper: python mundo/adjudicar.py) y corre el paso 7. Mejor todavía: que el 7 reconozca los dos formatos o falle con un mensaje que diga cuál espera.
Usa el request_id que muestra verificar.py. El metadato copiado-por delata de dónde vino.
La salida depende de tu corrida.
El arreglo. Corre de nuevo el paso 4 (sobrescribe con fotos limpias) y prohíbe los scripts que se saltan el pipeline: el bucket solo se escribe desde el paso 4.
Para una demo con el proveedor, alguien «abrió un momento» el bucket restringido. La demo fue hace una semana.
Síntoma. verificar.py: no existe el bloqueo de acceso público. Un GET sin credenciales a una foto da 200.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
Se quitó el bloqueo de acceso público.
Hay una política que deja leer a cualquiera.
Se borró la regla de vencimiento.
cómo confirmar
python -c "from pipeline._comun import s3; c = s3(); print(c.get_bucket_policy(Bucket='etiquetado-restringido')['Policy'])"
Busca "Principal": "*". Revisa también la regla de vencimiento.
La salida depende de tu corrida.
El arreglo. Borra la política (delete_bucket_policy) y corre el paso 4, que vuelve a poner el bloqueo completo y la regla de 90 días. Para compartir fotos con el proveedor existen las URL prefirmadas, que vencen solas.