Несколько раз в год отдел оптимизации офисного пространства Альфа‑Банка думает над тем, как разместить сотрудников бэк‑офиса по локациям на несколько лет вперёд. Раньше ребята делали это вручную: было медленно (месяц работы), больно (Excel) и неоптимально (никто не мог гарантировать, что найденная рассадка удовлетворяет всем ограничениям).
Коллеги хотели автоматизировать ручную работу — с этим они и пришли к нам.
В целом, получаем самую стандартную задачу оптимизации, которая решается с помощью одной библиотеки. Постараюсь рассказать о некоторых нюансах, которые не с первого раза гуглятся (со второго).
Условия задачи и ограничения
Немного контекста.
Бэк‑офис — это внутренние подразделения банка, которые обеспечивают его работу, но не взаимодействуют напрямую с клиентами, например, IT или HR. Сотрудники бэк‑офиса находятся по всей России, но мы будем говорить только про Москву (задача рассадки сотрудников в регионах перед нами пока не стояла).
Москва разделена на локации, например, Комсомольская или Технопарк. Внутри локации есть блоки, в которых обычно по 2-4 офиса.

Далее есть БЛ — бизнес-линии (что-то вроде департамента), их обычно несколько десятков. Для каждой бизнес-линии есть некоторое количество требуемых мест для рассадки на несколько лет вперед. Нужно найти рассадку, которая учитывала бы множество ограничений:
Есть БЛ, которые нельзя делить по разным офисам/блокам/локациям.
Некоторые БЛ должны занимать только ближайшие этажи.
Нужно учесть количество требуемых кабинетов на этажах и закрытые контуры.
И ещё некоторое количество требований, которые мы опустим.
В целом, задача звучала так:
Автоматизировать ручной процесс с учётом множества бизнес-правил и ограничений, найти самое оптимальное решение и в итоге получить план рассадки сразу на несколько лет вперед с учетом роста потребности в местах.
Рассчитать в Excel такое сложно. Представьте, что в каком-то департаменте в следующем году появится 500 человек и их придется досаживать на ближайшие этажи и брать где-то еще места…
Если вы вдруг возьметесь за эту задачу, то, вероятнее всего, первым делом захотите узнать, как это делают в других компаниях. И не узнаете)) Почему-то такой информацией никто не делится. Сейчас мы это исправим.
Про задачу оптимизации и CP-SAT
Задачу оптимизации в общем виде можно сформулировать следующим образом:
Необходимо найти значения переменных, которые минимизируют или максимизируют целевую функцию, при этом удовлетворяют заданным ограничениям.
В нашем случае мы должны найти такую рассадку, которая бы удовлетворяла бизнес-требованиям и минимизировала количество нерассаженных сотрудников БЛ.
Посмотрим, как можно решать подобные задачи.
Подход №1: CP (Constraint Programming)
CP (Constraint Programming) использует декларативный подход для решения задач выполнимости — поиска набора значений переменных, удовлетворяющего всем ограничениям (отсюда constraint в названии метода).
Если в императивном подходе мы описываем шаги, необходимые для достижения результата, то в декларативном — желаемый результат.
Как и в декларативном подходе, с помощью CP мы описываем, что хотим получить (а получить мы хотим выполнение правил рассадки) — это называется модель. У модели есть переменные и ограничения.
Переменные определяют то, что мы ищем. Например, в задаче рассадки бэк‑офиса целевая переменная — сколько мест БЛ занимает на определенной площадке.
Также у каждой переменной есть область определения. Например, количество занятых мест на этаже не больше, чем всего есть мест на этаже (на балконе сидеть без шансов).
Наконец, нужно ввести ограничения, описывающие связи между переменными. Например, если БЛ переехала из Офиса 1 в Офис 2, то переменная с переездом этой БЛ будет равна 1, иначе — 0.
Вообще, в CP есть богатый набор ограничений. Из тех, что я использовала, например:
Линейные ограничения
(x + y <= 10).-
Логические ограничения:
AddBoolAnd,AddBoolOr— булевыИ/ИЛИ;OnlyEnforceIf— логическая импликация(A ⇒ B);AddExactlyOne— ровно одно значение из списка истинно;и др.
Мин/макс ограничения:
AddMaxEquality (x = max(y, z)).
Программа, которая интерпретирует полученную модель и ищет решения, удовлетворяющие ограничениям, называется решателем (солвером или solver-ом).
CP-solver перебирает возможные значения переменных, чтобы найти допустимые решения, используя различные оптимизации:
№1. Constraint Propagation (распространение ограничений).
После того как решатель присваивает значение одной переменной, он ограничивает область определения остальных.
Пример: есть ограничение x < z; x,z > 0. Если, например, решатель начинает перебор с x = 3, тогда область определения zсузится до интервала [4, ∞), тем самым решатель исключит из перебора три значения z за один шаг.

