Skip to content

●  사용방법 · 시간표

학교 시간표

모든 수업을 교시와 교실에 배정합니다. 교사 · 학급 · 교실이 겹치지 않게 한 뒤, 공강, 연속 수업, 교사 희망을 다듬습니다.

무엇을 정하나요

수업의 주간 시수마다 요일과 교시를 정합니다. 특별실은 그다음에 그리디 방식으로 배정합니다.

최소 실행 예제

from optimaru import school_timetabling as st

problem = {
    "calendar": {"periods_per_day": {"0": 4, "1": 4, "2": 4}},
    "subjects": [
        {"id": "MATH", "name": "Math", "required_room_type": None,
         "preferred_periods": [1, 2], "avoided_periods": []},
        {"id": "ART", "name": "Art", "required_room_type": None,
         "preferred_periods": [], "avoided_periods": []},
    ],
    "teachers": [
        {"id": "T1", "name": "Kim", "subject_ids": ["MATH"], "unavailable": [{"day": 0, "period": 1}],
         "max_periods_per_week": 20, "max_periods_per_day": 4, "max_consecutive_periods": 3,
         "preferred_free_days": [], "homeroom_class_id": None},
        {"id": "T2", "name": "Lee", "subject_ids": ["ART"], "unavailable": [],
         "max_periods_per_week": 20, "max_periods_per_day": 4, "max_consecutive_periods": 3,
         "preferred_free_days": [], "homeroom_class_id": None},
    ],
    "class_groups": [{"id": "C1", "name": "Class 1", "grade": 1, "unavailable": []}],
    "rooms": [],
    "courses": [
        {"id": "C1-MATH", "class_group_id": "C1", "subject_id": "MATH", "teacher_ids": ["T1"],
         "periods_per_week": 3, "block_periods": 0, "max_periods_per_day": 1,
         "fixed_slots": [], "forbidden_slots": [], "parallel_group": None},
        {"id": "C1-ART", "class_group_id": "C1", "subject_id": "ART", "teacher_ids": ["T2"],
         "periods_per_week": 2, "block_periods": 2, "max_periods_per_day": 2,
         "fixed_slots": [], "forbidden_slots": [], "parallel_group": None},
    ],
}

timetable = st.solve(problem, time_limit_seconds=2.0, random_seed=42)  # 딕셔너리를 돌려줍니다
print(timetable["status"], timetable["objective_value"], timetable["penalties"])
for lesson in timetable["lessons"]:
    print(lesson["course_id"], lesson["slot"], lesson["room_id"])

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

입력 파라미터

problem["calendar"]

필드 의미
periods_per_day 요일별 교시 수입니다. {"0": 7, "1": 7, ...} 형식이고, 0이 월요일입니다.

problem["subjects"][i]

필드 의미
id 과목 id입니다. required_room_type 필요한 특별실 유형입니다. 학급 교실에서 하면 None입니다. preferred_periods 되도록 배치하고 싶은 교시입니다. avoided_periods 되도록 피하고 싶은 교시입니다.

problem["teachers"][i]

필드 의미
id 교사 id입니다. unavailable 수업할 수 없는 시간입니다. {"day": 0, "period": 1} 형식입니다. max_periods_per_week 주간 최대 시수입니다. max_periods_per_day 하루 최대 시수입니다. 없으면 6입니다. max_consecutive_periods 연속 수업 한도입니다. 없으면 4입니다. preferred_free_days 쉬고 싶은 요일입니다.

problem["class_groups"][i]

필드 의미
id 학급 id입니다. unavailable 학급이 쓸 수 없는 시간입니다.

problem["rooms"][i]

필드 의미
id 교실 id입니다. room_type 교실 유형입니다. required_room_type과 맞춰 봅니다.

problem["courses"][i]

필드 의미
id 수업 id입니다. class_group_id 수업을 듣는 학급입니다. subject_id 과목입니다. teacher_ids 담당 교사입니다. 두 명 이상이면 공동 수업입니다. periods_per_week 주간 시수입니다. block_periods 2 이상이면 그만큼의 교시를 주 1회 연속 블록으로 묶습니다. max_periods_per_day 이 수업의 하루 최대 시수입니다. 없으면 1입니다. fixed_slots 고정된 시간입니다. 엔진이 옮기지 않습니다. forbidden_slots 이 수업이 쓸 수 없는 시간입니다. parallel_group 값이 같은 수업은 분반 수업으로, 항상 같은 시간에 열리고 학급 시간표의 한 칸을 차지합니다.

