Dlaczego nowa biblioteka losowa jest lepsza niż std :: rand ()?


82

Widziałem więc wykład o nazwie rand () Uważany za szkodliwy, który zalecał użycie paradygmatu dystrybucji przez silnik generowania liczb losowych zamiast prostego std::rand()paradygmatu plus moduł.

Chciałem jednak na std::rand()własne oczy zobaczyć wady, więc zrobiłem szybki eksperyment:

  1. Zasadniczo, pisałem 2 funkcje getRandNum_Old()i getRandNum_New()który wygenerował liczbę losową z zakresu od 0 do 5 włącznie użyciu std::rand()i std::mt19937+ std::uniform_int_distributionodpowiednio.
  2. Następnie wygenerowałem 960 000 (podzielnych przez 6) liczb losowych stosując „stary” sposób i zapisałem częstotliwości liczb 0-5. Następnie obliczyłem odchylenie standardowe tych częstotliwości. To, czego szukam, to odchylenie standardowe tak niskie, jak to możliwe, ponieważ tak by się stało, gdyby rozkład był naprawdę jednolity.
  3. Przeprowadziłem tę symulację 1000 razy i zapisałem odchylenie standardowe dla każdej symulacji. Zapisałem również czas w milisekundach.
  4. Później zrobiłem dokładnie to samo, ale tym razem generowałem liczby losowe w „nowy” sposób.
  5. Na koniec obliczyłem średnią i odchylenie standardowe listy odchyleń standardowych zarówno dla starej, jak i nowej drogi oraz średnią i odchylenie standardowe dla listy czasów przyjętych dla starej i nowej drogi.

Oto wyniki:

[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

Co zaskakujące, łączne rozłożenie rolek było takie samo dla obu metod. Tj. std::mt19937+ std::uniform_int_distributionNie był „bardziej jednolity” niż prosty std::rand()+ %. Kolejną obserwacją, jaką zrobiłem, było to, że nowy był około 4x wolniejszy niż stary sposób. Ogólnie rzecz biorąc, wydawało się, że płacę olbrzymie koszty za prędkość, prawie nie podnosząc jakości.

Czy mój eksperyment jest w jakiś sposób błędny? A może std::rand()naprawdę nie jest tak źle, a może nawet lepiej?

Dla porównania, oto kod, którego użyłem w całości:

#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));
}

32
Właśnie dlatego istnieje ta rada. Jeśli nie wiesz, jak przetestować RNG pod kątem dostatecznej entropii lub czy ma to znaczenie dla twojego programu, powinieneś założyć, że std :: rand () nie jest wystarczająco dobre. en.wikipedia.org/wiki/Entropy_(computing)
Hans Passant

4
Ostateczna kwestia, czy rand()jest wystarczająco dobra, zależy w dużej mierze od tego, do czego używasz zbioru liczb losowych. Jeśli potrzebujesz określonego typu losowej dystrybucji, to oczywiście implementacja biblioteki będzie lepsza. Jeśli po prostu potrzebujesz liczb losowych i nie przejmujesz się „losowością” lub typem dystrybucji, to rand()jest w porządku. Dopasuj odpowiednie narzędzie do wykonywanej pracy.
David C. Rankin

2
możliwy duplikat: stackoverflow.com/questions/52869166/ ... Po prostu nie chcę w to wbijać, więc powstrzymuję się od głosowania.
bolov

18
for (i=0; i<k*n; i++) a[i]=i%n;daje taką samą dokładną średnią i odchylenie standardowe, co najlepszy dostępny RNG. Jeśli jest to wystarczająco dobre dla twojej aplikacji, po prostu użyj tej sekwencji.
n. zaimki m.

3
„odchylenie standardowe tak niskie, jak to możliwe” - nie. To jest źle. Państwo oczekiwać częstotliwości być nieco inna - o sqrt (częstotliwości) jest o tym, co można oczekiwać odchylenie standardowe być. „Licznik inkrementujący”, który wytworzył nm, będzie miał znacznie niższy współczynnik sd (i jest bardzo złym rng).
Martin Bonner wspiera Monikę

Odpowiedzi:


106

Prawie każda implementacja „starej” wersji rand()używa LCG ; chociaż generalnie nie są to najlepsze generatory w okolicy, zwykle nie zobaczysz, jak zawodzą w tak podstawowym teście - średnie i odchylenie standardowe jest generalnie poprawne nawet przez najgorsze PRNG.

Typowe wady „złych” - ale wystarczająco powszechnych - rand()implementacji to:

  • mała losowość mniej znaczących bitów;
  • krótki okres;
  • niski RAND_MAX;
  • pewna korelacja między kolejnymi ekstrakcjami (ogólnie LCG generują liczby, które znajdują się na ograniczonej liczbie hiperpłaszczyzn, chociaż można to w jakiś sposób złagodzić).