№2. Backtracking (поиск с возвратом).
Происходит, когда решатель либо не может присвоить значение следующей переменной из-за ограничений, либо находит итоговое решение. В обоих случаях решатель возвращается к предыдущему шагу и меняет значение переменной на то, которое еще не было проверено.
Пример: есть ограничения x < y, y = max(x, z), y < 5. Пусть решатель на данном этапе начинает перебор с x = 3 и y = 4:

Подход №2: SAT (Boolean Satisfiability)
SAT (Boolean Satisfiability) — задача булевой выполнимости. Пытается выяснить, существует ли набор значений переменных (каждая из которых может быть истиной или ложью), при котором заданная булева формула принимает значение ИСТИНА.
Или по-простому: можно ли подобрать такие
ИСТИНАиЛОЖЬдля переменных, чтобы все выражение сталоИСТИНОЙ?
Если такой набор существует, формула называется выполнимой (satisfiable), в противном случае невыполнимой (unsatisfiable).
Современные SAT-решатели умеют запоминать, почему какой-то вариант не сработал, и больше не пробовать похожие (в отличие от CP-солверов) — этот механизм называется Conflict-Driven Clause Learning (CDCL). Как это работает:
Решатель делает предположение (например,
x = ИСТИНА).Начинает выводить следствия из этого предположения через распространение ограничений (propagation).
-
После propagation строит implication graph (импликационный граф) — ориентированный граф, в котором:
Вершины — это присвоенные значения переменных, например,
x = ИСТИНА.Рёбра — импликации между вершинами: если одна вершина истинна, то из неё следует истинность другой вершины
[x] → [y].
Например, ребро от вершиныx = ИСТИНАкy = ИСТИНАозначает, что изx = ИСТИНАследуетy = ИСТИНА.
Когда возникает конфликт, решатель анализирует этот граф, чтобы понять, какая комбинация предположений привела к противоречию.
Отрицание предположений, приведших к противоречию, запоминается как conflict clause (или nogood).
В будущем, когда решатель снова встретит похожую ситуацию, он проверит, не нарушает ли его новый набор предположений накопленные nogood, а если нарушает — отбросит этот вариант сразу, даже не начиная перебор.
CP + SAT OR-Tools
С SAT и CP по отдельности разобрались, теперь про их гибрид — решатель CP‑SAT из библиотеки OR‑Tools от Google, который я использовала в задаче рассадки бэк‑офиса.
Этот инструмент можно использовать для разных задач: планирование расписания врачей в больницах, распределение задач между работниками и др. Делал ли кто‑то на CP‑SAT офисную рассадку выяснить не удалось, поэтому мы здесь.
В реализации метода CP-SAT от Google используется метод LCG (lazy clause generation, ленивая генерация дизъюнктов). Если вспомните то, что читали пару минут назад, то классический CP-solver при обнаружении противоречия просто возвращается на шаг назад (backtracking) и пробует другой вариант, но не запоминает, почему этот путь не сработал.
Суть же метода LCG состоит в том, чтобы добавить к решателю CP возможность обучения и запоминания «плохого опыта» (nogood learning) из SAT-решателя.

