Skip to content

Machine Learning

Esta página reúne os fundamentos de aprendizado de máquina supervisionado, com foco em classificação, usando Python e a biblioteca scikit-learn. Para a visão geral de IA (história, subáreas) ver I.A.: conceitos e história; para LLMs, que são um caso particular de modelo treinado em escala, ver LLM.

O que é classificação

Definição: Machine Learning (aprendizado de máquina)

Abordagem em que, em vez de escrever regras explícitas, fornecemos exemplos ao programa e deixamos um algoritmo descobrir o padrão. O programa "aprende" com a experiência passada para tomar decisões sobre casos novos.

Definição: Classificação

Tarefa de aprendizado supervisionado em que o modelo atribui cada item a uma categoria (classe) a partir de suas características. Exemplos: e-mail é spam ou não; cliente vai pagar a dívida ou não; vídeo tem conteúdo permitido ou não; funcionário vai pedir demissão ou não.

O computador não sabe o que "spam" significa, mas entende números. Por isso o problema é reduzido a codificar a resposta como número (ex.: 1 = spam, 0 = não spam) e ensinar o programa com exemplos já classificados — da mesma forma que uma pessoa reconhece spam "batendo o olho" porque já viu milhares de e-mails.

Os ingredientes de um problema de classificação

Elemento O que é Exemplo (classificar animais)
Item (amostra) Cada elemento a ser classificado Um animal
Características (features) Atributos observáveis do item, em geral codificados como número [é gordinho?, tem perna curta?, faz "oinc"?] → [1, 1, 0]
Marcação (label, rótulo) A classe correta já conhecida de um item de treino 1 = porco, -1 = cachorro
Dados de treino Itens com características + marcações 3 porcos e 3 cachorros já classificados
Modelo O que o algoritmo aprende dos dados de treino Objeto treinado que sabe prever
Item novo Item sem marcação, a ser previsto Um animal "misterioso"

A escolha das características é o que mais importa

O modelo só enxerga o que for representado nas características. Para classificar e-mails poderiam ser: tamanho, palavras que aparecem, horário de envio, se o remetente é conhecido, se é a primeira vez que ele escreve. Escolher características que de fato diferenciam as classes é parte central do trabalho (e a mesma lógica vale para qualquer problema de classificação).

Definição: Marcação binária

Quando só há duas classes, qualquer par de valores serve (0/1, -1/1, A/B). Usar 1 e -1 é uma convenção que reforça que uma classe é o oposto da outra; o importante é manter a mesma convenção do treino ao teste.

Treinando e prevendo com scikit-learn

O scikit-learn (módulo sklearn) é a biblioteca de ML clássico mais usada em Python. O ciclo é sempre o mesmo, independente do algoritmo:

flowchart LR
    A["Dados + marcações"] --> B["Criar modelo"]
    B --> C["fit(dados, marcações)<br/>treinar"]
    C --> D["predict(itens novos)<br/>prever"]
    D --> E["Comparar com a resposta<br/>esperada"]

Instalação (o pip já vem com o Python 3):

pip install scikit-learn numpy scipy

Exemplo completo com o primeiro algoritmo do livro, o Naive Bayes multinomial (MultinomialNB):

from sklearn.naive_bayes import MultinomialNB

# [é gordinho?, tem perna curta?, faz "auau"?]
porco1 = [1, 1, 0]
porco2 = [1, 1, 0]
porco3 = [1, 1, 0]
cachorro1 = [1, 1, 1]
cachorro2 = [0, 1, 1]
cachorro3 = [0, 1, 1]

dados = [porco1, porco2, porco3, cachorro1, cachorro2, cachorro3]
marcacoes = [1, 1, 1, -1, -1, -1]       # 1 = porco, -1 = cachorro

modelo = MultinomialNB()                 # 1. cria o modelo
modelo.fit(dados, marcacoes)             # 2. treina ("adequa-se" aos dados)

misterioso1 = [1, 1, 1]
misterioso2 = [1, 0, 0]
teste = [misterioso1, misterioso2]       # lista de itens, mesmo que seja um só
print(modelo.predict(teste))             # [-1  1] -> cachorro, porco

Pontos de atenção:

  • fit(X, y) treina o modelo; predict(X) devolve uma classe para cada item recebido.
  • predict sempre recebe uma lista de itens (lista de listas / matriz 2D), mesmo quando se quer prever um único item. Passar uma lista simples (1D) está obsoleto e gera erro nas versões atuais da biblioteca.
  • Criar o modelo não treina nada — só o fit faz o modelo aprender.
  • O mesmo código serve para qualquer problema de duas classes (spam/não spam, adimplente/inadimplente): só mudam as características e as marcações.

Definição: Naive Bayes

Família de algoritmos de classificação baseada no teorema de Bayes: calcula a probabilidade de cada classe dado o conjunto de características e escolhe a mais provável. É chamado de naive ("ingênuo") porque assume que as características são independentes entre si — uma simplificação que raramente é verdadeira, mas funciona surpreendentemente bem na prática, especialmente com texto. O MultinomialNB é a variante para contagens/frequências (como ocorrências de palavras). Os detalhes matemáticos aparecem mais adiante na página.

Avaliando o modelo: taxa de acerto

Não basta o código rodar: é preciso medir quão bom o modelo é com dados que ele não viu no treino. Cria-se um conjunto de teste com itens cuja resposta correta já se conhece (as marcacoes_teste) e compara-se com o que o modelo previu.

marcacoes_teste = [-1, 1, -1]            # respostas corretas conhecidas
resultado = modelo.predict([[1, 1, 1], [1, 0, 0], [0, 0, 1]])

acertos = (resultado == marcacoes_teste).sum()
taxa_de_acerto = 100.0 * acertos / len(marcacoes_teste)
print(taxa_de_acerto)                    # 100.0

O livro usa um truque equivalente: subtrair resultado - marcacoes_teste — com marcações 1/-1, o resultado é 0 quando acerta e ±2 quando erra (1-(-1)=2, -1-1=-2). Comparar diretamente com == é mais claro e funciona com quaisquer rótulos.

Definição: Taxa de acerto (accuracy, acurácia)

Percentual de itens de teste que o modelo classificou corretamente: acertos / total de itens × 100. É a métrica mais simples de qualidade de um classificador. Se um dos três itens de teste fosse previsto errado, a taxa seria 66,7%.

Dois recados importantes para a entrevista:

  1. 100% é raro no mundo real. Um animal atípico (um porco que "late", por exemplo) já é suficiente para o modelo errar. Todo classificador erra; o trabalho é medir esse erro e decidir se ele é aceitável para aquele contexto.
  2. Erro tem tipos. Classificar um gato como "não gato" e classificar outra coisa como "gato" são erros diferentes, com custos diferentes (ex.: mandar um e-mail legítimo para a caixa de spam é pior do que deixar passar um spam). A taxa de acerto sozinha esconde essa diferença.

Resumo do ciclo de classificação

  1. Representar cada item como um vetor de características numéricas.
  2. Marcar cada item de treino com a classe correta.
  3. Treinar: modelo.fit(dados, marcacoes).
  4. Prever itens novos: modelo.predict(teste).
  5. Medir a taxa de acerto contra marcações de teste conhecidas.

Um problema real: prever se o usuário vai comprar

O mesmo molde serve para o "mundo real" de uma aplicação web. Cada usuário é um item; as características são os comportamentos observados (visitou a página inicial? visitou a página "como funciona"? visitou a página de contato?); a marcação é o que queremos prever (comprou ou não). Outros problemas com a mesma forma: funcionário vai pedir demissão? aluno vai reprovar? cliente vai cancelar o plano?

Para que serve prever? Se o modelo indica que o usuário não vai comprar, a empresa pode agir — entrar em contato, tirar uma dúvida, descobrir se falta algum produto — em vez de só observar.

Os dados de histórico costumam vir em CSV (comma separated values, "valores separados por vírgula"), formato que qualquer planilha exporta. A primeira linha é o cabeçalho; cada linha seguinte é um usuário do passado:

home,como_funciona,contato,comprou
1,1,0,0
1,0,1,1
0,0,1,0

As três primeiras colunas são as características e a última é a marcação.

Definição: X e Y

Convenção universal em ML supervisionado: X é a matriz de características (o que conhecemos do item, uma linha por item) e Y é o vetor de marcações (o que queremos prever). Todo fit recebe (X, Y); todo predict recebe apenas X.

Carregando o CSV em X e Y

import csv

def carregar_acessos():
    X = []
    Y = []
    with open("acesso.csv", "r") as arquivo:
        leitor = csv.reader(arquivo)
        next(leitor)                      # pula o cabeçalho
        for home, como_funciona, contato, comprou in leitor:
            dado = [int(home), int(como_funciona), int(contato)]
            X.append(dado)
            Y.append(int(comprou))
    return X, Y

Dois cuidados que aparecem em qualquer carga de dados:

  • Descartar o cabeçalho (next(leitor)), senão o nome das colunas entra como se fosse um dado.
  • Converter os tipos. O módulo csv lê tudo como texto ('0', '1'); o modelo precisa de números, então converte-se com int() (ou float() para decimais).

A extração da variável dado é uma refatoração simples (extract variable) que deixa claro o significado do trecho. Nomes curtos (home, em vez de acessou_home) também reduzem o ruído quando o contexto já deixa óbvio do que se trata.

Com X e Y carregados, o restante é igual ao exemplo dos animais:

from sklearn.naive_bayes import MultinomialNB

X, Y = carregar_acessos()
modelo = MultinomialNB()
modelo.fit(X, Y)
print(modelo.predict([[1, 0, 1], [0, 1, 0]]))   # ex.: [1 0] -> o 1º compra, o 2º não

Treino e teste: nunca avalie com os dados do treino

Se o modelo for treinado com todos os 99 registros e depois avaliado com os mesmos 99, a taxa de acerto sai altíssima (93,9% no exemplo do livro) — mas esse número não diz nada sobre o mundo real. É como ensinar 90 animais a uma pessoa e depois pedir para ela classificar exatamente os mesmos animais: ela vai acertar quase tudo porque já os viu. O que importa é como o modelo se comporta diante de itens novos.

Definição: Conjunto de treino e conjunto de teste

Os dados rotulados são divididos em duas partes: treino (usado no fit, para o modelo aprender) e teste (usado só na avaliação, com itens que o modelo nunca viu). Uma divisão tradicional é 90% treino / 10% teste (80/20 também é comum).

X, Y = carregar_acessos()

treino_dados = X[:90]
treino_marcacoes = Y[:90]
teste_dados = X[-9:]
teste_marcacoes = Y[-9:]

modelo = MultinomialNB()
modelo.fit(treino_dados, treino_marcacoes)

resultado = modelo.predict(teste_dados)
acertos = sum(1 for previsto, real in zip(resultado, teste_marcacoes) if previsto == real)
taxa_de_acerto = 100.0 * acertos / len(teste_dados)
print(taxa_de_acerto)      # 88.89 no exemplo do livro (8 de 9)

Note que o denominador da taxa passa a ser o tamanho do teste, não o do conjunto inteiro. O resultado (≈89%) é menor que os 93,9% anteriores — e é o número honesto.

Vazamento de dados (data leakage)

Usar qualquer informação do conjunto de teste durante o treino, ou testar com os mesmos dados do treino, produz uma estimativa de qualidade irrealisticamente boa. Em entrevista, "separei treino e teste antes de qualquer ajuste" é uma resposta que mostra maturidade.

Um bom hábito é registrar cada experimento como comentário ou em um log (qual estratégia, qual taxa de acerto), para comparar abordagens depois:

# 1. 90% treino / 10% teste: 88.89%

Duas limitações ficam em aberto e são tratadas nas seções seguintes: com apenas 9 itens de teste, a taxa varia muito conforme quais itens caíram ali; e a taxa de acerto isolada não diz se 89% é bom ou ruim para aquele problema.

Variáveis categóricas e dummies

Até aqui toda característica era binária (0 ou 1). Dados reais têm colunas como "o que o usuário buscou" (algoritmos, java, ruby, ...). Texto solto o algoritmo não entende; é preciso convertê-lo em números.

Definição: Variável categórica

Variável que assume um valor dentre um conjunto finito de categorias (ex.: termo buscado, estado onde a pessoa está, tipo de plano). Diferente de uma variável binária (duas opções) ou numérica (quantidade, como horas trabalhadas ou preço).

A técnica é transformar a pergunta "qual foi a busca?" em uma pergunta de sim/não para cada categoria:

busca original busca_algoritmos busca_java busca_ruby
algoritmos 1 0 0
java 0 1 0
ruby 0 0 1

Definição: Dummies (one-hot encoding)

Colunas binárias criadas a partir de uma variável categórica — uma por categoria —, em que exatamente uma vale 1 por linha. São chamadas "dummies" (variáveis "de mentira") porque não estavam na coleta original: a informação foi perguntada de um jeito e é preenchida de outro. A coluna categórica original é descartada depois da conversão. O nome técnico mais usado em ML é one-hot encoding.

Com isso, tudo vira 0/1 de novo e o mesmo algoritmo já usado serve sem alterações.

Pandas: leitura e preparação de dados

Ler CSV "na unha" obriga a escrever uma função por arquivo, informando o tipo de cada coluna. O Pandas (Python Data Analysis Library) resolve isso e é a biblioteca padrão para análise de dados em Python.

pip install pandas

Definição: DataFrame

Estrutura de tabela do Pandas (linhas × colunas nomeadas), devolvida por pd.read_csv. Reconhece o cabeçalho, infere o tipo de cada coluna (int64, object para texto etc.) e oferece operações de seleção, filtro e transformação. Por convenção, abrevia-se o Pandas como pd e o DataFrame como df.

Operações essenciais:

Operação Código Observação
Ler CSV df = pd.read_csv("buscas.csv") Detecta o cabeçalho automaticamente
Uma coluna df["home"] Acessa por nome; df[0] dá KeyError
Várias colunas df[["home", "busca", "logado"]] Note os colchetes duplos (uma lista de nomes)
Gerar dummies pd.get_dummies(X_df) Converte só as colunas de texto; as numéricas ficam como estão
DataFrame → array df.values O scikit-learn aceita também DataFrames, mas .values devolve o array NumPy

Pipeline completo, com uma coluna categórica (busca):

import pandas as pd
from sklearn.naive_bayes import MultinomialNB

df = pd.read_csv("buscas.csv")              # colunas: home, busca, logado, comprou

X_df = df[["home", "busca", "logado"]]
Y_df = df["comprou"]

