Skip to content

●  사용방법 · 스케줄링

직원 근무표

모든 근무를 자격을 갖춘 사람으로 채우면서 휴식 규칙, 주간 근무시간, 연속 근무 제한, 개인 희망을 반영합니다.

무엇을 정하나요

근무마다 맡을 직원을 정합니다. 어떤 규칙을 적용할지는 직접 고릅니다.

최소 실행 예제

from optimaru import employee_scheduling as es
from optimaru.employee_scheduling.models import (
    ScheduleRequest, SchedulingInput, ShiftModel, SolverSettings, WorkerModel, solve_request,
)

def shift(id, day, skill):
    return ShiftModel(id=id, start=es.minutes(day, 9), end=es.minutes(day, 17),
                      location="Site A", required_skill=skill)

request = ScheduleRequest(
    timelimit=2,
    settings=SolverSettings(random_seed=42),
    model_input=SchedulingInput(
        workers=[
            WorkerModel(id="Ana", skills=["Skill A"], unavailable_dates=[1]),
            WorkerModel(id="Ben", skills=["Skill A"], undesired_dates=[2]),
            WorkerModel(id="Cho", skills=["Skill B"], desired_dates=[0]),
        ],
        shifts=[
            shift("S1", 0, "Skill A"), shift("S2", 1, "Skill A"),
            shift("S3", 2, "Skill A"), shift("S4", 0, "Skill B"),
        ],
    ),
)

solution = solve_request(request)
print(solution.feasible, solution.score_hard, solution.score_soft)
print({worker: [a.shift_id for a in shifts] for worker, shifts in solution.schedule.items()})
print(solution.unassigned, [(t.name, t.cost) for t in solution.rule_totals])

같은 유스케이스를 그림과 실행 결과가 담긴 Jupyter 노트북으로도 볼 수 있습니다. examples/employee-scheduling.ipynb ↗

입력 파라미터

WorkerModel

필드 의미
id 필수 입력 직원 id입니다. skills 직원이 가진 자격입니다. unavailable_dates 근무할 수 없는 날짜 번호입니다. undesired_dates 되도록 쉬고 싶은 날짜 번호입니다. desired_dates 근무하고 싶은 날짜 번호입니다. desired_shifts (날짜, 위치) 형식의 근무 희망입니다. desired_shift_cost 규칙이 읽습니다.

ShiftModel

필드 의미
id 필수 입력 근무 id입니다. start 필수 입력 0일 00:00부터 센 시작 시각(분)입니다. es.minutes(day, hour)로 만들 수 있습니다. end 필수 입력 0일 00:00부터 센 종료 시각(분)입니다. location 자유 문자열입니다. 위치 규칙이 이 값을 읽으므로 사업장, 팀, 근무 유형 무엇이든 담을 수 있습니다. required_skill 근무에 필요한 자격입니다. worker 미리 정한 직원 id입니다. 시작점일 뿐 고정이 아니라서 탐색 중에 바뀔 수 있습니다. pinned worker 배정을 고정합니다. 점수에는 들어가지만 바뀌지 않습니다. worker가 있어야 합니다.

SchedulingInput

필드 의미
workers 필수 입력 직원 목록입니다. shifts 필수 입력 채워야 할 근무 목록입니다. rules 적용할 규칙입니다. 제약 클래스나 그 JSON 형식으로 줍니다. 생략하면 default_rules()의 규칙 8개를 씁니다.

ScheduleRequest

필드 의미
model_input 필수 입력 위의 SchedulingInput입니다.

풀이 파라미터

풀이를 호출할 때 넘기는 값입니다. 시간 제한, 시드, 병렬 수가 여기에 있습니다.

파라미터 기본값 의미
name None 요청에 붙이는 이름입니다. 선택 사항입니다.
timelimit 30.0 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다.
num_workers 1 병렬 프로세스에서 독립적으로 푸는 횟수입니다. 시작마다 시드가 다르고 timelimit을 모두 쓰며, 가장 좋은 결과를 돌려줍니다. settings.multi_start도 정했다면 둘 중 큰 값을 씁니다.
settings SolverSettings() 시드와 탐색 옵션을 담은 SolverSettings입니다.
select None 시작이 둘 이상일 때 해 목록에서 결과를 고르는 함수입니다. 한 번만 풀 때는 무시합니다.
탐색 옵션 전체 (46)