Примечание. Подробно про LCG можно посмотреть в презентации Питера Стаки под скромным названием Search is Dead. Long Live Proof.
SAT-решатель оперирует булевыми переменными и выражениями. В CP-SAT переменные целочисленные, и просто так взять и применить CDCL-алгоритм SAT-решателя к ним нельзя.
LCG решает эту проблему так:
-
Кодирует целочисленные переменные в булевы и накладывает на них clauses (дизъюнкты) для описания отношения между ними.
Например, для переменнойx ∈ [0, 5]получаем:булевы переменные:
[x ≤ 0], [x ≤ 1], [x ≤ 2], [x ≤ 3], [x ≤ 4]- пороги;[x = 0], [x = 1], [x = 2], [x = 3], [x = 4], [x = 5]- равенства.и дизъюнкты:
[x ≤ d] → [x ≤ d+1]- clause на монотонность порогов;
только одно из равенств[x = d], [x = d+1],.. истинно - clause на равенства.
(Скобки нужны для того, чтобы получить значения ИСТИНА или ЛОЖЬ от выражения)
Распространяет ограничения методом CP для исходных целочисленных переменных и создаёт на их основе новые clauses.
Например, если есть ограничениеx ≥ yи предыдущими итерациями установлено[x ≤ 2], то генерируется clause[x ≤ 2] → [y ≤ 2]. Эти дизъюнкты добавляются в implication graph.Использует nogood learning из SAT.
На основе графа импликаций выявляются причины конфликта и создаются nogoods (условия, которые приводят к неудаче), чтобы избежать повторения тех же ошибок в будущем.
Также OR-Tools CP-SAT поддерживает распараллеливание вычислений между ядрами процессора через параметр num_search_workers.
Ну и где тут оптимизация?
Важно отметить, что классический CP решает задачу выполнимости (поиска возможного решения), а не оптимизации (поиска оптимального решения с точки зрения целевой функции).
В нашей задаче оптимизация критически важна: мало рассадить людей, нужно при этом минимизировать количество переездов.
Для решения задачи оптимизации в OR-Tools CP-SAT используется метод ветвей и границ:
Solver находит первое допустимое решение. Допустим, 7 переездов. Это наш текущий рекорд (верхняя граница).
Добавляет ограничение: у следующего решения значение целевой функции должно быть лучше верхней границы, то есть меньше 7 переездов.
Решает задачу с новым ограничением: на каждом шаге поиска, исследуя очередную ветвь (подмножество решений), solver оценивает её нижнюю границу — минимум целевой функции, который вообще можно достичь.
Если нижняя граница (например, 9) оказывается хуже верхней границы (7 переездов), решатель отсекает эту ветвь. Даже в теории там не может быть значения лучше нашего рекорда.
Если же нижняя граница (например, 5) оказывается лучше текущего рекорда (7 переездов), solver ищет в этой ветке реальное целочисленное решение (например, 6).
Найдя 6, солвер обновляет рекорд. Теперь он ищет решение меньше 6 переездов.
Повторяет пункты 1-6, пока нижняя граница (теоретический минимум) и верхняя граница (текущий рекорд) не сравняются или не будет достигнут лимит по времени, заданным пользователем.
Сервис для размещения рабочих мест
Для бизнеса мы реализовали веб-сервис на не очень нишевом streamlit со следующим сценарием использования:
-
Пользователь загружает файл с исходными данными для рассадки:
Данные по офисам.
Они имеют следующую структуру: локация — блок — офис — этаж.Фиксированные БЛ.
Здесь пользователем фиксируется рассадка тех БЛ, которые не будут переезжать (например, в каждом офисе должны быть сотрудники IT-сопровождения).Начальная рассадка.
Рассадка, актуальная на сегодняшний день, относительно которой будем считать переезды.Разбивка по приоритетам БЛ (об этом позже).
Пользователь нажимает кнопку «Запустить решатель».
Streamlit идет в PostgreSQL и добавляет в таблицу с задачами новую таску, чтобы потом отслеживать статус её выполнения.
-
Запускает фоновый процесс через threading в python - модуль для работы с многопоточностью:
во-первых, это нужно для того, чтобы пользователи могли запускать сразу несколько задач и они отрабатывались параллельно;
во-вторых, если страница закроется или перезагрузится (а streamlit любит такое делать — сам решууу), решатель прервется.
Запуск решателя CP-SAT OR-Tools, который записывает результат работы в таблицу с тасками.
Streamlit раз в пару минут проверяет статус задачи в таблице с тасками, чтобы после завершения работы решателя вывести результат на страницу пользователю.
Внутри решателя реализовали следующие шаги для нахождения оптимальной рассадки:
№1. Фиксируем начальную рассадку, заданную пользователем, для последующего подсчета переездов, отталкиваясь от нее.
№2. Для следующих лет сначала рассаживаем фиксированные БЛ, переданные пользователем, которые не будут переезжать и участвовать в динамической рассадке.
№3. Для остальных БЛ задаем в CP-SAT OR-Tools переменные и ограничения для каждого года отдельно.
Переменные:
а) X[(bl_name, o, f)] — количество занятых мест БЛ «bl_name» в офисе «o» на этаже «f»
b) x_unsettled[bl_name] — количество нерассаженных людей (да, такое тоже может быть)
c) Переменные выбора площадки:
x_office_is_chosen[(bl_name, o)]# 1, если БЛ заняла офис ox_block_is_chosen[(bl_name, b)]# 1, если БЛ заняла блок bx_loc_is_chosen[(bl_name, l)]# 1, если БЛ заняла локацию lx_floor_is_chosen[(bl_name, f)]# 1, если БЛ заняла этаж f
d) Переменные с переездами:
move_loc_vars[(bl_name, loc1, loc2)]# 1, если БЛ переехала между локациями loc1 и loc2move_block_vars# переезд между блокамиmove_office_vars# переезд между офисамиmove_floors_vars# переезд между этажами
e) move_places_var — целевая переменная, которую хотим минимизировать. Представляет собой взвешенную сумму переездов.