Xdummies_df = pd.get_dummies(X_df)          # busca -> busca_algoritmos, busca_java, busca_ruby
X = Xdummies_df.values
Y = Y_df.values                             # já é binária: não precisa de dummies

porcentagem_treino = 0.9
tamanho_de_treino = int(porcentagem_treino * len(Y))   # fatias exigem inteiro

treino_dados = X[:tamanho_de_treino]
treino_marcacoes = Y[:tamanho_de_treino]
teste_dados = X[tamanho_de_treino:]
teste_marcacoes = Y[tamanho_de_treino:]

modelo = MultinomialNB()
modelo.fit(treino_dados, treino_marcacoes)

resultado = modelo.predict(teste_dados)
taxa_de_acerto = 100.0 * (resultado == teste_marcacoes).mean()
print(taxa_de_acerto)                       # 82.0 no exemplo do livro (1.000 linhas)

Boas práticas vistas aqui:

  • Nomear variáveis pelo que elas são (X_df, Xdummies_df, X): deixa claro se é DataFrame ou array.
  • Parametrizar a proporção (porcentagem_treino = 0.9) e derivar o tamanho do teste do tamanho do treino, para mudar a divisão em um só lugar.
  • O tamanho de treino precisa ser inteiro para fatiar a lista (int(0.9 * len(Y))).
  • Código de treino, predição e cálculo da taxa de acerto não muda quando muda o problema — só a preparação dos dados (X, Y) muda. Vale extrair essa parte para funções reutilizáveis.

Com dados reais (900 treino / 100 teste), o modelo acertou 82% usando apenas três características do comportamento do usuário. A pergunta natural é: 82% é bom? Sem um ponto de comparação, não há como dizer — é exatamente o que a próxima seção resolve.

82% é bom? Compare com o algoritmo base

Uma taxa de acerto isolada não diz nada: 82% pode ser excelente ou péssimo. É preciso uma referência de comparação, e a mais simples possível é o classificador "burro".

Definição: Algoritmo base (baseline)

Classificador sem nenhuma inteligência, que responde sempre a mesma classe — a mais frequente — para qualquer item, sem olhar as características. Serve como piso de qualidade: um modelo que não supera o baseline não está agregando valor e deveria ser descartado. No scikit-learn existe pronto: DummyClassifier(strategy="most_frequent").

No exemplo do livro, 832 de 1.000 usuários compraram. Chutar "comprou" para todo mundo acerta 83,2%; chutar "não comprou" acerta só 16,8%. O baseline usa a maior das duas taxas (83,2%) — ou seja, o modelo "inteligente", com 82%, ficou abaixo de quem não faz análise nenhuma.

Isso expõe o perigo de dados desbalanceados: quando 83% dos itens são de uma classe, a taxa de acerto alta é fácil de obter sem aprender nada.

from collections import Counter

# baseline: acerta a classe mais frequente em Y
acerto_base = max(Counter(Y).values())
taxa_de_acerto_base = 100.0 * acerto_base / len(Y)

Counter (do módulo collections) devolve um dicionário com a contagem de cada valor (Counter({'sim': 832, 'nao': 168})). Como a implementação só pega o maior valor, ela funciona com quaisquer rótulos (0/1, sim/nao, spam/ham), sem depender de saber quais são.

Compare sempre com os mesmos dados

O baseline deve ser calculado sobre o mesmo conjunto de teste usado na avaliação do modelo — não sobre todos os dados. Comparar o modelo (avaliado em 10% dos dados) com o baseline (calculado em 100%) não é justo. Se o teste for sorteado aleatoriamente, o baseline usa exatamente o mesmo sorteio.

resultado = modelo.predict(teste_dados)
taxa_de_acerto = 100.0 * (resultado == teste_marcacoes).mean()

acerto_base = max(Counter(teste_marcacoes).values())
taxa_de_acerto_base = 100.0 * acerto_base / len(teste_marcacoes)

print(f"Algoritmo: {taxa_de_acerto:.2f}%  |  Base: {taxa_de_acerto_base:.2f}%")
# no livro, com o mesmo conjunto de teste: 82,00% x 82,00% — o modelo só empata

Dois critérios para dizer que um modelo é bom

  1. Supera o algoritmo base nos mesmos dados (critério mínimo).
  2. O número faz sentido para o negócio. Um detector de terremotos com 51% de acerto pode até vencer o baseline, mas metade dos alertas seria inútil. Um modelo para identificar alunos com dificuldade pode acertar bastante e, ainda assim, gastar o tempo dos professores com quem não precisava de ajuda. É preciso decidir qual tipo de erro custa mais (retomado na seção de matriz de confusão e métricas mais adiante).

Marcações em texto (sim/não) e dados genéricos

Nem sempre a marcação vem como 0/1; é comum ser sim/nao, true/false etc. O scikit-learn treina normalmente com marcações em texto, mas o código ao redor precisa ser ajustado:

Problema Solução
resultado - teste_marcacoes dá TypeError (não existe subtração de texto) Comparar com resultado == teste_marcacoes → vetor de True/False
Contar acertos sum(acertos) — em Python True vale 1 e False vale 0 (em NumPy, acertos.mean() já dá a proporção)
Contar classes sem fixar os valores (len(Y[Y == 'sim'])) Counter(Y): conta qualquer valor, sem hardcode

Filtros booleanos com NumPy/Pandas também ajudam a explorar os dados: Y == 1 gera uma máscara True/False; Y[Y == 1] devolve só os itens daquela classe; X[Y == 0] devolve as características de todos os usuários que não compraram.

Naive Bayes por trás dos panos

Entender o que o MultinomialNB faz ajuda a explicar, em entrevista, por que ele é rápido, simples e tão usado com texto.

Probabilidade e regras de decisão

Se, numa pesquisa com 100 pessoas, 70 acham que o candidato A ganha e 30 que é o B, as probabilidades são 70% e 30%. Dadas as probabilidades, é preciso uma regra de decisão para responder "quem vai ganhar?":

Regra Como decide Quando faz sentido
Maior probabilidade (maximum a posteriori, MAP) Escolhe sempre a classe mais provável Quando o objetivo é maximizar a taxa de acerto — a mais usada
Menor probabilidade Escolhe a classe menos provável Não faz sentido: errará quase sempre
Sorteio proporcional Sorteia um número de 1 a 100; cai em 1–70 → A, em 71–100 → B Quando se quer simular a distribuição real, e não só a classe vencedora

Definição: Maximum a posteriori (MAP)

Regra de decisão que, dadas as probabilidades de cada classe depois de observar as características (a posteriori), escolhe a classe de maior probabilidade. É o critério padrão dos classificadores Naive Bayes.

O ponto fraco do MAP puro: ele não distingue 70/30 de 99/1 ou 51/49 — sempre escolhe o maior, e se a classe majoritária vencer em todos os cenários, o modelo responderá sempre a mesma coisa (comportamento do algoritmo base, visto acima).

Probabilidade condicional

Definição: Probabilidade condicional

Probabilidade de um evento dado que outro ocorreu. Escreve-se P(Comprar | Rio de Janeiro) ("probabilidade de comprar, dado que o cliente é do Rio de Janeiro"). A barra | lê-se "dado que".

Exemplo do livro (site de imóveis): contando o histórico de clientes por estado, 68,5% dos clientes do Rio compraram e 27,5% dos de São Paulo. Com o MAP, um cliente do Rio é classificado como "vai comprar" e um de São Paulo como "não vai". Treinar, aqui, é literalmente contar o histórico e montar as tabelas de probabilidade.

