SARIMAX vs Экспоненциальное сглаживание: Когда простота побеждает

В прошлом посте я рассказывала про свои мучения с моделькой ARIMA. Здесь же я расскажу о следующей серии издевательств над временными рядами, SARIMAX и экспоненциальным сглаживанием.

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

Занудная математика

Гауссов (ский) процесс: если конечные распределения процесса ξt для любых t1,. . ., tn являются нормально распределенными (гауссовскими), то процесс ξt называется гауссовским.

Как справедливо заметили в комментариях к предыдущему посту, очень часто гауссовость случайной части зашита во многие модели, которые к процессу применяются. Не рискну лезть вглубь математики в этой части, но замечание крайне ценное. После этого я стала смотреть не только на поведение модели на тестовой выборке, но и на распределение остатков (residuals). Подробнее можно почитать здесь. За наводку спасибо @adeshere.

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

Единственный недостаток Яндексовского учебника по ML — очень мало примеров кода. Мне, как новичку, было крайне тяжело связать математику и код с первого раза.

Прочие косяки прошлой статьи

Также в прошлой статье в комментах заметили, что ACF и PACF не гарант хорошего подбора гиперпараметров. Я в этом убедилась на собственном печальном опыте, но ещё раз это подчеркну.

К сожалению, пока не было времени попробовать инструменты из R, модель ARCH, Pycaret и fbprophet.

Данные

У меня была статистика компании за 2.5 года по дням, для пары экспериментов я пробовала склеить её в недельные данные. К сожалению или к счастью никакой физики за процессом тут не лежит. Требовалась просто сносная модель, которая бы предсказывала данные на следующий месяц и при этом давала результат лучше, чем предсказания аналитиков через конверсии. И была возможность вытащить из данных ещё дополнительную колонку внешних параметров.

Какой-то такой временной ряд был у меня в начале

Какой-то такой временной ряд был у меня в начале

SARIMA

На модель SARIMA у меня были большие надежды, всё-таки в отличие от ARIMA она могла оперировать внешними параметрами и с сезонностью у неё дела обстояли сильно лучше. В итоге я остановилась на статистике по дням.

Чтобы работать со стационарным рядом, я дифференцировала изначальный:


diff1 = df.copy(deep=True) 
diff1['1'] = df['1'].diff()
diff1['2'] = df['2'].diff()
diff1.dropna(inplace=True)

Затем сделала пробный тычок пальцем в небо, чтобы посмотреть на работу модели:

from statsmodels.tsa.statespace.sarimax import SARIMAX
# Ensure the SARIMAX model is initialized properly with your training data and exogenous variables
model = SARIMAX(diff1['count_offers'][:962], exog=diff1['count_sends'][:962], order=(1, 1, 1), seasonal_order=(1, 0, 1, 12))

# Fit the model
result = model.fit(disp=False)
# Forecast using the correct steps and aligned exogenous data
forecast = result.get_forecast(steps=len(df_test), exog=forecast_exog)
predictions = result.forecast(len(df_test), exog=forecast_exog)
# predictions
predictions = pd.Series(predictions.values, index=df_test.index)
# If the forecast returns NaNs, check alignment and data quality
forecast_df = pd.DataFrame({
    'predicted_mean': forecast.predicted_mean,
    'lower_ci': forecast.conf_int().iloc[:, 0],
    'upper_ci': forecast.conf_int().iloc[:, 1]
}, index=df_test.index)

Оптимизация с помощью Optuna:


#Generate all different combinations of p, d and q triplets
#Generate all different combinations of p, d, q and s triplets
import itertools
import optuna

p = range(1, 3)  # Smaller range for non-seasonal AR component
d = range(0, 2)
q = range(0, 3)
P = range(0, 2)  # Smaller range for seasonal AR component
D = range(0, 2)
Q = range(0, 2)
s = 12

