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

Определение сезонности

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

Способы моделирования сезонности

Самый простой способ смоделировать сезонность — добавить функции, связанные со временем. Примерами таких индикаторов сезонности являются:

  • квартал года
  • День недели
  • Неделя ИСО в году
  • Месяц в году
  • Индикатор теплой/холодной погоды: одна характеристика, описывающая достаточно теплые или холодные месяцы (относительно теплые месяцы — это месяцы во 2 и 3 кварталах, а относительно холодные — в 1 и 4 кварталах).

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

Быстрое преобразование Фурье (БПФ)

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

Основные компоненты члена Фурье

Член Фурье состоит из следующих компонентов:

  • Амплитуда: амплитуда пикового значения.
  • Период: количество времени, необходимое для завершения одного полного цикла.

  • Частота: количество наблюдений, измеренных до повторения сезонной картины.

Практический пример

Практический пример облегчит понимание теории. Данные трендов Google для поиска по ключевому слову «meloen» (в этом примере использовалось голландское слово «дыня»). Вот график временного ряда за период времени в четыре года.

На графике выше хорошо видно, что поиски дыни начинают расти где-то в июне и достигают пика в июле за все годы. Это указывает на наличие сезонности вокруг лета.

Гипотезу можно проверить с помощью следующего теста на сезонность:

import pandas as pd
from scipy.stats import friedmanchisquare

def seasonality_test(input_data: pd.DataFrame, label_field: str) -> bool:
    input_data["year"] = pd.DatetimeIndex(input_data["date"]).year
    input_data["month"] = pd.DatetimeIndex(input_data["date"]).month

    transformations = {label_field: "mean"}

    input_data = input_data.groupby(["year", "month"]).agg(transformations)
    input_data.reset_index(inplace=True)

    min_year = input_data[input_data["month"] == 1]["year"].min()
    max_year = input_data[input_data["month"] == 12]["year"].max()
    input_data = (input_data[(input_data["year"] >= min_year) & 
                 (input_data["year"] <= max_year)])

    year_values = [year for year in input_data["year"].unique()]

    number_of_years = len(year_values)

    input_data = input_data.pivot(index="month", columns="year", 
                                  values=label_field)

    stat, p = friedmanchisquare(
        input_data[year_values[number_of_years - 3]],
        input_data[year_values[number_of_years - 2]],
        input_data[year_values[number_of_years - 1]])

    print(f"Statistics = {stat}, p = {p}")

    alpha = 0.01
    if p > alpha:
        print("Same distributions (fail to reject H0)")
        season = True
    else:
        print("Different distributions (reject H0)")
        season = False
    return season

season = seasonality_test(input_data=pdf, label_field="searches")
print(f"Seasonality is: {season}")

>> Statistics = 0.383, p = 0.826
>> Same distributions (fail to reject H0)
>> Seasonality is True

Тест на сезонность показывает, что ему не удалось отклонить нулевую гипотезу H0, поэтому в наборе данных присутствует сезонность.

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

БПФ можно использовать для моделирования сезонности, указанной в приведенном выше тесте.

import math
from cmath import phase
import numpy as np
import pandas as pd
from scipy import fft
from scipy import signal as sig
from sklearn.linear_model import LinearRegression
import matplotlib.pyplot as plt
import seaborn as sns

def add_trend_term(pdf):
    pdf["trend"] = pdf.apply(lambda row: row.name + 1, axis=1)
    return pdf