Jednak żaden z nich nie jest specyficzny dla interfejsu API programu rand(). Konkretna implementacja może umieścić generator z rodziny xorshift za srand/ randi, mówiąc algorytmicznie, uzyskać najnowocześniejszy PRNG bez zmian interfejsu, więc żaden test taki jak ten, który zrobiłeś, nie wykazałby żadnych słabych wyników.

Edycja: @R. poprawnie zauważa, że interfejs rand/ srandjest ograniczony przez fakt, że srandprzyjmuje on an unsigned int, więc każdy generator, który może za sobą postawić implementacja, jest z natury rzeczy ograniczony do UINT_MAXmożliwych początków początkowych (a tym samym generowanych sekwencji). Jest to rzeczywiście prawda, chociaż API można by w trywialny sposób rozszerzyć, aby wykonać srandpobranie unsigned long longlub dodać oddzielne srand(unsigned char *, size_t)przeciążenie.


Rzeczywiście, rzeczywisty problem z zasadniczorand() nie dotyczy implementacji, ale:

  • kompatybilność wsteczna; wiele obecnych implementacji używa generatorów nieoptymalnych, zazwyczaj o źle dobranych parametrach; znanym przykładem jest Visual C ++, który ma RAND_MAXzaledwie 32767. Jednak nie można tego łatwo zmienić, ponieważ złamałoby to kompatybilność z przeszłością - ludzie używający srandz ustalonym ziarnem do powtarzalnych symulacji nie byliby zbyt szczęśliwi (w rzeczywistości IIRC wspomniana implementacja sięga wczesnych wersji Microsoft C - a nawet Lattice C - z połowy lat osiemdziesiątych);
  • uproszczony interfejs; rand()zapewnia pojedynczy generator ze stanem globalnym dla całego programu. Chociaż jest to całkowicie w porządku (i całkiem przydatne) w wielu prostych przypadkach użycia, stwarza problemy:

    • z kodem wielowątkowym: aby to naprawić, potrzebujesz albo globalnego muteksu - który spowolniłby wszystko bez powodu i zabiłby jakąkolwiek szansę na powtarzalność, ponieważ sekwencja wywołań sama staje się losowa - lub stan lokalny wątku; ten ostatni został przyjęty w kilku implementacjach (zwłaszcza w Visual C ++);
    • jeśli chcesz mieć „prywatną”, odtwarzalną sekwencję w określonym module programu, która nie wpływa na stan globalny.

Wreszcie randstan rzeczy:

  • nie określa rzeczywistej implementacji (standard C zapewnia tylko przykładową implementację), więc każdy program, który ma generować powtarzalne dane wyjściowe (lub oczekuje PRNG o jakiejś znanej jakości) w różnych kompilatorach, musi uruchomić swój własny generator;
  • nie zapewnia żadnej międzyplatformowej metody uzyskania przyzwoitego ziarna ( time(NULL)nie jest, ponieważ nie jest wystarczająco szczegółowe i często - myślą urządzenia wbudowane bez RTC - nawet niewystarczająco losowe).

Stąd nowy <random>nagłówek, który próbuje naprawić ten bałagan, dostarczając algorytmy:

  • w pełni określony (dzięki czemu można uzyskać powtarzalne dane wyjściowe kompilatora krzyżowego i gwarantowane charakterystyki - powiedzmy, zakres generatora);
  • ogólnie najnowocześniejszej jakości ( od momentu zaprojektowania biblioteki ; patrz poniżej);
  • zamknięte w klasach (więc żaden stan globalny nie jest wymuszany na tobie, co pozwala uniknąć problemów związanych z wątkami i nielokalnością);

... a także ustawienie domyślne, random_deviceaby je wysiać.

Teraz, jeśli mnie zapytasz, wolałbym również proste API zbudowane na tym samym dla "łatwych", "zgadnij liczbę" przypadków (podobnie do tego, jak Python dostarcza "skomplikowane" API, ale także trywialne random.randint& Co . używając globalnego, pre-seeded PRNG dla nas, nieskomplikowanych ludzi, którzy nie chcieliby utopić się w przypadkowych urządzeniach / silnikach / adapterach / czymkolwiek za każdym razem, gdy chcemy wyodrębnić numer do kart bingo), ale to prawda, że ​​możesz łatwo zbuduj go samodzielnie na obecnych obiektach (podczas gdy budowanie „pełnego” API na uproszczonym nie byłoby możliwe).


