Modelos de aprendizado consomem números. Texto é uma sequência de símbolos com ordem, contexto e significado. Tudo em processamento de linguagem depende da ponte entre os dois — e a qualidade dessa ponte costuma pesar mais no resultado final que a escolha do algoritmo que vem depois.

Esta aula percorre essa ponte do começo: das contagens simples às representações densas, entendendo o que cada etapa ganha e o que ela perde. Não é história por curiosidade — o TF-IDF continua sendo linha de base difícil de bater em classificação de texto com pouco dado, e saber quando ele vence os embeddings vale mais que saber usar os dois.

Transformar texto em características numéricas com Bag-of-Words, TF-IDF e n-grams; entender o que cada representação captura e descarta; conhecer embeddings densos e contextuais; e escolher entre eles por critério de volume de dados e natureza da tarefa.

Antes de tudo: normalizar (com parcimônia)

O pré-processamento decide o que o modelo pode enxergar. Cada passo é uma perda deliberada de informação, e isso precisa ser consciente:

import re

def normalizar(texto):
    texto = texto.lower()                         # "Casa" e "casa" viram uma coisa só
    texto = re.sub(r"https?://\S+", " URL ", texto)
    texto = re.sub(r"\d+", " NUM ", texto)        # o dígito específico costuma ser ruído
    texto = re.sub(r"[^\w\s]", " ", texto)        # pontuação fora
    return re.sub(r"\s+", " ", texto).strip()

Cada linha é discutível, e a decisão depende da tarefa:

  • Minúsculas unificam formas, mas apagam a distinção entre "Ministro" e "ministro" — que pode importar num reconhecimento de entidades.
  • Remover pontuação é padrão em classificação temática e péssimo em análise de sentimento, onde "!!!" e "?" carregam sinal.
  • Substituir números ajuda quando o valor não importa e atrapalha quando importa (um processo sobre "artigo 155" perde o artigo).
  • **Remover *stopwords*** reduz ruído em classificação por tema e destrói a negação: "não é bom" vira "bom".

A regra: não aplique um passo por hábito. Teste com e sem, no seu conjunto de validação.

Bag-of-Words: contar e esquecer a ordem

A representação mais simples possível: um vocabulário, uma coluna por termo, e a contagem de ocorrências.

from sklearn.feature_extraction.text import CountVectorizer

docs = [
    "o contrato prevê multa por atraso na entrega",
    "a entrega atrasou e o contrato prevê multa",
    "o réu alega cerceamento de defesa no processo",
]

vetorizador = CountVectorizer(min_df=1)
X = vetorizador.fit_transform(docs)

print(vetorizador.get_feature_names_out())
print(X.toarray())

O nome descreve bem a limitação: é um saco de palavras. Repare que os dois primeiros documentos, que dizem coisas diferentes sobre quem atrasou, produzem vetores praticamente idênticos.

As três perdas do BoW:

  • A ordem, e com ela a negação e a estrutura sintática;
  • A semântica — "contrato" e "acordo" são colunas totalmente independentes, sem nenhuma noção de proximidade;
  • A escala — palavras frequentes dominam a magnitude sem carregar informação distintiva.

O último problema tem solução direta.

TF-IDF: ponderar pela raridade

A ideia é que o valor informativo de um termo é inversamente proporcional à sua difusão. Uma palavra que aparece em todos os documentos não distingue nenhum; uma que aparece em poucos é característica desses poucos.

$$\text{tfidf}(t, d) = \underbrace{f_{t,d}}_{\text{no documento}} \times \underbrace{\log\frac{N}{n_t}}_{\text{raridade no corpus}}$$

from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer(
    min_df=3,             # ignora termos em menos de 3 documentos (ruído, erro de digitação)
    max_df=0.85,          # ignora termos em mais de 85% (não distinguem nada)
    sublinear_tf=True,    # usa 1 + log(tf): a 10ª ocorrência vale menos que a 2ª
    ngram_range=(1, 2),
)
X = tfidf.fit_transform(corpus_treino)

Os quatro parâmetros acima resolvem quase tudo na prática:

  • min_df poda o vocabulário de cauda longa — erros de digitação, nomes próprios únicos. Reduz drasticamente a dimensão com perda mínima.
  • max_df faz o papel de uma lista de stopwords derivada do seu corpus, o que é melhor que uma lista genérica: num corpus jurídico, "processo" é uma stopword de fato.
  • sublinear_tf corrige a suposição implícita de que dez ocorrências informam dez vezes mais que uma. Não informam.

Vazamento clássico: ajustar o vetorizador no corpus inteiro antes de dividir treino e teste. O IDF passa a carregar informação da distribuição de termos do teste, e o desempenho medido fica otimista. Use fit só no treino, transform no teste — o que o Pipeline garante automaticamente.

