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_dfpoda o vocabulário de cauda longa — erros de digitação, nomes próprios únicos. Reduz drasticamente a dimensão com perda mínima.max_dffaz 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_tfcorrige 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
fitsó no treino,transformno teste — o que oPipelinegarante 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ática | TF-IDF + modelo linear |
| Precisa explicar a decisão termo a termo | TF-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, agrupamento | Embeddings contextuais |
| Muito dado e tarefa difícil (sentimento com ironia, inferência) | Transformer ajustado |
| Idioma com morfologia rica, texto com erro de digitação | FastText, ou n-grams de caractere |
| Sem GPU e com prazo | TF-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
-
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.
-
Explique
min_dfemax_dfe por quemax_dfé melhor que uma lista de stopwords pronta.✓ Resposta:min_dfdescarta 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_dfdescarta termos presentes em mais de uma fração dos documentos, porque um termo quase universal não distingue nada.max_dfsupera 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. -
O que caracteriza o vazamento ao ajustar o
TfidfVectorizerantes 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_transformapenas no treino etransformno teste; palavras novas do teste simplesmente não têm coluna, que é exatamente o que acontecerá em produção. UmPipelinedoscikit-learngarante isso automaticamente dentro da validação cruzada. -
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. -
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))numPipeline, com validação cruzada, e só considerar algo mais pesado se a métrica exigir.
Fontes e leituras recomendadas
- scikit-learn — extração de características de texto
- Mikolov et al. (2013) — Efficient Estimation of Word Representations in Vector Space
- Bojanowski et al. (2017) — Enriching Word Vectors with Subword Information (FastText)
- Pennington, Socher & Manning (2014) — GloVe
- Reimers & Gurevych (2019) — Sentence-BERT
- gensim — Word2Vec e FastText em Python
- Jurafsky & Martin — Speech and Language Processing (rascunho aberto)