О чем статья

По заказу одного из проектов мне потребовалось сделать агрегатор новостей в Телеграм. Есть список новостных порталов, с которых требовалось собирать новости; после этого необходимо отфильтровать новости по релевантности: убрать рекламные сообщения, в также те, которые по разным причинам не подходили под требования. Сформулировать точные критерии "плохих" новостей было нельзя, но была сделана разметка ("естественным интеллектом", т.е. человеком) их по критерию: 0 - "хорошая", 1 - "плохая". Постоянная фильтрация вручную очень трудоемкий процесс. Поэтому напрашивалась идея реализации автоматической фильтрации на базе машинного обучения.

После долгих поисков реализации (о них ниже в статье) была создана нейронная сеть на базе Keras, которая имела высокое качество, но оказалось, что Keras нельзя было установить на инфраструктуре (просто не было соответствующей сборки) и мне пришлось решать вопрос, как перевести обученную модель в Keras на реализацию, которая не требует установленного Keras. Я не нашел соответствующего материала в Интернет (разве что вот тут автор делал что-то похоже, только для LTSM), поэтому сделал это сам.

Эта статья о том, как я переписал обученную в Keras сеть на работу с матричными операциями в Python Numpy. Заодно это помогло мне "заглянуть под капот" нейронной сети.

Отдельно хотел бы отметить, что код упрощен для наглядности, но в целом он полностью рабочий

Немного о себе

Думаю, тут важно сообщить, что к моменту поступления задачи у меня не было никакого опыта в data science. Был любительский опыт создания Телеграм ботов на Python. Я просто стал смотреть в Интернете, как классификацию текста делают другие и больше всего мне помогла классическая страница от sklearn. Первая "коммерческая" версия была сделана по этому принципу.

И самое главное, что успешный опыт решения этого вопроса привел меня к тому, что я решил попробовать сменить специальность (в которой у меня 20 лет стажа), стать профессиональным data science и спустя пару лет прошел соответствующее профильное обучение.

Выбор модели классификации текста

Целью данной статьи не является разбор методов классификации текста, но без описания пути, который был пройден для создания рабочей модели, статья будет неполной.

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

Классически в задачах машинного обучения (см. CRISP-DM) исходные данные надо: 1) предобработать, 2) подготовить параметры, включая целевой, 3) обучить модель (и наиболее вероятно снова вернуться на первый этап для улучшения качества модели).

В используемых в проекте моделях (кроме, разве что, BERT) обязательно надо надо провести лемматизацию (и, возможно, стемминг) текста, удаление стоп-слов, очистку текста от html-тэгов и разного "мусора" (ведь новости собираются с разных сайтов). В моем проекте используется pymorphy2 для лемматизации, регулярные выражения - для фильтрации всего, кроме текста. Про это в данной статье нет информации - подробного материала в Сети предостаточно.

Кстати, на большинстве новостных сайтов не было RSS-версии (они "местного уровня" - там, возможно, не очень в этом понимают) и мне пришлось активно использовать BeautifulSoup для разбора html-версий сайтов и извлечения оттуда новостей. (Интересно, как большие агрегаторы, типа Google, Yandex, это решают? Пишут под каждый сайт свой парсер?)

У нас несбалансированная выборка - новости с положительным классом составляют 30% от всей выборки, а поэтому разумно применять какой-то метод выравнивания при обучении. Я использовал "upsampling" (дублировал новости с положительным классом) и наглядно убедился, что этот простой метод значительно повышает качество модели.

Реализация на базе TF-IDF и моделей sklearn

В первой и долгое время работающей реализации классификатора мною использовалось TF-IDF для создания векторного представления текста, а именно TfidfVectorizer c параметром max_features = 20, подобранным опытным путем.

Можно много писать про метрики; мне свое время очень помогла статья "Метрики в задачах машинного обучения", и для этой статьи я буду использовать F1, которая в целом более точно оценивает способность модели различать классы.

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

Модель

F1 score

Random Forest

0.82

SGDClassifier

0.82

LogisticRegression

0.81

MultinomialNB

0.69

KNeighborsClassifier

0.80

LGBMClassifier*

0.82

  • - это, конечно, модель не от sklearn, а от LightGBM — она приведена для сравнения качества

Какие выводы можно сделать после анализа моделей на базе TF-IDF?

  1. Все модели в целом имеют одинаковое качество 

  2. Для практической задачи можно выбрать любую из них. Я выбрал в итоге SGDClassifier 

Но поиски более качественной модели не останавливались.

Модели на базе BERT

При исследовании также была протестирована модель на базе BERT (в том числе fine-tunning последнего слоя). В качестве реализации использовалась версия из следующего источника - robert-tiny2, предобученная на большом количестве текстов на русском языке.

