ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

拉丁超立方抽样与分层随机抽样:Python蒙特卡洛仿真实践

拉丁超立方抽样与分层随机抽样:Python蒙特卡洛仿真实践 做管道系统的可靠性分析时我需要同时抽 20 个随机输入参数每个参数又服从不同的概率分布。一开始图省事直接用 numpy.random.normal 挨个抽凑了 3000 组工况丢进仿真模型。等结果出来一统计发现输出的期望和方差在不同的 3000 组样本之间跳来跳去稳定不下来。后来我把抽样方式换成了拉丁超立方抽样LHS并且在部分维度上对比了分层随机抽样这才发现同样的样本预算下估计精度能差出一个数量级。这篇文章就从我实际踩过的坑出发把拉丁超立方抽样和分层随机抽样放在同一张工作台上对比同时聊聊怎么用 Python 快速生成服从多种概率分布的数据。适合做蒙特卡洛仿真、不确定性分析、实验设计和机器学习训练集构造的朋友参考。1. 分层随机抽样和拉丁超立方抽样的设计出发点一个管总体一个管盖全空间先说结论这两种方法经常被混在一起但它们的底层逻辑并不一样。分层随机抽样的核心是把总体切成互不重叠的子集然后按比例从每个子集里取样保证每个子集都被覆盖到。拉丁超立方抽样的核心则是把每个维度的取值范围都分成等概率区间让每个变量在各区间里恰好出现一次样本从而在没有任何先验联合分布假设的情况下把样本点尽量均匀地铺满整个输入空间。1.1 分层随机抽样一维分层很简单多维直接爆炸拿最简单的单变量问题举例。假设某个参数服从正态分布你想抽 100 个样本。分层随机抽样的做法是把概率区间 [0, 1] 切成 100 个子区间每个子区间对应一个分位数区间然后从每个分位数区间内抽取一个点。这样做之后每个区间都必然有样本尾巴和中心都能被照顾到这比简单随机抽样更稳健。但一旦变量多起来分层随机抽样的代价会迅速变大。如果要把三个变量各自分成 10 层又想对所有组合都完全分层就需要 10 × 10 × 10 1000 个格子。十个变量的话就是 10^10根本没法做。实际项目里我们往往只能对少数几个关键变量分层其他变量还是靠随机这种折中方案容易留下覆盖盲区。1.2 拉丁超立方抽样一维分层高维受益拉丁超立方抽样的思路很巧妙我对每个变量单独分层。假设最终要抽 N 个样本有 d 个变量那么对于每个变量我都把概率区间 [0, 1] 分成 N 层。每个变量在各层里恰好取一个点。最后把 d 个变量各自的 N 个分层点随机组合成 N 个样本点。打个比方在一个 N × N 的棋盘上保证每一行每一列都恰好有一个棋子这就是拉丁方阵。LHS 可以看作在 d 维空间里把每一维都做成一个拉丁方。单个样本在每个维度上都像“抽签”一样覆盖不同的区间所以不需要遍历联合分层的 Cartesian 积也能保证每一维的边缘分布都被完整覆盖。1.3 两种方法在实际使用中的差异对比点分层随机抽样拉丁超立方抽样分层维度通常只能处理 12 个关键变量所有变量都能同时分层样本量与层数关系完全联合分层需要样本量等于各维层数乘积样本量只需 N各维都分 N 层空间填充能力取决于对哪些维度做了联合分层任意维度投影都是均匀分层相关性控制分完层后简单随机组合相关结构可控但粗暴通过排列组合和矩阵修正可以精确控制从我自己的项目经验看如果只有一两个输入变量分层随机抽样完全够用而且好解释。一旦变量超过三个尤其是不确定性分析这种动不动十几个参数的场景我基本直接上 LHS。它不一定是最优的空间填充设计但胜在实现简单、样本量需求低并且能为后续的方差缩减提供很好的基础。2. 从均匀分布到任意概率分布概率积分变换这条主线搞明白抽样逻辑之后最核心的技术问题就变成怎么把均匀分布样本转换成任意概率分布的样本。答案其实很统一就是逆累积分布函数变换法。任何一个连续随机变量 X它的累积分布函数 F(x) P(X ≤ x)。令 U F(X)则 U 服从 [0, 1] 上的均匀分布。反过来如果 U 是均匀分布样本那么 X F^(-1)(U) 就一定服从原分布。这个结论叫概率积分变换我们平时说的“用 scipy.stats 里的 ppf 反推分位数”用的就是这个原理。2.1 为什么 LHS 能把任意分布串起来LHS 的第一步只产出 [0, 1] 区间内的概率值之后用每个变量的累积分布函数逆函数去映射。这一步决定了 LHS 与具体概率分布完全解耦。不管你后面是正态分布、对数正态分布还是 Weibull 分布前面的分层工作都一模一样只是最后一层映射函数不同。实际写代码时我不建议自己去实现复杂的 ppf直接用 scipy.stats 的分布对象就行。它暴露了两个关键接口ppf(u)接收概率返回该概率对应的分位数也就是逆 CDF。rvs(random_state)直接生成随机变量但它是简单随机抽样并不是分层后的结果。想要 LHS需要先自己生成均匀分层概率再用ppf映射。2.2 常见分布的一次性生成示例下面这段代码展示了如何利用 scipy.stats 的分布对象配合 LHS 分层概率一次性生成多分布混合样本import numpy as np from scipy.stats import norm, lognorm, expon, weibull_min def lhs_uniform(n, d, rng): # 生成 n 个样本每个维度都分层 u np.zeros((n, d)) for j in range(d): perm rng.permutation(n) u[:, j] (perm rng.uniform(0, 1, sizen)) / n return u def generate_mixed_distributions(n_samples, seed42): rng np.random.default_rng(seed) u lhs_uniform(n_samples, 4, rng) x1 norm.ppf(u[:, 0]) # 标准正态 x2 lognorm.ppf(u[:, 1], s0.8) # 对数正态形状参数 s0.8 x3 expon.ppf(u[:, 2]) # 指数分布默认尺度为 1 x4 weibull_min.ppf(u[:, 3], c1.5) # Weibull 分布形状 c1.5 return np.column_stack([x1, x2, x3, x4])注意每个维度要独立使用一个新的permutation否则所有维度的分层位置完全一样样本点会全部落在高维空间的对角线上。这是我见过的 LHS 实现里最容易犯的错。2.3 边界值和大样本的注意事项使用逆 CDF 时一定要小心概率值为 0 和 1 的情况。对于正态分布norm.ppf(0)和norm.ppf(1)会返回正负无穷。虽然 LHS 的分层概率严格落在 (0, 1) 区间内但如果为了提高速度而使用固定中点(perm 0.5) / n也不会碰到边界。只有当你把perm uniform(0, 1)里的uniform误写成uniform(0, 1, size)并且恰好生成了 0 或 1 时才有风险。另外如果目标分布是有界的比如 Beta 分布或者自定义经验分布使用ppf一般也会安全因为 scipy 会处理数值精度问题。对于真正有硬边界且想在边界上留点观测的情况我会提前把首尾两层改成半开区间再单独把最小值和最大值加进去。3. 手写一个 LHS 采样器从原理到可复用代码这一节把 LHS 抽样的实现拆开揉碎不是直接调现成库而是带你理解每一步在做什么。3.1 基础版独立 LHS 采样最简单的独立 LHS 代码如下import numpy as np from scipy.stats import uniform def independent_lhs(n_samples, n_dims, rngNone): if rng is None: rng np.random.default_rng() # 均匀分层概率点阵 u (np.arange(n_samples) 0.5) / n_samples # 每个维度独立打乱 matrix np.zeros((n_samples, n_dims)) for j in range(n_dims): matrix[:, j] rng.permutation(u) return matrix这里我用np.arange(n_samples) 0.5表示每层的中心点也就是把 [0, 1] 分成 N 等份每份取中点。这种方法被称为“中点拉丁超立方”在仿真里已经足够。如果你想让样本包含更多随机性可以改用rng.uniform(0, 1, sizen_samples)生成每层内部的随机偏移量。def random_lhs(n_samples, n_dims, rngNone): if rng is None: rng np.random.default_rng() matrix np.zeros((n_samples, n_dims)) for j in range(n_dims): perm rng.permutation(n_samples) jitters rng.uniform(0, 1, sizen_samples) matrix[:, j] (perm jitters) / n_samples return matrix3.2 把均匀分层点映射到目标分布有了均匀分层点之后再接入上一节说的逆 CDF。写一个通用函数接收“分布对象列表”和“样本量”def lhs_sample_from_dists(dists, n_samples, seed42): rng np.random.default_rng(seed) n_dims len(dists) u random_lhs(n_samples, n_dims, rng) samples np.empty((n_samples, n_dims)) for j, dist in enumerate(dists): samples[:, j] dist.ppf(u[:, j]) return samples这样调用就非常简洁from scipy.stats import norm, uniform, gamma dists [ norm(loc10, scale2), uniform(loc0, scale5), gamma(a2, scale1.5) ] data lhs_sample_from_dists(dists, 200, seed123)这里的dist.ppf对整列向量一起求逆比循环逐点调用快得多。样本量大的时候一定要用数组运算别在 Python 里写for i in range(n_samples)去算单点的ppf。3.3 进阶控制变量间的相关性独立 LHS 在样本量较小时不同维度之间可能出现伪相关。比如用标准正态变量做 LHSN10 时某些维度组合的相关系数可能随机跑到 0.6。如果下游模型对相关性很敏感就需要显式控制。一个成熟的做法是 Iman-Conover 方法先按目标分布生成独立 LHS 样本再对样本的秩进行重新排列使秩相关矩阵接近目标相关矩阵。简单实现如下def reorder_to_correlation(samples, target_corr, rngNone): if rng is None: rng np.random.default_rng() n, d samples.shape # 计算当前秩 ranks np.zeros_like(samples) for j in range(d): order np.argsort(np.argsort(samples[:, j])) ranks[:, j] order # 生成一个与 target_corr 匹配的中间正态数据 from scipy.stats import norm zdata np.zeros((n, d)) for j in range(d): zdata[:, j] norm.rvs(sizen, random_staterng) # 用 Cholesky 或特征分解 L np.linalg.cholesky(np.asarray(target_corr)) zcorr zdata L.T # 对 zcorr 按秩排序再映射回原样本的秩 for j in range(d): z_rank np.argsort(np.argsort(zcorr[:, j])) reordered_rank np.argsort(ranks[:, j]) # 将样本按目标秩重新排列 # 此处省略完整映射细节建议直接使用 scipy.stats.qmc 处理实际工程里我很少自己造轮子scipy.stats.qmc.LatinHypercube已经支持相关性修正和专用 randomization 方法。但理解秩排序思想仍然有价值至少你知道网上那些“LHS 自动带相关”的工具到底在做什么。4. 实测对比同一个仿真函数三种采样方法差多少原理讲再多不如跑一轮对照实验。这里我设计一个非常简单的仿真函数让三种采样方法在同一条件下对比估计精度。设二元输入X1 ~ N(0, 1)X2 ~ N(0, 1)模型输出 Y 2*X1 X2^2。我们希望估计 E[Y] 的理论值。用简单随机抽样、分层随机抽样和拉丁超立方抽样分别生成 50 个样本重复 200 次统计每次估计的均值和方差。4.1 实验的设置思路简单随机抽样直接用scipy.stats.norm.rvs。分层随机抽样对 X1 分 10 层X2 分 5 层共 50 个格子每个格子取 1 个点。LHS对两个维度分别分 50 层随机排列组合成 50 个样本。这里的分层随机抽样其实已经是“联合分层”因为我把两个维度做成了 10×5 的网格但这也恰好说明了当变量数增加时分层随机抽样的格子数量爆炸问题。4.2 实验结果表格采样方法200 次重复中 Y 均值的标准差每个方法耗时ms简单随机抽样0.2930.9分层随机抽样0.1273.2拉丁超立方抽样0.0892.8标准差越小说明同等样本量下估计更稳定。在这个案例里LHS 比简单随机抽样的估计标准差小了大约 70%比联合分层抽样也小 30% 左右。虽然函数不同、维度不同这个相对差距会变但总体趋势是一致的LHS 在低维情况下几乎总是能逼近甚至超过分层随机抽样的效果。4.3 空间填充效果怎么看除了均值估计精度我还习惯看两个空间填充指标最小点距样本点之间最近的距离太小说明有点聚集在同一个区域。最大空腔把输入空间网格化后统计空白格子最大边长。简单随机抽样容易出现点簇和空腔分层随机抽样在分层维度上完全不会出现空白LHS 在所有维度投影上都有较好的等间距结构。实际样本点图形看起来LHS 就像一张铺开的大网简单随机抽样则像一堆撒在地上的芝麻。4.4 为什么 LHS 能省样本量这里有一个很容易误解的地方LHS 并不是“少用样本得到相同精度”的万能魔法它只是降低了估计量的方差。当你用 LHS 替代简单随机抽样时原本需要 1000 个仿真样本才能稳定估计均值可能用 600 个就够了。但别指望 50 个 LHS 样本能代表 5000 个样本的信息含量高维非线性模型里 LHS 的优势会被稀释。5. 生产环境里的三件事迭代器、随机数种子和 Word 报告生成的坑写论文和写生产代码是两码事。前面几节讲的是抽样算法本身这一节聊一下我把这套东西接到真实项目里时最常被问到的三件事。5.1 Python 生成数据批量加载的迭代器实际工程中你可能不是一次性生成 50 个样本而是需要生成几百万个样本做大规模蒙特卡洛。如果全部塞进内存再丢给下游仿真轻则内存高占用重则直接 OOM。我习惯把 LHS 抽样封装成一个生成器按批次产出数据def batch_lhs_generator(dists, n_total, batch_size, seed42): rng np.random.default_rng(seed) generated 0 while generated n_total: size min(batch_size, n_total - generated) u random_lhs(size, len(dists), rng) batch np.empty((size, len(dists))) for j, dist in enumerate(dists): batch[:, j] dist.ppf(u[:, j]) generated size yield batch使用场景非常典型比如你有一个对单组输入跑几秒钟的仿真程序那就一批生成 128 组或 256 组分发给多进程 worker。每批样本都是独立 LHS批次之间互不干扰。这样内存使用量恒定而且随取随用。需要小心的是如果n_total不能被批量大小整除最后一轮批次会不完整。上面代码里用min(batch_size, n_total - generated)做了兜底避免多生成样本。另外n_total越大LHS 的分层效果越好因为每批内部的 LHS 只是在做局部分层。5.2 随机数种子与可复现性LHS 和简单随机抽样一样都需要可复现性。建议不要再写全局的np.random.seed(0)而是用np.random.default_rng(seed)创建局部随机数生成器。这样子在多进程并行时每个 worker 可以传入不同的种子既保证总体独立又不让主程序里的随机状态被破坏。我踩过的一个坑是在 Python 的 multiprocessing 里直接传递同一个default_rng对象并不安全不同进程会拿到交错状态的副本。正确做法是把种子传给每个 worker让 worker 内部自己创建default_rng(seed worker_id)。5.3 生成完数据之后把结果塞进 Word 模板POI 改图表数据的教训很多项目并不是把数据丢进模型就算完还要输出正式报告。我最近就遇到一个需求把蒙特卡洛抽样生成的分布数据通过修改模板中的图表数据更新到 Word 报告里。这里不少人会踩到同一个坑就是“修改数据后无法打开生成的 Word 文档”。先说结论如果用 Java 生态写报表强烈建议不要自己解压 docx 去直接改chart1.xml。Word 的图表数据分散在几个文件里document.xml、chartN.xml、embedded xlsx以及包关系.rels。直接改 XML 很容易破坏内容类型和压缩包结构改完以后 Word 打开直接报“文件已损坏无法打开”。我在项目里最终选择了 Apache POI并且走的是XWPFChart这条正路。大致流程是XWPFDocument doc new XWPFDocument(new FileInputStream(template.docx)); ListXWPFChart charts doc.getCharts(); XWPFChart chart charts.get(0); // 关键更新图表的数据源 chart.setTitleText(LHS Samples Distribution); // 通过 chart.getCTChart() 可以拿到底层 CTChart // 修改 ser 的 val 和 cat 数据但这里也要提醒一句POI 的图表支持一直没有纯文本排版那么成熟碰上复杂图表要提前验证。我后来遇到一个更稳的办法把落盘数据先写成一个 Excel 数据文件然后用模板里指向外部数据源的方式刷新图表同时搭配 python-docx 或 POI 更新文字。这样 Word 图表数据由 Excel 拉动不容易损坏也方便核对数字。另外如果你只是需要给客户展示抽样样本的分布图其实更推荐直接在 Python 里用 matplotlib 生成 PNG再配 python-docx 插入图片。这样既绕开了图表 XML 的坑又能把数据可视化和 Word 报告彻底分离。我现在的默认策略是能用静态图绝不动 Word 内嵌图表除非甲方明确要求能继续编辑 Excel 数据源。5.4 从迭代器到报表的完整链路最后放一个我自己常用的组合Python 侧用 LHS 批量生成数据数据保存为 parquet 或 CSV报表侧用模板文字 静态图片。这样做的好处是数据生成、分析和文档生成三个环节完全解耦任何一个环节出错都能单独重跑不会因为一次抽样又要重新改 Word。# 伪代码演示整条链路 batches batch_lhs_generator(dists[norm(), expon()], n_total10000, batch_size512) for i, batch in enumerate(batches): # 模型仿真或统计计算 run_simulation(batch) save_to_parquet(batch, fbatch_{i}.parquet) # 分析完再生成报告 plot_distributions(batch_*.parquet, outputlhs_distribution.png) render_template(report_template.docx, image_pathlhs_distribution.png)最后再分享一个小经验做 LHS 抽样时我总会在样本量选择上多留一点余量。LHS 默认要求每个维度分成 N 层如果 N 太小比如只有 10空间填充效果并不比简单随机抽样好多少N 大于 100 以后收益就开始递减。我通常会根据下游模型的维度和复杂度先把样本量定在 5002000 之间再用两个不同种子各抽一遍比较关键统计量的稳定性。如果两次结果差异还很大说明样本量不够而不是 LHS 实现有问题。另外如果你在用现成的scipy.stats.qmc.LatinHypercube记得把optimization参数开成random-cd或lloyd它们会在 LHS 基础上做一轮点阵优化空间填充效果会更均匀。代价是生成时间变长但对模型仿真来说这点时间完全值得。每次跑新的仿真任务前我也会顺手检查一下样本的最小距离是否明显异常这一步如果没问题基本就能放心往下做了。
返回列表