Passivity-Regularized Sequence Layer / passivity_experiment.py
Mechanism confirmed, baseline not beaten
1import json
2import math
3import random
4from pathlib import Path
5
6import numpy as np
7import torch
8from torch import nn
9
10SEED = 587
11DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
12
13
14def seed_all(seed=SEED):
15 random.seed(seed)
16 np.random.seed(seed)
17 torch.manual_seed(seed)
18 if torch.cuda.is_available():
19 torch.cuda.manual_seed_all(seed)
20
21
22class GRUSequence(nn.Module):
23 def __init__(self, hidden=24):
24 super().__init__()
25 self.gru = nn.GRU(1, hidden, batch_first=True)
26 self.readout = nn.Linear(hidden, 1)
27
28 def forward(self, u):
29 # h_all includes h_1,...,h_T; h0 is explicitly zero.
30 out, _ = self.gru(u)
31 h0 = torch.zeros(u.shape[0], 1, out.shape[-1], device=u.device)
32 h_all = torch.cat([h0, out], dim=1)
33 y = self.readout(out).squeeze(-1)
34 return y, h_all
35
36
37def passivity_loss(u, y, h_all, gamma=0.10, incremental=None, eta=0.0):
38 # u: B,T,1; y: B,T; h_all: B,T+1,H
39 r = (h_all[:, 1:].pow(2).sum(-1) + y.pow(2)
40 - h_all[:, :-1].pow(2).sum(-1) - u.squeeze(-1).pow(2))
41 lp = torch.relu(r).mean() + gamma * torch.relu(r.sum(dim=1)).mean()
42 inc_lp = torch.zeros((), device=u.device)
43 if incremental is not None and eta:
44 u2, y2, h2 = incremental
45 du = u2 - u
46 dy = y2 - y
47 dh = h2 - h_all
48 ri = (dh[:, 1:].pow(2).sum(-1) + dy.pow(2)
49 - dh[:, :-1].pow(2).sum(-1) - du.squeeze(-1).pow(2))
50 inc_lp = torch.relu(ri).mean()
51 lp = lp + eta * inc_lp
52 stats = {
53 "max_positive_residual": float(torch.relu(r).max().detach().cpu()),
54 "mean_cumulative_residual": float(r.sum(1).mean().detach().cpu()),
55 "mean_hidden_norm": float(h_all[:, 1:].norm(dim=-1).mean().detach().cpu()),
56 "penalty": float(lp.detach().cpu()),
57 "incremental_penalty": float(inc_lp.detach().cpu()),
58 }
59 return lp, stats
60
61
62def math_check():
63 # Directly check sum_t r_t = ||h_T||^2 + sum ||y||^2 - ||h_0||^2 - sum ||u||^2.
64 g = torch.Generator().manual_seed(SEED)
65 B, T, H = 7, 13, 5
66 u = torch.randn(B, T, 1, generator=g)
67 h = torch.randn(B, T + 1, H, generator=g)
68 y = torch.randn(B, T, generator=g)
69 r = h[:, 1:].pow(2).sum(-1) + y.pow(2) - h[:, :-1].pow(2).sum(-1) - u.squeeze(-1).pow(2)
70 lhs = r.sum(1)
71 rhs = h[:, -1].pow(2).sum(-1) + y.pow(2).sum(1) - h[:, 0].pow(2).sum(-1) - u.squeeze(-1).pow(2).sum(1)
72 err = float((lhs - rhs).abs().max())
73 # A deliberately amplifying scalar trajectory has positive residuals.
74 hs = [torch.tensor([[1.0]])]
75 us, ys = [], []
76 for _ in range(8):
77 us.append(torch.zeros(1, 1))
78 ys.append(torch.zeros(1))
79 hs.append(1.15 * hs[-1])
80 rr = []
81 for t in range(8):
82 rr.append(hs[t+1].pow(2).sum() - hs[t].pow(2).sum())
83 positive_fraction = float((torch.stack(rr) > 0).float().mean())
84 return {"telescoping_max_abs_error": err, "amplifying_positive_residual_fraction": positive_fraction}
85
86
87def batch_data(n, T, seed):
88 # Long-context signal: classify whether the sum of random +/-1 inputs is positive.
89 gen = torch.Generator().manual_seed(seed)
90 x = torch.randint(0, 2, (n, T, 1), generator=gen).float() * 2 - 1
91 # Add a weak final tie breaker so labels are balanced and deterministic.
92 sums = x.sum(dim=1).squeeze(-1)
93 labels = (sums > 0).float()
94 ties = sums == 0
95 labels[ties] = (x[ties, -1, 0] > 0).float()
96 return x, labels
97
98
99def run_model(reg_lambda, paired, seed=SEED):
100 seed_all(seed)
101 model = GRUSequence().to(DEVICE)
102 opt = torch.optim.Adam(model.parameters(), lr=3e-3)
103 train_u, train_y = batch_data(96, 32, 1001)
104 train_u, train_y = train_u.to(DEVICE), train_y.to(DEVICE)
105 last = {}
106 for step in range(260):
107 # Fresh small batches are unnecessary here; fixed data makes the comparison cheap/reproducible.
108 if paired:
109 noise = 0.05 * torch.randn_like(train_u)
110 u2 = train_u + noise
111 y2, h2 = model(u2)
112 y, h = model(train_u)
113 task = nn.functional.binary_cross_entropy_with_logits(y[:, -1], train_y)
114 inc = (u2, y2, h2) if paired else None
115 pl, st = passivity_loss(train_u, y, h, gamma=0.10, incremental=inc, eta=0.10 if paired else 0.0)
116 loss = task + reg_lambda * pl
117 opt.zero_grad()
118 loss.backward()
119 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
120 opt.step()
121 last = {"task": float(task.detach().cpu()), **st}
122
123 def evaluate(T, seed_offset):
124 u, target = batch_data(512, T, 4000 + seed_offset)
125 u, target = u.to(DEVICE), target.to(DEVICE)
126 with torch.no_grad():
127 y, h = model(u)
128 acc = ((torch.sigmoid(y[:, -1]) > .5) == target.bool()).float().mean()
129 _, st = passivity_loss(u, y, h, gamma=0.10)
130 # A finite input perturbation amplification at the final state.
131 u2 = u + 0.02 * torch.randn_like(u)
132 _, h2 = model(u2)
133 amp = ((h2[:, -1] - h[:, -1]).norm(dim=-1) /
134 (u2 - u).norm(dim=(1, 2)).clamp_min(1e-8)).mean()
135 return {"accuracy": float(acc.cpu()), "amplification": float(amp.cpu()), **st}
136
137 return {"train": last, "test_T32": evaluate(32, 32), "test_T160": evaluate(160, 160)}
138
139
140def main():
141 seed_all()
142 math_result = math_check()
143 results = {
144 "device": DEVICE,
145 "math_check": math_result,
146 "baseline": run_model(0.0, False, SEED),
147 "passivity_lambda_0.01": run_model(0.01, False, SEED),
148 "passivity_lambda_0.01_incremental": run_model(0.01, True, SEED),
149 }
150 Path("results.json").write_text(json.dumps(results, indent=2))
151 print(json.dumps(results, indent=2))
152
153
154if __name__ == "__main__":
155 try:
156 main()
157 except Exception as e:
158 # CUDA can fail in a shared environment; retry entirely on CPU.
159 if DEVICE == "cuda":
160 print("CUDA failed, retrying on CPU:", repr(e))
161 torch.cuda.empty_cache()
162 DEVICE = "cpu"
163 main()
164 else:
165 raise