From 2d90210a69dea9821277a4535de2d8f0db57d62f Mon Sep 17 00:00:00 2001 From: Shine1i Date: Tue, 17 Feb 2026 19:10:24 +0100 Subject: [PATCH] feat: add reusable chart components for training metrics visualization - Introduced `EvalLossChartCard`, `GradNormChartCard`, `LearningRateChartCard`, and `TrainingLossChartCard` components. - Implemented shared chart settings via `SharedChartSettings` to manage scale, outliers, and view configuration. - Added utilities for metrics formatting, step tick generation, data compression, and smoothing (`utils.ts`). - Created types and structures for chart data handling (`types.ts`). --- .../studio/sections/charts-content.tsx | 792 +++++------------- .../sections/charts/eval-loss-chart-card.tsx | 146 ++++ .../sections/charts/grad-norm-chart-card.tsx | 153 ++++ .../charts/learning-rate-chart-card.tsx | 156 ++++ .../sections/charts/shared-chart-settings.tsx | 90 ++ .../charts/training-loss-chart-card.tsx | 248 ++++++ .../features/studio/sections/charts/types.ts | 22 + .../features/studio/sections/charts/utils.ts | 123 +++ .../studio/sections/progress-section.tsx | 62 +- 9 files changed, 1156 insertions(+), 636 deletions(-) create mode 100644 studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx create mode 100644 studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx create mode 100644 studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx create mode 100644 studio/frontend/src/features/studio/sections/charts/shared-chart-settings.tsx create mode 100644 studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx create mode 100644 studio/frontend/src/features/studio/sections/charts/types.ts create mode 100644 studio/frontend/src/features/studio/sections/charts/utils.ts diff --git a/studio/frontend/src/features/studio/sections/charts-content.tsx b/studio/frontend/src/features/studio/sections/charts-content.tsx index bb7a7a131c..4d35d23c87 100644 --- a/studio/frontend/src/features/studio/sections/charts-content.tsx +++ b/studio/frontend/src/features/studio/sections/charts-content.tsx @@ -1,151 +1,19 @@ -import { - Card, - CardAction, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; -import type { ChartConfig } from "@/components/ui/chart"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Label } from "@/components/ui/label"; -import { Slider } from "@/components/ui/slider"; -import { ChartAverageIcon, Settings02Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; import { type ReactElement, useMemo, useState } from "react"; +import { EvalLossChartCard } from "./charts/eval-loss-chart-card"; +import { GradNormChartCard } from "./charts/grad-norm-chart-card"; +import { LearningRateChartCard } from "./charts/learning-rate-chart-card"; +import { TrainingLossChartCard } from "./charts/training-loss-chart-card"; +import type { OutlierMode, ScaleMode, TrainingChartSeries, ViewSettingsState } from "./charts/types"; import { - CartesianGrid, - Line, - LineChart, - ReferenceLine, - XAxis, - YAxis, -} from "recharts"; - -const lossConfig = { - loss: { label: "Loss", color: "#3b82f6" }, - smoothed: { label: "Smoothed", color: "#f59e0b" }, -} satisfies ChartConfig; - -const lrConfig = { - lr: { label: "LR", color: "#8b5cf6" }, -} satisfies ChartConfig; - -const gradNormConfig = { - gradNorm: { label: "Grad Norm", color: "#f97316" }, -} satisfies ChartConfig; - -const evalLossConfig = { - loss: { label: "Eval Loss", color: "#ef4444" }, -} satisfies ChartConfig; - -const placeholderEvalData = [ - { step: 0, loss: 2.8 }, - { step: 50, loss: 2.4 }, - { step: 100, loss: 2.0 }, - { step: 150, loss: 1.7 }, - { step: 200, loss: 1.5 }, -]; - -type LossHistoryItem = { step: number; loss: number }; -type SmoothedLossItem = LossHistoryItem & { smoothed: number }; - -interface TrainingChartSeries { - lossHistory: LossHistoryItem[]; - lrHistory: { step: number; lr: number }[]; - gradNormHistory: { step: number; gradNorm: number }[]; - evalLossHistory: { step: number; loss: number }[]; -} - -const CHART_SYNC_ID = "train-metrics-sync"; -const MAX_RENDER_POINTS = 800; -const DEFAULT_VISIBLE_POINTS = 160; - -function formatStepTick(value: number): string { - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1)}M`; - } - if (value >= 1_000) { - return `${(value / 1_000).toFixed(1)}k`; - } - return String(Math.round(value)); -} - -function compressSeries(data: T[], maxPoints: number): T[] { - if (data.length <= maxPoints) { - return data; - } - - const stride = Math.ceil(data.length / maxPoints); - return data.filter( - (_item, index) => index % stride === 0 || index === data.length - 1, - ); -} - -function buildStepTicks(min: number, max: number, targetCount = 6): number[] { - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return [0, 1]; - } - if (max <= min) { - return [min, max]; - } - - const stepSize = Math.max(1, Math.ceil((max - min) / (targetCount - 1))); - const ticks: number[] = []; - let current = min; - - while (current < max) { - ticks.push(current); - current += stepSize; - } - - ticks.push(max); - return Array.from(new Set(ticks)); -} - -function buildYDomain(values: number[]): [number, number] { - const finiteValues = values.filter((value) => Number.isFinite(value)); - if (finiteValues.length === 0) { - return [0, 1]; - } - - const min = Math.min(...finiteValues); - const max = Math.max(...finiteValues); - - if (min === max) { - const base = Math.abs(min); - const pad = base > 0 ? base * 0.08 : 0.1; - return [min - pad, max + pad]; - } - - const pad = (max - min) * 0.12; - return [min - pad, max + pad]; -} - -function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] { - if (data.length === 0) { - return []; - } - - let s = data[0].loss; - return data.map((d) => { - s = alpha * d.loss + (1 - alpha) * s; - return { ...d, smoothed: +s.toFixed(4) }; - }); -} + DEFAULT_VISIBLE_POINTS, + MAX_RENDER_POINTS, + applyOutlierCap, + buildStepTicks, + buildYDomain, + compressSeries, + ema, + toLog1p, +} from "./charts/utils"; export function ChartsContent({ metrics, @@ -156,48 +24,65 @@ export function ChartsContent({ const [showRaw, setShowRaw] = useState(true); const [showSmoothed, setShowSmoothed] = useState(true); const [showAvgLine, setShowAvgLine] = useState(true); + const [windowSize, setWindowSize] = useState(DEFAULT_VISIBLE_POINTS); + const [panOffset, setPanOffset] = useState(0); + + const [lossScale, setLossScale] = useState("linear"); + const [lrScale, setLrScale] = useState("linear"); + const [gradScale, setGradScale] = useState("linear"); + + const [lossOutlierMode, setLossOutlierMode] = useState("none"); + const [gradOutlierMode, setGradOutlierMode] = useState("none"); + const [lrOutlierMode, setLrOutlierMode] = useState("none"); - const lossHistory = metrics.lossHistory; const smoothedData = useMemo( - () => (lossHistory.length > 0 ? ema(lossHistory, 1 - smoothing) : []), - [lossHistory, smoothing], + () => (metrics.lossHistory.length > 0 ? ema(metrics.lossHistory, 1 - smoothing) : []), + [metrics.lossHistory, smoothing], ); const reducedLossData = useMemo( () => compressSeries(smoothedData, MAX_RENDER_POINTS), [smoothedData], ); - const reducedGradNormData = useMemo( () => compressSeries(metrics.gradNormHistory, MAX_RENDER_POINTS), [metrics.gradNormHistory], ); - const reducedLrData = useMemo( () => compressSeries(metrics.lrHistory, MAX_RENDER_POINTS), [metrics.lrHistory], ); - const reducedEvalLossData = useMemo( () => compressSeries(metrics.evalLossHistory, MAX_RENDER_POINTS), [metrics.evalLossHistory], ); - const visibleStepDomain = useMemo<[number, number]>(() => { - const allSteps = [ - ...reducedLossData.map((point) => point.step), - ...reducedGradNormData.map((point) => point.step), - ...reducedLrData.map((point) => point.step), - ].sort((a, b) => a - b); + const allSteps = useMemo(() => { + const set = new Set(); + for (const point of reducedLossData) set.add(point.step); + for (const point of reducedGradNormData) set.add(point.step); + for (const point of reducedLrData) set.add(point.step); + return Array.from(set).sort((a, b) => a - b); + }, [reducedGradNormData, reducedLossData, reducedLrData]); + const effectiveWindowSize = Math.min( + Math.max(1, Math.round(windowSize)), + Math.max(1, allSteps.length), + ); + const maxPanOffset = Math.max(0, allSteps.length - effectiveWindowSize); + const effectivePanOffset = Math.min(Math.max(0, Math.round(panOffset)), maxPanOffset); + + const visibleStepDomain = useMemo<[number, number]>(() => { if (allSteps.length === 0) { return [0, 1]; } + const endIndex = Math.max(0, allSteps.length - 1 - effectivePanOffset); + const startIndex = Math.max(0, endIndex - effectiveWindowSize + 1); const minStep = allSteps[0] ?? 0; - const endStep = allSteps[allSteps.length - 1] ?? 1; - const startIndex = Math.max(0, allSteps.length - DEFAULT_VISIBLE_POINTS); const startStep = allSteps[startIndex] ?? minStep; + const endStep = allSteps[endIndex] ?? startStep; + if (startStep === endStep) { return [startStep, startStep + 4]; } @@ -205,68 +90,114 @@ export function ChartsContent({ return [Math.max(minStep, endStep - 6), endStep]; } return [startStep, endStep]; - }, [reducedGradNormData, reducedLossData, reducedLrData]); + }, [allSteps, effectivePanOffset, effectiveWindowSize]); const xAxisTicks = useMemo( () => buildStepTicks(visibleStepDomain[0], visibleStepDomain[1]), [visibleStepDomain], ); - const visibleLossValues = useMemo( + const displayLossData = useMemo( () => - reducedLossData - .filter( - (point) => - point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], - ) - .map((point) => point.loss), - [reducedLossData, visibleStepDomain], + reducedLossData.map((point) => ({ + ...point, + displayLoss: lossScale === "log" ? toLog1p(point.loss) : point.loss, + displaySmoothed: + lossScale === "log" ? toLog1p(point.smoothed) : point.smoothed, + })), + [lossScale, reducedLossData], ); - const visibleSmoothValues = useMemo( + const displayGradData = useMemo( () => - reducedLossData - .filter( - (point) => - point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], - ) - .map((point) => point.smoothed), - [reducedLossData, visibleStepDomain], + reducedGradNormData.map((point) => ({ + ...point, + displayGradNorm: + gradScale === "log" ? toLog1p(point.gradNorm) : point.gradNorm, + })), + [gradScale, reducedGradNormData], ); - const visibleGradValues = useMemo( + const displayLrData = useMemo( () => - reducedGradNormData + reducedLrData.map((point) => ({ + ...point, + displayLr: lrScale === "log" ? toLog1p(point.lr) : point.lr, + })), + [lrScale, reducedLrData], + ); + + const visibleLossDisplayValues = useMemo(() => { + const values: number[] = []; + + for (const point of displayLossData) { + if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) { + continue; + } + if (showRaw && Number.isFinite(point.displayLoss)) { + values.push(point.displayLoss); + } + if (showSmoothed && Number.isFinite(point.displaySmoothed)) { + values.push(point.displaySmoothed); + } + } + + if (values.length === 0) { + for (const point of displayLossData) { + if (point.step < visibleStepDomain[0] || point.step > visibleStepDomain[1]) { + continue; + } + if (Number.isFinite(point.displayLoss)) { + values.push(point.displayLoss); + } + if (Number.isFinite(point.displaySmoothed)) { + values.push(point.displaySmoothed); + } + } + } + + return values; + }, [displayLossData, showRaw, showSmoothed, visibleStepDomain]); + + const visibleGradDisplayValues = useMemo( + () => + displayGradData .filter( (point) => point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], ) - .map((point) => point.gradNorm) + .map((point) => point.displayGradNorm) .filter((value) => Number.isFinite(value)), - [reducedGradNormData, visibleStepDomain], + [displayGradData, visibleStepDomain], ); - const visibleLrValues = useMemo( + const visibleLrDisplayValues = useMemo( () => - reducedLrData + displayLrData .filter( (point) => point.step >= visibleStepDomain[0] && point.step <= visibleStepDomain[1], ) - .map((point) => point.lr) + .map((point) => point.displayLr) .filter((value) => Number.isFinite(value)), - [reducedLrData, visibleStepDomain], + [displayLrData, visibleStepDomain], ); const lossDomain = useMemo( - () => buildYDomain([...visibleLossValues, ...visibleSmoothValues]), - [visibleLossValues, visibleSmoothValues], + () => buildYDomain(applyOutlierCap(visibleLossDisplayValues, lossOutlierMode)), + [lossOutlierMode, visibleLossDisplayValues], + ); + const gradDomain = useMemo( + () => buildYDomain(applyOutlierCap(visibleGradDisplayValues, gradOutlierMode)), + [gradOutlierMode, visibleGradDisplayValues], + ); + const lrDomain = useMemo( + () => buildYDomain(applyOutlierCap(visibleLrDisplayValues, lrOutlierMode)), + [lrOutlierMode, visibleLrDisplayValues], ); - const gradDomain = useMemo(() => buildYDomain(visibleGradValues), [visibleGradValues]); - const lrDomain = useMemo(() => buildYDomain(visibleLrValues), [visibleLrValues]); const evalLossDomain = useMemo(() => { - const vals = reducedEvalLossData.map((p) => p.loss); + const vals = reducedEvalLossData.map((point) => point.loss); return buildYDomain(vals); }, [reducedEvalLossData]); @@ -277,427 +208,78 @@ export function ChartsContent({ return buildStepTicks(min, max); }, [reducedEvalLossData]); - const avg = + const avgRaw = metrics.lossHistory.length > 0 ? +( - metrics.lossHistory.reduce((a, b) => a + b.loss, 0) / - metrics.lossHistory.length - ).toFixed(4) + metrics.lossHistory.reduce((sum, point) => sum + point.loss, 0) / + metrics.lossHistory.length + ).toFixed(4) : 0; + const avgDisplay = lossScale === "log" ? toLog1p(avgRaw) : avgRaw; + + const minWindow = Math.min(10, Math.max(1, allSteps.length)); + const viewSettings: ViewSettingsState = { + effectiveWindowSize, + minWindow, + allStepsLength: allSteps.length, + effectivePanOffset, + maxPanOffset, + setWindowSize: (value) => setWindowSize(value), + setPanOffset: (value) => setPanOffset(value), + }; return (
- - - Training Loss - - - - - - - - Chart Settings - - -
-
- - - {smoothing.toFixed(2)} - -
- setSmoothing(v)} - min={0} - max={0.99} - step={0.01} - /> -
- - - Show raw loss - - - Show smoothed loss - - - Show average line - -
-
-
-
- - - - - formatStepTick(Number(value))} - interval="preserveStartEnd" - /> - Number(value).toFixed(2)} - /> - - `Step ${payload?.[0]?.payload?.step ?? ""}` - } - /> - } - /> - {showAvgLine && ( - - )} - {showRaw && ( - - )} - {showSmoothed && ( - - )} - } /> - - - -
- - - - Gradient Norm - - - - - - formatStepTick(Number(value))} - interval="preserveStartEnd" - /> - Number(value).toFixed(2)} - /> - - `Step ${payload?.[0]?.payload?.step ?? ""}` - } - /> - } - /> - - } /> - - - - - - - - Learning Rate - - - - - - formatStepTick(Number(value))} - interval="preserveStartEnd" - /> - { - const num = Number(value); - return Number.isFinite(num) ? num.toExponential(0) : "0e+0"; - }} - /> - - `Step ${payload?.[0]?.payload?.step ?? ""}` - } - formatter={(value) => { - const num = Number(value); - return [Number.isFinite(num) ? num.toExponential(3) : "0e+0", "LR"]; - }} - /> - } - /> - - } /> - - - - - - - - 0 ? "" : " text-muted-foreground"}`}> - Eval Loss - - - - {reducedEvalLossData.length > 0 ? ( - - - - formatStepTick(Number(value))} - interval="preserveStartEnd" - /> - Number(value).toFixed(2)} - /> - - `Step ${payload?.[0]?.payload?.step ?? ""}` - } - /> - } - /> - - } /> - - - ) : ( -
- - - - - - - - -
- -

- {isTraining && evalEnabled ? "Waiting for first evaluation step…" : "Evaluation not configured"} -

-

- {isTraining && evalEnabled ? "Chart will appear once eval_steps is reached" : "Set eval dataset & eval_steps to track eval loss"} -

-
-
- )} -
-
+ + + +
); } diff --git a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx new file mode 100644 index 0000000000..844eb87b4e --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx @@ -0,0 +1,146 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ChartConfig } from "@/components/ui/chart"; +import { ChartAverageIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { formatStepTick, placeholderEvalData } from "./utils"; + +const evalLossConfig = { + loss: { label: "Eval Loss", color: "#ef4444" }, +} satisfies ChartConfig; + +export function EvalLossChartCard({ + data, + domain, + ticks, + isTraining, + evalEnabled, +}: { + data: { step: number; loss: number }[]; + domain: [number, number]; + ticks?: number[]; + isTraining: boolean; + evalEnabled: boolean; +}): ReactElement { + return ( + + + 0 ? "" : " text-muted-foreground"}`}> + Eval Loss + + + + {data.length > 0 ? ( + + + + formatStepTick(Number(value))} + interval="preserveStartEnd" + /> + Number(value).toFixed(2)} + /> + + `Step ${payload?.[0]?.payload?.step ?? ""}` + } + /> + } + /> + + } /> + + + ) : ( +
+ + + + + + + + +
+ +

+ {isTraining && evalEnabled + ? "Waiting for first evaluation step…" + : "Evaluation not configured"} +

+

+ {isTraining && evalEnabled + ? "Chart will appear once eval_steps is reached" + : "Set eval dataset & eval_steps to track eval loss"} +

+
+
+ )} +
+
+ ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx new file mode 100644 index 0000000000..0efa57c3fd --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/grad-norm-chart-card.tsx @@ -0,0 +1,153 @@ +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ChartConfig } from "@/components/ui/chart"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Settings02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { SharedChartSettings } from "./shared-chart-settings"; +import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; +import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils"; + +const gradNormConfig = { + displayGradNorm: { label: "Grad Norm", color: "#f97316" }, +} satisfies ChartConfig; + +interface GradNormPoint { + step: number; + gradNorm: number; + displayGradNorm: number; +} + +export function GradNormChartCard({ + data, + domain, + visibleStepDomain, + xAxisTicks, + scale, + setScale, + outlierMode, + setOutlierMode, + viewSettings, +}: { + data: GradNormPoint[]; + domain: [number, number]; + visibleStepDomain: [number, number]; + xAxisTicks: number[]; + scale: ScaleMode; + setScale: (value: ScaleMode) => void; + outlierMode: OutlierMode; + setOutlierMode: (value: OutlierMode) => void; + viewSettings: ViewSettingsState; +}): ReactElement { + return ( + + + Gradient Norm + + + + + + + Chart Settings + + + + + + + + + + formatStepTick(Number(value))} + interval="preserveStartEnd" + /> + { + const num = Number(value); + if (!Number.isFinite(num)) return "0"; + const shown = scale === "log" ? fromLog1p(num) : num; + return formatMetric(shown); + }} + /> + + `Step ${payload?.[0]?.payload?.step ?? ""}` + } + formatter={(_value, _name, item) => { + const raw = Number(item?.payload?.gradNorm); + return [formatMetric(raw), "Grad Norm"]; + }} + /> + } + /> + + } /> + + + + + ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx new file mode 100644 index 0000000000..cc2cfd3ed2 --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx @@ -0,0 +1,156 @@ +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ChartConfig } from "@/components/ui/chart"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Settings02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { SharedChartSettings } from "./shared-chart-settings"; +import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; +import { CHART_SYNC_ID, formatStepTick, fromLog1p } from "./utils"; + +const lrConfig = { + displayLr: { label: "LR", color: "#8b5cf6" }, +} satisfies ChartConfig; + +interface LearningRatePoint { + step: number; + lr: number; + displayLr: number; +} + +export function LearningRateChartCard({ + data, + domain, + visibleStepDomain, + xAxisTicks, + scale, + setScale, + outlierMode, + setOutlierMode, + viewSettings, +}: { + data: LearningRatePoint[]; + domain: [number, number]; + visibleStepDomain: [number, number]; + xAxisTicks: number[]; + scale: ScaleMode; + setScale: (value: ScaleMode) => void; + outlierMode: OutlierMode; + setOutlierMode: (value: OutlierMode) => void; + viewSettings: ViewSettingsState; +}): ReactElement { + return ( + + + Learning Rate + + + + + + + Chart Settings + + + + + + + + + + formatStepTick(Number(value))} + interval="preserveStartEnd" + /> + { + const num = Number(value); + if (!Number.isFinite(num)) return "0e+0"; + const shown = scale === "log" ? fromLog1p(num) : num; + return shown.toExponential(0); + }} + /> + + `Step ${payload?.[0]?.payload?.step ?? ""}` + } + formatter={(_value, _name, item) => { + const raw = Number(item?.payload?.lr); + return [ + Number.isFinite(raw) ? raw.toExponential(3) : "0e+0", + "LR", + ]; + }} + /> + } + /> + + } /> + + + + + ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/shared-chart-settings.tsx b/studio/frontend/src/features/studio/sections/charts/shared-chart-settings.tsx new file mode 100644 index 0000000000..22526abc90 --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/shared-chart-settings.tsx @@ -0,0 +1,90 @@ +import { DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import type { ReactElement } from "react"; +import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; + +export function SharedChartSettings({ + view, + scale, + setScale, + outlierMode, + setOutlierMode, +}: { + view: ViewSettingsState; + scale: ScaleMode; + setScale: (value: ScaleMode) => void; + outlierMode: OutlierMode; + setOutlierMode: (value: OutlierMode) => void; +}): ReactElement { + return ( + <> + + View +
+
+ + + {view.effectiveWindowSize} + +
+ view.setWindowSize(Math.max(1, Math.round(v)))} + min={view.minWindow} + max={Math.max(view.minWindow, view.allStepsLength)} + step={1} + /> +
+
+
+ + + {view.effectivePanOffset} + +
+ view.setPanOffset(Math.max(0, Math.round(v)))} + min={0} + max={Math.max(0, view.maxPanOffset)} + step={1} + /> +
+ + Y Scale + checked && setScale("linear")} + > + Linear + + checked && setScale("log")} + > + Log (log1p) + + + Outliers + checked && setOutlierMode("none")} + > + No clipping + + checked && setOutlierMode("p99")} + > + Clip above p99 + + checked && setOutlierMode("p95")} + > + Clip above p95 + + + ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx new file mode 100644 index 0000000000..bc548d0c11 --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx @@ -0,0 +1,248 @@ +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { ChartConfig } from "@/components/ui/chart"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { Settings02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { ReactElement } from "react"; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts"; +import { SharedChartSettings } from "./shared-chart-settings"; +import type { OutlierMode, ScaleMode, ViewSettingsState } from "./types"; +import { CHART_SYNC_ID, formatMetric, formatStepTick, fromLog1p } from "./utils"; + +const lossConfig = { + displayLoss: { label: "Loss", color: "#3b82f6" }, + displaySmoothed: { label: "Smoothed", color: "#f59e0b" }, +} satisfies ChartConfig; + +interface LossChartPoint { + step: number; + loss: number; + smoothed: number; + displayLoss: number; + displaySmoothed: number; +} + +export function TrainingLossChartCard({ + data, + domain, + visibleStepDomain, + xAxisTicks, + avgRaw, + avgDisplay, + smoothing, + setSmoothing, + showRaw, + setShowRaw, + showSmoothed, + setShowSmoothed, + showAvgLine, + setShowAvgLine, + viewSettings, + scale, + setScale, + outlierMode, + setOutlierMode, +}: { + data: LossChartPoint[]; + domain: [number, number]; + visibleStepDomain: [number, number]; + xAxisTicks: number[]; + avgRaw: number; + avgDisplay: number; + smoothing: number; + setSmoothing: (value: number) => void; + showRaw: boolean; + setShowRaw: (value: boolean) => void; + showSmoothed: boolean; + setShowSmoothed: (value: boolean) => void; + showAvgLine: boolean; + setShowAvgLine: (value: boolean) => void; + viewSettings: ViewSettingsState; + scale: ScaleMode; + setScale: (value: ScaleMode) => void; + outlierMode: OutlierMode; + setOutlierMode: (value: OutlierMode) => void; +}): ReactElement { + return ( + + + Training Loss + + + + + + + Chart Settings + +
+
+ + + {smoothing.toFixed(2)} + +
+ setSmoothing(v)} + min={0} + max={0.99} + step={0.01} + /> +
+ + setShowRaw(Boolean(value))} + > + Show raw loss + + setShowSmoothed(Boolean(value))} + > + Show smoothed loss + + setShowAvgLine(Boolean(value))} + > + Show average line + + +
+
+
+
+ + + + + formatStepTick(Number(value))} + interval="preserveStartEnd" + /> + { + const num = Number(value); + if (!Number.isFinite(num)) return "0"; + const shown = scale === "log" ? fromLog1p(num) : num; + return formatMetric(shown); + }} + /> + + `Step ${payload?.[0]?.payload?.step ?? ""}` + } + formatter={(_value, name, item) => { + if (name === "displaySmoothed") { + return [formatMetric(Number(item?.payload?.smoothed)), "Smoothed"]; + } + return [formatMetric(Number(item?.payload?.loss)), "Loss"]; + }} + /> + } + /> + {showAvgLine && ( + + )} + {showRaw && ( + + )} + {showSmoothed && ( + + )} + } /> + + + +
+ ); +} diff --git a/studio/frontend/src/features/studio/sections/charts/types.ts b/studio/frontend/src/features/studio/sections/charts/types.ts new file mode 100644 index 0000000000..dc28933e64 --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/types.ts @@ -0,0 +1,22 @@ +export type ScaleMode = "linear" | "log"; +export type OutlierMode = "none" | "p99" | "p95"; + +export type LossHistoryItem = { step: number; loss: number }; +export type SmoothedLossItem = LossHistoryItem & { smoothed: number }; + +export interface TrainingChartSeries { + lossHistory: LossHistoryItem[]; + lrHistory: { step: number; lr: number }[]; + gradNormHistory: { step: number; gradNorm: number }[]; + evalLossHistory: { step: number; loss: number }[]; +} + +export interface ViewSettingsState { + effectiveWindowSize: number; + minWindow: number; + allStepsLength: number; + effectivePanOffset: number; + maxPanOffset: number; + setWindowSize: (value: number) => void; + setPanOffset: (value: number) => void; +} diff --git a/studio/frontend/src/features/studio/sections/charts/utils.ts b/studio/frontend/src/features/studio/sections/charts/utils.ts new file mode 100644 index 0000000000..ca9b3bc3ca --- /dev/null +++ b/studio/frontend/src/features/studio/sections/charts/utils.ts @@ -0,0 +1,123 @@ +import type { LossHistoryItem, OutlierMode, SmoothedLossItem } from "./types"; + +export const CHART_SYNC_ID = "train-metrics-sync"; +export const MAX_RENDER_POINTS = 800; +export const DEFAULT_VISIBLE_POINTS = 160; + +export const placeholderEvalData = [ + { step: 0, loss: 2.8 }, + { step: 50, loss: 2.4 }, + { step: 100, loss: 2.0 }, + { step: 150, loss: 1.7 }, + { step: 200, loss: 1.5 }, +]; + +export function toLog1p(value: number): number { + const safe = Number.isFinite(value) ? Math.max(value, 0) : 0; + return Math.log10(safe + 1); +} + +export function fromLog1p(value: number): number { + return Math.max(0, 10 ** value - 1); +} + +export function formatMetric(value: number): string { + if (!Number.isFinite(value)) return "0"; + if (value === 0) return "0"; + if (value >= 1000) return value.toFixed(0); + if (value >= 1) return value.toFixed(2); + return value.toExponential(2); +} + +export function formatStepTick(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return String(Math.round(value)); +} + +export function compressSeries(data: T[], maxPoints: number): T[] { + if (data.length <= maxPoints) { + return data; + } + + const stride = Math.ceil(data.length / maxPoints); + return data.filter( + (_item, index) => index % stride === 0 || index === data.length - 1, + ); +} + +export function buildStepTicks(min: number, max: number, targetCount = 6): number[] { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return [0, 1]; + } + if (max <= min) { + return [min, max]; + } + + const stepSize = Math.max(1, Math.ceil((max - min) / (targetCount - 1))); + const ticks: number[] = []; + let current = min; + + while (current < max) { + ticks.push(current); + current += stepSize; + } + + ticks.push(max); + return Array.from(new Set(ticks)); +} + +export function buildYDomain(values: number[]): [number, number] { + const finiteValues = values.filter((value) => Number.isFinite(value)); + if (finiteValues.length === 0) { + return [0, 1]; + } + + const min = Math.min(...finiteValues); + const max = Math.max(...finiteValues); + + if (min === max) { + const base = Math.abs(min); + const pad = base > 0 ? base * 0.08 : 0.1; + return [min - pad, max + pad]; + } + + const pad = (max - min) * 0.12; + return [min - pad, max + pad]; +} + +function getUpperPercentile(values: number[], mode: OutlierMode): number | null { + if (mode === "none") return null; + const finiteValues = values.filter((value) => Number.isFinite(value)); + if (finiteValues.length < 3) return null; + + const sorted = [...finiteValues].sort((a, b) => a - b); + const q = mode === "p99" ? 0.99 : 0.95; + const index = Math.max( + 0, + Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * q)), + ); + return sorted[index] ?? null; +} + +export function applyOutlierCap(values: number[], mode: OutlierMode): number[] { + const cap = getUpperPercentile(values, mode); + if (cap == null) return values; + return values.map((value) => Math.min(value, cap)); +} + +export function ema(data: LossHistoryItem[], alpha: number): SmoothedLossItem[] { + if (data.length === 0) { + return []; + } + + let s = data[0].loss; + return data.map((d) => { + s = alpha * d.loss + (1 - alpha) * s; + return { ...d, smoothed: +s.toFixed(4) }; + }); +} diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index 140b9ead66..a98e34d269 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -30,7 +30,7 @@ import { ZapIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; +import { useState, type ReactElement, type ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { useGpuUtilization } from "@/hooks"; import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib"; @@ -52,6 +52,9 @@ export function ProgressSection(): ReactElement { etaSeconds: state.etaSeconds, currentNumTokens: state.currentNumTokens, isTrainingRunning: state.isTrainingRunning, + lossHistory: state.lossHistory, + lrHistory: state.lrHistory, + gradNormHistory: state.gradNormHistory, })), ); @@ -75,8 +78,6 @@ export function ProgressSection(): ReactElement { const { stopTrainingRun } = useTrainingActions(); const gpu = useGpuUtilization(runtime.isTrainingRunning); const [stopDialogOpen, setStopDialogOpen] = useState(false); - const localStartAtRef = useRef(null); - const [, setLocalTick] = useState(0); const pct = runtime.totalSteps > 0 @@ -89,31 +90,7 @@ export function ProgressSection(): ReactElement { ) : Math.round(runtime.progressPercent); - useEffect(() => { - if (runtime.elapsedSeconds != null && runtime.elapsedSeconds >= 0) { - localStartAtRef.current = Date.now() - runtime.elapsedSeconds * 1000; - return; - } - if (runtime.currentStep > 0 && localStartAtRef.current == null) { - localStartAtRef.current = Date.now(); - } - }, [runtime.currentStep, runtime.elapsedSeconds]); - - useEffect(() => { - if (!runtime.isTrainingRunning) { - return; - } - const timer = window.setInterval(() => { - setLocalTick((prev) => prev + 1); - }, 1000); - return () => window.clearInterval(timer); - }, [runtime.isTrainingRunning]); - - const elapsed = - runtime.elapsedSeconds ?? - (localStartAtRef.current == null - ? null - : Math.max(0, Math.floor((Date.now() - localStartAtRef.current) / 1000))); + const elapsed = runtime.elapsedSeconds; const derivedEta = elapsed != null && pct > 0 ? Math.round((elapsed * (100 - pct)) / Math.max(pct, 1)) @@ -125,6 +102,19 @@ export function ProgressSection(): ReactElement { ? runtime.currentStep / elapsed : null; + const stoppedLoss = + !runtime.isTrainingRunning + ? lastNonZeroValue(runtime.lossHistory) ?? runtime.currentLoss + : runtime.currentLoss; + const stoppedLr = + !runtime.isTrainingRunning + ? lastNonZeroValue(runtime.lrHistory) ?? runtime.currentLearningRate + : runtime.currentLearningRate; + const stoppedGradNorm = + !runtime.isTrainingRunning + ? lastNonZeroValue(runtime.gradNormHistory) ?? runtime.currentGradNorm + : runtime.currentGradNorm; + const configItems = [ { section: "Hyperparams", @@ -269,19 +259,19 @@ export function ProgressSection(): ReactElement {

Loss

- {runtime.currentLoss.toFixed(4)} + {stoppedLoss.toFixed(4)}

LR

- {runtime.currentLearningRate.toExponential(2)} + {stoppedLr.toExponential(2)}

Grad Norm

- {formatNumber(runtime.currentGradNorm, 3)} + {formatNumber(stoppedGradNorm, 3)}

@@ -352,6 +342,16 @@ export function ProgressSection(): ReactElement { ); } +function lastNonZeroValue(points: { value: number }[]): number | null { + for (let i = points.length - 1; i >= 0; i -= 1) { + const value = points[i]?.value; + if (Number.isFinite(value) && value !== 0) { + return value; + } + } + return null; +} + function GpuStat({ label, icon,