Skip to content

English · Español

Solución 03 — referencia de seed_everything

Léelo solo tras completar ../lab/03-seed-by-hand.md y commitear tu intento.

Implementación de referencia

src/utils/seeding.py:

"""Deterministic seeding for every RNG in scope.

What this covers:
- PYTHONHASHSEED  — dict iteration, hash(str) for new processes
- random          — stdlib RNG
- numpy.random    — legacy global generator
- torch           — CPU + per-CUDA-device + cuDNN deterministic
- log_versions    — capture installed library versions for manifests

What it does NOT cover:
- Multi-threaded BLAS reduction order (set OMP_NUM_THREADS=1 explicitly)
- TF32 / FP16 hardware nondeterminism on Ampere+ GPUs
- numpy.random.default_rng(seed) — those generators have their own state
  (prefer passing rng around explicitly in your own code; seed_everything
  only sets the legacy global state for libraries that haven't migrated)
"""

from __future__ import annotations

import os
import random
import sys


def seed_everything(seed: int) -> None:
    """Seed every reachable RNG with the given integer.

    Call this **before** any RNG use. Setting PYTHONHASHSEED here only
    affects the current process for code that reads os.environ at runtime;
    to make hash(str) deterministic across processes, also set
    PYTHONHASHSEED in the launcher shell.
    """
    os.environ["PYTHONHASHSEED"] = str(seed)
    random.seed(seed)

    try:
        import numpy as np
    except ImportError:
        pass
    else:
        np.random.seed(seed)

    try:
        import torch
    except ImportError:
        pass
    else:
        torch.manual_seed(seed)
        if torch.cuda.is_available():
            torch.cuda.manual_seed_all(seed)
            torch.backends.cudnn.deterministic = True
            torch.backends.cudnn.benchmark = False


def log_versions() -> dict[str, str]:
    """Return a dict {name: version-or-'not installed'} for the libs we care about."""
    versions: dict[str, str] = {"python": sys.version.split()[0]}
    for name in ("numpy", "torch", "scipy"):
        try:
            mod = __import__(name)
        except ImportError:
            versions[name] = "not installed"
        else:
            versions[name] = getattr(mod, "__version__", "unknown")
    return versions

Decisiones tomadas en esta referencia

  • try/except ImportError por librería: permite a seed_everything funcionar en un venv vacío, en una máquina solo-CPU, etc. Los tests lo verifican en tests/test_seeding.py.
  • Cláusula else: tras try:: más clara que ifs anidados. Se lee como "si el import tuvo éxito, haz el seeding".
  • from __future__ import annotations: deja que los type hints se mantengan en estilo PEP 604 en Python 3.11 incluso antes de fijar target-version. Seguro barato.
  • np.random.seed solamente: deliberadamente no intentamos enumerar todas las instancias np.random.default_rng(...) — tienen estado independiente por diseño. Prefiere pasar generadores explícitos en tu propio código.
  • torch.manual_seed antes de cuda.is_available: manual_seed es barato; hacerlo incondicionalmente está bien y reduce preocupaciones de orden.

Sutilezas a comparar

  • ¿Escribiste from numpy.random import seed as np_seed? Funciona pero tira de numpy en tiempo de import de seeding.py — que es malo si un experimento solo-CPU nunca quiere numpy cargado.
  • ¿Devolviste __version__ directamente sin getattr? Algunas libs no la exponen (raro para las que nos interesan, pero getattr(mod, "__version__", "unknown") es defensivo sin ser ruidoso).
  • ¿Fijaste cudnn.benchmark = False sin cudnn.deterministic = True? Ambos son necesarios para determinismo real.
  • ¿Llamaste torch.cuda.manual_seed (singular — solo dispositivo actual) en vez de manual_seed_all (cada dispositivo)?

Test de propiedades (acompañante)

Añade a tests/test_seeding.py:

import random
from hypothesis import given, strategies as st
from utils.seeding import seed_everything


@given(st.integers(min_value=0, max_value=2**31 - 1))
def test_seed_determinism_stdlib(seed: int) -> None:
    seed_everything(seed)
    a = [random.random() for _ in range(5)]
    seed_everything(seed)
    b = [random.random() for _ in range(5)]
    assert a == b


@given(st.integers(min_value=0, max_value=2**31 - 1))
def test_seed_determinism_numpy(seed: int) -> None:
    np = __import__("numpy")
    seed_everything(seed)
    a = np.random.rand(5).tolist()
    seed_everything(seed)
    b = np.random.rand(5).tolist()
    assert a == b

El test de determinismo de torch sería similar pero gateado por pytest.importorskip("torch").

Qué enseña realmente este lab

Casi seguro escribirás algo cercano a esta implementación al primer intento. La lección no es "¿pusiste el código correcto?". Es:

  1. ¿Entendiste el scope de PYTHONHASHSEED? (A nivel de proceso; fijarlo en código no afecta retroactivamente a hash(str) de strings ya en claves de dict.)
  2. ¿Entendiste que np.random.seednp.random.default_rng(seed)? Producen secuencias distintas desde la misma semilla.
  3. ¿Escribiste un test que falla en una versión bugged? Si tu test es seed_everything(0); assert random.random() != 0, no es un test de determinismo — es un smoke test. El test de propiedades de arriba es correcto porque compara dos ejecuciones sembradas.
  4. ¿Listaste una fuente de no-determinismo que seed_everything no puede cubrir? (Orden de reducción de BLAS multihilo, TF32, atomic-add en FP16, fork multi-proceso sin re-sembrar.)

Si tu learners/borja/phase-00/notes/seeding-notes.md cubre esos cuatro puntos honestamente, este lab está hecho.

Cuando comparas y decides "me quedo con la mía"

A veces la respuesta al diff es "mi versión está bien para este currículo, aunque la referencia es ligeramente más conservadora". Esa es una llamada válida — anótalo en seeding-notes.md y sigue. El lab no es "haz tu archivo idéntico a la referencia". Es "ser capaz de defender cada línea que escribiste".