November 12, 2025

Data Science подход к прогнозированию обращений в контакт-центре

Введение

Контакт-центр является ключевым звеном взаимодействия банка с клиентом. Через него проходят обращения напрямую влияющие на выполнение операционных KPI. Таких как уровень обслуживания (SLA), время ожидания, доля успешно обработанных с первого звонка (FCR - First Call Resolution), ну и общий клиентский опыт.

Для контак-центра, а именно для входящей линии прогнозирование количества входящей линии важно. По нему можно планировать персонал: недооценка ведёт к росту времени ожидания и потери звонков. Но в то же время переоценка ведет к лишним тратам на оплату труда. Данная проблема является актуальной в X банке для которого я и написал модель прогнозирования(доля потерянных звонков находится в районе 25-35%). То есть задача прогнозирования является стратегической частью управления.

Для прогноза в данной работе используется рекурентная нейронная сеть GRU (Gated Recurrent Unit).

ChatGPT:
Главные преимущества GRU (Gated Recurrent Unit):

1. Улавливает долгосрочные зависимости во временных рядах, не теряя контекст при длинных последовательностях.

2. Меньше параметров, чем у LSTM — обучается быстрее и требует меньше данных.

3. Избегает проблемы затухающего градиента, что делает обучение стабильным.

4. Хорошо работает на нерегулярных и шумных данных, типичных для реальных бизнес-процессов (например, звонков).

В итоге GRU — это компромисс между скоростью и качеством LSTM: легче, быстрее, но всё ещё «понимает» время.

Задача направлена на построение нейросетевой модели, прогнозирующей количество входящих звонков в контакт-центр банка на горизонте 30 дней с шагом 30 минут.


Описание данных

Для того чтобы собрать данные и в дальнейшем использовать их для анализа, для начала важно понять их природу. Звонки во входящую линию можно описать в виде воронки (см. Рис. 1)

Рис. 1: Воронка входящей линии

После звонка в горячую линию, клиент попадает в IVR - Interactive Voice Response, и далее вниз по воронке. Заказчику нужен прогноз всех звонков поступающих в IVR.

Уже для построения модели использовались исторические звонки агрегированные в статистику обращений клиентов. Каждый элемент временного ряда является 30 минутным интервалом и к нему соответствует количество звонков в этом интервале.

Исходные данные содержат два признака:

  • datetime - временная метка (дата и начала интервала);
  • count - количество входящих вызов в данный интервал.

Период исторических данных: от 01.03.2025 до 10.11.2025

Пример:
 datetime           | count
 09.07.2002 02:30:00| 67
 11.09.2001 08:30:00| 11

Очистка и обработка данных

Это мой второй опыт построения временных прогнозов и вот основные шаги предобработки, к которым я пришел:

  1. Базовая очистка данных от пустых значении;
  2. Усреднение аномальных выбросов. Так как звонки явление нестабильное и аномальные выбросы могут оказать большое негативное влияние на обучение модели;
  3. Масштабирование (другими словами нормализация).

Первичная очистка

data = df_final.copy().sort_values('datetime')
data['datetime'] = pd.to_datetime(data['datetime'])
data = data.dropna(subset=['datetime', 'count']).reset_index(drop=True)
data = data.set_index('datetime').sort_index()

Рис. 2: Исходный временной ряд (посл 7 дней)

Обнаружение и замена аномалий

Для выявления выбросов использовался алгоритм Isolation Forest. Он помечает редкие значения (аномалии) как -1.

Чистка аномалии значило простая замена на медианные значения неаномальных точек.

iso = IsolationForest(contamination=0.001, random_state=42)
iso.fit(scaled)
anom_df['anomaly'] = iso.predict(scaled)
median_value = anom_df.loc[anom_df['anomaly'] == 1, 'count'].median()
anom_df.loc[anom_df['anomaly'] == -1, 'count'] = median_value
Рис. 3: Выявление аномалии во временном ряде

