entrenar/lora/layer/core.rs
1//! LoRA (Low-Rank Adaptation) layer implementation
2//!
3//! LoRA enables parameter-efficient fine-tuning by adding trainable low-rank
4//! decomposition matrices to frozen pretrained weights.
5//!
6//! For a frozen weight matrix W ∈ ℝ^(d_out × d_in), LoRA adds:
7//! ΔW = B @ A where A ∈ ℝ^(r × d_in) and B ∈ ℝ^(d_out × r)
8//!
9//! Forward pass: y = (W + α·B·A) @ x = W@x + α·(B@(A@x))
10//! where α is a scaling factor (typically alpha/r)
11//!
12//! Dropout placement (PMAT-879): matching HF PEFT `lora.Linear.forward`, dropout
13//! is applied to the INPUT `x` *before* the down-projection `A`:
14//!
15//! `y = W@x + scale · B(A(dropout(x)))`
16//!
17//! Dropout is active only in training mode; in eval mode (the default) it is the
18//! identity, so inference output is unchanged. Inverted dropout scales the
19//! surviving activations by `1/(1-p)` so the expected value is preserved.
20
21use crate::autograd::matmul;
22use crate::Tensor;
23use std::cell::Cell;
24
25/// LoRA scaling mode (ENT-LoRA-004)
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum LoRAScaling {
28 /// Standard: scale = alpha / rank
29 Standard,
30 /// rsLoRA: scale = alpha / sqrt(rank) — rank-stable, default for rank > 16
31 RsLoRA,
32}
33
34impl LoRAScaling {
35 /// Compute the scaling factor
36 ///
37 /// # Panics
38 /// Panics if rank is zero
39 pub fn compute(self, alpha: f32, rank: usize) -> f32 {
40 assert!(rank > 0, "LoRA rank must be > 0");
41 match self {
42 Self::Standard => alpha / rank as f32,
43 Self::RsLoRA => alpha / (rank as f32).sqrt(),
44 }
45 }
46}
47
48/// LoRA layer: adds trainable low-rank adaptation to a frozen base weight
49#[derive(Clone)]
50pub struct LoRALayer {
51 /// Frozen base weight matrix stored as 1D [d_out * d_in]
52 base_weight: Tensor,
53 /// LoRA matrix A stored as 1D [r * d_in] - downprojection
54 lora_a: Tensor,
55 /// LoRA matrix B stored as 1D [d_out * r] - upprojection
56 lora_b: Tensor,
57 /// Output dimension
58 d_out: usize,
59 /// Input dimension
60 d_in: usize,
61 /// LoRA rank
62 rank: usize,
63 /// Scaling factor (alpha/rank)
64 scale: f32,
65 /// Whether the adapter is merged into base_weight
66 merged: bool,
67 /// LoRA dropout probability applied to the input `x` in the LoRA branch
68 /// (PMAT-879). `0.0` disables dropout (identity). Active only in training mode.
69 dropout: f32,
70 /// Training mode flag. `false` (eval) is the default so inference output is
71 /// deterministic and dropout-free, matching PEFT (`nn.Dropout` is identity in eval).
72 training: bool,
73 /// Base seed for the deterministic dropout RNG (PMAT-879).
74 dropout_seed: u64,
75 /// Per-forward counter that advances the dropout RNG so successive training
76 /// steps draw fresh, but reproducible, masks. Interior mutability keeps
77 /// `forward(&self)` (shared borrow) while remaining deterministic for a seed.
78 dropout_step: Cell<u64>,
79}
80
81impl LoRALayer {
82 /// Create a new LoRA layer
83 ///
84 /// # Arguments
85 /// * `base_weight` - Frozen pretrained weight [d_out * d_in]
86 /// * `d_out` - Output dimension
87 /// * `d_in` - Input dimension
88 /// * `rank` - LoRA rank (typically 4, 8, 16, 32, or 64)
89 /// * `alpha` - LoRA scaling parameter (often same as rank)
90 ///
91 /// # Returns
92 /// LoRA layer with randomly initialized A (Gaussian) and zero-initialized B
93 pub fn new(base_weight: Tensor, d_out: usize, d_in: usize, rank: usize, alpha: f32) -> Self {
94 assert!(rank > 0, "LoRA rank must be > 0");
95 assert_eq!(base_weight.len(), d_out * d_in, "Base weight size must match d_out * d_in");
96
97 // Initialize A with small Gaussian noise, B with zeros (standard LoRA init)
98 // This ensures that initially ΔW = B·A = 0
99 let lora_a_data: Vec<f32> = (0..rank * d_in)
100 .map(|i| {
101 // Simple deterministic "random" init for reproducibility in tests
102 let x = (i as f32 * 0.1).sin();
103 x * 0.01 // Small values
104 })
105 .collect();
106 let lora_a = Tensor::from_vec(lora_a_data, true);
107
108 let lora_b = Tensor::zeros(d_out * rank, true);
109
110 let scale = alpha / rank as f32;
111
112 Self {
113 base_weight,
114 lora_a,
115 lora_b,
116 d_out,
117 d_in,
118 rank,
119 scale,
120 merged: false,
121 dropout: 0.0,
122 training: false,
123 dropout_seed: 0,
124 dropout_step: Cell::new(0),
125 }
126 }
127
128 /// Create a new LoRA layer with explicit scaling mode (ENT-LoRA-004)
129 ///
130 /// Use `LoRAScaling::RsLoRA` for rank-stable training (recommended for rank > 16).
131 pub fn new_with_scaling(
132 base_weight: Tensor,
133 d_out: usize,
134 d_in: usize,
135 rank: usize,
136 alpha: f32,
137 scaling: LoRAScaling,
138 ) -> Self {
139 let mut layer = Self::new(base_weight, d_out, d_in, rank, alpha);
140 layer.scale = scaling.compute(alpha, rank);
141 layer
142 }
143
144 /// Override the LoRA scaling factor.
145 ///
146 /// Used when restoring a serialized adapter whose `scale` was produced by a
147 /// non-Standard mode (e.g. rsLoRA, where `scale = alpha / sqrt(rank)` rather than
148 /// the `alpha / rank` that [`LoRALayer::new`] recomputes). The adapter stores the
149 /// resulting scale *value*, not the scaling mode, so restoration sets it directly.
150 #[must_use]
151 pub fn with_scale(mut self, scale: f32) -> Self {
152 self.scale = scale;
153 self
154 }
155
156 /// Set the LoRA dropout probability (PMAT-879).
157 ///
158 /// Matches HF PEFT `lora_dropout`: dropout is applied to the input `x` before
159 /// the down-projection `A`. `p` is clamped to `[0.0, 1.0)`; `0.0` disables
160 /// dropout. Dropout is only active in training mode (see [`LoRALayer::train`]).
161 #[must_use]
162 pub fn with_dropout(mut self, p: f32) -> Self {
163 // Clamp to [0, 1): p == 1.0 would zero everything and divide by zero in
164 // the inverted-dropout scale, which is never a valid configuration.
165 self.dropout = p.clamp(0.0, 0.999_999);
166 self
167 }
168
169 /// Set the deterministic dropout RNG seed (PMAT-879).
170 ///
171 /// With a fixed seed, dropout masks are fully reproducible, which makes the
172 /// training-mode forward path testable.
173 #[must_use]
174 pub fn with_dropout_seed(mut self, seed: u64) -> Self {
175 self.dropout_seed = seed;
176 self
177 }
178
179 /// Switch the layer to training mode. Dropout is active when `dropout > 0.0`.
180 pub fn train(&mut self) {
181 self.training = true;
182 }
183
184 /// Switch the layer to evaluation mode (the default). Dropout is the identity,
185 /// so inference output is unchanged — matching PEFT `nn.Dropout` in eval.
186 pub fn eval(&mut self) {
187 self.training = false;
188 }
189
190 /// Set training/eval mode explicitly.
191 pub fn set_training(&mut self, training: bool) {
192 self.training = training;
193 }
194
195 /// Whether the layer is in training mode.
196 pub fn is_training(&self) -> bool {
197 self.training
198 }
199
200 /// LoRA dropout probability.
201 pub fn dropout(&self) -> f32 {
202 self.dropout
203 }
204
205 /// Apply inverted dropout to the LoRA-branch input (PMAT-879).
206 ///
207 /// Returns `x` unchanged when not in training mode or when `p == 0.0` (the
208 /// identity), exactly mirroring PEFT's `nn.Dropout`/`nn.Identity` placement.
209 /// Otherwise each element is independently zeroed with probability `p` and the
210 /// survivors are scaled by `1/(1-p)` so the expectation is preserved.
211 ///
212 /// Uses a deterministic RNG seeded from `(dropout_seed, dropout_step)` so the
213 /// mask is reproducible for a given seed while advancing per forward call.
214 fn apply_input_dropout(&self, x: &Tensor) -> Tensor {
215 if !self.training || self.dropout <= 0.0 {
216 return x.clone();
217 }
218
219 use rand::rngs::StdRng;
220 use rand::{Rng, SeedableRng};
221
222 let step = self.dropout_step.get();
223 self.dropout_step.set(step.wrapping_add(1));
224
225 // Mix seed and step so each forward draws a fresh, reproducible mask.
226 let mixed = self.dropout_seed ^ step.wrapping_mul(0x9E37_79B9_7F4A_7C15);
227 let mut rng = StdRng::seed_from_u64(mixed);
228
229 let keep = 1.0 - self.dropout;
230 let inv_keep = 1.0 / keep;
231
232 let dropped: Vec<f32> = x
233 .data()
234 .iter()
235 .map(|&v| if rng.random::<f32>() < self.dropout { 0.0 } else { v * inv_keep })
236 .collect();
237
238 Tensor::from_vec(dropped, x.requires_grad())
239 }
240
241 /// Forward pass: y = W@x + scale * (B @ (A @ dropout(x)))
242 ///
243 /// # Arguments
244 /// * `x` - Input tensor `[d_in]`
245 ///
246 /// # Returns
247 /// Output tensor `[d_out]`
248 pub fn forward(&self, x: &Tensor) -> Tensor {
249 assert_eq!(x.len(), self.d_in, "Input size must match d_in");
250
251 // Base forward: W @ x [d_out, d_in] @ [d_in, 1] -> [d_out, 1]
252 let base_output = matmul(&self.base_weight, x, self.d_out, self.d_in, 1);
253
254 if self.merged {
255 // If merged, W already includes LoRA adaptation
256 base_output
257 } else {
258 // LoRA forward: scale * (B @ (A @ dropout(x)))
259 // PEFT placement: dropout is applied to the INPUT x before A.
260 // In eval mode (default) or with dropout == 0.0 this is the identity.
261 let dropped_x = self.apply_input_dropout(x);
262
263 // Step 1: A @ dropout(x) [r, d_in] @ [d_in, 1] -> [r, 1]
264 let lora_out_a = matmul(&self.lora_a, &dropped_x, self.rank, self.d_in, 1);
265
266 // Step 2: B @ (A @ x) [d_out, r] @ [r, 1] -> [d_out, 1]
267 let lora_out_b = matmul(&self.lora_b, &lora_out_a, self.d_out, self.rank, 1);
268
269 // Step 3: scale * LoRA output
270 let mut scaled_lora_data = lora_out_b.data().to_owned();
271 for val in &mut scaled_lora_data {
272 *val *= self.scale;
273 }
274 let scaled_lora = Tensor::new(scaled_lora_data, false);
275
276 // Step 4: base + LoRA
277 let mut result_data = base_output.data().to_owned();
278 for (i, val) in result_data.iter_mut().enumerate() {
279 *val += scaled_lora.data()[i];
280 }
281 Tensor::new(result_data, base_output.requires_grad())
282 }
283 }
284
285 /// Merge LoRA weights into base weight: W' = W + scale * (B @ A)
286 ///
287 /// After merging, forward pass only uses W' (more efficient).
288 /// This is typically done for inference.
289 pub fn merge(&mut self) {
290 if self.merged {
291 return; // Already merged
292 }
293
294 // Compute B @ A [d_out, r] @ [r, d_in] -> [d_out, d_in]
295 let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
296
297 // Scale and add to base weight: W' = W + scale * B @ A
298 for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
299 *val += self.scale * ba.data()[i];
300 }
301
302 self.merged = true;
303 }
304
305 /// Unmerge LoRA weights from base weight: W = W' - scale * (B @ A)
306 ///
307 /// Reverses the merge operation. Useful for continuing training or
308 /// switching adapters.
309 pub fn unmerge(&mut self) {
310 if !self.merged {
311 return; // Not merged
312 }
313
314 // Compute B @ A
315 let ba = matmul(&self.lora_b, &self.lora_a, self.d_out, self.rank, self.d_in);
316
317 // Subtract from base weight: W = W' - scale * B @ A
318 for (i, val) in self.base_weight.data_mut().iter_mut().enumerate() {
319 *val -= self.scale * ba.data()[i];
320 }
321
322 self.merged = false;
323 }
324
325 /// Get reference to base weight matrix
326 pub fn base_weight(&self) -> &Tensor {
327 &self.base_weight
328 }
329
330 /// Get reference to LoRA A matrix
331 pub fn lora_a(&self) -> &Tensor {
332 &self.lora_a
333 }
334
335 /// Get mutable reference to LoRA A matrix
336 pub fn lora_a_mut(&mut self) -> &mut Tensor {
337 &mut self.lora_a
338 }
339
340 /// Get reference to LoRA B matrix
341 pub fn lora_b(&self) -> &Tensor {
342 &self.lora_b
343 }
344
345 /// Get mutable reference to LoRA B matrix
346 pub fn lora_b_mut(&mut self) -> &mut Tensor {
347 &mut self.lora_b
348 }
349
350 /// Get trainable parameters (A and B)
351 pub fn trainable_params(&mut self) -> Vec<&mut Tensor> {
352 vec![&mut self.lora_a, &mut self.lora_b]
353 }
354
355 /// Check if LoRA is merged
356 pub fn is_merged(&self) -> bool {
357 self.merged
358 }
359
360 /// Get rank
361 pub fn rank(&self) -> usize {
362 self.rank
363 }
364
365 /// Get scale factor
366 pub fn scale(&self) -> f32 {
367 self.scale
368 }
369
370 /// Get output dimension
371 pub fn d_out(&self) -> usize {
372 self.d_out
373 }
374
375 /// Get input dimension
376 pub fn d_in(&self) -> usize {
377 self.d_in
378 }
379}