Skip to content

English · Español

Break — Quitar PYTHONHASHSEED de seed_everything

Pequeña ruptura, gran lección: si PYTHONHASHSEED no se fija (o se fija demasiado tarde), hash(str) cambia entre procesos y los dataloaders que indexan por hash producen orden distinto en cada ejecución. La reproducibilidad muere en silencio.

Este ejercicio /break apunta a la higiene de reproducibilidad. Es intencionadamente sutil: la suite de tests puede seguir pasando; el fallo aparece solo al reiniciar procesos.

Hipótesis

El learner predice: "Si quito la línea os.environ['PYTHONHASHSEED'] de seed_everything, mis tests unitarios seguirán pasando porque random y numpy siguen sembrados — pero cualquier código que dependa del orden de hash(str) entre procesos Python frescos divergirá en silencio."

La ruptura

En src/utils/seeding.py, comenta la línea que fija PYTHONHASHSEED:

 def seed_everything(seed: int) -> None:
-    os.environ["PYTHONHASHSEED"] = str(seed)
+    # os.environ["PYTHONHASHSEED"] = str(seed)  # /break: removed
     random.seed(seed)
     np.random.seed(seed)
     ...

Procedimiento de ejecución

Ejecuta esto dos veces como procesos Python separados (no dos llamadas en el mismo REPL):

uv run python -c "
from src.utils.seeding import seed_everything
seed_everything(0)
print(sorted({'work','play','walk','study','listen'}, key=hash))
"
# Ejecuta el mismo comando de nuevo. Compara las dos salidas.

En un control paralelo, fija la variable de entorno a nivel del launcher:

PYTHONHASHSEED=0 uv run python -c "
print(sorted({'work','play','walk','study','listen'}, key=hash))
"
# Repite. Las salidas ahora deberían coincidir.

Modo de fallo esperado

  • Sin el fix: las dos invocaciones de proceso producen distintos ordenamientos del set de verbos. Las salidas de random y numpy siguen coincidiendo (porque esas están sembradas dentro del proceso), así que un test unitario ingenuo pasaría.
  • Con la variable de entorno a nivel del launcher: las salidas coinciden entre procesos.

Firma cuantitativa: en 10 trials con sets de 5 strings, la probabilidad esperada de que dos arranques aleatorios de Python produzcan el mismo orden hash de 5 strings es 1/5! = 1/120 ≈ 0.83%. En la práctica verás desacuerdo en 1–2 trials.

Diagnóstico

Desde los logs solos, el síntoma es "mi dataloader produce un orden de muestra distinto en cada reinicio, aunque seed_everything(0) es lo primero que llamo". El chequeo definitivo:

echo $PYTHONHASHSEED         # vacío cuando está roto
uv run python -c "import os; print(os.environ.get('PYTHONHASHSEED', 'unset'))"

Si la variable de entorno está sin fijar al arranque del proceso pero os.environ['PYTHONHASHSEED'] se fija dentro del proceso, no afecta retroactivamente a hash(str) — el hash randomizer a nivel C se inicializa antes de que cualquier código Python corra.

Lección

PYTHONHASHSEED es especial: debe fijarse en el entorno del shell que lanza, no dentro del script. El wrapper de receta just y el test harness lo exportan ambos; llamar a seed_everything dentro del script es higiénico pero no suficiente. Documentar esta advertencia en el docstring de la función (y en la entrada de diario del día) es el entregable real; el estado roto enseñó la razón.

Referencia

  • Docs de CPython, PYTHONHASHSEED y sys.flags.hash_randomization.
  • PEP 456 — Secure and interchangeable hash algorithm (background sobre por qué existe hash randomization en primer lugar).