Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Commits on Source (11)
# -*- coding: utf-8 -*-
from collections import defaultdict
from collections import defaultdict, Iterable
from contextlib import suppress
from functools import partial
from operator import itemgetter
......@@ -317,70 +317,67 @@ class BalancingLearner(BaseLearner):
learners.append(learner)
return cls(learners, cdims=arguments)
def save(self, folder, compress=True):
def save(self, fname, compress=True):
"""Save the data of the child learners into pickle files
in a directory.
Parameters
----------
folder : str
Directory in which the learners's data will be saved.
fname: callable or sequence of strings
Given a learner, returns a filename into which to save the data.
Or a list (or iterable) with filenames.
compress : bool, default True
Compress the data upon saving using `gzip`. When saving
using compression, one must load it with compression too.
Notes
-----
The child learners need to have a 'fname' attribute in order to use
this method.
Example
-------
>>> def combo_fname(val):
... return '__'.join([f'{k}_{v}.p' for k, v in val.items()])
...
... def f(x, a, b): return a * x**2 + b
...
>>> learners = []
>>> for combo in adaptive.utils.named_product(a=[1, 2], b=[1]):
... l = Learner1D(functools.partial(f, combo=combo))
... l.fname = combo_fname(combo) # 'a_1__b_1.p', 'a_2__b_1.p' etc.
... learners.append(l)
... learner = BalancingLearner(learners)
... # Run the learner
... runner = adaptive.Runner(learner)
... # Then save
... learner.save('data_folder') # use 'load' in the same way
>>> def combo_fname(learner):
... val = learner.function.keywords # because functools.partial
... fname = '__'.join([f'{k}_{v}.pickle' for k, v in val])
... return 'data_folder/' + fname
>>>
>>> def f(x, a, b): return a * x**2 + b
>>>
>>> learners = [Learner1D(functools.partial(f, **combo), (-1, 1))
... for combo in adaptive.utils.named_product(a=[1, 2], b=[1]]
>>>
>>> learner = BalancingLearner(learners)
>>> # Run the learner
>>> runner = adaptive.Runner(learner)
>>> # Then save
>>> learner.save(combo_fname) # use 'load' in the same way
"""
if len(self.learners) != len(set(l.fname for l in self.learners)):
raise RuntimeError("The 'learner.fname's are not all unique.")
for l in self.learners:
l.save(os.path.join(folder, l.fname), compress=compress)
if isinstance(fname, Iterable):
for l, _fname in zip(fname, self.learners):
l.save(_fname, compress=compress)
else:
for l in self.learners:
l.save(fname(l), compress=compress)
def load(self, folder, compress=True):
def load(self, fname, compress=True):
"""Load the data of the child learners from pickle files
in a directory.
Parameters
----------
folder : str
Directory from which the learners's data will be loaded.
fname: callable or sequence of strings
Given a learner, returns a filename from which to load the data.
Or a list (or iterable) with filenames.
compress : bool, default True
If the data is compressed when saved, one must load it
with compression too.
Notes
-----
The child learners need to have a 'fname' attribute in order to use
this method.
Example
-------
See the example in the `BalancingLearner.save` doc-string.
"""
for l in self.learners:
l.load(os.path.join(folder, l.fname), compress=compress)
if isinstance(fname, Iterable):
for l, _fname in zip(fname, self.learners):
l.load(_fname, compress=compress)
else:
for l in self.learners:
l.load(fname(l), compress=compress)
def _get_data(self):
return [l._get_data() for l in learner.learners]
......
......@@ -107,48 +107,31 @@ class BaseLearner(metaclass=abc.ABCMeta):
"""
self._set_data(other._get_data())
def save(self, fname=None, compress=True):
def save(self, fname, compress=True):
"""Save the data of the learner into a pickle file.
Parameters
----------
fname : str, optional
The filename of the learner's pickle data file. If None use
the 'fname' attribute, like 'learner.fname = "example.p".
fname : str
The filename into which to save the learner's data.
compress : bool, default True
Compress the data upon saving using 'gzip'. When saving
using compression, one must load it with compression too.
Notes
-----
There are **two ways** of naming the files:
1. Using the ``fname`` argument in ``learner.save(fname='example.p')``
2. Setting the ``fname`` attribute, like
``learner.fname = "data/example.p"`` and then ``learner.save()``.
"""
fname = fname or self.fname
data = self._get_data()
save(fname, data, compress)
def load(self, fname=None, compress=True):
def load(self, fname, compress=True):
"""Load the data of a learner from a pickle file.
Parameters
----------
fname : str, optional
The filename of the saved learner's pickled data file.
If None use the 'fname' attribute, like
'learner.fname = "example.p".
fname : str
The filename from which to load the learner's data.
compress : bool, default True
If the data is compressed when saved, one must load it
with compression too.
Notes
-----
See the notes in the `save` doc-string.
"""
fname = fname or self.fname
with suppress(FileNotFoundError, EOFError):
data = load(fname, compress)
self._set_data(data)
......@@ -158,19 +141,3 @@ class BaseLearner(metaclass=abc.ABCMeta):
def __setstate__(self, state):
self.__dict__ = state
@property
def fname(self):
"""Filename for the learner when it is saved (or loaded) using
`~adaptive.BaseLearner.save` (or `~adaptive.BaseLearner.load` ).
"""
# This is a property because then it will be availible in the DataSaver
try:
return self._fname
except AttributeError:
raise AttributeError("Set 'learner.fname' or use the 'fname'"
" argument when using 'learner.save' or 'learner.load'.")
@fname.setter
def fname(self, fname):
self._fname = fname
......@@ -53,13 +53,13 @@ class DataSaver:
self.learner._set_data(learner_data)
@copy_docstring_from(BaseLearner.save)
def save(self, fname=None, compress=True):
def save(self, fname, compress=True):
# We copy this method because the 'DataSaver' is not a
# subclass of the 'BaseLearner'.
BaseLearner.save(self, fname, compress)
@copy_docstring_from(BaseLearner.load)
def load(self, fname=None, compress=True):
def load(self, fname, compress=True):
# We copy this method because the 'DataSaver' is not a
# subclass of the 'BaseLearner'.
BaseLearner.load(self, fname, compress)
......
......@@ -123,6 +123,7 @@ def triangle_loss(xs, ys):
def curvature_loss_function(area_factor=1, euclid_factor=0.02, horizontal_factor=0.02):
# XXX: add a doc-string
@uses_nth_neighbors(1)
def curvature_loss(xs, ys):
xs_middle = xs[1:3]
......@@ -227,6 +228,11 @@ class Learner1D(BaseLearner):
self.losses = {}
self.losses_combined = {}
# When the scale changes by a factor 2, the losses are
# recomputed. This is tunable such that we can test
# the learners behavior in the tests.
self._recompute_losses_factor = 2
self.data = {}
self.pending_points = set()
......@@ -446,7 +452,7 @@ class Learner1D(BaseLearner):
self._update_losses(x, real=True)
# If the scale has increased enough, recompute all losses.
if self._scale[1] > 2 * self._oldscale[1]:
if self._scale[1] > self._recompute_losses_factor * self._oldscale[1]:
for interval in self.losses:
self._update_interpolated_loss_in_interval(*interval)
......@@ -562,8 +568,13 @@ class Learner1D(BaseLearner):
def finite_loss(loss, xs):
# If the loss is infinite we return the
# distance between the two points.
return (loss if not math.isinf(loss)
else (xs[1] - xs[0]) / self._scale[0])
if math.isinf(loss):
loss = (xs[1] - xs[0]) / self._scale[0]
# We round the loss to 12 digits such that losses
# are equal up to numerical precision will be considered
# equal.
return round(loss, ndigits=12)
quals = [(-finite_loss(loss, x), x, 1)
for x, loss in self.losses_combined.items()]
......
......@@ -15,18 +15,39 @@ import numpy as np
import pytest
import scipy.spatial
from ..learner import (AverageLearner, BalancingLearner, DataSaver,
import adaptive
from adaptive.learner import (AverageLearner, BalancingLearner, DataSaver,
IntegratorLearner, Learner1D, Learner2D, LearnerND)
from ..runner import simple
from adaptive.runner import simple
try:
import skopt
from ..learner import SKOptLearner
from adaptive.learner import SKOptLearner
except ModuleNotFoundError:
SKOptLearner = None
LOSS_FUNCTIONS = {
Learner1D: ('loss_per_interval', (
adaptive.learner.learner1D.default_loss,
adaptive.learner.learner1D.uniform_loss,
adaptive.learner.learner1D.curvature_loss_function(),
)),
Learner2D: ('loss_per_triangle', (
adaptive.learner.learner2D.default_loss,
adaptive.learner.learner2D.uniform_loss,
adaptive.learner.learner2D.minimize_triangle_surface_loss,
adaptive.learner.learner2D.resolution_loss_function(),
)),
LearnerND: ('loss_per_simplex', (
adaptive.learner.learnerND.default_loss,
adaptive.learner.learnerND.std_loss,
adaptive.learner.learnerND.uniform_loss,
)),
}
def generate_random_parametrization(f):
"""Return a realization of 'f' with parameters bound to random values.
......@@ -74,7 +95,6 @@ def maybe_skip(learner):
# All parameters except the first must be annotated with a callable that
# returns a random value for that parameter.
@learn_with(Learner1D, bounds=(-1, 1))
def quadratic(x, m: uniform(0, 10), b: uniform(0, 1)):
return m * x**2 + b
......@@ -108,20 +128,33 @@ def gaussian(n):
# Decorators for tests.
def run_with(*learner_types):
# Create a sequence of learner parameters by adding all
# possible loss functions to an existing parameter set.
def add_loss_to_params(learner_type, existing_params):
if learner_type not in LOSS_FUNCTIONS:
return [existing_params]
loss_param, loss_functions = LOSS_FUNCTIONS[learner_type]
loss_params = [{loss_param: f} for f in loss_functions]
return [dict(**existing_params, **lp) for lp in loss_params]
def run_with(*learner_types, with_all_loss_functions=True):
pars = []
for l in learner_types:
has_marker = isinstance(l, tuple)
if has_marker:
marker, l = l
for f, k in learner_function_combos[l]:
# Check if learner was marked with our `xfail` decorator
# XXX: doesn't work when feeding kwargs to xfail.
if has_marker:
pars.append(pytest.param(l, f, dict(k),
marks=[marker]))
else:
pars.append((l, f, dict(k)))
ks = add_loss_to_params(l, k) if with_all_loss_functions else [k]
for k in ks:
# Check if learner was marked with our `xfail` decorator
# XXX: doesn't work when feeding kwargs to xfail.
if has_marker:
pars.append(pytest.param(l, f, dict(k),
marks=[marker]))
else:
pars.append((l, f, dict(k)))
return pytest.mark.parametrize('learner_type, f, learner_kwargs', pars)
......@@ -196,22 +229,19 @@ def test_learner_accepts_lists(learner_type, bounds):
simple(learner, goal=lambda l: l.npoints > 10)
@run_with(xfail(Learner1D), Learner2D, LearnerND)
@run_with(Learner1D, Learner2D, LearnerND)
def test_adding_existing_data_is_idempotent(learner_type, f, learner_kwargs):
"""Adding already existing data is an idempotent operation.
Either it is idempotent, or it is an error.
This is the only sane behaviour.
This test will fail for the Learner1D because the losses are normalized by
_scale which is updated after every point. After one iteration of adding
points, the _scale could be different from what it was when calculating
the losses of the intervals. Readding the points a second time means
that the losses are now all normalized by the correct _scale.
"""
f = generate_random_parametrization(f)
learner = learner_type(f, **learner_kwargs)
control = learner_type(f, **learner_kwargs)
if learner_type is Learner1D:
learner._recompute_losses_factor = 1
control._recompute_losses_factor = 1
N = random.randint(10, 30)
control.ask(N)
......@@ -265,14 +295,11 @@ def test_adding_non_chosen_data(learner_type, f, learner_kwargs):
assert set(pls) == set(cpls)
@run_with(xfail(Learner1D), xfail(Learner2D), xfail(LearnerND), AverageLearner)
@run_with(Learner1D, xfail(Learner2D), xfail(LearnerND), AverageLearner)
def test_point_adding_order_is_irrelevant(learner_type, f, learner_kwargs):
"""The order of calls to 'tell' between calls to 'ask'
is arbitrary.
This test will fail for the Learner1D for the same reason as described in
the doc-string in `test_adding_existing_data_is_idempotent`.
This test will fail for the Learner2D because
`interpolate.interpnd.estimate_gradients_2d_global` will give different
outputs based on the order of the triangles and values in
......@@ -282,6 +309,10 @@ def test_point_adding_order_is_irrelevant(learner_type, f, learner_kwargs):
learner = learner_type(f, **learner_kwargs)
control = learner_type(f, **learner_kwargs)
if learner_type is Learner1D:
learner._recompute_losses_factor = 1
control._recompute_losses_factor = 1
N = random.randint(10, 30)
control.ask(N)
xs, _ = learner.ask(N)
......@@ -353,7 +384,7 @@ def test_learner_performance_is_invariant_under_scaling(learner_type, f, learner
learner = learner_type(lambda x: yscale * f(np.array(x) / xscale),
**l_kwargs)
npoints = random.randrange(1000, 2000)
npoints = random.randrange(300, 500)
for n in range(npoints):
cxs, _ = control.ask(1)
......@@ -366,10 +397,11 @@ def test_learner_performance_is_invariant_under_scaling(learner_type, f, learner
assert np.allclose(xs_unscaled, cxs)
# Check if the losses are close
assert abs(learner.loss() - control.loss()) / learner.loss() < 1e-11
assert math.isclose(learner.loss(), control.loss(), rel_tol=1e-10)
@run_with(Learner1D, Learner2D, LearnerND, AverageLearner)
@run_with(Learner1D, Learner2D, LearnerND, AverageLearner,
with_all_loss_functions=False)
def test_balancing_learner(learner_type, f, learner_kwargs):
"""Test if the BalancingLearner works with the different types of learners."""
learners = [learner_type(generate_random_parametrization(f), **learner_kwargs)
......@@ -403,19 +435,22 @@ def test_balancing_learner(learner_type, f, learner_kwargs):
@run_with(Learner1D, Learner2D, LearnerND, AverageLearner,
maybe_skip(SKOptLearner), IntegratorLearner)
maybe_skip(SKOptLearner), IntegratorLearner,
with_all_loss_functions=False)
def test_saving(learner_type, f, learner_kwargs):
f = generate_random_parametrization(f)
learner = learner_type(f, **learner_kwargs)
control = learner_type(f, **learner_kwargs)
if learner_type is Learner1D:
learner._recompute_losses_factor = 1
control._recompute_losses_factor = 1
simple(learner, lambda l: l.npoints > 100)
fd, path = tempfile.mkstemp()
try:
learner.save(path)
control.load(path)
if learner_type is not Learner1D:
# Because different scales result in differnt losses
np.testing.assert_almost_equal(learner.loss(), control.loss())
np.testing.assert_almost_equal(learner.loss(), control.loss())
# Try if the control is runnable
simple(control, lambda l: l.npoints > 200)
......@@ -424,24 +459,29 @@ def test_saving(learner_type, f, learner_kwargs):
@run_with(Learner1D, Learner2D, LearnerND, AverageLearner,
maybe_skip(SKOptLearner), IntegratorLearner)
maybe_skip(SKOptLearner), IntegratorLearner,
with_all_loss_functions=False)
def test_saving_of_balancing_learner(learner_type, f, learner_kwargs):
f = generate_random_parametrization(f)
learner = BalancingLearner([learner_type(f, **learner_kwargs)])
control = BalancingLearner([learner_type(f, **learner_kwargs)])
# set fnames
learner.learners[0].fname = 'test'
control.learners[0].fname = 'test'
if learner_type is Learner1D:
for l, c in zip(learner.learners, control.learners):
l._recompute_losses_factor = 1
c._recompute_losses_factor = 1
simple(learner, lambda l: l.learners[0].npoints > 100)
folder = tempfile.mkdtemp()
def fname(learner):
return folder + 'test'
try:
learner.save(folder=folder)
control.load(folder=folder)
if learner_type is not Learner1D:
# Because different scales result in differnt losses
np.testing.assert_almost_equal(learner.loss(), control.loss())
learner.save(fname=fname)
control.load(fname=fname)
np.testing.assert_almost_equal(learner.loss(), control.loss())
# Try if the control is runnable
simple(control, lambda l: l.learners[0].npoints > 200)
......@@ -450,21 +490,27 @@ def test_saving_of_balancing_learner(learner_type, f, learner_kwargs):
@run_with(Learner1D, Learner2D, LearnerND, AverageLearner,
maybe_skip(SKOptLearner), IntegratorLearner)
maybe_skip(SKOptLearner), IntegratorLearner,
with_all_loss_functions=False)
def test_saving_with_datasaver(learner_type, f, learner_kwargs):
f = generate_random_parametrization(f)
g = lambda x: {'y': f(x), 't': random.random()}
arg_picker = operator.itemgetter('y')
learner = DataSaver(learner_type(g, **learner_kwargs), arg_picker)
control = DataSaver(learner_type(g, **learner_kwargs), arg_picker)
if learner_type is Learner1D:
learner.learner._recompute_losses_factor = 1
control.learner._recompute_losses_factor = 1
simple(learner, lambda l: l.npoints > 100)
fd, path = tempfile.mkstemp()
try:
learner.save(path)
control.load(path)
if learner_type is not Learner1D:
# Because different scales result in differnt losses
np.testing.assert_almost_equal(learner.loss(), control.loss())
np.testing.assert_almost_equal(learner.loss(), control.loss())
assert learner.extra_data == control.extra_data
# Try if the control is runnable
......
......@@ -33,13 +33,10 @@ Saving and loading learners
Every learner has a `~adaptive.BaseLearner.save` and `~adaptive.BaseLearner.load`
method that can be used to save and load **only** the data of a learner.
There are **two ways** of naming the files: 1. Using the ``fname``
argument in ``learner.save(fname=...)`` 2. Setting the ``fname``
attribute, like ``learner.fname = 'data/example.p`` and then
``learner.save()``
Use the ``fname`` argument in ``learner.save(fname=...)``.
The second way *must be used* when saving the ``learner``\s of a
`~adaptive.BalancingLearner`.
Or, when using a `~adaptive.BalancingLearner` one can use either a callable
that takes the child learner and returns a filename **or** a list of filenames.
By default the resulting pickle files are compressed, to turn this off
use ``learner.save(fname=..., compress=False)``
......