¿Cómo manejan los lenguajes funcionales los números aleatorios?

68

Lo que quiero decir sobre eso es que en casi todos los tutoriales que he leído sobre lenguajes funcionales, es que una de las mejores cosas de las funciones, es que si llamas a una función con los mismos parámetros dos veces, siempre terminarás con el mismo resultado

¿Cómo demonios haces una función que toma una semilla como parámetro y luego devuelve un número aleatorio basado en esa semilla?

Quiero decir que esto parecería ir en contra de una de las cosas que son tan buenas sobre las funciones, ¿verdad? ¿O me estoy perdiendo algo por completo aquí?

Cafe electrico
fuente

Respuestas:

89

No puede crear una función pura llamada randomque dará un resultado diferente cada vez que se llama. De hecho, ni siquiera puede "llamar" funciones puras. Los aplicas. Así que no te estás perdiendo nada, pero esto no significa que los números aleatorios estén fuera de los límites en la programación funcional. Permítame demostrar, usaré la sintaxis de Haskell en todo momento.

Viniendo de un contexto imperativo, inicialmente puede esperar que aleatorio tenga un tipo como este:

random :: () -> Integer

Pero esto ya se ha descartado porque el azar no puede ser una función pura.

Considere la idea de un valor. Un valor es una cosa inmutable. Nunca cambia y cada observación que puede hacer al respecto es coherente en todo momento.

Claramente, aleatorio no puede producir un valor entero. En cambio, produce una variable aleatoria entera. Su tipo podría verse así:

random :: () -> Random Integer

Excepto que pasar un argumento es completamente innecesario, las funciones son puras, por lo que una random ()es tan buena como la otra random (). Daré al azar, de aquí en adelante, este tipo:

random :: Random Integer

Lo cual está muy bien, pero no es muy útil. Es posible que pueda escribir expresiones como random + 42, pero no puede, porque no va a escribir. No puedes hacer nada con variables aleatorias, todavía.

Ésto plantea una pregunta interesante. ¿Qué funciones deberían existir para manipular variables aleatorias?

Esta función no puede existir:

bad :: Random a -> a

de alguna manera útil, porque entonces podrías escribir:

badRandom :: Integer
badRandom = bad random

Lo que introduce una inconsistencia. Se supone que badRandom es un valor, pero también es un número aleatorio; Una contradicción.

Quizás deberíamos agregar esta función:

randomAdd :: Integer -> Random Integer -> Random Integer

Pero esto es solo un caso especial de un patrón más general. Debería poder aplicar cualquier función a una cosa aleatoria para obtener otras cosas aleatorias como esta:

randomMap :: (a -> b) -> Random a -> Random b

En lugar de escribir random + 42, ahora podemos escribir randomMap (+42) random.

Si todo lo que tuviera fuera randomMap, no podría combinar variables aleatorias. No podría escribir esta función, por ejemplo:

randomCombine :: Random a -> Random b -> Random (a, b)

Puedes intentar escribirlo así:

randomCombine a b = randomMap (\a' -> randomMap (\b' -> (a', b')) b) a

Pero tiene el tipo incorrecto. En lugar de terminar con un Random (a, b), terminamos con unRandom (Random (a, b))

Esto se puede solucionar agregando otra función:

randomJoin :: Random (Random a) -> Random a

Pero, por razones que eventualmente pueden quedar claras, no voy a hacer eso. En cambio, voy a agregar esto:

randomBind :: Random a -> (a -> Random b) -> Random b

No es inmediatamente obvio que esto realmente resuelva el problema, pero lo hace:

randomCombine a b = randomBind a (\a' -> randomMap (\b' -> (a', b')) b)

De hecho, es posible escribir randomBind en términos de randomJoin y randomMap. También es posible escribir randomJoin en términos de randomBind. Pero, me iré haciendo esto como ejercicio.

Podríamos simplificar esto un poco. Permítame definir esta función:

randomUnit :: a -> Random a

randomUnit convierte un valor en una variable aleatoria. Esto significa que podemos tener variables aleatorias que en realidad no son aleatorias. Sin embargo, este siempre fue el caso; podríamos haberlo hecho randomMap (const 4) randomantes. La razón por la cual randomUnit es una buena idea es que ahora podemos definir randomMap en términos de randomUnit y randomBind:

randomMap :: (a -> b) -> Random a -> Random b
randomMap f x = randomBind x (randomUnit . f)

Ok, ahora estamos llegando a alguna parte. Tenemos variables aleatorias que podemos manipular. Sin embargo:

  • No es obvio cómo podríamos implementar estas funciones,
  • Es bastante engorroso.

Implementación

Abordaré números pseudoaleatorios. Es posible implementar estas funciones para números aleatorios reales, pero esta respuesta ya se está haciendo bastante larga.

Esencialmente, la forma en que esto funcionará es que vamos a pasar un valor semilla por todas partes. Cada vez que generamos un nuevo valor aleatorio, produciremos una nueva semilla. Al final, cuando hayamos terminado de construir una variable aleatoria, querremos muestrearla usando esta función:

