Skip to content

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:

  1. Toda la aleatoriedad está sembrada — cada RNG que toca el grafo de cómputo.
  2. Todas las dependencias de código están fijadas — versiones exactas + hashes, en un lockfile commiteado.
  3. 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, fija OMP_NUM_THREADS=1 o 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.txt con >= 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 → reejecuta uv lock.
  • La restricción de una dep transitiva se mueve (porque una dep directa cambió sus restricciones) → uv lock regenera.
  • 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_everything desde cero sin mirar. Confirma con pytest que 10 invocaciones de random.random() tras seed_everything(0) producen la misma secuencia que 10 invocaciones tras random.seed(0).
  • §lab/00 checklist: confirma que uv.lock está presente, pip-audit está limpio, bandit está 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 PYTHONHASHSEED en código en vez de en el launcher. Afecta a hash(str) solo para procesos nuevos.
  • Confiar en bool(torch.cuda.is_available()) en tiempo de import del módulo. Puede devolver True en sistemas donde CUDA está roto en runtime. Envuelve rutas CUDA-only en try/except.
  • Confiar en que numpy.random.seed siembra np.random.default_rng(...). No lo hace — default_rng tiene su propio estado.
  • Persistir el manifiesto sin wall_seconds y finished_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/)

  1. Sin mirar src/utils/seeding.py, escribe un seed_everything(seed: int) -> None que cubra random, numpy, torch (si es importable). Testea que llamarlo dos veces con la misma semilla da las mismas diez primeras salidas de random.random().
  2. 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.
  3. Escribe un record_manifest(experiment_id: str, config: dict, seed: int, artifacts: list[str]) -> Path que capture el schema de §3, lo escriba en experiments/<date>-<id>/manifest.json y devuelva la ruta. Incluye git_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 sync vs. uv pip install.

§8 Siguiente lectura

02-engineering-hygiene.md — pre-commit, ruff, mypy, bandit, pip-audit como política.