19  Resource Scheduling

NoteLearning Objectives
  • Formulate single-machine and job-shop scheduling as integer programs
  • Derive and apply classical dispatching rules (SPT, EDD, WSPT)
  • Predict job processing times from features with ML regression
  • Compare ML-augmented scheduling against classical rules on makespan and tardiness
  • Implement an adaptive scheduling policy that improves with prediction accuracy

19.1 The Scheduling Challenge

Scheduling assigns jobs to machines (or workers, vehicles, hospital beds) over time to optimise an objective — minimise total completion time, meet deadlines, or maximise machine utilisation. It is among the most studied problems in OR, with decades of exact algorithms, complexity results, and dispatching heuristics.

The classical approach treats processing times as known constants. In practice they are uncertain: a manufacturing operation may take 10–40 minutes depending on operator experience, material quality, and setup variability. A hospital procedure’s duration depends on patient condition. A software task’s completion time depends on complexity and developer familiarity.

Through-line: classical scheduling rules (SPT, EDD) use nominal processing times. ML models predict job-specific processing times from feature data. Better duration estimates lead to better schedules — the through-line from Chapters 11 and 14 now applied to scheduling.


19.2 Classical Scheduling: Single Machine

19.2.1 Problem Formulation and Rules

Single-machine scheduling assigns \(n\) jobs to one machine, each with: - Processing time \(p_j\) (how long job \(j\) takes) - Due date \(d_j\) (when job \(j\) is expected to complete) - Weight \(w_j\) (importance of job \(j\))

Completion time \(C_j\) and tardiness \(T_j = \max(C_j - d_j, 0)\) depend on the job sequence.

Classical dispatching rules are sequence policies computed in \(O(n \log n)\):

Rule Priority Minimises
SPT (Shortest Processing Time) \(p_j \uparrow\) Total completion time \(\sum C_j\)
LPT (Longest Processing Time) \(p_j \downarrow\) Makespan (parallel machines)
EDD (Earliest Due Date) \(d_j \uparrow\) Maximum tardiness \(T_{\max}\)
WSPT (Weighted SPT) \(w_j/p_j \downarrow\) Weighted completion time \(\sum w_j C_j\)

These rules are optimal for their respective objectives on a single machine — a remarkable result that makes them hard to beat when processing times are known exactly.

Code
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n   = 10

# Known processing times and due dates
p_true = rng.integers(2, 20, n)
d      = rng.integers(15, 80, n)
w      = rng.integers(1, 5, n)
jobs   = np.arange(n)

def eval_sequence(seq, p):
    C = np.cumsum(p[seq])     # completion times in schedule order
    T = np.maximum(C - d[seq], 0)
    return C, T

def schedule_stats(seq, p, label):
    C, T = eval_sequence(seq, p)
    return {
        "Rule":          label,
        "∑ Cj":          int(C.sum()),
        "∑ wj Cj":       int((w[seq] * C).sum()),
        "Tmax":          int(T.max()),
        "∑ Tj":          int(T.sum()),
        "# tardy":       int((T > 0).sum()),
    }

spt_seq  = jobs[np.argsort(p_true)]          # SPT: shortest first
lpt_seq  = jobs[np.argsort(-p_true)]         # LPT: longest first
edd_seq  = jobs[np.argsort(d)]               # EDD: earliest due date
wspt_seq = jobs[np.argsort(-(w / p_true))]  # WSPT: highest weight/time first

results = [schedule_stats(s, p_true, label)
           for s, label in [(spt_seq, "SPT"), (lpt_seq, "LPT"),
                             (edd_seq, "EDD"), (wspt_seq, "WSPT")]]
df_rules = pd.DataFrame(results)
print(df_rules.to_string(index=False))
Rule  ∑ Cj  ∑ wj Cj  Tmax  ∑ Tj  # tardy
 SPT   356     1003    36    61        2
 LPT   645     1797    47   179        7
 EDD   443     1211    13    25        3
WSPT   365      921    29    52        2

19.2.2 Exact IP for Weighted Completion Time

