← Voltar pra playlist

Texto Vira Vetor: Meus Primeiros Passos em NLP

Aula 14. A última da matéria, e o assunto fecha a playlist inteira com uma virada bacana: todo modelo que eu usei até aqui, da regressão linear lá no primeiro post até a seleção de características no post anterior, espera um vetor de número como entrada. Pixel já é número. Medida química de vinho já é número. Mas texto, uma resenha de filme escrita por gente de verdade, não é número nenhum. Essa aula é sobre o primeiro passo de qualquer (Natural Language Processing): como transformar texto em vetor sem perder o que importa.

Padronizar e tokenizar: o mínimo necessário

Antes de qualquer conta, o professor limpa o texto: tudo minúsculo, pontuação fora.

def standardize(text):
    text = text.lower()
    return "".join(c for c in text if c not in string.punctuation)

def tokenize(text):
    return standardize(text).split()

tokenize("I write, erase, rewrite, erase again, and then a poppy blooms!")

Saída: ['i', 'write', 'erase', 'rewrite', 'erase', 'again', 'and', 'then', 'a', 'poppy', 'blooms'].

Repara que "Erase" e "erase" agora são a mesma palavra, e a vírgula depois de "erase" sumiu sem grudar na palavra seguinte. Sem esse passo, o modelo trataria "erase" e "erase," (com vírgula colada) como duas palavras completamente diferentes, e "Write" e "write" também, inflando o vocabulário com duplicata que não deveria existir. É o mesmo espírito da normalização que já vi lá no post de KNN, só que em vez de reescalar número, aqui é reescalar texto pra uma forma canônica.

O vetorizador caseiro: palavra vira índice

class Vectorizer:
    def standardize(self, text):
        ...
    def tokenize(self, text):
        ...
    def make_vocabolary(self, dataset):
        self.vocabulary = {"": 0, "[UNK]": 1}
        for text in dataset:
            text = self.standardize(text)
            tokens = self.tokenize(text)
            for token in tokens:
                if token not in self.vocabulary:
                    self.vocabulary[token] = len(self.vocabulary)

    def encode(self, text):
        text = self.standardize(text)
        tokens = self.tokenize(text)
        return [self.vocabulary.get(token, 1) for token in tokens]

A ideia é literal: cada palavra vista no treino ganha um número inteiro, um índice num dicionário. "UNK" (unknown) fica reservado no índice 1 desde o início, pra qualquer palavra nova que apareça depois, numa frase de teste, e não estava no vocabulário de treino. O professor testa isso com uma frase que usa a palavra "still", ausente do dataset de treino de 3 frases:

Saída: a palavra "still" vira o índice 1, o mesmo de [UNK], enquanto as outras palavras da frase (que já apareciam no treino) viram seus índices de verdade.

Isso é o mesmo problema que categoria nova resolvia lá no post do Titanic com o OneHotEncoder: o que fazer quando o dado de produção traz algo que o treino nunca viu. Lá era categoria de embarque, aqui é palavra.

O dataset: 50 mil resenhas de filme, de verdade

O professor baixa o IMDB Large Movie Review Dataset, da Stanford, um dos datasets mais citados da área de análise de sentimento: 50 mil resenhas reais do site IMDB, metade marcada como positiva, metade como negativa, já divididas 25 mil treino / 25 mil teste.

train_df = text_dataset_from_directory('aclImdb/train')
test_df = text_dataset_from_directory('aclImdb/test')

Saída: 25000 linhas de treino, 25000 de teste, colunas text e label.

Bag-of-words: cada palavra é um voto, sem ordem

A primeira estratégia pra vetorizar uma resenha inteira: um vetor do tamanho do vocabulário, com 1 em cada posição cuja palavra aparece na resenha, 0 no resto. Ignora completamente a ordem das palavras, só marca presença.

class UnigramTransformer(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        self.vocabulary = {"": 0, "[UNK]": 1}
        for text in X:
            for token in set(self.tokenize(text)):
                if token not in self.vocabulary and len(self.vocabulary) < self.max_features:
                    self.vocabulary[token] = len(self.vocabulary)
        return self

    def transform(self, X, y=None):
        rows, cols, data = [], [], []
        for row, text in enumerate(X):
            for token in set(self.tokenize(text)):
                rows.append(row)
                cols.append(self.vocabulary.get(token, 1))
                data.append(1)
        return csr_matrix((data, (rows, cols)), shape=(len(X), len(self.vocabulary)))

Repara no csr_matrix: com um vocabulário de 10 mil palavras e cada resenha usando só umas poucas centenas delas, a matriz é quase toda zero. Guardar um vetor denso de 10 mil posições, quase todas zero, pra cada uma das 20 mil resenhas seria um desperdício gigante de memória. O formato esparso guarda só as posições com valor diferente de zero.

pipeline = Pipeline([("vectorizer", UnigramTransformer(10000)), ("classifier", RandomForestClassifier(random_state=42))])
pipeline.fit(train_texts, train_labels)

Saída: 0.832 de acurácia na validação, só olhando pra quais palavras aparecem, sem ordem nenhuma.

TF-IDF: nem toda palavra pesa igual

Bag-of-words trata "the" (aparece em quase toda resenha) e "wonderful" (aparece só nas boas) do mesmo jeito: os dois contam 1 se aparecem. Mas "the" não carrega informação nenhuma sobre a resenha ser boa ou ruim, já "wonderful" carrega bastante. O resolve isso com duas contas multiplicadas:

class TfidfTransformer(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        doc_freq = {}
        for text in X:
            for token in set(self.tokenize(text)):
                doc_freq[token] = doc_freq.get(token, 0) + 1
        ...
        self.idf = {token: math.log(len(X) / freq) for token, freq in doc_freq.items()}
        return self

    def transform(self, X, y=None):
        ...
        for token, count in token_counts.items():
            tf = count / total_tokens_no_documento
            value = tf * self.idf[token]

TF (term frequency) é simples: quantas vezes a palavra aparece na resenha, dividido pelo total de palavras dela, o peso "local". IDF (inverse document frequency) é o peso "global": log(total de documentos / quantos documentos contêm a palavra). Palavra que aparece em quase todo documento (tipo "the") tem IDF perto de zero, quase não pesa nada. Já palavra rara, presente em poucos documentos, tem IDF alto. Multiplicando os dois, uma palavra só ganha peso alto se for frequente naquela resenha específica e rara no restante do corpus, exatamente o tipo de palavra que ajuda a diferenciar uma resenha da outra.

pipeline = Pipeline([("vectorizer", TfidfTransformer(10000)), ("classifier", RandomForestClassifier(random_state=42))])

Saída: 0.8316 de acurácia na validação, praticamente empatado com bag-of-words (0.832).

Interativo: digita uma frase abaixo (em inglês, o mesmo idioma do dataset) e vê o peso TF-IDF de cada palavra, calculado contra um corpuzinho de 16 resenhas curtas de exemplo. Palavra comum tipo "the" ou "was" fica com barra curta, palavra rara e carregada tipo "wonderful", "terrible" ou "boring" fica com barra bem mais alta, e palavra que esse corpo de exemplo nunca viu aparece marcada como [UNK], sem peso, o mesmo problema do vetorizador caseiro lá em cima.

theactingwaswonderfulbuttheplotwasboring
plot0.308
was0.218
acting0.186
wonderful0.186
but0.154
boring0.154
the0.154

7 / 9 token(s) reconhecidos no vocabulário de exemplo

No dataset inteiro, e contra o TfidfVectorizer oficial

Com as duas técnicas testadas na validação, o professor treina de novo em cima do treino completo (25 mil resenhas, não só a fatia de 20 mil) e mede no teste real:

TécnicaAcurácia no teste
Bag-of-words (unigram)0.83804
TF-IDF caseiro0.84292
TfidfVectorizer do scikit-learn0.83872

TF-IDF ganha por uma margem pequena do bag-of-words, e o mais importante: minha implementação caseira de TF-IDF (0.84292) fica a menos de meio ponto percentual da implementação oficial do scikit-learn (0.83872), a mesma régua de "bate com o profissional, na casa decimal" que já vi lá na equação normal e no PCA. O TfidfVectorizer de verdade tem otimizações e detalhes a mais (normalização L2 do vetor final, por exemplo), mas a ideia central, a mesma conta de TF vezes IDF, é idêntica.

Fechando

O que eu já sabiaO que essa aula assentou
Todo modelo espera um vetor de númeroTexto também precisa virar vetor, e o jeito de fazer isso é uma escolha de design, não um detalhe automático
Categoria nova em produção precisa de um plano[UNK] resolve pra palavra o mesmo problema que categoria desconhecida resolvia pro Titanic
Bater com o scikit-learn valida a implementação caseiraTF-IDF montado na mão (0.84292) chega perto o bastante do TfidfVectorizer oficial (0.83872) pra confirmar que a lógica está certa

E com isso fecho as 14 aulas dessa matéria. Comecei ajustando uma reta com equação normal e termino aqui, transformando resenha de filme em vetor de palavra pesada. No meio do caminho teve árvore, ensemble, cluster, redução de dimensão, dado desbalanceado, e o Bishop segurando a ponta teórica de quase tudo isso, menos das últimas três aulas (DBSCAN, semi-supervisionado e NLP), que vieram depois do livro dele de 2006 e mesmo assim encaixaram direitinho na mesma lógica de sempre: dado vira vetor, vetor vira decisão. Valeu, professor Boldt.

Aplicação Prática

Pra confirmar o achado da aula (bag-of-words, TF-IDF caseiro e TfidfVectorizer oficial dando resultado parecido) num dataset diferente do IMDB, usei o 20newsgroups do próprio scikit-learn: posts de fórum reais, sobre dois assuntos bem distintos, sci.space (espaço) e rec.sport.baseball (beisebol), 1190 posts de treino e 791 de teste.

from sklearn.datasets import fetch_20newsgroups
cats = ['sci.space', 'rec.sport.baseball']
train = fetch_20newsgroups(subset='train', categories=cats, remove=('headers','footers','quotes'), random_state=42)
test = fetch_20newsgroups(subset='test', categories=cats, remove=('headers','footers','quotes'), random_state=42)

Reproduzi as três abordagens (UnigramTransformer e TfidfTransformer do jeito exato que o professor fez, mais o TfidfVectorizer oficial), com RandomForestClassifier(random_state=42) em cima de um vocabulário de 5000 palavras:

TécnicaAcurácia no teste
Bag-of-words (unigram)0.8786
TF-IDF caseiro0.8774
TfidfVectorizer do scikit-learn0.8963

Três números bem próximos de novo, num assunto e num dataset totalmente diferentes dos filmes do IMDB, o que dá confiança de que o padrão da aula não foi coincidência daquele dataset específico: qualquer vetorização razoável de texto (presença de palavra ou TF-IDF) já entrega a maior parte do sinal útil pra separar duas classes bem distintas, aqui espaço de foguete contra taco de beisebol.