diff --git a/.eslintrc.js b/.eslintrc.js
index 5b390797..1c8f2bb5 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -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',
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-dashboard-tests.yml
similarity index 75%
rename from .github/workflows/e2e-tests.yml
rename to .github/workflows/e2e-dashboard-tests.yml
index 4665f676..a0396976 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-dashboard-tests.yml
@@ -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
diff --git a/.github/workflows/e2e-standalone-tests.yml b/.github/workflows/e2e-standalone-tests.yml
new file mode 100644
index 00000000..2de6d610
--- /dev/null
+++ b/.github/workflows/e2e-standalone-tests.yml
@@ -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
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 8fee4e44..60dd6ea0 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -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: |
diff --git a/.gitignore b/.gitignore
index 49c137e0..646c1439 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,5 +39,4 @@ coverage.xml
.vscode/
.DS_Store
tmp/
-examples/preferential-optimization/artifact/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 04002ad7..df519b53 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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)
diff --git a/README.md b/README.md
index 62edd6c8..13022125 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@

[](https://pypistats.org/packages/optuna-dashboard)
[](https://optuna-dashboard.readthedocs.io/en/latest/?badge=latest)
+[](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
+## Jupyter Lab Extension (Experimental)
+
+You can install the Jupyter Lab extension via [PyPI](https://pypi.org/project/jupyterlab-optuna/).
+
+```
+$ pip install jupyterlab jupyterlab-optuna
+```
+
+
+
+To use, click the tile to launch the extension, and enter your Optuna’s storage URL (e.g. `sqlite:///db.sqlite3`) in the dialog.
+
## Browser-only version (Experimental)
@@ -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).
-
+
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.
diff --git a/docs/_static/jupyterlab-extension.png b/docs/_static/jupyterlab-extension.png
new file mode 100644
index 00000000..7652e87f
Binary files /dev/null and b/docs/_static/jupyterlab-extension.png differ
diff --git a/docs/api.rst b/docs/api.rst
index 18f8bc72..e26dcfd6 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -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
diff --git a/docs/getting-started.rst b/docs/getting-started.rst
index 295dd55c..d3b040c6 100644
--- a/docs/getting-started.rst
+++ b/docs/getting-started.rst
@@ -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 `_.
+
+.. 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 Optuna’s storage URL (e.g. ``sqlite:///db.sqlite3``) in the dialog.
Browser-only version (Experimental)
-----------------------------------
diff --git a/docs/tutorials/hitl.rst b/docs/tutorials/hitl.rst
index e06709c8..ad4f5e7d 100644
--- a/docs/tutorials/hitl.rst
+++ b/docs/tutorials/hitl.rst
@@ -95,7 +95,7 @@ Given the above system, we carry out HITL optimization as follows:
Environment setup
^^^^^^^^^^^^^^^^^
-To run `the script `_ used in this tutorial, you need to install following libraries:
+To run `the script `_ 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 `_
+Run a python script below which you copied from `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 @@ Let’s 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}
-
+

"""
)
@@ -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)
diff --git a/docs/tutorials/preferential-optimization.rst b/docs/tutorials/preferential-optimization.rst
index 4d2644ea..ab7ec654 100644
--- a/docs/tutorials/preferential-optimization.rst
+++ b/docs/tutorials/preferential-optimization.rst
@@ -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 `_,
+It differs from :ref:`human-in-the-loop optimization utilizing 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 `_.
+aligining with the problem setting in :ref:`this tutorial `.
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
diff --git a/e2e_tests/test_usecases/__init__.py b/e2e_tests/test_dashboard/__init__.py
similarity index 100%
rename from e2e_tests/test_usecases/__init__.py
rename to e2e_tests/test_dashboard/__init__.py
diff --git a/e2e_tests/test_dashboard/test_usecases/__init__.py b/e2e_tests/test_dashboard/test_usecases/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/e2e_tests/test_dashboard/test_usecases/test_preferential_optimization.py b/e2e_tests/test_dashboard/test_usecases/test_preferential_optimization.py
new file mode 100644
index 00000000..0b19fea0
--- /dev/null
+++ b/e2e_tests/test_dashboard/test_usecases/test_preferential_optimization.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()
diff --git a/e2e_tests/test_usecases/test_study_history.py b/e2e_tests/test_dashboard/test_usecases/test_study_history.py
similarity index 94%
rename from e2e_tests/test_usecases/test_study_history.py
rename to e2e_tests/test_dashboard/test_usecases/test_study_history.py
index e37b8f5a..f2d937bb 100644
--- a/e2e_tests/test_usecases/test_study_history.py
+++ b/e2e_tests/test_dashboard/test_usecases/test_study_history.py
@@ -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
diff --git a/e2e_tests/visual_regression_test.py b/e2e_tests/test_dashboard/visual_regression_test.py
similarity index 99%
rename from e2e_tests/visual_regression_test.py
rename to e2e_tests/test_dashboard/visual_regression_test.py
index d83fbb5f..ab189343 100644
--- a/e2e_tests/visual_regression_test.py
+++ b/e2e_tests/test_dashboard/visual_regression_test.py
@@ -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
diff --git a/e2e_tests/test_server.py b/e2e_tests/test_server.py
index b6d14734..a15acc68 100644
--- a/e2e_tests/test_server.py
+++ b/e2e_tests/test_server.py
@@ -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}"
diff --git a/e2e_tests/test_standalone/__init__.py b/e2e_tests/test_standalone/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/e2e_tests/test_standalone/test_study_list.py b/e2e_tests/test_standalone/test_study_list.py
new file mode 100644
index 00000000..b0d4735e
--- /dev/null
+++ b/e2e_tests/test_standalone/test_study_list.py
@@ -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.)"
diff --git a/e2e_tests/utils.py b/e2e_tests/utils.py
new file mode 100644
index 00000000..68952ae9
--- /dev/null
+++ b/e2e_tests/utils.py
@@ -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()
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..ab86ed0a
--- /dev/null
+++ b/examples/README.md
@@ -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.
diff --git a/examples/hitl/main.py b/examples/hitl/main.py
deleted file mode 100644
index 7844bc7a..00000000
--- a/examples/hitl/main.py
+++ /dev/null
@@ -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}
-
- 
- """
- )
- 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()
diff --git a/examples/preferential-optimization/evaluator.sh b/examples/preferential-optimization/evaluator.sh
deleted file mode 100755
index f7ebeca5..00000000
--- a/examples/preferential-optimization/evaluator.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/usr/bin/env sh
-optuna-dashboard sqlite:///example.db --artifact-dir ./artifact
\ No newline at end of file
diff --git a/examples/preferential-optimization/generator.py b/examples/preferential-optimization/generator.py
deleted file mode 100644
index a26343bb..00000000
--- a/examples/preferential-optimization/generator.py
+++ /dev/null
@@ -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()
diff --git a/examples/streamlit_plugin/rgb_evaluator.py b/examples/streamlit_plugin/rgb_evaluator.py
deleted file mode 100644
index 5836dbd6..00000000
--- a/examples/streamlit_plugin/rgb_evaluator.py
+++ /dev/null
@@ -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()
diff --git a/examples/streamlit_plugin/rgb_generator.py b/examples/streamlit_plugin/rgb_generator.py
deleted file mode 100644
index fc76ad02..00000000
--- a/examples/streamlit_plugin/rgb_generator.py
+++ /dev/null
@@ -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()
diff --git a/optuna_dashboard/__init__.py b/optuna_dashboard/__init__.py
index ea2a8dbe..2ca1d487 100644
--- a/optuna_dashboard/__init__.py
+++ b/optuna_dashboard/__init__.py
@@ -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"
diff --git a/optuna_dashboard/_app.py b/optuna_dashboard/_app.py
index 812201ad..ead27188 100644
--- a/optuna_dashboard/_app.py
+++ b/optuna_dashboard/_app.py
@@ -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/")
+ 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"]
diff --git a/optuna_dashboard/_cached_extra_study_property.py b/optuna_dashboard/_cached_extra_study_property.py
index 2f27fa64..24e22372 100644
--- a/optuna_dashboard/_cached_extra_study_property.py
+++ b/optuna_dashboard/_cached_extra_study_property.py
@@ -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():
diff --git a/optuna_dashboard/_note.py b/optuna_dashboard/_note.py
index 95853a3d..507e3d8f 100644
--- a/optuna_dashboard/_note.py
+++ b/optuna_dashboard/_note.py
@@ -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 {
diff --git a/optuna_dashboard/_serializer.py b/optuna_dashboard/_serializer.py
index b5a3b305..7030abec 100644
--- a/optuna_dashboard/_serializer.py
+++ b/optuna_dashboard/_serializer.py
@@ -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 = ""
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
diff --git a/optuna_dashboard/artifact/_backend.py b/optuna_dashboard/artifact/_backend.py
index 10a5b1ee..81cd766d 100644
--- a/optuna_dashboard/artifact/_backend.py
+++ b/optuna_dashboard/artifact/_backend.py
@@ -105,7 +105,7 @@ def register_artifact_route(
@app.post("/api/artifacts//")
@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/")
+ @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///")
@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//")
+ @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
diff --git a/optuna_dashboard/preferential/_study.py b/optuna_dashboard/preferential/_study.py
index d715b97b..101b61de 100644
--- a/optuna_dashboard/preferential/_study.py
+++ b/optuna_dashboard/preferential/_study.py
@@ -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.")
diff --git a/optuna_dashboard/preferential/samplers/gp.py b/optuna_dashboard/preferential/samplers/gp.py
index 2e782b68..590c5570 100644
--- a/optuna_dashboard/preferential/samplers/gp.py
+++ b/optuna_dashboard/preferential/samplers/gp.py
@@ -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 `_.
+ 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
diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts
index 41afdadb..3c8f06b6 100644
--- a/optuna_dashboard/ts/action.ts
+++ b/optuna_dashboard/ts/action.ts
@@ -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) => {
- 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) => {
+ 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,
diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts
index e5510d67..f42d20de 100644
--- a/optuna_dashboard/ts/apiClient.ts
+++ b/optuna_dashboard/ts/apiClient.ts
@@ -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 => {
+ return axiosInstance
+ .post(`/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 => {
+ return axiosInstance
+ .delete(`/api/artifacts/${studyId}/${artifactId}`)
+ .then(() => {
+ return
+ })
+}
+
export const tellTrialAPI = (
trialId: number,
state: TrialStateFinished,
diff --git a/optuna_dashboard/ts/components/ArtifactCardMedia.tsx b/optuna_dashboard/ts/components/ArtifactCardMedia.tsx
index 6326cb16..fecef837 100644
--- a/optuna_dashboard/ts/components/ArtifactCardMedia.tsx
+++ b/optuna_dashboard/ts/components/ArtifactCardMedia.tsx
@@ -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 (
-
+
+
+
)
} else if (artifact.mimetype.startsWith("image")) {
return (
diff --git a/optuna_dashboard/ts/components/DataGrid.tsx b/optuna_dashboard/ts/components/DataGrid.tsx
index 2d6a5670..adf64e77 100644
--- a/optuna_dashboard/ts/components/DataGrid.tsx
+++ b/optuna_dashboard/ts/components/DataGrid.tsx
@@ -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 {
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(props: {
@@ -81,28 +85,13 @@ function DataGrid(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(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(filteredRows, order, orderBy, columns)
const currentPageRows =
rowsPerPage > 0
@@ -135,20 +119,6 @@ function DataGrid(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 (
@@ -160,50 +130,32 @@ function DataGrid(props: {
{collapseBody ? : null}
- {columns.map((column, columnIdx) => (
-
-
- {column.sortable ? (
-
- {column.label}
- {orderBy === column.field ? (
-
- {order === "desc"
- ? "sorted descending"
- : "sorted ascending"}
-
- ) : null}
-
- ) : (
- column.label
- )}
- {column.filterable ? (
- {
- clearFilter(columnIdx)
- }}
- >
-
-
- ) : null}
-
-
- ))}
+ {columns.map((column, columnIdx) => {
+ return (
+
+ 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)
+ }}
+ />
+ )
+ })}
@@ -215,7 +167,6 @@ function DataGrid(props: {
keyField={keyField}
collapseBody={collapseBody}
key={`${row[keyField]}`}
- handleClickFilterCell={handleClickFilterCell}
/>
))}
{emptyRows > 0 && (
@@ -239,30 +190,119 @@ function DataGrid(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(props: {
+ column: DataGridColumn
+ 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)
+
+ const filterChoices = column.filterChoices
+
+ return (
+
+
+ {column.sortable ? (
+ {
+ onOrderByChange(order === "asc" ? "desc" : "asc")
+ }}
+ >
+ {column.label}
+ {order !== null ? (
+
+ {order === "desc" ? "sorted descending" : "sorted ascending"}
+
+ ) : null}
+
+ ) : (
+ column.label
+ )}
+ {filterChoices !== undefined ? (
+ <>
+ {
+ setFilterMenuAnchorEl(e.currentTarget)
+ }}
+ >
+
+
+
+ >
+ ) : null}
+
+
+ )
+}
+
function DataGridRow(props: {
columns: DataGridColumn[]
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 (
@@ -283,21 +323,7 @@ function DataGridRow(props: {
: // TODO(c-bata): Avoid this implicit type conversion.
(row[column.field] as number | string | null | undefined)
- return column.filterable ? (
- {
- const value =
- column.toCellValue !== undefined
- ? column.toCellValue(rowIndex)
- : row[column.field]
- handleClickFilterCell(columnIndex, value)
- }}
- >
- {cellItem}
-
- ) : (
+ return (
(
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])
diff --git a/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx b/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx
index 2c9c229e..7ac46162 100644
--- a/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx
+++ b/optuna_dashboard/ts/components/DeleteArtifactDialog.tsx
@@ -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 (
-
+
)
}
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 (
+
+ )
+ }
+ return [openDialog, renderDeleteArtifactDialog]
+}
+
+const DeleteDialog: FC<{
+ openDeleteArtifactDialog: boolean
+ handleCloseDeleteArtifactDialog: () => void
+ filename: string | undefined
+ handleDeleteArtifact: () => void
+}> = ({
+ openDeleteArtifactDialog,
+ handleCloseDeleteArtifactDialog,
+ filename,
+ handleDeleteArtifact,
+}) => {
+ return (
+
+ )
+}
diff --git a/optuna_dashboard/ts/components/GraphContour.tsx b/optuna_dashboard/ts/components/GraphContour.tsx
index b186fffe..b7895ddf 100644
--- a/optuna_dashboard/ts/components/GraphContour.tsx
+++ b/optuna_dashboard/ts/components/GraphContour.tsx
@@ -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()
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)
- }
-}
diff --git a/optuna_dashboard/ts/components/GraphIntermediateValues.tsx b/optuna_dashboard/ts/components/GraphIntermediateValues.tsx
index ca0bfda0..a06832d9 100644
--- a/optuna_dashboard/ts/components/GraphIntermediateValues.tsx
+++ b/optuna_dashboard/ts/components/GraphIntermediateValues.tsx
@@ -78,22 +78,27 @@ const plotIntermediateValue = (
t.state === "Pruned" &&
t.values &&
t.values.length > 0) ||
- t.state == "Running"
+ t.state === "Running"
)
const plotData: Partial[] = 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)
diff --git a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx
index 71f2b32d..b991ef94 100644
--- a/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx
+++ b/optuna_dashboard/ts/components/GraphParallelCoordinate.tsx
@@ -164,7 +164,7 @@ const plotCoordinate = (
return truncated
.split("")
.map((c, i) => {
- return (i + 1) % breakLength == 0 ? c + "
" : c
+ return (i + 1) % breakLength === 0 ? c + "
" : c
})
.join("")
}
diff --git a/optuna_dashboard/ts/components/GraphRank.tsx b/optuna_dashboard/ts/components/GraphRank.tsx
new file mode 100644
index 00000000..6daaf10a
--- /dev/null
+++ b/optuna_dashboard/ts/components/GraphRank.tsx
@@ -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(0)
+ const searchSpace = useMergedUnionSearchSpace(study?.union_search_space)
+ const [xParam, setXParam] = useState(null)
+ const [yParam, setYParam] = useState(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) => {
+ setobjectiveId(Number(event.target.value))
+ }
+ const handleXParamChange = (event: SelectChangeEvent) => {
+ const param = searchSpace.find((item) => item.name === event.target.value)
+ setXParam(param || null)
+ }
+ const handleYParamChange = (event: SelectChangeEvent) => {
+ 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 (
+
+
+
+ Rank
+
+ {study !== null && study.directions.length !== 1 ? (
+
+ Objective:
+
+
+ ) : null}
+ {study !== null && space.length > 0 ? (
+
+
+ x:
+
+
+
+ y:
+
+
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
+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 = {
+ 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[] = [
+ {
+ 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}",
+ 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}",
+ hovertext: rankPlotInfo.hovertext.filter(
+ (_, i) => !rankPlotInfo.is_feasible[i]
+ ),
+ },
+ ]
+ plotly.react(plotDomId, plotData, layout)
+}
diff --git a/optuna_dashboard/ts/components/GraphSlice.tsx b/optuna_dashboard/ts/components/GraphSlice.tsx
index f37a5413..2a807cb7 100644
--- a/optuna_dashboard/ts/components/GraphSlice.tsx
+++ b/optuna_dashboard/ts/components/GraphSlice.tsx
@@ -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)
}
diff --git a/optuna_dashboard/ts/components/GraphTimeline.tsx b/optuna_dashboard/ts/components/GraphTimeline.tsx
index 68afe0fc..cc0f7f57 100644
--- a/optuna_dashboard/ts/components/GraphTimeline.tsx
+++ b/optuna_dashboard/ts/components/GraphTimeline.tsx
@@ -92,12 +92,7 @@ const plotTimeline = (trials: Trial[], mode: string) => {
template: mode === "dark" ? plotlyDarkTemplate : {},
}
- const traces: Partial[] = []
- 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 = {
@@ -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}" + s + "",
+ hovertemplate: "%{text}" + state + "",
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[] = []
+ 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)
}
diff --git a/optuna_dashboard/ts/components/Note.tsx b/optuna_dashboard/ts/components/Note.tsx
index 09270213..586f34e0 100644
--- a/optuna_dashboard/ts/components/Note.tsx
+++ b/optuna_dashboard/ts/components/Note.tsx
@@ -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) => {
diff --git a/optuna_dashboard/ts/components/PreferenceHistory.tsx b/optuna_dashboard/ts/components/PreferenceHistory.tsx
index 6aa67317..b7bd04b4 100644
--- a/optuna_dashboard/ts/components/PreferenceHistory.tsx
+++ b/optuna_dashboard/ts/components/PreferenceHistory.tsx
@@ -147,6 +147,16 @@ const CandidateTrial: FC<{
overflow: "auto",
}}
>
+ setDetailShown(false)}
+ >
+
+
false}
diff --git a/optuna_dashboard/ts/components/StudyArtifactCards.tsx b/optuna_dashboard/ts/components/StudyArtifactCards.tsx
new file mode 100644
index 00000000..c6394896
--- /dev/null
+++ b/optuna_dashboard/ts/components/StudyArtifactCards.tsx
@@ -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 (
+ <>
+
+ {study.artifacts.map((artifact) => {
+ const urlPath = `/artifacts/${study.id}/${artifact.artifact_id}`
+ return (
+
+
+
+
+ {artifact.filename}
+
+ {isThreejsArtifact(artifact) ? (
+ {
+ openThreejsArtifactModal(urlPath, artifact)
+ }}
+ >
+
+
+ ) : null}
+ {
+ openDeleteArtifactDialog(study.id, artifact)
+ }}
+ >
+
+
+
+
+
+
+
+ )
+ })}
+
+
+ {renderDeleteArtifactDialog()}
+ {renderThreejsArtifactModal()}
+ >
+ )
+}
+
+const StudyArtifactUploader: FC<{
+ study: StudyDetail
+ width: string
+ height: string
+}> = ({ study, width, height }) => {
+ const theme = useTheme()
+ const [dragOver, setDragOver] = useState(false)
+ const action = actionCreator()
+
+ const inputRef = useRef(null)
+ const handleClick: MouseEventHandler = () => {
+ if (!inputRef || !inputRef.current) {
+ return
+ }
+ inputRef.current.click()
+ }
+
+ const handleOnChange: ChangeEventHandler = (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 (
+
+
+
+
+
+ Upload a New File
+
+ Drag your file here or click to browse.
+
+
+
+
+ )
+}
diff --git a/optuna_dashboard/ts/components/StudyDetail.tsx b/optuna_dashboard/ts/components/StudyDetail.tsx
index 1a8ced33..79702f2e 100644
--- a/optuna_dashboard/ts/components/StudyDetail.tsx
+++ b/optuna_dashboard/ts/components/StudyDetail.tsx
@@ -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<{
+
+
+
+
+
Empirical Distribution of the Objective Value
@@ -139,16 +146,44 @@ export const StudyDetail: FC<{
)
- } else if (page === "trialTable") {
- content = (
-
-
-
-
-
- )
} else if (page === "trialList") {
content =
+ } else if (page === "trialTable") {
+ content = (
+
+
+
+
+
+
+ Download CSV File
+
+
+
+
+
+
+
+
+
+
+ )
} else if (page === "note" && studyDetail !== null) {
content = (
)
- } else if (page == "preferenceHistory") {
+ } else if (page === "preferenceHistory") {
content =
}
diff --git a/optuna_dashboard/ts/components/StudyHistory.tsx b/optuna_dashboard/ts/components/StudyHistory.tsx
index 907acd1a..8fab513a 100644
--- a/optuna_dashboard/ts/components/StudyHistory.tsx
+++ b/optuna_dashboard/ts/components/StudyHistory.tsx
@@ -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(false)
const [includePruned, setIncludePruned] = useState(true)
+ const artifactEnabled = useRecoilValue(artifactIsAvailable)
const handleLogScaleChange = () => {
setLogScale(!logScale)
@@ -104,17 +108,6 @@ export const StudyHistory: FC<{ studyId: number }> = ({ studyId }) => {
- {studyDetail !== null &&
- studyDetail.directions.length == 1 &&
- studyDetail.has_intermediate_values ? (
-
-
-
- ) : null}
= ({ studyId }) => {
+ {studyDetail !== null &&
+ studyDetail.directions.length === 1 &&
+ studyDetail.has_intermediate_values ? (
+
+
+
+ ) : null}
+
+ {artifactEnabled && studyDetail !== null && (
+
+
+
+
+
+ Study Artifacts
+
+
+
+
+
+
+ )}
)
}
diff --git a/optuna_dashboard/ts/components/StudyList.tsx b/optuna_dashboard/ts/components/StudyList.tsx
index c41c51cc..14eaa68b 100644
--- a/optuna_dashboard/ts/components/StudyList.tsx
+++ b/optuna_dashboard/ts/components/StudyList.tsx
@@ -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))
diff --git a/optuna_dashboard/ts/components/ThreejsArtifactViewer.tsx b/optuna_dashboard/ts/components/ThreejsArtifactViewer.tsx
index 3c4e2e01..8d8c045d 100644
--- a/optuna_dashboard/ts/components/ThreejsArtifactViewer.tsx
+++ b/optuna_dashboard/ts/components/ThreejsArtifactViewer.tsx
@@ -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 = (
props
) => {
const [geometry, setGeometry] = useState([])
- const [modelSize, setModelSize] = useState(
- new THREE.Vector3(10, 10, 10)
+ const [boundingBox, setBoundingBox] = useState(
+ new THREE.Box3(
+ new THREE.Vector3(-10, -10, -10),
+ new THREE.Vector3(10, 10, 10)
+ )
)
const [cameraSettings, setCameraSettings] = useState(
new THREE.PerspectiveCamera()
@@ -54,55 +62,54 @@ export const ThreejsArtifactViewer: React.FC = (
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 (