ml-reliability-toolkit 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.
@@ -0,0 +1,611 @@
1
+ import math
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+
7
+ from ml_reliability.checks.calibration import check_calibration
8
+ from ml_reliability.checks.cardinality import check_cardinality
9
+ from ml_reliability.checks.classification_metrics import check_classification_metrics
10
+ from ml_reliability.checks.distribution_shift import check_distribution_shift
11
+ from ml_reliability.checks.duplicates import check_duplicates
12
+ from ml_reliability.checks.generalization_gap import check_generalization_gap
13
+ from ml_reliability.checks.missing_values import check_missing_values
14
+ from ml_reliability.checks.model_performance import check_model_performance
15
+ from ml_reliability.checks.regression_metrics import check_regression_metrics
16
+ from ml_reliability.checks.schema import check_schema
17
+ from ml_reliability.checks.slice_analysis import check_slice_analysis
18
+ from ml_reliability.checks.stress import (
19
+ StressTestOutcome,
20
+ evaluate_categorical_corruption_stress,
21
+ evaluate_distribution_perturbation_stress,
22
+ evaluate_feature_dropout_stress,
23
+ evaluate_missing_value_stress,
24
+ evaluate_noise_stress,
25
+ )
26
+ from ml_reliability.checks.stress.base import validate_rate
27
+ from ml_reliability.checks.target_distribution import check_target_distribution
28
+ from ml_reliability.core.result import CheckResult
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ReliabilityReport:
33
+ score: float | None
34
+ grade: str
35
+ checks: list[CheckResult] = field(default_factory=list)
36
+ stress_tests: list[StressTestOutcome] = field(default_factory=list)
37
+
38
+ @property
39
+ def failed_checks(self) -> list[CheckResult]:
40
+ return [check for check in self.checks if not check.passed]
41
+
42
+ @property
43
+ def passed_checks(self) -> list[CheckResult]:
44
+ return [check for check in self.checks if check.passed]
45
+
46
+ @property
47
+ def warnings(self) -> list[CheckResult]:
48
+ return [check for check in self.checks if check.severity == "warning"]
49
+
50
+ @property
51
+ def invalid_score_checks(self) -> list[CheckResult]:
52
+ return [
53
+ check
54
+ for check in self.checks
55
+ if check.score is not None
56
+ and (
57
+ isinstance(check.score, bool)
58
+ or not isinstance(check.score, (int, float))
59
+ or not math.isfinite(check.score)
60
+ or not 0.0 <= check.score <= 100.0
61
+ )
62
+ ]
63
+
64
+ @property
65
+ def successful_stress_tests(self) -> list[StressTestOutcome]:
66
+ return [result for result in self.stress_tests if result.passed]
67
+
68
+ @property
69
+ def failed_stress_tests(self) -> list[StressTestOutcome]:
70
+ return [result for result in self.stress_tests if not result.passed]
71
+
72
+ @property
73
+ def worst_stress_degradation(self) -> float | None:
74
+ degradations = [
75
+ result.degradation
76
+ for result in self.successful_stress_tests
77
+ if all(
78
+ isinstance(metric, (int, float))
79
+ and not isinstance(metric, bool)
80
+ and math.isfinite(metric)
81
+ for metric in (
82
+ result.baseline_metric,
83
+ result.stressed_metric,
84
+ result.degradation,
85
+ )
86
+ )
87
+ ]
88
+ return max(degradations) if degradations else None
89
+
90
+ def to_json(self, path: str) -> None:
91
+ """Export this report as a JSON file."""
92
+ from ml_reliability.reporting import write_json_report
93
+
94
+ write_json_report(self, path)
95
+
96
+ def to_markdown(self, path: str) -> None:
97
+ """Export this report as a Markdown file."""
98
+ from ml_reliability.reporting import write_markdown_report
99
+
100
+ write_markdown_report(self, path)
101
+
102
+ def to_html(self, path: str) -> None:
103
+ """Export this report as an HTML file."""
104
+ from ml_reliability.reporting import write_html_report
105
+
106
+ write_html_report(self, path)
107
+
108
+
109
+ def _run_check_safely(
110
+ check_id,
111
+ title,
112
+ category,
113
+ check_function,
114
+ *args,
115
+ **kwargs,
116
+ ):
117
+ try:
118
+ return check_function(*args, **kwargs)
119
+ except Exception as exc: # noqa: BLE001 - Isolate individual check failures.
120
+ return CheckResult(
121
+ check_id=check_id,
122
+ title=title,
123
+ category=category,
124
+ passed=False,
125
+ severity="critical",
126
+ score=None,
127
+ message=(f"Check execution failed with {type(exc).__name__}: {exc}"),
128
+ evidence={
129
+ "error_type": type(exc).__name__,
130
+ "error_message": str(exc),
131
+ },
132
+ recommendation=(
133
+ "Investigate the check implementation and "
134
+ "the input that caused the execution failure."
135
+ ),
136
+ )
137
+
138
+
139
+ def _run_stress_test_safely(
140
+ stress_type,
141
+ evaluator,
142
+ *args,
143
+ **kwargs,
144
+ ):
145
+ try:
146
+ return evaluator(*args, **kwargs)
147
+ except Exception as exc: # noqa: BLE001 - Isolate individual stress-test failures.
148
+ return StressTestOutcome(
149
+ stress_type=stress_type,
150
+ baseline_metric=float("nan"),
151
+ stressed_metric=float("nan"),
152
+ degradation=float("inf"),
153
+ passed=False,
154
+ evidence={
155
+ "error_type": type(exc).__name__,
156
+ "error_message": str(exc),
157
+ "execution_failed": True,
158
+ },
159
+ )
160
+
161
+
162
+ class ReliabilitySuite:
163
+ def __init__(self, task_type: str | None = None):
164
+ if task_type not in {None, "classification", "regression"}:
165
+ raise ValueError(
166
+ "task_type must be 'classification', 'regression', or None."
167
+ )
168
+ self.task_type = task_type
169
+
170
+ def run(
171
+ self,
172
+ *,
173
+ X_train=None,
174
+ y_train=None,
175
+ X_test=None,
176
+ y_test=None,
177
+ model=None,
178
+ slices=None,
179
+ stress_tests=None,
180
+ ) -> ReliabilityReport:
181
+ if stress_tests is not None and not isinstance(stress_tests, dict):
182
+ raise TypeError("stress_tests must be a dictionary or None")
183
+
184
+ if stress_tests is not None:
185
+ supported_stress_types = (
186
+ "missing_values",
187
+ "feature_noise",
188
+ "feature_dropout",
189
+ "categorical_corruption",
190
+ "distribution_perturbation",
191
+ )
192
+
193
+ unsupported_stress_types = stress_tests.keys() - set(supported_stress_types)
194
+
195
+ if unsupported_stress_types:
196
+ unsupported = sorted(
197
+ unsupported_stress_types,
198
+ key=lambda value: (type(value).__name__, repr(value)),
199
+ )
200
+ raise ValueError(
201
+ f"Unsupported stress test(s): {unsupported}. "
202
+ f"Supported stress tests are: "
203
+ f"{list(supported_stress_types)}"
204
+ )
205
+
206
+ supported_config_options = {
207
+ "missing_values": {"rate", "random_state"},
208
+ "feature_noise": {"noise_level", "random_state"},
209
+ "feature_dropout": {"features"},
210
+ "categorical_corruption": {
211
+ "columns",
212
+ "rate",
213
+ "random_state",
214
+ },
215
+ "distribution_perturbation": {
216
+ "shift",
217
+ "scale",
218
+ "random_state",
219
+ },
220
+ }
221
+
222
+ for config_key in supported_stress_types:
223
+ config = stress_tests.get(config_key)
224
+
225
+ if config is None:
226
+ continue
227
+
228
+ if not isinstance(config, dict):
229
+ raise TypeError(
230
+ f"stress_tests['{config_key}'] must be a dictionary"
231
+ )
232
+
233
+ unsupported_options = (
234
+ config.keys() - supported_config_options[config_key]
235
+ )
236
+
237
+ if unsupported_options:
238
+ unsupported = sorted(
239
+ unsupported_options,
240
+ key=lambda value: (type(value).__name__, repr(value)),
241
+ )
242
+ raise ValueError(
243
+ f"Unsupported option(s) for stress test "
244
+ f"'{config_key}': {unsupported}. "
245
+ f"Supported options are: "
246
+ f"{sorted(supported_config_options[config_key])}"
247
+ )
248
+
249
+ rate_options = {
250
+ "missing_values": "rate",
251
+ "feature_noise": "noise_level",
252
+ "categorical_corruption": "rate",
253
+ }
254
+
255
+ if config_key in rate_options:
256
+ option_name = rate_options[config_key]
257
+ validate_rate(config.get(option_name, 0.1))
258
+
259
+ if config_key in {
260
+ "missing_values",
261
+ "feature_noise",
262
+ "categorical_corruption",
263
+ "distribution_perturbation",
264
+ }:
265
+ random_state = config.get("random_state", 42)
266
+
267
+ if (
268
+ isinstance(random_state, (bool, np.bool_))
269
+ or not isinstance(random_state, (int, np.integer))
270
+ or random_state < 0
271
+ ):
272
+ raise ValueError("random_state must be a non-negative integer")
273
+
274
+ if config_key == "distribution_perturbation":
275
+ shift = config.get("shift", 0.0)
276
+ scale = config.get("scale", 1.0)
277
+
278
+ for name, value in (("shift", shift), ("scale", scale)):
279
+ if isinstance(value, (bool, np.bool_)) or not isinstance(
280
+ value, (int, float, np.integer, np.floating)
281
+ ):
282
+ raise TypeError(f"{name} must be a numeric value")
283
+
284
+ try:
285
+ is_finite = math.isfinite(value)
286
+ except OverflowError:
287
+ is_finite = False
288
+
289
+ if not is_finite:
290
+ raise ValueError(f"{name} must be a finite number")
291
+
292
+ if scale <= 0:
293
+ raise ValueError("scale must be a finite number greater than 0")
294
+
295
+ if config_key == "feature_dropout":
296
+ features = config.get("features", [])
297
+
298
+ if isinstance(features, (str, bytes)) or not isinstance(
299
+ features, Sequence
300
+ ):
301
+ raise TypeError("features must be a sequence of column names")
302
+
303
+ if any(not isinstance(feature, str) for feature in features):
304
+ raise ValueError("features must contain only column names")
305
+
306
+ if config_key == "categorical_corruption":
307
+ columns = config.get("columns", [])
308
+
309
+ if isinstance(columns, (str, bytes)) or not isinstance(
310
+ columns, Sequence
311
+ ):
312
+ raise TypeError("columns must be a sequence of column names")
313
+
314
+ if any(not isinstance(column, str) for column in columns):
315
+ raise ValueError("columns must contain only column names")
316
+
317
+ if len(columns) != len(set(columns)):
318
+ raise ValueError(
319
+ "duplicate requested column names are not allowed"
320
+ )
321
+
322
+ checks: list[CheckResult] = []
323
+ stress_results: list[StressTestOutcome] = []
324
+ detected_task_type = self.task_type
325
+
326
+ if X_train is not None:
327
+ checks.append(
328
+ _run_check_safely(
329
+ "data.schema",
330
+ "Dataset Schema",
331
+ "data_quality",
332
+ check_schema,
333
+ X_train,
334
+ )
335
+ )
336
+ checks.append(
337
+ _run_check_safely(
338
+ "data.missing_values",
339
+ "Missing Values",
340
+ "data_quality",
341
+ check_missing_values,
342
+ X_train,
343
+ )
344
+ )
345
+ checks.append(
346
+ _run_check_safely(
347
+ "data.duplicates",
348
+ "Duplicate Rows",
349
+ "data_quality",
350
+ check_duplicates,
351
+ X_train,
352
+ )
353
+ )
354
+ checks.append(
355
+ _run_check_safely(
356
+ "data.cardinality",
357
+ "Feature Cardinality",
358
+ "data_quality",
359
+ check_cardinality,
360
+ X_train,
361
+ )
362
+ )
363
+
364
+ if X_train is not None and X_test is not None:
365
+ checks.append(
366
+ _run_check_safely(
367
+ "data.distribution_shift",
368
+ "Train/Test Distribution Shift",
369
+ "distribution",
370
+ check_distribution_shift,
371
+ X_train,
372
+ X_test,
373
+ )
374
+ )
375
+
376
+ if y_train is not None:
377
+ target_check = _run_check_safely(
378
+ "target.distribution",
379
+ "Target Distribution",
380
+ "target_quality",
381
+ check_target_distribution,
382
+ y_train,
383
+ task_type=self.task_type,
384
+ )
385
+ checks.append(target_check)
386
+
387
+ detected_task_type = target_check.evidence.get(
388
+ "task_type",
389
+ self.task_type,
390
+ )
391
+
392
+ if (
393
+ model is not None
394
+ and X_test is not None
395
+ and y_test is not None
396
+ and detected_task_type in {"classification", "regression"}
397
+ ):
398
+ checks.append(
399
+ _run_check_safely(
400
+ "model.performance",
401
+ "Model Performance",
402
+ "performance",
403
+ check_model_performance,
404
+ model=model,
405
+ X_test=X_test,
406
+ y_test=y_test,
407
+ task=detected_task_type,
408
+ )
409
+ )
410
+
411
+ if X_train is not None and y_train is not None:
412
+ checks.append(
413
+ _run_check_safely(
414
+ "model.generalization_gap",
415
+ "Generalization Gap",
416
+ "performance",
417
+ check_generalization_gap,
418
+ model=model,
419
+ X_train=X_train,
420
+ y_train=y_train,
421
+ X_test=X_test,
422
+ y_test=y_test,
423
+ task=detected_task_type,
424
+ )
425
+ )
426
+
427
+ if detected_task_type == "classification":
428
+ checks.append(
429
+ _run_check_safely(
430
+ "model.classification_metrics",
431
+ "Classification Metrics",
432
+ "performance",
433
+ check_classification_metrics,
434
+ model=model,
435
+ X_test=X_test,
436
+ y_test=y_test,
437
+ )
438
+ )
439
+
440
+ checks.append(
441
+ _run_check_safely(
442
+ "model.calibration",
443
+ "Classification Calibration",
444
+ "performance",
445
+ check_calibration,
446
+ model=model,
447
+ X_test=X_test,
448
+ y_test=y_test,
449
+ )
450
+ )
451
+
452
+ if slices is not None:
453
+ checks.append(
454
+ _run_check_safely(
455
+ "model.slice_analysis",
456
+ "Slice Analysis",
457
+ "performance",
458
+ check_slice_analysis,
459
+ model=model,
460
+ X_test=X_test,
461
+ y_test=y_test,
462
+ slices=slices,
463
+ task=detected_task_type,
464
+ )
465
+ )
466
+
467
+ if detected_task_type == "regression":
468
+ checks.append(
469
+ _run_check_safely(
470
+ "model.regression_metrics",
471
+ "Regression Metrics",
472
+ "performance",
473
+ check_regression_metrics,
474
+ model=model,
475
+ X_test=X_test,
476
+ y_test=y_test,
477
+ )
478
+ )
479
+
480
+ # Stress tests execute independently of ordinary model diagnostics.
481
+ if (
482
+ stress_tests is not None
483
+ and model is not None
484
+ and X_test is not None
485
+ and y_test is not None
486
+ and detected_task_type in {"classification", "regression"}
487
+ ):
488
+ missing_value_config = stress_tests.get("missing_values")
489
+
490
+ if missing_value_config is not None:
491
+ stress_results.append(
492
+ _run_stress_test_safely(
493
+ "missing_values",
494
+ evaluate_missing_value_stress,
495
+ model=model,
496
+ X_test=X_test,
497
+ y_test=y_test,
498
+ task=detected_task_type,
499
+ rate=missing_value_config.get("rate", 0.1),
500
+ random_state=missing_value_config.get("random_state", 42),
501
+ )
502
+ )
503
+
504
+ feature_noise_config = stress_tests.get("feature_noise")
505
+
506
+ if feature_noise_config is not None:
507
+ stress_results.append(
508
+ _run_stress_test_safely(
509
+ "feature_noise",
510
+ evaluate_noise_stress,
511
+ model=model,
512
+ X_test=X_test,
513
+ y_test=y_test,
514
+ task=detected_task_type,
515
+ noise_level=feature_noise_config.get("noise_level", 0.1),
516
+ random_state=feature_noise_config.get("random_state", 42),
517
+ )
518
+ )
519
+
520
+ feature_dropout_config = stress_tests.get("feature_dropout")
521
+
522
+ if feature_dropout_config is not None:
523
+ stress_results.append(
524
+ _run_stress_test_safely(
525
+ "feature_dropout",
526
+ evaluate_feature_dropout_stress,
527
+ model=model,
528
+ X_test=X_test,
529
+ y_test=y_test,
530
+ task=detected_task_type,
531
+ features=feature_dropout_config.get("features", []),
532
+ )
533
+ )
534
+
535
+ categorical_corruption_config = stress_tests.get("categorical_corruption")
536
+
537
+ if categorical_corruption_config is not None:
538
+ stress_results.append(
539
+ _run_stress_test_safely(
540
+ "categorical_corruption",
541
+ evaluate_categorical_corruption_stress,
542
+ model=model,
543
+ X_test=X_test,
544
+ y_test=y_test,
545
+ task=detected_task_type,
546
+ columns=categorical_corruption_config.get("columns", []),
547
+ rate=categorical_corruption_config.get("rate", 0.1),
548
+ random_state=categorical_corruption_config.get(
549
+ "random_state", 42
550
+ ),
551
+ )
552
+ )
553
+
554
+ distribution_perturbation_config = stress_tests.get(
555
+ "distribution_perturbation"
556
+ )
557
+
558
+ if distribution_perturbation_config is not None:
559
+ stress_results.append(
560
+ _run_stress_test_safely(
561
+ "distribution_perturbation",
562
+ evaluate_distribution_perturbation_stress,
563
+ model=model,
564
+ X_test=X_test,
565
+ y_test=y_test,
566
+ task=detected_task_type,
567
+ shift=distribution_perturbation_config.get("shift", 0.0),
568
+ scale=distribution_perturbation_config.get("scale", 1.0),
569
+ random_state=distribution_perturbation_config.get(
570
+ "random_state", 42
571
+ ),
572
+ )
573
+ )
574
+
575
+ scored_checks = [
576
+ check
577
+ for check in checks
578
+ if check.score is not None
579
+ and not isinstance(check.score, bool)
580
+ and isinstance(check.score, (int, float))
581
+ and math.isfinite(check.score)
582
+ and 0.0 <= check.score <= 100.0
583
+ ]
584
+
585
+ if not scored_checks:
586
+ return ReliabilityReport(
587
+ score=None,
588
+ grade="N/A",
589
+ checks=checks,
590
+ stress_tests=stress_results,
591
+ )
592
+
593
+ score = sum(check.score for check in scored_checks) / len(scored_checks)
594
+
595
+ if score >= 90:
596
+ grade = "A"
597
+ elif score >= 75:
598
+ grade = "B"
599
+ elif score >= 60:
600
+ grade = "C"
601
+ elif score >= 40:
602
+ grade = "D"
603
+ else:
604
+ grade = "F"
605
+
606
+ return ReliabilityReport(
607
+ score=score,
608
+ grade=grade,
609
+ checks=checks,
610
+ stress_tests=stress_results,
611
+ )