Как выполнить классификацию текста с помощью JavaScript

Несколько месяцев назад я пытался найти информацию о том, как сделать некоторую обработку естественного языка (Natural Language Programming (NLP)) с помощью JavaScript. Ее было не так много. В основном, я натыкался на учебники по тому, как сделать это на Python. Я пишу эту статью в надежде помочь кому-то сделать то же самое с помощью JavaScript. По крайней мере, попытаться. Экосистема JavaScript велика, но машинное обучение в основном делается на Python. Для некоторых специфических (сложных) случаев вы, возможно, решите не использовать JavaScript. Я объясню, почему вы можете так поступить.

Хочу отметить, что я не являюсь инженером по машинному обучению. Я буду рассматривать простые случаи без глубоких объяснений алгоритмов, лежащих в их основе.

Есть приемлемые случаи, когда вы можете попробовать JS-пакеты, которые выполняют классификацию. В некоторых других случаях, если вы понимаете концепции ML, вы можете создавать собственные модели с помощью TensorFlow.js.

Мой случай казался простым. Я хотел классифицировать потенциальные бизнес-проблемы (возможности) для моего инструмента расширенного поиска на Reddit. Я скоро расскажу вам, как все прошло, как только мы рассмотрим инструменты. Давайте начнем с простых случаев.

Natural.js

Это пакет для Node.js, который помогает работать с естественным языком. В нем есть много полезных встроенных помощников. Например, он может выполнять анализ настроений “из коробки” и без какой-либо настройки. Давайте установим его:

$ npm install --save natural

Простой анализ настроений, верно?

const { SentimentAnalyzer, PorterStemmer } = require('natural');

const analyzer = new SentimentAnalyzer("English", PorterStemmer, "afinn");
const result = analyzer.getSentiment(["I", "love", "cakes"]);

console.log(result); // 0.66

Да, это просто. PorterStemmer – это функция трансформации, которая преобразует слова к их корням. Проще говоря, в их первоначальную форму. Мы передаем массив слов в функцию getSentiment, но мы можем использовать встроенные говорители (built-in tokenizers), чтобы сделать это автоматически.

Где обещанная классификация текстов, Лебовский?

Я хотел показать простоту использования, даже не обучаясь каким-то сложным алгоритмам. Теперь давайте посмотрим, как этот пакет справляется с классификацией текста.

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

const { BayesClassifier } = require('natural');

const classifier = new BayesClassifier();

classifier.addDocument('buy our limited offer', 'spam');
classifier.addDocument('grow your audience with us', 'spam');
classifier.addDocument('our company provides a great deal', 'spam');
classifier.addDocument('I like to read books and watch movies', 'regular');
classifier.addDocument('My friend likes to walk near the mall', 'regular');
classifier.addDocument('Pizza was awesome yesterday', 'regular');

classifier.train();

console.log(classifier.classify('we would like to propose our offer')); // spam
console.log(classifier.classify('I\'m feeling tired and want to watch something')); // regular

Обычно требуется большое количество примеров. При небольшом их количестве любой выбранный вами метод (эта библиотека или собственная модель) даст не самые лучшие результаты. Уделяйте огромное внимание своим данным, это основной элемент в классификации текста. Возможно, Natural.js охватит ваш случай, и вы сможете закончить чтение. Если вам нужна более индивидуальная настройка (если вы так думаете, просмотрите свои данные еще раз), читайте дальше.

Brain.js

Эта библиотека поможет вам построить нейронные сети. Natural работает с более простыми алгоритмами. Нейронные сети – это множество алгоритмов, которые работают как единое целое, проще говоря. Они отражают поведение биологических нейронов, которые отлично справляются с распознаванием закономерностей или паттернов (recognizing patterns).

Теперь вы можете настраивать алгоритмы. В частности, вы можете создавать собственные архитектуры нейронных сетей – указывать количество необходимых слоев, функции активации, скорость обучения и другие параметры. Вот здесь все становится сложнее. Не существует “золотых правил” построения архитектур нейронных сетей. Процесс сильно варьируется в зависимости от конкретного случая использования. Мы можем использовать параметры по умолчанию в таких случаях, как определение цвета из параметров RGB:

