Эконометрика в ритейле: как не потратить миллионы на заведомо неэффективные эксперименты
Всем привет! На связи команда ad-hoc аналитики X5 Tech. Если вы уже знакомы с нашими статьями, то наверняка знаете, что нашей ключевой темой является А/Б тестирование. Важной составляющей А/Б теста является дизайн: для успешного проведения эксперимента необходимо оценить размер тестовой и контрольной групп, зафиксировав предварительно ожидаемый эффект. Но возникает вопрос: как убедиться в обоснованности гипотезы и рассчитать ожидаемые эффекты от инициативы?
В статье мы рассмотрим ключевые понятия из эконометрики, такие как коинтеграция и модель коррекции ошибок, и продемонстрируем их применение на ретроспективных данных. Мы подробно разберём, как использовать эти инструменты для анализа взаимосвязей между временными рядами. В качестве практического примера с помощью функции импульсного отклика мы проведём количественную оценку ожидаемого влияния повышения комплектности персонала на списания на выбранном кейсе.
Представьте, что к вам пришёл заказчик — например, представитель отдела HR — и выдвинул следующую гипотезу: повышение комплектности персонала ведёт к снижению списаний в магазинах продуктовой сети. Под списаниями в данном случае понимаются списанные с учёта товары, которые не были реализованы и ушли в убыток компании. Комплектность персонала — это отношение числа отработанных часов к числу запланированных часов. Логика проста: чем ближе количество часов к плановому, тем больше времени уделяется выкладке товаров, контролю запасов и улучшению обслуживания. Таким образом, перед заказчиком стоит вопрос об окупаемости инвестиций в персонал: если компании придётся потратить определённую сумму денег для увеличения комплектности, окупится ли эта затрата через снижение потерь? Итак, наша цель — понять, на сколько процентов потенциально снизятся потери при повышении комплектности на X%.
Коинтеграция
Исторически в эконометрике для анализа взаимосвязей в данных с явной временной зависимостью было разработано понятие коинтеграции. Давайте познакомимся с ним подробнее.
Разностью порядка называют последовательное вычитание предыдущего значения ряда из текущего, повторенное раз. Например, для ряда , первая разность определяется как , вторая разность как и так далее.
Временной ряд называется интегрированным порядка , если разности -го порядка являются стационарными рядами, и при этом разности порядков не стационарны. Обозначается это следующим образом:
По определению стационарный временной ряд является процессом.
Мы говорим, что два временных ряда и коинтегрированы, когда существует линейная комбинация , где . Вектор называется коинтеграционным вектором.
Чаще всего на практике мы сталкиваемся с нестационарными временными рядами. В частности, если два интегрированных временных ряда первого порядка коинтегрированы, то это означает, что существует их стационарная линейная комбинация . Из данной комбинации можно выразить , в итоге мы получим следующее соотношение, переобозначив коэффициенты:
где , то есть является стационарным временным рядом. Данное уравнение называется коинтеграционным.
Какая интуиция лежит за этим понятием? Коинтеграция двух временных рядов означает, что несмотря на то, что каждый из рядов может быть не стационарен сам по себе (например, представлять собой случайные блуждания), между ними существует долгосрочная взаимосвязь. Иными словами, временные ряды «притягиваются» друг к другу через некую устойчивую зависимость, несмотря на свою индивидуальную изменчивость.
Оказывается, для коинтегрированных временных рядов выполняются несколько замечательных свойств:
Первое свойство
Наверняка вы уже заметили сходство коинтеграционного уравнения с моделью линейной регрессии . И если два ряда коинтегрированы, то в коинтеграционном уравнении можно оценивать через OLS, при этом оценка будет суперсостоятельной. Иными словами, для оценки нам достаточно воспользоваться методом наименьших квадратов, при этом будет сходиться к истинному значению очень быстро.
Второе свойство
Пара временных рядов является коинтегрированной тогда и только тогда, когда существует представление её динамики в виде модели VECM (векторной модели коррекции ошибок). Данный результат известен как теорема Грейнджера о представлении.
Если с первым свойством всё понятно, то второе требует некоторых пояснений. Давайте подробнее посмотрим на то, что такое VECM.
VECM
Модель коррекции ошибок (VECM) специально разработана для обработки нестационарных рядов с учётом как краткосрочной динамики, так и долгосрочных равновесных отношений между несколькими временными рядами.
Рассмотрим два временных ряда . Мы ожидаем, что их разности будут иметь стационарные представления. Например, они могут быть представлены двумя стационарными AR (k)-процессами:
Допустим, что и коинтегрированы. Тогда по определению существует коэффициент , такой, что . Но в приведённых выше AR-процессах пока что нет членов, отвечающих за взаимосвязь между рядами. Ряды не зависят друг от друга. Давайте попробуем добавить дополнительное стационарное слагаемое вида к правым частям, чтобы включить связь искусственно:
Данные уравнения являются представлением динамики двух временных рядов в виде модели VECM. Так как изначально ряды разностей были стационарны, после прибавления слагаемых итоговая сумма также останется стационарной.
Дополнительно можно включить слагаемые вида и наоборот.
В целях иллюстрации рассмотрим случай, когда . Если выросло слишком сильно и вышло за пределы «равновесного состояния» в стационарном процессе , то должно затем упасть (ввиду отрицательности ) и/или в ответ должно вырасти значение . Таким образом, пара временных рядов остаётся коинтегрированной, то есть будет находиться в «равновесии».
Тестирование рядов на коинтеграцию
Как проверить ряды на коинтеграцию? Для этого существует статистический тест Йохансена, позволяющий определить ранг коинтеграции — размерность пространства коинтеграционных векторов. В двумерном случае, если два ряда коинтегрированы, то ранг коинтеграции равен 1.
Нулевая гипотеза теста — отсутствие коинтеграции между рядами. Если гипотеза отвергается, можно оценить количество линейно независимых коинтеграционных векторов.
Подробнее с математикой, лежащей в основе теста Йохансена, можно ознакомиться в данной статье.
Имплементация теста Йохансена
from typing import List, Tuple
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from statsmodels.tsa.vector_ar import vecm
from statsmodels.tsa.vector_ar.vecm import coint_johansen, select_order
from tqdm import tqdm
def johansen_trace_test(
pair: Tuple[np.ndarray, np.ndarray],
k_ar_diff: int,
significance_level: float = 0.05
) -> int:
"""
Тест Йохансена на коинтеграцию.
Проверяет гипотезы о числе коинтеграционных связей:
H_0: r = r_0 (число коинтеграционных связей равно r_0) против альтернативы H_1: r_0 < r <= K,
где K - общее количество временных рядов.
Тест проводится на основе trace statistic
Параметры:
-----------
pair : tuple of numpy arrays
Кортеж, содержащий два временных ряда (x, y)
k_ar_diff : int
Количество лагов для включения в модель
significance_level : float, optional
Уровень значимости
Возвращает:
--------
int
Число коинтеграционных связей между временными рядами на заданном уровне значимости
"""
df = pd.DataFrame({'x': pair[0], 'y': pair[1]})
johansen_test = coint_johansen(df, det_order=0, k_ar_diff=k_ar_diff)
# Критические значения
trace_stat = johansen_test.lr1
critical_values = johansen_test.cvt[:, {0.1: 2, 0.05: 1, 0.01: 0}[significance_level]]
# Ранг коинтеграции
num_cointegrating_rels = sum(trace_stat > critical_values)
return num_cointegrating_rels
Выбор порядка
Процесс выбора оптимального числа лагов в модели VECM чаще всего основывается на информационных критериях:
где — функция правдоподобия, — число учитываемых лагов, — длина временных рядов. Лаги в данном случае — значение переменных на предыдущих шагах времени, которые учитываются в модели для описания текущей динамики. Функция правдоподобия показывает, насколько хорошо модель объясняет данные, а параметры и используются для вычисления штрафа за сложность модели.
В контексте временных рядов минимизация AIC и BIC позволяет найти такой минимальный параметр , при котором модель лучше всего описывает данные.
Код
from statsmodels.tsa.vector_ar.vecm import select_order
def select_k_ar_diff(df: pd.DataFrame, maxlags: int, criterion: str = 'aic') -> int:
"""
Выбор оптимального количества лагов для модели VECM с помощью информационного критерия.
"""
# Определяем порядок лагов на основе выбранного критерия
result = select_order(df, maxlags=maxlags, deterministic='n')
if criterion == 'aic':
return result.aic
elif criterion == 'bic':
return result.bic
Поиск взаимосвязей между списаниями и комплектностью персонала
Приступим к основной части анализа — проверке взаимосвязи между показателями списаний и комплектности персонала. Давайте вспомним постановку изначальной задачи. Нам необходимо понять, есть ли взаимосвязь между списаниями и комплектностью, а также численно оценить степень зависимости, если она действительно существует.
Для демонстрации подхода будем использовать синтетические данные. Смоделируем связь между двумя метриками для каждого магазина по отдельности при помощи простой модели VECM:
где .
Первый ряд представляет собой случайное блуждание с трендом и будет означать растущую комплектность. Второй ряд, зависящий от первого, и связанный с ним коинтеграционным слагаемым, будет обозначать списания в магазинах. Как мы уже знаем из теоремы Грейнджера о представлении, в таком случае теоретически мы должны получить два коинтегрированных временных ряда.
Зафиксируем коэффициенты так, чтобы увеличение комплектности сопровождалось снижением списаний (что было бы логичным). Длину временных рядов выберем равной 365 дням.
Сгенерируем пару временных рядов комплектности и списаний для каждого из 1000 магазинов:
Код генерации
def generate_vecm_processes(
num_pairs: int,
series_length: int,
seed: int = None,
trend: float = 0.05,
alpha1: float = 1.0,
alpha2: float = -1.0,
gamma: float = 0.5,
initial_values: Tuple[float, float] = (0.0, 0.0),
error_std: Tuple[float, float] = (1.0, 1.0)
) -> List[Tuple[np.ndarray, np.ndarray]]:
"""
Генерирует num_pairs пар коинтегрированнвых временных рядов.
Первый ряд в паре обозначает комплектность, второй - списания.
"""
if seed is not None:
np.random.seed(seed)
series_pairs = []
for pair_idx in range(num_pairs):
# Инициализация
x = np.zeros(series_length)
y = np.zeros(series_length)
x[0], y[0] = initial_values
# Генерируем шум из нормального распределения
epsilon1 = np.random.normal(0, error_std[0], series_length)
epsilon2 = np.random.normal(0, error_std[1], series_length)
for t in range(series_length - 1):
# x обозначает комплектность, которая влияет на списания,
# следовательно, коинтеграционное слагаемое в x_{t+1} не включается
x[t + 1] = trend + x[t] + epsilon1[t]
# в y_{t+1} присутствует коинтеграционное слагаемое
y[t + 1] = y[t] - gamma * (y[t] + (alpha1 / alpha2) * x[t]) + epsilon2[t]
series_pairs.append((x, y))
return series_pairs
# приведение временных рядов к разумному масштабу
def scale_series(series_pairs, x_start, x_end, y_start, y_end):
scaled_pairs = []
for x, y in series_pairs:
x_scaled = (x - x[0]) / (x[-1] - x[0]) * (x_end - x_start) + x_start
y_scaled = (y - y[0]) / (y[-1] - y[0]) * (y_end - y_start) + y_start
scaled_pairs.append([x_scaled, y_scaled])
return scaled_pairs
num_pairs = 1000 # число пар временных рядов (каждая пара соответствует одному магазину)
series_length = 365 # длины временных рядов
seed = 42 # seed для воспроизводимости
trend = 0.1 # положительный тренд по комплектности
alpha1 = 0.9 # параметр коинтеграционного уравнения
alpha2 = 0.8 # параметр коинтеграционного уравнения
gamma = 0.2 # степень связи рядов
initial_values = (0.0, 0.0) # стартовые значения
error_std = (1.0, 2.0) # стандартные откленения для шумов
# сгенерируем пары коинтегрированных рядов
series_pairs = generate_vecm_processes(
num_pairs=num_pairs,
series_length=series_length,
seed=seed,
trend=trend,
alpha1=alpha1,
alpha2=alpha2,
gamma=gamma,
initial_values=initial_values,
error_std=error_std
)
series_pairs = scale_series(series_pairs, x_start=0.8, x_end=0.9, y_start=0.04, y_end=0.02)
fig, ax1 = plt.subplots(figsize=(8, 4))
color = 'tab:blue'
ax1.set_xlabel('День')
ax1.set_ylabel('Комплектность', color=color)
ax1.plot(series_pairs[0][0], color=color)
ax1.tick_params(axis='y', labelcolor=color)
ax2 = ax1.twinx()
color = 'tab:orange'
ax2.set_ylabel('Списания', color=color)
ax2.plot(series_pairs[0][1], color=color)
ax2.grid(False)
ax2.tick_params(axis='y', labelcolor=color)
plt.show()
Пример пары сгенерированных временных рядов
Предположим, что сгенерированные данные соответствуют реальным наблюдениям. Первый шаг, который нам необходимо сделать, — это проверить ряды на коинтеграцию. Сделаем это с помощью теста Йохансена:
Проверка на коинтеграцию
maxlags = int(np.around((4 * num_pairs / 100) ** (1 / 4))) # эвристика верхней границы для перебора лагов
coint_dict = {} # словарь с рангами коинтеграции для каждой пары
k_ar_diff_dict = {}
for idx, pair in tqdm(enumerate(series_pairs), total=num_pairs):
# Определяем количество лагов для включения в модель
df_pair = pd.DataFrame({'x': pair[0], 'y': pair[1]})
k_ar_diff = select_k_ar_diff(df_pair, maxlags)
r = johansen_trace_test(pair, 1)
k_ar_diff_dict[idx] = k_ar_diff
coint_dict[idx] = r
coint_df = pd.DataFrame(coint_dict.items(), columns=['pair_id', 'coint_rank'])
k_ar_diff_df = pd.DataFrame(k_ar_diff_dict.items(), columns=['pair_id', 'k_ar_diff'])
# Статистика по рангам коинтеграции
print(coint_df['coint_rank'].value_counts())
coint_df['coint_rank'].value_counts())
Отлично. В подавляющем числе случаев ряды коинтегрированы (ранг коинтеграции равен 1), а в части случаев вообще являются стационарными (ранг коинтеграции равен 2). Делаем вывод, что между рядами действительно существует долгосрочная связь.
Следующий шаг — понять, как эта зависимость выражается в числах.
Функция импульсного отклика
Итак, переходим к самому интересному: как численно оценить влияние одного временного ряда на другой.
Убедившись, что ряды являются коинтегрированными, можно воспользоваться двумя подходами.
1) Первый подход — построить регрессию вида и оценить коэффициент с помощью OLS. Если два ряда коинтегрированы, то OLS-оценка будет суперсостоятельной. В этом случае можно интерпретировать как эластичность: на сколько процентов изменится при изменении на 1%, если предварительно прологарифмировать ряды. Такой подход позволяет учесть долгосрочные зависимости между рядами.
Однако, несмотря на простоту и интуитивность, OLS имеет ограничение: данный метод не учитывает краткосрочные эффекты. Коинтегрированные временные ряды имеют как долгосрочные зависимости (то есть постоянная связь между рядами, которая сохраняется на длительном горизонте времени), так и краткосрочные (временные колебания перед установлением долгосрочного равновесия), которые OLS игнорирует.
2) Второй подход, который позволяет учесть также и краткосрочные зависимости, — это функция импульсного отклика (Impulse Response Function, IRF). IRF описывает траекторию реакции переменной на одномоментное возмущение в . Важная характеристика функции импульсного отклика — она показывает, как быстро система восстанавливается к своему равновесию после возмущения. Например, если в модели два коинтегрированных временных ряда и , функция импульсного отклика продемонстрирует, как одномоментное увеличение повлияет на , и как долго этот эффект будет длиться. С более строгим и подробным теоретическим изложением можно ознакомиться в книге.
Так как функция импульсного отклика позволяет учесть не только долгосрочная эффекты, но и краткосрочные, именно её мы будем использовать для расчёта влияния увеличения комплектности на потери. Давайте применим IRF, чтобы количественно оценить степень влияния и получить абсолютное снижение списаний в ответ на увеличение комплектности на один процентный пункт:
Применение IRF
maxlags = int(np.around((4 * num_pairs / 100) ** (1 / 4))) # эвристика верхней границы для перебора лагов
coint_dict = {} # словарь с рангами коинтеграции для каждой пары
irf_dict = {} # словар со значениями IRF для каждой пары
horizon = 100 # max число шагов в будущее после которого считаем что irf сойдется
for idx, pair in tqdm(enumerate(series_pairs), total=num_pairs):
df_pair = pd.DataFrame({'x': pair[0] * 100, 'y': pair[1]}) # умножаем x (комплектность) на 100 для перевода в проценты
# Определяем количество лагов для включения в модель VECM
k_ar_diff = select_k_ar_diff(df_pair, maxlags)
r = johansen_trace_test(pair, k_ar_diff)
if r != 1:
continue
coint_dict[idx] = r
model = vecm.VECM(df_pair, k_ar_diff=k_ar_diff, coint_rank=r, deterministic='co')
model_fit = model.fit()
irf_dict[idx] = model_fit.irf(horizon).irfs[:, 1, 0][-1] # x -> y (комплектность -> списания)
# Отфильтруем выбросы
irfs = np.array(list(irf_dict.values()))
low_quantile = np.quantile(irfs, 0.025)
high_quantile = np.quantile(irfs, 0.975)
irfs_filtered = irfs[(irfs >= low_quantile) & (irfs <= high_quantile)]
# Визуализация
plt.figure()
plt.hist(irfs_filtered * 100, bins=10)
plt.xlabel('IRF x 10$^{-2}$')
plt.ylabel('Число магазинов')
plt.title('Гистограмма IRF')
plt.show()
В нашем подходе мы интерпретируем функцию импульсного отклика для пары временных рядов, как случайную величину. Соответственно, мы можем находить для неё точечные оценки параметров распределения и построить доверительные интервалы. Давайте найдём среднее значение IRF, а также доверительный интервал для него, исходя из всех доступных данных для магазинов сети:
Код
def calculate_mean_and_bootstrap_ci(data: np.ndarray, num_bootstrap: int = 1000, alpha: float = 0.95, seed: float = 42) -> Tuple[float, Tuple[float, float]]:
"""
Рассчитывает среднее значение и доверительный интервал с помощью бутстрепа
Параметры:
-----------
data : np.ndarray
Входные данные
num_bootstrap : int, optional
Количество бутстреп-выборок
alpha : float, optional
Уровень значимости для доверительного интервала
Возвращает:
--------
float
Среднее
tuple of float
Нижняя и верхняя границы доверительного интервала
"""
np.random.seed(seed)
mean_value = np.mean(data)
bootstrap_means = []
for _ in range(num_bootstrap):
bootstrap_sample = np.random.choice(data, size=len(data), replace=True)
bootstrap_means.append(np.mean(bootstrap_sample))
lower_bound = np.percentile(bootstrap_means, (1 - alpha) / 2 * 100)
upper_bound = np.percentile(bootstrap_means, (1 + alpha) / 2 * 100)
return mean_value, (lower_bound, upper_bound), bootstrap_means
mean, (lower_bound, upper_bound), bootstrap_means = calculate_mean_and_bootstrap_ci(irfs_filtered)
scaled_mean, scaled_lower, scaled_upper = mean * 100, lower_bound * 100, upper_bound * 100
print(f"{mean:.6f} ± {(upper_bound - mean):.6f}")
plt.figure(figsize=(6, 4))
plt.hist(np.array(bootstrap_means) * 1e2, bins=10)
plt.axvline(scaled_mean, color='red', linestyle='--', linewidth=2, label=f"Mean {scaled_mean:.3f}e-2")
plt.axvline(scaled_lower, color='green', linestyle='--', linewidth=2, label=f"Lower bound {scaled_lower:.3f}e-2")
plt.axvline(scaled_upper, color='green', linestyle='--', linewidth=2, label=f"Upper bound {scaled_upper:.3f}e-2")
plt.ylim([0, 340])
plt.xlabel('Среднее IRF x 10$^{-2}$')
plt.title('Распределение средних значений IRF, полученное с помощью бутстрепа')
plt.legend()
plt.grid(True)
plt.show()
Таким образом, мы можем заключить, что среднее IRF значимо отклоняется от нуля. Интерпретация полученного значения следующая: в среднем по сети увеличение показателя комплектности относительно текущих средних значений на один процентный пункт скоррелировано с уменьшением потерь на 0.2 процентных пункта.
Интерпретация результатов и предостережения
Очевидно, что при росте комплектности невозможно бесконечное снижение списаний: в реальных условиях существует нижний предел, который определяется непредсказуемыми факторами. Таким образом, рано или поздно списания должны выйти на насыщение. Применив линейную модель VECM в комбинации с IRF, мы линейно приблизили поведение зависимости списаний от комплектности в небольшой окрестности значений рядом с текущим средним уровнем комплектности в магазинах сети.
С практической точки зрения, функция импульсного отклика сама по себе не показывает причинно-следственную связь, поэтому для точной оценки эффекта потребуется A/B тест. Однако функция импульсного отклика полезна для предварительного анализа: она позволяет оценить потенциальные финансовые выгоды и затраты до запуска пилота. Например, если мы хотим зафиксировать снижение списаний на 1 процентный пункт, это потребует увеличения комплектности персонала приблизительно на 5 процентных пунктов для тестовой группы магазинов. Таким образом, мы даём бизнес-заказчику инструмент для принятия управленческого решения: «проводить/не проводить эксперимент».
Подведём итоги
В этой статье на примере исследования взаимосвязи комплектности персонала и списаний магазина мы показали, как эконометрический анализ с использованием модели VECM, свойства коинтеграции, а также функции импульсного отклика могут помочь бизнесу экономить миллионы рублей на заведомо сомнительных экспериментах.
Надеемся, что наш подход будет полезен и вам для дизайна инициатив и позволит принимать более взвешенные управленческие решения, минимизируя финансовые риски.
Над статьёй работали:
Сорокин Вадим — DA/DS ad-hoc X5 Tech
Лавров Денис — Team Lead ad-hoc X5 Tech
Полушкин Андрей — Team Lead ad-hoc X5 Tech
Сахнов Александр — Head of DA/DS X5 Tech