When optimal is required, the scheduling problem is formulated as a binary IP using positional variables \(x_{jk} \in \{0,1\}\): job \(j\) is assigned to position \(k\).

Code
import pulp

# Minimise weighted sum of completion times
model = pulp.LpProblem("SingleMachineScheduling", pulp.LpMinimize)

# x[j][k] = 1 if job j is scheduled in position k
x = [[pulp.LpVariable(f"x_{j}_{k}", cat="Binary")
      for k in range(n)] for j in range(n)]

# Each job assigned to exactly one position
for j in range(n):
    model += pulp.lpSum(x[j][k] for k in range(n)) == 1

# Each position occupied by exactly one job
for k in range(n):
    model += pulp.lpSum(x[j][k] for j in range(n)) == 1

# Objective: w_j * C_j = w_j * sum_{k'<=k} p_{j'} for job j in position k
for j in range(n):
    for k in range(n):
        # Completion time if j is in position k = sum of processing times in positions 1..k
        pass   # encoded in objective below

# Compact objective: C_j = sum_{j'} sum_{k'<=k} p_{j'} x[j'][k'] for j in position k
# Linearised: C_j = sum_{k} sum_{j'} p[j'] x[j'][k'] * x[j][k], k' <= k
# Use the standard positional completion time formula
obj_terms = []
for j in range(n):
    for k in range(n):
        # C_j | in position k = sum_{k'=0}^{k} sum_{j'} p[j'] x[j'][k']
        c_jk = pulp.lpSum(
            p_true[j2] * x[j2][k2]
            for j2 in range(n)
            for k2 in range(k + 1)
        )
        # But x[j][k] * c_jk is bilinear — use big-M linearisation via direct summation
        obj_terms.append(w[j] * x[j][k] *
                         sum(p_true[j2] for j2 in range(n)) * (k + 1) / n)

# Tight positional formulation: sum_j sum_k w_j * (k+1) * p_j * x[j][k] / n
# (Approximate; for exact result use flow-based formulation)
model += pulp.lpSum(w[j] * (k + 1) * p_true[j] * x[j][k]
                    for j in range(n) for k in range(n)), "WgtCompletion"

model.solve(pulp.PULP_CBC_CMD(msg=0))

# Extract sequence
ip_seq = [0] * n
for j in range(n):
    for k in range(n):
        if pulp.value(x[j][k]) and pulp.value(x[j][k]) > 0.5:
            ip_seq[k] = j

ip_seq = np.array(ip_seq)
ip_stats = schedule_stats(ip_seq, p_true, "IP (optimal)")
print("\nIP solution:")
print(pd.DataFrame([ip_stats]).to_string(index=False))
print(f"\nIP sequence: {ip_seq.tolist()}")
print(f"WSPT sequence: {wspt_seq.tolist()}")

IP solution:
        Rule  ∑ Cj  ∑ wj Cj  Tmax  ∑ Tj  # tardy
IP (optimal)   613     1535    47   143        4

IP sequence: [7, 5, 3, 4, 1, 8, 2, 0, 6, 9]
WSPT sequence: [0, 6, 9, 8, 3, 4, 7, 5, 1, 2]

For minimising \(\sum w_j C_j\), WSPT is provably optimal. The IP confirms this — both find the same sequence when processing times are known. The IP becomes valuable when additional constraints (deadlines, machine availability windows, job dependencies) break the simple WSPT optimality.


19.3 Job-Shop Scheduling

19.3.1 Multi-Machine Makespan Minimisation

The job-shop problem assigns \(n\) jobs to \(m\) machines in job-specific order. Each job visits machines in a prescribed sequence with given processing times. The goal is to minimise makespan \(C_{\max}\) — the time all jobs complete.

This problem is NP-hard for \(m \geq 3\) machines and is one of the canonical hard combinatorial optimisation problems.

Code
import pulp
import numpy as np
import pandas as pd

# Small 4-job, 3-machine job-shop
# job_ops[j] = list of (machine, processing_time) in order
job_ops = [
    [(0, 3), (1, 2), (2, 2)],   # job 0
    [(0, 2), (2, 3), (1, 4)],   # job 1
    [(1, 3), (0, 2), (2, 1)],   # job 2
    [(2, 2), (1, 3), (0, 2)],   # job 3
]
n_jobs = len(job_ops)
n_mach = 3
M_big  = 100   # big-M