Т.е. с помощью BERT проведена векторизация исходного текста без очистки (BERT лояльна к сырому тексту - она обучается как раз на таком), и на основании этих данных обучены модели, которые использовались ранее для TF-IDF.

Метрика F1 в этом случае становилась равной 0,87 - значительно выше, чем при TF-IDF, но развернуть BERT на продуктовой среде не получилось - нет соответствующей версии.

Было принято решение подобрать модель на базе нейронной сети

Классификатор на базе нейронной сети

После изучения материалов, тестирования различных вариантов и конфигураций была выбрана достаточно простая модель нейронной сети, которая показала себя хорошо с точки зрения соотношения качество/ресурсы.  Метрика F1 для нее равна 0,88 - показатель выше, чем было получено ранее. 

Схема модели приведена на рисунке 1

Рисунок 1. Модель нейронной сети для классификации текста
Рисунок 1. Модель нейронной сети для классификации текста

В виде кода данная модель выглядит следующим образом:

vocab_size = 1000 # количество уникальных слов в словаре
embedding_dim = 40 # число параметров после эмбеддинга
max_length = 100 # максимальная длина новости

model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vocab_size, embedding_dim, input_length=max_length),
    tf.keras.layers.GlobalAveragePooling1D(),
    tf.keras.layers.Dense(6, activation='relu'),
    tf.keras.layers.Dense(1, activation='sigmoid')
])

model.compile(loss='binary_crossentropy',optimizer='adam',
              metrics=[tf.metrics.BinaryAccuracy(threshold=0.5)])

num_epochs = 10
history=model.fit(features_train, 
                  training_labels_final, 
                  epochs=num_epochs, 
                  validation_data=(features_valid, testing_labels_final))

Итак мы подобрали, обучили модель и получили нужное качество. Но на целевой системе нет tensorflow, а есть стандартный python 3 и, максимум, библиотека numpy; т.е. мы не может просто сохранить модель и реализовать предсказание, как

predictions = model.predict(news)

Необходимо перевести эту модель на обычные “матричные вычисления” для чего требуется выполнить следующие шаги:

  1. получить веса обученной модели,

  2. понять, как работает каждый этап, 

  3. создать код для расчета предсказания. 

Получение весов и смещений обученной модели

Получить веса i-го слоя можно с помощью следующей команды:

weights = model.layers[i].get_weights()[0]

Смещение (bias), если оно есть в данном слое, получают с помощью команды:

bias = model.layers[i].get_weights()[1]

Проверка обученной модели

Есть очень полезный инструмент для самоконтроля при реализации модели. Можно запустить обученную модель на какой-нибудь выборке и посмотреть, какие промежуточные значения она (модель) рассчитывает на каждом этапе.

from tensorflow import keras
from tensorflow.keras import layers

extractor = keras.Model(inputs=model.inputs,
                        outputs=[layer.output for layer in model.layers])

features = extractor( features_valid[0].numpy().reshape(-1,100))
print(features)

Вывод получает примерно такой:

[<tf.Tensor: shape=(1, 100, 20), dtype=float32, numpy=
array([[[-0.3023754 ,  0.0460441 , -0.03640036, ...,  0.14973998,
          0.04820368, -0.16159618],
        [-0.16039295,  0.25132295, -0.13751882, ...,  0.16573162,
         -0.15154448, -0.0574923 ],
        [-0.3023754 ,  0.0460441 , -0.03640036, ...,  0.14973998,
          0.04820368, -0.16159618],
        ...,
        [-0.3023754 ,  0.0460441 , -0.03640036, ...,  0.14973998,
          0.04820368, -0.16159618],
        [-0.22955681, -0.08269349,  0.13517892, ...,  0.00153243,
          0.13046908, -0.16767927],
        [-0.3023754 ,  0.0460441 , -0.03640036, ...,  0.14973998,
          0.04820368, -0.16159618]]], dtype=float32)>, <tf.Tensor: shape=(1, 20), dtype=float32, numpy=
array([[-0.18203291,  0.11690798, -0.08938053,  0.10450792, -0.09504858,
        -0.08279163,  0.29856998, -0.23120254, -0.2559827 , -0.12028799,
         0.00566523, -0.06708373,  0.05338131, -0.15103005,  0.08447236,
         0.10225956, -0.33394486,  0.15348543, -0.04525973, -0.07986856]],
      dtype=float32)>, <tf.Tensor: shape=(1, 6), dtype=float32, numpy=
array([[1.9048874 , 0.07643622, 1.4660159 , 1.907875  , 0.02882011,
        0.        ]], dtype=float32)>, <tf.Tensor: shape=(1, 1), dtype=float32, numpy=array([[0.0283242]], dtype=float32)>]

