Skip to main content

sklears_neural/
visualization.rs

1//! Model Visualization Utilities
2//!
3//! This module provides comprehensive visualization capabilities for neural networks,
4//! including architecture diagrams, training metrics, attention heatmaps, and
5//! weight distributions.
6
7use crate::NeuralResult;
8use scirs2_core::ndarray::{Array2, Array3};
9use sklears_core::error::SklearsError;
10use sklears_core::types::FloatBounds;
11use std::collections::HashMap;
12use std::fs::File;
13use std::io::Write;
14
15/// Configuration for visualization output
16#[derive(Debug, Clone)]
17pub struct VisualizationConfig {
18    /// Output directory for visualizations
19    pub output_dir: String,
20    /// Image format (SVG, PNG, etc.)
21    pub format: ImageFormat,
22    /// Color scheme
23    pub color_scheme: ColorScheme,
24    /// DPI for raster formats
25    pub dpi: u32,
26    /// Whether to show layer names
27    pub show_layer_names: bool,
28    /// Whether to show tensor shapes
29    pub show_tensor_shapes: bool,
30}
31
32impl Default for VisualizationConfig {
33    fn default() -> Self {
34        Self {
35            output_dir: "./visualizations".to_string(),
36            format: ImageFormat::SVG,
37            color_scheme: ColorScheme::Default,
38            dpi: 300,
39            show_layer_names: true,
40            show_tensor_shapes: true,
41        }
42    }
43}
44
45/// Supported image formats
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub enum ImageFormat {
48    /// Scalable Vector Graphics — lossless, suitable for diagrams
49    SVG,
50    /// Portable Network Graphics — raster format (SVG is generated first, then converted externally)
51    PNG,
52    /// Interactive HTML with embedded JavaScript visualizations
53    HTML,
54}
55
56/// Color schemes for visualizations
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum ColorScheme {
59    /// Default color scheme
60    Default,
61    /// Perceptually uniform sequential color map
62    Viridis,
63    /// High-contrast sequential color map
64    Plasma,
65    /// Black-to-white grayscale ramp
66    Grayscale,
67}
68
69/// Model architecture visualizer
70pub struct ModelVisualizer {
71    config: VisualizationConfig,
72}
73
74impl ModelVisualizer {
75    /// Create a new model visualizer
76    pub fn new(config: VisualizationConfig) -> Self {
77        Self { config }
78    }
79
80    /// Generate a model architecture diagram
81    pub fn visualize_architecture(&self, layers: &[LayerInfo], filename: &str) -> NeuralResult<()> {
82        let output_path = format!(
83            "{}/{}.{}",
84            self.config.output_dir,
85            filename,
86            self.format_extension()
87        );
88
89        match self.config.format {
90            ImageFormat::SVG => self.generate_svg_architecture(layers, &output_path),
91            ImageFormat::HTML => self.generate_html_architecture(layers, &output_path),
92            ImageFormat::PNG => {
93                // For PNG, we'll generate SVG first then mention it needs conversion
94                self.generate_svg_architecture(layers, &output_path.replace(".png", ".svg"))?;
95                println!("SVG generated. Use external tool to convert to PNG if needed.");
96                Ok(())
97            }
98        }
99    }
100
101    /// Generate SVG architecture diagram
102    fn generate_svg_architecture(
103        &self,
104        layers: &[LayerInfo],
105        output_path: &str,
106    ) -> NeuralResult<()> {
107        let mut svg = String::new();
108
109        // SVG header
110        svg.push_str(&format!(
111            "<svg width=\"800\" height=\"{}\" xmlns=\"http://www.w3.org/2000/svg\">\n            <defs>\n                <style>\n                    .layer-box {{ fill: #e1f5fe; stroke: #0277bd; stroke-width: 2; }}\n                    .layer-text {{ font-family: Arial, sans-serif; font-size: 12px; text-anchor: middle; }}\n                    .layer-name {{ font-weight: bold; }}\n                    .layer-shape {{ font-size: 10px; fill: #666; }}\n                    .connection {{ stroke: #424242; stroke-width: 2; marker-end: url(#arrowhead); }}\n                </style>\n                <marker id=\"arrowhead\" markerWidth=\"10\" markerHeight=\"7\" \n                    refX=\"10\" refY=\"3.5\" orient=\"auto\">\n                    <polygon points=\"0,0 10,3.5 0,7\" fill=\"#424242\" />\n                </marker>\n            </defs>\n            ", 
112            layers.len() * 100 + 100
113        ));
114
115        // Draw layers
116        for (i, layer) in layers.iter().enumerate() {
117            let y = i * 100 + 50;
118            let x = 400;
119
120            // Layer box
121            svg.push_str(&format!(
122                r#"<rect x="{}" y="{}" width="200" height="60" class="layer-box" />
123                "#,
124                x - 100,
125                y - 30
126            ));
127
128            // Layer name
129            if self.config.show_layer_names {
130                svg.push_str(&format!(
131                    r#"<text x="{}" y="{}" class="layer-text layer-name">{}</text>
132                    "#,
133                    x,
134                    y - 10,
135                    layer.name
136                ));
137            }
138
139            // Layer type
140            svg.push_str(&format!(
141                r#"<text x="{}" y="{}" class="layer-text">{}</text>
142                "#,
143                x,
144                y + 5,
145                layer.layer_type
146            ));
147
148            // Shape information
149            if self.config.show_tensor_shapes {
150                svg.push_str(&format!(
151                    r#"<text x="{}" y="{}" class="layer-text layer-shape">{:?}</text>
152                    "#,
153                    x,
154                    y + 20,
155                    layer.output_shape
156                ));
157            }
158
159            // Connection to next layer
160            if i < layers.len() - 1 {
161                svg.push_str(&format!(
162                    r#"<line x1="{}" y1="{}" x2="{}" y2="{}" class="connection" />
163                    "#,
164                    x,
165                    y + 30,
166                    x,
167                    y + 70
168                ));
169            }
170        }
171
172        svg.push_str("</svg>");
173
174        // Write to file
175        let mut file = File::create(output_path)
176            .map_err(|e| SklearsError::InvalidInput(format!("Failed to create file: {}", e)))?;
177        file.write_all(svg.as_bytes())
178            .map_err(|e| SklearsError::InvalidInput(format!("Failed to write file: {}", e)))?;
179
180        Ok(())
181    }
182
183    /// Generate HTML architecture diagram with interactive features
184    fn generate_html_architecture(
185        &self,
186        layers: &[LayerInfo],
187        output_path: &str,
188    ) -> NeuralResult<()> {
189        let mut html = String::new();
190
191        html.push_str(
192            r#"
193        <!DOCTYPE html>
194        <html>
195        <head>
196            <title>Neural Network Architecture</title>
197            <style>
198                body { font-family: Arial, sans-serif; margin: 20px; }
199                .architecture { display: flex; flex-direction: column; align-items: center; }
200                .layer { 
201                    background: #e1f5fe; 
202                    border: 2px solid #0277bd; 
203                    border-radius: 8px;
204                    padding: 15px; 
205                    margin: 10px;
206                    min-width: 200px;
207                    text-align: center;
208                    transition: all 0.3s ease;
209                }
210                .layer:hover { 
211                    background: #b3e5fc; 
212                    transform: scale(1.05);
213                    box-shadow: 0 4px 8px rgba(0,0,0,0.2);
214                }
215                .layer-name { font-weight: bold; font-size: 16px; color: #0277bd; }
216                .layer-type { font-size: 14px; color: #424242; margin: 5px 0; }
217                .layer-shape { font-size: 12px; color: #666; }
218                .arrow { 
219                    font-size: 24px; 
220                    color: #424242; 
221                    margin: 5px 0;
222                }
223                .layer-details {
224                    display: none;
225                    margin-top: 10px;
226                    padding: 10px;
227                    background: #f5f5f5;
228                    border-radius: 4px;
229                    font-size: 12px;
230                }
231            </style>
232            <script>
233                function toggleDetails(layerId) {
234                    const details = document.getElementById(layerId);
235                    details.style.display = details.style.display === 'none' ? 'block' : 'none';
236                }
237            </script>
238        </head>
239        <body>
240            <h1>Neural Network Architecture</h1>
241            <div class="architecture">
242        "#,
243        );
244
245        for (i, layer) in layers.iter().enumerate() {
246            html.push_str(&format!(
247                r#"
248                <div class="layer" onclick="toggleDetails('details_{}')">
249                    <div class="layer-name">{}</div>
250                    <div class="layer-type">{}</div>
251                    <div class="layer-shape">Shape: {:?}</div>
252                    <div id="details_{}" class="layer-details">
253                        <strong>Parameters:</strong> {}<br>
254                        <strong>Activation:</strong> {}<br>
255                        <strong>Trainable:</strong> {}
256                    </div>
257                </div>
258                "#,
259                i,
260                layer.name,
261                layer.layer_type,
262                layer.output_shape,
263                i,
264                layer.num_parameters,
265                layer.activation.as_deref().unwrap_or("None"),
266                layer.trainable
267            ));
268
269            if i < layers.len() - 1 {
270                html.push_str(r#"<div class="arrow">↓</div>"#);
271            }
272        }
273
274        html.push_str(
275            r#"
276            </div>
277        </body>
278        </html>
279        "#,
280        );
281
282        // Write to file
283        let mut file = File::create(output_path)
284            .map_err(|e| SklearsError::InvalidInput(format!("Failed to create file: {}", e)))?;
285        file.write_all(html.as_bytes())
286            .map_err(|e| SklearsError::InvalidInput(format!("Failed to write file: {}", e)))?;
287
288        Ok(())
289    }
290
291    /// Get file extension for current format
292    fn format_extension(&self) -> &'static str {
293        match self.config.format {
294            ImageFormat::SVG => "svg",
295            ImageFormat::PNG => "png",
296            ImageFormat::HTML => "html",
297        }
298    }
299}
300
301/// Information about a layer for visualization
302#[derive(Debug, Clone)]
303pub struct LayerInfo {
304    /// Human-readable name identifying this layer in the architecture diagram
305    pub name: String,
306    /// String descriptor of the layer class (e.g., `"Dense"`, `"Conv2D"`)
307    pub layer_type: String,
308    /// Shape of the output tensor produced by this layer
309    pub output_shape: Vec<usize>,
310    /// Total number of trainable parameters in this layer
311    pub num_parameters: usize,
312    /// Name of the activation function applied after this layer, if any
313    pub activation: Option<String>,
314    /// Whether the layer's parameters are updated during training
315    pub trainable: bool,
316}
317
318/// Training metrics visualizer
319pub struct TrainingVisualizer {
320    config: VisualizationConfig,
321}
322
323impl TrainingVisualizer {
324    /// Create a new training visualizer
325    pub fn new(config: VisualizationConfig) -> Self {
326        Self { config }
327    }
328
329    /// Plot training history (loss, accuracy, etc.)
330    pub fn plot_training_history(
331        &self,
332        metrics: &TrainingMetrics,
333        filename: &str,
334    ) -> NeuralResult<()> {
335        let output_path = format!("{}/{}.html", self.config.output_dir, filename);
336
337        let mut html = String::new();
338        html.push_str(
339            r#"
340        <!DOCTYPE html>
341        <html>
342        <head>
343            <title>Training History</title>
344            <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
345            <style>
346                body { font-family: Arial, sans-serif; margin: 20px; }
347                .plot-container { width: 100%; height: 400px; margin: 20px 0; }
348            </style>
349        </head>
350        <body>
351            <h1>Training History</h1>
352        "#,
353        );
354
355        // Loss plot
356        html.push_str(r#"<div id="loss-plot" class="plot-container"></div>"#);
357        html.push_str(&format!(
358            r#"
359        <script>
360            var lossData = [{{
361                x: [{}],
362                y: [{}],
363                type: 'scatter',
364                mode: 'lines',
365                name: 'Training Loss',
366                line: {{color: '#1f77b4'}}
367            }}
368        "#,
369            (0..metrics.train_loss.len())
370                .map(|i| i.to_string())
371                .collect::<Vec<_>>()
372                .join(","),
373            metrics
374                .train_loss
375                .iter()
376                .map(|x| x.to_string())
377                .collect::<Vec<_>>()
378                .join(",")
379        ));
380
381        if !metrics.val_loss.is_empty() {
382            html.push_str(&format!(
383                r#",{{
384                x: [{}],
385                y: [{}],
386                type: 'scatter',
387                mode: 'lines',
388                name: 'Validation Loss',
389                line: {{color: '#ff7f0e'}}
390            }}"#,
391                (0..metrics.val_loss.len())
392                    .map(|i| i.to_string())
393                    .collect::<Vec<_>>()
394                    .join(","),
395                metrics
396                    .val_loss
397                    .iter()
398                    .map(|x| x.to_string())
399                    .collect::<Vec<_>>()
400                    .join(",")
401            ));
402        }
403
404        html.push_str(
405            r#"];
406            var lossLayout = {
407                title: 'Training Loss',
408                xaxis: { title: 'Epoch' },
409                yaxis: { title: 'Loss' }
410            };
411            Plotly.newPlot('loss-plot', lossData, lossLayout);
412        </script>
413        "#,
414        );
415
416        // Accuracy plot (if available)
417        if !metrics.train_accuracy.is_empty() {
418            html.push_str(r#"<div id="accuracy-plot" class="plot-container"></div>"#);
419            html.push_str(&format!(
420                r#"
421            <script>
422                var accuracyData = [{{
423                    x: [{}],
424                    y: [{}],
425                    type: 'scatter',
426                    mode: 'lines',
427                    name: 'Training Accuracy',
428                    line: {{color: '#2ca02c'}}
429                }}
430            "#,
431                (0..metrics.train_accuracy.len())
432                    .map(|i| i.to_string())
433                    .collect::<Vec<_>>()
434                    .join(","),
435                metrics
436                    .train_accuracy
437                    .iter()
438                    .map(|x| x.to_string())
439                    .collect::<Vec<_>>()
440                    .join(",")
441            ));
442
443            if !metrics.val_accuracy.is_empty() {
444                html.push_str(&format!(
445                    r#",{{
446                    x: [{}],
447                    y: [{}],
448                    type: 'scatter',
449                    mode: 'lines',
450                    name: 'Validation Accuracy',
451                    line: {{color: '#d62728'}}
452                }}"#,
453                    (0..metrics.val_accuracy.len())
454                        .map(|i| i.to_string())
455                        .collect::<Vec<_>>()
456                        .join(","),
457                    metrics
458                        .val_accuracy
459                        .iter()
460                        .map(|x| x.to_string())
461                        .collect::<Vec<_>>()
462                        .join(",")
463                ));
464            }
465
466            html.push_str(
467                r#"];
468                var accuracyLayout = {
469                    title: 'Training Accuracy',
470                    xaxis: { title: 'Epoch' },
471                    yaxis: { title: 'Accuracy' }
472                };
473                Plotly.newPlot('accuracy-plot', accuracyData, accuracyLayout);
474            </script>
475            "#,
476            );
477        }
478
479        html.push_str(
480            r#"
481        </body>
482        </html>
483        "#,
484        );
485
486        // Write to file
487        let mut file = File::create(&output_path)
488            .map_err(|e| SklearsError::InvalidInput(format!("Failed to create file: {}", e)))?;
489        file.write_all(html.as_bytes())
490            .map_err(|e| SklearsError::InvalidInput(format!("Failed to write file: {}", e)))?;
491
492        println!("Training history saved to: {}", output_path);
493        Ok(())
494    }
495}
496
497/// Training metrics for visualization
498#[derive(Debug, Clone, Default)]
499pub struct TrainingMetrics {
500    /// Per-epoch training loss values
501    pub train_loss: Vec<f64>,
502    /// Per-epoch validation loss values
503    pub val_loss: Vec<f64>,
504    /// Per-epoch training accuracy values
505    pub train_accuracy: Vec<f64>,
506    /// Per-epoch validation accuracy values
507    pub val_accuracy: Vec<f64>,
508    /// Learning rate schedule over epochs
509    pub learning_rates: Vec<f64>,
510}
511
512/// Attention heatmap visualizer
513pub struct AttentionVisualizer {
514    config: VisualizationConfig,
515}
516
517impl AttentionVisualizer {
518    /// Create a new attention visualizer
519    pub fn new(config: VisualizationConfig) -> Self {
520        Self { config }
521    }
522
523    /// Generate attention heatmap visualization
524    pub fn visualize_attention_weights<T: FloatBounds>(
525        &self,
526        attention_weights: &Array3<T>,
527        tokens: &[String],
528        filename: &str,
529    ) -> NeuralResult<()> {
530        let output_path = format!("{}/{}.html", self.config.output_dir, filename);
531
532        let mut html = String::new();
533        html.push_str(
534            r#"
535        <!DOCTYPE html>
536        <html>
537        <head>
538            <title>Attention Heatmap</title>
539            <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
540            <style>
541                body { font-family: Arial, sans-serif; margin: 20px; }
542                .heatmap-container { width: 100%; height: 600px; margin: 20px 0; }
543            </style>
544        </head>
545        <body>
546            <h1>Attention Weights Heatmap</h1>
547            <div id="heatmap" class="heatmap-container"></div>
548            <script>
549        "#,
550        );
551
552        // Get the first head of the first layer for visualization
553        let (batch_size, seq_len, _) = attention_weights.dim();
554        if batch_size > 0 && seq_len > 0 {
555            // Convert attention weights to JavaScript format
556            let mut weights_js = String::new();
557            weights_js.push('[');
558            for i in 0..seq_len {
559                weights_js.push('[');
560                for j in 0..seq_len {
561                    if j > 0 {
562                        weights_js.push(',');
563                    }
564                    weights_js.push_str(
565                        &attention_weights[[0, i, j]]
566                            .to_f64()
567                            .unwrap_or(0.0)
568                            .to_string(),
569                    );
570                }
571                weights_js.push(']');
572                if i < seq_len - 1 {
573                    weights_js.push(',');
574                }
575            }
576            weights_js.push(']');
577
578            // Convert tokens to JavaScript format
579            let tokens_js = format!("[\"{}\"]", tokens.join("\",\""));
580
581            html.push_str(&format!(
582                r#"
583                var data = [{{
584                    z: {},
585                    x: {},
586                    y: {},
587                    type: 'heatmap',
588                    colorscale: 'Viridis'
589                }}];
590                
591                var layout = {{
592                    title: 'Attention Weights',
593                    xaxis: {{ title: 'Key Tokens' }},
594                    yaxis: {{ title: 'Query Tokens' }}
595                }};
596                
597                Plotly.newPlot('heatmap', data, layout);
598            "#,
599                weights_js, tokens_js, tokens_js
600            ));
601        }
602
603        html.push_str(
604            r#"
605            </script>
606        </body>
607        </html>
608        "#,
609        );
610
611        // Write to file
612        let mut file = File::create(&output_path)
613            .map_err(|e| SklearsError::InvalidInput(format!("Failed to create file: {}", e)))?;
614        file.write_all(html.as_bytes())
615            .map_err(|e| SklearsError::InvalidInput(format!("Failed to write file: {}", e)))?;
616
617        println!("Attention heatmap saved to: {}", output_path);
618        Ok(())
619    }
620}
621
622/// Weight distribution visualizer
623pub struct WeightVisualizer {
624    config: VisualizationConfig,
625}
626
627impl WeightVisualizer {
628    /// Create a new weight visualizer
629    pub fn new(config: VisualizationConfig) -> Self {
630        Self { config }
631    }
632
633    /// Visualize weight distributions across layers
634    pub fn visualize_weight_distributions<T: FloatBounds>(
635        &self,
636        weights: &HashMap<String, Array2<T>>,
637        filename: &str,
638    ) -> NeuralResult<()> {
639        let output_path = format!("{}/{}.html", self.config.output_dir, filename);
640
641        let mut html = String::new();
642        html.push_str(
643            r#"
644        <!DOCTYPE html>
645        <html>
646        <head>
647            <title>Weight Distributions</title>
648            <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
649            <style>
650                body { font-family: Arial, sans-serif; margin: 20px; }
651                .histogram-container { width: 100%; height: 400px; margin: 20px 0; }
652            </style>
653        </head>
654        <body>
655            <h1>Weight Distributions</h1>
656        "#,
657        );
658
659        for (layer_name, weight_matrix) in weights.iter() {
660            let div_id = format!("histogram-{}", layer_name.replace(".", "-"));
661            html.push_str(&format!(r#"<h2>{}</h2>"#, layer_name));
662            html.push_str(&format!(
663                r#"<div id="{}" class="histogram-container"></div>"#,
664                div_id
665            ));
666
667            // Flatten weights and convert to JavaScript array
668            let flattened: Vec<f64> = weight_matrix
669                .iter()
670                .map(|&w| w.to_f64().unwrap_or(0.0))
671                .collect();
672
673            let weights_js = format!(
674                "[{}]",
675                flattened
676                    .iter()
677                    .map(|x| x.to_string())
678                    .collect::<Vec<_>>()
679                    .join(",")
680            );
681
682            html.push_str(&format!(
683                r#"
684            <script>
685                var data_{} = [{{
686                    x: {},
687                    type: 'histogram',
688                    nbinsx: 50,
689                    name: '{}'
690                }}];
691                
692                var layout_{} = {{
693                    title: '{} Weight Distribution',
694                    xaxis: {{ title: 'Weight Value' }},
695                    yaxis: {{ title: 'Frequency' }}
696                }};
697                
698                Plotly.newPlot('{}', data_{}, layout_{});
699            </script>
700            "#,
701                div_id, weights_js, layer_name, div_id, layer_name, div_id, div_id, div_id
702            ));
703        }
704
705        html.push_str(
706            r#"
707        </body>
708        </html>
709        "#,
710        );
711
712        // Write to file
713        let mut file = File::create(&output_path)
714            .map_err(|e| SklearsError::InvalidInput(format!("Failed to create file: {}", e)))?;
715        file.write_all(html.as_bytes())
716            .map_err(|e| SklearsError::InvalidInput(format!("Failed to write file: {}", e)))?;
717
718        println!("Weight distributions saved to: {}", output_path);
719        Ok(())
720    }
721}
722
723/// Create output directory if it doesn't exist
724pub fn ensure_output_directory(path: &str) -> NeuralResult<()> {
725    std::fs::create_dir_all(path)
726        .map_err(|e| SklearsError::InvalidInput(format!("Failed to create directory: {}", e)))
727}
728
729#[allow(non_snake_case)]
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    #[test]
735    fn test_visualization_config_default() {
736        let config = VisualizationConfig::default();
737        assert_eq!(config.format, ImageFormat::SVG);
738        assert_eq!(config.color_scheme, ColorScheme::Default);
739        assert_eq!(config.dpi, 300);
740    }
741
742    #[test]
743    fn test_layer_info_creation() {
744        let layer_info = LayerInfo {
745            name: "dense_1".to_string(),
746            layer_type: "Dense".to_string(),
747            output_shape: vec![128, 64],
748            num_parameters: 8256,
749            activation: Some("ReLU".to_string()),
750            trainable: true,
751        };
752
753        assert_eq!(layer_info.name, "dense_1");
754        assert_eq!(layer_info.num_parameters, 8256);
755    }
756
757    #[test]
758    fn test_training_metrics_default() {
759        let metrics = TrainingMetrics::default();
760        assert!(metrics.train_loss.is_empty());
761        assert!(metrics.val_loss.is_empty());
762        assert!(metrics.train_accuracy.is_empty());
763    }
764}