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.
- modelcheck/__init__.py +59 -0
- modelcheck/_validate.py +214 -0
- modelcheck/ale.py +60 -0
- modelcheck/ale_detector.py +277 -0
- modelcheck/ale_fast.py +167 -0
- modelcheck/ale_selective.py +157 -0
- modelcheck/api.py +835 -0
- modelcheck/audit.py +477 -0
- modelcheck/backends.py +184 -0
- modelcheck/baselines.py +213 -0
- modelcheck/calibration.py +221 -0
- modelcheck/canary.py +187 -0
- modelcheck/causal.py +233 -0
- modelcheck/causal_bootstrap.py +346 -0
- modelcheck/ci.py +41 -0
- modelcheck/cli.py +380 -0
- modelcheck/compare/__init__.py +0 -0
- modelcheck/compare/model_comparator.py +47 -0
- modelcheck/confidence.py +158 -0
- modelcheck/contradict.py +144 -0
- modelcheck/dashboard.py +102 -0
- modelcheck/drift.py +47 -0
- modelcheck/experimental/__init__.py +28 -0
- modelcheck/experimental/calibration.py +221 -0
- modelcheck/experimental/gtsf.py +183 -0
- modelcheck/experimental/llm.py +393 -0
- modelcheck/experimental/llm_audit.py +597 -0
- modelcheck/experimental/rhale.py +229 -0
- modelcheck/experimental/topology.py +247 -0
- modelcheck/experimental/vision.py +377 -0
- modelcheck/experimental/zest.py +155 -0
- modelcheck/explainers/__init__.py +0 -0
- modelcheck/explainers/shap_explainer.py +67 -0
- modelcheck/explorer.py +246 -0
- modelcheck/fairness.py +120 -0
- modelcheck/frechet.py +332 -0
- modelcheck/geometry.py +186 -0
- modelcheck/gtsf.py +183 -0
- modelcheck/guard.py +144 -0
- modelcheck/importance.py +389 -0
- modelcheck/integrations/__init__.py +0 -0
- modelcheck/integrations/mlflow_integration.py +40 -0
- modelcheck/laudo.py +882 -0
- modelcheck/leakage.py +43 -0
- modelcheck/leakage_temporal.py +201 -0
- modelcheck/narrate.py +36 -0
- modelcheck/ood.py +146 -0
- modelcheck/orchestrator.py +699 -0
- modelcheck/paired.py +229 -0
- modelcheck/pipeline.py +379 -0
- modelcheck/plots.py +53 -0
- modelcheck/psi_shap.py +196 -0
- modelcheck/ranking.py +19 -0
- modelcheck/report.py +72 -0
- modelcheck/rhale.py +229 -0
- modelcheck/rules/__init__.py +0 -0
- modelcheck/rules/business_rules.py +36 -0
- modelcheck/rules/rule_learner.py +154 -0
- modelcheck/score.py +64 -0
- modelcheck/shape.py +368 -0
- modelcheck/shape_extensions.py +479 -0
- modelcheck/shapedist.py +130 -0
- modelcheck/silence.py +247 -0
- modelcheck/slope.py +261 -0
- modelcheck/snapshot.py +429 -0
- modelcheck/stability.py +153 -0
- modelcheck/studio.py +124 -0
- modelcheck/temporal.py +74 -0
- modelcheck/temporal_shape.py +72 -0
- modelcheck/topology.py +247 -0
- modelcheck/trending.py +205 -0
- modelcheck/universal.py +278 -0
- modelcheck/validation.py +247 -0
- modelcheck/zest.py +155 -0
- modelguard/__init__.py +41 -0
- shapeaudit/__init__.py +39 -0
- shapeaudit-1.0.0.dist-info/LICENSE +21 -0
- shapeaudit-1.0.0.dist-info/METADATA +179 -0
- shapeaudit-1.0.0.dist-info/RECORD +82 -0
- shapeaudit-1.0.0.dist-info/WHEEL +5 -0
- shapeaudit-1.0.0.dist-info/entry_points.txt +4 -0
- 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
|