Т.е. код выводит результаты работы каждого слоя - можно сверить, верно ли мы реализовали вычисления.

TextVectorization  

TextVectorization - это слой tf.keras.layers, который преобразует текст в числовые тензоры. Он может выполнять стандартизацию, токенизацию и векторизацию текста. Он также может создавать словарь из часто встречающихся слов и отображать их на целочисленные индексы:

В моей реализации он делает следующее (см. рис. 2): 

  1. Назначает всем уникальным словам из текстового корпуса числовой идентификатор (от 2 до числа уникальных слов). В качестве гиперпараметра max_tokens мы указываем максимальное количество уникальных слов - все остальные слова будут обозначаться единицей. 

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

Рисунок 2. Принцип работы модуля TextVectorization
Рисунок 2. Принцип работы модуля TextVectorization

Важно отдельно отметить, что тексты должны быть предобработаны (лемматизация и очистка) перед загрузкой их в TextVectorization

TextVectorization с примером (но помним, что нам надо будет сделать это без использования Keras)

import tensorflow as tf

# определяем TextVectorization. Максимальное кол-во уникальных слов 10, максимальная длина теста - 8 слов
vectorize_layer = tf.keras.layers.TextVectorization(
#     standardize=custom_standardization,
    max_tokens=10,
    output_mode='int',
    output_sequence_length=8)


test_texts=["chatgpt чатбот с искусственный интеллект разработать компания openai и способен работать в диалоговый режим",
            "чатбот нет аналоги в россия разработка"]

vectorize_layer.adapt(test_texts)

features_train = vectorize_layer(test_texts)

print("Преобразованная выборка:", features_train)

print("Словарь. Индекс слова в словаре и есть его числовой идентификатор:", vectorize_layer.get_vocabulary())
Преобразованная выборка: tf.Tensor(
[[1 2 5 1 1 9 1 1]
 [2 1 1 3 6 8 0 0]], shape=(2, 8), dtype=int64)
Словарь. Индекс слова в словаре и есть его числовой идентификатор: ['', '[UNK]', 'чатбот', 'в', 'способен', 'с', 'россия', 'режим', 'разработка', 'разработать']

В реальной задаче размер словаря составляет 1000 слов, максимальная длина текста - 100 слов (средняя длина текста новостей в выборке 187 слов - немного сократим текст)

"Матричная реализация" TextVectorizaion просто встроена в слой embedding. По сути это выбор индекса слова из словаря:

Слои нейронной сети

Сама модель нейронной сети состоит из следующих слоев:

Слой Embedding

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

Embedding layer - это слой tf.keras.layers, который преобразует целочисленные последовательности в плотные векторы. Выходом Embedding layer является трехмерный тензор с формой (batch_size, output_sequence_length, embedding_dim). В отличите от  популярных предобученных эмбеддингов в нашем случае он обучается во время обучения нейронной сети (back-propagation). 

Слой получает на вход выборку из числовых индексов (см. рис. 2), подготовленных TextVectorization, и далее преобразует их с помощью весов. По совету одного из уважаемых читателей (@mahmud-podzhigai) первоначальный код был значительно упрощен и по сути сейчас этот слой это просто выбор из таблицы весов вектора по индексу слова.

В полученной модели размер вектора  40 элементов (это значение было подобрано опытным путем) и после слоя эмбеддинга будет матрица (100, 40).

"Матричная реализация" слоя Embedding:

def embedding(text):
    out = []
    for word in text.split()[:max_length]:
        out.append(emb_weights[vocal_dict.get(word, 1)])
    return np.array(out)

Слой GlobalAveragePooling1D

Этот слой просто усредняет значения матрицы: на входе у него матрица (100, 40), на выходе вектор из 40 элементов.

Код для его реализации следующий:

def avarage(data):
        av_out = np.mean(data, axis=0)
        return av_out

Слой Dense.

Тут тоже достаточно просто - это по сути умножение входного вектора на веса в “нейронах”. В нашем случае их 6 и веса в этом слое имеют размер (40, 6) = (размер эмбеддинга, количество нейронов).

def ReLU(x):
        return x * (x > 0)

def dense_6(data):
        dense_6_out = ReLU(np.dot(data, dense_6_weights) + dense_6_bias)
        return dense_6_out

На выходе имеем вектор из 6 элементов.

Выходной слой

Далее умножаем полученные вектор на вектор весов выходного слоя (плюс смещение) и применяем сигмоиду. 

def sigmoid(data):
    return 1 / (1 + np.exp((-1) * data))

def dense_out(data):
  _dense_out = sigmoid(np.dot(data, weights_out) + self.bias_out[0])
  return _dense_out

