Машинное обучение Снижение размерности: PCA, SVD, t-SNE, UMAP
0%

Снижение размерности: PCA, SVD, t-SNE, UMAP

Снижение размерности: 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/ (масштабирование, утечки) — здесь и то и другое критично.

Проклятие размерности: что именно ломается

Фраза «проклятие размерности» звучит как заклинание, поэтому разберём её на три конкретных, измеримых эффекта.

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. Значит, максимизировать дисперсию проекций и минимизировать остатки — буквально одно и то же.

Геометрия PCA и scree plot

Вывод через множители Лагранжа

Пусть 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

Разберём варианты честно.

Порог по дисперсии (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 для евклидовых) двойно-центрированная 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 вредит

Список ситуаций, где снижение размерности активно ухудшает результат:

  1. Сигнал в направлении малой дисперсии. Разобрано выше на примере фрода. Проверка простая: сравните качество модели на сырых признаках и на компонентах. Если PCA хуже — сигнал был в хвосте.
  2. Деревья и бустинг. Деревья делают осевые разрезы (см. https://courses.digitable.life/post/machine-learning/06-decision-trees/), и поворот пространства ломает интерпретируемость и обычно снижает качество: линейная комбинация 200 признаков разрезается по одному порогу хуже, чем исходный осмысленный признак. Для https://courses.digitable.life/post/machine-learning/07-ensembles-and-boosting/ PCA почти всегда лишний.
  3. Категориальные признаки после one-hot. PCA предполагает непрерывность и евклидову геометрию; на бинарных индикаторах компоненты трудноинтерпретируемы. Лучше целевое кодирование или эмбеддинги, либо специализированный MCA.
  4. Требуется интерпретируемость. «Компонента 3 = 0.21·возраст − 0.44·доход + …» — это не объяснение для регулятора. Альтернативы: Sparse PCA (компоненты с нулевыми нагрузками), NMF (только неотрицательные вклады, «части объекта»), отбор признаков вместо проекции.
  5. Выбросы. PCA минимизирует квадрат ошибки, то есть максимально чувствителен к выбросам: одна точка с z = 50 утащит первую компоненту на себя. Лечится робастными вариантами (RobustScaler до PCA, Robust PCA через разложение на низкоранговую и разреженную части).
  6. Данных мало. При n < D PCA не может дать больше 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}")

Типичные ошибки

  1. fit на всей выборке до split — утечка, завышенная оценка качества. Только Pipeline.
  2. PCA без стандартизации на разномасштабных признаках — первая компонента = признак с самой большой единицей измерения.
  3. TruncatedSVD там, где нужен PCA (плотные данные, центрирование нужно) — и наоборот, PCA на разреженной матрице: sklearn выбросит TypeError, а если сделать .toarray(), упадёте по памяти.
  4. Кластеризация по t-SNE/UMAP координатам. Кластеризуйте в PCA-пространстве, вложение используйте для иллюстрации.
  5. Подача t-SNE-координат в модель как признаков. Нет transform — нет продакшена.
  6. Вывод про размеры и расстояния кластеров на t-SNE. Их там нет.
  7. Один seed, одна перплексия. Всегда несколько прогонов.
  8. learning_rate=200 по умолчанию на больших n — получите однородный шар.
  9. Запуск t-SNE прямо на 20 000 признаках без предварительного PCA до 50 — медленно и шумно.
  10. Whitening с большим k — усиление шумовых компонент, деградация метрических методов.
  11. Забыли зафиксировать знак компонент. components_ определены с точностью до знака; без нормализации знака результаты между запусками/версиями отличаются, и сохранённая модель на новом PCA не работает.
  12. PCA перед бустингом «на всякий случай» — почти всегда потеря качества и интерпретируемости.
  13. Выбор k только по 95 % дисперсии, когда k — гиперпараметр модели и должен подбираться по целевой метрике.
  14. Игнорирование выбросов. Один экстремальный объект переопределяет главную ось.

Мини-итог

  • Проклятие размерности — это три конкретных эффекта: пустой центр, концентрация расстояний, нехватка данных для оценки параметров. Гипотеза многообразия — причина, по которой снижение размерности вообще работает.
  • PCA = максимум дисперсии = минимум реконструкции = лучшее низкоранговое приближение. Считается через SVD, оптимален по Эккарту — Янгу, детерминирован, обратим, имеет transform.
  • Центрирование обязательно, масштабирование меняет ответ, whitening — только с агрессивным усечением.
  • k выбирается по целевой метрике внутри CV; пороги дисперсии — грубая эвристика, parallel analysis и Minka MLE — статистически обоснованные варианты.
  • Truncated SVD — для разреженных текстовых матриц; случайные проекции — когда важна гарантия на расстояния и скорость, а не интерпретируемость.
  • t-SNE и UMAP — инструменты анализа, а не признаки для модели. t-SNE сохраняет только локальную структуру и не умеет transform; UMAP быстрее, лучше с глобальной структурой и умеет вкладывать новые точки.
  • На картинке t-SNE не значат ничего: размеры кластеров, расстояния между ними, оси.
  • В проде основной кейс — сжатие эмбеддингов для векторных индексов (PCA + PQ) и детектор дрейфа по ошибке реконструкции.

Источники

Что дальше

Мы много раз опирались на фразу «подбирать по целевой метрике внутри кросс-валидации» — пора разобрать её строго: какие бывают метрики, как устроены схемы валидации, почему accuracy врёт на несбалансированных данных и что такое калибровка вероятностей.

Оценка моделей: метрики, кросс-валидация, калибровка

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов