limno 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- limno/__init__.py +14 -0
- limno/base.py +305 -0
- limno/importance/__init__.py +9 -0
- limno/importance/_base.py +98 -0
- limno/importance/pfi.py +110 -0
- limno/importance/sage.py +194 -0
- limno/imputer.py +62 -0
- limno/multioutput.py +150 -0
- limno/storage.py +88 -0
- limno/tracker.py +77 -0
- limno-0.1.0.dist-info/METADATA +121 -0
- limno-0.1.0.dist-info/RECORD +14 -0
- limno-0.1.0.dist-info/WHEEL +4 -0
- limno-0.1.0.dist-info/licenses/LICENSE +28 -0
limno/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""limno: explainable AI for data streams and continual learning."""
|
|
2
|
+
|
|
3
|
+
from limno import base, importance, imputer, multioutput, storage, tracker
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"base",
|
|
9
|
+
"importance",
|
|
10
|
+
"imputer",
|
|
11
|
+
"multioutput",
|
|
12
|
+
"storage",
|
|
13
|
+
"tracker",
|
|
14
|
+
]
|
limno/base.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Base classes and protocols shared across limno.
|
|
2
|
+
|
|
3
|
+
limno follows River's conventions: features are a dictionary `x`, and everything is processed one
|
|
4
|
+
instance at a time through `*_one` methods.
|
|
5
|
+
|
|
6
|
+
Explainers only rely on the `Model` and `Metric` protocols, not on a library. River models and
|
|
7
|
+
metrics satisfy them as they are. Models from other libraries can be used through an adapter that
|
|
8
|
+
exposes the same methods.
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import abc
|
|
15
|
+
import collections.abc
|
|
16
|
+
import typing
|
|
17
|
+
from collections.abc import Hashable
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Explainer",
|
|
21
|
+
"Feature",
|
|
22
|
+
"ImportanceExplainer",
|
|
23
|
+
"Imputer",
|
|
24
|
+
"Metric",
|
|
25
|
+
"Model",
|
|
26
|
+
"Storage",
|
|
27
|
+
"Tracker",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
Feature = Hashable
|
|
31
|
+
"""The name of a feature, i.e. a key of `x`."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@typing.runtime_checkable
|
|
35
|
+
class Model(typing.Protocol):
|
|
36
|
+
"""An incremental model.
|
|
37
|
+
|
|
38
|
+
Any River model satisfies this protocol, including pipelines. To be scored with a metric that
|
|
39
|
+
needs probabilities, such as `river.metrics.LogLoss`, a model also needs a
|
|
40
|
+
`predict_proba_one(x)` method.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def predict_one(self, x: dict) -> typing.Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@typing.runtime_checkable
|
|
48
|
+
class Metric(typing.Protocol):
|
|
49
|
+
"""A metric that can score one instance at a time.
|
|
50
|
+
|
|
51
|
+
Any River metric satisfies this protocol. It tells which way is better with
|
|
52
|
+
`bigger_is_better`. A metric that has a `requires_labels` attribute set to `False` is given
|
|
53
|
+
probabilities instead of predicted labels, as in River.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
bigger_is_better: bool
|
|
58
|
+
|
|
59
|
+
def update(self, y_true: typing.Any, y_pred: typing.Any) -> typing.Any: ...
|
|
60
|
+
|
|
61
|
+
def revert(self, y_true: typing.Any, y_pred: typing.Any) -> typing.Any: ...
|
|
62
|
+
|
|
63
|
+
def get(self) -> float: ...
|
|
64
|
+
|
|
65
|
+
def clone(self) -> Metric: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@typing.runtime_checkable
|
|
69
|
+
class Tracker(typing.Protocol):
|
|
70
|
+
"""A running statistic of a series of numbers.
|
|
71
|
+
|
|
72
|
+
Statistics from `river.stats` satisfy this protocol.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def update(self, x: float) -> typing.Any: ...
|
|
77
|
+
|
|
78
|
+
def get(self) -> float: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Storage(abc.ABC):
|
|
82
|
+
"""Base class for storages, which keep some of the past instances of a stream."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self.instances: list[dict] = []
|
|
86
|
+
|
|
87
|
+
def __len__(self) -> int:
|
|
88
|
+
return len(self.instances)
|
|
89
|
+
|
|
90
|
+
@abc.abstractmethod
|
|
91
|
+
def update(self, x: dict) -> None:
|
|
92
|
+
"""Offer an instance to the storage, which may keep it.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
x
|
|
97
|
+
A dictionary of features.
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Imputer(abc.ABC):
|
|
103
|
+
"""Base class for imputers, which replace the values of some features of an instance."""
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def ready(self) -> bool:
|
|
107
|
+
"""Whether the imputer can impute."""
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
@abc.abstractmethod
|
|
111
|
+
def impute_one(self, x: dict, features: collections.abc.Iterable[Feature]) -> dict:
|
|
112
|
+
"""Replace the values of some features.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
x
|
|
117
|
+
A dictionary of features. It is not changed.
|
|
118
|
+
features
|
|
119
|
+
The features to replace.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
A new dictionary of features.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def learn_one(self, x: dict) -> None:
|
|
128
|
+
"""Update the imputer with an instance of the stream. By default, it does nothing.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
x
|
|
133
|
+
A dictionary of features.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _check_groups(groups: collections.abc.Mapping) -> None:
|
|
139
|
+
"""Check that every group has features, and that no feature is in two groups."""
|
|
140
|
+
seen = set()
|
|
141
|
+
for name, features in groups.items():
|
|
142
|
+
if not features:
|
|
143
|
+
raise ValueError(f"group {name!r} has no features")
|
|
144
|
+
for feature in features:
|
|
145
|
+
if feature in seen:
|
|
146
|
+
raise ValueError(f"feature {feature!r} is in more than one group")
|
|
147
|
+
seen.add(feature)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _group(x: dict, groups: collections.abc.Mapping) -> dict[Hashable, list[Feature]]:
|
|
151
|
+
"""The features of `x` by group. A feature that is in no group is a group of its own."""
|
|
152
|
+
group_of = {feature: name for name, features in groups.items() for feature in features}
|
|
153
|
+
grouped: dict[Hashable, list[Feature]] = {}
|
|
154
|
+
for feature in x:
|
|
155
|
+
if feature not in group_of and feature in groups:
|
|
156
|
+
raise ValueError(f"{feature!r} is both a feature and the name of a group")
|
|
157
|
+
grouped.setdefault(group_of.get(feature, feature), []).append(feature)
|
|
158
|
+
return grouped
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_model(model: typing.Any) -> None:
|
|
162
|
+
"""Check that `model` can predict."""
|
|
163
|
+
if not isinstance(model, Model):
|
|
164
|
+
raise TypeError(f"model must have a predict_one method, got {type(model).__name__}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _check_river_metric(model: Model, metric: Metric) -> None:
|
|
168
|
+
"""Check that River allows `metric` with `model`, if `model` is from River."""
|
|
169
|
+
try:
|
|
170
|
+
from river import base as river_base
|
|
171
|
+
except ImportError:
|
|
172
|
+
return
|
|
173
|
+
works_with = getattr(metric, "works_with", None)
|
|
174
|
+
if isinstance(model, river_base.Estimator) and works_with and not works_with(model):
|
|
175
|
+
raise ValueError(f"{type(metric).__name__} does not work with {type(model).__name__}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _predictor(model: Model, metric: Metric) -> collections.abc.Callable[[dict], typing.Any]:
|
|
179
|
+
"""The method of `model` that gives what `metric` expects, following River's rule."""
|
|
180
|
+
if not isinstance(metric, Metric):
|
|
181
|
+
raise TypeError(
|
|
182
|
+
"metric must have update, revert, get and clone methods and a bigger_is_better "
|
|
183
|
+
f"attribute, got {type(metric).__name__}"
|
|
184
|
+
)
|
|
185
|
+
# An adapter around a River model can have its own check, as River does not know the adapter
|
|
186
|
+
check_metric = getattr(model, "_check_metric", None)
|
|
187
|
+
if check_metric is not None:
|
|
188
|
+
check_metric(metric)
|
|
189
|
+
else:
|
|
190
|
+
_check_river_metric(model, metric)
|
|
191
|
+
|
|
192
|
+
if getattr(metric, "requires_labels", True):
|
|
193
|
+
return model.predict_one
|
|
194
|
+
predict_proba_one = getattr(model, "predict_proba_one", None)
|
|
195
|
+
if predict_proba_one is None:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
f"{type(metric).__name__} needs probabilities, but {type(model).__name__} has no "
|
|
198
|
+
"predict_proba_one method"
|
|
199
|
+
)
|
|
200
|
+
return predict_proba_one
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Explainer(abc.ABC):
|
|
204
|
+
"""Base class for local explainers, which explain one prediction.
|
|
205
|
+
|
|
206
|
+
A local explainer scores how much each feature of an instance contributes to the prediction of
|
|
207
|
+
a model. It keeps a reference to the model, so its explanations follow the model as it learns.
|
|
208
|
+
The explainer never updates the model: that is up to the code that runs the stream.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
model
|
|
213
|
+
The model to explain.
|
|
214
|
+
|
|
215
|
+
Examples
|
|
216
|
+
--------
|
|
217
|
+
>>> class Linear:
|
|
218
|
+
... def predict_one(self, x):
|
|
219
|
+
... return 2 * x.get("a", 0) + x.get("b", 0)
|
|
220
|
+
|
|
221
|
+
>>> class DropFeature(Explainer):
|
|
222
|
+
... def explain_one(self, x):
|
|
223
|
+
... y_pred = self.model.predict_one(x)
|
|
224
|
+
... return {
|
|
225
|
+
... f: y_pred - self.model.predict_one({k: v for k, v in x.items() if k != f})
|
|
226
|
+
... for f in x
|
|
227
|
+
... }
|
|
228
|
+
|
|
229
|
+
>>> DropFeature(Linear()).explain_one({"a": 1, "b": 3})
|
|
230
|
+
{'a': 2, 'b': 3}
|
|
231
|
+
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self, model: Model) -> None:
|
|
235
|
+
_check_model(model)
|
|
236
|
+
self.model = model
|
|
237
|
+
|
|
238
|
+
@abc.abstractmethod
|
|
239
|
+
def explain_one(self, x: dict) -> dict[Feature, float]:
|
|
240
|
+
"""Explain the prediction of the model for an instance.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
x
|
|
245
|
+
A dictionary of features.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
The contribution of each feature to the prediction.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def learn_one(self, x: dict, y: typing.Any) -> None:
|
|
254
|
+
"""Update the explainer with an instance of the stream.
|
|
255
|
+
|
|
256
|
+
By default, it does nothing. Explainers that keep a state, such as a sample of past
|
|
257
|
+
instances, update it here.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
x
|
|
262
|
+
A dictionary of features.
|
|
263
|
+
y
|
|
264
|
+
The target of the instance.
|
|
265
|
+
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class ImportanceExplainer(abc.ABC):
|
|
270
|
+
"""Base class for global explainers, which measure how much a model relies on each feature.
|
|
271
|
+
|
|
272
|
+
It learns from the stream with `learn_one`, and `importances` gives the current values. It keeps
|
|
273
|
+
a reference to the model, but never updates it: that is up to the code that runs the stream.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
model
|
|
278
|
+
The model to explain.
|
|
279
|
+
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
def __init__(self, model: Model) -> None:
|
|
283
|
+
_check_model(model)
|
|
284
|
+
self.model = model
|
|
285
|
+
|
|
286
|
+
@abc.abstractmethod
|
|
287
|
+
def learn_one(self, x: dict, y: typing.Any) -> None:
|
|
288
|
+
"""Update the importances with an instance of the stream.
|
|
289
|
+
|
|
290
|
+
Call this before the model learns from the instance, so that the instance is new to the
|
|
291
|
+
model.
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
x
|
|
296
|
+
A dictionary of features.
|
|
297
|
+
y
|
|
298
|
+
The target of the instance.
|
|
299
|
+
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
@abc.abstractmethod
|
|
304
|
+
def importances(self) -> dict[Feature, float]:
|
|
305
|
+
"""The current importance of each feature."""
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What the explainers that compare losses have in common."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections.abc
|
|
6
|
+
import copy
|
|
7
|
+
import random
|
|
8
|
+
import typing
|
|
9
|
+
|
|
10
|
+
from limno.base import (
|
|
11
|
+
Feature,
|
|
12
|
+
ImportanceExplainer,
|
|
13
|
+
Imputer,
|
|
14
|
+
Metric,
|
|
15
|
+
Model,
|
|
16
|
+
Tracker,
|
|
17
|
+
_check_groups,
|
|
18
|
+
_group,
|
|
19
|
+
_predictor,
|
|
20
|
+
)
|
|
21
|
+
from limno.imputer import Marginal
|
|
22
|
+
from limno.storage import GeometricReservoir
|
|
23
|
+
from limno.tracker import EWMean
|
|
24
|
+
|
|
25
|
+
Groups = collections.abc.Mapping[typing.Hashable, collections.abc.Collection[Feature]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _LossImportance(ImportanceExplainer):
|
|
29
|
+
"""What the explainers that compare losses have in common."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
model: Model,
|
|
34
|
+
metric: Metric,
|
|
35
|
+
imputer: Imputer | None,
|
|
36
|
+
tracker: Tracker | None,
|
|
37
|
+
n_samples: int,
|
|
38
|
+
seed: int | None,
|
|
39
|
+
max_age: int | None,
|
|
40
|
+
groups: Groups | None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(model)
|
|
43
|
+
self._predict = _predictor(model, metric)
|
|
44
|
+
if n_samples < 1:
|
|
45
|
+
raise ValueError(f"n_samples must be at least 1, got {n_samples}")
|
|
46
|
+
if max_age is not None and max_age < 1:
|
|
47
|
+
raise ValueError(f"max_age must be at least 1 or None, got {max_age}")
|
|
48
|
+
groups = {} if groups is None else groups
|
|
49
|
+
_check_groups(groups)
|
|
50
|
+
|
|
51
|
+
# A clone, as the loss of an instance must not be mixed with what the metric has seen
|
|
52
|
+
self.metric = metric.clone()
|
|
53
|
+
self.n_samples = n_samples
|
|
54
|
+
self.seed = seed
|
|
55
|
+
self.max_age = max_age
|
|
56
|
+
self.groups = groups
|
|
57
|
+
self._rng = random.Random(seed)
|
|
58
|
+
if imputer is None:
|
|
59
|
+
storage = GeometricReservoir(seed=self._rng.getrandbits(32))
|
|
60
|
+
imputer = Marginal(storage, seed=self._rng.getrandbits(32))
|
|
61
|
+
self.imputer = imputer
|
|
62
|
+
self.tracker = EWMean() if tracker is None else tracker
|
|
63
|
+
self._trackers: dict[Feature, Tracker] = {}
|
|
64
|
+
self._sign = -1.0 if metric.bigger_is_better else 1.0
|
|
65
|
+
self._n_seen = 0
|
|
66
|
+
self._last_seen: dict[Feature, int] = {}
|
|
67
|
+
|
|
68
|
+
def _see(self, x: dict) -> dict[typing.Hashable, list[Feature]]:
|
|
69
|
+
"""Note which features and groups are in `x`, and give its features by group."""
|
|
70
|
+
grouped = _group(x, self.groups)
|
|
71
|
+
self._n_seen += 1
|
|
72
|
+
for name in grouped:
|
|
73
|
+
self._last_seen[name] = self._n_seen
|
|
74
|
+
return grouped
|
|
75
|
+
|
|
76
|
+
def _features(self, name: typing.Hashable, grouped: dict) -> collections.abc.Collection:
|
|
77
|
+
"""All the features of a group, so that those missing from `x` are filled in."""
|
|
78
|
+
return self.groups.get(name, grouped[name])
|
|
79
|
+
|
|
80
|
+
def _loss(self, y: typing.Any, y_pred: typing.Any) -> float:
|
|
81
|
+
self.metric.update(y, y_pred)
|
|
82
|
+
loss = self.metric.get()
|
|
83
|
+
self.metric.revert(y, y_pred)
|
|
84
|
+
return self._sign * loss
|
|
85
|
+
|
|
86
|
+
def _update_importance(self, feature: Feature, value: float) -> None:
|
|
87
|
+
if feature not in self._trackers:
|
|
88
|
+
self._trackers[feature] = copy.deepcopy(self.tracker)
|
|
89
|
+
self._trackers[feature].update(value)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def importances(self) -> dict[Feature, float]:
|
|
93
|
+
"""The current importance of each feature or group, without those hidden by `max_age`."""
|
|
94
|
+
return {
|
|
95
|
+
feature: tracker.get()
|
|
96
|
+
for feature, tracker in self._trackers.items()
|
|
97
|
+
if self.max_age is None or self._n_seen - self._last_seen[feature] < self.max_age
|
|
98
|
+
}
|
limno/importance/pfi.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Incremental permutation feature importance (iPFI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from limno.base import Imputer, Metric, Model, Tracker
|
|
8
|
+
from limno.importance._base import Groups, _LossImportance
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PFI(_LossImportance):
|
|
12
|
+
"""Incremental permutation feature importance (iPFI).
|
|
13
|
+
|
|
14
|
+
For each instance and feature, the feature is replaced with its value in a past instance. The
|
|
15
|
+
importance of a feature is how much this increases the loss of the model, as a running mean
|
|
16
|
+
over the stream. A positive value means that the model does better with the feature, and a
|
|
17
|
+
value close to zero that it does not rely on it.
|
|
18
|
+
|
|
19
|
+
Each instance takes `1 + n_features * n_samples` predictions, or `1 + n_groups * n_samples` with
|
|
20
|
+
`groups`.
|
|
21
|
+
|
|
22
|
+
The method is from Fumagalli et al., "Incremental permutation feature importance (iPFI):
|
|
23
|
+
towards online explanations on data streams", Machine Learning, 2023,
|
|
24
|
+
https://doi.org/10.1007/s10994-023-06385-y.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
model
|
|
29
|
+
The model to explain. It is not updated, but explanations follow it as it learns.
|
|
30
|
+
metric
|
|
31
|
+
The metric that gives the loss, such as `river.metrics.MAE` or `river.metrics.LogLoss`.
|
|
32
|
+
Whether the model is asked for labels or probabilities depends on the metric, as in River.
|
|
33
|
+
It is computed on one instance at a time, so metrics that need many instances, such as
|
|
34
|
+
`river.metrics.ROCAUC` or `river.metrics.F1`, are not useful here.
|
|
35
|
+
imputer
|
|
36
|
+
How features are replaced. By default, an `imputer.Marginal` with a
|
|
37
|
+
`storage.GeometricReservoir` of 100 instances, which follows changes in the stream.
|
|
38
|
+
tracker
|
|
39
|
+
The running statistic of each importance, which is copied for each feature. By default, a
|
|
40
|
+
`tracker.EWMean` with an `alpha` of 0.001. The authors of iPFI found that an `alpha`
|
|
41
|
+
between 0.001 and 0.01 works well. Use `tracker.Mean` with a
|
|
42
|
+
`storage.UniformReservoir` if the stream does not change.
|
|
43
|
+
n_samples
|
|
44
|
+
The number of past instances to use per feature and instance. More samples give less noise
|
|
45
|
+
but take more predictions.
|
|
46
|
+
seed
|
|
47
|
+
Random seed of the default imputer. To use a seed with your own imputer, give it to the
|
|
48
|
+
imputer and its storage.
|
|
49
|
+
max_age
|
|
50
|
+
Hide a feature from `importances` once it is missing from this many instances in a row, as
|
|
51
|
+
its value is out of date. It comes back with its old value if it appears again. Raise this
|
|
52
|
+
for features that are rare, and use `None` to never hide a feature. The default matches the
|
|
53
|
+
window of the default tracker. A group is seen when any of its features is. This is not
|
|
54
|
+
part of iPFI, which assumes that the features do not change.
|
|
55
|
+
groups
|
|
56
|
+
Features to treat as one, such as `{"location": ["lat", "lon"]}`. A group is replaced as a
|
|
57
|
+
whole and gets one importance, under its name. With `imputer.Marginal`, its values all come
|
|
58
|
+
from the same past instance. Features in no group get their own importance. This suits
|
|
59
|
+
features that only make sense together, such as the columns of a one-hot encoding, or that
|
|
60
|
+
are correlated. A model can split its weight between correlated features, and then each one
|
|
61
|
+
alone looks less important than they are together.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
Examples
|
|
65
|
+
--------
|
|
66
|
+
>>> from river import datasets, linear_model, metrics, preprocessing
|
|
67
|
+
|
|
68
|
+
>>> model = preprocessing.StandardScaler() | linear_model.LinearRegression()
|
|
69
|
+
>>> explainer = PFI(model, metrics.MAE(), seed=42)
|
|
70
|
+
|
|
71
|
+
>>> for x, y in datasets.synth.Friedman(seed=42).take(1000):
|
|
72
|
+
... explainer.learn_one(x, y)
|
|
73
|
+
... model.learn_one(x, y)
|
|
74
|
+
|
|
75
|
+
In this stream, feature 3 has the largest linear effect on the target.
|
|
76
|
+
|
|
77
|
+
>>> max(explainer.importances, key=explainer.importances.get)
|
|
78
|
+
3
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
model: Model,
|
|
85
|
+
metric: Metric,
|
|
86
|
+
imputer: Imputer | None = None,
|
|
87
|
+
tracker: Tracker | None = None,
|
|
88
|
+
n_samples: int = 1,
|
|
89
|
+
seed: int | None = None,
|
|
90
|
+
max_age: int | None = 2000,
|
|
91
|
+
groups: Groups | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
super().__init__(model, metric, imputer, tracker, n_samples, seed, max_age, groups)
|
|
94
|
+
if imputer is not None and seed is not None:
|
|
95
|
+
raise ValueError("seed only applies to the default imputer, give it to your imputer")
|
|
96
|
+
|
|
97
|
+
def learn_one(self, x: dict, y: typing.Any) -> None:
|
|
98
|
+
grouped = self._see(x)
|
|
99
|
+
if self.imputer.ready:
|
|
100
|
+
y_pred = self._predict(x)
|
|
101
|
+
# A River classifier that has not learned yet predicts None or {}
|
|
102
|
+
if y_pred is not None and y_pred != {}:
|
|
103
|
+
loss = self._loss(y, y_pred)
|
|
104
|
+
for name in grouped:
|
|
105
|
+
imputed_loss = 0.0
|
|
106
|
+
for _ in range(self.n_samples):
|
|
107
|
+
imputed = self.imputer.impute_one(x, self._features(name, grouped))
|
|
108
|
+
imputed_loss += self._loss(y, self._predict(imputed))
|
|
109
|
+
self._update_importance(name, imputed_loss / self.n_samples - loss)
|
|
110
|
+
self.imputer.learn_one(x)
|
limno/importance/sage.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Incremental Shapley additive global importance (iSAGE)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import numbers
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
from limno.base import Imputer, Metric, Model, Tracker
|
|
10
|
+
from limno.importance._base import Groups, _LossImportance
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _number(y_pred: typing.Any) -> float:
|
|
14
|
+
"""Check that a prediction can be averaged."""
|
|
15
|
+
if isinstance(y_pred, bool) or not isinstance(y_pred, numbers.Real):
|
|
16
|
+
raise TypeError(
|
|
17
|
+
"SAGE averages predictions, so they must be numbers or probabilities, got "
|
|
18
|
+
f"{type(y_pred).__name__}"
|
|
19
|
+
)
|
|
20
|
+
return float(y_pred)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _average(y_preds: list) -> typing.Any:
|
|
24
|
+
"""The mean of predictions, in which a key that is missing from a dictionary counts as 0."""
|
|
25
|
+
if isinstance(y_preds[0], dict):
|
|
26
|
+
keys = dict.fromkeys(key for y_pred in y_preds for key in y_pred)
|
|
27
|
+
return {
|
|
28
|
+
key: sum(_number(y_pred.get(key, 0.0)) for y_pred in y_preds) / len(y_preds)
|
|
29
|
+
for key in keys
|
|
30
|
+
}
|
|
31
|
+
return sum(_number(y_pred) for y_pred in y_preds) / len(y_preds)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_no_labels(metric: Metric) -> None:
|
|
35
|
+
"""Check that `metric` is not a River metric for labels, as labels cannot be averaged."""
|
|
36
|
+
try:
|
|
37
|
+
from river import linear_model
|
|
38
|
+
except ImportError:
|
|
39
|
+
return
|
|
40
|
+
works_with = getattr(metric, "works_with", None)
|
|
41
|
+
if (
|
|
42
|
+
getattr(metric, "requires_labels", False)
|
|
43
|
+
and works_with
|
|
44
|
+
and works_with(linear_model.LogisticRegression())
|
|
45
|
+
):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"{type(metric).__name__} needs labels, but SAGE averages predictions. Use a metric "
|
|
48
|
+
"for probabilities, such as LogLoss"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SAGE(_LossImportance):
|
|
53
|
+
"""Incremental Shapley additive global importance (iSAGE).
|
|
54
|
+
|
|
55
|
+
SAGE shares out the drop in loss between predicting the mean and using the model, based on
|
|
56
|
+
Shapley values. For each instance, it reveals the features in a random order. Features that are
|
|
57
|
+
not revealed yet take their values from past instances, and the importance of a feature is how
|
|
58
|
+
much revealing it lowers the loss, as a running mean over the stream.
|
|
59
|
+
|
|
60
|
+
A positive value means that the model does better with the feature. Unlike `PFI`, the values
|
|
61
|
+
add up to the drop in loss, as long as the features of the stream do not change. This also
|
|
62
|
+
accounts for features that interact, as each feature is seen with different others.
|
|
63
|
+
|
|
64
|
+
Each instance takes `1 + (n_features - 1) * n_samples` predictions, or
|
|
65
|
+
`1 + (n_groups - 1) * n_samples` with `groups`.
|
|
66
|
+
|
|
67
|
+
Features are replaced from the marginal distribution, the interventional approach of the paper.
|
|
68
|
+
So correlated features can make unrealistic instances. The observational approach of the paper
|
|
69
|
+
is not implemented.
|
|
70
|
+
|
|
71
|
+
The method is from Muschalik et al., "iSAGE: An Incremental Version of SAGE for Online
|
|
72
|
+
Explanation on Data Streams", ECML PKDD, 2023, https://doi.org/10.1007/978-3-031-43418-1_26.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
model
|
|
77
|
+
The model to explain. It is not updated, but explanations follow it as it learns.
|
|
78
|
+
metric
|
|
79
|
+
The metric that gives the loss, such as `river.metrics.MAE` or `river.metrics.LogLoss`.
|
|
80
|
+
Predictions are averaged, so it must take numbers or probabilities. Metrics that need
|
|
81
|
+
labels, such as `river.metrics.Accuracy`, are rejected. A metric of your own for labels
|
|
82
|
+
that are numbers cannot be detected, so do not use one.
|
|
83
|
+
imputer
|
|
84
|
+
How features are replaced. By default, an `imputer.Marginal` with a
|
|
85
|
+
`storage.GeometricReservoir` of 100 instances, which follows changes in the stream.
|
|
86
|
+
tracker
|
|
87
|
+
The running statistic of each importance and of the mean prediction, which is copied for
|
|
88
|
+
each of them. By default, a `tracker.EWMean` with an `alpha` of 0.001. Use `tracker.Mean`
|
|
89
|
+
with a `storage.UniformReservoir` if the stream does not change.
|
|
90
|
+
n_samples
|
|
91
|
+
The number of past instances to average over at each step. The paper only proves that the
|
|
92
|
+
values reach the true ones as this grows. With few samples, importance moves from unused
|
|
93
|
+
features to used ones, so that unused features are below zero, even though the sum stays
|
|
94
|
+
right. Lower this only if predictions are slow, and raise it if the exact values matter.
|
|
95
|
+
seed
|
|
96
|
+
Random seed of the order of the features and of the default imputer. To also use a seed
|
|
97
|
+
with your own imputer, give it to the imputer and its storage.
|
|
98
|
+
max_age
|
|
99
|
+
Hide a feature from `importances` once it is missing from this many instances in a row, as
|
|
100
|
+
its value is out of date. It comes back with its old value if it appears again. Raise this
|
|
101
|
+
for features that are rare, and use `None` to never hide a feature. The default matches the
|
|
102
|
+
window of the default tracker. A group is seen when any of its features is. This is not
|
|
103
|
+
part of iSAGE, which assumes that the features do not change.
|
|
104
|
+
groups
|
|
105
|
+
Features to treat as one, such as `{"location": ["lat", "lon"]}`. A group is revealed as a
|
|
106
|
+
whole and gets one importance, under its name. With `imputer.Marginal`, its values all come
|
|
107
|
+
from the same past instance. Features in no group get their own importance. This suits
|
|
108
|
+
features that only make sense together, such as the columns of a one-hot encoding, or that
|
|
109
|
+
are correlated. A model can split its weight between correlated features, and then each one
|
|
110
|
+
alone looks less important than they are together.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
Examples
|
|
114
|
+
--------
|
|
115
|
+
>>> from river import datasets, linear_model, metrics, preprocessing
|
|
116
|
+
|
|
117
|
+
>>> model = preprocessing.StandardScaler() | linear_model.LinearRegression()
|
|
118
|
+
>>> explainer = SAGE(model, metrics.MAE(), seed=42)
|
|
119
|
+
|
|
120
|
+
>>> for x, y in datasets.synth.Friedman(seed=42).take(1000):
|
|
121
|
+
... explainer.learn_one(x, y)
|
|
122
|
+
... model.learn_one(x, y)
|
|
123
|
+
|
|
124
|
+
In this stream, feature 3 has the largest linear effect on the target.
|
|
125
|
+
|
|
126
|
+
>>> max(explainer.importances, key=explainer.importances.get)
|
|
127
|
+
3
|
|
128
|
+
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
model: Model,
|
|
134
|
+
metric: Metric,
|
|
135
|
+
imputer: Imputer | None = None,
|
|
136
|
+
tracker: Tracker | None = None,
|
|
137
|
+
n_samples: int = 5,
|
|
138
|
+
seed: int | None = None,
|
|
139
|
+
max_age: int | None = 2000,
|
|
140
|
+
groups: Groups | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
super().__init__(model, metric, imputer, tracker, n_samples, seed, max_age, groups)
|
|
143
|
+
_check_no_labels(metric)
|
|
144
|
+
self._means: dict[typing.Any, Tracker] = {}
|
|
145
|
+
self._n_means = 0
|
|
146
|
+
|
|
147
|
+
def _mean_prediction(self, y_pred: typing.Any) -> typing.Any:
|
|
148
|
+
"""Update the running mean of the predictions, and give its current value."""
|
|
149
|
+
is_dict = isinstance(y_pred, dict)
|
|
150
|
+
values = y_pred if is_dict else {None: y_pred}
|
|
151
|
+
for key in {**self._means, **values}:
|
|
152
|
+
if key not in self._means:
|
|
153
|
+
self._means[key] = copy.deepcopy(self.tracker)
|
|
154
|
+
# A new class had a probability of 0 until now, and all must still add up to 1
|
|
155
|
+
for _ in range(self._n_means):
|
|
156
|
+
self._means[key].update(0.0)
|
|
157
|
+
self._means[key].update(_number(values.get(key, 0.0)))
|
|
158
|
+
self._n_means += 1
|
|
159
|
+
if is_dict:
|
|
160
|
+
return {key: mean.get() for key, mean in self._means.items()}
|
|
161
|
+
return self._means[None].get()
|
|
162
|
+
|
|
163
|
+
def _explain(self, x: dict, y: typing.Any, y_pred: typing.Any, grouped: dict) -> None:
|
|
164
|
+
"""Reveal the features or groups of an instance in a random order."""
|
|
165
|
+
previous_loss = self._loss(y, self._mean_prediction(y_pred))
|
|
166
|
+
order = list(grouped)
|
|
167
|
+
self._rng.shuffle(order)
|
|
168
|
+
contributions = {}
|
|
169
|
+
for i, name in enumerate(order, start=1):
|
|
170
|
+
hidden = [feature for later in order[i:] for feature in self._features(later, grouped)]
|
|
171
|
+
# With no feature left to hide, every sample would give `y_pred`
|
|
172
|
+
y_mean = y_pred
|
|
173
|
+
if hidden:
|
|
174
|
+
y_mean = _average(
|
|
175
|
+
[
|
|
176
|
+
self._predict(self.imputer.impute_one(x, hidden))
|
|
177
|
+
for _ in range(self.n_samples)
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
loss = self._loss(y, y_mean)
|
|
181
|
+
contributions[name] = previous_loss - loss
|
|
182
|
+
previous_loss = loss
|
|
183
|
+
# In the order of `x`, so that the order of `importances` is not random
|
|
184
|
+
for name in grouped:
|
|
185
|
+
self._update_importance(name, contributions[name])
|
|
186
|
+
|
|
187
|
+
def learn_one(self, x: dict, y: typing.Any) -> None:
|
|
188
|
+
grouped = self._see(x)
|
|
189
|
+
if self.imputer.ready:
|
|
190
|
+
y_pred = self._predict(x)
|
|
191
|
+
# A River classifier that has not learned yet predicts None or {}
|
|
192
|
+
if y_pred is not None and y_pred != {}:
|
|
193
|
+
self._explain(x, y, y_pred, grouped)
|
|
194
|
+
self.imputer.learn_one(x)
|
limno/imputer.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Imputers, which replace the values of some features of an instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
|
|
8
|
+
from limno.base import Feature, Imputer, Storage
|
|
9
|
+
from limno.storage import GeometricReservoir
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Marginal",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Marginal(Imputer):
|
|
17
|
+
"""Replace features with their values in a random past instance.
|
|
18
|
+
|
|
19
|
+
The replaced features of an instance are always taken from the same past instance. If that
|
|
20
|
+
instance does not have a feature, the feature is removed, as River models accept instances
|
|
21
|
+
with missing features.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
storage
|
|
26
|
+
Where the past instances are kept. By default, a `storage.GeometricReservoir`.
|
|
27
|
+
seed
|
|
28
|
+
Random seed for picking past instances.
|
|
29
|
+
|
|
30
|
+
Examples
|
|
31
|
+
--------
|
|
32
|
+
>>> imputer = Marginal(seed=42)
|
|
33
|
+
>>> imputer.learn_one({"a": 1, "b": 2})
|
|
34
|
+
>>> imputer.impute_one({"a": 5, "b": 6}, ["b"])
|
|
35
|
+
{'a': 5, 'b': 2}
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, storage: Storage | None = None, seed: int | None = None) -> None:
|
|
40
|
+
self.storage = GeometricReservoir() if storage is None else storage
|
|
41
|
+
self.seed = seed
|
|
42
|
+
self._rng = random.Random(seed)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def ready(self) -> bool:
|
|
46
|
+
"""Whether there is a past instance to take values from."""
|
|
47
|
+
return len(self.storage) > 0
|
|
48
|
+
|
|
49
|
+
def impute_one(self, x: dict, features: Iterable[Feature]) -> dict:
|
|
50
|
+
if not self.ready:
|
|
51
|
+
raise ValueError("the storage is empty, call learn_one first")
|
|
52
|
+
past = self._rng.choice(self.storage.instances)
|
|
53
|
+
imputed = dict(x)
|
|
54
|
+
for feature in features:
|
|
55
|
+
if feature in past:
|
|
56
|
+
imputed[feature] = past[feature]
|
|
57
|
+
else:
|
|
58
|
+
imputed.pop(feature, None)
|
|
59
|
+
return imputed
|
|
60
|
+
|
|
61
|
+
def learn_one(self, x: dict) -> None:
|
|
62
|
+
self.storage.update(x)
|
limno/multioutput.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Wrappers that explain each output of a multi-output model on its own."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections.abc
|
|
6
|
+
import typing
|
|
7
|
+
from collections.abc import Hashable
|
|
8
|
+
|
|
9
|
+
from limno.base import Feature, ImportanceExplainer, Metric, Model, _check_model
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PerOutput",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _stand_in(model: typing.Any) -> typing.Any:
|
|
17
|
+
"""A River model with one output of the same kind as `model`, or `None` if it is not from River.
|
|
18
|
+
|
|
19
|
+
River's `works_with` rejects a metric for one output when given a multi-output model, so it is
|
|
20
|
+
given this model instead.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
from river import base as river_base
|
|
25
|
+
from river import linear_model
|
|
26
|
+
except ImportError:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(model, river_base.MultiTargetRegressor):
|
|
29
|
+
return linear_model.LinearRegression()
|
|
30
|
+
if isinstance(model, river_base.MultiLabelClassifier):
|
|
31
|
+
return linear_model.LogisticRegression()
|
|
32
|
+
if isinstance(model, river_base.Estimator):
|
|
33
|
+
raise TypeError(f"model must have many outputs, got {type(model).__name__}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Output:
|
|
38
|
+
"""One output of a multi-output model, shown as a model with one output."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, model: typing.Any, output: Hashable, stand_in: typing.Any) -> None:
|
|
41
|
+
self.model = model
|
|
42
|
+
self.output = output
|
|
43
|
+
self._stand_in = stand_in
|
|
44
|
+
|
|
45
|
+
def predict_one(self, x: dict) -> typing.Any:
|
|
46
|
+
return self.model.predict_one(x).get(self.output)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def predict_proba_one(self) -> collections.abc.Callable[[dict], typing.Any]:
|
|
50
|
+
# A property, so that this is missing if the model does not have the method
|
|
51
|
+
predict_proba_one = self.model.predict_proba_one
|
|
52
|
+
return lambda x: predict_proba_one(x).get(self.output)
|
|
53
|
+
|
|
54
|
+
def _check_metric(self, metric: Metric) -> None:
|
|
55
|
+
works_with = getattr(metric, "works_with", None)
|
|
56
|
+
if self._stand_in is not None and works_with and not works_with(self._stand_in):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{type(metric).__name__} does not work with one output of "
|
|
59
|
+
f"{type(self.model).__name__}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PerOutput:
|
|
64
|
+
"""Measure how much each output of a multi-output model relies on each feature.
|
|
65
|
+
|
|
66
|
+
There is one explainer per output, which is created when the output first appears in `y`. For
|
|
67
|
+
one value per feature over all the outputs, use a global explainer with a metric from
|
|
68
|
+
`river.metrics.multioutput` instead.
|
|
69
|
+
|
|
70
|
+
Each output costs as much as a model with one output, so `PFI` takes
|
|
71
|
+
`n_outputs * (1 + n_features * n_samples)` predictions per instance. Each output also keeps its
|
|
72
|
+
own explainer state, such as past instances.
|
|
73
|
+
|
|
74
|
+
In a chain, a later output uses the predictions of earlier ones. So the importance of a feature
|
|
75
|
+
for an output includes its effect through earlier outputs.
|
|
76
|
+
|
|
77
|
+
This is not part of iPFI, whose paper has examples with one output.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
model
|
|
82
|
+
The model to explain, whose `predict_one` gives a dictionary with one value per output. It
|
|
83
|
+
is not updated, but explanations follow it as it learns.
|
|
84
|
+
make_explainer
|
|
85
|
+
Creates the explainer of an output. It is given a model with that one output. Use a metric
|
|
86
|
+
for one output here, such as `river.metrics.MAE`.
|
|
87
|
+
|
|
88
|
+
Examples
|
|
89
|
+
--------
|
|
90
|
+
>>> import random
|
|
91
|
+
|
|
92
|
+
>>> from river import linear_model, metrics, multioutput
|
|
93
|
+
|
|
94
|
+
>>> from limno.importance import PFI
|
|
95
|
+
|
|
96
|
+
>>> model = multioutput.RegressorChain(linear_model.LinearRegression(), order=["y1", "y2"])
|
|
97
|
+
>>> explainer = PerOutput(model, lambda output: PFI(output, metrics.MAE(), seed=42))
|
|
98
|
+
|
|
99
|
+
>>> rng = random.Random(42)
|
|
100
|
+
>>> for _ in range(2000):
|
|
101
|
+
... x = {"a": rng.random(), "b": rng.random()}
|
|
102
|
+
... y = {"y1": 3 * x["a"], "y2": 3 * x["b"]}
|
|
103
|
+
... explainer.learn_one(x, y)
|
|
104
|
+
... model.learn_one(x, y)
|
|
105
|
+
|
|
106
|
+
>>> {output: max(values, key=values.get) for output, values in explainer.importances.items()}
|
|
107
|
+
{'y1': 'a', 'y2': 'b'}
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
model: Model,
|
|
114
|
+
make_explainer: collections.abc.Callable[[Model], ImportanceExplainer],
|
|
115
|
+
) -> None:
|
|
116
|
+
_check_model(model)
|
|
117
|
+
self.model = model
|
|
118
|
+
self.make_explainer = make_explainer
|
|
119
|
+
self.explainers: dict[Hashable, ImportanceExplainer] = {}
|
|
120
|
+
self._stand_in = _stand_in(model)
|
|
121
|
+
|
|
122
|
+
def learn_one(self, x: dict, y: dict) -> None:
|
|
123
|
+
"""Update the explainers with an instance of the stream.
|
|
124
|
+
|
|
125
|
+
Call this before the model learns from the instance, so that the instance is new to the
|
|
126
|
+
model. Only the outputs in `y` are updated.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
x
|
|
131
|
+
A dictionary of features.
|
|
132
|
+
y
|
|
133
|
+
A dictionary with the target of each output.
|
|
134
|
+
|
|
135
|
+
"""
|
|
136
|
+
for output, y_output in y.items():
|
|
137
|
+
if output not in self.explainers:
|
|
138
|
+
explainer = self.make_explainer(_Output(self.model, output, self._stand_in))
|
|
139
|
+
if not isinstance(explainer, ImportanceExplainer):
|
|
140
|
+
raise TypeError(
|
|
141
|
+
"make_explainer must give an ImportanceExplainer, got "
|
|
142
|
+
f"{type(explainer).__name__}"
|
|
143
|
+
)
|
|
144
|
+
self.explainers[output] = explainer
|
|
145
|
+
self.explainers[output].learn_one(x, y_output)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def importances(self) -> dict[Hashable, dict[Feature, float]]:
|
|
149
|
+
"""The current importance of each feature, for each output seen so far."""
|
|
150
|
+
return {output: explainer.importances for output, explainer in self.explainers.items()}
|
limno/storage.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Storages, which keep some of the past instances of a stream using a fixed amount of memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
from limno.base import Storage
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"GeometricReservoir",
|
|
11
|
+
"UniformReservoir",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Reservoir(Storage):
|
|
16
|
+
def __init__(self, size: int = 100, seed: int | None = None) -> None:
|
|
17
|
+
if size < 1:
|
|
18
|
+
raise ValueError(f"size must be at least 1, got {size}")
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.size = size
|
|
21
|
+
self.seed = seed
|
|
22
|
+
self._rng = random.Random(seed)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UniformReservoir(_Reservoir):
|
|
26
|
+
"""Keep a sample in which every past instance is equally likely to be found.
|
|
27
|
+
|
|
28
|
+
This suits a stream that does not change, since old and recent instances count the same.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
size
|
|
33
|
+
The number of instances to keep.
|
|
34
|
+
seed
|
|
35
|
+
Random seed.
|
|
36
|
+
|
|
37
|
+
Examples
|
|
38
|
+
--------
|
|
39
|
+
>>> storage = UniformReservoir(size=3, seed=42)
|
|
40
|
+
>>> for i in range(100):
|
|
41
|
+
... storage.update({"a": i})
|
|
42
|
+
>>> len(storage)
|
|
43
|
+
3
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, size: int = 100, seed: int | None = None) -> None:
|
|
48
|
+
super().__init__(size, seed)
|
|
49
|
+
self._n_seen = 0
|
|
50
|
+
|
|
51
|
+
def update(self, x: dict) -> None:
|
|
52
|
+
self._n_seen += 1
|
|
53
|
+
if len(self.instances) < self.size:
|
|
54
|
+
self.instances.append(dict(x))
|
|
55
|
+
return
|
|
56
|
+
i = self._rng.randrange(self._n_seen)
|
|
57
|
+
if i < self.size:
|
|
58
|
+
self.instances[i] = dict(x)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GeometricReservoir(_Reservoir):
|
|
62
|
+
"""Keep a sample that favours recent instances, which suits a stream that changes.
|
|
63
|
+
|
|
64
|
+
Once full, each new instance replaces a random one. So an instance is kept for about `size`
|
|
65
|
+
steps, and the chance that it is still kept falls by `1 - 1 / size` at each step.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
size
|
|
70
|
+
The number of instances to keep.
|
|
71
|
+
seed
|
|
72
|
+
Random seed.
|
|
73
|
+
|
|
74
|
+
Examples
|
|
75
|
+
--------
|
|
76
|
+
>>> storage = GeometricReservoir(size=3, seed=42)
|
|
77
|
+
>>> for i in range(100):
|
|
78
|
+
... storage.update({"a": i})
|
|
79
|
+
>>> len(storage)
|
|
80
|
+
3
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def update(self, x: dict) -> None:
|
|
85
|
+
if len(self.instances) < self.size:
|
|
86
|
+
self.instances.append(dict(x))
|
|
87
|
+
else:
|
|
88
|
+
self.instances[self._rng.randrange(self.size)] = dict(x)
|
limno/tracker.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Trackers, which keep a running statistic of a series of numbers.
|
|
2
|
+
|
|
3
|
+
Statistics from `river.stats` can be used as trackers too.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"EWMean",
|
|
11
|
+
"Mean",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Mean:
|
|
16
|
+
"""The mean of all the values so far, which suits a stream that does not change.
|
|
17
|
+
|
|
18
|
+
Examples
|
|
19
|
+
--------
|
|
20
|
+
>>> tracker = Mean()
|
|
21
|
+
>>> for value in [1.0, 2.0, 6.0]:
|
|
22
|
+
... tracker.update(value)
|
|
23
|
+
>>> tracker.get()
|
|
24
|
+
3.0
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._mean = 0.0
|
|
30
|
+
self._n = 0
|
|
31
|
+
|
|
32
|
+
def update(self, x: float) -> None:
|
|
33
|
+
self._n += 1
|
|
34
|
+
self._mean += (x - self._mean) / self._n
|
|
35
|
+
|
|
36
|
+
def get(self) -> float:
|
|
37
|
+
return self._mean
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EWMean:
|
|
41
|
+
"""An exponentially weighted mean, which favours recent values and so follows changes.
|
|
42
|
+
|
|
43
|
+
The weights are normalized. So it does not start from zero or from the first value: at the
|
|
44
|
+
start it is close to a plain mean, and over time it becomes the usual
|
|
45
|
+
`(1 - alpha) * mean + alpha * x`.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
alpha
|
|
50
|
+
The weight of a new value, between 0 and 1. Larger values follow changes faster, but are
|
|
51
|
+
noisier. It covers about the last `2 / alpha` values.
|
|
52
|
+
|
|
53
|
+
Examples
|
|
54
|
+
--------
|
|
55
|
+
>>> tracker = EWMean(alpha=0.5)
|
|
56
|
+
>>> tracker.update(3.0)
|
|
57
|
+
>>> tracker.get()
|
|
58
|
+
3.0
|
|
59
|
+
>>> tracker.update(6.0)
|
|
60
|
+
>>> tracker.get()
|
|
61
|
+
5.0
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, alpha: float = 0.001) -> None:
|
|
66
|
+
if not 0 < alpha <= 1:
|
|
67
|
+
raise ValueError(f"alpha must be above 0 and at most 1, got {alpha}")
|
|
68
|
+
self.alpha = alpha
|
|
69
|
+
self._sum = 0.0
|
|
70
|
+
self._weight = 0.0
|
|
71
|
+
|
|
72
|
+
def update(self, x: float) -> None:
|
|
73
|
+
self._sum = (1 - self.alpha) * self._sum + self.alpha * x
|
|
74
|
+
self._weight = (1 - self.alpha) * self._weight + self.alpha
|
|
75
|
+
|
|
76
|
+
def get(self) -> float:
|
|
77
|
+
return self._sum / self._weight if self._weight else 0.0
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: limno
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Explainable AI for data streams and continual learning.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vitor-costa-garcia/limno
|
|
6
|
+
Project-URL: Issues, https://github.com/vitor-costa-garcia/limno/issues
|
|
7
|
+
Author-email: Vitor da Costa Garcia <vitorcostagarcia2006@gmail.com>
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: continual-learning,data-streams,explainable-ai,incremental-learning,online-learning,river,xai
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
20
|
+
Requires-Dist: river>=0.21; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Provides-Extra: docs
|
|
23
|
+
Requires-Dist: furo; extra == 'docs'
|
|
24
|
+
Requires-Dist: myst-parser; extra == 'docs'
|
|
25
|
+
Requires-Dist: sphinx; extra == 'docs'
|
|
26
|
+
Provides-Extra: river
|
|
27
|
+
Requires-Dist: river>=0.21; extra == 'river'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# limno
|
|
31
|
+
|
|
32
|
+
[](https://github.com/vitor-costa-garcia/limno/actions/workflows/ci.yml)
|
|
33
|
+
|
|
34
|
+
**Explainable AI for data streams and continual learning.**
|
|
35
|
+
|
|
36
|
+
> limno is in early development: the API may change.
|
|
37
|
+
|
|
38
|
+
## What is this?
|
|
39
|
+
|
|
40
|
+
A model that learns from a stream keeps changing, and so do the reasons behind its predictions.
|
|
41
|
+
Most explanation tools assume a fixed model and a fixed dataset, so they do not fit this setting.
|
|
42
|
+
limno provides explanations that are computed one instance at a time and that follow the model as
|
|
43
|
+
it learns. Limnology is the study of inland waters, and limno looks at what goes on inside a
|
|
44
|
+
stream.
|
|
45
|
+
|
|
46
|
+
limno follows [River](https://riverml.xyz)'s conventions and works with River's models, pipelines,
|
|
47
|
+
metrics and statistics as they are. It complements River rather than replacing it. Other libraries,
|
|
48
|
+
such as [CapyMOA](https://capymoa.org), are not supported yet, but explainers do not depend on
|
|
49
|
+
River, so an adapter can add them later.
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Incremental permutation feature importance** (`lm.importance.PFI`), from
|
|
54
|
+
[Fumagalli et al. (2023)](https://doi.org/10.1007/s10994-023-06385-y). It shows how much a model
|
|
55
|
+
relies on each feature, as the stream goes on.
|
|
56
|
+
- **Incremental SAGE** (`lm.importance.SAGE`), from
|
|
57
|
+
[Muschalik et al. (2023)](https://doi.org/10.1007/978-3-031-43418-1_26). Its values add up to the
|
|
58
|
+
drop in loss between predicting the mean and using the model.
|
|
59
|
+
- **Groups of features** (`groups=`), to measure features that only make sense together as one.
|
|
60
|
+
- **Models with many outputs** (`lm.multioutput.PerOutput`), to see which features each output
|
|
61
|
+
relies on.
|
|
62
|
+
- **River metrics as the loss.** Whether the model is asked for labels or probabilities depends on
|
|
63
|
+
the metric, as in River.
|
|
64
|
+
- **Separate pieces** that can be swapped:
|
|
65
|
+
- Storages (`lm.storage`) keep past instances: `UniformReservoir` and `GeometricReservoir`.
|
|
66
|
+
- Imputers (`lm.imputer`) replace feature values: `Marginal`.
|
|
67
|
+
- Trackers (`lm.tracker`) keep running values: `Mean`, `EWMean`, or any of `river.stats`.
|
|
68
|
+
- **Features can appear and disappear** along the stream, with no list of names to give upfront. A
|
|
69
|
+
feature that stops appearing is hidden after `max_age` instances.
|
|
70
|
+
- **Reproducible runs** with a `seed`.
|
|
71
|
+
- **A base class for your own explainers** (`lm.base.Explainer` and
|
|
72
|
+
`lm.base.ImportanceExplainer`).
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install "limno[river]"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `river` extra installs River. limno itself has no required dependencies, so
|
|
81
|
+
`pip install limno` is enough for models and metrics from elsewhere.
|
|
82
|
+
|
|
83
|
+
## Quickstart
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from river import datasets, linear_model, metrics, preprocessing
|
|
87
|
+
|
|
88
|
+
import limno as lm
|
|
89
|
+
|
|
90
|
+
model = preprocessing.StandardScaler() | linear_model.LogisticRegression()
|
|
91
|
+
explainer = lm.importance.PFI(model, metrics.LogLoss(), seed=42)
|
|
92
|
+
|
|
93
|
+
for x, y in datasets.Phishing():
|
|
94
|
+
explainer.learn_one(x, y) # before the model learns, so the instance is new to it
|
|
95
|
+
model.learn_one(x, y)
|
|
96
|
+
|
|
97
|
+
explainer.importances
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
{'empty_server_form_handler': 0.177, 'popup_window': 0.11, 'https': 0.106, ...}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A positive value means that the model does better with the feature, and a value close to zero that
|
|
105
|
+
it does not rely on it. The explainer never updates the model.
|
|
106
|
+
|
|
107
|
+
## Notebooks
|
|
108
|
+
|
|
109
|
+
The [notebooks](https://github.com/vitor-costa-garcia/limno/tree/main/notebooks) show limno in use
|
|
110
|
+
with River. [How incremental PFI works](https://github.com/vitor-costa-garcia/limno/blob/main/notebooks/01_how_pfi_works.ipynb)
|
|
111
|
+
and [How incremental SAGE works](https://github.com/vitor-costa-garcia/limno/blob/main/notebooks/02_how_sage_works.ipynb)
|
|
112
|
+
explain the methods step by step.
|
|
113
|
+
|
|
114
|
+
## Contributing and feedback
|
|
115
|
+
|
|
116
|
+
Ideas, use cases and criticism are welcome through
|
|
117
|
+
[GitHub issues](https://github.com/vitor-costa-garcia/limno/issues).
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
BSD 3-Clause. See [LICENSE](https://github.com/vitor-costa-garcia/limno/blob/main/LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
limno/__init__.py,sha256=hOVjc-yqO6uJgTMKFaCwbP108YZ-bsp4o0yuwRePrp0,277
|
|
2
|
+
limno/base.py,sha256=v-HP7ERQ4spv5nYCc-vQ2FR3EN2c-lnuQs5z_28jhYs,9062
|
|
3
|
+
limno/imputer.py,sha256=m6oFBljRPGA65FRyCJFAiBzbhK0X4Jb4MbY4Fs7Zmd4,1887
|
|
4
|
+
limno/multioutput.py,sha256=E6kF9gQUHPSVekuIo9P83gL6fnaQZG2XWPvfi9uliRo,5643
|
|
5
|
+
limno/storage.py,sha256=2bWO1cDDFzqkAE1gnOw2579E-XYSckHbyUzuiyy5Hrw,2296
|
|
6
|
+
limno/tracker.py,sha256=a3GzgpRqR6PV6DeJyDo_SgAzqo4FQdXrRG0al2oNUF4,1934
|
|
7
|
+
limno/importance/__init__.py,sha256=mRwr3pP6wYITPuuEvoNWofv0RZOczzm6aZ21zWOyCgY,209
|
|
8
|
+
limno/importance/_base.py,sha256=a6IGmtYQAzH5W4kxKwsDtzP95W_aDCSwn7P-1UAx1IM,3431
|
|
9
|
+
limno/importance/pfi.py,sha256=pokDiW9iI7R5GP9zTybpLH8RZ0vFRycOHE0tcDcIAcg,5174
|
|
10
|
+
limno/importance/sage.py,sha256=SONA59tNYgvYdfpwJsii1bTGTwYTNctDtgI_MG6m__Y,8916
|
|
11
|
+
limno-0.1.0.dist-info/METADATA,sha256=71br83xVAQ2oPH2TRtaOZ0uY0-ap7nBtvh76mlDiR64,5137
|
|
12
|
+
limno-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
13
|
+
limno-0.1.0.dist-info/licenses/LICENSE,sha256=HDjsC4F6fmIndiseOUHa1geZao3FtHRItTdztrHTpHI,1492
|
|
14
|
+
limno-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Vitor
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|