Skip to main content

entrenar/train/transformer_trainer/
trainer.rs

1//! Transformer trainer implementation
2
3use crate::autograd::{checkpoint, GradScaler};
4use crate::io::{save_model, Model, ModelFormat, ModelMetadata, SaveConfig};
5use crate::lora::LoRALayer;
6use crate::optim::{clip_grad_norm_refs, AdamW, Optimizer};
7use crate::train::{CausalLMLoss, LossFn, MetricsTracker};
8use crate::transformer::Transformer;
9use crate::Tensor;
10use std::path::Path;
11
12use super::batch::LMBatch;
13use super::config::TransformerTrainConfig;
14
15/// Transformer training state
16pub struct TransformerTrainer {
17    /// Model
18    model: Transformer,
19    /// Loss function
20    loss_fn: CausalLMLoss,
21    /// Optimizer
22    optimizer: AdamW,
23    /// Gradient scaler for mixed precision
24    grad_scaler: GradScaler,
25    /// Configuration
26    config: TransformerTrainConfig,
27    /// Metrics tracker
28    pub metrics: MetricsTracker,
29    /// Current step
30    step: usize,
31    /// Accumulated gradients (for gradient accumulation)
32    accumulated_loss: f32,
33    /// Number of accumulated batches
34    accumulated_batches: usize,
35    /// LoRA layers (ENT-LoRA-001): [Q_0, V_0, Q_1, V_1, ...] per transformer layer
36    /// None = full fine-tuning, Some = LoRA fine-tuning
37    lora_layers: Option<Vec<LoRALayer>>,
38}
39
40impl TransformerTrainer {
41    /// Create a new transformer trainer
42    pub fn new(config: TransformerTrainConfig) -> Self {
43        // GATE-TRAIN-006 / INV-TRAIN-006: honor config.seed before weight init
44        // AND hold the init-seed lock for the full Transformer::new call so
45        // concurrent callers (parallel tests, concurrent harnesses) cannot
46        // clobber INIT_SEED between set and read. Previously only the YAML
47        // loader set this; direct TransformerTrainer::new callers silently
48        // inherited the global default (42), breaking seed reproducibility
49        // for any non-default seed.
50        let seed_guard = crate::transformer::init::lock_init_seed(config.seed);
51        let model = Transformer::new(&config.model_config);
52        drop(seed_guard);
53        Self::build(model, config)
54    }
55
56    /// Create trainer from existing model
57    pub fn with_model(model: Transformer, config: TransformerTrainConfig) -> Self {
58        Self::build(model, config)
59    }
60
61    /// Internal builder: initializes LoRA layers when config has LoRA enabled
62    fn build(model: Transformer, config: TransformerTrainConfig) -> Self {
63        let loss_fn = CausalLMLoss::new(config.model_config.vocab_size);
64        let optimizer = AdamW::default_params(config.lr);
65        let grad_scaler = GradScaler::from_config(&config.precision_config);
66
67        // ENT-LoRA-001: Create LoRA layers when config has LoRA rank
68        let lora_layers = if let Some(rank) = config.lora_rank {
69            let alpha = config.lora_alpha.unwrap_or(rank as f32 * 2.0);
70            let default_targets = vec!["q_proj".to_string(), "v_proj".to_string()];
71            // ENT-LoRA-005: Expand shorthand targets ("all_linear", "attention", etc.)
72            let raw_targets = config.lora_target_modules.as_deref().unwrap_or(&default_targets);
73            let expanded = crate::lora::LoRAConfig::expand_shorthand(raw_targets);
74            let target_modules = expanded.as_slice();
75
76            let mut layers = Vec::new();
77            let hidden_size = config.model_config.hidden_size;
78            let num_kv_heads = config.model_config.num_kv_heads;
79            let head_dim = config.model_config.head_dim();
80            let q_dim = config.model_config.q_dim();
81            let kv_hidden_size = num_kv_heads * head_dim;
82
83            let intermediate = config.model_config.intermediate_size;
84
85            for block in &model.layers {
86                // Attention projections (ENT-LoRA-005: flexible targets)
87                if target_modules.iter().any(|m| m == "q_proj") {
88                    layers.push(LoRALayer::new(
89                        block.self_attn.w_q.clone(),
90                        q_dim,
91                        hidden_size,
92                        rank,
93                        alpha,
94                    ));
95                }
96                if target_modules.iter().any(|m| m == "k_proj") {
97                    layers.push(LoRALayer::new(
98                        block.self_attn.w_k.clone(),
99                        kv_hidden_size,
100                        hidden_size,
101                        rank,
102                        alpha,
103                    ));
104                }
105                if target_modules.iter().any(|m| m == "v_proj") {
106                    layers.push(LoRALayer::new(
107                        block.self_attn.w_v.clone(),
108                        kv_hidden_size,
109                        hidden_size,
110                        rank,
111                        alpha,
112                    ));
113                }
114                if target_modules.iter().any(|m| m == "o_proj") {
115                    layers.push(LoRALayer::new(
116                        block.self_attn.w_o.clone(),
117                        hidden_size,
118                        q_dim,
119                        rank,
120                        alpha,
121                    ));
122                }
123                // MLP projections (ENT-LoRA-005)
124                if target_modules.iter().any(|m| m == "gate_proj") {
125                    layers.push(LoRALayer::new(
126                        block.ffn.w_gate.clone(),
127                        intermediate,
128                        hidden_size,
129                        rank,
130                        alpha,
131                    ));
132                }
133                if target_modules.iter().any(|m| m == "up_proj") {
134                    layers.push(LoRALayer::new(
135                        block.ffn.w_up.clone(),
136                        intermediate,
137                        hidden_size,
138                        rank,
139                        alpha,
140                    ));
141                }
142                if target_modules.iter().any(|m| m == "down_proj") {
143                    layers.push(LoRALayer::new(
144                        block.ffn.w_down.clone(),
145                        hidden_size,
146                        intermediate,
147                        rank,
148                        alpha,
149                    ));
150                }
151            }
152
153            let lora_param_count: usize =
154                layers.iter().map(|l| l.rank() * (l.d_in() + l.d_out())).sum();
155            let total_params: usize = model.parameters().iter().map(|p| p.len()).sum();
156            println!(
157                "  LoRA enabled: rank={rank}, alpha={alpha}, \
158                 {lora_param_count} trainable params ({:.2}% of {total_params})",
159                100.0 * lora_param_count as f64 / total_params as f64
160            );
161
162            Some(layers)
163        } else {
164            None
165        };
166
167        Self {
168            model,
169            loss_fn,
170            optimizer,
171            grad_scaler,
172            config,
173            metrics: MetricsTracker::new(),
174            step: 0,
175            accumulated_loss: 0.0,
176            accumulated_batches: 0,
177            lora_layers,
178        }
179    }
180
181    /// Forward pass on a single batch item
182    ///
183    /// Returns (loss_value, loss_tensor, logits)
184    /// When LoRA is active, routes through `forward_with_lora` so only
185    /// LoRA adapter gradients are accumulated.
186    pub fn forward_single(&self, input_ids: &[u32], target_ids: &[u32]) -> (f32, Tensor, Tensor) {
187        // Forward through transformer (LoRA or full)
188        let logits = if let Some(ref lora) = self.lora_layers {
189            // ENT-LoRA-001: Use LoRA forward path
190            self.model.forward_with_lora(input_ids, lora)
191        } else if self.config.checkpoint_config.enabled {
192            checkpoint(|_| self.model.forward(input_ids), &Tensor::zeros(1, false))
193        } else {
194            self.model.forward(input_ids)
195        };
196
197        // Compute loss
198        let targets = Tensor::from_vec(target_ids.iter().map(|&id| id as f32).collect(), false);
199        let loss = self.loss_fn.forward(&logits, &targets);
200        let loss_val = loss.data()[0];
201
202        (loss_val, loss, logits)
203    }
204
205    /// Compute forward + backward for all items in a batch, returning average loss.
206    fn compute_batch_gradients(&self, batch: &LMBatch) -> f32 {
207        let mut total_loss = 0.0;
208
209        for i in 0..batch.batch_size {
210            let Some(input_ids) = batch.get_input(i) else {
211                continue;
212            };
213            let Some(target_ids) = batch.get_target(i) else {
214                continue;
215            };
216
217            let (loss_val, loss, _logits) = self.forward_single(input_ids, target_ids);
218
219            if let Some(backward_op) = loss.backward_op() {
220                backward_op.backward();
221            }
222
223            total_loss += loss_val / self.config.accumulation_steps as f32;
224        }
225
226        total_loss / batch.batch_size as f32
227    }
228
229    /// Apply gradient clipping and run the optimizer step, then reset accumulation.
230    fn clip_and_step(&mut self) {
231        // ENT-LoRA-002: Only update trainable params (LoRA A/B + norms when active)
232        if let Some(ref mut lora) = self.lora_layers {
233            // ENT-LoRA-006: LoRA+ gradient scaling for B matrices
234            let ratio = self.config.lora_plus_ratio;
235            if ratio != 1.0 {
236                for layer in lora.iter_mut() {
237                    if let Some(grad) = layer.lora_b_mut().grad() {
238                        let scaled = grad.mapv(|g| g * ratio);
239                        layer.lora_b_mut().set_grad(scaled);
240                    }
241                }
242            }
243
244            let mut params: Vec<&mut Tensor> =
245                lora.iter_mut().flat_map(|l| l.trainable_params()).collect();
246            // Also include norm weights (small, critical for adaptation)
247            for layer in &mut self.model.layers {
248                params.push(&mut layer.input_norm.weight);
249                params.push(&mut layer.post_attn_norm.weight);
250            }
251            params.push(&mut self.model.norm.weight);
252            // Clip the gradients ACTUALLY being optimized (torch clip_grad_norm_) BEFORE
253            // the step. Previously the clip coefficient was computed then discarded (no-op).
254            if let Some(max_norm) = self.config.base.max_grad_norm {
255                clip_grad_norm_refs(&mut params, max_norm);
256            }
257            self.optimizer.step_refs(&mut params);
258        } else {
259            let mut params = self.model.parameters_mut();
260            if let Some(max_norm) = self.config.base.max_grad_norm {
261                clip_grad_norm_refs(&mut params, max_norm);
262            }
263            self.optimizer.step_refs(&mut params);
264        }
265
266        self.step += 1;
267        self.metrics.losses.push(self.accumulated_loss);
268        self.metrics.increment_step();
269
270        self.accumulated_loss = 0.0;
271        self.accumulated_batches = 0;
272    }
273
274    /// Process a batch (forward + backward + optimizer step)
275    ///
276    /// Returns average loss for the batch
277    pub fn train_batch(&mut self, batch: &LMBatch) -> f32 {
278        if batch.batch_size == 0 {
279            return 0.0;
280        }
281
282        if self.accumulated_batches == 0 {
283            // ENT-LoRA-002: Zero grad only on trainable params (LoRA A/B + norms)
284            if let Some(ref mut lora) = self.lora_layers {
285                let mut params: Vec<&mut Tensor> =
286                    lora.iter_mut().flat_map(|l| l.trainable_params()).collect();
287                for layer in &mut self.model.layers {
288                    params.push(&mut layer.input_norm.weight);
289                    params.push(&mut layer.post_attn_norm.weight);
290                }
291                params.push(&mut self.model.norm.weight);
292                self.optimizer.zero_grad_refs(&mut params);
293            } else {
294                let mut params = self.model.parameters_mut();
295                self.optimizer.zero_grad_refs(&mut params);
296            }
297        }
298
299        let avg_loss = self.compute_batch_gradients(batch);
300
301        self.accumulated_loss += avg_loss;
302        self.accumulated_batches += 1;
303
304        if self.accumulated_batches >= self.config.accumulation_steps {
305            self.clip_and_step();
306        }
307
308        avg_loss
309    }
310
311    /// Train for one epoch over batches
312    pub fn train_epoch(&mut self, batches: &[LMBatch]) -> f32 {
313        self.train_epoch_with_callback(batches, |_, _, _| {})
314    }
315
316    /// Train for one epoch with a per-step callback.
317    ///
318    /// The callback receives (batch_index, batch_loss, &self) after each batch.
319    /// Use this for progress logging, checkpointing, or early stopping.
320    ///
321    /// Stops early if `max_steps` is set and the step count reaches it.
322    /// Returns `(avg_loss, reached_max_steps)`.
323    pub fn train_epoch_with_callback<F>(&mut self, batches: &[LMBatch], mut on_batch: F) -> f32
324    where
325        F: FnMut(usize, f32, &Self),
326    {
327        if batches.is_empty() {
328            return 0.0;
329        }
330
331        let mut total_loss = 0.0;
332        let mut batches_processed = 0;
333
334        for (i, batch) in batches.iter().enumerate() {
335            // Check max_steps before processing
336            if let Some(max) = self.config.max_steps {
337                if self.step >= max {
338                    break;
339                }
340            }
341
342            let batch_loss = self.train_batch(batch);
343            total_loss += batch_loss;
344            batches_processed += 1;
345            on_batch(i, batch_loss, self);
346        }
347
348        total_loss / batches_processed.max(1) as f32
349    }
350
351    /// Returns true if max_steps has been reached.
352    pub fn reached_max_steps(&self) -> bool {
353        self.config.max_steps.is_some_and(|max| self.step >= max)
354    }
355
356    /// Get current step count
357    pub fn step(&self) -> usize {
358        self.step
359    }
360
361    /// Get reference to model
362    pub fn model(&self) -> &Transformer {
363        &self.model
364    }
365
366    /// Get mutable reference to model
367    pub fn model_mut(&mut self) -> &mut Transformer {
368        &mut self.model
369    }
370
371    /// Get current learning rate (with warmup applied)
372    pub fn current_lr(&self) -> f32 {
373        let base_lr = self.config.lr;
374
375        if self.step < self.config.warmup_steps {
376            // Linear warmup
377            base_lr * (self.step as f32 / self.config.warmup_steps as f32)
378        } else {
379            base_lr
380        }
381    }
382
383    /// Get gradient scaler stats
384    pub fn grad_scaler_stats(&self) -> (f32, usize, usize) {
385        (
386            self.grad_scaler.scale(),
387            self.grad_scaler.overflow_count(),
388            self.grad_scaler.successful_steps(),
389        )
390    }
391
392    /// Check if using mixed precision
393    pub fn is_mixed_precision(&self) -> bool {
394        self.config.precision_config.is_mixed()
395    }
396
397    /// Check if using gradient checkpointing
398    pub fn is_checkpointing(&self) -> bool {
399        self.config.checkpoint_config.enabled
400    }
401
402    /// Check if LoRA training is active
403    pub fn is_lora(&self) -> bool {
404        self.lora_layers.is_some()
405    }
406
407    /// Get reference to LoRA layers (for checkpoint saving)
408    pub fn lora_layers(&self) -> Option<&[LoRALayer]> {
409        self.lora_layers.as_deref()
410    }
411
412    /// Get mutable reference to LoRA layers
413    pub fn lora_layers_mut(&mut self) -> Option<&mut Vec<LoRALayer>> {
414        self.lora_layers.as_mut()
415    }
416
417    /// Save LoRA adapter in PEFT-compatible format (ENT-LoRA-003)
418    ///
419    /// Saves only LoRA A/B weights as `adapter_model.safetensors` + `adapter_config.json`.
420    /// Adapter checkpoint is typically <1% of full model size.
421    ///
422    /// # Arguments
423    /// * `output_dir` - Directory to save adapter files
424    /// * `base_model_name` - Optional HuggingFace model ID for adapter_config.json
425    ///
426    /// # Errors
427    /// Returns error if not in LoRA mode or I/O fails.
428    pub fn save_lora_adapter(
429        &self,
430        output_dir: impl AsRef<Path>,
431        base_model_name: Option<&str>,
432    ) -> crate::Result<()> {
433        let lora = self.lora_layers.as_ref().ok_or_else(|| {
434            crate::error::Error::ConfigError("Cannot save adapter: LoRA not enabled".into())
435        })?;
436
437        let rank = self.config.lora_rank.unwrap_or(8);
438        let alpha = self.config.lora_alpha.unwrap_or(rank as f32 * 2.0);
439        let target_modules = self
440            .config
441            .lora_target_modules
442            .clone()
443            .unwrap_or_else(|| vec!["q_proj".to_string(), "v_proj".to_string()]);
444
445        // ENT-LoRA-005: Expand shorthand targets for correct naming
446        let expanded = crate::lora::LoRAConfig::expand_shorthand(&target_modules);
447        let lora_config = crate::lora::LoRAConfig::new(rank, alpha)
448            .target_modules(&expanded.iter().map(String::as_str).collect::<Vec<_>>());
449
450        // ENT-LoRA-007: Build named adapter pairs with correct PEFT naming
451        // Layers are ordered per build(): [q, k, v, o, gate, up, down] per block
452        let num_layers = self.model.layers.len();
453
454        // Map target module names to their layer path prefix
455        let module_paths: Vec<(&str, &str)> = [
456            ("q_proj", "self_attn.q_proj"),
457            ("k_proj", "self_attn.k_proj"),
458            ("v_proj", "self_attn.v_proj"),
459            ("o_proj", "self_attn.o_proj"),
460            ("gate_proj", "mlp.gate_proj"),
461            ("up_proj", "mlp.up_proj"),
462            ("down_proj", "mlp.down_proj"),
463        ]
464        .iter()
465        .filter(|(name, _)| expanded.iter().any(|t| t == *name))
466        .copied()
467        .collect();
468
469        // Generate full path names for each (block, module) pair
470        let all_names: Vec<String> = (0..num_layers)
471            .flat_map(|i| {
472                module_paths.iter().map(move |(_, path)| format!("model.layers.{i}.{path}"))
473            })
474            .collect();
475
476        let mut adapters: Vec<(&str, &LoRALayer)> = Vec::new();
477        for (idx, layer) in lora.iter().enumerate() {
478            if idx < all_names.len() {
479                adapters.push((&all_names[idx], layer));
480            }
481        }
482
483        crate::lora::save_adapter_peft(&adapters, &lora_config, base_model_name, output_dir)
484            .map_err(|e| crate::error::Error::Io(e.to_string()))
485    }
486
487    /// Save model weights to a SafeTensors file
488    ///
489    /// This persists the trained transformer weights to disk.
490    /// Call this after training completes to preserve the learned parameters.
491    ///
492    /// # Arguments
493    ///
494    /// * `path` - Output file path (should end in .safetensors)
495    /// * `name` - Model name for metadata
496    /// * `architecture` - Model architecture description (e.g., "Qwen2ForCausalLM")
497    ///
498    /// # Errors
499    ///
500    /// Returns an error if the file cannot be written.
501    pub fn save(
502        &self,
503        path: impl AsRef<Path>,
504        name: &str,
505        architecture: &str,
506    ) -> crate::Result<()> {
507        // Use named_parameters() for correct name mapping (handles attention biases etc.)
508        let params: Vec<(String, Tensor)> = self
509            .model
510            .named_parameters()
511            .into_iter()
512            .map(|(name, tensor)| (name, tensor.clone()))
513            .collect();
514
515        let metadata = ModelMetadata::new(name, architecture);
516        let model = Model::new(metadata, params);
517        let config = SaveConfig::new(ModelFormat::SafeTensors);
518
519        save_model(&model, path, &config)
520    }
521
522    /// Save model weights in the sovereign APR format.
523    ///
524    /// Mirror of `CudaTransformerTrainer::save_apr` for the CPU path.
525    /// APR is the row-major atomic single-file format shared across
526    /// training and inference (per aprender-train CLAUDE.md LAYOUT-002
527    /// mandate), so training checkpoints emitted by `PretrainLoop`
528    /// load directly in realizar / `apr run` with no re-transpose.
529    ///
530    /// # Arguments
531    ///
532    /// * `path` - Output file path (should end in `.apr`)
533    /// * `name` - Model name for metadata
534    /// * `architecture` - Model architecture description
535    ///   (e.g., `"LlamaForCausalLM"`)
536    pub fn save_apr(
537        &self,
538        path: impl AsRef<Path>,
539        name: &str,
540        architecture: &str,
541    ) -> crate::Result<()> {
542        let params: Vec<(String, Tensor)> =
543            self.model.named_parameters().into_iter().map(|(n, t)| (n, t.clone())).collect();
544        let metadata = ModelMetadata::new(name, architecture);
545        let model = Model::new(metadata, params);
546        let config = SaveConfig::new(ModelFormat::Apr);
547        save_model(&model, path, &config)
548    }
549
550    /// sha256 over the AdamW optimizer state bytes (INV-TRAIN-003).
551    ///
552    /// Hashes `(t, m_buffers, v_buffers)` in fixed order so two runs
553    /// with matching hyperparameters, seed, and batch order produce
554    /// the same digest (GATE-TRAIN-006 reproducibility).
555    ///
556    /// Uninitialized buffers (before the first step) hash to the
557    /// tag `"none"` so they still participate deterministically in
558    /// the digest — missing `m[i]` is semantically distinct from
559    /// an all-zeros `m[i]`.
560    #[must_use]
561    pub fn optimizer_state_sha256(&self) -> String {
562        use sha2::{Digest, Sha256};
563        let mut hasher = Sha256::new();
564        hasher.update(b"aprender-train:adamw:optstate:v1");
565        hasher.update(self.optimizer.step_count().to_le_bytes());
566        let moment_streams: [(&[u8], &[Option<ndarray::Array1<f32>>]); 2] =
567            [(b"m", self.optimizer.first_moments()), (b"v", self.optimizer.second_moments())];
568        for (tag, buffers) in moment_streams {
569            hasher.update(tag);
570            hasher.update((buffers.len() as u64).to_le_bytes());
571            for slot in buffers {
572                match slot {
573                    Some(arr) => {
574                        hasher.update(b"some");
575                        hasher.update((arr.len() as u64).to_le_bytes());
576                        let bytes: &[u8] = bytemuck::cast_slice(
577                            arr.as_slice().expect("AdamW buffers are contiguous"),
578                        );
579                        hasher.update(bytes);
580                    }
581                    None => hasher.update(b"none"),
582                }
583            }
584        }
585        format!("{:x}", hasher.finalize())
586    }
587}
588
589#[cfg(test)]
590mod clip_and_step_tests {
591    use super::*;
592    use crate::transformer::TransformerConfig;
593
594    /// FALSIFY-TRAINER-GRADCLIP-001 (PMAT-829): `clip_and_step` computed the clip
595    /// coefficient and then DISCARDED it (`let _ = scale;`), so `--grad-clip` /
596    /// `with_grad_clip(..)` was a silent no-op on the CPU trainer — training ran with raw,
597    /// unclipped gradients (divergence risk), while the WGPU trainer clips correctly
598    /// (silent CPU-vs-GPU divergence). Prior tests only asserted the config field
599    /// (`base.max_grad_norm`), never that gradients are actually clipped at step time.
600    #[test]
601    fn falsify_clip_and_step_actually_clips_gradients() {
602        let config = TransformerTrainConfig::new(TransformerConfig::tiny())
603            .with_lr(0.001)
604            .with_grad_clip(1.0);
605        let mut trainer = TransformerTrainer::new(config);
606
607        // Inject a known oversized gradient (global norm >> max_norm=1.0) on every param.
608        for p in trainer.model.parameters() {
609            p.set_grad(ndarray::Array1::from_elem(p.len(), 10.0_f32));
610        }
611        let norm = |t: &TransformerTrainer| -> f32 {
612            t.model
613                .parameters()
614                .iter()
615                .filter_map(|p| p.grad())
616                .map(|g| g.iter().map(|x| x * x).sum::<f32>())
617                .sum::<f32>()
618                .sqrt()
619        };
620        let before = norm(&trainer);
621        assert!(before > 1.0, "precondition: injected grad norm {before} must exceed max_norm 1.0");
622
623        trainer.clip_and_step();
624
625        // After clip_and_step the global grad norm MUST be clipped to ~max_norm (1.0).
626        // RED pre-fix: grads untouched (norm == `before` >> 1.0). GREEN post-fix: ~1.0.
627        let after = norm(&trainer);
628        assert!(
629            after <= 1.0 + 1e-2,
630            "clip_and_step did not clip gradients: global norm = {after} (expected <= 1.0); --grad-clip is a no-op"
631        );
632    }
633}