第 9 章 随机数生成

前面几章主要讨论确定性算法:给定同样的输入,算法每次都会给出同样的输出。从本章开始,我们进入随机模拟方法。随机模拟的基本思想是:如果某个统计量难以直接计算,就通过大量随机样本来近似它。因此,随机数生成是 Monte Carlo、Bootstrap 和 MCMC 的共同起点。

在经济统计中,随机模拟有很多自然用途。例如,我们可以模拟家庭收入分布来评估贫困率估计的不确定性;模拟抽样调查过程来比较不同样本量的标准误;模拟企业违约事件来研究风险模型的误差;也可以在课程项目中用模拟数据检查估计算法是否按理论预期工作。

本章的学习重点有三个。第一,理解计算机中的随机数为什么可以复现;第二,掌握从均匀随机数生成常见分布随机数的基本方法;第三,能够写出清楚的数据生成过程,为后续 Monte Carlo、Bootstrap 和MCMC 章节做准备。

9.1 伪随机数与可重复性

计算机生成的随机数通常不是“真正随机”的,而是由确定性算法产生的伪随机数。给定同一个随机种子,算法会生成完全相同的随机数序列。这一点对统计计算非常重要:模拟结果既要像随机实验,又要能够被教师、同学或审稿人复现。

set.seed(2026)
runif(5)
#> [1] 0.6987 0.5565 0.1401 0.2857 0.5554

set.seed(2026)
runif(5)
#> [1] 0.6987 0.5565 0.1401 0.2857 0.5554

两次结果相同,是因为我们把随机数生成器重置到了同一个初始状态。写模拟代码时,通常应在主要模拟开始前明确设置 set.seed()。但也不要在循环内部反复设置同一个种子,否则会不断重复同一批随机数。

R 中可以查看当前随机数生成器类型:

RNGkind()
#> [1] "Mersenne-Twister" "Inversion"        "Rejection"

本科阶段不需要深入研究随机数生成器的数学构造,但要形成两个基本习惯:第一,模拟实验要设置随机种子;第二,报告模拟结果时要说明模拟设定,而不是只给出一个无法复现的数字。

一个最简单的伪随机数生成思想是线性同余法:

\[ x_{t+1}=(a x_t+c)\bmod m, \]

其中 \(x_t\) 是第 \(t\) 步的整数状态,\(a\)\(c\)\(m\) 是算法参数,\(\bmod\) 表示取余。把

\[ u_t=\frac{x_t}{m} \]

看作 \((0,1)\) 上的数,就得到一串伪随机数。下面用很小的参数演示这一点。这个例子只是为了说明机制,实际模拟中不要使用这样简陋的生成器。

lcg <- function(seed, a = 17, c = 43, m = 100, n = 12) {
  x <- numeric(n)
  x[1] <- seed
  for (i in 2:n) {
    x[i] <- (a * x[i - 1] + c) %% m
  }
  x / m
}

lcg(seed = 7)
#>  [1] 0.07 0.62 0.97 0.92 0.07 0.62 0.97 0.92 0.07 0.62 0.97 0.92
lcg(seed = 7)
#>  [1] 0.07 0.62 0.97 0.92 0.07 0.62 0.97 0.92 0.07 0.62 0.97 0.92

两次结果相同,说明“随机种子”本质上就是随机数生成器的初始状态。好的随机数生成器会使用更复杂的状态更新规则,使序列在统计性质上接近真正随机。R 默认的随机数生成器已经足够支持本书中的教学模拟。

还要注意,不应在循环内部反复设置同一个种子。下面的代码会生成 5 个完全相同的数:

bad <- numeric(5)
for (i in 1:5) {
  set.seed(99)
  bad[i] <- runif(1)
}
bad
#> [1] 0.5847 0.5847 0.5847 0.5847 0.5847

正确做法是在模拟开始前设置一次种子,然后让随机数生成器自然向前推进。

9.2 均匀随机数

最基本的随机数是均匀分布随机数。若

\[ U\sim U(0,1), \]

\(U\) 在区间 \((0,1)\) 上各处取值机会相同。R 中使用 runif() 生成均匀随机数:

u <- runif(10)
u
#>  [1] 0.025131 0.466231 0.861011 0.252501 0.580806 0.005933 0.691866 0.231125
#>  [9] 0.848532 0.153835

很多其他分布的随机数都可以从均匀随机数变换得到。直观地说,均匀随机数提供了“随机性来源”,而不同的变换把这种随机性塑造成指数分布、正态分布、二项分布等不同形状。

还有一个与逆变换法配套的重要事实:如果 \(X\) 的分布函数为 \(F\),且 \(F\) 连续,则

\[ F(X)\sim U(0,1). \]

这称为概率积分变换(probability integral transform)。它说明分布函数可以把任意连续随机变量重新标定到 \((0,1)\) 区间上;反过来,分位数函数又可以把均匀随机数变成目标分布随机数。下面用正态随机数做一个简单检查。

set.seed(2027)
x_norm <- rnorm(10000)
u_from_norm <- pnorm(x_norm)

c(mean = mean(u_from_norm),
  variance = var(u_from_norm),
  theoretical_mean = 1 / 2,
  theoretical_variance = 1 / 12)
#>                 mean             variance     theoretical_mean 
#>              0.49676              0.08335              0.50000 
#> theoretical_variance 
#>              0.08333

如果画出 u_from_norm 的直方图,应接近一条高度为 1 的水平线。这个例子也帮助我们理解 R 中p 函数和 q 函数的关系:pnorm() 把正态变量变成均匀变量,qnorm() 则把均匀变量变回正态变量。

R 对常见分布函数采用统一命名规则。以前缀为例:

表9.1: R 中常见分布函数的命名规则
前缀 含义 正态分布例子 Poisson 分布例子
d 密度或概率质量函数 dnorm() dpois()
p 分布函数 pnorm() ppois()
q 分位数函数 qnorm() qpois()
r 随机数生成 rnorm() rpois()

例如,rnorm(100) 表示生成 100 个正态随机数,qnorm(0.975) 表示标准正态分布的 97.5% 分位数。熟悉这套命名规则,可以显著减少查函数帮助的成本。

9.3 逆变换法

设随机变量 \(X\) 的分布函数为 \(F(x)\)。如果 \(F\) 连续且严格单调,则

\[ X=F^{-1}(U),\quad U\sim U(0,1) \]

服从分布函数 \(F\)。这是因为

\[ P\{F^{-1}(U)\leq x\}=P\{U\leq F(x)\}=F(x). \]

其中 \(F^{-1}\) 是分位数函数。这个方法称为逆变换法。以指数分布为例,若\(X\sim \operatorname{Exp}(\lambda)\),则

\[ F(x)=1-\exp(-\lambda x),\quad x\geq 0. \]

\(U=F(X)\),可得

\[ X=-\frac{1}{\lambda}\log(1-U). \]

set.seed(1)
lambda <- 2
u <- runif(10000)
x <- -log(1 - u) / lambda

c(sample_mean = mean(x), theoretical_mean = 1 / lambda)
#>      sample_mean theoretical_mean 
#>            0.503            0.500

R 已经提供了 rexp(),但手写一次逆变换有助于理解随机数生成背后的原理。

逆变换法也可以用于离散分布。若随机变量 \(X\) 取值为 \(x_1,\ldots,x_K\),对应概率为\(p_1,\ldots,p_K\),则可以先计算累积概率

\[ P_k=\sum_{j=1}^k p_j, \]

再生成 \(U\sim U(0,1)\),取满足 \(U\leq P_k\) 的第一个类别。下面手写一个离散抽样函数。

sample_discrete_inverse <- function(values, prob, n) {
  prob <- prob / sum(prob)
  cum_prob <- cumsum(prob)
  u <- runif(n)
  values[findInterval(u, c(0, cum_prob), rightmost.closed = TRUE)]
}

set.seed(11)
labor_status <- sample_discrete_inverse(
  c("就业", "失业", "退出劳动力市场"),
  prob = c(0.72, 0.08, 0.20),
  n = 10
)

labor_tab <- data.frame(index = seq_along(labor_status),
                        status = labor_status)
