Comment les langages fonctionnels gèrent-ils des nombres aléatoires?

68

Ce que je veux dire par là, c’est que dans presque tous les tutoriels que j’ai lus sur les langages fonctionnels, c’est que l’un des avantages des fonctions, c’est que si vous appelez une fonction avec les mêmes paramètres deux fois, vous obtiendrez toujours la même résultat.

Comment pouvez-vous alors créer une fonction qui prend une graine en tant que paramètre, puis renvoie un nombre aléatoire basé sur cette graine?

Je veux dire que cela semblerait aller à l’encontre d’une des choses qui sont si bonnes au sujet des fonctions, non? Ou est-ce que je manque complètement quelque chose ici?

Café électrique
la source

Réponses:

89

Vous ne pouvez pas créer une fonction pure appelée randomqui donnera un résultat différent à chaque appel. En fait, vous ne pouvez même pas "appeler" des fonctions pures. Vous les appliquez. Donc, vous ne manquez rien, mais cela ne signifie pas que les nombres aléatoires sont interdits dans la programmation fonctionnelle. Permettez-moi de démontrer que je vais utiliser la syntaxe Haskell.

Venant d’un arrière-plan impératif, vous pouvez vous attendre initialement à ce que random ait un type comme celui-ci:

random :: () -> Integer

Mais cela a déjà été exclu parce que le hasard ne peut pas être une fonction pure.

Considérons l'idée d'une valeur. Une valeur est une chose immuable. Cela ne change jamais et toutes les observations que vous pouvez faire à ce sujet sont cohérentes pour tous les temps.

Clairement, aléatoire ne peut pas produire une valeur Integer. Au lieu de cela, il produit une variable aléatoire Integer. Son type pourrait ressembler à ceci:

random :: () -> Random Integer

Sauf que passer un argument est totalement inutile, les fonctions sont pures, donc l'une random ()vaut l'autre random (). Je vais donner au hasard, à partir de maintenant, ce type:

random :: Random Integer

Ce qui est très bien, mais pas très utile. Vous pouvez vous attendre à pouvoir écrire des expressions comme random + 42, mais vous ne pouvez pas le faire, car cela ne vérifiera pas le texte. Vous ne pouvez rien faire avec des variables aléatoires pour le moment.

Cela soulève une question intéressante. Quelles fonctions devraient exister pour manipuler des variables aléatoires?

Cette fonction ne peut pas exister:

bad :: Random a -> a

de toute façon utile, car alors vous pourriez écrire:

badRandom :: Integer
badRandom = bad random

Ce qui introduit une incohérence. badRandom est censé être une valeur, mais c'est aussi un nombre aléatoire; une contradiction.

Peut-être devrions-nous ajouter cette fonction:

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

Mais ceci n’est qu’un cas particulier d’un modèle plus général. Vous devriez pouvoir appliquer n'importe quelle fonction à une chose aléatoire afin d'obtenir d'autres choses aléatoires comme ceci:

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

Au lieu d'écrire random + 42, nous pouvons maintenant écrire randomMap (+42) random.

Si vous n'aviez que randomMap, vous ne pourriez pas combiner des variables aléatoires ensemble. Vous ne pouvez pas écrire cette fonction par exemple:

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

Vous pourriez essayer de l'écrire comme ceci:

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

Mais c'est du mauvais type. Au lieu de finir avec un Random (a, b), on se retrouve avec unRandom (Random (a, b))

Cela peut être corrigé en ajoutant une autre fonction:

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

Mais, pour des raisons qui pourraient éventuellement devenir claires, je ne le ferai pas. Au lieu de cela, je vais ajouter ceci:

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

Il n'est pas immédiatement évident que cela résout le problème, mais cela le fait:

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

En fait, il est possible d'écrire randomBind en termes randomJoin et randomMap. Il est également possible d'écrire randomJoin en termes de randomBind. Mais, je vais laisser faire cela comme un exercice.

Nous pourrions simplifier un peu cela. Permettez-moi de définir cette fonction:

randomUnit :: a -> Random a

randomUnit transforme une valeur en une variable aléatoire. Cela signifie que nous pouvons avoir des variables aléatoires qui ne sont pas réellement aléatoires. Ce fut toujours le cas cependant; nous aurions pu faire randomMap (const 4) randomavant. La raison pour laquelle randomUnit est une bonne idée est que nous pouvons maintenant définir randomMap en termes de randomUnit et randomBind:

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

Ok, maintenant nous allons quelque part. Nous avons des variables aléatoires que nous pouvons manipuler. Pourtant:

  • Il n'est pas évident de savoir comment nous pourrions réellement implémenter ces fonctions,
  • C'est assez encombrant.

la mise en oeuvre