Предсказание готово! 

Полный класс предсказателя выглядит следующим образом
class Predictor:
    def __init__(self, emb_weights, dense_6_weights, dense_6_bias,
                 weights_out, bias_out, vocal_dict, vocab_size,
                 max_length, show_intermediate_data=False):

        self.emb_weights = emb_weights
        self.dense_6_weights = dense_6_weights
        self.dense_6_bias = dense_6_bias
        self.weights_out = weights_out
        self.bias_out = bias_out
        self.max_length = max_length
        self.vocab_size = vocab_size
        self.show_data = show_intermediate_data
        self.vocal_dict = {vocal_dict[k]: k for k in range(self.vocab_size)} 
        self.zero_line=[1]+ [0] * (self.vocab_size-1)
       

    def predict(self, x):
        results = []
        for sentanence in x:
            emb_out = self.embedding(self.text_to_numbers(sentanence))
            out_avarage = self.avarage(emb_out)
            out_dense_6 = self.dense_6(out_avarage)
            results.append(self.dense_out(out_dense_6))
        return np.array(results)

    def embedding(self, text):
        out = []
        for word in text.split()[:self.max_length]:
            out.append(self.emb_weights[self.vocal_dict.get(word, 1)])
        return np.array(out)
    
    def avarage(self, data):
        av_out = np.mean(data, axis=0)

        if self.show_data:
            print(f'avarage out:{av_out}')

        return av_out

    def dense_6(self, data):
        dense_6_out = self.ReLU(np.dot(data, self.dense_6_weights) + self.dense_6_bias)

        if self.show_data:
            print(f'Dense 6 out:{dense_6_out}')

        return dense_6_out

    def dense_out(self, data):
        _dense_out = self.sigmoid(np.dot(data, self.weights_out) + self.bias_out[0])

        if self.show_data:
            print(f'Final out:{_dense_out}')

        return _dense_out


    def ReLU(self, x):
        return x * (x > 0)

    def sigmoid(self, data):
        return 1 / (1 + np.exp((-1) * data))

config_dict={
    
    'emb_weights':model.layers[0].get_weights()[0].tolist(),
    'dense_6_weights':model.layers[2].get_weights()[0].tolist(),
    'dense_6_bias': model.layers[2].get_weights()[1].tolist(),
    'weights_out':model.layers[3].get_weights()[0].tolist(),
    'bias_out':model.layers[3].get_weights()[1].tolist(),
    "vocab_size":vocab_size,
    "max_length":max_length,
    "vocal_dict":vectorize_layer.get_vocabulary()
    
}

# Использование
predictor=Predictor(**config_dict, show_intermediate_data=False) 

prediction = predictor.predict(testing_sentences)

Сохранение (после обучения) и загрузку (на продуктовой среде) конфигурации я делаю с помощью следующего кода

import json

# сохранить  config
with open('./data/config.json', 'w') as fp:
    json.dump(config_dict, fp)
import json

# загрузить config
with open('./data/config.json', 'r') as fp:
    config_dict = json.load(fp)
config_dict

Заключение

В результате я смог перевести обученную модель Keras на работу со "стандартными" библиотеками Python без необходимости установки Keras/Tensorflow на продуктовую среду. Это позволило использовать её в продуктовой среде.

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

Комментарии (6)


  1. masai
    00.00.0000 00:00

    А почему не взяли, например, ONNX + ONNX Runtime или OpenVINO (он ещё и ускорит всё на процессорах Intel)?


    1. vova_sam Автор
      00.00.0000 00:00

      Так я все равно не смогу все это развернуть на целевом железе.


      1. masai
        00.00.0000 00:00

        Не очень понятно, какие ограничения. Numpy же получилось развернуть.


        1. vova_sam Автор
          00.00.0000 00:00

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

          но спасибо за, что обратили внимание на эти инструменты


  1. mahmud-podzhigai
    00.00.0000 00:00
    +6

    Возник вопрос по вашей "матричной реализации" слоя embedding: а зачем вам там вообще перемножение? Умножение one-hot на матрицу просто вернет тот эмбеддинг, позиция которого соответствует позиции единички. Можно же просто обращаться в массив эмбеддиннгов по индексу слова, без one-hot вообще, разве нет?

    Функция должна выглядеть как-то так:

    return emb_weights[data],

    где data - матрица до one-hot размером 100 (а не 100х1000, как после one-hot), emb_weights - матрица 1000x40


    1. vova_sam Автор
      00.00.0000 00:00

      Не понимаю, куда пропал прошлый комментарий.
      Спасибо больше за этот совет. Действительно я делал в принципе лишнее действие.

      Все исправил.