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

В этой статье будут приведены некоторые примеры анализа текстов, посвященные выступлениям американских сенаторов на 105-м Конгрессе США в Вашингтоне, округ Колумбия, в период с января 1997 года по январь 1999 года (в период президентства Билла Клинтона). Эти выступления можно восстановить из следующего репозитория GitHub 105-extracted-date. Список сенаторов США включен в sen105kh_fix.csv. Это перекрестный набор данных, включающий информацию о 100 сенаторах США, занимающих посты от разных штатов, чьи выступления действительно содержатся в репозитории GitHub 105-extracted-date.

Итак, мы загружаем файл и называем его doc. Мы сортируем набор данных в соответствии с алфавитным порядком имени сенатора (lname).

doc = pd.read_csv('https://raw.githubusercontent.com/ariedamuco/ML-for-NLP/main/Inputs/sen105kh_fix.csv', sep = ';')
doc = doc.sort_values('lname')

На следующем изображении представлен обзор того, как отображаются данные.

Наиболее важными переменными для нас являются lname, lstate и party, и они будут полезны позже.

Итак, в этой статье мы сначала сравним политические выступления двух сенаторов США в наборе данных выше. Мы будем использовать подход Term Frequency-Inverse Document Frequency (TF-IDF) и вычислим их соответствующее косинусное сходство. Последует обсуждение меры подобия, а также сравнение подходов мешка слов и TF-IDF. Наконец, сравним выступления многих американских политиков с выступлением сенатора от Делавэра Джо Байдена (находившегося у власти с 1973 по 2009 год). Цель состоит в том, чтобы найти речь, наиболее близкую к его речи.

Сравнение выступлений Т. Кеннеди и Дж. Керри

Как видно из загруженного ранее набора данных, Тед Кеннеди и Джон Керри были сенаторами США от штата Массачусетс. Первый находился у власти с 1962 по 2009 год; последний был у власти более 20 лет спустя, с 1985 по 2013 год. Оба политика были и остаются членами Демократической партии, и поэтому мы ожидаем, что их выступления не так уж отличаются друг от друга, несмотря на разницу во времени в их мандатах.

doc[doc['lstate']=='MASSACH']

Итак, мы начинаем загружать текстовые файлы из упомянутой выше папки GitHub, а затем объединяем оба файла в список с именем speech:

import requests

kerry_url = "https://raw.githubusercontent.com/ariedamuco/ML-for-NLP/main/Inputs/105-extracted-date/105-kerry-ma.txt"
kennedy_url = "https://raw.githubusercontent.com/ariedamuco/ML-for-NLP/main/Inputs/105-extracted-date/105-kennedy-ma.txt"

kerry = requests.get(kerry_url)
speech_kerry = kerry.text
kennedy = requests.get(kennedy_url)
speech_kennedy = kennedy.text
speech = [speech_kennedy, speech_kerry]
speech

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

def preprocessing_text(text):
    words = word_tokenize(text.lower())
    tokens = [word for word in words if word not in string.punctuation]
    tokens = [token for token in tokens if token not in complete_drop_list]
    tokens = [word for word in tokens if len(word)>=3] 
    stemmer = PorterStemmer()
    tokens_lematized= [stemmer.stem(word) for word in tokens] 
    preprocessed_text = ' '.join(tokens_lematized)
    return preprocessed_text 

Итак, как видно из приведенной выше функции, мы сначала переводим слова в нижний регистр, затем отфильтровываем знаки препинания, удаляем слова из complete_drop_list, отбрасываем токены, содержащие менее двух букв, пока не применим наиболее распространенный алгоритм стемминга, т. е. стеммер Портера, и объединение всех токенов вместе. Мы решили использовать алгоритм стемминга вместо лемматизирующего, потому что значение слов не имеет отношения к цели этого анализа. На самом деле мы стремимся найти только какое-то сходство между двумя речами, не обращая внимания на значение используемых слов. Тем не менее, это не означает, что мы хотим сохранить слова, которые не добавляют никакой полезной информации к нашим текстам, иначе они сделают две речи более похожими, чем они есть на самом деле. По этой причине мы опускаем слова, включенные в complete_drop_list. Этот список создается с помощью приведенного ниже кода.

