The Monte Carlo Family

Quasi-Monte Carlo: better points,
not more points

Stop scattering points at random. Place them deterministically to fill space evenly, and watch integration error fall from 1/√N toward 1/N.

Prerequisite: Monte Carlo — you already know the 1/√N law and standard error. That's it.
12
Chapters
12
Simulations
1/√N
The wall we break

Chapter 0: Why random points waste themselves

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.

D*N = maxt ∈ [0,1]  | (#points ≤ t)/N − t |
Random clumps vs. a space-filling sequence

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.

N points64
The misconception this kills: "More random samples is the only way to get a better Monte Carlo estimate." Plain MC error shrinks like 1/√N no matter how clever you are with the randomness — but you are not forced to use randomness at all. The lumpiness IS the error source, and you can engineer it away with deterministic points, often turning 1/√N into nearly 1/N.

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.

Why does a set of 64 independent uniform random points in the unit square typically leave visible gaps and clumps?

Chapter 1: Discrepancy — measuring non-uniformity exactly

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.

D*N = sup(t1,…,td)  | (1/N) #{ points in [0,t1]×…×[0,td] } − t1·…·td |

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.

Hunt the worst test box

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*.

The misconception this kills: "Low discrepancy means the points look random / independent." The opposite is true: low-discrepancy points are deliberately correlated and anti-clustered. A truly random set has expected star discrepancy of order √(log log N / N) — far larger than the (log N)d/N a constructed sequence achieves. Looking random is a sign of HIGH discrepancy, not low.

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.

For a set of N points in [0,1]d, what does the star discrepancy D*N measure?

Chapter 2: Van der Corput — the radical inverse

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.

n = Σk≥0 ak bk  ⇒  φb(n) = Σk≥0 ak b−(k+1)

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.

The radical inverse, digit by digit

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.

Base b2
The misconception this kills: "Van der Corput just means equally-spaced points like 1/N, 2/N, …" No — equally-spaced points require you to fix N in advance and start over to add more. The radical inverse is a sequence: it is balanced after 1, 2, 3, … points, automatically refining without rescaling, because each new point falls in the biggest existing hole.

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.

To compute the van der Corput point φ2(6), you write 6 in binary (110) and then…

Chapter 3: Halton — van der Corput in many dimensions

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.

Halton point n in [0,1]d:   ( φ2(n), φ3(n), φ5(n), …, φpd(n) )

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).

Halton fills the square — until the bases get too big

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.

Base x2
Base y3
N points64
The misconception this kills: "Any set of prime bases works equally well for Halton." In high dimensions the large primes produce radical-inverse sequences that cycle slowly and become nearly collinear for the first many points — Halton with bases 17 and 19 looks like a thin diagonal streak before it fills in. This is the well-known Halton degradation, fixed by scrambling or by switching to Sobol for d beyond ~6.

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.

Why does the Halton sequence assign a different prime base to each dimension?

Chapter 4: Sobol — digital nets done right

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.

(t,m,d)-net: every elementary dyadic box of volume 2t−m holds exactly 2t of the 2m points.

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.

The net property: one point per elementary box

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).

Points (2^m)4
The misconception this kills: "Sobol points are just shuffled random numbers in base 2." They are fully deterministic and engineered: the direction numbers come from primitive polynomials over GF(2), chosen so the sequence forms a (t, m, d)-net — meaning equidistribution in elementary dyadic intervals is GUARANTEED, not probabilistic. That structure, not randomness, is what gives the low discrepancy.

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.

What property makes Sobol a good high-dimensional low-discrepancy sequence where Halton degrades?

Chapter 5: Koksma-Hlawka — error ≤ variation × discrepancy

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*).

| (1/N) Σi f(xi) − ∫ f |  ≤  V(f) · D*N

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.

Reshape the function, reshape the points, watch the bound

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.

Wiggle (raises V)0.0
Uniformity (lowers D*)1.00
The misconception this kills: "Koksma-Hlawka tells you the actual QMC error." It is an UPPER BOUND, usually loose — real QMC error is often far smaller. And it has a sharp limitation: V(f), the Hardy-Krause total variation, can be infinite (e.g. a discontinuous payoff along a diagonal), in which case the bound says nothing even though QMC may still beat MC in practice. The bound explains the rate, not the exact number.

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.

In the Koksma-Hlawka inequality |estimate − true| ≤ V(f)·D*N, what does this tell you about reducing error for a FIXED integrand?

