Merge branch 'main' into test-orthants-mvn-gibbs-sampling

This commit is contained in:
Naoto Mizuno
2023-12-18 16:12:52 +09:00
committed by GitHub
82 changed files with 3585 additions and 1784 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,10 +1,10 @@
name: e2e-tests
name: e2e-dashboard-tests
on:
pull_request:
branches:
- main
paths:
- '.github/workflows/e2e-tests.yml'
- '.github/workflows/e2e-dashboard-tests.yml'
- '**.py'
- '**.ts'
- '**.tsx'
@@ -47,4 +47,10 @@ jobs:
run: playwright install
- name: Run e2e tests
run: pytest e2e_tests
run: |
if [ "${{ matrix.optuna-version }}" = "optuna==2.10.0" ]; then
ignore_option="--ignore e2e_tests/test_dashboard/test_usecases/test_preferential_optimization.py"
else
ignore_option=""
fi
pytest e2e_tests/test_dashboard $ignore_option
@@ -0,0 +1,51 @@
name: e2e-standalone-tests
on:
pull_request:
branches:
- main
paths:
- '.github/workflows/e2e-standalone-tests.yml'
- '**.py'
- '**.ts'
- '**.tsx'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
jobs:
test:
runs-on: ubuntu-20.04
strategy:
matrix:
optuna-version: ['optuna==2.10.0', 'git+https://github.com/optuna/optuna.git']
steps:
- uses: actions/checkout@v3
- name: Install Rust toolchains
uses: dtolnay/rust-toolchain@stable
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Setup Optuna ${{ matrix.optuna-version }}
run: |
python -m pip install --progress-bar off --upgrade pip setuptools
python -m pip install --progress-bar off --upgrade ${{ matrix.optuna-version }}
- name: Install dependencies
run: |
python -m pip install --progress-bar off .
python -m pip install --progress-bar off pytest-playwright
- name: Build standalone_app
run: make MODE="prd" standalone_app/public/bundle.js
- name: Install the required browsers
run: playwright install
- name: Run e2e tests
run: pytest e2e_tests/test_standalone
+2 -1
View File
@@ -6,6 +6,7 @@ on:
paths:
- '.github/workflows/python-tests.yml'
- '**.py'
- 'pyproject.toml'
jobs:
lint:
runs-on: ubuntu-latest
@@ -57,7 +58,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
python-version: '3.11'
architecture: x64
- name: Install dependencies
run: |
-1
View File
@@ -39,5 +39,4 @@ coverage.xml
.vscode/
.DS_Store
tmp/
examples/preferential-optimization/artifact/
+4
View File
@@ -83,6 +83,7 @@ $ pytest python_tests/
```
$ pip install -r requirements.txt
$ playwright install
$ pytest e2e_tests
```
@@ -92,6 +93,9 @@ If you want to create a screenshot for each test, please run a following command
$ pytest e2e_tests --screenshot on --output tmp
```
If you want to generate a locator in each webpage, please use the playwright codegen. See [this page](https://playwright.dev/python/docs/codegen-intro) for more details.
For more detail options, you can check [this page](https://playwright.dev/python/docs/test-runners).
#### Linters (flake8, black and mypy)
+14 -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).
@@ -77,6 +78,18 @@ $ docker run -it --rm -p 8080:8080 ghcr.io/optuna/optuna-dashboard postgresql+ps
</details>
## Jupyter Lab Extension (Experimental)
You can install the Jupyter Lab extension via [PyPI](https://pypi.org/project/jupyterlab-optuna/).
```
$ pip install jupyterlab jupyterlab-optuna
```
<img src="./docs/_static/jupyterlab-extension.png" style="width:600px;" alt="Jupyter Lab Extension">
To use, click the tile to launch the extension, and enter your Optunas storage URL (e.g. `sqlite:///db.sqlite3`) in the dialog.
## Browser-only version (Experimental)
<img src="./docs/_static/browser-app.gif" style="width:600px;" alt="Browser-only version">
@@ -93,7 +106,7 @@ https://optuna.github.io/optuna-dashboard/
You can install the VS Code extension via [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=Optuna.optuna-dashboard#overview).
<img src="./docs/_static/vscode-extension.png" style="width:600px;" alt="VSCode Extension">
<img src="./docs/_static/vscode-extension.png" style="width:600px;" alt="VS Code Extension">
Please right-click the SQLite3 files (`*.db` or `*.sqlite3`) in the VS Code file explorer and select the "Open in Optuna Dashboard" command from the dropdown menu.
This extension leverages the browser-only version of Optuna Dashboard, so the same limitations apply.
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

+1
View File
@@ -44,6 +44,7 @@ Preferential Optimization
optuna_dashboard.preferential.create_study
optuna_dashboard.preferential.load_study
optuna_dashboard.preferential.PreferentialStudy
optuna_dashboard.preferential.samplers.gp.PreferentialGPSampler
optuna_dashboard.register_preference_feedback_component
Streamlit
+13
View File
@@ -178,6 +178,19 @@ or
$ pip install uwsgi
$ uwsgi --http :8080 --workeers 4 --wsgi-file wsgi.py
Jupyter Lab Extension (Experimental)
--------------------------------
You can install the Jupyter Lab extension via `PyPI <https://pypi.org/project/jupyterlab-optuna/>`_.
.. figure:: _static/jupyterlab-extension.png
:alt: Screenshot for the Jupyter Lab Extension
:align: center
:width: 800px
Jupyter Lab Extension
To use, click the tile to launch the extension, and enter your Optunas storage URL (e.g. ``sqlite:///db.sqlite3``) in the dialog.
Browser-only version (Experimental)
-----------------------------------
+14 -14
View File
@@ -95,7 +95,7 @@ Given the above system, we carry out HITL optimization as follows:
Environment setup
^^^^^^^^^^^^^^^^^
To run `the script <https://github.com/optuna/optuna-dashboard/blob/main/examples/hitl/main.py>`_ used in this tutorial, you need to install following libraries:
To run `the script <https://github.com/optuna/optuna-examples/blob/main/dashboard/hitl/main.py>`_ used in this tutorial, you need to install following libraries:
.. code-block:: console
@@ -109,7 +109,7 @@ You will use SQLite for the storage backend in this tutorial. Ensure that the fo
Execution of the HITL optimization script
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Run a python script below which you copied from `main.py <https://github.com/optuna/optuna-dashboard/blob/main/examples/hitl/main.py>`_
Run a python script below which you copied from `main.py <https://github.com/optuna/optuna-examples/blob/main/dashboard/hitl/main.py>`_
.. code-block:: console
@@ -150,7 +150,7 @@ Click the third item in the sidebar. You will see a list of all trials.
.. image:: ./images/hitl10.png
For each trial, you can see its details such as RGB parameter values and importantly, the generated image based on these values.
For each trial, you can see its details such as RGB parameter values and importantly, the generated image based on these values.
.. image:: ./images/hitl11.gif
:width: 90%
@@ -189,21 +189,21 @@ Lets walk through the script we used for the optimization.
r = trial.suggest_int("r", 0, 255)
g = trial.suggest_int("g", 0, 255)
b = trial.suggest_int("b", 0, 255)
# 2. Generate image
image_path = f"tmp/sample-{trial.number}.png"
image = Image.new("RGB", (320, 240), color=(r, g, b))
image.save(image_path)
# 3. Upload Artifact
artifact_id = upload_artifact(trial, image_path, artifact_store)
artifact_path = get_artifact_path(trial, artifact_id)
# 4. Save Note
note = textwrap.dedent(
f"""\
## Trial {trial.number}
![generated-image]({artifact_path})
"""
)
@@ -222,10 +222,10 @@ In the ``suggest_and_generate_image`` function, a new Trial is obtained and new
sampler=optuna.samplers.TPESampler(constant_liar=True, n_startup_trials=5),
load_if_exists=True,
)
# 2. Set an objective name
study.set_metric_names(["Looks like sunset color?"])
# 3. Register ChoiceWidget
register_objective_form_widgets(
study,
@@ -237,7 +237,7 @@ In the ``suggest_and_generate_image`` function, a new Trial is obtained and new
),
],
)
# 4. Start Human-in-the-loop Optimization
n_batch = 4
while True:
@@ -259,17 +259,17 @@ The function ``start_optimization`` defines our loop for HITL optimization to ge
def main() -> NoReturn:
tmp_path = os.path.join(os.path.dirname(__file__), "tmp")
# 1. Create Artifact Store
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
artifact_store = FileSystemArtifactStore(artifact_path)
if not os.path.exists(artifact_path):
os.mkdir(artifact_path)
if not os.path.exists(tmp_path):
os.mkdir(tmp_path)
# 2. Run optimize loop
start_optimization(artifact_store)
+5 -5
View File
@@ -5,11 +5,11 @@ What is Preferential Optimization?
----------------------------------
Preferential optimization is a method for optimizing hyperparameters, focusing of human preferences, by determining which trial is superior when comparing a pair.
It differs from `human-in-the-loop optimization utilizing objective form widgets <tutorial-hitl-objective-form-widgets>`_,
It differs from :ref:`human-in-the-loop optimization utilizing objective form widgets <tutorial-hitl-objective-form-widgets>`,
which relies on absolute evaluations, as it significantly reduces fluctuations in evaluators' criteria, thus ensuring more consistent results.
In this tutorial, we'll interactively optimize RGB values to generate a color resembling a "sunset hue",
aligining with the problem setting in `this tutorial <tutorial-hitl-objective-form-widgets>`_.
aligining with the problem setting in :ref:`this tutorial <tutorial-hitl-objective-form-widgets>`.
Familiarity with the tutorial ob objective form widgets may enhance your understanding.
How to Run Preferential Optimization
@@ -27,7 +27,7 @@ First, ensure the necessary packages are installed by executing the following co
.. code-block:: console
$ pip install "optuna>=3.3.0" "optuna-dashboard>=0.13.0b1" pillow botorch
$ pip install "optuna>=3.3.0" "optuna-dashboard[preferential]>=0.13.0b1" pillow
Next, execute the Python script, copied from `generator.py`_.
@@ -99,7 +99,7 @@ enabling the Optuna Dashboard to display images on the evaluation feedback page.
register_preference_feedback_component(study, "artifact", user_attr_key)
Following this, we create a loop that continuously checks if new trials should be generated, awaiting human evaluation if not.
Within the while loop, new trials are generated if the condition :meth:`~optuna_dashboard.preferential.PreferentialStudy.should_generate` returns ``True``.
Within the while loop, new trials are generated if the condition :meth:`~optuna_dashboard.preferential.PreferentialStudy.should_generate` returns ``True``.
For each trial, RGB values are sampled, an image is generated with these values, saved temporarily.
Then the image is uploaded to the artifact store, and finally, the ``artifact_id`` is stored to the key, which is specified via :func:`~optuna_dashboard.register_preference_feedback_component`.
@@ -127,4 +127,4 @@ Then the image is uploaded to the artifact store, and finally, the ``artifact_id
artifact_id = upload_artifact(trial, image_path, artifact_store)
trial.set_user_attr(user_attr_key, artifact_id)
.. _generator.py: https://github.com/optuna/optuna-dashboard/blob/main/examples/preferential-optimization/generator.py
.. _generator.py: https://github.com/optuna/optuna-examples/blob/main/dashboard/preferential-optimization/generator.py
@@ -0,0 +1,129 @@
import re
import optuna
from optuna.trial import TrialState
from optuna_dashboard import ChoiceWidget
from optuna_dashboard import register_objective_form_widgets
from playwright.sync_api import expect
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:
storage = optuna.storages.InMemoryStorage()
sampler = optuna.samplers.RandomSampler(seed=0)
study = optuna.create_study(
study_name="preferential_optimization",
storage=storage,
sampler=sampler,
)
register_objective_form_widgets(
study,
widgets=[
ChoiceWidget(
choices=["Good", "So-so", "Bad"],
values=[-1, 0, 1],
),
],
)
n_batch = 4
while True:
running_trials = study.get_trials(deepcopy=False, states=(TrialState.RUNNING,))
if len(running_trials) >= n_batch:
break
study.ask()
return storage
@pytest.fixture
def storage() -> optuna.storages.InMemoryStorage:
clear_inmemory_cache()
storage = make_test_storage()
return storage
@pytest.fixture
def server_url(request: pytest.FixtureRequest, storage: optuna.storages.InMemoryStorage) -> str:
return make_test_server(request, storage)
def test_preferential_optimization(
page: Page,
storage: optuna.storages.InMemoryStorage,
server_url: str,
) -> None:
summaries = optuna.get_all_study_summaries(storage)
study_id = summaries[0]._study_id
url = f"{server_url}/studies/{study_id}/trials"
page.goto(url)
# Confirm that the trial list page is displayed.
expect(page.get_by_role("heading").filter(has_text=re.compile("Trial"))).to_contain_text(
"Trial 0 (trial_id=0)"
)
page.get_by_label("Filter").click()
# Confirm that all trials are running.
expect(
page.get_by_text("Complete (0)Pruned (0)Fail (0)Running (4)Waiting (0)")
).to_be_visible()
page.locator(".MuiBackdrop-root").click()
# Confirm that the trial detail page is displayed.
expect(page.get_by_role("heading").filter(has_text=re.compile("Trial"))).to_contain_text(
"Trial 0 (trial_id=0)"
)
# This trial is running.
expect(page.get_by_text("Running", exact=True).nth(4)).to_be_visible()
page.get_by_label("Bad").check()
page.get_by_role("button", name="Submit").click()
# This trial is completed and is the best trial.
expect(page.get_by_text("Complete").nth(1)).to_be_visible()
expect(page.get_by_text("Best Trial").nth(1)).to_be_visible()
# Move the next trial page.
page.get_by_role("button", name="Trial 1 Running").click()
# Confirm that the trial detail page is displayed.
expect(page.get_by_role("heading").filter(has_text=re.compile("Trial"))).to_contain_text(
"Trial 1 (trial_id=1)"
)
# This trial is running.
expect(page.get_by_text("Running", exact=True).nth(3)).to_be_visible()
page.get_by_label("So-so").check()
page.get_by_role("button", name="Submit").click()
# This trial is completed and is the best trial.
expect(page.get_by_text("Complete").nth(2)).to_be_visible()
expect(page.get_by_text("Best Trial").nth(1)).to_be_visible()
# Move the next trial page.
page.get_by_role("button", name="Trial 2 Running").click()
# Confirm that the trial detail page is displayed.
expect(page.get_by_role("heading").filter(has_text=re.compile("Trial"))).to_contain_text(
"Trial 2 (trial_id=2)"
)
# This trial is running.
expect(page.get_by_text("Running", exact=True).nth(2)).to_be_visible()
page.get_by_label("Good").check()
page.get_by_role("button", name="Submit").click()
# This trial is completed and is the best trial.
expect(page.get_by_text("Complete").nth(3)).to_be_visible()
expect(page.get_by_text("Best Trial").nth(1)).to_be_visible()
# Move the next trial page.
page.get_by_role("button", name="Trial 3 Running").click()
# Confirm that the trial detail page is displayed.
expect(page.get_by_role("heading").filter(has_text=re.compile("Trial"))).to_contain_text(
"Trial 3 (trial_id=3)"
)
# This trial is running.
expect(page.get_by_text("Running", exact=True).nth(1)).to_be_visible()
page.get_by_role("button", name="Fail Trial").click()
# This trial is failed.
expect(page.get_by_text("Fail").nth(1)).to_be_visible()
@@ -2,7 +2,8 @@ import optuna
from playwright.sync_api import Page
import pytest
from ..test_server import make_test_server
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
@@ -4,11 +4,13 @@ import optuna
from playwright.sync_api import Page
import pytest
from .test_server import make_test_server
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
+28
View File
@@ -1,4 +1,6 @@
import http.server
import socket
import socketserver
import threading
from wsgiref.simple_server import make_server
@@ -33,3 +35,29 @@ def make_test_server(
request.addfinalizer(stop_server)
return f"http://{addr}:{port}/dashboard"
def make_standalone_server(request: pytest.FixtureRequest) -> str:
addr = "127.0.0.1"
port = get_free_port()
directory = "./standalone_app/"
Handler = http.server.SimpleHTTPRequestHandler
httpd = socketserver.TCPServer(
("", port), lambda *args, **kwargs: Handler(*args, directory=directory, **kwargs)
)
def serve_httpd():
httpd.serve_forever()
thread = threading.Thread(target=serve_httpd)
thread.start()
def stop_server() -> None:
httpd.shutdown()
httpd.server_close()
thread.join()
request.addfinalizer(stop_server)
return f"http://{addr}:{port}"
@@ -0,0 +1,22 @@
from playwright.sync_api import Page
import pytest
from ..test_server import make_standalone_server
@pytest.fixture
def server_url(request: pytest.FixtureRequest) -> str:
return make_standalone_server(request)
def test_home(
page: Page,
server_url: str,
) -> None:
url = f"{server_url}"
page.goto(url)
element = page.get_by_role("heading")
assert element is not None
title = element.text_content()
assert title is not None
assert title == "Optuna Dashboard (Wasm ver.)"
+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()
+5
View File
@@ -0,0 +1,5 @@
Optuna Dashboard Examples
=========================
Example files have been moved to the [optuna/optuna-examples](https://github.com/optuna/optuna-examples/) repoistory.
You can find the dashboard-related examples in the [dashboard](https://github.com/optuna/optuna-examples/tree/main/dashboard) directory.
-98
View File
@@ -1,98 +0,0 @@
import os
import textwrap
import time
from typing import NoReturn
import optuna
from optuna.artifacts import FileSystemArtifactStore
from optuna.artifacts import upload_artifact
from optuna.trial import TrialState
from optuna_dashboard import ChoiceWidget
from optuna_dashboard import register_objective_form_widgets
from optuna_dashboard import save_note
from optuna_dashboard.artifact import get_artifact_path
from PIL import Image
def suggest_and_generate_image(
study: optuna.Study, artifact_store: FileSystemArtifactStore
) -> None:
# 1. Ask new parameters
trial = study.ask()
r = trial.suggest_int("r", 0, 255)
g = trial.suggest_int("g", 0, 255)
b = trial.suggest_int("b", 0, 255)
# 2. Generate image
image_path = f"tmp/sample-{trial.number}.png"
image = Image.new("RGB", (320, 240), color=(r, g, b))
image.save(image_path)
# 3. Upload Artifact
artifact_id = upload_artifact(trial, image_path, artifact_store)
artifact_path = get_artifact_path(trial, artifact_id)
# 4. Save Note
note = textwrap.dedent(
f"""\
## Trial {trial.number}
![generated-image]({artifact_path})
"""
)
save_note(trial, note)
def start_optimization(artifact_store: FileSystemArtifactStore) -> NoReturn:
# 1. Create Study
study = optuna.create_study(
study_name="Human-in-the-loop Optimization",
storage="sqlite:///db.sqlite3",
sampler=optuna.samplers.TPESampler(constant_liar=True, n_startup_trials=5),
load_if_exists=True,
)
# 2. Set an objective name
study.set_metric_names(["Looks like sunset color?"])
# 3. Register ChoiceWidget
register_objective_form_widgets(
study,
widgets=[
ChoiceWidget(
choices=["Good 👍", "So-so👌", "Bad 👎"],
values=[-1, 0, 1],
description="Please input your score!",
),
],
)
# 4. Start Human-in-the-loop Optimization
n_batch = 4
while True:
running_trials = study.get_trials(deepcopy=False, states=(TrialState.RUNNING,))
if len(running_trials) >= n_batch:
time.sleep(1) # Avoid busy-loop
continue
suggest_and_generate_image(study, artifact_store)
def main() -> NoReturn:
tmp_path = os.path.join(os.path.dirname(__file__), "tmp")
# 1. Create Artifact Store
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
artifact_store = FileSystemArtifactStore(artifact_path)
if not os.path.exists(artifact_path):
os.mkdir(artifact_path)
if not os.path.exists(tmp_path):
os.mkdir(tmp_path)
# 2. Run optimize loop
start_optimization(artifact_store)
if __name__ == "__main__":
main()
@@ -1,2 +0,0 @@
#!/usr/bin/env sh
optuna-dashboard sqlite:///example.db --artifact-dir ./artifact
@@ -1,60 +0,0 @@
from __future__ import annotations
import os
import tempfile
import time
from typing import NoReturn
from optuna.artifacts import FileSystemArtifactStore
from optuna.artifacts import upload_artifact
from optuna_dashboard import register_preference_feedback_component
from optuna_dashboard.preferential import create_study
from optuna_dashboard.preferential.samplers.gp import PreferentialGPSampler
from PIL import Image
STORAGE_URL = "sqlite:///example.db"
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
artifact_store = FileSystemArtifactStore(base_path=artifact_path)
os.makedirs(artifact_path, exist_ok=True)
def main() -> NoReturn:
study = create_study(
n_generate=4,
study_name="Preferential Optimization",
storage=STORAGE_URL,
sampler=PreferentialGPSampler(),
load_if_exists=True,
)
# Change the component, displayed on the human feedback pages.
# By default (component_type="note"), the Trial's Markdown note is displayed.
user_attr_key = "rgb_image"
register_preference_feedback_component(study, "artifact", user_attr_key)
with tempfile.TemporaryDirectory() as tmpdir:
while True:
# If study.should_generate() returns False,
# the generator waits for human evaluation.
if not study.should_generate():
time.sleep(0.1) # Avoid busy-loop
continue
trial = study.ask()
# 1. Ask new parameters
r = trial.suggest_int("r", 0, 255)
g = trial.suggest_int("g", 0, 255)
b = trial.suggest_int("b", 0, 255)
# 2. Generate image
image_path = os.path.join(tmpdir, f"sample-{trial.number}.png")
image = Image.new("RGB", (320, 240), color=(r, g, b))
image.save(image_path)
# 3. Upload Artifact and set artifact_id to trial.user_attrs["rgb_image"].
artifact_id = upload_artifact(trial, image_path, artifact_store)
trial.set_user_attr(user_attr_key, artifact_id)
if __name__ == "__main__":
main()
@@ -1,54 +0,0 @@
from __future__ import annotations
import os
import shutil
import tempfile
import uuid
import optuna
from optuna.trial import TrialState
from optuna_dashboard.artifact.file_system import FileSystemBackend
from optuna_dashboard.streamlit import render_objective_form_widgets
from optuna_dashboard.streamlit import render_trial_note
import streamlit as st
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
artifact_backend = FileSystemBackend(base_path=artifact_path)
def get_tmp_dir() -> str:
if "tmp_dir" not in st.session_state:
tmp_dir_name = str(uuid.uuid4())
tmp_dir_path = os.path.join(tempfile.gettempdir(), tmp_dir_name)
os.makedirs(tmp_dir_path, exist_ok=True)
st.session_state.tmp_dir = tmp_dir_path
return st.session_state.tmp_dir
def start_streamlit() -> None:
tmpdir = get_tmp_dir()
study = optuna.load_study(
storage="sqlite:///streamlit-db.sqlite3", study_name="Human-in-the-loop Optimization"
)
selected_trial = st.sidebar.selectbox("Trial", study.trials, format_func=lambda t: t.number)
if selected_trial is None:
return
render_trial_note(study, selected_trial)
artifact_id = selected_trial.user_attrs.get("artifact_id")
if artifact_id:
with artifact_backend.open(artifact_id) as fsrc:
tmp_img_path = os.path.join(tmpdir, artifact_id + ".png")
with open(tmp_img_path, "wb") as fdst:
shutil.copyfileobj(fsrc, fdst)
st.image(tmp_img_path, caption="Image")
if selected_trial.state == TrialState.RUNNING:
render_objective_form_widgets(study, selected_trial)
if __name__ == "__main__":
start_streamlit()
@@ -1,81 +0,0 @@
from __future__ import annotations
import os
import tempfile
import time
from typing import NoReturn
import optuna
from optuna.trial import TrialState
from optuna_dashboard import ChoiceWidget
from optuna_dashboard import register_objective_form_widgets
from optuna_dashboard import save_note
from optuna_dashboard.artifact import upload_artifact
from optuna_dashboard.artifact.file_system import FileSystemBackend
from PIL import Image
def suggest_and_generate_image(
study: optuna.Study, artifact_backend: FileSystemBackend, tmpdir: str
) -> None:
# 1. Ask new parameters
trial = study.ask()
r = trial.suggest_int("r", 0, 255)
g = trial.suggest_int("g", 0, 255)
b = trial.suggest_int("b", 0, 255)
# 2. Generate image
image_path = os.path.join(tmpdir, f"sample-{trial.number}.png")
image = Image.new("RGB", (320, 240), color=(r, g, b))
image.save(image_path)
# 3. Upload Artifact
artifact_id = upload_artifact(artifact_backend, trial, image_path)
trial.set_user_attr("artifact_id", artifact_id)
# 4. Save Note
save_note(trial, f"## Trial {trial.number}")
def main() -> NoReturn:
# 1. Create Artifact Store
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
artifact_backend = FileSystemBackend(base_path=artifact_path)
if not os.path.exists(artifact_path):
os.mkdir(artifact_path)
# 2. Create Study
study = optuna.create_study(
study_name="Human-in-the-loop Optimization",
storage="sqlite:///streamlit-db.sqlite3",
sampler=optuna.samplers.TPESampler(constant_liar=True, n_startup_trials=5),
load_if_exists=True,
)
study.set_metric_names(["Looks like sunset color?"])
# 4. Register ChoiceWidget
register_objective_form_widgets(
study,
widgets=[
ChoiceWidget(
choices=["Good 👍", "So-so👌", "Bad 👎"],
values=[-1, 0, 1],
description="Please input your score!",
),
],
)
# 5. Start Human-in-the-loop Optimization
n_batch = 4
with tempfile.TemporaryDirectory() as tmpdir:
while True:
running_trials = study.get_trials(deepcopy=False, states=(TrialState.RUNNING,))
if len(running_trials) >= n_batch:
time.sleep(1) # Avoid busy-loop
continue
suggest_and_generate_image(study, artifact_backend, tmpdir)
if __name__ == "__main__":
main()
+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.0b1"
__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
+23 -15
View File
@@ -7,9 +7,9 @@ from typing import Iterable
import optuna
from optuna import logging
from optuna._imports import try_import
from optuna.distributions import BaseDistribution
from optuna.samplers import BaseSampler
from optuna.samplers import RandomSampler
from optuna.trial import FrozenTrial
from optuna.trial import TrialState
from optuna_dashboard.preferential._system_attrs import get_n_generate
@@ -20,6 +20,10 @@ from optuna_dashboard.preferential._system_attrs import report_preferences
from optuna_dashboard.preferential._system_attrs import set_n_generate
with try_import() as _imports:
from optuna_dashboard.preferential.samplers.gp import PreferentialGPSampler
_logger = logging.get_logger(__name__)
_SYSTEM_ATTR_PREFERENTIAL_STUDY = "preference:is_preferential"
@@ -344,11 +348,10 @@ def create_study(
sampler:
A sampler object that implements background algorithm for value suggestion.
If :obj:`None` is specified, `RandomSampler`_ is used. Please note that
most Optuna samplers does not work efficiently for preferential optimization.
.. _RandomSampler: https://optuna.readthedocs.io/en/stable/reference/\
samplers/generated/optuna.samplers.RandomSampler.html
If :obj:`None` is specified,
:class:`~optuna_dashboard.preferential.samplers.gp.PreferentialGPSampler` is used.
Please note that most Optuna samplers does not work efficiently for preferential
optimization.
study_name:
Study's name. If this argument is set to None, a unique name is generated
@@ -369,9 +372,13 @@ def create_study(
The interface may change in newer versions without prior notice.
"""
try:
if sampler is None:
_imports.check() # If BoTorch is not installed, raise ImportError.
sampler = PreferentialGPSampler()
study = optuna.create_study(
storage=storage,
sampler=sampler or RandomSampler(),
sampler=sampler,
study_name=study_name,
)
study._storage.set_study_system_attr(
@@ -441,11 +448,10 @@ def load_study(
:func:`~optuna.study.create_study` for further details.
sampler:
A sampler object that implements background algorithm for value suggestion.
If :obj:`None` is specified, `RandomSampler`_ is used. Please note that
most Optuna samplers does not work efficiently for preferential optimization.
.. _RandomSampler: https://optuna.readthedocs.io/en/stable/reference/samplers/\
generated/optuna.samplers.RandomSampler.html
If :obj:`None` is specified,
:class:`~optuna_dashboard.preferential.samplers.gp.PreferentialGPSampler` is used.
Please note that most Optuna samplers does not work efficiently for preferential
optimization.
Returns:
A :class:`~optuna_dashboard.preferential.PreferentialStudy` object.
@@ -454,9 +460,11 @@ def load_study(
Preferential optimization is an experimental feature (introduced in v0.13.0).
The interface may change in newer versions without prior notice.
"""
study = optuna.load_study(
study_name=study_name, storage=storage, sampler=sampler or RandomSampler()
)
if sampler is None:
_imports.check() # If BoTorch is not installed, raise ImportError.
sampler = PreferentialGPSampler()
study = optuna.load_study(study_name=study_name, storage=storage, sampler=sampler)
system_attrs = study._storage.get_study_system_attrs(study._study_id)
if not system_attrs.get(_SYSTEM_ATTR_PREFERENTIAL_STUDY):
raise ValueError("The study is not a PreferentialStudy.")
+31 -11
View File
@@ -50,15 +50,15 @@ def _orthants_MVN_Gibbs_sampling(cov_inv: Tensor, cycles: int, initial_sample: T
def _one_side_trunc_norm_sampling(lower: Tensor) -> Tensor:
if lower > 4.0:
r = torch.clamp_min(torch.rand(torch.Size(()), dtype=torch.float64), min=1e-300)
return (lower * lower - 2 * r.log()).sqrt()
else:
SQRT2 = math.sqrt(2)
r = torch.rand(torch.Size(()), dtype=torch.float64) * torch.erfc(lower / SQRT2)
while 1 - r == 1:
r = torch.rand(torch.Size(()), dtype=torch.float64) * torch.erfc(lower / SQRT2)
return torch.erfinv(1 - r) * SQRT2
r = torch.rand(torch.Size(()), dtype=torch.float64)
ret = -torch.special.ndtri(torch.exp(torch.special.log_ndtr(-lower) + r.log()))
# If sampled random number is very small, `ret` becomes inf.
while torch.isinf(ret):
r = torch.rand(torch.Size(()), dtype=torch.float64)
ret = -torch.special.ndtri(torch.exp(torch.special.log_ndtr(-lower) + r.log()))
return ret
_orthants_MVN_Gibbs_sampling_jit = torch.jit.script(_orthants_MVN_Gibbs_sampling)
@@ -284,6 +284,26 @@ class _PreferentialGP:
class PreferentialGPSampler(optuna.samplers.BaseSampler):
"""Sampler for preferential optimization using Gaussian process.
The sampling algorithm is based on `Takeno et al., 2023 <https://arxiv.org/abs/2302.01513>`_.
This sampler uses BoTorch to optimize acquisition function.
Args:
kernel:
Kernel that computes the covariance on the Gaussian process. Defaults to
Matern 3/2 Kernel + ARD.
noise_prior:
Prior of the observation noise. Defaults to gamma prior.
independent_sampler:
A :class:`~optuna.samplers.BaseSampler` instance that is used for independent
sampling. The parameters not contained in the relative search space are sampled
by this sampler. If :obj:`None` is specified,
:class:`~optuna.samplers.RandomSampler` is used as the default.
seed:
Seed for random number generator.
"""
def __init__(
self,
*,
@@ -297,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()
@@ -335,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,8 +3,9 @@ import {
ThreejsArtifactViewer,
isThreejsArtifact,
} from "./ThreejsArtifactViewer"
import { WaveSurferArtifactViewer } from "./WaveSurferArtifactViewer"
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"
import { CardMedia } from "@mui/material"
import { CardMedia, Box } from "@mui/material"
export const ArtifactCardMedia: FC<{
artifact: Artifact
@@ -35,9 +36,21 @@ export const ArtifactCardMedia: FC<{
)
} else if (artifact.mimetype.startsWith("audio")) {
return (
<audio controls>
<source src={urlPath} type={artifact.mimetype} />
</audio>
<Box
style={{
width: "100%",
height: height,
display: "flex",
alignItems: "center",
}}
>
<WaveSurferArtifactViewer
height={100}
waveColor="rgb(200, 0, 200)"
progressColor="rgb(100, 0, 100)"
url={urlPath}
/>
</Box>
)
} else if (artifact.mimetype.startsWith("image")) {
return (
+150 -121
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"
@@ -28,15 +32,15 @@ interface DataGridColumn<T> {
field: keyof T
label: string
sortable?: boolean
less?: (a: T, b: T) => number
filterable?: boolean
less?: (a: T, b: T, ascending: boolean) => number
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,28 +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 clearFilter = (columnIdx: number): void => {
setFilters(filters.filter((f) => f.columnIdx !== columnIdx))
}
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}`
@@ -110,20 +99,15 @@ 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)
})
})
// Sorting
const createSortHandler = (columnId: number) => () => {
const isAsc = orderBy === columnId && order === "asc"
setOrder(isAsc ? "desc" : "asc")
setOrderBy(columnId)
}
const sortedRows = stableSort<T>(filteredRows, order, orderBy, columns)
const currentPageRows =
rowsPerPage > 0
@@ -135,20 +119,6 @@ function DataGrid<T>(props: {
const RootDiv = styled("div")({
width: "100%",
})
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 (
<RootDiv>
<TableContainer>
@@ -160,50 +130,32 @@ function DataGrid<T>(props: {
<TableHead>
<TableRow>
{collapseBody ? <TableCell /> : null}
{columns.map((column, columnIdx) => (
<TableCell
key={columnIdx}
padding={column.padding || "normal"}
sortDirection={orderBy === column.field ? order : false}
>
<TableHeaderCellSpan>
{column.sortable ? (
<TableSortLabel
active={orderBy === columnIdx}
direction={orderBy === columnIdx ? order : "asc"}
onClick={createSortHandler(columnIdx)}
>
{column.label}
{orderBy === column.field ? (
<HiddenSpan>
{order === "desc"
? "sorted descending"
: "sorted ascending"}
</HiddenSpan>
) : null}
</TableSortLabel>
) : (
column.label
)}
{column.filterable ? (
<IconButton
size={dense ? "small" : "medium"}
style={
fieldAlreadyFiltered(columnIdx)
? {}
: { visibility: "hidden" }
}
color="inherit"
onClick={() => {
clearFilter(columnIdx)
}}
>
<Clear />
</IconButton>
) : null}
</TableHeaderCellSpan>
</TableCell>
))}
{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>
@@ -215,7 +167,6 @@ function DataGrid<T>(props: {
keyField={keyField}
collapseBody={collapseBody}
key={`${row[keyField]}`}
handleClickFilterCell={handleClickFilterCell}
/>
))}
{emptyRows > 0 && (
@@ -239,30 +190,119 @@ 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>
order: Order | null
onOrderByChange: (order: Order) => void
filter: RowFilter | null
onFilterChange: (values: Value[]) => void
dense?: boolean
}) {
const { column, order, onOrderByChange, filter, onFilterChange, dense } =
props
const [filterMenuAnchorEl, setFilterMenuAnchorEl] =
React.useState<null | HTMLElement>(null)
const filterChoices = column.filterChoices
return (
<TableCell
padding={column.padding || "normal"}
sortDirection={order !== null ? order : false}
>
<TableHeaderCellSpan>
{column.sortable ? (
<TableSortLabel
active={order !== null}
direction={order || "asc"}
onClick={() => {
onOrderByChange(order === "asc" ? "desc" : "asc")
}}
>
{column.label}
{order !== null ? (
<HiddenSpan>
{order === "desc" ? "sorted descending" : "sorted ascending"}
</HiddenSpan>
) : null}
</TableSortLabel>
) : (
column.label
)}
{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>
)
}
function DataGridRow<T>(props: {
columns: DataGridColumn<T>[]
rowIndex: number
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}>
@@ -283,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"}
@@ -358,7 +384,10 @@ function stableSort<T>(
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
if (less) {
const result = order == "asc" ? -less(a[0], b[0]) : less(a[0], b[0])
const ascending = order === "asc"
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending)
if (result !== 0) return result
} else {
const result = comparator(a[0], b[0])
@@ -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>
)
}
+15 -110
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<{
@@ -195,12 +178,16 @@ const plotContour = (
const xValues: plotly.Datum[] = []
const yValues: plotly.Datum[] = []
const zValues: plotly.Datum[][] = new Array(yIndices.length)
const feasibleXY = new Set<number>()
for (let j = 0; j < yIndices.length; j++) {
zValues[j] = new Array(xIndices.length).fill(null)
}
filteredTrials.forEach((trial, i) => {
if (xAxis.values[i] && yAxis.values[i] && trial.values) {
if (trial.constraints.every((c) => c <= 0)) {
feasibleXY.add(xValues.length)
}
const xValue = xAxis.values[i] as string | number
const yValue = yAxis.values[i] as string | number
xValues.push(xValue)
@@ -234,12 +221,20 @@ const plotContour = (
},
{
type: "scatter",
x: xValues,
y: yValues,
x: xValues.filter((_, i) => feasibleXY.has(i)),
y: yValues.filter((_, i) => feasibleXY.has(i)),
marker: { line: { width: 2.0, color: "Grey" }, color: "black" },
mode: "markers",
showlegend: false,
},
{
type: "scatter",
x: xValues.filter((_, i) => !feasibleXY.has(i)),
y: yValues.filter((_, i) => !feasibleXY.has(i)),
marker: { line: { width: 2.0, color: "Grey" }, color: "#cccccc" },
mode: "markers",
showlegend: false,
},
]
plotly.react(plotDomId, plotData, layout)
return
@@ -272,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,22 +78,27 @@ 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(
(iv) => iv.value !== "inf" && iv.value !== "-inf" && iv.value !== "nan"
)
const isFeasible = trial.constraints.every((c) => c <= 0)
return {
x: values.map((iv) => iv.step),
y: values.map((iv) => iv.value),
marker: { maxdisplayed: 10 },
mode: "lines+markers",
type: "scatter",
name:
trial.state !== "Running"
? `trial #${trial.number}`
: `trial #${trial.number} (running)`,
name: `trial #${trial.number} ${
trial.state === "Running"
? "(running)"
: !isFeasible
? "(infeasible)"
: ""
}`,
...(!isFeasible && { line: { color: "#CCCCCC" } }),
}
})
plotly.react(plotDomId, plotData, layout)
@@ -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("")
}
@@ -0,0 +1,342 @@
import * as plotly from "plotly.js-dist-min"
import React, { FC, useEffect, useState } from "react"
import {
Grid,
FormControl,
FormLabel,
MenuItem,
Select,
Typography,
SelectChangeEvent,
useTheme,
Box,
} from "@mui/material"
import { plotlyDarkTemplate } from "./PlotlyDarkMode"
import { getAxisInfo, makeHovertext } from "../graphUtil"
import { useMergedUnionSearchSpace } from "../searchSpace"
const plotDomId = "graph-rank"
interface RankPlotInfo {
xtitle: string
ytitle: string
xtype: plotly.AxisType
ytype: plotly.AxisType
xvalues: (string | number)[]
yvalues: (string | number)[]
colors: number[]
is_feasible: boolean[]
hovertext: string[]
}
export const GraphRank: FC<{
study: StudyDetail | null
}> = ({ study = null }) => {
const theme = useTheme()
const [objectiveId, setobjectiveId] = useState<number>(0)
const searchSpace = useMergedUnionSearchSpace(study?.union_search_space)
const [xParam, setXParam] = useState<SearchSpaceItem | null>(null)
const [yParam, setYParam] = useState<SearchSpaceItem | null>(null)
const objectiveNames: string[] = study?.objective_names || []
if (xParam === null && searchSpace.length > 0) {
setXParam(searchSpace[0])
}
if (yParam === null && searchSpace.length > 1) {
setYParam(searchSpace[1])
}
const handleObjectiveChange = (event: SelectChangeEvent<number>) => {
setobjectiveId(Number(event.target.value))
}
const handleXParamChange = (event: SelectChangeEvent<string>) => {
const param = searchSpace.find((item) => item.name === event.target.value)
setXParam(param || null)
}
const handleYParamChange = (event: SelectChangeEvent<string>) => {
const param = searchSpace.find((item) => item.name === event.target.value)
setYParam(param || null)
}
useEffect(() => {
if (study != null) {
const rankPlotInfo = getRankPlotInfo(study, objectiveId, xParam, yParam)
plotRank(rankPlotInfo, theme.palette.mode)
}
}, [study, objectiveId, xParam, yParam, theme.palette.mode])
const space: SearchSpaceItem[] = study ? study.union_search_space : []
return (
<Grid container direction="row">
<Grid
item
xs={3}
container
direction="column"
sx={{ paddingRight: theme.spacing(2) }}
>
<Typography
variant="h6"
sx={{ margin: "1em 0", fontWeight: theme.typography.fontWeightBold }}
>
Rank
</Typography>
{study !== null && study.directions.length !== 1 ? (
<FormControl component="fieldset">
<FormLabel component="legend">Objective:</FormLabel>
<Select value={objectiveId} onChange={handleObjectiveChange}>
{study.directions.map((d, i) => (
<MenuItem value={i} key={i}>
{objectiveNames.length === study?.directions.length
? objectiveNames[i]
: `${i}`}
</MenuItem>
))}
</Select>
</FormControl>
) : null}
{study !== null && space.length > 0 ? (
<Grid container direction="column" gap={1}>
<FormControl component="fieldset" fullWidth>
<FormLabel component="legend">x:</FormLabel>
<Select value={xParam?.name || ""} onChange={handleXParamChange}>
{space.map((d) => (
<MenuItem value={d.name} key={d.name}>
{d.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl component="fieldset" fullWidth>
<FormLabel component="legend">y:</FormLabel>
<Select value={yParam?.name || ""} onChange={handleYParamChange}>
{space.map((d) => (
<MenuItem value={d.name} key={d.name}>
{d.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
) : null}
</Grid>
<Grid item xs={9}>
<Box id={plotDomId} sx={{ height: "450px" }} />
</Grid>
</Grid>
)
}
const getRankPlotInfo = (
study: StudyDetail | null,
objectiveId: number,
xParam: SearchSpaceItem | null,
yParam: SearchSpaceItem | null
): RankPlotInfo | null => {
if (study === null) {
return null
}
const trials = study.trials
const filteredTrials = trials.filter(filterFunc)
if (filteredTrials.length < 2 || xParam === null || yParam === null) {
return null
}
const xAxis = getAxisInfo(filteredTrials, xParam)
const yAxis = getAxisInfo(filteredTrials, yParam)
let xValues: (string | number)[] = []
let yValues: (string | number)[] = []
const zValues: number[] = []
const isFeasible: boolean[] = []
const hovertext: string[] = []
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 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 {
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,
colors,
is_feasible: isFeasible,
hovertext,
}
}
const filterFunc = (trial: Trial): boolean => {
return trial.state === "Complete" && trial.values !== undefined
}
const getColors = (values: number[]): number[] => {
const rawRanks = getOrderWithSameOrderAveraging(values)
let colorIdxs: number[] = []
if (values.length > 2) {
colorIdxs = rawRanks.map((rank) => rank / (values.length - 1))
} else {
colorIdxs = [0.5]
}
return colorIdxs
}
const getOrderWithSameOrderAveraging = (values: number[]): number[] => {
const sortedValues = values.slice().sort((a, b) => a - b)
const ranks: number[] = []
values.forEach((value) => {
const firstIndex = sortedValues.indexOf(value)
const lastIndex = sortedValues.lastIndexOf(value)
const sumOfTheValue =
((firstIndex + lastIndex) * (lastIndex - firstIndex + 1)) / 2
const rank = sumOfTheValue / (lastIndex - firstIndex + 1)
ranks.push(rank)
})
return ranks
}
const plotRank = (rankPlotInfo: RankPlotInfo | null, mode: string) => {
if (document.getElementById(plotDomId) === null) {
return
}
if (rankPlotInfo === null) {
plotly.react(plotDomId, [], {
template: mode === "dark" ? plotlyDarkTemplate : {},
})
return
}
const layout: Partial<plotly.Layout> = {
xaxis: {
title: rankPlotInfo.xtitle,
type: rankPlotInfo.xtype,
},
yaxis: {
title: rankPlotInfo.ytitle,
type: rankPlotInfo.ytype,
},
margin: {
l: 50,
t: 0,
r: 50,
b: 50,
},
uirevision: "true",
template: mode === "dark" ? plotlyDarkTemplate : {},
}
const xValues = rankPlotInfo.xvalues
const yValues = rankPlotInfo.yvalues
const plotData: Partial<plotly.PlotData>[] = [
{
type: "scatter",
x: xValues.filter((_, i) => rankPlotInfo.is_feasible[i]),
y: yValues.filter((_, i) => rankPlotInfo.is_feasible[i]),
marker: {
color: rankPlotInfo.colors.filter(
(_, i) => rankPlotInfo.is_feasible[i]
),
colorscale: "Portland",
colorbar: {
title: "Rank",
},
size: 10,
line: {
color: "Grey",
width: 0.5,
},
},
mode: "markers",
showlegend: false,
hovertemplate: "%{hovertext}<extra></extra>",
hovertext: rankPlotInfo.hovertext.filter(
(_, i) => rankPlotInfo.is_feasible[i]
),
},
{
type: "scatter",
x: xValues.filter((_, i) => !rankPlotInfo.is_feasible[i]),
y: yValues.filter((_, i) => !rankPlotInfo.is_feasible[i]),
marker: {
color: "#cccccc",
size: 10,
line: {
color: "Grey",
width: 0.5,
},
},
mode: "markers",
showlegend: false,
hovertemplate: "%{hovertext}<extra></extra>",
hovertext: rankPlotInfo.hovertext.filter(
(_, i) => !rankPlotInfo.is_feasible[i]
),
},
]
plotly.react(plotDomId, plotData, layout)
}
+51 -45
View File
@@ -188,65 +188,71 @@ const plotSlice = (
return
}
const objectiveValues: number[] = trials.map(
const feasibleTrials: Trial[] = []
const infeasibleTrials: Trial[] = []
trials.forEach((t) => {
if (t.constraints.every((c) => c <= 0)) {
feasibleTrials.push(t)
} else {
infeasibleTrials.push(t)
}
})
const feasibleObjectiveValues: number[] = feasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const values = trials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
const infeasibleObjectiveValues: number[] = infeasibleTrials.map(
(t) => objectiveTarget.getTargetValue(t) as number
)
const trialNumbers: number[] = trials.map((t) => t.number)
if (selectedParamSpace.distribution.type !== "CategoricalDistribution") {
const trace: plotly.Data[] = [
{
type: "scatter",
x: values,
y: objectiveValues,
mode: "markers",
marker: {
color: trialNumbers,
colorscale: "Blues",
reversescale: true,
colorbar: {
title: "Trial",
},
line: {
color: "Grey",
width: 0.5,
},
const feasibleValues = feasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const infeasibleValues = infeasibleTrials.map(
(t) => selectedParamTarget.getTargetValue(t) as number
)
const trace: plotly.Data[] = [
{
type: "scatter",
x: feasibleValues,
y: feasibleObjectiveValues,
mode: "markers",
name: "Feasible Trial",
marker: {
color: feasibleTrials.map((t) => t.number),
colorscale: "Blues",
reversescale: true,
colorbar: {
title: "Trial",
},
line: {
color: "Grey",
width: 0.5,
},
},
]
},
{
type: "scatter",
x: infeasibleValues,
y: infeasibleObjectiveValues,
mode: "markers",
name: "Infeasible Trial",
marker: {
color: "#cccccc",
reversescale: true,
},
},
]
if (selectedParamSpace.distribution.type !== "CategoricalDistribution") {
layout["xaxis"] = {
title: selectedParamTarget.toLabel(),
type: isLogScale(selectedParamSpace) ? "log" : "linear",
gridwidth: 1,
automargin: true, // Otherwise the label is outside of the plot
}
plotly.react(plotDomId, trace, layout)
} else {
const vocabArr = selectedParamSpace.distribution.choices.map((c) => c.value)
const tickvals: number[] = vocabArr.map((v, i) => i)
const trace: plotly.Data[] = [
{
type: "scatter",
x: values,
y: objectiveValues,
mode: "markers",
marker: {
color: trialNumbers,
colorscale: "Blues",
reversescale: true,
colorbar: {
title: "Trial",
},
line: {
color: "Grey",
width: 0.5,
},
},
},
]
layout["xaxis"] = {
title: selectedParamTarget.toLabel(),
type: "linear",
@@ -255,6 +261,6 @@ const plotSlice = (
ticktext: vocabArr,
automargin: true, // Otherwise the label is outside of the plot
}
plotly.react(plotDomId, trace, layout)
}
plotly.react(plotDomId, trace, layout)
}
@@ -92,12 +92,7 @@ const plotTimeline = (trials: Trial[], mode: string) => {
template: mode === "dark" ? plotlyDarkTemplate : {},
}
const traces: Partial<plotly.PlotData>[] = []
for (const s of Object.keys(cm) as TrialState[]) {
const bars = trials.filter((t) => t.state === s)
if (bars.length === 0) {
continue
}
const makeTrace = (bars: Trial[], state: string, color: string) => {
const starts = bars.map((b) => b.datetime_start ?? new Date())
const completes = bars.map((b, i) => b.datetime_complete ?? starts[i])
const trace: Partial<plotly.PlotData> = {
@@ -106,14 +101,38 @@ const plotTimeline = (trials: Trial[], mode: string) => {
y: bars.map((b) => b.number),
// @ts-ignore: To suppress ts(2322)
base: starts.map((s) => s.toISOString()),
name: s,
name: state,
text: bars.map((b) => makeHovertext(b)),
hovertemplate: "%{text}<extra>" + s + "</extra>",
hovertemplate: "%{text}<extra>" + state + "</extra>",
orientation: "h",
marker: { color: cm[s] },
marker: { color: color },
textposition: "none", // Avoid drawing hovertext in a bar.
}
traces.push(trace)
return trace
}
const traces: Partial<plotly.PlotData>[] = []
for (const [state, color] of Object.entries(cm)) {
const bars = trials.filter((t) => t.state === state)
if (bars.length === 0) {
continue
}
if (state === "Complete") {
const feasibleTrials = bars.filter((t) =>
t.constraints.every((c) => c <= 0)
)
const infeasibleTrials = bars.filter((t) =>
t.constraints.some((c) => c > 0)
)
if (feasibleTrials.length > 0) {
traces.push(makeTrace(feasibleTrials, "Complete", color))
}
if (infeasibleTrials.length > 0) {
traces.push(makeTrace(infeasibleTrials, "Infeasible", "#cccccc"))
}
} else {
traces.push(makeTrace(bars, state, color))
}
}
plotly.react(plotDomId, traces, layout)
}
+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) => {
@@ -147,6 +147,16 @@ const CandidateTrial: FC<{
overflow: "auto",
}}
>
<IconButton
sx={{
position: "absolute",
top: theme.spacing(2),
right: theme.spacing(2),
}}
onClick={() => setDetailShown(false)}
>
<ClearIcon />
</IconButton>
<TrialListDetail
trial={trial}
isBestTrial={() => false}
@@ -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>
)
}
+44 -9
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"
@@ -27,6 +28,7 @@ import { GraphParallelCoordinate } from "./GraphParallelCoordinate"
import { Contour } from "./GraphContour"
import { GraphSlice } from "./GraphSlice"
import { GraphEdf } from "./GraphEdf"
import { GraphRank } from "./GraphRank"
import { TrialList } from "./TrialList"
import { StudyHistory } from "./StudyHistory"
import { PreferentialTrials } from "./PreferentialTrials"
@@ -121,6 +123,11 @@ export const StudyDetail: FC<{
<Contour study={studyDetail} />
</CardContent>
</Card>
<Card sx={{ margin: theme.spacing(2) }}>
<CardContent>
<GraphRank study={studyDetail} />
</CardContent>
</Card>
<Typography variant="h5" sx={{ margin: theme.spacing(2) }}>
Empirical Distribution of the Objective Value
</Typography>
@@ -139,16 +146,44 @@ export const StudyDetail: FC<{
</Grid2>
</Box>
)
} else if (page === "trialTable") {
content = (
<Card sx={{ margin: theme.spacing(2) }}>
<CardContent>
<TrialTable studyDetail={studyDetail} initialRowsPerPage={50} />
</CardContent>
</Card>
)
} else if (page === "trialList") {
content = <TrialList studyDetail={studyDetail} />
} else if (page === "trialTable") {
content = (
<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 = (
<Box
@@ -186,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>
)
}
+1 -2
View File
@@ -64,8 +64,7 @@ export const StudyList: FC<{
return useMemo(() => new URLSearchParams(search), [search])
}
const query = useQuery()
const initialSortBy =
query.get("studies_order_by") === "desc" ? "desc" : "asc"
const initialSortBy = query.get("studies_order_by") === "asc" ? "asc" : "desc"
const [sortBy, setSortBy] = useState<"asc" | "desc">(initialSortBy)
let filteredStudies = studies.filter((s) => !studyFilter(s))
@@ -4,12 +4,17 @@ import { Canvas } from "@react-three/fiber"
import { GizmoHelper, GizmoViewport, OrbitControls } from "@react-three/drei"
import { STLLoader } from "three/examples/jsm/loaders/STLLoader"
import { Rhino3dmLoader } from "three/examples/jsm/loaders/3DMLoader"
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader"
import { PerspectiveCamera } from "three"
import { Modal, Box } from "@mui/material"
import { Modal, Box, useTheme } from "@mui/material"
import ClearIcon from "@mui/icons-material/Clear"
import IconButton from "@mui/material/IconButton"
export const isThreejsArtifact = (artifact: Artifact): boolean => {
return (
artifact.filename.endsWith(".stl") || artifact.filename.endsWith(".3dm")
artifact.filename.endsWith(".stl") ||
artifact.filename.endsWith(".3dm") ||
artifact.filename.endsWith(".obj")
)
}
@@ -32,7 +37,7 @@ const CustomGizmoHelper: React.FC = () => {
)
}
const calculateBoundingBox = (geometries: THREE.BufferGeometry[]) => {
const computeBoundingBox = (geometries: THREE.BufferGeometry[]) => {
const boundingBox = new THREE.Box3()
geometries.forEach((geometry) => {
const mesh = new THREE.Mesh(geometry)
@@ -45,8 +50,11 @@ export const ThreejsArtifactViewer: React.FC<ThreejsArtifactViewerProps> = (
props
) => {
const [geometry, setGeometry] = useState<THREE.BufferGeometry[]>([])
const [modelSize, setModelSize] = useState<THREE.Vector3>(
new THREE.Vector3(10, 10, 10)
const [boundingBox, setBoundingBox] = useState<THREE.Box3>(
new THREE.Box3(
new THREE.Vector3(-10, -10, -10),
new THREE.Vector3(10, 10, 10)
)
)
const [cameraSettings, setCameraSettings] = useState<PerspectiveCamera>(
new THREE.PerspectiveCamera()
@@ -54,55 +62,54 @@ export const ThreejsArtifactViewer: React.FC<ThreejsArtifactViewerProps> = (
const handleLoadedGeometries = (geometries: THREE.BufferGeometry[]) => {
setGeometry(geometries)
const boundingBox = calculateBoundingBox(geometries)
const boundingBox = computeBoundingBox(geometries)
if (boundingBox !== null) {
const size = boundingBox.getSize(new THREE.Vector3())
setModelSize(size)
setBoundingBox(boundingBox)
}
return boundingBox
}
useEffect(() => {
if ("stl" === props.filetype) {
const stlLoader = new STLLoader()
stlLoader.load(props.src, (stlGeometries: THREE.BufferGeometry) => {
if (stlGeometries) {
handleLoadedGeometries([stlGeometries])
}
})
loadSTL(props, handleLoadedGeometries)
} else if ("3dm" === props.filetype) {
const loader = new Rhino3dmLoader()
loader.setLibraryPath("https://cdn.jsdelivr.net/npm/rhino3dm@7.15.0/")
loader.load(props.src, (object: THREE.Object3D) => {
const meshes = object.children as THREE.Mesh[]
const rhinoGeometries = meshes.map((mesh) => mesh.geometry)
THREE.Object3D.DEFAULT_UP.set(0, 0, 1)
if (rhinoGeometries.length > 0) {
handleLoadedGeometries(rhinoGeometries)
}
})
loadRhino3dm(props, handleLoadedGeometries)
} else if ("obj" === props.filetype) {
loadOBJ(props, handleLoadedGeometries)
}
const maxModelSize = Math.max(modelSize.x, modelSize.y, modelSize.z)
const cameraSet = new THREE.PerspectiveCamera(
modelSize
? Math.min(
45,
Math.atan(modelSize.y / modelSize.z) * (180 / Math.PI) * 2
)
: 45,
window.innerWidth / window.innerHeight
)
cameraSet.position.set(maxModelSize * 2, maxModelSize * 2, maxModelSize * 2)
setCameraSettings(cameraSet)
}, [])
useEffect(() => {
const cameraSet = new THREE.PerspectiveCamera(
50,
window.innerWidth / window.innerHeight,
0.1,
boundingBox.getSize(new THREE.Vector3()).length() * 100
)
const maxPosition = Math.max(
boundingBox.max.x,
boundingBox.max.y,
boundingBox.max.z
)
cameraSet.position.set(
maxPosition * 1.5,
maxPosition * 1.5,
maxPosition * 1.5
)
const center = boundingBox.getCenter(new THREE.Vector3())
cameraSet.lookAt(center.x, center.y, center.z)
setCameraSettings(cameraSet)
}, [boundingBox])
return (
<Canvas
frameloop="demand"
camera={cameraSettings}
style={{ width: props.width, height: props.height }}
>
<ambientLight />
<OrbitControls />
<gridHelper args={[Math.max(modelSize?.x, modelSize?.y) * 5]} />
<gridHelper args={[Math.max(boundingBox.max.x, boundingBox.max.y) * 5]} />
{props.hasGizmo && <CustomGizmoHelper />}
<axesHelper />
{geometry.length > 0 &&
@@ -121,6 +128,7 @@ export const useThreejsArtifactModal = (): [
] => {
const [open, setOpen] = useState(false)
const [target, setTarget] = useState<[string, Artifact | null]>(["", null])
const theme = useTheme()
const openModal = (artifactUrlPath: string, artifact: Artifact) => {
setTarget([artifactUrlPath, artifact])
@@ -146,6 +154,19 @@ export const useThreejsArtifactModal = (): [
borderRadius: "15px",
}}
>
<IconButton
sx={{
position: "absolute",
top: theme.spacing(2),
right: theme.spacing(2),
}}
onClick={() => {
setOpen(false)
setTarget(["", null])
}}
>
<ClearIcon />
</IconButton>
<ThreejsArtifactViewer
src={target[0]}
width={`${innerWidth * 0.8}px`}
@@ -159,3 +180,45 @@ export const useThreejsArtifactModal = (): [
}
return [openModal, renderDeleteStudyDialog]
}
function loadSTL(
props: ThreejsArtifactViewerProps,
handleLoadedGeometries: (geometries: THREE.BufferGeometry[]) => THREE.Box3
) {
const stlLoader = new STLLoader()
stlLoader.load(props.src, (stlGeometries: THREE.BufferGeometry) => {
if (stlGeometries) {
handleLoadedGeometries([stlGeometries])
}
})
}
function loadRhino3dm(
props: ThreejsArtifactViewerProps,
handleLoadedGeometries: (geometries: THREE.BufferGeometry[]) => THREE.Box3
) {
const rhino3dmLoader = new Rhino3dmLoader()
rhino3dmLoader.setLibraryPath("https://cdn.jsdelivr.net/npm/rhino3dm@7.15.0/")
rhino3dmLoader.load(props.src, (object: THREE.Object3D) => {
const meshes = object.children as THREE.Mesh[]
const rhinoGeometries = meshes.map((mesh) => mesh.geometry)
THREE.Object3D.DEFAULT_UP.set(0, 0, 1)
if (rhinoGeometries.length > 0) {
handleLoadedGeometries(rhinoGeometries)
}
})
}
function loadOBJ(
props: ThreejsArtifactViewerProps,
handleLoadedGeometries: (geometries: THREE.BufferGeometry[]) => THREE.Box3
) {
const objLoader = new OBJLoader()
objLoader.load(props.src, (object: THREE.Object3D) => {
const meshes = object.children as THREE.Mesh[]
const objGeometries = meshes.map((mesh) => mesh.geometry)
if (objGeometries.length > 0) {
handleLoadedGeometries(objGeometries)
}
})
}
@@ -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])
}
+17 -13
View File
@@ -18,17 +18,17 @@ 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",
sortable: true,
less: (firstEl, secondEl): number => {
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[0]
const secondVal = secondEl.values?.[0]
@@ -36,9 +36,9 @@ export const TrialTable: FC<{
return 0
}
if (firstVal === undefined) {
return -1
return ascending ? -1 : 1
} else if (secondVal === undefined) {
return 1
return ascending ? 1 : -1
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1
@@ -63,7 +63,7 @@ export const TrialTable: FC<{
? objectiveNames[objectiveId]
: `Objective ${objectiveId}`,
sortable: true,
less: (firstEl, secondEl): number => {
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[objectiveId]
const secondVal = secondEl.values?.[objectiveId]
@@ -71,9 +71,9 @@ export const TrialTable: FC<{
return 0
}
if (firstVal === undefined) {
return -1
return ascending ? -1 : 1
} else if (secondVal === undefined) {
return 1
return ascending ? 1 : -1
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1
@@ -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,8 +108,9 @@ export const TrialTable: FC<{
trials[i].params.find((p) => p.name === s.name)
?.param_external_value || null,
sortable: sortable,
filterable: filterable,
less: (firstEl, secondEl): number => {
filterChoices: filterChoices,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.params.find(
(p) => p.name === s.name
)?.param_internal_value
@@ -145,8 +149,8 @@ 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,
less: (firstEl, secondEl): number => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.user_attrs.find(
(attr) => attr.key === attr_spec.key
)?.value
@@ -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 => {
+961 -899
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -26,7 +26,7 @@
"@react-three/drei": "^9.80.0",
"@react-three/fiber": "^8.13.6",
"@types/three": "^0.154.0",
"axios": "^1.2.1",
"axios": "^1.6.0",
"elkjs": "^0.8.2",
"notistack": "^3.0.1",
"plotly.js-dist-min": "^2.22.0",
@@ -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",
+5 -1
View File
@@ -36,6 +36,7 @@ dynamic = ["version"]
[project.optional-dependencies]
docs = [
"boto3",
"botorch",
"streamlit",
"sphinx",
"sphinx_rtd_theme",
@@ -51,9 +52,12 @@ test = [
optional = [
"streamlit",
"boto3",
"botorch",
"botorch>=0.8.1; python_version>='3.8'",
]
preferential = [
"botorch>=0.8.1",
]
[project.scripts]
optuna-dashboard = "optuna_dashboard._cli:main"
+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"
+17 -1
View File
@@ -3,12 +3,12 @@ from unittest.mock import patch
import numpy as np
import pytest
import torch
if sys.version_info >= (3, 8):
from optuna_dashboard.preferential.samplers.gp import _one_side_trunc_norm_sampling
from optuna_dashboard.preferential.samplers.gp import _orthants_MVN_Gibbs_sampling
import torch
else:
pytest.skip("BoTorch dropped Python3.7 support", allow_module_level=True)
@@ -36,3 +36,19 @@ def test_one_side_trunc_norm_sampling() -> None:
assert np.allclose(
_one_side_trunc_norm_sampling(torch.Tensor([5])).numpy(), 5.426934003050024
)
def test_one_side_trunc_norm_sampling() -> None:
for lower in np.linspace(-10, 10, 100):
assert _one_side_trunc_norm_sampling(torch.tensor([lower], dtype=torch.float64)) >= lower
with patch.object(torch, "rand", return_value=torch.tensor([0.4], dtype=torch.float64)):
sampled_value = _one_side_trunc_norm_sampling(torch.tensor([0.1], dtype=torch.float64))
assert np.allclose(sampled_value.numpy(), 0.899967154837563)
with patch.object(torch, "rand", return_value=torch.tensor([0.8], dtype=torch.float64)):
sampled_value = _one_side_trunc_norm_sampling(torch.tensor([-2.3], dtype=torch.float64))
assert np.allclose(sampled_value.numpy(), -0.8113606739551955)
with patch.object(torch, "rand", return_value=torch.tensor([0.1], dtype=torch.float64)):
sampled_value = _one_side_trunc_norm_sampling(torch.tensor([5], dtype=torch.float64))
assert np.allclose(sampled_value.numpy(), 5.426934003050024)
+5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import copy
import multiprocessing
import pickle
import sys
from typing import Callable
from unittest.mock import patch
import uuid
@@ -22,6 +23,10 @@ from ..storage_supplier import parametrize_storages
from ..storage_supplier import StorageSupplier
if sys.version_info < (3, 8):
pytest.skip("BoTorch dropped Python3.7 support", allow_module_level=True)
@parametrize_storages
def test_study_set_and_get_user_attrs(storage_supplier: Callable[[], StorageSupplier]) -> None:
with storage_supplier() as storage:
+234
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import sys
from unittest import TestCase
import optuna
@@ -8,12 +9,15 @@ 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
from optuna_dashboard._preferential_history import report_history
from optuna_dashboard._serializer import serialize_preference_history
from optuna_dashboard.preferential import create_study
import pytest
from .wsgi_client import send_request
@@ -105,6 +109,7 @@ class APITestCase(TestCase):
)
self.assertEqual(status, 400)
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_get_best_trials_of_preferential_study(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(n_generate=4, storage=storage)
@@ -128,6 +133,7 @@ class APITestCase(TestCase):
assert len(best_trials) == 1
assert best_trials[0]["number"] == 0
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_report_preference(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(n_generate=4, storage=storage)
@@ -161,6 +167,7 @@ class APITestCase(TestCase):
assert better.number == 2
assert worse.number == 1
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_report_preference_when_typo_mode(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(storage=storage, n_generate=3)
@@ -184,6 +191,7 @@ class APITestCase(TestCase):
)
self.assertEqual(status, 400)
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_change_component(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(storage=storage, n_generate=3)
@@ -214,6 +222,111 @@ 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()
study = create_study(n_generate=4, storage=storage)
@@ -238,6 +351,7 @@ class APITestCase(TestCase):
assert len(best_trials) == 1
assert best_trials[0].number == 2
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_remove_history(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(storage=storage, n_generate=3)
@@ -271,6 +385,7 @@ class APITestCase(TestCase):
assert histories[0]["is_removed"]
assert len(study.get_preferences()) == 0
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_restore_history(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = create_study(storage=storage, n_generate=3)
@@ -390,6 +505,125 @@ class APITestCase(TestCase):
)
self.assertEqual(status, 404)
def test_tell_trial_complete(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial_id = study.ask()._trial_id
app = create_app(storage)
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps(
{
"state": "Complete",
"values": [0, 1, 2],
}
),
content_type="application/json",
)
self.assertEqual(status, 204)
trial = storage.get_trial(trial_id)
assert trial.state == optuna.trial.TrialState.COMPLETE
assert trial.values == [0, 1, 2]
def test_tell_trial_fail(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial_id = study.ask()._trial_id
app = create_app(storage)
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps(
{
"state": "Fail",
}
),
content_type="application/json",
)
self.assertEqual(status, 204)
trial = storage.get_trial(trial_id)
assert trial.state == optuna.trial.TrialState.FAIL
def test_tell_trial_with_no_state(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial_id = study.ask()._trial_id
app = create_app(storage)
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps({}),
content_type="application/json",
)
self.assertEqual(status, 400)
def test_tell_trial_with_invalid_state(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
for state in ["Pruned", "Running", "Waiting", "Invalid"]:
trial_id = study.ask()._trial_id
app = create_app(storage)
with self.subTest(state=state):
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps(
{
"state": state,
}
),
content_type="application/json",
)
self.assertEqual(status, 400)
def test_tell_trial_with_no_values(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
trial_id = study.ask()._trial_id
app = create_app(storage)
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps(
{
"state": "Complete",
}
),
content_type="application/json",
)
self.assertEqual(status, 400)
def test_tell_trial_with_invalid_values(self) -> None:
storage = optuna.storages.InMemoryStorage()
study = optuna.create_study(storage=storage)
for values in [1.0, ["foo"]]:
trial_id = study.ask()._trial_id
app = create_app(storage)
with self.subTest(values=values):
status, _, _ = send_request(
app,
f"/api/trials/{trial_id}/tell",
"POST",
body=json.dumps(
{
"state": "Complete",
"values": values,
}
),
content_type="application/json",
)
self.assertEqual(status, 400)
class BottleRequestHookTestCase(TestCase):
def test_ignore_trailing_slashes(self) -> None:
@@ -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")
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import sys
from typing import Callable
from typing import TYPE_CHECKING
@@ -13,6 +14,7 @@ from optuna_dashboard._preferential_history import restore_history
from optuna_dashboard._serializer import serialize_preference_history
from optuna_dashboard.preferential import create_study
from optuna_dashboard.preferential._system_attrs import _SYSTEM_ATTR_PREFIX_PREFERENCE
import pytest
from .storage_supplier import parametrize_storages
from .storage_supplier import StorageSupplier
@@ -22,6 +24,10 @@ if TYPE_CHECKING:
from optuna_dashboard._preferential_history import History
if sys.version_info < (3, 8):
pytest.skip("BoTorch dropped Python3.7 support", allow_module_level=True)
@parametrize_storages
def test_report_and_get_choices(storage_supplier: Callable[[], StorageSupplier]) -> None:
with storage_supplier() as storage:
+32
View File
@@ -1,11 +1,15 @@
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
from optuna_dashboard._serializer import serialize_study_summary
from optuna_dashboard._storage import get_study_summaries
from optuna_dashboard.preferential import create_study
import pytest
def test_serialize_bytes() -> None:
@@ -22,6 +26,33 @@ 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()
study = create_study(n_generate=4, storage=storage)
@@ -48,6 +79,7 @@ def test_get_study_detail_is_not_preferential() -> None:
assert not study_detail["is_preferential"]
@pytest.mark.skipif(sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support")
def test_get_study_summary_is_preferential() -> None:
storage = optuna.storages.InMemoryStorage()
create_study(n_generate=4, storage=storage)
+13 -8
View File
@@ -1,16 +1,20 @@
use fanova::{FanovaOptions, RandomForestOptions};
use js_sys::Array;
use serde_wasm_bindgen::from_value;
use fanova::{FanovaOptions, RandomForestOptions};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn wasm_fanova_calculate(features: Array, targets: Array) -> Vec<f64> {
// TODO(c-bata): Fix error handling
pub fn wasm_fanova_calculate(features: Array, targets: Array) -> Result<Vec<f64>, JsError> {
let features_vec: Vec<Vec<f64>> = features
.iter()
.map(|x| from_value::<Vec<f64>>(x).unwrap())
.collect();
let targets_vec: Vec<f64> = targets.iter().map(|x| x.as_f64().unwrap()).collect();
.map(|x| from_value::<Vec<f64>>(x))
.collect::<Result<_, _>>()
.map_err(|_| JsError::new("features must be of type number[][]"))?;
let targets_vec: Vec<f64> = targets
.iter()
.map(|x| x.as_f64())
.collect::<Option<_>>()
.ok_or(JsError::new("targets must be of type number[]"))?;
let mut fanova = FanovaOptions::new()
.random_forest(RandomForestOptions::new().seed(0))
@@ -18,9 +22,10 @@ pub fn wasm_fanova_calculate(features: Array, targets: Array) -> Vec<f64> {
features_vec.iter().map(|x| x.as_slice()).collect(),
&targets_vec,
)
.unwrap();
.map_err(|e| JsError::new(&format!("failed to build fANOVA model: {}", e)))?;
let importances = (0..features_vec.len())
.map(|i| fanova.quantify_importance(&[i]).mean)
.collect::<Vec<_>>();
return importances;
Ok(importances)
}
+5 -2
View File
@@ -28,7 +28,7 @@ interface DataGridColumn<T> {
field: keyof T
label: string
sortable?: boolean
less?: (a: T, b: T) => number
less?: (a: T, b: T, ascending: boolean) => number
filterable?: boolean
toCellValue?: (rowIndex: number) => string | React.ReactNode
padding?: "normal" | "checkbox" | "none"
@@ -358,7 +358,10 @@ function stableSort<T>(
const stabilizedThis = array.map((el, index) => [el, index] as [T, number])
stabilizedThis.sort((a, b) => {
if (less) {
const result = order == "asc" ? -less(a[0], b[0]) : less(a[0], b[0])
const ascending = order === "asc"
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending)
if (result !== 0) return result
} else {
const result = comparator(a[0], b[0])
@@ -39,6 +39,7 @@ export const PlotImportance: FC<{ study: Study }> = ({ study }) => {
const values = filteredTrials.map(
(t) => t.values?.[objectiveId] as number
)
// TODO: handle errors thrown by wasm_fanova_calculate
const importance = wasm_fanova_calculate(features, values)
return study.intersection_search_space.map((s, i) => ({
name: s.name,
@@ -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(
+11 -9
View File
@@ -20,12 +20,12 @@ export const TrialTable: FC<{
},
]
if (study === null || study.directions.length == 1) {
if (study === null || study.directions.length === 1) {
columns.push({
field: "values",
label: "Value",
sortable: true,
less: (firstEl, secondEl): number => {
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[0]
const secondVal = secondEl.values?.[0]
@@ -33,9 +33,9 @@ export const TrialTable: FC<{
return 0
}
if (firstVal === undefined) {
return -1
return ascending ? -1 : 1
} else if (secondVal === undefined) {
return 1
return ascending ? 1 : -1
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1
@@ -57,7 +57,7 @@ export const TrialTable: FC<{
field: "values",
label: `Objective ${objectiveId}`,
sortable: true,
less: (firstEl, secondEl): number => {
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[objectiveId]
const secondVal = secondEl.values?.[objectiveId]
@@ -65,9 +65,9 @@ export const TrialTable: FC<{
return 0
}
if (firstVal === undefined) {
return -1
return ascending ? -1 : 1
} else if (secondVal === undefined) {
return 1
return ascending ? 1 : -1
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1
@@ -96,7 +96,8 @@ export const TrialTable: FC<{
null,
sortable: true,
filterable: false,
less: (firstEl, secondEl): number => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.params.find(
(p) => p.name === s.name
)?.param_internal_value
@@ -126,7 +127,8 @@ export const TrialTable: FC<{
?.value || null,
sortable: attr_spec.sortable,
filterable: false,
less: (firstEl, secondEl): number => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.user_attrs.find(
(attr) => attr.key === attr_spec.key
)?.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)
})
+4
View File
@@ -14,6 +14,10 @@ Nothing to configure.
## Release Notes
### 0.1.0
Added older database schemas support (Optuna 2.6.0 or later)
### 0.0.1
Initial Release
+1 -1
View File
@@ -3,7 +3,7 @@
"displayName": "Optuna Dashboard",
"description": "Web Dashboard for Optuna",
"publisher": "Optuna",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"icon": "images/optuna-logo.png",
"engines": {