File size: 6,422 Bytes
e812d9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
#!/usr/bin/env python3
"""Reproduction of Figure 2 of the SGLRW paper (arXiv 2602.15925v1):
multimodal univariate target, exact gradient corrupted by symmetric alpha-stable
noise (alpha = 1.5) of increasing scale, lr = 1e-1, parallel chains.

Samplers: SGLD, Clipped-SGLD (R = sqrt(2 delta_t)), and SGLRW *with the
implementation clipping* of sqrt(delta_t/2)|grad U| to 1 that the paper
introduces at the end of Section 4 -- i.e. exactly the implementation whose
stability Figure 2 asserts.  The un-clipped SGLRW definition is included as a
control.

Stability statistics (the paper's figure reports none):
  * frac_diverged  : |theta| > 1e3 or non-finite at the final step
  * W1             : 1-Wasserstein distance between the final sample set and the
                     true target (exact quantile coupling, 4001 quantiles)
  * KS             : Kolmogorov-Smirnov distance to the true target CDF
  * sd, p99_abs    : spread statistics of the final sample set
"""
from __future__ import annotations
import argparse, json, math, time
import numpy as np

MIXES = {
    "A": ((0.4, -2.0, 1.0), (0.6, 2.0, 1.0)),
    "B": ((0.35, -2.5, 1.0), (0.65, 1.5, 1.2)),
    "C": ((0.5, -3.0, 0.8), (0.5, 2.0, 1.5)),
}


def mix_logpdf_grad(theta, mix):
    """-grad U = grad log p for a Gaussian mixture."""
    ws = np.array([m[0] for m in mix])[:, None]
    mus = np.array([m[1] for m in mix])[:, None]
    sds = np.array([m[2] for m in mix])[:, None]
    z = (theta[None, :] - mus) / sds
    logc = np.log(ws / (sds * np.sqrt(2 * np.pi))) - 0.5 * z ** 2
    mx = logc.max(0)
    w = np.exp(logc - mx)
    s = w.sum(0)
    resp = w / s
    grad_logp = -np.sum(resp * z / sds, axis=0)
    return -grad_logp                      # grad U


def mix_cdf(x, mix):
    from math import erf
    out = np.zeros_like(x)
    for w, mu, sd in mix:
        out += w * 0.5 * (1 + np.vectorize(erf)((x - mu) / (sd * np.sqrt(2))))
    return out


def mix_quantiles(probs, mix):
    lo, hi = -60.0, 60.0
    q = np.zeros_like(probs)
    a = np.full_like(probs, lo)
    b = np.full_like(probs, hi)
    for _ in range(80):
        m = 0.5 * (a + b)
        c = mix_cdf(m, mix)
        a = np.where(c < probs, m, a)
        b = np.where(c < probs, b, m)
    return 0.5 * (a + b)


def stable_rvs(rng, size, alpha=1.5):
    """Chambers-Mallows-Stuck symmetric alpha-stable, unit scale."""
    U = rng.uniform(-np.pi / 2, np.pi / 2, size)
    W = rng.exponential(1.0, size)
    return (np.sin(alpha * U) / np.cos(U) ** (1.0 / alpha)
            * (np.cos(U - alpha * U) / W) ** ((1.0 - alpha) / alpha))


def run(sampler, scale, seed, mix="A", delta0=0.1, iters=2000, chains=4000,
        schedule="const", alpha=1.5):
    m = MIXES[mix]
    rng = np.random.default_rng(7000 + seed * 101 + int(scale) * 7 + hash(sampler) % 97)
    theta = rng.standard_normal(chains) * 3.0
    clip_hits = 0
    total = 0
    for t in range(iters):
        dt = delta0 * (1.0 + t) ** (-0.55) if schedule == "decay" else delta0
        g = mix_logpdf_grad(theta, m)
        if scale > 0:
            g = g + scale * stable_rvs(rng, theta.shape, alpha)
        if sampler == "sgld":
            theta = theta - dt * g + np.sqrt(2 * dt) * rng.standard_normal(chains)
        elif sampler == "clipped_sgld":
            R = np.sqrt(2 * dt)
            theta = theta - np.clip(dt * g, -R, R) + R * rng.standard_normal(chains)
        elif sampler in ("sglrw", "sglrw_unclipped"):
            u = np.sqrt(dt / 2.0) * g
            clip_hits += int(np.sum(np.abs(u) > 1.0))
            total += chains
            if sampler == "sglrw":
                u = np.clip(u, -1.0, 1.0)
                p = 0.5 - 0.5 * u
            else:
                p = np.clip(0.5 - 0.5 * u, 0.0, 1.0)
            s = np.where(rng.random(chains) < p, 1.0, -1.0)
            theta = theta + np.sqrt(2 * dt) * s
        else:
            raise ValueError(sampler)
        theta = np.where(np.isfinite(theta), theta, np.sign(theta) * 1e12)
        theta = np.clip(theta, -1e12, 1e12)
    bad = ~np.isfinite(theta) | (np.abs(theta) > 1e3)
    good = theta[~bad]
    probs = (np.arange(4001) + 0.5) / 4001
    tq = mix_quantiles(probs, m)
    if good.size > 100:
        sq = np.quantile(good, probs)
        w1 = float(np.mean(np.abs(sq - tq)))
        grid = np.linspace(-15, 15, 3001)
        emp = np.searchsorted(np.sort(good), grid) / good.size
        ks = float(np.max(np.abs(emp - mix_cdf(grid, m))))
    else:
        w1, ks = float("inf"), 1.0
    return {"sampler": sampler, "scale": scale, "seed": seed, "mix": mix,
            "schedule": schedule, "delta0": delta0, "iters": iters, "chains": chains,
            "frac_diverged": float(bad.mean()),
            "W1": w1, "KS": ks,
            "sd": float(np.std(good)) if good.size > 100 else float("inf"),
            "p99_abs": float(np.quantile(np.abs(good), 0.99)) if good.size > 100 else float("inf"),
            "max_abs": float(np.max(np.abs(theta))),
            "clip_fraction": (clip_hits / total) if total else None}


def _job(j):
    return run(**j)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", required=True)
    ap.add_argument("--quick", action="store_true")
    a = ap.parse_args()
    scales = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0]
    samplers = ["sgld", "sglrw", "clipped_sgld"]
    seeds = range(2) if a.quick else range(5)
    jobs = []
    for sc in scales:
        for s in samplers:
            for sd in seeds:
                jobs.append(dict(sampler=s, scale=sc, seed=sd))
    for sc in [0.0, 25.0, 50.0]:
        for sd in seeds:
            jobs.append(dict(sampler="sglrw_unclipped", scale=sc, seed=sd))
        for s in samplers:
            for sd in seeds:
                jobs.append(dict(sampler=s, scale=sc, seed=sd, schedule="decay"))
            for mx in ["B", "C"]:
                for sd in seeds:
                    jobs.append(dict(sampler=s, scale=sc, seed=sd, mix=mx))
    from concurrent.futures import ProcessPoolExecutor
    out = []
    t0 = time.time()
    with ProcessPoolExecutor(max_workers=6) as ex:
        for i, r in enumerate(ex.map(_job, jobs)):
            out.append(r)
            if i % 20 == 0:
                print(i, len(jobs), round(time.time() - t0, 1), flush=True)
    with open(a.out, "w") as f:
        json.dump(out, f, indent=1)
    print("DONE", len(out), round(time.time() - t0, 1))


if __name__ == "__main__":
    main()