Je vais aborder des nombres pseudo aléatoires. Il est possible d'implémenter ces fonctions pour de vrais nombres aléatoires, mais cette réponse est déjà assez longue.

Cela fonctionnera essentiellement en transmettant une valeur de départ partout. Chaque fois que nous générons une nouvelle valeur aléatoire, nous produirons une nouvelle graine. A la fin, lorsque nous aurons fini de construire une variable aléatoire, nous voudrons en échantillonner à l'aide de cette fonction:

runRandom :: Seed -> Random a -> a

Je vais définir le type aléatoire de la manière suivante:

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

Ensuite, nous devons juste fournir les implémentations de randomUnit, randomBind, runRandom et random, ce qui est assez simple:

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

Pour le hasard, je suppose qu'il existe déjà une fonction du type:

psuedoRandom :: Seed -> (Seed, Integer)

Dans ce cas, le hasard est juste Random psuedoRandom.

Rendre les choses moins lourdes

Haskell utilise du sucre syntaxique pour rendre les choses plus agréables aux yeux. Cela s'appelle do-notation et pour tout utiliser, il faut créer une instance de Monad pour Random.

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

Terminé. randomCombined'avant pourrait maintenant être écrit:

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

Si je le faisais moi-même, j'irais même plus loin et créerais un exemple d'application. (Ne vous inquiétez pas si cela n'a aucun sens).

instance Functor Random where
  fmap = liftM

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

RandomCombine pourrait alors être écrit:

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

Maintenant que nous avons ces instances, nous pouvons utiliser à la >>=place de randomBind, join à la place de randomJoin, fmap au lieu de randomMap, return à la place de randomUnit. Nous recevons également tout un tas de fonctions gratuitement.

Est-ce que ça vaut le coup? Vous pourriez argumenter qu'arriver à cette étape, où travailler avec des nombres aléatoires n'était pas complètement horrible, était assez difficile et long. Qu'avons-nous eu en échange de cet effort?

La récompense la plus immédiate est que nous pouvons maintenant voir exactement quelles parties de notre programme dépendent de l’aléatoire et lesquelles sont entièrement déterministes. D'après mon expérience, forcer une séparation aussi stricte que celle-ci simplifie énormément les choses.

Jusqu'à présent, nous avons supposé que nous voulions seulement un échantillon de chaque variable aléatoire que nous générons, mais s'il s'avère qu'à l'avenir, nous aimerions voir plus de la distribution, c'est trivial. Vous pouvez simplement utiliser runRandom de nombreuses fois sur la même variable aléatoire avec des graines différentes. Ceci est bien sûr possible dans les langages impératifs, mais dans ce cas, nous pouvons être certains que nous n'allons pas effectuer d'E / S inattendues à chaque fois que nous échantillonnons une variable aléatoire et que nous ne devons pas faire attention à l'initialisation de l'état.

dan_waterworth
la source
6
+1 pour un bon exemple d'utilisation pratique des foncteurs / monades.
jozefg
9
Bonne réponse, mais ça va un peu trop vite avec quelques étapes. Par exemple, pourquoi bad :: Random a -> aintroduirait des incohérences? Quel est le problème? S'il vous plaît allez lentement dans l'explication, en particulier pour les premières étapes :) Si vous pouviez expliquer pourquoi les fonctions "utiles" sont utiles, cela pourrait être une réponse en 1000 points! :)
Andres F.
@AndresF. Ok, je vais le réviser un peu.
dan_waterworth
1
@AndresF. J'ai révisé ma réponse, mais je ne pense pas avoir suffisamment expliqué comment vous pourriez utiliser cette pratique, alors je pourrais y revenir plus tard.
dan_waterworth
3
Réponse remarquable. Je ne suis pas un programmeur fonctionnel, mais je comprends la plupart des concepts et j'ai "joué" avec Haskell. C'est le type de réponse qui informe à la fois le questionneur et inspire les autres à aller plus loin et à en apprendre davantage sur le sujet. J'aimerais pouvoir vous donner quelques points supplémentaires au-dessus des 10 de mon vote positif.
RLH
10

Tu n'as pas tort. Si vous donnez la même graine à un groupe de ressources nucléaires deux fois, le premier nombre pseudo-aléatoire renvoyé sera le même. Cela n'a rien à voir avec la programmation fonctionnelle par rapport aux effets secondaires; la définition d'une graine est qu'une entrée spécifique provoque une sortie spécifique de valeurs bien distribuées mais décidément non aléatoires. C'est pourquoi on l'appelle pseudo-aléatoire, et c'est souvent une bonne chose d'avoir, par exemple, d'écrire des tests unitaires prévisibles, de comparer de manière fiable différentes méthodes d'optimisation sur le même problème, etc.

