Introducción
Suele decirse que este código:
unsigned int randomNumber = rand() % k;
es una mala idea, al menos si esperás obtener una distribución uniforme. Voy a intentar explorar este tema de manera más formal de lo que he visto hasta ahora.
La razón por la que es problemático es bastante elemental y fácil de entender: imaginá que tenés un generador aleatorio que produce valores entre y (es decir, RAND_MAX es ), y querés valores entre y (así que establecerías ). Entonces, tenemos el siguiente mapeo:
| rand() | Random Number |
|---|---|
| 0, 3, 6, 9 | 0 |
| 1, 4, 7 | 1 |
| 2, 5, 8 | 2 |
Así, en la práctica, ; en cambio, . Esencialmente, el problema radica en que como no divide de manera exacta a RAND_MAX, esto hace que hasta valores adicionales caigan en los primeros números del rango. Para hacerte una idea, podés jugar con el gráfico de abajo, que muestra las probabilidades precisas para distintos valores de RAND_MAX y :
En la práctica, el efecto del parámetro se vuelve más pronunciado cuando el valor de crece; hasta que se hace demasiado grande (es decir, ), momento en el que es casi como si el problema no existiera. De manera más formal, podemos analizar esto definiendo como una variable aleatoria discreta tal que , donde es el número más alto alcanzable por el generador aleatorio y .
Sea . Lo que queremos es encontrar cuál es la función de distribución de probabilidad de , ya que eso nos permitirá entender con precisión lo que está ocurriendo. En primer lugar, notemos que . De ahí, la probabilidad:
Donde . En concreto, la probabilidad de que pertenezca a una clase de equivalencia particular es el número de elementos de esa clase dividido por el total de elementos de los que podemos elegir. Entonces, lo siguiente es calcular .
Los elementos que pertenecen a tienen todos la misma forma: , con . Sin embargo, están acotados superiormente por e inferiormente por :
Por lo tanto, . Lo que significa que nuestra función de distribución de probabilidad es
Queremos verificar que esto suma , ya que eso confirmaría que es una distribución de probabilidad. Usaré las identidades y ; ambas demostraciones pueden encontrarse en {1}:
El hecho de que la distribución dependa del valor de ya es un problema: buscábamos una distribución uniforme y, por lo tanto, esperábamos que . Si graficás la función, verás exactamente el mismo gráfico con el que estuviste jugando arriba.
¿Qué tan grave es?
Qué tan grave sea depende de lo que estés haciendo. Una pregunta más interesante es cómo cuantificar esa gravedad. Una forma de hacerlo es medir qué tan diferente es la distribución de respecto a la distribución de ; hay una infinidad de formas de hacer esto , pero voy a usar la divergencia de Kullback-Leibler .
Entonces, supongamos que es una variable aleatoria con distribución uniforme tal que (es decir, la distribución real que queríamos obtener). Dado algún , sabemos que puede tomar valores entre ; para cada uno de esos valores, tenemos una distribución de probabilidad, llamémosla , y podemos calcular :
Si explorás distintos valores de , quedará claro que solo alcanza 0 cuando divide exactamente a ; y por lo tanto algunos valores de nunca llegan a un punto que alcance ; específicamente, estos son los números primos.
Además, el espacio entre los ceros se va haciendo cada vez mayor a medida que crece. Esto se debe básicamente a que los divisores pares de están más separados cuanto más grandes son; es fácil verlo mirando la descomposición prima de : todo divisor par puede verse exactamente como esa descomposición, solo que con un valor menor o igual para cada , lo que significa que los aumentos en el valor son multiplicativos.
La última clave para entender esto son las parábolas. ¿Por qué aparece una parábola invertida entre cada divisor par? Es simplemente por cómo funciona el módulo:
- Al aumentar , un valor más queda sesgado. Esto significa que la distribución generada se aleja de la distribución objetivo .
- Cuando la cantidad de valores sesgados () es aproximadamente , alcanzamos el punto más alto de la parábola.
- Después de esto solo puede decrecer: a medida que más valores quedan sesgados, la distribución se parece cada vez más a una distribución uniforme nuevamente.
¿Cómo puedo corregirlo?
Quizás la solución más sencilla es hacer:
unsigned int randomNumber = k;
while (randomNumber >= k) randomNumber = rand();
Esto funciona exactamente como se espera: el número producido siempre está entre y , y se distribuye de manera uniforme en el rango. Para verlo, imaginá que definimos como variables independientes e idénticamente distribuidas con distribución uniforme entre y ; entonces podemos calcular la función de distribución de probabilidad de :
¡Lo cual ahora luce exactamente como queríamos! Quizás la siguiente pregunta interesante es cuántas veces llamaremos a rand() hasta encontrar un número que satisfaga la restricción.
Esto tiene una respuesta sencilla: la probabilidad de obtener un número aleatorio en el rango es , y los números aleatorios generados son independientes entre sí; lo que significa que el número de iteraciones es una variable aleatoria geométrica . Por lo tanto, tenemos que , y así podemos esperar tomar iteraciones hasta generar un número aleatorio.
La esperanza señala un problema en nuestro código: si , lo que ocurre frecuentemente en implementaciones prácticas donde es el número máximo representable, entonces el número de iteraciones va a ser alto.
¿Y ahora qué?
Bien, el problema con la última idea es que descartamos la mayor parte de los números que generamos, mientras que el problema con la primera idea es que existe un conjunto de números que sesgan nuestro generador. La siguiente idea es intentar descartar únicamente los números que producen el sesgo.
Podemos escribir como ; si aplicáramos la técnica del módulo sobre un generador con salida entre y , obtendríamos una distribución uniforme, como vimos antes (porque el rango sería exactamente divisible). En efecto, todo lo que necesitamos hacer es descartar los valores al final o al comienzo del rango, y luego aplicar el módulo sobre el resultado; esto es prácticamente una «fusión» de las dos ideas anteriores. Quedaría algo así:
unsigned int threshold = M - (M % k);
unsigned int randomNumber;
do {
randomNumber = rand();
} while (randomNumber >= threshold)
randomNumber = randomNumber % k;
En realidad no necesitamos demostrar que esto es correcto: es evidente que las primeras cuatro líneas muestrean de manera uniforme un número del rango (a partir de la demostración del esquema anterior), y luego aplicar el módulo sobre ese valor lo convierte en una distribución uniforme con el rango deseado (gracias a la demostración del primer esquema).
El único cambio interesante aquí es la cantidad de llamadas a rand(). Ahora, la probabilidad de que un número esté en el rango deseado para el bucle es , y por lo tanto el número de iteraciones es . Podemos ahora ver la cantidad esperada de iteraciones para distintos valores de y :
En efecto, esto es prácticamente lo que esperaríamos que ocurriera: cuanto más grande es , más valores desperdiciamos y, por lo tanto, más iteraciones se necesitan. Podemos demostrar, sin embargo, que el número esperado de iteraciones está acotado por :
Donde hemos usado repetidamente el hecho de que . Notemos que es estrictamente menor que , porque si fuera o más, podríamos incrementar el valor de , lo que contradice la definición del propio operador de división. Por lo tanto, toda la expresión es menor que 2. Esto significa que nuestro algoritmo tiene un tiempo de ejecución esperado de , asumiendo que la operación rand es .
Quizás la última pregunta interesante respecto a este algoritmo es cuál es la probabilidad de hacer más de iteraciones antes de producir un número aleatorio. Trabajando con la función de distribución acumulada de la distribución geométrica se obtiene:
Cuanto más cerca esté de , menos probable será necesitar más iteraciones. Por supuesto, este término se parece bastante a la cantidad esperada de iteraciones, solo que invertido y dividido por dos:
Es bastante fácil ver que este término es siempre mayor que . Sustituyendo esto en la fórmula anterior, obtenemos que .
{1} Knuth, Donald. 1994. Concrete Mathematics.