NLP (doc2vec с нуля) и кластеризация Классификация новостных репортажей на основе содержания текста
NLP (doc2vec с нуля) и кластеризация классификация новостных репортажей на основе содержания текста
Использование NLP (doc2vec), с глубокой и настраиваемой очисткой текста, а затем кластеризацией (Birch) для поиска тематик в тексте новостей статей.
В этом примере я использую NLP (Doc2Vec) и алгоритмы кластеризации для попытки классификации новостей по темам.
Существует множество способов сделать такую классификацию, такие как использование методов с учителем (размеченный набор данных), использование кластеризации и использование определенного алгоритма LDA (моделирование тематик).
Я использую Doc2Vec, потому что считаю его хорошим алгоритмом для векторизации текста и относительно простым для обучения с нуля.
Общий обзор того, как я собираюсь решить эту задачу, следующий:
- Нейронные сети упрощены для учеников десятого класса
- Следующий этап эффективности электронной почты с LLM
- НЛП, НН, временные ряды возможно ли предсказать цены на нефть с использованием данных от Google Trends?
Как обычно, первый шаг – загрузить необходимые библиотеки:
#для обработки данныхimport numpy as npimport pandas as pd#исходные данные словаря в формате jsonimport jsonpd.options.mode.chained_assignment = None #чтение с дискаfrom io import StringIO#предобработка и очистка текстаimport reimport nltkfrom nltk.corpus import stopwordsnltk.download('stopwords')nltkstop = stopwords.words('english')from nltk.stem.snowball import SnowballStemmernltk.download('punkt')snow = SnowballStemmer(language='English')#моделированиеfrom gensim.models.doc2vec import Doc2Vec, TaggedDocumentfrom nltk.tokenize import word_tokenizefrom sklearn.decomposition import PCAfrom sklearn.preprocessing import StandardScalerfrom sklearn.metrics import pairwise_distancesfrom sklearn.cluster import Birchfrom sklearn.metrics import silhouette_samples, silhouette_score, calinski_harabasz_scoreimport warnings#графикиimport matplotlib.pyplot as pltimport matplotlib.cm as cmimport seaborn as sns
Затем, я читаю данные и подготавливаю файлы словаря. Исходные данные взяты из общедоступных наборов данных Kaggle (списки стран, имен, валют и т.д.)
#это набор статей для обработкиmaindataset = pd.read_csv("articles1.csv")maindataset2 = pd.read_csv("articles2.csv")maindataset = pd.concat([maindataset,maindataset2], ignore_index=True)#это список стран. Мы заменяем имена стран в статьях на xcountryxcountries = pd.read_json("countries.json")countries["country"] = countries["country"].str.lower()countries = pd.DataFrame(countries["country"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())countries.columns = ['word']countries["replacement"] = "xcountryx"#это список провинций. В этот список включены несколько альтернативных названий и список стран, которые я также добавляю в словарьprovincies = pd.read_csv("countries_provincies.csv")provincies1 = provincies[["name"]]provincies1["name"] = provincies1["name"].str.lower()provincies1 = pd.DataFrame(provincies1["name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies1.columns = ['word']provincies1["replacement"] = "xprovincex"provincies2 = provincies[["name_alt"]]provincies2["name_alt"] = provincies2["name_alt"].str.lower()provincies2 = pd.DataFrame(provincies2["name_alt"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies2.columns = ['word']provincies2["replacement"] = "xprovincex"provincies3 = provincies[["type_en"]]provincies3["type_en"] = provincies3["type_en"].str.lower()provincies3 = pd.DataFrame(provincies3["type_en"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies3.columns = ['word']provincies3["replacement"] = "xsubdivisionx"provincies4 = provincies[["admin"]]provincies4["admin"] = provincies4["admin"].str.lower()provincies4 = pd.DataFrame(provincies4["admin"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies4.columns = ['word']provincies4["replacement"] = "xcountryx"provincies5 = provincies[["geonunit"]]provincies5["geonunit"] = provincies5["geonunit"].str.lower()provincies5 = pd.DataFrame(provincies5["geonunit"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies5.columns = ['word']provincies5["replacement"] = "xcountryx"provincies6 = provincies[["gn_name"]]provincies6["gn_name"] = provincies6["gn_name"].str.lower()provincies6 = pd.DataFrame(provincies6["gn_name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())provincies6.columns = ['word']provincies6["replacement"] = "xcountryx"provincies = pd.concat([provincies1,provincies2,provincies3,provincies4,provincies5,provincies6], axis=0, ignore_index=True)#список валютcurrencies = pd.read_json("country-by-currency-name.json")currencies1 = currencies[["country"]]currencies1["country"] = currencies1["country"].str.lower()currencies1 = pd.DataFrame(currencies1["country"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())currencies1.columns = ['word']currencies1["replacement"] = "xcountryx"currencies2 = currencies[["currency_name"]]currencies2["currency_name"] = currencies2["currency_name"].str.lower()currencies2 = pd.DataFrame(currencies2["currency_name"].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())currencies2.columns = ['word']currencies2["replacement"] = "xcurrencyx"currencies = pd.concat([currencies1,currencies2], axis=0, ignore_index=True)#именаfirstnames = pd.read_csv("interall.csv", header=None)firstnames = firstnames[firstnames[1]>=10000]firstnames = firstnames[[0]]firstnames[0] = firstnames[0].str.lower()firstnames = pd.DataFrame(firstnames[0].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())firstnames.columns = ['word']firstnames["replacement"] = "xfirstnamex"#фамилииластnames = pd.read_csv("intersurnames.csv", header=None)lastnames = lastnames[lastnames[1]>=10000]lastnames = lastnames[[0]]lastnames[0] = lastnames[0].str.lower()lastnames = pd.DataFrame(lastnames[0].apply(lambda x: str(x).replace('-',' ').replace('.',' ').replace('_',' ').replace(',',' ').replace(':',' ').split(" ")).explode())lastnames.columns = ['word']lastnames["replacement"] = "xlastnamex"#месяцы, дни и другие временные названияtemporaldata = pd.read_csv("temporal.csv")#полный словарьdictionary = pd.concat([lastnames,temporaldata,firstnames,currencies,provincies,countries], axis=0, ignore_index=True)dictionary = dictionary.groupby(["word"]).first().reset_index(drop=False)dictionary = dictionary.dropna()maindataset
Это предварительный просмотр исходного набора данных
maindataset
Следующие функции выполняют следующие задачи:
- Заменяют слова с использованием созданного выше словаря
- Удаляют пунктуацию, двойные пробелы и т.д.
def replace_words(tt, lookp_dict): temp = tt.split() res = [] for wrd in temp: res.append(lookp_dict.get(wrd, wrd)) res = ' '.join(res) return resdef preprepare(eingang): ausgang = eingang.lower() ausgang = ausgang.replace(u'\xa0', u' ') ausgang = re.sub(r'^\s*$',' ',str(ausgang)) ausgang = ausgang.replace('|', ' ') ausgang = ausgang.replace('ï', ' ') ausgang = ausgang.replace('»', ' ') ausgang = ausgang.replace('¿', '. ') ausgang = ausgang.replace('', ' ') ausgang = ausgang.replace('"', ' ') ausgang = ausgang.replace("'", " ") ausgang = ausgang.replace('?', ' ') ausgang = ausgang.replace('!', ' ') ausgang = ausgang.replace(',', ' ') ausgang = ausgang.replace(';', ' ') ausgang = ausgang.replace('.', ' ') ausgang = ausgang.replace("(", " ") ausgang = ausgang.replace(")", " ") ausgang = ausgang.replace("{", " ") ausgang = ausgang.replace("}", " ") ausgang = ausgang.replace("[", " ") ausgang = ausgang.replace("]", " ") ausgang = ausgang.replace("~", " ") ausgang = ausgang.replace("@", " ") ausgang = ausgang.replace("#", " ") ausgang = ausgang.replace("$", " ") ausgang = ausgang.replace("%", " ") ausgang = ausgang.replace("^", " ") ausgang = ausgang.replace("&", " ") ausgang = ausgang.replace("*", " ") ausgang = ausgang.replace("<", " ") ausgang = ausgang.replace(">", " ") ausgang = ausgang.replace("/", " ") ausgang = ausgang.replace("\\", " ") ausgang = ausgang.replace("`", " ") ausgang = ausgang.replace("+", " ") ausgang = ausgang.replace("=", " ") ausgang = ausgang.replace("_", " ") ausgang = ausgang.replace("-", " ") ausgang = ausgang.replace(':', ' ') ausgang = ausgang.replace('\n', ' ').replace('\r', ' ') ausgang = ausgang.replace(" +", " ") ausgang = ausgang.replace(" +", " ") ausgang = ausgang.replace('?', ' ') ausgang = re.sub('[^a-zA-Z]', ' ', ausgang) ausgang = re.sub(' +', ' ', ausgang) ausgang = re.sub('\ +', ' ', ausgang) ausgang = re.sub(r'\s([?.!"](?:\s|$))', r'\1', ausgang) return ausgang
Очистка данных словаря
dictionary["word"] = dictionary["word"].apply(lambda x: preprepare(x))dictionary = dictionary[dictionary["word"] != " "]dictionary = dictionary[dictionary["word"] != ""]dictionary = {row['word']: row['replacement'] for index, row in dictionary.iterrows()}
Подготовка текстовых данных для преобразования: создание нового столбца с объединением заголовка (4 раза) и резюме. Это то, что будет преобразовано в векторы. Я делаю это, так как таким образом я придаю большую ценность заголовку, чем самому содержанию статьи.
Затем я заменяю стоп-слова и слова в словаре
maindataset["NLPtext"] = maindataset["title"] + maindataset["title"] + maindataset["content"] + maindataset["title"] + maindataset["title"]maindataset["NLPtext"] = maindataset["NLPtext"].str.lower()maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: preprepare(str(x)))maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: ' '.join([word for word in x.split() if word not in (nltkstop)]))maindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: replace_words(str(x), dictionary))
Последний этап подготовки текста – стемминг. В этом случае это делается, так как я обучаю модель с нуля.
Решение о стемминге зависит от используемой модели. При использовании заранее обученных моделей, например, BERT, это не рекомендуется, так как слова не будут соответствовать словам в их библиотеках.
def steming(sentence): words = word_tokenize(sentence) stems = [snow.stem(whole) для whole in words] oup = ' '.join(stems) return oupmaindataset["NLPtext"] = maindataset["NLPtext"].apply(lambda x: steming(x))maindataset['lentitle'] = maindataset["title"].apply(lambda x: len(str(x).split(' ')))maindataset['lendesc'] = maindataset["content"].apply(lambda x: len(str(x).split(' ')))maindataset['lentext'] = maindataset["NLPtext"].apply(lambda x: len(str(x).split(' ')))maindataset = maindataset[maindataset['NLPtext'].notna()]maindataset = maindataset[maindataset['lentitle']>=4]maindataset = maindataset[maindataset['lendesc']>=4]maindataset = maindataset[maindataset['lentext']>=4]maindataset = maindataset.reset_index(drop=False)maindataset
Наконец, пришло время обучить модель doc2vec.
#рандомизировать набор данныхtrainset = maindataset.sample(frac=1).reset_index(drop=True)#исключить текст, который слишком короткийtrainset = trainset[(trainset['NLPtext'].str.len() >= 5)]#выбрать столбец с текстомtrainset = trainset[["NLPtext"]]#токенизировать и создать набор обучающих данныхtagged_data = []for index, row in trainset.iterrows(): part = TaggedDocument(words=word_tokenize(row[0]), tags=[str(index)]) tagged_data.append(part)#определить модельmodel = Doc2Vec(vector_size=250, min_count=3, epochs=20, dm=1)model.build_vocab(tagged_data)#обучить и сохранитьmodel.train(tagged_data, total_examples=model.corpus_count, epochs=model.epochs)model.save("d2v.model")print("Model Saved")
С целью ограничения размера данных и времени, я буду фильтровать источник новостей.
maindataset.groupby('publication').count()['index']
maindatasetF = maindataset[maindataset["publication"]=="Guardian"]
Теперь я векторизую текстовую информацию для выбранного источника публикации.
a = []for index, row in maindatasetF.iterrows(): nlptext = row['NLPtext'] ids = row['index'] vector = model.infer_vector(word_tokenize(nlptext)) vector = pd.DataFrame(vector).T vector.index = [ids] a.append(vector)textvectors = pd.concat(a)textvectors
Стандартизируйте вложения и выполняйте PCA (уменьшение числа измерений)
def properscaler(simio): scaler = StandardScaler() resultsWordstrans = scaler.fit_transform(simio) resultsWordstrans = pd.DataFrame(resultsWordstrans) resultsWordstrans.index = simio.index resultsWordstrans.columns = simio.columns return resultsWordstransdatasetR = properscaler(textvectors)def varred(simio): scaler = PCA(n_components=0.8, svd_solver='full') resultsWordstrans = simio.copy() resultsWordstrans = scaler.fit_transform(resultsWordstrans) resultsWordstrans = pd.DataFrame(resultsWordstrans) resultsWordstrans.index = simio.index resultsWordstrans.columns = resultsWordstrans.columns.astype(str) return resultsWordstransdatasetR = varred(datasetR)
Теперь я хочу выполнить поиск похожих статей. Найти статьи, похожие на предоставленный пример.
#Найти по индексу и вывести исходный объект поиска
index = 95133
texttofind = maindatasetF[maindatasetF["index"]==index]["title"]
print(str(texttofind))
id = index
print(str(id))
cat = maindatasetF[maindatasetF["index"]==index]["publication"]
print(str(cat))
embdfind = datasetR[datasetR.index==id]
#Вычислить евклидовы попарные расстояния и извлечь наиболее похожие на предоставленный пример
distances = pairwise_distances(X=embdfind, Y=datasetR, metric='euclidean')
distances = pd.DataFrame(distances).T
distances.index = datasetR.index
distances = distances.sort_values(0)
distances = distances.reset_index(drop=False)
distances = pd.merge(distances, maindatasetF[["index","title","publication","content"]], left_on=["index"], right_on=["index"])
pd.options.display.max_colwidth = 100
distances.head(100)[['index',0,'publication','title']]
Мы видим, что извлеченные тексты имеют смысл, они схожи по своей природе с предоставленным примером.
Для кластеризации первый шаг – найти оптимальное количество кластеров. В этот момент мы хотим максимизировать значения силуэта и Калински-Харабаса, при этом сохраняя логичное количество кластеров (не слишком маленькое, чтобы было сложно интерпретировать, и не слишком большое, чтобы было слишком подробно).
#Цикл для проверки моделей и количества кластеров
a = []
X = datasetR.to_numpy(dtype='float')
for ncl in np.arange(2, int(20), 1):
clusterer = Birch(n_clusters=int(ncl))
#поймать предупреждения, которые загромождают вывод
with warnings.catch_warnings():
warnings.simplefilter("ignore")
cluster_labels2 = clusterer.fit_predict(X)
silhouette_avg2 = silhouette_score(X, cluster_labels2)
calinski2 = calinski_harabasz_score(X, cluster_labels2)
row = pd.DataFrame({"ncl": [ncl],
"silKMeans": [silhouette_avg2], "c_hKMeans": [calinski2],
})
a.append(row)
scores = pd.concat(a, ignore_index=True)
#построить результаты
plt.style.use('bmh')
fig, [ax_sil, ax_ch] = plt.subplots(1,2,figsize=(15,7))
ax_sil.plot(scores["ncl"], scores["silKMeans"], 'b-')
ax_ch.plot(scores["ncl"], scores["c_hKMeans"], 'b-')
ax_sil.set_title("Кривые силуэта")
ax_ch.set_title("Кривые Калински-Харабаса")
ax_sil.set_xlabel('кластеры')
ax_sil.set_ylabel('среднее значение силуэта')
ax_ch.set_xlabel('кластеры')
ax_ch.set_ylabel('Калински-Харабас')
ax_ch.legend(loc="upper right")
plt.show()
Я выбираю 5 кластеров и запускаю алгоритм.
ncl_birch = 5
with warnings.catch_warnings():
warnings.simplefilter("ignore")
clusterer2 = Birch(n_clusters=int(ncl_birch))
cluster_labels2 = clusterer2.fit_predict(X)
n_clusters2 = max(cluster_labels2)
silhouette_avg2 = silhouette_score(X, cluster_labels2)
sample_silhouette_values2 = silhouette_samples(X, cluster_labels2)
finalDF = datasetR.copy()
finalDF["cluster"] = cluster_labels2
finalDF["silhouette"] = sample_silhouette_values2
#построить график силуэта
fig, ax2 = plt.subplots()
ax2.set_xlim([-0.1, 1])
ax2.set_ylim([0, len(X) + (n_clusters2 + 1) * 10])
y_lower = 10
for i in range(min(cluster_labels2),max(cluster_labels2)+1):
ith_cluster_silhouette_values = sample_silhouette_values2[cluster_labels2 == i]
ith_cluster_silhouette_values.sort()
size_cluster_i = ith_cluster_silhouette_values.shape[0]
y_upper = y_lower + size_cluster_i
color = cm.nipy_spectral(float(i) / n_clusters2)
ax2.fill_betweenx( np.arange(y_lower, y_upper), 0, ith_cluster_silhouette_values, facecolor=color, edgecolor=color, alpha=0.7, )
ax2.text(-0.05, y_lower + 0.5 * size_cluster_i, str(i))
y_lower = y_upper + 10
ax2.set_title("Силуэтный график для Birch")
ax2.set_xlabel("Значения коэффициента силуэта")
ax2.set_ylabel("Метки кластеров")
ax2.axvline(x=silhouette_avg2, color="red", linestyle="--")
ax2.set_yticks([])
ax2.set_xticks([-0.1, 0, 0.2, 0.4, 0.6, 0.8, 1])
Эти результаты говорят мне о том, что кластер номер 4, возможно, выглядит менее “связанным” по сравнению с остальными. Напротив, кластеры номера 3 и 1 ясно определены. Это пример результатов.
showDF = finalDF.sort_values(['cluster','silhouette'], ascending=[False,False]).groupby('cluster').head(3)showDF = pd.merge(showDF[['cluster','silhouette']],maindatasetF[["index",'title']], left_index=True ,right_on=["index"])showDF
Я вижу, что кластер 4 относится к новостям о технологиях, кластер 3 – к войне / международным событиям, кластер 2 – к развлечениям, кластер 1 – к спорту, и 0, как обычно, это категория, которую можно считать “другим”.