Skip to content

Commit 783b0a0

Browse files
authored
Merge pull request #264 from appliedAI-Initiative/feature/scorer
Fixes for GTShapley and scorers
2 parents 9f9f87f + c2e404d commit 783b0a0

File tree

17 files changed

+427
-412
lines changed

17 files changed

+427
-412
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Added `Scorer` class for a cleaner interface. Fix minor bugs around
6+
Group-Testing Shapley and switched to cvxpy for the constraint solver.
7+
[PR #264](https://github.com/appliedAI-Initiative/pyDVL/pull/264)
58
- Generalised stopping criteria for valuation algorithms. Improved classes
69
`ValuationResult` and `Status` with more operations. Some minor issues fixed.
710
[PR #252](https://github.com/appliedAI-Initiative/pyDVL/pull/250)

docs/30-data-valuation.rst

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ is implemented, it is important not to reuse `Utility` objects for different
118118
datasets. You can read more about :ref:`caching setup` in the installation guide
119119
and the documentation of the :mod:`pydvl.utils.caching` module.
120120

121+
Using custom scorers
122+
^^^^^^^^^^^^^^^^^^^^
123+
124+
The `scoring` argument of :class:`~pydvl.utils.utility.Utility` can be used to
125+
specify a custom :class:`~pydvl.utils.utility.Scorer` object. This is a simple
126+
wrapper for a callable that takes a model, and test data and returns a score.
127+
128+
More importantly, the object provides information about the range of the score,
129+
which is used by some methods by estimate the number of samples necessary, and
130+
about what default value to use when the model fails to train.
131+
132+
.. note::
133+
The most important property of a `Scorer` is its default value. Because many
134+
models will fail to fit on small subsets of the data, it is important to
135+
provide a sensible default value for the score.
136+
137+
It is possible to skip the construction of the :class:`~pydvl.utils.utility.Scorer`
138+
when constructing the `Utility` object. The two following calls are equivalent:
139+
140+
.. code-block:: python
141+
142+
utility = Utility(
143+
model, dataset, "explained_variance", score_range=(-np.inf, 1), default_score=0.0
144+
)
145+
utility = Utility(
146+
model, dataset, Scorer("explained_variance", range=(-np.inf, 1), default=0.0)
147+
)
148+
121149
Learning the utility
122150
^^^^^^^^^^^^^^^^^^^^
123151

@@ -369,14 +397,15 @@ $$
369397
but we don't advocate its use because of the speed and memory cost. Despite
370398
our best efforts, the number of samples required in practice for convergence
371399
can be several orders of magnitude worse than with e.g. Truncated Monte Carlo.
400+
Additionally, the CSP can sometimes turn out to be infeasible.
372401

373402
Usage follows the same pattern as every other Shapley method, but with the
374-
addition of an ``eps`` parameter required for the solution of the CSP. It should
375-
be the same value used to compute the minimum number of samples required. This
376-
can be done with :func:`~pydvl.value.shapley.gt.num_samples_eps_delta`, but note
377-
that the number returned will be huge! In practice, fewer samples can be enough,
378-
but the actual number will strongly depend on the utility, in particular its
379-
variance.
403+
addition of an ``epsilon`` parameter required for the solution of the CSP. It
404+
should be the same value used to compute the minimum number of samples required.
405+
This can be done with :func:`~pydvl.value.shapley.gt.num_samples_eps_delta`, but
406+
note that the number returned will be huge! In practice, fewer samples can be
407+
enough, but the actual number will strongly depend on the utility, in particular
408+
its variance.
380409

381410
.. code-block:: python
382411
@@ -550,7 +579,7 @@ nature of every (non-trivial) ML problem can have an effect:
550579

551580
pyDVL offers a dedicated :func:`function composition
552581
<pydvl.utils.types.compose_score>` for scorer functions which can be used to
553-
squash a score. The following is defined in module :mod:`~pydvl.utils.numeric`:
582+
squash a score. The following is defined in module :mod:`~pydvl.utils.scorer`:
554583

555584
.. code-block:: python
556585

src/pydvl/utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .numeric import *
55
from .parallel import *
66
from .progress import *
7+
from .score import *
78
from .status import *
89
from .types import *
910
from .utility import *

src/pydvl/utils/numeric.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,10 @@
44
"""
55

66
from itertools import chain, combinations
7-
from typing import Collection, Generator, Iterator, Optional, Tuple, TypeVar, overload
7+
from typing import Collection, Generator, Iterator, Optional, Tuple, TypeVar
88

99
import numpy as np
1010
from numpy.typing import NDArray
11-
from scipy.special import expit
12-
13-
from pydvl.utils.types import compose_score
1411

1512
FloatOrArray = TypeVar("FloatOrArray", float, NDArray[np.float_])
1613
IntOrArray = TypeVar("IntOrArray", int, NDArray[np.int_])
@@ -26,8 +23,6 @@
2623
"random_powerset",
2724
"random_subset_of_size",
2825
"top_k_value_accuracy",
29-
"squashed_r2",
30-
"squashed_variance",
3126
]
3227

3328
T = TypeVar("T", bound=np.generic)
@@ -277,14 +272,3 @@ def top_k_value_accuracy(
277272
top_k_pred_values = np.argsort(y_pred)[-k:]
278273
top_k_accuracy = len(np.intersect1d(top_k_exact_values, top_k_pred_values)) / k
279274
return top_k_accuracy
280-
281-
282-
def sigmoid(x: float) -> float:
283-
result: float = expit(x).item()
284-
return result
285-
286-
287-
squashed_r2 = compose_score("r2", sigmoid, "squashed r2")
288-
squashed_variance = compose_score(
289-
"explained_variance", sigmoid, "squashed explained variance"
290-
)

src/pydvl/utils/score.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""
2+
This module provides a :class:`Scorer` class that wraps scoring functions with
3+
additional information.
4+
5+
Scorers can be constructed in the same way as in scikit-learn: either from
6+
known strings or from a callable. Greater values must be better. If they are not,
7+
a negated version can be used, see scikit-learn's `make_scorer()
8+
<https://scikit-learn.org/stable/modules/generated/sklearn.metrics.make_scorer.html>`_.
9+
10+
:class:`Scorer` provides additional information about the scoring function, like
11+
its range and default values.
12+
"""
13+
from typing import Callable, Optional, Protocol, Tuple, Union
14+
15+
import numpy as np
16+
from numpy.typing import NDArray
17+
from scipy.special import expit
18+
from sklearn.metrics import get_scorer
19+
20+
from pydvl.utils.types import SupervisedModel
21+
22+
__all__ = ["Scorer", "compose_score", "squashed_r2", "squashed_variance"]
23+
24+
25+
class ScorerCallable(Protocol):
26+
"""Signature for a scorer"""
27+
28+
def __call__(self, model: SupervisedModel, X: NDArray, y: NDArray) -> float:
29+
...
30+
31+
32+
class Scorer:
33+
"""A scoring callable that takes a model, data, and labels and returns a
34+
scalar.
35+
36+
:param scoring: Either a string or callable that can be passed to
37+
`get_scorer
38+
<https://scikit-learn.org/stable/modules/generated/sklearn.metrics.get_scorer.html>`_.
39+
:param default: score to be used when a model cannot be fit, e.g. when too
40+
little data is passed, or errors arise.
41+
:param range: numerical range of the score function. Some Monte Carlo
42+
methods can use this to estimate the number of samples required for a
43+
certain quality of approximation. If not provided, it can be read from
44+
the ``scoring`` object if it provides it, for instance if it was
45+
constructed with :func:`~pydvl.utils.types.compose_score`.
46+
:param name: The name of the scorer. If not provided, the name of the
47+
function passed will be used.
48+
49+
.. versionadded:: 0.5.0
50+
51+
"""
52+
53+
_name: str
54+
range: NDArray[np.float_]
55+
56+
def __init__(
57+
self,
58+
scoring: Union[str, ScorerCallable],
59+
default: float = np.nan,
60+
range: Tuple = (-np.inf, np.inf),
61+
name: Optional[str] = None,
62+
):
63+
self._scorer = get_scorer(scoring)
64+
self.default = default
65+
# TODO: auto-fill from known scorers ?
66+
self.range = np.array(range)
67+
self._name = getattr(self._scorer, "__name__", name or "scorer")
68+
69+
def __call__(self, model: SupervisedModel, X: NDArray, y: NDArray) -> float:
70+
return self._scorer(model, X, y) # type: ignore
71+
72+
def __str__(self):
73+
return self._name
74+
75+
def __repr__(self):
76+
capitalized_name = "".join(s.capitalize() for s in self._name.split(" "))
77+
return f"{capitalized_name} (scorer={self._scorer})"
78+
79+
80+
def compose_score(
81+
scorer: Scorer,
82+
transformation: Callable[[float], float],
83+
range: Tuple[float, float],
84+
name: str,
85+
) -> Scorer:
86+
"""Composes a scoring function with an arbitrary scalar transformation.
87+
88+
Useful to squash unbounded scores into ranges manageable by data valuation
89+
methods.
90+
91+
.. code-block:: python
92+
:caption: Example usage
93+
94+
sigmoid = lambda x: 1/(1+np.exp(-x))
95+
compose_score(Scorer("r2"), sigmoid, range=(0,1), name="squashed r2")
96+
97+
:param scorer: The object to be composed.
98+
:param transformation: A scalar transformation
99+
:param range: The range of the transformation. This will be used e.g. by
100+
:class:`~pydvl.utils.utility.Utility` for the range of the composed.
101+
:param name: A string representation for the composition, for `str()`.
102+
:return: The composite :class:`Scorer`.
103+
"""
104+
105+
class NewScorer(Scorer):
106+
def __call__(self, model: SupervisedModel, X: NDArray, y: NDArray) -> float:
107+
score = self._scorer(model=model, X=X, y=y)
108+
return transformation(score)
109+
110+
return NewScorer(scorer, range=range, name=name)
111+
112+
113+
def _sigmoid(x: float) -> float:
114+
result: float = expit(x).item()
115+
return result
116+
117+
118+
squashed_r2 = compose_score(Scorer("r2"), _sigmoid, (0, 1), "squashed r2")
119+
""" A scorer that squashes the R² score into the range [0, 1] using a sigmoid."""
120+
121+
122+
squashed_variance = compose_score(
123+
Scorer("explained_variance"), _sigmoid, (0, 1), "squashed explained variance"
124+
)
125+
""" A scorer that squashes the explained variance score into the range [0, 1] using
126+
a sigmoid."""

src/pydvl/utils/types.py

Lines changed: 6 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22
transformations. Some of it probably belongs elsewhere.
33
"""
44
import inspect
5-
from typing import Callable, Optional, Protocol, Type, Union
5+
from typing import Callable, Protocol, Type
66

7-
from numpy import ndarray
8-
from sklearn.metrics import get_scorer
7+
from numpy.typing import NDArray
98

10-
__all__ = ["SupervisedModel", "Scorer", "compose_score"]
9+
__all__ = ["SupervisedModel"]
1110

1211

1312
class SupervisedModel(Protocol):
@@ -18,19 +17,16 @@ class SupervisedModel(Protocol):
1817
`score()`.
1918
"""
2019

21-
def fit(self, x: ndarray, y: ndarray):
20+
def fit(self, x: NDArray, y: NDArray):
2221
pass
2322

24-
def predict(self, x: ndarray) -> ndarray:
23+
def predict(self, x: NDArray) -> NDArray:
2524
pass
2625

27-
def score(self, x: ndarray, y: ndarray) -> float:
26+
def score(self, x: NDArray, y: NDArray) -> float:
2827
pass
2928

3029

31-
Scorer = Callable[[SupervisedModel, ndarray, ndarray], float]
32-
33-
3430
def unpackable(cls: Type) -> Type:
3531
"""A class decorator that allows unpacking of all attributes of an object
3632
with the double asterisk operator.
@@ -103,49 +99,3 @@ def wrapper(*args, **kwargs):
10399
return fun(*args, **kwargs)
104100

105101
return wrapper
106-
107-
108-
# FIXME: This probably should be somewhere else
109-
def compose_score(
110-
score: Union[str, Scorer],
111-
transformation: Callable[[float], float],
112-
name: str = None,
113-
):
114-
"""Composes a scoring function with an arbitrary scalar transformation.
115-
116-
Useful to squash unbounded scores into ranges manageable by data valuation
117-
methods.
118-
119-
.. code-block:: python
120-
:caption: Example usage
121-
122-
sigmoid = lambda x: 1/(1+np.exp(-x))
123-
compose_score("r2", sigmoid, "squashed r2")
124-
125-
:param score: Either a callable or a string naming any of sklearn's scorers
126-
:param transformation: A scalar transformation
127-
:param name: A string representation for the composition, for `str()`.
128-
129-
:return: The function composition.
130-
"""
131-
scoring_function: Scorer = get_scorer(score) if isinstance(score, str) else score
132-
133-
class NewScorer(object):
134-
def __init__(self, scorer: Scorer, name: Optional[str] = None):
135-
self._scorer = scorer
136-
self._name = name or "Composite " + getattr(
137-
self._scorer, "__name__", "scorer"
138-
)
139-
140-
def __call__(self, *args, **kwargs):
141-
score = self._scorer(*args, **kwargs)
142-
return transformation(score)
143-
144-
def __str__(self):
145-
return self._name
146-
147-
def __repr__(self):
148-
capitalized_name = "".join(s.capitalize() for s in self._name.split(" "))
149-
return f"{capitalized_name} (scorer={self._scorer})"
150-
151-
return NewScorer(scoring_function, name=name)

0 commit comments

Comments
 (0)