- 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>
257 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|