Pular para conteúdo

U4.2 TF-IDF

O bag-of-words terminou com dois problemas numa mesma figura: as contagens brutas põem no topo palavras presentes em todo documento ("the", em 9.475 dos 11.314 posts) e um único documento estranho pode dominar uma coluna ("ax", 62.387 ocorrências em 20 posts). Uma lista de stop words resolve o primeiro problema para uma única língua e apenas para as palavras que alguém lembrou de listar. O TF-IDF (frequência do termo × frequência inversa nos documentos) resolve os dois com uma única regra aprendida do próprio corpus: uma palavra pesa mais num documento quanto mais vezes aparece nele e menos quanto mais documentos a contêm.

Esta página calcula o TF-IDF à mão em três frases, confere que o scikit-learn dá exatamente os mesmos números, compara documentos pelo cosseno, monta um pequeno buscador e mede onde toda a abordagem esparsa falha. Todo número sai do código ao lado.

A aula segue um pipeline em quatro etapas, desenhado abaixo: 1. pesar as palavras, a ideia e a fórmula do idf; 2. calcular e conferir, cada número à mão e depois no scikit-learn; 3. comparar e buscar, com o cosseno e um pequeno buscador; 4. onde falha, as falhas de toda representação esparsa. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.

  1. Pesar as palavrastf × idf, a curva do idf
  2. Calcular e conferirtrês frases à mão, scikit-learn, três botões
  3. Comparar e buscarcosseno, um pequeno buscador, um corpus real
  4. Onde falhasinônimos, ordem das palavras, dois sentidos, palavras novas

Slides

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

A ideia: pesar as palavras pelo quanto são específicas

Etapa 1. A ideia é de Spärck Jones (1972): um termo que aparece em muitos documentos é um jeito ruim de distinguir documentos, então deve contar menos. Ela mediu isso em experimentos de recuperação. Salton & Buckley (1988), comparando muitos esquemas de peso em várias coleções, fizeram do tf × idf com normalização de comprimento o padrão da recuperação de informação.

A versão padrão do scikit-learn, para uma palavra \(t\) num documento \(d\), num corpus de \(n\) documentos dos quais \(\text{df}(t)\) contêm \(t\):

\[ \text{tf-idf}(t, d) = \underbrace{\text{tf}(t, d)}_{\text{contagem de } t \text{ em } d} \times \underbrace{\Big(\ln \frac{1 + n}{1 + \text{df}(t)} + 1\Big)}_{\text{idf}(t)} \]

e depois cada vetor de documento é dividido pelo seu comprimento euclidiano, para que toda linha tenha norma 1 (norm='l2'). Dois detalhes diferem do \(\ln(n/\text{df})\) dos livros:

  • o "+1" dentro da fração (smooth_idf=True) age como se um documento extra contivesse cada palavra uma vez: nada de divisão por zero e uma curva um pouco mais plana;
  • o "+1" de fora mantém uma palavra presente em todo documento com peso 1, não 0. Ela é descontada, não apagada.

idf contra o número de posts que contêm a palavra, para n = 11.314 posts: a curva do scikit-learn cai de 9,6 para uma palavra em um post até 1 para uma palavra em todos, enquanto o ln(n/df) dos livros chega a 0; filioque (df=2) fica perto de 9,2, car (df=396) perto de 4,4 e the (df=9.475) perto de 1,2

(script da figura)

A curva é logarítmica: ir de 1 post para 10 custa mais ou menos tanto peso quanto ir de 1.000 para 10.000. Uma palavra que aparece em 2 posts ("filioque", um termo da teologia cristã) recebe idf 9,2, quase 8 vezes o 1,18 de uma palavra que aparece em quase todos ("the").

À mão, em três frases

Etapa 2. Os três documentos da página de bag-of-words e suas contagens:

import numpy as np
import pandas as pd
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."]
bow = CountVectorizer()
X = bow.fit_transform(docs)
print(pd.DataFrame(X.toarray(), columns=bow.get_feature_names_out(), index=['d1', 'd2', 'd3']))
    cachorro  comeu  do  dorme  gato  peixe  ração  sonha
d1         0      1   0      0     1      1      0      0
d2         1      1   1      0     1      0      1      0
d3         0      0   0      1     2      0      0      1

