为什么新的随机库比std :: rand()好?


82

因此,我看到了一个名为rand()认为有害的演讲,该演讲提倡在简单std::rand()加模范范式上使用随机数生成的引擎分布范式。

但是,我想看看std::rand()第一手的失败,所以我做了一个快速实验:

  1. 基本上,我编写了2个函数getRandNum_Old(),分别getRandNum_New()使用std::rand()std::mt19937+生成了一个介于0和5之间(含0和5)的随机数std::uniform_int_distribution
  2. 然后,我使用“旧”方式生成了960,000(可被6整除)随机数,并记录了数字0-5的频率。然后,我计算了这些频率的标准偏差。我要寻找的是尽可能低的标准偏差,因为如果分布真正均匀,就会发生这种情况。
  3. 我对该模拟运行了1000次,并记录了每个模拟的标准偏差。我还记录了所花费的时间(以毫秒为单位)。
  4. 之后,我再次进行了完全相同的操作,但是这次以“新”方式生成随机数。
  5. 最后,我计算了旧方法和新方法的标准差列表的均值和标准差,以及新方法和旧方法的时间列表的均值和标准差。

结果如下:

[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

出人意料的是,两种方法的面包卷总撒布相同。即,std::mt19937+std::uniform_int_distribution不是简单std::rand()+的“统一” %。我所做的另一项观察是,新方法比旧方法慢大约4倍。总体而言,似乎我付出了巨大的速度代价,却几乎没有质量上的提高。

我的实验有某种缺陷吗?还是std::rand()真的不是那么糟糕,甚至更好?

作为参考,这是我完整使用的代码:

#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
这几乎就是为什么存在此建议的原因。如果您不知道如何测试RNG的熵或它是否对您的程序很重要,则应假定std :: rand()不够好。 en.wikipedia.org/wiki/Entropy_(计算)
汉斯·帕桑

4
是否rand()足够的底线很大程度上取决于您使用随机数集合的目的。如果您需要特定类型的随机分布,那么库的实现当然会更好。如果您只需要随机数,而不关心“随机性”或生成哪种类型的分布,那rand()很好。将合适的工具与手头的工作相匹配。
David C. Rankin

2
可能的欺骗:stackoverflow.com/questions/52869166 / ...我只是不想敲打这个,所以我避免实际投票。
bolov

18
for (i=0; i<k*n; i++) a[i]=i%n;产生的精确平均值和标准偏差与最佳RNG相同。如果这对您的应用程序足够好,请使用此序列。
n。代词

3
“标准偏差尽可能小”-不。错了 您希望频率有所不同-关于sqrt(frequency)大约是您期望的标准偏差。nm产生的“递增计数器”将具有更低的sd(并且是非常差的rng)。
Martin Bonner

Answers:


106

几乎所有“旧”的实现都rand()使用LCG;尽管它们通常不是最好的发生器,但是通常您不会看到它们无法通过这样的基本测试-即使在最差的PRNG情况下,均值和标准差也通常是正确的。

“坏”的常见失败-但足够常见-rand()实现是:

  • 低阶位的低随机性;
  • 短期内;
  • RAND_MAX;
  • 连续提取之间的一些相关性(通常,LCG产生的数字在有限数量的超平面上,尽管可以通过某种方式缓解)。

不过,这些都不是特定于的API rand()。一种特定的实现方式可以将xorshift系列生成器放置在srand/后面,rand并且从算法上讲,它可以获取最先进的PRNG,而无需更改接口,因此没有像您所做的那样的测试可以显示输出中的任何弱点。

编辑: @R。正确地注意到,rand/srand接口是由如下事实的限制srand需要一个unsigned int,所以任何发生器一种实施方式可以把它们后面固有地限于UINT_MAX可能起始种子(并且因此产生的序列)。的确确实如此,尽管可以对API进行简单的扩展以使其srand带有unsigned long long或添加单独的srand(unsigned char *, size_t)重载。


确实,实际的问题在原则上rand()并不是很多实现而是:

  • 向后兼容;许多当前的实现使用次优生成器,这些生成器通常具有错误选择的参数;一个臭名昭著的例子是Visual C ++,它RAND_MAX仅支持32767。但是,这不能轻易更改,因为它会破坏与过去的兼容性-使用srand固定种子进行可再现模拟的人们不会太高兴(事实上,IIRC上述实现可以追溯到80年代中期的Microsoft C早期版本-甚至是Lattice C);
  • 简单的界面;rand()为整个程序提供具有全局状态的单个生成器。尽管对于许多简单的用例来说,这是完全可以的(实际上非​​常方便),但它会带来一些问题:

    • 使用多线程代码:要解决此问题,您要么需要一个全局互斥体-它会无缘无故地降低所有速度杀死任何可重复性,因为调用序列本身变得随机-或线程局部状态;最后一个已被多种实现采用(特别是Visual C ++);
    • 如果您要在程序的特定模块中使用不影响全局状态的“私有”,可复制序列。

最后,rand事态:

  • 没有指定实际的实现(C标准仅提供了示例实现),因此任何旨在在不同编译器之间产生可重现输出(或期望具有某些已知质量的PRNG)的程序都必须使用自己的生成器;
  • 没有提供任何跨平台的方法来获得体面的种子(time(NULL)因为它不够细小,而且通常-认为没有RTC的嵌入式设备-甚至不够随机),这是没有的。

因此,提供了新的<random>标头,该标头试图解决以下问题,从而提供了以下算法:

  • 完全指定的(这样您就可以具有交叉编译器可再现的输出和有保证的特性-例如,生成器的范围);
  • 通常具有最先进的质量(从设计库时开始;请参阅下文);
  • 封装在类中(这样就不会强制您使用全局状态,从而避免了完全的线程和非局部性问题);

...以及默认的random_device种子。

现在,如果你问我,我也喜欢建立在此之上的简单API“易”,“猜一个数字”的情况下(类似Python不如何提供“复杂”的API,而且琐碎random.randint&CO使用简单的全局PRNG为我们提供了简单的条件,他们不想在每次我们想要提取宾果卡的号码时淹死在随机的设备/引擎/适配器/任何物品中,但是确实可以轻松地由您自己在当前工具上构建它(而无法通过简单的工具构建“完整” API)。


最后,回到性能比较:正如其他人所指出的,您正在将快速LCG与较慢(但通常认为质量更好)的Mersenne Twister进行比较。如果您对LCG的质量没问题,可以使用std::minstd_rand代替std::mt19937

确实,调整函数以使其使用std::minstd_rand并避免使用无用的静态变量进行初始化之后

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

我得到9毫秒(旧)对21毫秒(新);最后,如果我摆脱了dist(与经典的模运算符相比,它处理输出范围而不是输入范围倍数的分布偏斜),然后返回到您正在执行的操作getRandNum_Old()

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

我将其降低到6毫秒(因此快了30%),可能是因为,与对的调用不同rand()std::minstd_rand它更易于内联。


顺便说一句,我使用手动滚动进行了相同的测试(但几乎符合标准库接口)XorShift64*,并且比rand()(3.68 ms vs 8.61 ms)快2.3倍;鉴于此,与Mersenne Twister和提供的各种LCG不同,它通过了当前带有随机色彩的随机性测试套件, 并且速度非常快,这使您想知道为什么它尚未包含在标准库中。


3
这恰恰是srandstd::rand 麻烦的未指定算法的组合。另请参阅我对另一个问题的回答
Peter O.

2
rand从根本上限制了API级别,因为种子(以及因此可能产生的可能序列的数量)由限制UINT_MAX+1
R .. GitHub STOP HELPING ICE

2
只需注意:minstd是不好的PRNG,mt19973更好,但不是很多:pcg-random.org/…(在该图表中,minstd == LCG32 / 64)。可惜的是,C ++没有提供任何高质量,快速的PRNG,如PCG或xoroshiro128 +。
user60561

2
@MatteoItalia我们没有意见分歧。这也是比耶恩的观点。我们确实希望<random>在标准中使用,但我们还希望有“仅给我一个可以立即使用的不错的实现”选项。用于PRNG以及其他东西。
ravnsgaard '18 -10-30

2
有几点注意事项:1.替换std::uniform_int_distribution<int> dist{0, 5}(eng);eng() % 6重新引入了std::rand代码所遭受的偏斜因素(在这种情况下,如果引擎具有2**31 - 1输出,则将其归为较小的偏斜,并且将它们分配给6个存储区)。2.在您的注释中,“srand采用unsigned int”限制了可能的输出,正如所写的那样,给您的引擎注入std::random_device{}()同样的问题;您需要seed_seq正确初始化大多数PRNG
ShadowRanger

6

如果您以大于5的范围重复实验,则可能会看到不同的结果。当您的范围明显小于RAND_MAX大多数应用程序时,就没有问题了。

例如,如果aRAND_MAX为25,rand() % 5则将产生具有以下频率的数字:

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

作为RAND_MAX保证是超过32767,并在最有可能,最可能的频率之间的差异仅仅是1,对小号码分布是足够的随机接近大多数用例。


3
这在STL的第二张幻灯片解释
阿伦Birtles

4
好的,但是...谁是STL?什么幻灯片?(严重问题)
kebs

@ kebs,Stephan Lavavej,请参阅问题中的YouTube参考。
EVG

3

首先,令人惊讶的是,答案随您使用随机数的目的而变化。如果要驱动,例如,使用rand()驱动一个随机的背景颜色更改器就很好了。如果您使用随机数来创建随机的扑克手或加密安全密钥,那是不正确的。

可预测性:序列012345012345012345012345 ...将为您的样本中的每个数字提供均匀分布,但显然不是随机的。对于随机序列,不能轻易通过n值(甚至是n,n-1,n-2,n-3等的值)来预测n + 1的值。相同的数字是简并的情况,但是可以对使用任何线性同余生成器生成的序列进行分析;如果您使用通用库中通用LCG的默认开箱即用设置,则恶意人员可以不费吹灰之力就“破坏序列”。过去,一些在线赌场(以及一些实体赌场)因使用不良随机数生成器的机器而遭受损失。即使是应该更了解的人也被赶上了。

分布:如视频中所暗示的那样,取100的模(或任何不能在序列长度上均分的值)将确保某些结果比其他结果至少更有可能出现。在32767个可能的初始值模为100的范围中,数字0到66的出现频率比值67到99的出现频率高328/327(0.3%);可能为攻击者提供优势的因素。


1
“可预测性:序列012345012345012345012345 ...将通过您的“随机性”测试,因为样本中每个数字的分布都是均匀的”,实际上并非如此。他正在测量的是两次运行之间的标准差的标准差,即本质上是如何分散各种运行直方图的。使用012345012345012345 ...生成器,它将始终为零。
Matteo Italia

好点子; 恐怕我过快地阅读了OP的代码。编辑我的答案以反映出来。
JackLThornton

呵呵我知道,因为我虽然做该测试,以及,我发现我得到不同的结果😄
利玛窦意大利

1

正确的答案是:这取决于您所说的“更好”。

“新”<random>引擎是13年前引入C ++的,因此并不是真正的新引擎。C库rand()是几十年前引入的,它在当时对于许多事物都非常有用。

C ++标准库提供了三类随机数生成器引擎:“线性同余”(rand()示例),“滞后斐波那契”和“梅森扭曲器”。每个类都有权衡,每个类在某些方面是“最佳”的。例如,LCG的状态非常小,如果选择了正确的参数,则在现代台式机处理器上的运行速度非常快。LFG具有较大的状态,并且仅使用存储器提取和加法运算,因此在缺少专用数学硬件的嵌入式系统和微控制器上的速度非常快。MTG具有巨大的状态并且很慢,但是可以具有非常大的非重复序列,并具有出色的光谱特性。

如果提供的所有生成器都不足以满足您的特定用途,则C ++标准库还将为硬件生成器或您自己的自定义引擎提供接口。没有一个发生器可以独立使用:它们的预期用途是通过一个分配对象提供的,该对象提供具有特定概率分布函数的随机序列。

<random>over的另一个优点rand()rand()使用全局状态,不是可重入的或线程安全的,并且每个进程只允许一个实例。如果您需要细粒度的控制或可预测性(即,能够在给出RNG种子状态的情况下重现错误),那rand()将毫无用处。该<random>发电机组是在本地实例化和具有序列化(和可恢复)状态。

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.