Lipschitz Finite-Newton–Schulz Muon / experiment.py

Mechanism works

Raw ⬇ ZIP
  1import json
  2import time
  3import numpy as np
  4
  5SEED = 194
  6rng = np.random.default_rng(SEED)
  7
  8
  9def ns_transform(M, depth):
 10    """Finite Newton-Schulz polar-like transform, scaled back to M's units."""
 11    m, n = M.shape
 12    alpha = max(1e-12, np.linalg.norm(M, 2))
 13    X = M / alpha
 14    if m >= n:
 15        I = np.eye(n)
 16        for _ in range(depth):
 17            X = 0.5 * X @ (3.0 * I - X.T @ X)
 18    else:
 19        I = np.eye(m)
 20        for _ in range(depth):
 21            X = 0.5 * (3.0 * I - X @ X.T) @ X
 22    return alpha * X
 23
 24
 25def exact_polar(M):
 26    U, _, Vt = np.linalg.svd(M, full_matrices=False)
 27    return U @ Vt
 28
 29
 30def residual(M):
 31    alpha = max(1e-12, np.linalg.norm(M, 2))
 32    X = M / alpha
 33    m, n = X.shape
 34    if m >= n:
 35        R = X.T @ X - np.eye(n)
 36    else:
 37        R = X @ X.T - np.eye(m)
 38    return np.linalg.norm(R, 'fro') / np.sqrt(min(m, n))
 39
 40
 41def duality_check():
 42    # Numerically test sup_{||X||op <= 1} <S,X> = ||S||_* using polar(S),
 43    # plus random feasible competitors that should not exceed the optimum.
 44    r = np.random.default_rng(SEED + 7)
 45    S = r.normal(size=(9, 6))
 46    P = exact_polar(S)
 47    optimum = float(np.sum(np.linalg.svd(S, compute_uv=False)))
 48    achieved = float(np.sum(S * P))
 49    competitors = []
 50    for _ in range(200):
 51        Q = r.normal(size=S.shape)
 52        qnorm = np.linalg.norm(Q, 2)
 53        competitors.append(float(np.sum(S * (Q / max(qnorm, 1e-12)))))
 54    return {'nuclear_norm': optimum, 'polar_objective': achieved,
 55            'absolute_gap': abs(achieved - optimum),
 56            'max_random_feasible_objective': max(competitors),
 57            'random_excess_over_optimum': max(competitors) - optimum}
 58
 59
 60def scalar_check():
 61    # Includes a zero, a very small singular value, and a well-scaled one.
 62    s = np.array([0.0, 1e-4, 0.03, 0.2, 0.7, 0.95])
 63    vals = []
 64    x = s.copy()
 65    for t in range(7):
 66        vals.append(x.copy())
 67        x = 0.5 * x * (3.0 - x * x)
 68    vals = np.asarray(vals)
 69    err = np.abs(1.0 - vals[:, 1:])
 70    # For a direction starting below one, normalized error contracts
 71    # rapidly once it is in the attraction basin.
 72    tail = err[1:, -1]
 73    contraction = (tail[1:] / np.maximum(tail[:-1], 1e-30)).tolist()
 74    # A finite polynomial maps zero continuously to zero, unlike polar's
 75    # nonzero-singular-value response (the intended smoothing observation).
 76    smooth_gap_at_small = float(vals[2, 1])
 77    return {
 78        'values': vals.tolist(),
 79        'small_sigma_response_t2': smooth_gap_at_small,
 80        'zero_response_all_t': vals[:, 0].tolist(),
 81        'largest_direction_errors': tail.tolist(),
 82        'successive_error_ratios': contraction,
 83        't7_small_sigma_error': float(abs(1.0 - vals[7-1, 1])),
 84    }
 85
 86
 87def make_problem(seed, m=32, n=24):
 88    r = np.random.default_rng(seed)
 89    # Ill-conditioned target: broad singular spectrum makes shallow NS visibly
 90    # different from exact polar while remaining a tiny CPU problem.
 91    U, _ = np.linalg.qr(r.normal(size=(m, n)))
 92    V, _ = np.linalg.qr(r.normal(size=(n, n)))
 93    singulars = np.geomspace(8.0, 0.02, n)
 94    A = U @ np.diag(singulars) @ V.T
 95    # A diagonal preconditioner creates nontrivial, changing momentum matrices.
 96    weights = np.geomspace(0.4, 2.5, m)[:, None] * np.geomspace(0.7, 1.8, n)[None, :]
 97    return A, weights
 98
 99
