Латентно-семантический анализ

Латентно-семантический анализ – для чего он нужен?

Здравствуйте и вновь добро пожаловать на занятия по теме «Обработка данных: обработка естественных языков на языке Python».

Ранее мы рассмотрели несколько задач, где преобразовывали слова в числа и говорили, что если слово показывает много спама или отрицательную тональность, то это значит, что слово ассоциировано с соответствующей меткой.

Но что, если у нас есть множество слов с одинаковым значением или если у нас есть одно слово, но способное принимать множество различных значений? Тогда у нас возникает проблема. Эти две проблемы называются синонимией и полисемией. Вот несколько примеров, найденные мною на Википедии: слова «покупать» и «приобретать» – синонимы, «огромный» и «колоссальный» – тоже синонимы, так же как и «быстрый» и «резвый».

Примером полисемии может служить слово «лицо». Это слово может подразумевать собой отдельного человека, личность, а может подразумевать часть тела, в отличие, скажем, от ноги или затылка. Ещё одним примером может служить слово «три» – в одном случае это числительное, обозначающее количество чего-либо, а в другом – это производное слово от «тереть», и тогда это глагол.

Один из возможных путей решения этой проблемы – это скомбинировать слова с одинаковыми значениями. К примеру, вы, возможно, замечали, что слова «компьютер», «ПК» и «ноутбук» часто встречаются вместе. Это значит, что между ними сильная корреляция. Можно представить их в виде некоей скрытой, или латентной, переменной, представляющей все эти слова, отсюда и термин – латентно-семантический анализ.

Задача латентно-семантического анализа (LSA) как раз и состоит в том, чтобы найти эти скрытые переменные и преобразовать в них наши исходные данные, ожидая, что размерность этих новых данных будет намного меньше оригинального текста, что позволяет ускорить вычисления.

Важно отметить, что LSA определённо помогает решить проблему синонимии путём комбинации коррелирующих переменных, но мне встречались противоречащие друг другу точки зрения на то, действительно ли он помогает решить проблему полисемии.

Далее мы рассмотрим математику, лежащую в основе LSA, а также примеры из реального мира.

PCA и SVD. Математическое обоснование LSA

В этом разделе мы изучим механику действия LSA. LSA, по сути, является приложением сингулярного разложения (singular value decomposition, или SVD) применительно к терм-документной матрице. Метод главных компонент (principal component analysis, или PCA) является более простой формой SVD, поэтому прежде всего разберём его.

Хочу подчеркнуть, что эта лекция является факультативной и предназначена лишь для тех, кто хочет понять математическое обоснование латентно-семантического анализа.

Итак, рассмотрим сначала метод главных компонент, или PCA, и обсудим в общих чертах, для чего он предназначен. Говоря самыми общими словами, PCA преобразует наши входные векторы данных:

z = Q*x.

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

Первое. Он декоррелирует все наши данные, так что данные в новой системе координат имеют нулевую корреляцию между любыми двумя признаками.

Второе. Преобразованные данные упорядочиваются в зависимости от важности. Так, первый компонент вектора несёт больше всего информации, второй – меньше чем первый, но больше чем третий, и так далее.

И третье – метод главных компонент позволяет уменьшить размерность наших данных. Предположим, например, что наш оригинальный словарь включает в себя 1 000 слов. Объединив слова в зависимости от того, насколько часто они соседствуют в документах, мы можем обнаружить, что общее число оригинальных скрытых терминов составляет лишь одну сотню. Это является прямым следствием второго пункта. Таким образом, если мы отбросим высшие размерности, если они содержат, скажем, менее 5% общей информации, то в результате потеряем весьма немногое.

Обратите внимание, что убирая информацию, мы далеко не всегда уменьшаем предсказательную способность модели. Одна из перспективных целей PCA – это удаление шума. В случае NLP, когда размер словаря велик, шум окажется довольно распространённым явлением. Таким образом, проводя сглаживание или отсечение шума, мы в действительности можем куда лучше обобщить наши данные.

Эти три идеи ясно подсказывают, что же нам надо сделать. Центральный элемент всех трёх идей – ковариационная матрица. Диагонали ковариационной матрицы показывают дисперсии компонент вектора, а внедиагональные элементы – как два разных компонента коррелируют друг с другом.

Напомним, что для большинства классических статистических методов большая дисперсия рассматривается как синоним большей информации и, как следствие, полностью определённая переменная рассматривается как содержащая нулевую информацию. Почему так? Потому что если мы можем предсказать точное значение переменной, то её измерение ничего нам не даст, ведь мы уже знаем ответ, который получим.

