web-dev-qa-db-fra.com

Pourquoi la nouvelle bibliothèque aléatoire est-elle meilleure que std :: Rand ()?

J'ai donc vu un exposé intitulé Rand () considéré comme préjudiciable et il a préconisé l'utilisation du paradigme de distribution par moteur de la génération de nombres aléatoires par rapport au simple paradigme std::Rand() plus modulus.

Cependant, je voulais voir les échecs de std::Rand() de première main et j'ai donc fait une expérience rapide:

  1. En gros, j’ai écrit 2 fonctions getRandNum_Old() et getRandNum_New() qui ont généré un nombre aléatoire compris entre 0 et 5 inclus à l’aide de std::Rand() et std::mt19937 + std::uniform_int_distribution.
  2. Ensuite, j'ai généré 960 000 (divisibles par 6) nombres aléatoires en utilisant "l'ancienne" méthode et enregistré les fréquences des nombres de 0 à 5. Ensuite, j'ai calculé l'écart type de ces fréquences. Ce que je recherche, c’est un écart-type aussi faible que possible car c’est ce qui se produirait si la distribution était vraiment uniforme.
  3. J'ai exécuté cette simulation 1000 fois et enregistré l'écart type pour chaque simulation. J'ai également enregistré le temps qu'il a pris en millisecondes.
  4. Ensuite, j'ai refait exactement la même chose, mais cette fois en générant des nombres aléatoires de la "nouvelle" manière.
  5. Enfin, j’ai calculé l’écart moyen et standard de la liste des écarts-types pour l’ancienne et la nouvelle méthode, ainsi que les écarts moyen et standard pour la liste des durées prises entre l’ancienne et la nouvelle.

Voici les résultats:

[OLD WAY]
Spread
       mean:  346.554406
    std dev:  110.318361
Time Taken (ms)
       mean:  6.662910
    std dev:  0.366301

[NEW WAY]
Spread
       mean:  350.346792
    std dev:  110.449190
Time Taken (ms)
       mean:  28.053907
    std dev:  0.654964

Étonnamment, la répartition globale des rouleaux était la même pour les deux méthodes. En d'autres termes, std::mt19937 + std::uniform_int_distribution n'était pas "plus uniforme" que la simple std::Rand() + %. Une autre observation que j'ai faite est que la nouvelle était environ 4 fois plus lente que l'ancienne. Globalement, il me semblait que je payais un coût énorme en vitesse sans presque aucun gain en qualité.

Mon expérience est-elle imparfaite? Ou est-ce que std::Rand() n'est pas si mal, et peut-être même mieux?

Pour référence, voici le code que j'ai utilisé dans son intégralité:

#include <cstdio>
#include <random>
#include <algorithm>
#include <chrono>

int getRandNum_Old() {
    static bool init = false;
    if (!init) {
        std::srand(time(nullptr)); // Seed std::Rand
        init = true;
    }

    return std::Rand() % 6;
}

int getRandNum_New() {
    static bool init = false;
    static std::random_device rd;
    static std::mt19937 eng;
    static std::uniform_int_distribution<int> dist(0,5);
    if (!init) {
        eng.seed(rd()); // Seed random engine
        init = true;
    }

    return dist(eng);
}

template <typename T>
double mean(T* data, int n) {
    double m = 0;
    std::for_each(data, data+n, [&](T x){ m += x; });
    m /= n;
    return m;
}

template <typename T>
double stdDev(T* data, int n) {
    double m = mean(data, n);
    double sd = 0.0;
    std::for_each(data, data+n, [&](T x){ sd += ((x-m) * (x-m)); });
    sd /= n;
    sd = sqrt(sd);
    return sd;
}

