Simo76 commited on
Commit
91ae3e3
·
1 Parent(s): 3f5b03f

Update Unified LoRA controller documentation

Browse files

Refactor Unified LoRA controller documentation and structure.

Files changed (1) hide show
  1. controller.py +31 -383
controller.py CHANGED
@@ -1,391 +1,39 @@
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}")
 
1
  """
2
+ Unified LoRA Controller
3
+ ========================
4
 
5
+ Convenience wrapper that re-exports from the two core modules:
6
+ - nested_lora.py (engine: NestedLoRALinear, inject, set_rank)
7
+ - orbital_controller.py (intelligence: OrbitalController)
8
 
9
+ Import from here for quick usage, or from the individual modules
10
+ for finer control.
 
 
 
 
 
 
11
 
12
  Author: Simona Vargiu
13
  License: Apache 2.0
14
  """
15
 
16
+ # Engine
17
+ from nested_lora import (
18
+ NestedLoRALinear,
19
+ inject_nested_lora,
20
+ set_rank,
21
+ get_lora_params,
22
+ count_params,
23
+ )
24
+
25
+ # Intelligence
26
+ from orbital_controller import (
27
+ OrbitalController,
28
+ setup_unified_lora,
29
+ )
30
+
31
+ __all__ = [
32
+ "NestedLoRALinear",
33
+ "inject_nested_lora",
34
+ "set_rank",
35
+ "get_lora_params",
36
+ "count_params",
37
+ "OrbitalController",
38
+ "setup_unified_lora",
39
+ ]