1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::io::Write as _;
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct LayerGradientInfo {
19 pub name: String,
21 pub mean_abs_grad: f64,
23 pub max_abs_grad: f64,
25 pub grad_norm: f64,
27 pub is_vanishing: bool,
29 pub is_exploding: bool,
31 pub flow_intensity: f64,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GradientFrame {
38 pub step: u64,
40 pub layers: Vec<LayerGradientInfo>,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub enum GradientHealth {
51 Healthy,
52 MinorIssues,
53 ProblemsDetected,
54 Critical,
55}
56
57#[derive(Debug, Clone)]
59pub struct GradientFlowSummary {
60 pub total_steps: u64,
62 pub layers_with_vanishing_grads: Vec<String>,
64 pub layers_with_exploding_grads: Vec<String>,
66 pub overall_health: GradientHealth,
68 pub recommendations: Vec<String>,
70}
71
72pub struct GradientFlowAnimator {
78 frames: Vec<GradientFrame>,
79 max_frames: usize,
81 vanishing_threshold: f64,
83 exploding_threshold: f64,
85}
86
87impl GradientFlowAnimator {
88 pub fn new(max_frames: usize) -> Self {
92 Self {
93 frames: Vec::new(),
94 max_frames,
95 vanishing_threshold: 1e-7,
96 exploding_threshold: 1e3,
97 }
98 }
99
100 pub fn with_vanishing_threshold(mut self, threshold: f64) -> Self {
102 self.vanishing_threshold = threshold;
103 self
104 }
105
106 pub fn with_exploding_threshold(mut self, threshold: f64) -> Self {
108 self.exploding_threshold = threshold;
109 self
110 }
111
112 pub fn record_step(&mut self, step: u64, gradients: &HashMap<String, Vec<f64>>) {
116 let global_max_norm: f64 = gradients.values().map(|g| l2_norm(g)).fold(0.0_f64, f64::max);
118
119 let mut layers: Vec<LayerGradientInfo> = gradients
120 .iter()
121 .map(|(name, grad)| {
122 let mean_abs = mean_abs(grad);
123 let max_abs = max_abs(grad);
124 let norm = l2_norm(grad);
125 let is_vanishing = mean_abs < self.vanishing_threshold;
126 let is_exploding = max_abs > self.exploding_threshold;
127 let flow_intensity = if global_max_norm > 0.0 {
128 (norm / global_max_norm).clamp(0.0, 1.0)
129 } else {
130 0.0
131 };
132 LayerGradientInfo {
133 name: name.clone(),
134 mean_abs_grad: mean_abs,
135 max_abs_grad: max_abs,
136 grad_norm: norm,
137 is_vanishing,
138 is_exploding,
139 flow_intensity,
140 }
141 })
142 .collect();
143
144 layers.sort_by(|a, b| a.name.cmp(&b.name));
146
147 let frame = GradientFrame { step, layers };
148 self.frames.push(frame);
149
150 if self.max_frames > 0 && self.frames.len() > self.max_frames {
152 self.frames.remove(0);
153 }
154 }
155
156 pub fn frames(&self) -> &[GradientFrame] {
158 &self.frames
159 }
160
161 pub fn export_json(&self, path: &Path) -> Result<()> {
163 let json = serde_json::to_string_pretty(&self.frames)
164 .context("failed to serialise gradient frames")?;
165 if let Some(parent) = path.parent() {
166 std::fs::create_dir_all(parent).with_context(|| {
167 format!("failed to create output directory: {}", parent.display())
168 })?;
169 }
170 std::fs::write(path, json).with_context(|| {
171 format!(
172 "failed to write gradient animation JSON: {}",
173 path.display()
174 )
175 })?;
176 Ok(())
177 }
178
179 pub fn export_csv(&self, path: &Path) -> Result<()> {
181 if let Some(parent) = path.parent() {
182 std::fs::create_dir_all(parent).with_context(|| {
183 format!("failed to create output directory: {}", parent.display())
184 })?;
185 }
186
187 let mut file = std::fs::File::create(path)
188 .with_context(|| format!("failed to create CSV file: {}", path.display()))?;
189
190 writeln!(
191 file,
192 "step,layer,mean_abs_grad,max_abs_grad,grad_norm,is_vanishing,is_exploding"
193 )
194 .context("failed to write CSV header")?;
195
196 for frame in &self.frames {
197 for layer in &frame.layers {
198 writeln!(
199 file,
200 "{},{},{:.8e},{:.8e},{:.8e},{},{}",
201 frame.step,
202 layer.name,
203 layer.mean_abs_grad,
204 layer.max_abs_grad,
205 layer.grad_norm,
206 layer.is_vanishing as u8,
207 layer.is_exploding as u8,
208 )
209 .context("failed to write CSV row")?;
210 }
211 }
212 Ok(())
213 }
214
215 pub fn to_ascii_animation(&self) -> String {
220 if self.frames.is_empty() {
221 return "(no gradient frames recorded)\n".to_string();
222 }
223
224 let layer_names: Vec<String> = {
226 let mut seen: HashMap<&str, ()> = HashMap::new();
227 let mut names: Vec<String> = Vec::new();
228 for frame in &self.frames {
229 for layer in &frame.layers {
230 if seen.insert(layer.name.as_str(), ()).is_none() {
231 names.push(layer.name.clone());
232 }
233 }
234 }
235 names.sort();
236 names
237 };
238
239 let blocks = [' ', '░', '▒', '▓', '█'];
240 let max_name_len = layer_names.iter().map(|n| n.len()).max().unwrap_or(8);
241
242 let mut out = String::new();
243 out.push_str("Gradient Flow Animation (step → right, layer ↓)\n");
244 out.push_str(&format!("{:>width$} ", "layer", width = max_name_len));
245 for (i, _) in self.frames.iter().enumerate() {
246 out.push_str(&format!("{}", i % 10));
247 }
248 out.push('\n');
249 out.push_str(&"─".repeat(max_name_len + 2 + self.frames.len()));
250 out.push('\n');
251
252 for layer_name in &layer_names {
253 out.push_str(&format!("{:>width$} ", layer_name, width = max_name_len));
254 for frame in &self.frames {
255 let intensity = frame
256 .layers
257 .iter()
258 .find(|l| l.name == *layer_name)
259 .map(|l| l.flow_intensity)
260 .unwrap_or(0.0);
261 let idx = ((intensity * (blocks.len() - 1) as f64).round() as usize)
262 .min(blocks.len() - 1);
263 out.push(blocks[idx]);
264 }
265 out.push('\n');
266 }
267
268 out
269 }
270
271 pub fn summary_report(&self) -> GradientFlowSummary {
273 let total_steps = self.frames.last().map(|f| f.step + 1).unwrap_or(0);
274
275 let mut vanishing: HashMap<String, ()> = HashMap::new();
276 let mut exploding: HashMap<String, ()> = HashMap::new();
277
278 for frame in &self.frames {
279 for layer in &frame.layers {
280 if layer.is_vanishing {
281 vanishing.insert(layer.name.clone(), ());
282 }
283 if layer.is_exploding {
284 exploding.insert(layer.name.clone(), ());
285 }
286 }
287 }
288
289 let mut layers_with_vanishing_grads: Vec<String> = vanishing.into_keys().collect();
290 layers_with_vanishing_grads.sort();
291 let mut layers_with_exploding_grads: Vec<String> = exploding.into_keys().collect();
292 layers_with_exploding_grads.sort();
293
294 let overall_health = classify_health(
295 &layers_with_vanishing_grads,
296 &layers_with_exploding_grads,
297 &self.frames,
298 );
299
300 let recommendations = build_recommendations(
301 &overall_health,
302 &layers_with_vanishing_grads,
303 &layers_with_exploding_grads,
304 );
305
306 GradientFlowSummary {
307 total_steps,
308 layers_with_vanishing_grads,
309 layers_with_exploding_grads,
310 overall_health,
311 recommendations,
312 }
313 }
314}
315
316fn mean_abs(values: &[f64]) -> f64 {
321 if values.is_empty() {
322 return 0.0;
323 }
324 values.iter().map(|v| v.abs()).sum::<f64>() / values.len() as f64
325}
326
327fn max_abs(values: &[f64]) -> f64 {
328 values.iter().map(|v| v.abs()).fold(0.0_f64, f64::max)
329}
330
331fn l2_norm(values: &[f64]) -> f64 {
332 values.iter().map(|v| v * v).sum::<f64>().sqrt()
333}
334
335fn classify_health(
336 vanishing: &[String],
337 exploding: &[String],
338 frames: &[GradientFrame],
339) -> GradientHealth {
340 let issue_frames = frames
342 .iter()
343 .filter(|f| f.layers.iter().any(|l| l.is_vanishing || l.is_exploding))
344 .count();
345 let total = frames.len().max(1);
346 let issue_ratio = issue_frames as f64 / total as f64;
347
348 if !exploding.is_empty() && issue_ratio > 0.5 {
349 return GradientHealth::Critical;
350 }
351 if !exploding.is_empty() || issue_ratio > 0.3 {
352 return GradientHealth::ProblemsDetected;
353 }
354 if !vanishing.is_empty() || issue_ratio > 0.1 {
355 return GradientHealth::MinorIssues;
356 }
357 GradientHealth::Healthy
358}
359
360fn build_recommendations(
361 health: &GradientHealth,
362 vanishing: &[String],
363 exploding: &[String],
364) -> Vec<String> {
365 let mut recs = Vec::new();
366
367 if !vanishing.is_empty() {
368 recs.push(format!(
369 "Vanishing gradients detected in: {}. Consider residual connections, layer normalisation, or a larger learning rate.",
370 vanishing.join(", ")
371 ));
372 recs.push("Investigate weight initialisation — Xavier or Kaiming init can prevent early vanishing.".to_string());
373 }
374 if !exploding.is_empty() {
375 recs.push(format!(
376 "Exploding gradients detected in: {}. Apply gradient clipping (clip_grad_norm).",
377 exploding.join(", ")
378 ));
379 recs.push("Consider reducing the learning rate or switching to a gradient-friendly optimiser (e.g. AdamW with weight decay).".to_string());
380 }
381 match health {
382 GradientHealth::Critical => {
383 recs.push("CRITICAL: Training stability is severely compromised — halt training and diagnose before continuing.".to_string());
384 },
385 GradientHealth::ProblemsDetected => {
386 recs.push("Significant gradient issues detected. Review architecture depth and learning rate schedule.".to_string());
387 },
388 GradientHealth::MinorIssues => {
389 recs.push("Minor gradient issues detected. Monitor closely; intervention may not be required immediately.".to_string());
390 },
391 GradientHealth::Healthy => {
392 recs.push("Gradients appear healthy — no immediate action required.".to_string());
393 },
394 }
395
396 recs
397}
398
399#[cfg(test)]
404mod tests {
405 use super::*;
406 use std::env::temp_dir;
407
408 fn simple_grads(layer_names: &[&str], value: f64) -> HashMap<String, Vec<f64>> {
409 layer_names
410 .iter()
411 .map(|&n| (n.to_string(), vec![value, value, value, value]))
412 .collect()
413 }
414
415 #[test]
416 fn test_record_step_basic() {
417 let mut animator = GradientFlowAnimator::new(0);
418 let grads = simple_grads(&["attn", "ffn"], 0.01);
419 animator.record_step(0, &grads);
420 assert_eq!(animator.frames().len(), 1);
421 let frame = &animator.frames()[0];
422 assert_eq!(frame.layers.len(), 2);
423 }
424
425 #[test]
426 fn test_rolling_window() {
427 let mut animator = GradientFlowAnimator::new(3);
428 for i in 0..10u64 {
429 let grads = simple_grads(&["layer_a"], 0.1);
430 animator.record_step(i, &grads);
431 }
432 assert_eq!(
433 animator.frames().len(),
434 3,
435 "rolling window should cap at max_frames"
436 );
437 }
438
439 #[test]
440 fn test_vanishing_detection() {
441 let mut animator = GradientFlowAnimator::new(0).with_vanishing_threshold(1e-6);
442 let mut grads = HashMap::new();
443 grads.insert("shallow".to_string(), vec![1e-8, 1e-8]);
444 grads.insert("deep".to_string(), vec![0.01, 0.01]);
445 animator.record_step(0, &grads);
446 let frame = &animator.frames()[0];
447 let shallow = frame.layers.iter().find(|l| l.name == "shallow").unwrap();
448 assert!(shallow.is_vanishing);
449 let deep = frame.layers.iter().find(|l| l.name == "deep").unwrap();
450 assert!(!deep.is_vanishing);
451 }
452
453 #[test]
454 fn test_exploding_detection() {
455 let mut animator = GradientFlowAnimator::new(0).with_exploding_threshold(100.0);
456 let mut grads = HashMap::new();
457 grads.insert("bad_layer".to_string(), vec![500.0, 200.0]);
458 grads.insert("ok_layer".to_string(), vec![0.1, 0.1]);
459 animator.record_step(0, &grads);
460 let frame = &animator.frames()[0];
461 let bad = frame.layers.iter().find(|l| l.name == "bad_layer").unwrap();
462 assert!(bad.is_exploding);
463 let ok = frame.layers.iter().find(|l| l.name == "ok_layer").unwrap();
464 assert!(!ok.is_exploding);
465 }
466
467 #[test]
468 fn test_flow_intensity_normalised() {
469 let mut animator = GradientFlowAnimator::new(0);
470 let mut grads = HashMap::new();
471 grads.insert("large".to_string(), vec![1.0; 10]);
472 grads.insert("small".to_string(), vec![0.001; 10]);
473 animator.record_step(0, &grads);
474 let frame = &animator.frames()[0];
475 for layer in &frame.layers {
476 assert!(layer.flow_intensity >= 0.0 && layer.flow_intensity <= 1.0);
477 }
478 }
479
480 #[test]
481 fn test_export_json() -> Result<()> {
482 let mut animator = GradientFlowAnimator::new(0);
483 animator.record_step(0, &simple_grads(&["a", "b"], 0.1));
484 animator.record_step(1, &simple_grads(&["a", "b"], 0.05));
485
486 let path = temp_dir().join(format!("grad_anim_{}.json", uuid::Uuid::new_v4()));
487 animator.export_json(&path)?;
488 assert!(path.exists());
489 let content = std::fs::read_to_string(&path)?;
490 let frames: Vec<GradientFrame> = serde_json::from_str(&content)?;
491 assert_eq!(frames.len(), 2);
492 Ok(())
493 }
494
495 #[test]
496 fn test_export_csv() -> Result<()> {
497 let mut animator = GradientFlowAnimator::new(0);
498 animator.record_step(0, &simple_grads(&["encoder"], 0.2));
499 animator.record_step(1, &simple_grads(&["encoder"], 0.18));
500
501 let path = temp_dir().join(format!("grad_anim_{}.csv", uuid::Uuid::new_v4()));
502 animator.export_csv(&path)?;
503 assert!(path.exists());
504 let content = std::fs::read_to_string(&path)?;
505 assert!(content.lines().count() >= 3);
507 assert!(content.contains("step,layer,mean_abs_grad"));
508 Ok(())
509 }
510
511 #[test]
512 fn test_to_ascii_animation_empty() {
513 let animator = GradientFlowAnimator::new(0);
514 let out = animator.to_ascii_animation();
515 assert!(out.contains("no gradient frames"));
516 }
517
518 #[test]
519 fn test_to_ascii_animation_nonempty() {
520 let mut animator = GradientFlowAnimator::new(0);
521 for i in 0..5u64 {
522 animator.record_step(i, &simple_grads(&["embed", "attn"], 0.1 * i as f64));
523 }
524 let out = animator.to_ascii_animation();
525 assert!(out.contains("embed"));
526 assert!(out.contains("attn"));
527 }
528
529 #[test]
530 fn test_summary_report_healthy() {
531 let mut animator = GradientFlowAnimator::new(0);
532 for i in 0..5u64 {
533 animator.record_step(i, &simple_grads(&["layer"], 0.1));
534 }
535 let summary = animator.summary_report();
536 assert_eq!(summary.overall_health, GradientHealth::Healthy);
537 assert!(summary.layers_with_vanishing_grads.is_empty());
538 assert!(summary.layers_with_exploding_grads.is_empty());
539 }
540}