{"id":14291,"url":"\/distributions\/14291\/click?bit=1&hash=257d5375fbb462be671b713a7a4184bd5d4f9c6ce46e0d204104db0e88eadadd","hash":"257d5375fbb462be671b713a7a4184bd5d4f9c6ce46e0d204104db0e88eadadd","title":"\u0420\u0435\u043a\u043b\u0430\u043c\u0430 \u043d\u0430 Ozon \u0434\u043b\u044f \u0442\u0435\u0445, \u043a\u0442\u043e \u043d\u0438\u0447\u0435\u0433\u043e \u0442\u0430\u043c \u043d\u0435 \u043f\u0440\u043e\u0434\u0430\u0451\u0442","buttonText":"","imageUuid":""}

t-SNE с нуля (ft. NumPy)

Я осознал, что один из лучших способов по-настоящему понять любой статистический алгоритм или методологию - это реализовать его самостоятельно вручную. С другой стороны, написание этих алгоритмов иногда может отнимать много времени и доставлять настоящую боль, и когда кто-то другой уже сделал это, зачем мне тратить на это свое время - кажется неэффективным, не так ли? И то, и другое справедливо, и я здесь не для того, чтобы приводить доводы в пользу одного, а не другого.

Эта статья предназначена для читателей, которые заинтересованы в понимании t-SNE посредством перевода математики из оригинальной статьи — Лоренса ван дер Маатена и Джеффри Хинтона — в реализацию кода на python.[1] Я нахожу, что такого рода упражнения достаточно проливают свет на внутреннюю работу статистических алгоритмов / моделей и действительно проверяют ваше базовое понимание относительно этих алгоритмов / моделей. Как минимум, успешная реализация всегда приносит большое удовлетворение!

Эта статья будет доступна лицам с любым уровнем взаимодействия с t-SNE. Однако, стоить заметить, что эта статья не будет каким-либо полноценным руководством:

  • Строго концептуальное введение и исследование t-SNE, поскольку существует множество других замечательных ресурсов, которые делают это; тем не менее, я сделаю всё возможное, чтобы связать математические уравнения с их интуитивными / концептуальными аналогами на каждом этапе реализации.
  • Всестороннее обсуждение применений и плюсов /минусов t-SNE, а также прямые сравнения t-SNE с другими методами уменьшения размерности. Я, однако, кратко коснусь этих тем на протяжении всей этой статьи, но ни в коем случае не буду подробно их освещать.

Без лишних слов давайте начнём с краткого введения в t-SNE.

Краткое введение в t-SNE

Стохастическое вложение соседей с t-распределением (t-SNE) - это инструмент уменьшения размерности, который в основном используется в наборах данных с пространством пространственных объектов большой размерности и позволяет визуализировать данные или спроецировать их в пространство меньшей размерности (обычно 2-D). Это особенно полезно для визуализации нелинейно разделяемых данных, в которых линейные методы, такие как анализ главных компонент (PCA), потерпели бы неудачу. Обобщение линейных структур уменьшения размерности (таких как PCA) на нелинейные подходы (такие как t-SNE) также известно как многообразное обучение. Эти методы могут быть чрезвычайно полезны для визуализации и понимания базовой структуры многомерного нелинейного набора данных, а также могут быть полезны для распутывания и группировки воедино наблюдений, которые похожи в многомерном пространстве. Для получения дополнительной информации о t-SNE и других разнообразных методах обучения документация scikit-learn является отличным ресурсом. Кроме того, чтобы прочитать о некоторых интересных областях применения t-SNE, на странице Википедии освещаются некоторые из этих областей со ссылками на работу.

Давайте начнём с разбиения названия стохастического вложение соседей с t-распределением на его компоненты. t-SNE - это расширение стохастического вложения соседей (SNE), представленное 6 годами ранее в этой статье Джеффри Хинтоном и Сэмом Роуэйсом. Итак, давайте начнём с этого. Стохастическая часть названия происходит от того факта, что целевая функция не является выпуклой и, следовательно, при разных инициализациях могут получаться разные результаты. Вложение соседей подчёркивает природу алгоритма — оптимальное отображение точек в исходном многомерном пространстве в соответствующее низкоразмерное пространство при наилучшем сохранении структуры точек ”окрестности". SNE состоит из следующих (упрощённых) шагов:

  • Получение матрицы подобия между точками в исходном пространстве: Вычислите условные вероятности для каждой точки данных j относительно каждой точки данных i. Эти условные вероятности вычисляются в исходном многомерном пространстве с использованием гауссова уравнения с центром в точке i и принимают следующую интерпретацию: вероятность того, что i выберет j в качестве своего соседа в исходном пространстве. Это создаёт матрицу, которая представляет сходства между точками.
  • Инициализация: Выберите случайные начальные точки в пространстве нижнего измерения (скажем, 2-D) для каждой точки данных в исходном пространстве и вычислите новые условные вероятности аналогично описанным выше в этом новом пространстве.
  • Отображение: Итеративно улучшайте точки в пространстве меньшей размерности до тех пор, пока расхождения Кульбака-Лейблера между всеми условными вероятностями не будут сведены к минимуму. По сути, мы сводим к минимуму различия в вероятностях между матрицами подобия двух пространств, чтобы обеспечить наилучшее сохранение сходства при отображении исходного набора данных в низкоразмерный датасетах.