pdq = list(itertools.product(p, d, q))
pdqs = [(x[0], x[1], x[2], s) for x in list(itertools.product(P, D, Q))]
#%%
def objective_sarima(trial):
    non_seasonal_order = trial.suggest_categorical('non_seasonal_order', pdq)
    seasonal_order = trial.suggest_categorical('seasonal_order', pdqs)
    trend = trial.suggest_categorical('trend', ['n', 'c', 't', 'ct', None])
    # # Define SARIMA model with specified non-seasonal and seasonal orders
    # model = SARIMAX(df_test['count_offers'][:483], exog=df['count_sends'][:483], order=non_seasonal_order, seasonal_order=seasonal_order, trend=trend, initialization='approximate_diffuse')
    # # Fit the model
    # mdl = model.fit(disp=0)
    
    # # Generate predictions
    # predictions = mdl.forecast(len(df_test))
    model = SARIMAX(diff1['count_offers'][:962], exog=diff1['count_sends'][:962], order=non_seasonal_order, seasonal_order=seasonal_order)
    # Fit the model
    result = model.fit(disp=True)
    # Forecast using the correct steps and aligned exogenous data
    forecast = result.get_forecast(steps=len(df_test), exog=forecast_exog)
    predictions = result.forecast(len(df_test), exog=forecast_exog)
    # predictions
    predictions = pd.Series(predictions.values, index=df_test.index)
    # If the forecast returns NaNs, check alignment and data quality
    forecast_df = pd.DataFrame({
        'predicted_mean': forecast.predicted_mean,
        'lower_ci': forecast.conf_int().iloc[:, 0],
        'upper_ci': forecast.conf_int().iloc[:, 1]
    }, index=df_test.index)
    # Calculate residuals and error metric
    residuals = diff1['count_offers'] - predictions
    mse = np.sqrt(np.mean(residuals**2))
    return mse

study=optuna.create_study(direction="minimize")
study.optimize(objective_sarima,n_trials=10)

Затем я на всё это счастье визуализировала и смотрела. Получилось устрашающе:

plt.figure(figsize=(10, 6))
# plt.plot(df['count_offers'][:483] label='Historical Offers', marker='o', color='blue')
plt.plot(df_test['count_offers'], label='Actual Future Offers', marker='o', color='green')
plt.plot(forecast_df['predicted_mean'], label='Forecasted Offers', marker='o', color='red')
plt.fill_between(forecast_df.index, forecast_df['lower_ci'], forecast_df['upper_ci'], color='red', alpha=0.2)
plt.title('Forecasted vs Actual Offer Counts')
plt.xlabel('Date')
plt.ylabel('Offer Count')
plt.legend()
plt.grid(True)
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()

На картинке стационарные ряды: предсказанный и реальный

На картинке стационарные ряды: предсказанный и реальный

После я сравнила ещё и обратно проинтегрированный ряд с исходными данными, которые мне предоставили аналитики. Меня просили оформить всё с табличкой-сравнением, так что код получился монструозный. Тут важно опять-таки не забывать про константу интегрирования, которая в коде носит имя offset. Иначе это чеховское ружьё выстрелит в ногу.

#integral constant!
offset = int(list(dataset_sends['count_offers'])[-len(df_test)])
print(offset)
inverted_forecast = pd.Series(forecast_df['predicted_mean']).cumsum()
inverted_forecast = pd.DataFrame(forecast_df['predicted_mean'])
inverted_true = pd.DataFrame(list(dataset_sends['count_offers'][-len(df_test):]), index=range(0, len(df_test)))

inverted_forecast = inverted_forecast + offset
inverted_forecast = pd.DataFrame(list(inverted_forecast['predicted_mean'][-len(df_test):]), index=range(0, len(df_test)))
dates = pd.DataFrame(list(dataset_sends['date'][-len(df_test):]), index=range(0, len(df_test)))

df_merged = pd.concat([inverted_forecast, inverted_true, dates], axis=1)

df_merged.columns = ['predicted_mean', 'count_offers', 'date']

df_merged.dropna(inplace=True)
df_merged['count_offers'] = df_merged['count_offers'].apply(int)
df_merged['predicted_mean'] = df_merged['predicted_mean'].apply(int)
# Absolute Error
df_merged['error'] = abs(df_merged['count_offers'] - df_merged['predicted_mean'])
df_merged['date'] = pd.to_datetime(df_merged['date'])
# Set 'date' column as the index
df_merged.set_index('date', inplace=True)

# Group by week and sum the other columns
df_merged = df_merged.resample('W').sum()

# Reset index to make 'date' a column again
df_merged.reset_index(inplace=True)
# Relative Error
df_merged['relative_error, %'] = abs(df_merged['count_offers'] - df_merged['predicted_mean']) / df_merged['count_offers'] * 100
df_merged.dropna(inplace=True)


