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/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from importlib.metadata import version as _v
|
|
3
|
+
try:
|
|
4
|
+
__version__ = _v("shapeaudit")
|
|
5
|
+
except Exception: # instalado pelo nome anterior
|
|
6
|
+
__version__ = _v("modelguard-ml")
|
|
7
|
+
except Exception:
|
|
8
|
+
__version__ = "0.0.0+dev"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from .ale_detector import ALEShapeDetector, ale_shape_profile
|
|
12
|
+
|
|
13
|
+
from .audit import audit
|
|
14
|
+
from .geometry import GeometryDivergenceDetector
|
|
15
|
+
|
|
16
|
+
from .paired import PairedDivergenceAnalyzer
|
|
17
|
+
|
|
18
|
+
from .rhale import RHALEDetector, rhale_profile
|
|
19
|
+
from .zest import ArchitectureAgnosticComparator
|
|
20
|
+
|
|
21
|
+
from .trending import ShapeTrendingDetector
|
|
22
|
+
from .ood import OODBehavioralDetector
|
|
23
|
+
|
|
24
|
+
from .canary import GeometricCanaryDetector
|
|
25
|
+
from .explorer import InteractiveDivergenceExplorer
|
|
26
|
+
|
|
27
|
+
from .topology import TopologicalDriftDetector
|
|
28
|
+
from .stability import SupervisedStabilityAnalyzer
|
|
29
|
+
from .gtsf import GTSF
|
|
30
|
+
|
|
31
|
+
from .orchestrator import ModelGuardOrchestrator
|
|
32
|
+
|
|
33
|
+
from .importance import ImportanceCollapseDetector
|
|
34
|
+
|
|
35
|
+
from .confidence import score_with_ci, compare_scores_ci
|
|
36
|
+
|
|
37
|
+
from .baselines import run_all_baselines
|
|
38
|
+
|
|
39
|
+
from .shapedist import shape_cost, severity, profile_distance
|
|
40
|
+
|
|
41
|
+
from .ale_fast import ale_curve_fast, model_embedding_ale, feature_kind
|
|
42
|
+
|
|
43
|
+
from .universal import UniversalCanaryDetector, TopologicalDriftGudhi
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
from .ale_selective import SelectiveALEDetector
|
|
47
|
+
|
|
48
|
+
from .validation import TrainServingValidator
|
|
49
|
+
|
|
50
|
+
from .guard import ModelGuard
|
|
51
|
+
from .api import Guard, AuditResult
|
|
52
|
+
|
|
53
|
+
from .leakage_temporal import TemporalLeakageDetector
|
|
54
|
+
|
|
55
|
+
from .psi_shap import PSIShapDetector
|
|
56
|
+
|
|
57
|
+
from .backends import importance as feature_importance, BACKENDS
|
|
58
|
+
|
|
59
|
+
from modelcheck import snapshot # noqa: E402,F401
|
modelcheck/_validate.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
_validate.py — Validação de entrada compartilhada (v1.47)
|
|
3
|
+
|
|
4
|
+
BUG 17: sondando a API pública com entradas inválidas, vários detectores
|
|
5
|
+
devolviam resultado NORMAL em vez de erro:
|
|
6
|
+
- `features=[]` -> resultado como se tudo estivesse bem
|
|
7
|
+
- `X` vazio -> frechet_distance = nan, score 100
|
|
8
|
+
- `X` com 1 linha-> score 100
|
|
9
|
+
- `X` como numpy -> AttributeError cru
|
|
10
|
+
- modelo None -> AttributeError cru
|
|
11
|
+
|
|
12
|
+
Um resultado plausível para entrada inválida é a pior falha possível: o
|
|
13
|
+
usuário confia num número que não significa nada.
|
|
14
|
+
"""
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
MIN_ROWS = 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_inputs(model_v1, model_v2,
|
|
23
|
+
X, feature_names: Optional[List[str]] = None,
|
|
24
|
+
min_rows: int = MIN_ROWS,
|
|
25
|
+
caller: str = "detector"):
|
|
26
|
+
"""
|
|
27
|
+
Normaliza e valida. Devolve (X_df, feature_names).
|
|
28
|
+
Levanta ValueError/TypeError com instrução acionável.
|
|
29
|
+
"""
|
|
30
|
+
if model_v1 is None or model_v2 is None:
|
|
31
|
+
qual = "model_v1" if model_v1 is None else "model_v2"
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"{caller}: {qual} é None. Passe dois modelos treinados com "
|
|
34
|
+
f"método predict().")
|
|
35
|
+
for nome, m in (("model_v1", model_v1), ("model_v2", model_v2)):
|
|
36
|
+
if not hasattr(m, "predict"):
|
|
37
|
+
raise TypeError(
|
|
38
|
+
f"{caller}: {nome} ({type(m).__name__}) não tem método "
|
|
39
|
+
f"predict(). Passe um modelo treinado.")
|
|
40
|
+
|
|
41
|
+
if X is None:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"{caller}: X é None. Passe um DataFrame com as features de "
|
|
44
|
+
f"avaliação.")
|
|
45
|
+
if not isinstance(X, pd.DataFrame):
|
|
46
|
+
arr = np.asarray(X)
|
|
47
|
+
if arr.ndim != 2:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{caller}: X deve ser 2D, recebido ndim={arr.ndim}.")
|
|
50
|
+
X = pd.DataFrame(arr, columns=[f"f{i}" for i in range(arr.shape[1])])
|
|
51
|
+
|
|
52
|
+
if len(X) == 0:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"{caller}: X está vazio (0 linhas). Não é possível avaliar.")
|
|
55
|
+
if len(X) < min_rows:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"{caller}: X tem apenas {len(X)} linhas (mínimo {min_rows}). "
|
|
58
|
+
f"Resultados com n menor não são interpretáveis.")
|
|
59
|
+
|
|
60
|
+
if feature_names is None:
|
|
61
|
+
feature_names = list(X.columns)
|
|
62
|
+
if isinstance(feature_names, str):
|
|
63
|
+
feature_names = [feature_names]
|
|
64
|
+
feature_names = list(feature_names)
|
|
65
|
+
if not feature_names:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"{caller}: feature_names está vazio. Passe ao menos uma "
|
|
68
|
+
f"feature, ou None para usar todas as colunas de X.")
|
|
69
|
+
faltando = [f for f in feature_names if f not in X.columns]
|
|
70
|
+
if faltando:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"{caller}: features ausentes em X: {faltando[:5]}. "
|
|
73
|
+
f"Colunas disponíveis: {list(X.columns)[:8]}.")
|
|
74
|
+
return X, feature_names
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ══ Condições que invalidam ou enfraquecem a comparação (v1.71) ══════
|
|
78
|
+
#
|
|
79
|
+
# Encontradas por varredura sistemática depois do caso da imputação. A
|
|
80
|
+
# classe é sempre a mesma: a ferramenta produz um veredito num caso onde
|
|
81
|
+
# o veredito não significa o que o leitor supõe.
|
|
82
|
+
#
|
|
83
|
+
# recusa a comparação não é válida — nenhum número salva
|
|
84
|
+
# aviso é válida mas frágil; o resultado sai com a ressalva
|
|
85
|
+
|
|
86
|
+
N_MINIMO_CONFIAVEL = 60 # abaixo disto a classificação de forma oscila
|
|
87
|
+
N_MINIMO_CLASSE = 20 # abaixo disto nada se conclui sobre a classe
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def natureza_da_saida(model, X):
|
|
91
|
+
"""'classe', 'continuo' ou None se não for possível determinar."""
|
|
92
|
+
import numpy as np
|
|
93
|
+
try:
|
|
94
|
+
p = np.asarray(model.predict(X[:200]), dtype=float)
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
97
|
+
if p.ndim > 1:
|
|
98
|
+
p = p.ravel()
|
|
99
|
+
p = p[np.isfinite(p)]
|
|
100
|
+
if len(p) == 0:
|
|
101
|
+
return None
|
|
102
|
+
unicos = np.unique(p)
|
|
103
|
+
if len(unicos) <= max(2, len(p) * 0.02) and np.all(unicos == unicos.astype(int)):
|
|
104
|
+
return 'classe'
|
|
105
|
+
return 'continuo'
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def checar_comparabilidade(model_v1, model_v2, X, feature_names=None,
|
|
109
|
+
caller='audit', y_eval=None):
|
|
110
|
+
"""
|
|
111
|
+
Levanta ValueError quando a comparação não é válida; devolve a lista de
|
|
112
|
+
ressalvas quando é válida mas frágil.
|
|
113
|
+
"""
|
|
114
|
+
import numpy as np
|
|
115
|
+
ressalvas = []
|
|
116
|
+
|
|
117
|
+
# ── recusa: ordem de colunas diferente da vista no treino ────────
|
|
118
|
+
# BUG 32 (v1.73): o sklearn recusa `X[colunas_reordenadas]` com
|
|
119
|
+
# "Feature names must be in the same order as they were in fit". Alguma
|
|
120
|
+
# camada engolia esse erro e o veredito saía mesmo assim — apontando a
|
|
121
|
+
# variável ERRADA (corrompemos x0, o laudo dizia x3).
|
|
122
|
+
#
|
|
123
|
+
# Reordenar colunas é acidente comum: um `select` diferente, um join
|
|
124
|
+
# que muda a ordem, um CSV regravado.
|
|
125
|
+
for rotulo, m in (('v1', model_v1), ('v2', model_v2)):
|
|
126
|
+
esperado = getattr(m, 'feature_names_in_', None)
|
|
127
|
+
if esperado is None:
|
|
128
|
+
continue
|
|
129
|
+
recebido = list(X.columns)
|
|
130
|
+
esperado = [str(f) for f in esperado]
|
|
131
|
+
if recebido == esperado:
|
|
132
|
+
continue
|
|
133
|
+
if sorted(recebido) == sorted(esperado):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"{caller}: as colunas de X estão em ordem diferente da "
|
|
136
|
+
f"usada no treino do modelo {rotulo}. O modelo casa colunas "
|
|
137
|
+
f"por posição, então isto entrega valores trocados e o "
|
|
138
|
+
f"diagnóstico apontaria a variável errada. Reordene com "
|
|
139
|
+
f"X[list(model.feature_names_in_)].")
|
|
140
|
+
faltando = [f for f in esperado if f not in recebido]
|
|
141
|
+
sobrando = [f for f in recebido if f not in esperado]
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{caller}: as colunas de X não correspondem às do treino do "
|
|
144
|
+
f"modelo {rotulo}. Faltando: {faltando[:4] or '—'}. "
|
|
145
|
+
f"Sobrando: {sobrando[:4] or '—'}.")
|
|
146
|
+
|
|
147
|
+
# ── recusa: naturezas de saída diferentes ────────────────────────
|
|
148
|
+
n1, n2 = natureza_da_saida(model_v1, X), natureza_da_saida(model_v2, X)
|
|
149
|
+
if n1 and n2 and n1 != n2:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"{caller}: os dois modelos produzem saídas de naturezas "
|
|
152
|
+
f"diferentes ({n1} e {n2}). Um regressor e um classificador não "
|
|
153
|
+
f"são comparáveis — as atribuições estão em unidades distintas e "
|
|
154
|
+
f"qualquer veredito seria sobre a diferença de unidade, não sobre "
|
|
155
|
+
f"o modelo. Compare modelos do mesmo tipo.")
|
|
156
|
+
|
|
157
|
+
# ── aviso: conjunto de avaliação pequeno ─────────────────────────
|
|
158
|
+
if len(X) < N_MINIMO_CONFIAVEL:
|
|
159
|
+
ressalvas.append({
|
|
160
|
+
'layer': 'evaluation size',
|
|
161
|
+
'reason': f'apenas {len(X)} amostras de avaliação',
|
|
162
|
+
'consequence': (f'abaixo de {N_MINIMO_CONFIAVEL} a classificação '
|
|
163
|
+
f'de forma não é estável; o veredito pode mudar '
|
|
164
|
+
f'com outra amostra'),
|
|
165
|
+
'severity': 'alta',
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
# ── aviso: classe rara demais para sustentar conclusão ───────────
|
|
169
|
+
# v1.73: um classificador avaliado com 2 exemplos de uma das classes
|
|
170
|
+
# produzia APROVADO sem ressalva. Com 2 amostras nada pode ser
|
|
171
|
+
# concluído sobre o comportamento naquela classe — e o leitor de um
|
|
172
|
+
# APROVADO supõe que houve verificação.
|
|
173
|
+
if y_eval is not None:
|
|
174
|
+
try:
|
|
175
|
+
vc = pd.Series(y_eval).value_counts()
|
|
176
|
+
if 1 < len(vc) <= 20: # parece classificação
|
|
177
|
+
# o repr de um np.int64 polui a mensagem do usuário
|
|
178
|
+
_r = vc.index[-1]
|
|
179
|
+
rara = _r.item() if hasattr(_r, 'item') else _r
|
|
180
|
+
n_rara = int(vc.iloc[-1])
|
|
181
|
+
if n_rara < N_MINIMO_CLASSE:
|
|
182
|
+
ressalvas.append({
|
|
183
|
+
'layer': 'class representation',
|
|
184
|
+
'reason': (f'a classe {rara!r} tem apenas {n_rara} '
|
|
185
|
+
f'exemplos na avaliação'),
|
|
186
|
+
'consequence': ('nada pode ser concluído sobre o '
|
|
187
|
+
'comportamento do modelo nessa '
|
|
188
|
+
'classe'),
|
|
189
|
+
'severity': 'alta',
|
|
190
|
+
})
|
|
191
|
+
except Exception:
|
|
192
|
+
pass # noqa: lacuna-nao-aplicavel — heurística de aviso
|
|
193
|
+
|
|
194
|
+
# ── aviso: avaliação fora da faixa vista no treino ───────────────
|
|
195
|
+
fora = []
|
|
196
|
+
for m in (model_v1, model_v2):
|
|
197
|
+
faixa = getattr(m, '_modelcheck_train_range', None)
|
|
198
|
+
if faixa:
|
|
199
|
+
for col, (lo, hi) in faixa.items():
|
|
200
|
+
if col in X.columns:
|
|
201
|
+
v = X[col].to_numpy(dtype=float)
|
|
202
|
+
v = v[np.isfinite(v)]
|
|
203
|
+
if len(v) and (v.min() > hi or v.max() < lo):
|
|
204
|
+
fora.append(col)
|
|
205
|
+
if fora:
|
|
206
|
+
ressalvas.append({
|
|
207
|
+
'layer': 'evaluation range',
|
|
208
|
+
'reason': f'avaliação fora da faixa de treino em {sorted(set(fora))[:3]}',
|
|
209
|
+
'consequence': ('os dois modelos estão extrapolando; a "concordância" '
|
|
210
|
+
'entre eles não diz nada sobre o comportamento na '
|
|
211
|
+
'faixa de operação'),
|
|
212
|
+
'severity': 'alta',
|
|
213
|
+
})
|
|
214
|
+
return ressalvas
|
modelcheck/ale.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""ALE (Accumulated Local Effects) como alternativa ao SHAP no SDD."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ALEProfile:
|
|
7
|
+
"""Resultado de perfil ALE — mesma interface que SHAP values."""
|
|
8
|
+
def __init__(self, values):
|
|
9
|
+
self.values = values # shape (n, p) — compatível com ShapExplainer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ale_explain(model, X: pd.DataFrame, n_bins: int = 5) -> ALEProfile:
|
|
13
|
+
"""Calcula ALE 1D para todas as features numéricas.
|
|
14
|
+
|
|
15
|
+
ALE mede o efeito local médio da feature em cada bin,
|
|
16
|
+
acumulando para obter o efeito total — robusto a correlações.
|
|
17
|
+
Retorna matriz (n_rows, n_features) compatível com ShapeDivergenceDetector.
|
|
18
|
+
"""
|
|
19
|
+
n, p = X.shape
|
|
20
|
+
ale_matrix = np.zeros((n, p))
|
|
21
|
+
|
|
22
|
+
for j, col in enumerate(X.columns):
|
|
23
|
+
if not pd.api.types.is_numeric_dtype(X[col]):
|
|
24
|
+
continue
|
|
25
|
+
vals = X[col].to_numpy(dtype=float)
|
|
26
|
+
if np.std(vals) == 0:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
_, edges = pd.qcut(vals, n_bins, duplicates="drop", retbins=True)
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# efeito local em cada bin
|
|
34
|
+
bin_effects = []
|
|
35
|
+
bin_centers = []
|
|
36
|
+
for i in range(len(edges) - 1):
|
|
37
|
+
lo, hi = edges[i], edges[i + 1]
|
|
38
|
+
mask = (vals >= lo) & (vals <= hi)
|
|
39
|
+
if mask.sum() < 3:
|
|
40
|
+
bin_effects.append(0.0)
|
|
41
|
+
else:
|
|
42
|
+
X_lo = X.copy(); X_lo.iloc[:, j] = lo
|
|
43
|
+
X_hi = X.copy(); X_hi.iloc[:, j] = hi
|
|
44
|
+
eff = (model.predict(X_hi[mask]) -
|
|
45
|
+
model.predict(X_lo[mask])).mean()
|
|
46
|
+
bin_effects.append(float(eff))
|
|
47
|
+
bin_centers.append((lo + hi) / 2)
|
|
48
|
+
|
|
49
|
+
# acumular e centrar
|
|
50
|
+
cumulative = np.cumsum(bin_effects)
|
|
51
|
+
cumulative -= cumulative.mean()
|
|
52
|
+
|
|
53
|
+
# interpolar para cada linha
|
|
54
|
+
for idx in range(n):
|
|
55
|
+
v = vals[idx]
|
|
56
|
+
bin_idx = np.searchsorted(edges[1:-1], v)
|
|
57
|
+
bin_idx = int(np.clip(bin_idx, 0, len(cumulative) - 1))
|
|
58
|
+
ale_matrix[idx, j] = cumulative[bin_idx]
|
|
59
|
+
|
|
60
|
+
return ALEProfile(ale_matrix)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""ALE-based Shape Instability Detector.
|
|
2
|
+
|
|
3
|
+
Contribuição nova em relação ao SDD original (baseado em SHAP):
|
|
4
|
+
|
|
5
|
+
Problema com SHAP:
|
|
6
|
+
1. Depende de background dataset — a forma muda com background diferente
|
|
7
|
+
2. Features correlacionadas — SHAP distribui efeito entre features
|
|
8
|
+
3. KernelSHAP para MLP/SVM é lento (O(n*d)) e ruidoso com n pequeno
|
|
9
|
+
4. Modelos proprietários via API — não dá extrair pesos para SHAP
|
|
10
|
+
|
|
11
|
+
Solução ALE:
|
|
12
|
+
ALE (Accumulated Local Effects, Apley & Zhu 2020) computa o efeito
|
|
13
|
+
local de cada feature condicionando nos vizinhos — robusto a correlações.
|
|
14
|
+
Usa apenas model.predict() — funciona para qualquer arquitetura.
|
|
15
|
+
|
|
16
|
+
Algoritmo:
|
|
17
|
+
1. Para cada bin k da feature j:
|
|
18
|
+
a. Seleciona amostras dentro do bin
|
|
19
|
+
b. Prediz com feature no limite inferior e superior do bin
|
|
20
|
+
c. Delta médio = efeito local do bin
|
|
21
|
+
2. Acumula os deltas → perfil ALE cumulativo
|
|
22
|
+
3. Centraliza (subtrai a média)
|
|
23
|
+
4. Classifica a forma do perfil com _shape_profile_small_n
|
|
24
|
+
|
|
25
|
+
Resultado experimental (benchmark controlado, n=2000, 6 cenários):
|
|
26
|
+
SHAP: Recall=100%, Precision=67%, F1=0.80, FP=3
|
|
27
|
+
ALE: Recall=83%, Precision=45%, F1=0.59, FP=6
|
|
28
|
+
|
|
29
|
+
SHAP é superior para modelos de árvore onde é exato.
|
|
30
|
+
ALE é a ÚNICA opção para:
|
|
31
|
+
- MLP, SVM, redes neurais (KernelSHAP lento/ruidoso)
|
|
32
|
+
- Modelos via API (sem acesso aos pesos)
|
|
33
|
+
- LLMs via behavioral probing (LLMGuard)
|
|
34
|
+
|
|
35
|
+
Uso:
|
|
36
|
+
from modelcheck.ale_detector import ALEShapeDetector
|
|
37
|
+
|
|
38
|
+
detector = ALEShapeDetector(n_bins=6, n_boot=40, min_confidence=0.65)
|
|
39
|
+
results = detector.compare(model_v1, model_v2, X_eval, feature_names)
|
|
40
|
+
print(detector.report(results))
|
|
41
|
+
"""
|
|
42
|
+
import numpy as np
|
|
43
|
+
import pandas as pd
|
|
44
|
+
from typing import List, Optional, Union
|
|
45
|
+
from collections import Counter
|
|
46
|
+
|
|
47
|
+
from .shape import _shape_profile_small_n
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ale_shape_profile(model,
|
|
51
|
+
X: pd.DataFrame,
|
|
52
|
+
feature: str,
|
|
53
|
+
n_bins: int = 6,
|
|
54
|
+
min_bin_n: int = 5) -> Optional[dict]:
|
|
55
|
+
"""Calcula o perfil de forma ALE para uma feature.
|
|
56
|
+
|
|
57
|
+
Parâmetros:
|
|
58
|
+
model : modelo com interface predict(X) → array 1D
|
|
59
|
+
X : DataFrame de avaliação
|
|
60
|
+
feature : nome da feature
|
|
61
|
+
n_bins : número de bins quantílicos (padrão 6)
|
|
62
|
+
min_bin_n : mínimo de amostras por bin (padrão 5)
|
|
63
|
+
|
|
64
|
+
Retorna dict com:
|
|
65
|
+
shape : classificação ('crescente', 'decrescente', 'u',
|
|
66
|
+
'u_invertido', 'irregular')
|
|
67
|
+
confidence: confiança do classificador (0–1)
|
|
68
|
+
ale_cum : array do perfil ALE cumulativo
|
|
69
|
+
centers : centros dos bins
|
|
70
|
+
n_bins_ok : número de bins com amostras suficientes
|
|
71
|
+
|
|
72
|
+
Retorna None se não há amostras suficientes.
|
|
73
|
+
|
|
74
|
+
Notas técnicas:
|
|
75
|
+
O perfil ALE mede o efeito ACUMULADO da feature — análogo à
|
|
76
|
+
integral do efeito local. Isso torna a classificação de forma
|
|
77
|
+
diretamente comparável ao SHAP profile: crescente significa
|
|
78
|
+
que a feature tem efeito positivo crescente ao longo do seu range.
|
|
79
|
+
"""
|
|
80
|
+
X = X.copy()
|
|
81
|
+
x = np.asarray(X[feature].values, dtype=float)
|
|
82
|
+
|
|
83
|
+
if np.std(x) < 1e-8:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Bins quantílicos
|
|
87
|
+
bins = np.unique(np.percentile(x, np.linspace(0, 100, n_bins + 1)))
|
|
88
|
+
if len(bins) < 4:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
ale_means = []
|
|
92
|
+
centers = []
|
|
93
|
+
|
|
94
|
+
for i in range(len(bins) - 1):
|
|
95
|
+
mask = (x >= bins[i]) & (x <= bins[i + 1])
|
|
96
|
+
if mask.sum() < min_bin_n:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
Xlo = X[mask].copy()
|
|
100
|
+
Xhi = X[mask].copy()
|
|
101
|
+
Xlo[feature] = bins[i]
|
|
102
|
+
Xhi[feature] = bins[i + 1]
|
|
103
|
+
|
|
104
|
+
# Efeito local médio do bin
|
|
105
|
+
delta = model.predict(Xhi.values) - model.predict(Xlo.values)
|
|
106
|
+
ale_means.append(float(delta.mean()))
|
|
107
|
+
centers.append((bins[i] + bins[i + 1]) / 2)
|
|
108
|
+
|
|
109
|
+
if len(ale_means) < 3:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# Perfil cumulativo centralizado
|
|
113
|
+
ale_cum = np.cumsum(ale_means)
|
|
114
|
+
ale_cum -= ale_cum.mean()
|
|
115
|
+
|
|
116
|
+
# Classifica a forma usando _shape_profile_small_n
|
|
117
|
+
# (min_n=3 porque temos n_bins centros, não n amostras)
|
|
118
|
+
p = _shape_profile_small_n(
|
|
119
|
+
np.array(centers, dtype=float),
|
|
120
|
+
ale_cum,
|
|
121
|
+
min_n=3
|
|
122
|
+
)
|
|
123
|
+
if not p:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
'shape': p['shape'],
|
|
128
|
+
'confidence': p.get('confidence', 1.0),
|
|
129
|
+
'ale_cum': ale_cum.tolist(),
|
|
130
|
+
'centers': centers,
|
|
131
|
+
'n_bins_ok': len(ale_means),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ALEShapeDetector:
|
|
136
|
+
"""Detecta Shape Instability entre dois modelos usando ALE.
|
|
137
|
+
|
|
138
|
+
Funciona com qualquer model.predict() — RF, GB, MLP, SVM, API.
|
|
139
|
+
|
|
140
|
+
Parâmetros:
|
|
141
|
+
n_bins : bins quantílicos para o ALE (padrão 6)
|
|
142
|
+
min_bin_n : amostras mínimas por bin (padrão 5)
|
|
143
|
+
n_boot : iterações bootstrap para estabilidade (padrão 40)
|
|
144
|
+
min_confidence: confiança mínima para alertar (padrão 0.65)
|
|
145
|
+
Valor mais alto → menos FPs, mais FNs
|
|
146
|
+
|
|
147
|
+
Uso com modelos de árvore (RF, GB):
|
|
148
|
+
Quando TreeSHAP está disponível, prefira o SDD original.
|
|
149
|
+
Use ALEShapeDetector quando:
|
|
150
|
+
- O modelo não é árvore (MLP, SVM, NN)
|
|
151
|
+
- Não tem background dataset
|
|
152
|
+
- Quer resultado comparável entre arquiteturas diferentes
|
|
153
|
+
|
|
154
|
+
Uso com modelos via API:
|
|
155
|
+
def my_api_model(X):
|
|
156
|
+
# chama a API e retorna array de predições
|
|
157
|
+
return np.array([api_predict(row) for row in X])
|
|
158
|
+
|
|
159
|
+
detector = ALEShapeDetector()
|
|
160
|
+
results = detector.compare(my_api_model, my_api_model_v2,
|
|
161
|
+
X_eval, features)
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self,
|
|
165
|
+
n_bins: int = 6,
|
|
166
|
+
min_bin_n: int = 5,
|
|
167
|
+
n_boot: int = 40,
|
|
168
|
+
min_confidence: float = 0.65):
|
|
169
|
+
self.n_bins = n_bins
|
|
170
|
+
self.min_bin_n = min_bin_n
|
|
171
|
+
self.n_boot = n_boot
|
|
172
|
+
self.min_confidence = min_confidence
|
|
173
|
+
|
|
174
|
+
def _profile_bootstrap(self, model, X: pd.DataFrame,
|
|
175
|
+
feature: str) -> tuple:
|
|
176
|
+
"""Classifica a forma com bootstrap — retorna (shape, confidence)."""
|
|
177
|
+
shapes = []
|
|
178
|
+
n = len(X)
|
|
179
|
+
for seed in range(self.n_boot):
|
|
180
|
+
np.random.seed(seed)
|
|
181
|
+
idx = np.random.choice(n, n, replace=True)
|
|
182
|
+
Xb = X.iloc[idx].reset_index(drop=True)
|
|
183
|
+
p = ale_shape_profile(model, Xb, feature,
|
|
184
|
+
self.n_bins, self.min_bin_n)
|
|
185
|
+
if p:
|
|
186
|
+
shapes.append(p['shape'])
|
|
187
|
+
|
|
188
|
+
if not shapes:
|
|
189
|
+
return 'n/a', 0.0
|
|
190
|
+
|
|
191
|
+
sh, c = Counter(shapes).most_common(1)[0]
|
|
192
|
+
return sh, round(c / len(shapes), 2)
|
|
193
|
+
|
|
194
|
+
def compare(self,
|
|
195
|
+
model1,
|
|
196
|
+
model2,
|
|
197
|
+
X_eval: pd.DataFrame,
|
|
198
|
+
feature_names: Optional[List[str]] = None) -> list:
|
|
199
|
+
"""Compara formas ALE entre dois modelos.
|
|
200
|
+
|
|
201
|
+
Parâmetros:
|
|
202
|
+
model1, model2 : modelos com interface predict(array) → array
|
|
203
|
+
X_eval : DataFrame de avaliação
|
|
204
|
+
feature_names : lista de features (default: todas)
|
|
205
|
+
|
|
206
|
+
Retorna lista de dicts por feature:
|
|
207
|
+
feature : nome
|
|
208
|
+
shape1 : forma do modelo 1
|
|
209
|
+
shape2 : forma do modelo 2
|
|
210
|
+
conf1, conf2: confiança bootstrap de cada classificação
|
|
211
|
+
divergence : True se shapes divergem com confiança >= min_confidence
|
|
212
|
+
alert : descrição da divergência ou None
|
|
213
|
+
"""
|
|
214
|
+
if feature_names is None:
|
|
215
|
+
feature_names = list(X_eval.columns)
|
|
216
|
+
|
|
217
|
+
results = []
|
|
218
|
+
for f in feature_names:
|
|
219
|
+
sh1, c1 = self._profile_bootstrap(model1, X_eval, f)
|
|
220
|
+
sh2, c2 = self._profile_bootstrap(model2, X_eval, f)
|
|
221
|
+
|
|
222
|
+
div = (sh1 != sh2
|
|
223
|
+
and sh1 not in ('n/a', 'irregular')
|
|
224
|
+
and sh2 not in ('n/a', 'irregular')
|
|
225
|
+
and c1 >= self.min_confidence
|
|
226
|
+
and c2 >= self.min_confidence)
|
|
227
|
+
|
|
228
|
+
alert = (f"ALE_SHAPE_DIV: '{f}' {sh1}→{sh2} "
|
|
229
|
+
f"(conf: {c1:.0%}/{c2:.0%})") if div else None
|
|
230
|
+
|
|
231
|
+
results.append({
|
|
232
|
+
'feature': f,
|
|
233
|
+
'shape1': sh1, 'conf1': c1,
|
|
234
|
+
'shape2': sh2, 'conf2': c2,
|
|
235
|
+
'divergence': div,
|
|
236
|
+
'alert': alert,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
return results
|
|
240
|
+
|
|
241
|
+
def report(self, results: list) -> str:
|
|
242
|
+
"""Relatório textual dos resultados."""
|
|
243
|
+
divs = [r for r in results if r['divergence']]
|
|
244
|
+
n = len(results)
|
|
245
|
+
|
|
246
|
+
lines = [f"ALEShapeDetector (n_boot={self.n_boot}, "
|
|
247
|
+
f"min_conf={self.min_confidence:.0%}):"]
|
|
248
|
+
|
|
249
|
+
if not divs:
|
|
250
|
+
lines.append(f" ✅ Nenhuma divergência de forma detectada ({n} features)")
|
|
251
|
+
else:
|
|
252
|
+
lines.append(f" ⚠ {len(divs)}/{n} feature(s) com Shape Instability:")
|
|
253
|
+
for r in divs:
|
|
254
|
+
lines.append(f" '{r['feature']}': "
|
|
255
|
+
f"{r['shape1']} (conf={r['conf1']:.0%}) → "
|
|
256
|
+
f"{r['shape2']} (conf={r['conf2']:.0%})")
|
|
257
|
+
|
|
258
|
+
stable = [r for r in results
|
|
259
|
+
if not r['divergence'] and r['shape1'] != 'n/a']
|
|
260
|
+
if stable:
|
|
261
|
+
lines.append(f" ✅ {len(stable)} feature(s) estáveis: "
|
|
262
|
+
f"{[r['feature'] for r in stable]}")
|
|
263
|
+
|
|
264
|
+
na = [r for r in results if r['shape1'] == 'n/a']
|
|
265
|
+
if na:
|
|
266
|
+
lines.append(f" → {len(na)} feature(s) sem amostras suficientes "
|
|
267
|
+
f"(aumentar n_eval ou reduzir n_bins)")
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
def health_score(self, results: list) -> dict:
|
|
272
|
+
"""Health Score 0–100 baseado nas divergências ALE."""
|
|
273
|
+
n_div = sum(1 for r in results if r['divergence'])
|
|
274
|
+
score = max(0, 100 - n_div * 15)
|
|
275
|
+
status = ("HEALTHY" if score >= 85 else
|
|
276
|
+
"ATTENTION" if score >= 65 else "CRITICAL")
|
|
277
|
+
return {'score': score, 'status': status, 'n_divergences': n_div}
|