diff --git a/.coveragerc b/.coveragerc index 4cb78b98..9c61856d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,6 +5,7 @@ source = src omit = tests/* **/__init__.py + src/f3dasm/_src/experimentdata/_newdata.py [report] # Regexes for lines to exclude from consideration diff --git a/.github/workflows/pr_to_main.yml b/.github/workflows/pr_to_main.yml index f064c755..a9fc7a98 100644 --- a/.github/workflows/pr_to_main.yml +++ b/.github/workflows/pr_to_main.yml @@ -56,7 +56,10 @@ jobs: python -m pip install --upgrade pip pip install -r requirements_dev.txt - name: Build documentation - run: | + uses: r-lib/actions/setup-pandoc@v2 + with: + pandoc-version: '3.1.2' # The pandoc version to download (if necessary) and use. + - run: | sphinx-build -b html ./docs/source ./docs/build/html build-package: strategy: diff --git a/.github/workflows/pr_to_pr.yml b/.github/workflows/pr_to_pr.yml index 87a109a9..2e4fcc46 100644 --- a/.github/workflows/pr_to_pr.yml +++ b/.github/workflows/pr_to_pr.yml @@ -67,7 +67,10 @@ jobs: run: | pip install -r requirements_dev.txt - name: Build documentation - run: | + uses: r-lib/actions/setup-pandoc@v2 + with: + pandoc-version: '3.1.2' # The pandoc version to download (if necessary) and use. + - run: | sphinx-build -b html ./docs/source ./docs/build/html build-package: strategy: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b4512759 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,134 @@ +name: release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +env: + PACKAGE_NAME: "f3dasm" + OWNER: "bessagroup" + +jobs: + details: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.release.outputs.new_version }} + suffix: ${{ steps.release.outputs.suffix }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: actions/checkout@v2 + + - name: Extract tag and Details + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') + SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "Version is $NEW_VERSION" + echo "Suffix is $SUFFIX" + echo "Tag name is $TAG_NAME" + else + echo "No tag found" + exit 1 + fi + + check_pypi: + needs: details + runs-on: ubuntu-latest + steps: + - name: Fetch information from PyPI + run: | + response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") + latest_previous_version=$(echo $response | awk '/"releases":/ {flag=1} flag' | grep -oP '"\d+\.\d+(\.\d+)*"' | tr -d '"' | sort -rV | head -n 1) + if [ -z "$latest_previous_version" ]; then + echo "Package not found on PyPI." + latest_previous_version="0.0.0" + fi + echo "Latest version on PyPI: $latest_previous_version" + echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + + - name: Compare versions and exit if not newer + run: | + NEW_VERSION=${{ needs.details.outputs.new_version }} + LATEST_VERSION=$latest_previous_version + if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then + echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." + exit 1 + else + echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." + fi + + setup_and_build: + needs: [details, check_pypi] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.8" + + - name: Install build dependencies + run: python -m pip install -U setuptools wheel build + + - name: Build the package + run: python -m build . + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + + pypi_publish: + name: Upload release to PyPI + needs: [setup_and_build, details] + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github_release: + name: Create GitHub Release + needs: [setup_and_build, details] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + id: create_release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes diff --git a/README.md b/README.md index 65639227..3322ee33 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ or ```bash # PyPI -$ conda install f3dasm +$ conda install conda-forge::f3dasm ``` * Follow the complete [installation instructions](https://f3dasm.readthedocs.io/en/latest/rst_doc_files/general/installation.html) to get going! @@ -100,7 +100,7 @@ If you find any **issues, bugs or problems** with this template, please use the ## License -Copyright 2024, Martin van der Schelling +Copyright 2025, Martin van der Schelling All rights reserved. diff --git a/VERSION b/VERSION index 63ebd3fe..359a5b95 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.4 \ No newline at end of file +2.0.0 \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 5d481e61..9d42ec45 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,7 @@ sphinx -sphinx_rtd_theme +sphinx-book-theme sphinxcontrib-bibtex sphinx_autodoc_typehints sphinx-tabs==3.4.4 -sphinx-gallery \ No newline at end of file +nbsphinx +ipykernel \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index df3d65bc..1ccd9014 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -7,8 +7,6 @@ import os import sys -from sphinx_gallery.sorting import FileNameSortKey - # -- Search path for extensions and modules ----------------------------------- # If extensions or Python modules are in a different directory than this file, # then add these directories to sys.path so that Sphinx can search for them @@ -26,9 +24,9 @@ project = 'f3dasm' author = 'Martin van der Schelling' -copyright = '2024, Martin van der Schelling' -version = '1.5.4' -release = '1.5.4' +copyright = '2025, Martin van der Schelling' +version = '2.0.0' +release = '2.0.0' # -- General configuration ---------------------------------------------------- @@ -38,7 +36,6 @@ # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.duration', - 'sphinx_rtd_theme', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.autosummary', @@ -46,18 +43,18 @@ 'sphinx.ext.viewcode', 'sphinx_autodoc_typehints', 'sphinx_tabs.tabs', - 'sphinx_gallery.gen_gallery',] - -sphinx_gallery_conf = { - 'examples_dirs': ['../../examples'], # path to your example scripts - 'gallery_dirs': ['auto_examples'], - 'reference_url': {'sphinx_gallery': None, }, - 'backreferences_dir': 'gen_modules/backreferences', - 'doc_module': ('f3dasm',), - "filename_pattern": r"/*\.py", - "within_subsection_order": FileNameSortKey, - "nested_sections": False, -} + 'nbsphinx',] + +# sphinx_gallery_conf = { +# 'examples_dirs': ['../../examples'], # path to your example scripts +# 'gallery_dirs': ['auto_examples'], +# 'reference_url': {'sphinx_gallery': None, }, +# 'backreferences_dir': 'gen_modules/backreferences', +# 'doc_module': ('f3dasm',), +# "filename_pattern": r"/*\.py", +# "within_subsection_order": FileNameSortKey, +# "nested_sections": False, +# } # Source: https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-source_suffix source_suffix = {'.rst': 'restructuredtext', } @@ -122,15 +119,17 @@ # Source: https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_theme_path on_rtd = os.environ.get('READTHEDOCS', None) == 'True' if on_rtd: - import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + # import sphinx_rtd_theme + # html_theme = 'sphinx_rtd_theme' + # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + html_theme = 'sphinx_book_theme' else: # Source: https://sphinx-rtd-theme.readthedocs.io/en/stable/index.html # Requires installation of Python package 'sphinx_rtd_theme' - import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + # import sphinx_rtd_theme + # html_theme = 'sphinx_rtd_theme' + # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + html_theme = 'sphinx_book_theme' # Source: https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_static_path html_static_path = ['_static', ] diff --git a/docs/source/index.rst b/docs/source/index.rst index 37373255..420650bd 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -9,7 +9,7 @@ f3dasm .. toctree:: :name: gettingstartedtoc - :caption: Getting started + :caption: 🚢 Getting started :maxdepth: 3 :hidden: :includehidden: @@ -20,21 +20,21 @@ f3dasm .. toctree:: :name: functionalitiestoc - :caption: Functionalities + :caption: 🌊 Functionalities :maxdepth: 3 :hidden: :includehidden: - auto_examples/index + rst_doc_files/gallery rst_doc_files/defaults .. toctree:: :name: apitoc - :caption: API + :caption: πŸ“– Reference :hidden: rst_doc_files/reference/index.rst - API reference <_autosummary/f3dasm> + API documentation <_autosummary/f3dasm> .. toctree:: :name: licensetoc diff --git a/docs/source/license.rst b/docs/source/license.rst index cb7ae73f..17751b21 100644 --- a/docs/source/license.rst +++ b/docs/source/license.rst @@ -3,7 +3,7 @@ BSD 3-Clause License ==================== -Copyright (c) 2022, Martin van der Schelling +Copyright (c) 2025, Martin van der Schelling All rights reserved. diff --git a/docs/source/notebooks/builtins/builtinfunction.ipynb b/docs/source/notebooks/builtins/builtinfunction.ipynb new file mode 100644 index 00000000..327c7ab6 --- /dev/null +++ b/docs/source/notebooks/builtins/builtinfunction.ipynb @@ -0,0 +1,562 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Use the built-in benchmark functions\n", + "\n", + "In this example, we will use the built-in benchmark functions provided by the `f3dasm.datageneration` submodule to generate output for a data-driven experiment.\n", + "\n", + "The `f3dasm` framework includes a collection of benchmark functions designed for testing the performance of optimization algorithms or simulating expensive computations to evaluate the data-driven process. These functions are adapted from the [Python Benchmark Test Optimization Function Single Objective GitHub repository](https://github.com/AxelThevenot/Python_Benchmark_Test_Optimization_Function_Single_Objective).\n", + "\n", + "Let’s start by creating a continuous domain with 2 input variables, each ranging from -1.0 to 1.0" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import make_nd_continuous_domain\n", + "\n", + "domain = make_nd_continuous_domain([[-1., 1.], [-1., 1.]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We generate the input data by sampling the domain equally spaced with the grid sampler and create the ExperimentData object:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open-1.0-1.0
1open-1.0-0.9
2open-1.0-0.8
3open-1.0-0.7
4open-1.0-0.6
............
395open0.90.5
396open0.90.6
397open0.90.7
398open0.90.8
399open0.90.9
\n", + "

400 rows Γ— 3 columns

\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open -1.0 -1.0\n", + "1 open -1.0 -0.9\n", + "2 open -1.0 -0.8\n", + "3 open -1.0 -0.7\n", + "4 open -1.0 -0.6\n", + ".. ... ... ...\n", + "395 open 0.9 0.5\n", + "396 open 0.9 0.6\n", + "397 open 0.9 0.7\n", + "398 open 0.9 0.8\n", + "399 open 0.9 0.9\n", + "\n", + "[400 rows x 3 columns]" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from f3dasm import ExperimentData\n", + "experiment_data = ExperimentData.from_sampling(\n", + " 'grid', domain=domain, stepsize_continuous_parameters=0.1)\n", + "\n", + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Evaluating a 2D version of the 'Ackley' function can be done in two ways:\n", + "\n", + "## Method 1: Providing a function name as a string\n", + "\n", + "simply calling the `evaluate()` method with the function name as the `data_generator` argument:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished-1.0-1.03.625385
1finished-1.0-0.93.712731
2finished-1.0-0.84.107175
3finished-1.0-0.74.476565
4finished-1.0-0.64.658923
...............
395finished0.90.54.519325
396finished0.90.64.555183
397finished0.90.74.412588
398finished0.90.84.100991
399finished0.90.93.767178
\n", + "

400 rows Γ— 4 columns

\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished -1.0 -1.0 3.625385\n", + "1 finished -1.0 -0.9 3.712731\n", + "2 finished -1.0 -0.8 4.107175\n", + "3 finished -1.0 -0.7 4.476565\n", + "4 finished -1.0 -0.6 4.658923\n", + ".. ... ... ... ...\n", + "395 finished 0.9 0.5 4.519325\n", + "396 finished 0.9 0.6 4.555183\n", + "397 finished 0.9 0.7 4.412588\n", + "398 finished 0.9 0.8 4.100991\n", + "399 finished 0.9 0.9 3.767178\n", + "\n", + "[400 rows x 4 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_data.evaluate(data_generator='Ackley')\n", + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 2: Importing the function from the `f3dasm.datageneration.functions` module\n", + "\n", + "Another way is to import the `ackley()` function from the `f3dasm.datageneration.functions` module and calling it. This will return a `DataGenerator` object that can be used to evaluate the function." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.datageneration.functions import ackley" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In addition, you can provide the followinging keywords to the function call:\n", + "- `scale_bounds`: A 2D list of floats that define the scaling lower and upper boundaries for each dimension. The normal benchmark function box-constraints will be scaled to these boundaries.\n", + "- `noise`: A float that defines the standard deviation of the Gaussian noise that is added to the objective value.\n", + "- `offset`: A boolean value. If True, the benchmark function will be offset by a constant vector that will be randomly generated\\*.\n", + "- `seed`: Seed for the random number generator for the noise and offset calculations.\n", + "\n", + "\\* As benchmark functions usually have their minimum at the origin, the offset is used to test the robustness of the optimization algorithm.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished-1.0-1.03.625385
1finished-1.0-0.93.712731
2finished-1.0-0.84.107175
3finished-1.0-0.74.476565
4finished-1.0-0.64.658923
...............
395finished0.90.54.519325
396finished0.90.64.555183
397finished0.90.74.412588
398finished0.90.84.100991
399finished0.90.93.767178
\n", + "

400 rows Γ— 4 columns

\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished -1.0 -1.0 3.625385\n", + "1 finished -1.0 -0.9 3.712731\n", + "2 finished -1.0 -0.8 4.107175\n", + "3 finished -1.0 -0.7 4.476565\n", + "4 finished -1.0 -0.6 4.658923\n", + ".. ... ... ... ...\n", + "395 finished 0.9 0.5 4.519325\n", + "396 finished 0.9 0.6 4.555183\n", + "397 finished 0.9 0.7 4.412588\n", + "398 finished 0.9 0.8 4.100991\n", + "399 finished 0.9 0.9 3.767178\n", + "\n", + "[400 rows x 4 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_data.evaluate(data_generator=ackley(scale_bounds=[[-1., 1.], [-1., 1.]], offset=False))\n", + "experiment_data" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "arr_in, arr_out = experiment_data.to_numpy()\n", + "fig, ax = plt.subplots(subplot_kw={'projection': '3d'})\n", + "ax.scatter(arr_in[:, 0], arr_in[:, 1], arr_out.ravel())\n", + "_ = ax.set_xlabel('$x_0$')\n", + "_ = ax.set_ylabel('$x_1$')\n", + "_ = ax.set_zlabel('$f(x)$')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/builtins/builtinoptimizers.ipynb b/docs/source/notebooks/builtins/builtinoptimizers.ipynb new file mode 100644 index 00000000..78ea3fa8 --- /dev/null +++ b/docs/source/notebooks/builtins/builtinoptimizers.ipynb @@ -0,0 +1,759 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Use the built-in optimization algorithms" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this example, we will use the built-in optimization algorithms to minimize a built-in benchmark function" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Domain(input_space={'x0': ContinuousParameter(lower_bound=0.0, upper_bound=1.0, log=False), 'x1': ContinuousParameter(lower_bound=0.0, upper_bound=1.0, log=False)}, output_space={})" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from f3dasm.design import make_nd_continuous_domain\n", + "domain = make_nd_continuous_domain(bounds=[[0., 1.], [0., 1.]])\n", + "domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We sample random points from the search space and evaluate the function at these points with the `'Ackley'` function:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished0.7739560.43887818.573435
1finished0.8585980.69736821.308662
2finished0.0941770.97562221.815402
3finished0.7611400.78606420.719672
4finished0.1281140.45038621.396762
5finished0.3707980.92676521.396784
6finished0.6438650.82276221.112180
7finished0.4434140.22723919.952145
8finished0.5545850.06381721.959833
9finished0.8276310.63166421.527180
\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished 0.773956 0.438878 18.573435\n", + "1 finished 0.858598 0.697368 21.308662\n", + "2 finished 0.094177 0.975622 21.815402\n", + "3 finished 0.761140 0.786064 20.719672\n", + "4 finished 0.128114 0.450386 21.396762\n", + "5 finished 0.370798 0.926765 21.396784\n", + "6 finished 0.643865 0.822762 21.112180\n", + "7 finished 0.443414 0.227239 19.952145\n", + "8 finished 0.554585 0.063817 21.959833\n", + "9 finished 0.827631 0.631664 21.527180" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from f3dasm import ExperimentData\n", + "# 1. Sample the points from the domain\n", + "experiment_data = ExperimentData.from_sampling(sampler='random', domain=domain, n_samples=10, seed=42)\n", + "\n", + "# 2. Evaluate the points with the built-in Ackley function\n", + "experiment_data.evaluate('Ackley', scale_bounds=[[0., 1.], [0., 1.]], offset=False)\n", + "\n", + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we will use the built-in optimization algorithm `'Nelder-Mead'` to minimize the output paramater `'y'`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 1: Using the Optimizer Name as a String\n", + "\n", + "You can easily initiate optimization by calling the `optimize()` method of the `ExperimentData` object and passing the optimizer's name as a string.\n", + "\n", + "- **Iterative Optimization Process**: Since optimization occurs iteratively, you need to evaluate new points and update the `ExperimentData` object with the resulting data. To facilitate this, pass a `DataGenerator` object as an argument to the `optimize()` method. Any additional arguments for the `DataGenerator` can be provided as a dictionary through the `kwargs` parameter.\n", + "- **Default and Custom Hyperparameters**: By default, the optimizer runs with predefined hyperparameters. To customize them, pass the desired hyperparameters as keyword arguments to the `optimize()` method.\n", + "- **Specifying Iterations**: Use the `iterations` keyword to specify the number of function evaluations to perform during optimization.\n", + "- **Initial Point Strategy (`x0_strategy`)**: This argument defines how the starting point of the optimization is selected:\n", + " - `'best'` (default): Uses the best point in the `ExperimentData` object as the starting point.\n", + " - `'last'`: Uses the most recently added point in the `ExperimentData` object as the starting point.\n", + " - `'new'`: Samples a new random point from the search space. Optionally, you can specify a `sampler` argument to control how the initial point is sampled (defaults to random uniform sampling)." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/martin/mambaforge/envs/f3dasm_env3/lib/python3.8/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "/home/martin/mambaforge/envs/f3dasm_env3/lib/python3.8/site-packages/scipy/optimize/_minimize.py:549: RuntimeWarning: Method Nelder-Mead does not use gradient information (jac).\n", + " warn('Method %s does not use gradient information (jac).' % method,\n" + ] + } + ], + "source": [ + "experiment_data.optimize(optimizer='nelder mead', data_generator='ackley',\n", + " kwargs={'scale_bounds': [[0., 1.], [0., 1.]], 'offset': False},\n", + " iterations=10)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished0.7739560.43887818.573435
1finished0.8585980.69736821.308662
2finished0.0941770.97562221.815402
3finished0.7611400.78606420.719672
4finished0.1281140.45038621.396762
5finished0.3707980.92676521.396784
6finished0.6438650.82276221.112180
7finished0.4434140.22723919.952145
8finished0.5545850.06381721.959833
9finished0.8276310.63166421.527180
10finished0.7739560.43887821.465102
11finished0.7739560.43887821.465102
12finished0.7739560.43887821.465102
13finished0.7739560.43887821.465102
14finished0.7739560.43887821.465102
15finished0.7739560.43887821.465102
16finished0.7739560.43887821.465102
17finished0.7739560.43887821.465102
18finished0.7739560.43887821.465102
19finished0.7739560.43887821.465102
\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished 0.773956 0.438878 18.573435\n", + "1 finished 0.858598 0.697368 21.308662\n", + "2 finished 0.094177 0.975622 21.815402\n", + "3 finished 0.761140 0.786064 20.719672\n", + "4 finished 0.128114 0.450386 21.396762\n", + "5 finished 0.370798 0.926765 21.396784\n", + "6 finished 0.643865 0.822762 21.112180\n", + "7 finished 0.443414 0.227239 19.952145\n", + "8 finished 0.554585 0.063817 21.959833\n", + "9 finished 0.827631 0.631664 21.527180\n", + "10 finished 0.773956 0.438878 21.465102\n", + "11 finished 0.773956 0.438878 21.465102\n", + "12 finished 0.773956 0.438878 21.465102\n", + "13 finished 0.773956 0.438878 21.465102\n", + "14 finished 0.773956 0.438878 21.465102\n", + "15 finished 0.773956 0.438878 21.465102\n", + "16 finished 0.773956 0.438878 21.465102\n", + "17 finished 0.773956 0.438878 21.465102\n", + "18 finished 0.773956 0.438878 21.465102\n", + "19 finished 0.773956 0.438878 21.465102" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 2: Importing the optimizer from the `f3dasm.optimization` module\n", + "\n", + "Alternatively, you can import the optimizer from the `f3dasm.optimization` module and use it directly. This method provides more flexibility in customizing the optimizer's hyperparameters and behavior." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# Recreate the experiment data before optimizing\n", + "experiment_data = ExperimentData.from_sampling(\n", + " sampler='random', domain=domain, n_samples=10, seed=42\n", + " )\n", + "\n", + "experiment_data.evaluate('Ackley', scale_bounds=[[0., 1.], [0., 1.]], offset=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.optimization import nelder_mead" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `nelder_mead` function can be called without parameters to use the default hyperparameters. To customize the optimizer, pass the desired hyperparameters as keyword arguments to the constructor:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "optimizer = nelder_mead()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we call the `optimize()` method with the optimizer object to perform the optimization." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/martin/mambaforge/envs/f3dasm_env3/lib/python3.8/site-packages/scipy/optimize/_minimize.py:549: RuntimeWarning: Method Nelder-Mead does not use gradient information (jac).\n", + " warn('Method %s does not use gradient information (jac).' % method,\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished0.7739560.43887818.573435
1finished0.8585980.69736821.308662
2finished0.0941770.97562221.815402
3finished0.7611400.78606420.719672
4finished0.1281140.45038621.396762
5finished0.3707980.92676521.396784
6finished0.6438650.82276221.112180
7finished0.4434140.22723919.952145
8finished0.5545850.06381721.959833
9finished0.8276310.63166421.527180
10finished0.7739560.43887821.465102
11finished0.7739560.43887821.465102
12finished0.7739560.43887821.465102
13finished0.7739560.43887821.465102
14finished0.7739560.43887821.465102
15finished0.7739560.43887821.465102
16finished0.7739560.43887821.465102
17finished0.7739560.43887821.465102
18finished0.7739560.43887821.465102
19finished0.7739560.43887821.465102
\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished 0.773956 0.438878 18.573435\n", + "1 finished 0.858598 0.697368 21.308662\n", + "2 finished 0.094177 0.975622 21.815402\n", + "3 finished 0.761140 0.786064 20.719672\n", + "4 finished 0.128114 0.450386 21.396762\n", + "5 finished 0.370798 0.926765 21.396784\n", + "6 finished 0.643865 0.822762 21.112180\n", + "7 finished 0.443414 0.227239 19.952145\n", + "8 finished 0.554585 0.063817 21.959833\n", + "9 finished 0.827631 0.631664 21.527180\n", + "10 finished 0.773956 0.438878 21.465102\n", + "11 finished 0.773956 0.438878 21.465102\n", + "12 finished 0.773956 0.438878 21.465102\n", + "13 finished 0.773956 0.438878 21.465102\n", + "14 finished 0.773956 0.438878 21.465102\n", + "15 finished 0.773956 0.438878 21.465102\n", + "16 finished 0.773956 0.438878 21.465102\n", + "17 finished 0.773956 0.438878 21.465102\n", + "18 finished 0.773956 0.438878 21.465102\n", + "19 finished 0.773956 0.438878 21.465102" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_data.optimize(optimizer='nelder mead', data_generator='ackley',\n", + " kwargs={'scale_bounds': [[0., 1.], [0., 1.]], 'offset': False},\n", + " iterations=10)\n", + "\n", + "experiment_data" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/builtins/builtinsamplers.ipynb b/docs/source/notebooks/builtins/builtinsamplers.ipynb new file mode 100644 index 00000000..6d922a45 --- /dev/null +++ b/docs/source/notebooks/builtins/builtinsamplers.ipynb @@ -0,0 +1,389 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Use the built-in sampling strategies\n", + "\n", + "In this example, we will use the built-in sampling strategies provided by `f3dasm` to generate samples for a data-driven experiment.\n", + "We first create 2D continuous input domain with the `make_nd_continuous_domain()` helper function:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Domain(input_space={'x0': ContinuousParameter(lower_bound=0.0, upper_bound=1.0, log=False), 'x1': ContinuousParameter(lower_bound=0.0, upper_bound=1.0, log=False)}, output_space={})" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from f3dasm.design import make_nd_continuous_domain\n", + "domain = make_nd_continuous_domain(bounds=[[0., 1.], [0., 1.]])\n", + "domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Sampling from this domain can be done in two ways:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 1: Providing a sampler name as a string:\n", + "\n", + "\n", + "simply call the `ExperimentData.from_sampling` method of the with the domain and the name of the sampler as a string. Some sampler require additional parameters, which can be passed as keyword arguments:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.7739560.438878
1open0.8585980.697368
2open0.0941770.975622
3open0.7611400.786064
4open0.1281140.450386
5open0.3707980.926765
6open0.6438650.822762
7open0.4434140.227239
8open0.5545850.063817
9open0.8276310.631664
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.773956 0.438878\n", + "1 open 0.858598 0.697368\n", + "2 open 0.094177 0.975622\n", + "3 open 0.761140 0.786064\n", + "4 open 0.128114 0.450386\n", + "5 open 0.370798 0.926765\n", + "6 open 0.643865 0.822762\n", + "7 open 0.443414 0.227239\n", + "8 open 0.554585 0.063817\n", + "9 open 0.827631 0.631664" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from f3dasm import ExperimentData\n", + "\n", + "samples = ExperimentData.from_sampling(sampler='random', domain=domain,\n", + " seed=42, n_samples=10)\n", + "\n", + "samples" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 2: Importing the sampler from the `f3dasm.design` module\n", + "\n", + "Another way is to import e.g. the `random()` sampler from the `f3dasm.design` module and pass it to the `ExperimentData.from_sampling` method:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import random\n", + "\n", + "sampler = random(seed=42)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.7739560.438878
1open0.8585980.697368
2open0.0941770.975622
3open0.7611400.786064
4open0.1281140.450386
5open0.3707980.926765
6open0.6438650.822762
7open0.4434140.227239
8open0.5545850.063817
9open0.8276310.631664
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.773956 0.438878\n", + "1 open 0.858598 0.697368\n", + "2 open 0.094177 0.975622\n", + "3 open 0.761140 0.786064\n", + "4 open 0.128114 0.450386\n", + "5 open 0.370798 0.926765\n", + "6 open 0.643865 0.822762\n", + "7 open 0.443414 0.227239\n", + "8 open 0.554585 0.063817\n", + "9 open 0.827631 0.631664" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "samples = ExperimentData.from_sampling(sampler=sampler, domain=domain,\n", + " n_samples=10)\n", + "\n", + "samples" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Text(0, 0.5, 'x1')" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "fig, ax = plt.subplots(figsize=(4, 4))\n", + "\n", + "df_random, _ = samples.to_pandas()\n", + "ax.scatter(df_random.iloc[:, 0], df_random.iloc[:, 1])\n", + "ax.set_xlabel(domain.input_names[0])\n", + "ax.set_ylabel(domain.input_names[1])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/data-driven/block.png b/docs/source/notebooks/data-driven/block.png new file mode 100644 index 00000000..b1c0eb57 Binary files /dev/null and b/docs/source/notebooks/data-driven/block.png differ diff --git a/docs/source/notebooks/data-driven/block.svg b/docs/source/notebooks/data-driven/block.svg new file mode 100644 index 00000000..33f8603f --- /dev/null +++ b/docs/source/notebooks/data-driven/block.svg @@ -0,0 +1,221 @@ + + + + + Paper schematic template + + + + + + + + + + + + + + + + + + image/svg+xml + + Paper schematic template + + + + + + + Block + + + ExperimentData + arm(self, data) + + + + ExperimentData + call(self, **kwargs) + + + diff --git a/docs/source/notebooks/data-driven/blocks.ipynb b/docs/source/notebooks/data-driven/blocks.ipynb new file mode 100644 index 00000000..29c343e6 --- /dev/null +++ b/docs/source/notebooks/data-driven/blocks.ipynb @@ -0,0 +1,142 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Blocks\n", + "\n", + "In the `f3dasm` framework, every component of the data-driven process is encapsulated as a `Block`. A block is an object designed to work with an `ExperimentData` instance. When invoked, it processes the data within the `ExperimentData` instance and produces a new `ExperimentData` instance. By chaining different blocks, you can construct a complete data-driven pipeline." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The block base class looks like this:\n", + "\n", + "```python\n", + "\n", + "class Block(ABC):\n", + " def arm(self, data: ExperimentData) -> None:\n", + " self.data = data\n", + "\n", + " @abstractmethod\n", + " def call(self, **kwargs) -> ExperimentData:\n", + " pass\n", + "\n", + "```\n", + "\n", + "To create a new block, subclass the `Block` class and implement the `call` method. This method is executed when the block is invoked, accepting any keyword arguments and returning an `ExperimentData` instance. Before the `call` method runs, the `arm` method is used to equip the block with the `ExperimentData` instance it will process.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + " \"Block\"\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```python\n", + "class CustomBlock(Block)\n", + " def call(self):\n", + " ...\n", + " # Any method that manipulates dthe experiments\n", + " ...\n", + " return self.data\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "In order to start the data-driven process, you need to create an `ExperimentData` instance and call the `run()` method of experiment data instance with the block object(s) you want to run." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```pyton\n", + "custom_block = CustomBlock()\n", + "\n", + "experiment_data.run(block=custom_block)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Multiple blocks can be chained together by passing a list of blocks to the `run` method.\n", + "\n", + "```python\n", + "experiment_data.run(block=[custom_block, another_custom_block])\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + " \"Blocks\"\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A single block or a list of blocks can be 'looped' over by passing them through the `f3dasm.loop` function. This function will return a new block that will run the original block(s) multiple times.\n", + "\n", + "```python\n", + "\n", + "from f3dasm import loop\n", + "\n", + "looped_block = loop(custom_block, n_loops=10)\n", + "\n", + "experiment_data.run(block=looped_block)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + " \"Block\n", + "
" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/data-driven/blocks.png b/docs/source/notebooks/data-driven/blocks.png new file mode 100644 index 00000000..27f06379 Binary files /dev/null and b/docs/source/notebooks/data-driven/blocks.png differ diff --git a/docs/source/notebooks/data-driven/blocks.svg b/docs/source/notebooks/data-driven/blocks.svg new file mode 100644 index 00000000..d6a0c985 --- /dev/null +++ b/docs/source/notebooks/data-driven/blocks.svg @@ -0,0 +1,246 @@ + + + + + Paper schematic template + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Paper schematic template + + + + + + + + Block + + + + AnotherBlock + + + + + ExperimentData + ExperimentData + + diff --git a/docs/source/notebooks/data-driven/blocks_loop.png b/docs/source/notebooks/data-driven/blocks_loop.png new file mode 100644 index 00000000..f89b1466 Binary files /dev/null and b/docs/source/notebooks/data-driven/blocks_loop.png differ diff --git a/docs/source/notebooks/data-driven/blocks_loop.svg b/docs/source/notebooks/data-driven/blocks_loop.svg new file mode 100644 index 00000000..e43ddb9a --- /dev/null +++ b/docs/source/notebooks/data-driven/blocks_loop.svg @@ -0,0 +1,265 @@ + + + + + Paper schematic template + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Paper schematic template + + + + + + + + Block + + + + AnotherBlock + + + + + ExperimentData + ExperimentData + + n_loops + + diff --git a/docs/source/notebooks/data-driven/carstoppingdistance.ipynb b/docs/source/notebooks/data-driven/carstoppingdistance.ipynb new file mode 100644 index 00000000..30e89e04 --- /dev/null +++ b/docs/source/notebooks/data-driven/carstoppingdistance.ipynb @@ -0,0 +1,801 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Car stopping distance problem\n", + "\n", + "In this example, we will implement a data-driven process that generates output for a data-driven experiment. We will use the β€˜car stopping distance’ problem as an example." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + " \"car\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Defining the problem\n", + "\n", + "Car stopping distance $y$ as a function of its velocity $x$ before it starts braking:\n", + "\n", + "$y = z x + \\frac{1}{2 \\mu g} x^2 = z x + 0.1 x^2$\n", + "- $z$ is the driver's reaction time (in seconds)\n", + "- $\\mu$ is the road/tires coefficient of friction (we assume $\\mu=0.5$)\n", + "- $g$ is the acceleration of gravity (assume $g=10 m/s^2$).\n", + "\n", + "$y = d_r + d_{b}$\n", + "- where $d_r$ is the reaction distance, and $d_b$ is the braking distance.\n", + "\n", + "Reaction distance $d_r$:\n", + "\n", + "$d_r = z x$\n", + "- with $z$ being the driver's reaction time, $x$ being the velocity of the car at the start of braking.\n", + "\n", + "Kinetic energy of moving car:\n", + "\n", + "$E = \\frac{1}{2}m x^2$\n", + "- where $m$ is the car mass.\n", + "\n", + "Work done by braking:\n", + "\n", + "$W = \\mu m g d_b$\n", + "- where $\\mu$ is the coefficient of friction between the road and the tire, $g$ is the acceleration of gravity, and $d_b$ is the car braking distance.\n", + "\n", + "The braking distance follows from $E=W$:\n", + "\n", + "$d_b = \\frac{1}{2\\mu g}x^2$\n", + "\n", + "Therefore, if we add the reacting distance $d_r$ to the braking distance $d_b$ we get the stopping distance $y$:\n", + "\n", + "$y = d_r + d_b = z x + \\frac{1}{2\\mu g} x^2$\n", + "\n", + "Every driver has its own reaction time $z$. Assume the distribution associated to $z$ is Gaussian with mean $\\mu_z=1.5$ seconds and variance $\\sigma_z^2=0.5^2$ seconds $^2$:\n", + "\n", + "$z \\sim \\mathcal{N}(\\mu_z=1.5,\\sigma_z^2=0.5^2)$\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We create a function that generates the stopping distance $y$ given the velocity $x$ and the reaction time $z$:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.stats import norm\n", + "\n", + "def y(x):\n", + " z = norm.rvs(1.5, 0.5, size=1)\n", + " y = float(z*x + 0.1*x**2)\n", + " return y" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we create a design-of-experiments by creating a Domain object with $x$ as the car velocity:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import Domain\n", + "\n", + "domain = Domain()\n", + "domain.add_float('x', low=0., high=100.)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For demonstration purposes, we will generate a dataset of stopping distances for velocities between 3 and 83 m/s." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x
0open3.000000
1open3.808081
2open4.616162
3open5.424242
4open6.232323
.........
95open79.767677
96open80.575758
97open81.383838
98open82.191919
99open83.000000
\n", + "

100 rows Γ— 2 columns

\n", + "
" + ], + "text/plain": [ + " jobs input\n", + " x\n", + "0 open 3.000000\n", + "1 open 3.808081\n", + "2 open 4.616162\n", + "3 open 5.424242\n", + "4 open 6.232323\n", + ".. ... ...\n", + "95 open 79.767677\n", + "96 open 80.575758\n", + "97 open 81.383838\n", + "98 open 82.191919\n", + "99 open 83.000000\n", + "\n", + "[100 rows x 2 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "from f3dasm import ExperimentData\n", + "\n", + "N = 33 # number of points to generate\n", + "Data_x = np.linspace(3, 83, 100)\n", + "\n", + "experiment_data = ExperimentData(input_data=Data_x, domain=domain)\n", + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As you can see, the `ExperimentData` object has been created successfully and the jobs have the label `β€˜open’`. This means that the output has not been generated yet. Now, we want to compute the stopping distance for each velocity in the design-of-experiments. There are several ways to approach this with `f3dasm`:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 1: Use the `Block` abstraction directly:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We create a new class `CarStoppingDistance` that inherits from `Block` and implements the `call` method accordingly:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm import Block\n", + "\n", + "class CarStoppingDistance(Block):\n", + " def call(self):\n", + " for id, experiment_sample in self.data:\n", + "\n", + " # Extract the car velocity x from the experiment sample\n", + " x = experiment_sample.input_data['x']\n", + "\n", + " # Evaluate the stopping distance y(x)\n", + " distance = y(x)\n", + "\n", + " # Store the stopping distance back in the experiment sample\n", + " experiment_sample.store(name='distance', object=distance)\n", + "\n", + " # Mark the experiment as finished\n", + " experiment_sample.mark('finished')\n", + "\n", + " # After all experiments are finished, return the data\n", + " return self.data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We create a new instance of `CarStoppingDistance` and run it on our experiments:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
xdistance
0finished3.0000007.318851
1finished3.8080815.617512
2finished4.61616213.960788
3finished5.4242427.987772
4finished6.23232316.914891
............
95finished79.767677777.219006
96finished80.575758750.739983
97finished81.383838809.754817
98finished82.191919809.877997
99finished83.000000825.231563
\n", + "

100 rows Γ— 3 columns

\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x distance\n", + "0 finished 3.000000 7.318851\n", + "1 finished 3.808081 5.617512\n", + "2 finished 4.616162 13.960788\n", + "3 finished 5.424242 7.987772\n", + "4 finished 6.232323 16.914891\n", + ".. ... ... ...\n", + "95 finished 79.767677 777.219006\n", + "96 finished 80.575758 750.739983\n", + "97 finished 81.383838 809.754817\n", + "98 finished 82.191919 809.877997\n", + "99 finished 83.000000 825.231563\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "car_stopping_distance = CarStoppingDistance()\n", + "experiment_data.run(car_stopping_distance)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 2: Using the `DataGenerator` class:\n", + "\n", + "The `DataGenerator` class is a wrapper around the `Block` class that simplifies the process of running a function on every experiment. Instead of implementing the `call()` method of this block and operating on the whole `ExperimentData`, we implement an `execute()` method that operates on each `ExperimentSample` iteratively. The currently processed `ExperimentSample` is stored in the `experiment_sample` attribute of the `DataGenerator` class." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.datageneration import DataGenerator\n", + "\n", + "class CarStoppingDistanceDataGenerator(DataGenerator):\n", + " def execute(self):\n", + " # Extract the car velocity x from the experiment sample\n", + " x = self.experiment_sample.input_data['x']\n", + "\n", + " # Evaluate the stopping distance y(x)\n", + " distance = y(x)\n", + "\n", + " # Store the stopping distance back in the experiment sample\n", + " self.experiment_sample.store(name='distance', object=distance)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "car_stopping_distance_datagenerator = CarStoppingDistanceDataGenerator()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
xdistance
0finished3.0000004.133655
1finished3.8080819.113758
2finished4.6161627.694777
3finished5.42424211.401391
4finished6.23232313.355383
............
95finished79.767677752.353654
96finished80.575758803.593101
97finished81.383838748.149040
98finished82.191919720.842429
99finished83.000000813.449692
\n", + "

100 rows Γ— 3 columns

\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x distance\n", + "0 finished 3.000000 4.133655\n", + "1 finished 3.808081 9.113758\n", + "2 finished 4.616162 7.694777\n", + "3 finished 5.424242 11.401391\n", + "4 finished 6.232323 13.355383\n", + ".. ... ... ...\n", + "95 finished 79.767677 752.353654\n", + "96 finished 80.575758 803.593101\n", + "97 finished 81.383838 748.149040\n", + "98 finished 82.191919 720.842429\n", + "99 finished 83.000000 813.449692\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Recreate the experiment data\n", + "experiment_data = ExperimentData(input_data=Data_x, domain=domain)\n", + "\n", + "# Evaluate the experiment data on the DataGenerator\n", + "experiment_data.evaluate(car_stopping_distance_datagenerator, mode='sequential')\n", + "\n", + "experiment_data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There are three methods available of evaluating the experiments:\n", + "\n", + "- `'sequential'`: regular for-loop over each of the experiments in order\n", + "- `'parallel'`: utilizing the multiprocessing capabilities (with the pathos multiprocessing library), each experiment is run in a separate core\n", + "- `'cluster'`: each experiment is run in a seperate node. This is especially useful on a high-performance computation cluster where you have multiple worker nodes and a commonly accessible resource folder. After completion of an experiment, the node will automatically pick the next available open experiment.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Method 3: Using the function directly\n", + "\n", + "The function `y(x)` can be called directly on the `ExperimentData.evaluate` method. This is the simplest way to run a function on every experiment in the design-of-experiments.\n", + "In order to use this method, we need to specify the `output_names` of the return values of the function `y(x)` in the method call:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
xdistance
0finished3.0000006.865499
1finished3.8080818.001959
2finished4.61616212.034931
3finished5.4242427.698961
4finished6.23232318.099358
............
95finished79.767677705.504497
96finished80.575758718.290648
97finished81.383838732.702359
98finished82.191919776.652027
99finished83.000000716.987663
\n", + "

100 rows Γ— 3 columns

\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x distance\n", + "0 finished 3.000000 6.865499\n", + "1 finished 3.808081 8.001959\n", + "2 finished 4.616162 12.034931\n", + "3 finished 5.424242 7.698961\n", + "4 finished 6.232323 18.099358\n", + ".. ... ... ...\n", + "95 finished 79.767677 705.504497\n", + "96 finished 80.575758 718.290648\n", + "97 finished 81.383838 732.702359\n", + "98 finished 82.191919 776.652027\n", + "99 finished 83.000000 716.987663\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Recreate the experiment data\n", + "experiment_data = ExperimentData(input_data=Data_x, domain=domain)\n", + "\n", + "# Evaluate the experiment data on the DataGenerator\n", + "experiment_data.evaluate(y, output_names=['distance'], mode='sequential')\n", + "\n", + "experiment_data" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/data-driven/cluster.ipynb b/docs/source/notebooks/data-driven/cluster.ipynb new file mode 100644 index 00000000..2c503195 --- /dev/null +++ b/docs/source/notebooks/data-driven/cluster.ipynb @@ -0,0 +1,259 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Using `f3dasm` on a High-Performance Cluster Computer\n", + "\n", + "Your `f3dasm` workflow can be seamlessly translated to a high-performance computing cluster. \n", + "The advantage is that you can parallelize the total number of experiments among the nodes of the cluster. \n", + "This is especially useful when you have a large number of experiments to run.\n", + "\n", + "> This example has been tested on the following high-performance computing cluster systems:\n", + "> \n", + "> - The [hpc06 cluster of Delft University of Technology](https://hpcwiki.tudelft.nl/index.php/Main_Page), using the [TORQUE resource manager](https://en.wikipedia.org/wiki/TORQUE).\n", + "> - The [DelftBlue: TU Delft supercomputer](https://www.tudelft.nl/dhpc/system), using the [SLURM resource manager](https://slurm.schedmd.com/documentation.html).\n", + "> - The [OSCAR compute cluster from Brown University](https://docs.ccv.brown.edu/oscar/getting-started), using the [SLURM resource manager](https://slurm.schedmd.com/documentation.html).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from time import sleep\n", + "\n", + "import numpy as np\n", + "\n", + "from f3dasm import HPC_JOBID, ExperimentData\n", + "from f3dasm.design import make_nd_continuous_domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will create the following data-driven process:\n", + "\n", + "- Create a 20D continuous `Domain`.\n", + "- Sample from the domain using a Latin-hypercube sampler.\n", + "- With multiple nodes, use a data generation function, which will be the `\"Ackley\"` function from the benchmark functions.\n", + "\n", + "
\n", + " \"Block\"\n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We want to ensure that the sampling is done only once, and that the data generation is performed in parallel. \n", + "Therefore, we can divide the different nodes into two categories:\n", + "\n", + "- The first node (`f3dasm.HPC_JOBID == 0`) will be the **master** node, responsible for creating the design-of-experiments and sampling (the `create_experimentdata` function).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "def create_experimentdata():\n", + " \"\"\"Design of Experiment\"\"\"\n", + " # Create a domain object\n", + " domain = make_nd_continuous_domain(\n", + " bounds=np.tile([0.0, 1.0], (20, 1)), dimensionality=20)\n", + "\n", + " # Create the ExperimentData object\n", + " data = ExperimentData(domain=domain)\n", + "\n", + " # Sampling from the domain\n", + " data.sample(sampler='latin', n_samples=10)\n", + "\n", + " # Store the data to disk\n", + " data.store()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- All the other nodes (`f3dasm.HPC_JOBID > 0`) will be **process** nodes, which will retrieve the `ExperimentData` from disk and proceed directly to the data generation function.\n", + "\n", + "
\n", + " \"Block\"\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "def worker_node():\n", + " # Extract the experimentdata from disk\n", + " data = ExperimentData.from_file(project_dir='.')\n", + "\n", + " \"\"\"Data Generation\"\"\"\n", + " # Use the data-generator to evaluate the initial samples\n", + " data.evaluate(data_generator='Ackley', mode='cluster')\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The entrypoint of the script can now check the jobid of the current node and decide whether to create the experiment data or to run the data generation function:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "if __name__ == '__main__':\n", + " # Check the jobid of the current node\n", + " if HPC_JOBID is None:\n", + " # If the jobid is none, we are not running anything now\n", + " pass\n", + "\n", + " elif HPC_JOBID == 0:\n", + " create_experimentdata()\n", + " worker_node()\n", + " elif HPC_JOBID > 0:\n", + " # Asynchronize the jobs in order to omit racing conditions\n", + " sleep(HPC_JOBID)\n", + " worker_node()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Running the Program\n", + "\n", + "You can run the workflow by submitting the bash script to the HPC queue. \n", + "Make sure you have [miniconda3](https://docs.anaconda.com/free/miniconda/index.html) installed on the cluster and that you have created a conda environment (in this example named `f3dasm_env`) with the necessary packages.\n", + "\n", + "### TORQUE Example\n", + "\n", + "---\n", + "\n", + "```bash\n", + "#!/bin/bash\n", + "# Torque directives (#PBS) must always be at the start of a job script!\n", + "#PBS -N ExampleScript\n", + "#PBS -q mse\n", + "#PBS -l nodes=1:ppn=12,walltime=12:00:00\n", + "\n", + "# Make sure I'm the only one that can read my output\n", + "umask 0077\n", + "\n", + "# The PBS_JOBID looks like 1234566[0].\n", + "# With the following line, we extract the PBS_ARRAYID, the part in the brackets []:\n", + "PBS_ARRAYID=$(echo \"${PBS_JOBID}\" | sed 's/\\[[^][]*\\]//g')\n", + "\n", + "module load use.own\n", + "module load miniconda3\n", + "cd $PBS_O_WORKDIR\n", + "\n", + "# Activate my conda environment:\n", + "source activate f3dasm_env\n", + "\n", + "# Limit the number of threads\n", + "OMP_NUM_THREADS=12\n", + "export OMP_NUM_THREADS=12\n", + "\n", + "# If the PBS_ARRAYID is not set, set it to None\n", + "if ! [ -n \"${PBS_ARRAYID+1}\" ]; then\n", + " PBS_ARRAYID=None\n", + "fi\n", + "\n", + "# Execute my Python program with the jobid flag\n", + "python main.py --jobid=${PBS_ARRAYID}\n", + "```\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### SLURM Example\n", + "\n", + "---\n", + "\n", + "```bash\n", + "#!/bin/bash -l\n", + "\n", + "#SBATCH -J \"ExampleScript\" # Name of the job\n", + "#SBATCH --get-user-env # Set environment variables\n", + "\n", + "#SBATCH --partition=compute\n", + "#SBATCH --time=12:00:00\n", + "#SBATCH --nodes=1\n", + "#SBATCH --ntasks-per-node=12\n", + "#SBATCH --cpus-per-task=1\n", + "#SBATCH --mem=0\n", + "#SBATCH --account=research-eemcs-me\n", + "#SBATCH --array=0-2\n", + "\n", + "source activate f3dasm_env\n", + "\n", + "# Execute my Python program with the jobid flag\n", + "python3 main.py --jobid=${SLURM_ARRAY_TASK_ID}\n", + "```\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can run the workflow by submitting the bash script to the HPC queue. \n", + "The following command submits an array job with 3 jobs where `f3dasm.HPC_JOBID` takes values of 0, 1, and 2.\n", + "\n", + "### TORQUE Example\n", + "\n", + "```bash\n", + "qsub pbsjob.sh -t 0-2\n", + "```\n", + "\n", + "### SLURM Example\n", + "\n", + "```bash\n", + "sbatch --array 0-2 pbsjob.sh\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/data-driven/gridsampler.ipynb b/docs/source/notebooks/data-driven/gridsampler.ipynb new file mode 100644 index 00000000..8e8c1b17 --- /dev/null +++ b/docs/source/notebooks/data-driven/gridsampler.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Implementing a grid search sampler from scratch\n", + "\n", + "In this example, we will implement a [grid search sampler](https://en.wikipedia.org/wiki/Hyperparameter_optimization) from scratch. The grid search sampler is a simple sampler that evaluates all possible combinations of the parameters in the domain. This is useful for small domains, but it can become computationally expensive for larger domains. We will show how to create this sampler and use it in a `f3dasm` data-driven experiment." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from __future__ import annotations\n", + "\n", + "from itertools import product\n", + "from typing import Dict, Optional\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from f3dasm import ExperimentData, Block\n", + "from f3dasm.design import Domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When integrating your sampling strategy into the data-driven process, you have to create a new class that inherits from the `Block` base class." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "class GridSampler(Block):\n", + " def call(self, stepsize_continuous_parameters: Optional[Dict[str, float]] = None) -> ExperimentData:\n", + "\n", + " # Extract only the continuous variables\n", + " continuous = self.data.domain.continuous\n", + " discrete = self.data.domain.discrete\n", + " categorical = self.data.domain.categorical\n", + " constant = self.data.domain.constant\n", + "\n", + " _iterdict = {}\n", + "\n", + " if continuous.input_space:\n", + "\n", + " discrete_space = {key: continuous.input_space[key].to_discrete(\n", + " step=value) for key,\n", + " value in stepsize_continuous_parameters.items()}\n", + "\n", + " continuous = Domain(input_space=discrete_space)\n", + "\n", + " for k, v in categorical.input_space.items():\n", + " _iterdict[k] = v.categories\n", + "\n", + " for k, v, in discrete.input_space.items():\n", + " _iterdict[k] = range(v.lower_bound, v.upper_bound+1, v.step)\n", + "\n", + " for k, v, in continuous.input_space.items():\n", + " _iterdict[k] = np.arange(\n", + " start=v.lower_bound, stop=v.upper_bound, step=v.step)\n", + "\n", + " for k, v, in constant.input_space.items():\n", + " _iterdict[k] = [v.value]\n", + "\n", + " df = pd.DataFrame(list(product(*_iterdict.values())),\n", + " columns=_iterdict, dtype=object\n", + " )[self.data.domain.input_names]\n", + "\n", + " return ExperimentData(domain=self.data.domain,\n", + " input_data=df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will now sample the domain using the grid sampler we implemented.\n", + "- First, we will create a domain with a mix of continuous, discrete, and categorical parameters to test our implementation." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "domain = Domain()\n", + "domain.add_float(\"param_1\", -1.0, 1.0)\n", + "domain.add_int(\"param_2\", 1, 5)\n", + "domain.add_category(\"param_3\", [\"red\", \"blue\", \"green\", \"yellow\", \"purple\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- We create an `ExperimentData` object with the domain:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "experiment_data = ExperimentData(domain=domain)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- Then, we can create a `GridSampler` block object:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "grid_sampler = GridSampler()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- Lastly, we call the `run()` method on the created `ExperimentData`, providing the grid sampler:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
param_1param_2param_3
0open-1.01red
1open-0.91red
2open-0.81red
3open-0.71red
4open-0.61red
...............
495open0.55purple
496open0.65purple
497open0.75purple
498open0.85purple
499open0.95purple
\n", + "

500 rows Γ— 4 columns

\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " param_1 param_2 param_3\n", + "0 open -1.0 1 red\n", + "1 open -0.9 1 red\n", + "2 open -0.8 1 red\n", + "3 open -0.7 1 red\n", + "4 open -0.6 1 red\n", + ".. ... ... ... ...\n", + "495 open 0.5 5 purple\n", + "496 open 0.6 5 purple\n", + "497 open 0.7 5 purple\n", + "498 open 0.8 5 purple\n", + "499 open 0.9 5 purple\n", + "\n", + "[500 rows x 4 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_data.run(grid_sampler, stepsize_continuous_parameters={\"param_1\": 0.1})" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/data-driven/reaction-braking-stopping.png b/docs/source/notebooks/data-driven/reaction-braking-stopping.png new file mode 100644 index 00000000..915c20db Binary files /dev/null and b/docs/source/notebooks/data-driven/reaction-braking-stopping.png differ diff --git a/docs/source/notebooks/design/domain_creation.ipynb b/docs/source/notebooks/design/domain_creation.ipynb new file mode 100644 index 00000000..230fc604 --- /dev/null +++ b/docs/source/notebooks/design/domain_creation.ipynb @@ -0,0 +1,495 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Introduction to domain and parameters\n", + "\n", + "This section will give you information on how to set up your search space with the `Domain` class and the paramaters\n", + "The `Domain` contains a dictionary of parameter instances for both the `input_space` and `output_space` that make up the feasible search space.\n", + "This notebook demonstrates how to use the `Domain` class effectively, from initialization to advanced use cases.\n", + "\n", + "The `Domain` class can be imported from the `f3dasm.design` module:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import Domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A domain object can be created as follows" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Creating a Domain Object\n", + "\n", + "To start, we create an empty domain object:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "domain = Domain()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "### Input Parameters\n", + "\n", + "Now we will add some input parameters. You can use the `add_parameter` method to add an input parameter:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_parameter(name='x0')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Parameters can be of any type. `f3dasm` has built-in support for the following types:\n", + "\n", + "- floating point parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_float(name='x1', low=0.0, high=100.0)\n", + "domain.add_float(name='x2', low=0.0, high=4.0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- discrete integer parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_int(name='x3', low=2, high=4)\n", + "domain.add_int(name='x4', low=74, high=99)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- categorical parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_category(name='x5', categories=['test1', 'test2', 'test3', 'test4'])\n", + "domain.add_category(name='x6', categories=[0.9, 0.2, 0.1, -2])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- constant parameters" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_constant(name='x7', value=0.9)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can print the domain object to see the parameters that have been added:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Domain(\n", + " Input Space: { x0: Parameter(type=object, to_disk=False), x1: ContinuousParameter(lower_bound=0.0, upper_bound=100.0, log=False), x2: ContinuousParameter(lower_bound=0.0, upper_bound=4.0, log=False), x3: DiscreteParameter(lower_bound=2, upper_bound=4, step=1), x4: DiscreteParameter(lower_bound=74, upper_bound=99, step=1), x5: CategoricalParameter(categories=['test1', 'test2', 'test3', 'test4']), x6: CategoricalParameter(categories=[0.9, 0.2, 0.1, -2]), x7: ConstantParameter(value=0.9) }\n", + " Output Space: { }\n", + ")\n" + ] + } + ], + "source": [ + "print(domain)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Output Parameters\n", + "\n", + "Output parameters are the results of evaluating the input design with a data generation model. Output parameters can hold any type of data, e.g., a scalar value, a vector, a matrix, etc. Normally, you would not need to define output parameters, as they are created automatically when you store a variable to the `ExperimentData` object." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_output(name='y', to_disk=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Storing parameters on disk\n", + "\n", + "As you will see in the next section, ?the `ExperimentData` object stores data associated with parameters. The data is stored in a tabular format, where each row corresponds to a single evaluation of the designspace. The columns of the table correspond to the input parameters and the output values.\n", + "\n", + "Sometimes it is wise to store the data associated with a parameter separately outside this table:\n", + "- when the data associated with a parameter is very large (e.g., large arrays or matrices), it allows you to lazy-load the data when needed\n", + "- when the data should not or cannot be casted to a `.csv` file (e.g., a custom object)\n", + "\n", + "You can choose to only store a reference in the `ExperimentData` object and store the data on disk. This can be done by setting the `to_disk` parameter to `True` when adding the parameter to the domain.\n", + "\n", + "`f3dasm` supports storing and loading data for a few commonly used data types:\n", + "\n", + "- numpy arrays\n", + "- pandas dataframes\n", + "- xarray datasets and data arrays\n", + "\n", + "For any other data types, you have to define custom functions to store and load data. These functions should take the data as input and return a string that can be used to identify the data when loading it. You can define these functions using the `store_function` and `load_function` parameters when adding the parameter to the domain.\n", + "\n", + "The following example demonstrates how to store and load a numpy array to and from disk. We will use a custom store and load function for this example, but these functions are not necessary for numpy arrays, as `f3dasm` provides built-in support for storing and loading numpy arrays:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import numpy as np\n", + "\n", + "def numpy_store(object: np.ndarray, path: str) -> str:\n", + " \"\"\"\n", + " Store a numpy array.\n", + "\n", + " Parameters\n", + " ----------\n", + " object : np.ndarray\n", + " The numpy array to store.\n", + " path : str\n", + " The path where the array will be stored.\n", + "\n", + " Returns\n", + " -------\n", + " str\n", + " The path to the stored array.\n", + " \"\"\"\n", + " _path = Path(path).with_suffix('.npy')\n", + " np.save(file=_path, arr=object)\n", + " return str(_path)\n", + "\n", + "\n", + "def numpy_load(path: str) -> np.ndarray:\n", + " \"\"\"\n", + " Load a numpy array.\n", + "\n", + " Parameters\n", + " ----------\n", + " path : str\n", + " The path to the array to load.\n", + "\n", + " Returns\n", + " -------\n", + " np.ndarray\n", + " The loaded array.\n", + " \"\"\"\n", + " _path = Path(path).with_suffix('.npy')\n", + " return np.load(file=_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With these functions defined, we can add the parameter to the input of the domain:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_parameter(name='array_input', to_disk=True,\n", + " store_function=numpy_store, load_function=numpy_load)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In the same fashion, we can add an output parameter to the domain:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "domain.add_output(name='array_output', to_disk=True,\n", + " store_function=numpy_store, load_function=numpy_load)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "## Filtering the Domain\n", + "\n", + "The domain object can be filtered to only include certain types of parameters. This might be useful when you want to create a design of experiments with only continuous parameters, for example.\n", + "\n", + "The attributes `Domain.continuous`, `Domain.discrete`, `Domain.categorical`, and `Domain.constant` can be used to filter the domain object." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Continuous domain: Domain(\n", + " Input Space: { x1: ContinuousParameter(lower_bound=0.0, upper_bound=100.0, log=False), x2: ContinuousParameter(lower_bound=0.0, upper_bound=4.0, log=False) }\n", + " Output Space: { }\n", + ")\n", + "Discrete domain: Domain(\n", + " Input Space: { x3: DiscreteParameter(lower_bound=2, upper_bound=4, step=1), x4: DiscreteParameter(lower_bound=74, upper_bound=99, step=1) }\n", + " Output Space: { }\n", + ")\n", + "Categorical domain: Domain(\n", + " Input Space: { x5: CategoricalParameter(categories=['test1', 'test2', 'test3', 'test4']), x6: CategoricalParameter(categories=[0.9, 0.2, 0.1, -2]) }\n", + " Output Space: { }\n", + ")\n", + "Constant domain: Domain(\n", + " Input Space: { x7: ConstantParameter(value=0.9) }\n", + " Output Space: { }\n", + ")\n" + ] + } + ], + "source": [ + "print(f\"Continuous domain: {domain.continuous}\")\n", + "print(f\"Discrete domain: {domain.discrete}\")\n", + "print(f\"Categorical domain: {domain.categorical}\")\n", + "print(f\"Constant domain: {domain.constant}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Storing the `Domain` object\n", + "\n", + "The `Domain` object can be stored to disk using the `store` method. This method saves the domain object to a JSON file." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "domain.store('my_domain.json')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `Domain` object can be loaded from disk using the `Domain.from_file` method:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Domain(input_space={'x0': Parameter(to_disk=False), 'x1': ContinuousParameter(lower_bound=0.0, upper_bound=100.0, log=False), 'x2': ContinuousParameter(lower_bound=0.0, upper_bound=4.0, log=False), 'x3': DiscreteParameter(lower_bound=2, upper_bound=4, step=1), 'x4': DiscreteParameter(lower_bound=74, upper_bound=99, step=1), 'x5': CategoricalParameter(categories=['test1', 'test2', 'test3', 'test4']), 'x6': CategoricalParameter(categories=[0.9, 0.2, 0.1, -2]), 'x7': ConstantParameter(value=0.9), 'array_input': Parameter(to_disk=True)}, output_space={'y': Parameter(to_disk=False), 'array_output': Parameter(to_disk=True)})" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "Domain.from_file('my_domain.json')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> Custom storing and loading functions will be encoded with `pickle` and converted to hexadecimal strings. This allows you to store and load custom functions without having to define them again." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "## Helper Function for Single-Objective, N-Dimensional Continuous Domains\n", + "\n", + "We can easily create an $n$-dimensional continuous domain with the helper function `make_nd_continuous_domain`. We have to specify the boundaries (bounds) for each of the dimensions with a list of lists or a NumPy `numpy.ndarray`:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import make_nd_continuous_domain" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Domain(\n", + " Input Space: { x0: ContinuousParameter(lower_bound=-1.0, upper_bound=1.0, log=False), x1: ContinuousParameter(lower_bound=-1.0, upper_bound=1.0, log=False) }\n", + " Output Space: { }\n", + ")\n" + ] + } + ], + "source": [ + "bounds = [[-1.0, 1.0], [-1.0, 1.0]]\n", + "domain = make_nd_continuous_domain(bounds=bounds)\n", + "\n", + "print(domain)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/design/my_domain.json b/docs/source/notebooks/design/my_domain.json new file mode 100644 index 00000000..8e861606 --- /dev/null +++ b/docs/source/notebooks/design/my_domain.json @@ -0,0 +1,97 @@ +{ + "input_space": { + "x0": { + "type": "object", + "to_disk": false, + "store_function": null, + "load_function": null + }, + "x1": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 100.0, + "log": false + }, + "x2": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 4.0, + "log": false + }, + "x3": { + "type": "int", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 2, + "upper_bound": 4, + "step": 1 + }, + "x4": { + "type": "int", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 74, + "upper_bound": 99, + "step": 1 + }, + "x5": { + "type": "category", + "to_disk": false, + "store_function": null, + "load_function": null, + "categories": [ + "test1", + "test2", + "test3", + "test4" + ] + }, + "x6": { + "type": "category", + "to_disk": false, + "store_function": null, + "load_function": null, + "categories": [ + 0.9, + 0.2, + 0.1, + -2 + ] + }, + "x7": { + "type": "constant", + "to_disk": false, + "store_function": null, + "load_function": null, + "value": 0.9 + }, + "array_input": { + "type": "object", + "to_disk": true, + "store_function": "8004951c000000000000008c085f5f6d61696e5f5f948c0b6e756d70795f73746f72659493942e", + "load_function": "8004951b000000000000008c085f5f6d61696e5f5f948c0a6e756d70795f6c6f61649493942e" + } + }, + "output_space": { + "y": { + "type": "object", + "to_disk": false, + "store_function": null, + "load_function": null + }, + "array_output": { + "type": "object", + "to_disk": true, + "store_function": "8004951c000000000000008c085f5f6d61696e5f5f948c0b6e756d70795f73746f72659493942e", + "load_function": "8004951b000000000000008c085f5f6d61696e5f5f948c0a6e756d70795f6c6f61649493942e" + } + } +} \ No newline at end of file diff --git a/docs/source/notebooks/experimentdata/experimentdata.ipynb b/docs/source/notebooks/experimentdata/experimentdata.ipynb new file mode 100644 index 00000000..57ad72e2 --- /dev/null +++ b/docs/source/notebooks/experimentdata/experimentdata.ipynb @@ -0,0 +1,1403 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# The `ExperimentData` object\n", + "\n", + "We define an experiment as a single set of input parameters that are used to run a simulation or an experiment. `f3dasm` uses a custom `ExperimentSample` object to keep track of these inputs and outputs of the experiments.\n", + "\n", + "Each `ExperimentSample` is effectively an individual experiment. \n", + "- It contains a dictionary `input_data` with key-value pairs of the input variables and a dictionary `output_data` with key-value pairs of the resulted output variables.\n", + "- The property `job_status` is used to keep track of the status of the experiment. It can be one of the following values: `open`, `in progress`, `finished`, or `error`.\n", + "\n", + "All of these individual experiments are bundled together in the custom `ExperimentData` object. The `ExperimentData` object is the main object used to keep track of results, perform optimization and extract data for machine learning purposes. All other processses of `f3dasm` use this object to manipulate and access data about your experiments.\n", + "\n", + "The ExperimentData object consists of the following attributes:\n", + "\n", + "- `domain`: The `Domain` of the Experiment. This is used for keeping track of the input and output variables of the experiments\n", + "- `data`: A dictionary containing the data of the experiments. The keys of the dictionary are numerical identifiers (starting from $0$) of the experiments and the values are `ExperimentSample` objects. \n", + "- `project_dir`: A user-defined project directory where all data related to your data-driven process will be stored." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Creating the `ExperimentData` object\n", + "\n", + "The `ExperimentData` object can be constructed in several ways:\n", + "\n", + "You can construct a ExperimentData object by providing it `input data`, `output data`, a `Domain` object and a project directory:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm import ExperimentData" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `domain` object needs to be constructed before creating the `ExperimentData` object. The `Domain` object is used to keep track of the input and output variables of the experiments:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from f3dasm.design import Domain\n", + "\n", + "domain = Domain()\n", + "domain.add_float(name='x0', low=0., high=1.)\n", + "domain.add_float(name='x1', low=0., high=1.)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `input_data` and `output_data` can be provided in a tabular matter and in one of the following formats:\n", + "- a 2D numpy array. The first dimension corresponds to the number of experiments and the second dimension corresponds to the input/output variables.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.10.4
1open0.20.5
2open0.30.6
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.1 0.4\n", + "1 open 0.2 0.5\n", + "2 open 0.3 0.6" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import numpy as np\n", + "\n", + "input_data = np.array([\n", + " [0.1, 0.4],\n", + " [0.2, 0.5],\n", + " [0.3, 0.6]\n", + "])\n", + "\n", + "experimentdata = ExperimentData(domain=domain, input_data=input_data)\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- a pandas DataFrame. The columns of the DataFrame correspond to the input/output variables and the rows correspond to the experiments." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.10.4
1open0.20.5
2open0.30.6
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.1 0.4\n", + "1 open 0.2 0.5\n", + "2 open 0.3 0.6" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "input_data = pd.DataFrame({\n", + " 'x0': [0.1, 0.2, 0.3],\n", + " 'x1': [0.4, 0.5, 0.6]\n", + "})\n", + "\n", + "\n", + "experimentdata = ExperimentData(domain=domain, input_data=input_data)\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n", + "- a list of dictionaries. Each dictionary corresponds to an experiment and the keys of the dictionary correspond to the input/output variables." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.10.4
1open0.20.5
2open0.30.6
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.1 0.4\n", + "1 open 0.2 0.5\n", + "2 open 0.3 0.6" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "input_data = [{'x0': 0.1, 'x1': 0.4}, \n", + " {'x0': 0.2, 'x1': 0.5}, \n", + " {'x0': 0.3, 'x1': 0.6}]\n", + "\n", + "experimentdata = ExperimentData(domain=domain, input_data=input_data)\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> It is also possible to infer the parameter names from the input and output data. This will automatically infer the names of the input and output variables from the input and output data. For numpy arrays, there is no way to infer the names of the variables, so default names (e.g. `x0`, `x1`, `y0`, `y1`) will be used. " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- a path to a `.csv` file." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.10.4
1open0.20.5
2open0.30.6
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.1 0.4\n", + "1 open 0.2 0.5\n", + "2 open 0.3 0.6" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "input_data = pd.DataFrame({\n", + " 'x0': [0.1, 0.2, 0.3],\n", + " 'x1': [0.4, 0.5, 0.6]\n", + "})\n", + "\n", + "# For the sake of this example, we store the input_data in a file:\n", + "input_data.to_csv('input_data.csv')\n", + "\n", + "# Now we can load the input_data from the file:\n", + "experimentdata = ExperimentData(domain=domain, input_data='input_data.csv')\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We demonstrated how to use various datatypes for the `input_data` but the same applies to the `output_data` of the `ExperimentData` object:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished0.10.40.5
1finished0.20.50.6
2open0.30.6NaN
\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished 0.1 0.4 0.5\n", + "1 finished 0.2 0.5 0.6\n", + "2 open 0.3 0.6 NaN" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "domain.add_output(name='y')\n", + "\n", + "input_data = [{'x0': 0.1, 'x1': 0.4}, \n", + " {'x0': 0.2, 'x1': 0.5}, \n", + " {'x0': 0.3, 'x1': 0.6}]\n", + "\n", + "output_data = [{'y': 0.5},\n", + " {'y': 0.6}]\n", + "\n", + "experimentdata = ExperimentData(domain=domain, input_data=input_data, output_data=output_data)\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For experiments where the output data is given upon creation, the `job_status` will be set to `finished`, indicating that these experiments do not need to be run again.\n", + "For experiments where the output data is not given upon creation, the `job_status` will be set to `open`, indicating that these experiments are open to be evaluated.\n", + "\n", + "The status of a job can be manually set by using the `mark` method of the `ExperimentData` object:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinputoutput
x0x1y
0finished0.10.40.5
1open0.20.50.6
2open0.30.6NaN
\n", + "
" + ], + "text/plain": [ + " jobs input output\n", + " x0 x1 y\n", + "0 finished 0.1 0.4 0.5\n", + "1 open 0.2 0.5 0.6\n", + "2 open 0.3 0.6 NaN" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experimentdata.mark(indices=1, status='open')\n", + "experimentdata" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Upon inspecting the `ExperimentData` object, you will see that the `data` attribute is a dictionary with numerical identifiers as keys and `ExperimentSample` objects as values:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "defaultdict(f3dasm._src.experimentsample.ExperimentSample,\n", + " {0: ExperimentSample(input_data={'x0': 0.1, 'x1': 0.4}, output_data={'y': 0.5}, job_status=JobStatus.FINISHED),\n", + " 1: ExperimentSample(input_data={'x0': 0.2, 'x1': 0.5}, output_data={'y': 0.6}, job_status=JobStatus.OPEN),\n", + " 2: ExperimentSample(input_data={'x0': 0.3, 'x1': 0.6}, output_data={}, job_status=JobStatus.OPEN)})" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experimentdata.data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Manipulating the `ExperimentData` object" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Multiple `ExperimentData` objects can be combined using the `+` operator. This will create a new `ExperimentData` object that contains all the experiments from the two original `ExperimentData` objects.\n", + "If applicable, the `ExperimentData` objects are combined as well." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
jobsinput
x0x1
0open0.10.4
1open0.20.5
2open0.30.6
3open0.70.3
4open0.80.2
5open0.90.1
\n", + "
" + ], + "text/plain": [ + " jobs input \n", + " x0 x1\n", + "0 open 0.1 0.4\n", + "1 open 0.2 0.5\n", + "2 open 0.3 0.6\n", + "3 open 0.7 0.3\n", + "4 open 0.8 0.2\n", + "5 open 0.9 0.1" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experimentdata_1 = ExperimentData(domain=domain, input_data=np.array([\n", + " [0.1, 0.4],\n", + " [0.2, 0.5],\n", + " [0.3, 0.6]\n", + "]))\n", + "\n", + "experimentdata_2 = ExperimentData(domain=domain, input_data=pd.DataFrame({\n", + " 'x0': [0.7, 0.8, 0.9],\n", + " 'x1': [0.3, 0.2, 0.1]\n", + "}))\n", + "\n", + "experimentdata_1 + experimentdata_2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Storing the `ExperimentData` object\n", + "\n", + "The `ExperimentData` object can be stored to a series of files using the `store()` method. In this example, we will show how to store the `ExperimentData` to disk using the `store()` method and how to load the stored data using the `from_file()` method.\n", + "\n", + "- The `project_dir` argument of the is used to store the `ExperimentData` to disk. You can provide a string or a path to a directory. This can either be a relative or absolute path. If the directory does not exist, it will be created.\n", + "- The `store()` method is used to store the experiment data to the directory provided." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "experimentdata.set_project_dir('./my_project')\n", + "\n", + "experimentdata.store()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The data is stored in several files in an `/experiment_data` subfolder in the provided project directory:\n", + "\n", + "```\n", + "my_project/\n", + "β”œβ”€β”€ my_script.py\n", + "└── experiment_data\n", + " β”œβ”€β”€ domain.json\n", + " β”œβ”€β”€ input_data.csv\n", + " β”œβ”€β”€ output_data.csv\n", + " └── jobs.csv\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to load the data, you can use `ExperimentData.from_file()`:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "data_loaded = ExperimentData.from_file(project_dir=\"./my_project\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Exporting the `ExperimentData` object\n", + "\n", + "The `ExperimentData` object can be exported to several common data formats:\n", + "\n", + "- To two numpy arrays (one for the input data and one for the output data) using the `to_numpy()` method." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(array([[0.1, 0.4],\n", + " [0.2, 0.5],\n", + " [0.3, 0.6]]),\n", + " array([[0.5],\n", + " [0.6],\n", + " [nan]]))" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experimentdata.to_numpy()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- To two pandas DataFrames (one for the input data and one for the output data) using the `to_pandas()` method." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
x0x1
00.10.4
10.20.5
20.30.6
\n", + "
" + ], + "text/plain": [ + " x0 x1\n", + "0 0.1 0.4\n", + "1 0.2 0.5\n", + "2 0.3 0.6" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_input, df_output = experimentdata.to_pandas()\n", + "df_input" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "- to an `xarray.Dataset` object using the `to_xarray()` method:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset>\n",
+       "Dimensions:     (iterations: 3, input_dim: 2, output_dim: 1)\n",
+       "Coordinates:\n",
+       "  * iterations  (iterations) int64 0 1 2\n",
+       "  * input_dim   (input_dim) object 'x0' 'x1'\n",
+       "  * output_dim  (output_dim) object 'y'\n",
+       "Data variables:\n",
+       "    input       (iterations, input_dim) float64 0.1 0.4 0.2 0.5 0.3 0.6\n",
+       "    output      (iterations, output_dim) float64 0.5 0.6 nan
" + ], + "text/plain": [ + "\n", + "Dimensions: (iterations: 3, input_dim: 2, output_dim: 1)\n", + "Coordinates:\n", + " * iterations (iterations) int64 0 1 2\n", + " * input_dim (input_dim) object 'x0' 'x1'\n", + " * output_dim (output_dim) object 'y'\n", + "Data variables:\n", + " input (iterations, input_dim) float64 0.1 0.4 0.2 0.5 0.3 0.6\n", + " output (iterations, output_dim) float64 0.5 0.6 nan" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experimentdata.to_xarray()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/notebooks/experimentdata/input_data.csv b/docs/source/notebooks/experimentdata/input_data.csv new file mode 100644 index 00000000..4fd60426 --- /dev/null +++ b/docs/source/notebooks/experimentdata/input_data.csv @@ -0,0 +1,4 @@ +,x0,x1 +0,0.1,0.4 +1,0.2,0.5 +2,0.3,0.6 diff --git a/docs/source/notebooks/experimentdata/my_project/experiment_data/domain.json b/docs/source/notebooks/experimentdata/my_project/experiment_data/domain.json new file mode 100644 index 00000000..46af0a52 --- /dev/null +++ b/docs/source/notebooks/experimentdata/my_project/experiment_data/domain.json @@ -0,0 +1,30 @@ +{ + "input_space": { + "x0": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 1.0, + "log": false + }, + "x1": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 1.0, + "log": false + } + }, + "output_space": { + "y": { + "type": "object", + "to_disk": false, + "store_function": null, + "load_function": null + } + } +} \ No newline at end of file diff --git a/docs/source/notebooks/experimentdata/my_project/experiment_data/input.csv b/docs/source/notebooks/experimentdata/my_project/experiment_data/input.csv new file mode 100644 index 00000000..4fd60426 --- /dev/null +++ b/docs/source/notebooks/experimentdata/my_project/experiment_data/input.csv @@ -0,0 +1,4 @@ +,x0,x1 +0,0.1,0.4 +1,0.2,0.5 +2,0.3,0.6 diff --git a/docs/source/notebooks/experimentdata/my_project/experiment_data/jobs.csv b/docs/source/notebooks/experimentdata/my_project/experiment_data/jobs.csv new file mode 100644 index 00000000..f78a9d54 --- /dev/null +++ b/docs/source/notebooks/experimentdata/my_project/experiment_data/jobs.csv @@ -0,0 +1,4 @@ +,0 +0,FINISHED +1,OPEN +2,OPEN diff --git a/docs/source/notebooks/experimentdata/my_project/experiment_data/output.csv b/docs/source/notebooks/experimentdata/my_project/experiment_data/output.csv new file mode 100644 index 00000000..19b41063 --- /dev/null +++ b/docs/source/notebooks/experimentdata/my_project/experiment_data/output.csv @@ -0,0 +1,4 @@ +,y +0,0.5 +1,0.6 +2, diff --git a/docs/source/notebooks/hydra/usehydra.ipynb b/docs/source/notebooks/hydra/usehydra.ipynb new file mode 100644 index 00000000..a2aed022 --- /dev/null +++ b/docs/source/notebooks/hydra/usehydra.ipynb @@ -0,0 +1,203 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Combining hydra configurations with `f3dasm`\n", + "\n", + "[hydra](https://hydra.cc/) is an open-source configuration management framework that is widely used in machine learning and other software development domains. It is designed to help developers manage and organize complex configuration settings for their projects, making it easier to experiment with different configurations, manage multiple environments, and maintain reproducibility in their work.\n", + "\n", + "[hydra](https://hydra.cc/) can be seamlessly integrated with the worfklows in f3dasm to manage the configuration settings for the project." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from hydra import compose, initialize\n", + "\n", + "from f3dasm import ExperimentData\n", + "from f3dasm.design import Domain" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `Domain` from a [hydra](https://hydra.cc/) configuration file\n", + "\n", + "If you are using [hydra](https://hydra.cc/) to manage your configuration files, you can create a `Domain` from a configuration file. Your config needs to have a key (e.g. `'domain'`) that has two keys: `'input_space'` and `'output_space'`. Each design space dictionary can have parameter names (e.g. `'param_1'`) as keys and a dictionary with an optional parameter type (`'type'`) and the corresponding arguments as values:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```yaml\n", + "domain:\n", + " input:\n", + " param_1:\n", + " type: float\n", + " low: -1.0\n", + " high: 1.0\n", + " param_2:\n", + " type: int\n", + " low: 1\n", + " high: 10\n", + " param_3:\n", + " type: category\n", + " categories: ['red', 'blue', 'green', 'yellow', 'purple']\n", + " param_4:\n", + " type: constant\n", + " value: some_value\n", + " output:\n", + " y:\n", + " to_disk: False\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to run the following code snippet, you need to have a configuration file named `'config.yaml'` in the current working directory." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```python\n", + "with initialize(version_base=None, config_path=\".\"):\n", + " config = compose(config_name=\"config\")\n", + "\n", + "domain = Domain.from_yaml(config.domain)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `ExperimentData` from a [hydra](https://hydra.cc/) configuration file" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you are using [hydra](https://hydra.cc/) for configuring your experiments, you can use it to construct an `ExperimentData` object from the information in the `'config.yaml'` file with the `from_yaml()` method:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### `ExperimentData` from file\n", + "\n", + "You can create an `ExperimentData` object in the same way as the `from_file()` method, but with the `'from_file'` key in the `'config.yaml'` file:\n", + "\n", + "```yaml\n", + "experimentdata:\n", + " from_file: ./example_project_dir\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```python\n", + "\n", + "with initialize(version_base=None, config_path=\".\"):\n", + " config = compose(config_name=\"config\")\n", + "\n", + "\n", + "experiment_data = ExperimentData.from_yaml(config.experimentdata)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `ExperimentData` from sampling with [hydra](https://hydra.cc/)\n", + "\n", + "To create the `ExperimentData` object with the `from_sampling()` method, you can use the following configuration:\n", + "\n", + "```yaml\n", + "domain:\n", + " input:\n", + " param_1:\n", + " type: float\n", + " low: -1.0\n", + " high: 1.0\n", + " param_2:\n", + " type: int\n", + " low: 1\n", + " high: 10\n", + " param_3:\n", + " type: category\n", + " categories: ['red', 'blue', 'green', 'yellow', 'purple']\n", + " param_4:\n", + " type: constant\n", + " value: some_value\n", + " output:\n", + " y:\n", + " to_disk: False\n", + "\n", + "experimentdata:\n", + " from_sampling:\n", + " domain: ${domain}\n", + " sampler: random\n", + " seed: 1\n", + " n_samples: 1\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```python\n", + "\n", + "with initialize(version_base=None, config_path=\".\"):\n", + " config = compose(config_name=\"config\")\n", + "\n", + "experiment_data = ExperimentData.from_yaml(config.experimentdata)\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> You can also combine both approaches to create the `ExperimentData` object by continuing an existing experiment with new samples. This can be done by providing both keys `'from_sampling'` and '`from_file'`" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "f3dasm_env3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.17" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/readme.rst b/docs/source/readme.rst index 2fa4d79a..8521ed4f 100644 --- a/docs/source/readme.rst +++ b/docs/source/readme.rst @@ -21,7 +21,7 @@ The best way to get started is to: * Read the :ref:`overview` section, containing a brief introduction to the framework and a statement of need. * Follow the :ref:`installation-instructions` to get going! -* Check out the :ref:`examples` section, containing a collection of examples to get you familiar with the framework. +* Check out the :ref:`tutorials` section, containing a collection of examples to get you familiar with the framework. ---- @@ -31,6 +31,28 @@ Authorship & Citation .. [1] PhD Candiate, Delft University of Technology, `Website `_ , `GitHub `_ +.. note:: + + If you use ``f3dasm`` in your research or in a scientific publication, it is appreciated that you cite the paper below: + + **Journal of Open Source Software** (`paper `_): + + .. code-block:: bibtex + + @article{vanderSchelling2024, + title = {f3dasm: Framework for Data-Driven Design and Analysis of Structures and Materials}, + author = {M. P. van der Schelling and B. P. Ferreira and M. A. Bessa}, + doi = {10.21105/joss.06912}, + url = {https://doi.org/10.21105/joss.06912}, + year = {2024}, + publisher = {The Open Journal}, + volume = {9}, + number = {100}, + pages = {6912}, + journal = {Journal of Open Source Software} + } + + ---- Contribute @@ -42,8 +64,9 @@ Contribute Useful links ------------ * `GitHub repository `_ (source code) -* `Wiki for development `_ +* `Journal of Open Source Software `_ (paper) * `PyPI package `_ (distribution package) +* `Wiki for development `_ Related extension libraries --------------------------- @@ -53,7 +76,7 @@ Related extension libraries License ------- -Copyright 2024, Martin van der Schelling +Copyright 2025, Martin van der Schelling All rights reserved. diff --git a/docs/source/rst_doc_files/defaults.rst b/docs/source/rst_doc_files/defaults.rst index 76d5b9a6..d583169a 100644 --- a/docs/source/rst_doc_files/defaults.rst +++ b/docs/source/rst_doc_files/defaults.rst @@ -1,6 +1,85 @@ Built-in functionalities ======================== +:mod:`f3dasm` provides a set of built-in functionalities that can be used to perform data-driven optimization and sensitivity analysis. +All built-ins are implementations of the :class:`~f3dasm.Block` class and can be used on your :class:`~f3dasm.ExperimentData` object. + +Blocks can be run on a :class:`~f3dasm.ExperimentData` object by calling the :meth:`~f3dasm.ExperimentData.run` method. +Alternatively, you can use the following methods for specific parts of the data-driven process. + +=============================== ======================================= =================================================== +Part of the data-driven process Submodule for built-ins Method on :class:`~f3dasm.ExperimentData` +=============================== ======================================= =================================================== +Sampling :mod:`f3dasm.design` :meth:`f3dasm.ExperimentData.sample` +Data generation :mod:`f3dasm.datageneration` :meth:`f3dasm.ExperimentData.evaluate` +Optimization :mod:`f3dasm.optimization` :meth:`f3dasm.ExperimentData.optimize` +=============================== ======================================= =================================================== + +:mod:`f3dasm` provides two ways to use the built-in functionalities: + +1. Call the built-in functions + +You can import the built-in functions directly from the respective submodules and call them to change the (hyper)parameters. + +.. code-block:: python + + from f3dasm.design import random + from f3dasm.datageneration import ackley + + # Call the random uniform sampler with a specific seed + sampler_block = random(seed=123) + + # Create a 2D instance of the 'Ackley' function with its box-constraints scaled to [0, 1] + data_generation_block = ackley(scale_bounds=[[0., 1.], [0., 1.]]) + + # Create an empty Domain + domain = Domain() + + # Add two continuous parameters 'x0' and 'x1' + domain.add_float(name='x0', low=0.0, high=1.0) + domain.add_float(name='x1', low=0.0, high=1.0) + + # Create an empty ExperimentData object with the domain + experiment_data = ExperimentData(domain=domain) + + # 1. Sampling + experiment_data.sample(sampler=sampler_block, n_samples=10) + + # 2. Evaluating the samples + experiment_data.evaluate(data_generator=data_generation_block) + + +2. Use a string argument + +Alternatively, you can use a string argument to specify the built-in function you want to use. The default parameters will be used. + + +.. code-block:: python + + # Create an empty Domain + domain = Domain() + + # Add two continuous parameters 'x0' and 'x1' + domain.add_float(name='x0', low=0.0, high=1.0) + domain.add_float(name='x1', low=0.0, high=1.0) + + # Create an empty ExperimentData object with the domain + experiment_data = ExperimentData(domain=domain) + + # 1. Sampling + experiment_data.sample(sampler='random', n_samples=10) + + # 2. Evaluating the samples + experiment_data.evaluate(data_generator='ackley') + + +.. warning:: + + The built-in functionalities are designed with the built-in parameters in mind! + This means that in order to make use of the samplers, benchmark functions and optimizers, + you are restricted to add parameters via the :meth:`~f3dasm.design.Domain.add_float`, :meth:`~f3dasm.design.Domain.add_int`, + :meth:`~f3dasm.design.Domain.add_category` and :meth:`~f3dasm.design.Domain.add_constant` methods. + .. _implemented samplers: Implemented samplers @@ -8,14 +87,14 @@ Implemented samplers The following built-in implementations of samplers can be used in the data-driven process. -======================== ====================================================================== =========================================================================================================== -Name Method Reference -======================== ====================================================================== =========================================================================================================== -``"random"`` Random Uniform sampling `numpy.random.uniform `_ -``"latin"`` Latin Hypercube sampling `SALib.latin `_ -``"sobol"`` Sobol Sequence sampling `SALib.sobol_sequence `_ -``"grid"`` Grid Search sampling `itertools.product `_ -======================== ====================================================================== =========================================================================================================== +======================== ============================= ======================================== =========================================================================================================== +Name Key-word Function Reference +======================== ============================= ======================================== =========================================================================================================== +Random Uniform sampling ``"random"`` :func:`~f3dasm.design.random` `numpy.random.uniform `_ +Latin Hypercube sampling ``"latin"`` :func:`~f3dasm.design.latin` `SALib.latin `_ +Sobol Sequence sampling ``"sobol"`` :func:`~f3dasm.design.sobol` `SALib.sobol_sequence `_ +Grid Search sampling ``"grid"`` :func:`~f3dasm.design.grid` `itertools.product `_ +======================== ============================= ======================================== =========================================================================================================== .. _implemented-benchmark-functions: @@ -33,116 +112,113 @@ The following implementations of benchmark functions can instantiated with the n Convex functions ^^^^^^^^^^^^^^^^ -======================== ====================================================== =========================== -Name Docs of the Python class Data-generator argument -======================== ====================================================== =========================== -Ackley N. 2 :class:`~f3dasm.datageneration.AckleyN2` ``"Ackley N. 2"`` -Bohachevsky N. 1 :class:`~f3dasm.datageneration.BohachevskyN1` ``"Bohachevsky N. 1"`` -Booth :class:`~f3dasm.datageneration.Booth` ``"Booth"`` -Brent :class:`~f3dasm.datageneration.Brent` ``"Brent"`` -Brown :class:`~f3dasm.datageneration.Brown` ``"Brown"`` -Bukin N. 6 :class:`~f3dasm.datageneration.BukinN6` ``"Bukin N. 6"`` -Dixon Price :class:`~f3dasm.datageneration.DixonPrice` ``"Dixon Price"`` -Exponential :class:`~f3dasm.datageneration.Exponential` ``"Exponential"`` -Matyas :class:`~f3dasm.datageneration.Matyas` ``"Matyas"`` -McCormick :class:`~f3dasm.datageneration.McCormick` ``"McCormick"`` -Perm 0, d, beta :class:`~f3dasm.datageneration.PermZeroDBeta` ``"Perm 0, d, beta"`` -Powell :class:`~f3dasm.datageneration.Powell` ``"Powell"`` -Rotated Hyper-Ellipsoid :class:`~f3dasm.datageneration.RotatedHyperEllipsoid` ``"Rotated Hyper-Ellipsoid"`` -Schwefel 2.20 :class:`~f3dasm.datageneration.Schwefel2_20` ``"Schwefel 2.20"`` -Schwefel 2.21 :class:`~f3dasm.datageneration.Schwefel2_21` ``"Schwefel 2.21"`` -Schwefel 2.22 :class:`~f3dasm.datageneration.Schwefel2_22` ``"Schwefel 2.22"`` -Schwefel 2.23 :class:`~f3dasm.datageneration.Schwefel2_23` ``"Schwefel 2.23"`` -Sphere :class:`~f3dasm.datageneration.Sphere` ``"Sphere"`` -Sum Squares :class:`~f3dasm.datageneration.SumSquares` ``"Sum Squares"`` -Thevenot :class:`~f3dasm.datageneration.Thevenot` ``"Thevenot"`` -Trid :class:`~f3dasm.datageneration.Trid` ``"Trid"`` -Xin She Yang N.3 :class:`~f3dasm.datageneration.XinSheYangN3` ``"Xin She Yang N.3"`` -Xin-She Yang N.4 :class:`~f3dasm.datageneration.XinSheYangN4` ``"Xin-She Yang N.4"`` -======================== ====================================================== =========================== +======================== ====================================================== =============================================================== +Name Key-word Function +======================== ====================================================== =============================================================== +Ackley N. 2 ``"Ackley N. 2"`` :func:`~f3dasm.datageneration.functions.ackleyn2` +Bohachevsky N. 1 ``"Bohachevsky N. 1"`` :func:`~f3dasm.datageneration.functions.bohachevskyn1` +Booth ``"Booth"`` :func:`~f3dasm.datageneration.functions.booth` +Brent ``"Brent"`` :func:`~f3dasm.datageneration.functions.brent` +Brown ``"Brown"`` :func:`~f3dasm.datageneration.functions.brown` +Bukin N. 6 ``"Bukin N. 6"`` :func:`~f3dasm.datageneration.functions.bukinn6` +Dixon Price ``"Dixon Price"`` :func:`~f3dasm.datageneration.functions.dixonprice` +Exponential ``"Exponential"`` :func:`~f3dasm.datageneration.functions.exponential` +Matyas ``"Matyas"`` :func:`~f3dasm.datageneration.functions.matyas` +McCormick ``"McCormick"`` :func:`~f3dasm.datageneration.functions.mccormick` +Powell ``"Powell"`` :func:`~f3dasm.datageneration.functions.powell` +Rotated Hyper-Ellipsoid ``"Rotated Hyper-Ellipsoid"`` :func:`~f3dasm.datageneration.functions.rotatedhyperellipsoid` +Schwefel 2.20 ``"Schwefel 2.20"`` :func:`~f3dasm.datageneration.functions.schwefel2_20` +Schwefel 2.21 ``"Schwefel 2.21"`` :func:`~f3dasm.datageneration.functions.schwefel2_21` +Schwefel 2.22 ``"Schwefel 2.22"`` :func:`~f3dasm.datageneration.functions.schwefel2_22` +Schwefel 2.23 ``"Schwefel 2.23"`` :func:`~f3dasm.datageneration.functions.schwefel2_23` +Sphere ``"Sphere"`` :func:`~f3dasm.datageneration.functions.sphere` +Sum Squares ``"Sum Squares"`` :func:`~f3dasm.datageneration.functions.sumsquares` +Thevenot ``"Thevenot"`` :func:`~f3dasm.datageneration.functions.thevenot` +Trid ``"Trid"`` :func:`~f3dasm.datageneration.functions.trid` +======================== ====================================================== =============================================================== + Seperable functions ^^^^^^^^^^^^^^^^^^^ -======================== ============================================== ============================ -Name Docs of the Python class Data-generator argument -======================== ============================================== ============================ -Ackley :class:`~f3dasm.datageneration.Ackley` ``"Ackley"`` -Bohachevsky N. 1 :class:`~f3dasm.datageneration.BohachevskyN1` ``"Bohachevsky N. 1"`` -Easom :class:`~f3dasm.datageneration.Easom` ``"Easom"`` -Egg Crate :class:`~f3dasm.datageneration.EggCrate` ``"Egg Crate"`` -Exponential :class:`~f3dasm.datageneration.Exponential` ``"Exponential"`` -Griewank :class:`~f3dasm.datageneration.Griewank` ``"Griewank"`` -Michalewicz :class:`~f3dasm.datageneration.Michalewicz` ``"Michalewicz"`` -Powell :class:`~f3dasm.datageneration.Powell` ``"Powell"`` -Qing :class:`~f3dasm.datageneration.Qing` ``"Qing"`` -Quartic :class:`~f3dasm.datageneration.Quartic` ``"Quartic"`` -Rastrigin :class:`~f3dasm.datageneration.Rastrigin` ``"Rastrigin"`` -Schwefel :class:`~f3dasm.datageneration.Schwefel` ``"Schwefel"`` -Schwefel 2.20 :class:`~f3dasm.datageneration.Schwefel2_20` ``"Schwefel 2.20"`` -Schwefel 2.21 :class:`~f3dasm.datageneration.Schwefel2_21` ``"Schwefel 2.21"`` -Schwefel 2.22 :class:`~f3dasm.datageneration.Schwefel2_22` ``"Schwefel 2.22"`` -Schwefel 2.23 :class:`~f3dasm.datageneration.Schwefel2_23` ``"Schwefel 2.23"`` -Sphere :class:`~f3dasm.datageneration.Sphere` ``"Sphere"`` -Styblinski Tank :class:`~f3dasm.datageneration.StyblinskiTank` ``"Styblinski Tank"`` -Sum Squares :class:`~f3dasm.datageneration.SumSquares` ``"Sum Squares"`` -Thevenot :class:`~f3dasm.datageneration.Thevenot` ``"Thevenot"`` -Xin She Yang :class:`~f3dasm.datageneration.XinSheYang` ``"Xin She Yang"`` -======================== ============================================== ============================ +======================== ============================================== ========================================================== +Name Key-word Function +======================== ============================================== ========================================================== +Ackley ``"Ackley"`` :func:`~f3dasm.datageneration.functions.ackley` +Bohachevsky N. 1 ``"Bohachevsky N. 1"`` :func:`~f3dasm.datageneration.functions.bohachevskyn1` +Easom ``"Easom"`` :func:`~f3dasm.datageneration.functions.easom` +Egg Crate ``"Egg Crate"`` :func:`~f3dasm.datageneration.functions.eggcrate` +Exponential ``"Exponential"`` :func:`~f3dasm.datageneration.functions.exponential` +Griewank ``"Griewank"`` :func:`~f3dasm.datageneration.functions.griewank` +Michalewicz ``"Michalewicz"`` :func:`~f3dasm.datageneration.functions.michalewicz` +Powell ``"Powell"`` :func:`~f3dasm.datageneration.functions.powell` +Qing ``"Qing"`` :func:`~f3dasm.datageneration.functions.qing` +Quartic ``"Quartic"`` :func:`~f3dasm.datageneration.functions.quartic` +Rastrigin ``"Rastrigin"`` :func:`~f3dasm.datageneration.functions.rastrigin` +Schwefel ``"Schwefel"`` :func:`~f3dasm.datageneration.functions.schwefel` +Schwefel 2.20 ``"Schwefel 2.20"`` :func:`~f3dasm.datageneration.functions.schwefel2_20` +Schwefel 2.21 ``"Schwefel 2.21"`` :func:`~f3dasm.datageneration.functions.schwefel2_21` +Schwefel 2.22 ``"Schwefel 2.22"`` :func:`~f3dasm.datageneration.functions.schwefel2_22` +Schwefel 2.23 ``"Schwefel 2.23"`` :func:`~f3dasm.datageneration.functions.schwefel2_23` +Sphere ``"Sphere"`` :func:`~f3dasm.datageneration.functions.sphere` +Styblinski Tank ``"Styblinski Tank"`` :func:`~f3dasm.datageneration.functions.styblinskitang` +Sum Squares ``"Sum Squares"`` :func:`~f3dasm.datageneration.functions.sumsquares` +Thevenot ``"Thevenot"`` :func:`~f3dasm.datageneration.functions.thevenot` +Xin She Yang ``"Xin She Yang"`` :func:`~f3dasm.datageneration.functions.xin_she_yang` +======================== ============================================== ========================================================== Multimodal functions ^^^^^^^^^^^^^^^^^^^^ -======================== ================================================ ========================== -Name Docs of the Python class Data-generator argument -======================== ================================================ ========================== -Ackley :class:`~f3dasm.datageneration.Ackley` ``"Ackley"`` -Ackley N. 3 :class:`~f3dasm.datageneration.AckleyN3` ``"Ackley N. 3"`` -Ackley N. 4 :class:`~f3dasm.datageneration.AckleyN4` ``"Ackley N. 4"`` -Adjiman :class:`~f3dasm.datageneration.Adjiman` ``"Adjiman"`` -Bartels :class:`~f3dasm.datageneration.Bartels` ``"Bartels"`` -Beale :class:`~f3dasm.datageneration.Beale` ``"Beale"`` -Bird :class:`~f3dasm.datageneration.Bird` ``"Bird"`` -Bohachevsky N. 2 :class:`~f3dasm.datageneration.BohachevskyN2` ``"Bohachevsky N. 2"`` -Bohachevsky N. 3 :class:`~f3dasm.datageneration.BohachevskyN3` ``"Bohachevsky N. 3"`` -Branin :class:`~f3dasm.datageneration.Branin` ``"Branin"`` -Bukin N. 6 :class:`~f3dasm.datageneration.BukinN6` ``"Bukin N. 6"`` -Colville :class:`~f3dasm.datageneration.Colville` ``"Colville"`` -Cross-in-Tray :class:`~f3dasm.datageneration.CrossInTray` ``"Cross-in-Tray"`` -De Jong N. 5 :class:`~f3dasm.datageneration.DeJongN5` ``"De Jong N. 5"`` -Deckkers-Aarts :class:`~f3dasm.datageneration.DeckkersAarts` ``"Deckkers-Aarts"`` -Easom :class:`~f3dasm.datageneration.Easom` ``"Easom"`` -Egg Crate :class:`~f3dasm.datageneration.EggCrate` ``"Egg Crate"`` -Egg Holder :class:`~f3dasm.datageneration.EggHolder` ``"Egg Holder"`` -Goldstein-Price :class:`~f3dasm.datageneration.GoldsteinPrice` ``"Goldstein-Price"`` -Happy Cat :class:`~f3dasm.datageneration.HappyCat` ``"Happy Cat"`` -Himmelblau :class:`~f3dasm.datageneration.Himmelblau` ``"Himmelblau"`` -Holder-Table :class:`~f3dasm.datageneration.HolderTable` ``"Holder-Table"`` -Keane :class:`~f3dasm.datageneration.Keane` ``"Keane"`` -Langermann :class:`~f3dasm.datageneration.Langermann` ``"Langermann"`` -Levy :class:`~f3dasm.datageneration.Levy` ``"Levy"`` -Levy N. 13 :class:`~f3dasm.datageneration.LevyN13` ``"Levy N. 13"`` -McCormick :class:`~f3dasm.datageneration.McCormick` ``"McCormick"`` -Michalewicz :class:`~f3dasm.datageneration.Michalewicz` ``"Michalewicz"`` -Periodic :class:`~f3dasm.datageneration.Periodic` ``"Periodic"`` -Perm d, beta :class:`~f3dasm.datageneration.PermDBeta` ``"Perm d, beta"`` -Qing :class:`~f3dasm.datageneration.Qing` ``"Qing"`` -Quartic :class:`~f3dasm.datageneration.Quartic` ``"Quartic"`` -Rastrigin :class:`~f3dasm.datageneration.Rastrigin` ``"Rastrigin"`` -Rosenbrock :class:`~f3dasm.datageneration.Rosenbrock` ``"Rosenbrock"`` -Salomon :class:`~f3dasm.datageneration.Salomon` ``"Salomon"`` -Schwefel :class:`~f3dasm.datageneration.Schwefel` ``"Schwefel"`` -Shekel :class:`~f3dasm.datageneration.Shekel` ``"Shekel"`` -Shubert :class:`~f3dasm.datageneration.Shubert` ``"Shubert"`` -Shubert N. 3 :class:`~f3dasm.datageneration.ShubertN3` ``"Shubert N. 3"`` -Shubert N. 4 :class:`~f3dasm.datageneration.ShubertN4` ``"Shubert N. 4"`` -Styblinski Tank :class:`~f3dasm.datageneration.StyblinskiTank` ``"Styblinski Tank"`` -Thevenot :class:`~f3dasm.datageneration.Thevenot` ``"Thevenot"`` -Xin She Yang :class:`~f3dasm.datageneration.XinSheYang` ``"Xin She Yang"`` -Xin She Yang N.2 :class:`~f3dasm.datageneration.XinSheYangN2` ``"Xin She Yang N.2"`` -======================== ================================================ ========================== +======================== ================================================ =========================================================== +Name Key-word Function +======================== ================================================ =========================================================== +Ackley ``"Ackley"`` :func:`~f3dasm.datageneration.functions.ackley` +Ackley N. 3 ``"Ackley N. 3"`` :func:`~f3dasm.datageneration.functions.ackleyn3` +Ackley N. 4 ``"Ackley N. 4"`` :func:`~f3dasm.datageneration.functions.ackleyn4` +Adjiman ``"Adjiman"`` :func:`~f3dasm.datageneration.functions.adjiman` +Bartels ``"Bartels"`` :func:`~f3dasm.datageneration.functions.bartels` +Beale ``"Beale"`` :func:`~f3dasm.datageneration.functions.beale` +Bird ``"Bird"`` :func:`~f3dasm.datageneration.functions.bird` +Bohachevsky N. 2 ``"Bohachevsky N. 2"`` :func:`~f3dasm.datageneration.functions.bohachevskyn2` +Bohachevsky N. 3 ``"Bohachevsky N. 3"`` :func:`~f3dasm.datageneration.functions.bohachevskyn3` +Branin ``"Branin"`` :func:`~f3dasm.datageneration.functions.branin` +Bukin N. 6 ``"Bukin N. 6"`` :func:`~f3dasm.datageneration.functions.bukinn6` +Colville ``"Colville"`` :func:`~f3dasm.datageneration.functions.colville` +Cross-in-Tray ``"Cross-in-Tray"`` :func:`~f3dasm.datageneration.functions.crossintray` +De Jong N. 5 ``"De Jong N. 5"`` :func:`~f3dasm.datageneration.functions.dejongn5` +Deckkers-Aarts ``"Deckkers-Aarts"`` :func:`~f3dasm.datageneration.functions.deckkersaarts` +Easom ``"Easom"`` :func:`~f3dasm.datageneration.functions.easom` +Egg Crate ``"Egg Crate"`` :func:`~f3dasm.datageneration.functions.eggcrate` +Egg Holder ``"Egg Holder"`` :func:`~f3dasm.datageneration.functions.eggholder` +Goldstein-Price ``"Goldstein-Price"`` :func:`~f3dasm.datageneration.functions.goldsteinprice` +Happy Cat ``"Happy Cat"`` :func:`~f3dasm.datageneration.functions.happycat` +Himmelblau ``"Himmelblau"`` :func:`~f3dasm.datageneration.functions.himmelblau` +Holder-Table ``"Holder-Table"`` :func:`~f3dasm.datageneration.functions.holdertable` +Keane ``"Keane"`` :func:`~f3dasm.datageneration.functions.keane` +Langermann ``"Langermann"`` :func:`~f3dasm.datageneration.functions.langermann` +Levy ``"Levy"`` :func:`~f3dasm.datageneration.functions.levy` +Levy N. 13 ``"Levy N. 13"`` :func:`~f3dasm.datageneration.functions.levyn13` +McCormick ``"McCormick"`` :func:`~f3dasm.datageneration.functions.mccormick` +Michalewicz ``"Michalewicz"`` :func:`~f3dasm.datageneration.functions.michalewicz` +Periodic ``"Periodic"`` :func:`~f3dasm.datageneration.functions.periodic` +Qing ``"Qing"`` :func:`~f3dasm.datageneration.functions.qing` +Quartic ``"Quartic"`` :func:`~f3dasm.datageneration.functions.quartic` +Rastrigin ``"Rastrigin"`` :func:`~f3dasm.datageneration.functions.rastrigin` +Rosenbrock ``"Rosenbrock"`` :func:`~f3dasm.datageneration.functions.rosenbrock` +Salomon ``"Salomon"`` :func:`~f3dasm.datageneration.functions.salomon` +Schwefel ``"Schwefel"`` :func:`~f3dasm.datageneration.functions.schwefel` +Shekel ``"Shekel"`` :func:`~f3dasm.datageneration.functions.shekel` +Shubert ``"Shubert"`` :func:`~f3dasm.datageneration.functions.shubert` +Shubert N. 3 ``"Shubert N. 3"`` :func:`~f3dasm.datageneration.functions.shubertn3` +Shubert N. 4 ``"Shubert N. 4"`` :func:`~f3dasm.datageneration.functions.shubertn4` +Styblinski Tank ``"Styblinski Tank"`` :func:`~f3dasm.datageneration.functions.styblinskitang` +Thevenot ``"Thevenot"`` :func:`~f3dasm.datageneration.functions.thevenot` +Xin She Yang ``"Xin She Yang"`` :func:`~f3dasm.datageneration.functions.xin_she_yang` +======================== ================================================ =========================================================== + .. _implemented optimizers: @@ -152,14 +228,14 @@ Implemented optimizers The following implementations of optimizers can found under the :mod:`f3dasm.optimization` module: These are ported from `scipy-optimize `_ -======================== ========================================================================= =============================================================================================== -Name Key-word Reference -======================== ========================================================================= =============================================================================================== -Conjugate Gradient ``"CG"`` `scipy.minimize CG `_ -L-BFGS-B ``"LBFGSB"`` `scipy.minimize L-BFGS-B `_ -Nelder Mead ``"NelderMead"`` `scipy.minimize NelderMead `_ -Random search ``"RandomSearch"`` `numpy `_ -======================== ========================================================================= =============================================================================================== +======================== ========================================================================= ============================================== =========================================================================================================== +Name Key-word Function Reference +======================== ========================================================================= ============================================== =========================================================================================================== +Conjugate Gradient ``"cg"`` :func:`~f3dasm.optimization.cg` `scipy.minimize CG `_ +L-BFGS-B ``"lbfgsb"`` :func:`~f3dasm.optimization.lbfgsb` `scipy.minimize L-BFGS-B `_ +Nelder Mead ``"nelder_mead"`` :func:`~f3dasm.optimization.nelder_mead` `scipy.minimize NelderMead `_ +Random search ``"random_search"`` :func:`~f3dasm.optimization.random_search` `numpy `_ +======================== ========================================================================= ============================================== =========================================================================================================== .. _f3dasm-optimize: @@ -173,4 +249,4 @@ These extensions are provided as separate package: `f3dasm_optimize `_ \ No newline at end of file +More information about this extension can be found in the `f3dasm_optimize documentation page `_ \ No newline at end of file diff --git a/docs/source/rst_doc_files/gallery.rst b/docs/source/rst_doc_files/gallery.rst new file mode 100644 index 00000000..f6929b66 --- /dev/null +++ b/docs/source/rst_doc_files/gallery.rst @@ -0,0 +1,63 @@ +.. _tutorials: + +Tutorials +========= + +Design-of-experiments +--------------------- + +The submodule :mod:`f3dasm.design` contains the :class:`~f3dasm.design.Domain` object that makes up your feasible search space. + +.. nblinkgallery:: + :name: designofexperiments + :glob: + + ../notebooks/design/* + +Managing experiments with the ExperimentData object +--------------------------------------------------- + +The :class:`~f3dasm.ExperimentData` object is the main object used to store implementations of a design-of-experiments, +keep track of results, perform optimization and extract data for machine learning purposes. + +All other processses of :mod:`f3dasm` use this object to manipulate and access data about your experiments. + +.. nblinkgallery:: + :name: experimentdata + :glob: + + ../notebooks/experimentdata/* + +Designing your data-driven process +---------------------------------- + +Use the :class:`~f3dasm.Block` abstraction to manipulate the :class:`~f3dasm.ExperimentData` object and create your data-driven process. + +.. nblinkgallery:: + :name: datadriven + :glob: + + ../notebooks/data-driven/* + +Built-in implementations +------------------------ + +The :mod:`f3dasm` package comes with built-in implementations of the :class:`~f3dasm.Block` object that you can use to create your data-driven process. + +.. nblinkgallery:: + :name: builtins + :glob: + + ../notebooks/builtins/* + +Integration with hydra +---------------------- + +Examples that integrate the :mod:`f3dasm` package with the configuration manager hydra + +.. nblinkgallery:: + :name: hydra + :glob: + + ../notebooks/hydra/* + diff --git a/docs/source/rst_doc_files/general/installation.rst b/docs/source/rst_doc_files/general/installation.rst index dcb2c4b4..f9bda299 100644 --- a/docs/source/rst_doc_files/general/installation.rst +++ b/docs/source/rst_doc_files/general/installation.rst @@ -76,22 +76,11 @@ Then run: >$ pip install -U f3dasm$ conda create -n f3dasm_env python=3.8$ conda activate f3dasm_env$ conda install pip$ pip install -U f3dasm$ conda install conda-forge::f3dasm ---- -In order to check your installation you can use - -.. code-block:: console - - $ python -c "import f3dasm" - >>> 2023-09-03 20:12:41,088 - Imported f3dasm (version: 1.x.x) - -This will show the installed version of f3dasm. - - .. note:: :mod:`f3dasm` requires a few other packages as dependencies, which will be automatically installed when installing :mod:`f3dasm` using the above instructions. @@ -124,7 +113,7 @@ Building from source is required to work on a contribution (bug fix, new feature cd f3dasm -2. Install a recent version of Python (3.7, 3.8, 3.9 or 3.10) +2. Install a recent version of Python (3.8, 3.9 or 3.10) for instance using `Miniconda3 `_ or `Mamba `_. If you installed Python with conda, we recommend to create a dedicated conda environment with all the build dependencies of f3dasm: @@ -142,15 +131,6 @@ Building from source is required to work on a contribution (bug fix, new feature pip install --verbose --no-build-isolation --editable . -4. In order to check your installation you can use - - .. code-block:: console - - $ python -c "import f3dasm" - >>> 2023-07-05 14:56:40,015 - Imported f3dasm (version: 1.x.x) - - - .. note:: You can check if the package is linked to your local clone of f3dasm by running :code:`pip show list` and look for f3dasm. diff --git a/docs/source/rst_doc_files/general/overview.rst b/docs/source/rst_doc_files/general/overview.rst index 61b9b4fa..29c74339 100644 --- a/docs/source/rst_doc_files/general/overview.rst +++ b/docs/source/rst_doc_files/general/overview.rst @@ -10,7 +10,7 @@ A quick overview of the f3dasm package. Conceptual framework -------------------- -``f3dasm`` is a Python project that provides a general and user-friendly data-driven framework for researchers and practitioners working on the design and analysis of materials and structures. +``f3dasm`` is a Python project that provides a general and user-friendly data-driven framework for researchers and practitioners working on the design and analysis of materials and structures [1]_. The package aims to streamline the data-driven process and make it easier to replicate research articles in this field, as well as share new work with the community. In the last decades, advancements in computational resources have accelerated novel inverse design approaches for structures and materials. @@ -22,7 +22,7 @@ This lack of shared practices also leads to compatibility issues for benchmarkin In this work we introduce an interface for researchers and practitioners working on design and analysis of materials and structures. The package is called ``f3dasm`` (Framework for Data-driven Design \& Analysis of Structures and Materials). -This work generalizes the original closed-source framework proposed by the Bessa and co-workers [1]_, making it more flexible and adaptable to different applications, +This work generalizes the original closed-source framework proposed by the Bessa and co-workers [2]_, making it more flexible and adaptable to different applications, namely by allowing the integration of different choices of software packages needed in the different steps of the data-driven process: - **Design of experiments**, in which input variables describing the microstructure, properties and external conditions of the system are determined and sampled. @@ -51,21 +51,24 @@ Computational framework - The framework automatically manages I/O processes, saving you time and effort implementing these common procedures. -- :doc:`Easy parallelization <../../auto_examples/005_workflow/001_cluster_computing>` +- Easy parallelization - The framework manages parallelization of experiments, and is compatible with both local and high-performance cluster computing. -- :doc:`Built-in defaults <../defaults>` +- Built-in defaults - The framework includes a collection of :ref:`benchmark functions `, :ref:`optimization algorithms ` and :ref:`sampling strategies ` to get you started right away! -- :doc:`Hydra integration <../../auto_examples/006_hydra/001_hydra_usage>` +- Hydra integration - The framework is integrated with `hydra `_ configuration manager, to easily manage and run experiments. Comprehensive `online documentation `_ is also available to assist users and developers of the framework. -.. [1] Bessa, M. A., Bostanabad, R., Liu, Z., Hu, A., Apley, D. W., Brinson, C., Chen, W., & Liu, W. K. (2017). +.. [1] van der Schelling, M. P., Ferreira, B. P., & Bessa, M. A. (2024). + *f3dasm: Framework for data-driven design and analysis of structures and materials. Journal of Open Source Software*, 9(100), 6912. + +.. [2] Bessa, M. A., Bostanabad, R., Liu, Z., Hu, A., Apley, D. W., Brinson, C., Chen, W., & Liu, W. K. (2017). *A framework for data-driven analysis of materials under uncertainty: Countering the curse of dimensionality. Computer Methods in Applied Mechanics and Engineering*, 320, 633-667. diff --git a/examples/001_domain/001_domain_creation.py b/examples/001_domain/001_domain_creation.py deleted file mode 100644 index fcc29c61..00000000 --- a/examples/001_domain/001_domain_creation.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Introduction to domain and parameters -===================================== - -This section will give you information on how to set up your search space with the :class:`~f3dasm.design.Domain` class and the paramaters -The :class:`~f3dasm.design.Domain` is a set of parameter instances that make up the feasible search space. -""" -############################################################################### -# To start, we create an empty domain object: -import numpy as np -from hydra import compose, initialize - -from f3dasm.design import Domain, make_nd_continuous_domain - -domain = Domain() - -############################################################################### -# Input parameters -# ---------------- -# -# Now we well add some input parameters: -# There are four types of parameters that can be created: -# -# - floating point parameters - -domain.add_float(name='x1', low=0.0, high=100.0) -domain.add_float(name='x2', low=0.0, high=4.0) - -############################################################################### -# - discrete integer parameters - -domain.add_int(name='x3', low=2, high=4) -domain.add_int(name='x4', low=74, high=99) - -############################################################################### -# - categorical parameters - -domain.add_category(name='x5', categories=['test1', 'test2', 'test3', 'test4']) -domain.add_category(name='x6', categories=[0.9, 0.2, 0.1, -2]) - -############################################################################### -# - constant parameters - -domain.add_constant(name='x7', value=0.9) - -############################################################################### -# We can print the domain object to see the parameters that have been added: - -print(domain) - -############################################################################### -# Output parameters -# ----------------- -# -# Output parameters are the results of evaluating the input design with a data generation model. -# Output parameters can hold any type of data, e.g. a scalar value, a vector, a matrix, etc. -# Normally, you would not need to define output parameters, as they are created automatically when you store a variable to the :class:`~f3dasm.ExperimentData` object. - -domain.add_output(name='y', to_disk=False) - -############################################################################### -# The :code:`to_disk` argument can be set to :code:`True` to store the output parameter on disk. A reference to the file is stored in the :class:`~f3dasm.ExperimentData` object. -# This is useful when the output data is very large, or when the output data is an array-like object. - -############################################################################### -# Filtering the domain -# -------------------- -# -# The domain object can be filtered to only include certain types of parameters. -# This might be useful when you want to create a design of experiments with only continuous parameters, for example. -# The attributes :attr:`~f3dasm.design.Domain.continuous`, :attr:`~f3dasm.design.Domain.discrete`, :attr:`~f3dasm.design.Domain.categorical`, and :attr:`~f3dasm.design.Domain.constant` can be used to filter the domain object. - -print(f"Continuous domain: {domain.continuous}") -print(f"Discrete domain: {domain.discrete}") -print(f"Categorical domain: {domain.categorical}") -print(f"Constant domain: {domain.constant}") - -############################################################################### -# Helper function for single-objective, n-dimensional continuous domains -# ---------------------------------------------------------------------- -# -# We can make easily make a :math:`n`-dimensional continous domain with the helper function :func:`~f3dasm.design.make_nd_continuous_domain`. -# We have to specify the boundaries (``bounds``) for each of the dimensions with a list of lists or numpy :class:`~numpy.ndarray`: - -bounds = np.array([[-1.0, 1.0], [-1.0, 1.0]]) -domain = make_nd_continuous_domain(bounds=bounds) - -print(domain) diff --git a/examples/001_domain/002_own_sampler.py b/examples/001_domain/002_own_sampler.py deleted file mode 100644 index f5cc2265..00000000 --- a/examples/001_domain/002_own_sampler.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Implementing a grid search sampler from scratch -=============================================== - -In this example, we will implement a `grid search sampler `_ from scratch. -The grid search sampler is a simple sampler that evaluates all possible combinations of the parameters in the domain. This is useful for small domains, but it can become computationally expensive for larger domains. -We will show how to create this sampler and use it in a :mod:`f3dasm` data-driven experiment. -""" - -from __future__ import annotations - -from itertools import product -from typing import Dict, Optional - -import numpy as np -import pandas as pd - -from f3dasm import ExperimentData -from f3dasm.design import Domain - -############################################################################### -# When integrating your sampling strategy into the data-driven process, you have to create a function that will take the domain as argument: -# Several other optional arguments can be passed to the function as well, such as: -# -# * :code:`n_samples`: The number of samples you wish to generate. It's not always necessary to define this upfront, as some sampling methods might inherently determine the number of samples based on certain criteria or properties of the domain. -# * :code:`seed`: A seed for the random number generator to replicate the sampling process. This enables you to control the randomness of the sampling process [1]_. By setting a seed, you ensure reproducibility, meaning that if you run the sampling function with the same seed multiple times, you'll get the same set of samples. -# -# .. [1] If no seed is provided, the function should use a random seed. -# -# Additionally, the function can accept any other keyword arguments that you might need to pass to the sampling function. -# This also means that you shoud handle arbitrary keyword arguments in your function with the `**kwargs` syntax. -# The function should return the samples (``input_data``) in one of the following formats: -# -# * A :class:`~pandas.DataFrame` object -# * A :class:`~numpy.ndarray` object -# -# For our implementation of the grid-search sampler, we need to handle the case of continous parameters. -# We require the user to pass down a dictionary with the discretization stepsize for each continuous parameter. - - -def grid( - domain: Domain, stepsize_continuous_parameters: - Optional[Dict[str, float] | float] = None, **kwargs) -> pd.DataFrame: - - # Extract the continous part of the domain - continuous = domain.continuous - - # If therei s no continuos space, we can return an empty dictionary - if not continuous.space: - discrete_space = {} - - else: - discrete_space = {key: continuous.space[key].to_discrete( - step=value) for key, - value in stepsize_continuous_parameters.items()} - - continuous_to_discrete = Domain(discrete_space) - - _iterdict = {} - - # For all the categorical parameters, we will iterate over the categories - for k, v in domain.categorical.space.items(): - _iterdict[k] = v.categories - - # For all the discrete parameters, we will iterate over the range of values - for k, v, in domain.discrete.space.items(): - _iterdict[k] = range(v.lower_bound, v.upper_bound+1, v.step) - - # For all the continuous parameters, we will iterate over the range of values - # based on the stepsize provided - for k, v, in continuous_to_discrete.space.items(): - _iterdict[k] = np.arange( - start=v.lower_bound, stop=v.upper_bound, step=v.step) - - # We will create a dataframe with all the possible combinations using - # the itertools.product function - df = pd.DataFrame(list(product(*_iterdict.values())), - columns=_iterdict, dtype=object)[domain.names] - - # return the samples - return df - -############################################################################### -# To test our implementation, we will create a domain with a mix of continuous, discrete, and categorical parameters. - - -domain = Domain() -domain.add_float("param_1", -1.0, 1.0) -domain.add_int("param_2", 1, 5) -domain.add_category("param_3", ["red", "blue", "green", "yellow", "purple"]) - -############################################################################### -# We will now sample the domain using the grid sampler we implemented. -# We can create an empty ExperimentData object and call the :meth:`~f3dasm.data.ExperimentData.sample` method to add the samples to the object: - -experiment_data = ExperimentData(domain=domain) -experiment_data.sample( - sampler=grid, stepsize_continuous_parameters={"param_1": 0.2}) - -############################################################################### -# We can print the samples to see the results: - -print(experiment_data) diff --git a/examples/001_domain/003_builtin_sampler.py b/examples/001_domain/003_builtin_sampler.py deleted file mode 100644 index 67dfcf79..00000000 --- a/examples/001_domain/003_builtin_sampler.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Use the built-in sampling strategies -==================================== - -In this example, we will use the built-in sampling strategies provided by :mod:`f3dasm` to generate samples for a data-driven experiment. -""" - -from matplotlib import pyplot as plt - -from f3dasm import ExperimentData -from f3dasm.design import make_nd_continuous_domain - -############################################################################### -# We create 2D continuous input domain with the :func:`~f3dasm.design.make_nd_continuous_domain` helper function: - -domain = make_nd_continuous_domain(bounds=[[0., 1.], [0., 1.]]) -print(domain) - -############################################################################### -# You can create an :class:`~f3dasm.ExperimentData` object with the :meth:`~f3dasm.ExperimentData.from_sampling` constructor directly: - -data_random = ExperimentData.from_sampling( - domain=domain, n_samples=10, sampler='random', seed=42) - -fig, ax = plt.subplots(figsize=(4, 4)) - -print(data_random) - -df_random, _ = data_random.to_pandas() -ax.scatter(df_random.iloc[:, 0], df_random.iloc[:, 1]) -ax.set_xlabel(domain.names[0]) -ax.set_ylabel(domain.names[1]) - -############################################################################### -# :mod:`f3dasm` provides several built-in samplers. -# The example below shows how to use the Latin Hypercube Sampling (LHS) sampler: - -data_lhs = ExperimentData.from_sampling( - domain=domain, n_samples=10, sampler='latin', seed=42) - -fig, ax = plt.subplots(figsize=(4, 4)) - -print(data_lhs) - -df_lhs, _ = data_lhs.to_pandas() -ax.scatter(df_lhs.iloc[:, 0], df_lhs.iloc[:, 1]) -ax.set_xlabel(domain.names[0]) -ax.set_ylabel(domain.names[1]) - -############################################################################### -# More information all the available samplers can be found in :ref:`here `. diff --git a/examples/001_domain/README.rst b/examples/001_domain/README.rst deleted file mode 100644 index 26d0da6a..00000000 --- a/examples/001_domain/README.rst +++ /dev/null @@ -1,4 +0,0 @@ -Design-of-experiments -^^^^^^^^^^^^^^^^^^^^^ - -The submodule :mod:`f3dasm.design` contains the :class:`~f3dasm.design.Domain` object that makes up your feasible search space. diff --git a/examples/002_experimentdata/001_experimentdata.py b/examples/002_experimentdata/001_experimentdata.py deleted file mode 100644 index 05e3b90c..00000000 --- a/examples/002_experimentdata/001_experimentdata.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -Creating an ExperimentData object from various external sources -=============================================================== - -The :class:`~f3dasm.ExperimentData` object is the main object used to store implementations of a design-of-experiments, -keep track of results, perform optimization and extract data for machine learning purposes. - -All other processses of :mod:`f3dasm` use this object to manipulate and access data about your experiments. - -The :class:`~f3dasm.ExperimentData` object consists of the following attributes: - -- domain: The feasible :class:`~f3dasm.design.Domain` of the Experiment. Used for sampling and optimization. -- input_data: Tabular data containing the input variables of the experiments as column and the experiments as rows. -- output_data: Tabular data containing the tracked outputs of the experiments. -- project_dir: A user-defined project directory where all files related to your data-driven process will be stored. -""" - -############################################################################### -# The :class:`~f3dasm.ExperimentData` object can be constructed in several ways: -# -# You can construct a :class:`~f3dasm.ExperimentData` object by providing it input data, -# output data, a :class:`~f3dasm.design.Domain` object and a project directory. - -import numpy as np -import pandas as pd - -from f3dasm import ExperimentData -from f3dasm.design import Domain - -############################################################################### -# domain -# ^^^^^^ -# The domain object is used to define the feasible space of the experiments. - -domain = Domain() -domain.add_float('x0', 0., 1.) -domain.add_float('x1', 0., 1.) - -############################################################################### -# input_data -# ^^^^^^^^^^ -# -# Input data describes the input variables of the experiments. -# The input data is provided in a tabular manner, with the number of rows equal to the number of experiments and the number of columns equal to the number of input variables. -# -# Single parameter values can have any of the basic built-in types: ``int``, ``float``, ``str``, ``bool``. Lists, tuples or array-like structures are not allowed. -# -# We can give the input data as a :class:`~pandas.DataFrame` object with the input variable names as columns and the experiments as rows. - -input_data = pd.DataFrame({ - 'x0': [0.1, 0.2, 0.3], - 'x1': [0.4, 0.5, 0.6] -}) - -experimentdata = ExperimentData(domain=domain, input_data=input_data) -print(experimentdata) - -############################################################################### -# or a two-dimensional :class:`~numpy.ndarray` object with shape (, ): - -input_data = np.array([ - [0.1, 0.4], - [0.2, 0.5], - [0.3, 0.6] -]) - -experimentdata = ExperimentData(domain=domain, input_data=input_data) -print(experimentdata) - -############################################################################### -# .. note:: -# -# When providing a :class:`~numpy.ndarray` object, you need to provide a :class:`~f3dasm.design.Domain` object as well. -# Also, the order of the input variables is inferred from the order of the columns in the :class:`~f3dasm.design.Domain` object. - -############################################################################### -# Another option is a path to a ``.csv`` file containing the input data. -# The ``.csv`` file should contain a header row with the names of the input variables -# and the first column should be indices for the experiments. -# -# output_data -# ^^^^^^^^^^^ -# -# Output data describes the output variables of the experiments. -# The output data is provided in a tabular manner, with the number of rows equal to the number of experiments and the number of columns equal to the number of output variables. -# -# The same rules apply for the output data as for the input data: - -output_data = pd.DataFrame({ - 'y': [1.1, 1.2, 1.3], -}) - -experimentdata = ExperimentData(domain=domain, input_data=input_data, - output_data=output_data) - -print(experimentdata) - -############################################################################### -# .. note:: -# -# When the output to an ExperimentData object is provided, the job will be set to finished, -# as the output data is considerd the result of the experiment. -# -# Adding data after constructing -# ------------------------------ -# -# If you have constructed your :class:`~f3dasm.ExperimentData` object, -# you can add ``input_data``, ``output_data``, a ``domain`` or the ``project_dir`` using the :meth:`~f3dasm.ExperimentData.add` method: - -new_data = pd.DataFrame({ - 'x0': [1.5, 1.7], - 'x1': [1.3, 1.9] -}) -experimentdata.add(input_data=new_data, domain=domain) -print(experimentdata) - -############################################################################### -# Exporting the data to various formats -# ------------------------------------- -# -# You can convert the input- and outputdata of your data-driven process to other well-known datatypes: -# -# * :meth:`~f3dasm.ExperimentData.to_numpy`; creates a tuple of two :class:`~numpy.ndarray` objects containing the input- and outputdata. - -arr_input, arr_output = experimentdata.to_numpy() -print(arr_input) - -############################################################################### -# * :meth:`~f3dasm.ExperimentData.to_xarray`; creates a :class:`~xarray.Dataset` object containing the input- and outputdata. - -ds = experimentdata.to_xarray() -print(ds) - -############################################################################### -# * :meth:`~f3dasm.ExperimentData.to_pandas`; creates a tuple of two :class:`~pd.DataFrame` object containing the input- and outputdata. - -df_input, df_output = experimentdata.to_pandas() -print(df_input) diff --git a/examples/002_experimentdata/002_experimentdata_storing.py b/examples/002_experimentdata/002_experimentdata_storing.py deleted file mode 100644 index 39d66a39..00000000 --- a/examples/002_experimentdata/002_experimentdata_storing.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Storing experiment data to disk -=============================== - -In this example, we will show how to store the experiment data to disk using the :meth:`~f3dasm.ExperimentData.store` method and -how to load the stored data using the :meth:`~f3dasm.ExperimentData.from_file` method. -""" - -############################################################################### -# project directory -# ^^^^^^^^^^^^^^^^^ -# -# The ``project_dir`` argument is used to store the ExperimentData to disk -# You can provide a string or a path to a directory. This can either be a relative or absolute path. -# If the directory does not exist, it will be created. - -from f3dasm import ExperimentData - -data = ExperimentData() -data.set_project_dir("./example_project_dir") - -print(data.project_dir) - -############################################################################### -# Storing the data -# ^^^^^^^^^^^^^^^^^ -# -# The :meth:`~f3dasm.ExperimentData.store` method is used to store the experiment data to disk. - -data.store() - -############################################################################### -# The data is stored in several files in an 'experiment_data' subfolder in the provided project directory: -# -# .. code-block:: none -# :caption: Directory Structure -# -# my_project/ -# β”œβ”€β”€ my_script.py -# └── experiment_data -# β”œβ”€β”€ domain.pkl -# β”œβ”€β”€ input_data.csv -# β”œβ”€β”€ output_data.csv -# └── jobs.pkl -# -# In order to load the data, you can use the :meth:`~f3dasm.ExperimentData.from_file` method. - -data_loaded = ExperimentData.from_file(project_dir="./example_project_dir") - -print(data_loaded) diff --git a/examples/002_experimentdata/README.rst b/examples/002_experimentdata/README.rst deleted file mode 100644 index 4da7ded9..00000000 --- a/examples/002_experimentdata/README.rst +++ /dev/null @@ -1,7 +0,0 @@ -Managing experiments with the ExperimentData object -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The :class:`~f3dasm.ExperimentData` object is the main object used to store implementations of a design-of-experiments, -keep track of results, perform optimization and extract data for machine learning purposes. - -All other processses of :mod:`f3dasm` use this object to manipulate and access data about your experiments. diff --git a/examples/002_experimentdata/example_project_dir/experiment_data/domain.pkl b/examples/002_experimentdata/example_project_dir/experiment_data/domain.pkl deleted file mode 100644 index 6b319cb7..00000000 Binary files a/examples/002_experimentdata/example_project_dir/experiment_data/domain.pkl and /dev/null differ diff --git a/examples/002_experimentdata/example_project_dir/experiment_data/input.csv b/examples/002_experimentdata/example_project_dir/experiment_data/input.csv deleted file mode 100644 index e16c76df..00000000 --- a/examples/002_experimentdata/example_project_dir/experiment_data/input.csv +++ /dev/null @@ -1 +0,0 @@ -"" diff --git a/examples/002_experimentdata/example_project_dir/experiment_data/jobs.pkl b/examples/002_experimentdata/example_project_dir/experiment_data/jobs.pkl deleted file mode 100644 index 1884af77..00000000 Binary files a/examples/002_experimentdata/example_project_dir/experiment_data/jobs.pkl and /dev/null differ diff --git a/examples/002_experimentdata/example_project_dir/experiment_data/output.csv b/examples/002_experimentdata/example_project_dir/experiment_data/output.csv deleted file mode 100644 index e16c76df..00000000 --- a/examples/002_experimentdata/example_project_dir/experiment_data/output.csv +++ /dev/null @@ -1 +0,0 @@ -"" diff --git a/examples/003_datageneration/001_own_datagenerator.py b/examples/003_datageneration/001_own_datagenerator.py deleted file mode 100644 index 0a004427..00000000 --- a/examples/003_datageneration/001_own_datagenerator.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Implement your own datagenerator: car stopping distance problem -=============================================================== - -In this example, we will implement a custom data generator that generates output for a data-driven experiment. -We will use the 'car stopping distance' problem as an example. -""" - -import matplotlib.pyplot as plt -import numpy as np -from scipy.stats import norm - -from f3dasm import ExperimentData -from f3dasm.datageneration import DataGenerator -from f3dasm.design import Domain - -############################################################################### -# -# Car stopping distance problem -# ----------------------------- -# -# .. image:: ../../img/reaction-braking-stopping.png -# :width: 70% -# :align: center -# :alt: Workflow -# -# Car stopping distance :math:`y` as a function of its velocity :math:`x` before it starts braking: -# -# .. math:: -# -# y = z x + \frac{1}{2 \mu g} x^2 = z x + 0.1 x^2 -# -# -# - :math:`z` is the driver's reaction time (in seconds) -# - :math:`\mu` is the road/tires coefficient of friction (we assume :math:`\mu=0.5`) -# - :math:`g` is the acceleration of gravity (assume :math:`g=10 m/s^2`). -# -# .. math:: -# -# y = d_r + d_{b} -# -# where :math:`d_r` is the reaction distance, and :math:`d_b` is the braking distance. -# -# Reaction distance :math:`d_r` -# -# .. math:: -# -# d_r = z x -# -# with :math:`z` being the driver's reaction time, and :math:`x` being the velocity of the car at the start of braking. -# -# Kinetic energy of moving car: -# -# .. math:: -# -# E = \frac{1}{2}m x^2 -# -# where :math:`m` is the car mass. -# -# Work done by braking: -# -# .. math:: -# -# W = \mu m g d_b -# -# -# where :math:`\mu` is the coefficient of friction between the road and the tire, :math:`g` is the acceleration of gravity, and :math:`d_b` is the car braking distance. -# -# The braking distance follows from :math:`E=W`: -# -# .. math:: -# -# d_b = \frac{1}{2\mu g}x^2 -# -# Therefore, if we add the reacting distance :math:`d_r` to the braking distance :math:`d_b` we get the stopping distance :math:`y`: -# -# .. math:: -# -# y = d_r + d_b = z x + \frac{1}{2\mu g} x^2 -# -# -# Every driver has its own reaction time :math:`z` -# Assume the distribution associated to :math:`z` is Gaussian with mean :math:`\mu_z=1.5` seconds and variance :math:`\sigma_z^2=0.5^2`` seconds\ :sup:`2`: -# -# .. math:: -# -# z \sim \mathcal{N}(\mu_z=1.5,\sigma_z^2=0.5^2) -# -# -# We create a function that generates the stopping distance :math:`y` given the velocity :math:`x` and the reaction time :math:`z`: - - -def y(x): - z = norm.rvs(1.5, 0.5, size=1) - y = z*x + 0.1*x**2 - return y - - -############################################################################### -# Implementing the data generator -# ------------------------------- -# -# Implementing this relationship in :mod:`f3dasm` can be done in two ways: -# -# -# 1. Directly using a function -# 2. Providing an object from a custom class that inherits from the :class:`~f3dasm.datageneration.DataGenerator` class. -# -# Using a function directly -# ^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# We can use the function :func:`y(x)` directly as the data generator. We will demonstrate this in the following example code: -# -# -# In order to create an :class:`~f3dasm.ExperimentData` object, we have to first create a domain -domain = Domain() -domain.add_float('x', low=0., high=100.) - -############################################################################### -# For demonstration purposes, we will generate a dataset of stopping distances for velocities between 3 and 83 m/s. - -N = 33 # number of points to generate -Data_x = np.linspace(3, 83, 100) - -############################################################################### -# We can construct an :class:`~f3dasm.ExperimentData` object with the :class:`~f3dasm.design.Domain` and the numpy array: - -experiment_data = ExperimentData(input_data=Data_x, domain=domain) -print(experiment_data) - -############################################################################### -# As you can see, the ExperimentData object has been created successfully and the jobs have the label 'open'. -# This means that the output has not been generated yet. We can now compute the stopping distance by calling the :meth:`~f3dasm.ExperimentData.evaluate` method: -# We have to provide the function as the ``data_generator`` argument and provide name of the return value as the ``output_names`` argument: - -experiment_data.evaluate(data_generator=y, output_names=['y']) - -arr_in, arr_out = experiment_data.to_numpy() - -fig, ax = plt.subplots() -ax.scatter(arr_in, arr_out.flatten(), s=2) -_ = ax.set_xlabel('Car velocity ($m/s$)') -_ = ax.set_ylabel('Stopping distance ($m$)') - -############################################################################### -# The experiments have been evaluated and the jobs value has been set to 'finished' - -print(experiment_data) - -############################################################################### -# -# Using the DataGenerator class -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# We can also implement the data generator as a class that inherits from the :class:`~f3dasm.datageneration.DataGenerator` class. -# This allows for more flexibility and control over the data generation process. - -experiment_data_class = ExperimentData(input_data=Data_x, domain=domain) - -############################################################################### -# The custom data generator class should have an :meth:`~f3dasm.datageneration.DataGenerator.execute` method. -# In this method, we can access the experiment using the :attr:`~f3dasm.datageneration.DataGenerator.experiment_sample` attribute. -# We can store the output of the data generation process using the :meth:`~f3dasm.datageneration.DataGenerator.experiment_sample.store` method. - - -class CarStoppingDistance(DataGenerator): - def __init__(self, mu_z: float, sigma_z: float): - self.mu_z = mu_z - self.sigma_z = sigma_z - - def execute(self): - x = self.experiment_sample.get('x') - z = norm.rvs(self.mu_z, self.sigma_z, size=1) - y = z*x + 0.1*x**2 - self.experiment_sample.store(object=y, name='y', to_disk=False) - -############################################################################### -# We create an object of the :class:`~CarStoppingDistance` class and pass it to the :meth:`~f3dasm.ExperimentData.evaluate` method: - - -car_stopping_distance = CarStoppingDistance(mu_z=1.5, sigma_z=0.5) -experiment_data_class.evaluate( - data_generator=car_stopping_distance, mode='sequential') - -print(experiment_data_class) - -############################################################################### -# -# There are three methods available of evaluating the experiments: -# -# * :code:`sequential`: regular for-loop over each of the experiments in order -# * :code:`parallel`: utilizing the multiprocessing capabilities (with the `pathos `_ multiprocessing library), each experiment is run in a separate core -# * :code:`cluster`: each experiment is run in a seperate node. This is especially useful on a high-performance computation cluster where you have multiple worker nodes and a commonly accessible resource folder. After completion of an experiment, the node will automatically pick the next available open experiment. -# * :code:`cluster_parallel`: Combination of the :code:`cluster` and :code:`parallel` mode. Each node will run multiple samples in parallel. diff --git a/examples/003_datageneration/002_builtin_benchmarkfunctions.py b/examples/003_datageneration/002_builtin_benchmarkfunctions.py deleted file mode 100644 index 3d1d6b3d..00000000 --- a/examples/003_datageneration/002_builtin_benchmarkfunctions.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Use the built-in benchmark functions -==================================== - -In this example, we will use the built-in benchmark functions provided by :mod:`f3dasm.datageneration.functions` to generate output for a data-driven experiment. -""" - -import matplotlib.pyplot as plt - -from f3dasm import ExperimentData -from f3dasm.design import make_nd_continuous_domain - -############################################################################### -# :mod:`f3dasm` ships with a set of benchmark functions that can be used to test the performance of -# optimization algorithms or to mock some expensive simulation in order to test the data-driven process. -# These benchmark functions are taken and modified from the `Python Benchmark Test Optimization Function Single Objective `_ github repository. -# -# Let's start by creating a continuous domain -# with 2 input variables, each ranging from -1.0 to 1.0 - -domain = make_nd_continuous_domain([[-1., 1.], [-1., 1.]]) - -############################################################################### -# We generate the input data by sampling the domain equally spaced with the grid sampler and create the :class:`~f3dasm.ExperimentData` object: - -experiment_data = ExperimentData.from_sampling( - 'grid', domain=domain, stepsize_continuous_parameters=0.1) - -print(experiment_data) - -############################################################################### -# Evaluating a 2D version of the Ackley function is as simple as -# calling the :meth:`~f3dasm.ExperimentData.evaluate` method with the function name as the ``data_generator`` argument. -# -# In addition, you can provide a dictionary (``kwargs``) with the followinging keywords to the :class:`~f3dasm.design.ExperimentData.evaluate` method: -# -# * ``scale_bounds``: A 2D list of floats that define the scaling lower and upper boundaries for each dimension. The normal benchmark function box-constraints will be scaled to these boundaries. -# * ``noise``: A float that defines the standard deviation of the Gaussian noise that is added to the objective value. -# * ``offset``: A boolean value. If ``True``, the benchmark function will be offset by a constant vector that will be randomly generated [1]_. -# * ``seed``: Seed for the random number generator for the ``noise`` and ``offset`` calculations. -# -# .. [1] As benchmark functions usually have their minimum at the origin, the offset is used to test the robustness of the optimization algorithm. - -experiment_data.evaluate(data_generator='Ackley', kwargs={ - 'scale_bounds': domain.get_bounds(), 'offset': False}) - -############################################################################### -# The function values are stored in the ``y`` variable of the output data: - -print(experiment_data) - -############################################################################### - -arr_in, arr_out = experiment_data.to_numpy() -fig, ax = plt.subplots(subplot_kw={'projection': '3d'}) -ax.scatter(arr_in[:, 0], arr_in[:, 1], arr_out.ravel()) -_ = ax.set_xlabel('$x_0$') -_ = ax.set_ylabel('$x_1$') -_ = ax.set_zlabel('$f(x)$') - -############################################################################### -# A complete list of all the implemented benchmark functions can be found :ref:`here ` diff --git a/examples/003_datageneration/003_storing.py b/examples/003_datageneration/003_storing.py deleted file mode 100644 index 3b2d0477..00000000 --- a/examples/003_datageneration/003_storing.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Storing data generation output to disk -====================================== - -After running your simulation, you can store the result back into the :class:`~f3dasm.ExperimentSample` with the :meth:`~f3dasm.ExperimentSample.store` method. -There are two ways of storing your output: - -* Singular values can be stored directly to the :attr:`~f3dasm.ExperimentData.output_data` -* Large objects can be stored to disk and a reference path will be stored to the :attr:`~f3dasm.ExperimentData.output_data`. -""" - -import numpy as np - -from f3dasm import ExperimentData, StoreProtocol -from f3dasm.datageneration import DataGenerator -from f3dasm.design import make_nd_continuous_domain - -############################################################################### -# For this example we create a 3 dimensional continuous domain and generate 10 random samples. - -domain = make_nd_continuous_domain([[0., 1.], [0., 1.], [0., 1.]]) -experiment_data = ExperimentData.from_sampling( - sampler='random', domain=domain, n_samples=10, seed=42) - -############################################################################### -# Single values -# ------------- - -# Single values or small lists can be stored to the :class:`~f3dasm.ExperimentData` using the ``to_disk=False`` argument, with the name of the parameter as the key. -# This will create a new output parameter if the parameter name is not found in :attr:`~f3dasm.ExperimentData.output_data` of the :class:`~f3dasm.ExperimentData` object: -# This is especially useful if you want to get a quick overview of some loss or design metric of your sample. -# -# We create a custom datagenerator that sums the input features and stores the result back to the :class:`~f3dasm.ExperimentData` object: - - -class MyDataGenerator_SumInput(DataGenerator): - def execute(self): - input_, _ = self.experiment_sample.to_numpy() - y = float(sum(input_)) - self.experiment_sample.store(object=y, name='y', to_disk=False) - -############################################################################### -# We pass the custom data generator to the :meth:`~f3dasm.ExperimentData.evaluate` method and inspect the experimentdata after completion: - - -my_data_generator_single = MyDataGenerator_SumInput() - -experiment_data.evaluate(data_generator=my_data_generator_single) -print(experiment_data) - -############################################################################### -# -# All built-in singular types are supported for storing to the :class:`~f3dasm.ExperimentData` this way. Array-like data such as numpy arrays and pandas dataframes are **not** supported and will raise an error. -# -# .. note:: -# -# Outputs stored directly to the :attr:`~f3dasm.ExperimentData.output_data` will be stored within the :class:`~f3dasm.ExperimentData` object. -# This means that the output will be loaded into memory everytime this object is accessed. For large outputs, it is recommended to store the output to disk. -# -# Large objects and array-like data -# --------------------------------- -# -# In order to store large objects or array-like data, the :meth:`~f3dasm.ExperimentSample.store` method using the ``to_disk=True`` argument, can be used. -# A reference (:code:`Path`) will be saved to the :attr:`~f3dasm.ExperimentData.output_data`. -# -# We create a another custom datagenerator that doubles the input features, but leaves them as an array: - -experiment_data = ExperimentData.from_sampling( - sampler='random', domain=domain, n_samples=10, seed=42) - - -class MyDataGenerator_DoubleInputs(DataGenerator): - def execute(self): - input_, output_ = self.experiment_sample.to_numpy() - y = input_ * 2 - self.experiment_sample.store( - object=y, name='output_numpy', to_disk=True) - - -my_data_generator = MyDataGenerator_DoubleInputs() - -experiment_data.evaluate(data_generator=my_data_generator) -print(experiment_data) - -############################################################################### -# :mod:`f3dasm` will automatically create a new directory in the project directory for each output parameter and store the object with a generated filename referencing the :attr:`~f3dasm.design.ExperimentSample.job_number` of the design. -# -# .. code-block:: none -# :caption: Directory Structure -# -# project_dir/ -# β”œβ”€β”€ output_numpy/ -# β”‚ β”œβ”€β”€ 0.npy -# β”‚ β”œβ”€β”€ 1.npy -# β”‚ β”œβ”€β”€ 2.npy -# β”‚ └── 3.npy -# β”‚ -# └── experiment_data/ -# β”œβ”€β”€ domain.pkl -# β”œβ”€β”€ input.csv -# β”œβ”€β”€ output.csv -# └── jobs.pkl -# -# -# In the output data of the :class:`~f3dasm.ExperimentData` object, a reference path (e.g. :code:`/output_numpy/0.npy`) to the stored object will be saved. -# -# Create a custom storage method -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# :mod:`f3dasm` has built-in storing functions for numpy :class:`~numpy.ndarray`, pandas :class:`~pandas.DataFrame` and xarray :class:`~xarray.DataArray` and :class:`~xarray.Dataset` objects. -# For any other type of object, the object will be stored in the `pickle `_ format -# -# You can provide your own storing class to the :class:`~f3dasm.ExperimentSample.store` method call: -# -# * a ``store`` method should store an ``self.object`` to disk at the location of ``self.path`` -# * a ``load`` method should load the object from disk at the location of ``self.path`` and return it -# * a class variable ``suffix`` should be defined, which is the file extension of the stored object as a string. -# * the class should inherit from the :class:`~f3dasm.StoreProtocol` class -# -# You can take the following class for a :class:`~numpy.ndarray` object as an example: - - -class NumpyStore(StoreProtocol): - suffix: int = '.npy' - - def store(self) -> None: - np.save(file=self.path.with_suffix(self.suffix), arr=self.object) - - def load(self) -> np.ndarray: - return np.load(file=self.path.with_suffix(self.suffix)) - -############################################################################### -# After defining the storing function, it can be used as an additional argument in the :meth:`~f3dasm.ExperimentSample.store` method: - - -class MyDataGenerator_DoubleInputs(DataGenerator): - def execute(self): - input_, output_ = self.experiment_sample.to_numpy() - y = input_ * 2 - self.experiment_sample.store( - object=y, name='output_numpy', - to_disk=True, store_method=NumpyStore) diff --git a/examples/003_datageneration/README.rst b/examples/003_datageneration/README.rst deleted file mode 100644 index 8726d463..00000000 --- a/examples/003_datageneration/README.rst +++ /dev/null @@ -1,8 +0,0 @@ -Generating output using the Data Generator -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The :class:`~f3dasm.datageneration.DataGenerator` class is the main class of the :mod:`~f3dasm.datageneration` module. -It is used to generate :attr:`~f3dasm.ExperimentData.output_data` for the :class:`~f3dasm.ExperimentData`. - -The :class:`~f3dasm.datageneration.DataGenerator` can serve as the interface between the -:class:`~f3dasm.ExperimentData` object and any third-party simulation software. \ No newline at end of file diff --git a/examples/003_datageneration/output_numpy/0.npy b/examples/003_datageneration/output_numpy/0.npy deleted file mode 100644 index 3759655a..00000000 Binary files a/examples/003_datageneration/output_numpy/0.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/1.npy b/examples/003_datageneration/output_numpy/1.npy deleted file mode 100644 index 4a77a22a..00000000 Binary files a/examples/003_datageneration/output_numpy/1.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/2.npy b/examples/003_datageneration/output_numpy/2.npy deleted file mode 100644 index 0abee086..00000000 Binary files a/examples/003_datageneration/output_numpy/2.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/3.npy b/examples/003_datageneration/output_numpy/3.npy deleted file mode 100644 index 39c452a0..00000000 Binary files a/examples/003_datageneration/output_numpy/3.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/4.npy b/examples/003_datageneration/output_numpy/4.npy deleted file mode 100644 index c89c9782..00000000 Binary files a/examples/003_datageneration/output_numpy/4.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/5.npy b/examples/003_datageneration/output_numpy/5.npy deleted file mode 100644 index f7073b2a..00000000 Binary files a/examples/003_datageneration/output_numpy/5.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/6.npy b/examples/003_datageneration/output_numpy/6.npy deleted file mode 100644 index b2fa3491..00000000 Binary files a/examples/003_datageneration/output_numpy/6.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/7.npy b/examples/003_datageneration/output_numpy/7.npy deleted file mode 100644 index 8a2172a5..00000000 Binary files a/examples/003_datageneration/output_numpy/7.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/8.npy b/examples/003_datageneration/output_numpy/8.npy deleted file mode 100644 index 5246aa5d..00000000 Binary files a/examples/003_datageneration/output_numpy/8.npy and /dev/null differ diff --git a/examples/003_datageneration/output_numpy/9.npy b/examples/003_datageneration/output_numpy/9.npy deleted file mode 100644 index 47f2dcd4..00000000 Binary files a/examples/003_datageneration/output_numpy/9.npy and /dev/null differ diff --git a/examples/004_optimization/001_builtin_optimizers.py b/examples/004_optimization/001_builtin_optimizers.py deleted file mode 100644 index ec615dea..00000000 --- a/examples/004_optimization/001_builtin_optimizers.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Use the built-in optimization algorithms -======================================== - -In this example, we will use the built-in optimization algorithms provided by the :mod:`f3dasm.optimization` submodule to optimize the Rosenbrock benchmark function. -""" - -import matplotlib.pyplot as plt - -from f3dasm import ExperimentData -from f3dasm.design import make_nd_continuous_domain -from f3dasm.optimization import OPTIMIZERS - -############################################################################### -# We create a 3D continous domain and sample one point from it. - -domain = make_nd_continuous_domain([[-1., 1.], [-1., 1.], [-1., 1.]]) - -experimentdata = ExperimentData.from_sampling( - domain=domain, sampler="random", seed=42, n_samples=1) - -print(experimentdata) - -############################################################################### -# We evaluate the sample point on the Rosenbrock benchmark function: - -experimentdata.evaluate(data_generator='Rosenbrock', kwargs={ - 'scale_bounds': domain.get_bounds(), 'offset': False}) - -print(experimentdata) - -############################################################################### -# We call the :meth:`~f3dasm.ExperimentData.optimize` method with ``optimizer='CG'`` -# and ``data_generator='Rosenbrock'`` to optimize the Rosenbrock benchmark function with the -# Conjugate Gradient Optimizer: - -experimentdata.optimize(optimizer='CG', data_generator='Rosenbrock', kwargs={ - 'scale_bounds': domain.get_bounds(), 'offset': False}, - iterations=50) - -print(experimentdata) - -############################################################################### -# We plot the convergence of the optimization process: - -_, df_output = experimentdata.to_pandas() - -fig, ax = plt.subplots() -ax.plot(df_output) -_ = ax.set_xlabel('number of function evaluations') -_ = ax.set_ylabel('$f(x)$') -ax.set_yscale('log') - -############################################################################### -# Hyper-parameters of the optimizer can be passed as dictionary to the :meth:`~f3dasm.ExperimentData.optimize` method. -# If none are provided, default hyper-parameters are used. The hyper-parameters are specific to the optimizer used, and can be found in the corresponding documentation. -# -# An overview of the available optimizers can be found in :ref:`this section ` of the documentation -# Access to more off-the-shelf optimizers requires the installation of the `f3dasm_optimize `_ package and its corresponding dependencies. -# You can check which optimizers can be used by inspecting the ``f3dasm.optimization.OPTIMIZERS`` variable: - -print(OPTIMIZERS) diff --git a/examples/004_optimization/README.rst b/examples/004_optimization/README.rst deleted file mode 100644 index 9c060fee..00000000 --- a/examples/004_optimization/README.rst +++ /dev/null @@ -1,4 +0,0 @@ -Optimizing your design -^^^^^^^^^^^^^^^^^^^^^^ - -Optimize your design with your optimization algorithm or use the built-in optimizers. \ No newline at end of file diff --git a/examples/005_workflow/001_cluster_computing.py b/examples/005_workflow/001_cluster_computing.py deleted file mode 100644 index 9400a330..00000000 --- a/examples/005_workflow/001_cluster_computing.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -Using f3dasm on a high-performance cluster computer -=================================================== - -Your :mod:`f3dasm` workflow can be seemlessly translated to a high-performance computing cluster. -The advantage is that you can parallelize the total number of experiments among the nodes of the cluster. -This is especially useful when you have a large number of experiments to run. -""" - -############################################################################### -# .. note:: -# This example has been tested on the following high-performance computing cluster systems: -# -# * The `hpc06 cluster of Delft University of Technology `_ , using the `TORQUE resource manager `_. -# * The `DelftBlue: TU Delft supercomputer `_, using the `SLURM resource manager `_. -# * The `OSCAR compute cluster from Brown University `_, using the `SLURM resource manager `_. - -from time import sleep - -import numpy as np - -from f3dasm import HPC_JOBID, ExperimentData -from f3dasm.design import make_nd_continuous_domain - -############################################################################### -# We will create the following data-driven process: -# -# * Create a 20D continuous :class:`~f3dasm.design.Domain` -# * Sample from the domain using a the Latin-hypercube sampler -# * With multiple nodes; use a data generation function, which will be the ``"Ackley"`` function a from the benchmark functions -# -# -# .. image:: ../../img/f3dasm-workflow-example-cluster.png -# :width: 70% -# :align: center -# :alt: Workflow - -############################################################################### -# We want to make sure that the sampling is done only once, and that the data generation is done in parallel. -# Therefore we can divide the different nodes into two categories: -# -# * The first node (:code:`f3dasm.HPC_JOBID == 0`) will be the **master** node, which will be responsible for creating the design-of-experiments and sampling (the ``create_experimentdata`` function). - - -def create_experimentdata(): - """Design of Experiment""" - # Create a domain object - domain = make_nd_continuous_domain( - bounds=np.tile([0.0, 1.0], (20, 1)), dimensionality=20) - - # Create the ExperimentData object - data = ExperimentData(domain=domain) - - # Sampling from the domain - data.sample(sampler='latin', n_samples=10) - - # Store the data to disk - data.store() - -############################################################################### -# * All the other nodes (:code:`f3dasm.HPC_JOBID > 0`) will be **process** nodes, which will retrieve the :class:`~f3dasm.ExperimentData` from disk and go straight to the data generation function. -# -# .. image:: ../../img/f3dasm-workflow-cluster-roles.png -# :width: 100% -# :align: center -# :alt: Cluster roles - - -def worker_node(): - # Extract the experimentdata from disk - data = ExperimentData.from_file(project_dir='.') - - """Data Generation""" - # Use the data-generator to evaluate the initial samples - data.evaluate(data_generator='Ackley', mode='cluster') - -############################################################################### -# The entrypoint of the script can now check the jobid of the current node and decide whether to create the experiment data or to run the data generation function: - - -if __name__ == '__main__': - # Check the jobid of the current node - if HPC_JOBID is None: - # If the jobid is none, we are not running anything now - pass - - elif HPC_JOBID == 0: - create_experimentdata() - worker_node() - elif HPC_JOBID > 0: - # Asynchronize the jobs in order to omit racing conditions - sleep(HPC_JOBID) - worker_node() - -############################################################################### -# -# Running the program -# ------------------- -# -# You can run the workflow by submitting the bash script to the HPC queue: -# Make sure you have `miniconda3 `_ installed on the cluster, and that you have created a conda environment (in this example named ``f3dasm_env``) with the necessary packages: -# -# .. tabs:: -# -# .. group-tab:: TORQUE -# -# .. code-block:: bash -# -# #!/bin/bash -# # Torque directives (#PBS) must always be at the start of a job script! -# #PBS -N ExampleScript -# #PBS -q mse -# #PBS -l nodes=1:ppn=12,walltime=12:00:00 -# -# # Make sure I'm the only one that can read my output -# umask 0077 -# -# -# # The PBS_JOBID looks like 1234566[0]. -# # With the following line, we extract the PBS_ARRAYID, the part in the brackets []: -# PBS_ARRAYID=$(echo "${PBS_JOBID}" | sed 's/\[[^][]*\]//g') -# -# module load use.own -# module load miniconda3 -# cd $PBS_O_WORKDIR -# -# # Here is where the application is started on the node -# # activating my conda environment: -# -# source activate f3dasm_env -# -# # limiting number of threads -# OMP_NUM_THREADS=12 -# export OMP_NUM_THREADS=12 -# -# -# # If the PBS_ARRAYID is not set, set it to None -# if ! [ -n "${PBS_ARRAYID+1}" ]; then -# PBS_ARRAYID=None -# fi -# -# # Executing my python program with the jobid flag -# python main.py --jobid=${PBS_ARRAYID} -# -# .. group-tab:: SLURM -# -# .. code-block:: bash -# -# #!/bin/bash -l -# -# #SBATCH -J "ExmpleScript" # name of the job (can be change to whichever name you like) -# #SBATCH --get-user-env # to set environment variables -# -# #SBATCH --partition=compute -# #SBATCH --time=12:00:00 -# #SBATCH --nodes=1 -# #SBATCH --ntasks-per-node=12 -# #SBATCH --cpus-per-task=1 -# #SBATCH --mem=0 -# #SBATCH --account=research-eemcs-me -# #SBATCH --array=0-2 -# -# source activate f3dasm_env -# -# # Executing my python program with the jobid flag -# python3 main.py --jobid=${SLURM_ARRAY_TASK_ID} -# -# -# You can run the workflow by submitting the bash script to the HPC queue. -# the following command submits an array job with 3 jobs with :code:`f3dasm.HPC_JOBID` of 0, 1 and 2. -# -# .. tabs:: -# -# .. group-tab:: TORQUE -# -# .. code-block:: bash -# -# qsub pbsjob.sh -t 0-2 -# -# .. group-tab:: SLURM -# -# .. code-block:: bash -# -# sbatch --array 0-2 pbsjob.sh -# diff --git a/examples/005_workflow/README.rst b/examples/005_workflow/README.rst deleted file mode 100644 index 256635b3..00000000 --- a/examples/005_workflow/README.rst +++ /dev/null @@ -1,2 +0,0 @@ -Combining everything in a data-driven workflow -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \ No newline at end of file diff --git a/examples/006_hydra/001_hydra_usage.py b/examples/006_hydra/001_hydra_usage.py deleted file mode 100644 index 7799bf7f..00000000 --- a/examples/006_hydra/001_hydra_usage.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -Combine hydra configurations with f3dasm -======================================== - -.. _hydra: https://hydra.cc/ - -`hydra `_ is an open-source configuration management framework that is widely used in machine learning and other software development domains. -It is designed to help developers manage and organize complex configuration settings for their projects, -making it easier to experiment with different configurations, manage multiple environments, and maintain reproducibility in their work. - -`hydra `_ can be seamlessly integrated with the worfklows in :mod:`f3dasm` to manage the configuration settings for the project. -""" - -from hydra import compose, initialize - -from f3dasm import ExperimentData -from f3dasm.design import Domain - -############################################################################### -# Domain from a `hydra `_ configuration file -# ------------------------------------------------------------- -# -# If you are using `hydra `_ to manage your configuration files, you can create a domain from a configuration file. -# Your config needs to have a key (e.g. :code:`domain`) that has a dictionary with the parameter names (e.g. :code:`param_1`) as keys -# and a dictionary with the parameter type (:code:`type`) and the corresponding arguments as values: -# -# .. code-block:: yaml -# :caption: config.yaml -# -# domain: -# param_1: -# type: float -# low: -1.0 -# high: 1.0 -# param_2: -# type: int -# low: 1 -# high: 10 -# param_3: -# type: category -# categories: ['red', 'blue', 'green', 'yellow', 'purple'] -# param_4: -# type: constant -# value: some_value -# -# In order to run the following code snippet, you need to have a configuration file named :code:`config.yaml` in the current working directory. - - -with initialize(version_base=None, config_path="."): - config = compose(config_name="config") - -domain = Domain.from_yaml(config.domain) -print(domain) - -############################################################################### -# ExperimentData from a `hydra `_ configuration file -# --------------------------------------------------------------------- -# -# If you are using `hydra `_ for configuring your experiments, you can use it to construct -# an :class:`~f3dasm.ExperimentData` object from the information in the :code:`config.yaml` file with the :meth:`~f3dasm.ExperimentData.from_yaml` method. -# -# ExperimentData from file with hydra -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# You can create an experimentdata :class:`~f3dasm.ExperimentData` object in the same way as the :meth:`~f3dasm.design.Domain.from_file` method, but with the :code:`from_file` key in the :code:`config.yaml` file: -# -# .. code-block:: yaml -# :caption: config_from_file.yaml -# -# domain: -# x0: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# x1: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# -# experimentdata: -# from_file: ./example_project_dir -# -# .. note:: -# -# The :class:`~f3dasm.design.Domain` object will be constructed using the :code:`domain` key in the :code:`config.yaml` file. Make sure you have the :code:`domain` key in your :code:`config.yaml`! -# -# -# Inside your python script, you can then create the :class:`~f3dasm.ExperimentData` object with the :meth:`~f3dasm.ExperimentData.from_yaml` method: - -with initialize(version_base=None, config_path="."): - config_from_file = compose(config_name="config_from_file") - -data_from_file = ExperimentData.from_yaml(config_from_file.experimentdata) -print(data_from_file) - -############################################################################### -# ExperimentData from sampling with hydra -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# To create the :class:`~f3dasm.ExperimentData` object with the :meth:`~f3dasm.ExperimentData.from_sampling` method, -# you can use the following configuration: -# -# .. code-block:: yaml -# :caption: config_from_sampling.yaml -# -# domain: -# x0: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# x1: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# -# experimentdata: -# from_sampling: -# domain: ${domain} -# sampler: random -# seed: 1 -# n_samples: 10 -# -# In order to run the following code snippet, you need to have a configuration file named :code:`config_from_sampling.yaml` in the current working directory. - -with initialize(version_base=None, config_path="."): - config_sampling = compose(config_name="config_from_sampling") - -data_from_sampling = ExperimentData.from_yaml(config_sampling.experimentdata) -print(data_from_sampling) - -############################################################################### -# Combining both approaches -# ^^^^^^^^^^^^^^^^^^^^^^^^^ -# -# You can also combine both approaches to create the :class:`~f3dasm.ExperimentData` object by -# continuing an existing experiment with new samples. This can be done by providing both keys: -# -# .. code-block:: yaml -# :caption: config_combining.yaml -# -# domain: -# x0: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# x1: -# type: float -# lower_bound: 0. -# upper_bound: 1. -# -# experimentdata: -# from_file: ./example_project_dir -# from_sampling: -# domain: ${domain} -# sampler: random -# seed: 1 -# n_samples: 10 -# -# In order to run the following code snippet, you need to have a configuration file named :code:`config_combining.yaml` in the current working directory. - -with initialize(version_base=None, config_path="."): - config_sampling = compose(config_name="config_combining") - -data_combining = ExperimentData.from_yaml(config_sampling.experimentdata) -print(data_combining) diff --git a/examples/006_hydra/002_cluster_hydra.py b/examples/006_hydra/002_cluster_hydra.py deleted file mode 100644 index 276784d9..00000000 --- a/examples/006_hydra/002_cluster_hydra.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Using hydra on the high-performance cluster computer -==================================================== - -`hydra `_ can be seamlessly integrated with the worfklows in :mod:`f3dasm` to manage the configuration settings for the project. -""" -############################################################################### -# -# The following example is the same as in workflow section; we will create a workflow for the following data-driven process: -# -# * Create a 2D continuous :class:`~f3dasm.design.Domain` -# * Sample from the domain using a the Latin-hypercube sampler -# * Use a data generation function, which will be the ``"Ackley"`` function a from the benchmark functions -# -# .. image:: ../../img/f3dasm-workflow-example-cluster.png -# :width: 70% -# :align: center -# :alt: Workflow - -from time import sleep - -import hydra - -from f3dasm import ExperimentData - -############################################################################### -# Directory Structure -# ^^^^^^^^^^^^^^^^^^^ -# -# The directory structure for the project is as follows: -# -# - `my_project/` is the current working directory. -# - `config.yaml` is a hydra YAML configuration file. -# - `main.py` is the main entry point of the project, governed by :mod:`f3dasm`. -# -# -# .. code-block:: none -# :caption: Directory Structure -# -# my_project/ -# β”œβ”€β”€ my_script.py -# └── config.yaml -# └── main.py -# -# The `config_from_sampling.yaml` file contains the configuration settings for the project: -# -# .. code-block:: yaml -# :caption: config_from_sampling.yaml -# -# domain: -# x0: -# type: float -# low: 0. -# high: 1. -# x1: -# type: float -# low: 0. -# high: 1. -# -# experimentdata: -# from_sampling: -# domain: ${domain} -# sampler: random -# seed: 1 -# n_samples: 10 -# -# mode: sequential -# -# hpc: -# jobid: -1 -# -# It specifies the search-space domain, sampler settings, and the execution mode (`sequential` in this case). -# The domain is defined with `x0` and `x1` as continuous parameters with their corresponding lower and upper bounds. -# -# We want to make sure that the sampling is done only once, and that the data generation is done in parallel. -# Therefore we can divide the different nodes into two categories: -# -# * The first node (:code:`f3dasm.HPC_JOBID == 0`) will be the **master** node, which will be responsible for creating the design-of-experiments and sampling (the ``create_experimentdata`` function). - - -def create_experimentdata(config): - """Design of Experiment""" - # Create the ExperimentData object - data = ExperimentData.from_yaml(config.experimentdata) - - # Store the data to disk - data.store() - - -def worker_node(config): - # Extract the experimentdata from disk - data = ExperimentData.from_file(project_dir='.') - - """Data Generation""" - # Use the data-generator to evaluate the initial samples - data.evaluate(data_generator='Ackley', mode=config.mode) - - -############################################################################### -# The entrypoint of the script can now check the jobid of the current node and decide whether to create the experiment data or to run the data generation function: - -@hydra.main(config_path=".", config_name="config_from_sampling") -def main(config): - # Check the jobid of the current node - if config.hpc.jobid == 0: - create_experimentdata() - worker_node() - elif config.hpc.jobid == -1: # Sequential - create_experimentdata() - worker_node() - elif config.hpc.jobid > 0: - # Asynchronize the jobs in order to omit racing conditions - sleep(config.hpc.jobid) - worker_node() - - -############################################################################### -# -# Running the program -# ------------------- -# -# You can run the workflow by submitting the bash script to the HPC queue: -# Make sure you have `miniconda3 `_ installed on the cluster, and that you have created a conda environment (in this example named ``f3dasm_env``) with the necessary packages: -# -# .. tabs:: -# -# .. group-tab:: TORQUE -# -# .. code-block:: bash -# -# #!/bin/bash -# # Torque directives (#PBS) must always be at the start of a job script! -# #PBS -N ExampleScript -# #PBS -q mse -# #PBS -l nodes=1:ppn=12,walltime=12:00:00 -# -# # Make sure I'm the only one that can read my output -# umask 0077 -# -# -# # The PBS_JOBID looks like 1234566[0]. -# # With the following line, we extract the PBS_ARRAYID, the part in the brackets []: -# PBS_ARRAYID=$(echo "${PBS_JOBID}" | sed 's/\[[^][]*\]//g') -# -# module load use.own -# module load miniconda3 -# cd $PBS_O_WORKDIR -# -# # Here is where the application is started on the node -# # activating my conda environment: -# -# source activate f3dasm_env -# -# # limiting number of threads -# OMP_NUM_THREADS=12 -# export OMP_NUM_THREADS=12 -# -# -# # If the PBS_ARRAYID is not set, set it to None -# if ! [ -n "${PBS_ARRAYID+1}" ]; then -# PBS_ARRAYID=None -# fi -# -# # Executing my python program with the jobid flag -# python main.py ++hpc.jobid=${PBS_ARRAYID} hydra.run.dir=outputs/${now:%Y-%m-%d}/${JOB_ID} -# -# .. group-tab:: SLURM -# -# .. code-block:: bash -# -# #!/bin/bash -l -# -# #SBATCH -J "ExmpleScript" # name of the job (can be change to whichever name you like) -# #SBATCH --get-user-env # to set environment variables -# -# #SBATCH --partition=compute -# #SBATCH --time=12:00:00 -# #SBATCH --nodes=1 -# #SBATCH --ntasks-per-node=12 -# #SBATCH --cpus-per-task=1 -# #SBATCH --mem=0 -# #SBATCH --account=research-eemcs-me -# #SBATCH --array=0-2 -# -# source activate f3dasm_env -# -# # Executing my python program with the jobid flag -# python main.py ++hpc.jobid=${SLURM_ARRAY_TASK_ID} hydra.run.dir=/scratch/${USER}/${projectfolder}/${SLURM_ARRAY_JOB_ID} -# -# .. warning:: -# Make sure you set the ``hydra.run.dir`` argument in the jobscript to the location where you want to store the output of the hydra runs! -# -# You can run the workflow by submitting the bash script to the HPC queue. -# the following command submits an array job with 3 jobs with :code:`f3dasm.HPC_JOBID` of 0, 1 and 2. -# -# .. tabs:: -# -# .. group-tab:: TORQUE -# -# .. code-block:: bash -# -# qsub pbsjob.sh -t 0-2 -# -# .. group-tab:: SLURM -# -# .. code-block:: bash -# -# sbatch --array 0-2 pbsjob.sh -# diff --git a/examples/006_hydra/README.rst b/examples/006_hydra/README.rst deleted file mode 100644 index 07dfdd1c..00000000 --- a/examples/006_hydra/README.rst +++ /dev/null @@ -1,4 +0,0 @@ -Integration with hydra -^^^^^^^^^^^^^^^^^^^^^^ - -Examples that integrate the :mod:`f3dasm` package with the configuration manager `hydra `_ diff --git a/examples/006_hydra/config.yaml b/examples/006_hydra/config.yaml deleted file mode 100644 index fd99800d..00000000 --- a/examples/006_hydra/config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -domain: - param_1: - type: float - low: -1.0 - high: 1.0 - param_2: - type: int - low: 1 - high: 10 - param_3: - type: category - categories: ['red', 'blue', 'green', 'yellow', 'purple'] - param_4: - type: constant - value: some_value diff --git a/examples/006_hydra/config_combining.yaml b/examples/006_hydra/config_combining.yaml deleted file mode 100644 index bcea14ee..00000000 --- a/examples/006_hydra/config_combining.yaml +++ /dev/null @@ -1,23 +0,0 @@ -domain: - param_1: - type: float - low: -1.0 - high: 1.0 - param_2: - type: int - low: 1 - high: 10 - param_3: - type: category - categories: ['red', 'blue', 'green', 'yellow', 'purple'] - param_4: - type: constant - value: some_value - -experimentdata: - from_file: ./example_project_dir - from_sampling: - domain: ${domain} - sampler: random - seed: 1 - n_samples: 10 diff --git a/examples/006_hydra/config_from_file.yaml b/examples/006_hydra/config_from_file.yaml deleted file mode 100644 index 282de482..00000000 --- a/examples/006_hydra/config_from_file.yaml +++ /dev/null @@ -1,12 +0,0 @@ -domain: - x0: - type: float - low: 0. - high: 1. - x1: - type: float - low: 0. - high: 1. - -experimentdata: - from_file: ./example_project_dir \ No newline at end of file diff --git a/examples/006_hydra/config_from_sampling.yaml b/examples/006_hydra/config_from_sampling.yaml deleted file mode 100644 index 096d03c4..00000000 --- a/examples/006_hydra/config_from_sampling.yaml +++ /dev/null @@ -1,21 +0,0 @@ -domain: - x0: - type: float - low: 0. - high: 1. - x1: - type: float - low: 0. - high: 1. - -experimentdata: - from_sampling: - domain: ${domain} - sampler: random - seed: 1 - n_samples: 10 - -mode: sequential - -hpc: - jobid: -1 \ No newline at end of file diff --git a/examples/006_hydra/example_project_dir/experiment_data/domain.pkl b/examples/006_hydra/example_project_dir/experiment_data/domain.pkl deleted file mode 100644 index 6b319cb7..00000000 Binary files a/examples/006_hydra/example_project_dir/experiment_data/domain.pkl and /dev/null differ diff --git a/examples/006_hydra/example_project_dir/experiment_data/input.csv b/examples/006_hydra/example_project_dir/experiment_data/input.csv deleted file mode 100644 index e16c76df..00000000 --- a/examples/006_hydra/example_project_dir/experiment_data/input.csv +++ /dev/null @@ -1 +0,0 @@ -"" diff --git a/examples/006_hydra/example_project_dir/experiment_data/jobs.pkl b/examples/006_hydra/example_project_dir/experiment_data/jobs.pkl deleted file mode 100644 index 1884af77..00000000 Binary files a/examples/006_hydra/example_project_dir/experiment_data/jobs.pkl and /dev/null differ diff --git a/examples/006_hydra/example_project_dir/experiment_data/output.csv b/examples/006_hydra/example_project_dir/experiment_data/output.csv deleted file mode 100644 index e16c76df..00000000 --- a/examples/006_hydra/example_project_dir/experiment_data/output.csv +++ /dev/null @@ -1 +0,0 @@ -"" diff --git a/examples/README.rst b/examples/README.rst deleted file mode 100644 index 341c60a6..00000000 --- a/examples/README.rst +++ /dev/null @@ -1,9 +0,0 @@ -.. _examples: - -Tutorials -========= - -.. toctree:: - :hidden: - -Below is a gallery of tutorials that use various part of the :mod:`f3dasm` package diff --git a/src/f3dasm/__init__.py b/src/f3dasm/__init__.py index 7dee4105..4fc27698 100644 --- a/src/f3dasm/__init__.py +++ b/src/f3dasm/__init__.py @@ -15,9 +15,9 @@ from .__version__ import __version__ from ._src._argparser import HPC_JOBID -from ._src.experimentdata._io import StoreProtocol -from ._src.experimentdata.experimentdata import ExperimentData -from ._src.experimentdata.experimentsample import ExperimentSample +from ._src.core import Block, loop +from ._src.experimentdata import ExperimentData +from ._src.experimentsample import ExperimentSample from ._src.logger import DistributedFileHandler, logger # Authorship and Credits @@ -35,6 +35,8 @@ __all__ = [ 'ExperimentData', 'ExperimentSample', + 'Block', + 'loop', 'DistributedFileHandler', 'logger', 'HPC_JOBID', diff --git a/src/f3dasm/__version__.py b/src/f3dasm/__version__.py index 329982a2..f0b4591c 100644 --- a/src/f3dasm/__version__.py +++ b/src/f3dasm/__version__.py @@ -1 +1 @@ -__version__: str = "1.5.4" +__version__: str = "2.0.0" diff --git a/src/f3dasm/_src/_io.py b/src/f3dasm/_src/_io.py new file mode 100644 index 00000000..7f45b5a0 --- /dev/null +++ b/src/f3dasm/_src/_io.py @@ -0,0 +1,450 @@ +""" +Module to load and save output data of experiments and other common IO +operations. +""" + +# Modules +# ============================================================================= + +from __future__ import annotations + +# Standard +import pickle +from pathlib import Path +from typing import Any, Callable, Mapping, Optional, Type + +# Third-party +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import xarray as xr + +# Local +from .logger import logger + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= + +# Global folder and file names +# ============================================================================= + +EXPERIMENTDATA_SUBFOLDER = "experiment_data" + +LOCK_FILENAME = "lock" +DOMAIN_FILENAME = "domain" +INPUT_DATA_FILENAME = "input" +OUTPUT_DATA_FILENAME = "output" +JOBS_FILENAME = "jobs" + +RESOLUTION_MATPLOTLIB_FIGURE = 300 +MAX_TRIES = 10 + +# Storing methods +# ============================================================================= + + +def pickle_store(object: Any, path: str) -> str: + """ + Store an object using pickle. + + Parameters + ---------- + object : Any + The object to store. + path : str + The path where the object will be stored. + + Returns + ------- + str + The path to the stored object. + """ + _path = Path(path).with_suffix('.pkl') + with open(_path, 'wb') as file: + pickle.dump(object, file) + + return str(_path) + + +def pickle_load(path: str) -> Any: + """ + Load an object using pickle. + + Parameters + ---------- + path : str + The path to the object to load. + + Returns + ------- + Any + The loaded object. + """ + _path = Path(path).with_suffix('.pkl') + with open(_path, 'rb') as file: + return pickle.load(file) + + +def numpy_store(object: np.ndarray, path: str) -> str: + """ + Store a numpy array. + + Parameters + ---------- + object : np.ndarray + The numpy array to store. + path : str + The path where the array will be stored. + + Returns + ------- + str + The path to the stored array. + """ + _path = Path(path).with_suffix('.npy') + np.save(file=_path, arr=object) + return str(_path) + + +def numpy_load(path: str) -> np.ndarray: + """ + Load a numpy array. + + Parameters + ---------- + path : str + The path to the array to load. + + Returns + ------- + np.ndarray + The loaded array. + """ + _path = Path(path).with_suffix('.npy') + return np.load(file=_path) + + +def pandas_store(object: pd.DataFrame, path: str) -> str: + """ + Store a pandas DataFrame. + + Parameters + ---------- + object : pd.DataFrame + The DataFrame to store. + path : str + The path where the DataFrame will be stored. + + Returns + ------- + str + The path to the stored DataFrame. + """ + _path = Path(path).with_suffix('.csv') + object.to_csv(_path) + return str(_path) + + +def pandas_load(path: str) -> pd.DataFrame: + """ + Load a pandas DataFrame. + + Parameters + ---------- + path : str + The path to the DataFrame to load. + + Returns + ------- + pd.DataFrame + The loaded DataFrame. + """ + _path = Path(path).with_suffix('.csv') + return pd.read_csv(_path, index_col=0, header=0) + + +def xarray_dataset_store(object: xr.DataArray | xr.Dataset, path: str) -> str: + """ + Store an xarray DataArray or Dataset. + + Parameters + ---------- + object : xr.DataArray or xr.Dataset + The xarray object to store. + path : str + The path where the object will be stored. + + Returns + ------- + str + The path to the stored object. + """ + _path = Path(path).with_suffix('.ncs') + object.to_netcdf(_path) + return str(_path) + + +def xarray_dataarray_store(object: xr.DataArray | xr.Dataset, path: str + ) -> str: + """ + Store an xarray DataArray or Dataset. + + Parameters + ---------- + object : xr.DataArray or xr.Dataset + The xarray object to store. + path : str + The path where the object will be stored. + + Returns + ------- + str + The path to the stored object. + """ + _path = Path(path).with_suffix('.nca') + object.to_netcdf(_path) + return str(_path) + + +def xarray_dataset_load(path: str) -> xr.DataArray | xr.Dataset: + """ + Load an xarray Dataset. + + Parameters + ---------- + path : str + The path to the Dataset to load. + + Returns + ------- + xr.DataArray or xr.Dataset + The loaded Dataset. + """ + # TODO: open_dataset and open_dataarray? + _path = Path(path).with_suffix('.ncs') + return xr.open_dataset(_path) + + +def xarray_dataarray_load(path: str) -> xr.DataArray | xr.Dataset: + """ + Load an xarray DataArray. + + Parameters + ---------- + path : str + The path to the DataArray to load. + + Returns + ------- + xr.DataArray or xr.Dataset + The loaded DataArray. + """ + # TODO: open_dataset and open_dataarray? + _path = Path(path).with_suffix('.nca') + return xr.open_dataarray(_path) + + +def figure_store(object: plt.Figure, path: str) -> str: + """ + Store a matplotlib figure. + + Parameters + ---------- + object : plt.Figure + The figure to store. + path : str + The path where the figure will be stored. + + Returns + ------- + str + The path to the stored figure. + """ + _path = Path(path).with_suffix('.png') + object.savefig(_path, dpi=RESOLUTION_MATPLOTLIB_FIGURE, + bbox_inches='tight') + return str(_path) + + +def figure_load(path: str) -> np.ndarray: + """ + Load a matplotlib figure. + + Parameters + ---------- + path : str + The path to the figure to load. + + Returns + ------- + np.ndarray + The loaded figure. + """ + _path = Path(path).with_suffix('.png') + return plt.imread(_path) + + +STORE_FUNCTION_MAPPING: Mapping[Type, Callable] = { + np.ndarray: numpy_store, + pd.DataFrame: pandas_store, + pd.Series: pandas_store, + xr.DataArray: xarray_dataarray_store, + xr.Dataset: xarray_dataset_store, + plt.Figure: figure_store, +} + +LOAD_FUNCTION_MAPPING: Mapping[str, Callable] = { + '.npy': numpy_load, + '.csv': pandas_load, + '.ncs': xarray_dataset_load, + '.nca': xarray_dataarray_load, + '.png': figure_load, +} + +# Loading and saving functions +# ============================================================================= + + +def _project_dir_factory(project_dir: Path | str | None) -> Path: + """ + Create a Path object for the project directory. + + Parameters + ---------- + project_dir : Path or str or None + The path of the user-defined directory where to create the f3dasm + project folder. + + Returns + ------- + Path + The Path object for the project directory. + + Raises + ------ + TypeError + If the project_dir is not of type Path, str, or None. + """ + if isinstance(project_dir, Path): + return project_dir.absolute() + + if project_dir is None: + return Path().cwd() + + if isinstance(project_dir, str): + return Path(project_dir).absolute() + + raise TypeError( + f"project_dir must be of type Path, str or None, \ + not {type(project_dir).__name__}") + + +def store_to_disk(project_dir: Path, object: Any, + name: str, id: int, + store_function: Optional[Callable] = None) -> str: + """ + Store an object to disk. + + Parameters + ---------- + project_dir : Path + The ExperimentData project_dir path. + object : Any + The object to store. + name : str + The name of the object. + id : int + The id of the object. + store_function : Optional[Callable], optional + The method to store the object, by default None. + + Returns + ------- + str + The path to the stored object. + + Notes + ----- + If no store method is provided, the function will try to find a matching + store type based on the object's type. If no matching type is found, the + function will use pickle to store the object to disk. + """ + path = project_dir / EXPERIMENTDATA_SUBFOLDER / name / str(id) + + # Check if the storage parent folder exists + path.parent.mkdir(parents=True, exist_ok=True) + + # If no store method is provided, try to find a matching store type + if store_function is None: + # Check if object type is supported + object_type = type(object) + + if object_type not in STORE_FUNCTION_MAPPING: + store_function = pickle_store + logger.debug( + f"Object type {object_type} is not natively supported. " + f"The default pickle storage method will be used.") + + else: + store_function = STORE_FUNCTION_MAPPING[object_type] + + # Store the object + absolute_path = Path(store_function(object, path)) + + # Return the path relative from the the project directory + return str(absolute_path.relative_to( + project_dir / EXPERIMENTDATA_SUBFOLDER)) + + +def load_object(project_dir: Path, path: str | Path, + load_function: Optional[Callable] = None) -> Any: + """ + Load an object from disk from a given path and storing method. + + Parameters + ---------- + project_dir : Path + The ExperimentData project_dir path. + path : str or Path + The path to the object. + load_function : Optional[Callable], optional + The method to load the object, by default None. + + Returns + ------- + Any + The object loaded from disk. + + Raises + ------ + ValueError + If no matching store type is found. + + Notes + ----- + If no store method is provided, the function will try to find a matching + store type based on the suffix of the item's path. If no matching type + is found, the function will use pickle to load the object from disk. + """ + _path = project_dir / EXPERIMENTDATA_SUBFOLDER / path + suffix = _path.suffix + + # If no store method is provided, try to find a matching store type + if load_function is None: + # Check if object type is supported + + if suffix not in LOAD_FUNCTION_MAPPING: + load_function = pickle_load + logger.debug( + f"Object type '{suffix}' is not natively supported. " + f"The default pickle load method will be used.") + + else: + load_function = LOAD_FUNCTION_MAPPING[suffix] + + # Store the object and return the storage location + return load_function(_path) diff --git a/src/f3dasm/_src/core.py b/src/f3dasm/_src/core.py new file mode 100644 index 00000000..9119be47 --- /dev/null +++ b/src/f3dasm/_src/core.py @@ -0,0 +1,472 @@ +""" +This module contains the core blocks and protocols for the f3dasm package. +""" +# Modules +# ============================================================================= + +from __future__ import annotations + +# Standard +import traceback +from abc import ABC, abstractmethod +from pathlib import Path +from typing import (Any, Callable, Dict, Iterable, List, Literal, Optional, + Protocol, Tuple) + +# Third-party +import numpy as np +import pandas as pd +from pathos.helpers import mp + +# Local +from .logger import logger + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Alpha' +# ============================================================================= +# +# ============================================================================= + + +class Block(ABC): + """ + Abstract base class representing an operation in the data-driven process + """ + + def arm(self, data: ExperimentData) -> None: + """ + Prepare the block with a given ExperimentData. + + Parameters + ---------- + data : ExperimentData + The experiment data to be used by the block. + + Notes + ----- + This method can be inherited by a subclasses to prepare the block + with the given experiment data. It is not required to implement this + method in the subclass. + """ + pass + + @abstractmethod + def call(self, data: ExperimentData, **kwargs) -> ExperimentData: + """ + Execute the block's operation on the ExperimentData. + + Parameters + ---------- + data : ExperimentData + The experiment data to process. + **kwargs : dict + Additional keyword arguments for the operation. + + Returns + ------- + ExperimentData + The processed experiment data. + """ + pass + + +class LoopBlock(Block): + def __init__(self, blocks: Block | Iterable[Block], n_loops: int): + """ + Initialize a LoopBlock instance. + + Parameters + ---------- + blocks : Block or Iterable[Block] + The block or blocks to loop over. + n_loops : int + The number of loops to perform. + """ + if isinstance(blocks, Block): + blocks = [blocks] + + self.blocks = blocks + self.n_loops = n_loops + + def call(self, data: ExperimentData, **kwargs) -> ExperimentData: + """ + Execute the looped blocks on the ExperimentData. + + Parameters + ---------- + data : ExperimentData + The experiment data to process. + **kwargs : dict + Additional keyword arguments for the blocks. + + Returns + ------- + ExperimentData + The processed experiment data after looping. + """ + for _ in range(self.n_loops): + for block in self.blocks: + block.arm(data) + data = block.call(data=data, **kwargs) + + return data + + +def loop(blocks: Block | Iterable[Block], n_loops: int) -> Block: + """ + Create a loop to execute blocks multiple times. + + Parameters + ---------- + blocks : Block or Iterable[Block] + The block or blocks to loop over. + n_loops : int + The number of loops to perform. + + Returns + ------- + Block + An new Block instance that loops over the given blocks. + """ + return LoopBlock(blocks=blocks, n_loops=n_loops) + +# ============================================================================= + + +class Domain(Protocol): + ... + + +class ExperimentSample(Protocol): + def mark(self, + status: Literal['open', 'in_progress', 'finished', 'error']): + ... + + @property + def input_data(self) -> Dict[str, Any]: + ... + + @property + def output_data(self) -> Dict[str, Any]: + ... + + +class ExperimentData(Protocol): + def __init__(self, domain: Domain, input_data: np.ndarray, + output_data: np.ndarray): + ... + + @property + def domain(self) -> Domain: + ... + + @property + def project_dir(self) -> Path: + ... + + @property + def index(self) -> pd.Index: + ... + + @classmethod + def from_sampling(cls, domain: Domain, sampler: Block, + n_samples: int, seed: int) -> ExperimentData: + ... + + def access_file(self, operation: Callable) -> Callable: + ... + + def get_open_job(self) -> Tuple[int, ExperimentSample]: + ... + + def store_experimentsample(self, + experiment_sample: ExperimentSample, id: int): + ... + + def from_file(self, project_dir: str) -> ExperimentData: + ... + + def store(self) -> None: + ... + + def mark(self, indices: int | Iterable[int], + status: Literal['open', 'in_progress', 'finished', 'error']): + ... + + def remove_lockfile(self) -> None: + ... + + def sample(self, sampler: Block, **kwargs): + ... + + def evaluate(self, data_generator: DataGenerator, mode: + str, output_names: Optional[List[str]] = None, **kwargs): + ... + + def get_n_best_output(self, n_samples: int) -> ExperimentData: + ... + + def to_numpy() -> Tuple[np.ndarray, np.ndarray]: + ... + + def select(self, indices: int | slice | Iterable[int]) -> ExperimentData: + ... + + def get_experiment_sample(self, id: int) -> ExperimentData: + ... + + def remove_rows_bottom(self, number_of_rows: int): + ... + + def add_experiments(self, experiment_sample: ExperimentData): + ... + + def _overwrite_experiments(self, experiment_sample: ExperimentData, + indices: pd.Index, add_if_not_exist: bool): + ... + + def _reset_index(self): + ... + + +# ============================================================================= + + +class DataGenerator(Block): + """Base class for a data generator""" + + def call(self, data: ExperimentData, mode: str = 'sequential', **kwargs + ) -> ExperimentData: + """ + Evaluate the data generator. + + Parameters + ---------- + mode : str, optional + The mode of evaluation, by default 'sequential' + kwargs : dict + The keyword arguments to pass to the pre_process, execute and + post_process + + Returns + ------- + ExperimentData + The processed data + """ + if mode == 'sequential': + return self._evaluate_sequential(data=data, **kwargs) + elif mode == 'parallel': + return self._evaluate_multiprocessing(data=data, **kwargs) + elif mode.lower() == "cluster": + return self._evaluate_cluster(data=data, **kwargs) + else: + raise ValueError(f"Invalid parallelization mode specified: {mode}") + + # ========================================================================= + + def _evaluate_sequential(self, data: ExperimentData, **kwargs + ) -> ExperimentData: + """Run the operation sequentially + + Parameters + ---------- + kwargs : dict + Any keyword arguments that need to be supplied to the function + + Raises + ------ + NoOpenJobsError + Raised when there are no open jobs left + """ + while True: + + job_number, experiment_sample = data.get_open_job() + logger.debug( + f"Accessed experiment_sample \ + {job_number}") + if job_number is None: + logger.debug("No Open Jobs left") + break + + try: + + # If kwargs is empty dict + if not kwargs: + logger.debug( + f"Running experiment_sample " + f"{job_number}") + else: + logger.debug( + f"Running experiment_sample " + f"{job_number} with kwargs {kwargs}") + + _experiment_sample = self._run( + experiment_sample, **kwargs) # no *args! + data.store_experimentsample( + experiment_sample=_experiment_sample, + id=job_number) + except Exception as e: + error_msg = f"Error in experiment_sample \ + {job_number}: {e}" + error_traceback = traceback.format_exc() + logger.error(f"{error_msg}\n{error_traceback}") + data.mark(indices=job_number, status='error') + + return data + + def _evaluate_multiprocessing( + self, data: ExperimentData, + nodes: int = mp.cpu_count(), **kwargs) -> ExperimentData: + options = [] + while True: + job_number, experiment_sample = data.get_open_job() + if job_number is None: + break + options.append( + ({'experiment_sample': experiment_sample, + '_job_number': job_number, **kwargs},)) + + def f(options: Dict[str, Any]) -> Tuple[int, ExperimentSample, int]: + try: + + logger.debug( + f"Running experiment_sample " + f"{options['_job_number']}") + + # no *args! + return (options['_job_number'], self._run(**options), 0) + + except Exception as e: + error_msg = f"Error in experiment_sample \ + {options['_job_number']}: {e}" + error_traceback = traceback.format_exc() + logger.error(f"{error_msg}\n{error_traceback}") + return (options['_job_number'], + options['experiment_sample'], 1) + + with mp.Pool() as pool: + # maybe implement pool.starmap_async ? + _experiment_samples: List[ + Tuple[int, ExperimentSample, int]] = pool.starmap(f, options) + + for job_number, _experiment_sample, exit_code in _experiment_samples: + if exit_code == 0: + data.store_experimentsample( + experiment_sample=_experiment_sample, + id=job_number) + else: + data.mark(indices=job_number, status='error') + + return data + + def _evaluate_cluster( + self, data: ExperimentData, **kwargs) -> ExperimentData: + """Run the operation on the cluster + + Parameters + ---------- + operation : ExperimentSampleCallable + function execution for every entry in the ExperimentData object + kwargs : dict + Any keyword arguments that need to be supplied to the function + + Raises + ------ + NoOpenJobsError + Raised when there are no open jobs left + """ + # Retrieve the updated experimentdata object from disc + try: + data = type(data).from_file(data.project_dir) + except FileNotFoundError: # If not found, store current + data.store() + + get_open_job = data.access_file(type(data).get_open_job) + store_experiment_sample = data.access_file( + type(data).store_experimentsample) + mark = data.access_file(type(data).mark) + + while True: + + job_number, experiment_sample = get_open_job() + if job_number is None: + logger.debug("No Open jobs left!") + break + + try: + _experiment_sample = self._run( + experiment_sample, **kwargs) + store_experiment_sample(experiment_sample=_experiment_sample, + id=job_number) + except Exception: + # n = experiment_sample.job_number + error_msg = f"Error in experiment_sample {job_number}: " + error_traceback = traceback.format_exc() + logger.error(f"{error_msg}\n{error_traceback}") + mark(indices=job_number, status='error') + continue + + data = type(data).from_file(data.project_dir) + + # Remove the lockfile from disk + data.remove_lockfile() + return data + + # ========================================================================= + + @abstractmethod + def execute(self, **kwargs) -> None: + """Interface function that handles the execution of the data generator + + Raises + ------ + NotImplementedError + If the function is not implemented by the user + + Note + ---- + The experiment_sample is cached inside the data generator. This + allows the user to access the experiment_sample in + the execute and function as a class variable called + self.experiment_sample. + """ + ... + + def _run( + self, experiment_sample: ExperimentSample, + **kwargs) -> ExperimentSample: + """ + Run the data generator. + + The function also caches the experiment_sample in the data generator. + This allows the user to access the experiment_sample in the + execute function as a class variable + called self.experiment_sample. + + Parameters + ---------- + ExperimentSample : ExperimentSample + The design to run the data generator on + + kwargs : dict + The keyword arguments to pass to the pre_process, execute \ + and post_process + + Returns + ------- + ExperimentSample + Processed design with the response of the data generator \ + saved in the experiment_sample + """ + self.experiment_sample = experiment_sample + + self.experiment_sample.mark('in_progress') + + self.execute(**kwargs) + + self.experiment_sample.mark('finished') + + return self.experiment_sample diff --git a/src/f3dasm/_src/datageneration/__init__.py b/src/f3dasm/_src/datageneration/__init__.py index bcd5928a..47949683 100644 --- a/src/f3dasm/_src/datageneration/__init__.py +++ b/src/f3dasm/_src/datageneration/__init__.py @@ -8,12 +8,8 @@ from typing import List # Local -from .datagenerator import DataGenerator -from .functions import pybenchfunction -from .functions.adapters.augmentor import (FunctionAugmentor, Noise, Offset, - Scale) -from .functions.function import Function -from .functions.pybenchfunction import * # NOQA +from ..core import DataGenerator +from .datagenerator_factory import _datagenerator_factory # Authorship & Credits # ============================================================================= @@ -24,21 +20,7 @@ # # ============================================================================= -try: - import f3dasm_simulate # NOQA -except ImportError: - pass - -# List of available optimizers -DATAGENERATORS: List[DataGenerator] = [] - __all__ = [ 'DataGenerator', - 'Function', - 'FunctionAugmentor', - 'Noise', - 'Offset', - 'Scale', - 'DATAGENERATORS', - *pybenchfunction.__all__ + '_datagenerator_factory' ] diff --git a/src/f3dasm/_src/datageneration/datagenerator.py b/src/f3dasm/_src/datageneration/datagenerator_factory.py similarity index 58% rename from src/f3dasm/_src/datageneration/datagenerator.py rename to src/f3dasm/_src/datageneration/datagenerator_factory.py index 7705d3fc..53c0b162 100644 --- a/src/f3dasm/_src/datageneration/datagenerator.py +++ b/src/f3dasm/_src/datageneration/datagenerator_factory.py @@ -1,5 +1,6 @@ """ -Interface class for data generators +Factory method for creating DataGenerator objects from strings, functions, or +DataGenerator objects. """ # Modules @@ -10,83 +11,27 @@ # Standard import inspect -from abc import abstractmethod from typing import Any, Callable, Dict, List, Optional -# Third-party -import numpy as np - # Local -from ..design.domain import Domain -from ..experimentdata.experimentsample import (ExperimentSample, - _experimentsample_factory) +from ..core import DataGenerator +from .functions import _DATAGENERATORS # Authorship & Credits # ============================================================================= __author__ = "Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)" __credits__ = ["Martin van der Schelling"] -__status__ = "Alpha" +__status__ = "Stable" # ============================================================================= # # ============================================================================= -class DataGenerator: - """Base class for a data generator""" - @abstractmethod - def execute(self, **kwargs) -> None: - """Interface function that handles the execution of the data generator - - Raises - ------ - NotImplementedError - If the function is not implemented by the user - - Note - ---- - The experiment_sample is cached inside the data generator. This - allows the user to access the experiment_sample in - the execute and function as a class variable called - self.experiment_sample. - """ - ... - - def _run( - self, experiment_sample: ExperimentSample | np.ndarray, - domain: Optional[Domain] = None, - **kwargs) -> ExperimentSample: - """ - Run the data generator. - - The function also caches the experiment_sample in the data generator. - This allows the user to access the experiment_sample in the - execute function as a class variable - called self.experiment_sample. - - Parameters - ---------- - ExperimentSample : ExperimentSample - The design to run the data generator on - domain : Domain, optional - The domain of the data generator, by default None - - kwargs : dict - The keyword arguments to pass to the pre_process, execute \ - and post_process - - Returns - ------- - ExperimentSample - Processed design with the response of the data generator \ - saved in the experiment_sample - """ - # Cache the design - self.experiment_sample: ExperimentSample = _experimentsample_factory( - experiment_sample=experiment_sample, domain=domain) - - self.execute(**kwargs) - - return self.experiment_sample +DATAGENERATOR_MAPPING: Dict[str, DataGenerator] = { + f.name.lower().replace(' ', '').replace('-', '').replace( + '_', '').replace('.', ''): f for f in _DATAGENERATORS} + +# ============================================================================= def convert_function(f: Callable, @@ -129,7 +74,8 @@ def convert_function(f: Callable, class TempDataGenerator(DataGenerator): def execute(self, **_kwargs) -> None: - _input = {input_name: self.experiment_sample.get(input_name) + _input = {input_name: + self.experiment_sample.input_data.get(input_name) for input_name in input if input_name not in kwargs} _output = f(**_input, **kwargs) @@ -151,3 +97,40 @@ def execute(self, **_kwargs) -> None: to_disk=False) return TempDataGenerator() + + +def _datagenerator_factory( + data_generator: str | Callable | DataGenerator, + output_names: Optional[List[str]] = None, **kwargs) -> DataGenerator: + + # If the data generator is already a DataGenerator object, return it + if isinstance(data_generator, DataGenerator): + return data_generator + + # If the data generator is a function, convert it to a DataGenerator object + if inspect.isfunction(data_generator): + if output_names is None: + raise TypeError( + ("If you provide a function as data generator, you have to" + "provide the names of the return arguments with the" + "output_names attribute.")) + return convert_function( + f=data_generator, output=output_names, kwargs=kwargs) + + # If the data generator is a string, check if it is a known data generator + if isinstance(data_generator, str): + + filtered_name = data_generator.lower().replace( + ' ', '').replace('-', '').replace('_', '').replace('.', '') + + if filtered_name in DATAGENERATOR_MAPPING: + return DATAGENERATOR_MAPPING[filtered_name](**kwargs) + + else: + raise KeyError(f"Unknown data generator name: {data_generator}") + + # If the data generator is not a known type, raise an error + else: + raise TypeError(f"Unknown data generator type: {type(data_generator)}") + +# ============================================================================= diff --git a/src/f3dasm/_src/datageneration/functions/__init__.py b/src/f3dasm/_src/datageneration/functions/__init__.py index 011ea5fd..2751c3fb 100644 --- a/src/f3dasm/_src/datageneration/functions/__init__.py +++ b/src/f3dasm/_src/datageneration/functions/__init__.py @@ -115,7 +115,7 @@ def get_functions( return [function_class.name for function_class in function_classes] -_FUNCTIONS = get_function_classes() +_DATAGENERATORS = get_function_classes() _FUNCTIONS_2D = get_function_classes(d=2) _FUNCTIONS_7D = get_function_classes(d=7) @@ -136,6 +136,7 @@ class of the requested function """ try: return list( - filter(lambda function: function.__name__ == query, _FUNCTIONS))[0] + filter(lambda function: function.__name__ == query, + _DATAGENERATORS))[0] except IndexError: return ValueError(f'Function {query} not found!') diff --git a/src/f3dasm/_src/datageneration/functions/adapters/augmentor.py b/src/f3dasm/_src/datageneration/functions/adapters/augmentor.py index 5f827e56..2f0d2375 100644 --- a/src/f3dasm/_src/datageneration/functions/adapters/augmentor.py +++ b/src/f3dasm/_src/datageneration/functions/adapters/augmentor.py @@ -56,8 +56,16 @@ def reverse_augment(self, output: np.ndarray) -> np.ndarray: ... +class EmptyAugmentor(_Augmentor): + def augment(self, input: np.ndarray) -> np.ndarray: + return input + + def reverse_augment(self, output: np.ndarray) -> np.ndarray: + return output + + class Noise(_Augmentor): - def __init__(self, noise: float): + def __init__(self, noise: float, rng: np.random.Generator): """Augmentor class to add noise to a function output Parameters @@ -66,6 +74,7 @@ def __init__(self, noise: float): standard deviation of Gaussian noise (mean is zero) """ self.noise = noise + self.rng = rng def augment(self, input: np.ndarray) -> np.ndarray: if hasattr(input, "_value"): @@ -81,7 +90,7 @@ def augment(self, input: np.ndarray) -> np.ndarray: # convert to numpy float input = np.float64(input) - noise: np.ndarray = np.random.normal( + noise: np.ndarray = self.rng.normal( loc=0.0, scale=scale, size=input.shape) y_noise = input + float(noise) return y_noise diff --git a/src/f3dasm/_src/datageneration/functions/adapters/pybenchfunction.py b/src/f3dasm/_src/datageneration/functions/adapters/pybenchfunction.py index fde70a5f..965a067d 100644 --- a/src/f3dasm/_src/datageneration/functions/adapters/pybenchfunction.py +++ b/src/f3dasm/_src/datageneration/functions/adapters/pybenchfunction.py @@ -2,14 +2,16 @@ # ============================================================================= # Standard -from typing import Optional +from typing import Optional, Protocol # Third-party import autograd.numpy as np # Locals +from ....design.domain import Domain from ..function import Function -from .augmentor import Noise, Offset, Scale +from .augmentor import (EmptyAugmentor, FunctionAugmentor, Noise, Offset, + Scale, _Augmentor) # Authorship & Credits # ============================================================================= @@ -21,10 +23,15 @@ # ============================================================================= +class ExperimentData(Protocol): + @property + def domain(self) -> Domain: + ... + + class PyBenchFunction(Function): def __init__( self, - dimensionality: int, scale_bounds: Optional[np.ndarray] = None, noise: Optional[float] = None, offset: bool = True, @@ -36,8 +43,6 @@ def __init__( Parameters ---------- - dimensionality - number of dimensions scale_bounds, optional array containing the lower and upper bound of the scaling factor of the input data, by default None @@ -50,60 +55,60 @@ def __init__( seed for the random number generator, by default None """ super().__init__(seed=seed) - self.dimensionality = dimensionality self.scale_bounds = scale_bounds self.noise = noise self.offset = offset - self.__post_init__() - - def __post_init__(self): + def arm(self, data: ExperimentData): + self.set_seed(self.seed) + self.augmentor = FunctionAugmentor() + self.dimensionality = len(data.domain) self._set_parameters() + s = self._configure_scale_bounds() + n = self._configure_noise() + o = self._configure_offset() - self._configure_scale_bounds() - self._configure_noise() - self._configure_offset() + self.augmentor = FunctionAugmentor(input_augmentors=[o, s], + output_augmentors=[n]) - def _configure_scale_bounds(self): + def _configure_scale_bounds(self) -> _Augmentor: """Create a Scale augmentor""" if self.scale_bounds is None: - return + return EmptyAugmentor() + s = Scale(scale_bounds=self.scale_bounds, input_domain=self.input_domain) - self.augmentor.add_input_augmentor(s) + self.augmentor = FunctionAugmentor(input_augmentors=[s]) + return s + + # self.augmentor.add_input_augmentor(s) def _configure_noise(self): """Create a Noise augmentor""" if self.noise is None: - return + return EmptyAugmentor() - n = Noise(noise=self.noise) - self.augmentor.add_output_augmentor(n) + n = Noise(noise=self.noise, rng=self.rng) + return n def _configure_offset(self): """Create an Offset augmentor""" if not self.offset or self.scale_bounds is None: - return + return EmptyAugmentor() g = self._get_global_minimum_for_offset_calculation() - unscaled_offset = np.atleast_1d( [ - # np.random.uniform( - # low=-abs(g[d] - self.scale_bounds[d, 0]), - # high=abs(g[d] - self.scale_bounds[d, 1])) - # This is added so we only create offsets in one quadrant - - np.random.uniform( + self.rng.uniform( low=-abs(g[d] - self.scale_bounds[d, 0]), high=0.0) for d in range(self.dimensionality) ] ) - self.o = Offset(offset=unscaled_offset) - self.augmentor.insert_input_augmentor(position=0, augmentor=self.o) + return Offset(offset=unscaled_offset) + # self.augmentor.insert_input_augmentor(position=0, augmentor=self.o) def _get_global_minimum_for_offset_calculation(self): """Get the global minimum used for offset calculations diff --git a/src/f3dasm/_src/datageneration/functions/alias.py b/src/f3dasm/_src/datageneration/functions/alias.py new file mode 100644 index 00000000..7e2fd0e2 --- /dev/null +++ b/src/f3dasm/_src/datageneration/functions/alias.py @@ -0,0 +1,290 @@ +""" +Functional aliasses for the builtin functions. +""" +# Modules +# ============================================================================= + +# Standard +from functools import partial +from typing import Optional + +# Third-party +import numpy as np + +# Local +from ...core import DataGenerator +from .pybenchfunction import (Ackley, AckleyN2, AckleyN3, AckleyN4, Adjiman, + Bartels, Beale, Bird, BohachevskyN1, + BohachevskyN2, BohachevskyN3, Booth, Branin, + Brent, Brown, BukinN6, Colville, CrossInTray, + DeckkersAarts, DeJongN5, DixonPrice, DropWave, + Easom, EggCrate, EggHolder, Exponential, + GoldsteinPrice, Griewank, HappyCat, Himmelblau, + HolderTable, Keane, Langermann, Leon, Levy, + LevyN13, Matyas, McCormick, Michalewicz, + Periodic, Powell, Qing, Quartic, Rastrigin, + Ridge, Rosenbrock, RotatedHyperEllipsoid, + Salomon, SchaffelN1, SchaffelN2, SchaffelN3, + SchaffelN4, Schwefel, Schwefel2_20, Schwefel2_21, + Schwefel2_22, Schwefel2_23, Shekel, Shubert, + ShubertN3, ShubertN4, Sphere, StyblinskiTang, + SumSquares, Thevenot, ThreeHump, Trid, Wolfe, + XinSheYang, XinSheYangN2, XinSheYangN3, + XinSheYangN4, Zakharov) + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= +# +# ============================================================================= + + +def fn(base_class, scale_bounds: Optional[np.ndarray] = None, + noise: Optional[float] = None, offset: bool = True, + seed: Optional[int] = None) -> DataGenerator: + """ + Parameters + ---------- + scale_bounds : Optional[np.ndarray], optional + Bounds for scaling the input data, by default None. + noise : Optional[float], optional + Amount of noise to add to the generated data, by default None. + offset : bool, optional + Whether to apply an offset to the input data, by default True. + seed : Optional[int], optional + Random seed for reproducibility, by default None. + + Returns + ------- + DataGenerator + A DataGenerator configured with the provided + options. + """ + return base_class(scale_bounds=scale_bounds, noise=noise, offset=offset, + seed=seed) +# ============================================================================= + + +ackley = partial(fn, base_class=Ackley) +ackley.__doc__ = f"{Ackley.__doc__}\n\n{fn.__doc__}" + +ackleyn2 = partial(fn, base_class=AckleyN2) +ackleyn2.__doc__ = f"{AckleyN2.__doc__}\n\n{fn.__doc__}" + +ackleyn3 = partial(fn, base_class=AckleyN3) +ackleyn3.__doc__ = f"{AckleyN3.__doc__}\n\n{fn.__doc__}" + +ackleyn4 = partial(fn, base_class=AckleyN4) +ackleyn4.__doc__ = f"{AckleyN4.__doc__}\n\n{fn.__doc__}" + +adjiman = partial(fn, base_class=Adjiman) +adjiman.__doc__ = f"{Adjiman.__doc__}\n\n{fn.__doc__}" + +bartels = partial(fn, base_class=Bartels) +bartels.__doc__ = f"{Bartels.__doc__}\n\n{fn.__doc__}" + +beale = partial(fn, base_class=Beale) +beale.__doc__ = f"{Beale.__doc__}\n\n{fn.__doc__}" + +bird = partial(fn, base_class=Bird) +bird.__doc__ = f"{Bird.__doc__}\n\n{fn.__doc__}" + +# Manually creating the rest of the partial functions +bohachevskyn1 = partial(fn, base_class=BohachevskyN1) +bohachevskyn1.__doc__ = f"{BohachevskyN1.__doc__}\n\n{fn.__doc__}" + +bohachevskyn2 = partial(fn, base_class=BohachevskyN2) +bohachevskyn2.__doc__ = f"{BohachevskyN2.__doc__}\n\n{fn.__doc__}" + +bohachevskyn3 = partial(fn, base_class=BohachevskyN3) +bohachevskyn3.__doc__ = f"{BohachevskyN3.__doc__}\n\n{fn.__doc__}" + +booth = partial(fn, base_class=Booth) +booth.__doc__ = f"{Booth.__doc__}\n\n{fn.__doc__}" + +branin = partial(fn, base_class=Branin) +branin.__doc__ = f"{Branin.__doc__}\n\n{fn.__doc__}" + +brent = partial(fn, base_class=Brent) +brent.__doc__ = f"{Brent.__doc__}\n\n{fn.__doc__}" + +brown = partial(fn, base_class=Brown) +brown.__doc__ = f"{Brown.__doc__}\n\n{fn.__doc__}" + +bukinn6 = partial(fn, base_class=BukinN6) +bukinn6.__doc__ = f"{BukinN6.__doc__}\n\n{fn.__doc__}" + +colville = partial(fn, base_class=Colville) +colville.__doc__ = f"{Colville.__doc__}\n\n{fn.__doc__}" + +crossintray = partial(fn, base_class=CrossInTray) +crossintray.__doc__ = f"{CrossInTray.__doc__}\n\n{fn.__doc__}" + +deckkersaarts = partial(fn, base_class=DeckkersAarts) +deckkersaarts.__doc__ = f"{DeckkersAarts.__doc__}\n\n{fn.__doc__}" + +dejongn5 = partial(fn, base_class=DeJongN5) +dejongn5.__doc__ = f"{DeJongN5.__doc__}\n\n{fn.__doc__}" + +dixonprice = partial(fn, base_class=DixonPrice) +dixonprice.__doc__ = f"{DixonPrice.__doc__}\n\n{fn.__doc__}" + +dropwave = partial(fn, base_class=DropWave) +dropwave.__doc__ = f"{DropWave.__doc__}\n\n{fn.__doc__}" + +easom = partial(fn, base_class=Easom) +easom.__doc__ = f"{Easom.__doc__}\n\n{fn.__doc__}" + +eggcrate = partial(fn, base_class=EggCrate) +eggcrate.__doc__ = f"{EggCrate.__doc__}\n\n{fn.__doc__}" + +eggholder = partial(fn, base_class=EggHolder) +eggholder.__doc__ = f"{EggHolder.__doc__}\n\n{fn.__doc__}" + +exponential = partial(fn, base_class=Exponential) +exponential.__doc__ = f"{Exponential.__doc__}\n\n{fn.__doc__}" + +goldsteinprice = partial(fn, base_class=GoldsteinPrice) +goldsteinprice.__doc__ = f"{GoldsteinPrice.__doc__}\n\n{fn.__doc__}" + +griewank = partial(fn, base_class=Griewank) +griewank.__doc__ = f"{Griewank.__doc__}\n\n{fn.__doc__}" + +happycat = partial(fn, base_class=HappyCat) +happycat.__doc__ = f"{HappyCat.__doc__}\n\n{fn.__doc__}" + +himmelblau = partial(fn, base_class=Himmelblau) +himmelblau.__doc__ = f"{Himmelblau.__doc__}\n\n{fn.__doc__}" + +holdertable = partial(fn, base_class=HolderTable) +holdertable.__doc__ = f"{HolderTable.__doc__}\n\n{fn.__doc__}" + +keane = partial(fn, base_class=Keane) +keane.__doc__ = f"{Keane.__doc__}\n\n{fn.__doc__}" + +langermann = partial(fn, base_class=Langermann) +langermann.__doc__ = f"{Langermann.__doc__}\n\n{fn.__doc__}" + +leon = partial(fn, base_class=Leon) +leon.__doc__ = f"{Leon.__doc__}\n\n{fn.__doc__}" + +levy = partial(fn, base_class=Levy) +levy.__doc__ = f"{Levy.__doc__}\n\n{fn.__doc__}" + +levyn13 = partial(fn, base_class=LevyN13) +levyn13.__doc__ = f"{LevyN13.__doc__}\n\n{fn.__doc__}" + +matyas = partial(fn, base_class=Matyas) +matyas.__doc__ = f"{Matyas.__doc__}\n\n{fn.__doc__}" + +mccormick = partial(fn, base_class=McCormick) +mccormick.__doc__ = f"{McCormick.__doc__}\n\n{fn.__doc__}" + +michalewicz = partial(fn, base_class=Michalewicz) +michalewicz.__doc__ = f"{Michalewicz.__doc__}\n\n{fn.__doc__}" + +periodic = partial(fn, base_class=Periodic) +periodic.__doc__ = f"{Periodic.__doc__}\n\n{fn.__doc__}" + +powell = partial(fn, base_class=Powell) +powell.__doc__ = f"{Powell.__doc__}\n\n{fn.__doc__}" + +qing = partial(fn, base_class=Qing) +qing.__doc__ = f"{Qing.__doc__}\n\n{fn.__doc__}" + +quartic = partial(fn, base_class=Quartic) +quartic.__doc__ = f"{Quartic.__doc__}\n\n{fn.__doc__}" + +rastrigin = partial(fn, base_class=Rastrigin) +rastrigin.__doc__ = f"{Rastrigin.__doc__}\n\n{fn.__doc__}" + +ridge = partial(fn, base_class=Ridge) +ridge.__doc__ = f"{Ridge.__doc__}\n\n{fn.__doc__}" + +rosenbrock = partial(fn, base_class=Rosenbrock) +rosenbrock.__doc__ = f"{Rosenbrock.__doc__}\n\n{fn.__doc__}" + +rotatedhyperellipsoid = partial(fn, base_class=RotatedHyperEllipsoid) +rotatedhyperellipsoid.__doc__ = ( + f"{RotatedHyperEllipsoid.__doc__}\n\n{fn.__doc__}") + +salomon = partial(fn, base_class=Salomon) +salomon.__doc__ = f"{Salomon.__doc__}\n\n{fn.__doc__}" + +schaffeln1 = partial(fn, base_class=SchaffelN1) +schaffeln1.__doc__ = f"{SchaffelN1.__doc__}\n\n{fn.__doc__}" + +schaffeln2 = partial(fn, base_class=SchaffelN2) +schaffeln2.__doc__ = f"{SchaffelN2.__doc__}\n\n{fn.__doc__}" + +schaffeln3 = partial(fn, base_class=SchaffelN3) +schaffeln3.__doc__ = f"{SchaffelN3.__doc__}\n\n{fn.__doc__}" + +schaffeln4 = partial(fn, base_class=SchaffelN4) +schaffeln4.__doc__ = f"{SchaffelN4.__doc__}\n\n{fn.__doc__}" + +schwefel = partial(fn, base_class=Schwefel) +schwefel.__doc__ = f"{Schwefel.__doc__}\n\n{fn.__doc__}" + +schwefel2_20 = partial(fn, base_class=Schwefel2_20) +schwefel2_20.__doc__ = f"{Schwefel2_20.__doc__}\n\n{fn.__doc__}" + +schwefel2_21 = partial(fn, base_class=Schwefel2_21) +schwefel2_21.__doc__ = f"{Schwefel2_21.__doc__}\n\n{fn.__doc__}" + +schwefel2_22 = partial(fn, base_class=Schwefel2_22) +schwefel2_22.__doc__ = f"{Schwefel2_22.__doc__}\n\n{fn.__doc__}" + +schwefel2_23 = partial(fn, base_class=Schwefel2_23) +schwefel2_23.__doc__ = f"{Schwefel2_23.__doc__}\n\n{fn.__doc__}" + +shekel = partial(fn, base_class=Shekel) +shekel.__doc__ = f"{Shekel.__doc__}\n\n{fn.__doc__}" + +shubert = partial(fn, base_class=Shubert) +shubert.__doc__ = f"{Shubert.__doc__}\n\n{fn.__doc__}" + +shubertn3 = partial(fn, base_class=ShubertN3) +shubertn3.__doc__ = f"{ShubertN3.__doc__}\n\n{fn.__doc__}" + +shubertn4 = partial(fn, base_class=ShubertN4) +shubertn4.__doc__ = f"{ShubertN4.__doc__}\n\n{fn.__doc__}" + +sphere = partial(fn, base_class=Sphere) +sphere.__doc__ = f"{Sphere.__doc__}\n\n{fn.__doc__}" + +styblinskitang = partial(fn, base_class=StyblinskiTang) +styblinskitang.__doc__ = f"{StyblinskiTang.__doc__}\n\n{fn.__doc__}" + +sumsquares = partial(fn, base_class=SumSquares) +sumsquares.__doc__ = f"{SumSquares.__doc__}\n\n{fn.__doc__}" + +thevenot = partial(fn, base_class=Thevenot) +thevenot.__doc__ = f"{Thevenot.__doc__}\n\n{fn.__doc__}" + +threehump = partial(fn, base_class=ThreeHump) +threehump.__doc__ = f"{ThreeHump.__doc__}\n\n{fn.__doc__}" + +trid = partial(fn, base_class=Trid) +trid.__doc__ = f"{Trid.__doc__}\n\n{fn.__doc__}" + +wolfe = partial(fn, base_class=Wolfe) +wolfe.__doc__ = f"{Wolfe.__doc__}\n\n{fn.__doc__}" + +xin_she_yang = partial(fn, base_class=XinSheYang) +xin_she_yang.__doc__ = f"{XinSheYang.__doc__}\n\n{fn.__doc__}" + +xin_she_yang2 = partial(fn, base_class=XinSheYangN2) +xin_she_yang2.__doc__ = f"{XinSheYangN2.__doc__}\n\n{fn.__doc__}" + +xin_she_yang3 = partial(fn, base_class=XinSheYangN3) +xin_she_yang3.__doc__ = f"{XinSheYangN3.__doc__}\n\n{fn.__doc__}" + +xin_she_yang4 = partial(fn, base_class=XinSheYangN4) +xin_she_yang4.__doc__ = f"{XinSheYangN4.__doc__}\n\n{fn.__doc__}" + +zakharov = partial(fn, base_class=Zakharov) +zakharov.__doc__ = f"{Zakharov.__doc__}\n\n{fn.__doc__}" diff --git a/src/f3dasm/_src/datageneration/functions/function.py b/src/f3dasm/_src/datageneration/functions/function.py index 1b8bb71c..ba055ccd 100644 --- a/src/f3dasm/_src/datageneration/functions/function.py +++ b/src/f3dasm/_src/datageneration/functions/function.py @@ -1,11 +1,8 @@ """ -This module contains a base class for an analytical function that - can be inherited -to create specific analytical functions. -The Function class is the base class that defines the interface for - all analytical -functions. It can be called with an input vector to evaluate the - function at that point. +This module contains a base class for an analytical function that can be +inherited to create specific analytical functions. The Function class is the +base class that defines the interface for all analytical functions. It can be +called with an input vector to evaluate the function at that point. """ # Modules # ============================================================================= @@ -22,10 +19,8 @@ from autograd import grad from autograd.numpy.numpy_boxes import ArrayBox -from ...design.domain import Domain # Locals -from ...experimentdata.experimentsample import _experimentsample_factory -from ..datagenerator import DataGenerator +from ...core import DataGenerator from ..functions.adapters.augmentor import FunctionAugmentor # Authorship & Credits @@ -48,33 +43,66 @@ def to_numpy(self) -> Tuple[np.ndarray, np.ndarray]: class Function(DataGenerator): def __init__(self, seed: Optional[int] = None): - """Class for an analytical function + """ + Class for an analytical function. Parameters ---------- - seed, optional - seed for the random number generator, by default None + seed : Optional[int], optional + Seed for the random number generator, by default None. + + Examples + -------- + >>> func = Function(seed=42) + >>> print(func) + Function(seed=42) """ self.augmentor = FunctionAugmentor() + if seed is None: + seed = np.random.randint(2**31) + self.seed = seed self.grad = grad(self.__call__) self.set_seed(seed) - def set_seed(self, seed): - """Set the seed of the random number generator. - By default the numpy generator + def set_seed(self, seed: int): + """ + Set the seed of the random number generator. By default, the numpy + generator is used. Parameters ---------- - seed - seed for the random number generator + seed : int + Seed for the random number generator. + + Examples + -------- + >>> func = Function() + >>> func.set_seed(42) """ - if seed is None: - return - np.random.seed(seed) + self.rng = np.random.default_rng(seed) def __call__(self, input_x: np.ndarray) -> np.ndarray: + """ + Evaluate the function at the given input. + + Parameters + ---------- + input_x : np.ndarray + Input vector. + + Returns + ------- + np.ndarray + Output of the function. + + Examples + -------- + >>> func = Function() + >>> func(np.array([1.0, 2.0])) + array([[5.0]]) + """ x = input_x x = np.atleast_2d(x) @@ -88,68 +116,102 @@ def __call__(self, input_x: np.ndarray) -> np.ndarray: return np.array(y).reshape(-1, 1) - def execute(self, experiment_sample: ExperimentSample) -> ExperimentSample: - x, _ = experiment_sample.to_numpy() + def execute(self, **kwargs) -> None: + """ + Execute the function and store the result in the experiment sample. + + Parameters + ---------- + **kwargs : dict + Additional parameters for execution. + + Examples + -------- + >>> func = Function() + >>> func.execute() + """ + x, _ = self.experiment_sample.to_numpy() if isinstance(x, ArrayBox): x = x._value if isinstance(x, ArrayBox): x = x._value y = np.nan_to_num(self(x), nan=np.nan) - experiment_sample["y"] = float(y.ravel().astype(np.float64)) - return experiment_sample - - def _run( - self, experiment_sample: ExperimentSample | np.ndarray, - domain: Optional[Domain] = None, **kwargs) -> ExperimentSample: - _experiment_sample = _experimentsample_factory( - experiment_sample=experiment_sample, domain=domain) - return self.execute(_experiment_sample) + self.experiment_sample.store( + name="y", object=float(y.ravel().astype(np.float64))) def _retrieve_original_input(self, x: np.ndarray): - """Retrieve the original input vector if the input is augmented + """ + Retrieve the original input vector if the input is augmented. Parameters ---------- - x - augmented input vector + x : np.ndarray + Augmented input vector. Returns ------- - original input vector + np.ndarray + Original input vector. + + Examples + -------- + >>> func = Function() + >>> func._retrieve_original_input(np.array([1.0, 2.0])) + array([[1.0, 2.0]]) """ x = np.atleast_2d(x) xxi = self.augmentor.augment_reverse_input(x) return xxi - def check_if_within_bounds(self, x: np.ndarray, bounds=np.ndarray) -> bool: - """Check if the input vector is between the given scaling bounds + def check_if_within_bounds(self, x: np.ndarray, + bounds: np.ndarray) -> bool: + """ + Check if the input vector is between the given scaling bounds. Parameters ---------- - x - input vector - bounds - boundaries for each dimension + x : np.ndarray + Input vector. + bounds : np.ndarray + Boundaries for each dimension. Returns ------- - boolean value whether the vector is within the boundaries + bool + True if the vector is within the boundaries, False otherwise. + + Examples + -------- + >>> func = Function() + >>> func.check_if_within_bounds(np.array([0.5, 0.5]), + ... np.array([[0.0, 1.0], [0.0, 1.0]])) + True """ return ((bounds[:, 0] <= x) & (x <= bounds[:, 1])).all() - def dfdx_legacy(self, x: np.ndarray, dx=1e-8) -> np.ndarray: - """Compute the gradient at a particular point in space. - Gradient is computed by central differences + def dfdx_legacy(self, x: np.ndarray, dx: float = 1e-8) -> np.ndarray: + """ + Compute the gradient at a particular point in space using central + differences. Parameters ---------- - x - input vector + x : np.ndarray + Input vector. + dx : float, optional + Step size for central differences, by default 1e-8. Returns ------- - gradient + np.ndarray + Gradient. + + Examples + -------- + >>> func = Function() + >>> func.dfdx_legacy(np.array([1.0, 2.0])) + array([1.0, 1.0]) """ def central_differences(x: float, h: float): @@ -168,6 +230,25 @@ def central_differences(x: float, h: float): return grad.ravel() def dfdx(self, x: np.ndarray) -> np.ndarray: + """ + Compute the gradient at a particular point in space. + + Parameters + ---------- + x : np.ndarray + Input vector. + + Returns + ------- + np.ndarray + Gradient. + + Examples + -------- + >>> func = Function() + >>> func.dfdx(np.array([1.0, 2.0])) + array([1.0, 1.0]) + """ # check if the object has a 'custom_grad' method if hasattr(self, 'error_autograd'): if self.error_autograd: @@ -176,42 +257,62 @@ def dfdx(self, x: np.ndarray) -> np.ndarray: return self.grad(x) def evaluate(self, x: np.ndarray) -> np.ndarray: - """Analytical expression to calculate the objective value - To be inherited by subclasses + """ + Analytical expression to calculate the objective value. To be + inherited by subclasses. Parameters ---------- - x - input_vector + x : np.ndarray + Input vector. Returns ------- - objective value(s) + np.ndarray + Objective value(s). """ ... def get_name(self) -> str: - """Get the name of the function + """ + Get the name of the function. Returns ------- - name of the function + str + Name of the function. + + Examples + -------- + >>> func = Function() + >>> func.get_name() + 'Function' """ return self.__class__.__name__ def _create_mesh(self, px: int, domain: np.ndarray): - """Create mesh to use for plotting + """ + Create mesh to use for plotting. Parameters ---------- - px - Number of points per dimension - domain - Domain that needes to be plotted + px : int + Number of points per dimension. + domain : np.ndarray + Domain that needs to be plotted. Returns ------- - 2D mesh used for plotting and another mesh + Tuple[np.ndarray, np.ndarray, np.ndarray] + 2D mesh used for plotting and another mesh. + + Examples + -------- + >>> func = Function() + >>> func._create_mesh(100, np.array([[0.0, 1.0], [0.0, 1.0]])) + (array([[0.0, 0.01, ..., 0.99, 1.0], [0.0, 0.01, ..., 0.99, 1.0]]), + array([[0.0, 0.0, ..., 0.0, 0.0], [0.01, 0.01, ..., 0.01, 0.01]]), + array([[0.0, 0.01, ..., 0.99, 1.0], [0.0, 0.01, ..., 0.99, 1.0]])) """ x1 = np.linspace(domain[0, 0], domain[0, 1], num=px) x2 = np.linspace(domain[1, 0], domain[1, 1], num=px) @@ -240,23 +341,33 @@ def plot( show: bool = True, ax: plt.Axes = None, ) -> Tuple[plt.Figure, plt.Axes]: - # TODO: orientation string is case sensitive! - """Generate a surface plot, either 2D or 3D, of the function + """ + Generate a surface plot, either 2D or 3D, of the function. Parameters ---------- - orientation, optional - Either 2D or 3D orientation - px, optional - Number of points per dimension - domain, optional - Domain that needs to be plotted - show, optional - Show the figure in interactive mode + orientation : str, optional + Either 2D or 3D orientation, by default "3D". + px : int, optional + Number of points per dimension, by default 300. + domain : np.ndarray, optional + Domain that needs to be plotted, by default + np.array([[0.0, 1.0], [0.0, 1.0]]). + show : bool, optional + Show the figure in interactive mode, by default True. + ax : plt.Axes, optional + Axes object to plot on, by default None. Returns ------- - matplotlib figure object and axes of the figure + Tuple[plt.Figure, plt.Axes] + Matplotlib figure object and axes of the figure. + + Examples + -------- + >>> func = Function() + >>> fig, ax = func.plot() + >>> plt.show() """ if not show: plt.ioff() @@ -270,13 +381,13 @@ def plot( Y_shifted = Y - np.min(Y) + 1e-6 fig = plt.figure(figsize=(7, 7), constrained_layout=True) - if orientation == "2D": + if orientation.upper() == "2D": if ax is None: ax = plt.axes() ax.pcolormesh(xv, yv, Y_shifted, cmap="viridis", norm=mcol.LogNorm()) - if orientation == "3D": + if orientation.upper() == "3D": if ax is None: ax = plt.axes(projection="3d", elev=50, azim=-50) diff --git a/src/f3dasm/_src/datageneration/functions/function_factory.py b/src/f3dasm/_src/datageneration/functions/function_factory.py deleted file mode 100644 index 9d5931ff..00000000 --- a/src/f3dasm/_src/datageneration/functions/function_factory.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Module for the data generator factory. -""" -# Modules -# ============================================================================= - -from __future__ import annotations - -from typing import Any, Dict, Optional - -from ...design.domain import Domain -from ..datagenerator import DataGenerator -from . import _FUNCTIONS - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - -FUNCTION_MAPPING: Dict[str, DataGenerator] = { - f.name.lower().replace(' ', '').replace('-', '').replace( - '_', '').replace('.', ''): f for f in _FUNCTIONS} - - -def _datagenerator_factory( - data_generator: str, domain: Domain | int, - kwargs: Optional[Dict[str, Any]] = None) -> DataGenerator: - - if isinstance(domain, int): - dim = domain - - else: - dim = len(domain) - - if kwargs is None: - kwargs = {} - - filtered_name = data_generator.lower().replace( - ' ', '').replace('-', '').replace('_', '').replace('.', '') - - if filtered_name in FUNCTION_MAPPING: - return FUNCTION_MAPPING[filtered_name](dimensionality=dim, **kwargs) - - else: - raise KeyError(f"Unknown data generator: {data_generator}") - - -def is_dim_compatible(data_generator: str, domain: Domain) -> bool: - func = _datagenerator_factory(data_generator, domain) - return func.is_dim_compatible(len(domain)) diff --git a/src/f3dasm/_src/datageneration/functions/pybenchfunction.py b/src/f3dasm/_src/datageneration/functions/pybenchfunction.py index c3831565..e1a06a96 100644 --- a/src/f3dasm/_src/datageneration/functions/pybenchfunction.py +++ b/src/f3dasm/_src/datageneration/functions/pybenchfunction.py @@ -49,9 +49,6 @@ def _set_parameters(self, m=5, beta=15): self.m = m self.beta = beta - def get_param(self): - return {"m": self.m, "beta": self.beta} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), self( @@ -89,9 +86,6 @@ def _set_parameters(self, a=20, b=0.2, c=2 * np.pi): self.b = b self.c = c - def get_param(self): - return {"a": self.a, "b": self.b, "c": self.c} - def get_global_minimum(self, d): X = np.array([1 / (i + 1) for i in range(d)]) X = np.array([0 for _ in range(d)]) @@ -128,9 +122,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self=None): self.input_domain = np.array([[-32, 32], [-32, 32]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -141,9 +132,6 @@ def evaluate(self, X): res = -200 * np.exp(-0.2 * np.sqrt(x**2 + y**2)) return res - def custom_grad(self, X): - x, y = X - class AckleyN3(PyBenchFunction): """.. image:: ../img/functions/AckleyN3.png""" @@ -167,9 +155,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self=None): self.input_domain = np.array([[-32, 32], [-32, 32]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0.682584587365898, -0.36075325513719]) Y = np.array([[-195.629028238419]]) @@ -205,9 +190,6 @@ def _set_parameters(self=None): d = self.dimensionality self.input_domain = np.array([[-35, 35] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): if d != 2: # WARNING ! Is only is available for d=2 @@ -247,9 +229,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-1, 2], [-1, 1]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1 / (i + 1) for i in range(d)]) X = np.array([0, 0]) @@ -284,9 +263,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-500, 500], [-500, 500]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -321,9 +297,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-4.5, 4.5], [-4.5, 4.5]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([3, 0.5]) return (self._retrieve_original_input(X), self( @@ -360,9 +333,6 @@ def _set_parameters(self): self.input_domain = np.array( [[-2 * np.pi, 2 * np.pi], [-2 * np.pi, 2 * np.pi]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([[4.70104, 3.15294], [-1.58214, -3.13024]]) return (self._retrieve_original_input(X), @@ -397,9 +367,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-100, 100], [-100, 100]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -434,9 +401,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-100, 100], [-100, 100]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -471,9 +435,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-50, 50], [-50, 50]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -508,9 +469,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1, 3]) return (self._retrieve_original_input(X), self( @@ -558,16 +516,6 @@ def _set_parameters( self.s = s self.t = t - def get_param(self): - return { - "a": self.a, - "b": self.b, - "c": self.c, - "r": self.r, - "s": self.s, - "t": self.t, - } - def get_global_minimum(self, d): X = np.array([[-np.pi, 12.275], [np.pi, 2.275], [9.42478, 2.475]]) return (self._retrieve_original_input(X), @@ -602,9 +550,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-20, 0], [-20, 0]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([-10, -10]) return (self._retrieve_original_input(X), self( @@ -639,9 +584,6 @@ def _set_parameters(self=None): d = self.dimensionality self.input_domain = np.array([[-1, 4] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), self( @@ -676,9 +618,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-15, -5], [-3, 3]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([-10, 1]) return (self._retrieve_original_input(X), self( @@ -713,9 +652,6 @@ def _set_parameters(self): self.input_domain = np.array( [[-10, 10], [-10, 10], [-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1, 1, 1, 1]) return (self._retrieve_original_input(X), self( @@ -752,9 +688,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array( [ @@ -804,9 +737,6 @@ def _set_parameters(self, a=None): else: self.a = a - def get_param(self): - return {"a": self.a} - def get_global_minimum(self, d): X = self.a[0] return (self._retrieve_original_input(X), self( @@ -843,9 +773,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-20, 20], [-20, 20]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([[0, -15], [0, 15]]) return (self._retrieve_original_input(X), @@ -881,9 +808,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([2 ** -(((2 ** (i)) - 2) / 2**i) for i in range(1, d + 1)]) @@ -919,9 +843,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-5.2, 5.2], [-5.2, 5.2]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -956,9 +877,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-100, 100], [-100, 100]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([np.pi, np.pi]) return (self._retrieve_original_input(X), self( @@ -993,9 +911,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-5, 5], [-5, 5]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), self( @@ -1029,9 +944,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-512, 512], [-512, 512]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([512, 404.2319]) return (self._retrieve_original_input(X), self( @@ -1067,9 +979,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-1, 1] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), self( @@ -1102,9 +1011,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-2, 2], [-2, 2]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, -1]) return (self._retrieve_original_input(X), self( @@ -1143,9 +1049,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-600, 600] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), self( @@ -1183,9 +1086,6 @@ def _set_parameters(self, alpha=0.5): self.input_domain = np.array([[-2, 2] for _ in range(d)]) self.alpha = alpha - def get_param(self): - return {"alpha": self.alpha} - def get_global_minimum(self, d): X = np.array([-1 for _ in range(d)]) return (self._retrieve_original_input(X), self( @@ -1221,9 +1121,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-6, 6], [-6, 6]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array( [ @@ -1264,9 +1161,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array( [ @@ -1309,9 +1203,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([[1.393249070031784, 0], [0, 1.393249070031784]]) return (self._retrieve_original_input(X), @@ -1350,9 +1241,6 @@ def _set_parameters(self, m=None, c=None, A=None): self.A = A if A is not None else np.array( [[3, 5], [5, 2], [2, 1], [1, 4], [7, 9]]) - def get_param(self): - return {"m": self.m, "c": self.c, "A": self.A} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) # Global minimum is not known but the following @@ -1394,9 +1282,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[0, 10], [0, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1, 1]) return (self._retrieve_original_input(X), @@ -1431,9 +1316,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1472,9 +1354,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([1, 1]) return (self._retrieve_original_input(X), @@ -1512,9 +1391,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-10, 10], [-10, 10]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), @@ -1548,9 +1424,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-1.5, 4], [-3, 3]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([-0.547, -1.547]) return (self._retrieve_original_input(X), @@ -1586,9 +1459,6 @@ def _set_parameters(self, m=10): self.input_domain = np.array([[0, np.pi] for _ in range(d)]) self.m = m - def get_param(self): - return {"m": self.m} - def get_global_minimum(self, d): if d != 2: # Michalewicz minimum is only given for d=2 # Calculated with polyfit @@ -1630,9 +1500,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1666,9 +1533,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-1, 1] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1703,9 +1567,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-500, 500] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) X = np.array([range(d)]) + 1 @@ -1749,9 +1610,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-1.28, 1.28] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) # Global minimum value without the randomized term @@ -1760,7 +1618,7 @@ def get_global_minimum(self, d): def evaluate(self, X): d = X.shape[0] - res = np.sum(np.arange(1, d + 1) * X**4) # + np.random.random() + res = np.sum(np.arange(1, d + 1) * X**4) return res @@ -1787,9 +1645,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-5.12, 5.12] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1827,9 +1682,6 @@ def _set_parameters(self, beta=2, alpha=0.1): self.beta = beta self.alpha = alpha - def get_param(self): - return {"beta": self.beta, "alpha": self.alpha} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) X[0] = self.input_domain[0, 0] @@ -1866,9 +1718,6 @@ def _set_parameters(self, a=1, b=100): self.a = a self.b = b - def get_param(self): - return {"a": self.a, "b": self.b} - def get_global_minimum(self, d): X = np.array([1 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1906,9 +1755,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-65.536, 65.536] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1945,9 +1791,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-100, 100] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -1981,9 +1824,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-100, 100], [-100, 100]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), @@ -2018,9 +1858,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-4, 4], [-4, 4]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), @@ -2055,9 +1892,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-4, 4], [-4, 4]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 1.253115]) return (self._retrieve_original_input(X), @@ -2093,9 +1927,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-4, 4], [-4, 4]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 1.253115]) return (self._retrieve_original_input(X), @@ -2134,9 +1965,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-500, 500] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([420.9687 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2173,9 +2001,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-100, 100] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2211,9 +2036,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-100, 100] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2250,9 +2072,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-100, 100] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2288,9 +2107,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2345,9 +2161,6 @@ def _set_parameters(self, m=None, C=None, beta=None): ) ) - def get_param(self): - return {"m": self.m, "C": self.C, "beta": self.beta} - def get_global_minimum(self, d): X = self.C[0] return (self._retrieve_original_input(X), @@ -2385,9 +2198,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): # Global minimum from # https://documentation.sas.com/doc/en/orcdc/14.2/ @@ -2429,9 +2239,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([-7.4 for _ in range(d)]) @@ -2469,9 +2276,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([4.85 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2508,9 +2312,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-5.12, 5.12] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2546,9 +2347,6 @@ def _set_parameters( d = self.dimensionality self.input_domain = np.array([[-5, 5] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([-2.903534 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2582,9 +2380,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for _ in range(d)]) return (self._retrieve_original_input(X), @@ -2619,9 +2414,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[-5, 5], [-5, 5]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0]) return (self._retrieve_original_input(X), @@ -2656,9 +2448,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-(d**2), d**2] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([i * (d + 1 - i) for i in range(1, d + 1)]) return (self._retrieve_original_input(X), @@ -2691,9 +2480,6 @@ def is_dim_compatible(cls, d): def _set_parameters(self): self.input_domain = np.array([[0, 2], [0, 2], [0, 2]]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0, 0, 0]) return (self._retrieve_original_input(X), @@ -2728,9 +2514,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-5, 5] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), @@ -2768,9 +2551,6 @@ def _set_parameters(self): self.input_domain = np.array( [[-2 * np.pi, 2 * np.pi] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), @@ -2807,9 +2587,6 @@ def _set_parameters(self, m=5, beta=15): self.m = m self.beta = beta - def get_param(self): - return {"m": self.m, "beta": self.beta} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), @@ -2845,9 +2622,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-10, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), @@ -2882,9 +2656,6 @@ def _set_parameters(self): d = self.dimensionality self.input_domain = np.array([[-5, 10] for _ in range(d)]) - def get_param(self): - return {} - def get_global_minimum(self, d): X = np.array([0 for i in range(1, d + 1)]) return (self._retrieve_original_input(X), diff --git a/src/f3dasm/_src/design/__init__.py b/src/f3dasm/_src/design/__init__.py index e69de29b..a2aaabdc 100644 --- a/src/f3dasm/_src/design/__init__.py +++ b/src/f3dasm/_src/design/__init__.py @@ -0,0 +1,24 @@ +""" +Design-of-experiments (DOE) module for the f3dasm package. +""" + +# Modules +# ============================================================================= + +# Local +from .domain import Domain, _domain_factory +from .samplers import _sampler_factory + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= +# +# ============================================================================= + +__all__ = ['Domain', + '_domain_factory', + '_sampler_factory' + ] diff --git a/src/f3dasm/_src/design/domain.py b/src/f3dasm/_src/design/domain.py index b399f2b1..c08f4419 100644 --- a/src/f3dasm/_src/design/domain.py +++ b/src/f3dasm/_src/design/domain.py @@ -9,22 +9,21 @@ from __future__ import annotations # Standard +import json import math -import pickle -from dataclasses import dataclass, field +from itertools import zip_longest from pathlib import Path -from typing import (Any, Dict, Iterable, Iterator, List, Literal, Optional, - Sequence, Type) +from typing import Any, Dict, List, Literal, Optional, Sequence, Type # Third-party core import numpy as np -import pandas as pd from omegaconf import DictConfig, OmegaConf # Local -from .parameter import (CategoricalType, _CategoricalParameter, - _ConstantParameter, _ContinuousParameter, - _DiscreteParameter, _OutputParameter, _Parameter) +from .parameter import (CategoricalParameter, CategoricalType, + ConstantParameter, ContinuousParameter, + DiscreteParameter, LoadFunction, Parameter, + StoreFunction) # Authorship & Credits # ============================================================================= @@ -36,122 +35,179 @@ # ============================================================================= -@dataclass class Domain: """Main class for defining the domain of the design of experiments. Parameters ---------- - space : Dict[str, Parameter], optional - Dict of input parameters, by default an empty dict + input_space : Dict[str, Parameter], optional + Dict of input parameters, by default None + output_space : Dict[str, Parameter], optional + Dict of output parameters, by default None """ - space: Dict[str, _Parameter] = field(default_factory=dict) - output_space: Dict[str, _OutputParameter] = field(default_factory=dict) + def __init__(self, input_space: Dict[str, Parameter] = None, + output_space: Dict[str, Parameter] = None): + self.input_space = input_space if input_space is not None else {} + self.output_space = output_space if output_space is not None else {} def __len__(self) -> int: - """The len() method returns the number of parameters""" - return len(self.space) + """The len() method returns the number of input parameters""" + return len(self.input_space) def __eq__(self, __o: Domain) -> bool: """Custom equality comparison for Domain objects.""" if not isinstance(__o, Domain): - return TypeError(f"Cannot compare Domain with \ - {type(__o.__name__)}") + raise TypeError(f"Cannot compare Domain with \ + {type(__o)}") return ( - self.space == __o.space) + self.input_space == __o.input_space + and self.output_space == __o.output_space) + + def __bool__(self) -> bool: + """Check if the Domain object is empty""" + return bool(self.input_space) or bool(self.output_space) + + def __str__(self): + input_space_str = ", ".join( + f"{k}: {v}" for k, v in self.input_space.items()) + output_space_str = ", ".join( + f"{k}: {v}" for k, v in self.output_space.items()) + return (f"Domain(\n" + f" Input Space: {{ {input_space_str} }}\n" + f" Output Space: {{ {output_space_str} }}\n)") + + def __repr__(self): + return (f"{self.__class__.__name__}(" + f"input_space={repr(self.input_space)}, " + f"output_space={repr(self.output_space)})") def __add__(self, __o: Domain) -> Domain: if not isinstance(__o, Domain): - raise TypeError(f"Cannot add Domain with {type(__o.__name__)}") + raise TypeError(f"Cannot add Domain with {type(__o)}") combined_space = {} # Merge values for keys that are present in both dictionaries - for key in self.space.keys(): - if key in __o.space: - combined_space[key] = self.space[key] + __o.space[key] + for key in self.input_space.keys(): + if key in __o.input_space: + combined_space[key] = self.input_space[key] + \ + __o.input_space[key] else: - combined_space[key] = self.space[key] + combined_space[key] = self.input_space[key] # Add keys from dict2 that are not present in dict1 - for key in __o.space.keys(): - if key not in self.space: - combined_space[key] = __o.space[key] + for key in __o.input_space.keys(): + if key not in self.input_space: + combined_space[key] = __o.input_space[key] - return Domain(space=combined_space, + return Domain(input_space=combined_space, output_space={**self.output_space, **__o.output_space}) - def items(self) -> Iterator[_Parameter]: - """Return an iterator over the items of the parameters""" - return self.space.items() - - def values(self) -> Iterator[_Parameter]: - """Return an iterator over the values of the parameters""" - return self.space.values() - - def keys(self) -> Iterator[str]: - """Return an iterator over the keys of the parameters""" - return self.space.keys() - @property - def names(self) -> List[str]: - """Return a list of the names of the parameters""" - return list(self.keys()) + def input_names(self) -> List[str]: + """ + Retrieve the input space names + + Returns + ------- + List[str] + List of the names of the input parameters + """ + return list(self.input_space.keys()) @property def output_names(self) -> List[str]: - """Return a list of the names of the output parameters""" + """ + Retrieve the output space names + + Returns + ------- + List[str] + List of the names of the output parameters""" return list(self.output_space.keys()) @property def continuous(self) -> Domain: - """Returns a Domain object containing only the continuous parameters""" - return self._filter(_ContinuousParameter) + """Filter the continuous parameters of the domain + + Returns + ------- + Domain + Domain object containing the continuous parameters + """ + return self._filter(ContinuousParameter) @property def discrete(self) -> Domain: - """Returns a Domain object containing only the discrete parameters""" - return self._filter(_DiscreteParameter) + """Filter the discrete parameters of the domain + + Returns + ------- + Domain + Domain object containing the discrete parameters + """ + return self._filter(DiscreteParameter) @property def categorical(self) -> Domain: - """Returns a Domain object containing only - the categorical parameters""" - return self._filter(_CategoricalParameter) + """Filter the categorical parameters of the domain + + Returns + ------- + Domain + Domain object containing the categorical parameters + """ + return self._filter(CategoricalParameter) @property def constant(self) -> Domain: - """Returns a Domain object containing only the constant parameters""" - return self._filter(_ConstantParameter) + """Filter the constant parameters of the domain + + Returns + ------- + Domain + Domain object containing the constant parameters + """ + return self._filter(ConstantParameter) # Alternative constructors # ============================================================================= @classmethod def from_file(cls: Type[Domain], filename: Path | str) -> Domain: - """Create a Domain object from a pickle file. + """ + Create a Domain object from a JSON file. Parameters ---------- - filename : Path - Name of the file. + filename : Path or str + Path of the JSON file to load the Domain object from. Returns ------- Domain - Domain object containing the loaded data. + Domain object containing the loaded design spaces. + + Examples + -------- + >>> domain = Domain.from_json('domain.json') """ # convert filename to Path object filename = Path(filename) # Check if filename exists - if not filename.with_suffix('.pkl').exists(): + if not filename.with_suffix('.json').exists(): raise FileNotFoundError(f"Domain file {filename} does not exist.") - with open(filename.with_suffix('.pkl'), "rb") as file: - obj = pickle.load(file) + with open(filename.with_suffix('.json'), 'r') as f: + domain_dict = json.load(f) + + input_space = {k: Parameter.from_dict( + v) for k, v in domain_dict['input_space'].items()} + output_space = {k: Parameter.from_dict( + v) for k, v in domain_dict['output_space'].items()} - return obj + return cls(input_space=input_space, output_space=output_space) @classmethod def from_yaml(cls: Type[Domain], cfg: DictConfig) -> Domain: @@ -165,12 +221,16 @@ def from_yaml(cls: Type[Domain], cfg: DictConfig) -> Domain: .. code-block:: yaml domain: - : - type: - - : - type: - + input: + : + type: + + : + type: + + output: + : + to_disk: Parameters @@ -183,91 +243,135 @@ def from_yaml(cls: Type[Domain], cfg: DictConfig) -> Domain: Domain Domain object """ + def process_input(items): + for key, value in items.items(): + _dict = OmegaConf.to_container(value, resolve=True) + domain.add(name=key, type=_dict.pop('type', None), **_dict) + + def process_output(items): + for key, value in items.items(): + _dict = OmegaConf.to_container(value, resolve=True) + domain.add_output(name=key, **_dict) + domain = cls() - for key, value in cfg.items(): - _dict = OmegaConf.to_container(value, resolve=True) - domain.add(name=key, type=_dict.pop('type'), **_dict) + if 'input' in cfg: + process_input(cfg.input) + else: + process_input(cfg) + + if 'output' in cfg: + process_output(cfg.output) return domain @classmethod - def from_dataframe(cls, df_input: pd.DataFrame, - df_output: pd.DataFrame) -> Domain: - """Initializes a Domain from a pandas DataFrame. + def from_data(cls, input_data: List[Dict[str, Any]], + output_data: List[Dict[str, Any]] + ) -> Domain: + """ + Initialize a Domain from input and output data. Parameters ---------- - df_input : pd.DataFrame - DataFrame containing the input parameters. - df_output : pd.DataFrame - DataFrame containing the output parameters. + input_data : List[Dict[str, Any]] + List of dictionaries containing the input parameters. + output_data : List[Dict[str, Any]] + List of dictionaries containing the output parameters. Returns ------- Domain - Domain object + Domain object containing the input and output parameter names. """ - input_space = {} - for name, type in df_input.dtypes.items(): - if type == 'float64': - if float(df_input[name].min()) == float(df_input[name].max()): - input_space[name] = _ConstantParameter( - value=float(df_input[name].min())) - continue - - input_space[name] = _ContinuousParameter(lower_bound=float( - df_input[name].min()), - upper_bound=float(df_input[name].max())) - elif type == 'int64': - if int(df_input[name].min()) == int(df_input[name].max()): - input_space[name] = _ConstantParameter( - value=int(df_input[name].min())) - continue - - input_space[name] = _DiscreteParameter(lower_bound=int( - df_input[name].min()), - upper_bound=int(df_input[name].max())) - else: - input_space[name] = _CategoricalParameter( - df_input[name].unique().tolist()) + all_input_parameters, all_output_parameters = set(), set() + for experiment_input, experiment_output in zip_longest( + input_data, output_data, fillvalue={}): - output_space = {} - for name in df_output.columns: - output_space[name] = _OutputParameter(to_disk=False) + all_input_parameters.update(experiment_input.keys()) + all_output_parameters.update(experiment_output.keys()) - return cls(space=input_space, output_space=output_space) + input_names = sorted(list(all_input_parameters)) + output_names = sorted(list(all_output_parameters)) + + input_space = {name: Parameter() for name in input_names} + output_space = {name: Parameter() for name in output_names} + + return cls(input_space=input_space, output_space=output_space) # Export # ============================================================================= - def store(self, filename: Path) -> None: - """Stores the Domain in a pickle file. + def store(self, filename: Path | str) -> None: + """ + Store the Domain object and its parameters as a JSON file. Parameters ---------- - filename : str - Name of the file. - """ - with open(filename.with_suffix('.pkl'), 'wb') as f: - pickle.dump(self, f) + filename : Path or str + Path of the JSON file to store the Domain object. - def _cast_types_dataframe(self) -> dict: - """Make a dictionary that provides the datatype of each parameter""" - return {name: parameter._type for - name, parameter in self.space.items()} + Examples + -------- + >>> domain.to_json('domain.json') + """ + domain_dict = { + 'input_space': {k: v.to_dict() + for k, v in self.input_space.items()}, + 'output_space': {k: v.to_dict() + for k, v in self.output_space.items()} + } + with open(Path(filename).with_suffix('.json'), 'w') as f: + json.dump(domain_dict, f, indent=4) # Append and remove parameters # ============================================================================= - def _add(self, name: str, parameter: _Parameter): + def _add(self, name: str, parameter: Parameter): + """ + Add a new input parameter to the domain. + + Parameters + ---------- + name : str + Name of the input parameter. + parameter : Parameter + Parameter object to be added to the domain. + """ # Check if parameter is already in the domain - if name in self.space: + if name in self.input_space: raise KeyError( f"Parameter {name} already exists in the domain! \ Choose a different name.") - self.space[name] = parameter + self.input_space[name] = parameter + + def add_parameter(self, name: str, + to_disk=False, + store_function: Optional[StoreFunction] = None, + load_function: Optional[LoadFunction] = None): + """Add a new parameter to the domain. + + Parameters + ---------- + name : str + Name of the input parameter. + store_function : StoreFunction, optional + Function to store the parameter, by default None. + load_function : LoadFunction, optional + Function to load the parameter, by default None. + + Example + ------- + >>> domain = Domain() + >>> domain.add_parameter('param1', store_function, load_function) + >>> domain.input_space + {'param1': Parameter(store_function=store_function, + load_function=load_function)} + """ + self._add(name, Parameter(store_function=store_function, + load_function=load_function, + to_disk=to_disk)) def add_int(self, name: str, low: int, high: int, step: int = 1): """Add a new discrete input parameter to the domain. @@ -287,8 +391,8 @@ def add_int(self, name: str, low: int, high: int, step: int = 1): ------- >>> domain = Domain() >>> domain.add_int('param1', 0, 10, 2) - >>> domain.space - {'param1': _DiscreteParameter(lower_bound=0, upper_bound=10, step=2)} + >>> domain.input_space + {'param1': DiscreteParameter(lower_bound=0, upper_bound=10, step=2)} Note ---- @@ -297,7 +401,8 @@ def add_int(self, name: str, low: int, high: int, step: int = 1): """ if low == high: self.add_constant(name, low) - self._add(name, _DiscreteParameter(low, high, step)) + else: + self._add(name, DiscreteParameter(low, high, step)) def add_float(self, name: str, low: float = -np.inf, high: float = np.inf, log: bool = False): @@ -318,8 +423,8 @@ def add_float(self, name: str, low: float = -np.inf, high: float = np.inf, ------- >>> domain = Domain() >>> domain.add_float('param1', 0., 10., log=True) - >>> domain.space - {'param1': _ContinuousParameter(lower_bound=0., + >>> domain.input_space + {'param1': ContinuousParameter(lower_bound=0., upper_bound=10., log=True)} Note @@ -330,7 +435,7 @@ def add_float(self, name: str, low: float = -np.inf, high: float = np.inf, if math.isclose(low, high): self.add_constant(name, low) else: - self._add(name, _ContinuousParameter(low, high, log)) + self._add(name, ContinuousParameter(low, high, log)) def add_category(self, name: str, categories: Sequence[CategoricalType]): """Add a new categorical input parameter to the domain. @@ -346,10 +451,10 @@ def add_category(self, name: str, categories: Sequence[CategoricalType]): ------- >>> domain = Domain() >>> domain.add_category('param1', [0, 1, 2]) - >>> domain.space + >>> domain.input_space {'param1': CategoricalParameter(categories=[0, 1, 2])} """ - self._add(name, _CategoricalParameter(categories)) + self._add(name, CategoricalParameter(categories)) def add_constant(self, name: str, value: Any): """Add a new constant input parameter to the domain. @@ -365,10 +470,10 @@ def add_constant(self, name: str, value: Any): ------- >>> domain = Domain() >>> domain.add_constant('param1', 0) - >>> domain.space + >>> domain.input_space {'param1': ConstantParameter(value=0)} """ - self._add(name, _ConstantParameter(value)) + self._add(name, ConstantParameter(value)) def add(self, name: str, type: Literal['float', 'int', 'category', 'constant'], @@ -394,8 +499,8 @@ def add(self, name: str, ------- >>> domain = Domain() >>> domain._new_add('param1', 'float', low=0., high=1.) - >>> domain.space - {'param1': _ContinuousParameter(lower_bound=0., upper_bound=1.)} + >>> domain.input_space + {'param1': ContinuousParameter(lower_bound=0., upper_bound=1.)} """ if type == 'float': @@ -412,7 +517,9 @@ def add(self, name: str, f"Possible types are: 'float', 'int', 'category', 'constant'.") def add_output(self, name: str, to_disk: bool = False, - exist_ok: bool = False): + exist_ok: bool = False, + store_function: Optional[StoreFunction] = None, + load_function: Optional[LoadFunction] = None): """Add a new output parameter to the domain. Parameters @@ -429,7 +536,7 @@ def add_output(self, name: str, to_disk: bool = False, ------- >>> domain = Domain() >>> domain.add_output('param1', True) - >>> domain.space + >>> domain.input_space {'param1': OutputParameter(to_disk=True)} """ if name in self.output_space: @@ -439,7 +546,9 @@ def add_output(self, name: str, to_disk: bool = False, Choose a different name.") return - self.output_space[name] = _OutputParameter(to_disk) + self.output_space[name] = Parameter(to_disk=to_disk, + store_function=store_function, + load_function=load_function) # Getters # ============================================================================= @@ -454,10 +563,10 @@ def get_bounds(self) -> np.ndarray: Example ------- >>> domain = Domain() - >>> domain.space = { - ... 'param1': _ContinuousParameter(lower_bound=0, upper_bound=1), - ... 'param2': _ContinuousParameter(lower_bound=-1, upper_bound=1), - ... 'param3': _ContinuousParameter(lower_bound=0, upper_bound=10) + >>> domain.input_space = { + ... 'param1': ContinuousParameter(lower_bound=0, upper_bound=1), + ... 'param2': ContinuousParameter(lower_bound=-1, upper_bound=1), + ... 'param3': ContinuousParameter(lower_bound=0, upper_bound=10) ... } >>> bounds = domain.get_bounds() >>> bounds @@ -467,10 +576,10 @@ def get_bounds(self) -> np.ndarray: """ return np.array( [[parameter.lower_bound, parameter.upper_bound] - for _, parameter in self.continuous.space.items()] + for _, parameter in self.continuous.input_space.items()] ) - def _filter(self, type: Type[_Parameter]) -> Domain: + def _filter(self, type: Type[Parameter]) -> Domain: """Filter the parameters of the domain by type Parameters @@ -486,119 +595,25 @@ def _filter(self, type: Type[_Parameter]) -> Domain: Example ------- >>> domain = Domain() - >>> domain.space = { - ... 'param1': _ContinuousParameter(lower_bound=0., upper_bound=1.), - ... 'param2': _DiscreteParameter(lower_bound=0, upper_bound=8), + >>> domain.input_space = { + ... 'param1': ContinuousParameter(lower_bound=0., upper_bound=1.), + ... 'param2': DiscreteParameter(lower_bound=0, upper_bound=8), ... 'param3': CategoricalParameter(categories=['cat1', 'cat2']) ... } - >>> filtered_domain = domain.filter_parameters(_ContinuousParameter) - >>> filtered_domain.space - {'param1': _ContinuousParameter(lower_bound=0, upper_bound=1)} + >>> filtered_domain = domain.filter_parameters(ContinuousParameter) + >>> filtered_domain.input_space + {'param1': ContinuousParameter(lower_bound=0, upper_bound=1)} """ return Domain( - space={name: parameter for name, parameter in self.space.items() - if isinstance(parameter, type)} + input_space={ + name: parameter for name, parameter in self.input_space.items() + if isinstance(parameter, type)} ) - def select(self, names: str | Iterable[str]) -> Domain: - """Select a subset of parameters from the domain. - - Parameters - ---------- - - names : str or Iterable[str] - The names of the parameters to select. - - Returns - ------- - Domain - A new domain with the selected parameters. - - Example - ------- - >>> domain = Domain() - >>> domain.space = { - ... 'param1': _ContinuousParameter(lower_bound=0., upper_bound=1.), - ... 'param2': _DiscreteParameter(lower_bound=0, upper_bound=8), - ... 'param3': CategoricalParameter(categories=['cat1', 'cat2']) - ... } - >>> domain.select(['param1', 'param3']) - Domain({'param1': _ContinuousParameter(lower_bound=0, upper_bound=1), - 'param3': CategoricalParameter(categories=['cat1', 'cat2'])}) - """ - - if isinstance(names, str): - names = [names] - - return Domain(space={key: self.space[key] for key in names}) - - def drop_output(self, names: str | Iterable[str]) -> Domain: - """Drop a subset of output parameters from the domain. - - Parameters - ---------- - - names : str or Iterable[str] - The names of the output parameters to drop. - - Returns - ------- - Domain - A new domain with the dropped output parameters. - - Example - ------- - >>> domain = Domain() - >>> domain.output_space = { - ... 'param1': _OutputParameter(to_disk=True), - ... 'param2': _OutputParameter(to_disk=True), - ... 'param3': _OutputParameter(to_disk=True) - ... } - >>> domain.drop_output(['param1', 'param3']) - Domain({'param2': _OutputParameter(to_disk=True)}) - """ - - if isinstance(names, str): - names = [names] - - return Domain( - space=self.space, - output_space={key: self.output_space[key] - for key in self.output_space - if key not in names}) - # Miscellaneous # ============================================================================= - def _all_input_continuous(self) -> bool: - """Check if all input parameters are continuous""" - return len(self) == len(self._filter(_ContinuousParameter)) - - def is_in_output(self, output_name: str) -> bool: - """Check if output is in the domain - - Parameters - ---------- - output_name : str - Name of the output - - Returns - ------- - bool - True if output is in the domain, False otherwise - - Example - ------- - >>> domain = Domain() - >>> domain.add_output('output1') - >>> domain.is_in_output('output1') - True - >>> domain.is_in_output('output2') - False - """ - return output_name in self.output_space - def make_nd_continuous_domain(bounds: np.ndarray | List[List[float]], dimensionality: Optional[int] = None) -> Domain: @@ -635,7 +650,7 @@ def make_nd_continuous_domain(bounds: np.ndarray | List[List[float]], >>> dimensionality = 2 >>> domain = make_nd_continuous_domain(bounds, dimensionality) """ - space = {} + input_space = {} # bounds is a list of lists, convert to numpy array: bounds = np.array(bounds) @@ -643,31 +658,42 @@ def make_nd_continuous_domain(bounds: np.ndarray | List[List[float]], dimensionality = bounds.shape[0] for dim in range(dimensionality): - space[f"x{dim}"] = _ContinuousParameter( + input_space[f"x{dim}"] = ContinuousParameter( lower_bound=bounds[dim, 0], upper_bound=bounds[dim, 1]) - return Domain(space) + return Domain(input_space=input_space) -def _domain_factory(domain: Domain | DictConfig | None, - input_data: pd.DataFrame, - output_data: pd.DataFrame) -> Domain: +def _domain_factory(domain: Domain | DictConfig | Path | str) -> Domain: + """ + Factory function to create a Domain object from various input types. + + Parameters + ---------- + domain : Domain | DictConfig | Path | str + The domain to be converted to a Domain object. + + Returns + ------- + Domain + Domain object + """ + # If domain is already a Domain object, return it if isinstance(domain, Domain): return domain + # If domain is not given, return an empty Domain object + elif domain is None: + return Domain() + + # If domain is a path, load the domain from the file elif isinstance(domain, (Path, str)): return Domain.from_file(Path(domain)) + # If the domain is a hydra DictConfig, convert it to a Domain object elif isinstance(domain, DictConfig): return Domain.from_yaml(domain) - elif (input_data.empty and output_data.empty and domain is None): - return Domain() - - elif domain is None: - return Domain.from_dataframe( - input_data, output_data) - else: raise TypeError( f"Domain must be of type Domain, DictConfig " diff --git a/src/f3dasm/_src/design/parameter.py b/src/f3dasm/_src/design/parameter.py index 07b67e3f..311c99cf 100644 --- a/src/f3dasm/_src/design/parameter.py +++ b/src/f3dasm/_src/design/parameter.py @@ -1,4 +1,4 @@ -"""Parameters for constructing the feasible search space.""" +"""Parameters for constructing a feasible search space.""" # Modules # ============================================================================= @@ -6,11 +6,8 @@ from __future__ import annotations # Standard -from dataclasses import dataclass, field -from typing import Any, ClassVar, Sequence, Union - -# Third-party -import numpy as np +import pickle +from typing import Any, ClassVar, Iterable, Optional, Protocol, Union # Authorship & Credits # ============================================================================= @@ -23,25 +20,236 @@ CategoricalType = Union[None, int, float, str] +# ============================================================================= -@dataclass -class _Parameter: - """Interface class of a search space parameter - Parameters - ---------- - """ - _type: ClassVar[str] = field(init=False, default="object") +class StoreFunction(Protocol): + """Base class for storing and loading output data from disk""" + + def __call__(object: Any, path: str) -> str: + """ + Protocol class for storing output data from disk. + + Parameters + ---------- + object : Any + The object to store. + path : str + The location to store the object to. + + Notes + ----- + The function should store the object to the location specified by the + path parameter. The suffix of the file should be determined by the + object type, and is not yet implemented in the path! + """ + ... + + +class LoadFunction(Protocol): + """Base class for storing and loading output data from disk""" + + def __call__(path: str) -> Any: + """ + Protocol class for loading output data from disk. + + Parameters + ---------- + path : str + The location to load the object from. + + Returns + ------- + Any + The loaded object. + """ + ... + + +# ============================================================================= + + +class Parameter: + """Interface class of a search space parameter.""" + _type: ClassVar[str] = "object" + + def __init__(self, to_disk: bool = False, + store_function: Optional[StoreFunction] = None, + load_function: Optional[LoadFunction] = None): + """ + Initialize the Parameter. + + Parameters + ---------- + to_disk : bool, optional + Whether the parameter should be saved to disk. Defaults to False. + store_function : Optional[StoreFunction], optional + Function to store the parameter to disk. Defaults to None. + load_function : Optional[LoadFunction], optional + Function to load the parameter from disk. Defaults to None. + + Raises + ------ + ValueError + If `to_disk` is False but either `store_function` or + `load_function` is not None. + + Notes + ----- + The `store_function` and `load_function` parameters are used to store + the parameter to disk and load it from disk, respectively. f3dasm has + built-in support for some common datatypes (numpy array, pandas + DataFrame, xarray Dataarray and Dataset), for those data types the + `store_function` and `load_function` are automatically set. If the + parameter is not one of these types, the user must provide a custom + `store_function` and `load_function`. + + Examples + -------- + >>> param = Parameter(to_disk=True) + >>> print(param) + Parameter(type=object, to_disk=True) + """ + + if not to_disk and ( + store_function is not None or load_function is not None): + raise ValueError(("If 'to_disk' is False, 'store_function' and" + "load_function' must be None.") + ) + + self.to_disk = to_disk + self.store_function = store_function + self.load_function = load_function + + def __str__(self): + return f"Parameter(type={self._type}, to_disk={self.to_disk})" + + def __repr__(self): + return f"{self.__class__.__name__}(to_disk={self.to_disk})" + + def __eq__(self, __o: Parameter): + return self.to_disk == __o.to_disk + + def __add__(self, __o: Parameter) -> Parameter: + """ + Add two parameters together. + Parameters + ---------- + __o : Parameter + The parameter to add to the current parameter. -@dataclass -class _OutputParameter(_Parameter): - to_disk: bool = field(default=False) + Returns + ------- + Parameter + The first parameter + Notes + ----- + Adding two parameters together will return the first parameter. This + is because the parameters are not additive. + """ + return self -@dataclass -class _ConstantParameter(_Parameter): - """Create a search space parameter that is constant. + def to_dict(self) -> dict: + """ + Convert the Parameter object to a dictionary. + + Returns + ------- + dict + Dictionary representation of the Parameter object. + + Notes + ----- + The dictionary representation of the Parameter object contains the + type of the parameter, whether it should be saved to disk, and the + store and load functions. The functions are stored as hex strings. + + Examples + -------- + >>> param = Parameter(to_disk=True) + >>> param_dict = param.to_dict() + + + """ + param_dict = { + 'type': self._type, + 'to_disk': self.to_disk, + 'store_function': None, + 'load_function': None + } + if self.store_function: + param_dict['store_function'] = pickle.dumps( + self.store_function).hex() + if self.load_function: + param_dict['load_function'] = pickle.dumps( + self.load_function).hex() + return param_dict + + @classmethod + def from_dict(cls, param_dict: dict) -> Parameter: + """ + Create a Parameter object from a dictionary. + + Parameters + ---------- + param_dict : dict + Dictionary representation of the Parameter object. + + Returns + ------- + Parameter + Parameter object created from the dictionary. + + Examples + -------- + >>> param_dict = {'type': 'object', 'to_disk': False} + >>> param = Parameter.from_dict(param_dict) + """ + param_type = param_dict['type'] + store_function = None + load_function = None + if param_dict['store_function']: + store_function = pickle.loads( + bytes.fromhex(param_dict['store_function'])) + if param_dict['load_function']: + load_function = pickle.loads( + bytes.fromhex(param_dict['load_function'])) + + if param_type == 'object': + return Parameter(to_disk=param_dict['to_disk'], + store_function=store_function, + load_function=load_function) + elif param_type == 'float': + return ContinuousParameter( + lower_bound=param_dict.get('lower_bound', float('-inf')), + upper_bound=param_dict.get('upper_bound', float('inf')), + log=param_dict.get('log', False) + ) + elif param_type == 'int': + return DiscreteParameter( + lower_bound=param_dict.get('lower_bound', 0), + upper_bound=param_dict.get('upper_bound', 1), + step=param_dict.get('step', 1) + ) + elif param_type == 'category': + return CategoricalParameter( + categories=param_dict.get('categories', []) + ) + elif param_type == 'constant': + return ConstantParameter( + value=param_dict.get('value') + ) + else: + raise ValueError(f"Unknown parameter type: {param_type}") + +# ============================================================================= + + +class ConstantParameter(Parameter): + """ + Create a search space parameter that is constant. Parameters ---------- @@ -58,269 +266,342 @@ class _ConstantParameter(_Parameter): TypeError If the value is not hashable. + Examples + -------- + >>> param = ConstantParameter(value=5) + >>> print(param) + ConstantParameter(value=5) """ - value: Any - _type: ClassVar[str] = field(init=False, default="object") - - def __post_init__(self): - self._check_hashable() + def __init__(self, value: Any): + super().__init__() + self.value = value + self._validate_hashable() - def __add__(self, __o: _Parameter - ) -> _ConstantParameter | _CategoricalParameter: - if isinstance(__o, _ConstantParameter): - if self.value == __o.value: + def __add__(self, other: Parameter): + if isinstance(other, ConstantParameter): + if self.value == other.value: return self else: - return _CategoricalParameter( - categories=[self.value, __o.value]) + return CategoricalParameter( + categories=[self.value, other.value]) - if isinstance(__o, _CategoricalParameter): - return self.to_categorical() + __o + if isinstance(other, CategoricalParameter): + return self.to_categorical() + other - if isinstance(__o, _DiscreteParameter): - return self.to_categorical() + __o + if isinstance(other, DiscreteParameter): + return self.to_categorical() + other - if isinstance(__o, _ContinuousParameter): + if isinstance(other, ContinuousParameter): raise ValueError("Cannot add continuous parameter to constant!") - def to_categorical(self) -> _CategoricalParameter: - return _CategoricalParameter(categories=[self.value]) + def to_categorical(self) -> CategoricalParameter: + """ + Convert the constant parameter to a categorical parameter. + + Returns + ------- + CategoricalParameter + The converted categorical parameter. + """ + return CategoricalParameter(categories=[self.value]) - def _check_hashable(self): + def to_dict(self): + param_dict = super().to_dict() + param_dict['type'] = 'constant' + param_dict['value'] = self.value + return param_dict + + def _validate_hashable(self): """Check if the value is hashable.""" try: hash(self.value) except TypeError: raise TypeError("The value must be hashable.") + def __str__(self): + return f"ConstantParameter(value={self.value})" + + def __repr__(self): + return f"{self.__class__.__name__}(value={repr(self.value)})" + + def __eq__(self, __o: Parameter) -> bool: + if not isinstance(__o, ConstantParameter): + return False + + return self.value == __o.value + +# ============================================================================= -@dataclass -class _ContinuousParameter(_Parameter): + +class ContinuousParameter(Parameter): """ A search space parameter that is continuous. - Attributes + Parameters ---------- lower_bound : float, optional - The lower bound of the continuous search space. - Defaults to negative infinity. + The lower bound of the parameter. Defaults to -inf. upper_bound : float, optional - The upper bound of the continuous search space (exclusive). - Defaults to infinity. + The upper bound of the parameter. Defaults to inf. log : bool, optional - Whether the search space is logarithmic. Defaults to False. + Whether the parameter should be on a log scale. Defaults to False. Raises ------ - TypeError - If the boundaries are not floats. ValueError - If the upper bound is less than the lower bound, or if the - lower bound is equal to the upper bound. - - Note - ---- - This class inherits from the `Parameter` class and adds the ability - to specify a continuous search space. + If `log` is True and `lower_bound` is less than or equal to 0. + If `upper_bound` is less than or equal to `lower_bound`. + + Examples + -------- + >>> param = ContinuousParameter(lower_bound=0.0, upper_bound=1.0) + >>> print(param) + ContinuousParameter(lower_bound=0.0, upper_bound=1.0, log=False) """ + _type: ClassVar[str] = "float" - lower_bound: float = field(default=-np.inf) - upper_bound: float = field(default=np.inf) - log: bool = field(default=False) - _type: ClassVar[str] = field(init=False, default="float") - - def __post_init__(self): + def __init__(self, lower_bound: float = float('-inf'), + upper_bound: float = float('inf'), log: bool = False): + super().__init__() + self.lower_bound = float(lower_bound) + self.upper_bound = float(upper_bound) + self.log = log if self.log and self.lower_bound <= 0.0: - raise ValueError( - f"The `lower_bound` value must be larger than 0 for a \ - log distribution " - f"(low={self.lower_bound}, high={self.upper_bound})." - ) - - self._check_types() - self._check_range() - - def __add__(self, __o: _Parameter) -> _ContinuousParameter: - if not isinstance(__o, _ContinuousParameter): + raise ValueError(( + f"The `lower_bound` value must be larger than 0 for a " + f"log distribution (low={self.lower_bound}, " + f"high={self.upper_bound})." + )) + self._validate_range() + + def __add__(self, other: Parameter) -> "ContinuousParameter": + if not isinstance(other, ContinuousParameter): raise ValueError( "Cannot add non-continuous parameter to continuous!") - - if self.log != __o.log: + if self.log != other.log: raise ValueError( "Cannot add continuous parameters with different log scales!") - - if self.lower_bound == __o.lower_bound \ - and self.upper_bound == __o.upper_bound: - # If both lower and upper bounds are the same, - # return the first object - return self - - if self.lower_bound > __o.upper_bound \ - or __o.lower_bound > self.upper_bound: - # If the ranges do not coincide, raise ValueError + if self.lower_bound > other.upper_bound or \ + other.lower_bound > self.upper_bound: raise ValueError("Ranges do not coincide, cannot add") - # For other scenarios, join the ranges - return _ContinuousParameter( - lower_bound=min(self.lower_bound, __o.lower_bound), - upper_bound=max(self.upper_bound, __o.upper_bound)) + return ContinuousParameter( + lower_bound=min(self.lower_bound, other.lower_bound), + upper_bound=max(self.upper_bound, other.upper_bound) + ) + + def __str__(self): + return (f"ContinuousParameter(lower_bound={self.lower_bound}, " + f"upper_bound={self.upper_bound}, log={self.log})") - def _check_types(self): - """Check if the boundaries are actually floats""" - if isinstance(self.lower_bound, int): - self.lower_bound = float(self.lower_bound) + def __repr__(self): + return (f"{self.__class__.__name__}(lower_bound={self.lower_bound}, " + f"upper_bound={self.upper_bound}, log={self.log})") - if isinstance(self.upper_bound, int): - self.upper_bound = float(self.upper_bound) + def __eq__(self, __o: Parameter) -> bool: + if not isinstance(__o, ContinuousParameter): + return False - if not isinstance( - self.lower_bound, float) or not isinstance( - self.upper_bound, float): - raise TypeError( - f"Expect float, got {type(self.lower_bound).__name__} \ - and {type(self.upper_bound).__name__}") + return (self.lower_bound == __o.lower_bound and self.upper_bound + == __o.upper_bound and self.log == __o.log) - def _check_range(self): - """Check if the lower boundary is lower than the higher boundary""" + def _validate_range(self): if self.upper_bound <= self.lower_bound: - raise ValueError(f"The `upper_bound` value must be larger than \ - the `lower_bound` value " - f"(lower_bound={self.lower_bound}, \ - higher_bound={self.upper_bound}") + raise ValueError(( + f"The `upper_bound` value must be larger than `lower_bound`. " + f"(lower_bound={self.lower_bound}, " + f"upper_bound={self.upper_bound})") + ) - def to_discrete(self, step: int = 1) -> _DiscreteParameter: - """Convert the continuous parameter to a discrete parameter. + def to_discrete(self, step: int = 1) -> "DiscreteParameter": + """ + Convert the continuous parameter to a discrete parameter. Parameters ---------- - step : int - The step size of the discrete search space, which defaults to 1. + step : int, optional + The step size for the discrete parameter. Defaults to 1. Returns ------- DiscreteParameter - The discrete parameter. + The converted discrete parameter. Raises ------ ValueError If the step size is less than or equal to 0. + Examples + -------- + >>> param = ContinuousParameter(lower_bound=0.0, upper_bound=1.0) + >>> discrete_param = param.to_discrete(step=0.1) + >>> print(discrete_param) + DiscreteParameter(lower_bound=0.0, upper_bound=1.0, step=0.1) """ if step <= 0: raise ValueError("The step size must be larger than 0.") - - return _DiscreteParameter( - lower_bound=int(self.lower_bound), - upper_bound=int(self.upper_bound), + return DiscreteParameter( + lower_bound=self.lower_bound, + upper_bound=self.upper_bound, step=step ) + def to_dict(self) -> dict: + param_dict = super().to_dict() + param_dict['type'] = 'float' + param_dict['lower_bound'] = self.lower_bound + param_dict['upper_bound'] = self.upper_bound + param_dict['log'] = self.log + return param_dict + +# ============================================================================= -@dataclass -class _DiscreteParameter(_Parameter): - """Create a search space parameter that is discrete + +class DiscreteParameter(Parameter): + """ + Create a search space parameter that is discrete. Parameters ---------- lower_bound : int, optional - lower bound of discrete search space + The lower bound of the parameter. Defaults to 0. upper_bound : int, optional - upper bound of discrete search space (exclusive) + The upper bound of the parameter. Defaults to 1. step : int, optional - step size of discrete search space - """ - - lower_bound: int = field(default=0) - upper_bound: int = field(default=1) - step: int = field(default=1) - _type: ClassVar[str] = field(init=False, default="int") - - def __post_init__(self): - - self._check_types() - self._check_range() + The step size for the parameter. Defaults to 1. - def __add__(self, __o: _Parameter) -> _DiscreteParameter: - if isinstance(__o, _DiscreteParameter): - if self.lower_bound == __o.lower_bound and \ - self.upper_bound == __o.upper_bound and \ - self.step == __o.step: - return self + Raises + ------ + ValueError + If `upper_bound` is less than or equal to `lower_bound`. + If `step` is less than or equal to 0. + + Examples + -------- + >>> param = DiscreteParameter(lower_bound=0, upper_bound=10, step=1) + >>> print(param) + DiscreteParameter(lower_bound=0, upper_bound=10, step=1) + """ - if isinstance(__o, _CategoricalParameter): - return __o + self + def __init__(self, lower_bound: int = 0, + upper_bound: int = 1, step: int = 1): + super().__init__() + self.lower_bound = int(lower_bound) + self.upper_bound = int(upper_bound) + self.step = step + self._type = "int" + + self._validate_range() + + def __str__(self): + return (f"DiscreteParameter(lower_bound={self.lower_bound}, " + f"upper_bound={self.upper_bound}, step={self.step})") + + def __repr__(self): + return (f"{self.__class__.__name__}(lower_bound={self.lower_bound}, " + f"upper_bound={self.upper_bound}, step={self.step})") + + def __add__(self, other: Parameter) -> "DiscreteParameter": + if isinstance(other, CategoricalParameter): + return other + self + if isinstance(other, ConstantParameter): + return other.to_categorical() + self + if isinstance(other, ContinuousParameter): + raise ValueError("Cannot add continuous parameter to discrete!") + return self # Assuming the same discrete parameters are being added. - if isinstance(__o, _ConstantParameter): - return __o.to_categorical() + self + def __eq__(self, __o: Parameter) -> bool: + if not isinstance(__o, DiscreteParameter): + return False - if isinstance(__o, _ContinuousParameter): - raise ValueError("Cannot add continuous parameter to discrete!") + return (self.lower_bound == __o.lower_bound and self.upper_bound + == __o.upper_bound and self.step + == __o.step) - def _check_types(self): - """Check if the boundaries are actually ints""" - if not isinstance(self.lower_bound, int) or not isinstance( - self.upper_bound, int): - raise TypeError( - f"Expect integer, got {type(self.lower_bound).__name__} and \ - {type(self.upper_bound).__name__}") + def _validate_range(self): + if self.upper_bound <= self.lower_bound: + raise ValueError("Upper bound must be greater than lower bound.") + if self.step <= 0: + raise ValueError("Step size must be positive.") - def _check_range(self): - """Check if the lower boundary is lower than the higher boundary""" - if self.upper_bound < self.lower_bound: - raise ValueError("not the right range!") + def to_dict(self): + param_dict = super().to_dict() + param_dict['type'] = 'int' + param_dict['lower_bound'] = self.lower_bound + param_dict['upper_bound'] = self.upper_bound + param_dict['step'] = self.step + return param_dict - if self.upper_bound == self.lower_bound: - raise ValueError("same lower as upper bound!") - if self.step <= 0: - raise ValueError("step size must be larger than 0!") +# ============================================================================= -@dataclass -class _CategoricalParameter(_Parameter): - """Create a search space parameter that is categorical +class CategoricalParameter(Parameter): + """ + Create a search space parameter that is categorical. Parameters ---------- - categories - list of strings that represent available categories - """ + categories : Iterable[Any] + The categories of the parameter. - categories: Sequence[CategoricalType] - _type: str = field(init=False, default="object") - - def __post_init__(self): - self._check_duplicates() - - def __add__(self, __o: _Parameter) -> _CategoricalParameter: - if isinstance(__o, _CategoricalParameter): - # join unique categories - joint_categories = list(set(self.categories + __o.categories)) + Raises + ------ + ValueError + If the categories contain duplicates. - if isinstance(__o, _ConstantParameter): - joint_categories = list(set(self.categories + [__o.value])) + Examples + -------- + >>> param = CategoricalParameter(categories=['a', 'b', 'c']) + >>> print(param) + CategoricalParameter(categories=['a', 'b', 'c']) + """ + _type: ClassVar[str] = "object" - if isinstance(__o, _DiscreteParameter): - roll_out_discrete = list(range( - __o.lower_bound, __o.upper_bound, __o.step)) - joint_categories = list(set(self.categories + roll_out_discrete)) + def __init__(self, categories: Iterable[Any]): + super().__init__() + self.categories = categories + self._check_duplicates() - if isinstance(__o, _ContinuousParameter): + def __str__(self): + return f"CategoricalParameter(categories={self.categories})" + + def __repr__(self): + return (f"{self.__class__.__name__}" + f"(categories={list(self.categories)})") + + def __add__(self, other: Parameter) -> "CategoricalParameter": + if isinstance(other, CategoricalParameter): + joint_categories = list(set(self.categories + other.categories)) + elif isinstance(other, ConstantParameter): + joint_categories = list(set(self.categories + [other.value])) + elif isinstance(other, DiscreteParameter): + joint_categories = list(set(self.categories + list(range( + other.lower_bound, other.upper_bound, other.step)))) + elif isinstance(other, ContinuousParameter): raise ValueError("Cannot add continuous parameter to categorical!") + else: + raise ValueError( + f"Cannot add parameter of type {type(other)} to categorical.") + return CategoricalParameter(joint_categories) - return _CategoricalParameter(joint_categories) - - def __eq__(self, __o: _CategoricalParameter) -> bool: - return set(self.categories) == set(__o.categories) + def __eq__(self, other: CategoricalParameter) -> bool: + return set(self.categories) == set(other.categories) def _check_duplicates(self): - """Check if there are duplicates in the categories list""" if len(self.categories) != len(set(self.categories)): raise ValueError("Categories contain duplicates!") + def to_dict(self) -> dict: + param_dict = super().to_dict() + param_dict['type'] = 'category' + param_dict['categories'] = self.categories + return param_dict +# ============================================================================= + -PARAMETERS = [_CategoricalParameter, _ConstantParameter, - _ContinuousParameter, _DiscreteParameter] +PARAMETERS = [CategoricalParameter, ConstantParameter, + ContinuousParameter, DiscreteParameter] diff --git a/src/f3dasm/_src/design/samplers.py b/src/f3dasm/_src/design/samplers.py new file mode 100644 index 00000000..b9562cc4 --- /dev/null +++ b/src/f3dasm/_src/design/samplers.py @@ -0,0 +1,631 @@ +"""Base class for sampling methods""" + +# Modules +# ============================================================================= + +from __future__ import annotations + +# Standard +from itertools import product +from typing import Dict, Literal, Optional + +# Third-party +import numpy as np +import pandas as pd +from SALib.sample import latin as salib_latin +from SALib.sample import sobol_sequence + +# Locals +from ..core import Block, ExperimentData +from .domain import Domain + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= +# +# ============================================================================= + +# Utility functions +# ============================================================================= + + +def _stretch_samples(domain: Domain, samples: np.ndarray) -> np.ndarray: + """Stretch samples to their boundaries + + Parameters + ---------- + domain : Domain + domain object + samples : np.ndarray + samples to stretch + + Returns + ------- + np.ndarray + stretched samples + """ + for dim, param in enumerate(domain.input_space.values()): + samples[:, dim] = ( + samples[:, dim] * ( + param.upper_bound - param.lower_bound + ) + param.lower_bound + ) + + # If param.log is True, take the 10** of the samples + if param.log: + samples[:, dim] = 10**samples[:, dim] + + return samples + + +def sample_constant(domain: Domain, n_samples: int) -> np.ndarray: + """ + Sample the constant parameters. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + + Returns + ------- + np.ndarray + The sampled data. + """ + samples = np.array([param.value for param in domain.input_space.values()]) + return np.tile(samples, (n_samples, 1)) + + +def sample_np_random_choice( + domain: Domain, n_samples: int, + seed: Optional[int] = None, **kwargs) -> np.ndarray: + """ + Sample with numpy random choice. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + np.ndarray + The sampled data. + """ + rng = np.random.default_rng(seed) + samples = np.empty(shape=(n_samples, len(domain)), dtype=object) + for dim, param in enumerate(domain.input_space.values()): + samples[:, dim] = rng.choice( + param.categories, size=n_samples) + + return samples + + +def sample_np_random_choice_range( + domain: Domain, n_samples: int, + seed: Optional[int] = None, **kwargs) -> np.ndarray: + """ + Sample with numpy random choice within a range of values. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + np.ndarray + The sampled data. + """ + samples = np.empty(shape=(n_samples, len(domain)), dtype=np.int32) + rng = np.random.default_rng(seed) + for dim, param in enumerate(domain.input_space.values()): + samples[:, dim] = rng.choice( + range(param.lower_bound, + param.upper_bound + 1, param.step), + size=n_samples, + ) + + return samples + + +def sample_np_random_uniform( + domain: Domain, n_samples: int, + seed: Optional[int] = None, **kwargs) -> np.ndarray: + """ + Sample with numpy random uniform distribution. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + np.ndarray + The sampled data. + """ + rng = np.random.default_rng(seed) + samples = rng.uniform(low=0.0, high=1.0, size=(n_samples, len(domain))) + + # stretch samples + samples = _stretch_samples(domain, samples) + return samples + + +def sample_latin_hypercube( + domain: Domain, n_samples: int, + seed: Optional[int] = None, **kwargs) -> np.ndarray: + """ + Sample with Latin Hypercube sampling. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + np.ndarray + The sampled data. + """ + problem = { + "num_vars": len(domain), + "names": domain.input_names, + "bounds": [[s.lower_bound, s.upper_bound] + for s in domain.input_space.values()], + } + + samples = salib_latin.sample(problem=problem, N=n_samples, seed=seed) + return samples + + +def sample_sobol_sequence( + domain: Domain, n_samples: int, **kwargs) -> np.ndarray: + """ + Sample with Sobol sequence sampling. + + Parameters + ---------- + domain : Domain + The domain object containing the input space. + n_samples : int + The number of samples to generate. + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + np.ndarray + The sampled data. + """ + samples = sobol_sequence.sample(N=n_samples, D=len(domain)) + + # stretch samples + samples = _stretch_samples(domain, samples) + return samples + + +# Built-in samplers +# ============================================================================= + +class RandomUniform(Block): + def __init__(self, seed: Optional[int], **parameters): + """ + Initialize the RandomUniform sampler. + + Parameters + ---------- + seed : Optional[int] + The random seed. + **parameters : dict + Additional parameters for the sampler. + """ + self.seed = seed + self.parameters = parameters + + def call(self, data: ExperimentData, n_samples: int, **kwargs + ) -> ExperimentData: + """ + Sample data using the RandomUniform method. + + Parameters + ---------- + n_samples : int + The number of samples to generate. + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + pd.DataFrame + The sampled data. + """ + _continuous = sample_np_random_uniform( + domain=data.domain.continuous, n_samples=n_samples, + seed=self.seed) + + _discrete = sample_np_random_choice_range( + domain=data.domain.discrete, n_samples=n_samples, + seed=self.seed) + + _categorical = sample_np_random_choice( + domain=data.domain.categorical, n_samples=n_samples, + seed=self.seed) + + _constant = sample_constant(data.domain.constant, n_samples) + + df = pd.concat( + [pd.DataFrame(_continuous, + columns=data.domain.continuous.input_names), + pd.DataFrame( + _discrete, columns=data.domain.discrete.input_names), + pd.DataFrame( + _categorical, + columns=data.domain.categorical.input_names), + pd.DataFrame(_constant, + columns=data.domain.constant.input_names)], + axis=1 + )[data.domain.input_names] + + return type(data)(domain=data.domain, + input_data=df) + + +def random(seed: Optional[int] = None, **kwargs) -> Block: + """ + Create a RandomUniform sampler. + + Parameters + ---------- + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for the sampler. + + Returns + ------- + Block + An Block instance of a random uniform sampler. + """ + return RandomUniform(seed=seed, **kwargs) + + +# ============================================================================= + +class Grid(Block): + def __init__(self, **parameters): + """ + Initialize the Grid sampler. + + Parameters + ---------- + **parameters : dict + Additional parameters for the sampler. + """ + self.parameters = parameters + + def call(self, data: ExperimentData, + stepsize_continuous_parameters: + Optional[Dict[str, float] | float] = None, + **kwargs) -> ExperimentData: + """ + Sample data using the Grid method. + + Parameters + ---------- + stepsize_continuous_parameters : Optional[Dict[str, float] | float] + The step size for continuous parameters. + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + pd.DataFrame + The sampled data. + """ + continuous = data.domain.continuous + + if not continuous.input_space: + discrete_space = continuous.input_space + + elif isinstance(stepsize_continuous_parameters, (float, int)): + discrete_space = {name: param.to_discrete( + step=stepsize_continuous_parameters) + for name, param in continuous.input_space.items()} + + elif isinstance(stepsize_continuous_parameters, dict): + discrete_space = {key: continuous.input_space[key].to_discrete( + step=value) for key, + value in stepsize_continuous_parameters.items()} + + if len(discrete_space) != len(data.domain.continuous): + raise ValueError( + "If you specify the stepsize for continuous parameters, \ + the stepsize_continuous_parameters should \ + contain all continuous parameters") + + continuous_to_discrete = Domain(discrete_space) + + _iterdict = {} + + for k, v in data.domain.categorical.input_space.items(): + _iterdict[k] = v.categories + + for k, v, in data.domain.discrete.input_space.items(): + _iterdict[k] = range(v.lower_bound, v.upper_bound+1, v.step) + + for k, v, in continuous_to_discrete.input_space.items(): + _iterdict[k] = np.arange( + start=v.lower_bound, stop=v.upper_bound, step=v.step) + + for k, v, in data.domain.constant.input_space.items(): + _iterdict[k] = [v.value] + + df = pd.DataFrame(list(product(*_iterdict.values())), + columns=_iterdict, dtype=object + )[data.domain.input_names] + + return type(data)(domain=data.domain, + input_data=df) + + +def grid(**kwargs) -> Block: + """ + Create a Grid sampler. + + **kwargs : dict + Additional parameters for the sampler. + + Returns + ------- + Block + An Block instance of a grid sampler. + """ + return Grid(**kwargs) + +# ============================================================================= + + +class Sobol(Block): + def __init__(self, seed: Optional[int], **parameters): + """ + Initialize the Sobol sampler. + + Parameters + ---------- + n_samples : int + The number of samples to generate. + seed : Optional[int] + The random seed. + **parameters : dict + Additional parameters for the sampler. + """ + self.seed = seed + self.parameters = parameters + + def call(self, data: ExperimentData, n_samples: int, **kwargs + ) -> ExperimentData: + """ + Sample data using the Sobol method. + + Parameters + ---------- + n_samples : int + The number of samples to generate. + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + pd.DataFrame + The sampled data. + """ + _continuous = sample_sobol_sequence( + domain=data.domain.continuous, n_samples=n_samples) + + _discrete = sample_np_random_choice_range( + domain=data.domain.discrete, n_samples=n_samples, + seed=self.seed) + + _categorical = sample_np_random_choice( + domain=data.domain.categorical, n_samples=n_samples, + seed=self.seed) + + _constant = sample_constant( + domain=data.domain.constant, n_samples=n_samples) + + df = pd.concat( + [pd.DataFrame(_continuous, + columns=data.domain.continuous.input_names), + pd.DataFrame( + _discrete, columns=data.domain.discrete.input_names), + pd.DataFrame( + _categorical, + columns=data.domain.categorical.input_names), + pd.DataFrame(_constant, + columns=data.domain.constant.input_names)], + axis=1 + )[data.domain.input_names] + + return type(data)(domain=data.domain, + input_data=df) + + +def sobol(seed: Optional[int] = None, **kwargs) -> Block: + """ + Create a Sobol sampler. + + Parameters + ---------- + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for the sampler. + + Returns + ------- + Block + A Block instance of a sobol sequence sampler. + """ + return Sobol(seed=seed, **kwargs) + + +# ============================================================================= + +class Latin(Block): + def __init__(self, seed: Optional[int], **parameters): + """ + Initialize the Latin sampler. + + Parameters + ---------- + seed : Optional[int] + The random seed. + **parameters : dict + Additional parameters for the sampler. + """ + self.seed = seed + self.parameters = parameters + + def call(self, data: ExperimentData, n_samples: int, **kwargs + ) -> ExperimentData: + """ + Sample data using the Latin Hypercube method. + + Parameters + ---------- + n_samples : int + The number of samples to generate. + **kwargs : dict + Additional parameters for sampling. + + Returns + ------- + pd.DataFrame + The sampled data. + """ + _continuous = sample_latin_hypercube( + domain=data.domain.continuous, n_samples=n_samples, + seed=self.seed) + + _discrete = sample_np_random_choice_range( + domain=data.domain.discrete, n_samples=n_samples, + seed=self.seed) + + _categorical = sample_np_random_choice( + domain=data.domain.categorical, n_samples=n_samples, + seed=self.seed) + + _constant = sample_constant( + domain=data.domain.constant, n_samples=n_samples) + + df = pd.concat( + [pd.DataFrame(_continuous, + columns=data.domain.continuous.input_names), + pd.DataFrame( + _discrete, columns=data.domain.discrete.input_names), + pd.DataFrame( + _categorical, + columns=data.domain.categorical.input_names), + pd.DataFrame(_constant, + columns=data.domain.constant.input_names)], + axis=1 + )[data.domain.input_names] + + return type(data)(domain=data.domain, + input_data=df) + + +def latin(seed: Optional[int] = None, **kwargs) -> Block: + """ + Create a lating hypercube sampler. + + Parameters + ---------- + seed : Optional[int], optional + The random seed, by default None + **kwargs : dict + Additional parameters for the sampler. + + Returns + ------- + Block + An Block instance of a latin hypercube sampler. + """ + return Latin(seed=seed, **kwargs) + +# ============================================================================= + + +_SAMPLERS = [random, latin, sobol, grid] + +SAMPLER_MAPPING: Dict[str, Block] = { + sampler.__name__.lower(): sampler for sampler in _SAMPLERS} + +# Factory function +# ============================================================================= + + +def _sampler_factory(sampler: str | Block, **parameters) -> Block: + """ + Factory function for samplers + + Parameters + ---------- + sampler : str | Block + name of the sampler + + Returns + ------- + Block + sampler object + """ + + if isinstance(sampler, Block): + return sampler + + elif isinstance(sampler, str): + filtered_name = sampler.lower().replace( + ' ', '').replace('-', '').replace('_', '') + + if filtered_name in SAMPLER_MAPPING: + return SAMPLER_MAPPING[filtered_name](**parameters) + + else: + raise KeyError(f"Unknown sampler name: {sampler}") + + else: + raise TypeError(f"Unknown sampler type: {type(sampler)}") + + +SamplerNames = Literal['random', 'latin', 'sobol', 'grid'] diff --git a/src/f3dasm/_src/experimentdata.py b/src/f3dasm/_src/experimentdata.py new file mode 100644 index 00000000..ed0cf8f9 --- /dev/null +++ b/src/f3dasm/_src/experimentdata.py @@ -0,0 +1,1742 @@ +""" +The ExperimentData object is the main object used to store implementations + of a design-of-experiments, keep track of results, perform optimization and + extract data for machine learning purposes. +""" + +# Modules +# ============================================================================= + +from __future__ import annotations + +# Standard +import functools +from collections import defaultdict +from copy import copy +from functools import partial +from itertools import zip_longest +from pathlib import Path +from time import sleep +from typing import (Any, Callable, Dict, Iterable, Iterator, List, Literal, + Optional, Tuple, Type) + +# Third-party +import numpy as np +import pandas as pd +import xarray as xr +from filelock import FileLock +from hydra.utils import get_original_cwd +from omegaconf import DictConfig + +from ._io import (DOMAIN_FILENAME, EXPERIMENTDATA_SUBFOLDER, + INPUT_DATA_FILENAME, JOBS_FILENAME, LOCK_FILENAME, MAX_TRIES, + OUTPUT_DATA_FILENAME, _project_dir_factory, store_to_disk) +# Local +from .core import Block, DataGenerator +from .datageneration import _datagenerator_factory +from .design import Domain, _domain_factory, _sampler_factory +from .experimentsample import ExperimentSample +from .logger import logger +from .optimization import _optimizer_factory + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= +# +# ============================================================================= + + +class ExperimentData: + def __init__( + self, + domain: Optional[Domain] = None, + input_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + output_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + jobs: Optional[pd.Series] = None, + project_dir: Optional[Path] = None): + """ + Main object to store implementations of a design-of-experiments, keep + track of results, perform optimization and extract data for machine + learning purposes. + + Parameters + ---------- + domain : Domain, optional + The domain of the experiment, by default None. + input_data : pd.DataFrame | np.ndarray | List[Dict[str, Any]] | + str | Path, optional + The input data of the experiment, by default None. + output_data : pd.DataFrame | np.ndarray | List[Dict[str, Any]] | + str | Path, optional + The output data of the experiment, by default None. + jobs : pd.Series, optional + The status of all the jobs, by default None. + project_dir : Path, optional + Directory of the project, by default None. + + Examples + -------- + >>> experiment_data = ExperimentData( + ... domain=domain_obj, + ... input_data=input_df, + ... output_data=output_df + ... ) + """ + _domain = _domain_factory(domain) + _project_dir = _project_dir_factory(project_dir) + _jobs = jobs_factory(jobs) + + # If input_data is a numpy array, create pd.Dataframe to include column + # names from the domain + if isinstance(input_data, np.ndarray): + input_data = convert_numpy_to_dataframe_with_domain( + array=input_data, + names=_domain.input_names, + mode='input') + + # Same with output data + if isinstance(output_data, np.ndarray): + output_data = convert_numpy_to_dataframe_with_domain( + array=output_data, + names=_domain.output_names, + mode='output') + + _input_data = _dict_factory(data=input_data) + _output_data = _dict_factory(data=output_data) + + # If the domain is empty, try to infer it from the input_data + # and output_data + if not _domain: + _domain = Domain.from_data(input_data=_input_data, + output_data=_output_data) + + _data = data_factory( + input_data=_input_data, output_data=_output_data, + domain=_domain, jobs=_jobs, project_dir=_project_dir + ) + + self.data = _data + self.domain = _domain + self.project_dir = _project_dir + + # Store to_disk objects so that the references are kept only + for id, experiment_sample in self: + self.store_experimentsample(experiment_sample, id) + + def __len__(self): + """ + Returns the number of experiments in the ExperimentData object. + + Returns + ------- + int + Number of experiments. + + Examples + -------- + >>> experimentdata = ExperimentData(input_data=np.array([1, 2, 3]),) + >>> len(experiment_data) + 3 + """ + return len(self.data) + + def __iter__(self) -> Iterator[Tuple[int, ExperimentSample]]: + """ + Returns an iterator over the ExperimentData object. + + Returns + ------- + Iterator[Tuple[int, ExperimentSample]] + Iterator over the ExperimentData object. + + Examples + -------- + >>> for id, sample in experiment_data: + ... print(id, sample) + 0 ExperimentSample(...) + """ + return iter(self.data.items()) + + def __add__(self, __o: ExperimentData) -> ExperimentData: + """ + Adds two ExperimentData objects. + + Parameters + ---------- + __o : ExperimentData + The other ExperimentData object to add. + + Returns + ------- + ExperimentData + The combined ExperimentData object. + + Examples + -------- + >>> combined_data = experiment_data1 + experiment_data2 + """ + copy_self = copy(self).reset_index() + copy_self._add(__o) + return copy_self + + def __eq__(self, __o: ExperimentData) -> bool: + """ + Checks if two ExperimentData objects are equal. + + Parameters + ---------- + __o : ExperimentData + The other ExperimentData object to compare. + + Returns + ------- + bool + True if the objects are equal, False otherwise. + + Notes + ----- + Two ExperimentData objects are considered equal if their data, domain + and project_dir are equal. + + Examples + -------- + >>> experiment_data1 == experiment_data2 + True + """ + return (self.data == __o.data and self.domain == __o.domain + and self.project_dir == __o.project_dir) + + def __getitem__(self, key: int | Iterable[int]) -> ExperimentData: + """ + Gets a subset of the ExperimentData object. + + Parameters + ---------- + key : int or Iterable[int] + The indices to select. + + Returns + ------- + ExperimentData + The selected subset of the ExperimentData object. + + """ + if isinstance(key, int): + key = [key] + + return ExperimentData.from_data( + data={k: self.data[k] for k in self.index[key]}, + domain=self.domain, + project_dir=self.project_dir) + + def _repr_html_(self) -> str: + """ + Returns an HTML representation of the ExperimentData object. + + Returns + ------- + str + HTML representation of the ExperimentData object. + + Examples + -------- + >>> experiment_data._repr_html_() + '
...
' + """ + return self.to_multiindex()._repr_html_() + + def __repr__(self) -> str: + """ + Returns a string representation of the ExperimentData object. + + Returns + ------- + str + String representation of the ExperimentData object. + + Examples + -------- + >>> repr(experiment_data) + 'ExperimentData(...)' + """ + return self.to_multiindex().__repr__() + + def access_file(self, operation: Callable) -> Callable: + """ + Wrapper for accessing a single resource with a file lock. + + Parameters + ---------- + operation : Callable + The operation to be performed on the resource. + + Returns + ------- + Callable + The wrapped operation. + + Examples + -------- + >>> @experiment_data.access_file + ... def read_data(project_dir): + ... # read data from file + ... pass + """ + @functools.wraps(operation) + def wrapper_func(project_dir: Path, *args, **kwargs) -> None: + lock = FileLock( + (project_dir / EXPERIMENTDATA_SUBFOLDER / LOCK_FILENAME + ).with_suffix('.lock')) + + # If the lock has been acquired: + with lock: + tries = 0 + while tries < MAX_TRIES: + # try: + # print(f"{args=}, {kwargs=}") + # self = ExperimentData.from_file(project_dir) + # value = operation(*args, **kwargs) + # self.store() + # break + try: + # Load a fresh instance of ExperimentData from file + loaded_self = ExperimentData.from_file( + self.project_dir) + + # Call the operation with the loaded instance + # Replace the self in args with the loaded instance + # Modify the first argument + args = (loaded_self,) + args[1:] + value = operation(*args, **kwargs) + loaded_self.store() + break + + # Racing conditions can occur when the file is empty + # and the file is being read at the same time + except pd.errors.EmptyDataError: + tries += 1 + logger.debug(( + f"EmptyDataError occurred, retrying" + f" {tries+1}/{MAX_TRIES}")) + sleep(1) + + raise pd.errors.EmptyDataError() + + return value + + return partial(wrapper_func, project_dir=self.project_dir) + + # Properties + # ========================================================================= + + @property + def index(self) -> pd.Index: + """ + Returns an iterable of the job number of the experiments. + + Returns + ------- + pd.Index + The job number of all the experiments in pandas Index format. + + Examples + -------- + >>> experiment_data.index + Int64Index([0, 1, 2], dtype='int64') + """ + return pd.Index(self.data.keys()) + + @property + def jobs(self) -> pd.Series: + """ + Returns the status of all the jobs. + + Returns + ------- + pd.Series + The status of all the jobs. + + Examples + -------- + >>> experiment_data.jobs + 0 open + 1 finished + dtype: object + """ + return pd.Series({id: es.job_status.name for id, es in self}) + + # Alternative constructors + # ========================================================================= + + @classmethod + def from_file(cls: Type[ExperimentData], + project_dir: Path | str) -> ExperimentData: + """ + Create an ExperimentData object from .csv and .json files. + + Parameters + ---------- + project_dir : Path or str + User defined path of the experimentdata directory. + + Returns + ------- + ExperimentData + ExperimentData object containing the loaded data. + + Examples + -------- + >>> experiment_data = ExperimentData.from_file('path/to/project_dir') + """ + if isinstance(project_dir, str): + project_dir = Path(project_dir) + + try: + return _from_file_attempt(project_dir) + except FileNotFoundError: + try: + filename_with_path = Path(get_original_cwd()) / project_dir + except ValueError: # get_original_cwd() hydra initialization error + raise FileNotFoundError( + f"Cannot find the folder {project_dir} !") + + return _from_file_attempt(filename_with_path) + + @classmethod + def from_sampling(cls, sampler: Block | str, + domain: Domain | DictConfig | str | Path, + **kwargs): + """ + Create an ExperimentData object from a sampler. + + Parameters + ---------- + sampler : Block or str + Sampler object containing the sampling strategy or one of the + built-in sampler names. + domain : Domain or DictConfig + Domain object containing the domain of the experiment or hydra + DictConfig object containing the configuration. + **kwargs + Additional keyword arguments passed to the sampler. + + Returns + ------- + ExperimentData + ExperimentData object containing the sampled data. + + Examples + -------- + >>> experiment_data = ExperimentData.from_sampling('random', domain) + """ + data = cls(domain=domain) + data.sample(sampler=sampler, **kwargs) + return data + + @classmethod + def from_yaml(cls, config: DictConfig) -> ExperimentData: + """ + Create an ExperimentData object from a YAML configuration. + + Parameters + ---------- + config : DictConfig + Hydra DictConfig object containing the configuration. + + Returns + ------- + ExperimentData + ExperimentData object containing the loaded data. + + Examples + -------- + >>> experiment_data = ExperimentData.from_yaml(config) + """ + # Option 0: Both existing and sampling + if 'from_file' in config and 'from_sampling' in config: + return cls.from_file(config.from_file) + cls.from_sampling( + **config.from_sampling) + + # Option 1: From exisiting ExperimentData files + if 'from_file' in config: + return cls.from_file(config.from_file) + + # Option 2: Sample from the domain + if 'from_sampling' in config: + return cls.from_sampling(**config.from_sampling) + + else: + return cls(**config) + + @classmethod + def from_data(cls, data: Optional[Dict[int, ExperimentSample]] = None, + domain: Optional[Domain] = None, + project_dir: Optional[Path] = None) -> ExperimentData: + """ + Create an ExperimentData object from existing data. + + Parameters + ---------- + data : dict of int to ExperimentSample, optional + The existing data, by default None. + domain : Domain, optional + The domain of the data, by default None. + project_dir : Path, optional + The project directory, by default None. + + Returns + ------- + ExperimentData + ExperimentData object containing the loaded data. + + Examples + -------- + >>> experiment_data = ExperimentData.from_data(data, domain) + """ + if data is None: + data = {} + + if domain is None: + domain = Domain() + + experiment_data = cls() + + experiment_data.data = defaultdict(ExperimentSample, data) + experiment_data.domain = domain + experiment_data.project_dir = _project_dir_factory(project_dir) + return experiment_data + + # Selecting subsets + # ========================================================================= + + def select(self, indices: int | Iterable[int]) -> ExperimentData: + """ + Select a subset of the ExperimentData object. + + Parameters + ---------- + indices : int or Iterable[int] + The indices to select. + + Returns + ------- + ExperimentData + The selected subset of the ExperimentData object. + + Examples + -------- + >>> subset = experiment_data.select([0, 1, 2]) + """ + return self[indices] + + def select_with_status( + self, + status: Literal['open', 'in_progress', 'finished', 'error'] + ) -> ExperimentData: + """ + Select a subset of the ExperimentData object with a given status. + + Parameters + ---------- + status : {'open', 'in_progress', 'finished', 'error'} + The status to select. + + Returns + ------- + ExperimentData + The selected subset of the ExperimentData object with the given + status. + + Examples + -------- + >>> subset = experiment_data.select_with_status('finished') + """ + idx = [i for i, es in self if es.is_status(status)] + return self[idx] + + # Export + # ========================================================================= + + def store(self, project_dir: Optional[Path | str] = None): + """ + Write the ExperimentData to disk in the project directory. + + Parameters + ---------- + project_dir : Optional[Path | str], optional + The f3dasm project directory to store the + ExperimentData object to, by default None. + + Note + ---- + If no project directory is provided, the ExperimentData object is + stored in the directory provided by the `.project_dir` attribute that + is set upon creation of the object. + + The ExperimentData object is stored in a subfolder 'experiment_data'. + + The ExperimentData object is stored in four files: + + * the input data (`input.csv`) + * the output data (`output.csv`) + * the jobs (`jobs.csv`) + * the domain (`domain.json`) + + To avoid the ExperimentData to be written simultaneously by multiple + processes, a '.lock' file is automatically created + in the project directory. Concurrent process can only sequentially + access the lock file. This lock file is removed after the + ExperimentData object is written to disk. + """ + if project_dir is not None: + self.set_project_dir(project_dir) + + subdirectory = self.project_dir / EXPERIMENTDATA_SUBFOLDER + + # Create the experimentdata subfolder if it does not exist + subdirectory.mkdir(parents=True, exist_ok=True) + + df_input, df_output = self.to_pandas(keep_references=True) + + df_input.to_csv( + (subdirectory / INPUT_DATA_FILENAME).with_suffix('.csv')) + df_output.to_csv( + (subdirectory / OUTPUT_DATA_FILENAME).with_suffix('.csv')) + self.domain.store(subdirectory / DOMAIN_FILENAME) + self.jobs.to_csv((subdirectory / JOBS_FILENAME).with_suffix('.csv')) + + def to_numpy(self) -> Tuple[np.ndarray, np.ndarray]: + """ + Convert the ExperimentData object to a tuple of numpy arrays. + + Returns + ------- + tuple of np.ndarray + A tuple containing two numpy arrays, the first one for input + columns, and the second for output columns. + + Examples + -------- + >>> input_array, output_array = experiment_data.to_numpy() + """ + df_input, df_output = self.to_pandas(keep_references=False) + return df_input.to_numpy(), df_output.to_numpy() + + def to_pandas(self, keep_references: bool = False + ) -> Tuple[pd.DataFrame, pd.DataFrame]: + """ + Convert the ExperimentData object to pandas DataFrames. + + Parameters + ---------- + keep_references : bool, optional + If True, the references to the output data are kept, by default + False. + + Returns + ------- + tuple of pd.DataFrame + A tuple containing two pandas DataFrames, the first one for input + columns, and the second for output columns. + + Examples + -------- + >>> df_input, df_output = experiment_data.to_pandas() + """ + if keep_references: + return ( + pd.DataFrame([es._input_data for _, es in self], + index=self.index), + pd.DataFrame([es._output_data for _, es in self], + index=self.index) + ) + else: + return ( + pd.DataFrame([es.input_data for _, es in self], + index=self.index), + pd.DataFrame([es.output_data for _, es in self], + index=self.index) + ) + + def to_xarray(self, keep_references: bool = False) -> xr.Dataset: + """ + Convert the ExperimentData object to an xarray Dataset. + + Parameters + ---------- + keep_references : bool, optional + If True, the references to the output data are kept, by default + False. + + Returns + ------- + xr.Dataset + An xarray Dataset containing the data. + + Examples + -------- + >>> dataset = experiment_data.to_xarray() + """ + df_input, df_output = self.to_pandas(keep_references=keep_references) + + da_input = xr.DataArray(df_input, dims=['iterations', 'input_dim'], + coords={'iterations': self.index, + 'input_dim': df_input.columns}) + + da_output = xr.DataArray(df_output, dims=['iterations', 'output_dim'], + coords={'iterations': self.index, + 'output_dim': df_output.columns}) + + return xr.Dataset({'input': da_input, 'output': da_output}) + + # TODO: Implement this + def get_n_best_output(self, n_samples: int, + output_name: Optional[str] = 'y') -> ExperimentData: + """ + Get the n best samples from the output data. Lower values are better. + + Parameters + ---------- + n_samples : int + Number of samples to select. + output_name : str, optional + The name of the output column to sort by, by default 'y'. + + Returns + ------- + ExperimentData + New ExperimentData object with a selection of the n best samples. + + Examples + -------- + >>> best_samples = experiment_data.get_n_best_output(5) + """ + _, df_out = self.to_pandas() + indices = df_out.nsmallest(n=n_samples, columns=output_name).index + return self[indices] + + def to_multiindex(self) -> pd.DataFrame: + """ + Convert the ExperimentData object to a pandas DataFrame with a + MultiIndex. This is used for visualization purposes in a Jupyter + notebook environment. + + Returns + ------- + pd.DataFrame + A pandas DataFrame with a MultiIndex. + + Examples + -------- + >>> df_multiindex = experiment_data.to_multiindex() + """ + list_of_dicts = [sample.to_multiindex() for _, sample in self] + return pd.DataFrame(merge_dicts(list_of_dicts), index=self.index) + + # Append or remove data + # ========================================================================= + + def add_experiments(self, + data: ExperimentSample | ExperimentData + ) -> None: + """ + Add an ExperimentSample or ExperimentData to the ExperimentData + attribute. + + Parameters + ---------- + data : ExperimentSample or ExperimentData + Experiment(s) to add. + + Raises + ------ + ValueError + If the input is not an ExperimentSample or ExperimentData object. + + Examples + -------- + >>> experiment_data.add_experiments(new_sample) + >>> experiment_data.add_experiments(new_data) + """ + if isinstance(data, ExperimentSample): + self._add_experiment_sample(data) + + elif isinstance(data, ExperimentData): + self._add(data) + + else: + raise ValueError(( + f"The input to this function should be an ExperimentSample or " + f"ExperimentData object, not {type(data)} ") + ) + + # Not used + def overwrite( + self, indices: Iterable[int], + domain: Optional[Domain] = None, + input_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + output_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + jobs: Optional[Path | str] = None, + add_if_not_exist: bool = False + ) -> None: + """Overwrite the ExperimentData object. + + Parameters + ---------- + indices : Iterable[int] + The indices to overwrite. + domain : Optional[Domain], optional + Domain of the new object, by default None + input_data : Optional[DataTypes], optional + input parameters of the new object, by default None + output_data : Optional[DataTypes], optional + output parameters of the new object, by default None + jobs : Optional[Path | str], optional + jobs off the new object, by default None + add_if_not_exist : bool, optional + If True, the new objects are added if the requested indices + do not exist in the current ExperimentData object, by default False + """ + raise NotImplementedError() + + # Not used + def _overwrite_experiments( + self, indices: Iterable[int], + experiment_sample: ExperimentSample | ExperimentData, + add_if_not_exist: bool) -> None: + """ + Overwrite the ExperimentData object at the given indices. + + Parameters + ---------- + indices : Iterable[int] + The indices to overwrite. + experimentdata : ExperimentData | ExperimentSample + The new ExperimentData object to overwrite with. + add_if_not_exist : bool + If True, the new objects are added if the requested indices + do not exist in the current ExperimentData object. + """ + raise NotImplementedError() + + # Used in parallel mode + def overwrite_disk( + self, indices: Iterable[int], + domain: Optional[Domain] = None, + input_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + output_data: Optional[ + pd.DataFrame | np.ndarray + | List[Dict[str, Any]] | str | Path] = None, + jobs: Optional[Path | str] = None, + add_if_not_exist: bool = False + ) -> None: + """ + Overwrite experiments on disk with new data. + + Parameters + ---------- + indices : Iterable[int] + The indices of the experiments to overwrite. + domain : Domain, optional + The new domain, by default None. + input_data : pd.DataFrame | np.ndarray | List[Dict[str, Any]] | + str | Path, optional + The new input data, by default None. + output_data : pd.DataFrame | np.ndarray | List[Dict[str, Any]] | + str | Path, optional + The new output data, by default None. + jobs : Path | str, optional + The new jobs data, by default None. + add_if_not_exist : bool, optional + If True, add experiments if the indices do not exist, + by default False. + """ + raise NotImplementedError() + + def remove_rows_bottom(self, number_of_rows: int): + """ + Remove a number of rows from the end of the ExperimentData object. + + Parameters + ---------- + number_of_rows : int + Number of rows to remove from the bottom. + + Examples + -------- + >>> experiment_data.remove_rows_bottom(3) + """ + # remove the last n rows + for i in range(number_of_rows): + self.data.pop(self.index[-1]) + + def reset_index(self) -> ExperimentData: + """ + Reset the index of the ExperimentData object. + The index will be reset to a range from 0 to the number of experiments. + + Returns + ------- + ExperimentData + ExperimentData object with a reset index. + + Examples + -------- + >>> reset_data = experiment_data.reset_index() + """ + return ExperimentData.from_data( + data={i: v for i, v in enumerate(self.data.values())}, + domain=self.domain, + project_dir=self.project_dir) + + def join(self, experiment_data: ExperimentData) -> ExperimentData: + """ + Join two ExperimentData objects. + + Parameters + ---------- + experiment_data : ExperimentData + The other ExperimentData object to join with. + + Returns + ------- + ExperimentData + The joined ExperimentData object. + + Examples + -------- + >>> joined_data = experiment_data1.join(experiment_data2) + """ + copy_self = self.reset_index() + # TODO: Reset isnt necessary, only copy + copy_other = experiment_data.reset_index() + + for (i, es_self), (_, es_other) in zip(copy_self, copy_other): + copy_self.data[i] = es_self + es_other + + copy_self.domain += copy_other.domain + + return copy_self + + def _add(self, experiment_data: ExperimentData): + # copy and reset self + copy_other = experiment_data.reset_index() + + # Find the last key in my_dict + last_key = max(self.index) if self else -1 + + # Update keys of other dict + other_updated_data = { + last_key + 1 + i: v for i, v in enumerate( + copy_other.data.values())} + + self.data.update(other_updated_data) + self.domain += copy_other.domain + + def _add_experiment_sample(self, experiment_sample: ExperimentSample): + last_key = max(self.index) if self else -1 + self.data[last_key + 1] = experiment_sample + + def _overwrite(self, experiment_data: ExperimentData, + indices: Iterable[int], + add_if_not_exist: bool = False): + if len(indices) != len(experiment_data): + raise ValueError(( + f"The number of indices ({len(indices)}) must match the number" + f"of experiments ({len(experiment_data)}).") + ) + copy_other = experiment_data.reset_index() + + for (_, es), id in zip(copy_other, indices): + self.data[id] = es + + self.domain += copy_other.domain + + def replace_nan(self, value: Any): + """ + Replace all NaN values in the output data with the given value. + + Parameters + ---------- + value : Any + The value to replace NaNs with. + + Examples + -------- + >>> experiment_data.replace_nan(0) + """ + for _, es in self: + es.replace_nan(value) + + def round(self, decimals: int): + """ + Round all output data to the given number of decimals. + + Parameters + ---------- + decimals : int + Number of decimals to round to. + + Examples + -------- + >>> experiment_data.round(2) + """ + for _, es in self: + es.round(decimals) + + def sort(self, criterion: Callable[[ExperimentSample], Any], + reverse: bool = False) -> ExperimentData: + """ + Sort the ExperimentData object based on a criterion. + + Parameters + ---------- + criterion : Callable[[ExperimentSample], Any] + The criterion to sort on. This should be a function that takes an + ExperimentSample object and returns a value to sort on. + reverse : bool, optional + If True, sort in descending order, by default False. + + Returns + ------- + ExperimentData + The sorted ExperimentData object. + + Examples + -------- + >>> sorted_data = experiment_data.sort(lambda x: x.output_data['y']) + """ + + sorted_data = dict( + sorted(self.data.items(), key=lambda item: criterion( + item[1]), reverse=reverse) + ) + return ExperimentData.from_data( + data=sorted_data, + domain=self.domain, + project_dir=self.project_dir + ) + + # ExperimentSample + # ========================================================================= + + def get_experiment_sample(self, id: int) -> ExperimentSample: + """ + Gets the experiment_sample at the given index. + + Parameters + ---------- + id : int + The index of the experiment_sample to retrieve. + + Returns + ------- + ExperimentSample + The ExperimentSample at the given index. + + Examples + -------- + >>> sample = experiment_data.get_experiment_sample(0) + """ + return self.data[id] + + def store_experimentsample( + self, experiment_sample: ExperimentSample, id: int): + """ + Store an ExperimentSample object in the ExperimentData object. + + Parameters + ---------- + experiment_sample : ExperimentSample + The ExperimentSample object to store. + id : int + The index of the ExperimentSample object. + + Examples + -------- + >>> experiment_data.store_experimentsample(sample, 0) + """ + self.domain += experiment_sample.domain + + for name, value in experiment_sample._output_data.items(): + + # # If the output parameter is not in the domain, add it + # if name not in self.domain.output_names: + # self.domain.add_output(name=name, to_disk=True) + + parameter = self.domain.output_space[name] + + # If the parameter is to be stored on disk, store it + # Also check if the value is not already a reference! + if parameter.to_disk and not isinstance(value, (Path, str)): + storage_location = store_to_disk( + project_dir=self.project_dir, object=value, name=name, + id=id, store_function=parameter.store_function) + + experiment_sample._output_data[name] = Path(storage_location) + + for name, value in experiment_sample._input_data.items(): + parameter = self.domain.input_space[name] + + # If the parameter is to be stored on disk, store it + # Also check if the value is not already a reference! + if parameter.to_disk and not isinstance(value, (Path, str)): + storage_location = store_to_disk( + project_dir=self.project_dir, object=value, name=name, + id=id, store_function=parameter.store_function) + + experiment_sample._input_data[name] = Path(storage_location) + + # Set the experiment sample in the ExperimentData object + self.data[id] = experiment_sample + + # Used in parallel mode + + def get_open_job(self) -> Tuple[int, ExperimentSample]: + """ + Get the first open job in the ExperimentData object. + + Returns + ------- + tuple of int and ExperimentSample + The index and ExperimentSample of the first open job. + + Notes + ----- + This function iterates over the ExperimentData object and returns the + first open job. If no open jobs are found, it returns None. + + The returned open job is marked as 'in_progress'. + + Examples + -------- + >>> job_id, job_sample = experiment_data.get_open_job() + """ + for id, es in self: + if es.is_status('open'): + es.mark('in_progress') + return id, es + + return None, ExperimentSample() + + # Jobs + # ========================================================================= + + def is_all_finished(self) -> bool: + """ + Check if all jobs are finished. + + Returns + ------- + bool + True if all jobs are finished, False otherwise. + + Examples + -------- + >>> experiment_data.is_all_finished() + True + """ + return all(es.is_status('finished') for _, es in self) + + def mark(self, indices: int | Iterable[int], + status: Literal['open', 'in_progress', 'finished', 'error']): + """ + Mark the jobs at the given indices with the given status. + + Parameters + ---------- + indices : int or Iterable[int] + Indices of the jobs to mark. + status : {'open', 'in_progress', 'finished', 'error'} + Status to mark the jobs with. + + Raises + ------ + ValueError + If the given status is not valid. + + Examples + -------- + >>> experiment_data.mark([0, 1], 'finished') + """ + if isinstance(indices, int): + indices = [indices] + for i in indices: + self.data[i].mark(status) + + def mark_all(self, + status: Literal['open', 'in_progress', 'finished', 'error']): + """ + Mark all the experiments with the given status. + + Parameters + ---------- + status : {'open', 'in_progress', 'finished', 'error'} + Status to mark the jobs with. + + Raises + ------ + ValueError + If the given status is not valid. + + Examples + -------- + >>> experiment_data.mark_all('finished') + """ + for _, es in self: + es.mark(status) + + def run(self, block: Block | Iterable[Block], **kwargs) -> ExperimentData: + """ + Run a block over the entire ExperimentData object. + + Parameters + ---------- + block : Block + The block(s) to run. + **kwargs + Additional keyword arguments passed to the block. + + Returns + ------- + ExperimentData + The ExperimentData object after running the block. + + Examples + -------- + >>> experiment_data.run(block) + """ + if isinstance(block, Block): + block = [block] + + for b in block: + b.arm(data=self) + self = b.call(data=self, **kwargs) + + return self + + # Datageneration + # ========================================================================= + + def evaluate(self, data_generator: Block | str, + mode: Literal['sequential', 'parallel', + 'cluster', 'cluster_parallel'] = 'sequential', + output_names: Optional[List[str]] = None, + **kwargs) -> None: + """ + Run any function over the entirety of the experiments. + + Parameters + ---------- + data_generator : DataGenerator + Data generator to use. + mode : {'sequential', 'parallel', 'cluster', 'cluster_parallel'}, + optional + Operational mode, by default 'sequential'. + output_names : list of str, optional + Names of the output parameters, by default None. + **kwargs + Additional keyword arguments passed to the data generator. + + Raises + ------ + ValueError + If an invalid parallelization mode is specified. + + Examples + -------- + >>> experiment_data.evaluate(data_generator, mode='parallel') + """ + # Create + data_generator = _datagenerator_factory( + data_generator=data_generator, output_names=output_names, **kwargs) + + self = self.run(block=data_generator, mode=mode, **kwargs) + + # Optimization + # ========================================================================= + + def optimize(self, optimizer: Block | str, + data_generator: DataGenerator | str, + iterations: int, + kwargs: Optional[Dict[str, Any]] = None, + hyperparameters: Optional[Dict[str, Any]] = None, + x0_selection: Literal['best', 'random', + 'last', + 'new'] | ExperimentData = 'best', + sampler: Optional[Block | str] = 'random', + overwrite: bool = False) -> None: + """ + Optimize the ExperimentData object. + + Parameters + ---------- + optimizer : Block or str or Callable + Optimizer object. + data_generator : DataGenerator or str + DataGenerator object. + iterations : int + Number of iterations. + kwargs : dict, optional + Additional keyword arguments passed to the DataGenerator. + hyperparameters : dict, optional + Additional keyword arguments passed to the optimizer. + x0_selection : {'best', 'random', 'last', 'new'} or ExperimentData + How to select the initial design, by default 'best'. + sampler : Block or str, optional + Sampler to use if x0_selection is 'new', by default 'random'. + overwrite : bool, optional + If True, the optimizer will overwrite the current data, by default + False. + + Raises + ------ + ValueError + If an invalid x0_selection is specified. + + Examples + -------- + >>> experiment_data.optimize(optimizer, data_generator, iterations=10) + """ + if kwargs is None: + kwargs = {} + + # Create the data generator object if a string reference is passed + if isinstance(data_generator, str): + data_generator = _datagenerator_factory( + data_generator=data_generator, **kwargs) + + if hyperparameters is None: + hyperparameters = {} + + # Create the optimizer object if a string reference is passed + if isinstance(optimizer, str): + optimizer = _optimizer_factory( + optimizer=optimizer, **hyperparameters) + + # Create the sampler object if a string reference is passed + if isinstance(sampler, str): + sampler = _sampler_factory(sampler=sampler) + + population = optimizer.population if hasattr( + optimizer, 'population') else 1 + + opt_type = optimizer.type if hasattr(optimizer, 'type') else None + + if iterations < population and x0_selection == 'new': + raise ValueError( + f'For creating new samples, the total number of ' + f'requested iterations ({iterations}) cannot be ' + f'smaller than the population size ' + f'({population})') + + x0 = x0_factory(experiment_data=self, mode=x0_selection, + n_samples=population, sampler=sampler) + + x0.evaluate(data_generator=data_generator, mode='sequential', + **kwargs) + + if len(x0) < population: + raise ValueError(( + f"There are {len(self.data)} samples available, " + f"need {population} for initial population!" + )) + + optimizer.arm(data=x0) + data_generator.arm(data=x0) + + x = x0 + + n_updates = range( + iterations // population + (iterations % population > 0)) + + if opt_type == 'scipy': + # Scipy optimizers work differently since they are not + # able to output a single update step + optimizer._iterate(data=x, data_generator=data_generator, + iterations=iterations, kwargs=kwargs, + overwrite=overwrite) + else: + for _ in n_updates: + if overwrite: + x = optimizer.call(data=x, + grad_fn=data_generator.dfdx) + else: + x += optimizer.call(data=x, + grad_fn=data_generator.dfdx) + x = data_generator.call(data=x, mode='sequential', + **kwargs) + + if not overwrite: + x.remove_rows_bottom( + number_of_rows=population * n_updates.stop - iterations) + self._add(experiment_data=x[population:]) + + else: + self._add(experiment_data=x) + + # Sampling + # ========================================================================= + + def sample(self, sampler: Block | str, **kwargs) -> None: + """ + Sample data from the domain providing the sampler strategy + + Parameters + ---------- + sampler: BlockAbstract | str + Sampler callable or string of built-in sampler + If a string is passed, it should be one of the built-in samplers: + + * 'random' : Random sampling + * 'latin' : Latin Hypercube Sampling + * 'sobol' : Sobol Sequence Sampling + * 'grid' : Grid Search Sampling + + Note + ---- + When using the 'grid' sampler, an optional argument + 'stepsize_continuous_parameters' can be passed to specify the stepsize + to cast continuous parameters to discrete parameters. + + - The stepsize should be a dictionary with the parameter names as keys\ + and the stepsize as values. + - Alternatively, a single stepsize can be passed for all continuous\ + parameters. + + Raises + ------ + ValueError + Raised when invalid sampler type is specified + """ + + # Creation + sampler = _sampler_factory(sampler=sampler, **kwargs) + + samples = self.run(block=sampler, **kwargs) + + self._add(samples) + + # Project directory + # ========================================================================= + + def set_project_dir(self, project_dir: Path | str): + """Set the directory of the f3dasm project folder. + + Parameters + ---------- + project_dir : Path or str + Path to the project directory + """ + self.project_dir = _project_dir_factory(project_dir) + + def remove_lockfile(self): + """ + Remove the lock file from the project directory + + Note + ---- + The lock file is automatically created when the ExperimentData object + is written to disk. Concurrent processes can only sequentially access + the lock file. This lock file is removed after the ExperimentData + object is written to disk. + + Examples + -------- + >>> experiment_data.remove_lockfile() + """ + (self.project_dir / EXPERIMENTDATA_SUBFOLDER / LOCK_FILENAME + ).with_suffix('.lock').unlink(missing_ok=True) + + +# ============================================================================= + + +def x0_factory(experiment_data: ExperimentData, + mode: str | ExperimentData, n_samples: int, + sampler: Block) -> ExperimentData: + """Set the initial population to the best n samples of the given data + + Parameters + ---------- + experiment_data : ExperimentData + Data to be used for the initial population + mode : str + Mode of selecting the initial population, by default 'best' + The following modes are available: + + - best: select the best n samples + - new: create n new samples + - random: select n random samples + - last: select the last n samples + n_samples : int + Number of samples to select + sampler : Block or str + Sampler object containing the sampling strategy or one of the + built-in sampler names. Used if mode is 'new' + + Returns + ------- + ExperimentData + Initial population of the optimization + + Raises + ------ + ValueError + Raises when the mode is not recognized + """ + if isinstance(mode, ExperimentData): + x0 = mode + + if mode == 'new': + x0 = ExperimentData.from_sampling( + sampler=sampler, + domain=experiment_data.domain, + n_samples=n_samples + ) + + elif mode == 'best': + x0 = experiment_data.get_n_best_output(n_samples) + + elif mode == 'random': + x0 = experiment_data.select( + np.random.choice( + experiment_data.index, + size=n_samples, replace=False)) + + elif mode == 'last': + x0 = experiment_data.select( + experiment_data.index[-n_samples:]) + + else: + raise ValueError( + f'Unknown selection mode {mode}, use best, random or last') + + return x0.reset_index() + + +def _from_file_attempt(project_dir: Path) -> ExperimentData: + """Attempt to create an ExperimentData object + from .csv and .pkl files. + + Parameters + ---------- + path : Path + Name of the user-defined directory where the files are stored. + + Returns + ------- + ExperimentData + ExperimentData object containing the loaded data. + + Raises + ------ + FileNotFoundError + If the files cannot be found. + """ + subdirectory = project_dir / EXPERIMENTDATA_SUBFOLDER + + try: + return ExperimentData(domain=subdirectory / DOMAIN_FILENAME, + input_data=subdirectory / INPUT_DATA_FILENAME, + output_data=subdirectory / OUTPUT_DATA_FILENAME, + jobs=subdirectory / JOBS_FILENAME, + project_dir=project_dir) + + except FileNotFoundError: + raise FileNotFoundError( + f"Cannot find the files from {subdirectory}.") + + +def convert_numpy_to_dataframe_with_domain( + array: np.ndarray, names: Optional[List[str]], + mode: Literal['input', 'output'] +) -> pd.DataFrame: + """ + Convert a numpy array to a pandas DataFrame with the domain names + + Parameters + ---------- + array : np.ndarray + The numpy array to be converted + names : List[str], optional + The names of the columns, by default None + mode : str + The mode of the data, either 'input' or 'output' + + Returns + ------- + pd.DataFrame + The converted data as a pandas DataFrame + """ + if not names: + if mode == 'input': + names = [f'x{i}' for i in range(array.shape[1])] + elif mode == 'output': + if array.shape[1] == 1: + names = ['y'] + else: + names = [f'y{i}' for i in range(array.shape[1])] + + else: + raise ValueError( + f"Unknown mode {mode}, use 'input' or 'output'") + + return pd.DataFrame(array, columns=names) + + +def merge_dicts(list_of_dicts): + merged_dict = defaultdict(list) + + # Get all unique keys from all dictionaries + all_keys = sorted({key for d in list_of_dicts for key in d}) + + # Define the desired order for the first element of the tuple + order = {'jobs': 0, 'input': 1, 'output': 2} + + # Sort the keys first by the defined order then alphabetically within + # each group + sorted_keys = sorted(all_keys, key=lambda k: ( + order.get(k[0], float('inf')), k)) + + # Iterate over each dictionary and insert None for missing keys + for d in list_of_dicts: + for key in sorted_keys: + # Use None for missing keys + merged_dict[key].append(d.get(key, None)) + + return dict(merged_dict) + + +def _dict_factory(data: pd.DataFrame | List[Dict[str, Any]] | None | Path | str + ) -> List[Dict[str, Any]]: + """ + Convert the DataTypes to a list of dictionaries + + Parameters + ---------- + data : pd.DataFrame | List[Dict[str, Any]] | None | Path | str + The data to be converted + + Returns + ------- + List[Dict[str, Any]] + The converted data as a list of dictionaries + + Raises + ------ + ValueError + Raised when the data type is not supported + + Notes + ----- + If the data is None, an empty list is returned. + """ + if data is None: + return [] + + elif isinstance(data, (Path, str)): + return _dict_factory(pd.read_csv( + Path(data).with_suffix('.csv'), + header=0, index_col=0)) + + # check if data is already a list of dicts + elif isinstance(data, list) and all(isinstance(d, dict) for d in data): + return data + + # If the data is a pandas DataFrame, convert it to a list of dictionaries + # Note : itertuples() is faster but renames the column names + elif isinstance(data, pd.DataFrame): + return [row.to_dict() for _, row in data.iterrows()] + + raise ValueError(f"Data type {type(data)} not supported") + + +def data_factory(input_data: List[Dict[str, Any]], + output_data: List[Dict[str, Any]], + domain: Domain, + jobs: pd.Series, + project_dir: Path, + ) -> Dict[int, ExperimentSample]: + """ + Convert the input and output data to a defaultdictionary + of ExperimentSamples + + Parameters + ---------- + input_data : List[Dict[str, Any]] + The input data of the experiments + output_data : List[Dict[str, Any]] + The output data of the experiments + domain : Domain + The domain of the data + jobs : pd.Series + The status of all the jobs + project_dir : Path + The project directory of the data + + + Returns + ------- + Dict[int, ExperimentSample] + The converted data as a defaultdict of ExperimentSamples + + """ + # remove all key-value pairs that have a None or np.nan value + input_data = remove_nan_and_none_keys_inplace(input_data) + output_data = remove_nan_and_none_keys_inplace(output_data) + # Combine the two lists into a dictionary of ExperimentSamples + data = {index: ExperimentSample(input_data=experiment_input, + output_data=experiment_output, + domain=domain, + job_status=job_status, + project_dir=project_dir) + for index, (experiment_input, experiment_output, job_status) in + enumerate(zip_longest(input_data, output_data, jobs))} + + return defaultdict(ExperimentSample, data) + + +def remove_nan_and_none_keys_inplace(data_list: List[Dict[str, Any]]) -> None: + for data in data_list: + keys_to_remove = [k for k, v in data.items() if v is None or ( + isinstance(v, float) and np.isnan(v))] + for k in keys_to_remove: + del data[k] + + return data_list + + +def jobs_factory(jobs: pd.Series | str | Path | None) -> pd.Series: + if isinstance(jobs, pd.Series): + return jobs + + elif jobs is None: + return pd.Series() + + elif isinstance(jobs, (Path, str)): + df = pd.read_csv( + Path(jobs).with_suffix('.csv'), + header=0, index_col=0).squeeze() + # If the jobs is jut one value, it is parsed as a string + # So, make sure that we return a pd.Series either way! + if not isinstance(df, pd.Series): + df = pd.Series(df) + + return df + + else: + raise ValueError(f"Jobs type {type(jobs)} not supported") diff --git a/src/f3dasm/_src/experimentdata/__init__.py b/src/f3dasm/_src/experimentdata/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/f3dasm/_src/experimentdata/_columns.py b/src/f3dasm/_src/experimentdata/_columns.py deleted file mode 100644 index 76a3f474..00000000 --- a/src/f3dasm/_src/experimentdata/_columns.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -The _Columns class is used to order and track the parameter names of the data -columns. This class is not intended to be used directly by the user. -It is used by the _Data class to provide an interface to datatypes that do not -have a column structure, such as numpy arrays. - -Note ----- - -For the default back-end of _Data, this class is obsolete since pandas -DataFrames have a column structure. However, this class is intended to be a -uniform interface to data that does not have a column structure. -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from typing import Dict, List, Optional - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -class _Columns: - def __init__(self, columns: Optional[Dict[str, None]] = None): - """Class that keeps track of the names and order of parameters - in the raw data. - - Parameters - ---------- - columns: Dict[str, None], optional - dictionary with names as column names and None as values - , by default None - - Note - ---- - The datatype of a dict with nonsensical values is used to prevent - duplicate keys. This is because the dict is used as a set. - """ - if columns is None: - columns = {} - - self.columns: Dict[str, None] = columns - - def __repr__(self) -> str: - """Representation of the _Columns object.""" - return self.columns.keys().__repr__() - - def __add__(self, __o: _Columns) -> _Columns: - """Add two _Columns objects. - - Parameters - ---------- - __o: _Columns - _Columns object to add - - Returns - ------- - _Columns - _Columns object with the columns of both _Columns objects - """ - return _Columns({**self.columns, **__o.columns}) - - @property - def names(self) -> List[str]: - """List of the names of the columns. - - Returns - ------- - List[str] - list of the names of the columns - """ - return list(self.columns.keys()) - - def add(self, name: str): - """Add a column to the _Columns object. - - Parameters - ---------- - name: str - name of the column to add - """ - self.columns[name] = None - - def iloc(self, name: str | List[str]) -> List[int]: - """Get the index of a column. - - Parameters - ---------- - name: str | List[str] - name of the column(s) to get the index of - - Returns - ------- - List[int] - list of the indices of the columns - """ - if isinstance(name, str): - name = [name] - - _indices = [] - for n in name: - _indices.append(self.names.index(n)) - return _indices - - def rename(self, old_name: str, new_name: str): - """Replace the name of a column. - - Parameters - ---------- - old_name: str - name of the column to replace - new_name: str - name of the column to replace with - """ - self.columns[new_name] = self.columns.pop(old_name) diff --git a/src/f3dasm/_src/experimentdata/_data.py b/src/f3dasm/_src/experimentdata/_data.py deleted file mode 100644 index 3ca3c5b2..00000000 --- a/src/f3dasm/_src/experimentdata/_data.py +++ /dev/null @@ -1,585 +0,0 @@ -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from copy import deepcopy -from pathlib import Path -from typing import (Any, Dict, Iterable, Iterator, List, Optional, Tuple, Type, - Union) - -# Third-party -import numpy as np -import pandas as pd -import xarray as xr - -# Local -from ..design.domain import Domain -from ._columns import _Columns - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -class _Data: - def __init__(self, data: Optional[pd.DataFrame] = None, - columns: Optional[_Columns] = None): - if data is None: - data = pd.DataFrame() - - if columns is None: - columns = _Columns({col: None for col in data.columns}) - - self.columns: _Columns = columns - self.data = data.rename( - columns={name: i for i, name in enumerate(data.columns)}) - - def __len__(self): - """The len() method returns the number of datapoints""" - return len(self.data) - - def __iter__(self) -> Iterator[Tuple[Dict[str, Any]]]: - self.current_index = 0 - return self - - def __next__(self): - if self.current_index >= len(self): - raise StopIteration - else: - index = self.data.index[self.current_index] - current_value = self.get_data_dict(index) - self.current_index += 1 - return current_value - - def __getitem__(self, index: int | Iterable[int]) -> _Data: - """Get a subset of the data. - - Parameters - ---------- - index : int, list - The index of the data to get. - - Returns - ------- - A subset of the data. - """ - # If the object is empty, return itself - if self.is_empty(): - return self - - if isinstance(index, int): - index = [index] - return _Data(data=self.data.loc[index].copy(), - columns=self.columns) - - def __add__(self, other: _Data | Dict[str, Any]) -> _Data: - """Add two Data objects together. - - Parameters - ---------- - other : Data - The Data object to add. - - Returns - ------- - The sum of the two Data objects. - """ - # If other is a dictionary, convert it to a _Data object - if isinstance(other, Dict): - other = _convert_dict_to_data(other) - - try: - last_index = self.data.index[-1] - except IndexError: # Empty DataFrame - # Make a copy of other.data - return _Data(data=other.data.copy(), columns=other.columns) - - # Make a copy of other.data and modify its index - other_data_copy = other.data.copy() - other_data_copy.index = other_data_copy.index + last_index + 1 - return _Data(pd.concat( - [self.data, other_data_copy]), columns=self.columns) - - def __eq__(self, __o: _Data) -> bool: - try: - pd.testing.assert_frame_equal(self.data, __o.data) - except AssertionError: - return False - - return True - - def _repr_html_(self) -> str: - return self.to_dataframe()._repr_html_() - -# Properties -# ============================================================================= - - @property - def indices(self) -> pd.Index: - return self.data.index - - @property - def names(self) -> List[str]: - return self.columns.names - -# Alternative constructors -# ============================================================================= - - @classmethod - def from_indices(cls, indices: pd.Index) -> _Data: - """Create a Data object from a list of indices. - - Parameters - ---------- - indices : pd.Index - The indices of the data. - - Returns - ------- - Empty data object with indices - """ - return cls(pd.DataFrame(index=indices)) - - @classmethod - def from_domain(cls, domain: Domain) -> _Data: - """Create a Data object from a domain. - - Parameters - ---------- - design - _description_ - - Returns - ------- - _description_ - """ - _dtypes = {index: parameter._type for index, - (_, parameter) in enumerate(domain.space.items())} - - df = pd.DataFrame(columns=range(len(domain))).astype(_dtypes) - - # Set the categories tot the categorical parameters - for index, (name, categorical_input) in enumerate( - domain.categorical.space.items()): - df[index] = pd.Categorical( - df[index], categories=categorical_input.categories) - - _columns = {name: None for name in domain.names} - return cls(df, columns=_Columns(_columns)) - - @classmethod - def from_file(cls, filename: Path | str) -> _Data: - """Loads the data from a file. - - Parameters - ---------- - filename : Path - The filename to load the data from. - """ - file = Path(filename).with_suffix('.csv') - df = pd.read_csv(file, header=0, index_col=0) - _columns = {name: False for name in df.columns.to_list()} - # Reset the columns to be consistent - df.columns = range(df.columns.size) - return cls(df, columns=_Columns(_columns)) - - @classmethod - def from_numpy(cls: Type[_Data], array: np.ndarray) -> _Data: - """Loads the data from a numpy array. - - Parameters - ---------- - array : np.ndarray - The numpy array to load the data from. - data_type : str - """ - return cls(pd.DataFrame(array)) - - @classmethod - def from_dataframe(cls, dataframe: pd.DataFrame) -> _Data: - """Loads the data from a dataframe. - - Parameters - ---------- - dataframe : pd.DataFrame - The dataframe to load the data from. - """ - _columns = {name: None for name in dataframe.columns.to_list()} - return cls(dataframe, columns=_Columns(_columns)) - - def reset(self, domain: Optional[Domain] = None): - """Resets the data to the initial state. - - Parameters - ---------- - domain : Domain, optional - The domain of the experiment. - - Note - ---- - If the domain is None, the data will be reset to an empty dataframe. - """ - - if domain is None: - self.data = pd.DataFrame() - self.columns = _Columns() - else: - self.data = self.from_domain(domain).data - self.columns = self.from_domain(domain).columns - -# Export -# ============================================================================= - - def to_numpy(self) -> np.ndarray: - """Export the _Data object to a numpy array. - - Returns - ------- - np.ndarray - numpy array with the data. - """ - return self.data.to_numpy(dtype=np.float32) - - def to_xarray(self, label: str) -> xr.DataArray: - """Export the _Data object to a xarray DataArray. - - Parameters - ---------- - label : str - The name of the data. - - Returns - ------- - xr.DataArray - xarray DataArray with the data. - """ - return xr.DataArray(self.data, dims=['iterations', label], coords={ - 'iterations': self.indices, label: self.names}) - - def to_dataframe(self) -> pd.DataFrame: - """Export the _Data object to a pandas DataFrame. - - Returns - ------- - pd.DataFrame - pandas dataframe with the data. - """ - df = deepcopy(self.data) - df.columns = self.names - return df.astype(object) - - def combine_data_to_multiindex(self, other: _Data, - jobs_df: pd.DataFrame) -> pd.DataFrame: - """Combine the data to a multiindex dataframe. - - Parameters - ---------- - other : _Data - The other data to combine. - jobs : pd.DataFrame - The jobs dataframe. - - Returns - ------- - pd.DataFrame - The combined dataframe. - - Note - ---- - This function is mainly used to show the combined ExperimentData - object in a Jupyter Notebook - """ - return pd.concat([jobs_df, self.to_dataframe(), - other.to_dataframe()], - axis=1, keys=['jobs', 'input', 'output']) - - def store(self, filename: Path) -> None: - """Stores the data to a file. - - Parameters - ---------- - filename : Path - The filename to store the data to. - - Note - ---- - The data is stored as a csv file. - """ - # TODO: The column information is not saved in the .csv! - self.to_dataframe().to_csv(filename.with_suffix('.csv')) - - def n_best_samples(self, nosamples: int, - column_name: List[str] | str) -> pd.DataFrame: - """Returns the n best samples. We consider to be lower values better. - - Parameters - ---------- - nosamples : int - The number of samples to return. - column_name : List[str] | str - The column name(s) to sort on. If this is a list; priority will \ - be given on the first entry. - - Returns - ------- - pd.DataFrame - The n best samples. - """ - return self.data.nsmallest( - n=nosamples, columns=self.columns.iloc(column_name)) - - def select_columns(self, columns: Iterable[str] | str) -> _Data: - """Filter the data on the selected columns. - - Parameters - ---------- - columns : Iterable[str] | str - The columns to select. - - Returns - ------- - _Data - The data only with the selected columns - """ - # This is necessary otherwise self.data[columns] will be a Series - if isinstance(columns, str): - columns = [columns] - _selected_columns = _Columns( - {column: self.columns.columns[column] for column in columns}) - return _Data( - self.data[self.columns.iloc(columns)], columns=_selected_columns) - - def drop(self, columns: Iterable[str] | str) -> _Data: - """Drop the selected columns from the data. - - Parameters - ---------- - columns : Iterable[str] | str - The columns to drop. - - Returns - ------- - _Data - The data without the selected columns - """ - if isinstance(columns, str): - columns = [columns] - _selected_columns = _Columns( - { - name: None for name in self.columns.columns - if name not in columns}) - return _Data( - data=self.data.drop(columns=self.columns.iloc(columns)), - columns=_selected_columns) - -# Append and remove data -# ============================================================================= - - def add(self, data: pd.DataFrame): - try: - last_index = self.data.index[-1] - except IndexError: # Empty dataframe - self.data = data - return - - new_indices = pd.RangeIndex( - start=last_index + 1, stop=last_index + len(data) + 1, step=1) - - # set the indices of the data to new_indices - data.index = new_indices - - self.data = pd.concat([self.data, data], ignore_index=False) - - def add_empty_rows(self, number_of_rows: int): - if self.data.index.empty: - last_index = -1 - else: - last_index = self.data.index[-1] - - new_indices = pd.RangeIndex( - start=last_index + 1, stop=last_index + number_of_rows + 1, step=1) - empty_data = pd.DataFrame( - np.nan, index=new_indices, columns=self.data.columns) - self.data = pd.concat([self.data, empty_data], ignore_index=False) - - def add_column(self, name: str, exist_ok: bool = False): - if name in self.columns.names: - if not exist_ok: - raise ValueError( - f"Column {name} already exists in the data. " - "Set exist_ok to True to allow skipping existing columns.") - return - - if self.data.columns.empty: - new_columns_index = 0 - else: - new_columns_index = self.data.columns[-1] + 1 - - self.columns.add(name) - self.data[new_columns_index] = np.nan - - def remove(self, indices: List[int]): - self.data = self.data.drop(indices) - - def round(self, decimals: int): - self.data = self.data.round(decimals=decimals) - - def overwrite(self, indices: Iterable[int], other: _Data | Dict[str, Any]): - if isinstance(other, Dict): - other = _convert_dict_to_data(other) - - for other_column in other.columns.names: - if other_column not in self.columns.names: - self.add_column(other_column) - - self.data.update(other.data.set_index(pd.Index(indices))) - - def join(self, __o: _Data) -> _Data: - """Join two Data objects together. - - Parameters - ---------- - __o : Data - The Data object to join. - - Returns - ------- - The joined Data object. - """ - return _Data( - pd.concat([self.data, __o.data], axis=1, ignore_index=True), - columns=self.columns + __o.columns) - -# Getters and setters -# ============================================================================= - - def get_data_dict(self, index: int) -> Dict[str, Any]: - return self.to_dataframe().loc[index].to_dict() - - def set_data(self, index: int, value: Any, column: Optional[str] = None): - # check if the index exists - if index not in self.data.index: - raise IndexError(f"Index {index} does not exist in the data.") - - if column is None: - # Set the entire row to the values - self.data.loc[index] = value - return - - elif column not in self.columns.names: - self.add_column(column) - - _column_index = self.columns.iloc(column)[0] - try: - self.data.at[index, _column_index] = value - except ValueError: - self.data = self.data.astype(object) - self.data.at[index, _column_index] = value - - def reset_index(self, indices: Optional[Iterable[int]] = None) -> None: - """Reset the index of the data. - - Parameters - ---------- - indices : Optional[Iterable[int]], optional - The indices to reset, by default None - - Note - ---- - If indices is None, the entire index will be reset to a RangeIndex - with the same length as the data. - """ - if indices is None: - self.data.reset_index(drop=True, inplace=True) - else: - self.data.index = indices - - def is_empty(self) -> bool: - """Check if the data is empty.""" - return self.data.empty - - def get_index_with_nan(self) -> pd.Index: - """Get the indices with NaN values. - - Returns - ------- - pd.Index - The indices with NaN values. - """ - return self.indices[self.data.isna().any(axis=1)] - - def has_columnnames(self, names: Iterable[str]) -> bool: - return set(names).issubset(self.names) - - def set_columnnames(self, names: Iterable[str]) -> None: - for old_name, new_name in zip(self.names, names): - self.columns.rename(old_name, new_name) - - def cast_types(self, domain: Domain): - """Cast the types of the data to the types of the domain. - - Parameters - ---------- - domain : Domain - The domain with specific parameters to cast the types to. - - Raises - ------ - ValueError - If the types of the domain and the data do not match. - """ - _dtypes = {index: parameter._type - for index, (_, parameter) in enumerate( - domain.space.items())} - self.data = self.data.astype(_dtypes) - - -def _convert_dict_to_data(dictionary: Dict[str, Any]) -> _Data: - """Converts a dictionary with scalar values to a data object. - - Parameters - ---------- - dict : Dict[str, Any] - The dictionary to convert. Note that the dictionary - should only have scalar values! - - Returns - ------- - _Data - The data object. - """ - _columns = {name: None for name in dictionary.keys()} - df = pd.DataFrame(dictionary, index=[0]).copy() - return _Data(data=df, columns=_Columns(_columns)) - - -def _data_factory(data: DataTypes) -> _Data: - if data is None: - return _Data() - - elif isinstance(data, _Data): - return data - - elif isinstance(data, pd.DataFrame): - return _Data.from_dataframe(data) - - elif isinstance(data, (Path, str)): - return _Data.from_file(data) - - elif isinstance(data, np.ndarray): - return _Data.from_numpy(data) - - else: - raise TypeError( - f"Data must be of type _Data, pd.DataFrame, np.ndarray, " - f"Path or str, not {type(data)}") - - -DataTypes = Union[pd.DataFrame, np.ndarray, Path, str, _Data] diff --git a/src/f3dasm/_src/experimentdata/_io.py b/src/f3dasm/_src/experimentdata/_io.py deleted file mode 100644 index f602dbac..00000000 --- a/src/f3dasm/_src/experimentdata/_io.py +++ /dev/null @@ -1,367 +0,0 @@ -""" -Module to load and save output data of experiments \ -and other common IO operations -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -import pickle -from pathlib import Path -from typing import Any, Mapping, Optional, Type - -# Third-party -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import xarray as xr - -# Local -from ..logger import logger - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= - -# Global folder and file names -# ============================================================================= - -EXPERIMENTDATA_SUBFOLDER = "experiment_data" - -LOCK_FILENAME = "lock" -DOMAIN_FILENAME = "domain" -INPUT_DATA_FILENAME = "input" -OUTPUT_DATA_FILENAME = "output" -JOBS_FILENAME = "jobs" - -RESOLUTION_MATPLOTLIB_FIGURE = 300 -MAX_TRIES = 10 - -# Storing methods -# ============================================================================= - - -class StoreProtocol: - """Base class for storing and loading output data from disk""" - suffix: int - - def __init__(self, object: Any, path: Path): - """ - Protocol class for storing and loading output data from disk - - Parameters - ---------- - object : Any - object to store - path : Path - location to store the object to - """ - self.path = path - self.object = object - - def store(self) -> None: - """ - Protocol class for storing objects to disk - - Raises - ------ - NotImplementedError - Raises if the method is not implemented - """ - raise NotImplementedError() - - def load(self) -> Any: - """ - Protocol class for loading objects to disk - - Returns - ------- - Any - The loaded object - - Raises - ------ - NotImplementedError - Raises if the method is not implemented - """ - raise NotImplementedError() - - -class PickleStore(StoreProtocol): - """Class to store and load objects using the pickle protocol""" - suffix: str = '.pkl' - - def store(self) -> None: - """ - Store the object to disk using the pickle protocol - """ - with open(self.path.with_suffix(self.suffix), 'wb') as file: - pickle.dump(self.object, file) - - def load(self) -> Any: - """ - Load the object from disk using the pickle protocol - - Returns - ------- - Any - The loaded object - - """ - with open(self.path.with_suffix(self.suffix), 'rb') as file: - return pickle.load(file) - - -class NumpyStore(StoreProtocol): - """Class to store and load objects using the numpy protocol""" - suffix: str = '.npy' - - def store(self) -> None: - """ - Store the object to disk using the numpy protocol - """ - np.save(file=self.path.with_suffix(self.suffix), arr=self.object) - - def load(self) -> np.ndarray: - """ - Load the object from disk using the numpy protocol - - Returns - ------- - np.ndarray - The loaded object - """ - return np.load(file=self.path.with_suffix(self.suffix)) - - -class PandasStore(StoreProtocol): - """Class to store and load objects using the pandas protocol""" - suffix: str = '.csv' - - def store(self) -> None: - """ - Store the object to disk using the pandas protocol - """ - self.object.to_csv(self.path.with_suffix(self.suffix)) - - def load(self) -> pd.DataFrame: - """ - Load the object from disk using the pandas protocol - - Returns - ------- - pd.DataFrame - The loaded object - """ - return pd.read_csv(self.path.with_suffix(self.suffix)) - - -class XarrayStore(StoreProtocol): - """Class to store and load objects using the xarray protocol""" - suffix: str = '.nc' - - def store(self) -> None: - """ - Store the object to disk using the xarray protocol - """ - self.object.to_netcdf(self.path.with_suffix(self.suffix)) - - def load(self) -> xr.DataArray | xr.Dataset: - """ - Load the object from disk using the xarray protocol - - Returns - ------- - xr.DataArray | xr.Dataset - The loaded object - """ - return xr.open_dataset(self.path.with_suffix(self.suffix)) - - -class FigureStore(StoreProtocol): - """Class to store and load objects using the matplotlib protocol""" - suffix: str = '.png' - - def store(self) -> None: - """ - Store the figure to disk as a png file - - Notes - ----- - - The figure is saved with a resolution of 300 dpi. - - The figure is saved with tight bounding boxes. - """ - self.object.savefig(self.path.with_suffix( - self.suffix), dpi=RESOLUTION_MATPLOTLIB_FIGURE, - bbox_inches='tight') - - def load(self) -> np.ndarray: - """ - Load the image as an numpy array from disk - using the matplotlib `plt.imread` function. - - Returns - ------- - np.ndarray - The loaded image in the form of a numpy array - - Notes - ----- - The returned array has shape - - (M, N) for grayscale images. - - (M, N, 3) for RGB images. - - (M, N, 4) for RGBA images. - - Images are returned as float arrays (0-1). - """ - return plt.imread(self.path.with_suffix(self.suffix)) - - -STORE_TYPE_MAPPING: Mapping[Type, StoreProtocol] = { - np.ndarray: NumpyStore, - pd.DataFrame: PandasStore, - pd.Series: PandasStore, - xr.DataArray: XarrayStore, - xr.Dataset: XarrayStore, - plt.Figure: FigureStore, -} - -# Loading and saving functions -# ============================================================================= - - -def load_object(path: Path, experimentdata_directory: Path, - store_method: Type[StoreProtocol] = PickleStore) -> Any: - """ - Load an object from disk from a given path and storing method - - Parameters - ---------- - path : Path - path of the object to load - experimentdata_directory : Path - path of the f3dasm project directory - store_method : Type[_Store], optional - storage method protocol, by default PickleStore - - Returns - ------- - Any - the object loaded from disk - - Raises - ------ - ValueError - Raises if no matching store type is found - - Note - ---- - If no store method is provided, the function will try to find a matching - store type based on the suffix of the item's path. If no matching store - type is found, the function will raise a ValueError. By default, the - function will use the PickleStore protocol to load the object from disk. - """ - - _path = experimentdata_directory / path - - if store_method is not None: - return store_method(None, _path).load() - - if not _path.exists(): - return None - - # Extract the suffix from the item's path - item_suffix = _path.suffix - - # Use a generator expression to find the first matching store type, - # or None if no match is found - matched_store_type: StoreProtocol = next( - (store_type for store_type in STORE_TYPE_MAPPING.values() if - store_type.suffix == item_suffix), PickleStore) - - if matched_store_type: - return matched_store_type(None, _path).load() - else: - # Handle the case when no matching suffix is found - raise ValueError( - f"No matching store type for item type: '{item_suffix}'") - - -def save_object(object: Any, path: Path, experimentdata_directory: Path, - store_method: Optional[Type[StoreProtocol]] = None) -> str: - """Function to save the object to path, - with the appropriate storing method. - - Parameters - ---------- - object : Any - Object to store - path : Path - Path to store the object to - store_method : Optional[Store], optional - Storage method, by default None - - Returns - ------- - str - suffix of the storage method - - Raises - ------ - TypeError - Raises if the object type is not supported, - and you haven't provided a custom store method. - """ - _path = experimentdata_directory / path - - if store_method is not None: - storage = store_method(object, _path) - return - - # Check if object type is supported - object_type = type(object) - - if object_type not in STORE_TYPE_MAPPING: - storage: StoreProtocol = PickleStore(object, _path) - logger.debug(f"Object type {object_type} is not natively supported. " - f"The default pickle storage method will be used.") - - else: - storage: StoreProtocol = STORE_TYPE_MAPPING[object_type](object, _path) - # Store object - storage.store() - return storage.suffix - - -def _project_dir_factory(project_dir: Path | str | None) -> Path: - """Creates a Path object for the project directory from a particular input - - Parameters - ---------- - project_dir : Path | str | None - path of the user-defined directory where to create the f3dasm project \ - folder. - - Returns - ------- - Path - Path object - """ - if isinstance(project_dir, Path): - return project_dir.absolute() - - if project_dir is None: - return Path().cwd() - - if isinstance(project_dir, str): - return Path(project_dir).absolute() - - raise TypeError( - f"project_dir must be of type Path, str or None, \ - not {type(project_dir).__name__}") diff --git a/src/f3dasm/_src/experimentdata/_jobqueue.py b/src/f3dasm/_src/experimentdata/_jobqueue.py deleted file mode 100644 index 438b6c4d..00000000 --- a/src/f3dasm/_src/experimentdata/_jobqueue.py +++ /dev/null @@ -1,346 +0,0 @@ -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from enum import Enum -from pathlib import Path -from typing import Iterable, List, Optional, Type - -# Third-party -import pandas as pd - -# Local -from ._data import _Data - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -class Status(str, Enum): - """Enum class for the status of a job.""" - OPEN = 'open' - IN_PROGRESS = 'in progress' - FINISHED = 'finished' - ERROR = 'error' - - def __str__(self) -> str: - return self.value - - -class NoOpenJobsError(Exception): - """ - Exception raised when there are no open jobs. - - Attributes: - message (str): The error message. - """ - - def __init__(self, message): - super().__init__(message) - - -class _JobQueue: - def __init__(self, jobs: Optional[pd.Series] = None): - """ - A class that represents a dictionary of jobs that - can be marked as 'open', 'in progress', finished', or 'error'. - - Parameters - ---------- - filename : str - The name of the file that the jobs will be saved in. - """ - if jobs is None: - jobs = pd.Series(dtype='string') - - self.jobs: pd.Series = jobs - - def __add__(self, other: _JobQueue | str) -> _JobQueue: - """Add two JobQueue objects together. - - Parameters - ---------- - other : JobQueue - JobQueue object to add. - - Returns - ------- - JobQueue - JobQueue object containing the added jobs. - """ - if isinstance(other, str): - # make _JobQueue from the jobnumber - other = _JobQueue( - pd.Series(other, index=[0], dtype='string')) - - try: - last_index = self.jobs.index[-1] - except IndexError: # Empty Series - return _JobQueue(other.jobs) - - # Make a copy of other.jobs and modify its index - other_jobs_copy = other.jobs.copy() - other_jobs_copy.index = other_jobs_copy.index + last_index + 1 - return _JobQueue(pd.concat([self.jobs, other_jobs_copy])) - - def __getitem__(self, index: int | slice | Iterable[int]) -> _Data: - """Get a subset of the data. - - Parameters - ---------- - index : int, slice, list - The index of the data to get. - - Returns - ------- - A subset of the data. - """ - if isinstance(index, int): - index = [index] - return _JobQueue(self.jobs[index].copy()) - - def __eq__(self, __o: _JobQueue) -> bool: - return self.jobs.equals(__o.jobs) - - def _repr_html_(self) -> str: - return self.jobs.__repr__() - - @property - def indices(self) -> pd.Index: - """The indices of the jobs.""" - return self.jobs.index - # Alternative Constructors - # ========================================================================= - - @classmethod - def from_data(cls: Type[_JobQueue], data: _Data, - value: str = Status.OPEN) -> _JobQueue: - """Create a JobQueue object from a Data object. - - Parameters - ---------- - data : Data - Data object containing the data. - value : str - The value to assign to the jobs. Can be 'open', - 'in progress', 'finished', or 'error'. - - Returns - ------- - JobQueue - JobQueue object containing the loaded data. - """ - return cls(pd.Series([value] * len(data), dtype='string')) - - @classmethod - def from_file(cls: Type[_JobQueue], filename: Path | str) -> _JobQueue: - """Create a JobQueue object from a pickle file. - - Parameters - ---------- - filename : Path | str - Name of the file. - - Returns - ------- - JobQueue - JobQueue object containing the loaded data. - """ - # Convert filename to Path - filename = Path(filename).with_suffix('.pkl') - - # Check if the file exists - if not filename.exists(): - raise FileNotFoundError(f"Jobfile {filename} does not exist.") - - return cls(pd.read_pickle(filename)) - - def reset(self) -> None: - """Resets the job queue.""" - self.jobs = pd.Series(dtype='string') - - # Select - # ========================================================================= - - def select_all(self, status: str) -> _JobQueue: - """Selects all jobs with a certain status. - - Parameters - ---------- - status : str - Status of the jobs to select - - Returns - ------- - JobQueue - JobQueue object containing the selected jobs. - """ - return _JobQueue(self.jobs[self.jobs == status]) - - # Export - # ========================================================================= - - def store(self, filename: Path) -> None: - """Stores the jobs in a pickle file. - - Parameters - ---------- - filename : Path - Path of the file. - """ - self.jobs.to_pickle(filename.with_suffix('.pkl')) - - def to_dataframe(self, name: str = "") -> pd.DataFrame: - """Converts the job queue to a DataFrame. - - Parameters - ---------- - name : str, optional - Name of the column, by default "". - - Note - ---- - If the name is not specified, the column name will be an empty string - - Returns - ------- - DataFrame - DataFrame containing the jobs. - """ - return self.jobs.to_frame("") - - # Append and remove jobs - # ========================================================================= - - def remove(self, indices: List[int]): - """Removes a subset of the jobs. - - Parameters - ---------- - indices : List[int] - List of indices to remove. - """ - self.jobs = self.jobs.drop(indices) - - def add(self, number_of_jobs: int = 1, status: str = Status.OPEN): - """Adds a number of jobs to the job queue. - - Parameters - ---------- - number_of_jobs : int, optional - Number of jobs to add, by default 1 - status : str, optional - Status of the jobs, by default 'open'. - """ - try: - last_index = self.jobs.index[-1] - except IndexError: # Empty Series - self.jobs = pd.Series([status] * number_of_jobs, dtype='string') - return - - new_indices = pd.RangeIndex( - start=last_index + 1, stop=last_index + number_of_jobs + 1, step=1) - jobs_to_add = pd.Series(status, index=new_indices, dtype='string') - self.jobs = pd.concat([self.jobs, jobs_to_add], ignore_index=False) - - def overwrite( - self, indices: Iterable[int], - other: _JobQueue | str) -> None: - - if isinstance(other, str): - other = _JobQueue( - pd.Series([other], index=[0], dtype='string')) - - self.jobs.update(other.jobs.set_axis(indices)) - - # Mark - # ========================================================================= - - def mark(self, index: int | slice | Iterable[int], status: Status) -> None: - """Marks a job with a certain status. - - Parameters - ---------- - index : int - Index of the job to mark. - status : str - Status to mark the job with. - """ - self.jobs.loc[index] = status - - def mark_all_in_progress_open(self) -> None: - """Marks all jobs as 'open'.""" - self.jobs = self.jobs.replace(Status.IN_PROGRESS, Status.OPEN) - - def mark_all_error_open(self) -> None: - """Marks all jobs as 'open'.""" - self.jobs = self.jobs.replace(Status.ERROR, Status.OPEN) - # Miscellanous - # ========================================================================= - - def is_all_finished(self) -> bool: - """Checks if all jobs are finished. - - Returns - ------- - bool - True if all jobs are finished, False otherwise. - """ - return all(self.jobs.isin([Status.FINISHED, Status.ERROR])) - - def get_open_job(self) -> int: - """Returns the index of an open job. - - Returns - ------- - int - Index of an open job. - """ - try: # try to find an open job - return int(self.jobs[self.jobs == Status.OPEN].index[0]) - except IndexError: - raise NoOpenJobsError("No open jobs found.") - - def reset_index(self) -> None: - """Resets the index of the jobs.""" - self.jobs.reset_index(drop=True, inplace=True) - - -def _jobs_factory(jobs: Path | str | _JobQueue | None, input_data: _Data, - output_data: _Data, job_value: Status) -> _JobQueue: - """Creates a _JobQueue object from particular inpute - - Parameters - ---------- - jobs : Path | str | None - input data for the jobs - input_data : _Data - _Data object of input data to extract indices from, if necessary - output_data : _Data - _Data object of output data to extract indices from, if necessary - job_value : Status - initial value of all the jobs - - Returns - ------- - _JobQueue - JobQueue object - """ - if isinstance(jobs, _JobQueue): - return jobs - - if isinstance(jobs, (Path, str)): - return _JobQueue.from_file(Path(jobs)) - - if input_data.is_empty(): - return _JobQueue.from_data(output_data, value=job_value) - - return _JobQueue.from_data(input_data, value=job_value) diff --git a/src/f3dasm/_src/experimentdata/_newdata.py b/src/f3dasm/_src/experimentdata/_newdata.py deleted file mode 100644 index b8a45d01..00000000 --- a/src/f3dasm/_src/experimentdata/_newdata.py +++ /dev/null @@ -1,828 +0,0 @@ -""" -Module that contains the underlying data structure for ordering -input and output data of experiments. - -Note ----- -The data is stored as a list of lists, where each list represents -this is a work in progress and not yet implemented, that's why the name is -_newdata.py and the module is not imported in the __init__.py file. -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -from pathlib import Path -from typing import (Any, Dict, Iterable, Iterator, List, Optional, Tuple, Type, - Union) - -import numpy as np -import pandas as pd -import xarray as xr - -from f3dasm._src.experimentdata._columns import _Columns -from f3dasm.design import Domain - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -DataType = List[List[Any]] - - -class _Index: - def __init__(self, indices: Optional[Iterable[int]] = None): - if indices is None: - indices = [] - - self.indices: pd.Index = pd.Index(indices) - - def __add__(self, other: _Index) -> _Index: - - if self.is_empty(): - return _Index(other.indices.copy()) - - return _Index( - self.indices.append(other.indices + self.indices[-1] + 1)) - - def __repr__(self) -> str: - return self.indices.__repr__() - - def iloc(self, index: int | Iterable[int]) -> List[int]: - - if isinstance(index, int) or isinstance(index, np.int64): - index = [index] - - _indices = [] - for n in index: - _indices.append(self.indices.get_loc(n)) - return _indices - - def is_empty(self) -> bool: - return self.indices.empty - - -class _Data: - def __init__(self, data: Optional[DataType] = None, - columns: Optional[_Columns] = None, - index: Optional[_Index] = None): - """ - Class for capturing input or output data in a tabular format - - Parameters - ---------- - data : Optional[DataType], optional - Data in tabular format, by default None - columns : Optional[_Columns], optional - _Columns object representing the names of the parameters, - by default None - index : Optional[_Index], optional - _Index object representing the indices of the experiments, - by default - - Note - ---- - - * The data is stored as a list of lists, where each list represents - a row in the table. - * The columns are stored as a dict with the - column names as keys and None as values. - * The index is stored as a list of integers in a pd.Index object. - - If no column names are given, the are by default number starting from - zero. If no index is given, the indices are by default numbers starting - from zero. - """ - if data is None: - data = [] - - if columns is None: - try: - columns = _Columns( - {col: None for col, _ in enumerate(data[0])}) - except IndexError: - columns = _Columns() - - if index is None: - index = _Index(range(len(data))) - - self.columns = columns - self.data = data - self.index = index - - def __len__(self) -> int: - """ - Calculate the number of rows in the data. - - Returns - ------- - int - the number of experiments - """ - return len(self.data) - - def __iter__(self) -> Iterator[Tuple[Dict[str, Any]]]: - """ - Iterator for the experiments in the data object - - Yields - ------ - Iterator[Tuple[Dict[str, Any]]] - Iterator object - """ - self.current_index = 0 - return self - - def __next__(self): - """ - Get the next experiment in the data object - - Returns - ------- - Tuple[Dict[str, Any]] - The next experiment - - Raises - ------ - StopIteration - If the last experiment has been reached - """ - if self.current_index >= len(self): - raise StopIteration - else: - current_value = self.data[self.current_index] - self.current_index += 1 - return current_value - - def __getitem__(self, index: int | Iterable[int]) -> _Data: - """ - Get the experiment(s) at the given index - - Parameters - ---------- - index : int | Iterable[int] - The index of the experiment(s) to get - - Returns - ------- - _Data - The experiment(s) at the given index - """ - _index = self.index.iloc(index) - if self.is_empty(): - return _Data(columns=self.columns, index=_Index(_index)) - - else: - return _Data(data=[self.data[i] for i in _index], - columns=self.columns, index=_Index(_index)) - - def __add__(self, other: _Data | Dict[str, Any]) -> _Data: - """ - Add two data objects together - - Parameters - ---------- - other : _Data - The data object to add - - Returns - ------- - _Data - The combined data object - - Note - ---- - * The columns of the two data objects must be the same. - * The indices of the second object are shifted by the number of - experiments in the first object. - """ - # If other is a dictionary, convert it to a _Data object - if not isinstance(other, _Data): - other = _convert_dict_to_data(other) - - return _Data(data=self.data + other.data, - columns=self.columns, - index=self.index + other.index) - - def __eq__(self, __o: _Data) -> bool: - """ - Check if two data objects are equal - - Parameters - ---------- - __o : _Data - The data object to compare with - - Returns - ------- - bool - True if the data objects are equal, False otherwise - - Note - ---- - The data objects will first be converted to a pandas DataFrame and - then compared. - """ - return self.to_dataframe().equals(__o.to_dataframe()) - - def _repr_html_(self) -> str: - """ - HTML representation of the data object - - Returns - ------- - str - HTML representation of the data object - - Note - ---- - This method is used by Jupyter Notebook to display the data object. - """ - return self.to_dataframe()._repr_html_() - -# Properties -# ============================================================================= - - @property - def names(self) -> List[str]: - """ - Names of the columns of the data - - Returns - ------- - List[str] - Names of the columns of the data - - Note - ---- - This is a shortcut for self.columns.names, accessing the private - object. - """ - return self.columns.names - - @property - def indices(self) -> pd.Index: - """ - Indices of the experiments in the data - - Returns - ------- - pd.Index - Indices of the experiments in the data - - Note - ---- - This is a shortcut for self.index.indices, accessing the private - object. - """ - return self.index.indices - -# Alternative constructors -# ============================================================================= - - @classmethod - def from_list(cls: Type[_Data], list: List[List[Any]]) -> _Data: - """Creates a data object from a list of lists. - - Parameters - ---------- - list : List[List[Any]] - The list of lists to create the data object from. - - Returns - ------- - _Data - The data object. - """ - return _Data(data=list) - - @classmethod - def from_indices(cls: Type[_Data], indices: pd.Index) -> _Data: - """Creates a data object from a pd.Index object. - - Parameters - ---------- - indices : pd.Index - The indices of the experiments. - - Returns - ------- - _Data - The data object. - - Note - ---- - The returned object will have no columns and the indices will be - the given indices. The data will be an empty list. - """ - return _Data(index=_Index(indices)) - - @classmethod - def from_domain(cls: Type[_Data], domain: Domain) -> _Data: - """Creates a data object from a domain. - - Parameters - ---------- - domain : Domain - The domain to create the data object from. - - Returns - ------- - _Data - The data object. - - Note - ---- - * The returned object will have no data and empty indices. - * The columns will be the names of the provided domain. - """ - _columns = {name: None for name in domain.names} - return _Data(columns=_Columns(_columns)) - - @classmethod - def from_file(cls: Type[_Data], filename: Path | str) -> _Data: - # TODO: Fix this method for _newdata - """Loads the data from a file. - - Parameters - ---------- - filename : Path - The filename to load the data from. - - Returns - ------- - _Data - The loaded data object. - """ - file = Path(filename).with_suffix('.csv') - df = pd.read_csv(file, header=0, index_col=0) - return cls.from_dataframe(df) - - @classmethod - def from_numpy(cls: Type[_Data], array: np.ndarray) -> _Data: - """Loads the data from a numpy array. - - Parameters - ---------- - array : np.ndarray - The array to load the data from. - - Returns - ------- - _Data - The data object. - - Note - ---- - The returned _Data object will have the default column names and - indices. - """ - return cls.from_dataframe(pd.DataFrame(array)) - - @classmethod - def from_dataframe(cls: Type[_Data], df: pd.DataFrame) -> _Data: - """Loads the data from a pandas dataframe. - - Parameters - ---------- - df : pd.DataFrame - The dataframe to load the data from. - - Returns - ------- - _Data - The data object. - - Note - ---- - The returned _Data object will have the column names of the dataframe - and the indices of the dataframe. - """ - _columns = {name: None for name in df.columns.to_list()} - return _Data(data=df.to_numpy().tolist(), - columns=_Columns(_columns), - index=_Index(df.index)) - - def reset(self, domain: Optional[Domain] = None): - """Resets the data object. - - Parameters - ---------- - domain : Optional[Domain], optional - The domain to reset the data object to, by default None - - Note - ---- - * If domain is None, the data object will be reset to an empty data - object. - * If a domain is provided, the data object will be reset to a data - object with the given domain and no data. - """ - if domain is None: - self.data = [] - self.columns = _Columns() - - else: - _reset_data = self.from_domain(domain) - self.data = _reset_data.data - self.columns = _reset_data.columns - - self.index = _Index() - -# Export -# ============================================================================= - - def to_numpy(self) -> np.ndarray: - """ - Convert the data to a numpy array - - Returns - ------- - np.ndarray - The data as a numpy array - - Note - ---- - This method converts the data to a pandas DataFrame and then to a - numpy array. - """ - return self.to_dataframe().to_numpy() - - def to_xarray(self, label: str) -> Any: - # TODO: THIS WILL NOT WORK IF DATA IS INHOMOGENEOUS! - return xr.DataArray(self.to_dataframe(), dims=['iterations', label], - coords={'iterations': self.indices, - label: self.names}) - - def to_dataframe(self) -> pd.DataFrame: - """ - Convert the data to a pandas DataFrame - - Returns - ------- - pd.DataFrame - The data as a pandas DataFrame - - Note - ---- - The resulting dataframe has the indices as rows and the columns as - column names. - """ - return pd.DataFrame(self.data, columns=self.names, index=self.indices) - - def combine_data_to_multiindex(self, other: _Data, - jobs_df: pd.DataFrame) -> pd.DataFrame: - """ - Combine the data to a multiindex dataframe. - - Parameters - ---------- - other : _Data - The other data to combine. - jobs : pd.DataFrame - The jobs dataframe. - - Returns - ------- - pd.DataFrame - The combined dataframe. - - Note - ---- - This function is mainly used to show the combined ExperimentData - object in a Jupyter Notebook - """ - return pd.concat([jobs_df, self.to_dataframe(), - other.to_dataframe()], - axis=1, keys=['jobs', 'input', 'output']) - - def store(self, filename: Path) -> None: - """Stores the data to a file. - - Parameters - ---------- - filename : Path - The filename to store the data to. - - Note - ---- - The data is stored as a csv file. - """ - # TODO: Test this function! - self.to_dataframe().to_csv(filename.with_suffix('.csv')) - - def n_best_samples(self, nosamples: int, - column_name: List[str] | str) -> pd.DataFrame: - return self.to_dataframe().nsmallest(n=nosamples, - columns=column_name) - - def select_columns(self, columns: Iterable[str] | str) -> _Data: - """Selects columns from the data. - - Parameters - ---------- - columns : Iterable[str] | str - The column(s) to select. - - Returns - ------- - _Data - The data with the selected columns. - - Note - ---- - This method returns a new data object with the selected columns. - """ - if isinstance(columns, str): - columns = [columns] - - _selected_columns = _Columns( - {column: None for column in columns}) - - return _Data( - data=self.to_dataframe()[columns].values.tolist(), - columns=_selected_columns, index=self.index) - -# Append and remove data -# ============================================================================= - - def add(self, data: pd.DataFrame): - """Adds data to the data object. - - Parameters - ---------- - data : pd.DataFrame - The data to add. - - Note - ---- - This method adds the dataframe in-place to the currentdata object. - The data is added to the end of the data object. - - The + operator will do the same thing but return a new data object.s - """ - _other = _Data.from_dataframe(data) - - self.data += _other.data - self.index += _other.index - - def add_empty_rows(self, number_of_rows: int): - """Adds empty rows to the data object. - - Parameters - ---------- - number_of_rows : int - The number of rows to add. - - Note - ---- - This method adds empty rows to the data object. The value of these - empty rows is np.nan. The columns are not changed. - - The rows are added to the end of the data object. - """ - self.data += [[np.nan for _ in self.names] - for _ in range(number_of_rows)] - self.index += _Index(range(number_of_rows)) - - def add_column(self, name: str): - """Adds a column to the data object. - - Parameters - ---------- - name : str - The name of the column to add. - - Note - ---- - The values in the rows of this new column will be set to np.nan. - """ - self.columns.add(name) - - if self.is_empty(): - self.data = [[np.nan] for _ in self.indices] - - else: - for row in self.data: - row.append(np.nan) - - def remove(self, indices: List[int] | int): - """Removes rows from the data object. - - Parameters - ---------- - indices : List[int] | int - The indices of the rows to remove. - """ - if isinstance(indices, int): - indices = [indices] - - self.data = [row for i, row in enumerate(self.data) - if self.index.iloc(i)[0] not in indices] - self.index = _Index([i for i in self.indices if i not in indices]) - - def round(self, decimals: int): - """Rounds the data. - - Parameters - ---------- - decimals : int - The number of decimals to round to. - """ - self.data = [[round(value, decimals) for value in row] - for row in self.data] - - def overwrite(self, data: _Data, indices: Iterable[int]): - # TODO: Implement this method! - ... - -# Getters and setters -# ============================================================================= - - def get_data_dict(self, index: int) -> Dict[str, Any]: - """ - Get the data as a dictionary. - - Parameters - ---------- - index : int - Index of the data to get. - - Returns - ------- - Dict[str, Any] - Dictionary with the data. - - Note - ---- - If the data is empty, an empty dictionary is returned. - """ - if self.is_empty(): - return {} - - _index = self.index.iloc(index)[0] - return {name: value for name, value in zip(self.names, - self.data[_index])} - - def set_data(self, index: int, value: Any, column: Optional[str] = None): - """ - Set the data at the given index. - - Parameters - ---------- - index : int - Index of the data to set. - value : Any - Value to set. - column : Optional[str], optional - Column to set, by default None - - Raises - ------ - IndexError - If the index is not in the data. - - Note - ---- - * If the column is not in the data, it will be added. - * If the column is None, the value will be set to the whole row. Make - sure that you provide a list with the same length as the number of - columns in the data. - - """ - if index not in self.indices: - raise IndexError(f'Index {index} not in data.') - - if column is None: - _index = self.index.iloc(index)[0] - self.data[_index] = value - return - - elif column not in self.names: - self.add_column(column) - - _column_index = self.columns.iloc(column)[0] - _index = self.index.iloc(index)[0] - self.data[_index][_column_index] = value - - def reset_index(self, indices: Optional[Iterable[int]] = None): - """Resets the index of the data object. - - Parameters - ---------- - indices : Optional[Iterable[int]], optional - The indices to reset the index to, by default None - - Note - ---- - This method resets the index of the data object. - - If no indices are provided, the index will be - reset to range(len(data)). This means that the index will be - [0, 1, 2, ..., len(data) - 1]. - """ - if indices is None: - indices = range(len(self.data)) - - self.index = _Index(indices) - - def is_empty(self) -> bool: - """Checks if the data object is empty. - - Returns - ------- - bool - True if the data object is empty, False otherwise. - """ - return len(self.data) == 0 - - def has_columnnames(self, names: Iterable[str]) -> bool: - """Checks if the data object has the given column names. - - Parameters - ---------- - names : Iterable[str] - The column names to check. - - Returns - ------- - bool - True if the data object has the given column names, False - otherwise. - """ - return set(names).issubset(self.names) - - def set_columnnames(self, names: Iterable[str]): - """Sets the column names of the data object. - - Parameters - ---------- - names : Iterable[str] - The column names to set. - - Note - ---- - This method overwrite the column names of the data object. - The number of names should be equal to the number of columns in the - data object. - """ - - for old_name, new_name in zip(self.names, names): - self.columns.rename(old_name, new_name) - - def cast_types(self, domain: Domain): - pass - - -def _convert_dict_to_data(dictionary: Dict[str, Any]) -> _Data: - """Converts a dictionary with scalar values to a data object. - - Parameters - ---------- - dict : Dict[str, Any] - The dictionary to convert. Note that the dictionary - should only have scalar values! - - Returns - ------- - _Data - The data object. - """ - df = pd.DataFrame(dictionary, index=[0]).copy() - return _Data.from_dataframe(df) - - -def _data_factory(data: DataTypes) -> _Data: - if data is None: - return _Data() - - elif isinstance(data, list): - return _Data.from_list(data) - - elif isinstance(data, _Data): - return data - - elif isinstance(data, pd.DataFrame): - return _Data.from_dataframe(data) - - elif isinstance(data, (Path, str)): - return _Data.from_file(data) - - elif isinstance(data, np.ndarray): - return _Data.from_numpy(data) - - else: - raise TypeError( - f"Data must be of type _Data, pd.DataFrame, np.ndarray, " - f"Path or str, not {type(data)}") - - -DataTypes = Union[pd.DataFrame, np.ndarray, Path, str, _Data] diff --git a/src/f3dasm/_src/experimentdata/experimentdata.py b/src/f3dasm/_src/experimentdata/experimentdata.py deleted file mode 100644 index 71c60473..00000000 --- a/src/f3dasm/_src/experimentdata/experimentdata.py +++ /dev/null @@ -1,1848 +0,0 @@ -""" -The ExperimentData object is the main object used to store implementations - of a design-of-experiments, keep track of results, perform optimization and - extract data for machine learning purposes. -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -import inspect -import traceback -from copy import copy -from functools import wraps -from pathlib import Path -from time import sleep -from typing import (Any, Callable, Dict, Iterable, Iterator, List, Literal, - Optional, Tuple, Type) - -# Third-party -import numpy as np -import pandas as pd -import xarray as xr -from filelock import FileLock -from hydra.utils import get_original_cwd -from omegaconf import DictConfig -from pathos.helpers import mp - -# Local -from ..datageneration.datagenerator import DataGenerator, convert_function -from ..datageneration.functions.function_factory import _datagenerator_factory -from ..design.domain import Domain, _domain_factory -from ..logger import logger -from ..optimization import Optimizer -from ..optimization.optimizer_factory import _optimizer_factory -from ._data import DataTypes, _Data, _data_factory -from ._io import (DOMAIN_FILENAME, EXPERIMENTDATA_SUBFOLDER, - INPUT_DATA_FILENAME, JOBS_FILENAME, LOCK_FILENAME, MAX_TRIES, - OUTPUT_DATA_FILENAME, _project_dir_factory) -from ._jobqueue import NoOpenJobsError, Status, _jobs_factory -from .experimentsample import ExperimentSample -from .samplers import Sampler, SamplerNames, _sampler_factory -from .utils import number_of_overiterations, number_of_updates - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -class ExperimentData: - """ - A class that contains data for experiments. - """ - - def __init__(self, - domain: Optional[Domain] = None, - input_data: Optional[DataTypes] = None, - output_data: Optional[DataTypes] = None, - jobs: Optional[Path | str] = None, - project_dir: Optional[Path] = None): - """ - Initializes an instance of ExperimentData. - - Parameters - ---------- - domain : Domain, optional - The domain of the experiment, by default None - input_data : DataTypes, optional - The input data of the experiment, by default None - output_data : DataTypes, optional - The output data of the experiment, by default None - jobs : Path | str, optional - The path to the jobs file, by default None - project_dir : Path | str, optional - A user-defined directory where the f3dasm project folder will be \ - created, by default the current working directory. - - Note - ---- - - The following data formats are supported for input and output data: - - * numpy array - * pandas Dataframe - * path to a csv file - - If no domain object is provided, the domain is inferred from the \ - input_data. - - If the provided project_dir does not exist, it will be created. - - Raises - ------ - - ValueError - If the input_data is a numpy array, the domain has to be provided. - """ - - if isinstance(input_data, np.ndarray) and domain is None: - raise ValueError( - 'If you provide a numpy array as input_data, \ - you have to provide the domain!') - - self.project_dir = _project_dir_factory(project_dir) - - self._input_data = _data_factory(input_data) - self._output_data = _data_factory(output_data) - - # Create empty output_data from indices if output_data is empty - if self._output_data.is_empty(): - self._output_data = _Data.from_indices(self._input_data.indices) - job_value = Status.OPEN - - else: - job_value = Status.FINISHED - - self.domain = _domain_factory( - domain=domain, input_data=self._input_data.to_dataframe(), - output_data=self._output_data.to_dataframe()) - - # Create empty input_data from domain if input_data is empty - if self._input_data.is_empty(): - self._input_data = _Data.from_domain(self.domain) - - self._jobs = _jobs_factory( - jobs, self._input_data, self._output_data, job_value) - - # Check if the columns of input_data are in the domain - if not self._input_data.has_columnnames(self.domain.names): - self._input_data.set_columnnames(self.domain.names) - - if not self._output_data.has_columnnames(self.domain.output_names): - self._output_data.set_columnnames(self.domain.output_names) - - # For backwards compatibility; if the output_data has - # only one column, rename it to 'y' - if self._output_data.names == [0]: - self._output_data.set_columnnames(['y']) - - def __len__(self): - """The len() method returns the number of datapoints""" - if self._input_data.is_empty(): - return len(self._output_data) - - return len(self._input_data) - - def __iter__(self) -> Iterator[Tuple[Dict[str, Any]]]: - self.current_index = 0 - return self - - def __next__(self) -> ExperimentSample: - if self.current_index >= len(self): - raise StopIteration - else: - index = self.index[self.current_index] - self.current_index += 1 - return self.get_experiment_sample(index) - - def __add__(self, - __o: ExperimentData | ExperimentSample) -> ExperimentData: - """The + operator combines two ExperimentData objects""" - # Check if the domains are the same - - if not isinstance(__o, (ExperimentData, ExperimentSample)): - raise TypeError( - f"Can only add ExperimentData or " - f"ExperimentSample objects, not {type(__o)}") - - return ExperimentData( - input_data=self._input_data + __o._input_data, - output_data=self._output_data + __o._output_data, - jobs=self._jobs + __o._jobs, domain=self.domain + __o.domain, - project_dir=self.project_dir) - - def __eq__(self, __o: ExperimentData) -> bool: - return all([self._input_data == __o._input_data, - self._output_data == __o._output_data, - self._jobs == __o._jobs, - self.domain == __o.domain]) - - def _repr_html_(self) -> str: - return self._input_data.combine_data_to_multiindex( - self._output_data, self._jobs.to_dataframe())._repr_html_() - - def __repr__(self) -> str: - return self._input_data.combine_data_to_multiindex( - self._output_data, self._jobs.to_dataframe()).__repr__() - - def _access_file(operation: Callable) -> Callable: - """Wrapper for accessing a single resource with a file lock - - Parameters - ---------- - operation : Callable - The operation to be performed on the resource - - Returns - ------- - Callable - The wrapped operation - """ - @wraps(operation) - def wrapper_func(self: ExperimentData, *args, **kwargs) -> None: - lock = FileLock( - (self. - project_dir / EXPERIMENTDATA_SUBFOLDER / LOCK_FILENAME) - .with_suffix('.lock')) - - # If the lock has been acquired: - with lock: - tries = 0 - while tries < MAX_TRIES: - try: - self = ExperimentData.from_file(self.project_dir) - value = operation(self, *args, **kwargs) - self.store() - break - - # Racing conditions can occur when the file is empty - # and the file is being read at the same time - except pd.errors.EmptyDataError: - tries += 1 - logger.debug(( - f"EmptyDataError occurred, retrying" - f" {tries+1}/{MAX_TRIES}")) - sleep(1) - - raise pd.errors.EmptyDataError() - - return value - - return wrapper_func - # Properties - # ========================================================================= - - @property - def index(self) -> pd.Index: - """Returns an iterable of the job number of the experiments - - Returns - ------- - pd.Index - The job number of all the experiments in pandas Index format - """ - if self._input_data.is_empty(): - return self._output_data.indices - - return self._input_data.indices - - # Alternative Constructors - # ========================================================================= - - @classmethod - def from_file(cls: Type[ExperimentData], - project_dir: Path | str) -> ExperimentData: - """Create an ExperimentData object from .csv and .json files. - - Parameters - ---------- - project_dir : Path | str - User defined path of the experimentdata directory. - - Returns - ------- - ExperimentData - ExperimentData object containing the loaded data. - """ - if isinstance(project_dir, str): - project_dir = Path(project_dir) - - try: - return cls._from_file_attempt(project_dir) - except FileNotFoundError: - try: - filename_with_path = Path(get_original_cwd()) / project_dir - except ValueError: # get_original_cwd() hydra initialization error - raise FileNotFoundError( - f"Cannot find the folder {project_dir} !") - - return cls._from_file_attempt(filename_with_path) - - @classmethod - def from_sampling(cls, sampler: Sampler | str, domain: Domain | DictConfig, - n_samples: int = 1, - seed: Optional[int] = None, - **kwargs) -> ExperimentData: - """Create an ExperimentData object from a sampler. - - Parameters - ---------- - sampler : Sampler | str - Sampler object containing the sampling strategy or one of the - built-in sampler names. - domain : Domain | DictConfig - Domain object containing the domain of the experiment or hydra - DictConfig object containing the configuration. - n_samples : int, optional - Number of samples, by default 1. - seed : int, optional - Seed for the random number generator, by default None. - - Returns - ------- - ExperimentData - ExperimentData object containing the sampled data. - - Note - ---- - - If a string is passed for the sampler argument, it should be one - of the built-in samplers: - - * 'random' : Random sampling - * 'latin' : Latin Hypercube Sampling - * 'sobol' : Sobol Sequence Sampling - * 'grid' : Grid Search Sampling - - Any additional keyword arguments are passed to the sampler. - """ - experimentdata = cls(domain=domain) - experimentdata.sample( - sampler=sampler, n_samples=n_samples, seed=seed, **kwargs) - return experimentdata - - @classmethod - def from_yaml(cls, config: DictConfig) -> ExperimentData: - """Create an ExperimentData object from a hydra yaml configuration. - - Parameters - ---------- - config : DictConfig - A DictConfig object containing the configuration of the \ - experiment data. - - Returns - ------- - ExperimentData - ExperimentData object containing the loaded data. - """ - # Option 0: Both existing and sampling - if 'from_file' in config and 'from_sampling' in config: - return cls.from_file(config.from_file) + cls.from_sampling( - **config.from_sampling) - - # Option 1: From exisiting ExperimentData files - if 'from_file' in config: - return cls.from_file(config.from_file) - - # Option 2: Sample from the domain - if 'from_sampling' in config: - return cls.from_sampling(**config.from_sampling) - - else: - return cls(**config) - - @classmethod - def _from_file_attempt(cls: Type[ExperimentData], - project_dir: Path) -> ExperimentData: - """Attempt to create an ExperimentData object - from .csv and .pkl files. - - Parameters - ---------- - path : Path - Name of the user-defined directory where the files are stored. - - Returns - ------- - ExperimentData - ExperimentData object containing the loaded data. - - Raises - ------ - FileNotFoundError - If the files cannot be found. - """ - subdirectory = project_dir / EXPERIMENTDATA_SUBFOLDER - - try: - return cls(domain=subdirectory / DOMAIN_FILENAME, - input_data=subdirectory / INPUT_DATA_FILENAME, - output_data=subdirectory / OUTPUT_DATA_FILENAME, - jobs=subdirectory / JOBS_FILENAME, - project_dir=project_dir) - except FileNotFoundError: - raise FileNotFoundError( - f"Cannot find the files from {subdirectory}.") - - # Selecting subsets - # ========================================================================= - - def select(self, indices: int | Iterable[int]) -> ExperimentData: - """Select a subset of the ExperimentData object - - Parameters - ---------- - indices : int | Iterable[int] - The indices to select. - - Returns - ------- - ExperimentData - The selected ExperimentData object with only the selected indices. - """ - - return ExperimentData(input_data=self._input_data[indices], - output_data=self._output_data[indices], - jobs=self._jobs[indices], - domain=self.domain, project_dir=self.project_dir) - - def drop_output(self, names: Iterable[str] | str) -> ExperimentData: - """Drop a column from the output data - - Parameters - ---------- - names : Iteraeble | str - The names of the columns to drop. - - Returns - ------- - ExperimentData - The ExperimentData object with the column dropped. - """ - return ExperimentData(input_data=self._input_data, - output_data=self._output_data.drop(names), - jobs=self._jobs, domain=self.domain.drop_output( - names), - project_dir=self.project_dir) - - def select_with_status(self, status: Literal['open', 'in progress', - 'finished', 'error'] - ) -> ExperimentData: - """Select a subset of the ExperimentData object with a given status - - Parameters - ---------- - status : Literal['open', 'in progress', 'finished', 'error'] - The status to select. - - Returns - ------- - ExperimentData - The selected ExperimentData object with only the selected status. - - Raises - ------ - ValueError - Raised when invalid status is specified - """ - if status not in [s.value for s in Status]: - raise ValueError(f"Invalid status {status} given. " - f"\nChoose from values: " - f"{', '.join([s.value for s in Status])}") - - _indices = self._jobs.select_all(status).indices - return self.select(_indices) - - def get_input_data(self, - parameter_names: Optional[str | Iterable[str]] = None - ) -> ExperimentData: - """Retrieve a subset of the input data from the ExperimentData object - - Parameters - ---------- - parameter_names : str | Iterable[str], optional - The name(s) of the input parameters that you want to retrieve, \ - if None all input parameters are retrieved, by default None - - Returns - ------- - ExperimentData - The selected ExperimentData object with only the\ - selected input data. - - Note - ---- - If parameter_names is None, all input data is retrieved. \ - The returned ExperimentData object has the domain of \ - the original ExperimentData object, \ - but only with the selected input parameters.\ - """ - if parameter_names is None: - return ExperimentData(input_data=self._input_data, - jobs=self._jobs, - domain=self.domain.select(self.domain.names), - project_dir=self.project_dir) - else: - return ExperimentData(input_data=self._input_data.select_columns( - parameter_names), - jobs=self._jobs, - domain=self.domain.select(parameter_names), - project_dir=self.project_dir) - - def get_output_data(self, - parameter_names: Optional[str | Iterable[str]] = None - ) -> ExperimentData: - """Retrieve a subset of the output data from the ExperimentData object - - Parameters - ---------- - parameter_names : str | Iterable[str], optional - The name(s) of the output parameters that you want to retrieve, \ - if None all output parameters are retrieved, by default None - - Returns - ------- - ExperimentData - The selected ExperimentData object with only \ - the selected output data. - - Note - ---- - If parameter_names is None, all output data is retrieved. \ - The returned ExperimentData object has no domain object and \ - no input data! - """ - if parameter_names is None: - # TODO: Make a domain where space is empty - # but it tracks output_space! - return ExperimentData( - output_data=self._output_data, jobs=self._jobs, - project_dir=self.project_dir) - else: - return ExperimentData( - output_data=self._output_data.select_columns(parameter_names), - jobs=self._jobs, - project_dir=self.project_dir) - - # Export - # ========================================================================= - - def store(self, project_dir: Optional[Path | str] = None): - """Write the ExperimentData to disk in the project directory. - - Parameters - ---------- - project_dir : Optional[Path | str], optional - The f3dasm project directory to store the \ - ExperimentData object to, by default None. - - Note - ---- - If no project directory is provided, the ExperimentData object is \ - stored in the directory provided by the `.project_dir` attribute that \ - is set upon creation of the object. - - The ExperimentData object is stored in a subfolder 'experiment_data'. - - The ExperimentData object is stored in four files: - - * the input data (`input.csv`) - * the output data (`output.csv`) - * the jobs (`jobs.pkl`) - * the domain (`domain.pkl`) - - To avoid the ExperimentData to be written simultaneously by multiple \ - processes, a '.lock' file is automatically created \ - in the project directory. Concurrent process can only sequentially \ - access the lock file. This lock file is removed after the \ - ExperimentData object is written to disk. - """ - if project_dir is not None: - self.set_project_dir(project_dir) - - subdirectory = self.project_dir / EXPERIMENTDATA_SUBFOLDER - - # Create the subdirectory if it does not exist - subdirectory.mkdir(parents=True, exist_ok=True) - - self._input_data.store(subdirectory / Path(INPUT_DATA_FILENAME)) - self._output_data.store(subdirectory / Path(OUTPUT_DATA_FILENAME)) - self._jobs.store(subdirectory / Path(JOBS_FILENAME)) - self.domain.store(subdirectory / Path(DOMAIN_FILENAME)) - - def to_numpy(self) -> Tuple[np.ndarray, np.ndarray]: - """ - Convert the ExperimentData object to a tuple of numpy arrays. - - Returns - ------- - tuple - A tuple containing two numpy arrays, \ - the first one for input columns, \ - and the second for output columns. - """ - return self._input_data.to_numpy(), self._output_data.to_numpy() - - def to_pandas(self) -> Tuple[pd.DataFrame, pd.DataFrame]: - """ - Convert the ExperimentData object to a pandas DataFrame. - - Returns - ------- - tuple - A tuple containing two pandas DataFrames, \ - the first one for input columns, and the second for output - """ - return (self._input_data.to_dataframe(), - self._output_data.to_dataframe()) - - def to_xarray(self) -> xr.Dataset: - """ - Convert the ExperimentData object to an xarray Dataset. - - Returns - ------- - xarray.Dataset - An xarray Dataset containing the data. - """ - return xr.Dataset( - {'input': self._input_data.to_xarray('input_dim'), - 'output': self._output_data.to_xarray('output_dim')}) - - def get_n_best_output(self, n_samples: int) -> ExperimentData: - """Get the n best samples from the output data. \ - We consider lower values to be better. - - Parameters - ---------- - n_samples : int - Number of samples to select. - - Returns - ------- - ExperimentData - New experimentData object with a selection of the n best samples. - - Note - ---- - - The n best samples are selected based on the output data. \ - The output data is sorted based on the first output parameter. \ - The n best samples are selected based on this sorting. \ - """ - df = self._output_data.n_best_samples( - n_samples, self._output_data.names) - return self.select(df.index) - - # Append or remove data - # ========================================================================= - - def add(self, domain: Optional[Domain] = None, - input_data: Optional[DataTypes] = None, - output_data: Optional[DataTypes] = None, - jobs: Optional[Path | str] = None) -> None: - """Add data to the ExperimentData object. - - Parameters - ---------- - domain : Optional[Domain], optional - Domain of the added object, by default None - input_data : Optional[DataTypes], optional - input parameters of the added object, by default None - output_data : Optional[DataTypes], optional - output parameters of the added object, by default None - jobs : Optional[Path | str], optional - jobs off the added object, by default None - """ - self.add_experiments(ExperimentData( - domain=domain, input_data=input_data, - output_data=output_data, - jobs=jobs)) - - def add_experiments(self, - experiment_sample: ExperimentSample | ExperimentData - ) -> None: - """ - Add an ExperimentSample or ExperimentData to the ExperimentData - attribute. - - Parameters - ---------- - experiment_sample : ExperimentSample or ExperimentData - Experiment(s) to add. - - Raises - ------ - ValueError - If -after checked- the indices of the input and output data - objects are not equal. - """ - - if isinstance(experiment_sample, ExperimentData): - experiment_sample._reset_index() - self.domain += experiment_sample.domain - - self._input_data += experiment_sample._input_data - self._output_data += experiment_sample._output_data - self._jobs += experiment_sample._jobs - - # Check if indices of the internal objects are equal - if not (self._input_data.indices.equals(self._output_data.indices) - and self._input_data.indices.equals(self._jobs.indices)): - raise ValueError(f"Indices of the internal objects are not equal." - f"input_data {self._input_data.indices}, " - f"output_data {self._output_data.indices}," - f"jobs: {self._jobs.indices}") - - # Apparently you need to cast the types again - # TODO: Breaks if values are NaN or infinite - # self._input_data.cast_types(self.domain) - - def overwrite( - self, indices: Iterable[int], - domain: Optional[Domain] = None, - input_data: Optional[DataTypes] = None, - output_data: Optional[DataTypes] = None, - jobs: Optional[Path | str] = None, - add_if_not_exist: bool = False - ) -> None: - """Overwrite the ExperimentData object. - - Parameters - ---------- - indices : Iterable[int] - The indices to overwrite. - domain : Optional[Domain], optional - Domain of the new object, by default None - input_data : Optional[DataTypes], optional - input parameters of the new object, by default None - output_data : Optional[DataTypes], optional - output parameters of the new object, by default None - jobs : Optional[Path | str], optional - jobs off the new object, by default None - add_if_not_exist : bool, optional - If True, the new objects are added if the requested indices - do not exist in the current ExperimentData object, by default False - """ - - # Be careful, if a job has output data and gets overwritten with a - # job that has no output data, the status is set to open. But the job - # will still have the output data! - - # This is usually not a problem, because the output data will be - # immediately overwritten in optimization. - - self._overwrite_experiments( - indices=indices, - experiment_sample=ExperimentData( - domain=domain, input_data=input_data, - output_data=output_data, - jobs=jobs), - add_if_not_exist=add_if_not_exist) - - def _overwrite_experiments( - self, indices: Iterable[int], - experiment_sample: ExperimentSample | ExperimentData, - add_if_not_exist: bool) -> None: - """ - Overwrite the ExperimentData object at the given indices. - - Parameters - ---------- - indices : Iterable[int] - The indices to overwrite. - experimentdata : ExperimentData | ExperimentSample - The new ExperimentData object to overwrite with. - add_if_not_exist : bool - If True, the new objects are added if the requested indices - do not exist in the current ExperimentData object. - """ - if not all(pd.Index(indices).isin(self.index)): - if add_if_not_exist: - self.add_experiments(experiment_sample) - return - else: - raise ValueError( - f"The given indices {indices} do not exist in the current " - f"ExperimentData object. " - f"If you want to add the new experiments, " - f"set add_if_not_exist to True.") - - self._input_data.overwrite( - indices=indices, other=experiment_sample._input_data) - self._output_data.overwrite( - indices=indices, other=experiment_sample._output_data) - - self._jobs.overwrite( - indices=indices, other=experiment_sample._jobs) - - if isinstance(experiment_sample, ExperimentData): - self.domain += experiment_sample.domain - - @_access_file - def overwrite_disk( - self, indices: Iterable[int], - domain: Optional[Domain] = None, - input_data: Optional[DataTypes] = None, - output_data: Optional[DataTypes] = None, - jobs: Optional[Path | str] = None, - add_if_not_exist: bool = False - ) -> None: - self.overwrite(indices=indices, domain=domain, input_data=input_data, - output_data=output_data, jobs=jobs, - add_if_not_exist=add_if_not_exist) - - def add_input_parameter( - self, name: str, - type: Literal['float', 'int', 'category', 'constant'], - **kwargs): - """Add a new input column to the ExperimentData object. - - Parameters - ---------- - name - name of the new input column - type - type of the new input column: float, int, category or constant - kwargs - additional arguments for the new parameter - """ - self._input_data.add_column(name) - self.domain.add(name=name, type=type, **kwargs) - - def add_output_parameter( - self, name: str, is_disk: bool, exist_ok: bool = False) -> None: - """Add a new output column to the ExperimentData object. - - Parameters - ---------- - name - name of the new output column - is_disk - Whether the output column will be stored on disk or not - exist_ok - If True, it will not raise an error if the output column already - exists, by default False - """ - self._output_data.add_column(name, exist_ok=exist_ok) - self.domain.add_output(name=name, to_disk=is_disk, exist_ok=exist_ok) - - def remove_rows_bottom(self, number_of_rows: int): - """ - Remove a number of rows from the end of the ExperimentData object. - - Parameters - ---------- - number_of_rows : int - Number of rows to remove from the bottom. - """ - if number_of_rows == 0: - return # Don't do anything if 0 rows need to be removed - - # get the last indices from data.data - indices = self.index[-number_of_rows:] - - # remove the indices rows_to_remove from data.data - self._input_data.remove(indices) - self._output_data.remove(indices) - self._jobs.remove(indices) - - def _reset_index(self) -> None: - """ - Reset the index of the ExperimentData object. - """ - self._input_data.reset_index() - - if self._input_data.is_empty(): - self._output_data.reset_index() - else: - self._output_data.reset_index(self._input_data.indices) - self._jobs.reset_index() - - def join(self, other: ExperimentData) -> ExperimentData: - """Join two ExperimentData objects. - - Parameters - ---------- - other : ExperimentData - The other ExperimentData object to join with. - - Returns - ------- - ExperimentData - The joined ExperimentData object. - """ - return ExperimentData( - input_data=self._input_data.join(other._input_data), - output_data=self._output_data.join(other._output_data), - jobs=self._jobs, - domain=self.domain + other.domain, - project_dir=self.project_dir) -# ExperimentSample - # ============================================================================= - - def get_experiment_sample(self, index: int) -> ExperimentSample: - """ - Gets the experiment_sample at the given index. - - Parameters - ---------- - index : int - The index of the experiment_sample to retrieve. - - Returns - ------- - ExperimentSample - The ExperimentSample at the given index. - """ - output_experiment_sample_dict = self._output_data.get_data_dict(index) - - dict_output = {k: (v, self.domain.output_space[k].to_disk) - for k, v in output_experiment_sample_dict.items()} - - return ExperimentSample(dict_input=self._input_data.get_data_dict( - index), - dict_output=dict_output, - jobnumber=index, - experimentdata_directory=self.project_dir) - - def get_experiment_samples( - self, - indices: Optional[Iterable[int]] = None) -> List[ExperimentSample]: - """ - Gets the experiment_samples at the given indices. - - Parameters - ---------- - indices : Optional[Iterable[int]], optional - The indices of the experiment_samples to retrieve, by default None - If None, all experiment_samples are retrieved. - - Returns - ------- - List[ExperimentSample] - The ExperimentSamples at the given indices. - """ - if indices is None: - # Return a list of the iterator over ExperimentData - return list(self) - - return [self.get_experiment_sample(index) for index in indices] - - def _set_experiment_sample(self, - experiment_sample: ExperimentSample) -> None: - """ - Sets the ExperimentSample at the given index. - - Parameters - ---------- - experiment_sample : ExperimentSample - The ExperimentSample to set. - """ - for column, (value, is_disk) in experiment_sample._dict_output.items(): - - if not self.domain.is_in_output(column): - self.domain.add_output(column, to_disk=is_disk) - - self._output_data.set_data( - index=experiment_sample.job_number, value=value, - column=column) - - self._jobs.mark(experiment_sample._jobnumber, status=Status.FINISHED) - - @_access_file - def _write_experiment_sample(self, - experiment_sample: ExperimentSample) -> None: - """ - Sets the ExperimentSample at the given index. - - Parameters - ---------- - experiment_sample : ExperimentSample - The ExperimentSample to set. - """ - self._set_experiment_sample(experiment_sample) - - def _access_open_job_data(self) -> ExperimentSample: - """Get the data of the first available open job. - - Returns - ------- - ExperimentSample - The ExperimentSample object of the first available open job. - """ - job_index = self._jobs.get_open_job() - self._jobs.mark(job_index, status=Status.IN_PROGRESS) - experiment_sample = self.get_experiment_sample(job_index) - return experiment_sample - - @_access_file - def _get_open_job_data(self) -> ExperimentSample: - """Get the data of the first available open job by - accessing the ExperimenData on disk. - - Returns - ------- - ExperimentSample - The ExperimentSample object of the first available open job. - """ - return self._access_open_job_data() - - # Jobs - # ========================================================================= - - def _set_error(self, index: int) -> None: - """Mark the experiment_sample at the given index as error. - - Parameters - ---------- - index - index of the experiment_sample to mark as error - """ - # self.jobs.mark_as_error(index) - self._jobs.mark(index, status=Status.ERROR) - self._output_data.set_data( - index, - value=['ERROR' for _ in self._output_data.names]) - - @_access_file - def _write_error(self, index: int): - """Mark the experiment_sample at the given index as - error and write to ExperimentData file. - - Parameters - ---------- - index - index of the experiment_sample to mark as error - """ - self._set_error(index) - - @_access_file - def is_all_finished(self) -> bool: - """Check if all jobs are finished - - Returns - ------- - bool - True if all jobs are finished, False otherwise - """ - return self._jobs.is_all_finished() - - def mark(self, indices: Iterable[int], - status: Literal['open', 'in progress', 'finished', 'error']): - """Mark the jobs at the given indices with the given status. - - Parameters - ---------- - indices : Iterable[int] - indices of the jobs to mark - status : Literal['open', 'in progress', 'finished', 'error'] - status to mark the jobs with: choose between: 'open', \ - 'in progress', 'finished' or 'error' - - Raises - ------ - ValueError - If the given status is not any of 'open', 'in progress', \ - 'finished' or 'error' - """ - # Check if the status is in Status - if not any(status.lower() == s.value for s in Status): - raise ValueError(f"Invalid status {status} given. " - f"\nChoose from values: " - f"{', '.join([s.value for s in Status])}") - - self._jobs.mark(indices, status) - - def mark_all(self, - status: Literal['open', 'in progress', 'finished', 'error']): - """Mark all the experiments with the given status - - Parameters - ---------- - status : Literal['open', 'in progress', 'finished', 'error'] - status to mark the jobs with: \ - choose between: - - * 'open', - * 'in progress', - * 'finished' - * 'error' - - Raises - ------ - ValueError - If the given status is not any of \ - 'open', 'in progress', 'finished' or 'error' - """ - self.mark(self._jobs.indices, status) - - def mark_all_error_open(self) -> None: - """ - Mark all the experiments that have the status 'error' open - """ - self._jobs.mark_all_error_open() - - def mark_all_in_progress_open(self) -> None: - """ - Mark all the experiments that have the status 'in progress' open - """ - self._jobs.mark_all_in_progress_open() - - def mark_all_nan_open(self) -> None: - """ - Mark all the experiments that have 'nan' in output open - """ - indices = self._output_data.get_index_with_nan() - self.mark(indices=indices, status='open') - # Datageneration - # ========================================================================= - - def evaluate(self, data_generator: DataGenerator, - mode: Literal['sequential', 'parallel', - 'cluster', 'cluster_parallel'] = 'sequential', - kwargs: Optional[dict] = None, - output_names: Optional[List[str]] = None) -> None: - """Run any function over the entirety of the experiments - - Parameters - ---------- - data_generator : DataGenerator - data generator to use - mode : str, optional - operational mode, by default 'sequential'. Choose between: - - * 'sequential' : Run the operation sequentially - * 'parallel' : Run the operation on multiple cores - * 'cluster' : Run the operation on the cluster - * 'cluster_parallel' : Run the operation on the cluster in parallel - - kwargs, optional - Any keyword arguments that need to - be supplied to the function, by default None - output_names : List[str], optional - If you provide a function as data generator, you have to provide - the names of all the output parameters that are in the return - statement, in order of appearance. - - Raises - ------ - ValueError - Raised when invalid parallelization mode is specified - """ - if kwargs is None: - kwargs = {} - - if inspect.isfunction(data_generator): - if output_names is None: - raise TypeError( - ("If you provide a function as data generator, you have to" - "provide the names of the return arguments with the" - "output_names attribute.")) - data_generator = convert_function( - f=data_generator, output=output_names) - - elif isinstance(data_generator, str): - data_generator = _datagenerator_factory( - data_generator, self.domain, kwargs) - - if mode.lower() == "sequential": - return self._run_sequential(data_generator, kwargs) - elif mode.lower() == "parallel": - return self._run_multiprocessing(data_generator, kwargs) - elif mode.lower() == "cluster": - return self._run_cluster(data_generator, kwargs) - elif mode.lower() == "cluster_parallel": - return self._run_cluster_parallel(data_generator, kwargs) - else: - raise ValueError("Invalid parallelization mode specified.") - - def _run_sequential(self, data_generator: DataGenerator, kwargs: dict): - """Run the operation sequentially - - Parameters - ---------- - operation : ExperimentSampleCallable - function execution for every entry in the ExperimentData object - kwargs : dict - Any keyword arguments that need to be supplied to the function - - Raises - ------ - NoOpenJobsError - Raised when there are no open jobs left - """ - while True: - try: - experiment_sample = self._access_open_job_data() - logger.debug( - f"Accessed experiment_sample \ - {experiment_sample._jobnumber}") - except NoOpenJobsError: - logger.debug("No Open Jobs left") - break - - try: - - # If kwargs is empty dict - if not kwargs: - logger.debug( - f"Running experiment_sample " - f"{experiment_sample._jobnumber}") - else: - logger.debug( - f"Running experiment_sample " - f"{experiment_sample._jobnumber} with kwargs {kwargs}") - - _experiment_sample = data_generator._run( - experiment_sample, **kwargs) # no *args! - self._set_experiment_sample(_experiment_sample) - except Exception as e: - error_msg = f"Error in experiment_sample \ - {experiment_sample._jobnumber}: {e}" - error_traceback = traceback.format_exc() - logger.error(f"{error_msg}\n{error_traceback}") - self._set_error(experiment_sample._jobnumber) - - def _run_multiprocessing(self, data_generator: DataGenerator, - kwargs: dict): - """Run the operation on multiple cores - - Parameters - ---------- - operation : ExperimentSampleCallable - function execution for every entry in the ExperimentData object - kwargs : dict - Any keyword arguments that need to be supplied to the function - - Raises - ------ - NoOpenJobsError - Raised when there are no open jobs left - """ - # Get all the jobs - options = [] - while True: - try: - experiment_sample = self._access_open_job_data() - options.append( - ({'experiment_sample': experiment_sample, **kwargs},)) - except NoOpenJobsError: - break - - def f(options: Dict[str, Any]) -> Tuple[ExperimentSample, int]: - try: - - logger.debug( - f"Running experiment_sample " - f"{options['experiment_sample'].job_number}") - - return (data_generator._run(**options), 0) # no *args! - - except Exception as e: - error_msg = f"Error in experiment_sample \ - {options['experiment_sample'].job_number}: {e}" - error_traceback = traceback.format_exc() - logger.error(f"{error_msg}\n{error_traceback}") - return (options['experiment_sample'], 1) - - with mp.Pool() as pool: - # maybe implement pool.starmap_async ? - _experiment_samples: List[ - Tuple[ExperimentSample, int]] = pool.starmap(f, options) - - for _experiment_sample, exit_code in _experiment_samples: - if exit_code == 0: - self._set_experiment_sample(_experiment_sample) - else: - self._set_error(_experiment_sample.job_number) - - def _run_cluster(self, data_generator: DataGenerator, kwargs: dict): - """Run the operation on the cluster - - Parameters - ---------- - operation : ExperimentSampleCallable - function execution for every entry in the ExperimentData object - kwargs : dict - Any keyword arguments that need to be supplied to the function - - Raises - ------ - NoOpenJobsError - Raised when there are no open jobs left - """ - # Retrieve the updated experimentdata object from disc - try: - self = self.from_file(self.project_dir) - except FileNotFoundError: # If not found, store current - self.store() - - while True: - try: - experiment_sample = self._get_open_job_data() - except NoOpenJobsError: - logger.debug("No Open jobs left!") - break - - try: - _experiment_sample = data_generator._run( - experiment_sample, **kwargs) - self._write_experiment_sample(_experiment_sample) - except Exception: - n = experiment_sample.job_number - error_msg = f"Error in experiment_sample {n}: " - error_traceback = traceback.format_exc() - logger.error(f"{error_msg}\n{error_traceback}") - self._write_error(experiment_sample._jobnumber) - continue - - self = self.from_file(self.project_dir) - # Remove the lockfile from disk - (self.project_dir / EXPERIMENTDATA_SUBFOLDER / LOCK_FILENAME - ).with_suffix('.lock').unlink(missing_ok=True) - - def _run_cluster_parallel( - self, data_generator: DataGenerator, kwargs: dict): - """Run the operation on the cluster and parallelize it over cores - - Parameters - ---------- - operation : ExperimentSampleCallable - function execution for every entry in the ExperimentData object - kwargs : dict - Any keyword arguments that need to be supplied to the function - - Raises - ------ - NoOpenJobsError - Raised when there are no open jobs left - """ - # Retrieve the updated experimentdata object from disc - try: - self = self.from_file(self.project_dir) - except FileNotFoundError: # If not found, store current - self.store() - - no_jobs = False - - while True: - es_list = [] - for core in range(mp.cpu_count()): - try: - es_list.append(self._get_open_job_data()) - except NoOpenJobsError: - logger.debug("No Open jobs left!") - no_jobs = True - break - - d = self.select([e.job_number for e in es_list]) - - d._run_multiprocessing( - data_generator=data_generator, kwargs=kwargs) - - # TODO access resource first! - self.overwrite_disk( - indices=d.index, input_data=d._input_data, - output_data=d._output_data, jobs=d._jobs, - domain=d.domain, add_if_not_exist=False) - - if no_jobs: - break - - self = self.from_file(self.project_dir) - # Remove the lockfile from disk - (self.project_dir / EXPERIMENTDATA_SUBFOLDER / LOCK_FILENAME - ).with_suffix('.lock').unlink(missing_ok=True) - - # Optimization - # ========================================================================= - - def optimize(self, optimizer: Optimizer | str, - data_generator: DataGenerator | str, - iterations: int, - kwargs: Optional[Dict[str, Any]] = None, - hyperparameters: Optional[Dict[str, Any]] = None, - x0_selection: Literal['best', 'random', - 'last', - 'new'] | ExperimentData = 'best', - sampler: Optional[Sampler | str] = 'random', - overwrite: bool = False, - callback: Optional[Callable] = None) -> None: - """Optimize the experimentdata object - - Parameters - ---------- - optimizer : Optimizer | str - Optimizer object - data_generator : DataGenerator | str - DataGenerator object - iterations : int - number of iterations - kwargs : Dict[str, Any], optional - any additional keyword arguments that will be passed to - the DataGenerator - hyperparameters : Dict[str, Any], optional - any additional keyword arguments that will be passed to - the optimizer - x0_selection : str | ExperimentData - How to select the initial design. By default 'best' - The following x0_selections are available: - - * 'best': Select the best designs from the current experimentdata - * 'random': Select random designs from the current experimentdata - * 'last': Select the last designs from the current experimentdata - * 'new': Create new random designs from the current experimentdata - - If the x0_selection is 'new', new designs are sampled with the - sampler provided. The number of designs selected is equal to the - population size of the optimizer. - - If an ExperimentData object is passed as x0_selection, - the optimizer will use the input_data and output_data from this - object as initial samples. - sampler: Sampler, optional - If x0_selection = 'new', the sampler to use. By default 'random' - overwrite: bool, optional - If True, the optimizer will overwrite the current data. By default - False - callback : Callable, optional - A callback function that is called after every iteration. It has - the following signature: - - ``callback(intermediate_result: ExperimentData)`` - - where the first argument is a parameter containing an - `ExperimentData` object with the current iterate(s). - - Raises - ------ - ValueError - Raised when invalid x0_selection is specified - """ - # Create the data generator object if a string reference is passed - if isinstance(data_generator, str): - data_generator: DataGenerator = _datagenerator_factory( - data_generator=data_generator, - domain=self.domain, kwargs=kwargs) - - # Create a copy of the optimizer object - _optimizer = copy(optimizer) - - # Create the optimizer object if a string reference is passed - if isinstance(_optimizer, str): - _optimizer: Optimizer = _optimizer_factory( - _optimizer, self.domain, hyperparameters) - - # Create the sampler object if a string reference is passed - if isinstance(sampler, str): - sampler: Sampler = _sampler_factory(sampler, self.domain) - - if _optimizer.type == 'scipy': - self._iterate_scipy( - optimizer=_optimizer, data_generator=data_generator, - iterations=iterations, kwargs=kwargs, - x0_selection=x0_selection, - sampler=sampler, - overwrite=overwrite, - callback=callback) - else: - self._iterate( - optimizer=_optimizer, data_generator=data_generator, - iterations=iterations, kwargs=kwargs, - x0_selection=x0_selection, - sampler=sampler, - overwrite=overwrite, - callback=callback) - - def _iterate(self, optimizer: Optimizer, data_generator: DataGenerator, - iterations: int, kwargs: Dict[str, Any], x0_selection: str, - sampler: Sampler, overwrite: bool, - callback: Callable): - """Internal represenation of the iteration process - - Parameters - ---------- - optimizer : Optimizer - Optimizer object - data_generator : DataGenerator - DataGenerator object - iterations : int - number of iterations - kwargs : Dict[str, Any] - any additional keyword arguments that will be passed to - the DataGenerator - x0_selection : str | ExperimentData - How to select the initial design. - The following x0_selections are available: - - * 'best': Select the best designs from the current experimentdata - * 'random': Select random designs from the current experimentdata - * 'last': Select the last designs from the current experimentdata - * 'new': Create new random designs from the current experimentdata - - If the x0_selection is 'new', new designs are sampled with the - sampler provided. The number of designs selected is equal to the - population size of the optimizer. - - If an ExperimentData object is passed as x0_selection, - the optimizer will use the input_data and output_data from this - object as initial samples. - - sampler: Sampler - If x0_selection = 'new', the sampler to use - overwrite: bool - If True, the optimizer will overwrite the current data. - callback : Callable - A callback function that is called after every iteration. It has - the following signature: - - ``callback(intermediate_result: ExperimentData)`` - - where the first argument is a parameter containing an - `ExperimentData` object with the current iterate(s). - - Raises - ------ - ValueError - Raised when invalid x0_selection is specified - """ - last_index = self.index[-1] if not self.index.empty else -1 - - if isinstance(x0_selection, str): - if x0_selection == 'new': - - if iterations < optimizer._population: - raise ValueError( - f'For creating new samples, the total number of ' - f'requested iterations ({iterations}) cannot be ' - f'smaller than the population size ' - f'({optimizer._population})') - - init_samples = ExperimentData.from_sampling( - domain=self.domain, - sampler=sampler, - n_samples=optimizer._population, - seed=optimizer._seed) - - init_samples.evaluate( - data_generator=data_generator, kwargs=kwargs, - mode='sequential') - - if callback is not None: - callback(init_samples) - - if overwrite: - _indices = init_samples.index + last_index + 1 - self._overwrite_experiments( - experiment_sample=init_samples, - indices=_indices, - add_if_not_exist=True) - - else: - self.add_experiments(init_samples) - - x0_selection = 'last' - iterations -= optimizer._population - - x0 = x0_factory(experiment_data=self, mode=x0_selection, - n_samples=optimizer._population) - optimizer._set_data(x0) - - optimizer._check_number_of_datapoints() - - optimizer._construct_model(data_generator) - - for _ in range(number_of_updates( - iterations, - population=optimizer._population)): - new_samples = optimizer.update_step(data_generator) - - # If new_samples is a tuple of input_data and output_data - if isinstance(new_samples, tuple): - new_samples = ExperimentData( - domain=self.domain, - input_data=new_samples[0], - output_data=new_samples[1], - ) - # If applicable, evaluate the new designs: - new_samples.evaluate( - data_generator, mode='sequential', kwargs=kwargs) - - if callback is not None: - callback(new_samples) - - if overwrite: - _indices = new_samples.index + last_index + 1 - self._overwrite_experiments(experiment_sample=new_samples, - indices=_indices, - add_if_not_exist=True) - - else: - self.add_experiments(new_samples) - - optimizer._set_data(self) - - if not overwrite: - # Remove overiterations - self.remove_rows_bottom(number_of_overiterations( - iterations, - population=optimizer._population)) - - # Reset the optimizer - # optimizer.reset(ExperimentData(domain=self.domain)) - - def _iterate_scipy(self, optimizer: Optimizer, - data_generator: DataGenerator, - iterations: int, kwargs: dict, - x0_selection: str | ExperimentData, - sampler: Sampler, overwrite: bool, - callback: Callable): - """Internal represenation of the iteration process for scipy-minimize - optimizers. - - Parameters - ---------- - optimizer : Optimizer - Optimizer object - data_generator : DataGenerator - DataGenerator object - iterations : int - number of iterations - kwargs : Dict[str, Any] - any additional keyword arguments that will be passed to - the DataGenerator - x0_selection : str | ExperimentData - How to select the initial design. - The following x0_selections are available: - - * 'best': Select the best designs from the current experimentdata - * 'random': Select random designs from the current experimentdata - * 'last': Select the last designs from the current experimentdata - * 'new': Create new random designs from the current experimentdata - - If the x0_selection is 'new', new designs are sampled with the - sampler provided. The number of designs selected is equal to the - population size of the optimizer. - - If an ExperimentData object is passed as x0_selection, - the optimizer will use the input_data and output_data from this - object as initial samples. - - sampler: Sampler - If x0_selection = 'new', the sampler to use - overwrite: bool - If True, the optimizer will overwrite the current data. - callback : Callable - A callback function that is called after every iteration. It has - the following signature: - - ``callback(intermediate_result: ExperimentData)`` - - where the first argument is a parameter containing an - `ExperimentData` object with the current iterate(s). - - Raises - ------ - ValueError - Raised when invalid x0_selection is specified - """ - last_index = self.index[-1] if not self.index.empty else -1 - n_data_before_iterate = len(self) - - if isinstance(x0_selection, str): - if x0_selection == 'new': - - if iterations < optimizer._population: - raise ValueError( - f'For creating new samples, the total number of ' - f'requested iterations ({iterations}) cannot be ' - f'smaller than the population size ' - f'({optimizer._population})') - - init_samples = ExperimentData.from_sampling( - domain=self.domain, - sampler=sampler, - n_samples=optimizer._population, - seed=optimizer._seed) - - init_samples.evaluate( - data_generator=data_generator, kwargs=kwargs, - mode='sequential') - - if callback is not None: - callback(init_samples) - - if overwrite: - _indices = init_samples.index + last_index + 1 - self._overwrite_experiments( - experiment_sample=init_samples, - indices=_indices, - add_if_not_exist=True) - - else: - self.add_experiments(init_samples) - - x0_selection = 'last' - - x0 = x0_factory(experiment_data=self, mode=x0_selection, - n_samples=optimizer._population) - optimizer._set_data(x0) - - optimizer._check_number_of_datapoints() - - optimizer.run_algorithm(iterations, data_generator) - - new_samples: ExperimentData = optimizer.data.select( - optimizer.data.index[1:]) - new_samples.evaluate(data_generator, mode='sequential', kwargs=kwargs) - - if callback is not None: - callback(new_samples) - - if overwrite: - self.add_experiments( - optimizer.data.select([optimizer.data.index[-1]])) - - elif not overwrite: - # Do not add the first element, as this is already - # in the sampled data - self.add_experiments(new_samples) - - # TODO: At the end, the data should have - # n_data_before_iterate + iterations amount of elements! - # If x_new is empty, repeat best x0 to fill up total iteration - if len(self) == n_data_before_iterate: - repeated_sample = self.get_n_best_output( - n_samples=1) - - for repetition in range(iterations): - self.add_experiments(repeated_sample) - - # Repeat last iteration to fill up total iteration - if len(self) < n_data_before_iterate + iterations: - last_design = self.get_experiment_sample(len(self)-1) - - while len(self) < n_data_before_iterate + iterations: - self.add_experiments(last_design) - - # Evaluate the function on the extra iterations - self.evaluate(data_generator, mode='sequential', kwargs=kwargs) - - # Reset the optimizer - # optimizer.reset(ExperimentData(domain=self.domain)) - - # Sampling - # ========================================================================= - - def sample(self, sampler: Sampler | SamplerNames, n_samples: int = 1, - seed: Optional[int] = None, **kwargs) -> None: - """Sample data from the domain providing the sampler strategy - - Parameters - ---------- - sampler: Sampler | str - Sampler callable or string of built-in sampler - If a string is passed, it should be one of the built-in samplers: - - * 'random' : Random sampling - * 'latin' : Latin Hypercube Sampling - * 'sobol' : Sobol Sequence Sampling - * 'grid' : Grid Search Sampling - n_samples : int, optional - Number of samples to generate, by default 1 - seed : Optional[int], optional - Seed to use for the sampler, by default None - - Note - ---- - When using the 'grid' sampler, an optional argument - 'stepsize_continuous_parameters' can be passed to specify the stepsize - to cast continuous parameters to discrete parameters. - - - The stepsize should be a dictionary with the parameter names as keys\ - and the stepsize as values. - - Alternatively, a single stepsize can be passed for all continuous\ - parameters. - - Raises - ------ - ValueError - Raised when invalid sampler type is specified - """ - - if isinstance(sampler, str): - sampler = _sampler_factory(sampler, self.domain) - - sample_data: DataTypes = sampler( - domain=self.domain, n_samples=n_samples, seed=seed, **kwargs) - self.add(input_data=sample_data, domain=self.domain) - - # Project directory - # ========================================================================= - - def set_project_dir(self, project_dir: Path | str): - """Set the directory of the f3dasm project folder. - - Parameters - ---------- - project_dir : Path or str - Path to the project directory - """ - self.project_dir = _project_dir_factory(project_dir) - - -def x0_factory(experiment_data: ExperimentData, - mode: str | ExperimentData, n_samples: int): - """Set the initial population to the best n samples of the given data - - Parameters - ---------- - experiment_data : ExperimentData - Data to be used for the initial population - mode : str - Mode of selecting the initial population, by default 'best' - The following modes are available: - - - best: select the best n samples - - random: select n random samples - - last: select the last n samples - n_samples : int - Number of samples to select - - Raises - ------ - ValueError - Raises when the mode is not recognized - """ - if isinstance(mode, ExperimentData): - x0 = mode - - elif mode == 'best': - x0 = experiment_data.get_n_best_output(n_samples) - - elif mode == 'random': - x0 = experiment_data.select( - np.random.choice( - experiment_data.index, - size=n_samples, replace=False)) - - elif mode == 'last': - x0 = experiment_data.select( - experiment_data.index[-n_samples:]) - - else: - raise ValueError( - f'Unknown selection mode {mode}, use best, random or last') - - x0._reset_index() - return x0 diff --git a/src/f3dasm/_src/experimentdata/experimentsample.py b/src/f3dasm/_src/experimentdata/experimentsample.py deleted file mode 100644 index dfea1f31..00000000 --- a/src/f3dasm/_src/experimentdata/experimentsample.py +++ /dev/null @@ -1,393 +0,0 @@ -""" -A ExperimentSample object contains a single realization of - the design-of-experiment in ExperimentData. -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from pathlib import Path -from typing import Any, Dict, Literal, Optional, Tuple, Type - -# Third-party -import autograd.numpy as np - -# Local -from ..design.domain import Domain -from ..logger import logger -from ._io import StoreProtocol, load_object, save_object - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= - - -class ExperimentSample: - def __init__(self, dict_input: Dict[str, Any], - dict_output: Dict[str, Tuple[Any, bool]], - jobnumber: int, - experimentdata_directory: Optional[Path] = None): - """Single realization of a design of experiments. - - Parameters - ---------- - dict_input : Dict[str, Any] - Input parameters of one experiment. \ - The key is the name of the parameter. - dict_output : Dict[str, Tuple[Any, bool]] - Output parameters of one experiment. \ - The key is the name of the parameter, \ - the first value of the tuple is the actual value and the second \ - if the value is stored to disk or not. - jobnumber : int - Index of the experiment - """ - self._dict_input = dict_input - self._dict_output = dict_output - self._jobnumber = jobnumber - - if experimentdata_directory is None: - experimentdata_directory = Path.cwd() - - self._experimentdata_directory = experimentdata_directory - - @classmethod - def from_numpy(cls: Type[ExperimentSample], input_array: np.ndarray, - output_value: Optional[float] = None, - jobnumber: int = 0, - domain: Optional[Domain] = None) -> ExperimentSample: - """Create a ExperimentSample object from a numpy array. - - Parameters - ---------- - input_array : np.ndarray - input 1D numpy array - output_value : Optional[float], optional - objective value, by default None - jobnumber : int - jobnumber of the design - domain : Optional[Domain], optional - domain of the design, by default None - - Returns - ------- - ExperimentSample - ExperimentSample object - - Note - ---- - If no domain is given, the default parameter names are used. - These are x0, x1, x2, etc. for input and y for output. - """ - if domain is None: - dict_input, dict_output = cls._from_numpy_without_domain( - input_array=input_array, output_value=output_value) - - else: - dict_input, dict_output = cls._from_numpy_with_domain( - input_array=input_array, domain=domain, - output_value=output_value) - - return cls(dict_input=dict_input, - dict_output=dict_output, jobnumber=jobnumber) - - @classmethod - def _from_numpy_with_domain( - cls: Type[ExperimentSample], - input_array: np.ndarray, - domain: Domain, - output_value: Optional[float] = None - ) -> Tuple[Dict[str, Any], Dict[str, Any]]: - - dict_input = {name: val for name, - val in zip(domain.names, input_array)} - - if output_value is None: - dict_output = {name: (np.nan, False) - for name in domain.output_space.keys()} - else: - dict_output = { - name: (output_value, False) for - name in domain.output_space.keys()} - - return dict_input, dict_output - - @classmethod - def _from_numpy_without_domain( - cls: Type[ExperimentSample], - input_array: np.ndarray, - output_value: Optional[float] = None - ) -> Tuple[Dict[str, Any], Dict[str, Any]]: - - default_input_names = [f"x{i}" for i in range(len(input_array))] - default_output_name = "y" - - dict_input = { - name: val for name, val - in zip(default_input_names, input_array)} - - if output_value is None: - dict_output = {name: (np.nan, False) - for name in default_output_name} - else: - dict_output = {default_output_name: (output_value, False)} - - return dict_input, dict_output - - def get(self, item: str, - load_method: Optional[Type[StoreProtocol]] = None) -> Any: - """Retrieve a sample parameter by its name. - - Parameters - ---------- - item : str - name of the parameter - load_method : Optional[Type[_Store]], optional - class of defined type to load the data. By default None, \ - will try to load the data with the default methods - - Returns - ------- - Any - Value of the parameter of the sample - """ - # Load the value literally (even if it is a reference) - value, from_disk = self._load_from_experimentdata(item) - - if not from_disk: - return value - - if isinstance(value, float): - # value is NaN - return value - - # Load the object from the reference - return load_object(Path(value), - self._experimentdata_directory, load_method) - - def _load_from_experimentdata(self, item: str) -> Tuple[Any, bool]: - """Load the data from the experiment data. - - Parameters - ---------- - item : str - key of the data to load - - Returns - ------- - Tuple[Any, bool] - data and if it is stored to disk or not - """ - value = self._dict_input.get(item, None) - - if value is None: - return self._dict_output.get(item, None) - else: - return value, False - - def __setitem__(self, key: str, value: Any): - self._dict_output[key] = (value, False) - - def __repr__(self) -> str: - return (f"ExperimentSample({self.job_number} ({self.jobs}) :" - f"{self.input_data} - {self.output_data})") - - @property - def input_data(self) -> Dict[str, Any]: - """Retrieve the input data of the design as a dictionary. - - Returns - ------- - Dict[str, Any] - The input data of the design as a dictionary. - """ - return self._dict_input - - _input_data = input_data - - @property - def output_data(self) -> Dict[str, Any]: - """Retrieve the output data of the design as a dictionary. - - Returns - ------- - Dict[str, Any] - The output data of the design as a dictionary. - """ - # This is the loaded data ! - return {key: self.get(key) for key in self._dict_output} - - # create an alias for output_data names output_data_loaded - # this is for backward compatibility - output_data_loaded = output_data - - _output_data = output_data - - @property - def output_data_with_references(self) -> Dict[str, Any]: - """Retrieve the output data of the design as a dictionary, \ - but refrain from loading the data from disk and give the references. - - Note - ---- - If you want to use the data, you can load it in memory with the \ - :func:`output_data` property. - - Returns - ------- - Dict[str, Any] - The output data of the design as a dictionary with references. - """ - return {key: value for key, (value, _) in self._dict_output.items()} - - @property - def job_number(self) -> int: - """Retrieve the job number of the design. - - Returns - ------- - int - The job number of the design. - """ - return self._jobnumber - - @property - def jobs(self) -> Literal['finished', 'open']: - """Retrieve the job status. - - Returns - ------- - str - The job number of the design as a tuple. - """ - # Check if the output contains values or not all nan - has_all_nan = np.all(np.isnan(list(self._output_data.values()))) - - if self._output_data and not has_all_nan: - status = 'finished' - else: - status = 'open' - - return status - - # Alias - _jobs = jobs - -# Export -# ============================================================================= - - def to_numpy(self) -> Tuple[np.ndarray, np.ndarray]: - """Converts the design to a tuple of numpy arrays. - - Returns - ------- - Tuple[np.ndarray, np.ndarray] - A tuple of numpy arrays containing the input and output data. - """ - return np.array(list(self.input_data.values())), np.array( - list(self.output_data.values())) - - def to_dict(self) -> Dict[str, Any]: - """Converts the design to a dictionary. - - Returns - ------- - Dict[str, Any] - A dictionary containing the input and output data. - """ - return {**self.input_data, **self.output_data, - 'job_number': self.job_number} - - def store(self, name: str, object: Any, to_disk: bool = False, - store_method: Optional[Type[StoreProtocol]] = None) -> None: - """Store an object to disk. - - Parameters - ---------- - - name : str - The name of the file to store the object in. - object : Any - The object to store. - to_disk : bool, optional - Whether to store the object to disk, by default False - store_method : Store, optional - The method to use to store the object, by default None - - Note - ---- - If to_disk is True and no store_method is provided, the default store \ - method will be used. - - The default store method is saving the object as a pickle file (.pkl). - """ - if to_disk: - self._store_to_disk(object=object, name=name, - store_method=store_method) - else: - self._store_to_experimentdata(object=object, name=name) - - def _store_to_disk( - self, object: Any, name: str, - store_method: Optional[Type[StoreProtocol]] = None) -> None: - file_path = Path(name) / str(self.job_number) - - # Check if the file_dir exists - (self._experimentdata_directory / Path(name) - ).mkdir(parents=True, exist_ok=True) - - # Save the object to disk - suffix = save_object( - object=object, path=file_path, - experimentdata_directory=self._experimentdata_directory, - store_method=store_method) - - # Store the path to the object in the output_data - self._dict_output[name] = (str( - file_path.with_suffix(suffix)), True) - - logger.info(f"Stored {name} to {file_path.with_suffix(suffix)}") - - def _store_to_experimentdata(self, object: Any, name: str) -> None: - self._dict_output[name] = (object, False) - - -def _experimentsample_factory( - experiment_sample: np.ndarray | ExperimentSample | Dict, - domain: Domain | None) \ - -> ExperimentSample: - """Factory function for the ExperimentSample class. - - Parameters - ---------- - experiment_sample : np.ndarray | ExperimentSample | Dict - The experiment sample to convert to an ExperimentSample. - domain: Domain | None - The domain of the experiment sample. - - Returns - ------- - ExperimentSample - The converted experiment sample. - """ - if isinstance(experiment_sample, np.ndarray): - return ExperimentSample.from_numpy(input_array=experiment_sample, - domain=domain) - - elif isinstance(experiment_sample, dict): - return ExperimentSample(dict_input=experiment_sample, - dict_output={}, jobnumber=0) - - elif isinstance(experiment_sample, ExperimentSample): - return experiment_sample - - else: - raise TypeError( - f"The experiment_sample should be a numpy array" - f", dictionary or ExperimentSample, not {type(experiment_sample)}") diff --git a/src/f3dasm/_src/experimentdata/samplers.py b/src/f3dasm/_src/experimentdata/samplers.py deleted file mode 100644 index 307a58ec..00000000 --- a/src/f3dasm/_src/experimentdata/samplers.py +++ /dev/null @@ -1,465 +0,0 @@ -"""Base class for sampling methods""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from itertools import product -from typing import Dict, Literal, Optional, Protocol - -# Third-party -import numpy as np -import pandas as pd -from SALib.sample import latin as salib_latin -from SALib.sample import sobol_sequence - -# Locals -from ..design.domain import Domain -from ._data import DataTypes - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - -SamplerNames = Literal['random', 'latin', 'sobol', 'grid'] - - -class Sampler(Protocol): - """ - Interface class for samplers - """ - def __call__(domain: Domain, **kwargs) -> DataTypes: - ... - -# Factory function -# ============================================================================= - - -def _sampler_factory(sampler: str, domain: Domain) -> Sampler: - """ - Factory function for samplers - - Parameters - ---------- - sampler : str - name of the sampler - domain : Domain - domain object - - Returns - ------- - Sampler - sampler object - """ - if sampler.lower() == 'random': - return randomuniform - - elif sampler.lower() == 'latin': - return latin - - elif sampler.lower() == 'sobol': - return sobol - - elif sampler.lower() == 'grid': - return grid - - else: - raise KeyError(f"Sampler {sampler} not found!" - f"Available built-in samplers are: 'random'," - f"'latin' and 'sobol'") - - -# Utility functions -# ============================================================================= - -def _stretch_samples(domain: Domain, samples: np.ndarray) -> np.ndarray: - """Stretch samples to their boundaries - - Parameters - ---------- - domain : Domain - domain object - samples : np.ndarray - samples to stretch - - Returns - ------- - np.ndarray - stretched samples - """ - for dim, param in enumerate(domain.space.values()): - samples[:, dim] = ( - samples[:, dim] * ( - param.upper_bound - param.lower_bound - ) + param.lower_bound - ) - - # If param.log is True, take the 10** of the samples - if param.log: - samples[:, dim] = 10**samples[:, dim] - - return samples - - -def sample_constant(domain: Domain, n_samples: int): - """Sample the constant parameters - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - - Returns - ------- - np.ndarray - samples - """ - samples = np.array([param.value for param in domain.space.values()]) - return np.tile(samples, (n_samples, 1)) - - -def sample_np_random_choice( - domain: Domain, n_samples: int, - seed: Optional[int] = None, **kwargs): - """Sample with np random choice - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : Optional[int], optional - random seed, by default None - - Returns - ------- - np.ndarray - samples - """ - rng = np.random.default_rng(seed) - samples = np.empty(shape=(n_samples, len(domain)), dtype=object) - for dim, param in enumerate(domain.space.values()): - samples[:, dim] = rng.choice( - param.categories, size=n_samples) - - return samples - - -def sample_np_random_choice_range( - domain: Domain, n_samples: int, - seed: Optional[int] = None, **kwargs): - """Samples with np random choice with a range of values - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : Optional[int], optional - random seed, by default None - - Returns - ------- - np.ndarray - samples - """ - samples = np.empty(shape=(n_samples, len(domain)), dtype=np.int32) - rng = np.random.default_rng(seed) - for dim, param in enumerate(domain.space.values()): - samples[:, dim] = rng.choice( - range(param.lower_bound, - param.upper_bound + 1, param.step), - size=n_samples, - ) - - return samples - - -def sample_np_random_uniform( - domain: Domain, n_samples: int, - seed: Optional[int] = None, **kwargs) -> np.ndarray: - """Sample with numpy random uniform - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : Optional[int], optional - random seed, by default None - - Returns - ------- - np.ndarray - samples - """ - rng = np.random.default_rng(seed) - samples = rng.uniform(low=0.0, high=1.0, size=(n_samples, len(domain))) - - # stretch samples - samples = _stretch_samples(domain, samples) - return samples - - -def sample_latin_hypercube( - domain: Domain, n_samples: int, - seed: Optional[int] = None, **kwargs) -> np.ndarray: - """Sample with Latin Hypercube sampling - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : Optional[int], optional - random seed, by default None - - Returns - ------- - np.ndarray - samples - """ - problem = { - "num_vars": len(domain), - "names": domain.names, - "bounds": [[s.lower_bound, s.upper_bound] - for s in domain.space.values()], - } - - samples = salib_latin.sample(problem, N=n_samples, seed=seed) - return samples - - -def sample_sobol_sequence( - domain: Domain, n_samples: int, **kwargs) -> np.ndarray: - """Sample with Sobol sequence sampling - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - - Returns - ------- - np.ndarray - samples - """ - samples = sobol_sequence.sample(n_samples, len(domain)) - - # stretch samples - samples = _stretch_samples(domain, samples) - return samples - - -# Built-in samplers -# ============================================================================= - - -def randomuniform( - domain: Domain, n_samples: int, seed: int, **kwargs) -> DataTypes: - """ - Random uniform sampling - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : int - random seed for reproducibility - - Returns - ------- - DataTypes - input samples in one of the supported data types for the ExperimentData - input data. - """ - _continuous = sample_np_random_uniform( - domain=domain.continuous, n_samples=n_samples, - seed=seed) - - _discrete = sample_np_random_choice_range( - domain=domain.discrete, n_samples=n_samples, - seed=seed) - - _categorical = sample_np_random_choice( - domain=domain.categorical, n_samples=n_samples, - seed=seed) - - _constant = sample_constant(domain.constant, n_samples) - - df = pd.concat( - [pd.DataFrame(_continuous, columns=domain.continuous.names), - pd.DataFrame(_discrete, columns=domain.discrete.names), - pd.DataFrame( - _categorical, columns=domain.categorical.names), - pd.DataFrame(_constant, columns=domain.constant.names)], axis=1 - )[domain.names] - - return df - - -def grid( - domain: Domain, stepsize_continuous_parameters: - Optional[Dict[str, float] | float] = None, **kwargs) -> DataTypes: - """Receive samples of the search space - - Parameters - ---------- - n_samples : int - number of samples - stepsize_continuous_parameters : Dict[str, float] | float, optional - stepsize for the continuous parameters, by default None. - If a float is given, all continuous parameters are sampled with - the same stepsize. If a dictionary is given, the stepsize for each - continuous parameter can be specified. - - Returns - ------- - DataTypes - input samples in one of the supported data types for the ExperimentData - input data. - - Raises - ------ - ValueError - If the stepsize_continuous_parameters is given as a dictionary - and not specified for all continuous parameters. - """ - continuous = domain.continuous - - if not continuous.space: - discrete_space = continuous.space - - elif isinstance(stepsize_continuous_parameters, (float, int)): - discrete_space = {name: param.to_discrete( - step=stepsize_continuous_parameters) - for name, param in continuous.space.items()} - - elif isinstance(stepsize_continuous_parameters, dict): - discrete_space = {key: continuous.space[key].to_discrete( - step=value) for key, - value in stepsize_continuous_parameters.items()} - - if len(discrete_space) != len(domain.continuous): - raise ValueError( - "If you specify the stepsize for continuous parameters, \ - the stepsize_continuous_parameters should \ - contain all continuous parameters") - - continuous_to_discrete = Domain(discrete_space) - - _iterdict = {} - - for k, v in domain.categorical.space.items(): - _iterdict[k] = v.categories - - for k, v, in domain.discrete.space.items(): - _iterdict[k] = range(v.lower_bound, v.upper_bound+1, v.step) - - for k, v, in continuous_to_discrete.space.items(): - _iterdict[k] = np.arange( - start=v.lower_bound, stop=v.upper_bound, step=v.step) - - df = pd.DataFrame(list(product(*_iterdict.values())), - columns=_iterdict, dtype=object)[domain.names] - - return df - - -def sobol(domain: Domain, n_samples: int, seed: int, **kwargs) -> DataTypes: - """ - Sobol sequence sampling - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : int - random seed for reproducibility - - Returns - ------- - DataTypes - input samples in one of the supported data types for the ExperimentData - input data. - """ - _continuous = sample_sobol_sequence( - domain=domain.continuous, n_samples=n_samples) - - _discrete = sample_np_random_choice_range( - domain=domain.discrete, n_samples=n_samples, seed=seed) - - _categorical = sample_np_random_choice( - domain=domain.categorical, n_samples=n_samples, seed=seed) - - _constant = sample_constant(domain=domain.constant, n_samples=n_samples) - - df = pd.concat( - [pd.DataFrame(_continuous, columns=domain.continuous.names), - pd.DataFrame(_discrete, columns=domain.discrete.names), - pd.DataFrame( - _categorical, columns=domain.categorical.names), - pd.DataFrame(_constant, columns=domain.constant.names)], axis=1 - )[domain.names] - - return df - - -def latin(domain: Domain, n_samples: int, seed: int, **kwargs) -> DataTypes: - """ - Latin Hypercube sampling - - Parameters - ---------- - domain : Domain - domain object - n_samples : int - number of samples - seed : int - random seed for reproducibility - - Returns - ------- - DataTypes - input samples in one of the supported data types for the ExperimentData - input data. - """ - _continuous = sample_latin_hypercube( - domain=domain.continuous, n_samples=n_samples, seed=seed) - - _discrete = sample_np_random_choice_range( - domain=domain.discrete, n_samples=n_samples, seed=seed) - - _categorical = sample_np_random_choice( - domain=domain.categorical, n_samples=n_samples, seed=seed) - - _constant = sample_constant(domain=domain.constant, n_samples=n_samples) - - df = pd.concat( - [pd.DataFrame(_continuous, columns=domain.continuous.names), - pd.DataFrame(_discrete, columns=domain.discrete.names), - pd.DataFrame( - _categorical, columns=domain.categorical.names), - pd.DataFrame(_constant, columns=domain.constant.names)], axis=1 - )[domain.names] - - return df diff --git a/src/f3dasm/_src/experimentdata/utils.py b/src/f3dasm/_src/experimentdata/utils.py deleted file mode 100644 index 46a0267c..00000000 --- a/src/f3dasm/_src/experimentdata/utils.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Utility functions for the experimentdata module -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# - - -def number_of_updates(iterations: int, population: int): - """Calculate number of update steps to acquire the - correct number of iterations - - Parameters - ---------- - iterations - number of desired iteration steps - population - the population size of the optimizer - - Returns - ------- - number of consecutive update steps - """ - return iterations // population + (iterations % population > 0) - - -def number_of_overiterations(iterations: int, population: int) -> int: - """Calculate the number of iterations that are over the iteration limit - - Parameters - ---------- - iterations - number of desired iteration steos - population - the population size of the optimizer - - Returns - ------- - number of iterations that are over the limit - """ - overiterations: int = iterations % population - if overiterations == 0: - return overiterations - else: - return population - overiterations diff --git a/src/f3dasm/_src/experimentsample.py b/src/f3dasm/_src/experimentsample.py new file mode 100644 index 00000000..1466bb6b --- /dev/null +++ b/src/f3dasm/_src/experimentsample.py @@ -0,0 +1,545 @@ +""" +A ExperimentSample object contains a single realization of + the design-of-experiment in ExperimentData. +""" + +# Modules +# ============================================================================= + +from __future__ import annotations + +# Standard +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, Literal, Optional, Tuple, Type + +# Third-party +import autograd.numpy as np + +# Local +from ._io import load_object +from .design.domain import Domain + +# Authorship & Credits +# ============================================================================= +__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' +__credits__ = ['Martin van der Schelling'] +__status__ = 'Stable' +# ============================================================================= + + +class JobStatus(Enum): + OPEN = 0 + IN_PROGRESS = 1 + FINISHED = 2 + ERROR = 3 + + +class ExperimentSample: + def __init__(self, input_data: Optional[Dict[str, Any]] = None, + output_data: Optional[Dict[str, Any]] = None, + domain: Optional[Domain] = None, + job_status: Optional[str] = None, + project_dir: Optional[Path] = None): + """ + Realization of a single experiment in the design-of-experiment. + + Parameters + ---------- + input_data : Optional[Dict[str, Any]] + Input parameters of one experiment. + The key is the name of the parameter. + output_data : Optional[Dict[str, Any]] + Output parameters of one experiment. + The key is the name of the parameter. + domain : Optional[Domain] + Domain of the experiment, by default None. + job_status : Optional[str] + Job status of the experiment, by default None. + project_dir : Optional[Path] + Directory of the project, by default None. + + Examples + -------- + >>> sample = ExperimentSample( + ... input_data={'param1': 1.0}, + ... output_data={'result1': 2.0} + ... ) + >>> print(sample) + ExperimentSample(input_data={'param1': 1.0}, + output_data={'result1': 2.0}, job_status=JobStatus.OPEN) + """ + if input_data is None: + input_data = dict() + + if output_data is None: + output_data = dict() + + if domain is None: + domain = Domain() + + if job_status is None: + if output_data: + job_status = 'FINISHED' + else: + job_status = 'OPEN' + + if project_dir is None: + project_dir = Path.cwd() + + self._input_data = input_data + self._output_data = output_data + self.domain = domain + self.job_status = JobStatus[job_status] + self.project_dir = project_dir + + def __repr__(self): + """ + Return a string representation of the ExperimentSample instance. + + Returns + ------- + str + String representation of the ExperimentSample instance. + """ + return (f"ExperimentSample(" + f"input_data={self.input_data}, " + f"output_data={self.output_data}, " + f"job_status={self.job_status})") + + def __add__(self, __o: ExperimentSample) -> ExperimentSample: + """ + Add two ExperimentSample instances. + + Parameters + ---------- + __o : ExperimentSample + Another ExperimentSample instance. + + Returns + ------- + ExperimentSample + A new ExperimentSample instance with combined input + and output data. + + Notes + ----- + The job status of the new ExperimentSample instance will be + reconstructed from the absence or presence of output data. + If output data is present, the job status will be 'FINISHED'. + Otherwise, the job status will be 'OPEN'. + + Examples + -------- + >>> sample1 = ExperimentSample(input_data={'param1': 1.0}) + >>> sample2 = ExperimentSample(output_data={'result1': 2.0}) + >>> combined_sample = sample1 + sample2 + >>> print(combined_sample) + ExperimentSample(input_data={'param1': 1.0}, + output_data={'result1': 2.0}, job_status=JobStatus.FINISHED) + """ + return ExperimentSample( + input_data={**self._input_data, **__o._input_data}, + output_data={**self._output_data, **__o._output_data}, + domain=self.domain + __o.domain, + project_dir=self.project_dir, + ) + + def __eq__(self, __o: ExperimentSample) -> bool: + """ + Check if two ExperimentSample instances are equal. + + Parameters + ---------- + __o : ExperimentSample + Another ExperimentSample instance. + + Returns + ------- + bool + True if the instances are equal, False otherwise. + """ + return (self._input_data == __o._input_data + and self._output_data == __o._output_data + and self.job_status == __o.job_status) + + @property + def input_data(self) -> Dict[str, Any]: + """ + Get the input data of the experiment. + + Returns + ------- + Dict[str, Any] + Input data of the experiment. + """ + return {k: self._get_input(k) for k in self._input_data} + + @property + def output_data(self) -> Dict[str, Any]: + """ + Get the output data of the experiment. + + Returns + ------- + Dict[str, Any] + Output data of the experiment. + """ + return {k: self._get_output(k) for k in self._output_data} + + # Alternative constructors + # ========================================================================= + + @classmethod + def from_numpy(cls: Type[ExperimentSample], input_array: np.ndarray, + domain: Optional[Domain] = None) -> ExperimentSample: + """ + Create an ExperimentSample instance from a numpy array. + The input data will be stored in the input space of the domain. + + Parameters + ---------- + cls : Type[ExperimentSample] + The class type. + input_array : np.ndarray + Numpy array containing input data. + domain : Optional[Domain] + Domain of the experiment, by default None. + + Returns + ------- + ExperimentSample + A new ExperimentSample instance. + + Notes + ----- + If no domain is provided, the default names will be 'x0', 'x1', etc. + + Examples + -------- + >>> import numpy as np + >>> sample = ExperimentSample.from_numpy(np.array([1.0, 2.0])) + >>> print(sample) + ExperimentSample(input_data={'x0': 1.0, 'x1': 2.0}, + output_data={}, job_status=JobStatus.OPEN) + """ + if domain is None: + n_dim = input_array.flatten().shape[0] + domain = Domain() + for i in range(n_dim): + domain.add_float(name=f'x{i}') + + return cls(input_data={input_name: v for input_name, v in + zip(domain.input_space.keys(), + input_array.flatten())}, + domain=domain,) + # Getters + # ========================================================================= + + def get(self, name: str) -> Any: + """ + Get the value of a parameter by name. + + Parameters + ---------- + name : str + The name of the parameter. + + Returns + ------- + Any + The value of the parameter. + + Raises + ------ + KeyError + If the parameter is not found in input or output data. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': 1.0}) + >>> sample.get('param1') + 1.0 + """ + value = self._get_input(name) + + if value is not None: + return value + + value = self._get_output(name) + + if value is not None: + return value + + raise KeyError(f"Parameter {name} not found in input or output data.") + + def _get_input(self, name: str) -> Any: + """ + Get the value of an input parameter by name. + + Parameters + ---------- + name : str + The name of the input parameter. + + Returns + ------- + Any + The value of the input parameter, or None if not found. + """ + if name not in self.domain.input_names: + return None + + parameter = self.domain.input_space[name] + + if parameter.to_disk: + return load_object(project_dir=self.project_dir, + path=self._input_data[name], + load_function=parameter.load_function) + else: + return self._input_data[name] + + def _get_output(self, name: str) -> Any: + """ + Get the value of an output parameter by name. + + Parameters + ---------- + name : str + The name of the output parameter. + + Returns + ------- + Any + The value of the output parameter, or None if not found. + """ + if name not in self.domain.output_names: + return None + + parameter = self.domain.output_space[name] + + if parameter.to_disk: + return load_object(project_dir=self.project_dir, + path=self._output_data[name], + load_function=parameter.load_function) + else: + return self._output_data[name] + + # Setters + # ========================================================================= + + def mark(self, + status: Literal['open', 'in_progress', 'finished', 'error']): + """ + Mark the job status of the experiment. + + Parameters + ---------- + status : Literal['open', 'in_progress', 'finished', 'error'] + The new job status. + + Raises + ------ + ValueError + If the status is not valid. + + Examples + -------- + >>> sample = ExperimentSample() + >>> sample.mark('finished') + >>> sample.job_status + + """ + if status.upper() not in JobStatus.__members__: + raise ValueError(f"Invalid status: {status}") + + self.job_status = JobStatus[status.upper()] + + def replace_nan(self, replacement_value: Any): + """ + Replace NaN values in input_data and output_data with a custom value. + + Parameters + ---------- + replacement_value : Any + The value to replace NaN values with. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': np.nan}) + >>> sample.replace_nan(0) + >>> sample.input_data['param1'] + 0 + """ + def replace_nan_in_dict(data: Dict[str, Any]) -> Dict[str, Any]: + return {k: (replacement_value if np.isnan(v) else v) + for k, v in data.items()} + + self._input_data = replace_nan_in_dict(self._input_data) + self._output_data = replace_nan_in_dict(self._output_data) + + def round(self, decimals: int): + """ + Round the input and output data to a specified number + of decimal places. + + Parameters + ---------- + decimals : int + The number of decimal places to round to. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': 1.2345}) + >>> sample.round(2) + >>> sample.input_data['param1'] + 1.23 + """ + def round_dict(data: Dict[str, Any]) -> Dict[str, Any]: + return {k: round(v, decimals) if isinstance(v, (int, float)) + else v for k, v in data.items()} + + self._input_data = round_dict(self._input_data) + self._output_data = round_dict(self._output_data) + + # Exporting + # ========================================================================= + + def to_multiindex(self) -> Dict[Tuple[str, str], Any]: + """ + Convert the experiment sample to a multiindex dictionary. + Used to display the data prettily as a table in a Jupyter notebook. + + Returns + ------- + Dict[Tuple[str, str], Any] + A multiindex dictionary containing the job status, input, + and output data. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': 1.0}) + >>> sample.to_multiindex() + {('jobs', ''): 'open', ('input', 'param1'): 1.0} + """ + return {('jobs', ''): self.job_status.name.lower(), + **{('input', k): v for k, v in self._input_data.items()}, + **{('output', k): v for k, v in self._output_data.items()}, + } + + def to_numpy(self) -> Tuple[np.ndarray, np.ndarray]: + """ + Convert the experiment sample to numpy arrays. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + A tuple containing numpy arrays of input and output data. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': 1.0}) + >>> sample.to_numpy() + (array([1.]), array([])) + """ + return (np.array(list(self.input_data.values())), + np.array(list(self.output_data.values())) + ) + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the experiment sample to a dictionary. + + Returns + ------- + Dict[str, Any] + A dictionary containing both input and output data. + + Examples + -------- + >>> sample = ExperimentSample(input_data={'param1': 1.0}) + >>> sample.to_dict() + {'param1': 1.0} + """ + return {**self.input_data, **self.output_data} + + # Storing + # ========================================================================= + + def store(self, name: str, object: Any, to_disk: bool = False, + store_function: Optional[Type[Callable]] = None, + load_function: Optional[Type[Callable]] = None): + """ + Store an object in the experiment sample. + + Parameters + ---------- + name : str + The name of the object to store. + object : Any + The object to store. + to_disk : bool, optional + If True, the object will be stored on disk, by default False. + store_function : Optional[Type[Callable]], optional + The function to use for storing the object on disk + by default None. + load_function : Optional[Type[Callable]], optional + The function to use for loading the object from disk, + by default None. + + Notes + ----- + The object will be stored in the input data if the name is in the + input space of the domain. Otherwise, the object will be stored in + the output data if the name is in the output space of the domain. + + If the object is stored on disk, the path to the stored object will + be stored in the input or output data, depending on where the object + is stored. + + The store_function should have the following signature: + + .. code-block:: python + + def store_function(object: Any, path: str) -> Path: + ... + + The load_function should have the following signature: + + .. code-block:: python + + def load_function(path: str) -> Any: + ... + """ + if name not in self.domain.output_names: + self.domain.add_output(name=name, to_disk=to_disk, + store_function=store_function, + load_function=load_function) + + self._output_data[name] = object + + # Job status + # ========================================================================= + + def is_status(self, status: str) -> bool: + """ + Check if the job's current status matches the given status. + + Parameters + ---------- + status : str + The status to check against the job's current status. + + Returns + ------- + bool + True if the job's current status matches the given status, + False otherwise. + + Examples + -------- + >>> sample = ExperimentSample() + >>> sample.is_status('open') + True + """ + return self.job_status == JobStatus[status.upper()] diff --git a/src/f3dasm/_src/hydra_utils.py b/src/f3dasm/_src/hydra_utils.py index cb8ad947..832687e7 100644 --- a/src/f3dasm/_src/hydra_utils.py +++ b/src/f3dasm/_src/hydra_utils.py @@ -11,7 +11,7 @@ from omegaconf import OmegaConf # Local -from .experimentdata.experimentsample import ExperimentSample +from .experimentsample import ExperimentSample # Authorship & Credits # ============================================================================= @@ -27,14 +27,14 @@ def update_config_with_experiment_sample( config: OmegaConf, experiment_sample: ExperimentSample, force_add: bool = False) -> OmegaConf: """ - Update the config with the values from the experiment sample + Update the config with the values from the experiment sample. Parameters ---------- config : OmegaConf - The configuration to update + The configuration to update. experiment_sample : ExperimentSample - The experiment sample to update the configuration with + The experiment sample to update the configuration with. force_add : bool, optional If True, the function will add keys that are not present in the configuration. If False, the function will ignore keys that are not @@ -43,7 +43,7 @@ def update_config_with_experiment_sample( Returns ------- OmegaConf - The updated configuration + The updated configuration. Notes ----- @@ -56,6 +56,17 @@ def update_config_with_experiment_sample( The function will return a new configuration object with the updated values. The original configuration object will not be modified. + + Examples + -------- + >>> from omegaconf import OmegaConf + >>> from f3dasm._src.experimentdata.experimentsample + import ExperimentSample + >>> config = OmegaConf.create({'param1': 1, 'param2': 2}) + >>> sample = ExperimentSample(input_data={'param1': 10}) + >>> updated_config = update_config_with_experiment_sample(config, sample) + >>> print(updated_config) + {'param1': 10, 'param2': 2} """ cfg = deepcopy(config) for key, value in experiment_sample.to_dict().items(): diff --git a/src/f3dasm/_src/logger.py b/src/f3dasm/_src/logger.py index 7c6c336a..701dff78 100644 --- a/src/f3dasm/_src/logger.py +++ b/src/f3dasm/_src/logger.py @@ -44,13 +44,13 @@ class DistributedFileHandler(FileHandler): - def __init__(self, filename): + def __init__(self, filename: str): """Distributed FileHandler class for handling logging to one single file when multiple nodes access the same resource Parameters ---------- - filename + filename : str name of the logging file """ super().__init__(filename) diff --git a/src/f3dasm/_src/optimization/__init__.py b/src/f3dasm/_src/optimization/__init__.py index 6343b5d4..66545b94 100644 --- a/src/f3dasm/_src/optimization/__init__.py +++ b/src/f3dasm/_src/optimization/__init__.py @@ -5,12 +5,14 @@ # ============================================================================= # Standard -from typing import List +from typing import Callable, List + +from .numpy_implementations import random_search +from .optimizer_factory import _optimizer_factory, available_optimizers +from .scipy_implementations import cg, lbfgsb, nelder_mead # Local -from .numpy_implementations import RandomSearch -from .optimizer import Optimizer -from .scipy_implementations import CG, LBFGSB, NelderMead + # Authorship & Credits # ============================================================================= @@ -21,35 +23,15 @@ # # ============================================================================= -# List of available optimizers -_OPTIMIZERS: List[Optimizer] = [RandomSearch, CG, LBFGSB, NelderMead] + +# ============================================================================= __all__ = [ - 'CG', - 'LBFGSB', - 'NelderMead', - 'Optimizer', - 'RandomSearch', - '_OPTIMIZERS', - 'find_optimizer', + '_optimizer_factory', + 'cg', + 'lbfgsb', + 'nelder_mead', + 'random_search', + 'available_optimizers', ] - - -def find_optimizer(query: str) -> Optimizer: - """Find a optimizer from the f3dasm.optimizer submodule - - Parameters - ---------- - query - string representation of the requested optimizer - - Returns - ------- - class of the requested optimizer - """ - try: - return list(filter( - lambda optimizer: optimizer.__name__ == query, _OPTIMIZERS))[0] - except IndexError: - return ValueError(f'Optimizer {query} not found!') diff --git a/src/f3dasm/_src/optimization/adapters/scipy_implementations.py b/src/f3dasm/_src/optimization/adapters/scipy_implementations.py index 1c412df5..a23e544d 100644 --- a/src/f3dasm/_src/optimization/adapters/scipy_implementations.py +++ b/src/f3dasm/_src/optimization/adapters/scipy_implementations.py @@ -1,18 +1,18 @@ # Modules # ============================================================================= +from __future__ import annotations # Standard import warnings +from copy import deepcopy # Third-party core import autograd.numpy as np from scipy.optimize import minimize # Locals -from ...datageneration.datagenerator import DataGenerator -from ...design.domain import Domain -from ...experimentdata.experimentsample import ExperimentSample -from ..optimizer import Optimizer +from ...core import Block, DataGenerator, ExperimentData +from ...experimentsample import ExperimentSample # Authorship & Credits # ============================================================================= @@ -27,54 +27,117 @@ "ignore", message="^OptimizeWarning: Unknown solver options.*") -class _SciPyOptimizer(Optimizer): +class ScipyOptimizer(Block): + require_gradients: bool = False type: str = 'scipy' - def __init__(self, domain: Domain, method: str, **hyperparameters): - self.domain = domain - self.method = method - self.options = {**hyperparameters} + def __init__(self, algorithm, **hyperparameters): + self.algorithm = algorithm + self.hyperparameters = hyperparameters def _callback(self, xk: np.ndarray, *args, **kwargs) -> None: self.data.add_experiments( - ExperimentSample.from_numpy(xk, domain=self.domain)) + type(self.data)(domain=self.data.domain, + input_data=np.atleast_2d(xk), + project_dir=self.data.project_dir) + ) - def update_step(self): + def call(self, data: ExperimentData, **kwargs): """Update step function""" raise ValueError( 'Scipy optimizers don\'t have an update steps. \ Multiple iterations are directly called \ througout scipy.minimize.') - def run_algorithm(self, iterations: int, data_generator: DataGenerator): + def run_algorithm(self, data_generator: DataGenerator, iterations: int): """Run the algorithm for a number of iterations Parameters ---------- iterations number of iterations - function - function to be evaluated """ def fun(x): - sample: ExperimentSample = data_generator._run( - x, domain=self.domain) + x_ = ExperimentSample.from_numpy(input_array=x, + domain=self.data.domain) + sample = data_generator._run( + x_, domain=self.data.domain) _, y = sample.to_numpy() return float(y) - self.options['maxiter'] = iterations - if not hasattr(data_generator, 'dfdx'): data_generator.dfdx = None + self.hyperparameters['maxiter'] = iterations + minimize( fun=fun, - method=self.method, + method=self.algorithm, jac=data_generator.dfdx, x0=self.data.get_n_best_output(1).to_numpy()[0].ravel(), callback=self._callback, - options=self.options, - bounds=self.domain.get_bounds(), + options=self.hyperparameters, + bounds=self.data.domain.get_bounds(), tol=0.0, ) + + def _iterate(self, data: ExperimentData, data_generator: DataGenerator, + iterations: int, + kwargs: dict, overwrite: bool): + """Internal represenation of the iteration process for scipy-minimize + optimizers. + + Parameters + ---------- + data_generator : DataGenerator + DataGenerator object + iterations : int + number of iterations + kwargs : Dict[str, Any] + any additional keyword arguments that will be passed to + the DataGenerator + overwrite: bool + If True, the optimizer will overwrite the current data. + """ + self.data = data + n_data_before_iterate = deepcopy(len(data)) + if len(data) < 1: + raise ValueError( + f'There are {len(data)} datapoints available, \ + need 1 for initial \ + population!' + ) + + self.run_algorithm(data_generator=data_generator, + iterations=iterations) + + new_samples = self.data.select(self.data.index[1:]) + + new_samples.evaluate(data_generator=data_generator, + mode='sequential', **kwargs) + + if overwrite: + self.data.add_experiments( + data.select([self.data.index[-1]])) + + elif not overwrite: + # If x_new is empty, repeat best x0 to fill up total iteration + if len(self.data) == n_data_before_iterate: + repeated_sample = self.data.get_n_best_output( + n_samples=1) + + for repetition in range(iterations): + self.data.add_experiments(repeated_sample) + + # Repeat last iteration to fill up total iteration + if len(self.data) < n_data_before_iterate + iterations: + last_design = self.data.get_experiment_sample(len(self.data)-1) + + while len(self.data) < n_data_before_iterate + iterations: + self.data.add_experiments(last_design) + + self.data.evaluate(data_generator=data_generator, + mode='sequential', **kwargs) + + return self.data diff --git a/src/f3dasm/_src/optimization/numpy_implementations.py b/src/f3dasm/_src/optimization/numpy_implementations.py index a79a4415..360c0c49 100644 --- a/src/f3dasm/_src/optimization/numpy_implementations.py +++ b/src/f3dasm/_src/optimization/numpy_implementations.py @@ -6,15 +6,13 @@ # ============================================================================= # Standard -from typing import List, Optional, Tuple +from typing import Optional # Third-party core import numpy as np # Locals -from ..datageneration.datagenerator import DataGenerator -from ..design.domain import Domain -from .optimizer import Optimizer +from ..core import Block, ExperimentData # Authorship & Credits # ============================================================================= @@ -26,32 +24,46 @@ # ============================================================================= -class RandomSearch(Optimizer): - """Naive random search""" +class NumpyOptimizer(Block): + """Numpy optimizer class""" require_gradients: bool = False - def __init__(self, domain: Domain, seed: Optional[int] = None, **kwargs): - self.domain = domain + def __init__(self, seed: Optional[int], **hyperparameters): self.seed = seed - self._set_algorithm() + self.hyperparameters = hyperparameters + self.algorithm = np.random.default_rng(seed) - def _set_algorithm(self): - self.algorithm = np.random.default_rng(self.seed) - - def update_step( - self, data_generator: DataGenerator - ) -> Tuple[np.ndarray, np.ndarray]: + def call(self, data: ExperimentData, **kwargs) -> ExperimentData: x_new = np.atleast_2d( [ self.algorithm.uniform( - low=self.domain.get_bounds()[d, 0], - high=self.domain.get_bounds()[d, 1]) - for d in range(len(self.domain)) + low=data.domain.get_bounds()[d, 0], + high=data.domain.get_bounds()[d, 1]) + for d in range(len(data.domain.input_space)) ] ) # return the data - return x_new, None + return type(data)(domain=data.domain, + input_data=x_new, + ) + + +def random_search(seed: Optional[int] = None, **kwargs) -> Block: + """ + Random search optimizer + + Parameters + ---------- + seed : int, optional + Random seed, by default None - def _get_info(self) -> List[str]: - return ['Fast', 'Single-Solution'] + Returns + ------- + Block + Optimizer object. + """ + return NumpyOptimizer( + seed=seed, + **kwargs + ) diff --git a/src/f3dasm/_src/optimization/optimizer.py b/src/f3dasm/_src/optimization/optimizer.py deleted file mode 100644 index adf5cb85..00000000 --- a/src/f3dasm/_src/optimization/optimizer.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Module containing the interface class Optimizer -""" - -# Modules -# ============================================================================= - -from __future__ import annotations - -# Standard -from typing import ClassVar, Iterable, List, Protocol, Tuple - -# Third-party core -import numpy as np -import pandas as pd - -# Locals -from ..datageneration.datagenerator import DataGenerator - -# Authorship & Credits -# ============================================================================= -__author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' -__credits__ = ['Martin van der Schelling'] -__status__ = 'Stable' -# ============================================================================= -# -# ============================================================================= - - -class ExperimentData(Protocol): - @property - def index(self, index) -> pd.Index: - ... - - def get_n_best_output(self, n_samples: int) -> ExperimentData: - ... - - def to_numpy() -> Tuple[np.ndarray, np.ndarray]: - ... - - def select(self, indices: int | slice | Iterable[int]) -> ExperimentData: - ... - -# ============================================================================= - - -class Optimizer: - """ - Abstract class for optimization algorithms - To implement a new optimizer, inherit from this class and implement the - update_step method. - - Note - ---- - The update_step method should have the following signature: - - ''' - def update_step(self, data_generator: DataGenerator) - -> Tuple[np.ndarray, np.ndarray]: - ''' - - The method should return a tuple containing the new samples and the - corresponding objective values. The new samples should be a numpy array. - - If the optimizer requires gradients, set the require_gradients attribute to - True. This will ensure that the data_generator will calculate the gradients - of the objective function from the DataGenerator.dfdx method. - - Hyperparameters can be set in the __init__ method of the child class. - There are two hyperparameters that have special meaning: - - population: the number of individuals in the population - - seed: the seed of the random number generator - - You can create extra methods in your child class as you please, however - it is advised not to create private methods (methods starting with an - underscore) as these might be used in the base class. - """ - type: ClassVar[str] = 'any' - require_gradients: ClassVar[bool] = False - -# Private Properties -# ============================================================================= - - @property - def _seed(self) -> int: - """ - Property to return the seed of the optimizer - - Returns - ------- - int | None - Seed of the optimizer - - Note - ---- - If the seed is not set, the property will return None - This is done to prevent errors when the seed is not an available - attribute in a custom optimizer class. - """ - return self.seed if hasattr(self, 'seed') else None - - @property - def _population(self) -> int: - """ - Property to return the population size of the optimizer - - Returns - ------- - int - Number of individuals in the population - - Note - ---- - If the population is not set, the property will return 1 - This is done to prevent errors when the population size is not an - available attribute in a custom optimizer class. - """ - return self.population if hasattr(self, 'population') else 1 - -# Public Methods -# ============================================================================= - - def update_step(self, data_generator: DataGenerator) -> ExperimentData: - """Update step of the optimizer. Needs to be implemented - by the child class - - Parameters - ---------- - data_generator : DataGenerator - data generator object to calculate the objective value - - Returns - ------- - ExperimentData - ExperimentData object containing the new samples - - Raises - ------ - NotImplementedError - Raises when the method is not implemented by the child class - - Note - ---- - You can access the data attribute of the optimizer to get the - available data points. The data attribute is an - f3dasm.ExperimentData object. - """ - raise NotImplementedError( - "You should implement an update step for your algorithm!") - -# Private Methods -# ============================================================================= - - def _set_algorithm(self): - """ - Method that can be implemented to set the optimization algorithm. - Whenever the reset method is called, this method will be called to - reset the algorithm to its initial state.""" - ... - - def _construct_model(self, data_generator: DataGenerator): - """ - Method that is called before the optimization starts. This method can - be used to construct a model based on the available data or a specific - data generator. - - Parameters - ---------- - data_generator : DataGenerator - DataGenerator object - - Note - ---- - When this method is not implemented, the method will do nothing. - """ - ... - - def _check_number_of_datapoints(self): - """ - Check if the number of datapoints is sufficient for the - initial population - - Raises - ------ - ValueError - Raises when the number of datapoints is insufficient - """ - if len(self.data) < self._population: - raise ValueError( - f'There are {len(self.data)} datapoints available, \ - need {self._population} for initial \ - population!' - ) - - def _reset(self, data: ExperimentData): - """Reset the optimizer to its initial state - - Parameters - ---------- - data : ExperimentData - Data to set the optimizer to its initial state - - Note - ---- - This method should be called whenever the optimizer is reset - to its initial state. This can be done when the optimizer is - re-initialized or when the optimizer is re-used for a new - optimization problem. - - The following steps are taken when the reset method is called: - - The data attribute is set to the given data (self._set_data) - - The algorithm is set to its initial state (self._set_algorithm) - """ - self._set_data(data) - self._set_algorithm() - - def _set_data(self, data: ExperimentData): - """Set the data attribute to the given data - - Parameters - ---------- - data : ExperimentData - Data to set the optimizer to its initial state - """ - self.data = data - - def _get_info(self) -> List[str]: - """Give a list of characteristic features of this optimizer - - Returns - ------- - List[str] - List of characteristics of the optimizer - """ - return [] diff --git a/src/f3dasm/_src/optimization/optimizer_factory.py b/src/f3dasm/_src/optimization/optimizer_factory.py index 47ec1b68..07a6e7d5 100644 --- a/src/f3dasm/_src/optimization/optimizer_factory.py +++ b/src/f3dasm/_src/optimization/optimizer_factory.py @@ -4,13 +4,15 @@ # Modules # ============================================================================= +from __future__ import annotations + # Standard -from typing import Any, Dict, Optional +from typing import Callable, Dict, List # Local -from ..design.domain import Domain -from . import _OPTIMIZERS -from .optimizer import Optimizer +from ..core import Block +from .numpy_implementations import random_search +from .scipy_implementations import cg, lbfgsb, nelder_mead # Authorship & Credits # ============================================================================= @@ -21,25 +23,40 @@ # # ============================================================================= -# Try importing f3dasm_optimize package -try: - import f3dasm_optimize # NOQA - _OPTIMIZERS.extend(f3dasm_optimize._OPTIMIZERS) -except ImportError: - pass +def available_optimizers(): + """ + Returns a list of all available built-in optimization algorithms. -OPTIMIZER_MAPPING: Dict[str, Optimizer] = { - opt.__name__.lower().replace(' ', '').replace('-', '').replace( - '_', ''): opt for opt in _OPTIMIZERS} + Returns + ------- + List[str] + List of all available optimization algorithms + """ + return list(get_optimizer_mapping().keys()) -OPTIMIZERS = [opt.__name__ for opt in _OPTIMIZERS] +def get_optimizer_mapping() -> Dict[str, Block]: + # List of available optimizers + _OPTIMIZERS: List[Callable] = [ + cg, lbfgsb, nelder_mead, random_search] + # Try importing f3dasm_optimize package + try: + from f3dasm_optimize import optimizers_extension # NOQA + _OPTIMIZERS.extend(optimizers_extension()) + except ImportError: + pass -def _optimizer_factory( - optimizer: str, domain: Domain, - hyperparameters: Optional[Dict[str, Any]] = None) -> Optimizer: + OPTIMIZER_MAPPING: Dict[str, Block] = { + opt.__name__.lower().replace(' ', '').replace('-', '').replace( + '_', ''): opt for opt in _OPTIMIZERS} + + return OPTIMIZER_MAPPING + + +def _optimizer_factory(optimizer: str | Block, **hyperparameters + ) -> Block: """Factory function for optimizers Parameters @@ -47,10 +64,6 @@ def _optimizer_factory( optimizer : str Name of the optimizer to use - domain : Domain - Domain of the design space - hyperparameters : dict, optional - Hyperparameters for the optimizer Returns ------- @@ -64,16 +77,19 @@ def _optimizer_factory( KeyError If the optimizer is not found """ + if isinstance(optimizer, Block): + return optimizer + + elif isinstance(optimizer, str): - if hyperparameters is None: - hyperparameters = {} + filtered_name = optimizer.lower().replace( + ' ', '').replace('-', '').replace('_', '') - filtered_name = optimizer.lower().replace( - ' ', '').replace('-', '').replace('_', '') + OPTIMIZER_MAPPING = get_optimizer_mapping() - if filtered_name in OPTIMIZER_MAPPING: - return OPTIMIZER_MAPPING[filtered_name]( - domain=domain, **hyperparameters) + if filtered_name in OPTIMIZER_MAPPING: + return OPTIMIZER_MAPPING[filtered_name]( + **hyperparameters) else: raise KeyError(f"Unknown optimizer: {optimizer}") diff --git a/src/f3dasm/_src/optimization/scipy_implementations.py b/src/f3dasm/_src/optimization/scipy_implementations.py index 42e1bc0a..53a8c810 100644 --- a/src/f3dasm/_src/optimization/scipy_implementations.py +++ b/src/f3dasm/_src/optimization/scipy_implementations.py @@ -5,12 +5,9 @@ # Modules # ============================================================================= -# Standard -from typing import List - # Locals -from ..design.domain import Domain -from .adapters.scipy_implementations import _SciPyOptimizer +from ..core import Block +from .adapters.scipy_implementations import ScipyOptimizer # Authorship & Credits # ============================================================================= @@ -22,51 +19,81 @@ # ============================================================================= -class CG(_SciPyOptimizer): - """CG""" - require_gradients: bool = True +def cg(gtol: float = 0.0, **kwargs) -> Block: + """ + Conjugate Gradient optimizer + Adapted from scipy.optimize.minimize - def __init__(self, domain: Domain, gtol: float = 0.0, **kwargs): - super().__init__( - domain=domain, method='CG', gtol=gtol) - self.gtol = gtol + Parameters + ---------- + gtol : float, optional + Gradient norm tolerance, by default 0.0 - def _get_info(self) -> List[str]: - return ['Stable', 'First-Order', 'Single-Solution'] + Returns + ------- + Optimizer + Optimizer + """ + return ScipyOptimizer( + algorithm='CG', + gtol=gtol, + **kwargs + ) # ============================================================================= -class LBFGSB(_SciPyOptimizer): - """L-BFGS-B""" - require_gradients: bool = True - - def __init__(self, domain: Domain, - ftol: float = 0.0, gtol: float = 0.0, **kwargs): - super().__init__( - domain=domain, method='L-BFGS-B', ftol=ftol, gtol=gtol) - self.ftol = ftol - self.gtol = gtol - - def _get_info(self) -> List[str]: - return ['Stable', 'First-Order', 'Single-Solution'] +def lbfgsb(ftol: float = 0.0, gtol: float = 0.0, **kwargs) -> Block: + """ + L-BFGS-B optimizer + Adapted from scipy.optimize.minimize + + Parameters + ---------- + ftol : float, optional + Function value tolerance, by default 0.0 + gtol : float, optional + Gradient norm tolerance, by default 0.0 + + Returns + ------- + Optimizer + Optimizer + """ + return ScipyOptimizer( + algorithm='L-BFGS-B', + ftol=ftol, + gtol=gtol, + **kwargs + ) # ============================================================================= -class NelderMead(_SciPyOptimizer): - """Nelder-Mead""" - require_gradients: bool = False - - def __init__(self, domain: Domain, - xatol: float = 0.0, fatol: float = 0.0, - adaptive: bool = False, **kwargs): - super().__init__( - domain=domain, method='Nelder-Mead', xatol=xatol, fatol=fatol, - adaptive=adaptive) - self.xatol = xatol - self.fatol = fatol - self.adaptive = adaptive - - def _get_info(self) -> List[str]: - return ['Fast', 'Global', 'First-Order', 'Single-Solution'] +def nelder_mead(xatol: float = 0.0, fatol: float = 0.0, + adaptive: bool = False, **kwargs) -> Block: + """ + Nelder-Mead optimizer + Adapted from scipy.optimize.minimize + + Parameters + ---------- + xatol : float, optional + Absolute error in xopt between iterations, by default 0.0 + fatol : float, optional + Absolute error in fun(xopt) between iterations, by default 0.0 + adaptive : bool, optional + Adapt the algorithm, by default False + + Returns + ------- + Optimizer + Optimizer + """ + return ScipyOptimizer( + algorithm='Nelder-Mead', + xatol=xatol, + fatol=fatol, + adaptive=adaptive, + **kwargs + ) diff --git a/src/f3dasm/datageneration/__init__.py b/src/f3dasm/datageneration/__init__.py index 657e0a71..36c09a3a 100644 --- a/src/f3dasm/datageneration/__init__.py +++ b/src/f3dasm/datageneration/__init__.py @@ -4,7 +4,7 @@ # Modules # ============================================================================= -from .._src.datageneration.datagenerator import DataGenerator +from .._src.core import DataGenerator # Authorship & Credits # ============================================================================= diff --git a/src/f3dasm/datageneration/functions.py b/src/f3dasm/datageneration/functions.py index 14c95ecf..f019b4b2 100644 --- a/src/f3dasm/datageneration/functions.py +++ b/src/f3dasm/datageneration/functions.py @@ -7,6 +7,40 @@ # Local from .._src.datageneration.functions import (FUNCTIONS, FUNCTIONS_2D, FUNCTIONS_7D, get_functions) +from .._src.datageneration.functions.alias import (ackley, ackleyn2, ackleyn3, + ackleyn4, adjiman, bartels, + beale, bird, bohachevskyn1, + bohachevskyn2, + bohachevskyn3, booth, + branin, brent, brown, + bukinn6, colville, + crossintray, deckkersaarts, + dejongn5, dixonprice, + dropwave, easom, eggcrate, + eggholder, exponential, + goldsteinprice, griewank, + happycat, himmelblau, + holdertable, keane, + langermann, leon, levy, + levyn13, matyas, mccormick, + michalewicz, periodic, + powell, qing, quartic, + rastrigin, ridge, + rosenbrock, + rotatedhyperellipsoid, + salomon, schaffeln1, + schaffeln2, schaffeln3, + schaffeln4, schwefel, + schwefel2_20, schwefel2_21, + schwefel2_22, schwefel2_23, + shekel, shubert, shubertn3, + shubertn4, sphere, + styblinskitang, sumsquares, + thevenot, threehump, trid, + wolfe, xin_she_yang, + xin_she_yang2, + xin_she_yang3, + xin_she_yang4, zakharov) # Authorship & Credits # ============================================================================= @@ -22,4 +56,77 @@ 'FUNCTIONS_2D', 'FUNCTIONS_7D', 'get_functions', + 'ackley', + 'ackleyn2', + 'ackleyn3', + 'ackleyn4', + 'adjiman', + 'bartels', + 'beale', + 'bird', + 'bohachevskyn1', + 'bohachevskyn2', + 'bohachevskyn3', + 'booth', + 'branin', + 'brent', + 'brown', + 'bukinn6', + 'colville', + 'crossintray', + 'deckkersaarts', + 'dejongn5', + 'dixonprice', + 'dropwave', + 'easom', + 'eggcrate', + 'eggholder', + 'exponential', + 'goldsteinprice', + 'griewank', + 'happycat', + 'himmelblau', + 'holdertable', + 'keane', + 'langermann', + 'leon', + 'levy', + 'levyn13', + 'matyas', + 'mccormick', + 'michalewicz', + 'periodic', + 'powell', + 'qing', + 'quartic', + 'rastrigin', + 'ridge', + 'rosenbrock', + 'rotatedhyperellipsoid', + 'salomon', + 'schaffeln1', + 'schaffeln2', + 'schaffeln3', + 'schaffeln4', + 'schwefel', + 'schwefel2_20', + 'schwefel2_21', + 'schwefel2_22', + 'schwefel2_23', + 'shekel', + 'shubert', + 'shubertn3', + 'shubertn4', + 'sphere', + 'styblinskitang', + 'sumsquares', + 'thevenot', + 'threehump', + 'trid', + 'wolfe', + 'xin_she_yang', + 'xin_she_yang2', + 'xin_she_yang3', + 'xin_she_yang4', + 'zakharov', ] diff --git a/src/f3dasm/design.py b/src/f3dasm/design.py index 47fe19fb..d805671b 100644 --- a/src/f3dasm/design.py +++ b/src/f3dasm/design.py @@ -6,7 +6,7 @@ # Local from ._src.design.domain import Domain, make_nd_continuous_domain -from ._src.experimentdata._jobqueue import NoOpenJobsError, Status +from ._src.design.samplers import grid, latin, random, sobol # Authorship & Credits # ============================================================================= @@ -20,6 +20,8 @@ __all__ = [ 'Domain', 'make_nd_continuous_domain', - 'NoOpenJobsError', - 'Status', + 'latin', + 'random', + 'grid', + 'sobol', ] diff --git a/src/f3dasm/optimization.py b/src/f3dasm/optimization.py index d5dfb49a..4198f18d 100644 --- a/src/f3dasm/optimization.py +++ b/src/f3dasm/optimization.py @@ -5,8 +5,8 @@ # ============================================================================= # Local -from ._src.optimization.optimizer import Optimizer -from ._src.optimization.optimizer_factory import OPTIMIZERS +from ._src.optimization import cg, lbfgsb, nelder_mead, random_search +from ._src.optimization.optimizer_factory import available_optimizers # Authorship & Credits # ============================================================================= @@ -18,6 +18,9 @@ # ============================================================================= __all__ = [ - 'Optimizer', - 'OPTIMIZERS', + 'available_optimizers', + 'cg', + 'lbfgsb', + 'nelder_mead', + 'random_search' ] diff --git a/studies/benchmark_optimizers/README.md b/studies/benchmark_optimizers/README.md index 40f99200..3e558248 100644 --- a/studies/benchmark_optimizers/README.md +++ b/studies/benchmark_optimizers/README.md @@ -33,7 +33,7 @@ The benchmark function is optimized for a given number of iterations and repeate ### Before running the experiment -1. Install `f3dasm_optimize` in your environment. See [here](https://bessagroup.github.io/f3dasm_optimize/rst_doc_files/getting_started.html) for instructions. +1. Install `f3dasm_optimize` in your environment. See [here](https://f3dasm-optimize.readthedocs.io/en/latest/) for instructions. 3. Change the `config.yaml` file to your liking. See [here](#explanation-of-configyaml-parameters) for an explanation of the parameters. ### Running the experiment on your local machine @@ -134,7 +134,7 @@ outputs/ | sampler_name | `str` | Name of the sampling strategy for the first iterations ([reference](https://f3dasm.readthedocs.io/en/latest/rst_doc_files/classes/sampling/sampling.html#id2)) | | number_of_samples | `int` | Number of initial samples ($\vec{x}_0$) | | realizations | `int` | Number of realizations with different initial conditions | -| optimizers | `List[str]` | List of dictionaries. Each dictionary contains a key ``name`` with the optimizer name ([from `f3dasm`](https://f3dasm.readthedocs.io/en/latest/rst_doc_files/classes/optimization/optimizers.html#implemented-optimizers), [and `f3dasm_optimize`](https://bessagroup.github.io/f3dasm_optimize/rst_doc_files/optimizers.html#implemented-optimizers)) and a (optionally) a ``hyperparameters`` key to overwrite hyper-parameters of that specific optimzier. | +| optimizers | `List[str]` | List of dictionaries. Each dictionary contains a key ``name`` with the optimizer name ([from `f3dasm`](https://f3dasm.readthedocs.io/en/latest/rst_doc_files/classes/optimization/optimizers.html#implemented-optimizers), [and `f3dasm_optimize`](https://f3dasm-optimize.readthedocs.io/en/latest/)) and a (optionally) a ``hyperparameters`` key to overwrite hyper-parameters of that specific optimzier. | ### HPC diff --git a/studies/benchmark_optimizers/main.py b/studies/benchmark_optimizers/main.py index c7294355..3e2ee4b8 100644 --- a/studies/benchmark_optimizers/main.py +++ b/studies/benchmark_optimizers/main.py @@ -29,7 +29,7 @@ import xarray as xr # Local -from f3dasm import ExperimentData +from f3dasm import Block, ExperimentData from f3dasm.datageneration import DataGenerator from f3dasm.datageneration.functions import get_functions from f3dasm.design import Domain, make_nd_continuous_domain @@ -47,27 +47,36 @@ # Custom sampler method # ============================================================================= -def sample_if_compatible_function( - domain: Domain, n_samples: int, seed: int) -> pd.DataFrame: - rng = np.random.default_rng(seed) - samples = [] +class CustomSampler(Block): + def __init__(self, seed: int): + self.seed = seed - for i in range(n_samples): - dim = rng.choice(domain.space['dimensionality'].categories) + def call(self, data: ExperimentData, n_samples: int) -> ExperimentData: + rng = np.random.default_rng(self.seed) + samples = [] - available_functions = list(set(get_functions(d=int(dim))) & set( - domain.space['function_name'].categories)) - function_name = rng.choice(available_functions) + for i in range(n_samples): + dim = rng.choice( + data.domain.input_space['dimensionality'].categories) - noise = rng.choice(domain.space['noise'].categories) - seed = rng.integers( - low=domain.space['seed'].lower_bound, - high=domain.space['seed'].upper_bound) - budget = domain.space['budget'].value + available_functions = list(set(get_functions(d=int(dim))) & set( + data.domain.input_space['function_name'].categories)) + function_name = rng.choice(available_functions) - samples.append([function_name, dim, noise, seed, budget]) + noise = rng.choice(data.domain.input_space['noise'].categories) + seed = rng.integers( + low=data.domain.input_space['seed'].lower_bound, + high=data.domain.input_space['seed'].upper_bound) + budget = data.domain.input_space['budget'].value - return pd.DataFrame(samples, columns=domain.names)[domain.names] + samples.append([function_name, dim, noise, seed, budget]) + + df = pd.DataFrame( + samples, columns=data.domain.input_names)[data.domain.input_names] + + return ExperimentData( + domain=data.domain, input_data=df, + project_dir=data.project_dir) # Custom datagenerator # ============================================================================= @@ -78,11 +87,11 @@ def __init__(self, config): self.config = config def optimize_function(self, optimizer: dict) -> xr.Dataset: - seed = self.experiment_sample.get('seed') - function_name = self.experiment_sample.get('function_name') - dimensionality = self.experiment_sample.get('dimensionality') - noise = self.experiment_sample.get('noise') - budget = self.experiment_sample.get('budget') + seed = self.experiment_sample.input_data['seed'] + function_name = self.experiment_sample.input_data['function_name'] + dimensionality = self.experiment_sample.input_data['dimensionality'] + noise = self.experiment_sample.input_data['noise'] + budget = self.experiment_sample.input_data['budget'] hyperparameters = optimizer['hyperparameters'] \ if 'hyperparameters' in optimizer else {} @@ -103,9 +112,8 @@ def optimize_function(self, optimizer: dict) -> xr.Dataset: data.evaluate( data_generator=function_name, - kwargs={'scale_bounds': domain.get_bounds(), 'offset': True, - 'noise': noise, 'seed': seed}, - mode='sequential') + scale_bounds=domain.get_bounds(), offset=True, noise=noise, + seed=seed, mode='sequential') data.optimize( optimizer=optimizer['name'], data_generator=function_name, @@ -133,12 +141,15 @@ def execute(self): def pre_processing(config): + custom_sampler = CustomSampler( + seed=config.experimentdata.from_sampling.seed) + if 'from_sampling' in config.experimentdata: experimentdata = ExperimentData.from_sampling( - sampler=sample_if_compatible_function, + sampler=custom_sampler, domain=Domain.from_yaml(config.domain), n_samples=config.experimentdata.from_sampling.n_samples, - seed=config.experimentdata.from_sampling.seed) + ) else: experimentdata = ExperimentData.from_yaml(config.experimentdata) diff --git a/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.json b/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.json new file mode 100644 index 00000000..fe4f5009 --- /dev/null +++ b/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.json @@ -0,0 +1,105 @@ +{ + "input_space": { + "young_modulus": { + "type": "constant", + "to_disk": false, + "store_function": null, + "load_function": null, + "value": 3500.0 + }, + "n_longerons": { + "type": "constant", + "to_disk": false, + "store_function": null, + "load_function": null, + "value": 3 + }, + "bottom_diameter": { + "type": "constant", + "to_disk": false, + "store_function": null, + "load_function": null, + "value": 100.0 + }, + "ratio_top_diameter": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 0.8, + "log": false + }, + "ratio_pitch": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.25, + "upper_bound": 1.5, + "log": false + }, + "ratio_shear_modulus": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.035, + "upper_bound": 0.45, + "log": false + }, + "ratio_area": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 1.17e-05, + "upper_bound": 0.0041, + "log": false + }, + "ratio_Ixx": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 1.128e-11, + "upper_bound": 1.4e-06, + "log": false + }, + "ratio_Iyy": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 1.128e-11, + "upper_bound": 1.4e-06, + "log": false + }, + "ratio_J": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 1.353e-11, + "upper_bound": 7.77e-06, + "log": false + }, + "circular": { + "type": "constant", + "to_disk": false, + "store_function": null, + "load_function": null, + "value": false + }, + "imperfection": { + "type": "float", + "to_disk": false, + "store_function": null, + "load_function": null, + "lower_bound": 0.0, + "upper_bound": 1.0, + "log": false + } + }, + "output_space": {} +} \ No newline at end of file diff --git a/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.pkl b/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.pkl deleted file mode 100644 index 195a1995..00000000 Binary files a/studies/fragile_becomes_supercompressible/example_design/experiment_data/domain.pkl and /dev/null differ diff --git a/studies/fragile_becomes_supercompressible/example_design/experiment_data/input.csv b/studies/fragile_becomes_supercompressible/example_design/experiment_data/input.csv index 0878f0a3..a858a864 100644 --- a/studies/fragile_becomes_supercompressible/example_design/experiment_data/input.csv +++ b/studies/fragile_becomes_supercompressible/example_design/experiment_data/input.csv @@ -1,2 +1,2 @@ ,bottom_diameter,circular,imperfection,n_longerons,ratio_Ixx,ratio_Iyy,ratio_J,ratio_area,ratio_pitch,ratio_shear_modulus,ratio_top_diameter,young_modulus -0,100,0,0.2,3,0.0000001697758,0.0000003717950,1e-06,0.003624,0.75,0.367714286,0.4,3500.0 +0,100.0,0.0,0.2,3.0,1.697758e-07,3.71795e-07,1e-06,0.003624,0.75,0.367714286,0.4,3500.0 diff --git a/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.csv b/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.csv new file mode 100644 index 00000000..ac944188 --- /dev/null +++ b/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.csv @@ -0,0 +1,2 @@ +,0 +0,OPEN diff --git a/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.pkl b/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.pkl deleted file mode 100644 index 68654e40..00000000 Binary files a/studies/fragile_becomes_supercompressible/example_design/experiment_data/jobs.pkl and /dev/null differ diff --git a/studies/fragile_becomes_supercompressible/main.py b/studies/fragile_becomes_supercompressible/main.py index 2f608819..d78f3401 100644 --- a/studies/fragile_becomes_supercompressible/main.py +++ b/studies/fragile_becomes_supercompressible/main.py @@ -27,12 +27,12 @@ import hydra import numpy as np import pandas as pd -from f3dasm import ExperimentData +from abaqus2py import F3DASMAbaqusSimulator + +from f3dasm import Block, ExperimentData from f3dasm import logger as f3dasm_logger from f3dasm.design import Domain -from abaqus2py import F3DASMAbaqusSimulator - # Authorship & Credits # ============================================================================= __author__ = 'Martin van der Schelling (M.P.vanderSchelling@tudelft.nl)' @@ -46,32 +46,33 @@ # Custom sampler method # ============================================================================= -def log_normal_sampler(domain: Domain, n_samples: int, - mean: float, sigma: float, seed: Optional[int] = None): - """Sampler function for lognormal distribution - Parameters - ---------- - domain - Domain object - n_samples - Number of samples to generate - mean - Mean of the lognormal distribution - sigma - Standard deviation of the lognormal distribution - seed - Seed for the random number generator - - Returns - ------- - DataFrame - pandas DataFrame with the samples - """ - rng = np.random.default_rng(seed) - sampled_imperfections = rng.lognormal( - mean=mean, sigma=sigma, size=n_samples) - return pd.DataFrame(sampled_imperfections, columns=domain.names) +class LogNormalSampler(Block): + def __init__(self, mean: float, sigma: float, seed: Optional[int] = None): + """Sampler function for lognormal distribution + + Parameters + ---------- + mean + Mean of the lognormal distribution + sigma + Standard deviation of the lognormal distribution + seed + Seed for the random number generator + """ + self.mean = mean + self.sigma = sigma + self.seed = seed + + def call(self, data: ExperimentData, n_samples: int) -> ExperimentData: + rng = np.random.default_rng(self.seed) + sampled_imperfections = rng.lognormal( + mean=self.mean, sigma=self.sigma, size=n_samples) + df = pd.DataFrame(sampled_imperfections, + columns=self.domain.input_names) + return ExperimentData(domain=data.domain, + input_data=df, + project_dir=data.project_dir) # ============================================================================= @@ -81,14 +82,16 @@ def pre_processing(config): if 'from_sampling' in config.imperfection: domain_imperfections = Domain.from_yaml(config.imperfection.domain) + log_normal_sampler = LogNormalSampler( + mean=config.imperfection.mean, + sigma=config.imperfection.sigma, + seed=config.experimentdata.from_sampling.seed) imperfections = ExperimentData.from_sampling( sampler=log_normal_sampler, domain=domain_imperfections, n_samples=config.experimentdata.from_sampling.n_samples, - mean=config.imperfection.mean, - sigma=config.imperfection.sigma, - seed=config.experimentdata.from_sampling.seed) + ) experimentdata = experimentdata.join(imperfections) diff --git a/tests/datageneration/test_datagenerator.py b/tests/datageneration/test_datagenerator.py index 5a757ebe..a9a3f503 100644 --- a/tests/datageneration/test_datagenerator.py +++ b/tests/datageneration/test_datagenerator.py @@ -3,8 +3,9 @@ import pytest from f3dasm import ExperimentData -from f3dasm._src.datageneration.datagenerator import convert_function +from f3dasm._src.datageneration.datagenerator_factory import convert_function from f3dasm.datageneration import DataGenerator +from f3dasm.design import make_nd_continuous_domain pytestmark = pytest.mark.smoke @@ -27,3 +28,32 @@ def test_convert_function2( assert isinstance(data_generator, DataGenerator) experiment_data.evaluate(data_generator) + + +@pytest.mark.parametrize("mode", ['sequential', 'parallel']) +def test_parallelization(mode, tmp_path): + domain = make_nd_continuous_domain([[0, 1], [0, 1]]) + experiment_data = ExperimentData.from_sampling( + sampler='random', + domain=domain, + n_samples=10, + seed=42) + + experiment_data.remove_lockfile() + experiment_data.set_project_dir(tmp_path) + + experiment_data.evaluate(data_generator='ackley', + mode=mode) + + +def test_invalid_parallelization_mode(): + domain = make_nd_continuous_domain([[0, 1], [0, 1]]) + experiment_data = ExperimentData.from_sampling( + sampler='random', + domain=domain, + n_samples=10, + seed=42) + + with pytest.raises(ValueError): + experiment_data.evaluate(data_generator='ackley', + mode='invalid') diff --git a/tests/design/conftest.py b/tests/design/conftest.py index 4b749991..3733a9fe 100644 --- a/tests/design/conftest.py +++ b/tests/design/conftest.py @@ -1,23 +1,23 @@ import pandas as pd import pytest -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ContinuousParameter, - _DiscreteParameter) +from f3dasm._src.design.parameter import (CategoricalParameter, + ContinuousParameter, + DiscreteParameter) from f3dasm.design import Domain @pytest.fixture(scope="package") def doe(): - x1 = _ContinuousParameter(lower_bound=2.4, upper_bound=10.3) - x2 = _DiscreteParameter(lower_bound=5, upper_bound=80) - x3 = _ContinuousParameter(lower_bound=10.0, upper_bound=380.3) - x4 = _CategoricalParameter(categories=["test1", "test2", "test3"]) - x5 = _DiscreteParameter(lower_bound=2, upper_bound=3) + x1 = ContinuousParameter(lower_bound=2.4, upper_bound=10.3) + x2 = DiscreteParameter(lower_bound=5, upper_bound=80) + x3 = ContinuousParameter(lower_bound=10.0, upper_bound=380.3) + x4 = CategoricalParameter(categories=["test1", "test2", "test3"]) + x5 = DiscreteParameter(lower_bound=2, upper_bound=3) designspace = {'x1': x1, 'x2': x2, 'x3': x3, 'x4': x4, 'x5': x5} - doe = Domain(space=designspace) + doe = Domain(input_space=designspace) return doe @@ -25,33 +25,33 @@ def doe(): def domain(): space = { - 'x1': _ContinuousParameter(-5.12, 5.12), - 'x2': _DiscreteParameter(-3, 3), - 'x3': _CategoricalParameter(["red", "green", "blue"]) + 'x1': ContinuousParameter(-5.12, 5.12), + 'x2': DiscreteParameter(-3, 3), + 'x3': CategoricalParameter(["red", "green", "blue"]) } - return Domain(space=space) + return Domain(input_space=space) @pytest.fixture(scope="package") def continuous_parameter(): lower_bound = 3.3 upper_bound = 3.8 - return _ContinuousParameter(lower_bound=lower_bound, - upper_bound=upper_bound) + return ContinuousParameter(lower_bound=lower_bound, + upper_bound=upper_bound) @pytest.fixture(scope="package") def discrete_parameter(): lower_bound = 3 upper_bound = 6 - return _DiscreteParameter(lower_bound=lower_bound, upper_bound=upper_bound) + return DiscreteParameter(lower_bound=lower_bound, upper_bound=upper_bound) @pytest.fixture(scope="package") def categorical_parameter(): categories = ["test1", "test2", "test3"] - return _CategoricalParameter(categories=categories) + return CategoricalParameter(categories=categories) @pytest.fixture(scope="package") diff --git a/tests/design/test_data.py b/tests/design/test_data.py deleted file mode 100644 index 0d546ccd..00000000 --- a/tests/design/test_data.py +++ /dev/null @@ -1,125 +0,0 @@ - -import numpy as np -import pandas as pd -import pytest - -from f3dasm._src.experimentdata._data import _Data -from f3dasm.design import Domain - -pytestmark = pytest.mark.smoke - - -@pytest.fixture -def sample_data(): - input_data = pd.DataFrame({'input1': [1, 2, 3], 'input2': [4, 5, 6]}) - return _Data(input_data) - - -def test_data_initialization(sample_data: _Data): - assert isinstance(sample_data.data, pd.DataFrame) - - -def test_data_len(sample_data: _Data): - assert len(sample_data) == len(sample_data.data) - - -def test_data_repr_html(sample_data: _Data): - assert isinstance(sample_data._repr_html_(), str) - - -def test_data_from_design(domain: Domain): - # Assuming you have a Domain object named "domain" - data = _Data.from_domain(domain) - assert isinstance(data, _Data) - assert isinstance(data.data, pd.DataFrame) - - -def test_data_reset(sample_data: _Data): - # Assuming you have a Domain object named "domain" - design = Domain() - sample_data.reset(design) - assert isinstance(sample_data.data, pd.DataFrame) - assert len(sample_data) == 0 - - -def test_data_remove(sample_data: _Data): - indices = [0, 2] - sample_data.remove(indices) - assert len(sample_data) == 1 - - -def test_data_add_numpy_arrays(sample_data: _Data): - input_array = np.array([[1, 4], [2, 5]]) - df = pd.DataFrame(input_array, columns=sample_data.names) - sample_data.add(df) - assert len(sample_data) == 5 - - -def test_data_get_data(sample_data: _Data): - input_data = sample_data.data - assert isinstance(input_data, pd.DataFrame) - assert input_data.equals(sample_data.data) - - -def test_data_get_inputdata_dict(sample_data: _Data): - index = 0 - input_dict = sample_data.get_data_dict(index) - assert isinstance(input_dict, dict) - assert input_dict == {'input1': 1, 'input2': 4} - - -def test_data_set_data(sample_data: _Data): - index = 0 - sample_data.set_data(index=index, value=15, - column='output1') - _column_index = sample_data.columns.iloc('output1')[0] - assert sample_data.data.loc[index, _column_index] == 15 - - -def test_data_to_numpy(sample_data: _Data): - input_array = sample_data.to_numpy() - assert isinstance(input_array, np.ndarray) - assert input_array.shape == ( - len(sample_data), len(sample_data.data.columns)) - - -def test_data_n_best_samples(sample_data: _Data): - nosamples = 2 - output_names = 'input1' - result = sample_data.n_best_samples(nosamples, output_names) - assert isinstance(result, pd.DataFrame) - assert len(result) == nosamples - - -def test_compatible_columns_add(): - # create a 4 column dataframe with random numpy values - df = pd.DataFrame(np.random.randn(100, 4), columns=list('ABCD')) - dg = pd.DataFrame(np.random.randn(100, 4), columns=list('ABCD')) - - f = _Data(df) - g = _Data(dg) - - _ = f + g - - -def test_overwrite_data(sample_data: _Data): - overwrite_data = _Data(pd.DataFrame( - {'input1': [5, 6, 7], 'input2': [8, 9, 10]})) - - sample_data.overwrite(other=overwrite_data, indices=[0, 1, 2]) - - pd.testing.assert_frame_equal(sample_data.data, overwrite_data.data, - check_dtype=False, atol=1e-6) - - -def test_overwrite_data2(sample_data: _Data): - overwrite_data = _Data(pd.DataFrame( - {'input1': [5, 6, ], 'input2': [8, 9]})) - - sample_data.overwrite(other=overwrite_data, indices=[1, 2]) - - ground_truth = _Data(pd.DataFrame( - {'input1': [1, 5, 6], 'input2': [4, 8, 9]})) - - pd.testing.assert_frame_equal(sample_data.data, ground_truth.data, - check_dtype=False, atol=1e-6) diff --git a/tests/design/test_designofexperiments.py b/tests/design/test_designofexperiments.py index 29354121..85a71a07 100644 --- a/tests/design/test_designofexperiments.py +++ b/tests/design/test_designofexperiments.py @@ -1,10 +1,11 @@ import numpy as np -import pandas as pd import pytest -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ContinuousParameter, - _DiscreteParameter) +from f3dasm._src.design.domain import _domain_factory +from f3dasm._src.design.parameter import (CategoricalParameter, + ConstantParameter, + ContinuousParameter, + DiscreteParameter) from f3dasm.design import Domain, make_nd_continuous_domain pytestmark = pytest.mark.smoke @@ -13,7 +14,7 @@ def test_empty_space_doe(): doe = Domain() empty_dict = {} - assert doe.space == empty_dict + assert doe.input_space == empty_dict def test_correct_doe(doe): @@ -21,85 +22,85 @@ def test_correct_doe(doe): def test_get_continuous_parameters(doe: Domain): - design = {'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3)} - assert doe.continuous.space == design + design = {'x1': ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + 'x3': ContinuousParameter(lower_bound=10.0, upper_bound=380.3)} + assert doe.continuous.input_space == design def test_get_discrete_parameters(doe: Domain): - design = {'x2': _DiscreteParameter(lower_bound=5, upper_bound=80), - 'x5': _DiscreteParameter(lower_bound=2, upper_bound=3)} - assert doe.discrete.space == design + design = {'x2': DiscreteParameter(lower_bound=5, upper_bound=80), + 'x5': DiscreteParameter(lower_bound=2, upper_bound=3)} + assert doe.discrete.input_space == design def test_get_categorical_parameters(doe: Domain): - assert doe.categorical.space == {'x4': _CategoricalParameter( + assert doe.categorical.input_space == {'x4': CategoricalParameter( categories=["test1", "test2", "test3"])} def test_get_continuous_names(doe: Domain): - assert doe.continuous.names == ["x1", "x3"] + assert doe.continuous.input_names == ["x1", "x3"] def test_get_discrete_names(doe: Domain): - assert doe.discrete.names == ["x2", "x5"] + assert doe.discrete.input_names == ["x2", "x5"] def test_get_categorical_names(doe: Domain): - assert doe.categorical.names == ["x4"] + assert doe.categorical.input_names == ["x4"] def test_add_arbitrary_list_as_categorical_parameter(): arbitrary_list_1 = [3.1416, "pi", 42] arbitrary_list_2 = np.linspace(start=142, stop=214, num=10) - designspace = {'x1': _CategoricalParameter(categories=arbitrary_list_1), - 'x2': _CategoricalParameter(categories=arbitrary_list_2) + designspace = {'x1': CategoricalParameter(categories=arbitrary_list_1), + 'x2': CategoricalParameter(categories=arbitrary_list_2) } - design = Domain(space=designspace) + design = Domain(input_space=designspace) - assert design.space == designspace + assert design.input_space == designspace def test_add_input_space(): designspace = { - 'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x2': _DiscreteParameter(lower_bound=5, upper_bound=80), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + 'x1': ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + 'x2': DiscreteParameter(lower_bound=5, upper_bound=80), + 'x3': ContinuousParameter(lower_bound=10.0, upper_bound=380.3), } - design = Domain(space=designspace) + design = Domain(input_space=designspace) design.add('x4', type='category', categories=["test1", "test2", "test3"]) design.add('x5', type='int', low=2, high=3) - assert design.space == { - 'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x2': _DiscreteParameter(lower_bound=5, upper_bound=80), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - 'x4': _CategoricalParameter(categories=["test1", "test2", "test3"]), - 'x5': _DiscreteParameter(lower_bound=2, upper_bound=3) + assert design.input_space == { + 'x1': ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + 'x2': DiscreteParameter(lower_bound=5, upper_bound=80), + 'x3': ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + 'x4': CategoricalParameter(categories=["test1", "test2", "test3"]), + 'x5': DiscreteParameter(lower_bound=2, upper_bound=3) } def test_add_space(): designspace = { - 'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x2': _DiscreteParameter(lower_bound=5, upper_bound=80), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + 'x1': ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + 'x2': DiscreteParameter(lower_bound=5, upper_bound=80), + 'x3': ContinuousParameter(lower_bound=10.0, upper_bound=380.3), } - domain = Domain(space=designspace) + domain = Domain(input_space=designspace) domain.add('x4', type='category', categories=["test1", "test2", "test3"]) domain.add('x5', type='int', low=2, high=3) - assert domain.space == { - 'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x2': _DiscreteParameter(lower_bound=5, upper_bound=80), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - 'x4': _CategoricalParameter(categories=["test1", "test2", "test3"]), - 'x5': _DiscreteParameter(lower_bound=2, upper_bound=3), + assert domain.input_space == { + 'x1': ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + 'x2': DiscreteParameter(lower_bound=5, upper_bound=80), + 'x3': ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + 'x4': CategoricalParameter(categories=["test1", "test2", "test3"]), + 'x5': DiscreteParameter(lower_bound=2, upper_bound=3), } @@ -107,60 +108,116 @@ def test_getNumberOfInputParameters(doe: Domain): assert len(doe) == 5 -def test_all_input_continuous_False(doe: Domain): - assert doe._all_input_continuous() is False +def test_get_input_names(domain: Domain): + # Ensure that get_input_names returns the correct input parameter names + assert domain.input_names == ['x1', 'x2', 'x3'] -def test_all_input_continuous_True(): - designspace = { - 'x1': _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - 'x3': _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - } +def test_get_number_of_input_parameters(domain: Domain): + # Ensure that get_number_of_input_parameters returns the correct number of input parameters + assert len(domain) == 3 - doe = Domain(space=designspace) - assert doe._all_input_continuous() is True +def test_add_parameter_with_same_name_error(): + domain = Domain() + domain.add('x1', type='float', low=0.0, high=1.0) + with pytest.raises(KeyError): + domain.add('x1', type='int', low=0, high=1) -def test_cast_types_dataframe_input(doe: Domain): - ground_truth = { - "x1": "float", - "x2": "int", - "x3": "float", - "x4": "object", - "x5": "int", - } +def test_add_int_with_same_low_and_high(): + domain = Domain() + domain.add_int('x1', low=1, high=1) + assert domain.input_space == { + 'x1': ConstantParameter(value=1)} - assert doe._cast_types_dataframe() == ground_truth +def test_add_float_with_same_low_and_high(): + domain = Domain() + domain.add_float('x1', low=1.0, high=1.0) + assert domain.input_space == { + 'x1': ConstantParameter(value=1.0)} -def test_get_input_names(domain: Domain): - # Ensure that get_input_names returns the correct input parameter names - assert domain.names == ['x1', 'x2', 'x3'] +def test_add_category_with_add_method(): + domain = Domain() + domain.add('x1', type='category', categories=['a', 'b', 'c']) + assert domain.input_space == { + 'x1': CategoricalParameter(categories=['a', 'b', 'c'])} -def test_get_number_of_input_parameters(domain: Domain): - # Ensure that get_number_of_input_parameters returns the correct number of input parameters - assert len(domain) == 3 + +def test_add_constant_with_add_method(): + domain = Domain() + domain.add('x1', type='constant', value=1) + assert domain.input_space == { + 'x1': ConstantParameter(value=1)} + + +def test_add_output_that_exists_without_exist_ok(): + domain = Domain() + domain.add_output('y') + with pytest.raises(KeyError): + domain.add_output('y', exist_ok=False) + + +def test_add_output_that_exists_with_exist_ok(): + domain = Domain() + domain.add_output('y') + domain.add_output('y', exist_ok=True) + assert domain.output_names == ['y'] + + +def test_domain_factory_with_invalid_input(): + with pytest.raises(TypeError): + _domain_factory(domain=0) + + +def test_eq_different_types(): + domain = Domain() + with pytest.raises(TypeError): + domain == 0 + + +def test_add_different_types(): + domain = Domain() + with pytest.raises(TypeError): + domain + 0 + + +def test_str_method(): + domain = Domain() + assert str( + domain) == 'Domain(\n Input Space: { }\n Output Space: { }\n)' + + +def test_add_two_constant_parameters(): + c1 = ConstantParameter(value=1) + c2 = ConstantParameter(value=2) + assert CategoricalParameter(categories=[1, 2]) == c1 + c2 + + +def test_add_discrete_to_constant(): + c = ConstantParameter(value=1) + disc = DiscreteParameter(lower_bound=2, upper_bound=4) + assert CategoricalParameter(categories=[1, 2, 3]) == c + disc -def test_domain_from_dataframe(sample_dataframe: pd.DataFrame): - domain = Domain.from_dataframe( - df_input=sample_dataframe, df_output=pd.DataFrame()) - ground_truth = Domain(space={'feature1': _ContinuousParameter(lower_bound=1.0, upper_bound=3.0), - 'feature2': _DiscreteParameter(lower_bound=4, upper_bound=6), - 'feature3': _CategoricalParameter(['A', 'B', 'C'])}) - assert (domain.space == ground_truth.space) +def test_add_continuous_to_constant_error(): + c = ConstantParameter(value=1) + cont = ContinuousParameter(lower_bound=2, upper_bound=3) + with pytest.raises(ValueError): + c + cont @pytest.mark.parametrize("bounds", [((0., 1.), (0., 1.), (0., 1.)), ([0., 1.], [0., 1.], [0., 1.]), np.array([[0., 1.], [0., 1.], [0., 1.]]), np.tile([0., 1.], (3, 1))]) def test_make_nd_continuous_domain(bounds): domain = make_nd_continuous_domain(bounds=bounds, dimensionality=3) - ground_truth = Domain(space={'x0': _ContinuousParameter(lower_bound=0.0, upper_bound=1.0), - 'x1': _ContinuousParameter(lower_bound=0.0, upper_bound=1.0), - 'x2': _ContinuousParameter(lower_bound=0.0, upper_bound=1.0)}) - assert (domain.space == ground_truth.space) + ground_truth = Domain() + ground_truth.add_float('x0', low=0.0, high=1.0) + ground_truth.add_float('x1', low=0.0, high=1.0) + ground_truth.add_float('x2', low=0.0, high=1.0) + assert (domain.input_space == ground_truth.input_space) if __name__ == "__main__": # pragma: no cover diff --git a/tests/design/test_domain.py b/tests/design/test_domain.py new file mode 100644 index 00000000..d1c7ff31 --- /dev/null +++ b/tests/design/test_domain.py @@ -0,0 +1,240 @@ +import json +from pathlib import Path + +import numpy as np +import pytest + +from f3dasm._src.design.domain import Domain +from f3dasm._src.design.parameter import (CategoricalParameter, + ConstantParameter, + ContinuousParameter, + DiscreteParameter) + +pytestmark = pytest.mark.smoke + + +# Continuous space tests + + +def test_check_default_lower_bound(): + continuous = ContinuousParameter() + assert continuous.lower_bound == -np.inf + + +def test_check_default_upper_bound(): + continuous = ContinuousParameter() + assert continuous.upper_bound == np.inf + + +def test_correct_continuous_space(): + lower_bound = 3.3 + upper_bound = 3.8 + _ = ContinuousParameter(lower_bound=lower_bound, upper_bound=upper_bound) + + +def test_higher_upper_bound_than_lower_bound_continuous_space(): + lower_bound = 2.0 + upper_bound = 1.5 + with pytest.raises(ValueError): + _ = ContinuousParameter( + lower_bound=lower_bound, upper_bound=upper_bound) + + +def test_same_lower_and_upper_bound_continuous_space(): + lower_bound = 1.5 + upper_bound = 1.5 + with pytest.raises(ValueError): + _ = ContinuousParameter( + lower_bound=lower_bound, upper_bound=upper_bound) + + +# Discrete space tests + + +def test_correct_discrete_space(): + lower_bound = 1 + upper_bound = 100 + _ = DiscreteParameter(lower_bound=lower_bound, upper_bound=upper_bound) + + +def test_higher_upper_bound_than_lower_bound_discrete_space(): + lower_bound = 2 + upper_bound = 1 + with pytest.raises(ValueError): + _ = DiscreteParameter(lower_bound=lower_bound, + upper_bound=upper_bound) + + +def test_same_lower_and_upper_bound_discrete_space(): + lower_bound = 1 + upper_bound = 1 + with pytest.raises(ValueError): + _ = DiscreteParameter(lower_bound=lower_bound, + upper_bound=upper_bound) + + +def test_integer_types_arg1_float_discrete_space(): + lower_bound = 1 # float + upper_bound = 2.5 + parameter = DiscreteParameter(lower_bound=lower_bound, + upper_bound=upper_bound) + + assert isinstance(parameter.lower_bound, int) + + +def test_float_types_arg2_float_discrete_space(): + lower_bound = 1 + upper_bound = 2.0 # float + parameter = DiscreteParameter(lower_bound=lower_bound, + upper_bound=upper_bound) + assert isinstance(parameter.upper_bound, int) + + +# Categorical space tests + + +def test_correct_categorical_space(): + categories = ["test1", "test2", "test3", "test4"] + _ = CategoricalParameter(categories=categories) + + +def test_invalid_types_in_categories_categorical_space(): + categories = ["test1", "test2", [3, 4], "test4"] + with pytest.raises(TypeError): + _ = CategoricalParameter(categories=categories) + + +def test_duplicates_categories_categorical_space(): + categories = ["test1", "test2", "test1"] + with pytest.raises(ValueError): + _ = CategoricalParameter(categories=categories) + + +@pytest.mark.parametrize("args", [((0., 5.), (-1., 3.), (-1., 5.),), + ((0., 5.), (1., 3.), (0., 5.),), + ((-1., 3.), (0., 5.), (-1., 5.),), + ((0., 5.), (0., 5.), (0., 5.),)]) +def test_add_continuous(args): + a, b, expected = args + param_a = ContinuousParameter(*a) + param_b = ContinuousParameter(*b) + + assert param_a + param_b == ContinuousParameter(*expected) + + +@pytest.mark.parametrize("args", [((0., 5.), (6., 10.)),]) +def test_faulty_continuous_ranges(args): + a, b = args + param_a = ContinuousParameter(*a) + param_b = ContinuousParameter(*b) + with pytest.raises(ValueError): + param_a + param_b + + +def test_faulty_continous_log(): + a = ContinuousParameter(1., 5., log=True) + b = ContinuousParameter(0., 5., log=False) + with pytest.raises(ValueError): + a + b + + +@pytest.mark.parametrize("args", [(('test1', 'test2'), ('test3',), ('test1', 'test2', 'test3'),), + (('test1', 'test3'), ('test3',), + ('test1', 'test3'),)]) +def test_add_categorical(args): + a, b, expected = args + param_a = CategoricalParameter(list(a)) + param_b = CategoricalParameter(list(b)) + + assert param_a + param_b == CategoricalParameter(list(expected)) + + +@pytest.mark.parametrize( + "args", + [(CategoricalParameter(['test1', 'test2']), ConstantParameter('test3'), CategoricalParameter(['test1', 'test2', 'test3']),), + (CategoricalParameter(['test1', 'test2']), DiscreteParameter( + 1, 3), CategoricalParameter(['test1', 'test2', 1, 2]),), + (CategoricalParameter(['test1', 'test2']), ConstantParameter( + 'test1'), CategoricalParameter(['test1', 'test2']),), + (CategoricalParameter(['test1', 'test2']), CategoricalParameter([ + 'test1']), CategoricalParameter(['test1', 'test2']),), + (ConstantParameter('test3'), CategoricalParameter( + ['test1', 'test2']), CategoricalParameter(['test1', 'test2', 'test3'])) + + + ]) +def test_add_combination(args): + a, b, expected = args + assert a + b == expected + + +def test_to_discrete(): + a = ContinuousParameter(0., 5.) + c = DiscreteParameter(0, 5, 0.2) + b = a.to_discrete(0.2) + assert isinstance(b, DiscreteParameter) + assert b.lower_bound == 0 + assert b.upper_bound == 5 + assert b.step == 0.2 + assert b == c + + +def test_to_discrete_negative_stepsize(): + a = ContinuousParameter(0., 5.) + with pytest.raises(ValueError): + a.to_discrete(-0.2) + + +def test_default_stepsize_to_discrete(): + default_stepsize = 1 + a = ContinuousParameter(0., 5.) + c = DiscreteParameter(0, 5, default_stepsize) + b = a.to_discrete() + assert isinstance(b, DiscreteParameter) + assert b.lower_bound == 0 + assert b.upper_bound == 5 + assert b.step == default_stepsize + assert b == c + + +def test_domain_store(tmp_path): + domain = Domain() + domain.add_float('param1', 0.0, 1.0) + domain.add_int('param2', 0, 10) + domain.add_category('param3', ['a', 'b', 'c']) + domain.add_constant('param4', 42) + + json_file = tmp_path / "domain.json" + domain.store(json_file) + + with open(json_file, 'r') as f: + data = json.load(f) + + assert 'input_space' in data + assert 'output_space' in data + assert 'param1' in data['input_space'] + assert 'param2' in data['input_space'] + assert 'param3' in data['input_space'] + assert 'param4' in data['input_space'] + + +def test_domain_from_json(tmp_path): + domain = Domain() + domain.add_float('param1', 0.0, 1.0) + domain.add_int('param2', 0, 10) + domain.add_category('param3', ['a', 'b', 'c']) + domain.add_constant('param4', 42) + + json_file = tmp_path / "domain.json" + domain.store(json_file) + + loaded_domain = Domain.from_file(json_file) + + assert loaded_domain.input_space['param1'] == domain.input_space['param1'] + assert loaded_domain.input_space['param2'] == domain.input_space['param2'] + assert loaded_domain.input_space['param3'] == domain.input_space['param3'] + assert loaded_domain.input_space['param4'] == domain.input_space['param4'] + + +if __name__ == "__main__": # pragma: no cover + pytest.main() diff --git a/tests/design/test_jobqueue.py b/tests/design/test_jobqueue.py deleted file mode 100644 index efc4b8e3..00000000 --- a/tests/design/test_jobqueue.py +++ /dev/null @@ -1,93 +0,0 @@ -import pandas as pd -import pytest - -from f3dasm._src.experimentdata._jobqueue import _JobQueue -from f3dasm.design import NoOpenJobsError, Status - -pytestmark = pytest.mark.smoke - - -@pytest.fixture -def sample_job_queue(): - jobs = pd.Series(['open', 'open', 'in progress', - 'finished'], dtype='string') - job_queue = _JobQueue(jobs) - - yield job_queue - - # Reset the job queue to its original state after each test - job_queue.reset() - - -@pytest.fixture -def empty_job_queue(): - return _JobQueue() - - -def test_job_queue_initialization(sample_job_queue: _JobQueue): - assert isinstance(sample_job_queue.jobs, pd.Series) - - -def test_job_queue_repr_html(sample_job_queue: _JobQueue): - assert isinstance(sample_job_queue._repr_html_(), str) - - -def test_job_queue_remove(sample_job_queue: _JobQueue): - sample_job_queue.remove([1, 3]) - expected_jobs = pd.Series(['open', 'in progress'], index=[ - 0, 2], dtype='string') - assert sample_job_queue.jobs.equals(expected_jobs) - - -def test_job_queue_add(): - job_queue = _JobQueue() - job_queue.add(5, 'open') - assert job_queue.jobs.equals( - pd.Series(['open', 'open', 'open', 'open', 'open'], dtype='string')) - - -def test_job_queue_reset(sample_job_queue: _JobQueue): - sample_job_queue.reset() - assert sample_job_queue.jobs.empty - - -def test_job_queue_get_open_job(sample_job_queue: _JobQueue): - open_job_index = sample_job_queue.get_open_job() - assert isinstance(open_job_index, int) - assert open_job_index in sample_job_queue.jobs.index - - -def test_job_queue_get_open_job_no_jobs(): - jobs = pd.Series(['finished', 'finished', 'in progress', - 'finished'], dtype='string') - job_queue = _JobQueue(jobs) - with pytest.raises(NoOpenJobsError): - job_queue.get_open_job() - - -def test_job_queue_mark_as_in_progress(sample_job_queue: _JobQueue): - # sample_job_queue.mark_as_in_progress(0) - sample_job_queue.mark(0, Status.IN_PROGRESS) - assert sample_job_queue.jobs.loc[0] == 'in progress' - - -def test_job_queue_mark_as_finished(sample_job_queue: _JobQueue): - # sample_job_queue.mark_as_finished(1) - sample_job_queue.mark(1, Status.FINISHED) - assert sample_job_queue.jobs.loc[1] == 'finished' - - -def test_job_queue_mark_as_error(sample_job_queue: _JobQueue): - # sample_job_queue.mark_as_error(2) - sample_job_queue.mark(2, Status.ERROR) - assert sample_job_queue.jobs.loc[2] == 'error' - - -def test_job_queue_mark_all_in_progress_open(sample_job_queue: _JobQueue): - sample_job_queue.mark_all_in_progress_open() - assert sample_job_queue.jobs.equals( - pd.Series(['open', 'open', 'open', 'finished'], dtype='string')) - - -def test_job_queue_is_all_finished(sample_job_queue: _JobQueue): - assert not sample_job_queue.is_all_finished() diff --git a/tests/design/test_parameters.py b/tests/design/test_parameters.py new file mode 100644 index 00000000..197b7056 --- /dev/null +++ b/tests/design/test_parameters.py @@ -0,0 +1,283 @@ +import pickle + +import pytest + +from f3dasm._src.design.parameter import (CategoricalParameter, + ConstantParameter, + ContinuousParameter, + DiscreteParameter, Parameter) + +pytestmark = pytest.mark.smoke + + +def store_function(obj, path): + with open(path, 'wb') as f: + pickle.dump(obj, f) + return path + + +def load_function(path): + with open(path, 'rb') as f: + return pickle.load(f) + +# Test Parameter Base Class + + +@pytest.mark.parametrize("to_disk, store_function, load_function, raises", [ + (False, None, None, False), + (True, store_function, load_function, False), + (False, store_function, None, True), + (False, None, load_function, True), +]) +def test_parameter_init(to_disk, store_function, load_function, raises): + if raises: + with pytest.raises(ValueError): + Parameter(to_disk, store_function, load_function) + else: + param = Parameter(to_disk, store_function, load_function) + assert param.to_disk == to_disk + assert param.store_function == store_function + assert param.load_function == load_function + + +def test_parameter_str(): + param = Parameter() + assert str(param) == "Parameter(type=object, to_disk=False)" + + +def test_parameter_repr(): + param = Parameter() + assert repr(param) == "Parameter(to_disk=False)" + + +def test_parameter_eq(): + param1 = Parameter() + param2 = Parameter() + assert param1 == param2 + + +def test_parameter_add(): + param1 = Parameter() + param2 = Parameter() + result = param1 + param2 + assert result == param1 + + +def test_parameter_to_dict(): + param = Parameter(to_disk=True) + param_dict = param.to_dict() + assert param_dict['type'] == 'object' + assert param_dict['to_disk'] is True + + +def test_parameter_from_dict(): + param_dict = {'type': 'object', 'to_disk': True, 'store_function': None, + 'load_function': None} + param = Parameter.from_dict(param_dict) + assert param.to_disk is True + + +def test_parameter_with_functions_to_dict(): + param = Parameter(to_disk=True, store_function=store_function, + load_function=load_function) + param_dict = param.to_dict() + assert param_dict['store_function'] is not None + assert param_dict['load_function'] is not None + + +def test_parameter_with_functions_from_dict(): + param = Parameter(to_disk=True, store_function=store_function, + load_function=load_function) + param_dict = param.to_dict() + loaded_param = Parameter.from_dict(param_dict) + assert loaded_param.to_disk is True + assert pickle.dumps( + loaded_param.store_function) == pickle.dumps(store_function) + assert pickle.dumps( + loaded_param.load_function) == pickle.dumps(load_function) + +# Test ConstantParameter + + +@pytest.mark.parametrize("value, raises", [ + (5, False), + ("test", False), + ({"a": 1}, True), +]) +def test_constant_parameter_init(value, raises): + if raises: + with pytest.raises(TypeError): + ConstantParameter(value) + else: + param = ConstantParameter(value) + assert param.value == value + assert param.to_disk is False + + +def test_constant_parameter_to_categorical(): + param = ConstantParameter(42) + cat_param = param.to_categorical() + assert isinstance(cat_param, CategoricalParameter) + assert cat_param.categories == [42] + + +def test_constant_parameter_str(): + param = ConstantParameter(42) + assert str(param) == "ConstantParameter(value=42)" + + +def test_constant_parameter_repr(): + param = ConstantParameter(42) + assert repr(param) == "ConstantParameter(value=42)" + + +def test_constant_parameter_eq(): + param1 = ConstantParameter(42) + param2 = ConstantParameter(42) + assert param1 == param2 + +# Test ContinuousParameter + + +@pytest.mark.parametrize("lower_bound, upper_bound, log, raises", [ + (0.0, 10.0, False, False), + (0.0, 10.0, True, True), + (0.0, 0.0, False, True), + (10.0, 5.0, False, True), + (-1.0, 5.0, True, True), +]) +def test_continuous_parameter_init(lower_bound, upper_bound, log, raises): + if raises: + with pytest.raises(ValueError): + ContinuousParameter(lower_bound, upper_bound, log) + else: + param = ContinuousParameter(lower_bound, upper_bound, log) + assert param.lower_bound == lower_bound + assert param.upper_bound == upper_bound + assert param.log == log + + +def test_continuous_parameter_to_discrete(): + param = ContinuousParameter(0.0, 10.0) + discrete_param = param.to_discrete(step=2) + assert isinstance(discrete_param, DiscreteParameter) + assert discrete_param.lower_bound == 0.0 + assert discrete_param.upper_bound == 10.0 + assert discrete_param.step == 2 + + +def test_continuous_parameter_str(): + param = ContinuousParameter(0.0, 10.0, log=False) + assert str( + param) == "ContinuousParameter(lower_bound=0.0, upper_bound=10.0, log=False)" + + +def test_continuous_parameter_repr(): + param = ContinuousParameter(0.0, 10.0, log=False) + assert repr( + param) == "ContinuousParameter(lower_bound=0.0, upper_bound=10.0, log=False)" + + +def test_continuous_parameter_eq(): + param1 = ContinuousParameter(0.0, 10.0, log=False) + param2 = ContinuousParameter(0.0, 10.0, log=False) + assert param1 == param2 + +# Test DiscreteParameter + + +@pytest.mark.parametrize("lower_bound, upper_bound, step, raises", [ + (0, 10, 1, False), + (10, 0, 1, True), + (0, 10, -1, True), +]) +def test_discrete_parameter_init(lower_bound, upper_bound, step, raises): + if raises: + with pytest.raises(ValueError): + DiscreteParameter(lower_bound, upper_bound, step) + else: + param = DiscreteParameter(lower_bound, upper_bound, step) + assert param.lower_bound == lower_bound + assert param.upper_bound == upper_bound + assert param.step == step + + +def test_discrete_parameter_str(): + param = DiscreteParameter(0, 10, 2) + assert str(param) == "DiscreteParameter(lower_bound=0, upper_bound=10, step=2)" + + +def test_discrete_parameter_repr(): + param = DiscreteParameter(0, 10, 2) + assert repr( + param) == "DiscreteParameter(lower_bound=0, upper_bound=10, step=2)" + + +def test_discrete_parameter_eq(): + param1 = DiscreteParameter(0, 10, 2) + param2 = DiscreteParameter(0, 10, 2) + assert param1 == param2 + +# Test CategoricalParameter + + +@pytest.mark.parametrize("categories, raises", [ + (["a", "b", "c"], False), + ([1, 2, 3], False), + (["a", "a", "b"], True), +]) +def test_categorical_parameter_init(categories, raises): + if raises: + with pytest.raises(ValueError): + CategoricalParameter(categories) + else: + param = CategoricalParameter(categories) + assert set(param.categories) == set(categories) + + +def test_categorical_parameter_str(): + param = CategoricalParameter(["a", "b", "c"]) + assert str(param) == "CategoricalParameter(categories=['a', 'b', 'c'])" + + +def test_categorical_parameter_repr(): + param = CategoricalParameter(["a", "b", "c"]) + assert repr(param) == "CategoricalParameter(categories=['a', 'b', 'c'])" + + +def test_categorical_parameter_eq(): + param1 = CategoricalParameter(["a", "b", "c"]) + param2 = CategoricalParameter(["c", "b", "a"]) + assert param1 == param2 + + +def test_add_constant_to_constant(): + param1 = ConstantParameter(5) + param2 = ConstantParameter(5) + result = param1 + param2 + assert result == param1 + + +def test_add_constant_to_categorical(): + const_param = ConstantParameter(5) + cat_param = CategoricalParameter([1, 2, 3]) + result = const_param + cat_param + assert isinstance(result, CategoricalParameter) + assert set(result.categories) == {1, 2, 3, 5} + + +def test_add_categorical_to_categorical(): + cat_param1 = CategoricalParameter(["a", "b"]) + cat_param2 = CategoricalParameter(["b", "c"]) + result = cat_param1 + cat_param2 + assert isinstance(result, CategoricalParameter) + assert set(result.categories) == {"a", "b", "c"} + + +def test_add_continuous_to_continuous(): + param1 = ContinuousParameter(0.0, 10.0) + param2 = ContinuousParameter(5.0, 15.0) + result = param1 + param2 + assert isinstance(result, ContinuousParameter) + assert result.lower_bound == 0.0 + assert result.upper_bound == 15.0 diff --git a/tests/design/test_space.py b/tests/design/test_space.py deleted file mode 100644 index 5670fa77..00000000 --- a/tests/design/test_space.py +++ /dev/null @@ -1,212 +0,0 @@ -import numpy as np -import pytest - -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ConstantParameter, - _ContinuousParameter, - _DiscreteParameter) - -pytestmark = pytest.mark.smoke - - -# Continuous space tests - - -def test_check_default_lower_bound(): - continuous = _ContinuousParameter() - assert continuous.lower_bound == -np.inf - - -def test_check_default_upper_bound(): - continuous = _ContinuousParameter() - assert continuous.upper_bound == np.inf - - -def test_correct_continuous_space(): - lower_bound = 3.3 - upper_bound = 3.8 - _ = _ContinuousParameter(lower_bound=lower_bound, upper_bound=upper_bound) - - -def test_higher_upper_bound_than_lower_bound_continuous_space(): - lower_bound = 2.0 - upper_bound = 1.5 - with pytest.raises(ValueError): - _ = _ContinuousParameter( - lower_bound=lower_bound, upper_bound=upper_bound) - - -def test_same_lower_and_upper_bound_continuous_space(): - lower_bound = 1.5 - upper_bound = 1.5 - with pytest.raises(ValueError): - _ = _ContinuousParameter( - lower_bound=lower_bound, upper_bound=upper_bound) - - -def test_invalid_types_string_continuous_space(): - lower_bound = "1" # string - upper_bound = 1.5 - with pytest.raises(TypeError): - _ = _ContinuousParameter( - lower_bound=lower_bound, upper_bound=upper_bound) - - -# Discrete space tests - - -def test_correct_discrete_space(): - lower_bound = 1 - upper_bound = 100 - _ = _DiscreteParameter(lower_bound=lower_bound, upper_bound=upper_bound) - - -def test_higher_upper_bound_than_lower_bound_discrete_space(): - lower_bound = 2 - upper_bound = 1 - with pytest.raises(ValueError): - _ = _DiscreteParameter(lower_bound=lower_bound, - upper_bound=upper_bound) - - -def test_same_lower_and_upper_bound_discrete_space(): - lower_bound = 1 - upper_bound = 1 - with pytest.raises(ValueError): - _ = _DiscreteParameter(lower_bound=lower_bound, - upper_bound=upper_bound) - - -def test_invalid_types_arg1_float_discrete_space(): - lower_bound = 1 # float - upper_bound = 1.5 - with pytest.raises(TypeError): - _ = _DiscreteParameter(lower_bound=lower_bound, - upper_bound=upper_bound) - - -def test_invalid_types_arg2_float_discrete_space(): - lower_bound = 1 - upper_bound = 2.0 # float - with pytest.raises(TypeError): - _ = _DiscreteParameter(lower_bound=lower_bound, - upper_bound=upper_bound) - - -def test_invalid_types_string_discrete_space(): - lower_bound = "1" # string - upper_bound = 1 - with pytest.raises(TypeError): - _ = _DiscreteParameter(lower_bound=lower_bound, - upper_bound=upper_bound) - - -# Categorical space tests - - -def test_correct_categorical_space(): - categories = ["test1", "test2", "test3", "test4"] - _ = _CategoricalParameter(categories=categories) - - -def test_invalid_types_in_categories_categorical_space(): - categories = ["test1", "test2", [3, 4], "test4"] - with pytest.raises(TypeError): - _ = _CategoricalParameter(categories=categories) - - -def test_duplicates_categories_categorical_space(): - categories = ["test1", "test2", "test1"] - with pytest.raises(ValueError): - _ = _CategoricalParameter(categories=categories) - - -@pytest.mark.parametrize("args", [((0., 5.), (-1., 3.), (-1., 5.),), - ((0., 5.), (1., 3.), (0., 5.),), - ((-1., 3.), (0., 5.), (-1., 5.),), - ((0., 5.), (0., 5.), (0., 5.),)]) -def test_add_continuous(args): - a, b, expected = args - param_a = _ContinuousParameter(*a) - param_b = _ContinuousParameter(*b) - - assert param_a + param_b == _ContinuousParameter(*expected) - - -@pytest.mark.parametrize("args", [((0., 5.), (6., 10.)),]) -def test_faulty_continuous_ranges(args): - a, b = args - param_a = _ContinuousParameter(*a) - param_b = _ContinuousParameter(*b) - with pytest.raises(ValueError): - param_a + param_b - - -def test_faulty_continous_log(): - a = _ContinuousParameter(1., 5., log=True) - b = _ContinuousParameter(0., 5., log=False) - with pytest.raises(ValueError): - a + b - - -@pytest.mark.parametrize("args", [(('test1', 'test2'), ('test3',), ('test1', 'test2', 'test3'),), - (('test1', 'test3'), ('test3',), - ('test1', 'test3'),)]) -def test_add_categorical(args): - a, b, expected = args - param_a = _CategoricalParameter(list(a)) - param_b = _CategoricalParameter(list(b)) - - assert param_a + param_b == _CategoricalParameter(list(expected)) - - -@pytest.mark.parametrize( - "args", - [(_CategoricalParameter(['test1', 'test2']), _ConstantParameter('test3'), _CategoricalParameter(['test1', 'test2', 'test3']),), - (_CategoricalParameter(['test1', 'test2']), _DiscreteParameter( - 1, 3), _CategoricalParameter(['test1', 'test2', 1, 2]),), - (_CategoricalParameter(['test1', 'test2']), _ConstantParameter( - 'test1'), _CategoricalParameter(['test1', 'test2']),), - (_CategoricalParameter(['test1', 'test2']), _CategoricalParameter([ - 'test1']), _CategoricalParameter(['test1', 'test2']),), - (_ConstantParameter('test3'), _CategoricalParameter( - ['test1', 'test2']), _CategoricalParameter(['test1', 'test2', 'test3'])) - - - ]) -def test_add_combination(args): - a, b, expected = args - assert a + b == expected - - -def test_to_discrete(): - a = _ContinuousParameter(0., 5.) - c = _DiscreteParameter(0, 5, 0.2) - b = a.to_discrete(0.2) - assert isinstance(b, _DiscreteParameter) - assert b.lower_bound == 0 - assert b.upper_bound == 5 - assert b.step == 0.2 - assert b == c - - -def test_to_discrete_negative_stepsize(): - a = _ContinuousParameter(0., 5.) - with pytest.raises(ValueError): - a.to_discrete(-0.2) - - -def test_default_stepsize_to_discrete(): - default_stepsize = 1 - a = _ContinuousParameter(0., 5.) - c = _DiscreteParameter(0, 5, default_stepsize) - b = a.to_discrete() - assert isinstance(b, _DiscreteParameter) - assert b.lower_bound == 0 - assert b.upper_bound == 5 - assert b.step == default_stepsize - assert b == c - - -if __name__ == "__main__": # pragma: no cover - pytest.main() diff --git a/tests/design/test_trial.py b/tests/design/test_trial.py deleted file mode 100644 index ccbc7b85..00000000 --- a/tests/design/test_trial.py +++ /dev/null @@ -1,32 +0,0 @@ -import numpy as np -import pytest - -from f3dasm import ExperimentSample - -pytestmark = pytest.mark.smoke - - -def test_design_initialization(design_data): - dict_input, dict_output, job_number = design_data - design = ExperimentSample(dict_input, dict_output, job_number) - assert design.input_data == dict_input - assert design._dict_output == dict_output - assert design.job_number == job_number - - -def test_design_to_numpy(design_data): - dict_input, dict_output, job_number = design_data - design = ExperimentSample(dict_input, dict_output, job_number) - input_array, output_array = design.to_numpy() - - check_output_array = np.array([v for v, _ in dict_output.values()]) - assert np.array_equal(output_array, check_output_array) - - -def test_design_set(design_data): - dict_input, dict_output, job_number = design_data - design = ExperimentSample(dict_input, dict_output, job_number) - design['output3'] = 5 - - # Check if the output data is updated - design.output_data['output3'] == 5 diff --git a/tests/experimentdata/conftest.py b/tests/experimentdata/conftest.py index f2b70947..22abfa39 100644 --- a/tests/experimentdata/conftest.py +++ b/tests/experimentdata/conftest.py @@ -1,134 +1,162 @@ -from __future__ import annotations +# from __future__ import annotations -import numpy as np -import pandas as pd -import pytest -import xarray as xr +# import numpy as np +# import pandas as pd +# import pytest +# import xarray as xr -from f3dasm import ExperimentData -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ContinuousParameter, - _DiscreteParameter) -from f3dasm.design import Domain, make_nd_continuous_domain +# from f3dasm import ExperimentData +# from f3dasm._src.design.parameter import (CategoricalParameter, +# ContinuousParameter, +# DiscreteParameter) +# from f3dasm.design import Domain, make_nd_continuous_domain -SEED = 42 +# SEED = 42 -@pytest.fixture(scope="package") -def seed() -> int: - return SEED +# @pytest.fixture(scope="package") +# def seed() -> int: +# return SEED -@pytest.fixture(scope="package") -def domain() -> Domain: +# @pytest.fixture(scope="package") +# def domain() -> Domain: - space = { - 'x1': _ContinuousParameter(-5.12, 5.12), - 'x2': _DiscreteParameter(-3, 3), - 'x3': _CategoricalParameter(["red", "green", "blue"]) - } +# space = { +# 'x1': ContinuousParameter(-5.12, 5.12), +# 'x2': DiscreteParameter(-3, 3), +# 'x3': CategoricalParameter(["red", "green", "blue"]) +# } - return Domain(space=space) +# return Domain(input_space=space) -@pytest.fixture(scope="package") -def domain_continuous() -> Domain: - return make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) +# @pytest.fixture(scope="package") +# def domain_continuous() -> Domain: +# return make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) -@pytest.fixture(scope="package") -def experimentdata(domain: Domain) -> ExperimentData: - e_data = ExperimentData(domain) - e_data.sample(sampler='random', n_samples=10, seed=SEED) - return e_data - - -@pytest.fixture(scope="package") -def experimentdata2(domain: Domain) -> ExperimentData: - return ExperimentData.from_sampling(sampler='random', domain=domain, n_samples=10, seed=SEED) - - -@pytest.fixture(scope="package") -def experimentdata_continuous(domain_continuous: Domain) -> ExperimentData: - return ExperimentData.from_sampling(sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) - - -@pytest.fixture(scope="package") -def experimentdata_expected() -> ExperimentData: - domain_continuous = make_nd_continuous_domain( - bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) - data = ExperimentData.from_sampling( - sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) - for es, output in zip(data, np.zeros((10, 1))): - es.store(name='y', object=float(output)) - data._set_experiment_sample(es) - data.add(input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), - output_data=np.array([[0.0], [0.0]]), domain=domain_continuous) - - data._input_data.round(6) - # data._input_data.data = [[round(num, 6) if isinstance( - # num, float) else num for num in sublist] - # for sublist in data._input_data.data] - return data - - -@pytest.fixture(scope="package") -def experimentdata_expected_no_output() -> ExperimentData: - domain_continuous = make_nd_continuous_domain( - bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) - data = ExperimentData.from_sampling( - sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) - data.add(input_data=np.array( - [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), domain=domain_continuous) - - data._input_data.round(6) - # data._input_data.data = [[round(num, 6) if isinstance( - # num, float) else num for num in sublist] - # for sublist in data._input_data.data] - return data - - -@pytest.fixture(scope="package") -def experimentdata_expected_only_domain() -> ExperimentData: - domain_continuous = make_nd_continuous_domain( - bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) - return ExperimentData(domain=domain_continuous) - - -@pytest.fixture(scope="package") -def numpy_array(domain_continuous: Domain) -> np.ndarray: - rng = np.random.default_rng(SEED) - return rng.random((10, len(domain_continuous))) - - -@pytest.fixture(scope="package") -def numpy_output_array(domain_continuous: Domain) -> np.ndarray: - return np.zeros((10, 1)) - - -@pytest.fixture(scope="package") -def xarray_dataset(domain_continuous: Domain) -> xr.Dataset: - rng = np.random.default_rng(SEED) - # np.random.seed(SEED) - input_data = rng.random((10, len(domain_continuous))) - input_names = domain_continuous.names - - output_data = pd.DataFrame() - output_names = output_data.columns.to_list() - - return xr.Dataset({'input': xr.DataArray(input_data, dims=['iterations', 'input_dim'], coords={ - 'iterations': range(len(input_data)), 'input_dim': input_names}), - 'output': xr.DataArray(output_data, dims=['iterations', 'output_dim'], coords={ - 'iterations': range(len(output_data)), 'output_dim': output_names})}) - - -@pytest.fixture(scope="package") -def pandas_dataframe(domain_continuous: Domain) -> pd.DataFrame: - # np.random.seed(SEED) - rng = np.random.default_rng(SEED) - return pd.DataFrame(rng.random((10, len(domain_continuous))), columns=domain_continuous.names) - - -@pytest.fixture(scope="package") -def continuous_parameter() -> _ContinuousParameter: - return _ContinuousParameter(lower_bound=0., upper_bound=1.) +# @pytest.fixture(scope="package") +# def experimentdata(domain: Domain) -> ExperimentData: +# e_data = ExperimentData(domain) +# e_data.sample(sampler='random', n_samples=10, seed=SEED) +# return e_data + + +# @pytest.fixture(scope="package") +# def experimentdata2(domain: Domain) -> ExperimentData: +# return ExperimentData.from_sampling(sampler='random', domain=domain, n_samples=10, seed=SEED) + + +# @pytest.fixture(scope="package") +# def experimentdata_continuous(domain_continuous: Domain) -> ExperimentData: +# experiment_data = ExperimentData.from_sampling( +# sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) +# experiment_data.round(3) +# return experiment_data + + +# @pytest.fixture(scope="package") +# def experimentdata_expected() -> ExperimentData: +# domain_continuous = make_nd_continuous_domain( +# bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) +# data = ExperimentData.from_sampling( +# sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) +# for (id, es), output in zip(data, np.zeros((10, 1))): +# es.store(name='y', object=float(output)) +# data.store_experimentsample(experiment_sample=es, +# id=id) +# data.add(input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), +# output_data=np.array([[0.0], [0.0]]), domain=domain_continuous) + +# # data._input_data.round(6) +# # data._input_data.data = [[round(num, 6) if isinstance( +# # num, float) else num for num in sublist] +# # for sublist in data._input_data.data] +# data.round(3) +# data.mark_all('finished') +# return data + + +# @pytest.fixture(scope="package") +# def experimentdata_expected_no_output() -> ExperimentData: +# domain_continuous = make_nd_continuous_domain( +# bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) +# data = ExperimentData.from_sampling( +# sampler='random', domain=domain_continuous, n_samples=10, seed=SEED) +# data.add(input_data=np.array( +# [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), domain=domain_continuous) + +# data._input_data.round(6) +# # data._input_data.data = [[round(num, 6) if isinstance( +# # num, float) else num for num in sublist] +# # for sublist in data._input_data.data] +# return data + + +# @pytest.fixture(scope="package") +# def experimentdata_expected_only_domain() -> ExperimentData: +# domain_continuous = make_nd_continuous_domain( +# bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3) +# return ExperimentData(domain=domain_continuous) + + +# @pytest.fixture(scope="package") +# def numpy_array(domain_continuous: Domain) -> np.ndarray: +# rng = np.random.default_rng(SEED) +# return rng.random((10, len(domain_continuous))) + + +# @pytest.fixture(scope="package") +# def numpy_output_array(domain_continuous: Domain) -> np.ndarray: +# return np.zeros((10, 1)) + + +# @pytest.fixture(scope="package") +# def xarray_dataset(domain_continuous: Domain) -> xr.Dataset: +# rng = np.random.default_rng(SEED) +# input_data = rng.random((10, len(domain_continuous))).round(3) +# input_names = domain_continuous.input_names + +# output_data = pd.DataFrame() +# output_names = output_data.columns.to_list() + +# iterations = np.arange(len(input_data)).astype( +# np.int32) # Cast to desired dtype +# # Ensure strings for input_dim +# input_names = np.array(input_names, dtype=object) + +# return xr.Dataset( +# { +# 'input': xr.DataArray( +# input_data, +# dims=['iterations', 'input_dim'], +# coords={ +# 'iterations': iterations, +# 'input_dim': input_names, +# } +# ), +# 'output': xr.DataArray( +# output_data, +# dims=['iterations', 'output_dim'], +# coords={ +# 'iterations': iterations[:len(output_data)], +# # Ensure strings for output_dim +# 'output_dim': np.array(output_names, dtype=object), +# } +# ) +# } +# ) + + +# @pytest.fixture(scope="package") +# def pandas_dataframe(domain_continuous: Domain) -> pd.DataFrame: +# # np.random.seed(SEED) +# rng = np.random.default_rng(SEED) +# return pd.DataFrame(rng.random((10, len(domain_continuous))).round(3), +# columns=domain_continuous.input_names) + + +# @pytest.fixture(scope="package") +# def continuous_parameter() -> ContinuousParameter: +# return ContinuousParameter(lower_bound=0., upper_bound=1.) diff --git a/tests/experimentdata/test__jobqueue.py b/tests/experimentdata/test__jobqueue.py deleted file mode 100644 index b38268bc..00000000 --- a/tests/experimentdata/test__jobqueue.py +++ /dev/null @@ -1,36 +0,0 @@ -import pandas as pd - -from f3dasm._src.experimentdata._jobqueue import _JobQueue - - -def test_select_all_with_matching_status(): - # Create a job queue with some jobs - job_queue = _JobQueue() - job_queue.jobs = pd.Series(['in progress', 'running', 'completed', 'in progress', 'failed']) - - # Select all jobs with status 'in progress' - selected_jobs = job_queue.select_all('in progress') - - # Check if the selected jobs match the expected result - assert (selected_jobs.jobs == ['in progress', 'in progress']).all() - -def test_select_all_with_no_matching_status(): - # Create a job queue with some jobs - job_queue = _JobQueue() - job_queue.jobs = pd.Series(['in progress', 'running', 'completed', 'in progress', 'failed']) - - # Select all jobs with status 'cancelled' - selected_jobs = job_queue.select_all('cancelled') - - # Check if the selected jobs match the expected result - assert selected_jobs.jobs.empty - -def test_select_all_with_empty_job_queue(): - # Create an empty job queue - job_queue = _JobQueue() - - # Select all jobs with status 'in progress' - selected_jobs = job_queue.select_all('in progress') - - # Check if the selected jobs match the expected result - assert selected_jobs.jobs.empty diff --git a/tests/experimentdata/test_experimentdata.py b/tests/experimentdata/test_experimentdata.py index 4b682997..2aa2044d 100644 --- a/tests/experimentdata/test_experimentdata.py +++ b/tests/experimentdata/test_experimentdata.py @@ -1,734 +1,800 @@ -from __future__ import annotations +# from __future__ import annotations -import csv -import pickle -from pathlib import Path -from typing import Iterable +# import csv +# import pickle +# from pathlib import Path +# from typing import Callable, Iterable, Union -import numpy as np -import pandas as pd -import pytest -import xarray as xr +# import numpy as np +# import pandas as pd +# import pytest +# import xarray as xr -from f3dasm import ExperimentData, ExperimentSample -from f3dasm._src.design.parameter import _ContinuousParameter -from f3dasm._src.experimentdata._data import DataTypes, _Data -from f3dasm._src.experimentdata._jobqueue import _JobQueue -from f3dasm.design import Domain, Status, make_nd_continuous_domain +# from f3dasm import ExperimentData, ExperimentSample +# from f3dasm._src.design.parameter import ContinuousParameter +# from f3dasm._src.experimentdata.utils import DataTypes +# from f3dasm.design import Domain, make_nd_continuous_domain -pytestmark = pytest.mark.smoke +# pytestmark = pytest.mark.smoke -SEED = 42 +# SEED = 42 -def test_check_experimentdata(experimentdata: ExperimentData): - assert isinstance(experimentdata, ExperimentData) +# def test_check_experimentdata(experimentdata: ExperimentData): +# assert isinstance(experimentdata, ExperimentData) -# Write test functions +# # Write test functions -def test_experiment_data_init(experimentdata: ExperimentData, domain: Domain): - assert experimentdata.domain == domain - assert experimentdata.project_dir == Path.cwd() - # Add more assertions as needed +# def test_experiment_data_init(experimentdata: ExperimentData, domain: Domain): +# assert experimentdata.domain == domain +# assert experimentdata.project_dir == Path.cwd() +# # Add more assertions as needed -def test_experiment_data_add(experimentdata: ExperimentData, - experimentdata2: ExperimentData, domain: Domain): - experimentdata_total = ExperimentData(domain) - experimentdata_total.add_experiments(experimentdata) - experimentdata_total.add_experiments(experimentdata2) - assert experimentdata_total == experimentdata + experimentdata2 +# def test_experiment_data_add(experimentdata: ExperimentData, +# experimentdata2: ExperimentData, domain: Domain): +# experimentdata_total = ExperimentData(domain) +# experimentdata_total.add_experiments(experimentdata) +# experimentdata_total.add_experiments(experimentdata2) +# assert experimentdata_total == experimentdata + experimentdata2 -def test_experiment_data_len_empty(domain: Domain): - experiment_data = ExperimentData(domain) - assert len(experiment_data) == 0 # Update with the expected length +# def test_experiment_data_len_empty(domain: Domain): +# experiment_data = ExperimentData(domain) +# assert len(experiment_data) == 0 # Update with the expected length -def test_experiment_data_len_equals_input_data(experimentdata: ExperimentData): - assert len(experimentdata) == len(experimentdata._input_data) +# def test_experiment_data_len_equals_input_data(experimentdata: ExperimentData): +# assert len(experimentdata) == len(experimentdata.data) -@pytest.mark.parametrize("slice_type", [3, [0, 1, 3]]) -def test_experiment_data_select(slice_type: int | Iterable[int], experimentdata: ExperimentData): - input_data = experimentdata._input_data[slice_type] - output_data = experimentdata._output_data[slice_type] - jobs = experimentdata._jobs[slice_type] - constructed_experimentdata = ExperimentData( - input_data=input_data, output_data=output_data, jobs=jobs, domain=experimentdata.domain) - assert constructed_experimentdata == experimentdata.select(slice_type) +# @pytest.mark.parametrize("slice_type", [3, [0, 1, 3]]) +# def test_experiment_data_select(slice_type: int | Iterable[int], experimentdata: ExperimentData): +# sliced_experimentdata = experimentdata.select(slice_type) +# constructed_experimentdata = ExperimentData.from_data(data=sliced_experimentdata.data, +# domain=experimentdata.domain) +# assert sliced_experimentdata == constructed_experimentdata -# Constructors -# ====================================================================================== +# # Constructors +# # ====================================================================================== -def test_from_file(experimentdata_continuous: ExperimentData, seed: int, tmp_path: Path): - # experimentdata_continuous.filename = tmp_path / 'test001' - experimentdata_continuous.store(tmp_path / 'experimentdata') +# def test_from_file(experimentdata_continuous: ExperimentData, seed: int, tmp_path: Path): +# # experimentdata_continuous.filename = tmp_path / 'test001' +# experimentdata_continuous.store(tmp_path / 'experimentdata') - experimentdata_from_file = ExperimentData.from_file( - tmp_path / 'experimentdata') +# experimentdata_from_file = ExperimentData.from_file( +# tmp_path / 'experimentdata') - # Check if the input_data attribute of ExperimentData matches the expected_data - pd.testing.assert_frame_equal( - experimentdata_continuous._input_data.to_dataframe(), experimentdata_from_file._input_data.to_dataframe(), check_dtype=False, atol=1e-6) - pd.testing.assert_frame_equal(experimentdata_continuous._output_data.to_dataframe(), - experimentdata_from_file._output_data.to_dataframe()) - pd.testing.assert_series_equal( - experimentdata_continuous._jobs.jobs, experimentdata_from_file._jobs.jobs) - # assert experimentdata_continuous.input_data == experimentdata_from_file.input_data - assert experimentdata_continuous._output_data == experimentdata_from_file._output_data - assert experimentdata_continuous.domain == experimentdata_from_file.domain - assert experimentdata_continuous._jobs == experimentdata_from_file._jobs +# experimentdata_from_file.round(3) +# assert experimentdata_from_file == experimentdata_continuous +# # # Check if the input_data attribute of ExperimentData matches the expected_data +# # pd.testing.assert_frame_equal( +# # experimentdata_continuous._input_data.to_dataframe(), experimentdata_from_file._input_data.to_dataframe(), check_dtype=False, atol=1e-6) +# # pd.testing.assert_frame_equal(experimentdata_continuous._output_data.to_dataframe(), +# # experimentdata_from_file._output_data.to_dataframe()) +# # pd.testing.assert_series_equal( +# # experimentdata_continuous._jobs.jobs, experimentdata_from_file._jobs.jobs) +# # # assert experimentdata_continuous.input_data == experimentdata_from_file.input_data +# # assert experimentdata_continuous._output_data == experimentdata_from_file._output_data +# # assert experimentdata_continuous.domain == experimentdata_from_file.domain +# # assert experimentdata_continuous._jobs == experimentdata_from_file._jobs -def test_from_file_wrong_name(experimentdata_continuous: ExperimentData, seed: int, tmp_path: Path): - experimentdata_continuous.filename = tmp_path / 'test001' - experimentdata_continuous.store() - with pytest.raises(FileNotFoundError): - _ = ExperimentData.from_file(tmp_path / 'experimentdata') +# def test_from_file_wrong_name(experimentdata_continuous: ExperimentData, seed: int, tmp_path: Path): +# experimentdata_continuous.set_project_dir(tmp_path / 'test001') +# experimentdata_continuous.store() +# with pytest.raises(FileNotFoundError): +# _ = ExperimentData.from_file(tmp_path / 'experimentdata') -def test_from_sampling(experimentdata_continuous: ExperimentData, seed: int): - # sampler = RandomUniform(domain=experimentdata_continuous.domain, number_of_samples=10, seed=seed) - experimentdata_from_sampling = ExperimentData.from_sampling(sampler='random', - domain=experimentdata_continuous.domain, - n_samples=10, seed=seed) - assert experimentdata_from_sampling == experimentdata_continuous +# def test_from_sampling(experimentdata_continuous: ExperimentData, seed: int): +# # sampler = RandomUniform(domain=experimentdata_continuous.domain, number_of_samples=10, seed=seed) +# experimentdata_from_sampling = ExperimentData.from_sampling(sampler='random', +# domain=experimentdata_continuous.domain, +# n_samples=10, seed=seed) -@pytest.fixture -def sample_csv_inputdata(tmp_path): - # Create sample CSV files for testing - input_csv_file = tmp_path / 'experimentdata_data.csv' +# experimentdata_from_sampling.round(3) +# assert experimentdata_from_sampling == experimentdata_continuous - # Create sample input and output dataframes - input_data = pd.DataFrame( - {'input_col1': [1, 2, 3], 'input_col2': [4, 5, 6]}) - return input_csv_file, input_data +# @pytest.fixture +# def sample_csv_inputdata(tmp_path): +# # Create sample CSV files for testing +# input_csv_file = tmp_path / 'experimentdata_data.csv' +# # Create sample input and output dataframes +# input_data = pd.DataFrame( +# {'input_col1': [1, 2, 3], 'input_col2': [4, 5, 6]}) -@pytest.fixture -def sample_csv_outputdata(tmp_path): - # Create sample CSV files for testing - output_csv_file = tmp_path / 'experimentdata_output.csv' +# return input_csv_file, input_data - # Create sample input and output dataframes - output_data = pd.DataFrame( - {'output_col1': [7, 8, 9], 'output_col2': [10, 11, 12]}) - return output_csv_file, output_data +# @pytest.fixture +# def sample_csv_outputdata(tmp_path): +# # Create sample CSV files for testing +# output_csv_file = tmp_path / 'experimentdata_output.csv' +# # Create sample input and output dataframes +# output_data = pd.DataFrame( +# {'output_col1': [7, 8, 9], 'output_col2': [10, 11, 12]}) -def test_from_object(experimentdata_continuous: ExperimentData): - input_data = experimentdata_continuous._input_data - output_data = experimentdata_continuous._output_data - jobs = experimentdata_continuous._jobs - domain = experimentdata_continuous.domain - experiment_data = ExperimentData( - input_data=input_data, output_data=output_data, jobs=jobs, domain=domain) - assert experiment_data == ExperimentData( - input_data=input_data, output_data=output_data, jobs=jobs, domain=domain) - assert experiment_data == experimentdata_continuous +# return output_csv_file, output_data -# Exporters -# ====================================================================================== +# def test_from_object(experimentdata_continuous: ExperimentData): +# df_input, df_output = experimentdata_continuous.to_pandas() -def test_to_numpy(experimentdata_continuous: ExperimentData, numpy_array: np.ndarray): - x, y = experimentdata_continuous.to_numpy() +# experiment_data = ExperimentData( +# input_data=df_input, +# output_data=df_output, +# domain=experimentdata_continuous.domain, +# project_dir=experimentdata_continuous.project_dir) - # cast x to floats - x = x.astype(float) - # assert if x and numpy_array have all the same values - assert np.allclose(x, numpy_array) - - -def test_to_xarray(experimentdata_continuous: ExperimentData, xarray_dataset: xr.DataSet): - exported_dataset = experimentdata_continuous.to_xarray() - # assert if xr_dataset is equal to xarray - assert exported_dataset.equals(xarray_dataset) +# assert experimentdata_continuous == experiment_data +# # input_data = experimentdata_continuous._input_data +# # output_data = experimentdata_continuous._output_data +# # jobs = experimentdata_continuous._jobs +# # domain = experimentdata_continuous.domain +# # experiment_data = ExperimentData( +# # input_data=input_data, output_data=output_data, jobs=jobs, domain=domain) +# # assert experiment_data == ExperimentData( +# # input_data=input_data, output_data=output_data, jobs=jobs, domain=domain) +# # assert experiment_data == experimentdata_continuous -def test_to_pandas(experimentdata_continuous: ExperimentData, pandas_dataframe: pd.DataFrame): - exported_dataframe, _ = experimentdata_continuous.to_pandas() - # assert if pandas_dataframe is equal to exported_dataframe - pd.testing.assert_frame_equal( - exported_dataframe, pandas_dataframe, atol=1e-6, check_dtype=False) -# Exporters -# ====================================================================================== +# # Exporters +# # ====================================================================================== -def test_add_new_input_column(experimentdata: ExperimentData, - continuous_parameter: _ContinuousParameter): - kwargs = {'low': continuous_parameter.lower_bound, - 'high': continuous_parameter.upper_bound} - experimentdata.add_input_parameter( - name='test', type='float', **kwargs) - assert 'test' in experimentdata._input_data.names +# def test_to_numpy(experimentdata_continuous: ExperimentData, numpy_array: np.ndarray): +# x, y = experimentdata_continuous.to_numpy() +# # cast x to floats +# x = x.astype(float) -def test_add_new_output_column(experimentdata: ExperimentData): - experimentdata.add_output_parameter(name='test', is_disk=False) - assert 'test' in experimentdata._output_data.names +# # assert if x and numpy_array have all the same values +# assert np.allclose(x, numpy_array, rtol=1e-2) -def test_set_error(experimentdata_continuous: ExperimentData): - experimentdata_continuous._set_error(3) - assert experimentdata_continuous._jobs.jobs[3] == Status.ERROR +# def test_to_xarray(experimentdata_continuous: ExperimentData, xarray_dataset: xr.DataSet): +# exported_dataset = experimentdata_continuous.to_xarray() +# # assert if xr_dataset is equal to xarray +# assert exported_dataset.equals(xarray_dataset) -# Helper function to create a temporary CSV file with sample data -def create_sample_csv_input(file_path): - data = [ - ["x0", "x1", "x2"], - [0.77395605, 0.43887844, 0.85859792], - [0.69736803, 0.09417735, 0.97562235], - [0.7611397, 0.78606431, 0.12811363], - [0.45038594, 0.37079802, 0.92676499], - [0.64386512, 0.82276161, 0.4434142], - [0.22723872, 0.55458479, 0.06381726], - [0.82763117, 0.6316644, 0.75808774], - [0.35452597, 0.97069802, 0.89312112], - [0.7783835, 0.19463871, 0.466721], - [0.04380377, 0.15428949, 0.68304895], - [0.000000, 0.000000, 0.000000], - [1.000000, 1.000000, 1.000000], - ] - with open(file_path, mode='w', newline='') as file: - writer = csv.writer(file) - writer.writerows(data) +# def test_to_pandas(experimentdata_continuous: ExperimentData, pandas_dataframe: pd.DataFrame): +# exported_dataframe, _ = experimentdata_continuous.to_pandas() +# # assert if pandas_dataframe is equal to exported_dataframe +# pd.testing.assert_frame_equal( +# exported_dataframe, pandas_dataframe, atol=1e-6, check_dtype=False) +# # Exporters +# # ====================================================================================== -def create_sample_csv_output(file_path): - data = [ - ["y"], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], +# def test_set_error(experimentdata_continuous: ExperimentData): +# experimentdata_continuous.mark(indices=3, status='error') +# assert experimentdata_continuous.data[3].is_status('error') - ] - with open(file_path, mode='w', newline='') as file: - writer = csv.writer(file) - writer.writerows(data) -# Pytest fixture to create a temporary CSV file +# # Helper function to create a temporary CSV file with sample data +# def create_sample_csv_input(file_path): +# data = [ +# ["x0", "x1", "x2"], +# [0.77395605, 0.43887844, 0.85859792], +# [0.69736803, 0.09417735, 0.97562235], +# [0.7611397, 0.78606431, 0.12811363], +# [0.45038594, 0.37079802, 0.92676499], +# [0.64386512, 0.82276161, 0.4434142], +# [0.22723872, 0.55458479, 0.06381726], +# [0.82763117, 0.6316644, 0.75808774], +# [0.35452597, 0.97069802, 0.89312112], +# [0.7783835, 0.19463871, 0.466721], +# [0.04380377, 0.15428949, 0.68304895], +# [0.000000, 0.000000, 0.000000], +# [1.000000, 1.000000, 1.000000], +# ] +# with open(file_path, mode='w', newline='') as file: +# writer = csv.writer(file) +# writer.writerows(data) -def create_domain_pickle(filepath): - domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3) - domain.store(filepath) +# def create_sample_csv_output(file_path): +# data = [ +# ["y"], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# ] +# with open(file_path, mode='w', newline='') as file: +# writer = csv.writer(file) +# writer.writerows(data) -def create_jobs_pickle_finished(filepath): - domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3) +# # Pytest fixture to create a temporary CSV file - _data_input = _Data.from_dataframe(pd_input()) - _data_output = _Data.from_dataframe(pd_output()) - experimentdata = ExperimentData( - domain=domain, input_data=_data_input, output_data=_data_output) - experimentdata._jobs.store(filepath) - - -def create_jobs_pickle_open(filepath): - domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3) - - _data_input = _Data.from_dataframe(pd_input()) - experimentdata = ExperimentData(domain=domain, input_data=_data_input) - experimentdata._jobs.store(filepath) +# def create_domain_pickle(filepath): +# domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# dimensionality=3) +# domain.add_output('y', exist_ok=True) +# domain.store(filepath) -def path_domain(tmp_path): - domain_file_path = tmp_path / "test_domain.pkl" - create_domain_pickle(domain_file_path) - return domain_file_path - - -def str_domain(tmp_path): - domain_file_path = tmp_path / "test_domain.pkl" - create_domain_pickle(domain_file_path) - return str(domain_file_path) - - -def path_jobs_finished(tmp_path): - jobs_file_path = tmp_path / "test_jobs.pkl" - create_jobs_pickle_finished(jobs_file_path) - return jobs_file_path - - -def str_jobs_finished(tmp_path): - jobs_file_path = tmp_path / "test_jobs.pkl" - create_jobs_pickle_finished(jobs_file_path) - return str(jobs_file_path) - - -def path_jobs_open(tmp_path): - jobs_file_path = tmp_path / "test_jobs.pkl" - create_jobs_pickle_open(jobs_file_path) - return jobs_file_path - - -def str_jobs_open(tmp_path): - jobs_file_path = tmp_path / "test_jobs.pkl" - create_jobs_pickle_open(jobs_file_path) - return str(jobs_file_path) - - -def path_input(tmp_path): - csv_file_path = tmp_path / "test_input.csv" - create_sample_csv_input(csv_file_path) - return csv_file_path - - -def str_input(tmp_path): - csv_file_path = tmp_path / "test_input.csv" - create_sample_csv_input(csv_file_path) - return str(csv_file_path) - - -def path_output(tmp_path: Path): - csv_file_path = tmp_path / "test_output.csv" - create_sample_csv_output(csv_file_path) - return csv_file_path - - -def str_output(tmp_path: Path): - csv_file_path = tmp_path / "test_output.csv" - create_sample_csv_output(csv_file_path) - return str(csv_file_path) - -# Pytest test function for reading and monkeypatching a CSV file +# def create_jobs_csv_finished(filepath): +# domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# dimensionality=3) -def numpy_input(): - return np.array([ - [0.77395605, 0.43887844, 0.85859792], - [0.69736803, 0.09417735, 0.97562235], - [0.7611397, 0.78606431, 0.12811363], - [0.45038594, 0.37079802, 0.92676499], - [0.64386512, 0.82276161, 0.4434142], - [0.22723872, 0.55458479, 0.06381726], - [0.82763117, 0.6316644, 0.75808774], - [0.35452597, 0.97069802, 0.89312112], - [0.7783835, 0.19463871, 0.466721], - [0.04380377, 0.15428949, 0.68304895], - [0.000000, 0.000000, 0.000000], - [1.000000, 1.000000, 1.000000], - ]) +# _data_input = pd_input() +# _data_output = pd_output() +# experimentdata = ExperimentData( +# domain=domain, input_data=_data_input, output_data=_data_output) +# experimentdata.jobs.to_csv(Path(filepath).with_suffix('.csv')) -def numpy_output(): - return np.array([ - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], +# def create_jobs_csv_open(filepath): +# domain = make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# dimensionality=3) - ]) +# _data_input = pd_input() +# experimentdata = ExperimentData(domain=domain, input_data=_data_input) +# experimentdata.jobs.to_csv(Path(filepath).with_suffix('.csv')) -def pd_input(): - return pd.DataFrame([ - [0.77395605, 0.43887844, 0.85859792], - [0.69736803, 0.09417735, 0.97562235], - [0.7611397, 0.78606431, 0.12811363], - [0.45038594, 0.37079802, 0.92676499], - [0.64386512, 0.82276161, 0.4434142], - [0.22723872, 0.55458479, 0.06381726], - [0.82763117, 0.6316644, 0.75808774], - [0.35452597, 0.97069802, 0.89312112], - [0.7783835, 0.19463871, 0.466721], - [0.04380377, 0.15428949, 0.68304895], - [0.000000, 0.000000, 0.000000], - [1.000000, 1.000000, 1.000000], - ], columns=["x0", "x1", "x2"]) - - -def pd_output(): - return pd.DataFrame([ - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - [0.0], - - ], columns=["y"]) - - -def data_input(): - return _Data.from_dataframe(pd_input()) - - -def data_output(): - return _Data.from_dataframe(pd_output()) - - -@pytest.mark.parametrize("input_data", [path_input, str_input, pd_input(), data_input(), numpy_input()]) -@pytest.mark.parametrize("output_data", [path_output, str_output, pd_output(), data_output()]) -@pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3), None, path_domain, str_domain]) -@pytest.mark.parametrize("jobs", [None, path_jobs_finished, str_jobs_finished]) -def test_init_with_output(input_data: DataTypes, output_data: DataTypes, domain: Domain | str | Path | None, - jobs: _JobQueue | str | Path | None, - experimentdata_expected: ExperimentData, monkeypatch, tmp_path: Path): - - # if input_data is Callable - if callable(input_data): - input_data = input_data(tmp_path) - expected_data_input = pd.read_csv(input_data) - - # if output_data is Callable - if callable(output_data): - output_data = output_data(tmp_path) - expected_data_output = pd.read_csv(output_data) - - if callable(domain): - domain = domain(tmp_path) - expected_domain = Domain.from_file(domain) - - if callable(jobs): - jobs = jobs(tmp_path) - expected_jobs = _JobQueue.from_file(jobs).jobs - - # monkeypatch pd.read_csv to return the expected_data DataFrame - def mock_read_csv(*args, **kwargs): - - path = args[0] - if isinstance(args[0], str): - path = Path(path) - - if path == tmp_path / "test_input.csv": - return expected_data_input - - elif path == tmp_path / "test_output.csv": - return expected_data_output - - else: - raise ValueError("Unexpected file path") - - def mock_load_pickle(*args, **kwargs): - return expected_domain - - def mock_pd_read_pickle(*args, **kwargs): - path = args[0] - - if isinstance(path, str): - path = Path(path) - - if path == tmp_path / "test_jobs.pkl": - return expected_jobs - - else: - raise ValueError("Unexpected jobs file path") - - monkeypatch.setattr(pd, "read_csv", mock_read_csv) - monkeypatch.setattr(pickle, "load", mock_load_pickle) - monkeypatch.setattr(pd, "read_pickle", mock_pd_read_pickle) - - if isinstance(input_data, np.ndarray) and domain is None: - with pytest.raises(ValueError): - ExperimentData(domain=domain, input_data=input_data, - output_data=output_data, jobs=jobs) - return - # Initialize ExperimentData with the CSV file - experiment_data = ExperimentData(domain=domain, input_data=input_data, - output_data=output_data, jobs=jobs) - - # Check if the input_data attribute of ExperimentData matches the expected_data - pd.testing.assert_frame_equal( - experiment_data._input_data.to_dataframe(), experimentdata_expected._input_data.to_dataframe(), check_dtype=False, atol=1e-6) - pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), - experimentdata_expected._output_data.to_dataframe(), check_dtype=False) - - -@pytest.mark.parametrize("input_data", [pd_input(), path_input, str_input, data_input(), numpy_input()]) -@pytest.mark.parametrize("output_data", [None]) -@pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3), None, path_domain, str_domain]) -@pytest.mark.parametrize("jobs", [None, path_jobs_open, str_jobs_open]) -def test_init_without_output(input_data: DataTypes, output_data: DataTypes, domain: Domain, jobs: _JobQueue, - experimentdata_expected_no_output: ExperimentData, monkeypatch, tmp_path): +# def path_domain(tmp_path): +# domain_file_path = tmp_path / "test_domain.pkl" +# create_domain_pickle(domain_file_path) +# return domain_file_path + + +# def str_domain(tmp_path): +# domain_file_path = tmp_path / "test_domain.pkl" +# create_domain_pickle(domain_file_path) +# return str(domain_file_path) + + +# def path_jobs_finished(tmp_path): +# jobs_file_path = tmp_path / "test_jobs.csv" +# create_jobs_csv_finished(jobs_file_path) +# return jobs_file_path + + +# def str_jobs_finished(tmp_path): +# jobs_file_path = tmp_path / "test_jobs.csv" +# create_jobs_csv_finished(jobs_file_path) +# return str(jobs_file_path) + + +# def path_jobs_open(tmp_path): +# jobs_file_path = tmp_path / "test_jobs.pkl" +# create_jobs_csv_open(jobs_file_path) +# return jobs_file_path + + +# def str_jobs_open(tmp_path): +# jobs_file_path = tmp_path / "test_jobs.pkl" +# create_jobs_csv_open(jobs_file_path) +# return str(jobs_file_path) + + +# def path_input(tmp_path): +# csv_file_path = tmp_path / "test_input.csv" +# create_sample_csv_input(csv_file_path) +# return csv_file_path + + +# def str_input(tmp_path): +# csv_file_path = tmp_path / "test_input.csv" +# create_sample_csv_input(csv_file_path) +# return str(csv_file_path) + + +# def path_output(tmp_path: Path): +# csv_file_path = tmp_path / "test_output.csv" +# create_sample_csv_output(csv_file_path) +# return csv_file_path + + +# def str_output(tmp_path: Path): +# csv_file_path = tmp_path / "test_output.csv" +# create_sample_csv_output(csv_file_path) +# return str(csv_file_path) + +# # Pytest test function for reading and monkeypatching a CSV file + + +# def numpy_input(*args, **kwargs): +# return np.array([ +# [0.77395605, 0.43887844, 0.85859792], +# [0.69736803, 0.09417735, 0.97562235], +# [0.7611397, 0.78606431, 0.12811363], +# [0.45038594, 0.37079802, 0.92676499], +# [0.64386512, 0.82276161, 0.4434142], +# [0.22723872, 0.55458479, 0.06381726], +# [0.82763117, 0.6316644, 0.75808774], +# [0.35452597, 0.97069802, 0.89312112], +# [0.7783835, 0.19463871, 0.466721], +# [0.04380377, 0.15428949, 0.68304895], +# [0.000000, 0.000000, 0.000000], +# [1.000000, 1.000000, 1.000000], +# ]) + + +# def numpy_output(*args, **kwargs): +# return np.array([ +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], + +# ]) + + +# def pd_input(*args, **kwargs): +# return pd.DataFrame([ +# [0.77395605, 0.43887844, 0.85859792], +# [0.69736803, 0.09417735, 0.97562235], +# [0.7611397, 0.78606431, 0.12811363], +# [0.45038594, 0.37079802, 0.92676499], +# [0.64386512, 0.82276161, 0.4434142], +# [0.22723872, 0.55458479, 0.06381726], +# [0.82763117, 0.6316644, 0.75808774], +# [0.35452597, 0.97069802, 0.89312112], +# [0.7783835, 0.19463871, 0.466721], +# [0.04380377, 0.15428949, 0.68304895], +# [0.000000, 0.000000, 0.000000], +# [1.000000, 1.000000, 1.000000], +# ], columns=["x0", "x1", "x2"]) + + +# def pd_output(*args, **kwargs): +# return pd.DataFrame([ +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], +# [0.0], + +# ], columns=["y"]) + + +# # def data_input(): +# # return _Data.from_dataframe(pd_input()) + + +# # def data_output(): +# # return _Data.from_dataframe(pd_output()) + + +# @pytest.mark.parametrize("input_data", [path_input, str_input, pd_input, numpy_input]) +# @pytest.mark.parametrize("output_data", [path_output, str_output, pd_output]) +# @pytest.mark.parametrize("domain", [ +# make_nd_continuous_domain(bounds=np.array( +# [[0., 1.], [0., 1.], [0., 1.]]), dimensionality=3), +# None, +# path_domain, +# str_domain +# ]) +# @pytest.mark.parametrize("jobs", [None, path_jobs_finished, str_jobs_finished]) +# def test_init_with_output( +# input_data: Union[Callable[[Path], Union[str, Path]], DataTypes], +# output_data: Union[Callable[[Path], Union[str, Path]], DataTypes], +# domain: Union[Domain, str, Path, None], +# jobs: Union[str, Path, None], +# experimentdata_expected: ExperimentData, +# monkeypatch, +# tmp_path: Path, +# ): +# # Handle callable parameters +# def resolve_param(param, tmp_path): +# if callable(param): +# return param(tmp_path) +# return param + +# input_data = resolve_param(input_data, tmp_path) +# output_data = resolve_param(output_data, tmp_path) +# domain = resolve_param(domain, tmp_path) +# jobs = resolve_param(jobs, tmp_path) + +# # Mock `pd.read_csv` +# def mock_read_csv(file_path, *args, **kwargs): +# path = Path(file_path) +# if path == tmp_path / "test_input.csv": +# return experimentdata_expected.to_pandas()[0] +# elif path == tmp_path / "test_output.csv": +# return experimentdata_expected.to_pandas()[1] +# elif path == tmp_path / "test_jobs.csv": +# return experimentdata_expected.jobs +# raise ValueError(f"Unexpected file path: {file_path}") + +# # Mock `pickle.load` +# def mock_load_pickle(file, *args, **kwargs): +# if Path(file) == tmp_path / "test_domain.pkl": +# return experimentdata_expected.domain +# raise ValueError(f"Unexpected pickle file path: {file}") + +# monkeypatch.setattr(pd, "read_csv", mock_read_csv) +# monkeypatch.setattr(pickle, "load", mock_load_pickle) + +# # # Validation logic for specific inputs +# # if isinstance(input_data, np.ndarray) and domain is None: +# # with pytest.raises(ValueError): +# # ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data, jobs=jobs) +# # return + +# # Initialize ExperimentData and validate + +# experiment_data = ExperimentData( +# domain=domain, input_data=input_data, output_data=output_data, +# jobs=jobs) +# experiment_data.domain.add_output('y', exist_ok=True) +# experiment_data.round(3) + +# print(experiment_data) + +# print(experiment_data.domain) +# print(experimentdata_expected) + +# print(experimentdata_expected.domain) + +# # Assertions +# assert experiment_data == experimentdata_expected + + +# # @pytest.mark.parametrize("input_data", [path_input, str_input, pd_input, numpy_input]) +# # @pytest.mark.parametrize("output_data", [path_output, str_output, pd_output]) +# # @pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# # dimensionality=3), None, path_domain, str_domain]) +# # @pytest.mark.parametrize("jobs", [None, path_jobs_finished, str_jobs_finished]) +# # def test_init_with_output( +# # input_data: DataTypes, output_data: DataTypes, +# # domain: Domain | str | Path | None, +# # jobs: str | Path | None, +# # experimentdata_expected: ExperimentData, +# # monkeypatch, tmp_path: Path): + +# # # if input_data is Callable +# # if callable(input_data): +# # input_data = input_data(tmp_path) +# # expected_data_input = pd.read_csv(input_data) + +# # # if output_data is Callable +# # if callable(output_data): +# # output_data = output_data(tmp_path) +# # expected_data_output = pd.read_csv(output_data) + +# # if callable(domain): +# # domain = domain(tmp_path) +# # expected_domain = Domain.from_file(domain) + +# # if callable(jobs): +# # jobs = jobs(tmp_path) +# # expected_jobs = pd.read_csv(jobs) + +# # # monkeypatch pd.read_csv to return the expected_data DataFrame +# # def mock_read_csv(*args, **kwargs): + +# # path = args[0] +# # if isinstance(args[0], str): +# # path = Path(path) + +# # if path == tmp_path / "test_input.csv": +# # return expected_data_input + +# # elif path == tmp_path / "test_output.csv": +# # return expected_data_output + +# # elif path == tmp_path / "test_jobs.csv": +# # return expected_jobs - # if input_data is Callable - if callable(input_data): - input_data = input_data(tmp_path) - expected_data_input = pd.read_csv(input_data) +# # else: +# # raise ValueError("Unexpected file path") - # if output_data is Callable - if callable(output_data): - output_data = output_data(tmp_path) - expected_data_output = pd.read_csv(output_data) +# # def mock_load_pickle(*args, **kwargs): +# # return expected_domain - if callable(domain): - domain = domain(tmp_path) - expected_domain = Domain.from_file(domain) - - if callable(jobs): - jobs = jobs(tmp_path) - expected_jobs = _JobQueue.from_file(jobs).jobs - - # monkeypatch pd.read_csv to return the expected_data DataFrame - def mock_read_csv(*args, **kwargs): +# # def mock_pd_read_pickle(*args, **kwargs): +# # path = args[0] + +# # if isinstance(path, str): +# # path = Path(path) - path = args[0] - if isinstance(args[0], str): - path = Path(path) - - if path == tmp_path / "test_input.csv": - return expected_data_input - - elif path == tmp_path / "test_output.csv": - return expected_data_output +# # if path == tmp_path / "test_jobs.pkl": +# # return expected_jobs - else: - raise ValueError("Unexpected file path") +# # else: +# # raise ValueError("Unexpected jobs file path") - def mock_load_pickle(*args, **kwargs): - return expected_domain +# # monkeypatch.setattr(pd, "read_csv", mock_read_csv) +# # monkeypatch.setattr(pickle, "load", mock_load_pickle) +# # monkeypatch.setattr(pd, "read_pickle", mock_pd_read_pickle) - def mock_pd_read_pickle(*args, **kwargs): - path = args[0] +# # if isinstance(input_data, np.ndarray) and domain is None: +# # with pytest.raises(ValueError): +# # ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data, jobs=jobs) +# # return +# # # Initialize ExperimentData with the CSV file +# # experiment_data = ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data, jobs=jobs) - if isinstance(path, str): - path = Path(path) +# # experiment_data.round(3) - if path == tmp_path / "test_jobs.pkl": - return expected_jobs +# # # Check if the input_data attribute of ExperimentData matches the expected_data +# # # pd.testing.assert_frame_equal( +# # # experiment_data._input_data.to_dataframe(), experimentdata_expected._input_data.to_dataframe(), check_dtype=False, atol=1e-6) +# # # pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), +# # # experimentdata_expected._output_data.to_dataframe(), check_dtype=False) - monkeypatch.setattr(pd, "read_csv", mock_read_csv) - monkeypatch.setattr(pickle, "load", mock_load_pickle) - monkeypatch.setattr(pd, "read_pickle", mock_pd_read_pickle) +# # assert experiment_data == experimentdata_expected - if isinstance(input_data, np.ndarray) and domain is None: - with pytest.raises(ValueError): - ExperimentData(domain=domain, input_data=input_data, - output_data=output_data, jobs=jobs) - return - # Initialize ExperimentData with the CSV file - experiment_data = ExperimentData(domain=domain, input_data=input_data, - output_data=output_data, jobs=jobs) +# # @pytest.mark.parametrize("input_data", [pd_input(), path_input, str_input, numpy_input()]) +# # @pytest.mark.parametrize("output_data", [None]) +# # @pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# # dimensionality=3), None, path_domain, str_domain]) +# # @pytest.mark.parametrize("jobs", [None, path_jobs_open, str_jobs_open]) +# # def test_init_without_output(input_data: DataTypes, output_data: DataTypes, domain: Domain, jobs: _JobQueue, +# # experimentdata_expected_no_output: ExperimentData, monkeypatch, tmp_path): - # Check if the input_data attribute of ExperimentData matches the expected_data - pd.testing.assert_frame_equal( - experiment_data._input_data.to_dataframe(), experimentdata_expected_no_output._input_data.to_dataframe(), atol=1e-6, check_dtype=False) - pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), - experimentdata_expected_no_output._output_data.to_dataframe()) - pd.testing.assert_series_equal( - experiment_data._jobs.jobs, experimentdata_expected_no_output._jobs.jobs) - # assert experiment_data.domain == experimentdata_expected_no_output.domain - assert experiment_data._jobs == experimentdata_expected_no_output._jobs +# # # if input_data is Callable +# # if callable(input_data): +# # input_data = input_data(tmp_path) +# # expected_data_input = pd.read_csv(input_data) + +# # # if output_data is Callable +# # if callable(output_data): +# # output_data = output_data(tmp_path) +# # expected_data_output = pd.read_csv(output_data) +# # if callable(domain): +# # domain = domain(tmp_path) +# # expected_domain = Domain.from_file(domain) -@pytest.mark.parametrize("input_data", [None]) -@pytest.mark.parametrize("output_data", [None]) -@pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), - dimensionality=3), path_domain, str_domain]) -def test_init_only_domain(input_data: DataTypes, output_data: DataTypes, domain: Domain | str | Path, - experimentdata_expected_only_domain: ExperimentData, - monkeypatch, tmp_path): +# # if callable(jobs): +# # jobs = jobs(tmp_path) +# # expected_jobs = _JobQueue.from_file(jobs).jobs - # if input_data is Callable - if callable(input_data): - input_data = input_data(tmp_path) - expected_data_input = pd.read_csv(input_data) +# # # monkeypatch pd.read_csv to return the expected_data DataFrame +# # def mock_read_csv(*args, **kwargs): - # if output_data is Callable - if callable(output_data): - output_data = output_data(tmp_path) - expected_data_output = pd.read_csv(output_data) +# # path = args[0] +# # if isinstance(args[0], str): +# # path = Path(path) - if callable(domain): - domain = domain(tmp_path) - expected_domain = Domain.from_file(domain) +# # if path == tmp_path / "test_input.csv": +# # return expected_data_input - # monkeypatch pd.read_csv to return the expected_data DataFrame - def mock_read_csv(*args, **kwargs): +# # elif path == tmp_path / "test_output.csv": +# # return expected_data_output - path = args[0] - if isinstance(args[0], str): - path = Path(path) +# # else: +# # raise ValueError("Unexpected file path") - if path == tmp_path / "test_input.csv": - return expected_data_input +# # def mock_load_pickle(*args, **kwargs): +# # return expected_domain - elif path == tmp_path / "test_output.csv": - return expected_data_output +# # def mock_pd_read_pickle(*args, **kwargs): +# # path = args[0] - else: - raise ValueError("Unexpected file path") +# # if isinstance(path, str): +# # path = Path(path) - def mock_load_pickle(*args, **kwargs): - return expected_domain +# # if path == tmp_path / "test_jobs.pkl": +# # return expected_jobs - monkeypatch.setattr(pd, "read_csv", mock_read_csv) - monkeypatch.setattr(pickle, "load", mock_load_pickle) +# # monkeypatch.setattr(pd, "read_csv", mock_read_csv) +# # monkeypatch.setattr(pickle, "load", mock_load_pickle) +# # monkeypatch.setattr(pd, "read_pickle", mock_pd_read_pickle) - # Initialize ExperimentData with the CSV file - experiment_data = ExperimentData(domain=domain, input_data=input_data, - output_data=output_data) +# # if isinstance(input_data, np.ndarray) and domain is None: +# # with pytest.raises(ValueError): +# # ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data, jobs=jobs) +# # return - # Check if the input_data attribute of ExperimentData matches the expected_data - pd.testing.assert_frame_equal( - experiment_data._input_data.to_dataframe(), experimentdata_expected_only_domain._input_data.to_dataframe(), check_dtype=False) - pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), - experimentdata_expected_only_domain._output_data.to_dataframe(), check_dtype=False) - assert experiment_data._input_data == experimentdata_expected_only_domain._input_data - assert experiment_data._output_data == experimentdata_expected_only_domain._output_data - assert experiment_data.domain == experimentdata_expected_only_domain.domain - assert experiment_data._jobs == experimentdata_expected_only_domain._jobs +# # # Initialize ExperimentData with the CSV file +# # experiment_data = ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data, jobs=jobs) - assert experiment_data == experimentdata_expected_only_domain +# # # Check if the input_data attribute of ExperimentData matches the expected_data +# # pd.testing.assert_frame_equal( +# # experiment_data._input_data.to_dataframe(), experimentdata_expected_no_output._input_data.to_dataframe(), atol=1e-6, check_dtype=False) +# # pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), +# # experimentdata_expected_no_output._output_data.to_dataframe()) +# # pd.testing.assert_series_equal( +# # experiment_data._jobs.jobs, experimentdata_expected_no_output._jobs.jobs) +# # # assert experiment_data.domain == experimentdata_expected_no_output.domain +# # assert experiment_data._jobs == experimentdata_expected_no_output._jobs -@pytest.mark.parametrize("input_data", [[0.1, 0.2], {"a": 0.1, "b": 0.2}, 0.2, 2]) -def test_invalid_type(input_data): - with pytest.raises(TypeError): - ExperimentData(input_data=input_data) +# # @pytest.mark.parametrize("input_data", [None]) +# # @pytest.mark.parametrize("output_data", [None]) +# # @pytest.mark.parametrize("domain", [make_nd_continuous_domain(bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# # dimensionality=3), path_domain, str_domain]) +# # def test_init_only_domain(input_data: DataTypes, output_data: DataTypes, domain: Domain | str | Path, +# # experimentdata_expected_only_domain: ExperimentData, +# # monkeypatch, tmp_path): +# # # if input_data is Callable +# # if callable(input_data): +# # input_data = input_data(tmp_path) +# # expected_data_input = pd.read_csv(input_data) -def test_add_invalid_type(experimentdata: ExperimentData): - with pytest.raises(TypeError): - experimentdata + 1 +# # # if output_data is Callable +# # if callable(output_data): +# # output_data = output_data(tmp_path) +# # expected_data_output = pd.read_csv(output_data) +# # if callable(domain): +# # domain = domain(tmp_path) +# # expected_domain = Domain.from_file(domain) + +# # # monkeypatch pd.read_csv to return the expected_data DataFrame +# # def mock_read_csv(*args, **kwargs): -def test_add_two_different_domains(experimentdata: ExperimentData, experimentdata_continuous: ExperimentData): - with pytest.raises(ValueError): - experimentdata + experimentdata_continuous +# # path = args[0] +# # if isinstance(args[0], str): +# # path = Path(path) +# # if path == tmp_path / "test_input.csv": +# # return expected_data_input -def test_repr_html(experimentdata: ExperimentData, monkeypatch): - assert isinstance(experimentdata._repr_html_(), str) +# # elif path == tmp_path / "test_output.csv": +# # return expected_data_output +# # else: +# # raise ValueError("Unexpected file path") -def test_store(experimentdata: ExperimentData, tmp_path: Path): - experimentdata.store(tmp_path / "test") - assert (tmp_path / "test" / "experiment_data" / "input.csv").exists() - assert (tmp_path / "test" / "experiment_data" / "output.csv").exists() - assert (tmp_path / "test" / "experiment_data" / "domain.pkl").exists() - assert (tmp_path / "test" / "experiment_data" / "jobs.pkl").exists() +# # def mock_load_pickle(*args, **kwargs): +# # return expected_domain +# # monkeypatch.setattr(pd, "read_csv", mock_read_csv) +# # monkeypatch.setattr(pickle, "load", mock_load_pickle) -def test_store_give_no_filename(experimentdata: ExperimentData, tmp_path: Path): - experimentdata.set_project_dir(tmp_path / 'test2') - experimentdata.store() - assert (tmp_path / "test2" / "experiment_data" / "input.csv").exists() - assert (tmp_path / "test2" / "experiment_data" / "output.csv").exists() - assert (tmp_path / "test2" / "experiment_data" / "domain.pkl").exists() - assert (tmp_path / "test2" / "experiment_data" / "jobs.pkl").exists() +# # # Initialize ExperimentData with the CSV file +# # experiment_data = ExperimentData(domain=domain, input_data=input_data, +# # output_data=output_data) +# # # Check if the input_data attribute of ExperimentData matches the expected_data +# # pd.testing.assert_frame_equal( +# # experiment_data._input_data.to_dataframe(), experimentdata_expected_only_domain._input_data.to_dataframe(), check_dtype=False) +# # pd.testing.assert_frame_equal(experiment_data._output_data.to_dataframe(), +# # experimentdata_expected_only_domain._output_data.to_dataframe(), check_dtype=False) +# # assert experiment_data._input_data == experimentdata_expected_only_domain._input_data +# # assert experiment_data._output_data == experimentdata_expected_only_domain._output_data +# # assert experiment_data.domain == experimentdata_expected_only_domain.domain +# # assert experiment_data._jobs == experimentdata_expected_only_domain._jobs -@pytest.mark.parametrize("mode", ["sequential", "parallel", "typo"]) -def test_evaluate_mode(mode: str, experimentdata_continuous: ExperimentData, tmp_path: Path): - experimentdata_continuous.filename = tmp_path / 'test009' +# # assert experiment_data == experimentdata_expected_only_domain - if mode == "typo": - with pytest.raises(ValueError): - experimentdata_continuous.evaluate("ackley", mode=mode, kwargs={ - "scale_bounds": np.array([[0., 1.], [0., 1.], [0., 1.]]), 'seed': SEED}) - else: - experimentdata_continuous.evaluate("ackley", mode=mode, kwargs={ - "scale_bounds": np.array([[0., 1.], [0., 1.], [0., 1.]]), 'seed': SEED}) +# @pytest.mark.parametrize("input_data", [[0.1, 0.2], {"a": 0.1, "b": 0.2}, 0.2, 2]) +# def test_invalid_type(input_data): +# with pytest.raises(TypeError): +# ExperimentData(input_data=input_data) -def test_get_input_data(experimentdata_expected_no_output: ExperimentData): - input_data = experimentdata_expected_no_output.get_input_data() - df, _ = input_data.to_pandas() - pd.testing.assert_frame_equal(df, pd_input(), check_dtype=False, atol=1e-6) - assert experimentdata_expected_no_output._input_data == input_data._input_data +# def test_add_invalid_type(experimentdata: ExperimentData): +# with pytest.raises(TypeError): +# experimentdata + 1 -@pytest.mark.parametrize("selection", ["x0", ["x0"], ["x0", "x2"]]) -def test_get_input_data_selection(experimentdata_expected_no_output: ExperimentData, selection: Iterable[str] | str): - input_data = experimentdata_expected_no_output.get_input_data(selection) - df, _ = input_data.to_pandas() - if isinstance(selection, str): - selection = [selection] - selected_pd = pd_input()[selection] - pd.testing.assert_frame_equal( - df, selected_pd, check_dtype=False, atol=1e-6) +# def test_add_two_different_domains(experimentdata: ExperimentData, experimentdata_continuous: ExperimentData): +# with pytest.raises(ValueError): +# experimentdata + experimentdata_continuous -def test_get_output_data(experimentdata_expected: ExperimentData): - output_data = experimentdata_expected.get_output_data() - _, df = output_data.to_pandas() - pd.testing.assert_frame_equal(df, pd_output(), check_dtype=False) - assert experimentdata_expected._output_data == output_data._output_data +# def test_repr_html(experimentdata: ExperimentData, monkeypatch): +# assert isinstance(experimentdata._repr_html_(), str) -@pytest.mark.parametrize("selection", ["y", ["y"]]) -def test_get_output_data_selection(experimentdata_expected: ExperimentData, selection: Iterable[str] | str): - output_data = experimentdata_expected.get_output_data(selection) - _, df = output_data.to_pandas() - if isinstance(selection, str): - selection = [selection] - selected_pd = pd_output()[selection] - pd.testing.assert_frame_equal(df, selected_pd, check_dtype=False) +# def test_store(experimentdata: ExperimentData, tmp_path: Path): +# experimentdata.store(tmp_path / "test") +# assert (tmp_path / "test" / "experiment_data" / "input.csv").exists() +# assert (tmp_path / "test" / "experiment_data" / "output.csv").exists() +# assert (tmp_path / "test" / "experiment_data" / "domain.pkl").exists() +# assert (tmp_path / "test" / "experiment_data" / "jobs.pkl").exists() -def test_iter_behaviour(experimentdata_continuous: ExperimentData): - for i in experimentdata_continuous: - assert isinstance(i, ExperimentSample) - selected_experimentdata = experimentdata_continuous.select([0, 2, 4]) - for i in selected_experimentdata: - assert isinstance(i, ExperimentSample) +# def test_store_give_no_filename(experimentdata: ExperimentData, tmp_path: Path): +# experimentdata.set_project_dir(tmp_path / 'test2') +# experimentdata.store() +# assert (tmp_path / "test2" / "experiment_data" / "input.csv").exists() +# assert (tmp_path / "test2" / "experiment_data" / "output.csv").exists() +# assert (tmp_path / "test2" / "experiment_data" / "domain.pkl").exists() +# assert (tmp_path / "test2" / "experiment_data" / "jobs.pkl").exists() -def test_select_with_status_open(experimentdata: ExperimentData): - selected_data = experimentdata.select_with_status('open') - assert all(job == Status.OPEN for job in selected_data._jobs.jobs) +# @pytest.mark.parametrize("mode", ["sequential", "parallel", "typo"]) +# def test_evaluate_mode(mode: str, experimentdata_continuous: ExperimentData, tmp_path: Path): +# experimentdata_continuous.filename = tmp_path / 'test009' +# if mode == "typo": +# with pytest.raises(ValueError): +# experimentdata_continuous.evaluate( +# data_generator="ackley", mode=mode, +# scale_bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# seed=SEED) +# else: +# experimentdata_continuous.evaluate( +# data_generator="ackley", mode=mode, +# scale_bounds=np.array([[0., 1.], [0., 1.], [0., 1.]]), +# seed=SEED) -def test_select_with_status_in_progress(experimentdata: ExperimentData): - selected_data = experimentdata.select_with_status('in progress') - assert all(job == Status.IN_PROGRESS for job in selected_data._jobs.jobs) +# @pytest.mark.parametrize("selection", ["x0", ["x0"], ["x0", "x2"]]) +# def test_get_input_data_selection(experimentdata_expected_no_output: ExperimentData, selection: Iterable[str] | str): +# input_data = experimentdata_expected_no_output.get_input_data(selection) +# df, _ = input_data.to_pandas() +# if isinstance(selection, str): +# selection = [selection] +# selected_pd = pd_input()[selection] +# pd.testing.assert_frame_equal( +# df, selected_pd, check_dtype=False, atol=1e-6) -def test_select_with_status_finished(experimentdata: ExperimentData): - selected_data = experimentdata.select_with_status('finished') - assert all(job == Status.FINISHED for job in selected_data._jobs.jobs) +# def test_iter_behaviour(experimentdata_continuous: ExperimentData): +# for i in experimentdata_continuous: +# assert isinstance(i, ExperimentSample) -def test_select_with_status_error(experimentdata: ExperimentData): - selected_data = experimentdata.select_with_status('error') - assert all(job == Status.ERROR for job in selected_data._jobs.jobs) +# selected_experimentdata = experimentdata_continuous.select([0, 2, 4]) +# for i in selected_experimentdata: +# assert isinstance(i, ExperimentSample) -def test_select_with_status_invalid_status(experimentdata: ExperimentData): - with pytest.raises(ValueError): - _ = experimentdata.select_with_status('invalid_status') +# def test_select_with_status_open(experimentdata: ExperimentData): +# selected_data = experimentdata.select_with_status('open') +# assert all(es.is_status('open') for _, es in selected_data) -if __name__ == "__main__": # pragma: no cover - pytest.main() +# def test_select_with_status_in_progress(experimentdata: ExperimentData): +# selected_data = experimentdata.select_with_status('in progress') +# assert all(es.is_status('in progress') for _, es in selected_data) + + +# def test_select_with_status_finished(experimentdata: ExperimentData): +# selected_data = experimentdata.select_with_status('finished') +# assert all(es.is_status('finished') for _, es in selected_data) + + +# def test_select_with_status_error(experimentdata: ExperimentData): +# selected_data = experimentdata.select_with_status('error') +# assert all(es.is_status('error') for _, es in selected_data) + + +# def test_select_with_status_invalid_status(experimentdata: ExperimentData): +# with pytest.raises(ValueError): +# _ = experimentdata.select_with_status('invalid_status') + + +# if __name__ == "__main__": # pragma: no cover +# pytest.main() diff --git a/tests/experimentdata/test_experimentdata2.py b/tests/experimentdata/test_experimentdata2.py new file mode 100644 index 00000000..4fecc56b --- /dev/null +++ b/tests/experimentdata/test_experimentdata2.py @@ -0,0 +1,315 @@ +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from omegaconf import DictConfig + +from f3dasm import ExperimentData +from f3dasm.design import Domain, make_nd_continuous_domain + +SEED = 42 + +pytestmark = pytest.mark.smoke + + +def domain_dictconfig_with_output(): + config_dict = {"input": {"x0": {"type": "float", "low": 0.0, "high": 1.0}, + "x1": {"type": "float", "low": 0.0, "high": 1.0}, + "x2": {"type": "float", "low": 0.0, "high": 1.0}}, + + "output": {'y': {}} + } + + return DictConfig(config_dict) + + +def domain_dictconfig_without_output(): + config_dict = {"input": {"x0": {"type": "float", "low": 0.0, "high": 1.0}, + "x1": {"type": "float", "low": 0.0, "high": 1.0}, + "x2": {"type": "float", "low": 0.0, "high": 1.0}}, + } + + return DictConfig(config_dict) + + +def edata_domain_with_output() -> Domain: + return experiment_data_with_output().domain + + +def edata_domain_without_output() -> Domain: + return experiment_data_without_output().domain + +# ============================================================================= + + +def test_project_dir_false_data(): + with pytest.raises(TypeError): + ExperimentData(project_dir=0) + +# ============================================================================= + + +def experiment_data_with_output() -> ExperimentData: + domain = make_nd_continuous_domain( + bounds=[[0., 1.], [0., 1.], [0., 1.]]) + + data = ExperimentData.from_sampling( + sampler='random', domain=domain, n_samples=10, seed=SEED + ) + + data += ExperimentData( + input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + domain=data.domain) + + def f(*args, **kwargs): + return 0.0 + + data.evaluate(data_generator=f, output_names=['y']) + data.round(3) + + data.set_project_dir('./test_project') + return data + + +def experiment_data_without_output() -> ExperimentData: + domain = make_nd_continuous_domain( + bounds=[[0., 1.], [0., 1.], [0., 1.]]) + + data = ExperimentData.from_sampling( + sampler='random', domain=domain, n_samples=10, seed=SEED + ) + + data += ExperimentData( + input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + domain=data.domain) + + data.round(3) + + data.set_project_dir('./test_project') + return data + + +# ============================================================================= + +@ pytest.fixture(scope="package") +def edata_expected_with_output() -> ExperimentData: + domain = make_nd_continuous_domain( + bounds=[[0., 1.], [0., 1.], [0., 1.]]) + + data = ExperimentData.from_sampling( + sampler='random', domain=domain, n_samples=10, seed=SEED + ) + + data += ExperimentData( + input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + domain=data.domain) + + def f(*args, **kwargs): + return 0.0 + + data.evaluate(data_generator=f, output_names=['y']) + data.round(3) + + data.set_project_dir('./test_project') + + return data + + +@ pytest.fixture(scope="package") +def edata_expected_without_output() -> ExperimentData: + domain = make_nd_continuous_domain( + bounds=[[0., 1.], [0., 1.], [0., 1.]]) + + data = ExperimentData.from_sampling( + sampler='random', domain=domain, n_samples=10, seed=SEED + ) + + data += ExperimentData( + input_data=np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]), + domain=data.domain) + + data.round(3) + + data.set_project_dir('./test_project') + + return data + + +# ============================================================================= + + +def edata_jobs_with_output() -> pd.Series: + return experiment_data_with_output().jobs + + +def edata_jobs_without_output() -> pd.Series: + return experiment_data_without_output().jobs + +# ============================================================================= + + +def arr_input(): + return np.array([ + [0.77395605, 0.43887844, 0.85859792], + [0.69736803, 0.09417735, 0.97562235], + [0.7611397, 0.78606431, 0.12811363], + [0.45038594, 0.37079802, 0.92676499], + [0.64386512, 0.82276161, 0.4434142], + [0.22723872, 0.55458479, 0.06381726], + [0.82763117, 0.6316644, 0.75808774], + [0.35452597, 0.97069802, 0.89312112], + [0.7783835, 0.19463871, 0.466721], + [0.04380377, 0.15428949, 0.68304895], + [0.000000, 0.000000, 0.000000], + [1.000000, 1.000000, 1.000000], + ]).round(3) + + +def list_of_dicts_input(): + return [ + {'x0': 0.77395605, 'x1': 0.43887844, 'x2': 0.85859792}, + {'x0': 0.69736803, 'x1': 0.09417735, 'x2': 0.97562235}, + {'x0': 0.7611397, 'x1': 0.78606431, 'x2': 0.12811363}, + {'x0': 0.45038594, 'x1': 0.37079802, 'x2': 0.92676499}, + {'x0': 0.64386512, 'x1': 0.82276161, 'x2': 0.4434142}, + {'x0': 0.22723872, 'x1': 0.55458479, 'x2': 0.06381726}, + {'x0': 0.82763117, 'x1': 0.6316644, 'x2': 0.75808774}, + {'x0': 0.35452597, 'x1': 0.97069802, 'x2': 0.89312112}, + {'x0': 0.7783835, 'x1': 0.19463871, 'x2': 0.466721}, + {'x0': 0.04380377, 'x1': 0.15428949, 'x2': 0.68304895}, + {'x0': 0.000000, 'x1': 0.000000, 'x2': 0.000000}, + {'x0': 1.000000, 'x1': 1.000000, 'x2': 1.000000}, + ] + + +def list_of_dicts_output(): + return [ + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + {'y': 0.0}, + ] + + +def pd_input(): + return pd.DataFrame(arr_input(), columns=['x0', 'x1', 'x2']) + + +def arr_output(): + return np.array([[0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.], + [0.]]) + + +def pd_output(): + return pd.DataFrame(arr_output(), columns=['y']) + + +@ pytest.mark.parametrize("input_data", ["test_input.csv", pd_input(), arr_input(), list_of_dicts_input()]) +@ pytest.mark.parametrize("output_data", ["test_output.csv", pd_output(), arr_output(), list_of_dicts_output()]) +@ pytest.mark.parametrize("domain", ["test_domain.json", edata_domain_with_output(), None, domain_dictconfig_with_output()]) +@ pytest.mark.parametrize("jobs", [edata_jobs_with_output(), "test_jobs.csv", None]) +@ pytest.mark.parametrize("project_dir", ["./test_project", Path("./test_project")]) +def test_experimentdata_creation_with_output( + input_data, output_data, domain, jobs, project_dir, edata_expected_with_output, monkeypatch): + + def mock_read_csv(path, *args, **kwargs): + if str(path) == "test_input.csv": + return pd_input() + elif str(path) == "test_output.csv": + return pd_output() + elif str(path) == "test_jobs.csv": + return edata_jobs_with_output() + raise ValueError(f"Unexpected file path: {path}") + + def mock_domain_from_file(path, *args, **kwargs): + return edata_domain_with_output() + + monkeypatch.setattr(pd, 'read_csv', mock_read_csv) + monkeypatch.setattr(Domain, 'from_file', mock_domain_from_file) + + experiment_data = ExperimentData(domain=domain, input_data=input_data, + output_data=output_data, jobs=jobs, + project_dir=project_dir) + + if domain is None: + experiment_data.domain = edata_domain_with_output() + + experiment_data.round(3) + assert experiment_data == edata_expected_with_output + + +# ============================================================================= + +@ pytest.mark.parametrize("input_data", ["test_input.csv", pd_input(), arr_input(), list_of_dicts_input()]) +@ pytest.mark.parametrize("domain", ["test_domain.json", edata_domain_without_output(), domain_dictconfig_without_output()]) +@ pytest.mark.parametrize("jobs", [edata_jobs_without_output(), "test_jobs.csv", None]) +@ pytest.mark.parametrize("project_dir", ["./test_project", Path("./test_project")]) +def test_experimentdata_creation_without_output( + input_data, domain, jobs, project_dir, edata_expected_without_output, monkeypatch): + + def mock_read_csv(path, *args, **kwargs): + if str(path) == "test_input.csv": + return pd_input() + elif str(path) == "test_output.csv": + return pd_output() + elif str(path) == "test_jobs.csv": + return edata_jobs_without_output() + raise ValueError(f"Unexpected file path: {path}") + + def mock_domain_from_file(path, *args, **kwargs): + return edata_domain_without_output() + + monkeypatch.setattr(pd, 'read_csv', mock_read_csv) + monkeypatch.setattr(Domain, 'from_file', mock_domain_from_file) + + experiment_data = ExperimentData(domain=domain, input_data=input_data, + jobs=jobs, + project_dir=project_dir) + + if domain is None: + experiment_data.domain = edata_domain_without_output() + + experiment_data.round(3) + assert experiment_data == edata_expected_without_output + + +def test_experiment_data_from_yaml_sampling(): + domain = make_nd_continuous_domain( + bounds=[[0., 1.], [0., 1.], [0., 1.]]) + + data_expected = ExperimentData.from_sampling( + sampler='random', domain=domain, n_samples=10, seed=SEED + ) + + dict_config = DictConfig({'from_sampling': { + 'sampler': 'random', + 'domain': {"x0": {"type": "float", "low": 0.0, "high": 1.0}, + "x1": {"type": "float", "low": 0.0, "high": 1.0}, + "x2": {"type": "float", "low": 0.0, "high": 1.0}, + }, + 'n_samples': 10, + 'seed': SEED + }}) + + data = ExperimentData.from_yaml(dict_config) + + assert data.domain == data_expected.domain diff --git a/tests/experimentdata/test_experimentdata_to_disk.py b/tests/experimentdata/test_experimentdata_to_disk.py new file mode 100644 index 00000000..bb145b6c --- /dev/null +++ b/tests/experimentdata/test_experimentdata_to_disk.py @@ -0,0 +1,150 @@ +from f3dasm.design import Domain +from f3dasm import ExperimentData +import xarray as xr +import pytest +import pandas as pd +import numpy as np +from pathlib import Path + +pytestmark = pytest.mark.smoke + + +class CustomObject: + @classmethod + def load(cls, _path): + return CustomObject() + + def save(self, _path): + ... + + +def custom_object_store(object: CustomObject, path: str) -> str: + _path = Path(path).with_suffix(".xxx") + object.save(_path) + return str(_path) + + +def custom_object_load(path: str) -> CustomObject: + _path = Path(path).with_suffix(".xxx") + return CustomObject.load(_path) + + +@pytest.fixture +def domain_with_custom_object() -> Domain: + domain = Domain() + domain.add_float(name="x1", low=0, high=1) + domain.add_parameter(name="custom_in", store_function=custom_object_store, + load_function=custom_object_load, to_disk=True) + domain.add_output(name="y") + domain.add_output(name="custom_out", store_function=custom_object_store, + load_function=custom_object_load, to_disk=True) + return domain + + +def test_custom_object(domain_with_custom_object, tmp_path): + input_data = [{'custom_in': CustomObject(), 'x1': 0.5}] + output_data = [{'y': 1, 'custom_out': CustomObject()}] + data = ExperimentData(domain=domain_with_custom_object, input_data=input_data, + output_data=output_data, project_dir=tmp_path) + + assert isinstance(data.data[0].input_data['custom_in'], CustomObject) + assert isinstance(data.data[0].output_data['custom_out'], CustomObject) + assert isinstance(data.data[0]._input_data['custom_in'], Path) + + +def test_numpy_array_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + x = np.array([1, 2, 3]) + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + assert isinstance(data.data[0].input_data['x'], np.ndarray) + assert isinstance(data.data[0]._input_data['x'], Path) + assert np.allclose(data.data[0].input_data['x'], x) + + +def test_pandas_dataframe_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + x = pd.DataFrame([{f'u{i}': 3.2 for i in range(10)} for _ in range(10)]) + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + assert isinstance(data.data[0].input_data['x'], pd.DataFrame) + assert isinstance(data.data[0]._input_data['x'], Path) + pd.testing.assert_frame_equal(data.data[0].input_data['x'], x) + + +def test_xarray_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + x = pd.DataFrame([{f'u{i}': 3.2 for i in range(10)} for _ in range(10)]) + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + assert isinstance(data.data[0].input_data['x'], pd.DataFrame) + assert isinstance(data.data[0]._input_data['x'], Path) + pd.testing.assert_frame_equal(data.data[0].input_data['x'], x) + + +def test_xarray_dataarray_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + + # Create an xarray.DataArray + x = xr.DataArray( + data=[[3.2 for i in range(10)] for _ in range(10)], + dims=["row", "col"], + coords={"row": range(10), "col": [f'u{i}' for i in range(10)]}, + name="x_data" + ) + + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + # Assertions + assert isinstance(data.data[0].input_data['x'], xr.DataArray) + assert isinstance(data.data[0]._input_data['x'], Path) + xr.testing.assert_equal(data.data[0].input_data['x'], x) + + +def test_xarray_dataset_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + + # Create an xarray.DataArray + _x = xr.DataArray( + data=[[3.2 for i in range(10)] for _ in range(10)], + dims=["row", "col"], + coords={"row": range(10), "col": [f'u{i}' for i in range(10)]}, + ) + + x = xr.Dataset({'x': _x}) + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + # Assertions + assert isinstance(data.data[0].input_data['x'], xr.Dataset) + assert isinstance(data.data[0]._input_data['x'], Path) + xr.testing.assert_equal(data.data[0].input_data['x'], x) + + +def test_xarray_pickle_object(tmp_path): + domain = Domain() + domain.add_parameter('x', to_disk=True) + + # Create an xarray.DataArray + x = CustomObject() + input_data = [{'x': x}] + data = ExperimentData( + domain=domain, input_data=input_data, project_dir=tmp_path) + + # Assertions + assert isinstance(data.data[0].input_data['x'], CustomObject) + assert isinstance(data.data[0]._input_data['x'], Path) diff --git a/tests/experimentdata/test_experimentsample.py b/tests/experimentdata/test_experimentsample.py new file mode 100644 index 00000000..23f599b5 --- /dev/null +++ b/tests/experimentdata/test_experimentsample.py @@ -0,0 +1,206 @@ +from pathlib import Path + +import numpy as np +import pytest + +from f3dasm import ExperimentSample +from f3dasm._src.experimentsample import JobStatus +from f3dasm.design import Domain + +pytestmark = pytest.mark.smoke + + +@pytest.fixture +def sample_domain() -> Domain: + domain = Domain() + domain.add_float(name='x1') + domain.add_float(name='x2') + domain.add_output(name='y1') + + return domain + + +def test_initialization(sample_domain): + sample = ExperimentSample( + input_data={'x1': 1, 'x2': 2}, + output_data={'y1': 3}, + domain=sample_domain, + job_status='FINISHED', + project_dir=Path("/tmp") + ) + + assert sample.input_data == {'x1': 1, 'x2': 2} + assert sample.output_data == {'y1': 3} + assert sample.job_status == JobStatus.FINISHED + assert sample.project_dir == Path("/tmp") + + +def test_default_initialization(sample_domain): + sample = ExperimentSample(domain=sample_domain) + assert sample.input_data == {} + assert sample.output_data == {} + assert sample.job_status == JobStatus.OPEN + assert sample.project_dir == Path.cwd() + + +def test_from_numpy(sample_domain): + array = np.array([1.0, 2.0]) + sample = ExperimentSample.from_numpy(array, domain=sample_domain) + assert sample.input_data == {'x1': 1.0, 'x2': 2.0} + assert sample.output_data == {} + + +def test_from_numpy_no_domain(): + array = np.array([1.0, 2.0]) + sample = ExperimentSample.from_numpy(array) + assert sample.input_data == {'x0': 1.0, 'x1': 2.0} + assert sample.output_data == {} + expected_domain = Domain() + expected_domain.add_float(name='x0') + expected_domain.add_float(name='x1') + assert sample.domain == expected_domain + + +def test_addition(sample_domain): + sample1 = ExperimentSample(input_data={'x1': 1}, output_data={ + 'y1': 3}, domain=sample_domain) + sample2 = ExperimentSample( + input_data={'x2': 2}, output_data={}, domain=sample_domain) + combined = sample1 + sample2 + assert combined.input_data == {'x1': 1, 'x2': 2} + assert combined.output_data == {'y1': 3} + + +def test_equality(sample_domain): + sample1 = ExperimentSample(input_data={'x1': 1}, output_data={ + 'y1': 3}, domain=sample_domain) + sample2 = ExperimentSample(input_data={'x1': 1}, output_data={ + 'y1': 3}, domain=sample_domain) + assert sample1 == sample2 + + +def test_replace_nan(sample_domain): + sample = ExperimentSample(input_data={'x1': float('nan')}, output_data={ + 'y1': 3}, domain=sample_domain) + sample.replace_nan(0) + assert sample.input_data == {'x1': 0} + + +def test_round(sample_domain): + sample = ExperimentSample(input_data={'x1': 1.2345}, output_data={ + 'y1': 3.6789}, domain=sample_domain) + sample.round(2) + assert sample.input_data == {'x1': 1.23} + assert sample.output_data == {'y1': 3.68} + + +def test_store(sample_domain): + sample = ExperimentSample(domain=sample_domain) + sample.store('y2', 42, to_disk=False) + assert sample.output_data['y2'] == 42 + + +def test_mark(sample_domain): + sample = ExperimentSample(domain=sample_domain) + sample.mark('in_progress') + assert sample.job_status == JobStatus.IN_PROGRESS + + +def test_to_multiindex(sample_domain): + sample = ExperimentSample(input_data={'x1': 1}, output_data={ + 'y1': 3}, domain=sample_domain) + multiindex = sample.to_multiindex() + assert multiindex == { + ('jobs', ''): 'finished', + ('input', 'x1'): 1, + ('output', 'y1'): 3 + } + + +def test_to_numpy(sample_domain): + sample = ExperimentSample(input_data={'x1': 1, 'x2': 2}, output_data={ + 'y1': 3}, domain=sample_domain) + input_array, output_array = sample.to_numpy() + np.testing.assert_array_equal(input_array, [1, 2]) + np.testing.assert_array_equal(output_array, [3]) + + +def test_to_dict(sample_domain): + sample = ExperimentSample(input_data={'x1': 1, 'x2': 2}, output_data={ + 'y1': 3}, domain=sample_domain) + dictionary = sample.to_dict() + assert dictionary == {'x1': 1, 'x2': 2, 'y1': 3} + + +def test_invalid_status(sample_domain): + sample = ExperimentSample(domain=sample_domain) + with pytest.raises(ValueError): + sample.mark('invalid_status') + + +def test_get(sample_domain): + sample = ExperimentSample(input_data={'x1': 1}, output_data={ + 'y1': 3}, domain=sample_domain) + assert sample.get('x1') == 1 + assert sample.get('y1') == 3 + with pytest.raises(KeyError): + sample.get('nonexistent') + + +def test_is_status(sample_domain): + """ + Test the is_status method of the ExperimentSample class. + + Parameters + ---------- + sample_domain : Domain + The domain fixture for the experiment sample. + """ + sample = ExperimentSample(domain=sample_domain, job_status='OPEN') + assert sample.is_status('open') + assert not sample.is_status('finished') + + +def test_store_to_disk(sample_domain, tmp_path): + """ + Test the store method of the ExperimentSample class with to_disk=True. + + Parameters + ---------- + sample_domain : Domain + The domain fixture for the experiment sample. + tmp_path : Path + Temporary directory for storing the object. + """ + def dummy_store_function(obj, path): + with open(path, 'w') as f: + f.write(str(obj)) + return path + + def dummy_load_function(path): + with open(path, 'r') as f: + return f.read() + + sample = ExperimentSample(domain=sample_domain, project_dir=tmp_path) + sample.store('y2', 42, to_disk=True, store_function=dummy_store_function, + load_function=dummy_load_function) + assert sample._output_data['y2'] == 42 + assert sample.domain.output_space['y2'].to_disk + assert sample.domain.output_space['y2'].store_function == dummy_store_function + assert sample.domain.output_space['y2'].load_function == dummy_load_function + + +def test_to_numpy_with_nan(sample_domain): + """ + Test the to_numpy method of the ExperimentSample class with NaN values. + + Parameters + ---------- + sample_domain : Domain + The domain fixture for the experiment sample. + """ + sample = ExperimentSample(input_data={'x1': float('nan'), 'x2': 2}, output_data={ + 'y1': 3}, domain=sample_domain) + input_array, output_array = sample.to_numpy() + np.testing.assert_array_equal(input_array, [np.nan, 2]) + np.testing.assert_array_equal(output_array, [3]) diff --git a/tests/functions/test_function.py b/tests/functions/test_function.py index 108647d7..53d88f6c 100644 --- a/tests/functions/test_function.py +++ b/tests/functions/test_function.py @@ -1,18 +1,22 @@ import numpy as np import pytest +from f3dasm import ExperimentData from f3dasm._src.datageneration.functions.pybenchfunction import Ackley +from f3dasm.design import make_nd_continuous_domain pytestmark = pytest.mark.smoke + def test_create_mesh_returns_correct_mesh_shape(): # Arrange px = 10 - domain = np.array([[-5.0, 5.0], [-5.0, 5.0]]) - instance = Ackley(dimensionality=2) - + arr = np.array([[-5.0, 5.0], [-5.0, 5.0]]) + domain = make_nd_continuous_domain(bounds=arr) + instance = Ackley() + instance.arm(data=ExperimentData(domain=domain)) # Act - xv, yv, Y = instance._create_mesh(px, domain) + xv, yv, Y = instance._create_mesh(px, arr) # Assert assert xv.shape == (px, px) @@ -23,23 +27,15 @@ def test_create_mesh_returns_correct_mesh_shape(): def test_create_mesh_raises_value_error_with_invalid_px(): # Arrange px = -10 - domain = np.array([[-5.0, 5.0], [-5.0, 5.0]]) - instance = Ackley(dimensionality=2) + arr = np.array([[-5.0, 5.0], [-5.0, 5.0]]) + domain = make_nd_continuous_domain(bounds=arr) + instance = Ackley() + + instance.arm(data=ExperimentData(domain=domain)) # Act / Assert with pytest.raises(ValueError): - instance._create_mesh(px, domain) - - -# def test_create_mesh_raises_value_error_with_invalid_domain_shape(): -# # Arrange -# px = 10 -# domain = np.array([[-5.0, 5.0], [-5.0, 5.0], [-5.0, 5.0]]) -# instance = Ackley(dimensionality=2) - -# # Act / Assert -# with pytest.raises(ValueError): -# instance._create_mesh(px, domain) + instance._create_mesh(px, arr) if __name__ == "__main__": # pragma: no cover diff --git a/tests/functions/test_scaling_and_offset.py b/tests/functions/test_scaling_and_offset.py index 15011e50..b9968f82 100644 --- a/tests/functions/test_scaling_and_offset.py +++ b/tests/functions/test_scaling_and_offset.py @@ -3,10 +3,11 @@ import numpy as np import pytest +from f3dasm import ExperimentData +from f3dasm._src.datageneration.datagenerator_factory import \ + _datagenerator_factory from f3dasm._src.datageneration.functions import (FUNCTIONS, Function, get_function_classes) -from f3dasm._src.datageneration.functions.function_factory import ( - _datagenerator_factory, is_dim_compatible) from f3dasm.design import make_nd_continuous_domain pytestmark = pytest.mark.smoke @@ -17,72 +18,82 @@ def test_offset(function: Function, seed: int): dim = 2 - bounds = np.tile([0.0, 1.0], (dim, 1)) + domain = make_nd_continuous_domain(bounds=np.tile([0.0, 1.0], (dim, 1))) func: Function = function( seed=seed, - dimensionality=dim, - scale_bounds=bounds, + scale_bounds=domain.get_bounds(), ) + func.arm(data=ExperimentData(domain=domain)) + xmin = func._get_global_minimum_for_offset_calculation() - assert func.check_if_within_bounds(xmin, bounds=bounds) + assert func.check_if_within_bounds(xmin, bounds=domain.get_bounds()) @pytest.mark.parametrize("function", FUNCTIONS) def test_check_global_minimum(function: str): + _func = _datagenerator_factory( + data_generator=function) + dim = 6 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 4 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 3 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 2 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) seed = 42 func = _datagenerator_factory( - function, domain=domain, kwargs={'seed': seed}) + data_generator=function, seed=seed) + func.arm(data=ExperimentData(domain=domain)) _ = func.get_global_minimum(dim) @pytest.mark.parametrize("function", FUNCTIONS) -@pytest.mark.parametrize("scale_bounds_list", ([-1.0, 0.0], [-1.0, 1.0], [0.0, 1.0], [-3.0, 1.0])) +@pytest.mark.parametrize("scale_bounds_list", + ([-1.0, 0.0], [-1.0, 1.0], [0.0, 1.0], [-3.0, 1.0])) def test_scaling_1(function: str, scale_bounds_list: List[float]): + _func = _datagenerator_factory( + data_generator=function) + dim = 6 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 4 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 3 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(function, domain): + if not _func.is_dim_compatible(d=dim): dim = 2 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) seed = np.random.randint(low=0, high=1e5) scale_bounds = np.tile(scale_bounds_list, (dim, 1)) - # func: Function = function(seed=seed, scale_bounds=scale_bounds, dimensionality=dim) domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - func = _datagenerator_factory(function, domain=domain, kwargs={ - 'seed': seed, 'scale_bounds': scale_bounds}) + func = _datagenerator_factory( + data_generator=function, seed=seed, scale_bounds=scale_bounds) + func.arm(data=ExperimentData(domain=domain)) x = np.random.uniform( - low=scale_bounds[0, 0], high=scale_bounds[0, 1], size=(1, func.dimensionality)) + low=scale_bounds[0, 0], high=scale_bounds[0, 1], + size=(1, func.dimensionality)) assert func._retrieve_original_input( func.augmentor.augment_input(x)) == pytest.approx(x) diff --git a/tests/newdata/conftest.py b/tests/newdata/conftest.py deleted file mode 100644 index be072701..00000000 --- a/tests/newdata/conftest.py +++ /dev/null @@ -1,48 +0,0 @@ -import numpy as np -import pytest - -from f3dasm._src.experimentdata._columns import _Columns -from f3dasm._src.experimentdata._newdata import _Index -from f3dasm.design import Domain - - -@pytest.fixture(scope="package") -def list_1(): - return [[np.array([0.3, 5.0, 0.34]), 'd', 3], [np.array( - [0.23, 5.0, 0.0]), 'f', 4], [np.array([0.3, 5.0, 0.2]), 'c', 0]] - - -@pytest.fixture(scope="package") -def columns_1(): - return _Columns({'a': None, 'b': None, 'c': None}) - - -@pytest.fixture(scope="package") -def indices_1(): - return _Index([3, 5, 6]) - - -@pytest.fixture(scope="package") -def list_2(): - return [[np.array([0.3, 0.2])], [np.array([0.4, 0.3])], [np.array([0.0, 1.0])]] - - -@pytest.fixture(scope="package") -def columns_2(): - return _Columns({'a': None}) - - -@pytest.fixture(scope="package") -def list_3(): - return [[np.array([1.1, 0.2])], [np.array([8.9, 0.3])], [np.array([0.0, 0.87])]] - - -@pytest.fixture(scope="package") -def domain(): - domain = Domain() - domain.add_float('a', 0.0, 1.0) - domain.add_float('b', 0.0, 1.0) - domain.add_float('c', 0.0, 1.0) - domain.add_category('d', ['a', 'b', 'c']) - domain.add_int('e', 0, 10) - return domain diff --git a/tests/newdata/test_data.py b/tests/newdata/test_data.py deleted file mode 100644 index 38b1b0ce..00000000 --- a/tests/newdata/test_data.py +++ /dev/null @@ -1,292 +0,0 @@ -from copy import deepcopy -from typing import Any, List - -import numpy as np -import pandas as pd -import pytest - -from f3dasm._src.experimentdata._columns import _Columns -from f3dasm._src.experimentdata._newdata import _Data, _Index -from f3dasm.design import Domain - -pytestmark = pytest.mark.smoke - -DataType = List[List[Any]] - - -def test_init(list_1: DataType): - data = _Data(list_1) - assert data.data == list_1 - assert data.columns.names == [0, 1, 2] - assert data.indices.equals(pd.Index([0, 1, 2])) - - -def test_init_with_columns(list_1: DataType, columns_1: _Columns): - data = _Data(list_1, columns_1) - assert data.data == list_1 - assert data.names == ['a', 'b', 'c'] - - -def test_init_with_columns_and_indices( - list_1: DataType, columns_1: _Columns, indices_1: _Index): - data = _Data(list_1, columns_1, indices_1) - assert data.data == list_1 - assert data.names == ['a', 'b', 'c'] - assert data.indices.equals(pd.Index([3, 5, 6])) - - -def test__len__(list_1: DataType): - data = _Data(list_1) - assert len(data) == 3 - - -def test__iter__(list_1: DataType): - data = _Data(list_1) - for i, row in enumerate(data): - assert row == list_1[i] - - -def test__getitem__(list_1: DataType): - data = _Data(list_1) - assert data[0].data[0] == list_1[0] - assert data[1].data[0] == list_1[1] - assert data[2].data[0] == list_1[2] - - -def test__getitem__list(list_1: DataType): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None}), index=_Index([3, 45])) - assert data[[3, 45]].data == data.data - - -def test__add__(list_1: DataType, list_3: DataType): - data_1 = _Data(list_1) - data_2 = _Data(list_3) - data_3 = data_1 + data_2 - assert data_3.data == list_1 + list_3 - assert data_3.columns.names == [0, 1, 2] - - -def test__add__empty(list_3: DataType): - data_1 = _Data(columns=_Columns({0: None, 1: None, 2: None})) - data_2 = _Data(list_3) - data_3 = data_1 + data_2 - assert data_3.data == list_3 - assert data_3.columns.names == [0, 1, 2] - - -def test__eq__(list_1: DataType): - data_1 = _Data(list_1) - data_2 = _Data(list_1) - assert data_1 == data_2 - - -def test_repr_html(list_1: DataType): - data = _Data(list_1) - assert data._repr_html_() == data.to_dataframe()._repr_html_() - -# Properties -# ============================================================================= - - -def test_names(list_1: DataType, columns_1: _Columns): - data = _Data(list_1, columns=columns_1) - assert data.names == ['a', 'b', 'c'] - - -def test_names_default(list_1: DataType): - data = _Data(list_1) - assert data.names == [0, 1, 2] - - -def test_indices(list_1: DataType, indices_1: _Index): - data = _Data(list_1, index=indices_1) - assert data.indices.equals(pd.Index([3, 5, 6])) - - -def test_indices_default(list_1: DataType): - data = _Data(list_1) - assert data.indices.equals(pd.Index([0, 1, 2])) - -# Alternative constructors -# ============================================================================= - - -def test_from_indices(): - data = _Data.from_indices(pd.Index([0, 1])) - assert data.indices.equals(pd.Index(([0, 1]))) - assert not data.names - assert data.is_empty() - - -def test_from_domain(domain: Domain): - data = _Data.from_domain(domain) - assert data.indices.equals(pd.Index([])) - assert data.names == ['a', 'b', 'c', 'd', 'e'] - assert data.is_empty() - - -def test_from_numpy(): - data = _Data.from_numpy(np.array([[1, 2, 3], [4, 5, 6]])) - assert data.data == [[1, 2, 3], [4, 5, 6]] - assert data.names == [0, 1, 2] - assert data.indices.equals(pd.Index([0, 1])) - - -def test_from_dataframe(): - data = _Data.from_dataframe(pd.DataFrame([[1, 2, 3], [4, 5, 6]])) - assert data.data == [[1, 2, 3], [4, 5, 6]] - assert data.names == [0, 1, 2] - assert data.indices.equals(pd.Index([0, 1])) - - -def test_reset(): - data = _Data.from_numpy(np.array([[1, 2, 3], [4, 5, 6]])) - data.reset() - assert data.data == [] - assert not data.names - assert data.indices.equals(pd.Index([])) - - -def test_reset_with_domain(domain: Domain): - data = _Data.from_numpy(np.array([[1, 2, 3], [4, 5, 6]])) - data.reset(domain) - assert data.data == [] - assert data.names == domain.names - assert data.indices.equals(pd.Index([])) - -# Export -# ============================================================================= - - -def test_to_numpy(list_1: DataType): - data = _Data(list_1) - data.to_numpy() - - -def to_dataframe(list_1: DataType): - data = _Data(list_1) - data.to_dataframe() - assert data.to_dataframe().equals(pd.DataFrame(list_1)) - - -def test_select_columns(list_1: DataType, columns_1: _Columns): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=columns_1) - new_data = data.select_columns(['a', 'c']) - assert new_data.names == ['a', 'c'] - assert new_data.data == [[1, 3], [4, 6]] - - -def test_select_column(list_1: DataType, columns_1: _Columns): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=columns_1) - new_data = data.select_columns('a') - assert new_data.names == ['a'] - assert new_data.data == [[1], [4]] - - -def test_add(list_2: DataType, list_3: DataType): - data_0 = _Data(deepcopy(list_2)) - data_1 = _Data(deepcopy(list_2)) - data_2 = _Data(list_3) - data_1.add(data_2.to_dataframe()) - assert data_1 == (data_0 + data_2) - - -def test_add_empty_rows(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]]) - data.add_empty_rows(2) - assert data.data == [[1, 2, 3], [4, 5, 6], [ - np.nan, np.nan, np.nan], [np.nan, np.nan, np.nan]] - - -def test_add_column(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]]) - data.add_column('a') - assert data.data == [[1, 2, 3, np.nan], [4, 5, 6, np.nan]] - assert data.names == [0, 1, 2, 'a'] - - -def test_remove(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]]) - data.remove(0) - assert data.data == [[4, 5, 6]] - assert data.names == [0, 1, 2] - - -def test_remove_list(): - data = _Data(data=[[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - data.remove([0, 2]) - assert data.data == [[4, 5, 6]] - assert data.names == [0, 1, 2] - - -def test_get_data_dict(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]]) - assert data.get_data_dict(0) == {0: 1, 1: 2, 2: 3} - - -def test_set_data_all_columns(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]]) - data.set_data(index=0, value=[4, 5, 6]) - assert data.data == [[4, 5, 6], [4, 5, 6]] - - -def test_set_data(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None})) - data.set_data(index=0, value=99, column='b') - assert data.data == [[1, 99, 3], [4, 5, 6]] - - -def test_set_data_no_valid_index(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None})) - with pytest.raises(IndexError): - data.set_data(index=2, value=99, column='b') - - -def test_set_data_unknown_column(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None})) - - data.set_data(index=0, value=99, column='d') - assert data.names == ['a', 'b', 'c', 'd'] - assert data.data == [[1, 2, 3, 99], [4, 5, 6, np.nan]] - - -def test_reset_index(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None}), index=_Index([3, 45])) - data.reset_index() - assert data.indices.equals(pd.Index([0, 1])) - - -def test_is_empty(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None}), index=_Index([3, 45])) - assert not data.is_empty() - data.reset() - assert data.is_empty() - - -def test_has_columnnames(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None}), index=_Index([3, 45])) - assert not data.has_columnnames('d') - assert data.has_columnnames('c') - data.add_column('d') - assert data.has_columnnames('d') - - -def test_set_columnnames(): - data = _Data(data=[[1, 2, 3], [4, 5, 6]], columns=_Columns( - {'a': None, 'b': None, 'c': None}), index=_Index([3, 45])) - data.set_columnnames(['d', 'f', 'g']) - assert data.names == ['d', 'f', 'g'] - - -if __name__ == "__main__": # pragma: no cover - pytest.main() - - # return [[np.array([0.3, 5.0, 0.34]), 'd', 3], [np.array( - # [0.23, 5.0, 0.0]), 'f', 4], [np.array([0.3, 5.0, 0.2]), 'c', 0]] diff --git a/tests/optimization/conftest.py b/tests/optimization/conftest.py index 17884367..c955e570 100644 --- a/tests/optimization/conftest.py +++ b/tests/optimization/conftest.py @@ -1,7 +1,7 @@ import pytest from f3dasm import ExperimentData -from f3dasm._src.design.parameter import _ContinuousParameter +from f3dasm._src.design.parameter import ContinuousParameter from f3dasm.design import Domain @@ -12,13 +12,14 @@ def data(): # Define the parameters input_parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x3": _ContinuousParameter(lower_bound=0.6, upper_bound=7.3), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x3": ContinuousParameter(lower_bound=0.6, upper_bound=7.3), } # Create the design space - design = Domain(space=input_parameters) + design = Domain(input_space=input_parameters) # Set the lower_bound and upper_bound of 'y' to None, indicating it has no bounds - return ExperimentData.from_sampling(sampler='random', domain=design, n_samples=N, seed=seed) + return ExperimentData.from_sampling(sampler='random', + domain=design, n_samples=N, seed=seed) diff --git a/tests/optimization/test_all_optimizers.py b/tests/optimization/test_all_optimizers.py index b8005aa3..cb1d89e1 100644 --- a/tests/optimization/test_all_optimizers.py +++ b/tests/optimization/test_all_optimizers.py @@ -1,46 +1,39 @@ from __future__ import annotations -from typing import List - import numpy as np import pytest from f3dasm import ExperimentData -from f3dasm._src.datageneration.functions.function_factory import \ - is_dim_compatible +from f3dasm._src.datageneration.datagenerator_factory import \ + _datagenerator_factory from f3dasm._src.optimization.optimizer_factory import _optimizer_factory from f3dasm.datageneration import DataGenerator from f3dasm.datageneration.functions import FUNCTIONS from f3dasm.design import make_nd_continuous_domain -from f3dasm.optimization import OPTIMIZERS, Optimizer - - -@pytest.mark.smoke -@pytest.mark.parametrize("optimizer", OPTIMIZERS) -def test_get_info(data: ExperimentData, optimizer: str): - opt: Optimizer = _optimizer_factory(optimizer, data.domain) - characteristics = opt._get_info() - assert isinstance(characteristics, List) +from f3dasm.optimization import available_optimizers @pytest.mark.parametrize("seed", [42]) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) +@pytest.mark.parametrize("optimizer", available_optimizers()) @pytest.mark.parametrize("data_generator", FUNCTIONS) def test_all_optimizers_and_functions(seed: int, data_generator: str, optimizer: str): i = 10 # iterations + _func = _datagenerator_factory( + data_generator=data_generator, seed=seed) + dim = 6 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 4 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 3 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 2 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) @@ -52,10 +45,10 @@ def test_all_optimizers_and_functions(seed: int, data_generator: str, optimizer: data2 = ExperimentData.from_sampling( sampler='random', domain=domain, n_samples=30, seed=seed) - data1.evaluate(data_generator, kwargs={'noise': None, 'seed': seed, - 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1))}) - data2.evaluate(data_generator, kwargs={'noise': None, 'seed': seed, - 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1))}) + data1.evaluate(data_generator=data_generator, noise=None, seed=seed, + scale_bounds=np.tile([-1.0, 1.0], (dim, 1))) + data2.evaluate(data_generator=data_generator, noise=None, seed=seed, + scale_bounds=np.tile([-1.0, 1.0], (dim, 1))) data1.optimize(optimizer=optimizer, data_generator=data_generator, iterations=i, kwargs={'noise': None, 'seed': seed, @@ -66,62 +59,74 @@ def test_all_optimizers_and_functions(seed: int, data_generator: str, optimizer: 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1))}, hyperparameters={'seed': seed}) + data1.replace_nan(None) + data2.replace_nan(None) + assert (data1 == data2) -@pytest.mark.smoke -@pytest.mark.parametrize("seed", [42]) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) -@pytest.mark.parametrize("data_generator", ['levy', 'ackley', 'sphere']) +@ pytest.mark.smoke +@ pytest.mark.parametrize("seed", [42]) +@ pytest.mark.parametrize("optimizer", available_optimizers()) +@ pytest.mark.parametrize("data_generator", ['levy', 'ackley', 'sphere']) def test_all_optimizers_3_functions(seed: int, data_generator: DataGenerator, optimizer: str): test_all_optimizers_and_functions(seed, data_generator, optimizer) # TODO: Use stored data to assess this property (maybe hypothesis ?) -@pytest.mark.smoke -@pytest.mark.parametrize("iterations", [10, 23, 66, 86]) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) -@pytest.mark.parametrize("data_generator", ["sphere"]) -@pytest.mark.parametrize("x0_selection", ["best", "new"]) +@ pytest.mark.smoke +@ pytest.mark.parametrize("iterations", [10, 23, 66, 86]) +@ pytest.mark.parametrize("optimizer", available_optimizers()) +@ pytest.mark.parametrize("data_generator", ["sphere"]) +@ pytest.mark.parametrize("x0_selection", ["best", "new"]) def test_optimizer_iterations(iterations: int, data_generator: str, optimizer: str, x0_selection: str): numsamples = 40 # initial samples seed = 42 + _func = _datagenerator_factory( + data_generator=data_generator) + dim = 6 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 4 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 3 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - if not is_dim_compatible(data_generator, domain): + if not _func.is_dim_compatible(d=dim): dim = 2 domain = make_nd_continuous_domain(bounds=np.tile( [-1.0, 1.0], (dim, 1)), dimensionality=dim) - data = ExperimentData.from_sampling( sampler='random', domain=domain, n_samples=numsamples, seed=seed) # func = data_generator(noise=None, seed=seed, scale_bounds=np.tile([-1.0, 1.0], (dim, 1)), dimensionality=dim) # Evaluate the initial samples - data.evaluate(data_generator, mode='sequential', kwargs={'seed': seed, 'noise': None, - 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1)), }) + data.evaluate(data_generator, mode='sequential', seed=seed, noise=None, + scale_bounds=np.tile([-1.0, 1.0], (dim, 1))) - _optimizer = _optimizer_factory(optimizer, domain=domain) + _data_generator = _datagenerator_factory( + data_generator=data_generator, + scale_bounds=np.tile([-1.0, 1.0], (dim, 1)), seed=seed) - if x0_selection == "new" and iterations < _optimizer._population: + _optimizer = _optimizer_factory(optimizer) + population = _optimizer._population if hasattr( + _optimizer, '_population') else 1 + if x0_selection == "new" and iterations < population: with pytest.raises(ValueError): - data.optimize(optimizer=optimizer, data_generator=data_generator, - iterations=iterations, kwargs={'seed': seed, 'noise': None, - 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1)), }, - hyperparameters={'seed': seed}, - x0_selection=x0_selection) + data.optimize( + optimizer=optimizer, data_generator=data_generator, + iterations=iterations, + kwargs={'seed': seed, 'noise': None, + 'scale_bounds': np.tile([-1.0, 1.0], (dim, 1)), }, + hyperparameters={'seed': seed}, + x0_selection=x0_selection) else: data.optimize(optimizer=optimizer, data_generator=data_generator, diff --git a/tests/optimization/test_run_optimization.py b/tests/optimization/test_run_optimization.py index 3aa3639e..e047a3ff 100644 --- a/tests/optimization/test_run_optimization.py +++ b/tests/optimization/test_run_optimization.py @@ -12,18 +12,18 @@ from pathos.helpers import mp # Locals -from f3dasm import ExperimentData, logger -from f3dasm._src.datageneration.functions.function_factory import \ +from f3dasm import Block, ExperimentData, logger +from f3dasm._src.datageneration.datagenerator_factory import \ _datagenerator_factory from f3dasm._src.optimization.optimizer_factory import _optimizer_factory from f3dasm.datageneration import DataGenerator from f3dasm.datageneration.functions import FUNCTIONS_2D, FUNCTIONS_7D from f3dasm.design import Domain, make_nd_continuous_domain -from f3dasm.optimization import OPTIMIZERS, Optimizer +from f3dasm.optimization import available_optimizers class OptimizationResult: - def __init__(self, data: List[ExperimentData], optimizer: Optimizer, + def __init__(self, data: List[ExperimentData], optimizer: Block, kwargs: Optional[Dict[str, Any]], data_generator: DataGenerator, number_of_samples: int, seeds: List[int], @@ -48,8 +48,6 @@ def __init__(self, data: List[ExperimentData], optimizer: Optimizer, total optimization time """ self.data = data - self.optimizer = _optimizer_factory( - optimizer=optimizer, domain=self.data[0].domain) self.data_generator = data_generator self.kwargs = kwargs, self.number_of_samples = number_of_samples @@ -57,8 +55,8 @@ def __init__(self, data: List[ExperimentData], optimizer: Optimizer, self.opt_time = opt_time self.func = _datagenerator_factory( - data_generator=self.data_generator, - domain=self.data[0].domain, kwargs=kwargs) + data_generator=self.data_generator, **kwargs) + self.optimizer = _optimizer_factory(optimizer=optimizer) self._log() def _log(self): @@ -96,7 +94,7 @@ def to_xarray(self) -> xr.Dataset: def run_optimization( - optimizer: Optimizer | str, + optimizer: Block | str, data_generator: DataGenerator | str, sampler: Callable | str, domain: Domain, @@ -140,14 +138,16 @@ def run_optimization( hyperparameters = {} # Set function seed - optimizer = _optimizer_factory( - optimizer=optimizer, domain=domain, hyperparameters=hyperparameters) + data_generator = _datagenerator_factory( + data_generator=data_generator, **kwargs) + + optimizer = _optimizer_factory(optimizer=optimizer, **hyperparameters) # Sample data = ExperimentData.from_sampling( sampler=sampler, domain=domain, n_samples=number_of_samples, seed=seed) - data.evaluate(data_generator, mode='sequential', kwargs=kwargs) + data.evaluate(data_generator, mode='sequential', **kwargs) data.optimize(optimizer=optimizer, data_generator=data_generator, iterations=iterations, kwargs=kwargs, hyperparameters=hyperparameters) @@ -156,7 +156,7 @@ def run_optimization( def run_multiple_realizations( - optimizer: Optimizer, + optimizer: Block, data_generator: DataGenerator | str, sampler: Callable | str, domain: Domain, @@ -245,7 +245,7 @@ def run_multiple_realizations( @pytest.mark.smoke -@pytest.mark.parametrize("optimizer", OPTIMIZERS) +@pytest.mark.parametrize("optimizer", available_optimizers()) @pytest.mark.parametrize("data_generator", ['Levy', 'Ackley', 'Sphere']) @pytest.mark.parametrize("dimensionality", [2]) def test_run_multiple_realizations_3_functions(data_generator: str, @@ -253,7 +253,7 @@ def test_run_multiple_realizations_3_functions(data_generator: str, test_run_multiple_realizations(data_generator, optimizer, dimensionality) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) +@pytest.mark.parametrize("optimizer", available_optimizers()) @pytest.mark.parametrize("data_generator", FUNCTIONS_2D) @pytest.mark.parametrize("dimensionality", [2]) def test_run_multiple_realizations(data_generator: str, optimizer: str, dimensionality: int): @@ -273,7 +273,12 @@ def test_run_multiple_realizations(data_generator: str, optimizer: str, dimensio else: PARALLELIZATION = True - if optimizer in ['EvoSaxCMAES', 'EvoSaxSimAnneal', 'EvoSaxPSO', 'EvoSaxDE']: + data_generator_ = _datagenerator_factory( + data_generator=data_generator, **kwargs) + optimizer_ = _optimizer_factory(optimizer=optimizer) + + opt_type = optimizer_.type if hasattr(optimizer_, 'type') else None + if opt_type == 'evosax': PARALLELIZATION = False _ = run_multiple_realizations( @@ -288,14 +293,14 @@ def test_run_multiple_realizations(data_generator: str, optimizer: str, dimensio ) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) +@pytest.mark.parametrize("optimizer", available_optimizers()) @pytest.mark.parametrize("data_generator", FUNCTIONS_7D) @pytest.mark.parametrize("dimensionality", [7]) def test_run_multiple_realizations_7D(data_generator: str, optimizer: str, dimensionality: int): test_run_multiple_realizations(data_generator, optimizer, dimensionality) -@pytest.mark.parametrize("optimizer", OPTIMIZERS) +@pytest.mark.parametrize("optimizer", available_optimizers()) @pytest.mark.parametrize("data_generator", ['griewank']) @pytest.mark.parametrize("dimensionality", [2]) def test_run_multiple_realizations_fast(data_generator: str, optimizer: str, dimensionality: int): diff --git a/tests/sampling/conftest.py b/tests/sampling/conftest.py index dc14fcd6..214506ac 100644 --- a/tests/sampling/conftest.py +++ b/tests/sampling/conftest.py @@ -1,8 +1,8 @@ import pytest -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ContinuousParameter, - _DiscreteParameter) +from f3dasm._src.design.parameter import (CategoricalParameter, + ContinuousParameter, + DiscreteParameter) from f3dasm.design import Domain @@ -10,16 +10,16 @@ def design(): # Define the parameters parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _DiscreteParameter(lower_bound=5, upper_bound=80), - "x3": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x4": _CategoricalParameter(categories=["test1", "test2", "test3"]), - "x5": _DiscreteParameter(lower_bound=3, upper_bound=6), - "x6": _CategoricalParameter(categories=["material1", "material2", "material3"]), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": DiscreteParameter(lower_bound=5, upper_bound=80), + "x3": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x4": CategoricalParameter(categories=["test1", "test2", "test3"]), + "x5": DiscreteParameter(lower_bound=3, upper_bound=6), + "x6": CategoricalParameter(categories=["material1", "material2", "material3"]), } # Create the design space - design = Domain(parameters) + design = Domain(input_space=parameters) return design @@ -27,16 +27,16 @@ def design(): def design2(): # Define the parameters parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _CategoricalParameter(categories=["main"]), - "x3": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x4": _CategoricalParameter(categories=["test" + str(i) for i in range(80)]), - "x5": _DiscreteParameter(lower_bound=3, upper_bound=6), - "x6": _CategoricalParameter(categories=["material" + str(i) for i in range(20)]), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": CategoricalParameter(categories=["main"]), + "x3": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x4": CategoricalParameter(categories=["test" + str(i) for i in range(80)]), + "x5": DiscreteParameter(lower_bound=3, upper_bound=6), + "x6": CategoricalParameter(categories=["material" + str(i) for i in range(20)]), } # Create the design space - design = Domain(parameters) + design = Domain(input_space=parameters) return design @@ -44,15 +44,15 @@ def design2(): def design3(): # Define the parameters parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _DiscreteParameter(lower_bound=5, upper_bound=80), - "x3": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x4": _CategoricalParameter(categories=["test1", "test2", "test3"]), - "x5": _ContinuousParameter(lower_bound=0.6, upper_bound=7.3), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": DiscreteParameter(lower_bound=5, upper_bound=80), + "x3": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x4": CategoricalParameter(categories=["test1", "test2", "test3"]), + "x5": ContinuousParameter(lower_bound=0.6, upper_bound=7.3), } # Create the design space - design = Domain(parameters) + design = Domain(input_space=parameters) return design @@ -60,15 +60,15 @@ def design3(): def design4(): # Define the parameters parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _DiscreteParameter(lower_bound=5, upper_bound=80), - "x3": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x4": _CategoricalParameter(categories=["test1", "test2", "test3"]), - "x5": _DiscreteParameter(lower_bound=3, upper_bound=6), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": DiscreteParameter(lower_bound=5, upper_bound=80), + "x3": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x4": CategoricalParameter(categories=["test1", "test2", "test3"]), + "x5": DiscreteParameter(lower_bound=3, upper_bound=6), } # Create the design space - design = Domain(parameters) + design = Domain(input_space=parameters) return design @@ -76,14 +76,14 @@ def design4(): def design5(): # Define the parameters parameters = { - "x1": _ContinuousParameter(lower_bound=2.4, upper_bound=10.3), - "x2": _DiscreteParameter(lower_bound=5, upper_bound=80), - "x3": _ContinuousParameter(lower_bound=10.0, upper_bound=380.3), - "x4": _CategoricalParameter(categories=["test1", "test2", "test3"]), - "x5": _DiscreteParameter(lower_bound=3, upper_bound=6), - "x6": _DiscreteParameter(lower_bound=500, upper_bound=532), + "x1": ContinuousParameter(lower_bound=2.4, upper_bound=10.3), + "x2": DiscreteParameter(lower_bound=5, upper_bound=80), + "x3": ContinuousParameter(lower_bound=10.0, upper_bound=380.3), + "x4": CategoricalParameter(categories=["test1", "test2", "test3"]), + "x5": DiscreteParameter(lower_bound=3, upper_bound=6), + "x6": DiscreteParameter(lower_bound=500, upper_bound=532), } # Create the design space - design = Domain(parameters) + design = Domain(input_space=parameters) return design diff --git a/tests/sampling/test_hypothesis.py b/tests/sampling/test_hypothesis.py index 821a1fd9..c9828716 100644 --- a/tests/sampling/test_hypothesis.py +++ b/tests/sampling/test_hypothesis.py @@ -6,9 +6,9 @@ from hypothesis.strategies import (SearchStrategy, composite, floats, integers, text) -from f3dasm._src.design.parameter import (_CategoricalParameter, - _ContinuousParameter, - _DiscreteParameter, _Parameter) +from f3dasm._src.design.parameter import (CategoricalParameter, + ContinuousParameter, + DiscreteParameter, Parameter) from f3dasm.design import Domain pytestmark = pytest.mark.smoke @@ -20,14 +20,14 @@ def design_space(draw: Callable[[SearchStrategy[int]], int], min_value: int = 1, _name = text(alphabet="abcdefghijklmnopqrstuvwxyz", min_size=10, max_size=10) - def get_space(number_of_parameters: int) -> Dict[str, _Parameter]: + def get_space(number_of_parameters: int) -> Dict[str, Parameter]: space = {} names = [] for _ in range(number_of_parameters): names.append(_name.filter(lambda x: x not in names)) for i in range(number_of_parameters): - parameter: _Parameter = np.random.choice( + parameter: Parameter = np.random.choice( a=["ContinuousSpace", "DiscreteSpace", "CategoricalSpace"] ) name = names[i] @@ -38,7 +38,7 @@ def get_space(number_of_parameters: int) -> Dict[str, _Parameter]: draw(floats(min_value=0.1)), ) - space[name] = _ContinuousParameter( + space[name] = ContinuousParameter( lower_bound=lower_bound, upper_bound=upper_bound) elif parameter == "DiscreteSpace": @@ -47,16 +47,16 @@ def get_space(number_of_parameters: int) -> Dict[str, _Parameter]: draw(integers(min_value=1)), ) - space[name] = _DiscreteParameter( + space[name] = DiscreteParameter( lower_bound=lower_bound, upper_bound=upper_bound) elif parameter == "CategoricalSpace": categories = ["test1", "test2"] - space[name] = _CategoricalParameter(categories=categories) + space[name] = CategoricalParameter(categories=categories) return space design_space = Domain( - space=get_space(number_of_input_parameters), + input_space=get_space(number_of_input_parameters), ) return design_space @@ -64,11 +64,11 @@ def get_space(number_of_parameters: int) -> Dict[str, _Parameter]: @given(design_space()) @settings(max_examples=10) def test_check_length_input_when_adding_parameter(design: Domain): - length_input_space = len(design.space) - parameter = _DiscreteParameter() + length_input_space = len(design.input_space) + parameter = DiscreteParameter() kwargs = {'low': parameter.lower_bound, 'high': parameter.upper_bound} design.add(name="test", type='int', **kwargs) - assert length_input_space + 1 == (len(design.space)) + assert length_input_space + 1 == (len(design.input_space)) if __name__ == "__main__": # pragma: no cover diff --git a/tests/sampling/test_sampling.py b/tests/sampling/test_sampling.py index b542840b..c8348254 100644 --- a/tests/sampling/test_sampling.py +++ b/tests/sampling/test_sampling.py @@ -4,7 +4,7 @@ from pandas.testing import assert_frame_equal from f3dasm import ExperimentData -from f3dasm._src.design.parameter import _ContinuousParameter +from f3dasm._src.design.parameter import ContinuousParameter from f3dasm.design import Domain pytestmark = pytest.mark.smoke @@ -14,7 +14,7 @@ def test_sampling_interface_not_implemented_error(): seed = 42 # Define the parameters - x1 = _ContinuousParameter(lower_bound=2.4, upper_bound=10.3) + x1 = ContinuousParameter(lower_bound=2.4, upper_bound=10.3) space = {'x1': x1} design = Domain(space) @@ -49,8 +49,6 @@ def test_correct_sampling_ran(design3: Domain): samples = ExperimentData(domain=design3) samples.sample(sampler='random', n_samples=numsamples, seed=seed) - samples._input_data.round(6) - df_input, _ = samples.to_pandas() df_input.columns = df_ground_truth.columns diff --git a/tests/sampling/test_sampling_grid.py b/tests/sampling/test_sampling_grid.py new file mode 100644 index 00000000..f135ea81 --- /dev/null +++ b/tests/sampling/test_sampling_grid.py @@ -0,0 +1,149 @@ +from itertools import product + +import numpy as np +import pandas as pd +import pytest + +from f3dasm import ExperimentData +from f3dasm._src.design.samplers import Grid +from f3dasm.design import Domain + +pytestmark = pytest.mark.smoke + + +@pytest.fixture +def sample_domain() -> Domain: + """Fixture to provide a sample domain.""" + + domain = Domain() + + domain.add_float(name='x1', low=0, high=1) + domain.add_float(name='x2', low=2, high=4) + domain.add_int(name='d1', low=1, high=3) + domain.add_category(name='cat1', categories=["A", "B", "C"]) + domain.add_constant(name='const1', value=42) + return domain + + +@pytest.fixture +def sample_domain_no_continuous() -> Domain: + """Fixture to provide a sample domain.""" + + domain = Domain() + domain.add_int(name='d1', low=1, high=3) + domain.add_category(name='cat1', categories=["A", "B", "C"]) + domain.add_constant(name='const1', value=42) + return domain + + +def test_grid_sample_with_default_steps(sample_domain): + """Test Grid sampler with default step size.""" + grid_sampler = Grid() + experiment_data = ExperimentData(domain=sample_domain) + grid_sampler.arm(experiment_data) + + stepsize = 0.5 + samples = grid_sampler.call(data=experiment_data, + stepsize_continuous_parameters=stepsize) + df, _ = samples.to_pandas() + # Expected continuous values + x1_values = np.arange(0, 1, stepsize) + x2_values = np.arange(2, 4, stepsize) + + # Expected discrete values + d1_values = [1, 2, 3] + + # Expected categorical values + cat1_values = ["A", "B", "C"] + + # Generate all combinations + expected_combinations = list( + product(x1_values, x2_values, d1_values, cat1_values, [42])) + + # Convert to DataFrame + expected_df = pd.DataFrame( + expected_combinations, + columns=["x1", "x2", "d1", "cat1", "const1"], + ) + + df.sort_values(by=['x1', 'x2', 'd1', 'cat1', 'const1'], + inplace=True, ignore_index=True) + expected_df.sort_values( + ["x1", "x2", "d1", "cat1", "const1"], inplace=True, ignore_index=True) + + # Assert equality + pd.testing.assert_frame_equal(df, expected_df, check_dtype=False) + + +def test_grid_sample_with_custom_steps(sample_domain): + """Test Grid sampler with custom step sizes for continuous parameters.""" + grid_sampler = Grid() + experiment_data = ExperimentData(domain=sample_domain) + grid_sampler.arm(experiment_data) + + custom_steps = {"x1": 0.25, "x2": 0.5} + samples = grid_sampler.call(data=experiment_data, + stepsize_continuous_parameters=custom_steps) + df, _ = samples.to_pandas() + + # Expected continuous values + x1_values = np.arange(0, 1, custom_steps["x1"]) + x2_values = np.arange(2, 4, custom_steps["x2"]) + + # Expected discrete values + d1_values = [1, 2, 3] + + # Expected categorical values + cat1_values = ["A", "B", "C"] + + # Generate all combinations + expected_combinations = list( + product(x1_values, x2_values, d1_values, cat1_values, [42])) + + # Convert to DataFrame + expected_df = pd.DataFrame( + expected_combinations, + columns=["x1", "x2", "d1", "cat1", "const1"], + ) + + df.sort_values(by=['x1', 'x2', 'd1', 'cat1', 'const1'], + inplace=True, ignore_index=True) + expected_df.sort_values( + ["x1", "x2", "d1", "cat1", "const1"], inplace=True, ignore_index=True) + + # Assert equality + pd.testing.assert_frame_equal(df, expected_df, check_dtype=False) + + +def test_grid_sample_with_no_continuous(sample_domain_no_continuous): + """Test Grid sampler with no continuous parameters.""" + grid_sampler = Grid() + experiment_data = ExperimentData(domain=sample_domain_no_continuous) + grid_sampler.arm(experiment_data) + + samples = grid_sampler.call(data=experiment_data, + stepsize_continuous_parameters=None) + df, _ = samples.to_pandas() + + # Expected discrete values + d1_values = [1, 2, 3] + + # Expected categorical values + cat1_values = ["A", "B", "C"] + + # Generate all combinations + expected_combinations = list(product(d1_values, cat1_values, [42])) + + # Convert to DataFrame + expected_df = pd.DataFrame( + expected_combinations, + columns=["d1", "cat1", "const1"], + ) + + df.sort_values(by=['d1', 'cat1', 'const1'], + inplace=True, ignore_index=True) + expected_df.sort_values( + ["d1", "cat1", "const1"], inplace=True, ignore_index=True) + + # Assert equality + pd.testing.assert_frame_equal(df, expected_df, check_dtype=False) diff --git a/tests/test_hydra_utils.py b/tests/test_hydra_utils.py new file mode 100644 index 00000000..88b11baa --- /dev/null +++ b/tests/test_hydra_utils.py @@ -0,0 +1,42 @@ +import pytest +from omegaconf import OmegaConf + +from f3dasm._src.experimentsample import ExperimentSample +from f3dasm._src.hydra_utils import update_config_with_experiment_sample +from f3dasm.design import Domain + +pytestmark = pytest.mark.smoke + + +@pytest.fixture +def sample_config(): + return OmegaConf.create({ + 'parameter1': 1, + 'parameter2': 2, + 'nested': { + 'parameter3': 3 + } + }) + + +@pytest.fixture +def experiment_sample(): + + domain = Domain() + domain.add_parameter('parameter1') + domain.add_parameter('nested.parameter3') + domain.add_output('parameter2') + return ExperimentSample( + input_data={'parameter1': 10, 'nested.parameter3': 30}, + output_data={'parameter2': 20}, + domain=domain + ) + + +def test_update_config_with_experiment_sample(sample_config, experiment_sample): + updated_config = update_config_with_experiment_sample( + sample_config, experiment_sample) + + assert updated_config.parameter1 == 10 + assert updated_config.parameter2 == 20 + assert updated_config.nested.parameter3 == 30