В этой статье я расскажу о том, почему нормально иногда делать анализ данных в браузере.
В чем суть?
На своей работе в качестве React Front-end разработчика я обычно работаю с дашбордами и различными видами данных. В какой-то момент нам понадобилось добавить предсказания по метрикам, а в команде не было специалистов по анализу данных, которые могли бы этим заняться.
Наш стек - это React + Java.
Проблема 1
Очень большой объем данных для предсказания и малое количество записей - тысячи возможных срезов данных, но малое количество исторических данных.
Проблема 2
Очень большая нагрузка на ребят из бэкенда, так что они физически не могли справиться с этой задачей. Ограниченная квота Java инстансов в компании на проект. Все эксперты заняты, согласовывать долго, делать долго, ждать бекенд долго.
Поэтому мы решили сделать предсказание рядов на стороне клиента - в браузере. Мы ж фронтендеры!
Проверим, что ряды вообще можно предсказать
Для этого загоним данные в эксель и посмотрим на результаты функции FORECAST.ETS()
. Наши сезонные прогнозы выглядят правдоподобно. Мы проверили, что на наших данных реально получить что-то адекватное, поэтому можно теперь искать JS-либы для предсказаний!
Прогнозы рядов на JS
Если решились делать предсказания на фронте (и экономить время бекендеров), то нужно найти что-то готовенькое, а не делать предсказания с нуля.
Я экспериментировал с моделью Tensorflow.js RNN из этой статьи, но она требует много времени для обучения на заданном наборе данных, сам набор данных должен быть достаточно большим, предсказание тоже не быстрое. Короче, нам она не подошла: у нас 1000+ рядов из 40-50 записей в каждом.
Быстро найти норм реализацию ARIMA в JS не удалось, зато нашли либу Nostradamus, где реализован алгоритм экспоненциациального сглаживания Холта-Уинтерса.
Найденная либа работает достаточно удобно:
predict = (
data,
a = 0.95,
b = 0.4,
g = 0.2,
p = this.PERIODS_TO_PREDICT,
) => {
const alpha = a;
const beta = b;
const gamma = g;
const predictions = forecast(data, alpha, beta, gamma, this.OBSERVATIONS_PER_SEASON, p);
return predictions;
};
Функция Forecast возвращает массив элементов, где последние p элементов являются предсказанными значениями. Чисто и просто.
Но это не конец
Было бы как-то очень слабо закончить статью на этом месте. Добавлю подводные камни, которые замедлили интеграцию client-side предсказаний в проект:
У этого алгоритма есть ограничение, которое может оказаться довольно весомым: мы не можем прогнозировать дальше, чем на количество элементов в одном “сезоне”. То есть если, к примеру, мы прогнозируем продажи книг по месяцам год к году, то в таком случае мы не можем предсказывать дальше, чем на 12 месяцев.
Помимо пункта 1, у нас есть еще один лимит - у нас должно быть по меньшей мере 2 полных сезона с данными. Если взять тот же пример с книгами, то мы должны знать количество проданных книг в месяц за последние 24 месяца (2 года).
Иногда бывает так, что в рамках проекта мы предсказываем разные метрики, которые, очевидно, друг с другом не связаны. А это значит, что коэффиценты (альфа/гамма/бета) от одной метрики не подойдут к другой и нам надо вычислять их динамически. В этом случае мы вычисляем значение ошибки для разных показателей и в конце выбираем набор с наименьшей ошибкой (сниппет с примером такого вычисления будет как бонус в конце статьи). Очевидно, что это влияет на производительность, но в нашем случае это было незначительно.
Нам нужно такое количество записей, чтобы оно нацело делилось на размер сезона. Если сезон - это год, и в нем 12 записей (месяцев), то для прогноза нужно брать, например, 24/36/48 записей.
И еще одно: я не понял, в чем дело, но имея один набор исторических данных и разное количество записей, которые мы собираемся предсказать (например, есть история за 2 года, а предсказать хотим то на 3 месяца вперед, то на 12), мы получим разные прогнозы. Нам нужно было считать на 3 месяца вперед, поэтому я сделал еще один лайфхак - считал ошибку для обоих случаев и выбирал тот, в котором ошибка меньше.
Бонус - код для расчета ошибок и подгона параметров
const adjustParams = (period) => {
const iter = 10;
const incr = 1 / iter;
let bestAlpha = 0.0;
let bestError = -1;
let alpha = bestAlpha;
let bestGamma = 0.0;
let gamma = bestGamma;
let bestDelta = 0.0;
let delta = bestDelta;
while (alpha < 1) {
while (gamma < 1) {
while (delta < 1) {
const pred = this.predict(data, alpha, delta, gamma, period);
const error = this.computeMeanSquaredError(data, pred);
if (error < bestError || bestError === -1) {
bestAlpha = alpha;
bestGamma = gamma;
bestDelta = delta;
bestError = error;
}
delta += incr;
}
delta = 0;
gamma += incr;
}
gamma = 0;
alpha += incr;
}
alpha = bestAlpha;
gamma = bestGamma;
delta = bestDelta;
return {
alpha,
gamma,
delta,
bestError,
};
};
Бонус 2
После публикции на Medium мне написали несколько человек с просьбой проконсультировать их подробнее на этот счёт и по итогу у меня собрался репозиторий-песочница, в которой можно поковырять как это работает. Код проекта.
А какие вы задачи решали на стороне клиента? Напишите свою историю в комментариях!
Перевел: Даниил Охлопков.
debagger
На приведенных скриншотах прогнозы какие-то странные. На первом видно четкое монотонное убывание, а предсказание показывает на следующей точке резкий рост. Со вторым скриншотом тоже непонятно — второй график сверху — шел монотонный рост, который потом замедлился, а предсказание показывает какой-то дикий расколбас.
Может я что-то не понял конечно, просветите, почему так?
Hennessy811
Да, так действительно могло показаться, сейчас объясню, почему так:
Такая штука свзяна с самим показателем - на этом скрине не видно, но его значения "сбрасываются" каждый год, поэтому на той точке мы видим резкий рост, при том, что весь год до этого он убывал. Если бы мы смотрели в рамках 2-3 лет, закономерность была бы очевидна.
На фото ниже пример с похожей метрикой
Собственно со вторым скриншотом такая же история)