Objetivo da Aula
Ao concluir esta aula, você será capaz de:
Implementar balancear_dataset(exemplos, max_ratio) por categoria
Implementar validar_dataset(exemplos) com verificações de PII, tamanho e truncamento
Exportar dataset no formato JSONL compatível com OpenAI fine-tuning
Completar os 2 TODOs do starter Python
Calcular custo estimado de fine-tuning baseado no dataset
Por que isso importa
Fundamento: Dataset como Produto
No fine-tuning, o modelo aprende os padrões do dataset. Se 90% dos exemplos são de um tipo e 10% de outro, o modelo priorizará o tipo majoritário — viés de distribuição, não de lógica. PII no dataset é duplamente problemático: viola LGPD na coleta e pode vazar em outputs do modelo (que aprendeu a reproduzir o padrão). Exemplos truncados ensinam o modelo a truncar respostas. O formato padrão é JSONL de mensagens system/user/assistant: cada linha é um exemplo completo de conversa. A qualidade do modelo de fine-tuning é limitada pela qualidade do dataset — "garbage in, garbage out" com custo de treino por cima.
Dataset desbalanceado produz modelo com viés: 90% dos exemplos são sobre “o que é RAG?” → modelo fica excelente em RAG mas medíocre em todo o resto. Dataset com PII cria risco legal (LGPD) e de segurança. Dataset com exemplos truncados (“…”) ensina o modelo a truncar respostas.
Coleta de dados é onde a maioria dos projetos de fine-tuning falha — não no treinamento.
Conceitos Fundamentais
TODO 1: balancear_dataset(exemplos, max_ratio=3.0)
def balancear_dataset(exemplos: list[ExemploFT], max_ratio: float = 3.0) -> list[ExemploFT]:
# 1. Agrupar por categoria
por_categoria: dict[str, list[ExemploFT]] = {}
sem_categoria: list[ExemploFT] = []
for ex in exemplos:
if ex.categoria:
por_categoria.setdefault(ex.categoria, []).append(ex)
else:
sem_categoria.append(ex)
if not por_categoria:
return exemplos # sem categorias, sem balanceamento
# 2. Encontrar menor categoria
min_count = min(len(v) for v in por_categoria.values())
limite_por_categoria = int(min_count * max_ratio)
print(f" Balanceando: menor categoria={min_count}, limite={limite_por_categoria} (ratio {max_ratio}x)")
# 3. Limitar cada categoria ao limite
balanceado = []
for categoria, exemplos_cat in por_categoria.items():
selecionados = exemplos_cat[:limite_por_categoria]
descartados = len(exemplos_cat) - len(selecionados)
if descartados > 0:
print(f" [{categoria}]: {len(exemplos_cat)} → {len(selecionados)} (-{descartados})")
balanceado.extend(selecionados)
# Incluir exemplos sem categoria
balanceado.extend(sem_categoria)
return balanceadoPor que max_ratio=3.0? A menor categoria pode ter 5 exemplos e a maior pode ter 100. Com ratio 3.0, a maior terá no máximo 15 exemplos — ainda 3x maior, mas sem dominar o treinamento.
TODO 2: validar_dataset(exemplos)
import re
def validar_dataset(exemplos: list[ExemploFT]) -> list[ExemploFT]:
# Padrões de PII
PII_PATTERNS = {
'cpf': r'\d{3}[\.-]?\d{3}[\.-]?\d{3}[-]?\d{2}',
'email_pessoal': r'\b[a-zA-Z0-9._%+-]+@(gmail|hotmail|yahoo|outlook)\.(com|br)\b',
'telefone': r'(\+55|0)?\s*\(?\d{2}\)?\s*9?\d{4}[-\s]?\d{4}',
}
validos = []
descartados = {'curto': 0, 'longo': 0, 'truncado': 0, 'pii': 0}
for ex in exemplos:
# 1. Tamanho mínimo/máximo
palavras_user = len(ex.usuario.split())
chars_output = len(ex.assistente)
if palavras_user < 3:
descartados['curto'] += 1
continue
if chars_output > 4000:
descartados['longo'] += 1
continue
# 2. Verificar truncamento (não termina com "...")
if ex.assistente.rstrip().endswith('...') or ex.assistente.rstrip().endswith('…'):
descartados['truncado'] += 1
continue
# 3. Verificar PII
texto_completo = f"{ex.usuario} {ex.assistente}"
tem_pii = any(re.search(p, texto_completo) for p in PII_PATTERNS.values())
if tem_pii:
descartados['pii'] += 1
print(f" [PII] Removido: '{ex.usuario[:40]}'")
continue
validos.append(ex)
print(f"\n Validação: {len(validos)} válidos | {descartados}")
return validosAprofundamento Técnico
Formato JSONL OpenAI Fine-Tuning
{"messages": [{"role": "system", "content": "Você é..."}, {"role": "user", "content": "Pergunta"}, {"role": "assistant", "content": "Resposta"}]}
{"messages": [{"role": "system", "content": "Você é..."}, {"role": "user", "content": "Pergunta 2"}, {"role": "assistant", "content": "Resposta 2"}]}Cada linha é um exemplo completo. O campo messages segue o formato de chat completions.
Estimativa de Custo
def estimar_tokens(exemplos: list[ExemploFT]) -> dict:
total_chars = sum(len(ex.sistema) + len(ex.usuario) + len(ex.assistente) for ex in exemplos)
total_tokens = total_chars // 4 # ~4 chars/token
custo = total_tokens * 0.000008 # gpt-4o-mini: $0.008/1K tokens
return {
'exemplos': len(exemplos),
'tokens_estimados': total_tokens,
'custo_usd_estimado': round(custo, 4),
}
# 1000 exemplos × 300 chars médio = 300K chars = 75K tokens = ~$0.60Exemplos Anotados
Exemplo 1: Dataset Desbalanceado
exemplos_com_categoria = [
ExemploFT(..., categoria='rag'), # × 50
ExemploFT(..., categoria='agentes'), # × 5
ExemploFT(..., categoria='finetune'), # × 30
]
balancear_dataset(exemplos_com_categoria, max_ratio=3.0)
# min_count = 5 (agentes)
# limite = 15 por categoria
# rag: 50 → 15 (-35)
# agentes: 5 → 5 (sem alteração)
# finetune: 30 → 15 (-15)
# Total: 50 exemplos (antes: 85)Exemplo 2: Detecção de PII
ex_com_pii = ExemploFT(
sistema="Você é um assistente",
usuario="Meu e-mail é joao@gmail.com, pode me ajudar?",
assistente="Claro, João!",
)
# → REMOVIDO: contém email_pessoalPadrões e Armadilhas
Padrões
Padrão 1: Seed no shuffle para reprodutibilidade
import random
random.seed(42)
random.shuffle(exemplos_por_categoria)Padrão 2: Relatório de validação detalhado
Validação: 847 válidos | {'curto': 12, 'longo': 3, 'truncado': 8, 'pii': 2}Armadilhas
⚠️ Armadilha 1: CPF sem formatação não é detectado por regex com separadores
# ERRADO: só detecta "123.456.789-09"
r'\d{3}\.\d{3}\.\d{3}-\d{2}'
# CORRETO: detecta "12345678909" também
r'\d{3}[\.-]?\d{3}[\.-]?\d{3}[-]?\d{2}'⚠️ Armadilha 2: max_ratio < 1.0 elimina todos
limite_por_categoria = max(min_count, int(min_count * max_ratio)) # mínimo de min_count⚠️ Armadilha 3: Truncamento em meio de palavra
# "...ao" não é truncamento, "..." ao final é
if ex.assistente.rstrip().endswith('...'): # rstrip() remove whitespace finalSe não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
TODO 1 — balancear_dataset(exemplos, max_ratio): agrupar por categoria, encontrar menor, limitar cada uma ao min_count * max_ratio.
TODO 2 — validar_dataset(exemplos): regex para CPF/email/telefone; rejeitar truncados (endswith('...')); contar descartados por motivo.
Agora você está pronto para o lab.