diff --git a/optuna_dashboard/ts/components/GraphContour.tsx b/optuna_dashboard/ts/components/GraphContour.tsx index 11ccf617..002950d2 100644 --- a/optuna_dashboard/ts/components/GraphContour.tsx +++ b/optuna_dashboard/ts/components/GraphContour.tsx @@ -85,7 +85,7 @@ export const Contour: FC<{ {study !== null && study.directions.length !== 1 ? ( - Objective ID: + Objective: + Objective: + - {targetList.map((t, i) => ( - - {t.toLabel(objectiveNames)} + diff --git a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx index 7347f42e..7b953906 100644 --- a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx +++ b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx @@ -1,36 +1,98 @@ import * as plotly from "plotly.js-dist-min" -import React, { FC, useEffect, useState } from "react" +import React, { FC, ReactNode, useEffect, useState } from "react" import { - Grid, - FormControl, - FormLabel, - MenuItem, - Select, Typography, - SelectChangeEvent, useTheme, Box, + Grid, + FormGroup, + FormControlLabel, + Checkbox, } from "@mui/material" import { plotlyDarkTemplate } from "./PlotlyDarkMode" +import { + Target, + useFilteredTrials, + useObjectiveAndUserAttrTargets, + useParamTargets, +} from "../trialFilter" +import { useMergedUnionSearchSpace } from "../searchSpace" const plotDomId = "graph-parallel-coordinate" +const useTargets = ( + study: StudyDetail | null +): [Target[], SearchSpaceItem[], () => ReactNode] => { + const [targets1, _target1, _setter1] = useObjectiveAndUserAttrTargets(study) + const searchSpace = useMergedUnionSearchSpace(study?.union_search_space) + const [targets2, _target2, _setter2] = useParamTargets(searchSpace) + const [checked, setChecked] = useState([true]) + + const allTargets = [...targets1, ...targets2] + useEffect(() => { + if (allTargets.length !== checked.length) { + setChecked( + allTargets.map((t) => { + if (t.kind !== "params" || study === null) { + return true + } + // By default, params that is not included in intersection search space should be disabled, + // otherwise all trials are filtered. + return ( + study.intersection_search_space.find((s) => s.name === t.key) !== + undefined + ) + }) + ) + } + }, [allTargets]) + + const handleOnChange = (event: React.ChangeEvent) => { + setChecked( + checked.map((c, i) => + i.toString() === event.target.name ? event.target.checked : c + ) + ) + } + + const renderCheckBoxes = (): ReactNode => ( + + {allTargets.map((t, i) => { + return ( + i ? checked[i] : true} + onChange={handleOnChange} + name={i.toString()} + /> + } + label={t.toLabel(study?.objective_names)} + /> + ) + })} + + ) + + const targets = allTargets.filter((t, i) => + checked.length > i ? checked[i] : true + ) + return [targets, searchSpace, renderCheckBoxes] +} + export const GraphParallelCoordinate: FC<{ study: StudyDetail | null }> = ({ study = null }) => { const theme = useTheme() - const [objectiveId, setObjectiveId] = useState(0) - const objectiveNames: string[] = study?.objective_names || [] - - const handleObjectiveChange = (event: SelectChangeEvent) => { - setObjectiveId(event.target.value as number) - } + const [targets, searchSpace, renderCheckBoxes] = useTargets(study) + const trials = useFilteredTrials(study, targets, false, false) useEffect(() => { if (study !== null) { - plotCoordinate(study, objectiveId, theme.palette.mode) + plotCoordinate(study, trials, targets, searchSpace, theme.palette.mode) } - }, [study, objectiveId, theme.palette.mode]) + }, [study, trials, targets, searchSpace, theme.palette.mode]) return ( @@ -39,25 +101,16 @@ export const GraphParallelCoordinate: FC<{ xs={3} container direction="column" - sx={{ paddingRight: theme.spacing(2) }} + sx={{ + paddingRight: theme.spacing(2), + display: "flex", + flexDirection: "column", + }} > Parallel Coordinate - {study !== null && study.directions.length !== 1 ? ( - - Objective ID: - - - ) : null} + {renderCheckBoxes()} @@ -66,23 +119,11 @@ export const GraphParallelCoordinate: FC<{ ) } -const filterFunc = (trial: Trial, objectiveId: number): boolean => { - if (trial.state !== "Complete" && trial.state !== "Pruned") { - return false - } - if (trial.values === undefined) { - return false - } - return ( - trial.values.length > objectiveId && - trial.values[objectiveId] !== "inf" && - trial.values[objectiveId] !== "-inf" - ) -} - const plotCoordinate = ( study: StudyDetail, - objectiveId: number, + trials: Trial[], + targets: Target[], + searchSpace: SearchSpaceItem[], mode: string ) => { if (document.getElementById(plotDomId) === null) { @@ -98,14 +139,11 @@ const plotCoordinate = ( }, template: mode === "dark" ? plotlyDarkTemplate : {}, } - - if (study.trials.length === 0) { + if (trials.length === 0 || targets.length === 0) { plotly.react(plotDomId, [], layout) return } - const filteredTrials = study.trials.filter((t) => filterFunc(t, objectiveId)) - const maxLabelLength = 40 const breakLength = maxLabelLength / 2 const ellipsis = "…" @@ -124,41 +162,58 @@ const plotCoordinate = ( .join("") } - // Intersection param names - const objectiveValues: number[] = filteredTrials.map( - (t) => t.values![objectiveId] as number - ) - const dimensions = [ - { - label: "Objective value", - values: objectiveValues, - range: [Math.min(...objectiveValues), Math.max(...objectiveValues)], - }, - ] - study.intersection_search_space.forEach((s) => { - const values: number[] = filteredTrials.map( - (t) => t.params.find((p) => p.name === s.name)!.param_internal_value - ) - if (s.distribution.type !== "CategoricalDistribution") { - dimensions.push({ - label: breakLabelIfTooLong(s.name), + const dimensions = targets.map((target) => { + if (target.kind === "objective" || target.kind === "user_attr") { + const values: number[] = trials.map( + (t) => target.getTargetValue(t) as number + ) + return { + label: target.toLabel(study.objective_names), values: values, range: [Math.min(...values), Math.max(...values)], - }) + } } else { - // categorical - const vocabArr: string[] = s.distribution.choices.map((c) => c.value) - const tickvals: number[] = vocabArr.map((v, i) => i) - dimensions.push({ - label: breakLabelIfTooLong(s.name), - values: values, - range: [Math.min(...values), Math.max(...values)], - // @ts-ignore - tickvals: tickvals, - ticktext: vocabArr, - }) + const s = searchSpace.find( + (s) => s.name === target.key + ) as SearchSpaceItem // Must be already filtered. + + const values: number[] = trials.map( + (t) => target.getTargetValue(t) as number + ) + if (s.distribution.type !== "CategoricalDistribution") { + return { + label: breakLabelIfTooLong(s.name), + values: values, + range: [s.distribution.low, s.distribution.high], + } + } else { + // categorical + const vocabArr: string[] = s.distribution.choices.map((c) => c.value) + const tickvals: number[] = vocabArr.map((v, i) => i) + return { + label: breakLabelIfTooLong(s.name), + values: values, + range: [0, s.distribution.choices.length - 1], + // @ts-ignore + tickvals: tickvals, + ticktext: vocabArr, + } + } } }) + if (dimensions.length === 0) { + console.log("Must not reach here.") + plotly.react(plotDomId, [], layout) + return + } + let reversescale = false + if ( + targets[0].kind === "objective" && + (targets[0].getObjectiveId() as number) < study.directions.length && + study.directions[targets[0].getObjectiveId() as number] === "maximize" + ) { + reversescale = true + } const plotData: Partial[] = [ { type: "parcoords", @@ -170,10 +225,10 @@ const plotCoordinate = ( // @ts-ignore colorscale: "Blues", colorbar: { - title: "Objective value", + title: targets[0].toLabel(study.objective_names), }, showscale: true, - reversescale: study.directions[objectiveId] === "maximize", + reversescale: reversescale, }, }, ] diff --git a/optuna_dashboard/ts/components/GraphParetoFront.tsx b/optuna_dashboard/ts/components/GraphParetoFront.tsx index 22c4007c..54f49d0e 100644 --- a/optuna_dashboard/ts/components/GraphParetoFront.tsx +++ b/optuna_dashboard/ts/components/GraphParetoFront.tsx @@ -52,7 +52,7 @@ export const GraphParetoFront: FC<{ {study !== null && study.directions.length !== 1 ? ( <> - Objective X ID: + Objective X: - Objective Y ID: + Objective Y: + Objective: + )} - {paramTargets.length !== 0 && paramTargetsIndex !== null && ( + {paramTargets.length !== 0 && selectedParamTarget !== null && ( Parameter: - {paramTargets.map((t, i) => ( - + {t.toLabel()} ))} @@ -131,7 +142,8 @@ export const GraphSlice: FC<{ const plotSlice = ( trials: Trial[], objectiveTarget: Target, - selected: SearchSpaceItem | null, + selectedParamTarget: Target | null, + selectedParamSpace: SearchSpaceItem | null, logYScale: boolean, mode: string ) => { @@ -147,8 +159,11 @@ const plotSlice = ( b: 0, }, xaxis: { - title: selected?.name || "", - type: selected !== null && isLogScale(selected) ? "log" : "linear", + title: selectedParamTarget?.toLabel() || "", + type: + selectedParamSpace !== null && isLogScale(selectedParamSpace) + ? "log" + : "linear", gridwidth: 1, automargin: true, }, @@ -161,11 +176,11 @@ const plotSlice = ( showlegend: false, template: mode === "dark" ? plotlyDarkTemplate : {}, } - if (selected === null) { - plotly.react(plotDomId, [], layout) - return - } - if (trials.length === 0) { + if ( + selectedParamSpace === null || + selectedParamTarget === null || + trials.length === 0 + ) { plotly.react(plotDomId, [], layout) return } @@ -173,11 +188,12 @@ const plotSlice = ( const objectiveValues: number[] = trials.map( (t) => objectiveTarget.getTargetValue(t) as number ) - const paramTarget = new Target("params", selected.name) - const values = trials.map((t) => paramTarget.getTargetValue(t) as number) + const values = trials.map( + (t) => selectedParamTarget.getTargetValue(t) as number + ) const trialNumbers: number[] = trials.map((t) => t.number) - if (selected.distribution.type !== "CategoricalDistribution") { + if (selectedParamSpace.distribution.type !== "CategoricalDistribution") { const trace: plotly.Data[] = [ { type: "scatter", @@ -199,14 +215,14 @@ const plotSlice = ( }, ] layout["xaxis"] = { - title: selected.name, - type: selected.distribution.log ? "log" : "linear", + title: selectedParamTarget.toLabel(), + type: isLogScale(selectedParamSpace) ? "log" : "linear", gridwidth: 1, automargin: true, // Otherwise the label is outside of the plot } plotly.react(plotDomId, trace, layout) } else { - const vocabArr = selected.distribution.choices.map((c) => c.value) + const vocabArr = selectedParamSpace.distribution.choices.map((c) => c.value) const tickvals: number[] = vocabArr.map((v, i) => i) const trace: plotly.Data[] = [ { @@ -229,7 +245,7 @@ const plotSlice = ( }, ] layout["xaxis"] = { - title: selected.name, + title: selectedParamTarget.toLabel(), type: "linear", gridwidth: 1, tickvals: tickvals, diff --git a/optuna_dashboard/ts/components/StudyDetail.tsx b/optuna_dashboard/ts/components/StudyDetail.tsx index b71d8591..8e15d58d 100644 --- a/optuna_dashboard/ts/components/StudyDetail.tsx +++ b/optuna_dashboard/ts/components/StudyDetail.tsx @@ -18,7 +18,7 @@ import Brightness7Icon from "@mui/icons-material/Brightness7" import { GraphParallelCoordinate } from "./GraphParallelCoordinate" import { GraphHyperparameterImportances } from "./GraphHyperparameterImportances" -import { Edf } from "./GraphEdf" +import { GraphEdf } from "./GraphEdf" import { Contour } from "./GraphContour" import { GraphIntermediateValues } from "./GraphIntermediateValues" import { GraphSlice } from "./GraphSlice" @@ -200,7 +200,7 @@ export const StudyDetail: FC<{ {graphVisibility.edf ? ( - + ) : null} diff --git a/optuna_dashboard/ts/components/StudyDetailBeta.tsx b/optuna_dashboard/ts/components/StudyDetailBeta.tsx index a98c3d04..9e2f8f73 100644 --- a/optuna_dashboard/ts/components/StudyDetailBeta.tsx +++ b/optuna_dashboard/ts/components/StudyDetailBeta.tsx @@ -32,7 +32,7 @@ import { GraphSlice } from "./GraphSlice" import { GraphParetoFront } from "./GraphParetoFront" import { DataGrid, DataGridColumn } from "./DataGrid" import { GraphIntermediateValues } from "./GraphIntermediateValues" -import { Edf } from "./GraphEdf" +import { GraphEdfBeta } from "./GraphEdf" import { TrialList } from "./TrialList" import { BestTrialsCard } from "./BestTrialsCard" @@ -173,11 +173,19 @@ export const StudyDetailBeta: FC<{ Empirical Distribution of the Objective Value - - - - - + + {studyDetail !== null + ? studyDetail.directions.map((d, i) => ( + + + + + + + + )) + : null} + ) } else if (page === "trialTable") { diff --git a/optuna_dashboard/ts/trialFilter.ts b/optuna_dashboard/ts/trialFilter.ts index 204fa817..e3424956 100644 --- a/optuna_dashboard/ts/trialFilter.ts +++ b/optuna_dashboard/ts/trialFilter.ts @@ -1,5 +1,4 @@ -import { useMemo } from "react" -import { mergeUnionSearchSpace } from "./searchSpace" +import { useMemo, useState } from "react" type TargetKind = "objective" | "user_attr" | "params" @@ -29,6 +28,10 @@ export class Target { return true } + identifier(): string { + return `${this.kind}:${this.key}` + } + toLabel(objectiveNames?: string[]): string { if (this.kind === "objective") { const objectiveId: number = this.key as number @@ -113,32 +116,52 @@ export const useFilteredTrials = ( }) }, [study?.trials, targets, filterComplete, filterPruned]) -export const useObjectiveTargets = (study: StudyDetail | null): Target[] => - useMemo(() => { +export const useObjectiveTargets = ( + study: StudyDetail | null +): [Target[], Target, (ident: string) => void] => { + const defaultTarget = new Target("objective", 0) + const [selected, setTargetIdent] = useState( + defaultTarget.identifier() + ) + const targetList = useMemo(() => { if (study !== null) { return study.directions.map((v, i) => new Target("objective", i)) } else { - return [new Target("objective", 0)] + return [defaultTarget] } }, [study?.directions]) + const selectedTarget = useMemo( + () => targetList.find((t) => t.identifier() === selected) || defaultTarget, + [targetList, selected] + ) + return [targetList, selectedTarget, setTargetIdent] +} export const useParamTargets = ( - study: StudyDetail | null -): [Target[], SearchSpaceItem[]] => - useMemo<[Target[], SearchSpaceItem[]]>(() => { - if (study !== null) { - const searchSpace = mergeUnionSearchSpace(study.union_search_space) - const targets = searchSpace.map((s) => new Target("params", s.name)) - return [targets, searchSpace] - } else { - return [[], []] - } - }, [study?.union_search_space]) + searchSpace: SearchSpaceItem[] +): [Target[], Target | null, (ident: string) => void] => { + const [selected, setTargetIdent] = useState("") + const targetList = useMemo(() => { + const targets = searchSpace.map((s) => new Target("params", s.name)) + if (selected === "" && targets.length > 0) + setTargetIdent(targets[0].identifier()) + return targets + }, [searchSpace]) + const selectedTarget = useMemo( + () => targetList.find((t) => t.identifier() === selected) || null, + [targetList, selected] + ) + return [targetList, selectedTarget, setTargetIdent] +} -export const useObjectiveAndSystemAttrTargets = ( +export const useObjectiveAndUserAttrTargets = ( study: StudyDetail | null -): Target[] => - useMemo(() => { +): [Target[], Target, (ident: string) => void] => { + const defaultTarget = new Target("objective", 0) + const [selected, setTargetIdent] = useState( + defaultTarget.identifier() + ) + const targetList = useMemo(() => { if (study !== null) { return [ ...study.directions.map((v, i) => new Target("objective", i)), @@ -147,6 +170,12 @@ export const useObjectiveAndSystemAttrTargets = ( .map((attr) => new Target("user_attr", attr.key)), ] } else { - return [new Target("objective", 0)] + return [defaultTarget] } }, [study?.directions, study?.union_user_attrs]) + const selectedTarget = useMemo( + () => targetList.find((t) => t.identifier() === selected) || defaultTarget, + [targetList, selected] + ) + return [targetList, selectedTarget, setTargetIdent] +}