Merge pull request #893 from porink0424/fix/move-graphSlice-to-tslib

Move `GraphSlice` from `optuna_dashboard/ts` to `tslib/react`
This commit is contained in:
c-bata
2024-06-26 13:53:49 +09:00
committed by GitHub
8 changed files with 405 additions and 288 deletions
+3 -7
View File
@@ -1,9 +1,4 @@
import {
GraphContainer,
PlotEdf,
getPlotDomId,
useGraphComponentState,
} from "@optuna/react"
import { GraphContainer, PlotEdf, useGraphComponentState } from "@optuna/react"
import * as plotly from "plotly.js-dist-min"
import React, { FC, useEffect } from "react"
import { StudyDetail } from "ts/types/optuna"
@@ -22,6 +17,8 @@ export const GraphEdf: FC<{
}
}
const domId = "graph-edf"
const GraphEdfBackend: FC<{
studies: StudyDetail[]
}> = ({ studies }) => {
@@ -29,7 +26,6 @@ const GraphEdfBackend: FC<{
const { graphComponentState, notifyGraphDidRender } = useGraphComponentState()
const studyIds = studies.map((s) => s.id)
const domId = getPlotDomId(-1)
const numCompletedTrials = studies.reduce(
(acc, study) =>
acc + study?.trials.filter((t) => t.state === "Complete").length,
+9 -279
View File
@@ -1,41 +1,14 @@
import {
FormControl,
FormLabel,
Grid,
MenuItem,
Select,
SelectChangeEvent,
Switch,
Typography,
useTheme,
} from "@mui/material"
import {
GraphContainer,
PlotSlice,
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, 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 { useBackendRender, usePlotlyColorTheme } from "../state"
const plotDomId = "graph-slice"
const isLogScale = (s: SearchSpaceItem): boolean => {
if (s.distribution.type === "CategoricalDistribution") {
return false
}
return s.distribution.log
}
import { useBackendRender } from "../state"
export const GraphSlice: FC<{
study: StudyDetail | null
@@ -43,10 +16,12 @@ export const GraphSlice: FC<{
if (useBackendRender()) {
return <GraphSliceBackend study={study} />
} else {
return <GraphSliceFrontend study={study} />
return <PlotSlice study={study} />
}
}
const domId = "graph-slice"
const GraphSliceBackend: FC<{
study: StudyDetail | null
}> = ({ study = null }) => {
@@ -64,7 +39,7 @@ const GraphSliceBackend: FC<{
useEffect(() => {
if (data && layout && graphComponentState !== "componentWillMount") {
plotly.react(plotDomId, data, layout).then(notifyGraphDidRender)
plotly.react(domId, data, layout).then(notifyGraphDidRender)
}
}, [data, layout, graphComponentState])
useEffect(() => {
@@ -75,253 +50,8 @@ const GraphSliceBackend: FC<{
return (
<GraphContainer
plotDomId={plotDomId}
plotDomId={domId}
graphComponentState={graphComponentState}
/>
)
}
const GraphSliceFrontend: FC<{
study: StudyDetail | null
}> = ({ study = null }) => {
const { graphComponentState, notifyGraphDidRender } = useGraphComponentState()
const theme = useTheme()
const colorTheme = usePlotlyColorTheme(theme.palette.mode)
const [objectiveTargets, selectedObjective, setObjectiveTarget] =
useObjectiveAndUserAttrTargets(study)
const searchSpace = useMergedUnionSearchSpace(study?.union_search_space)
const [paramTargets, selectedParamTarget, setParamTarget] =
useParamTargets(searchSpace)
const [logYScale, setLogYScale] = useState<boolean>(false)
const trials = useFilteredTrials(
study,
selectedParamTarget !== null
? [selectedObjective, selectedParamTarget]
: [selectedObjective],
false
)
useEffect(() => {
if (graphComponentState !== "componentWillMount") {
plotSlice(
trials,
selectedObjective,
selectedParamTarget,
searchSpace.find((s) => s.name === selectedParamTarget?.key) || null,
logYScale,
colorTheme
)?.then(notifyGraphDidRender)
}
}, [
trials,
selectedObjective,
searchSpace,
selectedParamTarget,
logYScale,
colorTheme,
graphComponentState,
])
const handleObjectiveChange = (event: SelectChangeEvent<string>) => {
setObjectiveTarget(event.target.value)
}
const handleSelectedParam = (e: SelectChangeEvent<string>) => {
setParamTarget(e.target.value)
}
const handleLogYScaleChange = () => {
setLogYScale(!logYScale)
}
return (
<Grid container direction="row">
<Grid
item
xs={3}
container
direction="column"
sx={{ paddingRight: theme.spacing(2) }}
>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: theme.typography.fontWeightBold }}
>
Slice
</Typography>
{objectiveTargets.length !== 1 && (
<FormControl component="fieldset">
<FormLabel component="legend">Objective:</FormLabel>
<Select
value={selectedObjective.identifier()}
onChange={handleObjectiveChange}
>
{objectiveTargets.map((t, i) => (
<MenuItem value={t.identifier()} key={i}>
{t.toLabel(study?.objective_names)}
</MenuItem>
))}
</Select>
</FormControl>
)}
{paramTargets.length !== 0 && selectedParamTarget !== null && (
<FormControl component="fieldset">
<FormLabel component="legend">Parameter:</FormLabel>
<Select
value={selectedParamTarget.identifier()}
onChange={handleSelectedParam}
>
{paramTargets.map((t, i) => (
<MenuItem value={t.identifier()} key={i}>
{t.toLabel()}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl component="fieldset">
<FormLabel component="legend">Log y scale:</FormLabel>
<Switch
checked={logYScale}
onChange={handleLogYScaleChange}
value="enable"
/>
</FormControl>
</Grid>
<Grid item xs={9}>
<GraphContainer
plotDomId={plotDomId}
graphComponentState={graphComponentState}
/>
</Grid>
</Grid>
)
}
const plotSlice = (
trials: Optuna.Trial[],
objectiveTarget: Target,
selectedParamTarget: Target | null,
selectedParamSpace: SearchSpaceItem | null,
logYScale: boolean,
colorTheme: Partial<Plotly.Template>
) => {
if (document.getElementById(plotDomId) === null) {
return
}
const layout: Partial<plotly.Layout> = {
margin: {
l: 50,
t: 0,
r: 50,
b: 0,
},
xaxis: {
title: selectedParamTarget?.toLabel() || "",
type:
selectedParamSpace !== null && isLogScale(selectedParamSpace)
? "log"
: "linear",
gridwidth: 1,
automargin: true,
},
yaxis: {
title: "Objective Value",
type: logYScale ? "log" : "linear",
gridwidth: 1,
automargin: true,
},
showlegend: false,
uirevision: "true",
template: colorTheme,
}
if (
selectedParamSpace === null ||
selectedParamTarget === null ||
trials.length === 0
) {
return plotly.react(plotDomId, [], layout)
}
const feasibleTrials: Optuna.Trial[] = []
const infeasibleTrials: Optuna.Trial[] = []
trials.forEach((t) => {
if (t.constraints.every((c) => c <= 0)) {
feasibleTrials.push(t)
} else {
infeasibleTrials.push(t)
}
})
const feasibleObjectiveValues: number[] = feasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const infeasibleObjectiveValues: number[] = infeasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const feasibleValues = feasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const infeasibleValues = infeasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const trace: plotly.Data[] = [
{
type: "scatter",
x: feasibleValues,
y: feasibleObjectiveValues,
mode: "markers",
name: "Feasible Trial",
marker: {
color: feasibleTrials.map((t) => t.number),
colorscale: "Blues",
reversescale: true,
colorbar: {
title: "Trial",
},
line: {
color: "Grey",
width: 0.5,
},
},
},
{
type: "scatter",
x: infeasibleValues,
y: infeasibleObjectiveValues,
mode: "markers",
name: "Infeasible Trial",
marker: {
color: "#cccccc",
reversescale: true,
},
},
]
if (selectedParamSpace.distribution.type !== "CategoricalDistribution") {
layout["xaxis"] = {
title: selectedParamTarget.toLabel(),
type: isLogScale(selectedParamSpace) ? "log" : "linear",
gridwidth: 1,
automargin: true, // Otherwise the label is outside of the plot
}
} else {
const vocabArr = selectedParamSpace.distribution.choices.map(
(c) => c?.toString() ?? "null"
)
const tickvals: number[] = vocabArr.map((v, i) => i)
layout["xaxis"] = {
title: selectedParamTarget.toLabel(),
type: "linear",
gridwidth: 1,
tickvals: tickvals,
ticktext: vocabArr,
automargin: true, // Otherwise the label is outside of the plot
}
}
return plotly.react(plotDomId, trace, layout)
}
+1 -1
View File
@@ -12,7 +12,7 @@ export type EdfPlotInfo = {
trials: Optuna.Trial[]
}
export const getPlotDomId = (objectiveId: number) => `graph-edf-${objectiveId}`
const getPlotDomId = (objectiveId: number) => `plot-edf-${objectiveId}`
export const PlotEdf: FC<{
studies: Optuna.Study[]
@@ -0,0 +1,37 @@
import { CssBaseline, ThemeProvider } from "@mui/material"
import { Meta, StoryObj } from "@storybook/react"
import React from "react"
import { useMockStudy } from "../MockStudies"
import { lightTheme } from "../styles/lightTheme"
import { PlotSlice } from "./PlotSlice"
const meta: Meta<typeof PlotSlice> = {
component: PlotSlice,
title: "PlotSlice",
tags: ["autodocs"],
decorators: [
(Story, storyContext) => {
const { study } = useMockStudy(storyContext.parameters?.studyId)
if (!study) return <p>loading...</p>
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Story
args={{
study,
}}
/>
</ThemeProvider>
)
},
],
}
export default meta
type Story = StoryObj<typeof PlotSlice>
export const MockStudyExample1: Story = {
parameters: {
studyId: 1,
},
}
+281
View File
@@ -0,0 +1,281 @@
import {
FormControl,
FormLabel,
Grid,
MenuItem,
Select,
SelectChangeEvent,
Switch,
Typography,
useTheme,
} from "@mui/material"
import * as Optuna from "@optuna/types"
import * as plotly from "plotly.js-dist-min"
import { FC, useEffect, useState } from "react"
import { useGraphComponentState } from "../hooks/useGraphComponentState"
import { useMergedUnionSearchSpace } from "../utils/searchSpace"
import {
Target,
useFilteredTrials,
useObjectiveAndUserAttrTargets,
useParamTargets,
} from "../utils/trialFilter"
import { GraphContainer } from "./GraphContainer"
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
const isLogScale = (s: Optuna.SearchSpaceItem): boolean => {
if (s.distribution.type === "CategoricalDistribution") {
return false
}
return s.distribution.log
}
const domId = "plot-slice"
export const PlotSlice: FC<{
study: Optuna.Study | null
}> = ({ study = null }) => {
const { graphComponentState, notifyGraphDidRender } = useGraphComponentState()
const theme = useTheme()
const [objectiveTargets, selectedObjective, setObjectiveTarget] =
useObjectiveAndUserAttrTargets(study)
const searchSpace = useMergedUnionSearchSpace(study?.union_search_space)
const [paramTargets, selectedParamTarget, setParamTarget] =
useParamTargets(searchSpace)
const [logYScale, setLogYScale] = useState(false)
const trials = useFilteredTrials(
study,
selectedParamTarget !== null
? [selectedObjective, selectedParamTarget]
: [selectedObjective],
false
)
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (graphComponentState !== "componentWillMount") {
plotSlice(
trials,
selectedObjective,
selectedParamTarget,
searchSpace.find((s) => s.name === selectedParamTarget?.key) || null,
logYScale,
theme.palette.mode
)?.then(notifyGraphDidRender)
}
}, [
trials,
selectedObjective,
searchSpace,
selectedParamTarget,
logYScale,
theme.palette.mode,
graphComponentState,
])
const handleObjectiveChange = (event: SelectChangeEvent<string>) => {
setObjectiveTarget(event.target.value)
}
const handleSelectedParam = (e: SelectChangeEvent<string>) => {
setParamTarget(e.target.value)
}
const handleLogYScaleChange = () => {
setLogYScale(!logYScale)
}
return (
<Grid container direction="row">
<Grid
item
xs={3}
container
direction="column"
sx={{ paddingRight: theme.spacing(2) }}
>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: theme.typography.fontWeightBold }}
>
Slice
</Typography>
{objectiveTargets.length !== 1 && (
<FormControl component="fieldset">
<FormLabel component="legend">Objective:</FormLabel>
<Select
value={selectedObjective.identifier()}
onChange={handleObjectiveChange}
>
{objectiveTargets.map((t, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<MenuItem value={t.identifier()} key={i}>
{t.toLabel(study?.metric_names)}
</MenuItem>
))}
</Select>
</FormControl>
)}
{paramTargets.length !== 0 && selectedParamTarget !== null && (
<FormControl component="fieldset">
<FormLabel component="legend">Parameter:</FormLabel>
<Select
value={selectedParamTarget.identifier()}
onChange={handleSelectedParam}
>
{paramTargets.map((t, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<MenuItem value={t.identifier()} key={i}>
{t.toLabel()}
</MenuItem>
))}
</Select>
</FormControl>
)}
<FormControl component="fieldset">
<FormLabel component="legend">Log y scale:</FormLabel>
<Switch
checked={logYScale}
onChange={handleLogYScaleChange}
value="enable"
/>
</FormControl>
</Grid>
<Grid item xs={9}>
<GraphContainer
plotDomId={domId}
graphComponentState={graphComponentState}
/>
</Grid>
</Grid>
)
}
const plotSlice = (
trials: Optuna.Trial[],
objectiveTarget: Target,
selectedParamTarget: Target | null,
selectedParamSpace: Optuna.SearchSpaceItem | null,
logYScale: boolean,
mode: string
) => {
if (document.getElementById(domId) === null) {
return
}
const layout: Partial<plotly.Layout> = {
margin: {
l: 50,
t: 0,
r: 50,
b: 0,
},
xaxis: {
title: selectedParamTarget?.toLabel() || "",
type:
selectedParamSpace !== null && isLogScale(selectedParamSpace)
? "log"
: "linear",
gridwidth: 1,
automargin: true,
},
yaxis: {
title: "Objective Value",
type: logYScale ? "log" : "linear",
gridwidth: 1,
automargin: true,
},
showlegend: false,
uirevision: "true",
template: mode === "dark" ? plotlyDarkTemplate : {},
}
if (
selectedParamSpace === null ||
selectedParamTarget === null ||
trials.length === 0
) {
return plotly.react(domId, [], layout)
}
const feasibleTrials: Optuna.Trial[] = []
const infeasibleTrials: Optuna.Trial[] = []
// biome-ignore lint/complexity/noForEach: <explanation>
trials.forEach((t) => {
if (t.constraints.every((c) => c <= 0)) {
feasibleTrials.push(t)
} else {
infeasibleTrials.push(t)
}
})
const feasibleObjectiveValues: number[] = feasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const infeasibleObjectiveValues: number[] = infeasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const feasibleValues = feasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const infeasibleValues = infeasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const trace: plotly.Data[] = [
{
type: "scatter",
x: feasibleValues,
y: feasibleObjectiveValues,
mode: "markers",
name: "Feasible Trial",
marker: {
color: feasibleTrials.map((t) => t.number),
colorscale: "Blues",
reversescale: true,
colorbar: {
title: "Trial",
},
line: {
color: "Grey",
width: 0.5,
},
},
},
{
type: "scatter",
x: infeasibleValues,
y: infeasibleObjectiveValues,
mode: "markers",
name: "Infeasible Trial",
marker: {
color: "#cccccc",
reversescale: true,
},
},
]
if (selectedParamSpace.distribution.type !== "CategoricalDistribution") {
layout.xaxis = {
title: selectedParamTarget.toLabel(),
type: isLogScale(selectedParamSpace) ? "log" : "linear",
gridwidth: 1,
automargin: true, // Otherwise the label is outside of the plot
}
} else {
const vocabArr = selectedParamSpace.distribution.choices.map(
(c) => c?.toString() ?? "null"
)
const tickvals: number[] = vocabArr.map((_v, i) => i)
layout.xaxis = {
title: selectedParamTarget.toLabel(),
type: "linear",
gridwidth: 1,
tickvals: tickvals,
ticktext: vocabArr,
automargin: true, // Otherwise the label is outside of the plot
}
}
return plotly.react(domId, trace, layout)
}
@@ -0,0 +1,40 @@
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 { PlotSlice } from "./PlotSlice"
const meta: Meta<typeof PlotSlice> = {
component: PlotSlice,
title: "PlotSliceDark",
tags: ["autodocs"],
decorators: [
(Story, storyContext) => {
const { study } = useMockStudy(storyContext.parameters?.studyId)
if (!study) return <p>loading...</p>
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Story
args={{
study,
}}
/>
</ThemeProvider>
)
},
],
parameters: {
backgrounds: { default: "dark" },
},
}
export default meta
type Story = StoryObj<typeof PlotSlice>
export const MockStudyExample1: Story = {
parameters: {
studyId: 1,
},
}
+2 -1
View File
@@ -1,10 +1,11 @@
export { DataGrid } from "./components/DataGrid"
export { plotlyDarkTemplate } from "./components/PlotlyDarkMode"
export { PlotEdf, getPlotDomId } from "./components/PlotEdf"
export { PlotEdf } from "./components/PlotEdf"
export type { EdfPlotInfo } from "./components/PlotEdf"
export { PlotHistory } from "./components/PlotHistory"
export { PlotImportance } from "./components/PlotImportance"
export { PlotIntermediateValues } from "./components/PlotIntermediateValues"
export { PlotSlice } from "./components/PlotSlice"
export { TrialTable } from "./components/TrialTable"
export { GraphContainer } from "./components/GraphContainer"
export { useGraphComponentState } from "./hooks/useGraphComponentState"
+32
View File
@@ -0,0 +1,32 @@
import * as Optuna from "@optuna/types"
import { render, screen } from "@testing-library/react"
import React from "react"
import { describe, expect, test } from "vitest"
import { PlotSlice } from "../src/components/PlotSlice"
describe("PlotSlice Tests", async () => {
const setup = ({
study,
dataTestId,
}: { study: Optuna.Study; dataTestId: string }) => {
const Wrapper = ({
dataTestId,
children,
}: {
dataTestId: string
children: React.ReactNode
}) => <div data-testid={dataTestId}>{children}</div>
return render(
<Wrapper dataTestId={dataTestId}>
<PlotSlice study={study} />
</Wrapper>
)
}
for (const study of window.mockStudies) {
test(`PlotSlice (study name: ${study.name})`, () => {
setup({ study, dataTestId: `plot-slice-${study.id}` })
expect(screen.getByTestId(`plot-slice-${study.id}`)).toBeInTheDocument()
})
}
})