Várias características: a suposição "ingênua"

Com duas características (estado e faixa de renda, acima/abaixo de R$ 5.000), a pergunta passa a ser P(Comprar | São Paulo, renda ≤ 5000). O Naive Bayes assume que as características são independentes entre si, dada a classe — por isso é "ingênuo" — e por isso consegue tratar cada uma separadamente e multiplicar:

flowchart LR
    A["Item novo<br/>(estado, renda)"] --> B["Prob. de cada característica<br/>por classe (tabelas do treino)"]
    B --> C["Multiplica as probabilidades<br/>de cada classe"]
    C --> D["Regra de decisão<br/>(MAP: maior valor)"]
    D --> E["Classe prevista"]

No exemplo simplificado do livro: 0,275 × 0,18 = 4,95% de chance de compra para um cliente de São Paulo com renda baixa, contra 0,685 × 0,80 = 54,8% para um do Rio com renda alta — perfis bem diferentes, e o modelo os separa.

Versão rigorosa da fórmula

O livro simplifica para facilitar a intuição. O Naive Bayes formal usa o teorema de Bayes: para cada classe c, calcula-se

\[\hat{c} = \arg\max_{c}\; P(c) \prod_{i=1}^{n} P(x_i \mid c)\]

ou seja, a probabilidade da classe no histórico (o prior) vezes a probabilidade de cada característica dada a classe; escolhe-se a classe de maior valor (MAP). A ideia é a mesma: treinar = contar frequências no histórico; prever = multiplicar e escolher o maior.

  • Treino extremamente rápido: é só contar ocorrências e montar tabelas — custo linear no número de itens (100 itens → ~100 operações; 1.000 → ~1.000).
  • Simples de implementar e fácil de explicar.
  • Muito usado em classificação de texto (spam): as características são as palavras e suas frequências.
  • Corresponde a um raciocínio humano intuitivo: "a maior parte dos brasileiros gosta de futebol, então respondo que sim" — frequência passada guiando a decisão presente.
  • Limitação: a independência entre características raramente é real (ex.: renda e estado podem estar correlacionados), mas na prática o desempenho costuma ser bom o bastante para servir de ponto de partida.

Comparando modelos e escolhendo o vencedor

Um resultado só vale para o conjunto de dados em que foi medido. No livro, o mesmo código teve 82% (vs. 82% do baseline) com 1.000 linhas e 75% (vs. 62,5%) com um segundo conjunto de apenas 75 linhas. Conclusões: o desempenho depende da quantidade de dados, da natureza do problema e das características escolhidas — e todo novo conjunto de dados exige uma nova avaliação.

As características também são uma variável do experimento

Testar o modelo removendo uma característica por vez mostra o quanto cada uma pesa. No segundo conjunto, tirar logado ou home não mudou nada; tirar busca derrubou o resultado ao nível do baseline; usar só busca manteve os 75%. Ou seja: a busca é a informação valiosa, e mais características nem sempre significam melhor modelo — em excesso, podem confundir o algoritmo.

# variações testadas (registrar todas!)
X_df = df[["home", "busca", "logado"]]   # 75%
X_df = df[["home", "busca"]]             # 72,5%
X_df = df[["home", "logado"]]            # 62,5% (= baseline)
X_df = df[["busca", "logado"]]           # 75%
X_df = df[["busca"]]                     # 75%

O perigo de tentar demais (e a importância de registrar)

Resultado bom por sorte

Cada nova combinação de variáveis é mais um teste. Quanto mais combinações se testa até "acertar", maior a chance de o resultado ótimo ser coincidência. É como pedir a 100 pessoas que joguem uma moeda 10 vezes: alguém vai tirar 10 caras só por acaso. Correlações espúrias famosas (vendas de sorvete × afogamentos; o ano em que a bolsa caiu × filmes de um ator) mostram como se "descobre" padrão onde há só acaso — sorvete não causa afogamento; o verão causa os dois.

A defesa é registrar cada experimento (variáveis, algoritmo, resultado) e apresentar o resultado final junto com todas as tentativas feitas para chegar a ele — inclusive as que falharam. Isso mostra que o número não é fruto de seleção a dedo.

# teste inicial: home, busca, logado => comprou
# home, busca
# home, logado
# busca, logado
# busca: 75% (8 testes)

O algoritmo também é uma variável: AdaBoost

Além das características, o algoritmo pode ser trocado. O scikit-learn expõe todos os classificadores com a mesma interface (fit/predict), então trocar de modelo é mudar uma linha:

from sklearn.naive_bayes import MultinomialNB
from sklearn.ensemble import AdaBoostClassifier

modelo_a = MultinomialNB()
modelo_b = AdaBoostClassifier()

Definição: AdaBoost (Adaptive Boosting)

Algoritmo de ensemble (conjunto) que combina muitos classificadores simples e fracos — cada novo "reforça" os itens que o anterior errou — para formar um classificador forte. O livro só o usa como segundo candidato; o ponto não é o algoritmo em si, e sim comparar candidatos nos mesmos dados. No mesmo conjunto, o AdaBoost fez 84–85% contra 82% do MultinomialNB; no conjunto pequeno, os dois empataram.

Como o código de treino e avaliação é o mesmo para qualquer modelo, extrai-se uma função:

def fit_and_predict(nome, modelo, treino_dados, treino_marcacoes,
                    teste_dados, teste_marcacoes):
    modelo.fit(treino_dados, treino_marcacoes)
    resultado = modelo.predict(teste_dados)
    taxa_de_acerto = 100.0 * (resultado == teste_marcacoes).mean()
    print(f"Taxa de acerto do algoritmo {nome}: {taxa_de_acerto}")
    return taxa_de_acerto

Três conjuntos: treino, teste e validação

Escolher o vencedor com base no teste contamina o teste: o modelo "melhor" foi selecionado justamente por ir bem nele, então a taxa já não estima o desempenho no mundo real. A solução é separar um terceiro conjunto, que só é usado uma vez, no fim:

Conjunto Proporção típica Uso
Treino 80% Cada candidato aprende (fit)
Teste 10% Compara os candidatos e escolhe o vencedor
Validação ("mundo real") 10% Mede o vencedor com dados que ninguém viu — esta é a taxa que se reporta
porcentagem_de_treino = 0.8
porcentagem_de_teste = 0.1

tamanho_de_treino = int(porcentagem_de_treino * len(Y))
tamanho_de_teste = int(porcentagem_de_teste * len(Y))
fim_de_treino = tamanho_de_treino + tamanho_de_teste
# a validação é o que sobra: len(Y) - tamanho_de_treino - tamanho_de_teste

treino_dados,    treino_marcacoes    = X[:tamanho_de_treino],              Y[:tamanho_de_treino]
teste_dados,     teste_marcacoes     = X[tamanho_de_treino:fim_de_treino], Y[tamanho_de_treino:fim_de_treino]
validacao_dados, validacao_marcacoes = X[fim_de_treino:],                  Y[fim_de_treino:]

resultado_a = fit_and_predict("MultinomialNB", modelo_a, treino_dados, treino_marcacoes, teste_dados, teste_marcacoes)
resultado_b = fit_and_predict("AdaBoost", modelo_b, treino_dados, treino_marcacoes, teste_dados, teste_marcacoes)

vencedor = modelo_a if resultado_a > resultado_b else modelo_b