display(df_merged[-17:-1])
plt.plot(df_merged.index, df_merged['count_offers'], label='count_offers')
# Plot 'predicted_mean'
plt.plot(df_merged.index, df_merged['predicted_mean'], label='predicted_mean')

# Set labels and title
plt.xlabel('Time')
plt.ylabel('Count')
plt.title('Comparison of count_offers and predicted_mean')
plt.legend()

# Show plot
plt.show()

Увы, показать табличку я не могу из-за NDA, и так хожу по офигенно тонкому льду:)

предсказание получилось, мягко говоря, не идеальным

предсказание получилось, мягко говоря, не идеальным

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

Экспоненциальное сглаживание

Этот подход упоминают в Яндекс-учебнике по ML, но уделяют не так много внимания, как моделям ARIMA/SARIMA. А вот мне он дал неожиданно классные результаты.

Здесь я тоже юзала пакетную историю и работала с данным, лежащими в переменной aust. Также руками поставила период в 40 дней, который подбирала ручками, без всяких оптимизаторов. Не делайте так:)

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime
import statsmodels.api as sm
from pandas.plotting import register_matplotlib_converters
from statsmodels.tsa.holtwinters import SimpleExpSmoothing
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from sklearn.metrics import mean_squared_error,mean_absolute_error
import warnings
from statsmodels.tsa.holtwinters import SimpleExpSmoothing, Holt
warnings.filterwarnings('ignore')

period = 40
fit1 = ExponentialSmoothing(
    aust,
    seasonal_periods=period,
    trend="add",
    seasonal="add",
    use_boxcox=True,
    initialization_method="estimated",
).fit()
fit2 = ExponentialSmoothing(
    aust,
    seasonal_periods=period,
    trend="add",
    seasonal="mul",
    use_boxcox=True,
    initialization_method="estimated",
).fit()
fit3 = ExponentialSmoothing(
    aust,
    seasonal_periods=period,
    trend="add",
    seasonal="add",
    damped_trend=True,
    use_boxcox=True,
    initialization_method="estimated",
).fit()
fit4 = ExponentialSmoothing(
    aust,
    seasonal_periods=period,
    trend="add",
    seasonal="mul",
    damped_trend=True,
    use_boxcox=True,
    initialization_method="estimated",
).fit()
results = pd.DataFrame(
    index=[r"$\alpha$", r"$\beta$", r"$\phi$", r"$\gamma$", r"$l_0$", "$b_0$", "SSE"]
)
params = [
    "smoothing_level",
    "smoothing_trend",
    "damping_trend",
    "smoothing_seasonal",
    "initial_level",
    "initial_trend",
]
results["Additive"] = [fit1.params[p] for p in params] + [fit1.sse]
results["Multiplicative"] = [fit2.params[p] for p in params] + [fit2.sse]
results["Additive Dam"] = [fit3.params[p] for p in params] + [fit3.sse]
results["Multiplica Dam"] = [fit4.params[p] for p in params] + [fit4.sse]



ax = train_data.plot(marker="o",
    color="black",
    title="Forecasts from Holt-Winters' multiplicative method",
)

ax.set_xlim(1697587200000000000, 1713139200000000000)
ax.set_ylim(0, 20)
ax.set_ylabel("counts")
ax.set_xlabel("Year")

fit1.fittedvalues.plot(style="--",marker="o", color="red", ax=ax)
fit2.fittedvalues.plot(style="--", marker="o",color="green", ax=ax)
fit3.fittedvalues.plot(style="--", marker="o",color="blue", ax=ax)
fit4.fittedvalues.plot(style="--", marker="o",color="green", ax=ax)


forecast1 = fit1.forecast(121).rename("Holt-Winters (add-add-seasonal)")
forecast2 = fit2.forecast(121).rename("Holt-Winters (add-mul-seasonal)")
forecast3 = fit3.forecast(121).rename("Holt-Winters (add-add-seasonal heuristic)")
forecast4 = fit4.forecast(121).rename("Holt-Winters (add-mul-seasonal heuristic)")

predictions1 = pd.Series(forecast1.values, index=test_data.index)
predictions2 = pd.Series(forecast2.values, index=test_data.index)
predictions3 = pd.Series(forecast3.values, index=test_data.index)
predictions4 = pd.Series(forecast4.values, index=test_data.index)

predictions1.dropna(inplace=True)
predictions2.dropna(inplace=True)
predictions3.dropna(inplace=True)
predictions4.dropna(inplace=True)

