Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions DashAI/back/evaluation/base_evaluation_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,16 @@ def get_metadata(cls) -> dict:
-------
dict
Mapping with ``kind``, which says whether this strategy splits the
dataset once or into folds.
dataset once or into folds, and ``scored_splits``, the partitions
it writes metrics for. A screen that offers one control per
partition reads the latter instead of assuming all three exist:
a forecasting strategy scores no training partition, so asking it
for train metrics finds nothing.
"""
return {"kind": cls.KIND}
return {
"kind": cls.KIND,
"scored_splits": [split.value for split in cls.SCORED_SPLITS],
}

def __init__(
self,
Expand Down
50 changes: 39 additions & 11 deletions DashAI/front/src/components/models/FoldMetricsChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import ResultsGraphsParameters from "../../pages/results/components/ResultsGraph
import PillToggleButtonGroup from "../shared/PillToggleButtonGroup";
import PlotActions from "../shared/PlotActions";
import { getTraceColors } from "../../utils/chartColors";
import { useStrategyMetadata } from "../../hooks/useStrategyKind";
import { useModels } from "./ModelsContext";

const FOLD_SPLITS = ["TRAIN", "VALIDATION"];

// ─── Pure helpers (defined outside component — stable references) ─────────────

Expand Down Expand Up @@ -175,17 +179,37 @@ export default function FoldMetricsChart({ run }) {
const [error, setError] = useState(null);
const [chartType, setChartType] = useState("boxplot");
const [foldScope, setFoldScope] = useState("default");
const [split, setSplit] = useState("TRAIN");
const [split, setSplit] = useState(null);
const [selectedMetrics, setSelectedMetrics] = useState([]);

const selectedMetricsRef = useRef([]);

const metricSplit = split.toLowerCase();
const metricSplit = split?.toLowerCase() ?? null;
const isNestedCV = !!run.nested;

const strategyName =
useModels()?.selectedSession?.evaluation_strategy ?? null;
const strategyMetadata = useStrategyMetadata(strategyName);
const awaitingStrategy = !!strategyName && strategyMetadata === null;

const foldSplits = useMemo(() => {
const scored = strategyMetadata?.scored_splits;
if (!Array.isArray(scored)) return FOLD_SPLITS;
const available = FOLD_SPLITS.filter((name) =>
scored.includes(name.toLowerCase()),
);
return available.length > 0 ? available : FOLD_SPLITS;
}, [strategyMetadata]);

useEffect(() => {
setSplit((current) =>
foldSplits.includes(current) ? current : foldSplits[0],
);
}, [foldSplits]);

// ── Fetch ──────────────────────────────────────────────────────────────────
useEffect(() => {
if (!run) return;
if (!run || !split || awaitingStrategy) return;

setLoading(true);
setError(null);
Expand Down Expand Up @@ -238,7 +262,7 @@ export default function FoldMetricsChart({ run }) {
return () => controller.abort();
// selectedRepetition intentionally excluded — we only read it as "current value at fetch time"
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [run, metricSplit, foldScope, isNestedCV]);
}, [run, metricSplit, foldScope, isNestedCV, awaitingStrategy]);

// ── Derived data ───────────────────────────────────────────────────────────

Expand Down Expand Up @@ -395,7 +419,7 @@ export default function FoldMetricsChart({ run }) {
);
}

if (loading) {
if (loading || awaitingStrategy || !split) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
Expand Down Expand Up @@ -456,14 +480,18 @@ export default function FoldMetricsChart({ run }) {
if (v) setSplit(v);
}}
>
<ToggleButton value="TRAIN" sx={{ px: 1.5 }}>
{t("models:label.train")}
</ToggleButton>
{foldSplits.includes("TRAIN") && (
<ToggleButton value="TRAIN" sx={{ px: 1.5 }}>
{t("models:label.train")}
</ToggleButton>
)}
{/* A fold is scored on its validation partition; the reserved
rows produce a single value, with nothing to chart per fold. */}
<ToggleButton value="VALIDATION" sx={{ px: 1.5 }}>
{t("models:label.validation")}
</ToggleButton>
{foldSplits.includes("VALIDATION") && (
<ToggleButton value="VALIDATION" sx={{ px: 1.5 }}>
{t("models:label.validation")}
</ToggleButton>
)}
</PillToggleButtonGroup>
</Box>

Expand Down
43 changes: 34 additions & 9 deletions DashAI/front/src/components/models/LiveMetricsChart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import ResultsGraphsParameters from "../../pages/results/components/ResultsGraph
import PillToggleButtonGroup from "../shared/PillToggleButtonGroup";
import PlotActions from "../shared/PlotActions";
import { getTraceColors } from "../../utils/chartColors";
import { useStrategyMetadata } from "../../hooks/useStrategyKind";
import { useModels } from "./ModelsContext";

const SPLITS = ["TRAIN", "VALIDATION", "TEST"];

function toFinalValue(value) {
const resolved = Array.isArray(value)
Expand Down Expand Up @@ -315,10 +319,27 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) {
return (modelSessionDetail.test_metrics ?? []).length > 0;
}, [modelSessionDetail]);

const strategyName =
useModels()?.selectedSession?.evaluation_strategy ?? null;
const strategyMetadata = useStrategyMetadata(strategyName);

const availableSplits = useMemo(() => {
const scored = strategyMetadata?.scored_splits;
const offered = Array.isArray(scored)
? SPLITS.filter((name) => scored.includes(name.toLowerCase()))
: SPLITS;
const withTest = hasTestSplit
? offered
: offered.filter((name) => name !== "TEST");
return withTest.length > 0 ? withTest : SPLITS;
}, [strategyMetadata, hasTestSplit]);

// Never leave the group pointing at a button that is no longer rendered.
useEffect(() => {
if (!hasTestSplit && split === "TEST") setSplit("TRAIN");
}, [hasTestSplit, split]);
setSplit((current) =>
availableSplits.includes(current) ? current : availableSplits[0],
);
}, [availableSplits]);

return (
<Box
Expand Down Expand Up @@ -364,13 +385,17 @@ export function LiveMetricsChart({ run, modelSessionDetail = null }) {
backdropFilter: "blur(8px)",
}}
>
<ToggleButton value="TRAIN" sx={{ px: 1.5 }}>
{t("models:label.train")}
</ToggleButton>
<ToggleButton value="VALIDATION" sx={{ px: 1.5 }}>
{t("models:label.validation")}
</ToggleButton>
{hasTestSplit && (
{availableSplits.includes("TRAIN") && (
<ToggleButton value="TRAIN" sx={{ px: 1.5 }}>
{t("models:label.train")}
</ToggleButton>
)}
{availableSplits.includes("VALIDATION") && (
<ToggleButton value="VALIDATION" sx={{ px: 1.5 }}>
{t("models:label.validation")}
</ToggleButton>
)}
{availableSplits.includes("TEST") && (
<ToggleButton value="TEST" sx={{ px: 1.5 }}>
{t("models:label.test")}
</ToggleButton>
Expand Down
28 changes: 13 additions & 15 deletions DashAI/front/src/components/models/ModelComparisonTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,20 @@ function ModelComparisonTable({
color: theme.palette.accent.teal,
label: "HPO",
},
...(isCrossValidation
? {
nestedCv: {
bg: "#585370",
border: "#585370",
color: "#585370",
label: "CV anidado",
},
}
: {}),
nestedCv: {
bg: "#585370",
border: "#585370",
color: "#585370",
label: "CV anidado",
},
};

const runTypeLegend = Object.entries(runTypeStyles).map(([key, value]) => ({
key,
...value,
}));
const runTypeLegend = Object.entries(runTypeStyles)
.filter(([key]) => key !== "nestedCv" || isCrossValidation)
.map(([key, value]) => ({
key,
...value,
}));

const getMetricColumns = () => {
const metricsSet = new Set();
Expand Down Expand Up @@ -442,7 +440,7 @@ function ModelComparisonTable({
state: { columnOrder },
muiTableBodyRowProps: ({ row }) => {
const runType = getRunType(row.original);
const { bg, border } = runTypeStyles[runType];
const { bg, border } = runTypeStyles[runType] ?? runTypeStyles.withoutHpo;
return {
onClick: () => {
if (onRowClick) onRowClick(row.original.id);
Expand Down
46 changes: 26 additions & 20 deletions DashAI/front/src/hooks/useStrategyKind.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,65 @@ import { useEffect, useState } from "react";
import { getComponents } from "../api/component";

/**
* How each evaluation strategy divides the dataset, once fetched.
* What each evaluation strategy declares about itself, once fetched.
*
* Strategy names never change for a given session, so the answer is cached
* across components: several screens ask the same question about the same
* session and none of them should trigger its own request.
*/
const kindCache = new Map();
const metadataCache = new Map();

/**
* Read the split shape of the strategy a session uses.
* Read the metadata an evaluation strategy declares.
*
* Screens used to ask this by comparing the stored strategy name against a
* literal, which meant every new strategy silently read as "not cross
* validation" and rendered the wrong controls. The backend reports the shape,
* so it is read rather than guessed.
* Screens used to answer these questions by comparing the stored strategy
* name against a literal, which meant every new strategy silently read as
* "not cross validation" and rendered the wrong controls. The backend reports
* what it does, so it is read rather than guessed.
*
* @param {string|null} strategyName a session's evaluation_strategy
* @returns {string|null} "holdout", "cv", or null while unknown
* @returns {object|null} the strategy metadata, or null while it is unknown
*/
export function useStrategyKind(strategyName) {
const [kind, setKind] = useState(() => kindCache.get(strategyName) ?? null);
export function useStrategyMetadata(strategyName) {
const [metadata, setMetadata] = useState(
() => metadataCache.get(strategyName) ?? null,
);

useEffect(() => {
if (!strategyName) {
setKind(null);
setMetadata(null);
return undefined;
}

if (kindCache.has(strategyName)) {
setKind(kindCache.get(strategyName));
if (metadataCache.has(strategyName)) {
setMetadata(metadataCache.get(strategyName));
return undefined;
}

let cancelled = false;
const fetchKind = async () => {
const fetchMetadata = async () => {
try {
const component = await getComponents({ model: strategyName });
const value = component?.metadata?.kind ?? null;
kindCache.set(strategyName, value);
if (!cancelled) setKind(value);
const value = component?.metadata ?? {};
metadataCache.set(strategyName, value);
if (!cancelled) setMetadata(value);
} catch (error) {
console.error(`Error fetching the ${strategyName} metadata`, error);
if (!cancelled) setKind(null);
if (!cancelled) setMetadata({});
}
};

fetchKind();
fetchMetadata();
return () => {
cancelled = true;
};
}, [strategyName]);

return kind;
return metadata;
}

export function useStrategyKind(strategyName) {
return useStrategyMetadata(strategyName)?.kind ?? null;
}

export default useStrategyKind;
25 changes: 25 additions & 0 deletions tests/back/evaluation/test_forecasting_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,31 @@ def test_each_strategy_declares_the_shape_of_its_splits():
# --- which partitions get scored ---------------------------------------------


def test_each_strategy_declares_which_partitions_it_scores():
"""The fold charts build one toggle per scored partition.

Reading it from the strategy is what stops them from asking for the train
fold metrics a forecasting run never wrote.
"""
assert ForecastingCrossValidationEvaluationStrategy.get_metadata()[
"scored_splits"
] == ["validation", "test"]
assert ForecastingHoldoutEvaluationStrategy.get_metadata()["scored_splits"] == [
"validation",
"test",
]
assert CrossValidationEvaluationStrategy.get_metadata()["scored_splits"] == [
"train",
"validation",
"test",
]
assert HoldoutEvaluationStrategy.get_metadata()["scored_splits"] == [
"train",
"validation",
"test",
]


@pytest.mark.parametrize("strategy", FORECASTING_STRATEGIES)
def test_forecasting_strategies_do_not_score_the_training_partition(strategy):
assert SplitEnum.TRAIN not in strategy.SCORED_SPLITS
Expand Down
Loading