runRandom :: Seed -> Random a -> a

Voy a definir el tipo aleatorio de esta manera:

data Random a = Random (Seed -> (Seed, a))

Entonces, solo necesitamos proporcionar implementaciones de randomUnit, randomBind, runRandom y random, que es bastante sencillo:

randomUnit :: a -> Random a
randomUnit x = Random (\seed -> (seed, x))

randomBind :: Random a -> (a -> Random b) -> Random b
randomBind (Random f) g =
  Random (\seed ->
    let (seed', x) = f seed
        Random g' = g x in
          g' seed')

runRandom :: Seed -> Random a -> a
runRandom seed (Random f) = (snd . f) seed

Para el azar, voy a suponer que ya hay una función del tipo:

psuedoRandom :: Seed -> (Seed, Integer)

En cuyo caso el azar es justo Random psuedoRandom.

Hacer las cosas menos engorrosas

Haskell tiene azúcar sintáctica para hacer que estas cosas sean más agradables para los ojos. Se llama do-notation y para usarlo todo lo que tenemos que hacer es crear una instancia de Monad for Random.

instance Monad Random where
  return = randomUnit
  (>>=) = randomBind

Hecho. randomCombinede antes ahora podría escribirse:

randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = do
  a' <- a
  b' <- b
  return (a', b')

Si estuviera haciendo esto por mí mismo, incluso iría un paso más allá y crearía una instancia de aplicativo. (No se preocupe si esto no tiene sentido).

instance Functor Random where
  fmap = liftM

instance Applicative Random where
  pure = return
  (<*>) = ap

Entonces randomCombine podría escribirse:

randomCombine :: Random a -> Random b -> Random (a, b)
randomCombine a b = (,) <$> a <*> b

Ahora que tenemos estas instancias, podemos usar en >>=lugar de randomBind, join en lugar de randomJoin, fmap en lugar de randomMap, return en lugar de randomUnit. También obtenemos una gran cantidad de funciones de forma gratuita.

¿Vale la pena? Se podría argumentar que llegar a esta etapa, donde trabajar con números aleatorios no es completamente horrendo, fue bastante difícil y largo. ¿Qué obtuvimos a cambio de este esfuerzo?

La recompensa más inmediata es que ahora podemos ver exactamente qué partes de nuestro programa dependen de la aleatoriedad y qué partes son completamente deterministas. En mi experiencia, forzar una separación estricta como esta simplifica enormemente las cosas.

Asumimos hasta ahora que solo queremos una única muestra de cada variable aleatoria que generamos, pero si resulta que en el futuro realmente nos gustaría ver más de la distribución, esto es trivial. Puede usar runRandom muchas veces en la misma variable aleatoria con diferentes semillas. Esto es posible, por supuesto, en lenguajes imperativos, pero en este caso, podemos estar seguros de que no realizaremos IO no anticipadas cada vez que muestreemos una variable aleatoria y no tengamos que tener cuidado con la inicialización del estado.

dan_waterworth
fuente
66
+1 para un buen ejemplo de uso práctico de Funtores / Mónadas Aplicativos.
jozefg
9
Buena respuesta, pero va un poco demasiado rápido con algunos pasos. Por ejemplo, ¿por qué bad :: Random a -> aintroducir inconsistencias? ¿Qué tiene de malo? Vaya lentamente en la explicación, especialmente para los primeros pasos :) Si pudiera explicar por qué las funciones "útiles" son útiles, ¡esta podría ser una respuesta de 1000 puntos! :)
Andres F.
@AndresF. Ok, lo revisaré un poco.
dan_waterworth
1
@AndresF. He revisado mi respuesta, pero no creo que haya explicado suficientemente cómo puedes usar esta práctica, así que podría volver a ello más tarde.
dan_waterworth
3
Notable respuesta. No soy un programador funcional, pero entiendo la mayoría de los conceptos y he "jugado" con Haskell. Este es el tipo de respuesta que informa al interrogador e inspira a otros a profundizar y aprender más sobre el tema. Desearía poder darle algunos puntos extra por encima de los 10 de mi voto positivo.
RLH
10

Tu no estas equivocado. Si le da la misma semilla a un RNG dos veces, entonces el primer número pseudoaleatorio que devuelve será el mismo. Esto no tiene nada que ver con la programación funcional versus la de efectos secundarios; La definición de una semilla es que una entrada específica causa una salida específica de valores bien distribuidos pero decididamente no aleatorios. Es por eso que se llama pseudoaleatorio, y a menudo es bueno tenerlo, por ejemplo, para escribir pruebas unitarias predecibles, para comparar de manera confiable diferentes métodos de optimización en el mismo problema, etc.

