Алгоритм перестановки рабочих / временных интервалов / фильтрация ограничений - PullRequest
6 голосов
/ 12 октября 2009

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

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

Заранее спасибо за чтение; Я знаю, что это большой пост. Я пытаюсь сделать все возможное, чтобы документально подтвердить это, и показать, что я приложил усилия самостоятельно.

Проблема

Мне нужен алгоритм планирования работника / временного интервала, который генерирует смены для работников, который соответствует следующим критериям:

Входные данные

import datetime.datetime as dt

class DateRange:
    def __init__(self, start, end):
        self.start = start
        self.end   = end

class Shift:
    def __init__(self, range, min, max):
        self.range = range
        self.min_workers = min
        self.max_workers = max

tue_9th_10pm = dt(2009, 1, 9,   22, 0)
wed_10th_4am = dt(2009, 1, 10,   4, 0)
wed_10th_10am = dt(2009, 1, 10, 10, 0)

shift_1_times = Range(tue_9th_10pm, wed_10th_4am)
shift_2_times = Range(wed_10th_4am, wed_10th_10am)
shift_3_times = Range(wed_10th_10am, wed_10th_2pm)

shift_1 = Shift(shift_1_times, 2,3)  # allows 3, requires 2, but only 2 available
shift_2 = Shift(shift_2_times, 2,2)  # allows 2
shift_3 = Shift(shift_3_times, 2,3)  # allows 3, requires 2, 3 available

shifts = ( shift_1, shift_2, shift_3 )

joe_avail = [ shift_1, shift_2 ]
bob_avail = [ shift_1, shift_3 ]
sam_avail = [ shift_2 ]
amy_avail = [ shift_2 ]
ned_avail = [ shift_2, shift_3 ]
max_avail = [ shift_3 ]
jim_avail = [ shift_3 ]

joe = Worker('joe', joe_avail)
bob = Worker('bob', bob_avail)
sam = Worker('sam', sam_avail)
ned = Worker('ned', ned_avail)
max = Worker('max', max_avail)
amy = Worker('amy', amy_avail)
jim = Worker('jim', jim_avail)

workers = ( joe, bob, sam, ned, max, amy, jim )

Обработка

Сверху сдвиги и рабочие являются двумя основными входными переменными для обработки

В каждой смене требуется минимальное и максимальное количество работников. Заполнение минимальных требований для смены имеет решающее значение для успеха, но если все остальное терпит неудачу, ротация с пробелами, которые должны быть заполнены вручную, лучше, чем «ошибка» :) Основная алгоритмическая проблема заключается в том, что не должно быть ненужных пробелов, когда достаточно рабочие доступны.

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

Гибкие ограничения

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

  • Существует минимальный период времени между двумя сменами. Итак, рабочий не должно быть запланировано на две смены в тот же день, например.
  • Максимальное количество смен работник может сделать в данный период времени (скажем, месяц)
  • Максимальное количество определенных сдвиги, которые можно сделать за месяц (скажем, ночные смены)

Приятно иметь, но не обязательно

Если вы сможете придумать алгоритм, который выполняет вышеизложенное и включает в себя все / все из них, я буду серьезно впечатлен и благодарен. Даже дополнительный скрипт для выполнения этих битов по отдельности тоже был бы великолепен.

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

  • Минимальный срок переноса для работников уточняется на одного работника (а не на глобальном уровне). Например, если Джо чувствует себя перегруженным работой или имеет дело с личными проблемами, или начинающий изучает веревки, мы могли бы запланировать его реже, чем другие работники.

  • Какой-то автоматический / случайный / честный способ подбора персонала для заполнения минимума Смена чисел, когда нет свободных работников подходят.

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

Тест на выход

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

class Solution:
    def __init__(self, shifts_workers):
        """shifts_workers -- a dictionary of shift objects as keys, and a
        a lists of workers filling the shift as values."""

        assert isinstance(dict, shifts_workers)
        self.shifts_workers = shifts_workers

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