Com \(n = 3\), só há três valores possíveis de idf:

  • "gato", nos 3 documentos: \(\ln(4/4) + 1 = 1\);
  • "comeu", em 2 documentos: \(\ln(4/3) + 1 = 1{,}2877\);
  • todas as outras palavras, em 1 documento: \(\ln(4/2) + 1 = 1{,}6931\).

Para d1 ("gato comeu peixe", cada palavra uma vez), tf × idf dá gato 1, comeu 1,2877, peixe 1,6931. Seu comprimento é \(\sqrt{1^2 + 1{,}2877^2 + 1{,}6931^2} = \sqrt{5{,}5249} = 2{,}3505\); dividir por ele dá 0,4254, 0,5478, 0,7203. A palavra mais rara de d1 agora tem o maior peso. Os mesmos passos em NumPy:

n = len(docs)
df = (X.toarray() > 0).sum(axis=0)              # em quantos documentos cada palavra aparece
idf = np.log((1 + n) / (1 + df)) + 1            # idf suavizado do scikit-learn, log natural
for word, d, i in zip(bow.get_feature_names_out(), df, idf):
    print(f"{word:9s} df = {d}   idf = {i:.4f}")
cachorro  df = 1   idf = 1.6931
comeu     df = 2   idf = 1.2877
do        df = 1   idf = 1.6931
dorme     df = 1   idf = 1.6931
gato      df = 3   idf = 1.0000
peixe     df = 1   idf = 1.6931
ração     df = 1   idf = 1.6931
sonha     df = 1   idf = 1.6931
w = X.toarray()[0] * idf                        # d1: contagem bruta x idf
print('tf x idf :', w.round(4))
print(f'length   : {np.linalg.norm(w):.4f}')
print('tf-idf   :', (w / np.linalg.norm(w)).round(4))
tf x idf : [0.     1.2877 0.     0.     1.     1.6931 0.     0.    ]
length   : 2.3505
tf-idf   : [0.     0.5478 0.     0.     0.4254 0.7203 0.     0.    ]

O scikit-learn confirma

O TfidfVectorizer é o CountVectorizer seguido deste mesmo peso:

from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer()          # padrões: smooth_idf=True, norm='l2', sublinear_tf=False
T = tfidf.fit_transform(docs)
print(pd.DataFrame(T.toarray(), columns=tfidf.get_feature_names_out(), index=['d1', 'd2', 'd3']).round(4))
print('same idf as by hand:', np.allclose(tfidf.idf_, idf))
    cachorro   comeu      do   dorme    gato   peixe   ração   sonha
d1    0.0000  0.5478  0.0000  0.0000  0.4254  0.7203  0.0000  0.0000
d2    0.5046  0.3838  0.5046  0.0000  0.2980  0.0000  0.5046  0.0000
d3    0.0000  0.0000  0.0000  0.5427  0.6411  0.0000  0.0000  0.5427
same idf as by hand: True

A linha d1 bate com a conta à mão até a quarta casa decimal; o atributo idf_ guarda o idf de cada coluna.

Dois mapas de calor dos mesmos três documentos. Contagens: gato tem 2 em d3. TF-IDF: gato, presente em todo documento, cai para 0,43 em d1 e 0,30 em d2, enquanto peixe, presente em um documento, recebe 0,72

(script da figura)

Há duas coisas a observar na figura. Em d1 e d2, "gato" agora tem o menor peso não nulo, porque está em toda parte. Em d3 ele ainda tem o maior (0,64): aparece duas vezes e o tf continua contando. O peso não remove as palavras comuns; só as rebaixa em relação às raras.

Três botões