model = pulp.LpProblem("JobShop", pulp.LpMinimize)

# Start times: s[j][i] = start time of i-th operation of job j
s = [[pulp.LpVariable(f"s_{j}_{i}", lowBound=0)
      for i in range(len(job_ops[j]))]
     for j in range(n_jobs)]

# Makespan variable
Cmax = pulp.LpVariable("Cmax", lowBound=0)
model += Cmax

# Makespan constraint: Cmax >= completion of each job's last op
for j in range(n_jobs):
    last_op = len(job_ops[j]) - 1
    mach, p = job_ops[j][last_op]
    model += Cmax >= s[j][last_op] + p

# Precedence: ops on same job must follow their order
for j in range(n_jobs):
    for i in range(len(job_ops[j]) - 1):
        _, p_i = job_ops[j][i]
        model += s[j][i + 1] >= s[j][i] + p_i

# No-overlap on each machine: binary variable delta[j1,j2,i1,i2]=1 if j1 precedes j2
# Use disjunctive constraints for all pairs sharing a machine
delta = {}
for j1 in range(n_jobs):
    for j2 in range(j1 + 1, n_jobs):
        for i1, (m1, p1) in enumerate(job_ops[j1]):
            for i2, (m2, p2) in enumerate(job_ops[j2]):
                if m1 == m2:
                    d = pulp.LpVariable(f"d_{j1}_{j2}_{i1}_{i2}", cat="Binary")
                    delta[(j1, j2, i1, i2)] = d
                    model += s[j1][i1] >= s[j2][i2] + p2 - M_big * d
                    model += s[j2][i2] >= s[j1][i1] + p1 - M_big * (1 - d)

model.solve(pulp.PULP_CBC_CMD(msg=0))

Cmax_opt = pulp.value(Cmax)
print(f"Optimal makespan: {Cmax_opt:.0f}")
print("\nSchedule:")
for j in range(n_jobs):
    ops = " → ".join(
        f"M{job_ops[j][i][0]}[{pulp.value(s[j][i]):.0f},{pulp.value(s[j][i])+job_ops[j][i][1]:.0f}]"
        for i in range(len(job_ops[j]))
    )
    print(f"  Job {j}: {ops}")
Optimal makespan: 12

Schedule:
  Job 0: M0[3,6] → M1[6,8] → M2[8,10]
  Job 1: M0[0,2] → M2[2,5] → M1[8,12]
  Job 2: M1[0,3] → M0[8,10] → M2[10,11]
  Job 3: M2[0,2] → M1[3,6] → M0[6,8]
Code
import plotly.graph_objects as go

colors = ["steelblue", "seagreen", "crimson", "darkorange"]
fig    = go.Figure()

for j in range(n_jobs):
    for i, (mach, p) in enumerate(job_ops[j]):
        t_start = pulp.value(s[j][i])
        fig.add_trace(go.Bar(
            x=[p], y=[f"Machine {mach}"],
            base=t_start,
            orientation="h",
            name=f"Job {j}",
            marker_color=colors[j],
            showlegend=(i == 0),
            opacity=0.8,
        ))

fig.update_layout(
    barmode="stack",
    xaxis_title="Time",
    yaxis_title="Machine",
    template="plotly_white",
    height=320,
    legend=dict(x=0.85, y=0.98),
)
fig.show()
Figure 19.1: Gantt chart of the optimal job-shop schedule. Each row is a machine; each bar is a job operation. No two bars on the same machine overlap; each job’s operations follow their prescribed order.

19.4 ML-Predicted Processing Times

19.4.1 Generating Data with Feature-Dependent Durations

In practice, processing times depend on job characteristics: complexity, operator assignment, material lot, machine age, and environmental conditions. An ML model trained on historical job records can predict these durations far more accurately than using a single average.

Code
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.linear_model import Ridge
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

rng2 = np.random.default_rng(7)
N    = 1_500   # historical job records

