From 2688b76678ba16aabd830755fd69b5d6cedce3eb Mon Sep 17 00:00:00 2001 From: Aaron Fenyes Date: Wed, 25 Jun 2025 12:57:11 -0700 Subject: [PATCH] Switch to log scale for diagnostics charts Split the spectrum of the Hessian into its positive and negative parts and plot them on the same logarithmic axis. The data zoom controls are still linearly scaled, as discussed in ECharts issue #20927 https://github.com/apache/echarts/issues/20927 --- app-proto/src/diagnostics.rs | 107 +++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 37 deletions(-) diff --git a/app-proto/src/diagnostics.rs b/app-proto/src/diagnostics.rs index d283c51..0aa46c3 100644 --- a/app-proto/src/diagnostics.rs +++ b/app-proto/src/diagnostics.rs @@ -2,7 +2,7 @@ use charming::{ Chart, WasmRenderer, component::{Axis, DataZoom, Grid}, - element::AxisType, + element::{AxisType, Symbol}, series::{Line, Scatter}, }; use sycamore::prelude::*; @@ -50,6 +50,10 @@ fn RealizationStatus() -> View { } } +fn into_time_point((step, value): (usize, f64)) -> Vec> { + vec![Some(step as f64), Some(value)] +} + // the loss history from the last realization #[component] fn LossHistory() -> View { @@ -60,38 +64,35 @@ fn LossHistory() -> View { on_mount(move || { create_effect(move || { // get the loss history - let scaled_loss = state.assembly.descent_history.with( - |history| history.scaled_loss.clone() + let scaled_loss: Vec<_> = state.assembly.descent_history.with( + |history| history.scaled_loss + .iter() + .enumerate() + .map(|(step, &loss)| (step, loss)) + .map(into_time_point) + .collect() ); - let step_cnt = scaled_loss.len(); // initialize the chart axes and series - const MIN_INTERVAL: f64 = 0.01; - let mut step_axis = Axis::new() + let step_axis = Axis::new() .type_(AxisType::Category) .boundary_gap(false); - let scaled_loss_axis = Axis::new() - .type_(AxisType::Value) - .min(0) - .min_interval(MIN_INTERVAL); + let scaled_loss_axis = Axis::new().type_(AxisType::Log); // load the chart data. when there's no history, we load the data // point (0, None) to clear the chart. it would feel more natural to // load empty data vectors, but that turns out not to clear the // chart: it instead leads to previous data being re-used - let mut scaled_loss_series = Line::new(); - if step_cnt > 0 { - step_axis = step_axis.data( - (0..step_cnt).map(|step| step.to_string()).collect() - ); - scaled_loss_series = scaled_loss_series.data(scaled_loss); - } else { - step_axis = step_axis.data(vec![0.to_string()]); - scaled_loss_series = scaled_loss_series.data(vec![None::]); - } + let scaled_loss_series = Line::new().data( + if scaled_loss.len() > 0 { + scaled_loss + } else { + vec![vec![Some(0.0), None::]] + } + ); let chart = Chart::new() .animation(false) - .data_zoom(DataZoom::new().y_axis_index(0).right(40).start(0).end(100)) + .data_zoom(DataZoom::new().y_axis_index(0).right(40)) .x_axis(step_axis) .y_axis(scaled_loss_axis) .grid(Grid::new().top(20).right(80).bottom(30).left(60)) @@ -114,41 +115,73 @@ fn SpectrumHistory() -> View { on_mount(move || { create_effect(move || { - // get the spectrum of the Hessian at each step - let hess_eigvals = state.assembly.descent_history.with( + // get the spectrum of the Hessian at each step, split into its + // positive and negative parts. throw away eigenvalues that are + // close to zero + const ZERO_THRESHOLD: f64 = 1e-6; + let ( + hess_eigvals_pos, + hess_eigvals_neg + ): (Vec<_>, Vec<_>) = state.assembly.descent_history.with( |history| history.hess_eigvals .iter() .enumerate() .map( - |(step, eigvals)| eigvals.iter().map( - move |val| vec![step as f64, *val] - ) + |(step, eigvals)| eigvals + .iter() + .filter(|&&val| val.abs() > ZERO_THRESHOLD) + .map( + move |&val| (step, val) + ) ) .flatten() - .collect::>() + .partition(|&(_, val)| val > 0.0) ); // initialize the chart axes and series - let step_axis = Axis::new(); - let eigval_axis = Axis::new(); + let step_axis = Axis::new() + .type_(AxisType::Category) + .boundary_gap(false); + let eigval_axis = Axis::new().type_(AxisType::Log); // load the chart data. when there's no history, we load the data // point (0, None) to clear the chart. it would feel more natural to // load empty data vectors, but that turns out not to clear the // chart: it instead leads to previous data being re-used - let mut eigval_series = Scatter::new().symbol_size(7); - if hess_eigvals.len() > 0 { - eigval_series = eigval_series.data(hess_eigvals); - } else { - eigval_series = eigval_series.data(vec![None::, None::]); - } + let eigval_series_pos = Scatter::new() + .symbol_size(4.5) + .data( + if hess_eigvals_pos.len() > 0 { + hess_eigvals_pos + .into_iter() + .map(into_time_point) + .collect() + } else { + vec![vec![Some(0.0), None::]] + } + ); + let eigval_series_neg = Scatter::new() + .symbol(Symbol::Diamond) + .symbol_size(6.0) + .data( + if hess_eigvals_neg.len() > 0 { + hess_eigvals_neg + .into_iter() + .map(|(step, val)| (step, -val)) + .map(into_time_point) + .collect() + } else { + vec![vec![Some(0.0), None::]] + } + ); let chart = Chart::new() .animation(false) - .data_zoom(DataZoom::new().y_axis_index(0).right(40).start(0).end(100)) + .data_zoom(DataZoom::new().y_axis_index(0).right(40)) .x_axis(step_axis) .y_axis(eigval_axis) .grid(Grid::new().top(20).right(80).bottom(30).left(60)) - .series(eigval_series); + .series(eigval_series_pos) + .series(eigval_series_neg); renderer.render(CONTAINER_ID, &chart).unwrap(); }); });