4. Determinismo: que dos ejecuciones den exactamente lo mismo
Nivel 7: Del cuaderno al proceso
Un resultado que no puedes volver a obtener no es un resultado: es una anécdota. Si hoy no eres capaz de reproducir la señal que enviaste al mercado hace tres semanas, no puedes auditarla, no puedes depurarla y, sobre todo, no puedes afirmar que tu sistema en vivo ejecuta la misma estrategia que probaste.
Las cuatro fugas del determinismo
Un proceso es determinista cuando, con las mismas entradas declaradas, produce siempre la misma salida. Las entradas que se cuelan sin declararse son casi siempre estas cuatro:
- El azar. Cualquier remuestreo, inicialización aleatoria o simulación introduce una secuencia que cambia en cada ejecución si no se fija la semilla.
- Las versiones. Una función que cambia de comportamiento entre dos versiones menores de una librería basta para mover un número.
- El tiempo. Leer la hora o la fecha desde dentro del cálculo es meter una entrada distinta cada vez que se ejecuta.
- Los datos. El proveedor revisa el histórico, corrige un dividendo, rellena un hueco. El fichero que descargaste ayer y el de hoy no son el mismo.
Las tres primeras se cierran con disciplina y cuestan una tarde. La cuarta es la cara, porque no depende de ti: obliga a guardar una copia propia de lo que se usó, identificada por una huella del contenido, y a que el registro anote qué copia alimentó cada decisión. Sin eso, dentro de seis meses no podrás distinguir un error de tu código de una revisión silenciosa del proveedor, que es exactamente la discusión que nunca quieres tener a ciegas.
Semillas y versiones: la parte fácil
Fijar la semilla es barato y hay que hacerlo en un solo sitio, el punto de entrada, y nunca dentro de una función que se llama muchas veces:
# ejecutar.py
import random
import numpy as np
SEMILLA = 20240115
def preparar_azar(semilla=SEMILLA):
random.seed(semilla)
np.random.seed(semilla)Anclar versiones es igual de barato: en lugar de pedir la librería a secas, se fija la versión exacta de cada dependencia, incluidas las indirectas, y ese fichero se versiona junto al código. Instalar la última disponible significa que el ordenador de mañana no ejecutará el mismo programa que el de hoy, aunque tu código no haya cambiado ni una coma.
El tiempo es una entrada, no un dato ambiental
Este es el punto que más gente falla. Compara:
# Mal: la funcion lee el reloj por dentro
from datetime import datetime
def es_dia_operable(calendario):
hoy = datetime.now().date()
return hoy in calendario
# Bien: el instante entra por la puerta, como cualquier otra entrada
def es_dia_operable(calendario, momento):
return momento.date() in calendarioLa primera versión no se puede probar. No puedes escribir una prueba que compruebe qué hace en Nochebuena, ni reproducir lo que decidió un martes de marzo, porque su respuesta depende de cuándo la ejecutes: la misma llamada da resultados distintos hoy y mañana sin que nadie haya tocado nada. La segunda se prueba con una línea y se reproduce siempre, porque el instante viaja con los argumentos.
La regla general: la hora entra por el punto de entrada, se congela una vez y se pasa hacia abajo. Todo el sistema opera con ese único instante, lo que además elimina una familia entera de fallos sutiles, como que dos partes del programa crucen la medianoche en momentos distintos.
# ejecutar.py
from datetime import datetime, timezone
def main():
args = parsear_argumentos()
ahora = (datetime.fromisoformat(args.momento) if args.momento
else datetime.now(timezone.utc))
preparar_azar()
precios = cargar_precios(args.simbolo, hasta=ahora)
decision = calcular_senal(precios)
anotar('decision', momento=ahora.isoformat(), decision=decision)Ese argumento opcional convierte la reproducción de un día pasado en un ejercicio trivial y, de paso, hace que el mismo punto de entrada sirva para recorrer el histórico.
Reproducir es poder recalcular, no poder consultar
Guardar en el registro qué señal salió cada día es imprescindible, pero no es determinismo. El registro te dice qué pasó; el determinismo te permite volver a obtenerlo. Para eso hay que anotar, junto al resultado, todo lo que hizo falta para producirlo: instante congelado, versión del código, versión de las dependencias, semilla, identificador del conjunto de datos usado y configuración efectiva. Con eso, un resultado dudoso se puede rehacer meses después. Sin eso, la única respuesta posible ante una discrepancia es encogerse de hombros.
Caso real: 29 de febrero de 2012
Aquel día el servicio en la nube de Microsoft, Azure, sufrió una caída larga que afectó a clientes de todo el mundo durante horas. La causa raíz, publicada por la propia compañía en su análisis posterior, fue de manual: al crear una máquina virtual, un componente generaba un certificado con validez de un año calculando la fecha de vencimiento como el mismo día del año siguiente. Ejecutado el 29 de febrero de 2012, ese cálculo pedía el 29 de febrero de 2013, una fecha que no existe. El proceso fallaba, el reintento fallaba igual, y el fallo se propagó por el resto del sistema.
Nadie escribió mal esa fórmula por descuido: funcionaba perfectamente en 1.460 de cada 1.461 días. Falló porque la fecha era una entrada oculta que ninguna prueba había podido fijar.
Y una segunda historia, sin bolsa de por medio pero muy ilustrativa: en 2010 un artículo académico muy citado sobre deuda y crecimiento sostenía que los países con deuda por encima del 90% del PIB crecían de media un 0,1% negativo. En 2013, unos investigadores que pidieron la hoja de cálculo original encontraron un rango mal seleccionado que dejaba fuera varios países; con el cálculo corregido, aquel promedio pasaba a ser positivo, en torno al 2,2%. El error tardó tres años en aparecer por una razón muy simple: hasta que alguien no reprodujo el cálculo, no existía forma de verlo. Esto es formación, no asesoramiento de inversión, pero la moraleja operativa vale igual: si nadie puede recalcular tu número, nadie puede corregirlo.