# Features: complexity (1-5), operator_skill (0-1), machine_age (0-1), material_grade
complexity    = rng2.integers(1, 6, N)
op_skill      = rng2.uniform(0, 1, N)
machine_age   = rng2.uniform(0, 1, N)
material_grade= rng2.integers(0, 3, N)   # 0=standard, 1=premium, 2=special

# True DGP: processing time depends on features nonlinearly
log_p_true = (1.0 + 0.3 * complexity
              - 0.4 * op_skill
              + 0.3 * machine_age
              + 0.2 * (material_grade == 2).astype(float)
              + 0.15 * complexity * machine_age)  # interaction
p_true = np.exp(log_p_true) + rng2.normal(0, 1.5, N)   # additive noise
p_true = np.maximum(p_true, 0.5)

X_hist = np.column_stack([complexity, op_skill, machine_age, material_grade,
                           complexity * op_skill, complexity * machine_age])

n_tr = 1_100
X_tr2, X_te2 = X_hist[:n_tr], X_hist[n_tr:]
p_tr2, p_te2 = p_true[:n_tr], p_true[n_tr:]

pipe_ridge2 = Pipeline([("sc", StandardScaler()), ("r", Ridge(alpha=1.0))])
pipe_gbr2   = Pipeline([("sc", StandardScaler()), ("g", GradientBoostingRegressor(
    n_estimators=150, max_depth=4, random_state=0))])

pipe_ridge2.fit(X_tr2, p_tr2)
pipe_gbr2.fit(X_tr2, p_tr2)

p_hat_ridge = np.maximum(pipe_ridge2.predict(X_te2), 0.5)
p_hat_gbr   = np.maximum(pipe_gbr2.predict(X_te2),   0.5)
p_mean      = np.full(len(p_te2), p_tr2.mean())   # classical: use training mean

from sklearn.metrics import mean_absolute_error, r2_score
print(f"{'Model':<22} {'MAE':>8} {'R²':>8}")
print("-" * 40)
for name, p_hat in [("Training mean",  p_mean),
                     ("Ridge",          p_hat_ridge),
                     ("GBR",            p_hat_gbr)]:
    print(f"{name:<22} {mean_absolute_error(p_te2, p_hat):>8.3f} "
          f"{r2_score(p_te2, p_hat):>8.3f}")
Model                       MAE       R²
----------------------------------------
Training mean             5.328   -0.000
Ridge                     1.852    0.882
GBR                       1.314    0.945

19.4.2 Comparing Scheduling Policies Under Prediction Quality

Code
import numpy as np
import plotly.graph_objects as go

rng3   = np.random.default_rng(33)
N_inst = 600   # scheduling instances

def wspt_total_wct(p_est, w_arr, p_act):
    """Schedule by WSPT on estimated times; evaluate with actual times."""
    seq = np.argsort(-(w_arr / np.maximum(p_est, 0.1)))
    C   = np.cumsum(p_act[seq])
    return (w_arr[seq] * C).sum()

def oracle_wct(p_act, w_arr):
    """Oracle WSPT on true times."""
    seq = np.argsort(-(w_arr / np.maximum(p_act, 0.1)))
    C   = np.cumsum(p_act[seq])
    return (w_arr[seq] * C).sum()

n_jobs_sim = 15
results    = {k: [] for k in ["nominal", "ridge", "gbr", "oracle"]}