const brain = require('brain.js');

// Build a default neural net
const net = new brain.NeuralNetwork();

// This is where we specify our data: input and the result(output)
// the data is an array of examples(input and output).
// And then the network trains on them.
net.train([
  // we tell it: if "r" from RGB scheme is 0.03, and "g" is 0.7
  // then the output should be "black"
  { input: { r: 0.03, g: 0.7 }, output: { black: 1 } },
    
  // notice that we skip some values from RGB, in this case we
  // missed "g"
  { input: { r: 0.16, b: 0.2 }, output: { white: 1 } },
    
  // here we point out all the RGB values
  { input: { r: 0.5, g: 0.5, b: 1.0 }, output: { white: 1 } },
]);

// This is how we run the network to get a prediction
const output = net.run({ r: 1, g: 0.4, b: 0 }); // { white: 0.81, black: 0.18 }

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

Преобразование текста в числовые векторы

Теперь мы говорим о нормализации данных. Для классификации текста нам необходимо преобразовать текст в числовые значения, поскольку в Brain.js нет пользовательского потока преобразования данных для обычных нейронных сетей, но вы можете попробовать это сделать, например, для LSTM. Зачем преобразовывать строки в числа? Обучение нейронных сетей – это процесс множества математических вычислений, для которых требуются числа, а не другие типы данных. Вы можете использовать необработанные строки, они будут преобразованы в числовые представления, но не в тот формат, который, вероятно, нужен вам (и алгоритмам). Что делают эти “алгоритмы”? Они выясняют закономерности входных данных, чтобы построить функцию, которая может вычислить выходной результат на основе входных данных. Поэтому важно, как вы выполняете это преобразование.

Первый вариант, который вы можете предложить, – это преобразовать каждый символ в его числовой порядок в алфавите. Например, “a” – 0, “b” – 1, “c” – 2 и так далее. Таким образом, у нас будет 26 возможных значений для каждого символа. То есть слово “автомобиль” можно представить как [2, 0, 17]. В этом случае, если вашей задачей является классификация текста с большим количеством предложений, размерность входных данных становится двумерной, что не очень хорошо, потому что входные данные должны быть одномерными. Мы можем сплющить двумерный массив, но тогда он станет тонким (или другими словами не достаточно информативным для распознавания). Это означает, что текст типа “Я хочу яблок” преобразуется в “яхочуяблок” (а затем в числовой одномерный вектор). Это может быть нормально, но мы не уверены, что сеть распознает в нем паттерн для правильной классификации.

Большая проблема такого подхода заключается в том, что каждый символ воспринимается сетью независимо, а не как слово. Так, “car” – это [2, 0, 17], а результирующая функция (набор функций, обрабатывающих входные данные) может “думать”, что это почти то же самое, что и “bar” – [1, 0, 17]. Конечно, она не думает, но шаблон говорит об этом. Таким образом, трудно извлечь какой-либо контекст, мы просто воспринимаем каждый символ независимо.

Второй вариант – сделать то же самое, но для слов. В действительности мы извлекаем контекст в основном из слов, а не по символам отдельно. Такой подход также упрощает вычисления: нам не нужно преобразовывать двумерный вход в одномерный, и нейронная сеть получает меньше чисел для обработки, что повышает производительность. Чтобы преобразовать слова в числа, мы должны понять, какие числа им присвоить. Вы можете создать примеры текста, на котором будете обучаться, разбить его на слова (опуская пунктуацию, поскольку она не добавляет контекста), составить словарь этих слов, где каждому из них будет присвоен порядковый номер. Это как добавление слов в набор, а их номер – это порядок, в котором они в нем появляются. Например, если у меня есть текст “Я хочу яблок”, мой словарь будет [“я”, “хочу”, “яблоки”], где слову “я” будет присвоен 0, “хочу” – 1, а “яблоки” – 2.

Мы можем оптимизировать этот подход, также приводя слова к их корневой форме, например, “яблоки” становятся “яблоком”, потому что сети не нужно знать (за исключением случаев, когда ваша задача – классифицировать формы единственного или множественного числа), единственная это форма или множественная, лучше иметь числовое представление для абстракции слова – яблоки (“яблоко”, “яблоки”).

