Perché rand ()% 6 è parziale?


109

Durante la lettura di come utilizzare std :: rand, ho trovato questo codice su cppreference.com

int x = 7;
while(x > 6) 
    x = 1 + std::rand()/((RAND_MAX + 1u)/6);  // Note: 1+rand()%6 is biased

Cosa c'è di sbagliato nell'espressione a destra? L'ho provato e funziona perfettamente.


24
Nota che è ancora meglio usare std::uniform_int_distributionper i dadi
Caleth

1
@Caleth Sì, era solo per capire perché questo codice era "sbagliato" ..
yO_

15
Cambiato "è sbagliato" in "è di parte"
Cubbi

3
rand()è così male nelle implementazioni tipiche, potresti anche usare xkcd RNG . Quindi è sbagliato perché usa rand().
CodesInChaos

3
Ho scritto questa cosa (beh, non il commento - è @Cubbi) e quello che avevo in mente all'epoca era ciò che spiegava la risposta di Pete Becker . (Cordiali saluti, questo è fondamentalmente lo stesso algoritmo di libstdc ++ uniform_int_distribution.)
TC

Risposte:


136

Ci sono due problemi con rand() % 6(il 1+non influisce su nessuno dei problemi).

Innanzitutto, come diverse risposte hanno sottolineato, se i bit bassi di rand()non sono adeguatamente uniformi, anche il risultato dell'operatore resto non è uniforme.

Secondo, se il numero di valori distinti prodotti da rand()non è un multiplo di 6, il resto produrrà più valori bassi rispetto a valori alti. Ciò è vero anche se rand()restituisce valori perfettamente distribuiti.

Come esempio estremo, fingere che rand()produca valori distribuiti uniformemente nell'intervallo [0..6]. Se guardi i resti per quei valori, quando rand()restituisce un valore nell'intervallo [0..5], il resto produce risultati distribuiti uniformemente nell'intervallo [0..5]. Quando rand()restituisce 6, rand() % 6restituisce 0, proprio come se rand()avesse restituito 0. Quindi si ottiene una distribuzione con il doppio di 0 rispetto a qualsiasi altro valore.

Il secondo è il vero problema con rand() % 6.