def add_fourier_seasonality_term(pdf, column_name, period_min, period_max):
    # Performs fourier transformation
    fft_output = fft.fft(pdf[column_name].to_numpy())
    amplitude = np.abs(fft_output)
    freq = fft.fftfreq(len(pdf[column_name].to_numpy()))

    mask = freq >= 0
    freq = freq[mask]
    amplitude = amplitude[mask]

    # determine peaks
    peaks = sig.find_peaks(amplitude[freq >= 0])[0]
    peak_freq = freq[peaks]
    peak_amplitude = amplitude[peaks]

    # Create dataframe containing necessary parameters
    fourier_output = pd.DataFrame()
    fourier_output["index"] = peaks
    fourier_output["freq"] = peak_freq
    fourier_output["amplitude"] = peak_amplitude
    fourier_output["period"] = 1 / peak_freq
    fourier_output["fft"] = fft_output[peaks]
    fourier_output["amplitude"] = fourier_output.fft.apply(lambda z: np.abs(z))
    fourier_output["phase"] = fourier_output.fft.apply(lambda z: phase(z))

    N = len(pdf.index)
    fourier_output["amplitude"] = fourier_output["amplitude"] / N

    fourier_output = fourier_output.sort_values("amplitude", ascending=False)
    fourier_output = fourier_output[fourier_output["period"] >= period_min]
    fourier_output = fourier_output[fourier_output["period"] <= period_max]

    # Turn our dataframe into a dictionary for easy lookup
    fourier_output_dict = fourier_output.to_dict("index")
    pdf_temp = pdf[["trend"]]

    lst_periods = fourier_output["period"].to_list()
    lst_periods = [int(round(val, 0)) for val in lst_periods]

    for key in fourier_output_dict.keys():
        a = fourier_output_dict[key]["amplitude"]
        w = 2 * math.pi * fourier_output_dict[key]["freq"]
        p = fourier_output_dict[key]["phase"]
        pdf_temp[key] = pdf_temp["trend"].apply(
                        lambda t: a * math.cos(w * t + p))

    pdf_temp["FT_All"] = 0
    for column in list(fourier_output.index):
        pdf_temp["FT_All"] = pdf_temp["FT_All"] + pdf_temp[column]

    pdf["seasonality"] = pdf_temp["FT_All"].astype(float)
    pdf["seasonality"] = pdf["seasonality"].round(4)

    predictors = ["trend", "seasonality"]
    X = pdf[predictors]
    y = pdf["searches"]

    X_predict = pdf[predictors]

    # Initialise and fit model
    lm = LinearRegression()
    model = lm.fit(X, y)

    # Forecast baseline for entire dataset
    pdf["baseline"] = model.predict(X_predict)
    pdf["baseline"] = pdf["baseline"].round(4)
    return (fourier_output, pdf)


def create_plots(pdf, period_min, period_max):
    pdf = pdf.reset_index()
    pdf = add_trend_term(pdf=pdf)

    (fourier_output, pdf) = add_fourier_seasonality_term(
        pdf,
        column_name="searches",
        period_min=period_min,
        period_max=period_max
    )

    pdf["date"] = pd.to_datetime(pdf["date"], format="%Y-%m-%d")
    pdf = pdf.set_index("date")

    fig, axs = plt.subplots(ncols=1, figsize=(30, 5))
    sns.lineplot(data=pdf, x="date", y="searches", 
                 label="searches", color="grey")

    sns.lineplot(x="date", y="baseline", data=pdf, ax=axs, 
                 label="baseline", color="black")
    axs.legend()
    plt.show()

При настройке параметров min_period и max_period синусоида применяется на уровне, указанном на основе базовых данных.

Эксперимент № 1: Создайте синусоиду на недельном уровне

create_plots(pdf=pdf, period_min=3, period_max=52)

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

Эксперимент № 2: Создайте синусоиду на годовом уровне

create_plots(pdf=pdf, period_min=52, period_max=62)

Член Фурье во втором эксперименте отражает годовую сезонность для дыни. Член Фурье можно использовать в качестве признака при построении модели для учета сезонности.

Выводы

Сезонность является важным аспектом набора данных временных рядов. Есть простой способ смоделировать это с помощью сезонных манекенов. Более научный метод заключается в применении БПФ для моделирования сезонности за определенный период времени.

Рекомендации

https://towardsdatascience.com/taking-seasonality-into-consideration-for-time-series-analysis-4e1f4fbb768f

https://towardsdatascience.com/how-to-add-fourier-terms-to-your-regression-seasonality-analysis-using-python-scipy-99a94d3ae51

Сезонность дыни в Нидерландах: https://www.vangeldernederland.nl/nl_NL/blog/item/meloen-seizoen-op-zn-top-241/

Данные трендов Google: https://trends.google.com/trends/

Код GitHub: https://github.com/frida-ah/fourier