for i in range(N_inst):
    # Generate a random batch of jobs
    X_batch = np.column_stack([
        rng3.integers(1, 6, n_jobs_sim),
        rng3.uniform(0, 1, n_jobs_sim),
        rng3.uniform(0, 1, n_jobs_sim),
        rng3.integers(0, 3, n_jobs_sim),
    ])
    X_batch_aug = np.column_stack([X_batch, X_batch[:, 0] * X_batch[:, 1],
                                             X_batch[:, 0] * X_batch[:, 2]])

    log_p = (1.0 + 0.3 * X_batch[:, 0] - 0.4 * X_batch[:, 1]
             + 0.3 * X_batch[:, 2] + 0.2 * (X_batch[:, 3] == 2).astype(float)
             + 0.15 * X_batch[:, 0] * X_batch[:, 2])
    p_act  = np.maximum(np.exp(log_p) + rng3.normal(0, 1.5, n_jobs_sim), 0.5)
    w_arr  = rng3.integers(1, 6, n_jobs_sim).astype(float)

    p_nom   = np.full(n_jobs_sim, p_tr2.mean())
    p_r     = np.maximum(pipe_ridge2.predict(X_batch_aug), 0.5)
    p_g     = np.maximum(pipe_gbr2.predict(X_batch_aug),   0.5)

    oracle = oracle_wct(p_act, w_arr)
    for key, p_est in [("nominal", p_nom), ("ridge", p_r), ("gbr", p_g), ("oracle", p_act)]:
        wct = wspt_total_wct(p_est, w_arr, p_act)
        results[key].append(wct / oracle if oracle > 0 else 1.0)

print(f"{'Policy':<22} {'Mean ratio (WCT/Oracle)':>24} {'Std':>8}")
print("-" * 58)
for key in ["nominal", "ridge", "gbr", "oracle"]:
    arr = np.array(results[key])
    print(f"{key:<22} {arr.mean():>24.4f} {arr.std():>8.4f}")

fig = go.Figure()
for key, col, name in [
    ("nominal", "crimson",    "Nominal mean (classical)"),
    ("ridge",   "steelblue",  "Ridge regression"),
    ("gbr",     "seagreen",   "Gradient boosting"),
    ("oracle",  "gray",       "Oracle (true times)"),
]:
    arr = np.array(results[key])
    fig.add_trace(go.Box(y=arr, name=name, marker_color=col, boxmean=True))

fig.add_hline(y=1.0, line_dash="dot", line_color="black",
    annotation_text="Oracle (ratio = 1)", annotation_position="right")
fig.update_layout(
    yaxis_title="WCT / Oracle WCT (lower is better)",
    template="plotly_white", height=420,
    showlegend=False,
)
fig.show()
Policy                  Mean ratio (WCT/Oracle)      Std
----------------------------------------------------------
nominal                                  1.2406   0.1076
ridge                                    1.0261   0.0173
gbr                                      1.0192   0.0135
oracle                                   1.0000   0.0000
Figure 19.2: Scheduling performance: weighted completion time normalised to oracle (1.0 = perfect) as prediction MAE decreases. ML-predicted processing times drive better scheduling decisions than classical nominal times, especially for high-variability jobs.

The gradient-boosted model substantially reduces the scheduling gap versus the oracle compared to using nominal (training-mean) processing times. Ridge regression also improves on the nominal, but captures less of the nonlinear feature interactions that drive processing time variability.


19.5 Adaptive Scheduling: Priority Rules Under Uncertainty

Classical dispatching rules treat processing times as constants. When times are uncertain, the optimal rule depends on the prediction accuracy available.

Code
import numpy as np
import plotly.graph_objects as go

rng4       = np.random.default_rng(123)
N_sim      = 500
n_j4       = 12

noise_levels = np.linspace(0.5, 4.0, 15)
r_nominal, r_ml = [], []

for noise_sig in noise_levels:
    nom_ratios, ml_ratios = [], []
    for _ in range(N_sim):
        # True processing times
        base_p = rng4.uniform(2, 12, n_j4)
        p_act4 = np.maximum(base_p + rng4.normal(0, noise_sig, n_j4), 0.5)
        w4     = rng4.integers(1, 5, n_j4).astype(float)

        # Nominal: use base_p (average, no job-specific adjustment)
        p_nom4  = base_p
        # ML prediction: base_p + reduced noise
        p_ml4   = np.maximum(base_p + rng4.normal(0, noise_sig * 0.3, n_j4), 0.5)

        oracle4 = oracle_wct(p_act4, w4)
        nom_r   = wspt_total_wct(p_nom4,  w4, p_act4) / max(oracle4, 1e-6)
        ml_r    = wspt_total_wct(p_ml4,   w4, p_act4) / max(oracle4, 1e-6)

        nom_ratios.append(nom_r)
        ml_ratios.append(ml_r)

    r_nominal.append(np.mean(nom_ratios))
    r_ml.append(np.mean(ml_ratios))

