mirror of
https://github.com/wassname/optuna-dashboard.git
synced 2026-09-11 12:30:25 +08:00
Merge branch 'main' into support-optuna-study-artifacts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
github: optuna
|
||||
@@ -0,0 +1,29 @@
|
||||
name: stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 23 * * SUN-THU'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: github.repository == 'optuna/optuna-dashboard'
|
||||
steps:
|
||||
- uses: actions/stale@v6
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue has not seen any recent activity.'
|
||||
stale-pr-message: 'This pull request has not seen any recent activity.'
|
||||
close-issue-message: 'This issue was closed automatically because it had not seen any recent activity. If you want to discuss it, you can reopen it freely.'
|
||||
close-pr-message: 'This pull request was closed automatically because it had not seen any recent activity. If you want to discuss it, you can reopen it freely.'
|
||||
days-before-issue-stale: 60 # default number
|
||||
days-before-issue-close: 7 # default number
|
||||
days-before-pr-stale: 60 # default number
|
||||
days-before-pr-close: 7 # default number
|
||||
stale-issue-label: 'stale'
|
||||
stale-pr-label: 'stale'
|
||||
exempt-issue-labels: 'no-stale'
|
||||
exempt-pr-labels: 'no-stale'
|
||||
operations-per-run: 1000
|
||||
@@ -44,6 +44,7 @@ Preferential Optimization
|
||||
optuna_dashboard.preferential.create_study
|
||||
optuna_dashboard.preferential.load_study
|
||||
optuna_dashboard.preferential.PreferentialStudy
|
||||
optuna_dashboard.register_preference_feedback_component
|
||||
|
||||
Streamlit
|
||||
-----------------
|
||||
|
||||
+21
-18
@@ -1,11 +1,13 @@
|
||||
Tutorial: Human-in-the-loop Optimization
|
||||
========================================
|
||||
.. _tutorial-hitl-objective-form-widgets:
|
||||
|
||||
Tutorial: Human-in-the-loop Optimization using Objective Form Widgets
|
||||
=====================================================================
|
||||
|
||||
.. image:: ./images/hitl1.png
|
||||
|
||||
In tasks involving image generation, natural language, or speech synthesis, evaluating results mechanically can be tough, and human evaluation becomes crucial. Until now, managing such tasks with Optuna has been challenging. However, the introduction of Optuna Dashboard enables humans and optimization algorithms to work interactively and execute the optimization process.
|
||||
|
||||
In this tutorial, we will explain how to optimize hyperparameters to generate a simple image using Optuna Dashboard. While the tutorial focuses on a simple task, the same approach can be applied to for instance optimize more complex images, natural language, and speech.
|
||||
In this tutorial, we will explain how to optimize hyperparameters to generate a simple image using Optuna Dashboard. While the tutorial focuses on a simple task, the same approach can be applied to for instance optimize more complex images, natural language, and speech.
|
||||
|
||||
The tutorial is organized as follows:
|
||||
|
||||
@@ -93,11 +95,11 @@ Given the above system, we carry out HITL optimization as follows:
|
||||
Environment setup
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
To run `the script <https://github.com/optuna/optuna-dashboard/blob/main/examples/hitl/main.py>`_ used in this tutorial, you need to install two libraries:
|
||||
To run `the script <https://github.com/optuna/optuna-dashboard/blob/main/examples/hitl/main.py>`_ used in this tutorial, you need to install following libraries:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pip install "optuna>=3.2.0" "optuna-dashboard>=0.10.0" pillow
|
||||
$ pip install "optuna>=3.3.0" "optuna-dashboard>=0.12.0" pillow
|
||||
|
||||
|
||||
You will use SQLite for the storage backend in this tutorial. Ensure that the following library is installed:
|
||||
@@ -179,7 +181,9 @@ Let’s walk through the script we used for the optimization.
|
||||
.. code-block:: python
|
||||
:linenos:
|
||||
|
||||
def suggest_and_generate_image(study: optuna.Study, artifact_backend: FileSystemBackend) -> None:
|
||||
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)
|
||||
@@ -192,7 +196,7 @@ Let’s walk through the script we used for the optimization.
|
||||
image.save(image_path)
|
||||
|
||||
# 3. Upload Artifact
|
||||
artifact_id = upload_artifact(artifact_backend, trial, image_path)
|
||||
artifact_id = upload_artifact(trial, image_path, artifact_store)
|
||||
artifact_path = get_artifact_path(trial, artifact_id)
|
||||
|
||||
# 4. Save Note
|
||||
@@ -205,12 +209,12 @@ Let’s walk through the script we used for the optimization.
|
||||
)
|
||||
save_note(trial, note)
|
||||
|
||||
In the ``suggest_and_generate_image`` function, a new Trial is obtained and new hyperparameters are suggested for that Trial. Based on those hyperparameters, an RGB image is generated as an artifact. The generated image is then uploaded to the Artifact Storage of the Optuna Dashboard, and the image is also displayed in the Dashboard's Note. For more information on how to use the Note feature, please refer to the API Reference of :func:`~optuna_dashboard.save_note`.
|
||||
In the ``suggest_and_generate_image`` function, a new Trial is obtained and new hyperparameters are suggested for that Trial. Based on those hyperparameters, an RGB image is generated as an artifact. The generated image is then uploaded to the Artifact Store of the Optuna, and the image is also displayed in the Dashboard's Note. For more information on how to use the Note feature, please refer to the API Reference of :func:`~optuna_dashboard.save_note`.
|
||||
|
||||
.. code-block:: python
|
||||
:linenos:
|
||||
|
||||
def start_optimization(artifact_backend: FileSystemBackend) -> NoReturn:
|
||||
def start_optimization(artifact_store: FileSystemArtifactStore) -> NoReturn:
|
||||
# 1. Create Study
|
||||
study = optuna.create_study(
|
||||
study_name="Human-in-the-loop Optimization",
|
||||
@@ -218,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,
|
||||
@@ -234,15 +238,14 @@ In the ``suggest_and_generate_image`` function, a new Trial is obtained and new
|
||||
],
|
||||
)
|
||||
|
||||
# 4. Start Optimization
|
||||
# 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_backend)
|
||||
|
||||
suggest_and_generate_image(study, artifact_store)
|
||||
|
||||
The function ``start_optimization`` defines our loop for HITL optimization to generate an image resembling a sunset color.
|
||||
|
||||
@@ -256,10 +259,10 @@ 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_backend = FileSystemBackend(base_path=artifact_path)
|
||||
artifact_store = FileSystemArtifactStore(artifact_path)
|
||||
|
||||
if not os.path.exists(artifact_path):
|
||||
os.mkdir(artifact_path)
|
||||
@@ -268,11 +271,11 @@ The function ``start_optimization`` defines our loop for HITL optimization to ge
|
||||
os.mkdir(tmp_path)
|
||||
|
||||
# 2. Run optimize loop
|
||||
start_optimization(artifact_backend)
|
||||
start_optimization(artifact_store)
|
||||
|
||||
In the ``main`` function, at first, the locations of the Artifact Store is set.
|
||||
|
||||
* At #1, the :class:`~optuna_dashboard.FileSystemBackend` is created, which is one of the Artifact Storage options used in the Optuna Dashboard. Artifact Storage is used to store artifacts (data, files, etc.) generated during Optuna trials. For more information, please refer to the API Reference.
|
||||
* At #1, the `FileSystemArtifactStore <https://optuna.readthedocs.io/en/stable/reference/generated/optuna.artifacts.FileSystemArtifactStore.html>`_ is created, which is one of the Artifact Store options used in the Optuna. Artifact Store is used to store artifacts (data, files, etc.) generated during Optuna trials. For more information, please refer to the API Reference.
|
||||
* At #2, `start_optimization()` function, which is described above, is called.
|
||||
|
||||
After that, two folders are created, artifact and tmp, and then ``start_optimization`` function is called to start the HITL optimization using Optuna.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -5,3 +5,4 @@ Tutorials
|
||||
:maxdepth: 1
|
||||
|
||||
hitl
|
||||
preferential-optimization
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
Tutorial: Preferential Optimization
|
||||
===================================
|
||||
|
||||
What is Preferential Optimization?
|
||||
----------------------------------
|
||||
|
||||
Preferential optimization is a method for optimizing hyperparameters, focusing of human preferences, by determining which trial is superior when comparing a pair.
|
||||
It differs from `human-in-the-loop optimization utilizing objective form widgets <tutorial-hitl-objective-form-widgets>`_,
|
||||
which relies on absolute evaluations, as it significantly reduces fluctuations in evaluators' criteria, thus ensuring more consistent results.
|
||||
|
||||
In this tutorial, we'll interactively optimize RGB values to generate a color resembling a "sunset hue",
|
||||
aligining with the problem setting in `this tutorial <tutorial-hitl-objective-form-widgets>`_.
|
||||
Familiarity with the tutorial ob objective form widgets may enhance your understanding.
|
||||
|
||||
How to Run Preferential Optimization
|
||||
------------------------------------
|
||||
|
||||
In preferential optimization, two programs run concurrently: `generator.py`_ performing parameter sampling and image generation,
|
||||
and the Optuna Dashboard, offering a user interface for human evaluation.
|
||||
|
||||
.. figure:: ./images/preferential-optimization/system-architecture.png
|
||||
:alt: System Architecture
|
||||
:align: center
|
||||
:width: 800px
|
||||
|
||||
First, ensure the necessary packages are installed by executing the following command in your terminal:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pip install "optuna>=3.3.0" "optuna-dashboard>=0.13.0b1" pillow botorch
|
||||
|
||||
Next, execute the Python script, copied from `generator.py`_.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ python generator.py
|
||||
|
||||
Then, launch Optuna Dashboard in a separate process using the following command.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ optuna-dashboard sqlite:///example.db --artifact-dir ./artifact
|
||||
|
||||
Here, the storage is configured to ``sqlite:///example.db`` to retain Optuna's trial history,
|
||||
and ``--artifact-dir ./artifact`` is specified to store the artifacts (output images).
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
Listening on http://127.0.0.1:8080/
|
||||
Hit Ctrl-C to quit.
|
||||
|
||||
Upon executing the command, a message like the above will appear.
|
||||
Open `http://127.0.0.1:8080/dashboard/ <http://127.0.0.1:8080/dashboard/>`_ in your browser to view the Optuna Dashboard:
|
||||
|
||||
.. figure:: ./images/preferential-optimization/anim.gif
|
||||
:alt: GIF animation for preferential optimization
|
||||
:align: center
|
||||
:width: 800px
|
||||
|
||||
Select the least sunset-like color from four trials to record human preferences.
|
||||
|
||||
|
||||
Script Explanation
|
||||
------------------
|
||||
|
||||
First, we specify the SQLite database URL and initialize the artifact store to house the images produced during the trial.
|
||||
|
||||
.. code-block:: python
|
||||
:linenos:
|
||||
|
||||
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)
|
||||
|
||||
Within the ``main()`` function, creating dedicated ``Study`` and ``Sampler`` objects since preferential optimization relies on the comparison results between trials, lacking absolute evaluation values for each one.
|
||||
|
||||
Then, the component to be displayed on the human feedback pages is registered via :func:`~optuna_dashboard.register_preference_feedback_component`.
|
||||
The generated images are uploaded to the artifact store, and their ``artifact_id`` is stored in the trial user attribute (e.g., ``trial.user_attrs["rgb_image"]``),
|
||||
enabling the Optuna Dashboard to display images on the evaluation feedback page.
|
||||
|
||||
.. code-block:: python
|
||||
:linenos:
|
||||
|
||||
from optuna_dashboard import register_preference_feedback_component
|
||||
from optuna_dashboard.preferential import create_study
|
||||
from optuna_dashboard.preferential.samplers.gp import PreferentialGPSampler
|
||||
|
||||
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)
|
||||
|
||||
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``.
|
||||
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`.
|
||||
|
||||
.. code-block:: python
|
||||
:linenos:
|
||||
|
||||
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()
|
||||
# Ask new parameters
|
||||
r = trial.suggest_int("r", 0, 255)
|
||||
g = trial.suggest_int("g", 0, 255)
|
||||
b = trial.suggest_int("b", 0, 255)
|
||||
|
||||
# Generate an 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)
|
||||
|
||||
# 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)
|
||||
|
||||
.. _generator.py: https://github.com/optuna/optuna-dashboard/blob/main/examples/preferential-optimization/generator.py
|
||||
+10
-8
@@ -4,17 +4,19 @@ 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 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) -> None:
|
||||
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)
|
||||
@@ -27,7 +29,7 @@ def suggest_and_generate_image(study: optuna.Study, artifact_backend: FileSystem
|
||||
image.save(image_path)
|
||||
|
||||
# 3. Upload Artifact
|
||||
artifact_id = upload_artifact(artifact_backend, trial, image_path)
|
||||
artifact_id = upload_artifact(trial, image_path, artifact_store)
|
||||
artifact_path = get_artifact_path(trial, artifact_id)
|
||||
|
||||
# 4. Save Note
|
||||
@@ -41,7 +43,7 @@ def suggest_and_generate_image(study: optuna.Study, artifact_backend: FileSystem
|
||||
save_note(trial, note)
|
||||
|
||||
|
||||
def start_optimization(artifact_backend: FileSystemBackend) -> NoReturn:
|
||||
def start_optimization(artifact_store: FileSystemArtifactStore) -> NoReturn:
|
||||
# 1. Create Study
|
||||
study = optuna.create_study(
|
||||
study_name="Human-in-the-loop Optimization",
|
||||
@@ -72,7 +74,7 @@ def start_optimization(artifact_backend: FileSystemBackend) -> NoReturn:
|
||||
if len(running_trials) >= n_batch:
|
||||
time.sleep(1) # Avoid busy-loop
|
||||
continue
|
||||
suggest_and_generate_image(study, artifact_backend)
|
||||
suggest_and_generate_image(study, artifact_store)
|
||||
|
||||
|
||||
def main() -> NoReturn:
|
||||
@@ -80,7 +82,7 @@ def main() -> NoReturn:
|
||||
|
||||
# 1. Create Artifact Store
|
||||
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
|
||||
artifact_backend = FileSystemBackend(base_path=artifact_path)
|
||||
artifact_store = FileSystemArtifactStore(artifact_path)
|
||||
|
||||
if not os.path.exists(artifact_path):
|
||||
os.mkdir(artifact_path)
|
||||
@@ -89,7 +91,7 @@ def main() -> NoReturn:
|
||||
os.mkdir(tmp_path)
|
||||
|
||||
# 2. Run optimize loop
|
||||
start_optimization(artifact_backend)
|
||||
start_optimization(artifact_store)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,14 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
from typing import NoReturn
|
||||
|
||||
from optuna_dashboard import save_note
|
||||
from optuna_dashboard.artifact import get_artifact_path
|
||||
from optuna_dashboard.artifact import upload_artifact
|
||||
from optuna_dashboard.artifact.file_system import FileSystemBackend
|
||||
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
|
||||
@@ -17,22 +15,26 @@ from PIL import Image
|
||||
|
||||
STORAGE_URL = "sqlite:///example.db"
|
||||
artifact_path = os.path.join(os.path.dirname(__file__), "artifact")
|
||||
artifact_backend = FileSystemBackend(base_path=artifact_path)
|
||||
artifact_store = FileSystemArtifactStore(base_path=artifact_path)
|
||||
os.makedirs(artifact_path, exist_ok=True)
|
||||
|
||||
|
||||
def main() -> NoReturn:
|
||||
study = create_study(
|
||||
n_generate=5,
|
||||
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 n_comparison "best" trials (that are not reported bad) exists,
|
||||
# If study.should_generate() returns False,
|
||||
# the generator waits for human evaluation.
|
||||
if not study.should_generate():
|
||||
time.sleep(0.1) # Avoid busy-loop
|
||||
@@ -49,20 +51,9 @@ def main() -> NoReturn:
|
||||
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)
|
||||
print("RGB:", (r, g, b))
|
||||
|
||||
# 4. Save Note
|
||||
note = textwrap.dedent(
|
||||
f"""\
|
||||
})
|
||||
|
||||
(R, G, B) = ({r}, {g}, {b})
|
||||
"""
|
||||
)
|
||||
save_note(trial, note)
|
||||
# 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__":
|
||||
|
||||
@@ -14,6 +14,7 @@ from ._form_widget import TextInputWidget # noqa
|
||||
from ._named_objectives import set_objective_names # noqa
|
||||
from ._note import get_note # noqa
|
||||
from ._note import save_note # noqa
|
||||
from ._preference_setting import register_preference_feedback_component # noqa
|
||||
|
||||
|
||||
__version__ = "0.13.0b1"
|
||||
|
||||
@@ -28,8 +28,12 @@ from ._cached_extra_study_property import get_cached_extra_study_property
|
||||
from ._custom_plot_data import get_plotly_graph_objects
|
||||
from ._importance import get_param_importance_from_trials_cache
|
||||
from ._pareto_front import get_pareto_front_trials
|
||||
from ._preference_setting import _register_preference_feedback_component
|
||||
from ._preferential_history import NewHistory
|
||||
from ._preferential_history import PreferenceHistoryNotFound
|
||||
from ._preferential_history import remove_history
|
||||
from ._preferential_history import report_history
|
||||
from ._preferential_history import restore_history
|
||||
from ._rdb_migration import register_rdb_migration_route
|
||||
from ._serializer import serialize_study_detail
|
||||
from ._serializer import serialize_study_summary
|
||||
@@ -43,6 +47,7 @@ from .artifact._backend import register_artifact_route
|
||||
from .artifact._backend_to_store import to_artifact_store
|
||||
from .preferential._study import _SYSTEM_ATTR_PREFERENTIAL_STUDY
|
||||
from .preferential._study import get_best_trials as get_best_preferential_trials
|
||||
from .preferential._system_attrs import get_skipped_trial_ids
|
||||
from .preferential._system_attrs import report_skip
|
||||
|
||||
|
||||
@@ -217,6 +222,8 @@ def create_app(
|
||||
) = get_cached_extra_study_property(study_id, trials)
|
||||
|
||||
plotly_graph_objects = get_plotly_graph_objects(system_attrs)
|
||||
skipped_trial_ids = get_skipped_trial_ids(system_attrs)
|
||||
skipped_trial_numbers = [t.number for t in trials if t._trial_id in skipped_trial_ids]
|
||||
return serialize_study_detail(
|
||||
summary,
|
||||
best_trials,
|
||||
@@ -226,6 +233,7 @@ def create_app(
|
||||
union_user_attrs,
|
||||
has_intermediate_values,
|
||||
plotly_graph_objects,
|
||||
skipped_trial_numbers,
|
||||
)
|
||||
|
||||
@app.get("/api/studies/<study_id:int>/param_importances")
|
||||
@@ -306,6 +314,52 @@ def create_app(
|
||||
response.status = 204
|
||||
return {}
|
||||
|
||||
@app.put("/api/studies/<study_id:int>/preference_feedback_component")
|
||||
@json_api_view
|
||||
def put_preference_feedback_component(study_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
component_type = request.json.get("output_type", "")
|
||||
artifact_key = request.json.get("artifact_key", None)
|
||||
except ValueError:
|
||||
response.status = 400
|
||||
return {"reason": "invalid request."}
|
||||
if component_type not in ["note", "artifact"]:
|
||||
response.status = 400
|
||||
return {"reason": "component_type must be either 'note' or 'artifact'."}
|
||||
|
||||
_register_preference_feedback_component(
|
||||
study_id=study_id,
|
||||
storage=storage,
|
||||
component_type=component_type,
|
||||
artifact_key=artifact_key,
|
||||
)
|
||||
response.status = 204
|
||||
return {}
|
||||
|
||||
@app.delete("/api/studies/<study_id:int>/preference/<history_id>")
|
||||
@json_api_view
|
||||
def remove_preference(study_id: int, history_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
remove_history(study_id, storage, history_id)
|
||||
except PreferenceHistoryNotFound:
|
||||
response.status = 404
|
||||
return {"reason": f"history_id={history_id} is not found"}
|
||||
|
||||
response.status = 204
|
||||
return {}
|
||||
|
||||
@app.post("/api/studies/<study_id:int>/preference/<history_id>")
|
||||
@json_api_view
|
||||
def restore_preference(study_id: int, history_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
restore_history(study_id, storage, history_id)
|
||||
except PreferenceHistoryNotFound:
|
||||
response.status = 404
|
||||
return {"reason": f"history_id={history_id} is not found"}
|
||||
|
||||
response.status = 204
|
||||
return {}
|
||||
|
||||
@app.post("/api/trials/<trial_id:int>/tell")
|
||||
@json_api_view
|
||||
def tell_trial(trial_id: int) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from optuna.storages import BaseStorage
|
||||
|
||||
from .preferential._study import PreferentialStudy
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
OUTPUT_COMPONENT_TYPE = Literal["note", "artifact"]
|
||||
|
||||
_SYSTEM_ATTR_FEEDBACK_COMPONENT = "preference:component"
|
||||
|
||||
|
||||
def _register_preference_feedback_component(
|
||||
study_id: int,
|
||||
storage: BaseStorage,
|
||||
component_type: OUTPUT_COMPONENT_TYPE,
|
||||
artifact_key: str | None = None,
|
||||
) -> None:
|
||||
value: dict[str, Any] = {"output_type": component_type}
|
||||
if artifact_key is not None:
|
||||
value["artifact_key"] = artifact_key
|
||||
storage.set_study_system_attr(
|
||||
study_id=study_id,
|
||||
key=_SYSTEM_ATTR_FEEDBACK_COMPONENT,
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
def register_preference_feedback_component(
|
||||
study: PreferentialStudy,
|
||||
component_type: OUTPUT_COMPONENT_TYPE,
|
||||
artifact_key: str | None = None,
|
||||
) -> None:
|
||||
"""Register a preference feedback component to the study.
|
||||
|
||||
With this feature, you can change the component, displayed on the
|
||||
human feedback pages. By default, the Markdown note (``component_type="note"``)
|
||||
is displayed. If you specify ``component_type="artifact"``, the viewer for the
|
||||
specified artifact file will be displayed.
|
||||
|
||||
Args:
|
||||
study:
|
||||
The study to register the preference feedback component.
|
||||
component_type:
|
||||
The component type, displayed on the human feedback pages
|
||||
(default: ``"note"``).
|
||||
user_attr_artifact_key:
|
||||
This option is required when the ``component_type`` is ``"artifact"``.
|
||||
The user attribute, which is specified this field, must contain the
|
||||
``artifact``id you want to display on the human feedback page.
|
||||
"""
|
||||
if component_type == "artifact":
|
||||
assert (
|
||||
artifact_key is not None
|
||||
), "artifact_key must be specified when component_type is Artifact"
|
||||
|
||||
_register_preference_feedback_component(
|
||||
study_id=study._study._study_id,
|
||||
storage=study._study._storage,
|
||||
component_type=component_type,
|
||||
artifact_key=artifact_key,
|
||||
)
|
||||
@@ -4,10 +4,10 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
|
||||
from optuna.storages import BaseStorage
|
||||
|
||||
from .preferential._system_attrs import _SYSTEM_ATTR_PREFIX_PREFERENCE
|
||||
from .preferential._system_attrs import report_preferences
|
||||
|
||||
|
||||
@@ -23,13 +23,24 @@ if TYPE_CHECKING:
|
||||
{
|
||||
"mode": FeedbackMode,
|
||||
"id": str,
|
||||
"preference_id": str,
|
||||
"timestamp": str,
|
||||
"candidates": list[int],
|
||||
"clicked": int,
|
||||
"preferences": list[tuple[int, int]],
|
||||
},
|
||||
)
|
||||
History = ChooseWorstHistory
|
||||
SerializedHistory = TypedDict(
|
||||
"SerializedHistory",
|
||||
{
|
||||
"history": History,
|
||||
"is_removed": bool,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PreferenceHistoryNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -43,14 +54,14 @@ def report_history(
|
||||
study_id: int,
|
||||
storage: BaseStorage,
|
||||
input_data: NewHistory,
|
||||
) -> None:
|
||||
) -> str:
|
||||
preferences = []
|
||||
# TODO(moririn): Use TypeGuard after adding other history types.
|
||||
if input_data.mode == "ChooseWorst":
|
||||
preferences = [
|
||||
(best, input_data.clicked)
|
||||
for best in input_data.candidates
|
||||
if best != input_data.clicked
|
||||
(better, input_data.clicked)
|
||||
for better in input_data.candidates
|
||||
if better != input_data.clicked
|
||||
]
|
||||
else:
|
||||
assert False, f"Unknown data: {input_data}"
|
||||
@@ -60,21 +71,40 @@ def report_history(
|
||||
storage=storage,
|
||||
preferences=preferences,
|
||||
)
|
||||
history_id = str(uuid.uuid4())
|
||||
|
||||
if input_data.mode == "ChooseWorst":
|
||||
history: ChooseWorstHistory = {
|
||||
"mode": "ChooseWorst",
|
||||
"id": history_id,
|
||||
"preference_id": preference_id,
|
||||
"id": preference_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"candidates": input_data.candidates,
|
||||
"clicked": input_data.clicked,
|
||||
"preferences": preferences,
|
||||
}
|
||||
|
||||
key = _SYSTEM_ATTR_PREFIX_HISTORY + history_id
|
||||
key = _SYSTEM_ATTR_PREFIX_HISTORY + preference_id
|
||||
storage.set_study_system_attr(
|
||||
study_id=study_id,
|
||||
key=key,
|
||||
value=json.dumps(history),
|
||||
)
|
||||
return preference_id
|
||||
|
||||
|
||||
def remove_history(study_id: int, storage: BaseStorage, history_id: str) -> None:
|
||||
system_attrs = storage.get_study_system_attrs(study_id)
|
||||
history_key = _SYSTEM_ATTR_PREFIX_HISTORY + history_id
|
||||
if history_key not in system_attrs:
|
||||
raise PreferenceHistoryNotFound
|
||||
storage.set_study_system_attr(study_id, _SYSTEM_ATTR_PREFIX_PREFERENCE + history_id, [])
|
||||
|
||||
|
||||
def restore_history(study_id: int, storage: BaseStorage, history_id: str) -> None:
|
||||
system_attrs = storage.get_study_system_attrs(study_id)
|
||||
history_key = _SYSTEM_ATTR_PREFIX_HISTORY + history_id
|
||||
if history_key not in system_attrs:
|
||||
raise PreferenceHistoryNotFound
|
||||
history: History = json.loads(system_attrs.get(history_key, ""))
|
||||
storage.set_study_system_attr(
|
||||
study_id, _SYSTEM_ATTR_PREFIX_PREFERENCE + history_id, history["preferences"]
|
||||
)
|
||||
|
||||
@@ -15,18 +15,21 @@ from optuna.trial import FrozenTrial
|
||||
from . import _note as note
|
||||
from ._form_widget import get_form_widgets_json
|
||||
from ._named_objectives import get_objective_names
|
||||
from ._preference_setting import _SYSTEM_ATTR_FEEDBACK_COMPONENT
|
||||
from ._preferential_history import _SYSTEM_ATTR_PREFIX_HISTORY
|
||||
from .artifact._backend import list_study_artifacts
|
||||
from .artifact._backend import list_trial_artifacts
|
||||
from .preferential._study import _SYSTEM_ATTR_PREFERENTIAL_STUDY
|
||||
from .preferential._system_attrs import get_preferences
|
||||
from .preferential._system_attrs import is_preference_removed
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
from typing import TypedDict
|
||||
|
||||
from ._preferential_history import ChooseWorstHistory
|
||||
from ._preferential_history import History
|
||||
from ._preferential_history import SerializedHistory
|
||||
|
||||
Attribute = TypedDict(
|
||||
"Attribute",
|
||||
@@ -134,6 +137,7 @@ def serialize_study_detail(
|
||||
union_user_attrs: list[tuple[str, bool]],
|
||||
has_intermediate_values: bool,
|
||||
plotly_graph_objects: dict[str, str],
|
||||
skipped_trial_numbers: list[int],
|
||||
) -> dict[str, Any]:
|
||||
serialized: dict[str, Any] = {
|
||||
"name": summary.study_name,
|
||||
@@ -163,8 +167,16 @@ def serialize_study_detail(
|
||||
form_widgets = get_form_widgets_json(system_attrs)
|
||||
if form_widgets:
|
||||
serialized["form_widgets"] = form_widgets
|
||||
serialized["feedback_component_type"] = system_attrs.get(
|
||||
_SYSTEM_ATTR_FEEDBACK_COMPONENT,
|
||||
{
|
||||
"output_type": "note",
|
||||
},
|
||||
)
|
||||
if serialized["is_preferential"]:
|
||||
serialized["preference_history"] = serialize_preference_history(system_attrs)
|
||||
serialized["preferences"] = get_preferences(system_attrs)
|
||||
serialized["skipped_trial_numbers"] = skipped_trial_numbers
|
||||
serialized["plotly_graph_objects"] = [
|
||||
{"id": id_, "graph_object": graph_object}
|
||||
for id_, graph_object in plotly_graph_objects.items()
|
||||
@@ -174,24 +186,29 @@ def serialize_study_detail(
|
||||
|
||||
def serialize_preference_history(
|
||||
system_attrs: dict[str, Any],
|
||||
) -> list[History]:
|
||||
histories: list[History] = []
|
||||
) -> list[SerializedHistory]:
|
||||
histories: list[SerializedHistory] = []
|
||||
for k, v in system_attrs.items():
|
||||
if not k.startswith(_SYSTEM_ATTR_PREFIX_HISTORY):
|
||||
continue
|
||||
choice: dict[str, Any] = json.loads(v)
|
||||
if choice["mode"] == "ChooseWorst":
|
||||
history: ChooseWorstHistory = {
|
||||
history: History = {
|
||||
"mode": "ChooseWorst",
|
||||
"id": choice["id"],
|
||||
"preference_id": choice["preference_id"],
|
||||
"timestamp": choice["timestamp"],
|
||||
"candidates": choice["candidates"],
|
||||
"clicked": choice["clicked"],
|
||||
"preferences": choice["preferences"],
|
||||
}
|
||||
histories.append(history)
|
||||
histories.append(
|
||||
{
|
||||
"history": history,
|
||||
"is_removed": is_preference_removed(system_attrs, choice["id"]),
|
||||
}
|
||||
)
|
||||
|
||||
histories.sort(key=lambda c: datetime.fromisoformat(c["timestamp"]))
|
||||
histories.sort(key=lambda c: datetime.fromisoformat(c["history"]["timestamp"]))
|
||||
return histories
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ def get_preferences(study_system_attrs: dict[str, Any]) -> list[tuple[int, int]]
|
||||
return preferences
|
||||
|
||||
|
||||
def is_preference_removed(study_system_attrs: dict[str, Any], preference_id: str) -> bool:
|
||||
key = _SYSTEM_ATTR_PREFIX_PREFERENCE + preference_id
|
||||
preference = study_system_attrs.get(key, [])
|
||||
return len(preference) == 0
|
||||
|
||||
|
||||
def report_skip(
|
||||
study_id: int,
|
||||
trial_id: int,
|
||||
|
||||
@@ -1,155 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
from math import erfc
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import cast
|
||||
|
||||
from botorch.acquisition.analytic import LogExpectedImprovement
|
||||
from botorch.models.gpytorch import GPyTorchModel
|
||||
from botorch.optim import optimize_acqf
|
||||
import botorch.acquisition.analytic
|
||||
import botorch.models.model
|
||||
import botorch.optim
|
||||
import botorch.posteriors.gpytorch
|
||||
import gpytorch.constraints
|
||||
import gpytorch.kernels
|
||||
import gpytorch.likelihoods.gaussian_likelihood
|
||||
from gpytorch.likelihoods.gaussian_likelihood import GaussianLikelihood
|
||||
from gpytorch.likelihoods.gaussian_likelihood import Interval
|
||||
from gpytorch.likelihoods.gaussian_likelihood import Prior
|
||||
from gpytorch.models.exact_gp import ExactGP
|
||||
import gpytorch.module
|
||||
from linear_operator.operators import DiagLinearOperator
|
||||
from linear_operator.operators import LinearOperator
|
||||
from linear_operator.utils.errors import NotPSDError
|
||||
import numpy as np
|
||||
import optuna
|
||||
from optuna import distributions
|
||||
from optuna import Study
|
||||
from optuna._transform import _SearchSpaceTransform
|
||||
from optuna.distributions import BaseDistribution
|
||||
from optuna.search_space import IntersectionSearchSpace
|
||||
from optuna.trial import FrozenTrial
|
||||
import pyro
|
||||
import pyro.infer.autoguide
|
||||
import pyro.infer.mcmc
|
||||
from scipy.special import erfcinv
|
||||
import optuna._transform
|
||||
from optuna.distributions import CategoricalDistribution
|
||||
import torch
|
||||
from torch import Tensor
|
||||
|
||||
from .._system_attrs import get_preferences
|
||||
|
||||
|
||||
class _WeightedGaussianLikelihood(GaussianLikelihood):
|
||||
def __init__(
|
||||
self,
|
||||
weights: torch.Tensor | None = None,
|
||||
noise_prior: Prior | None = None,
|
||||
noise_constraint: Interval | None = None,
|
||||
batch_shape: torch.Size = torch.Size(),
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
noise_prior=noise_prior,
|
||||
noise_constraint=noise_constraint,
|
||||
batch_shape=batch_shape,
|
||||
**kwargs,
|
||||
)
|
||||
self.weights = weights
|
||||
|
||||
def _shaped_noise_covar(
|
||||
self, base_shape: torch.Size, *params: Any, **kwargs: Any
|
||||
) -> Tensor | LinearOperator:
|
||||
assert self.weights is not None
|
||||
assert base_shape[-1] == self.weights.shape[-1]
|
||||
return DiagLinearOperator(1.0 / self.weights) * super()._shaped_noise_covar(
|
||||
base_shape, *params, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _sample_y(
|
||||
preferences: np.ndarray,
|
||||
cov_X_X: np.ndarray,
|
||||
obs_noise_var: float,
|
||||
cycles: int,
|
||||
initial_sample: np.ndarray,
|
||||
rng: np.random.RandomState,
|
||||
) -> np.ndarray:
|
||||
# TODO: Refactor and write tests for this function.
|
||||
|
||||
N = cov_X_X.shape[0]
|
||||
M = len(preferences)
|
||||
cov_X_X = cov_X_X + np.eye(N) * 1e-6 # Add jitter
|
||||
cov_X_X_chol = np.linalg.cholesky(cov_X_X)
|
||||
cov_X_X_inv = np.linalg.inv(cov_X_X)
|
||||
|
||||
# (sI + A K A^T)^-1 = s^-1 I - s^-2 A(K^-1 + s^-1 A^T A)^-1 A^T
|
||||
|
||||
schur = cov_X_X_inv.copy()
|
||||
np.add.at(schur, (preferences[:, 0], preferences[:, 0]), 1.0 / (2 * obs_noise_var))
|
||||
np.add.at(schur, (preferences[:, 1], preferences[:, 1]), 1.0 / (2 * obs_noise_var))
|
||||
np.add.at(schur, (preferences[:, 0], preferences[:, 1]), -1.0 / (2 * obs_noise_var))
|
||||
np.add.at(schur, (preferences[:, 1], preferences[:, 0]), -1.0 / (2 * obs_noise_var))
|
||||
idx_M = np.arange(M)
|
||||
|
||||
schur_inv = np.linalg.inv(schur)
|
||||
|
||||
cov_diff_inv = schur_inv[:, preferences[:, 0]] - schur_inv[:, preferences[:, 1]]
|
||||
cov_diff_inv = cov_diff_inv[preferences[:, 0], :] - cov_diff_inv[preferences[:, 1], :]
|
||||
cov_diff_inv *= -1 / (2 * obs_noise_var) ** 2
|
||||
cov_diff_inv[idx_M, idx_M] += 1.0 / (2 * obs_noise_var)
|
||||
|
||||
diffs = _orthants_MVN_Gibbs_sampling(
|
||||
cov_diff_inv,
|
||||
cycles=cycles,
|
||||
initial_sample=initial_sample[:, 0] - initial_sample[:, 1],
|
||||
rng=rng,
|
||||
)[-1]
|
||||
|
||||
random_ys = (cov_X_X_chol @ rng.randn(N))[preferences] + np.sqrt(obs_noise_var) * rng.randn(
|
||||
M, 2
|
||||
)
|
||||
errors = diffs - (random_ys[:, 0] - random_ys[:, 1])
|
||||
cov_diff_inv_errors = cov_diff_inv @ errors
|
||||
|
||||
AT_cov_diff_inv_errors = np.zeros((N,))
|
||||
np.add.at(AT_cov_diff_inv_errors, preferences[:, 0], cov_diff_inv_errors)
|
||||
np.add.at(AT_cov_diff_inv_errors, preferences[:, 1], -cov_diff_inv_errors)
|
||||
|
||||
return (
|
||||
random_ys
|
||||
+ (cov_X_X @ AT_cov_diff_inv_errors)[preferences]
|
||||
+ obs_noise_var * np.array([[1, -1]]) * cov_diff_inv_errors[:, None]
|
||||
)
|
||||
|
||||
|
||||
_SQRT2 = math.sqrt(2)
|
||||
|
||||
|
||||
def _orthants_MVN_Gibbs_sampling(
|
||||
cov_inv: np.ndarray,
|
||||
cycles: int,
|
||||
initial_sample: np.ndarray,
|
||||
rng: np.random.RandomState,
|
||||
) -> np.ndarray:
|
||||
def _orthants_MVN_Gibbs_sampling(cov_inv: Tensor, cycles: int, initial_sample: Tensor) -> Tensor:
|
||||
dim = cov_inv.shape[0]
|
||||
assert cov_inv.shape == (dim, dim)
|
||||
|
||||
if initial_sample is None:
|
||||
sample_chain = np.zeros(dim)
|
||||
else:
|
||||
sample_chain = initial_sample
|
||||
sample_chain = initial_sample
|
||||
conditional_std = torch.rsqrt(torch.diag(cov_inv))
|
||||
scaled_cov_inv = cov_inv / torch.diag(cov_inv)[:, None]
|
||||
|
||||
conditional_std = 1 / np.sqrt(np.diag(cov_inv))
|
||||
|
||||
scaled_cov_inv = cov_inv / np.c_[np.diag(cov_inv)]
|
||||
|
||||
out = np.empty((cycles + 1, dim))
|
||||
out = torch.empty((cycles + 1, dim), dtype=torch.float64)
|
||||
out[0, :] = sample_chain
|
||||
|
||||
for i in range(cycles):
|
||||
for j in range(dim):
|
||||
conditional_mean = sample_chain[j] - scaled_cov_inv[j] @ sample_chain
|
||||
sample_chain[j] = (
|
||||
_one_side_trunc_norm_sampling(
|
||||
lower=-conditional_mean / conditional_std[j], rng=rng
|
||||
)
|
||||
_one_side_trunc_norm_sampling(lower=-conditional_mean / conditional_std[j])
|
||||
* conditional_std[j]
|
||||
+ conditional_mean
|
||||
)
|
||||
@@ -158,144 +47,234 @@ def _orthants_MVN_Gibbs_sampling(
|
||||
return out
|
||||
|
||||
|
||||
def _one_side_trunc_norm_sampling(lower: float, rng: np.random.RandomState) -> float:
|
||||
return erfcinv(rng.rand() * erfc(lower / _SQRT2)) * _SQRT2
|
||||
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
|
||||
|
||||
|
||||
class _PreferentialGP(GPyTorchModel, ExactGP):
|
||||
_num_outputs = 1
|
||||
_orthants_MVN_Gibbs_sampling_jit = torch.jit.script(_orthants_MVN_Gibbs_sampling)
|
||||
|
||||
|
||||
def _compute_cov_diff_diff_inv(preferences: Tensor, cov_x_x: Tensor, noise_var: Tensor) -> Tensor:
|
||||
N = cov_x_x.shape[0]
|
||||
M = preferences.shape[0]
|
||||
|
||||
# (sI + A K A^T)^-1 = s^-1 I - s^-2 A(K^-1 + s^-1 A^T A)^-1 A^T
|
||||
# (K^-1 + s^-1 A^T A)^-1 = K (I + s^-1 A^T A K)^-1 (To avoid computing K^-1)
|
||||
|
||||
I_plus_sinv_AT_A_K = torch.eye(N, dtype=torch.float64)
|
||||
A_K = cov_x_x[preferences[:, 0], :] - cov_x_x[preferences[:, 1], :]
|
||||
I_plus_sinv_AT_A_K.index_add_(0, preferences[:, 0], A_K * (1 / noise_var))
|
||||
I_plus_sinv_AT_A_K.index_add_(0, preferences[:, 1], A_K * (-1 / noise_var))
|
||||
schur_inv: Tensor = torch.linalg.solve(I_plus_sinv_AT_A_K, cov_x_x, left=False)
|
||||
cov_diff_diff_inv = schur_inv[:, preferences[:, 0]] - schur_inv[:, preferences[:, 1]]
|
||||
cov_diff_diff_inv = (
|
||||
cov_diff_diff_inv[preferences[:, 0], :] - cov_diff_diff_inv[preferences[:, 1], :]
|
||||
)
|
||||
cov_diff_diff_inv *= -1 / noise_var**2
|
||||
idx_M = torch.arange(M)
|
||||
cov_diff_diff_inv[idx_M, idx_M] += 1.0 / noise_var
|
||||
|
||||
return cov_diff_diff_inv
|
||||
|
||||
|
||||
class _SampledGP(botorch.models.model.Model):
|
||||
def __init__(
|
||||
self,
|
||||
kernel: gpytorch.kernels.Kernel,
|
||||
noise_prior: Prior | None = None,
|
||||
noise_constraint: Interval | None = None,
|
||||
kernel_func: Callable[[Tensor, Tensor], Tensor],
|
||||
x: Tensor,
|
||||
preferences: Tensor,
|
||||
noise_var: Tensor,
|
||||
diff: Tensor,
|
||||
) -> None:
|
||||
GPyTorchModel.__init__(self)
|
||||
likelihood = _WeightedGaussianLikelihood(
|
||||
noise_prior=noise_prior, noise_constraint=noise_constraint
|
||||
super().__init__()
|
||||
self.kernel_func = kernel_func
|
||||
self.x = x
|
||||
self.preferences = preferences
|
||||
self.diff = diff
|
||||
self.noise_var = noise_var
|
||||
self._cov_diff_diff_inv = _compute_cov_diff_diff_inv(
|
||||
preferences=preferences,
|
||||
cov_x_x=self.kernel_func(x, x),
|
||||
noise_var=noise_var,
|
||||
)
|
||||
ExactGP.__init__(self, train_inputs=None, train_targets=None, likelihood=likelihood)
|
||||
self.covar_module = kernel
|
||||
|
||||
self._last_params: dict[str, torch.Tensor] | None = None
|
||||
self._last_mcmc_step_size: float | None = None
|
||||
def posterior(
|
||||
self,
|
||||
X: Tensor,
|
||||
output_indices: list[int] | None = None,
|
||||
observation_noise: bool = False,
|
||||
posterior_transform: Any | None = None,
|
||||
**kwargs: Any,
|
||||
) -> botorch.posteriors.gpytorch.GPyTorchPosterior:
|
||||
assert posterior_transform is None
|
||||
assert output_indices is None
|
||||
assert self.x.shape[-1] == X.shape[-1]
|
||||
|
||||
def _pyro_model(self, train_x: torch.Tensor, train_y: torch.Tensor) -> None:
|
||||
# with gpytorch.settings.fast_computations(False, False, False):
|
||||
sampled_model = self.pyro_sample_from_prior()
|
||||
x_expanded = self.x.expand(X.shape[:-2] + (self.x.shape[-2], X.shape[-1]))
|
||||
|
||||
ys = sampled_model.likelihood(sampled_model.forward(train_x))
|
||||
cov_X_x = self.kernel_func(X, x_expanded)
|
||||
cov_X_diff = cov_X_x[..., self.preferences[:, 0]] - cov_X_x[..., self.preferences[:, 1]]
|
||||
|
||||
pyro.sample("y", ys, obs=train_y)
|
||||
mean = cov_X_diff @ (self._cov_diff_diff_inv @ self.diff)
|
||||
cov = self.kernel_func(X, X) - cov_X_diff @ self._cov_diff_diff_inv @ cov_X_diff.transpose(
|
||||
-1, -2
|
||||
)
|
||||
if observation_noise:
|
||||
idx = torch.arange(cov.shape[-1])
|
||||
cov[..., idx, idx] += self.noise_var
|
||||
|
||||
def fit_mcmc(
|
||||
self, X: torch.Tensor, preferences: torch.Tensor, cycles: int, rng: np.random.RandomState
|
||||
) -> None:
|
||||
return botorch.posteriors.gpytorch.GPyTorchPosterior(
|
||||
distribution=gpytorch.distributions.MultivariateNormal(
|
||||
mean=mean,
|
||||
covariance_matrix=cov,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def batch_shape(self) -> torch.Size:
|
||||
return torch.Size()
|
||||
|
||||
@property
|
||||
def num_outputs(self) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _truncnorm_mean_var_logz(alpha: Tensor) -> tuple[Tensor, Tensor, Tensor]:
|
||||
SQRT_HALF = math.sqrt(0.5)
|
||||
SQRT_HALF_PI = math.sqrt(0.5 * math.pi)
|
||||
logz = torch.special.log_ndtr(-alpha)
|
||||
mean = 1 / (SQRT_HALF_PI * torch.special.erfcx(alpha * SQRT_HALF))
|
||||
var = 1 - mean * (mean - alpha)
|
||||
return mean, var, logz
|
||||
|
||||
|
||||
def _orthants_MVN_EP(
|
||||
cov0: Tensor, preferences: Tensor, noise_var: Tensor, cycles: int
|
||||
) -> tuple[Tensor, Tensor, Tensor]:
|
||||
N = cov0.shape[0]
|
||||
M = preferences.shape[0]
|
||||
mu = torch.zeros(N, dtype=cov0.dtype)
|
||||
cov = cov0.clone()
|
||||
virtual_obs_a = [torch.tensor(0.0, dtype=cov0.dtype) for _ in range(M)]
|
||||
virtual_obs_b = [torch.tensor(0.0, dtype=cov0.dtype) for _ in range(M)]
|
||||
log_zs = torch.zeros(M, dtype=cov0.dtype)
|
||||
|
||||
for _ in range(cycles):
|
||||
for i in range(M):
|
||||
pref_i = preferences[i, :]
|
||||
mean1 = mu[pref_i[0]] - mu[pref_i[1]]
|
||||
Sxy = cov[pref_i[0]] - cov[pref_i[1]]
|
||||
var1 = Sxy[pref_i[0]] - Sxy[pref_i[1]]
|
||||
|
||||
r0 = (1 - var1 * virtual_obs_a[i]).reciprocal()
|
||||
var0 = var1 * r0
|
||||
mean0 = (mean1 + var1 * virtual_obs_b[i]) * r0
|
||||
|
||||
obs_var = var0 + noise_var
|
||||
obs_sigma = torch.sqrt(obs_var)
|
||||
alpha = -mean0 / torch.clamp_min(obs_sigma, min=1e-20)
|
||||
mean_norm, var_norm, logz = _truncnorm_mean_var_logz(alpha)
|
||||
|
||||
kalman_factor = var0 / torch.clamp_min(obs_var, min=1e-20)
|
||||
mean2 = mean0 + obs_sigma * mean_norm * kalman_factor
|
||||
var2 = kalman_factor * (noise_var + var_norm * var0)
|
||||
|
||||
var1_var2_inv = torch.clamp_min(var1 * var2, min=1e-20).reciprocal()
|
||||
db = (mean1 * var2 - mean2 * var1) * var1_var2_inv
|
||||
da = (var1 - var2) * var1_var2_inv
|
||||
virtual_obs_b[i] = virtual_obs_b[i] + db
|
||||
virtual_obs_a[i] = virtual_obs_a[i] + da
|
||||
|
||||
dr = (1 + var1 * da).reciprocal()
|
||||
mu = mu - Sxy * ((db + mean1 * da) * dr)
|
||||
cov = cov - (Sxy[:, None] * (da * dr)) @ Sxy[None, :]
|
||||
log_zs[i] = logz
|
||||
return mu, cov, torch.sum(log_zs)
|
||||
|
||||
|
||||
_orthants_MVN_EP_jit = torch.jit.script(_orthants_MVN_EP)
|
||||
|
||||
|
||||
class _PreferentialGP:
|
||||
def __init__(self, kernel: gpytorch.kernels.Kernel, noise_prior: Prior, dims: int) -> None:
|
||||
self.kernel = kernel
|
||||
self.noise_prior = noise_prior
|
||||
self.dims = dims
|
||||
|
||||
self.diff = torch.empty((0,), dtype=torch.float64, requires_grad=False)
|
||||
self.log_noise = torch.nn.Parameter(
|
||||
torch.tensor(0.0, dtype=torch.float64), requires_grad=True
|
||||
)
|
||||
|
||||
def fit_params_EP(self, X: Tensor, preferences: Tensor) -> None:
|
||||
if len(preferences) == 0:
|
||||
# Skip actual MCMC computation
|
||||
self.set_train_data(
|
||||
inputs=torch.empty((0, X.shape[-1])),
|
||||
targets=torch.empty((0,)),
|
||||
strict=False,
|
||||
)
|
||||
self.likelihood.weights = torch.empty((0,))
|
||||
else:
|
||||
dtype = torch.float64
|
||||
return
|
||||
tolerance = 1e-3
|
||||
max_iter = 100
|
||||
|
||||
cnt = torch.bincount(preferences.reshape(-1))
|
||||
mask = cnt > 0
|
||||
train_x = X[mask]
|
||||
weights = cnt[mask]
|
||||
optim = torch.optim.LBFGS([*self.kernel.parameters(), self.log_noise])
|
||||
|
||||
assert isinstance(self.likelihood, _WeightedGaussianLikelihood)
|
||||
self.likelihood.weights = weights
|
||||
last_params = [p.detach().clone() for p in optim.param_groups[0]["params"]]
|
||||
for _ in range(max_iter):
|
||||
|
||||
preferences_np = preferences.detach().numpy()
|
||||
def closure() -> Tensor:
|
||||
optim.zero_grad()
|
||||
noise = self.log_noise.exp()
|
||||
cov0 = self.kernel.forward(X, X).to_dense()
|
||||
_, _, logz = _orthants_MVN_EP_jit(cov0, preferences, noise, cycles=2)
|
||||
|
||||
all_ys_np = np.zeros((len(preferences), 2))
|
||||
train_y = torch.zeros(
|
||||
(
|
||||
len(
|
||||
train_x,
|
||||
)
|
||||
),
|
||||
dtype=dtype,
|
||||
loss = -logz - self.noise_prior.log_prob(noise)
|
||||
for _, _, prior, param, _ in self.kernel.named_priors():
|
||||
loss = loss - prior.log_prob(param(self.kernel)).sum()
|
||||
|
||||
loss.backward()
|
||||
return loss
|
||||
|
||||
optim.step(closure)
|
||||
|
||||
# Check for convergence
|
||||
params = optim.param_groups[0]["params"]
|
||||
for p_old, p_new in zip(last_params, params):
|
||||
if torch.max(torch.abs(p_old - p_new)) > tolerance:
|
||||
break
|
||||
else:
|
||||
break
|
||||
last_params = [p.detach().clone() for p in params]
|
||||
|
||||
def sample_gp(self, x: Tensor, preferences: Tensor) -> _SampledGP:
|
||||
self.fit_params_EP(x, preferences)
|
||||
|
||||
with torch.no_grad():
|
||||
cov_diff_diff_inv = _compute_cov_diff_diff_inv(
|
||||
preferences=preferences,
|
||||
cov_x_x=self.kernel(x, x).to_dense(),
|
||||
noise_var=self.log_noise.exp(),
|
||||
)
|
||||
|
||||
nuts = pyro.infer.mcmc.NUTS(
|
||||
model=self._pyro_model,
|
||||
init_strategy=pyro.infer.autoguide.init_to_sample,
|
||||
step_size=self._last_mcmc_step_size or 1.0,
|
||||
original_diff_size = len(self.diff)
|
||||
self.diff.resize_(len(preferences))
|
||||
self.diff[original_diff_size:] = 0.0
|
||||
|
||||
self.diff = _orthants_MVN_Gibbs_sampling_jit(
|
||||
cov_inv=cov_diff_diff_inv,
|
||||
initial_sample=self.diff,
|
||||
cycles=20,
|
||||
)[-1]
|
||||
return _SampledGP(
|
||||
kernel_func=lambda x1, x2: self.kernel(x1, x2).to_dense(),
|
||||
x=x,
|
||||
preferences=preferences,
|
||||
noise_var=self.log_noise.exp(),
|
||||
diff=self.diff,
|
||||
)
|
||||
warmup_steps = max(0, cycles - 2)
|
||||
nuts.setup(warmup_steps=warmup_steps, train_x=train_x, train_y=train_y)
|
||||
|
||||
raw_params = self._last_params or nuts.initial_params
|
||||
for i in range(cycles):
|
||||
params = {
|
||||
name: nuts.transforms[name].inv(value) for name, value in raw_params.items()
|
||||
}
|
||||
_set_params(self, params)
|
||||
self.set_train_data(train_x, train_y, strict=False)
|
||||
all_ys_np = _sample_y(
|
||||
preferences=preferences_np,
|
||||
cov_X_X=self.covar_module(train_x).detach().numpy(),
|
||||
obs_noise_var=float(self.likelihood.noise_covar.noise),
|
||||
cycles=10,
|
||||
initial_sample=all_ys_np,
|
||||
rng=rng,
|
||||
)
|
||||
ys_sum_np = np.zeros((len(X),))
|
||||
np.add.at(ys_sum_np, preferences_np.reshape(-1), all_ys_np.reshape(-1))
|
||||
ys_sum = torch.from_numpy(ys_sum_np)
|
||||
train_y[:] = ys_sum[mask] / cnt[mask]
|
||||
nuts.clear_cache()
|
||||
try:
|
||||
raw_params = nuts.sample(raw_params)
|
||||
except NotPSDError:
|
||||
nuts.cleanup()
|
||||
nuts = pyro.infer.mcmc.NUTS(
|
||||
model=self._pyro_model,
|
||||
init_strategy=pyro.infer.autoguide.init_to_sample,
|
||||
step_size=self._last_mcmc_step_size or 1.0,
|
||||
)
|
||||
nuts.setup(warmup_steps=warmup_steps, train_x=train_x, train_y=train_y)
|
||||
raw_params = nuts.initial_params
|
||||
|
||||
params = {name: nuts.transforms[name].inv(value) for name, value in raw_params.items()}
|
||||
self.set_train_data(train_x, train_y, strict=False)
|
||||
_set_params(self, params)
|
||||
|
||||
self._last_params = raw_params
|
||||
self._last_mcmc_step_size = nuts.step_size
|
||||
nuts.cleanup()
|
||||
|
||||
def forward(self, x: torch.Tensor) -> gpytorch.distributions.MultivariateNormal:
|
||||
mean_module = gpytorch.means.ZeroMean()
|
||||
return gpytorch.distributions.MultivariateNormal(
|
||||
mean_module(x),
|
||||
self.covar_module(x),
|
||||
)
|
||||
|
||||
|
||||
def _set_params(
|
||||
module: gpytorch.Module,
|
||||
params_dict: dict[str, torch.Tensor],
|
||||
memo: set | None = None,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
if memo is None:
|
||||
memo = set()
|
||||
if hasattr(module, "_priors"):
|
||||
for name, (prior, closure, setting_closure) in module._priors.items():
|
||||
if prior is not None and prior not in memo:
|
||||
memo.add(prior)
|
||||
setting_closure(module, params_dict[prefix + ("." if prefix else "") + name])
|
||||
|
||||
for mname, module_ in module.named_children():
|
||||
submodule_prefix = prefix + ("." if prefix else "") + mname
|
||||
_set_params(module_, params_dict, memo=memo, prefix=submodule_prefix)
|
||||
|
||||
|
||||
class PreferentialGPSampler(optuna.samplers.BaseSampler):
|
||||
@@ -306,18 +285,16 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
|
||||
noise_prior: Prior | None = None,
|
||||
independent_sampler: optuna.samplers.BaseSampler | None = None,
|
||||
seed: int | None = None,
|
||||
device: torch.device | None = None,
|
||||
) -> None:
|
||||
self._rng = np.random.RandomState(seed)
|
||||
self._search_space = IntersectionSearchSpace()
|
||||
|
||||
self.kernel = kernel
|
||||
self.noise_prior = noise_prior
|
||||
self.independent_sampler = independent_sampler or optuna.samplers.RandomSampler(
|
||||
seed=self._rng.randint(2**32),
|
||||
)
|
||||
self.device = device or torch.device("cpu")
|
||||
self.noise_prior = noise_prior or gpytorch.priors.GammaPrior(5.0, 50.0)
|
||||
|
||||
self._rng = np.random.RandomState(seed)
|
||||
self.independent_sampler = independent_sampler or optuna.samplers.RandomSampler(
|
||||
seed=self._rng.randint(2**32)
|
||||
)
|
||||
|
||||
self._search_space = optuna.search_space.IntersectionSearchSpace()
|
||||
self._gp: _PreferentialGP | None = None
|
||||
|
||||
def reseed_rng(self) -> None:
|
||||
@@ -325,92 +302,98 @@ class PreferentialGPSampler(optuna.samplers.BaseSampler):
|
||||
self._rng = np.random.RandomState()
|
||||
|
||||
def infer_relative_search_space(
|
||||
self, study: Study, trial: FrozenTrial
|
||||
) -> dict[str, BaseDistribution]:
|
||||
self, study: optuna.Study, trial: optuna.trial.FrozenTrial
|
||||
) -> dict[str, optuna.distributions.BaseDistribution]:
|
||||
return self._search_space.calculate(study)
|
||||
|
||||
def sample_relative(
|
||||
self,
|
||||
study: Study,
|
||||
trial: FrozenTrial,
|
||||
search_space: dict[str, BaseDistribution],
|
||||
study: optuna.Study,
|
||||
trial: optuna.trial.FrozenTrial,
|
||||
search_space: dict[str, optuna.distributions.BaseDistribution],
|
||||
) -> dict[str, Any]:
|
||||
preferences = get_preferences(study.system_attrs)
|
||||
if len(preferences) == 0 or len(search_space) == 0:
|
||||
return {}
|
||||
|
||||
trials = study.get_trials(deepcopy=False)
|
||||
trials_with_preference = list({t for (b, w) in preferences for t in (b, w)})
|
||||
ids = {t: i for i, t in enumerate(trials_with_preference)}
|
||||
|
||||
trans = optuna._transform._SearchSpaceTransform(
|
||||
search_space, transform_log=True, transform_step=True, transform_0_1=True
|
||||
)
|
||||
params = torch.tensor(
|
||||
np.array([trans.transform(trials[t].params) for t in trials_with_preference]),
|
||||
dtype=torch.float64,
|
||||
)
|
||||
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))
|
||||
pyro.set_rng_seed(self._rng.randint(2**32))
|
||||
|
||||
if len(search_space) == 0:
|
||||
return {}
|
||||
|
||||
preferences = get_preferences(study.system_attrs)
|
||||
trials = study.get_trials(deepcopy=False)
|
||||
if len(preferences) == 0:
|
||||
return {}
|
||||
|
||||
trans = _SearchSpaceTransform(
|
||||
search_space, transform_log=True, transform_step=True, transform_0_1=True
|
||||
)
|
||||
dims = len(trans.bounds)
|
||||
self._gp = self._gp or _PreferentialGP(
|
||||
kernel=self.kernel
|
||||
or gpytorch.kernels.MaternKernel(
|
||||
nu=2.5,
|
||||
ard_num_dims=dims,
|
||||
lengthscale_prior=gpytorch.priors.GammaPrior(3.0, 6.0),
|
||||
lengthscale_constraint=gpytorch.constraints.Positive(),
|
||||
nu=1.5,
|
||||
ard_num_dims=len(trans.bounds),
|
||||
lengthscale_prior=gpytorch.priors.GammaPrior(5.0, 10.0),
|
||||
lengthscale_constraint=gpytorch.constraints.GreaterThan(
|
||||
0.0,
|
||||
transform=torch.exp,
|
||||
inv_transform=torch.log,
|
||||
),
|
||||
),
|
||||
noise_prior=self.noise_prior or gpytorch.priors.GammaPrior(1.1, 2.0),
|
||||
noise_constraint=gpytorch.constraints.Positive(),
|
||||
noise_prior=self.noise_prior,
|
||||
dims=len(trans.bounds),
|
||||
)
|
||||
if self._gp.dims != len(trans.bounds):
|
||||
raise NotImplementedError(
|
||||
"The search space has changed. "
|
||||
"Dynamic search space is not supported in PreferentialGPSampler."
|
||||
)
|
||||
|
||||
sampled_gp = self._gp.sample_gp(params, pref_ids)
|
||||
acqf = botorch.acquisition.analytic.LogExpectedImprovement(
|
||||
model=sampled_gp,
|
||||
best_f=torch.max(sampled_gp.posterior(params[:, None, :]).mean),
|
||||
)
|
||||
|
||||
ids: dict[int, int] = {}
|
||||
params: list[torch.Tensor] = []
|
||||
pref_ids: list[tuple[int, int]] = []
|
||||
|
||||
for better, worse in preferences:
|
||||
for t in (better, worse):
|
||||
if t not in ids:
|
||||
ids[t] = len(ids)
|
||||
params.append(trans.transform(trials[t].params))
|
||||
pref_ids.append((ids[better], ids[worse]))
|
||||
dtype = torch.float64
|
||||
|
||||
params_torch = torch.tensor(np.array(params), dtype=dtype, device=self.device)
|
||||
pref_ids_torch = torch.tensor(
|
||||
np.array(pref_ids),
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
self._gp.fit_mcmc(params_torch, pref_ids_torch, cycles=10, rng=self._rng)
|
||||
self._gp.eval()
|
||||
scores = self._gp(params_torch).mean
|
||||
|
||||
best_f = torch.max(scores)
|
||||
|
||||
acqf = LogExpectedImprovement(
|
||||
model=self._gp,
|
||||
best_f=best_f,
|
||||
)
|
||||
|
||||
# TODO: Make it possible to apply it on categorical variables
|
||||
candidates, _ = optimize_acqf(
|
||||
acq_function=acqf,
|
||||
bounds=torch.from_numpy(trans.bounds.T),
|
||||
q=1,
|
||||
num_restarts=10,
|
||||
raw_samples=512,
|
||||
options={"batch_limit": 5, "maxiter": 200},
|
||||
sequential=True,
|
||||
)
|
||||
# TODO: Make it possible to apply it on mixed search space
|
||||
if all(isinstance(dist, CategoricalDistribution) for dist in search_space.values()):
|
||||
all_param_combinations = itertools.product(
|
||||
*[
|
||||
[(name, choice) for choice in cast(CategoricalDistribution, dist).choices]
|
||||
for name, dist in search_space.items()
|
||||
]
|
||||
)
|
||||
choices = torch.tensor(
|
||||
np.array([trans.transform(dict(params)) for params in all_param_combinations]),
|
||||
dtype=torch.float64,
|
||||
)
|
||||
candidates, _ = botorch.optim.optimize_acqf_discrete(
|
||||
acq_function=acqf,
|
||||
choices=choices,
|
||||
q=1,
|
||||
)
|
||||
else:
|
||||
candidates, _ = botorch.optim.optimize_acqf(
|
||||
acq_function=acqf,
|
||||
bounds=torch.from_numpy(trans.bounds.T),
|
||||
q=1,
|
||||
num_restarts=10,
|
||||
raw_samples=512,
|
||||
options={"batch_limit": 5, "maxiter": 200},
|
||||
sequential=True,
|
||||
)
|
||||
next_x = trans.untransform(candidates[0].detach().numpy())
|
||||
return next_x
|
||||
|
||||
def sample_independent(
|
||||
self,
|
||||
study: Study,
|
||||
trial: FrozenTrial,
|
||||
study: optuna.Study,
|
||||
trial: optuna.trial.FrozenTrial,
|
||||
param_name: str,
|
||||
param_distribution: distributions.BaseDistribution,
|
||||
param_distribution: optuna.distributions.BaseDistribution,
|
||||
) -> Any:
|
||||
return self.independent_sampler.sample_independent(
|
||||
study, trial, param_name, param_distribution
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
deleteArtifactAPI,
|
||||
reportPreferenceAPI,
|
||||
skipPreferentialTrialAPI,
|
||||
removePreferentialHistoryAPI,
|
||||
restorePreferentialHistoryAPI,
|
||||
reportFeedbackComponentAPI,
|
||||
} from "./apiClient"
|
||||
import {
|
||||
graphVisibilityState,
|
||||
@@ -586,11 +589,11 @@ export const actionCreator = () => {
|
||||
}
|
||||
|
||||
const updatePreference = (
|
||||
study_id: number,
|
||||
studyId: number,
|
||||
candidates: number[],
|
||||
clicked: number
|
||||
) => {
|
||||
reportPreferenceAPI(study_id, candidates, clicked).catch((err) => {
|
||||
reportPreferenceAPI(studyId, candidates, clicked).catch((err) => {
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(`Failed to report preference. Reason: ${reason}`, {
|
||||
variant: "error",
|
||||
@@ -608,6 +611,73 @@ export const actionCreator = () => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
const updateFeedbackComponent = (
|
||||
studyId: number,
|
||||
compoennt_type: FeedbackComponentType
|
||||
) => {
|
||||
reportFeedbackComponentAPI(studyId, compoennt_type)
|
||||
.then(() => {
|
||||
const newStudy = Object.assign({}, studyDetails[studyId])
|
||||
newStudy.feedback_component_type = compoennt_type
|
||||
setStudyDetailState(studyId, newStudy)
|
||||
})
|
||||
.catch((err) => {
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(
|
||||
`Failed to report feedback component. Reason: ${reason}`,
|
||||
{
|
||||
variant: "error",
|
||||
}
|
||||
)
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
|
||||
const removePreferentialHistory = (studyId: number, historyId: string) => {
|
||||
removePreferentialHistoryAPI(studyId, historyId)
|
||||
.then(() => {
|
||||
const newStudy = Object.assign({}, studyDetails[studyId])
|
||||
newStudy.preference_history = newStudy.preference_history?.map((h) =>
|
||||
h.id === historyId ? { ...h, is_removed: true } : h
|
||||
)
|
||||
const removed = newStudy.preference_history
|
||||
?.filter((h) => h.id === historyId)
|
||||
.pop()?.preferences
|
||||
newStudy.preferences = newStudy.preferences?.filter(
|
||||
(p) => !removed?.some((r) => r[0] === p[0] && r[1] === p[1])
|
||||
)
|
||||
setStudyDetailState(studyId, newStudy)
|
||||
})
|
||||
.catch((err) => {
|
||||
const reason = err.response?.data.reason
|
||||
|
||||
enqueueSnackbar(`Failed to switch history. Reason: ${reason}`, {
|
||||
variant: "error",
|
||||
})
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
const restorePreferentialHistory = (studyId: number, historyId: string) => {
|
||||
restorePreferentialHistoryAPI(studyId, historyId)
|
||||
.then(() => {
|
||||
const newStudy = Object.assign({}, studyDetails[studyId])
|
||||
newStudy.preference_history = newStudy.preference_history?.map((h) =>
|
||||
h.id === historyId ? { ...h, is_removed: false } : h
|
||||
)
|
||||
const restored = newStudy.preference_history
|
||||
?.filter((h) => h.id === historyId)
|
||||
.pop()?.preferences
|
||||
newStudy.preferences = newStudy.preferences?.concat(restored ?? [])
|
||||
setStudyDetailState(studyId, newStudy)
|
||||
})
|
||||
.catch((err) => {
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(`Failed to switch history. Reason: ${reason}`, {
|
||||
variant: "error",
|
||||
})
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
updateAPIMeta,
|
||||
@@ -630,6 +700,9 @@ export const actionCreator = () => {
|
||||
saveTrialUserAttrs,
|
||||
updatePreference,
|
||||
skipPreferentialTrial,
|
||||
removePreferentialHistory,
|
||||
restorePreferentialHistory,
|
||||
updateFeedbackComponent,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,25 +55,29 @@ const convertTrialResponse = (res: TrialResponse): Trial => {
|
||||
}
|
||||
}
|
||||
|
||||
interface PreferenceHistoryResponce {
|
||||
id: string
|
||||
preference_id: string
|
||||
candidates: number[]
|
||||
clicked: number
|
||||
mode: PreferenceFeedbackMode
|
||||
timestamp: string
|
||||
interface PreferenceHistoryResponse {
|
||||
history: {
|
||||
id: string
|
||||
candidates: number[]
|
||||
clicked: number
|
||||
mode: PreferenceFeedbackMode
|
||||
timestamp: string
|
||||
preferences: [number, number][]
|
||||
}
|
||||
is_removed: boolean
|
||||
}
|
||||
|
||||
const convertPreferenceHistory = (
|
||||
res: PreferenceHistoryResponce
|
||||
res: PreferenceHistoryResponse
|
||||
): PreferenceHistory => {
|
||||
return {
|
||||
id: res.id,
|
||||
preference_id: res.preference_id,
|
||||
candidates: res.candidates,
|
||||
clicked: res.clicked,
|
||||
feedback_mode: res.mode,
|
||||
timestamp: new Date(res.timestamp),
|
||||
id: res.history.id,
|
||||
candidates: res.history.candidates,
|
||||
clicked: res.history.clicked,
|
||||
feedback_mode: res.history.mode,
|
||||
timestamp: new Date(res.history.timestamp),
|
||||
preferences: res.history.preferences,
|
||||
is_removed: res.is_removed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +96,12 @@ interface StudyDetailResponse {
|
||||
is_preferential: boolean
|
||||
objective_names?: string[]
|
||||
form_widgets?: FormWidgets
|
||||
preference_history?: PreferenceHistoryResponce[]
|
||||
preferences?: [number, number][]
|
||||
preference_history?: PreferenceHistoryResponse[]
|
||||
plotly_graph_objects: PlotlyGraphObject[]
|
||||
artifacts: Artifact[]
|
||||
feedback_component_type: FeedbackComponentType
|
||||
skipped_trial_numbers?: number[]
|
||||
}
|
||||
|
||||
export const getStudyDetailAPI = (
|
||||
@@ -130,11 +137,14 @@ export const getStudyDetailAPI = (
|
||||
objective_names: res.data.objective_names,
|
||||
form_widgets: res.data.form_widgets,
|
||||
is_preferential: res.data.is_preferential,
|
||||
feedback_component_type: res.data.feedback_component_type,
|
||||
preferences: res.data.preferences,
|
||||
preference_history: res.data.preference_history?.map(
|
||||
convertPreferenceHistory
|
||||
),
|
||||
plotly_graph_objects: res.data.plotly_graph_objects,
|
||||
artifacts: res.data.artifacts,
|
||||
skipped_trial_numbers: res.data.skipped_trial_numbers ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -368,3 +378,38 @@ export const skipPreferentialTrialAPI = (
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
export const removePreferentialHistoryAPI = (
|
||||
studyId: number,
|
||||
historyUuid: string
|
||||
): Promise<void> => {
|
||||
return axiosInstance
|
||||
.delete<void>(`/api/studies/${studyId}/preference/${historyUuid}`)
|
||||
.then(() => {
|
||||
return
|
||||
})
|
||||
}
|
||||
export const restorePreferentialHistoryAPI = (
|
||||
studyId: number,
|
||||
historyUuid: string
|
||||
): Promise<void> => {
|
||||
return axiosInstance
|
||||
.post<void>(`/api/studies/${studyId}/preference/${historyUuid}`)
|
||||
.then(() => {
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
export const reportFeedbackComponentAPI = (
|
||||
studyId: number,
|
||||
component_type: FeedbackComponentType
|
||||
): Promise<void> => {
|
||||
return axiosInstance
|
||||
.put<void>(
|
||||
`/api/studies/${studyId}/preference_feedback_component`,
|
||||
component_type
|
||||
)
|
||||
.then(() => {
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,6 +87,15 @@ export const App: FC = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={URL_PREFIX + "/studies/:studyId/graph"}
|
||||
element={
|
||||
<StudyDetail
|
||||
toggleColorMode={toggleColorMode}
|
||||
page={"graph"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={URL_PREFIX + "/studies/:studyId"}
|
||||
element={
|
||||
|
||||
@@ -17,8 +17,7 @@ import ListItemText from "@mui/material/ListItemText"
|
||||
import {
|
||||
drawerOpenState,
|
||||
reloadIntervalState,
|
||||
useStudyDetailValue,
|
||||
useStudySummaryValue,
|
||||
useStudyIsPreferencial,
|
||||
} from "../state"
|
||||
import { Link } from "react-router-dom"
|
||||
import AutoGraphIcon from "@mui/icons-material/AutoGraph"
|
||||
@@ -35,6 +34,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew"
|
||||
import QueryStatsIcon from "@mui/icons-material/QueryStats"
|
||||
import ThumbUpAltIcon from "@mui/icons-material/ThumbUpAlt"
|
||||
import HistoryIcon from "@mui/icons-material/History"
|
||||
import LanIcon from "@mui/icons-material/Lan"
|
||||
import { Switch } from "@mui/material"
|
||||
import { actionCreator } from "../action"
|
||||
|
||||
@@ -47,6 +47,7 @@ export type PageId =
|
||||
| "trialList"
|
||||
| "note"
|
||||
| "preferenceHistory"
|
||||
| "graph"
|
||||
|
||||
const openedMixin = (theme: Theme): CSSObject => ({
|
||||
width: drawerWidth,
|
||||
@@ -128,12 +129,8 @@ export const AppDrawer: FC<{
|
||||
const action = actionCreator()
|
||||
const [open, setOpen] = useRecoilState<boolean>(drawerOpenState)
|
||||
const reloadInterval = useRecoilValue<number>(reloadIntervalState)
|
||||
const studyDetail =
|
||||
studyId !== undefined ? useStudyDetailValue(studyId) : null
|
||||
const studySummary =
|
||||
studyId !== undefined ? useStudySummaryValue(studyId) : null
|
||||
const isPreferential =
|
||||
studyDetail?.is_preferential ?? studySummary?.is_preferential ?? false
|
||||
studyId !== undefined ? useStudyIsPreferencial(studyId) : null
|
||||
|
||||
const styleListItem = {
|
||||
display: "block",
|
||||
@@ -206,7 +203,7 @@ export const AppDrawer: FC<{
|
||||
{isPreferential ? <ThumbUpAltIcon /> : <AutoGraphIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={isPreferential ? "HumanInTheLoop" : "History"}
|
||||
primary={isPreferential ? "Feedback Preference" : "History"}
|
||||
sx={styleListItemText}
|
||||
/>
|
||||
</ListItemButton>
|
||||
@@ -227,7 +224,7 @@ export const AppDrawer: FC<{
|
||||
<HistoryIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="PreferenceHistory"
|
||||
primary="Preferences (History)"
|
||||
sx={styleListItemText}
|
||||
/>
|
||||
</ListItemButton>
|
||||
@@ -246,6 +243,24 @@ export const AppDrawer: FC<{
|
||||
<ListItemText primary="Analytics" sx={styleListItemText} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{isPreferential && (
|
||||
<ListItem key="PreferenceGraph" disablePadding sx={styleListItem}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
to={`${URL_PREFIX}/studies/${studyId}/graph`}
|
||||
sx={styleListItemButton}
|
||||
selected={page === "graph"}
|
||||
>
|
||||
<ListItemIcon sx={styleListItemIcon}>
|
||||
<LanIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Preferences (Graph)"
|
||||
sx={styleListItemText}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
<ListItem key="TableList" disablePadding sx={styleListItem}>
|
||||
<ListItemButton
|
||||
component={Link}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { FC } from "react"
|
||||
import {
|
||||
ThreejsArtifactViewer,
|
||||
isThreejsArtifact,
|
||||
} from "./ThreejsArtifactViewer"
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"
|
||||
import { CardMedia } from "@mui/material"
|
||||
|
||||
export const ArtifactCardMedia: FC<{
|
||||
artifact: Artifact
|
||||
urlPath: string
|
||||
height: string
|
||||
}> = ({ artifact, urlPath, height }) => {
|
||||
if (isThreejsArtifact(artifact)) {
|
||||
return (
|
||||
<ThreejsArtifactViewer
|
||||
src={urlPath}
|
||||
width={"100%"}
|
||||
height={height}
|
||||
hasGizmo={false}
|
||||
filetype={artifact.filename.split(".").pop()}
|
||||
/>
|
||||
)
|
||||
} else if (artifact.mimetype.startsWith("audio")) {
|
||||
return (
|
||||
<audio controls>
|
||||
<source src={urlPath} type={artifact.mimetype} />
|
||||
</audio>
|
||||
)
|
||||
} else if (artifact.mimetype.startsWith("image")) {
|
||||
return (
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={height}
|
||||
image={urlPath}
|
||||
alt={artifact.filename}
|
||||
style={{
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <InsertDriveFileIcon sx={{ fontSize: 80 }} />
|
||||
}
|
||||
@@ -32,22 +32,23 @@ export const BestTrialsCard: FC<{
|
||||
header = `Best Trial (number=${bestTrial.number})`
|
||||
content = (
|
||||
<>
|
||||
{bestTrial.values === undefined || bestTrial.values.length === 1 ? (
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
marginBottom: theme.spacing(2),
|
||||
}}
|
||||
color="secondary"
|
||||
>
|
||||
{bestTrial.values}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography>
|
||||
Objective Values = [{bestTrial.values?.join(", ")}]
|
||||
</Typography>
|
||||
)}
|
||||
{!studyDetail?.is_preferential &&
|
||||
(bestTrial.values === undefined || bestTrial.values.length === 1 ? (
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
marginBottom: theme.spacing(2),
|
||||
}}
|
||||
color="secondary"
|
||||
>
|
||||
{bestTrial.values}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography>
|
||||
Objective Values = [{bestTrial.values?.join(", ")}]
|
||||
</Typography>
|
||||
))}
|
||||
<Typography>
|
||||
Params = [
|
||||
{bestTrial.params
|
||||
|
||||
@@ -10,12 +10,17 @@ import {
|
||||
import ClearIcon from "@mui/icons-material/Clear"
|
||||
import IconButton from "@mui/material/IconButton"
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull"
|
||||
import RestoreFromTrashIcon from "@mui/icons-material/RestoreFromTrash"
|
||||
import DeleteIcon from "@mui/icons-material/Delete"
|
||||
import Modal from "@mui/material/Modal"
|
||||
import { red } from "@mui/material/colors"
|
||||
|
||||
import { TrialListDetail } from "./TrialList"
|
||||
import { MarkdownRenderer } from "./Note"
|
||||
import { getArtifactUrlPath } from "./PreferentialTrials"
|
||||
import { formatDate } from "../dateUtil"
|
||||
import { actionCreator } from "../action"
|
||||
import { useStudyDetailValue } from "../state"
|
||||
import { PreferentialOutputComponent } from "./PreferentialOutputComponent"
|
||||
|
||||
type TrialType = "worst" | "none"
|
||||
|
||||
@@ -26,8 +31,24 @@ const CandidateTrial: FC<{
|
||||
const theme = useTheme()
|
||||
const trialWidth = 300
|
||||
const trialHeight = 300
|
||||
const studyDetail = useStudyDetailValue(trial.study_id)
|
||||
const [detailShown, setDetailShown] = useState(false)
|
||||
|
||||
if (studyDetail === null) {
|
||||
return null
|
||||
}
|
||||
const componentType = studyDetail.feedback_component_type
|
||||
const artifactId =
|
||||
componentType.output_type === "artifact"
|
||||
? trial.user_attrs.find((a) => a.key === componentType.artifact_key)
|
||||
?.value
|
||||
: undefined
|
||||
const artifact = trial.artifacts.find((a) => a.artifact_id === artifactId)
|
||||
const urlPath =
|
||||
artifactId !== undefined
|
||||
? getArtifactUrlPath(trial.study_id, trial.trial_id, artifactId)
|
||||
: ""
|
||||
|
||||
const cardComponentSx = {
|
||||
padding: 0,
|
||||
position: "relative",
|
||||
@@ -76,7 +97,12 @@ const CandidateTrial: FC<{
|
||||
padding: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<MarkdownRenderer body={trial.note.body} />
|
||||
<PreferentialOutputComponent
|
||||
trial={trial}
|
||||
artifact={artifact}
|
||||
componentType={componentType}
|
||||
urlPath={urlPath}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{type === "worst" ? (
|
||||
@@ -134,33 +160,75 @@ const CandidateTrial: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const ChoiceTrials: FC<{ choice: PreferenceHistory; trials: Trial[] }> = ({
|
||||
choice,
|
||||
trials,
|
||||
}) => {
|
||||
const ChoiceTrials: FC<{
|
||||
choice: PreferenceHistory
|
||||
trials: Trial[]
|
||||
studyId: number
|
||||
}> = ({ choice, trials, studyId }) => {
|
||||
const [isRemoved, setRemoved] = useState(choice.is_removed)
|
||||
const theme = useTheme()
|
||||
const worst_trials = new Set([choice.clicked])
|
||||
const action = actionCreator()
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
marginBottom: theme.spacing(4),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: theme.typography.fontWeightLight,
|
||||
}}
|
||||
>
|
||||
{formatDate(choice.timestamp)}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: theme.typography.fontWeightLight,
|
||||
margin: "auto 0",
|
||||
}}
|
||||
>
|
||||
{formatDate(choice.timestamp)}
|
||||
</Typography>
|
||||
{choice.is_removed ? (
|
||||
<IconButton
|
||||
disabled={!isRemoved}
|
||||
onClick={() => {
|
||||
setRemoved(false)
|
||||
action.restorePreferentialHistory(studyId, choice.id)
|
||||
}}
|
||||
sx={{
|
||||
margin: `auto ${theme.spacing(2)}`,
|
||||
}}
|
||||
>
|
||||
<RestoreFromTrashIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
disabled={isRemoved}
|
||||
onClick={() => {
|
||||
setRemoved(true)
|
||||
action.removePreferentialHistory(studyId, choice.id)
|
||||
}}
|
||||
sx={{
|
||||
margin: `auto ${theme.spacing(2)}`,
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
filter: choice.is_removed ? "brightness(0.4)" : undefined,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
{choice.candidates.map((trial_num, index) => (
|
||||
<CandidateTrial
|
||||
@@ -211,6 +279,7 @@ export const PreferenceHistory: FC<{ studyDetail: StudyDetail | null }> = ({
|
||||
key={choice.id}
|
||||
choice={choice}
|
||||
trials={studyDetail.trials}
|
||||
studyId={studyDetail.id}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { FC, useState, useCallback, useEffect } from "react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
useTheme,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
} from "@mui/material"
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
NodeProps,
|
||||
NodeTypes,
|
||||
Edge,
|
||||
DefaultEdgeOptions,
|
||||
applyNodeChanges,
|
||||
OnNodesChange,
|
||||
MiniMap,
|
||||
Position,
|
||||
Handle,
|
||||
} from "reactflow"
|
||||
import "reactflow/dist/style.css"
|
||||
import ELK from "elkjs/lib/elk.bundled.js"
|
||||
import { ElkNode } from "elkjs/lib/elk-api.js"
|
||||
|
||||
import { useStudyDetailValue } from "../state"
|
||||
import { getArtifactUrlPath } from "./PreferentialTrials"
|
||||
import { PreferentialOutputComponent } from "./PreferentialOutputComponent"
|
||||
|
||||
const elk = new ELK()
|
||||
const nodeWidth = 400
|
||||
const nodeHeight = 300
|
||||
const nodeMargin = 60
|
||||
|
||||
type NodeData = {
|
||||
trial?: Trial
|
||||
isBest: boolean
|
||||
}
|
||||
const GraphNode: FC<NodeProps<NodeData>> = ({ data, isConnectable }) => {
|
||||
const theme = useTheme()
|
||||
const trial = data.trial
|
||||
if (trial === undefined) {
|
||||
return null
|
||||
}
|
||||
const studyDetail = useStudyDetailValue(trial.study_id)
|
||||
const componentType = studyDetail?.feedback_component_type
|
||||
if (componentType === undefined) {
|
||||
return null
|
||||
}
|
||||
const artifactId =
|
||||
componentType.output_type === "artifact"
|
||||
? trial.user_attrs.find((a) => a.key === componentType.artifact_key)
|
||||
?.value
|
||||
: undefined
|
||||
const artifact = trial.artifacts.find((a) => a.artifact_id === artifactId)
|
||||
const urlPath =
|
||||
artifactId !== undefined
|
||||
? getArtifactUrlPath(trial.study_id, trial.trial_id, artifactId)
|
||||
: ""
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
width: nodeWidth,
|
||||
height: nodeHeight,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
displayDirection: "row",
|
||||
margin: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Trial {trial.number}</Typography>
|
||||
{data.isBest && (
|
||||
<Chip
|
||||
label={"Best Trial"}
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={{ background: "#555" }}
|
||||
isConnectable={isConnectable}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{
|
||||
position: "relative",
|
||||
margin: 0,
|
||||
padding: theme.spacing(1),
|
||||
width: nodeWidth,
|
||||
height: nodeHeight - 72,
|
||||
}}
|
||||
>
|
||||
<PreferentialOutputComponent
|
||||
trial={trial}
|
||||
artifact={artifact}
|
||||
componentType={componentType}
|
||||
urlPath={urlPath}
|
||||
/>
|
||||
</CardContent>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={{ background: "#555" }}
|
||||
isConnectable={isConnectable}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
note: GraphNode,
|
||||
}
|
||||
const defaultEdgeOptions: DefaultEdgeOptions = {
|
||||
animated: true,
|
||||
}
|
||||
|
||||
const reductionPreference = (
|
||||
input_preferences: [number, number][]
|
||||
): [number, number][] => {
|
||||
const preferences: [number, number][] = []
|
||||
let n = 0
|
||||
for (const [source, target] of input_preferences) {
|
||||
if (
|
||||
preferences.find((p) => p[0] === source && p[1] === target) !==
|
||||
undefined ||
|
||||
input_preferences.find((p) => p[0] === target && p[1] === source) !==
|
||||
undefined
|
||||
) {
|
||||
continue
|
||||
}
|
||||
n = Math.max(n - 1, source, target) + 1
|
||||
preferences.push([source, target])
|
||||
}
|
||||
if (n === 0) {
|
||||
return []
|
||||
}
|
||||
const graph: number[][] = Array.from({ length: n }, () => [])
|
||||
const reverseGraph: number[][] = Array.from({ length: n }, () => [])
|
||||
const degree: number[] = Array.from({ length: n }, () => 0)
|
||||
for (const [source, target] of preferences) {
|
||||
graph[source].push(target)
|
||||
reverseGraph[target].push(source)
|
||||
degree[target]++
|
||||
}
|
||||
const topologicalOrder: number[] = []
|
||||
const q: number[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (degree[i] === 0) {
|
||||
q.push(i)
|
||||
}
|
||||
}
|
||||
while (q.length > 0) {
|
||||
const v = q.pop()
|
||||
if (v === undefined) break
|
||||
topologicalOrder.push(v)
|
||||
graph[v].forEach((u) => {
|
||||
degree[u]--
|
||||
if (degree[u] === 0) {
|
||||
q.push(u)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (topologicalOrder.length !== n) {
|
||||
console.error("cycle detected")
|
||||
return preferences
|
||||
}
|
||||
|
||||
const response: [number, number][] = []
|
||||
const descendants: Set<number>[] = Array.from(
|
||||
{ length: n },
|
||||
() => new Set<number>()
|
||||
)
|
||||
topologicalOrder.reverse().forEach((v) => {
|
||||
const descendant = new Set<number>([v])
|
||||
graph[v].forEach((u) => {
|
||||
descendants[u].forEach((d) => descendant.add(d))
|
||||
})
|
||||
graph[v].forEach((u) => {
|
||||
if (reverseGraph[u].filter((d) => descendant.has(d)).length === 1) {
|
||||
response.push([v, u])
|
||||
}
|
||||
})
|
||||
descendants[v] = descendant
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
export const PreferentialGraph: FC<{
|
||||
studyDetail: StudyDetail | null
|
||||
}> = ({ studyDetail }) => {
|
||||
const theme = useTheme()
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [edges, setEdges] = useState<Edge[]>([])
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||
[setNodes]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (studyDetail === null) return
|
||||
if (!studyDetail.is_preferential || studyDetail.preferences === undefined)
|
||||
return
|
||||
const preferences = reductionPreference(studyDetail.preferences)
|
||||
const trialNodes = Array.from(new Set(preferences.flat()))
|
||||
const graph: ElkNode = {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "DOWN",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": nodeMargin.toString(),
|
||||
"elk.spacing.nodeNode": nodeMargin.toString(),
|
||||
},
|
||||
children: trialNodes.map((trial) => ({
|
||||
id: `${trial}`,
|
||||
targetPosition: "top",
|
||||
sourcePosition: "bottom",
|
||||
width: nodeWidth,
|
||||
height: nodeHeight,
|
||||
})),
|
||||
edges: preferences.map(([source, target]) => ({
|
||||
id: `e${source}-${target}`,
|
||||
sources: [`${source}`],
|
||||
targets: [`${target}`],
|
||||
})),
|
||||
}
|
||||
elk
|
||||
.layout(graph)
|
||||
.then((layoutedGraph) => {
|
||||
setNodes(
|
||||
layoutedGraph.children?.map((node, index) => {
|
||||
const trial = studyDetail.trials[trialNodes[index]]
|
||||
return {
|
||||
id: `${trial.number}`,
|
||||
type: "note",
|
||||
data: {
|
||||
label: `Trial ${trial.number}`,
|
||||
trial: trial,
|
||||
isBest:
|
||||
studyDetail.best_trials.find(
|
||||
(t) => t.number === trial.number
|
||||
) !== undefined,
|
||||
},
|
||||
position: {
|
||||
x: node.x ?? 0,
|
||||
y: node.y ?? 0,
|
||||
},
|
||||
style: {
|
||||
width: nodeWidth,
|
||||
height: nodeHeight,
|
||||
padding: 0,
|
||||
},
|
||||
deletable: false,
|
||||
connectable: false,
|
||||
draggable: false,
|
||||
}
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
.catch(console.error)
|
||||
setEdges(
|
||||
preferences.map((p) => {
|
||||
return {
|
||||
id: `e${p[0]}-${p[1]}`,
|
||||
source: `${p[0]}`,
|
||||
target: `${p[1]}`,
|
||||
style: { stroke: theme.palette.text.primary },
|
||||
} as Edge
|
||||
}) ?? []
|
||||
)
|
||||
}, [studyDetail, theme.palette.text.primary])
|
||||
|
||||
if (studyDetail === null || !studyDetail.is_preferential) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
nodeTypes={nodeTypes}
|
||||
zoomOnScroll={false}
|
||||
panOnScroll={true}
|
||||
minZoom={0.1}
|
||||
defaultViewport={{
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 0.5,
|
||||
}}
|
||||
>
|
||||
<MiniMap nodeStrokeWidth={1} zoomable pannable />
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { FC, useMemo } from "react"
|
||||
import { ArtifactCardMedia } from "./ArtifactCardMedia"
|
||||
import { MarkdownRenderer } from "./Note"
|
||||
|
||||
export const PreferentialOutputComponent: FC<{
|
||||
trial: Trial
|
||||
artifact?: Artifact
|
||||
componentType: FeedbackComponentType
|
||||
urlPath: string
|
||||
}> = ({ trial, artifact, componentType, urlPath }) => {
|
||||
const note = useMemo(() => {
|
||||
return <MarkdownRenderer body={trial.note.body} />
|
||||
}, [trial.note.body])
|
||||
if (componentType === undefined || componentType.output_type === "note") {
|
||||
return note
|
||||
}
|
||||
if (componentType.output_type === "artifact") {
|
||||
if (artifact === undefined) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ArtifactCardMedia artifact={artifact} urlPath={urlPath} height="100%" />
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FC, useState } from "react"
|
||||
import React, { FC, useEffect, useState } from "react"
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -6,31 +6,214 @@ import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
CardActionArea,
|
||||
Button,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Modal,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material"
|
||||
import ClearIcon from "@mui/icons-material/Clear"
|
||||
import IconButton from "@mui/material/IconButton"
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull"
|
||||
import ReplayIcon from "@mui/icons-material/Replay"
|
||||
import Modal from "@mui/material/Modal"
|
||||
import { red } from "@mui/material/colors"
|
||||
import UndoIcon from "@mui/icons-material/Undo"
|
||||
import ClearIcon from "@mui/icons-material/Clear"
|
||||
import SettingsIcon from "@mui/icons-material/Settings"
|
||||
import FullscreenIcon from "@mui/icons-material/Fullscreen"
|
||||
|
||||
import { actionCreator } from "../action"
|
||||
import { TrialListDetail } from "./TrialList"
|
||||
import { MarkdownRenderer } from "./Note"
|
||||
import {
|
||||
isThreejsArtifact,
|
||||
useThreejsArtifactModal,
|
||||
} from "./ThreejsArtifactViewer"
|
||||
import { PreferentialOutputComponent } from "./PreferentialOutputComponent"
|
||||
|
||||
const SettingsPage: FC<{
|
||||
studyDetail: StudyDetail
|
||||
settingShown: boolean
|
||||
setSettingShown: (flag: boolean) => void
|
||||
}> = ({ studyDetail, settingShown, setSettingShown }) => {
|
||||
const actions = actionCreator()
|
||||
const [outputComponentType, setOutputComponentType] = useState(
|
||||
studyDetail.feedback_component_type.output_type
|
||||
)
|
||||
const [artifactKey, setArtifactKey] = useState(
|
||||
studyDetail.feedback_component_type.output_type === "artifact"
|
||||
? studyDetail.feedback_component_type.artifact_key
|
||||
: undefined
|
||||
)
|
||||
useEffect(() => {
|
||||
setOutputComponentType(studyDetail.feedback_component_type.output_type)
|
||||
}, [studyDetail.feedback_component_type.output_type])
|
||||
useEffect(() => {
|
||||
if (studyDetail.feedback_component_type.output_type === "artifact") {
|
||||
setArtifactKey(studyDetail.feedback_component_type.artifact_key)
|
||||
}
|
||||
}, [
|
||||
studyDetail.feedback_component_type.output_type === "artifact"
|
||||
? studyDetail.feedback_component_type.artifact_key
|
||||
: undefined,
|
||||
])
|
||||
const onClose = () => {
|
||||
setSettingShown(false)
|
||||
}
|
||||
const onApply = () => {
|
||||
setSettingShown(false)
|
||||
const outputComponent: FeedbackComponentType =
|
||||
outputComponentType === "note"
|
||||
? ({ output_type: "note" } as FeedbackComponentNote)
|
||||
: ({
|
||||
output_type: "artifact",
|
||||
artifact_key: artifactKey,
|
||||
} as FeedbackComponentArtifact)
|
||||
actions.updateFeedbackComponent(studyDetail.id, outputComponent)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={settingShown}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth={true}
|
||||
>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">Output Component:</FormLabel>
|
||||
<Select
|
||||
value={outputComponentType}
|
||||
onChange={(e) => {
|
||||
setOutputComponentType(e.target.value as "note" | "artifact")
|
||||
}}
|
||||
>
|
||||
<MenuItem value="note">Note</MenuItem>
|
||||
<MenuItem value="artifact">Artifact</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{outputComponentType === "artifact" ? (
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
disabled={studyDetail.union_user_attrs.length === 0}
|
||||
>
|
||||
<FormLabel component="legend">
|
||||
User Attribute Key Corresponding to Output Artifact Id:
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
studyDetail.union_user_attrs.length !== 0
|
||||
? artifactKey ?? ""
|
||||
: "error"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setArtifactKey(e.target.value)
|
||||
}}
|
||||
>
|
||||
{studyDetail.union_user_attrs.length === 0 ? (
|
||||
<MenuItem value="error">No user attributes</MenuItem>
|
||||
) : null}
|
||||
{studyDetail.union_user_attrs.map((attr, index) => {
|
||||
return (
|
||||
<MenuItem key={index} value={attr.key}>
|
||||
{attr.key}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onApply}
|
||||
color="primary"
|
||||
disabled={
|
||||
outputComponentType === "artifact" && artifactKey === undefined
|
||||
}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const isComparisonReady = (
|
||||
trial: Trial,
|
||||
componentType: FeedbackComponentType
|
||||
): boolean => {
|
||||
if (componentType === undefined || componentType.output_type === "note") {
|
||||
return trial.note.body !== ""
|
||||
}
|
||||
if (componentType.output_type === "artifact") {
|
||||
const artifactId = trial?.user_attrs.find(
|
||||
(a) => a.key === componentType.artifact_key
|
||||
)?.value
|
||||
const artifact = trial?.artifacts.find((a) => a.artifact_id === artifactId)
|
||||
return artifact !== undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const getArtifactUrlPath = (
|
||||
studyId: number,
|
||||
trialId: number,
|
||||
artifactId: string
|
||||
): string => {
|
||||
return `/artifacts/${studyId}/${trialId}/${artifactId}`
|
||||
}
|
||||
|
||||
const PreferentialTrial: FC<{
|
||||
trial?: Trial
|
||||
studyDetail: StudyDetail
|
||||
candidates: number[]
|
||||
hideTrial: () => void
|
||||
}> = ({ trial, candidates, hideTrial }) => {
|
||||
openDetailTrial: () => void
|
||||
openThreejsArtifactModal: (urlPath: string, artifact: Artifact) => void
|
||||
}> = ({
|
||||
trial,
|
||||
studyDetail,
|
||||
candidates,
|
||||
hideTrial,
|
||||
openDetailTrial,
|
||||
openThreejsArtifactModal,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
const trialWidth = 500
|
||||
const [buttonHover, setButtonHover] = useState(false)
|
||||
const trialWidth = 400
|
||||
const trialHeight = 300
|
||||
const [detailShown, setDetailShown] = useState(false)
|
||||
const componentType = studyDetail.feedback_component_type
|
||||
const artifactId =
|
||||
componentType.output_type === "artifact"
|
||||
? trial?.user_attrs.find((a) => a.key === componentType.artifact_key)
|
||||
?.value
|
||||
: undefined
|
||||
const artifact = trial?.artifacts.find((a) => a.artifact_id === artifactId)
|
||||
const urlPath =
|
||||
trial !== undefined && artifactId !== undefined
|
||||
? getArtifactUrlPath(studyDetail.id, trial?.trial_id, artifactId)
|
||||
: ""
|
||||
const is3dModel =
|
||||
componentType.output_type === "artifact" &&
|
||||
artifact !== undefined &&
|
||||
isThreejsArtifact(artifact)
|
||||
|
||||
if (trial == undefined) {
|
||||
if (trial === undefined) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -42,7 +225,11 @@ const PreferentialTrial: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const isBestTrial = trial.state === "Complete"
|
||||
const onFeedback = () => {
|
||||
hideTrial()
|
||||
action.updatePreference(trial.study_id, candidates, trial.number)
|
||||
}
|
||||
const isReady = isComparisonReady(trial, componentType)
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -51,10 +238,47 @@ const PreferentialTrial: FC<{
|
||||
minHeight: trialHeight,
|
||||
margin: theme.spacing(2),
|
||||
padding: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<CardActions>
|
||||
<Typography variant="h5">Trial {trial.number}</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
margin: theme.spacing(0, 2),
|
||||
maxWidth: `calc(${trialWidth}px - ${
|
||||
is3dModel ? theme.spacing(8) : theme.spacing(4)
|
||||
})`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5">Trial {trial.number}</Typography>
|
||||
{componentType.output_type === "artifact" &&
|
||||
artifact !== undefined ? (
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
margin: theme.spacing(0, 2),
|
||||
}}
|
||||
>
|
||||
{`(${artifact.filename})`}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{is3dModel ? (
|
||||
<IconButton
|
||||
aria-label="show artifact 3d model"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ marginLeft: "auto" }}
|
||||
onClick={() => {
|
||||
openThreejsArtifactModal(urlPath, artifact)
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
@@ -71,188 +295,330 @@ const PreferentialTrial: FC<{
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
onClick={() => setDetailShown(true)}
|
||||
onClick={openDetailTrial}
|
||||
aria-label="show detail"
|
||||
>
|
||||
<OpenInFullIcon />
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
<CardActionArea>
|
||||
<CardContent
|
||||
aria-label="trial-button"
|
||||
onClick={() => {
|
||||
hideTrial()
|
||||
action.updatePreference(trial.study_id, candidates, trial.number)
|
||||
}}
|
||||
sx={{
|
||||
padding: 0,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark" ? "white" : "black",
|
||||
opacity: 0,
|
||||
zIndex: 1,
|
||||
transition: "opacity 0.3s ease-out",
|
||||
},
|
||||
":hover::before": {
|
||||
opacity: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
padding: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<MarkdownRenderer body={trial.note.body} />
|
||||
</Box>
|
||||
|
||||
<ClearIcon
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
top: 0,
|
||||
left: 0,
|
||||
color: red[600],
|
||||
opacity: 0,
|
||||
transition: "opacity 0.3s ease-out",
|
||||
zIndex: 1,
|
||||
":hover": {
|
||||
opacity: 0.3,
|
||||
filter:
|
||||
theme.palette.mode === "dark"
|
||||
<CardContent
|
||||
aria-label="trial-button"
|
||||
onClick={(e) => {
|
||||
if (e.shiftKey) onFeedback()
|
||||
}}
|
||||
sx={{
|
||||
position: "relative",
|
||||
padding: theme.spacing(2),
|
||||
overflow: "hidden",
|
||||
minHeight: theme.spacing(20),
|
||||
}}
|
||||
>
|
||||
{isReady ? (
|
||||
<>
|
||||
<PreferentialOutputComponent
|
||||
trial={trial}
|
||||
artifact={artifact}
|
||||
componentType={componentType}
|
||||
urlPath={urlPath}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor:
|
||||
theme.palette.mode === "dark" ? "white" : "black",
|
||||
opacity: buttonHover ? 0.2 : 0,
|
||||
zIndex: 1,
|
||||
transition: "opacity 0.3s ease-out",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<ClearIcon
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
color: red[600],
|
||||
opacity: buttonHover ? 0.3 : 0,
|
||||
transition: "opacity 0.3s ease-out",
|
||||
zIndex: 1,
|
||||
filter: buttonHover
|
||||
? theme.palette.mode === "dark"
|
||||
? "brightness(1.1)"
|
||||
: "brightness(1.7)",
|
||||
},
|
||||
: "brightness(1.7)"
|
||||
: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CircularProgress
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
margin: "auto",
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
<Modal open={detailShown} onClose={() => setDetailShown(false)}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: "80%",
|
||||
maxHeight: "90%",
|
||||
margin: "auto",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.palette.mode === "dark" ? "black" : "white",
|
||||
borderRadius: theme.spacing(3),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<TrialListDetail
|
||||
trial={trial}
|
||||
isBestTrial={() => isBestTrial}
|
||||
directions={[]}
|
||||
objectiveNames={[]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
</CardContent>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onFeedback}
|
||||
onMouseEnter={() => {
|
||||
setButtonHover(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setButtonHover(false)
|
||||
}}
|
||||
color="error"
|
||||
disabled={!isReady && candidates.length > 0}
|
||||
sx={{
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
Worst
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
type DisplayTrials = {
|
||||
numbers: number[]
|
||||
last_number: number
|
||||
display: number[]
|
||||
clicked: number[]
|
||||
}
|
||||
|
||||
export const PreferentialTrials: FC<{ studyDetail: StudyDetail | null }> = ({
|
||||
studyDetail,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
const [undoHistoryFlag, setUndoHistoryFlag] = useState(false)
|
||||
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
|
||||
useThreejsArtifactModal()
|
||||
const [displayTrials, setDisplayTrials] = useState<DisplayTrials>({
|
||||
display: [],
|
||||
clicked: [],
|
||||
})
|
||||
const [settingShown, setSettingShown] = useState(false)
|
||||
const [detailTrial, setDetailTrial] = useState<number | null>(null)
|
||||
|
||||
if (studyDetail === null || !studyDetail.is_preferential) {
|
||||
return null
|
||||
}
|
||||
const theme = useTheme()
|
||||
|
||||
const runningTrials = studyDetail.trials.filter((t) => t.state === "Running")
|
||||
const activeTrials = runningTrials.concat(studyDetail.best_trials)
|
||||
|
||||
const [displayTrials, setDisplayTrials] = useState<DisplayTrials>({
|
||||
numbers: activeTrials.map((t) => t.number),
|
||||
last_number: Math.max(...activeTrials.map((t) => t.number), -1),
|
||||
})
|
||||
const new_trails = activeTrials.filter(
|
||||
(t) =>
|
||||
displayTrials.last_number < t.number &&
|
||||
displayTrials.numbers.find((n) => n === t.number) === undefined
|
||||
const hiddenTrials = new Set(
|
||||
studyDetail.preference_history
|
||||
?.filter((h) => !h.is_removed)
|
||||
.map((p) => p.clicked)
|
||||
.concat(studyDetail.skipped_trial_numbers) ?? []
|
||||
)
|
||||
if (new_trails.length > 0) {
|
||||
setDisplayTrials((display) => {
|
||||
const numbers = [...display.numbers]
|
||||
new_trails.map((t) => {
|
||||
const index = numbers.findIndex((n) => n === -1)
|
||||
const activeTrials = studyDetail.trials.filter(
|
||||
(t) =>
|
||||
(t.state === "Running" || t.state === "Complete") &&
|
||||
!hiddenTrials.has(t.number)
|
||||
)
|
||||
const newTrials = activeTrials.filter(
|
||||
(t) =>
|
||||
!displayTrials.display.includes(t.number) &&
|
||||
!displayTrials.clicked.includes(t.number)
|
||||
)
|
||||
const deleteTrials = displayTrials.display.filter(
|
||||
(t) => t !== -1 && !activeTrials.map((t) => t.number).includes(t)
|
||||
)
|
||||
if (newTrials.length > 0 || deleteTrials.length > 0) {
|
||||
setDisplayTrials((prev) => {
|
||||
const display = [...prev.display].map((t) =>
|
||||
deleteTrials.includes(t) ? -1 : t
|
||||
)
|
||||
const clicked = [...prev.clicked]
|
||||
newTrials.map((t) => {
|
||||
const index = display.findIndex((n) => n === -1)
|
||||
if (index === -1) {
|
||||
numbers.push(t.number)
|
||||
display.push(t.number)
|
||||
clicked.push(-1)
|
||||
} else {
|
||||
numbers[index] = t.number
|
||||
display[index] = t.number
|
||||
}
|
||||
})
|
||||
return {
|
||||
numbers: numbers,
|
||||
last_number: Math.max(...numbers, -1),
|
||||
display: display,
|
||||
clicked: clicked,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hideTrial = (num: number) => {
|
||||
setDisplayTrials((display) => {
|
||||
const index = display.numbers.findIndex((n) => n === num)
|
||||
setDisplayTrials((prev) => {
|
||||
const index = prev.display.findIndex((n) => n === num)
|
||||
if (index === -1) {
|
||||
return display
|
||||
return prev
|
||||
}
|
||||
const numbers = [...displayTrials.numbers]
|
||||
numbers[index] = -1
|
||||
const display = [...prev.display]
|
||||
const clicked = [...prev.clicked]
|
||||
display[index] = -1
|
||||
clicked[index] = num
|
||||
return {
|
||||
numbers: numbers,
|
||||
last_number: display.last_number,
|
||||
display: display,
|
||||
clicked: clicked,
|
||||
}
|
||||
})
|
||||
}
|
||||
const visibleTrial = (num: number) => {
|
||||
setDisplayTrials((prev) => {
|
||||
const index = prev.clicked.findIndex((n) => n === num)
|
||||
if (index === -1) {
|
||||
return prev
|
||||
}
|
||||
const clicked = [...prev.clicked]
|
||||
clicked[index] = -1
|
||||
return {
|
||||
display: prev.display,
|
||||
clicked: clicked,
|
||||
}
|
||||
})
|
||||
}
|
||||
const latestHistoryId =
|
||||
studyDetail?.preference_history?.filter((h) => !h.is_removed).pop()?.id ??
|
||||
null
|
||||
|
||||
return (
|
||||
<Box padding={theme.spacing(2)}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}}
|
||||
>
|
||||
Which trial is the worst?
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", flexWrap: "wrap" }}>
|
||||
{displayTrials.numbers.map((t, index) => (
|
||||
<PreferentialTrial
|
||||
key={index}
|
||||
trial={activeTrials.find((trial) => trial.number === t)}
|
||||
candidates={displayTrials.numbers.filter((n) => n !== -1)}
|
||||
hideTrial={() => {
|
||||
hideTrial(t)
|
||||
<Box display="flex">
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}}
|
||||
>
|
||||
Which trial is the worst?
|
||||
</Typography>
|
||||
<Box
|
||||
display="flex"
|
||||
sx={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={latestHistoryId === null || undoHistoryFlag}
|
||||
sx={{
|
||||
marginRight: theme.spacing(2),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
startIcon={<UndoIcon />}
|
||||
onClick={() => {
|
||||
if (latestHistoryId === null) {
|
||||
return
|
||||
}
|
||||
setUndoHistoryFlag(true)
|
||||
const clicked = studyDetail.preference_history
|
||||
?.filter((h) => h.id === latestHistoryId)
|
||||
?.pop()?.clicked
|
||||
if (clicked !== undefined) visibleTrial(clicked)
|
||||
action.removePreferentialHistory(studyDetail.id, latestHistoryId)
|
||||
setUndoHistoryFlag(false)
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
marginRight: theme.spacing(2),
|
||||
}}
|
||||
startIcon={<SettingsIcon />}
|
||||
onClick={() => setSettingShown(true)}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flexDirection: "row", flexWrap: "wrap" }}>
|
||||
{displayTrials.display.map((t, index) => {
|
||||
const trial = activeTrials.find((trial) => trial.number === t)
|
||||
const candidates = displayTrials.display.filter(
|
||||
(n) =>
|
||||
n !== -1 &&
|
||||
isComparisonReady(
|
||||
studyDetail.trials[n],
|
||||
studyDetail.feedback_component_type
|
||||
)
|
||||
)
|
||||
return (
|
||||
<PreferentialTrial
|
||||
key={t === -1 ? -index - 1 : t}
|
||||
trial={trial}
|
||||
studyDetail={studyDetail}
|
||||
candidates={candidates}
|
||||
hideTrial={() => hideTrial(t)}
|
||||
openDetailTrial={() => setDetailTrial(t)}
|
||||
openThreejsArtifactModal={openThreejsArtifactModal}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<SettingsPage
|
||||
settingShown={settingShown}
|
||||
setSettingShown={setSettingShown}
|
||||
studyDetail={studyDetail}
|
||||
/>
|
||||
{detailTrial !== null && (
|
||||
<Modal open={true} onClose={() => setDetailTrial(null)}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: "80%",
|
||||
maxHeight: "90%",
|
||||
margin: "auto",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
borderRadius: theme.spacing(3),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: theme.spacing(2),
|
||||
right: theme.spacing(2),
|
||||
}}
|
||||
onClick={() => setDetailTrial(null)}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
<TrialListDetail
|
||||
trial={studyDetail.trials[detailTrial]}
|
||||
isBestTrial={(trialId) =>
|
||||
studyDetail.trials.find((t) => t.trial_id === trialId)
|
||||
?.state === "Complete" ?? false
|
||||
}
|
||||
directions={[]}
|
||||
objectiveNames={[]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
)}
|
||||
{renderThreejsArtifactModal()}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import { actionCreator } from "../action"
|
||||
import {
|
||||
reloadIntervalState,
|
||||
useStudyDetailValue,
|
||||
useStudyIsPreferencial,
|
||||
useStudyName,
|
||||
useStudySummaryValue,
|
||||
} from "../state"
|
||||
import { TrialTable } from "./TrialTable"
|
||||
import { AppDrawer, PageId } from "./AppDrawer"
|
||||
@@ -32,6 +32,7 @@ import { StudyHistory } from "./StudyHistory"
|
||||
import { PreferentialTrials } from "./PreferentialTrials"
|
||||
import { PreferenceHistory } from "./PreferenceHistory"
|
||||
import { PreferentialAnalytics } from "./PreferentialAnalytics"
|
||||
import { PreferentialGraph } from "./PreferentialGraph"
|
||||
|
||||
interface ParamTypes {
|
||||
studyId: string
|
||||
@@ -51,11 +52,9 @@ export const StudyDetail: FC<{
|
||||
const action = actionCreator()
|
||||
const studyId = useURLVars()
|
||||
const studyDetail = useStudyDetailValue(studyId)
|
||||
const studySummary = useStudySummaryValue(studyId)
|
||||
const reloadInterval = useRecoilValue<number>(reloadIntervalState)
|
||||
const studyName = useStudyName(studyId)
|
||||
const isPreferential =
|
||||
studySummary?.is_preferential ?? studyDetail?.is_preferential ?? false
|
||||
const isPreferential = useStudyIsPreferencial(studyId)
|
||||
|
||||
const title =
|
||||
studyName !== null ? `${studyName} (id=${studyId})` : `Study #${studyId}`
|
||||
@@ -176,6 +175,17 @@ export const StudyDetail: FC<{
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
} else if (page === "graph") {
|
||||
content = (
|
||||
<Box
|
||||
sx={{
|
||||
height: `calc(100vh - ${theme.spacing(8)})`,
|
||||
padding: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<PreferentialGraph studyDetail={studyDetail} />
|
||||
</Box>
|
||||
)
|
||||
} else if (page == "preferenceHistory") {
|
||||
content = <PreferenceHistory studyDetail={studyDetail} />
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import { Rhino3dmLoader } from "three/examples/jsm/loaders/3DMLoader"
|
||||
import { PerspectiveCamera } from "three"
|
||||
import { Modal, Box } from "@mui/material"
|
||||
|
||||
export const isThreejsArtifact = (artifact: Artifact): boolean => {
|
||||
return (
|
||||
artifact.filename.endsWith(".stl") || artifact.filename.endsWith(".3dm")
|
||||
)
|
||||
}
|
||||
|
||||
interface ThreejsArtifactViewerProps {
|
||||
src: string
|
||||
width: string
|
||||
@@ -69,10 +75,8 @@ export const ThreejsArtifactViewer: React.FC<ThreejsArtifactViewerProps> = (
|
||||
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) {
|
||||
rhinoGeometries.forEach((rhinoGeometry) => {
|
||||
rhinoGeometry.rotateX(-Math.PI / 4)
|
||||
})
|
||||
handleLoadedGeometries(rhinoGeometries)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
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 { useDeleteArtifactDialog } from "./DeleteArtifactDialog"
|
||||
import {
|
||||
useThreejsArtifactModal,
|
||||
isThreejsArtifact,
|
||||
} from "./ThreejsArtifactViewer"
|
||||
import { ArtifactCardMedia } from "./ArtifactCardMedia"
|
||||
|
||||
export const TrialArtifactCards: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
const theme = useTheme()
|
||||
const [openDeleteArtifactDialog, renderDeleteArtifactDialog] =
|
||||
useDeleteArtifactDialog()
|
||||
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
|
||||
useThreejsArtifactModal()
|
||||
|
||||
const width = "200px"
|
||||
const height = "150px"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: theme.typography.fontWeightBold }}
|
||||
>
|
||||
Artifacts
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", p: theme.spacing(1, 0) }}>
|
||||
{trial.artifacts.map((artifact) => {
|
||||
const urlPath = `/artifacts/${trial.study_id}/${trial.trial_id}/${artifact.artifact_id}`
|
||||
return (
|
||||
<Card
|
||||
key={artifact.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
}}
|
||||
>
|
||||
<ArtifactCardMedia
|
||||
artifact={artifact}
|
||||
urlPath={urlPath}
|
||||
height={height}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
wordWrap: "break-word",
|
||||
maxWidth: `calc(100% - ${
|
||||
isThreejsArtifact(artifact)
|
||||
? theme.spacing(12)
|
||||
: theme.spacing(8)
|
||||
})`,
|
||||
}}
|
||||
>
|
||||
{artifact.filename}
|
||||
</Typography>
|
||||
{isThreejsArtifact(artifact) ? (
|
||||
<IconButton
|
||||
aria-label="show artifact 3d model"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openThreejsArtifactModal(urlPath, artifact)
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
artifact
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
download={artifact.filename}
|
||||
sx={{ margin: "auto 0" }}
|
||||
href={urlPath}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
<TrialArtifactUploader trial={trial} width={width} height={height} />
|
||||
</Box>
|
||||
{renderDeleteArtifactDialog()}
|
||||
{renderThreejsArtifactModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TrialArtifactUploader: FC<{
|
||||
trial: Trial
|
||||
width: string
|
||||
height: string
|
||||
}> = ({ trial, width, height }) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
const [dragOver, setDragOver] = useState<boolean>(false)
|
||||
|
||||
if (trial.state !== "Running" && trial.state !== "Waiting") {
|
||||
return null
|
||||
}
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const handleClick: MouseEventHandler = () => {
|
||||
if (!inputRef || !inputRef.current) {
|
||||
return
|
||||
}
|
||||
inputRef.current.click()
|
||||
}
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const files = e.target.files
|
||||
if (files === null) {
|
||||
return
|
||||
}
|
||||
action.uploadArtifact(trial.study_id, trial.trial_id, files[0])
|
||||
}
|
||||
const handleDrop: DragEventHandler = (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
const files = e.dataTransfer.files
|
||||
setDragOver(false)
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
action.uploadArtifact(trial.study_id, trial.trial_id, files[i])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
minHeight: height,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
border: dragOver
|
||||
? `3px dashed ${theme.palette.mode === "dark" ? "white" : "black"}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon
|
||||
sx={{ fontSize: 80, marginBottom: theme.spacing(2) }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleOnChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Typography>Upload a New File</Typography>
|
||||
<Typography
|
||||
sx={{ textAlign: "center", color: theme.palette.grey.A400 }}
|
||||
>
|
||||
Drag your file here or click to browse.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,4 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
DragEventHandler,
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import React, { FC, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
@@ -16,10 +7,6 @@ import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
} from "@mui/material"
|
||||
import Chip from "@mui/material/Chip"
|
||||
import Divider from "@mui/material/Divider"
|
||||
@@ -31,11 +18,6 @@ import ListSubheader from "@mui/material/ListSubheader"
|
||||
import FilterListIcon from "@mui/icons-material/FilterList"
|
||||
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"
|
||||
import CheckBoxIcon from "@mui/icons-material/CheckBox"
|
||||
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 InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"
|
||||
import StopCircleIcon from "@mui/icons-material/StopCircle"
|
||||
|
||||
import { TrialNote } from "./Note"
|
||||
@@ -44,12 +26,8 @@ import ListItemIcon from "@mui/material/ListItemIcon"
|
||||
import { useRecoilValue } from "recoil"
|
||||
import { artifactIsAvailable } from "../state"
|
||||
import { actionCreator } from "../action"
|
||||
import { useDeleteArtifactDialog } from "./DeleteArtifactDialog"
|
||||
import { TrialFormWidgets } from "./TrialFormWidgets"
|
||||
import {
|
||||
ThreejsArtifactViewer,
|
||||
useThreejsArtifactModal,
|
||||
} from "./ThreejsArtifactViewer"
|
||||
import { TrialArtifactCards } from "./TrialArtifactCards"
|
||||
|
||||
const states: TrialState[] = [
|
||||
"Complete",
|
||||
@@ -321,418 +299,11 @@ export const TrialListDetail: FC<{
|
||||
value !== null ? renderInfo(key, value) : null
|
||||
)}
|
||||
</Box>
|
||||
{artifactEnabled && <TrialArtifact trial={trial} />}
|
||||
{artifactEnabled && <TrialArtifactCards trial={trial} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const TrialArtifact: FC<{ trial: Trial }> = ({ trial }) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
const [openDeleteArtifactDialog, renderDeleteArtifactDialog] =
|
||||
useDeleteArtifactDialog()
|
||||
const [dragOver, setDragOver] = useState<boolean>(false)
|
||||
const [openThreejsArtifactModal, renderThreejsArtifactModal] =
|
||||
useThreejsArtifactModal()
|
||||
|
||||
const width = "200px"
|
||||
const height = "150px"
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const handleClick: MouseEventHandler = () => {
|
||||
if (!inputRef || !inputRef.current) {
|
||||
return
|
||||
}
|
||||
inputRef.current.click()
|
||||
}
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const files = e.target.files
|
||||
if (files === null) {
|
||||
return
|
||||
}
|
||||
action.uploadArtifact(trial.study_id, trial.trial_id, files[0])
|
||||
}
|
||||
const handleDrop: DragEventHandler = (e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
const files = e.dataTransfer.files
|
||||
setDragOver(false)
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
action.uploadArtifact(trial.study_id, trial.trial_id, files[i])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: theme.typography.fontWeightBold }}
|
||||
>
|
||||
Artifacts
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", p: theme.spacing(1, 0) }}>
|
||||
{trial.artifacts.map((a) => {
|
||||
if (a.mimetype.startsWith("image")) {
|
||||
return (
|
||||
<Card
|
||||
key={a.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height={height}
|
||||
image={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
alt={a.filename}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
wordWrap: "break-word",
|
||||
maxWidth: `calc(100% - ${theme.spacing(8)})`,
|
||||
}}
|
||||
>
|
||||
{a.filename}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
a
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
download={a.filename}
|
||||
sx={{ margin: "auto 0" }}
|
||||
href={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
} else if (
|
||||
a.filename.endsWith(".stl") ||
|
||||
a.filename.endsWith(".3dm")
|
||||
) {
|
||||
return (
|
||||
<Card
|
||||
key={a.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: width,
|
||||
minHeight: "100%",
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ThreejsArtifactViewer
|
||||
src={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
width={width}
|
||||
height={height}
|
||||
hasGizmo={false}
|
||||
filetype={a.filename.split(".").pop()}
|
||||
/>
|
||||
</Box>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
wordWrap: "break-word",
|
||||
maxWidth: `calc(100% - ${theme.spacing(12)})`,
|
||||
}}
|
||||
>
|
||||
{a.filename}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="show artifact 3d model"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
const urlPath = `/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`
|
||||
openThreejsArtifactModal(urlPath, a)
|
||||
}}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
a
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
download={a.filename}
|
||||
href={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
} else if (a.mimetype.startsWith("audio")) {
|
||||
return (
|
||||
<Card
|
||||
key={a.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: width,
|
||||
minHeight: "100%",
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<audio controls>
|
||||
<source
|
||||
src={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
type={a.mimetype}
|
||||
/>
|
||||
</audio>
|
||||
</Box>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
maxWidth: `calc(100% - ${theme.spacing(8)})`,
|
||||
}}
|
||||
>
|
||||
{a.filename}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
a
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
download={a.filename}
|
||||
href={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Card
|
||||
key={a.artifact_id}
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: width,
|
||||
minHeight: "100%",
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<InsertDriveFileIcon sx={{ fontSize: 80 }} />
|
||||
</Box>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
padding: `${theme.spacing(1)} !important`,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
p: theme.spacing(0.5, 0),
|
||||
flexGrow: 1,
|
||||
maxWidth: `calc(100% - ${theme.spacing(8)})`,
|
||||
}}
|
||||
>
|
||||
{a.filename}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="delete artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
onClick={() => {
|
||||
openDeleteArtifactDialog(
|
||||
trial.study_id,
|
||||
trial.trial_id,
|
||||
a
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="download artifact"
|
||||
size="small"
|
||||
color="inherit"
|
||||
sx={{ margin: "auto 0" }}
|
||||
download={a.filename}
|
||||
href={`/artifacts/${trial.study_id}/${trial.trial_id}/${a.artifact_id}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{trial.state === "Running" || trial.state === "Waiting" ? (
|
||||
<Card
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
width: width,
|
||||
minHeight: height,
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
border: dragOver
|
||||
? `3px dashed ${
|
||||
theme.palette.mode === "dark" ? "white" : "black"
|
||||
}`
|
||||
: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<UploadFileIcon
|
||||
sx={{ fontSize: 80, marginBottom: theme.spacing(2) }}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
onChange={handleOnChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Typography>Upload a New File</Typography>
|
||||
<Typography
|
||||
sx={{ textAlign: "center", color: theme.palette.grey.A400 }}
|
||||
>
|
||||
Drag your file here or click to browse.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
) : null}
|
||||
</Box>
|
||||
{renderDeleteArtifactDialog()}
|
||||
{renderThreejsArtifactModal()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const getTrialListLink = (
|
||||
studyId: number,
|
||||
exclude: TrialState[],
|
||||
|
||||
@@ -87,6 +87,12 @@ export const useStudyDirections = (
|
||||
return studyDetail?.directions || studySummary?.directions || null
|
||||
}
|
||||
|
||||
export const useStudyIsPreferencial = (studyId: number): boolean | null => {
|
||||
const studyDetail = useStudyDetailValue(studyId)
|
||||
const studySummary = useStudySummaryValue(studyId)
|
||||
return studyDetail?.is_preferential || studySummary?.is_preferential || null
|
||||
}
|
||||
|
||||
export const useStudyName = (studyId: number): string | null => {
|
||||
const studyDetail = useStudyDetailValue(studyId)
|
||||
const studySummary = useStudySummaryValue(studyId)
|
||||
|
||||
Vendored
+16
-2
@@ -187,6 +187,17 @@ type PlotlyGraphObject = {
|
||||
graph_object: string
|
||||
}
|
||||
|
||||
type FeedbackComponentNote = {
|
||||
output_type: "note"
|
||||
}
|
||||
|
||||
type FeedbackComponentArtifact = {
|
||||
output_type: "artifact"
|
||||
artifact_key: string
|
||||
}
|
||||
|
||||
type FeedbackComponentType = FeedbackComponentArtifact | FeedbackComponentNote
|
||||
|
||||
type StudyDetail = {
|
||||
id: number
|
||||
name: string
|
||||
@@ -203,9 +214,12 @@ type StudyDetail = {
|
||||
is_preferential: boolean
|
||||
objective_names?: string[]
|
||||
form_widgets?: FormWidgets
|
||||
feedback_component_type: FeedbackComponentType
|
||||
preferences?: [number, number][]
|
||||
preference_history?: PreferenceHistory[]
|
||||
plotly_graph_objects: PlotlyGraphObject[]
|
||||
artifacts: Artifact[]
|
||||
skipped_trial_numbers: number[]
|
||||
}
|
||||
|
||||
type StudyDetails = {
|
||||
@@ -215,12 +229,12 @@ type StudyDetails = {
|
||||
type StudyParamImportance = {
|
||||
[study_id: string]: ParamImportance[][]
|
||||
}
|
||||
|
||||
type PreferenceHistory = {
|
||||
id: string
|
||||
preference_id: string
|
||||
candidates: number[]
|
||||
clicked: number
|
||||
feedback_mode: PreferenceFeedbackMode
|
||||
timestamp: Date
|
||||
preferences: [number, number][]
|
||||
is_removed: boolean
|
||||
}
|
||||
|
||||
Generated
+1422
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@
|
||||
"@react-three/fiber": "^8.13.6",
|
||||
"@types/three": "^0.154.0",
|
||||
"axios": "^1.2.1",
|
||||
"elkjs": "^0.8.2",
|
||||
"notistack": "^3.0.1",
|
||||
"plotly.js-dist-min": "^2.22.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-router-dom": "^6.11.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"reactflow": "^11.8.3",
|
||||
"recoil": "^0.7.7",
|
||||
"rehype-mathjax": "^4.0.2",
|
||||
"rehype-raw": "^6.1.1",
|
||||
@@ -53,12 +55,14 @@
|
||||
"@typescript-eslint/eslint-plugin": "^4.26.1",
|
||||
"@typescript-eslint/parser": "^4.26.1",
|
||||
"compression-webpack-plugin": "^10.0.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"esbuild-loader": "^2.18.0",
|
||||
"eslint": "^7.28.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-canvas-mock": "^2.3.1",
|
||||
"jest-environment-jsdom": "^29.3.1",
|
||||
"prettier": "^2.5.1",
|
||||
"style-loader": "^3.3.3",
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-loader": "^9.2.7",
|
||||
"typescript": "^4.6.2",
|
||||
|
||||
@@ -51,6 +51,7 @@ test = [
|
||||
optional = [
|
||||
"streamlit",
|
||||
"boto3",
|
||||
"botorch",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import sys
|
||||
|
||||
import optuna
|
||||
from optuna import create_trial
|
||||
from optuna.distributions import CategoricalDistribution
|
||||
from optuna.distributions import FloatDistribution
|
||||
from optuna.distributions import IntDistribution
|
||||
from optuna.samplers import BaseSampler
|
||||
from optuna.trial import TrialState
|
||||
from optuna_dashboard.preferential import create_study
|
||||
import pytest
|
||||
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from optuna_dashboard.preferential.samplers.gp import PreferentialGPSampler
|
||||
else:
|
||||
PreferentialGPSampler = None
|
||||
|
||||
|
||||
parametrize_sampler = pytest.mark.parametrize(
|
||||
"sampler_class",
|
||||
[
|
||||
optuna.samplers.RandomSampler,
|
||||
pytest.param(
|
||||
PreferentialGPSampler,
|
||||
marks=pytest.mark.skipif(
|
||||
sys.version_info < (3, 8), reason="BoTorch dropped Python3.7 support"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_float(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
|
||||
for i in range(5):
|
||||
past_trial = create_trial(
|
||||
state=TrialState.RUNNING,
|
||||
params={"x": 1.0},
|
||||
distributions={"x": FloatDistribution(0, 10)},
|
||||
)
|
||||
study.add_trial(past_trial)
|
||||
study.report_preference(study.trials[:-1], study.trials[-1])
|
||||
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", 0, 10)
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_int(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
|
||||
for i in range(5):
|
||||
past_trial = create_trial(
|
||||
state=TrialState.RUNNING,
|
||||
params={"x": 1},
|
||||
distributions={"x": IntDistribution(0, 10)},
|
||||
)
|
||||
study.add_trial(past_trial)
|
||||
study.report_preference(study.trials[:-1], study.trials[-1])
|
||||
|
||||
trial = study.ask()
|
||||
trial.suggest_int("x", 0, 10)
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_categorical(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
|
||||
for i in range(5):
|
||||
past_trial = create_trial(
|
||||
state=TrialState.RUNNING,
|
||||
params={"x": "A"},
|
||||
distributions={"x": CategoricalDistribution(["A", "B", "C"])},
|
||||
)
|
||||
study.add_trial(past_trial)
|
||||
study.report_preference(study.trials[:-1], study.trials[-1])
|
||||
|
||||
trial = study.ask()
|
||||
trial.suggest_categorical("x", ["A", "B", "C"])
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_mixed(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
|
||||
for i in range(5):
|
||||
past_trial = create_trial(
|
||||
state=TrialState.RUNNING,
|
||||
params={"x": 1.0, "y": 1, "z": "A"},
|
||||
distributions={
|
||||
"x": FloatDistribution(0, 10),
|
||||
"y": IntDistribution(0, 10),
|
||||
"z": CategoricalDistribution(["A", "B", "C"]),
|
||||
},
|
||||
)
|
||||
study.add_trial(past_trial)
|
||||
study.report_preference(study.trials[:-1], study.trials[-1])
|
||||
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", 0, 10)
|
||||
trial.suggest_int("y", 0, 10)
|
||||
trial.suggest_categorical("z", ["A", "B", "C"])
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_first_trial(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", 0, 10)
|
||||
|
||||
|
||||
@parametrize_sampler
|
||||
def test_sample_dynamic_search_space(sampler_class: Callable[[], BaseSampler]) -> None:
|
||||
study = create_study(n_generate=4, sampler=sampler_class())
|
||||
|
||||
for i in range(5):
|
||||
past_trial = create_trial(
|
||||
state=TrialState.RUNNING,
|
||||
params={"x": 1.0},
|
||||
distributions={"x": FloatDistribution(0, 10)},
|
||||
)
|
||||
study.add_trial(past_trial)
|
||||
study.report_preference(study.trials[:-1], study.trials[-1])
|
||||
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", -100, 100)
|
||||
@@ -8,6 +8,11 @@ from optuna import get_all_study_summaries
|
||||
from optuna.study import StudyDirection
|
||||
from optuna_dashboard._app import create_app
|
||||
from optuna_dashboard._app import create_new_study
|
||||
from optuna_dashboard._preference_setting import register_preference_feedback_component
|
||||
from optuna_dashboard._preferential_history import NewHistory
|
||||
from optuna_dashboard._preferential_history import remove_history
|
||||
from optuna_dashboard._preferential_history import report_history
|
||||
from optuna_dashboard._serializer import serialize_preference_history
|
||||
from optuna_dashboard.preferential import create_study
|
||||
|
||||
from .wsgi_client import send_request
|
||||
@@ -179,6 +184,36 @@ class APITestCase(TestCase):
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_change_component(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = create_study(storage=storage, n_generate=3)
|
||||
register_preference_feedback_component(study, "note")
|
||||
for _ in range(3):
|
||||
study.ask()
|
||||
|
||||
app = create_app(storage)
|
||||
study_id = study._study._study_id
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/studies/{study_id}/preference_feedback_component",
|
||||
"PUT",
|
||||
body=json.dumps({"output_type": "artifact", "artifact_key": "image"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
|
||||
status, _, body = send_request(
|
||||
app,
|
||||
f"/api/studies/{study_id}",
|
||||
"GET",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
study_detail = json.loads(body)
|
||||
assert study_detail["feedback_component_type"]["output_type"] == "artifact"
|
||||
assert study_detail["feedback_component_type"]["artifact_key"] == "image"
|
||||
|
||||
def test_skip_trial(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = create_study(n_generate=4, storage=storage)
|
||||
@@ -203,6 +238,82 @@ class APITestCase(TestCase):
|
||||
assert len(best_trials) == 1
|
||||
assert best_trials[0].number == 2
|
||||
|
||||
def test_remove_history(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = create_study(storage=storage, n_generate=3)
|
||||
for _ in range(3):
|
||||
study.ask()
|
||||
|
||||
app = create_app(storage)
|
||||
study_id = study._study._study_id
|
||||
history_id = report_history(
|
||||
study_id,
|
||||
storage,
|
||||
NewHistory(
|
||||
mode="ChooseWorst",
|
||||
candidates=[0, 1, 2],
|
||||
clicked=2,
|
||||
),
|
||||
)
|
||||
histories = serialize_preference_history(storage.get_study_system_attrs(study_id))
|
||||
assert len(histories) == 1
|
||||
assert not histories[0]["is_removed"]
|
||||
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/studies/{study_id}/preference/{history_id}",
|
||||
"DELETE",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
histories = serialize_preference_history(storage.get_study_system_attrs(study_id))
|
||||
assert len(histories) == 1
|
||||
assert histories[0]["is_removed"]
|
||||
assert len(study.get_preferences()) == 0
|
||||
|
||||
def test_restore_history(self) -> None:
|
||||
storage = optuna.storages.InMemoryStorage()
|
||||
study = create_study(storage=storage, n_generate=3)
|
||||
for _ in range(3):
|
||||
study.ask()
|
||||
|
||||
app = create_app(storage)
|
||||
study_id = study._study._study_id
|
||||
history_id = report_history(
|
||||
study_id,
|
||||
storage,
|
||||
NewHistory(
|
||||
mode="ChooseWorst",
|
||||
candidates=[0, 1, 2],
|
||||
clicked=2,
|
||||
),
|
||||
)
|
||||
remove_history(study_id, storage, history_id)
|
||||
histories = serialize_preference_history(storage.get_study_system_attrs(study_id))
|
||||
assert len(histories) == 1
|
||||
assert histories[0]["is_removed"]
|
||||
assert len(study.get_preferences()) == 0
|
||||
|
||||
status, _, _ = send_request(
|
||||
app,
|
||||
f"/api/studies/{study_id}/preference/{history_id}",
|
||||
"POST",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(status, 204)
|
||||
histories = serialize_preference_history(storage.get_study_system_attrs(study_id))
|
||||
assert len(histories) == 1
|
||||
assert not histories[0]["is_removed"]
|
||||
preferences = study.get_preferences()
|
||||
preferences.sort(key=lambda x: (x[0].number, x[1].number))
|
||||
assert len(preferences) == 2
|
||||
better, worse = preferences[0]
|
||||
assert better.number == 0
|
||||
assert worse.number == 2
|
||||
better, worse = preferences[1]
|
||||
assert better.number == 1
|
||||
assert worse.number == 2
|
||||
|
||||
def test_create_study(self) -> None:
|
||||
for name, directions, expected_status in [
|
||||
("single-objective success", ["minimize"], 201),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import TestCase
|
||||
|
||||
import optuna
|
||||
from optuna_dashboard._preference_setting import _SYSTEM_ATTR_FEEDBACK_COMPONENT
|
||||
from optuna_dashboard._preference_setting import register_preference_feedback_component
|
||||
from optuna_dashboard.preferential._study import PreferentialStudy
|
||||
|
||||
|
||||
class FeedbackSettingTestCase(TestCase):
|
||||
def test_widget_to_dict_from_dict(self) -> None:
|
||||
study = PreferentialStudy(optuna.create_study())
|
||||
register_preference_feedback_component(study, "artifact", "image_key")
|
||||
system_attrs = study._study.system_attrs
|
||||
feedback_type = system_attrs.get(_SYSTEM_ATTR_FEEDBACK_COMPONENT, {})
|
||||
assert "output_type" in feedback_type
|
||||
assert feedback_type["output_type"] == "artifact"
|
||||
assert "artifact_key" in feedback_type
|
||||
assert feedback_type["artifact_key"] == "image_key"
|
||||
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from optuna.storages import BaseStorage
|
||||
from optuna_dashboard._preferential_history import _SYSTEM_ATTR_PREFIX_HISTORY
|
||||
from optuna_dashboard._preferential_history import NewHistory
|
||||
from optuna_dashboard._preferential_history import remove_history
|
||||
from optuna_dashboard._preferential_history import report_history
|
||||
from optuna_dashboard._preferential_history import restore_history
|
||||
from optuna_dashboard._serializer import serialize_preference_history
|
||||
from optuna_dashboard.preferential import create_study
|
||||
from optuna_dashboard.preferential._system_attrs import _SYSTEM_ATTR_PREFIX_PREFERENCE
|
||||
@@ -12,6 +18,10 @@ from .storage_supplier import parametrize_storages
|
||||
from .storage_supplier import StorageSupplier
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from optuna_dashboard._preferential_history import History
|
||||
|
||||
|
||||
@parametrize_storages
|
||||
def test_report_and_get_choices(storage_supplier: Callable[[], StorageSupplier]) -> None:
|
||||
with storage_supplier() as storage:
|
||||
@@ -25,37 +35,102 @@ def test_report_and_get_choices(storage_supplier: Callable[[], StorageSupplier])
|
||||
report_history(
|
||||
study_id=study_id,
|
||||
storage=storage,
|
||||
input_data=NewHistory(
|
||||
mode="ChooseWorst",
|
||||
candidates=[0, 1, 2],
|
||||
clicked=1,
|
||||
),
|
||||
input_data=NewHistory(mode="ChooseWorst", candidates=[0, 1, 2], clicked=1),
|
||||
)
|
||||
report_history(
|
||||
study_id=study_id,
|
||||
storage=storage,
|
||||
input_data=NewHistory(
|
||||
mode="ChooseWorst",
|
||||
candidates=[0, 2, 3, 4],
|
||||
clicked=0,
|
||||
),
|
||||
input_data=NewHistory(mode="ChooseWorst", candidates=[0, 2, 3, 4], clicked=0),
|
||||
)
|
||||
history = serialize_preference_history(storage.get_study_system_attrs(study_id))
|
||||
sys_attrs = storage.get_study_system_attrs(study_id)
|
||||
assert len(history) == 2
|
||||
assert history[0]["candidates"] == [0, 1, 2]
|
||||
assert history[0]["clicked"] == 1
|
||||
preferences = sys_attrs[_SYSTEM_ATTR_PREFIX_PREFERENCE + history[0]["preference_id"]]
|
||||
assert history[0]["history"]["candidates"] == [0, 1, 2]
|
||||
assert history[0]["history"]["clicked"] == 1
|
||||
preferences = sys_attrs[_SYSTEM_ATTR_PREFIX_PREFERENCE + history[0]["history"]["id"]]
|
||||
assert len(preferences) == 2
|
||||
for i, (best, worst) in enumerate([(0, 1), (2, 1)]):
|
||||
assert len(preferences[i]) == 2
|
||||
assert preferences[i][0] == best
|
||||
assert preferences[i][1] == worst
|
||||
assert history[1]["candidates"] == [0, 2, 3, 4]
|
||||
assert history[1]["clicked"] == 0
|
||||
preferences = sys_attrs[_SYSTEM_ATTR_PREFIX_PREFERENCE + history[1]["preference_id"]]
|
||||
assert history[1]["history"]["candidates"] == [0, 2, 3, 4]
|
||||
assert history[1]["history"]["clicked"] == 0
|
||||
preferences = sys_attrs[_SYSTEM_ATTR_PREFIX_PREFERENCE + history[1]["history"]["id"]]
|
||||
assert len(preferences) == 3
|
||||
for i, (best, worst) in enumerate([(2, 0), (3, 0), (4, 0)]):
|
||||
assert len(preferences[i]) == 2
|
||||
assert preferences[i][0] == best
|
||||
assert preferences[i][1] == worst
|
||||
|
||||
|
||||
def get_preferences_history(
|
||||
study_id: int,
|
||||
storage: BaseStorage,
|
||||
history_id: str,
|
||||
) -> tuple[list[tuple[int, int]], History]:
|
||||
system_attrs = storage.get_study_system_attrs(study_id)
|
||||
history: History = json.loads(system_attrs.get(_SYSTEM_ATTR_PREFIX_HISTORY + history_id, ""))
|
||||
preference: list[tuple[int, int]] = system_attrs.get(
|
||||
_SYSTEM_ATTR_PREFIX_PREFERENCE + history_id, []
|
||||
)
|
||||
return preference, history
|
||||
|
||||
|
||||
@parametrize_storages
|
||||
def test_remove_history(storage_supplier: Callable[[], StorageSupplier]) -> None:
|
||||
with storage_supplier() as storage:
|
||||
study = create_study(storage=storage, n_generate=5)
|
||||
for _ in range(5):
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", 0, 1)
|
||||
study_id = study._study._study_id
|
||||
|
||||
history_id = report_history(
|
||||
study_id=study_id,
|
||||
storage=storage,
|
||||
input_data=NewHistory(mode="ChooseWorst", candidates=[0, 1, 2], clicked=1),
|
||||
)
|
||||
remove_history(study_id, storage, history_id)
|
||||
preference, history = get_preferences_history(study_id, storage, history_id)
|
||||
assert history["mode"] == "ChooseWorst"
|
||||
assert history["candidates"] == [0, 1, 2]
|
||||
assert history["clicked"] == 1
|
||||
assert len(preference) == 0
|
||||
|
||||
remove_history(study_id, storage, history_id)
|
||||
preference, history = get_preferences_history(study_id, storage, history_id)
|
||||
assert len(preference) == 0
|
||||
|
||||
|
||||
@parametrize_storages
|
||||
def test_restore_history(storage_supplier: Callable[[], StorageSupplier]) -> None:
|
||||
with storage_supplier() as storage:
|
||||
study = create_study(storage=storage, n_generate=5)
|
||||
for _ in range(5):
|
||||
trial = study.ask()
|
||||
trial.suggest_float("x", 0, 1)
|
||||
study_id = study._study._study_id
|
||||
|
||||
history_id = report_history(
|
||||
study_id=study_id,
|
||||
storage=storage,
|
||||
input_data=NewHistory(mode="ChooseWorst", candidates=[0, 1, 2], clicked=1),
|
||||
)
|
||||
remove_history(study_id, storage, history_id)
|
||||
preference, history = get_preferences_history(study_id, storage, history_id)
|
||||
assert len(preference) == 0
|
||||
|
||||
restore_history(study_id, storage, history_id)
|
||||
preference, history = get_preferences_history(study_id, storage, history_id)
|
||||
assert history["mode"] == "ChooseWorst"
|
||||
assert history["candidates"] == [0, 1, 2]
|
||||
assert history["clicked"] == 1
|
||||
assert len(preference) == 2
|
||||
for i, (best, worst) in enumerate([(0, 1), (2, 1)]):
|
||||
assert len(preference[i]) == 2
|
||||
assert preference[i][0] == best
|
||||
assert preference[i][1] == worst
|
||||
|
||||
restore_history(study_id, storage, history_id)
|
||||
preference, history = get_preferences_history(study_id, storage, history_id)
|
||||
assert len(preference) == 2
|
||||
|
||||
@@ -29,7 +29,9 @@ def test_get_study_detail_is_preferential() -> None:
|
||||
assert len(study_summaries) == 1
|
||||
|
||||
study_summary = study_summaries[0]
|
||||
study_detail = serialize_study_detail(study_summary, [], study.trials, [], [], [], False, {})
|
||||
study_detail = serialize_study_detail(
|
||||
study_summary, [], study.trials, [], [], [], False, {}, []
|
||||
)
|
||||
assert study_detail["is_preferential"]
|
||||
|
||||
|
||||
@@ -40,7 +42,9 @@ def test_get_study_detail_is_not_preferential() -> None:
|
||||
assert len(study_summaries) == 1
|
||||
|
||||
study_summary = study_summaries[0]
|
||||
study_detail = serialize_study_detail(study_summary, [], study.trials, [], [], [], False, {})
|
||||
study_detail = serialize_study_detail(
|
||||
study_summary, [], study.trials, [], [], [], False, {}, []
|
||||
)
|
||||
assert not study_detail["is_preferential"]
|
||||
|
||||
|
||||
|
||||
+75
-59
@@ -1,65 +1,81 @@
|
||||
const webpack = require('webpack');
|
||||
const webpack = require("webpack")
|
||||
|
||||
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
||||
const isDev = mode === 'development';
|
||||
const mode =
|
||||
process.env.NODE_ENV === "production" ? "production" : "development"
|
||||
const isDev = mode === "development"
|
||||
|
||||
const typeScriptLoader = process.env.TYPESCRIPT_LOADER === "esbuild-loader" ? {
|
||||
test: /\.tsx?$/,
|
||||
exclude: [/node_modules/],
|
||||
loader: 'esbuild-loader',
|
||||
options: {
|
||||
loader: 'tsx',
|
||||
tsconfigRaw: require('./tsconfig.json')
|
||||
}
|
||||
} : {
|
||||
test: /\.tsx?$/,
|
||||
exclude: [/node_modules/],
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
configFile: __dirname + '/tsconfig.json',
|
||||
transpileOnly: isDev,
|
||||
happyPackMode: true
|
||||
}
|
||||
}
|
||||
const typeScriptLoader =
|
||||
process.env.TYPESCRIPT_LOADER === "esbuild-loader"
|
||||
? {
|
||||
test: /\.tsx?$/,
|
||||
exclude: [/node_modules/],
|
||||
loader: "esbuild-loader",
|
||||
options: {
|
||||
loader: "tsx",
|
||||
tsconfigRaw: require("./tsconfig.json"),
|
||||
},
|
||||
}
|
||||
: {
|
||||
test: /\.tsx?$/,
|
||||
exclude: [/node_modules/],
|
||||
loader: "ts-loader",
|
||||
options: {
|
||||
configFile: __dirname + "/tsconfig.json",
|
||||
transpileOnly: isDev,
|
||||
happyPackMode: true,
|
||||
},
|
||||
}
|
||||
|
||||
var config = {
|
||||
mode,
|
||||
entry: [__dirname + '/optuna_dashboard/ts/index.tsx'],
|
||||
output: {
|
||||
path: __dirname + '/optuna_dashboard/public/',
|
||||
filename: 'bundle.js',
|
||||
publicPath: '/public/'
|
||||
},
|
||||
module: {
|
||||
rules: [{oneOf: [typeScriptLoader]}]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js']
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'APP_BAR_TITLE': JSON.stringify(process.env.APP_BAR_TITLE || "Optuna Dashboard"),
|
||||
'API_ENDPOINT': JSON.stringify(process.env.API_ENDPOINT),
|
||||
'URL_PREFIX': JSON.stringify(process.env.URL_PREFIX || "/dashboard")
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (isDev) {
|
||||
config.devtool = 'source-map';
|
||||
config.cache = {
|
||||
type: 'filesystem',
|
||||
buildDependencies: {
|
||||
config: [__filename],
|
||||
}
|
||||
}
|
||||
console.log('= = = = = = = = = = = = = = = = = = =');
|
||||
console.log('DEVELOPMENT BUILD');
|
||||
console.log(process.env.TYPESCRIPT_LOADER === 'esbuild-loader' ? 'esbuild-loader' : 'ts-loader');
|
||||
console.log('= = = = = = = = = = = = = = = = = = =');
|
||||
} else {
|
||||
const CompressionPlugin = require("compression-webpack-plugin");
|
||||
config.plugins.push(new CompressionPlugin())
|
||||
mode,
|
||||
entry: [__dirname + "/optuna_dashboard/ts/index.tsx"],
|
||||
output: {
|
||||
path: __dirname + "/optuna_dashboard/public/",
|
||||
filename: "bundle.js",
|
||||
publicPath: "/public/",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{ oneOf: [typeScriptLoader] },
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ["style-loader", "css-loader"],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js"],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
APP_BAR_TITLE: JSON.stringify(
|
||||
process.env.APP_BAR_TITLE || "Optuna Dashboard"
|
||||
),
|
||||
API_ENDPOINT: JSON.stringify(process.env.API_ENDPOINT),
|
||||
URL_PREFIX: JSON.stringify(process.env.URL_PREFIX || "/dashboard"),
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
if (isDev) {
|
||||
config.devtool = "source-map"
|
||||
config.cache = {
|
||||
type: "filesystem",
|
||||
buildDependencies: {
|
||||
config: [__filename],
|
||||
},
|
||||
}
|
||||
console.log("= = = = = = = = = = = = = = = = = = =")
|
||||
console.log("DEVELOPMENT BUILD")
|
||||
console.log(
|
||||
process.env.TYPESCRIPT_LOADER === "esbuild-loader"
|
||||
? "esbuild-loader"
|
||||
: "ts-loader"
|
||||
)
|
||||
console.log("= = = = = = = = = = = = = = = = = = =")
|
||||
} else {
|
||||
const CompressionPlugin = require("compression-webpack-plugin")
|
||||
config.plugins.push(new CompressionPlugin())
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
|
||||
Reference in New Issue
Block a user