# teste final com dados nunca vistos — e o baseline sobre os MESMOS dados de validação
taxa_real = 100.0 * (vencedor.predict(validacao_dados) == validacao_marcacoes).mean()

O fluxo tem sempre três fases: treinar os candidatos, testar e escolher o melhor, e validar o escolhido com dados novos. Só o número da última fase diz como o modelo deve se comportar na prática — e ele deve ser comparado com o baseline sobre esses mesmos dados de validação. No livro, o AdaBoost venceu com 84% no teste e fez 85% na validação; no conjunto pequeno, igualou o baseline (62,5%), ou seja, não agregou nada.

Definição: Conjunto de validação

Fatia dos dados reservada para a avaliação final, usada uma única vez, depois de todas as escolhas (características, algoritmo, ajustes). Evita que a escolha do "melhor" modelo vaze para a medição de qualidade. Observação de nomenclatura: em outras fontes, o conjunto usado para escolher o modelo é chamado de "validação" e o reservado para a avaliação final de "teste" — o importante é a separação de papéis, não os rótulos.

Classificação com mais de duas categorias (multiclasse)

No mundo real nem sempre a resposta é sim/não. Um e-mail pode ser spam, promoção, fórum, atualização, importante, familiar ou normal; um cliente pode estar alegre, neutro ou chateado; um produto novo pode ser sucesso, neutro ou fracasso.

Definição: Classificação multiclasse

Classificação em que cada item pertence a uma entre três ou mais classes (0, 1, 2 … N). Em oposição à classificação binária (duas classes).

Exemplo: situação do cliente (3 classes)

Características de cada cliente (todas numéricas, não binárias):

Característica Pergunta que responde
Recência Há quanto tempo foi o último acesso? (1 = ontem)
Frequência Em quantos dias distintos acessou depois de inscrito?
Tempo de inscrição (semanas) Há quanto tempo é cliente?

Marcação: situação do cliente — 2 = alegre, 1 = neutro, 0 = chateado. A cada classe corresponde uma ação diferente (alegre: sem preocupação; neutro: contato para entender; chateado: investigar o motivo e tentar recuperar).

Definição: Recência e frequência

Duas características clássicas de comportamento de clientes. Recência: quão recentemente a pessoa interagiu (último acesso/compra). Frequência: com que regularidade interagiu (quantos dias distintos, não quantas vezes). A unidade (dias, horas, semanas) é livre, desde que consistente. Não há regra universal de que mais tempo de cadastro signifique mais ou menos satisfação — por isso a relação é deixada para o algoritmo descobrir.

O fluxo de código é o mesmo dos exemplos anteriores (treino 80% / teste 10% / validação 10%, vários modelos, baseline); só mudam as colunas. Na validação com 23 itens, o baseline acertou 82,6% — e o MultinomialNB e o AdaBoostClassifier ficaram em 72,7% e 68,2% no teste, com o vencedor empatando com o baseline: previam praticamente sempre a classe majoritária (1).

Observação sobre as versões atuais

Nas versões atuais do scikit-learn, MultinomialNB e AdaBoostClassifier já suportam multiclasse nativamente; o que o exemplo do livro mostra é que, naqueles dados, eles não superaram o baseline. O mecanismo abaixo (One-vs-Rest / One-vs-One) continua importante porque é a forma de usar com várias classes os algoritmos que são intrinsecamente binários e porque ilustra a estratégia geral de "reduzir um problema grande a problemas pequenos que já sabemos resolver".

Estratégia 1: One-vs-Rest ("um contra o resto")

Reduz o problema de N classes a N problemas binários: para cada classe, treina-se um classificador "essa classe × todas as outras". Na previsão, roda-se todos e vence a classe cujo classificador der maior confiança.

flowchart TD
    D["Dados: classes 0, 1, 2"] --> A["Classificador A<br/>0 vs (1,2)"]
    D --> B["Classificador B<br/>1 vs (0,2)"]
    D --> C["Classificador C<br/>2 vs (0,1)"]
    A --> E["Escolhe a classe de<br/>maior confiança"]
    B --> E
    C --> E

Com 3 classes são 3 classificadores; com 10 classes, 10 — o custo cresce linearmente com o número de classes.

Definição: One-vs-Rest (OvR, um-contra-todos)

Estratégia multiclasse que treina um classificador binário por classe ("esta classe contra todas as demais") e escolhe, na previsão, a classe cujo classificador tiver a maior confiança. No scikit-learn: OneVsRestClassifier.

from sklearn.multiclass import OneVsRestClassifier
from sklearn.svm import LinearSVC

modelo_ovr = OneVsRestClassifier(LinearSVC(random_state=0))
modelo_ovr.fit(treino_dados, treino_marcacoes)

O OneVsRestClassifier é só um "embrulho": recebe o classificador binário a ser repetido (aqui, o LinearSVC, uma máquina de vetores de suporte linear) e o replica por classe. random_state=0 fixa a semente para o resultado ser reproduzível. No exemplo, o OvR errou só 2 itens de teste: 90,9%, contra 72,7% e 68,2% dos anteriores.

Estratégia 2: One-vs-One ("um contra um")

Treina um classificador para cada par de classes (0×1, 0×2, 1×2…) e escolhe a classe que mais "vence" nos confrontos (votação). Com N classes são N × (N − 1) / 2 classificadores: o custo cresce quadraticamente.

Estratégia Nº de classificadores (N classes) Custo Quando preferir
One-vs-Rest N Linear Muitas classes; escolha padrão
One-vs-One N(N−1)/2 Quadrático Poucas classes, ou algoritmos que escalam mal com o número de itens (cada par usa só os itens daquelas duas classes)
from sklearn.multiclass import OneVsOneClassifier

modelo_ovo = OneVsOneClassifier(LinearSVC(random_state=0))

No exemplo do livro, o One-vs-One acertou 100% no teste (e foi o vencedor). Com poucas classes (3) o custo extra é pequeno; com dezenas de classes ficaria caro.

Escolhendo o vencedor entre vários modelos

Com mais candidatos, uma cadeia de if/else não escala. Uma forma enxuta é guardar as taxas por nome e escolher o maior valor com max:

candidatos = {
    "OneVsRest": OneVsRestClassifier(LinearSVC(random_state=0)),
    "OneVsOne": OneVsOneClassifier(LinearSVC(random_state=0)),
    "MultinomialNB": MultinomialNB(),
    "AdaBoost": AdaBoostClassifier(),
}

taxas = {}
for nome, modelo in candidatos.items():
    taxas[nome] = fit_and_predict(nome, modelo, treino_dados, treino_marcacoes,
                                  teste_dados, teste_marcacoes)

nome_vencedor = max(taxas, key=taxas.get)       # chave com a maior taxa
vencedor = candidatos[nome_vencedor]
teste_real(vencedor, validacao_dados, validacao_marcacoes)

O livro usa um dicionário {taxa: modelo} e max sobre a chave; funciona, mas dois modelos com a mesma taxa se sobrescrevem. Com o nome como chave e key=taxas.get não há esse problema.

Rode todos os candidatos de uma vez

Não escolha o próximo algoritmo a testar com base no resultado do anterior até "dar certo": isso é a mesma armadilha da sorte vista antes. O processo correto é treinar todos os candidatos, comparar todos no conjunto de teste, eleger o vencedor e só então medi-lo nos dados de validação — compare sempre com o baseline nos mesmos dados.

Validação cruzada (k-fold)

