В данной статье мы, Advanced Analytics GlowByte, расскажем, как нам удалось ускорить решение задачи NBO на open-source солвере CBC примерно в 100 раз и добиться повышения оптимального значения целевой функции на 0,5%.

Введение

Задача NBO в нашем случае – это составление оптимального плана коммуникаций с клиентской базой на ближайшую неделю в различных каналах (звонок, смс и т. д.). Более подробно о постановке такой задачи и ее решении мы рассказывали в статье и на семинаре в нашем комьюнити NoML.

Напомним кратко смысл задачи и ее математическую постановку. У нас есть база данных из N клиентов банка. Мы хотим предлагать им определенные продукты: кредит, депозит и т. д. Для этого мы можем контактировать с ними по определенным каналам: можем позвонить, отправить СМС и т. д. Допустим, для каждого клиента мы оценили вероятность отклика этого клиента на предложение данного продукта по данному каналу. Под откликом имеем в виду открытие продукта клиентом в течение некоторого времени после того, как мы предложим этот продукт клиенту. Мы хотим составить план коммуникаций на неделю, т. е. ответить на вопрос, каким клиентам какие продукты по каким каналам предлагать, чтобы максимизировать ожидаемое число откликов и чтобы соблюсти ряд бизнес-требований:

  • с клиентом можно коммуницировать не более одного раза; 

  • у клиентов могут быть запрещенные продукты или каналы, по которым коммуницировать нельзя;

  • есть ограничения на суммарное число коммуникаций по парам "продукт-канал". 

Эту задачу можно сформулировать в виде целочисленной задачи линейного

программирования:

\begin{equation*}\begin{array}{ll@{}ll}\text{maximize}  & \displaystyle\sum\limits_{i}\displaystyle\sum\limits_{g \in G_{i}} C_{i,g}\ x_{i,g} \\\text{subject to} & \displaystyle\sum\limits_{g \in G_{i}} x_{i,g} <= 1, & \quad \forall i\\                         & m_{g} <= \displaystyle\sum\limits_{i: g \in G_{i}} x_{i,g} <= M_{g}, & \quad \forall g\\                         & x_{i,g} \in \{0, 1\}, & \quad \forall i,\ \forall g \in G_{i}\\\end{array}\end{equation*}

i — индекс клиента.

g — индекс пары продукт-канал. Далее пару продукт-канал будем называть коллгруппа (callgroup).

x_{i,g} — переменные задачи. Они бинарные, если x_{i,g} = 1,то с клиентом i нужно проконтактировать по коллгруппе g.Если x_{i,g} = 0, то ненужно.

C_{i,g}— вероятность отклика клиента i на коммуникацию по каналу, соответствующему коллгруппе g.

G_{i} — множество индексов коллгрупп g, по которым можно коммуницировать с клиентом i.

m_g, M_g — мин. и макс. допустимый объем коммуникаций (капасити) для коллгруппы g.

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

Сложности и их текущие решения

Число клиентов, для которых мы хотим составлять планы коммуникаций, — десятки миллионов. Число переменных и ограничений в задаче оптимизации — сотни миллионов. При таком размере эта задача решается полтора дня, что слишком долго. У нас есть требование на время решения задачи — примерно 20 минут.

Чтобы решить эту проблему, делалось следующее приближение. Вся клиентская база разбивалась на маленькие группы — чанки (chunks). Для каждого чанка ставилась такая же задача оптимизации, как описано в предыдущем разделе, только m_g и M_g делились на число чанков. Далее задачи решались по отдельности.

m_{g} = \frac{m_g^{total}}{N_{chunks}}, \quad M_{g} = \frac{M_g^{total}}{N_{chunks}},

N_{chunks}— число чанков.m_g, M_g — мин. и макс. капасити для коллгруппы g для одного чанка. m_g^{total}, M_g^{total} - мин. и макс. капасити для коллгруппы g для всей клиентской базы.

Множество "маленьких" задач решаются быстрее, чем одна большая, так как время решения задачи увеличивается нелинейно с ростом числа переменных. К тому же, разбивая большую задачу на множество меньших, мы можем распараллелить процесс и значительно ускорить вычисления. Однако такое ускорение имеет свою цену: существует риск получить неоптимальный план коммуникаций. Разбиение задачи накладывает отдельные ограничения на количество коммуникаций внутри каждой подзадачи, которые могут быть жестче, чем в исходной задаче. Это приводит к тому, что часть допустимых решений, включая оптимальные, может быть упущена. В результате итоговый план коммуникаций, составленный на основе решений подзадач, может оказаться субоптимальным.

Цель нашего эксперимента — увеличить размер чанков (подзадач), тем самым уменьшив их количество, и оценить, как это отразится на росте целевой функции. Важно, чтобы при этом время решения не увеличилось. Основной вызов заключается в оптимизации времени решения. Поэтому, прежде чем увеличивать размер чанков, мы должны ускорить процесс решения задач.

