Ir para o conteúdo

Dimensionality Reduction

Redução de Dimensionalidade

Três métodos, três objetivos diferentes. O PCA preserva variância global e é reversível. O t-SNE preserva vizinhança local e distorce todo o resto. O UMAP tenta as duas coisas e falha de forma diferente. Saber qual distorção cada um introduz é o conteúdo inteiro desta página.1

Quatro razões para reduzir — e elas exigem métodos distintos

Confundi-las é a origem de quase todo mau uso.

Objetivo O que se exige do método Escolha típica
Visualizar saída em 2D ou 3D; fidelidade local basta t-SNE, UMAP
Comprimir / acelerar reconstrução e uma transformação aplicável a dados novos PCA
Remover ruído ou colinearidade descartar direções de baixa variância PCA, SVD truncada
Pré-processar para outro modelo um fit/transform honesto, sem vazamento PCA, quase sempre

Um método construído para a primeira linha não tem transform para linhas novas e um construído para a segunda faz uma figura ruim. Escolher por popularidade em vez de por linha é como se acaba alimentando um classificador com duas coordenadas de t-SNE.


1. Por que a estatística empurra você a reduzir

Além dos quatro objetivos acima há uma razão estrutural: em dimensão alta, a geometria deixa de se comportar como a intuição de duas e três dimensões espera. O volume foge para a superfície, a bola inscrita praticamente some, direções aleatórias ficam quase ortogonais e — a que de fato quebra as coisas — as distâncias par a par se concentram, então "vizinho mais próximo" pouco a pouco deixa de significar algo.

Esse último efeito é a razão pela qual t-SNE e UMAP recomendam rodar PCA antes quando a entrada tem centenas ou milhares de colunas. Não é só velocidade: é a qualidade das vizinhanças sobre as quais esses métodos são construídos.

Mas a versão de uma linha está errada de um jeito que importa

"Dimensão alta quebra métodos de distância" não é bem isso. A afirmação correta é:

Dimensões que não carregam informação sobre a tarefa diluem a informação das que carregam — cada uma acrescenta ruído à distância enquanto o sinal permanece constante.

Medido abaixo: as mesmas 128 colunas extras levam um 5-NN de 0,906 para 0,595 quando são ruído e para 1,000 quando carregam sinal. A página a maldição da dimensionalidade desmonta a afirmação nos quatro fenômenos distintos que ela comprime, com um painel para cada.

2. PCA: duas derivações, uma resposta

Centralize os dados de modo que \(\mu = 0\) e procure uma direção unitária \(\mathbf{w}\) que os resuma bem. Há duas formas de dizer "bem" e a elegância do PCA é que elas coincidem.

Guardar a maior dispersão possível depois de projetar:

\[ \max_{\lVert \mathbf{w} \rVert = 1} \; \frac{1}{n}\sum_{i=1}^{n} (\mathbf{x}_i^\top \mathbf{w})^2 \;=\; \max_{\lVert \mathbf{w} \rVert = 1} \; \mathbf{w}^\top \Sigma \mathbf{w} \]

Da restrição sai um lagrangiano; derivando e igualando a zero, chega-se a \(\Sigma \mathbf{w} = \lambda \mathbf{w}\). A direção procurada é um autovetor da matriz de covariância e a variância ao longo dela é o autovalor \(\lambda\) correspondente.

Perder o mínimo possível ao projetar e voltar:

\[ \min_{\lVert \mathbf{w} \rVert = 1} \; \frac{1}{n}\sum_{i=1}^{n} \lVert \mathbf{x}_i - (\mathbf{x}_i^\top \mathbf{w})\mathbf{w} \rVert^2 \]

Como o resíduo é perpendicular à projeção, Pitágoras reparte a norma de cada ponto entre as duas:

\[ \lVert \mathbf{x}_i \rVert^2 = (\mathbf{x}_i^\top \mathbf{w})^2 + \lVert \text{resíduo}_i \rVert^2 \]