O esquema "um treino, um teste, uma validação" tem uma fragilidade: o resultado depende de quais itens caíram em cada fatia. No exemplo do livro, com 10 itens, treinar com {1,2,3,4,5,6} e testar com {7,8} deu 82%; trocar apenas um cliente de lugar (treino {1,2,3,4,6,7}, teste {5,8}) deu 72%. Os dois números são "válidos" — logo nenhum deles é confiável sozinho. Uma mudança qualquer no mundo real (alguém que acessou um dia depois, um cliente que ficou doente) altera a ordem e, com ela, o resultado.

A saída é repetir o treino e o teste com várias divisões diferentes e tirar a média. Testar todas as permutações possíveis é inviável; o k-fold faz isso de forma sistemática.

Definição: k-fold (validação cruzada, cross-validation)

Técnica que divide os dados de treino em k pedaços (folds) de tamanho parecido e repete k vezes: em cada rodada, um pedaço é o teste e os k − 1 restantes são o treino. O resultado final é a média das k taxas de acerto. Assim, todo item é usado para treinar e para testar, e a estimativa deixa de depender de uma divisão específica.

Exemplo com 8 itens e k = 3 (pedaços {1,2,3}, {4,5,6}, {7,8}):

Rodada Treino Teste Acerto
1 {4,5,6,7,8} {1,2,3} 88%
2 {1,2,3,7,8} {4,5,6} 74%
3 {1,2,3,4,5,6} {7,8} 83%
Média 81,66%
flowchart LR
    subgraph K3["k = 3"]
    direction TB
    R1["Rodada 1: teste = pedaço 1<br/>treino = pedaços 2 e 3"]
    R2["Rodada 2: teste = pedaço 2<br/>treino = pedaços 1 e 3"]
    R3["Rodada 3: teste = pedaço 3<br/>treino = pedaços 1 e 2"]
    end
    K3 --> M["Média das 3 taxas<br/>= estimativa final"]

Escolha do k

  • k varia de 2 até o número de itens. Em k = 2 cada metade vira treino e teste uma vez; em k = N cada item vira o teste uma vez (leave-one-out, o caso extremo).
  • Quanto maior o k, mais rodadas (k treinos por modelo) e mais lento o processo. O livro usa k = 10 como valor típico no restante do exemplo.
  • Com k = 3, 4 e 10, o mesmo modelo deu 92,22%, 92,77% e 92,31%: valores próximos, mas diferentes. Decida o k antes e não o ajuste olhando o resultado — escolher o k que dá o melhor número é, de novo, "viciar" a decisão.

Implementação com cross_val_score

O scikit-learn faz os cortes, treina e devolve a taxa de cada rodada:

import numpy as np
from sklearn.model_selection import cross_val_score
from sklearn.multiclass import OneVsRestClassifier
from sklearn.svm import LinearSVC

# 1. reserva apenas validação final (ex.: 20%); o k-fold usa só o treino
tamanho_de_treino = int(0.8 * len(Y))
treino_dados, treino_marcacoes = X[:tamanho_de_treino], Y[:tamanho_de_treino]
validacao_dados, validacao_marcacoes = X[tamanho_de_treino:], Y[tamanho_de_treino:]

# 2. k-fold apenas nos dados de treino
k = 10
modelo = OneVsRestClassifier(LinearSVC(random_state=0))
scores = cross_val_score(modelo, treino_dados, treino_marcacoes, cv=k)

taxa_de_acerto = 100.0 * np.mean(scores)    # média das k rodadas
print(taxa_de_acerto)

Observações:

  • Agora não há mais conjunto de teste separado: o k-fold usa os mesmos dados de treino, ora como treino, ora como teste. Fica só a validação final, reservada e intocada.
  • cross_val_score devolve um array com a taxa de cada rodada; a média é o resultado e o desvio padrão (scores.std()) indica a estabilidade do modelo.
  • Para classificação, cv=k já faz a divisão estratificada (mantém a proporção das classes em cada pedaço).
  • A função fit_and_predict passa a receber o modelo e os dados de treino e devolver a média do k-fold; o restante do fluxo (comparar candidatos, eleger o vencedor, treinar o vencedor com todo o treino e medir na validação final) permanece igual.
def fit_and_predict(nome, modelo, treino_dados, treino_marcacoes, k=10):
    scores = cross_val_score(modelo, treino_dados, treino_marcacoes, cv=k)
    taxa_de_acerto = 100.0 * np.mean(scores)
    print(f"Taxa de acerto do algoritmo {nome}: {taxa_de_acerto:.2f}")
    return taxa_de_acerto

Transparência do processo

Variar o tipo de validação, o k e os modelos até aparecer um bom número é mais uma forma de se enganar por coincidência. Quem apresenta resultados deve apresentar também o processo completo — incluindo o que deu errado. É comum (um "vício humano") publicar só o que funcionou, e isso faz a amostra parecer melhor do que é.

Classificando texto: do texto ao vetor de números

Até aqui os algoritmos receberam números. Mas muitos problemas reais envolvem texto: mensagens enviadas pelo formulário de contato de um site, por exemplo, precisam ser direcionadas ao setor certo.

Mensagem recebida Categoria
"Se eu comprar cinco anos antecipados, eu ganho algum desconto?" Comercial
"O exercício 15 do curso de Java 1 está com a resposta errada. Pode conferir?" Técnico
"Existe algum curso para cuidar do marketing da minha empresa?" Carreira
"Já trabalho como designer e queria aprender mais de UX, quais cursos devo fazer?" Carreira

Pedir que o usuário escolha a categoria em uma lista funciona com 3 opções, mas degrada rápido: com Comercial, Financeiro, Técnico, Conteúdo e Carreira, uma mensagem pode caber em duas ou três ao mesmo tempo e a pessoa não sabe qual escolher. É o caso de treinar um classificador com mensagens já categorizadas e deixá-lo decidir — um problema de classificação multiclasse, só que com texto no lugar de números.

O desafio: toda entrada precisa ter o mesmo tamanho

Os algoritmos esperam uma tabela de colunas fixas (como recencia, frequencia, semanas). Textos têm tamanhos diferentes. A solução é reduzir o problema novo a um que já se sabe resolver: transformar cada texto em um vetor de números de tamanho fixo.

Definição: Bag of words (saco de palavras)

Representação de um texto como um vetor em que cada posição corresponde a uma palavra do vocabulário e o valor é quantas vezes ela aparece no texto. Ignora a ordem das palavras e a gramática — só importa quais palavras ocorrem e quantas vezes. É a representação mais simples de texto para ML clássico.

Definição: Vocabulário (dicionário)

Lista de todas as palavras distintas encontradas nos textos de treino. Define as colunas do vetor: todo texto, curto ou longo, vira um vetor com o mesmo número de posições (o tamanho do vocabulário).

Passo a passo

  1. Montar o vocabulário: juntar as palavras distintas de todos os textos. Em "Se eu comprar cinco anos antecipados, eu ganho algum desconto?" há 10 palavras mas 9 distintas ("eu" repete).
  2. Contar quantas vezes cada palavra do vocabulário aparece em cada texto.

Com o vocabulário [se, eu, comprar, cinco, anos, antecipados, ganho, algum, desconto]:

Texto se eu comprar cinco anos antecipados ganho algum desconto
"Se eu comprar cinco anos antecipados, eu ganho algum desconto?" 1 2 1 1 1 1 1 1 1
"Eu ganho desconto se comprar cinco anos antecipados?" 1 1 1 1 1 1 1 0 1

