diff --git a/GPflowOpt/__init__.py b/GPflowOpt/__init__.py index bacda19..860032e 100644 --- a/GPflowOpt/__init__.py +++ b/GPflowOpt/__init__.py @@ -21,5 +21,6 @@ from . import scaling from . import objective from . import pareto +from . import models from ._version import __version__ diff --git a/GPflowOpt/acquisition/acquisition.py b/GPflowOpt/acquisition/acquisition.py index a190dd7..69d4b80 100644 --- a/GPflowOpt/acquisition/acquisition.py +++ b/GPflowOpt/acquisition/acquisition.py @@ -14,8 +14,10 @@ from ..scaling import DataScaler from ..domain import UnitCube +from ..models import ModelWrapper from GPflow.param import Parameterized, AutoFlow, ParamList +from GPflow.model import Model from GPflow import settings import numpy as np @@ -48,7 +50,9 @@ def __init__(self, models=[], optimize_restarts=5): :param optimize_restarts: number of optimization restarts to use when training the models """ super(Acquisition, self).__init__() - self._models = ParamList([DataScaler(m) for m in np.atleast_1d(models).tolist()]) + models = np.atleast_1d(models) + assert all(isinstance(model, (Model, ModelWrapper)) for model in models) + self._models = ParamList([DataScaler(m) for m in models]) assert (optimize_restarts >= 0) self.optimize_restarts = optimize_restarts diff --git a/GPflowOpt/acquisition/ei.py b/GPflowOpt/acquisition/ei.py index 90507cb..2c9ce87 100644 --- a/GPflowOpt/acquisition/ei.py +++ b/GPflowOpt/acquisition/ei.py @@ -57,7 +57,6 @@ def __init__(self, model): :param model: GPflow model (single output) representing our belief of the objective """ super(ExpectedImprovement, self).__init__(model) - assert (isinstance(model, Model)) self.fmin = DataHolder(np.zeros(1)) self.setup() diff --git a/GPflowOpt/models.py b/GPflowOpt/models.py new file mode 100644 index 0000000..0cdda58 --- /dev/null +++ b/GPflowOpt/models.py @@ -0,0 +1,92 @@ +# Copyright 2017 Joachim van der Herten +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from GPflow.param import Parameterized +from GPflow.model import Model + + +class ModelWrapper(Parameterized): + """ + Class for fast implementation of a wrapper for models defined in GPflow. + + Once wrapped, all lookups for attributes which are not found in the wrapper class are automatically forwarded + to the wrapped model. To influence the I/O of methods on the wrapped class, simply implement the method in the + wrapper and call the appropriate methods on the wrapped class. Specific logic is included to make sure that if + AutoFlow methods are influenced following this pattern, the original AF storage (if existing) is unaffected and a + new storage is added to the subclass. + """ + def __init__(self, model): + """ + :param model: model to be wrapped + """ + super(ModelWrapper, self).__init__() + + assert isinstance(model, (Model, ModelWrapper)) + #: Wrapped model + self.wrapped = model + + def __getattr__(self, item): + """ + If an attribute is not found in this class, it is searched in the wrapped model + """ + # Exception for AF storages, if a method with the same name exists in this class, do not find the cache + # in the wrapped model. + if item.endswith('_AF_storage'): + method = item[1:].rstrip('_AF_storage') + if method in dir(self): + raise AttributeError("{0} has no attribute {1}".format(self.__class__.__name__, item)) + return getattr(self.wrapped, item) + + def __setattr__(self, key, value): + """ + 1) If setting :attr:`wrapped` attribute, point parent to this object (the ModelWrapper). + 2) Setting attributes in the right objects. The following rules are processed in order: + (a) If attribute exists in wrapper, set in wrapper. + (b) If no object has been wrapped (wrapper is None), set attribute in the wrapper. + (c) If attribute is found in the wrapped object, set it there. This rule is ignored for AF storages. + (d) Set attribute in wrapper. + """ + if key is 'wrapped': + object.__setattr__(self, key, value) + value.__setattr__('_parent', self) + return + + try: + # If attribute is in this object, set it. Test by using getattribute instead of hasattr to avoid lookup in + # wrapped object. + self.__getattribute__(key) + super(ModelWrapper, self).__setattr__(key, value) + except AttributeError: + # Attribute is not in wrapper. + # In case no wrapped object is set yet (e.g. constructor), set in wrapper. + if 'wrapped' not in self.__dict__: + super(ModelWrapper, self).__setattr__(key, value) + return + + if hasattr(self, key): + # Now use hasattr, we know getattribute already failed so if it returns true, it must be in the wrapped + # object. Hasattr is called on self instead of self.wrapped to account for the different handling of + # AF storages. + # Prefer setting the attribute in the wrapped object if exists. + setattr(self.wrapped, key, value) + else: + # If not, set in wrapper nonetheless. + super(ModelWrapper, self).__setattr__(key, value) + + def __eq__(self, other): + return self.wrapped == other + + @Parameterized.name.getter + def name(self): + name = super(ModelWrapper, self).name + return ".".join([name, str.lower(self.__class__.__name__)]) diff --git a/GPflowOpt/scaling.py b/GPflowOpt/scaling.py index baa4f08..a8dee0e 100644 --- a/GPflowOpt/scaling.py +++ b/GPflowOpt/scaling.py @@ -12,21 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -from GPflow.param import DataHolder, AutoFlow, Parameterized -from GPflow.model import Model, GPModel +from GPflow.param import DataHolder, AutoFlow +from GPflow.model import GPModel from GPflow import settings import numpy as np from .transforms import LinearTransform, DataTransform from .domain import UnitCube +from .models import ModelWrapper float_type = settings.dtypes.float_type -class DataScaler(GPModel): +class DataScaler(ModelWrapper): """ - Model-wrapping class, primarily intended to assure the data in GPflow models is scaled. One DataScaler wraps one - GPflow model, and can scale the input as well as the output data. By default, if any kind of object attribute - is not found in the datascaler object, it is searched on the wrapped model. + Model-wrapping class, primarily intended to assure the data in GPflow models is scaled. + + One DataScaler wraps one GPflow model, and can scale the input as well as the output data. By default, + if any kind of object attribute is not found in the datascaler object, it is searched on the wrapped model. The datascaler supports both input as well as output scaling, although both scalings are set up differently: @@ -59,13 +61,8 @@ def __init__(self, model, domain=None, normalize_Y=False): :param normalize_Y: (default: False) enable automatic scaling of output values to zero mean and unit variance. """ - # model sanity checks - assert (model is not None) - assert (isinstance(model, GPModel)) - self._parent = None - - # Wrap model - self.wrapped = model + # model sanity checks, slightly stronger conditions than the wrapper + super(DataScaler, self).__init__(model) # Initial configuration of the datascaler n_inputs = model.X.shape[1] @@ -74,34 +71,8 @@ def __init__(self, model, domain=None, normalize_Y=False): self._normalize_Y = normalize_Y self._output_transform = LinearTransform(np.ones(n_outputs), np.zeros(n_outputs)) - # The assignments in the constructor of GPModel take care of initial re-scaling of model data. - super(DataScaler, self).__init__(model.X.value, model.Y.value, None, None, 1, name=model.name+"_datascaler") - del self.kern - del self.mean_function - del self.likelihood - - def __getattr__(self, item): - """ - If an attribute is not found in this class, it is searched in the wrapped model - """ - return self.wrapped.__getattribute__(item) - - def __setattr__(self, key, value): - """ - If setting :attr:`wrapped` attribute, point parent to this object (the datascaler) - """ - if key is 'wrapped': - object.__setattr__(self, key, value) - value.__setattr__('_parent', self) - return - - super(DataScaler, self).__setattr__(key, value) - - def __eq__(self, other): - return self.wrapped == other - - def __str__(self, prepend=''): - return self.wrapped.__str__(prepend) + self.X = model.X.value + self.Y = model.Y.value @property def input_transform(self): @@ -216,6 +187,20 @@ def build_predict(self, Xnew, full_cov=False): f, var = self.wrapped.build_predict(self.input_transform.build_forward(Xnew), full_cov=full_cov) return self.output_transform.build_backward(f), self.output_transform.build_backward_variance(var) + @AutoFlow((float_type, [None, None])) + def predict_f(self, Xnew): + """ + Compute the mean and variance of held-out data at the points Xnew + """ + return self.build_predict(Xnew) + + @AutoFlow((float_type, [None, None])) + def predict_f_full_cov(self, Xnew): + """ + Compute the mean and variance of held-out data at the points Xnew + """ + return self.build_predict(Xnew, full_cov=True) + @AutoFlow((float_type, [None, None])) def predict_y(self, Xnew): """ @@ -230,6 +215,6 @@ def predict_density(self, Xnew, Ynew): """ Compute the (log) density of the data Ynew at the points Xnew """ - mu, var = self.build_predict(Xnew) + mu, var = self.wrapped.build_predict(self.input_transform.build_forward(Xnew)) Ys = self.output_transform.build_forward(Ynew) return self.likelihood.predict_density(mu, var, Ys) diff --git a/GPflowOpt/transforms.py b/GPflowOpt/transforms.py index 4d5b82d..c87fac3 100644 --- a/GPflowOpt/transforms.py +++ b/GPflowOpt/transforms.py @@ -61,9 +61,6 @@ def __invert__(self): """ raise NotImplementedError - def __str__(self): - raise NotImplementedError - class LinearTransform(DataTransform): """ @@ -155,5 +152,3 @@ def __invert__(self): A_inv = np.linalg.inv(self.A.value.T) return LinearTransform(A_inv, -np.dot(self.b.value, A_inv)) - def __str__(self): - return 'XA + b' diff --git a/doc/source/interfaces.rst b/doc/source/interfaces.rst index 6756e40..86b0b0f 100644 --- a/doc/source/interfaces.rst +++ b/doc/source/interfaces.rst @@ -36,3 +36,11 @@ Transform :special-members: .. autoclass:: GPflowOpt.transforms.DataTransform :special-members: + +ModelWrapper +------------ +.. automodule:: GPflowOpt.models + :special-members: +.. autoclass:: GPflowOpt.models.ModelWrapper + :members: + :special-members: diff --git a/testing/test_datascaler.py b/testing/test_datascaler.py index ba91577..07ffb9c 100644 --- a/testing/test_datascaler.py +++ b/testing/test_datascaler.py @@ -26,8 +26,6 @@ def test_object_integrity(self): Xs, Ys = m.X.value, m.Y.value n = DataScaler(m, self.domain) - self.assertEqual(n.wrapped, m) - self.assertEqual(m._parent, n) self.assertTrue(np.allclose(Xs, n.X.value)) self.assertTrue(np.allclose(Ys, n.Y.value)) @@ -80,7 +78,7 @@ def test_enabling_transforms(self): def test_predict_scaling(self): m = self.create_parabola_model() - n = DataScaler(self.create_parabola_model(), self.domain) + n = DataScaler(self.create_parabola_model(), self.domain, normalize_Y=True) m.optimize() n.optimize() @@ -100,7 +98,8 @@ def test_predict_scaling(self): self.assertTrue(np.allclose(fr, fs, atol=1e-3)) self.assertTrue(np.allclose(vr, vs, atol=1e-3)) - Yt = parabola2d(Xt) #+ np.random.rand(20, 1) * 0.05 + Yt = parabola2d(Xt) fr = m.predict_density(Xt, Yt) fs = n.predict_density(Xt, Yt) - np.testing.assert_allclose(fr, fs, rtol=1e-3) + np.testing.assert_allclose(fr, fs, rtol=1e-2) + diff --git a/testing/test_modelwrapper.py b/testing/test_modelwrapper.py new file mode 100644 index 0000000..439858e --- /dev/null +++ b/testing/test_modelwrapper.py @@ -0,0 +1,123 @@ +import GPflowOpt +import unittest +import GPflow +import numpy as np + +float_type = GPflow.settings.dtypes.float_type + + +class MethodOverride(GPflowOpt.models.ModelWrapper): + + def __init__(self, m): + super(MethodOverride, self).__init__(m) + self.A = GPflow.param.DataHolder(np.array([1.0])) + + @GPflow.param.AutoFlow((float_type, [None, None])) + def predict_f(self, Xnew): + """ + Compute the mean and variance of held-out data at the points Xnew + """ + m, v = self.build_predict(Xnew) + return self.A * m, v + + @property + def X(self): + return self.wrapped.X + + @X.setter + def X(self, Xc): + self.wrapped.X = Xc + + @property + def foo(self): + return 1 + + @foo.setter + def foo(self, val): + self.wrapped.foo = val + + +class TestModelWrapper(unittest.TestCase): + + def simple_model(self): + x = np.random.rand(10,2) * 2 * np.pi + y = np.sin(x[:,[0]]) + m = GPflow.gpr.GPR(x,y, kern=GPflow.kernels.RBF(1)) + return m + + def test_object_integrity(self): + m = self.simple_model() + w = GPflowOpt.models.ModelWrapper(m) + self.assertEqual(w.wrapped, m) + self.assertEqual(m._parent, w) + self.assertEqual(w.optimize, m.optimize) + + def test_optimize(self): + m = self.simple_model() + w = GPflowOpt.models.ModelWrapper(m) + logL = m.compute_log_likelihood() + self.assertTrue(np.allclose(logL, w.compute_log_likelihood())) + + # Check if compiled & optimized, verify attributes are set in the right object. + w.optimize(maxiter=5) + self.assertTrue(hasattr(m, '_minusF')) + self.assertFalse('_minusF' in w.__dict__) + self.assertGreater(m.compute_log_likelihood(), logL) + + def test_af_storage_detection(self): + # Regression test for a bug with predict_f/predict_y... etc. + m = self.simple_model() + x = np.random.rand(10,2) + m.predict_f(x) + self.assertTrue(hasattr(m, '_predict_f_AF_storage')) + w = MethodOverride(m) + self.assertFalse(hasattr(w, '_predict_f_AF_storage')) + w.predict_f(x) + self.assertTrue(hasattr(w, '_predict_f_AF_storage')) + + def test_set_wrapped_attributes(self): + # Regression test for setting certain keys in the right object + m = self.simple_model() + w = GPflowOpt.models.ModelWrapper(m) + w._needs_recompile = False + self.assertFalse('_needs_recompile' in w.__dict__) + self.assertTrue('_needs_recompile' in m.__dict__) + self.assertFalse(w._needs_recompile) + self.assertFalse(m._needs_recompile) + + def test_double_wrap(self): + m = self.simple_model() + n = GPflowOpt.models.ModelWrapper(MethodOverride(m)) + n.optimize(maxiter=10) + Xt = np.random.rand(10, 2) + n.predict_f(Xt) + self.assertFalse('_predict_f_AF_storage' in n.__dict__) + self.assertTrue('_predict_f_AF_storage' in n.wrapped.__dict__) + self.assertFalse('_predict_f_AF_storage' in n.wrapped.wrapped.__dict__) + + n = MethodOverride(GPflowOpt.models.ModelWrapper(m)) + Xn = np.random.rand(10, 2) + Yn = np.random.rand(10, 1) + n.X = Xn + n.Y = Yn + self.assertTrue(np.allclose(Xn, n.wrapped.wrapped.X.value)) + self.assertTrue(np.allclose(Yn, n.wrapped.wrapped.Y.value)) + self.assertFalse('Y' in n.wrapped.__dict__) + self.assertFalse('X' in n.wrapped.__dict__) + + n.foo = 5 + self.assertTrue('foo' in n.wrapped.__dict__) + self.assertFalse('foo' in n.wrapped.wrapped.__dict__) + + def test_name(self): + n = GPflowOpt.models.ModelWrapper(self.simple_model()) + self.assertEqual(n.name, 'unnamed.modelwrapper') + p = GPflow.param.Parameterized() + p.model = n + self.assertEqual(n.name, 'model.modelwrapper') + n = MethodOverride(self.simple_model()) + self.assertEqual(n.name, 'unnamed.methodoverride') + + + +