Skip to content

Commit 14f20fe

Browse files
[MRG] Add new scorer: MaNoScorer (#289)
* Add new scorer: MaNoScorer * Update MaNoScorer for deep NN case * Update MNoScorer test: regression, invalid p-norm, both normalization * Add MaNoScorer deep tests * Add static decorator * Update metrics to deal with skorch update * Update metrics to match skorch update * Update test for MaNo in the deep case * Add attribute to detect type of normalization * Add pipeline test and choice of normalization * Add output range test --------- Co-authored-by: Théo Gnassounou <[email protected]>
1 parent 485752e commit 14f20fe

File tree

5 files changed

+296
-8
lines changed

5 files changed

+296
-8
lines changed

README.md

+7-5
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The following algorithms are currently implemented.
5050

5151
Any methods that can be cast as an adaptation of the input data can be used in one of two ways:
5252
- a scikit-learn transformer (Adapter) which provides both a full Classifier/Regressor estimator
53-
- or an `Adapter` that can be used in a DA pipeline with `make_da_pipeline`.
53+
- or an `Adapter` that can be used in a DA pipeline with `make_da_pipeline`.
5454
Refer to the examples below and visit [the gallery](https://scikit-adaptation.github.io/auto_examples/index.html)for more details.
5555

5656
### Deep learning domain adaptation algorithms
@@ -84,7 +84,7 @@ details, please refer to this [example](https://scikit-adaptation.github.io/auto
8484
First, the DA data in the SKADA API is stored in the following format:
8585

8686
```python
87-
X, y, sample_domain
87+
X, y, sample_domain
8888
```
8989

9090
Where `X` is the input data, `y` is the target labels and `sample_domain` is the
@@ -112,7 +112,7 @@ pipe = make_da_pipeline(StandardScaler(), CORALAdapter(), LogisticRegression())
112112
pipe.fit(X, y, sample_domain=sample_domain) # sample_domain passed by name
113113
```
114114

115-
Please note that for `Adapter` classes that implement sample reweighting, the
115+
Please note that for `Adapter` classes that implement sample reweighting, the
116116
subsequent classifier/regressor must require sample_weights as input. This is
117117
done with the `set_fit_requires` method. For instance, with `LogisticRegression`, you
118118
would use `LogisticRegression().set_fit_requires('sample_weight')`:
@@ -143,7 +143,7 @@ cv = SourceTargetShuffleSplit()
143143
scorer = PredictionEntropyScorer()
144144

145145
# cross val score
146-
scores = cross_val_score(pipe, X, y, params={'sample_domain': sample_domain},
146+
scores = cross_val_score(pipe, X, y, params={'sample_domain': sample_domain},
147147
cv=cv, scoring=scorer)
148148

149149
# grid search
@@ -239,8 +239,10 @@ The library is distributed under the 3-Clause BSD license.
239239

240240
[33] Kang, G., Jiang, L., Yang, Y., & Hauptmann, A. G. (2019). [Contrastive Adaptation Network for Unsupervised Domain Adaptation](https://arxiv.org/abs/1901.00976). In Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (pp. 4893-4902).
241241

242-
[34] Jin, Ying, Wang, Ximei, Long, Mingsheng, Wang, Jianmin. [Minimum Class Confusion for Versatile Domain Adaptation](https://arxiv.org/pdf/1912.03699). ECCV, 2020.
242+
[34] Jin, Ying, Wang, Ximei, Long, Mingsheng, Wang, Jianmin. [Minimum Class Confusion for Versatile Domain Adaptation](https://arxiv.org/pdf/1912.03699). ECCV, 2020.
243243

244244
[35] Zhang, Y., Liu, T., Long, M., & Jordan, M. I. (2019). [Bridging Theory and Algorithm for Domain Adaptation](https://arxiv.org/abs/1904.05801). In Proceedings of the 36th International Conference on Machine Learning, (pp. 7404-7413).
245245

246246
[36] Xiao, Zhiqing, Wang, Haobo, Jin, Ying, Feng, Lei, Chen, Gang, Huang, Fei, Zhao, Junbo.[SPA: A Graph Spectral Alignment Perspective for Domain Adaptation](https://arxiv.org/pdf/2310.17594). In Neurips, 2023.
247+
248+
[37] Xie, Renchunzi, Odonnat, Ambroise, Feofanov, Vasilii, Deng, Weijian, Zhang, Jianfeng and An, Bo. [MaNo: Exploiting Matrix Norm for Unsupervised Accuracy Estimation Under Distribution Shifts](https://arxiv.org/pdf/2405.18979). In NeurIPS, 2024.

docs/source/all.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Main module :py:mod:`skada`
1313
:no-members:
1414
:no-inherited-members:
1515

16-
Sample reweighting DA methods
16+
Sample reweighting DA methods
1717
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1818

1919
.. .. autosummary::
@@ -211,6 +211,7 @@ DA metrics :py:mod:`skada.metrics`
211211
SoftNeighborhoodDensity
212212
CircularValidation
213213
MixValScorer
214+
MaNoScorer
214215

215216

216217
Model Selection :py:mod:`skada.model_selection`

skada/deep/tests/test_deep_scorer.py

+102-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Author: Yanis Lalou <[email protected]>
2+
# Ambroise Odonnat <[email protected]>
23
#
34
# License: BSD 3-Clause
45

@@ -16,6 +17,7 @@
1617
CircularValidation,
1718
DeepEmbeddedValidation,
1819
ImportanceWeightedScorer,
20+
MaNoScorer,
1921
MixValScorer,
2022
PredictionEntropyScorer,
2123
SoftNeighborhoodDensity,
@@ -29,6 +31,7 @@
2931
PredictionEntropyScorer(),
3032
SoftNeighborhoodDensity(),
3133
CircularValidation(),
34+
MaNoScorer(),
3235
MixValScorer(),
3336
ImportanceWeightedScorer(),
3437
],
@@ -66,6 +69,7 @@ def test_generic_scorer_on_deepmodel(scorer, da_dataset):
6669
PredictionEntropyScorer(),
6770
SoftNeighborhoodDensity(),
6871
DeepEmbeddedValidation(),
72+
MaNoScorer(),
6973
],
7074
)
7175
def test_generic_scorer(scorer, da_dataset):
@@ -101,6 +105,7 @@ def test_generic_scorer(scorer, da_dataset):
101105
[
102106
DeepEmbeddedValidation(),
103107
ImportanceWeightedScorer(),
108+
MaNoScorer(),
104109
],
105110
)
106111
def test_scorer_with_nd_features(scorer, da_dataset):
@@ -191,7 +196,14 @@ def test_dev_scorer_on_source_only(da_dataset):
191196
assert ~np.isnan(scores), "The score is computed"
192197

193198

194-
def test_dev_exception_layer_name(da_dataset):
199+
@pytest.mark.parametrize(
200+
"scorer",
201+
[
202+
DeepEmbeddedValidation(),
203+
MaNoScorer(),
204+
],
205+
)
206+
def test_exception_layer_name(scorer, da_dataset):
195207
X, y, sample_domain = da_dataset.pack_train(as_sources=["s"], as_targets=["t"])
196208
X_test, y_test, sample_domain_test = da_dataset.pack_test(as_targets=["t"])
197209

@@ -209,4 +221,92 @@ def test_dev_exception_layer_name(da_dataset):
209221
estimator.fit(X, y, sample_domain=sample_domain)
210222

211223
with pytest.raises(ValueError, match="The layer_name of the estimator is not set."):
212-
DeepEmbeddedValidation()(estimator, X, y, sample_domain)
224+
scorer(estimator, X, y, sample_domain)
225+
226+
227+
def test_mano_softmax(da_dataset):
228+
X, y, sample_domain = da_dataset.pack_train(as_sources=["s"], as_targets=["t"])
229+
X_test, y_test, sample_domain_test = da_dataset.pack_test(as_targets=["t"])
230+
231+
estimator = DeepCoral(
232+
ToyModule2D(proba=True),
233+
reg=1,
234+
layer_name="dropout",
235+
batch_size=10,
236+
max_epochs=10,
237+
train_split=None,
238+
)
239+
240+
X = X.astype(np.float32)
241+
X_test = X_test.astype(np.float32)
242+
243+
# without dict
244+
estimator.fit(X, y, sample_domain=sample_domain)
245+
246+
estimator.predict(X_test, sample_domain=sample_domain_test, allow_source=True)
247+
estimator.predict_proba(X, sample_domain=sample_domain, allow_source=True)
248+
249+
scorer = MaNoScorer(threshold=-1)
250+
scorer(estimator, X, y, sample_domain)
251+
print(scorer.chosen_normalization.lower())
252+
assert (
253+
scorer.chosen_normalization.lower() == "softmax"
254+
), "the wrong normalization was chosen"
255+
256+
257+
def test_mano_taylor(da_dataset):
258+
X, y, sample_domain = da_dataset.pack_train(as_sources=["s"], as_targets=["t"])
259+
X_test, y_test, sample_domain_test = da_dataset.pack_test(as_targets=["t"])
260+
261+
estimator = DeepCoral(
262+
ToyModule2D(proba=True),
263+
reg=1,
264+
layer_name="dropout",
265+
batch_size=10,
266+
max_epochs=10,
267+
train_split=None,
268+
)
269+
270+
X = X.astype(np.float32)
271+
X_test = X_test.astype(np.float32)
272+
273+
# without dict
274+
estimator.fit(X, y, sample_domain=sample_domain)
275+
276+
estimator.predict(X_test, sample_domain=sample_domain_test, allow_source=True)
277+
estimator.predict_proba(X, sample_domain=sample_domain, allow_source=True)
278+
279+
scorer = MaNoScorer(threshold=float("inf"))
280+
scorer(estimator, X, y, sample_domain)
281+
assert (
282+
scorer.chosen_normalization.lower() == "taylor"
283+
), "the wrong normalization was chosen"
284+
285+
286+
def test_mano_output_range(da_dataset):
287+
X, y, sample_domain = da_dataset.pack_train(as_sources=["s"], as_targets=["t"])
288+
X_test, y_test, sample_domain_test = da_dataset.pack_test(as_targets=["t"])
289+
290+
estimator = DeepCoral(
291+
ToyModule2D(proba=True),
292+
reg=1,
293+
layer_name="dropout",
294+
batch_size=10,
295+
max_epochs=10,
296+
train_split=None,
297+
)
298+
299+
X = X.astype(np.float32)
300+
X_test = X_test.astype(np.float32)
301+
302+
# without dict
303+
estimator.fit(X, y, sample_domain=sample_domain)
304+
305+
estimator.predict(X_test, sample_domain=sample_domain_test, allow_source=True)
306+
estimator.predict_proba(X, sample_domain=sample_domain, allow_source=True)
307+
308+
scorer = MaNoScorer(threshold=float("inf"))
309+
score = scorer(estimator, X, y, sample_domain)
310+
assert (scorer._sign * score >= 0) and (
311+
scorer._sign * score <= 1
312+
), "The output range should be [-1, 0] or [0, 1]."

skada/metrics.py

+135
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Remi Flamary <[email protected]>
33
# Oleksii Kachaiev <[email protected]>
44
# Yanis Lalou <[email protected]>
5+
# Ambroise Odonnat <[email protected]>
56
#
67
# License: BSD 3-Clause
78

@@ -396,6 +397,7 @@ def _score(self, estimator, X, y, sample_domain=None, **kwargs):
396397
)
397398

398399
has_transform_method = False
400+
399401
if not isinstance(estimator, Pipeline):
400402
# The estimator is a deep model
401403
if estimator.module_.layer_name is None:
@@ -782,3 +784,136 @@ def _score(self, estimator, X, y=None, sample_domain=None, **params):
782784
ice_score = ice_diff
783785

784786
return self._sign * ice_score
787+
788+
789+
class MaNoScorer(_BaseDomainAwareScorer):
790+
"""
791+
MaNo scorer inspired by [37]_, an approach for unsupervised accuracy estimation.
792+
793+
This scorer used the model's predictions on target data to estimate
794+
the accuracy of the model. The original implementation in [37]_ is
795+
tailored to neural networks and consist of three steps:
796+
1) Recover target logits (inference step),
797+
2) Normalize them as probabilities (e.g., with softmax),
798+
3) Aggregate by averaging the p-norms of the target normalized logits.
799+
800+
To ensure compatibility with any estimator, we adapt the original implementation.
801+
If the estimator is a neural network, follow 1) --> 2) --> 3) like in [37]_.
802+
Else, directly use the probabilities predicted by the estimator and then do 3).
803+
804+
See [37]_ for details.
805+
806+
Parameters
807+
----------
808+
p : int, default=4
809+
Order for the p-norm normalization.
810+
It must be non-negative.
811+
threshold : int, default=5
812+
Threshold value to determine which normalization to use.
813+
If threshold <= 0, softmax normalization is always used.
814+
See Eq.(6) of [37]_ for more details.
815+
greater_is_better : bool, default=True
816+
Whether higher scores are better.
817+
818+
Returns
819+
-------
820+
score : float in [0, 1] (or [-1, 0] depending on the value of self._sign).
821+
822+
References
823+
----------
824+
.. [37] Renchunzi Xie et al. MaNo: Matrix Norm for Unsupervised Accuracy Estimation
825+
under Distribution Shifts.
826+
In NeurIPS, 2024.
827+
"""
828+
829+
def __init__(self, p=4, threshold=5, greater_is_better=True):
830+
super().__init__()
831+
self.p = p
832+
self.threshold = threshold
833+
self._sign = 1 if greater_is_better else -1
834+
self.chosen_normalization = None
835+
836+
if self.p <= 0:
837+
raise ValueError("The order of the p-norm must be positive")
838+
839+
def _score(self, estimator, X, y, sample_domain=None, **params):
840+
if not hasattr(estimator, "predict_proba"):
841+
raise AttributeError(
842+
"The estimator passed should have a 'predict_proba' method. "
843+
f"The estimator {estimator!r} does not."
844+
)
845+
846+
X, y, sample_domain = check_X_y_domain(X, y, sample_domain, allow_nd=True)
847+
source_idx = extract_source_indices(sample_domain)
848+
849+
# Check from y values if it is a classification problem
850+
y_type = _find_y_type(y)
851+
if y_type != Y_Type.DISCRETE:
852+
raise ValueError("MaNo scorer only supports classification problems.")
853+
854+
if not isinstance(estimator, Pipeline):
855+
# The estimator is a deep model
856+
if estimator.module_.layer_name is None:
857+
raise ValueError("The layer_name of the estimator is not set.")
858+
859+
# 1) Recover logits on target
860+
logits = estimator.infer(X[~source_idx], **params).cpu().detach().numpy()
861+
862+
# 2) Normalize logits to obtain probabilities
863+
criterion = self._get_criterion(logits)
864+
proba = self._softrun(
865+
logits=logits,
866+
criterion=criterion,
867+
threshold=self.threshold,
868+
)
869+
else:
870+
# Directly recover predicted probabilities
871+
proba = estimator.predict_proba(
872+
X[~source_idx], sample_domain=sample_domain[~source_idx], **params
873+
)
874+
875+
# 3) Aggregate following Eq.(2) of [37]_.
876+
score = np.mean(proba**self.p) ** (1 / self.p)
877+
878+
return self._sign * score
879+
880+
def _get_criterion(self, logits):
881+
"""
882+
Compute criterion to select the proper normalization.
883+
See Eq.(6) of [1]_ for more details.
884+
"""
885+
proba = self._stable_softmax(logits)
886+
proba = np.log(proba)
887+
divergence = -np.mean(proba)
888+
889+
return divergence
890+
891+
def _softrun(self, logits, criterion, threshold):
892+
"""Normalize the logits following Eq.(6) of [37]_."""
893+
if criterion > threshold:
894+
# Apply softmax normalization
895+
outputs = self._stable_softmax(logits)
896+
self.chosen_normalization = "softmax"
897+
else:
898+
# Apply Taylor approximation
899+
outputs = self._taylor_softmax(logits)
900+
self.chosen_normalization = "taylor"
901+
902+
return outputs
903+
904+
@staticmethod
905+
def _stable_softmax(logits):
906+
"""Compute softmax function."""
907+
logits -= np.max(logits, axis=1, keepdims=True)
908+
exp_logits = np.exp(logits)
909+
exp_logits /= np.sum(exp_logits, axis=1, keepdims=True)
910+
return exp_logits
911+
912+
@staticmethod
913+
def _taylor_softmax(logits):
914+
"""Compute Taylor approximation of order 2 of softmax."""
915+
tay_logits = 1 + logits + logits**2 / 2
916+
tay_logits -= np.min(tay_logits, axis=1, keepdims=True)
917+
tay_logits /= np.sum(tay_logits, axis=1, keepdims=True)
918+
919+
return tay_logits

0 commit comments

Comments
 (0)