for params in [{}, {'smooth_idf': False}, {'sublinear_tf': True}, {'norm': None}]:
    V = TfidfVectorizer(**params).fit_transform(docs)
    print(f"{str(params):24s} d3 = {V.toarray()[2].round(4)}")
{}                       d3 = [0.     0.     0.     0.5427 0.6411 0.     0.     0.5427]
{'smooth_idf': False}    d3 = [0.     0.     0.     0.5864 0.5588 0.     0.     0.5864]
{'sublinear_tf': True}   d3 = [0.     0.     0.     0.5774 0.5774 0.     0.     0.5774]
{'norm': None}           d3 = [0.     0.     0.     1.6931 2.     0.     0.     1.6931]
  • smooth_idf=False usa \(\ln(n/\text{df}) + 1\): as palavras raras de d3 recebem \(\ln 3 + 1 = 2{,}0986\) em vez de 1,6931, enquanto "gato" fica em 1. Elas passam à frente: 0,5864 contra 0,5588;
  • sublinear_tf=True troca a contagem por \(1 + \ln(\text{tf})\): as duas ocorrências de "gato" contam \(1 + \ln 2 = 1{,}6931\) em vez de 2. Por coincidência, isso é igual ao idf das palavras raras, então os três pesos de d3 ficam iguais (0,5774 cada). Uma palavra muito repetida não consegue mais dominar a própria linha;
  • norm=None mantém os produtos brutos; as linhas deixam de ter comprimento 1. Documentos mais longos passam a ter vetores maiores, o que importa para a distância euclidiana mas não para o cosseno.

Comparando documentos: similaridade de cosseno

Etapa 3. A página de bag-of-words apresentou o cosseno entre dois vetores, \(\cos(x_a, x_b) = x_a \cdot x_b / (\lVert x_a \rVert \lVert x_b \rVert)\). As linhas do TF-IDF já têm comprimento 1, então o cosseno é só o produto escalar delas. À mão, d1 e d2 compartilham comeu e gato:

\[ \cos(d_1, d_2) = 0{,}5478 \times 0{,}3838 + 0{,}4254 \times 0{,}2980 = 0{,}2102 + 0{,}1268 = 0{,}337 \]

e d1 e d3 compartilham só gato: \(0{,}4254 \times 0{,}6411 = 0{,}273\). Lado a lado com os cossenos das contagens brutas:

from sklearn.metrics.pairwise import cosine_similarity

names = ['d1', 'd2', 'd3']
print('counts\n', pd.DataFrame(cosine_similarity(X), index=names, columns=names).round(3))
print('TF-IDF\n', pd.DataFrame(cosine_similarity(T), index=names, columns=names).round(3))
counts
        d1     d2     d3
d1  1.000  0.516  0.471
d2  0.516  1.000  0.365
d3  0.471  0.365  1.000
TF-IDF
        d1     d2     d3
d1  1.000  0.337  0.273
d2  0.337  1.000  0.191
d3  0.273  0.191  1.000

Todas as similaridades caíram, porque toda palavra compartilhada aqui inclui "gato", que o TF-IDF desconta. O par d1–d3 foi o que mais caiu (0,471 → 0,273): eles compartilham só "gato", a palavra menos informativa do corpus, que ainda aparece duas vezes em d3.

Um pequeno buscador

O TF-IDF foi criado justamente para recuperar documentos: transformar a consulta num vetor com o mesmo vetorizador ajustado e ordenar os documentos pelo cosseno. Considere um FAQ de central de ajuda com dez perguntas e cinco consultas escritas do jeito que um aluno digitaria:

faq = ["Como faço para trocar a senha do portal do aluno?",
       "Qual é o prazo para trancar a matrícula?",
       "Onde consigo a segunda via do boleto da mensalidade?",
       "Como peço revisão da nota de uma prova?",
       "O restaurante do campus abre aos sábados?",
       "Como reservo uma sala de estudos na biblioteca?",
       "Posso levar um visitante ao campus?",
       "Como justifico uma falta por motivo de saúde?",
       "O que fazer quando aparece o erro 0x80070005 na instalação?",
       "O que fazer quando aparece o erro 0x80004005 na atualização?"]
queries = ["esqueci minha senha",
           "erro 0x80004005",
           "quero contestar a correção do exame",
           "onde almoçar no sábado",
           "perdi a fatura"]

tf_faq = TfidfVectorizer().fit(faq)
F = tf_faq.transform(faq)
for q in queries:
    sims = cosine_similarity(tf_faq.transform([q]), F).ravel()
    best = sims.argmax()
    used = [w for w in tf_faq.build_analyzer()(q) if w in tf_faq.vocabulary_]
    print(f"{q:37s} {sims[best]:.2f}  FAQ {best}: {faq[best][:45]:45s} words used: {used}")
esqueci minha senha                   0.35  FAQ 0: Como faço para trocar a senha do portal do al words used: ['senha']
erro 0x80004005                       0.53  FAQ 9: O que fazer quando aparece o erro 0x80004005  words used: ['erro', '0x80004005']
quero contestar a correção do exame   0.51  FAQ 0: Como faço para trocar a senha do portal do al words used: ['do']
onde almoçar no sábado                0.37  FAQ 2: Onde consigo a segunda via do boleto da mensa words used: ['onde']
perdi a fatura                        0.00  FAQ 0: Como faço para trocar a senha do portal do al words used: []

Duas consultas funcionam e três falham; a última coluna diz por quê:

  • "esqueci minha senha" acha a pergunta da senha pela palavra "senha" e o código de erro acha a própria entrada: um token raro tem idf alto, então um código exato é a consulta ideal para o TF-IDF;
  • "quero contestar a correção do exame" só tem "do" em comum com o FAQ. A pergunta da senha, que contém "do" duas vezes, vence com 0,51. A resposta certa ("revisão da nota de uma prova") não usa nenhuma palavra da consulta;
  • "onde almoçar no sábado" casa com a pergunta do boleto pela palavra "onde", porque a entrada do restaurante diz "sábados", não "sábado". Um stemmer resolveria esta;
  • "perdi a fatura" não tem nenhuma palavra em comum com nenhuma entrada: similaridade 0,00 com tudo; o argmax devolve a primeira pergunta por padrão.

Uma lista de stop words remove as falsas correspondências, mas não cria as verdadeiras:

stop_pt = ['o', 'a', 'os', 'as', 'do', 'da', 'de', 'no', 'na', 'e', 'um', 'uma', 'em', 'que',
           'como', 'para', 'onde', 'qual', 'quero', 'minha']
tf_stop = TfidfVectorizer(stop_words=stop_pt).fit(faq)
for q in queries[2:4]:
    sims = cosine_similarity(tf_stop.transform([q]), tf_stop.transform(faq)).ravel()
    print(f"{q:37s} best score {sims.max():.2f}")
quero contestar a correção do exame   best score 0.00
onde almoçar no sábado                best score 0.00

Sem as palavras funcionais, as duas consultas dão 0,00 com todas as entradas: o TF-IDF agora diz, honestamente, que não achou nada. A busca por palavras-chave nunca é melhor do que a sobreposição entre as palavras da consulta e as dos documentos.

Num corpus real

A mesma comparação em escala: 2.097 posts de quatro newsgroups (carros, medicina, espaço, armas), representados por contagens brutas ou por TF-IDF, reduzidos a 50 dimensões com uma SVD truncada e desenhados com t-SNE. O newsgroup é usado só para colorir:

Mapas t-SNE dos mesmos 2.097 posts de quatro newsgroups. Com contagens brutas as cores se misturam e 46% dos 5 vizinhos mais próximos de cada post vêm do seu newsgroup; com TF-IDF aparecem quatro regiões coloridas e o número vai a 81%

(script da figura)

Com contagens brutas, os posts mais próximos são os que usam the, of, and em proporções parecidas: só 46% dos 5 vizinhos mais próximos de cada post vêm do seu newsgroup (uma escolha ao acaso daria cerca de 25%). O TF-IDF, que desconta justamente essas palavras, sobe para 81%. No mapa, aparecem quatro regiões.

Os limites das representações esparsas

Etapa 4. O TF-IDF é rápido, não precisa de dados de treino e é fácil de ler: cada coluna é uma palavra que você pode imprimir. Sua fraqueza vem da própria construção: cada palavra é um eixo próprio, ortogonal a todas as outras. Quatro consequências medidas, cada uma num par de frases:

analyzer = CountVectorizer().build_analyzer()
pairs = {'synonyms':     ("o carro é rápido", "o automóvel é veloz"),
         'word order':   ("o cachorro mordeu o homem", "o homem mordeu o cachorro"),
         'two meanings': ("a manga da camisa rasgou", "comi uma manga madura"),
         'same meaning': ("a manga da camisa rasgou", "o punho da blusa descosturou")}
for name, (a, b) in pairs.items():
    V = TfidfVectorizer().fit_transform([a, b])
    shared = sorted(set(analyzer(a)) & set(analyzer(b)))
    print(f"{name:13s} cosine {cosine_similarity(V)[0, 1]:.2f}   shared words: {shared}")
synonyms      cosine 0.00   shared words: []
word order    cosine 1.00   shared words: ['cachorro', 'homem', 'mordeu']
two meanings  cosine 0.14   shared words: ['manga']
same meaning  cosine 0.14   shared words: ['da']
  • Sinônimos dão zero. "o carro é rápido" e "o automóvel é veloz" não têm nenhum token em comum ("é" tem uma letra), então a similaridade é exatamente 0,00.
  • A ordem é invisível. Quem mordeu quem não muda o vetor: 1,00.
  • Uma grafia, dois sentidos. Manga é parte da camisa em "a manga da camisa rasgou" e é a fruta em "comi uma manga madura". As duas frases compartilham esse token e dão 0,14.
  • Mesmo sentido, palavras diferentes. "a manga da camisa rasgou" e "o punho da blusa descosturou" descrevem quase o mesmo acontecimento, compartilham só a preposição "da" e também dão 0,14. O TF-IDF não consegue distinguir o par relacionado do par sem relação.

E uma quinta: palavras nunca vistas no fit não têm coluna. Com o vetorizador ajustado nos três documentos desta página:

for new in ["o cachorro latiu", "um felino adormeceu"]:
    v = tfidf.transform([new])
    print(f"{new!r:24s} non-zeros: {v.nnz}   {dict(zip(tfidf.get_feature_names_out()[v.indices], v.data.round(2).tolist()))}")
'o cachorro latiu'       non-zeros: 1   {'cachorro': 1.0}
'um felino adormeceu'    non-zeros: 0   {}

"latiu" é descartada e "um felino adormeceu" vira o vetor nulo: uma frase sobre um gato dormindo, ao lado de documentos sobre gatos dormindo, com similaridade 0 com todos eles. Pela lei de Heaps, palavras novas nunca param de chegar.

São essas as falhas que motivaram as representações densas, em que palavras e frases de sentido parecido recebem vetores próximos mesmo sem nenhum token em comum: word embeddings e sentence embeddings. A visão geral mede quando cada família vence.

Simulador: TF-IDF e busca ao vivo

Digite um documento por linha e, embaixo, uma consulta. O simulador calcula df, idf, a matriz TF-IDF, o cosseno entre documentos e a ordenação da consulta exatamente como o TfidfVectorizer faz, com os três botões desta página.

O que testar:

  • o preset aula reproduz os números acima (idf 1, 1,288, 1,693; d1 = 0,548, 0,425, 0,720; cos(d1, d2) = 0,337). Desmarque smooth_idf e veja o idf das palavras raras subir;
  • acrescente uma quarta linha que repete "gato" cinco vezes e marque sublinear_tf: quanto a repetição ainda conta?
  • o preset sinônimos: "carro veloz" casa com a segunda frase só por "veloz" e com a primeira só por "carro"; nenhuma é reconhecida como tendo o mesmo sentido;
  • o preset FAQ: a consulta acha a pergunta do boleto pela palavra "onde" e "sábado" é ignorada porque os documentos só têm "sábados".

Exercícios

  1. À mão, calcule a linha TF-IDF de d3 ("Gato dorme, gato sonha.") e confira 0,6411, 0,5427, 0,5427. Depois calcule \(\cos(d_2, d_3)\) e confira 0,191.
  2. Acrescente um quarto documento, "O peixe dorme.", aos três desta página. Quais valores de idf mudam? Por que o peso de "gato" em d1 sobe, se d1 não mudou?
  3. Com sublinear_tf=True, qual é o peso de uma palavra que aparece 10 vezes, em relação a uma que aparece uma vez (mesmo idf)? Compare com a contagem bruta.
  4. No buscador acima, acrescente "almoço" à entrada do restaurante. "onde almoçar no sábado" passa a achá-la? Explique com os tokens.

Lab: um buscador TF-IDF em posts reais

Quatro newsgroups da coleção 20 Newsgroups (computação gráfica, beisebol, eletrônica e cristianismo), cerca de 2.100 posts. O lab monta a matriz TF-IDF, lê o que ela considera importante, acha os posts mais parecidos, mede como cada opção muda a qualidade dos vizinhos e transforma a matriz num buscador por palavras-chave, incluindo uma consulta que ele não consegue responder. Usa só o scikit-learn e roda em bem menos de um minuto.

Rode os blocos em ordem, numa mesma sessão. Para trabalhar direto nele, baixe o notebook do lab e abra no Jupyter ou no Colab (Arquivo → Fazer upload de notebook).

Passo 1 — carregar os posts

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))))
2116 posts: comp.graphics 521, rec.sport.baseball 495, sci.electronics 535, soc.religion.christian 565

Os mesmos 2.116 posts do lab de bag-of-words: quatro grupos equilibrados, de 495 a 565 posts cada, sem cabeçalhos, assinaturas ou respostas citadas. Os posts com menos de 20 palavras foram descartados.

Passo 2 — a matriz TF-IDF e seu idf

from sklearn.feature_extraction.text import TfidfVectorizer

tfidf = TfidfVectorizer(stop_words='english', min_df=2)
T = tfidf.fit_transform(docs)
terms = tfidf.get_feature_names_out()
print(T.shape, f"{1 - T.nnz / (T.shape[0] * T.shape[1]):.2%} zeros")

idf = pd.Series(tfidf.idf_, index=terms).sort_values()
print('lowest idf :', idf.head(6).round(2).to_dict())
print(f"highest idf: {idf.iloc[-1]:.2f}, shared by the {(idf == idf.iloc[-1]).sum()} words found in exactly 2 posts")
print('row lengths:', np.sqrt(T.multiply(T).sum(axis=1)).A.ravel()[:5].round(3))
(2116, 12001) 99.52% zeros
lowest idf : {'like': 2.37, 'know': 2.41, 'don': 2.46, 'just': 2.49, 'think': 2.58, 'does': 2.62}
highest idf: 7.56, shared by the 3653 words found in exactly 2 posts
row lengths: [1. 1. 1. 1. 1.]

Como ler:

  • 2.116 posts × 12.001 termos, 99,52% de zeros. A lista de stop words do inglês e o min_df=2 já cortaram o vocabulário pela metade; o TF-IDF muda os valores, não a esparsidade.
  • Os menores idf (2,37 a 2,62) são de palavras que não estão na lista de stop words, mas aparecem em toda parte na escrita informal: "like", "know", "just", "think". "don" é o que o tokenizador deixa de "don't". O TF-IDF as desconta sozinho, sem que ninguém as liste.
  • O maior idf é \(\ln(2117/3) + 1 = 7{,}56\), o valor de uma palavra presente em exatamente 2 posts, o mínimo que o min_df=2 permite. 3.653 palavras o compartilham.
  • Toda linha tem comprimento 1: é a normalização L2; por isso, o produto escalar entre duas linhas é o cosseno delas.

Passo 3 — do que um post fala, em oito palavras

i = 14
row = T[i].toarray().ravel()
print(f"post {i} ({names[y[i]]}):", ' '.join(docs[i].split())[:150])
print(pd.Series(row, index=terms).nlargest(8).round(3).to_string())
post 14 (sci.electronics): : >I want to upgrade my system and was thinking of buying Adcom seperates. : >I have heard from alot of people, though, that Adcom sounds great but :
adcom         0.463
amp           0.408
amplifier     0.270
components    0.250
amps          0.203
quality       0.165
sound         0.161
brilliant     0.151

O post 14 pergunta sobre a compra de amplificadores de áudio da Adcom; seus oito termos mais pesados parecem um resumo: a marca, "amp", "amplifier", "components", "sound". O TF-IDF funciona como um extrator de palavras-chave: uma palavra sobe quando este post a usa e poucos outros posts usam. "brilliant" (0,151) lembra que ele não sabe nada de significado: um adjetivo que o autor por acaso usou, raro no corpus, fica ao lado das palavras do assunto.

Passo 4 — os posts mais parecidos

from sklearn.metrics.pairwise import cosine_similarity

