Files
Atomizer/atomizer-dashboard/frontend/src/components/ConvergencePlot.tsx
Antoine 75d7036193 feat: Enhance dashboard with charts, study report viewer, and pruning tracking
- Add ConvergencePlot component with running best, statistics, gradient fill
- Add ParameterImportanceChart with Pearson correlation analysis
- Add StudyReportViewer with KaTeX math rendering and full markdown support
- Update pruning endpoint to query Optuna database directly
- Add /report endpoint for STUDY_REPORT.md files
- Fix chart data transformation for single/multi-objective studies
- Update Protocol 13 documentation with new components
- Update generate-report skill with dashboard integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 22:01:49 -05:00

257 lines
8.3 KiB
TypeScript

/**
* Convergence Plot
* Shows optimization progress over time with running best and improvement rate
*/
import { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Area,
ComposedChart,
Legend
} from 'recharts';
interface Trial {
trial_number: number;
values: number[];
state?: string;
}
interface ConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
}
export function ConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize'
}: ConvergencePlotProps) {
const convergenceData = useMemo(() => {
if (!trials || trials.length === 0) return [];
// Sort by trial number
const sortedTrials = [...trials]
.filter(t => t.values && t.values.length > objectiveIndex && t.state !== 'FAIL')
.sort((a, b) => a.trial_number - b.trial_number);
if (sortedTrials.length === 0) return [];
let runningBest = direction === 'minimize' ? Infinity : -Infinity;
const data = sortedTrials.map((trial, idx) => {
const value = trial.values[objectiveIndex];
// Update running best
if (direction === 'minimize') {
runningBest = Math.min(runningBest, value);
} else {
runningBest = Math.max(runningBest, value);
}
// Calculate improvement from first trial
const firstValue = sortedTrials[0].values[objectiveIndex];
const improvement = direction === 'minimize'
? ((firstValue - runningBest) / firstValue) * 100
: ((runningBest - firstValue) / firstValue) * 100;
return {
trial: trial.trial_number,
value,
best: runningBest,
improvement: Math.max(0, improvement),
index: idx + 1
};
});
return data;
}, [trials, objectiveIndex, direction]);
// Calculate statistics
const stats = useMemo(() => {
if (convergenceData.length === 0) return null;
const values = convergenceData.map(d => d.value);
const best = convergenceData[convergenceData.length - 1]?.best ?? 0;
const first = convergenceData[0]?.value ?? 0;
const improvement = direction === 'minimize'
? ((first - best) / first) * 100
: ((best - first) / first) * 100;
// Calculate when we found 90% of the improvement
const targetImprovement = 0.9 * improvement;
let trialsTo90 = convergenceData.length;
for (let i = 0; i < convergenceData.length; i++) {
if (convergenceData[i].improvement >= targetImprovement) {
trialsTo90 = i + 1;
break;
}
}
return {
best,
first,
improvement: Math.max(0, improvement),
totalTrials: convergenceData.length,
trialsTo90,
mean: values.reduce((a, b) => a + b, 0) / values.length,
std: Math.sqrt(values.map(v => Math.pow(v - values.reduce((a, b) => a + b, 0) / values.length, 2)).reduce((a, b) => a + b, 0) / values.length)
};
}, [convergenceData, direction]);
if (convergenceData.length === 0) {
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<h3 className="text-lg font-semibold mb-4 text-dark-100">Convergence Plot</h3>
<div className="h-64 flex items-center justify-center text-dark-400">
No trial data available
</div>
</div>
);
}
return (
<div className="bg-dark-700 rounded-lg p-6 border border-dark-500 shadow-sm">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-dark-100">Convergence Plot</h3>
<p className="text-sm text-dark-400 mt-1">
{objectiveName} over {convergenceData.length} trials
</p>
</div>
{stats && (
<div className="flex gap-6 text-sm">
<div className="text-right">
<div className="text-dark-400">Best</div>
<div className="font-semibold text-green-400">{stats.best.toFixed(4)}</div>
</div>
<div className="text-right">
<div className="text-dark-400">Improvement</div>
<div className="font-semibold text-blue-400">{stats.improvement.toFixed(1)}%</div>
</div>
<div className="text-right">
<div className="text-dark-400">90% at trial</div>
<div className="font-semibold text-purple-400">#{stats.trialsTo90}</div>
</div>
</div>
)}
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={convergenceData} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="trial"
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
label={{ value: 'Trial', position: 'bottom', offset: -5, fill: '#94a3b8', fontSize: 12 }}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 11 }}
stroke="#334155"
tickFormatter={(v) => v.toFixed(2)}
label={{ value: objectiveName, angle: -90, position: 'insideLeft', fill: '#94a3b8', fontSize: 12 }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0,0,0,0.3)'
}}
labelStyle={{ color: '#e2e8f0' }}
formatter={(value: number, name: string) => {
const label = name === 'value' ? 'Trial Value' :
name === 'best' ? 'Running Best' : name;
return [value.toFixed(4), label];
}}
labelFormatter={(label) => `Trial #${label}`}
/>
<Legend
verticalAlign="top"
height={36}
wrapperStyle={{ color: '#94a3b8' }}
formatter={(value) => value === 'value' ? 'Trial Value' : 'Running Best'}
/>
{/* Area under trial values */}
<Area
type="monotone"
dataKey="value"
stroke="#60a5fa"
fill="url(#colorValue)"
strokeWidth={0}
/>
{/* Trial values as scatter points */}
<Line
type="monotone"
dataKey="value"
stroke="#60a5fa"
strokeWidth={1}
dot={{ fill: '#60a5fa', r: 3 }}
activeDot={{ fill: '#93c5fd', r: 5 }}
/>
{/* Running best line */}
<Line
type="stepAfter"
dataKey="best"
stroke="#10b981"
strokeWidth={2.5}
dot={false}
/>
{/* Reference line at best value */}
{stats && (
<ReferenceLine
y={stats.best}
stroke="#10b981"
strokeDasharray="5 5"
strokeOpacity={0.5}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Summary stats bar */}
{stats && (
<div className="grid grid-cols-4 gap-4 mt-4 pt-4 border-t border-dark-500 text-center text-sm">
<div>
<div className="text-dark-400">First Value</div>
<div className="font-medium text-dark-100">{stats.first.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Mean</div>
<div className="font-medium text-dark-100">{stats.mean.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Std Dev</div>
<div className="font-medium text-dark-100">{stats.std.toFixed(4)}</div>
</div>
<div>
<div className="text-dark-400">Total Trials</div>
<div className="font-medium text-dark-100">{stats.totalTrials}</div>
</div>
</div>
)}
</div>
);
}