Somando sobre os \(n\) pontos, o lado esquerdo não depende de \(\mathbf{w}\). Então minimizar o resíduo e maximizar a variância projetada são o mesmo problema, com o sinal trocado.

Tente achar o melhor ângulo antes de apertar ir para a PC1. As duas barras sempre somam o mesmo número, em qualquer ângulo: é a identidade acima, aplicada à nuvem inteira.

A segunda componente é o autovetor do segundo maior autovalor, necessariamente ortogonal ao primeiro, porque \(\Sigma\) é simétrica. Empilhando as \(k\) primeiras em \(W\): projeção \(Z = XW\) e reconstrução \(\hat X = ZW^\top\).

Na prática: SVD, não a matriz de covariância

Bibliotecas não formam \(\Sigma = \frac{1}{n}X^\top X\). Elas calculam \(X = U S V^\top\); as colunas de \(V\) são as componentes e \(\lambda_j = s_j^2 / n\). Formar \(X^\top X\) eleva o número de condição ao quadrado e degrada a precisão — a mesma razão pela qual não se resolve mínimos quadrados pela equação normal.

Escolhendo \(k\)

k explained cumulative recon_mse trustworthiness knn_acc
1 0.7296 0.7296 0.2704 0.888 0.913
2 0.2285 0.9581 0.0419 0.980 0.900
3 0.0367 0.9948 0.0052 0.999 0.960
4 0.0052 1.0000 0.0000 1.000 0.953
"""PCA on Iris: what each component costs, and what it is worth.

Four standardized measurements, four components. `explained` is the share of
total variance each one carries, `recon_mse` is what is lost by keeping only
the first k and projecting back, `trustworthiness` is how many of each point's
twelve nearest neighbours in four dimensions are still its neighbours in k, and
`knn_acc` is a 5-NN classifier trained on the k components.

The last column is the one to look at twice. Two components carry 95.8% of the
variance and reconstruct almost perfectly — and they classify *worse* than
three. PCA is unsupervised: it maximizes variance, and nothing guarantees that
the directions with the most variance are the directions that separate the
classes.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA
from sklearn.manifold import trustworthiness
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_iris(return_X_y=True)
X_std = StandardScaler().fit_transform(X)        # PCA maximizes variance, and variance has units
cv = StratifiedKFold(5, shuffle=True, random_state=0)

ratios = PCA().fit(X_std).explained_variance_ratio_

print("| `k` | `explained` | `cumulative` | `recon_mse` | `trustworthiness` | `knn_acc` |")
print("|---:|---:|---:|---:|---:|---:|")
for k in (1, 2, 3, 4):
    pca = PCA(k).fit(X_std)
    scores = pca.transform(X_std)
    recon = np.mean((X_std - pca.inverse_transform(scores)) ** 2)
    # the accuracy is cross-validated with PCA *inside* the pipeline, or it would leak
    acc = cross_val_score(
        make_pipeline(StandardScaler(), PCA(k), KNeighborsClassifier(5)), X, y, cv=cv
    ).mean()
    print(f"| {k} | {ratios[k - 1]:.4f} | {ratios[:k].sum():.4f} | {recon:.4f} "
          f"| {trustworthiness(X_std, scores, n_neighbors=12):.3f} | **{acc:.3f}** |")

Iris, padronizado, quatro componentes. As duas primeiras carregam 95,8% da variância e reconstroem com erro de 0,042 contra 0,270 de uma só. Pelos critérios de sempre — "guarde 95% da variância", "procure o cotovelo no gráfico de escarpa" — a resposta é \(k = 2\) e você para por aí.

Agora leia a última coluna. \(k = 2\) classifica a 0,900 e \(k = 3\) a 0,960. A terceira componente carrega 3,7% da variância e vale seis pontos de acurácia.

As cinco armadilhas

  1. Não padronizar. O PCA maximiza variância e variância tem unidade. Renda em reais domina idade em anos por construção. Padronize sempre que as escalas forem incomparáveis — o que equivale a fazer PCA sobre a matriz de correlação.
  2. Ajustar antes da divisão. fit no conjunto completo é vazamento: a base de projeção viu o teste. O correto é fit no treino e transform no resto — por isso o PCA vive dentro de um Pipeline.
  3. Supor que componentes de alta variância são as úteis para prever. Não há garantia: o PCA é não supervisionado e ignora \(y\) por completo. A tabela acima é um caso brando; há exemplos clássicos em que o sinal preditivo está na última componente. Quando o objetivo é discriminar, considere LDA ou PLS.
  4. Interpretar componentes como fatores reais. O sinal e a escala são arbitrários e a rotação dentro de um subespaço de autovalores próximos é indeterminada.
  5. Aplicar PCA a colunas one-hot sem pensar. Funciona mecanicamente, mas a "variância" de uma indicadora é \(p(1-p)\) — categorias raras são descartadas automaticamente.
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipe = make_pipeline(StandardScaler(), PCA(n_components=0.95), MLPClassifier())
# n_components como float = "guarde componentes suficientes para esta fração da variância"
# e, dentro do pipeline, ele é reajustado a cada dobra de treino

Fazendo à mão, uma vez

Vale escrever a decomposição em autovalores uma vez, para o PCA(n_components=2) deixar de ser caixa-preta. Os dois scripts produzem a mesma projeção, a menos do sinal arbitrário de cada componente.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from io import StringIO
from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler

# Loading Iris dataset
iris = load_iris()

# Transform in dataframe
df = pd.DataFrame(
    data=iris.data,
    columns=['sepal_l', 'sepal_w', 'petal_l', 'petal_w']
)
df['class'] = iris.target_names[iris.target]

X = df.iloc[:,0:4].values
y = df.iloc[:,4].values

# Standardizing
X_std = StandardScaler().fit_transform(X)

# Covariance
cov_mat = np.cov(X_std.T)

# Calculate autovalues and autovectors
eig_vals, eig_vecs = np.linalg.eig(cov_mat)

print('Eigenvectors \n%s' %eig_vecs)
print('\nEigenvalues \n%s' %eig_vals)

# Make a list of (eigenvalue, eigenvector) tuples
eig_pairs = [(np.abs(eig_vals[i]), eig_vecs[:,i]) for i in range(len(eig_vals))]

# Sort the (eigenvalue, eigenvector) tuples from high to low
eig_pairs.sort(key=lambda x: x[0], reverse=True)

# Visually confirm that the list is correctly sorted by decreasing eigenvalues
print('Eigenvalues in descending order:')
for i in eig_pairs: print(i[0])

# Sum the cummulative of each eigen value
tot = sum(eig_vals)
var_exp = [(i / tot)*100 for i in sorted(eig_vals, reverse=True)]
cum_var_exp = np.cumsum(var_exp)

n_eigen = [1, 2, 3, 4]

# Plot the cumulative for each eign value
plt.figure(figsize=(6, 4))
plt.bar(n_eigen, var_exp, alpha=0.5, align='center',
    label='individual explained variance')
plt.step(n_eigen, cum_var_exp, where='mid',
    label='cumulative explained variance')
plt.ylabel('Explained variance ratio')
plt.xlabel('Principal components')
plt.legend(loc='best')
plt.tight_layout()

# Take the only the two firsts eigen values
matrix_w = np.hstack((eig_pairs[0][1].reshape(4,1),
                      eig_pairs[1][1].reshape(4,1)))

print('*' * 10)
print('Reduced to 2-D')
print('Matrix W:\n', matrix_w)

# Calculate the new Y for all samples
Y = X_std.dot(matrix_w)

# Plot the data for the 2 firsts principal components
plt.figure(figsize=(6, 4))
for lab, col in zip(('setosa', 'versicolor', 'virginica'), ('blue', 'red', 'green')):
    plt.scatter(Y[y==lab, 0],
                Y[y==lab, 1],
                label=lab,
                c=col)
plt.xlabel('Principal Component 1')
plt.ylabel('Principal Component 2')
plt.legend(loc='lower center')
plt.tight_layout()

# Para imprimir na página HTML
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=True)
print(buffer.getvalue())
plt.close()
import matplotlib.pyplot as plt
import pandas as pd
from io import StringIO
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA as pca
from sklearn.preprocessing import StandardScaler

# Loading Iris dataset
iris = load_iris()

# Transform in dataframe
df = pd.DataFrame(
    data=iris.data,
    columns=['sepal_l', 'sepal_w', 'petal_l', 'petal_w']
)
df['class'] = iris.target_names[iris.target]

X = df.iloc[:,0:4].values
y = df.iloc[:,4].values

# Standardizing
X_std = StandardScaler().fit_transform(X)

sklearn_pca = pca(n_components=2)
Y = sklearn_pca.fit_transform(X_std)

# Plot the data for the 2 firsts principal components
plt.figure(figsize=(6, 4))
for lab, col in zip(('setosa', 'versicolor', 'virginica'), ('blue', 'red', 'green')):
    plt.scatter(Y[y==lab, 0],
                Y[y==lab, 1],
                label=lab,
                c=col)
plt.xlabel('Principal Component 1')
plt.ylabel('Principal Component 2')
plt.legend(loc='lower center')
plt.tight_layout()

# Para imprimir na página HTML
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=True)
print(buffer.getvalue())
plt.close()

Dois detalhes em que as pessoas tropeçam

Os autovalores não saem do np.linalg.eig em ordem decrescente — ordená-los é um passo, não formalidade. E o np.cov divide por \(n-1\), mesma convenção do explained_variance_ do scikit-learn; se você dividir por \(n\) à mão, os seus autovalores diferem dos do scikit-learn por um fator \(n/(n-1)\) enquanto as razões são idênticas. As componentes e a projeção não mudam.


3. Onde o PCA falha

O PCA encontra um subespaço linear. Quando os dados moram sobre uma variedade curva — uma espiral, um rocambole suíço, o conjunto de todas as imagens de um objeto girando — plano algum os resume e as primeiras componentes vão alegremente descrever uma direção que atravessa a curva em linha reta.

1970-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/

Uma mola: uma curva só, enrolada em três dimensões, colorida pela posição ao longo dela. O plano que captura mais variância é aquele em que as voltas estão, então o PCA empilha cada volta sobre todas as outras.

O número sob cada projeção conta os vizinhos falsos: dos dez pontos mais próximos no mapa, quantos estão a mais de um décimo da curva de distância ao longo dela. O PCA marca 72% — três em cada quatro dos vizinhos aparentes de um ponto estão, na verdade, em outro lugar. O par destacado é o caso extremo: quatro voltas de distância na mola, 0,007 de distância depois de projetar.

Duas coisas merecem atenção antes de seguir.

Primeiro, essa falha é invisível para a trustworthiness, a métrica que a seção 7 recomenda. Ela dá 0,956 ao PCA aqui, porque compara o mapa contra distâncias no espaço ambiente de 3-D — e em 3-D pontos de voltas vizinhas de fato estão perto. O que a projeção destrói é a estrutura intrínseca — a distância ao longo da curva — e isso só se enxerga medindo contra ela. A métrica escolhida decide o que conta como falha.

Segundo, veja o que o t-SNE fez. Ele acerta as vizinhanças quase perfeitamente — 1% de falsos — e rasga a curva em arcos e os espalha. Isso não é um defeito a consertar; é a troca. As vizinhanças estão certas e a disposição dos pedaços não significa nada, que é exatamente por que existem as sete afirmações proibidas.

É essa a abertura para os métodos não lineares e eles mudam a pergunta. O PCA pergunta "quais direções concentram variância?" — pergunta global, resposta linear e reversível. O t-SNE e o UMAP perguntam "quem está perto de quem?" — pergunta local, respondida sacrificando deliberadamente a geometria global.


4. t-SNE: casar distribuições de vizinhança2

Em uma frase: transformar distâncias em probabilidades de vizinhança nos dois espaços e mover os pontos do mapa 2D até que as duas distribuições coincidam.

Passo 1 — vizinhança no espaço original. Para cada ponto \(i\), um núcleo gaussiano sobre os vizinhos dele:

\[ p_{j|i} = \frac{\exp(-\lVert \mathbf{x}_i - \mathbf{x}_j \rVert^2 / 2\sigma_i^2)}{\sum_{k \neq i} \exp(-\lVert \mathbf{x}_i - \mathbf{x}_k \rVert^2 / 2\sigma_i^2)} \]

Cada ponto ganha seu próprio \(\sigma_i\), achado por busca binária até a perplexidade \(2^{H(P_i)}\) atingir o valor pedido. Perplexidade ≈ número efetivo de vizinhos. Regiões densas ganham \(\sigma\) pequeno; regiões esparsas, \(\sigma\) grande — uma adaptação automática de escala. O resultado é simetrizado, \(p_{ij} = (p_{j|i} + p_{i|j})/2n\), para que todo ponto contribua para a perda, inclusive pontos isolados.

Passo 2 — vizinhança no mapa, com cauda pesada.

\[ q_{ij} = \frac{(1 + \lVert \mathbf{y}_i - \mathbf{y}_j \rVert^2)^{-1}}{\sum_{k \neq l}(1 + \lVert \mathbf{y}_k - \mathbf{y}_l \rVert^2)^{-1}} \]

Uma \(t\) de Student com um grau de liberdade (uma Cauchy) e essa é a diferença essencial em relação ao SNE original. Por que a cauda pesada — o problema do amontoamento: o volume de uma bola cresce como \(r^d\), então um ponto em dimensão alta tem espaço para muitos vizinhos a distância moderada e em 2D esse espaço não existe. Com gaussiana dos dois lados, todo vizinho moderado seria forçado ao centro e o mapa colapsaria. A cauda pesada permite que pares moderadamente distantes fiquem muito distantes no mapa a um custo baixo, o que libera espaço para separar os grupos.

O efeito colateral é o que se precisa lembrar: as distâncias entre grupos na figura deixam de ser interpretáveis.

Passo 3 — minimizar a divergência.

\[ \mathrm{KL}(P \parallel Q) = \sum_{i \neq j} p_{ij} \log \frac{p_{ij}}{q_{ij}} \]

A KL é assimétrica e a assimetria é o comportamento inteiro do método. \(p\) alto com \(q\) baixo — vizinhos reais afastados — é punido caro. \(p\) baixo com \(q\) alto — pontos distantes desenhados perto — é quase de graça. Daí a regra:

O que está junto num mapa t-SNE pode não estar junto de verdade.

Veja acontecer. O painel roda o algoritmo de verdade — a busca da perplexidade, o exagero inicial, o gradiente descendente — algumas iterações por quadro.

O conjunto em que vale passar mais tempo é o último. São doze colunas de ruído uniforme, sem nada a encontrar — e o t-SNE lhe entrega grupos separados e arrumados assim mesmo.


5. UMAP: um grafo difuso, desenhado

O UMAP chega a um resultado parecido por um caminho diferente. Em vez de distribuições sobre todos os pares, constrói explicitamente um grafo de \(k\) vizinhos com pesos difusos e depois o desenha.

Passo 1 — grafo local com conectividade garantida.

\[ w_{i \to j} = \exp\!\left(-\frac{\max(0, d(\mathbf{x}_i, \mathbf{x}_j) - \rho_i)}{\sigma_i}\right) \]

\(\rho_i\) é a distância ao vizinho mais próximo e \(\sigma_i\) é calibrado para que \(\sum_j w_{i \to j} = \log_2 k\). Subtrair \(\rho_i\) garante que todo ponto tenha ao menos uma aresta de peso 1, então nada fica isolado. As duas visões direcionadas são combinadas por uma união difusa — o análogo da simetrização do t-SNE.

Passo 2 — desenhar o grafo. A perda é uma entropia cruzada binária, não uma KL:

\[ \sum_{(i,j)} \Big[ w_{ij} \log \frac{w_{ij}}{q_{ij}} + (1 - w_{ij}) \log \frac{1 - w_{ij}}{1 - q_{ij}} \Big] \]

O segundo termo é o que o t-SNE não tem: uma penalidade explícita por aproximar o que está longe. É a origem da preservação global um pouco melhor do UMAP. A otimização usa SGD com amostragem negativa — uma aresta é sorteada e os extremos dela se atraem, alguns pontos aleatórios são sorteados como negativos e se repelem — o que evita a soma sobre todos os pares e é a razão de o UMAP ser mais rápido.

# não é dependência do curso: pip install umap-learn
import umap
Z = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(X_std)

O n_neighbors troca detalhe local por estrutura global; o min_dist controla o quanto os pontos podem se empacotar no mapa e é puramente estético — muda a figura sem mudar o que foi aprendido.

O painel abaixo constrói o grafo e depois o desenha, com os mesmos quatro conjuntos. Ligue grafo para ver aquilo que está sendo otimizado: o layout é esse grafo, desembaraçado.

Rode a curva enrolada nos dois painéis e a diferença entre as perdas aparece direto — o segundo termo do UMAP, a penalidade explícita por aproximar o que está longe, mantém a curva conectada por bem mais tempo.


6. Escolhendo — e o que se pode dizer depois

PCA t-SNE UMAP
Preserva variância global vizinhança local local e um pouco de global
Linear sim não não
Reversível sim não não
transform para linhas novas sim não aproximado
Determinístico sim (a menos de sinal) não não
Parâmetro principal \(k\) perplexidade n_neighbors, min_dist
Uso típico compressão, pré-processamento diagnóstico visual exploração visual em escala
flowchart TD
    A["para que serve a redução?"] --> B{"um modelo vai consumir<br/>a saída?"}
    B -->|sim| P["<b>PCA</b><br/><small>fit/transform honesto, reversível</small>"]
    B -->|"não — é uma figura"| C{"quantas linhas?"}
    C -->|"até ~10 mil"| T["<b>t-SNE</b><br/><small>melhor fidelidade local</small>"]
    C -->|"mais, ou chegam linhas novas"| U["<b>UMAP</b><br/><small>mais rápido, transform aproximado</small>"]
    T --> V["e sempre: PCA para ~50 antes"]
    U --> V

    classDef ok fill:#e6f4ea,stroke:#3fb950,color:#14532d
    classDef q  fill:#eef2f7,stroke:#8b949e,color:#1f2937
    class P,T,U,V ok
    class A,B,C q

A última caixa não é opcional. Com centenas ou milhares de colunas de entrada, rode PCA para cerca de 50 antes do t-SNE ou do UMAP — não só por velocidade, mas porque as vizinhanças que esses métodos consomem são calculadas justamente com as distâncias de que o A3 acima trata.

Sete afirmações proibidas sobre um gráfico t-SNE ou UMAP

  1. "Este grupo é maior, logo tem mais variabilidade." — O tamanho no mapa é artefato da densidade local.
  2. "Estes dois grupos estão próximos, logo são parecidos." — Distâncias entre grupos não são preservadas.
  3. "Há cinco grupos nos dados." — Há cinco manchas no gráfico. Agrupamento se valida no espaço original.
  4. "O eixo horizontal representa X." — Os eixos não têm significado; rotação e reflexão são arbitrárias.
  5. "Rodei uma vez e vi isso." — Resultados variam com a semente. Reporte várias execuções.
  6. "Usei as duas coordenadas do t-SNE como atributos do meu classificador." — Não há transform honesto para linhas novas e você comprimiu 500 dimensões em 2 escolhidas para agradar aos olhos.
  7. "Rodei t-SNE em todo o conjunto e depois dividi treino/teste." — Vazamento: o mapa foi construído usando o teste.

7. Como avaliar uma redução

Um gráfico não é resultado. Se a redução vai sustentar uma afirmação, ela precisa de métrica. As três usadas:

  • Erro de reconstrução — só faz sentido para métodos reversíveis (PCA, autoencoder). Mede o que foi perdido.
  • Confiabilidade (trustworthiness) — dos \(k\) vizinhos mais próximos de cada ponto no mapa, quantos eram de fato vizinhos próximos dele no espaço original. Aplicável a qualquer método, inclusive t-SNE e UMAP. Está em sklearn.manifold.trustworthiness.
  • Desempenho a jusante — a acurácia de um kNN treinado no espaço reduzido contra a do espaço original. É o teste mais honesto quando a redução é pré-processamento.
digits_1797x64 -> 2d trustworthiness
PCA 0.817
t-SNE 0.985
uniform_noise_600x20 silhouette_of_5_means
original_20d 0.042
t-SNE_map_2d 0.333
"""What t-SNE is good at, and the thing it will do to you if you let it.