drop_list_url = "https://raw.githubusercontent.com/ariedamuco/ML-for-NLP/main/Inputs/droplist.txt"
drop_list = requests.get(drop_list_url)
drop_list = drop_list.text
with open('droplist.txt', 'r') as f:
    droplist = [line.strip().replace('"','') for line in f]
stopw = set(stopwords.words('english'))
complete_drop_list = stopw.union(droplist)
other_useless_words = ['docno', 'text', '/text', '/doc', 'doc', '/docno', 'mr.', "n't", 'would', 'President', 'Senator', 'Senators']
complete_drop_list = complete_drop_list.union(other_useless_words)
len(complete_drop_list)

В complete_drop_list мы сначала включаем droplist.txt, лежащий в репозитории GitHub, который представляет собой уже подготовленный список слов для удаления. Мы решили использовать этот список, потому что слова, которые он включает, на самом деле не добавляют информации к речам и могут привести к вводящим в заблуждение результатам при анализе текста. Кроме того, мы добавляем к этому набору слов еще один список бесполезныхслов, который называется other_useless_words. Их извлекают, применяя к данным функцию preprocessing_text , а затем используя приведенный ниже код для создания гистограммы, отображающей 50 наиболее часто встречающихся токенов. Среди этих последних мы выделяем те, которые не добавляют полезной информации к выступлениям, и включаем их в список другие_бесполезные_слова. Наконец, мы добавляем стоп-слова из английского словаря в complete_drop_list. Всего в этом списке 786 слов.

preprocessed_words = preprocessing_text(" ".join(speech)).split()

from collections import Counter
dict_counts = Counter(preprocessed_words)
dict_counts

labels, values = zip(*dict_counts.items())
%matplotlib inline 
indSort = np.argsort(values)[::-1]

labels = np.array(labels)[indSort][0:50]
values = np.array(values)[indSort][0:50]

indexes = np.arange(len(labels))

plt.bar(indexes, values, color="red")

plt.xticks(indexes, labels, rotation=45)

Для простоты мы наносим на график только 30 наиболее распространенных слов в обоих выступлениях, полученных после обработки данных и перед добавлением other_useless_words в complete_drop_list. Из этого графика видно, почему мы добавили 'docno', 'text', '/text', '/doc', 'doc', '/docno', 'mr. ', "n't", "будет", "Президент", "Сенатор", "Сенаторы" в complete_drop_list.

В нашем анализе мы будем использовать подход «Частота термина — обратная частота документа» (TF-IDF), который представляет собой алгоритм, переоценивающий важность слов в соответствии с их частотой. Это можно записать следующим образом:

где i — токен, а j — документ (речь).

Согласно приведенной выше формуле, редкие или чрезвычайно частые токены считаются менее репрезентативными и связаны с более низким TF-IDF. Напротив, те слова, которые имеют высокий TF-IDF, являются наиболее репрезентативными словами, которые действительно являются наиболее релевантными в документе. Эту логику можно наглядно показать на следующем графике:

Таким образом, мы вспоминаем этот алгоритм с помощью команды TfidfVectorizer, которая использует определенную выше функцию препроцессора и учитывает наличие как униграмм, так и биграмм внутри речей. Далее применяем этот алгоритм к списку используемых документов.

tfidf_vectorizer = TfidfVectorizer(preprocessor = preprocessing_text, ngram_range=(1,2))
tfidf = tfidf_vectorizer.fit_transform(speech)

