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/__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
@@ -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}