shapeaudit 1.0.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.
Files changed (82) hide show
  1. modelcheck/__init__.py +59 -0
  2. modelcheck/_validate.py +214 -0
  3. modelcheck/ale.py +60 -0
  4. modelcheck/ale_detector.py +277 -0
  5. modelcheck/ale_fast.py +167 -0
  6. modelcheck/ale_selective.py +157 -0
  7. modelcheck/api.py +835 -0
  8. modelcheck/audit.py +477 -0
  9. modelcheck/backends.py +184 -0
  10. modelcheck/baselines.py +213 -0
  11. modelcheck/calibration.py +221 -0
  12. modelcheck/canary.py +187 -0
  13. modelcheck/causal.py +233 -0
  14. modelcheck/causal_bootstrap.py +346 -0
  15. modelcheck/ci.py +41 -0
  16. modelcheck/cli.py +380 -0
  17. modelcheck/compare/__init__.py +0 -0
  18. modelcheck/compare/model_comparator.py +47 -0
  19. modelcheck/confidence.py +158 -0
  20. modelcheck/contradict.py +144 -0
  21. modelcheck/dashboard.py +102 -0
  22. modelcheck/drift.py +47 -0
  23. modelcheck/experimental/__init__.py +28 -0
  24. modelcheck/experimental/calibration.py +221 -0
  25. modelcheck/experimental/gtsf.py +183 -0
  26. modelcheck/experimental/llm.py +393 -0
  27. modelcheck/experimental/llm_audit.py +597 -0
  28. modelcheck/experimental/rhale.py +229 -0
  29. modelcheck/experimental/topology.py +247 -0
  30. modelcheck/experimental/vision.py +377 -0
  31. modelcheck/experimental/zest.py +155 -0
  32. modelcheck/explainers/__init__.py +0 -0
  33. modelcheck/explainers/shap_explainer.py +67 -0
  34. modelcheck/explorer.py +246 -0
  35. modelcheck/fairness.py +120 -0
  36. modelcheck/frechet.py +332 -0
  37. modelcheck/geometry.py +186 -0
  38. modelcheck/gtsf.py +183 -0
  39. modelcheck/guard.py +144 -0
  40. modelcheck/importance.py +389 -0
  41. modelcheck/integrations/__init__.py +0 -0
  42. modelcheck/integrations/mlflow_integration.py +40 -0
  43. modelcheck/laudo.py +882 -0
  44. modelcheck/leakage.py +43 -0
  45. modelcheck/leakage_temporal.py +201 -0
  46. modelcheck/narrate.py +36 -0
  47. modelcheck/ood.py +146 -0
  48. modelcheck/orchestrator.py +699 -0
  49. modelcheck/paired.py +229 -0
  50. modelcheck/pipeline.py +379 -0
  51. modelcheck/plots.py +53 -0
  52. modelcheck/psi_shap.py +196 -0
  53. modelcheck/ranking.py +19 -0
  54. modelcheck/report.py +72 -0
  55. modelcheck/rhale.py +229 -0
  56. modelcheck/rules/__init__.py +0 -0
  57. modelcheck/rules/business_rules.py +36 -0
  58. modelcheck/rules/rule_learner.py +154 -0
  59. modelcheck/score.py +64 -0
  60. modelcheck/shape.py +368 -0
  61. modelcheck/shape_extensions.py +479 -0
  62. modelcheck/shapedist.py +130 -0
  63. modelcheck/silence.py +247 -0
  64. modelcheck/slope.py +261 -0
  65. modelcheck/snapshot.py +429 -0
  66. modelcheck/stability.py +153 -0
  67. modelcheck/studio.py +124 -0
  68. modelcheck/temporal.py +74 -0
  69. modelcheck/temporal_shape.py +72 -0
  70. modelcheck/topology.py +247 -0
  71. modelcheck/trending.py +205 -0
  72. modelcheck/universal.py +278 -0
  73. modelcheck/validation.py +247 -0
  74. modelcheck/zest.py +155 -0
  75. modelguard/__init__.py +41 -0
  76. shapeaudit/__init__.py +39 -0
  77. shapeaudit-1.0.0.dist-info/LICENSE +21 -0
  78. shapeaudit-1.0.0.dist-info/METADATA +179 -0
  79. shapeaudit-1.0.0.dist-info/RECORD +82 -0
  80. shapeaudit-1.0.0.dist-info/WHEEL +5 -0
  81. shapeaudit-1.0.0.dist-info/entry_points.txt +4 -0
  82. shapeaudit-1.0.0.dist-info/top_level.txt +3 -0