Это самый простой метод векторизации текста. Однако и у него есть проблемы. В случаях, когда вам нужно, чтобы нейронная сеть “вычислила” контекст путем поиска набора слов, это сложно, потому что в приведенном примере “я” и “хочу” расположены как соседи (0 и 1 соответственно), но они не похожи, они означают разные вещи. Например, “машина” и “автомобиль” означают одно и то же, но при таком подходе могут быть представлены как 14 и 8233. Таким образом, ваша модель может получить разные результаты в зависимости от того, есть ли в ваших примерах синонимы.

Третий вариант – использовать предварительно сгенерированные векторы. Те, которые были сгенерированы путем обработки большого количества текстов и выведения того, какие слова похожи, а какие отличаются. Так, например, вектор для слова “машина” может быть [0.45, 0.78, 0.97, 0.34, 0.87], а для слова “автомобиль” – [0.49, 0.73, 0.98, 0.33, 0.88]. Как вы заметили, это не отдельные числа, а векторы для каждого слова. Таким образом, вы получаете двумерный массив для всего текста. Я бы посоветовал вам использовать предварительно сгенерированные векторы, например, GloVe.

Возвращаясь к Brain.js

Теперь вы знаете, как преобразовывать строки в векторы, вы можете использовать эту библиотеку для помощи. В ней есть различные типы предопределенных нейронных сетей. Та, которую мы видели раньше, – это нейронная сеть с обратным распространением (feedforward neural net with backpropagation). Здесь тоже есть тонкости – выбор правильного типа сети. Нейронная сеть с прямолинейным движением – это простая сеть, которая принимает входные данные, выполняет некоторые вычисления-трансформации и возвращает результаты. Она видит каждый вход независимо, у нее нет памяти. Это означает, что она не может извлечь контекст из нескольких слов. Если ваша задача требует этого, лучше выбрать рекуррентные нейронные сети, такие как RNN или LSTM (см. подробности о них в Brain.js).

TensorFlow.js

Это путь, когда вы решили, что вам требуется более индивидуальная настройка. Это Javascript-версия мощного фреймворка машинного обучения для Python. Он позволяет строить любые модели или использовать уже созданные сообществом. Однако их не так много. И их функциональность по конвертации Python-моделей в JS-модели и наоборот пока работает недостаточно хорошо.

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

const tf = require('@tensorflow/tfjs-node');

const data = {
    // assume we already have vector representations of the text examples
    inputs: vectorRepresentations,
    // imagine we have such 3 classes
    output: [0, 0, 2, 1, 2, 1, 0, 1],
}

// tensors are TensorFlow vectors to simplify the internal
// processing for the library
const inputTensors = tf.tensor(data.inputs);
const outputTensors = tf.tensor(data.outputs);

const model = tf.sequential();

// 1st layer: a 1d convolutional network
model.add(tf.layers.conv1d({
  filters: 100,
  kernelSize: 3,
  strides: 1,
  activation: 'relu',
  padding: 'valid',
  inputShape: [MAX_WORDS_LENGTH, GLOVE_VECTOR_DIMENSIONS],
}));

// transform 2d input into 1d
model.add(tf.layers.globalMaxPool1d({}));

// the final layer with one neuron
model.add(tf.layers.dense({ units: 1, activation: 'sigmoid' }));

// here are some tuning, read in the TF docs for more
model.compile({
    optimizer: tf.train.adam(LEARNING_RATE),
    loss: 'binaryCrossentropy',
    metrics: ['accuracy'],
});

// print the model architecture
model.summary();

// train the model
await model.fit(inputs, answers, {
    // the default size, how many inputs to process per time
    batchSize: 32,
    
    // how many times to "process", simply put
    epochs: EPOCHS,
    
    // the fraction of the inputs to be in the validation set:
    // the set, which isn't trained on, but participates in calculating
    // the model's metrics such as accuracy and loss
    validationSplit: 0.2,
    
    // shuffle inputs randomly to have a different starting seed every time
    shuffle: true,
});

// save the model to load in the future and run classifications
await model.save('file://./data/models/myFirstModel');

Здесь мы построили модель для классификации текста для 3 псевдоклассов (0, 1, 2). Мы использовали 1d конволюционную сеть для 1-го слоя. TensorFlow позволяет задавать любое количество слоев, задавать периоды обучения, разбиение валидации, выбирать различные алгоритмы ML, функции активации для каждого слоя и многие другие опции. Однако нам необходимо знать, как строить ML-модели. В противном случае мы можем добавлять что угодно, настраивать параметры и не получить хороших результатов.

Я перешел на TensorFlow.js для большей настраиваемости, но потратил месяцы на настройку многих вещей и не получил хороших результатов. Я многому научился на этом пути, но все же я не инженер ML, поэтому лучше (быстрее) использовать модели, созданные профессионалами, а не создавать свое собственное колесо. Но если это для развлечения, то почему бы и нет! Итак, давайте разберемся в коде, который я написал.

Я выбрал эту архитектуру из-за ее производительности: сверточные сети быстрее для обработки текста, а также они обрабатывают входные данные в некотором контексте. Они в основном используются в компьютерном зрении, потому что обрабатывают входные матрицы, а не просто 1d массивы чисел. Так, например, если вы получаете изображение 100×100 px, конволюционная сеть может обрабатывать 5×5 пиксельных окон за раз. Таким образом, некоторые шумы и детали могут быть классифицированы правильно. Для текста почти то же самое – нам нужно взять несколько слов в пакете и не обрабатывать их независимо друг от друга. Таким образом, упрощается работа модели по распознаванию паттернов.

Я выбрал векторные представления GloVe, поэтому мои входные данные представляли собой двумерный массив чисел, где каждый подмассив был представлением слова. Параметр kernelSize в конволюционной сети отвечает за “скользящее окно” – те 5×5 пикселей, которые нужно обработать за раз. В моем случае я указал kernelSize равным 3. Это означает, что сеть обрабатывает 3 вектора (3 слова) за раз. Параметр filters указывает, сколько нейронов вы хотите. strides означает, сколько “шагов” нужно сделать за один раз при перемещении “скользящего окна”. Например, для текста “Завтра я хочу съесть яблоки”, первая партия – [“я”, “хочу”, “чтобы”], вторая партия – [“хочу”, “чтобы”, “есть”], третья – [“чтобы”, “есть”, “яблоки”] и так далее. Таким образом, она перемещается на одно слово вправо.

Итоговые общие рекомендации

Я провел некоторое время с Natural.js, затем с Brain.js и TensorFlow. Я обратился к последнему за пользовательской конфигурацией и потратил много времени на создание собственных моделей. Было бы лучше использовать уже построенную модель для классификации текста. Однако я не нашел хорошего способа преобразовать Python TensorFlow модели в Javascript модели, поэтому в итоге я перешел на Python настройку с HuggingFace. Но моя задача была не такой простой. Я хотел классифицировать потенциальные проблемы и боли людей: когда кто-то ненавидит что-то использовать или жалуется на что-то.

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

Есть много вещей, о которых стоит упомянуть при построении ML-моделей. Вот некоторые из них, которые я выделил в своем журнале. Надеюсь, это сэкономит чье-то время на сужение круга поиска при устранении неполадок.

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

У вас точность 1,0. В большинстве случаев, если у вас 100% точность обучения, вы, вероятно, сильно переоптимизировали модель. Или модель распознала “ложный” паттерн в ваших данных.

Переоптимизация (Overfitting)? Большая тема. Вот некоторые ссылки (не мои, но я не могу найти источник):

If validation loss >> training loss you can call it overfitting.
If validation loss > training loss you can call it some overfitting.
If validation loss < training loss you can call it some underfitting.
If validation loss << training loss you can call it underfitting.

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

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

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

Точность или потери колеблются.

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

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

Следите за размером своего пакета. Иногда его необходимо корректировать:

Меньшие размеры пакета дают шумные градиенты, но они сходятся быстрее, потому что за период у вас больше обновлений. Если размер пакета равен 1, вы будете иметь N обновлений за период. Если он равен N, вы будете иметь только 1 обновление за период. С другой стороны, пакеты большего размера дают более информативный градиент, но они сходятся медленнее и увеличивают вычислительную сложность.

Ответить