Simo76 commited on
Commit
b15ebfb
Β·
1 Parent(s): 2742e36

Refactor Unified LoRA Controller to Nested Orbital

Browse files

Updated Unified LoRA Controller to Nested Orbital Controller with enhanced functionality and dynamic rank control.

Files changed (1) hide show
  1. controller.py +351 -171
controller.py CHANGED
@@ -1,211 +1,391 @@
1
  """
2
- Unified LoRA Controller
3
- ========================
4
 
5
- Adaptive parameter-efficient fine-tuning controller with automatic
6
- Single/Multi/Mirror mode switching based on synaptic stress signals.
 
 
 
 
 
 
 
 
7
 
8
  Author: Simona Vargiu
9
  License: Apache 2.0
10
  """
11
 
 
 
12
  import torch
13
- from typing import Dict, Optional, Tuple
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
 
 
 
15
 
16
- class UnifiedController:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
18
- Unified LoRA adaptive controller.
19
-
20
- Monitors training stress via synaptic signal Ο†(t) and automatically
21
- switches between three operational modes:
22
- - Mode 0 (Single): Shared adapter for low conflict
23
- - Mode 1 (Multi): Task-specific adapters for moderate stress
24
- - Mode 2 (Mirror): Stability snapshots for catastrophic forgetting
25
-
26
  Args:
27
- alpha (float): Learning rate for Ο†(t) updates (default: 0.1)
28
- beta (float): EMA smoothing factor for loss (default: 0.9)
29
- theta0 (float): Single/Multi threshold (default: 0.3)
30
- theta1 (float): Multi/Mirror threshold (default: 0.7)
31
- lr_single (float): Learning rate for Single mode (default: 5e-5)
32
- lr_multi (float): Learning rate for Multi mode (default: 3e-5)
33
- lr_mirror (float): Learning rate for Mirror mode (default: 1e-5)
34
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  Example:
36
- >>> controller = UnifiedController()
37
- >>> for step, batch in enumerate(train_loader):
38
- ... outputs = model(**batch)
39
- ... new_lr = controller.update(outputs.loss.item())
40
- ... # Apply new_lr to optimizer
41
  """
42
-
43
  def __init__(
44
  self,
45
- alpha: float = 0.1,
46
- beta: float = 0.9,
47
- theta0: float = 0.3,
48
- theta1: float = 0.7,
49
- lr_single: float = 5e-5,
50
- lr_multi: float = 3e-5,
51
- lr_mirror: float = 1e-5,
52
  ):
53
- self.alpha = alpha
54
- self.beta = beta
55
- self.theta0 = theta0
56
- self.theta1 = theta1
57
-
58
- # Learning rates per mode
59
- self.lr_map = {
60
- 0: lr_single,
61
- 1: lr_multi,
62
- 2: lr_mirror,
63
- }
64
-
65
- # State variables
66
- self.phi = 0.5 # Synaptic stress signal
67
- self.E_smooth = 1.0 # Smoothed loss
68
- self.mode = 1 # Current mode (start with Multi)
69
- self.step = 0
70
-
71
  # History tracking
72
  self.history = {
 
73
  "phi": [],
74
- "E_smooth": [],
75
- "mode": [],
76
- "step": [],
77
  }
78
-
79
- def update(self, loss: float) -> float:
80
  """
81
- Update controller state and return new learning rate.
82
-
83
- Args:
84
- loss (float): Current training loss
85
-
86
- Returns:
87
- float: New learning rate based on current mode
88
  """
89
- self.step += 1
90
-
91
- # Update smoothed loss (EMA)
92
- E = float(loss)
93
- self.E_smooth = self.beta * self.E_smooth + (1 - self.beta) * E
94
-
95
- # Compute normalized stress signal
96
- D = self.E_smooth / (1 + self.E_smooth) # Normalize to [0,1]
97
-
98
- # Update synaptic signal Ο†(t) with EMA
99
- self.phi = (1 - self.alpha) * self.phi + self.alpha * D
100
-
101
- # FSM: Determine mode based on Ο†(t)
102
- if self.phi < self.theta0:
103
- self.mode = 0 # Single
104
- elif self.phi < self.theta1:
105
- self.mode = 1 # Multi
106
- else:
107
- self.mode = 2 # Mirror
108
-
109
- # Log history
110
- self.history["phi"].append(self.phi)
111
- self.history["E_smooth"].append(self.E_smooth)
112
- self.history["mode"].append(self.mode)
113
- self.history["step"].append(self.step)
114
-
115
- # Return learning rate for current mode
116
- return self.lr_map[self.mode]
117
-
118
- def get_state(self) -> Dict[str, float]:
119
  """