Na koniec, wracając do porównania wydajności: jak określili inni, porównujesz szybki LCG z wolniejszym (ale ogólnie uważanym za lepszą jakość) Mersenne Twister; jeśli nie masz nic przeciwko jakości LCG, możesz użyć std::minstd_randzamiast std::mt19937.

Rzeczywiście, po dostosowaniu funkcji, aby używać std::minstd_randi unikać niepotrzebnych zmiennych statycznych do inicjalizacji

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

Otrzymuję 9 ms (stary) vs 21 ms (nowy); na koniec, jeśli się pozbędę dist(który, w porównaniu do klasycznego operatora modulo, obsługuje odchylenie rozkładu dla zakresu wyjściowego nie będącego wielokrotnością zakresu wejściowego) i wrócę do tego, co robiszgetRandNum_Old()

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

Mam go w dół do 6 ms (tak, 30% szybciej), prawdopodobnie dlatego, że w przeciwieństwie do wywołania rand(), std::minstd_randjest łatwiejszy do inline.


Nawiasem mówiąc, zrobiłem ten sam test, używając ręcznie zwijanego (ale prawie zgodnego ze standardowym interfejsem biblioteki) XorShift64*i jest 2,3 razy szybszy niż rand()(3,68 ms vs 8,61 ms); biorąc pod uwagę, że w przeciwieństwie do Mersenne Twister i różnych dostarczonych LCG, pomyślnie przechodzi obecne zestawy testów losowości i jest niesamowicie szybki, sprawia, że ​​zastanawiasz się, dlaczego nie jest jeszcze uwzględniony w standardowej bibliotece.


3
To właśnie połączenie srandi nieokreślony algorytm powoduje std::rand kłopoty. Zobacz także moją odpowiedź na inne pytanie .
Peter O.

2
randjest zasadniczo ograniczony na poziomie API, ponieważ ziarno (a tym samym liczba możliwych sekwencji, które można wyprodukować) jest ograniczone przez UINT_MAX+1.
R .. GitHub STOP HELPING ICE

2
tylko uwaga: minstd to zły PRNG, mt19973 jest lepszy, ale niewiele: pcg-random.org/ ... (w tym wykresie minstd == LCG32 / 64). Szkoda, że ​​C ++ nie zapewnia żadnych wysokiej jakości, szybkich PRNG, takich jak PCG czy xoroshiro128 +.
user60561

2
@MatteoItalia Nie jesteśmy w sporze. To był również punkt widzenia Bjarne'a. Naprawdę chcemy <random>w standardzie, ale chcielibyśmy również opcję „po prostu daj mi przyzwoitą implementację, z której mogę teraz skorzystać”. Dla PRNG i innych rzeczy.
ravnsgaard

2
Kilka uwag: 1. Wymiana std::uniform_int_distribution<int> dist{0, 5}(eng);z eng() % 6przywraca współczynnik nachylenia że std::randcierpi kod z (co prawda drobne przekrzywienie w tym przypadku, gdy silnik ma 2**31 - 1wyjścia, a ty ich dystrybucję do 6 łyżek). 2. W twojej notatce o " srandpobiera unsigned int", która ogranicza możliwe wyjścia, jak napisano, obsadzenie silnika std::random_device{}()ma ten sam problem; potrzebujesz, seed_seqaby poprawnie zainicjować większość PRNG .
ShadowRanger

6

Jeśli powtórzysz eksperyment z zakresem większym niż 5, prawdopodobnie zobaczysz inne wyniki. Gdy twój zasięg jest znacznie mniejszy niżRAND_MAX nie ma problemu dla większości zastosowań.

Na przykład, jeśli mamy RAND_MAX25, to rand() % 5wygenerujemy liczby o następujących częstotliwościach:

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

Ponieważ RAND_MAXgwarantuje się, że będzie więcej niż 32767, a różnica w częstotliwościach między najmniej prawdopodobnym a najbardziej prawdopodobnym wynosi tylko 1, dla małych liczb rozkład jest wystarczająco losowy dla większości przypadków użycia.


3
Jest to wyjaśnione na drugim slajdzie STL
Alan Birtles,

4
Ok, ale ... kto to jest STL? A jakie slajdy? (poważne pytanie)
kebs

@kebs, Stephan Lavavej, zobacz odniesienie do Youtube w pytaniu.
Evg

3

Po pierwsze, co zaskakujące, odpowiedź zmienia się w zależności od tego, do czego używasz liczby losowej. Jeśli ma to być, powiedzmy, losowy zmieniacz kolorów tła, użycie rand () jest całkowicie w porządku. Jeśli używasz losowej liczby do utworzenia losowego układu pokerowego lub kryptograficznie zabezpieczonego klucza, to nie jest w porządku.

