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/cli.py ADDED
@@ -0,0 +1,380 @@
1
+ """
2
+ cli.py — interface de linha de comando (v1.62)
3
+
4
+ Projetada para dois usos:
5
+
6
+ 1. pessoa no terminal → saída legível, cores quando há tty
7
+ 2. pipeline de CI/CD → saída JSON, código de saída acionável
8
+
9
+ CÓDIGOS DE SAÍDA
10
+ ----------------
11
+ 0 nenhum problema no nível exigido por --fail-on
12
+ 1 problema encontrado no nível exigido
13
+ 2 erro de execução (arquivo ausente, modelos incompatíveis, etc.)
14
+
15
+ O código 2 é distinto do 1 de propósito: um pipeline deve tratar "o modelo
16
+ tem problema" de forma diferente de "a auditoria não rodou". Confundir os
17
+ dois faz uma falha de infraestrutura passar por aprovação.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import sys
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ EXIT_OK, EXIT_FOUND, EXIT_ERROR = 0, 1, 2
28
+
29
+ _SEV = {"APROVADO": 0, "ATENCAO": 1, "REVISAR": 2, "BLOQUEADO": 3,
30
+ "INDETERMINADO": 2}
31
+ _NIVEL = {"none": 99, "low": 1, "medium": 2, "high": 3}
32
+
33
+
34
+ # ── apresentação ──────────────────────────────────────────────────────
35
+ def _cor(txt: str, cor: str) -> str:
36
+ """Cor só quando a saída é um terminal — pipes recebem texto limpo."""
37
+ if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
38
+ return txt
39
+ c = {"red": "31", "yellow": "33", "green": "32", "blue": "36",
40
+ "grey": "90", "bold": "1"}.get(cor, "0")
41
+ return f"\033[{c}m{txt}\033[0m"
42
+
43
+
44
+ def _icone(verdict: str) -> str:
45
+ return {"APROVADO": _cor("OK", "green"),
46
+ "ATENCAO": _cor("!", "yellow"),
47
+ "REVISAR": _cor("?", "yellow"),
48
+ "BLOQUEADO": _cor("X", "red")}.get(verdict, "-")
49
+
50
+
51
+ def _texto(res: Dict[str, Any]) -> str:
52
+ """Relatório para leitura humana no terminal."""
53
+ v = res.get("verdict", "INDETERMINADO")
54
+ L = [""]
55
+ L.append(f" {_icone(v)} {_cor(v, 'bold')} {res.get('problem_type','')}")
56
+
57
+ raiz = res.get("root_cause_features") or []
58
+ if raiz:
59
+ L.append("")
60
+ L.append(f" {_cor('Comece por:', 'bold')} " +
61
+ ", ".join(_cor(f, "red") for f in raiz))
62
+ L.append(_cor(" estas variaveis deixaram de influenciar o modelo.",
63
+ "grey"))
64
+
65
+ div = res.get("divergences") or []
66
+ outras = [d["feature"] for d in div if d["feature"] not in raiz]
67
+ if outras:
68
+ L.append("")
69
+ L.append(" Mudaram de padrao: " + ", ".join(outras[:6]) +
70
+ (f" (+{len(outras)-6})" if len(outras) > 6 else ""))
71
+
72
+ snap = res.get("snapshot")
73
+ if snap:
74
+ L.append("")
75
+ L.append(_cor(f" comparado com: {snap.get('label')} "
76
+ f"({str(snap.get('created'))[:10]})", "grey"))
77
+ ne = res.get("not_evaluated") or []
78
+ if ne:
79
+ L.append("")
80
+ L.append(_cor(" nao verificado nesta auditoria:", "yellow"))
81
+ for it in ne:
82
+ L.append(_cor(f" - {it['layer']}: {it['reason']}", "grey"))
83
+ L.append(_cor(" (o veredito acima nao cobre estes pontos)", "grey"))
84
+
85
+ L.append(_cor(f" {res.get('n_eval','?')} amostras - "
86
+ f"atribuicao por {res.get('representation','?')}", "grey"))
87
+ L.append("")
88
+ return "\n".join(L)
89
+
90
+
91
+ def _html(res: Dict[str, Any]) -> str:
92
+ """v1.69: delega ao renderizador de laudo (modelcheck/laudo.py)."""
93
+ from modelcheck.laudo import render
94
+ return render(res)
95
+
96
+
97
+ def _html_legado(res: Dict[str, Any]) -> str:
98
+ """Relatorio autocontido, para anexar num ticket ou enviar por e-mail."""
99
+ v = res.get("verdict", "?")
100
+ cor = {"APROVADO": "#2E7D57", "ATENCAO": "#B8860B",
101
+ "REVISAR": "#B8860B", "BLOQUEADO": "#B03A2E"}.get(v, "#555")
102
+ raiz = res.get("root_cause_features") or []
103
+ linhas = "".join(
104
+ f"<tr><td><code>{d['feature']}</code></td><td>{d.get('shape_before')}</td>"
105
+ f"<td>{d.get('shape_after')}</td><td>{d.get('spread','')}</td></tr>"
106
+ for d in res.get("divergences", []))
107
+ col = "".join(
108
+ f"<tr><td><code>{c['feature']}</code></td>"
109
+ f"<td>{c.get('importance_before')}</td>"
110
+ f"<td>{c.get('importance_after')}</td><td>{c.get('ratio')}</td></tr>"
111
+ for c in res.get("collapses", []))
112
+ bloco_raiz = ""
113
+ if raiz:
114
+ nomes = ", ".join(f"<code>{f}</code>" for f in raiz)
115
+ bloco_raiz = (f'<div class="box"><b>Comece por:</b> {nomes}<br>'
116
+ f'<span style="color:#666">estas variaveis deixaram de '
117
+ f'influenciar o modelo.</span></div>')
118
+ t_col = (f'<h2 style="font-size:17px">Colapso de importancia</h2><table>'
119
+ f'<tr><th>variavel</th><th>antes</th><th>depois</th>'
120
+ f'<th>razao</th></tr>{col}</table>') if col else ""
121
+ t_div = (f'<h2 style="font-size:17px">Mudanca de forma</h2><table>'
122
+ f'<tr><th>variavel</th><th>antes</th><th>depois</th>'
123
+ f'<th>espalhamento</th></tr>{linhas}</table>') if linhas else ""
124
+ ne = res.get("not_evaluated") or []
125
+ t_ne = ""
126
+ if ne:
127
+ itens = "".join(f"<li><b>{i['layer']}</b>: {i['reason']}<br>"
128
+ f"<span style='color:#777'>{i.get('consequence','')}"
129
+ f"</span></li>" for i in ne)
130
+ t_ne = (f'<div class="warn"><b>Nao verificado nesta auditoria</b>'
131
+ f'<ul>{itens}</ul>'
132
+ f'<span style="color:#777">O resultado acima nao cobre estes '
133
+ f'pontos.</span></div>')
134
+
135
+ snap = res.get("snapshot") or {}
136
+ t_snap = (f" - comparado com <b>{snap.get('label')}</b> de "
137
+ f"{str(snap.get('created'))[:10]}") if snap else ""
138
+ return f"""<!DOCTYPE html><html lang="pt-BR"><head><meta charset="utf-8">
139
+ <title>Auditoria de modelo - {v}</title><style>
140
+ body{{font:15px/1.6 system-ui,-apple-system,sans-serif;max-width:860px;
141
+ margin:40px auto;padding:0 20px;color:#1a1a1a}}
142
+ h1{{font-size:26px;margin:0 0 4px}} .v{{color:{cor};font-weight:700}}
143
+ table{{width:100%;border-collapse:collapse;margin:18px 0;font-size:14px}}
144
+ th,td{{text-align:left;padding:9px 10px;border-bottom:1px solid #e4e4e4}}
145
+ th{{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#666;
146
+ font-weight:500}}
147
+ code{{background:#f4f4f4;padding:1px 5px;border-radius:3px;font-size:13px}}
148
+ .meta{{color:#777;font-size:13px;margin-top:26px;padding-top:14px;
149
+ border-top:1px solid #e4e4e4}}
150
+ .box{{background:#faf9f7;border-left:3px solid {cor};padding:14px 18px;
151
+ margin:18px 0}}
152
+ .warn{{background:#fffdf5;border-left:3px solid #C9A227;padding:12px 18px;
153
+ margin:20px 0;font-size:14px}}
154
+ .warn ul{{margin:8px 0 4px;padding-left:20px}} .warn li{{margin:5px 0}}</style></head><body>
155
+ <h1>Auditoria de modelo</h1>
156
+ <p>Resultado: <span class="v">{v}</span> - {res.get('problem_type','')}</p>
157
+ {bloco_raiz}{t_col}{t_div}{t_ne}
158
+ <div class="meta">{res.get('n_eval','?')} amostras - atribuicao por
159
+ {res.get('representation','?')}{t_snap}
160
+ <br>Gerado por modelcheck. Limites conhecidos em LIMITATIONS.md.</div>
161
+ </body></html>"""
162
+
163
+
164
+ def _emitir(res: Dict[str, Any], formato: str, saida: Optional[str]) -> None:
165
+ txt = {"json": lambda: json.dumps(res, indent=2, ensure_ascii=False),
166
+ "html": lambda: _html(res),
167
+ "text": lambda: _texto(res)}[formato]()
168
+ if saida:
169
+ with open(saida, "w", encoding="utf-8") as fh:
170
+ fh.write(txt)
171
+ if formato != "json":
172
+ print(_cor(f"relatorio salvo em {saida}", "grey"))
173
+ else:
174
+ print(txt)
175
+
176
+
177
+ # ── carregamento ──────────────────────────────────────────────────────
178
+ def _carregar_modelo(path: str):
179
+ if not os.path.exists(path):
180
+ raise FileNotFoundError(f"modelo nao encontrado: {path}")
181
+ try:
182
+ import joblib
183
+ return joblib.load(path)
184
+ except Exception:
185
+ import pickle
186
+ with open(path, "rb") as fh:
187
+ return pickle.load(fh)
188
+
189
+
190
+ def _carregar_dados(path: str, sep: Optional[str], target: Optional[str]):
191
+ import pandas as pd
192
+ if not os.path.exists(path):
193
+ raise FileNotFoundError(f"dados nao encontrados: {path}")
194
+ df = pd.read_csv(path, sep=sep or None, engine="python")
195
+ y = df.pop(target) if target and target in df.columns else None
196
+ return df, y
197
+
198
+
199
+ # ── comandos ──────────────────────────────────────────────────────────
200
+ def _cmd_snapshot_save(a) -> int:
201
+ from modelcheck import snapshot
202
+ X, _ = _carregar_dados(a.data, a.sep, a.target)
203
+ m = _carregar_modelo(a.model)
204
+ s = snapshot.save(m, X, a.output, label=a.label,
205
+ representation=a.representation)
206
+ print(f"{_cor('OK','green')} snapshot gravado: {a.output}")
207
+ print(_cor(f" {s['label']} - {len(s['features'])} variaveis - "
208
+ f"{s['meta']['n_eval']} amostras - "
209
+ f"{s['meta']['representation']}", "grey"))
210
+ return EXIT_OK
211
+
212
+
213
+ def _cmd_snapshot_list(a) -> int:
214
+ from modelcheck import snapshot
215
+ rows = snapshot.listar(a.dir)
216
+ if not rows:
217
+ print(_cor(f"nenhum snapshot em {a.dir}", "grey"))
218
+ return EXIT_OK
219
+ print(f"\n {'arquivo':<26}{'rotulo':<20}{'criado':<12}{'vars':>5}{'n':>8}")
220
+ print(" " + "-" * 70)
221
+ for r in rows:
222
+ print(f" {r['file']:<26}{str(r['label'])[:19]:<20}"
223
+ f"{str(r['created'])[:10]:<12}{r['features']:>5}{r['n_eval']:>8}")
224
+ print()
225
+ return EXIT_OK
226
+
227
+
228
+ def _cmd_diff(a) -> int:
229
+ from modelcheck import snapshot
230
+ X, _ = _carregar_dados(a.data, a.sep, a.target)
231
+ m = _carregar_modelo(a.model)
232
+ mon = [s.strip() for s in a.monitor.split(",")] if a.monitor else None
233
+ res = snapshot.diff(a.against, m, X, monitored=mon,
234
+ strict_eval=not a.allow_different_eval)
235
+ _emitir(res, a.format, a.output)
236
+ return EXIT_FOUND if _SEV.get(res["verdict"], 0) >= \
237
+ _NIVEL.get(a.fail_on, 99) else EXIT_OK
238
+
239
+
240
+ def _cmd_compare(a) -> int:
241
+ """Compara dois modelos carregados - o modo antigo, ainda util."""
242
+ from modelcheck import Guard
243
+ X, y = _carregar_dados(a.data, a.sep, a.target)
244
+ m1, m2 = _carregar_modelo(a.model_a), _carregar_modelo(a.model_b)
245
+ mon = [s.strip() for s in a.monitor.split(",")] if a.monitor else None
246
+ r = Guard().audit(m1, m2, X, y, list(X.columns))
247
+ res = {"verdict": r.verdict, "problem_type": r.problem_type,
248
+ "root_cause_features": r.root_cause_features,
249
+ "divergences": [{"feature": d["feature"],
250
+ "shape_before": d.get("shape_v1"),
251
+ "shape_after": d.get("shape_v2"),
252
+ "spread": d.get("spread")}
253
+ for d in r.raw.get("layer1", {})
254
+ .get("sdd", {}).get("divs", [])],
255
+ "collapses": [], "n_eval": len(X),
256
+ "representation": "shap", "monitored": mon,
257
+ "not_evaluated": r.not_evaluated}
258
+ _emitir(res, a.format, a.output)
259
+ return EXIT_FOUND if _SEV.get(r.verdict, 0) >= \
260
+ _NIVEL.get(a.fail_on, 99) else EXIT_OK
261
+
262
+
263
+ def _cmd_check(a) -> int:
264
+ """Verificacao de integridade de variaveis - o modo de maior F1."""
265
+ from modelcheck import Guard
266
+ X, _ = _carregar_dados(a.data, a.sep, a.target)
267
+ m1, m2 = _carregar_modelo(a.model_a), _carregar_modelo(a.model_b)
268
+ mon = [s.strip() for s in a.monitor.split(",")]
269
+ c = Guard().check_features(m1, m2, X, monitored=mon,
270
+ feature_names=list(X.columns))
271
+ res = {"verdict": "BLOQUEADO" if c["alert"] else "APROVADO",
272
+ "problem_type": "IMPORTANCE_COLLAPSE" if c["alert"] else "NONE",
273
+ "root_cause_features": c["collapsed"], "divergences": [],
274
+ "collapses": [], "monitored": mon, "n_eval": len(X),
275
+ "representation": "shap",
276
+ "not_evaluated": [
277
+ {"layer": "shape divergence",
278
+ "reason": "modo `check` verifica apenas integridade",
279
+ "consequence": "mudanca de forma nao foi verificada"},
280
+ {"layer": "train/serving consistency",
281
+ "reason": "nao executada no modo `check`",
282
+ "consequence": "skew de dados nao foi verificado"}]}
283
+ _emitir(res, a.format, a.output)
284
+ return EXIT_FOUND if c["alert"] and _NIVEL.get(a.fail_on, 99) <= 3 \
285
+ else EXIT_OK
286
+
287
+
288
+ # ── parser ────────────────────────────────────────────────────────────
289
+ def _add_comuns(sp):
290
+ sp.add_argument("--sep", default=None, help="CSV separator")
291
+ sp.add_argument("--target", default=None, help="target column, if present")
292
+ sp.add_argument("--format", choices=["text", "json", "html"],
293
+ default="text")
294
+ sp.add_argument("-o", "--output", default=None, help="output file")
295
+ sp.add_argument("--fail-on", choices=["none", "low", "medium", "high"],
296
+ default="none",
297
+ help="exit 1 from this severity upwards")
298
+ sp.add_argument("--monitor", default=None,
299
+ help="critical features, comma-separated")
300
+
301
+
302
+ def build_parser() -> argparse.ArgumentParser:
303
+ p = argparse.ArgumentParser(
304
+ prog="modelcheck",
305
+ description="Audit what changed between two versions of a model.",
306
+ epilog="exit codes: 0 clean | 1 problem found | 2 execution error")
307
+ sub = p.add_subparsers(dest="cmd", required=True)
308
+
309
+ sn = sub.add_parser("snapshot", help="save and list snapshots")
310
+ sn_sub = sn.add_subparsers(dest="sub", required=True)
311
+
312
+ s1 = sn_sub.add_parser("save", help="save a model's structural state")
313
+ s1.add_argument("model")
314
+ s1.add_argument("data")
315
+ s1.add_argument("-o", "--output", required=True)
316
+ s1.add_argument("--label", default=None)
317
+ s1.add_argument("--sep", default=None)
318
+ s1.add_argument("--target", default=None)
319
+ s1.add_argument("--representation", choices=["shap", "ale"],
320
+ default="shap")
321
+ s1.set_defaults(func=_cmd_snapshot_save)
322
+
323
+ s2 = sn_sub.add_parser("list", help="list snapshots in a directory")
324
+ s2.add_argument("dir", nargs="?", default=".")
325
+ s2.set_defaults(func=_cmd_snapshot_list)
326
+
327
+ d = sub.add_parser("diff",
328
+ help="compare a model against a saved snapshot")
329
+ d.add_argument("model")
330
+ d.add_argument("data")
331
+ d.add_argument("--against", required=True, help="snapshot file")
332
+ d.add_argument("--allow-different-eval", action="store_true",
333
+ help="allow a different evaluation set (not "
334
+ "recommended: measures the gap between sets)")
335
+ _add_comuns(d)
336
+ d.set_defaults(func=_cmd_diff)
337
+
338
+ c = sub.add_parser("compare", help="compare two loaded models")
339
+ c.add_argument("model_a")
340
+ c.add_argument("model_b")
341
+ c.add_argument("data")
342
+ _add_comuns(c)
343
+ c.set_defaults(func=_cmd_compare)
344
+
345
+ k = sub.add_parser("check",
346
+ help="check integrity of critical features")
347
+ k.add_argument("model_a")
348
+ k.add_argument("model_b")
349
+ k.add_argument("data")
350
+ _add_comuns(k)
351
+ k.set_defaults(func=_cmd_check)
352
+ return p
353
+
354
+
355
+ def main(argv: Optional[List[str]] = None) -> int:
356
+ args = build_parser().parse_args(argv)
357
+ if args.cmd == "check" and not getattr(args, "monitor", None):
358
+ print(_cor("error: `check` requires --monitor with the critical features",
359
+ "red"), file=sys.stderr)
360
+ return EXIT_ERROR
361
+ try:
362
+ return args.func(args)
363
+ except (FileNotFoundError, ValueError, TypeError) as e:
364
+ print(f"{_cor('erro:', 'red')} {e}", file=sys.stderr)
365
+ return EXIT_ERROR
366
+ except KeyboardInterrupt:
367
+ print(_cor("\ninterrompido", "grey"), file=sys.stderr)
368
+ return EXIT_ERROR
369
+ except Exception as e:
370
+ print(f"{_cor('erro inesperado:', 'red')} {type(e).__name__}: {e}",
371
+ file=sys.stderr)
372
+ print(_cor(" para o traceback completo: MODELCHECK_DEBUG=1",
373
+ "grey"), file=sys.stderr)
374
+ if os.environ.get("MODELCHECK_DEBUG"):
375
+ raise
376
+ return EXIT_ERROR
377
+
378
+
379
+ if __name__ == "__main__":
380
+ sys.exit(main())
File without changes
@@ -0,0 +1,47 @@
1
+ """Compara feature importance entre múltiplos modelos."""
2
+ import pandas as pd
3
+
4
+ from modelcheck.explainers.shap_explainer import ShapExplainer
5
+
6
+
7
+ class ModelComparator:
8
+ def __init__(self, models: dict, X_background, max_shap_rows=2000):
9
+ self.models = models
10
+ self.X_background = X_background
11
+ self.max_shap_rows = max_shap_rows
12
+ self.importances = {}
13
+
14
+ def compute_all(self, X):
15
+ for name, model in self.models.items():
16
+ explainer = ShapExplainer(model, self.X_background,
17
+ max_shap_rows=self.max_shap_rows)
18
+ self.importances[name] = explainer.feature_importance(X)
19
+ return self.importances
20
+
21
+ def build_comparison_table(self):
22
+ tables = []
23
+ for model_name, importance_df in self.importances.items():
24
+ renamed = importance_df.rename(
25
+ columns={"mean_abs_shap": model_name}
26
+ )
27
+ tables.append(renamed.set_index("feature"))
28
+
29
+ comparison = pd.concat(tables, axis=1)
30
+ return comparison
31
+
32
+ def find_divergences(self, threshold=0.3):
33
+ comparison = self.build_comparison_table()
34
+ normalized = comparison.div(comparison.max(axis=0), axis=1)
35
+
36
+ divergences = []
37
+ for feature in normalized.index:
38
+ row = normalized.loc[feature]
39
+ spread = row.max() - row.min()
40
+ if spread > threshold:
41
+ divergences.append({
42
+ "feature": feature,
43
+ "spread": round(spread, 3),
44
+ "values": row.to_dict(),
45
+ })
46
+
47
+ return divergences
@@ -0,0 +1,158 @@
1
+ """
2
+ confidence.py — Intervalos de confiança para scores do ModelGuard
3
+
4
+ Motivação (Revisão Crítica, lacuna A):
5
+ Nenhum score tinha IC. Não era possível afirmar que score 58.2 e 71.8
6
+ são estatisticamente distintos. Sem isso, o benchmark não é publicável.
7
+
8
+ Método: bootstrap não-paramétrico sobre X_eval (reamostragem com
9
+ reposição), recalculando o score em cada réplica. Retorna média, mediana,
10
+ IC percentil e desvio.
11
+
12
+ CALIBRAÇÃO MEDIDA (v1.35) — leia antes de usar o IC em decisão:
13
+
14
+ Cobertura empírica com nominal de 95%, contra o score da população
15
+ (5.000 amostras), 18 repetições por condição, n_eval=150:
16
+
17
+ par divergente: cobertura 100% largura média 23.1 pts
18
+ par de controle: cobertura 100% largura média 44.5 pts
19
+
20
+ O IC é CONSERVADOR (sobre-cobertura), não anticonservador. Não há risco de
21
+ falsa precisão — o risco é o oposto: intervalos largos demais, com pouco
22
+ poder de discriminação.
23
+
24
+ Causa: o Health Score é DISCRETO (múltiplos de ~5 e ~15 pontos, conforme a
25
+ contagem de alertas). O bootstrap percentil sobre uma estatística discreta
26
+ produz intervalos que saltam de degrau em degrau, sempre englobando o valor
27
+ verdadeiro. Um IC de 44.5 pontos numa escala de 0-100 é quase metade da
28
+ escala.
29
+
30
+ CONSEQUÊNCIA PRÁTICA: use o IC para afirmar que dois cenários NÃO são
31
+ distinguíveis (a sobreposição é conclusiva). Não o use para afirmar que
32
+ são distinguíveis com 95% de confiança — a sobre-cobertura torna o teste
33
+ excessivamente permissivo nessa direção. Para comparação, prefira
34
+ `compare_scores_ci`, que usa o teste bootstrap da diferença (`boot_p`)
35
+ em vez da sobreposição de intervalos.
36
+
37
+ Uso:
38
+ from modelcheck.confidence import score_with_ci, compare_scores_ci
39
+ r = score_with_ci(audit, model_v1, model_v2, X_eval, n_boot=50)
40
+ print(r['summary'])
41
+ """
42
+ import numpy as np
43
+ import pandas as pd
44
+ from typing import Callable, Optional, List
45
+
46
+
47
+ def score_with_ci(score_fn: Callable,
48
+ model_v1, model_v2,
49
+ X_eval: pd.DataFrame,
50
+ y_eval: Optional[pd.Series] = None,
51
+ feature_names: Optional[List[str]] = None,
52
+ n_boot: int = 50,
53
+ alpha: float = 0.05,
54
+ score_key: str = 'health_score',
55
+ seed: int = 0) -> dict:
56
+ """
57
+ Calcula IC bootstrap para qualquer função de score.
58
+
59
+ score_fn deve aceitar (m1, m2, X, y, feature_names) ou (m1, m2, X)
60
+ e retornar dict contendo score_key.
61
+ """
62
+ if feature_names is None:
63
+ feature_names = list(X_eval.columns)
64
+
65
+ rng = np.random.RandomState(seed)
66
+ scores, n_fail = [], 0
67
+
68
+ for b in range(n_boot):
69
+ idx = rng.choice(len(X_eval), len(X_eval), replace=True)
70
+ Xb = X_eval.iloc[idx].reset_index(drop=True)
71
+ yb = (y_eval.iloc[idx].reset_index(drop=True)
72
+ if y_eval is not None else None)
73
+ try:
74
+ try:
75
+ out = score_fn(model_v1, model_v2, Xb, yb, feature_names)
76
+ except TypeError:
77
+ out = score_fn(model_v1, model_v2, Xb)
78
+ s = out.get(score_key)
79
+ scores.append(np.nan if s is None else float(s))
80
+ except Exception:
81
+ n_fail += 1
82
+ scores.append(np.nan)
83
+
84
+ arr = np.array(scores, dtype=float)
85
+ valid = arr[~np.isnan(arr)]
86
+ if len(valid) == 0:
87
+ return {'error': 'todas as réplicas falharam', 'n_fail': n_fail}
88
+
89
+ lo = float(np.percentile(valid, 100 * alpha / 2))
90
+ hi = float(np.percentile(valid, 100 * (1 - alpha / 2)))
91
+ mean = float(valid.mean())
92
+
93
+ return {
94
+ 'mean': round(mean, 2),
95
+ 'median': round(float(np.median(valid)), 2),
96
+ 'std': round(float(valid.std()), 2),
97
+ 'ci_low': round(lo, 2),
98
+ 'ci_high': round(hi, 2),
99
+ 'ci_width': round(hi - lo, 2),
100
+ 'n_boot': n_boot,
101
+ 'n_valid': int(len(valid)),
102
+ 'n_fail': n_fail,
103
+ 'alpha': alpha,
104
+ 'scores': valid.tolist(),
105
+ 'summary': (f"{mean:.1f} "
106
+ f"[IC{int((1-alpha)*100)}: {lo:.1f}–{hi:.1f}]"),
107
+ }
108
+
109
+
110
+ def compare_scores_ci(ci_a: dict, ci_b: dict,
111
+ label_a: str = "A", label_b: str = "B") -> dict:
112
+ """
113
+ Testa se dois scores com IC são estatisticamente distintos.
114
+
115
+ Dois critérios:
116
+ overlap — ICs se sobrepõem? (critério conservador, informal)
117
+ boot_p — proporção de réplicas em que a >= b (teste bootstrap)
118
+ """
119
+ a = np.array(ci_a.get('scores', []), dtype=float)
120
+ b = np.array(ci_b.get('scores', []), dtype=float)
121
+ overlap = not (ci_a['ci_high'] < ci_b['ci_low'] or
122
+ ci_b['ci_high'] < ci_a['ci_low'])
123
+
124
+ boot_p = None
125
+ if len(a) > 0 and len(b) > 0:
126
+ n = min(len(a), len(b))
127
+ diff = a[:n] - b[:n]
128
+ m = float(diff.mean())
129
+ # BUG CORRIGIDO (v1.35): a versão anterior usava
130
+ # p = 2 * min(frac(diff>=0), 1-frac(diff>=0))
131
+ # que devolve p=0 quando TODAS as diferenças são zero — declarando
132
+ # "altamente significativo" exatamente no caso de nenhuma diferença.
133
+ # Pego pelo teste de dois controles idênticos.
134
+ if np.allclose(diff, 0.0):
135
+ boot_p = 1.0
136
+ else:
137
+ # proporção de réplicas com sinal oposto à diferença média,
138
+ # contando empates como metade; correção de continuidade (+1)
139
+ oposto = float((np.sign(diff) != np.sign(m)).sum()
140
+ - 0.5 * float((diff == 0).sum()))
141
+ oposto = max(oposto, 0.0)
142
+ boot_p = round(min(1.0, 2 * (oposto + 1) / (n + 1)), 4)
143
+
144
+ return {
145
+ 'label_a': label_a,
146
+ 'label_b': label_b,
147
+ 'mean_a': ci_a['mean'],
148
+ 'mean_b': ci_b['mean'],
149
+ 'delta': round(ci_a['mean'] - ci_b['mean'], 2),
150
+ 'ci_overlap': overlap,
151
+ 'boot_p': boot_p,
152
+ 'distinct': bool((not overlap) or
153
+ (boot_p is not None and boot_p < 0.05)),
154
+ 'summary': (f"{label_a}={ci_a['summary']} vs "
155
+ f"{label_b}={ci_b['summary']} → "
156
+ f"{'DISTINTOS' if ((not overlap) or (boot_p is not None and boot_p < 0.05)) else 'NÃO distinguíveis'}"
157
+ + (f" (p={boot_p})" if boot_p is not None else "")),
158
+ }