Números Pseudoaleatorios · Tema 37

Múltiples secuencias

Un flujo por componente: llegadas, servicio y ruteo con streams independientes que no se pisan ni se acoplan.

01 · Punto de partida

Un solo stream acopla lo que toca

Si tu banco simulado saca llegadas y tiempos de servicio del mismo stream, agregar un cliente de más corre toda la secuencia de servicio: dos escenarios dejan de ser comparables y un cambio inocente altera todo. La cura es asignar un stream por componente: el de llegadas solo da llegadas, el de servicio solo servicio, cada uno con vida propia.

Y hay un reverso poderoso: para comparar dos escenarios conviene lo contrario a propósito —los mismos streams en ambos (common random numbers)— para que la diferencia mida el cambio y no el azar.

  • ¿Por qué un stream único acopla componentes?
  • ¿Cómo creo k streams garantizados sin solape?
  • ¿Cuándo conviene compartir streams a propósito (CRN)?
  • ¿Por qué las semillas “1, 2, 3…” son una trampa?

02 · Definición

Streams, substreams y madre

◈

Stream

Secuencia larga independiente: la de llegadas, la de servicio. No comparten estado ni posición.

⇄

Semilla madre

Un solo número maestro del que derivan todos (SeedSequence(2026).spawn(3)). Trazable y sin colisiones.

▣

CRN

Common Random Numbers: mismos streams en escenarios A y B para comparar con menos varianza. Excepción deliberada.

Cómo partir según tu generador.
GeneradorMecanismoNotas
NumPy (PCG64)SeedSequence(m).spawn(k)Garantía matemática, lo recomendado
PCG32Incremento impar por stream2⁶³ streams disjuntos
PhiloxLlave por streamIdeal paralelo (Tema 17)
random clásicoRandom(hash(madre, i))Práctico; sin garantía formal

03 · Técnicas de partición

Espaciar, incrementar o derivar

Hay tres formas honestas de partir: bloques (stream i arranca en i·L: exige salto rápido), incrementos/llaves (cada stream vive en su carril: PCG/Philox) y derivación por hash (semilla i = hash(madre, i): práctica universal sin garantías formales).

Semillas 1, 2, 3…
Trampa clásica: con LCG chicos los estados iniciales están pegados y los streams se solapan o correlacionan. Parecen “distintas” y son vecinas del mismo ciclo.
Reloj por stream
Dos streams nacidos en el mismo milisegundo son gemelos (Tema 33). El tiempo no es esquema de partición.
CRN bien usado
Escenarios A/B con idénticos streams: la diferencia A−B tiene menos varianza porque el azar común se cancela. Solo vale si el consumo por componente es idéntico en ambos.

Bien partido

Madre + spawn

Streams disjuntos garantizados, réplica 137 re-corrible sola, comparación CRN válida.

Mal partido

Semillas a mano

Solapes silenciosos y acoples entre componentes: cambiar un parámetro altera streams ajenos.

04 · Ejemplos

Tres repartos típicos

Madre 2026→spawn(3)→llegadas · servicio · ruteo
  1. 1
    Banco: llegadas vs servicio.

    Agregar una llegada no mueve el servicio: los escenarios son comparables y el debug es por componente.

  2. 2
    A/B con CRN.

    Misma madre en cola con 1 y 2 cajeros: la diferencia de espera mide los cajeros, no la suerte.

  3. 3
    100 réplicas.

    spawn(100): la réplica 57 se re-corre sola con su hijo 57. Sin spawn, repetir una es repetir todas.

05 · Implementación en Python

Derivar streams de una madre

Con la librería estándar: semilla hija = hash(madre, índice), un Random por componente. Mismo esquema mental que spawn, sin NumPy.

Python en tu navegador. Cambiá la madre y todo cambia; cambiá un componente y los demás ni se enteran.

import hashlib
import random

def hijo(madre, i):
    h = hashlib.sha256(f"{madre}:{i}".encode()).digest()
    return int.from_bytes(h[:8], "big")


def streams(madre, n=3):
    return [random.Random(hijo(madre, i)) for i in range(n)]


lleg, serv, ruta = streams(2026)
print([round(lleg.random(), 4) for _ in range(3)])
print([round(serv.random(), 4) for _ in range(3)])

Tres generadores independientes desde la madre 2026: tocar llegadas no mueve servicio.

CRN: mismos streams, dos escenarios

import hashlib
import random

def hijo(madre, i):
    h = hashlib.sha256(f"{madre}:{i}".encode()).digest()
    return int.from_bytes(h[:8], "big")


def espera(cajeros, madre=2026, n=2000):
    lleg = random.Random(hijo(madre, 0))
    serv = random.Random(hijo(madre, 1))
    t, libres = 0.0, [0.0] * cajeros
    suma = 0.0
    for _ in range(n):
        t += lleg.expovariate(1.0)
        s = serv.expovariate(1.5)
        c = min(range(cajeros), key=lambda i: libres[i])
        ini = max(t, libres[c])
        suma += ini - t
        libres[c] = ini + s
    return suma / n


print("1 cajero:", round(espera(1), 4))
print("2 cajeros:", round(espera(2), 4))

06 · Exploración

Laboratorio: solape a la vista