Веса были подобраны экспертно таким образом, чтобы:
больше штрафовать за переезд между локациям,
меньше — между блоками,
ещё меньше — между офисами,
и самый маленький штраф был за переезд между этажами.
Основные ограничения:
a) Количество занятых мест + количество нерассаженных = количество требуемых пользователем: sum(X[(bl_name,o,f)] for all o,f) + x_unsettled[bl_name] == required_seats
b) Ограничение на переменные выбранной площадки.
Например, если БЛ нельзя делить по разным офисам, только одна переменная x_office_is_chosen должна быть равна 1.
c) Вместимость этажа: X[(bl_name, o, f)] не может быть больше кол-ва свободных мест на этаже
d) Доступность площадки: X[(bl_name, o, f)] = 0, если офис «o» ещё не открыт в этом году
e) Ограничения на закрытый контур и кабинеты.
Если бизнес-линия требует закрытый контур, то этаж должен предполагать наличие закрытого контура.
Количество кабинетов на занятых этажах должна быть не меньше требуемого числа.
Затем идет разветвление в решении задачи.
Условие x_unsettled == 0 (все БЛ рассажены в полном объеме):
|
Выполнимо Тогда просим решатель найти такую рассадку, при которой сумма переездов минимальна. Итог: рассадили всех и минимизировали переезды. |
Не выполнимо (Можно понять по статусу, который вернул солвер). Вместо жесткого условия Для найденного максимума, ищем минимальный значение дефицита (сколько людей не получится рассадить) и затем для него пытаемся найти оптимальное решение с минимальными переездами. Найденное решение с учетом дефицита мест возвращаем пользователю. Итог: рассадили не всех, но постарались соблюсти допустимый процент нерассаженных (без перекосов, когда 1 БЛ рассажены полностью, а остальные - на 5%) и минимизировали переезды. |
* для БЛ, в зависимости от её приоритета, заранее задаем допустимый процент нерассаженных (например, для БЛ с приоритетом 5 допустимо до 15% дефицита).
Беды ✨
Во-первых, успех решения задачи сильно зависит от того, как вы задали переменные и их области определения, описали связи между ними и добавили ограничения. Если при запуске солвера, он буквально через секунду возвращает статус INFEASIBLE, скорее всего это значит, что не очень аккуратно объявлены переменные и ограничения. Есть полезная статья Подробный гайд: Оптимизация моделей Constraint Programming (CP) про то, как этого избежать.

