diff --git a/optuna_dashboard/_form_widget.py b/optuna_dashboard/_form_widget.py index 2d6809ab..03b29f66 100644 --- a/optuna_dashboard/_form_widget.py +++ b/optuna_dashboard/_form_widget.py @@ -46,7 +46,12 @@ if TYPE_CHECKING: ) TextInputWidgetJSON = TypedDict( "TextInputWidgetJSON", - {"type": Literal["text"], "description": Optional[str], "user_attr_key": Optional[str]}, + { + "type": Literal["text"], + "description": Optional[str], + "user_attr_key": Optional[str], + "optional": bool, + }, ) UserAttrRefJSON = TypedDict("UserAttrRefJSON", {"type": Literal["user_attr"], "key": str}) FormWidgetJSON = TypedDict( @@ -185,6 +190,7 @@ class TextInputWidget: description: A description of the text input field. user_attr_key: The key used by `register_user_attr_form_widgets`. Form output is saved as `trial.user_attrs[user_attr_key]`. Defaults to None. + optional: If True, an empty string is acceptable. Example: .. code-block:: python @@ -197,6 +203,7 @@ class TextInputWidget: description: Optional[str] = None user_attr_key: Optional[str] = None + optional: bool = False def to_dict(self) -> TextInputWidgetJSON: """ @@ -209,6 +216,7 @@ class TextInputWidget: "type": "text", "description": self.description, "user_attr_key": self.user_attr_key, + "optional": self.optional, } @classmethod @@ -217,6 +225,7 @@ class TextInputWidget: return cls( description=d.get("description"), user_attr_key=d.get("user_attr_key"), + optional=d.get("optional", False), ) @@ -347,6 +356,8 @@ def register_objective_form_widgets( not isinstance(w, ObjectiveUserAttrRef) and w.user_attr_key is not None for w in widgets ): warnings.warn("`user_attr_key` specified, but it will not be used.") + if any(isinstance(w, TextInputWidget) and w.optional for w in widgets): + raise ValueError("TextInputWidget.optional must be False.") form_widgets: FormWidgetJSON = { "output_type": "objective", "widgets": [w.to_dict() for w in widgets], diff --git a/optuna_dashboard/_serializer.py b/optuna_dashboard/_serializer.py index e789e18a..64ff7cf3 100644 --- a/optuna_dashboard/_serializer.py +++ b/optuna_dashboard/_serializer.py @@ -91,6 +91,8 @@ def serialize_attrs(attrs: dict[str, Any]) -> list[Attribute]: value: str if isinstance(v, bytes): value = "" + elif isinstance(v, str): + value = v else: value = json.dumps(v) value = value[:MAX_ATTR_LENGTH] if len(value) > MAX_ATTR_LENGTH else value diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts index ca49cc88..67210906 100644 --- a/optuna_dashboard/ts/action.ts +++ b/optuna_dashboard/ts/action.ts @@ -23,6 +23,7 @@ import { isFileUploading, artifactIsAvailable, reloadIntervalState, + trialsUpdatingState, } from "./state" import { getDominatedTrials } from "./dominatedTrials" @@ -45,6 +46,7 @@ export const actionCreator = () => { const [paramImportance, setParamImportance] = useRecoilState(paramImportanceState) const setUploading = useSetRecoilState(isFileUploading) + const setTrialsUpdating = useSetRecoilState(trialsUpdatingState) const setArtifactIsAvailable = useSetRecoilState(artifactIsAvailable) const setStudyDetailState = (studyId: number, study: StudyDetail) => { @@ -62,6 +64,13 @@ export const actionCreator = () => { newStudy.trials = newTrials setStudyDetailState(studyId, newStudy) } + const setTrialUpdating = (trialId: number, updating: boolean) => { + setTrialsUpdating((prev) => { + const newVal = Object.assign({}, prev) + newVal[trialId] = updating + return newVal + }) + } const setTrialNote = (studyId: number, index: number, note: Note) => { const newTrial: Trial = Object.assign( @@ -157,7 +166,7 @@ export const actionCreator = () => { const setTrialUserAttrs = ( studyId: number, index: number, - user_attrs: { [key: string]: number } + user_attrs: { [key: string]: number | string } ) => { const newTrial: Trial = Object.assign( {}, @@ -469,6 +478,7 @@ export const actionCreator = () => { const makeTrialFail = (studyId: number, trialId: number): void => { const message = `id=${trialId}, state=Fail` + setTrialUpdating(trialId, true) tellTrialAPI(trialId, "Fail") .then(() => { const index = studyDetails[studyId].trials.findIndex( @@ -486,6 +496,7 @@ export const actionCreator = () => { }) }) .catch((err) => { + setTrialUpdating(trialId, false) const reason = err.response?.data.reason enqueueSnackbar( `Failed to update trial (${message}). Reason: ${reason}`, @@ -503,6 +514,7 @@ export const actionCreator = () => { values: number[] ): void => { const message = `id=${trialId}, state=Complete, values=${values}` + setTrialUpdating(trialId, true) tellTrialAPI(trialId, "Complete", values) .then(() => { const index = studyDetails[studyId].trials.findIndex( @@ -517,6 +529,7 @@ export const actionCreator = () => { setTrialStateValues(studyId, index, "Complete", values) }) .catch((err) => { + setTrialUpdating(trialId, false) const reason = err.response?.data.reason enqueueSnackbar( `Failed to update trial (${message}). Reason: ${reason}`, @@ -531,10 +544,10 @@ export const actionCreator = () => { const saveTrialUserAttrs = ( studyId: number, trialId: number, - user_attrs: { [key: string]: number } + user_attrs: { [key: string]: string | number } ): void => { - console.log("user_attrs", user_attrs) const message = `id=${trialId}, user_attrs=${JSON.stringify(user_attrs)}` + setTrialUpdating(trialId, true) saveTrialUserAttrsAPI(trialId, user_attrs) .then(() => { const index = studyDetails[studyId].trials.findIndex( @@ -552,6 +565,7 @@ export const actionCreator = () => { }) }) .catch((err) => { + setTrialUpdating(trialId, false) const reason = err.response?.data.reason enqueueSnackbar( `Failed to update trial (${message}). Reason: ${reason}`, diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts index 578bcc03..3e291310 100644 --- a/optuna_dashboard/ts/apiClient.ts +++ b/optuna_dashboard/ts/apiClient.ts @@ -283,7 +283,7 @@ export const tellTrialAPI = ( export const saveTrialUserAttrsAPI = ( trialId: number, - user_attrs: { [key: string]: number } + user_attrs: { [key: string]: number | string } ): Promise => { const req = { user_attrs: user_attrs } diff --git a/optuna_dashboard/ts/components/ObjectiveForm.tsx b/optuna_dashboard/ts/components/ObjectiveForm.tsx deleted file mode 100644 index 09a44776..00000000 --- a/optuna_dashboard/ts/components/ObjectiveForm.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import React, { FC, useMemo, useState } from "react" -import { - Typography, - Box, - useTheme, - Card, - FormControlLabel, - FormControl, - FormLabel, - Button, - RadioGroup, - Radio, - Slider, - TextField, -} from "@mui/material" -import { DebouncedInputTextField } from "./Debounce" -import { actionCreator } from "../action" - -export const ObjectiveForm: FC<{ - trial: Trial - directions: StudyDirection[] - names: string[] - formWidgets: FormWidgets -}> = ({ trial, directions, names, formWidgets }) => { - const theme = useTheme() - const action = actionCreator() - const [values, setValues] = useState<(number | null)[]>( - formWidgets.widgets.map((widget) => { - if (widget === undefined) { - return null - } else if (widget.type === "text") { - return null - } else if (widget.type === "choice") { - return widget.values.at(0) || null - } else if (widget.type === "slider") { - return widget.min - } else if (widget.type === "user_attr") { - const attr = trial.user_attrs.find((attr) => attr.key == widget.key) - if (attr === undefined) { - return null - } else { - const n = Number(attr.value) - return isNaN(n) ? null : n - } - } else { - return null - } - }) - ) - - const setValue = (objectiveId: number, value: number | null) => { - const newValues = [...values] - if (newValues.length <= objectiveId) { - return - } - newValues[objectiveId] = value - setValues(newValues) - } - - const disableSubmit = useMemo( - () => values.findIndex((v) => v === null) >= 0, - [values] - ) - - const handleSubmit = (e: React.MouseEvent): void => { - e.preventDefault() - if (formWidgets.output_type == "objective") { - const filtered = values.filter((v): v is number => v !== null) - if (filtered.length !== directions.length) { - return - } - action.makeTrialComplete(trial.study_id, trial.trial_id, filtered) - } else if (formWidgets.output_type == "user_attr") { - const user_attrs = Object.fromEntries( - formWidgets.widgets.map((widget, i) => [ - widget.user_attr_key, - values[i], - ]) - ) - action.saveTrialUserAttrs(trial.study_id, trial.trial_id, user_attrs) - } - } - - const getMetricName = (i: number): string => { - if (formWidgets.output_type == "objective") { - const n = names.at(i) - if (n !== undefined) { - return n - } - if (directions.length == 1) { - return `Objective` - } else { - return `Objective ${i}` - } - } else if (formWidgets.output_type == "user_attr") { - return formWidgets.widgets[i].user_attr_key as string - } - return "Unkown metric name" - } - - return ( - <> - - {directions.length > 1 ? "Set Objective Values" : "Set Objective Value"} - - - - {formWidgets.widgets.map((widget, i) => { - const value = values.at(i) - const key = `objective-${i}` - if (widget.type === "text") { - return ( - - - {getMetricName(i)} - {widget.description} - - { - const n = Number(s) - if (s.length > 0 && valid && !isNaN(n)) { - setValue(i, n) - return - } else if (values.at(i) !== null) { - setValue(i, null) - } - }} - delay={500} - textFieldProps={{ - required: true, - autoFocus: true, - fullWidth: true, - helperText: - value === null || value === undefined - ? `Please input the float number.` - : "", - type: "text", - inputProps: { - pattern: "[-+]?[0-9]*.?[0-9]+([eE][-+]?[0-9]+)?", - }, - }} - /> - - ) - } else if (widget.type === "choice") { - return ( - - - {getMetricName(i)} - {widget.description} - - - {widget.choices.map((c, j) => ( - { - const selected = widget.values.at(j) - if (e.target.checked) { - setValue( - i, - selected === undefined ? null : selected - ) - } - }} - /> - } - label={c} - /> - ))} - - - ) - } else if (widget.type === "slider") { - return ( - - - {getMetricName(i)} - {widget.description} - - - { - // @ts-ignore - setValue(i, e.target.value as number) - }} - defaultValue={widget.min} - min={widget.min} - max={widget.max} - step={widget.step} - marks={ - widget.labels === null || widget.labels.length == 0 - ? true - : widget.labels - } - valueLabelDisplay="auto" - /> - - - ) - } else if (widget.type === "user_attr") { - return ( - - {getMetricName(i)} - - - ) - } - return null - })} - - - - - - - - - ) -} - -export const ReadonlyObjectiveForm: FC<{ - trial: Trial - directions: StudyDirection[] - names: string[] - formWidgets: FormWidgets -}> = ({ trial, directions, names, formWidgets }) => { - const theme = useTheme() - const getMetricName = (i: number): string => { - if (formWidgets.output_type == "objective") { - const n = names.at(i) - if (n !== undefined) { - return n - } - if (directions.length == 1) { - return `Objective` - } else { - return `Objective ${i}` - } - } else if (formWidgets.output_type == "user_attr") { - return formWidgets.widgets[i].user_attr_key as string - } - return "Unkown metric name" - } - return ( - <> - - {directions.length > 1 ? "Set Objective Values" : "Set Objective Value"} - - - - {formWidgets.widgets.map((widget, i) => { - const key = `objective-${i}` - if (widget.type === "text") { - return ( - - - {getMetricName(i)} - {widget.description} - - - - ) - } else if (widget.type === "choice") { - return ( - - - {getMetricName(i)} - {widget.description} - - - {widget.choices.map((c, j) => ( - - } - label={c} - disabled - /> - ))} - - - ) - } else if (widget.type === "slider") { - const value = trial.values?.at(i) - return ( - - - {getMetricName(i)} - {widget.description} - - - - - - ) - } else if (widget.type === "user_attr") { - return ( - - {getMetricName(i)} - - - ) - } - return null - })} - - - - ) -} diff --git a/optuna_dashboard/ts/components/TrialFormWidgets.tsx b/optuna_dashboard/ts/components/TrialFormWidgets.tsx new file mode 100644 index 00000000..14609fe5 --- /dev/null +++ b/optuna_dashboard/ts/components/TrialFormWidgets.tsx @@ -0,0 +1,470 @@ +import React, { FC, ReactNode, useMemo, useState } from "react" +import { + Typography, + Box, + useTheme, + Card, + FormControlLabel, + FormControl, + FormLabel, + Button, + RadioGroup, + Radio, + Slider, + TextField, +} from "@mui/material" +import { DebouncedInputTextField } from "./Debounce" +import { actionCreator } from "../action" +import { useTrialUpdatingValue } from "../state" + +type WidgetState = { + isValid: boolean + value: number | string + render: () => ReactNode +} + +export const TrialFormWidgets: FC<{ + trial: Trial + objectiveNames: string[] + directions: StudyDirection[] + formWidgets?: FormWidgets +}> = ({ trial, objectiveNames, directions, formWidgets }) => { + if ( + formWidgets === undefined || + trial.state === "Pruned" || + trial.state === "Fail" + ) { + return null + } + const theme = useTheme() + const trialNowUpdating = useTrialUpdatingValue(trial.trial_id) + const headerText = + formWidgets.output_type === "user_attr" + ? "Set User Attributes Form" + : directions.length > 1 + ? "Set Objective Values Form" + : "Set Objective Value Form" + const widgetNames = formWidgets.widgets.map((widget, i) => { + if (formWidgets.output_type == "objective") { + if (objectiveNames.at(i) !== undefined) { + return objectiveNames[i] + } + return directions.length == 1 ? "Objective" : `Objective ${i}` + } else if (formWidgets.output_type == "user_attr") { + if (widget.type !== "user_attr" && widget.user_attr_key !== undefined) { + return widget.user_attr_key + } + } + console.error("Must not reach here") + return "Unknown" + }) + + return ( + <> + + {headerText} + + {trial.state === "Running" && !trialNowUpdating ? ( + + ) : ( + + )} + + ) +} + +const UpdatableFormWidgets: FC<{ + trial: Trial + widgetNames: string[] + formWidgets: FormWidgets +}> = ({ trial, widgetNames, formWidgets }) => { + const theme = useTheme() + const action = actionCreator() + + const widgetStates = formWidgets.widgets + .map((w, i) => { + const key = `${formWidgets.output_type}-${i}` + const outputType = formWidgets.output_type + if (w.type === "text") { + return useTextInputWidget(key, outputType, w, widgetNames[i]) + } else if (w.type === "choice") { + return useChoiceWidget(key, outputType, w, widgetNames[i]) + } else if (w.type === "slider") { + return useSliderWidget(key, outputType, w, widgetNames[i]) + } else if (w.type === "user_attr") { + return useUserAttrRefWidget(key, w, widgetNames[i], trial) + } + console.error("Must not reach here") + return undefined + }) + .filter((w): w is WidgetState => w !== undefined) + + const disableSubmit = useMemo( + () => !widgetStates.every((ws) => ws.isValid), + [widgetStates] + ) + + const handleSubmit = (e: React.MouseEvent): void => { + e.preventDefault() + const values = widgetStates.map((ws) => ws.value) + if (formWidgets.output_type == "objective") { + const filtered = values.filter((v): v is number => v !== null) + if (filtered.length !== formWidgets.widgets.length) { + return + } + action.makeTrialComplete(trial.study_id, trial.trial_id, filtered) + } else if (formWidgets.output_type == "user_attr") { + const user_attrs = Object.fromEntries( + formWidgets.widgets.map((widget, i) => [ + widget.user_attr_key, + values[i] !== null ? values[i] : "", + ]) + ) + action.saveTrialUserAttrs(trial.study_id, trial.trial_id, user_attrs) + } + } + + return ( + + + {widgetStates.map((ws) => ws.render())} + + + + + + + + ) +} + +export const useTextInputWidget = ( + key: string, + widgetType: "user_attr" | "objective", + widget: ObjectiveTextInputWidget, + metricName: string +): WidgetState => { + const theme = useTheme() + const [value, setValue] = useState("") + const isValid = useMemo( + () => + widgetType === "user_attr" + ? value !== "" || widget.optional + : value !== "" && !isNaN(Number(value)), + [widget, value] + ) + + const inputProps = + widgetType === "objective" + ? { + pattern: "[-+]?[0-9]*.?[0-9]+([eE][-+]?[0-9]+)?", + } + : undefined + const helperText = + !widget.optional && value === "" ? `Please input the float number.` : "" + const render = () => ( + + + {metricName} - {widget.description} + + { + if (widgetType === "user_attr") { + setValue(s) + return + } + + const n = Number(s) + if (s.length > 0 && valid && !isNaN(n)) { + setValue(n) + } else { + setValue("") + } + }} + delay={500} + textFieldProps={{ + type: "text", + autoFocus: true, + fullWidth: true, + required: !widget.optional, + helperText, + inputProps, + }} + /> + + ) + return { isValid, value, render } +} + +export const useChoiceWidget = ( + key: string, + widgetType: "user_attr" | "objective", + widget: ObjectiveChoiceWidget, + metricName: string +): WidgetState => { + const theme = useTheme() + const [value, setValue] = useState(widget.values[0]) + const render = () => ( + + + {metricName} - {widget.description} + + + {widget.choices.map((c, j) => ( + { + const selected = widget.values.at(j) + if (selected === undefined) { + console.error("Must not reach here.") + return + } + if (e.target.checked) { + setValue(selected) + } + }} + /> + } + label={c} + /> + ))} + + + ) + return { isValid: true, value, render } +} + +export const useSliderWidget = ( + key: string, + widgetType: "user_attr" | "objective", + widget: ObjectiveSliderWidget, + metricName: string +): WidgetState => { + const theme = useTheme() + const [value, setValue] = useState(widget.min) + const render = () => ( + + + {metricName} - {widget.description} + + + { + // @ts-ignore + setValue(e.target.value as number) + }} + defaultValue={widget.min} + min={widget.min} + max={widget.max} + step={widget.step} + marks={ + widget.labels === null || widget.labels.length == 0 + ? true + : widget.labels + } + valueLabelDisplay="auto" + /> + + + ) + return { isValid: true, value, render } +} + +export const useUserAttrRefWidget = ( + key: string, + widget: ObjectiveUserAttrRef, + metricName: string, + trial: Trial +): WidgetState => { + const theme = useTheme() + const value = useMemo(() => { + const attr = trial.user_attrs.find((attr) => attr.key === widget.key) + if (attr === undefined) { + return null + } + const n = Number(attr.value) + if (isNaN(n)) { + return null + } + return n + }, [trial.user_attrs]) + const render = () => ( + + {metricName} + + + ) + return { + isValid: value !== null, + value: value !== null ? value : "", + render, + } +} + +const ReadonlyFormWidgets: FC<{ + trial: Trial + widgetNames: string[] + formWidgets: FormWidgets +}> = ({ trial, widgetNames, formWidgets }) => { + const theme = useTheme() + const getValue = (i: number): string | TrialValueNumber => { + if (formWidgets.output_type === "user_attr") { + const widget = formWidgets.widgets[i] as UserAttrFormWidget + return ( + trial.user_attrs.find((attr) => attr.key === widget.user_attr_key) + ?.value || "" + ) + } + const value = trial.values?.at(i) + if (value === undefined) { + console.error("Must not reach here.") + return 0 + } + return value + } + + return ( + + + {formWidgets.widgets.map((widget, i) => { + const key = `objective-${i}` + const widgetName = widgetNames[i] + if (widget.type === "text") { + return ( + + + {widgetName} - {widget.description} + + + + ) + } else if (widget.type === "choice") { + return ( + + + {widgetName} - {widget.description} + + + {widget.choices.map((c, j) => ( + + } + label={c} + disabled + /> + ))} + + + ) + } else if (widget.type === "slider") { + const value = trial.values?.at(i) + return ( + + + {widgetName} - {widget.description} + + + + + + ) + } else if (widget.type === "user_attr") { + return ( + + {widgetName} + + + ) + } + return null + })} + + + ) +} diff --git a/optuna_dashboard/ts/components/TrialList.tsx b/optuna_dashboard/ts/components/TrialList.tsx index 66031c20..6ccfa516 100644 --- a/optuna_dashboard/ts/components/TrialList.tsx +++ b/optuna_dashboard/ts/components/TrialList.tsx @@ -42,7 +42,7 @@ import { useRecoilValue } from "recoil" import { artifactIsAvailable } from "../state" import { actionCreator } from "../action" import { useDeleteArtifactDialog } from "./DeleteArtifactDialog" -import { ObjectiveForm, ReadonlyObjectiveForm } from "./ObjectiveForm" +import { TrialFormWidgets } from "./TrialFormWidgets" const states: TrialState[] = [ "Complete", @@ -274,26 +274,12 @@ const TrialListDetail: FC<{ latestNote={trial.note} cardSx={{ marginBottom: theme.spacing(2) }} /> - {trial.state === "Running" && - directions.length > 0 && - formWidgets !== undefined && ( - - )} - {trial.state === "Complete" && - directions.length > 0 && - formWidgets !== undefined && ( - - )} + ({ default: {}, }) +export const trialsUpdatingState = atom<{ + [trialId: string]: boolean +}>({ + key: "trialsUpdating", + default: {}, +}) + export const paramImportanceState = atom({ key: "paramImportance", default: {}, @@ -59,6 +66,11 @@ export const useStudySummaryValue = (studyId: number): StudySummary | null => { return studySummaries.find((s) => s.study_id == studyId) || null } +export const useTrialUpdatingValue = (trialId: number): boolean => { + const updating = useRecoilValue(trialsUpdatingState) + return updating[trialId] || false +} + export const useParamImportanceValue = ( studyId: number ): ParamImportance[][] | null => { diff --git a/optuna_dashboard/ts/types/index.d.ts b/optuna_dashboard/ts/types/index.d.ts index 24548a2c..b58786d7 100644 --- a/optuna_dashboard/ts/types/index.d.ts +++ b/optuna_dashboard/ts/types/index.d.ts @@ -151,6 +151,7 @@ type ObjectiveSliderWidget = { type ObjectiveTextInputWidget = { type: "text" description: string + optional: boolean user_attr_key?: string } diff --git a/python_tests/test_serializers.py b/python_tests/test_serializers.py index 131013c9..4ea8c9bd 100644 --- a/python_tests/test_serializers.py +++ b/python_tests/test_serializers.py @@ -10,17 +10,6 @@ class SerializeAttrsTestCase(TestCase): serialized = serialize_attrs({"bytes": b"This is a bytes object."}) self.assertEqual(serialized[0]["value"], "") - def test_serialize_string(self) -> None: - for length in [1000, 1024, 1100]: - with self.subTest(f"length: {length}"): - value = "a" * length - serialized = serialize_attrs( - { - "key": value, - } - ) - self.assertLessEqual(len(serialized[0]["value"]), 1024) - def test_serialize_dict(self) -> None: serialized = serialize_attrs( {