Si realmente desea números no pseudoaleatorios de una computadora, debe conectarlo a algo realmente aleatorio, como una fuente de descomposición de partículas, eventos impredecibles que ocurren dentro de la red en la que está la computadora, etc. Esto es difícil de es correcto y generalmente costoso incluso si funciona, pero es la única forma de no obtener valores pseudoaleatorios (generalmente los valores que recibe de su lenguaje de programación se basan en alguna semilla, incluso si no proporcionó uno explícitamente).

Esto, y solo esto, comprometería la naturaleza funcional de un sistema. Dado que los generadores no pseudoaleatorios son raros, esto no ocurre a menudo, pero sí, si realmente tiene un método que genera números aleatorios verdaderos, entonces al menos esa pequeña parte de su lenguaje de programación no puede ser 100% puramente funcional. Si un lenguaje sería una excepción para él o no es solo una cuestión de cuán pragmático es el implementador del lenguaje.

Kilian Foth
fuente
9
Un verdadero RNG no puede ser un programa informático, independientemente de si es puro (funcional o no) o no. Todos conocemos la cita de von Neumann sobre los métodos aritméticos para producir dígitos aleatorios (aquellos que no lo hacen, búsquelo, preferiblemente todo, no solo la primera oración). Necesitaría interactuar con algún hardware no determinista, que por supuesto también es impuro. Pero eso es solo E / S, que se ha reconciliado con la pureza varias veces en formas muy diferentes. Ningún lenguaje que sea de alguna manera utilizable no permite la E / S por completo; de lo contrario, ni siquiera podría ver el resultado del programa.
¿Qué pasa con el voto negativo?
l0b0
66
¿Por qué una fuente externa y verdaderamente aleatoria comprometería la naturaleza funcional del sistema? Sigue siendo "la misma entrada -> misma salida". A menos que considere la fuente externa como parte del sistema, pero no sería "externa", ¿verdad?
Andres F.
44
Esto no tiene nada que ver con PRNG vs TRNG. No puede tener una función de tipo no constante () -> Integer. Puede tener un PRNG de tipo puramente funcional PRNG_State -> (PRNG_State, Integer), pero deberá inicializarlo por medios impuros).
Gilles 'SO- deja de ser malvado'
44
@Brian estuvo de acuerdo, pero la redacción ("conectarlo a algo realmente aleatorio") sugiere que la fuente aleatoria es externa al sistema. Por lo tanto, el sistema en sí sigue siendo puramente funcional; es la fuente de entrada que no lo es.
Andres F.
6

Una forma es pensarlo como una secuencia infinita de números aleatorios:

IEnumerable<int> randomNumberGenerator = new RandomNumberGenerator(seed);

Es decir, solo piense en ella como una estructura de datos sin fondo, como un lugar Stackdonde solo puede llamar Pop, pero puede llamarlo para siempre. Al igual que una pila inmutable normal, sacar una de la parte superior te da otra pila (diferente).

Por lo tanto, un generador de números aleatorios inmutable (con evaluación diferida) podría verse así:

class RandomNumberGenerator
{
    private readonly int nextSeed;
    private RandomNumberGenerator next;

    public RandomNumberGenerator(int seed)
    {
        this.nextSeed = this.generateNewSeed(seed);
        this.RandomNumber = this.generateRandomNumberBasedOnSeed(seed);
    }

    public int RandomNumber { get; private set; }

    public RandomNumberGenerator Next
    {
        get
        {
            if(this.next == null) this.next = new RandomNumberGenerator(this.nextSeed);
            return this.next;
        }
    }

    private static int generateNewSeed(int seed)
    {
        //...
    }

    private static int generateRandomNumberBasedOnSeed(int seed)
    {
        //...
    }
}

Eso es funcional

Scott Whitlock
fuente
No veo cómo la creación de una lista infinita de números aleatorios es más fácil de trabajar que funcionan como: pseudoRandom :: Seed -> (Seed, Integer). Incluso podría terminar escribiendo una función de este tipo[Integer] -> ([Integer], Integer)
dan_waterworth
2
@dan_waterworth en realidad tiene mucho sentido. No se puede decir que un entero sea aleatorio. Una lista de números puede tener esta propiedad. Entonces, la verdad es que un generador aleatorio puede tener el tipo int -> [int], es decir, una función que toma una semilla y devuelve una lista aleatoria de enteros. Claro, puede tener una mónada estatal alrededor de esto para obtener la notación de haskell. Pero como respuesta genérica a la pregunta, creo que esto es realmente útil.
Simon Bergot
5

Es lo mismo para los lenguajes no funcionales. Ignorando el problema ligeramente separado de los números verdaderamente aleatorios aquí.

Un generador de números aleatorios siempre toma un valor de inicialización y para la misma inicialización devuelve la misma secuencia de números aleatorios (muy útil si necesita probar un programa que utiliza números aleatorios). Básicamente comienza con la semilla que eliges y luego usa el último resultado como semilla para la próxima iteración. Así que la mayoría de las implementaciones son funciones "puras" como las describe: tome un valor y para el mismo valor siempre devuelve el mismo resultado.

Thorsten Müller
fuente