Merge branch 'main' into support-optuna-study-artifacts

This commit is contained in:
c-bata
2023-09-27 09:06:44 +09:00
42 changed files with 3999 additions and 1107 deletions
+1
View File
@@ -0,0 +1 @@
github: optuna
+29
View File
@@ -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
+1
View File
@@ -44,6 +44,7 @@ Preferential Optimization
optuna_dashboard.preferential.create_study
optuna_dashboard.preferential.load_study
optuna_dashboard.preferential.PreferentialStudy
optuna_dashboard.register_preference_feedback_component
Streamlit
-----------------
+21 -18
View File
@@ -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 @@ Lets 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 @@ Lets 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 @@ Lets 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

+1
View File
@@ -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
View File
@@ -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__":
+13 -22
View File
@@ -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"""\
![generated-image]({get_artifact_path(trial, artifact_id)})
(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__":
+1
View File
@@ -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"
+54
View File
@@ -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]:
+68
View File
@@ -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,
)
+40 -10
View File
@@ -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"]
)
+24 -7
View File
@@ -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,
+302 -319
View File
@@ -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
+75 -2
View File
@@ -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,
}
}
+60 -15
View File
@@ -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
})
}
+9
View File
@@ -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={
+24 -9
View File
@@ -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>
)
}
+14 -4
View File
@@ -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>
)
}
+3 -432
View File
@@ -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[],
+6
View File
@@ -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)
+16 -2
View File
@@ -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
}
+1422
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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",
+1
View File
@@ -51,6 +51,7 @@ test = [
optional = [
"streamlit",
"boto3",
"botorch",
]
+132
View File
@@ -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)
+111
View File
@@ -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),
+20
View File
@@ -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"
+91 -16
View File
@@ -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
+6 -2
View File
@@ -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
View File
@@ -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