def check_solution(solution):
  assert isinstance(Solution, solution)

  def shift_check(shift, workers, workers_allowed):
      assert isinstance(Shift, shift):
      assert isinstance(list, workers):
      assert isinstance(list, workers_allowed)

      num_workers = len(workers)
      assert num_workers >= shift.min_workers
      assert num_workers <= shift.max_workers

      for w in workers_allowed:
          assert w in workers

  shifts_workers = solution.shifts_workers

  # all shifts should be covered
  assert len(shifts_workers.keys()) == 3
  assert shift1 in shifts_workers.keys()
  assert shift2 in shifts_workers.keys()
  assert shift3 in shifts_workers.keys()

  # shift_1 should be covered by 2 people - joe, and bob
  shift_check(shift_1, shifts_workers[shift_1], (joe, bob))

  # shift_2 should be covered by 2 people - sam and amy
  shift_check(shift_2, shifts_workers[shift_2], (sam, amy))

  # shift_3 should be covered by 3 people - ned, max, and jim
  shift_check(shift_3, shifts_workers[shift_3], (ned,max,jim))

Попытка

Я пытался реализовать это с помощью Генетического алгоритма, но, похоже, не могу его настроить совершенно правильно, поэтому, хотя базовый принцип, кажется, работает на одной смене, он не может решить даже простые случаи с несколькими сменами и несколько рабочих.

Моя последняя попытка - сгенерировать каждую возможную перестановку в качестве решения, а затем сократить перестановки, которые не соответствуют ограничениям. Кажется, это работает намного быстрее, и это дало мне дальнейшее развитие, но я использую itertools.product () в python 2.6, чтобы помочь генерировать перестановки, и я не совсем понимаю, как это правильно. Меня не удивит, если будет много ошибок, если честно, проблема не вписывается в мою голову так хорошо:)

В настоящее время мой код для этого находится в двух файлах: models.py и rota.py. models.py выглядит так:

# -*- coding: utf-8 -*-
class Shift:
    def __init__(self, start_datetime, end_datetime, min_coverage, max_coverage):
        self.start = start_datetime
        self.end = end_datetime
        self.duration = self.end - self.start
        self.min_coverage = min_coverage
        self.max_coverage = max_coverage

    def __repr__(self):
        return "<Shift %s--%s (%r<x<%r)" % (self.start, self.end, self.min_coverage, self.max_coverage)

class Duty:
    def __init__(self, worker, shift, slot):
        self.worker = worker
        self.shift = shift
        self.slot = slot

    def __repr__(self):
        return "<Duty worker=%r shift=%r slot=%d>" % (self.worker, self.shift, self.slot)

    def dump(self,  indent=4,  depth=1):
        ind = " " * (indent * depth)
        print ind + "<Duty shift=%s slot=%s" % (self.shift, self.slot)
        self.worker.dump(indent=indent, depth=depth+1)
        print ind + ">"

class Avail:
    def __init__(self, start_time, end_time):
        self.start = start_time
        self.end = end_time

    def __repr__(self):
        return "<%s to %s>" % (self.start, self.end)

class Worker:
    def __init__(self, name, availabilities):
        self.name = name
        self.availabilities = availabilities

    def __repr__(self):
        return "<Worker %s Avail=%r>" % (self.name, self.availabilities)

    def dump(self,  indent=4,  depth=1):
        ind = " " * (indent * depth)
        print ind + "<Worker %s" % self.name
        for avail in self.availabilities:
            print ind + " " * indent + repr(avail)
        print ind + ">"

    def available_for_shift(self,  shift):
        for a in self.availabilities:
            if shift.start >= a.start and shift.end <= a.end:
                return True
        print "Worker %s not available for %r (Availability: %r)" % (self.name,  shift, self.availabilities)
        return False

class Solution:
    def __init__(self, shifts):
        self._shifts = list(shifts)

    def __repr__(self):
        return "<Solution: shifts=%r>" % self._shifts

    def duties(self):
        d = []
        for s in self._shifts:
            for x in s:
                yield x

    def shifts(self):
        return list(set([ d.shift for d in self.duties() ]))

    def dump_shift(self, s, indent=4, depth=1):
        ind = " " * (indent * depth)
        print ind + "<ShiftList"
        for duty in s:
            duty.dump(indent=indent, depth=depth+1)
        print ind + ">"

    def dump(self, indent=4, depth=1):
        ind = " " * (indent * depth)
        print ind + "<Solution"
        for s in self._shifts:
            self.dump_shift(s, indent=indent, depth=depth+1)
        print ind + ">"

