1
1
from typing import Optional , Sequence
2
2
3
- from abc import abstractmethod
3
+ from abc import ABC , abstractmethod
4
4
5
5
import numpy as np
6
6
import pandas as pd
7
7
8
8
from ...datasets .base import Dataset
9
9
from ...llm import LLMImportError
10
10
from ...models .base import BaseModel
11
+ from ...models .base .model_prediction import ModelPredictionResults
11
12
from ..issues import Issue , IssueLevel , Robustness
12
13
from ..logger import logger
13
14
from ..registry import Detector
15
+ from .base_perturbation_function import PerturbationFunction
16
+ from .numerical_transformations import NumericalTransformation
14
17
from .text_transformations import TextTransformation
15
18
16
19
17
- class BaseTextPerturbationDetector (Detector ):
18
- """Base class for metamorphic detectors based on text transformations."""
20
+ def _relative_delta (actual : np .ndarray , reference : np .ndarray ) -> np .ndarray :
21
+ """
22
+ Computes elementwise relative delta. If reference[i] == 0, we replace it with epsilon
23
+ to avoid division by zero.
24
+ """
25
+ epsilon = 1e-9
26
+ safe_ref = np .where (reference == 0 , epsilon , reference )
27
+ return (actual - reference ) / safe_ref
28
+
29
+
30
+ def _get_default_num_samples (model ) -> int :
31
+ if model .is_text_generation :
32
+ return 10
33
+ return 1_000
34
+
35
+
36
+ def _get_default_output_sensitivity (model ) -> float :
37
+ if model .is_text_generation :
38
+ return 0.15
39
+ return 0.05
40
+
41
+
42
+ def _get_default_threshold (model ) -> float :
43
+ if model .is_text_generation :
44
+ return 0.10
45
+ return 0.05
46
+
47
+
48
+ def _generate_robustness_tests (issue : Issue ):
49
+ from ...testing .tests .metamorphic import test_metamorphic_invariance
50
+
51
+ # Only generates a single metamorphic test
52
+ return {
53
+ f"Invariance to “{ issue .transformation_fn } ”" : test_metamorphic_invariance (
54
+ transformation_function = issue .transformation_fn ,
55
+ slicing_function = None ,
56
+ threshold = 1 - issue .meta ["threshold" ],
57
+ output_sensitivity = issue .meta .get ("output_sentitivity" , None ),
58
+ )
59
+ }
60
+
61
+
62
+ class BasePerturbationDetector (Detector , ABC ):
63
+ """
64
+ Common parent class for metamorphic perturbation detectors (both text and numerical).
65
+ """
19
66
20
67
_issue_group = Robustness
21
68
_taxonomy = ["avid-effect:performance:P0201" ]
22
69
23
70
def __init__ (
24
71
self ,
25
- transformations : Optional [Sequence [TextTransformation ]] = None ,
72
+ transformations : Optional [Sequence [PerturbationFunction ]] = None ,
26
73
threshold : Optional [float ] = None ,
27
- output_sensitivity = None ,
74
+ output_sensitivity : Optional [ float ] = None ,
28
75
num_samples : Optional [int ] = None ,
29
76
):
30
- """Creates a new instance of the detector.
77
+ """
78
+ Creates a new instance of the detector.
31
79
32
80
Parameters
33
81
----------
34
- transformations: Optional[Sequence[TextTransformation ]]
35
- The text transformations used in the metamorphic testing. See :ref:`transformation_functions` for details
82
+ transformations: Optional[Sequence[PerturbationFunction ]]
83
+ The transformations used in the metamorphic testing. See :ref:`transformation_functions` for details
36
84
about the available transformations. If not provided, a default set of transformations will be used.
37
85
threshold: Optional[float]
38
86
The threshold for the fail rate, which is defined as the proportion of samples for which the model
@@ -52,53 +100,103 @@ def __init__(
52
100
self .num_samples = num_samples
53
101
self .output_sensitivity = output_sensitivity
54
102
55
- def run (self , model : BaseModel , dataset : Dataset , features : Sequence [str ]) -> Sequence [Issue ]:
56
- transformations = self .transformations or self ._get_default_transformations (model , dataset )
103
+ @abstractmethod
104
+ def _select_features (self , dataset : Dataset , features : Sequence [str ]) -> Sequence [str ]:
105
+ raise NotImplementedError
57
106
58
- # Only analyze text features
59
- text_features = [
60
- f
61
- for f in features
62
- if dataset .column_types [f ] == "text" and pd .api .types .is_string_dtype (dataset .df [f ].dtype )
63
- ]
107
+ @abstractmethod
108
+ def _get_default_transformations (self ) -> Sequence [PerturbationFunction ]:
109
+ raise NotImplementedError
64
110
65
- logger .info (
66
- f"{ self .__class__ .__name__ } : Running with transformations={ [t .name for t in transformations ]} "
67
- f"threshold={ self .threshold } output_sensitivity={ self .output_sensitivity } num_samples={ self .num_samples } "
68
- )
111
+ @abstractmethod
112
+ def _supports_text_generation (self ) -> bool :
113
+ raise NotImplementedError
69
114
70
- issues = []
71
- for transformation in transformations :
72
- issues .extend (self ._detect_issues (model , dataset , transformation , text_features ))
115
+ def _compute_passed (
116
+ self ,
117
+ model : BaseModel ,
118
+ original_pred : ModelPredictionResults ,
119
+ perturbed_pred : ModelPredictionResults ,
120
+ output_sensitivity : float ,
121
+ ) -> np .ndarray :
122
+ if model .is_classification :
123
+ return original_pred .raw_prediction == perturbed_pred .raw_prediction
124
+
125
+ elif model .is_regression :
126
+ rel_delta = _relative_delta (perturbed_pred .raw_prediction , original_pred .raw_prediction )
127
+ return np .abs (rel_delta ) < output_sensitivity
128
+
129
+ elif model .is_text_generation :
130
+ if not self ._supports_text_generation ():
131
+ raise NotImplementedError ("Text generation is not supported by this detector." )
132
+ try :
133
+ import evaluate
134
+ except ImportError as err :
135
+ raise LLMImportError () from err
136
+
137
+ scorer = evaluate .load ("bertscore" )
138
+ score = scorer .compute (
139
+ predictions = perturbed_pred .prediction ,
140
+ references = original_pred .prediction ,
141
+ model_type = "distilbert-base-multilingual-cased" ,
142
+ idf = True ,
143
+ )
144
+ return np .array (score ["f1" ]) > 1 - output_sensitivity
73
145
74
- return [i for i in issues if i is not None ]
146
+ else :
147
+ raise NotImplementedError ("Only classification, regression, or text generation models are supported." )
75
148
76
- @abstractmethod
77
- def _get_default_transformations (self , model : BaseModel , dataset : Dataset ) -> Sequence [TextTransformation ]:
78
- ...
149
+ def _create_examples (
150
+ self ,
151
+ original_data : Dataset ,
152
+ original_pred : ModelPredictionResults ,
153
+ perturbed_data : Dataset ,
154
+ perturbed_pred : ModelPredictionResults ,
155
+ feature : str ,
156
+ passed : np .ndarray ,
157
+ model : BaseModel ,
158
+ transformation_fn ,
159
+ ) -> pd .DataFrame :
160
+ examples = original_data .df .loc [~ passed , [feature ]].copy ()
161
+ examples [f"{ transformation_fn .name } ({ feature } )" ] = perturbed_data .df .loc [~ passed , feature ]
162
+
163
+ examples ["Original prediction" ] = original_pred .prediction [~ passed ]
164
+ examples ["Prediction after perturbation" ] = perturbed_pred .prediction [~ passed ]
165
+
166
+ if model .is_classification :
167
+ examples ["Original prediction" ] = examples ["Original prediction" ].astype (str )
168
+ examples ["Prediction after perturbation" ] = examples ["Prediction after perturbation" ].astype (str )
169
+
170
+ ps_before = pd .Series (original_pred .probabilities [~ passed ], index = examples .index )
171
+ ps_after = pd .Series (perturbed_pred .probabilities [~ passed ], index = examples .index )
172
+
173
+ examples ["Original prediction" ] += ps_before .apply (lambda p : f" (p={ p :.2f} )" )
174
+ examples ["Prediction after perturbation" ] += ps_after .apply (lambda p : f" (p={ p :.2f} )" )
175
+
176
+ return examples
79
177
80
178
def _detect_issues (
81
179
self ,
82
180
model : BaseModel ,
83
181
dataset : Dataset ,
84
- transformation : TextTransformation ,
182
+ transformation ,
85
183
features : Sequence [str ],
86
184
) -> Sequence [Issue ]:
185
+ # Fall back to defaults if not explicitly set
87
186
num_samples = self .num_samples if self .num_samples is not None else _get_default_num_samples (model )
187
+ threshold = self .threshold if self .threshold is not None else _get_default_threshold (model )
88
188
output_sensitivity = (
89
189
self .output_sensitivity if self .output_sensitivity is not None else _get_default_output_sensitivity (model )
90
190
)
91
- threshold = self .threshold if self .threshold is not None else _get_default_threshold (model )
92
191
93
192
issues = []
94
- # @TODO: integrate this with Giskard metamorphic tests already present
95
193
for feature in features :
194
+ # Build transformation function for this feature
96
195
transformation_fn = transformation (column = feature )
97
196
transformed = dataset .transform (transformation_fn )
98
197
99
198
# Select only the records which were changed
100
199
changed_idx = dataset .df .index [transformed .df [feature ] != dataset .df [feature ]]
101
-
102
200
if changed_idx .empty :
103
201
continue
104
202
@@ -107,6 +205,7 @@ def _detect_issues(
107
205
rng = np .random .default_rng (747 )
108
206
changed_idx = changed_idx [rng .choice (len (changed_idx ), num_samples , replace = False )]
109
207
208
+ # Build original vs. perturbed datasets
110
209
original_data = Dataset (
111
210
dataset .df .loc [changed_idx ],
112
211
target = dataset .target ,
@@ -124,27 +223,12 @@ def _detect_issues(
124
223
original_pred = model .predict (original_data )
125
224
perturbed_pred = model .predict (perturbed_data )
126
225
127
- if model .is_classification :
128
- passed = original_pred .raw_prediction == perturbed_pred .raw_prediction
129
- elif model .is_regression :
130
- rel_delta = _relative_delta (perturbed_pred .raw_prediction , original_pred .raw_prediction )
131
- passed = np .abs (rel_delta ) < output_sensitivity
132
- elif model .is_text_generation :
133
- try :
134
- import evaluate
135
- except ImportError as err :
136
- raise LLMImportError () from err
137
-
138
- scorer = evaluate .load ("bertscore" )
139
- score = scorer .compute (
140
- predictions = perturbed_pred .prediction ,
141
- references = original_pred .prediction ,
142
- model_type = "distilbert-base-multilingual-cased" ,
143
- idf = True ,
144
- )
145
- passed = np .array (score ["f1" ]) > 1 - output_sensitivity
146
- else :
147
- raise NotImplementedError ("Only classification, regression, or text generation models are supported." )
226
+ passed = self ._compute_passed (
227
+ model = model ,
228
+ original_pred = original_pred ,
229
+ perturbed_pred = perturbed_pred ,
230
+ output_sensitivity = output_sensitivity ,
231
+ )
148
232
149
233
pass_rate = passed .mean ()
150
234
fail_rate = 1 - pass_rate
@@ -196,61 +280,88 @@ def _detect_issues(
196
280
)
197
281
198
282
# Add examples
199
- examples = original_data .df .loc [~ passed , (feature ,)].copy ()
200
- examples [f"{ transformation_fn .name } ({ feature } )" ] = perturbed_data .df .loc [~ passed , feature ]
201
-
202
- examples ["Original prediction" ] = original_pred .prediction [~ passed ]
203
- examples ["Prediction after perturbation" ] = perturbed_pred .prediction [~ passed ]
204
-
205
- if model .is_classification :
206
- examples ["Original prediction" ] = examples ["Original prediction" ].astype (str )
207
- examples ["Prediction after perturbation" ] = examples ["Prediction after perturbation" ].astype (str )
208
- ps_before = pd .Series (original_pred .probabilities [~ passed ], index = examples .index )
209
- ps_after = pd .Series (perturbed_pred .probabilities [~ passed ], index = examples .index )
210
- examples ["Original prediction" ] += ps_before .apply (lambda p : f" (p = { p :.2f} )" )
211
- examples ["Prediction after perturbation" ] += ps_after .apply (lambda p : f" (p = { p :.2f} )" )
212
-
283
+ examples = self ._create_examples (
284
+ original_data ,
285
+ original_pred ,
286
+ perturbed_data ,
287
+ perturbed_pred ,
288
+ feature ,
289
+ passed ,
290
+ model ,
291
+ transformation_fn ,
292
+ )
213
293
issue .add_examples (examples )
214
294
215
295
issues .append (issue )
216
296
217
297
return issues
218
298
299
+ def run (self , model : BaseModel , dataset : Dataset , features : Sequence [str ]) -> Sequence [Issue ]:
300
+ """
301
+ Runs the perturbation detector on the given model and dataset.
219
302
220
- def _generate_robustness_tests (issue : Issue ):
221
- from ...testing .tests .metamorphic import test_metamorphic_invariance
303
+ Parameters
304
+ ----------
305
+ model: BaseModel
306
+ The model to test.
307
+ dataset: Dataset
308
+ The dataset to use for testing.
309
+ features: Sequence[str]
310
+ The features (columns) to test.
311
+
312
+ Returns
313
+ -------
314
+ Sequence[Issue]
315
+ A list of issues found during the testing.
316
+ """
317
+ transformations = self .transformations or self ._get_default_transformations ()
318
+ selected_features = self ._select_features (dataset , features )
222
319
223
- # Only generates a single metamorphic test
224
- return {
225
- f"Invariance to “{ issue .transformation_fn } ”" : test_metamorphic_invariance (
226
- transformation_function = issue .transformation_fn ,
227
- slicing_function = None ,
228
- threshold = 1 - issue .meta ["threshold" ],
229
- output_sensitivity = issue .meta ["output_sentitivity" ],
320
+ logger .info (
321
+ f"{ self .__class__ .__name__ } : Running with transformations={ [t .name for t in transformations ]} "
322
+ f"threshold={ self .threshold } output_sensitivity={ self .output_sensitivity } num_samples={ self .num_samples } "
230
323
)
231
- }
232
324
325
+ issues = []
326
+ for transformation in transformations :
327
+ issues .extend (self ._detect_issues (model , dataset , transformation , selected_features ))
233
328
234
- def _relative_delta (actual , reference ):
235
- return (actual - reference ) / reference
329
+ return [i for i in issues if i is not None ]
236
330
237
331
238
- def _get_default_num_samples (model ) -> int :
239
- if model .is_text_generation :
240
- return 10
332
+ class BaseTextPerturbationDetector (BasePerturbationDetector ):
333
+ """
334
+ Base class for metamorphic detectors based on text transformations.
335
+ """
241
336
242
- return 1_000
337
+ def _select_features (self , dataset : Dataset , features : Sequence [str ]) -> Sequence [str ]:
338
+ # Only analyze text features
339
+ return [
340
+ f
341
+ for f in features
342
+ if dataset .column_types [f ] == "text" and pd .api .types .is_string_dtype (dataset .df [f ].dtype )
343
+ ]
243
344
345
+ @abstractmethod
346
+ def _get_default_transformations (self ) -> Sequence [TextTransformation ]:
347
+ raise NotImplementedError
244
348
245
- def _get_default_output_sensitivity (model ) -> float :
246
- if model .is_text_generation :
247
- return 0.15
349
+ def _supports_text_generation (self ) -> bool :
350
+ return True
248
351
249
- return 0.05
250
352
353
+ class BaseNumericalPerturbationDetector (BasePerturbationDetector ):
354
+ """
355
+ Base class for metamorphic detectors based on numerical feature perturbations.
356
+ """
251
357
252
- def _get_default_threshold ( model ) -> float :
253
- if model . is_text_generation :
254
- return 0.10
358
+ def _select_features ( self , dataset : Dataset , features : Sequence [ str ] ) -> Sequence [ str ] :
359
+ # Only analyze numeric features
360
+ return [ f for f in features if dataset . column_types [ f ] == "numeric" ]
255
361
256
- return 0.05
362
+ @abstractmethod
363
+ def _get_default_transformations (self ) -> Sequence [NumericalTransformation ]:
364
+ raise NotImplementedError
365
+
366
+ def _supports_text_generation (self ) -> bool :
367
+ return False
0 commit comments