From 4a5639e6cea475433dded9ab4ae216114891bd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20P=2E=20D=C3=BCrholt?= Date: Wed, 6 Nov 2024 16:09:14 +0100 Subject: [PATCH] add working version --- .github/workflows/lint.yaml | 33 ++++++++ .github/workflows/test.yaml | 38 +++++++++ app/app.py | 16 ++++ app/models/__init__.py | 0 app/models/candidates.py | 24 ++++++ app/routers/__init__.py | 0 app/routers/candidates.py | 26 ++++++ app/routers/versions.py | 10 +++ requirements.txt | 5 ++ ruff.toml | 19 +++++ tests/__init__.py | 0 tests/conftest.py | 26 ++++++ tests/test_candidates.py | 115 ++++++++++++++++++++++++++ tests/test_versions.py | 9 ++ webserver.ipynb | 159 ++++++++++++++++++++++++++++++++++++ 15 files changed, 480 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 app/app.py create mode 100644 app/models/__init__.py create mode 100644 app/models/candidates.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/candidates.py create mode 100644 app/routers/versions.py create mode 100644 requirements.txt create mode 100644 ruff.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_candidates.py create mode 100644 tests/test_versions.py create mode 100644 webserver.ipynb diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..00dc098 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,33 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Set up uv + uses: astral-sh/setup-uv@v2 + with: + enable-cache: true + + - name: Install dependencies + run: uv pip install pre-commit --system + + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..280514d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,38 @@ +name: Tests +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + testing: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11' ] + steps: + - name: Check out repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v2 + with: + enable-cache: true + + - name: Install Dependencies + run: uv pip install -r requirements.txt --system + + - name: Run tests + run: | + export ADD_DUMMY_TYPES=True + uvicorn --app-dir=app app:app & sleep 10 + pytest + kill %1 diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..5484038 --- /dev/null +++ b/app/app.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from routers.candidates import router as candidates_router +from routers.versions import router as versions_router +from starlette.responses import RedirectResponse + + +app = FastAPI(title="BoFire Candidates API", version="0.1.0", root_path="/") + + +@app.get("/", include_in_schema=False) +async def redirect(): + return RedirectResponse(url="/docs") + + +app.include_router(candidates_router) +app.include_router(versions_router) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/candidates.py b/app/models/candidates.py new file mode 100644 index 0000000..8191f51 --- /dev/null +++ b/app/models/candidates.py @@ -0,0 +1,24 @@ +from typing import Optional + +from bofire.data_models.dataframes.api import Candidates, Experiments +from bofire.data_models.strategies.api import AnyStrategy +from pydantic import BaseModel, Field, model_validator + + +class CandidateRequest(BaseModel): + strategy_data: AnyStrategy + n_candidates: int = Field( + default=1, gt=0, description="Number of candidates to generate" + ) + experiments: Optional[Experiments] + pendings: Optional[Candidates] + + @model_validator(mode="after") + def validate_experiments(self): + if self.experiments is not None: + self.strategy_data.domain.validate_experiments(self.experiments.to_pandas()) + if self.pendings is not None: + self.strategy_data.domain.validate_candidates( + self.pendings.to_pandas(), only_inputs=True + ) + return self diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/candidates.py b/app/routers/candidates.py new file mode 100644 index 0000000..9a13a60 --- /dev/null +++ b/app/routers/candidates.py @@ -0,0 +1,26 @@ +import bofire.strategies.api as strategies +from bofire.data_models.dataframes.api import Candidates +from fastapi import APIRouter, HTTPException +from models.candidates import CandidateRequest + + +router = APIRouter(prefix="", tags=["candidates"]) + + +@router.post("/candidates/generate", response_model=Candidates) +def generate( + candidate_request: CandidateRequest, +) -> Candidates: + strategy = strategies.map(candidate_request.strategy_data) + if candidate_request.experiments is not None: + strategy.tell(candidate_request.experiments.to_pandas()) + try: + df_candidates = strategy.ask(candidate_request.n_candidates) + except ValueError as e: + if str(e) == "Not enough experiments available to execute the strategy.": + raise HTTPException(status_code=404, detail=str(e)) + else: + raise HTTPException( + status_code=500, detail=f"A server error occurred. Details: {e}" + ) + return Candidates.from_pandas(df_candidates, candidate_request.strategy_data.domain) diff --git a/app/routers/versions.py b/app/routers/versions.py new file mode 100644 index 0000000..9da6139 --- /dev/null +++ b/app/routers/versions.py @@ -0,0 +1,10 @@ +import bofire +from fastapi import APIRouter + + +router = APIRouter(prefix="/versions", tags=["versions"]) + + +@router.get("", response_model=dict[str, str]) +def get_versions() -> dict[str, str]: + return {"bofire": bofire.__version__} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..171b894 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +bofire>=0.0.14 +uvicorn +fastapi +pytest +requests diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a5f623c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,19 @@ +target-version = "py39" +line-length = 88 +output-format = "concise" + +[lint] +select = ["B", "C", "E", "F", "W", "I"] +ignore = [ + "E501", # don't enforce for comments and docstrings + "B017", # required for tests + "B027", # required for optional _tell method + "B028", + "B904", + "B905", +] +isort.split-on-trailing-comma = false +isort.lines-after-imports = 2 + +[lint.mccabe] +max-complexity = 18 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a2227e1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +import os + +import requests +from pytest import fixture + + +HEADERS = {"accept": "application/json", "Content-Type": "application/json"} + + +class Client: + def __init__(self, base_url: str, requests=requests): + self.base_url = base_url + self.requests = requests + + def get(self, path: str) -> requests.Response: + return self.requests.get(f"{self.base_url}{path}", headers=HEADERS) + + def post(self, path: str, request_body: str) -> requests.Response: + return self.requests.post( + f"{self.base_url}{path}", data=request_body, headers=HEADERS + ) + + +@fixture +def client() -> Client: + return Client(base_url=os.getenv("CANDIDATES_URL", "http://localhost:8000")) diff --git a/tests/test_candidates.py b/tests/test_candidates.py new file mode 100644 index 0000000..2a31e0f --- /dev/null +++ b/tests/test_candidates.py @@ -0,0 +1,115 @@ +import json +from typing import Optional + +from bofire.benchmarks.api import DTLZ2, Himmelblau +from bofire.data_models.dataframes.api import Candidates, Experiments +from bofire.data_models.strategies.api import ( + AlwaysTrueCondition, + AnyStrategy, + NumberOfExperimentsCondition, + RandomStrategy, + SoboStrategy, + Step, + StepwiseStrategy, +) +from pydantic import BaseModel, Field + +from tests.conftest import Client + + +class CandidateRequest(BaseModel): + strategy_data: AnyStrategy + n_candidates: int = Field( + default=1, gt=0, description="Number of candidates to generate" + ) + experiments: Optional[Experiments] + pendings: Optional[Candidates] + + +bench = Himmelblau() +bench2 = DTLZ2(dim=6) +experiments = bench.f(bench.domain.inputs.sample(15), return_complete=True) +experiments2 = bench2.f(bench2.domain.inputs.sample(15), return_complete=True) + +strategy_data = StepwiseStrategy( + domain=bench.domain, + steps=[ + Step( + condition=NumberOfExperimentsCondition(n_experiments=10), + strategy_data=RandomStrategy(domain=bench.domain), + ), + Step( + condition=AlwaysTrueCondition(), + strategy_data=SoboStrategy(domain=bench.domain), + ), + ], +) + + +def test_candidates_request_validation(client: Client): + cr = CandidateRequest( + strategy_data=strategy_data, + n_candidates=1, + experiments=Experiments.from_pandas(experiments2, bench2.domain), + pendings=None, + ) + + response = client.post( + path="/candidates/generate", request_body=cr.model_dump_json() + ) + assert response.status_code == 422 + assert ( + json.loads(response.content)["detail"][0]["msg"] + == "Value error, no col for input feature `y`" + ) + + +def test_candidates_missing_experiments(client: Client): + cr = CandidateRequest( + strategy_data=SoboStrategy(domain=bench.domain), + n_candidates=1, + experiments=None, + pendings=None, + ) + response = client.post( + path="/candidates/generate", request_body=cr.model_dump_json() + ) + assert response.status_code == 404 + assert ( + json.loads(response.content)["detail"] + == "Not enough experiments available to execute the strategy." + ) + + +def test_candidates_generate(client: Client): + cr = CandidateRequest( + strategy_data=strategy_data, + n_candidates=2, + experiments=None, + pendings=None, + ) + response = client.post( + path="/candidates/generate", request_body=cr.model_dump_json() + ) + df_candidates = Candidates(**json.loads(response.content)).to_pandas() + assert df_candidates.shape[0] == 2 + assert df_candidates.shape[1] == 2 + assert sorted(df_candidates.columns.tolist()) == sorted( + bench.domain.inputs.get_keys() + ) + + cr = CandidateRequest( + strategy_data=strategy_data, + n_candidates=1, + experiments=Experiments.from_pandas(experiments, bench.domain), + pendings=None, + ) + response = client.post( + path="/candidates/generate", request_body=cr.model_dump_json() + ) + df_candidates = Candidates(**json.loads(response.content)).to_pandas() + assert df_candidates.shape[0] == 1 + assert df_candidates.shape[1] == 5 + assert sorted(df_candidates.columns.tolist()) == sorted( + bench.domain.candidate_column_names + ) diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..67c6bb1 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,9 @@ +import json + +from tests.conftest import Client + + +def test_candidates_request_validation(client: Client): + response = client.get(path="/versions") + assert response.status_code == 200 + assert list(json.loads(response.content).keys()) == ["bofire"] diff --git a/webserver.ipynb b/webserver.ipynb new file mode 100644 index 0000000..7ec9791 --- /dev/null +++ b/webserver.ipynb @@ -0,0 +1,159 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "from bofire.data_models.domain.api import Domain\n", + "\n", + "from pydantic import BaseModel, Field\n", + "from bofire.data_models.strategies.api import AnyStrategy, RandomStrategy, SoboStrategy\n", + "from typing import Optional\n", + "from bofire.data_models.features.api import ContinuousInput\n", + "\n", + "from bofire.data_models.dataframes.api import Experiments, Candidates\n", + "from bofire.benchmarks.api import Himmelblau, DTLZ2\n", + "import json\n", + "\n", + "\n", + "class CandidateRequest(BaseModel):\n", + " strategy_data: AnyStrategy\n", + " n_candidates: int = Field(\n", + " default=1, gt=0, description=\"Number of candidates to generate\"\n", + " )\n", + " experiments: Optional[Experiments]\n", + " pendings: Optional[Candidates]\n", + "\n", + "bench = Himmelblau()\n", + "bench2 = DTLZ2(dim=6)\n", + "experiments = bench.f(bench.domain.inputs.sample(10), return_complete=True)\n", + "experiments2 = bench2.f(bench2.domain.inputs.sample(10), return_complete=True)\n", + "\n", + "\n", + "cr = CandidateRequest(\n", + " strategy_data=SoboStrategy(domain=bench.domain),\n", + " n_candidates=1,\n", + " experiments=Experiments.from_pandas(experiments2, bench2.domain),\n", + " pendings=None\n", + ")\n", + "\n", + "\n", + "URL = \"http://127.0.0.1:8000/candidates\"\n", + "HEADERS = {'accept': 'application/json', 'Content-Type': 'application/json'}\n", + "\n", + "response = requests.post(url=f\"{URL}/generate\", data=cr.model_dump_json(), headers=HEADERS)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "b'{\"detail\":[{\"type\":\"value_error\",\"loc\":[\"body\"],\"msg\":\"Value error, no col for input feature `y`\",\"input\":{\"strategy_data\":{\"type\":\"SoboStrategy\",\"domain\":{\"type\":\"Domain\",\"inputs\":{\"type\":\"Inputs\",\"features\":[{\"type\":\"ContinuousInput\",\"key\":\"x_1\",\"unit\":null,\"bounds\":[-6.0,6.0],\"local_relative_bounds\":null,\"stepsize\":null},{\"type\":\"ContinuousInput\",\"key\":\"x_2\",\"unit\":null,\"bounds\":[-6.0,6.0],\"local_relative_bounds\":null,\"stepsize\":null}]},\"outputs\":{\"type\":\"Outputs\",\"features\":[{\"type\":\"ContinuousOutput\",\"key\":\"y\",\"unit\":null,\"objective\":{\"type\":\"MinimizeObjective\",\"w\":1.0,\"bounds\":[0.0,1.0]}}]},\"constraints\":{\"type\":\"Constraints\",\"constraints\":[]}},\"seed\":null,\"num_restarts\":8,\"num_raw_samples\":1024,\"maxiter\":2000,\"batch_limit\":8,\"descriptor_method\":\"EXHAUSTIVE\",\"categorical_method\":\"EXHAUSTIVE\",\"discrete_method\":\"EXHAUSTIVE\",\"surrogate_specs\":{\"surrogates\":[{\"hyperconfig\":{\"type\":\"SingleTaskGPHyperconfig\",\"hyperstrategy\":\"FactorialStrategy\",\"inputs\":{\"type\":\"Inputs\",\"features\":[{\"type\":\"CategoricalInput\",\"key\":\"kernel\",\"categories\":[\"rbf\",\"matern_1.5\",\"matern_2.5\"],\"allowed\":[true,true,true]},{\"type\":\"CategoricalInput\",\"key\":\"prior\",\"categories\":[\"mbo\",\"botorch\"],\"allowed\":[true,true]},{\"type\":\"CategoricalInput\",\"key\":\"ard\",\"categories\":[\"True\",\"False\"],\"allowed\":[true,true]}]},\"n_iterations\":null,\"target_metric\":\"MAE\"},\"aggregations\":null,\"type\":\"SingleTaskGPSurrogate\",\"inputs\":{\"type\":\"Inputs\",\"features\":[{\"type\":\"ContinuousInput\",\"key\":\"x_1\",\"unit\":null,\"bounds\":[-6.0,6.0],\"local_relative_bounds\":null,\"stepsize\":null},{\"type\":\"ContinuousInput\",\"key\":\"x_2\",\"unit\":null,\"bounds\":[-6.0,6.0],\"local_relative_bounds\":null,\"stepsize\":null}]},\"outputs\":{\"type\":\"Outputs\",\"features\":[{\"type\":\"ContinuousOutput\",\"key\":\"y\",\"unit\":null,\"objective\":{\"type\":\"MinimizeObjective\",\"w\":1.0,\"bounds\":[0.0,1.0]}}]},\"input_preprocessing_specs\":{},\"dump\":null,\"scaler\":\"NORMALIZE\",\"output_scaler\":\"STANDARDIZE\",\"kernel\":{\"type\":\"ScaleKernel\",\"base_kernel\":{\"type\":\"MaternKernel\",\"ard\":true,\"nu\":2.5,\"lengthscale_prior\":{\"type\":\"GammaPrior\",\"concentration\":3.0,\"rate\":6.0}},\"outputscale_prior\":{\"type\":\"GammaPrior\",\"concentration\":2.0,\"rate\":0.15}},\"noise_prior\":{\"type\":\"GammaPrior\",\"concentration\":1.1,\"rate\":0.05}}]},\"outlier_detection_specs\":null,\"min_experiments_before_outlier_check\":1,\"frequency_check\":1,\"frequency_hyperopt\":0,\"folds\":5,\"local_search_config\":null,\"acquisition_function\":{\"type\":\"qLogNEI\",\"prune_baseline\":true,\"n_mc_samples\":512}},\"n_candidates\":1,\"experiments\":{\"type\":\"Experiments\",\"rows\":[{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.3427501192597544},\"x_1\":{\"value\":0.2248806738882979},\"x_2\":{\"value\":0.8003505911844943},\"x_3\":{\"value\":0.5496478127474617},\"x_4\":{\"value\":0.6784806084539219},\"x_5\":{\"value\":0.44598910305378847}},\"outputs\":{\"f_0\":{\"value\":1.0329365461946272,\"valid\":true},\"f_1\":{\"value\":0.6169152426370024,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.7307238068427896},\"x_1\":{\"value\":0.38811093055046497},\"x_2\":{\"value\":0.7491624180475678},\"x_3\":{\"value\":0.025220276398040142},\"x_4\":{\"value\":0.7810733605661323},\"x_5\":{\"value\":0.2635829692280586}},\"outputs\":{\"f_0\":{\"value\":0.5889996653153501,\"valid\":true},\"f_1\":{\"value\":1.3084541064140396,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.23270421549900577},\"x_1\":{\"value\":0.06170310809264712},\"x_2\":{\"value\":0.8643943603828655},\"x_3\":{\"value\":0.348390768255757},\"x_4\":{\"value\":0.5521183287632825},\"x_5\":{\"value\":0.6750931687504705}},\"outputs\":{\"f_0\":{\"value\":1.289993426681666,\"valid\":true},\"f_1\":{\"value\":0.49372000130756777,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.40630255545911453},\"x_1\":{\"value\":0.6268592388753335},\"x_2\":{\"value\":0.4219821142244917},\"x_3\":{\"value\":0.8556460220440621},\"x_4\":{\"value\":0.2890354892457304},\"x_5\":{\"value\":0.37627189687807383}},\"outputs\":{\"f_0\":{\"value\":0.9705998554311998,\"valid\":true},\"f_1\":{\"value\":0.7199701208241278,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.24239145170561927},\"x_1\":{\"value\":0.682231601617558},\"x_2\":{\"value\":0.9612679495346724},\"x_3\":{\"value\":0.8653156008241764},\"x_4\":{\"value\":0.9246465459322838},\"x_5\":{\"value\":0.9487898660512805}},\"outputs\":{\"f_0\":{\"value\":1.6350465358688016,\"valid\":true},\"f_1\":{\"value\":0.6544761786231652,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.14186604785048063},\"x_1\":{\"value\":0.2179031196426503},\"x_2\":{\"value\":0.8937723471178198},\"x_3\":{\"value\":0.8849014989346576},\"x_4\":{\"value\":0.954950376904073},\"x_5\":{\"value\":0.5347004904647011}},\"outputs\":{\"f_0\":{\"value\":1.5516288114067027,\"valid\":true},\"f_1\":{\"value\":0.3516086224967904,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.8748828197055685},\"x_1\":{\"value\":0.8158072755176716},\"x_2\":{\"value\":0.7710387942608429},\"x_3\":{\"value\":0.5982383795561488},\"x_4\":{\"value\":0.7962388882651396},\"x_5\":{\"value\":0.12998475533131415}},\"outputs\":{\"f_0\":{\"value\":0.2748468047904255,\"valid\":true},\"f_1\":{\"value\":1.3804202145427396,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.18827505231464126},\"x_1\":{\"value\":0.08516482602561604},\"x_2\":{\"value\":0.402553106144209},\"x_3\":{\"value\":0.08113633706124646},\"x_4\":{\"value\":0.580836180282068},\"x_5\":{\"value\":0.5779032502138202}},\"outputs\":{\"f_0\":{\"value\":1.3101732891172118,\"valid\":true},\"f_1\":{\"value\":0.399179213219553,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.8042208561351486},\"x_1\":{\"value\":0.3285350488476546},\"x_2\":{\"value\":0.9496838919525497},\"x_3\":{\"value\":0.1650790762586849},\"x_4\":{\"value\":0.6429166455733385},\"x_5\":{\"value\":0.8227018610806988}},\"outputs\":{\"f_0\":{\"value\":0.4444762172347086,\"valid\":true},\"f_1\":{\"value\":1.3994610368185196,\"valid\":true}}},{\"type\":\"ExperimentRow\",\"inputs\":{\"x_0\":{\"value\":0.5971468698779449},\"x_1\":{\"value\":0.9339177625674423},\"x_2\":{\"value\":0.8841040139375298},\"x_3\":{\"value\":0.6848538733478423},\"x_4\":{\"value\":0.7396016091770633},\"x_5\":{\"value\":0.014247780078584071}},\"outputs\":{\"f_0\":{\"value\":0.983716994499296,\"valid\":true},\"f_1\":{\"value\":1.3412877406367683,\"valid\":true}}}]},\"pendings\":null},\"ctx\":{\"error\":{}}}]}'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "response.content" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "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", + "
x_1x_2y_predy_stdy_des
0-3.029691-1.24733245.611653250.717458-45.611653
\n", + "
" + ], + "text/plain": [ + " x_1 x_2 y_pred y_std y_des\n", + "0 -3.029691 -1.247332 45.611653 250.717458 -45.611653" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "Candidates(**json.loads(response.content)).to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "bofire-2", + "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.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +}