← Voltar pra playlist

Seleção de Características: Deixar o Rótulo Escolher, não só a Variância

Aula 13, e o assunto fecha um ciclo aberto lá no post de PCA: reduzir dimensão sem usar o rótulo pode jogar fora informação importante. Seleção de características resolve isso do jeito mais direto possível, usando o rótulo pra decidir o que descartar.

O dataset: dígitos, pixel por pixel

MNIST de novo, 60 mil imagens de treino, 784 pixels cada. O professor imprime um dígito como arte ASCII (X onde tem tinta, espaço onde não tem), um jeito bem literal de lembrar que cada uma dessas 784 "variáveis" é só um pixel, e a maioria deles (as bordas da imagem, por exemplo) provavelmente nunca tem tinta nenhuma.

class Normalizer255(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        return self
    def transform(self, X, y=None):
        return X / 255.0

pipeline = make_pipeline(Normalizer255(), RidgeClassifier())

Saída (todos os 784 pixels): 0.8604 de acurácia no teste.

Repara na normalização: em vez de StandardScaler (que precisa calcular média e desvio do treino), o professor usa uma constante fixa, 255, porque ele já sabe de antemão que pixel de imagem em escala de cinza vai de 0 a 255. Conhecimento do domínio substituindo uma conta que seria desnecessária.

Descartar por variância: nem sempre ajuda

from sklearn.feature_selection import VarianceThreshold
X_transformed = VarianceThreshold(0.1).fit_transform(X_train)

Saída: de 784 pixels pra 695 (89 descartados, provavelmente as bordas que quase nunca têm tinta). Acurácia depois do corte: 0.8449, pior que os 784 originais.

Vale registrar essa honestidade: mesmo um corte que parece óbvio (descartar pixel que quase nunca muda) piorou o resultado aqui, em vez de simplificar sem custo. VarianceThreshold só olha pra X, nunca pro rótulo y, então não tem como saber se aquele pouquinho de variância que ele descartou carregava sinal relevante pra separar os dígitos.

A régua caseira: intra-classe contra inter-classe

O professor constrói do zero uma função de distância euclidiana vetorizada (a mesma ideia que dá pra derivar de ab2=a22ab+b2\|a-b\|^2 = \|a\|^2 - 2a\cdot b + \|b\|^2, evitando um laço por par de pontos) e usa ela pra definir uma régua de qualidade por variável:

def distance_score(X, y):
    in_dist, out_dist = 0, 0
    for label in np.unique(y):
        in_class = X[y == label]
        out_class = X[y != label]
        in_dist += pairwise_distances(in_class, in_class).mean()
        out_dist += pairwise_distances(in_class, out_class).mean()
    return in_dist / (out_dist + 1e-8)

A ideia: pra cada variável, mede a distância média dentro de cada classe (será que todos os "3" se parecem nessa variável?) e a distância média entre classes diferentes (será que um "3" e um "7" são bem diferentes nessa variável?). Uma variável boa tem distância intra-classe baixa e distância inter-classe alta, então a razão intra/inter fica pequena. Isso é praticamente a mesma ideia do discriminante de Fisher que o Bishop descreve (capítulo 4.1.4, que eu já citei lá no post de classificação de outro jeito): separação entre classes dividida pela variação dentro de cada classe, só que aqui aplicada variável por variável, em vez de numa direção de projeção aprendida.

O custo de calcular isso em tudo

Calcular distância par a par em 60 mil pontos é caro (o custo cresce com o quadrado da quantidade de pontos), então o professor reduz pra uma amostra de 1200 exemplos de treino antes de rankear:

Saída: rankear as 784 variáveis nessa amostra menor levou cerca de 20 segundos. Mesmo assim, bem mais rápido que tentar isso nos 60 mil pontos inteiros.

Um lembrete direto de que "quanto custa calcular" também é parte do design de uma técnica, não só "o que ela mede".

Selecionando de verdade

class UnivariatedRanking(BaseEstimator, TransformerMixin):
    def __init__(self, n_features):
        self.n_features = n_features
    def fit(self, X, y=None):
        self.scores_ = univariate_ranking(X, y)
        return self
    def transform(self, X, y=None):
        X_sorted = X[:, self.scores_.argsort()]
        return X_sorted[:, :self.n_features]

Com só 1200 exemplos de treino (bem menos que os 60 mil de antes, pra caber no orçamento de tempo), o baseline com todas as 784 variáveis já cai bastante, pra 0.7678, o preço de treinar com menos dado. Selecionando só as 180 melhores variáveis (23% do total):

Saída: 0.7681. Praticamente empatado com usar tudo, só que com menos de um quarto das variáveis.

Essa é a diferença de fundo pro PCA: aqui o rótulo participa da escolha desde o início, então a técnica não corre o risco de descartar exatamente a informação que separa as classes, o mesmo risco que derrubou a acurácia de 100% pra 47% naquele dataset artificial do post de PCA. Seleção supervisionada de variáveis e redução não supervisionada de dimensão resolvem problemas parecidos, mas com garantias bem diferentes.

Automatizando o "quantas variáveis manter"

Faltava decidir 180 de algum jeito, e o professor resolve isso também: HybridRanking usa o ranking univariado pra ordenar as variáveis, depois testa incrementalmente (1 variável, 2 variáveis, 3...) num conjunto de validação separado, guardando o tamanho que deu a melhor acurácia:

for i in range(1, len(score_idxs)):
    X_selected = X_tr[:, score_idxs[0:i]]
    self.estimator.fit(X_selected, y_tr)
    acc = self.estimator.score(X_val[:, score_idxs[0:i]], y_val)
    if acc > best_score:
        best_score, best_set = acc, i

Saída: o algoritmo escolheu sozinho 199 variáveis, com acurácia 0.7702551020408164, exatamente igual, até a última casa decimal, ao resultado de escolher manualmente as 199 melhores variáveis do ranking.

A combinação (ranking rápido primeiro, depois testar tamanhos incrementalmente) é um padrão clássico de seleção de variáveis: um filtro barato (distance_score, calcula uma vez, ordena) reduz o espaço de busca, e um método mais caro (treinar e validar de verdade) decide o ponto de corte, sem precisar treinar um modelo pra cada uma das 27842^{784} combinações possíveis de variáveis.

Fechando

O que eu já sabiaO que essa aula assentou
PCA reduz dimensão maximizando variânciaSeleção de variáveis pode reduzir dimensão usando o rótulo, evitando o risco de descartar o que realmente importa pra classificar
Descartar variável "óbvia" sempre ajudaNem sempre: VarianceThreshold piorou o resultado aqui, porque variância baixa não é sinônimo de inútil
Fisher mede separação entre classes sobre variação dentro delasA mesma ideia rende uma régua simples pra rankear variáveis individualmente, não só pra achar uma direção de projeção

Aplicação Prática

Reproduzi a mesma régua (distance_score, ranking univariado) num dataset de dígitos menor, o mesmo Digits (1797 imagens, 8×8 pixels) que já apareceu no post de K-means, rápido o bastante pra rodar nos 64 pixels inteiros sem precisar reduzir a amostra.

scores = univariate_ranking(X_train, y_train)
sorted_idxs = np.argsort(scores)
for n in [10, 20, 32]:
    model = RidgeClassifier().fit(X_train[:, sorted_idxs[:n]], y_train)
    acc = accuracy_score(y_test, model.predict(X_test[:, sorted_idxs[:n]]))
Variáveis usadasFração do totalAcurácia no teste
64 (todas)100%0.9389
32 (melhores)50%0.9278
20 (melhores)31%0.8806
10 (melhores)16%0.7722

Com metade das variáveis (32 de 64), a acurácia cai só um ponto percentual (0.9389 para 0.9278). A curva não é linear: cortar de 64 pra 32 quase não dói, mas cortar de 32 pra 20 e depois pra 10 começa a doer rápido, sinal de que boa parte do sinal útil realmente está concentrada numa fração das variáveis, exatamente a suposição que torna seleção de características uma técnica que vale a pena.