Следующим шагом является создание кадра данных TF-IDF. Для этого мы сначала транспонируем матрицу терминов документа, чтобы переменные были строками, а наблюдения — столбцами фрейма данных. Итак, токены — это переменные, а две речи — это два наблюдения этого фрейма данных.

tfidf_transpose = tfidf.toarray().transpose()
df = pd.DataFrame(tfidf_transpose, index=tfidf_vectorizer.get_feature_names_out())
df.columns = ['Kennedy', 'Kerry']
df

Обзор сгенерированного кадра данных показан на следующем изображении:

Мы продолжаем разбивать кадр данных на два вектора с 208 502 токенами, соответствующими каждой речи. Таким образом, мы можем вычислить их косинусное сходство.

for i in range(1,3):
    globals()["txt" + str(i)] =df[df.columns[i-1]].values.reshape(1, -1)

from sklearn.metrics.pairwise import cosine_similarity
print("Similarity txt1 and txt2:", cosine_similarity(txt1, txt2))

Косинусное сходство получается равным 0,75, что очень близко к 1. Это означает, что речи Теда Кеннеди и Джона Керри очень похожи, как мы и ожидали. Это объясняется тем, что оба сенатора входят в Демократическую партию.

Мешок слов против подхода TF-IDF

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

Поскольку набор слов и частота терминов — обратная частота документа работают по-разному, мы ожидаем, что они будут давать различное косинусное сходство.

Итак, мы можем проверить эти рассуждения с помощью следующего кода. Прежде всего, мы определяем функцию vectorizer, которая интегрирует подход "мешок слов". Подобно TF-IDF, он использует функцию preprocessing_text и рассматривает как униграммы, так и биграммы. Затем мы создаем фрейм данных, включая частоту каждого токена в обеих речах. Наконец, мы вычисляем косинусное сходство, и в результате получаем 0,79. Таким образом, подход «мешок слов» предполагает более высокую степень сходства между двумя речами по сравнению с подходом TF-IDF. Это можно объяснить тем, что этот первый подход не принижает значения наиболее часто встречающихся слов в обеих речах, и, таким образом, разумно, что они выглядят более похожими.

vectorizer = CountVectorizer(preprocessor = preprocessing_text, ngram_range=(2,2)) # to get df1

vector = vectorizer.fit_transform(speech)
vector_transpose = vector.toarray().transpose()

df_bag = pd.DataFrame(vector_transpose, index=vectorizer.get_feature_names_out())
df_bag.columns = ['Kennedy', 'Kerry']
df_bag

for i in range(1,3):
    globals()["txt_bag" + str(i)] =df_bag[df_bag.columns[i-1]].values.reshape(1, -1)  

print("Cosine similarity txt1 and txt2:", cosine_similarity(txt_bag1, txt_bag2))

Вот как выглядит датафрейм частоты каждого токена.

В случае n-грамм их может учитывать как пакет слов, так и подход TF-IDF, и они не разбиваются на отдельные термины. На самом деле обе функции CountVectorizer и TfidfVectorizer имеют аргумент ngram_range, в котором вы можете указать нижнюю и верхнюю границу диапазона n- значения для различных n-грамм, которые необходимо извлечь.

Какая мера подобия является альтернативой косинусному сходству?
Есть и другие возможные меры подобия, которые меняются в зависимости от типа используемых вами переменных. Например, в случае непрерывных переменных мы можем использовать либо евклидово расстояние, либо манхэттенское расстояние; в случае категориальных переменных мы можем использовать простую оценку сходства или коэффициенты Жаккара. Если переменные смешанные, то есть одни непрерывные, а другие бинарные, мы можем использовать индекс Гауэра. Однако это только несколько примеров.

Поскольку частота терминов — обратная частота документов являются непрерывными переменными, мы можем использовать евклидово расстояние в качестве альтернативы косинусному сходству. Это корень суммы квадратов разностей между соответствующими TF-IDF. Чем ближе этот показатель к 0, тем больше похожи два выступления.