SolverSettings에 값을 넣어 settings=로 넘기세요.

파라미터 기본값 의미
random_seed 0 탐색에 쓰는 난수 시드입니다.
guided_moves True 위반과 자격을 보고 이동을 고릅니다. False면 일반 변경·교환 이동만 씁니다.
employee_greedy_construction True 로컬 서치 전에 날짜를 고려한 그리디 구성으로 미배정 근무를 채웁니다.
greedy_repair_budget_fraction 0.0 로컬 서치 뒤의 그리디 필수 복구에 떼어 두는 시간 비율입니다. 0이면 끕니다.
late_acceptance_size 400 늦은 수락(late acceptance) 기록의 길이입니다.
stop_at_first_feasible False 필수 점수가 0인 첫 일정에서 멈춥니다.
best_score_limit None 최고 점수가 이 값에 닿으면 일찍 멈춥니다. 기본값으로는 작동하지 않습니다.
no_improvement_limit_seconds None unimproved_spent_limit_seconds의 별칭입니다.
unimproved_spent_limit_seconds 0.0 새 최고 해 없이 이 시간(초)이 지나면 멈춥니다. 0이면 제한이 없습니다.
step_count_limit 0 이 걸음 수에 도달하면 멈춥니다. 0이면 제한이 없습니다.
no_improvement_step_count_limit None unimproved_step_count_limit의 별칭입니다.
unimproved_step_count_limit 0 새 최고 해 없이 이 걸음 수가 지나면 멈춥니다. 0이면 제한이 없습니다.
ruin_recreate True 탐색이 정체되면 적응형 파괴·재구성을 실행합니다.
ruin_recreate_unimproved_step_trigger 5000 파괴·재구성을 실행하기까지 새 최고 해 없이 지나는 걸음 수입니다.
ruin_recreate_min_ruin_fraction 0.1 한 번에 제거하는 해의 최소 비율입니다.
ruin_recreate_max_ruin_fraction 0.3 한 번에 제거하는 해의 최대 비율입니다.
ruin_recreate_max_ruin_count 60 한 번에 제거하는 항목 수의 절대 상한입니다.
ruin_recreate_operator_decay 0.8 연산자 가중치가 적응하는 속도입니다.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] 새 최고, 개선, 수락, 거절 결과에 주는 연산자 보상입니다.
ruin_recreate_polish_steps 5000 실행 후 다듬는 언덕 오르기 이동 수입니다. step_count_limit에는 세지 않습니다.
ruin_recreate_as_move_weight 0.0 한 걸음을 작은 파괴·복구로 쓰고 일반 이동처럼 판단할 확률입니다. 음수는 지원하는 유스케이스에서 자동 선택이고, 0이면 끕니다.
ruin_recreate_as_move_max_ruin 12 그 작은 파괴로 제거할 최대 항목 수입니다.
descent_weight 0.02 최선 개선 하강의 비중입니다. 0이면 끕니다.
descent_max_sweeps 4 한 이웃을 포기하기 전까지 도는 개선 횟수입니다.
change_move_weight 0.75 변경 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
swap_move_weight 0.25 교환 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
nearby_shifts True 교환 상대, 하강 쌍, 파괴 묶음을 시작 시각이 가까운 근무 쪽으로 치우치게 합니다.
nearby_shifts_probability 0.65 교환 상대를 그 이웃 목록에서 고를 확률입니다.
smart_change_moves False 근무 하나에 여러 직원을 시험해 가장 좋은 배정을 고릅니다. guided_moves가 켜져 있어야 합니다.
smart_change_candidate_count 6 그 이동에서 시험할 직원 수입니다.
employee_day_repair_moves True 하루 한 근무 위반을 고치는 이동을 씁니다. guided_moves가 켜져 있어야 합니다.
employee_run_repair_moves True 지나치게 긴 연속 근무를 고치는 이동을 씁니다. guided_moves가 켜져 있어야 합니다.
employee_run_repair_max_days 3 그 복구를 시작하는 연속 근무 길이입니다.
employee_window_moves True 두 직원의 배정을 짧은 기간 단위로 맞바꿉니다. guided_moves가 켜져 있어야 합니다.
employee_window_max_days 3 그 이동의 최대 기간(일)입니다.
custom_move_probability 0.3 한 걸음에서 유스케이스 전용 이동을 고를 수 있는 확률의 상한입니다. 1이면 제한이 없습니다.
employee_ruin_moves True 직원을 고려한 파괴 집합을 씁니다. guided_moves와 ruin_recreate가 켜져 있어야 합니다.
employee_unassign_moves False 인원이 넘치는 자리를 비우는 이동을 허용합니다. 그래서 충원 자리가 선택 사항이 됩니다.
multi_start 1 병렬 프로세스에서 독립적으로 푸는 횟수입니다. 시드는 random_seed + i이고 가장 좋은 결과를 고릅니다. num_workers와 함께 쓰면 곱하지 않고 둘 중 큰 값을 씁니다.
portfolio [] 시작마다 덮어쓸 SolverConfig 값입니다. 시작들에 돌아가며 적용합니다. SolverConfig가 받지 않는 키나 값은 ValueError를 냅니다.
select_coverage_divisor 1.0 결과를 고를 때 근무 충원 비용을 이 값으로 나눕니다. 탐색을 위해 그 가중치를 키운 경우에 씁니다. 1이면 보정하지 않습니다.
relocate_move_weight 0.5 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다.
list_swap_move_weight 0.3 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다.
two_opt_move_weight 0.2 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다.
or_opt_move_weight 0.0 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다.
tail_swap_move_weight 0.0 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다.