names(labor_tab) <- c("序号", "劳动力状态")
knitr::kable(labor_tab, row.names = FALSE)
序号 劳动力状态
1 就业
2 就业
3 就业
4 就业
5 就业
6 退出劳动力市场
7 就业
8 就业
9 退出劳动力市场
10 就业

R 的 sample() 已经封装了这类操作,但手写版本有助于理解类别抽样如何从均匀随机数出发。

9.4 变换法与正态随机数

正态分布随机数的生成比指数分布更复杂。一个经典方法是 Box–Muller 变换。若\(U_1,U_2\) 独立同分布于 \(U(0,1)\),则

\[ Z_1=\sqrt{-2\log U_1}\cos(2\pi U_2) \]

服从标准正态分布。这里 \(U_1\) 控制半径,\(U_2\) 控制角度,变换后得到正态形状。

set.seed(2)
n <- 10000
u1 <- runif(n)
u2 <- runif(n)
z <- sqrt(-2 * log(u1)) * cos(2 * pi * u2)

c(mean = mean(z), sd = sd(z))
#>    mean      sd 
#> 0.02117 0.99594

实际使用中,我们通常直接调用 rnorm()。学习 Box–Muller 变换的目的不是替代 R 的实现,而是理解:复杂分布的随机数往往由简单随机数经过数学变换得到。

9.5 拒绝抽样简介

有些分布既没有简单的分位数函数,也不容易通过简单变换生成随机数。这时可以使用拒绝抽样。设目标密度为 \(f(x)\),选择一个容易抽样的建议密度 \(g(x)\),并找到常数 \(M\),使得

\[ f(x)\leq M g(x) \]

对所有 \(x\) 成立。拒绝抽样的步骤如下。

拒绝抽样

  1. 从建议分布 \(g(x)\) 中生成候选值 \(X^\ast\)
  2. 生成 \(U\sim U(0,1)\)

\[ U\leq \frac{f(X^\ast)}{M g(X^\ast)}, \]

则接受 \(X^\ast\);否则拒绝并重新抽样。

直观地说,\(M g(x)\) 是罩住目标密度 \(f(x)\) 的“外壳”。在外壳下面按比例接受候选值,被接受的样本就服从目标分布。一个合适的建议分布至少要满足两点:

  1. 目标分布可能取值的地方,建议分布也必须有正密度。否则某些区域永远抽不到。
  2. 比值 \(f(x)/g(x)\) 必须有有限上界,也就是存在有限的 \(M\)。实践中常选择尾部比目标分布更厚的建议分布,以避免尾部位置的比值爆炸。

下面用自由度为 3 的 \(t\) 分布作为建议分布,生成标准正态随机数。这个例子主要用于说明算法,实际工作中当然直接使用 rnorm()

target <- function(x) dnorm(x)
proposal_density <- function(x) dt(x, df = 3)
ratio <- function(x) target(x) / proposal_density(x)
M <- optimize(ratio, interval = c(-10, 10), maximum = TRUE)$objective

M
#> [1] 1.17
1 / M
#> [1] 0.8544

这里 \(1/M\) 是理论接受率。由于 \(t_3\) 分布比标准正态分布尾部更厚,它可以作为覆盖正态密度的建议分布;但它也会生成较多极端候选值,这些候选值往往更容易被拒绝。

set.seed(12)
proposal_sample <- function(n) rt(n, df = 3)

n_accept <- 2000
x_accept <- numeric(n_accept)
k <- 0
trial <- 0

while (k < n_accept) {
  trial <- trial + 1
  x_star <- proposal_sample(1)
  alpha <- target(x_star) / (M * proposal_density(x_star))
  if (runif(1) <= alpha) {
    k <- k + 1
    x_accept[k] <- x_star
  }
}

c(sample_mean = mean(x_accept),
  sample_sd = sd(x_accept),
  acceptance_rate = n_accept / trial)
#>     sample_mean       sample_sd acceptance_rate 
#>         0.01658         1.00445         0.84998