Two measurements, each answering one half of the question.

First: does a two-dimensional map keep the neighbourhoods of the original
space? `trustworthiness` counts, for every point, how many of its twelve
nearest neighbours in the map were genuinely among its nearest neighbours in
64 dimensions. This is what t-SNE optimizes and PCA does not, and the gap is
the reason t-SNE exists.

Second: what does t-SNE do to data with no structure at all? The input is
uniform noise in twenty dimensions — no clusters, by construction. A 5-means
silhouette near zero is the correct answer, and the original space gives it.
The t-SNE map does not: the optimization has to put the points somewhere, and
where it puts them looks like groups.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE, trustworthiness
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

NEIGHBOURS, PERPLEXITY = 12, 30

digits = StandardScaler().fit_transform(load_digits(return_X_y=True)[0])
embed = lambda X: TSNE(n_components=2, init="pca", perplexity=PERPLEXITY,
                       random_state=0).fit_transform(X)

print(f"| `digits_1797x64 -> 2d` | `trustworthiness` |")
print("|---|---:|")
for name, Z in (("PCA", PCA(2, random_state=0).fit_transform(digits)),
                ("t-SNE", embed(digits))):
    print(f"| `{name}` | **{trustworthiness(digits, Z, n_neighbors=NEIGHBOURS):.3f}** |")