Масштабирование

После очистки, значения нормализовались при помощи StandardScaler, чтобы упростить для модели, абсолютные величины. Он убирает смещение и выравнивает разброс данных. Простыми словами все значения count по типу 6, 12, 143..., он размещает в одинаковый диапазон от -1 до 1. Для каждого типа задачи, существуют свои алгоритмы. Например TFIDF Vectorizor (из прошлой статьи) тоже в какой то мере является одним из таких алгоритмов так как, он помещает каждое слово в диапазон от 0 до 1.

Таблица 1: Нормализация при помощи StandardScaler
Рис. 4: Нормализация временного периода при помощи StandarScaler

Инженерия признаков (Feature Engineering)

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

Календарные признаки

Для каждого временного интервала были добавлены базовые календарные фичи:

  • день недели,
  • час,
  • половина часа,
  • признак выходного (weekend).

Дополнительно применено one-hot-кодирование для дней недели и 30 минутных временных слотов, чтобы нейронка не воспринимала их как непрерывные величины.

Пояснение:
One-hot-кодирование - это способ превратить категории в набор чисел, понятных моделей. Недели и половина часа являются категориальными.

Если просто дать модели данные типа Понедельник - 0; Вторник - 1;.. Воскресенье - 6, это не передаст "категориальность" дней недели. Другими словами, модель может решить что воскресенье больше чем понедельник. Поэтому мы создаем отдельный столбец для каждого дня недели:

weekday_0  weekday_1  ...  weekday_6
1           0              0     → Понедельник
0           1              0     → Вторник
...

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

df = data.copy()
df['weekday'] = df.index.weekday
weekday_dummies = pd.get_dummies(df['weekday'], prefix='weekday').astype(int)
df = df.join(weekday_dummies)

