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/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
|
modelcheck/confidence.py
ADDED
|
@@ -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
|
+
}
|