t-SNE улучшает SNE двумя основными способами:

  • Это сводит к минимуму расхождения Кульбака-Лейблера между совместными вероятностями, а не условными. Авторы называют это “симметричным SNE”, поскольку их подход гарантирует, что совместные вероятности p_ij = p_ji. Это приводит к гораздо лучшему функционированию функции затрат, которую легче оптимизировать.
  • Он вычисляет сходства между точками, используя распределение Стьюдента-t с одной степенью свободы (также распределение Коши), а не гауссово в низкоразмерном пространстве (шаг 2 выше). Здесь мы можем видеть, откуда берётся буква “t” в t-SNE. Это улучшение помогает устранить “проблему скученности”, выделенную авторами, и ещё больше улучшить задачу оптимизации. Эту “проблему скученности” можно представить следующим образом: представьте, что у нас есть 10-мерное пространство, объема пространства, доступного в 2-мерном пространстве, будет недостаточно для точного захвата этих умеренно непохожих точек по сравнению с объемом пространства для соседних точек относительно объема пространства, доступного в 10-мерном пространстве. Проще говоря, просто представьте, что вы берёте трёхмерное пространство и проецируете его в 2-D. В трёхмерном пространстве будет гораздо больше общего пространства для моделирования сходств относительно 2-D проекции. Распределение Стьюдента-t помогает облегчить эту проблему, поскольку имеет более тяжелые хвосты, чем нормальное распределение.

Я надеюсь, что когда мы реализуем это в коде, все части встанут на свои места. Основной вывод таков: t-SNE моделирует сходства между точками данных в многомерном пространстве, используя совместные вероятности точек данных, выбирающих других в качестве своих соседей, а затем пытается найти наилучшее отображение этих точек вниз в низкоразмерное пространство, наилучшим образом сохраняя исходные многомерные сходства.

Внедрение с Scratch

Давайте теперь перейдем к пониманию t-SNE посредством реализации оригинальной версии алгоритма, представленной в статье Лоуренса ван дер Маатена и Джеффри Хинтона. Сначала мы начнём с пошаговой реализации приведённого ниже алгоритма, который будет охватывать 95% основного алгоритма. Авторы отмечают два дополнительных улучшения: 1) Раннее преувеличение и 2) Адаптивная скорость обучения. Мы обсудим только добавление ранних преувеличений, поскольку это наиболее способствует интерпретации внутренней работы реальных алгоритмов, поскольку адаптивная скорость обучения направлена на повышение скорости сходимости.

1. Входные и выходные данные

Следуя оригинальной статье, мы будем использовать общедоступный набор данных MNIST из OpenML с изображениями рукописных цифр от 0 до 9.[2] Мы также произвольно выберем 1000 изображений из набора данных и уменьшим размерность набора данных с помощью анализа основных компонентов (PCA) и сохраним 30 компонентов. Оба они предназначены для увеличения вычислительного времени алгоритма, поскольку код здесь оптимизирован не для скорости, а скорее для интерпретируемости и обучения.

from sklearn.datasets import fetch_openml from sklearn.decomposition import PCA import pandas as pd # Fetch MNIST data mnist = fetch_openml('mnist_784', version=1, as_frame=False) mnist.target = mnist.target.astype(np.uint8) X_total = pd.DataFrame(mnist["data"]) y_total = pd.DataFrame(mnist["target"]) X_reduced = X_total.sample(n=1000) y_reduced = y_total.loc[X_total.index] # PCA to keep 30 components X = PCA(n_components=30).fit_transform(X_reduced)

Это будет наш набор данных X, где каждая строка является изображением, а каждый столбец - объектом или основным компонентом в данном случае (т.е. линейными комбинациями исходных пикселей):

Нам также нужно будет указать параметры функции затрат — сложность — и параметры оптимизации — итерации, скорость обучения и импульс. Мы пока воздержимся от них и будем рассматривать по мере их появления на каждом этапе.

