From 4cf0981acffc32c3da84230d33e6e374d65fe29d Mon Sep 17 00:00:00 2001 From: Finesim97 Date: Tue, 11 Apr 2023 08:32:52 +0000 Subject: [PATCH 1/2] Expose time scale of prediction models in pipeline Adds conditional property _predict_risk_score to pipeline if the final estimator of the pipeline has such property. Fixes #324 --- sksurv/__init__.py | 8 +++++ sksurv/util.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_aft.py | 20 ++++++++++++ tests/test_util.py | 57 +++++++++++++++++++++++++++++++- 4 files changed, 165 insertions(+), 1 deletion(-) diff --git a/sksurv/__init__.py b/sksurv/__init__.py index 96a68138..66a048db 100644 --- a/sksurv/__init__.py +++ b/sksurv/__init__.py @@ -6,6 +6,8 @@ from sklearn.pipeline import Pipeline, _final_estimator_has from sklearn.utils.metaestimators import available_if +from .util import conditionalAvailableProperty + def _get_version(name): try: @@ -125,9 +127,15 @@ def predict_survival_function(self, X, **kwargs): return self.steps[-1][-1].predict_survival_function(Xt, **kwargs) +@conditionalAvailableProperty(_final_estimator_has('_predict_risk_score')) +def _predict_risk_score(self): + return self.steps[-1][-1]._predict_risk_score + + def patch_pipeline(): Pipeline.predict_survival_function = predict_survival_function Pipeline.predict_cumulative_hazard_function = predict_cumulative_hazard_function + Pipeline._predict_risk_score = _predict_risk_score try: diff --git a/sksurv/util.py b/sksurv/util.py index b8778143..679f791f 100644 --- a/sksurv/util.py +++ b/sksurv/util.py @@ -259,3 +259,84 @@ def safe_concat(objs, *args, **kwargs): concatenated[name] = pd.Categorical(concatenated[name], **params) return concatenated + + +class _ConditionalAvailableProperty: + """Implements a conditional property using the descriptor protocol based on the property decorator. + + The corresponding class in scikit-learn (_AvailableIfDescriptor) only supports callables. This + class adopts the property decorator as presented by the descriptor guide in the offical documentation. + + The check is defined on the getter function. Setter, Deleter also need to be defined on the exposing clas +s + if they are supposed to be available. + + See Also + -------- + sklearn.utils.available_if._AvailableIfDescriptor: + The original class in scikit-learn. + """ + + def __init__(self, check, fget=None, fset=None, fdel=None, doc=None): + self._check = check + self.fget = fget + self.fset = fset + self.fdel = fdel + if doc is None and fget is not None: + doc = fget.__doc__ + self.__doc__ = doc + self._name = '' + + def _run_check(self, obj): + attr_err = AttributeError( + f"This {repr(obj)} has no attribute {repr(self._name)}" + ) + if not self.check(obj): + raise attr_err + + def __set_name__(self, owner, name): + self._name = name + + def __get__(self, obj, objtype=None): + if obj is None: + return self + self._run_check(obj) + if self.fget is None: + raise AttributeError(f"property '{self._name}' has no getter") + return self.fget(obj) + + def __set__(self, obj, value): + self._run_check(obj) + if self.fset is None: + raise AttributeError(f"property '{self._name}' has no setter") + self.fset(obj, value) + + def __delete__(self, obj): + self._run_check(obj) + if self.fdel is None: + raise AttributeError(f"property '{self._name}' has no deleter") + self.fdel(obj) + + @property + def check(self): + return self._check + + def getter(self, fget): + prop = type(self)(self.check, fget, self.fset, self.fdel, self.__doc__) + prop._name = self._name + return prop + + def setter(self, fset): + prop = type(self)(self.check, self.fget, fset, self.fdel, self.__doc__) + prop._name = self._name + return prop + + def deleter(self, fdel): + prop = type(self)(self.check, self.fget, self.fset, fdel, self.__doc__) + prop._name = self._name + return prop + + +def conditionalAvailableProperty(check): + prop = _ConditionalAvailableProperty(check=check) + return prop.getter diff --git a/tests/test_aft.py b/tests/test_aft.py index 2c584744..bd36e991 100644 --- a/tests/test_aft.py +++ b/tests/test_aft.py @@ -1,7 +1,9 @@ import numpy as np from numpy.testing import assert_array_almost_equal import pytest +from sklearn.pipeline import make_pipeline +from sksurv.base import SurvivalAnalysisMixin from sksurv.linear_model import IPCRidge from sksurv.testing import assert_cindex_almost_equal @@ -51,3 +53,21 @@ def test_predict(make_whas500): ) assert model.score(x_test, y_test) == 0.66925817946226107 + + @staticmethod + def test_pipeline_score(make_whas500): + whas500 = make_whas500() + pipe = make_pipeline(IPCRidge()) + pipe.fit(whas500.x[:400], whas500.y[:400]) + + x_test = whas500.x[400:] + y_test = whas500.y[400:] + p = pipe.predict(x_test) + assert_cindex_almost_equal( + y_test["fstat"], + y_test["lenfol"], + -p, + (0.66925817946226107, 2066, 1021, 0, 1), + ) + + assert SurvivalAnalysisMixin.score(pipe, x_test, y_test) == 0.66925817946226107 diff --git a/tests/test_util.py b/tests/test_util.py index 590eb49f..65398e63 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -8,7 +8,7 @@ import pytest from sksurv.testing import FixtureParameterFactory -from sksurv.util import Surv, safe_concat +from sksurv.util import Surv, conditionalAvailableProperty, safe_concat class ConcatCasesFactory(FixtureParameterFactory): @@ -369,3 +369,58 @@ def test_from_dataframe(args, expected, expected_error): if expected is not None: assert_array_equal(y, expected) + + +def test_cond_avail_property(): + class WithCondProp: + def __init__(self, val): + self.avail = False + self._prop = val + + @conditionalAvailableProperty(lambda self: self.avail) + def prop(self): + return self._prop + + @prop.setter + def prop(self, new): + self._prop = new + + @prop.deleter + def prop(self): + self.avail = False + + testval = 43 + msg = "has no attribute 'prop'" + + assert WithCondProp.prop is not None + + test_obj = WithCondProp(testval) + with pytest.raises(AttributeError, match=msg): + _ = test_obj.prop + with pytest.raises(AttributeError, match=msg): + test_obj.prop = testval - 1 + with pytest.raises(AttributeError, match=msg): + del test_obj.prop + test_obj.avail = True + assert test_obj.prop == testval + test_obj.prop = testval - 2 + assert test_obj.prop == testval - 2 + del test_obj.prop + assert test_obj.avail is False + with pytest.raises(AttributeError, match=msg): + _ = test_obj.prop + with pytest.raises(AttributeError, match=msg): + test_obj.prop = testval - 3 + with pytest.raises(AttributeError, match=msg): + del test_obj.prop + + test_obj.avail = True + WithCondProp.prop.fget = None + with pytest.raises(AttributeError, match="has no getter"): + _ = test_obj.prop + WithCondProp.prop.fset = None + with pytest.raises(AttributeError, match="has no setter"): + test_obj.prop = testval - 4 + WithCondProp.prop.fdel = None + with pytest.raises(AttributeError, match="has no deleter"): + del test_obj.prop From 309ef8e837f713b5c21c216bd100d6df39db0ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20P=C3=B6lsterl?= Date: Sat, 10 Jun 2023 15:50:21 +0200 Subject: [PATCH 2/2] Make property_available_if a read-only property --- sksurv/__init__.py | 4 +- sksurv/util.py | 95 ++++++++++++++++++---------------------------- tests/test_util.py | 37 ++++-------------- 3 files changed, 47 insertions(+), 89 deletions(-) diff --git a/sksurv/__init__.py b/sksurv/__init__.py index 66a048db..26366fbf 100644 --- a/sksurv/__init__.py +++ b/sksurv/__init__.py @@ -6,7 +6,7 @@ from sklearn.pipeline import Pipeline, _final_estimator_has from sklearn.utils.metaestimators import available_if -from .util import conditionalAvailableProperty +from .util import property_available_if def _get_version(name): @@ -127,7 +127,7 @@ def predict_survival_function(self, X, **kwargs): return self.steps[-1][-1].predict_survival_function(Xt, **kwargs) -@conditionalAvailableProperty(_final_estimator_has('_predict_risk_score')) +@property_available_if(_final_estimator_has("_predict_risk_score")) def _predict_risk_score(self): return self.steps[-1][-1]._predict_risk_score diff --git a/sksurv/util.py b/sksurv/util.py index 679f791f..92f32852 100644 --- a/sksurv/util.py +++ b/sksurv/util.py @@ -261,38 +261,28 @@ def safe_concat(objs, *args, **kwargs): return concatenated -class _ConditionalAvailableProperty: +class _PropertyAvailableIfDescriptor: """Implements a conditional property using the descriptor protocol based on the property decorator. - The corresponding class in scikit-learn (_AvailableIfDescriptor) only supports callables. This - class adopts the property decorator as presented by the descriptor guide in the offical documentation. + The corresponding class in scikit-learn (`_AvailableIfDescriptor`) only supports callables. + This class adopts the property decorator as described in the descriptor guide in the offical Python documentation. - The check is defined on the getter function. Setter, Deleter also need to be defined on the exposing clas -s - if they are supposed to be available. - - See Also + See also -------- - sklearn.utils.available_if._AvailableIfDescriptor: - The original class in scikit-learn. + https://docs.python.org/3/howto/descriptor.html + Descriptor HowTo Guide + + :class:`sklearn.utils.available_if._AvailableIfDescriptor` + The original class in scikit-learn. """ - def __init__(self, check, fget=None, fset=None, fdel=None, doc=None): - self._check = check + def __init__(self, check, fget, doc=None): + self.check = check self.fget = fget - self.fset = fset - self.fdel = fdel if doc is None and fget is not None: doc = fget.__doc__ self.__doc__ = doc - self._name = '' - - def _run_check(self, obj): - attr_err = AttributeError( - f"This {repr(obj)} has no attribute {repr(self._name)}" - ) - if not self.check(obj): - raise attr_err + self._name = "" def __set_name__(self, owner, name): self._name = name @@ -300,43 +290,32 @@ def __set_name__(self, owner, name): def __get__(self, obj, objtype=None): if obj is None: return self - self._run_check(obj) + + attr_err = AttributeError(f"This {obj!r} has no attribute {self._name!r}") + if not self.check(obj): + raise attr_err + if self.fget is None: raise AttributeError(f"property '{self._name}' has no getter") return self.fget(obj) - def __set__(self, obj, value): - self._run_check(obj) - if self.fset is None: - raise AttributeError(f"property '{self._name}' has no setter") - self.fset(obj, value) - - def __delete__(self, obj): - self._run_check(obj) - if self.fdel is None: - raise AttributeError(f"property '{self._name}' has no deleter") - self.fdel(obj) - - @property - def check(self): - return self._check - - def getter(self, fget): - prop = type(self)(self.check, fget, self.fset, self.fdel, self.__doc__) - prop._name = self._name - return prop - - def setter(self, fset): - prop = type(self)(self.check, self.fget, fset, self.fdel, self.__doc__) - prop._name = self._name - return prop - - def deleter(self, fdel): - prop = type(self)(self.check, self.fget, self.fset, fdel, self.__doc__) - prop._name = self._name - return prop - - -def conditionalAvailableProperty(check): - prop = _ConditionalAvailableProperty(check=check) - return prop.getter + +def property_available_if(check): + """A property attribute that is available only if check returns a truthy value. + + Only supports getting an attribute value, setting or deleting an attribute value are not supported. + + Parameters + ---------- + check : callable + When passed the object of the decorated method, this should return + `True` if the property attribute is available, and either return `False` + or raise an `AttributeError` if not available. + + Returns + ------- + callable + Callable makes the decorated property available if `check` returns + `True`, otherwise the decorated property is unavailable. + """ + return lambda fn: _PropertyAvailableIfDescriptor(check=check, fget=fn) diff --git a/tests/test_util.py b/tests/test_util.py index 65398e63..fc3fd71d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -8,7 +8,7 @@ import pytest from sksurv.testing import FixtureParameterFactory -from sksurv.util import Surv, conditionalAvailableProperty, safe_concat +from sksurv.util import Surv, _PropertyAvailableIfDescriptor, property_available_if, safe_concat class ConcatCasesFactory(FixtureParameterFactory): @@ -377,17 +377,11 @@ def __init__(self, val): self.avail = False self._prop = val - @conditionalAvailableProperty(lambda self: self.avail) + @property_available_if(lambda self: self.avail) def prop(self): return self._prop - @prop.setter - def prop(self, new): - self._prop = new - - @prop.deleter - def prop(self): - self.avail = False + no_prop = _PropertyAvailableIfDescriptor(lambda self: self.avail, fget=None) testval = 43 msg = "has no attribute 'prop'" @@ -397,30 +391,15 @@ def prop(self): test_obj = WithCondProp(testval) with pytest.raises(AttributeError, match=msg): _ = test_obj.prop - with pytest.raises(AttributeError, match=msg): - test_obj.prop = testval - 1 - with pytest.raises(AttributeError, match=msg): - del test_obj.prop + assert test_obj.avail is False + test_obj.avail = True assert test_obj.prop == testval - test_obj.prop = testval - 2 - assert test_obj.prop == testval - 2 - del test_obj.prop - assert test_obj.avail is False + + test_obj.avail = False with pytest.raises(AttributeError, match=msg): _ = test_obj.prop - with pytest.raises(AttributeError, match=msg): - test_obj.prop = testval - 3 - with pytest.raises(AttributeError, match=msg): - del test_obj.prop test_obj.avail = True - WithCondProp.prop.fget = None with pytest.raises(AttributeError, match="has no getter"): - _ = test_obj.prop - WithCondProp.prop.fset = None - with pytest.raises(AttributeError, match="has no setter"): - test_obj.prop = testval - 4 - WithCondProp.prop.fdel = None - with pytest.raises(AttributeError, match="has no deleter"): - del test_obj.prop + _ = test_obj.no_prop