sim = cosine_similarity(T[i], T).ravel()
sim[i] = -1                                         # ignora o próprio post
for j in np.argsort(-sim)[:3]:
    shared = [terms[k] for k in np.intersect1d(T[i].indices, T[j].indices)]
    print(f"{sim[j]:.2f}  {names[y[j]]:18s} shared terms: {shared[:8]}")
0.66  sci.electronics    shared terms: ['adcom', 'amp', 'amplifier', 'amplifiers', 'amps', 'brilliant', 'build', 'built']
0.37  sci.electronics    shared terms: ['amp', 'amps', 'components', 'designed', 'good', 'heat', 'hot', 'inside']
0.30  sci.electronics    shared terms: ['adcom', 'components', 'designed', 'east', 'far', 'like', 'people']

Os três posts mais parecidos vêm todos de sci.electronics. O primeiro (cosseno 0,66) é uma resposta na mesma discussão e compartilha a marca e todo o vocabulário de amplificadores; o segundo fala de amplificadores esquentando ("heat", "hot"); o terceiro, de onde a Adcom fabrica seus produtos ("far", "east"). Os termos compartilhados impressos são o motivo de cada escore: o cosseno entre linhas do TF-IDF é uma soma sobre as palavras que os dois posts têm em comum.

Passo 5 — quais opções dão os melhores vizinhos?

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}")
for params in [{'min_df': 2}, {'min_df': 2, 'max_df': 0.5}, {'stop_words': 'english', 'min_df': 2},
               {'stop_words': 'english', 'min_df': 2, 'sublinear_tf': True},
               {'stop_words': 'english', 'min_df': 2, 'ngram_range': (1, 2)}]:
    V = TfidfVectorizer(**params).fit_transform(docs)
    print(f"TF-IDF {str(params)[1:-1]:60s} {agreement(V):.3f}")
chance level:                       0.251
raw counts:                         0.490
TF-IDF 'min_df': 2                                                  0.770
TF-IDF 'min_df': 2, 'max_df': 0.5                                   0.829
TF-IDF 'stop_words': 'english', 'min_df': 2                         0.850
TF-IDF 'stop_words': 'english', 'min_df': 2, 'sublinear_tf': True   0.865
TF-IDF 'stop_words': 'english', 'min_df': 2, 'ngram_range': (1, 2)  0.843

A concordância de vizinhos do lab de bag-of-words: a fração dos 5 posts mais próximos de cada post que vêm do seu newsgroup (0,251 por acaso).

  • Contagens brutas: 0,490. TF-IDF sem lista de stop words: 0,770. O idf sozinho faz a maior parte do trabalho de uma lista de stop words: as palavras presentes em todo post recebem pesos perto do fundo da escala.
  • max_df=0.5: 0,829; a lista de stop words do inglês: 0,850. Remover de vez as palavras mais comuns ainda ajuda um pouco mais.
  • sublinear_tf=True: 0,865, o melhor aqui: posts longos que repetem muito uma palavra já não a deixam dominar.
  • Bigramas: 0,843, nada melhor que as palavras sozinhas, como no lab de bag-of-words.

Passo 6 — um buscador por palavras-chave

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'}

def search(query, k=5):
    return np.argsort(-cosine_similarity(tfidf.transform([query]), T).ravel())[:k]

for q, group in queries.items():
    top = search(q)
    used = list(terms[tfidf.transform([q]).indices])
    print(f"{q}\n   {np.sum(names[y[top]] == group)}/5 from {group} · words used: {used}")
my picture looks blurry after I enlarge it
   3/5 from comp.graphics · words used: ['blurry', 'enlarge', 'looks', 'picture']
software to convert a photo from one file format to another
   5/5 from comp.graphics · words used: ['convert', 'file', 'format', 'photo', 'software']
the umpire made a terrible call last night
   3/5 from rec.sport.baseball · words used: ['night', 'terrible', 'umpire']
why does my TV screen flicker
   3/5 from sci.electronics · words used: ['does', 'screen', 'tv']
how to stop my circuit from overheating
   5/5 from sci.electronics · words used: ['circuit', 'stop']
does the soul survive after death
   2/5 from soc.religion.christian · words used: ['death', 'does', 'soul', 'survive']

