TaLLeR43
Админы мастера

Когда-то меня очень радовал один паблик в соцсети ВК. По заявлениям администрации нейросеть генерировала рецепты, которые и составляли 99% контента. Вероятно, действительно это была простенькая нейросеть вроде RNN или LSTM. К сожалению, последний пост в паблике датирован 2019 годом, а моя тяга к изысканным блюдам не угасла, поэтому было решено сделать генератор рецептов на JS и цепях Маркова. Почему не повторить эксперимент с более продвинутой доступной нейросетью вроде GPT-2? Потому что для ее обучения требуется достаточно много времени, ресурсов и данных.
Чтобы генерировать рецепты, мы будем использовать цепи Маркова — математическую модель, которая может предсказывать следующий элемент в последовательности на основе предыдущих. Для начала нам нужно собрать корпус данных — набор рецептов на определенную кухню. Затем мы обучим цепь Маркова на этом корпусе данных и будем генерировать новые рецепты на основе полученной модели. Да, про цепи Маркова было достаточно много статей и на Хабре, и вне его. Но меня восхищает простота реализации этого алгоритма, а результаты генерации веселят. Мы будем использовать простую реализацию, чтобы получить быстрый результат, а в конце статьи будут приведены лучшие из сгенерированных рецептов.
Готовим корпус
Когда-то у меня уже был собран датасет на 3000~ строк из кучи рецептов. Если мне не изменяет память, это результат парсинга одной из кулинарных групп в ВК. В txt файле все рецепты разделены пустыми строками.
Синхронно считаем данные, приведем к строке, и, разделим ее на массив абзацев по пустым строкам с помощью \n\n.
// index.js
const fs = require('fs')
const corpus = fs.readFileSync('./data.txt').toString().split('\n\n')
console.log(corpus)
> node index.js
> [
'Хочу поделиться рецептом приготовления оладушек на сметане, в состав которых не входят яйца. Оладьи пышные, нежные, безумно вкусные. Вам и вашим близким обязательно придется по вкусу этот рецепт.\n' +
'Для приготовления оладьев на сметане вам потребуется:\n' +
'120 г сметаны;\n' +
'120 мл кефира;\n' +
'0,5 ч. л. соды;\n' +
'2-3 ст. л. сахара;\n' +
'0,5 ч. л. соли;\n' +
'150 г муки;\n' +
'масло для жарки.',
'Кефир смешать со сметаной и содой, оставить на 5-10 минут.',
'Добавить сахар и соль, перемешать.',
'Просеять муку в тесто.',
'Перемешать, долго не месить.',
'Жарить оладьи на масле обычным способом.',
... 560 more items
]
Корпус готов!
Разбираемся с Марковым
Как и упоминалось в введении, будем использовать математическую модель цепей Маркова. Это модель, которая предсказывает следующий элемент в последовательности на основе предыдущих. В контексте генерации рецептов на основе цепей Маркова мы будем использовать модель, которая будет предсказывать следующее слово в рецепте на основе предыдущих слов. Для этого мы будем использовать статистический подход, который будет анализировать частоту встречаемости слов в корпусе данных и использовать эту информацию для генерации новых рецептов.
Для примера возьмем два заголовка, которые будут условным корпусом: “Тосты с сельдью и огурцом” и “Тосты с анчоусами и грецкими орехами”
Представим матрицу переходов для этих предложений:
key | value |
---|---|
START | Тосты |
Тосты | с |
с | сельдью / анчоусами |
сельдью | и |
анчоусами | и |
и | огурцом / грецкими |
огурцом | END |
грецкими | орехами |
орехами | END |
END |

Следуя этой матрице, после слова “Тосты” с вероятностью 100% будет идти “с”, а вот после “с” с вероятностью в 50% может идти либо “сельдью”, либо “анчоусами”. Очевидно, что чем больше корпус — тем больше вариантов и тем больше статистический разброс.
Реализация
Для начала соберем объект токенов в конструкторе класса генератора. Знаки препинания будут включаться в токены, а регистр букв останется оригинальным. Во-первых, это упростит токенизацию, во-вторых сделает абзацы более корректными.
Изначально tokens будет содержать ключ START для сбора стартовых слов. В процессе итеративно пройдем по всем элементам корпуса, разделив их по пробелу. Далее, работая с каждым словом по отдельности, будем добавлять их в качестве ключей в tokens, а следующее слово помещать в массив свойства этого ключа. Если же следующего слова нет, будет помещаться ключевое слово END, которое в дальнейшем будет сигнализировать генератору о том, что абзац сформирован.
// markov.js
export default class Markov {
tokens = {
START: []
};
constructor(corpus) {
corpus.forEach(element => {
const words = element.split(' ');
words.forEach((word, index, arr) => {
const nextWord = arr[index + 1] || 'END';
if (index === 0) {
this.tokens.START.push(word)
}
if (this.tokens[word]) {
this.tokens[word].push(nextWord);
}
else {
this.tokens[word] = [nextWord];
}
})
});
}
Если залогировать получившийся объект tokens, он будет иметь такой вид:
{
/* ... */
'хлебом': [ 'через' ],
'необходимости': [ 'влить' ],
'шарики,': [ 'обвалять', 'разложить' ],
'муке': [
'(20', 'и', 'и',
'и', 'и', '(30',
'и', 'и'
],
'(20': [ 'г)', 'г)' ],
/* ... */
}
Вы можете заметить, что токены могут повторяться. Мы их оставляем в таком виде, чтобы сохранить статистические вероятности. Например, после токена ‘муке’ с вероятностью в 75% будет идти ‘и’, а ‘(20’ или ‘(30’ с вероятностью в 7.5% соответственно.
Для генерации нового текста берем случайное стартовое слово. После, в цикле while, выбираем случайные слова для текущего токена и вставляем их в массив результата, пока не наткнемся на END. В конце возвращаем результат в виде строки, соеденив элементы массива пробелами.
// markov.js
export default class Markov {
tokens = {
START: []
};
/* ... */
generate() {
const startWords = this.tokens.START;
let picked = startWords[Math.floor(Math.random() * startWords.length)];
const result = [];
while (picked !== 'END') {
result.push(picked);
const currentTokens = this.tokens[picked];
picked = currentTokens[Math.floor(Math.random() * currentTokens.length)];
}
return result.join(' ');
}
}
В конце концов, можно протестировать:
// index.js
const fs = require('fs')
const Markov = require('./markov')
const corpus = fs.readFileSync('./data.txt').toString().split('\r\n\r\n')
const markov = new Markov(corpus)
console.log(markov.generate())
> node index.js
> Кефир — 1 б. (можно больше)
1-1,5 чайная ложка.
Готовим: Плавленный сыр и вкусом ваших родных и положить 2 шт.
Мука пшеничная (стакан 250 градусов.
Далее духовку на кусочки размером с мясом к муке с картофелем, готов.
Приятного аппетита, радуйте своих близких!
Пирог "Подсолнух" украсит любой крем, джем, шоколадно-ореховая паста.
ПРИЯТНОГО ЧАЕПИТИЯ!