2. 簡単なモンテカルロプログラム
Ray Tracing: The Rest of Your Life (v3.2.3): 2 A Simple Monte Carlo Program / 3.2 簡単なモンテカルロプログラム
モンテカルロ法(MC 法)の本質的な特徴は「単純なプログラムで真の値を統計的に近似でき,実行時間が長いほど精度が上がる」という点です。本章ではその最小例として
扱うのは 3 つのトピックです。
- 乱数サンプルを使ったモンテカルロ推定量の構成
- 収束速度(
)の実測確認 - 層化サンプリングによる分散低減
π の推定
となります。確率変数の期待値を標本平均で代替するという構造がそのまま現れており,これがモンテカルロ積分の最も単純な例です。
サンプル数
Rust 実装
この章は ray tracing の幾何学とは独立した確率論の準備であるため,r302-monte-carlo クレートは common に依存しないスタンドアローン実装としています。
固定シードによる再現性を確保するため,Xorshift64 乱数生成器をローカルに実装します。common::utils にも同等の実装がありますが,本章を読む段階では common の内部実装には触れていないため,ここで全体を見せます。
struct XorShift64 {
state: u64,
}
impl XorShift64 {
fn new(seed: u64) -> Self {
let state = if seed == 0 { 0x9e3779b97f4a7c15 } else { seed };
Self { state }
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
fn range_f64(&mut self, min: f64, max: f64) -> f64 {
min + (max - min) * self.next_f64()
}
}推定関数は
fn estimate_pi(samples: usize, rng: &mut XorShift64) -> f64 {
let mut inside_circle = 0usize;
for _ in 0..samples {
let x = rng.range_f64(-1.0, 1.0);
let y = rng.range_f64(-1.0, 1.0);
if x * x + y * y < 1.0 {
inside_circle += 1;
}
}
4.0 * inside_circle as f64 / samples as f64
}generate_report は 2 段階の出力を構成します。まず N=1000 の単発推定で 1 回の試行がどの程度の精度になるかを示し,続いて
pub fn generate_report() -> String {
let mut output = String::new();
output.push_str("Monte Carlo Estimation of PI\n");
output.push_str("============================\n\n");
let mut first_run_rng = XorShift64::new(0x30200001);
let first_estimate = estimate_pi(1000, &mut first_run_rng);
output.push_str(&format!(
"Single run (N=1000): {:.12} (error={:.12})\n\n",
first_estimate,
(first_estimate - PI).abs()
));
output.push_str("Convergence checkpoints:\n");
output.push_str("samples,estimate,abs_error\n");
let checkpoints = [10usize, 100, 1_000, 10_000, 100_000, 1_000_000];
let mut checkpoint_index = 0usize;
let mut inside_circle = 0usize;
let mut rng = XorShift64::new(0x30200002);
for n in 1..=checkpoints[checkpoints.len() - 1] {
let x = rng.range_f64(-1.0, 1.0);
let y = rng.range_f64(-1.0, 1.0);
if x * x + y * y < 1.0 {
inside_circle += 1;
}
if n == checkpoints[checkpoint_index] {
let estimate = 4.0 * inside_circle as f64 / n as f64;
output.push_str(&format!(
"{n},{estimate:.12},{error:.12}\n",
n = n,
estimate = estimate,
error = (estimate - PI).abs()
));
checkpoint_index += 1;
if checkpoint_index == checkpoints.len() {
break;
}
}
}
// ...stratified sampling comparison (next section)...
output
}層化サンプリング
収束速度
すべてのセルに必ず 1 点が入るため,サンプルの被覆が均一になり分散が下がります。非層化では偶然に点が集中する場所や空白が生まれますが,層化ではそれが起きません。
generate_report の最後のブロックで,同じ総サンプル数(
let sqrt_n: usize = 1000;
let total = sqrt_n * sqrt_n;
let mut inside_regular = 0usize;
let mut inside_stratified = 0usize;
let mut strat_rng = XorShift64::new(0x30200003);
for i in 0..sqrt_n {
for j in 0..sqrt_n {
// Regular: unconstrained uniform sample in [-1, 1)^2
let xr = strat_rng.range_f64(-1.0, 1.0);
let yr = strat_rng.range_f64(-1.0, 1.0);
if xr * xr + yr * yr < 1.0 {
inside_regular += 1;
}
// Stratified: one jittered sample per (i, j) cell
let xs = 2.0 * ((i as f64 + strat_rng.next_f64()) / sqrt_n as f64) - 1.0;
let ys = 2.0 * ((j as f64 + strat_rng.next_f64()) / sqrt_n as f64) - 1.0;
if xs * xs + ys * ys < 1.0 {
inside_stratified += 1;
}
}
}
let est_regular = 4.0 * inside_regular as f64 / total as f64;
let est_stratified = 4.0 * inside_stratified as f64 / total as f64;
let err_regular = (est_regular - PI).abs();
let err_stratified = (est_stratified - PI).abs();
output.push_str(&format!("\nStratified sampling comparison (sqrt_N={sqrt_n}):\n"));
output.push_str(&format!(
"regular estimate={est_regular:.12} error={err_regular:.12}\n"
));
output.push_str(&format!(
"stratified estimate={est_stratified:.12} error={err_stratified:.12}\n"
));同じ
C++ と Rust の違い
C++ 原典では rand() でグローバルな乱数を生成します。
inline double random_double() {
return rand() / (RAND_MAX + 1.0);
}Rust には rand() のようなグローバル乱数状態がありません。本章では common に依存しないため Xorshift64 をローカルに実装し,rng を引数として明示的に渡しています。関数の副作用が引数と戻り値だけに限定されるこのスタイルは,再現性の確保にも有利です。
出力フォーマットについては,C++ が std::cout << std::fixed << std::setprecision(12) を使うのに対して,Rust は format!("{:.12}") で書式を文字列内に直接指定します。
r302-monte-carlo/src/lib.rs
const PI: f64 = std::f64::consts::PI;
struct XorShift64 {
state: u64,
}
impl XorShift64 {
fn new(seed: u64) -> Self {
let state = if seed == 0 { 0x9e3779b97f4a7c15 } else { seed };
Self { state }
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
fn range_f64(&mut self, min: f64, max: f64) -> f64 {
min + (max - min) * self.next_f64()
}
}
fn estimate_pi(samples: usize, rng: &mut XorShift64) -> f64 {
let mut inside_circle = 0usize;
for _ in 0..samples {
let x = rng.range_f64(-1.0, 1.0);
let y = rng.range_f64(-1.0, 1.0);
if x * x + y * y < 1.0 {
inside_circle += 1;
}
}
4.0 * inside_circle as f64 / samples as f64
}
pub fn generate_report() -> String {
let mut output = String::new();
output.push_str("Monte Carlo Estimation of PI\n");
output.push_str("============================\n\n");
// Single run
let mut first_run_rng = XorShift64::new(0x30200001);
let first_estimate = estimate_pi(1000, &mut first_run_rng);
output.push_str(&format!(
"Single run (N=1000): {:.12} (error={:.12})\n\n",
first_estimate,
(first_estimate - PI).abs()
));
// Convergence checkpoints
output.push_str("Convergence checkpoints:\n");
output.push_str("samples,estimate,abs_error\n");
let checkpoints = [10usize, 100, 1_000, 10_000, 100_000, 1_000_000];
let mut checkpoint_index = 0usize;
let mut inside_circle = 0usize;
let mut rng = XorShift64::new(0x30200002);
for n in 1..=checkpoints[checkpoints.len() - 1] {
let x = rng.range_f64(-1.0, 1.0);
let y = rng.range_f64(-1.0, 1.0);
if x * x + y * y < 1.0 {
inside_circle += 1;
}
if n == checkpoints[checkpoint_index] {
let estimate = 4.0 * inside_circle as f64 / n as f64;
output.push_str(&format!(
"{n},{estimate:.12},{error:.12}\n",
n = n,
estimate = estimate,
error = (estimate - PI).abs()
));
checkpoint_index += 1;
if checkpoint_index == checkpoints.len() {
break;
}
}
}
// Stratified sampling comparison
let sqrt_n: usize = 1000;
let total = sqrt_n * sqrt_n;
let mut inside_regular = 0usize;
let mut inside_stratified = 0usize;
let mut strat_rng = XorShift64::new(0x30200003);
for i in 0..sqrt_n {
for j in 0..sqrt_n {
// Regular: unconstrained uniform sample in [-1, 1)^2
let xr = strat_rng.range_f64(-1.0, 1.0);
let yr = strat_rng.range_f64(-1.0, 1.0);
if xr * xr + yr * yr < 1.0 {
inside_regular += 1;
}
// Stratified: one jittered sample per (i, j) cell
let xs = 2.0 * ((i as f64 + strat_rng.next_f64()) / sqrt_n as f64) - 1.0;
let ys = 2.0 * ((j as f64 + strat_rng.next_f64()) / sqrt_n as f64) - 1.0;
if xs * xs + ys * ys < 1.0 {
inside_stratified += 1;
}
}
}
let est_regular = 4.0 * inside_regular as f64 / total as f64;
let est_stratified = 4.0 * inside_stratified as f64 / total as f64;
let err_regular = (est_regular - PI).abs();
let err_stratified = (est_stratified - PI).abs();
output.push_str(&format!("\nStratified sampling comparison (sqrt_N={sqrt_n}):\n"));
output.push_str(&format!(
"regular estimate={est_regular:.12} error={err_regular:.12}\n"
));
output.push_str(&format!(
"stratified estimate={est_stratified:.12} error={err_stratified:.12}\n"
));
output
}ブラウザ上での実行結果
収束チェックポイントでは abs_error が単調には減らず,乱数の偏りで前後しながらも全体として小さくなっていく様子が確認できます。層化サンプリング比較では,同じ
まとめ
- モンテカルロ推定量を
推定の最小例で実装した。収束速度は であり, を 倍にして精度は 倍にしかならない。 - 収束チェックポイントにより,サンプル数と精度の関係を実測した。
- 層化サンプリングは同じサンプル数でより均一な被覆を実現し,2 次元の場合に分散を低減できる。次元が上がるほど恩恵は薄れる(次元の呪い)。
次章では,この