Przewidywalność: sekwencja 012345012345012345012345 ... zapewniłaby równomierny rozkład każdej liczby w twojej próbie, ale oczywiście nie jest losowa. Aby sekwencja była losowa, nie można łatwo przewidzieć wartości n + 1 na podstawie wartości n (ani nawet na podstawie wartości n, n-1, n-2, n-3 itd.). tych samych cyfr jest przypadkiem zdegenerowanym, ale sekwencja wygenerowana za pomocą dowolnego generatora kongruencji liniowej może być poddana analizie; jeśli użyjesz domyślnych, gotowych do użycia ustawień zwykłego LCG ze wspólnej biblioteki, złośliwa osoba może „przerwać sekwencję” bez większego wysiłku. W przeszłości kilka kasyn on-line (i kilka tradycyjnych) zostało dotkniętych stratami przez maszyny korzystające ze słabych generatorów liczb losowych. Nawet ludzie, którzy powinni wiedzieć lepiej, zostali złapani;

Dystrybucja: jak wspomniano w filmie, przyjęcie modulo równego 100 (lub dowolnej wartości niepodzielnej równo na długość sekwencji) zagwarantuje, że niektóre wyniki staną się co najmniej nieco bardziej prawdopodobne niż inne. We wszechświecie 32767 możliwych wartości początkowych modulo 100 liczby od 0 do 66 będą pojawiać się 328/327 (0,3%) częściej niż wartości od 67 do 99; czynnik, który może zapewnić atakującemu przewagę.


1
„Przewidywalność: sekwencja 012345012345012345012345… zdałaby twój test na„ losowość ”, ponieważ istniałby równy rozkład każdej liczby w twojej próbce„ właściwie, nie naprawdę; to, co mierzy, to odchylenie standardowe wartości odchylenia standardowego między przebiegami, tj. zasadniczo sposób rozłożenia histogramu różnych przebiegów. W przypadku generatora 012345012345012345 ... zawsze będzie wynosić zero.
Matteo Italia

Słuszna uwaga; Obawiam się, że przeczytałem kod OP trochę za szybko. Zredagowałem moją odpowiedź, aby odzwierciedlić.
JackLThornton

Hehe wiem, bo pomyślałem, że też zrobiłem ten test i zauważyłem, że uzyskałem inne wyniki 😄
Matteo Italia

1

Prawidłowa odpowiedź brzmi: to zależy od tego, co masz na myśli, mówiąc „lepiej”.

„Nowe” <random>silniki zostały wprowadzone do C ++ ponad 13 lat temu, więc nie są tak naprawdę nowe. Biblioteka C rand()została wprowadzona dziesiątki lat temu i była wówczas bardzo przydatna do wielu rzeczy.

Biblioteka standardowa C ++ udostępnia trzy klasy silników generatorów liczb losowych: liniowy kongruentny (którego rand()jest przykładem), opóźniony Fibonacci i Mersenne Twister. Każda klasa ma swoje kompromisy, a każda klasa jest „najlepsza” pod pewnymi względami. Na przykład LCG mają bardzo mały stan i jeśli zostaną wybrane odpowiednie parametry, dość szybko na nowoczesnych procesorach do komputerów stacjonarnych. LFG mają większy stan i używają tylko pobierania pamięci i operacji dodawania, więc są bardzo szybkie w systemach wbudowanych i mikrokontrolerach, które nie posiadają specjalistycznego sprzętu matematycznego. MTG ma ogromny stan i jest powolny, ale może mieć bardzo dużą, niepowtarzalną sekwencję o doskonałych właściwościach widmowych.

Jeśli żaden z dostarczonych generatorów nie jest wystarczająco dobry do konkretnego zastosowania, standardowa biblioteka C ++ zapewnia również interfejs dla generatora sprzętowego lub własnego silnika niestandardowego. Żaden z generatorów nie jest przeznaczony do samodzielnego użytku: ich zamierzone zastosowanie polega na wykorzystaniu obiektu rozkładu, który zapewnia ciąg losowy z określoną funkcją rozkładu prawdopodobieństwa.

Inną zaletą <random>over rand()jest to, że rand()używa stanu globalnego, nie jest ponownie wprowadzany ani wątkowo i pozwala na pojedyncze wystąpienie na proces. Jeśli potrzebujesz precyzyjnej kontroli lub przewidywalności (tj. Zdolnego do odtworzenia błędu, biorąc pod uwagę stan nasion RNG), rand()jest to bezużyteczne. Te <random>generatory są lokalnie instanced i mieć zaszeregować (i remontu) stan.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.