Stop scattering points at random. Place them deterministically to fill space evenly, and watch integration error fall from 1/√N toward 1/N.
You are integrating a function over the unit square with 64 sample points. You scatter them uniformly at random and watch what happens: three points land almost on top of each other near one corner while a whole quadrant gets nothing at all. You did not ask for that. It is the inevitable lumpiness of independence.
In Monte Carlo you learned that the error of a random estimate shrinks like 1/√N — slowly, and only on average. This chapter asks a sharper question: where does that error come from? The answer is the clumping. Independence means each point ignores where the others landed, so by pure chance some regions get crowded and others stay empty.
Here is the radical idea behind quasi-Monte Carlo (QMC): you were never forced to use random points. If the clumps and gaps are the error source, then engineer them away. Place 64 points that genuinely fill the square — no clumps, no holes — using a deterministic low-discrepancy sequence, and the same 64 points integrate far more accurately.
To make "fills space evenly" precise we need a number. Take four points on the line [0,1] instead of the square, so we can compute by hand. Consider a clumped set C = {0.10, 0.15, 0.20, 0.70}: three points bunched low, then a big gap up to 0.70. Compare it to an evenly-spread midpoint set E = {1/8, 3/8, 5/8, 7/8}.
The star discrepancy D*N measures the worst gap between the fraction of points below a threshold t and t itself: D*N = maxt | (#points ≤ t)/N − t |. It is a single scalar that says how non-uniform a point set is, and — we will prove in Chapter 5 — it is exactly what becomes integration error.
Let us compute D* for the clumped set by hand. We scan the threshold t upward and watch the empirical fraction (#points ≤ t)/4 chase the diagonal line y = t. The mismatch is biggest at t = 0.20: three of the four points are at or below 0.20, so the fraction is 3/4 = 0.75, while t is only 0.20. The gap is 0.75 − 0.20 = 0.55. No other threshold does worse, so D*(C) = 0.55.
Now the even set. Its fraction-below step function climbs by 1/4 at each of 0.125, 0.375, 0.625, 0.875, hugging the diagonal as tightly as four points can. The worst gap sits right at a jump and equals half the spacing, 1/(2N) = 1/8 = 0.125. So D*(E) = 0.125. The clumped set is 0.55 / 0.125 = 4.4× more non-uniform — and that excess non-uniformity is wasted samples.
Left: N independent uniform random points. Right: the same N from a low-discrepancy (Halton) sequence. Both estimate the area of the quarter-disk (true value π/4 ≈ 0.7854). Watch the random side leave cells empty while the QMC side puts roughly one point in each.
From scratch — star discrepancy in 1-D:
python import numpy as np def star_discrepancy_1d(pts): pts = np.sort(pts); N = len(pts) # worst gap is achieved just at/after a sample point ts = np.concatenate([pts, [1.0]]) below = np.searchsorted(pts, ts, side='right') / N return np.max(np.abs(below - ts)) star_discrepancy_1d(np.array([0.1,0.15,0.2,0.7])) # 0.55 star_discrepancy_1d(np.array([1,3,5,7])/8) # 0.125
The library one-liner:
python from scipy.stats import qmc # discrepancy of a point set (column vector in 1-D) qmc.discrepancy(pts.reshape(-1, 1))
qmc.discrepancy returns an L2-style discrepancy — a smooth cousin of the star discrepancy that has a closed form, so it needs no threshold scan. Same message: smaller = more uniform.
Two colleagues each hand you 100 points in the unit square and both swear theirs are "well spread." You need a single number that says which is genuinely more uniform — not a vibe, a measurement. Star discrepancy is that number.
In Chapter 0 we computed it in one dimension as a threshold scan. The full definition generalizes that to any dimension. Anchor a box at the origin (0,0) and slide its far corner to (t1, t2). The box has volume t1·t2, and some fraction of your points fall inside it. If the points were perfectly uniform, that fraction would equal the volume. The worst mismatch over all such boxes is the star discrepancy.
The word "sup" (supremum) means we hunt for the single worst test box. That is what makes star discrepancy a worst-case uniformity measure rather than an average one. A point set can look fine on average and still have one terrible box that drives its discrepancy up — and as we will see in Chapter 5, it is exactly this worst box that bounds your integration error.
Let us re-derive the two 1-D numbers from Chapter 0 as a careful warm-up, because the same scan is the heart of the canvas below. For the even midpoint set E = {1/8, 3/8, 5/8, 7/8}, the empirical step function jumps by 1/4 at each midpoint. The diagonal line y = t passes through the middle of each step, so the largest vertical gap occurs right at a jump and equals half the step height times the spacing — concretely 1/(2N) = 1/8 = 0.125.
For the clumped set C = {0.10, 0.15, 0.20, 0.70}, just below t = 0.20 we already have 3 of 4 points inside [0, t], giving a fraction of 0.75 against an interval length of 0.20 — a mismatch of 0.75 − 0.20 = 0.55. So D*(E) = 0.125 and D*(C) = 0.55. The evenly-spread set is 0.55/0.125 = 4.4× more uniform by this exact measure.
One subtlety worth internalizing: in 1-D the worst box is always achieved right at (or just after) a sample point, because the empirical fraction is a step function that only changes there. That is why our from-scratch code only needs to test the sample points, not a million thresholds. In 2-D and higher you sweep a grid of corners, which is what the auto-search button does.
Drag the box's far corner to find the worst mismatch between (fraction of points inside) and (box area). Toggle between random and Halton points to feel how much smaller the achievable worst-case mismatch is for a low-discrepancy set. Auto-search sweeps the corner over a grid and reports D*.
From scratch — brute-force 2-D star discrepancy:
python def star_disc_2d(P, grid=200): N = len(P) ts = np.linspace(0, 1, grid+1)[1:] best = 0.0 for t1 in ts: for t2 in ts: inside = np.mean((P[:,0] <= t1) & (P[:,1] <= t2)) best = max(best, abs(inside - t1*t2)) return best
The library one-liner:
python from scipy.stats import qmc # closed-form L2-star discrepancy, no grid search qmc.discrepancy(P, method='L2-star')
The brute-force scan is O(gridd) and only approximates the true sup. The closed-form L2-star discrepancy is exact and fast, which is why every QMC library reports that variant instead.
You want to drop points into [0,1] one at a time so that after any number of them, they are spread out — not just at the end. Equally-spaced points 1/N, 2/N, … cannot do this: they require you to fix N in advance and start over if you want more.
The trick is almost childish. Take the integer n, write it in a base b, then read the digits backwards behind a radix point. That single mirror operation is the radical inverse φb(n), and it produces a sequence that keeps bisecting every gap it has left.
Formally, write n = a0 + a1b + a2b2 + … in base b. The radical inverse reflects those digits across the point: φb(n) = a0/b + a1/b2 + a2/b3 + …. The least-significant digit a0 (which alternates fastest as n increments) becomes the most-significant fractional digit, so consecutive n land on opposite ends of the interval.
Let us compute several points by hand in base 2. Take n = 6. In binary 6 = 110, so the digits are a0 = 0, a1 = 1, a2 = 1. Reflect them: φ2(6) = 0/2 + 1/4 + 1/8 = 0 + 0.25 + 0.125 = 0.375 = 3/8.
Now n = 5. In binary 5 = 101, so a0 = 1, a1 = 0, a2 = 1. Reflect: φ2(5) = 1/2 + 0/4 + 1/8 = 0.5 + 0 + 0.125 = 0.625 = 5/8. Notice that 5 and 6 land on opposite halves of [0,1] — 0.625 is in the upper half, 0.375 in the lower — even though the integers differ by only 1. That is the digit-reversal at work: it scatters neighbors.
The trick is not tied to base 2. In base 3, take n = 7. Write 7 in base 3: 7 = 2·3 + 1, so the digits are a0 = 1, a1 = 2 (i.e. 7 = "21" in base 3). Reflect: φ3(7) = 1/3 + 2/9 = 3/9 + 2/9 = 5/9 = 0.5556. We will use base-3 radical inverses for the second coordinate of the Halton sequence next chapter.
Why does this fill gaps? Watch the base-2 sequence in order: φ2(1) = 0.5, then 0.25, then 0.75, then 0.125, 0.625, 0.375, 0.875, …. The first point halves the interval. The next two land in the centers of the two halves. The next four land in the centers of the four quarters. Each new point falls in the largest current gap and bisects it — equidistribution that refines automatically, with no N fixed in advance.
Press Next point to advance n by one. Watch its base-b digits reflect across the radix point into φb(n), and see the new point drop into the largest remaining gap. The live readout tracks the star discrepancy of the points so far — it falls smoothly, unlike a random sequence's noisy decline.
From scratch — the radical inverse:
python def radical_inverse(n, base): f, r = 1.0, 0.0 while n > 0: f /= base r += f * (n % base) n //= base return r # van der Corput sequence, base 2 vdc = [radical_inverse(n, 2) for n in range(1, 9)] # -> 0.5, 0.25, 0.75, 0.125, 0.625, 0.375, 0.875, ...
The library one-liner:
python from scipy.stats import qmc # Sobol's 1-D coords ARE the base-2 # van der Corput points qmc.Sobol(d=1, scramble=False).random(8)
A neat fact we will use later: the first dimension of an unscrambled Sobol sequence is precisely the base-2 van der Corput sequence (in a Gray-code order). The radical inverse is the atom everything else is built from.
You are placing sample points for a renderer's pixel jitter and depth-of-field — a 2-D problem — and you want them to tile the square smoothly, not in stripes. You have the van der Corput sequence from Chapter 2, but it only fills one dimension. What do you use for the second coordinate?
The naive answer fails badly. If you used base 2 for both coordinates, then point n would be (φ2(n), φ2(n)) — identical coordinates — and every point would sit on the diagonal line x = y. You would cover a line, not a square.
The Halton sequence fixes this by giving each axis its own prime base. Coordinate 1 uses base 2, coordinate 2 uses base 3, coordinate 3 uses base 5, and so on through the primes. Distinct (coprime) bases keep the per-dimension radical inverses decorrelated, so the joint point set fills the whole hypercube instead of marching in lock-step.
Let us compute Halton point n = 4 with bases (2, 3) by hand. Coordinate 1 is φ2(4): 4 = 100 in binary, so digits a0 = 0, a1 = 0, a2 = 1; reflect to get 0/2 + 0/4 + 1/8 = 0.125. Coordinate 2 is φ3(4): 4 = "11" in base 3, so a0 = 1, a1 = 1; reflect to get 1/3 + 1/9 = 3/9 + 1/9 = 4/9 = 0.4444. So Halton point #4 is (0.125, 0.4444).
That decorrelation is not just a hope — you can measure it. For the first 16 Halton points with bases (2,3), the Pearson correlation between the two coordinate columns is about −0.19: essentially uncorrelated, nicely spread. The points fill the square.
But Halton has a famous weakness. With large prime bases, the radical inverse cycles slowly, so for the first many points two large-base coordinates move almost in step. With bases (17, 19), the correlation of the first 16 points is +1.00 — the points are essentially collinear, a thin diagonal streak before they slowly spread. This is Halton degradation, and it is why Halton is rarely used beyond about 6 dimensions without scrambling.
The fix is either to scramble the digits (which breaks the lock-step) or to switch to the Sobol sequence (next chapter), which works entirely in base 2 and stays well-distributed in much higher dimensions. The canvas lets you switch bases and literally watch the diagonal streak appear with (17, 19).
Pick a base for each axis. With (2,3) the square fills evenly. Switch to (17,19) and watch the first points fall on a near-straight diagonal streak — degradation made visible. The readout shows the live coordinate correlation: near 0 for good bases, near +1 for the bad ones.
From scratch — the Halton sequence:
python def halton(N, dims_bases): def phi(n, b): f, r = 1.0, 0.0 while n > 0: f /= b; r += f*(n % b); n //= b return r return np.array([[phi(n, b) for b in dims_bases] for n in range(1, N+1)]) P = halton(64, [2, 3]) # 64 points in [0,1]^2
The library one-liner:
python from scipy.stats import qmc # scrambled Halton avoids large-prime degradation qmc.Halton(d=2, scramble=True).random(64)
Scrambling randomly permutes the digits in each base. It destroys the deterministic lock-step that causes degradation while preserving the low-discrepancy structure — a preview of the randomized QMC idea in Chapter 8.
You need 4096 sample points in 20 dimensions for a path tracer, and Halton's high-prime axes have gone stripey. Sobol is the industry answer. Instead of mixing primes, it works entirely in base 2 with carefully chosen direction numbers, so that every dyadic box of a fixed size contains exactly its fair share of points.
That "fair share" property has a precise name: a (t, m, d)-net. Split the d-dimensional cube into elementary dyadic intervals — boxes whose side lengths are powers of 1/2 and whose volume is 2t−m. A (t, m, d)-net guarantees that every such box contains exactly 2t of the 2m points. The smaller the t, the stronger the equidistribution; the best nets have t = 0.
This is the structural upgrade over Halton. Halton hopes that coprime bases keep coordinates decorrelated, and that hope fails for large primes. Sobol guarantees balance in every elementary box across all dimensions, by construction, with no primes to run out of.
Let us verify the net property by hand on scipy's first four 2-D Sobol points. The unscrambled 1-D Sobol points (the same base-2 van der Corput values, in Gray-code order) are 0, 0.5, 0.75, 0.25, 0.375, 0.875, 0.625, 0.125 — notice how 0.75 and 0.25 fill the two halves the instant they appear. The first four 2-D points are (0,0), (0.5,0.5), (0.75,0.25), (0.25,0.75).
Now bin those four points into the 2×2 dyadic grid (each cell has area 1/4). Point (0,0) lands in cell (0,0); (0.5,0.5) lands in cell (1,1); (0.75,0.25) lands in cell (1,0); (0.25,0.75) lands in cell (0,1). Each of the four cells gets exactly one point — the net property holds exactly. That is t = 0: perfect stratification of four points across four boxes.
The defining trick is that this balance holds for every elementary grid of the same total volume at once — the 1×4, the 2×2, and the 4×1 partitions all get one point per box. Random points cannot promise that: a random quadruple typically leaves a box empty and crowds another with 2 or 3. The canvas lets you flip between the three grids and confirm the green check on each.
Step through powers-of-two point counts. Switch the elementary-grid shape (2×2, 1×4, 4×1) — all have boxes of area 1/4 — and confirm Sobol puts exactly one point in each. Toggle the random comparison to watch it violate the balance (0, 2, 3 per box).
From scratch — Sobol via direction numbers (sketch):
python # base 2, Gray-code recurrence def sobol_1d(N, direction): # direction = v_i ints x, pts, BITS = 0, [], 30 for i in range(1, N+1): c = (i ^ (i-1)).bit_length() - 1 # low zero bit x ^= direction[c] pts.append(x / (1 << BITS)) return pts # real direction numbers: primitive polynomials over GF(2)
The library one-liner:
python from scipy.stats import qmc # 4096 scrambled Sobol points in 20-D qmc.Sobol(d=20, scramble=True).random_base2(m=12)
Always request Sobol counts as a power of two (random_base2(m) gives 2m points). Stopping mid-block breaks the net balance — a common and silent QMC bug.
You finally have a guarantee, not just a hope. For any reasonably smooth integrand, the error of your QMC estimate is at most V(f) times D*N: the function's "wiggle budget" multiplied by your points' non-uniformity. Halve the discrepancy and you halve the worst-case error — deterministically, no probability hand-waving.
This is the Koksma-Hlawka inequality, the theorem that justifies the whole field. It factors the error cleanly into two independent pieces: a property of the function (its total variation V), and a property of the points (their star discrepancy D*).
The total variation V(f) is the integrand's "wiggle budget" — in 1-D, the sum of all its up-and-down moves, Σ|f(xi+1) − f(xi)| in the limit. (In higher dimensions it is the Hardy-Krause variation, a sum of variations of all the mixed partials.) A flat function has V = 0; a wildly oscillating one has large V.
The crucial consequence: once you have fixed the integrand, V(f) is a constant you cannot change. The only lever left is D*N. That is why QMC research is obsessed with discrepancy — it is the entire controllable factor in the worst-case error. Build lower-discrepancy points and you directly tighten the bound.
Let us see the bound work end to end. Integrate f(x) = x over [0,1] (true value 1/2) using the four van der Corput-style midpoints {1/8, 3/8, 5/8, 7/8}. This f is monotone increasing, so its total variation is simply V(f) = f(1) − f(0) = 1 − 0 = 1. The point set's star discrepancy we computed in Chapter 1: D*N = 1/(2N) = 1/8 = 0.125.
The QMC estimate is the plain average of the function at those points: (1/8 + 3/8 + 5/8 + 7/8)/4 = (16/8)/4 = 2/4 = 0.5. That equals the true integral exactly, so the actual error here is 0. The Koksma-Hlawka bound is V(f) · D*N = 1 × 0.125 = 0.125. The bound (0.125) correctly upper-bounds the true error (0) — and it is loose, as promised: the real error sat far below the worst case.
Left slider sets the integrand's wiggle (more wiggle = larger V). Right slider morphs the points from random-clumped to van der Corput (lower D*). The bound bar V×D* and the actual error bar both respond — and the actual error always sits under the bound.
From scratch — the KH bound in 1-D:
python def total_variation_1d(f, grid=10000): xs = np.linspace(0, 1, grid) return np.sum(np.abs(np.diff(f(xs)))) # Hardy-Krause TV def kh_bound(f, pts): V = total_variation_1d(f) Dstar = star_discrepancy_1d(np.sort(pts)) return V * Dstar # |error| <= V * D* kh_bound(lambda x: x, np.array([1,3,5,7])/8) # 1 * 0.125 = 0.125
The library "one-liner":
python from scipy.stats import qmc # no single call gives V(f); pair the # discrepancy with your own TV estimate qmc.discrepancy(pts.reshape(-1,1)) # the D* half
There is no library call for V(f) because it depends on your specific integrand. The split is the point: the library owns the points, you own the function.
Plain Monte Carlo is brutally honest about cost: to add one decimal digit of accuracy you need 100× more samples, because error falls only as 1/√N. QMC changes the deal. With error decaying nearly as 1/N, one more digit costs only ~10× more points. Over a long simulation that is the difference between an hour and a week.
You already proved the 1/√N law in Monte Carlo — this chapter does not re-derive it, it contrasts against it. The new fact is the QMC rate: a low-discrepancy sequence achieves star discrepancy of order (log N)d/N, and by Koksma-Hlawka that is the error rate. Strip the slowly-growing (log N)d factor and the core is 1/N, twice as fast (in the exponent) as 1/√N.
Let us compare the two formulas directly at N = 64, d = 1 — no random draws, just arithmetic. The MC scale is 1/√64 = 1/8 = 0.125. The QMC discrepancy scale with d = 1 is (log N)1/N = log(64)/64. Now log(64) = 4.1589, so log(64)/64 = 4.1589/64 = 0.06498 — already smaller than the MC scale, even at this modest N.
The deeper point is the cost-to-improve, not the value at one N. To halve MC error from 0.125 to 0.0625, you need 1/√N = 0.0625, i.e. √N = 16, so N = 256 — four times the points. To halve QMC error (ignoring the slow log factor) you roughly double N. So MC's cost-to-improve is quadratically worse: 4× vs 2× for every halving. That asymmetry, compounded over many digits, is the entire reason QMC exists.
But read the formula's fine print. The (log N)d factor explodes with dimension d. For large d and modest N, (log N)d can exceed √N, so QMC can actually lose to MC until N is astronomically large. The clean QMC win is for small-to-moderate dimension — which is exactly the cliffhanger the next chapter resolves with "effective dimension."
The canvas draws the convergence on log-log axes, where a power law N−p appears as a straight line of slope −p. Random MC has slope ≈ −0.5 (and is noisy); Sobol QMC has slope near −1; a regular grid has slope ≈ −1/d (catastrophic past 2-D). Drag the dimension slider and watch the QMC-beats-MC crossover slide rightward as d climbs.
Error vs N for three methods: random MC (slope −1/2), grid (slope −1/d), and Sobol QMC (slope near −1). The crossover where Sobol overtakes MC is marked; raise the dimension and watch it slide right.
From scratch — measure the convergence slopes:
python def conv_curves(f, true, d, Ns): from scipy.stats import qmc mc, qm = [], [] for N in Ns: mc.append(abs(np.mean(f(np.random.rand(N, d))) - true)) X = qmc.Sobol(d=d, scramble=True).random(N) qm.append(abs(np.mean(f(X)) - true)) return np.array(mc), np.array(qm) # log-log slopes: MC ~ -0.5, QMC ~ -1.0 for small d
The library one-liner:
python from scipy.stats import qmc # average f over these for ~1/N convergence qmc.Sobol(d=d, scramble=True).random(N)
To see a slope, fit a line to log(error) vs log(N). MC's cloud will hug slope −0.5; Sobol's will track −1 until the (log N)d factor bends it in high d.
You face a 360-dimensional integral — the monthly cash flows of a 30-year mortgage pool. The (log N)360 factor in the QMC bound is astronomically large; the theory from last chapter says QMC should be useless. Yet in the famous Paskov-Traub experiment, QMC crushed Monte Carlo on exactly this problem. The resolution is that the integral only looks 360-dimensional.
The escape hatch is effective dimension: most real high-dimensional integrands concentrate almost all their variance in a few "important" coordinates. The nominal dimension d can be 360, but the truncation effective dimension — the smallest k of coordinates that together carry, say, 95% of the variance — may be just 1 or 2.
This rescues QMC because Sobol stratifies its leading coordinates best. The first dimension of a Sobol sequence is the perfectly-balanced van der Corput sequence; later dimensions are progressively less perfectly balanced. So if you arrange for the dominant variance to live in coordinates 1, 2, 3, Sobol equidistributes exactly the directions that matter, and the (log N)360 worst case never materializes.
Let us make this concrete with an integrand dominated by its first coordinate: g(x) = exp(x1) + 0.05·Σi=2..10 sin(xi) on [0,1]10. The first term carries almost all the action; the nine small sin terms are perturbations. Its nominal dimension is 10, but its effective dimension is about 1.
Compute the true value by integrating term by term. The first term: ∫01 ex dx = e − 1 = 1.71828. Each sin term: ∫01 sin(x) dx = 1 − cos(1) = 0.45970. There are nine of them, each scaled by 0.05, contributing 9 × 0.05 × 0.45970 = 0.20687.
Add them: the true value is 1.71828 + 0.20687 = 1.92515. Because the nominal d = 10 but the effective dimension is ~1, Sobol — which stratifies coordinate 1 superbly — integrates g far more accurately than the (log N)10 worst-case bound would ever suggest. The canvas shows how a Brownian-bridge construction deliberately loads variance into the low coordinates.
A multi-step path built two ways. Sequential: each coordinate adds one increment, spreading variance across all dimensions. Brownian bridge: coordinate 1 sets the endpoint, coordinate 2 the midpoint, … loading variance into the first few dimensions. The bars show per-coordinate variance; the shaded band marks the coordinates holding 95% of it (the effective dimension).
From scratch — Brownian bridge loads variance low:
python def brownian_bridge(U, T=1.0): from scipy.stats import norm Z = norm.ppf(U) # Gaussian increments via inverse CDF # fill endpoint first, then midpoints recursively return build_bridge(Z, T) # leading coords set coarse shape # effective dim = smallest k explaining ~95% of variance
The library one-liner:
python from scipy.stats import qmc, norm # Gaussian QMC increments for a 360-step path norm.ppf(qmc.Sobol(d=360, scramble=True).random_base2(m=14))
The inverse-CDF step norm.ppf turns uniform cube points into Gaussian increments. Feed those through a Brownian bridge and the dominant path shape rides on Sobol's best-stratified leading coordinates.
Your boss wants a confidence interval. Plain Sobol gives one deterministic number with no error bar — you cannot say "±0.3%". The fix is to randomize the point set in a way that preserves its low discrepancy: apply a random scramble or shift, repeat with a few independent randomizations, and take the sample standard deviation of the resulting estimates as your error bar.
This is randomized QMC (RQMC). The trick is to choose a randomization that keeps the net structure intact. Two standard ones: a Cranley-Patterson shift (add a single random offset to every point, modulo 1) and Owen scrambling (randomly permute the digits of each coordinate). Both leave the points just as evenly spread, so each randomized estimate still converges at the QMC rate — but now it is also unbiased and you can estimate its variance.
Let us trace a Cranley-Patterson shift by hand. Fix the random offset U = 0.8 for this replicate, and apply it to two van der Corput points. Shift 0.375: frac(0.375 + 0.8) = frac(1.175) = 0.175. Shift 0.625: frac(0.625 + 0.8) = frac(1.425) = 0.425.
Notice what the shift did. The two points moved by the same amount and wrapped around the torus (the [0,1] interval glued end to end), so their relative spacing — and hence their low discrepancy — is unchanged, while their absolute positions are now random. Averaging the integrand over many independent shifts is unbiased, and the spread across shifts is your error bar.
Scrambling is unbiased too, and we can confirm it numerically with a deterministic test. Integrate ∏i(2xi) over [0,1]2 (true value 1.0, since ∫2x dx = 1 in each dimension). Average the estimate over 500 independently-scrambled Sobol sets of 256 points each: the mean comes out to 1.000. No bias — each scramble is centered on the truth, and the scatter of the 500 estimates is the variance you report.
The payoff: a handful of replicates R (say 10–30) buys you an honest standard error std/√R, while each replicate individually still rides the near-1/N QMC convergence. You pay a small multiplier in total cost to gain a confidence interval — far cheaper than plain MC's wide intervals for the same accuracy. The canvas builds the histogram of R estimates live.
Each frame applies a fresh random shift to the base Sobol set and records the integral estimate as a dot in the side histogram. The histogram's mean is unbiased and its spread is the error bar. Compare with plain MC's much wider histogram for the same point budget.
From scratch — RQMC with a Cranley-Patterson shift:
python def rqmc_estimate(f, d, N, R, rng): from scipy.stats import qmc base = qmc.Sobol(d=d, scramble=False).random(N) ests = [] for _ in range(R): # R independent randomizations U = rng.random(d) shifted = (base + U) % 1.0 # Cranley-Patterson shift ests.append(np.mean(f(shifted))) ests = np.array(ests) return ests.mean(), ests.std(ddof=1)/np.sqrt(R) # est, std err
The library one-liner:
python from scipy.stats import qmc # R Owen-scrambled replicates -> mean +/- std error [qmc.Sobol(d=d, scramble=True, seed=r).random(N) for r in range(R)]
Pass a different seed per replicate to get independent scrambles. The mean of the R estimates is your price; their sample std over √R is the honest error bar.
Price an arithmetic Asian option: its payoff depends on the average of a stock's price over many monitoring dates, so each price path is a high-dimensional point. Monte Carlo converges painfully slowly. Watch scrambled Sobol — fed through a Brownian bridge that loads the path's gross shape into its first coordinates — reach the same accuracy with a fraction of the paths, and with honest error bars from randomization.
This is the showcase that made QMC famous. The deterministic anchor is the Paskov-Traub benchmark: they priced a Collateralized Mortgage Obligation with 30 years of monthly cash flows. That is 30 × 12 = 360 monitoring dates, hence a 360-dimensional integral — the dimension that made their result surprising, because the (log N)360 bound predicted disaster.
Why Sobol and not Halton here? Halton's base for the 30th dimension would be the 30th prime, 113, whose radical inverse cycles so slowly it degrades to a near-collinear streak (Chapter 3). Sobol stays in base 2 as a digital net, so it keeps its low discrepancy in all 360 dimensions. And the cost asymmetry to gain one digit — MC needs 100× the paths, QMC only ~10× — compounds over those dimensions into the large practical speedups the dashboard shows.
Recall the halving cost from Chapter 6, now in finance terms: for QMC to halve its error costs about a 2× increase in paths; for MC, about 4× (since (2)2 = 4). Over a long pricing run that turns a week of compute into an afternoon. The dashboard below simulates GBM price paths under each method and tracks the running price, its shrinking confidence band, and a "paths to reach target accuracy" counter.
The mechanics in the dashboard: each path is a sequence of log-returns. MC draws them from independent Gaussians; QMC draws Sobol points, maps them to Gaussians via the inverse normal CDF, and (optionally) reorders them through a Brownian bridge so the path's coarse shape rides Sobol's best coordinates. The payoff is max(average price − strike, 0), discounted. Toggle the construction and watch the effective-dimension effect from Chapter 7 sharpen or blunt the QMC advantage.
Play with it: raise the number of monitoring dates (the dimension), lower the path count, switch MC → plain-Sobol → scrambled-Sobol+bridge, and read the speedup ratio (MC paths / QMC paths for equal accuracy). The bridge switch is the most instructive — with it on, QMC's error curve steepens dramatically even at high nominal dimension.
Top: simulated price paths under the chosen sampler. Middle: the running option-price estimate with shrinking confidence bands for MC and QMC. Bottom: live error vs. paths and the equal-accuracy speedup. Toggle Brownian bridge to feel the effective-dimension effect.
Two renders of the same soft shadow, four samples per pixel each. The random-sampled one is splotchy — bright and dark clumps that look like film grain. The low-discrepancy one is smooth, almost noise-free. You spent the same compute. The only difference is where the four samples landed inside each pixel, and that is pure quasi-Monte Carlo.
Every pixel in a ray-traced image is itself an integral: the average of incoming light over the pixel's area, the lens aperture, the light source, and time. A renderer estimates that integral with a few samples per pixel. Random samples clump, so neighboring pixels get wildly different estimates — that variance is the visible grain.
Low-discrepancy samples fix this two ways. First, within a pixel they cover the integration domain evenly, so each pixel's estimate is closer to the truth (lower variance, by exactly the QMC argument). Second, and subtler: good samplers make the residual error blue noise — error energy pushed to high spatial frequencies, which the human eye tolerates far better than low-frequency blotches. The same RMSE looks dramatically cleaner.
Let us anchor the idea with the area integral a single pixel computes. Estimate the area of a quarter-disk inside the unit square (true value π/4 = 0.7854) — a stand-in for a pixel's coverage integral — using the first four 2-D Halton points (bases 2 and 3). A point counts as "inside" if x² + y² ≤ 1.
The first four Halton points are (1/2, 1/3), (1/4, 2/3), (3/4, 1/9), (1/8, 4/9). Check each against the circle: (0.5, 0.333) gives 0.361 ≤ 1, inside; (0.25, 0.667) gives 0.507, inside; (0.75, 0.111) gives 0.575, inside; (0.125, 0.444) gives 0.213, inside. All four are inside, so the estimate is 4/4 = 1.0 against the true 0.7854.
With only four points the estimate is rough — but crucially the Halton points are evenly spread, no two in the same pixel-quadrant. As samples grow, the estimate converges far faster and far smoother than random, with no clumps to create grain. That smoothness, sample after sample, is the anti-grain effect the sandbox below makes visible.
A soft-shadow scene rendered with a selectable per-pixel sampler. Left half uses random sub-pixel samples; right half uses the chosen low-discrepancy sampler — same samples-per-pixel. Watch the right half stay smooth while the left stays grainy. The meter reports per-half RMSE vs a high-sample ground truth.
You have learned to beat the 1/√N wall not by being cleverer about randomness but by abandoning it for structure: deterministic low-discrepancy points that fill space. That same idea now shows up far beyond integration — in how modern probabilistic ML draws its samples — and it stops cleanly where a different idea, reweighting, takes over.
The one sentence that defines QMC: it reduces error by choosing better points on a known domain (usually the unit cube), not by reweighting samples and not by building a chain. Hold that next to the family's other tools and the division of labor is crisp.
The deterministic backbone is shared across the whole ML ecosystem. The first dimension of an unscrambled Sobol sequence is exactly the base-2 van der Corput sequence from Chapter 2: 0, 0.5, 0.75, 0.25, 0.375, 0.875, 0.625, 0.125. Both scipy.stats.qmc.Sobol(d=1, scramble=False) and torch.quasirandom.SobolEngine(dimension=1, scramble=False) return precisely those values — the same digital-net construction under two libraries.
Let us verify one shared value by hand. The 4th point of the sequence (index n = 3) is φ2(3): 3 = 11 in binary, so a0 = 1, a1 = 1; reflect to get 1/2 + 1/4 = 0.75. That single number appears identically in scipy, in PyTorch, and in the rendering and finance pipelines of the previous chapters — one atom, reused everywhere.
Where does this backbone go in 2024–2026? Scrambled Sobol ships in scipy.stats.qmc and PyTorch's SobolEngine. QMC drives variational inference with lower-variance ELBO gradients (you reparameterize z = μ + σ·Φ−1(sobol)). It powers Bayesian quadrature, where points are placed to shrink posterior variance over the integral. And low-discrepancy sampling appears throughout modern generative and rendering pipelines.
And it stops where reweighting begins. QMC needs a known domain it can fill evenly — usually the cube. When you cannot even sample the target, or must correct for sampling the wrong distribution, you reach for importance sampling or MCMC instead. The map below lets you flip "better points vs. better weights" on the same integrand and feel the boundary.
Central node: QMC = better points. The better points vs better weights toggle contrasts QMC's evenly-spread cube points against importance sampling's reweighted random points on the same integrand — with a shared error-vs-N readout so you see QMC's steeper descent next to its siblings'.
| Method | Lever | Error rate | Needs |
|---|---|---|---|
| Monte Carlo | random points | 1/√N | only i.i.d. samples |
| Quasi-Monte Carlo | better points (low discrepancy) | ~(log N)d/N | known domain (the cube) |
| Importance sampling | better weights (p/q) | 1/√N, smaller constant | a good proposal q |
| MCMC | a chain to the target | 1/√(ESS) | only an unnormalized target |
The shared backbone, two libraries:
python import torch from scipy.stats import qmc a = qmc.Sobol(d=2, scramble=True).random(1024) b = torch.quasirandom.SobolEngine( dimension=2, scramble=True).draw(1024) # QMC for VI: z = mu + sigma * Phi^-1(sobol) # better POINTS, not weights -> lower-variance ELBO grads
The library one-liner (the ML stack):
python import torch # scrambled Sobol in the ML stack torch.quasirandom.SobolEngine( dimension=d, scramble=True).draw(N)
Want to keep going? Importance sampling is the reweighting sibling; MCMC builds chains to intractable targets; and sampling & decoding shows where structured randomness meets language models.
You now know how to fill space on purpose. The next time someone reaches for "just sample more," you will know there was a better question all along: sample where?