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/api.py
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api.py — API de alto nível do ModelGuard (classe Guard)
|
|
3
|
+
|
|
4
|
+
Fachada única para uso em produção. Esconde a escolha de detectores e
|
|
5
|
+
traduz o diagnóstico para linguagem de negócio.
|
|
6
|
+
|
|
7
|
+
from modelcheck import ModelGuard
|
|
8
|
+
|
|
9
|
+
g = Guard()
|
|
10
|
+
r = g.audit(model_v1, model_v2, X_eval, y_eval)
|
|
11
|
+
print(r.plain) # relatório em linguagem natural
|
|
12
|
+
print(r.verdict) # APROVADO | ATENCAO | BLOQUEADO
|
|
13
|
+
r.to_mlflow() # dict achatado para MLflow/W&B
|
|
14
|
+
r.to_slack() # payload de mensagem para Slack/Teams
|
|
15
|
+
"""
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SEV_ORD = {'APROVADO': 0, 'ATENCAO': 1, 'REVISAR': 2,
|
|
22
|
+
'BLOQUEADO': 3, 'INDETERMINADO': 2}
|
|
23
|
+
|
|
24
|
+
_VERDICT = [
|
|
25
|
+
(85, 'APROVADO', 'Pode seguir para produção.'),
|
|
26
|
+
(70, 'ATENCAO', 'Pode seguir com monitoramento reforçado.'),
|
|
27
|
+
(55, 'REVISAR', 'Revisar antes de promover.'),
|
|
28
|
+
(0, 'BLOQUEADO', 'Não promover sem investigação.'),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_PLAIN = {
|
|
32
|
+
'NONE': "Os dois modelos aprenderam as mesmas relações. Nada a fazer.",
|
|
33
|
+
'IMPORTANCE_COLLAPSE':
|
|
34
|
+
"Uma ou mais variáveis deixaram de influenciar o modelo novo. Esse "
|
|
35
|
+
"padrão é típico de falha no pipeline de dados (coluna virou nula, "
|
|
36
|
+
"join perdido, filtro novo) e não de retreinamento normal.",
|
|
37
|
+
'LOCAL_SHAPE':
|
|
38
|
+
"O modelo novo mudou a forma como usa algumas variáveis: onde antes "
|
|
39
|
+
"o efeito crescia, agora pode decrescer. As métricas gerais não "
|
|
40
|
+
"mostram isso, mas decisões individuais mudam.",
|
|
41
|
+
'MIXED':
|
|
42
|
+
"Houve mudança tanto no uso de variáveis específicas quanto na "
|
|
43
|
+
"estrutura geral de decisão. É o padrão mais preocupante: sugere que "
|
|
44
|
+
"os dados de treino mudaram de forma substancial.",
|
|
45
|
+
'GEOMETRIC':
|
|
46
|
+
"A estrutura geral de decisão mudou, mesmo que nenhuma variável "
|
|
47
|
+
"isolada tenha mudado de forma. Pode indicar deriva nos dados de "
|
|
48
|
+
"treino ou, em casos raros, contaminação.",
|
|
49
|
+
'TOPOLOGICAL':
|
|
50
|
+
"Surgiram (ou desapareceram) grupos de comportamento no modelo novo. "
|
|
51
|
+
"Vale checar se apareceu um segmento de dados inesperado.",
|
|
52
|
+
'OOD_ONLY':
|
|
53
|
+
"Os modelos concordam no dia a dia, mas divergem em cenários "
|
|
54
|
+
"extremos. Atenção se o negócio depende de eventos raros.",
|
|
55
|
+
'INTENSITY_ONLY':
|
|
56
|
+
"A direção dos efeitos se manteve, mas a intensidade mudou. "
|
|
57
|
+
"Limiares operacionais podem precisar de recalibração.",
|
|
58
|
+
'EXPECTED_ARCH_DIFF':
|
|
59
|
+
"As diferenças encontradas são as esperadas ao comparar arquiteturas "
|
|
60
|
+
"distintas. Não é anomalia.",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AuditResult:
|
|
65
|
+
"""Resultado de auditoria com saídas prontas para consumo."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, raw: dict):
|
|
68
|
+
self.raw = raw
|
|
69
|
+
# `score` mantido para compatibilidade, mas é sinal secundário.
|
|
70
|
+
# Ver LIMITATIONS.md §3 — não use para comparar cenários.
|
|
71
|
+
self.score = raw.get('global_score')
|
|
72
|
+
self.debug_score = self.score
|
|
73
|
+
self.problem_type = raw.get('interpretation', {}).get('problem_type', 'NONE')
|
|
74
|
+
self.mode = raw.get('mode', 'retraining')
|
|
75
|
+
# ── VEREDITO (v0.11.0): só sinais medidos; o índice saiu ─────
|
|
76
|
+
# O índice de saúde decidia os casos em que nenhuma camada
|
|
77
|
+
# disparava. Medido: acompanha a sobreposição das janelas de treino
|
|
78
|
+
# (rho=+0.34), não separa corrupção de retreino saudável dentro de
|
|
79
|
+
# cada janela (p=0.23-0.49) e tem AUC 0.53 contra degradação real.
|
|
80
|
+
# Continua calculado em `debug_score`, fora da decisão.
|
|
81
|
+
#
|
|
82
|
+
# Ordem por confiabilidade medida:
|
|
83
|
+
# integridade (variável saiu) -> BLOQUEADO
|
|
84
|
+
# treino/inferência (dados mudaram) -> ATENCAO
|
|
85
|
+
# clone compensou a perda -> ATENCAO
|
|
86
|
+
# forma mudou, mesmos dados -> REVISAR
|
|
87
|
+
# nenhum sinal -> APROVADO
|
|
88
|
+
ts = self.raw.get('train_serving') or {}
|
|
89
|
+
self.skew_features = list(ts.get('flagged') or [])
|
|
90
|
+
self.clone_redistribution = self._colapsos_por_clone(self._colapsos_brutos())
|
|
91
|
+
self.relearning = self._relearning(self._colapsos_brutos())
|
|
92
|
+
l1 = self.raw.get('layer1') or {}
|
|
93
|
+
self.shape_divergences = list((l1.get('sdd') or {}).get('divs') or [])
|
|
94
|
+
|
|
95
|
+
if not l1:
|
|
96
|
+
self.verdict, self.verdict_driver = 'INDETERMINADO', 'sem_medicao'
|
|
97
|
+
self.action = 'Não foi possível medir. Verifique compatibilidade dos modelos.'
|
|
98
|
+
elif self.root_cause_features:
|
|
99
|
+
self.verdict = 'BLOQUEADO'
|
|
100
|
+
self.verdict_driver = ('feature_integrity+train_serving'
|
|
101
|
+
if self.skew_features else 'feature_integrity')
|
|
102
|
+
self.action = ('Não promover. Uma ou mais variáveis deixaram de '
|
|
103
|
+
'influenciar o modelo — verifique o pipeline de '
|
|
104
|
+
'dados antes de qualquer retreino.')
|
|
105
|
+
elif self.raw.get('model_collapsed'):
|
|
106
|
+
self.verdict, self.verdict_driver = 'BLOQUEADO', 'model_collapse'
|
|
107
|
+
self.action = ('Não promover. O modelo passou a prever praticamente '
|
|
108
|
+
'o mesmo valor para todos os casos.')
|
|
109
|
+
elif [s for s in self.skew_features
|
|
110
|
+
if s not in self.clone_redistribution and s not in self.relearning]:
|
|
111
|
+
# v0.12.2: a coluna trocada por um clone (ou que o modelo deixou
|
|
112
|
+
# de usar) também aparece como mudança de dados. É a mesma
|
|
113
|
+
# observação, não duas — não conta duas vezes.
|
|
114
|
+
self.verdict, self.verdict_driver = 'ATENCAO', 'train_serving'
|
|
115
|
+
self.action = ('Os dados chegam diferentes do que o modelo viu no '
|
|
116
|
+
'treino. Retreine e revalide os limites '
|
|
117
|
+
'operacionais antes de promover.')
|
|
118
|
+
elif self.relearning:
|
|
119
|
+
self.verdict, self.verdict_driver = 'REVISAR', 'relearning'
|
|
120
|
+
self.action = ('O modelo novo deixou de usar uma variável que '
|
|
121
|
+
'continua chegando normalmente. Não é falha de '
|
|
122
|
+
'pipeline; confirme se a mudança é desejada.')
|
|
123
|
+
elif self.clone_redistribution:
|
|
124
|
+
self.verdict, self.verdict_driver = 'ATENCAO', 'clone_redistribution'
|
|
125
|
+
self.action = ('Uma variável perdeu peso, mas outra quase idêntica '
|
|
126
|
+
'assumiu a informação. Confirme se a mudança no '
|
|
127
|
+
'pipeline foi intencional.')
|
|
128
|
+
elif self.shape_divergences:
|
|
129
|
+
self.verdict, self.verdict_driver = 'REVISAR', 'shape'
|
|
130
|
+
self.action = ('Com os mesmos dados, o modelo novo reage de forma '
|
|
131
|
+
'diferente a uma ou mais variáveis. Revise antes de '
|
|
132
|
+
'promover.')
|
|
133
|
+
else:
|
|
134
|
+
self.verdict, self.verdict_driver = 'APROVADO', 'nenhum_sinal'
|
|
135
|
+
self.action = 'Nenhuma camada medida encontrou mudança.'
|
|
136
|
+
|
|
137
|
+
def _relearning(self, raiz=None) -> dict:
|
|
138
|
+
"""
|
|
139
|
+
Separa "a coluna sumiu" de "o modelo deixou de usar" (v0.12.0).
|
|
140
|
+
|
|
141
|
+
A camada de integridade via só o peso da atribuição. Medido: em
|
|
142
|
+
retreinos sazonais legítimos o modelo novo parou de usar uma
|
|
143
|
+
defasagem e o veredito foi BLOQUEADO — 1 em 5 retreinos saudáveis.
|
|
144
|
+
|
|
145
|
+
Se a variável continua variando normalmente nos dados de treino do
|
|
146
|
+
modelo novo, ela está lá e o modelo escolheu não usá-la: isso é
|
|
147
|
+
reaprendizado, não falha de pipeline. Se perdeu variação (virou
|
|
148
|
+
constante, ou quase), é falha de pipeline.
|
|
149
|
+
|
|
150
|
+
Devolve {variavel: motivo} apenas para os casos de reaprendizado.
|
|
151
|
+
Sem X_train não há como distinguir, e nada é rebaixado.
|
|
152
|
+
"""
|
|
153
|
+
Xt = self.raw.get('X_train_ref')
|
|
154
|
+
Xe = self.raw.get('X_eval_ref')
|
|
155
|
+
raiz = list(raiz or [])
|
|
156
|
+
if Xt is None or Xe is None or not raiz:
|
|
157
|
+
return {}
|
|
158
|
+
out = {}
|
|
159
|
+
try:
|
|
160
|
+
for f in raiz:
|
|
161
|
+
if f not in Xt.columns or f not in Xe.columns:
|
|
162
|
+
continue
|
|
163
|
+
vt, ve = float(Xt[f].std()), float(Xe[f].std())
|
|
164
|
+
if ve <= 1e-12:
|
|
165
|
+
continue
|
|
166
|
+
# "perdeu variação" = desvio no treino < 10% do desvio nos
|
|
167
|
+
# dados avaliados. Coluna constante dá 0.
|
|
168
|
+
if vt > 0.10 * ve:
|
|
169
|
+
out[f] = (f'a variável continua variando no treino '
|
|
170
|
+
f'(desvio {vt:.3g} vs {ve:.3g} na avaliação): '
|
|
171
|
+
f'o modelo novo deixou de usá-la')
|
|
172
|
+
except Exception:
|
|
173
|
+
return {} # noqa: lacuna-nao-aplicavel — heurística de aviso
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
def _colapsos_por_clone(self, raiz=None) -> dict:
|
|
177
|
+
"""
|
|
178
|
+
Para cada variável apontada como colapso, verifica se existe outra
|
|
179
|
+
quase idêntica (|r| >= 0.95) no conjunto de avaliação. Se existir,
|
|
180
|
+
o "colapso" pode ser só o modelo novo preferindo o clone — os dois
|
|
181
|
+
carregam a mesma informação.
|
|
182
|
+
|
|
183
|
+
Devolve {variavel_colapsada: clone}. Heurística conservadora: serve
|
|
184
|
+
para NÃO acusar falha de pipeline sem certeza, não para decidir.
|
|
185
|
+
"""
|
|
186
|
+
X = self.raw.get('X_eval_ref')
|
|
187
|
+
raiz = list(raiz or [])
|
|
188
|
+
if X is None or not raiz:
|
|
189
|
+
return {}
|
|
190
|
+
out = {}
|
|
191
|
+
try:
|
|
192
|
+
num = X.select_dtypes('number')
|
|
193
|
+
C = num.corr().abs()
|
|
194
|
+
for f in raiz:
|
|
195
|
+
if f not in C.columns:
|
|
196
|
+
continue
|
|
197
|
+
# BUG 34 (v0.9.1): o clone só serve de explicação se ele
|
|
198
|
+
# próprio NÃO colapsou. Com temp e atemp quebrados juntos, um
|
|
199
|
+
# desculpava o outro e a causa raiz ficava vazia.
|
|
200
|
+
viz = C[f].drop([f] + [g for g in raiz if g in C.columns
|
|
201
|
+
and g != f], errors='ignore')
|
|
202
|
+
if len(viz) and viz.max() >= 0.95:
|
|
203
|
+
out[f] = str(viz.idxmax())
|
|
204
|
+
except Exception:
|
|
205
|
+
return {} # noqa: lacuna-nao-aplicavel — heurística de aviso
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
def _sinais_de_imputacao(self) -> list:
|
|
209
|
+
"""
|
|
210
|
+
Colunas com marca de imputação: valor sentinela negativo extremo, ou
|
|
211
|
+
um único valor repetido em parte relevante da coluna.
|
|
212
|
+
|
|
213
|
+
Heurística deliberadamente conservadora — serve para elevar o peso de
|
|
214
|
+
um aviso, não para decidir nada.
|
|
215
|
+
"""
|
|
216
|
+
import numpy as np
|
|
217
|
+
X = self.raw.get('X_eval_ref')
|
|
218
|
+
if X is None:
|
|
219
|
+
return []
|
|
220
|
+
achados = []
|
|
221
|
+
try:
|
|
222
|
+
for col in list(getattr(X, 'columns', []))[:60]:
|
|
223
|
+
s = X[col]
|
|
224
|
+
if not np.issubdtype(getattr(s, 'dtype', None), np.number):
|
|
225
|
+
continue
|
|
226
|
+
v = s.to_numpy(dtype=float)
|
|
227
|
+
v = v[np.isfinite(v)]
|
|
228
|
+
# v1.72: o piso de 20 fazia a heurística SILENCIAR justo
|
|
229
|
+
# quando a amostra é pequena — ou seja, menos aviso quando
|
|
230
|
+
# o resultado é menos confiável. Medido: com n=15 a
|
|
231
|
+
# severidade caía de 'alta' para normal.
|
|
232
|
+
#
|
|
233
|
+
# Com poucos pontos exigimos proporção maior para compensar
|
|
234
|
+
# a variância, em vez de desligar a checagem.
|
|
235
|
+
if len(v) < 8:
|
|
236
|
+
continue
|
|
237
|
+
piso = 0.05 if len(v) >= 20 else 0.15
|
|
238
|
+
# sentinela: valor muito fora da faixa, repetido
|
|
239
|
+
for sent in (-999, -9999, -1, 0):
|
|
240
|
+
prop = float((v == sent).mean())
|
|
241
|
+
if prop >= piso and (sent < 0 or prop >= 0.30):
|
|
242
|
+
achados.append(str(col))
|
|
243
|
+
break
|
|
244
|
+
except Exception:
|
|
245
|
+
return [] # noqa: lacuna-nao-aplicavel — heurística de aviso
|
|
246
|
+
return achados
|
|
247
|
+
|
|
248
|
+
# ── o que NÃO foi avaliado ─────────────────────────────────────
|
|
249
|
+
@property
|
|
250
|
+
def not_evaluated(self) -> list:
|
|
251
|
+
"""
|
|
252
|
+
Camadas que não rodaram nesta auditoria, e por quê (v1.65).
|
|
253
|
+
|
|
254
|
+
Um relatório que omite isto parece completo enquanto mede menos do
|
|
255
|
+
que o leitor supõe. "Não avaliamos X" e "X está ok" são afirmações
|
|
256
|
+
diferentes, e só a primeira é verdadeira quando a camada não rodou.
|
|
257
|
+
|
|
258
|
+
Cada item traz `layer`, `reason` e `consequence` — o que fica sem
|
|
259
|
+
cobertura por causa da ausência.
|
|
260
|
+
"""
|
|
261
|
+
out = []
|
|
262
|
+
ts = self.raw.get('train_serving')
|
|
263
|
+
if ts is None:
|
|
264
|
+
# v1.71: quando há sinal de imputação nos dados (valor sentinela
|
|
265
|
+
# como -999, ou um único valor repetido em mais de 5% da coluna),
|
|
266
|
+
# a ausência de X_train custa caro: trocar a estratégia de
|
|
267
|
+
# imputação entre versões é rotina de pipeline, e sem esta camada
|
|
268
|
+
# passa como APROVADO.
|
|
269
|
+
#
|
|
270
|
+
# Medido: com nulos imputados por -999 numa versão e pela mediana
|
|
271
|
+
# na outra, o R² cai de 0.897 para 0.334 na avaliação cruzada, e
|
|
272
|
+
# o veredito sem X_train era APROVADO.
|
|
273
|
+
suspeitas = self._sinais_de_imputacao()
|
|
274
|
+
if suspeitas:
|
|
275
|
+
out.append({
|
|
276
|
+
'layer': 'train/serving consistency',
|
|
277
|
+
'reason': ('X_train não foi fornecido, e há sinal de '
|
|
278
|
+
f'imputação em {", ".join(suspeitas[:3])}'),
|
|
279
|
+
'consequence': ('uma troca de estratégia de imputação '
|
|
280
|
+
'entre as versões não seria detectada — '
|
|
281
|
+
'passe X_train para verificar'),
|
|
282
|
+
'severity': 'alta',
|
|
283
|
+
})
|
|
284
|
+
else:
|
|
285
|
+
out.append({
|
|
286
|
+
'layer': 'train/serving consistency',
|
|
287
|
+
'reason': ('não recebemos os dados de treino do modelo '
|
|
288
|
+
'novo (parâmetro X_train)'),
|
|
289
|
+
'consequence': ('não dá para saber se os dados que chegam '
|
|
290
|
+
'ao modelo mudaram em relação ao treino. '
|
|
291
|
+
'Passe `X_train=` para verificar'),
|
|
292
|
+
})
|
|
293
|
+
elif ts.get('error'):
|
|
294
|
+
out.append({
|
|
295
|
+
'layer': 'train/serving consistency',
|
|
296
|
+
'reason': ts['error'],
|
|
297
|
+
'consequence': ('esta verificação não rodou; o que ela cobre '
|
|
298
|
+
'ficou sem exame'),
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
# redistribuição entre clones (v0.9.1): não vira causa raiz, mas o
|
|
302
|
+
# leitor precisa saber que houve e por que foi descartada.
|
|
303
|
+
for f, clone in (getattr(self, 'clone_redistribution', {}) or {}).items():
|
|
304
|
+
out.append({
|
|
305
|
+
'layer': 'feature integrity',
|
|
306
|
+
'reason': (f'{f} perdeu peso, mas tem clone quase idêntico '
|
|
307
|
+
f'({clone}, |r|>=0.95)'),
|
|
308
|
+
'consequence': ('tratado como redistribuição, não como falha '
|
|
309
|
+
'de pipeline; confirme se a mudança foi '
|
|
310
|
+
'intencional'),
|
|
311
|
+
'severity': 'media',
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
# ressalvas de comparabilidade (v1.71)
|
|
315
|
+
for r in (self.raw.get('_ressalvas') or []):
|
|
316
|
+
out.append(dict(r))
|
|
317
|
+
|
|
318
|
+
# falhas registradas pela política de exceção (v1.66): toda camada
|
|
319
|
+
# que levantou exceção durante a auditoria vira lacuna declarada.
|
|
320
|
+
for camada in ('layer1', 'layer2', 'layer3'):
|
|
321
|
+
for falha in (self.raw.get(camada) or {}).get('_failures', []):
|
|
322
|
+
out.append(dict(falha))
|
|
323
|
+
|
|
324
|
+
l1 = self.raw.get('layer1', {}) or {}
|
|
325
|
+
m = str((l1.get('sdd') or {}).get('method', ''))
|
|
326
|
+
|
|
327
|
+
# v1.71: o método de atribuição entra no resultado porque auditorias
|
|
328
|
+
# feitas com métodos diferentes NÃO são comparáveis em escala
|
|
329
|
+
# (LIMITATIONS.md). Um histórico que mistura as duas produziria
|
|
330
|
+
# "mudanças" que são troca de instrumento, não de modelo.
|
|
331
|
+
if 'ALE' in m.upper() and 'SHAP falhou' in m:
|
|
332
|
+
out.append({
|
|
333
|
+
'layer': 'attribution method',
|
|
334
|
+
'reason': f'SHAP indisponível para este modelo; usou ALE',
|
|
335
|
+
'consequence': ('este modelo não aceita o método rápido de '
|
|
336
|
+
'atribuição, então usamos outro. Os números '
|
|
337
|
+
'não são comparáveis com auditorias anteriores '
|
|
338
|
+
'deste mesmo par'),
|
|
339
|
+
})
|
|
340
|
+
if 'ERROR' in m.upper():
|
|
341
|
+
out.append({
|
|
342
|
+
'layer': 'shape divergence',
|
|
343
|
+
'reason': m,
|
|
344
|
+
'consequence': ('não foi possível comparar as curvas: uma '
|
|
345
|
+
'mudança de comportamento passaria sem ser '
|
|
346
|
+
'vista'),
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
# fairness nunca roda aqui — decisão documentada em fairness.py
|
|
350
|
+
out.append({
|
|
351
|
+
'layer': 'fairness',
|
|
352
|
+
'reason': ('não verificamos se o erro do modelo mudou para algum '
|
|
353
|
+
'grupo de pessoas'),
|
|
354
|
+
'consequence': ('duas versões podem errar igual no total e errar '
|
|
355
|
+
'mais para um grupo específico. Se o seu modelo '
|
|
356
|
+
'afeta pessoas, meça isso à parte com '
|
|
357
|
+
'`fairness_evolucao`'),
|
|
358
|
+
})
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
# ── contagens ──────────────────────────────────────────────────
|
|
362
|
+
@property
|
|
363
|
+
def counts(self) -> dict:
|
|
364
|
+
i = self.raw.get('interpretation', {})
|
|
365
|
+
return {k: i.get(k, 0) for k in
|
|
366
|
+
('n_shape_divs', 'n_slope', 'n_hetero',
|
|
367
|
+
'n_canary', 'n_topo', 'n_ood')}
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def affected_features(self) -> List[str]:
|
|
371
|
+
"""
|
|
372
|
+
Features afetadas, ordenadas por PROBABILIDADE DE SER A CAUSA RAIZ.
|
|
373
|
+
|
|
374
|
+
Achado de UX (v1.49): num caso com apenas `alcohol` corrompida, a
|
|
375
|
+
lista saía como ['residual sugar', 'chlorides', 'pH', 'alcohol'] —
|
|
376
|
+
a causa raiz por ÚLTIMO. As outras três eram divergências de forma
|
|
377
|
+
reais (o modelo redistribui peso), mas são CONSEQUÊNCIA. O usuário
|
|
378
|
+
investigaria quatro features na ordem errada.
|
|
379
|
+
|
|
380
|
+
Ordem agora:
|
|
381
|
+
1. colapso de importância — a feature saiu do modelo (causa raiz)
|
|
382
|
+
2. divergência de forma — pode ser consequência da redistribuição
|
|
383
|
+
"""
|
|
384
|
+
colapsos, formas = [], []
|
|
385
|
+
l1 = self.raw.get('layer1', {})
|
|
386
|
+
imp = l1.get('importance', {}) or {}
|
|
387
|
+
for r in imp.get('features', []):
|
|
388
|
+
if r.get('alert') == 'IMPORTANCE_COLLAPSE':
|
|
389
|
+
colapsos.append(r['feature'])
|
|
390
|
+
for d in l1.get('sdd', {}).get('divs', []):
|
|
391
|
+
if d['feature'] not in colapsos:
|
|
392
|
+
formas.append(d['feature'])
|
|
393
|
+
seen, uniq = set(), []
|
|
394
|
+
for f in colapsos + formas:
|
|
395
|
+
if f not in seen:
|
|
396
|
+
seen.add(f); uniq.append(f)
|
|
397
|
+
return uniq
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def root_cause_features(self) -> List[str]:
|
|
401
|
+
"""
|
|
402
|
+
Colapsos de importância — as prováveis causas raiz — excluindo os
|
|
403
|
+
que são redistribuição para um clone quase idêntico (v0.9.1).
|
|
404
|
+
"""
|
|
405
|
+
brutos = self._colapsos_brutos()
|
|
406
|
+
clones = self._colapsos_por_clone(brutos)
|
|
407
|
+
rele = self._relearning(brutos)
|
|
408
|
+
return [f for f in brutos if f not in clones and f not in rele]
|
|
409
|
+
|
|
410
|
+
def to_html(self, lang: str = "pt", decisao: Optional[Dict] = None) -> str:
|
|
411
|
+
"""
|
|
412
|
+
Laudo HTML completo, pronto para abrir no navegador (v0.14.0).
|
|
413
|
+
|
|
414
|
+
`decisao` é opcional: passe {'trocados': n, 'selecionados': m, 'pct': p}
|
|
415
|
+
quando você tiver medido o impacto na sua regra de decisão. Sem isso o
|
|
416
|
+
laudo declara "não avaliado" em vez de omitir a pergunta.
|
|
417
|
+
"""
|
|
418
|
+
from modelcheck.laudo import render
|
|
419
|
+
return render({
|
|
420
|
+
"verdict": self.verdict,
|
|
421
|
+
"problem_type": getattr(self, "problem_type", "") or "",
|
|
422
|
+
"root_cause_features": self.root_cause_features,
|
|
423
|
+
"divergences": self.shape_divergences,
|
|
424
|
+
"collapses": [],
|
|
425
|
+
"model_diff": self.model_diff,
|
|
426
|
+
"skew_features": self.skew_features,
|
|
427
|
+
"clone_redistribution": self.clone_redistribution,
|
|
428
|
+
"relearning": list(self.relearning),
|
|
429
|
+
"decisao": decisao or {},
|
|
430
|
+
"n_eval": len(self.raw.get("X_eval_ref")) if self.raw.get("X_eval_ref") is not None else "—",
|
|
431
|
+
"representation": (self.raw.get("layer1", {}) or {}).get("sdd", {}).get("method", "shap"),
|
|
432
|
+
"not_evaluated": self.not_evaluated,
|
|
433
|
+
}, lang=lang)
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def model_diff(self) -> List[Dict[str, Any]]:
|
|
437
|
+
"""
|
|
438
|
+
Diff de comportamento entre as duas versões, por variável (v0.14.0).
|
|
439
|
+
|
|
440
|
+
Junta duas perguntas que costumam ser feitas separadas:
|
|
441
|
+
"o modelo continua usando os mesmos sinais?" (peso) e "continua
|
|
442
|
+
usando do mesmo jeito?" (forma). A categoria interessante é
|
|
443
|
+
`peso_estavel_forma_mudou`: olhando só importância, ninguém veria.
|
|
444
|
+
"""
|
|
445
|
+
infl = (self.raw.get('layer1') or {}).get('influencia') or []
|
|
446
|
+
formas = {d['feature']: d for d in self.shape_divergences}
|
|
447
|
+
perdidas = set(self.root_cause_features)
|
|
448
|
+
out = []
|
|
449
|
+
for r in infl:
|
|
450
|
+
f = r['feature']
|
|
451
|
+
mudou_forma = f in formas
|
|
452
|
+
d = abs(r['delta_pp'])
|
|
453
|
+
if f in perdidas:
|
|
454
|
+
cat = 'saiu_do_modelo'
|
|
455
|
+
elif r['delta_pp'] >= 5:
|
|
456
|
+
cat = 'ganhou_influencia'
|
|
457
|
+
elif r['delta_pp'] <= -5:
|
|
458
|
+
cat = 'perdeu_influencia'
|
|
459
|
+
elif mudou_forma:
|
|
460
|
+
cat = 'peso_estavel_forma_mudou'
|
|
461
|
+
else:
|
|
462
|
+
cat = 'estavel'
|
|
463
|
+
out.append(dict(r, categoria=cat, forma_mudou=mudou_forma,
|
|
464
|
+
shape_v1=formas.get(f, {}).get('shape_v1'),
|
|
465
|
+
shape_v2=formas.get(f, {}).get('shape_v2'),
|
|
466
|
+
frechet=formas.get(f, {}).get('frechet')))
|
|
467
|
+
return out
|
|
468
|
+
|
|
469
|
+
def _colapsos_brutos(self) -> List[str]:
|
|
470
|
+
imp = (self.raw.get('layer1', {}) or {}).get('importance', {}) or {}
|
|
471
|
+
return [r['feature'] for r in imp.get('features', [])
|
|
472
|
+
if r.get('alert') == 'IMPORTANCE_COLLAPSE']
|
|
473
|
+
|
|
474
|
+
# ── relatório em linguagem natural ─────────────────────────────
|
|
475
|
+
@property
|
|
476
|
+
def plain(self) -> str:
|
|
477
|
+
"""
|
|
478
|
+
Relatório em linguagem de negócio.
|
|
479
|
+
|
|
480
|
+
IDIOMA (v1.74): este relatório existe apenas em português. Quando
|
|
481
|
+
MODELCHECK_LANG (ou LANG) indica inglês, o texto vem com um aviso
|
|
482
|
+
no topo — preferimos declarar a lacuna a entregar uma tradução
|
|
483
|
+
parcial que misturaria os dois idiomas.
|
|
484
|
+
|
|
485
|
+
Para relatório em inglês use `modelcheck.laudo.render(res, "en")`
|
|
486
|
+
ou a CLI com `--format html`, que são bilíngues.
|
|
487
|
+
|
|
488
|
+
O ÍNDICE DE SAÚDE NÃO APARECE POR PADRÃO (v1.47). Cinco medições
|
|
489
|
+
independentes mostraram que é o sinal mais fraco do framework:
|
|
490
|
+
|
|
491
|
+
- dominado pela sobreposição das janelas de treino (rho=+0.54)
|
|
492
|
+
- não discrimina dentro de cada estrato (p=0.31 a 0.53)
|
|
493
|
+
- INVERTEU a ordem num caso medido: retreino legítimo 43
|
|
494
|
+
(BLOQUEADO) contra pipeline quebrado 57 (REVISAR)
|
|
495
|
+
- satura perto de zero
|
|
496
|
+
- calibração contra degradação real observada deu AUC 0.533,
|
|
497
|
+
indistinguível de aleatório
|
|
498
|
+
|
|
499
|
+
O que discrimina é o par (problem_type, affected_features), que é o
|
|
500
|
+
que este relatório mostra. Para depuração use `plain_debug()`.
|
|
501
|
+
"""
|
|
502
|
+
feats = self.affected_features
|
|
503
|
+
# O aviso só aparece quando o idioma foi PEDIDO em inglês —
|
|
504
|
+
# explicitamente ou por MODELCHECK_LANG/LANG. Sem nenhum sinal, o
|
|
505
|
+
# padrão deste relatório é português, e avisar seria ruído.
|
|
506
|
+
import os as _os
|
|
507
|
+
_pediu_en = any(
|
|
508
|
+
(_os.environ.get(v) or "").lower().startswith("en")
|
|
509
|
+
for v in ("MODELCHECK_LANG", "LC_ALL", "LANG"))
|
|
510
|
+
L = []
|
|
511
|
+
if _pediu_en:
|
|
512
|
+
L += ["[This text report is only available in Portuguese. For "
|
|
513
|
+
"English use modelcheck.laudo.render(result, 'en') or the "
|
|
514
|
+
"CLI with --format html.]", ""]
|
|
515
|
+
L += [
|
|
516
|
+
f"Resultado: {self.verdict}",
|
|
517
|
+
"",
|
|
518
|
+
_PLAIN.get(self.problem_type, _PLAIN['NONE']),
|
|
519
|
+
]
|
|
520
|
+
raiz = self.root_cause_features
|
|
521
|
+
if raiz:
|
|
522
|
+
nomes = ", ".join(f"'{f}'" for f in raiz[:3])
|
|
523
|
+
_frase = ("estas variáveis deixaram" if len(raiz) > 1
|
|
524
|
+
else "esta variável deixou")
|
|
525
|
+
L += ["", f"Comece por: {nomes} — {_frase} de influenciar "
|
|
526
|
+
f"o modelo."]
|
|
527
|
+
outras = [f for f in feats if f not in raiz]
|
|
528
|
+
if outras:
|
|
529
|
+
on = ", ".join(f"'{f}'" for f in outras[:3])
|
|
530
|
+
ex = f" e outras {len(outras)-3}" if len(outras) > 3 else ""
|
|
531
|
+
L += [f"Também mudaram, possivelmente como consequência: "
|
|
532
|
+
f"{on}{ex}."]
|
|
533
|
+
elif feats:
|
|
534
|
+
nomes = ", ".join(f"'{f}'" for f in feats[:4])
|
|
535
|
+
extra = f" e outras {len(feats)-4}" if len(feats) > 4 else ""
|
|
536
|
+
L += ["", f"Variáveis envolvidas: {nomes}{extra}."]
|
|
537
|
+
c = self.counts
|
|
538
|
+
detalhes = []
|
|
539
|
+
if c['n_shape_divs']: detalhes.append(f"{c['n_shape_divs']} variável(is) mudaram de padrão")
|
|
540
|
+
if c['n_slope']: detalhes.append(f"{c['n_slope']} mudaram de intensidade")
|
|
541
|
+
if c['n_canary']: detalhes.append("a estrutura geral de decisão mudou")
|
|
542
|
+
if c['n_ood']: detalhes.append("há divergência em cenários extremos")
|
|
543
|
+
if detalhes:
|
|
544
|
+
L += ["", "Em números: " + "; ".join(detalhes) + "."]
|
|
545
|
+
L += ["", f"Recomendação: {self.action}"]
|
|
546
|
+
|
|
547
|
+
ne = self.not_evaluated
|
|
548
|
+
if ne:
|
|
549
|
+
L += ["", "Não verificado nesta auditoria:"]
|
|
550
|
+
for item in ne:
|
|
551
|
+
L.append(f" · {item['layer']} — {item['reason']}")
|
|
552
|
+
L.append(" (o veredito acima não cobre estes pontos)")
|
|
553
|
+
if self.mode == 'exploratory':
|
|
554
|
+
L += ["", "(Modo comparação de arquiteturas: diferenças de padrão "
|
|
555
|
+
"são esperadas e não contam como falha.)"]
|
|
556
|
+
return "\n".join(L)
|
|
557
|
+
|
|
558
|
+
def plain_debug(self) -> str:
|
|
559
|
+
"""
|
|
560
|
+
Igual a `plain`, mais o índice de saúde e os scores por camada.
|
|
561
|
+
|
|
562
|
+
Apenas para depuração. O índice de saúde NÃO deve ser usado para
|
|
563
|
+
comparar cenários — ver LIMITATIONS.md §3 (rho=+0.54 com sobreposição
|
|
564
|
+
de janelas; inverteu a ordem num caso medido; AUC 0.533 na calibração).
|
|
565
|
+
"""
|
|
566
|
+
sc = ("não mensurável" if self.score is None
|
|
567
|
+
else f"{self.score:.0f} de 100")
|
|
568
|
+
L = [self.plain, "",
|
|
569
|
+
f"[depuração] índice de saúde: {sc}",
|
|
570
|
+
"[depuração] não compare este número entre cenários "
|
|
571
|
+
"(ver LIMITATIONS.md §3)"]
|
|
572
|
+
for k, nome in (('layer1', 'forma local'), ('layer2', 'geométrica'),
|
|
573
|
+
('layer3', 'extremos')):
|
|
574
|
+
v = (self.raw.get(k) or {}).get('layer_score')
|
|
575
|
+
if v is not None:
|
|
576
|
+
L.append(f"[depuração] camada {nome}: {v:.0f}")
|
|
577
|
+
return "\n".join(L)
|
|
578
|
+
|
|
579
|
+
# ── integrações ────────────────────────────────────────────────
|
|
580
|
+
def to_mlflow(self) -> dict:
|
|
581
|
+
"""Métricas achatadas para mlflow.log_metrics / wandb.log."""
|
|
582
|
+
c = self.counts
|
|
583
|
+
m = {
|
|
584
|
+
'modelguard.health_score': float(self.score) if self.score is not None else float('nan'),
|
|
585
|
+
'modelguard.shape_divergences': float(c['n_shape_divs']),
|
|
586
|
+
'modelguard.slope_alerts': float(c['n_slope']),
|
|
587
|
+
'modelguard.canary_alerts': float(c['n_canary']),
|
|
588
|
+
'modelguard.topology_alerts': float(c['n_topo']),
|
|
589
|
+
'modelguard.ood_alerts': float(c['n_ood']),
|
|
590
|
+
}
|
|
591
|
+
for k in ('layer1', 'layer2', 'layer3'):
|
|
592
|
+
v = self.raw.get(k, {}).get('layer_score')
|
|
593
|
+
m[f'modelguard.{k}_score'] = float(v) if v is not None else float('nan')
|
|
594
|
+
return m
|
|
595
|
+
|
|
596
|
+
def to_tags(self) -> dict:
|
|
597
|
+
"""Tags textuais para MLflow/W&B."""
|
|
598
|
+
return {'modelguard.verdict': self.verdict,
|
|
599
|
+
'modelguard.problem_type': self.problem_type,
|
|
600
|
+
'modelguard.mode': self.mode,
|
|
601
|
+
'modelguard.affected_features': ",".join(self.affected_features[:8])}
|
|
602
|
+
|
|
603
|
+
def to_slack(self, model_name: str = "modelo") -> dict:
|
|
604
|
+
"""Payload de mensagem para Slack (blocks) ou Teams (text)."""
|
|
605
|
+
emoji = {'APROVADO': ':white_check_mark:', 'ATENCAO': ':warning:',
|
|
606
|
+
'REVISAR': ':mag:', 'BLOQUEADO': ':no_entry:',
|
|
607
|
+
'INDETERMINADO': ':question:'}.get(self.verdict, ':warning:')
|
|
608
|
+
head = f"{emoji} ModelGuard — {model_name}: *{self.verdict}*"
|
|
609
|
+
body = _PLAIN.get(self.problem_type, _PLAIN['NONE'])
|
|
610
|
+
feats = self.affected_features
|
|
611
|
+
if feats:
|
|
612
|
+
body += f"\nVariáveis: {', '.join(feats[:4])}"
|
|
613
|
+
return {
|
|
614
|
+
'text': f"{head}\n{body}\n{self.action}",
|
|
615
|
+
'blocks': [
|
|
616
|
+
{'type': 'section',
|
|
617
|
+
'text': {'type': 'mrkdwn', 'text': head}},
|
|
618
|
+
{'type': 'section',
|
|
619
|
+
'text': {'type': 'mrkdwn', 'text': body}},
|
|
620
|
+
{'type': 'context',
|
|
621
|
+
'elements': [{'type': 'mrkdwn', 'text': self.action}]},
|
|
622
|
+
],
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
@property
|
|
626
|
+
def summary(self) -> dict:
|
|
627
|
+
"""Saída primária: veredito, tipo do problema e features afetadas."""
|
|
628
|
+
return {
|
|
629
|
+
'verdict': self.verdict,
|
|
630
|
+
'problem_type': self.problem_type,
|
|
631
|
+
'affected_features': self.affected_features,
|
|
632
|
+
'action': self.action,
|
|
633
|
+
'mode': self.mode,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
def __repr__(self):
|
|
637
|
+
feats = self.affected_features[:3]
|
|
638
|
+
return (f"<AuditResult {self.verdict} type={self.problem_type} "
|
|
639
|
+
f"features={feats or '—'}>")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class Guard:
|
|
643
|
+
"""
|
|
644
|
+
API de alto nível.
|
|
645
|
+
|
|
646
|
+
g = Guard()
|
|
647
|
+
r = g.audit(v1, v2, X_eval, y_eval)
|
|
648
|
+
print(r.plain)
|
|
649
|
+
|
|
650
|
+
Parâmetros do construtor:
|
|
651
|
+
mode 'retraining' (default) ou 'exploratory'
|
|
652
|
+
threshold limiar de divergência de forma (default 0.30)
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
def __init__(self, mode: str = "retraining", threshold: float = 0.30):
|
|
656
|
+
self.mode = mode
|
|
657
|
+
self.threshold = threshold
|
|
658
|
+
|
|
659
|
+
def check_features(self, model_v1, model_v2,
|
|
660
|
+
X_eval: pd.DataFrame,
|
|
661
|
+
monitored: List[str],
|
|
662
|
+
feature_names: Optional[List[str]] = None) -> dict:
|
|
663
|
+
"""
|
|
664
|
+
Verificação de integridade de features específicas.
|
|
665
|
+
|
|
666
|
+
Modo recomendado para pipelines com retreino temporal, onde o
|
|
667
|
+
Health Score global é dominado pela sobreposição das janelas de
|
|
668
|
+
treino (ρ=+0.54) e perde poder discriminativo.
|
|
669
|
+
|
|
670
|
+
Informe em `monitored` as features cuja integridade é crítica.
|
|
671
|
+
Medido em 108 pares reais: recall 97%, especificidade 100% em
|
|
672
|
+
todos os níveis de sobreposição (0%, 51%, 84%, 100%).
|
|
673
|
+
|
|
674
|
+
r = g.check_features(v1, v2, X, monitored=['ENA_MLT','EAR'])
|
|
675
|
+
if r['alert']: print(r['plain'])
|
|
676
|
+
"""
|
|
677
|
+
from modelcheck.importance import ImportanceCollapseDetector
|
|
678
|
+
if isinstance(monitored, str):
|
|
679
|
+
monitored = [monitored] # aceita string única sem virar lista de chars
|
|
680
|
+
if not monitored:
|
|
681
|
+
raise ValueError(
|
|
682
|
+
"check_features: `monitored` está vazio. Informe as features "
|
|
683
|
+
"cuja integridade é crítica, ex: monitored=['ENA_MLT'].")
|
|
684
|
+
icd = ImportanceCollapseDetector()
|
|
685
|
+
res = icd.compare(model_v1, model_v2, X_eval,
|
|
686
|
+
feature_names, monitored=monitored)
|
|
687
|
+
hits = res['monitored_hits']
|
|
688
|
+
if hits:
|
|
689
|
+
det = ", ".join(f"'{h}'" for h in hits)
|
|
690
|
+
plain = (f"Falha de integridade: {det} deixou de influenciar o "
|
|
691
|
+
f"modelo novo. Verifique o pipeline de dados dessas "
|
|
692
|
+
f"variáveis (coluna nula, join perdido, filtro novo).")
|
|
693
|
+
else:
|
|
694
|
+
plain = ("As variáveis monitoradas continuam influenciando o "
|
|
695
|
+
"modelo. Nenhuma falha de integridade detectada.")
|
|
696
|
+
return {'alert': res['monitored_alert'], 'collapsed': hits,
|
|
697
|
+
'monitored': monitored, 'detail': res, 'plain': plain}
|
|
698
|
+
|
|
699
|
+
def audit(self, model_v1, model_v2,
|
|
700
|
+
X_eval: pd.DataFrame,
|
|
701
|
+
y_eval=None,
|
|
702
|
+
feature_names: Optional[List[str]] = None,
|
|
703
|
+
label_v1: str = "versão anterior",
|
|
704
|
+
label_v2: str = "versão nova",
|
|
705
|
+
mode: Optional[str] = None,
|
|
706
|
+
X_train: Optional[pd.DataFrame] = None) -> AuditResult:
|
|
707
|
+
"""
|
|
708
|
+
Audita duas versões de modelo.
|
|
709
|
+
|
|
710
|
+
X_train : conjunto de treino do modelo novo. Quando fornecido, a
|
|
711
|
+
camada de consistência treino/inferência participa do veredito.
|
|
712
|
+
Sem ele, uma inconsistência de escala entre treino e inferência
|
|
713
|
+
NÃO é detectada por esta chamada — v1.63: antes disso a camada
|
|
714
|
+
existia apenas como detector separado e o veredito a ignorava,
|
|
715
|
+
o que fazia um caso com skew real sair como APROVADO.
|
|
716
|
+
"""
|
|
717
|
+
from modelcheck._validate import checar_comparabilidade
|
|
718
|
+
_ressalvas = checar_comparabilidade(
|
|
719
|
+
model_v1, model_v2, X_eval,
|
|
720
|
+
feature_names or list(X_eval.columns), caller='Guard.audit',
|
|
721
|
+
y_eval=y_eval)
|
|
722
|
+
|
|
723
|
+
from modelcheck.orchestrator import ModelGuardOrchestrator
|
|
724
|
+
orc = ModelGuardOrchestrator(threshold=self.threshold)
|
|
725
|
+
raw = orc.diagnose(model_v1, model_v2, X_eval, y_eval,
|
|
726
|
+
feature_names, label_v1, label_v2,
|
|
727
|
+
mode=mode or self.mode)
|
|
728
|
+
raw['X_eval_ref'] = X_eval
|
|
729
|
+
raw['X_train_ref'] = X_train
|
|
730
|
+
raw['_ressalvas'] = _ressalvas
|
|
731
|
+
if X_train is not None:
|
|
732
|
+
try:
|
|
733
|
+
from modelcheck.validation import TrainServingValidator
|
|
734
|
+
fn = list(feature_names or X_eval.columns)
|
|
735
|
+
raw['train_serving'] = TrainServingValidator().check(
|
|
736
|
+
X_train, X_eval, fn)
|
|
737
|
+
except Exception as e:
|
|
738
|
+
raw['train_serving'] = {'error': f'{type(e).__name__}: {e}',
|
|
739
|
+
'flagged': [], 'alerts': []}
|
|
740
|
+
return AuditResult(raw)
|
|
741
|
+
|
|
742
|
+
def audit_versions(self, models: list, X_eval: pd.DataFrame,
|
|
743
|
+
y_eval=None,
|
|
744
|
+
feature_names: Optional[List[str]] = None,
|
|
745
|
+
labels: Optional[List[str]] = None) -> dict:
|
|
746
|
+
"""
|
|
747
|
+
Audita pares consecutivos de uma sequência de versões.
|
|
748
|
+
|
|
749
|
+
reports = g.audit_versions([v1, v2, v3, v4], X_eval, y_eval)
|
|
750
|
+
print(reports['summary'])
|
|
751
|
+
|
|
752
|
+
Retorna dict com:
|
|
753
|
+
steps lista de AuditResult por transição
|
|
754
|
+
scores scores por transição
|
|
755
|
+
trend 'estável' | 'piorando' | 'melhorando'
|
|
756
|
+
worst índice da pior transição
|
|
757
|
+
summary texto em linguagem natural
|
|
758
|
+
"""
|
|
759
|
+
if len(models) < 2:
|
|
760
|
+
raise ValueError("audit_versions precisa de ao menos 2 modelos")
|
|
761
|
+
if labels is None:
|
|
762
|
+
labels = [f"v{i+1}" for i in range(len(models))]
|
|
763
|
+
steps, scores = [], []
|
|
764
|
+
for i in range(len(models) - 1):
|
|
765
|
+
r = self.audit(models[i], models[i+1], X_eval, y_eval,
|
|
766
|
+
feature_names, labels[i], labels[i+1])
|
|
767
|
+
steps.append(r)
|
|
768
|
+
scores.append(r.score if r.score is not None else np.nan)
|
|
769
|
+
arr = np.array(scores, dtype=float)
|
|
770
|
+
valid = arr[~np.isnan(arr)]
|
|
771
|
+
# Tendência: exige >= 4 transições e alpha=0.05.
|
|
772
|
+
# Medido em 3.000 séries aleatórias por tamanho:
|
|
773
|
+
# n=3 -> 34.2% de falso positivo (inaceitável, bloqueado)
|
|
774
|
+
# n>=4 com alpha=0.05 -> 5.3%-8.0% (calibrado)
|
|
775
|
+
# n>=6 com alpha=0.05 -> ~5.7% (nominal)
|
|
776
|
+
trend = 'indeterminado (precisa de 4+ transições)'
|
|
777
|
+
trend_p = None
|
|
778
|
+
if len(valid) >= 4:
|
|
779
|
+
from scipy import stats as _st
|
|
780
|
+
rho, p = _st.spearmanr(np.arange(len(valid)), valid)
|
|
781
|
+
trend_p = round(float(p), 4)
|
|
782
|
+
trend = ('piorando' if rho < 0 and p < 0.05 else
|
|
783
|
+
'melhorando' if rho > 0 and p < 0.05 else 'estável')
|
|
784
|
+
worst = int(np.nanargmin(arr)) if len(valid) else 0
|
|
785
|
+
bad = [i for i, s in enumerate(scores)
|
|
786
|
+
if not np.isnan(s) and s < 70]
|
|
787
|
+
L = [f"Auditadas {len(steps)} transições de versão "
|
|
788
|
+
f"({labels[0]} → {labels[-1]}).",
|
|
789
|
+
f"Índice médio de saúde: {np.nanmean(arr):.0f} de 100. "
|
|
790
|
+
f"Tendência ao longo das versões: {trend}."]
|
|
791
|
+
if bad:
|
|
792
|
+
nomes = ", ".join(f"{labels[i]}→{labels[i+1]}" for i in bad[:4])
|
|
793
|
+
L.append(f"Transições que merecem atenção: {nomes}.")
|
|
794
|
+
L.append(f"A mais crítica foi {labels[worst]}→{labels[worst+1]} "
|
|
795
|
+
f"({steps[worst].verdict}): "
|
|
796
|
+
f"{steps[worst].problem_type}.")
|
|
797
|
+
else:
|
|
798
|
+
L.append("Nenhuma transição apresentou problema relevante.")
|
|
799
|
+
return {'steps': steps, 'scores': scores, 'labels': labels,
|
|
800
|
+
'trend': trend, 'trend_p': trend_p, 'worst': worst,
|
|
801
|
+
'mean_score': float(np.nanmean(arr)) if len(valid) else None,
|
|
802
|
+
'summary': "\n".join(L)}
|
|
803
|
+
|
|
804
|
+
def data_drift_context(self, model_v1, model_v2, X_eval,
|
|
805
|
+
y_eval=None,
|
|
806
|
+
feature_names: Optional[List[str]] = None) -> dict:
|
|
807
|
+
"""
|
|
808
|
+
Contexto de data/prediction drift, para acompanhar o diagnóstico
|
|
809
|
+
de Shape Instability.
|
|
810
|
+
|
|
811
|
+
Shape Instability é ORTOGONAL a data drift: quando v1 e v2 são
|
|
812
|
+
avaliados no mesmo X, P(X) é idêntico por construção. Este método
|
|
813
|
+
existe para o usuário ver o quadro completo, não para substituir
|
|
814
|
+
o diagnóstico estrutural.
|
|
815
|
+
"""
|
|
816
|
+
from modelcheck.baselines import run_all_baselines
|
|
817
|
+
bl = run_all_baselines(model_v1, model_v2, X_eval, y_eval,
|
|
818
|
+
feature_names)
|
|
819
|
+
alerts = [v['tool'] for k, v in bl.items()
|
|
820
|
+
if isinstance(v, dict) and v.get('alert')]
|
|
821
|
+
return {'raw': bl, 'tools_alerting': alerts,
|
|
822
|
+
'any_alert': bool(alerts),
|
|
823
|
+
'plain': ("Nenhum sinal de deriva de dados ou de performance "
|
|
824
|
+
"pelos métodos tradicionais."
|
|
825
|
+
if not alerts else
|
|
826
|
+
"Métodos tradicionais também alertaram: "
|
|
827
|
+
+ ", ".join(alerts) + ".")}
|
|
828
|
+
|
|
829
|
+
def quick(self, model_v1, model_v2, X_eval,
|
|
830
|
+
representation: str = "auto") -> dict:
|
|
831
|
+
"""Auditoria rápida: apenas camada de forma local (SDD)."""
|
|
832
|
+
from modelcheck.audit import audit as _audit
|
|
833
|
+
return _audit(model_v1, model_v2, X_eval,
|
|
834
|
+
representation=representation,
|
|
835
|
+
threshold=self.threshold, verbose=False)
|