Il modo per evitare questo problema è scartare i valori che produrrebbe duplicati non uniformi. Calcoli il più grande multiplo di 6 che è minore o uguale a RAND_MAX, e ogni volta che rand()restituisce un valore maggiore o uguale a quel multiplo lo rifiuti e chiami di nuovo `rand (), quante volte è necessario.

Così:

int max = 6 * ((RAND_MAX + 1u) / 6)
int value = rand();
while (value >= max)
    value = rand();

Questa è un'implementazione diversa del codice in questione, intesa a mostrare più chiaramente cosa sta succedendo.


2
Ho promesso ad almeno un regolare su questo sito di produrre un articolo su questo, ma penso che il campionamento e il rifiuto possano eliminare momenti di alto livello; es. gonfiare eccessivamente la varianza.
Bathsheba

30
Ho fatto un grafico di quanto bias questa tecnica introduce se rand_max è 32768, che è in alcune implementazioni. ericlippert.com/2013/12/16/…
Eric Lippert

2
@Bathsheba: è vero che alcune funzioni di rifiuto potrebbero causare questo, ma questo semplice rifiuto trasformerà un IID uniforme in una diversa distribuzione IID uniforme. Nessun bit viene trasferito, in modo indipendente, tutti i campioni usano lo stesso rifiuto in modo identico e banale per mostrare l'uniformità. E i momenti più alti di una variabile casuale integrale uniforme sono completamente definiti dal suo intervallo.
MSalters

4
@MSalters: la tua prima frase è corretta per un vero generatore, non necessariamente vera per uno pseudo generatore. Quando andrò in pensione, scriverò un articolo su questo.
Betsabea

2
@Anthony Pensa in termini di dadi. Vuoi un numero casuale compreso tra 1 e 3 e hai solo un dado standard a 6 facce. Puoi ottenerlo sottraendo 3 se ottieni 4-6. Ma diciamo invece che vuoi un numero compreso tra 1 e 5. Se sottrai 5 quando ottieni un 6, allora finirai con il doppio degli 1 di qualsiasi altro numero. Questo è fondamentalmente ciò che sta facendo il codice cppreference. La cosa corretta da fare è ripetere il tiro dei 6. Questo è ciò che sta facendo Pete qui: dividi il dado in modo che ci siano lo stesso numero di modi per tirare ogni numero e rilancia tutti i numeri che non rientrano nelle divisioni pari
Ray

19

Ci sono profondità nascoste qui:

  1. L'uso del piccolo uin RAND_MAX + 1u. RAND_MAXè definito come un inttipo ed è spesso il più grande possibile int. Il comportamento di RAND_MAX + 1sarebbe indefinito in tali casi in cui si sovraccaricherebbe un signedtipo. La scrittura 1uforza la conversione del tipo da RAND_MAXa unsigned, ovviando così all'overflow.

  2. L'uso di % 6 can (ma su ogni implementazione di std::randche ho visto no introduce) alcun pregiudizio statistico aggiuntivo al di là dell'alternativa presentata. Tali casi in cui % 6è pericoloso sono casi in cui il generatore di numeri ha pianure di correlazione nei bit di ordine basso, come un'implementazione IBM piuttosto famosa (in C) degli randanni '70, credo, che ha invertito i bit alti e bassi come "una finale fiorire". Un'ulteriore considerazione è che 6 è molto piccolo cfr. RAND_MAX, quindi ci sarà un effetto minimo se RAND_MAXnon è un multiplo di 6, cosa che probabilmente non è.

In conclusione, in questi giorni, data la sua trattabilità, lo userei % 6. Non è probabile che introduca anomalie statistiche oltre a quelle introdotte dal generatore stesso. Se sei ancora in dubbio, prova tuo generatore per vedere se ha le proprietà statistiche appropriate per il tuo caso d'uso.


12
% 6produce un risultato distorto ogni volta che il numero di valori distinti generati da rand()non è un multiplo di 6. Principio della casella. Certo, il bias è piccolo quando RAND_MAXè molto più grande di 6, ma c'è. E per intervalli di obiettivi più ampi l'effetto è, ovviamente, maggiore.
Pete Becker

2
@PeteBecker: In effetti, dovrei chiarirlo. Ma tieni presente che ottieni anche il pigeon-holing quando la gamma di campioni si avvicina a RAND_MAX, a causa degli effetti di troncamento della divisione intera.
Bathsheba

2
@Bathsheba quell'effetto di troncamento non porta a un risultato maggiore di 6 e quindi a un'esecuzione ripetuta dell'intera operazione?
Gerhardh

1
@Gerhardh: corretto. In effetti, porta esattamente al risultato x==7. In pratica, dividi l'intervallo [0, RAND_MAX]in 7 sottointervalli, 6 della stessa dimensione e un sottointervallo più piccolo alla fine. I risultati dell'ultimo sottointervallo vengono scartati. È abbastanza ovvio che in questo modo non puoi avere due sottointervalli più piccoli alla fine.
MSalters

@MSalters: infatti. Ma tieni presente che l'altro modo soffre ancora a causa del troncamento. La mia ipotesi è che la gente si accontenti di quest'ultimo poiché le insidie ​​statistiche sono più difficili da comprendere!
Bathsheba

13

Questo codice di esempio illustra che std::randè un caso di balderdash di culto del carico legacy che dovrebbe farti sollevare le sopracciglia ogni volta che lo vedi.

Ci sono diversi problemi qui:

Il contratto che le persone di solito assumono - anche le povere anime sfortunate che non sanno niente di meglio e non ci penseranno esattamente in questi termini - è che randcampioni dalla distribuzione uniforme sugli interi in 0, 1, 2, ... RAND_MAX, e ogni chiamata produce un campione indipendente .

Il primo problema è che il contratto assunto, campioni casuali uniformi indipendenti in ogni chiamata, non è effettivamente ciò che dice la documentazione e, in pratica, le implementazioni storicamente non sono riuscite a fornire nemmeno il più semplice simulacro di indipendenza. Ad esempio, C99 §7.20.2.1 'La randfunzione' dice, senza elaborazione:

La randfunzione calcola una sequenza di numeri interi pseudo-casuali nell'intervallo da 0 a RAND_MAX.

Questa è una frase priva di significato, perché la pseudocasualità è una proprietà di una funzione (o famiglia di funzioni ), non di un numero intero, ma ciò non impedisce nemmeno ai burocrati ISO di abusare del linguaggio. Dopotutto, gli unici lettori che ne sarebbero sconvolti sanno meglio che leggere la documentazione randper paura che le loro cellule cerebrali si deteriorino.

Una tipica implementazione storica in C funziona in questo modo:

static unsigned int seed = 1;

static void
srand(unsigned int s)
{
    seed = s;
}

static unsigned int
rand(void)
{
    seed = (seed*1103515245 + 12345) % ((unsigned long)RAND_MAX + 1);
    return (int)seed;
}

Questo ha la sfortunata proprietà che anche se un singolo campione può essere distribuito uniformemente sotto un seme casuale uniforme (che dipende dal valore specifico di RAND_MAX), alterna interi pari e dispari in chiamate consecutive, dopo

int a = rand();
int b = rand();

l'espressione (a & 1) ^ (b & 1)restituisce 1 con probabilità del 100%, il che non è il caso di campioni casuali indipendenti su qualsiasi distribuzione supportata su numeri interi pari e dispari. Così, è emerso un culto del carico che si dovrebbe scartare i pezzi di basso ordine per inseguire la bestia sfuggente della "migliore casualità". (Avviso spoiler: questo non è un termine tecnico. Questo è un segno che la prosa di chi stai leggendo o non sa di cosa stanno parlando, o pensa che tu non abbia idea e debba essere condiscendente.)

Il secondo problema è che anche se ogni chiamata campionasse indipendentemente da una distribuzione casuale uniforme su 0, 1, 2, ... RAND_MAX, il risultato di rand() % 6non sarebbe distribuito uniformemente in 0, 1, 2, 3, 4, 5 come un dado roll, a meno che non RAND_MAXsia congruente a -1 modulo 6. Semplice controesempio: se RAND_MAX= 6, allora da rand(), tutti i risultati hanno probabilità uguale 1/7, ma da rand() % 6, il risultato 0 ha probabilità 2/7 mentre tutti gli altri risultati hanno probabilità 1/7 .

Il modo giusto per farlo è con il campionamento del rifiuto: disegna ripetutamente un campione casuale uniforme indipendente sda 0, 1, 2, ... RAND_MAX, e rifiuta (ad esempio) i risultati 0, 1, 2, ..., ((RAND_MAX + 1) % 6) - 1- se ottieni uno dei quelli, ricomincia; altrimenti, cedere s % 6.

unsigned int s;
while ((s = rand()) < ((unsigned long)RAND_MAX + 1) % 6)
    continue;
return s % 6;

In questo modo, l'insieme di risultati da rand()quello che accettiamo è uniformemente divisibile per 6, e ogni possibile risultato da s % 6è ottenuto dallo stesso numero di risultati accettati da rand(), quindi se rand()è uniformemente distribuito, lo è s. Non vi è alcun limite al numero di prove, ma il numero atteso è inferiore a 2 e la probabilità di successo cresce esponenzialmente con il numero di prove.

La scelta di quali risultati rand()rifiutare è irrilevante, a condizione di mappare un numero uguale di essi a ciascun numero intero inferiore a 6. Il codice su cppreference.com fa una scelta diversa , a causa del primo problema sopra, che nulla è garantito sul distribuzione o indipendenza degli output dirand() , e in pratica i bit di ordine inferiore hanno mostrato schemi che non "sembrano abbastanza casuali" (non importa che l'output successivo sia una funzione deterministica di quello precedente).

Esercizio per il lettore: dimostra che il codice su cppreference.com produce una distribuzione uniforme sui rotoli di dado se rand()produce una distribuzione uniforme su 0, 1, 2, ...,RAND_MAX .

Esercizio per il lettore: perché potresti preferire che uno o gli altri sottoinsiemi rifiutino? Quale calcolo è necessario per ogni prova nei due casi?

Un terzo problema è che lo spazio seme è così piccolo che anche se il seme è distribuito uniformemente, un avversario armato della conoscenza del tuo programma e di un risultato ma non il seme può prontamente prevedere il seme e i risultati successivi, il che li fa sembrare non così dopo tutto casuale. Quindi non pensare nemmeno di usarlo per la crittografia.

Puoi seguire la stravagante strada ingegnerizzata e la std::uniform_int_distributionclasse C ++ 11 con un dispositivo casuale appropriato e il tuo motore casuale preferito come il sempre popolare tornado Mersenne std::mt19937per giocare ai dadi con tuo cugino di quattro anni, ma anche questo non lo farà essere adatto a generare materiale per chiavi crittografiche - e anche il Mersenne Twister è un terribile maiale spaziale con uno stato multi-kilobyte che crea scompiglio nella cache della CPU con un tempo di configurazione osceno, quindi è dannoso anche per, ad es. , simulazioni Monte Carlo parallele con alberi riproducibili di sottocomputer; la sua popolarità deriva probabilmente principalmente dal suo nome accattivante. Ma puoi usarlo per lanciare dadi giocattolo come questo esempio!

Un altro approccio consiste nell'utilizzare un semplice generatore di numeri pseudocasuali crittografici con uno stato piccolo, come una semplice cancellazione rapida della chiave PRNG , o semplicemente un cifrario a flusso come AES-CTR o ChaCha20 se sei sicuro ( ad esempio , in una simulazione Monte Carlo per ricerca nelle scienze naturali) che non ci sono conseguenze negative nel prevedere i risultati passati se lo stato è mai compromesso.


4
"un tempo di setup osceno" In ogni caso non dovresti usare più di un generatore di numeri casuali (per thread), quindi il tempo di setup verrà ammortizzato a meno che il tuo programma non funzioni molto a lungo.
JAB

2
Downvote BTW per non aver capito che il loop nella domanda sta eseguendo lo stesso identico campionamento di rifiuto, esattamente degli stessi (RAND_MAX + 1 )% 6valori. Non importa come suddividi i possibili risultati. Puoi rifiutarli da qualsiasi punto dell'intervallo [0, RAND_MAX), purché la dimensione dell'intervallo accettato sia un multiplo di 6. Diavolo, puoi rifiutare completamente qualsiasi risultato x>6e non ne avrai più bisogno %6.
MSalters

12
Non sono abbastanza soddisfatto di questa risposta. Le invettive possono essere buone, ma stai andando nella direzione sbagliata. Ad esempio, ti lamenti che "migliore casualità" non è un termine tecnico e che è privo di significato. Questo è vero per metà. Sì, non è un termine tecnico, ma è una scorciatoia perfettamente significativa nel contesto. Insinuare che gli utenti di un tale termine siano ignoranti o maliziosi è di per sé una di queste cose. "Buona casualità" può essere molto difficile da definire con precisione, ma è abbastanza facile da capire quando una funzione produce risultati con proprietà di casualità migliori o peggiori.
Konrad Rudolph

3
Mi è piaciuta questa risposta. È un po 'di sproloquio, ma contiene molte buone informazioni di base. Tieni presente che i veri esperti usano solo generatori casuali hardware, il problema è così difficile.
Tiger4Hire

10
Per me è il contrario. Sebbene contenga buone informazioni, è troppo uno sproloquio per sembrare qualcosa di diverso dall'opinione. Utilità a parte.
Mr Lister

2

Non sono un utente C ++ esperto in alcun modo, ma ero interessato a vedere se le altre risposte riguardanti l' std::rand()/((RAND_MAX + 1u)/6)essere meno prevenute di quanto 1+std::rand()%6effettivamente siano vere. Quindi ho scritto un programma di test per tabulare i risultati per entrambi i metodi (non ho scritto C ++ da secoli, per favore controllalo). Un collegamento per eseguire il codice si trova qui . Inoltre è riprodotto come segue:

// Example program
#include <cstdlib>
#include <iostream>
#include <ctime>
#include <string>

int main()
{
    std::srand(std::time(nullptr)); // use current time as seed for random generator

    // Roll the die 6000000 times using the supposedly unbiased method and keep track of the results

    int results[6] = {0,0,0,0,0,0};

    // roll a 6-sided die 20 times
    for (int n=0; n != 6000000; ++n) {
        int x = 7;
        while(x > 6) 
            x = 1 + std::rand()/((RAND_MAX + 1u)/6);  // Note: 1+rand()%6 is biased

        results[x-1]++;
    }

    for (int n=0; n !=6; n++) {
        std::cout << results[n] << ' ';
    }

    std::cout << "\n";


    // Roll the die 6000000 times using the supposedly biased method and keep track of the results

    int results_bias[6] = {0,0,0,0,0,0};

    // roll a 6-sided die 20 times
    for (int n=0; n != 6000000; ++n) {
        int x = 7;
        while(x > 6) 
            x = 1 + std::rand()%6;

        results_bias[x-1]++;
    }

    for (int n=0; n !=6; n++) {
        std::cout << results_bias[n] << ' ';
    }
}

Ho quindi preso l'output di questo e ho usato la chisq.testfunzione in R per eseguire un test Chi-quadrato per vedere se i risultati sono significativamente diversi dal previsto. Questa domanda sullo scambio di stack va più in dettaglio sull'uso del test chi-quadrato per testare l'equità dello stampo: come posso verificare se un dado è giusto? . Ecco i risultati per alcune corse:

> ?chisq.test
> unbias <- c(100150, 99658, 100319, 99342, 100418, 100113)
> bias <- c(100049, 100040, 100091, 99966, 100188, 99666 )

> chisq.test(unbias)

Chi-squared test for given probabilities

data:  unbias
X-squared = 8.6168, df = 5, p-value = 0.1254

> chisq.test(bias)

Chi-squared test for given probabilities

data:  bias
X-squared = 1.6034, df = 5, p-value = 0.9008

> unbias <- c(998630, 1001188, 998932, 1001048, 1000968, 999234 )
> bias <- c(1000071, 1000910, 999078, 1000080, 998786, 1001075   )
> chisq.test(unbias)

Chi-squared test for given probabilities

data:  unbias
X-squared = 7.051, df = 5, p-value = 0.2169

> chisq.test(bias)

Chi-squared test for given probabilities

data:  bias
X-squared = 4.319, df = 5, p-value = 0.5045

> unbias <- c(998630, 999010, 1000736, 999142, 1000631, 1001851)
> bias <- c(999803, 998651, 1000639, 1000735, 1000064,1000108)
> chisq.test(unbias)

Chi-squared test for given probabilities

data:  unbias
X-squared = 7.9592, df = 5, p-value = 0.1585

> chisq.test(bias)

Chi-squared test for given probabilities

data:  bias
X-squared = 2.8229, df = 5, p-value = 0.7273

Nelle tre analisi eseguite, il valore p per entrambi i metodi era sempre maggiore dei valori alfa tipici utilizzati per testare la significatività (0,05). Ciò significa che non considereremmo nessuno dei due di parte. È interessante notare che il metodo apparentemente imparziale ha valori p costantemente più bassi, il che indica che potrebbe effettivamente essere più distorta. L'avvertenza è che ho fatto solo 3 corse.

AGGIORNAMENTO: Mentre stavo scrivendo la mia risposta, Konrad Rudolph ha pubblicato una risposta che ha lo stesso approccio, ma ottiene un risultato molto diverso. Non ho la reputazione di commentare la sua risposta, quindi ne parlerò qui. Innanzitutto, la cosa principale è che il codice che usa utilizza lo stesso seme per il generatore di numeri casuali ogni volta che viene eseguito. Se cambi il seme, ottieni effettivamente una varietà di risultati. In secondo luogo, se non modifichi il seme, ma modifichi il numero di prove, ottieni anche una varietà di risultati. Prova ad aumentare o diminuire di un ordine di grandezza per capire cosa intendo. In terzo luogo, è in corso un troncamento o un arrotondamento di interi in cui i valori attesi non sono abbastanza accurati. Probabilmente non è abbastanza per fare la differenza, ma c'è.

Fondamentalmente, in sintesi, gli è capitato di ottenere il seme giusto e il numero di prove che avrebbe potuto ottenere un risultato falso.


La tua implementazione contiene un difetto fatale dovuto a un tuo malinteso: il passaggio citato non si confronta rand()%6con rand()/(1+RAND_MAX)/6. Piuttosto, sta confrontando la semplice presa del resto con il campionamento del rifiuto (vedere altre risposte per una spiegazione). Di conseguenza, il tuo secondo codice è sbagliato (il whileciclo non fa nulla). Anche il tuo test statistico ha dei problemi (non puoi semplicemente eseguire ripetizioni del tuo test per la robustezza, non hai eseguito la correzione, ...).
Konrad Rudolph

1
@KonradRudolph Non ho il rappresentante per commentare la tua risposta, quindi l'ho aggiunta come aggiornamento alla mia. Il tuo ha anche un difetto fatale in quanto capita di utilizzare un seme impostato e un numero di prove ogni corsa che dà un risultato falso. Se avessi eseguito ripetizioni con semi diversi, potresti averlo preso. Ma sì, hai ragione il ciclo while non fa nulla, ma non cambia nemmeno i risultati di quel particolare blocco di codice
anjama

Ho eseguito ripetizioni, in realtà. Il seme non è intenzionalmente impostato poiché impostare un seme casuale con std::srand(e nessun uso di <random>) è abbastanza difficile da fare in modo conforme agli standard e non volevo che la sua complessità sminuisse il codice rimanente. È anche irrilevante per il calcolo: ripetere la stessa sequenza in una simulazione è del tutto accettabile. Ovviamente semi diversi produrranno risultati diversi e alcuni non saranno significativi. Questo è del tutto previsto in base a come viene definito il valore p.
Konrad Rudolph

1
Ratti, ho commesso un errore nelle mie ripetizioni; e hai ragione, il 95 ° quantile delle ripetizioni è abbastanza vicino a p = 0,05 - cioè esattamente quello che ci aspetteremmo sotto allora nullo. In sintesi, la mia implementazione della libreria standard di std::randproduce simulazioni di lancio di monete straordinariamente buone per un d6, attraverso la gamma di semi casuali.
Konrad Rudolph

1
La significatività statistica è solo una parte della storia. Hai un'ipotesi nulla (distribuita uniformemente) e un'ipotesi alternativa (bias modulo), in realtà una famiglia di ipotesi alternative, indicizzate dalla scelta di RAND_MAX, che determina la dimensione dell'effetto del bias modulo. La significatività statistica è la probabilità sotto l'ipotesi nulla che tu la rifiuti falsamente. Qual è il potere statistico - la probabilità in un'ipotesi alternativa che il tuo test rigetti correttamente l'ipotesi nulla? Ti rilevare rand() % 6in questo modo quando RAND_MAX = 2 ^ 31-1?
Squeamish Ossifrage

2

Si può pensare a un generatore di numeri casuali come lavorare su un flusso di cifre binarie. Il generatore trasforma il flusso in numeri suddividendolo in pezzi. Se la std:randfunzione funziona con un valore RAND_MAXdi 32767, utilizza 15 bit in ciascuna sezione.

Quando si prendono i moduli di un numero compreso tra 0 e 32767 inclusi, si trova che 5462 '0 e' 1 'ma solo 5461' 2 ',' 3 ',' 4 'e' 5. Quindi il risultato è parziale. Maggiore è il valore RAND_MAX, minore sarà il bias, ma è inevitabile.

Ciò che non è polarizzato è un numero compreso nell'intervallo [0 .. (2 ^ n) -1]. È possibile generare un numero (teoricamente) migliore nell'intervallo 0..5 estraendo 3 bit, convertendoli in un numero intero nell'intervallo 0..7 e rifiutando 6 e 7.

Si spera che ogni bit nel flusso di bit abbia la stessa possibilità di essere uno "0" o un "1" indipendentemente da dove si trova nel flusso o dai valori degli altri bit. Ciò è eccezionalmente difficile nella pratica. Le molte diverse implementazioni dei PRNG software offrono diversi compromessi tra velocità e qualità. Un generatore congruente lineare come std::randoffre la massima velocità per la qualità più bassa. Un generatore crittografico offre la massima qualità per la velocità più bassa.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.