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).
This commit is contained in:
Manuel Raimann
2026-02-11 20:12:35 +01:00
parent 0e54356345
commit e359392a00
7 changed files with 716 additions and 0 deletions
+182
View File
@@ -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<f64> = 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("<!DOCTYPE html>"));
assert!(content.contains("plotly"));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_contains_all_chart_sections() {
let study: Study<f64> = 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<f64> = 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("<!DOCTYPE html>"));
assert!(content.contains("0 trials"));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_single_param_no_parcoords() {
let study: Study<f64> = 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<f64> = 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<f64> = 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("<!DOCTYPE html>"));
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<f64> =
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();
}