Во-вторых, сейчас я вам расскажу про разработку, за которую мне такууую премию дадут.
К сожалению, часто у нас не получалось рассадить всех наших любимых коллег и нужно было что-то с этим делать. Надеюсь, вы уже догадались, что мы будем искать ограничения, которые не дают нам рассадить всех сотрудников, и пытаться эти ограничения ослабить, чтобы рассадить всех.
В OR‑Tools нет встроенного метода поиска ограничений, которые приводят к неразрешимости задачи. Единственное, что нагуглилось — нужно искать MCS (Minimal Correction Set — минимальное множество исправлений) — минимальный набор ограничений, которые нужно убрать, чтобы исходная система ограничений стала выполнимой.
А как искать-то?
Спустя несколько дней ресерча я нашла не медь, а золото — CPMpy, про который почему-то не так много упоминаний.
CPMpy — это библиотека Python для решения задач CP, которая позволяет:
во‑первых, описать задачу на удобном Python‑языке,
а во‑вторых, запустить её решение на различных встроенных солверах (в том числе ortools), не подстраиваясь под их синтаксис и используя автоматическую декомпозицию высокоуровневых ограничений, если выбранный решатель их не поддерживает.
Наконец, она предоставляет explanation tools — инструменты для поиска несовместных (конфликтующих) ограничений.
В частности, там есть метод MARCO, который ищет все MCS (Minimal Correction Sets) исследуемой модели. Таким образом, мы предоставляем пользователю несколько наборов ограничений, чтобы он сам выбрал, какие из них убрать/смягчить, чтобы рассадить всех коллег.
Алгоритм поиска следующий:

№1. Берем подмножество ограничений (генерируем seed).
№2. Проверяем это подмножество на совместимость.
Для этого запускается решатель, в нашем случае ortools, который возвращает статус, разрешима ли задача с данными ограничениями или нет.
№3. Если подмножество совместимо, добавляем оставшиеся ограничения по одному до тех пор, пока задача остается разрешимой.
№4. В итоге получаем максимальное подмножество совместных ограничений — MSS (Maximal Satisfiable Subset).
Ограничения, которые не вошли в этот MSS, образуют MCS — минимальный набор ограничений, которые нужно убрать, чтобы задача стала разрешима.
№5. Алгоритм повторяет шаги 1–4 до тех пор, пока не найдет все MCS. При этом перебора всех возможных подмножеств не происходит — алгоритм запоминает множества совместных и несовместных ограничений, чтобы не тратить время на проверку заведомо выполнимых или невыполнимых вариантов.
Итак, как мы разрешаем ситуацию, когда CP‑SAT не смог рассадить всех сотрудников:
Пытаемся максимизировать количество БЛ, у которых дефицит мест попал в допустимый интервал в зависимости от приоритета.
Например, у БЛ1 с приоритетом 1 допустимый процент нерассаженных сотрудников 5%, а у БЛ2 с приоритетом 2 — 10%. Тогда хорошим вариантом будет, если мы попадаем у обеих БЛ в этот допустимый интервал.Найденный максимум фиксируем как новое ограничение модели, и для него пытаемся найти минимум нерассаженных.
Для найденного минимума нерассаженных минимизируем переезды.
Итоговое решение с учётом дефицита выдаём пользователю.
С помощью CPMpy даем подсказки, какие ограничения можно ослабить, чтобы в следующий раз решатель рассадил всех.
Получаем довольно гибкое решение.
Ещё более гибким его может сделать только поиск нескольких оптимальных вариантов. По просьбе бизнес-заказчиков наш сервис также умеет возвращать несколько (обычно 5-7) решений вблизи оптимума.
Для этой цели можно создать свой класс, унаследовав его от CpSolverSolutionCallback из CP-SAT OR-Tools, и вручную собирать решения в список. Этот класс возвращает решения, которые решатель перебирает при поиске самого оптимального. При этом, так как CP-SAT-солвер отсекает ветви, которые не ведут к оптимизации целевой функции, каждое следующее найденное решение будет лучше предыдущего. Поэтому мы просто возьмем конец списка и выведем его пользователю.
На этой все, спасибо за внимание!