PCA: Reduzir Dimensão sem Perder o que Importa (Nem Sempre)
Aula 10a e 10b. Depois de K-means e DBSCAN (agrupar sem rótulo), PCA ataca outro problema sem rótulo: como resumir um monte de variáveis em poucas, sem perder o que importa. Spoiler do post inteiro: "o que importa" é uma pergunta traiçoeira.
O que PCA calcula, de verdade
O professor volta pro dataset de câncer de mama que já apareceu nessa playlist, 30 variáveis, 569 pacientes:
from sklearn.decomposition import PCA
pca = PCA(n_components=30)
X_pca = pca.fit_transform(X)
print(pca.explained_variance_ratio_)
Saída: o primeiro componente sozinho explica 98.2% de toda a variância dos dados. Os 29 restantes, juntos, explicam os 1.8% que sobram.
Uma concentração absurda, e faz sentido: várias dessas 30 variáveis medem essencialmente a mesma coisa de jeitos diferentes (raio, perímetro e área do tumor são praticamente a mesma informação, só em unidades diferentes), então a variação real do dataset é bem mais "estreita" do que 30 números sugerem.
O Bishop define PCA como achar a direção que maximiza a variância dos dados projetados. Pra um único componente, isso vira um problema de autovalor: a direção ótima satisfaz
onde é a matriz de covariância dos dados. Ou seja, precisa ser um autovetor de , e a variância capturada é exatamente o autovalor correspondente. Quanto maior o autovalor, mais variância aquela direção captura, e é por isso que o "primeiro componente principal" é sempre o autovetor de maior autovalor. Os componentes seguintes repetem a receita, cada um maximizando a variância entre as direções que sobraram, ortogonais às anteriores.
Repara: em nenhum momento essa conta usa o rótulo y. PCA olha só pra X, procurando onde os dados variam mais. Guarda essa frase, ela é o fio condutor do post inteiro.
Reduzindo de verdade, com um porém
Com 9 componentes (30% das 30 variáveis originais), a variância capturada já é 99.99997%:
n_most_important = int(X.shape[1]*0.3) # 9
scores = cross_val_score(model, X_pca[:, :n_most_important], y, cv=splitter)
O professor compara acurácia (espaço original de 30 variáveis vs os 9 primeiros componentes) em cinco classificadores diferentes:
| Classificador | 30 variáveis originais | 9 componentes PCA |
|---|---|---|
| KNN (K=3) | 0.9274 | 0.9274 (idêntico) |
| Regressão Logística | 0.9520 | 0.9502 |
| Naive Bayes Gaussiano | 0.9391 | 0.9039 |
| SVM | 0.9150 | 0.9221 |
| Árvore de decisão | 0.9232 | 0.9203 |
Repara que o efeito não é o mesmo pra todo mundo. KNN nem percebe a diferença (PCA com quase toda a variância preservada é basicamente uma rotação + um corte quase sem perda, e distância euclidiana não liga pra rotação). Naive Bayes Gaussiano piora bastante, o que é meio contra-intuitivo à primeira vista: ele assume que as variáveis são independentes entre si, e os componentes do PCA são descorrelacionados por construção, então eu esperaria uma ajuda, não um tombo. Mas "descorrelacionado" não é a mesma coisa que "todo mundo importa igual": os 9 componentes que sobraram têm variância bem desigual entre si (o primeiro sozinho já carrega a fatia esmagadora da variância total), e Naive Bayes Gaussiano ajusta uma variância por variável por classe, então ele confia mais em componentes de variância maior. O problema é que "maior variância" e "melhor pra separar as classes" não são a mesma coisa, a mesma pegadinha que a próxima seção deixa ainda mais clara: descartar os componentes de menor variância pode estar jogando fora justamente o eixo onde a média de uma classe difere da outra, o que Naive Bayes Gaussiano sente na pele porque ele depende diretamente dessas variâncias por variável, ao contrário do KNN, que só olha a distância total. E árvore de decisão piora um pouquinho, pelo mesmo motivo que já vi no post de árvores: árvore corta um eixo de cada vez, e PCA gira os eixos, então a fronteira de decisão que ficava alinhada com uma variável original pode virar diagonal nos componentes, mais difícil de recortar com cortes retos.
O scikit-learn ainda deixa escolher a quantidade de componentes por fração de variância em vez de contagem fixa:
pca = PCA(n_components=0.9999)
X_pca = pca.fit_transform(X)
print(X_pca.shape)
Saída:
(569, 5). Só 5 componentes já bastam pra 99.99% da variância, menos até que os 9 escolhidos na mão.
O aviso: PCA não sabe o que é "importante" pra você
Essa seção (o professor batizou de "PCA fail" no próprio notebook) é o motivo pelo qual eu escrevi "guarda essa frase" lá em cima. Ele constrói um dataset artificial de propósito: 12 grupos de pontos, arranjados em duas fileiras (uma em , outra em ), espalhados ao longo do eixo de 0 a 1.1. A classe alterna: fileira de baixo é uma classe, fileira de cima é outra, não importa o valor de .
model = Perceptron()
model.fit(X, y)
print(accuracy_score(y, model.predict(X)))
Saída: 1.0. Cem por cento, porque separar por fileira (valor de
y) é fácil, é só uma reta horizontal.
Agora aplica PCA com 1 componente antes de treinar:
pca = PCA(n_components=1)
X_pca = pca.fit_transform(X)
model.fit(X_pca, y)
print(accuracy_score(y, model.predict(X_pca)))
Saída: 0.47. Praticamente a cara de uma moeda sendo jogada.
O que aconteceu: o eixo (0 a 1.1) tem muito mais espalhamento que o eixo (0.1 a 0.4), então a direção de maior variância é quase totalmente ao longo de . PCA, fazendo exatamente o que promete, escolhe essa direção como o único componente. Só que a informação que separa as classes mora no eixo , o de menor variância, e PCA jogou ela fora inteira. O algoritmo não tem como saber que importava mais pra classificar, porque ele nunca olhou pro rótulo.
Interativo: pra onde o primeiro componente aponta
Reconstrução minha do mesmo dataset de 100 pontos (dois grupos, mesma disposição em fileiras), com os dois componentes principais desenhados por cima (vermelho sólido = 1º componente, verde tracejado = 2º, cada seta proporcional à raiz quadrada da variância que aquele componente explica):
componente principal 1 explica 83.5% da variância
Sem normalizar, o componente 1 (vermelho) fica quase deitado, seguindo o eixo , exatamente a direção que não separa as classes. Clica em "Normalizado (z-score)": agora as duas variáveis competem em pé de igualdade, e o componente 1 gira pra uma diagonal, ainda não perfeitamente alinhado com a separação real (por isso o resultado com normalização, 0.81 de acurácia no notebook, é melhor mas não perfeito), mas bem menos cego ao que importa do que a versão sem normalizar.
PCA como compressão de verdade
aula10b troca de dataset pro MNIST (70 mil imagens, 784 pixels cada) e usa PCA pra achar quantos componentes bastam pra reter 95% da variância:
pca = PCA()
pca.fit(X_train)
cumsum = np.cumsum(pca.explained_variance_ratio_)
d = np.argmax(cumsum >= 0.95) + 1
print(d)
Saída:
d = 154. De 784 pixels pra 154 componentes, uma redução de quase 80% no tamanho, ainda guardando 95% da variância.
Comparando acurácia, pixel cru contra os 154 componentes:
| Classificador | 784 pixels | 154 componentes PCA |
|---|---|---|
RidgeClassifier | 0.8603 | 0.8609 (praticamente igual) |
RandomForestClassifier | 0.9705 | 0.9488 (piora notável) |
O mesmo padrão do dataset de câncer: modelo linear não se importa (ou até melhora um pouquinho), floresta de árvores piora, porque de novo perde o alinhamento de eixo que ela precisa pra cortar bem.
E porque PCA guarda a direção de cada componente, dá pra fazer o caminho de volta (com perda) e reconstruir uma aproximação da imagem original:
X_train_recovered = pca.inverse_transform(X_train_reduced)
Os dígitos reconstruídos ficam visivelmente mais "borrados" que o original, mas ainda dá pra reconhecer qual número é qual, com só 154 números guardados por imagem em vez de 784. É literalmente compressão com perda, no mesmo espírito de um JPEG, só que a "base" usada pra descrever a imagem foi aprendida a partir do próprio dataset em vez de ser genérica.
Fechando
| O que eu já sabia | O que essa aula assentou |
|---|---|
| Reduzir dimensão simplifica o problema | PCA reduz preservando variância, não preservando "o que separa as classes", e essas duas coisas podem apontar pra direções completamente diferentes |
| Árvores cortam eixo por eixo | Rodar PCA antes de uma árvore ou floresta pode piorar o resultado, porque a rotação quebra o alinhamento que esses modelos exploram |
| Compressão com perda existe pra imagem e áudio | PCA faz exatamente isso em qualquer dado tabular: reconstrução aproximada, guardando só as direções que mais variam |
Aplicação Prática
Sem normalizar, o componente 1 do dataset "PCA fail" captura 83.5% da variância total, quase todo mundo. Normalizado, cai pra 54.8%, quase empatado com o componente 2 (45.2%). Isso por si só já é um sinal de alerta útil: quando o primeiro componente domina demais a variância (como no câncer de mama, 98.2%, ou aqui sem normalizar, 83.5%), vale desconfiar que uma variável de escala grande está dominando a conta sozinha, o mesmo problema de escala que eu já vi lá atrás com KNN, só que agora afetando o próprio PCA em vez de uma distância.
# variância explicada pelo componente 1, com e sem normalizar
razao_sem_normalizar = 0.835 # x domina, y quase não conta
razao_normalizado = 0.548 # x e y competem em pé de igualdade
A régua prática que fica: sempre que eu for usar PCA em variáveis que não estão na mesma unidade (preço em dólar ao lado de idade em anos, por exemplo), normalizar antes não é opcional, é o mesmo cuidado de sempre, agora aplicado a um lugar novo.