Ковариационная матрица вычисляется по следующей формуле. Каждый ij-й элемент матрицы равен

\sum _{ij} = \frac{1}{N} \sum_{i=1}^{N}(x_i - \mu_i) (x_j - \mu_j).

Таким образом, как вы можете убедиться, если i = j, то получим простую дисперсию.

Более удобным это уравнение предстаёт в матричной записи:

\sum _X = \frac{1}{N} (X - \mu_X)^T (X - \mu_X).

Отцентрировав данные, чтобы среднее значение было равно нулю, уравнение сводится до

\sum _X = \frac{X^T X}{N}.

Вы можете удостовериться в правильности результата, если учтёте, что XT имеет размерность DxN, а X имеет размерность NxD, так что результат будет иметь размерность DxD.

Итак, у нас есть ковариационная матрица размерностью DxD. Что же с ней делать дальше? Конечно же, для начала надо найти собственные значения и собственные векторы, после чего подставить их в матрицы: собственные значения – в диагональную матрицу, обозначаемую через Λ, а собственные векторы – в матрицу, обозначаемую через Q. Мы пропустим здесь математические выкладки, но суть в том, что матрица Λ, содержащая собственные значения, является ковариацией преобразованных данных. Поскольку внедиагональные элементы являются нулями, то это значит, что преобразованные данные являются полностью декоррелированными. В свою очередь, поскольку мы можем отсортировать собственные значения как нам заблагорассудится, мы можем сделать так, чтобы самые большие собственные значения шли первыми. Не забывайте, что собственные значения являются просто дисперсией преобразованных данных.

Если вы желаете углубиться в эту тему и провести все математические выкладки, я рекомендую просмотреть моё бесплатное руководство по этой теме, которое можно найти по адресу http://lazyprogrammer.me/tutorial-principal-components-analysis-pca/.

В NLP мы, как правило, создаём так называемые терм-документные матрицы. Каждый терм можно рассматривать как входящую размерность, а каждый документ – как наблюдение (образец, пример). Обратите внимание, что это обратно тому, что мы обычно делали, когда наблюдения шли вдоль строк, а входные признаки – вдоль столбцов.

Мы использовали PCA, чтобы комбинировать сходные термы (слова), но что, если мы захотим скомбинировать сходные документы? Да просто использовать PCA после транспонирования оригинальной матрицы! Что любопытно, мы по-прежнему получаем ковариантную матрицу размерности NxN с D собственными значениями, а кроме того – собственные значения будут теми же, которые мы нашли в оригинальной ковариационной матрице.

Теперь, когда вы знаете, что PCA находит корреляции между входными признаками, а транспонированные данные находят корреляции между входными наблюдениями, мы готовы поговорить о разложении по сингулярным значениям, или SVD.

Так вот, SVD позволяет провести обе PCA-операции одновременно. Мы находим собственные векторы для XXT, что обозначается через U, и собственные векторы для XTX, что обозначается через V. Не забывайте, что при этом мы упорядочили эти собственные векторы в порядке убывания. Взяв квадратный корень из собственных значений, мы помещаем их в матрицу, обозначаемую через S. Проделав всё это, мы можем записать:

X = USV^T.

Это и есть то, что понимается под разложением.

Мы точно так же можем преобразовывать данные, как и в случае PCA, но в отличие от PCA, где мы могли преобразовывать только входящие документы путём комбинирования слов, при SVD мы также можем преобразовывать входящие слова путём комбинирования документов.

Кроме того, мы можем получить низкоразрядную, или низкоразмерную, аппроксимацию X принимая во внимание лишь первые k элементов U, S и V:

X_k = U_kS_kV_k^T.

Латентно-семантический анализ на языке Python

Если вы не хотите сами писать код, а сразу его посмотреть, вы можете найти его по адресу, папка nlp_class, файлы lsa.py и all_books_titles.txt – последний является нашими входными данными.

Наши данные – это просто ряд заголовков книг в каждой строке.

Мы начнём с того же, с чего начинали при семантическом анализе, – с предварительной подготовки данных, поэтому нам понадобятся те же библиотеки – NLTK, NumPy, а поскольку нам придётся рисовывать графики – то и Matplotlib.

import nltk

import numpy as np

import matplotlib.pyplot as plt

Мы также вновь используем лемматизатор и библиотеку scikit-learn.

from nltk.stem import WordNetLemmatizer

