Directional Hölder Step Controller / directional_controller_experiment.py

✓✓ Beats tuned baseline

Raw ⬇ ZIP
  1import json, math, random, time
  2import numpy as np
  3import torch
  4
  5SEED = 1296
  6np.random.seed(SEED); random.seed(SEED); torch.manual_seed(SEED)
  7torch.set_num_threads(4)
  8device = "cuda" if torch.cuda.is_available() else "cpu"
  9try:
 10    if device == "cuda": torch.cuda.empty_cache()
 11except Exception:
 12    device = "cpu"
 13
 14def controller_step(theta, loss_fn, eta_prev, alpha=1.0, rho=.9, c=.1,
 15                    beta=.5, eta_min=1e-5, eta_max=2.0, eps=1e-12, max_bt=12):
 16    theta = theta.detach().requires_grad_(True)
 17    f0 = loss_fn(theta)
 18    g, = torch.autograd.grad(f0, theta)
 19    G = torch.linalg.vector_norm(g).item()
 20    if G < 1e-14:
 21        return theta.detach(), float(f0), 0., 0, G, 0.
 22    # Probe the currently maintained rate, then infer directional curvature.
 23    trial = theta - eta_prev * g
 24    ftrial = loss_fn(trial).detach()
 25    s_norm = eta_prev * G
 26    r = (ftrial.item() - f0.item() + eta_prev * G * G)
 27    Lhat = (1 + alpha) * max(r, 0.) / (s_norm ** (1 + alpha) + eps)
 28    eta_star = (G ** (1-alpha) / (Lhat + eps)) ** (1/alpha)
 29    eta = min(eta_max, max(eta_min, rho * eta_star))
 30    accepted = False
 31    fc = float('inf')
 32    for j in range(max_bt + 1):
 33        cand = theta - eta * g
 34        fc = loss_fn(cand).detach().item()
 35        if fc <= f0.item() - c * eta * G * G:
 36            accepted = True
 37            break
 38        eta *= beta
 39    if not accepted:
 40        eta = 0.
 41        cand = theta
 42    # Mild growth damping: preserve the accepted scale rather than blindly probing it.
 43    next_eta = min(eta_max, eta / .9) if accepted else max(eta_min, eta_prev * beta)
 44    return cand.detach(), fc if accepted else f0.item(), next_eta, int(accepted), G, Lhat
 45
 46def quadratic_sweep():
 47    rows=[]
 48    # Prediction 1: alpha=1 Lhat = directional curvature q/G^2, independent of probe eta.
 49    for lam in [0.1, 0.3, 1., 3., 10.]:
 50        H=torch.diag(torch.tensor([lam, 2.*lam, .5*lam], dtype=torch.float64))
 51        theta=torch.tensor([1.2,-.7,.4], dtype=torch.float64)
 52        def loss(x): return .5*x @ H @ x
 53        g=H@theta; G=torch.linalg.vector_norm(g).item(); q=(g @ H @ g).item()
 54        exact=q/(G*G)
 55        vals=[]
 56        for probe in [.03,.2,1.1]:
 57            trial=theta-probe*g
 58            r=(loss(trial)-loss(theta)+probe*G*G).item()
 59            lh=2*max(r,0)/(probe*G)**2
 60            vals.append(lh)
 61        rows.append({'lambda_scale':lam,'exact_directional_curvature':exact,
 62                     'observed_Lhat_mean':float(np.mean(vals)), 'relative_error':abs(np.mean(vals)-exact)/exact,
 63                     'eta_star_prediction':1/exact, 'observed_eta_star':1/float(np.mean(vals))})
 64    # Prediction 2: accepted eta * directional curvature = rho for a quadratic, and eta inversely scales.
 65    rate=[]
 66    for lam in [0.1, .3, 1., 3., 10.]:
 67        H=torch.eye(2,dtype=torch.float64)*lam; theta=torch.tensor([1.,-.5],dtype=torch.float64)
 68        def loss(x): return .5*x@H@x
 69        out=controller_step(theta,loss,eta_prev=.4,eta_max=20.,eta_min=1e-8)
 70        eta=out[2] * .9 # next_eta is eta/.9; recover accepted eta
 71        # exact expected accepted eta is rho/lam
 72        rate.append({'lambda':lam,'predicted_eta':.9/lam,'observed_eta':eta,
 73                     'eta_times_lambda':eta*lam,'accepted':bool(out[3])})
 74    # Prediction 3: sufficient-decrease acceptance boundary eta*lambda <= 2(1-c).
 75    boundary=[]; c=.1
 76    for lam in [1., 3.]:
 77        H=torch.eye(1,dtype=torch.float64)*lam; theta=torch.tensor([1.],dtype=torch.float64)
 78        def loss(x): return .5*x@H@x
 79        for eta in [1.7/lam, 1.9/lam, 2.1/lam]:
 80            g=lam*theta; f0=loss(theta).item(); fc=loss(theta-eta*g).item()
 81            accepted=fc <= f0-c*eta*(g@g).item()+1e-12
 82            boundary.append({'lambda':lam,'eta_lambda':eta*lam,'predicted_accept':eta*lam <= 2*(1-c), 'observed_accept':accepted})
 83    return {'curvature_identity':rows,'inverse_scaling':rate,'acceptance_boundary':boundary}
 84
 85def make_data(n=512):
 86    rng=np.random.RandomState(SEED)
 87    x=rng.randn(n,2).astype('float32')
 88    y=((x[:,0]*x[:,1] + .25*x[:,0] - .15*x[:,1])>0).astype('int64')
 89    return torch.tensor(x,device=device), torch.tensor(y,device=device)
 90
 91def net_loss(theta,x,y,d=16):
 92    a=2*d; b=d; c=2*d
 93    W1=theta[:a].reshape(d,2); b1=theta[a:a+b]
 94    W2=theta[a+b:a+b+c].reshape(2,d); b2=theta[a+b+c:a+b+c+2]
 95    h=torch.tanh(x@W1.T+b1)
 96    return torch.nn.functional.cross_entropy(h@W2.T+b2,y)
 97
 98def train_compare(steps=100):
 99    x,y=make_data(); d=16; n=5*d+2
