В "Тензоре" мы разрабатываем множество сервисов для управления бизнесом. А в бизнесе очень часто возникает желание немного "заглянуть в будущее" - спрогнозировать и увидеть на графике значение каких-то величин, которые мы можем только предполагать на основании данных предыдущих периодов. Например, на какую примерно выручку мы сможем рассчитывать в следующем месяце или сколько продуктов стоит закупить в столовую на следующую неделю.

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

Как будем считать

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

Итак, мы можем заранее предположить какие периоды актуальны для нашего вида бизнеса:

  • 1 week

  • 1 month

  • 3 months

  • 1 year

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

A / B = A' / B'
A / B = A' / B'

Тогда, если вывести более формально, где d - это период в днях:

1. A / B = A' / B'
2. ([dt] + sum[dt-1 .. dt-d+1]) / sum[dt-d .. dt-2*d+1] =
     = sum[dt-1 .. dt-i] / sum[dt-d-1 .. dt-2*d]
3. [dt] = sum[dt-1 .. dt-d] / sum[dt-d-1 .. dt-2*d] * sum[dt-d .. dt-2*d+1] -
            - sum[dt-1 .. dt-d+1]

Как можно увидеть, для расчета по данному алгоритму нам понадобятся данные за "удвоенный период" и еще один день (dt - 2 * d + 1).

Наложение периодов

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

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

|six x| + |sin x/2|
|six x| + |sin x/2|

Конструируем SQL-запрос

Для начала сгенерируем немного случайных данных за предыдущие пару лет:

CREATE TABLE tbl_fact AS
SELECT
  dt::date
, random() v
FROM
  generate_series(
    '2021-12-31'::date - '2 year 1 day'::interval -- 2 года и 1 день назад
  , '2021-12-31'::date
  , '1 day'::interval
  ) dt;
CREATE INDEX ON tbl_fact(dt);

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

"Си, бейсик, паскаль... русский со словарем"

Поскольку алгоритм у нас итеративный, то на SQL для этого мы будем использовать рекурсивный запрос. В теории, можно было бы вывести некую мегасложную рекуррентную формулу, но мы будем действовать проще.

Согласно приведенной выше формуле, нам понадобятся суммы за d последовательных дней периода до конкретной даты и значение в этой дате. Поэтому сначала Преобразуем интервалы в количество дней:

periods AS (
  SELECT
    *
  , extract(epoch FROM p)::integer/86400 d -- переводим интервалы в дни
  FROM
    unnest('{1 week,1 month,3 months,1 year}'::interval[]) p
)

Чтобы не извлекать повторно эти данные каждый раз из таблицы и пересчитывать заново, сложим их в json-словарь в формате {[dt:d] : v}:

src AS (
  SELECT
    jsonb_object(
      array_agg(f.dt || ':' || f.d)::text[]
    , array_agg(f.v)::text[]
    ) s -- свертка в {[dt:d] : v}
  FROM
    periods
  , LATERAL (
      SELECT
        dt
      , 1 d
      , v -- данные за [dt]
      FROM
        tbl_fact
    UNION ALL
      SELECT
        dt
      , d
      , coalesce(sum(v) OVER(ROWS BETWEEN d-1 PRECEDING AND CURRENT ROW), 0) v -- данные за [dt-d+1 .. dt]
      FROM
        tbl_fact
    ) f
)  

Возьмем типичный шаблон для итеративной работы со словарем, аналогичный рассмотренному в статье SQL HowTo: пишем while-цикл прямо в запросе, или «Элементарная трехходовка»:

T AS (
  SELECT
    '2022-01-01'::date - 1 dt -- стартовая дата
  , NULL::double precision v
  , (TABLE src) s -- начальное состояние словаря
UNION ALL
  SELECT
    dt + 1
  , X.v
  , X.s
  FROM
    T
  , LATERAL (
    -- ... magic!
    ) X
  WHERE
    dt < '2022-12-31' -- финальная рассчитываемая дата
)

То все присказка была... Пора реализовать сам алгоритм вычисления следующего значения. Сначала потренируемся вычислять значение дня для первого шага, без рекурсии, а для этого продолжим вывод значения с использованием уже агрегированных данных:

3. [dt] = sum[dt-1 .. dt-d] / sum[dt-d-1 .. dt-2*d] * sum[dt-d .. dt-2*d+1] -
            - sum[dt-1 .. dt-d+1]
4. [dt] = sum[dt-1:d] / sum[dt-d-1:d] * sum[dt-d:d] - (sum[dt-1:d] - sum[dt-d:1])
A = A' - [dt - 7] = sum[dt-1:7] - sum[dt-7:1]
A = A' - [dt - 7] = sum[dt-1:7] - sum[dt-7:1]