class Env:
    def __init__(self, shifts, workers):
        self.shifts = shifts
        self.workers = workers
        self.fittest = None
        self.generation = 0

class DisplayContext:
    def __init__(self,  env):
        self.env = env

    def status(self, msg, *args):
        raise NotImplementedError()

    def cleanup(self):
        pass

    def update(self):
        pass

и rota.py выглядит так:

#!/usr/bin/env python2.6
# -*- coding: utf-8 -*-

from datetime import datetime as dt
am2 = dt(2009,  10,  1,  2, 0)
am8 = dt(2009,  10,  1,  8, 0)
pm12 = dt(2009,  10,  1,  12, 0)

def duties_for_all_workers(shifts, workers):
    from models import Duty

    duties = []

    # for all shifts
    for shift in shifts:
        # for all slots
        for cov in range(shift.min_coverage, shift.max_coverage):
            for slot in range(cov):
                # for all workers
                for worker in workers:
                    # generate a duty
                    duty = Duty(worker, shift, slot+1)
                    duties.append(duty)

    return duties

def filter_duties_for_shift(duties,  shift):
    matching_duties = [ d for d in duties if d.shift == shift ]
    for m in matching_duties:
        yield m

def duty_permutations(shifts,  duties):
    from itertools import product

    # build a list of shifts
    shift_perms = []
    for shift in shifts:
        shift_duty_perms = []
        for slot in range(shift.max_coverage):
            slot_duties = [ d for d in duties if d.shift == shift and d.slot == (slot+1) ]
            shift_duty_perms.append(slot_duties)
        shift_perms.append(shift_duty_perms)

    all_perms = ( shift_perms,  shift_duty_perms )

    # generate all possible duties for all shifts
    perms = list(product(*shift_perms))
    return perms

def solutions_for_duty_permutations(permutations):
    from models import Solution
    res = []
    for duties in permutations:
        sol = Solution(duties)
        res.append(sol)
    return res

def find_clashing_duties(duty, duties):
    """Find duties for the same worker that are too close together"""
    from datetime import timedelta
    one_day = timedelta(days=1)
    one_day_before = duty.shift.start - one_day
    one_day_after = duty.shift.end + one_day
    for d in [ ds for ds in duties if ds.worker == duty.worker ]:
        # skip the duty we're considering, as it can't clash with itself
        if duty == d:
            continue

        clashes = False

        # check if dates are too close to another shift
        if d.shift.start >= one_day_before and d.shift.start <= one_day_after:
            clashes = True

        # check if slots collide with another shift
        if d.slot == duty.slot:
            clashes = True

        if clashes:
            yield d

def filter_unwanted_shifts(solutions):
    from models import Solution

    print "possibly unwanted:",  solutions
    new_solutions = []
    new_duties = []

    for sol in solutions:
        for duty in sol.duties():
            duty_ok = True

            if not duty.worker.available_for_shift(duty.shift):
                duty_ok = False

            if duty_ok:
                print "duty OK:"
                duty.dump(depth=1)
                new_duties.append(duty)
            else:
                print "duty **NOT** OK:"
                duty.dump(depth=1)

        shifts = set([ d.shift for d in new_duties ])
        shift_lists = []
        for s in shifts:
            shift_duties = [ d for d in new_duties if d.shift == s ]
            shift_lists.append(shift_duties)

        new_solutions.append(Solution(shift_lists))

    return new_solutions

def filter_clashing_duties(solutions):
    new_solutions = []

    for sol in solutions:
        solution_ok = True

        for duty in sol.duties():
            num_clashing_duties = len(set(find_clashing_duties(duty, sol.duties())))

            # check if many duties collide with this one (and thus we should delete this one
            if num_clashing_duties > 0:
                solution_ok = False
                break

        if solution_ok:
            new_solutions.append(sol)

    return new_solutions

def filter_incomplete_shifts(solutions):
    new_solutions = []

    shift_duty_count = {}

    for sol in solutions:
        solution_ok = True

        for shift in set([ duty.shift for duty in sol.duties() ]):
            shift_duties = [ d for d in sol.duties() if d.shift == shift ]
            num_workers = len(set([ d.worker for d in shift_duties ]))

            if num_workers < shift.min_coverage:
                solution_ok = False

        if solution_ok:
            new_solutions.append(sol)

    return new_solutions

def filter_solutions(solutions,  workers):
    # filter permutations ############################
    # for each solution
    solutions = filter_unwanted_shifts(solutions)
    solutions = filter_clashing_duties(solutions)
    solutions = filter_incomplete_shifts(solutions)
    return solutions

def prioritise_solutions(solutions):
    # TODO: not implemented!
    return solutions

    # prioritise solutions ############################
    # for all solutions
        # score according to number of staff on a duty
        # score according to male/female staff
        # score according to skill/background diversity
        # score according to when staff last on shift

    # sort all solutions by score

def solve_duties(shifts, duties,  workers):
    # ramify all possible duties #########################
    perms = duty_permutations(shifts,  duties)
    solutions = solutions_for_duty_permutations(perms)
    solutions = filter_solutions(solutions,  workers)
    solutions = prioritise_solutions(solutions)
    return solutions

def load_shifts():
    from models import Shift
    shifts = [
        Shift(am2, am8, 2, 3),
        Shift(am8, pm12, 2, 3),
    ]
    return shifts

def load_workers():
    from models import Avail, Worker
    joe_avail = ( Avail(am2,  am8), )
    sam_avail = ( Avail(am2,  am8), )
    ned_avail = ( Avail(am2,  am8), )
    bob_avail = ( Avail(am8,  pm12), )
    max_avail = ( Avail(am8,  pm12), )
    joe = Worker("joe", joe_avail)
    sam = Worker("sam", sam_avail)
    ned = Worker("ned", sam_avail)
    bob = Worker("bob", bob_avail)
    max = Worker("max", max_avail)
    return (joe, sam, ned, bob, max)

def main():
    import sys

    shifts = load_shifts()
    workers = load_workers()
    duties = duties_for_all_workers(shifts, workers)
    solutions = solve_duties(shifts, duties, workers)

    if len(solutions) == 0:
        print "Sorry, can't solve this.  Perhaps you need more staff available, or"
        print "simpler duty constraints?"
        sys.exit(20)
    else:
        print "Solved.  Solutions found:"
        for sol in solutions:
            sol.dump()

if __name__ == "__main__":
    main()

Использование результата отладки перед результатом, в настоящее время это дает:

Solved.  Solutions found:
    <Solution
        <ShiftList
            <Duty shift=<Shift 2009-10-01 02:00:00--2009-10-01 08:00:00 (2<x<3) slot=1
                <Worker joe
                    <2009-10-01 02:00:00 to 2009-10-01 08:00:00>
                >
            >
            <Duty shift=<Shift 2009-10-01 02:00:00--2009-10-01 08:00:00 (2<x<3) slot=1
                <Worker sam
                    <2009-10-01 02:00:00 to 2009-10-01 08:00:00>
                >
            >
            <Duty shift=<Shift 2009-10-01 02:00:00--2009-10-01 08:00:00 (2<x<3) slot=1
                <Worker ned
                    <2009-10-01 02:00:00 to 2009-10-01 08:00:00>
                >
            >
        >
        <ShiftList
            <Duty shift=<Shift 2009-10-01 08:00:00--2009-10-01 12:00:00 (2<x<3) slot=1
                <Worker bob
                    <2009-10-01 08:00:00 to 2009-10-01 12:00:00>
                >
            >
            <Duty shift=<Shift 2009-10-01 08:00:00--2009-10-01 12:00:00 (2<x<3) slot=1
                <Worker max
                    <2009-10-01 08:00:00 to 2009-10-01 12:00:00>
                >
            >
        >
    >

Ответы [ 3 ]

3 голосов
/ 12 октября 2009

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