120
- Get current controller state.
121
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  Returns:
123
- dict: Current values of phi, E_smooth, mode, step
124
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  return {
126
- "phi": self.phi,
127
- "E_smooth": self.E_smooth,
128
- "mode": self.mode,
129
- "step": self.step,
 
130
  }
131
-
132
  def get_history(self) -> Dict[str, list]:
133
- """
134
- Get complete training history.
135
-
136
- Returns:
137
- dict: History of phi, E_smooth, mode, step
138
- """
139
  return self.history
140
-
141
- def reset(self):
142
- """Reset controller to initial state."""
143
- self.phi = 0.5
144
- self.E_smooth = 1.0
145
- self.mode = 1
146
- self.step = 0
147
- self.history = {
148
- "phi": [],
149
- "E_smooth": [],
150
- "mode": [],
151
- "step": [],
152
- }
153
-
154
- @staticmethod
155
- def mode_name(mode: int) -> str:
156
- """
157
- Get human-readable mode name.
158
-
159
- Args:
160
- mode (int): Mode number (0, 1, or 2)
161
-
162
- Returns:
163
- str: Mode name
164
- """
165
- names = {0: "Single", 1: "Multi", 2: "Mirror"}
166
- return names.get(mode, "Unknown")
167
-
168
  def __repr__(self) -> str:
169
- """String representation of controller state."""
170
  return (
171
- f"UnifiedController(step={self.step}, phi={self.phi:.3f}, "
172
- f"mode={self.mode} ({self.mode_name(self.mode)}), "
173
- f"E_smooth={self.E_smooth:.3f})"
174
  )
175
 
176
 
177
- # Example usage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  if __name__ == "__main__":
179
- import numpy as np
180
-
181
- print("Unified LoRA Controller - Example")
182
  print("=" * 50)
183
-
184
- controller = UnifiedController()
185
-
186
- # Simulate training with stress events
187
- print("\nSimulating training with SHOCK at step 150...")
188
- print()
189
-
190
- for step in range(300):
191
- # Simulate loss
192
- if step < 150:
193
- loss = np.random.uniform(0.4, 0.6) # Normal training
194
  else:
195
- loss = np.random.uniform(2.0, 4.0) # SHOCK
196
-
197
- # Update controller
198
- new_lr = controller.update(loss)
199
-
200
- # Log every 50 steps
201
- if step % 50 == 0:
202
- state = controller.get_state()
203
  print(
204
- f"[{step:3d}] phi={state['phi']:.3f} | "
205
- f"mode={state['mode']} ({controller.mode_name(state['mode'])}) | "
206
- f"lr={new_lr:.1e}"
 
207
  )
208
-
209
- print("\n" + "=" * 50)
210
- print("Simulation complete!")
211
- print(f"\nFinal state: {controller}")
 
1
  """
2
+ Unified LoRA β€” Nested Orbital Controller
3
+ ==========================================
4
 
5
+ Adaptive parameter-efficient fine-tuning with dynamic rank control.
6
+
7
+ Architecture: Single LoRA adapter pair (A, B) with rank controlled via slicing.
8
+ r4 βŠ‚ r8 βŠ‚ r16 β€” one particle, multiple orbitals.
9
+ Descending = pausing dimensions, not destroying them. Zero cold start.
10
+
11
+ Controller: Closed-loop trajectory controller with orbital memory.
12
+ Stress β†’ ascend to higher orbital, push delta to stack
13
+ Stable β†’ pop delta, symmetric return to lower orbital
14
+ Neutral β†’ hold position
15
 
16
  Author: Simona Vargiu
17
  License: Apache 2.0
18
  """
19
 
20
+ import math
21
+ import numpy as np
22
  import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+ from typing import Dict, List, Optional