int main() {
    const int N = 960000; // Number of trials
    const int M = 1000;   // Number of simulations
    const int D = 6;      // Num sides on die

    /* Do the things the "old" way (blech) */

    int freqList_Old[D];
    double stdDevList_Old[M];
    double timeTakenList_Old[M];

    for (int j = 0; j < M; j++) {
        auto start = std::chrono::high_resolution_clock::now();
        std::fill_n(freqList_Old, D, 0);
        for (int i = 0; i < N; i++) {
            int roll = getRandNum_Old();
            freqList_Old[roll] += 1;
        }
        stdDevList_Old[j] = stdDev(freqList_Old, D);
        auto end = std::chrono::high_resolution_clock::now();
        auto dur = std::chrono::duration_cast<std::chrono::microseconds>(end-start);
        double timeTaken = dur.count() / 1000.0;
        timeTakenList_Old[j] = timeTaken;
    }

    /* Do the things the cool new way! */

    int freqList_New[D];
    double stdDevList_New[M];
    double timeTakenList_New[M];

    for (int j = 0; j < M; j++) {
        auto start = std::chrono::high_resolution_clock::now();
        std::fill_n(freqList_New, D, 0);
        for (int i = 0; i < N; i++) {
            int roll = getRandNum_New();
            freqList_New[roll] += 1;
        }
        stdDevList_New[j] = stdDev(freqList_New, D);
        auto end = std::chrono::high_resolution_clock::now();
        auto dur = std::chrono::duration_cast<std::chrono::microseconds>(end-start);
        double timeTaken = dur.count() / 1000.0;
        timeTakenList_New[j] = timeTaken;
    }

    /* Display Results */

    printf("[OLD WAY]\n");
    printf("Spread\n");
    printf("       mean:  %.6f\n", mean(stdDevList_Old, M));
    printf("    std dev:  %.6f\n", stdDev(stdDevList_Old, M));
    printf("Time Taken (ms)\n");
    printf("       mean:  %.6f\n", mean(timeTakenList_Old, M));
    printf("    std dev:  %.6f\n", stdDev(timeTakenList_Old, M));
    printf("\n");
    printf("[NEW WAY]\n");
    printf("Spread\n");
    printf("       mean:  %.6f\n", mean(stdDevList_New, M));
    printf("    std dev:  %.6f\n", stdDev(stdDevList_New, M));
    printf("Time Taken (ms)\n");
    printf("       mean:  %.6f\n", mean(timeTakenList_New, M));
    printf("    std dev:  %.6f\n", stdDev(timeTakenList_New, M));
}
80
rcplusplus

Pratiquement toutes les implémentations de "vieux" Rand() utilisent un LCG ; bien qu'ils ne soient généralement pas les meilleurs générateurs du moment, vous ne les verrez généralement pas échouer lors d'un test aussi élémentaire - l'écart moyen et standard est généralement obtenu, même par les pires PRNG.