Тут мы заменили сумму на интервале в d - 1 день как разность сумм на d-интервале и единичном. К счастью, мы ранее позаботились, чтобы они у нас были, и теперь наш модельный запрос выглядит так:

SELECT
  X.v
, Y.s
FROM
  (
    SELECT
      '2021-12-31'::date dt
    , (TABLE src) s
  ) _
, LATERAL(
    SELECT
      greatest(avg(
        (s ->> ((dt     - 1) || ':' || d))::double precision --   A'
      / (s ->> ((dt - d - 1) || ':' || d))::double precision -- / B'
      * (s ->> ((dt - d    ) || ':' || d))::double precision -- * B
      - (s ->> ((dt     - 1) || ':' || d))::double precision -- - A'
      + (s ->> ((dt - d    ) || ':' || 1))::double precision -- + [dt-d]
      ), 0) v -- прогнозируемое значение не может быть орицательным
    FROM
      periods
  ) X
, LATERAL(
    SELECT
      jsonb_object(
        array_agg((dt + 1) || ':' || d)::text[]
      , array_agg(
          (s ->> ((dt - 1) || ':' || d))::double precision --   A'
        - (s ->> ((dt - d) || ':' || 1))::double precision -- - [dt-d]
        + v                                                -- + v
        )::text[]
      ) s -- словарь рассчитанныx сумм
    FROM
      periods
  ) Y

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

Теперь осталось собрать все в один запрос:

WITH RECURSIVE periods AS (
  SELECT
    *
  , extract(epoch FROM p)::integer/86400 d
  FROM
    unnest('{1 week,1 month,3 months,1 year}'::interval[]) p
)
, src AS (
  SELECT
    jsonb_object(
      array_agg(f.dt || ':' || f.d)::text[]
    , array_agg(f.v)::text[]
    ) s -- свертка в {[dt:d] : v}
  FROM
    periods
  , LATERAL (
      SELECT
        dt
      , 1 d
      , v -- данные за [dt]
      FROM
        tbl_fact
    UNION ALL
      SELECT
        dt
      , d
      , coalesce(sum(v) OVER(ROWS BETWEEN d-1 PRECEDING AND CURRENT ROW), 0) v -- данные за [dt-d+1 .. dt]
      FROM
        tbl_fact
    ) f
  )
, T AS (
  SELECT
    '2022-01-01'::date - 1 dt -- стартовая дата
  , NULL::double precision v
  , (TABLE src) s -- начальное состояние словаря
UNION ALL
  SELECT
    dt + 1
  , X.v
  , X.s
  FROM
    T
  , LATERAL (
      SELECT
        X.v
      , T.s || Y.s s
      FROM
        (
          SELECT
            greatest(avg(
              (s ->> ((dt     - 1) || ':' || d))::double precision -- A'
            / (s ->> ((dt - d - 1) || ':' || d))::double precision -- B'
            * (s ->> ((dt - d    ) || ':' || d))::double precision -- B
            - (s ->> ((dt     - 1) || ':' || d))::double precision -- A' - [dt-d]
            + (s ->> ((dt - d    ) || ':' || 1))::double precision
            ), 0) v -- прогнозируемое значение не может быть отрицательным
          FROM
            periods
        ) X
      , LATERAL(
          SELECT
            jsonb_object(
              array_agg((dt + 1) || ':' || d)::text[]
            , array_agg(
                (s ->> ((dt - 1) || ':' || d))::double precision
              - (s ->> ((dt - d) || ':' || 1))::double precision
              + v
              )::text[]
            ) s -- словарь рассчитанныx сумм
          FROM
            periods
        ) Y
    ) X
  WHERE
    dt < '2022-12-31' -- финальная рассчитываемая дата
)
SELECT
  dt
, v
FROM
  T
WHERE
  dt >= '2022-01-01';

Пользуйтесь на свой страх и риск.

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


  1. Akina
    11.02.2022 23:27

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


    1. Kilor Автор
      11.02.2022 23:38

      Давно думаю над таким, но ничего эффективнее преобразования Фурье на ум не приходит, а для него хорошей и дешевой реализации на SQL вряд ли есть. Разве что подключать внешние либы через PL-языки - например, PL/R.


      1. Tzimie
        13.02.2022 18:00

        Ну на MSSQL как раз очень легко вызвать Python и сделать преобразование Фурье


      1. Tzimie
        13.02.2022 18:01

        Помимо Фурье циклы выявляет и функция автокорреляции


  1. Nekrodev
    14.02.2022 10:46

    Молодцы ребята, вы изобрели ряды Фурье))