На изображении ниже показано сравнение между косинусным сходством, обозначенным θ, и евклидовым расстоянием, обозначенным d. Как мы можем заметить, между этими двумя показателями сходства/расстояния есть различия. В частности, евклидово расстояние не нормирует длину текстов и, таким образом, может быть очень велико для двух очень похожих по содержанию, но разных по длине документов. Напротив, косинусное подобие учитывает длину текста и соответствующим образом настраивается. Таким образом, оно будет более надежным, чем евклидово расстояние, в случае документов разной длины.

Итак, мы вычисляем евклидово расстояние для нашей задачи анализа текста.

from sklearn.metrics.pairwise import euclidean_distances
print("Euclidean distance txt1 and txt2:", euclidean_distances(txt1, txt2))

В нашем случае евклидово расстояние равно 0,69, что означает, что два выступления относительно различны. Однако косинусное сходство предполагает, что две речи более похожи, чем это предполагает евклидово расстояние. Этот разный результат может быть связан с разной длиной текста. Итак, мы проверяем это, написав следующий код, и получаем вывод ниже.

speech_kennedy = [speech_kennedy]
speech_kerry = [speech_kerry]

tfidf1 = tfidf_vectorizer.fit_transform(speech_kennedy)
tfidf2 = tfidf_vectorizer.fit_transform(speech_kerry)

Итак, после вставки двух выступлений в отдельные списки мы применяем сгенерированную выше функцию TF-IDF и видим, что результирующие размеры этих матриц различны. Предварительно обработанный файл выступления Т. Кеннеди намного длиннее выступления Дж. Керри. Это объясняет, почему евклидово расстояние двух текстов передает другую историю, чем косинусное сходство. Таким образом, мы должны полагаться на последний. Эти две речи очень похожи друг на друга!

Обратите внимание, что мы могли бы не применять подход TF-IDF к тексту и прямо наблюдать, что длина двух предварительно обработанных речей явно отличается друг от друга, как показано ниже.

preproc_kennedy = preprocessing_text(speech_kennedy)
preproc_kerry = preprocessing_text(speech_kerry)

print(len(preproc_kennedy))
print(len(preproc_kerry))

Какая речь сенатора США ближе всего к речи Байдена?

Мы загружаем все текстовые файлы, включенные в репозиторий GitHub 105-extracted-date через команду glob.glob, которых 100 и они расположены в алфавитном порядке. Затем мы называем речь каждого сенатора как speechX, где X — число от 0 до 99 в соответствии с позицией речи (в алфавитном порядке) в папке.

import glob
glob = glob.glob('C:/Users/Anna Monisso/Desktop/UNIBO/CEU/corsi/Machine Learning for NLP/ML-for-NLP-main/Inputs/105-extracted-date/*.txt')
glob

for i in range(0,100):
    globals()["speech" + str(i)] = open(glob[i]).read()

Далее мы создаем список некоторых выступлений. Из-за низкой вычислительной мощности мы рассмотрим только подмножество этих 100 текстовых файлов, но учтите, что один и тот же следующий код можно запустить для всех из них.

Итак, мы создаем список подмножество, содержащий первые 50 выступлений. Обратите внимание, что здесь могут быть и другие, более оптимальные способы сделать это (через циклы), но мы используем ручной подход.

subset = [speech0, speech1, speech2, speech3, speech4, speech5, speech6, speech7, speech8, speech9, speech10, speech11, speech12, speech13, speech14, speech15, speech16, speech17, speech18, speech19, speech20,speech21, speech22, speech23, speech24, speech25, speech26, speech27, speech28, speech29, speech30, speech31, speech32, speech33, speech34, speech35, speech36, speech37, speech38, speech39, speech40, speech41, speech42, speech43, speech44, speech45, speech46, speech47, speech48, speech49]We recall the TF-IDF approach used before and its corresponding function. Thus, we can apply the same function tfidf_vectorizer to the list just generated. We need to employ parallel computing to speed up the process, as the time requested to run the following code is pretty long given the high amount of data. 

