Классификация текстов - одна из фундаментальных задач НЛП. Почти каждая система НЛП использует классификацию текста где-то в своей бэкэнде. Например - классификатор намерений чат-бота, распознавание именованных сущностей, автоматическая пометка и т. Д.
Существует множество подходов к этой проблеме: от статистических моделей машинного обучения (Logistic, Naive Bays, SVM и т. Д.) До высокопроизводительных моделей глубокого обучения (CNN, RNN, Transformers и т. Д.). В этом блоге рассматриваются практические аспекты (кодирование) построения модели классификации текста с использованием рекуррентной нейронной сети (BiLSTM). Мы будем использовать Python и Jupyter Notebook вместе с несколькими библиотеками для построения модели классификации оскорбительного языка / текста. Он состоит из трех частей.
- Подготовка данных
- Построение модели
- Обучение и оценка
Подготовка данных
Выбор правильного набора данных - это ключ к созданию современной модели, но ее поиск / создание - сложная задача. К счастью, у нас есть готовый набор данных для нашей задачи, выпущенный Гарвардским университетом.
Загрузите набор данных идентификации оскорбительного языка (OLID) отсюда. Вы также можете использовать любой другой набор данных для этой задачи. Давайте посмотрим на набор данных с помощью библиотеки Pandas.
import pandas as pd url = 'olid-training-v1.0.tsv' df = pd.read_csv(url, sep="\t") df.head()
Этот набор данных представляет собой набор твитов, классифицированных как оскорбительные (ВЫКЛ.) И не оскорбительные (НЕ) в столбце «subtask_a». Нам нужен только столбец «tweet» и «subtask_a», переименованный в «label».
del df['subtask_b'] del df['subtask_c'] del df['id'] df.columns = ['tweet', 'label'] df.head()
Следующим этапом подготовки данных является обработка твитов. Для этого мы будем использовать библиотеки NLTK, BS4, Contractions и Regex (re). Импортируем библиотеки и напишем необходимые функции. Цель каждой функции комментируется в самой функции. Первая функция - Noise_text - используется для удаления шума из текста.
#importing required libraries import nltk import inflect import contractions from bs4 import BeautifulSoup import re, string, unicodedata from nltk import word_tokenize, sent_tokenize from nltk.corpus import stopwords from nltk.stem import LancasterStemmer, WordNetLemmatizer from sklearn.preprocessing import LabelEncoder # First function is used to denoise text def denoise_text(text): # Strip html if any. For ex. removing <html>, <p> tags soup = BeautifulSoup(text, "html.parser") text = soup.get_text() # Replace contractions in the text. For ex. didn't -> did not text = contractions.fix(text) return text # Check the function sample_text = "<p>he didn't say anything </br> about what's gonna <html> happen in the climax" denoise_text(sample_text)
Следующая функция - normalize_text, которая включает в себя много шагов. Для каждого шага пишется отдельная функция, а затем они компилируются в одну функцию.
# Text normalization includes many steps. # Each function below serves a step. def remove_non_ascii(words): """Remove non-ASCII characters from list of tokenized words""" new_words = [] for word in words: new_word = unicodedata.normalize('NFKD', word).encode('ascii', 'ignore').decode('utf-8', 'ignore') new_words.append(new_word) return new_words def to_lowercase(words): """Convert all characters to lowercase from list of tokenized words""" new_words = [] for word in words: new_word = word.lower() new_words.append(new_word) return new_words def remove_punctuation(words): """Remove punctuation from list of tokenized words""" new_words = [] for word in words: new_word = re.sub(r'[^\w\s]', '', word) if new_word != '': new_words.append(new_word) return new_words def replace_numbers(words): """Replace all interger occurrences in list of tokenized words with textual representation""" p = inflect.engine() new_words = [] for word in words: if word.isdigit(): new_word = p.number_to_words(word) new_words.append(new_word) else: new_words.append(word) return new_words def remove_stopwords(words): """Remove stop words from list of tokenized words""" new_words = [] for word in words: if word not in stopwords.words('english'): new_words.append(word) return new_words def stem_words(words): """Stem words in list of tokenized words""" stemmer = LancasterStemmer() stems = [] for word in words: stem = stemmer.stem(word) stems.append(stem) return stems def lemmatize_verbs(words): """Lemmatize verbs in list of tokenized words""" lemmatizer = WordNetLemmatizer() lemmas = [] for word in words: lemma = lemmatizer.lemmatize(word, pos='v') lemmas.append(lemma) return lemmas def normalize_text(words): words = remove_non_ascii(words) words = to_lowercase(words) words = remove_punctuation(words) words = replace_numbers(words) words = remove_stopwords(words) #words = stem_words(words) words = lemmetize_verbs(words) return words # Testing the functions print("remove_non_ascii results: ", remove_non_ascii(['h', 'ॐ', '©', '1'])) print("to_lowercase results: ", to_lowercase(['HELLO', 'hiDDen', 'wanT', 'GOING'])) print("remove_punctuation results: ", remove_punctuation(['hello!!', 'how?', 'done,'])) print("replace_numbers results: ", replace_numbers(['1', '2', '3'])) print("remove_stopwords results: ", remove_stopwords(['this', 'and', 'amazing'])) print("stem_words results: ", stem_words(['beautiful', 'flying', 'waited'])) print("lemmatize_verbs results: ", lemmatize_verbs(['hidden', 'walking', 'ran'])) print("normalize_text results: ", normalize_text(['hidden', 'in', 'the', 'CAVES', 'he', 'WAited', '2', 'ॐ', 'hours!!']))
Мы видим, что каждая функция работает правильно. Вы могли заметить, что каждая функция принимает в качестве аргумента список токенов (слов). Итак, нам нужна функция для токенизации текста. Для этого мы будем использовать word_tokenize из NLTK.
# Tokenize tweet into words def tokenize(text): return nltk.word_tokenize(text) # check the function sample_text = 'he did not say anything about what is going to happen' print("tokenize results :", tokenize(sample_text))
Это тоже отлично работает. Теперь у нас есть все функции, необходимые для обработки твитов. Давайте применим их к нашему набору данных с помощью функции text_prepare. На этом этапе мы также будем кодировать нашу целевую переменную LabelEncode.
def text_prepare(text): text = denoise_text(text) text = ' '.join([x for x in normalize_text(tokenize(text))]) return text df['tweet'] = [text_prepare(x) for x in df['tweet']] le = LabelEncoder() df['label'] = le.fit_transform(df['label']) df.head()
Ура! Выглядит отлично. Мы закончили этап подготовки данных. Обратите внимание, что я не использовал функцию stem_words при нормализации текста, поскольку она приводит к лучшим результатам в данном конкретном случае. Перейдем к моделированию.
Построение модели
Построение модели - важный шаг, но благодаря структурам глубокого обучения, которые упростили его. Мы будем использовать библиотеку Keras для построения рекуррентной нейронной сети на основе двунаправленных LSTM. О LSTM читайте здесь. Эти модели принимают вложения слов в качестве входных данных, поэтому мы будем использовать предварительно обученные вложения GloVe для создания словаря встраивания. Скачайте вложения перчаток отсюда.
Перед этим нам сначала нужно разметить весь текст и превратить их в последовательности, в которых каждому слову присваивается целое число. Кроме того, все последовательности должны быть одинаковой длины, поэтому нам нужно заполнить последовательности. Давайте напишем функцию, которая будет принимать X_train, X_test, MAX_NB_WORDS (максимальное количество слов в словаре), MAX_SEQUENCE_LENGTH (максимальная длина текстовых последовательностей) в качестве входных данных и будет выполнять вышеупомянутые шаги для построения встраиваемого словаря. Сначала импортируйте все необходимые библиотеки.
from keras.layers import Dropout, Dense, Embedding, LSTM, Bidirectional from keras.preprocessing.text import Tokenizer from keras.preprocessing.sequence import pad_sequences from keras.models import Sequential from sklearn.metrics import matthews_corrcoef, confusion_matrix from sklearn.model_selection import train_test_split from sklearn import metrics from sklearn.utils import shuffle import numpy as np import pickle import matplotlib.pyplot as plt import warnings import logging logging.basicConfig(level=logging.INFO)
Теперь реализуйте функцию prepare_model_input, как показано ниже. Это возвращает X_train_Glove, X_test_Glove, word_index (слово, присвоенное целыми числами) и embedding_dict.
def prepare_model_input(X_train, X_test,MAX_NB_WORDS=75000,MAX_SEQUENCE_LENGTH=500): np.random.seed(7) text = np.concatenate((X_train, X_test), axis=0) text = np.array(text) tokenizer = Tokenizer(num_words=MAX_NB_WORDS) tokenizer.fit_on_texts(text) # pickle.dump(tokenizer, open('text_tokenizer.pkl', 'wb')) # Uncomment above line to save the tokenizer as .pkl file sequences = tokenizer.texts_to_sequences(text) word_index = tokenizer.word_index text = pad_sequences(sequences, maxlen=MAX_SEQUENCE_LENGTH) print('Found %s unique tokens.' % len(word_index)) indices = np.arange(text.shape[0]) # np.random.shuffle(indices) text = text[indices] print(text.shape) X_train_Glove = text[0:len(X_train), ] X_test_Glove = text[len(X_train):, ] embeddings_dict = {} f = open("glove.6B.50d.txt", encoding="utf8") for line in f: values = line.split() word = values[0] try: coefs = np.asarray(values[1:], dtype='float32') except: pass embeddings_dict[word] = coefs f.close() print('Total %s word vectors.' % len(embeddings_dict)) return (X_train_Glove, X_test_Glove, word_index, embeddings_dict) ## Check function x_train_sample = ["Lorem Ipsum is simply dummy text of the printing and typesetting industry", "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout"] x_test_sample = ["I’m creating a macro and need some text for testing purposes", "I’m designing a document and don’t want to get bogged down in what the text actually says"] X_train_Glove_s, X_test_Glove_s, word_index_s, embeddings_dict_s = prepare_model_input(x_train_sample, x_test_sample, 100, 20) print("\n X_train_Glove_s \n ", X_train_Glove_s) print("\n X_test_Glove_s \n ", X_test_Glove_s) print("\n Word index of the word testing is : ", word_index_s["testing"]) print("\n Embedding for thw word want \n \n", embeddings_dict_s["want"])
Все работает нормально. Теперь давайте реализуем вспомогательную функцию build_bilstms, которая будет возвращать модель BiLSTM. Мы будем использовать слои Embedding, Dense, Dropout, LSTM, Bidirectional из keras.layers для построения последовательной модели.
def build_bilstm(word_index, embeddings_dict, nclasses, MAX_SEQUENCE_LENGTH=500, EMBEDDING_DIM=50, dropout=0.5, hidden_layer = 3, lstm_node = 32): # Initialize a sequebtial model model = Sequential() # Make the embedding matrix using the embedding_dict embedding_matrix = np.random.random((len(word_index) + 1, EMBEDDING_DIM)) for word, i in word_index.items(): embedding_vector = embeddings_dict.get(word) if embedding_vector is not None: # words not found in embedding index will be all-zeros. if len(embedding_matrix[i]) != len(embedding_vector): print("could not broadcast input array from shape", str(len(embedding_matrix[i])), "into shape", str(len(embedding_vector)), " Please make sure your" " EMBEDDING_DIM is equal to embedding_vector file ,GloVe,") exit(1) embedding_matrix[i] = embedding_vector # Add embedding layer model.add(Embedding(len(word_index) + 1, EMBEDDING_DIM, weights=[embedding_matrix], input_length=MAX_SEQUENCE_LENGTH, trainable=True)) # Add hidden layers for i in range(0,hidden_layer): # Add a bidirectional lstm layer model.add(Bidirectional(LSTM(lstm_node, return_sequences=True, recurrent_dropout=0.2))) # Add a dropout layer after each lstm layer model.add(Dropout(dropout)) model.add(Bidirectional(LSTM(lstm_node, recurrent_dropout=0.2))) model.add(Dropout(dropout)) # Add the fully connected layer with 256 nurons and relu activation model.add(Dense(256, activation='relu')) # Add the output layer with softmax activation since we have 2 classes model.add(Dense(nclasses, activation='softmax')) # Compile the model using sparse_categorical_crossentropy model.compile(loss='sparse_categorical_crossentropy', optimizer='adam', metrics=['accuracy']) return model
Мы реализовали вспомогательную функцию для построения модели. Давайте построим реальную модель для нашей задачи.
X = df.tweet y = df.label X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2) print("Preparing model input ...") X_train_Glove, X_test_Glove, word_index, embeddings_dict = prepare_model_input(X_train,X_test) print("Done!") print("Building Model!") model = build_bilstm(word_index, embeddings_dict, 2) model.summary()
Ура снова! мы закончили с частью построения модели. Обучим и оценим модель.
Обучение и оценка
Обучение - это часть, где можно поэкспериментировать и найти лучшие гиперпараметры для своей модели. Во-первых, давайте реализуем некоторые служебные функции для обучения и проверки производительности модели.
def get_eval_report(labels, preds): mcc = matthews_corrcoef(labels, preds) tn, fp, fn, tp = confusion_matrix(labels, preds).ravel() precision = (tp)/(tp+fp) recall = (tp)/(tp+fn) f1 = (2*(precision*recall))/(precision+recall) return { "mcc": mcc, "true positive": tp, "true negative": tn, "false positive": fp, "false negative": fn, "pricision" : precision, "recall" : recall, "F1" : f1, "accuracy": (tp+tn)/(tp+tn+fp+fn) } def compute_metrics(labels, preds): assert len(preds) == len(labels) return get_eval_report(labels, preds) def plot_graphs(history, string): plt.plot(history.history[string]) plt.plot(history.history['val_'+string], '') plt.xlabel("Epochs") plt.ylabel(string) plt.legend([string, 'val_'+string]) plt.show()
Теперь мы готовы к обучению модели. Мы обучим нашу модель 5 эпох с размером партии 128.
history = model.fit(X_train_Glove, y_train, validation_data=(X_test_Glove,y_test), epochs=5, batch_size=128, verbose=1)
Что ж, точность, достигнутая нашей моделью, кажется хорошей, но давайте посмотрим на график потерь и точности, чтобы ясно увидеть, что происходит.
plot_graphs(history, 'accuracy') plot_graphs(history, 'loss')
На графике зависимости точности от эпох мы видим, что точность проверки поддерживается на уровне 0,74, тогда как точность обучения постоянно увеличивается. На графике потери в зависимости от эпохи потери при проверке также поддерживаются на уровне около 0,50, в то время как потери при обучении непрерывно снижаются. Это признак небольшого переобучения. Мы можем уменьшить сложность модели, увеличить вероятность отсева и использовать регуляризацию для уменьшения переобучения. Давайте, наконец, оценим модель, которую мы только что обучили.
print("\n Evaluating Model ... \n") predicted = model.predict_classes(X_test_Glove) print(metrics.classification_report(y_test, predicted)) print("\n") logger = logging.getLogger("logger") result = compute_metrics(y_test, predicted) for key in (result.keys()): logger.info(" %s = %s", key, str(result[key]))
Мы видим, что, несмотря на небольшую переоснащенность, модель неплохо работает на тестовых данных с точностью 75% и баллом F1 0,62.
Наконец-то Ура! мы успешно построили и обучили модель BiLSTM для классификации текста. Престижность! Найдите все коды в этом репозитории GitHub.
Необязательно -: вы можете рассмотреть возможность сохранения модели и токенизатора в виде файла .pkl для целей развертывания. (Следующая часть этого блога)
#To save the tokenizer follow instructions in prepare_model_input function i.e. uncomment this line #pickle.dump(tokenizer, open('text_tokenizer.pkl', 'wb')) in that function # To save the model run this line pickle.dump(model, open('model.pkl', 'wb')) # you are ready for deployment!
PS: - Спасибо, что дочитали до этого места. Любые исправления, критика или комплименты приветствуются.