为什么rand()%6有偏见?


109

在阅读如何使用std :: rand时,我在cppreference.com上找到了此代码

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

右边的表达式有什么问题?尝试了一下,它完美地工作。


24
请注意,使用骰子更好std::uniform_int_distribution
Caleth

1
@Caleth是的,这只是为了了解为什么此代码“错误” ..
yO_

15
从“错误”更改为“有偏见”
Cubbi

3
rand()在典型的实现中是如此糟糕,您不妨使用xkcd RNG。因此,这是错误的,因为它使用rand()
CodesInChaos

3
我写了这个东西(不是评论,而是@Cubbi),当时我想到的是Pete Becker的回答解释的内容。(仅供参考,这基本上与libstdc ++的算法相同uniform_int_distribution。)
TC

Answers:


136

有两个问题rand() % 61+不影响任何一个问题)。

首先,正如几个答案所指出的那样,如果的低位rand()未适当统一,则余数运算符的结果也将不一致。

其次,如果所产生的不同值的数目rand()不是6的倍数,则其余部分将产生比高值更多的低值。即使rand()返回完美分布的值也是如此。

举一个极端的例子,假装rand()在范围内产生均匀分布的值[0..6]。如果查看这些值的余数,则rand()返回范围内的值时[0..5],余数将生成范围内均匀分布的结果[0..5]。当rand()返回6时,rand() % 6返回0,就像rand()返回了0一样。因此,您得到的0分配是其他任何值的两倍。

第二个是真正的问题rand() % 6