풀이 파라미터

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

파라미터 기본값 의미
mode 'canonical' canonical은 아래 제약을 모두 적용합니다. quickstart는 더 작은 모델로 옮긴 근사 변환이고, 그 한계는 notes에 적힙니다.
time_limit_seconds None 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다. 지정하지 않으면 설정의 spent_limit_seconds를 쓰며, 기본값은 30입니다.
random_seed None 탐색에 쓰는 난수 시드입니다. 지정하지 않으면 설정값을 쓰며, 기본값은 0입니다.
parallel_solver_count None 독립적으로 동시에 돌리는 탐색 수입니다. 탐색마다 시드가 다르고 시간은 똑같이 쓰며, 가장 좋은 결과를 고릅니다. 지정하지 않으면 설정값을 쓰며, 기본값은 1입니다.
stop_at_first_feasible False 필수 점수가 0이 되는 즉시 멈춥니다.
weights None 제약 이름별 선호 가중치입니다. {"teacher_gap_cost": 40}처럼 씁니다. 빠진 이름은 기본값을 씁니다. canonical 모드에서만 되며, constraints=와 함께 쓸 수 없습니다.
constraints None 점수에 넣을 제약과 그 설정입니다. 지정하지 않으면 기본 제약을 씁니다.
on_new_best None 새 최고 해를 찾을 때마다 부르는 콜백입니다. 콜백이 예외를 내면 풀이를 멈추고 그 예외를 그대로 냅니다.
config None 기본으로 쓸 SolverConfig입니다. 여기서 준 키워드 인자는 이번 풀이에만 이 값을 덮어쓰며, 넘긴 객체 자체는 바뀌지 않습니다.
initial_solution None 시작점으로 쓸 이전 Timetable 딕셔너리입니다.
탐색 옵션 전체 (31)

canon.SolverConfig에 값을 넣어 config=로 넘기세요. 기본 canonical 모드에서는 최상위 SolverConfig를 받지 않습니다.

파라미터 기본값 의미
best_score_limit — 최고 점수가 이 값에 닿으면 일찍 멈춥니다. 기본값으로는 작동하지 않습니다.
change_move_weight 0.75 변경 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
descent_max_sweeps 4 한 이웃을 포기하기 전까지 도는 개선 횟수입니다.
descent_weight 0.0 최선 개선 하강의 비중입니다. 0이면 끕니다.
full_assert False 증분 점수를 전체 재계산과 매번 비교합니다. 느리므로 디버깅할 때만 씁니다.
hot_lesson_probability 0.7 필수 위반이 있는 수업을 옮길 확률입니다. 0이면 쓰지 않습니다.
late_acceptance_size 0 늦은 수락(late acceptance) 기록의 길이입니다. 0이면 문제 크기에 맞춰 정합니다.
on_new_best None 새 최고 해를 찾을 때마다 부르는 콜백입니다. 콜백이 예외를 내면 풀이를 멈추고 그 예외를 그대로 냅니다.
parallel_solver_count 1 독립적으로 동시에 돌리는 탐색 수입니다. 탐색마다 시드가 다르고 시간은 똑같이 쓰며, 가장 좋은 결과를 고릅니다.
random_seed 0 탐색에 쓰는 난수 시드입니다.
reassign_candidates 32 재배정 이동에서 시험할 시간 수입니다. 미배정 수업을 허용할 때만 씁니다.
ruin_recreate True 탐색이 정체되면 적응형 파괴·재구성을 실행합니다.
ruin_recreate_as_move_max_ruin 12 그 작은 파괴로 제거할 최대 항목 수입니다.
ruin_recreate_as_move_weight 0.0 한 걸음을 작은 파괴·복구로 쓰고 일반 이동처럼 판단할 확률입니다. 음수는 지원하는 유스케이스에서 자동 선택이고, 0이면 끕니다.
ruin_recreate_max_ruin_count 60 한 번에 제거하는 항목 수의 절대 상한입니다.
ruin_recreate_max_ruin_fraction 0.3 한 번에 제거하는 해의 최대 비율입니다.
ruin_recreate_min_ruin_fraction 0.1 한 번에 제거하는 해의 최소 비율입니다.
ruin_recreate_operator_decay 0.8 연산자 가중치가 적응하는 속도입니다.
ruin_recreate_polish_steps 5000 실행 후 다듬는 언덕 오르기 이동 수입니다. step_count_limit에는 세지 않습니다.
ruin_recreate_rewards [33.0, 9.0, 3.0, 0.0] 새 최고, 개선, 수락, 거절 결과에 주는 연산자 보상입니다.
ruin_recreate_unimproved_step_trigger 5000 파괴·재구성을 실행하기까지 새 최고 해 없이 지나는 걸음 수입니다.
same_class_nearby True 같은 학급의 수업을 서로 이웃으로 봅니다.
same_class_nearby_probability 0.5 교환 상대를 그 이웃에서 고를 확률입니다.
smart_class_swap_candidates 8 그 이동에서 시험할 교환 수입니다.
smart_class_swap_moves True 같은 학급의 수업끼리 여러 교환을 시험해 가장 좋은 것을 고릅니다.
spent_limit_seconds 30.0 탐색 시간(초)이며, 0부터 1e9까지 줄 수 있습니다.
step_count_limit 0 이 걸음 수에 도달하면 멈춥니다. 0이면 제한이 없습니다.
swap_move_weight 0.25 교환 이동의 상대적 비중입니다. 0이면 쓰지 않습니다.
unimproved_spent_limit_seconds 0.0 새 최고 해 없이 이 시간(초)이 지나면 멈춥니다. 0이면 제한이 없습니다.
unimproved_step_count_limit 0 새 최고 해 없이 이 걸음 수가 지나면 멈춥니다. 0이면 제한이 없습니다.
weights ObjectiveWeights() 선호 가중치 6개입니다. canon.ObjectiveWeights로 주며, 기본값은 teacher_gap_cost 20, teacher_workday_cost 3, teacher_free_day_cost 30, class_gap_cost 10, avoided_period_cost 5, non_preferred_period_cost 1입니다.

