mozak.tech Engenharia de IA Corporativa 47%

Parte II — Especialização de Modelos: Dados, Ajuste e Avaliação

2.2 — Preparação e Validação de Conjuntos de Dados para Treinamento (Datasets)

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 balanceado

Por 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 validos

Aprofundamento 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.60

Exemplos 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_pessoal

Padrõ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 final
⚗ Laboratório prático — mozak.tech
Se 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.