English · Español
Reproducibilidad — seeds, lockfiles, manifiestos¶
Resumen. Tres mecanismos: (1) sembrar todas las fuentes de aleatoriedad, (2) congelar versiones exactas con un lockfile, (3) persistir un manifiesto por experimento. Sin los tres, los resultados no son reproducibles — son anécdotas.
§0 Los tres pilares¶
Un resultado es reproducible cuando otra persona, seis meses después, en hardware distinto, puede reejecutar tu script y obtener resultados numéricos bit-idénticos (o, en GPU, "dentro de la tolerancia documentada"). Eso requiere tres cosas:
- Toda la aleatoriedad está sembrada — cada RNG que toca el grafo de cómputo.
- Todas las dependencias de código están fijadas — versiones exactas + hashes, en un lockfile commiteado.
- La proveniencia por experimento está registrada — la semilla, el sha del lockfile, el git sha, el hardware, la configuración. Pierde una y la ejecución es no reproducible por definición.
§1 Fuentes de aleatoriedad¶
En un stack de NumPy + (eventualmente) PyTorch + CUDA, las fuentes de RNG son:
| Fuente | Dónde te muerde |
|---|---|
random (stdlib) |
random.shuffle, random.choice, todo en secrets (no — secrets es intencionadamente no-sembrable) |
numpy.random (legacy + Generator) |
La mayoría de fases pre-ML |
RNG de CPU de torch |
torch.randn, torch.randperm, dropout, capas init |
RNG por dispositivo de torch.cuda |
Dropouts en GPU, init en GPU |
| Algoritmos no deterministas de cuDNN | torch.backends.cudnn.deterministic, cudnn.benchmark |
PYTHONHASHSEED |
Orden de iteración de dict, hash(str) — afecta dataloaders que indexan por hash |
| Scheduling del OS / multihilo | OMP_NUM_THREADS, hilos de BLAS; el orden de reducción de sumas en >2 hilos no es determinista |
| No determinismo de hardware (TF32, atomic add FP16) | TF32 en Ampere+; atomicAdd en FP16 |
La función en src/utils/seeding.py cubre las primeras cinco. Las tres restantes se manejan en las fronteras de ejecución — OMP_NUM_THREADS=1 para ejecuciones de CPU totalmente deterministas, TF32 deshabilitado donde el determinismo importa.
§1.1 ¿Por qué cada llamada de seeding?¶
def seed_everything(seed: int) -> None:
os.environ["PYTHONHASHSEED"] = str(seed) # dict order, hash(str)
random.seed(seed) # stdlib RNG
np.random.seed(seed) # NumPy legacy global RNG
torch.manual_seed(seed) # CPU + default-device RNG
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed) # every CUDA device
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
Sutilezas:
- PYTHONHASHSEED debe fijarse antes de que Python arranque para afectar a hash(str) de forma determinista entre procesos; fijarlo vía os.environ tras el import solo ayuda dentro del mismo proceso. En la práctica lo fijamos en el launcher (receta de just o wrapper de shell). Fijarlo a nivel de código es higiénico pero no suficiente.
- np.random.seed solo siembra el generador legacy global. El código que usa rng = np.random.default_rng(seed) es independiente — y mejor — pero la semilla legacy se fija igual para librerías que no han migrado.
- cudnn.benchmark = True deja que cuDNN escoja el algoritmo más rápido en tiempo de ejecución; la elección depende de las formas de entrada y puede cambiar entre ejecuciones. Desactivarlo cuesta rendimiento pero es el precio del determinismo.
§1.2 Qué no hace seed_everything¶
- No hace deterministas las reducciones paralelas
O(n)en BLAS multihilo. Para eso, fijaOMP_NUM_THREADS=1o usa implementaciones de reducción deterministas. - No hace deterministas algoritmos no deterministas (algunas variantes de
scatter_add;torch.use_deterministic_algorithms(True)es la forma de forzarlo; lanza error si usas una op no determinista). - No siembra hilos creados antes de la llamada. Llámala primero.
§2 Lockfiles — la diferencia entre "instalé numpy 2.x" y "los dos instalamos numpy 2.0.1 desde el mismo hash de wheel"¶
Un requirements.txt con especificadores de versión como numpy>=2,<3 no es un lockfile — es una restricción. La misma restricción resuelve a versiones exactas distintas en días distintos, según lo que se haya lanzado desde entonces.
Un lockfile registra: - La versión exacta de cada dependencia directa. - La versión exacta de cada dependencia transitiva. - El hash del wheel/sdist de cada una. - Las decisiones del resolvedor (qué conflictos se rompieron y cómo).
uv.lock es el formato de lockfile de uv. Está commiteado. uv sync lo lee; no re-resuelve a menos que se lo pidas. La comprobación de hash significa que un índice de PyPI comprometido no puede cambiar silenciosamente un paquete — la instalación fallaría.
Un
requirements.txtcon>=y<es una restricción, no un lockfile. Resuelve a versiones diferentes cada día. El lockfile congela las versiones exactas y los hashes — si alguien manipula el índice, la instalación falla.
§2.1 Cuándo cambia el lockfile¶
- Una dep directa se añade/quita/actualiza en
pyproject.toml→ reejecutauv lock. - La restricción de una dep transitiva se mueve (porque una dep directa cambió sus restricciones) →
uv lockregenera. - El lockfile se commitea. Los PR que lo cambien deben justificar el cambio.
§3 El manifiesto de experimento¶
Para cada ejecución que produce un artefacto numérico (una pérdida, una métrica, un checkpoint, un plot), persiste:
{
"id": "2026-05-22-softmax-stability",
"git_sha": "a1b2c3d4...",
"git_dirty": false,
"seed": 42,
"config": { "/* the actual hyperparameters */": null },
"versions": {
"python": "3.11.9",
"numpy": "2.0.1",
"torch": "2.3.1",
"uv": "0.4.18"
},
"hardware": {
"cpu": "Intel i5-8250U",
"ram_gb": 62,
"gpu": "Intel UHD 620 (no CUDA)",
"os": "Fedora 43"
},
"started_at": "2026-05-22T19:14:02Z",
"finished_at": "2026-05-22T19:14:08Z",
"wall_seconds": 6.31,
"artifacts": ["plot.svg", "loss.npy"]
}
src/utils/seeding.py tiene log_versions() para el bloque versions; el resto se compone en el script del experimento.
§3.1 Por qué el hardware está en el manifiesto¶
Las rutas CPU vs. GPU son bit-distintas. Dentro de GPU, sm_70 vs sm_80 difieren en el default de TF32. Dentro de CPU, el árbol de reducción de BLAS puede variar según el número de cores. Si no registras el hardware, no puedes saber si una discrepancia numérica es tu bug o el de la máquina.
§3.2 Por qué importa "git_dirty"¶
Si el working tree está sucio (cambios sin commitear), el git sha es mentira. Manifiestos con git_dirty: true son artefactos en cuarentena — útiles para iteración rápida, inútiles para el informe. El phase-gatekeeper marca cualquier manifiesto relevante para DoD con git_dirty: true.
§4 Qué probará el lab¶
- §lab/03: reimplementa
seed_everythingdesde cero sin mirar. Confirma conpytestque 10 invocaciones derandom.random()trasseed_everything(0)producen la misma secuencia que 10 invocaciones trasrandom.seed(0). - §lab/00 checklist: confirma que
uv.lockestá presente,pip-auditestá limpio,banditestá limpio.
§5 Escollos¶
- Olvidar sembrar antes de hacer fork de un proceso worker. El subproceso obtiene un estado fresco de RNG a menos que lo siembres dentro.
- Fijar
PYTHONHASHSEEDen código en vez de en el launcher. Afecta ahash(str)solo para procesos nuevos. - Confiar en
bool(torch.cuda.is_available())en tiempo de import del módulo. Puede devolverTrueen sistemas donde CUDA está roto en runtime. Envuelve rutas CUDA-only en try/except. - Confiar en que
numpy.random.seedsiembranp.random.default_rng(...). No lo hace —default_rngtiene su propio estado. - Persistir el manifiesto sin
wall_secondsyfinished_at. Vas a querer los datos de timing cuando estés depurando "¿por qué esta ejecución tardó 8× más esta vez?".
§6 Ejercicios (soluciones en solutions/)¶
- Sin mirar
src/utils/seeding.py, escribe unseed_everything(seed: int) -> Noneque cubrarandom,numpy,torch(si es importable). Testea que llamarlo dos veces con la misma semilla da las mismas diez primeras salidas derandom.random(). - Añade un
log_versions() -> dict[str, str]que devuelva las versiones de Python + NumPy + Torch + uv. Maneja el caso en que alguna no sea importable. - Escribe un
record_manifest(experiment_id: str, config: dict, seed: int, artifacts: list[str]) -> Pathque capture el schema de §3, lo escriba enexperiments/<date>-<id>/manifest.jsony devuelva la ruta. Incluyegit_sha,git_dirty, wall time.
§7 Referencias¶
- Reproducibility in ML — Pineau et al., 2020 (checklist de reproducibilidad de NeurIPS).
- Docs de determinismo de PyTorch — semántica de
torch.use_deterministic_algorithms. - Docs de
uv— formato de lockfile,uv syncvs.uv pip install.
§8 Siguiente lectura¶
→ 02-engineering-hygiene.md — pre-commit, ruff, mypy, bandit, pip-audit como política.