modelcheck/audit.py ADDED
@@ -0,0 +1,477 @@
1
+ """
2
+ audit.py — Interface unificada ModelGuard
3
+
4
+ Uso:
5
+ from modelcheck import audit
6
+ report = audit(model_v1, model_v2, X_eval, background=X_bg)
7
+ print(report)
8
+
9
+ Internamente chama:
10
+ - SDD (shape divergences via SHAP para árvores, ALE para outros)
11
+ - ShapeSlopeDetector
12
+ - ShapeSilenceAnalyzer
13
+ - ShapeThresholdCalibrator (se calibrate=True)
14
+ Health Score unificado 0-100
15
+ """
16
+ import numpy as np
17
+ import pandas as pd
18
+ from typing import Optional, List
19
+
20
+ def _is_tree_model(model) -> bool:
21
+ cls = type(model).__name__.lower()
22
+ return any(k in cls for k in
23
+ ['forest','tree','boost','xgb','lgb','catboost','extra'])
24
+
25
+ def audit(model_v1,
26
+ model_v2,
27
+ X_eval: pd.DataFrame,
28
+ background: Optional[pd.DataFrame] = None,
29
+ y_eval: Optional[pd.Series] = None,
30
+ feature_names: Optional[List[str]] = None,
31
+ calibrate: bool = False,
32
+ representation: str = "auto",
33
+ min_rel_importance: float = 0.01,
34
+ threshold: float = 0.30,
35
+ verbose: bool = True,
36
+ confirm_bootstrap: bool = False,
37
+ min_concordance: float = 0.85,
38
+ n_boot_shape: int = 12,
39
+ detector: str = "frechet",
40
+ frechet_threshold: float = None) -> dict:
41
+ """
42
+ Auditoria completa de shape instability entre dois modelos.
43
+
44
+ Parâmetros:
45
+ model_v1, model_v2 : modelos com interface predict(X)
46
+ X_eval : DataFrame de avaliação
47
+ background : background para SHAP (opcional; usa amostra de X_eval se None)
48
+ feature_names : lista de features (default: todas as colunas de X_eval)
49
+ min_rel_importance : ignora features com importância relativa abaixo
50
+ deste valor. Sem o filtro, ruído gera divergência
51
+ de forma espúria (medido em feature com 0.04% da
52
+ importância). Use 0.0 para desativar.
53
+ representation : 'shap' — TreeExplainer (mais sensível: 93%
54
+ recall, F1=0.80, porém só árvores)
55
+ 'ale' — Accumulated Local Effects (universal,
56
+ porém menos sensível: F1=0.59)
57
+ 'auto' — SHAP para árvores, ALE caso contrário
58
+ min_rel_importance : ignora features com importância relativa abaixo
59
+ deste valor. Sem o filtro, ruído gera divergência
60
+ de forma espúria (medido em feature com 0.04% da
61
+ importância). Use 0.0 para desativar.
62
+ representation : 'shap' — TreeExplainer, mais sensível
63
+ (recall 93%, F1 0.80) mas só árvores
64
+ 'ale' — model-agnostic, menos sensível
65
+ (F1 0.59), funciona em qualquer modelo
66
+ 'auto' — SHAP se disponível, ALE caso contrário
67
+ calibrate : calibra o threshold automaticamente via bootstrap
68
+ threshold : threshold manual (ignorado se calibrate=True)
69
+ verbose : imprime o relatório
70
+
71
+ Retorna dict com:
72
+ health_score, status, divergences, slope_alerts,
73
+ silence_categories, method_used, threshold_used, report_text
74
+ """
75
+ from modelcheck.ale_detector import ALEShapeDetector
76
+ from modelcheck.shape import _shape_profile
77
+ from modelcheck.slope import ShapeSlopeDetector
78
+ from modelcheck.silence import ShapeSilenceAnalyzer
79
+
80
+ from modelcheck._validate import validate_inputs
81
+ X_eval, feature_names = validate_inputs(
82
+ model_v1, model_v2, X_eval, feature_names, caller='audit')
83
+
84
+ # ── Validação de compatibilidade (FIX: antes retornava score=0) ──
85
+ n1 = getattr(model_v1, 'n_features_in_', None)
86
+ n2 = getattr(model_v2, 'n_features_in_', None)
87
+ # Compara o CONJUNTO de nomes quando disponível — mais informativo que
88
+ # a contagem: dois modelos podem ter 3 features cada e serem
89
+ # incompatíveis por usarem features diferentes.
90
+ fn1 = getattr(model_v1, 'feature_names_in_', None)
91
+ fn2 = getattr(model_v2, 'feature_names_in_', None)
92
+ if fn1 is not None and fn2 is not None:
93
+ s1, s2 = set(map(str, fn1)), set(map(str, fn2))
94
+ if s1 != s2:
95
+ faltam = sorted(s1 - s2)[:5]
96
+ sobram = sorted(s2 - s1)[:5]
97
+ raise ValueError(
98
+ f"Modelos treinados com features diferentes. "
99
+ f"Só em v1: {faltam or '—'}. Só em v2: {sobram or '—'}. "
100
+ f"Não é possível comparar.")
101
+ if n1 is not None and n2 is not None and n1 != n2:
102
+ raise ValueError(
103
+ f"Modelos incompatíveis: features diferentes em número — "
104
+ f"v1 espera {n1}, v2 espera {n2}. Não é possível comparar.")
105
+ if n1 is not None and n1 != len(feature_names):
106
+ raise ValueError(
107
+ f"X_eval tem {len(feature_names)} features mas o modelo "
108
+ f"espera {n1}.")
109
+
110
+ # ── Aviso de amostra insuficiente ───────────────────────────────
111
+ warnings_list = []
112
+
113
+ # ── Validação de dtypes (bug achado no dataset ENA do ONS) ──────
114
+ # Colunas numéricas vindas como string causavam TypeError dentro dos
115
+ # detectores, silenciado por except genérico no pipeline.
116
+ non_num = [f for f in feature_names
117
+ if f in X_eval.columns
118
+ and not pd.api.types.is_numeric_dtype(X_eval[f])]
119
+ if non_num:
120
+ raise TypeError(
121
+ f"Features não numéricas em X_eval: {non_num[:6]}. "
122
+ f"Converta com pd.to_numeric() antes de auditar "
123
+ f"(atenção a separador decimal e datas em formato misto).")
124
+ inf_cols = []
125
+ for f in feature_names:
126
+ if f in X_eval.columns:
127
+ try:
128
+ if np.isinf(X_eval[f].to_numpy(dtype=float)).any():
129
+ inf_cols.append(f)
130
+ except Exception:
131
+ # coluna não numérica: não há infinito a checar. O silêncio
132
+ # aqui é correto e local — não corresponde a nenhuma medição
133
+ # que deixou de acontecer.
134
+ continue # noqa: lacuna-nao-aplicavel
135
+ if inf_cols:
136
+ warnings_list.append(
137
+ f"INF_PRESENT: valores infinitos em {inf_cols[:4]}. "
138
+ f"Perfis podem ficar distorcidos.")
139
+ if len(X_eval) < 30:
140
+ warnings_list.append(
141
+ f"LOW_N: apenas {len(X_eval)} amostras (mínimo recomendado: 30). "
142
+ f"Resultados podem ser falsos positivos.")
143
+ if X_eval.isna().any().any():
144
+ n_na = int(X_eval.isna().sum().sum())
145
+ warnings_list.append(
146
+ f"NAN_PRESENT: {n_na} valores ausentes em X_eval. "
147
+ f"Perfis podem ser distorcidos.")
148
+
149
+ if background is None:
150
+ n_bg = min(100, len(X_eval))
151
+ background = X_eval.sample(n_bg, random_state=42)
152
+
153
+ # ── Calibração automática ─────────────────────────────────────
154
+ tau = threshold
155
+ if calibrate:
156
+ try:
157
+ from modelcheck.calibration import ShapeThresholdCalibrator
158
+ cal = ShapeThresholdCalibrator()
159
+ # assinatura real: fit(X, y, feature_names)
160
+ y_cal = y_eval if y_eval is not None else None
161
+ if y_cal is None:
162
+ raise ValueError(
163
+ "calibrate=True exige y_eval para calibrar o threshold")
164
+ cal.fit(X_eval, y_cal, feature_names)
165
+ tau_cal = getattr(cal, 'global_threshold_', None)
166
+ if tau_cal is None:
167
+ tau_cal = getattr(cal, 'threshold_', None)
168
+ if tau_cal is not None:
169
+ tau = float(tau_cal)
170
+ else:
171
+ warnings_list.append(
172
+ "CALIB_FAILED: calibrador não expôs threshold; "
173
+ f"usando manual tau={threshold}")
174
+ except Exception as e:
175
+ warnings_list.append(
176
+ f"CALIB_FAILED: {type(e).__name__}: {str(e)[:70]}. "
177
+ f"Usando tau={threshold}")
178
+
179
+ _falhas = [] # lacunas desta auditoria (política v1.66)
180
+
181
+
182
+ # ── Detecção de modelo degenerado ────────────────────────────────
183
+ # Um modelo que virou constante (previsão de variância ~zero) é um
184
+ # COLAPSO, não uma mudança de forma. Sem esta checagem, o diagnóstico
185
+ # dizia "divergência de forma em a e b" e o engenheiro iria investigar
186
+ # as features — quando o problema é o modelo inteiro.
187
+ def _degenerado(mdl):
188
+ try:
189
+ p = np.asarray(mdl.predict(X_eval), dtype=float)
190
+ if len(p) < 2:
191
+ return False, 0.0
192
+ rng_p = float(np.ptp(p))
193
+ ref = float(np.abs(p).mean()) + 1e-12
194
+ return (rng_p / ref) < 1e-6, rng_p
195
+ except Exception:
196
+ # modelo sem predict utilizável: a checagem de degenerado não se
197
+ # aplica, e a comparação principal já valida isso antes.
198
+ return False, 0.0 # noqa: lacuna-nao-aplicavel
199
+
200
+ deg1, rng1 = _degenerado(model_v1)
201
+ deg2, rng2 = _degenerado(model_v2)
202
+ if deg2 and not deg1:
203
+ warnings_list.append(
204
+ "MODEL_COLLAPSED: o modelo v2 prevê valor praticamente "
205
+ f"constante (amplitude={rng2:.3g}). Isto é colapso total do "
206
+ "modelo, não mudança de forma de features. Verifique o "
207
+ "treinamento antes de investigar features individuais.")
208
+ elif deg1 and not deg2:
209
+ warnings_list.append(
210
+ "MODEL_COLLAPSED_V1: o modelo v1 (referência) prevê valor "
211
+ f"praticamente constante (amplitude={rng1:.3g}). A comparação "
212
+ "não é informativa.")
213
+ elif deg1 and deg2:
214
+ warnings_list.append(
215
+ "BOTH_MODELS_COLLAPSED: ambos os modelos preveem valor "
216
+ "constante. Nenhuma comparação estrutural é possível.")
217
+
218
+ # ── Decide método de representação ────────────────────────────
219
+ rep = (representation or "auto").lower()
220
+ if rep not in ("auto", "shap", "ale"):
221
+ raise ValueError("representation deve ser 'auto', 'shap' ou 'ale'")
222
+ is_tree = _is_tree_model(model_v1) and _is_tree_model(model_v2)
223
+ if rep == "ale":
224
+ use_shap = False
225
+ elif rep == "shap":
226
+ use_shap = True
227
+ if not is_tree:
228
+ warnings_list.append(
229
+ "SHAP_FORCED: representation='shap' em modelo não-árvore; "
230
+ "TreeExplainer provavelmente falhará e cairá para ALE.")
231
+ else:
232
+ use_shap = is_tree
233
+ method = ("SHAP (TreeExplainer, F1=0.80, recall 93%)" if use_shap
234
+ else "ALE (model-agnostic, F1=0.59)")
235
+
236
+ shape_divs = []
237
+ sv1 = sv2 = None
238
+
239
+ # ── Alinhamento por NOME (bug 15, v1.44) ─────────────────────────
240
+ # O SHAP devolve colunas na ordem de TREINO de cada modelo. Se v1 foi
241
+ # treinado com (a,b,c) e v2 com (c,b,a), indexar sv1[:,i] e sv2[:,i] com
242
+ # o mesmo i compara 'a' de v1 contra 'c' de v2 — divergência 100%
243
+ # artificial. Medido: score 10 e 2 divergências espúrias com os MESMOS
244
+ # dados, só reordenando as colunas de treino.
245
+ # Cenário real: pipeline de feature engineering que reordena colunas.
246
+ def _order_of(m):
247
+ o = getattr(m, 'feature_names_in_', None)
248
+ return [] if o is None else [str(x) for x in list(o)]
249
+ order1 = _order_of(model_v1)
250
+ order2 = _order_of(model_v2)
251
+ if order1 and order2:
252
+ if set(order1) != set(order2):
253
+ raise ValueError(
254
+ f"Modelos treinados com conjuntos de features diferentes. "
255
+ f"Só em v1: {sorted(set(order1)-set(order2))[:5]}; "
256
+ f"só em v2: {sorted(set(order2)-set(order1))[:5]}.")
257
+ if order1 != order2:
258
+ warnings_list.append(
259
+ "FEATURE_ORDER_DIFFERS: os modelos foram treinados com as "
260
+ "colunas em ordens diferentes. As atribuições foram alinhadas "
261
+ "por NOME.")
262
+
263
+ def _align(sv, order, target):
264
+ """Reordena as colunas de sv da ordem do modelo para `target`."""
265
+ if not order or list(order) == list(target):
266
+ return sv
267
+ idx = [order.index(f) for f in target if f in order]
268
+ return sv[:, idx]
269
+
270
+ if use_shap:
271
+ try:
272
+ import shap
273
+ X1 = X_eval[order1] if order1 else X_eval
274
+ X2 = X_eval[order2] if order2 else X_eval
275
+ sv1 = shap.TreeExplainer(model_v1).shap_values(X1)
276
+ sv2 = shap.TreeExplainer(model_v2).shap_values(X2)
277
+ # garante 2D
278
+ if sv1.ndim == 3: sv1 = sv1[:,:,1]
279
+ if sv2.ndim == 3: sv2 = sv2[:,:,1]
280
+ # alinha ambos à ordem de `feature_names`
281
+ sv1 = _align(sv1, order1, feature_names)
282
+ sv2 = _align(sv2, order2, feature_names)
283
+
284
+ # FILTRO DE IMPORTÂNCIA (v1.37): sem ele, features irrelevantes
285
+ # geram divergência de forma espúria. Medido: feature de ruído
286
+ # com 0.04% da importância apontada como divergente tanto no
287
+ # controle quanto no caso com causa real. O perfil SHAP de ruído
288
+ # é instável, então a classificação de forma vira sozinha.
289
+ # Mesma guarda que o ImportanceCollapseDetector já tinha.
290
+ # v0.10.0: por padrão, a distância de Fréchet detecta e a classe
291
+ # nomeia (modelcheck/frechet.py). detector='classe' mantém o
292
+ # critério anterior.
293
+ if detector == 'frechet':
294
+ from modelcheck.frechet import divergencias_forma, LIMIAR_PADRAO
295
+ shape_divs = divergencias_forma(
296
+ X_eval, sv1, sv2, feature_names,
297
+ min_rel_importance=min_rel_importance,
298
+ limiar=frechet_threshold or LIMIAR_PADRAO)
299
+ else:
300
+ tot1 = float(np.abs(sv1).mean(axis=0).sum()) + 1e-12
301
+ tot2 = float(np.abs(sv2).mean(axis=0).sum()) + 1e-12
302
+ for i, f in enumerate(feature_names):
303
+ imp1 = float(np.abs(sv1[:,i]).mean())
304
+ imp2 = float(np.abs(sv2[:,i]).mean())
305
+ if max(imp1/tot1, imp2/tot2) < min_rel_importance:
306
+ continue
307
+ p1 = _shape_profile(X_eval[f].values, sv1[:,i])
308
+ p2 = _shape_profile(X_eval[f].values, sv2[:,i])
309
+ if not p1 or not p2: continue
310
+ if p1['shape'] == p2['shape']: continue
311
+ spread = abs(imp1-imp2) / (max(imp1,imp2)+1e-8)
312
+ # v1.59: o critério de confirmação era `spread >= tau`, que
313
+ # exigia mudança de IMPORTÂNCIA junto com a mudança de forma.
314
+ # Medido em 160 pares com importância preservada por
315
+ # construção: detectava 6% (F1 0.12). A confirmação por
316
+ # reamostragem detecta 100% com 8% de FP (F1 0.96).
317
+ # O spread continua reportado como informação.
318
+ if confirm_bootstrap:
319
+ from modelcheck.shape import ShapeDivergenceDetector as _S
320
+ f1s, c1 = _S._forma_estavel(X_eval[f].values, sv1[:, i],
321
+ n_boot=n_boot_shape, seed=11)
322
+ f2s, c2 = _S._forma_estavel(X_eval[f].values, sv2[:, i],
323
+ n_boot=n_boot_shape, seed=23)
324
+ confirmado = (f1s is not None and f2s is not None
325
+ and c1 >= min_concordance
326
+ and c2 >= min_concordance and f1s != f2s)
327
+ forma1, forma2 = (f1s, f2s) if confirmado else (p1['shape'],
328
+ p2['shape'])
329
+ else:
330
+ confirmado = spread >= tau
331
+ forma1, forma2 = p1['shape'], p2['shape']
332
+ if confirmado:
333
+ shape_divs.append({
334
+ 'feature': f,
335
+ 'shape_v1': forma1,
336
+ 'shape_v2': forma2,
337
+ 'spread': round(spread, 3),
338
+ })
339
+ except Exception as e:
340
+ use_shap = False
341
+ method = f"ALE (SHAP falhou: {e})"
342
+ _falhas.append({
343
+ 'layer': 'shape divergence (SHAP)',
344
+ 'reason': f'{type(e).__name__}: {e}'[:160],
345
+ 'consequence': ('caiu para ALE; os valores não são '
346
+ 'comparáveis em escala com execuções SHAP'),
347
+ })
348
+
349
+ if not use_shap:
350
+ det = ALEShapeDetector(n_bins=5, n_boot=12, min_confidence=0.6)
351
+ results = det.compare(model_v1, model_v2, X_eval, feature_names)
352
+ for r in results:
353
+ if r['divergence']:
354
+ shape_divs.append({
355
+ 'feature': r['feature'],
356
+ 'shape_v1': r['shape1'],
357
+ 'shape_v2': r['shape2'],
358
+ 'spread': round((r['conf1']+r['conf2'])/2, 3),
359
+ })
360
+
361
+ # ── Slope Detector ────────────────────────────────────────────
362
+ slope_alerts = []
363
+ if use_shap and sv1 is not None:
364
+ try:
365
+ slope_det = ShapeSlopeDetector(threshold_ratio=3.0)
366
+ slope_res = slope_det.check(sv1, sv2, X_eval, feature_names)
367
+ slope_alerts = [r for r in slope_res if r.get('divergence')]
368
+ except Exception as e:
369
+ _falhas.append({
370
+ 'layer': 'slope divergence',
371
+ 'reason': f'{type(e).__name__}: {e}'[:160],
372
+ 'consequence': ('não conseguimos comparar a inclinação das curvas '
373
+ 'neste par'),
374
+ })
375
+
376
+ # ── Silence Analyzer ─────────────────────────────────────────
377
+ silence_cats = {}
378
+ if use_shap and sv1 is not None:
379
+ try:
380
+ sil = ShapeSilenceAnalyzer()
381
+ sil_res = sil.analyze(sv1, sv2, X_eval, feature_names)
382
+ silence_cats = {r['feature']: r.get('silence_type','UNKNOWN')
383
+ for r in sil_res
384
+ if r.get('silence_type') not in (None,'STABLE')}
385
+ except Exception as e:
386
+ _falhas.append({
387
+ 'layer': 'silence analysis',
388
+ 'reason': f'{type(e).__name__}: {e}'[:160],
389
+ 'consequence': ('variáveis que ficaram silenciosas não foram '
390
+ 'identificadas'),
391
+ })
392
+
393
+ # ── Health Score unificado ────────────────────────────────────
394
+ n_shape = len(shape_divs)
395
+ n_slope = len(slope_alerts)
396
+ n_dist = sum(1 for v in silence_cats.values() if v == 'DIST_MISMATCH')
397
+
398
+ # FIX (v1.22): penalidade linear zerava o score em datasets de alta
399
+ # dimensionalidade. Com 30 features, ruído de seed já gerava
400
+ # divergências suficientes para levar o score a 0.
401
+ # Solução: penalidade sobre a FRAÇÃO de features divergentes, com
402
+ # saturação exponencial (nunca chega a 0 abruptamente).
403
+ n_feat = max(len(feature_names), 1)
404
+ frac_shape = n_shape / n_feat
405
+ frac_slope = n_slope / n_feat
406
+ frac_dist = n_dist / n_feat
407
+ penalty = (2.2 * frac_shape + 1.2 * frac_slope + 0.5 * frac_dist)
408
+ score = int(round(100 * float(np.exp(-penalty))))
409
+ status = ("HEALTHY" if score >= 85 else
410
+ "ATTENTION" if score >= 65 else "CRITICAL")
411
+
412
+ # ── Relatório em linguagem de negócio ─────────────────────────
413
+ lines = [
414
+ "=" * 60,
415
+ " MODELGUARD AUDIT REPORT",
416
+ "=" * 60,
417
+ f" Method: {method}",
418
+ f" Threshold: {tau:.2f}{' (calibrated)' if calibrate else ''}",
419
+ f" Features: {len(feature_names)}",
420
+ f" Eval samples: {len(X_eval)}",
421
+ "",
422
+ f" Health Score: {score}/100 [{status}]",
423
+ "",
424
+ ]
425
+
426
+ if not shape_divs and not slope_alerts and not silence_cats:
427
+ lines.append(" ✅ No structural divergences detected.")
428
+ lines.append(" Both models learned equivalent relationships.")
429
+ # Ausência de alerta é explicada, nunca deixada em branco
430
+ if not use_shap:
431
+ lines.append(" (método ALE: verifique se n é suficiente por bin)")
432
+ else:
433
+ if shape_divs:
434
+ lines.append(f" ⚠ Shape Divergences ({n_shape}):")
435
+ for d in shape_divs:
436
+ lines.append(f" '{d['feature']}': "
437
+ f"{d['shape_v1']} → {d['shape_v2']} "
438
+ f"(spread={d['spread']:.2f})")
439
+ if slope_alerts:
440
+ lines.append(f"\n ⚠ Slope Alerts ({n_slope}):")
441
+ for r in slope_alerts:
442
+ lines.append(f" '{r['feature']}': "
443
+ f"{r.get('alert_type','SLOPE_DIV')} "
444
+ f"ratio={r.get('ratio',0):.1f}x")
445
+ if silence_cats:
446
+ lines.append(f"\n ℹ Silence Categories:")
447
+ for f, cat in silence_cats.items():
448
+ if cat != 'STABLE':
449
+ lines.append(f" '{f}': {cat}")
450
+
451
+ if deg1 or deg2:
452
+ lines.insert(8, " 🔻 MODELO DEGENERADO — previsão constante. "
453
+ "As divergências abaixo são consequência, não causa.")
454
+ if warnings_list:
455
+ lines.append("")
456
+ for w in warnings_list:
457
+ lines.append(f" ⚠ {w}")
458
+ lines += ["", "=" * 60]
459
+ report_text = "\n".join(lines)
460
+
461
+ if verbose:
462
+ print(report_text)
463
+
464
+ return {
465
+ 'health_score': score,
466
+ 'status': status,
467
+ 'divergences': shape_divs,
468
+ 'slope_alerts': slope_alerts,
469
+ 'silence_categories': silence_cats,
470
+ 'method_used': method,
471
+ 'representation': 'shap' if use_shap else 'ale',
472
+ 'threshold_used': tau,
473
+ 'warnings': warnings_list,
474
+ 'model_collapsed': bool(deg1 or deg2),
475
+ '_failures': _falhas,
476
+ 'report_text': report_text,
477
+ }
modelcheck/backends.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ backends.py — Backends de importância para o detector de colapso
3
+
4
+ Motivação: o SHAP só é exato e rápido em árvores (TreeSHAP). Fora delas,
5
+ o fallback atual é permutação sobre a predição. Este módulo expõe quatro
6
+ backends com a MESMA interface, para que o `ImportanceCollapseDetector`
7
+ possa trocar de representação e para que a escolha seja decidida por
8
+ benchmark, não por preferência.
9
+
10
+ Backends implementados (todos métodos estabelecidos e verificáveis):
11
+
12
+ 'shap' TreeSHAP — média de |SHAP| por feature. Exato em árvores.
13
+ 'perm_pred' permutação medindo mudança na PREDIÇÃO (não usa y).
14
+ É o fallback atual.
15
+ 'perm_loss' permutação medindo aumento da PERDA (usa y). É a forma
16
+ clássica de permutation importance (Breiman) e a base
17
+ conceitual da importância global tipo SAGE.
18
+ 'pdp_range' amplitude da curva de dependência parcial. Barato, mas
19
+ assume independência entre features.
20
+ 'ice_var' variância entre curvas ICE individuais — capta
21
+ heterogeneidade de efeito, não só o efeito médio.
22
+
23
+ Nota sobre o PDP: ele assume independência e pode gerar perfis enganosos
24
+ com features correlacionadas. Está aqui para o benchmark, não como padrão.
25
+ """
26
+ import numpy as np
27
+ import pandas as pd
28
+ from typing import List, Optional
29
+
30
+ BACKENDS = ('shap', 'perm_pred', 'perm_loss', 'pdp_range', 'ice_var',
31
+ 'kernel_shap')
32
+
33
+
34
+ def _pred(model, X):
35
+ if hasattr(model, 'predict_proba'):
36
+ try:
37
+ p = model.predict_proba(X)
38
+ if p.ndim == 2 and p.shape[1] == 2:
39
+ return np.asarray(p[:, 1], dtype=float)
40
+ except Exception:
41
+ pass
42
+ return np.asarray(model.predict(X), dtype=float)
43
+
44
+
45
+ def imp_shap(model, X, feature_names, y=None, seed=0, **kw):
46
+ import shap
47
+ sv = shap.TreeExplainer(model).shap_values(X)
48
+ if isinstance(sv, list):
49
+ sv = sv[1] if len(sv) > 1 else sv[0]
50
+ sv = np.asarray(sv, dtype=float)
51
+ if sv.ndim == 3:
52
+ sv = sv[:, :, 1] if sv.shape[2] > 1 else sv[:, :, 0]
53
+ return np.abs(sv).mean(axis=0)
54
+
55
+
56
+ def imp_kernel_shap(model, X, feature_names, y=None, seed=0,
57
+ nsamples=100, n_background=50, **kw):
58
+ """
59
+ KernelSHAP — média de |SHAP| por feature, model-agnostic.
60
+
61
+ BACKEND OPCIONAL, não padrão. Medido (v1.51) em Ridge e MLP com 6
62
+ features, corrompendo uma:
63
+
64
+ detecção do colapso : idêntica a 'perm_pred' e ao ALE
65
+ divergências de forma: idêntica ao ALE (2 no Ridge, 0 no MLP)
66
+ tempo : 12-13x mais lento que 'perm_pred'
67
+ determinismo : sim (o shap fixa a semente interna)
68
+
69
+ Escalabilidade medida (Ridge, p de 6 a 50): a razão contra 'perm_pred'
70
+ CAI de 8x para 2x, ou seja o custo relativo melhora em alta
71
+ dimensionalidade — mas nunca compensa, porque a resposta é a mesma.
72
+
73
+ Use apenas se precisar de valores de Shapley propriamente ditos por
74
+ exigência externa (auditoria que pede SHAP nominalmente). Para detectar
75
+ colapso de importância, 'perm_pred' dá o mesmo resultado mais rápido.
76
+ """
77
+ import shap as _shap
78
+ bg = _shap.sample(X[feature_names], min(n_background, len(X)),
79
+ random_state=seed)
80
+ expl = _shap.KernelExplainer(model.predict, bg)
81
+ sv = expl.shap_values(X[feature_names], nsamples=nsamples, silent=True)
82
+ sv = np.asarray(sv, dtype=float)
83
+ if sv.ndim == 3:
84
+ sv = sv[:, :, 1] if sv.shape[2] > 1 else sv[:, :, 0]
85
+ return np.abs(sv).mean(axis=0)
86
+
87
+
88
+ def imp_perm_pred(model, X, feature_names, y=None, seed=0,
89
+ n_repeat=5, **kw):
90
+ rng = np.random.RandomState(seed)
91
+ base = _pred(model, X)
92
+ out = np.zeros(len(feature_names))
93
+ for j, f in enumerate(feature_names):
94
+ d = []
95
+ for _ in range(n_repeat):
96
+ Xp = X.copy()
97
+ Xp[f] = rng.permutation(Xp[f].values)
98
+ d.append(np.abs(_pred(model, Xp) - base).mean())
99
+ out[j] = float(np.mean(d))
100
+ return out
101
+
102
+
103
+ def imp_perm_loss(model, X, feature_names, y=None, seed=0,
104
+ n_repeat=5, **kw):
105
+ """Permutation importance clássica: aumento do erro ao embaralhar."""
106
+ if y is None:
107
+ return imp_perm_pred(model, X, feature_names, seed=seed,
108
+ n_repeat=n_repeat)
109
+ rng = np.random.RandomState(seed)
110
+ yv = np.asarray(y, dtype=float)
111
+ base = float(((_pred(model, X) - yv) ** 2).mean())
112
+ out = np.zeros(len(feature_names))
113
+ for j, f in enumerate(feature_names):
114
+ d = []
115
+ for _ in range(n_repeat):
116
+ Xp = X.copy()
117
+ Xp[f] = rng.permutation(Xp[f].values)
118
+ d.append(float(((_pred(model, Xp) - yv) ** 2).mean()) - base)
119
+ out[j] = max(float(np.mean(d)), 0.0)
120
+ return out
121
+
122
+
123
+ def imp_pdp_range(model, X, feature_names, y=None, seed=0,
124
+ n_grid=8, n_sample=150, **kw):
125
+ """Amplitude da curva PDP. Assume independência entre features."""
126
+ Xs = X.sample(min(n_sample, len(X)), random_state=seed)
127
+ out = np.zeros(len(feature_names))
128
+ for j, f in enumerate(feature_names):
129
+ xv = X[f].to_numpy(dtype=float)
130
+ if np.std(xv) < 1e-12:
131
+ continue
132
+ grid = np.unique(np.percentile(xv, np.linspace(5, 95, n_grid)))
133
+ means = []
134
+ for g in grid:
135
+ Xg = Xs.copy(); Xg[f] = g
136
+ means.append(_pred(model, Xg).mean())
137
+ out[j] = float(np.ptp(means)) if len(means) > 1 else 0.0
138
+ return out
139
+
140
+
141
+ def imp_ice_var(model, X, feature_names, y=None, seed=0,
142
+ n_grid=6, n_sample=60, **kw):
143
+ """
144
+ Variância entre curvas ICE. Captura heterogeneidade: features cujo
145
+ efeito difere entre instâncias pontuam alto mesmo com PDP plano.
146
+ """
147
+ Xs = X.sample(min(n_sample, len(X)), random_state=seed)
148
+ out = np.zeros(len(feature_names))
149
+ for j, f in enumerate(feature_names):
150
+ xv = X[f].to_numpy(dtype=float)
151
+ if np.std(xv) < 1e-12:
152
+ continue
153
+ grid = np.unique(np.percentile(xv, np.linspace(10, 90, n_grid)))
154
+ curves = []
155
+ for g in grid:
156
+ Xg = Xs.copy(); Xg[f] = g
157
+ curves.append(_pred(model, Xg))
158
+ if len(curves) < 2:
159
+ continue
160
+ C = np.vstack(curves) # (n_grid, n_amostras)
161
+ amp = C.max(axis=0) - C.min(axis=0) # amplitude por instância
162
+ out[j] = float(amp.mean())
163
+ return out
164
+
165
+
166
+ _FN = {'shap': imp_shap, 'perm_pred': imp_perm_pred,
167
+ 'perm_loss': imp_perm_loss, 'pdp_range': imp_pdp_range,
168
+ 'ice_var': imp_ice_var, 'kernel_shap': imp_kernel_shap}
169
+
170
+
171
+ def importance(model, X: pd.DataFrame, feature_names: List[str],
172
+ backend: str = 'shap', y=None, seed: int = 0,
173
+ **kw) -> Optional[np.ndarray]:
174
+ """
175
+ Importância por feature no backend escolhido. `None` se falhar.
176
+ Todos devolvem vetor não-negativo de tamanho len(feature_names).
177
+ """
178
+ if backend not in _FN:
179
+ raise ValueError(f"backend deve ser um de {BACKENDS}")
180
+ try:
181
+ v = _FN[backend](model, X, feature_names, y=y, seed=seed, **kw)
182
+ return np.asarray(v, dtype=float)
183
+ except Exception:
184
+ return None