Como montar o conjunto de avaliação que decide de verdade — amostragem, rotulagem, split e o erro assimétrico que move o corte
Toda discussão sobre qual modelo usar termina no mesmo lugar: alguém abre um leaderboard.
O problema é que aquele número foi medido em tarefas que não são as suas, com prompts que não são os seus, por um harness que não é o seu, em um domínio que não é o seu. Ele responde uma pergunta interessante e irrelevante.
A pergunta relevante — "este modelo resolve o meu problema com qualidade suficiente e custo aceitável?" — só tem uma fonte de resposta: um conjunto de casos do seu domínio, rotulado por gente que entende do assunto.
A boa notícia é que esse conjunto é muito menor do que as pessoas imaginam. Quarenta casos bem escolhidos separam modelos melhor do que quatro mil casos amostrados ao acaso. Este artigo é o protocolo para montar isso em um dia.
dev e test no dia zero. Ajustar o corte olhando o test corrompe o número.choice pede matriz de confusão, noul pede calibração, score pede erro absoluto.Antes de coletar qualquer caso, escreva a frase que o eval precisa responder. Ela tem uma forma fixa:
Vamos usar
<modelo/configuração>em<fluxo>se<métrica>ficar acima de<limiar>com custo por sucesso abaixo de<valor>.
Se você não consegue preencher os colchetes, o problema não é falta de dados. É que a decisão ainda não existe, e nenhum eval vai criá-la.
Exemplo concreto, do fluxo de triagem de tickets que uso como caso ao longo desta série:
Vamos usar
luna@mediumpara triagem automática se o recall de tickets urgentes ficar acima de 0,92 e a acurácia de departamento acima de 0,85, com custo por sucesso abaixo de US$ 0,02.
Repare que há dois limiares de qualidade e um de custo, e que o recall de urgência é mais exigente que a acurácia de departamento. Isso não é arbitrário: perder um ticket urgente custa mais do que mandá-lo para o time errado. Voltaremos a isso.
Amostra aleatória de produção tem um defeito previsível: ela é dominada pelo caso fácil. Se 80% dos seus tickets são triviais, 80% do seu eval será trivial, e todos os modelos vão parecer equivalentes.
Você quer o contrário: densidade nos lugares onde os modelos se separam.
| Estrato | Quantos | Por que existe |
|---|---|---|
| comum | 10 | garante que o básico não regrediu |
| raro mas importante | 8 | o caso de alto custo que aparece pouco |
| ambíguo | 10 | onde os modelos de fato divergem |
| multilíngue / código-misto | 6 | onde modelos monolíngues colapsam |
| adversarial | 6 | injeção, texto hostil, formatação quebrada |
| fora de escopo | 4 | deve produzir abstenção, não palpite |
Quarenta e quatro casos. Um dia de trabalho, incluindo rotulagem.
Para achar os ambíguos sem ler mil tickets, use o próprio modelo como minerador — não como rotulador:
def mine_ambiguous(candidates, client, questions, n=10):
"""Casos onde o modelo tem baixa confiança são candidatos a ambíguos."""
scored = []
for c in candidates:
r = client.evaluate(c.state, questions)
probs = r["answers"]["department"]["probabilities"]
top2 = sorted(probs.values(), reverse=True)[:2]
margin = top2[0] - top2[1] # margem pequena = disputa real
scored.append((margin, c))
scored.sort(key=lambda x: x[0])
return [c for _, c in scored[:n]]
O modelo aponta onde olhar. Quem rotula é o humano. Se você aceitar o rótulo do modelo, seu eval vira um teste de consistência do modelo consigo mesmo — e ele passa sempre.
Esta é a etapa que times pulam, e a que mais muda o resultado.
Rotule os casos ambíguos com duas pessoas independentes. Não para ter redundância, mas para descobrir o teto.
from statistics import mean
def human_ceiling(labels_a: list[str], labels_b: list[str]) -> dict:
"""Concordância entre dois rotuladores = teto realista do modelo."""
agree = [a == b for a, b in zip(labels_a, labels_b)]
return {
"concordancia": mean(agree),
"casos_em_disputa": [i for i, ok in enumerate(agree) if not ok],
}
Se dois especialistas concordam em 84% dos casos, um modelo com 86% de acurácia não é "bom" nem "ruim" — ele está no nível do desacordo humano, e a diferença já não é estatisticamente interessante. Comparar 86% com 88% nesse regime é ruído.
Mais importante: os casos em disputa quase sempre revelam que a pergunta está mal definida. Se duas pessoas não concordam se um ticket é "technical" ou "billing", o modelo também não vai. A correção é no enum, não no prompt.
# antes: categorias que se sobrepõem
criteria = {
"billing": "cobrança e pagamentos",
"technical": "problemas técnicos", # e uma falha no gateway de pagamento?
}
# depois: fronteira explícita
criteria = {
"billing": "valores, faturas, reembolsos e disputas de cobrança",
"technical": "falhas de sistema, incluindo falhas no processamento de pagamento",
}
Uma hora reescrevendo critérios costuma render mais que uma semana ajustando prompt.
Separe dev e test antes de rodar qualquer modelo. Congele o test.
import hashlib
def split(case_id: str, test_ratio: float = 0.4) -> str:
"""Split determinístico por hash: reprodutível e estável a novos casos."""
h = int(hashlib.sha256(case_id.encode()).hexdigest()[:8], 16)
return "test" if (h % 100) < test_ratio * 100 else "dev"
Hash em vez de random.shuffle porque adicionar casos novos não deve reembaralhar os antigos. Um caso nasce em um lado e fica lá.
A regra de uso é simples e constantemente violada: cortes, prompts e nível de esforço são ajustados olhando só o dev. O test é consultado uma vez, quando a decisão já está tomada, para confirmar. Se você rodou o test cinco vezes ajustando entre elas, ele virou dev e você não tem mais estimativa honesta.
Acurácia média é a métrica que mais esconde. Cada tipo de pergunta pede a sua.
choice — matriz de confusão, não acurácia.
from collections import Counter
def confusion(pairs: list[tuple[str, str]]) -> dict:
"""pairs: (rótulo_humano, predição)."""
matrix = Counter(pairs)
labels = sorted({l for p in pairs for l in p})
per_class = {}
for label in labels:
tp = matrix[(label, label)]
fn = sum(matrix[(label, p)] for p in labels if p != label)
fp = sum(matrix[(t, label)] for t in labels if t != label)
per_class[label] = {
"recall": tp / (tp + fn) if tp + fn else None,
"precisao": tp / (tp + fp) if tp + fp else None,
"suporte": tp + fn,
}
return per_class
A média esconde o caso pequeno. Uma classe com 4 exemplos e recall 0,25 desaparece numa acurácia global de 0,89 — e pode ser justamente a classe cara.
noul — calibração, não só acerto.
Uma probabilidade só serve se ela significa alguma coisa. Se o modelo diz 0,9 e acerta 60% das vezes, sua policy de corte está construída sobre areia.
def calibration_bins(preds: list[float], truths: list[bool], bins: int = 5) -> list[dict]:
out = []
for b in range(bins):
lo, hi = b / bins, (b + 1) / bins
idx = [i for i, p in enumerate(preds) if lo <= p < hi or (b == bins - 1 and p == 1.0)]
if not idx:
continue
out.append({
"faixa": f"{lo:.1f}–{hi:.1f}",
"n": len(idx),
"confianca_media": mean(preds[i] for i in idx),
"acerto_real": mean(truths[i] for i in idx),
})
return out
Uma tabela de cinco linhas resolve. Se confianca_media e acerto_real divergem sistematicamente, o modelo é confiante demais ou tímido demais, e você precisa de ajuste de temperatura antes de definir qualquer corte.
score — erro absoluto e direção do erro.
def score_error(preds: list[float], truths: list[float]) -> dict:
errs = [p - t for p, t in zip(preds, truths)]
return {
"mae": mean(abs(e) for e in errs),
"vies": mean(errs), # >0: superestima urgência
"subestimou_grave": sum(1 for e in errs if e < -1.0),
}
vies importa tanto quanto mae. Um modelo que erra 0,4 para cima é operacionalmente muito diferente de um que erra 0,4 para baixo, mesmo com MAE idêntico.
Aqui está a parte que transforma um relatório em decisão.
Perder um ticket urgente e revisar um falso positivo não custam a mesma coisa. Coloque números nisso — estimativas grosseiras acordadas com o negócio já bastam.
CUSTO_FN = 120.0 # urgente não detectado: SLA violado, cliente em risco
CUSTO_FP = 4.0 # falso alarme: alguém revisa por engano
def melhor_corte(preds: list[float], truths: list[bool]) -> dict:
melhor = None
for corte in [i / 100 for i in range(5, 100, 5)]:
fn = sum(1 for p, t in zip(preds, truths) if t and p < corte)
fp = sum(1 for p, t in zip(preds, truths) if not t and p >= corte)
custo = fn * CUSTO_FN + fp * CUSTO_FP
if melhor is None or custo < melhor["custo"]:
melhor = {"corte": corte, "fn": fn, "fp": fp, "custo": custo}
return melhor
Com razão de 30 para 1 entre os custos, o corte ótimo desce bem abaixo de 0,5 — o sistema passa a preferir errar para o lado do alarme. Isso não é um ajuste de modelo; é uma decisão de produto, e ela pertence a código versionado, não a um número escolhido no olho.
Rodou, mediu, decidiu. Agora grave.
{
"dataset_version": "triage-v1",
"n_dev": 26,
"n_test": 18,
"model": "gpt-6-luna",
"effort": "medium",
"measured_at": "2026-09-22",
"human_ceiling": 0.84,
"metrics": {
"urgent_recall": 0.94,
"department_accuracy": 0.86,
"urgency_mae": 0.31,
"cost_per_success_usd": 0.0138
},
"threshold": {"urgent": 0.35}
}
Esse arquivo vira o alvo dos testes de regressão. Quando alguém mexer no prompt, trocar o modelo ou subir o esforço, a comparação é contra ele — não contra a memória de quem estava na reunião.
test. Cada consulta ao test durante o ajuste tira um pouco da honestidade da estimativa. Depois de cinco, não sobra nada.O leaderboard responde uma pergunta boa sobre o domínio de outra pessoa. Seu eval responde uma pergunta chata sobre o seu — e é a chata que decide o deploy.
Quarenta casos bem estratificados, rotulados por duas pessoas, divididos por hash e medidos por primitivo custam uma tarde. Esse é o preço de trocar opinião por evidência.
E o efeito colateral mais útil raramente é a escolha do modelo. É descobrir, na hora de rotular, que dois especialistas discordam sobre a sua própria taxonomia. Essa correção melhora o sistema com qualquer modelo que você escolher depois.
Os números de exemplo — tamanhos de estrato, custos de erro e limiares — ilustram o método. Os seus saem do seu domínio e das suas conversas com o negócio.
Produtos gratuitos e pagos para transformar ideias em uma base que você consegue executar.
13 produtos disponíveisContinue explorando tópicos similares
Usar um LLM para avaliar outro é barato, rápido e escalável. Também é enviesado de seis formas previsíveis. A boa notícia: cinco delas têm correção mecânica.
Preço por token é o número do fornecedor. Custo por tarefa concluída é o seu. Entre os dois existem retries, tarefas que falham, contexto que cresce e um nível de esforço que ninguém varre.
OpenAI cortou preço pela metade, Anthropic lançou no mesmo dia e os modelos chineses lideram o ranking aberto. O problema: os três grupos quase não compartilham benchmarks. Este artigo lê os números que existem e nomeia os que faltam.
Checklist de 47 pontos para encontrar bugs, riscos de segurança e problemas de performance antes do lançamento.
Templates testados em produção, usados por desenvolvedores. Economize semanas de setup no seu próximo projeto.