돌아오는 결과

solve_request는 ScheduleSolution을 돌려줍니다. 결정 내용은 schedule과 unassigned에 있습니다.

ScheduleSolution

필드 의미
schedule 필수 입력 직원 id별로 배정된 근무입니다. 근무마다 shift_id, start, end, location, required_skill이 있습니다. unassigned 아무도 맡지 않은 근무 id입니다. score_hard 필수 입력 필수 점수입니다. 0이면 어긴 필수 규칙이 없습니다. score_soft 필수 입력 선호 점수입니다. 보상 때문에 양수가 될 수 있습니다. feasible 필수 입력 필수 규칙을 하나도 어기지 않으면 True입니다. rule_totals 규칙별 가중 비용입니다. name, cost, is_hard가 있고, 양수는 벌점, 음수는 보상입니다.

제약

optimaru.employee_scheduling.constraints에서 클래스를 가져와 RequiredSkillCost()처럼 호출해서 넘깁니다. 넘긴 제약만 점수에 들어가고, 아무것도 넘기지 않으면 기본 제약을 씁니다. 목록에 같은 제약을 여러 번 넣을 수 있으며, 넣은 것마다 따로 점수에 들어갑니다. 제약마다 다음 파라미터를 받습니다.

파라미터 기본값 의미
weight None 제약 비용에 곱하는 값이며, 적용되는 수준의 단위로 셉니다. 지정하지 않으면 아래 기본 가중치를 씁니다. 기본 가중치가 0인 제약은 가중치를 줘야 작동합니다.
level None "hard"면 필수, "soft"면 선호로 바꿉니다. Level.HARD, Level.SOFT도 됩니다. 지정하지 않으면 아래 수준을 그대로 씁니다. 이 유스케이스에는 중간 수준이 없어서 "medium"을 주면 ValueError가 납니다. 보상에 "hard"를 주거나 수준이 고정된 제약의 수준을 바꾸려 해도 ValueError가 납니다.
exponent 1.0 부호를 유지한 채 비용에 거듭제곱을 적용하며, 0보다 커야 합니다. offset을 반영한 뒤에 적용합니다. 2.0이면 제곱이라 한곳에 몰린 위반의 비용이 크게 늘어납니다. 차량이나 날짜 같은 단위마다 적용하고, 합계 하나로 계산하는 제약에는 그 합계에 적용합니다.
offset 0.0 거듭제곱 전에 비용 x에서 빼는 값입니다. exponent처럼 단위마다, 합계로 계산하는 제약은 그 합계에 적용하며, 비용과 같은 척도(초, 분, 개수 등)로 셉니다. 부호와 상관없이 유한한 수면 되고, 키워드로만 줄 수 있습니다. 이 값 아래에서 어떻게 계산할지는 clip이 정합니다.
clip True True면 offset을 문턱값으로 씁니다. 넘은 부분만 계산하므로(max(0, x - offset) ** exponent) 그 이하의 비용은 아무것도 더하지 않습니다. False면 부호를 유지한 채 (x - offset) ** exponent를 그대로 계산하므로, 오프셋보다 작은 단위는 비용이 0이어도 음수, 곧 보상으로 들어갑니다. 하드 점수에 들어가는 수준에서는 clip=False에 0이 아닌 오프셋을 주면 ValueError가 납니다. 하드 0이 위반 없음이라는 뜻을 지키기 위해서입니다. 키워드로만 줄 수 있습니다.
worker_indices None 규칙을 이 직원들에게만 적용합니다. workers 안의 위치로 적습니다. 직원 단위 규칙과 shift_coverage_cost에서만 쓸 수 있고, 다른 규칙은 받지 않습니다.
shift_indices None 규칙을 이 근무들에만 적용합니다. shifts 안의 위치로 적습니다. 직원 단위 규칙과 shift_coverage_cost에서만 쓸 수 있고, 다른 규칙은 받지 않습니다.