避免该问题的方法是丢弃将产生非均匀重复项的值。您计算出小于或等于6的最大倍数RAND_MAX,并且每当rand()返回一个大于或等于该倍数的值时,您都会拒绝它并再次调用`rand(),次数需要多次。

所以:

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

那是所讨论代码的另一种实现,旨在更清楚地显示正在发生的事情。


2
我已经保证在该站点上至少有一位常客会就此发表论文,但我认为抽样和剔除可能会浪费很多时间。例如,夸大方差。
Bathsheba

30
我绘制了一个图表,说明如果rand_max为32768,则此技术会引入多少偏差,在某些实现中是这样。ericlippert.com/2013/12/16/...
埃里克利珀

2
@Bathsheba:确实某些拒绝函数可能会导致这种情况,但是这种简单的拒绝会将统一IID转换为不同的统一IID分布。没有位会继续传播,因此非常独立,所有样本都使用相同的抑制率,因此相同,并且微不足道以显示均匀性。均匀积分随机变量的较高矩由其范围完全定义。
MSalters

4
@MSalters:您的第一句话对于一个真正的生成器是正确的,而对于一个伪生成器则不一定是正确的。当我退休时,我将为此写一篇论文。
Bathsheba

2
@Anthony从骰子角度考虑。您需要一个1到3之间的随机数,并且只有一个标准的6面模具。如果掷4-6,则只需减去3就可以得到。但是,假设您想要一个介于1到5之间的数字。如果在滚动6时减去5,那么最终得到的1就是其他任何数字的两倍。基本上,这就是cppreference代码正在做的事情。正确的做法是重新滚动6s。这就是Pete在这里所做的:将骰子划分为多个,以便用相同的方法滚动每个数字,然后重新滚动任何不适合偶数划分的数字
Ray

19

这里有隐藏的深度:

  1. 使用小的uRAND_MAX + 1uRAND_MAX被定义为int类型,并且通常是最大可能的类型int。在您溢出类型的情况下,的行为RAND_MAX + 1将是不确定的signed。写入会1u强制将类型转换RAND_MAXunsigned,从而避免了溢出。

  2. 使用% 6 can(但是在std::rand我见过的 每一种实现上都没有)会在所提供的替代方法之上引入任何其他统计偏差。这种% 6危险的情况是数字生成器在低阶位具有相关平原,例如rand我认为是1970年代的著名IBM实现(用C语言编写),将高低位翻转为“最终值”。繁荣”。进一步的考虑是6很小。RAND_MAX,因此,如果RAND_MAX不是6的倍数,可能会产生最小的影响,而实际上不是。

总之,这些天来,由于其易处理性,我会使用% 6。除了生成器本身引入的统计异常之外,不可能引入任何统计异常。如果仍然不确定,请测试生成器以查看其是否具有适合您的用例的统计属性。


12
% 6只要由生成的不同值的数量rand()不是6的倍数,就会产生有偏差的结果。鸽子洞原理。当然,当偏差RAND_MAX远大于6 时,偏差很小,但它确实存在。对于更大的目标范围,效果当然更大。
Pete Becker

2
@PeteBecker:确实,我应该说清楚。但是请注意,由于整数除法的截断效应,当采样范围接近RAND_MAX时,也会出现“信鸽”现象。
Bathsheba

2
@Bathsheba不会因为截断效果导致结果大于6,从而导致重复执行整个操作?
Gerhardh '18

1
@Gerhardh:是的。实际上,它恰好导致了结果x==7。通常,您将范围划分为[0, RAND_MAX]7 个子范围,其中6个具有相同的大小,最后一个较小的子范围。最后一个子范围的结果将被丢弃。显然,这样一来,您就不能拥有两个较小的子范围。
MSalters

@MSalters:的确如此。但是请注意,由于截断,其他方法仍然会受到影响。我的假设是,由于统计上的陷阱更难以理解,民间对于后者会感到丰满!
Bathsheba

13

此示例代码说明这std::rand是一种传统的货物崇拜秃头案,每当您看到它时,都会使您的眉毛扬起。

这里有几个问题:

该合同人们通常认为,即使是穷人倒霉的灵魂不知道谁更好,并正是这些不会想到这方面,是rand从样品均匀分布在0的整数,1,2,..., RAND_MAX,并且每次调用都会产生一个独立的样本。

第一个问题是假设的合同,即每次通话中的独立统一随机样本,实际上不是文档所说的,而且在实践中,历来的实现甚至都无法提供最严格的独立性。 例如,C99§7.20.2.1'The randfunction'无需详细说明:

rand函数计算范围为0到的伪随机整数序列RAND_MAX

这是一个毫无意义的句子,因为伪随机性是函数(或函数)的属性,而不是整数的属性,但这甚至不会阻止ISO官僚滥用语言。毕竟,唯一对它感到不满的读者比rand担心害怕脑细胞衰退的文档要好得多。

C语言中典型的历史实现如下:

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

不幸的是,即使单个样本可以均匀地分布在一个均匀的随机种子下(取决于的特定值RAND_MAX),它也会在连续调用中的偶数和奇数整数之间交替

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

该表达式(a & 1) ^ (b & 1)以100%的概率产生1,对于在偶数和奇数整数上支持的任何分布上的独立随机样本而言,情况并非如此。因此,出现了一种货物崇拜,人们应该丢弃低阶位来追逐难以捉摸的“更好的随机性”。(剧透警告:这不是一个技术术语。这是一个信号,表明您正在阅读的散文要么不知道他们在说什么,要么认为无能为力,必须屈服。)

第二个问题是,即使每个调用都独立于0、1、2,...,的均匀随机分布独立采样RAND_MAX,的结果rand() % 6也不会像骰子一样均匀地分布在0、1、2、3、4、5中滚动,除非RAND_MAX等于-1模6。 简单的反例:如果RAND_MAX= 6,则从rand(),所有结果的概率均等1/7,但是从rand() % 6,结果0的概率为2/7,而其他所有结果的概率为1/7。 。

正确的方法是拒绝采样: 从0、1、2,...,重复绘制一个独立的统一随机样本,然后拒绝(例如)结果0、1、2,…,—如果得到以下结果之一:这些,重新开始;否则,屈服。sRAND_MAX((RAND_MAX + 1) % 6) - 1s % 6

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

这样rand(),我们接受的结果集可以被6整除,并且每个可能的结果s % 6都可以通过相同数量的接受结果来获得rand(),因此如果rand()均匀分布,则也是s。试验次数没有限制,但是预期的数目小于2,并且成功的概率随试验次数呈指数增长。

如果您将相等数量的结果映射到低于6的每个整数,则选择哪个结果rand()就无关紧要。由于上面的第一个问题,cppreference.com上的代码做出了不同的选择-不能保证的分布或输出的独立性,rand()实际上,低阶位的显示模式看起来“不够随机”(不要介意下一个输出是前一个输出的确定性函数)。

读者练习:证明cppreference.com上的代码在rand()0、1、2,...,RAND_MAX。上均匀分布时,在模辊上产生均匀分布。

读者练习:为什么您更喜欢其中一个子集来拒绝?在这两种情况下,每个审判需要什么计算?

第三个问题是种子空间很小,以至于即使种子均匀分布,一个拥有您的程序知识和一个结果但不是种子的武装者却可以轻易预测种子和随后的结果,这使得他们似乎并非如此毕竟是随机的。 因此,甚至不要考虑将其用于加密。

您可以std::uniform_int_distribution使用合适的随机设备和喜欢的随机引擎(如广受欢迎的Mersenne Twister)去花哨的过度工程路线和C ++ 11的类,std::mt19937与您四岁的堂兄一起玩骰子,但即使那样也不会是适合生成的密码密钥材料和梅森捻线机是一个可怕的空间猪太关于您的CPU与一个下流的设置时间缓存多KB的状态肆虐,所以即使是坏的,比如,平行Monte Carlo模拟与子计算的可复制树;它的受欢迎程度可能主要来自其易记的名称。但是您可以像本例一样将它用于玩具骰子滚动!

另一种方法是使用具有小状态的简单密码伪随机数生成器,例如简单的快速密钥擦除PRNG,或者如果您有信心,则仅使用流密码(例如AES-CTR或ChaCha20)(例如,在Monte Carlo仿真中自然科学的研究),如果状态受到损害,则预测过去的结果不会有不利的后果。


4
“淫秽的安装时间”无论如何,您实际上不应使用一个随机数生成器(每个线程),因此,除非您的程序运行时间不长,否则将摊销安装时间。
JAB

2
顺便说一句BTW,因为它不了解问题中的环路正在执行完全相同的拒绝采样,并且具有完全相同的(RAND_MAX + 1 )% 6值。您如何细分可能的结果并不重要。您可以从范围内的任何位置拒绝它们[0, RAND_MAX),只要接受范围的大小是6的倍数即可。地狱,您可以拒绝任何结果x>6,并且不再需要%6
MSalters

12
我对这个答案不太满意。Rants可能很好,但是您将其带入错误的方向。例如,您抱怨“更好的随机性”不是技术术语,而是毫无意义的。这是正确的一半。是的,这不是技术术语,而是上下文中的一个非常有意义的速记。暗示这样一个术语的用户是无知的还是恶意的,本身就是这些事情之一。“良好的随机性”可能很难精确定义,但是当函数产生具有更好或更差的随机性的结果时,很容易掌握。
康拉德·鲁道夫'18

3
我喜欢这个答案。这有点刺耳,但是它有很多很好的背景信息。请记住,REAL专家只使用过硬件随机生成器,问题就这么难。
Tiger4Hire18年

10
对我来说是相反的。尽管它确实包含了很好的信息,但除了意见之外,它很难理解。除了有用性。
李斯特先生,

2

无论如何,我都不是经验丰富的C ++用户,但是我很想知道其他有关std::rand()/((RAND_MAX + 1u)/6)1+std::rand()%6实际少偏见的答案 是否成立。因此,我写了一个测试程序来列出这两种方法的结果(我已经很久没有写C ++了,请检查一下)。在此处找到用于运行代码的链接。它也被复制如下:

// 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] << ' ';
    }
}

然后,我将其输出,并使用chisq.testR中的函数进行卡方检验,以查看结果是否与预期有显着差异。这个stackexchange问​​题更详细地介绍了如何使用卡方检验来测试裸片公平性:如何测试裸片是否公平?。以下是几次运行的结果:

> ?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

在我进行的三个运行中,两种方法的p值始终大于用于检验显着性的典型alpha值(0.05)。这意味着我们不会认为它们中的任何一个都有偏见。有趣的是,所谓的无偏方法始终具有较低的p值,这表明它实际上可能有更大的偏倚。需要注意的是,我只进行了3次跑步。

更新:当我写我的答案时,康拉德·鲁道夫发布了一个采用相同方法的答案,但是得到了非常不同的结果。我没有信誉评论他的答案,所以我将在这里解决。首先,最主要的是,他使用的代码每次运行时都会为随机数生成器使用相同的种子。如果您更改种子,则实际上可以获得各种结果。其次,如果您不更改种子,但更改试验次数,那么您还将获得各种结果。尝试增加或减少一个数量级以了解我的意思。第三,在期望值不太准确的地方进行了一些整数截断或舍入。它可能不足以产生变化,但是就在那里。

基本上,总的来说,他只是碰巧得到了正确的种子和大量的试验,以至于他可能得到了错误的结果。


您实现包含一个致命的缺陷,由于你一个误区:引述通道不是比较rand()%6rand()/(1+RAND_MAX)/6。而是将剩余部分的简单获取与拒绝采样进行比较(有关说明,请参见其他答案)。因此,您的第二个代码是错误的(while循环不执行任何操作)。您的统计测试也有问题(您不能仅仅为了健壮性而重复测试,就没有执行校正,……)。
康拉德·鲁道夫'18

1
@KonradRudolph我没有代表对您的回答发表评论,所以我将其添加为我的更新。您的数据库还有一个致命缺陷,那就是它碰巧使用了固定的种子,并且每次运行都会产生错误结果,因此需要进行多次试验。如果您使用不同的种子进行重复,则可能会被发现。但是,是的,您是正确的,虽然while循环不执行任何操作,但是它也不会更改该特定代码块的结果
anjama

实际上,我确实重复过几次。故意不设置种子,因为很难以符合标准的方式设置std::srand(不使用<random>)随机种子,并且我不希望它的复杂性降低其余代码的难度。它与计算也无关紧要:在模拟中重复相同的序列是完全可以接受的。当然,不同的种子产生不同的结果,而某些种子是不重要的。完全可以根据p值的定义来预期。
康拉德·鲁道夫

1
老鼠,我重复了一次;没错,重复运行的第95个分位数非常接近p = 0.05-即恰好是我们期望的,然后是null。总之std::rand,在随机种子范围内,我的标准库实现的d6抛硬币模拟效果非常好。
康拉德·鲁道夫

1
统计意义只是故事的一部分。您有一个无效假设(均匀分布)和一个备用假设(模偏差),实际上,这是一组通过选择索引的备用假设RAND_MAX,它决定了模偏差的影响大小。统计显着性是在无效假设下您错误地拒绝它的概率。什么是统计力量 -备择假设您的测试下的概率正确地拒绝零假设?rand() % 6当RAND_MAX = 2 ^ 31-1时,您会以这种方式检测到吗?
Squeamish Ossifrage

2

可以认为随机数生成器可以处理二进制数字流。生成器通过将流切成块将流转换成数字。如果std:rand函数使用RAND_MAX32767的a 表示,则每个切片中使用15位。

当一个采用0到32767(含0和32767)之间的数字的模块时,会发现5462的“ 0”和“ 1”,但只有5461的“ 2”,“ 3”,“ 4”和“ 5”。因此,结果是有偏见的。RAND_MAX值越大,偏差将越小,但这是不可避免的。

没有偏差的是[0 ..(2 ^ n)-1]范围内的数字。通过提取3位,将它们转换为0..7范围内的整数,并拒绝6和7,可以在理论上更好地生成0..5范围内的数字。

人们希望,比特流中的每个比特都有相等的机会成为“ 0”或“ 1”,而不管它在比特流中的位置还是其他比特的值。这在实践中异常困难。软件PRNG的许多不同实现在速度和质量之间提供了不同的折衷。线性同余生成器(例如)可std::rand提供最快的速度和最低的质量。密码生成器以最低的速度提供最高的质量。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.