n-grams: devolver um pouco de ordem

Contar sequências de $n$ palavras contíguas recupera parte do que o BoW perdeu:

TfidfVectorizer(ngram_range=(1, 2))   # unigramas + bigramas

Com bigramas, "não bom" passa a ser um termo próprio, distinto de "não" e "bom" isolados — e a negação sobrevive. O custo é que o vocabulário cresce rápido: um corpus com 20 mil unigramas pode passar de 200 mil ao incluir bigramas.

Na prática, (1, 2) cobre a maioria dos casos com bom custo-benefício; trigramas só compensam com corpus grande. Para textos com erro de digitação ou morfologia rica, n-grams de caractere (analyzer="char_wb", ngram_range=(3, 5)) são surpreendentemente robustos — eles capturam radicais e toleram a grafia errada.

Embeddings densos: semântica no espaço vetorial

Word2Vec, GloVe e FastText atacam a limitação que sobrou: BoW e TF-IDF não sabem que "carro" e "automóvel" são parecidos.

A hipótese distribucional sustenta tudo: palavras que ocorrem em contextos parecidos têm significado parecido. Treinando um modelo para prever o contexto a partir da palavra (ou o inverso), os vetores internos acabam organizando o espaço por similaridade semântica.

from gensim.models import Word2Vec

frases = [normalizar(d).split() for d in corpus]

modelo = Word2Vec(
    frases,
    vector_size=200,
    window=5,          # tamanho da janela de contexto
    min_count=5,
    sg=1,              # 1 = skip-gram (melhor com corpus menor); 0 = CBOW
    workers=4,
    epochs=15,
)

modelo.wv.most_similar("contrato", topn=5)

Para representar um documento a partir de vetores de palavras, a média ponderada por IDF é uma linha de base forte e barata:

import numpy as np

def vetor_documento(tokens, wv, idf, dim=200):
    vetores, pesos = [], []
    for t in tokens:
        if t in wv:
            vetores.append(wv[t])
            pesos.append(idf.get(t, 1.0))
    if not vetores:
        return np.zeros(dim)
    return np.average(vetores, axis=0, weights=pesos)

A ponderação por IDF importa: sem ela, a média é dominada por palavras comuns e todos os documentos ficam parecidos.

O FastText merece nota especial para português: ele representa a palavra pela soma de n-grams de caractere, o que lhe dá duas vantagens relevantes aqui — lida com a morfologia rica do idioma (trabalh- aparece em dezenas de formas) e produz vetor para palavras que nunca viu, compondo a partir dos pedaços.

Embeddings contextuais: um vetor por ocorrência

A limitação que sobra nos embeddings estáticos: cada palavra tem um vetor, independentemente do contexto. "Banco" de sentar e "banco" de dinheiro compartilham o mesmo ponto no espaço, num compromisso que não serve bem a nenhum dos dois.

Modelos baseados em Transformer resolvem isso gerando o vetor em função da frase inteira:

from sentence_transformers import SentenceTransformer

modelo = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
vetores = modelo.encode(documentos, batch_size=32, normalize_embeddings=True)

Ganho enorme em qualidade semântica, com três custos honestos: exigem muito mais computação (GPU para volumes grandes), carregam um modelo de centenas de megabytes, e são bem menos interpretáveis — não há mais "a palavra X pesou tanto na decisão".

Quando usar o quê

Situação Escolha
Poucos milhares de documentos, classificação temáticaTF-IDF + modelo linear
Precisa explicar a decisão termo a termoTF-IDF (coeficientes são interpretáveis)
Vocabulário muito específico (jurídico, médico, técnico)TF-IDF, ou embeddings do próprio domínio
Busca semântica, similaridade, agrupamentoEmbeddings contextuais
Muito dado e tarefa difícil (sentimento com ironia, inferência)Transformer ajustado
Idioma com morfologia rica, texto com erro de digitaçãoFastText, ou n-grams de caractere
Sem GPU e com prazoTF-IDF — e ele frequentemente basta

A linha mais importante é a primeira. Com corpus pequeno, TF-IDF com regressão logística ou SVM linear é competitivo, treina em segundos, roda em CPU e é auditável. Sempre estabeleça essa linha de base antes de partir para embeddings — uma parte considerável dos projetos de PLN descobre tarde que o ganho do modelo pesado sobre o TF-IDF era de um ponto percentual.