df['hour'] = df.index.hour
df['half'] = (df.index.minute // 30).astype(int)
df['is_weekend'] = (df['weekday'] >= 5).astype(int)
Рис. 5: Среднее (mean) количество звонков по дням недели

Лаговые признаки

Чтобы модель могла учитывать предыдущие значения ряда, созданы лаги (Inq_1 - Inq_10) - значения количества звонков за 1-10 предыдущих интервалов. Это позволяет нейронке опираться на предыдущие значения и "чувствовать" тренд.

for k in range(1, 11):
    df[f'Inq_{k}'] = df['count'].shift(k)    
Рис. 6: Корреляция между лагами и текущим значением

Пояснение:

Это тепловая карта корреляции между количеством звонков (count) и лаговыми признаками (Inq_1, Inq_2,.. Inq_10).

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

  • Значение +1 - идеальная положительная связь (если один растет то другой тоже)
  • Значение 0 - отсутствует корреляция
  • Значение -1 - идеальная отрицательная связь (то есть когда один растет, другой падает)

Скользящие статистики

Тут я думал назвать раздел просто статистические признаки, но вариант "Скользящие статистики" звучит гораздо корректнее и вот почему:

Для учёта кратковременных трендов, добавлены скользящие метрики:

  • среднее значение за последние 7 интервалов Inq_mean_7;
  • стандартное отклонение за 3 интервала Inq_std_3

Каждый последующий интервал двигает окно интервалов вперед на одну и таким образом оба признака скользят по ряду.

df['Inq_mean_7'] = df['Inq_1'].rolling(window=7).mean()
df['Inq_std_3']  = df['Inq_1'].rolling(window=3).std()
Рис. 7: Скользящее среднее и стандартное отклонение количества звонков (посл 7 дней)

Итог

Все вышеописанные признаки включая count, войдут, а именно станут основой входных данных для обучения модели.


Масштабирование и разделение данных

Этот этап состоит из двух шагов: нормализация значений и разделение временного ряда на обучающую и тестовую части.

Масштабирование признаков

Различные созданные нами признаки имею слишком разные масштабы. Например часы варьируются от 0 до 23, в то время как дни недели бинарны. GRU-модель, а скорее любая машинная модель чувствительная к подобному. Она не сможет в полной мере измерить важность значении признаков по типу 0.23, -0.98 в то время как соседний признак измеряется в сотнях типа 231, 563.

Рис. 8: Иллюстрация нормализации

Чтобы уровнять все признаки, все численные колонки (лаги, средние и тд) приводятся к диапазону от -1 до 1 с помощью MinMaxScaler.

from sklearn.preprocessing import MinMaxScaler

target_col = 'count'
y = df[target_col].copy()
X = df.drop(columns=[target_col, 'weekday', 'time_of_day'])

num_cols = [c for c in X.columns if c.startswith('Inq_') or c in ['hour', 'half']]
scaler_x = MinMaxScaler()
X_scaled = X.copy()
X_scaled[num_cols] = scaler_x.fit_transform(X[num_cols])

scaler_y = MinMaxScaler()
y_scaled = scaler_y.fit_transform(y.values.reshape(-1, 1))

Разделение на обучающую и тестовую выборки

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

  • обучающая часть - все данные до последних 30 дней;
  • тестовая часть - последние 30 дней (1440 строк по 30 минутным интервалам)

Это нужно чтоб модель училась на прошлом и проверялась на будущем.

test_len = 48 * 30  # 30 дней по 48 интервалов в каждом
X_train, X_test = X_scaled.iloc[:-test_len], X_scaled.iloc[-test_len:]
y_train, y_test = y_scaled[:-test_len], y_scaled[-test_len:]

Подготовка формы

Ожидается вход трехмерных данных:

[samples, time_steps, features]

где time_steps - количество временных шагов (1).

X_train_np = X_train.to_numpy().reshape(-1, 1, X_train.shape[1])
X_test_np  = X_test.to_numpy().reshape(-1, 1, X_test.shape[1])
Рис. 9: Разделение временного ряда на train и test

Архитектура модели GRU

С подготовкой покончено, и мы можем переходить к построению нейросетевой модели.

Почему именно GRU?

Рис. 10: Грю из мультфильма Гадкий Я

Она "помнит" недавние значения временного ряда и понимает, как текущие данные зависят от прошлых.

ChatGPT:
GRU эффективна, когда:
- нужно учитывать последовательность событий (время важно),
- данные содержат сезонные колебания,
- требуется баланс между скоростью и точностью.

Структура сети

Слои модели:

  1. GRU(128) - первый слой с 128 нейронами, извлекает временные зависимости;
  2. Dropout(0.3) - случайно отключает 30% нейронов при обучении, чтобы избежать переобучения;
  3. GRU(64) - второй слой, обрабатывает более абстрактные паттерны;
  4. Dense(32, relu) - полносвязный слой, делает нелинейное преобразование признаков;
  5. Dense(1) - выходной слой, выдает одно число - то есть сам прогноз звонков.

Настоятельно рекомендую посетить YouTube канал 3Blue1Brown, чтобы лучше понять как работают слои с красивой визуализацией и четким объяснением.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import GRU, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping

model = Sequential([
    GRU(128, return_sequences=True, input_shape=(1, X_train.shape[1])),
    Dropout(0.3),
    GRU(64, return_sequences=False),
    Dense(32, activation='relu'),
    Dense(1)
])

model.compile(optimizer='adam', loss='mse', metrics=['mae'])

es = EarlyStopping(monitor='val_loss', patience=20, restore_best_weights=True)

history = model.fit(
    X_train_np, y_train,
    validation_data=(X_test_np, y_test),
    epochs=120, batch_size=256,
    callbacks=[es], verbose=0
)

Обучение и контроль качества

В процессе обучения используется функция потерь MSE (Mean Square Error) - чем она меньше, тем ближе прогноз к реальности. Метрика MAE (Mean Absolute Error) используется для оценки средней ошибки.

Рис. 11: Динамика ошибок обучения и валидации

Пояснение

То что вы видите на оси X, это количество эпох. То есть каждая попытка предсказания это одна итерация (эпоха).


Результат на тестовой сборке

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

Расчет метрик качества

Для оценки точности использованы три классические метрики регрессии (вспоминаем что такое регрессия с прошлой статьи ;Р):

ChatGPT:
- MAE (Mean Absolute Error) — средняя ошибка в единицах измерения (например, “в среднем на 25 звонков в час ошибается”);
- RMSE (Root Mean Squared Error) — более чувствительна к крупным ошибкам;
- R² (коэффициент детерминации) — показывает, насколько хорошо модель объясняет изменения данных (1.0 — идеал, 0 — “не лучше случайного”).
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
import numpy as np

y_pred_scaled = model.predict(X_test_np, verbose=0)
y_pred = scaler_y.inverse_transform(y_pred_scaled)
y_test_real = scaler_y.inverse_transform(y_test)

mae  = mean_absolute_error(y_test_real, y_pred)
rmse = np.sqrt(mean_squared_error(y_test_real, y_pred))
r2   = r2_score(y_test_real, y_pred)

print(f"Test MAE:  {mae:,.2f}")
print(f"Test RMSE: {rmse:,.2f}")
print(f"Test R²:   {r2:,.4f}")
Рис. 12: Прогноза временного ряда на тестовом наборе
Test MAE:  6.482
Test RMSE: 9.638
Test R²:   0.9463

Итеративный прогноз на 30 дней

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

Принцип итеративного прогноза

  1. Берется последняя строка данных.
  2. Модель предсказывает, сколько звонков будет на следующий 30 минутный интервал.
  3. Это предсказанное значение добавляется в данные как "новое прошлое".
  4. На основе обновленных лагов (Inq_1 - Inq-10) модель делает следующее предсказание.
future_steps = 48 * 30  
# ШАГ 1
last_row = X.iloc[[-1]].copy()        # последняя строка немасштабированных признаков
last_feats_scaled = X_scaled.iloc[[-1]].copy()  # последняя строка в масштабе

forecast_vals = []
future_index = pd.date_range(df.index[-1] + pd.Timedelta(minutes=30),
                             periods=future_steps, freq='30min')

for t in range(future_steps):
# ШАГ 2
    x_in = last_feats_scaled.to_numpy().reshape(1, 1, -1)
    y_next_scaled = model.predict(x_in, verbose=0)[0][0]
    y_next = scaler_y.inverse_transform([[y_next_scaled]])[0][0]
# ШАГ 3
    forecast_vals.append(y_next)
# C ЭТОГО МОМЕНТА ОБНОВЛЯЕМ СКОЛЬЗЯЩИЕ ПРИЗНАКИ И ГОТОВИМ ДАННЫЕ К ПРЕДСКАЗАНИЮ СЛЕД ИНТЕРВАЛА
    next_time = future_index[t]
    nr = last_row.copy()
    nr.index = pd.DatetimeIndex([next_time])

    for k in range(10, 1, -1):
        nr[f'Inq_{k}'] = last_row[f'Inq_{k-1}'].values[0]
    nr['Inq_1'] = y_next

    recent = [nr[f'Inq_{i}'].values[0] for i in range(1, 8)]
    nr['Inq_mean_7'] = np.mean(recent)
    nr['Inq_std_3']  = np.std(recent[:3])

    nr['hour'] = next_time.hour
    nr['half'] = next_time.minute // 30
    nr['is_weekend'] = int(next_time.weekday() >= 5)

    nr_scaled = nr.copy()
    nr_scaled[num_cols] = scaler_x.transform(nr[num_cols])

    last_row = nr.copy()
    last_feats_scaled = nr_scaled.copy()

forecast_df = pd.DataFrame({'datetime': future_index, 'forecast': forecast_vals})
Рис. 13: Временной ряд прогнозов на следующие 30

Конец

Спасибо все кто дочитал досюда эту статейку!