Show all logged metrics on the loss graph

This commit is contained in:
Jaret Burkett
2026-06-08 09:43:03 -06:00
parent e281df70dd
commit d7f8887bbf
2 changed files with 99 additions and 63 deletions

View File

@@ -12,9 +12,14 @@ interface Props {
function formatNum(v: number) { function formatNum(v: number) {
if (!Number.isFinite(v)) return ''; if (!Number.isFinite(v)) return '';
if (Math.abs(v) >= 1000) return v.toFixed(0); if (v === 0) return '0';
if (Math.abs(v) >= 10) return v.toFixed(3); const abs = Math.abs(v);
if (Math.abs(v) >= 1) return v.toFixed(4); // Very small / very large magnitudes read better as exponents (e.g. 1.00e-5)
// than as long decimal strings like 0.0000100.
if (abs < 1e-3 || abs >= 1e6) return v.toExponential(2);
if (abs >= 1000) return v.toFixed(0);
if (abs >= 10) return v.toFixed(3);
if (abs >= 1) return v.toFixed(4);
return v.toPrecision(4); return v.toPrecision(4);
} }
@@ -113,12 +118,13 @@ export default function JobLossGraph({ job }: Props) {
const [isZoomed, setIsZoomed] = useState(false); const [isZoomed, setIsZoomed] = useState(false);
// keep enabled map in sync with discovered keys (enable new ones automatically) // keep enabled map in sync with discovered keys. Only "loss/loss" is on by
// default; every other metric starts deactivated (user can toggle it on).
useEffect(() => { useEffect(() => {
setEnabled(prev => { setEnabled(prev => {
const next = { ...prev }; const next = { ...prev };
for (const k of lossKeys) { for (const k of lossKeys) {
if (next[k] === undefined) next[k] = true; if (next[k] === undefined) next[k] = k === 'loss/loss';
} }
for (const k of Object.keys(next)) { for (const k of Object.keys(next)) {
if (!lossKeys.includes(k)) delete next[k]; if (!lossKeys.includes(k)) delete next[k];
@@ -155,7 +161,23 @@ export default function JobLossGraph({ job }: Props) {
const data: (number[] | (number | null)[])[] = [xs]; const data: (number[] | (number | null)[])[] = [xs];
const seriesConfigs: uPlot.Series[] = [{}]; // x const seriesConfigs: uPlot.Series[] = [{}]; // x
for (const key of activeKeys) { // Each metric gets its own y-scale (so unrelated magnitudes auto-range
// independently) plus a matching colored axis.
const scales: uPlot.Scales = { x: { time: false } };
const axes: uPlot.Axis[] = [
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
},
];
// Data columns belonging to each scale, for per-scale clip percentiles.
const scaleArrays: Record<string, (number | null)[][]> = {};
for (let ki = 0; ki < activeKeys.length; ki++) {
const key = activeKeys[ki];
const scaleKey = `y::${key}`;
const pts: LossPoint[] = series[key] ?? []; const pts: LossPoint[] = series[key] ?? [];
const map = new Map<number, number>(); const map = new Map<number, number>();
for (const p of pts) { for (const p of pts) {
@@ -172,57 +194,92 @@ export default function JobLossGraph({ job }: Props) {
const colorFaded = color.replace('1)', '0.40)'); const colorFaded = color.replace('1)', '0.40)');
const colorDull = dulledColor(color); const colorDull = dulledColor(color);
const colArrays: (number | null)[][] = [];
if (showRaw) { if (showRaw) {
data.push(raw); data.push(raw);
seriesConfigs.push({ seriesConfigs.push({
label: `${key} (raw)`, label: `${key} (raw)`,
scale: scaleKey,
stroke: colorFaded, stroke: colorFaded,
width: 1.25, width: 1.25,
spanGaps: false, spanGaps: false,
points: { show: false }, points: { show: false },
}); });
colArrays.push(raw);
} }
if (showSmoothed) { if (showSmoothed) {
data.push(smooth); data.push(smooth);
seriesConfigs.push({ seriesConfigs.push({
label: key, label: key,
scale: scaleKey,
stroke: color, stroke: color,
width: 2, width: 2,
spanGaps: false, spanGaps: false,
points: { show: false }, points: { show: false },
}); });
colArrays.push(smooth);
} }
data.push(fullSmooth); data.push(fullSmooth);
seriesConfigs.push({ seriesConfigs.push({
label: `${key} (trend)`, label: `${key} (trend)`,
scale: scaleKey,
stroke: colorDull, stroke: colorDull,
width: 2.5, width: 2.5,
spanGaps: false, spanGaps: false,
points: { show: false }, points: { show: false },
}); });
colArrays.push(fullSmooth);
scaleArrays[scaleKey] = colArrays;
scales[scaleKey] = {
distr: useLogScale ? 3 : 1,
range: (_u, dataMin, dataMax) => {
const c = yClipRef.current?.[scaleKey];
if (c) return [c.min, c.max];
return [dataMin, dataMax];
},
};
axes.push({
scale: scaleKey,
side: ki % 2 === 0 ? 3 : 1, // alternate left / right
stroke: color,
label: key,
labelSize: 14,
// Only the first scale draws gridlines; overlaying grids from multiple
// independent scales would be unreadable.
grid: { show: ki === 0, stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
size: 60,
values: (_u, ticks) => ticks.map(tk => formatNum(tk)),
});
} }
// y-domain clipping (2nd–98th percentile of all visible y values). // y-domain clipping (2nd–98th percentile), computed per scale.
let yClip: { min: number; max: number } | null = null; let yClip: Record<string, { min: number; max: number }> | null = null;
if (clipOutliers && xs.length >= 10) { if (clipOutliers && xs.length >= 10) {
const vals: number[] = []; yClip = {};
for (let s = 1; s < data.length; s++) { for (const scaleKey of Object.keys(scaleArrays)) {
const arr = data[s] as (number | null)[]; const vals: number[] = [];
for (const v of arr) { for (const arr of scaleArrays[scaleKey]) {
if (v !== null && Number.isFinite(v)) vals.push(v as number); for (const v of arr) {
if (v !== null && Number.isFinite(v)) vals.push(v as number);
}
} }
} if (vals.length >= 10) {
if (vals.length >= 10) { vals.sort((a, b) => a - b);
vals.sort((a, b) => a - b); const lo = vals[Math.floor(vals.length * 0.02)];
const lo = vals[Math.floor(vals.length * 0.02)]; const hi = vals[Math.ceil(vals.length * 0.98) - 1];
const hi = vals[Math.ceil(vals.length * 0.98) - 1]; if (Number.isFinite(lo) && Number.isFinite(hi) && lo !== hi) {
if (Number.isFinite(lo) && Number.isFinite(hi) && lo !== hi) { yClip[scaleKey] = { min: lo, max: hi };
yClip = { min: lo, max: hi }; }
} }
} }
} }
return { data: data as uPlot.AlignedData, seriesConfigs, yClip }; return { data: data as uPlot.AlignedData, seriesConfigs, scales, axes, yClip };
}, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale, showRaw, showSmoothed, clipOutliers]); }, [series, activeKeys, smoothing, plotStride, windowSize, useLogScale, showRaw, showSmoothed, clipOutliers]);
// Layout wrapper we measure for sizing — uPlot collapses its own mount node // Layout wrapper we measure for sizing — uPlot collapses its own mount node
@@ -231,8 +288,8 @@ export default function JobLossGraph({ job }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null); const uplotRef = useRef<uPlot | null>(null);
// Latest yClip read by the y-scale range fn — kept current via effect. // Latest per-scale yClip read by the y-scale range fns — kept current via effect.
const yClipRef = useRef<{ min: number; max: number } | null>(null); const yClipRef = useRef<Record<string, { min: number; max: number }> | null>(null);
useEffect(() => { useEffect(() => {
yClipRef.current = built.yClip; yClipRef.current = built.yClip;
}, [built.yClip]); }, [built.yClip]);
@@ -267,31 +324,8 @@ export default function JobLossGraph({ job }: Props) {
height: initialHeight, height: initialHeight,
padding: [12, 16, 0, 4], padding: [12, 16, 0, 4],
series: built.seriesConfigs, series: built.seriesConfigs,
scales: { scales: built.scales,
x: { time: false }, axes: built.axes,
y: {
distr: useLogScale ? 3 : 1,
range: (_u, dataMin, dataMax) => {
const c = yClipRef.current;
if (c) return [c.min, c.max];
return [dataMin, dataMax];
},
},
},
axes: [
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
},
{
stroke: 'rgba(255,255,255,0.55)',
grid: { stroke: 'rgba(255,255,255,0.06)' },
ticks: { stroke: 'rgba(255,255,255,0.15)' },
size: 60,
values: (_u, ticks) => ticks.map(tk => formatNum(tk)),
},
],
cursor: { cursor: {
drag: { x: true, y: false, setScale: true }, drag: { x: true, y: false, setScale: true },
points: { size: 6 }, points: { size: 6 },
@@ -314,12 +348,20 @@ export default function JobLossGraph({ job }: Props) {
uplotRef.current = new uPlot(opts, built.data, containerRef.current); uplotRef.current = new uPlot(opts, built.data, containerRef.current);
setIsZoomed(false); setIsZoomed(false);
// After uPlot mounts its legend, right-size the canvas against the actual // Right-size the canvas against the legend height so it fills the remaining
// legend height so the canvas fills the remaining vertical space. // vertical space. Defer to the next frame: the legend's height depends on
const fitted = computeCanvasSize(host); // how many series wrap, and that layout isn't settled synchronously after
if (fitted) uplotRef.current.setSize(fitted); // construction — measuring now would read a stale height (the bug that
// previously required a manual resize to correct).
const raf = requestAnimationFrame(() => {
const u = uplotRef.current;
if (!u) return;
const fitted = computeCanvasSize(host);
if (fitted) u.setSize(fitted);
});
return () => { return () => {
cancelAnimationFrame(raf);
uplotRef.current?.destroy(); uplotRef.current?.destroy();
uplotRef.current = null; uplotRef.current = null;
}; };

View File

@@ -11,12 +11,6 @@ export interface LossPoint {
type SeriesMap = Record<string, LossPoint[]>; type SeriesMap = Record<string, LossPoint[]>;
function isLossKey(key: string) {
// treat anything containing "loss" as a loss-series
// (covers loss, train_loss, val_loss, loss/xyz, etc.)
return /loss/i.test(key);
}
export default function useJobLossLog(jobID: string, reloadInterval: null | number = null) { export default function useJobLossLog(jobID: string, reloadInterval: null | number = null) {
const [series, setSeries] = useState<SeriesMap>({}); const [series, setSeries] = useState<SeriesMap>({});
const [keys, setKeys] = useState<string[]>([]); const [keys, setKeys] = useState<string[]>([]);
@@ -29,10 +23,10 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
const lastStepByKeyRef = useRef<Record<string, number | null>>({}); const lastStepByKeyRef = useRef<Record<string, number | null>>({});
const lossKeys = useMemo(() => { const lossKeys = useMemo(() => {
const base = (keys ?? []).filter(isLossKey); const base = keys ?? [];
// if keys table is empty early on, fall back to just "loss" // if keys table is empty early on, fall back to just "loss"
if (base.length === 0) return ['loss']; if (base.length === 0) return ['loss'];
return base.sort(); return [...base].sort();
}, [keys]); }, [keys]);
const refreshLoss = useCallback(async () => { const refreshLoss = useCallback(async () => {
@@ -54,7 +48,7 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
const newKeys = first.keys ?? []; const newKeys = first.keys ?? [];
setKeys(newKeys); setKeys(newKeys);
const wantedLossKeys = (newKeys.filter(isLossKey).length ? newKeys.filter(isLossKey) : ['loss']).sort(); const wantedLossKeys = (newKeys.length ? [...newKeys] : ['loss']).sort();
// Step 2: fetch each loss key incrementally (since_step per key if polling) // Step 2: fetch each loss key incrementally (since_step per key if polling)
const requests = wantedLossKeys.map(k => { const requests = wantedLossKeys.map(k => {
@@ -100,9 +94,9 @@ export default function useJobLossLog(jobID: string, reloadInterval: null | numb
: (lastStepByKeyRef.current[k] ?? null); : (lastStepByKeyRef.current[k] ?? null);
} }
// remove stale loss keys that no longer exist (rare, but keeps UI clean) // remove stale keys that no longer exist (rare, but keeps UI clean)
for (const existingKey of Object.keys(next)) { for (const existingKey of Object.keys(next)) {
if (isLossKey(existingKey) && !wantedLossKeys.includes(existingKey)) { if (!wantedLossKeys.includes(existingKey)) {
delete next[existingKey]; delete next[existingKey];
delete lastStepByKeyRef.current[existingKey]; delete lastStepByKeyRef.current[existingKey];
} }