刚接手一个仿真项目时,我遇到过一个特别典型的场面:某同学用MATLAB写队列模型,连续跑十次,平均等待时间各不相同,但每次换一个参数组合,随机序列又完全一样。答辩现场被追问“你这个随机数到底是真的还是假的”,他支支吾吾说不清。问题不在MATLAB,而在于很多人没真正想明白真随机数和伪随机数的关系,以及手里的rand函数到底是哪种。
这篇文章不绕弯子,直接讲透三件事:真随机数与伪随机数的本质差异在哪里、主流伪随机数生成算法是怎么工作的、以及MATLAB里从随机种子到随机流控制的完整实操方案。中间附上可以直接运行的实验代码和一套简单的质量检验方法。适合刚接触数值仿真的学生,也适合写蒙特卡洛程序或做随机信号处理的工程师。看完之后,你会知道为什么固定种子后rand生成的序列一模一样,也会明白什么时候该用rng('shuffle'),什么时候该用固定种子。
1. 一个老问题的本质:真随机数和伪随机数到底差在哪里
1.1 为什么固定种子后“随机数”会一模一样
MATLAB执行rng(42)之后,接着调用rand(1,5),你得到的五位数是固定的:0.3745, 0.9507, 0.7320, 0.5987, 0.1560。再跑一次,还是这五个数。很多人的第一反应是“这不叫随机”,但从程序角度看,这恰恰是伪随机数的核心特征——它由一个确定性的算法按照初始状态一步步推演出来。
可以这样理解:伪随机数生成器就像一本极其复杂的菜谱。同一个厨师、同一种原料、同一套步骤,做出来的菜当然一模一样。这里的“原料”就是种子,而“菜谱”就是底层的递推公式。种子不同,序列才不同;种子相同,序列必然复现。MATLAB每次启动后默认会按当前时间初始化种子,所以你通常觉得rand每次运行都不一样,这只是因为种子一直在变,而不是因为rand本身具备了物理上的随机性。
1.2 真随机数从哪里来:物理熵源与应用边界
真随机数的来源是物理世界中的不可预测过程,例如芯片内部热噪声、振荡器时钟抖动、光电效应产生的事件间隔。这类信号本身携带“熵”,采样之后直接转成数值序列。好处很明显:序列不可复现、不可预测,即使知道之前生成的所有数,也无法推算下一个数。
正因如此,真随机数主要用在需要防预测、防作弊、防重放的场景,比如抽奖系统的中奖序号生成、安全令牌的种子、加密协议的密钥协商。但在日常数值计算里,真随机数反而没那么受欢迎:专用采集硬件成本不低,物理过程采样速度有限,而且无法复现实验结果。科学研究要求“可重复验证”,如果一篇论文里的随机浪花每次都不一样,评审拿什么复核你的结论?
1.3 伪随机数的“合格线”:统计上像随机,但不保证不可预测
伪随机数生成器输出的序列虽然由公式决定,但只要它在统计层面足够接近“独立同分布”,就可以放心用于仿真计算。这里的“足够接近”通常包含几个维度:均匀性(每个取值区间的频次大致相等)、独立性(前后数值之间没有明显关联)、周期足够长(不会在仿真中途循环回到起点)、可复现性(相同种子得到相同序列)。
需要特别提醒一点:伪随机数不等于“可以用来搞加密”。普通生成器的状态一旦被观测到足够多的输出,后续序列是可以被推算的。密码学场景需要的是另一类面向安全设计的生成器,它们往往内置外部熵源或经过刻意设计的不可逆状态更新。换句话说,统计仿真可以放心用伪随机数,但“防攻击”的需求不能交给rand了事。
2. 底层生成器的工作方式:从线性同余到梅森旋转
2.1 线性同余生成器(LCG):一眼看懂伪随机数公式
线性同余生成器是最古老也最容易理解的伪随机数算法,公式只有一行:
X_{n+1} = (a * X_n + c) mod m其中a是乘数,c是增量,m是模数。给定初始值X_0(也就是种子),每次迭代就得到下一个数。举一个教学用的例子:设a=69,c=1,m=100,种子为X_0=1,则序列为:
1, 70, 31, 40, 61, 10, 91, 80, 21, 50, 51, ...肉眼看上去已经有点乱,但很快就会被看穿:序列从第12项开始重复,周期只有m这么长。实际工程中选择a、c、m非常讲究,不是随便填三个数就能用。经典的参数组合能把周期拉到接近m,但低位的随机性依旧很差。
我曾用玩具LCG生成大量[0,9]的个位数,结果发现低位数字表现出明显的短周期循环:数字序列每隔一定间隔就会重现。原因很直观:取模运算会把高位的复杂信息丢掉,低位的随机性远低于高位。所以专业教科书和接口文档都会反复强调——不要从随机数里挑选低位比特当作独立随机数使用。
2.2 梅森旋转:为什么现代程序普遍选择它
当代主流编程环境里,算法层面的默认伪随机数生成器大多不是LCG,而是梅森旋转(Mersenne Twister)。它的核心思想是用一个624维的状态数组保存历史信息,再通过移位、异或、掩码等位运算不断刷新状态,并从中提取输出。MATLAB中通过rng(seed, 'twister')就可以显式指定使用它。
梅森旋转的周期达到2^19937 - 1,这个数字大到什么程度?哪怕每秒生成十亿个随机数,连续跑到宇宙毁灭也用不完。相比LCG,它生成的序列在统计均匀性、维度分布和运行速度上都更优秀。这也是为什么在蒙特卡洛仿真和机器学习实验里,梅森旋转是默认选项之一。
但它也有自己的短板:状态空间很大,恢复或保存状态比较占内存;而且整个算法本质上是确定性的,不适合安全敏感场景。如果只是做数据分析或仿真,这些短板几乎无感;一旦涉及密钥或对抗场景,就该换到其他类型的生成器。
2.3 周期长不等于质量好:两个容易被忽略的问题
“周期长”是很多初学者选生成器时的唯一标准,这里必须泼一盆冷水。一个周期很长的生成器,完全可能在某个区间内长期输出低质量序列,例如连续的数值落在同一条直线上。术语叫“高维均匀性差”,普通用户感知到的就是:看起来每一个数都很随机,但多个数放在一起,它们的组合模式会暴露出规律。
另一个常见问题是尾部效应:有些算法在状态刚刚初始化、还没“预热”时,前几十个输出的统计质量明显偏差。早年一些随机数库要求用户先丢弃前几百个数再正式使用,目的就是避开这个不稳定的启动区段。虽然现代主流生成器已经大幅改善了初始化质量,但在金融、天气等大规模仿真的极端需求下,软件实现方案仍要对齐源算法建议的预热方式。
3. MATLAB中伪随机数的标准姿势:函数、种子与随机流
3.1 rand / randn / randi 三个函数的正确用法
MATLAB和随机数打交道,日常高频的是三个函数。rand生成(0,1)区间内的均匀分布随机数;randn生成均值为0、标准差为1的标准正态分布随机数;randi生成指定区间内的整数随机数。调用格式分别为:
rng(7); % 先固定种子,保证下面结果可复现 uniformVec = rand(1, 1000); % 1000个(0,1)均匀数 normalVec = randn(1, 1000); % 1000个标准正态数 intMat = randi([1, 6], 10, 1); % 10个1到6之间的整数三种函数都支持指定维度参数,比如rand(3,4)生成3行4列的矩阵。注意rand的输出区间是左闭右开,严格来说是[0,1),但一般计算中不需要纠结边界值。如果你想要[a,b]区间的均匀分布,可以写成a + (b-a) * rand(m,n);想要均值为mu、标准差为sigma的正态分布,就写成mu + sigma * randn(m,n)。
3.2 rng 种子控制:为什么旧版接口不该再用
rng指令是MATLAB推荐的随机数控制入口。它支持三种常见用法:
rng(2024); % 固定种子,复现后续所有随机序列 rng('shuffle'); % 根据当前时间初始化种子,每次运行结果不同 rng(2024, 'twister'); % 固定种子,并显式指定梅森旋转生成器为什么现在写代码不建议再用老式的rand('seed', 2024)?因为旧接口为了兼容历史代码,改的是“全局随机状态”里的一小部分,容易不经意间把生成器状态搞乱,也让多随机流的管理变得困难。rng则统一管理种子和生成器类型,语义清晰得多。我见过不少旧脚本,运行到一半突然调用rand('twister'),后面的随机序列完全变成另一套体系,排查起来非常痛苦。
3.3 RandStream:并行仿真时的随机数隔离方案
当仿真任务拆成多个并行进程或并行池worker时,随机数最大的坑是:如果不做隔离,每个worker可能拿到完全相同的种子,生成完全相同的序列。这时要用RandStream给不同任务分配独立的随机流:
stream1 = RandStream.create('mlfg6331_64', 'Seed', 101); stream2 = RandStream.create('mlfg6331_64', 'Seed', 202); a = rand(stream1, 1, 100); b = rand(stream2, 1, 100);两个流之间互不干扰。如果希望整个程序里的rand、randn、randi全部走某一个流,可以调用RandStream.setGlobalStream(stream1)切换全局随机流。这种做法的价值在于:既保证每个并行任务拥有不同序列,又能在需要时复现某一条流的全部实验结果。MATLAB在并行计算工具箱里还支持基于s的流式随机数,具体到项目里,可以优先选择带有Substream机制的生成器,让每个worker都从同一个流的子流中取数,方便调试。
4. 两个拿来就能跑的MATLAB实验:蒙特卡洛算π与信号加噪
4.1 蒙特卡洛估算圆周率:从代码到误差分析
伪随机数最经典的入门实验就是撒点估算圆周率。思路是这样的:在边长为1的正方形内随机撒N个点,坐标(x,y)都从[0,1]均匀分布中取;统计落进四分之一圆内的点数,比例近似等于π/4。用MATLAB写起来极其顺手:
N = 1e6; rng(42); % 固定种子,结果可复现 x = rand(1, N); y = rand(1, N); inside = (x.^2 + y.^2) <= 1; piEst = 4 * sum(inside) / N; fprintf('估算的圆周率: %.6f\n', piEst);跑完你会发现,N=1e6时结果大约在3.14附近,但每次更换种子也会小幅波动。这个波动本身不是bug,而是蒙特卡洛方法的固有特性:随机样本的统计量围绕真值浮动,误差大概按1/sqrt(N)衰减。想要误差小一个数量级,样本量需要增加一百倍。
这个实验非常适合用来直观感受“伪随机数到底像不像真随机”:你把种子固定,结果就固定;你把种子换成shuffle,每次结果都不同。理论上,每次不同的“抖动”幅度恰好可以用统计学公式预判——大家关心的不是单次值准不准,而是大量重复之后,估算值的分布是否以真值为中心。这正是伪随机数在仿真里最重要的应用逻辑。
4.2 给信号加噪声:randn的标准差理解与SNR计算
模拟通信或传感器数据时,最常见的操作是给干净信号叠加高斯白噪声。很多人随手写y = signal + randn(size(signal)),结果发现噪声过强,把原始波形完全淹没了。根源在于randn默认生成的是标准差为1的噪声,而真实信号幅度往往远小于1。
正确的做法是根据期望信噪比计算噪声标准差。假设一个50Hz正弦信号,采样率1000Hz,采样1024点,想得到约20dB信噪比:
fs = 1000; t = (0:1023) / fs; signal = sin(2 * pi * 50 * t); signalPower = mean(signal.^2); snrDb = 20; noisePower = signalPower / (10^(snrDb / 10)); noise = randn(1, 1024) * sqrt(noisePower); y = signal + noise; snrEst = 10 * log10(mean(signal.^2) / mean(noise.^2)); fprintf('估算信噪比: %.2f dB\n', snrEst);核心认知是:加噪前先算信号功率,再反推噪声方差。很多新手容易把randn乘上的系数当成幅度,实际上那是标准差;标准差是0.1时噪声功率就是0.01,功率折算成信噪比时差了整整20dB。这个细节在实验报告里经常被忽略,却是信号处理中错误率最高的点之一。
5. 给随机数做体检:均匀性与相关性如何检验
5.1 均匀性检验:卡方检验和KS检验怎么用
伪随机数质量好不好,不能只靠“肉眼觉得挺乱”,要拿统计工具验证。最简单的是对均匀随机数做直方图观测,再配一个假设检验。MATLAB里可以用chi2gof做卡方拟合优度检验:
rng(123); data = rand(1, 10000); [h, p] = chi2gof(data, 'NBins', 20);如果h=0,说明没有足够证据拒绝“数据来自均匀分布”的原假设,也就是这组数据均匀性正常。p值较大时,序列和均匀分布的偏差可以被视为正常采样波动。这里要强调一个常见误读:p>0.05不等于“100%均匀”,只是说在当前样本量下没有发现显著差异;样本量增大以后,细微偏差才容易暴露。
对于连续分布数据,还可以用kstest做Kolmogorov-Smirnov检验。比如想验证randn的输出是否真的接近标准正态分布:
rng(456); z = randn(1, 5000); [h, p] = kstest(z); % 默认检验标准正态分布这类检验的价值不在于证明“这是完美的随机数”,而在于建立一种工程上的信心:至少到当前样本量为止,数据的行为符合预期。
5.2 独立性检验:自相关函数看门道
均匀性和独立性是两回事。一组数可能各个区间频次都很均匀,但相邻两个数之间却存在明显的相关性。用autocorr可以快速检查序列是否存在“记忆”:
rng(789); x = randn(1, 10000); autocorr(x, 20);输出的自相关图里通常会有两条虚线表示95%置信区间,落在带内表示该滞后阶数上不存在显著自相关。如果某个滞后阶数的相关超出边界,说明序列不是充分独立的,仿真结果可能被隐藏的周期结构污染。我曾在旧代码里看到过一个实际案例:某LCG生成器的低2位有强周期,导致一个随机数从每轮抽取0~3的整数,最终仿真结论完全偏离理论值。检查自相关图后才发现问题根源。
6. 我在项目中踩过的随机数相关的坑
6.1 回归测试不稳定:忘记固定种子的代价
有段时间我维护一个仿真算法库,某个夜间回归测试反复挂掉,失败点总在同一个断言:输出结果的方差应该落在某个区间。第一次排查时,我盯着代码看了很久没发现问题,后来才意识到测试脚本里根本没设置随机种子。每次运行时随机序列完全不同,方差一会儿高一会儿低,阈值判断自然不稳定。
解决办法很朴素:测试用例开头加一行rng(2024),整个测试过程就完全可复现。但这里有个容易被忽视的补充策略——固定种子只验证了一种随机情形。更稳妥的做法是为回归测试定义一组种子数组,轮流跑多个种子,把所有结果都纳入断言。这样既保留可复现性,又能覆盖更多随机状态下的行为。
6.2 parfor并行随机序列雷同
另一个坑出现在把for循环改成parfor之后。当时代码在单核上跑得好好的,换成并行池后,多个worker生成的随机数序列居然一模一样。原因在于每个worker初始状态下都读取了相同的全局默认随机流,自然产生相同序列。修法分两种:
- 思路一:不给每个worker设置随机种子,而是使用
RandStream的并行流特性,让MATLAB自动为每个worker分配独立的子流。 - 思路二:手动为不同任务设置不同种子,比如
rng(workerIndex * 1000),但要注意种的间隔足够大,避免生成的初始状态高度相关。
我在实际项目里更推荐第一种,因为它从机制上保证流之间的独立性,而不是依赖seed数值的“人工分类”。
6.3 随机初始化细节:洗牌、范围和种子策略
还有一些不起眼的细节会悄悄拉低实验质量。比如打乱数据集顺序,正确做法是用randperm生成随机排列索引,而不是先sort(rand)再取排序索引;后者本质上做了完全不必要的排序运算,而且当数据量很大时,数值相同导致的排序不稳定会影响可复现性。
关于随机种子策略,我的个人习惯是:探索阶段用rng('shuffle'),让每次尝试都覆盖不同随机路径;正式实验和发布代码时固定种子,并把种子编号记录在结果文件里。如果评审需要复现,只要给出种子值和生成器类型,就能完整重放整个过程。这个习惯帮助我在无数次“为什么换台机器结果变了”的排查中幸免于难。
说到底,真随机数和伪随机数的关系,是一个“物理现实与工程便利”的取舍问题。真随机数不可预测但昂贵,伪随机数可复现且廉价,MATLAB默认给我们的是一套成熟的、可校验的伪随机方案。只要理解了种子、生成器和随机流这三个层次,绝大多数仿真和算法实验都能做得既可信又可复现。如果你后面再遇到“随机数不随机”的质疑,不妨直接把种子方案和统计检验结果摆出来,比争论“它到底是不是真随机”有用得多。