Cada passo de normalização é uma perda deliberada, e vale tratá-lo como decisão, não como hábito: minúsculas apagam entidades, tirar pontuação prejudica análise de sentimento, e remover stopwords destrói a negação. O BoW conta e esquece a ordem, perdendo negação, semântica e escala. O TF-IDF pondera pela raridade, com min_df podando a cauda, max_df gerando as stopwords do próprio corpus e sublinear_tf corrigindo a linearidade da contagem — e o vetorizador se ajusta só no treino, porque ajustá-lo no corpus inteiro vaza a distribuição do teste para dentro do IDF. Os n-grams devolvem a ordem local, e (1,2) cobre a maioria dos casos; os de caractere aguentam erro de digitação e morfologia rica. Embeddings estáticos — Word2Vec, GloVe, FastText — trazem semântica, e para documento a média ponderada por IDF é uma linha de base forte; o FastText ainda compõe palavras novas a partir de pedaços. Os contextuais dão um vetor por ocorrência e resolvem ambiguidade, cobrando computação, tamanho e interpretabilidade. E a régua que fecha tudo: com pouco dado, TF-IDF com modelo linear é difícil de bater — estabeleça essa linha de base antes de escalar.

Exercícios

  1. Por que "o contrato prevê multa por atraso" e "o atraso prevê multa por contrato" produzem o mesmo vetor BoW, e como mitigar?

    ✓ Resposta:

    Porque o BoW registra apenas quais termos ocorrem e quantas vezes, descartando integralmente a posição. As duas frases têm exatamente o mesmo multiconjunto de palavras, logo os vetores são idênticos, embora os papéis sintáticos — quem prevê o quê — estejam invertidos. A mitigação mais barata são n-grams: com bigramas, "contrato prevê" e "atraso prevê" viram termos distintos e a diferença aparece. Mitigação completa exige representações que modelem sequência — RNNs ou Transformers —, que processam os tokens em ordem e podem representar a estrutura da frase.

  2. Explique min_df e max_df e por que max_df é melhor que uma lista de stopwords pronta.

    ✓ Resposta:

    min_df descarta termos que aparecem em menos de N documentos — tipicamente erros de digitação, nomes próprios únicos e ruído — reduzindo bastante a dimensão com perda mínima de informação. max_df descarta termos presentes em mais de uma fração dos documentos, porque um termo quase universal não distingue nada. max_df supera a lista pronta por ser derivada do seu corpus: numa base jurídica, "processo", "autos" e "réu" funcionam como stopwords de fato, embora não estejam em nenhuma lista genérica; inversamente, uma lista genérica de português remove "não", "mais" e "sem", que podem ser exatamente o sinal numa tarefa de sentimento ou de deferimento/indeferimento.

  3. O que caracteriza o vazamento ao ajustar o TfidfVectorizer antes de separar treino e teste?

    ✓ Resposta:

    O componente IDF é calculado sobre a frequência documental de todo o corpus. Ajustando no conjunto completo, os pesos passam a refletir também a distribuição de termos do teste, e o vocabulário incorpora palavras que só aparecem lá. Isso significa que o modelo é treinado com informação extraída dos dados que serão usados para avaliá-lo, e a estimativa de desempenho fica otimista — em produção, o vetorizador não terá visto os documentos futuros. O correto é fit_transform apenas no treino e transform no teste; palavras novas do teste simplesmente não têm coluna, que é exatamente o que acontecerá em produção. Um Pipeline do scikit-learn garante isso automaticamente dentro da validação cruzada.

  4. Em que situação o FastText supera claramente o Word2Vec em português?

    ✓ Resposta:

    Quando há palavras fora do vocabulário e morfologia rica — as duas condições típicas do português. O Word2Vec aprende um vetor por palavra inteira: uma forma que não apareceu no treino (trabalharíamos, um neologismo, um termo técnico, uma grafia errada) simplesmente não tem representação, e é descartada. O FastText representa a palavra como soma de n-grams de caractere, então consegue compor um vetor para qualquer forma nova a partir dos seus pedaços, e naturalmente aproxima formas que compartilham radical (trabalho, trabalhar, trabalhador). Isso importa muito em corpus com texto digitado por usuários, com erros e abreviações.

  5. Você tem 4.000 documentos rotulados e precisa classificá-los por tema, explicando cada decisão. O que usar?

    ✓ Resposta:

    TF-IDF com um modelo linear — regressão logística ou SVM linear. Com 4.000 documentos, um Transformer ajustado tende a sobreajustar e o ganho sobre uma linha de base bem construída costuma ser pequeno. O requisito de explicar decide a questão: num modelo linear sobre TF-IDF, o coeficiente de cada termo diz exatamente quanto ele empurra para cada classe, e é possível apresentar ao usuário as palavras que mais pesaram naquele documento específico. Com embeddings contextuais isso se perde — as dimensões não têm significado individual, e a explicação exigiria métodos post-hoc como SHAP ou LIME, mais caros e menos diretos. O caminho prático é TfidfVectorizer(min_df=3, max_df=0.85, sublinear_tf=True, ngram_range=(1,2)) num Pipeline, com validação cruzada, e só considerar algo mais pesado se a métrica exigir.

Fontes e leituras recomendadas