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

Существует множество подходов к этой проблеме: от статистических моделей машинного обучения (Logistic, Naive Bays, SVM и т. Д.) До высокопроизводительных моделей глубокого обучения (CNN, RNN, Transformers и т. Д.). В этом блоге рассматриваются практические аспекты (кодирование) построения модели классификации текста с использованием рекуррентной нейронной сети (BiLSTM). Мы будем использовать Python и Jupyter Notebook вместе с несколькими библиотеками для построения модели классификации оскорбительного языка / текста. Он состоит из трех частей.

  1. Подготовка данных
  2. Построение модели
  3. Обучение и оценка

Подготовка данных

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

Загрузите набор данных идентификации оскорбительного языка (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: - Спасибо, что дочитали до этого места. Любые исправления, критика или комплименты приветствуются.