From 7861dcad53de9f747d376811fd4bdfb456b0cc18 Mon Sep 17 00:00:00 2001 From: Marcos Lohmann Date: Wed, 7 Oct 2026 19:04:22 -0300 Subject: [PATCH] =?UTF-8?q?Calibra=C3=A7=C3=A3o:=20README=20com=20sum?= =?UTF-8?q?=C3=A1rio=20executivo,=20gr=C3=A1ficos,=20metodologia=20e=20con?= =?UTF-8?q?clus=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- analysis/charts.py | 102 ++- reports/calibration/README.md | 64 +- reports/calibration/chart-acuracia.svg | 251 ++++++ reports/calibration/chart-cobertura-risco.svg | 814 ++++++++++++++++++ reports/calibration/chart-ece.svg | 380 ++++++++ 5 files changed, 1604 insertions(+), 7 deletions(-) create mode 100644 reports/calibration/chart-acuracia.svg create mode 100644 reports/calibration/chart-cobertura-risco.svg create mode 100644 reports/calibration/chart-ece.svg diff --git a/analysis/charts.py b/analysis/charts.py index 19bd51f..ef033af 100644 --- a/analysis/charts.py +++ b/analysis/charts.py @@ -4,7 +4,11 @@ Paleta: slots 1 (azul, GPU) e 2 (laranja, CPU) da paleta categórica de referên (CVD ΔE 24,7; visão normal 33,6; contraste ≥ 3:1 sobre #fcfcfb). Uma série → sem legenda; duas séries → legenda + rótulos diretos. Um único eixo y por gráfico. -Uso: python -m analysis.charts reports/pilot +Uso: python -m analysis.charts reports/pilot [--kind pilot|calibration] + +Calibração: uma cor fixa por candidato (slots 1–4: SemIf, Rizzo Flow, Laya, GLiNER), validada +(CVD ΔE ≥ 9,1; visão normal ≥ 22,9). Aqua e amarelo ficam abaixo de 3:1 de contraste: as curvas +levam rótulo direto e o README traz a tabela. """ import json import os @@ -18,6 +22,7 @@ import matplotlib.pyplot as plt # noqa: E402 SURFACE, INK, INK_2, GRID = "#fcfcfb", "#0b0b0b", "#52514e", "#e6e5e1" GPU, CPU = "#2a78d6", "#eb6834" +ENTITY = {"semif": "#2a78d6", "rizzo_flow": "#eb6834", "laya": "#1baf7a", "gliner": "#eda100"} NAMES = {"semif": "SemIf", "rizzo_flow": "Rizzo Flow", "laya": "Laya", "gliner": "GLiNER"} P95_LIMIT_MS = 150 @@ -115,12 +120,99 @@ def tradeoff_chart(data, dest): plt.close(fig) +def calibration_charts(dest): + from analysis.calibrate import apply_temperature + from analysis.metrics import selective + + cal = json.loads((Path(__file__).resolve().parents[1] / "config" / "calibration.json").read_text())["candidates"] + data = load(dest) + order = [c for c in ("semif", "rizzo_flow", "laya", "gliner") if (c, "cuda") in data] + + # 1. Acurácia (GPU, partição de calibração) + rows = sorted(((c, data[(c, "cuda")]["quality"]["accuracy"] * 100) for c in order), key=lambda r: r[1]) + fig, ax = plt.subplots(figsize=(7, 2.8)) + bars = ax.barh([NAMES[c] for c, _ in rows], [v for _, v in rows], height=0.55, color=GPU) + for bar, (_, v) in zip(bars, rows): + ax.text(v + 1, bar.get_y() + bar.get_height() / 2, f"{v:.1f}%", va="center", color=INK) + ax.set_xlim(0, 105) + ax.set_xlabel("Acurácia (%)") + ax.set_title("Acurácia na partição de calibração (1.127 exemplos, GPU)", loc="left", fontsize=12, color=INK) + style(ax) + fig.tight_layout() + save(fig, dest / "chart-acuracia.svg") + plt.close(fig) + + # 2. ECE antes × depois da calibração + fig, ax = plt.subplots(figsize=(7, 2.9)) + for i, c in enumerate(sorted(order, key=lambda c: cal[c]["cuda"]["ece_raw"])): + raw, after = cal[c]["cuda"]["ece_raw"], cal[c]["cuda"]["ece_calibrated"] + ax.plot([after, raw], [i, i], color=GRID, linewidth=2, zorder=1) + ax.scatter(raw, i, s=70, facecolor=SURFACE, edgecolor=INK_2, linewidth=1.6, zorder=3, + label="Bruto" if i == 0 else None) + ax.scatter(after, i, s=70, color=GPU, edgecolor=SURFACE, linewidth=2, zorder=3, + label="Calibrado (temperatura)" if i == 0 else None) + ax.text(raw + 0.008, i, f"{raw:.3f}", va="center", color=INK, fontsize=10) + ax.text(after - 0.008, i, f"{after:.3f}", va="center", ha="right", color=INK, fontsize=10) + ax.set_yticks(list(range(i + 1))) + ax.set_yticklabels([NAMES[c] for c in sorted(order, key=lambda c: cal[c]["cuda"]["ece_raw"])]) + ax.set_xlim(-0.03, 0.4) + ax.set_xlabel("ECE — erro de calibração esperado (menor é melhor)") + ax.set_title("Calibração das probabilidades: antes × depois", loc="left", fontsize=12, color=INK) + ax.legend(loc="lower right", frameon=False, fontsize=10) + style(ax) + fig.tight_layout() + save(fig, dest / "chart-ece.svg") + plt.close(fig) + + # 3. Cobertura × risco (rota errada com confiança alta), probabilidades calibradas + fig, ax = plt.subplots(figsize=(7, 4.2)) + ax.axvline(2, color=INK_2, linewidth=1, linestyle=(0, (4, 3))) + ax.axhline(80, color=INK_2, linewidth=1, linestyle=(0, (1, 3))) + ax.text(2.15, 33, "risco máx. 2%", color=INK_2, fontsize=9) + label_at = {"semif": (0.25, 90), "rizzo_flow": (4.2, 47), "gliner": (4.6, 12), "laya": (4.6, 4)} + ax.text(15.8, 81.5, "meta de cobertura 80%", color=INK_2, fontsize=9, ha="right") + for c in order: + preds = [json.loads(l) for l in (dest / f"predictions-{c}-cuda.jsonl").open()] + t = cal[c]["cuda"]["temperature"] + rows_c = [] + for r in preds: + if r.get("probabilities"): + p = apply_temperature(r["probabilities"], t) + rows_c.append({**r, "probabilities": p, "pred": max(p, key=p.get)}) + xs, ys = [], [] + for thr in [i / 200 for i in range(0, 201)]: + sres = selective(rows_c, thr) + xs.append(sres["confident_wrong_rate"] * 100) + ys.append(sres["coverage"] * 100) + ax.plot(xs, ys, color=ENTITY[c], linewidth=2) + chosen = cal[c]["cuda"] + ax.scatter(chosen["confident_wrong_rate"] * 100, chosen["coverage"] * 100, s=60, color=ENTITY[c], + edgecolor=SURFACE, linewidth=2, zorder=3) + ax.annotate(f"{NAMES[c]}: {chosen['coverage'] * 100:.0f}% de cobertura", + (chosen["confident_wrong_rate"] * 100, chosen["coverage"] * 100), + xytext=label_at[c], textcoords="data", color=INK, fontsize=10, + arrowprops={"arrowstyle": "-", "color": INK_2, "linewidth": 0.8}) + ax.set_xlim(0, 16) + ax.set_ylim(0, 102) + ax.set_xlabel("Rota errada com confiança alta (% do total)") + ax.set_ylabel("Cobertura (% decidido sem desambiguar)") + ax.set_title("Cobertura × risco ao variar o limiar de confiança (GPU)", loc="left", fontsize=12, color=INK) + style(ax, grid_axis="both") + fig.tight_layout() + save(fig, dest / "chart-cobertura-risco.svg") + plt.close(fig) + + def main(): dest = Path(sys.argv[1]) - data = load(dest) - accuracy_chart(data, dest) - latency_chart(data, dest) - tradeoff_chart(data, dest) + kind = sys.argv[sys.argv.index("--kind") + 1] if "--kind" in sys.argv else "pilot" + if kind == "calibration": + calibration_charts(dest) + else: + data = load(dest) + accuracy_chart(data, dest) + latency_chart(data, dest) + tradeoff_chart(data, dest) print(sorted(p.name for p in dest.glob("chart-*.svg"))) diff --git a/reports/calibration/README.md b/reports/calibration/README.md index caa0403..1e6bc25 100644 --- a/reports/calibration/README.md +++ b/reports/calibration/README.md @@ -1,6 +1,44 @@ -# Resultados — partição `calibration` +# Calibração — partição de calibração (1.127 exemplos) -Gerado por `infra/ds/collect.py`. Lote 1; latências sem os exemplos de aquecimento; probabilidades sem calibração. +## Sumário executivo + +Nesta etapa, os quatro candidatos rodaram sobre a partição de calibração, e dela saíram os dois parâmetros que cada um leva para o teste final: uma **temperatura** que torna as probabilidades confiáveis e um **limiar de confiança** abaixo do qual o orquestrador pede esclarecimento ao usuário, em vez de rotear. + +- **Qualidade:** SemIf (**85,8%**) e Rizzo Flow (**83,3%**) mantêm a liderança do piloto. GLiNER (54,5%) e Laya (50,8%) ficam bem atrás; no conjunto maior, o Laya fica abaixo do que o piloto sugeria. +- **Calibração:** a temperatura melhorou a calibração dos quatro. O ganho maior foi no Laya, cujo erro de calibração (ECE) caiu de 0,33 para 0,08. SemIf e Rizzo terminam muito bem calibrados (ECE 0,02). +- **Abstenção com risco controlado:** limitando a 2% as rotas erradas com confiança alta, o SemIf decide sozinho **60%** dos casos (com 96,7% de acerto nesses casos) e o Rizzo Flow **48%** (95,9%). GLiNER (12%) e Laya (5%) quase sempre precisariam pedir esclarecimento. +- **Critério pré-registrado de risco (H2):** exige cobertura ≥ 80% com risco ≤ 2%, e **nenhum candidato atinge**. Para chegar a 80% de cobertura, SemIf e Rizzo aceitariam de 6% a 7% de rotas erradas com confiança alta. +- **Consequência:** a temperatura e o limiar ficam congelados por candidato (`config/calibration.json`, tag `congelamento-d1-v1`) e são aplicados sem alteração ao teste final. + +## Resultados gerais + +| Candidato | Acurácia | ECE bruto → calibrado | Temperatura | Limiar | Cobertura com risco ≤ 2% | Acurácia nos casos decididos | Risco para cobertura de 80% | +|---|---|---|---|---|---|---|---| +| SemIf | 85,8% | 0,055 → 0,022 | 1,28 | 0,915 | 59,5% | 96,7% | 6,1% | +| Rizzo Flow | 83,3% | 0,058 → 0,021 | 1,25 | 0,924 | 47,6% | 95,9% | 7,1% | +| GLiNER | 54,5% | 0,077 → 0,063 | 0,82 | 0,857 | 11,8% | 83,5% | 32,1% | +| Laya | 50,8% | 0,326 → 0,075 | 3,58 | 0,872 | 5,1% | 84,5% | 34,5% | + +*(GPU A10. Em CPU, Laya e GLiNER chegam às mesmas temperaturas e limiares.)* + +![Acurácia na partição de calibração](chart-acuracia.svg) + +![Calibração das probabilidades: antes × depois](chart-ece.svg) + +![Cobertura × risco ao variar o limiar de confiança](chart-cobertura-risco.svg) + +Cada curva mostra, para um candidato, quanto ele decide sozinho (cobertura) em troca de quantas rotas erradas com confiança alta, à medida que o limiar baixa. O ponto marcado é o limiar escolhido: a maior cobertura com no máximo 2% de risco. Quanto mais a curva sobe à esquerda, melhor. + +| Candidato | `sem_correspondencia` reconhecido | Acerto dentro do escopo | Pedidos válidos marcados como `sem_correspondencia` | +|---|---|---|---| +| SemIf | 68,8% | 90,3% | 32 | +| Rizzo Flow | 48,7% | 92,4% | 8 | +| GLiNER | 59,0% | 53,3% | 226 | +| Laya | 22,2% | 58,2% | 29 | + +## Resultados detalhados + +Probabilidades brutas (antes da calibração). Lote 1. As latências excluem os 5 primeiros exemplos (aquecimento). SemIf e Rizzo Flow rodam só em GPU, porque são inviáveis em CPU pela regra do piloto. | Candidato | Disp. | Precisão | n | Acurácia | Macro F1 | ECE (bruto) | p50 ms | p95 ms | p99 ms | Carga s | RAM pico MiB | GPU pico MiB | Erros | Truncados | @@ -12,3 +50,25 @@ Gerado por `infra/ds/collect.py`. Lote 1; latências sem os exemplos de aquecime | rizzo_flow | cuda | gguf-q8_0 | 1127 | 83.3% | 86.5% | 0.058 | 112.3 | 179.3 | 183.5 | 5.3 | 4528 | 5115 | 0 | 0 | | semif | cuda | bfloat16 | 1127 | 85.8% | 88.1% | 0.055 | 117.2 | 184.2 | 186.8 | 7.9 | 9030 | 8513 | 0 | 0 | + +Parâmetros completos por candidato e dispositivo, incluindo ECE e NLL em validação cruzada, estão em [`config/calibration.json`](../../config/calibration.json). As predições, com a distribuição completa, estão em `predictions--.jsonl`. + +## Metodologia + +**Dados.** A partição de calibração do conjunto congelado `d1-v1` inteira, sem amostragem: 1.127 exemplos de 94 lotes de geração, com os 43 rótulos. São 120 pedidos fora de escopo, 114 exemplos em que a intenção correta não está entre as opções (rótulo `sem_correspondencia`) e opções por pergunta distribuídas entre 4, 8, 12 e 16. A partição é disjunta da de desenvolvimento e da de teste por lote de geração. + +**Execução.** A mesma da avaliação: contrato `jev-compat-v1`, lote 1, precisão nativa de cada candidato e inferência isolada de fontes externas. GPU A10 em sa-saopaulo-1 para os quatro; CPU em us-chicago-1 para Laya e GLiNER. + +**Calibração (temperature scaling).** As probabilidades de cada candidato são reescaladas por uma temperatura T (p ∝ p^(1/T)), escolhida para minimizar a log-verossimilhança negativa na partição. A temperatura só é aceita se reduzir o ECE em validação cruzada de 5 dobras separadas por lote; as quatro foram aceitas. T > 1 suaviza modelos confiantes demais (Laya, SemIf, Rizzo); T < 1 acentua os pouco confiantes (GLiNER). + +**Limiar de abstenção.** Sobre as probabilidades calibradas, o candidato decide quando a maior probabilidade passa do limiar e, abaixo dele, o orquestrador pede esclarecimento. O limiar escolhido é o menor que mantém em até 2% do total as rotas erradas com confiança alta, o que maximiza a cobertura sob esse risco (critério pré-registrado, [Q5](../../docs/04-decisoes-de-projeto.md#q5--critérios-de-sucesso-pré-registrados)). + +**Uso no teste final.** Temperatura e limiar ficam congelados em `config/frozen.json`. O teste final aplica exatamente esses valores, e nenhum parâmetro é ajustado olhando o teste. + +## Conclusão + +1. **Calibrar vale a pena.** A temperatura corrige o excesso de confiança e deixa as probabilidades utilizáveis como sinal de decisão, principalmente no Laya (ECE de 0,33 para 0,08). +2. **Os modelos de 4B são os únicos com abstenção útil.** Com risco ≤ 2%, SemIf e Rizzo Flow resolvem metade ou mais dos casos com cerca de 96% de acerto; o resto vai para desambiguação. Laya e GLiNER teriam de desambiguar quase tudo. +3. **O critério de risco H2 não é atingido por nenhum candidato.** A meta de 80% de cobertura com 2% de risco ficou acima do que os modelos entregam neste domínio sintético. O mais próximo, o SemIf, precisa de cerca de 6% de risco para chegar lá. +4. **Perfis diferentes de erro.** O SemIf reconhece melhor os pedidos sem correspondência; o Rizzo Flow erra menos marcando pedidos válidos como `sem_correspondencia`; o GLiNER abstém demais (226 pedidos válidos) e o Laya de menos. +5. **Próximo passo.** O teste final (1.744 exemplos, três repetições) confirma esses números com intervalos de confiança e cortes por perfil e por número de opções. diff --git a/reports/calibration/chart-acuracia.svg b/reports/calibration/chart-acuracia.svg new file mode 100644 index 0000000..4cb4413 --- /dev/null +++ b/reports/calibration/chart-acuracia.svg @@ -0,0 +1,251 @@ + + + + + + + + 2026-10-07T22:03:02.533983 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 20 + + + + + + + + + + + + + 40 + + + + + + + + + + + + + 60 + + + + + + + + + + + + + 80 + + + + + + + + + + + + + 100 + + + + Acurácia (%) + + + + + + + + + + + + + + Laya + + + + + + + + + + GLiNER + + + + + + + + + + Rizzo Flow + + + + + + + + + + SemIf + + + + + + + + + + + + + + + + + + + + + + + 50.8% + + + 54.5% + + + 83.3% + + + 85.8% + + + Acurácia na partição de calibração (1.127 exemplos, GPU) + + + + + + + + + diff --git a/reports/calibration/chart-cobertura-risco.svg b/reports/calibration/chart-cobertura-risco.svg new file mode 100644 index 0000000..3d4506b --- /dev/null +++ b/reports/calibration/chart-cobertura-risco.svg @@ -0,0 +1,814 @@ + + + + + + + + 2026-10-07T22:03:03.016715 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 4 + + + + + + + + + + + + + 6 + + + + + + + + + + + + + 8 + + + + + + + + + + + + + 10 + + + + + + + + + + + + + 12 + + + + + + + + + + + + + 14 + + + + + + + + + + + + + 16 + + + + Rota errada com confiança alta (% do total) + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 20 + + + + + + + + + + + + + 40 + + + + + + + + + + + + + 60 + + + + + + + + + + + + + 80 + + + + + + + + + + + + + 100 + + + + Cobertura (% decidido sem desambiguar) + + + + + + + + + + + + + + + + + + + + + + + + + + + + risco máx. 2% + + + meta de cobertura 80% + + + + + + + + + + + + + + SemIf: 60% de cobertura + + + + + + + + + + + + + + Rizzo Flow: 48% de cobertura + + + + + + + + + + + + + + Laya: 5% de cobertura + + + + + + + + + + + + + + GLiNER: 12% de cobertura + + + Cobertura × risco ao variar o limiar de confiança (GPU) + + + + + + + + + diff --git a/reports/calibration/chart-ece.svg b/reports/calibration/chart-ece.svg new file mode 100644 index 0000000..65efdcf --- /dev/null +++ b/reports/calibration/chart-ece.svg @@ -0,0 +1,380 @@ + + + + + + + + 2026-10-07T22:03:02.608079 + image/svg+xml + + + Matplotlib v3.10.7, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.00 + + + + + + + + + + + + + 0.05 + + + + + + + + + + + + + 0.10 + + + + + + + + + + + + + 0.15 + + + + + + + + + + + + + 0.20 + + + + + + + + + + + + + 0.25 + + + + + + + + + + + + + 0.30 + + + + + + + + + + + + + 0.35 + + + + + + + + + + + + + 0.40 + + + + ECE — erro de calibração esperado (menor é melhor) + + + + + + + + + + + + + + SemIf + + + + + + + + + + Rizzo Flow + + + + + + + + + + GLiNER + + + + + + + + + + Laya + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.055 + + + 0.022 + + + + + + + + + + + + + 0.058 + + + 0.021 + + + + + + + + + + + + + 0.077 + + + 0.063 + + + + + + + + + + + + + 0.326 + + + 0.075 + + + Calibração das probabilidades: antes × depois + + + + + + + + + Bruto + + + + + + + + Calibrado (temperatura) + + + + + + + + + +