print("1mean absolute error : ",round(mean_absolute_error(test_data[:len(predictions1)], predictions1), 5))
print("1mean squared error : ",round(mean_squared_error(test_data[:len(predictions1)], predictions1), 5))
print("1Root mean squared error : ",round(mean_squared_error(test_data[:len(predictions1)], predictions1,squared=False), 5))

print("2mean absolute error : ",round(mean_absolute_error(test_data[:len(predictions2)], predictions2),5))
print("2mean squared error : ",round(mean_squared_error(test_data[:len(predictions2)], predictions2),5))
print("2Root mean squared error : ",round(mean_squared_error(test_data[:len(predictions2)], predictions2,squared=False),5))

print("3mean absolute error : ",round(mean_absolute_error(test_data[:len(predictions3)], predictions3),5))
print("3mean squared error : ",round(mean_squared_error(test_data[:len(predictions3)], predictions3),5))
print("3Root mean squared error : ",round(mean_squared_error(test_data[:len(predictions3)], predictions3,squared=False),5))

# print("4mean absolute error : ",round(mean_absolute_error(test_data[:len(predictions4)], predictions4),5))
# print("4mean squared error : ",round(mean_squared_error(test_data[:len(predictions4)], predictions4),5))
# print("4Root mean squared error : ",round(mean_squared_error(test_data[:len(predictions4)], predictions4,squared=False),5))

forecast1.plot(ax=ax, style="--", marker="o", color="green", legend=True, figsize=(20, 10))
forecast2.plot(ax=ax, style="--", marker="o", color="red", legend=True)
forecast3.plot(ax=ax, style="--", marker="o", color="blue", legend=True)
forecast4.plot(ax=ax, style="--", marker="o", color="yellow", legend=True)

plt.plot(predictions1, marker="o", color = "green")
plt.plot(predictions2, marker="o", color = "red")
plt.plot(predictions3, marker="o", color = "blue")
plt.plot(predictions4, marker="o", color = "yellow")
plt.plot(test_data, marker="o", color = 'black')
plt.plot(train_data, marker="o", color = 'black')
# Show the plot
plt.show()

print("Figure 7.6: Forecasting.")
# results

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

К тому же я не смогла по-человечески вписать сюда историю с адекватным временем. Мне пришлось в самом начале перевести всё в цифирьки и довольствоваться малым:

aust['date'] = pd.to_numeric(pd.to_datetime(aust['date']))

Исходная картинка у меня пропала, но картинка была какая-то такая:

674b70cd579783e422af1e11f1e22fc2.png

Затем я взяла среднее по всем эти предсказаниям и получила вполне сносную картинку

combined_predictions = pd.DataFrame({
    'predictions1': predictions1,
    'predictions2': predictions2,
    'predictions3': predictions3,
    'predictions4': predictions4
})
# Calculate the average prediction across all models
average_prediction = combined_predictions.mean(axis=1)

from sklearn.metrics import mean_absolute_percentage_error
plt.figure(figsize=(15, 5))
plt.plot(average_prediction, marker="o", color = "red")
plt.plot(test_data, marker="o", color = 'black')
plt.ylim(0, 20)

plt.show()
print("mean absolute error : ",round(mean_absolute_error(test_data, average_prediction),5))
print("mean squared error : ",round(mean_squared_error(test_data, average_prediction),5))
print("Root mean squared error : ",round(mean_squared_error(test_data, average_prediction,squared=False),5))
print("mean relative error", round(mean_absolute_percentage_error(test_data, average_prediction)), "%")

Результат прогноза с помощью экспоненциального сглаживания: красные точки — предсказания, чёрные — реальные данные

Результат прогноза с помощью экспоненциального сглаживания:
красные точки — предсказания, чёрные — реальные данные

Вывод:
1. Вероятно, такую хорошую картинку я получила именно из-за сглаживания. Про это писали среди комментов к прошлому посту.
2. Временную шкалу мне пришлось дополнительно обрабатывать, чтобы всё заработало. Можно ли было обойтись без этого — не знаю.

В заключительной части этой «временной» серии расскажу про мой опыт использования пакета FEDOT. И если успею потестить что-то из предложенного ранее в комментах — тоже напишу.

11de14442440f207b6a8449ad3749443.jpgЕрмак Марина

Аналитик, SENSE IT

© Habrahabr.ru