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 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,9 @@
1
+ """Global explainers, which measure how much a model relies on each feature over a stream."""
2
+
3
+ from limno.importance.pfi import PFI
4
+ from limno.importance.sage import SAGE
5
+
6
+ __all__ = [
7
+ "PFI",
8
+ "SAGE",
9
+ ]
@@ -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
+ }
@@ -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)
@@ -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
+ [![CI](https://github.com/vitor-costa-garcia/limno/actions/workflows/ci.yml/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.