100def run_optimizer(kind, seed, steps=180):
101    A, weights = make_problem(seed)
102    r = np.random.default_rng(seed + 1000)
103    W = 0.35 * r.normal(size=A.shape)
104    M = np.zeros_like(W)
105    beta, lr = 0.90, 0.055
106    losses, residuals, smoothness = [], [], []
107    prev_dir, prev_M = None, None
108    ns_mults = 0
109    t0 = time.perf_counter()
110    for step in range(steps):
111        grad = weights * (W - A)
112        M = beta * M + (1.0 - beta) * grad
113        if kind == 'exact':
114            direction = exact_polar(M)
115            orth_res = 0.0
116        elif kind == 't5':
117            direction = ns_transform(M, 5)
118            orth_res = residual(direction)
119            ns_mults += 5
120        elif kind == 't2':
121            direction = ns_transform(M, 2)
122            orth_res = residual(direction)
123            ns_mults += 2
124        elif kind == 'adaptive':
125            depth = 2 if step < int(0.7 * steps) else 4
126            direction = ns_transform(M, depth)
127            orth_res = residual(direction)
128            ns_mults += depth
129        else:
130            raise ValueError(kind)
131        # Muon-style update, with a common scalar normalization so comparisons
132        # reflect spectral direction rather than arbitrary matrix magnitude.
133        direction = direction * (np.linalg.norm(M, 'fro') / max(np.linalg.norm(direction, 'fro'), 1e-12))
134        if prev_dir is not None:
135            smoothness.append(np.linalg.norm(direction - prev_dir, 'fro') /
136                              max(np.linalg.norm(M - prev_M, 'fro'), 1e-12))
137        W -= lr * direction
138        losses.append(float(0.5 * np.mean(weights * (W - A) ** 2)))
139        residuals.append(float(orth_res))
140        prev_dir, prev_M = direction.copy(), M.copy()
141    elapsed = time.perf_counter() - t0
142    return {
143        'final_loss': losses[-1],
144        'best_loss': min(losses),
145        'loss_at_60': losses[59],
146        'loss_at_120': losses[119],
147        'mean_update_smoothness': float(np.mean(smoothness)),
148        'median_update_smoothness': float(np.median(smoothness)),
149        'mean_reported_residual': float(np.mean(residuals)),
150        'wall_seconds': elapsed,
151        'ns_matrix_multiplications': ns_mults,
152        'loss_curve': losses,
153    }
154
155
156def main():
157    check = scalar_check()
158    methods = ['exact', 't5', 't2', 'adaptive']
159    all_runs = {}
160    for method in methods:
161        all_runs[method] = [run_optimizer(method, seed) for seed in (11, 29, 47)]
162    summary = {}
163    for method, runs in all_runs.items():
164        keys = ['final_loss', 'best_loss', 'loss_at_60', 'loss_at_120',
165                'mean_update_smoothness', 'median_update_smoothness',
166                'mean_reported_residual', 'wall_seconds', 'ns_matrix_multiplications']
167        summary[method] = {k: float(np.mean([x[k] for x in runs])) for k in keys}
168    out = {'duality_verification': duality_check(), 'scalar_verification': check, 'summary_mean_over_3_seeds': summary,
169           'runs': all_runs}
170    with open('results.json', 'w') as f:
171        json.dump(out, f, indent=2)
172    print(json.dumps({'duality_verification': out['duality_verification'], 'scalar_verification': check, 'summary_mean_over_3_seeds': summary}, indent=2))
173
174
175if __name__ == '__main__':
176    main()