Итак, теперь мы применяем тот же процесс, что и раньше, просто рассматриваем больше речей. Первый шаг — применить ранее сгенерированную функцию tfidf_vectorizer, используя ту же функцию preprocessing_text и учитывая наличие как униграмм, так и биграмм.

 tfidf_all = tfidf_vectorizer.fit_transform(subset)

Опять же, мы создаем фрейм данных TF-IDF, где переменные — это токены, а наблюдения — это 50 рассмотренных сенаторов США. Поскольку мы рассмотрели первые 50 речей сенаторов США в папке, которые расположены в алфавитном порядке, мы называем столбцы этого нового фрейма данных именами первых 50 сенаторов США в наборе данных doc, загруженном по адресу начало.

tfidf_all_transpose = tfidf_all.toarray().transpose()
df_all = pd.DataFrame(tfidf_all_transpose, index=tfidf_vectorizer.get_feature_names_out())

name = doc['lname']
subset_name = name[0:50]
df_all.columns = subset_name
df_all

Вот как выглядит набор данных.

Точно так же мы разделяем кадр данных на 50 векторов для каждой речи.

for i in range(0,50):
    globals()["txt" + str(i)] =df_all[df_all.columns[i-1]].values.reshape(1, -1) 

Итак, теперь мы готовы вычислить косинусное сходство между соответствующим txt Джо Байдена (номер 6) и каждой из других речей txt. Сначала мы создаем фрейм данных cos_sim_df, чтобы сохранить косинусное сходство каждого сенатора в первом столбце, а имена 50 сенаторов США — во втором. Для второго столбца мы вызываем столбец lname из набора данных doc и создаем его подмножество, включая имена первых 50 сенаторов США. Затем этот столбец преобразуется в список и используется в cos_sim_df.

name = doc['lname']
subset_name = name[0:50]
rows = list(subset_name)

d = {'CosineSimilarity': range(0,50), 'SenatorName': rows}
cos_sim_df = pd.DataFrame(data=d)

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

from sklearn.metrics.pairwise import cosine_similarity

for i in range(0,50):
    cos_sim_df['CosineSimilarity'][i] = cosine_similarity(txt6, globals()["txt" + str(i)])
cos_sim_df = cos_sim_df.drop([6])

На следующем изображении показано, как выглядит cos_sim_df.

Чтобы получить максимальное косинусное сходство, т. е. наиболее близкие выступления сенатора США к выступлениям Дж. Байдена, мы напишем следующий код и получим результат ниже.

print(cos_sim_df.loc[cos_sim_df['CosineSimilarity'] == cos_sim_df['CosineSimilarity'].max()])

Судя по всему, сенатор США, чья речь наиболее близка к речи Джо Байдена, — это Джесси Хелмс. Это довольно удивительно, учитывая, что первый был членом Демократической партии, а второй был лидером консервативного движения в США, которое является политическим движением внутри Республиканской партии, находящимся под влиянием консервативных и христианских медиа-организаций. Более того, в рассмотренном подмножестве речей почти половина сенаторов были членами Демократической партии, как и Дж. Байден. Однако мы можем заметить, что косинусное сходство также не так велико, достигая всего 0,36. Таким образом, эти две речи на самом деле довольно сильно отличаются друг от друга. Стоит также отметить, что Джо Байден в прошлом был более консервативным и традиционалистским по сравнению с его нынешними политическими позициями и, таким образом, «ближе» к идеологиям Республиканской партии. Например, в 1970-е годы он был одним из главных противников в Сенате автобусов, объединяющих гонки. Кроме того, в 1993 году Байден проголосовал за положение, запрещающее гомосексуалистам служить в вооруженных силах, а три года спустя он проголосовал за Закон о защите брака, который запрещал федеральному правительству признавать однополые браки.