From 39196b18cebf960c08b5fca45c0a950b3ab600bc Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 01:15:38 -0700 Subject: [PATCH 01/37] Fix HoloViews opts deprecation warnings (#357) --- adaptive/learner/learner2D.py | 9 +++------ adaptive/learner/learnerND.py | 16 +++++----------- adaptive/learner/skopt_learner.py | 4 ++-- docs/source/algorithms_and_examples.md | 4 ++-- docs/source/tutorial/tutorial.LearnerND.md | 2 +- docs/source/tutorial/tutorial.advanced-topics.md | 2 +- setup.cfg | 6 ------ 7 files changed, 14 insertions(+), 29 deletions(-) delete mode 100644 setup.cfg diff --git a/adaptive/learner/learner2D.py b/adaptive/learner/learner2D.py index c565a6caa..248f2ffb7 100644 --- a/adaptive/learner/learner2D.py +++ b/adaptive/learner/learner2D.py @@ -818,12 +818,9 @@ def plot(self, n=None, tri_alpha=0): else: im = hv.Image([], bounds=lbrt) tris = hv.EdgePaths([]) - - im_opts = {"cmap": "viridis"} - tri_opts = {"line_width": 0.5, "alpha": tri_alpha} - no_hover = {"plot": {"inspection_policy": None, "tools": []}} - - return im.opts(style=im_opts) * tris.opts(style=tri_opts, **no_hover) + return im.opts(cmap="viridis") * tris.opts( + line_width=0.5, alpha=tri_alpha, tools=[] + ) def _get_data(self) -> dict[tuple[float, float], Float | np.ndarray]: return self.data diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index e48527efc..620ff9e00 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -932,12 +932,9 @@ def plot(self, n=None, tri_alpha=0): else: im = hv.Image([], bounds=lbrt) tris = hv.EdgePaths([]) - - im_opts = {"cmap": "viridis"} - tri_opts = {"line_width": 0.5, "alpha": tri_alpha} - no_hover = {"plot": {"inspection_policy": None, "tools": []}} - - return im.opts(style=im_opts) * tris.opts(style=tri_opts, **no_hover) + return im.opts(cmap="viridis") * tris.opts( + line_width=0.5, alpha=tri_alpha, tools=[] + ) def plot_slice(self, cut_mapping, n=None): """Plot a 1D or 2D interpolated slice of a N-dimensional function. @@ -1005,7 +1002,7 @@ def plot_slice(self, cut_mapping, n=None): else: im = hv.Image([], bounds=lbrt) - return im.opts(style={"cmap": "viridis"}) + return im.opts(cmap="viridis") else: raise ValueError("Only 1 or 2-dimensional plots can be generated.") @@ -1199,10 +1196,7 @@ def plot_isoline(self, level=0.0, n=None, tri_alpha=0): vertices, lines = self._get_iso(level, which="line") paths = [[vertices[i], vertices[j]] for i, j in lines] - contour = hv.Path(paths) - - contour_opts = {"color": "black"} - contour = contour.opts(style=contour_opts) + contour = hv.Path(paths).opts(color="black") return plot * contour def plot_isosurface(self, level=0.0, hull_opacity=0.2): diff --git a/adaptive/learner/skopt_learner.py b/adaptive/learner/skopt_learner.py index dd39f83cb..e8902ad95 100644 --- a/adaptive/learner/skopt_learner.py +++ b/adaptive/learner/skopt_learner.py @@ -98,12 +98,12 @@ def plot(self, nsamples=200): xsp = self.space.transform(xs.reshape(-1, 1).tolist()) y_pred, sigma = model.predict(xsp, return_std=True) # Plot model prediction for function - curve = hv.Curve((xs, y_pred)).opts(style={"line_dash": "dashed"}) + curve = hv.Curve((xs, y_pred)).opts(line_dash="dashed") # Plot 95% confidence interval as colored area around points area = hv.Area( (xs, y_pred - 1.96 * sigma, y_pred + 1.96 * sigma), vdims=["y", "y2"], - ).opts(style={"alpha": 0.5, "line_alpha": 0}) + ).opts(alpha=0.5, line_alpha=0) else: area = hv.Area([]) diff --git a/docs/source/algorithms_and_examples.md b/docs/source/algorithms_and_examples.md index 48e4cb61e..3c702784a 100644 --- a/docs/source/algorithms_and_examples.md +++ b/docs/source/algorithms_and_examples.md @@ -98,7 +98,7 @@ def plot_loss_interval(learner): x, y = [x_0, x_1], [y_0, y_1] else: x, y = [], [] - return hv.Scatter((x, y)).opts(style=dict(size=6, color="r")) + return hv.Scatter((x, y)).opts(size=6, color="r") def plot(learner, npoints): @@ -114,7 +114,7 @@ def get_hm(loss_per_interval, N=101): plot_homo = get_hm(uniform_loss).relabel("homogeneous sampling") plot_adaptive = get_hm(default_loss).relabel("with adaptive") layout = plot_homo + plot_adaptive -layout.opts(plot=dict(toolbar=None)) +layout.opts(toolbar=None) ``` ## {class}`adaptive.Learner2D` diff --git a/docs/source/tutorial/tutorial.LearnerND.md b/docs/source/tutorial/tutorial.LearnerND.md index e525fd9b8..c03f6b5c8 100644 --- a/docs/source/tutorial/tutorial.LearnerND.md +++ b/docs/source/tutorial/tutorial.LearnerND.md @@ -94,7 +94,7 @@ dm = dm.redim.values( # In a notebook one would run `dm` however we want a statically generated # html, so we use a HoloMap to display it here -dynamicmap_to_holomap(dm).options(hv.opts.Path(framewise=True)) +dynamicmap_to_holomap(dm).opts(hv.opts.Path(framewise=True)) ``` The plots show some wobbles while the original function was smooth, this is a result of the fact that the learner chooses points in 3 dimensions and the simplices are not in the same face as we try to interpolate our lines. diff --git a/docs/source/tutorial/tutorial.advanced-topics.md b/docs/source/tutorial/tutorial.advanced-topics.md index e6b6c55a3..92d4cae43 100644 --- a/docs/source/tutorial/tutorial.advanced-topics.md +++ b/docs/source/tutorial/tutorial.advanced-topics.md @@ -316,7 +316,7 @@ adaptive.runner.replay_log(reconstructed_learner, runner.log) ``` ```{code-cell} ipython3 -learner.plot().Scatter.I.opts(style=dict(size=6)) * reconstructed_learner.plot() +learner.plot().Scatter.I.opts(size=6) * reconstructed_learner.plot() ``` ## Adding coroutines diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e2bf9e391..000000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[flake8] -max-line-length = 100 -ignore = E501, W503, E203, E266, E741 -max-complexity = 18 -select = B, C, E, F, W, T4, B9 -exclude = .git, .tox, __pycache__, dist, .nox From 01569d263bcb71a7230b8c2bc97c73dedd5e92e8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 09:10:02 -0700 Subject: [PATCH 02/37] Bump scikit-optimize in environment.yml (#403) --- docs/environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environment.yml b/docs/environment.yml index fb5c30cc6..5e78c0527 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -6,7 +6,7 @@ channels: dependencies: - python - sortedcollections=2.1.0 - - scikit-optimize=0.8.1 + - scikit-optimize=0.9.0 - scikit-learn=0.24.2 - scipy=1.9.1 - holoviews=1.14.6 From 81464a384383c9001d357fd4408446df00ead367 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 12:59:34 -0700 Subject: [PATCH 03/37] Use nb_execution_raise_on_error Sphinx myst-nb option (#396) * Use nb_execution_raise_on_error Sphinx myst-nb option The option nb_execution_fail_on_error does not exist. * Pin recent versions * Fix logo * Downgrade panel * Do allow new packages --- docs/environment.yml | 34 +++++++++++++++++----------------- docs/logo.py | 4 +++- docs/source/conf.py | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 5e78c0527..4d45e9076 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -4,23 +4,23 @@ channels: - conda-forge dependencies: - - python + - python=3.11.3 - sortedcollections=2.1.0 - scikit-optimize=0.9.0 - - scikit-learn=0.24.2 - - scipy=1.9.1 - - holoviews=1.14.6 - - bokeh=2.4.0 - - panel=0.12.7 - - pandas=1.4.4 - - plotly=5.3.1 - - ipywidgets=7.6.5 - - myst-nb=0.16.0 + - scikit-learn=1.2.2 + - scipy=1.10.1 + - holoviews=1.15.4 + - bokeh=2.4.3 + - panel=0.14.4 + - pandas=2.0.0 + - plotly=5.14.1 + - ipywidgets=8.0.6 + - myst-nb=0.17.1 - sphinx_fontawesome=0.0.6 - - sphinx=4.2.0 - - ffmpeg=5.1.1 - - cloudpickle - - loky - - furo - - myst-parser - - dask + - sphinx=5.3.0 + - ffmpeg=5.1.2 + - cloudpickle=2.2.1 + - loky=3.3.0 + - furo=2023.3.27 + - myst-parser=0.18.1 + - dask=2023.3.2 diff --git a/docs/logo.py b/docs/logo.py index 595728db6..7544b396e 100644 --- a/docs/logo.py +++ b/docs/logo.py @@ -3,6 +3,7 @@ import holoviews import matplotlib.pyplot as plt +import numpy as np import matplotlib.tri as mtri from PIL import Image, ImageDraw @@ -31,7 +32,8 @@ def plot_learner_and_save(learner, fname): tri = learner.interpolator(scaled=True).tri triang = mtri.Triangulation(*tri.points.T, triangles=tri.vertices) ax.triplot(triang, c="k", lw=0.8) - ax.imshow(learner.plot().Image.I.data, extent=(-0.5, 0.5, -0.5, 0.5)) + data = learner.interpolated_on_grid() + ax.imshow(np.vstack(data), extent=(-0.5, 0.5, -0.5, 0.5)) ax.set_xticks([]) ax.set_yticks([]) plt.savefig(fname, bbox_inches="tight", transparent=True, dpi=300, pad_inches=-0.1) diff --git a/docs/source/conf.py b/docs/source/conf.py index 336484e3e..6265987d2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -76,7 +76,7 @@ # myst-nb configuration nb_execution_mode = "cache" nb_execution_timeout = 180 -nb_execution_fail_on_error = True +nb_execution_raise_on_error = True def setup(app): From 0590be684e42d4052251d27a583c44354f6d1c13 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 16:04:58 -0700 Subject: [PATCH 04/37] Add nbQA for notebook and docs linting (#361) * Add NBQA for notebook and docs linting This is now possible after https://github.com/nbQA-dev/nbQA/pull/745 which solved this issue (https://github.com/nbQA-dev/nbQA/issues/668) I opened a year ago. * Run pre-commit filters on all files * Lint * bump * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update os * Fix all nbqa issues --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 10 +++- docs/source/algorithms_and_examples.md | 30 +++++----- docs/source/logo.md | 7 +-- .../tutorial/tutorial.BalancingLearner.md | 18 +++--- docs/source/tutorial/tutorial.DataSaver.md | 2 +- .../tutorial/tutorial.IntegratorLearner.md | 15 +++-- docs/source/tutorial/tutorial.Learner1D.md | 19 ++++-- docs/source/tutorial/tutorial.Learner2D.md | 12 ++-- docs/source/tutorial/tutorial.LearnerND.md | 14 ++--- .../tutorial/tutorial.advanced-topics.md | 24 ++++---- docs/source/tutorial/tutorial.custom_loss.md | 24 ++++---- example-notebook.ipynb | 58 ++++++++++--------- readthedocs.yml | 2 +- 13 files changed, 128 insertions(+), 107 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index edaef1c72..e386b0858 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,9 +11,17 @@ repos: - repo: https://github.com/psf/black rev: 23.3.0 hooks: - - id: black + - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit rev: "v0.0.261" hooks: - id: ruff args: ["--fix"] + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.7.0 + hooks: + - id: nbqa-black + additional_dependencies: [jupytext, black] + - id: nbqa + args: ["ruff", "--fix", "--ignore=E402,B018,F704"] + additional_dependencies: [jupytext, ruff] diff --git a/docs/source/algorithms_and_examples.md b/docs/source/algorithms_and_examples.md index 3c702784a..e5c532dc3 100644 --- a/docs/source/algorithms_and_examples.md +++ b/docs/source/algorithms_and_examples.md @@ -1,15 +1,13 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 -execution: - timeout: 300 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- ```{include} ../../README.md @@ -101,16 +99,17 @@ def plot_loss_interval(learner): return hv.Scatter((x, y)).opts(size=6, color="r") -def plot(learner, npoints): - adaptive.runner.simple(learner, npoints_goal= npoints) +def plot_interval(learner, npoints): + adaptive.runner.simple(learner, npoints_goal=npoints) return (learner.plot() * plot_loss_interval(learner))[:, -1.1:1.1] def get_hm(loss_per_interval, N=101): learner = adaptive.Learner1D(f, bounds=(-1, 1), loss_per_interval=loss_per_interval) - plots = {n: plot(learner, n) for n in range(N)} + plots = {n: plot_interval(learner, n) for n in range(N)} return hv.HoloMap(plots, kdims=["npoints"]) + plot_homo = get_hm(uniform_loss).relabel("homogeneous sampling") plot_adaptive = get_hm(default_loss).relabel("with adaptive") layout = plot_homo + plot_adaptive @@ -122,7 +121,6 @@ layout.opts(toolbar=None) ```{code-cell} ipython3 :tags: [hide-input] - def ring(xy): import numpy as np @@ -131,7 +129,7 @@ def ring(xy): return x + np.exp(-((x**2 + y**2 - 0.75**2) ** 2) / a**4) -def plot(learner, npoints): +def plot_compare(learner, npoints): adaptive.runner.simple(learner, npoints_goal=npoints) learner2 = adaptive.Learner2D(ring, bounds=learner.bounds) xs = ys = np.linspace(*learner.bounds[0], int(learner.npoints**0.5)) @@ -146,7 +144,7 @@ def plot(learner, npoints): learner = adaptive.Learner2D(ring, bounds=[(-1, 1), (-1, 1)]) -plots = {n: plot(learner, n) for n in range(4, 1010, 20)} +plots = {n: plot_compare(learner, n) for n in range(4, 1010, 20)} hv.HoloMap(plots, kdims=["npoints"]).collate() ``` @@ -155,7 +153,6 @@ hv.HoloMap(plots, kdims=["npoints"]).collate() ```{code-cell} ipython3 :tags: [hide-input] - def g(n): import random @@ -167,12 +164,12 @@ def g(n): learner = adaptive.AverageLearner(g, atol=None, rtol=0.01) -def plot(learner, npoints): +def plot_avg(learner, npoints): adaptive.runner.simple(learner, npoints_goal=npoints) return learner.plot().relabel(f"loss={learner.loss():.2f}") -plots = {n: plot(learner, n) for n in range(10, 10000, 200)} +plots = {n: plot_avg(learner, n) for n in range(10, 10000, 200)} hv.HoloMap(plots, kdims=["npoints"]) ``` @@ -181,7 +178,6 @@ hv.HoloMap(plots, kdims=["npoints"]) ```{code-cell} ipython3 :tags: [hide-input] - def sphere(xyz): import numpy as np diff --git a/docs/source/logo.md b/docs/source/logo.md index c0baf5ddd..e7e553899 100644 --- a/docs/source/logo.md +++ b/docs/source/logo.md @@ -4,19 +4,16 @@ jupytext: extension: .md format_name: myst format_version: 0.13 - jupytext_version: 1.14.1 + jupytext_version: 1.14.5 kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 -execution: - timeout: 300 --- ```{code-cell} ipython3 :tags: [remove-input] -import os import functools import subprocess import tempfile @@ -75,7 +72,7 @@ def remove_rounded_corners(fname): def learner_till(till, learner, data): new_learner = adaptive.Learner2D(None, bounds=learner.bounds) - new_learner.data = {k: v for k, v in data[:till]} + new_learner.data = dict(data[:till]) for x, y in learner._bounds_points: # always include the bounds new_learner.tell((x, y), learner.data[x, y]) diff --git a/docs/source/tutorial/tutorial.BalancingLearner.md b/docs/source/tutorial/tutorial.BalancingLearner.md index 5f43bdd64..b33a6f823 100644 --- a/docs/source/tutorial/tutorial.BalancingLearner.md +++ b/docs/source/tutorial/tutorial.BalancingLearner.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Tutorial {class}`~adaptive.BalancingLearner` ```{note} @@ -60,7 +61,10 @@ runner.live_info() ``` ```{code-cell} ipython3 -plotter = lambda learner: hv.Overlay([L.plot() for L in learner.learners]) +def plotter(learner): + return hv.Overlay([L.plot() for L in learner.learners]) + + runner.live_plot(plotter=plotter, update_interval=0.1) ``` @@ -83,7 +87,7 @@ combos = { } learner = adaptive.BalancingLearner.from_product( - jacobi, adaptive.Learner1D, dict(bounds=(0, 1)), combos + jacobi, adaptive.Learner1D, {"bounds": (0, 1)}, combos ) runner = adaptive.BlockingRunner(learner, loss_goal=0.01) diff --git a/docs/source/tutorial/tutorial.DataSaver.md b/docs/source/tutorial/tutorial.DataSaver.md index 4d4e0efc4..1eb35e707 100644 --- a/docs/source/tutorial/tutorial.DataSaver.md +++ b/docs/source/tutorial/tutorial.DataSaver.md @@ -69,7 +69,7 @@ runner.live_info() ``` ```{code-cell} ipython3 -runner.live_plot(plotter=lambda l: l.learner.plot(), update_interval=0.1) +runner.live_plot(plotter=lambda lrn: lrn.learner.plot(), update_interval=0.1) ``` Now the `DataSavingLearner` will have an dictionary attribute `extra_data` that has `x` as key and the data that was returned by `learner.function` as values. diff --git a/docs/source/tutorial/tutorial.IntegratorLearner.md b/docs/source/tutorial/tutorial.IntegratorLearner.md index 8110512a9..50aaf2e5b 100644 --- a/docs/source/tutorial/tutorial.IntegratorLearner.md +++ b/docs/source/tutorial/tutorial.IntegratorLearner.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Tutorial {class}`~adaptive.IntegratorLearner` ```{note} @@ -60,9 +61,7 @@ learner = adaptive.IntegratorLearner(f24, bounds=(0, 3), tol=1e-8) # We use a SequentialExecutor, which runs the function to be learned in # *this* process only. This means we don't pay # the overhead of evaluating the function in another process. -runner = adaptive.Runner( - learner, executor=SequentialExecutor() -) +runner = adaptive.Runner(learner, executor=SequentialExecutor()) ``` ```{code-cell} ipython3 diff --git a/docs/source/tutorial/tutorial.Learner1D.md b/docs/source/tutorial/tutorial.Learner1D.md index db80e03ec..de40a83d1 100644 --- a/docs/source/tutorial/tutorial.Learner1D.md +++ b/docs/source/tutorial/tutorial.Learner1D.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + (TutorialLearner1D)= # Tutorial {class}`~adaptive.Learner1D` @@ -112,6 +113,8 @@ random.seed(0) offsets = [random.uniform(-0.8, 0.8) for _ in range(3)] # sharp peaks at random locations in the domain + + def f_levels(x, offsets=offsets): a = 0.01 return np.array( @@ -124,7 +127,9 @@ The `Learner1D` can be used for such functions: ```{code-cell} ipython3 learner = adaptive.Learner1D(f_levels, bounds=(-1, 1)) -runner = adaptive.Runner(learner, loss_goal=0.01) # continue until `learner.loss()<=0.01` +runner = adaptive.Runner( + learner, loss_goal=0.01 +) # continue until `learner.loss()<=0.01` ``` ```{code-cell} ipython3 @@ -211,12 +216,14 @@ learner.to_numpy() ``` If Pandas is installed (optional dependency), you can also run + ```{code-cell} ipython3 df = learner.to_dataframe() df ``` and load that data into a new learner with + ```{code-cell} ipython3 new_learner = adaptive.Learner1D(learner.function, (-1, 1)) # create an empty learner new_learner.load_dataframe(df) # load the pandas.DataFrame's data diff --git a/docs/source/tutorial/tutorial.Learner2D.md b/docs/source/tutorial/tutorial.Learner2D.md index d15446fe4..7d0130fc6 100644 --- a/docs/source/tutorial/tutorial.Learner2D.md +++ b/docs/source/tutorial/tutorial.Learner2D.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Tutorial {class}`~adaptive.Learner2D` ```{note} @@ -24,6 +25,7 @@ import holoviews as hv import numpy as np from functools import partial + adaptive.notebook_extension() ``` diff --git a/docs/source/tutorial/tutorial.LearnerND.md b/docs/source/tutorial/tutorial.LearnerND.md index c03f6b5c8..46f948708 100644 --- a/docs/source/tutorial/tutorial.LearnerND.md +++ b/docs/source/tutorial/tutorial.LearnerND.md @@ -1,16 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 -execution: - timeout: 300 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Tutorial {class}`~adaptive.LearnerND` ```{note} @@ -111,6 +110,7 @@ You could use the following code as an example: ```{code-cell} ipython3 import scipy + def f(xyz): x, y, z = xyz return x**4 + y**4 + z**4 - (x**2 + y**2 + z**2) ** 2 diff --git a/docs/source/tutorial/tutorial.advanced-topics.md b/docs/source/tutorial/tutorial.advanced-topics.md index 92d4cae43..2dfc6cf29 100644 --- a/docs/source/tutorial/tutorial.advanced-topics.md +++ b/docs/source/tutorial/tutorial.advanced-topics.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Advanced Topics ```{note} @@ -24,7 +25,6 @@ import adaptive adaptive.notebook_extension() import asyncio -from functools import partial import random offset = random.uniform(-0.5, 0.5) @@ -92,7 +92,7 @@ def slow_f(x): learner = adaptive.Learner1D(slow_f, bounds=[0, 1]) runner = adaptive.Runner(learner, npoints_goal=100) runner.start_periodic_saving( - save_kwargs=dict(fname="data/periodic_example.p"), interval=6 + save_kwargs={"fname": "data/periodic_example.p"}, interval=6 ) ``` @@ -168,9 +168,7 @@ If you want to enable determinism, want to continue using the non-blocking {clas from adaptive.runner import SequentialExecutor learner = adaptive.Learner1D(f, bounds=(-1, 1)) -runner = adaptive.Runner( - learner, executor=SequentialExecutor(), loss_goal=0.01 -) +runner = adaptive.Runner(learner, executor=SequentialExecutor(), loss_goal=0.01) ``` ```{code-cell} ipython3 @@ -275,6 +273,7 @@ If the runner stopped due to an exception then asking for the result will raise ```{code-cell} ipython3 :tags: [raises-exception] + runner.task.result() ``` @@ -380,6 +379,7 @@ a slow part `g` which can be reused by multiple inputs and shared across functio ```{code-cell} ipython3 import time + def f(x): """ Integer part of `x` repeats and should be reused @@ -407,9 +407,10 @@ from dask import delayed # Convert g and h to dask.Delayed objects g, h = delayed(g), delayed(h) + @delayed def f(x, y): - return (x + y)**2 + return (x + y) ** 2 ``` Next we define a computation using coroutines such that it reuses previously submitted tasks. @@ -421,6 +422,7 @@ client = await Client(asynchronous=True) g_futures = {} + async def f_parallel(x): # Get or sumbit the slow function future if (g_future := g_futures.get(int(x))) is None: diff --git a/docs/source/tutorial/tutorial.custom_loss.md b/docs/source/tutorial/tutorial.custom_loss.md index f76af484d..222dc6306 100644 --- a/docs/source/tutorial/tutorial.custom_loss.md +++ b/docs/source/tutorial/tutorial.custom_loss.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Custom adaptive logic for 1D and 2D ```{note} @@ -25,7 +26,6 @@ adaptive.notebook_extension() # Import modules that are used in multiple cells import numpy as np -from functools import partial import holoviews as hv ``` @@ -79,9 +79,6 @@ learner.plot().select(y=(0, 10000)) ``` ```{code-cell} ipython3 -from adaptive.runner import SequentialExecutor - - def uniform_sampling_2d(ip): from adaptive.learner.learner2D import areas @@ -99,7 +96,9 @@ learner = adaptive.Learner2D( ) # this takes a while, so use the async Runner so we know *something* is happening -runner = adaptive.Runner(learner, goal=lambda l: l.loss() < 0.03 or l.npoints > 1000) +runner = adaptive.Runner( + learner, goal=lambda lrn: lrn.loss() < 0.03 or lrn.npoints > 1000 +) ``` ```{code-cell} ipython3 @@ -113,7 +112,10 @@ runner.live_info() ``` ```{code-cell} ipython3 -plotter = lambda l: l.plot(tri_alpha=0.3).relabel("1 / (x^2 + y^2) in log scale") +def plotter(lrn): + return lrn.plot(tri_alpha=0.3).relabel("1 / (x^2 + y^2) in log scale") + + runner.live_plot(update_interval=0.2, plotter=plotter) ``` diff --git a/example-notebook.ipynb b/example-notebook.ipynb index d3a739056..af6fa1888 100644 --- a/example-notebook.ipynb +++ b/example-notebook.ipynb @@ -27,11 +27,12 @@ "\n", "adaptive.notebook_extension()\n", "\n", + "import random\n", + "from functools import partial\n", + "\n", "# Import modules that are used in multiple cells\n", "import holoviews as hv\n", - "import numpy as np\n", - "from functools import partial\n", - "import random" + "import numpy as np" ] }, { @@ -60,15 +61,15 @@ "\n", "\n", "def peak(x, offset=offset, wait=True):\n", - " from time import sleep\n", " from random import random\n", + " from time import sleep\n", "\n", " a = 0.01\n", " if wait:\n", " # we pretend that this is a slow function\n", " sleep(random())\n", "\n", - " return x + a**2 / (a**2 + (x - offset)**2)" + " return x + a**2 / (a**2 + (x - offset) ** 2)" ] }, { @@ -173,16 +174,17 @@ "outputs": [], "source": [ "def ring(xy, wait=True):\n", - " import numpy as np\n", - " from time import sleep\n", " from random import random\n", + " from time import sleep\n", + "\n", + " import numpy as np\n", "\n", " if wait:\n", " # we pretend that this is a slow function\n", " sleep(random() / 10)\n", " x, y = xy\n", " a = 0.2\n", - " return x + np.exp(-((x**2 + y**2 - 0.75**2)**2) / a**4)\n", + " return x + np.exp(-((x**2 + y**2 - 0.75**2) ** 2) / a**4)\n", "\n", "\n", "learner = adaptive.Learner2D(ring, bounds=[(-1, 1), (-1, 1)])" @@ -223,7 +225,7 @@ "# Create a learner and add data on homogeneous grid, so that we can plot it\n", "learner2 = adaptive.Learner2D(ring, bounds=learner.bounds)\n", "n = int(learner.npoints**0.5)\n", - "xs, ys = [np.linspace(*bounds, n) for bounds in learner.bounds]\n", + "xs, ys = (np.linspace(*bounds, n) for bounds in learner.bounds)\n", "xys = list(itertools.product(xs, ys))\n", "zs = [ring(xy, wait=False) for xy in xys]\n", "learner2.tell_many(xys, zs)\n", @@ -233,7 +235,7 @@ " + learner.plot().relabel(\"With adaptive\")\n", " + learner2.plot(n, tri_alpha=0.4)\n", " + learner.plot(tri_alpha=0.4)\n", - ").cols(2).opts({\"EdgePaths\": dict(color=\"w\")})" + ").cols(2).opts({\"EdgePaths\": {\"color\": \"w\"}})" ] }, { @@ -316,7 +318,7 @@ "source": [ "def noisy_peak(seed_x, sigma=0, peak_width=0.05, offset=-0.5):\n", " seed, x = seed_x\n", - " y = x**3 - x + 3 * peak_width**2 / (peak_width**2 + (x - offset)**2)\n", + " y = x**3 - x + 3 * peak_width**2 / (peak_width**2 + (x - offset) ** 2)\n", " rng = np.random.RandomState(int(seed))\n", " noise = rng.normal(scale=sigma)\n", " return y + noise" @@ -373,8 +375,8 @@ "learner = adaptive.AverageLearner1D(partial(noisy_peak, sigma=1), bounds=(-2, 2))\n", "\n", "\n", - "def goal(l):\n", - " return l.nsamples >= 10_000 and l.min_samples_per_point >= 20\n", + "def goal(lrn):\n", + " return lrn.nsamples >= 10_000 and lrn.min_samples_per_point >= 20\n", "\n", "\n", "runner = adaptive.Runner(learner, goal=goal)\n", @@ -446,10 +448,11 @@ ] }, { + "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ - "We initialize a learner again and pass the bounds and relative tolerance we want to reach. Then in the `Runner` we pass `goal=lambda l: l.done()` where `learner.done()` is `True` when the relative tolerance has been reached." + "We initialize a learner again and pass the bounds and relative tolerance we want to reach. Then in the `Runner` we pass `goal=lambda lrn: lrn.done()` where `learner.done()` is `True` when the relative tolerance has been reached." ] }, { @@ -515,9 +518,9 @@ "random.seed(0)\n", "offsets = [random.uniform(-0.8, 0.8) for _ in range(3)]\n", "\n", + "\n", "# sharp peaks at random locations in the domain\n", "def f_levels(x, offsets=offsets):\n", - " a = 0.01\n", " return np.array([offset + peak(x, offset, wait=False) for offset in offsets])" ] }, @@ -567,7 +570,7 @@ "def sphere(xyz):\n", " x, y, z = xyz\n", " a = 0.4\n", - " return x + z**2 + np.exp(-((x**2 + y**2 + z**2 - 0.75**2)**2) / a**4)\n", + " return x + z**2 + np.exp(-((x**2 + y**2 + z**2 - 0.75**2) ** 2) / a**4)\n", "\n", "\n", "learner = adaptive.LearnerND(sphere, bounds=[(-1, 1), (-1, 1), (-1, 1)])\n", @@ -612,7 +615,7 @@ "source": [ "def plot_cut(x1, x2, directions, learner=learner):\n", " cut_mapping = {\"xyz\".index(d): x for d, x in zip(directions, [x1, x2])}\n", - " return learner.plot_slice(cut_mapping).opts({\"Path\": dict(framewise=True)})\n", + " return learner.plot_slice(cut_mapping).opts({\"Path\": {\"framewise\": True}})\n", "\n", "\n", "dm = hv.DynamicMap(plot_cut, kdims=[\"v1\", \"v2\", \"directions\"])\n", @@ -709,7 +712,7 @@ "\n", "def plot_logz(learner):\n", " p = learner.plot(tri_alpha=0.3).relabel(\"1 / (x^2 + y^2) in log scale\")\n", - " return p.opts({\"Image\": dict(logz=True), \"EdgePaths\": dict(color=\"w\")})\n", + " return p.opts({\"Image\": {\"logz\": True}, \"EdgePaths\": {\"color\": \"w\"}})\n", "\n", "\n", "learner = adaptive.Learner2D(\n", @@ -818,7 +821,7 @@ "source": [ "def h(x, offset=0):\n", " a = 0.01\n", - " return x + a**2 / (a**2 + (x - offset)**2)\n", + " return x + a**2 / (a**2 + (x - offset) ** 2)\n", "\n", "\n", "learners = [\n", @@ -837,7 +840,10 @@ "metadata": {}, "outputs": [], "source": [ - "plotter = lambda learner: hv.Overlay([L.plot() for L in learner.learners])\n", + "def plotter(learner):\n", + " return hv.Overlay([lrn.plot() for lrn in learner.learners])\n", + "\n", + "\n", "runner.live_plot(plotter=plotter, update_interval=0.1)" ] }, @@ -868,7 +874,7 @@ "}\n", "\n", "learner = adaptive.BalancingLearner.from_product(\n", - " jacobi, adaptive.Learner1D, dict(bounds=(0, 1)), combos\n", + " jacobi, adaptive.Learner1D, {\"bounds\": (0, 1)}, combos\n", ")\n", "\n", "runner = adaptive.BlockingRunner(learner, loss_goal=0.01)\n", @@ -946,7 +952,7 @@ "metadata": {}, "outputs": [], "source": [ - "runner.live_plot(plotter=lambda l: l.learner.plot(), update_interval=0.1)" + "runner.live_plot(plotter=lambda lrn: lrn.learner.plot(), update_interval=0.1)" ] }, { @@ -1243,7 +1249,7 @@ "runner = adaptive.Runner(learner, npoints_goal=100)\n", "\n", "runner.start_periodic_saving(\n", - " save_kwargs=dict(fname=\"data/periodic_example.p\"), interval=6\n", + " save_kwargs={\"fname\": \"data/periodic_example.p\"}, interval=6\n", ")\n", "\n", "runner.live_info()" @@ -1394,9 +1400,7 @@ "\n", "learner = adaptive.Learner1D(peak, bounds=(-1, 1))\n", "\n", - "runner = adaptive.Runner(\n", - " learner, executor=SequentialExecutor(), loss_goal=0.002\n", - ")\n", + "runner = adaptive.Runner(learner, executor=SequentialExecutor(), loss_goal=0.002)\n", "runner.live_info()\n", "runner.live_plot(update_interval=0.1)" ] @@ -1573,7 +1577,7 @@ "metadata": {}, "outputs": [], "source": [ - "learner.plot().Scatter.I.opts(style=dict(size=6)) * reconstructed_learner.plot()" + "learner.plot().Scatter.I.opts(style={\"size\": 6}) * reconstructed_learner.plot()" ] }, { diff --git a/readthedocs.yml b/readthedocs.yml index 280e95ee7..23fab10c6 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -1,7 +1,7 @@ version: 2 build: - os: "ubuntu-20.04" + os: "ubuntu-22.04" tools: python: "mambaforge-4.10" From bfd1a435e8abcbca9d2b67fbf620315d36981fb7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 17:30:47 -0700 Subject: [PATCH 05/37] Use the build module (#407) --- .github/workflows/pythonpublish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 3e18e69ed..a810fb9e2 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -21,11 +21,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel twine build - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | - python setup.py sdist bdist_wheel + python -m build twine upload dist/* From 815fd312c627be7dce754c527888e42b6ad42ff5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 17:33:21 -0700 Subject: [PATCH 06/37] Avoid asyncio.coroutine error in Readthedocs.org builds (#406) --- adaptive/tests/test_learner1d.py | 2 +- docs/environment.yml | 2 +- docs/source/logo.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/adaptive/tests/test_learner1d.py b/adaptive/tests/test_learner1d.py index dceb28797..9f211e39d 100644 --- a/adaptive/tests/test_learner1d.py +++ b/adaptive/tests/test_learner1d.py @@ -409,4 +409,4 @@ def test_inf_loss_with_missing_bounds(): # must be done in parallel because otherwise the bounds will be evaluated first BlockingRunner(learner, loss_goal=0.01) - assert learner.npoints > 20 + assert learner.npoints >= 5 diff --git a/docs/environment.yml b/docs/environment.yml index 4d45e9076..8328db147 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -4,7 +4,7 @@ channels: - conda-forge dependencies: - - python=3.11.3 + - python=3.10 - sortedcollections=2.1.0 - scikit-optimize=0.9.0 - scikit-learn=1.2.2 diff --git a/docs/source/logo.md b/docs/source/logo.md index e7e553899..5cb740c2f 100644 --- a/docs/source/logo.md +++ b/docs/source/logo.md @@ -9,6 +9,8 @@ kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 +execution: + timeout: 300 --- ```{code-cell} ipython3 From 295516b080a09641d657b3fdd2899318cce2d35e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 10 Apr 2023 12:46:05 -0700 Subject: [PATCH 07/37] Only really import packages when needed (#410) * Only really import packages when needed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- adaptive/learner/triangulation.py | 2 +- adaptive/runner.py | 57 +++++++++++-------- docs/logo.py | 2 +- docs/source/algorithms_and_examples.md | 6 +- .../tutorial/tutorial.AverageLearner1D.md | 14 +++-- .../tutorial/tutorial.BalancingLearner.md | 5 +- docs/source/tutorial/tutorial.Learner1D.md | 9 +-- docs/source/tutorial/tutorial.Learner2D.md | 10 ++-- docs/source/tutorial/tutorial.custom_loss.md | 4 +- pyproject.toml | 2 +- 10 files changed, 63 insertions(+), 48 deletions(-) diff --git a/adaptive/learner/triangulation.py b/adaptive/learner/triangulation.py index 4eb5952d5..03455e3b7 100644 --- a/adaptive/learner/triangulation.py +++ b/adaptive/learner/triangulation.py @@ -16,9 +16,9 @@ ones, square, subtract, + zeros, ) from numpy import sum as np_sum -from numpy import zeros from numpy.linalg import det as ndet from numpy.linalg import matrix_rank, norm, slogdet, solve diff --git a/adaptive/runner.py b/adaptive/runner.py index a5fbb5070..bd9d051b1 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -14,6 +14,7 @@ import warnings from contextlib import suppress from datetime import datetime, timedelta +from importlib.util import find_spec from typing import TYPE_CHECKING, Any, Callable, Union import loky @@ -49,35 +50,32 @@ except ImportError: from typing_extensions import Literal -try: - import ipyparallel - from ipyparallel.client.asyncresult import AsyncResult - with_ipyparallel = True - ExecutorTypes: TypeAlias = Union[ - ExecutorTypes, ipyparallel.Client, ipyparallel.client.view.ViewExecutor - ] - FutureTypes: TypeAlias = Union[FutureTypes, AsyncResult] -except ModuleNotFoundError: - with_ipyparallel = False +with_ipyparallel = find_spec("ipyparallel") is not None +with_distributed = find_spec("distributed") is not None +with_mpi4py = find_spec("mpi4py") is not None -try: - import distributed +if TYPE_CHECKING: + if with_distributed: + import distributed - with_distributed = True - ExecutorTypes: TypeAlias = Union[ - ExecutorTypes, distributed.Client, distributed.cfexecutor.ClientExecutor - ] -except ModuleNotFoundError: - with_distributed = False + ExecutorTypes: TypeAlias = Union[ + ExecutorTypes, distributed.Client, distributed.cfexecutor.ClientExecutor + ] -try: - import mpi4py.futures + if with_mpi4py: + import mpi4py.futures - with_mpi4py = True - ExecutorTypes: TypeAlias = Union[ExecutorTypes, mpi4py.futures.MPIPoolExecutor] -except ModuleNotFoundError: - with_mpi4py = False + ExecutorTypes: TypeAlias = Union[ExecutorTypes, mpi4py.futures.MPIPoolExecutor] + + if with_ipyparallel: + import ipyparallel + from ipyparallel.client.asyncresult import AsyncResult + + ExecutorTypes: TypeAlias = Union[ + ExecutorTypes, ipyparallel.Client, ipyparallel.client.view.ViewExecutor + ] + FutureTypes: TypeAlias = Union[FutureTypes, AsyncResult] with suppress(ModuleNotFoundError): import uvloop @@ -934,9 +932,12 @@ def replay_log( def _ensure_executor(executor: ExecutorTypes | None) -> concurrent.Executor: + if with_ipyparallel: + import ipyparallel + if with_distributed: + import distributed if executor is None: executor = _default_executor() - if isinstance(executor, concurrent.Executor): return executor elif with_ipyparallel and isinstance(executor, ipyparallel.Client): @@ -955,6 +956,12 @@ def _get_ncores( ex: (ExecutorTypes), ) -> int: """Return the maximum number of cores that an executor can use.""" + if with_ipyparallel: + import ipyparallel + if with_distributed: + import distributed + if with_mpi4py: + import mpi4py.futures if with_ipyparallel and isinstance(ex, ipyparallel.client.view.ViewExecutor): return len(ex.view) elif isinstance( diff --git a/docs/logo.py b/docs/logo.py index 7544b396e..384c71065 100644 --- a/docs/logo.py +++ b/docs/logo.py @@ -3,8 +3,8 @@ import holoviews import matplotlib.pyplot as plt -import numpy as np import matplotlib.tri as mtri +import numpy as np from PIL import Image, ImageDraw sys.path.insert(0, os.path.abspath("..")) # to get adaptive on the path diff --git a/docs/source/algorithms_and_examples.md b/docs/source/algorithms_and_examples.md index e5c532dc3..f517b504b 100644 --- a/docs/source/algorithms_and_examples.md +++ b/docs/source/algorithms_and_examples.md @@ -46,11 +46,13 @@ Click on the *Play* {fa}`play` button or move the sliders. :tags: [hide-cell] import itertools -import adaptive -from adaptive.learner.learner1D import uniform_loss, default_loss + import holoviews as hv import numpy as np +import adaptive +from adaptive.learner.learner1D import default_loss, uniform_loss + adaptive.notebook_extension() hv.output(holomap="scrubber") ``` diff --git a/docs/source/tutorial/tutorial.AverageLearner1D.md b/docs/source/tutorial/tutorial.AverageLearner1D.md index 799338f82..38c3c6353 100644 --- a/docs/source/tutorial/tutorial.AverageLearner1D.md +++ b/docs/source/tutorial/tutorial.AverageLearner1D.md @@ -1,14 +1,15 @@ --- -kernelspec: - name: python3 - display_name: python3 jupytext: text_representation: extension: .md format_name: myst - format_version: '0.13' - jupytext_version: 1.13.8 + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: python3 + name: python3 --- + # Tutorial {class}`~adaptive.AverageLearner1D` ```{note} @@ -23,9 +24,10 @@ import adaptive adaptive.notebook_extension() +from functools import partial + import holoviews as hv import numpy as np -from functools import partial ``` ## General use diff --git a/docs/source/tutorial/tutorial.BalancingLearner.md b/docs/source/tutorial/tutorial.BalancingLearner.md index b33a6f823..8e43259c3 100644 --- a/docs/source/tutorial/tutorial.BalancingLearner.md +++ b/docs/source/tutorial/tutorial.BalancingLearner.md @@ -24,10 +24,11 @@ import adaptive adaptive.notebook_extension() +import random +from functools import partial + import holoviews as hv import numpy as np -from functools import partial -import random ``` The balancing learner is a “meta-learner” that takes a list of learners. diff --git a/docs/source/tutorial/tutorial.Learner1D.md b/docs/source/tutorial/tutorial.Learner1D.md index de40a83d1..e5ffe491a 100644 --- a/docs/source/tutorial/tutorial.Learner1D.md +++ b/docs/source/tutorial/tutorial.Learner1D.md @@ -25,9 +25,10 @@ import adaptive adaptive.notebook_extension() -import numpy as np -from functools import partial import random +from functools import partial + +import numpy as np ``` ## scalar output: `f:ℝ → ℝ` @@ -41,8 +42,8 @@ offset = random.uniform(-0.5, 0.5) def f(x, offset=offset, wait=True): - from time import sleep from random import random + from time import sleep a = 0.01 if wait: @@ -155,8 +156,8 @@ To do this, you need to tell the learner to look at the curvature by specifying ```{code-cell} ipython3 from adaptive.learner.learner1D import ( curvature_loss_function, - uniform_loss, default_loss, + uniform_loss, ) curvature_loss = curvature_loss_function() diff --git a/docs/source/tutorial/tutorial.Learner2D.md b/docs/source/tutorial/tutorial.Learner2D.md index 7d0130fc6..babc27c39 100644 --- a/docs/source/tutorial/tutorial.Learner2D.md +++ b/docs/source/tutorial/tutorial.Learner2D.md @@ -20,11 +20,12 @@ Download the notebook in order to see the real behaviour. [^download] ```{code-cell} ipython3 :tags: [hide-cell] -import adaptive +from functools import partial + import holoviews as hv import numpy as np -from functools import partial +import adaptive adaptive.notebook_extension() ``` @@ -33,9 +34,10 @@ Besides 1D functions, we can also learn 2D functions: $f: ℝ^2 → ℝ$. ```{code-cell} ipython3 def ring(xy, wait=True): - import numpy as np - from time import sleep from random import random + from time import sleep + + import numpy as np if wait: sleep(random() / 10) diff --git a/docs/source/tutorial/tutorial.custom_loss.md b/docs/source/tutorial/tutorial.custom_loss.md index 222dc6306..f1d84cd3f 100644 --- a/docs/source/tutorial/tutorial.custom_loss.md +++ b/docs/source/tutorial/tutorial.custom_loss.md @@ -25,8 +25,8 @@ import adaptive adaptive.notebook_extension() # Import modules that are used in multiple cells -import numpy as np import holoviews as hv +import numpy as np ``` {class}`~adaptive.Learner1D` and {class}`~adaptive.Learner2D` both work on the principle of subdividing their domain into subdomains, and assigning a property to each subdomain, which we call the *loss*. @@ -137,7 +137,7 @@ def resolution_loss_function(min_distance=0, max_distance=1): because the total area is normalized to 1.""" def resolution_loss(ip): - from adaptive.learner.learner2D import default_loss, areas + from adaptive.learner.learner2D import areas, default_loss loss = default_loss(ip) diff --git a/pyproject.toml b/pyproject.toml index 933129f7d..e8db33f21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ python_version = "3.7" [tool.ruff] line-length = 150 target-version = "py37" -select = ["B", "C", "E", "F", "W", "T", "B9"] +select = ["B", "C", "E", "F", "W", "T", "B9", "I"] ignore = [ "T20", # flake8-print "ANN101", # Missing type annotation for {name} in method From 541e1f2b242824e3bc310035374c18f223d74648 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 16 Apr 2023 14:29:20 -0700 Subject: [PATCH 08/37] Cache the loss of a SequenceLearner (#411) --- adaptive/learner/sequence_learner.py | 7 ++++++- docs/source/conf.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/adaptive/learner/sequence_learner.py b/adaptive/learner/sequence_learner.py index 2b3515801..e0b19bf47 100644 --- a/adaptive/learner/sequence_learner.py +++ b/adaptive/learner/sequence_learner.py @@ -8,7 +8,11 @@ from sortedcontainers import SortedDict, SortedSet from adaptive.learner.base_learner import BaseLearner -from adaptive.utils import assign_defaults, partial_function_from_dataframe +from adaptive.utils import ( + assign_defaults, + cache_latest, + partial_function_from_dataframe, +) try: import pandas @@ -113,6 +117,7 @@ def ask( return points, loss_improvements + @cache_latest def loss(self, real: bool = True) -> float: if not (self._to_do_indices or self.pending_points): return 0.0 diff --git a/docs/source/conf.py b/docs/source/conf.py index 6265987d2..1983dda56 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,7 +17,7 @@ # -- Project information ----------------------------------------------------- project = "adaptive" -copyright = "2018-2022, Adaptive Authors" +copyright = "2018-2023, Adaptive Authors" author = "Adaptive Authors" # The short X.Y version From 1b0f15e44235643731c56d335f6711c5584d4828 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 16 Apr 2023 14:51:07 -0700 Subject: [PATCH 09/37] Use codecov/codecov-action instead of removed codecov package (#412) --- .github/workflows/coverage.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3612af643..c885edfcd 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,8 +13,8 @@ jobs: with: python-version: 3.7 - name: Install dependencies - run: pip install nox codecov + run: pip install nox - name: Test with nox run: nox -e coverage - - name: Upload test coverage - run: codecov -t ${{ secrets.CODECOV_TOKEN }} -f .coverage.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 From b16f0e5f5f72d5bf80fd4bba63d7259850e5c672 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Apr 2023 23:36:39 -0700 Subject: [PATCH 10/37] [pre-commit.ci] pre-commit autoupdate (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/charliermarsh/ruff-pre-commit: v0.0.261 → v0.0.262](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.261...v0.0.262) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e386b0858..3ba9dde3e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.261" + rev: "v0.0.262" hooks: - id: ruff args: ["--fix"] From 82ed0a45b1fa1e01c8b53a6150f95e694f4f932b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 28 Apr 2023 20:27:04 -0700 Subject: [PATCH 11/37] Remove _RequireAttrsABCMeta metaclass and replace with simple check (#409) * Remove _RequireAttrsABCMeta metaclass and replace with simple check * make BaseLearner a ABC --- adaptive/learner/average_learner.py | 1 + adaptive/learner/average_learner1D.py | 1 + adaptive/learner/balancing_learner.py | 1 + adaptive/learner/base_learner.py | 17 +++++++++++++++-- adaptive/learner/data_saver.py | 1 + adaptive/learner/integrator_learner.py | 1 + adaptive/learner/learner1D.py | 1 + adaptive/learner/learner2D.py | 1 + adaptive/learner/learnerND.py | 2 ++ adaptive/learner/sequence_learner.py | 1 + adaptive/utils.py | 18 ------------------ 11 files changed, 25 insertions(+), 20 deletions(-) diff --git a/adaptive/learner/average_learner.py b/adaptive/learner/average_learner.py index 2705eb04e..a30cf63c5 100644 --- a/adaptive/learner/average_learner.py +++ b/adaptive/learner/average_learner.py @@ -75,6 +75,7 @@ def __init__( self.min_npoints = max(min_npoints, 2) self.sum_f: Real = 0.0 self.sum_f_sq: Real = 0.0 + self._check_required_attributes() def new(self) -> AverageLearner: """Create a copy of `~adaptive.AverageLearner` without the data.""" diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py index 04d2163ed..c7d5ca56d 100644 --- a/adaptive/learner/average_learner1D.py +++ b/adaptive/learner/average_learner1D.py @@ -125,6 +125,7 @@ def __init__( self._distances: dict[Real, float] = decreasing_dict() # {xii: error[xii]/min(_distances[xi], _distances[xii], ...} self.rescaled_error: dict[Real, float] = decreasing_dict() + self._check_required_attributes() def new(self) -> AverageLearner1D: """Create a copy of `~adaptive.AverageLearner1D` without the data.""" diff --git a/adaptive/learner/balancing_learner.py b/adaptive/learner/balancing_learner.py index da9fd24fc..568b709d0 100644 --- a/adaptive/learner/balancing_learner.py +++ b/adaptive/learner/balancing_learner.py @@ -118,6 +118,7 @@ def __init__( ) self.strategy: STRATEGY_TYPE = strategy + self._check_required_attributes() def new(self) -> BalancingLearner: """Create a new `BalancingLearner` with the same parameters.""" diff --git a/adaptive/learner/base_learner.py b/adaptive/learner/base_learner.py index 2f3f09af1..9ee027a76 100644 --- a/adaptive/learner/base_learner.py +++ b/adaptive/learner/base_learner.py @@ -3,7 +3,7 @@ import cloudpickle -from adaptive.utils import _RequireAttrsABCMeta, load, save +from adaptive.utils import load, save def uses_nth_neighbors(n: int): @@ -60,7 +60,7 @@ def _wrapped(loss_per_interval): return _wrapped -class BaseLearner(metaclass=_RequireAttrsABCMeta): +class BaseLearner(abc.ABC): """Base class for algorithms for learning a function 'f: X → Y'. Attributes @@ -198,3 +198,16 @@ def __getstate__(self): def __setstate__(self, state): self.__dict__ = cloudpickle.loads(state) + + def _check_required_attributes(self): + for name, type_ in self.__annotations__.items(): + try: + x = getattr(self, name) + except AttributeError: + raise AttributeError( + f"Required attribute {name} not set in __init__." + ) from None + else: + if not isinstance(x, type_): + msg = f"The attribute '{name}' should be of type {type_}, not {type(x)}." + raise TypeError(msg) diff --git a/adaptive/learner/data_saver.py b/adaptive/learner/data_saver.py index 0c7dd4c47..953c25157 100644 --- a/adaptive/learner/data_saver.py +++ b/adaptive/learner/data_saver.py @@ -45,6 +45,7 @@ def __init__(self, learner: BaseLearner, arg_picker: Callable) -> None: self.extra_data = OrderedDict() self.function = learner.function self.arg_picker = arg_picker + self._check_required_attributes() def new(self) -> DataSaver: """Return a new `DataSaver` with the same `arg_picker` and `learner`.""" diff --git a/adaptive/learner/integrator_learner.py b/adaptive/learner/integrator_learner.py index ae2a18670..c5a3cf519 100644 --- a/adaptive/learner/integrator_learner.py +++ b/adaptive/learner/integrator_learner.py @@ -389,6 +389,7 @@ def __init__(self, function: Callable, bounds: tuple[int, int], tol: float) -> N ival = _Interval.make_first(*self.bounds) self.add_ival(ival) self.first_ival = ival + self._check_required_attributes() def new(self) -> IntegratorLearner: """Create a copy of `~adaptive.Learner2D` without the data.""" diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py index 3f2cc70c6..3166c2532 100644 --- a/adaptive/learner/learner1D.py +++ b/adaptive/learner/learner1D.py @@ -315,6 +315,7 @@ def __init__( self.__missing_bounds = set(self.bounds) # cache of missing bounds self._vdim: int | None = None + self._check_required_attributes() def new(self) -> Learner1D: """Create a copy of `~adaptive.Learner1D` without the data.""" diff --git a/adaptive/learner/learner2D.py b/adaptive/learner/learner2D.py index 248f2ffb7..4ce4fd3b7 100644 --- a/adaptive/learner/learner2D.py +++ b/adaptive/learner/learner2D.py @@ -393,6 +393,7 @@ def __init__( self._ip = self._ip_combined = None self.stack_size = 10 + self._check_required_attributes() def new(self) -> Learner2D: return Learner2D(self.function, self.bounds, self.loss_per_triangle) diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index 620ff9e00..d9277dc4e 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -376,6 +376,8 @@ def __init__(self, func, bounds, loss_per_simplex=None): # _pop_highest_existing_simplex self._simplex_queue = SortedKeyList(key=_simplex_evaluation_priority) + self._check_required_attributes() + def new(self) -> LearnerND: """Create a new learner with the same function and bounds.""" return LearnerND(self.function, self.bounds, self.loss_per_simplex) diff --git a/adaptive/learner/sequence_learner.py b/adaptive/learner/sequence_learner.py index e0b19bf47..2ca804b40 100644 --- a/adaptive/learner/sequence_learner.py +++ b/adaptive/learner/sequence_learner.py @@ -92,6 +92,7 @@ def __init__(self, function, sequence): self.sequence = copy(sequence) self.data = SortedDict() self.pending_points = set() + self._check_required_attributes() def new(self) -> SequenceLearner: """Return a new `~adaptive.SequenceLearner` without the data.""" diff --git a/adaptive/utils.py b/adaptive/utils.py index f2c23aa71..1385fe997 100644 --- a/adaptive/utils.py +++ b/adaptive/utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import abc import concurrent.futures as concurrent import functools import gzip @@ -90,23 +89,6 @@ def decorator(method): return decorator -class _RequireAttrsABCMeta(abc.ABCMeta): - def __call__(self, *args, **kwargs): - obj = super().__call__(*args, **kwargs) - for name, type_ in obj.__annotations__.items(): - try: - x = getattr(obj, name) - except AttributeError: - raise AttributeError( - f"Required attribute {name} not set in __init__." - ) from None - else: - if not isinstance(x, type_): - msg = f"The attribute '{name}' should be of type {type_}, not {type(x)}." - raise TypeError(msg) - return obj - - def _default_parameters(function, function_prefix: str = "function."): sig = inspect.signature(function) defaults = { From e0809ae710033d695934df675e74fce0ce03516a Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 28 Apr 2023 21:15:44 -0700 Subject: [PATCH 12/37] Add mypy to pre-commit and fix all current typing issues (#414) * Remove _RequireAttrsABCMeta metaclass and replace with simple check * add mypy * Fix some typing issues * Fix some typing issues * Fix some typing issues * fix all Runner type issues * fix all DataSaver type issues * fix all IntegratorLearner type issues * fix all SequenceLearner type issues * some fixes * some fixes * some fixes * Fix multiple issues * Fix all mypy issues * Make data a dict * make BaseLearner a ABC * remove BaseLearner._check_required_attributes() * remove unused deps * Wrap in TYPE_CHECKING * pin ipython * pin ipython * Add NotImplemented methods * remove unused import --- .pre-commit-config.yaml | 5 + adaptive/__init__.py | 4 +- adaptive/_version.py | 4 +- adaptive/learner/average_learner.py | 9 +- adaptive/learner/average_learner1D.py | 38 +++--- adaptive/learner/balancing_learner.py | 57 +++++---- adaptive/learner/base_learner.py | 72 ++++++++--- adaptive/learner/data_saver.py | 44 ++++--- adaptive/learner/integrator_coeffs.py | 4 +- adaptive/learner/integrator_learner.py | 44 +++++-- adaptive/learner/learner1D.py | 96 ++++++++------- adaptive/learner/learner2D.py | 20 ++-- adaptive/learner/learnerND.py | 6 +- adaptive/learner/sequence_learner.py | 17 ++- adaptive/learner/skopt_learner.py | 61 ++++++++++ adaptive/notebook_integration.py | 4 +- adaptive/runner.py | 126 +++++++++++--------- adaptive/tests/algorithm_4.py | 62 ++++++---- adaptive/tests/test_average_learner.py | 4 + adaptive/tests/test_average_learner1d.py | 4 + adaptive/tests/test_balancing_learner.py | 2 + adaptive/tests/test_learner1d.py | 6 + adaptive/tests/test_learners.py | 20 ++-- adaptive/tests/test_notebook_integration.py | 5 + adaptive/types.py | 10 +- adaptive/utils.py | 10 +- docs/source/tutorial/tutorial.Learner2D.md | 2 +- pyproject.toml | 15 ++- 28 files changed, 482 insertions(+), 269 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ba9dde3e..f6b5068ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,8 @@ repos: - id: nbqa args: ["ruff", "--fix", "--ignore=E402,B018,F704"] additional_dependencies: [jupytext, ruff] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.2.0" + hooks: + - id: mypy + exclude: ipynb_filter.py|docs/source/conf.py diff --git a/adaptive/__init__.py b/adaptive/__init__.py index c28e43fcb..dbf7c6b86 100644 --- a/adaptive/__init__.py +++ b/adaptive/__init__.py @@ -54,5 +54,5 @@ __all__.append("SKOptLearner") # to avoid confusion with `notebook_extension` and `__version__` -del _version # noqa: F821 -del notebook_integration # noqa: F821 +del _version # type: ignore[name-defined] # noqa: F821 +del notebook_integration # type: ignore[name-defined] # noqa: F821 diff --git a/adaptive/_version.py b/adaptive/_version.py index 42d0147c4..ce1351504 100644 --- a/adaptive/_version.py +++ b/adaptive/_version.py @@ -1,5 +1,7 @@ # This file is part of 'miniver': https://github.com/jbweston/miniver # +from __future__ import annotations + import os import subprocess from collections import namedtuple @@ -10,7 +12,7 @@ Version = namedtuple("Version", ("release", "dev", "labels")) # No public API -__all__ = [] +__all__: list[str] = [] package_root = os.path.dirname(os.path.realpath(__file__)) package_name = os.path.basename(package_root) diff --git a/adaptive/learner/average_learner.py b/adaptive/learner/average_learner.py index a30cf63c5..c3d4892b4 100644 --- a/adaptive/learner/average_learner.py +++ b/adaptive/learner/average_learner.py @@ -1,8 +1,6 @@ from __future__ import annotations from math import sqrt -from numbers import Integral as Int -from numbers import Real from typing import Callable import cloudpickle @@ -10,7 +8,7 @@ from adaptive.learner.base_learner import BaseLearner from adaptive.notebook_integration import ensure_holoviews -from adaptive.types import Float +from adaptive.types import Float, Int, Real from adaptive.utils import ( assign_defaults, cache_latest, @@ -75,7 +73,6 @@ def __init__( self.min_npoints = max(min_npoints, 2) self.sum_f: Real = 0.0 self.sum_f_sq: Real = 0.0 - self._check_required_attributes() def new(self) -> AverageLearner: """Create a copy of `~adaptive.AverageLearner` without the data.""" @@ -89,7 +86,7 @@ def to_numpy(self): """Data as NumPy array of size (npoints, 2) with seeds and values.""" return np.array(sorted(self.data.items())) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -129,7 +126,7 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py index c7d5ca56d..c3ba415a3 100644 --- a/adaptive/learner/average_learner1D.py +++ b/adaptive/learner/average_learner1D.py @@ -5,8 +5,6 @@ from collections import defaultdict from copy import deepcopy from math import hypot -from numbers import Integral as Int -from numbers import Real from typing import Callable, DefaultDict, Iterable, List, Sequence, Tuple import numpy as np @@ -16,6 +14,7 @@ from adaptive.learner.learner1D import Learner1D, _get_intervals from adaptive.notebook_integration import ensure_holoviews +from adaptive.types import Int, Real from adaptive.utils import assign_defaults, partial_function_from_dataframe try: @@ -99,7 +98,7 @@ def __init__( if min_samples > max_samples: raise ValueError("max_samples should be larger than min_samples.") - super().__init__(function, bounds, loss_per_interval) + super().__init__(function, bounds, loss_per_interval) # type: ignore[arg-type] self.delta = delta self.alpha = alpha @@ -110,7 +109,7 @@ def __init__( # Contains all samples f(x) for each # point x in the form {x0: {0: f_0(x0), 1: f_1(x0), ...}, ...} - self._data_samples = SortedDict() + self._data_samples: SortedDict[float, dict[int, Real]] = SortedDict() # Contains the number of samples taken # at each point x in the form {x0: n0, x1: n1, ...} self._number_samples = SortedDict() @@ -124,15 +123,14 @@ def __init__( # form {xi: ((xii-xi)^2 + (yii-yi)^2)^0.5, ...} self._distances: dict[Real, float] = decreasing_dict() # {xii: error[xii]/min(_distances[xi], _distances[xii], ...} - self.rescaled_error: dict[Real, float] = decreasing_dict() - self._check_required_attributes() + self.rescaled_error: ItemSortedDict[Real, float] = decreasing_dict() def new(self) -> AverageLearner1D: """Create a copy of `~adaptive.AverageLearner1D` without the data.""" return AverageLearner1D( self.function, self.bounds, - self.loss_per_interval, + self.loss_per_interval, # type: ignore[arg-type] self.delta, self.alpha, self.neighbor_sampling, @@ -164,7 +162,7 @@ def to_numpy(self, mean: bool = False) -> np.ndarray: ] ) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, mean: bool = False, with_default_function_args: bool = True, @@ -202,10 +200,10 @@ def to_dataframe( if not with_pandas: raise ImportError("pandas is not installed.") if mean: - data = sorted(self.data.items()) + data: list[tuple[Real, Real]] = sorted(self.data.items()) columns = [x_name, y_name] else: - data = [ + data: list[tuple[int, Real, Real]] = [ # type: ignore[no-redef] (seed, x, y) for x, seed_y in sorted(self._data_samples.items()) for seed, y in sorted(seed_y.items()) @@ -218,7 +216,7 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, @@ -258,7 +256,7 @@ def load_dataframe( self.function, df, function_prefix ) - def ask(self, n: int, tell_pending: bool = True) -> tuple[Points, list[float]]: + def ask(self, n: int, tell_pending: bool = True) -> tuple[Points, list[float]]: # type: ignore[override] """Return 'n' points that are expected to maximally reduce the loss.""" # If some point is undersampled, resample it if len(self._undersampled_points): @@ -311,18 +309,18 @@ def _ask_for_new_point(self, n: int) -> tuple[Points, list[float]]: new point, since in general n << min_samples and this point will need to be resampled many more times""" points, (loss_improvement,) = self._ask_points_without_adding(1) - points = [(seed, x) for seed, x in zip(range(n), n * points)] + seed_points = [(seed, x) for seed, x in zip(range(n), n * points)] loss_improvements = [loss_improvement / n] * n - return points, loss_improvements + return seed_points, loss_improvements # type: ignore[return-value] - def tell_pending(self, seed_x: Point) -> None: + def tell_pending(self, seed_x: Point) -> None: # type: ignore[override] _, x = seed_x self.pending_points.add(seed_x) if x not in self.data: self._update_neighbors(x, self.neighbors_combined) self._update_losses(x, real=False) - def tell(self, seed_x: Point, y: Real) -> None: + def tell(self, seed_x: Point, y: Real) -> None: # type: ignore[override] seed, x = seed_x if y is None: raise TypeError( @@ -493,7 +491,7 @@ def _calc_error_in_mean(self, ys: Iterable[Real], y_avg: Real, n: int) -> float: t_student = scipy.stats.t.ppf(1 - self.alpha, df=n - 1) return t_student * (variance_in_mean / n) ** 0.5 - def tell_many( + def tell_many( # type: ignore[override] self, xs: Points | np.ndarray, ys: Sequence[Real] | np.ndarray ) -> None: # Check that all x are within the bounds @@ -578,10 +576,10 @@ def tell_many_at_point(self, x: Real, seed_y_mapping: dict[int, Real]) -> None: self._update_interpolated_loss_in_interval(*interval) self._oldscale = deepcopy(self._scale) - def _get_data(self) -> dict[Real, dict[Int, Real]]: + def _get_data(self) -> dict[Real, dict[Int, Real]]: # type: ignore[override] return self._data_samples - def _set_data(self, data: dict[Real, dict[Int, Real]]) -> None: + def _set_data(self, data: dict[Real, dict[Int, Real]]) -> None: # type: ignore[override] if data: for x, samples in data.items(): self.tell_many_at_point(x, samples) @@ -616,7 +614,7 @@ def plot(self): return p.redim(x={"range": plot_bounds}) -def decreasing_dict() -> dict: +def decreasing_dict() -> ItemSortedDict: """This initialization orders the dictionary from large to small values""" def sorting_rule(key, value): diff --git a/adaptive/learner/balancing_learner.py b/adaptive/learner/balancing_learner.py index 568b709d0..a009722a1 100644 --- a/adaptive/learner/balancing_learner.py +++ b/adaptive/learner/balancing_learner.py @@ -1,24 +1,30 @@ from __future__ import annotations import itertools -import numbers +import sys from collections import defaultdict from collections.abc import Iterable from contextlib import suppress from functools import partial from operator import itemgetter -from typing import Any, Callable, Dict, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Sequence, Tuple, Union, cast import numpy as np -from adaptive.learner.base_learner import BaseLearner +from adaptive.learner.base_learner import BaseLearner, LearnerType from adaptive.notebook_integration import ensure_holoviews +from adaptive.types import Int, Real from adaptive.utils import cache_latest, named_product, restore -try: - from typing import Literal, TypeAlias -except ImportError: - from typing_extensions import Literal, TypeAlias +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal try: import pandas @@ -107,9 +113,9 @@ def __init__( # pickle the whole learner. self.function = partial(dispatch, [lrn.function for lrn in self.learners]) # type: ignore - self._ask_cache = {} - self._loss = {} - self._pending_loss = {} + self._ask_cache: dict[int, Any] = {} + self._loss: dict[int, float] = {} + self._pending_loss: dict[int, float] = {} self._cdims_default = cdims if len({learner.__class__ for learner in self.learners}) > 1: @@ -118,7 +124,6 @@ def __init__( ) self.strategy: STRATEGY_TYPE = strategy - self._check_required_attributes() def new(self) -> BalancingLearner: """Create a new `BalancingLearner` with the same parameters.""" @@ -129,21 +134,21 @@ def new(self) -> BalancingLearner: ) @property - def data(self) -> dict[tuple[int, Any], Any]: + def data(self) -> dict[tuple[int, Any], Any]: # type: ignore[override] data = {} for i, lrn in enumerate(self.learners): data.update({(i, p): v for p, v in lrn.data.items()}) return data @property - def pending_points(self) -> set[tuple[int, Any]]: + def pending_points(self) -> set[tuple[int, Any]]: # type: ignore[override] pending_points = set() for i, lrn in enumerate(self.learners): pending_points.update({(i, p) for p in lrn.pending_points}) return pending_points @property - def npoints(self) -> int: + def npoints(self) -> int: # type: ignore[override] return sum(lrn.npoints for lrn in self.learners) @property @@ -233,8 +238,8 @@ def _ask_and_tell_based_on_loss( return points, loss_improvements def _ask_and_tell_based_on_npoints( - self, n: numbers.Integral - ) -> tuple[list[tuple[numbers.Integral, Any]], list[float]]: + self, n: Int + ) -> tuple[list[tuple[Int, Any]], list[float]]: selected = [] # tuples ((learner_index, point), loss_improvement) total_points = [lrn.npoints + len(lrn.pending_points) for lrn in self.learners] for _ in range(n): @@ -252,7 +257,7 @@ def _ask_and_tell_based_on_npoints( def _ask_and_tell_based_on_cycle( self, n: int - ) -> tuple[list[tuple[numbers.Integral, Any]], list[float]]: + ) -> tuple[list[tuple[Int, Any]], list[float]]: points, loss_improvements = [], [] for _ in range(n): index = next(self._cycle) @@ -265,7 +270,7 @@ def _ask_and_tell_based_on_cycle( def ask( self, n: int, tell_pending: bool = True - ) -> tuple[list[tuple[numbers.Integral, Any]], list[float]]: + ) -> tuple[list[tuple[Int, Any]], list[float]]: """Chose points for learners.""" if n == 0: return [], [] @@ -276,14 +281,14 @@ def ask( else: return self._ask_and_tell(n) - def tell(self, x: tuple[numbers.Integral, Any], y: Any) -> None: + def tell(self, x: tuple[Int, Any], y: Any) -> None: index, x = x self._ask_cache.pop(index, None) self._loss.pop(index, None) self._pending_loss.pop(index, None) self.learners[index].tell(x, y) - def tell_pending(self, x: tuple[numbers.Integral, Any]) -> None: + def tell_pending(self, x: tuple[Int, Any]) -> None: index, x = x self._ask_cache.pop(index, None) self._loss.pop(index, None) @@ -356,7 +361,7 @@ def plot( # Normalize the format keys, values_list = cdims cdims = [dict(zip(keys, values)) for values in values_list] - + cdims = cast(list[dict[str, Real]], cdims) mapping = { tuple(_cdims.values()): lrn for lrn, _cdims in zip(self.learners, cdims) } @@ -394,7 +399,7 @@ def remove_unfinished(self) -> None: def from_product( cls, f, - learner_type: BaseLearner, + learner_type: LearnerType, learner_kwargs: dict[str, Any], combos: dict[str, Sequence[Any]], ) -> BalancingLearner: @@ -440,11 +445,11 @@ def from_product( learners = [] arguments = named_product(**combos) for combo in arguments: - learner = learner_type(function=partial(f, **combo), **learner_kwargs) + learner = learner_type(function=partial(f, **combo), **learner_kwargs) # type: ignore[operator] learners.append(learner) return cls(learners, cdims=arguments) - def to_dataframe(self, index_name: str = "learner_index", **kwargs): + def to_dataframe(self, index_name: str = "learner_index", **kwargs): # type: ignore[override] """Return the data as a concatenated `pandas.DataFrame` from child learners. Parameters @@ -476,7 +481,7 @@ def to_dataframe(self, index_name: str = "learner_index", **kwargs): df = pandas.concat(dfs, axis=0, ignore_index=True) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, index_name: str = "learner_index", **kwargs ): """Load the data from a `pandas.DataFrame` into the child learners. @@ -579,4 +584,4 @@ def __getstate__(self) -> tuple[list[BaseLearner], CDIMS_TYPE, STRATEGY_TYPE]: def __setstate__(self, state: tuple[list[BaseLearner], CDIMS_TYPE, STRATEGY_TYPE]): learners, cdims, strategy = state - self.__init__(learners, cdims=cdims, strategy=strategy) + self.__init__(learners, cdims=cdims, strategy=strategy) # type: ignore[misc] diff --git a/adaptive/learner/base_learner.py b/adaptive/learner/base_learner.py index 9ee027a76..934fee687 100644 --- a/adaptive/learner/base_learner.py +++ b/adaptive/learner/base_learner.py @@ -1,10 +1,16 @@ +from __future__ import annotations + import abc from contextlib import suppress +from typing import TYPE_CHECKING, Any, Callable, Dict, TypeVar import cloudpickle from adaptive.utils import load, save +if TYPE_CHECKING: + import pandas + def uses_nth_neighbors(n: int): """Decorator to specify how many neighboring intervals the loss function uses. @@ -60,6 +66,9 @@ def _wrapped(loss_per_interval): return _wrapped +DataType = Dict[Any, Any] + + class BaseLearner(abc.ABC): """Base class for algorithms for learning a function 'f: X → Y'. @@ -81,9 +90,10 @@ class BaseLearner(abc.ABC): and returns a holoviews plot. """ - data: dict + data: DataType npoints: int pending_points: set + function: Callable[..., Any] def tell(self, x, y): """Tell the learner about a single value. @@ -142,11 +152,11 @@ def ask(self, n, tell_pending=True): """ @abc.abstractmethod - def _get_data(self): + def _get_data(self) -> Any: pass @abc.abstractmethod - def _set_data(self): + def _set_data(self, data: Any) -> None: pass @abc.abstractmethod @@ -193,21 +203,53 @@ def load(self, fname, compress=True): data = load(fname, compress) self._set_data(data) + @abc.abstractmethod + def to_dataframe( + self, + with_default_function_args: bool = True, + function_prefix: str = "function.", + **kwargs: Any, + ) -> pandas.DataFrame: + """Return the data as a `pandas.DataFrame`. + + Parameters + ---------- + with_default_function_args : bool, optional + Include the ``learner.function``'s default arguments as a + column, by default True + function_prefix : str, optional + Prefix to the ``learner.function``'s default arguments' names, + by default "function." + x_name : str, optional + Name of the input value, by default "x" + y_name : str, optional + Name of the output value, by default "y" + + Returns + ------- + pandas.DataFrame + """ + + @abc.abstractmethod + def load_dataframe( + self, + df: pandas.DataFrame, + with_default_function_args: bool = True, + function_prefix: str = "function.", + **kwargs: Any, + ) -> None: + """Load data from a `pandas.DataFrame`. + + If ``with_default_function_args`` is True, then ``learner.function``'s + default arguments are set (using `functools.partial`) from the values + in the `pandas.DataFrame`. + """ + def __getstate__(self): return cloudpickle.dumps(self.__dict__) def __setstate__(self, state): self.__dict__ = cloudpickle.loads(state) - def _check_required_attributes(self): - for name, type_ in self.__annotations__.items(): - try: - x = getattr(self, name) - except AttributeError: - raise AttributeError( - f"Required attribute {name} not set in __init__." - ) from None - else: - if not isinstance(x, type_): - msg = f"The attribute '{name}' should be of type {type_}, not {type(x)}." - raise TypeError(msg) + +LearnerType = TypeVar("LearnerType", bound=BaseLearner) diff --git a/adaptive/learner/data_saver.py b/adaptive/learner/data_saver.py index 953c25157..a69807389 100644 --- a/adaptive/learner/data_saver.py +++ b/adaptive/learner/data_saver.py @@ -4,7 +4,7 @@ from collections import OrderedDict from typing import Any, Callable -from adaptive.learner.base_learner import BaseLearner +from adaptive.learner.base_learner import BaseLearner, LearnerType from adaptive.utils import copy_docstring_from try: @@ -40,12 +40,11 @@ class DataSaver(BaseLearner): >>> learner = DataSaver(_learner, arg_picker=itemgetter('y')) """ - def __init__(self, learner: BaseLearner, arg_picker: Callable) -> None: + def __init__(self, learner: LearnerType, arg_picker: Callable) -> None: self.learner = learner - self.extra_data = OrderedDict() + self.extra_data: OrderedDict[Any, Any] = OrderedDict() self.function = learner.function self.arg_picker = arg_picker - self._check_required_attributes() def new(self) -> DataSaver: """Return a new `DataSaver` with the same `arg_picker` and `learner`.""" @@ -76,8 +75,12 @@ def tell(self, x: Any, result: Any) -> None: def tell_pending(self, x: Any) -> None: self.learner.tell_pending(x) - def to_dataframe( - self, extra_data_name: str = "extra_data", **kwargs: Any + def to_dataframe( # type: ignore[override] + self, + with_default_function_args: bool = True, + function_prefix: str = "function.", + extra_data_name: str = "extra_data", + **kwargs: Any, ) -> pandas.DataFrame: """Return the data as a concatenated `pandas.DataFrame` from child learners. @@ -99,18 +102,24 @@ def to_dataframe( """ if not with_pandas: raise ImportError("pandas is not installed.") - df = self.learner.to_dataframe(**kwargs) + df = self.learner.to_dataframe( + with_default_function_args=with_default_function_args, + function_prefix=function_prefix, + **kwargs, + ) df[extra_data_name] = [ self.extra_data[_to_key(x)] for _, x in df[df.attrs["inputs"]].iterrows() ] return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, + with_default_function_args: bool = True, + function_prefix: str = "function.", extra_data_name: str = "extra_data", - input_names: tuple[str] = (), + input_names: tuple[str, ...] = (), **kwargs, ) -> None: """Load the data from a `pandas.DataFrame` into the learner. @@ -130,32 +139,37 @@ def load_dataframe( **kwargs : dict Keyword arguments passed to each ``child_learner.load_dataframe(**kwargs)``. """ - self.learner.load_dataframe(df, **kwargs) + self.learner.load_dataframe( + df, + with_default_function_args=with_default_function_args, + function_prefix=function_prefix, + **kwargs, + ) keys = df.attrs.get("inputs", list(input_names)) for _, x in df[keys + [extra_data_name]].iterrows(): key = _to_key(x[:-1]) self.extra_data[key] = x[-1] - def _get_data(self) -> tuple[Any, OrderedDict]: + def _get_data(self) -> tuple[Any, OrderedDict[Any, Any]]: return self.learner._get_data(), self.extra_data def _set_data( self, - data: tuple[Any, OrderedDict], + data: tuple[Any, OrderedDict[Any, Any]], ) -> None: learner_data, self.extra_data = data self.learner._set_data(learner_data) - def __getstate__(self) -> tuple[BaseLearner, Callable, OrderedDict]: + def __getstate__(self) -> tuple[LearnerType, Callable, OrderedDict]: return ( self.learner, self.arg_picker, self.extra_data, ) - def __setstate__(self, state: tuple[BaseLearner, Callable, OrderedDict]) -> None: + def __setstate__(self, state: tuple[LearnerType, Callable, OrderedDict]) -> None: learner, arg_picker, extra_data = state - self.__init__(learner, arg_picker) + self.__init__(learner, arg_picker) # type: ignore[misc] self.extra_data = extra_data @copy_docstring_from(BaseLearner.save) diff --git a/adaptive/learner/integrator_coeffs.py b/adaptive/learner/integrator_coeffs.py index 55a57de9b..872888194 100644 --- a/adaptive/learner/integrator_coeffs.py +++ b/adaptive/learner/integrator_coeffs.py @@ -49,7 +49,7 @@ def newton(n: int) -> np.ndarray: # monomial x^(n-d). mod = 2 * (n - 1) - terms = defaultdict(int) + terms: dict[tuple[int, int], int] = defaultdict(int) terms[0, 0] += 1 for i in range(n): @@ -105,7 +105,7 @@ def scalar_product(a: list[Fraction], b: list[Fraction]) -> Fraction: c[i + j] += a[j] * bi # Calculate the definite integral from -1 to 1. - return 2 * sum(c[i] / (i + 1) for i in range(0, lc, 2)) + return 2 * sum(c[i] / (i + 1) for i in range(0, lc, 2)) # type: ignore[return-value] def calc_bdef(ns: tuple[int, int, int, int]) -> list[np.ndarray]: diff --git a/adaptive/learner/integrator_learner.py b/adaptive/learner/integrator_learner.py index c5a3cf519..74aebe1ca 100644 --- a/adaptive/learner/integrator_learner.py +++ b/adaptive/learner/integrator_learner.py @@ -143,7 +143,7 @@ def __init__(self, a: int | float, b: int | float, depth: int, rdepth: int) -> N self.b = b self.depth = depth self.rdepth = rdepth - self.done_leaves: set[_Interval] = set() + self.done_leaves: set[_Interval] | None = set() self.depth_complete: int | None = None self.removed = False if TYPE_CHECKING: @@ -233,6 +233,7 @@ def calc_err(self, c_old: np.ndarray) -> float: return c_diff def calc_ndiv(self) -> None: + assert self.parent is not None div = self.parent.c00 and self.c00 / self.parent.c00 > 2 self.ndiv += int(div) @@ -282,7 +283,7 @@ def complete_process(self, depth: int) -> tuple[bool, bool] | tuple[bool, np.boo else: # Split self.c00 = self.c[0] - + assert self.parent is not None if self.parent.depth_complete is not None: c_old = ( self.T[:, : coeff.ns[self.parent.depth_complete]] @ self.parent.c @@ -310,7 +311,7 @@ def complete_process(self, depth: int) -> tuple[bool, bool] | tuple[bool, np.boo child for child in ival.children if child.done_leaves is not None ] - if not all(len(child.done_leaves) for child in unused_children): + if not all(len(child.done_leaves) for child in unused_children): # type: ignore[arg-type] break if ival.done_leaves is None: @@ -389,7 +390,6 @@ def __init__(self, function: Callable, bounds: tuple[int, int], tol: float) -> N ival = _Interval.make_first(*self.bounds) self.add_ival(ival) self.first_ival = ival - self._check_required_attributes() def new(self) -> IntegratorLearner: """Create a copy of `~adaptive.Learner2D` without the data.""" @@ -397,6 +397,7 @@ def new(self) -> IntegratorLearner: @property def approximating_intervals(self) -> set[_Interval]: + assert self.first_ival.done_leaves is not None return self.first_ival.done_leaves def tell(self, point: float, value: float) -> None: @@ -527,7 +528,7 @@ def _fill_stack(self) -> list[float]: return self._stack @property - def npoints(self) -> int: + def npoints(self) -> int: # type: ignore[override] """Number of evaluated points.""" return len(self.data) @@ -572,7 +573,7 @@ def to_numpy(self): """Data as NumPy array of size (npoints, 2).""" return np.array(sorted(self.data.items())) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -589,8 +590,6 @@ def to_dataframe( function_prefix : str, optional Prefix to the ``learner.function``'s default arguments' names, by default "function." - seed_name : str, optional - Name of the seed parameter, by default "seed" x_name : str, optional Name of the input value, by default "x" y_name : str, optional @@ -614,6 +613,35 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df + def load_dataframe( # type: ignore[override] + self, + df: pandas.DataFrame, + with_default_function_args: bool = True, + function_prefix: str = "function.", + x_name: str = "x", + y_name: str = "y", + ) -> None: + """Load data from a `pandas.DataFrame`. + + If ``with_default_function_args`` is True, then ``learner.function``'s + default arguments are set (using `functools.partial`) from the values + in the `pandas.DataFrame`. + + Parameters + ---------- + with_default_function_args : bool, optional + Include the ``learner.function``'s default arguments as a + column, by default True + function_prefix : str, optional + Prefix to the ``learner.function``'s default arguments' names, + by default "function." + x_name : str, optional + Name of the input value, by default "x" + y_name : str, optional + Name of the output value, by default "y" + """ + raise NotImplementedError + def _get_data(self): # Change the defaultdict of SortedSets to a normal dict of sets. x_mapping = {k: set(v) for k, v in self.x_mapping.items()} diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py index 3166c2532..38d7776e9 100644 --- a/adaptive/learner/learner1D.py +++ b/adaptive/learner/learner1D.py @@ -3,10 +3,9 @@ import collections.abc import itertools import math +import sys from copy import copy, deepcopy -from numbers import Integral as Int -from numbers import Real -from typing import Any, Callable, Dict, List, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Tuple, Union import cloudpickle import numpy as np @@ -17,19 +16,19 @@ from adaptive.learner.learnerND import volume from adaptive.learner.triangulation import simplex_volume_in_embedding from adaptive.notebook_integration import ensure_holoviews -from adaptive.types import Float +from adaptive.types import Float, Int, Real from adaptive.utils import ( assign_defaults, cache_latest, partial_function_from_dataframe, ) -try: +if sys.version_info >= (3, 10): from typing import TypeAlias -except ImportError: - # Remove this when we drop support for Python 3.9 +else: from typing_extensions import TypeAlias + try: import pandas @@ -38,24 +37,32 @@ except ModuleNotFoundError: with_pandas = False -# -- types -- - -# Commonly used types -Interval: TypeAlias = Union[Tuple[float, float], Tuple[float, float, int]] -NeighborsType: TypeAlias = Dict[float, List[Union[float, None]]] - -# Types for loss_per_interval functions -NoneFloat: TypeAlias = Union[Float, None] -NoneArray: TypeAlias = Union[np.ndarray, None] -XsType0: TypeAlias = Tuple[Float, Float] -YsType0: TypeAlias = Union[Tuple[Float, Float], Tuple[np.ndarray, np.ndarray]] -XsType1: TypeAlias = Tuple[NoneFloat, NoneFloat, NoneFloat, NoneFloat] -YsType1: TypeAlias = Union[ - Tuple[NoneFloat, NoneFloat, NoneFloat, NoneFloat], - Tuple[NoneArray, NoneArray, NoneArray, NoneArray], -] -XsTypeN: TypeAlias = Tuple[NoneFloat, ...] -YsTypeN: TypeAlias = Union[Tuple[NoneFloat, ...], Tuple[NoneArray, ...]] +if TYPE_CHECKING: + # -- types -- + + # Commonly used types + Interval: TypeAlias = Union[Tuple[float, float], Tuple[float, float, int]] + NeighborsType: TypeAlias = SortedDict[float, List[Optional[float]]] + + # Types for loss_per_interval functions + XsType0: TypeAlias = Tuple[float, float] + YsType0: TypeAlias = Union[Tuple[float, float], Tuple[np.ndarray, np.ndarray]] + XsType1: TypeAlias = Tuple[ + Optional[float], Optional[float], Optional[float], Optional[float] + ] + YsType1: TypeAlias = Union[ + Tuple[Optional[float], Optional[float], Optional[float], Optional[float]], + Tuple[ + Optional[np.ndarray], + Optional[np.ndarray], + Optional[np.ndarray], + Optional[np.ndarray], + ], + ] + XsTypeN: TypeAlias = Tuple[Optional[float], ...] + YsTypeN: TypeAlias = Union[ + Tuple[Optional[float], ...], Tuple[Optional[np.ndarray], ...] + ] __all__ = [ @@ -109,22 +116,22 @@ def default_loss(xs: XsType0, ys: YsType0) -> Float: @uses_nth_neighbors(0) def abs_min_log_loss(xs: XsType0, ys: YsType0) -> Float: """Calculate loss of a single interval that prioritizes the absolute minimum.""" - ys = tuple(np.log(np.abs(y).min()) for y in ys) - return default_loss(xs, ys) + ys_log: YsType0 = tuple(np.log(np.abs(y).min()) for y in ys) # type: ignore[assignment] + return default_loss(xs, ys_log) @uses_nth_neighbors(1) def triangle_loss(xs: XsType1, ys: YsType1) -> Float: assert len(xs) == 4 - xs = [x for x in xs if x is not None] - ys = [y for y in ys if y is not None] + xs = [x for x in xs if x is not None] # type: ignore[assignment] + ys = [y for y in ys if y is not None] # type: ignore[assignment] if len(xs) == 2: # we do not have enough points for a triangle - return xs[1] - xs[0] + return xs[1] - xs[0] # type: ignore[operator] N = len(xs) - 2 # number of constructed triangles if isinstance(ys[0], collections.abc.Iterable): - pts = [(x, *y) for x, y in zip(xs, ys)] + pts = [(x, *y) for x, y in zip(xs, ys)] # type: ignore[misc] vol = simplex_volume_in_embedding else: pts = [(x, y) for x, y in zip(xs, ys)] @@ -182,7 +189,7 @@ def curvature_loss(xs: XsType1, ys: YsType1) -> Float: triangle_loss_ = triangle_loss(xs, ys) default_loss_ = default_loss(xs_middle, ys_middle) - dx = xs_middle[1] - xs_middle[0] + dx = xs_middle[1] - xs_middle[0] # type: ignore[operator] return ( area_factor * (triangle_loss_**0.5) + euclid_factor * default_loss_ @@ -277,7 +284,9 @@ def __init__( ): self.function = function # type: ignore - if hasattr(loss_per_interval, "nth_neighbors"): + if loss_per_interval is not None and hasattr( + loss_per_interval, "nth_neighbors" + ): self.nth_neighbors = loss_per_interval.nth_neighbors else: self.nth_neighbors = 0 @@ -311,11 +320,10 @@ def __init__( # The precision in 'x' below which we set losses to 0. self._dx_eps = 2 * max(np.abs(bounds)) * np.finfo(float).eps - self.bounds = tuple(bounds) + self.bounds: tuple[float, float] = (float(bounds[0]), float(bounds[1])) self.__missing_bounds = set(self.bounds) # cache of missing bounds self._vdim: int | None = None - self._check_required_attributes() def new(self) -> Learner1D: """Create a copy of `~adaptive.Learner1D` without the data.""" @@ -347,7 +355,7 @@ def to_numpy(self): """ return np.array([(x, *np.atleast_1d(y)) for x, y in sorted(self.data.items())]) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -389,14 +397,14 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, function_prefix: str = "function.", x_name: str = "x", y_name: str = "y", - ): + ) -> None: """Load data from a `pandas.DataFrame`. If ``with_default_function_args`` is True, then ``learner.function``'s @@ -424,7 +432,7 @@ def load_dataframe( ) @property - def npoints(self) -> int: + def npoints(self) -> int: # type: ignore[override] """Number of evaluated points.""" return len(self.data) @@ -672,7 +680,7 @@ def tell_many( self.losses[ival] = self._get_loss_in_interval(*ival) # List with "real" intervals that have interpolated intervals inside - to_interpolate = [] + to_interpolate: list[tuple[Real, Real]] = [] self.losses_combined = loss_manager(self._scale[0]) for ival in intervals_combined: @@ -776,7 +784,9 @@ def _ask_points_without_adding(self, n: int) -> tuple[list[float], list[float]]: quals[(*xs, n + 1)] = loss_qual * n / (n + 1) points = list( - itertools.chain.from_iterable(linspace(*ival, n) for (*ival, n) in quals) + itertools.chain.from_iterable( + linspace(x_l, x_r, n) for (x_l, x_r, n) in quals + ) ) loss_improvements = list( @@ -864,7 +874,7 @@ def __setstate__(self, state): self.losses_combined.update(losses_combined) -def loss_manager(x_scale: float) -> dict[Interval, float]: +def loss_manager(x_scale: float) -> ItemSortedDict[Interval, float]: def sort_key(ival, loss): loss, ival = finite_loss(ival, loss, x_scale) return -loss, ival @@ -883,7 +893,7 @@ def finite_loss(ival: Interval, loss: float, x_scale: float) -> tuple[float, Int if len(ival) == 3: # Used when constructing quals. Last item is # the number of points inside the qual. - loss /= ival[2] + loss /= ival[2] # type: ignore[misc] # We round the loss to 12 digits such that losses # are equal up to numerical precision will be considered diff --git a/adaptive/learner/learner2D.py b/adaptive/learner/learner2D.py index 4ce4fd3b7..a1928e09a 100644 --- a/adaptive/learner/learner2D.py +++ b/adaptive/learner/learner2D.py @@ -376,9 +376,12 @@ def __init__( loss_per_triangle: Callable | None = None, ) -> None: self.ndim = len(bounds) - self._vdim = None + self._vdim: int | None = None self.loss_per_triangle = loss_per_triangle or default_loss - self.bounds = tuple((float(a), float(b)) for a, b in bounds) + self.bounds = ( + (float(bounds[0][0]), float(bounds[0][1])), + (float(bounds[1][0]), float(bounds[1][1])), + ) self.data = OrderedDict() self._stack = OrderedDict() self.pending_points = set() @@ -393,7 +396,6 @@ def __init__( self._ip = self._ip_combined = None self.stack_size = 10 - self._check_required_attributes() def new(self) -> Learner2D: return Learner2D(self.function, self.bounds, self.loss_per_triangle) @@ -414,7 +416,7 @@ def to_numpy(self): [(x, y, *np.atleast_1d(z)) for (x, y), z in sorted(self.data.items())] ) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -460,7 +462,7 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, @@ -507,7 +509,7 @@ def _unscale(self, points: np.ndarray) -> np.ndarray: return points * self.xy_scale + self.xy_mean @property - def npoints(self) -> int: + def npoints(self) -> int: # type: ignore[override] """Number of evaluated points.""" return len(self.data) @@ -534,7 +536,7 @@ def bounds_are_done(self) -> bool: ) def interpolated_on_grid( - self, n: int = None + self, n: int | None = None ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Get the interpolated data on a grid. @@ -655,7 +657,7 @@ def inside_bounds(self, xy: tuple[float, float]) -> Bool: return xmin <= x <= xmax and ymin <= y <= ymax def tell(self, point: tuple[float, float], value: float | Iterable[float]) -> None: - point = tuple(point) + point = tuple(point) # type: ignore[assignment] self.data[point] = value if not self.inside_bounds(point): return @@ -664,7 +666,7 @@ def tell(self, point: tuple[float, float], value: float | Iterable[float]) -> No self._stack.pop(point, None) def tell_pending(self, point: tuple[float, float]) -> None: - point = tuple(point) + point = tuple(point) # type: ignore[assignment] if not self.inside_bounds(point): return self.pending_points.add(point) diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index d9277dc4e..2c8a73e57 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -376,8 +376,6 @@ def __init__(self, func, bounds, loss_per_simplex=None): # _pop_highest_existing_simplex self._simplex_queue = SortedKeyList(key=_simplex_evaluation_priority) - self._check_required_attributes() - def new(self) -> LearnerND: """Create a new learner with the same function and bounds.""" return LearnerND(self.function, self.bounds, self.loss_per_simplex) @@ -409,7 +407,7 @@ def to_numpy(self): of ``learner.function``.""" return np.array([(*p, *np.atleast_1d(v)) for p, v in sorted(self.data.items())]) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -456,7 +454,7 @@ def to_dataframe( assign_defaults(self.function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, diff --git a/adaptive/learner/sequence_learner.py b/adaptive/learner/sequence_learner.py index 2ca804b40..8daa5d374 100644 --- a/adaptive/learner/sequence_learner.py +++ b/adaptive/learner/sequence_learner.py @@ -1,13 +1,14 @@ from __future__ import annotations +import sys from copy import copy -from numbers import Integral as Int from typing import Any, Tuple import cloudpickle from sortedcontainers import SortedDict, SortedSet from adaptive.learner.base_learner import BaseLearner +from adaptive.types import Int from adaptive.utils import ( assign_defaults, cache_latest, @@ -22,12 +23,11 @@ except ModuleNotFoundError: with_pandas = False -try: +if sys.version_info >= (3, 10): from typing import TypeAlias -except ImportError: +else: from typing_extensions import TypeAlias - PointType: TypeAlias = Tuple[Int, Any] @@ -92,7 +92,6 @@ def __init__(self, function, sequence): self.sequence = copy(sequence) self.data = SortedDict() self.pending_points = set() - self._check_required_attributes() def new(self) -> SequenceLearner: """Return a new `~adaptive.SequenceLearner` without the data.""" @@ -102,7 +101,7 @@ def ask( self, n: int, tell_pending: bool = True ) -> tuple[list[PointType], list[float]]: indices = [] - points = [] + points: list[PointType] = [] loss_improvements = [] for index in self._to_do_indices: if len(points) >= n: @@ -152,10 +151,10 @@ def result(self) -> list[Any]: return list(self.data.values()) @property - def npoints(self) -> int: + def npoints(self) -> int: # type: ignore[override] return len(self.data) - def to_dataframe( + def to_dataframe( # type: ignore[override] self, with_default_function_args: bool = True, function_prefix: str = "function.", @@ -202,7 +201,7 @@ def to_dataframe( assign_defaults(self._original_function, df, function_prefix) return df - def load_dataframe( + def load_dataframe( # type: ignore[override] self, df: pandas.DataFrame, with_default_function_args: bool = True, diff --git a/adaptive/learner/skopt_learner.py b/adaptive/learner/skopt_learner.py index e8902ad95..b1cb18840 100644 --- a/adaptive/learner/skopt_learner.py +++ b/adaptive/learner/skopt_learner.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +from typing import TYPE_CHECKING import numpy as np from skopt import Optimizer @@ -9,6 +10,9 @@ from adaptive.notebook_integration import ensure_holoviews from adaptive.utils import cache_latest +if TYPE_CHECKING: + import pandas + class SKOptLearner(Optimizer, BaseLearner): """Learn a function minimum using ``skopt.Optimizer``. @@ -122,3 +126,60 @@ def _get_data(self): def _set_data(self, data): xs, ys = data self.tell_many(xs, ys) + + def to_dataframe( # type: ignore[override] + self, + with_default_function_args: bool = True, + function_prefix: str = "function.", + seed_name: str = "seed", + y_name: str = "y", + ) -> pandas.DataFrame: + """Return the data as a `pandas.DataFrame`. + + Parameters + ---------- + with_default_function_args : bool, optional + Include the ``learner.function``'s default arguments as a + column, by default True + function_prefix : str, optional + Prefix to the ``learner.function``'s default arguments' names, + by default "function." + TODO + + Returns + ------- + pandas.DataFrame + + Raises + ------ + ImportError + If `pandas` is not installed. + """ + raise NotImplementedError + + def load_dataframe( # type: ignore[override] + self, + df: pandas.DataFrame, + with_default_function_args: bool = True, + function_prefix: str = "function.", + seed_name: str = "seed", + y_name: str = "y", + ): + """Load data from a `pandas.DataFrame`. + + If ``with_default_function_args`` is True, then ``learner.function``'s + default arguments are set (using `functools.partial`) from the values + in the `pandas.DataFrame`. + + Parameters + ---------- + df : pandas.DataFrame + The data to load. + with_default_function_args : bool, optional + The ``with_default_function_args`` used in ``to_dataframe()``, + by default True + function_prefix : str, optional + The ``function_prefix`` used in ``to_dataframe``, by default "function." + TODO + """ + raise NotImplementedError diff --git a/adaptive/notebook_integration.py b/adaptive/notebook_integration.py index 33ef01bec..165a84d82 100644 --- a/adaptive/notebook_integration.py +++ b/adaptive/notebook_integration.py @@ -87,14 +87,14 @@ def ensure_plotly(): def in_ipynb() -> bool: try: # If we are running in IPython, then `get_ipython()` is always a global - return get_ipython().__class__.__name__ == "ZMQInteractiveShell" + return get_ipython().__class__.__name__ == "ZMQInteractiveShell" # type: ignore[name-defined] except NameError: return False # Fancy displays in the Jupyter notebook -active_plotting_tasks = {} +active_plotting_tasks: dict[str, asyncio.Task] = {} def live_plot(runner, *, plotter=None, update_interval=2, name=None, normalize=True): diff --git a/adaptive/runner.py b/adaptive/runner.py index bd9d051b1..d8529113f 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -15,17 +15,17 @@ from contextlib import suppress from datetime import datetime, timedelta from importlib.util import find_spec -from typing import TYPE_CHECKING, Any, Callable, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union import loky from adaptive import ( BalancingLearner, - BaseLearner, DataSaver, IntegratorLearner, SequenceLearner, ) +from adaptive.learner.base_learner import LearnerType from adaptive.notebook_integration import in_ipynb, live_info, live_plot from adaptive.utils import SequentialExecutor @@ -40,14 +40,15 @@ if TYPE_CHECKING: import holoviews -try: + +if sys.version_info >= (3, 10): from typing import TypeAlias -except ImportError: +else: from typing_extensions import TypeAlias -try: +if sys.version_info >= (3, 8): from typing import Literal -except ImportError: +else: from typing_extensions import Literal @@ -56,26 +57,33 @@ with_mpi4py = find_spec("mpi4py") is not None if TYPE_CHECKING: + ExecutorTypes = Optional[()] + FutureTypes = Optional[()] + if with_distributed: import distributed - ExecutorTypes: TypeAlias = Union[ - ExecutorTypes, distributed.Client, distributed.cfexecutor.ClientExecutor + ExecutorTypes = Optional[ + Union[ + ExecutorTypes, distributed.Client, distributed.cfexecutor.ClientExecutor + ] ] if with_mpi4py: import mpi4py.futures - ExecutorTypes: TypeAlias = Union[ExecutorTypes, mpi4py.futures.MPIPoolExecutor] + ExecutorTypes = Optional[Union[ExecutorTypes, mpi4py.futures.MPIPoolExecutor]] if with_ipyparallel: import ipyparallel from ipyparallel.client.asyncresult import AsyncResult - ExecutorTypes: TypeAlias = Union[ - ExecutorTypes, ipyparallel.Client, ipyparallel.client.view.ViewExecutor + ExecutorTypes = Optional[ + Union[ + ExecutorTypes, ipyparallel.Client, ipyparallel.client.view.ViewExecutor + ] ] - FutureTypes: TypeAlias = Union[FutureTypes, AsyncResult] + FutureTypes = Optional[Union[FutureTypes, AsyncResult]] with suppress(ModuleNotFoundError): import uvloop @@ -84,9 +92,8 @@ # -- Runner definitions - if platform.system() == "Linux": - _default_executor = concurrent.ProcessPoolExecutor + _default_executor = concurrent.ProcessPoolExecutor # type: ignore[misc] else: # On Windows and MacOS functions, the __main__ module must be # importable by worker subprocesses. This means that @@ -94,7 +101,7 @@ # On Linux the whole process is forked, so the issue does not appear. # See https://docs.python.org/3/library/concurrent.futures.html#processpoolexecutor # and https://github.com/python-adaptive/adaptive/issues/301 - _default_executor = loky.get_reusable_executor + _default_executor = loky.get_reusable_executor # type: ignore[misc] class BaseRunner(metaclass=abc.ABCMeta): @@ -173,15 +180,15 @@ class BaseRunner(metaclass=abc.ABCMeta): def __init__( self, - learner: BaseLearner, - goal: Callable[[BaseLearner], bool] | None = None, + learner: LearnerType, + goal: Callable[[LearnerType], bool] | None = None, *, loss_goal: float | None = None, npoints_goal: int | None = None, end_time_goal: datetime | None = None, duration_goal: timedelta | int | float | None = None, executor: ExecutorTypes | None = None, - ntasks: int = None, + ntasks: int | None = None, log: bool = False, shutdown_executor: bool = False, retries: int = 0, @@ -222,7 +229,7 @@ def __init__( self._tracebacks: dict[int, str] = {} self._id_to_point: dict[int, Any] = {} - self._next_id: Callable[[], int] = functools.partial( + self._next_id: Callable[[], int] = functools.partial( # type: ignore[assignment] next, itertools.count() ) # some unique id to be associated with each point @@ -304,7 +311,7 @@ def _process_futures( self._tracebacks.pop(pid, None) x = self._id_to_point.pop(pid) if self.do_log: - self.log.append(("tell", x, y)) + self.log.append(("tell", x, y)) # type: ignore[union-attr] self.learner.tell(x, y) def _get_futures( @@ -316,7 +323,7 @@ def _get_futures( n_new_tasks = max(0, self._get_max_tasks() - len(self._pending_tasks)) if self.do_log: - self.log.append(("ask", n_new_tasks)) + self.log.append(("ask", n_new_tasks)) # type: ignore[union-attr] pids, _ = self._ask(n_new_tasks) @@ -461,8 +468,8 @@ class BlockingRunner(BaseRunner): def __init__( self, - learner: BaseLearner, - goal: Callable[[BaseLearner], bool] | None = None, + learner: LearnerType, + goal: Callable[[LearnerType], bool] | None = None, *, loss_goal: float | None = None, npoints_goal: int | None = None, @@ -620,8 +627,8 @@ class AsyncRunner(BaseRunner): def __init__( self, - learner: BaseLearner, - goal: Callable[[BaseLearner], bool] | None = None, + learner: LearnerType, + goal: Callable[[LearnerType], bool] | None = None, *, loss_goal: float | None = None, npoints_goal: int | None = None, @@ -667,7 +674,6 @@ def __init__( allow_running_forever=True, ) self.ioloop = ioloop or asyncio.get_event_loop() - self.task = None # When the learned function is 'async def', we run it # directly on the event loop, and not in the executor. @@ -724,9 +730,9 @@ def cancel(self) -> None: def live_plot( self, *, - plotter: Callable[[BaseLearner], holoviews.Element] | None = None, + plotter: Callable[[LearnerType], holoviews.Element] | None = None, update_interval: float = 2.0, - name: str = None, + name: str | None = None, normalize: bool = True, ) -> holoviews.DynamicMap: """Live plotting of the learner's data. @@ -777,7 +783,7 @@ async def _run(self) -> None: while not self.goal(self.learner): futures = self._get_futures() kw = {"loop": self.ioloop} if sys.version_info[:2] < (3, 10) else {} - done, _ = await asyncio.wait(futures, return_when=first_completed, **kw) + done, _ = await asyncio.wait(futures, return_when=first_completed, **kw) # type: ignore[arg-type] self._process_futures(done) finally: remaining = self._remove_unfinished() @@ -802,7 +808,7 @@ def start_periodic_saving( self, save_kwargs: dict[str, Any] | None = None, interval: int = 30, - method: Callable[[BaseLearner], None] | None = None, + method: Callable[[LearnerType], None] | None = None, ): """Periodically save the learner's data. @@ -850,8 +856,8 @@ async def _saver(): def simple( - learner: BaseLearner, - goal: Callable[[BaseLearner], bool] | None = None, + learner: LearnerType, + goal: Callable[[LearnerType], bool] | None = None, *, loss_goal: float | None = None, npoints_goal: int | None = None, @@ -902,6 +908,7 @@ def simple( duration_goal, allow_running_forever=False, ) + assert goal is not None while not goal(learner): xs, _ = learner.ask(1) for x in xs: @@ -910,7 +917,7 @@ def simple( def replay_log( - learner: BaseLearner, + learner: LearnerType, log: list[tuple[Literal["tell"], Any, Any] | tuple[Literal["ask"], int]], ) -> None: """Apply a sequence of method calls to a learner. @@ -967,9 +974,9 @@ def _get_ncores( elif isinstance( ex, (concurrent.ProcessPoolExecutor, concurrent.ThreadPoolExecutor) ): - return ex._max_workers # not public API! + return ex._max_workers # type: ignore[union-attr] elif isinstance(ex, loky.reusable_executor._ReusablePoolExecutor): - return ex._max_workers # not public API! + return ex._max_workers # type: ignore[union-attr] elif isinstance(ex, SequentialExecutor): return 1 elif with_distributed and isinstance(ex, distributed.cfexecutor.ClientExecutor): @@ -985,7 +992,7 @@ def _get_ncores( # TODO: deprecate -def stop_after(*, seconds=0, minutes=0, hours=0) -> Callable[[BaseLearner], bool]: +def stop_after(*, seconds=0, minutes=0, hours=0) -> Callable[[LearnerType], bool]: """Stop a runner after a specified time. For example, to specify a runner that should stop after @@ -1040,9 +1047,9 @@ def auto_goal( npoints: int | None = None, end_time: datetime | None = None, duration: timedelta | int | float | None = None, - learner: BaseLearner | None = None, + learner: LearnerType | None = None, allow_running_forever: bool = True, -) -> Callable[[BaseLearner], bool]: +) -> Callable[[LearnerType], bool]: """Extract a goal from the learners. Parameters @@ -1068,13 +1075,6 @@ def auto_goal( ------- Callable[[adaptive.BaseLearner], bool] """ - kw = { - "loss": loss, - "npoints": npoints, - "end_time": end_time, - "duration": duration, - "allow_running_forever": allow_running_forever, - } opts = (loss, npoints, end_time, duration) # all are mutually exclusive if sum(v is not None for v in opts) > 1: raise ValueError( @@ -1087,23 +1087,41 @@ def auto_goal( # Note that the float loss goal is more efficiently implemented in the # BalancingLearner itself. That is why the previous if statement is # above this one. - goals = [auto_goal(learner=lrn, **kw) for lrn in learner.learners] + goals = [ + auto_goal( + learner=lrn, + loss=loss, + npoints=npoints, + end_time=end_time, + duration=duration, + allow_running_forever=allow_running_forever, + ) + for lrn in learner.learners + ] return lambda learner: all( - goal(lrn) for lrn, goal in zip(learner.learners, goals) + goal(lrn) for lrn, goal in zip(learner.learners, goals) # type: ignore[attr-defined] ) if npoints is not None: - return lambda learner: learner.npoints >= npoints + return lambda learner: learner.npoints >= npoints # type: ignore[operator] if end_time is not None: return _TimeGoal(end_time) if duration is not None: return _TimeGoal(duration) if isinstance(learner, DataSaver): - return auto_goal(**kw, learner=learner.learner) + assert learner is not None + return auto_goal( + learner=learner.learner, + loss=loss, + npoints=npoints, + end_time=end_time, + duration=duration, + allow_running_forever=allow_running_forever, + ) if all(v is None for v in opts): if isinstance(learner, SequenceLearner): - return SequenceLearner.done + return SequenceLearner.done # type: ignore[return-value] if isinstance(learner, IntegratorLearner): - return IntegratorLearner.done + return IntegratorLearner.done # type: ignore[return-value] if not allow_running_forever: raise ValueError( "Goal is None which means the learners" @@ -1117,12 +1135,12 @@ def auto_goal( def _goal( - learner: BaseLearner | None, - goal: Callable[[BaseLearner], bool] | None, + learner: LearnerType | None, + goal: Callable[[LearnerType], bool] | None, loss_goal: float | None, npoints_goal: int | None, end_time_goal: datetime | None, - duration_goal: timedelta | None, + duration_goal: timedelta | int | float | None, allow_running_forever: bool, ): if callable(goal): diff --git a/adaptive/tests/algorithm_4.py b/adaptive/tests/algorithm_4.py index 180149ec2..b010b667a 100644 --- a/adaptive/tests/algorithm_4.py +++ b/adaptive/tests/algorithm_4.py @@ -1,18 +1,21 @@ # Copyright 2010 Pedro Gonnet # Copyright 2017 Christoph Groth +from __future__ import annotations from collections import defaultdict from fractions import Fraction -from typing import Callable, List, Tuple, Union +from typing import Callable import numpy as np from numpy.testing import assert_allclose from scipy.linalg import inv, norm +from adaptive.types import Real + eps = np.spacing(1) -def legendre(n: int) -> List[List[Fraction]]: +def legendre(n: int) -> list[list[Fraction]]: """Return the first n Legendre polynomials. The polynomials have *standard* normalization, i.e. @@ -52,7 +55,7 @@ def newton(n: int) -> np.ndarray: # monomial x^(n-d). mod = 2 * (n - 1) - terms = defaultdict(int) + terms: dict[tuple[int, int], int] = defaultdict(int) terms[0, 0] += 1 for i in range(n): @@ -90,7 +93,7 @@ def newton(n: int) -> np.ndarray: return cf -def scalar_product(a: List[Fraction], b: List[Fraction]) -> Fraction: +def scalar_product(a: list[Fraction], b: list[Fraction]) -> Fraction: """Compute the polynomial scalar product int_-1^1 dx a(x) b(x). The args must be sequences of polynomial coefficients. This @@ -108,10 +111,10 @@ def scalar_product(a: List[Fraction], b: List[Fraction]) -> Fraction: c[i + j] += a[j] * bi # Calculate the definite integral from -1 to 1. - return 2 * sum(c[i] / (i + 1) for i in range(0, lc, 2)) + return 2 * sum(c[i] / (i + 1) for i in range(0, lc, 2)) # type: ignore[return-value] -def calc_bdef(ns: Tuple[int, int, int, int]) -> List[np.ndarray]: +def calc_bdef(ns: tuple[int, int, int, int]) -> list[np.ndarray]: """Calculate the decompositions of Newton polynomials (over the nodes of the n-point Clenshaw-Curtis quadrature rule) in terms of Legandre polynomials. @@ -184,7 +187,7 @@ def calc_V(xi: np.ndarray, n: int) -> np.ndarray: gamma = np.concatenate([[0, 0], np.sqrt(k[2:] ** 2 / (4 * k[2:] ** 2 - 1))]) -def _downdate(c: np.ndarray, nans: List[int], depth: int) -> None: +def _downdate(c: np.ndarray, nans: list[int], depth: int) -> None: # This is algorithm 5 from the thesis of Pedro Gonnet. b = b_def[depth].copy() m = n[depth] - 1 @@ -201,7 +204,7 @@ def _downdate(c: np.ndarray, nans: List[int], depth: int) -> None: m -= 1 -def _zero_nans(fx: np.ndarray) -> List[int]: +def _zero_nans(fx: np.ndarray) -> list[int]: nans = [] for i in range(len(fx)): if not np.isfinite(fx[i]): @@ -231,9 +234,18 @@ def __init__(self, msg: str, igral: float, err: None, nr_points: int) -> None: class _Interval: __slots__ = ["a", "b", "c", "fx", "igral", "err", "depth", "rdepth", "ndiv", "c00"] - def __init__( - self, a: Union[int, float], b: Union[int, float], depth: int, rdepth: int - ) -> None: + a: Real + b: Real + c: np.ndarray + fx: np.ndarray + igral: Real + err: Real + depth: int + rdepth: int + ndiv: int + c00: Real + + def __init__(self, a: Real, b: Real, depth: int, rdepth: int) -> None: self.a = a self.b = b self.depth = depth @@ -247,7 +259,7 @@ def points(self) -> np.ndarray: @classmethod def make_first( cls, f: Callable, a: int, b: int, depth: int = 2 - ) -> Tuple["_Interval", int]: + ) -> tuple[_Interval, int]: ival = _Interval(a, b, depth, 1) fx = f(ival.points()) ival.c = _calc_coeffs(fx, depth) @@ -269,7 +281,7 @@ def calc_igral_and_err(self, c_old: np.ndarray) -> float: def split( self, f: Callable - ) -> Union[Tuple[Tuple[float, float, float], int], Tuple[List["_Interval"], int]]: + ) -> tuple[tuple[float, float, float], int] | tuple[list[_Interval], int]: m = (self.a + self.b) / 2 f_center = self.fx[(len(self.fx) - 1) // 2] @@ -287,14 +299,14 @@ def split( ival.calc_igral_and_err(T[:, : self.c.shape[0]] @ self.c) ival.c00 = ival.c[0] - ival.ndiv = self.ndiv + (self.c00 and ival.c00 / self.c00 > 2) + ival.ndiv = self.ndiv + (self.c00 and ival.c00 / self.c00 > 2) # type: ignore[assignment] if ival.ndiv > ndiv_max and 2 * ival.ndiv > ival.rdepth: # Signal a divergent integral. return (ival.a, ival.b, ival.b - ival.a), nr_points return ivals, nr_points - def refine(self, f: Callable) -> Tuple[np.ndarray, bool, int]: + def refine(self, f: Callable) -> tuple[np.ndarray, bool, int]: """Increase degree of interval.""" self.depth = depth = self.depth + 1 points = self.points() @@ -308,7 +320,7 @@ def refine(self, f: Callable) -> Tuple[np.ndarray, bool, int]: def algorithm_4( f: Callable, a: int, b: int, tol: float, N_loops: int = int(1e9) # noqa: B008 -) -> Tuple[float, float, int, List["_Interval"]]: +) -> tuple[float, float, int, list[_Interval]]: """ALGORITHM_4 evaluates an integral using adaptive quadrature. The algorithm uses Clenshaw-Curtis quadrature rules of increasing degree in each interval and bisects the interval if either the @@ -340,9 +352,9 @@ def algorithm_4( ival, nr_points = _Interval.make_first(f, a, b) ivals = [ival] - igral_excess = 0 - err_excess = 0 - i_max = 0 + igral_excess: float = 0 + err_excess: float = 0 + i_max: int = 0 for _ in range(N_loops): if ivals[i_max].depth == 3: @@ -415,7 +427,7 @@ def algorithm_4( # ############### Tests ################ -def f0(x: Union[float, np.ndarray]) -> Union[float, np.ndarray]: +def f0(x: float | np.ndarray) -> float | np.ndarray: return x * np.sin(1 / x) * np.sqrt(abs(1 - x)) @@ -423,20 +435,18 @@ def f7(x): return x**-0.5 -def f24(x: Union[float, np.ndarray]) -> Union[float, np.ndarray]: +def f24(x: float | np.ndarray) -> float | np.ndarray: return np.floor(np.exp(x)) -def f21(x: Union[float, np.ndarray]) -> Union[float, np.ndarray]: +def f21(x: float | np.ndarray) -> float | np.ndarray: y = 0 for i in range(1, 4): y += 1 / np.cosh(20**i * (x - 2 * i / 10)) return y -def f63( - x: Union[float, np.ndarray], alpha: float, beta: float -) -> Union[float, np.ndarray]: +def f63(x: float | np.ndarray, alpha: float, beta: float) -> float | np.ndarray: return abs(x - beta) ** alpha @@ -444,7 +454,7 @@ def F63(x, alpha, beta): return (x - beta) * abs(x - beta) ** alpha / (alpha + 1) -def fdiv(x: Union[float, np.ndarray]) -> Union[float, np.ndarray]: +def fdiv(x: float | np.ndarray) -> float | np.ndarray: return abs(x - 0.987654321) ** -1.1 diff --git a/adaptive/tests/test_average_learner.py b/adaptive/tests/test_average_learner.py index d0176858e..d94933397 100644 --- a/adaptive/tests/test_average_learner.py +++ b/adaptive/tests/test_average_learner.py @@ -1,4 +1,5 @@ import random +from typing import TYPE_CHECKING import flaky import numpy as np @@ -6,6 +7,9 @@ from adaptive.learner import AverageLearner from adaptive.runner import simple +if TYPE_CHECKING: + pass + def f_unused(seed): raise NotImplementedError("This function shouldn't be used.") diff --git a/adaptive/tests/test_average_learner1d.py b/adaptive/tests/test_average_learner1d.py index d76a034c7..c0148c5e9 100644 --- a/adaptive/tests/test_average_learner1d.py +++ b/adaptive/tests/test_average_learner1d.py @@ -1,4 +1,5 @@ from itertools import chain +from typing import TYPE_CHECKING import numpy as np @@ -9,6 +10,9 @@ simple_run, ) +if TYPE_CHECKING: + pass + def almost_equal_dicts(a, b): assert a.keys() == b.keys() diff --git a/adaptive/tests/test_balancing_learner.py b/adaptive/tests/test_balancing_learner.py index 72b1bc8f3..905a55e0c 100644 --- a/adaptive/tests/test_balancing_learner.py +++ b/adaptive/tests/test_balancing_learner.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from adaptive.learner import BalancingLearner, Learner1D diff --git a/adaptive/tests/test_learner1d.py b/adaptive/tests/test_learner1d.py index 9f211e39d..7dafbd3ab 100644 --- a/adaptive/tests/test_learner1d.py +++ b/adaptive/tests/test_learner1d.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import random import time +from typing import TYPE_CHECKING import flaky import numpy as np @@ -8,6 +11,9 @@ from adaptive.learner.learner1D import curvature_loss_function from adaptive.runner import BlockingRunner, simple +if TYPE_CHECKING: + pass + def flat_middle(x): x *= 1e7 diff --git a/adaptive/tests/test_learners.py b/adaptive/tests/test_learners.py index 6f3ee916e..17af7f9b8 100644 --- a/adaptive/tests/test_learners.py +++ b/adaptive/tests/test_learners.py @@ -34,7 +34,7 @@ from adaptive.learner.skopt_learner import SKOptLearner except (ModuleNotFoundError, ImportError): # XXX: catch the ImportError because of https://github.com/scikit-optimize/scikit-optimize/issues/902 - SKOptLearner = None + SKOptLearner = None # type: ignore[assignment,misc] LOSS_FUNCTIONS = { @@ -132,13 +132,13 @@ def maybe_skip(learner): @learn_with(Learner1D, bounds=(-1, 1)) -def quadratic(x, m: uniform(1, 4), b: uniform(0, 1)): +def quadratic(x, m: uniform(1, 4), b: uniform(0, 1)): # type: ignore[valid-type] return m * x**2 + b @learn_with(Learner1D, bounds=(-1, 1)) @learn_with(SequenceLearner, sequence=np.linspace(-1, 1, 201)) -def linear_with_peak(x, d: uniform(-1, 1)): +def linear_with_peak(x, d: uniform(-1, 1)): # type: ignore[valid-type] a = 0.01 return x + a**2 / (a**2 + (x - d) ** 2) @@ -146,7 +146,7 @@ def linear_with_peak(x, d: uniform(-1, 1)): @learn_with(LearnerND, bounds=((-1, 1), (-1, 1))) @learn_with(Learner2D, bounds=((-1, 1), (-1, 1))) @learn_with(SequenceLearner, sequence=np.random.rand(1000, 2)) -def ring_of_fire(xy, d: uniform(0.2, 1)): +def ring_of_fire(xy, d: uniform(0.2, 1)): # type: ignore[valid-type] a = 0.2 x, y = xy return x + math.exp(-((x**2 + y**2 - d**2) ** 2) / a**4) @@ -154,7 +154,7 @@ def ring_of_fire(xy, d: uniform(0.2, 1)): @learn_with(LearnerND, bounds=((-1, 1), (-1, 1), (-1, 1))) @learn_with(SequenceLearner, sequence=np.random.rand(1000, 3)) -def sphere_of_fire(xyz, d: uniform(0.2, 0.5)): +def sphere_of_fire(xyz, d: uniform(0.2, 0.5)): # type: ignore[valid-type] a = 0.2 x, y, z = xyz return x + math.exp(-((x**2 + y**2 + z**2 - d**2) ** 2) / a**4) + z**2 @@ -162,16 +162,16 @@ def sphere_of_fire(xyz, d: uniform(0.2, 0.5)): @learn_with(SequenceLearner, sequence=range(1000)) @learn_with(AverageLearner, rtol=1) -def gaussian(n): +def gaussian(n): # type: ignore[valid-type] return random.gauss(1, 1) @learn_with(AverageLearner1D, bounds=(-2, 2)) -def noisy_peak( +def noisy_peak( # type: ignore[valid-type] seed_x, - sigma: uniform(1.5, 2.5), - peak_width: uniform(0.04, 0.06), - offset: uniform(-0.6, -0.3), + sigma: uniform(1.5, 2.5), # type: ignore[valid-type] + peak_width: uniform(0.04, 0.06), # type: ignore[valid-type] + offset: uniform(-0.6, -0.3), # type: ignore[valid-type] ): seed, x = seed_x y = x**3 - x + 3 * peak_width**2 / (peak_width**2 + (x - offset) ** 2) diff --git a/adaptive/tests/test_notebook_integration.py b/adaptive/tests/test_notebook_integration.py index 9b3edcad7..3e4ddb298 100644 --- a/adaptive/tests/test_notebook_integration.py +++ b/adaptive/tests/test_notebook_integration.py @@ -1,8 +1,13 @@ +from __future__ import annotations + import os import sys +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + pass try: import ipykernel.iostream import zmq diff --git a/adaptive/types.py b/adaptive/types.py index a49b332a6..8f908e087 100644 --- a/adaptive/types.py +++ b/adaptive/types.py @@ -1,17 +1,17 @@ -from numbers import Integral as Int -from numbers import Real +import sys from typing import Union import numpy as np -try: +if sys.version_info >= (3, 10): from typing import TypeAlias -except ImportError: - # Remove this when we drop support for Python 3.9 +else: from typing_extensions import TypeAlias Float: TypeAlias = Union[float, np.float_] Bool: TypeAlias = Union[bool, np.bool_] +Int: TypeAlias = Union[int, np.int_] +Real: TypeAlias = Union[Float, Int] __all__ = ["Float", "Bool", "Int", "Real"] diff --git a/adaptive/utils.py b/adaptive/utils.py index 1385fe997..3300ef893 100644 --- a/adaptive/utils.py +++ b/adaptive/utils.py @@ -7,21 +7,21 @@ import os import pickle import warnings -from contextlib import _GeneratorContextManager, contextmanager +from contextlib import contextmanager from itertools import product -from typing import Any, Callable, Mapping, Sequence +from typing import Any, Callable, Iterator, Sequence import cloudpickle -def named_product(**items: Mapping[str, Sequence[Any]]): +def named_product(**items: Sequence[Any]): names = items.keys() vals = items.values() return [dict(zip(names, res)) for res in product(*vals)] @contextmanager -def restore(*learners) -> _GeneratorContextManager: +def restore(*learners) -> Iterator[None]: states = [learner.__getstate__() for learner in learners] try: yield @@ -77,7 +77,7 @@ def save(fname: str, data: Any, compress: bool = True) -> bool: def load(fname: str, compress: bool = True) -> Any: fname = os.path.expanduser(fname) _open = gzip.open if compress else open - with _open(fname, "rb") as f: + with _open(fname, "rb") as f: # type: ignore[operator] return cloudpickle.load(f) diff --git a/docs/source/tutorial/tutorial.Learner2D.md b/docs/source/tutorial/tutorial.Learner2D.md index babc27c39..43e5bdeff 100644 --- a/docs/source/tutorial/tutorial.Learner2D.md +++ b/docs/source/tutorial/tutorial.Learner2D.md @@ -78,7 +78,7 @@ import itertools # Create a learner and add data on homogeneous grid, so that we can plot it learner2 = adaptive.Learner2D(ring, bounds=learner.bounds) n = int(learner.npoints**0.5) -xs, ys = [np.linspace(*bounds, n) for bounds in learner.bounds] +xs, ys = (np.linspace(*bounds, n) for bounds in learner.bounds) xys = list(itertools.product(xs, ys)) learner2.tell_many(xys, map(partial(ring, wait=False), xys)) diff --git a/pyproject.toml b/pyproject.toml index e8db33f21..1bb5569d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,18 +30,21 @@ dependencies = [ [project.optional-dependencies] other = [ + "ipython; python_version > '3.8'", + "ipython<8.13; python_version <= '3.8'", # because https://github.com/ipython/ipython/issues/14053 "dill", "distributed", - "ipyparallel>=6.2.5", # because of https://github.com/ipython/ipyparallel/issues/404 - "scikit-optimize>=0.8.1", # because of https://github.com/scikit-optimize/scikit-optimize/issues/931 + "ipyparallel>=6.2.5", # because of https://github.com/ipython/ipyparallel/issues/404 + "scikit-optimize>=0.8.1", # because of https://github.com/scikit-optimize/scikit-optimize/issues/931 "scikit-learn", "wexpect; os_name == 'nt'", "pexpect; os_name != 'nt'", ] notebook = [ - "ipython", - "ipykernel>=4.8.0", # because https://github.com/ipython/ipykernel/issues/274 and https://github.com/ipython/ipykernel/issues/263 - "jupyter_client>=5.2.2", # because https://github.com/jupyter/jupyter_client/pull/314 + "ipython; python_version > '3.8'", + "ipython<8.13; python_version <= '3.8'", # because https://github.com/ipython/ipython/issues/14053 + "ipykernel>=4.8.0", # because https://github.com/ipython/ipykernel/issues/274 and https://github.com/ipython/ipykernel/issues/263 + "jupyter_client>=5.2.2", # because https://github.com/jupyter/jupyter_client/pull/314 "holoviews>=1.9.1", "ipywidgets", "bokeh", @@ -98,7 +101,7 @@ python_version = "3.7" [tool.ruff] line-length = 150 target-version = "py37" -select = ["B", "C", "E", "F", "W", "T", "B9", "I"] +select = ["B", "C", "E", "F", "W", "T", "B9", "I", "UP"] ignore = [ "T20", # flake8-print "ANN101", # Missing type annotation for {name} in method From 6795c067ad68b0388dddbdaa8ef3ec1fd6b303e4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 28 Apr 2023 21:28:07 -0700 Subject: [PATCH 13/37] Disable typeguard CI pipeline (#415) --- .github/workflows/typeguard.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/typeguard.yml b/.github/workflows/typeguard.yml index a79f23856..778ef8d90 100644 --- a/.github/workflows/typeguard.yml +++ b/.github/workflows/typeguard.yml @@ -1,7 +1,8 @@ name: typeguard -on: - - push +# TODO: enable this once typeguard=4 is released and issues are fixed. +# on: +# - push jobs: typeguard: From 429c4bd670dbe0d727bcf9587807e0c817dc6dc6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 28 Apr 2023 23:05:03 -0700 Subject: [PATCH 14/37] Update GitHub Actions CI (#416) * Update GitHub Actions CI * Use 3.11 for coverage --- .github/workflows/coverage.yml | 6 +++--- .github/workflows/nox.yml | 13 ++++--------- .github/workflows/pre-commit.yml | 14 -------------- .github/workflows/pythonpublish.yml | 4 ++-- .github/workflows/typeguard.yml | 4 ++-- noxfile.py | 2 +- 6 files changed, 12 insertions(+), 31 deletions(-) delete mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c885edfcd..f39544953 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,11 +7,11 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Install dependencies run: pip install nox - name: Test with nox diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index 1e09d0c04..b57973e88 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -15,21 +15,16 @@ jobs: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Set Python version for nox - run: echo "PY_VERSION=$(echo ${version})" >> $GITHUB_ENV - shell: bash - env: - version: ${{ matrix.python-version }} - name: Register Python problem matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Install dependencies run: pip install nox pytest-github-actions-annotate-failures - name: Test with nox using minimal dependencies - run: nox -e "pytest-${{ env.PY_VERSION }}(all_deps=False)" + run: nox -e "pytest-${{ matrix.python-version }}(all_deps=False)" - name: Test with nox with all dependencies - run: nox -e "pytest-${{ env.PY_VERSION }}(all_deps=True)" + run: nox -e "pytest-${{ matrix.python-version }}(all_deps=True)" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index b52d4afe5..000000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [main] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.0 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index a810fb9e2..6ccfc7898 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/typeguard.yml b/.github/workflows/typeguard.yml index 778ef8d90..d3c1da10e 100644 --- a/.github/workflows/typeguard.yml +++ b/.github/workflows/typeguard.yml @@ -8,9 +8,9 @@ jobs: typeguard: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.11" - name: Install dependencies diff --git a/noxfile.py b/noxfile.py index b4fcfe553..73f8a769e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -16,7 +16,7 @@ def pytest_typeguard(session): session.run("pytest", "--typeguard-packages=adaptive") -@nox.session(python="3.7") +@nox.session(python="3.11") def coverage(session): session.install("coverage") session.install(".[testing,other]") From 0936a9327f5ff4da4fb7c96629b46459e7486c71 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 29 Apr 2023 01:24:03 -0700 Subject: [PATCH 15/37] Rewrite parts of README, reorder sections, and add features section (#400) * Update README * Fix emojis * rename tags * fix emojis * Fix * add Tutorial emoji * Add extra text to examples * no zoom on scroll --- .github/workflows/toc.yaml | 10 ++ AUTHORS.md | 2 +- CHANGELOG.md | 2 +- README.md | 146 +++++++++++++++++-------- docs/environment.yml | 1 + docs/source/_static/logo_docs.png | Bin 102226 -> 100501 bytes docs/source/algorithms_and_examples.md | 33 ++++-- docs/source/conf.py | 32 +++++- docs/source/docs.md | 11 +- docs/source/faq.md | 2 +- docs/source/gallery.md | 2 +- docs/source/index.md | 20 ++-- docs/source/reference/adaptive.md | 2 +- docs/source/tutorial/tutorial.md | 2 +- 14 files changed, 185 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/toc.yaml diff --git a/.github/workflows/toc.yaml b/.github/workflows/toc.yaml new file mode 100644 index 000000000..28dac9125 --- /dev/null +++ b/.github/workflows/toc.yaml @@ -0,0 +1,10 @@ +on: push +name: TOC Generator +jobs: + generateTOC: + name: TOC Generator + runs-on: ubuntu-latest + steps: + - uses: technote-space/toc-generator@v4 + with: + TOC_TITLE: "" diff --git a/AUTHORS.md b/AUTHORS.md index 69a2ba67e..1624101b1 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,4 +1,4 @@ -## Authors +## 👥 Authors The current maintainers of Adaptive are: diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6d0d4b1..2559cd46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Changelog +# 🗞️ Changelog ## [v0.15.0](https://github.com/python-adaptive/adaptive/tree/v0.15.0) (2022-11-30) diff --git a/README.md b/README.md index e414e1e1c..8510b7ca1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ - -# ![logo](https://adaptive.readthedocs.io/en/latest/_static/logo.png) adaptive +# ![logo](https://adaptive.readthedocs.io/en/latest/_static/logo.png) *Adaptive*: Parallel Active Learning of Mathematical Functions :brain::1234: + [![Binder](https://mybinder.org/badge.svg)](https://mybinder.org/v2/gh/python-adaptive/adaptive/main?filepath=example-notebook.ipynb) [![Conda](https://img.shields.io/badge/install%20with-conda-green.svg)](https://anaconda.org/conda-forge/adaptive) @@ -13,56 +13,57 @@ [![Pipeline-status](https://dev.azure.com/python-adaptive/adaptive/_apis/build/status/python-adaptive.adaptive?branchName=main)](https://dev.azure.com/python-adaptive/adaptive/_build/latest?definitionId=6?branchName=main) [![PyPI](https://img.shields.io/pypi/v/adaptive.svg)](https://pypi.python.org/pypi/adaptive) -> *Adaptive*: parallel active learning of mathematical functions. - -`adaptive` is an open-source Python library designed to make adaptive parallel function evaluation simple. With `adaptive` you just supply a function with its bounds, and it will be evaluated at the “best” points in parameter space, rather than unnecessarily computing *all* points on a dense grid. -With just a few lines of code you can evaluate functions on a computing cluster, live-plot the data as it returns, and fine-tune the adaptive sampling algorithm. +Adaptive is an open-source Python library that streamlines adaptive parallel function evaluations. +Rather than calculating all points on a dense grid, it intelligently selects the "best" points in the parameter space based on your provided function and bounds. +With minimal code, you can perform evaluations on a computing cluster, display live plots, and optimize the adaptive sampling algorithm. -`adaptive` excels on computations where each function evaluation takes *at least* ≈50ms due to the overhead of picking potentially interesting points. +Adaptive is most efficient for computations where each function evaluation takes at least ≈50ms due to the overhead of selecting potentially interesting points. -Run the `adaptive` example notebook [live on Binder](https://mybinder.org/v2/gh/python-adaptive/adaptive/main?filepath=example-notebook.ipynb) to see examples of how to use `adaptive` or visit the [tutorial on Read the Docs](https://adaptive.readthedocs.io/en/latest/tutorial/tutorial.html). +To see Adaptive in action, try the [example notebook on Binder](https://mybinder.org/v2/gh/python-adaptive/adaptive/main?filepath=example-notebook.ipynb) or explore the [tutorial on Read the Docs](https://adaptive.readthedocs.io/en/latest/tutorial/tutorial.html). -## Implemented algorithms +
[ToC] 📚 -The core concept in `adaptive` is that of a *learner*. -A *learner* samples a function at the best places in its parameter space to get maximum “information” about the function. -As it evaluates the function at more and more points in the parameter space, it gets a better idea of where the best places are to sample next. + + -Of course, what qualifies as the “best places” will depend on your application domain! `adaptive` makes some reasonable default choices, but the details of the adaptive sampling are completely customizable. +- [:star: Key features](#star-key-features) +- [:rocket: Example usage](#rocket-example-usage) + - [:floppy_disk: Exporting Data](#floppy_disk-exporting-data) +- [:test_tube: Implemented Algorithms](#test_tube-implemented-algorithms) +- [:package: Installation](#package-installation) +- [:wrench: Development](#wrench-development) +- [:books: Citing](#books-citing) +- [:page_facing_up: Draft Paper](#page_facing_up-draft-paper) +- [:sparkles: Credits](#sparkles-credits) -The following learners are implemented: + - +
-- `Learner1D`, for 1D functions `f: ℝ → ℝ^N`, -- `Learner2D`, for 2D functions `f: ℝ^2 → ℝ^N`, -- `LearnerND`, for ND functions `f: ℝ^N → ℝ^M`, -- `AverageLearner`, for random variables where you want to average the result over many evaluations, -- `AverageLearner1D`, for stochastic 1D functions where you want to estimate the mean value of the function at each point, -- `IntegratorLearner`, for when you want to intergrate a 1D function `f: ℝ → ℝ`. -- `BalancingLearner`, for when you want to run several learners at once, selecting the “best” one each time you get more points. + -Meta-learners (to be used with other learners): +## :star: Key features -- `BalancingLearner`, for when you want to run several learners at once, selecting the “best” one each time you get more points, -- `DataSaver`, for when your function doesn't just return a scalar or a vector. +- 🎯 **Intelligent Adaptive Sampling**: Adaptive focuses on areas of interest within a function, ensuring better results with fewer evaluations, saving time, and computational resources. +- ⚡ **Parallel Execution**: The library leverages parallel processing for faster function evaluations, making optimal use of available computational resources. +- 📊 **Live Plotting and Info Widgets**: When working in Jupyter notebooks, Adaptive offers real-time visualization of the learning process, making it easier to monitor progress and identify areas of improvement. +- 🔧 **Customizable Loss Functions**: Adaptive supports various loss functions and allows customization, enabling users to tailor the learning process according to their specific needs. +- 📈 **Support for Multidimensional Functions**: The library can handle functions with scalar or vector outputs in one or multiple dimensions, providing flexibility for a wide range of problems. +- 🧩 **Seamless Integration**: Adaptive offers a simple and intuitive interface, making it easy to integrate with existing Python projects and workflows. +- 💾 **Flexible Data Export**: The library provides options to export learned data as NumPy arrays or Pandas DataFrames, ensuring compatibility with various data processing tools. +- 🌐 **Open-Source and Community-Driven**: Adaptive is an open-source project, encouraging contributions from the community to continuously improve and expand the library's features and capabilities. -In addition to the learners, `adaptive` also provides primitives for running the sampling across several cores and even several machines, with built-in support for -[concurrent.futures](https://docs.python.org/3/library/concurrent.futures.html), -[mpi4py](https://mpi4py.readthedocs.io/en/stable/mpi4py.futures.html), -[loky](https://loky.readthedocs.io/en/stable/), -[ipyparallel](https://ipyparallel.readthedocs.io/en/latest/), and -[distributed](https://distributed.readthedocs.io/en/latest/). + -## Examples +## :rocket: Example usage -Adaptively learning a 1D function (the `gif` below) and live-plotting the process in a Jupyter notebook is as easy as +Adaptively learning a 1D function and live-plotting the process in a Jupyter notebook: ```python from adaptive import notebook_extension, Runner, Learner1D @@ -82,9 +83,58 @@ runner.live_plot() - +### :floppy_disk: Exporting Data + +You can export the learned data as a NumPy array: + +```python +data = learner.to_numpy() +``` + +If you have Pandas installed, you can also export the data as a DataFrame: + +```python +df = learner.to_dataframe() +``` + + + +## :test_tube: Implemented Algorithms -## Installation +The core concept in `adaptive` is the *learner*. +A *learner* samples a function at the most interesting locations within its parameter space, allowing for optimal sampling of the function. +As the function is evaluated at more points, the learner improves its understanding of the best locations to sample next. + +The definition of the "best locations" depends on your application domain. +While `adaptive` provides sensible default choices, the adaptive sampling process can be fully customized. + +The following learners are implemented: + + + +- `Learner1D`: for 1D functions `f: ℝ → ℝ^N`, +- `Learner2D`: for 2D functions `f: ℝ^2 → ℝ^N`, +- `LearnerND`: for ND functions `f: ℝ^N → ℝ^M`, +- `AverageLearner`: for random variables, allowing averaging of results over multiple evaluations, +- `AverageLearner1D`: for stochastic 1D functions, estimating the mean value at each point, +- `IntegratorLearner`: for integrating a 1D function `f: ℝ → ℝ`, +- `BalancingLearner`: for running multiple learners simultaneously and selecting the "best" one as more points are gathered. + +Meta-learners (to be used with other learners): + +- `BalancingLearner`: for running several learners at once, selecting the "most optimal" one each time you get more points, +- `DataSaver`: for when your function doesn't return just a scalar or a vector. + +In addition to learners, `adaptive` offers primitives for parallel sampling across multiple cores or machines, with built-in support for: +[concurrent.futures](https://docs.python.org/3/library/concurrent.futures.html), +[mpi4py](https://mpi4py.readthedocs.io/en/stable/mpi4py.futures.html), +[loky](https://loky.readthedocs.io/en/stable/), +[ipyparallel](https://ipyparallel.readthedocs.io/en/latest/), and +[distributed](https://distributed.readthedocs.io/en/latest/). + + + +## :package: Installation `adaptive` works with Python 3.7 and higher on Linux, Windows, or Mac, and provides optional extensions for working with the Jupyter/IPython Notebook. @@ -109,7 +159,7 @@ jupyter labextension install @jupyter-widgets/jupyterlab-manager jupyter labextension install @pyviz/jupyterlab_pyviz ``` -## Development +## :wrench: Development Clone the repository and run `pip install -e ".[notebook,testing,other]"` to add a link to the cloned repo into your Python path: @@ -119,9 +169,9 @@ cd adaptive pip install -e ".[notebook,testing,other]" ``` -We highly recommend using a Conda environment or a virtualenv to manage the versions of your installed packages while working on `adaptive`. +We recommend using a Conda environment or a virtualenv for package management during Adaptive development. -In order to not pollute the history with the output of the notebooks, please setup the git filter by executing +To avoid polluting the history with notebook output, set up the git filter by running: ```bash python ipynb_filter.py @@ -129,7 +179,7 @@ python ipynb_filter.py in the repository. -We implement several other checks in order to maintain a consistent code style. We do this using [pre-commit](https://pre-commit.com), execute +To maintain consistent code style, we use [pre-commit](https://pre-commit.com). Install it by running: ```bash pre-commit install @@ -137,7 +187,7 @@ pre-commit install in the repository. -## Citing +## :books: Citing If you used Adaptive in a scientific work, please cite it as follows. @@ -151,17 +201,19 @@ If you used Adaptive in a scientific work, please cite it as follows. } ``` -## Credits +## :page_facing_up: Draft Paper + +If you're interested in the scientific background and principles behind Adaptive, we recommend taking a look at the [draft paper](https://github.com/python-adaptive/paper) that is currently being written. +This paper provides a comprehensive overview of the concepts, algorithms, and applications of the Adaptive library. + +## :sparkles: Credits We would like to give credits to the following people: - Pedro Gonnet for his implementation of [CQUAD](https://www.gnu.org/software/gsl/manual/html_node/CQUAD-doubly_002dadaptive-integration.html), “Algorithm 4” as described in “Increasing the Reliability of Adaptive Quadrature Using Explicit Interpolants”, P. Gonnet, ACM Transactions on Mathematical Software, 37 (3), art. no. 26, 2010. - Pauli Virtanen for his `AdaptiveTriSampling` script (no longer available online since SciPy Central went down) which served as inspiration for the `adaptive.Learner2D`. - - -For general discussion, we have a [Gitter chat channel](https://gitter.im/python-adaptive/adaptive). If you find any bugs or have any feature suggestions please file a GitHub [issue](https://github.com/python-adaptive/adaptive/issues/new) or submit a [pull request](https://github.com/python-adaptive/adaptive/pulls). - - + - +For general discussion, we have a [Gitter chat channel](https://gitter.im/python-adaptive/adaptive). +If you find any bugs or have any feature suggestions please file a GitHub [issue](https://github.com/python-adaptive/adaptive/issues/new) or submit a [pull request](https://github.com/python-adaptive/adaptive/pulls). diff --git a/docs/environment.yml b/docs/environment.yml index 8328db147..ade7f65ee 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -24,3 +24,4 @@ dependencies: - furo=2023.3.27 - myst-parser=0.18.1 - dask=2023.3.2 + - emoji=2.2.0 diff --git a/docs/source/_static/logo_docs.png b/docs/source/_static/logo_docs.png index ffaa94a18f342bbd346ae3acd9118636a13ef646..dfc5d94bdb0c0ff47f7ce017cd081c4c7a446587 100644 GIT binary patch literal 100501 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 literal 102226 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 diff --git a/docs/source/algorithms_and_examples.md b/docs/source/algorithms_and_examples.md index f517b504b..eda3c2fff 100644 --- a/docs/source/algorithms_and_examples.md +++ b/docs/source/algorithms_and_examples.md @@ -10,10 +10,10 @@ kernelspec: name: python3 --- -```{include} ../../README.md +```{include} ../README.md --- -start-after: -end-before: +start-after: +end-before: --- ``` @@ -37,7 +37,7 @@ In addition to the learners, `adaptive` also provides primitives for running the [ipyparallel](https://ipyparallel.readthedocs.io/en/latest/), and [distributed](https://distributed.readthedocs.io/en/latest/). -# Examples +# 💡 Examples Here are some examples of how Adaptive samples vs. homogeneous sampling. Click on the *Play* {fa}`play` button or move the sliders. @@ -59,6 +59,9 @@ hv.output(holomap="scrubber") ## {class}`adaptive.Learner1D` +The `Learner1D` class is designed for adaptively learning 1D functions of the form `f: ℝ → ℝ^N`. It focuses on sampling points where the function is less well understood to improve the overall approximation. +This learner is well-suited for functions with localized features or varying degrees of complexity across the domain. + Adaptively learning a 1D function (the plot below) and live-plotting the process in a Jupyter notebook is as easy as ```python @@ -86,6 +89,11 @@ runner.live_plot() ```{code-cell} ipython3 :tags: [hide-input] +from bokeh.models import WheelZoomTool + +wheel_zoom = WheelZoomTool(zoom_on_axis=False) + + def f(x, offset=0.07357338543088588): a = 0.01 return x + a**2 / (a**2 + (x - offset) ** 2) @@ -115,11 +123,14 @@ def get_hm(loss_per_interval, N=101): plot_homo = get_hm(uniform_loss).relabel("homogeneous sampling") plot_adaptive = get_hm(default_loss).relabel("with adaptive") layout = plot_homo + plot_adaptive -layout.opts(toolbar=None) +layout.opts(hv.opts.Scatter(active_tools=["box_zoom", wheel_zoom])) ``` ## {class}`adaptive.Learner2D` +The `Learner2D` class is tailored for adaptively learning 2D functions of the form `f: ℝ^2 → ℝ^N`. Similar to `Learner1D`, it concentrates on sampling points with higher uncertainty to provide a better approximation. +This learner is ideal for functions with complex features or varying behavior across a 2D domain. + ```{code-cell} ipython3 :tags: [hide-input] @@ -147,11 +158,15 @@ def plot_compare(learner, npoints): learner = adaptive.Learner2D(ring, bounds=[(-1, 1), (-1, 1)]) plots = {n: plot_compare(learner, n) for n in range(4, 1010, 20)} -hv.HoloMap(plots, kdims=["npoints"]).collate() +plot = hv.HoloMap(plots, kdims=["npoints"]).collate() +plot.opts(hv.opts.Image(active_tools=[wheel_zoom])) ``` ## {class}`adaptive.AverageLearner` +The `AverageLearner` class is designed for situations where you want to average the result of a function over multiple evaluations. +This is particularly useful when working with random variables or stochastic functions, as it helps to estimate the mean value of the function. + ```{code-cell} ipython3 :tags: [hide-input] @@ -172,11 +187,15 @@ def plot_avg(learner, npoints): plots = {n: plot_avg(learner, n) for n in range(10, 10000, 200)} -hv.HoloMap(plots, kdims=["npoints"]) +hm = hv.HoloMap(plots, kdims=["npoints"]) +hm.opts(hv.opts.Histogram(active_tools=[wheel_zoom])) ``` ## {class}`adaptive.LearnerND` +The `LearnerND` class is intended for adaptively learning ND functions of the form `f: ℝ^N → ℝ^M`. +It extends the adaptive learning capabilities of the 1D and 2D learners to functions with more dimensions, allowing for efficient exploration of complex, high-dimensional spaces. + ```{code-cell} ipython3 :tags: [hide-input] diff --git a/docs/source/conf.py b/docs/source/conf.py index 1983dda56..cbe37c5c8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -2,15 +2,19 @@ import os import sys +from pathlib import Path -package_path = os.path.abspath("../..") +package_path = Path("../..").resolve() # Insert into sys.path so that we can import adaptive here -sys.path.insert(0, package_path) +sys.path.insert(0, str(package_path)) # Insert into PYTHONPATH so that jupyter-sphinx will pick it up -os.environ["PYTHONPATH"] = ":".join((package_path, os.environ.get("PYTHONPATH", ""))) +os.environ["PYTHONPATH"] = ":".join( + (str(package_path), os.environ.get("PYTHONPATH", "")), +) # Insert `docs/` such that we can run the logo scripts -docs_path = os.path.abspath("..") -sys.path.insert(1, docs_path) +docs_path = Path("..").resolve() +sys.path.insert(1, str(docs_path)) + import adaptive # noqa: E402, isort:skip @@ -79,5 +83,23 @@ nb_execution_raise_on_error = True +def replace_named_emojis(input_file: Path, output_file: Path) -> None: + """Replace named emojis in a file with unicode emojis.""" + import emoji + + with input_file.open("r") as infile: + content = infile.read() + content_with_emojis = emoji.emojize(content, language="alias") + + with output_file.open("w") as outfile: + outfile.write(content_with_emojis) + + +# Call the function to replace emojis in the README.md file +input_file = package_path / "README.md" +output_file = docs_path / "README.md" +replace_named_emojis(input_file, output_file) + + def setup(app): app.add_css_file("custom.css") # For the `live_info` widget diff --git a/docs/source/docs.md b/docs/source/docs.md index b07a97e95..4d1fa479c 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -1,16 +1,15 @@ -```{include} ../../README.md +```{include} ../README.md --- -start-after: -end-before: +start-after: +end-before: --- ``` ```{include} ../../AUTHORS.md ``` -```{include} ../../README.md +```{include} ../README.md --- -start-after: -end-before: +start-after: --- ``` diff --git a/docs/source/faq.md b/docs/source/faq.md index 6c47e7906..cfb35d476 100644 --- a/docs/source/faq.md +++ b/docs/source/faq.md @@ -1,4 +1,4 @@ -# FAQ: frequently asked questions +# ❓ FAQ: Frequently Asked Questions ## Where can I learn more about the algorithm used? diff --git a/docs/source/gallery.md b/docs/source/gallery.md index 0f1757ce7..360889db4 100644 --- a/docs/source/gallery.md +++ b/docs/source/gallery.md @@ -1,4 +1,4 @@ -# Gallery +# 🖼️ Gallery Adaptive has been used in the following scientific publications: diff --git a/docs/source/index.md b/docs/source/index.md index a5c043adb..aff03a56a 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,4 +1,6 @@ -```{include} ../../README.md +# ![logo](https://adaptive.readthedocs.io/en/latest/_static/logo.png) *Adaptive*: Parallel Active Learning of Mathematical Functions + +```{include} ../README.md --- start-after: end-before: @@ -8,24 +10,24 @@ end-before: ```{include} logo.md ``` -```{include} ../../README.md +```{include} ../README.md --- start-after: end-before: --- ``` -```{tip} -Start with the {ref}`1D function learning tutorial`. -``` - -```{include} ../../README.md +```{include} ../README.md --- -start-after: -end-before: +start-after: +end-before: --- ``` +```{tip} +Start with the {ref}`1D function learning tutorial`. +``` + ```{toctree} :hidden: true diff --git a/docs/source/reference/adaptive.md b/docs/source/reference/adaptive.md index 740ecf325..fc0958a94 100644 --- a/docs/source/reference/adaptive.md +++ b/docs/source/reference/adaptive.md @@ -1,4 +1,4 @@ -# API documentation +# 📜 API Documentation ## Learners diff --git a/docs/source/tutorial/tutorial.md b/docs/source/tutorial/tutorial.md index 7ad2e81af..9813c25d8 100644 --- a/docs/source/tutorial/tutorial.md +++ b/docs/source/tutorial/tutorial.md @@ -9,7 +9,7 @@ jupytext: format_version: '0.13' jupytext_version: 1.13.8 --- -# Tutorial Adaptive +# 🎓 Tutorial Adaptive [Adaptive](https://github.com/python-adaptive/adaptive) is a package for adaptively sampling functions with support for parallel From 60149183425d6bb35c9b670f90d03c8276e98abb Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 29 Apr 2023 20:49:01 -0700 Subject: [PATCH 16/37] Update Python version requirement to 3.9 in accordance with NEP 29 (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump minimal Python to ≥3.8 * Drop support for 3.8 --- .github/workflows/nox.yml | 2 +- adaptive/learner/average_learner1D.py | 9 +++++---- adaptive/learner/balancing_learner.py | 13 +++++-------- adaptive/learner/base_learner.py | 4 ++-- adaptive/learner/integrator_coeffs.py | 4 ++-- adaptive/learner/learner1D.py | 21 +++++++++++---------- adaptive/learner/learner2D.py | 3 ++- adaptive/learner/learnerND.py | 2 +- adaptive/learner/sequence_learner.py | 4 ++-- adaptive/runner.py | 7 +------ adaptive/utils.py | 5 +++-- benchmarks/asv.conf.json | 2 +- noxfile.py | 2 +- pyproject.toml | 13 ++++--------- 14 files changed, 41 insertions(+), 50 deletions(-) diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index b57973e88..42326d4b4 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v3 diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py index c3ba415a3..12c7c9a6c 100644 --- a/adaptive/learner/average_learner1D.py +++ b/adaptive/learner/average_learner1D.py @@ -3,9 +3,10 @@ import math import sys from collections import defaultdict +from collections.abc import Iterable, Sequence from copy import deepcopy from math import hypot -from typing import Callable, DefaultDict, Iterable, List, Sequence, Tuple +from typing import Callable import numpy as np import scipy.stats @@ -25,8 +26,8 @@ except ModuleNotFoundError: with_pandas = False -Point = Tuple[int, Real] -Points = List[Point] +Point = tuple[int, Real] +Points = list[Point] __all__: list[str] = ["AverageLearner1D"] @@ -504,7 +505,7 @@ def tell_many( # type: ignore[override] ) # Create a mapping of points to a list of samples - mapping: DefaultDict[Real, DefaultDict[Int, Real]] = defaultdict( + mapping: defaultdict[Real, defaultdict[Int, Real]] = defaultdict( lambda: defaultdict(dict) ) for (seed, x), y in zip(xs, ys): diff --git a/adaptive/learner/balancing_learner.py b/adaptive/learner/balancing_learner.py index a009722a1..e9a4a661e 100644 --- a/adaptive/learner/balancing_learner.py +++ b/adaptive/learner/balancing_learner.py @@ -3,11 +3,11 @@ import itertools import sys from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from contextlib import suppress from functools import partial from operator import itemgetter -from typing import Any, Callable, Dict, Sequence, Tuple, Union, cast +from typing import Any, Callable, Union, cast import numpy as np @@ -21,10 +21,7 @@ else: from typing_extensions import TypeAlias -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal +from typing import Literal try: import pandas @@ -42,8 +39,8 @@ def dispatch(child_functions: list[Callable], arg: Any) -> Any: STRATEGY_TYPE: TypeAlias = Literal["loss_improvements", "loss", "npoints", "cycle"] CDIMS_TYPE: TypeAlias = Union[ - Sequence[Dict[str, Any]], - Tuple[Sequence[str], Sequence[Tuple[Any, ...]]], + Sequence[dict[str, Any]], + tuple[Sequence[str], Sequence[tuple[Any, ...]]], None, ] diff --git a/adaptive/learner/base_learner.py b/adaptive/learner/base_learner.py index 934fee687..73720dd52 100644 --- a/adaptive/learner/base_learner.py +++ b/adaptive/learner/base_learner.py @@ -2,7 +2,7 @@ import abc from contextlib import suppress -from typing import TYPE_CHECKING, Any, Callable, Dict, TypeVar +from typing import TYPE_CHECKING, Any, Callable, TypeVar import cloudpickle @@ -66,7 +66,7 @@ def _wrapped(loss_per_interval): return _wrapped -DataType = Dict[Any, Any] +DataType = dict[Any, Any] class BaseLearner(abc.ABC): diff --git a/adaptive/learner/integrator_coeffs.py b/adaptive/learner/integrator_coeffs.py index 872888194..862f76eb0 100644 --- a/adaptive/learner/integrator_coeffs.py +++ b/adaptive/learner/integrator_coeffs.py @@ -3,7 +3,7 @@ from collections import defaultdict from fractions import Fraction -from functools import lru_cache +from functools import cache import numpy as np import scipy.linalg @@ -143,7 +143,7 @@ def calc_V(x: np.ndarray, n: int) -> np.ndarray: return np.array(V).T -@lru_cache(maxsize=None) +@cache def _coefficients(): """Compute the coefficients on demand, in order to avoid doing linear algebra on import.""" eps = np.spacing(1) diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py index 38d7776e9..8385e67b8 100644 --- a/adaptive/learner/learner1D.py +++ b/adaptive/learner/learner1D.py @@ -4,8 +4,9 @@ import itertools import math import sys +from collections.abc import Sequence from copy import copy, deepcopy -from typing import TYPE_CHECKING, Any, Callable, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union import cloudpickle import numpy as np @@ -41,27 +42,27 @@ # -- types -- # Commonly used types - Interval: TypeAlias = Union[Tuple[float, float], Tuple[float, float, int]] - NeighborsType: TypeAlias = SortedDict[float, List[Optional[float]]] + Interval: TypeAlias = Union[tuple[float, float], tuple[float, float, int]] + NeighborsType: TypeAlias = SortedDict[float, list[Optional[float]]] # Types for loss_per_interval functions - XsType0: TypeAlias = Tuple[float, float] - YsType0: TypeAlias = Union[Tuple[float, float], Tuple[np.ndarray, np.ndarray]] - XsType1: TypeAlias = Tuple[ + XsType0: TypeAlias = tuple[float, float] + YsType0: TypeAlias = Union[tuple[float, float], tuple[np.ndarray, np.ndarray]] + XsType1: TypeAlias = tuple[ Optional[float], Optional[float], Optional[float], Optional[float] ] YsType1: TypeAlias = Union[ - Tuple[Optional[float], Optional[float], Optional[float], Optional[float]], - Tuple[ + tuple[Optional[float], Optional[float], Optional[float], Optional[float]], + tuple[ Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray], ], ] - XsTypeN: TypeAlias = Tuple[Optional[float], ...] + XsTypeN: TypeAlias = tuple[Optional[float], ...] YsTypeN: TypeAlias = Union[ - Tuple[Optional[float], ...], Tuple[Optional[np.ndarray], ...] + tuple[Optional[float], ...], tuple[Optional[np.ndarray], ...] ] diff --git a/adaptive/learner/learner2D.py b/adaptive/learner/learner2D.py index a1928e09a..a2aec2069 100644 --- a/adaptive/learner/learner2D.py +++ b/adaptive/learner/learner2D.py @@ -3,9 +3,10 @@ import itertools import warnings from collections import OrderedDict +from collections.abc import Iterable from copy import copy from math import sqrt -from typing import Callable, Iterable +from typing import Callable import cloudpickle import numpy as np diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index 2c8a73e57..edc839d8d 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -1111,7 +1111,7 @@ def _get_iso(self, level=0.0, which="surface"): vertices = [] # index -> (x,y,z) faces_or_lines = [] # tuple of indices of the corner points - @functools.lru_cache() + @functools.lru_cache def _get_vertex_index(a, b): vertex_a = self.tri.vertices[a] vertex_b = self.tri.vertices[b] diff --git a/adaptive/learner/sequence_learner.py b/adaptive/learner/sequence_learner.py index 8daa5d374..2f7a34171 100644 --- a/adaptive/learner/sequence_learner.py +++ b/adaptive/learner/sequence_learner.py @@ -2,7 +2,7 @@ import sys from copy import copy -from typing import Any, Tuple +from typing import Any import cloudpickle from sortedcontainers import SortedDict, SortedSet @@ -28,7 +28,7 @@ else: from typing_extensions import TypeAlias -PointType: TypeAlias = Tuple[Int, Any] +PointType: TypeAlias = tuple[Int, Any] class _IgnoreFirstArgument: diff --git a/adaptive/runner.py b/adaptive/runner.py index d8529113f..d1f39e1e2 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -15,7 +15,7 @@ from contextlib import suppress from datetime import datetime, timedelta from importlib.util import find_spec -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union import loky @@ -46,11 +46,6 @@ else: from typing_extensions import TypeAlias -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - with_ipyparallel = find_spec("ipyparallel") is not None with_distributed = find_spec("distributed") is not None diff --git a/adaptive/utils.py b/adaptive/utils.py index 3300ef893..7b2826a38 100644 --- a/adaptive/utils.py +++ b/adaptive/utils.py @@ -7,9 +7,10 @@ import os import pickle import warnings +from collections.abc import Iterator, Sequence from contextlib import contextmanager from itertools import product -from typing import Any, Callable, Iterator, Sequence +from typing import Any, Callable import cloudpickle @@ -147,7 +148,7 @@ class SequentialExecutor(concurrent.Executor): This executor is mainly for testing. """ - def submit(self, fn: Callable, *args, **kwargs) -> concurrent.Future: + def submit(self, fn: Callable, *args, **kwargs) -> concurrent.Future: # type: ignore[override] fut: concurrent.Future = concurrent.Future() try: fut.set_result(fn(*args, **kwargs)) diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index 3231a9915..65fdf4bfc 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -7,7 +7,7 @@ "environment_type": "conda", "install_timeout": 600, "show_commit_url": "https://github.com/python-adaptive/adaptive/commits/", - "pythons": ["3.7"], + "pythons": ["3.11"], "conda_channels": ["conda-forge"], "matrix": { "numpy": ["1.13"], diff --git a/noxfile.py b/noxfile.py index 73f8a769e..1866a94a6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox -@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11"]) +@nox.session(python=["3.9", "3.10", "3.11"]) @nox.parametrize("all_deps", [True, False]) def pytest(session, all_deps): session.install(".[testing,other]" if all_deps else ".[testing]") diff --git a/pyproject.toml b/pyproject.toml index 1bb5569d3..6a907965e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,13 +8,11 @@ dynamic = ["version"] description = "Parallel active learning of mathematical functions" maintainers = [{ name = "Adaptive authors" }] license = { text = "BSD" } -requires-python = ">=3.7" +requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", "License :: OSI Approved :: BSD License", "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -30,8 +28,6 @@ dependencies = [ [project.optional-dependencies] other = [ - "ipython; python_version > '3.8'", - "ipython<8.13; python_version <= '3.8'", # because https://github.com/ipython/ipython/issues/14053 "dill", "distributed", "ipyparallel>=6.2.5", # because of https://github.com/ipython/ipyparallel/issues/404 @@ -41,8 +37,7 @@ other = [ "pexpect; os_name != 'nt'", ] notebook = [ - "ipython; python_version > '3.8'", - "ipython<8.13; python_version <= '3.8'", # because https://github.com/ipython/ipython/issues/14053 + "ipython", "ipykernel>=4.8.0", # because https://github.com/ipython/ipykernel/issues/274 and https://github.com/ipython/ipykernel/issues/263 "jupyter_client>=5.2.2", # because https://github.com/jupyter/jupyter_client/pull/314 "holoviews>=1.9.1", @@ -96,11 +91,11 @@ output = ".coverage.xml" [tool.mypy] ignore_missing_imports = true -python_version = "3.7" +python_version = "3.9" [tool.ruff] line-length = 150 -target-version = "py37" +target-version = "py39" select = ["B", "C", "E", "F", "W", "T", "B9", "I", "UP"] ignore = [ "T20", # flake8-print From 3fbbfba6be0426f0641ada01e067443a671f4e9c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 30 Apr 2023 12:50:15 -0700 Subject: [PATCH 17/37] Use versioningit (#420) --- adaptive/__init__.py | 3 +- adaptive/_static_version.py | 11 -- adaptive/_version.py | 210 +----------------------------------- docs/environment.yml | 1 + pyproject.toml | 14 ++- setup.cfg | 5 + setup.py | 22 ---- 7 files changed, 24 insertions(+), 242 deletions(-) delete mode 100644 adaptive/_static_version.py create mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/adaptive/__init__.py b/adaptive/__init__.py index dbf7c6b86..4f99d67e7 100644 --- a/adaptive/__init__.py +++ b/adaptive/__init__.py @@ -53,6 +53,5 @@ __all__.append("SKOptLearner") -# to avoid confusion with `notebook_extension` and `__version__` -del _version # type: ignore[name-defined] # noqa: F821 +# to avoid confusion with `notebook_extension` del notebook_integration # type: ignore[name-defined] # noqa: F821 diff --git a/adaptive/_static_version.py b/adaptive/_static_version.py deleted file mode 100644 index f7d3e6c9c..000000000 --- a/adaptive/_static_version.py +++ /dev/null @@ -1,11 +0,0 @@ -# This file is part of 'miniver': https://github.com/jbweston/miniver -# -# This file will be overwritten by setup.py when a source or binary -# distribution is made. The magic value "__use_git__" is interpreted by -# _version.py. - -version = "__use_git__" - -# These values are only set if the distribution was created with 'git archive' -refnames = "$Format:%D$" -git_hash = "$Format:%h$" diff --git a/adaptive/_version.py b/adaptive/_version.py index ce1351504..2eb743f4e 100644 --- a/adaptive/_version.py +++ b/adaptive/_version.py @@ -1,208 +1,6 @@ -# This file is part of 'miniver': https://github.com/jbweston/miniver -# -from __future__ import annotations +from pathlib import Path -import os -import subprocess -from collections import namedtuple +import versioningit -from setuptools.command.build_py import build_py as build_py_orig -from setuptools.command.sdist import sdist as sdist_orig - -Version = namedtuple("Version", ("release", "dev", "labels")) - -# No public API -__all__: list[str] = [] - -package_root = os.path.dirname(os.path.realpath(__file__)) -package_name = os.path.basename(package_root) -distr_root = os.path.dirname(package_root) -# If the package is inside a "src" directory the -# distribution root is 1 level up. -if os.path.split(distr_root)[1] == "src": - _package_root_inside_src = True - distr_root = os.path.dirname(distr_root) -else: - _package_root_inside_src = False - -STATIC_VERSION_FILE = "_static_version.py" - - -def get_version(version_file=STATIC_VERSION_FILE): - version_info = get_static_version_info(version_file) - version = version_info["version"] - if version == "__use_git__": - version = get_version_from_git() - if not version: - version = get_version_from_git_archive(version_info) - if not version: - version = Version("unknown", None, None) - return pep440_format(version) - else: - return version - - -def get_static_version_info(version_file=STATIC_VERSION_FILE): - version_info = {} - with open(os.path.join(package_root, version_file), "rb") as f: - exec(f.read(), {}, version_info) - return version_info - - -def version_is_from_git(version_file=STATIC_VERSION_FILE): - return get_static_version_info(version_file)["version"] == "__use_git__" - - -def pep440_format(version_info): - release, dev, labels = version_info - - version_parts = [release] - if dev: - if release.endswith("-dev") or release.endswith(".dev"): - version_parts.append(dev) - else: # prefer PEP440 over strict adhesion to semver - version_parts.append(f".dev{dev}") - - if labels: - version_parts.append("+") - version_parts.append(".".join(labels)) - - return "".join(version_parts) - - -def get_version_from_git(): - try: - p = subprocess.Popen( - ["git", "rev-parse", "--show-toplevel"], - cwd=distr_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except OSError: - return - if p.wait() != 0: - return - if not os.path.samefile(p.communicate()[0].decode().rstrip("\n"), distr_root): - # The top-level directory of the current Git repository is not the same - # as the root directory of the distribution: do not extract the - # version from Git. - return - - # git describe --first-parent does not take into account tags from branches - # that were merged-in. The '--long' flag gets us the 'dev' version and - # git hash, '--always' returns the git hash even if there are no tags. - for opts in [["--first-parent"], []]: - try: - p = subprocess.Popen( - ["git", "describe", "--long", "--always", "--tags"] + opts, - cwd=distr_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - except OSError: - return - if p.wait() == 0: - break - else: - return - - description = ( - p.communicate()[0] - .decode() - .strip("v") # Tags can have a leading 'v', but the version should not - .rstrip("\n") - .rsplit("-", 2) # Split the latest tag, commits since tag, and hash - ) - - try: - release, dev, git = description - except ValueError: # No tags, only the git hash - # prepend 'g' to match with format returned by 'git describe' - git = "g{}".format(*description) - release = "unknown" - dev = None - - labels = [] - if dev == "0": - dev = None - else: - labels.append(git) - - try: - p = subprocess.Popen(["git", "diff", "--quiet"], cwd=distr_root) - except OSError: - labels.append("confused") # This should never happen. - else: - if p.wait() == 1: - labels.append("dirty") - - return Version(release, dev, labels) - - -# TODO: change this logic when there is a git pretty-format -# that gives the same output as 'git describe'. -# Currently we can only tell the tag the current commit is -# pointing to, or its hash (with no version info) -# if it is not tagged. -def get_version_from_git_archive(version_info): - try: - refnames = version_info["refnames"] - git_hash = version_info["git_hash"] - except KeyError: - # These fields are not present if we are running from an sdist. - # Execution should never reach here, though - return None - - if git_hash.startswith("$Format") or refnames.startswith("$Format"): - # variables not expanded during 'git archive' - return None - - VTAG = "tag: v" - refs = {r.strip() for r in refnames.split(",")} - version_tags = {r[len(VTAG) :] for r in refs if r.startswith(VTAG)} - if version_tags: - release, *_ = sorted(version_tags) # prefer e.g. "2.0" over "2.0rc1" - return Version(release, dev=None, labels=None) - else: - return Version("unknown", dev=None, labels=[f"g{git_hash}"]) - - -__version__ = get_version() - - -# The following section defines a module global 'cmdclass', -# which can be used from setup.py. The 'package_name' and -# '__version__' module globals are used (but not modified). - - -def _write_version(fname): - # This could be a hard link, so try to delete it first. Is there any way - # to do this atomically together with opening? - try: - os.remove(fname) - except OSError: - pass - with open(fname, "w") as f: - f.write( - "# This file has been created by setup.py.\n" - "version = '{}'\n".format(__version__) - ) - - -class _build_py(build_py_orig): - def run(self): - super().run() - _write_version(os.path.join(self.build_lib, package_name, STATIC_VERSION_FILE)) - - -class _sdist(sdist_orig): - def make_release_tree(self, base_dir, files): - super().make_release_tree(base_dir, files) - if _package_root_inside_src: - p = os.path.join("src", package_name) - else: - p = package_name - _write_version(os.path.join(base_dir, p, STATIC_VERSION_FILE)) - - -cmdclass = {"sdist": _sdist, "build_py": _build_py} +REPO_ROOT = Path(__file__).parent.parent +__version__ = versioningit.get_version(project_dir=REPO_ROOT) diff --git a/docs/environment.yml b/docs/environment.yml index ade7f65ee..67298496c 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -25,3 +25,4 @@ dependencies: - myst-parser=0.18.1 - dask=2023.3.2 - emoji=2.2.0 + - versioningit=2.2.0 diff --git a/pyproject.toml b/pyproject.toml index 6a907965e..f0ea8eddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "setuptools.build_meta" -requires = ["setuptools ~= 65.0.0", "versioningit ~= 2.0.1", "wheel"] +requires = ["setuptools ~= 65.0.0", "versioningit ~= 2.2.0", "wheel"] [project] name = "adaptive" @@ -24,6 +24,7 @@ dependencies = [ "cloudpickle", "loky >= 2.9", "typing_extensions; python_version < '3.10'", + "versioningit", ] [project.optional-dependencies] @@ -115,3 +116,14 @@ ignore = [ [tool.ruff.mccabe] max-complexity = 18 + +[tool.versioningit] + +[tool.versioningit.vcs] +method = "git" +match = ["v*"] +default-tag = "0.0.0" + +[tool.versioningit.onbuild] +build-file = "adaptive/_version.py" +source-file = "adaptive/_version.py" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..f464225ad --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +# All other settings are in pyproject.toml +[options] +cmdclass = + sdist = versioningit.cmdclass.sdist + build_py = versioningit.cmdclass.build_py diff --git a/setup.py b/setup.py deleted file mode 100644 index 175f16062..000000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 - -from setuptools import setup - - -# Loads _version.py module without importing the whole package. -def get_version_and_cmdclass(package_name): - import os - from importlib.util import module_from_spec, spec_from_file_location - - spec = spec_from_file_location("version", os.path.join(package_name, "_version.py")) - module = module_from_spec(spec) - spec.loader.exec_module(module) - return module.__version__, module.cmdclass - - -version, cmdclass = get_version_and_cmdclass("adaptive") - - -setup( - cmdclass=cmdclass, -) From 0dd5d987f92d6276139ba387c790b215aea71b2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 May 2023 23:17:43 -0700 Subject: [PATCH 18/37] [pre-commit.ci] pre-commit autoupdate (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/charliermarsh/ruff-pre-commit: v0.0.262 → v0.0.263](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.262...v0.0.263) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f6b5068ad..89cf92d3e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.262" + rev: "v0.0.263" hooks: - id: ruff args: ["--fix"] From 2b941528e57dbf753d5a73e398fb3d5b7cbb0a5e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 9 May 2023 16:22:55 -0700 Subject: [PATCH 19/37] Allow storing the full sequence in SequenceLearner.to_dataframe (#425) * Allow storing the full sequence in SequenceLearner.to_dataframe * Test dataframes * skip if minimal deps --- adaptive/learner/sequence_learner.py | 51 ++++++++++++++++++++++--- adaptive/tests/test_sequence_learner.py | 36 ++++++++++++++++- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/adaptive/learner/sequence_learner.py b/adaptive/learner/sequence_learner.py index 2f7a34171..c307744fd 100644 --- a/adaptive/learner/sequence_learner.py +++ b/adaptive/learner/sequence_learner.py @@ -2,7 +2,7 @@ import sys from copy import copy -from typing import Any +from typing import TYPE_CHECKING, Any import cloudpickle from sortedcontainers import SortedDict, SortedSet @@ -15,6 +15,10 @@ partial_function_from_dataframe, ) +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Callable + try: import pandas @@ -82,12 +86,17 @@ class SequenceLearner(BaseLearner): the added benefit of having results in the local kernel already. """ - def __init__(self, function, sequence): + def __init__( + self, + function: Callable[[Any], Any], + sequence: Sequence[Any], + ): self._original_function = function self.function = _IgnoreFirstArgument(function) # prefer range(len(...)) over enumerate to avoid slowdowns # when passing lazy sequences - self._to_do_indices = SortedSet(range(len(sequence))) + indices = range(len(sequence)) + self._to_do_indices = SortedSet(indices) self._ntotal = len(sequence) self.sequence = copy(sequence) self.data = SortedDict() @@ -161,6 +170,8 @@ def to_dataframe( # type: ignore[override] index_name: str = "i", x_name: str = "x", y_name: str = "y", + *, + full_sequence: bool = False, ) -> pandas.DataFrame: """Return the data as a `pandas.DataFrame`. @@ -178,6 +189,9 @@ def to_dataframe( # type: ignore[override] Name of the input value, by default "x" y_name : str, optional Name of the output value, by default "y" + full_sequence : bool, optional + If True, the returned dataframe will have the full sequence + where the y_name values are pd.NA if not evaluated yet. Returns ------- @@ -190,8 +204,16 @@ def to_dataframe( # type: ignore[override] """ if not with_pandas: raise ImportError("pandas is not installed.") - indices, ys = zip(*self.data.items()) if self.data else ([], []) - sequence = [self.sequence[i] for i in indices] + import pandas as pd + + if full_sequence: + indices = list(range(len(self.sequence))) + sequence = list(self.sequence) + ys = [self.data.get(i, pd.NA) for i in indices] + else: + indices, ys = zip(*self.data.items()) if self.data else ([], []) # type: ignore[assignment] + sequence = [self.sequence[i] for i in indices] + df = pandas.DataFrame(indices, columns=[index_name]) df[x_name] = sequence df[y_name] = ys @@ -209,6 +231,8 @@ def load_dataframe( # type: ignore[override] index_name: str = "i", x_name: str = "x", y_name: str = "y", + *, + full_sequence: bool = False, ): """Load data from a `pandas.DataFrame`. @@ -231,10 +255,25 @@ def load_dataframe( # type: ignore[override] The ``x_name`` used in ``to_dataframe``, by default "x" y_name : str, optional The ``y_name`` used in ``to_dataframe``, by default "y" + full_sequence : bool, optional + The ``full_sequence`` used in ``to_dataframe``, by default False """ + if not with_pandas: + raise ImportError("pandas is not installed.") + import pandas as pd + indices = df[index_name].values xs = df[x_name].values - self.tell_many(zip(indices, xs), df[y_name].values) + ys = df[y_name].values + + if full_sequence: + evaluated_indices = [i for i, y in enumerate(ys) if y is not pd.NA] + xs = xs[evaluated_indices] + ys = ys[evaluated_indices] + indices = indices[evaluated_indices] + + self.tell_many(zip(indices, xs), ys) + if with_default_function_args: self.function = partial_function_from_dataframe( self._original_function, df, function_prefix diff --git a/adaptive/tests/test_sequence_learner.py b/adaptive/tests/test_sequence_learner.py index fdd3dcb10..9ef5a8f14 100644 --- a/adaptive/tests/test_sequence_learner.py +++ b/adaptive/tests/test_sequence_learner.py @@ -1,7 +1,17 @@ import asyncio +import pytest + from adaptive import Runner, SequenceLearner -from adaptive.runner import SequentialExecutor +from adaptive.learner.learner1D import with_pandas +from adaptive.runner import SequentialExecutor, simple + +offset = 0.0123 + + +def peak(x, offset=offset, wait=True): + a = 0.01 + return {"x": x + a**2 / (a**2 + (x - offset) ** 2)} class FailOnce: @@ -22,3 +32,27 @@ def test_fail_with_sequence_of_unhashable(): runner = Runner(learner, retries=1, executor=SequentialExecutor()) asyncio.get_event_loop().run_until_complete(runner.task) assert runner.status() == "finished" + + +@pytest.mark.skipif(not with_pandas, reason="pandas is not installed") +def test_save_load_dataframe(): + learner = SequenceLearner(peak, sequence=range(10, 30, 1)) + simple(learner, npoints_goal=10) + df = learner.to_dataframe() + assert len(df) == 10 + assert df["x"].iloc[0] == 10 + df_full = learner.to_dataframe(full_sequence=True) + assert len(df_full) == 20 + assert df_full["x"].iloc[0] == 10 + assert df_full["x"].iloc[-1] == 29 + + learner2 = learner.new() + assert learner2.data == {} + learner2.load_dataframe(df) + assert len(learner2.data) == 10 + assert learner.to_dataframe().equals(df) + + learner3 = learner.new() + learner3.load_dataframe(df_full, full_sequence=True) + assert len(learner3.data) == 10 + assert learner3.to_dataframe(full_sequence=True).equals(df_full) From c22ea54b3ae93c9a2af4f42c435d767936ba7a28 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 May 2023 00:04:32 -0700 Subject: [PATCH 20/37] [pre-commit.ci] pre-commit autoupdate (#426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/charliermarsh/ruff-pre-commit: v0.0.263 → v0.0.265](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.263...v0.0.265) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89cf92d3e..2f94c9f1c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.263" + rev: "v0.0.265" hooks: - id: ruff args: ["--fix"] From 4959bfcd39883cdcbf0fa8f8f4768388dc274308 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 15 May 2023 10:39:15 -0700 Subject: [PATCH 21/37] Add github-changelog-generator CI workflow (#421) * Add github-changelog-generator CI workflow * specify version * push with action * fetch/merge * use fetch-depth: 0 * only run at push to main * make auto changelog manually triggered * Checkout commit * Cat CHANGELOG --- .../workflows/auto-changelog-generator.yml | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/auto-changelog-generator.yml diff --git a/.github/workflows/auto-changelog-generator.yml b/.github/workflows/auto-changelog-generator.yml new file mode 100644 index 000000000..03b77eb05 --- /dev/null +++ b/.github/workflows/auto-changelog-generator.yml @@ -0,0 +1,59 @@ +name: Generate Changelog + +on: + # Manual trigger only + workflow_dispatch: + +jobs: + generate-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Force-checkout latest commit on branch + run: | + git fetch + git reset --hard origin/${{ github.head_ref }} + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.0 + + - name: Install github-changelog-generator + run: gem install github_changelog_generator + + - name: Generate Changelog + run: | + github_changelog_generator \ + -u ${{ github.repository_owner }} \ + -p ${{ github.event.repository.name }} \ + --token ${{ secrets.GITHUB_TOKEN }} \ + --output CHANGELOG.md + + - name: Commit updated CHANGELOG.md + id: commit + run: | + git add CHANGELOG.md + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + if git diff --quiet && git diff --staged --quiet; then + echo "No changes in CHANGELOG.md, skipping commit." + echo "commit_status=skipped" >> $GITHUB_ENV + else + git commit -m "Update CHANGELOG.md" + echo "commit_status=committed" >> $GITHUB_ENV + fi + + - name: Echo CHANGELOG.md + run: cat CHANGELOG.md + + - name: Push changes + if: env.commit_status == 'committed' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ github.head_ref }} From 37139a190591e8cb4251f50a7d5468f8f4fd4845 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 15 May 2023 11:03:37 -0700 Subject: [PATCH 22/37] Update CHANGELOG for v1.0.0 (#429) * Update CHANGELOG for v1.0.0 * Run on push * Test * test * Update CHANGELOG.md * Update changelog * cleanup .github/workflows/auto-changelog-generator.yml --------- Co-authored-by: github-actions[bot] --- .../workflows/auto-changelog-generator.yml | 11 +++-- CHANGELOG.md | 40 +++++++++++++++++++ LICENSE | 2 +- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/.github/workflows/auto-changelog-generator.yml b/.github/workflows/auto-changelog-generator.yml index 03b77eb05..5cb0f7d12 100644 --- a/.github/workflows/auto-changelog-generator.yml +++ b/.github/workflows/auto-changelog-generator.yml @@ -3,6 +3,10 @@ name: Generate Changelog on: # Manual trigger only workflow_dispatch: + inputs: + branch: + description: 'Branch name' + required: true jobs: generate-changelog: @@ -13,11 +17,6 @@ jobs: with: fetch-depth: 0 - - name: Force-checkout latest commit on branch - run: | - git fetch - git reset --hard origin/${{ github.head_ref }} - - name: Setup Ruby uses: ruby/setup-ruby@v1 with: @@ -56,4 +55,4 @@ jobs: uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.head_ref }} + branch: ${{ github.event.inputs.branch }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2559cd46a..03f7b4811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 🗞️ Changelog +## [v1.0.0](https://github.com/python-adaptive/adaptive/tree/v1.0.0) + +[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.15.0...v1.0.0) + +**Closed issues:** + +- No module named 'typing\_extensions'" [\#394](https://github.com/python-adaptive/adaptive/issues/394) +- Documentation: use cases of coroutine by Learner and Runner not properly explained [\#360](https://github.com/python-adaptive/adaptive/issues/360) + +**Merged pull requests:** + +- \[pre-commit.ci\] pre-commit autoupdate [\#426](https://github.com/python-adaptive/adaptive/pull/426) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Allow storing the full sequence in SequenceLearner.to\_dataframe [\#425](https://github.com/python-adaptive/adaptive/pull/425) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#423](https://github.com/python-adaptive/adaptive/pull/423) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Add github-changelog-generator CI workflow [\#421](https://github.com/python-adaptive/adaptive/pull/421) ([basnijholt](https://github.com/basnijholt)) +- Use versioningit to work with pyproject.toml and remove setup.py [\#420](https://github.com/python-adaptive/adaptive/pull/420) ([basnijholt](https://github.com/basnijholt)) +- Update Python version requirement to 3.9 in accordance with NEP 29 [\#418](https://github.com/python-adaptive/adaptive/pull/418) ([basnijholt](https://github.com/basnijholt)) +- Update GitHub Actions CI [\#416](https://github.com/python-adaptive/adaptive/pull/416) ([basnijholt](https://github.com/basnijholt)) +- Disable typeguard CI pipeline [\#415](https://github.com/python-adaptive/adaptive/pull/415) ([basnijholt](https://github.com/basnijholt)) +- Add mypy to pre-commit and fix all current typing issues [\#414](https://github.com/python-adaptive/adaptive/pull/414) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#413](https://github.com/python-adaptive/adaptive/pull/413) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Use codecov/codecov-action instead of removed codecov package [\#412](https://github.com/python-adaptive/adaptive/pull/412) ([basnijholt](https://github.com/basnijholt)) +- Cache the loss of a SequenceLearner [\#411](https://github.com/python-adaptive/adaptive/pull/411) ([basnijholt](https://github.com/basnijholt)) +- Only really import packages when needed [\#410](https://github.com/python-adaptive/adaptive/pull/410) ([basnijholt](https://github.com/basnijholt)) +- Remove \_RequireAttrsABCMeta metaclass and replace with simple check [\#409](https://github.com/python-adaptive/adaptive/pull/409) ([basnijholt](https://github.com/basnijholt)) +- Use the build module [\#407](https://github.com/python-adaptive/adaptive/pull/407) ([basnijholt](https://github.com/basnijholt)) +- Avoid asyncio.coroutine error in Readthedocs.org builds [\#406](https://github.com/python-adaptive/adaptive/pull/406) ([basnijholt](https://github.com/basnijholt)) +- Bump scikit-optimize in environment.yml [\#403](https://github.com/python-adaptive/adaptive/pull/403) ([basnijholt](https://github.com/basnijholt)) +- Replace isort, flake8, and pyupgrade by ruff [\#402](https://github.com/python-adaptive/adaptive/pull/402) ([basnijholt](https://github.com/basnijholt)) +- Move to pyproject.toml based install [\#401](https://github.com/python-adaptive/adaptive/pull/401) ([basnijholt](https://github.com/basnijholt)) +- Rewrite parts of README, reorder sections, and add features section [\#400](https://github.com/python-adaptive/adaptive/pull/400) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#398](https://github.com/python-adaptive/adaptive/pull/398) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- \[pre-commit.ci\] pre-commit autoupdate [\#397](https://github.com/python-adaptive/adaptive/pull/397) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Use nb\_execution\_raise\_on\_error Sphinx myst-nb option [\#396](https://github.com/python-adaptive/adaptive/pull/396) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#395](https://github.com/python-adaptive/adaptive/pull/395) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- \[pre-commit.ci\] pre-commit autoupdate [\#393](https://github.com/python-adaptive/adaptive/pull/393) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- \[pre-commit.ci\] pre-commit autoupdate [\#392](https://github.com/python-adaptive/adaptive/pull/392) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Add nbQA for notebook and docs linting [\#361](https://github.com/python-adaptive/adaptive/pull/361) ([basnijholt](https://github.com/basnijholt)) +- Fix HoloViews opts deprecation warnings [\#357](https://github.com/python-adaptive/adaptive/pull/357) ([basnijholt](https://github.com/basnijholt)) + ## [v0.15.0](https://github.com/python-adaptive/adaptive/tree/v0.15.0) (2022-11-30) [Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.14.2...v0.15.0) diff --git a/LICENSE b/LICENSE index ecdd09650..abeb07779 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2017-2021, Adaptive authors +Copyright (c) 2017-2023, Adaptive authors All rights reserved. Redistribution and use in source and binary forms, with or without From cf0b9172c3e9d89fbca73ba13ce45d1bc54c9f2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 May 2023 06:10:03 +0000 Subject: [PATCH 23/37] [pre-commit.ci] pre-commit autoupdate (#431) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f94c9f1c..b0645a97d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.265" + rev: "v0.0.267" hooks: - id: ruff args: ["--fix"] @@ -26,7 +26,7 @@ repos: args: ["ruff", "--fix", "--ignore=E402,B018,F704"] additional_dependencies: [jupytext, ruff] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.2.0" + rev: "v1.3.0" hooks: - id: mypy exclude: ipynb_filter.py|docs/source/conf.py From f31d0a504f338b5b0eca43c2b3352a04dc4b232c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 May 2023 22:53:41 -0700 Subject: [PATCH 24/37] [pre-commit.ci] pre-commit autoupdate (#433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/charliermarsh/ruff-pre-commit: v0.0.267 → v0.0.269](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.267...v0.0.269) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0645a97d..6c10c533e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.267" + rev: "v0.0.269" hooks: - id: ruff args: ["--fix"] From 067444fa8aa9480775b221df4b96e49720922bb0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 21 Jun 2023 20:26:02 -0700 Subject: [PATCH 25/37] Add adaptive.utils.daskify (#422) * Add adaptive.utils.daskify * Do not overwrite variables g, h * Fix header level * Add link to TutorialAdvancedTopics --- adaptive/utils.py | 48 ++++++++++++- .../tutorial/tutorial.advanced-topics.md | 68 ++++++++++++++++--- docs/source/tutorial/tutorial.parallelism.md | 2 + 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/adaptive/utils.py b/adaptive/utils.py index 7b2826a38..ff80f62f1 100644 --- a/adaptive/utils.py +++ b/adaptive/utils.py @@ -7,13 +7,17 @@ import os import pickle import warnings -from collections.abc import Iterator, Sequence +from collections.abc import Awaitable, Iterator, Sequence from contextlib import contextmanager +from functools import wraps from itertools import product -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable, TypeVar import cloudpickle +if TYPE_CHECKING: + from dask.distributed import Client as AsyncDaskClient + def named_product(**items: Sequence[Any]): names = items.keys() @@ -161,3 +165,43 @@ def map(self, fn, *iterable, timeout=None, chunksize=1): def shutdown(self, wait=True): pass + + +def _cache_key(args: tuple[Any], kwargs: dict[str, Any]) -> str: + arg_strings = [str(a) for a in args] + kwarg_strings = [f"{k}={v}" for k, v in sorted(kwargs.items())] + return "_".join(arg_strings + kwarg_strings) + + +T = TypeVar("T") + + +def daskify( + client: AsyncDaskClient, cache: bool = False +) -> Callable[[Callable[..., T]], Callable[..., Awaitable[T]]]: + from dask import delayed + + def _daskify(func: Callable[..., T]) -> Callable[..., Awaitable[T]]: + if cache: + func.cache = {} # type: ignore[attr-defined] + + delayed_func = delayed(func) + + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> T: + if cache: + key = _cache_key(args, kwargs) # type: ignore[arg-type] + future = func.cache.get(key) # type: ignore[attr-defined] + + if future is None: + future = client.compute(delayed_func(*args, **kwargs)) + func.cache[key] = future # type: ignore[attr-defined] + else: + future = client.compute(delayed_func(*args, **kwargs)) + + result = await future + return result + + return wrapper + + return _daskify diff --git a/docs/source/tutorial/tutorial.advanced-topics.md b/docs/source/tutorial/tutorial.advanced-topics.md index 2dfc6cf29..8ba0caf40 100644 --- a/docs/source/tutorial/tutorial.advanced-topics.md +++ b/docs/source/tutorial/tutorial.advanced-topics.md @@ -9,7 +9,7 @@ kernelspec: display_name: python3 name: python3 --- - +(TutorialAdvancedTopics)= # Advanced Topics ```{note} @@ -365,22 +365,19 @@ await runner.task # This is not needed in a notebook environment! # The result will only be set when the runner is done. timer.result() ``` - +(CustomParallelization)= ## Custom parallelization using coroutines Adaptive by itself does not implement a way of sharing partial results between function executions. Instead its implementation of parallel computation using executors is minimal by design. The appropriate way to implement custom parallelization is by using coroutines (asynchronous functions). + We illustrate this approach by using `dask.distributed` for parallel computations in part because it supports asynchronous operation out-of-the-box. -Let us consider a function `f(x)` which is composed by two parts: -a slow part `g` which can be reused by multiple inputs and shared across function evaluations and a fast part `h` that will be computed for every `x`. +We will focus on a function `f(x)` that consists of two distinct components: a slow part `g` that can be reused across multiple inputs and shared among various function evaluations, and a fast part `h` that is calculated for each `x` value. ```{code-cell} ipython3 -import time - - -def f(x): +def f(x): # example function without caching """ Integer part of `x` repeats and should be reused Decimal part requires a new computation @@ -390,7 +387,9 @@ def f(x): def g(x): """Slow but reusable function""" - time.sleep(random.randrange(5)) + from time import sleep + + sleep(random.randrange(5)) return x**2 @@ -399,12 +398,59 @@ def h(x): return x**3 ``` +### Using `adaptive.utils.daskify` + +To simplify the process of using coroutines and caching with dask and Adaptive, we provide the {func}`adaptive.utils.daskify` decorator. This decorator can be used to parallelize functions with caching as well as functions without caching, making it a powerful tool for custom parallelization in Adaptive. + +```{code-cell} ipython3 +from dask.distributed import Client + +import adaptive + +client = await Client(asynchronous=True) + + +# The g function has caching enabled +g_dask = adaptive.utils.daskify(client, cache=True)(g) + +# Can be used like a decorator too: +# >>> @adaptive.utils.daskify(client, cache=True) +# ... def g(x): ... + +# The h function does not use caching +h_dask = adaptive.utils.daskify(client)(h) + +# Now we need to rewrite `f(x)` to use `g` and `h` as coroutines + + +async def f_parallel(x): + g_result = await g_dask(int(x)) + h_result = await h_dask(x % 1) + return (g_result + h_result) ** 2 + + +learner = adaptive.Learner1D(f_parallel, bounds=(-3.5, 3.5)) +runner = adaptive.AsyncRunner(learner, loss_goal=0.01, ntasks=20) +runner.live_info() +``` + +Finally, we wait for the runner to finish, and then plot the result. + +```{code-cell} ipython3 +await runner.task +learner.plot() +``` + +### Step-by-step explanation of custom parallelization + +Now let's dive into a detailed explanation of the process to understand how the {func}`adaptive.utils.daskify` decorator works. + In order to combine reuse of values of `g` with adaptive, we need to convert `f` into a dask graph by using `dask.delayed`. ```{code-cell} ipython3 from dask import delayed -# Convert g and h to dask.Delayed objects +# Convert g and h to dask.Delayed objects, such that they run in the Client g, h = delayed(g), delayed(h) @@ -441,7 +487,7 @@ learner = adaptive.Learner1D(f_parallel, bounds=(-3.5, 3.5)) runner = adaptive.AsyncRunner(learner, loss_goal=0.01, ntasks=20) ``` -Finally we await for the runner to finish, and then plot the result. +Finally we wait for the runner to finish, and then plot the result. ```{code-cell} ipython3 await runner.task diff --git a/docs/source/tutorial/tutorial.parallelism.md b/docs/source/tutorial/tutorial.parallelism.md index ef0963a3a..6b91f1266 100644 --- a/docs/source/tutorial/tutorial.parallelism.md +++ b/docs/source/tutorial/tutorial.parallelism.md @@ -57,6 +57,8 @@ runner.live_info() runner.live_plot(update_interval=0.1) ``` +Also check out the {ref}`Custom parallelization` section in the {ref}`advanced topics tutorial` for more control over caching and parallelization. + ## `mpi4py.futures.MPIPoolExecutor` This makes sense if you want to run a `Learner` on a cluster non-interactively using a job script. From 88f02588cf66d0276b9b77da466aa71c7c1f1773 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Jul 2023 14:26:48 -0700 Subject: [PATCH 26/37] Add Learner2D loss function 'thresholded_loss_factory' (#437) * Add Learner2D loss function 'thresholded_loss_factory' * Fix * simplify * Rename and test * test --- adaptive/learner/learner2D.py | 59 +++++++++++++++++++++++++++++++++ adaptive/tests/test_learners.py | 1 + 2 files changed, 60 insertions(+) diff --git a/adaptive/learner/learner2D.py b/adaptive/learner/learner2D.py index a2aec2069..1ea381794 100644 --- a/adaptive/learner/learner2D.py +++ b/adaptive/learner/learner2D.py @@ -231,6 +231,65 @@ def default_loss(ip: LinearNDInterpolator) -> np.ndarray: return losses +def thresholded_loss_function( + lower_threshold: float | None = None, + upper_threshold: float | None = None, + priority_factor: float = 0.1, +) -> Callable[[LinearNDInterpolator], np.ndarray]: + """ + Factory function to create a custom loss function that deprioritizes + values above an upper threshold and below a lower threshold. + + Parameters + ---------- + lower_threshold : float, optional + The lower threshold for deprioritizing values. If None (default), + there is no lower threshold. + upper_threshold : float, optional + The upper threshold for deprioritizing values. If None (default), + there is no upper threshold. + priority_factor : float, default: 0.1 + The factor by which the loss is multiplied for values outside + the specified thresholds. + + Returns + ------- + custom_loss : Callable[[LinearNDInterpolator], np.ndarray] + A custom loss function that can be used with Learner2D. + """ + + def custom_loss(ip: LinearNDInterpolator) -> np.ndarray: + """Loss function that deprioritizes values outside an upper and lower threshold. + + Parameters + ---------- + ip : `scipy.interpolate.LinearNDInterpolator` instance + + Returns + ------- + losses : numpy.ndarray + Loss per triangle in ``ip.tri``. + """ + losses = default_loss(ip) + + if lower_threshold is not None or upper_threshold is not None: + simplices = ip.tri.simplices + values = ip.values[simplices] + if lower_threshold is not None: + mask_lower = (values < lower_threshold).all(axis=(1, -1)) + if mask_lower.any(): + losses[mask_lower] *= priority_factor + + if upper_threshold is not None: + mask_upper = (values > upper_threshold).all(axis=(1, -1)) + if mask_upper.any(): + losses[mask_upper] *= priority_factor + + return losses + + return custom_loss + + def choose_point_in_triangle(triangle: np.ndarray, max_badness: int) -> np.ndarray: """Choose a new point in inside a triangle. diff --git a/adaptive/tests/test_learners.py b/adaptive/tests/test_learners.py index 17af7f9b8..e32aa75ef 100644 --- a/adaptive/tests/test_learners.py +++ b/adaptive/tests/test_learners.py @@ -53,6 +53,7 @@ adaptive.learner.learner2D.uniform_loss, adaptive.learner.learner2D.minimize_triangle_surface_loss, adaptive.learner.learner2D.resolution_loss_function(), + adaptive.learner.learner2D.thresholded_loss_function(upper_threshold=0.5), ), ), LearnerND: ( From b77e011a3e8172c8a6b224537ceac107c769c23e Mon Sep 17 00:00:00 2001 From: Joseph Weston Date: Wed, 19 Jul 2023 10:38:00 -0700 Subject: [PATCH 27/37] Ensure periodic saving fires immediately after runner task is finished (#440) * Ensure periodic saving fires immediately after runner task is finished * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- adaptive/runner.py | 3 ++- docs/source/tutorial/tutorial.IntegratorLearner.md | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/adaptive/runner.py b/adaptive/runner.py index d1f39e1e2..644433516 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -839,7 +839,8 @@ def default_save(learner): async def _saver(): while self.status() == "running": method(self.learner) - await asyncio.sleep(interval) + # No asyncio.shield needed, as 'wait' does not cancel any tasks. + await asyncio.wait([self.task], timeout=interval) method(self.learner) # one last time self.saving_task = self.ioloop.create_task(_saver()) diff --git a/docs/source/tutorial/tutorial.IntegratorLearner.md b/docs/source/tutorial/tutorial.IntegratorLearner.md index 50aaf2e5b..12b86e090 100644 --- a/docs/source/tutorial/tutorial.IntegratorLearner.md +++ b/docs/source/tutorial/tutorial.IntegratorLearner.md @@ -4,7 +4,7 @@ jupytext: extension: .md format_name: myst format_version: 0.13 - jupytext_version: 1.14.5 + jupytext_version: 1.14.7 kernelspec: display_name: python3 name: python3 @@ -86,9 +86,7 @@ if not runner.task.done(): ```{code-cell} ipython3 print( - "The integral value is {} with the corresponding error of {}".format( - learner.igral, learner.err - ) + f"The integral value is {learner.igral} with the corresponding error of {learner.err}" ) learner.plot() ``` From cfe628a24dc9bde795654c7df7a2af9fe098df39 Mon Sep 17 00:00:00 2001 From: Joseph Weston Date: Mon, 14 Aug 2023 11:43:36 -0700 Subject: [PATCH 28/37] Add changelog for v1.1.0 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f7b4811..9ef6ab4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # 🗞️ Changelog +## [v1.1.0](https://github.com/python-adaptive/adaptive/tree/v1.1.0) + +[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v1.0.0...v1.1.0) + +**Closed issues:** + +- Target function returns NaN [\#435](https://github.com/python-adaptive/adaptive/issues/435) +- large delay when using start_periodic_saving [\#439](https://github.com/python-adaptive/adaptive/issues/439) + +**Merged pull requests:** + +- Ensure periodic saving fires immediately after runner task is finished [\#440](https://github.com/python-adaptive/adaptive/pull/440) ([jbweston](https://github.com/jbweston)) +- Add Learner2D loss function 'thresholded_loss_factory' [\#437](https://github.com/python-adaptive/adaptive/pull/437) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#433](https://github.com/python-adaptive/adaptive/pull/433) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- \[pre-commit.ci\] pre-commit autoupdate [\#431](https://github.com/python-adaptive/adaptive/pull/431) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) + ## [v1.0.0](https://github.com/python-adaptive/adaptive/tree/v1.0.0) [Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.15.0...v1.0.0) From a7f01eb2e76aac1359cf8a68daceb3660086a815 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 13 Feb 2024 10:57:32 -0800 Subject: [PATCH 29/37] [pre-commit.ci] pre-commit autoupdate (#434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0) - [github.com/psf/black: 23.3.0 → 24.2.0](https://github.com/psf/black/compare/23.3.0...24.2.0) - https://github.com/charliermarsh/ruff-pre-commit → https://github.com/astral-sh/ruff-pre-commit - [github.com/astral-sh/ruff-pre-commit: v0.0.269 → v0.2.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.269...v0.2.1) - [github.com/nbQA-dev/nbQA: 1.7.0 → 1.7.1](https://github.com/nbQA-dev/nbQA/compare/1.7.0...1.7.1) - [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.8.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pre commit fixes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt --- .pre-commit-config.yaml | 12 ++++++------ adaptive/learner/average_learner1D.py | 7 ++++--- adaptive/learner/integrator_learner.py | 1 - adaptive/learner/learner1D.py | 12 +++++++----- adaptive/learner/learnerND.py | 8 +++++--- adaptive/runner.py | 6 +++--- pyproject.toml | 8 +++++--- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c10c533e..2405489e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -9,16 +9,16 @@ repos: - id: debug-statements - id: check-ast - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.2.0 hooks: - id: black-jupyter - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.269" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.2.1" hooks: - id: ruff args: ["--fix"] - repo: https://github.com/nbQA-dev/nbQA - rev: 1.7.0 + rev: 1.7.1 hooks: - id: nbqa-black additional_dependencies: [jupytext, black] @@ -26,7 +26,7 @@ repos: args: ["ruff", "--fix", "--ignore=E402,B018,F704"] additional_dependencies: [jupytext, ruff] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.3.0" + rev: "v1.8.0" hooks: - id: mypy exclude: ipynb_filter.py|docs/source/conf.py diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py index 12c7c9a6c..5dc9097ba 100644 --- a/adaptive/learner/average_learner1D.py +++ b/adaptive/learner/average_learner1D.py @@ -79,8 +79,9 @@ def __init__( self, function: Callable[[tuple[int, Real]], Real], bounds: tuple[Real, Real], - loss_per_interval: None - | (Callable[[Sequence[Real], Sequence[Real]], float]) = None, + loss_per_interval: None | ( + Callable[[Sequence[Real], Sequence[Real]], float] + ) = None, delta: float = 0.2, alpha: float = 0.005, neighbor_sampling: float = 0.3, @@ -310,7 +311,7 @@ def _ask_for_new_point(self, n: int) -> tuple[Points, list[float]]: new point, since in general n << min_samples and this point will need to be resampled many more times""" points, (loss_improvement,) = self._ask_points_without_adding(1) - seed_points = [(seed, x) for seed, x in zip(range(n), n * points)] + seed_points = list(zip(range(n), n * points)) loss_improvements = [loss_improvement / n] * n return seed_points, loss_improvements # type: ignore[return-value] diff --git a/adaptive/learner/integrator_learner.py b/adaptive/learner/integrator_learner.py index 74aebe1ca..0fc97df67 100644 --- a/adaptive/learner/integrator_learner.py +++ b/adaptive/learner/integrator_learner.py @@ -71,7 +71,6 @@ class DivergentIntegralError(ValueError): class _Interval: - """ Attributes ---------- diff --git a/adaptive/learner/learner1D.py b/adaptive/learner/learner1D.py index 8385e67b8..bf04743bd 100644 --- a/adaptive/learner/learner1D.py +++ b/adaptive/learner/learner1D.py @@ -135,7 +135,7 @@ def triangle_loss(xs: XsType1, ys: YsType1) -> Float: pts = [(x, *y) for x, y in zip(xs, ys)] # type: ignore[misc] vol = simplex_volume_in_embedding else: - pts = [(x, y) for x, y in zip(xs, ys)] + pts = list(zip(xs, ys)) vol = volume return sum(vol(pts[i : i + 3]) for i in range(N)) / N @@ -633,10 +633,12 @@ def tell_pending(self, x: float) -> None: def tell_many( self, xs: Sequence[Float] | np.ndarray, - ys: Sequence[Float] - | Sequence[Sequence[Float]] - | Sequence[np.ndarray] - | np.ndarray, + ys: ( + Sequence[Float] + | Sequence[Sequence[Float]] + | Sequence[np.ndarray] + | np.ndarray + ), *, force: bool = False, ) -> None: diff --git a/adaptive/learner/learnerND.py b/adaptive/learner/learnerND.py index edc839d8d..c8eacea2f 100644 --- a/adaptive/learner/learnerND.py +++ b/adaptive/learner/learnerND.py @@ -987,9 +987,11 @@ def plot_slice(self, cut_mapping, n=None): xs = ys = np.linspace(0, 1, n) xys = [xs[:, None], ys[None, :]] values = [ - cut_mapping[i] - if i in cut_mapping - else xys.pop(0) * (b[1] - b[0]) + b[0] + ( + cut_mapping[i] + if i in cut_mapping + else xys.pop(0) * (b[1] - b[0]) + b[0] + ) for i, b in enumerate(self._bbox) ] diff --git a/adaptive/runner.py b/adaptive/runner.py index 644433516..58abc7139 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -470,7 +470,7 @@ def __init__( npoints_goal: int | None = None, end_time_goal: datetime | None = None, duration_goal: timedelta | int | float | None = None, - executor: (ExecutorTypes | None) = None, + executor: ExecutorTypes | None = None, ntasks: int | None = None, log: bool = False, shutdown_executor: bool = False, @@ -629,7 +629,7 @@ def __init__( npoints_goal: int | None = None, end_time_goal: datetime | None = None, duration_goal: timedelta | int | float | None = None, - executor: (ExecutorTypes | None) = None, + executor: ExecutorTypes | None = None, ntasks: int | None = None, log: bool = False, shutdown_executor: bool = False, @@ -956,7 +956,7 @@ def _ensure_executor(executor: ExecutorTypes | None) -> concurrent.Executor: def _get_ncores( - ex: (ExecutorTypes), + ex: ExecutorTypes, ) -> int: """Return the maximum number of cores that an executor can use.""" if with_ipyparallel: diff --git a/pyproject.toml b/pyproject.toml index f0ea8eddf..1cdc34dfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,8 @@ python_version = "3.9" [tool.ruff] line-length = 150 target-version = "py39" + +[tool.ruff.lint] select = ["B", "C", "E", "F", "W", "T", "B9", "I", "UP"] ignore = [ "T20", # flake8-print @@ -109,14 +111,14 @@ ignore = [ "D401", # First line of docstring should be in imperative mood ] +[tool.ruff.lint.mccabe] +max-complexity = 18 + [tool.ruff.per-file-ignores] "tests/*" = ["SLF001"] "ci/*" = ["INP001"] "tests/test_examples.py" = ["E501"] -[tool.ruff.mccabe] -max-complexity = 18 - [tool.versioningit] [tool.versioningit.vcs] From bdcbbf35d808add277063cd473d67b1706b57a96 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Feb 2024 10:59:49 -0800 Subject: [PATCH 30/37] Add `AsyncRunner.block_until_done` (#444) * Add `AsyncRunner.block_until_done` I have answered this question so often that I think it warrents a new method. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use runner.block_until_done --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- adaptive/runner.py | 8 ++++++++ adaptive/tests/test_runner.py | 7 +++---- docs/source/tutorial/tutorial.parallelism.md | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/adaptive/runner.py b/adaptive/runner.py index 58abc7139..8ba3e0e7f 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -722,6 +722,14 @@ def cancel(self) -> None: """ self.task.cancel() + def block_until_done(self) -> None: + if in_ipynb(): + raise RuntimeError( + "Cannot block the event loop when running in a Jupyter notebook." + " Use `await runner.task` instead." + ) + self.ioloop.run_until_complete(self.task) + def live_plot( self, *, diff --git a/adaptive/tests/test_runner.py b/adaptive/tests/test_runner.py index e36abcbe1..f6bb6031e 100644 --- a/adaptive/tests/test_runner.py +++ b/adaptive/tests/test_runner.py @@ -1,4 +1,3 @@ -import asyncio import platform import sys import time @@ -34,7 +33,7 @@ def blocking_runner(learner, **kw): def async_runner(learner, **kw): runner = AsyncRunner(learner, executor=SequentialExecutor(), **kw) - asyncio.get_event_loop().run_until_complete(runner.task) + runner.block_until_done() runners = [simple, blocking_runner, async_runner] @@ -71,7 +70,7 @@ async def f(x): learner = Learner1D(f, (-1, 1)) runner = AsyncRunner(learner, npoints_goal=10) - asyncio.get_event_loop().run_until_complete(runner.task) + runner.block_until_done() # --- Test with different executors @@ -158,7 +157,7 @@ def test_loky_executor(loky_executor): def test_default_executor(): learner = Learner1D(linear, (-1, 1)) runner = AsyncRunner(learner, npoints_goal=10) - asyncio.get_event_loop().run_until_complete(runner.task) + runner.block_until_done() def test_auto_goal(): diff --git a/docs/source/tutorial/tutorial.parallelism.md b/docs/source/tutorial/tutorial.parallelism.md index 6b91f1266..5decc61d5 100644 --- a/docs/source/tutorial/tutorial.parallelism.md +++ b/docs/source/tutorial/tutorial.parallelism.md @@ -89,7 +89,7 @@ if __name__ == "__main__": runner.start_periodic_saving(dict(fname=fname), interval=600) # block until runner goal reached - runner.ioloop.run_until_complete(runner.task) + runner.block_until_done() # save one final time before exiting learner.save(fname) From 6c2bc1f2f5d68b0a0c87e6632c532db17a08c34e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Feb 2024 11:06:12 -0800 Subject: [PATCH 31/37] Add `live_info_terminal`, closes #436 (#441) * Add live_info_terminal, closes #436 * Add time * rename var * Add doc-string --- adaptive/runner.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/adaptive/runner.py b/adaptive/runner.py index 8ba3e0e7f..84fcecd9f 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -776,6 +776,55 @@ def live_info(self, *, update_interval: float = 0.1) -> None: """ return live_info(self, update_interval=update_interval) + def live_info_terminal( + self, *, update_interval: float = 0.5, overwrite_previous: bool = True + ) -> asyncio.Task: + """ + Display live information about the runner in the terminal. + + This function provides a live update of the runner's status in the terminal. + The update can either overwrite the previous status or be printed on a new line. + + Parameters + ---------- + update_interval : float, optional + The time interval (in seconds) at which the runner's status is updated in the terminal. + Default is 0.5 seconds. + overwrite_previous : bool, optional + If True, each update will overwrite the previous status in the terminal. + If False, each update will be printed on a new line. + Default is True. + + Returns + ------- + asyncio.Task + The asynchronous task responsible for updating the runner's status in the terminal. + + Examples + -------- + >>> runner = AsyncRunner(...) + >>> runner.live_info_terminal(update_interval=1.0, overwrite_previous=False) + + Notes + ----- + This function uses ANSI escape sequences to control the terminal's cursor position. + It might not work as expected on all terminal emulators. + """ + + async def _update(runner: AsyncRunner) -> None: + try: + while not runner.task.done(): + if overwrite_previous: + # Clear the terminal + print("\033[H\033[J", end="") + print(_info_text(runner, separator="\t")) + await asyncio.sleep(update_interval) + + except asyncio.CancelledError: + print("Live info display cancelled.") + + return self.ioloop.create_task(_update(self)) + async def _run(self) -> None: first_completed = asyncio.FIRST_COMPLETED @@ -855,6 +904,43 @@ async def _saver(): return self.saving_task +def _info_text(runner, separator: str = "\n"): + status = runner.status() + + color_map = { + "cancelled": "\033[33m", # Yellow + "failed": "\033[31m", # Red + "running": "\033[34m", # Blue + "finished": "\033[32m", # Green + } + + overhead = runner.overhead() + if overhead < 50: + overhead_color = "\033[32m" # Green + else: + overhead_color = "\033[31m" # Red + + info = [ + ("time", str(datetime.now())), + ("status", f"{color_map[status]}{status}\033[0m"), + ("elapsed time", str(timedelta(seconds=runner.elapsed_time()))), + ("overhead", f"{overhead_color}{overhead:.2f}%\033[0m"), + ] + + with suppress(Exception): + info.append(("# of points", runner.learner.npoints)) + + with suppress(Exception): + info.append(("# of samples", runner.learner.nsamples)) + + with suppress(Exception): + info.append(("latest loss", f'{runner.learner._cache["loss"]:.3f}')) + + width = 30 + formatted_info = [f"{k}: {v}".ljust(width) for i, (k, v) in enumerate(info)] + return separator.join(formatted_info) + + # Default runner Runner = AsyncRunner From b883911fbab6aede5460a90d0d65d2c83830bba7 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Feb 2024 11:08:37 -0800 Subject: [PATCH 32/37] Bump versions to compatible packages in `docs/environment.yml` (#445) --- docs/environment.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 67298496c..2e9d08909 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -9,9 +9,9 @@ dependencies: - scikit-optimize=0.9.0 - scikit-learn=1.2.2 - scipy=1.10.1 - - holoviews=1.15.4 - - bokeh=2.4.3 - - panel=0.14.4 + - holoviews=1.18.3 + - bokeh=3.3.4 + - panel=1.3.8 - pandas=2.0.0 - plotly=5.14.1 - ipywidgets=8.0.6 @@ -23,6 +23,8 @@ dependencies: - loky=3.3.0 - furo=2023.3.27 - myst-parser=0.18.1 - - dask=2023.3.2 + - dask=2024.2.0 - emoji=2.2.0 - versioningit=2.2.0 + - distributed=2024.2.0 + - param=2.0.2 From 378f9f6f641b7ad776ba4c8babe837e0c74d7554 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Feb 2024 11:36:44 -0800 Subject: [PATCH 33/37] Add benchmarks page for Learner1D and Learner2D functions (#405) --- docs/source/benchmarks.md | 515 ++++++++++++++++++++++++++++++++++++++ docs/source/index.md | 1 + 2 files changed, 516 insertions(+) create mode 100644 docs/source/benchmarks.md diff --git a/docs/source/benchmarks.md b/docs/source/benchmarks.md new file mode 100644 index 000000000..1e8cc4441 --- /dev/null +++ b/docs/source/benchmarks.md @@ -0,0 +1,515 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.14.5 +kernelspec: + display_name: adaptive + language: python + name: python3 +--- + +# Benchmarks + +```{tip} +This page is a Jupyter notebook that can be downloaded and run locally. [^download] +``` + +Adaptive sampling is a powerful technique for approximating functions with varying degrees of complexity across their domain. +This approach is particularly useful for functions with sharp features or rapid changes, as it focuses on calculating more points around those areas. +By concentrating points where they are needed most, adaptive sampling can provide an accurate representation of the function with fewer points compared to uniform sampling. +This results in both faster convergence and a more accurate representation of the function. + +On this benchmark showcase, we will explore the effectiveness of adaptive sampling for various 1D and 2D functions, including sharp peaks, Gaussian, sinusoidal, exponential decay, and Lorentzian functions. +We will also present benchmarking results to highlight the advantages (and disadvantages) of adaptive sampling over uniform sampling in terms of an error ratio, which is the ratio of uniform error to learner error (see the note about the error below). + +Look below where we demonstrate the use of the adaptive package to perform adaptive sampling and visualize the results. +By the end of this benchmarking showcase, you should better understand of the benefits of adaptive sampling and in which cases you could to apply this technique to your own simulations or functions. + +```{note} +> Note on error estimates + +The error is estimated using the L1 norm of the difference between the true function values and the interpolated values. Here's a step-by-step explanation of how the error is calculated: + +1. For each benchmark function, two learners are created: the adaptive learner and a homogeneous learner. The adaptive learner uses adaptive sampling, while the homogeneous learner uses a uniform grid of points. + +2. After the adaptive learning is complete, the error is calculated by comparing the interpolated values obtained from the adaptive learner to the true function values evaluated at the points used by the homogeneous learner. + +3. To calculate the error, the L1 norm is used. The L1 norm represents the average of the absolute differences between the true function values and the interpolated values. Specifically, it is calculated as the square root of the mean of the squared differences between the true function values and the interpolated values. + +Note that the choice of the L1 norm is somewhat arbitrary. +**Please judge the results for yourself by looking at the plots** and observe the significantly better function approximation obtained by the adaptive learner. +``` + +```{warning} +> Note on benchmark functions + +The benchmark functions used in this tutorial are analytical and cheap to evaluate. +In real-world applications ([see the gallery](gallery)), adaptive sampling is often more beneficial for expensive simulations where function evaluations are computationally demanding or time-consuming. +``` + +## Benchmarks 1D + +```{code-cell} ipython3 +:tags: [hide-cell] + +from __future__ import annotations + +import itertools + +import holoviews as hv +import numpy as np +import pandas as pd +from scipy.interpolate import interp1d + +import adaptive + +adaptive.notebook_extension() + +benchmarks = {} +benchmarks_2d = {} + + +def homogeneous_learner(learner): + if isinstance(learner, adaptive.Learner1D): + xs = np.linspace(*learner.bounds, learner.npoints) + homo_learner = adaptive.Learner1D(learner.function, learner.bounds) + homo_learner.tell_many(xs, learner.function(xs)) + else: + homo_learner = adaptive.Learner2D(learner.function, bounds=learner.bounds) + n = int(learner.npoints**0.5) + xs, ys = (np.linspace(*bounds, n) for bounds in learner.bounds) + xys = list(itertools.product(xs, ys)) + zs = map(homo_learner.function, xys) + homo_learner.tell_many(xys, zs) + return homo_learner + + +def plot(learner, other_learner): + if isinstance(learner, adaptive.Learner1D): + return learner.plot() + other_learner.plot() + else: + n = int(learner.npoints**0.5) + return ( + ( + other_learner.plot(n).relabel("Homogeneous grid") + + learner.plot().relabel("With adaptive") + + other_learner.plot(n, tri_alpha=0.4) + + learner.plot(tri_alpha=0.4) + ) + .cols(2) + .options(hv.opts.EdgePaths(color="w")) + ) + + +def err(ys, ys_other): + abserr = np.abs(ys - ys_other) + return np.average(abserr**2) ** 0.5 + + +def l1_norm_error(learner, other_learner): + if isinstance(learner, adaptive.Learner1D): + ys_interp = interp1d(*learner.to_numpy().T) + xs, _ = other_learner.to_numpy().T + ys = ys_interp(xs) # interpolate the other learner's points + _, ys_other = other_learner.to_numpy().T + return err(ys, ys_other) + else: + xys = other_learner.to_numpy()[:, :2] + zs = learner.function(xys.T) + interpolator = learner.interpolator() + zs_interp = interpolator(xys) + # Compute the L1 norm error between the true function and the interpolator + return err(zs_interp, zs) + + +def run_and_plot(learner, **goal): + adaptive.runner.simple(learner, **goal) + homo_learner = homogeneous_learner(learner) + bms = benchmarks if isinstance(learner, adaptive.Learner1D) else benchmarks_2d + bm = { + "npoints": learner.npoints, + "error": l1_norm_error(learner, homo_learner), + "uniform_error": l1_norm_error(homo_learner, learner), + } + bm["error_ratio"] = bm["uniform_error"] / bm["error"] + bms[learner.function.__name__] = bm + display(pd.DataFrame([bm])) # noqa: F821 + return plot(learner, homo_learner).relabel( + f"{learner.function.__name__} function with {learner.npoints} points" + ) + + +def to_df(benchmarks): + df = pd.DataFrame(benchmarks).T + df.sort_values("error_ratio", ascending=False, inplace=True) + return df + + +def plot_benchmarks(df, max_ratio: float = 1000, *, log_scale: bool = True): + import matplotlib.pyplot as plt + import numpy as np + + df_hist = df.copy() + + # Replace infinite values with 1000 + df_hist.loc[np.isinf(df_hist.error_ratio), "error_ratio"] = max_ratio + + # Convert the DataFrame index (function names) into a column + df_hist.reset_index(inplace=True) + df_hist.rename(columns={"index": "function_name"}, inplace=True) + + # Create a list of colors based on the error_ratio values + bar_colors = ["green" if x > 1 else "red" for x in df_hist["error_ratio"]] + + # Create the bar chart + plt.figure(figsize=(12, 6)) + plt.bar(df_hist["function_name"], df_hist["error_ratio"], color=bar_colors) + + # Add a dashed horizontal line at 1 + plt.axhline(y=1, linestyle="--", color="gray", linewidth=1) + + if log_scale: + # Set the y-axis to log scale + plt.yscale("log") + + # Customize the plot + plt.xlabel("Function Name") + plt.ylabel("Error Ratio (uniform Error / Learner Error)") + plt.title("Error Ratio Comparison for Different Functions") + plt.xticks(rotation=45) + + # Show the plot + plt.show() +``` + +1. **Sharp peak function**: + +In the case of the sharp peak function, adaptive sampling performs very well because it can capture the peak by calculating more points around it, while still accurately representing the smoother regions of the function with fewer points. + +```{code-cell} ipython3 +def peak(x, offset=0.123): + a = 0.01 + return x + a**2 / (a**2 + (x - offset) ** 2) + + +learner = adaptive.Learner1D(peak, bounds=(-1, 1)) +run_and_plot(learner, loss_goal=0.1) +``` + +2. **Gaussian function**: + +For smoother functions, like the Gaussian function, adaptive sampling may not provide a significant advantage over uniform sampling. +Nonetheless, the algorithm still focuses on areas of the function that have more rapid changes, but the improvement over uniform sampling might be less noticeable. + +```{code-cell} ipython3 +def gaussian(x, mu=0, sigma=0.5): + return (1 / np.sqrt(2 * np.pi * sigma**2)) * np.exp( + -((x - mu) ** 2) / (2 * sigma**2) + ) + + +learner = adaptive.Learner1D(gaussian, bounds=(-5, 5)) +run_and_plot(learner, loss_goal=0.1) +``` + +3. **Sinusoidal function**: + +The sinusoidal function is another example of a smoother function where adaptive sampling doesn't provide a substantial advantage over uniform sampling. + +```{code-cell} ipython3 +def sinusoidal(x, amplitude=1, frequency=1, phase=0): + return amplitude * np.sin(frequency * x + phase) + + +learner = adaptive.Learner1D(sinusoidal, bounds=(-2 * np.pi, 2 * np.pi)) +run_and_plot(learner, loss_goal=0.1) +``` + +4. **Exponential decay function**: + +Adaptive sampling can be useful for the exponential decay function, as it focuses on the steeper part of the curve and allocates fewer points to the flatter region. + +```{code-cell} ipython3 +def exponential_decay(x, tau=1): + return np.exp(-x / tau) + + +learner = adaptive.Learner1D(exponential_decay, bounds=(0, 5)) +run_and_plot(learner, loss_goal=0.1) +``` + +5. **Lorentzian function**: + +The Lorentzian function is another example of a function with a sharp peak. +Adaptive sampling performs well in this case, as it concentrates points around the peak while calculating fewer points to the smoother regions of the function. + +```{code-cell} ipython3 +def lorentzian(x, x0=0, gamma=0.3): + return (1 / np.pi) * (gamma / 2) / ((x - x0) ** 2 + (gamma / 2) ** 2) + + +learner = adaptive.Learner1D(lorentzian, bounds=(-5, 5)) +run_and_plot(learner, loss_goal=0.1) +``` + +6. **Sinc function**: + +The sinc function has oscillatory behavior with varying amplitude. +Adaptive sampling is helpful in this case, as it can allocate more points around the oscillations, effectively capturing the shape of the function. + +```{code-cell} ipython3 +def sinc(x): + return np.sinc(x / np.pi) + + +learner = adaptive.Learner1D(sinc, bounds=(-10, 10)) +run_and_plot(learner, loss_goal=0.1) +``` + +7. **Step function (Heaviside)**: + +In the case of the step function, adaptive sampling efficiently allocates more points around the discontinuity, providing an accurate representation of the function. + +```{code-cell} ipython3 +import numpy as np + + +def step(x, x0=0): + return np.heaviside(x - x0, 0.5) + + +learner = adaptive.Learner1D(step, bounds=(-5, 5)) +run_and_plot(learner, npoints_goal=20) +``` + +8. **Damped oscillation function**: + +The damped oscillation function has both oscillatory behavior and a decay component. +Adaptive sampling can effectively capture the behavior of this function, calculating more points around the oscillations while using fewer points in the smoother regions. + +```{code-cell} ipython3 +def damped_oscillation(x, a=1, omega=1, gamma=0.1): + return a * np.exp(-gamma * x) * np.sin(omega * x) + + +learner = adaptive.Learner1D(damped_oscillation, bounds=(-10, 10)) +run_and_plot(learner, loss_goal=0.1) +``` + +9. **Bump function (smooth function with compact support)**: + +For the bump function, adaptive sampling concentrates points around the region of the bump, efficiently capturing its shape and calculating fewer points in the flatter regions. + +```{code-cell} ipython3 +def bump(x, a=1, x0=0, s=0.5): + z = (x - x0) / s + return np.where(np.abs(z) < 1, a * np.exp(-1 / (1 - z**2)), 0) + + +learner = adaptive.Learner1D(bump, bounds=(-5, 5)) +run_and_plot(learner, loss_goal=0.1) +``` + +### Results + ++++ + +In summary, adaptive sampling is a powerful approach for approximating functions with sharp features or varying degrees of complexity across their domain. +It can efficiently allocate points where they are needed most, providing an accurate representation of the function while reducing the total number of points required. +For smoother functions, adaptive sampling still focuses on areas with more rapid changes but may not provide significant advantages over uniform sampling. + +```{code-cell} ipython3 +df = to_df(benchmarks) +df +``` + +```{code-cell} ipython3 +plot_benchmarks(df) +``` + +## Benchmarks 2D + ++++ + +1. **Sharp ring**: + +This function has a ring structure in 2D. + +```{code-cell} ipython3 +def ring(xy, a=0.2): + x, y = xy + return x + np.exp(-((x**2 + y**2 - 0.75**2) ** 2) / a**4) + + +learner = adaptive.Learner2D(ring, bounds=[(-1, 1), (-1, 1)]) +run_and_plot(learner, npoints_goal=1000) +``` + +1. **Gaussian surface**: +The Gaussian surface is a smooth, bell-shaped function in 2D. +It has a peak at the mean (mu) and spreads out with increasing standard deviation (sigma). +Adaptive sampling works well in this case because it can focus on the region around the peak where the function changes rapidly, while using fewer points in the flatter regions where the function changes slowly. + +```{code-cell} ipython3 +def gaussian_surface(xy, mu=(0, 0), sigma=(1, 1)): + x, y = xy + mu_x, mu_y = mu + sigma_x, sigma_y = sigma + return (1 / (2 * np.pi * sigma_x * sigma_y)) * np.exp( + -((x - mu_x) ** 2 / (2 * sigma_x**2) + (y - mu_y) ** 2 / (2 * sigma_y**2)) + ) + + +learner = adaptive.Learner2D(gaussian_surface, bounds=[(-5, 5), (-5, 5)]) +run_and_plot(learner, loss_goal=0.01) +``` + +2. **Sinusoidal surface**: +The sinusoidal surface is a product of two sinusoidal functions in the x and y directions. +The surface has a regular pattern of peaks and valleys. +Adaptive sampling works well in this case because it can adapt to the frequency of the sinusoidal pattern and allocate more points to areas with higher curvature, ensuring an accurate representation of the function. + +```{code-cell} ipython3 +def sinusoidal_surface(xy, amplitude=1, frequency=(0.3, 3)): + x, y = xy + freq_x, freq_y = frequency + return amplitude * np.sin(freq_x * x) * np.sin(freq_y * y) + + +learner = adaptive.Learner2D( + sinusoidal_surface, bounds=[(-2 * np.pi, 2 * np.pi), (-2 * np.pi, 2 * np.pi)] +) +run_and_plot(learner, loss_goal=0.01) +``` + +```{code-cell} ipython3 +def circular_peak(xy, x0=0, y0=0, a=0.01): + x, y = xy + r = np.sqrt((x - x0) ** 2 + (y - y0) ** 2) + return r + a**2 / (a**2 + r**2) + + +learner = adaptive.Learner2D(circular_peak, bounds=[(-1, 1), (-1, 1)]) +run_and_plot(learner, loss_goal=0.01) +``` + +4. **Paraboloid**: + +The paraboloid is a smooth, curved surface defined by a quadratic function in the x and y directions. +Adaptive sampling is less beneficial for this function compared to functions with sharp features, as the curvature is relatively constant across the entire surface. +However, the adaptive algorithm can still provide a good representation of the paraboloid with fewer points than a uniform grid. + +```{code-cell} ipython3 +def paraboloid(xy, a=1, b=1): + x, y = xy + return a * x**2 + b * y**2 + + +learner = adaptive.Learner2D(paraboloid, bounds=[(-5, 5), (-5, 5)]) +run_and_plot(learner, loss_goal=0.01) +``` + +5. **Cross-shaped function**: + +This function has a cross-shaped structure in 2D. + +```{code-cell} ipython3 +def cross(xy, a=0.2): + x, y = xy + return np.exp(-(x**2 + y**2) / a**2) * ( + np.cos(4 * np.pi * x) + np.cos(4 * np.pi * y) + ) + + +learner = adaptive.Learner2D(cross, bounds=[(-1, 1), (-1, 1)]) +run_and_plot(learner, npoints_goal=1000) +``` + +6. **Mexican hat function (Ricker wavelet)**: + +This function has a central peak surrounded by a circular trough. + +```{code-cell} ipython3 +def mexican_hat(xy, a=1): + x, y = xy + r2 = x**2 + y**2 + return a * (1 - r2) * np.exp(-r2 / 2) + + +learner = adaptive.Learner2D(mexican_hat, bounds=[(-2, 2), (-2, 2)]) +run_and_plot(learner, npoints_goal=1000) +``` + +7. **Saddle surface**: + +This function has a saddle shape with increasing curvature along the diagonal. + +```{code-cell} ipython3 +def saddle(xy, a=1, b=1): + x, y = xy + return a * x**2 - b * y**2 + + +learner = adaptive.Learner2D(saddle, bounds=[(-2, 2), (-2, 2)]) +run_and_plot(learner, npoints_goal=1000) +``` + +8. **Steep linear ramp**: + +This function has a steep linear ramp in a narrow region. + +```{code-cell} ipython3 +def steep_ramp(xy, width=0.1): + x, y = xy + result = np.where((-width / 2 < x) & (x < width / 2), 10 * x + y, y) + return result + + +learner = adaptive.Learner2D(steep_ramp, bounds=[(-1, 1), (-1, 1)]) +run_and_plot(learner, loss_goal=0.005) +``` + +9. **Localized sharp peak**: + +This function has a sharp peak in a small localized area. + +```{code-cell} ipython3 +def localized_sharp_peak(xy, x0=0, y0=0, a=0.01): + x, y = xy + r = np.sqrt((x - x0) ** 2 + (y - y0) ** 2) + return r + a**4 / (a**4 + r**4) + + +learner = adaptive.Learner2D(localized_sharp_peak, bounds=[(-1, 1), (-1, 1)]) +run_and_plot(learner, loss_goal=0.01) +``` + +10. **Ridge function**: + +A function with a narrow ridge along the x-axis, which can be controlled by a parameter `b`. + +```{code-cell} ipython3 +def ridge_function(xy, b=100): + x, y = xy + return np.exp(-b * y**2) * np.sin(x) + + +learner = adaptive.Learner2D(ridge_function, bounds=[(-2, 2), (-1, 1)]) +run_and_plot(learner, loss_goal=0.01) +``` + +### Results + +```{code-cell} ipython3 +df = to_df(benchmarks_2d) +df[["npoints", "error_ratio"]] +``` + +```{code-cell} ipython3 +plot_benchmarks(df) +``` + +[^download]: This notebook can be downloaded as **{nb-download}`benchmarks.ipynb`** and {download}`benchmarks.md`. diff --git a/docs/source/index.md b/docs/source/index.md index aff03a56a..b1bfe8977 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -41,6 +41,7 @@ self algorithms_and_examples docs tutorial/tutorial +benchmarks gallery reference/adaptive CHANGELOG From d2c80418b5e6055b524f5fda1b36d0f8cde40868 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 13 Feb 2024 12:10:29 -0800 Subject: [PATCH 34/37] Use ruff-format instead of black (#446) --- .pre-commit-config.yaml | 5 +---- adaptive/learner/average_learner1D.py | 5 ++--- adaptive/runner.py | 14 ++++++++------ adaptive/tests/algorithm_4.py | 6 +++++- pyproject.toml | 5 +++-- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2405489e1..833bcbdcb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,15 +8,12 @@ repos: - id: check-yaml - id: debug-statements - id: check-ast - - repo: https://github.com/psf/black - rev: 24.2.0 - hooks: - - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.2.1" hooks: - id: ruff args: ["--fix"] + - id: ruff-format - repo: https://github.com/nbQA-dev/nbQA rev: 1.7.1 hooks: diff --git a/adaptive/learner/average_learner1D.py b/adaptive/learner/average_learner1D.py index 5dc9097ba..9678b4f64 100644 --- a/adaptive/learner/average_learner1D.py +++ b/adaptive/learner/average_learner1D.py @@ -79,9 +79,8 @@ def __init__( self, function: Callable[[tuple[int, Real]], Real], bounds: tuple[Real, Real], - loss_per_interval: None | ( - Callable[[Sequence[Real], Sequence[Real]], float] - ) = None, + loss_per_interval: None + | (Callable[[Sequence[Real], Sequence[Real]], float]) = None, delta: float = 0.2, alpha: float = 0.005, neighbor_sampling: float = 0.3, diff --git a/adaptive/runner.py b/adaptive/runner.py index 84fcecd9f..4f877096f 100644 --- a/adaptive/runner.py +++ b/adaptive/runner.py @@ -788,8 +788,8 @@ def live_info_terminal( Parameters ---------- update_interval : float, optional - The time interval (in seconds) at which the runner's status is updated in the terminal. - Default is 0.5 seconds. + The time interval (in seconds) at which the runner's status is updated + in the terminal. Default is 0.5 seconds. overwrite_previous : bool, optional If True, each update will overwrite the previous status in the terminal. If False, each update will be printed on a new line. @@ -798,7 +798,8 @@ def live_info_terminal( Returns ------- asyncio.Task - The asynchronous task responsible for updating the runner's status in the terminal. + The asynchronous task responsible for updating the runner's status in + the terminal. Examples -------- @@ -807,8 +808,8 @@ def live_info_terminal( Notes ----- - This function uses ANSI escape sequences to control the terminal's cursor position. - It might not work as expected on all terminal emulators. + This function uses ANSI escape sequences to control the terminal's cursor + position. It might not work as expected on all terminal emulators. """ async def _update(runner: AsyncRunner) -> None: @@ -1189,7 +1190,8 @@ def auto_goal( for lrn in learner.learners ] return lambda learner: all( - goal(lrn) for lrn, goal in zip(learner.learners, goals) # type: ignore[attr-defined] + goal(lrn) + for lrn, goal in zip(learner.learners, goals) # type: ignore[attr-defined] ) if npoints is not None: return lambda learner: learner.npoints >= npoints # type: ignore[operator] diff --git a/adaptive/tests/algorithm_4.py b/adaptive/tests/algorithm_4.py index b010b667a..27832298e 100644 --- a/adaptive/tests/algorithm_4.py +++ b/adaptive/tests/algorithm_4.py @@ -319,7 +319,11 @@ def refine(self, f: Callable) -> tuple[np.ndarray, bool, int]: def algorithm_4( - f: Callable, a: int, b: int, tol: float, N_loops: int = int(1e9) # noqa: B008 + f: Callable, + a: int, + b: int, + tol: float, + N_loops: int = int(1e9), # noqa: B008 ) -> tuple[float, float, int, list[_Interval]]: """ALGORITHM_4 evaluates an integral using adaptive quadrature. The algorithm uses Clenshaw-Curtis quadrature rules of increasing diff --git a/pyproject.toml b/pyproject.toml index 1cdc34dfd..63037d1ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ ignore_missing_imports = true python_version = "3.9" [tool.ruff] -line-length = 150 +line-length = 88 target-version = "py39" [tool.ruff.lint] @@ -109,12 +109,13 @@ ignore = [ "D402", # First line should not be the function's signature "PLW0603", # Using the global statement to update `X` is discouraged "D401", # First line of docstring should be in imperative mood + "E501", # Line too long ] [tool.ruff.lint.mccabe] max-complexity = 18 -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/*" = ["SLF001"] "ci/*" = ["INP001"] "tests/test_examples.py" = ["E501"] From 5a5d1de67c187947a3277d9098c441d1882061ff Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Apr 2024 21:52:42 +0200 Subject: [PATCH 35/37] [pre-commit.ci] pre-commit autoupdate (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0) - [github.com/astral-sh/ruff-pre-commit: v0.2.1 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.1...v0.3.5) - [github.com/nbQA-dev/nbQA: 1.7.1 → 1.8.5](https://github.com/nbQA-dev/nbQA/compare/1.7.1...1.8.5) - [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 833bcbdcb..5a0a4184c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -9,13 +9,13 @@ repos: - id: debug-statements - id: check-ast - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.2.1" + rev: "v0.3.5" hooks: - id: ruff args: ["--fix"] - id: ruff-format - repo: https://github.com/nbQA-dev/nbQA - rev: 1.7.1 + rev: 1.8.5 hooks: - id: nbqa-black additional_dependencies: [jupytext, black] @@ -23,7 +23,7 @@ repos: args: ["ruff", "--fix", "--ignore=E402,B018,F704"] additional_dependencies: [jupytext, ruff] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.8.0" + rev: "v1.9.0" hooks: - id: mypy exclude: ipynb_filter.py|docs/source/conf.py From 8def2c248a05c10d0e1892da8a99ab07c4b2d194 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 10 Apr 2024 09:34:24 +0200 Subject: [PATCH 36/37] Test Python 3.12 and fix its installation (#453) * Test Python 3.12 * Bump CI actions * Bump setuptools and versioningit * Add 3.12 nox session * Add type-hints to noxfile.py --- .github/workflows/auto-changelog-generator.yml | 2 +- .github/workflows/coverage.yml | 6 +++--- .github/workflows/nox.yml | 6 +++--- .github/workflows/pythonpublish.yml | 4 ++-- .github/workflows/typeguard.yml | 4 ++-- noxfile.py | 13 +++++++++---- pyproject.toml | 3 ++- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-changelog-generator.yml b/.github/workflows/auto-changelog-generator.yml index 5cb0f7d12..6f68c3f83 100644 --- a/.github/workflows/auto-changelog-generator.yml +++ b/.github/workflows/auto-changelog-generator.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f39544953..ab393048a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,9 +7,9 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 - name: Install dependencies @@ -17,4 +17,4 @@ jobs: - name: Test with nox run: nox -e coverage - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 diff --git a/.github/workflows/nox.yml b/.github/workflows/nox.yml index 42326d4b4..499b48c4e 100644 --- a/.github/workflows/nox.yml +++ b/.github/workflows/nox.yml @@ -12,12 +12,12 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Register Python problem matcher diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 6ccfc7898..48251f61c 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/typeguard.yml b/.github/workflows/typeguard.yml index d3c1da10e..a95ac4f2f 100644 --- a/.github/workflows/typeguard.yml +++ b/.github/workflows/typeguard.yml @@ -8,9 +8,9 @@ jobs: typeguard: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install dependencies diff --git a/noxfile.py b/noxfile.py index 1866a94a6..55544435c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,23 +1,28 @@ +"""Nox configuration file.""" + import nox -@nox.session(python=["3.9", "3.10", "3.11"]) +@nox.session(python=["3.9", "3.10", "3.11", "3.12"]) @nox.parametrize("all_deps", [True, False]) -def pytest(session, all_deps): +def pytest(session: nox.Session, all_deps: bool) -> None: + """Run pytest with optional dependencies.""" session.install(".[testing,other]" if all_deps else ".[testing]") session.run("coverage", "erase") session.run("pytest") @nox.session(python="3.11") -def pytest_typeguard(session): +def pytest_typeguard(session: nox.Session) -> None: + """Run pytest with typeguard.""" session.install(".[testing,other]") session.run("coverage", "erase") session.run("pytest", "--typeguard-packages=adaptive") @nox.session(python="3.11") -def coverage(session): +def coverage(session: nox.Session) -> None: + """Generate coverage report.""" session.install("coverage") session.install(".[testing,other]") session.run("pytest") diff --git a/pyproject.toml b/pyproject.toml index 63037d1ec..617f478c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "setuptools.build_meta" -requires = ["setuptools ~= 65.0.0", "versioningit ~= 2.2.0", "wheel"] +requires = ["setuptools ~= 69.0.0", "versioningit ~= 3.0.0", "wheel"] [project] name = "adaptive" @@ -16,6 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ "scipy", From 68f19281297a8d5eea9f68d20d0233a0c2224d27 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 10 Apr 2024 09:42:46 +0200 Subject: [PATCH 37/37] Update CHANGELOG.md for v1.2.0 (#454) * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef6ab4eb..0f2a3653b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,45 @@ # 🗞️ Changelog -## [v1.1.0](https://github.com/python-adaptive/adaptive/tree/v1.1.0) +## [v.1.2.0](https://github.com/python-adaptive/adaptive/tree/v1.2.0) (2024-04-10) + +[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v1.1.0...HEAD) + +**Closed issues:** + +- Issues with Multiprocess and AsyncRunner in adaptive for Phase Diagram Illustration [\#449](https://github.com/python-adaptive/adaptive/issues/449) +- Create API for just signle process \(No pickle\) [\#442](https://github.com/python-adaptive/adaptive/issues/442) +- Handling with regions unreachable inside the `ConvexHull` in `LearnerND` [\#438](https://github.com/python-adaptive/adaptive/issues/438) +- Use in script with BlockingRunner: get log and/or feedback on progress [\#436](https://github.com/python-adaptive/adaptive/issues/436) + +**Merged pull requests:** + +- Test Python 3.12 and fix its installation [\#453](https://github.com/python-adaptive/adaptive/pull/453) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#447](https://github.com/python-adaptive/adaptive/pull/447) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Use ruff-format instead of black [\#446](https://github.com/python-adaptive/adaptive/pull/446) ([basnijholt](https://github.com/basnijholt)) +- Bump versions to compatible packages in `docs/environment.yml` [\#445](https://github.com/python-adaptive/adaptive/pull/445) ([basnijholt](https://github.com/basnijholt)) +- Add `AsyncRunner.block_until_done` [\#444](https://github.com/python-adaptive/adaptive/pull/444) ([basnijholt](https://github.com/basnijholt)) +- Add `live_info_terminal`, closes \#436 [\#441](https://github.com/python-adaptive/adaptive/pull/441) ([basnijholt](https://github.com/basnijholt)) +- \[pre-commit.ci\] pre-commit autoupdate [\#434](https://github.com/python-adaptive/adaptive/pull/434) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Add benchmarks page for Learner1D and Learner2D functions [\#405](https://github.com/python-adaptive/adaptive/pull/405) ([basnijholt](https://github.com/basnijholt)) + +## [v1.1.0](https://github.com/python-adaptive/adaptive/tree/v1.1.0) (2023-08-14) [Full Changelog](https://github.com/python-adaptive/adaptive/compare/v1.0.0...v1.1.0) **Closed issues:** +- large delay when using start\_periodic\_saving [\#439](https://github.com/python-adaptive/adaptive/issues/439) - Target function returns NaN [\#435](https://github.com/python-adaptive/adaptive/issues/435) -- large delay when using start_periodic_saving [\#439](https://github.com/python-adaptive/adaptive/issues/439) **Merged pull requests:** - Ensure periodic saving fires immediately after runner task is finished [\#440](https://github.com/python-adaptive/adaptive/pull/440) ([jbweston](https://github.com/jbweston)) -- Add Learner2D loss function 'thresholded_loss_factory' [\#437](https://github.com/python-adaptive/adaptive/pull/437) ([basnijholt](https://github.com/basnijholt)) +- Add Learner2D loss function 'thresholded\_loss\_factory' [\#437](https://github.com/python-adaptive/adaptive/pull/437) ([basnijholt](https://github.com/basnijholt)) - \[pre-commit.ci\] pre-commit autoupdate [\#433](https://github.com/python-adaptive/adaptive/pull/433) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - \[pre-commit.ci\] pre-commit autoupdate [\#431](https://github.com/python-adaptive/adaptive/pull/431) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) +- Add adaptive.utils.daskify [\#422](https://github.com/python-adaptive/adaptive/pull/422) ([basnijholt](https://github.com/basnijholt)) -## [v1.0.0](https://github.com/python-adaptive/adaptive/tree/v1.0.0) +## [v1.0.0](https://github.com/python-adaptive/adaptive/tree/v1.0.0) (2023-05-15) [Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.15.0...v1.0.0) @@ -27,6 +50,7 @@ **Merged pull requests:** +- Update CHANGELOG for v1.0.0 [\#429](https://github.com/python-adaptive/adaptive/pull/429) ([basnijholt](https://github.com/basnijholt)) - \[pre-commit.ci\] pre-commit autoupdate [\#426](https://github.com/python-adaptive/adaptive/pull/426) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - Allow storing the full sequence in SequenceLearner.to\_dataframe [\#425](https://github.com/python-adaptive/adaptive/pull/425) ([basnijholt](https://github.com/basnijholt)) - \[pre-commit.ci\] pre-commit autoupdate [\#423](https://github.com/python-adaptive/adaptive/pull/423) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) @@ -56,9 +80,13 @@ - Add nbQA for notebook and docs linting [\#361](https://github.com/python-adaptive/adaptive/pull/361) ([basnijholt](https://github.com/basnijholt)) - Fix HoloViews opts deprecation warnings [\#357](https://github.com/python-adaptive/adaptive/pull/357) ([basnijholt](https://github.com/basnijholt)) -## [v0.15.0](https://github.com/python-adaptive/adaptive/tree/v0.15.0) (2022-11-30) +## [v0.15.0](https://github.com/python-adaptive/adaptive/tree/v0.15.0) (2022-12-02) + +[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.15.1...v0.15.0) -[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.14.2...v0.15.0) +## [v0.15.1](https://github.com/python-adaptive/adaptive/tree/v0.15.1) (2022-12-02) + +[Full Changelog](https://github.com/python-adaptive/adaptive/compare/v0.14.2...v0.15.1) **Closed issues:** @@ -66,10 +94,8 @@ **Merged pull requests:** -- Add support for Python 3.11 and test on it [\#387](https://github.com/python-adaptive/adaptive/pull/387) ([basnijholt](https://github.com/basnijholt)) - Rename master -\> main [\#384](https://github.com/python-adaptive/adaptive/pull/384) ([basnijholt](https://github.com/basnijholt)) - Add loss\_goal, npoints\_goal, and an auto\_goal function and use it in the runners [\#382](https://github.com/python-adaptive/adaptive/pull/382) ([basnijholt](https://github.com/basnijholt)) -- Add type-hints to Runner [\#370](https://github.com/python-adaptive/adaptive/pull/370) ([basnijholt](https://github.com/basnijholt)) - Add docs section about executing coroutines [\#364](https://github.com/python-adaptive/adaptive/pull/364) ([juandaanieel](https://github.com/juandaanieel)) ## [v0.14.2](https://github.com/python-adaptive/adaptive/tree/v0.14.2) (2022-10-14) @@ -254,7 +280,7 @@ - bump pre-commit filter dependencies [\#293](https://github.com/python-adaptive/adaptive/pull/293) ([basnijholt](https://github.com/basnijholt)) - fix docs [\#291](https://github.com/python-adaptive/adaptive/pull/291) ([basnijholt](https://github.com/basnijholt)) - update to miniver 0.7.0 [\#290](https://github.com/python-adaptive/adaptive/pull/290) ([basnijholt](https://github.com/basnijholt)) -- add `runner.live\_plot\(\)` in README example [\#288](https://github.com/python-adaptive/adaptive/pull/288) ([basnijholt](https://github.com/basnijholt)) +- add `runner.live_plot()` in README example [\#288](https://github.com/python-adaptive/adaptive/pull/288) ([basnijholt](https://github.com/basnijholt)) - Update pre commit [\#287](https://github.com/python-adaptive/adaptive/pull/287) ([basnijholt](https://github.com/basnijholt)) - Use m2r2 [\#286](https://github.com/python-adaptive/adaptive/pull/286) ([basnijholt](https://github.com/basnijholt)) - temporarily pin scikit-learn\<=0.23.1 [\#285](https://github.com/python-adaptive/adaptive/pull/285) ([basnijholt](https://github.com/basnijholt)) @@ -299,7 +325,6 @@ **Closed issues:** - - add minimum number of points parameter to AverageLearner [\#273](https://github.com/python-adaptive/adaptive/issues/273) - Release v0.10 [\#258](https://github.com/python-adaptive/adaptive/issues/258) @@ -489,7 +514,7 @@ - Gracefully handle exceptions when evaluating the function to be learned [\#125](https://github.com/python-adaptive/adaptive/issues/125) - Allow BalancingLearner to return arbitrary number of points from 'choose\_points' [\#124](https://github.com/python-adaptive/adaptive/issues/124) - Increase the default refresh rate for 'live\_plot' [\#120](https://github.com/python-adaptive/adaptive/issues/120) -- remove default number of points to choose in `choose\_points` [\#118](https://github.com/python-adaptive/adaptive/issues/118) +- remove default number of points to choose in `choose_points` [\#118](https://github.com/python-adaptive/adaptive/issues/118) - Consider using Gaussian process optimization as a learner [\#115](https://github.com/python-adaptive/adaptive/issues/115) - Make `distributed.Client` work with automatic scaling of the cluster [\#104](https://github.com/python-adaptive/adaptive/issues/104) - Improve plotting for learners [\#83](https://github.com/python-adaptive/adaptive/issues/83) @@ -576,7 +601,7 @@ - Remove public 'fname' learner attribute [\#17](https://github.com/python-adaptive/adaptive/issues/17) - Release v0.7.0 [\#14](https://github.com/python-adaptive/adaptive/issues/14) - \(Learner1D\) improve time complexity [\#13](https://github.com/python-adaptive/adaptive/issues/13) -- Typo in documentation for` adaptive.learner.learner2D.uniform\_loss\(ip\)` [\#12](https://github.com/python-adaptive/adaptive/issues/12) +- Typo in documentation for` adaptive.learner.learner2D.uniform_loss(ip)` [\#12](https://github.com/python-adaptive/adaptive/issues/12) - \(LearnerND\) fix plotting of scaled domains [\#11](https://github.com/python-adaptive/adaptive/issues/11) - suggested points lie outside of domain [\#7](https://github.com/python-adaptive/adaptive/issues/7) - DEVELOPMENT IS ON GITLAB: https://gitlab.kwant-project.org/qt/adaptive [\#5](https://github.com/python-adaptive/adaptive/issues/5)