규칙은 model_input.rules에 넣습니다. optimaru.employee_scheduling.constraints의 클래스나 {"name": "max_consecutive_days_cost", "value": 5, "weight": 2} 같은 JSON 형식으로 줍니다. 규칙은 SchedulingInput을 만들 때, 그리고 아래처럼 rules에 대입할 때 검사를 거쳐 MaxConsecutiveDaysCostModel 같은 JSON 모델로 저장됩니다. 규칙의 파라미터는 키워드로 주세요. 직원 단위 규칙은 worker_indices와 shift_indices가 앞에 옵니다. 넣은 규칙만 점수에 들어갑니다. default_rules()에는 8개만 들어 있으므로, 나머지는 직접 넣어야 적용됩니다. 같은 규칙을 파라미터나 설정을 바꿔, 또는 똑같이 여러 번 넣을 수 있고 넣은 것마다 따로 점수에 들어갑니다. 그러니 기본 규칙을 바꾸려면 default_rules()에서 그 규칙을 빼고 새로 넣으세요. JSON 형식도 "level": "hard"처럼 같은 설정을 받습니다. 빈 목록이나 예전 "hard" 키가 있는 규칙은 ValidationError를 냅니다.

위의 최소 실행 예제에 이어서 실행합니다.

from optimaru.employee_scheduling.constraints import (
    MaxConsecutiveDaysCost, MinRestCost, UndesiredDayCost,
)
from optimaru.employee_scheduling.models import default_rules