돌아오는 결과

학교 시간표는 딕셔너리를 받아 딕셔너리를 돌려줍니다. 변환할 모델이 없습니다. 결정 내용은 lessons에 있습니다.

timetable

필드 의미
status 필수 위반이 없으면 FEASIBLE, 남아 있으면 INFEASIBLE입니다. lessons 배치된 교시마다 하나씩 있는 목록입니다. objective_value penalties에 가중치를 곱해 더한 값입니다. penalties 제약 이름별 선호 위반 횟수입니다. notes INFEASIBLE 결과에 남은 위반을 설명합니다. wall_time_seconds 풀이에 걸린 시간입니다. best_objective_bound 항상 None입니다. 로컬 서치는 하한을 증명하지 않습니다.

timetable["lessons"][i]

필드 의미
course_id 수업입니다. class_group_id 학급입니다. subject_id 과목입니다. teacher_ids 교사입니다. slot 정해진 시간입니다. {"day": 0, "period": 1} 형식입니다. room_id 특별실이 필요한 수업에만 값이 있습니다. 나머지는 None이며 학급 교실을 뜻합니다.

제약

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

파라미터 기본값 의미
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이 위반 없음이라는 뜻을 지키기 위해서입니다. 키워드로만 줄 수 있습니다.

선호 가중치만 바꾸려면 weights={"teacher_gap_cost": 40}을, 제약을 고르거나 빼거나 수준을 바꾸려면 constraints=Assembly()...를 넘기세요. Assembly는 이름마다 하나만 두므로, 이미 있는 이름을 add()하면 그 항목을 바꿉니다. 일반 constraints=[...] 목록에는 같은 이름을 여러 번 넣을 수 있고, 넣은 것마다 따로 점수에 들어갑니다. constraints= 없이 풀면 빠른 경로를 그대로 쓰고 병렬 탐색도 됩니다. 넘기면 탐색 하나로만 풉니다.

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

from optimaru.school_timetabling.constraints import Assembly