Textos diferentes (e de tamanhos diferentes) viram vetores do mesmo comprimento. Se aparece uma frase com palavras novas ("Ao terminar um curso, eu ganho um certificado?"), acrescentam-se as palavras novas ao vocabulário e todos os vetores passam a ter as novas colunas (com zero onde a palavra não ocorre).

O algoritmo descobre sozinho quais palavras indicam cada categoria: se "preço", "desconto", "valor" e "pagamento" aparecem quase só nas mensagens comerciais, ele aprende a associá-las a essa classe; "curso" e "carreira" puxam para o setor de carreira. Ninguém precisa escrever essas regras.

from sklearn.feature_extraction.text import CountVectorizer

frases = [
    "Se eu comprar cinco anos antecipados, eu ganho algum desconto?",
    "Eu ganho desconto se comprar cinco anos antecipados?",
    "Ao terminar um curso, eu ganho um certificado?",
]

vetorizador = CountVectorizer()
X = vetorizador.fit_transform(frases)

print(vetorizador.get_feature_names_out())   # o vocabulário (ordem alfabética)
print(X.toarray())                           # a contagem de cada palavra por frase

CountVectorizer faz o mesmo que o processo manual (montar o vocabulário e contar), e já converte tudo para minúsculas. A implementação manual — e os cuidados com limpeza do texto — são vistos adiante.

Resumo da ideia

Para classificar texto: (1) construir o vocabulário com os textos de treino; (2) transformar cada texto em um vetor de contagens; (3) usar esse vetor como X em qualquer classificador já visto (MultinomialNB, One-vs-Rest...). Esse é o ponto de partida de PLN clássico — e o motivo de o MultinomialNB, que trabalha com contagens, ser a escolha típica para spam e categorização de texto.

Classificação de texto na prática: roteando e-mails

Com a ideia do saco de palavras, o fluxo completo para o exemplo do formulário de contato (43 e-mails, cada um já marcado com a categoria 1, 2 ou 3) é:

flowchart LR
    A["emails.csv<br/>(texto + categoria)"] --> B["minúsculas +<br/>quebrar em palavras"]
    B --> C["vocabulário<br/>(conjunto de palavras)"]
    C --> D["palavra → posição<br/>(tradutor)"]
    D --> E["cada e-mail vira um vetor<br/>de contagens"]
    E --> F["treino / k-fold /<br/>validação"]

1. Ler o CSV e separar texto e marcação

import pandas as pd

classificacoes = pd.read_csv("emails.csv")      # colunas: email, classificacao
textos_puros = classificacoes["email"]
marcas = classificacoes["classificacao"]

2. Limpar e quebrar em palavras

textos_quebrados = textos_puros.str.lower().str.split(" ")

O .str.lower() evita que "Como" e "como" sejam tratadas como palavras diferentes — a chamada limpeza do texto. Aqui a escolha é minúsculas, mas depende do objetivo: para detectar spam ou um usuário bravo, texto em CAIXA ALTA pode ser justamente o sinal relevante, e converter tudo apagaria essa informação.

A divisão por espaço é a abordagem mais simples e ainda é imperfeita

split(" ") deixa pontuação grudada na palavra ("desconto?", "plano,"), de modo que "plano" e "plano," viram palavras distintas. Tratar isso (remover pontuação, acentos, stop words como "de", "a", "o") é o próximo passo de qualidade na preparação de texto.

3. Vocabulário com um conjunto (set)

dicionario = set()
for lista in textos_quebrados:
    dicionario.update(lista)          # só entra a palavra que ainda não existe

total_de_palavras = len(dicionario)   # 364 palavras distintas no exemplo

Definição: Conjunto (set)

Estrutura de dados que não admite elementos repetidos (a mesma ideia da teoria dos conjuntos: {1, 2, 3} não aceita outro 1). Ideal para montar o vocabulário: adicionar uma palavra já existente não tem efeito.

4. O "tradutor": palavra → posição no vetor

Cada palavra do vocabulário recebe um número (0, 1, 2 … 363) com zip e range, e o par vira um dicionário Python:

tuplas = zip(dicionario, range(total_de_palavras))
tradutor = {palavra: indice for palavra, indice in tuplas}

tradutor["curso"]          # devolve a posição da palavra "curso"
tradutor["palavra-inexistente"]   # KeyError: a palavra não está no vocabulário

Definição: Dicionário (dict) e tupla

Tupla é um par/sequência ordenada imutável, como ("curso", 16). Dicionário (dict) é um mapa chave → valor com busca direta pela chave. O zip junta duas sequências em tuplas, e a compreensão {k: v for k, v in tuplas} as transforma em dicionário.

5. Vetorizar cada texto

def vetorizar_texto(texto, tradutor):
    vetor = [0] * len(tradutor)           # tamanho = quantidade de palavras do vocabulário
    for palavra in texto:
        if palavra in tradutor:           # ignora palavras desconhecidas
            posicao = tradutor[palavra]
            vetor[posicao] += 1
    return vetor

vetores_de_texto = [vetorizar_texto(texto, tradutor) for texto in textos_quebrados]

Resultado: 43 vetores de 364 posições, todos com o mesmo tamanho, onde cada posição guarda quantas vezes a palavra apareceu naquele e-mail. Um texto cujas palavras são todas desconhecidas vira um vetor só de zeros.

Palavras fora do vocabulário

Em produção, um e-mail novo terá palavras que não estavam no treino. Elas são ignoradas (como na função acima) — o vocabulário é definido só com os textos de treino e reaproveitado para novos textos. (O CountVectorizer do scikit-learn faz exatamente isso: fit constrói o vocabulário e transform aplica.)

6. Treinar, comparar e validar

X = vetores_de_texto e Y = marcas. O restante é o fluxo já visto: separar 80% treino e 20% validação, rodar k-fold apenas no treino para cada candidato, eleger o vencedor, treiná-lo com todo o treino e medir na validação, comparando com o baseline.

Modelo Acerto (k-fold no treino)
OneVsRest (LinearSVC) 72,33%
MultinomialNB 71,50%
OneVsOne (LinearSVC) 65,67%
AdaBoost 42,33%

O vencedor, One-vs-Rest, acertou 88,9% na validação (8 de 9 e-mails), enquanto o baseline acertaria só 44,4%: a classificação automática erraria cerca de 11% dos encaminhamentos, contra 56% se se chutasse sempre a mesma seção. (Com apenas 9 e-mails de validação, a margem de confiança é pequena — é uma demonstração, não um resultado de produção.)

Dois aprendizados práticos:

  • Teste cada passo ao implementar. Colar muito código de uma vez e depender de imports esquecidos (NameError: cross_val_score) torna a depuração um pesadelo; rodar a cada etapa localiza o erro na hora.
  • Fixe a semente aleatória. O AdaBoostClassifier usa números aleatórios e dava resultados diferentes a cada execução (47,33%, depois 43,99%…), o que impede comparar e reproduzir. Passar random_state=0 fixa a seed e torna o resultado repetível:
from sklearn.ensemble import AdaBoostClassifier
modelo = AdaBoostClassifier(random_state=0)

Definição: Seed / random_state

Valor inicial do gerador de números pseudoaleatórios. Com a mesma seed, a sequência "aleatória" é sempre a mesma, tornando experimentos reproduzíveis. Sem fixar, cada execução pode dar um resultado diferente — e olhar várias execuções até achar a "melhor" é outra forma de viciar a decisão.