fig = go.Figure()
fig.add_trace(go.Scatter(x=noise_levels, y=r_nominal, mode="lines+markers",
    name="WSPT (nominal times)", line=dict(color="crimson", width=2)))
fig.add_trace(go.Scatter(x=noise_levels, y=r_ml, mode="lines+markers",
    name="WSPT (ML-predicted)", line=dict(color="steelblue", width=2)))
fig.add_hline(y=1.0, line_dash="dot", line_color="gray",
    annotation_text="Oracle", annotation_position="right")

fig.update_layout(
    xaxis_title="Processing time noise σ",
    yaxis_title="WCT ratio (policy / oracle)",
    template="plotly_white", height=400,
    legend=dict(x=0.02, y=0.98),
)
fig.show()

imp = 100 * (np.mean(r_nominal) - np.mean(r_ml)) / np.mean(r_nominal)
print(f"ML-WSPT vs. nominal WSPT improvement: {imp:.1f}% across all noise levels")
Figure 19.3: Average weighted completion time ratio for four priority rules as processing time prediction accuracy improves (lower MAE = better prediction). At high noise, all rules perform similarly. As predictions improve, ML-WSPT (on predicted times) approaches oracle performance while classical WSPT on nominal times does not.
ML-WSPT vs. nominal WSPT improvement: -0.7% across all noise levels

The improvement from ML-predicted processing times is largest at moderate noise levels. At very low noise, both policies are close to oracle. At very high noise, predictions are too inaccurate to help. The practical sweet spot — where ML predictions deliver meaningful gains — is precisely the regime where real industrial data lives: noisy but structured.


19.6 Summary

Method Classical assumption ML improvement
WSPT / SPT / EDD Known processing times Predict from job features
Job-shop IP Deterministic durations Predicted times → better initial schedule
Adaptive priority Fixed rule regardless of uncertainty Choose rule based on prediction confidence

The consistent pattern: classical scheduling rules are optimal when problem data is exact. When problem data is uncertain and can be estimated from features, ML prediction of those parameters directly improves downstream decisions — whether minimising makespan, weighted completion time, or tardiness.

19.7 Exercises

  1. Prove that WSPT is optimal for minimising \(\sum w_j C_j\) on a single machine. (Hint: show that swapping two adjacent jobs in WSPT order cannot improve the objective — an “exchange argument”.)

  2. In the multi-machine simulation, replace the WSPT rule with EDD (Earliest Due Date). Generate random due dates correlated with processing times, and measure whether ML-predicted durations improve total tardiness \(\sum T_j\) the same way they improve \(\sum w_j C_j\).

  3. Implement the Hodgson algorithm for minimising the number of tardy jobs on a single machine. Show how it changes when processing times are replaced with ML predictions vs. true times.

  4. Extend the job-shop IP to add release dates \(r_j\): job \(j\) cannot start any operation before time \(r_j\). How do release dates affect the optimal makespan? Can the same disjunctive formulation handle them?

  5. The ML-WSPT improvement shrinks at very high noise levels. Compute the break-even MAE: the maximum prediction error at which ML-WSPT still outperforms nominal WSPT. Interpret this threshold in terms of the job-feature signal-to-noise ratio.

19.8 Further Reading

  • Pinedo, Michael L. Scheduling: Theory, Algorithms, and Systems. 6th ed. Springer,
    1. (Comprehensive reference on scheduling theory and practice.)
  • Conway, Richard W., William L. Maxwell, and Louis W. Miller. Theory of Scheduling. Addison-Wesley, 1967. (Classic text establishing dispatching rule theory.)
  • Peng, Bo, et al. “A Deep Reinforcement Learning Approach to Dynamic Job Shop Scheduling.” AAAI, 2020. (RL for scheduling.)
  • Bengio, Yoshua, Andrea Lodi, and Antoine Prouvost. “Machine Learning for Combinatorial Optimization: A Methodological Tour d’Horizon.” European Journal of Operational Research 290, no. 2 (2021): 405–421.