为什么被接受的样本服从目标分布?直观解释是:候选值先按 \(g(x)\) 的形状出现,再按\(f(x)/(M g(x))\) 的比例保留。因此某个小区间附近被保留下来的概率与

\[ g(x)\frac{f(x)}{M g(x)}=\frac{f(x)}{M} \]

成正比,归一化以后就是目标密度 \(f(x)\)。同时,单个候选值被接受的总概率为

\[ \int \frac{f(x)}{M g(x)}g(x)\,dx =\frac{1}{M}. \]

拒绝抽样的效率取决于建议分布是否接近目标分布。若 \(M\) 很大,接受率就低;若目标分布维度很高,找到合适的建议分布也会变得困难。这正是后续 MCMC 方法的重要动机之一。

从 Monte Carlo 计算角度看,拒绝抽样也留下了一个自然问题:被拒绝的候选值虽然比例不合适,但并非完全没有信息。下一章的重要性抽样会沿着这个思路继续前进:不再简单丢弃候选值,而是用权重修正它们对目标期望的贡献。

9.6 离散分布与抽样

经济统计中经常需要从离散类别中抽样。例如,模拟家庭消费主项类别、企业行业类别或劳动力状态。R 中的 sample() 可以按指定概率抽样。

set.seed(3)
categories <- c("食品", "居住", "交通", "教育", "医疗")
prob <- c(0.30, 0.28, 0.16, 0.14, 0.12)

draw20 <- sample(categories, size = 20, replace = TRUE, prob = prob)
draw20_tab <- data.frame(index = seq_along(draw20),
                         category = draw20)
names(draw20_tab) <- c("序号", "消费主项")
knitr::kable(
  draw20_tab,
  row.names = FALSE,
  caption = "前 20 个模拟家庭的消费主项"
)
表9.2: 前 20 个模拟家庭的消费主项
序号 消费主项
1 食品
2 教育
3 居住
4 居住
5 交通
6 交通
7 食品
8 食品
9 居住
10 交通
11 居住
12 居住
13 居住
14 居住
15 教育
16 教育
17 食品
18 交通
19 医疗
20 食品
draw1000 <- sample(categories, size = 1000, replace = TRUE, prob = prob)
freq <- prop.table(table(draw1000))
freq_tab <- data.frame(category = names(freq),
                       proportion = as.numeric(freq))
names(freq_tab) <- c("消费主项", "模拟比例")
knitr::kable(
  freq_tab,
  row.names = FALSE,
  digits = 3,
  caption = "1000 次模拟抽样得到的类别比例"
)
表9.3: 1000 次模拟抽样得到的类别比例
消费主项 模拟比例
交通 0.159
医疗 0.117
居住 0.278
教育 0.155
食品 0.291

这里 replace = TRUE 表示每次抽样后放回,因此每个家庭的消费主项类别是独立生成的。若设置replace = FALSE,则表示不放回抽样,更接近从一个有限总体中抽取样本。

经济统计中还经常需要模拟有限总体抽样。假设一个城市有 \(N\) 个家庭,我们先生成一个有限总体,再从中不放回抽取样本。样本均值会围绕总体均值波动。

set.seed(13)
N <- 10000
population_income <- rlnorm(N, meanlog = log(8), sdlog = 0.55)
pop_mean <- mean(population_income)

n_sample <- 300
sample_id <- sample(seq_len(N), size = n_sample, replace = FALSE)
sample_mean <- mean(population_income[sample_id])

c(population_mean = pop_mean,
  sample_mean = sample_mean,
  sampling_error = sample_mean - pop_mean)
#> population_mean     sample_mean  sampling_error 
#>        9.278524        9.271229       -0.007295

这个例子区分了两个层次的随机性:有限总体本身是我们模拟出来的;从有限总体中抽取样本又引入了抽样误差。真实抽样调查中,总体不是程序生成的,但抽样误差同样存在。

9.7 一个经济统计模拟数据生成器

为了后续章节使用,我们写一个稍完整的家庭调查模拟函数。它生成地区、收入、就业状态、消费支出和抽样权重。所有数据都是模拟的,不对应任何真实调查。