print()
noise = np.random.default_rng(0).random((600, 20))       # no structure whatsoever
print("| `uniform_noise_600x20` | `silhouette_of_5_means` |")
print("|---|---:|")
for name, Z in (("original_20d", noise), ("t-SNE_map_2d", embed(noise))):
    labels = KMeans(5, n_init=10, random_state=0).fit_predict(Z)
    print(f"| `{name}` | **{silhouette_score(Z, labels):.3f}** |")

A primeira tabela é o t-SNE fazendo aquilo para que ele existe. Espremendo 64 dimensões de dígitos manuscritos em 2, o PCA preserva 0,817 da estrutura de vizinhança e o t-SNE preserva 0,985. Se a pergunta é "quais dígitos o modelo confunde com quais", essa diferença é a resposta.

A segunda tabela é o preço. A entrada é ruído uniforme em vinte dimensões — grupo algum existe, por construção. Uma silhueta de 5-médias de 0,042 no espaço original é o relatório correto: não há nada ali. No mapa t-SNE a mesma medida dá 0,333, que é o número que você leria como "grupos bem separados". A otimização precisa pôr os pontos em algum lugar e onde ela os põe parece grupos.

Três frases para levar

  1. O PCA responde "quais direções concentram variância" — pergunta global, resposta linear, reversível.
  2. O t-SNE e o UMAP respondem "quem está perto de quem" — pergunta local, respondida sacrificando deliberadamente a geometria global.
  3. Todo método de redução tem de perder informação. A competência profissional consiste em saber exatamente qual e em não afirmar nada que dependa da parte perdida.


  1. A estrutura, as derivações e o enquadramento desta página seguem o handout de aula Ver em 2D o que existe em D dimensões, que traz o exemplo numérico completo feito à mão, doze simuladores ao vivo, o roteiro do Iris e os exercícios do MNIST. ↩

  2. van der Maaten, L., Hinton, G. Visualizing Data using t-SNE, JMLR 2008. McInnes, L., Healy, J., Melville, J. UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction, 2018. ↩