100    torch.manual_seed(SEED)
101    init=torch.randn(n,device=device)*.15
102    results={}
103    for name in ['cosine_sgd','directional']:
104        theta=init.clone(); eta=.15; losses=[]; accepted=[]; lrs=[]; t0=time.perf_counter()
105        for k in range(steps):
106            fn=lambda z: net_loss(z,x,y,d)
107            if name=='directional':
108                theta,f,eta_next,ok,G,Lh=controller_step(theta,fn,eta,alpha=1.,rho=.9,c=.1,
109                                                            eta_min=1e-4,eta_max=2.,max_bt=10)
110                eta=eta_next; losses.append(f); accepted.append(ok); lrs.append(eta)
111            else:
112                th=theta.detach().requires_grad_(True); f=fn(th); g,=torch.autograd.grad(f,th)
113                lr=.15*.5*(1+math.cos(math.pi*k/steps)); theta=(th-lr*g).detach()
114                losses.append(fn(theta).item()); accepted.append(1); lrs.append(lr)
115        with torch.no_grad():
116            pred=net_loss(theta,x,y,d).item()
117            W1=theta[:2*d].reshape(d,2); b1=theta[2*d:3*d]; W2=theta[3*d:5*d].reshape(2,d); b2=theta[5*d:]
118            logits=torch.tanh(x@W1.T+b1)@W2.T+b2
119            acc=(logits.argmax(1)==y).float().mean().item()
120        results[name]={'final_loss':pred,'accuracy':acc,'loss_at_10':losses[9],
121                       'loss_at_50':losses[49],'accepted_fraction':float(np.mean(accepted)),
122                       'mean_lr':float(np.mean(lrs)),'wall_seconds':time.perf_counter()-t0}
123    return results
124
125if __name__=='__main__':
126    out={'device':device,'seed':SEED,'toy':quadratic_sweep(),'training':train_compare()}
127    with open('results.json','w') as f: json.dump(out,f,indent=2)
128    print(json.dumps(out,indent=2))