Representação de Texto
Toda técnica vista até aqui no curso parte de uma tabela de números: a PCA gira suas colunas, o k-means mede distâncias entre suas linhas. Texto não chega como tabela. Uma avaliação de cliente, um chamado de suporte, um post de fórum é uma sequência de caracteres de qualquer tamanho. Esta aula constrói a ponte de strings a vetores, para que as ferramentas geométricas que você já conhece (distâncias, projeções, agrupamentos) se apliquem a texto. Ela é também a base da próxima aula, Modelagem de Tópicos & BERTopic, que junta tudo o que está aqui.
Há duas famílias de respostas:
- representações esparsas (bag-of-words, TF-IDF): uma coluna por palavra do vocabulário; cada documento é a contagem das suas palavras, com algum peso. Obtidas por contagem, em um segundo, sem modelo algum;
- representações densas (word embeddings, sentence embeddings): algumas centenas de números por palavra ou por documento, produzidos por um modelo treinado com muito texto, em que textos de significado parecido ficam próximos mesmo sem nenhuma palavra em comum.
Esta página cobre o que todas têm em comum (cortar o texto em tokens e entender como o vocabulário cresce) e traz um mapa das quatro abordagens, cada uma com página, simulador e lab próprios. Mostra os vetores que cada uma produz e as medidas usadas para comparar dois vetores (cosseno, distância euclidiana, produto escalar). Depois, mede esparso contra denso nos mesmos dados, para você saber quando usar cada um. Todo número abaixo sai do código ao lado; cada figura é gerada por um pequeno script Python guardado junto da página. Os exemplos pequenos estão em português, então as manias do tokenizador são as mesmas que você vai encontrar nos seus dados.
Como esta aula está organizada
A aula tem duas abordagens. Cada uma tem slides, texto e um lab para fazer em aula; o quiz do fim cobre a fundamental. Comece pela fundamental: a avançada parte dela.
| Abordagem fundamental | Abordagem avançada | |
|---|---|---|
| pergunta | Como um texto vira um vetor e como medir se dois textos são parecidos? | O que acontece dentro do tokenizador: quão rápido o vocabulário cresce, como as variantes de uma palavra se juntam e por que os modelos neurais nunca encontram uma palavra desconhecida |
| conteúdo | tokens e o vocabulário, stop words, stemming e lematização, n-gramas; as quatro representações; cosseno, distância euclidiana, produto escalar, Jaccard; os limites dos vetores esparsos; word e sentence embeddings; esparso contra denso em similaridade, tokens exatos e classificação | as leis de Heaps e de Zipf, stemming e lematização com NLTK e spaCy, tokenizadores de subpalavras (WordPiece, SentencePiece), que decisão pertence a que família |
| lab em aula | quatro newsgroups: duas representações, vizinhos e busca, passos 1 a 4 | mapas dos dois espaços e um classificador caixa-preta, passos 5 e 6 |
| ao final, você sabe | prever o que o tokenizador do scikit-learn mantém; dizer o que cada uma das quatro representações faz e não faz; explicar por que o cosseno é o padrão para texto; escolher esparso ou denso para uma tarefa | prever como um vocabulário cresce com o corpus; distinguir um radical de um lema; ler uma tokenização em subpalavras; explicar por que os modelos densos tratam palavras nunca vistas |
Os dois labs usam o mesmo notebook do lab (baixe e abra no Jupyter ou no Colab, Arquivo → Fazer upload de notebook; no Colab, rode antes !pip install sentence-transformers): a parte fundamental vai do passo 1 ao 4; a avançada, passos 5 e 6. Ele roda em cerca de dois minutos numa CPU.
Abordagem fundamental
Uma pergunta guia esta parte: como uma sequência de caracteres vira um vetor e como medir se dois textos são parecidos? Qualquer que seja a família, a resposta segue um único pipeline em quatro etapas, desenhado abaixo: 1. texto → tokens; 2. tokens → vetor (esparso, por contagem; denso, com um modelo treinado); 3. comparar vetores com uma similaridade ou uma distância; 4. usar e escolher entre esparso e denso para busca e classificação. A etapa 2 aparece duas vezes, primeiro para a família esparsa e depois para a densa. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.
- Texto → tokenstokenização, normalização, vocabulário
- Tokens → vetoresparso: bag-of-words, TF-IDF · denso: embeddings
- Comparar vetorescosseno, distância euclidiana, produto escalar, Jaccard
- Usar e escolherbusca e classificação: esparso ou denso?
Slides da abordagem fundamental
Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova
1. Tokenização e o vocabulário
De uma string a tokens
Etapa 1. O primeiro passo é cortar o texto em unidades (os tokens) e reunir o vocabulário: o conjunto de tokens distintos do corpus (a coleção de documentos). Considere três documentos curtos:
from sklearn.feature_extraction.text import CountVectorizer
docs = ["O gato comeu o peixe.",
"O cachorro comeu a ração do gato.",
"Gato dorme, gato sonha."]
analyzer = CountVectorizer().build_analyzer() # o tokenizador padrão do scikit-learn
for d in docs:
print(analyzer(d))
['gato', 'comeu', 'peixe']
['cachorro', 'comeu', 'ração', 'do', 'gato']
['gato', 'dorme', 'gato', 'sonha']
Três coisas aconteceram sem aviso; cada uma é uma decisão que você pode mudar:
- tudo em minúsculas: "O" e "o", "Gato" e "gato" viram o mesmo token (
lowercase=True); - a pontuação some: o
token_patternpadrão,r"(?u)\b\w\w+\b", mantém sequências de duas ou mais letras ou dígitos. Vírgulas e pontos desaparecem; - palavras de uma letra somem também: os artigos "o" e "a" sumiram porque têm um só caractere. Em inglês isso remove sobretudo "a" e "I"; em português, remove os artigos o, a e a conjunção e, mas mantém do, da, no, na. Os acentos ficam ("ração");
strip_accents='unicode'transformaria em "racao".
Para ver essas decisões uma de cada vez, pegue uma única frase e corte-a de três jeitos, do tokenizador mais grosseiro ao do scikit-learn:
import re
s = "Os gatos não comeram a ração do cachorro, mas o gato comeu!"
print(s.split()) # 1. separa nos espaços
print(re.findall(r"\w+", s.lower())) # 2. minúsculas, depois uma regex de palavras
print(analyzer(s)) # 3. scikit-learn: também descarta palavras de 1 letra
['Os', 'gatos', 'não', 'comeram', 'a', 'ração', 'do', 'cachorro,', 'mas', 'o', 'gato', 'comeu!']
['os', 'gatos', 'não', 'comeram', 'a', 'ração', 'do', 'cachorro', 'mas', 'o', 'gato', 'comeu']
['os', 'gatos', 'não', 'comeram', 'ração', 'do', 'cachorro', 'mas', 'gato', 'comeu']
- separar nos espaços: o tokenizador mais barato. A pontuação fica grudada nas palavras, então "cachorro," e "comeu!" ganhariam colunas próprias, diferentes de "cachorro" e "comeu"; "Os" e "os" também seriam dois tokens;
- uma regex de palavras:
\w+mantém as sequências de letras e dígitos e descarta o resto; depois das minúsculas, os 12 pedaços são palavras limpas, inclusive "a" e "o", de uma letra só; - o padrão do scikit-learn: o mesmo, menos as palavras de uma letra: 10 tokens.
Stop words, stemming e lematização, n-gramas
Stop words são as palavras muito frequentes que carregam pouco conteúdo: the, of, and; de, do, que. O scikit-learn traz uma lista em inglês (stop_words='english') e aceita qualquer lista que você passar. n-gramas são sequências de \(n\) tokens consecutivos; ngram_range=(1, 2) mantém as palavras sozinhas e os pares de vizinhas (bigramas), o que recupera um pouco da ordem ("não gostei" não é "gostei"):
stop_pt = ['o', 'a', 'os', 'as', 'do', 'da', 'de', 'no', 'na', 'e', 'um', 'uma'] # uma lista mínima de stop words
for params in [{}, {'stop_words': stop_pt}, {'ngram_range': (1, 2)}]:
vocab = CountVectorizer(**params).fit(docs).get_feature_names_out()
print(params, len(vocab))
print(' ', list(vocab))
{} 8
['cachorro', 'comeu', 'do', 'dorme', 'gato', 'peixe', 'ração', 'sonha']
{'stop_words': ['o', 'a', 'os', 'as', 'do', 'da', 'de', 'no', 'na', 'e', 'um', 'uma']} 7
['cachorro', 'comeu', 'dorme', 'gato', 'peixe', 'ração', 'sonha']
{'ngram_range': (1, 2)} 17
['cachorro', 'cachorro comeu', 'comeu', 'comeu peixe', 'comeu ração', 'do', 'do gato', 'dorme', 'dorme gato', 'gato', 'gato comeu', 'gato dorme', 'gato sonha', 'peixe', 'ração', 'ração do', 'sonha']
A lista de stop words removeu "do": 8 palavras viram 7. Os bigramas mais que dobraram o vocabulário, de 8 para 17 colunas, em três frases. Repare em "comeu peixe": o artigo entre as duas palavras já tinha sido descartado, então o bigrama junta palavras que não eram vizinhas no texto original.
Duas outras normalizações reduzem as variantes de uma palavra a um só token. Nenhuma está no scikit-learn; as duas precisam de uma ferramenta específica da língua:
| o que faz | "sábados" | "comeu", "comeram" | ferramenta | |
|---|---|---|---|---|
| stemming | corta sufixos por regras; o resultado não precisa ser uma palavra | "sáb" | "com", "com" | RSLPStemmer do NLTK (português), Porter (inglês) |
| lematização | leva cada palavra à sua forma de dicionário, usando um vocabulário e a classe gramatical | "sábado" | "comeu" (não reduziu), "comer" | spaCy (pt_core_news_sm) |
As duas juntam colunas que significam a mesma coisa ("sábado" e "sábados" vão fazer diferença no buscador TF-IDF), ao custo de, às vezes, juntar palavras de sentidos diferentes. Com dados suficientes e com os modelos densos da segunda metade da aula, elas importam menos; em corpora pequenos e na busca por palavras-chave, ainda ajudam.
A abordagem avançada roda os dois numa frase inteira, com o stemmer de português do NLTK e um modelo pequeno do spaCy.
2. Um mapa das abordagens
Etapa 2. Depois que o texto foi cortado em tokens, as quatro páginas desta aula transformam esses tokens em vetores de quatro jeitos diferentes, do mais simples ao mais poderoso:
| página | um documento vira | construído a partir de | o que não consegue fazer |
|---|---|---|---|
| E2.1 Bag-of-words | as contagens de suas palavras, uma coluna por palavra | contagem, sem modelo | ver a ordem das palavras, sinônimos ou palavras não vistas no fit |
| E2.2 TF-IDF | contagens pesadas pela raridade de cada palavra | contagem sobre o corpus | o mesmo: ele só muda o peso das colunas |
| E2.3 Word embeddings | um vetor denso por palavra (a média deles, para um documento) | coocorrência num corpus grande | separar os sentidos de uma mesma palavra |
| E2.4 Sentence embeddings | um vetor denso por frase ou documento curto | um transformer pré-treinado | ser barato ou explicar suas dimensões |
Bag-of-Words
A representação mais simples: um documento vira o vetor das contagens das suas palavras (uma coluna por palavra do vocabulário) e a ordem das palavras é descartada. Os três documentos acima viram uma matriz 3 × 8:
import pandas as pd
bow = CountVectorizer()
X = bow.fit_transform(docs) # matriz esparsa: 3 linhas, 8 colunas
print(pd.DataFrame(X.toarray(), columns=bow.get_feature_names_out(), index=['doc 1', 'doc 2', 'doc 3']))
cachorro comeu do dorme gato peixe ração sonha
doc 1 0 1 0 0 1 1 0 0
doc 2 1 1 1 0 1 0 1 0
doc 3 0 0 0 1 2 0 0 1
Cada linha é um documento, cada coluna uma palavra, cada célula uma contagem: "gato" aparece duas vezes no documento 3 ("Gato dorme, gato sonha"). Das 24 células, 13 já são zero. Os 11.314 posts da parte de treino da coleção 20 Newsgroups (Lang, 1995), posts de grupos de discussão da Usenet do começo dos anos 1990, viram uma matriz 11.314 × 101.631 com 99,90% de zeros, guardada num formato esparso que só armazena as células não nulas. As contagens brutas são dominadas por palavras funcionais ("the" está em 9.475 dos 11.314 posts); é por isso que raramente são usadas sem peso. A página de bag-of-words monta a matriz à mão e com o CountVectorizer, compara contagem com presença (binary=True), mede o custo dos n-gramas e tem um simulador e um lab.
TF-IDF
O TF-IDF (frequência do termo × frequência inversa nos documentos) multiplica cada contagem por um peso que cai com o número de documentos que contêm a palavra. A versão do scikit-learn, para uma palavra \(t\) num documento \(d\) de um corpus de \(n\) documentos, dos quais \(\text{df}(t)\) contêm \(t\):
seguida da divisão de cada linha pelo seu comprimento. Nos três documentos acima:
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer()
T = tfidf.fit_transform(docs)
print(pd.DataFrame([tfidf.idf_], columns=tfidf.get_feature_names_out(), index=['idf']).round(4))
print(pd.DataFrame(T.toarray(), columns=tfidf.get_feature_names_out(), index=['doc 1', 'doc 2', 'doc 3']).round(3))
cachorro comeu do dorme gato peixe ração sonha
idf 1.6931 1.2877 1.6931 1.6931 1.0 1.6931 1.6931 1.6931
cachorro comeu do dorme gato peixe ração sonha
doc 1 0.000 0.548 0.000 0.000 0.425 0.72 0.000 0.000
doc 2 0.505 0.384 0.505 0.000 0.298 0.00 0.505 0.000
doc 3 0.000 0.000 0.000 0.543 0.641 0.00 0.000 0.543
"gato", presente nos três documentos, recebe idf 1; "comeu", presente em dois, recebe 1,2877; uma palavra presente em um só recebe 1,6931. Compare o documento 1 com a sua linha na bag-of-words: as contagens eram 1, 1, 1; agora "gato" pesa 0,425 e "peixe", que só o documento 1 tem, pesa 0,720. Cada linha tem comprimento 1 e os zeros continuam onde estavam. A ideia vem de Spärck Jones (1972) e virou o padrão da recuperação de informação com Salton & Buckley (1988). A página de TF-IDF calcula cada número à mão, confere que o TfidfVectorizer concorda até a quarta casa decimal, compara documentos pela similaridade de cosseno e monta um pequeno buscador.
3. Similaridade e distância
Etapa 3. Quando dois textos viram vetores, "quão parecidos eles são?" vira um número calculado a partir dos dois vetores. Quatro medidas cobrem quase todos os usos:
- similaridade de cosseno: compara as direções dos dois vetores e ignora os seus comprimentos. 1 significa que eles apontam para o mesmo lado (as mesmas palavras nas mesmas proporções); 0 significa que não têm nada em comum; com contagens, ela nunca é negativa. É a medida usada em toda esta aula;
- distância euclidiana: a distância em linha reta entre os dois pontos, a que o k-means usa. 0 significa vetores idênticos; ela cresce com qualquer diferença, inclusive de comprimento;
- produto escalar: multiplique os dois vetores coordenada a coordenada e some. Ele é grande quando os dois têm valores grandes nas mesmas colunas; por isso cresce com o tamanho dos documentos;
- similaridade de Jaccard: esqueça as contagens e compare os dois conjuntos de tokens: o número de tokens em comum dividido pelo número de tokens distintos nos dois. 1 significa o mesmo conjunto de palavras; 0, nenhuma palavra em comum.
Pegue o documento 1 da bag-of-words, o documento 3 e um terceiro documento que é o documento 1 escrito três vezes: o mesmo conteúdo, três vezes mais longo:
from sklearn.metrics import jaccard_score
from sklearn.metrics.pairwise import cosine_similarity, euclidean_distances
A, B, A3 = bow.transform(["O gato comeu o peixe.",
"Gato dorme, gato sonha.",
"O gato comeu o peixe. " * 3]).toarray() # A3: o documento A três vezes
print('columns:', list(bow.get_feature_names_out()))
print('A =', A, ' B =', B, ' A3 =', A3)
for name, u, v in [('A and B ', A, B), ('A and A3', A, A3), ('B and A3', B, A3)]:
print(f"{name} cosine {cosine_similarity([u], [v])[0, 0]:.3f} · Euclidean {euclidean_distances([u], [v])[0, 0]:.3f}"
f" · dot {u @ v:2d} · Jaccard {jaccard_score(u > 0, v > 0):.3f}")
columns: ['cachorro', 'comeu', 'do', 'dorme', 'gato', 'peixe', 'ração', 'sonha']
A = [0 1 0 0 1 1 0 0] B = [0 0 0 1 2 0 0 1] A3 = [0 3 0 0 3 3 0 0]
A and B cosine 0.471 · Euclidean 2.236 · dot 2 · Jaccard 0.200
A and A3 cosine 1.000 · Euclidean 3.464 · dot 9 · Jaccard 1.000
B and A3 cosine 0.471 · Euclidean 4.583 · dot 6 · Jaccard 0.200
Leia como um problema de busca: a consulta é A; os candidatos são B (outro assunto, que só tem "gato" em comum) e A3 (exatamente o mesmo assunto, só que mais longo).
- o cosseno diz o que queremos: A3 é idêntico a A (1,000) e B é uma correspondência parcial (0,471). B contra A3 também recebe 0,471, o mesmo que B contra A: repetir um documento não muda a sua direção;
- a distância euclidiana inverte a resposta: A3 fica mais longe de A (3,464) do que B (2,236), só porque A3 é mais longo;
- o produto escalar cresce com o comprimento: B contra A3 marca 6, o triplo do seu escore contra A, para o mesmo conteúdo;
- o Jaccard concorda com o cosseno aqui (1,000 e 0,200) porque só olha quais palavras estão presentes, o que também o deixa cego a quantas vezes elas aparecem.
É por isso que o cosseno é o padrão para texto: documentos têm todos os tamanhos; o tamanho de um documento diz pouco sobre o seu assunto. Quando os vetores são antes escalados para comprimento 1, as três medidas concordam: o produto escalar é o cosseno e a distância euclidiana ordena todos os pares na ordem inversa do cosseno. As linhas do TF-IDF já têm comprimento 1 (o scikit-learn divide cada linha pelo seu comprimento) e os sentence embeddings costumam ser pedidos com normalize_embeddings=True; na prática, eles são comparados por um produto escalar.
4. Os limites das representações esparsas
No bag-of-words e no TF-IDF, cada palavra é um eixo próprio, ortogonal a todas as outras. A página de TF-IDF mede as consequências em pares de frases:
- sinônimos dão zero: "o carro é rápido" e "o automóvel é veloz" têm cosseno 0,00;
- a ordem das palavras é invisível: "o cachorro mordeu o homem" e "o homem mordeu o cachorro" têm cosseno 1,00;
- uma grafia, dois sentidos: manga (da camisa ou a fruta) faz duas frases sem relação parecerem próximas, enquanto duas frases de mesmo sentido e palavras diferentes parecem não ter relação (0,14 nos dois casos);
- palavras novas somem: uma palavra não vista no
fitnão tem coluna, então uma frase feita só de palavras novas vira o vetor nulo.
No lab de TF-IDF, os 10 melhores resultados para "image" e os 10 melhores para "picture" não têm nenhum post em comum. São essas falhas que motivaram as representações densas.
5. Embeddings densos
De volta à etapa 2, agora para a família densa.
"Você conhecerá uma palavra pelas companhias que ela mantém" (Firth, 1957). A hipótese distribucional (Harris, 1954) diz que palavras usadas em contextos parecidos têm significados parecidos: "carro" e "automóvel" raramente aparecem na mesma frase, mas aparecem ao lado das mesmas palavras (motor, concessionária, pneu). As representações densas exploram isso. Em vez de uma coluna por palavra, cada palavra ou texto recebe um vetor de algumas centenas de números, aprendido num corpus grande de modo que contextos parecidos deem vetores próximos. Nenhum número isolado significa algo; só as distâncias entre vetores significam.
Word embeddings
O word2vec (Mikolov et al., 2013) aprende um vetor por palavra treinando uma rede pequena para prever as palavras em volta de cada palavra; o GloVe (Pennington et al., 2014) chega a vetores parecidos fatorando uma tabela de contagens de coocorrência. Sinônimos ficam próximos e algumas direções do espaço codificam relações. A limitação é um vetor por palavra, qualquer que seja o sentido. A página de word embeddings mostra como esses vetores são aprendidos e o que eles capturam.
Como é um desses vetores: os vetores GloVe pré-treinados, treinados em 6 bilhões de tokens de notícias e da Wikipédia em inglês, na sua menor versão (400.000 palavras, 50 números cada). São vetores do inglês, então os exemplos são palavras em inglês:
import gensim.downloader as api # pip install gensim
glove = api.load('glove-wiki-gigaword-50') # ~66 MB, baixado uma vez
print('cat =', glove['cat'][:8].round(3), '... (50 numbers, none of them zero)')
print('nearest to cat:', ', '.join(f"{w} {c:.2f}" for w, c in glove.most_similar('cat', topn=5)))
for a, b in [('car', 'automobile'), ('car', 'banana')]:
print(f"cosine {a} / {b}: {glove.similarity(a, b):.2f}")
cat = [ 0.453 -0.501 -0.537 -0.016 0.222 0.546 -0.673 -0.689] ... (50 numbers, none of them zero)
nearest to cat: dog 0.92, rabbit 0.85, monkey 0.80, rat 0.79, cats 0.79
cosine car / automobile: 0.70
cosine car / banana: 0.22
Os 50 números de "cat" (gato) não significam nada um a um; o que carrega o sentido é quais vetores estão por perto, medido pelo cosseno: as palavras mais próximas de "cat" são outros animais, a começar por "dog" (cachorro, 0,92). "car" e "automobile", que nunca dividiram uma coluna na bag-of-words, agora têm cosseno 0,70, contra 0,22 de "car" e "banana".
Sentence embeddings: contextuais e de documento inteiro
Modelos transformer como o BERT (Devlin et al., 2019) calculam o vetor de cada token a partir da frase inteira em volta dele, então manga recebe um vetor diferente ao lado de "camisa" e ao lado de "madura". O Sentence-BERT (Reimers & Gurevych, 2019) treina um modelo assim para que um único vetor resuma uma frase ou um documento curto inteiro e para que o cosseno entre dois desses vetores acompanhe a proximidade dos seus significados. A biblioteca sentence-transformers empacota centenas desses modelos, vários deles multilíngues. A página de sentence embeddings mostra como eles são construídos e como usá-los; o resto desta página usa um deles.
É um modelo multilíngue pequeno do sentence-transformers (a página de sentence embeddings o explica): cada texto vira 384 números; textos em mais de 50 línguas compartilham o mesmo espaço. Carregá-lo baixa cerca de 470 MB na primeira vez.
# pip install sentence-transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
emb = model.encode(["o carro é rápido",
"o automóvel é veloz", # o mesmo sentido, nenhuma palavra em comum
"a taxa de juros subiu"], # sem relação
normalize_embeddings=True)
print(emb.shape)
print('o carro é rápido =', emb[0][:8].round(3), '... (384 numbers)')
for j in [1, 2]:
print(f"vs {j}: cosine {cosine_similarity(emb[[0]], emb[[j]])[0, 0]:.2f} · dot {emb[0] @ emb[j]:.2f}"
f" · Euclidean {euclidean_distances(emb[[0]], emb[[j]])[0, 0]:.2f}")
(3, 384)
o carro é rápido = [ 0.016 0.033 -0.08 0.037 -0.014 -0.002 0.039 -0.028] ... (384 numbers)
vs 1: cosine 0.97 · dot 0.97 · Euclidean 0.25
vs 2: cosine 0.23 · dot 0.23 · Euclidean 1.24
Uma frase inteira vira 384 números, qualquer que seja o seu tamanho. A paráfrase, sem nenhuma palavra em comum, recebe cosseno 0,97; a frase sem relação, 0,23. Como os vetores foram escalados para comprimento 1 (normalize_embeddings=True), o produto escalar é igual ao cosseno e a distância euclidiana conta a mesma história ao contrário: pequena para a paráfrase, grande para o par sem relação.
6. Representações esparsas vs densas
As duas famílias produzem vetores de formatos muito diferentes. Pegue uma frase em inglês (para que as três representações consigam lê-la) e represente-a de três jeitos: uma bag-of-words sobre o vocabulário dos 11.314 posts dos newsgroups, a média dos vetores GloVe das suas palavras e o sentence embedding:
import numpy as np
from sklearn.datasets import fetch_20newsgroups
news = fetch_20newsgroups(subset='train', remove=('headers', 'footers', 'quotes')) # os 11.314 posts
s_en = "the cat sleeps on the couch"
bow_news = CountVectorizer().fit(news.data) # as 101.631 palavras dos newsgroups
x = bow_news.transform([s_en])
vocab = bow_news.get_feature_names_out()
print(f"bag-of-words length {x.shape[1]:>7,} · non-zero {x.nnz:>3}:",
', '.join(f"{vocab[j]} (column {j:,}) = {v}" for j, v in zip(x.indices, x.data)), '· all others 0')
g = np.mean([glove[w] for w in s_en.split()], axis=0) # média dos 6 vetores de palavras
print(f"GloVe average length {g.size:>7,} · non-zero {np.count_nonzero(g):>3}:", g[:6].round(3), '...')
e = model.encode(s_en)
print(f"sentence emb. length {e.size:>7,} · non-zero {np.count_nonzero(e):>3}:", e[:6].round(3), '...')
bag-of-words length 101,631 · non-zero 5: cat (column 26,019) = 1, couch (column 30,064) = 1, on (column 68,080) = 1, sleeps (column 83,252) = 1, the (column 88,532) = 2 · all others 0
GloVe average length 50 · non-zero 50: [ 0.271 0.037 -0.335 -0.147 0.541 0.055] ...
sentence emb. length 384 · non-zero 384: [ 0.39 -0.348 -0.135 0.145 0.115 0.278] ...
| esparsa (bag-of-words, TF-IDF) | densa (word e sentence embeddings) | |
|---|---|---|
| comprimento | uma coluna por palavra do vocabulário: aqui, 101.631 | fixado pelo modelo: 50 neste GloVe, 384 neste modelo de frases |
| valores não nulos | só as palavras do texto: aqui, 5 | todos |
| o que uma coordenada significa | legível: a coluna 26.019 é "quantas vezes cat aparece" | nada sozinha; só as distâncias entre vetores significam algo |
| como é guardada | só as células não nulas (índice, valor) | um vetor comum de números reais |
| dois textos sem palavra em comum | nenhuma coluna não nula em comum: cosseno 0 | ainda podem ficar próximos |
Esparso quer dizer que quase toda coordenada é zero e que as poucas não nulas são legíveis: cada uma é uma palavra do texto. Denso quer dizer que toda coordenada tem um valor (nenhum legível sozinho) e que a informação mora na geometria. Essa é toda a diferença entre as duas metades desta aula e ela decide tudo o que vem depois: vetores esparsos só aproximam textos que dividem palavras; vetores densos aproximam textos que dividem sentido.
Agora compare as quatro técnicas você mesmo. Escolha duas frases ou um dos pares prontos e observe os dois vetores em cada representação (as faixas da bag-of-words e do TF-IDF quase vazias, as densas cheias), além do cosseno, da distância euclidiana e do produto escalar de cada uma. Comece pela paráfrase (nenhuma palavra em comum), depois a ordem das palavras, depois a palavra ambígua banco (o banco que sobe os juros, o banco da praça) contra o par de mesmo sentido em outras palavras; por fim, marque B três vezes e comprimento 1 e veja quais números mudam. Os vetores GloVe só existem para o inglês; com as frases em português, essa faixa usa a frase em inglês do mesmo item.
Na paráfrase, as duas representações esparsas dão cosseno 0 e as duas densas não. Com a ordem trocada, bag-of-words, TF-IDF e média GloVe ficam idênticos, porque nenhum deles vê a ordem; só o sentence embedding se mexe, mas pouco. Repetir B muda a distância euclidiana e o produto escalar da bag-of-words, mas não o seu cosseno; com comprimento 1, o produto escalar e o cosseno viram o mesmo número.
7. Esparso vs denso: quando usar cada um
Etapa 4. Tudo nesta seção é medido com o modelo multilíngue carregado acima, contra o TF-IDF do scikit-learn.
Similaridade: os mesmos pares, dos dois jeitos
Os pares da seção de limites e mais alguns. O TF-IDF é ajustado em todas as frases da tabela:
pairs_all = [("o carro é rápido", "o automóvel é veloz"),
("comprei um celular novo", "adquiri um smartphone novo"),
("o gato dorme no sofá", "the cat sleeps on the couch"),
("a manga da camisa rasgou", "o punho da blusa descosturou"),
("a manga da camisa rasgou", "comi uma manga madura"),
("o filme é muito bom", "o filme não é bom"),
("o cachorro mordeu o homem", "o homem mordeu o cachorro"),
("a taxa de juros subiu", "o time venceu o jogo")]
sentences = list(dict.fromkeys(s for p in pairs_all for s in p)) # sem repetição, na ordem
tf_pairs = TfidfVectorizer().fit(sentences)
rows = []
for a, b in pairs_all:
e = model.encode([a, b], normalize_embeddings=True)
rows.append({'A': a, 'B': b,
'TF-IDF': cosine_similarity(tf_pairs.transform([a, b]))[0, 1],
'embedding': e[0] @ e[1]})
print(pd.DataFrame(rows).round(2).to_string(index=False))
A B TF-IDF embedding
o carro é rápido o automóvel é veloz 0.00 0.97
comprei um celular novo adquiri um smartphone novo 0.43 0.91
o gato dorme no sofá the cat sleeps on the couch 0.00 0.98
a manga da camisa rasgou o punho da blusa descosturou 0.21 0.87
a manga da camisa rasgou comi uma manga madura 0.21 0.35
o filme é muito bom o filme não é bom 0.60 0.46
o cachorro mordeu o homem o homem mordeu o cachorro 1.00 0.98
a taxa de juros subiu o time venceu o jogo 0.00 0.14
Linha a linha:
- paráfrases ("comprei um celular novo" / "adquiri um smartphone novo"): 0,91 contra 0,43; o TF-IDF só enxerga "um" e "novo";
- tradução (linha 3): a frase em português e sua tradução para o inglês não têm nenhum token em comum, mas o modelo multilíngue as coloca em 0,98. Uma consulta em português pode recuperar documentos escritos em inglês;
- as duas linhas de "manga": o TF-IDF dá o mesmo 0,21 aos dois pares, enquanto o embedding separa o par manga-e-punho (0,87) do par manga-e-fruta (0,35);
- negação ("o filme é muito bom" / "o filme não é bom"): o embedding fica abaixo do TF-IDF (0,46 contra 0,60), mas longe de "oposto". Duas frases sobre o quanto o mesmo filme é bom se sobrepõem bastante em significado;
- ordem das palavras: 0,98. Os sentence embeddings também confundem quem mordeu quem;
- frases sem relação dão 0,14, não 0. Os cossenos de embeddings ocupam uma escala comprimida: o que carrega a informação é a ordenação (0,97 contra 0,14), não o valor absoluto. Um limiar ajustado para um modelo não vale para outro.
Tokens raros e exatos: onde o TF-IDF é mais forte
Considere duas entradas de uma central de ajuda que só diferem pelo código de erro, com uma consulta para cada código:
entries = ["O que fazer quando aparece o erro 0x80070005 na instalação?",
"O que fazer quando aparece o erro 0x80004005 na atualização?"]
tf_entries = TfidfVectorizer().fit(entries)
E_entries = model.encode(entries, normalize_embeddings=True)
for q in ["erro 0x80070005", "erro 0x80004005"]:
t = cosine_similarity(tf_entries.transform([q]), tf_entries.transform(entries))[0]
e = E_entries @ model.encode(q, normalize_embeddings=True)
print(f"{q}: TF-IDF {t.round(2)} embedding {e.round(2)}")
erro 0x80070005: TF-IDF [0.55 0.18] embedding [0.8 0.77]
erro 0x80004005: TF-IDF [0.18 0.55] embedding [0.81 0.78]
As duas representações põem a entrada certa em primeiro lugar, mas observe as margens. Para o TF-IDF, o código é o token mais distintivo de cada entrada e decide a correspondência, 0,55 contra 0,18. Para o embedding, as duas entradas são "uma mensagem de erro na instalação ou atualização"; o código altera o escore em só 0,03 (0,80 contra 0,77). Com centenas de códigos parecidos, nomes de produtos ou referências a leis, a correspondência exata é o que você quer; sistemas de busca em produção costumam combinar as duas ordenações (busca híbrida).
Classificação: quantos rótulos você tem?
Quase todo projeto acaba precisando de uma previsão (encaminhar um chamado, etiquetar um post); a representação vira, então, a entrada de um modelo supervisionado. Nenhum foi ensinado ainda, então usamos uma regressão logística como caixa-preta (ela tem sua própria aula mais adiante): ela recebe um vetor e um rótulo para cada post de treino e aprende a prever os rótulos de posts novos. Só a representação muda.
Quatro newsgroups da coleção 20 Newsgroups (carros, medicina, espaço, armas), com a divisão oficial do scikit-learn: o modelo aprende com os posts mais antigos e é avaliado nos mais recentes, um hold-out que ele nunca vê durante o treino. Para ver quanto dado rotulado cada representação precisa, treinamos com 5, 10, 25 e 100 posts rotulados por classe e, depois, com todos:
from sklearn.datasets import fetch_20newsgroups
from sklearn.linear_model import LogisticRegression
def load(subset, cats):
d = fetch_20newsgroups(subset=subset, categories=cats,
remove=('headers', 'footers', 'quotes'), random_state=0)
keep = [i for i, t in enumerate(d.data) if len(t.split()) >= 20] # descarta posts vazios ou muito curtos
return [d.data[i] for i in keep], d.target[keep]
cats = ['rec.autos', 'sci.med', 'sci.space', 'talk.politics.guns']
docs_tr, y_tr = load('train', cats) # posts mais antigos
docs_te, y_te = load('test', cats) # posts mais recentes: o hold-out
E_tr = model.encode(docs_tr, normalize_embeddings=True)
E_te = model.encode(docs_te, normalize_embeddings=True)
print(len(docs_tr), 'training posts ·', len(docs_te), 'test posts')
rng = np.random.default_rng(0)
print('labeled posts per class | TF-IDF + clf | embedding + clf')
for n in [5, 10, 25, 100, 'all']:
if n == 'all':
idx = np.arange(len(docs_tr))
else:
idx = np.concatenate([rng.choice(np.flatnonzero(y_tr == c), n, replace=False) for c in range(4)])
vec = TfidfVectorizer(stop_words='english').fit([docs_tr[i] for i in idx])
sparse_clf = LogisticRegression(max_iter=2000).fit(vec.transform([docs_tr[i] for i in idx]), y_tr[idx])
dense_clf = LogisticRegression(max_iter=2000).fit(E_tr[idx], y_tr[idx])
print(f"{str(n):>23s} | {sparse_clf.score(vec.transform(docs_te), y_te):11.3f} | "
f"{dense_clf.score(E_te, y_te):15.3f}")
2097 training posts · 1364 test posts
labeled posts per class | TF-IDF + clf | embedding + clf
5 | 0.557 | 0.819
10 | 0.601 | 0.851
25 | 0.725 | 0.886
100 | 0.850 | 0.897
all | 0.904 | 0.901
O escore é a acurácia nos 1.364 posts de teste; chutar sempre uma classe daria cerca de 25%. Com 5 posts rotulados por classe, o classificador sobre embeddings chega a 81,9% e o sobre TF-IDF a 55,7%. Com 20 posts de treino no total, o TF-IDF viu só algumas centenas de palavras distintas; um post de teste que por acaso não use nenhuma delas vira um vetor nulo. O modelo de embeddings já chega sabendo que "sedan" fica perto de "car", pelo texto em que foi treinado. À medida que os rótulos se acumulam, o TF-IDF aprende o vocabulário de cada classe a partir dos próprios dados; com todos os 2.097 posts, os dois empatam: 90,4% contra 90,1%.
O que os escores não mostram é o custo. No notebook usado para escrever esta página, o TfidfVectorizer processa os 2.097 posts de treino em 0,2 segundo, enquanto o modelo de embeddings precisa de cerca de um minuto na mesma CPU (10 segundos na GPU do notebook), depois de baixar 470 MB.
| Esparso (bag-of-words, TF-IDF) | Denso (sentence embeddings) | |
|---|---|---|
| tamanho do vetor de um documento | \(\lvert V \rvert\) colunas (10⁴–10⁶), 99,9% de zeros | 384–1.024 números, nenhum nulo |
| sinônimos, paráfrases | ortogonais: cosseno 0,00 | próximos: cosseno 0,97 |
palavras nunca vistas no fit | descartadas | tratadas (pedaços de subpalavras) |
| tokens raros e exatos (códigos, nomes, jargão) | idf alto, sinal forte | diluídos no sentido geral |
| outras línguas | vocabulários separados | espaço compartilhado com um modelo multilíngue |
| poucos exemplos rotulados (5 por classe, acima) | 55,7% | 81,9% |
| muitos exemplos rotulados (os 2.097, acima) | 90,4% | 90,1% |
| interpretabilidade | cada coluna é uma palavra | colunas abstratas |
| custo | frações de segundo numa CPU, sem modelo | um modelo pré-treinado; uma GPU ajuda |
| usos típicos | busca por palavras-chave, baselines lineares fortes, jargão de domínio | busca semântica, poucos rótulos, agrupamento, BERTopic |
Na prática: comece pelo TF-IDF, que é barato e difícil de bater quando há muitos dados rotulados e vocabulário de domínio; recorra aos embeddings quando o significado importa mais que as palavras exatas (busca, textos curtos, paráfrases, várias línguas) ou quando os rótulos são escassos; e quando tanto os tokens exatos quanto o significado importam, combine os dois.
Para onde isso leva
Modelagem de Tópicos & BERTopic usa as duas metades desta aula. Ela gera embeddings dos documentos com um sentence-transformer, reduz os vetores com UMAP, agrupa-os com HDBSCAN e depois descreve cada grupo com uma variante do TF-IDF calculada por agrupamento. Esse pipeline é o BERTopic.
8. Resumo: representação de texto em cinco linhas
- O texto vira tokens primeiro: minúsculas, pontuação, palavras de uma letra, stop words e n-gramas são decisões; cada uma muda as colunas.
- Vetores esparsos contam: o bag-of-words e o TF-IDF têm uma coluna por palavra do vocabulário, quase todas zero, cada uma legível.
- Vetores densos são aprendidos: word e sentence embeddings têm algumas centenas de números, todos não nulos, nenhum legível sozinho; textos de sentido parecido ficam próximos mesmo sem nenhuma palavra em comum.
- Compare com o cosseno: ele ignora o comprimento; com comprimento 1, o produto escalar é o cosseno.
- Escolha pela tarefa: tokens raros e exatos e muitos rótulos favorecem o TF-IDF; paráfrases, outras línguas e poucos rótulos favorecem os embeddings; a busca híbrida combina os dois.
| sintoma | o que está acontecendo | o que fazer |
|---|---|---|
| duas paráfrases dão 0 | os vetores esparsos não compartilham coluna | sentence embeddings |
| uma cópia mais longa de um documento parece longe dele | a distância euclidiana cresce com o comprimento | o cosseno ou vetores de comprimento 1 |
| a busca por um código de erro põe um código parecido quase tão alto | o embedding borra os tokens exatos | TF-IDF ou busca híbrida |
| um classificador treinado com 5 rótulos por classe vai mal no TF-IDF | rótulos de menos para aprender o vocabulário de cada classe | os embeddings de um modelo pré-treinado |
Lab fundamental — esparso vs denso em posts reais
Quatro newsgroups da coleção 20 Newsgroups (computação gráfica, beisebol, eletrônica e cristianismo), cerca de 2.100 posts, representados duas vezes: com TF-IDF e com sentence embeddings. O lab coloca os dois lado a lado nas tarefas desta página: achar posts sobre o mesmo assunto, responder consultas de busca, desenhar um mapa e alimentar um classificador caixa-preta com muitos e com poucos rótulos. Os labs de bag-of-words e de TF-IDF olham por dentro da representação esparsa dos mesmos posts.
Esta parte roda os passos 1 a 4: as duas representações, os seus vizinhos e uma busca; o mapa dos dois espaços e o classificador ficam para o lab avançado.
Como fazer este lab em aula (cerca de 30 min, em duplas)
- Baixe o notebook do lab e abra no Jupyter ou no Colab (Arquivo → Fazer upload de notebook); no Colab, rode
!pip install sentence-transformersna primeira célula. Rode as células em ordem, numa mesma sessão. - Este lab é formado pelos passos 1 a 4 do notebook. O lab avançado continua no mesmo notebook, a partir do passo 5.
- O passo 2 codifica cada post com uma rede neural: é a maior parte dos dois minutos do lab numa CPU e poucos segundos com GPU no Colab. Comece cedo e continue lendo enquanto ele roda.
- Todo passo começa com um Preveja: responda com sua dupla antes de rodar a célula. Errar a previsão é o que faz o resultado ficar na memória.
- Depois de rodar, compare com a saída e a leitura desta página. Toda saída aqui é exatamente o que o código imprime.
- As Simulações pedem que você escreva um pequeno experimento em Python: mude uma entrada e veja o que acontece com a saída. Nada de contas à mão. A solução está recolhida logo abaixo: tente antes de abrir.
- Ritmo sugerido: passos 1 e 2 em 8 min; passo 3 e a primeira simulação em 10 min; passo 4 e a segunda simulação em 12 min.
Passo 1 — carregar os posts
Preveja. São os mesmos quatro newsgroups dos labs das subpáginas. Qual você acha que é o tamanho de um post típico: uma frase, um parágrafo ou uma página?
# No Colab, rode uma vez: !pip install sentence-transformers
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_20newsgroups
cats = ['comp.graphics', 'rec.sport.baseball', 'sci.electronics', 'soc.religion.christian']
raw = fetch_20newsgroups(subset='train', categories=cats,
remove=('headers', 'footers', 'quotes'), random_state=0)
keep = [i for i, t in enumerate(raw.data) if len(t.split()) >= 20] # descarta posts vazios ou muito curtos
docs = [raw.data[i] for i in keep]
y = raw.target[keep]
names = np.array(raw.target_names)
print(len(docs), 'posts:', ', '.join(f"{g} {c}" for g, c in zip(names, np.bincount(y))))
print(f"words per post: median {np.median([len(d.split()) for d in docs]):.0f}")
2116 posts: comp.graphics 521, rec.sport.baseball 495, sci.electronics 535, soc.religion.christian 565
words per post: median 97
Os mesmos 2.116 posts dos labs de bag-of-words e de TF-IDF: quatro grupos equilibrados, sem cabeçalhos, assinaturas ou respostas citadas. Os posts com menos de 20 palavras foram descartados. Um post mediano tem 97 palavras.
Passo 2 — duas representações dos mesmos posts
Preveja. O TF-IDF dá a cada post uma coluna por palavra do vocabulário; o modelo de frases dá 384 números. Qual das duas matrizes vai ocupar mais memória e qual vai demorar mais para ser calculada?
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
tfidf = TfidfVectorizer(stop_words='english', min_df=2)
T = tfidf.fit_transform(docs)
sparse_mb = (T.data.nbytes + T.indices.nbytes + T.indptr.nbytes) / 1e6
print(f"TF-IDF: {T.shape}, {T.nnz / T.shape[0]:.0f} non-zeros per post, {sparse_mb:.1f} MB")
model = SentenceTransformer('all-MiniLM-L6-v2') # inglês, 384 dimensões, ~90 MB
E = model.encode(docs, batch_size=64, normalize_embeddings=True)
print(f"embeddings: {E.shape}, all non-zero, {E.nbytes / 1e6:.1f} MB · reads at most {model.max_seq_length} tokens per post")
TF-IDF: (2116, 12001), 58 non-zeros per post, 1.5 MB
embeddings: (2116, 384), all non-zero, 3.3 MB · reads at most 256 tokens per post
Como ler:
- TF-IDF: 12.001 colunas, mas um post preenche em média só 58 delas; guardada em formato esparso, a matriz inteira ocupa 1,5 MB.
- Embeddings: 384 colunas, nenhuma nula, 3,3 MB em floats de 32 bits. Menos colunas, mas mais memória que a matriz esparsa aqui, porque nada é zero.
- 256 tokens: este modelo lê só o começo de cada post; o resto de um post mais longo é ignorado. O modelo multilíngue da página lê 128.
O tempo vai todo na codificação: o TF-IDF é uma passada de contagem; os embeddings, uma passada de uma rede de 6 camadas por post.
Passo 3 — qual das duas acha posts sobre o mesmo assunto?
Preveja. Para cada post, pegue os seus 5 posts mais próximos. Qual representação vai pôr mais deles no newsgroup do próprio post: o TF-IDF, ajustado nesses mesmos posts, ou um modelo que nunca os viu?
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.neighbors import NearestNeighbors
def agreement(V, k=5):
"""Fração dos k posts mais próximos (cosseno) de cada post que vêm do mesmo newsgroup."""
nn = NearestNeighbors(n_neighbors=k + 1, metric='cosine').fit(V)
_, idx = nn.kneighbors(V)
return (y[idx[:, 1:]] == y[:, None]).mean()
print(f"chance level: {np.sum(np.bincount(y) ** 2) / len(y) ** 2:.3f}")
print(f"raw counts: {agreement(CountVectorizer().fit_transform(docs)):.3f}")
print(f"TF-IDF: {agreement(T):.3f}")
print(f"sentence embeddings: {agreement(E):.3f}")
A concordância de vizinhos dos labs das subpáginas: para cada post, a fração dos seus 5 posts mais próximos (cosseno) que vêm do mesmo newsgroup; 0,251 por acaso. As contagens brutas chegam a 0,490, o TF-IDF a 0,850 e os sentence embeddings a 0,948. O modelo de embeddings nunca viu estes newsgroups nem seus rótulos: ele chega com uma noção de quais textos falam de coisas parecidas, aprendida no texto em que foi treinado. Essa noção coincide bem com os newsgroups.
Simulação: quantos vizinhos? A concordância conta os 5 posts mais próximos. Repita com k = 1, 25 e 100 nas duas representações. Qual delas piora mais depressa quando a vizinhança cresce?
Solução
for k in [1, 5, 25, 100]:
print(f"k = {k:3d}: TF-IDF {agreement(T, k):.3f} · embeddings {agreement(E, k):.3f}")
k = 1: TF-IDF 0.907 · embeddings 0.969
k = 5: TF-IDF 0.850 · embeddings 0.948
k = 25: TF-IDF 0.764 · embeddings 0.909
k = 100: TF-IDF 0.655 · embeddings 0.857
As duas caem quando k cresce, porque o 100º post mais próximo está mais longe e vem mais vezes de outro grupo. O TF-IDF cai mais depressa, de 0,907 com k = 1 para 0,655 com k = 100, enquanto os embeddings vão de 0,969 a 0,857. Com k = 1, o TF-IDF é quase tão bom: o post mais próximo costuma ser uma resposta na mesma discussão, que compartilha as suas palavras. Mais longe, os posts sobre o mesmo assunto compartilham menos palavras exatas e só o espaço denso ainda os mantém juntos.
Passo 4 — busca semântica contra busca por palavras-chave
Preveja. Seis consultas escritas à mão para os quatro grupos. Quais o TF-IDF vai responder bem? Os embeddings vão alguma vez se sair pior que o TF-IDF?
from sklearn.metrics.pairwise import cosine_similarity
queries = {"my picture looks blurry after I enlarge it": 'comp.graphics',
"software to convert a photo from one file format to another": 'comp.graphics',
"the umpire made a terrible call last night": 'rec.sport.baseball',
"why does my TV screen flicker": 'sci.electronics',
"how to stop my circuit from overheating": 'sci.electronics',
"does the soul survive after death": 'soc.religion.christian'}
T_q = tfidf.transform(list(queries))
E_q = model.encode(list(queries), normalize_embeddings=True)
hits = np.zeros(2, dtype=int)
for (q, group), tq, eq in zip(queries.items(), T_q, E_q):
top_s = np.argsort(-cosine_similarity(tq, T).ravel())[:5] # os 5 melhores posts, TF-IDF
top_d = np.argsort(-(E @ eq))[:5] # os 5 melhores posts, embeddings
h = np.sum(names[y[top_s]] == group), np.sum(names[y[top_d]] == group)
hits += h
print(f"{q:60s} TF-IDF {h[0]}/5 · embedding {h[1]}/5")
print(f"{'total':60s} TF-IDF {hits[0]}/30 · embedding {hits[1]}/30")
print('best embedding hit for the last query:', ' '.join(docs[top_d[0]].split())[:100])
my picture looks blurry after I enlarge it TF-IDF 3/5 · embedding 5/5
software to convert a photo from one file format to another TF-IDF 5/5 · embedding 5/5
the umpire made a terrible call last night TF-IDF 3/5 · embedding 5/5
why does my TV screen flicker TF-IDF 3/5 · embedding 5/5
how to stop my circuit from overheating TF-IDF 5/5 · embedding 5/5
does the soul survive after death TF-IDF 2/5 · embedding 5/5
total TF-IDF 21/30 · embedding 30/30
best embedding hit for the last query: Now this is getting interesting! I was raised Roman Catholic before becoming an atheist, so I have s
Cada consulta foi escrita à mão para um dos quatro grupos; contamos quantos dos 5 posts recuperados vêm desse grupo: 30 de 30 para os embeddings, 21 de 30 para o TF-IDF. O TF-IDF acerta os 5 quando a consulta usa as palavras do próprio grupo ("convert", "file", "format"; "circuit") e cai para 2 ou 3 quando descreve o assunto com outras palavras ou usa palavras fora do seu vocabulário ("flicker"). O melhor resultado dos embeddings para "does the soul survive after death" é um post que pergunta onde a alma fica entre a morte e a ressurreição: exatamente o assunto da consulta.
Simulação: sinônimos de uma palavra. No lab de TF-IDF, os 10 melhores posts para "image" e os 10 melhores para "picture" não tinham nenhum post em comum; o mesmo valia para "faith" e "belief". Busque essas mesmas consultas de uma palavra com as duas representações deste lab e conte os posts que as duas listas de top-10 compartilham.
Solução
for a, b in [('image', 'picture'), ('faith', 'belief')]:
top_s = [set(np.argsort(-cosine_similarity(tfidf.transform([w]), T).ravel())[:10]) for w in (a, b)]
top_d = [set(np.argsort(-(E @ model.encode(w, normalize_embeddings=True)))[:10]) for w in (a, b)]
print(f"{a!r} and {b!r}: top-10 in common · TF-IDF {len(top_s[0] & top_s[1])} · embeddings {len(top_d[0] & top_d[1])}")
'image' and 'picture': top-10 in common · TF-IDF 0 · embeddings 5
'faith' and 'belief': top-10 in common · TF-IDF 0 · embeddings 3
O TF-IDF repete o resultado da subpágina: nenhum post em comum nos dois pares, porque uma consulta de uma palavra só casa com posts que contêm aquela mesma palavra. Com embeddings, as duas listas compartilham 5 de 10 posts em image/picture e 3 de 10 em faith/belief: o modelo põe as duas palavras, e os posts sobre elas, na mesma região. Não todos os 10, porém: as palavras são próximas, não idênticas; uma palavra só é uma consulta magra para um modelo treinado com frases.
Para fechar a parte fundamental. Em uma frase cada: (1) por que o cosseno, e não a distância euclidiana, é o jeito padrão de comparar textos? (2) Por que "o carro é rápido" e "o automóvel é veloz" dão 0 no TF-IDF e 0,97 num sentence embedding? (3) Quando você ainda escolheria o TF-IDF em vez dos embeddings?
Sua vez (fundamental)
Em casa:
- Busca entre línguas. Troque o modelo do passo 2 por
paraphrase-multilingual-MiniLM-L12-v2e busque nos posts em inglês com as consultas do passo 4 traduzidas para o português ("minha foto fica borrada quando eu amplio", ...). Quantos dos 30 resultados são do grupo certo? Depois tente as consultas em português com o TF-IDF. -
Busca híbrida. Para cada consulta do passo 4, ordene os posts por \(0{,}5 \cdot \cos_{\text{TF-IDF}} + 0{,}5 \cdot \cos_{\text{embedding}}\). A combinação chega a vencer os embeddings sozinhos? Escreva uma consulta em que o TF-IDF deveria vencer (um nome de produto raro que aparece em poucos posts) e teste.
-
Posts longos. O
all-MiniLM-L6-v2lê só os primeiros 256 tokens de cada post. Divida cada post em pedaços de cerca de 150 palavras, gere os embeddings dos pedaços e represente o post pela média dos vetores dos pedaços. A concordância de vizinhos do passo 3 muda?
Abordagem avançada
A parte fundamental cortou o texto em tokens com os padrões do scikit-learn e seguiu para os vetores. Esta parte volta para dentro da etapa 1, onde o vocabulário é decidido: quão rápido ele cresce com o corpus (nunca para), como as ferramentas juntam as variantes de uma palavra e como os tokenizadores dos modelos neurais evitam de vez as palavras desconhecidas. Os blocos de código continuam os das seções 1 a 7.
Esta parte segue um caminho em três etapas, desenhado abaixo: 1. contar o vocabulário; 2. juntar as variantes; 3. nenhuma palavra desconhecida. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.
- Contar o vocabulárioas leis de Heaps e de Zipf
- Juntar as variantesradicais e lemas, com NLTK e spaCy
- Nenhuma palavra desconhecidaWordPiece, SentencePiece; quem decide o quê
Slides da abordagem avançada
Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova
9. Quão grande fica o vocabulário?
Etapa 1. Três frases deram 8 palavras. Um corpus de verdade: a coleção 20 Newsgroups (Lang, 1995), cerca de 18.000 posts de 20 grupos de discussão da Usenet do começo dos anos 1990, que vem com o scikit-learn. Usamos os 11.314 posts da parte de treino, sem cabeçalhos, assinaturas e respostas citadas, lemos um token de cada vez e contamos quantas palavras distintas já apareceram:
import numpy as np
from sklearn.datasets import fetch_20newsgroups
news = fetch_20newsgroups(subset='train', remove=('headers', 'footers', 'quotes'))
seen, n_tokens, curve = set(), 0, []
for post in news.data:
for tok in analyzer(post):
n_tokens += 1
seen.add(tok)
curve.append((n_tokens, len(seen))) # (N tokens lidos, V palavras distintas) após cada post
curve = np.array(curve, dtype=float)
fit = curve[:, 0] > 1000
beta, logK = np.polyfit(np.log(curve[fit, 0]), np.log(curve[fit, 1]), 1)
print(f"{len(news.data):,} posts · {n_tokens:,} tokens · {len(seen):,} distinct words")
print(f"Heaps fit: V = {np.exp(logK):.1f} · N^{beta:.2f}")
for N in [10_000, 100_000, 1_000_000]:
i = np.searchsorted(curve[:, 0], N)
print(f"after {curve[i, 0]:>9,.0f} tokens: {curve[i, 1]:>7,.0f} words (fit: {np.exp(logK) * curve[i, 0] ** beta:>7,.0f})")
11,314 posts · 2,158,589 tokens · 101,631 distinct words
Heaps fit: V = 15.0 · N^0.61
after 11,025 tokens: 3,279 words (fit: 4,246)
after 100,504 tokens: 18,692 words (fit: 16,210)
after 1,000,517 tokens: 63,306 words (fit: 65,283)
Duas leis empíricas aparecem nesses números, uma em cada figura.
Lei de Heaps (Heaps, 1978): o vocabulário cresce como uma potência do tamanho do texto, \(V \approx K N^{\beta}\), com \(\beta\) entre 0,4 e 0,7 na maioria dos corpora.
Como ler: o eixo x é o número de tokens lidos até ali e o eixo y o número de palavras distintas já vistas; os dois estão em escala log, em que uma lei de potência vira uma reta cuja inclinação é o expoente. A linha pontilhada mostra como seria a curva se todo token fosse uma palavra nova. Aqui \(\beta = 0{,}61\): dez vezes mais texto dá cerca de \(10^{0{,}61} \approx 4\) vezes mais palavras (11 mil tokens dão 3.279 palavras, 100 mil dão 18.692, um milhão dá 63.306). A curva nunca se estabiliza, porque nomes novos, erros de digitação, números e jargões continuam chegando. Um vocabulário montado hoje vai encontrar amanhã palavras que nunca viu; uma representação esparsa não tem coluna para elas.
Lei de Zipf (Zipf, 1949): a frequência cai aproximadamente como uma potência do posto.
Como ler: cada ponto é uma palavra, posicionada pelo seu posto no ranking (1 = a mais frequente) no eixo x e pelo número de vezes que aparece no eixo y, os dois em escala log. No topo, "the" aparece 106.381 vezes e as 10 palavras mais frequentes somam 21% de todos os tokens; no meio, "car" (posto 271) aparece 799 vezes; na faixa vermelha, 55% das 101.631 palavras aparecem uma única vez. A maioria das colunas de uma matriz bag-of-words descreve um único documento; as poucas palavras do topo são justamente as stop words.
As duas leis já aparecem num único parágrafo. Edite o texto abaixo e mude as decisões de tokenização uma de cada vez: minúsculas, pontuação, stop words, unigramas ou bigramas. Observe o tamanho do vocabulário \(V\), a fração de termos que aparecem uma única vez e a pequena curva de Heaps à direita. O simulador corta as palavras como a regex de palavras acima: as palavras de uma letra são mantidas, então a lista de stop words tem o que remover.
Cada decisão muda o \(V\): manter maiúsculas ou pontuação acrescenta colunas; remover stop words tira as mais frequentes. Mesmo em cem palavras a maioria dos termos aparece uma única vez e, com bigramas, a curva quase segue a diagonal, porque quase todo par de vizinhos é novo.
10. Stemming e lematização na prática
Etapa 2. A tabela da seção 1 comparou stemming e lematização. Aqui os dois rodam na frase s da seção 1, ao lado da lista mínima de stop words e dos bigramas. Nenhuma das duas ferramentas está no scikit-learn:
import nltk # pip install nltk spacy
import spacy # python -m spacy download pt_core_news_sm
nltk.download('rslp', quiet=True) # as regras do stemmer de português
stemmer = nltk.stem.RSLPStemmer()
nlp = spacy.load('pt_core_news_sm') # um modelo pequeno de português
words = re.findall(r"\w+", s.lower())
print('stop words out:', [w for w in words if w not in stop_pt])
print('stems (RSLP): ', [stemmer.stem(w) for w in words])
print('lemmas (spaCy):', [t.lemma_ for t in nlp(s) if not t.is_punct])
print('bigrams: ', CountVectorizer(ngram_range=(2, 2)).build_analyzer()(s))
print('"com" (with) and "comida" (food) stem to:', stemmer.stem('com'), stemmer.stem('comida'))
stop words out: ['gatos', 'não', 'comeram', 'ração', 'cachorro', 'mas', 'gato', 'comeu']
stems (RSLP): ['os', 'gat', 'não', 'com', 'a', 'raç', 'do', 'cachorr', 'mas', 'o', 'gat', 'com']
lemmas (spaCy): ['o', 'gato', 'não', 'comer', 'o', 'ração', 'de o', 'cachorro', 'mas', 'o', 'gato', 'comeu']
bigrams: ['os gatos', 'gatos não', 'não comeram', 'comeram ração', 'ração do', 'do cachorro', 'cachorro mas', 'mas gato', 'gato comeu']
"com" (with) and "comida" (food) stem to: com com
Linha a linha:
- stop words: os artigos e o "do" saíram; o "não" ficou, porque não está na lista. A lista em inglês do scikit-learn contém "not", "no" e "never", então
stop_words='english'apaga negações: confira uma lista de stop words antes de usá-la; - stemming: "gatos" e "gato" viram "gat"; "comeram" e "comeu" viram "com". Nenhum dos dois é palavra; "ração" vira "raç". A última linha mostra o preço: a preposição "com" e o substantivo "comida" também viram "com", então quatro palavras sem relação dividem uma coluna;
- lematização: "gatos" → "gato", "comeram" → "comer", os artigos "Os" e "a" → "o"; a contração "do" é separada em "de o". Mas o modelo pequeno deixou "comeu" como estava: um lematizador é um modelo estatístico e erra;
- bigramas: nove pares de vizinhas (depois do tokenizador do scikit-learn); "não comeram" sobrevive como um atributo só, a ordem que um saco de palavras soltas perde.
11. Subpalavras: os tokenizadores dos modelos neurais
Etapa 3. Os modelos densos da seção 5 não usam lista de palavras. Seus tokenizadores cortam o texto em subpalavras: palavras frequentes ficam inteiras e palavras raras são divididas em pedaços frequentes, de modo que nenhuma palavra é desconhecida. Estes são os tokenizadores dos dois modelos usados nesta aula, em duas palavras em inglês e na frase acima:
from transformers import AutoTokenizer # instalado junto com o sentence-transformers
wordpiece = AutoTokenizer.from_pretrained('sentence-transformers/all-MiniLM-L6-v2')
sentpiece = AutoTokenizer.from_pretrained('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
print('vocabularies:', len(wordpiece), 'and', len(sentpiece), 'pieces')
for w in ['tokenization', 'unbelievably']:
print(f"{w:13s}", wordpiece.tokenize(w))
print('English model: ', wordpiece.tokenize(s))
print('multilingual model:', sentpiece.tokenize(s))
print('a rare word: ', sentpiece.tokenize('desparafusaram')) # uma palavra rara
vocabularies: 30522 and 250002 pieces
tokenization ['token', '##ization']
unbelievably ['un', '##bel', '##ie', '##va', '##bly']
English model: ['os', 'ga', '##tos', 'na', '##o', 'come', '##ram', 'a', 'ra', '##cao', 'do', 'ca', '##cho', '##rro', ',', 'mas', 'o', 'ga', '##to', 'come', '##u', '!']
multilingual model: ['▁Os', '▁gato', 's', '▁não', '▁come', 'ram', '▁a', '▁ra', 'ção', '▁do', '▁cachorro', ',', '▁mas', '▁o', '▁gato', '▁come', 'u', '!']
a rare word: ['▁des', 'para', 'fus', 'aram']
- WordPiece (Schuster & Nakajima, 2012), o tokenizador do BERT (Bidirectional Encoder Representations from Transformers) e do
all-MiniLM-L6-v2: um vocabulário de 30.522 pedaços aprendido em texto em inglês. "tokenization" vira "token" + "##ization";##marca um pedaço colado ao anterior. Em português ele ainda produz algo, mas mal: este modelo põe tudo em minúsculas, tira os acentos ("não" → "na", "##o"; "ração" → "ra", "##cao") e corta "cachorro" em três pedaços sem sentido; - SentencePiece (Kudo & Richardson, 2018), o tokenizador do modelo multilíngue: 250.002 pedaços aprendidos em texto de mais de 50 línguas;
▁marca o início de uma palavra. Acentos e maiúsculas são mantidos, palavras frequentes ficam inteiras ("não", "cachorro") e as flexões são separadas: "gatos" → "gato" + "s", "comeram" → "come" + "ram"; - uma palavra rara como "desparafusaram" vira quatro pedaços conhecidos. Cada pedaço tem seu próprio vetor dentro do modelo, então uma palavra nunca vista inteira ainda recebe uma representação. É a linha "tratadas (pedaços de subpalavras)" da tabela comparativa da seção 7; um vocabulário esparso simplesmente não teria coluna para ela.
Onde cada uma dessas decisões entra. Minúsculas, stop words, stemming e n-gramas são decisões da família esparsa, porque definem as colunas. Nos modelos densos, o tokenizador e o vocabulário vêm com o modelo e o texto entra como está:
| bag-of-words, TF-IDF | word embeddings (GloVe) | sentence embeddings | |
|---|---|---|---|
| minúsculas, pontuação | escolha sua (o padrão do scikit-learn põe em minúsculas e tira a pontuação) | o vocabulário do GloVe é todo em minúsculas | o tokenizador do modelo decide |
| stop words | opcional: tira colunas | opcional, antes da média | não: "não" muda o sentido |
| stemming, lematização | opcional: junta colunas | não: um vetor por forma ("cat", "cats") | não |
| n-gramas | opcional: colunas de pares | não | não: o modelo lê a frase na ordem |
| subpalavras | não | não: palavra fora do vocabulário fica sem vetor | sim, o tokenizador do modelo |
Lab avançado — dois espaços e um classificador
O lab fundamental comparou as duas representações pelos vizinhos e pela busca. Esta parte olha a forma dos dois espaços e dá os dois ao mesmo classificador caixa-preta, com muitos e com poucos rótulos. Continue no mesmo notebook, na mesma sessão: os passos abaixo usam docs, y, names, tfidf, T, E, model e agreement dos passos 1 a 4.
Como fazer este lab em aula (cerca de 20 min, em duplas)
- Continue no notebook do lab, a partir do passo 5. Se a sessão caiu, rode antes as células dos passos 1 a 4.
- Como no lab fundamental: Preveja antes de rodar, compare com esta página e tente a Simulação antes de abrir a solução.
- Ritmo sugerido: passo 5 e a simulação em 12 min (o t-SNE e a nova codificação levam cerca de um minuto numa CPU); passo 6 em 8 min.
Passo 5 — olhar os dois espaços
Preveja. Num mapa 2-D, os quatro newsgroups vão formar ilhas separadas nos dois espaços ou só num deles?
import matplotlib.pyplot as plt
from sklearn.decomposition import TruncatedSVD
from sklearn.manifold import TSNE
from sklearn.metrics import silhouette_score
T50 = TruncatedSVD(n_components=50, random_state=0).fit_transform(T)
maps = {'TF-IDF (SVD to 50-d)': TSNE(2, init='pca', random_state=0).fit_transform(T50),
'sentence embeddings': TSNE(2, init='pca', random_state=0).fit_transform(E)}
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
for ax, (title, P) in zip(axes, maps.items()):
for c in range(4):
ax.scatter(P[y == c, 0], P[y == c, 1], s=4, label=names[c])
ax.set_title(title)
axes[1].legend(markerscale=3)
plt.show()
print(f"silhouette of the newsgroups, TF-IDF space: {silhouette_score(T, y, metric='cosine'):.3f}")
print(f"silhouette of the newsgroups, embedding space: {silhouette_score(E, y, metric='cosine'):.3f}")
silhouette of the newsgroups, TF-IDF space: 0.012
silhouette of the newsgroups, embedding space: 0.120
Os dois mapas são feitos do mesmo jeito (t-SNE, com o newsgroup usado só para colorir). Com embeddings, os quatro grupos formam quatro ilhas separadas, com gráficos e eletrônica, os dois grupos técnicos, lado a lado. Com TF-IDF, religião e beisebol se separam, mas eletrônica se espalha pelo meio e se sobrepõe a gráficos. A silhueta (da aula de agrupamento), calculada no espaço completo com o newsgroup como rótulo de grupo, quantifica isso: 0,012 para o TF-IDF, 0,120 para os embeddings. As duas são baixas, porque em centenas de dimensões os grupos se sobrepõem e todo post de fronteira pesa contra, mas o espaço dos embeddings é dez vezes mais organizado por assunto.
Simulação: a ordem importa? Um saco de palavras não distingue um post de suas palavras embaralhadas (lab de bag-of-words, passo 7). O modelo de frases distingue? Embaralhe as palavras de cada post, gere os embeddings dos posts embaralhados e compare a concordância de vizinhos do passo 3 e o cosseno entre cada post e a sua cópia embaralhada.
Solução
rng = np.random.default_rng(0)
shuffled = [' '.join(rng.permutation(d.split())) for d in docs] # as mesmas palavras, em ordem aleatória
T_shuf = tfidf.transform(shuffled)
E_shuf = model.encode(shuffled, batch_size=64, normalize_embeddings=True)
print(f"TF-IDF: same vectors as before: {np.allclose((T - T_shuf).data, 0)} · agreement {agreement(T_shuf):.3f}")
print(f"embeddings: agreement {agreement(E):.3f} -> {agreement(E_shuf):.3f} · "
f"cosine between a post and its shuffled copy: mean {np.mean(np.sum(E * E_shuf, axis=1)):.2f}")
TF-IDF: same vectors as before: True · agreement 0.850
embeddings: agreement 0.948 -> 0.927 · cosine between a post and its shuffled copy: mean 0.77
O TF-IDF não percebe: os posts embaralhados recebem os mesmos vetores, a menos de arredondamento, e os mesmos 0,850. O modelo de frases percebe, porque lê os tokens em ordem: um post e a sua cópia embaralhada têm cosseno médio de 0,77, não 1. Mesmo assim, a concordância cai só de 0,948 para 0,927, ainda bem acima do TF-IDF. Quase tudo o que o modelo usa para separar beisebol de eletrônica sobrevive ao embaralhamento: as próprias palavras, lidas pelo que o modelo aprendeu sobre elas. A ordem importa para o sentido ("o cachorro mordeu o homem"), muito menos para o assunto.
Passo 6 — o mesmo classificador caixa-preta nas duas representações
Preveja. Com todos os 1.481 posts de treino, qual representação vai classificar melhor? E com só 10 posts rotulados por classe?
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
idx_tr, idx_te = train_test_split(np.arange(len(docs)), test_size=0.3, stratify=y, random_state=0)
docs_tr = [docs[k] for k in idx_tr]
docs_te = [docs[k] for k in idx_te]
vec = TfidfVectorizer(stop_words='english', min_df=2).fit(docs_tr) # ajustado só nos posts de treino
X_tr, X_te = vec.transform(docs_tr), vec.transform(docs_te)
clf = LogisticRegression(max_iter=2000) # uma caixa-preta, por enquanto
print(f"TF-IDF + classifier: {clf.fit(X_tr, y[idx_tr]).score(X_te, y[idx_te]):.3f}")
print(f"embeddings + classifier: {clf.fit(E[idx_tr], y[idx_tr]).score(E[idx_te], y[idx_te]):.3f}")
rng = np.random.default_rng(0)
few = np.concatenate([rng.choice(idx_tr[y[idx_tr] == c], 10, replace=False) for c in range(4)])
vec_few = TfidfVectorizer(stop_words='english').fit([docs[k] for k in few])
acc_s = clf.fit(vec_few.transform([docs[k] for k in few]), y[few]).score(vec_few.transform(docs_te), y[idx_te])
acc_d = clf.fit(E[few], y[few]).score(E[idx_te], y[idx_te])
print(f"only 10 labeled posts per class: TF-IDF {acc_s:.3f} · embeddings {acc_d:.3f}")
TF-IDF + classifier: 0.917
embeddings + classifier: 0.951
only 10 labeled posts per class: TF-IDF 0.702 · embeddings 0.910
A caixa-preta é de novo uma regressão logística. A divisão separa 30% dos posts (635) como hold-out, estratificado por grupo; o vocabulário do TF-IDF é ajustado só nos posts de treino. Com os 1.481 posts de treino, os embeddings ganham por 3,4 pontos, 95,1% contra 91,7%. Com 10 posts rotulados por classe, 40 no total, eles mantêm 91,0% enquanto o TF-IDF cai para 70,2%. Nos quatro newsgroups da página, o TF-IDF tinha alcançado os embeddings com 2.097 rótulos; aqui os embeddings continuam na frente mesmo com todos. Qual representação vence com muitos rótulos depende do corpus; com poucos rótulos, o modelo pré-treinado leva grande vantagem.
Para fechar a aula. Em uma frase cada: (1) o que a lei de Heaps diz sobre um vocabulário ajustado hoje e usado por um ano? (2) Por que um stemmer pode juntar palavras que não têm nada em comum? (3) Por que um tokenizador de subpalavras nunca encontra uma palavra desconhecida?
Sua vez (avançado)
Em casa, com mais tempo:
-
Quão poucos rótulos? Transforme a última parte do passo 6 num laço sobre 2, 5, 10, 25 e 50 posts rotulados por classe, com 5 sementes diferentes para cada um. Depois, plote a acurácia média das duas representações contra o número de rótulos. Onde as curvas se encontram, se é que se encontram?
-
Grupos compactos. Calcule
silhouette_samples(E, y, metric='cosine')(desklearn.metrics) e tire a média por newsgroup; depois faça o mesmo comT. Qual grupo é o mais compacto em cada espaço? A ordem bate com os mapas do passo 5?
Exercícios
- Com o ajuste de Heaps desta página (\(V = 15{,}0\,N^{0{,}61}\)), quantas palavras distintas você esperaria num corpus dez vezes maior (cerca de 21,6 milhões de tokens)? O que isso implica para um vocabulário ajustado hoje e usado por um ano?
- Tokenize "Não gostei do filme, não!" com o analisador padrão do scikit-learn, com a lista mínima de stop words desta página e com
ngram_range=(1, 2). Qual versão mantém a negação? - Para cada tarefa, escolha esparso ou denso e justifique numa frase: (a) achar o contrato que cita a "Lei 13.709/2018"; (b) agrupar 50.000 avaliações de clientes por assunto; (c) classificar 300 chamados de suporte em 5 filas, com 20 exemplos rotulados; (d) buscar artigos em inglês com consultas em português.
Material de aula
Notebook do lab
O lab desta página como notebook, pronto para o Jupyter ou o Colab: notebook do lab. No Colab, rode !pip install sentence-transformers na primeira célula. As quatro subpáginas têm seus próprios labs: bag-of-words, TF-IDF, word embeddings e sentence embeddings.
Referências
- Harris, Z. S. "Distributional Structure." Word 10, no. 2–3 (1954): 146–162 — a hipótese distribucional. DOI
- Firth, J. R. "A Synopsis of Linguistic Theory, 1930–1955." In Studies in Linguistic Analysis, 1–32. Oxford: Blackwell (1957) — "You shall know a word by the company it keeps."
- Zipf, G. K. Human Behavior and the Principle of Least Effort. Addison-Wesley (1949) — a lei posto–frequência.
- Spärck Jones, K. "A Statistical Interpretation of Term Specificity and Its Application in Retrieval." Journal of Documentation 28, no. 1 (1972): 11–21 — a ideia por trás da frequência inversa nos documentos. DOI
- Heaps, H. S. Information Retrieval: Computational and Theoretical Aspects. Academic Press (1978) — a lei de crescimento do vocabulário.
- Porter, M. F. "An Algorithm for Suffix Stripping." Program 14, no. 3 (1980): 130–137 — o stemmer clássico do inglês. DOI
- Salton, G.; Buckley, C. "Term-Weighting Approaches in Automatic Text Retrieval." Information Processing & Management 24, no. 5 (1988): 513–523 — a comparação que tornou tf × idf com normalização de comprimento o padrão. DOI
- Lang, K. "NewsWeeder: Learning to Filter Netnews." Proceedings of ICML (1995): 331–339 — a origem da coleção 20 Newsgroups. DOI
- Orengo, V. M.; Huyck, C. "A Stemming Algorithm for the Portuguese Language." SPIRE (2001): 186–193 — o RSLP, o stemmer de português do NLTK. DOI
- Manning, C. D.; Raghavan, P.; Schütze, H. Introduction to Information Retrieval. Cambridge University Press (2008) — capítulo 2 (tokens, stop words, stemming e lematização) e capítulo 5 (leis de Heaps e de Zipf). Texto completo
- Schuster, M.; Nakajima, K. "Japanese and Korean Voice Search." ICASSP (2012): 5149–5152 — o WordPiece, o tokenizador de subpalavras do BERT. DOI
- Mikolov, T.; Chen, K.; Corrado, G.; Dean, J. "Efficient Estimation of Word Representations in Vector Space." ICLR Workshop (2013) — o word2vec. arXiv:1301.3781
- Pennington, J.; Socher, R.; Manning, C. D. "GloVe: Global Vectors for Word Representation." EMNLP (2014): 1532–1543. DOI
- Kudo, T.; Richardson, J. "SentencePiece: A Simple and Language Independent Subword Tokenizer and Detokenizer for Neural Text Processing." EMNLP: System Demonstrations (2018): 66–71 — o tokenizador do modelo multilíngue desta página. DOI
- Devlin, J.; Chang, M.-W.; Lee, K.; Toutanova, K. "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." NAACL-HLT (2019): 4171–4186. DOI
- Reimers, N.; Gurevych, I. "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks." EMNLP-IJCNLP (2019): 3982–3992 — o método por trás do
sentence-transformers. DOI - Reimers, N.; Gurevych, I. "Making Monolingual Sentence Embeddings Multilingual using Knowledge Distillation." EMNLP (2020): 4512–4525 — como o modelo multilíngue desta página foi treinado. DOI
Quiz
O quiz cobre a abordagem fundamental.