changes = [
    MaxConsecutiveDaysCost(value=5, weight=2),  # 기본 규칙이 아니므로 추가합니다
    MinRestCost(min_gap_minutes=12 * 60),       # 기본 min_rest_cost를 대신합니다
    UndesiredDayCost(weight=2, level="hard"),   # 기본 undesired_day_cost를 대신합니다
]
replaced = {rule.name for rule in changes}
kept = [rule for rule in default_rules() if rule.name not in replaced]
custom = request.model_copy(deep=True)
custom.model_input.rules = kept + changes  # 대입할 때도 검사합니다
print(solve_request(custom).score_hard)
이름 수준 기본 가중치 하는 일
required_skill_cost 필수 1 자격이 없는 사람에게 근무를 주면 벌점을 줍니다.
employee_unavailability_cost 필수 1 근무 불가 날짜에 일한 시간(분)에 벌점을 줍니다.
shift_overlap_cost 필수 1 한 사람의 두 근무가 겹치는 시간(분)에 벌점을 줍니다.
max_one_shift_per_day_cost 필수 1 한 사람의 같은 날 근무 쌍마다 벌점을 줍니다.
min_rest_cost 필수 1 두 근무 사이에 모자란 휴식 시간(분)에 벌점을 줍니다. 파라미터: min_gap_minutes=600
undesired_day_cost 선호 1 쉬고 싶은 날 일한 시간(분)에 벌점을 줍니다. per_day를 켜면 일수로 셉니다. 파라미터: per_day=False
desired_day_cost 선호 보상 1 근무하고 싶은 날 일한 시간(분)에 보상을 줍니다. per_day를 켜면 일수로 셉니다. 파라미터: per_day=False
desired_shift_cost 선호 보상 1 desired_shifts 희망과 맞는 근무에 보상을 줍니다.
shift_count_imbalance_cost 선호 1 근무 횟수의 통계적 불공정도를 줄입니다.
max_consecutive_days_cost 필수 1 연속 근무일이 value를 넘은 만큼 벌점을 줍니다. 파라미터: value=6
min_consecutive_days_cost 선호 1 연속 근무가 value에 못 미친 만큼 벌점을 줍니다. 파라미터: value=2
max_consecutive_days_off_cost 선호 1 양쪽이 근무로 막힌 연속 휴무가 value를 넘은 만큼 벌점을 줍니다. 파라미터: value=3
rest_after_consecutive_work_cost 필수 1 work_days일 이상 연속 근무한 뒤 휴식이 모자란 만큼 벌점을 줍니다. 파라미터: work_days=6, rest_days=3
max_weekly_minutes_cost 필수 1 한 주에 minutes를 넘긴 시간(분)에 벌점을 줍니다. week_anchor로 주의 시작을 맞춥니다. 파라미터: minutes=2400, week_anchor=0
min_weekly_minutes_cost 선호 1 주마다 minutes에 모자란 시간(분)에 벌점을 줍니다. 쉬는 주는 전체 값이 벌점입니다. 파라미터: minutes=480, week_anchor=0
min_weekly_rest_days_cost 필수 1 한 주에 7 - value일을 넘긴 근무일에 벌점을 줍니다. 파라미터: value=1
assignment_cost 선호 1 금액입니다. shift_costs 값이 우선이고, 없으면 시간당, 그다음 근무당 비용을 씁니다. 파라미터: cost_per_hour=0.0, cost_per_shift=0.0, shift_costs={}
unassigned_shifts_cost 필수 1 아무도 맡지 않은 근무에 벌점을 줍니다.
shift_count_gap_cost 선호 1 가장 바쁜 사람과 가장 한가한 사람의 근무 횟수 차이가 tolerance를 넘은 만큼 벌점을 줍니다. 파라미터: tolerance=0
weekly_minutes_gap_cost 선호 1 주마다 근무 시간 차이가 tolerance_minutes를 넘은 만큼 시간 단위로 벌점을 줍니다. 파라미터: tolerance_minutes=120
shift_coverage_cost 선호 1 날짜와 위치별로 worker_skill을 가진 배정 인원이 min_count~max_count를 벗어나면 벌점을 줍니다. max_count가 음수면 상한이 없습니다. 파라미터: worker_skill='', min_count=0, max_count=-1
location_run_length_cost 선호 1 location에서 연속 근무한 일수가 min_run~max_run을 벗어나면 벌점을 줍니다. 파라미터: location='', min_run=0, max_run=-1, waive_min_at_end=True
rest_after_location_run_cost 필수 1 location 연속 근무 뒤 rest_days 안에 일한 날에 벌점을 줍니다. 파라미터: location='', rest_days=1
location_run_gap_cost 선호 1 location 연속 근무 사이 간격이 min_gap일에 모자란 만큼 벌점을 줍니다. 파라미터: location='', min_gap=0
worker_location_count_cost 선호 1 worker_skill을 가진 직원별로 location 근무 수가 min_count~max_count를 벗어나면 벌점을 줍니다. 파라미터: location='', worker_skill='', min_count=0, max_count=-1
day_pattern_cost 선호 1 순서가 있는 날짜 pattern이 나타날 때마다 벌점을 줍니다. 쉼표로 나눈 토큰이며 위치, OFF, *, WORK, !x를 씁니다. 파라미터: pattern=''
target_days_off_cost 선호 1 target_days 중 쉬는 날이 min_count에 모자란 만큼 벌점을 줍니다. 파라미터: target_days=(), min_count=0
worker_day_count_in_range_cost 선호 1 lo~hi 사이에 일한 날짜 수가 min_count~max_count를 벗어나면 벌점을 줍니다. 파라미터: location='', lo=0, hi=-1, min_count=0, max_count=-1
location_imbalance_cost 선호 1 직원별 횟수의 공정성입니다. metric으로 ssd나 gap을 고릅니다. 파라미터: location='', worker_skill='', metric='ssd', tolerance=0, off_target_days=()
worker_pairing_cost 선호 1 두 직원(anchor, paired)이 함께하거나, penalty를 켜면 함께하지 말아야 할 (날짜, 위치) 묶음을 다룹니다. 파라미터: anchor=-1, paired=-1, bucket_days=(), bucket_locations=(), penalty=False