Что касается выходных данных, напомним, что мы ищем низкоразмерное отображение исходного набора данных X. В этом примере мы будем отображать исходное пространство в двумерное пространство. Таким образом, нашим новым результатом будет 1000 изображений, которые теперь представлены в 2-мерном пространстве, а не в исходном 30-мерном пространстве:

2. Вычисление сходства X в исходном пространстве

Теперь, когда у нас есть исходные данные, первым шагом является вычисление попарных сходств в исходном многомерном пространстве. То есть, для каждого изображения i мы вычисляем вероятность того, что я выбрал бы изображение j в качестве его соседа в исходном пространстве для каждого j. Эти вероятности вычисляются с помощью нормального распределения, центрированного вокруг каждой точки, а затем нормализуются для суммирования до 1. Математически мы имеем:

Обратите внимание, что в нашем случае с n = 1000 эти вычисления приведут к получению матрицы оценок сходства 1000 x 1000. Мы устанавливаем p = 0 всякий раз, когда i = j b / c, мы моделируем попарное сходство. Однако вы можете заметить, что мы не упомянули, как определяется σ. Это значение определяется для каждого наблюдения i с помощью поиска по сетке на основе заданной пользователем желаемой запутанности распределений. Мы поговорим об этом непосредственно ниже, но давайте сначала посмотрим, как мы будем программировать eq. (1):

def get_original_pairwise_affinities(X:np.array([]), perplexity=10): ''' Function to obtain affinities matrix. ''' n = len(X) print("Computing Pairwise Affinities....") p_ij = np.zeros(shape=(n,n)) for i in range(0,n): # Equation 1 numerator diff = X[i]-X σ_i = grid_search(diff, i, perplexity) # Grid Search for σ_i norm = np.linalg.norm(diff, axis=1) p_ij[i,:] = np.exp(-norm**2/(2*σ_i**2)) # Set p = 0 when j = i np.fill_diagonal(p_ij, 0) # Equation 1 p_ij[i,:] = p_ij[i,:]/np.sum(p_ij[i,:]) # Set 0 values to minimum numpy value (ε approx. = 0) ε = np.nextafter(0,1) p_ij = np.maximum(p_ij,ε) print("Completed Pairwise Affinities Matrix. \n") return p_ij

Теперь, прежде чем мы посмотрим на результаты этого кода, давайте обсудим, как мы определяем значения σ с помощью функции grid_search(). Учитывая заданное значение недоумения (которое в данном контексте можно условно представить как число ближайших соседей для каждой точки), мы выполняем поиск по сетке в диапазоне значений σ таким образом, чтобы следующее уравнение было максимально близко к равенству для каждого i:

где H(P) - энтропия Шеннона для P.

В нашем случае мы установим perplexity = 10 и установим, что пространство поиска определяется как [0,01 * стандартное отклонение норм для разницы между изображениями i и j, 5 * стандартное отклонение норм для разницы между изображениями i и j], разделённое на 200 равных шагов. Зная это, мы можем определить нашу функцию grid_search() следующим образом:

def grid_search(diff_i, i, perplexity): ''' Helper function to obtain σ's based on user-specified perplexity. ''' result = np.inf # Set first result to be infinity norm = np.linalg.norm(diff_i, axis=1) std_norm = np.std(norm) # Use standard deviation of norms to define search space for σ_search in np.linspace(0.01*std_norm,5*std_norm,200): # Equation 1 Numerator p = np.exp(-norm**2/(2*σ_search**2)) # Set p = 0 when i = j p[i] = 0 # Equation 1 (ε -> 0) ε = np.nextafter(0,1) p_new = np.maximum(p/np.sum(p),ε) # Shannon Entropy H = -np.sum(p_new*np.log2(p_new)) # Get log(perplexity equation) as close to equality if np.abs(np.log(perplexity) - H * np.log(2)) < np.abs(result): result = np.log(perplexity) - H * np.log(2) σ = σ_search return σ

Учитывая эти функции, мы можем вычислить аффинное преобразование через p_ij = get_original_pairwise_affinities(X), где мы получаем следующую матрицу:

Обратите внимание, что диагональным элементам задаётся значение ε ≈ 0 по конструкции (всякий раз, когда i = j). Напомним, что ключевым расширением алгоритма t-SNE является вычисление совместных вероятностей, а не условных вероятностей. Это вычисляется просто следующим образом:

Таким образом, мы можем определить новую функцию:

def get_symmetric_p_ij(p_ij:np.array([])): ''' Function to obtain symmetric affinities matrix utilized in t-SNE. ''' print("Computing Symmetric p_ij matrix....") n = len(p_ij) p_ij_symmetric = np.zeros(shape=(n,n)) for i in range(0,n): for j in range(0,n): p_ij_symmetric[i,j] = (p_ij[i,j] + p_ij[j,i]) / (2*n) # Set 0 values to minimum numpy value (ε approx. = 0) ε = np.nextafter(0,1) p_ij_symmetric = np.maximum(p_ij_symmetric,ε) print("Completed Symmetric p_ij Matrix. \n") return p_ij_symmetric

Вводя p_ij сверху, мы имеем p_ij_symmetric = get_symmetric_p_ij(p_ij), где мы получаем следующую диаграмму сродств:

Теперь мы завершили первый главный шаг в t-SNE! Мы вычислили симметричную матрицу аффинности в исходном многомерном пространстве. Прежде чем мы перейдём непосредственно к этапу оптимизации, мы обсудим основные компоненты задачи оптимизации на следующих двух этапах, а затем объединим их в нашем цикле for.

3. Примерьте исходное решение и вычислите матрицу аффинности низкой размерности

Теперь мы хотим выбрать случайное начальное решение в пространстве меньшей размерности следующим образом:

def initialization(X: np.array([]), n_dimensions = 2): return np.random.normal(loc=0,scale=1e-4,size=(len(X),n_dimensions))

где вызывая y0 = initialization(X), мы получаем случайное начальное решение:

Теперь мы хотим вычислить диаграмму сродства в этом пространстве меньшей размерности. Однако напомним, что мы делаем это, используя распределение Стьюдента-t с 1 степенью свободы:

Опять же, мы устанавливаем q = 0 всякий раз, когда i = j. Обратите внимание, что это уравнение отличается от уравнения eq. (1) в том смысле, что знаменатель находится над всеми i и, следовательно, симметричен по конструкции. Вводя это в код, мы получаем:

def get_low_dimensional_affinities(Y:np.array([])): ''' Obtain low-dimensional affinities. ''' n = len(Y) q_ij = np.zeros(shape=(n,n)) for i in range(0,n): # Equation 4 Numerator diff = Y[i]-Y norm = np.linalg.norm(diff, axis=1) q_ij[i,:] = (1+norm**2)**(-1) # Set p = 0 when j = i np.fill_diagonal(q_ij, 0) # Equation 4 q_ij = q_ij/q_ij.sum() # Set 0 values to minimum numpy value (ε approx. = 0) ε = np.nextafter(0,1) q_ij = np.maximum(q_ij,ε) return q_ij

Здесь мы ищем диаграмму сродства 1000 x 1000, но теперь в пространстве более низких измерений. Вызывая q_ij = get_low_dimensional_affinities(y0), мы получаем:

4. Вычисление градиента функции затрат

Напомним, что наша функция затрат представляет собой расхождение Кулбека-Лейблера между совместными распределениями вероятностей в пространстве высокой размерности и пространстве низкой размерности:

Интуитивно мы хотим свести к минимуму разницу в матрицах сродства p_ij и q_ij, тем самым наилучшим образом сохранив структуру “окрестностей” исходного пространства. Задача оптимизации решается с помощью градиентного спуска, но сначала давайте рассмотрим вычисление градиента для приведённой выше функции затрат. Авторы выводят (см. приложение А к статье) градиент функции затрат следующим образом:

В python у нас есть:

def get_gradient(p_ij: np.array([]), q_ij: np.array([]), Y: np.array([])): ''' Obtain gradient of cost function at current point Y. ''' n = len(p_ij) # Compute gradient gradient = np.zeros(shape=(n, Y.shape[1])) for i in range(0,n): # Equation 5 diff = Y[i]-Y A = np.array([(p_ij[i,:] - q_ij[i,:])]) B = np.array([(1+np.linalg.norm(diff,axis=1))**(-1)]) C = diff gradient[i] = 4 * np.sum((A * B).T * C, axis=0) return gradient

Вводя соответствующие аргументы, мы получаем градиент при y0 через gradient = get_gradient(p_ij_symmetric,q_ij,y0) с соответствующим выводом:

Теперь у нас есть всё необходимое для решения задачи оптимизации!

5. Повторение и оптимизация низкоразмерного отображения

Чтобы обновить наше низкоразмерное отображение, мы используем градиентный спуск с импульсом, как описано авторами:

где η - наша скорость обучения, а α (t) - наш импульсный член в зависимости от времени. Скорость обучения определяет размер шага на каждой итерации, а член импульса позволяет алгоритму оптимизации набирать инерцию в плавном направлении пространства поиска, не увязая при этом в зашумлённых частях градиента. Мы установим η=200 для нашего примера и зафиксируем α(t)=0,5, если t < 250, и α(t)=0,8 в противном случае. У нас есть все компоненты, необходимые выше для вычисления в соответствии с правилом обновления, таким образом, теперь мы можем выполнить нашу оптимизацию за заданное количество итераций T (мы установим T = 1000).

Прежде чем мы перейдём к итерационной схеме, давайте сначала представим усовершенствование, которое авторы называют “ранним преувеличением”. Этот член является константой, которая масштабирует исходную диаграмму сродств p_ij. Оно уделяет больше внимания моделированию очень похожих точек (высокие значения в p_ij из исходного пространства) в новом пространстве на ранней стадии и, таким образом, формированию “кластеров” из очень похожих точек. Раннее преувеличение включается в начале схемы итерации (T<250), а затем отключается в противном случае. В нашем случае раннее преувеличение будет равно 4. Мы увидим это в действии на нашем рисунке ниже после реализации.

Теперь, собрав все части алгоритма воедино, мы получаем следующее:

def tSNE(X: np.array([]), perplexity = 10, T = 1000, η = 200, early_exaggeration = 4, n_dimensions = 2): n = len(X) # Get original affinities matrix p_ij = get_original_pairwise_affinities(X, perplexity) p_ij_symmetric = get_symmetric_p_ij(p_ij) # Initialization Y = np.zeros(shape=(T, n, n_dimensions)) Y_minus1 = np.zeros(shape=(n, n_dimensions)) Y[0] = Y_minus1 Y1 = initialization(X, n_dimensions) Y[1] = np.array(Y1) print("Optimizing Low Dimensional Embedding....") # Optimization for t in range(1, T-1): # Momentum & Early Exaggeration if t < 250: α = 0.5 early_exaggeration = early_exaggeration else: α = 0.8 early_exaggeration = 1 # Get Low Dimensional Affinities q_ij = get_low_dimensional_affinities(Y[t]) # Get Gradient of Cost Function gradient = get_gradient(early_exaggeration*p_ij_symmetric, q_ij, Y[t]) # Update Rule Y[t+1] = Y[t] - η * gradient + α * (Y[t] - Y[t-1]) # Use negative gradient # Compute current value of cost function if t % 50 == 0 or t == 1: cost = np.sum(p_ij_symmetric * np.log(p_ij_symmetric / q_ij)) print(f"Iteration {t}: Value of Cost Function is {cost}") print(f"Completed Embedding: Final Value of Cost Function is {np.sum(p_ij_symmetric * np.log(p_ij_symmetric / q_ij))}") solution = Y[-1] return solution, Y

Вызывая решение, Y = tSNE(X), мы получаем следующий результат :

где solution - это окончательное двумерное отображение, а Y - наши сопоставленные двумерные значения на каждом шаге итерации. Построив график эволюции Y, где Y[-1] - наше окончательное двумерное отображение, мы получаем (обратите внимание, как алгоритм ведет себя при включении и выключении раннего преувеличения):

Я рекомендую поиграть с различными значениями параметров (например, недоумение, скорость обучения, раннее преувеличение и т.д.), чтобы увидеть, чем отличается решение (см. оригинальную статью и документацию scikit-learn для получения инструкций по использованию этих параметров).

Заключение

И вот оно, мы написали его с нуля! Я надеюсь, что вы сочли это упражнение проливающим свет на внутреннюю работу системы и, как минимум, приносящим удовлетворение. Обратите внимание, что эта реализация предназначена не для оптимизации скорости, а скорее для понимания. Для повышения скорости вычислений и производительности были реализованы дополнения к алгоритму t-SNE, такие как варианты алгоритма Барнса-Хата (древовидные подходы), использующие PCA в качестве инициализации встраивания или использующие дополнительные расширения градиентного спуска, такие как адаптивные скорости обучения. Реализация в scikit-learn использует многие из этих улучшений.

Как всегда, я надеюсь, что вам понравилось читать это так же, как мне понравилось это писать.

Ресурсы

[1] van der Maaten, L.J.P.; Hinton, G.E. Visualizing High-Dimensional Data Using t-SNE. Journal of Machine Learning Research 9:2579–2605, 2008.

[2] LeCun et al. (1999): The MNIST Dataset Of Handwritten Digits (Images) License: CC BY-SA 3.0

Статья была взята из этого источника:

0
Комментарии
-3 комментариев
Раскрывать всегда