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/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)