Снижение размерности: PCA, SVD, t-SNE, UMAP
Есть два совершенно разных вопроса, которые почему-то обсуждают в одной главе учебника, и из-за этого рождается половина всех ошибок в теме.
Вопрос первый: как сжать признаки, не потеряв сигнал? У вас 4096 чисел эмбеддинга, а
модель хочется быстрее, индекс — меньше, регуляризацию — сильнее. Нужно отображение
R^D -> R^k, которое можно применить к новым данным, обратить (хотя бы приблизительно) и
поставить в пайплайн между fit и predict.
Вопрос второй: как посмотреть на данные глазами? У вас 200 тысяч клеток, размеченных по 30 тысячам генов, и нужно понять, сколько там на самом деле популяций. Здесь никакой «применимости к новым данным» не требуется — требуется честная картинка на плоскости.
Первый вопрос решают линейные проекции: PCA, truncated SVD, случайные проекции. Они
дают матрицу W, это просто умножение. Второй решают нелинейные вложения: t-SNE, UMAP.
Они дают координаты конкретных точек, оптимизированные под сохранение соседства, и почти
никаких гарантий про всё остальное.
Спутать их — значит либо кормить модель t-SNE-координатами (катастрофа: утечка, нестабильность, невозможность применить к новым данным), либо пытаться разглядеть кластеры на первых двух главных компонентах, где они принципиально не разделяются.
Дальше — обе линии целиком: интуиция, вывод, сложность, код, продовые детали и список конкретных граблей. Предполагается, что вы прошли https://courses.digitable.life/post/machine-learning/01-math-foundations/ (собственные векторы, SVD, ковариация) и https://courses.digitable.life/post/machine-learning/02-data-and-features/ (масштабирование, утечки) — здесь и то и другое критично.
размерности)) Зачем Проклятие размерности Скорость и память Шум и мультиколлинеарность Визуализация Линейные PCA ковариация и SVD whitening Incremental PCA Truncated SVD / LSA разреженные матрицы без центрирования Random Projection лемма Джонсона-Линденштрауса Supervised LDA PLS Нелинейные Спектральные MDS Isomap LLE Laplacian Eigenmaps Kernel PCA Соседские t-SNE UMAP PaCMAP Обучаемые автокодировщики VAE Матричные разложения NMF ICA Sparse PCA
Проклятие размерности: что именно ломается
Фраза «проклятие размерности» звучит как заклинание, поэтому разберём её на три конкретных, измеримых эффекта.
1. Объём уходит в углы. Отношение объёма вписанного шара к объёму куба [-1,1]^D равно
V_ball/V_cube = π^(D/2) / (2^D · Γ(D/2 + 1)). При D=2 это 0.785, при D=10 — 0.0025, при
D=50 — 10^-28. Практическое следствие: равномерно распределённые точки в высокой размерности
почти все лежат вблизи границы, а «центр» пуст. Локальные методы (kNN, ядерные оценки
плотности, деревья) теряют смысл понятия «окрестность»: чтобы захватить долю p объёма,
кубическая окрестность должна иметь ребро p^(1/D) — для p = 1 % и D = 10 это 0.63, то есть
63 % диапазона каждого признака. Никакая это уже не «локальность».
2. Расстояния концентрируются. Для широкого класса распределений
(max_i ‖x_i − q‖ − min_i ‖x_i − q‖) / min_i ‖x_i − q‖ → 0 при D → ∞
(Beyer et al., 1999, «When Is “Nearest Neighbor” Meaningful?»,
https://link.springer.com/chapter/10.1007/3-540-49257-7_15). Все точки становятся
одинаково далеки, контраст между «ближайшим» и «дальним» соседом исчезает, а метрика
перестаёт нести информацию.
3. Статистическая ёмкость. Число параметров ковариационной матрицы растёт как D(D+1)/2.
При D = 1000 это полмиллиона параметров — на выборке в 10 тысяч объектов оценка вырожденная,
матрица необратима, а любая модель, опирающаяся на неё (LDA, QDA, гауссовы смеси, метрика
Махаланобиса), разваливается.
import numpy as np
rng = np.random.default_rng(0)
def contrast(D, n=2000):
"""Относительный контраст расстояний: (max - min) / min."""
X = rng.normal(size=(n, D))
q = rng.normal(size=D)
d = np.linalg.norm(X - q, axis=1)
return (d.max() - d.min()) / d.min()
for D in (2, 10, 50, 200, 1000):
print(f"D={D:5d} контраст = {contrast(D):.3f}")
# D= 2 контраст = 33.674
# D= 10 контраст = 4.517
# D= 50 контраст = 1.316
# D= 200 контраст = 0.601
# D= 1000 контраст = 0.259
Спасает от всего этого гипотеза многообразия (manifold hypothesis): реальные данные
никогда не заполняют R^D равномерно. Фотографии лиц 256×256 живут в R^65536, но
множество «правдоподобных лиц» имеет внутреннюю размерность порядка десятков. Данные лежат
на (или около) низкоразмерном многообразии, и вся задача — найти его координаты.
Картинка выше объясняет и границу применимости PCA: линейная проекция ленту не разворачивает, она может её только сплющить, слепив далёкие витки. Для развёртки нужны нелинейные методы.
Историческая линия
PCA: три эквивалентных определения
Метод главных компонент можно ввести тремя способами, и полезно держать в голове все три — они подсвечивают разные стороны.
Определение 1 (максимум дисперсии). Найти единичный вектор w, такой что дисперсия
проекций w^T x максимальна. Затем следующий, ортогональный первому, и так далее.
Определение 2 (минимум ошибки реконструкции). Найти k-мерное аффинное подпространство,
сумма квадратов расстояний до которого минимальна. Это исходная постановка Пирсона 1901 года.
Определение 3 (лучшее низкоранговое приближение). Найти матрицу ранга k, ближайшую к
центрированной матрице данных в норме Фробениуса.
Эти три постановки дают один и тот же ответ, и эквивалентность 1↔2 — прямая теорема Пифагора:
для центрированных данных ‖x‖² = ‖proj_w x‖² + ‖x − proj_w x‖². Сумма левых частей —
константа, не зависящая от w. Значит, максимизировать дисперсию проекций и минимизировать
остатки — буквально одно и то же.
Вывод через множители Лагранжа
Пусть X ∈ R^(n×D) центрирована (среднее по столбцам = 0), ковариация
S = (1/(n−1)) X^T X. Дисперсия проекции на w:
Var(Xw) = (1/(n−1)) w^T X^T X w = w^T S w
Задача: max w^T S w при w^T w = 1. Лагранжиан L = w^T S w − λ(w^T w − 1), производная
∂L/∂w = 2Sw − 2λw = 0, откуда
S w = λ w
То есть главные компоненты — собственные векторы ковариационной матрицы, а дисперсия
вдоль компоненты равна соответствующему собственному числу: w^T S w = λ. Максимум
достигается на векторе с наибольшим λ. Поскольку S симметрична и положительно
полуопределена, её собственные векторы ортогональны, а собственные числа неотрицательны —
компоненты автоматически образуют ортонормированный базис, отсортированный по «важности».
Связь с SVD
На практике ковариацию не строят. Пусть X = U Σ V^T — сингулярное разложение центрированной
матрицы. Тогда
X^T X = V Σ^T U^T U Σ V^T = V Σ² V^T
Правые сингулярные векторы V — это в точности собственные векторы X^T X, а
λ_i = σ_i² / (n − 1). Проекции (scores) считаются как Z = X V_k = U_k Σ_k.
Почему SVD, а не собственное разложение S? Численная устойчивость. Число обусловленности
X^T X равно квадрату числа обусловленности X. Если X слегка плохо обусловлена, явное
построение ковариации теряет вдвое больше значащих цифр. Плюс при D >> n матрица S
размера D×D может просто не поместиться в память, а SVD ранга k — поместится.
Реализация с нуля и через sklearn
import numpy as np
class PCAScratch:
"""PCA через SVD. Минимальная, но честная реализация."""
def __init__(self, n_components: int, whiten: bool = False):
self.k = n_components
self.whiten = whiten
def fit(self, X: np.ndarray) -> "PCAScratch":
X = np.asarray(X, dtype=np.float64)
self.mean_ = X.mean(axis=0) # центрирование ОБЯЗАТЕЛЬНО
Xc = X - self.mean_
# full_matrices=False: экономный SVD, O(n·D·min(n,D))
U, S, Vt = np.linalg.svd(Xc, full_matrices=False)
# знаковая нормализация: делает результат детерминированным
max_abs = np.argmax(np.abs(U), axis=0)
signs = np.sign(U[max_abs, range(U.shape[1])])
U, Vt = U * signs, Vt * signs[:, None]
n = X.shape[0]
self.components_ = Vt[: self.k] # (k, D)
self.singular_values_ = S[: self.k]
self.explained_variance_ = (S**2 / (n - 1))[: self.k]
total = (S**2 / (n - 1)).sum()
self.explained_variance_ratio_ = self.explained_variance_ / total
self.noise_variance_ = (S[self.k:] ** 2 / (n - 1)).sum() # отброшенное
return self
def transform(self, X: np.ndarray) -> np.ndarray:
Z = (X - self.mean_) @ self.components_.T
if self.whiten:
Z /= np.sqrt(self.explained_variance_) # единичная дисперсия
return Z
def inverse_transform(self, Z: np.ndarray) -> np.ndarray:
if self.whiten:
Z = Z * np.sqrt(self.explained_variance_)
return Z @ self.components_ + self.mean_
rng = np.random.default_rng(42)
# 3 «истинных» фактора, зашумлённые до 40 наблюдаемых признаков
F = rng.normal(size=(500, 3))
W = rng.normal(size=(3, 40))
X = F @ W + 0.35 * rng.normal(size=(500, 40))
p = PCAScratch(n_components=5).fit(X)
print(np.round(p.explained_variance_ratio_, 4))
# [0.5427 0.2708 0.1697 0.0009 0.0009] — «локоть» ровно после третьей компоненты
Xr = p.inverse_transform(p.transform(X))
err = ((X - Xr) ** 2).sum()
print(f"ошибка реконструкции = {err:.1f}")
Ошибка реконструкции не случайна: по теореме Эккарта — Янга — Мирского усечённое SVD даёт
оптимальное приближение ранга k, и остаточная ошибка равна сумме квадратов отброшенных
сингулярных чисел:
min_{rank(B) ≤ k} ‖X − B‖_F² = Σ_{i>k} σ_i²
Это редкий случай, когда жадный алгоритм (брать компоненты по убыванию) даёт глобальный оптимум невыпуклой задачи. Именно поэтому PCA так устойчив и не требует настройки гиперпараметров.
Сложность
| Метод | Время | Память | Когда применять |
|---|---|---|---|
| Ковариация + eigh | O(nD² + D³) |
O(D²) |
D мало (< 500), n огромно |
| Полный SVD | O(nD·min(n,D)) |
O(nD) |
средние матрицы, нужен весь спектр |
| Randomized SVD | O(nDk) |
O(nk) |
k << D, большие плотные/разреженные матрицы |
| Lanczos / ARPACK | O(k · nnz(X)) |
O(nk) |
сильно разреженные матрицы |
| Incremental PCA | O(nDk), батчами |
O(bD + Dk) |
данные не влезают в память |
sklearn.decomposition.PCA сам выбирает svd_solver: full для малых матриц, randomized
(алгоритм Halko–Martinsson–Tropp, https://arxiv.org/abs/0909.4061) когда
k < 0.8·min(n, D) и матрица большая. Рандомизированный SVD — красивая идея: умножаем X на
случайную матрицу Ω ∈ R^(D×(k+p)), получаем эскиз Y = XΩ, ортогонализуем его (QR),
и SVD маленькой матрицы Q^T X даёт приближение с гарантией по вероятности. p — oversampling
(обычно 10), плюс несколько степенных итераций (XX^T)^q X для быстро убывающего спектра.
Как выбирать k
вопрос закрыт] B -->|Сжатие для хранения| D[Порог по объяснённой дисперсии
95% / 99%] B -->|Признаки для модели| E[k — гиперпараметр
подбирать по CV целевой метрики] B -->|Оценка внутренней размерности| F[Более строгие критерии] F --> G[Scree plot: локоть] F --> H[Правило Кайзера lambda > 1
только для корреляционной матрицы] F --> I[Parallel analysis Хорна:
сравнить спектр со спектром
перемешанных данных] F --> J[Minka MLE:
байесовский выбор размерности] E --> K[Кросс-валидация
внутри Pipeline] D --> L[np.cumsum ratio >= 0.95] style C fill:#2f7a4f,color:#fff style K fill:#4f8ef7,color:#fff
Разберём варианты честно.
Порог по дисперсии (95 %) — самый распространённый и самый бездумный. Он опирается на предположение, что «дисперсия = информация», а это неверно, если целевой сигнал живёт в направлении с малой дисперсией. Классический контрпример: признак «сумма транзакции» имеет огромную дисперсию и не связан с фродом, а признак «отклонение времени от обычного паттерна» имеет крошечную дисперсию и является главным предиктором. PCA выкинет второй.
Parallel analysis (Horn, 1965) — статистически обоснованный вариант: перемешайте каждый
столбец независимо (разрушив корреляции), посчитайте спектр, повторите 100 раз и берите
столько компонент, у скольких λ выше 95-го перцентиля «шумового» спектра.
def horn_parallel_analysis(X, n_iter=100, q=95, rng=np.random.default_rng(0)):
"""Сколько компонент объясняют больше, чем чистый шум с той же маргинальной формой."""
Xc = X - X.mean(0)
real = np.linalg.svd(Xc, compute_uv=False) ** 2 / (len(X) - 1)
null = np.empty((n_iter, len(real)))
for i in range(n_iter):
Xs = np.column_stack([rng.permutation(col) for col in Xc.T])
null[i] = np.linalg.svd(Xs - Xs.mean(0), compute_uv=False) ** 2 / (len(X) - 1)
threshold = np.percentile(null, q, axis=0)
return int((real > threshold).sum())
print(horn_parallel_analysis(X)) # 3 — угадали истинное число факторов
Minka MLE (PCA(n_components='mle')) — байесовская модель, максимизирующая маргинальное
правдоподобие вероятностного PCA; работает при n > D (https://proceedings.neurips.cc/paper/2000/hash/7503cfacd12053d309b6bed5c89de212-Abstract.html).
Правило Кайзера (λ > 1) применимо только к корреляционной матрице (стандартизованные признаки) и известно тем, что систематически завышает число компонент. Использовать с осторожностью.
Центрирование, масштабирование, whitening
Три технические детали, каждая из которых регулярно ломает результат.
Центрирование обязательно. Без него первая «главная компонента» будет указывать примерно
на среднее, а не на направление максимальной изменчивости. sklearn.PCA центрирует всегда,
TruncatedSVD — никогда (в этом их главное отличие).
Масштабирование меняет ответ. PCA не инвариантен к масштабу: признак в миллиметрах даст
дисперсию в миллион раз больше того же признака в метрах и утащит первую компоненту на себя.
Правило: если признаки в разных единицах — сначала StandardScaler (это эквивалентно PCA на
корреляционной матрице). Если все признаки в одних единицах и разброс сам по себе осмыслен
(пиксели, спектры, координаты) — можно без стандартизации. Подробности —
https://courses.digitable.life/post/machine-learning/02-data-and-features/.
Whitening делит каждую компоненту на sqrt(λ), получая некоррелированные признаки с
единичной дисперсией. Полезно перед методами, чувствительными к масштабу (kNN, SVM, k-means —
см. https://courses.digitable.life/post/machine-learning/05-svm/ и https://courses.digitable.life/post/machine-learning/08-clustering/), но усиливает шумовые
компоненты: направление с λ = 10^-6 после whitening станет равноправным с главным. Поэтому
whitening имеет смысл только вместе с агрессивным усечением.
Truncated SVD, LSA и разреженные данные
Для мешка слов / TF-IDF центрирование недопустимо: оно превращает разреженную матрицу (0.1 % ненулей) в плотную и мгновенно съедает память. Поэтому используют truncated SVD без центрирования — исторически это Latent Semantic Analysis (Deerwester et al., 1990).
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import Normalizer
docs = ["кот сидит на окне", "собака бежит по улице",
"кошка спит на подоконнике", "пёс гуляет во дворе"]
lsa = make_pipeline(
TfidfVectorizer(min_df=1),
TruncatedSVD(n_components=2, random_state=0),
Normalizer(copy=False), # после SVD нормализуем — иначе косинус искажён
)
Z = lsa.fit_transform(docs)
print(Z.shape) # (4, 2) — документы в «семантическом» пространстве
Практические числа: для типичного корпуса берут k от 100 до 300. Матрица
50 000 документов × 100 000 терминов при 0.2 % плотности занимает ~ 800 МБ в разреженном
виде и сводится к 50 000 × 200 плотной (80 МБ) за минуты через ARPACK.
Та же математика лежит в основе матричной факторизации в рекомендательных системах — там
X это матрица «пользователь × объект», и усечённое разложение даёт латентные факторы вкусов
(см. https://courses.digitable.life/post/machine-learning/12-recommender-systems/).
Случайные проекции: когда «просто умножить на шум» работает
Удивительный факт: если умножить данные на случайную матрицу с гауссовыми элементами,
попарные расстояния почти сохранятся. Это лемма Джонсона — Линденштрауса: для любых n
точек и ε ∈ (0, 1) существует отображение в k = O(log n / ε²) измерений, такое что
(1 − ε)‖u − v‖² ≤ ‖f(u) − f(v)‖² ≤ (1 + ε)‖u − v‖²
Ключевое: k не зависит от исходной размерности D, только от числа точек и точности.
from sklearn.random_projection import johnson_lindenstrauss_min_dim, SparseRandomProjection
print(johnson_lindenstrauss_min_dim(n_samples=1_000_000, eps=0.1)) # 11 841
print(johnson_lindenstrauss_min_dim(n_samples=1_000_000, eps=0.25)) # 2 122
srp = SparseRandomProjection(eps=0.25, random_state=0)
Z = srp.fit_transform(X) # fit не смотрит на данные вообще, только на форму
Когда это лучше PCA: (1) данных настолько много, что даже randomized SVD дорог; (2) поток —
матрицу проекции можно сгенерировать заранее и применять к каждому объекту независимо, без
знания выборки; (3) нужна теоретическая гарантия на искажение расстояний, а не на дисперсию.
Минус — k обычно нужно на порядок больше, чем у PCA, и компоненты не интерпретируемы.
Kernel PCA и спектральные методы
PCA линеен. Первый способ сделать его нелинейным — тот же трюк, что в SVM: заменить скалярные
произведения ядром. Kernel PCA работает с центрированной матрицей Грама K размера n×n и
берёт её собственные векторы:
K̃ = K − 1_n K − K 1_n + 1_n K 1_n
Плата — O(n²) память и O(n³) время, поэтому Kernel PCA практически неприменим при
n > 10 000 без аппроксимаций (Nyström, Random Fourier Features). Плюс проблема
pre-image: обратное преобразование из спрямляющего пространства в исходное решается только
приближённо.
Дальше идёт семейство спектральных методов, объединённых общей схемой: построить матрицу попарных отношений → взять её собственные векторы.
| Метод | Что сохраняет | Матрица | Сложность |
|---|---|---|---|
| Классический MDS | все попарные расстояния (эквивалентен PCA для евклидовых) | двойно-центрированная D² |
O(n³) |
| Isomap | геодезические расстояния по графу соседей | кратчайшие пути + MDS | O(n² log n) |
| LLE | локальные линейные веса реконструкции | разреженная (I−W)^T(I−W) |
O(n k³ + n²) |
| Laplacian Eigenmaps | локальное соседство через лапласиан графа | L = D − W |
O(n²) |
| Diffusion Maps | диффузионное расстояние (устойчиво к шуму) | степени марковской матрицы | O(n²) |
Isomap — прямая реализация идеи с картинки про ленту: он не измеряет евклидово расстояние «через слой», а считает длину пути по графу k-ближайших соседей. Слабое место — топологическая хрупкость: одно «короткое замыкание» (шумная точка, соединившая два витка) портит все геодезические сразу.
Эти методы важны исторически и концептуально, но в 2026 году в прикладной работе их почти вытеснили t-SNE и UMAP — они масштабируются лучше и дают более читаемые картинки.
t-SNE: как устроено и почему оно так выглядит
t-SNE (van der Maaten & Hinton, 2008, https://jmlr.org/papers/v9/vandermaaten08a.html) решает узкую задачу: сохранить соседство, а не расстояния.
Шаг 1. Соседство в исходном пространстве как распределение. Для каждой точки i задаём
условные вероятности «выбрать j соседом»:
p_{j|i} = exp(−‖x_i − x_j‖² / 2σ_i²) / Σ_{k≠i} exp(−‖x_i − x_k‖² / 2σ_i²)
Ширина σ_i подбирается отдельно для каждой точки бинарным поиском так, чтобы
перплексия 2^H(P_i) равнялась заданной (обычно 5–50). Это адаптация к локальной плотности:
в плотном кластере σ маленькая, в разреженной области — большая. Перплексию можно читать
как «эффективное число соседей». Затем симметризация: p_ij = (p_{j|i} + p_{i|j}) / (2n).
Шаг 2. Соседство в 2D — распределение Стьюдента с одной степенью свободы:
q_ij = (1 + ‖y_i − y_j‖²)^(−1) / Σ_{k≠l} (1 + ‖y_k − y_l‖²)^(−1)
Почему не гаусс? Из-за crowding problem. Объём шара радиуса r растёт как r^D. В
10-мерии у точки может быть 500 «умеренно близких» соседей на примерно одном расстоянии; в
2D физически негде разместить 500 точек на равном расстоянии от центра. Если использовать
гаусс в обоих пространствах, умеренно далёкие точки будут выдавлены в одну кучу.
Тяжёлый хвост t-распределения даёт умеренно далёким парам «право» находиться в 2D сильно
дальше, освобождая место и раздвигая кластеры.
Шаг 3. Минимизация KL-дивергенции KL(P ‖ Q) = Σ p_ij log(p_ij / q_ij) градиентным спуском.
Асимметрия KL здесь — не деталь, а определяющее свойство: большой p при малом q (близкие
точки разнесли) штрафуется сильно, а малый p при большом q (далёкие точки сблизили)
штрафуется слабо. Отсюда прямое следствие: t-SNE достоверен локально и недостоверен глобально.
Сложность. Наивно O(n²) на итерацию. Barnes-Hut t-SNE группирует далёкие точки в
квадродереве и считает их влияние приближённо — O(n log n), что делает практичным n до
сотен тысяч. FIt-SNE (интерполяция на сетке) даёт почти O(n) и справляется с миллионами
(https://github.com/KlugerLab/FIt-SNE).
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler
X_std = StandardScaler().fit_transform(X)
# ВАЖНО: сначала PCA до ~50 — быстрее и заметно чище (давит шумовые измерения)
X_pca = PCA(n_components=min(50, X_std.shape[1]), random_state=0).fit_transform(X_std)
emb = TSNE(
n_components=2,
perplexity=30, # ~ эффективное число соседей; < n/3
early_exaggeration=12,
learning_rate="auto", # = n / early_exaggeration; НЕ оставляйте старое 200
init="pca", # ключ к воспроизводимости и глобальной структуре
max_iter=1000,
random_state=0,
).fit_transform(X_pca)
Два параметра, которые чаще всего задают неправильно:
init="pca"вместо дефолтного случайного (в новых версиях sklearn это уже дефолт). Инициализация из PCA сохраняет грубую глобальную структуру и делает результат воспроизводимым — это прямая рекомендация из Kobak & Linderman, Nature Biotechnology 2021 (https://www.nature.com/articles/s41587-020-00809-z).learning_rate="auto"— старое значение 200 приn = 100 000приводит к «шару» без структуры.
Как читать t-SNE (и как не надо)
Обязательное чтение — интерактивная статья distill.pub «How to Use t-SNE Effectively» (https://distill.pub/2016/misread-tsne/). Кратко, что на картинке не значит ничего:
- Размеры кластеров. t-SNE выравнивает плотность: плотный кластер из 1000 точек и рыхлый из 50 могут занять одинаковую площадь. Про «этот кластер шире, значит разнообразнее» — неверно.
- Расстояния между кластерами. Промежутки между «островами» не пропорциональны различию. Два соседних пятна могут быть похожи меньше, чем два далёких.
- Оси. У них нет никакого смысла, вращение и отражение произвольны.
- Случайный шум даёт «кластеры». При малой перплексии на чистом гауссовом шуме t-SNE нарисует убедительные комки. Всегда проверяйте на нескольких перплексиях (5, 30, 100) и нескольких seed’ах: устойчивое — реально, мигающее — артефакт.
И главное ограничение инженерного плана: у t-SNE нет метода transform. Вложение —
результат оптимизации координат конкретного набора точек. Новую точку нельзя «спроецировать»,
не пересчитав всё. Поэтому t-SNE — инструмент анализа, а не слой пайплайна.
UMAP: быстрее, глобальнее, с transform
UMAP (McInnes, Healy, Melville, 2018, https://arxiv.org/abs/1802.03426) выведен из теории нечётких симплициальных множеств и римановой геометрии, но операционно похож на t-SNE с несколькими важными отличиями.
1. Граф вместо полной матрицы. UMAP строит приближённый граф k-ближайших соседей
(алгоритм NN-Descent, pynndescent) — O(n^1.14) эмпирически, вместо O(n²). Веса рёбер:
w(i, j) = exp(−(d(i, j) − ρ_i) / σ_i)
где ρ_i — расстояние до ближайшего соседа. Вычитание ρ_i — это «локальная связность»:
гарантируется, что у каждой точки есть хотя бы одно ребро с весом 1, и в разреженных областях
точки не остаются изолированными. Симметризация — нечёткое объединение
w = w_ij + w_ji − w_ij·w_ji.
2. Кросс-энтропия вместо KL. Функция потерь UMAP штрафует обе ошибки: и разведённых близких, и сближенных далёких. Второе слагаемое (отталкивание) — причина того, что UMAP лучше сохраняет взаимное расположение кластеров.
3. Negative sampling + SGD. Вместо вычисления всех отталкиваний на каждом шаге UMAP берёт 5 случайных «отрицательных» примеров на ребро — тот же приём, что в word2vec. Это даёт линейную сложность по числу рёбер.
4. Есть transform. Новую точку можно вложить: найти её соседей в обученном графе и
оптимизировать только её координаты. Не бесплатно и не идеально (качество падает при дрейфе
распределения), но работает.
import umap # pip install umap-learn
reducer = umap.UMAP(
n_neighbors=15, # баланс локальное/глобальное: 5 — микроструктура, 100 — общая форма
min_dist=0.1, # минимальное расстояние в 2D: 0.0 — плотные комки, 0.5 — размазано
metric="cosine", # для эмбеддингов — почти всегда cosine
n_components=2,
random_state=42, # ВНИМАНИЕ: включает однопоточный режим, сильно медленнее
)
emb_train = reducer.fit_transform(X_train)
emb_new = reducer.transform(X_new) # чего t-SNE не умеет в принципе
Смысл двух главных гиперпараметров стоит держать в голове точно:
n_neighborsуправляет тем, что считается локальным. Маленькое значение — UMAP видит только ближайшее окружение и может разорвать связный кластер на части. Большое — усредняет, теряет тонкую структуру, но лучше передаёт глобальную форму.min_dist— чисто эстетический параметр раскладки, влияет только на 2D-представление, а не на топологию. Для кластеризации поверх UMAP ставьтеmin_dist=0.0.
Сравнение методов
Сводно, по признакам, которые реально влияют на выбор:
| PCA | Truncated SVD | t-SNE | UMAP | Автокодировщик | |
|---|---|---|---|---|---|
| Линеен | да | да | нет | нет | нет |
transform на новых |
да | да | нет | да | да |
| Обратим | да (точно) | да | нет | нет | да (декодер) |
| Детерминирован | да | да | нет | нет (без seed) | нет |
| Сложность | O(nDk) |
O(k·nnz) |
O(n log n) |
O(n^1.14) |
O(эпохи·n) |
| Разреженный вход | плохо | да | нет | да | да |
| Гиперпараметры | k |
k |
perplexity, lr | n_neighbors, min_dist | архитектура |
| Годится как фичи | да | да | нет | с осторожностью | да |
Правильный пайплайн: снижение размерности внутри кросс-валидации
Самая дорогая ошибка в теме — обучить PCA на всей выборке, а потом делать split. Это утечка:
компоненты «видели» валидацию, и оценка качества оптимистична. Правило то же, что для любого
fit-преобразования (см. https://courses.digitable.life/post/machine-learning/02-data-and-features/ и
https://courses.digitable.life/post/machine-learning/10-model-evaluation/): всё, что учится, учится только на train-фолде.
гиперпараметрами модели —
это часть модели, не препроцессинг
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.datasets import load_digits
Xd, yd = load_digits(return_X_y=True)
pipe = Pipeline([
("scale", StandardScaler()),
("pca", PCA(random_state=0)),
("clf", LogisticRegression(max_iter=2000)),
])
grid = GridSearchCV(
pipe,
param_grid={"pca__n_components": [10, 20, 30, 40, 64], "clf__C": [0.1, 1.0, 10.0]},
cv=StratifiedKFold(5, shuffle=True, random_state=0),
scoring="accuracy",
n_jobs=-1,
)
grid.fit(Xd, yd)
print(grid.best_params_, round(grid.best_score_, 4))
# {'clf__C': 10.0, 'pca__n_components': 40} 0.9722
Обратите внимание: оптимальное k = 40 из 64, а не «сколько нужно для 95 % дисперсии»
(это было бы около 28). Критерий дисперсии и критерий качества модели — разные критерии, и
второй важнее.
Полезный приём — memory= в Pipeline: результат fit PCA кешируется между комбинациями
гиперпараметров классификатора, что экономит время на больших сетках.
Когда PCA вредит
Список ситуаций, где снижение размерности активно ухудшает результат:
- Сигнал в направлении малой дисперсии. Разобрано выше на примере фрода. Проверка простая: сравните качество модели на сырых признаках и на компонентах. Если PCA хуже — сигнал был в хвосте.
- Деревья и бустинг. Деревья делают осевые разрезы (см. https://courses.digitable.life/post/machine-learning/06-decision-trees/), и поворот пространства ломает интерпретируемость и обычно снижает качество: линейная комбинация 200 признаков разрезается по одному порогу хуже, чем исходный осмысленный признак. Для https://courses.digitable.life/post/machine-learning/07-ensembles-and-boosting/ PCA почти всегда лишний.
- Категориальные признаки после one-hot. PCA предполагает непрерывность и евклидову геометрию; на бинарных индикаторах компоненты трудноинтерпретируемы. Лучше целевое кодирование или эмбеддинги, либо специализированный MCA.
- Требуется интерпретируемость. «Компонента 3 = 0.21·возраст − 0.44·доход + …» — это не объяснение для регулятора. Альтернативы: Sparse PCA (компоненты с нулевыми нагрузками), NMF (только неотрицательные вклады, «части объекта»), отбор признаков вместо проекции.
- Выбросы. PCA минимизирует квадрат ошибки, то есть максимально чувствителен к выбросам:
одна точка с
z = 50утащит первую компоненту на себя. Лечится робастными вариантами (RobustScalerдо PCA, Robust PCA через разложение на низкоранговую и разреженную части). - Данных мало. При
n < DPCA не может дать большеn − 1компонент, а оценки собственных направлений сильно смещены (эффект Марченко — Пастура: даже у чистого шума спектр выборочной ковариации широкий и выглядит «структурно»).
Отдельно про supervised-альтернативы: если y известен, LDA ищет проекцию, максимизирующую
отношение межклассового разброса к внутриклассовому (даёт максимум C−1 компонент для C
классов), а PLS — направления с максимальной ковариацией с целью. Часто это лучше PCA как
препроцессинг под классификацию — но их обязательно надо фитить внутри фолда, иначе утечка
целевой переменной особенно ядовита.
Продакшн: где это реально используется
Векторный поиск и RAG. Основной потребитель снижения размерности в 2026 году. Индекс на 100 млн эмбеддингов по 1536 float32 — это 600 ГБ RAM. Стандартный стек: PCA/OPQ до 256–384 измерений, затем product quantization до 64–96 байт на вектор — итого около 8 ГБ, то есть влезает в одну машину. Потеря recall@10 обычно 1–3 % при 50-кратной экономии. В FAISS это делается прямо в строке фабрики индекса:
import faiss
import numpy as np
d, k = 1536, 256
xb = np.random.rand(200_000, d).astype("float32")
# PCA-поворот с усечением + IVF + product quantization
index = faiss.index_factory(d, f"PCAR{k},IVF4096,PQ64", faiss.METRIC_INNER_PRODUCT)
index.train(xb) # обучает PCA-матрицу, центроиды IVF и кодовые книги PQ
index.add(xb)
D, I = index.search(xb[:5], 10)
PCAR — PCA с последующим случайным вращением: оно выравнивает дисперсию между
подпространствами, что критично для PQ (иначе первые подвекторы, где вся дисперсия,
квантуются грубо, а последние — впустую точно).
Matryoshka embeddings. Современная альтернатива: модель обучается так, чтобы первые
k координат сами по себе были валидным эмбеддингом (Kusupati et al., 2022,
https://arxiv.org/abs/2205.13147). Тогда усечение — это просто срез массива, без всякого
PCA и без обучения проекции. Так работают text-embedding-3 от OpenAI и ряд открытых моделей.
Если ваша модель это поддерживает, PCA над эмбеддингами не нужен.
Мониторинг дрейфа. PCA обученный на «нормальном» периоде даёт дешёвый детектор аномалий:
считаем ошибку реконструкции ‖x − x̂‖²; рост среднего значения по трафику = распределение
уехало. Это стандартный приём в https://courses.digitable.life/post/machine-learning/15-mlops/.
Сжатие фичей в feature store. Сотни телеметрических сигналов сворачиваются в 20–30
компонент, которые дешевле хранить и передавать. Важно версионировать матрицу components_
вместе с моделью: пересчитали PCA на новых данных — получили другой базис, старая модель на
нём неприменима (компоненты могут даже поменять знак).
Потоковые данные. IncrementalPCA обучается батчами и не требует держать выборку в памяти:
from sklearn.decomposition import IncrementalPCA
ipca = IncrementalPCA(n_components=50, batch_size=1024)
for batch in stream_batches("s3://bucket/features/*.parquet"): # псевдоисточник
ipca.partial_fit(batch)
print(ipca.explained_variance_ratio_.sum())
Единичная клетка / биоинформатика. Классический пайплайн: нормализация → PCA до 50 → граф соседей → Leiden-кластеризация → UMAP только для картинки. Обратите внимание на порядок: кластеризация делается в PCA-пространстве, а не в UMAP-координатах. Кластеризовать 2D-вложение — методологическая ошибка, потому что вложение уже исказило плотности.
Критика 2D-вложений: что важно знать
В 2023 году вышла работа Chari & Pachter «The Specious Art of Single-Cell Genomics» (https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1011288), где показано, что t-SNE и UMAP могут давать произвольно низкую достоверность сохранения структуры: авторы построили вложения, визуально убедительные, но искажающие данные сильнее, чем случайная проекция в 2D. Основной тезис: двумерная картинка физически не может вместить структуру высокоразмерных данных, и «красивые острова» часто являются артефактом алгоритма, а не свойством данных.
Практические выводы, которые из этого стоит сделать:
- Вложение — это гипотеза, а не результат. Всё, что вы «увидели», проверяйте количественно: тестом дифференциальной экспрессии, метрикой качества кластеризации в исходном пространстве, силуэтом на PCA-координатах.
- Считайте метрики достоверности:
trustworthiness(sklearn) — доля соседей во вложении, которые были соседями в исходном пространстве; continuity — обратная величина. - Показывайте несколько параметризаций рядом. Устойчивая структура переживает изменение perplexity/n_neighbors, артефакт — нет.
- Для оценки глобальной структуры используйте PCA-картинку рядом с UMAP.
from sklearn.manifold import trustworthiness
for k in (5, 15, 30):
t_pca = trustworthiness(X_std, PCA(2, random_state=0).fit_transform(X_std), n_neighbors=k)
t_tsne = trustworthiness(X_std, emb, n_neighbors=k)
print(f"k={k:2d} PCA={t_pca:.3f} t-SNE={t_tsne:.3f}")
Типичные ошибки
fitна всей выборке до split — утечка, завышенная оценка качества. ТолькоPipeline.- PCA без стандартизации на разномасштабных признаках — первая компонента = признак с самой большой единицей измерения.
TruncatedSVDтам, где нуженPCA(плотные данные, центрирование нужно) — и наоборот,PCAна разреженной матрице:sklearnвыброситTypeError, а если сделать.toarray(), упадёте по памяти.- Кластеризация по t-SNE/UMAP координатам. Кластеризуйте в PCA-пространстве, вложение используйте для иллюстрации.
- Подача t-SNE-координат в модель как признаков. Нет
transform— нет продакшена. - Вывод про размеры и расстояния кластеров на t-SNE. Их там нет.
- Один seed, одна перплексия. Всегда несколько прогонов.
learning_rate=200по умолчанию на большихn— получите однородный шар.- Запуск t-SNE прямо на 20 000 признаках без предварительного PCA до 50 — медленно и шумно.
- Whitening с большим
k— усиление шумовых компонент, деградация метрических методов. - Забыли зафиксировать знак компонент.
components_определены с точностью до знака; без нормализации знака результаты между запусками/версиями отличаются, и сохранённая модель на новом PCA не работает. - PCA перед бустингом «на всякий случай» — почти всегда потеря качества и интерпретируемости.
- Выбор
kтолько по 95 % дисперсии, когдаk— гиперпараметр модели и должен подбираться по целевой метрике. - Игнорирование выбросов. Один экстремальный объект переопределяет главную ось.
Мини-итог
- Проклятие размерности — это три конкретных эффекта: пустой центр, концентрация расстояний, нехватка данных для оценки параметров. Гипотеза многообразия — причина, по которой снижение размерности вообще работает.
- PCA = максимум дисперсии = минимум реконструкции = лучшее низкоранговое приближение. Считается
через SVD, оптимален по Эккарту — Янгу, детерминирован, обратим, имеет
transform. - Центрирование обязательно, масштабирование меняет ответ, whitening — только с агрессивным усечением.
kвыбирается по целевой метрике внутри CV; пороги дисперсии — грубая эвристика, parallel analysis и Minka MLE — статистически обоснованные варианты.- Truncated SVD — для разреженных текстовых матриц; случайные проекции — когда важна гарантия на расстояния и скорость, а не интерпретируемость.
- t-SNE и UMAP — инструменты анализа, а не признаки для модели. t-SNE сохраняет только
локальную структуру и не умеет
transform; UMAP быстрее, лучше с глобальной структурой и умеет вкладывать новые точки. - На картинке t-SNE не значат ничего: размеры кластеров, расстояния между ними, оси.
- В проде основной кейс — сжатие эмбеддингов для векторных индексов (PCA + PQ) и детектор дрейфа по ошибке реконструкции.
Источники
- Bishop C. «Pattern Recognition and Machine Learning», гл. 12 — вероятностный PCA и Kernel PCA.
- Hastie, Tibshirani, Friedman «The Elements of Statistical Learning», гл. 14.5 — https://hastie.su.domains/ElemStatLearn/
- Jolliffe I., Cadima J. «Principal component analysis: a review and recent developments» — https://royalsocietypublishing.org/doi/10.1098/rsta.2015.0202
- van der Maaten L., Hinton G. «Visualizing Data using t-SNE» — https://jmlr.org/papers/v9/vandermaaten08a.html
- McInnes L. et al. «UMAP: Uniform Manifold Approximation and Projection» — https://arxiv.org/abs/1802.03426, документация: https://umap-learn.readthedocs.io/
- Wattenberg, Viégas, Johnson «How to Use t-SNE Effectively» — https://distill.pub/2016/misread-tsne/
- Coenen, Pearce «Understanding UMAP» — https://pair-code.github.io/understanding-umap/
- Kobak D., Berens P. «The art of using t-SNE for single-cell transcriptomics» — https://www.nature.com/articles/s41467-019-13056-x
- Halko, Martinsson, Tropp «Finding Structure with Randomness» — https://arxiv.org/abs/0909.4061
- Документация scikit-learn: https://scikit-learn.org/stable/modules/decomposition.html и https://scikit-learn.org/stable/modules/manifold.html
- FAISS wiki, «The index factory» — https://github.com/facebookresearch/faiss/wiki/The-index-factory
Что дальше
Мы много раз опирались на фразу «подбирать по целевой метрике внутри кросс-валидации» — пора разобрать её строго: какие бывают метрики, как устроены схемы валидации, почему accuracy врёт на несбалансированных данных и что такое калибровка вероятностей.