LCG de juguete m = 256 con dos streams de largo L: con semillas vecinas (1, 2) se solapan; con partición por bloques (0, 128) no. El panel cuenta valores compartidos y correlación entre streams.

EXPERIMENTO 37

Vecinas vs bloques

solape = valores en común

Los resultados numéricos aparecen debajo.
Compartidos—
Corr(A,B)—
Período—
Veredicto—

Vecinas con L = 60: solape visible.

Puntos violeta = valores que aparecen en ambos streams. Con bloques bien separados no hay ninguno.

Preguntas para explorar

  1. Con vecinas y L = 200: ¿cuántos comparten? ¿Por qué tantos si “son distintas”?
  2. Con bloques y L = 120: ¿hay solape? ¿Y con L = 200?
  3. ¿Qué enseña esto sobre sembrar réplicas con 1, 2, 3… en un LCG real?
Ver respuestas sugeridas
  1. Casi todos: la semilla 2 está a un paso de la 1 en el mismo ciclo de 256. Vecinas = casi el mismo stream corrido.
  2. Con L = 120 no (bloques 0–119 y 128–247 disjuntos); con L = 200 sí en el borde: el bloque debe ser ≥ L con margen.
  3. Que “distinto número” ≠ “distinto tramo”: sin esquema (spawn/incremento/llave) las réplicas se pisan.

07 · Comprensión

Confusiones frecuentes

«Una semilla por corrida alcanza»

Alcanza para repetir, no para independizar: todos los componentes comparten el ciclo y se acoplan. Hace falta una semilla por componente derivada de la madre.

«Semillas al azar son independientes»

Dos puntos al azar en un ciclo de p pueden caer cerca y solaparse (Tema 33): el azar no garantiza disjunción, el esquema sí.

«CRN es hacer trampa»

No: es reducción de varianza legítima y documentada. Trampa sería usar CRN y no declararlo, o usarlo con consumos desalineados.

«Más streams = más lento»

Crear 100 Random cuesta microsegundos; el consumo total es el mismo. El costo es cero, el beneficio es independencia.

08 · Práctica guiada

Ejercicios con Python

Ejercicio 1: vecinas que se pisan

Con el juguete m = 256 mostrá que las semillas 1 y 2 comparten casi todo en L = 200.

def lcg(semilla, n, a=13, c=7, m=256):
    x = semilla % m
    sal = []
    for _ in range(n):
        x = (a * x + c) % m
        sal.append(x)
    return sal


a, b = lcg(1, 200), lcg(2, 200)
print("compartidos:", len(set(a) & set(b)))
Ver solución razonada

≈ 169 de 200 valores en común: la semilla 2 vive en el mismo ciclo de 256, a pocos pasos de la 1. Vecinas ≠ independientes.

Ejercicio 2: bloques que separan

Tomá un solo stream madre y partilo en bloques [0:60] y [128:188]: verificá disjunción.

def lcg(semilla, n, a=13, c=7, m=256):
    x = semilla % m
    sal = []
    for _ in range(n):
        x = (a * x + c) % m
        sal.append(x)
    return sal


todo = lcg(0, 256)
print("solape:", len(set(todo[0:60]) & set(todo[128:188])))
Ver solución

0 compartidos: bloques disjuntos del mismo ciclo. Así se reparte un LCG cuando no hay spawn: con regla y margen.

Ejercicio 3: CRN que reduce varianza

Compará 1 vs 2 cajeros con mismos streams (CRN) y con streams distintos: ¿cuál diferencia es más estable entre madres?

Ver una posible respuesta
import hashlib
import random

def hijo(madre, i):
    h = hashlib.sha256(f"{madre}:{i}".encode()).digest()
    return int.from_bytes(h[:8], "big")


def espera(cajeros, s1, s2, n=500):
    lleg = random.Random(s1)
    serv = random.Random(s2)
    t, libres, suma = 0.0, [0.0] * cajeros, 0.0
    for _ in range(n):
        t += lleg.expovariate(1.0)
        s = serv.expovariate(1.5)
        c = min(range(cajeros), key=lambda i: libres[i])
        ini = max(t, libres[c])
        suma += ini - t
        libres[c] = ini + s
    return suma / n


for madre in [11, 22, 33]:
    d_crn = espera(1, hijo(madre, 0), hijo(madre, 1)) - espera(2, hijo(madre, 0), hijo(madre, 1))
    d_ind = espera(1, hijo(madre, 0), hijo(madre, 1)) - espera(2, hijo(900 + madre, 0), hijo(900 + madre, 1))
    print(madre, "CRN:", round(d_crn, 4), "indep:", round(d_ind, 4))

Ambas vías estiman la misma brecha (≈ 1,0 a favor de 2 cajeros). La ventaja de CRN —menos varianza del estimador de la diferencia porque el azar común se cancela— se aprecia al promediar decenas de madres: extendé el bucle a 30 y compará el desvío de cada columna.

09 · Síntesis

Ideas para recordar

  • Un stream por componente desde una madre; nunca un único stream global.
  • Partir con esquema: spawn / incremento / llave / bloques con margen.
  • Semillas vecinas o por reloj no independizan: solapan.
  • CRN: mismos streams en A y B para comparar, declarado y con igual consumo.
  • Réplica re-corrible sola = spawn por índice.

En el próximo tema escalamos a paralelo real: simulaciones paralelas y semillas sin solapamientos.