From e359392a007a891dfb4af0ecd699c50f603f0ca7 Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Wed, 11 Feb 2026 20:12:35 +0100 Subject: [PATCH] feat: add HTML visualization reports with Plotly.js charts Add a `visualization` feature flag that generates self-contained HTML reports with interactive Plotly.js charts for offline visualization of optimization results. Charts include optimization history, slice plots, parallel coordinates, parameter importance, trial timeline, and intermediate values (learning curves). --- .github/workflows/ci.yml | 2 + Cargo.toml | 6 + examples/visualization_demo.rs | 45 ++++ src/lib.rs | 7 + src/study.rs | 16 ++ src/visualization.rs | 458 +++++++++++++++++++++++++++++++++ tests/visualization_tests.rs | 182 +++++++++++++ 7 files changed, 716 insertions(+) create mode 100644 examples/visualization_demo.rs create mode 100644 src/visualization.rs create mode 100644 tests/visualization_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 510cd19..ba2b76b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,8 @@ jobs: run: cargo run --example parameter_api --features derive - name: Run async example (async_api_optimization) run: cargo run --example async_api_optimization --features async + - name: Run visualization example (visualization_demo) + run: cargo run --example visualization_demo --features visualization docs: name: Docs diff --git a/Cargo.toml b/Cargo.toml index 347bff7..16cfcb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ serde = ["dep:serde", "dep:serde_json"] tracing = ["dep:tracing"] sobol = ["dep:sobol_burley"] cma-es = ["dep:nalgebra"] +visualization = [] [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } @@ -63,3 +64,8 @@ path = "examples/ml_hyperparameter_tuning.rs" name = "parameter_api" path = "examples/parameter_api.rs" required-features = ["derive"] + +[[example]] +name = "visualization_demo" +path = "examples/visualization_demo.rs" +required-features = ["visualization"] diff --git a/examples/visualization_demo.rs b/examples/visualization_demo.rs new file mode 100644 index 0000000..e2efd76 --- /dev/null +++ b/examples/visualization_demo.rs @@ -0,0 +1,45 @@ +use optimizer::prelude::*; + +fn main() { + // Multi-parameter optimization with TPE sampler. + let sampler = TpeSampler::builder().seed(42).build().unwrap(); + let mut study: Study = Study::with_sampler(Direction::Minimize, sampler); + study.set_pruner(MedianPruner::new(Direction::Minimize)); + + let lr = FloatParam::new(1e-5, 1e-1) + .log_scale() + .name("learning_rate"); + let n_layers = IntParam::new(1, 5).name("n_layers"); + let dropout = FloatParam::new(0.0, 0.5).step(0.05).name("dropout"); + let batch_size = CategoricalParam::new(vec![16, 32, 64, 128]).name("batch_size"); + + study + .optimize(80, |trial| { + let lr_val = lr.suggest(trial)?; + let layers = n_layers.suggest(trial)?; + let drop = dropout.suggest(trial)?; + let bs = batch_size.suggest(trial)?; + + // Simulate training with intermediate reporting. + let mut loss = 1.0; + for epoch in 0..10 { + loss *= 0.7 + 0.3 * lr_val.ln().abs() / 12.0; + loss += drop * 0.05; + loss += (1.0 / bs as f64) * 0.1; + loss -= layers as f64 * 0.02; + trial.report(epoch, loss); + if trial.should_prune() { + return Err(TrialPruned.into()); + } + } + + Ok::<_, Error>(loss) + }) + .unwrap(); + + println!("{}", study.summary()); + + let path = "optimization_report.html"; + generate_html_report(&study, path).unwrap(); + println!("\nReport saved to {path}"); +} diff --git a/src/lib.rs b/src/lib.rs index f332e0e..41832ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -190,6 +190,7 @@ //! - `serde`: Enable `Serialize`/`Deserialize` on public types and `Study::save()`/`Study::load()` //! - `sobol`: Enable the Sobol quasi-random sampler for better space coverage //! - `cma-es`: Enable the CMA-ES sampler for continuous optimization +//! - `visualization`: Generate self-contained HTML reports with interactive Plotly.js charts //! - `tracing`: Emit structured log events via the [`tracing`](https://docs.rs/tracing) crate at key optimization points /// Emit a `tracing::info!` event when the `tracing` feature is enabled. @@ -229,6 +230,8 @@ pub mod sampler; mod study; mod trial; mod types; +#[cfg(feature = "visualization")] +mod visualization; pub use error::{Error, Result, TrialPruned}; pub use multi_objective::{MultiObjectiveSampler, MultiObjectiveStudy, MultiObjectiveTrial}; @@ -258,6 +261,8 @@ pub use study::Study; pub use study::StudySnapshot; pub use trial::{AttrValue, Trial}; pub use types::{Direction, TrialState}; +#[cfg(feature = "visualization")] +pub use visualization::generate_html_report; /// Convenient wildcard import for the most common types. /// @@ -294,4 +299,6 @@ pub mod prelude { pub use crate::study::StudySnapshot; pub use crate::trial::{AttrValue, Trial}; pub use crate::types::Direction; + #[cfg(feature = "visualization")] + pub use crate::visualization::generate_html_report; } diff --git a/src/study.rs b/src/study.rs index 5f6d6a5..52bccb9 100644 --- a/src/study.rs +++ b/src/study.rs @@ -2154,6 +2154,22 @@ impl Study { } } +#[cfg(feature = "visualization")] +impl Study { + /// Generates an HTML report with interactive Plotly.js charts. + /// + /// Creates a self-contained HTML file that can be opened in any browser. + /// See [`generate_html_report`](crate::visualization::generate_html_report) + /// for details on the included charts. + /// + /// # Errors + /// + /// Returns an I/O error if the file cannot be created or written. + pub fn export_html(&self, path: impl AsRef) -> std::io::Result<()> { + crate::visualization::generate_html_report(self, path) + } +} + /// A serializable snapshot of a study's state. /// /// Since [`Study`] contains non-serializable fields (samplers, atomics, etc.), diff --git a/src/visualization.rs b/src/visualization.rs new file mode 100644 index 0000000..3f1a69e --- /dev/null +++ b/src/visualization.rs @@ -0,0 +1,458 @@ +//! HTML report generation for optimization visualization. +//! +//! Generates self-contained HTML files with embedded Plotly.js charts +//! for offline visualization of optimization results. + +use core::fmt::Write as _; +use std::collections::BTreeMap; +use std::path::Path; + +use crate::distribution::Distribution; +use crate::param::ParamValue; +use crate::parameter::ParamId; +use crate::sampler::CompletedTrial; +use crate::types::{Direction, TrialState}; + +/// Generate an HTML report with interactive Plotly.js charts. +/// +/// Creates a self-contained HTML file at `path` containing: +/// - **Optimization history**: Objective value vs trial number with best-so-far line +/// - **Slice plots**: Objective value vs each parameter (1D scatter) +/// - **Parallel coordinates**: Multi-parameter relationship view +/// - **Trial timeline**: Duration index of each trial (horizontal bar) +/// - **Intermediate values**: Learning curves per trial (if pruning data available) +/// - **Parameter importance**: Bar chart (if enough completed trials) +/// +/// # Errors +/// +/// Returns an I/O error if the file cannot be created or written. +pub fn generate_html_report( + study: &crate::Study, + path: impl AsRef, +) -> std::io::Result<()> { + let trials = study.trials(); + let direction = study.direction(); + let importance = study.param_importance(); + + let html = build_html(&trials, direction, &importance); + std::fs::write(path, html) +} + +fn build_html( + trials: &[CompletedTrial], + direction: Direction, + importance: &[(String, f64)], +) -> String { + let mut html = String::with_capacity(8192); + + let dir_label = match direction { + Direction::Minimize => "Minimize", + Direction::Maximize => "Maximize", + }; + + // Collect parameter metadata. + let param_info = collect_param_info(trials); + let has_intermediate = trials.iter().any(|t| !t.intermediate_values.is_empty()); + + let _ = write!( + html, + r#" + + + + +Optimization Report + + + + +

Optimization Report

+

{dir_label} · {n} trials

+"#, + n = trials.len(), + ); + + // Optimization history chart. + html.push_str("
Optimization History
\n"); + write_history_chart(&mut html, trials, direction); + + // Slice plots. + if !param_info.is_empty() { + html.push_str("
Slice Plots
\n"); + write_slice_charts(&mut html, trials, ¶m_info); + } + + // Parallel coordinates. + if param_info.len() >= 2 { + html.push_str("
Parallel Coordinates
\n"); + write_parallel_coordinates(&mut html, trials, ¶m_info, direction); + } + + // Parameter importance. + if !importance.is_empty() { + html.push_str("
Parameter Importance
\n"); + write_importance_chart(&mut html, importance); + } + + // Trial timeline. + html.push_str("
Trial Timeline
\n"); + write_timeline_chart(&mut html, trials); + + // Intermediate values. + if has_intermediate { + html.push_str("
Intermediate Values
\n"); + write_intermediate_chart(&mut html, trials); + } + + html.push_str("\n\n"); + html +} + +/// Metadata about each parameter seen across trials. +struct ParamMeta { + label: String, + #[allow(dead_code)] + dist: Option, +} + +/// Collect parameter labels and distributions across all trials. +fn collect_param_info(trials: &[CompletedTrial]) -> BTreeMap { + let mut info: BTreeMap = BTreeMap::new(); + for trial in trials { + for &id in trial.params.keys() { + info.entry(id).or_insert_with(|| { + let label = trial + .param_labels + .get(&id) + .cloned() + .unwrap_or_else(|| id.to_string()); + let dist = trial.distributions.get(&id).cloned(); + ParamMeta { label, dist } + }); + } + } + info +} + +// --------------------------------------------------------------------------- +// Chart generators +// --------------------------------------------------------------------------- + +fn write_history_chart(html: &mut String, trials: &[CompletedTrial], direction: Direction) { + let complete: Vec<_> = trials + .iter() + .filter(|t| t.state == TrialState::Complete) + .collect(); + if complete.is_empty() { + return; + } + + let mut ids = Vec::with_capacity(complete.len()); + let mut vals = Vec::with_capacity(complete.len()); + let mut best_vals = Vec::with_capacity(complete.len()); + let mut best = complete[0].value; + for t in &complete { + ids.push(t.id); + vals.push(t.value); + best = match direction { + Direction::Minimize => best.min(t.value), + Direction::Maximize => best.max(t.value), + }; + best_vals.push(best); + } + + let _ = write!( + html, + r##" +"##, + ); +} + +fn write_slice_charts( + html: &mut String, + trials: &[CompletedTrial], + param_info: &BTreeMap, +) { + let complete: Vec<_> = trials + .iter() + .filter(|t| t.state == TrialState::Complete) + .collect(); + if complete.is_empty() { + return; + } + + let n_params = param_info.len(); + let cols = if n_params <= 2 { n_params } else { 2 }; + let rows = n_params.div_ceil(cols); + + // Build subplot titles and data. + let mut subplot_titles = Vec::new(); + let mut traces = String::new(); + for (i, (id, meta)) in param_info.iter().enumerate() { + subplot_titles.push(format!("\"{}\"", escape_js(&meta.label))); + + let mut x_vals = Vec::new(); + let mut y_vals = Vec::new(); + for t in &complete { + if let Some(pv) = t.params.get(id) { + x_vals.push(param_value_to_f64(pv)); + y_vals.push(t.value); + } + } + + let subplot_idx = i + 1; + let xa = if subplot_idx == 1 { + "x".to_string() + } else { + format!("x{subplot_idx}") + }; + let ya = if subplot_idx == 1 { + "y".to_string() + } else { + format!("y{subplot_idx}") + }; + let _ = write!( + traces, + r##"{{ x: {x_vals:?}, y: {y_vals:?}, mode: "markers", type: "scatter", + xaxis: "{xa}", yaxis: "{ya}", + marker: {{ color: "#3498db", size: 5 }}, showlegend: false }},"##, + ); + } + + let _ = write!( + html, + r#" +"#, + annotations = build_subplot_annotations(&subplot_titles, rows, cols), + ); +} + +fn write_parallel_coordinates( + html: &mut String, + trials: &[CompletedTrial], + param_info: &BTreeMap, + direction: Direction, +) { + let complete: Vec<_> = trials + .iter() + .filter(|t| t.state == TrialState::Complete) + .collect(); + if complete.is_empty() { + return; + } + + let mut dimensions = String::new(); + + // Add objective value as the first dimension. + let obj_vals: Vec = complete.iter().map(|t| t.value).collect(); + let _ = write!( + dimensions, + r#"{{ label: "Objective", values: {obj_vals:?} }},"#, + ); + + // Add each parameter as a dimension. + for (id, meta) in param_info { + let vals: Vec = complete + .iter() + .map(|t| t.params.get(id).map_or(f64::NAN, param_value_to_f64)) + .collect(); + let _ = write!( + dimensions, + r#"{{ label: "{label}", values: {vals:?} }},"#, + label = escape_js(&meta.label), + ); + } + + // Color by objective value: green = good, red = bad. + let (cmin, cmax) = min_max(&obj_vals); + let colorscale = match direction { + Direction::Minimize => r##"[[0,"#2ecc71"],[1,"#e74c3c"]]"##, + Direction::Maximize => r##"[[0,"#e74c3c"],[1,"#2ecc71"]]"##, + }; + + let _ = write!( + html, + r#" +"#, + ); +} + +fn write_importance_chart(html: &mut String, importance: &[(String, f64)]) { + let names: Vec<_> = importance.iter().map(|(n, _)| format!("\"{n}\"")).collect(); + let values: Vec = importance.iter().map(|(_, v)| *v).collect(); + + let _ = write!( + html, + r##" +"##, + names = names.join(","), + ); +} + +fn write_timeline_chart(html: &mut String, trials: &[CompletedTrial]) { + let mut ids = Vec::with_capacity(trials.len()); + let mut colors = Vec::with_capacity(trials.len()); + let mut labels = Vec::with_capacity(trials.len()); + + for t in trials { + ids.push(format!("\"Trial {}\"", t.id)); + let (color, label) = match t.state { + TrialState::Complete => ("#2ecc71", "Complete"), + TrialState::Pruned => ("#f39c12", "Pruned"), + TrialState::Failed => ("#e74c3c", "Failed"), + TrialState::Running => ("#3498db", "Running"), + }; + colors.push(format!("\"{color}\"")); + labels.push(format!("\"{label}\"")); + } + + // Use trial index as a proxy for duration (no wallclock data available). + let indices: Vec = (0..trials.len()).collect(); + + let _ = write!( + html, + r#" +"#, + ids = ids.join(","), + colors = colors.join(","), + labels = labels.join(","), + ); +} + +fn write_intermediate_chart(html: &mut String, trials: &[CompletedTrial]) { + let trials_with_iv: Vec<_> = trials + .iter() + .filter(|t| !t.intermediate_values.is_empty()) + .collect(); + if trials_with_iv.is_empty() { + return; + } + + let mut traces = String::new(); + for t in &trials_with_iv { + let steps: Vec = t.intermediate_values.iter().map(|(s, _)| *s).collect(); + let values: Vec = t.intermediate_values.iter().map(|(_, v)| *v).collect(); + let color = match t.state { + TrialState::Pruned => "#f39c12", + _ => "#3498db", + }; + let _ = write!( + traces, + r#"{{ x: {steps:?}, y: {values:?}, mode: "lines+markers", name: "Trial {id}", + line: {{ color: "{color}", width: 1 }}, marker: {{ size: 3 }} }},"#, + id = t.id, + ); + } + + let _ = write!( + html, + r#" +"#, + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +#[allow(clippy::cast_precision_loss)] +fn param_value_to_f64(pv: &ParamValue) -> f64 { + match *pv { + ParamValue::Float(v) => v, + ParamValue::Int(v) => v as f64, + ParamValue::Categorical(v) => v as f64, + } +} + +fn escape_js(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +fn min_max(vals: &[f64]) -> (f64, f64) { + let mut mn = f64::INFINITY; + let mut mx = f64::NEG_INFINITY; + for &v in vals { + if v < mn { + mn = v; + } + if v > mx { + mx = v; + } + } + (mn, mx) +} + +/// Build Plotly annotation objects to act as subplot titles. +#[allow(clippy::cast_precision_loss)] +fn build_subplot_annotations(titles: &[String], rows: usize, cols: usize) -> String { + let mut anns = Vec::new(); + for (i, title) in titles.iter().enumerate() { + let row = i / cols; + let col = i % cols; + // Compute x/y anchor in paper coordinates. + let x = if cols == 1 { + 0.5 + } else { + (f64::from(u32::try_from(col).unwrap_or(0))) / (cols as f64 - 1.0) + }; + let y = 1.0 - (f64::from(u32::try_from(row).unwrap_or(0))) / (rows as f64).max(1.0) + 0.02; + anns.push(format!( + r#"{{ text: {title}, x: {x:.3}, y: {y:.3}, xref: "paper", yref: "paper", + showarrow: false, font: {{ size: 12 }} }}"#, + )); + } + anns.join(",") +} diff --git a/tests/visualization_tests.rs b/tests/visualization_tests.rs new file mode 100644 index 0000000..4a477bb --- /dev/null +++ b/tests/visualization_tests.rs @@ -0,0 +1,182 @@ +#![cfg(feature = "visualization")] + +use optimizer::parameter::{FloatParam, IntParam, Parameter}; +use optimizer::sampler::random::RandomSampler; +use optimizer::{Direction, Study, generate_html_report}; + +#[test] +fn html_report_creates_file() { + let study: Study = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42)); + let x = FloatParam::new(0.0, 10.0).name("x"); + let y = IntParam::new(1, 5).name("y"); + + study + .optimize(10, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, optimizer::Error>(xv + yv as f64) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_report_creates_file.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("")); + assert!(content.contains("plotly")); + std::fs::remove_file(&path).ok(); +} + +#[test] +fn html_report_contains_all_chart_sections() { + let study: Study = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42)); + let x = FloatParam::new(0.0, 10.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + study + .optimize(20, |trial| { + let xv = x.suggest(trial)?; + let yv = y.suggest(trial)?; + Ok::<_, optimizer::Error>(xv * xv + yv * yv) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_report_all_charts.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + + // Should contain all chart divs. + assert!(content.contains("id=\"history\"")); + assert!(content.contains("id=\"slices\"")); + assert!(content.contains("id=\"parcoords\"")); + assert!(content.contains("id=\"importance\"")); + assert!(content.contains("id=\"timeline\"")); + + // Should contain chart titles. + assert!(content.contains("Optimization History")); + assert!(content.contains("Slice Plots")); + assert!(content.contains("Parallel Coordinates")); + assert!(content.contains("Parameter Importance")); + assert!(content.contains("Trial Timeline")); + + // Should show direction and trial count. + assert!(content.contains("Minimize")); + assert!(content.contains("20 trials")); + + std::fs::remove_file(&path).ok(); +} + +#[test] +fn html_report_empty_study() { + let study: Study = Study::new(Direction::Minimize); + + let path = std::env::temp_dir().join("test_report_empty.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("")); + assert!(content.contains("0 trials")); + + std::fs::remove_file(&path).ok(); +} + +#[test] +fn html_report_single_param_no_parcoords() { + let study: Study = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42)); + let x = FloatParam::new(0.0, 10.0).name("x"); + + study + .optimize(5, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, optimizer::Error>(xv * xv) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_report_single_param.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + + // Should have slice plot but not parallel coordinates (needs >= 2 params). + assert!(content.contains("id=\"slices\"")); + assert!(!content.contains("id=\"parcoords\"")); + + std::fs::remove_file(&path).ok(); +} + +#[test] +fn html_report_maximize_direction() { + let study: Study = Study::with_sampler(Direction::Maximize, RandomSampler::with_seed(42)); + let x = FloatParam::new(0.0, 10.0).name("x"); + + study + .optimize(5, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, optimizer::Error>(xv) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_report_maximize.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("Maximize")); + + std::fs::remove_file(&path).ok(); +} + +#[test] +fn export_html_convenience_method() { + let study: Study = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42)); + let x = FloatParam::new(0.0, 10.0).name("x"); + + study + .optimize(5, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, optimizer::Error>(xv * xv) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_export_html.html"); + study.export_html(&path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("")); + assert!(content.contains("id=\"history\"")); + + std::fs::remove_file(&path).ok(); +} + +#[test] +fn html_report_with_intermediate_values() { + use optimizer::pruner::MedianPruner; + + let mut study: Study = + Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42)); + study.set_pruner(MedianPruner::new(Direction::Minimize)); + let x = FloatParam::new(0.0, 10.0).name("x"); + + study + .optimize(10, |trial| { + let xv = x.suggest(trial)?; + for step in 0..5 { + let val = xv * xv + step as f64; + trial.report(step, val); + if trial.should_prune() { + return Err(optimizer::TrialPruned.into()); + } + } + Ok::<_, optimizer::Error>(xv * xv) + }) + .unwrap(); + + let path = std::env::temp_dir().join("test_report_intermediate.html"); + generate_html_report(&study, &path).unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("id=\"intermediate\"")); + assert!(content.contains("Intermediate Values")); + + std::fs::remove_file(&path).ok(); +}