aboutsummaryrefslogtreecommitdiffstats
path: root/src/train.rs
blob: 87cc629d015959afd0abb0594aa76fe133c724ea (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
use anyhow::Result;
use burn::optim::{AdamWConfig, GradientsParams, Optimizer};
use burn::prelude::*;
use burn::tensor::activation::log_softmax;
use burn::tensor::backend::AutodiffBackend;
use burn::tensor::TensorData;

use crate::data::{collate, Example};
use crate::model::Llama;

/// Causal cross-entropy with a loss mask.
///
/// The shift is the key part: position `i` predicts token `i+1`. And `mask`
/// makes only the assistant answer count, so the prompt adds no gradient.
/// This is exactly what llama.cpp's `llama-finetune` does not do.
pub fn masked_ce_loss<B: Backend>(
    logits: Tensor<B, 3>,
    ids: Tensor<B, 2, Int>,
    mask: Tensor<B, 2>,
) -> Tensor<B, 1> {
    let [b, t, v] = logits.dims();
    let n = b * (t - 1);

    let logits = logits.slice([0..b, 0..t - 1, 0..v]).reshape([n, v]);
    let targets = ids.slice([0..b, 1..t]).reshape([n, 1]);
    let mask = mask.slice([0..b, 1..t]).reshape([n, 1]);

    let logp = log_softmax(logits, 1).gather(1, targets);
    let denom = mask.clone().sum().clamp_min(1.0);

    -(logp * mask).sum() / denom
}

pub struct TrainCfg {
    pub steps: usize,
    pub batch_size: usize,
    pub lr: f64,
}

pub fn train<B: AutodiffBackend>(
    mut model: Llama<B>,
    examples: &[Example],
    cfg: TrainCfg,
    device: &B::Device,
) -> Result<Llama<B>> {
    let trainable = model.trainable_ids();
    println!(
        "training {} LoRA tensors on {} examples\n",
        trainable.len(),
        examples.len()
    );

    // B starts at zero. If it is still zero at the end, the gradients never
    // reached LoRA; if W_base changes, freezing failed.
    let norm_base = |m: &Llama<B>| -> f32 {
        m.layers[0]
            .attn
            .q
            .base
            .weight
            .val()
            .powf_scalar(2.0)
            .sum()
            .sqrt()
            .into_scalar()
            .elem()
    };
    println!(
        "before -> |B| = {:.6}   |W_base| = {:.4}",
        model.lora_norm(),
        norm_base(&model)
    );

    let mut optim = AdamWConfig::new().init::<B, Llama<B>>();
    let mut cursor = 0usize;

    for step in 1..=cfg.steps {
        if cursor + cfg.batch_size > examples.len() {
            cursor = 0;
        }
        let batch = collate(&examples[cursor..cursor + cfg.batch_size]);
        cursor += cfg.batch_size;

        let dims = [batch.batch, batch.seq];
        let ids = Tensor::<B, 2, Int>::from_data(TensorData::new(batch.ids, dims), device);
        let mask = Tensor::<B, 2>::from_data(TensorData::new(batch.mask, dims), device);

        let logits = model.forward(ids.clone());
        let loss = masked_ce_loss(logits, ids, mask);
        let value: f32 = loss.clone().into_scalar().elem();

        let mut grads = loss.backward();
        // The heart of LoRA: only the gradients of the A and B tensors are
        // collected. The rest of the model is never touched.
        let grads = GradientsParams::from_params(&mut grads, &model, &trainable);
        model = optim.step(cfg.lr, model, grads);

        println!(
            "step {step:4}/{}  loss {value:.4}  seq {}",
            cfg.steps, batch.seq
        );
    }

    println!(
        "after  -> |B| = {:.6}   |W_base| = {:.4}",
        model.lora_norm(),
        norm_base(&model)
    );

    Ok(model)
}