# 17개 전체에서 시작해 하나를 바꾸고 하나를 뺍니다
custom = st.solve(
    problem, time_limit_seconds=2.0, random_seed=42,
    constraints=Assembly().add("teacher_gap_cost", weight=40).drop("teacher_workday_cost"),
)
print(custom["status"], custom["penalties"])
이름 수준 기본 가중치 하는 일
unassigned_lessons_cost 필수 1 미배정 수업마다 1점입니다. level="soft"로 낮추면 채우지 못한 교시가 위반이 아니라 결과가 됩니다. 아래 내용을 참고하세요.
class_conflict_cost 필수 1 같은 학급의 수업이 같은 시간에 겹치는 쌍마다 1점입니다.
teacher_conflict_cost 필수 1 같은 시간의 두 수업이 교사를 공유하면 교사마다 1점입니다. 공동 수업도 포함합니다.
disallowed_slot_cost 필수 1 허용되지 않은 시간에 놓인 수업마다 1점입니다. 금지 시간과 교사·학급의 불가능 시간이 여기에 해당합니다.
room_capacity_cost 필수 1 특별실 유형과 시간별로 수요가 교실 수를 넘은 만큼입니다.
course_max_periods_per_day_cost 필수 1 수업과 요일별로 하루 한도를 넘은 시수입니다.
block_adjacency_cost 필수 1 블록 수업의 교시가 하루 안에 연속으로 놓이지 않은 정도입니다.
teacher_max_periods_cost 필수 1 교사별로 하루·주간 시수 한도를 넘은 만큼입니다.
teacher_max_consecutive_periods_cost 필수 1 교사별로 연속 수업 한도를 넘은 만큼입니다.
teacher_gap_cost 선호 20 교사의 하루 중간에 비는 교시입니다.
teacher_workday_cost 선호 3 교사가 수업이 있는 요일마다 1점입니다.
teacher_free_day_cost 선호 30 쉬고 싶다고 한 요일에 수업이 있으면 1점입니다.
class_gap_cost 선호 10 학급의 하루 중간에 비는 교시입니다.
avoided_period_cost 선호 5 피하고 싶은 교시에 놓인 수업마다 1점입니다.
non_preferred_period_cost 선호 1 선호 교시가 있는 수업이 그 밖에 놓이면 1점입니다.
fixed_slots_cost 필수 수준 고정 1 강좌의 fixed_slots에 놓이지 않은 수업입니다. 엔진이 고정 수업을 그 시간에 묶어 두므로 항상 0이며, level="soft"를 주면 ValueError가 납니다.
parallel_groups_cost 필수 수준 고정 1 같은 parallel_group에 속하면서 서로 다른 시간에 놓인 분반입니다. 엔진이 그룹마다 분반을 한 수업으로 합쳐 배치하므로 항상 0이며, level="soft"를 주면 ValueError가 납니다.

제약 사용법 →

기존 해에서 시작하기

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

이 유스케이스는 문제가 딕셔너리라서 with_initial_solution이 없습니다. 이전 시간표를 solve의 initial_solution=으로 넘기세요.

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

better = st.solve(problem, time_limit_seconds=2.0, random_seed=42, initial_solution=timetable)
print(better["status"], better["objective_value"])
  • 수업은 course_id로 맞춥니다.
  • 완전한 해를 넣으면 구성 단계를 건너뛰고 그 해에서 바로 탐색을 시작합니다. 일부만 넣으면 넣은 부분은 그대로 두고, 빠진 교시만 새로 구성합니다.
  • time_limit_seconds=0을 주면 바로 멈추므로 완전한 해는 넣은 그대로 돌아옵니다. step_count_limit = 0은 제한이 없다는 뜻이므로, 바로 멈추려면 시간 제한을 쓰세요.
  • room_id는 결과에서 다시 계산하므로 무시합니다.
  • fixed_slots는 그대로 유지하고, 그 밖의 시간은 수업의 남은 시수 안에서 맞아야 합니다. 그래서 고정 수업을 옮긴 완전한 해는 받지 않습니다.
  • 모르는 수업, 같은 시간에 두 번 나온 수업, 남은 시수보다 많은 시간, 서로 다른 시간에 놓인 분반은 ValueError를 냅니다.
  • 수업이 쓸 수 없는 시간을 넣어도 받아들이고 필수 위반으로 채점합니다. 그래서 INFEASIBLE 결과에서도 이어서 풀 수 있습니다.

예외와 제한

  • 로컬 서치는 최적이나 불가능을 증명하지 못합니다. INFEASIBLE은 시간 제한까지 필수 위반이 남았다는 뜻이지, 시간표가 존재하지 않는다는 뜻이 아닙니다.
  • 달력은 16교시까지, 요일 번호는 8 미만까지입니다. 더 크면 ValueError가 납니다.
  • INFEASIBLE 대신 채울 수 없는 교시를 비워 두려면 첫 번째 제약을 선호로 낮추세요. Assembly().add("unassigned_lessons_cost", level="soft", weight=10_000)처럼 씁니다. 빈 교시 하나의 비용이 그 가중치이므로, 다른 선호 제약을 모두 더한 값보다 크게 두세요.
  • 이 설정에는 stop_at_first_feasible을 함께 쓰지 마세요. 수업을 모두 뺀 시간표도 필수 0점이라 탐색이 거기서 멈춥니다.