Merge branch 'main' into add_journal_storage_loader

This commit is contained in:
gen740
2023-12-15 16:33:46 +09:00
47 changed files with 2358 additions and 1308 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
'@typescript-eslint',
],
rules: {
"@typescript-eslint/ban-ts-comment": "off"
"@typescript-eslint/ban-ts-comment": "off",
"eqeqeq": ["error", "smart"],
},
extends: [
'eslint:recommended',
+1
View File
@@ -3,6 +3,7 @@
![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/optuna-dashboard)](https://pypistats.org/packages/optuna-dashboard)
[![Read the Docs](https://readthedocs.org/projects/optuna-dashboard/badge/?version=latest)](https://optuna-dashboard.readthedocs.io/en/latest/?badge=latest)
[![Codecov](https://codecov.io/gh/optuna/optuna-dashboard/branch/main/graph/badge.svg)](https://codecov.io/gh/optuna/optuna-dashboard)
Real-time dashboard for [Optuna](https://github.com/optuna/optuna).
@@ -9,6 +9,7 @@ from playwright.sync_api import Page
import pytest
from ...test_server import make_test_server
from ...utils import clear_inmemory_cache
def make_test_storage() -> optuna.storages.InMemoryStorage:
@@ -43,6 +44,7 @@ def make_test_storage() -> optuna.storages.InMemoryStorage:
@pytest.fixture
def storage() -> optuna.storages.InMemoryStorage:
clear_inmemory_cache()
storage = make_test_storage()
return storage
@@ -3,6 +3,7 @@ from playwright.sync_api import Page
import pytest
from ...test_server import make_test_server
from ...utils import clear_inmemory_cache
def make_test_storage() -> optuna.storages.InMemoryStorage:
@@ -23,6 +24,7 @@ def make_test_storage() -> optuna.storages.InMemoryStorage:
@pytest.fixture
def storage() -> optuna.storages.InMemoryStorage:
clear_inmemory_cache()
storage = make_test_storage()
return storage
@@ -5,10 +5,12 @@ from playwright.sync_api import Page
import pytest
from ..test_server import make_test_server
from ..utils import clear_inmemory_cache
@pytest.fixture
def storage() -> optuna.storages.InMemoryStorage:
clear_inmemory_cache()
storage = optuna.storages.InMemoryStorage()
return storage
+9
View File
@@ -0,0 +1,9 @@
from optuna_dashboard._storage import trials_cache
from optuna_dashboard._storage import trials_cache_lock
from optuna_dashboard._storage import trials_last_fetched_at
def clear_inmemory_cache() -> None:
with trials_cache_lock:
trials_cache.clear()
trials_last_fetched_at.clear()
+1 -1
View File
@@ -17,4 +17,4 @@ from ._note import save_note # noqa
from ._preference_setting import register_preference_feedback_component # noqa
__version__ = "0.13.0"
__version__ = "0.14.0"
+47
View File
@@ -1,8 +1,12 @@
from __future__ import annotations
import csv
import functools
import io
from itertools import chain
import logging
import os
import re
import typing
from typing import Any
from typing import Optional
@@ -152,6 +156,7 @@ def create_app(
storage=storage, study_name=dst_study_name, directions=src_study.directions
)
dst_study.add_trials(src_study.get_trials(deepcopy=False))
note.copy_notes(storage, src_study, dst_study)
except DuplicatedStudyError:
response.status = 400 # Bad request
return {"reason": f"study_name={dst_study_name} is duplicaated"}
@@ -448,6 +453,48 @@ def create_app(
response.status = 204 # No content
return {}
@app.get("/csv/<study_id:int>")
def download_csv(study_id: int) -> BottleViewReturn:
# Create a CSV file
try:
study_name = storage.get_study_name_from_id(study_id)
study = optuna.load_study(storage=storage, study_name=study_name)
except KeyError:
response.status = 404 # Not found
return {"reason": f"study_id={study_id} is not found"}
trials = study.trials
param_names = sorted(set(chain.from_iterable([t.params.keys() for t in trials])))
user_attr_names = sorted(set(chain.from_iterable([t.user_attrs.keys() for t in trials])))
param_names_header = [f"Param {x}" for x in param_names]
user_attr_names_header = [f"UserAttribute {x}" for x in user_attr_names]
n_objs = len(study.directions)
if study.metric_names is not None:
value_header = study.metric_names
else:
value_header = ["Value"] if n_objs == 1 else [f"Objective {x}" for x in range(n_objs)]
column_names = (
["Number", "State"] + value_header + param_names_header + user_attr_names_header
)
buf = io.StringIO("")
writer = csv.writer(buf)
writer.writerow(column_names)
for frozen_trial in trials:
row = [frozen_trial.number, frozen_trial.state.name]
row.extend(frozen_trial.values if frozen_trial.values is not None else [None] * n_objs)
row.extend([frozen_trial.params.get(name, None) for name in param_names])
row.extend([frozen_trial.user_attrs.get(name, None) for name in user_attr_names])
writer.writerow(row)
# Set response headers
output_name = "-".join(re.sub(r'[\\/:*?"<>|]+', "", study_name).split(" "))
response.headers["Content-Type"] = "text/csv; chatset=cp932"
response.headers["Content-Disposition"] = f"attachment; filename={output_name}.csv"
# Response body
buf.seek(0)
return buf.read()
@app.get("/favicon.ico")
def favicon() -> BottleViewReturn:
use_gzip = "gzip" in request.headers["Accept-Encoding"]
@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import numbers
import threading
from typing import List
from typing import Optional
@@ -85,9 +86,8 @@ class _CachedExtraStudyProperty:
self._cursor = next_cursor
def _update_user_attrs(self, trial: FrozenTrial) -> None:
# TODO(c-bata): Support numpy-specific number types.
current_user_attrs = {
k: not isinstance(v, bool) and isinstance(v, (int, float))
k: not isinstance(v, bool) and isinstance(v, numbers.Real)
for k, v in trial.user_attrs.items()
}
for attr_name, current_is_sortable in current_user_attrs.items():
+13
View File
@@ -110,6 +110,19 @@ def note_str_key_prefix(trial_id: Optional[int]) -> str:
return f"dashboard:{trial_id}:note_str:"
def copy_notes(storage: BaseStorage, src_study: optuna.Study, dst_study: optuna.Study) -> None:
system_attrs = storage.get_study_system_attrs(study_id=src_study._study_id)
# Copy individual trial notes
for src_trial, dst_trial in zip(src_study.get_trials(), dst_study.get_trials()):
note = get_note_from_system_attrs(system_attrs, src_trial._trial_id)["body"]
save_note_with_version(storage, dst_study._study_id, dst_trial._trial_id, 0, note)
# Copy study note
note = get_note_from_system_attrs(system_attrs, None)["body"]
save_note_with_version(storage, dst_study._study_id, None, 0, note)
def get_note_from_system_attrs(system_attrs: dict[str, Any], trial_id: Optional[int]) -> NoteType:
if note_ver_key(trial_id) not in system_attrs:
return {
+3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
import json
import numbers
from typing import Any
from typing import TYPE_CHECKING
from typing import Union
@@ -104,6 +105,8 @@ def serialize_attrs(attrs: dict[str, Any]) -> list[Attribute]:
value = "<binary object>"
elif isinstance(v, str):
value = v
elif isinstance(v, numbers.Real):
value = str(v)
else:
value = json.dumps(v)
value = value[:MAX_ATTR_LENGTH] if len(value) > MAX_ATTR_LENGTH else value
+55 -6
View File
@@ -105,7 +105,7 @@ def register_artifact_route(
@app.post("/api/artifacts/<study_id:int>/<trial_id:int>")
@json_api_view
def upload_artifact_api(study_id: int, trial_id: int) -> dict[str, Any]:
def upload_trial_artifact_api(study_id: int, trial_id: int) -> dict[str, Any]:
trial = storage.get_trial(trial_id)
if trial is None:
response.status = 400
@@ -139,14 +139,48 @@ def register_artifact_route(
storage.set_trial_system_attr(trial_id, attr_key, json.dumps(artifact))
response.status = 201
trial = storage.get_trial(trial_id) # Fetch trial.system_attrs again.
return {
"artifact_id": artifact_id,
"artifacts": list_trial_artifacts(storage.get_study_system_attrs(study_id), trial),
}
@app.post("/api/artifacts/<study_id:int>")
@json_api_view
def upload_study_artifact_api(study_id: int) -> dict[str, Any]:
if artifact_store is None:
response.status = 400 # Bad Request
return {"reason": "Cannot access to the artifacts."}
file = request.json.get("file")
if file is None:
response.status = 400
return {"reason": "Please specify the 'file' key."}
_, data = parse_data_uri(file)
filename = request.json.get("filename", "")
artifact_id = str(uuid.uuid4())
artifact_store.write(artifact_id, io.BytesIO(data))
mimetype, encoding = mimetypes.guess_type(filename)
artifact = {
"artifact_id": artifact_id,
"filename": filename,
"mimetype": mimetype or DEFAULT_MIME_TYPE,
"encoding": encoding,
}
attr_key = ARTIFACTS_ATTR_PREFIX + artifact_id
storage.set_study_system_attr(study_id, attr_key, json.dumps(artifact))
response.status = 201
return {
"artifact_id": artifact_id,
"artifacts": list_study_artifacts(storage.get_study_system_attrs(study_id)),
}
@app.delete("/api/artifacts/<study_id:int>/<trial_id:int>/<artifact_id:re:[0-9a-fA-F-]+>")
@json_api_view
def delete_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, Any]:
def delete_trial_artifact(study_id: int, trial_id: int, artifact_id: str) -> dict[str, Any]:
if artifact_store is None:
response.status = 400 # Bad Request
return {"reason": "Cannot access to the artifacts."}
@@ -154,7 +188,7 @@ def register_artifact_route(
# The artifact's metadata is stored in one of the following two locations:
storage.set_study_system_attr(
study_id, _dashboard_trial_artifact_prefix(trial_id) + artifact_id, json.dumps(None)
study_id, _dashboard_artifact_prefix(trial_id) + artifact_id, json.dumps(None)
)
storage.set_trial_system_attr(
trial_id, ARTIFACTS_ATTR_PREFIX + artifact_id, json.dumps(None)
@@ -163,6 +197,21 @@ def register_artifact_route(
response.status = 204
return {}
@app.delete("/api/artifacts/<study_id:int>/<artifact_id:re:[0-9a-fA-F-]+>")
@json_api_view
def delete_study_artifact(study_id: int, artifact_id: str) -> dict[str, Any]:
if artifact_store is None:
response.status = 400 # Bad Request
return {"reason": "Cannot access to the artifacts."}
artifact_store.remove(artifact_id)
storage.set_study_system_attr(
study_id, ARTIFACTS_ATTR_PREFIX + artifact_id, json.dumps(None)
)
response.status = 204
return {}
def upload_artifact(
backend: ArtifactBackend,
@@ -220,7 +269,7 @@ def upload_artifact(
return artifact_id
def _dashboard_trial_artifact_prefix(trial_id: int) -> str:
def _dashboard_artifact_prefix(trial_id: int) -> str:
return DASHBOARD_ARTIFACTS_ATTR_PREFIX + f"{trial_id}:"
@@ -240,7 +289,7 @@ def get_trial_artifact_meta(
) -> Optional[ArtifactMeta]:
# Search study_system_attrs due to backward compatibility.
study_system_attrs = storage.get_study_system_attrs(study_id)
attr_key = _dashboard_trial_artifact_prefix(trial_id=trial_id) + artifact_id
attr_key = _dashboard_artifact_prefix(trial_id=trial_id) + artifact_id
artifact_meta = study_system_attrs.get(attr_key)
if artifact_meta is not None:
return json.loads(artifact_meta)
@@ -284,7 +333,7 @@ def list_trial_artifacts(
dashboard_artifact_metas = [
json.loads(value)
for key, value in study_system_attrs.items()
if key.startswith(_dashboard_trial_artifact_prefix(trial._trial_id))
if key.startswith(_dashboard_artifact_prefix(trial._trial_id))
]
# Collect ArtifactMeta from trial_system_attrs. Note that artifacts uploaded via
+2 -2
View File
@@ -317,7 +317,7 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
self._rng = np.random.RandomState(seed)
self.independent_sampler = independent_sampler or optuna.samplers.RandomSampler(
seed=self._rng.randint(2**32)
seed=self._rng.randint(2**32, dtype=np.int64)
)
self._search_space = optuna.search_space.IntersectionSearchSpace()
@@ -355,7 +355,7 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
)
pref_ids = torch.tensor([[ids[b], ids[w]] for b, w in preferences], dtype=torch.int32)
with torch.random.fork_rng():
torch.manual_seed(self._rng.randint(2**32))
torch.manual_seed(self._rng.randint(2**32, dtype=np.int64))
self._gp = self._gp or _PreferentialGP(
kernel=self.kernel
+75 -11
View File
@@ -11,9 +11,11 @@ import {
tellTrialAPI,
saveTrialUserAttrsAPI,
renameStudyAPI,
uploadArtifactAPI,
uploadTrialArtifactAPI,
uploadStudyArtifactAPI,
getMetaInfoAPI,
deleteArtifactAPI,
deleteTrialArtifactAPI,
deleteStudyArtifactAPI,
reportPreferenceAPI,
skipPreferentialTrialAPI,
removePreferentialHistoryAPI,
@@ -100,7 +102,13 @@ export const actionCreator = () => {
setTrial(studyId, trialIndex, newTrial)
}
const deleteTrialArtifact = (
const setStudyArtifacts = (studyId: number, artifacts: Artifact[]) => {
const newStudy: StudyDetail = Object.assign({}, studyDetails[studyId])
newStudy.artifacts = artifacts
setStudyDetailState(studyId, newStudy)
}
const deleteTrialArtifactState = (
studyId: number,
trialId: number,
artifact_id: string
@@ -122,6 +130,18 @@ export const actionCreator = () => {
setTrialArtifacts(studyId, index, newArtifacts)
}
const deleteStudyArtifactState = (studyId: number, artifact_id: string) => {
const artifacts = studyDetails[studyId].artifacts
const artifactIndex = artifacts.findIndex(
(a) => a.artifact_id === artifact_id
)
const newArtifacts = [
...artifacts.slice(0, artifactIndex),
...artifacts.slice(artifactIndex + 1, artifacts.length),
]
setStudyArtifacts(studyId, newArtifacts)
}
const setTrialStateValues = (
studyId: number,
index: number,
@@ -154,7 +174,7 @@ export const actionCreator = () => {
currentValue > bestValue
) {
newStudy.best_trials = [newTrial]
} else if (currentValue == bestValue) {
} else if (currentValue === bestValue) {
newStudy.best_trials = [...newStudy.best_trials, newTrial]
}
}
@@ -430,7 +450,7 @@ export const actionCreator = () => {
})
}
const uploadArtifact = (
const uploadTrialArtifact = (
studyId: number,
trialId: number,
file: File
@@ -439,7 +459,7 @@ export const actionCreator = () => {
setUploading(true)
reader.readAsDataURL(file)
reader.onload = (upload: ProgressEvent<FileReader>) => {
uploadArtifactAPI(
uploadTrialArtifactAPI(
studyId,
trialId,
file.name,
@@ -467,14 +487,56 @@ export const actionCreator = () => {
}
}
const deleteArtifact = (
const uploadStudyArtifact = (studyId: number, file: File): void => {
const reader = new FileReader()
setUploading(true)
reader.readAsDataURL(file)
reader.onload = (upload: ProgressEvent<FileReader>) => {
uploadStudyArtifactAPI(
studyId,
file.name,
upload.target?.result as string
)
.then((res) => {
setUploading(false)
setStudyArtifacts(studyId, res.artifacts)
})
.catch((err) => {
setUploading(false)
const reason = err.response?.data.reason
enqueueSnackbar(`Failed to upload ${reason}`, { variant: "error" })
})
}
reader.onerror = (error) => {
enqueueSnackbar(`Failed to read the file ${error}`, { variant: "error" })
console.log(error)
}
}
const deleteTrialArtifact = (
studyId: number,
trialId: number,
artifactId: string
): void => {
deleteArtifactAPI(studyId, trialId, artifactId)
deleteTrialArtifactAPI(studyId, trialId, artifactId)
.then(() => {
deleteTrialArtifact(studyId, trialId, artifactId)
deleteTrialArtifactState(studyId, trialId, artifactId)
enqueueSnackbar(`Success to delete an artifact.`, {
variant: "success",
})
})
.catch((err) => {
const reason = err.response?.data.reason
enqueueSnackbar(`Failed to delete ${reason}.`, {
variant: "error",
})
})
}
const deleteStudyArtifact = (studyId: number, artifactId: string): void => {
deleteStudyArtifactAPI(studyId, artifactId)
.then(() => {
deleteStudyArtifactState(studyId, artifactId)
enqueueSnackbar(`Success to delete an artifact.`, {
variant: "success",
})
@@ -693,8 +755,10 @@ export const actionCreator = () => {
saveReloadInterval,
saveStudyNote,
saveTrialNote,
uploadArtifact,
deleteArtifact,
uploadTrialArtifact,
uploadStudyArtifact,
deleteTrialArtifact,
deleteStudyArtifact,
makeTrialComplete,
makeTrialFail,
saveTrialUserAttrs,
+28 -2
View File
@@ -280,7 +280,7 @@ type UploadArtifactAPIResponse = {
artifacts: Artifact[]
}
export const uploadArtifactAPI = (
export const uploadTrialArtifactAPI = (
studyId: number,
trialId: number,
fileName: string,
@@ -296,7 +296,22 @@ export const uploadArtifactAPI = (
})
}
export const deleteArtifactAPI = (
export const uploadStudyArtifactAPI = (
studyId: number,
fileName: string,
dataUrl: string
): Promise<UploadArtifactAPIResponse> => {
return axiosInstance
.post<UploadArtifactAPIResponse>(`/api/artifacts/${studyId}`, {
file: dataUrl,
filename: fileName,
})
.then((res) => {
return res.data
})
}
export const deleteTrialArtifactAPI = (
studyId: number,
trialId: number,
artifactId: string
@@ -308,6 +323,17 @@ export const deleteArtifactAPI = (
})
}
export const deleteStudyArtifactAPI = (
studyId: number,
artifactId: string
): Promise<void> => {
return axiosInstance
.delete<void>(`/api/artifacts/${studyId}/${artifactId}`)
.then(() => {
return
})
}
export const tellTrialAPI = (
trialId: number,
state: TrialStateFinished,
@@ -3,6 +3,7 @@ import {
ThreejsArtifactViewer,
isThreejsArtifact,
} from "./ThreejsArtifactViewer"
import { WaveSurferArtifactViewer } from "./WaveSurferArtifactViewer"
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"
import { CardMedia, Box } from "@mui/material"
@@ -43,9 +44,12 @@ export const ArtifactCardMedia: FC<{
alignItems: "center",
}}
>
<audio controls style={{ width: "100%" }}>
<source src={urlPath} type={artifact.mimetype} />
</audio>
<WaveSurferArtifactViewer
height={100}
waveColor="rgb(200, 0, 200)"
progressColor="rgb(100, 0, 100)"
url={urlPath}
/>
</Box>
)
} else if (artifact.mimetype.startsWith("image")) {
+116 -108
View File
@@ -10,12 +10,16 @@ import {
TableSortLabel,
Collapse,
IconButton,
useTheme,
Menu,
MenuItem,
} from "@mui/material"
import { styled } from "@mui/system"
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"
import { Clear } from "@mui/icons-material"
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"
import CheckBoxIcon from "@mui/icons-material/CheckBox"
import FilterListIcon from "@mui/icons-material/FilterList"
import ListItemIcon from "@mui/material/ListItemIcon"
type Order = "asc" | "desc"
@@ -29,14 +33,14 @@ interface DataGridColumn<T> {
label: string
sortable?: boolean
less?: (a: T, b: T, ascending: boolean) => number
filterable?: boolean
filterChoices?: string[]
toCellValue?: (rowIndex: number) => string | React.ReactNode
padding?: "normal" | "checkbox" | "none"
}
interface RowFilter {
columnIdx: number
value: Value
values: Value[]
}
function DataGrid<T>(props: {
@@ -81,24 +85,13 @@ function DataGrid<T>(props: {
}
// Filtering
const fieldAlreadyFiltered = (columnIdx: number): boolean =>
filters.some((f) => f.columnIdx === columnIdx)
const handleClickFilterCell = (columnIdx: number, value: Value) => {
if (fieldAlreadyFiltered(columnIdx)) {
return
}
const newFilters = [...filters, { columnIdx: columnIdx, value: value }]
setFilters(newFilters)
}
const filteredRows = rows.filter((row, rowIdx) => {
if (defaultFilter !== undefined && defaultFilter(row)) {
return false
}
return filters.length === 0
? true
: filters.some((f) => {
: filters.every((f) => {
if (columns.length <= f.columnIdx) {
console.log(
`columnIdx=${f.columnIdx} must be smaller than columns.length=${columns.length}`
@@ -106,11 +99,11 @@ function DataGrid<T>(props: {
return true
}
const toCellValue = columns[f.columnIdx].toCellValue
if (toCellValue !== undefined) {
return toCellValue(rowIdx) === f.value
}
const field = columns[f.columnIdx].field
return row[field] === f.value
const cellValue =
toCellValue !== undefined
? toCellValue(rowIdx)
: row[columns[f.columnIdx].field]
return f.values.some((v) => v === cellValue)
})
})
@@ -137,21 +130,32 @@ function DataGrid<T>(props: {
<TableHead>
<TableRow>
{collapseBody ? <TableCell /> : null}
{columns.map((column, columnIdx) => (
<DataGridHeaderColumn<T>
key={column.label}
column={column}
orderBy={orderBy === columnIdx ? order : null}
onOrderByChange={(direction: Order) => {
setOrder(direction)
setOrderBy(columnIdx)
}}
onFilterClear={() => {
setFilters(filters.filter((f) => f.columnIdx !== columnIdx))
}}
filtered={fieldAlreadyFiltered(columnIdx)}
/>
))}
{columns.map((column, columnIdx) => {
return (
<DataGridHeaderColumn<T>
key={columnIdx}
column={column}
order={orderBy === columnIdx ? order : null}
filter={
filters.find((f) => f.columnIdx === columnIdx) || null
}
onOrderByChange={(direction: Order) => {
setOrder(direction)
setOrderBy(columnIdx)
}}
onFilterChange={(values: Value[]) => {
const newFilters = filters.filter(
(f) => f.columnIdx !== columnIdx
)
newFilters.push({
columnIdx: columnIdx,
values: values,
})
setFilters(newFilters)
}}
/>
)
})}
</TableRow>
</TableHead>
<TableBody>
@@ -163,7 +167,6 @@ function DataGrid<T>(props: {
keyField={keyField}
collapseBody={collapseBody}
key={`${row[keyField]}`}
handleClickFilterCell={handleClickFilterCell}
/>
))}
{emptyRows > 0 && (
@@ -187,70 +190,103 @@ function DataGrid<T>(props: {
)
}
const TableHeaderCellSpan = styled("span")({
display: "inline-flex",
})
const HiddenSpan = styled("span")({
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute",
top: 20,
width: 1,
})
function DataGridHeaderColumn<T>(props: {
column: DataGridColumn<T>
orderBy: Order | null
onOrderByChange: (direction: Order) => void
filtered: boolean
onFilterClear: () => void
order: Order | null
onOrderByChange: (order: Order) => void
filter: RowFilter | null
onFilterChange: (values: Value[]) => void
dense?: boolean
}) {
const { column, orderBy, onOrderByChange, filtered, onFilterClear, dense } =
const { column, order, onOrderByChange, filter, onFilterChange, dense } =
props
const [filterMenuAnchorEl, setFilterMenuAnchorEl] =
React.useState<null | HTMLElement>(null)
const filterChoices = column.filterChoices
const HiddenSpan = styled("span")({
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute",
top: 20,
width: 1,
})
const TableHeaderCellSpan = styled("span")({
display: "inline-flex",
})
return (
<TableCell
padding={column.padding || "normal"}
sortDirection={orderBy || false}
sortDirection={order !== null ? order : false}
>
<TableHeaderCellSpan>
{column.sortable ? (
<TableSortLabel
active={orderBy !== null}
direction={orderBy || "asc"}
active={order !== null}
direction={order || "asc"}
onClick={() => {
if (orderBy === null) {
onOrderByChange("asc")
} else {
onOrderByChange(orderBy === "desc" ? "asc" : "desc")
}
onOrderByChange(order === "asc" ? "desc" : "asc")
}}
>
{column.label}
{orderBy !== null ? (
{order !== null ? (
<HiddenSpan>
{orderBy === "desc" ? "sorted descending" : "sorted ascending"}
{order === "desc" ? "sorted descending" : "sorted ascending"}
</HiddenSpan>
) : null}
</TableSortLabel>
) : (
column.label
)}
{column.filterable ? (
<IconButton
size={dense ? "small" : "medium"}
style={filtered ? {} : { visibility: "hidden" }}
color="inherit"
onClick={() => {
onFilterClear()
}}
>
<Clear />
</IconButton>
{filterChoices !== undefined ? (
<>
<IconButton
size={dense ? "small" : "medium"}
onClick={(e) => {
setFilterMenuAnchorEl(e.currentTarget)
}}
>
<FilterListIcon fontSize="small" />
</IconButton>
<Menu
anchorEl={filterMenuAnchorEl}
open={filterMenuAnchorEl !== null}
onClose={() => {
setFilterMenuAnchorEl(null)
}}
>
{filterChoices.map((choice) => (
<MenuItem
key={choice}
onClick={() => {
const newTickedValues =
filter === null
? filterChoices.filter((v) => v !== choice) // By default, every choice is ticked, so the chosen option will be unticked.
: filter.values.some((v) => v === choice)
? filter.values.filter((v) => v !== choice)
: [...filter.values, choice]
onFilterChange(newTickedValues)
}}
>
<ListItemIcon>
{!filter || filter.values.some((v) => v === choice) ? (
<CheckBoxIcon color="primary" />
) : (
<CheckBoxOutlineBlankIcon color="primary" />
)}
</ListItemIcon>
{choice}
</MenuItem>
))}
</Menu>
</>
) : null}
</TableHeaderCellSpan>
</TableCell>
@@ -263,24 +299,10 @@ function DataGridRow<T>(props: {
row: T
keyField: keyof T
collapseBody?: (rowIndex: number) => React.ReactNode
handleClickFilterCell: (columnIdx: number, value: Value) => void
}) {
const {
columns,
rowIndex,
row,
keyField,
collapseBody,
handleClickFilterCell,
} = props
const { columns, rowIndex, row, keyField, collapseBody } = props
const [open, setOpen] = React.useState(false)
const theme = useTheme()
const FilterableDiv = styled("div")({
color: theme.palette.primary.main,
textDecoration: "underline",
cursor: "pointer",
})
return (
<React.Fragment>
<TableRow hover tabIndex={-1}>
@@ -301,21 +323,7 @@ function DataGridRow<T>(props: {
: // TODO(c-bata): Avoid this implicit type conversion.
(row[column.field] as number | string | null | undefined)
return column.filterable ? (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
onClick={() => {
const value =
column.toCellValue !== undefined
? column.toCellValue(rowIndex)
: row[column.field]
handleClickFilterCell(columnIndex, value)
}}
>
<FilterableDiv>{cellItem}</FilterableDiv>
</TableCell>
) : (
return (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
@@ -376,7 +384,7 @@ function stableSort<T>(
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
if (less) {
const ascending = order == "asc"
const ascending = order === "asc"
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending)
@@ -1,4 +1,4 @@
import React, { ReactNode, useState } from "react"
import React, { ReactNode, useState, FC } from "react"
import {
Dialog,
DialogTitle,
@@ -9,7 +9,7 @@ import {
} from "@mui/material"
import { actionCreator } from "../action"
export const useDeleteArtifactDialog = (): [
export const useDeleteTrialArtifactDialog = (): [
(studyId: number, trialId: number, artifact: Artifact) => void,
() => ReactNode
] => {
@@ -33,7 +33,7 @@ export const useDeleteArtifactDialog = (): [
if (artifact === null) {
return
}
action.deleteArtifact(studyId, trialId, artifact.artifact_id)
action.deleteTrialArtifact(studyId, trialId, artifact.artifact_id)
setOpenDeleteArtifactDialog(false)
setTarget([-1, -1, null])
}
@@ -45,32 +45,96 @@ export const useDeleteArtifactDialog = (): [
const renderDeleteArtifactDialog = () => {
return (
<Dialog
open={openDeleteArtifactDialog}
onClose={() => {
handleCloseDeleteArtifactDialog()
}}
aria-labelledby="delete-artifact-dialog-title"
>
<DialogTitle id="delete-artifact-dialog-title">
Delete artifact
</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete an artifact ("
{target[2]?.filename}")?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteArtifactDialog} color="primary">
No
</Button>
<Button onClick={handleDeleteArtifact} color="primary">
Yes
</Button>
</DialogActions>
</Dialog>
<DeleteDialog
openDeleteArtifactDialog={openDeleteArtifactDialog}
handleCloseDeleteArtifactDialog={handleCloseDeleteArtifactDialog}
filename={target[2]?.filename}
handleDeleteArtifact={handleDeleteArtifact}
/>
)
}
return [openDialog, renderDeleteArtifactDialog]
}
export const useDeleteStudyArtifactDialog = (): [
(studyId: number, artifact: Artifact) => void,
() => ReactNode
] => {
const action = actionCreator()
const [openDeleteArtifactDialog, setOpenDeleteArtifactDialog] =
useState(false)
const [target, setTarget] = useState<[number, Artifact | null]>([-1, null])
const handleCloseDeleteArtifactDialog = () => {
setOpenDeleteArtifactDialog(false)
setTarget([-1, null])
}
const handleDeleteArtifact = () => {
const [studyId, artifact] = target
if (artifact === null) {
return
}
action.deleteStudyArtifact(studyId, artifact.artifact_id)
setOpenDeleteArtifactDialog(false)
setTarget([-1, null])
}
const openDialog = (studyId: number, artifact: Artifact) => {
setTarget([studyId, artifact])
setOpenDeleteArtifactDialog(true)
}
const renderDeleteArtifactDialog = () => {
return (
<DeleteDialog
openDeleteArtifactDialog={openDeleteArtifactDialog}
handleCloseDeleteArtifactDialog={handleCloseDeleteArtifactDialog}
filename={target[1]?.filename}
handleDeleteArtifact={handleDeleteArtifact}
/>
)
}
return [openDialog, renderDeleteArtifactDialog]
}
const DeleteDialog: FC<{
openDeleteArtifactDialog: boolean
handleCloseDeleteArtifactDialog: () => void
filename: string | undefined
handleDeleteArtifact: () => void
}> = ({
openDeleteArtifactDialog,
handleCloseDeleteArtifactDialog,
filename,
handleDeleteArtifact,
}) => {
return (
<Dialog
open={openDeleteArtifactDialog}
onClose={() => {
handleCloseDeleteArtifactDialog()
}}
aria-labelledby="delete-artifact-dialog-title"
>
<DialogTitle id="delete-artifact-dialog-title">
Delete artifact
</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete an artifact ("
{filename}")?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDeleteArtifactDialog} color="primary">
No
</Button>
<Button onClick={handleDeleteArtifact} color="primary">
Yes
</Button>
</DialogActions>
</Dialog>
)
}
+1 -108
View File
@@ -14,25 +14,8 @@ import {
import blue from "@mui/material/colors/blue"
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
import { useMergedUnionSearchSpace } from "../searchSpace"
import { getAxisInfo } from "../graphUtil"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unique = (array: any[]) => {
const knownElements = new Map()
array.forEach((elem) => knownElements.set(elem, true))
return Array.from(knownElements.keys())
}
type AxisInfo = {
name: string
min: number
max: number
isLog: boolean
isCat: boolean
indices: (string | number)[]
values: (string | number | null)[]
}
const PADDING_RATIO = 0.05
const plotDomId = "graph-contour"
export const Contour: FC<{
@@ -284,93 +267,3 @@ const plotContour = (
]
plotly.react(plotDomId, plotData, layout)
}
const getAxisInfoForNumericalParams = (
trials: Trial[],
paramName: string,
distribution: FloatDistribution | IntDistribution
): AxisInfo => {
let min = 0
let max = 0
if (distribution.log) {
const padding =
(Math.log10(distribution.high) - Math.log10(distribution.low)) *
PADDING_RATIO
min = Math.pow(10, Math.log10(distribution.low) - padding)
max = Math.pow(10, Math.log10(distribution.high) + padding)
} else {
const padding = (distribution.high - distribution.low) * PADDING_RATIO
min = distribution.low - padding
max = distribution.high + padding
}
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === paramName)?.param_internal_value ||
null
)
const indices = unique(values)
.filter((v) => v !== null)
.sort((a, b) => a - b)
if (indices.length >= 2) {
indices.unshift(min)
indices.push(max)
}
return {
name: paramName,
min,
max,
isLog: distribution.log,
isCat: false,
indices,
values,
}
}
const getAxisInfoForCategoricalParams = (
trials: Trial[],
paramName: string,
distribution: CategoricalDistribution
): AxisInfo => {
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === paramName)?.param_external_value ||
null
)
const isDynamic = values.some((v) => v === null)
const span = distribution.choices.length - (isDynamic ? 2 : 1)
const padding = span * PADDING_RATIO
const min = -padding
const max = span + padding
const indices = distribution.choices
.map((c) => c.value)
.sort((a, b) =>
a.toLowerCase() < b.toLowerCase()
? -1
: a.toLowerCase() > b.toLowerCase()
? 1
: 0
)
return {
name: paramName,
min,
max,
isLog: false,
isCat: true,
indices,
values,
}
}
const getAxisInfo = (trials: Trial[], param: SearchSpaceItem): AxisInfo => {
if (param.distribution.type === "CategoricalDistribution") {
return getAxisInfoForCategoricalParams(
trials,
param.name,
param.distribution
)
} else {
return getAxisInfoForNumericalParams(trials, param.name, param.distribution)
}
}
@@ -78,7 +78,7 @@ const plotIntermediateValue = (
t.state === "Pruned" &&
t.values &&
t.values.length > 0) ||
t.state == "Running"
t.state === "Running"
)
const plotData: Partial<plotly.PlotData>[] = filteredTrials.map((trial) => {
const values = trial.intermediate_values.filter(
@@ -164,7 +164,7 @@ const plotCoordinate = (
return truncated
.split("")
.map((c, i) => {
return (i + 1) % breakLength == 0 ? c + "<br>" : c
return (i + 1) % breakLength === 0 ? c + "<br>" : c
})
.join("")
}
+77 -149
View File
@@ -12,25 +12,18 @@ import {
Box,
} from "@mui/material"
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
import { makeHovertext } from "../graphUtil"
import { getAxisInfo, makeHovertext } from "../graphUtil"
import { useMergedUnionSearchSpace } from "../searchSpace"
const PADDING_RATIO = 0.05
const plotDomId = "graph-rank"
interface AxisInfo {
name: string
range: [number, number]
isLog: boolean
isCat: boolean
}
interface RankPlotInfo {
xaxis: AxisInfo
yaxis: AxisInfo
xtitle: string
ytitle: string
xtype: plotly.AxisType
ytype: plotly.AxisType
xvalues: (string | number)[]
yvalues: (string | number)[]
zvalues: number[]
colors: number[]
is_feasible: boolean[]
hovertext: string[]
@@ -154,38 +147,82 @@ const getRankPlotInfo = (
const xAxis = getAxisInfo(filteredTrials, xParam)
const yAxis = getAxisInfo(filteredTrials, yParam)
const xValues: (string | number)[] = []
const yValues: (string | number)[] = []
let xValues: (string | number)[] = []
let yValues: (string | number)[] = []
const zValues: number[] = []
const isFeasible: boolean[] = []
const hovertext: string[] = []
filteredTrials.forEach((trial) => {
const xValue =
trial.params.find((p) => p.name === xAxis.name)?.param_external_value ||
null
const yValue =
trial.params.find((p) => p.name === yAxis.name)?.param_external_value ||
null
if (trial.values === undefined || xValue === null || yValue === null) {
return
const convertTrialValueToNumber = (value: TrialValueNumber): number => {
// TrialValueNumber takes `number`, "inf", or "-inf".
return typeof value === "number"
? value
: value.includes("-")
? -Infinity
: Infinity
}
filteredTrials.forEach((trial, i) => {
const xValue = xAxis.values[i]
const yValue = yAxis.values[i]
if (xValue && yValue && trial.values) {
xValues.push(xValue)
yValues.push(yValue)
const zValue = convertTrialValueToNumber(trial.values[objectiveId])
zValues.push(zValue)
const feasibility = trial.constraints.every((c) => c <= 0)
isFeasible.push(feasibility)
hovertext.push(makeHovertext(trial))
}
const zValue = Number(trial.values[objectiveId])
const feasibility = trial.constraints.every((c) => c <= 0)
xValues.push(xValue)
yValues.push(yValue)
zValues.push(zValue)
isFeasible.push(feasibility)
hovertext.push(makeHovertext(trial))
})
const colors = getColors(zValues)
if (xAxis.isCat && !yAxis.isCat) {
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
(a, b) =>
xValues[a]
.toString()
.toLowerCase()
.localeCompare(xValues[b].toString().toLowerCase())
)
xValues = indices.map((i) => xValues[i])
yValues = indices.map((i) => yValues[i])
} else if (!xAxis.isCat && yAxis.isCat) {
const indices: number[] = Array.from(Array(yValues.length).keys()).sort(
(a, b) =>
yValues[a]
.toString()
.toLowerCase()
.localeCompare(yValues[b].toString().toLowerCase())
)
xValues = indices.map((i) => xValues[i])
yValues = indices.map((i) => yValues[i])
} else if (xAxis.isCat && yAxis.isCat) {
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
(a, b) => {
const xComp = xValues[a]
.toString()
.toLowerCase()
.localeCompare(xValues[b].toString().toLowerCase())
if (xComp !== 0) {
return xComp
}
return yValues[a]
.toString()
.toLowerCase()
.localeCompare(yValues[b].toString().toLowerCase())
}
)
xValues = indices.map((i) => xValues[i])
yValues = indices.map((i) => yValues[i])
}
return {
xaxis: xAxis,
yaxis: yAxis,
xtitle: xAxis.name,
ytitle: yAxis.name,
xtype: xAxis.isCat ? "category" : xAxis.isLog ? "log" : "linear",
ytype: yAxis.isCat ? "category" : yAxis.isLog ? "log" : "linear",
xvalues: xValues,
yvalues: yValues,
zvalues: zValues,
colors,
is_feasible: isFeasible,
hovertext,
@@ -196,72 +233,6 @@ const filterFunc = (trial: Trial): boolean => {
return trial.state === "Complete" && trial.values !== undefined
}
const getAxisInfo = (trials: Trial[], param: SearchSpaceItem): AxisInfo => {
if (param.distribution.type === "CategoricalDistribution") {
return getAxisInfoForCategorical(trials, param.name, param.distribution)
} else {
return getAxisInfoForNumerical(trials, param.name, param.distribution)
}
}
const getAxisInfoForCategorical = (
trials: Trial[],
param: string,
distribution: CategoricalDistribution
): AxisInfo => {
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === param)?.param_internal_value || null
)
const isDynamic = values.some((v) => v === null)
const span = distribution.choices.length - (isDynamic ? 2 : 1)
const padding = span * PADDING_RATIO
const min = -padding
const max = span + padding
return {
name: param,
range: [min, max],
isLog: false,
isCat: true,
}
}
const getAxisInfoForNumerical = (
trials: Trial[],
param: string,
distribution: FloatDistribution | IntDistribution
): AxisInfo => {
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === param)?.param_internal_value || null
)
const nonNullValues: number[] = []
values.forEach((value) => {
if (value !== null) {
nonNullValues.push(value)
}
})
let min = Math.min(...nonNullValues)
let max = Math.max(...nonNullValues)
if (distribution.log) {
const padding = (Math.log10(max) - Math.log10(min)) * PADDING_RATIO
min = Math.pow(10, Math.log10(min) - padding)
max = Math.pow(10, Math.log10(max) + padding)
} else {
const padding = (max - min) * PADDING_RATIO
min = min - padding
max = max + padding
}
return {
name: param,
range: [min, max],
isLog: distribution.log,
isCat: false,
}
}
const getColors = (values: number[]): number[] => {
const rawRanks = getOrderWithSameOrderAveraging(values)
let colorIdxs: number[] = []
@@ -274,7 +245,7 @@ const getColors = (values: number[]): number[] => {
}
const getOrderWithSameOrderAveraging = (values: number[]): number[] => {
const sortedValues = values.slice().sort()
const sortedValues = values.slice().sort((a, b) => a - b)
const ranks: number[] = []
values.forEach((value) => {
const firstIndex = sortedValues.indexOf(value)
@@ -299,16 +270,14 @@ const plotRank = (rankPlotInfo: RankPlotInfo | null, mode: string) => {
return
}
const xAxis = rankPlotInfo.xaxis
const yAxis = rankPlotInfo.yaxis
const layout: Partial<plotly.Layout> = {
xaxis: {
title: xAxis.name,
type: xAxis.isCat ? "category" : xAxis.isLog ? "log" : "linear",
title: rankPlotInfo.xtitle,
type: rankPlotInfo.xtype,
},
yaxis: {
title: yAxis.name,
type: yAxis.isCat ? "category" : yAxis.isLog ? "log" : "linear",
title: rankPlotInfo.ytitle,
type: rankPlotInfo.ytype,
},
margin: {
l: 50,
@@ -320,49 +289,8 @@ const plotRank = (rankPlotInfo: RankPlotInfo | null, mode: string) => {
template: mode === "dark" ? plotlyDarkTemplate : {},
}
let xValues = rankPlotInfo.xvalues
let yValues = rankPlotInfo.yvalues
if (xAxis.isCat && !yAxis.isCat) {
const xIndices: number[] = Array.from(Array(xValues.length).keys()).sort(
(a, b) =>
xValues[a]
.toString()
.toLowerCase()
.localeCompare(xValues[b].toString().toLowerCase())
)
xValues = xIndices.map((i) => xValues[i])
yValues = xIndices.map((i) => yValues[i])
}
if (!xAxis.isCat && yAxis.isCat) {
const yIndices: number[] = Array.from(Array(yValues.length).keys()).sort(
(a, b) =>
yValues[a]
.toString()
.toLowerCase()
.localeCompare(yValues[b].toString().toLowerCase())
)
xValues = yIndices.map((i) => xValues[i])
yValues = yIndices.map((i) => yValues[i])
}
if (xAxis.isCat && yAxis.isCat) {
const indices: number[] = Array.from(Array(xValues.length).keys()).sort(
(a, b) => {
const xComp = xValues[a]
.toString()
.toLowerCase()
.localeCompare(xValues[b].toString().toLowerCase())
if (xComp !== 0) {
return xComp
}
return yValues[a]
.toString()
.toLowerCase()
.localeCompare(yValues[b].toString().toLowerCase())
}
)
xValues = indices.map((i) => xValues[i])
yValues = indices.map((i) => yValues[i])
}
const xValues = rankPlotInfo.xvalues
const yValues = rankPlotInfo.yvalues
const plotData: Partial<plotly.PlotData>[] = [
{
+2 -2
View File
@@ -425,7 +425,7 @@ const ArtifactUploader: FC<{
if (files === null) {
return
}
action.uploadArtifact(studyId, trialId, files[0])
action.uploadTrialArtifact(studyId, trialId, files[0])
}
const handleDrop: DragEventHandler = (e) => {
@@ -433,7 +433,7 @@ const ArtifactUploader: FC<{
e.preventDefault()
const file = e.dataTransfer.files[0]
setDragOver(false)
action.uploadArtifact(studyId, trialId, file)
action.uploadTrialArtifact(studyId, trialId, file)
}
const handleDragOver: DragEventHandler = (e) => {
@@ -0,0 +1,226 @@
import React, {
ChangeEventHandler,
DragEventHandler,
FC,
MouseEventHandler,
useRef,
useState,
} from "react"
import {
Typography,
Box,
useTheme,
IconButton,
Card,
CardContent,
CardActionArea,
} from "@mui/material"
import UploadFileIcon from "@mui/icons-material/UploadFile"
import DownloadIcon from "@mui/icons-material/Download"
import DeleteIcon from "@mui/icons-material/Delete"
import FullscreenIcon from "@mui/icons-material/Fullscreen"
import { actionCreator } from "../action"
import { useDeleteStudyArtifactDialog } from "./DeleteArtifactDialog"
import {
useThreejsArtifactModal,
isThreejsArtifact,
} from "./ThreejsArtifactViewer"
import { ArtifactCardMedia } from "./ArtifactCardMedia"
export const StudyArtifactCards: FC<{ study: StudyDetail }> = ({ study }) => {
const theme = useTheme()
const [openDeleteArtifactDialog, renderDeleteArtifactDialog] =
useDeleteStudyArtifactDialog()
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
useThreejsArtifactModal()
const width = "200px"
const height = "150px"
return (
<>
<Box sx={{ display: "flex", flexWrap: "wrap", p: theme.spacing(1, 0) }}>
{study.artifacts.map((artifact) => {
const urlPath = `/artifacts/${study.id}/${artifact.artifact_id}`
return (
<Card
key={artifact.artifact_id}
sx={{
marginBottom: theme.spacing(2),
width: width,
margin: theme.spacing(0, 1, 1, 0),
border: `1px solid ${theme.palette.divider}`,
}}
>
<ArtifactCardMedia
artifact={artifact}
urlPath={urlPath}
height={height}
/>
<CardContent
sx={{
display: "flex",
flexDirection: "row",
padding: `${theme.spacing(1)} !important`,
}}
>
<Typography
sx={{
p: theme.spacing(0.5, 0),
flexGrow: 1,
wordWrap: "break-word",
maxWidth: `calc(100% - ${
isThreejsArtifact(artifact)
? theme.spacing(12)
: theme.spacing(8)
})`,
}}
>
{artifact.filename}
</Typography>
{isThreejsArtifact(artifact) ? (
<IconButton
aria-label="show artifact 3d model"
size="small"
color="inherit"
sx={{ margin: "auto 0" }}
onClick={() => {
openThreejsArtifactModal(urlPath, artifact)
}}
>
<FullscreenIcon />
</IconButton>
) : null}
<IconButton
aria-label="delete artifact"
size="small"
color="inherit"
sx={{ margin: "auto 0" }}
onClick={() => {
openDeleteArtifactDialog(study.id, artifact)
}}
>
<DeleteIcon />
</IconButton>
<IconButton
aria-label="download artifact"
size="small"
color="inherit"
download={artifact.filename}
sx={{ margin: "auto 0" }}
href={urlPath}
>
<DownloadIcon />
</IconButton>
</CardContent>
</Card>
)
})}
<StudyArtifactUploader study={study} width={width} height={height} />
</Box>
{renderDeleteArtifactDialog()}
{renderThreejsArtifactModal()}
</>
)
}
const StudyArtifactUploader: FC<{
study: StudyDetail
width: string
height: string
}> = ({ study, width, height }) => {
const theme = useTheme()
const [dragOver, setDragOver] = useState<boolean>(false)
const action = actionCreator()
const inputRef = useRef<HTMLInputElement>(null)
const handleClick: MouseEventHandler = () => {
if (!inputRef || !inputRef.current) {
return
}
inputRef.current.click()
}
const handleOnChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const files = e.target.files
if (files === null) {
return
}
action.uploadStudyArtifact(study.id, files[0])
}
const handleDragOver: DragEventHandler = (e) => {
e.stopPropagation()
e.preventDefault()
e.dataTransfer.dropEffect = "copy"
setDragOver(true)
}
const handleDragLeave: DragEventHandler = (e) => {
e.stopPropagation()
e.preventDefault()
e.dataTransfer.dropEffect = "copy"
setDragOver(false)
}
const handleDrop: DragEventHandler = (e) => {
e.stopPropagation()
e.preventDefault()
const files = e.dataTransfer.files
setDragOver(false)
for (let i = 0; i < files.length; i++) {
action.uploadStudyArtifact(study.id, files[i])
}
}
return (
<Card
sx={{
marginBottom: theme.spacing(2),
width: width,
minHeight: height,
margin: theme.spacing(0, 1, 1, 0),
border: dragOver
? `3px dashed ${theme.palette.mode === "dark" ? "white" : "black"}`
: `1px solid ${theme.palette.divider}`,
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<CardActionArea
onClick={handleClick}
sx={{
height: "100%",
}}
>
<CardContent
sx={{
display: "flex",
height: "100%",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<UploadFileIcon
sx={{ fontSize: 80, marginBottom: theme.spacing(2) }}
/>
<input
type="file"
ref={inputRef}
onChange={handleOnChange}
style={{ display: "none" }}
/>
<Typography>Upload a New File</Typography>
<Typography
sx={{ textAlign: "center", color: theme.palette.grey.A400 }}
>
Drag your file here or click to browse.
</Typography>
</CardContent>
</CardActionArea>
</Card>
)
}
+35 -6
View File
@@ -12,6 +12,7 @@ import {
import Grid2 from "@mui/material/Unstable_Grid2"
import ChevronRightIcon from "@mui/icons-material/ChevronRight"
import HomeIcon from "@mui/icons-material/Home"
import DownloadIcon from "@mui/icons-material/Download"
import { StudyNote } from "./Note"
import { actionCreator } from "../action"
@@ -149,11 +150,39 @@ export const StudyDetail: FC<{
content = <TrialList studyDetail={studyDetail} />
} else if (page === "trialTable") {
content = (
<Card sx={{ margin: theme.spacing(2) }}>
<CardContent>
<TrialTable studyDetail={studyDetail} initialRowsPerPage={50} />
</CardContent>
</Card>
<Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}>
<Card
sx={{
margin: theme.spacing(2),
width: "auto",
height: "auto",
display: "flex",
justifyContent: "left",
alignItems: "left",
}}
>
<CardContent>
<IconButton
aria-label="download csv"
size="small"
color="inherit"
download
sx={{ margin: "auto 0" }}
href={`/csv/${studyDetail?.id}`}
>
<DownloadIcon />
<Typography variant="button" sx={{ margin: theme.spacing(2) }}>
Download CSV File
</Typography>
</IconButton>
</CardContent>
</Card>
<Card sx={{ margin: theme.spacing(2) }}>
<CardContent>
<TrialTable studyDetail={studyDetail} initialRowsPerPage={50} />
</CardContent>
</Card>
</Box>
)
} else if (page === "note" && studyDetail !== null) {
content = (
@@ -192,7 +221,7 @@ export const StudyDetail: FC<{
<PreferentialGraph studyDetail={studyDetail} />
</Box>
)
} else if (page == "preferenceHistory") {
} else if (page === "preferenceHistory") {
content = <PreferenceHistory studyDetail={studyDetail} />
}
+41 -11
View File
@@ -17,12 +17,15 @@ import { DataGrid, DataGridColumn } from "./DataGrid"
import { GraphHyperparameterImportance } from "./GraphHyperparameterImportances"
import { UserDefinedPlot } from "./UserDefinedPlot"
import { BestTrialsCard } from "./BestTrialsCard"
import { StudyArtifactCards } from "./StudyArtifactCards"
import { useRecoilValue } from "recoil"
import {
useStudyDetailValue,
useStudyDirections,
useStudySummaryValue,
} from "../state"
import FormControlLabel from "@mui/material/FormControlLabel"
import { artifactIsAvailable } from "../state"
export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
const theme = useTheme()
@@ -31,6 +34,7 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
const studyDetail = useStudyDetailValue(studyId)
const [logScale, setLogScale] = useState<boolean>(false)
const [includePruned, setIncludePruned] = useState<boolean>(true)
const artifactEnabled = useRecoilValue<boolean>(artifactIsAvailable)
const handleLogScaleChange = () => {
setLogScale(!logScale)
@@ -104,17 +108,6 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
</CardContent>
</Card>
<Grid2 container spacing={2} sx={{ padding: theme.spacing(0, 2) }}>
{studyDetail !== null &&
studyDetail.directions.length == 1 &&
studyDetail.has_intermediate_values ? (
<Grid2 xs={6}>
<GraphIntermediateValues
trials={trials}
includePruned={includePruned}
logScale={logScale}
/>
</Grid2>
) : null}
<Grid2 xs={6}>
<GraphHyperparameterImportance
studyId={studyId}
@@ -166,7 +159,44 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
</CardContent>
</Card>
</Grid2>
{studyDetail !== null &&
studyDetail.directions.length === 1 &&
studyDetail.has_intermediate_values ? (
<Grid2 xs={6}>
<GraphIntermediateValues
trials={trials}
includePruned={includePruned}
logScale={logScale}
/>
</Grid2>
) : null}
</Grid2>
{artifactEnabled && studyDetail !== null && (
<Grid2 container spacing={2} sx={{ padding: theme.spacing(0, 2) }}>
<Grid2 xs={6}>
<Card>
<CardContent
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography
variant="h6"
sx={{
margin: "1em 0",
fontWeight: theme.typography.fontWeightBold,
}}
>
Study Artifacts
</Typography>
<StudyArtifactCards study={studyDetail} />
</CardContent>
</Card>
</Grid2>
</Grid2>
)}
</Box>
)
}
@@ -21,7 +21,7 @@ import DeleteIcon from "@mui/icons-material/Delete"
import FullscreenIcon from "@mui/icons-material/Fullscreen"
import { actionCreator } from "../action"
import { useDeleteArtifactDialog } from "./DeleteArtifactDialog"
import { useDeleteTrialArtifactDialog } from "./DeleteArtifactDialog"
import {
useThreejsArtifactModal,
isThreejsArtifact,
@@ -31,9 +31,12 @@ import { ArtifactCardMedia } from "./ArtifactCardMedia"
export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
const theme = useTheme()
const [openDeleteArtifactDialog, renderDeleteArtifactDialog] =
useDeleteArtifactDialog()
useDeleteTrialArtifactDialog()
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
useThreejsArtifactModal()
const isArtifactModifiable = (trial: Trial) => {
return trial.state === "Running" || trial.state === "Waiting"
}
const width = "200px"
const height = "150px"
@@ -75,11 +78,11 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
p: theme.spacing(0.5, 0),
flexGrow: 1,
wordWrap: "break-word",
maxWidth: `calc(100% - ${
isThreejsArtifact(artifact)
? theme.spacing(12)
: theme.spacing(8)
})`,
maxWidth: `calc(100% - ${theme.spacing(
4 +
(isThreejsArtifact(artifact) ? 4 : 0) +
(isArtifactModifiable(trial) ? 4 : 0)
)})`,
}}
>
{artifact.filename}
@@ -97,21 +100,23 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
<FullscreenIcon />
</IconButton>
) : null}
<IconButton
aria-label="delete artifact"
size="small"
color="inherit"
sx={{ margin: "auto 0" }}
onClick={() => {
openDeleteArtifactDialog(
trial.study_id,
trial.trial_id,
artifact
)
}}
>
<DeleteIcon />
</IconButton>
{isArtifactModifiable(trial) ? (
<IconButton
aria-label="delete artifact"
size="small"
color="inherit"
sx={{ margin: "auto 0" }}
onClick={() => {
openDeleteArtifactDialog(
trial.study_id,
trial.trial_id,
artifact
)
}}
>
<DeleteIcon />
</IconButton>
) : null}
<IconButton
aria-label="download artifact"
size="small"
@@ -126,7 +131,9 @@ export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
</Card>
)
})}
<TrialArtifactUploader trial={trial} width={width} height={height} />
{isArtifactModifiable(trial) ? (
<TrialArtifactUploader trial={trial} width={width} height={height} />
) : null}
</Box>
{renderDeleteArtifactDialog()}
{renderThreejsArtifactModal()}
@@ -143,9 +150,6 @@ const TrialArtifactUploader: FC<{
const action = actionCreator()
const [dragOver, setDragOver] = useState<boolean>(false)
if (trial.state !== "Running" && trial.state !== "Waiting") {
return null
}
const inputRef = useRef<HTMLInputElement>(null)
const handleClick: MouseEventHandler = () => {
if (!inputRef || !inputRef.current) {
@@ -158,7 +162,7 @@ const TrialArtifactUploader: FC<{
if (files === null) {
return
}
action.uploadArtifact(trial.study_id, trial.trial_id, files[0])
action.uploadTrialArtifact(trial.study_id, trial.trial_id, files[0])
}
const handleDrop: DragEventHandler = (e) => {
e.stopPropagation()
@@ -166,7 +170,7 @@ const TrialArtifactUploader: FC<{
const files = e.dataTransfer.files
setDragOver(false)
for (let i = 0; i < files.length; i++) {
action.uploadArtifact(trial.study_id, trial.trial_id, files[i])
action.uploadTrialArtifact(trial.study_id, trial.trial_id, files[i])
}
}
const handleDragOver: DragEventHandler = (e) => {
@@ -45,12 +45,12 @@ export const TrialFormWidgets: FC<{
? "Set Objective Values Form"
: "Set Objective Value Form"
const widgetNames = formWidgets.widgets.map((widget, i) => {
if (formWidgets.output_type == "objective") {
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") {
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
}
@@ -118,13 +118,13 @@ const UpdatableFormWidgets: FC<{
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.preventDefault()
const values = widgetStates.map((ws) => ws.value)
if (formWidgets.output_type == "objective") {
if (formWidgets.output_type === "objective") {
const filtered = values.filter<number>((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") {
} else if (formWidgets.output_type === "user_attr") {
const user_attrs = Object.fromEntries(
formWidgets.widgets.map((widget, i) => [
widget.user_attr_key,
@@ -433,7 +433,7 @@ const ReadonlyFormWidgets: FC<{
max={widget.max}
step={widget.step}
marks={
widget.labels === null || widget.labels.length == 0
widget.labels === null || widget.labels.length === 0
? true
: widget.labels
}
+1 -1
View File
@@ -119,7 +119,7 @@ const useIsBestTrial = (
return useMemo(() => {
const bestTrialIDs = studyDetail?.best_trials.map((t) => t.trial_id) || []
return (trialId: number): boolean =>
bestTrialIDs.findIndex((a) => a === trialId) != -1
bestTrialIDs.findIndex((a) => a === trialId) !== -1
}, [studyDetail])
}
@@ -18,12 +18,12 @@ export const TrialTable: FC<{
field: "state",
label: "State",
sortable: true,
filterable: true,
filterChoices: ["Complete", "Pruned", "Fail", "Running", "Waiting"],
padding: "none",
toCellValue: (i) => trials[i].state.toString(),
},
]
if (studyDetail === null || studyDetail.directions.length == 1) {
if (studyDetail === null || studyDetail.directions.length === 1) {
columns.push({
field: "values",
label: "Value",
@@ -97,7 +97,10 @@ export const TrialTable: FC<{
) {
studyDetail?.intersection_search_space.forEach((s) => {
const sortable = s.distribution.type !== "CategoricalDistribution"
const filterable = s.distribution.type === "CategoricalDistribution"
const filterChoices =
s.distribution.type === "CategoricalDistribution"
? s.distribution.choices.map((c) => c.value)
: undefined
columns.push({
field: "params",
label: `Param ${s.name}`,
@@ -105,7 +108,7 @@ export const TrialTable: FC<{
trials[i].params.find((p) => p.name === s.name)
?.param_external_value || null,
sortable: sortable,
filterable: filterable,
filterChoices: filterChoices,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.params.find(
@@ -146,7 +149,6 @@ export const TrialTable: FC<{
trials[i].user_attrs.find((attr) => attr.key === attr_spec.key)
?.value || null,
sortable: attr_spec.sortable,
filterable: !attr_spec.sortable,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.user_attrs.find(
@@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useState, useRef } from "react"
import WaveSurfer from "wavesurfer.js"
import { Box } from "@mui/material"
interface WaveSurferArtifactViewerProps {
height: number
waveColor: string
progressColor: string
url: string
}
const useWavesurfer = (
containerRef: React.MutableRefObject<HTMLDivElement>,
options: WaveSurferArtifactViewerProps
) => {
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null)
useEffect(() => {
if (!containerRef.current) return
const ws = WaveSurfer.create({
...options,
container: containerRef.current,
})
setWavesurfer(ws)
return () => {
ws.destroy()
}
}, [containerRef])
return wavesurfer
}
// Create a React component of wavesurfer.
export const WaveSurferArtifactViewer: React.FC<
WaveSurferArtifactViewerProps
> = (props) => {
const containerRef = useRef<HTMLDivElement>(null!)
const [isPlaying, setIsPlaying] = useState(false)
const wavesurfer = useWavesurfer(containerRef, props)
const onPlayClick = useCallback(() => {
if (!wavesurfer) return
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play()
}, [wavesurfer])
useEffect(() => {
if (!wavesurfer) return
setIsPlaying(false)
const subscriptions = [
wavesurfer.on("play", () => setIsPlaying(true)),
wavesurfer.on("pause", () => setIsPlaying(false)),
]
return () => {
subscriptions.forEach((unsub) => unsub())
}
}, [wavesurfer])
return (
<Box style={{ width: "100%", display: "flex", flexDirection: "column" }}>
<div ref={containerRef} style={{ minHeight: "120px", width: "100%" }} />
<button onClick={onPlayClick} style={{ marginTop: "1em" }}>
{isPlaying ? "Pause" : "Play"}
</button>
</Box>
)
}
+1 -1
View File
@@ -27,7 +27,7 @@ export const getDominatedTrials = (
const dominatedTrials: boolean[] = []
normalizedValues.forEach((values0: number[], i: number) => {
const dominated = normalizedValues.some((values1: number[], j: number) => {
if (i === j || values0.every((v, i) => v == values1[i])) {
if (i === j || values0.every((v, i) => v === values1[i])) {
return false
}
return values0.every((value0: number, k: number) => {
+101
View File
@@ -1,3 +1,104 @@
const PADDING_RATIO = 0.05
export type AxisInfo = {
name: string
isLog: boolean
isCat: boolean
indices: (string | number)[]
values: (string | number | null)[]
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unique = (array: any[]) => {
const knownElements = new Map()
array.forEach((elem) => knownElements.set(elem, true))
return Array.from(knownElements.keys())
}
export const getAxisInfo = (
trials: Trial[],
param: SearchSpaceItem
): AxisInfo => {
if (param.distribution.type === "CategoricalDistribution") {
return getAxisInfoForCategoricalParams(
trials,
param.name,
param.distribution
)
} else {
return getAxisInfoForNumericalParams(trials, param.name, param.distribution)
}
}
const getAxisInfoForCategoricalParams = (
trials: Trial[],
paramName: string,
distribution: CategoricalDistribution
): AxisInfo => {
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === paramName)?.param_external_value ||
null
)
const indices = distribution.choices
.map((c) => c.value)
.sort((a, b) =>
a.toLowerCase() < b.toLowerCase()
? -1
: a.toLowerCase() > b.toLowerCase()
? 1
: 0
)
return {
name: paramName,
isLog: false,
isCat: true,
indices,
values,
}
}
const getAxisInfoForNumericalParams = (
trials: Trial[],
paramName: string,
distribution: FloatDistribution | IntDistribution
): AxisInfo => {
let min = 0
let max = 0
if (distribution.log) {
const padding =
(Math.log10(distribution.high) - Math.log10(distribution.low)) *
PADDING_RATIO
min = Math.pow(10, Math.log10(distribution.low) - padding)
max = Math.pow(10, Math.log10(distribution.high) + padding)
} else {
const padding = (distribution.high - distribution.low) * PADDING_RATIO
min = distribution.low - padding
max = distribution.high + padding
}
const values = trials.map(
(trial) =>
trial.params.find((p) => p.name === paramName)?.param_internal_value ||
null
)
const indices = unique(values)
.filter((v) => v !== null)
.sort((a, b) => a - b)
if (indices.length >= 2) {
indices.unshift(min)
indices.push(max)
}
return {
name: paramName,
isLog: distribution.log,
isCat: false,
indices,
values,
}
}
export const makeHovertext = (trial: Trial): string => {
return JSON.stringify(
{
+1 -1
View File
@@ -63,7 +63,7 @@ export const useStudyDetailValue = (studyId: number): StudyDetail | null => {
export const useStudySummaryValue = (studyId: number): StudySummary | null => {
const studySummaries = useRecoilValue<StudySummary[]>(studySummariesState)
return studySummaries.find((s) => s.study_id == studyId) || null
return studySummaries.find((s) => s.study_id === studyId) || null
}
export const useTrialUpdatingValue = (trialId: number): boolean => {
+824 -766
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -41,7 +41,8 @@
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"three": "^0.155.0"
"three": "^0.155.0",
"wavesurfer.js": "^7.4.12"
},
"devDependencies": {
"@babel/core": "^7.14.3",
@@ -52,12 +53,12 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-syntax-highlighter": "^15.5.5",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"compression-webpack-plugin": "^10.0.0",
"css-loader": "^6.8.1",
"esbuild-loader": "^2.18.0",
"eslint": "^7.28.0",
"eslint": "^8.53.0",
"jest": "^29.2.1",
"jest-canvas-mock": "^2.3.1",
"jest-environment-jsdom": "^29.3.1",
+170 -1
View File
@@ -1,9 +1,18 @@
import base64
import json
import tempfile
from unittest.mock import MagicMock
import optuna
from optuna.artifacts import FileSystemArtifactStore
from optuna.artifacts import upload_artifact
from optuna.storages import BaseStorage
from optuna_dashboard._app import create_app
from optuna_dashboard.artifact import _backend
import pytest
from ..wsgi_client import send_request
def test_get_artifact_path() -> None:
study = MagicMock(_study_id=0)
@@ -12,7 +21,7 @@ def test_get_artifact_path() -> None:
def test_artifact_prefix() -> None:
actual = _backend._dashboard_trial_artifact_prefix(trial_id=0)
actual = _backend._dashboard_artifact_prefix(trial_id=0)
assert actual == "dashboard:artifacts:0:"
@@ -80,3 +89,163 @@ def test_list_trial_artifacts(init_storage_with_artifact_meta: MagicMock) -> Non
{"artifact_id": "id1", "filename": "bar.txt"},
{"artifact_id": "id2", "filename": "baz.txt"},
]
def test_study_artifact_store_none() -> None:
storage = optuna.storages.InMemoryStorage()
app = create_app(storage)
status, _, body = send_request(
app,
"/artifacts/0/0",
"GET",
)
assert status == 400
def test_study_artifact_not_found() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
app = create_app(storage, artifact_store)
status, _, body = send_request(
app,
f"/artifacts/{study._study_id}/abc123",
"GET",
)
assert status == 404
def test_successful_study_artifact_retrieval() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
with tempfile.NamedTemporaryFile() as f:
f.write(b"dummy_content")
f.flush()
artifact_id = upload_artifact(study, f.name, artifact_store=artifact_store)
app = create_app(storage, artifact_store)
status, _, body = send_request(
app,
f"/artifacts/{study._study_id}/{artifact_id}",
"GET",
)
assert status == 200
assert body == b"dummy_content"
def test_trial_artifact_store_none() -> None:
storage = optuna.storages.InMemoryStorage()
app = create_app(storage)
status, _, body = send_request(
app,
"/artifacts/0/0/0",
"GET",
)
assert status == 400
def test_trial_artifact_not_found() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial = study.ask()
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
app = create_app(storage, artifact_store)
status, _, body = send_request(
app,
f"/artifacts/{study._study_id}/{trial._trial_id}/abc123",
"GET",
)
assert status == 404
def test_successful_trial_artifact_retrieval() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial = study.ask()
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
with tempfile.NamedTemporaryFile() as f:
f.write(b"dummy_content")
f.flush()
artifact_id = upload_artifact(trial, f.name, artifact_store=artifact_store)
app = create_app(storage, artifact_store)
status, _, body = send_request(
app,
f"/artifacts/{study._study_id}/{trial._trial_id}/{artifact_id}",
"GET",
)
assert status == 200
assert body == b"dummy_content"
DUMMY_DATA_URL = (
f"data:text/plain; charset=utf-8,{base64.b64encode(b'dummy_content').decode('utf-8')}"
)
def test_upload_artifact_invalid_no_trial() -> None:
storage = optuna.storages.InMemoryStorage()
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
app = create_app(storage, artifact_store)
study = optuna.create_study(storage=storage)
status, _, body = send_request(
app,
f"/api/artifacts/{study._study_id}/0",
"POST",
body=json.dumps({"file": DUMMY_DATA_URL}),
content_type="application/json",
)
assert status == 500 # TODO(contramundum53): This should return 400
def test_upload_artifact_invalid_complete_trial() -> None:
storage = optuna.storages.InMemoryStorage()
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
app = create_app(storage, artifact_store)
study = optuna.create_study(storage=storage)
study.add_trial(optuna.create_trial(value=1.0, distributions={}, params={}))
trial = study.trials[-1]
status, _, body = send_request(
app,
f"/api/artifacts/{study._study_id}/{trial._trial_id}",
"POST",
body=json.dumps({"file": DUMMY_DATA_URL}),
content_type="application/json",
)
assert status == 400
def test_upload_artifact() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
with tempfile.TemporaryDirectory() as tmpdir:
artifact_store = FileSystemArtifactStore(tmpdir)
app = create_app(storage, artifact_store)
study.add_trial(optuna.create_trial(state=optuna.trial.TrialState.RUNNING))
trial = study.trials[-1]
status, _, body = send_request(
app,
f"/api/artifacts/{study._study_id}/{trial._trial_id}",
"POST",
body=json.dumps({"file": DUMMY_DATA_URL}),
content_type="application/json",
)
assert status == 201
res = json.loads(body)
with open(f"{tmpdir}/{res['artifact_id']}", "r") as f:
data = f.read()
assert data == "dummy_content"
+106
View File
@@ -9,6 +9,8 @@ from optuna import get_all_study_summaries
from optuna.study import StudyDirection
from optuna_dashboard._app import create_app
from optuna_dashboard._app import create_new_study
from optuna_dashboard._note import note_str_key_prefix
from optuna_dashboard._note import note_ver_key
from optuna_dashboard._preference_setting import register_preference_feedback_component
from optuna_dashboard._preferential_history import NewHistory
from optuna_dashboard._preferential_history import remove_history
@@ -220,6 +222,110 @@ class APITestCase(TestCase):
assert study_detail["feedback_component_type"]["output_type"] == "artifact"
assert study_detail["feedback_component_type"]["artifact_key"] == "image"
def test_save_trial_user_attrs(self) -> None:
study = optuna.create_study()
trials: list[optuna.Trial] = []
for _ in range(2):
trial = study.ask()
trials.append(trial)
request_body = {
"user_attrs": {
"number": 0,
},
}
app = create_app(study._storage)
status, _, _ = send_request(
app,
f"/api/trials/{trials[0]._trial_id}/user-attrs",
"POST",
content_type="application/json",
body=json.dumps(request_body),
)
self.assertEqual(status, 204)
assert study.trials[0].user_attrs == request_body["user_attrs"]
assert study.trials[1].user_attrs == {}
def test_save_trial_user_attrs_empty(self) -> None:
study = optuna.create_study()
trial = study.ask()
app = create_app(study._storage)
status, _, _ = send_request(
app,
f"/api/trials/{trial._trial_id}/user-attrs",
"POST",
content_type="application/json",
body=json.dumps({}),
)
self.assertEqual(status, 400)
assert study.trials[0].user_attrs == {}
def _save_trial_note(self, request_body: dict[str, int | str]) -> tuple[int, optuna.Study]:
study = optuna.create_study()
trial = study.ask()
app = create_app(study._storage)
status, _, _ = send_request(
app,
f"/api/studies/{study._study_id}/{trial._trial_id}/note",
"PUT",
content_type="application/json",
body=json.dumps(request_body),
)
return status, study
def test_save_trial_note_overwrite(self) -> None:
study = optuna.create_study()
trial = study.ask()
app = create_app(study._storage)
def _get_request_body(note_version: int) -> dict[str, str | int]:
return {"body": f"Test note ver. {note_version}.", "version": note_version}
for ver in range(1, 3):
status, _, _ = send_request(
app,
f"/api/studies/{study._study_id}/{trial._trial_id}/note",
"PUT",
content_type="application/json",
body=json.dumps(_get_request_body(note_version=ver)),
)
assert status == 204
# Check if the version 1 is deleted.
expected_request_body = _get_request_body(note_version=2)
expected_system_attrs = {
note_ver_key(trial_id=0): expected_request_body["version"],
f"{note_str_key_prefix(trial_id=0)}{0}": expected_request_body["body"],
}
for k, v in expected_system_attrs.items():
assert k in study.system_attrs
assert study.system_attrs[k] == v
def test_save_trial_note(self) -> None:
request_body: dict[str, int | str] = {"body": "Test note.", "version": 1}
status, study = self._save_trial_note(request_body)
assert status == 204
expected_system_attrs = {
note_ver_key(0): request_body["version"],
f"{note_str_key_prefix(0)}{0}": request_body["body"],
}
for k, v in expected_system_attrs.items():
assert k in study.system_attrs
assert study.system_attrs[k] == v
def test_save_trial_note_with_wrong_version(self) -> None:
request_body: dict[str, int | str] = {"body": "Test note.", "version": 0}
status, study = self._save_trial_note(request_body)
assert status == 409
assert note_ver_key(0) not in study.system_attrs
def test_save_trial_note_empty(self) -> None:
status, study = self._save_trial_note(request_body={})
assert status == 400
assert note_ver_key(0) not in study.system_attrs
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_skip_trial(self) -> None:
storage = optuna.storages.InMemoryStorage()
@@ -4,6 +4,7 @@ from typing import Any
from unittest import TestCase
import warnings
import numpy as np
import optuna
from optuna import create_trial
from optuna.distributions import BaseDistribution
@@ -254,11 +255,29 @@ class _CachedExtraStudyPropertyUserAttrs(TestCase):
def test_infer_sortable(self) -> None:
user_attrs_list: list[dict[str, Any]] = [
{"a": 1, "b": 1, "c": 1, "d": "a", "e": 1, "f": True},
{
"a": 1,
"b": 1,
"c": 1,
"d": "a",
"e": 1,
"f": True,
"g": np.float128(1.1),
"h": np.int64(2),
},
{"a": 2, "b": "a", "c": "a", "d": "a"},
{"a": 3, "b": None, "c": 3, "d": "a", "e": 3},
]
expected = {"a": True, "b": False, "c": False, "d": False, "e": True, "f": False}
expected = {
"a": True,
"b": False,
"c": False,
"d": False,
"e": True,
"f": False,
"g": True,
"h": True,
}
trials = []
for user_attrs in user_attrs_list:
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from typing import Any
import optuna
from optuna.trial import TrialState
from optuna_dashboard._app import create_app
import pytest
from .wsgi_client import send_request
def _validate_output(
storage: optuna.storages.BaseStorage,
correct_status: int,
study_id: int,
expect_no_result: bool = False,
extra_col_names: list[str] | None = None,
) -> None:
app = create_app(storage)
status, _, body = send_request(
app,
f"/csv/{study_id}",
"GET",
content_type="application/json",
)
assert status == correct_status
decoded_csv = str(body.decode("utf-8"))
if expect_no_result:
assert "is not found" in decoded_csv
else:
col_names = ["Number", "State"] + ([] if extra_col_names is None else extra_col_names)
assert all(col_name in decoded_csv for col_name in col_names)
def test_download_csv_no_trial() -> None:
def objective(trial: optuna.Trial) -> float:
x = trial.suggest_float("x", -100, 100)
y = trial.suggest_categorical("y", [-1, 0, 1])
return x**2 + y
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
study.optimize(objective, n_trials=0)
_validate_output(storage, 200, 0)
def test_download_csv_all_waiting() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
study.add_trial(optuna.trial.create_trial(state=TrialState.WAITING))
_validate_output(storage, 200, 0)
def test_download_csv_all_running() -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
study.add_trial(optuna.trial.create_trial(state=TrialState.RUNNING))
_validate_output(storage, 200, 0)
@pytest.mark.parametrize("study_id", [0, 1])
def test_download_csv_fail(study_id: int) -> None:
def objective(trial: optuna.Trial) -> float:
x = trial.suggest_float("x", -100, 100)
y = trial.suggest_categorical("y", [-1, 0, 1])
return x**2 + y
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
optuna.logging.set_verbosity(optuna.logging.ERROR)
study.optimize(objective, n_trials=10)
expect_no_result = study_id != 0
cols = ["Param x", "Param y", "Value"]
_validate_output(storage, 404 if expect_no_result else 200, study_id, expect_no_result, cols)
@pytest.mark.parametrize("is_multi_obj", [True, False])
def test_download_csv_multi_obj(is_multi_obj: bool) -> None:
def objective(trial: optuna.Trial) -> Any:
x = trial.suggest_float("x", -100, 100)
y = trial.suggest_categorical("y", [-1, 0, 1])
if is_multi_obj:
return x**2, y
return x**2 + y
storage = optuna.storages.InMemoryStorage()
directions = ["minimize", "minimize"] if is_multi_obj else ["minimize"]
study = optuna.create_study(storage=storage, directions=directions)
optuna.logging.set_verbosity(optuna.logging.ERROR)
study.optimize(objective, n_trials=10)
cols = ["Param x", "Param y"]
cols += ["Objective 0", "Objective 1"] if is_multi_obj else ["Value"]
_validate_output(storage, 200, 0, extra_col_names=cols)
def test_download_csv_user_attr() -> None:
def objective(trial: optuna.Trial) -> float:
x = trial.suggest_float("x", -100, 100)
y = trial.suggest_categorical("y", [-1, 0, 1])
trial.set_user_attr("abs_y", abs(y))
return x**2 + y
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
optuna.logging.set_verbosity(optuna.logging.ERROR)
study.optimize(objective, n_trials=10)
cols = ["Param x", "Param y", "Value", "UserAttribute abs_y"]
_validate_output(storage, 200, 0, extra_col_names=cols)
+22
View File
@@ -53,3 +53,25 @@ class NoteTestCase(TestCase):
note_dict = note.get_note_from_system_attrs(system_attrs, trial._trial_id)
self.assertEqual(note_dict["body"], body)
self.assertEqual(note_dict["version"], expected_ver)
def test_copy_notes(self) -> None:
old_study = optuna.create_study()
old_trials = [
old_study.ask({"x1": optuna.distributions.FloatDistribution(0, 10)}) for _ in range(2)
]
storage = old_study._storage
notes = ["trial 0", "trial 1"]
for trial, body in zip(old_trials, notes):
save_note(trial, body)
save_note(old_study, "Study")
new_study = optuna.create_study(storage=storage, directions=old_study.directions)
new_study.add_trials(old_study.get_trials(deepcopy=False))
note.copy_notes(storage, old_study, new_study)
system_attrs = new_study._storage.get_study_system_attrs(new_study._study_id)
for new_trial, body in zip(new_study.get_trials(), notes):
actual = note.get_note_from_system_attrs(system_attrs, new_trial._trial_id)
self.assertEqual(actual["body"], body)
self.assertEqual(get_note(new_study), "Study")
+27
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import sys
import numpy as np
import optuna
from optuna_dashboard._serializer import serialize_attrs
from optuna_dashboard._serializer import serialize_study_detail
@@ -25,6 +26,32 @@ def test_serialize_dict() -> None:
assert len(serialized) <= 1
def test_serialize_numpy_integer() -> None:
serialized = serialize_attrs(
{
"int8": np.int8(1),
"int16": np.int16(1),
"int32": np.int32(1),
"int64": np.int64(1),
}
)
assert len(serialized) == 4
assert all([v["value"] == "1" for v in serialized])
def test_serialize_numpy_floating() -> None:
serialized = serialize_attrs(
{
"float16": np.float16(1.0),
"float32": np.float32(1.0),
"float64": np.float64(1.0),
"float128": np.float128(1.0),
}
)
assert len(serialized) == 4
assert all([v["value"] == "1.0" for v in serialized])
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_get_study_detail_is_preferential() -> None:
storage = optuna.storages.InMemoryStorage()
+1 -1
View File
@@ -358,7 +358,7 @@ function stableSort<T>(
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
if (less) {
const ascending = order == "asc"
const ascending = order === "asc"
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending)
@@ -78,7 +78,7 @@ const plotIntermediateValue = (
t.state === "Pruned" &&
t.values &&
t.values.length > 0) ||
t.state == "Running"
t.state === "Running"
)
const plotData: Partial<plotly.PlotData>[] = filteredTrials.map((trial) => {
const values = trial.intermediate_values.filter(
+1 -1
View File
@@ -20,7 +20,7 @@ export const TrialTable: FC<{
},
]
if (study === null || study.directions.length == 1) {
if (study === null || study.directions.length === 1) {
columns.push({
field: "values",
label: "Value",
+4 -4
View File
@@ -64,7 +64,7 @@ const getSchemaVersion = (db: SQLite3DB): string => {
const isSupportedSchema = (schemaVersion: string): boolean => {
const lowestVersion = "v2.6.0.a" // supported: "v3.2.0.a", "v3.0.0.{a,b,c,d}", "v2.6.0.a"
if (schemaVersion == lowestVersion) return true
if (schemaVersion === lowestVersion) return true
return isGreaterSchemaVersion(schemaVersion, lowestVersion)
}
@@ -80,7 +80,7 @@ const isGreaterSchemaVersion = (
const left = Number(leftVersion)
const right = Number(rightVersion)
if (left == right) return leftSuffix > rightSuffix
if (left === right) return leftSuffix > rightSuffix
return left > right
}
@@ -106,7 +106,7 @@ const getStudies = (db: SQLite3DB, schemaVersion: string): Study[] => {
trials.forEach((trial) => {
const userAttrs = getTrialUserAttributes(db, trial.trial_id)
userAttrs.forEach((attr) => {
if (union_user_attrs.findIndex((s) => s.key === attr.key) == -1) {
if (union_user_attrs.findIndex((s) => s.key === attr.key) === -1) {
union_user_attrs.push({ key: attr.key, sortable: false })
}
})
@@ -116,7 +116,7 @@ const getStudies = (db: SQLite3DB, schemaVersion: string): Study[] => {
params.forEach((param) => {
param_names.add(param.name)
if (
union_search_space.findIndex((s) => s.name === param.name) == -1
union_search_space.findIndex((s) => s.name === param.name) === -1
) {
union_search_space.push({ name: param.name })
}
+3 -44
View File
@@ -1,7 +1,7 @@
import React from "react"
global.URL.createObjectURL = jest.fn()
import { cleanup, render, fireEvent } from "@testing-library/react"
import { cleanup, render } from "@testing-library/react"
import {
DataGrid,
DataGridColumn,
@@ -9,6 +9,7 @@ import {
afterEach(cleanup)
// TODO(c-bata): Add tests to check filterChoices option
it("Filter rows of DataGrid", () => {
interface DummyAttribute {
id: number
@@ -23,7 +24,7 @@ it("Filter rows of DataGrid", () => {
{ id: 5, key: "foo", value: 3 },
]
const columns: DataGridColumn<DummyAttribute>[] = [
{ field: "key", label: "Key", filterable: true },
{ field: "key", label: "Key" },
{
field: "value",
label: "Value",
@@ -39,46 +40,4 @@ it("Filter rows of DataGrid", () => {
/>
)
expect(queryAllByText("bar").length).toBe(2)
// Filter rows by "foo"
fireEvent.click(queryAllByText("foo")[0])
expect(queryAllByText("foo").length).toBe(3)
expect(queryAllByText("bar").length).toBe(0)
})
it("Filter rows after sorted", () => {
interface DummyAttribute {
id: number
key: string
value: number
}
const dummyAttributes = [
{ id: 1, key: "foo", value: 4000 },
{ id: 2, key: "bar", value: 1000 },
{ id: 3, key: "bar", value: 2000 },
{ id: 4, key: "foo", value: 3000 },
{ id: 5, key: "foo", value: 5000 },
]
const columns: DataGridColumn<DummyAttribute>[] = [
{ field: "key", label: "Key", filterable: true },
{
field: "value",
label: "Value",
sortable: true,
},
]
const { getByText, queryAllByText } = render(
<DataGrid<DummyAttribute>
columns={columns}
rows={dummyAttributes}
keyField={"id"}
/>
)
// Sort and filter rows
fireEvent.click(getByText("Value"))
fireEvent.click(queryAllByText("bar")[0])
expect(queryAllByText("1000").length).toBe(1)
expect(queryAllByText("2000").length).toBe(1)
})