simulate_households <- function(n) {
  region <- sample(c("东部", "中部", "西部"),
                   size = n, replace = TRUE,
                   prob = c(0.42, 0.33, 0.25))
  region_effect <- ifelse(region == "东部", 0.18,
                          ifelse(region == "西部", -0.16, 0))

  income <- rlnorm(n, meanlog = log(8) + region_effect, sdlog = 0.55)
  employed <- rbinom(n, size = 1, prob = plogis(-0.4 + 0.18 * income))
  consumption <- 1.2 + 0.62 * income + 0.8 * employed +
    ifelse(region == "东部", 0.4, ifelse(region == "西部", -0.25, 0)) +
    rnorm(n, sd = 1.5)
  consumption <- pmax(consumption, 0.2)
  weight <- runif(n, min = 0.7, max = 1.5)

  data.frame(
    region = region,
    income = income,
    employed = employed,
    consumption = consumption,
    weight = weight
  )
}

set.seed(4)
dat <- simulate_households(5)
knitr::kable(dat, row.names = FALSE, digits = 4)
region income employed consumption weight
中部 5.620 1 6.334 1.1537
东部 19.763 0 13.877 0.8912
东部 4.308 1 5.645 1.4024
东部 7.019 0 5.884 1.2236
西部 17.228 1 12.483 1.0859

这个函数包含了一个数据生成过程。收入服从右偏分布,不同地区有不同收入水平;就业概率随收入上升而提高;消费支出由收入、就业状态、地区和随机扰动共同决定。这样的模拟设定虽然简化,却非常适合用来学习估计、预测和不确定性评估。

模拟数据生成器应尽量写成函数,而不是把代码散落在脚本各处。这样做有三个好处:第一,便于改变样本量;第二,便于在 Monte Carlo 实验中重复调用;第三,便于在报告中清楚说明数据生成过程。

9.8 随机数生成的检查

生成随机数后,应做一些简单检查。检查不是为了证明随机数“绝对正确”,而是为了发现明显的程序错误。常见检查包括样本均值、样本方差、取值范围和类别比例。

set.seed(5)
sim_dat <- simulate_households(1000)

c(mean_income = mean(sim_dat$income),
  sd_income = sd(sim_dat$income),
  employment_rate = mean(sim_dat$employed),
  mean_consumption = mean(sim_dat$consumption))
#>      mean_income        sd_income  employment_rate mean_consumption 
#>            9.967            6.199            0.743            8.077

region_prop <- prop.table(table(sim_dat$region))
region_tab <- data.frame(region = names(region_prop),
                         proportion = as.numeric(region_prop))
names(region_tab) <- c("地区", "比例")
knitr::kable(
  region_tab,
  row.names = FALSE,
  digits = 3
)
地区 比例
东部 0.421
中部 0.320
西部 0.259

如果某个变量的均值、比例或范围明显不符合设定,就应回到数据生成函数检查概率、参数和变量变换。模拟研究的可信度首先来自透明、可检查的数据生成过程。

9.9 本章小结

随机数生成是随机模拟方法的基础。伪随机数由确定性算法产生,因此可以通过随机种子复现。均匀随机数是许多分布随机数的起点,逆变换法、变换法和拒绝抽样说明了如何从简单随机性构造复杂分布。R 中r/d/p/q 的函数命名规则为常用分布提供了统一接口。对于经济统计教学,模拟数据不仅可以帮助我们理解算法,也能在不依赖外部数据来源的情况下检验统计方法。

9.10 练习

  1. 用逆变换法生成 10000 个参数为 \(\lambda=0.5\) 的指数分布随机数,比较样本均值和理论均值。
  2. 修改 simulate_households() 中收入分布的 sdlog,观察收入右偏程度如何变化。
  3. sample() 模拟 1000 名劳动者的就业状态,设就业、失业、退出劳动力市场的概率分别为 0.7、0.1、0.2。
  4. 修改拒绝抽样例子中的建议分布自由度,观察接受率如何变化。
  5. 从一个有限总体中重复抽取 500 次样本,画出样本均值的分布,并比较其中心与总体均值。
  6. 思考为什么在模拟报告中只写“随机生成数据”是不够的。