Правим настройки CBC

Изменение настроек дало значительный прирост скорости на нашей задаче. Ниже перечислены настройки, которые мы переопределили, и объяснения, зачем это было сделано.

preprocess=off. Эта настройка отключает преобразования задачи, связанные с условиями целочисленности. Эти преобразования опциональные, их смысл — ускорить решение задачи. В нашей задаче от этих преобразований мало пользы, и на них уходит много времени. В итоге отключить препроцессинг оказывается более выгодно.

heur=off, rens=on. Эта пара настроек говорит CBC использовать только одну эвристику — RENS. В нашей задаче релаксация сразу находит целочисленное решение. Но CBC все равно тратит время на вызов эвристик. Поэтому мы оставляем только одну, которая отрабатывает быстро. В принципе можно было отключить эвристики вообще, но разницы во времени это не дает. Мы оставили одну эвристику на всякий случай.

zero=off. Эта настройка отключает один из методов отсечений. В нашей задаче это не нужно, потому что и без них мы находим хорошее решение. Но на этот метод уходит заметное время.

presolve=off. Эта настройка отключает общие преобразования задачи (не обязательно связанные с целочисленностью). Их смысл в том, чтобы эквивалентными преобразованиями упростить постановку задачи. Например уменьшить число переменных или ограничений. Трудно наверняка предсказать, как это повлияет на дальнейший ход решения, но обычно уменьшение размера задачи ускоряет решение. Однако в нашей задаче пресолв не помогает. Без него задача решается заметно быстрее.

slog=0. Эта отключает некоторые логи, которые нам не интересны. Мы это делаем, чтобы не засорять логи в Airflow. На перформанс это не влияет.

Переопределяем вызов CBC из Pyomo

Pyomo — это питоновская библиотека для постановки оптимизационных задач. В этом разделе описаны некоторые шаги, которые пришлось сделать, чтобы обойти пару проблем с CBC и Pyomo. Это дало в итоге значительный прирост скорости.

Pyomo всегда вызывает CBC с настройкой -stat=1, которая говорит ему собрать статистику о задаче и написать это в лог. Нам это не нужно, т. к. на ход решения это никак не влияет. В нашем случае на сбор этой статистики времени уходит значительно больше, чем на само решение. В интерфейсе Pyomo возможности не передавать эту настройку нет. Мы зарепортили это в github репе Pyomo. Авторы согласились взять это в работу, но неизвестно, когда это будет сделано. А пока мы сделали костыль, чтобы не передавать эту настройку в CBC.

Обычно для решения задачи в CBC используется его команда solve. Когда Pyomo вызывает CBC, он использует именно ее. Мы заметили, что настройка presolve=off не имеет эффекта, если вызвать эту команду. Но если вместо solve использовать две последовательные CBC команды — initialSolve, branch, то настройка имеет эффект. По смыслу эти две команды делают то же самое, что и одна команда solve. Видимо, в CBC есть проблема с тем, чтобы применить эту настройку из команды solve. Для того, чтобы применить presolve=off настройку, мы сделали костыль, чтобы Pyomo при вызове CBC использовал пару команд initialSolve, branch вместо solve.

Вводим вспомогательные переменные в задачу

Существенное ускорение при увеличении размера чанков дал следующий подход. Введем новые переменные как суммы исходных переменных x с одинаковыми коэффициентами целевой функции и одинаковой коллгруппой. И перепишем через эти переменные целевую функцию и ограничения на капасити. Составим множество уникальных коэффициентов целевой функции в задаче и пронумеруем их индексом p.

\begin{equation*} \begin{array}{ll@{}ll} \text{maximize}  & \displaystyle\sum\limits_{p,g} C_{p}\ n_{p,g} \\ \text{subject to} & \displaystyle\sum\limits_{g \in G_{i}} x_{i,g} <= 1, & \quad \forall i\\                          & m_{g} <= \displaystyle\sum\limits_{p} n_{p,g} <= M_{g}, \quad \forall g\\                          & n_{p,g} = \sum\limits_{i: C_{i,g}=C_{p}} x_{i,g}, & \quad \forall p, \forall g\\                          & x_{i,g} \in \{0, 1\}, & \quad \forall i,\ \forall g \in G_{i}\\ \end{array} \end{equation*}

p — индекс уникального коеффициента целевой функции.

C_{p} — уникальные значения коэффициентов C_{i,g}.

Остальные обозначения такие же, как в исходной задаче.

Результаты

Мы сравнили время решения задачи до и после изменений. Пробовали разные размеры чанков и замеряли относительный прирост скорости. Результат показан на графике ниже.

Рис 1. Размер чанков (подзадач) отложен по оси x. Значение "размер чанков = 1" — это исходное разбиение на чанки (которое использовалось до наших изменений). Оно равно 30. Значение "размер чанков = 3" — увеличение размера чанков в 3 раза, то есть имеем 10 чанков для той же клиентской базы. Замерялось время на обсчет клиентской базы. Чанки считались последовательно.
Рис 1. Размер чанков (подзадач) отложен по оси x. Значение "размер чанков = 1" — это исходное разбиение на чанки (которое использовалось до наших изменений). Оно равно 30. Значение "размер чанков = 3" — увеличение размера чанков в 3 раза, то есть имеем 10 чанков для той же клиентской базы. Замерялось время на обсчет клиентской базы. Чанки считались последовательно.

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

Теперь, когда нам удалось значительно ускорить процесс, мы можем решать более крупные подзадачи за то же время. Это позволяет увеличить размер чанков (уменьшить количество подзадач), на которые разбивается исходная задача. Вопрос заключается в том, насколько имеет смысл увеличивать размер. Чтобы ответить на этот вопрос, мы провели измерения прироста целевой функции при увеличении размера чанков (Рис. 2).

Рис 2. График показывает на сколько увеличивается целевая функция при увеличении размера чанков (уменьшении количества подзадач). Смысл оси x такой же, как и на Рис 1.
Рис 2. График показывает на сколько увеличивается целевая функция при увеличении размера чанков (уменьшении количества подзадач). Смысл оси x такой же, как и на Рис 1.

Прирост измерялся относительно разбиения на 150 подзадач (точка с "размер чанков=1"), поэтому для этой точки прирост равен нулю. Максимальный прирост составил около 0,5%. Увеличение размера чанков более чем в 30 раз не дает значительного прироста. Однако время решения значительно увеличивается с ростом размера чанков. Поэтому мы остановились на увеличении размера чанков в 30 раз, что соответствует сокращению их числа со 150 до 5.

Над статьей работали @Lazarev_Adrian@anikonorov@vagonoff

P. S. Про то, как мы ускоряем оптимизационные open-source солверы, мы рассказывали на одном из семинаров нашего сообщества NoML.

Комментарии (5)


  1. wataru
    16.09.2024 10:34
    +1

    Эта задача же решается через min cost max flow. Не надо тут линейного целочисленного программирования. Графовые алгоритмы могут работать лучше оптимизаторов. В одной доле у вас будут клиенты, в другой call-group-ы. Из клиента в сток ребро пропусконой способностью 1. В колгруппу из истока ребро с минимальным ограниченим mg и максимальным Mg. Между клиентом и колл-гуппой ребро с пропускной способностью 1 и ценой минус вероятность отклика.

    Да, у вас очень много вершин и ребер, так что нужно будет что-то очень продвинутое. Вроде какого-нибудь push-relabel. Возможно какие-то евристические методы поиска приближенного решения тут будут работать очень хорошо. Например, можно отсортировать клиентов и брать их группами. Решать задачу отдельно в каждой группе с уменьшенными ограничениями Mg. Или решать задачу итерационно - после нахождения потока в лучшей группе, добавляем следующую группу клиентов и работаем в остаточной сети.


    1. vagonoff Автор
      16.09.2024 10:34

      Не надо тут линейного целочисленного программирования

      Почему не надо, если оно работает и работает хорошо?)

       Графовые алгоритмы могут работать лучше оптимизаторов.

      Лучше с точки зрения скорости или оптимальности решения?)

      Если хотите, можете реализовать эту задачу на графах и выступить на семинаре нашего комьюнити NoML. Будем рады послушать и сравнить результаты.

      P.S. Графовые алгоритмы это круто, но в более общем случае, когда нужна выполнимость сложных контактно-продуктовых политик, непонятно, как это можно сделать на графах. Про более сложные постановки задачи и про то, как мы их ускоряем, мы рассказали на одном из наших семинаров.


      1. wataru
        16.09.2024 10:34

        Лучше с точки зрения скорости или оптимальности решения?)

        Скорости решения.

        но в более общем случае, когда нужна выполнимость сложных контактно-продуктовых политик, непонятно, как это можно сделать на графах

        Возможно, но более общий случай вы не привели, а приведенная задача - решается через min cost max flow.


        1. vagonoff Автор
          16.09.2024 10:34

          Скорости решения.

          А что с оптимальностью будет? Скорость можно по разному повышать -- например, разбивая на подзадачи, как это и делалось изначально. Но это может приводить к субоптимальному решению.

          В общем наше приглашение на семинар в силе. Если что пишите в ЛС)


          1. wataru
            16.09.2024 10:34
            +2

            > А что с оптимальностью будет?

            Это точные алгоримты. Они находят глобальный минимум целевой функции.

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