Cada consulta foi escrita para um dos quatro grupos; contamos quantos dos 5 resultados vêm desse grupo: 21 de 30. A última coluna explica os erros. Quando a consulta usa o vocabulário do próprio grupo ("convert", "file", "format"; "circuit"), os 5 resultados estão certos. "flicker" e "overheating" não estão no vocabulário (menos de 2 posts), então "why does my TV screen flicker" é buscada como "does screen tv". E um post cristão raramente fala de uma "soul" que "survives" com essas palavras, mesmo quando é esse o seu assunto: 2 de 5.

Passo 7 — o que a busca por palavras-chave não enxerga

col = tfidf.vocabulary_
for a, b in [('image', 'picture'), ('faith', 'belief')]:
    top_a, top_b = set(search(a, 10)), set(search(b, 10))
    n_a, n_b = (T[:, col[a]] > 0).sum(), (T[:, col[b]] > 0).sum()
    n_both = (T[:, col[a]] > 0).multiply(T[:, col[b]] > 0).sum()
    print(f"{a!r} in {n_a} posts, {b!r} in {n_b}, both in {n_both} · "
          f"top-10 results in common: {len(top_a & top_b)}")
print(tfidf.transform(["photograph of a luminous nebula"]).nnz, 'known words in the last query')
'image' in 90 posts, 'picture' in 39, both in 7 · top-10 results in common: 0
'faith' in 109 posts, 'belief' in 51, both in 22 · top-10 results in common: 0
0 known words in the last query

"image" e "picture" significam quase a mesma coisa num newsgroup de computação gráfica, mas só 7 posts contêm as duas. Os 10 melhores resultados de uma consulta não têm nenhum post em comum com os 10 melhores da outra. O mesmo vale para "faith" e "belief". A última consulta, sobre a fotografia de uma nebulosa, não tem nenhuma palavra no vocabulário destes quatro grupos: seu vetor é nulo e todo post dá 0. Esse é o limite de toda representação esparsa e o motivo dos word embeddings e dos sentence embeddings.

Sua vez

  1. Suas próprias consultas. Escreva três consultas para cada newsgroup: uma que reusa o vocabulário do grupo, uma que o parafraseia, uma com erro de digitação. Quantos dos 5 resultados estão certos em cada caso?
  2. Sublinear e stop words. O passo 5 deu a melhor concordância com sublinear_tf=True. Veja quais palavras saem do top-8 do post 14 com essa opção e explique por quê.
  3. Expansão de consulta. Faça a função search acrescentar sinônimos à consulta antes de transformá-la (por exemplo, "picture" → "picture image photo"). A sobreposição dos top-10 do passo 7 muda? Qual é o risco de expandir toda consulta?
  4. Stemming. Instale o NLTK (pip install nltk) e passe em tokenizer= uma função que aplica PorterStemmer().stem a cada token. Quantas colunas sobram? A concordância do passo 5 muda?

Material de aula

Notebook do lab

O lab desta página como notebook, pronto para o Jupyter ou o Colab: notebook do lab.


Referências

  • 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
  • Salton, G.; Wong, A.; Yang, C. S. "A Vector Space Model for Automatic Indexing." Communications of the ACM 18, no. 11 (1975): 613–620 — documentos como vetores, comparados pelo cosseno. DOI
  • Salton, G.; Buckley, C. "Term-Weighting Approaches in Automatic Text Retrieval." Information Processing & Management 24, no. 5 (1988): 513–523 — a comparação sistemática que tornou tf × idf com normalização de comprimento o padrão. DOI
  • Robertson, S. "Understanding Inverse Document Frequency: On Theoretical Arguments for IDF." Journal of Documentation 60, no. 5 (2004): 503–520 — por que o logaritmo e o que o idf estima. DOI
  • Manning, C. D.; Raghavan, P.; Schütze, H. Introduction to Information Retrieval. Cambridge University Press (2008) — capítulo 6: peso de termos e o modelo de espaço vetorial. Texto completo
  • Lang, K. "NewsWeeder: Learning to Filter Netnews." Proceedings of ICML (1995): 331–339 — a origem da coleção 20 Newsgroups. DOI

Quiz