Chapter 6: The Rate — (log N)d/N beats 1/√N

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.

MC error ∼ 1/√N   vs   QMC error ∼ (log N)d/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.

Convergence race on log-log axes

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.

Dimension d2
The misconception this kills: "QMC is always faster than MC." The rate (log N)d/N hides a constant and a (log N)d factor that EXPLODES with dimension d. For large d and modest N, (log N)d can exceed √N, so QMC can lose to MC until N is enormous — unless the integrand has low EFFECTIVE dimension (next chapter). The clean win is for small-to-moderate d.

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.

To halve the error of an estimate, roughly how many more points does each method need?

Chapter 7: Effective Dimension — the rescue from the curse

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.

effective dim ≈ smallest k with Var(f1..k) ≥ 0.95 · Var(f)

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.

Where does the variance live? Sequential vs Brownian bridge

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).

Steps (dims)16
The misconception this kills: "A 360-dimensional integral truly needs all 360 dimensions sampled well." Most high-dimensional finance integrands are dominated by a handful of directions (e.g. the overall interest-rate level), giving low effective dimension. A Brownian-bridge or PCA construction deliberately loads that variance into the FIRST few coordinates — precisely the ones Sobol equidistributes best — so QMC behaves as if d were small.

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.

Why can scrambled Sobol beat Monte Carlo on a 360-dimensional mortgage-pricing integral despite the (log N)d factor?

Chapter 8: Randomized QMC — error bars without losing the rate

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.

shift: x → frac(x + U),   U ∼ Uniform[0,1)d,   drawn once per replicate

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.

Build the error bar from independent randomizations

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.

Replicates R20
The misconception this kills: "Randomizing QMC throws away its advantage and makes it just Monte Carlo again." A proper scramble (Owen scrambling) or a Cranley-Patterson shift keeps the net structure intact, so each randomized estimate is unbiased AND still converges at the QMC rate; you only pay a small number of independent replicates to estimate the variance. Owen scrambling can even improve the rate for smooth functions.

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.

What does randomized QMC (e.g. Owen scrambling or a random shift) buy you over plain deterministic QMC?

Chapter 9: Showcase — pricing a high-dimensional option

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 increase in paths; for MC, about (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.

Asian option pricing dashboard

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.

Monitor dates (dim)16
Volatility0.30
Strike K100
The QMC win, made concrete: on a path-dependent option the dimension equals the number of monitoring dates. Scrambled Sobol with a Brownian bridge routes the path's gross shape into its best-stratified coordinates, so it reaches a target accuracy with one to two orders of magnitude fewer paths than Monte Carlo — the canonical result that launched QMC into quantitative finance.

Chapter 10: Showcase — less-grainy rendering with blue-noise samples

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.

Rendering sandbox: same samples, different placement

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.

Samples / pixel4
Why blue noise looks cleaner: two samplers can have the same average error yet look totally different. Random sampling spreads its error across all spatial frequencies, including the low ones the eye notices as blotches. Low-discrepancy and blue-noise samplers push the error to high frequencies — fine, even texture — so the same compute budget yields a visibly less grainy image.

Chapter 11: Connections — where QMC goes next

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.

Better points vs. better weights vs. chains. QMC fills the cube evenly. Importance sampling keeps random points but reweights them by a target/proposal density ratio. MCMC builds a Markov chain whose stationary distribution is the target. They are complementary — and QMC composes with the others (randomized QMC inside variational inference, for example).

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.

The Monte Carlo family map

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'.

The misconception this kills: "QMC is the answer to every Monte Carlo problem, including hard target distributions." QMC reduces error by better POINTS on a known domain (usually the unit cube). When you cannot even sample the target — or you must correct for sampling the wrong distribution — you need REWEIGHTING (importance sampling) or chain-building (MCMC). QMC composes with those ideas, but it does not replace them.
MethodLeverError rateNeeds
Monte Carlorandom points1/√Nonly i.i.d. samples
Quasi-Monte Carlobetter points (low discrepancy)~(log N)d/Nknown domain (the cube)
Importance samplingbetter weights (p/q)1/√N, smaller constanta good proposal q
MCMCa chain to the target1/√(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.

What is the defining way QMC reduces integration error, distinguishing it from importance sampling?
"The generation of random numbers is too important to be left to chance."
— Robert R. Coveyou

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?