- PVSM.RU - https://www.pvsm.ru -
Всем привет! Это небольшой рассказ про то, как команда Центра компетенции больших данных и искусственного интеллекта в ЛАНИТ [1] оптимизировала работу банкоматной сети. Упор в статье сделан не на описание подбора параметров и выбор лучшего алгоритма прогнозирования, а на рассмотрение концепции нашего подхода к решению поставленной задачи. Кому интересно, добро пожаловать под кат.
источник [2]
Банки теряют довольно много денег из-за того, что они просто лежат в банкоматах. Вместо этого деньги можно было бы успешно вложить и получать доход. Сегодня крупные банки с сильными аналитическими центрами имеют свои решения для того, чтобы рассчитать количество купюр и время, на которое их нужно заложить в банкоматы до следующей инкассации. Небольшие же игроки, не имея таких решений, просто ориентируются на средние значения, полученные в ходе предыдущих поездок инкассаторов.
Таким образом, формальная постановка задачи выглядит так.
На входе:
На выходе ожидается:
Разработка велась совместно с @vladbalv [3], от которого поступило множество предложений, в том числе ключевых, которые легли в основу описываемого решения.
Общая идея основана на нахождении минимума затрат как функции от количества дней между инкассациями. Для объяснения конечного решения можно сначала рассмотреть упрощенный вариант — в предположении, что сумма снимаемых денег не меняется изо дня в день и равна S, и что она только убывает. В таком случае сумма денег в банкомате является убывающей арифметической прогрессией.
Предположим, что в день снимают S руб. Помимо суммы снятий, введем также переменную X — число дней между инкассациями, меняя которую будем дальше искать минимум затрат банка. Логично, что сумма, которую выгоднее всего положить в банкомат, зная, что инкассация будет через X дней это S*X. При таком подходе за день до инкассации в банкомате будет находиться S руб., за два дня — 2*S руб., за три дня — 3*S руб. и т. д. Другими словами, наш ряд можно рассматривать, двигаясь от конца к началу, тогда это будет возрастающая арифметическая прогрессия. Поэтому за период между двумя инкассациями в банкомате будет лежать (S+S*X)/2 руб. Теперь, исходя из ставки рефинансирования, остаётся посчитать стоимость простаивающих запасов этой суммы за X дней и дополнительно прибавить стоимость совершённых инкассаторских поездок. Если между инкассациями X дней, то за n дней будет совершено (где — это целочисленное деление) инкассаций, поскольку ещё один раз придётся приехать, чтобы вывести остаток денег.
Таким образом, получившаяся функция выглядит так:
где:
Однако в реальности каждый день снимают разные суммы, поэтому у нас есть ряд снятий/внесений купюр, каждый день этот ряд пополняется новыми значениями. Если это учесть, функция примет следующий вид:
Что такое убывающие и возрастающие суммы: в зависимости от того, больше кладут или больше снимают, есть купюры, по которым сумма в банкомате накапливается, а есть купюры, по которым сумма в банкомате убывает. Таким образом формируются возрастающие и убывающие суммы купюр. В реализации было сделано три ряда: incr_sums — возрастающие купюры, decr_sums — убывающие купюры и withdrawal_sums — ряд сумм выдач банкомата (там присутствуют купюры, которые идут только на выдачу).
Также важный момент: если сумма возрастающая, то нам не нужно закладывать целиком всю сумму целого ряда, это необходимо делать только для убывающих сумм, поскольку они должны полностью исчезнуть к концу периода инкассации. Для возрастающих сумм мы решили закладывать сумму на три дня в качестве подушки безопасности, если что-то пойдёт не так.
Помимо прочего, чтобы применить описанную функцию, нужно учесть следующие моменты.
○ только на внос/вынос,
○ на внос и вынос одновременно,
○ на внос и вынос одновременно + ресайклинг (за счёт ресайклинга у банкомата есть возможность выдавать купюры, которые в него вносят другие клиенты).
Рис. 1. Значения функции TotalCost в зависимости от X (Days betw incas) и n (Num of considered days)
Чтобы избавиться от n, можно воспользоваться простым трюком — просто разделить значение функции на n. При этом мы усредняем и получаем среднюю величину стоимости затрат в день. Теперь функция затрат зависит только от количества дней между инкассациями. Это как раз тот параметр, по которому мы будем её минимизировать.
Учитывая вышесказанное, реальная функция будет иметь следующий шаблон:
TotalCost(n, x, incr_sums, decr_sums, withdrawal_sums, si), где
Функция проходит непересекающимся окном величиной X по входным рядам и считает суммы внесенных денег внутри окна. В отличие от первоначальной функции сумма арифметической прогрессии здесь превращается в обычную сумму (это та сумма, которая была заложена при инкассации). Далее внутри окна в цикле по дням происходит кумулятивное суммирование/вычитание сумм, которые ежедневно клались/снимались из банкомата. Это делается для того, чтобы получить сумму, которая лежала в банкомате на каждый день.
Затем из полученных сумм при помощи ставки рефинансирования рассчитывается стоимость затрат на простаивающие запасы, а также в конце прибавляются затраты от инкассаторских поездок.
def process_intervals(n, x, incr_sums, decr_sums, withdrawal_sums):
# генератор количества сумм, которые
# остаются в банкомате на каждый день
# incr_sums — ряд возрастающих сумм
# decr_sums — ряд убывающих сумм
# withdrawal_sums — ряд сумм выдач банкомата (там присутствуют купюры, которые идут только на выдачу)
# заполняется 0 для всех банкоматов кроме ресайклинговых
# x — количество дней между инкассациями
# n — количество дней, которые отслеживаем
if x>n: return
for i in range(n//x):
decr_interval = decr_sums[i*x:i*x+x]
incr_interval = incr_sums[i*x:i*x+x]
withdrawal_interval = withdrawal_sums[i*x:i*x+x]
interval_sum = np.sum(decr_interval)
interval_sum += np.sum(withdrawal_interval[:3])
for i, day_sum in enumerate(decr_interval):
interval_sum -= day_sum
interval_sum += incr_interval[i]
interval_sum += withdrawal_interval[i]
yield interval_sum
# остаток сумм. Берется целый интервал.
# но yield только для остатка ряда
decr_interval = decr_sums[(n//x)*x:(n//x)*x+x]
incr_interval = incr_sums[(n//x)*x:(n//x)*x+x]
withdrawal_interval = withdrawal_sums[(n//x)*x:(n//x)*x+x]
interval_sum = np.sum(decr_interval)
interval_sum += np.sum(withdrawal_sums[:3])
for i, day_sum in enumerate(decr_interval[:n-(n//x)*x]):
interval_sum -= day_sum
interval_sum += incr_interval[i]
interval_sum += withdrawal_sums[i]
yield interval_sum
def waiting_cost(n, x, incr_sums, decr_sums, withdrawal_sums, si):
# incr_sums — ряд возрастающих сумм
# decr_sums — ряд убывающих сумм
# withdrawal_sums — ряд сумм выдач банкомата (там присутствуют купюры, которые идут только на выдачу)
# заполняется 0 для всех банкоматов кроме ресайклинговых
# si — стоимость инкассации
# x — количество дней между инкассациями
# n — количество дней, которое отслеживаем
assert len(incr_sums)==len(decr_sums)
q = 4.25/100/365
processed_sums = list(process_intervals(n, x, incr_sums, decr_sums, withdrawal_sums))
# waiting_cost = np.sum(processed_sums)*q + si*(x+1)*n//x
waiting_cost = np.sum(processed_sums)*q + si*(n//x) + si
# делим на n, чтобы получить среднюю сумму в день (не зависящее от количества дней)
return waiting_cost/n
def TotalCost (incr_sums, decr_sums, withdrawal_sums, x_max=14, n=None, si=2500):
# x — количество дней между инкассациями
# n — количество дней, которое отслеживаем
assert len(incr_sums)==len(decr_sums) and len(decr_sums)==len(withdrawal_sums)
X = np.arange(1, x_max)
if n is None: n=len(incr_sums)
incr_sums = incr_sums[-n:]
decr_sums = decr_sums[-n:]
withdrawal_sums = withdrawal_sums[-n:]
waiting_cost_sums = np.zeros(len(X))
for i, x in enumerate(X):
waiting_cost_sums[i] = waiting_cost(n, x, incr_sums, decr_sums, withdrawal_sums, si)
return waiting_cost_sums
Теперь применим эту функцию к историческим данным наших банкоматов и получим следующую картинку:
Рис. 2. Оптимальное количество дней между инкассациями
Резкие перепады на некоторых графиках объясняются провалами в данных. А то, что они произошли в одинаковое время, скорее всего можно объяснить техническими работами, на время которых банкоматы не работали.
Дальше нужно сделать прогноз снятия/зачисления наличности, применить к нему эту функцию, найти оптимальный день инкассации и загрузить определенное количество купюр по сделанному прогнозу.
В зависимости от суммы денег, находящейся в обращении (чем меньше денег проходит через банкомат, тем дольше оптимальный срок инкассации), меняется количество оптимальных дней между инкассациями. Бывают случаи, когда это количество больше 30. Прогноз на такой большой период времени будет слишком большой ошибкой. Поэтому вначале происходит оценка по историческим данным, если она меньше 14 дней (это значение было выбрано эмпирически, потому что оптимальное количество дней до инкассации у большинства банкоматов меньше 14 дней, а также потому, что чем дальше горизонт прогнозирования, тем больше его ошибка), то в дальнейшем для определения оптимального количества дней между инкассациями используется прогноз по временному ряду, иначе по историческим данным.
Подробно останавливаться на том, как делается прогноз снятий и зачислений не буду. Если есть интерес к этой теме, то можно посмотреть видеодоклад о решении подобной задачи исследователями из Сбербанка (Data Science на примере управления банкоматной сетью банка [4]).
Из всего опробованного нами лучше всего показал себя CatBoostRegressor, регрессоры из sklearn немного отставали по качеству. Возможно, здесь не последнюю роль сыграло то, что после всех фильтраций и отделения валидационной выборки, в обучающей выборке осталось всего несколько сотен объектов.
Prophet показал себя плохо. SARIMA не стали пробовать, поскольку в видео выше о нем плохие отзывы.
Используемые признаки (всего их было 139, после признака приведено его обозначение на графике feature importance ниже)
Важность признаков для модели следующая (приведена модель для прогноза снятий купюры номиналом в 1000 руб. на один день вперед):
Рис. 3.Feature importance используемых признаков
Выводы по картинкам выше — наибольший вклад сделали лаговые признаки, дельты между ними, полиномиальные признаки и тригонометрические функции от даты/времени. Важно, что в своём прогнозе модель не опирается на какую-то одну фичу, а важность признаков равномерно убывает (правая часть графика на рис. 2).
Сам график прогноза выглядит следующим образом (по оси x отложены дни, по оси y количество купюр):
Рис. 4 — Прогноз CatBoostRegressor
По нему видно, что CatBoost всё же плохо выделяет пики (несмотря на то, что в ходе предварительной обработки выбросы были заменены на 95 перцентиль), но общую закономерность отлавливает, хотя случаются и грубые ошибки, как на примере провала в районе шестидесяти дней.
Ошибка по MAE там колеблется в примерном диапазоне от нескольких десятков до ста. Успешность прогноза сильно зависит от конкретного банкомата. Соответственно, величину реальной ошибки определяет номинал купюры.
Общий пайплайн работы выглядит следующим образом (пересчёт всех значений происходит раз в день).
Полученный профит мы посчитали следующим образом: взяли данные по снятиям/внесениям за последние три месяца и из этого промежутка по дню, как если бы к нам приходили ежедневные данные от банкоматов.
Для этих трёх месяцев рассчитали стоимость простаивающих запасов денег и инкассаторских поездок для исторических данных и для результата работы нашей системы. Получился профит. Усредненные за день величины приведены в таблице ниже (названия банкоматов заменены на латинские символы):
atm | profit(relative) | ≈profit/day (руб.) |
a | 0.61 | 367 |
b | 0.68 | 557 |
с | 0.70 | 470 |
d | 0.79 | 552 |
e | -0.30 | -66 |
f | 0.49 | 102 |
g | 0.41 | 128 |
h | 0.49 | 98 |
i | 0.34 | 112 |
j | 0.48 | 120 |
k | -0.01 | -2 |
l | -0.43 | -26 |
m | 0.127 | 34 |
n | -0.03 | -4 |
o | -0.21 | -57 |
p | 0.14 | 24 |
q | -0.21 | -37 |
Подходы и улучшения, которые интересно рассмотреть, но пока не реализованы на практике (в силу комплексности их реализации и ограниченности во времени):
В заключение можно отметить, что временные ряды снятий/внесений наличности поддаются прогнозированию, и что в целом предложенный подход к оптимизации работы банкоматов является довольно успешным. При грубой оценке текущих результатов работы в день получается сэкономить около 2400 руб., соответственно, в месяц это — 72 тыс. руб., а в год — порядка 0,9 млн руб. Причём чем больше суммы денег, находящихся в обращении у банкомата, тем большего профита можно достичь (поскольку при небольших суммах профит нивелируется ошибкой от прогноза).
За ценные советы при подготовке статьи большая благодарность vladbalv [5] и art_pro [6].
Спасибо за внимание!
Автор: ervin-x
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/363513
Ссылки в тексте:
[1] ЛАНИТ: http://www.lanit.ru
[2] источник: https://www.sondakika.com/haber/haber-tek-bir-sms-in-marifetleri-5835560/
[3] vladbalv: https://habr.com/users/vladbalv/
[4] Data Science на примере управления банкоматной сетью банка: https://www.youtube.com/watch?v=s7cYqaiA2mE
[5] vladbalv: https://habr.com/ru/users/vladbalv/
[6] art_pro: https://habr.com/ru/users/art_pro/
[7] Источник: https://habr.com/ru/post/552732/?utm_source=habrahabr&utm_medium=rss&utm_campaign=552732
Нажмите здесь для печати.