Si vous voulez réellement des nombres non pseudo-aléatoires d'un ordinateur, vous devez le brancher à quelque chose de vraiment aléatoire, comme une source de désintégration de particules, des événements imprévisibles survenant dans le réseau sur lequel l'ordinateur est allumé, etc. bien faire et généralement coûteux même si cela fonctionne, mais c'est le seul moyen de ne pas obtenir de valeurs pseudo-aléatoires (généralement, les valeurs que vous recevez de votre langage de programmation sont basées sur une graine, même si vous n'en avez pas explicitement fournie.)

Ceci, et seulement cela, compromettrait la nature fonctionnelle d'un système. Comme les générateurs non pseudo-aléatoires sont rares, cela ne se produit pas souvent, mais si vous avez vraiment une méthode générant de vrais nombres aléatoires, au moins ce petit bout de votre langage de programmation ne peut pas être 100% fonctionnel. Le fait qu'une langue fasse une exception ou non est simplement une question de pragmatisme de la part du développeur de la langue.

Kilian Foth
la source
9
Un vrai GNA ne peut pas être un programme informatique, qu'il soit purement fonctionnel ou non. Nous connaissons tous la citation de von Neumann sur les méthodes arithmétiques permettant de produire des chiffres aléatoires (ceux qui ne le font pas, le recherchent - de préférence le tout, pas seulement la première phrase). Vous auriez besoin d'interagir avec du matériel non déterministe, ce qui est bien sûr également impur. Mais il ne s’agit que d’I / O, qui a été réconcilié avec la pureté à plusieurs reprises et de manières très différentes. Aucune langue utilisable n’autorise les E / S - vous ne pourriez même pas voir le résultat du programme autrement.
Qu'est-ce qui se passe avec le vote négatif?
l0b0
6
Pourquoi une source externe et vraiment aléatoire compromettrait-elle la nature fonctionnelle du système? C'est toujours "même entrée -> même sortie". À moins que vous considériez la source externe comme faisant partie du système, mais alors cela ne serait pas "externe", n'est-ce pas?
Andres F.
4
Cela n'a rien à voir avec PRNG vs TRNG. Vous ne pouvez pas avoir une fonction de type non constante () -> Integer. Vous pouvez avoir un type de PRNG purement fonctionnel PRNG_State -> (PRNG_State, Integer), mais vous devrez l’initialiser par des moyens impurs).
Gilles, arrête de faire le mal
4
@Brian D'accord, mais le libellé ("rattachez-le à quelque chose de vraiment aléatoire") suggère que la source aléatoire est externe au système. Par conséquent, le système lui-même reste purement fonctionnel; c'est la source d'entrée qui ne l'est pas.
Andres F.
6

Une solution consiste à la considérer comme une suite infinie de nombres aléatoires:

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

C’est-à-dire qu’il suffit de penser à une structure de données sans fond, comme Stacksi vous ne pouviez appeler que Pop, mais vous pouvez toujours l’appeler. Comme une pile normale immuable, en enlever une du haut vous donne une autre pile (différente).

Ainsi, un générateur de nombre aléatoire immuable (avec évaluation paresseuse) pourrait ressembler à ceci:

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)
    {
        //...
    }
}

C'est fonctionnel.

Scott Whitlock
la source
Je ne vois pas comment créer une liste infinie de nombres aléatoires est plus facile à travailler que la fonction comme: pseudoRandom :: Seed -> (Seed, Integer). Vous pourriez même finir par écrire une fonction de ce type[Integer] -> ([Integer], Integer)
dan_waterworth le
2
@ dan_waterworth en fait, cela a beaucoup de sens. On ne peut pas dire qu'un entier est aléatoire. Une liste de nombres peut avoir cette propriété. En réalité, un générateur aléatoire peut avoir le type int -> [int], c’est-à-dire une fonction qui prend une graine et renvoie une liste aléatoire d’entiers. Bien sûr, vous pouvez avoir une monade d'état autour de cela pour obtenir la notation de haskell's do. Mais comme réponse générique à la question, je pense que cela est vraiment utile.
Simon Bergot Le
5

C'est la même chose pour les langages non fonctionnels. Ignorer le problème légèrement distinct des nombres vraiment aléatoires ici.

Un générateur de nombres aléatoires prend toujours une valeur de départ et renvoie pour la même valeur de départ la même séquence de nombres aléatoires (très utile si vous devez tester un programme utilisant des nombres aléatoires). Fondamentalement, cela commence par la graine que vous choisissez, puis utilise le dernier résultat comme graine pour la prochaine itération. Ainsi, la plupart des implémentations sont des fonctions "pures" telles que vous les décrivez: Prenez une valeur et pour la même valeur, vous obtenez toujours le même résultat.

Thorsten Müller
la source