26
+
27
+
28
+ # ============================================================
29
+ # NESTED LoRA β€” ONE PARTICLE, MULTIPLE ORBITALS
30
+ # ============================================================
31
+
32
+ class NestedLoRALinear(nn.Module):
33
+ """
34
+ Single LoRA adapter with dynamic rank via slicing.
35
+
36
+ Instead of separate adapters for each rank (which causes cold start
37
+ on transitions), a single pair of matrices A and B is shared.
38
+ The active rank is controlled by slicing:
39
+
40
+ r=4 β†’ A[:4, :], B[:, :4]
41
+ r=8 β†’ A[:8, :], B[:, :8]
42
+ r=16 β†’ A[:16,:], B[:, :16]
43
+
44
+ When descending from r=16 to r=4, dimensions 0-3 retain all
45
+ learned weights. Dimensions 4-15 are paused, not destroyed.
46
+ When ascending back, they resume exactly where they left off.
47
+
48
+ Args:
49
+ linear: Original nn.Linear layer to wrap
50
+ max_rank: Maximum LoRA rank (default: 16)
51
+ """
52
+
53
+ def __init__(self, linear: nn.Linear, max_rank: int = 16):
54
+ super().__init__()
55
+ self.linear = linear
56
+ self.max_rank = max_rank
57
+ self.active_rank = max_rank
58
 
59
+ # Freeze original weights
60
+ for p in self.linear.parameters():
61
+ p.requires_grad = False
62
 
63
+ # One particle: single A and B
64
+ self.lora_A = nn.Parameter(torch.empty(max_rank, linear.in_features))
65
+ self.lora_B = nn.Parameter(torch.zeros(linear.out_features, max_rank))
66
+
67
+ # Standard LoRA init: A = kaiming, B = zeros β†’ initial delta = 0
68
+ nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
69
+
70
+ def set_rank(self, r: int):
71
+ """Set the active orbital (rank). Must be <= max_rank."""
72
+ self.active_rank = min(r, self.max_rank)
73
+
74
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
75
+ base = self.linear(x)
76
+ r = self.active_rank
77
+
78
+ # Slice = same particle, smaller orbital
79
+ h = F.linear(x, self.lora_A[:r, :]) # (batch, r)
80
+ delta = F.linear(h, self.lora_B[:, :r]) # (batch, out)
81
+
82
+ # Scale: maintain output magnitude across ranks
83
+ scale = self.max_rank / r
84
+
85
+ return base + delta * scale
86
+
87
+
88
+ def inject_nested_lora(model: nn.Module, max_rank: int = 16) -> nn.Module:
89
  """
90
+ Replace attention Linear layers with NestedLoRALinear.
91
+
 
 
 
 
 
 
92
  Args:
93
+ model: PyTorch model
94
+ max_rank: Maximum LoRA rank
95
+
96
+ Returns:
97
+ Model with NestedLoRA injected into attention layers
98
+ """
99
+ for name, module in list(model.named_modules()):
100
+ if isinstance(module, nn.Linear) and "attention" in name:
101
+ parent = model
102
+ *path, last = name.split(".")
103
+ for p in path:
104
+ parent = getattr(parent, p)
105
+ setattr(parent, last, NestedLoRALinear(module, max_rank))
106
+ return model
107
+
108
+
109
+ def set_rank(model: nn.Module, r: int):
110
+ """Set active rank on all NestedLoRALinear modules."""
111
+ for m in model.modules():
112
+ if isinstance(m, NestedLoRALinear):
113
+ m.set_rank(r)
114
+
115
+
116
+ # ============================================================
117
+ # ORBITAL CONTROLLER β€” TRAJECTORY WITH MEMORY
118
+ # ============================================================
119
+
120
+ class OrbitalController:
121
+ """
122
+ Closed-loop trajectory controller for dynamic rank adaptation.
123
+
124
+ Unlike threshold-based controllers (AdaLoRA, schedule-based),
125
+ this implements a state machine with orbital memory:
126
+
127
+ Ascend: stress detected β†’ jump to higher orbital, push delta
128
+ Hold: oscillating β†’ stay, don't move
129
+ Descend: confirmed stable β†’ pop delta, symmetric return
130
+
131
+ The key insight: each capacity increase is tracked and reversed
132
+ only under confirmed stability, preventing premature compression
133
+ and oscillatory collapse.
134
+
135
+ "I climb β†’ I remember. I stabilize β†’ I return exactly.
136
+ I oscillate β†’ I don't move."
137
+
138
+ Args:
139
+ ranks: Available rank levels (default: [4, 8, 16])
140
+ warmup: Steps at max rank before controller activates
141
+ stable_window: Consecutive stable steps required for descent
142
+
143
  Example:
144
+ >>> ctrl = OrbitalController()
145
+ >>> for step in range(num_steps):
146
+ ... loss = train_step(model, batch)
147
+ ... new_rank = ctrl.step(loss)
148
+ ... set_rank(model, new_rank)
149
  """
150
+
151
  def __init__(
152
  self,
153
+ ranks: List[int] = None,
154
+ warmup: int = 10,
155
+ stable_window: int = 6,
 
 
 
 
156
  ):
