diff --git a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx index 0f26025d..d6cdcdc9 100644 --- a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx +++ b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx @@ -1,104 +1,24 @@ -import { - Checkbox, - FormControlLabel, - FormGroup, - Grid, - Typography, - useTheme, -} from "@mui/material" import { GraphContainer, + PlotParallelCoordinate, useGraphComponentState, - useMergedUnionSearchSpace, } from "@optuna/react" -import { - Target, - useFilteredTrials, - useObjectiveAndUserAttrTargets, - useParamTargets, -} from "@optuna/react" -import * as Optuna from "@optuna/types" import * as plotly from "plotly.js-dist-min" -import React, { FC, ReactNode, useEffect, useState } from "react" -import { SearchSpaceItem, StudyDetail } from "ts/types/optuna" +import React, { FC, useEffect } from "react" +import { StudyDetail } from "ts/types/optuna" import { PlotType } from "../apiClient" import { usePlot } from "../hooks/usePlot" -import { usePlotlyColorTheme } from "../state" import { useBackendRender } from "../state" const plotDomId = "graph-parallel-coordinate" -const useTargets = ( - study: StudyDetail | null -): [Target[], SearchSpaceItem[], () => ReactNode] => { - const [targets1] = useObjectiveAndUserAttrTargets(study) - const searchSpace = useMergedUnionSearchSpace(study?.union_search_space) - const [targets2] = useParamTargets(searchSpace) - const [checked, setChecked] = useState([true]) - - const allTargets = [...targets1, ...targets2] - useEffect(() => { - if (allTargets.length !== checked.length) { - setChecked( - allTargets.map((t) => { - if (t.kind === "user_attr") { - return false - } - 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 }) => { if (useBackendRender()) { return } else { - return + return } } @@ -135,198 +55,3 @@ const GraphParallelCoordinateBackend: FC<{ /> ) } - -const GraphParallelCoordinateFrontend: FC<{ - study: StudyDetail | null -}> = ({ study = null }) => { - const { graphComponentState, notifyGraphDidRender } = useGraphComponentState() - - const theme = useTheme() - const colorTheme = usePlotlyColorTheme(theme.palette.mode) - - const [targets, searchSpace, renderCheckBoxes] = useTargets(study) - - const trials = useFilteredTrials(study, targets, false) - useEffect(() => { - if (study !== null && graphComponentState !== "componentWillMount") { - plotCoordinate(study, trials, targets, searchSpace, colorTheme)?.then( - notifyGraphDidRender - ) - } - }, [study, trials, targets, searchSpace, colorTheme, graphComponentState]) - - return ( - - - - Parallel Coordinate - - {renderCheckBoxes()} - - - - - - ) -} - -const plotCoordinate = ( - study: StudyDetail, - trials: Optuna.Trial[], - targets: Target[], - searchSpace: SearchSpaceItem[], - colorTheme: Partial -) => { - if (document.getElementById(plotDomId) === null) { - return - } - - const layout: Partial = { - margin: { - l: 70, - t: 50, - r: 50, - b: 100, - }, - template: colorTheme, - uirevision: "true", - } - if (trials.length === 0 || targets.length === 0) { - return plotly.react(plotDomId, [], layout) - } - - const maxLabelLength = 40 - const breakLength = maxLabelLength / 2 - const ellipsis = "…" - const truncateLabelIfTooLong = (originalLabel: string): string => { - return originalLabel.length > maxLabelLength - ? originalLabel.substring(0, maxLabelLength - ellipsis.length) + ellipsis - : originalLabel - } - const breakLabelIfTooLong = (originalLabel: string): string => { - const truncated = truncateLabelIfTooLong(originalLabel) - return truncated - .split("") - .map((c, i) => { - return (i + 1) % breakLength === 0 ? c + "
" : c - }) - .join("") - } - - const calculateLogScale = (values: number[]) => { - const logValues = values.map((v) => { - return Math.log10(v) - }) - const minValue = Math.min(...logValues) - const maxValue = Math.max(...logValues) - const range = [Math.floor(minValue), Math.ceil(maxValue)] - const tickvals = Array.from( - { length: Math.ceil(maxValue) - Math.floor(minValue) + 1 }, - (_, i) => i + Math.floor(minValue) - ) - const ticktext = tickvals.map((x) => `${Math.pow(10, x).toPrecision(3)}`) - return { logValues, range, tickvals, ticktext } - } - - 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 { - 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") { - // categorical - const vocabArr: string[] = s.distribution.choices.map( - (c) => c?.toString() ?? "null" - ) - 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, - } - } else if (s.distribution.log) { - // numerical and log - const { logValues, range, tickvals, ticktext } = - calculateLogScale(values) - return { - label: breakLabelIfTooLong(s.name), - values: logValues, - range, - tickvals, - ticktext, - } - } else { - // numerical and linear - return { - label: breakLabelIfTooLong(s.name), - values: values, - range: [s.distribution.low, s.distribution.high], - } - } - } - }) - if (dimensions.length === 0) { - console.log("Must not reach here.") - return plotly.react(plotDomId, [], layout) - } - 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", - dimensions: dimensions, - labelangle: 30, - labelside: "bottom", - line: { - color: dimensions[0]["values"], - // @ts-ignore - colorscale: "Blues", - colorbar: { - title: targets[0].toLabel(study.objective_names), - }, - showscale: true, - reversescale: reversescale, - }, - }, - ] - - return plotly.react(plotDomId, plotData, layout) -} diff --git a/tslib/react/src/components/PlotParallelCoordinate.stories.tsx b/tslib/react/src/components/PlotParallelCoordinate.stories.tsx new file mode 100644 index 00000000..42443339 --- /dev/null +++ b/tslib/react/src/components/PlotParallelCoordinate.stories.tsx @@ -0,0 +1,53 @@ +import { CssBaseline, ThemeProvider } from "@mui/material" +import { Meta, StoryObj } from "@storybook/react" +import React from "react" +import { useMockStudy } from "../MockStudies" +import { darkTheme } from "../styles/darkTheme" +import { lightTheme } from "../styles/lightTheme" +import { PlotParallelCoordinate } from "./PlotParallelCoordinate" + +const meta: Meta = { + component: PlotParallelCoordinate, + title: "Plot/ParallelCoordinate", + tags: ["autodocs"], + decorators: [ + (Story, storyContext) => { + const { study } = useMockStudy(storyContext.parameters?.studyId) + if (!study) return

loading...

+ return ( + + + + + ) + }, + ], +} + +export default meta +type Story = StoryObj + +export const LightTheme: Story = { + parameters: { + studyId: 1, + theme: lightTheme, + }, +} + +export const DarkTheme: Story = { + parameters: { + studyId: 1, + theme: darkTheme, + }, +} + +// TODO(c-bata): Add a story for multi objective study. +// export const MultiObjective: Story = { +// parameters: { +// ... +// }, +// } diff --git a/tslib/react/src/components/PlotParallelCoordinate.tsx b/tslib/react/src/components/PlotParallelCoordinate.tsx new file mode 100644 index 00000000..f2a14dc6 --- /dev/null +++ b/tslib/react/src/components/PlotParallelCoordinate.tsx @@ -0,0 +1,291 @@ +import { + Checkbox, + FormControlLabel, + FormGroup, + Grid, + Typography, + useTheme, +} from "@mui/material" +import * as Optuna from "@optuna/types" +import * as plotly from "plotly.js-dist-min" +import React, { FC, ReactNode, useEffect, useState } from "react" +import { + GraphContainer, + Target, + useFilteredTrials, + useGraphComponentState, + useMergedUnionSearchSpace, + useObjectiveAndUserAttrTargets, + useParamTargets, +} from ".." +import { plotlyDarkTemplate } from "./PlotlyDarkMode" + +const plotDomId = "plot-parallel-coordinate" + +export const PlotParallelCoordinate: FC<{ + study: Optuna.Study | null + colorTheme?: Partial +}> = ({ study = null, colorTheme }) => { + const { graphComponentState, notifyGraphDidRender } = useGraphComponentState() + + const theme = useTheme() + const colorThemeUsed = + colorTheme ?? (theme.palette.mode === "dark" ? plotlyDarkTemplate : {}) + + const [targets, searchSpace, renderCheckBoxes] = useTargets(study) + + const trials = useFilteredTrials(study, targets, false) + useEffect(() => { + if (study !== null && graphComponentState !== "componentWillMount") { + plotCoordinate(study, trials, targets, searchSpace, colorThemeUsed)?.then( + notifyGraphDidRender + ) + } + }, [ + study, + trials, + targets, + searchSpace, + colorThemeUsed, + graphComponentState, + notifyGraphDidRender, + ]) + + return ( + + + + Parallel Coordinate + + {renderCheckBoxes()} + + + + + + ) +} + +const plotCoordinate = ( + study: Optuna.Study, + trials: Optuna.Trial[], + targets: Target[], + searchSpace: Optuna.SearchSpaceItem[], + colorTheme: Partial +) => { + if (document.getElementById(plotDomId) === null) { + return + } + + const layout: Partial = { + margin: { + l: 70, + t: 50, + r: 50, + b: 100, + }, + template: colorTheme, + uirevision: "true", + } + if (trials.length === 0 || targets.length === 0) { + return plotly.react(plotDomId, [], layout) + } + + const maxLabelLength = 40 + const breakLength = maxLabelLength / 2 + const ellipsis = "…" + const truncateLabelIfTooLong = (originalLabel: string): string => { + return originalLabel.length > maxLabelLength + ? originalLabel.substring(0, maxLabelLength - ellipsis.length) + ellipsis + : originalLabel + } + const breakLabelIfTooLong = (originalLabel: string): string => { + const truncated = truncateLabelIfTooLong(originalLabel) + return truncated + .split("") + .map((c, i) => { + return (i + 1) % breakLength === 0 ? `${c}
` : c + }) + .join("") + } + + const calculateLogScale = (values: number[]) => { + const logValues = values.map((v) => { + return Math.log10(v) + }) + const minValue = Math.min(...logValues) + const maxValue = Math.max(...logValues) + const range = [Math.floor(minValue), Math.ceil(maxValue)] + const tickvals = Array.from( + { length: Math.ceil(maxValue) - Math.floor(minValue) + 1 }, + (_, i) => i + Math.floor(minValue) + ) + const ticktext = tickvals.map((x) => `${(10 ** x).toPrecision(3)}`) + return { logValues, range, tickvals, ticktext } + } + + 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.metric_names), + values: values, + range: [Math.min(...values), Math.max(...values)], + } + } + const s = searchSpace.find( + (s) => s.name === target.key + ) as Optuna.SearchSpaceItem // Must be already filtered. + + const values: number[] = trials.map( + (t) => target.getTargetValue(t) as number + ) + if (s.distribution.type === "CategoricalDistribution") { + // categorical + const vocabArr: string[] = s.distribution.choices.map( + (c) => c?.toString() ?? "null" + ) + const tickvals: number[] = vocabArr.map((_, i) => i) + return { + label: breakLabelIfTooLong(s.name), + values: values, + range: [0, s.distribution.choices.length - 1], + // @ts-ignore + tickvals: tickvals, + ticktext: vocabArr, + } + } + if (s.distribution.log) { + // numerical and log + const { logValues, range, tickvals, ticktext } = calculateLogScale(values) + return { + label: breakLabelIfTooLong(s.name), + values: logValues, + range, + tickvals, + ticktext, + } + } + // numerical and linear + return { + label: breakLabelIfTooLong(s.name), + values: values, + range: [s.distribution.low, s.distribution.high], + } + }) + if (dimensions.length === 0) { + console.log("Must not reach here.") + return plotly.react(plotDomId, [], layout) + } + 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", + dimensions: dimensions, + labelangle: 30, + labelside: "bottom", + line: { + color: dimensions[0].values, + // @ts-ignore + colorscale: "Blues", + colorbar: { + title: targets[0].toLabel(study.metric_names), + }, + showscale: true, + reversescale: reversescale, + }, + }, + ] + + return plotly.react(plotDomId, plotData, layout) +} + +const useTargets = ( + study: Optuna.Study | null +): [Target[], Optuna.SearchSpaceItem[], () => ReactNode] => { + const [targets1] = useObjectiveAndUserAttrTargets(study) + const searchSpace = useMergedUnionSearchSpace(study?.union_search_space) + const [targets2] = useParamTargets(searchSpace) + const [checked, setChecked] = useState([true]) + + const allTargets = [...targets1, ...targets2] + useEffect(() => { + if (allTargets.length !== checked.length) { + setChecked( + allTargets.map((t) => { + if (t.kind === "user_attr") { + return false + } + 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, study, checked.length]) + + 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) => { + const key = t.toLabel(study?.metric_names) + return ( + i ? checked[i] : true} + onChange={handleOnChange} + name={i.toString()} + /> + } + label={t.toLabel(study?.metric_names)} + /> + ) + })} + + ) + + const targets = allTargets.filter((_, i) => + checked.length > i ? checked[i] : true + ) + return [targets, searchSpace, renderCheckBoxes] +} diff --git a/tslib/react/src/index.ts b/tslib/react/src/index.ts index 71518938..1e19d589 100644 --- a/tslib/react/src/index.ts +++ b/tslib/react/src/index.ts @@ -6,6 +6,7 @@ export { PlotHistory } from "./components/PlotHistory" export { PlotImportance } from "./components/PlotImportance" export { PlotIntermediateValues } from "./components/PlotIntermediateValues" export { PlotSlice } from "./components/PlotSlice" +export { PlotParallelCoordinate } from "./components/PlotParallelCoordinate" export { TrialTable } from "./components/TrialTable" export { GraphContainer } from "./components/GraphContainer" export { useGraphComponentState } from "./hooks/useGraphComponentState" diff --git a/tslib/react/test/PlotParallelCoordinate.test.tsx b/tslib/react/test/PlotParallelCoordinate.test.tsx new file mode 100644 index 00000000..5f8d4275 --- /dev/null +++ b/tslib/react/test/PlotParallelCoordinate.test.tsx @@ -0,0 +1,34 @@ +import * as Optuna from "@optuna/types" +import { render, screen } from "@testing-library/react" +import React from "react" +import { describe, expect, test } from "vitest" +import { PlotParallelCoordinate } from "../src/components/PlotParallelCoordinate" + +describe("PlotParallelCoordinate Tests", async () => { + const setup = ({ + study, + dataTestId, + }: { study: Optuna.Study; dataTestId: string }) => { + const Wrapper = ({ + dataTestId, + children, + }: { + dataTestId: string + children: React.ReactNode + }) =>
{children}
+ return render( + + + + ) + } + + for (const study of window.mockStudies) { + test(`PlotParallelCoordinate (study name: ${study.name})`, () => { + setup({ study, dataTestId: `plot-parallel-coordinate-${study.id}` }) + expect( + screen.getByTestId(`plot-parallel-coordinate-${study.id}`) + ).toBeInTheDocument() + }) + } +})