Les échecs courants de "mauvais" - mais assez commun - les implémentations de Rand() sont:

  • faible caractère aléatoire des bits de poids faible;
  • courte période;
  • faible Rand_MAX;
  • une certaine corrélation entre les extractions successives (en général, les GCL produisent des nombres sur un nombre limité d'hyperplans, même si cela peut être atténué).

Néanmoins, aucun d’entre eux n’est spécifique à l’API de Rand(). Une implémentation particulière pourrait placer un générateur de famille xorshift derrière srand/Rand et obtenir algoritmiquement un état de la technique PRNG sans changement d’interface, donc pas de test similaire celui que vous avez montré montrerait une faiblesse dans la sortie.

Edit: @ R. note correctement que la Rand/srand L’interface est limitée par le fait que srand prend un unsigned int, ainsi tout générateur qu’une implémentation peut placer derrière est intrinsèquement limité à UINT_MAX semences de départ possibles (et donc les séquences générées). C’est vrai, bien que l’API puisse être étendue de manière triviale pour que srand prenne un unsigned long long ou ajoute une surcharge srand(unsigned char *, size_t).


En effet, le problème réel avec Rand() ne concerne pas beaucoup la mise en œuvre en principe mais:

  • rétrocompatibilité; beaucoup d'implémentations actuelles utilisent des générateurs sous-optimaux, généralement avec des paramètres mal choisis; Un exemple notoire est Visual C++, qui affiche un Rand_MAX de seulement 32767. Cependant, cela ne peut pas être changé facilement, cela romprait la compatibilité avec le passé - les personnes utilisant srand avec une valeur de départ fixe pour des simulations reproductibles ne serait pas trop heureux (en effet, la mise en œuvre susmentionnée par IIRC remonte aux premières versions de Microsoft C - ou même de Lattice C - datant du milieu des années 80);
  • interface simpliste; Rand() fournit un seul générateur avec l'état global pour l'ensemble du programme. Bien que cela convienne parfaitement (et en fait très pratique) pour de nombreux cas d'utilisation simples, cela pose des problèmes:

    • avec un code multithread: pour résoudre ce problème, vous avez besoin d'un mutex global - ce qui ralentirait tout sans aucune raison et supprime toute possibilité de répétabilité, car la séquence d'appels devient aléatoire elle-même - ou état de thread local; ce dernier a été adopté par plusieurs implémentations (notamment Visual C++);
    • si vous voulez une séquence "privée", reproductible dans un module spécifique de votre programme qui n’affecte pas l’état global.

Enfin, la Rand état des lieux:

  • ne spécifie pas une implémentation réelle (le standard C ne fournit qu’un exemple d’implémentation), ainsi tout programme destiné à produire une sortie reproductible (ou s’attendant à un PRNGde quelque qualité connue) entre différents compilateurs doit lancer sa propre générateur;
  • ne fournit pas de méthode multiplateforme pour obtenir une graine décente (time(NULL) n'est pas, car ce n'est pas assez granulaire, et souvent - pensez aux périphériques intégrés sans RTC - même pas aléatoires. suffisant).

D'où le nouvel en-tête <random>, qui tente de réparer ce désordre en fournissant des algorithmes qui sont:

  • entièrement spécifié (vous pouvez donc obtenir une sortie reproductible entre compilateurs croisés et des caractéristiques garanties - disons, la portée du générateur);
  • généralement de qualité supérieure ( à partir de la conception de la bibliothèque ; voir ci-dessous);
  • encapsulé dans des classes (aucun état global ne vous est imposé, ce qui évite les problèmes de threading et de non-localisation);

... et un random_device par défaut aussi pour les semer.

Maintenant, si vous me demandez, j'aurais aimé aussi une API simple construite au-dessus de cela pour les cas "facile", "devine un nombre" (similaire à la façon dont Python fournit l’API "compliquée", mais également le trivial random.randint & Co., qui utilise un PRNGpré-ensemencé global pour nous, les gens simples qui aimeraient ne pas se noyer. appareils aléatoires/moteurs/adaptateurs/peu importe chaque fois que nous voulons extraire un numéro pour les cartes de bingo), mais il est vrai que vous pouvez facilement le construire par vous-même sur les installations actuelles (tout en construisant la "complète" API par-dessus une simple pas possible).


Enfin, pour revenir à votre comparaison de performances: comme d’autres l’ont déjà précisé, vous comparez une GCL rapide à une qualité plus lente (mais généralement considérée comme de meilleure qualité) Mersenne Twister; si vous êtes d'accord avec la qualité d'un LCG, vous pouvez utiliser std::minstd_Rand au lieu de std::mt19937.

En effet, après avoir peaufiné votre fonction pour utiliser std::minstd_Rand et éviter les variables statiques inutiles pour l'initialisation

int getRandNum_New() {
    static std::minstd_Rand eng{std::random_device{}()};
    static std::uniform_int_distribution<int> dist{0, 5};
    return dist(eng);
}

Je reçois 9 ms (ancien) contre 21 ms (nouveau); enfin, si je supprime dist (qui, comparé à l'opérateur classique modulo, gère l'inclinaison de la distribution pour une plage de sortie non multiple de la plage d'entrée) et retourne à ce que vous faites dans getRandNum_Old()

int getRandNum_New() {
    static std::minstd_Rand eng{std::random_device{}()};
    return eng() % 6;
}

Je le réduis à 6 ms (donc, 30% plus rapide), probablement parce que, contrairement à l'appel à Rand(), std::minstd_Rand est plus facile à intégrer.


A propos, j’ai fait le même test en utilisant un roulé à la main (mais me conformant plus ou moins à l’interface standard de la bibliothèque) XorShift64*, et il est 2,3 fois plus rapide que Rand() (3,68 ms contre 8,61 ms); Etant donné que, contrairement au Mersenne Twister et aux divers LCG fournis, il réussit avec brio les suites de tests aléatoires actuelles et , il est incroyablement rapide. Je me demande pourquoi il n’est pas encore inclus dans la bibliothèque standard.

104
Matteo Italia

Si vous répétez votre expérience avec une plage supérieure à 5, vous obtiendrez probablement des résultats différents. Lorsque votre plage est nettement inférieure à Rand_MAX, la plupart des applications ne posent pas de problème.

Par exemple, si nous avons un Rand_MAX de 25, alors Rand() % 5 produira des nombres avec les fréquences suivantes:

0: 6
1: 5
2: 5
3: 5
4: 5

Comme il est garanti que Rand_MAX sera supérieur à 32767 et que la différence de fréquences entre les moins probables et les plus probables n'est que de 1, pour les petits nombres, la distribution est suffisamment aléatoire pour la plupart des cas d'utilisation.

6
Alan Birtles

Tout d’abord, étonnamment, la réponse change en fonction de l’utilisation du nombre aléatoire. Si c'est pour conduire, disons, un changeur de couleur d'arrière-plan aléatoire, utiliser Rand () convient parfaitement. Si vous utilisez un nombre aléatoire pour créer une main de poker aléatoire ou une clé cryptographiquement sécurisée, alors tout va bien.

Prévisibilité: la séquence 012345012345012345012345 ... fournirait une distribution égale de chaque nombre de votre échantillon, mais ne serait évidemment pas aléatoire. Pour qu'une séquence soit aléatoire, la valeur de n + 1 ne peut pas être facilement prédite par la valeur de n (ou même par les valeurs de n, n-1, n-2, n-3, etc.). Clairement une séquence répétée des mêmes chiffres est un cas dégénéré, mais une séquence générée avec n’importe quel générateur de congruence linéaire peut être analysée; Si vous utilisez les paramètres par défaut prêts à l'emploi d'un LCG commun à partir d'une bibliothèque commune, une personne mal intentionnée pourrait "interrompre la séquence" sans trop d'effort. Dans le passé, plusieurs casinos en ligne (et certains castrés) ont été détruits par des machines utilisant de médiocres générateurs de nombres aléatoires. Même les personnes qui devraient savoir mieux ont été rattrapées; Il a été démontré que les puces TPM de plusieurs fabricants étaient plus faciles à décomposer que la longueur en bits des clés ne le prévoirait autrement en raison des mauvais choix effectués avec les paramètres de génération de clés.

Distribution: comme indiqué dans la vidéo, prendre un modulo de 100 (ou toute valeur non divisible de manière égale dans la longueur de la séquence) garantira que certains résultats seront au moins légèrement plus probables que d'autres. Dans l'univers de 32767 valeurs de départ possibles modulo 100, les nombres de 0 à 66 apparaîtront 328/327 (0,3%) plus souvent que les valeurs de 67 à 99; un facteur qui peut fournir un avantage à un attaquant.

3
JackLThornton

La réponse correcte est: cela dépend de ce que vous entendez par "meilleur".

Les "nouveaux" moteurs <random> ont été introduits en C++ il y a plus de 13 ans. Ils ne sont donc pas vraiment nouveaux. La bibliothèque C Rand() a été introduite il y a plusieurs décennies et a été très utile à cette époque pour de nombreuses choses.

La bibliothèque standard C++ fournit trois classes de moteurs générateurs de nombres aléatoires: le linéaire congruentiel (dont Rand() est un exemple), le retardé de Fibonacci et le Mersenne Twister. Il y a des compromis entre chaque classe et chaque classe est "meilleure" à certains égards. Par exemple, les LCG ont un très petit état et, si les bons paramètres sont choisis, assez rapidement sur les processeurs de bureau modernes. Les LFG ont un état plus étendu et utilisent uniquement des extractions de mémoire et des opérations d’addition. Ils sont donc très rapides sur les systèmes embarqués et les microcontrôleurs dépourvus de matériel mathématique spécialisé. Le MTG a un état énorme et est lent, mais peut avoir une très grande séquence non répétée avec d'excellentes caractéristiques spectrales.

Si aucun des générateurs fournis n'est suffisant pour votre utilisation spécifique, la bibliothèque standard C++ fournit également une interface pour un générateur de matériel ou votre propre moteur personnalisé. Aucun des générateurs n'est destiné à être utilisé de manière autonome: leur utilisation prévue est via un objet de distribution qui fournit une séquence aléatoire avec une fonction de distribution de probabilité particulière.

Un autre avantage de <random> par rapport à Rand() est que Rand() utilise l'état global, qu'il n'est ni réentrant ni threadsafe, et qu'il permet une seule instance par processus. Si vous avez besoin d'un contrôle précis ou d'une prévisibilité (c'est-à-dire capable de reproduire un bogue en fonction de l'état initial du RNG), alors Rand() est inutile. Les générateurs <random> sont instanciés localement et ont un état sérialisable (et restaurable).

1
Stephen M. Webb