Короче, не надо! Если у вас нет большого опыта работы с генетическими алгоритмами, вы не поймете это правильно.

  • Это приблизительные методы, которые не гарантируют сходимость к работоспособному решению.
  • Они работают только в том случае, если вы можете достаточно разумно установить качество вашего текущего решения (т. Е. Количество критериев не выполнено).
  • Их качество в решающей степени зависит от качества операторов, которые вы используете для объединения / преобразования предыдущих решений в новые.

Трудно сделать правильный выбор в небольшой программе на Python, если у вас почти нулевой опыт работы с GA. Если у вас небольшая группа людей, исчерпывающий поиск - это не плохой вариант. Проблема в том, что он может работать правильно для n человек, будет медленным для n+1 человек и невыносимо медленным для n+2, и вполне может быть, что ваш n в конечном итоге достигнет 10.

Вы работаете над NP-полной задачей, и нет простых решений для победы. Если выбранная вами задача планирования расписаний работает недостаточно хорошо, очень маловероятно, что у вас будет что-то лучшее с вашим скриптом Python.

Если вы настаиваете на том, чтобы делать это с помощью своего собственного кода, намного легче получить некоторые результаты с минимальным или имитированным отжигом.

1 голос
/ 12 октября 2009

Хорошо, я не знаю о конкретном алгоритме, но вот что я бы принял во внимание.

Оценка

Каким бы ни был метод, вам понадобится функция для оценки того, насколько ваше решение удовлетворяет ограничениям. Вы можете использовать подход «сравнения» (нет общего показателя, но есть способ сравнить два решения), но я бы порекомендовал оценку.

Что было бы действительно хорошо, если бы вы могли получить оценку за более короткий промежуток времени, например, ежедневно, очень полезно с алгоритмами, если вы можете «предсказать» диапазон итоговой оценки из частичного решения (например, просто первые 3 дня из 7). Таким образом, вы можете прервать вычисления на основе этого частичного решения, если оно уже слишком мало, чтобы оправдать ваши ожидания.

Symmetry

Вполне вероятно, что среди этих 200 человек у вас есть похожие профили: то есть люди, имеющие одинаковые характеристики (доступность, опыт, готовность, ...). Если вы берете двух человек с одинаковым профилем, они будут взаимозаменяемыми:

  • Решение 1: (смена 1: Джо) (смена 2: Джим) ... только другие работники ...
  • Решение 2: (смена 1: Джим) (смена 2: Джо) ... только для других работников ...

на самом деле это одно и то же решение с вашей точки зрения.

Хорошо, что обычно у вас меньше профилей, чем у людей, что очень помогает с временем, затрачиваемым на вычисления!

Например, представьте, что вы сгенерировали все решения на основе Решения 1, тогда нет необходимости что-либо вычислять на основе Решения 2.

Итерационный

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

Затем, когда у вас есть эта неделя, вы вычисляете вторую, стараясь, конечно, принять во внимание первое для ваших ограничений, конечно.

Преимущество заключается в том, что вы явно разрабатываете свой алгоритм для учета уже использованного решения. Таким образом, при следующем формировании расписания он не будет заставлять человека работать 24 часа в сутки!

Сериализация

Вы должны рассмотреть сериализацию объектов вашего решения (выберите ваш выбор, pickle вполне подходит для Python). При создании нового графика вам понадобится предыдущий график, и я уверен, что вы не хотите вводить его вручную для 200 человек.

Исчерпывающий

Теперь, после всего этого, я бы предпочел исчерпывающий поиск, поскольку при использовании симметрии и оценки возможностей может быть не так много (проблема остается NP-полной, хотя серебряной пули нет).

Возможно, вы захотите попробовать свои силы в алгоритме возврата .

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

Обе обсуждают проблемы, возникшие в ходе реализации, поэтому их проверка поможет вам.

1 голос
/ 12 октября 2009

У меня нет выбора алгоритма, но я могу привести некоторые практические соображения.

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

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

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

Может оказаться невозможным или нежелательным всегда генерировать наиболее оптимальное решение, но вы можете вести текущий подсчет «менее чем оптимальных» событий на одного работника и всегда выбирать работника с наименьшим количеством, когда вам нужно назначить еще один «плохой выбор». Это то, что обычно волнует людей (несколько «плохих» решений о планировании часто / несправедливо).

...