from sklearn.decomposition import TruncatedSVD

Инициируем наш лемматизатор и загрузим наши заголовки книг в массив.

wordnet_lemmatizer = WordNetLemmatizer()

titles = [line.rstrip() for line in open(‘all_book_titles.txt’)]

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

sropwords = set(w.rstrip() for w in open(‘stopwords.txt’))

stopwords = stopwords.union({

‘introduction’, ‘edition’, series’, ‘application’.

‘approach’, ‘card’, ‘access’, ‘package’, ‘plus’, ‘etext’,

‘brief’, ‘vol’, ‘fundamental’, ‘guide’, ‘essential’, ‘printed’,

‘third’, ‘second’, ‘fourth’,

})

Далее мы используем токенайзер из предыдущего примера, но добавим в него кое-что ещё. Нам по-прежнему понадобится токенайзер, мы по-прежнему убираем короткие слова, по-прежнему лемматизируем слова и убираем слова из стоп-листа. Что касается нового, то, как вы помните, многие названия книг содержат словосочетания вроде «второе издание» или «третье издание», поэтому мы уберём все токены, содержащие числа.

def my_tokenizer(s):

s = s.lower()

tokens = nltk.tokenize.word_tokenize(s)

tokens = [t for t in tokens if len(t) > 2]

tokens = [wordnet_lemmatizer.lemmatize(t) for t in tokens]

tokens = [t for t in tokens if t not in stopwords]

tokens = [t for t in tokens if not any(c.isdigit() for c in t)]

В следующем блоке кода мы вновь делаем то же, что и в примере с анализатором тональности текста – пройдём по всему словарю и присвоим словам индексы. Кроме того, некоторые заголовки некорректно отображены в кодировке ASCII, поэтому попытаемся заодно корректно перевести их в ASCII, а те из них, для которых это не удалось, будем пропускать.

word_index_map = {}

current_index = 0

all_tokens = []

all_titles = []

index_word_map = []

for title in titles:

try:

title = title.encode(‘ascii’, ‘ignore’)

all_titles.append(title)

all_tokens.append(tokens)

for token in tokens:

if token not in word_index_map:

word_index_map[token] = current_index

current_index += 1

index_word_map.append(token)

except:

pass

Затем мы скопируем функцию tokens_to_vector с тем небольшим отличием, что в данном случае у нас не будет метки. Между прочим, поскольку метки нет, это относится уже к сфере обучения без учителя. Кроме того, функция будет двоичной и принимать значения только 1 или 0.

def tokens_to_vector(tokens):

x = np.zeros(len(word_index_map))

for t in tokens:

i = word_index_map[t]

x[i] = 1

return x

Обозначим через N количество всех наших токенов, а через D – количество индексов слов, а также инициируем наш массив данных, обозначенный через X. Он имеет размерность DxN, поскольку мы рассматриваем терм-документную матрицу, а не документ-термную. Всё остальное же практически идентично тому, что мы делали ранее.

N = len(all_tokens)

D = len(word_index_map)

X = np.zeros((D, N))

i = 0

for tokens in all_tokens:

X[:, i] = tokens_to_vector(tokens)

i += 1

Затем инициируем SVD-объект.

svd = TruncatedSVD()

z = svd.fit_transform(X)

Убедимся, что всё работат. Итак, ошибок нет, всё хорошо.

Изобразим полученный результат на плоскости, назначив при этом каждой точке соответствующее ей слово.

plt.scatter(Z[:,0], Z[:,1])

for i in xrange(D):

plt.annotate(s=index_word_map[i], xy=(Z[i,0], Z[i,1]))

plt.show()

Запустим программу. Как вы можете видеть, слова «история» и «наука» очень обособлены друг от друга. Слова «история», «искусство», «том», «американский», «глобальный» имеют примерно одно и то же значение абсциссы, как и слова «компьютер» и «наука». Если мы увеличим наше скопление слов на графике, то сможем увидеть больше аналогичных групп. Слова «статистика», «бизнес», «биология», «инженерия», «вероятность» – все они расположены вместе. В другой части графика имеем «Америка», «глобальный», «книга», «мир», «современный». Если же мы ещё более увеличим диаграмму, то станет видно, что слова, связанные с искусством, религией и другими социальными науками, группируются вверху, тогда как большинство слов, связанных с естественными науками и математикой сосредоточены внизу.

Андрей Никитенко
Андрей Никитенко
Задать вопрос эксперту
Понравилась статья? Поделить с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!:

Share via
Copy link