무엇을 정하나요
근무마다 맡을 직원을 정합니다. 어떤 규칙을 적용할지는 직접 고릅니다.
최소 실행 예제
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 필수 입력 skills unavailable_ dates undesired_ dates desired_ dates desired_ shifts (날짜, 위치) 형식의 근무 희망입니다. desired_shift_cost 규칙이 읽습니다.ShiftModel
| 필드 | 의미 |
|---|
id 필수 입력 start 필수 입력 es.minutes(day, hour)로 만들 수 있습니다.end 필수 입력 location required_ skill worker pinned worker 배정을 고정합니다. 점수에는 들어가지만 바뀌지 않습니다. worker가 있어야 합니다.SchedulingInput
| 필드 | 의미 |
|---|
workers 필수 입력 shifts 필수 입력 rules default_rules()의 규칙 8개를 씁니다.ScheduleRequest
| 필드 | 의미 |
|---|
model_ input 필수 입력 SchedulingInput입니다.풀이 파라미터
풀이를 호출할 때 넘기는 값입니다. 시간 제한, 시드, 병렬 수가 여기에 있습니다.
| 파라미터 | 기본값 | 의미 |
|---|---|---|
name | None | 요청에 붙이는 이름입니다. 선택 사항입니다. |
timelimit | 30.0 | 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다. |
num_ | 1 | 병렬 프로세스에서 독립적으로 푸는 횟수입니다. 시작마다 시드가 다르고 timelimit을 모두 쓰며, 가장 좋은 결과를 돌려줍니다. settings.multi_start도 정했다면 둘 중 큰 값을 씁니다. |
settings | SolverSettings() | 시드와 탐색 옵션을 담은 SolverSettings입니다. |
select | None | 시작이 둘 이상일 때 해 목록에서 결과를 고르는 함수입니다. 한 번만 풀 때는 무시합니다. |
탐색 옵션 전체 (46)
SolverSettings에 값을 넣어 settings=로 넘기세요.
| 파라미터 | 기본값 | 의미 |
|---|---|---|
random_ | 0 | 탐색에 쓰는 난수 시드입니다. |
guided_ | True | 위반과 자격을 보고 이동을 고릅니다. False면 일반 변경·교환 이동만 씁니다. |
employee_ | True | 로컬 서치 전에 날짜를 고려한 그리디 구성으로 미배정 근무를 채웁니다. |
greedy_ | 0.0 | 로컬 서치 뒤의 그리디 필수 복구에 떼어 두는 시간 비율입니다. 0이면 끕니다. |
late_ | 400 | 늦은 수락(late acceptance) 기록의 길이입니다. |
stop_ | False | 필수 점수가 0인 첫 일정에서 멈춥니다. |
best_ | None | 최고 점수가 이 값에 닿으면 일찍 멈춥니다. 기본값으로는 작동하지 않습니다. |
no_ | None | unimproved_spent_limit_seconds의 별칭입니다. |
unimproved_ | 0.0 | 새 최고 해 없이 이 시간(초)이 지나면 멈춥니다. 0이면 제한이 없습니다. |
step_ | 0 | 이 걸음 수에 도달하면 멈춥니다. 0이면 제한이 없습니다. |
no_ | None | unimproved_step_count_limit의 별칭입니다. |
unimproved_ | 0 | 새 최고 해 없이 이 걸음 수가 지나면 멈춥니다. 0이면 제한이 없습니다. |
ruin_ | True | 탐색이 정체되면 적응형 파괴·재구성을 실행합니다. |
ruin_ | 5000 | 파괴·재구성을 실행하기까지 새 최고 해 없이 지나는 걸음 수입니다. |
ruin_ | 0.1 | 한 번에 제거하는 해의 최소 비율입니다. |
ruin_ | 0.3 | 한 번에 제거하는 해의 최대 비율입니다. |
ruin_ | 60 | 한 번에 제거하는 항목 수의 절대 상한입니다. |
ruin_ | 0.8 | 연산자 가중치가 적응하는 속도입니다. |
ruin_ | [33.0, 9.0, 3.0, 0.0] | 새 최고, 개선, 수락, 거절 결과에 주는 연산자 보상입니다. |
ruin_ | 5000 | 실행 후 다듬는 언덕 오르기 이동 수입니다. step_count_limit에는 세지 않습니다. |
ruin_ | 0.0 | 한 걸음을 작은 파괴·복구로 쓰고 일반 이동처럼 판단할 확률입니다. 음수는 지원하는 유스케이스에서 자동 선택이고, 0이면 끕니다. |
ruin_ | 12 | 그 작은 파괴로 제거할 최대 항목 수입니다. |
descent_ | 0.02 | 최선 개선 하강의 비중입니다. 0이면 끕니다. |
descent_ | 4 | 한 이웃을 포기하기 전까지 도는 개선 횟수입니다. |
change_ | 0.75 | 변경 이동의 상대적 비중입니다. 0이면 쓰지 않습니다. |
swap_ | 0.25 | 교환 이동의 상대적 비중입니다. 0이면 쓰지 않습니다. |
nearby_ | True | 교환 상대, 하강 쌍, 파괴 묶음을 시작 시각이 가까운 근무 쪽으로 치우치게 합니다. |
nearby_ | 0.65 | 교환 상대를 그 이웃 목록에서 고를 확률입니다. |
smart_ | False | 근무 하나에 여러 직원을 시험해 가장 좋은 배정을 고릅니다. guided_moves가 켜져 있어야 합니다. |
smart_ | 6 | 그 이동에서 시험할 직원 수입니다. |
employee_ | True | 하루 한 근무 위반을 고치는 이동을 씁니다. guided_moves가 켜져 있어야 합니다. |
employee_ | True | 지나치게 긴 연속 근무를 고치는 이동을 씁니다. guided_moves가 켜져 있어야 합니다. |
employee_ | 3 | 그 복구를 시작하는 연속 근무 길이입니다. |
employee_ | True | 두 직원의 배정을 짧은 기간 단위로 맞바꿉니다. guided_moves가 켜져 있어야 합니다. |
employee_ | 3 | 그 이동의 최대 기간(일)입니다. |
custom_ | 0.3 | 한 걸음에서 유스케이스 전용 이동을 고를 수 있는 확률의 상한입니다. 1이면 제한이 없습니다. |
employee_ | True | 직원을 고려한 파괴 집합을 씁니다. guided_moves와 ruin_recreate가 켜져 있어야 합니다. |
employee_ | False | 인원이 넘치는 자리를 비우는 이동을 허용합니다. 그래서 충원 자리가 선택 사항이 됩니다. |
multi_ | 1 | 병렬 프로세스에서 독립적으로 푸는 횟수입니다. 시드는 random_seed + i이고 가장 좋은 결과를 고릅니다. num_workers와 함께 쓰면 곱하지 않고 둘 중 큰 값을 씁니다. |
portfolio | [] | 시작마다 덮어쓸 SolverConfig 값입니다. 시작들에 돌아가며 적용합니다. SolverConfig가 받지 않는 키나 값은 ValueError를 냅니다. |
select_ | 1.0 | 결과를 고를 때 근무 충원 비용을 이 값으로 나눕니다. 탐색을 위해 그 가중치를 키운 경우에 씁니다. 1이면 보정하지 않습니다. |
relocate_ | 0.5 | 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다. |
list_ | 0.3 | 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다. |
two_ | 0.2 | 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다. |
or_ | 0.0 | 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다. |
tail_ | 0.0 | 목록 변수용 일반 옵션입니다. 이 유스케이스에서는 쓰지 않습니다. |
돌아오는 결과
solve_request는 ScheduleSolution을 돌려줍니다. 결정 내용은 schedule과 unassigned에 있습니다.
ScheduleSolution
| 필드 | 의미 |
|---|
schedule 필수 입력 shift_id, start, end, location, required_skill이 있습니다.unassigned score_ hard 필수 입력 score_ soft 필수 입력 feasible 필수 입력 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_ | None | 규칙을 이 직원들에게만 적용합니다. workers 안의 위치로 적습니다. 직원 단위 규칙과 shift_coverage_cost에서만 쓸 수 있고, 다른 규칙은 받지 않습니다. |
shift_ | 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_ | 필수 | 1 | 자격이 없는 사람에게 근무를 주면 벌점을 줍니다. |
employee_ | 필수 | 1 | 근무 불가 날짜에 일한 시간(분)에 벌점을 줍니다. |
shift_ | 필수 | 1 | 한 사람의 두 근무가 겹치는 시간(분)에 벌점을 줍니다. |
max_ | 필수 | 1 | 한 사람의 같은 날 근무 쌍마다 벌점을 줍니다. |
min_ | 필수 | 1 | 두 근무 사이에 모자란 휴식 시간(분)에 벌점을 줍니다. 파라미터: min_gap_minutes=600 |
undesired_ | 선호 | 1 | 쉬고 싶은 날 일한 시간(분)에 벌점을 줍니다. per_day를 켜면 일수로 셉니다. 파라미터: per_day=False |
desired_ | 선호 보상 | 1 | 근무하고 싶은 날 일한 시간(분)에 보상을 줍니다. per_day를 켜면 일수로 셉니다. 파라미터: per_day=False |
desired_ | 선호 보상 | 1 | desired_shifts 희망과 맞는 근무에 보상을 줍니다. |
shift_ | 선호 | 1 | 근무 횟수의 통계적 불공정도를 줄입니다. |
max_ | 필수 | 1 | 연속 근무일이 value를 넘은 만큼 벌점을 줍니다. 파라미터: value=6 |
min_ | 선호 | 1 | 연속 근무가 value에 못 미친 만큼 벌점을 줍니다. 파라미터: value=2 |
max_ | 선호 | 1 | 양쪽이 근무로 막힌 연속 휴무가 value를 넘은 만큼 벌점을 줍니다. 파라미터: value=3 |
rest_ | 필수 | 1 | work_days일 이상 연속 근무한 뒤 휴식이 모자란 만큼 벌점을 줍니다. 파라미터: work_days=6, rest_days=3 |
max_ | 필수 | 1 | 한 주에 minutes를 넘긴 시간(분)에 벌점을 줍니다. week_anchor로 주의 시작을 맞춥니다. 파라미터: minutes=2400, week_anchor=0 |
min_ | 선호 | 1 | 주마다 minutes에 모자란 시간(분)에 벌점을 줍니다. 쉬는 주는 전체 값이 벌점입니다. 파라미터: minutes=480, week_anchor=0 |
min_ | 필수 | 1 | 한 주에 7 - value일을 넘긴 근무일에 벌점을 줍니다. 파라미터: value=1 |
assignment_ | 선호 | 1 | 금액입니다. shift_costs 값이 우선이고, 없으면 시간당, 그다음 근무당 비용을 씁니다. 파라미터: cost_per_hour=0.0, cost_per_shift=0.0, shift_costs={} |
unassigned_ | 필수 | 1 | 아무도 맡지 않은 근무에 벌점을 줍니다. |
shift_ | 선호 | 1 | 가장 바쁜 사람과 가장 한가한 사람의 근무 횟수 차이가 tolerance를 넘은 만큼 벌점을 줍니다. 파라미터: tolerance=0 |
weekly_ | 선호 | 1 | 주마다 근무 시간 차이가 tolerance_minutes를 넘은 만큼 시간 단위로 벌점을 줍니다. 파라미터: tolerance_minutes=120 |
shift_ | 선호 | 1 | 날짜와 위치별로 worker_skill을 가진 배정 인원이 min_count~max_count를 벗어나면 벌점을 줍니다. max_count가 음수면 상한이 없습니다. 파라미터: worker_skill='', min_count=0, max_count=-1 |
location_ | 선호 | 1 | location에서 연속 근무한 일수가 min_run~max_run을 벗어나면 벌점을 줍니다. 파라미터: location='', min_run=0, max_run=-1, waive_min_at_end=True |
rest_ | 필수 | 1 | location 연속 근무 뒤 rest_days 안에 일한 날에 벌점을 줍니다. 파라미터: location='', rest_days=1 |
location_ | 선호 | 1 | location 연속 근무 사이 간격이 min_gap일에 모자란 만큼 벌점을 줍니다. 파라미터: location='', min_gap=0 |
worker_ | 선호 | 1 | worker_skill을 가진 직원별로 location 근무 수가 min_count~max_count를 벗어나면 벌점을 줍니다. 파라미터: location='', worker_skill='', min_count=0, max_count=-1 |
day_ | 선호 | 1 | 순서가 있는 날짜 pattern이 나타날 때마다 벌점을 줍니다. 쉼표로 나눈 토큰이며 위치, OFF, *, WORK, !x를 씁니다. 파라미터: pattern='' |
target_ | 선호 | 1 | target_days 중 쉬는 날이 min_count에 모자란 만큼 벌점을 줍니다. 파라미터: target_days=(), min_count=0 |
worker_ | 선호 | 1 | lo~hi 사이에 일한 날짜 수가 min_count~max_count를 벗어나면 벌점을 줍니다. 파라미터: location='', lo=0, hi=-1, min_count=0, max_count=-1 |
location_ | 선호 | 1 | 직원별 횟수의 공정성입니다. metric으로 ssd나 gap을 고릅니다. 파라미터: location='', worker_skill='', metric='ssd', tolerance=0, off_target_days=() |
worker_ | 선호 | 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를 켤 때는 꼭 필요합니다.- 고정된 근무는 확정된 이력으로 봅니다. 근무 단위 규칙은 다시 판단하지 않고, 패턴 규칙은 고정된 날을 맥락으로만 씁니다.
- 마지막 날까지 이어진 연속 근무는 그 뒤를 알 수 없으므로, 부족을 따지는 규칙은 넘어가고 초과를 따지는 규칙은 그대로 계산합니다.