157
+ self.RANKS = ranks or [4, 8, 16]
158
+ self.warmup = warmup
159
+ self.stable_window = stable_window
160
+ self.reset()
161
+
162
+ def reset(self):
163
+ """Reset controller to initial state."""
164
+ self.rank = self.RANKS[-1] # start at max during warmup
165
+ self.orbit_stack = [] # stack of deltas (orbital memory)
166
+ self.loss_ema = 0.0
167
+ self.prev_loss = None
168
+ self.phi_hist = []
169
+ self.stable_count = 0
170
+ self.step_count = 0
171
+ self.post_warmup = False
172
+
 
 
173
  # History tracking
174
  self.history = {
175
+ "rank": [],
176
  "phi": [],
177
+ "lr_label": [],
178
+ "stable_count": [],
 
179
  }
180
+
181
+ def _compute_phi(self, loss: float) -> float:
182
  """
183
+ Compute stress signal from loss trajectory.
184
+
185
+ phi = |loss - EMA| + 2.0 * max(0, loss - prev_loss)
186
+
187
+ Combines deviation from trend (general instability)
188
+ with spike detection (sudden deterioration).
 
189
  """
190
+ self.loss_ema = 0.9 * self.loss_ema + 0.1 * loss
191
+ delta = abs(loss - self.loss_ema)
192
+ spike = max(0.0, loss - self.prev_loss) if self.prev_loss is not None else 0.0
193
+ self.prev_loss = loss
194
+ return delta + 2.0 * spike
195
+
196
+ def _thresholds(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  """
198
+ Adaptive thresholds that auto-calibrate to loss scale.
199
+
200
+ Uses running statistics (mu, sigma) of phi history.
201
+ No manual tuning needed across different models/tasks.
202
+ """
203
+ if len(self.phi_hist) < 10:
204
+ return 0.15, 0.04 # conservative defaults
205
+ recent = self.phi_hist[-40:]
206
+ mu = np.mean(recent)
207
+ sigma = np.std(recent) + 1e-8
208
+ t_stress = mu + 0.7 * sigma
209
+ t_stable = max(mu - 0.3 * sigma, 0.0)
210
+ return t_stress, t_stable
211
+
212
+ def _rank_index(self) -> int:
213
+ return self.RANKS.index(self.rank)
214
+
215
+ def step(self, loss: float) -> int:
216
+ """
217
+ Called once per training step. Returns the rank to use.
218
+
219
+ Args:
220
+ loss: Current step loss value
221
+
222
  Returns:
223
+ int: Active rank for next step
224
  """
225
+ self.step_count += 1
226
+
227
+ # --- First step: initialize ---
228
+ if self.prev_loss is None:
229
+ self.loss_ema = loss
230
+ self.prev_loss = loss
231
+ self._log(0.0)
232
+ return self.rank
233
+
234
+ phi = self._compute_phi(loss)
235
+ self.phi_hist.append(phi)
236
+
237
+ # --- Warmup: build EMA baseline at max rank ---
238
+ if self.step_count <= self.warmup:
239
+ self._log(phi)
240
+ return self.rank
241
+
242
+ # --- Transition: warmup β†’ ground state ---
243
+ if not self.post_warmup:
244
+ self.post_warmup = True
245
+ self.rank = self.RANKS[0] # drop to ground state
246
+ self.orbit_stack = []
247
+ self.stable_count = 0
248
+ self._log(phi)
249
+ return self.rank
250
+
251
+ t_stress, t_stable = self._thresholds()
252
+
253
+ # --- Stability counter ---
254
+ if phi <= t_stable:
255
+ self.stable_count += 1
256
+ elif phi > t_stress:
257
+ self.stable_count = 0
258
+ else:
259
+ self.stable_count = max(0, self.stable_count - 1)
260
+
261
+ # --- ASCEND: stress β†’ orbital jump ---
262
+ if phi > t_stress and self.rank < self.RANKS[-1]:
263
+ idx = self._rank_index()
264
+ new_idx = min(idx + 1, len(self.RANKS) - 1)
265
+ new_rank = self.RANKS[new_idx]
266
+ if new_rank != self.rank:
267
+ self.orbit_stack.append(new_rank - self.rank)
268
+ self.rank = new_rank
269
+ self.stable_count = 0
270
+ self._log(phi)
271
+ return self.rank
272
+
273
+ # --- DESCEND: confirmed stability β†’ symmetric return ---
274
+ if self.stable_count >= self.stable_window and self.orbit_stack:
275
+ delta = self.orbit_stack.pop()
276
+ target = self.rank - delta
277
+ self.rank = min(self.RANKS, key=lambda r: abs(r - target))
278
+ self.rank = max(self.rank, self.RANKS[0])
279
+ self.stable_count = 0
280
+ self._log(phi)
281
+ return self.rank
282
+
283
+ # --- HOLD: oscillating or neutral β†’ don't move ---
284
+ self._log(phi)
285
+ return self.rank
286
+
287
+ def _log(self, phi: float):
288
+ """Record step in history."""
289
+ self.history["rank"].append(self.rank)
290
+ self.history["phi"].append(phi)
291
+ self.history["stable_count"].append(self.stable_count)
292
+
293
+ def get_state(self) -> Dict:
294
+ """Get current controller state."""
295
  return {
296
+ "rank": self.rank,
297
+ "step": self.step_count,
298
+ "orbit_stack": list(self.orbit_stack),
299
+ "stable_count": self.stable_count,
300
+ "phi": self.phi_hist[-1] if self.phi_hist else 0.0,
301
  }
302
+
303
  def get_history(self) -> Dict[str, list]:
304
+ """Get complete training history."""
 
 
 
 
 
305
  return self.history
306
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  def __repr__(self) -> str:
 
308
  return (
309
+ f"OrbitalController(step={self.step_count}, rank={self.rank}, "
310
+ f"stack={self.orbit_stack}, stable={self.stable_count})"
 
311
  )
312
 
313
 
314
+ # ============================================================
315
+ # CONVENIENCE: COMBINED USAGE
316
+ # ============================================================
317
+
318
+ def setup_unified_lora(
319
+ model: nn.Module,
320
+ max_rank: int = 16,
321
+ ranks: List[int] = None,
322
+ warmup: int = 10,
323
+ stable_window: int = 6,
324
+ ):
325
+ """
326
+ One-call setup: inject NestedLoRA and create OrbitalController.
327
+
328
+ Args:
329
+ model: PyTorch model to adapt
330
+ max_rank: Maximum LoRA rank
331
+ ranks: Available rank levels (default: [4, 8, 16])
332
+ warmup: Controller warmup steps
333
+ stable_window: Steps of stability before descent
334
+
335
+ Returns:
336
+ (model, controller) tuple
337
+
338
+ Example:
339
+ >>> model, ctrl = setup_unified_lora(model)
340
+ >>> optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5)
341
+ >>> for step, batch in enumerate(loader):
342
+ ... loss = model(**batch).loss
343
+ ... new_rank = ctrl.step(loss.item())
344
+ ... set_rank(model, new_rank)
345
+ ... loss.backward()
346
+ ... optimizer.step()
347
+ ... optimizer.zero_grad()
348
+ """
349
+ model = inject_nested_lora(model, max_rank)
350
+ controller = OrbitalController(
351
+ ranks=ranks or [4, 8, 16],
352
+ warmup=warmup,
353
+ stable_window=stable_window,
354
+ )
355
+ return model, controller
356
+
357
+
358
+ # ============================================================
359
+ # EXAMPLE
360
+ # ============================================================
361
+
362
  if __name__ == "__main__":
363
+ print("Unified LoRA β€” Nested Orbital Controller")
 
 
364
  print("=" * 50)
365
+
366
+ ctrl = OrbitalController(warmup=10, stable_window=6)
367
+
368
+ # Simulate: stable training β†’ shock β†’ recovery
369
+ print("\nSimulating: 40 steps stable β†’ SHOCK β†’ 40 steps recovery\n")
370
+
371
+ for step in range(80):
372
+ if step < 40:
373
+ loss = np.random.uniform(0.4, 0.6)
374
+ elif step < 50:
375
+ loss = np.random.uniform(1.5, 3.0) # SHOCK
376
  else:
377
+ loss = np.random.uniform(0.3, 0.5) # recovery
378
+
379
+ rank = ctrl.step(loss)
380
+
381
+ if step % 5 == 0 or step == 40:
382
+ state = ctrl.get_state()
383
+ marker = " <<<SHOCK" if step == 40 else ""
 
384
  print(
385
+ f" [{step:3d}] rank={rank:2d} "
386
+ f"phi={state['phi']:.3f} "
387
+ f"stack={state['orbit_stack']}"
388
+ f"{marker}"
389
  )
390
+
391
+ print(f"\nFinal: {ctrl}")