Limpeza de texto: stop words, stemming e tokenização

O vocabulário "cru" do exemplo dos e-mails (364 palavras) mistura palavras úteis ("recomendam", "carreira", "preço", "certificado", "trocar") com ruído ("com", "o", "uma", "isto") e com variações da mesma palavra. Quanto mais limpo o vocabulário, melhor a precisão (o algoritmo não decide com base em coincidências nas palavras comuns) e melhor o desempenho (menos colunas para processar — com 1 milhão de palavras a diferença é enorme).

A biblioteca padrão para isso em Python é o NLTK (Natural Language Toolkit, pip install nltk). Seus recursos linguísticos são baixados à parte com nltk.download(...) — instalar a biblioteca não instala os pacotes de idioma.

import nltk
nltk.download("stopwords")
nltk.download("rslp")
nltk.download("punkt")

1. Stop words

Definição: Stop words (palavras de parada)

Palavras muito frequentes de um idioma que servem para construir as frases, mas carregam pouca informação sobre o assunto (em português: "com", "o", "uma", "isto", "de", "que"…). Costumam ser removidas antes de vetorizar o texto. O NLTK traz a lista por idioma.

stopwords = nltk.corpus.stopwords.words("portuguese")
validas = [palavra for palavra in lista if palavra not in stopwords]

No exemplo, a remoção tirou 49 palavras (365 → 315). Atenção: a lista do NLTK pode conter formas que não existem no idioma — e se o seu problema depende de uma dessas palavras (ex.: "não" numa análise de sentimento), é preciso removê-la da lista.

2. Stemming (radical da palavra)

Definição: Stemming

Redução de uma palavra ao seu radical (raiz), para que variações de flexão sejam tratadas como uma só: "amigo", "amigas", "amigos" → amig; "deve", "devem", "deverão" → dev. Não gera necessariamente uma palavra válida — apenas um identificador comum. Para o português, o NLTK oferece o RSLPStemmer, baseado na remoção de sufixos.

stemmer = nltk.stem.RSLPStemmer()
stemmer.stem("amigas")      # 'amig'
stemmer.stem("carreira")    # 'carr'

Efeito no exemplo: o vocabulário caiu de 315 para 285 radicais.

O mesmo tratamento no treino e na previsão

Se o vocabulário guarda radicais, a vetorização também precisa aplicar o stemmer.stem() a cada palavra do texto antes de consultar o dicionário — senão "cinco" nunca será encontrado (o vocabulário só tem "cinc"). Foi exatamente esse erro que o livro cometeu e depois corrigiu: antes da correção apenas 3 das 7 palavras da frase de teste eram contadas, e a taxa de acerto dos modelos caiu de ~72% para ~40%. Regra geral: todo pré-processamento aplicado ao treino deve ser aplicado igual aos dados novos. Observe ainda que o RSLPStemmer quebra com uma string vazia (IndexError), então é preciso ignorar palavras de tamanho 0.

3. Tokenização

Separar o texto só por espaço deixa pontuação colada nas palavras ("eu,", "minutos.", "empresa?"), criando "palavras" distintas por causa de um sinal.

Definição: Tokenização

Divisão do texto em unidades (tokens) — normalmente palavras e sinais de pontuação. É a primeira etapa de qualquer pipeline de PLN. O word_tokenize do NLTK separa por espaço e por pontuação, respeitando o idioma.

nltk.tokenize.word_tokenize("Voce vai viajar? Este ano, eu penso que sim!")
# ['Voce', 'vai', 'viajar', '?', 'Este', 'ano', ',', 'eu', 'penso', 'que', 'sim', '!']

Os próprios sinais viram tokens; para descartá-los, usa-se um filtro de tamanho: é comum ignorar tokens com menos de 3 caracteres, o que elimina pontuação e palavras curtas sem significado. No exemplo, o vocabulário final ficou com 230 radicais.

O pipeline de limpeza completo

import nltk
import pandas as pd

classificacoes = pd.read_csv("emails.csv")
frases = classificacoes["email"].str.lower()
textos_quebrados = [nltk.tokenize.word_tokenize(frase) for frase in frases]

stopwords = nltk.corpus.stopwords.words("portuguese")
stemmer = nltk.stem.RSLPStemmer()

def limpar(palavras):
    return [stemmer.stem(p) for p in palavras if p not in stopwords and len(p) > 2]

dicionario = set()
for palavras in textos_quebrados:
    dicionario.update(limpar(palavras))

tradutor = {palavra: i for i, palavra in enumerate(dicionario)}

def vetorizar_texto(palavras, tradutor):
    vetor = [0] * len(tradutor)
    for radical in limpar(palavras):          # mesma limpeza do treino
        if radical in tradutor:
            vetor[tradutor[radical]] += 1
    return vetor
flowchart LR
    A["Texto bruto"] --> B["Minúsculas"]
    B --> C["Tokenização<br/>(espaços e pontuação)"]
    C --> D["Remove stop words<br/>e tokens curtos"]
    D --> E["Stemming<br/>(radicais)"]
    E --> F["Vocabulário + vetor<br/>de contagens"]

Hoje, no scikit-learn

CountVectorizer e TfidfVectorizer aceitam lowercase, stop_words, tokenizer e ngram_range, encapsulando boa parte desse pipeline em poucas linhas — e o Pipeline do scikit-learn garante que o mesmo pré-processamento seja aplicado em treino e previsão. Para trabalhos recentes de texto, modelos de linguagem e embeddings (ver LLM) tornam várias dessas etapas manuais desnecessárias, mas os conceitos de vocabulário, tokenização e limpeza continuam a base do entendimento.

Evite inflar o número de experimentos

Cada decisão de limpeza (remover stop words? usar stemming? mínimo de 2 ou 3 letras?) é mais um grau de liberdade. Como visto em Comparando modelos, registre as variações testadas e decida o pipeline antes de olhar o resultado final.

Checklist de um projeto de classificação

Resumo de tudo o que a página cobre, útil como roteiro de resposta em entrevista:

  1. Defina o problema e as classes; transforme respostas em números (0/1, 0/1/2…).
  2. Escolha características que diferenciem as classes; converta categóricas em dummies e texto em vetor de contagens (com limpeza).
  3. Separe os dados: treino, teste (ou k-fold no treino) e uma validação final intocada. Fixe random_state para reproduzir.
  4. Estabeleça o baseline (classe mais frequente) sobre os mesmos dados de teste.
  5. Treine vários candidatos de uma vez (Naive Bayes, AdaBoost, One-vs-Rest, One-vs-One…) e compare-os com validação cruzada.
  6. Escolha o vencedor e meça uma única vez na validação; o número só vale se superar o baseline e fizer sentido para o negócio.
  7. Registre todos os experimentos, inclusive os que falharam, para não se enganar com resultado obtido por sorte.

Para se aprofundar, o livro sugere praticar com dados reais da própria empresa ou de bases públicas, estudar a matemática por trás dos algoritmos (Naive Bayes, etc.) e explorar bibliotecas em R, Octave e Python — preferindo linguagens com comunidade ativa. Referências citadas: Fundamentals of Machine Learning for Predictive Data Analytics (D'Arcy, Kelleher, Mac Namee) e The Elements of Statistical Learning (Hastie, Tibshirani, Friedman).