Skip to main content

burn_core/module/
lora.rs

1use alloc::{
2    boxed::Box,
3    string::{String, ToString},
4    vec,
5    vec::Vec,
6};
7use burn_tensor::{Distribution, Tensor};
8
9use crate::module::{LoraAdapter, ModuleMapper, Param, ParamGroup, Quantizer};
10
11/// Configuration describing how to attach LoRA adapters to a module's weights.
12#[derive(Debug, Clone)]
13pub struct LoraConfig {
14    /// Rank of the low-rank decomposition.
15    pub rank: usize,
16    /// Scaling numerator; the adapter contribution is scaled by `alpha / rank`.
17    pub alpha: f64,
18    /// Standard deviation used to initialize the `A` factor. Defaults to `1 / rank`.
19    pub init_std: Option<f64>,
20    /// The parameter group on which to apply the LoRA.
21    pub param_group: ParamGroup,
22}
23
24impl LoraConfig {
25    /// Create a new LoRA configuration with the given rank and alpha.
26    pub fn new(rank: usize, alpha: f64) -> Self {
27        Self {
28            rank,
29            alpha,
30            init_std: None,
31            param_group: ParamGroup::all(),
32        }
33    }
34
35    /// Set the parameter group to quantize on which to apply the LoRA.
36    pub fn set_param_group(mut self, group: ParamGroup) -> Self {
37        self.param_group = group;
38        self
39    }
40}
41
42/// A [module mapper](ModuleMapper) that attaches LoRA adapters to 2-D weight parameters.
43///
44/// Apply it the same way as quantization:
45///
46/// ```rust,ignore
47/// let model = model.map(&mut LoraMapper::new(LoraConfig::new(8, 16.0)));
48/// ```
49///
50/// Each rank-2 float weight is frozen and given a trainable low-rank [adapter](LoraAdapter); other
51/// parameters are left untouched. No model or layer code needs to change — the same `Linear` (and
52/// any other module) keeps working, now producing `base + scale * (a @ b)` for adapted weights.
53#[derive(Debug, Clone)]
54pub struct LoraMapper {
55    config: LoraConfig,
56    path: Vec<String>,
57}
58
59impl LoraMapper {
60    /// Create a new mapper from the given configuration.
61    pub fn new(config: LoraConfig) -> Self {
62        Self {
63            config,
64            path: vec![],
65        }
66    }
67
68    /// Specify a parameter group on which to apply the LoRA.
69    pub fn for_group(mut self, group: ParamGroup) -> Self {
70        self.config.param_group = group;
71        self
72    }
73}
74
75impl ModuleMapper for LoraMapper {
76    fn enter_module(&mut self, name: &str, _container_type: &str) {
77        self.path.push(name.to_string());
78    }
79
80    fn exit_module(&mut self, _name: &str, _container_type: &str) {
81        self.path.pop();
82    }
83
84    fn map_float<const D: usize>(&mut self, param: Param<Tensor<D>>) -> Param<Tensor<D>> {
85        // LoRA only adapts 2-D weight matrices. Every other base parameter is frozen too, so that
86        // only the adapter factors are trained (the canonical LoRA fine-tuning contract).
87        if D != 2 {
88            let (id, tensor, mapper) = param.consume();
89            return Param::from_mapped_value(id, tensor.set_require_grad(false), mapper);
90        }
91
92        let rank = self.config.rank;
93        let (id, tensor, mapper) = param.consume();
94        let device = tensor.device();
95        let dims = tensor.dims();
96        let (d_in, d_out) = (dims[0], dims[1]);
97
98        // Freeze the base weight; only the adapter factors will be trained.
99        let base = Param::from_mapped_value(id, tensor.set_require_grad(false), mapper);
100
101        let path = self.path.join(".");
102        if self.config.param_group.matches(&id, Some(&path)) {
103            // Standard LoRA init: A ~ N(0, std) and B = 0, so the initial delta (and the model output)
104            // is unchanged when the adapter is first attached.
105            let std = self.config.init_std.unwrap_or(1.0 / rank as f64);
106            let a = Tensor::<2>::random([d_in, rank], Distribution::Normal(0.0, std), &device);
107            let b = Tensor::<2>::zeros([rank, d_out], &device);
108
109            let adapter = LoraAdapter {
110                a: Param::from_tensor(a),
111                b: Param::from_tensor(b),
112                scale: self.config.alpha / rank as f64,
113            };
114
115            return base.with_adapter(Some(Box::new(adapter)));
116        }
117
118        base
119    }
120}
121
122/// A [module mapper](ModuleMapper) implementing QLoRA: it quantizes the (frozen) base weights and
123/// attaches full-precision trainable LoRA adapters to 2-D weights.
124///
125/// The quantized base is kept at rest in its low-bit representation; the adapter contribution is
126/// added on top during the forward pass (the base is dequantized on the fly when composed).
127pub struct QLoraMapper {
128    lora: LoraMapper,
129    quantizer: Quantizer,
130}
131
132impl QLoraMapper {
133    /// Create a new QLoRA mapper from the LoRA configuration and a quantizer.
134    pub fn new(config: LoraConfig, quantizer: Quantizer) -> Self {
135        Self {
136            lora: LoraMapper::new(config),
137            quantizer,
138        }
139    }
140}
141
142impl ModuleMapper for QLoraMapper {
143    fn map_float<const D: usize>(&mut self, param: Param<Tensor<D>>) -> Param<Tensor<D>> {
144        // Quantize the frozen base weight first, then attach a trainable adapter to 2-D weights.
145        let param = self.quantizer.map_float(param);
146        self.lora.map_float(param)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    #[cfg(feature = "autodiff")]
154    use crate::module::AutodiffModule;
155    use crate::module::{Module, ParamId};
156    use crate::test_device;
157    use crate::test_utils::SimpleLinear;
158    use burn_tensor::Tolerance;
159
160    fn lora_model(in_features: usize, out_features: usize) -> (SimpleLinear, super::LoraConfig) {
161        let device = test_device();
162        let config = LoraConfig::new(2, 4.0);
163        let model =
164            SimpleLinear::new(in_features, out_features, &device).apply_lora(config.clone());
165        (model, config)
166    }
167
168    #[test]
169    fn compose_lora_matches_base_plus_delta() {
170        let device = test_device();
171        let (model, config) = lora_model(4, 6);
172
173        let weight = &model.weight;
174        let adapter = weight.adapter().expect("adapter should be attached");
175
176        // The effective value must equal base + scale * (a @ b).
177        let expected = weight.base() + adapter.delta();
178        weight
179            .val()
180            .into_data()
181            .assert_approx_eq::<f32>(&expected.into_data(), Tolerance::default());
182
183        // Scale is alpha / rank.
184        assert_eq!(adapter.scale, config.alpha / config.rank as f64);
185        let _ = device;
186    }
187
188    #[test]
189    fn lora_mapper_freezes_base_and_trains_adapter() {
190        let (model, _) = lora_model(4, 6);
191        let weight = &model.weight;
192        let adapter = weight.adapter().expect("adapter should be attached");
193
194        // Distinct parameter ids for base / a / b.
195        let ids = [weight.id, adapter.a.id, adapter.b.id];
196        assert_eq!(
197            ids.iter()
198                .collect::<alloc::collections::BTreeSet<&ParamId>>()
199                .len(),
200            3
201        );
202
203        // num_params includes the adapter factors:
204        // weight [6,4]=24, bias [6]=6, a [6,2]=12, b [2,4]=8.
205        assert_eq!(model.num_params(), 24 + 6 + 12 + 8);
206    }
207
208    #[test]
209    fn lora_b_is_zero_initialized_so_initial_delta_is_zero() {
210        let (model, _) = lora_model(4, 6);
211        let weight = &model.weight;
212        // B = 0 => delta = 0 => effective weight equals the (frozen) base at init.
213        weight
214            .val()
215            .into_data()
216            .assert_approx_eq::<f32>(&weight.base().into_data(), Tolerance::default());
217    }
218
219    #[test]
220    fn lora_record_roundtrip_preserves_base_and_adapter() {
221        let (model, config) = lora_model(4, 6);
222
223        // A freshly-prepared model has different random base/A and zero B.
224        let device = test_device();
225        let target = SimpleLinear::new(4, 6, &device).apply_lora(config);
226
227        let record = model.clone().into_record();
228        let loaded = target.load_record(record);
229
230        // Base, A and B must all be restored from the record (paths weight / weight.lora.a / .b).
231        loaded
232            .weight
233            .base()
234            .into_data()
235            .assert_eq(&model.weight.base().into_data(), true);
236        loaded
237            .weight
238            .adapter()
239            .unwrap()
240            .a
241            .val()
242            .into_data()
243            .assert_eq(&model.weight.adapter().unwrap().a.val().into_data(), true);
244        loaded
245            .weight
246            .val()
247            .into_data()
248            .assert_eq(&model.weight.val().into_data(), true);
249    }
250
251    #[cfg(feature = "autodiff")]
252    #[test]
253    fn lora_backward_grads_adapter_only() {
254        let device = test_device().autodiff();
255        let config = LoraConfig::new(2, 4.0);
256        let model = SimpleLinear::new(4, 6, &device).apply_lora(config);
257
258        // Forward through the composed weight and backpropagate.
259        let loss = model.weight.val().sum();
260        let grads = loss.backward();
261
262        let adapter = model.weight.adapter().unwrap();
263        // Adapter factors receive gradients; the frozen base does not.
264        assert!(adapter.a.val().grad(&grads).is_some());
265        assert!(adapter.b.val().grad(&grads).is_some());
266        assert!(model.weight.base().grad(&grads).is_none());
267    }
268
269    // #[cfg(not(feature = "tch"))]
270    #[test]
271    fn qlora_quantizes_base_and_attaches_adapter() {
272        use crate::module::Quantizer;
273        use burn_tensor::quantization::{Calibration, QuantLevel, QuantParam, QuantValue};
274
275        let device = test_device();
276        let scheme = device
277            .settings()
278            .quantization
279            .scheme
280            .with_value(QuantValue::Q8S)
281            .with_level(QuantLevel::Tensor)
282            .with_param(QuantParam::F32);
283        let quantizer = Quantizer::new(Calibration::MinMax, scheme);
284
285        let original = SimpleLinear::new(8, 8, &device).weight.val();
286
287        let model =
288            SimpleLinear::new(8, 8, &device).apply_qlora(LoraConfig::new(2, 4.0), quantizer);
289
290        let weight = &model.weight;
291        assert!(weight.adapter().is_some());
292
293        // The composed value (dequant(base) + delta) has the right shape and is finite. With B = 0
294        // the initial delta is zero, so it is just the dequantized base.
295        let composed = weight.val();
296        assert_eq!(composed.dims(), [8, 8]);
297        assert_eq!(composed.into_data().shape, original.into_data().shape);
298    }
299
300    #[test]
301    fn param_group_restricts_adapter_to_matching_parameters() {
302        use crate as burn;
303
304        #[derive(Module, Debug)]
305        struct TwoWeights {
306            a: Param<Tensor<2>>,
307            b: Param<Tensor<2>>,
308        }
309
310        let device = test_device();
311        let model = TwoWeights {
312            a: Param::from_tensor(Tensor::random(
313                [4, 4],
314                burn_tensor::Distribution::Default,
315                &device,
316            )),
317            b: Param::from_tensor(Tensor::random(
318                [4, 4],
319                burn_tensor::Distribution::Default,
320                &device,
321            )),
322        };
323
324        let group = ParamGroup::from_predicate("a");
325        let config = LoraConfig::new(2, 4.0).set_param_group(group);
326        let model = model.apply_lora(config);
327
328        // Only the parameter whose path matches the group gets an adapter attached.
329        assert!(
330            model.a.adapter().is_some(),
331            "parameter in the group should get a LoRA adapter"
332        );
333        assert!(
334            model.b.adapter().is_none(),
335            "parameter outside the group should not get a LoRA adapter"
336        );
337
338        // Every 2-D weight is frozen regardless of group membership.
339        assert!(!model.a.base().is_require_grad());
340        assert!(!model.b.val().is_require_grad());
341    }
342
343    #[cfg(feature = "autodiff")]
344    #[test]
345    fn lora_valid_folds_adapter_for_inference() {
346        let device = test_device().autodiff();
347        let config = LoraConfig::new(2, 4.0);
348        let model = SimpleLinear::new(4, 6, &device).apply_lora(config);
349
350        let inference = model.valid();
351        // The inference parameter has no adapter (folded) and equals the composed training weight.
352        assert!(inference.weight.adapter().is_none());
353        inference.weight.val().into_data().assert_approx_eq::<f32>(
354            &model.weight.val().inner().into_data(),
355            Tolerance::default(),
356        );
357    }
358}