제약 사용법 →

기존 해에서 시작하기

최신 버전이 필요합니다. pip install -U optimaru로 올려 주세요.

with_initial_solution(request, solution)은 ShiftModel.worker에 해를 채운 새 요청을 돌려줍니다. solution은 solve_request가 돌려준 값이나 model_dump(mode="json")의 딕셔너리입니다. 넘긴 요청은 바뀌지 않습니다.

위의 최소 실행 예제에 이어서 실행합니다.

from optimaru.employee_scheduling.models import with_initial_solution

seeded = with_initial_solution(request, solution)  # solution.model_dump(mode="json")도 됩니다
better = solve_request(seeded)
print(better.score_hard, better.score_soft)
  • 직원과 근무는 id로 맞춥니다. 배정에서는 shift_id만 읽습니다.
  • 완전한 해를 넣으면 구성 단계를 건너뛰고 그 해에서 바로 탐색을 시작합니다. 일부만 넣으면 넣은 부분은 그대로 두고, 빠진 근무만 새로 구성합니다.
  • 해의 unassigned에 있는 근무도 새로 구성합니다.
  • timelimit=0을 주면 바로 멈추므로 완전한 해는 넣은 그대로 돌아옵니다. step_count_limit = 0은 제한이 없다는 뜻이므로, 바로 멈추려면 시간 제한을 쓰세요.
  • 고정된 근무는 직원이 바뀌지 않으며, 해가 그 근무를 옮기거나 미배정으로 두면 ValueError가 납니다.
  • 모르는 id, 두 번 배정되었거나 배정과 미배정에 함께 있는 근무, 형식이 잘못된 배정도 ValueError를 냅니다.
  • 규칙을 어기는 해도 오류가 아닙니다. 시작점으로 받아들이고, 그 비용은 점수에 나타납니다.

예외와 제한

  • 시각은 0일 00:00부터 센 정수 분이고, 날짜는 일 번호입니다.
  • num_workers와 settings.multi_start는 곱해지지 않고 둘 중 큰 값을 씁니다. 시작마다 timelimit을 모두 쓰고 코어당 프로세스 하나만 동시에 돌기 때문에, 시작 수가 코어 수보다 많으면 timelimit보다 오래 걸립니다.
  • worker_location_count_cost와 worker_day_count_in_range_cost는 기본적으로 선호 수준으로 계산됩니다. 꼭 지켜야 한다면 level="hard"를 주세요.
  • unassigned_shifts_cost는 default_rules()에 없습니다. 빈 근무를 점수에 반영해야 할 때 넣어 주세요. employee_unassign_moves를 켤 때는 꼭 필요합니다.
  • 고정된 근무는 확정된 이력으로 봅니다. 근무 단위 규칙은 다시 판단하지 않고, 패턴 규칙은 고정된 날을 맥락으로만 씁니다.
  • 마지막 날까지 이어진 연속 근무는 그 뒤를 알 수 없으므로, 부족을 따지는 규칙은 넘어가고 초과를 따지는 규칙은 그대로 계산합니다.